Build an AppImage and a disk image, and publish them

Installing meant cloning the repository and running a shell script, which
is a fair ask of somebody who already has a terminal open and no ask at
all of anybody else. The releases page now carries an AppImage and a disk
image per Mac architecture: a push to master rebuilds a rolling "latest",
a v* tag publishes a version and leaves it there, and the Run button in
the Actions tab raises the number by running scripts/release.sh, which is
the same script and not a second copy of what it does.

Two things in the application had to give for that. A build has no
__main__.py on disk, and an AppImage is mounted somewhere new every run,
so the command a shortcut is registered with cannot go on being this
interpreter and this file; ipc.launcher() answers with the AppImage or
the bundle instead. And a build carries its own libstdc++, which every
process it starts inherits through LD_LIBRARY_PATH and none of them can
live with: ffmpeg, ydotool and wl-copy are the distribution's binaries
built against the distribution's libraries, and AppImageLauncher, which
is what starting the AppImage again goes through, refuses outright.
integrate.py puts that variable back before anything else runs.

Nothing installs itself over an installation that is already there.
install.sh's menu entry, install-mac.sh's login item and the desktop file
AppImageLauncher writes are each recognised and left alone, so trying a
download once does not quietly move the machine onto it. `dikte
integrate` is how you ask for it outright, and --remove takes it back.

The disk image carries an ffmpeg, pinned and checksummed, because macOS
records through one and ships nothing like it. It is signed ad-hoc and
not with an Apple certificate, so a first launch is refused until Open
Anyway and the permissions are asked for again after each update; both
READMEs and the release notes say so.
This commit is contained in:
2026-08-16 15:03:44 +03:00
parent 061dc5451b
commit 7e510f8b35
17 changed files with 1508 additions and 8 deletions
+77
View File
@@ -0,0 +1,77 @@
#!/usr/bin/env bash
# The Linux download: one file, no dependencies of its own beyond the sound
# and clipboard programs that come with the desktop.
#
# Run from anywhere; it works in build/ at the top of the checkout and leaves
# the finished AppImage in dist/. The release workflow runs it on the oldest
# Ubuntu still supported, because the glibc a build is linked against is the
# oldest one it will run on, and nothing here depends on which Ubuntu that is.
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
BUILD="$ROOT/build"
APPDIR="$BUILD/AppDir"
OUT="$ROOT/dist"
ARCH="${ARCH:-$(uname -m)}"
export ARCH
VERSION="$(cd "$ROOT" && python3 -c 'import dikte; print(dikte.__version__)')"
rm -rf "$BUILD" "$OUT"
mkdir -p "$APPDIR/usr/bin" "$OUT"
# 1. The application -------------------------------------------------------
python3 -m PyInstaller "$ROOT/packaging/dikte.spec" \
--distpath "$BUILD/dist" --workpath "$BUILD/work" --noconfirm --clean
cp -a "$BUILD/dist/dikte/." "$APPDIR/usr/bin/"
# 2. The icon --------------------------------------------------------------
# Drawn by the application itself, which is why there is no image file in the
# repository and no second place to change what Dikte looks like. Offscreen,
# since this runs with no display anywhere near it.
icons="$BUILD/icons"
QT_QPA_PLATFORM=offscreen PYTHONPATH="$ROOT" \
python3 -m dikte.trayicon --hicolor "$icons"
mkdir -p "$APPDIR/usr/share/icons"
cp -a "$icons/hicolor" "$APPDIR/usr/share/icons/"
# At the top as well, under the name the desktop entry gives: that copy is what
# appimagetool reads, and what a desktop shows before the file is ever run.
cp "$icons/hicolor/256x256/apps/dikte.png" "$APPDIR/dikte.png"
# 3. What the runtime reads ------------------------------------------------
# AppRun is started from the mount point, which is a different path every run,
# so it has to find its own directory rather than be told one.
cat > "$APPDIR/AppRun" <<'EOF'
#!/bin/sh
HERE="$(dirname "$(readlink -f "$0")")"
exec "$HERE/usr/bin/dikte" "$@"
EOF
chmod +x "$APPDIR/AppRun"
# Exec names the file rather than a path: a desktop that integrates the
# AppImage rewrites this line with wherever the user keeps it, and Dikte writes
# its own copy of this entry on first run, which is the one that matters.
cat > "$APPDIR/dikte.desktop" <<EOF
[Desktop Entry]
Type=Application
Name=Dikte
Comment=Voice dictation: record, transcribe, clean up, paste
Exec=dikte
Icon=dikte
Categories=Utility;AudioVideo;
Terminal=false
StartupNotify=false
EOF
# 4. The AppImage ----------------------------------------------------------
# appimagetool is itself an AppImage, and a container or a CI runner has no
# FUSE for it to mount itself with, so it is asked to unpack instead.
tool="$BUILD/appimagetool"
if [ ! -x "$tool" ]; then
curl -fsSL -o "$tool" \
"https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-$ARCH.AppImage"
chmod +x "$tool"
fi
"$tool" --appimage-extract-and-run "$APPDIR" "$OUT/Dikte-$VERSION-$ARCH.AppImage"
echo "dist/Dikte-$VERSION-$ARCH.AppImage"
+88
View File
@@ -0,0 +1,88 @@
#!/usr/bin/env bash
# The macOS download: a disk image with Dikte.app in it and the usual arrow at
# /Applications to drag it onto.
#
# Run from anywhere; it works in build/ at the top of the checkout and leaves
# the finished .dmg in dist/. One image per architecture, because PyQt6 has no
# universal wheel to build a universal binary out of, so the workflow runs this
# once on an Apple silicon runner and once on an Intel one.
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
BUILD="$ROOT/build"
OUT="$ROOT/dist"
ARCH="$(uname -m)"
BUNDLE_ID="io.github.yusufipk.dikte"
VERSION="$(cd "$ROOT" && python3 -c 'import dikte; print(dikte.__version__)')"
APP="$BUILD/dist/Dikte.app"
# 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. GPL, which is what Dikte
# is licensed under too. 6.1.1 is behind the current release and stays there
# until something Dikte asks of it needs a newer one.
FFMPEG_TAG="b6.1.1"
case "$ARCH" in
arm64) FFMPEG_ASSET="ffmpeg-darwin-arm64.gz"
FFMPEG_SHA="8923876afa8db5585022d7860ec7e589af192f441c56793971276d450ed3bbfa" ;;
x86_64) FFMPEG_ASSET="ffmpeg-darwin-x64.gz"
FFMPEG_SHA="929b375c1182d956c51f7ac25e0b2b0411fb01f6f407aa15c9758efeb4242106" ;;
*) echo "no ffmpeg pinned for $ARCH" >&2; exit 1 ;;
esac
rm -rf "$BUILD" "$OUT"
mkdir -p "$BUILD" "$OUT"
# 1. The icon --------------------------------------------------------------
# Before the application, because the bundle is built with it rather than
# having it copied in afterwards. Drawn by Dikte itself, offscreen, which is
# why there is no image file in the repository.
iconset="$BUILD/Dikte.iconset"
QT_QPA_PLATFORM=offscreen PYTHONPATH="$ROOT" python3 -m dikte.trayicon "$iconset"
iconutil -c icns "$iconset" -o "$BUILD/Dikte.icns"
export DIKTE_ICNS="$BUILD/Dikte.icns"
# 2. The application -------------------------------------------------------
python3 -m PyInstaller "$ROOT/packaging/dikte.spec" \
--distpath "$BUILD/dist" --workpath "$BUILD/work" --noconfirm --clean
# 3. ffmpeg ----------------------------------------------------------------
# Recording on a Mac goes through ffmpeg, and macOS ships nothing like it, so
# without this the disk image would be an application that cannot record until
# the person who downloaded it installs Homebrew. Resources/bin because
# Contents/MacOS is for the executable the bundle names, and integrate.py puts
# this directory in front of PATH at startup.
bin="$APP/Contents/Resources/bin"
mkdir -p "$bin"
curl -fsSL -o "$BUILD/$FFMPEG_ASSET" \
"https://github.com/eugeneware/ffmpeg-static/releases/download/$FFMPEG_TAG/$FFMPEG_ASSET"
echo "$FFMPEG_SHA $BUILD/$FFMPEG_ASSET" | shasum -a 256 -c -
gunzip -c "$BUILD/$FFMPEG_ASSET" > "$bin/ffmpeg"
chmod +x "$bin/ffmpeg"
# 4. Signing ---------------------------------------------------------------
# Ad-hoc, because there is no Developer ID to sign with. It is not decoration:
# macOS files a microphone or Accessibility permission against a code
# signature, and an arm64 binary carrying none is refused by the kernel outright
# rather than merely warned about. What it does not buy is Gatekeeper, which is
# why the README tells people how to get past the first-launch refusal.
#
# --deep is the wrong tool for a real signature and the right one here: every
# dylib PyInstaller collected plus the ffmpeg added above all need one, and
# adding ffmpeg invalidated the signature PyInstaller left.
codesign --force --deep --sign - --identifier "$BUNDLE_ID" "$APP"
codesign --verify --deep "$APP"
# 5. The disk image --------------------------------------------------------
# A staging directory rather than the bundle on its own, so that the window
# that opens has the arrow to drag it onto. UDZO is the compressed read-only
# format every Mac has understood for twenty years.
stage="$BUILD/stage"
mkdir -p "$stage"
cp -a "$APP" "$stage/"
ln -s /Applications "$stage/Applications"
hdiutil create -volname "Dikte $VERSION" -srcfolder "$stage" \
-ov -format UDZO -quiet "$OUT/Dikte-$VERSION-$ARCH.dmg"
echo "dist/Dikte-$VERSION-$ARCH.dmg"
+107
View File
@@ -0,0 +1,107 @@
# PyInstaller's description of the build, shared by the AppImage and the disk
# image. Run it through build-appimage.sh or build-dmg.sh rather than by hand:
# each of those has a few steps of its own on either side of this.
#
# A directory rather than a single file, on both platforms. Onefile unpacks
# itself into /tmp on every start, which for something a global shortcut is
# meant to bring up is a second of nothing happening, and for the AppImage it
# would be an unpacking inside an unpacking. The single file people download is
# the AppImage and the .dmg; this only has to be tidy inside them.
import os
import pathlib
import re
import sys
ROOT = pathlib.Path(SPECPATH).parent # noqa: F821 (PyInstaller's)
# Read rather than imported. Putting the checkout on sys.path to import dikte
# would put this directory there under the name `packaging`, which is a real
# library that PyInstaller itself uses, and a spec file is no place to find out
# whether that matters.
__version__ = re.search(r'^__version__ = "(.*)"$',
(ROOT / "dikte" / "__init__.py").read_text(),
re.M).group(1)
MACOS = sys.platform == "darwin"
BUNDLE_ID = "io.github.yusufipk.dikte"
# PyQt6's wheel is most of the build, and most of the wheel is modules nothing
# here imports: Qt ships a browser engine, three declarative UI stacks and a
# 3D renderer. Naming them keeps the download to something a person on a slow
# connection will actually finish. Only the four in dikte's imports are left.
UNUSED_QT = [
"PyQt6." + name for name in (
"Qt3DAnimation", "Qt3DCore", "Qt3DExtras", "Qt3DInput", "Qt3DLogic",
"Qt3DRender", "QtBluetooth", "QtCharts", "QtDataVisualization",
"QtDesigner", "QtHelp", "QtLocation", "QtMultimedia",
"QtMultimediaWidgets", "QtNfc", "QtPdf", "QtPdfWidgets",
"QtPositioning", "QtQml", "QtQuick", "QtQuick3D", "QtQuickWidgets",
"QtRemoteObjects", "QtSensors", "QtSerialPort", "QtSpatialAudio",
"QtSql", "QtTest", "QtTextToSpeech", "QtWebChannel", "QtWebEngineCore",
"QtWebEngineQuick", "QtWebEngineWidgets", "QtWebSockets",
)
]
analysis = Analysis( # noqa: F821
[str(ROOT / "packaging" / "entry.py")],
pathex=[str(ROOT)],
hiddenimports=["PyQt6.QtNetwork"],
# tkinter is the other GUI toolkit CPython ships and would be dead weight;
# dikte's own tests have no business in a build at all.
excludes=UNUSED_QT + ["tkinter", "tests"],
noarchive=False,
)
archive = PYZ(analysis.pure) # noqa: F821
executable = EXE( # noqa: F821
archive,
analysis.scripts,
[],
exclude_binaries=True,
name="Dikte" if MACOS else "dikte",
console=False,
# Both platforms use whatever the machine is, because neither build is
# cross-compiled: the workflow runs one job per architecture.
target_arch=None,
# Ad-hoc, and only on a Mac, where an arm64 binary that carries no
# signature at all is refused by the kernel rather than merely warned
# about. build-dmg.sh signs the finished bundle over the top of this.
codesign_identity="-" if MACOS else None,
)
collection = COLLECT( # noqa: F821
executable,
analysis.binaries,
analysis.datas,
name="dikte",
)
if MACOS:
# LSUIElement is the line that makes this a menu bar application: no Dock
# icon, no menu of its own, nothing in the app switcher. The usage strings
# are not decoration either, they are what the permission dialogs read out,
# and a bundle that asks for the microphone without one is killed rather
# than asked about.
app = BUNDLE( # noqa: F821
collection,
name="Dikte.app",
icon=os.environ.get("DIKTE_ICNS") or None,
bundle_identifier=BUNDLE_ID,
version=__version__,
info_plist={
"CFBundleName": "Dikte",
"CFBundleDisplayName": "Dikte",
"CFBundleShortVersionString": __version__,
"CFBundleVersion": __version__,
"LSMinimumSystemVersion": "11.0",
"LSUIElement": True,
"NSHighResolutionCapable": True,
"NSMicrophoneUsageDescription":
"Dikte records what you dictate so that it can be transcribed.",
"NSAppleEventsUsageDescription":
"Dikte puts the transcript on the clipboard and pastes it into "
"the window you were typing in.",
},
)
+23
View File
@@ -0,0 +1,23 @@
"""What the AppImage and the disk image start.
dikte/__main__.py is written for a checkout: it puts the directory above the
package on the import path, which a build has neither the need for nor a
directory to point at. What is left over is one thing a checkout never sees.
The Finder hands a double-clicked application a -psn_0_… argument naming the
process serial number, which argparse reads as a flag it has never heard of and
exits over, and no one clicking an icon would ever find out why.
The two environment lines have to run before anything starts another process,
and before is easier to be sure of here than anywhere further in.
"""
import sys
from dikte import integrate
from dikte.app import main
if __name__ == "__main__":
integrate.restore_library_path()
integrate.add_bundled_tools()
sys.argv[1:] = [arg for arg in sys.argv[1:] if not arg.startswith("-psn_")]
sys.exit(main())