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 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;/); +}); 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" diff --git a/packaging/pyinstaller/DroidWebDisplay.spec b/packaging/pyinstaller/DroidWebDisplay.spec index 376d2e9..a0dedef 100644 --- a/packaging/pyinstaller/DroidWebDisplay.spec +++ b/packaging/pyinstaller/DroidWebDisplay.spec @@ -1,43 +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 -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_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): - 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"))) +if sys.platform == "win32": + # Windows has dedicated specs that attach the icon and the VS version + # 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" + ) -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"), "."), -] +ROOT = common.repo_root(SPECPATH) 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=[], @@ -47,51 +31,33 @@ 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=True, - 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=True, - 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=True, - upx_exclude=[], - name="DroidWebDisplay", - ) +# 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, + [], + 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", +) diff --git a/packaging/pyinstaller/DroidWebDisplayWindows.spec b/packaging/pyinstaller/DroidWebDisplayWindows.spec index 10d51bb..433eff8 100644 --- a/packaging/pyinstaller/DroidWebDisplayWindows.spec +++ b/packaging/pyinstaller/DroidWebDisplayWindows.spec @@ -1,10 +1,7 @@ # -*- mode: python ; coding: utf-8 -*- from pathlib import Path -import base64 -import os import sys -from PyInstaller.utils.hooks import collect_submodules from PyInstaller.utils.win32.versioninfo import ( FixedFileInfo, StringFileInfo, @@ -15,19 +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() -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) - -ICON = ROOT / "packaging" / "windows" / "droidwebdisplay.ico" -ICON.write_bytes(base64.b64decode((ICON.with_suffix(".ico.base64")).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( @@ -57,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 b4fbf17..12c0119 100644 --- a/packaging/pyinstaller/DroidWebDisplayWindowsOnedir.spec +++ b/packaging/pyinstaller/DroidWebDisplayWindowsOnedir.spec @@ -1,10 +1,7 @@ # -*- mode: python ; coding: utf-8 -*- from pathlib import Path -import base64 -import os import sys -from PyInstaller.utils.hooks import collect_submodules from PyInstaller.utils.win32.versioninfo import ( FixedFileInfo, StringFileInfo, @@ -15,19 +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() -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) - -ICON = ROOT / "packaging" / "windows" / "droidwebdisplay.ico" -ICON.write_bytes(base64.b64decode((ICON.with_suffix(".ico.base64")).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( @@ -57,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/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_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 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()