mirror of
https://github.com/yusufipk/dikte.git
synced 2026-09-11 10:56:10 +00:00
Merge pull request #48 from yusufipk/windows-setup
Publish a Windows setup beside the AppImage and the disk image
This commit is contained in:
@@ -0,0 +1,114 @@
|
||||
name: build
|
||||
|
||||
# The three downloads, in one place. release.yml calls this one rather than
|
||||
# holding a copy of it, and a pull request that touches the packaging runs it
|
||||
# on its own, because the alternative is finding out that a build is broken
|
||||
# from the release that was supposed to publish it.
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
ref:
|
||||
description: what to check out; the caller's own ref when empty
|
||||
type: string
|
||||
required: false
|
||||
default: ""
|
||||
version:
|
||||
description: write this version into the tree before building
|
||||
type: string
|
||||
required: false
|
||||
default: ""
|
||||
pull_request:
|
||||
paths:
|
||||
- packaging/**
|
||||
- .github/workflows/build.yml
|
||||
- dikte/integrate.py
|
||||
- dikte/trayicon.py
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
# The oldest Ubuntu still offered, because the glibc a build links
|
||||
# against is the oldest one it will run on, and 22.04's covers every
|
||||
# distribution released since. Move it up only when it goes away.
|
||||
- os: ubuntu-22.04
|
||||
kind: appimage
|
||||
- os: macos-latest
|
||||
kind: dmg
|
||||
# Intel Macs. This runner is the last x86_64 image Actions will
|
||||
# offer, and it goes away in August 2027.
|
||||
- os: macos-15-intel
|
||||
kind: dmg
|
||||
# x64 only, which is what the PyQt6 wheel and whisper.cpp both
|
||||
# publish for Windows; a machine on ARM runs the result emulated,
|
||||
# the same way it runs everything else that was never built for it.
|
||||
- os: windows-latest
|
||||
kind: windows
|
||||
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ inputs.ref }}
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
# PyQt6 ships Qt itself, but Qt still loads these from the system, and
|
||||
# the build draws its own icon before it packages anything.
|
||||
- name: Install the Qt runtime libraries
|
||||
if: runner.os == 'Linux'
|
||||
timeout-minutes: 5
|
||||
run: |
|
||||
apt="-o Acquire::Retries=3 -o Acquire::http::Timeout=20"
|
||||
sudo apt-get $apt update
|
||||
sudo apt-get $apt install --no-install-recommends -y \
|
||||
libegl1 libgl1 libxkbcommon0 libdbus-1-3 libglib2.0-0 \
|
||||
libfontconfig1 libfreetype6 libgssapi-krb5-2
|
||||
|
||||
- name: Install PyQt6 and PyInstaller
|
||||
run: python -m pip install --quiet PyQt6 pyinstaller
|
||||
|
||||
# Only for the builds off master: a tagged build already says the number
|
||||
# it was tagged with, and rewriting it would be rewriting the tag.
|
||||
- name: Write the version being built
|
||||
if: inputs.version
|
||||
# Named, because the Windows runner's own shell is PowerShell and this
|
||||
# is a here-document.
|
||||
shell: bash
|
||||
env:
|
||||
VERSION: ${{ inputs.version }}
|
||||
run: |
|
||||
python - <<'PY'
|
||||
import os, pathlib, re
|
||||
path = pathlib.Path("dikte/__init__.py")
|
||||
path.write_text(re.sub(r'^__version__ = ".*"$',
|
||||
f'__version__ = "{os.environ["VERSION"]}"',
|
||||
path.read_text(), flags=re.M))
|
||||
PY
|
||||
|
||||
- name: Build
|
||||
if: runner.os != 'Windows'
|
||||
run: ./packaging/build-${{ matrix.kind }}.sh
|
||||
|
||||
# The same steps as the other two, in the language the platform already
|
||||
# has: drawing the icon, PyInstaller, and wrapping the result in what
|
||||
# people download.
|
||||
- name: Build
|
||||
if: runner.os == 'Windows'
|
||||
shell: pwsh
|
||||
run: ./packaging/build-${{ matrix.kind }}.ps1
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: dikte-${{ matrix.os }}
|
||||
path: dist/*
|
||||
if-no-files-found: error
|
||||
@@ -46,9 +46,11 @@ jobs:
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
- run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install --no-install-recommends -y \
|
||||
- timeout-minutes: 5
|
||||
run: |
|
||||
apt="-o Acquire::Retries=3 -o Acquire::http::Timeout=20"
|
||||
sudo apt-get $apt update
|
||||
sudo apt-get $apt install --no-install-recommends -y \
|
||||
libegl1 libgl1 libxkbcommon0 libdbus-1-3 libglib2.0-0 \
|
||||
libfontconfig1 libfreetype6 libgssapi-krb5-2
|
||||
- run: python -m pip install --quiet PyQt6
|
||||
@@ -107,70 +109,16 @@ jobs:
|
||||
esac
|
||||
echo "version=$version" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# The builds themselves are build.yml, which a pull request touching the
|
||||
# packaging also runs on its own. One definition, so the download somebody
|
||||
# gets from a release and the one a pull request was checked against cannot
|
||||
# come out of two different sets of steps.
|
||||
build:
|
||||
needs: version
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
# The oldest Ubuntu still offered, because the glibc a build links
|
||||
# against is the oldest one it will run on, and 22.04's covers every
|
||||
# distribution released since. Move it up only when it goes away.
|
||||
- os: ubuntu-22.04
|
||||
kind: appimage
|
||||
- os: macos-latest
|
||||
kind: dmg
|
||||
# Intel Macs. This runner is the last x86_64 image Actions will
|
||||
# offer, and it goes away in August 2027.
|
||||
- os: macos-15-intel
|
||||
kind: dmg
|
||||
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ needs.version.outputs.ref }}
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
# PyQt6 ships Qt itself, but Qt still loads these from the system, and
|
||||
# the build draws its own icon before it packages anything.
|
||||
- name: Install the Qt runtime libraries
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install --no-install-recommends -y \
|
||||
libegl1 libgl1 libxkbcommon0 libdbus-1-3 libglib2.0-0 \
|
||||
libfontconfig1 libfreetype6 libgssapi-krb5-2
|
||||
|
||||
- name: Install PyQt6 and PyInstaller
|
||||
run: python -m pip install --quiet PyQt6 pyinstaller
|
||||
|
||||
# Only for the builds off master: a tagged build already says the number
|
||||
# it was tagged with, and rewriting it would be rewriting the tag.
|
||||
- name: Write the version being built
|
||||
if: needs.version.outputs.prerelease == 'true'
|
||||
env:
|
||||
VERSION: ${{ needs.version.outputs.version }}
|
||||
run: |
|
||||
python - <<'PY'
|
||||
import os, pathlib, re
|
||||
path = pathlib.Path("dikte/__init__.py")
|
||||
path.write_text(re.sub(r'^__version__ = ".*"$',
|
||||
f'__version__ = "{os.environ["VERSION"]}"',
|
||||
path.read_text(), flags=re.M))
|
||||
PY
|
||||
|
||||
- name: Build
|
||||
run: ./packaging/build-${{ matrix.kind }}.sh
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: dikte-${{ matrix.os }}
|
||||
path: dist/*
|
||||
if-no-files-found: error
|
||||
uses: ./.github/workflows/build.yml
|
||||
with:
|
||||
ref: ${{ needs.version.outputs.ref }}
|
||||
version: ${{ needs.version.outputs.prerelease == 'true' && needs.version.outputs.version || '' }}
|
||||
|
||||
publish:
|
||||
needs: [version, build]
|
||||
@@ -207,6 +155,12 @@ jobs:
|
||||
Accessibility permissions, which macOS asks for the first time each is
|
||||
used. It asks again after an update, because an application signed with
|
||||
no certificate is one macOS has never seen before.
|
||||
|
||||
Windows: run the setup, which installs for your account alone and asks
|
||||
for no administrator. It carries the ffmpeg recording needs and adds a
|
||||
Start Menu entry, a `dikte` command and, unless you untick it, a start
|
||||
at sign-in. It is not signed either, so SmartScreen offers only "Don't
|
||||
run" until you press More info. Add/Remove Programs uninstalls it.
|
||||
EOF
|
||||
)
|
||||
|
||||
|
||||
@@ -24,10 +24,18 @@ jobs:
|
||||
# for the offscreen platform the tests run on. QtNetwork wants the Kerberos
|
||||
# library, and the widgets want fontconfig, whether or not anything is
|
||||
# ever drawn.
|
||||
#
|
||||
# A mirror that stops answering is what the retries and the timeout are
|
||||
# for: apt waits on it by default, and a job waiting on apt sits there
|
||||
# until the six hour limit rather than going red in a minute. The same
|
||||
# two lines are in build.yml and release.yml, which install the same
|
||||
# eight libraries.
|
||||
- name: Install the Qt runtime libraries
|
||||
timeout-minutes: 5
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install --no-install-recommends -y \
|
||||
apt="-o Acquire::Retries=3 -o Acquire::http::Timeout=20"
|
||||
sudo apt-get $apt update
|
||||
sudo apt-get $apt install --no-install-recommends -y \
|
||||
libegl1 libgl1 libxkbcommon0 libdbus-1-3 libglib2.0-0 \
|
||||
libfontconfig1 libfreetype6 libgssapi-krb5-2
|
||||
|
||||
@@ -107,11 +115,16 @@ jobs:
|
||||
run: python -m unittest discover --verbose
|
||||
|
||||
# What the Mac does for its installer, in the language this one is in.
|
||||
# Parsing only: install.ps1 writes into the Start Menu and the user PATH.
|
||||
- name: Check the installer parses
|
||||
# Parsing only: install.ps1 writes into the Start Menu and the user PATH,
|
||||
# and build-windows.ps1 downloads an ffmpeg and runs PyInstaller. The
|
||||
# setup program itself is compiled by build.yml, on the pull requests
|
||||
# that touch it.
|
||||
- name: Check the installer and the build script parse
|
||||
shell: pwsh
|
||||
run: |
|
||||
$problems = $null
|
||||
[System.Management.Automation.Language.Parser]::ParseFile(
|
||||
"$PWD/install.ps1", [ref]$null, [ref]$problems) > $null
|
||||
if ($problems) { $problems; exit 1 }
|
||||
foreach ($script in "install.ps1", "packaging/build-windows.ps1") {
|
||||
$problems = $null
|
||||
[System.Management.Automation.Language.Parser]::ParseFile(
|
||||
"$PWD/$script", [ref]$null, [ref]$problems) > $null
|
||||
if ($problems) { $problems; exit 1 }
|
||||
}
|
||||
|
||||
+6
-4
@@ -70,7 +70,7 @@ there can tell that apart from an empty device list, which only means the tool
|
||||
that lists them is not installed.
|
||||
|
||||
The tests are split along the same line, and almost none of them are skipped.
|
||||
1084 of the 1147 run on any machine, including every line of the Wayland, X11,
|
||||
1096 of the 1159 run on any machine, including every line of the Wayland, X11,
|
||||
macOS and Windows backends: the programs are faked at `shutil.which`, the
|
||||
frameworks and system libraries at the one function that loads them
|
||||
(`paste._win_api`, `hotkey._win_input`). A test class says which system it is
|
||||
@@ -89,9 +89,11 @@ class and subclassed by each of them.
|
||||
|
||||
The 43 that do carry `@linux_only` are the ones that would need the real thing:
|
||||
the `/dev/input` listener, KDE's shortcut file, GNOME's gsettings. The 20 with
|
||||
`@posix_only` are `integrate.py`, the menu entry and the login item a downloaded
|
||||
build writes for itself: there are two downloads, an AppImage and a disk image,
|
||||
so that module has no Windows half for a Windows host to check. Mark a test
|
||||
`@posix_only` are the half of `integrate.py` that writes files, the menu entry
|
||||
and the login item a downloaded build puts down for itself, which want a home
|
||||
directory laid out the way those two systems lay one out. Its Windows half is
|
||||
one registry value, since the setup program there did the rest, and the three
|
||||
functions that read and write it are faked like anything else. Mark a test
|
||||
either way only when faking it would leave nothing to test. A test that quietly
|
||||
stops running on the platform you are porting to protects nothing.
|
||||
|
||||
|
||||
@@ -24,15 +24,17 @@ library, 3.11 or newer, and PyQt6.
|
||||
|
||||
## Install
|
||||
|
||||
The [releases page](../../releases) has an AppImage and a disk image per Mac
|
||||
architecture. Both write their own menu entry, login item and `dikte` command
|
||||
the first time they run, and stand aside for an installation already on the
|
||||
machine; `dikte integrate --remove` takes them back. The AppImage still wants
|
||||
the system packages below, for the sound server, the clipboard and the
|
||||
keyboard. The disk image is signed with no Apple certificate, so the first
|
||||
launch is refused until you press **Open Anyway** under System Settings →
|
||||
Privacy & Security, and macOS asks for the microphone and Accessibility again
|
||||
after each update; installing from a checkout is what avoids that.
|
||||
The [releases page](../../releases) has an AppImage, a disk image per Mac
|
||||
architecture and a Windows setup. The first two write their own menu entry,
|
||||
login item and `dikte` command the first time they run, and stand aside for an
|
||||
installation already on the machine; `dikte integrate --remove` takes them
|
||||
back. The AppImage still wants the system packages below, for the sound
|
||||
server, the clipboard and the keyboard. The disk image is signed with no Apple
|
||||
certificate, so the first launch is refused until you press **Open Anyway**
|
||||
under System Settings → Privacy & Security, and macOS asks for the microphone
|
||||
and Accessibility again after each update; installing from a checkout is what
|
||||
avoids that. The Windows setup installs for your account alone and carries an
|
||||
ffmpeg with it.
|
||||
|
||||
```sh
|
||||
sudo pacman -S --needed pipewire-audio wl-clipboard ydotool ffmpeg python-pyqt6
|
||||
@@ -94,10 +96,12 @@ transcribe in the cloud. A meeting needs BlackHole or Loopback
|
||||
(`brew install blackhole-2ch`); dictation does not.
|
||||
|
||||
Windows works the same way, holding the keys through the system's own hotkey
|
||||
service while Dikte runs: `winget install Gyan.FFmpeg`, `pip install PyQt6`,
|
||||
then `python -m dikte`, with an optional `install.ps1` for the Start Menu entry
|
||||
and the `dikte` command. Meetings are not supported there yet; the details are
|
||||
in the [Windows README](README.windows.md).
|
||||
service while Dikte runs. The setup on the releases page carries the ffmpeg
|
||||
recording needs and asks for no administrator; from a checkout it is `winget
|
||||
install Gyan.FFmpeg`, `pip install PyQt6`, then `python -m dikte`, with an
|
||||
optional `install.ps1` for the Start Menu entry and the `dikte` command.
|
||||
Meetings are not supported there yet; the details are in the
|
||||
[Windows README](README.windows.md).
|
||||
|
||||
`install.sh` adds the `dikte` command, a menu entry, an autostart entry and the
|
||||
two global shortcuts, whose keys are its two arguments, or the ones already in
|
||||
@@ -221,8 +225,9 @@ keys.
|
||||
Everything below is in the `dikte` package, which is what `python3 -m dikte`
|
||||
runs and what the `__main__.py` in it hands to every launcher and shortcut.
|
||||
`scripts/` holds install-mac.sh, update.sh, uninstall.sh and release.sh;
|
||||
`packaging/` builds the AppImage and the disk image that release.sh's tag
|
||||
publishes; install.sh stays at the top, and `tests/` has a file per module.
|
||||
`packaging/` builds the AppImage, the disk image and the Windows setup that
|
||||
release.sh's tag publishes; install.sh stays at the top, and `tests/` has a
|
||||
file per module.
|
||||
|
||||
```
|
||||
app.py entry point, tray icon, state machine
|
||||
|
||||
+18
-16
@@ -23,15 +23,16 @@ Python standart kütüphanesi (3.11 veya üstü) ve PyQt6.
|
||||
|
||||
## Kurulum
|
||||
|
||||
[Sürümler sayfasında](../../releases) bir AppImage, bir de her Mac mimarisi
|
||||
için birer disk imajı var. İkisi de ilk çalıştıklarında kendi menü girdisini,
|
||||
oturum açılışını ve `dikte` komutunu yazar, makinede zaten duran bir kuruluma
|
||||
dokunmazlar; `dikte integrate --remove` yazdıklarını geri alır. AppImage yine
|
||||
de aşağıdaki sistem paketlerini ister: ses sunucusu, pano ve klavye onlardan
|
||||
gelir. Disk imajı bir Apple sertifikasıyla imzalı değil, bu yüzden ilk açılış
|
||||
reddedilir, Sistem Ayarları → Gizlilik ve Güvenlik altından **Yine de Aç**
|
||||
demek gerekir; macOS her güncellemeden sonra mikrofonu ve Erişilebilirliği
|
||||
yeniden sorar, checkout'tan kurmak bundan kurtarır.
|
||||
[Sürümler sayfasında](../../releases) bir AppImage, her Mac mimarisi için
|
||||
birer disk imajı, bir de Windows kurulumu var. İlk ikisi ilk çalıştıklarında
|
||||
kendi menü girdisini, oturum açılışını ve `dikte` komutunu yazar, makinede
|
||||
zaten duran bir kuruluma dokunmazlar; `dikte integrate --remove` yazdıklarını
|
||||
geri alır. AppImage yine de aşağıdaki sistem paketlerini ister: ses sunucusu,
|
||||
pano ve klavye onlardan gelir. Disk imajı bir Apple sertifikasıyla imzalı
|
||||
değil, bu yüzden ilk açılış reddedilir, Sistem Ayarları → Gizlilik ve Güvenlik
|
||||
altından **Yine de Aç** demek gerekir; macOS her güncellemeden sonra mikrofonu
|
||||
ve Erişilebilirliği yeniden sorar, checkout'tan kurmak bundan kurtarır.
|
||||
Windows kurulumu yalnızca kendi hesabına kurar ve yanında bir ffmpeg taşır.
|
||||
|
||||
```sh
|
||||
sudo pacman -S --needed pipewire-audio wl-clipboard ydotool ffmpeg python-pyqt6
|
||||
@@ -93,10 +94,11 @@ BlackHole veya Loopback gerekiyor (`brew install blackhole-2ch`); dikte için
|
||||
gerekmiyor.
|
||||
|
||||
Windows da aynı şekilde çalışıyor, Dikte açıkken kombinasyonu sistemin kendi
|
||||
kısayol servisi üzerinden tutuyor: `winget install Gyan.FFmpeg`, `pip install
|
||||
PyQt6`, sonra `python -m dikte`; Başlat Menüsü girdisi ve `dikte` komutu için
|
||||
isteğe bağlı `install.ps1`. Orada toplantı kaydı henüz yok, ayrıntılar
|
||||
[Windows README](README.windows.md)'sinde.
|
||||
kısayol servisi üzerinden tutuyor. Sürümler sayfasındaki kurulum kaydın
|
||||
istediği ffmpeg'i de taşıyor ve yönetici istemiyor; checkout'tan ise `winget
|
||||
install Gyan.FFmpeg`, `pip install PyQt6`, sonra `python -m dikte`, Başlat
|
||||
Menüsü girdisi ve `dikte` komutu için de isteğe bağlı `install.ps1`. Orada
|
||||
toplantı kaydı henüz yok, ayrıntılar [Windows README](README.windows.md)'sinde.
|
||||
|
||||
`install.sh` `dikte` komutunu, menü girdisini, oturum açılışında otomatik
|
||||
başlatmayı ve iki global kısayolu kurar; tuşları iki argümanı, argüman
|
||||
@@ -217,9 +219,9 @@ Kısayollar sekmesi bağlanacak komutu gösterir.
|
||||
Aşağıdakilerin hepsi `dikte` paketinin içinde: `python3 -m dikte` bunu çalıştırır,
|
||||
içindeki `__main__.py` de her başlatıcının ve kısayolun adlandırdığı dosyadır.
|
||||
`scripts/` altında install-mac.sh, update.sh, uninstall.sh ve release.sh var;
|
||||
`packaging/` release.sh'ın attığı etiketin yayımladığı AppImage ile disk
|
||||
imajını derler; install.sh en üstte kalır, `tests/` içinde de her modülün bir
|
||||
dosyası.
|
||||
`packaging/` release.sh'ın attığı etiketin yayımladığı AppImage'i, disk imajını
|
||||
ve Windows kurulumunu derler; install.sh en üstte kalır, `tests/` içinde de her
|
||||
modülün bir dosyası.
|
||||
|
||||
```
|
||||
app.py giriş noktası, tepsi simgesi, durum makinesi
|
||||
|
||||
+15
-3
@@ -5,15 +5,27 @@ up and pasted where your cursor is.
|
||||
|
||||
## Requirements
|
||||
|
||||
- **Windows 10/11**
|
||||
Windows 10 or 11. The setup on the [releases page](../../releases) carries
|
||||
everything else with it, and is x64, which an ARM machine runs emulated the way
|
||||
it runs whisper.cpp. A checkout wants:
|
||||
|
||||
- **Python 3.11+** with **PyQt6** (`pip install PyQt6`; install.ps1 installs
|
||||
it when it is missing)
|
||||
- **ffmpeg** for microphone capture: `winget install Gyan.FFmpeg`
|
||||
|
||||
## Installing
|
||||
|
||||
From a checkout: the releases page carries an AppImage and a disk image, and no
|
||||
Windows build yet.
|
||||
`Dikte-<version>-x64-setup.exe` from the releases page installs for your
|
||||
account alone, so no administrator is asked for, and puts down a Start Menu
|
||||
entry, a `dikte` command and, unless you untick it, a start at sign-in. It is
|
||||
signed with no certificate, so SmartScreen offers only **Don't run** until you
|
||||
press **More info**. Add/Remove Programs uninstalls it, and `dikte integrate`
|
||||
and `dikte integrate --remove` are the sign-in entry on its own, for changing
|
||||
your mind about that later. The `dikte` command is the same file install.ps1
|
||||
writes, so over a checkout the setup takes it over and uninstalling takes it
|
||||
away; run install.ps1 again to get the checkout's back.
|
||||
|
||||
From a checkout instead:
|
||||
|
||||
```powershell
|
||||
powershell -ExecutionPolicy Bypass -File install.ps1
|
||||
|
||||
+7
-3
@@ -777,11 +777,14 @@ def cmd_integrate(opts):
|
||||
|
||||
Run for you on every start, so this is for the two cases that start does
|
||||
not cover: undoing it, and repairing it from a terminal after the AppImage
|
||||
was moved while Dikte was not running.
|
||||
was moved while Dikte was not running. On Windows the setup program wrote
|
||||
the rest, and what is left for this is the switch it could only offer while
|
||||
it was on the screen: typing it starts Dikte at sign-in, --remove stops it.
|
||||
"""
|
||||
if not integrate.packaged():
|
||||
installer = "install.ps1" if sys.platform == "win32" else "./install.sh"
|
||||
return fail(opts, "this is a checkout, not a downloaded build; "
|
||||
"./install.sh writes those files here", 2)
|
||||
f"{installer} writes those files here", 2)
|
||||
try:
|
||||
# force, because typing this is asking for it outright, where the same
|
||||
# call on every start stands aside for an installation already there.
|
||||
@@ -1088,7 +1091,8 @@ def build_parser():
|
||||
remove.set_defaults(func=cmd_shortcut)
|
||||
|
||||
integrated = leaf(subs, "integrate",
|
||||
"menu entry, login item and command, for a downloaded build")
|
||||
"menu entry, start at sign-in and command, "
|
||||
"for a downloaded build")
|
||||
integrated.add_argument("--remove", action="store_true",
|
||||
help="take them away again")
|
||||
integrated.set_defaults(func=cmd_integrate)
|
||||
|
||||
+101
-5
@@ -6,6 +6,12 @@ downloaded an AppImage or dragged Dikte.app out of a disk image ran no
|
||||
installer at all, so the application writes those files itself, on its first
|
||||
run and again whenever the file it was started from has moved.
|
||||
|
||||
Windows is the one platform where the download is an installer, and it wrote
|
||||
the Start Menu entry, the `dikte` command and the uninstaller as it ran. What
|
||||
is left here is the one thing it can only ask about once: whether Dikte starts
|
||||
when you sign in. `dikte integrate` turns that on later and `--remove` turns it
|
||||
off, and a plain start only repairs an entry that is already there.
|
||||
|
||||
Nothing here runs from a checkout. install.sh has already written the same
|
||||
files there, pointing at the interpreter that checkout was installed against,
|
||||
and overwriting them with a guess would be a downgrade.
|
||||
@@ -35,6 +41,14 @@ AGENT_ID = "io.github.yusufipk.dikte"
|
||||
ICON_NAME = "dikte"
|
||||
DESKTOP_FILE = "dikte.desktop"
|
||||
MACOS_COMMAND_MARKER = "# Written by Dikte itself. Delete it to be rid of it.\n"
|
||||
# The windowed executable the Windows setup installs, beside the console one
|
||||
# the `dikte` command runs.
|
||||
WINDOWS_APP = "Dikte.exe"
|
||||
# Where Windows keeps what to start when somebody signs in, and the name the
|
||||
# setup program files Dikte's entry under. Both halves have to agree: the
|
||||
# uninstaller deletes this value, and so does `dikte integrate --remove`.
|
||||
RUN_KEY = "Software\\Microsoft\\Windows\\CurrentVersion\\Run"
|
||||
RUN_VALUE = "Dikte"
|
||||
|
||||
|
||||
def packaged():
|
||||
@@ -56,6 +70,13 @@ def target():
|
||||
for parent in executable.parents:
|
||||
if parent.suffix == ".app":
|
||||
return parent
|
||||
if sys.platform == "win32":
|
||||
# The windowed executable, whichever of the two is running: the console
|
||||
# one is what the `dikte` command names, and a sign-in that started
|
||||
# that one would open a console window nobody asked for.
|
||||
windowed = executable.with_name(WINDOWS_APP)
|
||||
if windowed.is_file():
|
||||
return windowed
|
||||
return executable
|
||||
|
||||
|
||||
@@ -152,11 +173,12 @@ def use_system_certificates():
|
||||
def bundled_bin():
|
||||
"""Where a build keeps the helper programs it carries, if it carries any.
|
||||
|
||||
The disk image ships an ffmpeg because macOS records through one and has
|
||||
nothing like it preinstalled, so a Mac that downloaded Dikte and nothing
|
||||
else would otherwise not be able to record at all. The AppImage carries
|
||||
none: Linux records through parec or pw-record, which come with the sound
|
||||
server, and the distributions all package ffmpeg for the rest.
|
||||
The disk image and the Windows setup both ship an ffmpeg, because both
|
||||
systems record through one and neither has anything like it preinstalled,
|
||||
so a machine that downloaded Dikte and nothing else would otherwise not be
|
||||
able to record at all. The AppImage carries none: Linux records through
|
||||
parec or pw-record, which come with the sound server, and the distributions
|
||||
all package ffmpeg for the rest.
|
||||
"""
|
||||
binary = pathlib.Path(sys.executable).parent
|
||||
if sys.platform == "darwin" and binary.name == "MacOS":
|
||||
@@ -210,6 +232,8 @@ def install(force=False):
|
||||
"""
|
||||
if sys.platform == "darwin":
|
||||
return _macos_install(target(), force)
|
||||
if sys.platform == "win32":
|
||||
return _windows_install(target(), force)
|
||||
return _linux_install(target(), force)
|
||||
|
||||
|
||||
@@ -217,6 +241,8 @@ def remove():
|
||||
"""Take them away again. The paths that were there to delete."""
|
||||
if sys.platform == "darwin":
|
||||
return _macos_remove()
|
||||
if sys.platform == "win32":
|
||||
return _windows_remove()
|
||||
return _linux_remove()
|
||||
|
||||
|
||||
@@ -500,3 +526,73 @@ def _launchctl_reload(agent):
|
||||
capture_output=True, check=False)
|
||||
subprocess.run(["launchctl", "bootstrap", f"gui/{os.getuid()}", str(agent)],
|
||||
capture_output=True, check=False)
|
||||
|
||||
|
||||
# --- Windows --------------------------------------------------------------
|
||||
#
|
||||
# The setup program did the installing here, which leaves one question a
|
||||
# wizard can only ask while it is on the screen: whether Dikte starts when you
|
||||
# sign in. That answer is a registry value, so it is one both sides can write:
|
||||
# the setup program sets it from the tick box, the uninstaller deletes it
|
||||
# however it got there, and the two functions below are the same switch from a
|
||||
# terminal, long after the wizard is gone.
|
||||
|
||||
|
||||
def _run_entry():
|
||||
"""What the autostart entry names, or "" when there is none."""
|
||||
import winreg
|
||||
try:
|
||||
with winreg.OpenKey(winreg.HKEY_CURRENT_USER, RUN_KEY) as key:
|
||||
value, kind = winreg.QueryValueEx(key, RUN_VALUE)
|
||||
except OSError:
|
||||
return ""
|
||||
return value if kind == winreg.REG_SZ and isinstance(value, str) else ""
|
||||
|
||||
|
||||
def _write_run_entry(command):
|
||||
import winreg
|
||||
with winreg.CreateKey(winreg.HKEY_CURRENT_USER, RUN_KEY) as key:
|
||||
winreg.SetValueEx(key, RUN_VALUE, 0, winreg.REG_SZ, command)
|
||||
|
||||
|
||||
def _delete_run_entry():
|
||||
"""Whether there was one to delete."""
|
||||
import winreg
|
||||
try:
|
||||
with winreg.OpenKey(winreg.HKEY_CURRENT_USER, RUN_KEY, 0,
|
||||
winreg.KEY_SET_VALUE) as key:
|
||||
winreg.DeleteValue(key, RUN_VALUE)
|
||||
except OSError:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _run_entry_name():
|
||||
"""What to call the value in a listing, since it is not a file."""
|
||||
return f"HKCU\\{RUN_KEY}\\{RUN_VALUE}"
|
||||
|
||||
|
||||
def _windows_install(app, force=False):
|
||||
"""Point the autostart entry at this build. What changed.
|
||||
|
||||
Only `force`, which is what typing `dikte integrate` means, creates one.
|
||||
The call on every start repairs an entry that is already there and names an
|
||||
executable somewhere else, which is what an installation moved to another
|
||||
drive or reinstalled into another directory leaves behind; somebody who
|
||||
unticked the box in the wizard, or turned it off since, is not asked again
|
||||
by every start.
|
||||
"""
|
||||
command = f'"{app}"'
|
||||
current = _run_entry()
|
||||
if not current and not force:
|
||||
return []
|
||||
if current == command:
|
||||
return []
|
||||
_write_run_entry(command)
|
||||
return [_run_entry_name()]
|
||||
|
||||
|
||||
def _windows_remove():
|
||||
"""Stop starting at sign-in. The Start Menu entry, the command and the
|
||||
files are the uninstaller's, and Add/Remove Programs is where they go."""
|
||||
return [_run_entry_name()] if _delete_run_entry() else []
|
||||
|
||||
@@ -15,6 +15,8 @@ import sys
|
||||
|
||||
from PyQt6.QtNetwork import QLocalSocket
|
||||
|
||||
from . import integrate
|
||||
|
||||
SERVER_NAME = "dikte-" + (
|
||||
str(os.getuid()) if hasattr(os, "getuid")
|
||||
else os.environ.get("USERNAME", "user"))
|
||||
@@ -43,9 +45,19 @@ def launcher():
|
||||
under a fresh /tmp path every run, so what a shortcut written today has to
|
||||
say is the .AppImage file the user keeps, not the binary inside this run's
|
||||
mount. APPIMAGE is what the runtime puts that path in.
|
||||
|
||||
The Windows build is two executables over one program, and the one to start
|
||||
again is always the windowed one: `dikte toggle` typed at a terminal runs
|
||||
the console one, and the application it leaves running should no more be
|
||||
tied to that terminal than the one the Start Menu starts.
|
||||
"""
|
||||
if not getattr(sys, "frozen", False):
|
||||
return [sys.executable, script_path()]
|
||||
if sys.platform == "win32":
|
||||
windowed = os.path.join(os.path.dirname(sys.executable),
|
||||
integrate.WINDOWS_APP)
|
||||
if os.path.isfile(windowed):
|
||||
return [windowed]
|
||||
return [os.environ.get("APPIMAGE") or sys.executable]
|
||||
|
||||
|
||||
|
||||
+47
-6
@@ -23,9 +23,10 @@ outwards, which stands out on a dark bar and stays readable on a light one.
|
||||
"""
|
||||
|
||||
import pathlib
|
||||
import struct
|
||||
import sys
|
||||
|
||||
from PyQt6.QtCore import QPointF, QRectF, Qt
|
||||
from PyQt6.QtCore import QBuffer, QPointF, QRectF, Qt
|
||||
from PyQt6.QtGui import (QColor, QIcon, QLinearGradient, QPainter, QPainterPath,
|
||||
QPen, QPixmap)
|
||||
|
||||
@@ -210,6 +211,9 @@ APP_ICON_SIZES = (16, 32, 128, 256, 512)
|
||||
# What an XDG icon theme is asked for: a menu wants 48, a task bar 22 or 24, a
|
||||
# file dialog 16, and something scaling for a HiDPI panel wants the big ones.
|
||||
HICOLOR_SIZES = (16, 22, 24, 32, 48, 64, 128, 256)
|
||||
# What goes into the .ico: the Windows shell picks the nearest of these itself,
|
||||
# and 256 is the one the large view in Explorer and the setup program read.
|
||||
ICO_SIZES = (16, 24, 32, 48, 64, 128, 256)
|
||||
|
||||
|
||||
def app_pixmap(size):
|
||||
@@ -292,26 +296,63 @@ def write_hicolor(directory, name="dikte"):
|
||||
return written
|
||||
|
||||
|
||||
def write_ico(path):
|
||||
"""Write the Windows icon, every size in the one file. The path it wrote.
|
||||
|
||||
An .ico is a directory of images and a run of image data after it, and
|
||||
since Vista each image may be a PNG rather than the bitmap-and-mask pair
|
||||
the format started with. PNGs are what Qt can already produce, so the
|
||||
twenty bytes of header per size are the whole of the work, and it saves
|
||||
both a build dependency and an icon file in the repository.
|
||||
"""
|
||||
path = pathlib.Path(path)
|
||||
images = []
|
||||
for size in ICO_SIZES:
|
||||
buffer = QBuffer()
|
||||
buffer.open(QBuffer.OpenModeFlag.WriteOnly)
|
||||
app_pixmap(size).save(buffer, "PNG")
|
||||
images.append((size, bytes(buffer.data())))
|
||||
buffer.close()
|
||||
|
||||
# 0, then 1 for an icon rather than a cursor, then the count.
|
||||
header = struct.pack("<HHH", 0, 1, len(images))
|
||||
offset = len(header) + 16 * len(images)
|
||||
entries, data = b"", b""
|
||||
for size, png in images:
|
||||
# A side of 256 is written as 0: the field is one byte, and the format
|
||||
# spends it on the sizes below that rather than on the one above.
|
||||
entries += struct.pack("<BBBBHHII", size % 256, size % 256, 0, 0,
|
||||
1, 32, len(png), offset)
|
||||
offset += len(png)
|
||||
data += png
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_bytes(header + entries + data)
|
||||
return path
|
||||
|
||||
|
||||
def _main(argv):
|
||||
"""`trayicon.py <path>.iconset` for install-mac.sh, `--hicolor <dir>` for
|
||||
install.sh.
|
||||
install.sh, `--ico <path>` for the Windows build.
|
||||
|
||||
A QGuiApplication has to exist before a QPixmap can, and offscreen because
|
||||
this runs from a shell script with no window to open.
|
||||
"""
|
||||
hicolor = len(argv) == 3 and argv[1] == "--hicolor"
|
||||
if not hicolor and len(argv) != 2:
|
||||
flag = argv[1] if len(argv) == 3 else ""
|
||||
if flag not in ("--hicolor", "--ico") and len(argv) != 2:
|
||||
print("usage: trayicon.py <directory>.iconset\n"
|
||||
" trayicon.py --hicolor <icon directory>", file=sys.stderr)
|
||||
" trayicon.py --hicolor <icon directory>\n"
|
||||
" trayicon.py --ico <path>.ico", file=sys.stderr)
|
||||
return 2
|
||||
from PyQt6.QtGui import QGuiApplication
|
||||
QGuiApplication.setAttribute(
|
||||
Qt.ApplicationAttribute.AA_UseSoftwareOpenGL, True)
|
||||
app = QGuiApplication(["dikte-icon", "-platform", "offscreen"])
|
||||
try:
|
||||
if hicolor:
|
||||
if flag == "--hicolor":
|
||||
for path in write_hicolor(argv[2]):
|
||||
print(path)
|
||||
elif flag == "--ico":
|
||||
print(write_ico(argv[2]))
|
||||
else:
|
||||
print(write_iconset(argv[1]))
|
||||
finally:
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
#!/usr/bin/env pwsh
|
||||
# The Windows download: one setup program, carrying everything Dikte needs to
|
||||
# record and to be started again after a sign-in.
|
||||
#
|
||||
# Run from anywhere; it works in build\ at the top of the checkout and leaves
|
||||
# the finished .exe in dist\. x64 only, because that is what the PyQt6 wheel and
|
||||
# whisper.cpp both publish for Windows; a Windows on ARM machine runs it under
|
||||
# the emulation it runs everything else under.
|
||||
#
|
||||
# powershell -ExecutionPolicy Bypass -File packaging\build-windows.ps1
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$root = Split-Path -Parent $PSScriptRoot
|
||||
$build = Join-Path $root "build"
|
||||
$out = Join-Path $root "dist"
|
||||
$dist = Join-Path $build "dist\dikte"
|
||||
|
||||
$env:PYTHONPATH = $root
|
||||
$version = & python -c "import dikte; print(dikte.__version__)"
|
||||
if ($LASTEXITCODE -ne 0) { throw "could not read the version out of dikte/__init__.py" }
|
||||
|
||||
# A pinned tag and a checksum rather than "whatever is newest": this binary goes
|
||||
# out inside something people run, so what it is has to be decided here and not
|
||||
# by whoever pushes to that repository next. The same release the disk image
|
||||
# takes its ffmpeg from, which is gyan.dev's essentials build repackaged, and
|
||||
# dshow is in it, which is the one part of ffmpeg recording here goes through.
|
||||
$ffmpegTag = "b6.1.1"
|
||||
$ffmpegAsset = "ffmpeg-win32-x64.gz"
|
||||
$ffmpegSha = "8883A3DFFBD0A16CF4EF95206EA05283F78908DBFB118F73C83F4951DCC06D77"
|
||||
|
||||
if (Test-Path $build) { Remove-Item $build -Recurse -Force }
|
||||
if (Test-Path $out) { Remove-Item $out -Recurse -Force }
|
||||
New-Item -ItemType Directory -Path $build, $out | Out-Null
|
||||
|
||||
# 1. The icon ---------------------------------------------------------------
|
||||
# Drawn by Dikte itself, offscreen, which is why there is no image file in the
|
||||
# repository. Before the application, because PyInstaller writes it into the
|
||||
# executable rather than beside it, and the setup program uses the same file.
|
||||
$icon = Join-Path $build "Dikte.ico"
|
||||
$env:QT_QPA_PLATFORM = "offscreen"
|
||||
& python -m dikte.trayicon --ico $icon
|
||||
if ($LASTEXITCODE -ne 0) { throw "the icon would not draw" }
|
||||
Remove-Item Env:\QT_QPA_PLATFORM
|
||||
$env:DIKTE_ICO = $icon
|
||||
|
||||
# 2. The application --------------------------------------------------------
|
||||
# Two executables in the one directory: Dikte.exe, which is windowed and is
|
||||
# what a shortcut starts, and dikte-cli.exe, which has a console and is what
|
||||
# the `dikte` command runs. Their names differ by more than case on purpose;
|
||||
# Windows would otherwise keep one file for both.
|
||||
& python -m PyInstaller (Join-Path $root "packaging\dikte.spec") `
|
||||
--distpath (Join-Path $build "dist") --workpath (Join-Path $build "work") `
|
||||
--noconfirm --clean
|
||||
if ($LASTEXITCODE -ne 0) { throw "PyInstaller failed" }
|
||||
|
||||
# And the check that the two are really two. Windows matches a filename
|
||||
# without regard to its case, so a rename that leaves them a case apart puts
|
||||
# one file in the directory and whichever was written second is what both
|
||||
# names find. It cost a windowed build once, silently: the machines the
|
||||
# packaging is otherwise checked on all have case-sensitive filesystems, so
|
||||
# this only ever shows up here. Subsystem 2 is windowed, 3 is a console, and
|
||||
# it is the field the loader reads to decide which to give.
|
||||
function Get-PeSubsystem($path) {
|
||||
$bytes = [System.IO.File]::ReadAllBytes($path)
|
||||
$header = [BitConverter]::ToInt32($bytes, 0x3C)
|
||||
return [BitConverter]::ToUInt16($bytes, $header + 0x5C)
|
||||
}
|
||||
foreach ($pair in @(@("Dikte.exe", 2), @("dikte-cli.exe", 3))) {
|
||||
$subsystem = Get-PeSubsystem (Join-Path $dist $pair[0])
|
||||
if ($subsystem -ne $pair[1]) {
|
||||
throw "$($pair[0]) is subsystem $subsystem, expected $($pair[1]): the two executables collided"
|
||||
}
|
||||
}
|
||||
|
||||
# 3. ffmpeg -----------------------------------------------------------------
|
||||
# Recording on Windows goes through ffmpeg's DirectShow input, and Windows
|
||||
# ships nothing like it, so without this the download would be an application
|
||||
# that cannot record until the person who downloaded it installs one. bin\
|
||||
# beside the executables, because integrate.py puts that directory in front of
|
||||
# PATH at startup and everything reaching for ffmpeg goes through shutil.which.
|
||||
$archive = Join-Path $build $ffmpegAsset
|
||||
Invoke-WebRequest -UseBasicParsing -OutFile $archive `
|
||||
"https://github.com/eugeneware/ffmpeg-static/releases/download/$ffmpegTag/$ffmpegAsset"
|
||||
$got = (Get-FileHash $archive -Algorithm SHA256).Hash
|
||||
if ($got -ne $ffmpegSha) { throw "ffmpeg checksum: expected $ffmpegSha, got $got" }
|
||||
|
||||
$bin = Join-Path $dist "bin"
|
||||
New-Item -ItemType Directory -Path $bin | Out-Null
|
||||
$compressed = [System.IO.File]::OpenRead($archive)
|
||||
$stream = New-Object System.IO.Compression.GzipStream(
|
||||
$compressed, [System.IO.Compression.CompressionMode]::Decompress)
|
||||
$binary = [System.IO.File]::Create((Join-Path $bin "ffmpeg.exe"))
|
||||
try { $stream.CopyTo($binary) } finally { $binary.Dispose(); $stream.Dispose(); $compressed.Dispose() }
|
||||
|
||||
# 4. The setup program ------------------------------------------------------
|
||||
# Inno Setup comes with the GitHub runner. On a machine that has not got it:
|
||||
# winget install JRSoftware.InnoSetup
|
||||
$iscc = (Get-Command iscc -ErrorAction SilentlyContinue).Source
|
||||
if (-not $iscc) {
|
||||
$iscc = Join-Path ${env:ProgramFiles(x86)} "Inno Setup 6\ISCC.exe"
|
||||
}
|
||||
if (-not (Test-Path $iscc)) {
|
||||
throw "no Inno Setup found. Install it with: winget install JRSoftware.InnoSetup"
|
||||
}
|
||||
& $iscc "/DVersion=$version" "/DSource=$dist" "/DIcon=$icon" `
|
||||
(Join-Path $root "packaging\dikte.iss")
|
||||
if ($LASTEXITCODE -ne 0) { throw "Inno Setup failed" }
|
||||
|
||||
Write-Host "dist\Dikte-$version-x64-setup.exe"
|
||||
@@ -0,0 +1,117 @@
|
||||
; What the Windows download is: the directory PyInstaller built, wrapped in the
|
||||
; setup program Windows expects. Run it through build-windows.ps1, which draws
|
||||
; the icon, builds that directory, puts an ffmpeg in it and passes the version
|
||||
; in; ISCC on its own has none of that.
|
||||
;
|
||||
; Per user rather than per machine. It keeps the whole thing out of the way of
|
||||
; the administrator prompt, which for something a person is trying out is the
|
||||
; difference between a download and a phone call to whoever owns the laptop,
|
||||
; and nothing here writes outside the account anyway.
|
||||
|
||||
#ifndef Version
|
||||
#define Version "0.0.0"
|
||||
#endif
|
||||
#ifndef Source
|
||||
#define Source "..\build\dist\dikte"
|
||||
#endif
|
||||
#ifndef Icon
|
||||
#define Icon "..\build\Dikte.ico"
|
||||
#endif
|
||||
|
||||
[Setup]
|
||||
; The identifier Add/Remove Programs files this under, and what an update
|
||||
; recognises the older installation by. The same one the Mac's login item and
|
||||
; the bundle use, and like those it never changes.
|
||||
AppId=io.github.yusufipk.dikte
|
||||
AppName=Dikte
|
||||
AppVersion={#Version}
|
||||
AppPublisher=Yusuf Ipek
|
||||
AppSupportURL=https://github.com/yusufipk/dikte
|
||||
DefaultDirName={localappdata}\Programs\Dikte
|
||||
DefaultGroupName=Dikte
|
||||
DisableProgramGroupPage=yes
|
||||
PrivilegesRequired=lowest
|
||||
ArchitecturesAllowed=x64compatible
|
||||
ArchitecturesInstallIn64BitMode=x64compatible
|
||||
OutputDir=..\dist
|
||||
OutputBaseFilename=Dikte-{#Version}-x64-setup
|
||||
SetupIconFile={#Icon}
|
||||
UninstallDisplayIcon={app}\Dikte.exe
|
||||
WizardStyle=modern
|
||||
; Most of the download is Qt and ffmpeg, both of which compress well, and the
|
||||
; slower setting is a minute of a build machine's time against a smaller file
|
||||
; for everybody who downloads it.
|
||||
Compression=lzma2/max
|
||||
SolidCompression=yes
|
||||
; An update over a running Dikte would otherwise fail on the executable it
|
||||
; cannot replace. Restart Manager closes it and starts it again afterwards.
|
||||
CloseApplications=yes
|
||||
RestartApplications=yes
|
||||
|
||||
[Languages]
|
||||
Name: "english"; MessagesFile: "compiler:Default.isl"
|
||||
|
||||
[Tasks]
|
||||
; On by default: Dikte is a tray application holding a global shortcut, and one
|
||||
; that is not running when you press the key is one that does nothing.
|
||||
Name: "autostart"; Description: "Start Dikte when I sign in"
|
||||
|
||||
[Files]
|
||||
Source: "{#Source}\*"; DestDir: "{app}"; Flags: recursesubdirs ignoreversion
|
||||
|
||||
[Icons]
|
||||
Name: "{autoprograms}\Dikte"; Filename: "{app}\Dikte.exe"
|
||||
|
||||
[Registry]
|
||||
; Starting at sign-in, as a registry value rather than a shortcut in the
|
||||
; Startup folder: it is the one place the setup program, the uninstaller and
|
||||
; `dikte integrate` can all read and write without a COM library between them.
|
||||
Root: HKCU; Subkey: "Software\Microsoft\Windows\CurrentVersion\Run"; \
|
||||
ValueType: string; ValueName: "Dikte"; ValueData: """{app}\Dikte.exe"""; \
|
||||
Flags: uninsdeletevalue; Tasks: autostart
|
||||
; And taking it away again, for an update where the box was unticked. Both
|
||||
; lines delete on uninstall, so an entry `dikte integrate` wrote later goes
|
||||
; too, whichever way it got there.
|
||||
Root: HKCU; Subkey: "Software\Microsoft\Windows\CurrentVersion\Run"; \
|
||||
ValueType: none; ValueName: "Dikte"; \
|
||||
Flags: deletevalue uninsdeletevalue; Tasks: not autostart
|
||||
|
||||
[Run]
|
||||
Filename: "{app}\Dikte.exe"; Description: "Start Dikte"; \
|
||||
Flags: nowait postinstall skipifsilent
|
||||
|
||||
[Code]
|
||||
{ The `dikte` command. WindowsApps is already on the user's PATH, so a .cmd
|
||||
left there runs from any terminal without touching the PATH and without an
|
||||
administrator; the alternative is an environment variable edit that every
|
||||
open terminal misses. It names dikte-cli.exe, the console executable, which
|
||||
is the one that can print to the terminal it was typed in. }
|
||||
|
||||
function ShimDir(): String;
|
||||
begin
|
||||
Result := ExpandConstant('{localappdata}\Microsoft\WindowsApps');
|
||||
end;
|
||||
|
||||
function ShimPath(): String;
|
||||
begin
|
||||
Result := ShimDir() + '\dikte.cmd';
|
||||
end;
|
||||
|
||||
procedure CurStepChanged(CurStep: TSetupStep);
|
||||
var
|
||||
Shim: String;
|
||||
begin
|
||||
if CurStep = ssPostInstall then begin
|
||||
if DirExists(ShimDir()) then begin
|
||||
Shim := '@echo off' + #13#10
|
||||
+ '"' + ExpandConstant('{app}\dikte-cli.exe') + '" %*' + #13#10;
|
||||
SaveStringToFile(ShimPath(), Shim, False);
|
||||
end;
|
||||
end;
|
||||
end;
|
||||
|
||||
procedure CurUninstallStepChanged(CurUninstallStep: TUninstallStep);
|
||||
begin
|
||||
if CurUninstallStep = usUninstall then
|
||||
DeleteFile(ShimPath());
|
||||
end;
|
||||
+40
-10
@@ -1,12 +1,14 @@
|
||||
# PyInstaller's description of the build, shared by the AppImage and the disk
|
||||
# image. Run it through build-appimage.sh or build-dmg.sh rather than by hand:
|
||||
# each of those has a few steps of its own on either side of this.
|
||||
# PyInstaller's description of the build, shared by the AppImage, the disk
|
||||
# image and the Windows setup. Run it through build-appimage.sh, build-dmg.sh
|
||||
# or build-windows.ps1 rather than by hand: each of those has a few steps of
|
||||
# its own on either side of this.
|
||||
#
|
||||
# A directory rather than a single file, on both platforms. Onefile unpacks
|
||||
# itself into /tmp on every start, which for something a global shortcut is
|
||||
# meant to bring up is a second of nothing happening, and for the AppImage it
|
||||
# would be an unpacking inside an unpacking. The single file people download is
|
||||
# the AppImage and the .dmg; this only has to be tidy inside them.
|
||||
# A directory rather than a single file, on all three. Onefile unpacks itself
|
||||
# into a temporary directory on every start, which for something a global
|
||||
# shortcut is meant to bring up is a second of nothing happening, and for the
|
||||
# AppImage it would be an unpacking inside an unpacking. The single file people
|
||||
# download is the AppImage, the .dmg and the setup program; this only has to be
|
||||
# tidy inside them.
|
||||
|
||||
import os
|
||||
import pathlib
|
||||
@@ -24,6 +26,7 @@ __version__ = re.search(r'^__version__ = "(.*)"$',
|
||||
re.M).group(1)
|
||||
|
||||
MACOS = sys.platform == "darwin"
|
||||
WINDOWS = sys.platform == "win32"
|
||||
BUNDLE_ID = "io.github.yusufipk.dikte"
|
||||
|
||||
# PyQt6's wheel is most of the build, and most of the wheel is modules nothing
|
||||
@@ -60,19 +63,46 @@ executable = EXE( # noqa: F821
|
||||
analysis.scripts,
|
||||
[],
|
||||
exclude_binaries=True,
|
||||
name="Dikte" if MACOS else "dikte",
|
||||
name="Dikte" if MACOS or WINDOWS else "dikte",
|
||||
console=False,
|
||||
# Both platforms use whatever the machine is, because neither build is
|
||||
# Every platform uses whatever the machine is, because no build here is
|
||||
# cross-compiled: the workflow runs one job per architecture.
|
||||
target_arch=None,
|
||||
# Ad-hoc, and only on a Mac, where an arm64 binary that carries no
|
||||
# signature at all is refused by the kernel rather than merely warned
|
||||
# about. build-dmg.sh signs the finished bundle over the top of this.
|
||||
codesign_identity="-" if MACOS else None,
|
||||
# Windows keeps the icon inside the executable, and build-windows.ps1 draws
|
||||
# it from the same shapes the tray uses. A Mac reads the one BUNDLE names
|
||||
# below, and the AppImage installs PNGs into the icon theme instead.
|
||||
icon=os.environ.get("DIKTE_ICO") or None,
|
||||
)
|
||||
|
||||
# The same program a second time, as a console application, and only on
|
||||
# Windows. A windowed executable there is one the loader gives no console and
|
||||
# no standard output at all, so `dikte doctor` started from a terminal would
|
||||
# print nothing to it and answer nothing to a script. Everywhere else the one
|
||||
# executable does both jobs: a terminal that started it keeps its output, and
|
||||
# nothing opens a window nobody asked for.
|
||||
#
|
||||
# Named apart from the windowed one rather than `dikte` beside `Dikte`.
|
||||
# Windows matches a filename without regard to its case, so those two are one
|
||||
# file in one directory: whichever PyInstaller writes second is the only one
|
||||
# installed, and a build made on a case-sensitive filesystem never sees it.
|
||||
console_executable = EXE( # noqa: F821
|
||||
archive,
|
||||
analysis.scripts,
|
||||
[],
|
||||
exclude_binaries=True,
|
||||
name="dikte-cli",
|
||||
console=True,
|
||||
target_arch=None,
|
||||
icon=os.environ.get("DIKTE_ICO") or None,
|
||||
) if WINDOWS else None
|
||||
|
||||
collection = COLLECT( # noqa: F821
|
||||
executable,
|
||||
*([console_executable] if WINDOWS else []),
|
||||
analysis.binaries,
|
||||
analysis.datas,
|
||||
name="dikte",
|
||||
|
||||
+3
-2
@@ -1,11 +1,12 @@
|
||||
"""What the AppImage and the disk image start.
|
||||
"""What the AppImage, the disk image and the Windows setup start.
|
||||
|
||||
dikte/__main__.py is written for a checkout: it puts the directory above the
|
||||
package on the import path, which a build has neither the need for nor a
|
||||
directory to point at. What is left over is one thing a checkout never sees.
|
||||
The Finder hands a double-clicked application a -psn_0_… argument naming the
|
||||
process serial number, which argparse reads as a flag it has never heard of and
|
||||
exits over, and no one clicking an icon would ever find out why.
|
||||
exits over, and no one clicking an icon would ever find out why. Nothing else
|
||||
here is one platform's: the same file is both Windows executables as well.
|
||||
|
||||
The three environment lines have to run before anything starts a process,
|
||||
opens a connection or reaches for ffmpeg, and before is easier to be sure of
|
||||
|
||||
@@ -9,6 +9,7 @@ has to notice AppImageLauncher's entry, which is not under the name ours is.
|
||||
import os
|
||||
import pathlib
|
||||
import plistlib
|
||||
import re
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
@@ -451,5 +452,103 @@ class MacOS(Home):
|
||||
self.assertIn("install-mac.sh", command.read_text())
|
||||
|
||||
|
||||
class Windows(unittest.TestCase):
|
||||
"""The half of the Windows install the setup program cannot do.
|
||||
|
||||
It writes the Start Menu entry, the command and the uninstaller as it runs,
|
||||
and asks once whether Dikte should start at sign-in. Changing that answer
|
||||
afterwards is what is left here, and the value is faked rather than the
|
||||
registry, so that all of it is read on every platform the tests run on.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
self.value = ""
|
||||
for name, function in (("_run_entry", lambda: self.value),
|
||||
("_write_run_entry", self._write),
|
||||
("_delete_run_entry", self._delete)):
|
||||
patch = mock.patch.object(integrate, name, function)
|
||||
patch.start()
|
||||
self.addCleanup(patch.stop)
|
||||
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.tmp.cleanup)
|
||||
self.installed = pathlib.Path(self.tmp.name).resolve()
|
||||
self.app = self.installed / "Dikte.exe"
|
||||
self.app.write_text("")
|
||||
|
||||
def _write(self, command):
|
||||
self.value = command
|
||||
|
||||
def _delete(self):
|
||||
there, self.value = bool(self.value), ""
|
||||
return there
|
||||
|
||||
def install(self, force=False):
|
||||
with Frozen(str(self.app), platform="win32"):
|
||||
return integrate.install(force=force)
|
||||
|
||||
def remove(self):
|
||||
with Frozen(str(self.app), platform="win32"):
|
||||
return integrate.remove()
|
||||
|
||||
def test_the_windowed_executable_is_what_starts_at_sign_in(self):
|
||||
"""The console one is what the `dikte` command runs, and a sign-in that
|
||||
started that would open a console window nobody asked for."""
|
||||
with Frozen(str(self.installed / "dikte-cli.exe"), platform="win32"):
|
||||
self.assertEqual(integrate.target(), self.app)
|
||||
|
||||
def test_a_start_does_not_turn_it_on_for_somebody_who_said_no(self):
|
||||
self.assertEqual(self.install(), [])
|
||||
self.assertEqual(self.value, "")
|
||||
|
||||
def test_typing_it_turns_starting_at_sign_in_on(self):
|
||||
self.assertEqual(len(self.install(force=True)), 1)
|
||||
self.assertEqual(self.value, f'"{self.app}"')
|
||||
|
||||
def test_an_installation_that_moved_is_pointed_at_where_it_is_now(self):
|
||||
self.value = '"D:\\Dikte\\Dikte.exe"'
|
||||
self.assertEqual(len(self.install()), 1)
|
||||
self.assertEqual(self.value, f'"{self.app}"')
|
||||
|
||||
def test_running_it_again_changes_nothing(self):
|
||||
self.install(force=True)
|
||||
self.assertEqual(self.install(), [])
|
||||
|
||||
def test_removing_stops_it_starting_and_says_so_once(self):
|
||||
self.install(force=True)
|
||||
self.assertEqual(len(self.remove()), 1)
|
||||
self.assertEqual(self.value, "")
|
||||
self.assertEqual(self.remove(), [])
|
||||
|
||||
|
||||
class WindowsExecutableNames(unittest.TestCase):
|
||||
"""The two Windows executables, read out of the files that name them.
|
||||
|
||||
Windows matches a filename without regard to its case, so Dikte.exe and
|
||||
dikte.exe are one file in one directory and whichever was written second is
|
||||
the only one installed. Nothing else here would catch that: these tests and
|
||||
the builds that check the packaging both run on filesystems where the two
|
||||
names are two files.
|
||||
"""
|
||||
|
||||
root = pathlib.Path(__file__).resolve().parent.parent
|
||||
|
||||
def executables(self):
|
||||
"""What the spec calls each one, in the order it builds them."""
|
||||
spec = (self.root / "packaging" / "dikte.spec").read_text()
|
||||
return [re.search(r'name="(.*?)"', block).group(1)
|
||||
for block in spec.split("EXE(")[1:]]
|
||||
|
||||
def test_the_two_are_more_than_a_case_apart(self):
|
||||
windowed, console = self.executables()
|
||||
self.assertNotEqual(windowed.lower(), console.lower())
|
||||
|
||||
def test_the_command_runs_the_console_one(self):
|
||||
"""The setup writes the shim, so it is the setup that has to be right."""
|
||||
console = self.executables()[1]
|
||||
setup = (self.root / "packaging" / "dikte.iss").read_text()
|
||||
self.assertIn(f"{{app}}\\{console}.exe", setup)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -7,6 +7,8 @@ test blends each icon onto a bar of its own and asks whether anything of it
|
||||
survives, once over black and once over white.
|
||||
"""
|
||||
|
||||
import pathlib
|
||||
import struct
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
@@ -150,6 +152,26 @@ class ApplicationIcon(DikteTest):
|
||||
self.assertFalse(icon.isNull())
|
||||
self.assertIn(48, [size.width() for size in icon.availableSizes()])
|
||||
|
||||
def test_the_windows_icon_is_one_file_holding_every_size(self):
|
||||
"""Written by hand, so the header is what a test can be wrong about:
|
||||
Windows reads the sizes out of the directory at the front rather than
|
||||
out of the images, and a 256 is written there as a zero."""
|
||||
with tempfile.TemporaryDirectory() as root:
|
||||
path = trayicon.write_ico(pathlib.Path(root) / "Dikte.ico")
|
||||
data = path.read_bytes()
|
||||
reserved, kind, count = struct.unpack_from("<HHH", data)
|
||||
self.assertEqual((reserved, kind), (0, 1))
|
||||
self.assertEqual(count, len(trayicon.ICO_SIZES))
|
||||
for index, size in enumerate(trayicon.ICO_SIZES):
|
||||
with self.subTest(size=size):
|
||||
(width, height, _, _, _, _,
|
||||
length, offset) = struct.unpack_from("<BBBBHHII", data,
|
||||
6 + 16 * index)
|
||||
self.assertEqual((width, height), (size % 256, size % 256))
|
||||
image = QImage.fromData(data[offset:offset + length])
|
||||
self.assertEqual((image.width(), image.height()),
|
||||
(size, size))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
Reference in New Issue
Block a user