From 23d56acfb57f73733eaa338a9eeab7c87295219f Mon Sep 17 00:00:00 2001 From: yusufipk Date: Tue, 18 Aug 2026 16:08:47 +0300 Subject: [PATCH] Publish a Windows setup beside the AppImage and the disk image The releases page had nothing for Windows, so the only way in was a checkout, a Python and a pip install. What goes out now is one setup program per release: PyInstaller's directory, the pinned ffmpeg the disk image already uses, and Inno Setup around both. It installs for the account alone, so no administrator is asked for. Two executables over the one program there, because a windowed one on Windows has no standard output at all: Dikte.exe for the Start Menu and dikte.exe for the terminal, sharing everything they carry. The icon is drawn by Dikte itself into an .ico, the way the Mac's .icns and Linux's PNGs already are, so there is still no image file in the repository. Starting at sign-in is a registry value rather than a Startup shortcut, which is what lets the setup program, the uninstaller and `dikte integrate` all mean the same thing: the wizard asks once, and typing the command changes the answer later. The three builds move into build.yml, which release.yml now calls instead of holding its own copy, and which a pull request touching the packaging runs on its own. A broken build is then a red pull request rather than a failed release. --- .github/workflows/build.yml | 112 ++++++++++++++++++++++++++++++++ .github/workflows/release.yml | 76 ++++------------------ .github/workflows/tests.yml | 17 +++-- CONTRIBUTING.md | 10 +-- README.md | 35 +++++----- README.tr.md | 34 +++++----- README.windows.md | 16 ++++- dikte/cli.py | 10 ++- dikte/integrate.py | 106 ++++++++++++++++++++++++++++-- dikte/ipc.py | 12 ++++ dikte/trayicon.py | 53 +++++++++++++-- packaging/build-windows.ps1 | 89 ++++++++++++++++++++++++++ packaging/dikte.iss | 117 ++++++++++++++++++++++++++++++++++ packaging/dikte.spec | 45 ++++++++++--- packaging/entry.py | 5 +- tests/test_integrate.py | 69 ++++++++++++++++++++ tests/test_trayicon.py | 22 +++++++ 17 files changed, 696 insertions(+), 132 deletions(-) create mode 100644 .github/workflows/build.yml create mode 100644 packaging/build-windows.ps1 create mode 100644 packaging/dikte.iss diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..1416b82 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,112 @@ +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' + 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: 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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6ae61d0..d6689ed 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -107,70 +107,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 +153,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 ) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 5a5ff42..89ee656 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -107,11 +107,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 } + } diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c364c89..b925daf 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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, +1094 of the 1157 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. diff --git a/README.md b/README.md index 75e40de..c195318 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/README.tr.md b/README.tr.md index 642e37f..65a50d1 100644 --- a/README.tr.md +++ b/README.tr.md @@ -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 diff --git a/README.windows.md b/README.windows.md index 05adcf6..33bb63d 100644 --- a/README.windows.md +++ b/README.windows.md @@ -5,15 +5,25 @@ 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--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. + +From a checkout instead: ```powershell powershell -ExecutionPolicy Bypass -File install.ps1 diff --git a/dikte/cli.py b/dikte/cli.py index 78efeef..8bfcf56 100644 --- a/dikte/cli.py +++ b/dikte/cli.py @@ -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) diff --git a/dikte/integrate.py b/dikte/integrate.py index e5a2477..a55fcf3 100644 --- a/dikte/integrate.py +++ b/dikte/integrate.py @@ -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 [] diff --git a/dikte/ipc.py b/dikte/ipc.py index 823799b..02f668a 100644 --- a/dikte/ipc.py +++ b/dikte/ipc.py @@ -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] diff --git a/dikte/trayicon.py b/dikte/trayicon.py index cce525b..87cc628 100644 --- a/dikte/trayicon.py +++ b/dikte/trayicon.py @@ -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(".iconset` for install-mac.sh, `--hicolor ` for - install.sh. + install.sh, `--ico ` 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 .iconset\n" - " trayicon.py --hicolor ", file=sys.stderr) + " trayicon.py --hicolor \n" + " trayicon.py --ico .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: diff --git a/packaging/build-windows.ps1 b/packaging/build-windows.ps1 new file mode 100644 index 0000000..9b64b96 --- /dev/null +++ b/packaging/build-windows.ps1 @@ -0,0 +1,89 @@ +#!/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.exe, which has a console and is what the +# `dikte` command runs. +& 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" } + +# 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" diff --git a/packaging/dikte.iss b/packaging/dikte.iss new file mode 100644 index 0000000..a727efa --- /dev/null +++ b/packaging/dikte.iss @@ -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 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.exe') + '" %*' + #13#10; + SaveStringToFile(ShimPath(), Shim, False); + end; + end; +end; + +procedure CurUninstallStepChanged(CurUninstallStep: TUninstallStep); +begin + if CurUninstallStep = usUninstall then + DeleteFile(ShimPath()); +end; diff --git a/packaging/dikte.spec b/packaging/dikte.spec index 68d4f15..6df6534 100644 --- a/packaging/dikte.spec +++ b/packaging/dikte.spec @@ -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,41 @@ 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. +console_executable = EXE( # noqa: F821 + archive, + analysis.scripts, + [], + exclude_binaries=True, + name="dikte", + 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", diff --git a/packaging/entry.py b/packaging/entry.py index 94c2a04..a20c60d 100644 --- a/packaging/entry.py +++ b/packaging/entry.py @@ -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 diff --git a/tests/test_integrate.py b/tests/test_integrate.py index 46636a9..999f115 100644 --- a/tests/test_integrate.py +++ b/tests/test_integrate.py @@ -451,5 +451,74 @@ 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.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(), []) + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_trayicon.py b/tests/test_trayicon.py index a257781..bfe3cc5 100644 --- a/tests/test_trayicon.py +++ b/tests/test_trayicon.py @@ -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("