Merge pull request #48 from yusufipk/windows-setup

Publish a Windows setup beside the AppImage and the disk image
This commit is contained in:
Yusuf İpek
2026-08-19 15:57:19 +03:00
committed by GitHub
17 changed files with 770 additions and 137 deletions
+114
View File
@@ -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
+17 -63
View File
@@ -46,9 +46,11 @@ jobs:
- uses: actions/setup-python@v5 - uses: actions/setup-python@v5
with: with:
python-version: "3.12" python-version: "3.12"
- run: | - timeout-minutes: 5
sudo apt-get update run: |
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 \ libegl1 libgl1 libxkbcommon0 libdbus-1-3 libglib2.0-0 \
libfontconfig1 libfreetype6 libgssapi-krb5-2 libfontconfig1 libfreetype6 libgssapi-krb5-2
- run: python -m pip install --quiet PyQt6 - run: python -m pip install --quiet PyQt6
@@ -107,70 +109,16 @@ jobs:
esac esac
echo "version=$version" >> "$GITHUB_OUTPUT" 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: build:
needs: version needs: version
strategy: uses: ./.github/workflows/build.yml
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: with:
ref: ${{ needs.version.outputs.ref }} ref: ${{ needs.version.outputs.ref }}
version: ${{ needs.version.outputs.prerelease == 'true' && needs.version.outputs.version || '' }}
- 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
publish: publish:
needs: [version, build] needs: [version, build]
@@ -207,6 +155,12 @@ jobs:
Accessibility permissions, which macOS asks for the first time each is Accessibility permissions, which macOS asks for the first time each is
used. It asks again after an update, because an application signed with used. It asks again after an update, because an application signed with
no certificate is one macOS has never seen before. 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 EOF
) )
+18 -5
View File
@@ -24,10 +24,18 @@ jobs:
# for the offscreen platform the tests run on. QtNetwork wants the Kerberos # for the offscreen platform the tests run on. QtNetwork wants the Kerberos
# library, and the widgets want fontconfig, whether or not anything is # library, and the widgets want fontconfig, whether or not anything is
# ever drawn. # 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 - name: Install the Qt runtime libraries
timeout-minutes: 5
run: | run: |
sudo apt-get update apt="-o Acquire::Retries=3 -o Acquire::http::Timeout=20"
sudo apt-get install --no-install-recommends -y \ sudo apt-get $apt update
sudo apt-get $apt install --no-install-recommends -y \
libegl1 libgl1 libxkbcommon0 libdbus-1-3 libglib2.0-0 \ libegl1 libgl1 libxkbcommon0 libdbus-1-3 libglib2.0-0 \
libfontconfig1 libfreetype6 libgssapi-krb5-2 libfontconfig1 libfreetype6 libgssapi-krb5-2
@@ -107,11 +115,16 @@ jobs:
run: python -m unittest discover --verbose run: python -m unittest discover --verbose
# What the Mac does for its installer, in the language this one is in. # 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. # Parsing only: install.ps1 writes into the Start Menu and the user PATH,
- name: Check the installer parses # 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 shell: pwsh
run: | run: |
foreach ($script in "install.ps1", "packaging/build-windows.ps1") {
$problems = $null $problems = $null
[System.Management.Automation.Language.Parser]::ParseFile( [System.Management.Automation.Language.Parser]::ParseFile(
"$PWD/install.ps1", [ref]$null, [ref]$problems) > $null "$PWD/$script", [ref]$null, [ref]$problems) > $null
if ($problems) { $problems; exit 1 } if ($problems) { $problems; exit 1 }
}
+6 -4
View File
@@ -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. that lists them is not installed.
The tests are split along the same line, and almost none of them are skipped. 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 macOS and Windows backends: the programs are faked at `shutil.which`, the
frameworks and system libraries at the one function that loads them 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 (`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 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 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 `@posix_only` are the half of `integrate.py` that writes files, the menu entry
build writes for itself: there are two downloads, an AppImage and a disk image, and the login item a downloaded build puts down for itself, which want a home
so that module has no Windows half for a Windows host to check. Mark a test 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 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. stops running on the platform you are porting to protects nothing.
+20 -15
View File
@@ -24,15 +24,17 @@ library, 3.11 or newer, and PyQt6.
## Install ## Install
The [releases page](../../releases) has an AppImage and a disk image per Mac The [releases page](../../releases) has an AppImage, a disk image per Mac
architecture. Both write their own menu entry, login item and `dikte` command architecture and a Windows setup. The first two write their own menu entry,
the first time they run, and stand aside for an installation already on the login item and `dikte` command the first time they run, and stand aside for an
machine; `dikte integrate --remove` takes them back. The AppImage still wants installation already on the machine; `dikte integrate --remove` takes them
the system packages below, for the sound server, the clipboard and the back. The AppImage still wants the system packages below, for the sound
keyboard. The disk image is signed with no Apple certificate, so the first server, the clipboard and the keyboard. The disk image is signed with no Apple
launch is refused until you press **Open Anyway** under System Settings → certificate, so the first launch is refused until you press **Open Anyway**
Privacy & Security, and macOS asks for the microphone and Accessibility again under System Settings → Privacy & Security, and macOS asks for the microphone
after each update; installing from a checkout is what avoids that. 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 ```sh
sudo pacman -S --needed pipewire-audio wl-clipboard ydotool ffmpeg python-pyqt6 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. (`brew install blackhole-2ch`); dictation does not.
Windows works the same way, holding the keys through the system's own hotkey 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`, service while Dikte runs. The setup on the releases page carries the ffmpeg
then `python -m dikte`, with an optional `install.ps1` for the Start Menu entry recording needs and asks for no administrator; from a checkout it is `winget
and the `dikte` command. Meetings are not supported there yet; the details are install Gyan.FFmpeg`, `pip install PyQt6`, then `python -m dikte`, with an
in the [Windows README](README.windows.md). 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 `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 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` 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. 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; `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 `packaging/` builds the AppImage, the disk image and the Windows setup that
publishes; install.sh stays at the top, and `tests/` has a file per module. 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 app.py entry point, tray icon, state machine
+18 -16
View File
@@ -23,15 +23,16 @@ Python standart kütüphanesi (3.11 veya üstü) ve PyQt6.
## Kurulum ## Kurulum
[Sürümler sayfasında](../../releases) bir AppImage, bir de her Mac mimarisi [Sürümler sayfasında](../../releases) bir AppImage, her Mac mimarisi için
için birer disk imajı var. İkisi de ilk çalıştıklarında kendi menü girdisini, birer disk imajı, bir de Windows kurulumu var. İlk ikisi ilk çalıştıklarında
oturum açılışını ve `dikte` komutunu yazar, makinede zaten duran bir kuruluma kendi menü girdisini, oturum açılışını ve `dikte` komutunu yazar, makinede
dokunmazlar; `dikte integrate --remove` yazdıklarını geri alır. AppImage yine zaten duran bir kuruluma dokunmazlar; `dikte integrate --remove` yazdıklarını
de aşağıdaki sistem paketlerini ister: ses sunucusu, pano ve klavye onlardan geri alır. AppImage yine de aşağıdaki sistem paketlerini ister: ses sunucusu,
gelir. Disk imajı bir Apple sertifikasıyla imzalı değil, bu yüzden ilk açılış pano ve klavye onlardan gelir. Disk imajı bir Apple sertifikasıyla imzalı
reddedilir, Sistem Ayarları → Gizlilik ve Güvenlik altından **Yine de Aç** değil, bu yüzden ilk açılış reddedilir, Sistem Ayarları → Gizlilik ve Güvenlik
demek gerekir; macOS her güncellemeden sonra mikrofonu ve Erişilebilirliği altından **Yine de Aç** demek gerekir; macOS her güncellemeden sonra mikrofonu
yeniden sorar, checkout'tan kurmak bundan kurtarır. 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 ```sh
sudo pacman -S --needed pipewire-audio wl-clipboard ydotool ffmpeg python-pyqt6 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. gerekmiyor.
Windows da aynı şekilde çalışıyor, Dikte açıkken kombinasyonu sistemin kendi Windows da aynı şekilde çalışıyor, Dikte açıkken kombinasyonu sistemin kendi
kısayol servisi üzerinden tutuyor: `winget install Gyan.FFmpeg`, `pip install kısayol servisi üzerinden tutuyor. Sürümler sayfasındaki kurulum kaydın
PyQt6`, sonra `python -m dikte`; Başlat Menüsü girdisi ve `dikte` komutu için istediği ffmpeg'i de taşıyor ve yönetici istemiyor; checkout'tan ise `winget
isteğe bağlı `install.ps1`. Orada toplantı kaydı henüz yok, ayrıntılar install Gyan.FFmpeg`, `pip install PyQt6`, sonra `python -m dikte`, Başlat
[Windows README](README.windows.md)'sinde. 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 `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 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, 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. 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; `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 `packaging/` release.sh'ın attığı etiketin yayımladığı AppImage'i, disk imajını
imajını derler; install.sh en üstte kalır, `tests/` içinde de her modülün bir ve Windows kurulumunu derler; install.sh en üstte kalır, `tests/` içinde de her
dosyası. modülün bir dosyası.
``` ```
app.py giriş noktası, tepsi simgesi, durum makinesi app.py giriş noktası, tepsi simgesi, durum makinesi
+15 -3
View File
@@ -5,15 +5,27 @@ up and pasted where your cursor is.
## Requirements ## 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 - **Python 3.11+** with **PyQt6** (`pip install PyQt6`; install.ps1 installs
it when it is missing) it when it is missing)
- **ffmpeg** for microphone capture: `winget install Gyan.FFmpeg` - **ffmpeg** for microphone capture: `winget install Gyan.FFmpeg`
## Installing ## Installing
From a checkout: the releases page carries an AppImage and a disk image, and no `Dikte-<version>-x64-setup.exe` from the releases page installs for your
Windows build yet. 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
powershell -ExecutionPolicy Bypass -File install.ps1 powershell -ExecutionPolicy Bypass -File install.ps1
+7 -3
View File
@@ -777,11 +777,14 @@ def cmd_integrate(opts):
Run for you on every start, so this is for the two cases that start does 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 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(): 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; " 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: try:
# force, because typing this is asking for it outright, where the same # force, because typing this is asking for it outright, where the same
# call on every start stands aside for an installation already there. # call on every start stands aside for an installation already there.
@@ -1088,7 +1091,8 @@ def build_parser():
remove.set_defaults(func=cmd_shortcut) remove.set_defaults(func=cmd_shortcut)
integrated = leaf(subs, "integrate", 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", integrated.add_argument("--remove", action="store_true",
help="take them away again") help="take them away again")
integrated.set_defaults(func=cmd_integrate) integrated.set_defaults(func=cmd_integrate)
+101 -5
View File
@@ -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 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. 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 Nothing here runs from a checkout. install.sh has already written the same
files there, pointing at the interpreter that checkout was installed against, files there, pointing at the interpreter that checkout was installed against,
and overwriting them with a guess would be a downgrade. and overwriting them with a guess would be a downgrade.
@@ -35,6 +41,14 @@ AGENT_ID = "io.github.yusufipk.dikte"
ICON_NAME = "dikte" ICON_NAME = "dikte"
DESKTOP_FILE = "dikte.desktop" DESKTOP_FILE = "dikte.desktop"
MACOS_COMMAND_MARKER = "# Written by Dikte itself. Delete it to be rid of it.\n" 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(): def packaged():
@@ -56,6 +70,13 @@ def target():
for parent in executable.parents: for parent in executable.parents:
if parent.suffix == ".app": if parent.suffix == ".app":
return parent 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 return executable
@@ -152,11 +173,12 @@ def use_system_certificates():
def bundled_bin(): def bundled_bin():
"""Where a build keeps the helper programs it carries, if it carries any. """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 The disk image and the Windows setup both ship an ffmpeg, because both
nothing like it preinstalled, so a Mac that downloaded Dikte and nothing systems record through one and neither has anything like it preinstalled,
else would otherwise not be able to record at all. The AppImage carries so a machine that downloaded Dikte and nothing else would otherwise not be
none: Linux records through parec or pw-record, which come with the sound able to record at all. The AppImage carries none: Linux records through
server, and the distributions all package ffmpeg for the rest. 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 binary = pathlib.Path(sys.executable).parent
if sys.platform == "darwin" and binary.name == "MacOS": if sys.platform == "darwin" and binary.name == "MacOS":
@@ -210,6 +232,8 @@ def install(force=False):
""" """
if sys.platform == "darwin": if sys.platform == "darwin":
return _macos_install(target(), force) return _macos_install(target(), force)
if sys.platform == "win32":
return _windows_install(target(), force)
return _linux_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.""" """Take them away again. The paths that were there to delete."""
if sys.platform == "darwin": if sys.platform == "darwin":
return _macos_remove() return _macos_remove()
if sys.platform == "win32":
return _windows_remove()
return _linux_remove() return _linux_remove()
@@ -500,3 +526,73 @@ def _launchctl_reload(agent):
capture_output=True, check=False) capture_output=True, check=False)
subprocess.run(["launchctl", "bootstrap", f"gui/{os.getuid()}", str(agent)], subprocess.run(["launchctl", "bootstrap", f"gui/{os.getuid()}", str(agent)],
capture_output=True, check=False) 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 []
+12
View File
@@ -15,6 +15,8 @@ import sys
from PyQt6.QtNetwork import QLocalSocket from PyQt6.QtNetwork import QLocalSocket
from . import integrate
SERVER_NAME = "dikte-" + ( SERVER_NAME = "dikte-" + (
str(os.getuid()) if hasattr(os, "getuid") str(os.getuid()) if hasattr(os, "getuid")
else os.environ.get("USERNAME", "user")) 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 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 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. 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): if not getattr(sys, "frozen", False):
return [sys.executable, script_path()] 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] return [os.environ.get("APPIMAGE") or sys.executable]
+47 -6
View File
@@ -23,9 +23,10 @@ outwards, which stands out on a dark bar and stays readable on a light one.
""" """
import pathlib import pathlib
import struct
import sys 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, from PyQt6.QtGui import (QColor, QIcon, QLinearGradient, QPainter, QPainterPath,
QPen, QPixmap) 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 # 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. # file dialog 16, and something scaling for a HiDPI panel wants the big ones.
HICOLOR_SIZES = (16, 22, 24, 32, 48, 64, 128, 256) 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): def app_pixmap(size):
@@ -292,26 +296,63 @@ def write_hicolor(directory, name="dikte"):
return written 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): def _main(argv):
"""`trayicon.py <path>.iconset` for install-mac.sh, `--hicolor <dir>` for """`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 A QGuiApplication has to exist before a QPixmap can, and offscreen because
this runs from a shell script with no window to open. this runs from a shell script with no window to open.
""" """
hicolor = len(argv) == 3 and argv[1] == "--hicolor" flag = argv[1] if len(argv) == 3 else ""
if not hicolor and len(argv) != 2: if flag not in ("--hicolor", "--ico") and len(argv) != 2:
print("usage: trayicon.py <directory>.iconset\n" 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 return 2
from PyQt6.QtGui import QGuiApplication from PyQt6.QtGui import QGuiApplication
QGuiApplication.setAttribute( QGuiApplication.setAttribute(
Qt.ApplicationAttribute.AA_UseSoftwareOpenGL, True) Qt.ApplicationAttribute.AA_UseSoftwareOpenGL, True)
app = QGuiApplication(["dikte-icon", "-platform", "offscreen"]) app = QGuiApplication(["dikte-icon", "-platform", "offscreen"])
try: try:
if hicolor: if flag == "--hicolor":
for path in write_hicolor(argv[2]): for path in write_hicolor(argv[2]):
print(path) print(path)
elif flag == "--ico":
print(write_ico(argv[2]))
else: else:
print(write_iconset(argv[1])) print(write_iconset(argv[1]))
finally: finally:
+109
View File
@@ -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"
+117
View File
@@ -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
View File
@@ -1,12 +1,14 @@
# PyInstaller's description of the build, shared by the AppImage and the disk # PyInstaller's description of the build, shared by the AppImage, the disk
# image. Run it through build-appimage.sh or build-dmg.sh rather than by hand: # image and the Windows setup. Run it through build-appimage.sh, build-dmg.sh
# each of those has a few steps of its own on either side of this. # 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 # A directory rather than a single file, on all three. Onefile unpacks itself
# itself into /tmp on every start, which for something a global shortcut is # into a temporary directory on every start, which for something a global
# meant to bring up is a second of nothing happening, and for the AppImage it # shortcut is meant to bring up is a second of nothing happening, and for the
# would be an unpacking inside an unpacking. The single file people download is # AppImage it would be an unpacking inside an unpacking. The single file people
# the AppImage and the .dmg; this only has to be tidy inside them. # download is the AppImage, the .dmg and the setup program; this only has to be
# tidy inside them.
import os import os
import pathlib import pathlib
@@ -24,6 +26,7 @@ __version__ = re.search(r'^__version__ = "(.*)"$',
re.M).group(1) re.M).group(1)
MACOS = sys.platform == "darwin" MACOS = sys.platform == "darwin"
WINDOWS = sys.platform == "win32"
BUNDLE_ID = "io.github.yusufipk.dikte" BUNDLE_ID = "io.github.yusufipk.dikte"
# PyQt6's wheel is most of the build, and most of the wheel is modules nothing # 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, analysis.scripts,
[], [],
exclude_binaries=True, exclude_binaries=True,
name="Dikte" if MACOS else "dikte", name="Dikte" if MACOS or WINDOWS else "dikte",
console=False, 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. # cross-compiled: the workflow runs one job per architecture.
target_arch=None, target_arch=None,
# Ad-hoc, and only on a Mac, where an arm64 binary that carries no # 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 # 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. # about. build-dmg.sh signs the finished bundle over the top of this.
codesign_identity="-" if MACOS else None, 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 collection = COLLECT( # noqa: F821
executable, executable,
*([console_executable] if WINDOWS else []),
analysis.binaries, analysis.binaries,
analysis.datas, analysis.datas,
name="dikte", name="dikte",
+3 -2
View File
@@ -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 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 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. 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 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 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, 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 opens a connection or reaches for ffmpeg, and before is easier to be sure of
+99
View File
@@ -9,6 +9,7 @@ has to notice AppImageLauncher's entry, which is not under the name ours is.
import os import os
import pathlib import pathlib
import plistlib import plistlib
import re
import sys import sys
import tempfile import tempfile
import unittest import unittest
@@ -451,5 +452,103 @@ class MacOS(Home):
self.assertIn("install-mac.sh", command.read_text()) 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__": if __name__ == "__main__":
unittest.main() unittest.main()
+22
View File
@@ -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. survives, once over black and once over white.
""" """
import pathlib
import struct
import sys import sys
import tempfile import tempfile
import unittest import unittest
@@ -150,6 +152,26 @@ class ApplicationIcon(DikteTest):
self.assertFalse(icon.isNull()) self.assertFalse(icon.isNull())
self.assertIn(48, [size.width() for size in icon.availableSizes()]) 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__": if __name__ == "__main__":
unittest.main() unittest.main()