mirror of
https://github.com/yusufipk/dikte.git
synced 2026-09-11 10:56:10 +00:00
Merge pull request #48 from yusufipk/windows-setup
Publish a Windows setup beside the AppImage and the disk image
This commit is contained in:
@@ -0,0 +1,114 @@
|
|||||||
|
name: build
|
||||||
|
|
||||||
|
# The three downloads, in one place. release.yml calls this one rather than
|
||||||
|
# holding a copy of it, and a pull request that touches the packaging runs it
|
||||||
|
# on its own, because the alternative is finding out that a build is broken
|
||||||
|
# from the release that was supposed to publish it.
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_call:
|
||||||
|
inputs:
|
||||||
|
ref:
|
||||||
|
description: what to check out; the caller's own ref when empty
|
||||||
|
type: string
|
||||||
|
required: false
|
||||||
|
default: ""
|
||||||
|
version:
|
||||||
|
description: write this version into the tree before building
|
||||||
|
type: string
|
||||||
|
required: false
|
||||||
|
default: ""
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- packaging/**
|
||||||
|
- .github/workflows/build.yml
|
||||||
|
- dikte/integrate.py
|
||||||
|
- dikte/trayicon.py
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
# The oldest Ubuntu still offered, because the glibc a build links
|
||||||
|
# against is the oldest one it will run on, and 22.04's covers every
|
||||||
|
# distribution released since. Move it up only when it goes away.
|
||||||
|
- os: ubuntu-22.04
|
||||||
|
kind: appimage
|
||||||
|
- os: macos-latest
|
||||||
|
kind: dmg
|
||||||
|
# Intel Macs. This runner is the last x86_64 image Actions will
|
||||||
|
# offer, and it goes away in August 2027.
|
||||||
|
- os: macos-15-intel
|
||||||
|
kind: dmg
|
||||||
|
# x64 only, which is what the PyQt6 wheel and whisper.cpp both
|
||||||
|
# publish for Windows; a machine on ARM runs the result emulated,
|
||||||
|
# the same way it runs everything else that was never built for it.
|
||||||
|
- os: windows-latest
|
||||||
|
kind: windows
|
||||||
|
|
||||||
|
runs-on: ${{ matrix.os }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
ref: ${{ inputs.ref }}
|
||||||
|
|
||||||
|
- uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: "3.12"
|
||||||
|
|
||||||
|
# PyQt6 ships Qt itself, but Qt still loads these from the system, and
|
||||||
|
# the build draws its own icon before it packages anything.
|
||||||
|
- name: Install the Qt runtime libraries
|
||||||
|
if: runner.os == 'Linux'
|
||||||
|
timeout-minutes: 5
|
||||||
|
run: |
|
||||||
|
apt="-o Acquire::Retries=3 -o Acquire::http::Timeout=20"
|
||||||
|
sudo apt-get $apt update
|
||||||
|
sudo apt-get $apt install --no-install-recommends -y \
|
||||||
|
libegl1 libgl1 libxkbcommon0 libdbus-1-3 libglib2.0-0 \
|
||||||
|
libfontconfig1 libfreetype6 libgssapi-krb5-2
|
||||||
|
|
||||||
|
- name: Install PyQt6 and PyInstaller
|
||||||
|
run: python -m pip install --quiet PyQt6 pyinstaller
|
||||||
|
|
||||||
|
# Only for the builds off master: a tagged build already says the number
|
||||||
|
# it was tagged with, and rewriting it would be rewriting the tag.
|
||||||
|
- name: Write the version being built
|
||||||
|
if: inputs.version
|
||||||
|
# Named, because the Windows runner's own shell is PowerShell and this
|
||||||
|
# is a here-document.
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
VERSION: ${{ inputs.version }}
|
||||||
|
run: |
|
||||||
|
python - <<'PY'
|
||||||
|
import os, pathlib, re
|
||||||
|
path = pathlib.Path("dikte/__init__.py")
|
||||||
|
path.write_text(re.sub(r'^__version__ = ".*"$',
|
||||||
|
f'__version__ = "{os.environ["VERSION"]}"',
|
||||||
|
path.read_text(), flags=re.M))
|
||||||
|
PY
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
if: runner.os != 'Windows'
|
||||||
|
run: ./packaging/build-${{ matrix.kind }}.sh
|
||||||
|
|
||||||
|
# The same steps as the other two, in the language the platform already
|
||||||
|
# has: drawing the icon, PyInstaller, and wrapping the result in what
|
||||||
|
# people download.
|
||||||
|
- name: Build
|
||||||
|
if: runner.os == 'Windows'
|
||||||
|
shell: pwsh
|
||||||
|
run: ./packaging/build-${{ matrix.kind }}.ps1
|
||||||
|
|
||||||
|
- uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: dikte-${{ matrix.os }}
|
||||||
|
path: dist/*
|
||||||
|
if-no-files-found: error
|
||||||
@@ -46,9 +46,11 @@ jobs:
|
|||||||
- uses: actions/setup-python@v5
|
- 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
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 []
|
||||||
|
|||||||
@@ -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
@@ -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:
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
#!/usr/bin/env pwsh
|
||||||
|
# The Windows download: one setup program, carrying everything Dikte needs to
|
||||||
|
# record and to be started again after a sign-in.
|
||||||
|
#
|
||||||
|
# Run from anywhere; it works in build\ at the top of the checkout and leaves
|
||||||
|
# the finished .exe in dist\. x64 only, because that is what the PyQt6 wheel and
|
||||||
|
# whisper.cpp both publish for Windows; a Windows on ARM machine runs it under
|
||||||
|
# the emulation it runs everything else under.
|
||||||
|
#
|
||||||
|
# powershell -ExecutionPolicy Bypass -File packaging\build-windows.ps1
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
$root = Split-Path -Parent $PSScriptRoot
|
||||||
|
$build = Join-Path $root "build"
|
||||||
|
$out = Join-Path $root "dist"
|
||||||
|
$dist = Join-Path $build "dist\dikte"
|
||||||
|
|
||||||
|
$env:PYTHONPATH = $root
|
||||||
|
$version = & python -c "import dikte; print(dikte.__version__)"
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "could not read the version out of dikte/__init__.py" }
|
||||||
|
|
||||||
|
# A pinned tag and a checksum rather than "whatever is newest": this binary goes
|
||||||
|
# out inside something people run, so what it is has to be decided here and not
|
||||||
|
# by whoever pushes to that repository next. The same release the disk image
|
||||||
|
# takes its ffmpeg from, which is gyan.dev's essentials build repackaged, and
|
||||||
|
# dshow is in it, which is the one part of ffmpeg recording here goes through.
|
||||||
|
$ffmpegTag = "b6.1.1"
|
||||||
|
$ffmpegAsset = "ffmpeg-win32-x64.gz"
|
||||||
|
$ffmpegSha = "8883A3DFFBD0A16CF4EF95206EA05283F78908DBFB118F73C83F4951DCC06D77"
|
||||||
|
|
||||||
|
if (Test-Path $build) { Remove-Item $build -Recurse -Force }
|
||||||
|
if (Test-Path $out) { Remove-Item $out -Recurse -Force }
|
||||||
|
New-Item -ItemType Directory -Path $build, $out | Out-Null
|
||||||
|
|
||||||
|
# 1. The icon ---------------------------------------------------------------
|
||||||
|
# Drawn by Dikte itself, offscreen, which is why there is no image file in the
|
||||||
|
# repository. Before the application, because PyInstaller writes it into the
|
||||||
|
# executable rather than beside it, and the setup program uses the same file.
|
||||||
|
$icon = Join-Path $build "Dikte.ico"
|
||||||
|
$env:QT_QPA_PLATFORM = "offscreen"
|
||||||
|
& python -m dikte.trayicon --ico $icon
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "the icon would not draw" }
|
||||||
|
Remove-Item Env:\QT_QPA_PLATFORM
|
||||||
|
$env:DIKTE_ICO = $icon
|
||||||
|
|
||||||
|
# 2. The application --------------------------------------------------------
|
||||||
|
# Two executables in the one directory: Dikte.exe, which is windowed and is
|
||||||
|
# what a shortcut starts, and dikte-cli.exe, which has a console and is what
|
||||||
|
# the `dikte` command runs. Their names differ by more than case on purpose;
|
||||||
|
# Windows would otherwise keep one file for both.
|
||||||
|
& python -m PyInstaller (Join-Path $root "packaging\dikte.spec") `
|
||||||
|
--distpath (Join-Path $build "dist") --workpath (Join-Path $build "work") `
|
||||||
|
--noconfirm --clean
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "PyInstaller failed" }
|
||||||
|
|
||||||
|
# And the check that the two are really two. Windows matches a filename
|
||||||
|
# without regard to its case, so a rename that leaves them a case apart puts
|
||||||
|
# one file in the directory and whichever was written second is what both
|
||||||
|
# names find. It cost a windowed build once, silently: the machines the
|
||||||
|
# packaging is otherwise checked on all have case-sensitive filesystems, so
|
||||||
|
# this only ever shows up here. Subsystem 2 is windowed, 3 is a console, and
|
||||||
|
# it is the field the loader reads to decide which to give.
|
||||||
|
function Get-PeSubsystem($path) {
|
||||||
|
$bytes = [System.IO.File]::ReadAllBytes($path)
|
||||||
|
$header = [BitConverter]::ToInt32($bytes, 0x3C)
|
||||||
|
return [BitConverter]::ToUInt16($bytes, $header + 0x5C)
|
||||||
|
}
|
||||||
|
foreach ($pair in @(@("Dikte.exe", 2), @("dikte-cli.exe", 3))) {
|
||||||
|
$subsystem = Get-PeSubsystem (Join-Path $dist $pair[0])
|
||||||
|
if ($subsystem -ne $pair[1]) {
|
||||||
|
throw "$($pair[0]) is subsystem $subsystem, expected $($pair[1]): the two executables collided"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# 3. ffmpeg -----------------------------------------------------------------
|
||||||
|
# Recording on Windows goes through ffmpeg's DirectShow input, and Windows
|
||||||
|
# ships nothing like it, so without this the download would be an application
|
||||||
|
# that cannot record until the person who downloaded it installs one. bin\
|
||||||
|
# beside the executables, because integrate.py puts that directory in front of
|
||||||
|
# PATH at startup and everything reaching for ffmpeg goes through shutil.which.
|
||||||
|
$archive = Join-Path $build $ffmpegAsset
|
||||||
|
Invoke-WebRequest -UseBasicParsing -OutFile $archive `
|
||||||
|
"https://github.com/eugeneware/ffmpeg-static/releases/download/$ffmpegTag/$ffmpegAsset"
|
||||||
|
$got = (Get-FileHash $archive -Algorithm SHA256).Hash
|
||||||
|
if ($got -ne $ffmpegSha) { throw "ffmpeg checksum: expected $ffmpegSha, got $got" }
|
||||||
|
|
||||||
|
$bin = Join-Path $dist "bin"
|
||||||
|
New-Item -ItemType Directory -Path $bin | Out-Null
|
||||||
|
$compressed = [System.IO.File]::OpenRead($archive)
|
||||||
|
$stream = New-Object System.IO.Compression.GzipStream(
|
||||||
|
$compressed, [System.IO.Compression.CompressionMode]::Decompress)
|
||||||
|
$binary = [System.IO.File]::Create((Join-Path $bin "ffmpeg.exe"))
|
||||||
|
try { $stream.CopyTo($binary) } finally { $binary.Dispose(); $stream.Dispose(); $compressed.Dispose() }
|
||||||
|
|
||||||
|
# 4. The setup program ------------------------------------------------------
|
||||||
|
# Inno Setup comes with the GitHub runner. On a machine that has not got it:
|
||||||
|
# winget install JRSoftware.InnoSetup
|
||||||
|
$iscc = (Get-Command iscc -ErrorAction SilentlyContinue).Source
|
||||||
|
if (-not $iscc) {
|
||||||
|
$iscc = Join-Path ${env:ProgramFiles(x86)} "Inno Setup 6\ISCC.exe"
|
||||||
|
}
|
||||||
|
if (-not (Test-Path $iscc)) {
|
||||||
|
throw "no Inno Setup found. Install it with: winget install JRSoftware.InnoSetup"
|
||||||
|
}
|
||||||
|
& $iscc "/DVersion=$version" "/DSource=$dist" "/DIcon=$icon" `
|
||||||
|
(Join-Path $root "packaging\dikte.iss")
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "Inno Setup failed" }
|
||||||
|
|
||||||
|
Write-Host "dist\Dikte-$version-x64-setup.exe"
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
; What the Windows download is: the directory PyInstaller built, wrapped in the
|
||||||
|
; setup program Windows expects. Run it through build-windows.ps1, which draws
|
||||||
|
; the icon, builds that directory, puts an ffmpeg in it and passes the version
|
||||||
|
; in; ISCC on its own has none of that.
|
||||||
|
;
|
||||||
|
; Per user rather than per machine. It keeps the whole thing out of the way of
|
||||||
|
; the administrator prompt, which for something a person is trying out is the
|
||||||
|
; difference between a download and a phone call to whoever owns the laptop,
|
||||||
|
; and nothing here writes outside the account anyway.
|
||||||
|
|
||||||
|
#ifndef Version
|
||||||
|
#define Version "0.0.0"
|
||||||
|
#endif
|
||||||
|
#ifndef Source
|
||||||
|
#define Source "..\build\dist\dikte"
|
||||||
|
#endif
|
||||||
|
#ifndef Icon
|
||||||
|
#define Icon "..\build\Dikte.ico"
|
||||||
|
#endif
|
||||||
|
|
||||||
|
[Setup]
|
||||||
|
; The identifier Add/Remove Programs files this under, and what an update
|
||||||
|
; recognises the older installation by. The same one the Mac's login item and
|
||||||
|
; the bundle use, and like those it never changes.
|
||||||
|
AppId=io.github.yusufipk.dikte
|
||||||
|
AppName=Dikte
|
||||||
|
AppVersion={#Version}
|
||||||
|
AppPublisher=Yusuf Ipek
|
||||||
|
AppSupportURL=https://github.com/yusufipk/dikte
|
||||||
|
DefaultDirName={localappdata}\Programs\Dikte
|
||||||
|
DefaultGroupName=Dikte
|
||||||
|
DisableProgramGroupPage=yes
|
||||||
|
PrivilegesRequired=lowest
|
||||||
|
ArchitecturesAllowed=x64compatible
|
||||||
|
ArchitecturesInstallIn64BitMode=x64compatible
|
||||||
|
OutputDir=..\dist
|
||||||
|
OutputBaseFilename=Dikte-{#Version}-x64-setup
|
||||||
|
SetupIconFile={#Icon}
|
||||||
|
UninstallDisplayIcon={app}\Dikte.exe
|
||||||
|
WizardStyle=modern
|
||||||
|
; Most of the download is Qt and ffmpeg, both of which compress well, and the
|
||||||
|
; slower setting is a minute of a build machine's time against a smaller file
|
||||||
|
; for everybody who downloads it.
|
||||||
|
Compression=lzma2/max
|
||||||
|
SolidCompression=yes
|
||||||
|
; An update over a running Dikte would otherwise fail on the executable it
|
||||||
|
; cannot replace. Restart Manager closes it and starts it again afterwards.
|
||||||
|
CloseApplications=yes
|
||||||
|
RestartApplications=yes
|
||||||
|
|
||||||
|
[Languages]
|
||||||
|
Name: "english"; MessagesFile: "compiler:Default.isl"
|
||||||
|
|
||||||
|
[Tasks]
|
||||||
|
; On by default: Dikte is a tray application holding a global shortcut, and one
|
||||||
|
; that is not running when you press the key is one that does nothing.
|
||||||
|
Name: "autostart"; Description: "Start Dikte when I sign in"
|
||||||
|
|
||||||
|
[Files]
|
||||||
|
Source: "{#Source}\*"; DestDir: "{app}"; Flags: recursesubdirs ignoreversion
|
||||||
|
|
||||||
|
[Icons]
|
||||||
|
Name: "{autoprograms}\Dikte"; Filename: "{app}\Dikte.exe"
|
||||||
|
|
||||||
|
[Registry]
|
||||||
|
; Starting at sign-in, as a registry value rather than a shortcut in the
|
||||||
|
; Startup folder: it is the one place the setup program, the uninstaller and
|
||||||
|
; `dikte integrate` can all read and write without a COM library between them.
|
||||||
|
Root: HKCU; Subkey: "Software\Microsoft\Windows\CurrentVersion\Run"; \
|
||||||
|
ValueType: string; ValueName: "Dikte"; ValueData: """{app}\Dikte.exe"""; \
|
||||||
|
Flags: uninsdeletevalue; Tasks: autostart
|
||||||
|
; And taking it away again, for an update where the box was unticked. Both
|
||||||
|
; lines delete on uninstall, so an entry `dikte integrate` wrote later goes
|
||||||
|
; too, whichever way it got there.
|
||||||
|
Root: HKCU; Subkey: "Software\Microsoft\Windows\CurrentVersion\Run"; \
|
||||||
|
ValueType: none; ValueName: "Dikte"; \
|
||||||
|
Flags: deletevalue uninsdeletevalue; Tasks: not autostart
|
||||||
|
|
||||||
|
[Run]
|
||||||
|
Filename: "{app}\Dikte.exe"; Description: "Start Dikte"; \
|
||||||
|
Flags: nowait postinstall skipifsilent
|
||||||
|
|
||||||
|
[Code]
|
||||||
|
{ The `dikte` command. WindowsApps is already on the user's PATH, so a .cmd
|
||||||
|
left there runs from any terminal without touching the PATH and without an
|
||||||
|
administrator; the alternative is an environment variable edit that every
|
||||||
|
open terminal misses. It names dikte-cli.exe, the console executable, which
|
||||||
|
is the one that can print to the terminal it was typed in. }
|
||||||
|
|
||||||
|
function ShimDir(): String;
|
||||||
|
begin
|
||||||
|
Result := ExpandConstant('{localappdata}\Microsoft\WindowsApps');
|
||||||
|
end;
|
||||||
|
|
||||||
|
function ShimPath(): String;
|
||||||
|
begin
|
||||||
|
Result := ShimDir() + '\dikte.cmd';
|
||||||
|
end;
|
||||||
|
|
||||||
|
procedure CurStepChanged(CurStep: TSetupStep);
|
||||||
|
var
|
||||||
|
Shim: String;
|
||||||
|
begin
|
||||||
|
if CurStep = ssPostInstall then begin
|
||||||
|
if DirExists(ShimDir()) then begin
|
||||||
|
Shim := '@echo off' + #13#10
|
||||||
|
+ '"' + ExpandConstant('{app}\dikte-cli.exe') + '" %*' + #13#10;
|
||||||
|
SaveStringToFile(ShimPath(), Shim, False);
|
||||||
|
end;
|
||||||
|
end;
|
||||||
|
end;
|
||||||
|
|
||||||
|
procedure CurUninstallStepChanged(CurUninstallStep: TUninstallStep);
|
||||||
|
begin
|
||||||
|
if CurUninstallStep = usUninstall then
|
||||||
|
DeleteFile(ShimPath());
|
||||||
|
end;
|
||||||
+40
-10
@@ -1,12 +1,14 @@
|
|||||||
# PyInstaller's description of the build, shared by the AppImage and the disk
|
# 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
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user