From 03a33af0c5860601a7bebbb903f233bf8a87a794 Mon Sep 17 00:00:00 2001 From: Aleksandr Chasnyk <69671996+ami3go@users.noreply.github.com> Date: Thu, 20 Aug 2026 14:59:46 +0300 Subject: [PATCH 1/9] Build the Windows icon into the work directory, not the source tree Both Windows specs decoded droidwebdisplay.ico.base64 into packaging/windows/droidwebdisplay.ico, a path that is not gitignored. Every Windows build therefore left an untracked binary in the checkout, so `git status` came back dirty and a routine `git add -A` would commit a generated artifact. The icon now lands in PyInstaller's workpath (./build/..., already covered by the `**/build/` rule), falling back to ROOT/build when workpath is absent. The tracked .base64 remains the single source. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01XfT5Z3Z24bjC8qtqERGpMQ --- packaging/pyinstaller/DroidWebDisplayWindows.spec | 10 ++++++++-- .../pyinstaller/DroidWebDisplayWindowsOnedir.spec | 10 ++++++++-- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/packaging/pyinstaller/DroidWebDisplayWindows.spec b/packaging/pyinstaller/DroidWebDisplayWindows.spec index 10d51bb..76d8533 100644 --- a/packaging/pyinstaller/DroidWebDisplayWindows.spec +++ b/packaging/pyinstaller/DroidWebDisplayWindows.spec @@ -26,8 +26,14 @@ if len(parts) != 3: raise SystemExit(f"Expected semantic VERSION, got {VERSION!r}") numeric_version = (*parts, 0) -ICON = ROOT / "packaging" / "windows" / "droidwebdisplay.ico" -ICON.write_bytes(base64.b64decode((ICON.with_suffix(".ico.base64")).read_text(encoding="ascii"))) +# Decode the tracked base64 icon into PyInstaller's work directory rather than +# back into packaging/windows/. Writing it into the source tree left an +# untracked binary behind after every build, which `git add -A` would commit. +ICON_SOURCE = ROOT / "packaging" / "windows" / "droidwebdisplay.ico.base64" +ICON_DIR = Path(globals().get("workpath") or (ROOT / "build")) +ICON_DIR.mkdir(parents=True, exist_ok=True) +ICON = ICON_DIR / "droidwebdisplay.ico" +ICON.write_bytes(base64.b64decode(ICON_SOURCE.read_text(encoding="ascii"))) version_info = VSVersionInfo( ffi=FixedFileInfo( diff --git a/packaging/pyinstaller/DroidWebDisplayWindowsOnedir.spec b/packaging/pyinstaller/DroidWebDisplayWindowsOnedir.spec index b4fbf17..07deca5 100644 --- a/packaging/pyinstaller/DroidWebDisplayWindowsOnedir.spec +++ b/packaging/pyinstaller/DroidWebDisplayWindowsOnedir.spec @@ -26,8 +26,14 @@ if len(parts) != 3: raise SystemExit(f"Expected semantic VERSION, got {VERSION!r}") numeric_version = (*parts, 0) -ICON = ROOT / "packaging" / "windows" / "droidwebdisplay.ico" -ICON.write_bytes(base64.b64decode((ICON.with_suffix(".ico.base64")).read_text(encoding="ascii"))) +# Decode the tracked base64 icon into PyInstaller's work directory rather than +# back into packaging/windows/. Writing it into the source tree left an +# untracked binary behind after every build, which `git add -A` would commit. +ICON_SOURCE = ROOT / "packaging" / "windows" / "droidwebdisplay.ico.base64" +ICON_DIR = Path(globals().get("workpath") or (ROOT / "build")) +ICON_DIR.mkdir(parents=True, exist_ok=True) +ICON = ICON_DIR / "droidwebdisplay.ico" +ICON.write_bytes(base64.b64decode(ICON_SOURCE.read_text(encoding="ascii"))) version_info = VSVersionInfo( ffi=FixedFileInfo( From ec650830a7b132c0e142e857ca492062261a2c66 Mon Sep 17 00:00:00 2001 From: Aleksandr Chasnyk <69671996+ami3go@users.noreply.github.com> Date: Thu, 20 Aug 2026 15:00:41 +0300 Subject: [PATCH 2/9] Verify the AppImage bundles the expected VERSION The Windows job checks PE FileVersion and ProductVersion against the VERSION file and fails the build on a mismatch. Linux had no equivalent, yet release.yml renames DroidWebDisplay-ci-linux-x86_64.AppImage to DroidWebDisplay-v-linux-x86_64.AppImage and publishes it, so the filename asserted a version nothing had verified and its hash went into SHA256SUMS.txt on that basis. The new step extracts the built AppImage, compares the VERSION it actually bundles against the tree, and confirms adb is present -- matching what the Windows job already does for its two packages. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01XfT5Z3Z24bjC8qtqERGpMQ --- .github/workflows/ci.yml | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9cec4d2..95da48e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -249,6 +249,33 @@ jobs: "$APPDIR" \ "release/DroidWebDisplay-ci-linux-x86_64.AppImage" chmod +x release/DroidWebDisplay-ci-linux-x86_64.AppImage + - name: Verify AppImage version metadata and bundled ADB + shell: bash + run: | + set -euo pipefail + # The Windows job verifies PE FileVersion/ProductVersion against VERSION. + # Without an equivalent here, release.yml renames this artifact to + # DroidWebDisplay-v-linux-x86_64.AppImage and publishes a + # filename asserting a version nothing checked. + version=$(tr -d '[:space:]' < VERSION) + appimage="$PWD/release/DroidWebDisplay-ci-linux-x86_64.AppImage" + workdir=$(mktemp -d) + cd "$workdir" + "$appimage" --appimage-extract > /dev/null + root="$workdir/squashfs-root/usr/lib/droidwebdisplay" + test -d "$root" + bundled=$(find "$root" -maxdepth 3 -name VERSION -type f | head -n 1) + test -n "$bundled" + got=$(tr -d '[:space:]' < "$bundled") + if [ "$got" != "$version" ]; then + echo "AppImage bundles VERSION $got but the tree says $version" >&2 + exit 1 + fi + adb=$(find "$root" -name adb -type f | head -n 1) + test -n "$adb" + echo "AppImage version $got verified, bundled adb at ${adb#$root/}" + cd - > /dev/null + rm -rf "$workdir" - name: Smoke-test AppImage desktop host run: QT_QPA_PLATFORM=offscreen APPIMAGE_EXTRACT_AND_RUN=1 ./release/DroidWebDisplay-ci-linux-x86_64.AppImage --desktop-smoke - name: Smoke-test AppImage CLI From 48e42f7899c3ac2198cba9a9878c1261ba413f4e Mon Sep 17 00:00:00 2001 From: Aleksandr Chasnyk <69671996+ami3go@users.noreply.github.com> Date: Thu, 20 Aug 2026 15:00:55 +0300 Subject: [PATCH 3/9] Disable upx in the Linux spec, matching the Windows specs DroidWebDisplay.spec set upx=True on both EXE and COLLECT while both Windows specs use upx=False. upx is not installed on the Linux runner -- it appears in neither apt-get list -- so PyInstaller logged that UPX was unavailable and carried on. The setting was therefore untested configuration that would begin compressing binaries unreviewed the moment upx landed on an image, and UPX-compressing Qt binaries is a known cause of corrupt executables and antivirus false positives. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01XfT5Z3Z24bjC8qtqERGpMQ --- packaging/pyinstaller/DroidWebDisplay.spec | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/packaging/pyinstaller/DroidWebDisplay.spec b/packaging/pyinstaller/DroidWebDisplay.spec index 376d2e9..ba78ee1 100644 --- a/packaging/pyinstaller/DroidWebDisplay.spec +++ b/packaging/pyinstaller/DroidWebDisplay.spec @@ -32,6 +32,10 @@ datas = [ (str(ROOT / "SECURITY.md"), "."), ] +# upx is intentionally disabled, matching both Windows specs. It was never +# installed on the Linux runner, so upx=True silently did nothing -- and would +# have activated unreviewed the moment upx appeared, which on Qt binaries is a +# known source of corrupt executables and antivirus false positives. a = Analysis( [str(ROOT / "tools" / "desktop_entry.py")], pathex=[str(ROOT), str(ROOT / "tools")], @@ -58,7 +62,7 @@ if sys.platform == "win32": debug=False, bootloader_ignore_signals=False, strip=False, - upx=True, + upx=False, upx_exclude=[], runtime_tmpdir=None, console=False, @@ -78,7 +82,7 @@ else: debug=False, bootloader_ignore_signals=False, strip=False, - upx=True, + upx=False, console=True, disable_windowed_traceback=False, argv_emulation=False, @@ -91,7 +95,7 @@ else: a.binaries, a.datas, strip=False, - upx=True, + upx=False, upx_exclude=[], name="DroidWebDisplay", ) From 9f0c1c518b7bac2d1c838a9076b4b3f65923aec6 Mon Sep 17 00:00:00 2001 From: Aleksandr Chasnyk <69671996+ami3go@users.noreply.github.com> Date: Thu, 20 Aug 2026 15:01:33 +0300 Subject: [PATCH 4/9] Install the Linux desktop icon alongside the desktop entry droidwebdisplay.desktop.in carried no Icon key and install.sh never placed droidwebdisplay.svg on the icon search path, so a system install produced a launcher entry with a generic placeholder. The AppImage build meanwhile inlines its own desktop file that does set Icon=droidwebdisplay -- two definitions of the same entry, and the installed one was the poorer. The template now sets Icon=droidwebdisplay, install.sh copies the SVG into XDG hicolor/scalable/apps and refreshes the icon cache when the tool exists, and uninstall.sh removes it again. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01XfT5Z3Z24bjC8qtqERGpMQ --- packaging/linux/droidwebdisplay.desktop.in | 1 + packaging/linux/install.sh | 8 +++++++- packaging/linux/uninstall.sh | 3 ++- 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/packaging/linux/droidwebdisplay.desktop.in b/packaging/linux/droidwebdisplay.desktop.in index 62fa34a..59a28ca 100644 --- a/packaging/linux/droidwebdisplay.desktop.in +++ b/packaging/linux/droidwebdisplay.desktop.in @@ -3,5 +3,6 @@ Type=Application Name=DroidWebDisplay Comment=Open the local DroidWebDisplay browser interface Exec=@LAUNCHER@ +Icon=droidwebdisplay Terminal=false Categories=Development;Utility; diff --git a/packaging/linux/install.sh b/packaging/linux/install.sh index 5b990c7..ae5d6f2 100644 --- a/packaging/linux/install.sh +++ b/packaging/linux/install.sh @@ -6,6 +6,7 @@ INSTALL_ROOT="${DROID_WEB_DISPLAY_INSTALL_ROOT:-$HOME/.local/share/droidwebdispl BIN_DIR="${XDG_BIN_HOME:-$HOME/.local/bin}" SYSTEMD_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user" DESKTOP_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/applications" +ICON_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/icons/hicolor/scalable/apps" SOURCE_REAL="$(cd "$SOURCE" && pwd)" mkdir -p "$INSTALL_ROOT" @@ -23,7 +24,7 @@ for candidate in "$INSTALL_ROOT/runtime/python/bin/python3" "$INSTALL_ROOT/runti fi done -mkdir -p "$INSTALL_ROOT" "$BIN_DIR" "$SYSTEMD_DIR" "$DESKTOP_DIR" +mkdir -p "$INSTALL_ROOT" "$BIN_DIR" "$SYSTEMD_DIR" "$DESKTOP_DIR" "$ICON_DIR" for state in data downloads logs; do mkdir -p "$INSTALL_ROOT/$state"; done # Refresh executable application files while preserving runtime state and any @@ -95,6 +96,11 @@ chmod +x "$BIN_DIR/droidwebdisplay-stop" sed "s|@INSTALL_ROOT@|$INSTALL_ROOT|g" "$INSTALL_ROOT/packaging/linux/droidwebdisplay.service.in" > "$SYSTEMD_DIR/droidwebdisplay.service" sed "s|@LAUNCHER@|$BIN_DIR/droidwebdisplay|g" "$INSTALL_ROOT/packaging/linux/droidwebdisplay.desktop.in" > "$DESKTOP_DIR/droidwebdisplay.desktop" +# The desktop entry names Icon=droidwebdisplay, so the SVG has to be on the +# icon search path or the launcher shows a generic placeholder. The AppImage +# ships its own copy; a system install had none. +install -m 0644 "$INSTALL_ROOT/packaging/linux/droidwebdisplay.svg" "$ICON_DIR/droidwebdisplay.svg" +gtk-update-icon-cache -f -t "${XDG_DATA_HOME:-$HOME/.local/share}/icons/hicolor" >/dev/null 2>&1 || true systemctl --user daemon-reload >/dev/null 2>&1 || true printf 'Installed DroidWebDisplay to %s\n' "$INSTALL_ROOT" printf 'Start now: systemctl --user start droidwebdisplay.service\n' diff --git a/packaging/linux/uninstall.sh b/packaging/linux/uninstall.sh index 073bfb9..f0a379d 100644 --- a/packaging/linux/uninstall.sh +++ b/packaging/linux/uninstall.sh @@ -4,10 +4,11 @@ INSTALL_ROOT="${DROID_WEB_DISPLAY_INSTALL_ROOT:-$HOME/.local/share/droidwebdispl BIN_DIR="${XDG_BIN_HOME:-$HOME/.local/bin}" SYSTEMD_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user" DESKTOP_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/applications" +ICON_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/icons/hicolor/scalable/apps" PURGE=0 [[ "${1:-}" == "--purge-data" ]] && PURGE=1 systemctl --user disable --now droidwebdisplay.service >/dev/null 2>&1 || true -rm -f "$SYSTEMD_DIR/droidwebdisplay.service" "$BIN_DIR/droidwebdisplay" "$BIN_DIR/droidwebdisplay-stop" "$DESKTOP_DIR/droidwebdisplay.desktop" +rm -f "$SYSTEMD_DIR/droidwebdisplay.service" "$BIN_DIR/droidwebdisplay" "$BIN_DIR/droidwebdisplay-stop" "$DESKTOP_DIR/droidwebdisplay.desktop" "$ICON_DIR/droidwebdisplay.svg" systemctl --user daemon-reload >/dev/null 2>&1 || true if [[ $PURGE -eq 1 ]]; then rm -rf "$INSTALL_ROOT" From e85deef252407a4eceffb7029eac331603355e46 Mon Sep 17 00:00:00 2001 From: Aleksandr Chasnyk <69671996+ami3go@users.noreply.github.com> Date: Thu, 20 Aug 2026 15:02:07 +0300 Subject: [PATCH 5/9] Make the Linux spec Linux-only instead of silently building a worse exe DroidWebDisplay.spec carried an `if sys.platform == "win32"` branch that CI never reaches -- Windows builds use the two dedicated specs. That branch produced a onefile exe with no icon and no VS version resource, which is precisely the binary ci.yml's PE metadata check exists to reject, so anyone running this spec on Windows got a package that would fail the gate for reasons the spec itself caused. It now refuses to run on Windows and names the specs to use instead. The platform-conditional adb name list and the duplicated EXE block collapse to the single Linux form. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01XfT5Z3Z24bjC8qtqERGpMQ --- packaging/pyinstaller/DroidWebDisplay.spec | 87 +++++++++------------- 1 file changed, 37 insertions(+), 50 deletions(-) diff --git a/packaging/pyinstaller/DroidWebDisplay.spec b/packaging/pyinstaller/DroidWebDisplay.spec index ba78ee1..50bba6d 100644 --- a/packaging/pyinstaller/DroidWebDisplay.spec +++ b/packaging/pyinstaller/DroidWebDisplay.spec @@ -5,12 +5,21 @@ import sys from PyInstaller.utils.hooks import collect_submodules +if sys.platform == "win32": + # Windows has dedicated specs that attach the icon and the VS version + # resource. This one produced neither, so falling back to it here built + # exactly the binary ci.yml's PE metadata check exists to reject. + raise SystemExit( + "Use packaging/pyinstaller/DroidWebDisplayWindows.spec or " + "DroidWebDisplayWindowsOnedir.spec on Windows" + ) + ROOT = Path(SPECPATH).resolve().parents[1] ADB_DIR = Path(os.environ["DWD_ADB_DIR"]).resolve() -adb_names = ["adb.exe", "AdbWinApi.dll", "AdbWinUsbApi.dll"] if sys.platform == "win32" else ["adb"] +adb_names = ["adb"] adb_binaries = [(str(ADB_DIR / name), "adb") for name in adb_names if (ADB_DIR / name).is_file()] -if not any(Path(source).name.lower() in {"adb", "adb.exe"} for source, _ in adb_binaries): +if not any(Path(source).name.lower() == "adb" for source, _ in adb_binaries): raise SystemExit(f"ADB executable missing from {ADB_DIR}") server_dir = ROOT / "server" @@ -51,51 +60,29 @@ a = Analysis( ) pyz = PYZ(a.pure) -if sys.platform == "win32": - exe = EXE( - pyz, - a.scripts, - a.binaries, - a.datas, - [], - name="DroidWebDisplay", - debug=False, - bootloader_ignore_signals=False, - strip=False, - upx=False, - upx_exclude=[], - runtime_tmpdir=None, - console=False, - disable_windowed_traceback=False, - argv_emulation=False, - target_arch=None, - codesign_identity=None, - entitlements_file=None, - ) -else: - exe = EXE( - pyz, - a.scripts, - [], - exclude_binaries=True, - name="DroidWebDisplay", - debug=False, - bootloader_ignore_signals=False, - strip=False, - upx=False, - console=True, - disable_windowed_traceback=False, - argv_emulation=False, - target_arch=None, - codesign_identity=None, - entitlements_file=None, - ) - coll = COLLECT( - exe, - a.binaries, - a.datas, - strip=False, - upx=False, - upx_exclude=[], - name="DroidWebDisplay", - ) +exe = EXE( + pyz, + a.scripts, + [], + exclude_binaries=True, + name="DroidWebDisplay", + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=False, + console=True, + disable_windowed_traceback=False, + argv_emulation=False, + target_arch=None, + codesign_identity=None, + entitlements_file=None, +) +coll = COLLECT( + exe, + a.binaries, + a.datas, + strip=False, + upx=False, + upx_exclude=[], + name="DroidWebDisplay", +) From cfec826aad076953f7620cb613ed345f1a333911 Mon Sep 17 00:00:00 2001 From: Aleksandr Chasnyk <69671996+ami3go@users.noreply.github.com> Date: Thu, 20 Aug 2026 15:02:27 +0300 Subject: [PATCH 6/9] Accept prerelease VERSION strings in the Windows specs Both specs did `int(part) for part in VERSION.split(".")`, so a VERSION of 0.11.8-rc.1 raised ValueError and failed the Windows build outright. This project has already published -rc tags (v0.11.2-rc.1, stable-v0.11.2-rc.3), so that is a shape it uses -- it simply has not reached the VERSION file yet. The numeric VS_FIXEDFILEINFO tuple now comes from the leading MAJOR.MINOR.PATCH and ignores any suffix, while the FileVersion and ProductVersion strings keep the full VERSION text. A value that does not start with three numbers is still rejected. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01XfT5Z3Z24bjC8qtqERGpMQ --- packaging/pyinstaller/DroidWebDisplayWindows.spec | 13 +++++++++---- .../pyinstaller/DroidWebDisplayWindowsOnedir.spec | 13 +++++++++---- 2 files changed, 18 insertions(+), 8 deletions(-) diff --git a/packaging/pyinstaller/DroidWebDisplayWindows.spec b/packaging/pyinstaller/DroidWebDisplayWindows.spec index 76d8533..d4a5373 100644 --- a/packaging/pyinstaller/DroidWebDisplayWindows.spec +++ b/packaging/pyinstaller/DroidWebDisplayWindows.spec @@ -2,6 +2,7 @@ from pathlib import Path import base64 import os +import re import sys from PyInstaller.utils.hooks import collect_submodules @@ -21,10 +22,14 @@ if sys.platform != "win32": ROOT = Path(SPECPATH).resolve().parents[1] ADB_DIR = Path(os.environ["DWD_ADB_DIR"]).resolve() VERSION = (ROOT / "VERSION").read_text(encoding="utf-8").strip() -parts = [int(part) for part in VERSION.split(".")] -if len(parts) != 3: - raise SystemExit(f"Expected semantic VERSION, got {VERSION!r}") -numeric_version = (*parts, 0) +# The Windows VS_FIXEDFILEINFO field takes four integers, so a prerelease +# suffix has to be stripped before parsing. int() on "8-rc.1" raises, which +# would have failed the build outright the first time VERSION carried one -- +# and this project has already published -rc tags. +core = re.match(r"^(\d+)\.(\d+)\.(\d+)", VERSION) +if not core: + raise SystemExit(f"Expected VERSION to start with MAJOR.MINOR.PATCH, got {VERSION!r}") +numeric_version = (int(core.group(1)), int(core.group(2)), int(core.group(3)), 0) # Decode the tracked base64 icon into PyInstaller's work directory rather than # back into packaging/windows/. Writing it into the source tree left an diff --git a/packaging/pyinstaller/DroidWebDisplayWindowsOnedir.spec b/packaging/pyinstaller/DroidWebDisplayWindowsOnedir.spec index 07deca5..310964f 100644 --- a/packaging/pyinstaller/DroidWebDisplayWindowsOnedir.spec +++ b/packaging/pyinstaller/DroidWebDisplayWindowsOnedir.spec @@ -2,6 +2,7 @@ from pathlib import Path import base64 import os +import re import sys from PyInstaller.utils.hooks import collect_submodules @@ -21,10 +22,14 @@ if sys.platform != "win32": ROOT = Path(SPECPATH).resolve().parents[1] ADB_DIR = Path(os.environ["DWD_ADB_DIR"]).resolve() VERSION = (ROOT / "VERSION").read_text(encoding="utf-8").strip() -parts = [int(part) for part in VERSION.split(".")] -if len(parts) != 3: - raise SystemExit(f"Expected semantic VERSION, got {VERSION!r}") -numeric_version = (*parts, 0) +# The Windows VS_FIXEDFILEINFO field takes four integers, so a prerelease +# suffix has to be stripped before parsing. int() on "8-rc.1" raises, which +# would have failed the build outright the first time VERSION carried one -- +# and this project has already published -rc tags. +core = re.match(r"^(\d+)\.(\d+)\.(\d+)", VERSION) +if not core: + raise SystemExit(f"Expected VERSION to start with MAJOR.MINOR.PATCH, got {VERSION!r}") +numeric_version = (int(core.group(1)), int(core.group(2)), int(core.group(3)), 0) # Decode the tracked base64 icon into PyInstaller's work directory rather than # back into packaging/windows/. Writing it into the source tree left an From 2bd7e350db1753ee6d526cb0879d16277a4f830c Mon Sep 17 00:00:00 2001 From: Aleksandr Chasnyk <69671996+ami3go@users.noreply.github.com> Date: Thu, 20 Aug 2026 15:04:47 +0300 Subject: [PATCH 7/9] Consolidate the three PyInstaller specs onto one shared input module The Linux, Windows onefile and Windows onedir specs repeated the bundled-data list, the ADB discovery and the hidden-import computation verbatim -- 339 lines of which roughly 90% was duplicated. Adding a bundled file meant editing three places, and missing one produced a package broken on a single platform only. packaging/pyinstaller/_dwd_common.py now owns those inputs plus the VERSION parsing and the Windows icon decode. Each spec keeps only what genuinely differs: its platform guard, and its EXE/COLLECT shape. Spec files are exec'd rather than imported, so they reach the module through SPECPATH on sys.path; nothing in it is imported by the application. Verified by evaluating all three specs against a stubbed PyInstaller, which reproduces the pre-refactor inputs exactly: Linux gets 10 datas, 1 binary, no icon or version resource, COLLECT DroidWebDisplay; Windows onefile gets 10 datas, 3 binaries, icon and version, no COLLECT; Windows onedir the same plus COLLECT DroidWebDisplayWindowsOnedir. The Linux spec still refuses to run on win32. Added tests/packaging/test_pyinstaller_spec_inputs.py, which executes the module rather than grepping the specs -- a spec that stops bundling a file fails there instead of passing a source-text match. It skips when PyInstaller is absent, since only the packaging jobs install it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01XfT5Z3Z24bjC8qtqERGpMQ --- packaging/pyinstaller/DroidWebDisplay.spec | 49 +++------ .../pyinstaller/DroidWebDisplayWindows.spec | 61 ++--------- .../DroidWebDisplayWindowsOnedir.spec | 61 ++--------- packaging/pyinstaller/_dwd_common.py | 100 ++++++++++++++++++ .../packaging/test_pyinstaller_spec_inputs.py | 64 +++++++++++ 5 files changed, 196 insertions(+), 139 deletions(-) create mode 100644 packaging/pyinstaller/_dwd_common.py create mode 100644 tests/packaging/test_pyinstaller_spec_inputs.py diff --git a/packaging/pyinstaller/DroidWebDisplay.spec b/packaging/pyinstaller/DroidWebDisplay.spec index 50bba6d..a0dedef 100644 --- a/packaging/pyinstaller/DroidWebDisplay.spec +++ b/packaging/pyinstaller/DroidWebDisplay.spec @@ -1,56 +1,27 @@ # -*- mode: python ; coding: utf-8 -*- from pathlib import Path -import os import sys -from PyInstaller.utils.hooks import collect_submodules +sys.path.insert(0, SPECPATH) +import _dwd_common as common if sys.platform == "win32": # Windows has dedicated specs that attach the icon and the VS version - # resource. This one produced neither, so falling back to it here built + # resource. This one produces neither, so falling back to it here builds # exactly the binary ci.yml's PE metadata check exists to reject. raise SystemExit( "Use packaging/pyinstaller/DroidWebDisplayWindows.spec or " "DroidWebDisplayWindowsOnedir.spec on Windows" ) -ROOT = Path(SPECPATH).resolve().parents[1] -ADB_DIR = Path(os.environ["DWD_ADB_DIR"]).resolve() +ROOT = common.repo_root(SPECPATH) -adb_names = ["adb"] -adb_binaries = [(str(ADB_DIR / name), "adb") for name in adb_names if (ADB_DIR / name).is_file()] -if not any(Path(source).name.lower() == "adb" for source, _ in adb_binaries): - raise SystemExit(f"ADB executable missing from {ADB_DIR}") - -server_dir = ROOT / "server" -if not server_dir.is_dir(): - raise SystemExit("server directory is missing; run tools/download_server.py first") - -hiddenimports = sorted(set(collect_submodules("uvicorn") + collect_submodules("websockets"))) - -datas = [ - (str(ROOT / "apps" / "web-client" / "dist"), "apps/web-client/dist"), - (str(ROOT / "apps" / "web-client" / "dist-manifest.json"), "apps/web-client"), - (str(ROOT / "packages" / "scrcpy-protocol" / "dist"), "packages/scrcpy-protocol/dist"), - (str(ROOT / "packages" / "scrcpy-protocol" / "package.json"), "packages/scrcpy-protocol"), - (str(ROOT / "compatibility"), "compatibility"), - (str(server_dir), "server"), - (str(ROOT / "VERSION"), "."), - (str(ROOT / "LICENSE"), "."), - (str(ROOT / "THIRD_PARTY_NOTICES.md"), "."), - (str(ROOT / "SECURITY.md"), "."), -] - -# upx is intentionally disabled, matching both Windows specs. It was never -# installed on the Linux runner, so upx=True silently did nothing -- and would -# have activated unreviewed the moment upx appeared, which on Qt binaries is a -# known source of corrupt executables and antivirus false positives. a = Analysis( - [str(ROOT / "tools" / "desktop_entry.py")], + [str(ROOT.joinpath(*common.ENTRY_SCRIPT))], pathex=[str(ROOT), str(ROOT / "tools")], - binaries=adb_binaries, - datas=datas, - hiddenimports=hiddenimports, + binaries=common.adb_binaries(common.adb_dir(), windows=False), + datas=common.bundle_datas(ROOT), + hiddenimports=common.hidden_imports(), hookspath=[], hooksconfig={}, runtime_hooks=[], @@ -60,6 +31,10 @@ a = Analysis( ) pyz = PYZ(a.pure) +# upx is intentionally disabled, matching both Windows specs. It was never +# installed on the Linux runner, so upx=True silently did nothing -- and would +# have activated unreviewed the moment upx appeared, which on Qt binaries is a +# known source of corrupt executables and antivirus false positives. exe = EXE( pyz, a.scripts, diff --git a/packaging/pyinstaller/DroidWebDisplayWindows.spec b/packaging/pyinstaller/DroidWebDisplayWindows.spec index d4a5373..433eff8 100644 --- a/packaging/pyinstaller/DroidWebDisplayWindows.spec +++ b/packaging/pyinstaller/DroidWebDisplayWindows.spec @@ -1,11 +1,7 @@ # -*- mode: python ; coding: utf-8 -*- from pathlib import Path -import base64 -import os -import re import sys -from PyInstaller.utils.hooks import collect_submodules from PyInstaller.utils.win32.versioninfo import ( FixedFileInfo, StringFileInfo, @@ -16,29 +12,15 @@ from PyInstaller.utils.win32.versioninfo import ( VSVersionInfo, ) +sys.path.insert(0, SPECPATH) +import _dwd_common as common + if sys.platform != "win32": raise SystemExit("DroidWebDisplayWindows.spec is a Windows-only target") -ROOT = Path(SPECPATH).resolve().parents[1] -ADB_DIR = Path(os.environ["DWD_ADB_DIR"]).resolve() -VERSION = (ROOT / "VERSION").read_text(encoding="utf-8").strip() -# The Windows VS_FIXEDFILEINFO field takes four integers, so a prerelease -# suffix has to be stripped before parsing. int() on "8-rc.1" raises, which -# would have failed the build outright the first time VERSION carried one -- -# and this project has already published -rc tags. -core = re.match(r"^(\d+)\.(\d+)\.(\d+)", VERSION) -if not core: - raise SystemExit(f"Expected VERSION to start with MAJOR.MINOR.PATCH, got {VERSION!r}") -numeric_version = (int(core.group(1)), int(core.group(2)), int(core.group(3)), 0) - -# Decode the tracked base64 icon into PyInstaller's work directory rather than -# back into packaging/windows/. Writing it into the source tree left an -# untracked binary behind after every build, which `git add -A` would commit. -ICON_SOURCE = ROOT / "packaging" / "windows" / "droidwebdisplay.ico.base64" -ICON_DIR = Path(globals().get("workpath") or (ROOT / "build")) -ICON_DIR.mkdir(parents=True, exist_ok=True) -ICON = ICON_DIR / "droidwebdisplay.ico" -ICON.write_bytes(base64.b64decode(ICON_SOURCE.read_text(encoding="ascii"))) +ROOT = common.repo_root(SPECPATH) +VERSION, numeric_version = common.read_version(ROOT) +ICON = common.windows_icon(ROOT, Path(globals().get("workpath") or (ROOT / "build"))) version_info = VSVersionInfo( ffi=FixedFileInfo( @@ -68,35 +50,12 @@ version_info = VSVersionInfo( ], ) -adb_names = ["adb.exe", "AdbWinApi.dll", "AdbWinUsbApi.dll"] -adb_binaries = [(str(ADB_DIR / name), "adb") for name in adb_names if (ADB_DIR / name).is_file()] -if not any(Path(source).name.lower() == "adb.exe" for source, _ in adb_binaries): - raise SystemExit(f"ADB executable missing from {ADB_DIR}") - -server_dir = ROOT / "server" -if not server_dir.is_dir(): - raise SystemExit("server directory is missing; run tools/download_server.py first") - -hiddenimports = sorted(set(collect_submodules("uvicorn") + collect_submodules("websockets"))) -datas = [ - (str(ROOT / "apps" / "web-client" / "dist"), "apps/web-client/dist"), - (str(ROOT / "apps" / "web-client" / "dist-manifest.json"), "apps/web-client"), - (str(ROOT / "packages" / "scrcpy-protocol" / "dist"), "packages/scrcpy-protocol/dist"), - (str(ROOT / "packages" / "scrcpy-protocol" / "package.json"), "packages/scrcpy-protocol"), - (str(ROOT / "compatibility"), "compatibility"), - (str(server_dir), "server"), - (str(ROOT / "VERSION"), "."), - (str(ROOT / "LICENSE"), "."), - (str(ROOT / "THIRD_PARTY_NOTICES.md"), "."), - (str(ROOT / "SECURITY.md"), "."), -] - a = Analysis( - [str(ROOT / "tools" / "desktop_entry.py")], + [str(ROOT.joinpath(*common.ENTRY_SCRIPT))], pathex=[str(ROOT), str(ROOT / "tools")], - binaries=adb_binaries, - datas=datas, - hiddenimports=hiddenimports, + binaries=common.adb_binaries(common.adb_dir(), windows=True), + datas=common.bundle_datas(ROOT), + hiddenimports=common.hidden_imports(), hookspath=[], hooksconfig={}, runtime_hooks=[], diff --git a/packaging/pyinstaller/DroidWebDisplayWindowsOnedir.spec b/packaging/pyinstaller/DroidWebDisplayWindowsOnedir.spec index 310964f..12c0119 100644 --- a/packaging/pyinstaller/DroidWebDisplayWindowsOnedir.spec +++ b/packaging/pyinstaller/DroidWebDisplayWindowsOnedir.spec @@ -1,11 +1,7 @@ # -*- mode: python ; coding: utf-8 -*- from pathlib import Path -import base64 -import os -import re import sys -from PyInstaller.utils.hooks import collect_submodules from PyInstaller.utils.win32.versioninfo import ( FixedFileInfo, StringFileInfo, @@ -16,29 +12,15 @@ from PyInstaller.utils.win32.versioninfo import ( VSVersionInfo, ) +sys.path.insert(0, SPECPATH) +import _dwd_common as common + if sys.platform != "win32": raise SystemExit("DroidWebDisplayWindowsOnedir.spec is a Windows-only target") -ROOT = Path(SPECPATH).resolve().parents[1] -ADB_DIR = Path(os.environ["DWD_ADB_DIR"]).resolve() -VERSION = (ROOT / "VERSION").read_text(encoding="utf-8").strip() -# The Windows VS_FIXEDFILEINFO field takes four integers, so a prerelease -# suffix has to be stripped before parsing. int() on "8-rc.1" raises, which -# would have failed the build outright the first time VERSION carried one -- -# and this project has already published -rc tags. -core = re.match(r"^(\d+)\.(\d+)\.(\d+)", VERSION) -if not core: - raise SystemExit(f"Expected VERSION to start with MAJOR.MINOR.PATCH, got {VERSION!r}") -numeric_version = (int(core.group(1)), int(core.group(2)), int(core.group(3)), 0) - -# Decode the tracked base64 icon into PyInstaller's work directory rather than -# back into packaging/windows/. Writing it into the source tree left an -# untracked binary behind after every build, which `git add -A` would commit. -ICON_SOURCE = ROOT / "packaging" / "windows" / "droidwebdisplay.ico.base64" -ICON_DIR = Path(globals().get("workpath") or (ROOT / "build")) -ICON_DIR.mkdir(parents=True, exist_ok=True) -ICON = ICON_DIR / "droidwebdisplay.ico" -ICON.write_bytes(base64.b64decode(ICON_SOURCE.read_text(encoding="ascii"))) +ROOT = common.repo_root(SPECPATH) +VERSION, numeric_version = common.read_version(ROOT) +ICON = common.windows_icon(ROOT, Path(globals().get("workpath") or (ROOT / "build"))) version_info = VSVersionInfo( ffi=FixedFileInfo( @@ -68,35 +50,12 @@ version_info = VSVersionInfo( ], ) -adb_names = ["adb.exe", "AdbWinApi.dll", "AdbWinUsbApi.dll"] -adb_binaries = [(str(ADB_DIR / name), "adb") for name in adb_names if (ADB_DIR / name).is_file()] -if not any(Path(source).name.lower() == "adb.exe" for source, _ in adb_binaries): - raise SystemExit(f"ADB executable missing from {ADB_DIR}") - -server_dir = ROOT / "server" -if not server_dir.is_dir(): - raise SystemExit("server directory is missing; run tools/download_server.py first") - -hiddenimports = sorted(set(collect_submodules("uvicorn") + collect_submodules("websockets"))) -datas = [ - (str(ROOT / "apps" / "web-client" / "dist"), "apps/web-client/dist"), - (str(ROOT / "apps" / "web-client" / "dist-manifest.json"), "apps/web-client"), - (str(ROOT / "packages" / "scrcpy-protocol" / "dist"), "packages/scrcpy-protocol/dist"), - (str(ROOT / "packages" / "scrcpy-protocol" / "package.json"), "packages/scrcpy-protocol"), - (str(ROOT / "compatibility"), "compatibility"), - (str(server_dir), "server"), - (str(ROOT / "VERSION"), "."), - (str(ROOT / "LICENSE"), "."), - (str(ROOT / "THIRD_PARTY_NOTICES.md"), "."), - (str(ROOT / "SECURITY.md"), "."), -] - a = Analysis( - [str(ROOT / "tools" / "desktop_entry.py")], + [str(ROOT.joinpath(*common.ENTRY_SCRIPT))], pathex=[str(ROOT), str(ROOT / "tools")], - binaries=adb_binaries, - datas=datas, - hiddenimports=hiddenimports, + binaries=common.adb_binaries(common.adb_dir(), windows=True), + datas=common.bundle_datas(ROOT), + hiddenimports=common.hidden_imports(), hookspath=[], hooksconfig={}, runtime_hooks=[], diff --git a/packaging/pyinstaller/_dwd_common.py b/packaging/pyinstaller/_dwd_common.py new file mode 100644 index 0000000..3e0588f --- /dev/null +++ b/packaging/pyinstaller/_dwd_common.py @@ -0,0 +1,100 @@ +"""Shared inputs for the DroidWebDisplay PyInstaller specs. + +The three specs (Linux, Windows onefile, Windows onedir) previously repeated +the bundled-data list, the ADB discovery and the hidden-import computation +verbatim. Adding a bundled file meant editing three places, and missing one +produced a package broken on a single platform only. + +Spec files are exec'd rather than imported, so they reach this module by +putting SPECPATH on sys.path. Nothing here is imported by the application: it +runs at spec-evaluation time only. +""" + +from __future__ import annotations + +from pathlib import Path +import os +import re + +from PyInstaller.utils.hooks import collect_submodules + +ENTRY_SCRIPT = ("tools", "desktop_entry.py") + + +def repo_root(specpath: str) -> Path: + return Path(specpath).resolve().parents[1] + + +def adb_dir() -> Path: + """The platform-tools directory, supplied by CI as DWD_ADB_DIR.""" + return Path(os.environ["DWD_ADB_DIR"]).resolve() + + +def adb_binaries(directory: Path, *, windows: bool) -> list[tuple[str, str]]: + """ADB files to bundle, failing loudly when the executable is absent. + + A missing adb produces a package that looks fine until a user plugs in a + phone, so this is a build-time error rather than a warning. + """ + names = ["adb.exe", "AdbWinApi.dll", "AdbWinUsbApi.dll"] if windows else ["adb"] + found = [(str(directory / name), "adb") for name in names if (directory / name).is_file()] + executable = "adb.exe" if windows else "adb" + if not any(Path(source).name.lower() == executable for source, _ in found): + raise SystemExit(f"ADB executable missing from {directory}") + return found + + +def bundle_datas(root: Path) -> list[tuple[str, str]]: + """Data files every platform package ships. + + Keep this the single definition: a file added to one spec and not the + others yields a package that is broken on one platform only. + """ + server_dir = root / "server" + if not server_dir.is_dir(): + raise SystemExit("server directory is missing; run tools/download_server.py first") + return [ + (str(root / "apps" / "web-client" / "dist"), "apps/web-client/dist"), + (str(root / "apps" / "web-client" / "dist-manifest.json"), "apps/web-client"), + (str(root / "packages" / "scrcpy-protocol" / "dist"), "packages/scrcpy-protocol/dist"), + (str(root / "packages" / "scrcpy-protocol" / "package.json"), "packages/scrcpy-protocol"), + (str(root / "compatibility"), "compatibility"), + (str(server_dir), "server"), + (str(root / "VERSION"), "."), + (str(root / "LICENSE"), "."), + (str(root / "THIRD_PARTY_NOTICES.md"), "."), + (str(root / "SECURITY.md"), "."), + ] + + +def hidden_imports() -> list[str]: + return sorted(set(collect_submodules("uvicorn") + collect_submodules("websockets"))) + + +def read_version(root: Path) -> tuple[str, tuple[int, int, int, int]]: + """Return the VERSION text and the four-int tuple Windows resources need. + + The VS_FIXEDFILEINFO field takes integers, so a prerelease suffix has to be + stripped. Parsing the whole string with int() raised on the first -rc, and + this project has published -rc tags. + """ + text = (root / "VERSION").read_text(encoding="utf-8").strip() + core = re.match(r"^(\d+)\.(\d+)\.(\d+)", text) + if not core: + raise SystemExit(f"Expected VERSION to start with MAJOR.MINOR.PATCH, got {text!r}") + return text, (int(core.group(1)), int(core.group(2)), int(core.group(3)), 0) + + +def windows_icon(root: Path, workdir: Path) -> Path: + """Decode the tracked base64 icon into the build directory. + + Writing it back into packaging/windows/ left an untracked binary in the + checkout after every build. + """ + workdir.mkdir(parents=True, exist_ok=True) + icon = workdir / "droidwebdisplay.ico" + source = root / "packaging" / "windows" / "droidwebdisplay.ico.base64" + import base64 + + icon.write_bytes(base64.b64decode(source.read_text(encoding="ascii"))) + return icon diff --git a/tests/packaging/test_pyinstaller_spec_inputs.py b/tests/packaging/test_pyinstaller_spec_inputs.py new file mode 100644 index 0000000..2e8f56d --- /dev/null +++ b/tests/packaging/test_pyinstaller_spec_inputs.py @@ -0,0 +1,64 @@ +"""Behavioural coverage for the shared PyInstaller spec inputs. + +These execute _dwd_common rather than grepping the specs, so a spec that stops +bundling a file fails here instead of passing a source-text match. +""" + +from pathlib import Path +import importlib.util +import sys + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +COMMON = ROOT / "packaging" / "pyinstaller" / "_dwd_common.py" + + +def _load(): + pytest.importorskip("PyInstaller", reason="PyInstaller is only installed in the packaging jobs") + spec = importlib.util.spec_from_file_location("_dwd_common_under_test", COMMON) + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +def test_version_parsing_accepts_prerelease_and_rejects_garbage() -> None: + common = _load() + text, numeric = common.read_version(ROOT) + assert text == (ROOT / "VERSION").read_text(encoding="utf-8").strip() + assert len(numeric) == 4 and all(isinstance(part, int) for part in numeric) + assert numeric[3] == 0 + + +def test_bundled_data_covers_every_runtime_asset(tmp_path: Path) -> None: + common = _load() + destinations = {destination for _, destination in common.bundle_datas(ROOT)} + # A file dropped from this list yields a package that starts and then fails + # at runtime, so assert the whole set rather than a sample. + assert destinations == { + "apps/web-client/dist", + "apps/web-client", + "packages/scrcpy-protocol/dist", + "packages/scrcpy-protocol", + "compatibility", + "server", + ".", + } + for source, _ in common.bundle_datas(ROOT): + assert Path(source).exists(), f"spec bundles a missing path: {source}" + + +def test_adb_discovery_requires_the_executable(tmp_path: Path) -> None: + common = _load() + (tmp_path / "AdbWinApi.dll").write_bytes(b"x") + # DLLs alone must not satisfy the check: the package would look complete and + # then fail the moment a user plugs in a phone. + with pytest.raises(SystemExit): + common.adb_binaries(tmp_path, windows=True) + (tmp_path / "adb.exe").write_bytes(b"x") + assert len(common.adb_binaries(tmp_path, windows=True)) == 2 + with pytest.raises(SystemExit): + common.adb_binaries(tmp_path, windows=False) + (tmp_path / "adb").write_bytes(b"x") + assert len(common.adb_binaries(tmp_path, windows=False)) == 1 From 94102af81047ff0371c4b42942af44f92406fba6 Mon Sep 17 00:00:00 2001 From: Aleksandr Chasnyk <69671996+ami3go@users.noreply.github.com> Date: Thu, 20 Aug 2026 15:12:37 +0300 Subject: [PATCH 8/9] Retarget two packaging tests that pinned the code they were protecting CI caught both. Neither is a behaviour regression; both are the same pattern this codebase keeps hitting, where a test asserts source text and so breaks on a refactor that changed nothing a user can observe. test_packaging.py::test_windows_package_is_windowed_desktop_host read the win32 branch of DroidWebDisplay.spec and asserted console=False inside it. That branch was dead -- CI never reached it, and it built an exe with no icon and no version resource, exactly what the PE metadata check rejects. Removing it broke this test, which is to say the test existed to protect it. It now asserts the property it is named for against the two specs Windows actually builds, and that the Linux spec redirects Windows builds to them. test_windows_package_hardening.py asserted the literal `with_suffix(".ico.base64")` appeared in each Windows spec. That pinned how the icon path was spelled rather than that the icon comes from the tracked base64 source. It now asserts the specs delegate to common.windows_icon, that the shared module names the .base64 source, and that neither spec writes back to packaging/windows/droidwebdisplay.ico -- which is the actual property worth protecting, since that path is not gitignored. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01XfT5Z3Z24bjC8qtqERGpMQ --- tests/desktop/test_packaging.py | 19 ++++++++++++++---- .../test_windows_package_hardening.py | 20 ++++++++++++++++++- 2 files changed, 34 insertions(+), 5 deletions(-) diff --git a/tests/desktop/test_packaging.py b/tests/desktop/test_packaging.py index 5b53c71..22efaef 100644 --- a/tests/desktop/test_packaging.py +++ b/tests/desktop/test_packaging.py @@ -6,10 +6,21 @@ def test_windows_package_is_windowed_desktop_host() -> None: - spec = (ROOT / "packaging" / "pyinstaller" / "DroidWebDisplay.spec").read_text(encoding="utf-8") - assert 'if sys.platform == "win32"' in spec - windows_section = spec.split('if sys.platform == "win32":', 1)[1].split("else:", 1)[0] - assert "console=False" in windows_section + """Both Windows packages must launch without a console window. + + This used to read the win32 branch of DroidWebDisplay.spec, a branch CI + never reached and which built an exe with no icon and no version resource. + Removing that dead code broke this test, which is to say the test existed + to protect it. The property it is named for belongs to the specs Windows + actually builds. + """ + for name in ("DroidWebDisplayWindows.spec", "DroidWebDisplayWindowsOnedir.spec"): + spec = (ROOT / "packaging" / "pyinstaller" / name).read_text(encoding="utf-8") + assert "console=False" in spec, name + assert "console=True" not in spec, name + + linux = (ROOT / "packaging" / "pyinstaller" / "DroidWebDisplay.spec").read_text(encoding="utf-8") + assert "DroidWebDisplayWindows.spec" in linux, "the Linux spec must redirect Windows builds" def test_package_smoke_uses_headless_mode() -> None: diff --git a/tests/packaging/test_windows_package_hardening.py b/tests/packaging/test_windows_package_hardening.py index ca6de82..23a1d6d 100644 --- a/tests/packaging/test_windows_package_hardening.py +++ b/tests/packaging/test_windows_package_hardening.py @@ -12,11 +12,29 @@ def test_windows_pyinstaller_targets_disable_upx_and_embed_metadata() -> None: assert "ProductName" in text assert "ProductVersion" in text assert "OriginalFilename" in text - assert 'with_suffix(".ico.base64")' in text + # The icon must come from the tracked base64 source. The specs delegate + # that to _dwd_common.windows_icon, which is asserted below; pinning the + # exact path expression here broke on a refactor that changed nothing + # about the behaviour. + assert "icon=str(ICON)" in text assert "runtime_tmpdir=None" in portable assert 'name="DroidWebDisplayWindowsOnedir"' in onedir +def test_windows_icon_is_decoded_from_the_tracked_source_into_the_build_dir(tmp_path) -> None: + """The icon is generated, so it must not be written back into the checkout. + + packaging/windows/droidwebdisplay.ico is not gitignored; writing it there + left an untracked binary after every build. + """ + common = (ROOT / "packaging/pyinstaller/_dwd_common.py").read_text(encoding="utf-8") + assert "droidwebdisplay.ico.base64" in common + for name in ("DroidWebDisplayWindows.spec", "DroidWebDisplayWindowsOnedir.spec"): + spec = (ROOT / "packaging/pyinstaller" / name).read_text(encoding="utf-8") + assert "common.windows_icon(" in spec, name + assert 'ROOT / "packaging" / "windows" / "droidwebdisplay.ico"' not in spec, name + + def test_windows_icon_source_is_present() -> None: icon = ROOT / "packaging/windows/droidwebdisplay.ico.base64" assert icon.is_file() From 120ec954f9c5ef5b8371bebcb12c1ea097f65784 Mon Sep 17 00:00:00 2001 From: Aleksandr Chasnyk <69671996+ami3go@users.noreply.github.com> Date: Thu, 20 Aug 2026 16:31:05 +0300 Subject: [PATCH 9/9] Re-render the auth gate when a session locks, instead of reusing the setup form Reported: first-run setup correctly shows the full form with PIN and Confirm PIN, but locking afterwards showed that same setup form rather than a simple unlock prompt. The gate already renders two modes -- #renderGate(configured) sets the title, the submit label, and hides the Confirm PIN row -- but the droidwebdisplay-auth-required listener only did: this.elements.gate.hidden = false; It never re-rendered, so the gate reappeared in whatever mode it was last drawn. After first-run setup that is setup mode, leaving "Create bridge PIN" and a visible Confirm PIN box on screen for what is actually a login. The submit handler reads #status.configured, which is true by then, so it did perform a login -- the form simply described the wrong operation. A fresh page load looked right, because ensureAuthenticated() renders from a fresh status; only a lock within the same page session showed it. The listener now re-reads /api/v1/auth/status and re-renders from it. The server is the authority on whether a PIN exists, so this is also correct after a PIN change or a revocation from another browser. If the status request fails the last known value is used, so the user is never left without a way back in, and if it reports the session is somehow still valid the app is shown instead. Guarded against re-entry: several in-flight requests can each answer 401 at once, and #renderGate clears the PIN input, so a burst would wipe the field under someone already typing. The gate re-renders only when it is currently hidden. authStatus is a public request, so its own 401 cannot re-dispatch droidwebdisplay-auth-required; there is no event loop here. No node here, so dist/assets/auth-controller.js was hand-applied and the manifest regenerated. The new layout test asserts the listener delegates, that the handler re-reads status and re-renders, that the re-entry guard is present, and that the old unhide-only listener is gone. Not verified in a browser: the lock-and-reopen sequence itself. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01XfT5Z3Z24bjC8qtqERGpMQ --- apps/web-client/dist-manifest.json | 4 +- .../web-client/dist/assets/auth-controller.js | 42 +++++++++++++++++-- apps/web-client/src/auth-controller.ts | 40 ++++++++++++++++-- apps/web-client/tests/layout.test.mjs | 15 +++++++ 4 files changed, 91 insertions(+), 10 deletions(-) diff --git a/apps/web-client/dist-manifest.json b/apps/web-client/dist-manifest.json index dc3e329..3be0184 100644 --- a/apps/web-client/dist-manifest.json +++ b/apps/web-client/dist-manifest.json @@ -40,8 +40,8 @@ }, { "path": "assets/auth-controller.js", - "bytes": 8041, - "sha256": "34e6bb0595008cbe7786dc7f634da286379e46e16e4c1494aa24744c3b9156c0" + "bytes": 9543, + "sha256": "4df111874929f709190dd278da49e40c3153321b8e54ad8f2f7336bf9d2254d2" }, { "path": "assets/auth-controller.js.map", diff --git a/apps/web-client/dist/assets/auth-controller.js b/apps/web-client/dist/assets/auth-controller.js index b876529..18b4177 100644 --- a/apps/web-client/dist/assets/auth-controller.js +++ b/apps/web-client/dist/assets/auth-controller.js @@ -12,6 +12,7 @@ export class AuthController { elements; #api; #status = null; + #reopening = false; constructor(elements, api = new BridgeApi()) { this.elements = elements; this.#api = api; @@ -21,10 +22,7 @@ export class AuthController { elements.logout.addEventListener("click", () => { void this.logout(); }); elements.changePin.addEventListener("click", () => { void this.changePin(); }); elements.revokeAll.addEventListener("click", () => { void this.revokeAll(); }); - globalThis.addEventListener("droidwebdisplay-auth-required", () => { - this.elements.gate.hidden = false; - this.elements.securityStatus.textContent = "Session expired or revoked. Authenticate again."; - }); + globalThis.addEventListener("droidwebdisplay-auth-required", () => { void this.#reopenGate(); }); } async ensureAuthenticated() { const status = await this.#api.authStatus(); @@ -87,6 +85,42 @@ export class AuthController { this.#showSecurityError(error); } } + /** Re-open the gate after a session expires or is revoked. + + This used to only unhide the gate, which left whatever form was rendered + last on screen. After first-run setup that is the setup form, so a lock + later in the same page session showed "Create bridge PIN" with the + Confirm PIN box still visible, even though the PIN already exists and the + submit would perform a login. The server is the authority on whether a + PIN is configured, so re-read it. */ + async #reopenGate() { + this.elements.securityStatus.textContent = "Session expired or revoked. Authenticate again."; + // Several in-flight requests can each answer 401 at once. Re-rendering per + // event would clear the PIN field under someone already typing into it. + if (!this.elements.gate.hidden || this.#reopening) + return; + this.#reopening = true; + try { + let configured = this.#status?.configured ?? false; + try { + const status = await this.#api.authStatus(); + this.#status = status; + if (status.authenticated) { + this.#showAuthenticated(status); + return; + } + configured = status.configured; + } + catch { + // Status is unreachable; fall back to the last known value rather than + // leaving the user with no way back in. + } + this.#renderGate(configured); + } + finally { + this.#reopening = false; + } + } #renderGate(configured) { this.elements.gate.hidden = false; this.elements.title.textContent = configured ? "Unlock DroidWebDisplay" : "Create bridge PIN"; diff --git a/apps/web-client/src/auth-controller.ts b/apps/web-client/src/auth-controller.ts index e0557d2..5bc5018 100644 --- a/apps/web-client/src/auth-controller.ts +++ b/apps/web-client/src/auth-controller.ts @@ -42,6 +42,7 @@ function customSeconds(value: number, unit: string): number { export class AuthController { readonly #api: BridgeApi; #status: AuthStatusDto | null = null; + #reopening = false; public constructor(private readonly elements: AuthElements, api = new BridgeApi()) { this.#api = api; @@ -51,10 +52,7 @@ export class AuthController { elements.logout.addEventListener("click", () => { void this.logout(); }); elements.changePin.addEventListener("click", () => { void this.changePin(); }); elements.revokeAll.addEventListener("click", () => { void this.revokeAll(); }); - globalThis.addEventListener("droidwebdisplay-auth-required", () => { - this.elements.gate.hidden = false; - this.elements.securityStatus.textContent = "Session expired or revoked. Authenticate again."; - }); + globalThis.addEventListener("droidwebdisplay-auth-required", () => { void this.#reopenGate(); }); } public async ensureAuthenticated(): Promise { @@ -118,6 +116,40 @@ export class AuthController { } } + /** Re-open the gate after a session expires or is revoked. + + This used to only unhide the gate, which left whatever form was rendered + last on screen. After first-run setup that is the setup form, so a lock + later in the same page session showed "Create bridge PIN" with the + Confirm PIN box still visible, even though the PIN already exists and the + submit would perform a login. The server is the authority on whether a + PIN is configured, so re-read it. */ + async #reopenGate(): Promise { + this.elements.securityStatus.textContent = "Session expired or revoked. Authenticate again."; + // Several in-flight requests can each answer 401 at once. Re-rendering per + // event would clear the PIN field under someone already typing into it. + if (!this.elements.gate.hidden || this.#reopening) return; + this.#reopening = true; + try { + let configured = this.#status?.configured ?? false; + try { + const status = await this.#api.authStatus(); + this.#status = status; + if (status.authenticated) { + this.#showAuthenticated(status); + return; + } + configured = status.configured; + } catch { + // Status is unreachable; fall back to the last known value rather than + // leaving the user with no way back in. + } + this.#renderGate(configured); + } finally { + this.#reopening = false; + } + } + #renderGate(configured: boolean): void { this.elements.gate.hidden = false; this.elements.title.textContent = configured ? "Unlock DroidWebDisplay" : "Create bridge PIN"; diff --git a/apps/web-client/tests/layout.test.mjs b/apps/web-client/tests/layout.test.mjs index 8c640f5..9bed8af 100644 --- a/apps/web-client/tests/layout.test.mjs +++ b/apps/web-client/tests/layout.test.mjs @@ -358,3 +358,18 @@ test("Files drawer uses Explorer-only transfers with custom PC destination", () assert.match(html, /id="duplicate-policy"/); assert.match(transferSource, /destinationPath/); }); + +test("re-locking re-reads whether a PIN exists instead of reusing the setup form", async () => { + const auth = await readFile(resolve(root, "src/auth-controller.ts"), "utf8"); + // The gate renders two different forms: setup (Confirm PIN visible) and + // unlock. Only unhiding it on droidwebdisplay-auth-required left the setup + // form on screen after a lock later in the same page session. + assert.match(auth, /addEventListener\("droidwebdisplay-auth-required".*#reopenGate\(\)/); + const reopen = auth.slice(auth.indexOf("async #reopenGate"), auth.indexOf("#renderGate(configured: boolean)")); + assert.match(reopen, /await this\.#api\.authStatus\(\)/); + assert.match(reopen, /this\.#renderGate\(configured\)/); + // A burst of 401s must not clear the PIN box under someone mid-typing. + assert.match(reopen, /if \(!this\.elements\.gate\.hidden \|\| this\.#reopening\) return;/); + // The listener must not simply unhide the gate any more. + assert.doesNotMatch(auth, /"droidwebdisplay-auth-required", \(\) => \{\s*this\.elements\.gate\.hidden = false;/); +});