From 029840e76059b7a3ec0bc20b2fadaa00900dfbcb Mon Sep 17 00:00:00 2001 From: Aleksandr Chasnyk <69671996+ami3go@users.noreply.github.com> Date: Thu, 20 Aug 2026 09:16:23 +0300 Subject: [PATCH 01/30] chore: stage Windows package hardening --- tools/apply_windows_package_hardening.py | 699 +++++++++++++++++++++++ 1 file changed, 699 insertions(+) create mode 100644 tools/apply_windows_package_hardening.py diff --git a/tools/apply_windows_package_hardening.py b/tools/apply_windows_package_hardening.py new file mode 100644 index 0000000..f7b87e4 --- /dev/null +++ b/tools/apply_windows_package_hardening.py @@ -0,0 +1,699 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +from pathlib import Path +import struct + +ROOT = Path(__file__).resolve().parents[1] + + +def replace_once(path: str, old: str, new: str) -> None: + target = ROOT / path + text = target.read_text(encoding="utf-8") + if old not in text: + raise SystemExit(f"Expected text not found in {path}: {old[:120]!r}") + target.write_text(text.replace(old, new, 1), encoding="utf-8") + + +def append_once(path: str, marker: str, addition: str) -> None: + target = ROOT / path + text = target.read_text(encoding="utf-8") + if addition.strip() in text: + return + if marker not in text: + raise SystemExit(f"Append marker not found in {path}: {marker!r}") + target.write_text(text.replace(marker, marker + addition, 1), encoding="utf-8") + + +def make_ico(path: Path) -> None: + """Generate a deterministic multi-size ICO matching the existing phone favicon.""" + + def image(size: int) -> bytes: + width = height = size + rgba = bytearray(width * height * 4) + + def blend_pixel(x: int, y: int, r: int, g: int, b: int, a: int = 255) -> None: + if 0 <= x < width and 0 <= y < height: + i = (y * width + x) * 4 + rgba[i : i + 4] = bytes((b, g, r, a)) + + def rounded_rect(x0: float, y0: float, x1: float, y1: float, radius: float, color: tuple[int, int, int, int]) -> None: + for y in range(height): + py = y + 0.5 + for x in range(width): + px = x + 0.5 + cx = min(max(px, x0 + radius), x1 - radius) + cy = min(max(py, y0 + radius), y1 - radius) + if (px - cx) ** 2 + (py - cy) ** 2 <= radius ** 2 and x0 <= px <= x1 and y0 <= py <= y1: + blend_pixel(x, y, *color) + + s = size / 64.0 + rounded_rect(12*s, 2*s, 52*s, 62*s, 9*s, (93, 124, 255, 255)) + rounded_rect(17*s, 10*s, 47*s, 50*s, 4*s, (10, 13, 19, 255)) + rounded_rect(26*s, 6*s, 38*s, 8.5*s, 1.25*s, (220, 228, 255, 255)) + cx, cy, rr = 32*s, 56*s, 2.5*s + for y in range(height): + for x in range(width): + if (x + 0.5 - cx) ** 2 + (y + 0.5 - cy) ** 2 <= rr ** 2: + blend_pixel(x, y, 243, 246, 251, 255) + + # ICO BMP stores rows bottom-up. Height is doubled to include the 1-bit AND mask. + dib = struct.pack( + " {\n', + ' this.#deviceListRefreshedAt = Date.now();\n this.updateConnectAvailability();\n\n' + ' const readyDevices = response.devices.filter((device) => device.ready);\n' + ' if (!readyDevices.length) {\n' + ' const blocked = response.devices.find((device) => device.authorizationRequired);\n' + ' const noPermissions = response.devices.find((device) => device.state === "no permissions");\n' + ' const offline = response.devices.find((device) => device.state === "offline");\n' + ' if (blocked) {\n' + ' this.setStatus("USB authorization required", `Unlock ${blocked.model ?? "the Android device"}, accept “Allow USB debugging?”, then refresh devices.`);\n' + ' } else if (noPermissions) {\n' + ' this.setStatus("ADB access blocked", "The device is visible but ADB cannot access it. On Windows, install/update the phone OEM USB driver and reconnect the cable.");\n' + ' } else if (offline) {\n' + ' this.setStatus("ADB device offline", "Reconnect USB, unlock the phone, and toggle USB debugging if the device remains offline.");\n' + ' } else if (!response.devices.length) {\n' + ' this.setStatus("No Android device", "Connect the phone by USB with USB debugging enabled. Windows may require the manufacturer USB driver.");\n' + ' } else {\n' + ' this.setStatus("ADB device not ready", `Device state: ${response.devices[0]!.state}. Resolve the Android/USB state and refresh devices.`);\n' + ' }\n' + ' } else {\n' + ' const android16 = readyDevices.find((device) => Number.parseInt(device.android_version?.split(".")[0] ?? "", 10) >= 16);\n' + ' if (android16) {\n' + ' this.setStatus("Ready · Android 16", `${android16.model ?? android16.serial} is connected. If scrcpy reports IDisplayWindowListener/AbstractMethodError, DWD will classify it as an upstream Android 16 compatibility failure.`);\n' + ' }\n' + ' }\n' + ' }\n\n public async connect(): Promise {\n', +) + +# --------------------------------------------------------------------------- +# Browser/GPU diagnostics for black-video reports. +# --------------------------------------------------------------------------- +(ROOT / "apps/web-client/src/browser-support.ts").write_text(r'''export interface BrowserCapabilityReport { + readonly supported: boolean; + readonly missing: readonly string[]; + readonly userAgent: string; + readonly browserName: string; + readonly platform: string; + readonly hardwareConcurrency: number | null; + readonly secureContext: boolean | null; + readonly gpuRenderer: string; + readonly audioSupported: boolean; + readonly missingAudio: readonly string[]; +} + +export interface BrowserCapabilityScope { + readonly WebSocket?: unknown; + readonly ReadableStream?: unknown; + readonly WritableStream?: unknown; + readonly VideoDecoder?: unknown; + readonly EncodedVideoChunk?: unknown; + readonly AudioDecoder?: unknown; + readonly EncodedAudioChunk?: unknown; + readonly AudioContext?: unknown; + readonly navigator?: { + readonly userAgent?: string; + readonly platform?: string; + readonly hardwareConcurrency?: number; + }; + readonly isSecureContext?: boolean; + readonly document?: Document; +} + +export function browserName(userAgent: string): string { + const matchers: readonly [RegExp, string][] = [ + [/Edg\/([0-9.]+)/, "Edge"], + [/Chrome\/([0-9.]+)/, "Chrome"], + [/Firefox\/([0-9.]+)/, "Firefox"], + [/Version\/([0-9.]+).*Safari\//, "Safari"], + ]; + for (const [pattern, name] of matchers) { + const match = pattern.exec(userAgent); + if (match) return `${name} ${match[1]}`; + } + return userAgent === "unknown" ? "unknown" : "Other browser"; +} + +function inspectGpuRenderer(scope: BrowserCapabilityScope): string { + try { + const canvas = scope.document?.createElement("canvas") as HTMLCanvasElement | undefined; + const gl = canvas?.getContext("webgl") ?? canvas?.getContext("experimental-webgl"); + if (!gl || !(gl instanceof WebGLRenderingContext)) return "unavailable"; + const debug = gl.getExtension("WEBGL_debug_renderer_info") as { readonly UNMASKED_RENDERER_WEBGL: number } | null; + const renderer = gl.getParameter(debug?.UNMASKED_RENDERER_WEBGL ?? gl.RENDERER); + return typeof renderer === "string" && renderer.trim() ? renderer.trim() : "available"; + } catch { + return "unavailable"; + } +} + +export function inspectBrowserCapabilities(scope: BrowserCapabilityScope = globalThis): BrowserCapabilityReport { + const required = ["WebSocket", "ReadableStream", "WritableStream", "VideoDecoder", "EncodedVideoChunk"] as const; + const missing = required.filter((name) => typeof scope[name] === "undefined"); + const audio = ["AudioDecoder", "EncodedAudioChunk", "AudioContext"] as const; + const missingAudio = audio.filter((name) => typeof scope[name] === "undefined"); + const userAgent = scope.navigator?.userAgent ?? "unknown"; + const concurrency = scope.navigator?.hardwareConcurrency; + return { + supported: missing.length === 0, + missing, + userAgent, + browserName: browserName(userAgent), + platform: scope.navigator?.platform || "unknown", + hardwareConcurrency: typeof concurrency === "number" && Number.isFinite(concurrency) ? concurrency : null, + secureContext: typeof scope.isSecureContext === "boolean" ? scope.isSecureContext : null, + gpuRenderer: inspectGpuRenderer(scope), + audioSupported: missingAudio.length === 0, + missingAudio, + }; +} +''', encoding="utf-8") + +replace_once( + "apps/web-client/src/main.ts", + ' const capabilities = inspectBrowserCapabilities();\n const unsupported = required("#unsupported");\n', + ' const capabilities = inspectBrowserCapabilities();\n' + ' required("#diagnostic-browser").textContent = `${capabilities.browserName} · ${capabilities.platform}`;\n' + ' required("#diagnostic-webcodecs").textContent = capabilities.supported ? "VideoDecoder ready" : `Missing ${capabilities.missing.join(", ")}`;\n' + ' const gpu = required("#diagnostic-gpu");\n' + ' gpu.textContent = capabilities.gpuRenderer.length > 70 ? `${capabilities.gpuRenderer.slice(0, 67)}…` : capabilities.gpuRenderer;\n' + ' gpu.title = capabilities.gpuRenderer;\n' + ' required("#diagnostic-environment").textContent = `${capabilities.hardwareConcurrency ?? "?"} logical CPU · ${capabilities.secureContext === false ? "non-secure context" : "secure/local context"}`;\n' + ' const unsupported = required("#unsupported");\n', +) +replace_once( + "apps/web-client/static/index.html", + '
Free RAM
\n \n
No video statistics
\n', + '
Free RAM
\n' + '
Browser
\n' + '
WebCodecs
\n' + '
GPU
\n' + '
Environment
\n' + ' \n' + '
No video statistics
\n' + '

Black video while controls still work: update Chrome/Edge and the GPU driver; if it persists, disable browser hardware acceleration, restart the browser, and reconnect.

\n', +) + +append_once( + "apps/web-client/tests/browser-support.test.mjs", + 'import { inspectBrowserCapabilities } from "../dist/assets/browser-support.js";\n', + 'import { browserName } from "../dist/assets/browser-support.js";\n', +) +append_once( + "apps/web-client/tests/browser-support.test.mjs", + 'test("reports all mandatory Chromium/WebCodecs capabilities", () => {\n', + '''\ntest("extracts useful browser identity for Windows diagnostics", () => {\n assert.equal(browserName("Mozilla/5.0 Chrome/150.0.0.0 Safari/537.36"), "Chrome 150.0.0.0");\n assert.equal(browserName("Mozilla/5.0 Chrome/150.0.0.0 Safari/537.36 Edg/150.0.0.0"), "Edge 150.0.0.0");\n});\n\n''', +) + +# --------------------------------------------------------------------------- +# Android 16 upstream server failure classification. +# --------------------------------------------------------------------------- +replace_once( + "droid_web_display/scrcpy/session.py", + 'from typing import Awaitable, Callable\n', + 'from typing import Awaitable, Callable, Iterable\n', +) +replace_once( + "droid_web_display/scrcpy/session.py", + 'MAX_RETAINED_TERMINATED_SESSIONS = 20\n\n\n\nclass PrefixedStreamReader:', + 'MAX_RETAINED_TERMINATED_SESSIONS = 20\n' + 'ANDROID16_DISPLAY_LISTENER_FAILURE = "android16_display_listener_incompatibility"\n\n\n' + 'def classify_scrcpy_server_failure(lines: Iterable[str]) -> str | None:\n' + ' text = "\\n".join(lines)\n' + ' if "AbstractMethodError" in text and "IDisplayWindowListener" in text:\n' + ' return ANDROID16_DISPLAY_LISTENER_FAILURE\n' + ' return None\n\n\n' + 'class PrefixedStreamReader:', +) +replace_once( + "droid_web_display/scrcpy/session.py", + ' except Exception as exc:\n' + ' session.state = SessionState.FAILED\n' + ' session.error = str(exc)\n' + ' session.stop_reason = "start_failed"\n' + ' await self._cleanup_session_resources(session)\n' + ' await self._retire_session(session)\n' + ' if isinstance(exc, SessionError):\n' + ' raise\n' + ' raise SessionError(\n' + ' f"Unable to start scrcpy session: {exc}",\n' + ' details={\n' + ' "sessionId": session.session_id,\n' + ' "serial": session.serial,\n' + ' "serverArguments": list(session.server_arguments),\n' + ' "serverLog": list(session.server_log),\n' + ' "classification": session.virtual_display_failure_classification,\n' + ' },\n' + ' ) from exc\n', + ' except Exception as exc:\n' + ' server_log = list(session.server_log)\n' + ' server_failure = classify_scrcpy_server_failure(server_log)\n' + ' session.state = SessionState.FAILED\n' + ' session.error = str(exc)\n' + ' session.stop_reason = "start_failed"\n' + ' await self._cleanup_session_resources(session)\n' + ' await self._retire_session(session)\n' + ' if isinstance(exc, SessionError):\n' + ' raise\n' + ' if server_failure == ANDROID16_DISPLAY_LISTENER_FAILURE:\n' + ' raise SessionError(\n' + ' "scrcpy server hit a known Android 16 display-listener incompatibility",\n' + ' details={\n' + ' "sessionId": session.session_id,\n' + ' "serial": session.serial,\n' + ' "classification": server_failure,\n' + ' "serverLog": server_log,\n' + ' "guidance": "This is an upstream scrcpy server compatibility failure, not a Windows renderer failure. Preserve the current DWD adapter and use a device/Android build that is not affected until a newer scrcpy adapter is promoted through the DWD compatibility gate.",\n' + ' },\n' + ' ) from exc\n' + ' raise SessionError(\n' + ' f"Unable to start scrcpy session: {exc}",\n' + ' details={\n' + ' "sessionId": session.session_id,\n' + ' "serial": session.serial,\n' + ' "serverArguments": list(session.server_arguments),\n' + ' "serverLog": server_log,\n' + ' "classification": session.virtual_display_failure_classification,\n' + ' },\n' + ' ) from exc\n', +) + +(ROOT / "tests/regression/test_scrcpy_android16_failure_classification.py").write_text('''from droid_web_display.scrcpy.session import (\n ANDROID16_DISPLAY_LISTENER_FAILURE,\n classify_scrcpy_server_failure,\n)\n\n\ndef test_android16_display_listener_failure_is_classified() -> None:\n log = [\n "[server] ERROR: Exception on binder thread",\n "java.lang.AbstractMethodError: android.view.IDisplayWindowListener.onDisplayAnimationsDisabledChanged",\n ]\n assert classify_scrcpy_server_failure(log) == ANDROID16_DISPLAY_LISTENER_FAILURE\n\n\ndef test_unrelated_server_failure_is_not_misclassified() -> None:\n assert classify_scrcpy_server_failure(["ERROR: encoder unavailable"]) is None\n''', encoding="utf-8") + +# --------------------------------------------------------------------------- +# CI: build and smoke both Windows distribution forms, verify PE metadata, +# execute bundled ADB, and exercise repeated start/stop cleanup. +# --------------------------------------------------------------------------- +old_windows = ''' - name: Build Windows executable\n run: python -m PyInstaller --noconfirm --clean packaging/pyinstaller/DroidWebDisplay.spec\n - name: Smoke-test Windows desktop host\n shell: pwsh\n run: |\n $process = Start-Process -FilePath ".\\dist\\DroidWebDisplay.exe" -ArgumentList "--desktop-smoke" -Wait -PassThru\n if ($process.ExitCode -ne 0) { exit $process.ExitCode }\n - name: Smoke-test Windows executable CLI\n shell: pwsh\n run: |\n $process = Start-Process -FilePath ".\\dist\\DroidWebDisplay.exe" -ArgumentList "--help" -Wait -PassThru\n if ($process.ExitCode -ne 0) { exit $process.ExitCode }\n - name: Smoke-test Windows executable service\n run: python tools/smoke_desktop_package.py .\\dist\\DroidWebDisplay.exe --timeout 60\n - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4\n with:\n name: windows-package-smoke\n path: dist/DroidWebDisplay.exe\n if-no-files-found: error\n''' +new_windows = ''' - name: Build Windows portable executable\n run: python -m PyInstaller --noconfirm --clean packaging/pyinstaller/DroidWebDisplay.spec\n - name: Build Windows stable onedir package\n run: python -m PyInstaller --noconfirm --clean packaging/pyinstaller/DroidWebDisplayOnedir.spec\n - name: Verify Windows metadata and bundled ADB\n shell: pwsh\n run: |\n $version = (Get-Content VERSION -Raw).Trim()\n $packages = @(\n ".\\dist\\DroidWebDisplay.exe",\n ".\\dist\\DroidWebDisplayOnedir\\DroidWebDisplay.exe"\n )\n foreach ($path in $packages) {\n $info = (Get-Item $path).VersionInfo\n if ($info.ProductName -ne "DroidWebDisplay") { throw "ProductName missing from $path" }\n if (-not $info.FileVersion.StartsWith($version)) { throw "FileVersion $($info.FileVersion) does not match $version in $path" }\n if (-not $info.ProductVersion.StartsWith($version)) { throw "ProductVersion $($info.ProductVersion) does not match $version in $path" }\n if ($info.OriginalFilename -ne "DroidWebDisplay.exe") { throw "OriginalFilename missing from $path" }\n }\n $adb = Get-ChildItem ".\\dist\\DroidWebDisplayOnedir" -Recurse -Filter adb.exe | Select-Object -First 1\n if (-not $adb) { throw "Bundled adb.exe missing from onedir package" }\n & $adb.FullName version\n if ($LASTEXITCODE -ne 0) { throw "Bundled adb.exe failed to execute" }\n - name: Smoke-test Windows portable desktop, CLI and ADB\n shell: pwsh\n run: |\n foreach ($arg in @("--desktop-smoke", "--help", "--adb-smoke")) {\n $process = Start-Process -FilePath ".\\dist\\DroidWebDisplay.exe" -ArgumentList $arg -Wait -PassThru\n if ($process.ExitCode -ne 0) { throw "Portable smoke $arg failed: $($process.ExitCode)" }\n }\n - name: Smoke-test Windows onedir desktop, CLI and ADB\n shell: pwsh\n run: |\n $exe = ".\\dist\\DroidWebDisplayOnedir\\DroidWebDisplay.exe"\n foreach ($arg in @("--desktop-smoke", "--help", "--adb-smoke")) {\n $process = Start-Process -FilePath $exe -ArgumentList $arg -Wait -PassThru\n if ($process.ExitCode -ne 0) { throw "Onedir smoke $arg failed: $($process.ExitCode)" }\n }\n - name: Repeat Windows service start-stop smoke\n shell: pwsh\n run: |\n 1..3 | ForEach-Object { python tools/smoke_desktop_package.py .\\dist\\DroidWebDisplay.exe --timeout 60 }\n 1..3 | ForEach-Object { python tools/smoke_desktop_package.py .\\dist\\DroidWebDisplayOnedir\\DroidWebDisplay.exe --timeout 60 }\n - name: Build Windows onedir ZIP\n shell: pwsh\n run: Compress-Archive -Path ".\\dist\\DroidWebDisplayOnedir\\*" -DestinationPath ".\\dist\\DroidWebDisplay-windows-x86_64.zip" -CompressionLevel Optimal\n - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4\n with:\n name: windows-package-smoke\n path: |\n dist/DroidWebDisplay.exe\n dist/DroidWebDisplay-windows-x86_64.zip\n if-no-files-found: error\n''' +replace_once(".github/workflows/ci.yml", old_windows, new_windows) + +replace_once( + ".github/workflows/release.yml", + ' windows=$(find artifacts/windows -type f -name \'DroidWebDisplay.exe\' -print -quit)\n' + ' linux=$(find artifacts/linux -type f -name \'*.AppImage\' -print -quit)\n' + ' test -n "$windows"\n' + ' test -n "$linux"\n\n' + ' cp "$windows" "release-assets/DroidWebDisplay-v${RELEASE_VERSION}-windows-x86_64.exe"\n' + ' cp "$linux" "release-assets/DroidWebDisplay-v${RELEASE_VERSION}-linux-x86_64.AppImage"\n', + ' windows=$(find artifacts/windows -type f -name \'DroidWebDisplay.exe\' -print -quit)\n' + ' windows_zip=$(find artifacts/windows -type f -name \'DroidWebDisplay-windows-x86_64.zip\' -print -quit)\n' + ' linux=$(find artifacts/linux -type f -name \'*.AppImage\' -print -quit)\n' + ' test -n "$windows"\n' + ' test -n "$windows_zip"\n' + ' test -n "$linux"\n\n' + ' cp "$windows" "release-assets/DroidWebDisplay-v${RELEASE_VERSION}-windows-x86_64.exe"\n' + ' cp "$windows_zip" "release-assets/DroidWebDisplay-v${RELEASE_VERSION}-windows-x86_64.zip"\n' + ' cp "$linux" "release-assets/DroidWebDisplay-v${RELEASE_VERSION}-linux-x86_64.AppImage"\n', +) +replace_once( + ".github/workflows/release.yml", + ' release-assets/DroidWebDisplay-v${RELEASE_VERSION}-windows-x86_64.exe \\\n release-assets/DroidWebDisplay-v${RELEASE_VERSION}-linux-x86_64.AppImage \\\n', + ' release-assets/DroidWebDisplay-v${RELEASE_VERSION}-windows-x86_64.exe \\\n release-assets/DroidWebDisplay-v${RELEASE_VERSION}-windows-x86_64.zip \\\n release-assets/DroidWebDisplay-v${RELEASE_VERSION}-linux-x86_64.AppImage \\\n', +) + +# --------------------------------------------------------------------------- +# Regression guards and operator documentation. +# --------------------------------------------------------------------------- +(ROOT / "tests/packaging/test_windows_package_hardening.py").write_text(r'''from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[2] + + +def test_windows_pyinstaller_targets_are_uncompressed_and_versioned() -> None: + portable = (ROOT / "packaging/pyinstaller/DroidWebDisplay.spec").read_text(encoding="utf-8") + onedir = (ROOT / "packaging/pyinstaller/DroidWebDisplayOnedir.spec").read_text(encoding="utf-8") + for text in (portable, onedir): + assert "upx=False" in text + assert "droidwebdisplay.ico" in text + assert "ProductName" in text + assert "DroidWebDisplay contributors" in text + assert "runtime_tmpdir=None" in portable + assert 'name="DroidWebDisplayOnedir"' in onedir + + +def test_windows_ci_builds_and_smokes_portable_and_onedir_packages() -> None: + workflow = (ROOT / ".github/workflows/ci.yml").read_text(encoding="utf-8") + assert "DroidWebDisplayOnedir.spec" in workflow + assert "DroidWebDisplay-windows-x86_64.zip" in workflow + assert "--adb-smoke" in workflow + assert "Repeat Windows service start-stop smoke" in workflow + assert "ProductName" in workflow + + +def test_release_pipeline_publishes_stable_windows_zip() -> None: + workflow = (ROOT / ".github/workflows/release.yml").read_text(encoding="utf-8") + assert "DroidWebDisplay-v${RELEASE_VERSION}-windows-x86_64.zip" in workflow + + +def test_windows_adb_and_browser_failure_guidance_is_present() -> None: + desktop = (ROOT / "tools/desktop_entry.py").read_text(encoding="utf-8") + controller = (ROOT / "apps/web-client/src/controller.ts").read_text(encoding="utf-8") + html = (ROOT / "apps/web-client/static/index.html").read_text(encoding="utf-8") + assert '"--adb-smoke"' in desktop + assert "USB authorization required" in controller + assert "manufacturer USB driver" in controller + assert "Android 16" in controller + assert "disable browser hardware acceleration" in html + assert 'id="diagnostic-gpu"' in html +''', encoding="utf-8") + +(ROOT / "docs/WINDOWS_PACKAGING.md").write_text('''# Windows packaging and troubleshooting\n\nDroidWebDisplay ships two unsigned Windows distribution forms. Code signing is intentionally deferred; these packaging rules are independent of signing.\n\n## Distribution forms\n\n- **Portable EXE** — `DroidWebDisplay-vX.Y.Z-windows-x86_64.exe`. Convenient single-file launch. PyInstaller extracts this form to a temporary runtime directory while it is running.\n- **Stable onedir ZIP** — `DroidWebDisplay-vX.Y.Z-windows-x86_64.zip`. Recommended for long-running installations because dependencies live in a stable directory instead of a temporary `_MEI...` extraction tree. Extract the ZIP and run `DroidWebDisplay.exe`.\n\nBoth forms include the verified Android platform-tools ADB executable and DLLs, the verified scrcpy server, the web client, licenses, and identical application version metadata. UPX compression is disabled.\n\n## USB / ADB states\n\nDroidWebDisplay surfaces ADB states explicitly. `unauthorized`/`authorizing` means the phone must be unlocked and the **Allow USB debugging?** prompt accepted. `offline` usually needs a USB reconnect or USB-debugging reset. `no permissions` means host access is blocked; on Windows, install or update the phone manufacturer's USB driver.\n\n## Black video with working controls\n\nDroidWebDisplay uses browser WebCodecs rather than scrcpy's native Windows renderer. Diagnostics reports the browser, WebCodecs availability, platform and visible WebGL GPU renderer. If controls work but the picture remains black: update Chrome/Edge, update the GPU driver, then try disabling browser hardware acceleration and restart the browser.\n\n## Android 16 compatibility\n\nThe protected stable adapter remains scrcpy 4.1. Some Android 16 builds have produced an upstream `AbstractMethodError` involving `IDisplayWindowListener`. DroidWebDisplay classifies that server signature explicitly so it is not mistaken for a Windows packaging or GPU failure. The stable scrcpy adapter must not be replaced until the normal DWD compatibility and HIL gates prove an equal-or-better update.\n''', encoding="utf-8") + +(ROOT / "docs/WINDOWS_RELEASE_HIL.md").write_text('''# Windows release HIL checklist\n\nRun this checklist on the exact packaged Windows artifact before promoting a release when hardware is available. CI covers package startup, repeated shutdown, bundled ADB execution, PE version metadata and service availability; these hardware/browser checks remain real-device qualification.\n\n- Start both the portable EXE and extracted onedir ZIP.\n- Verify the connected Android device transitions through ADB authorization correctly.\n- Connect physical display and confirm the first video frame without requiring Rotate.\n- Rotate twice and confirm video recovers without reconnecting.\n- Verify mouse/touch controls, PC keyboard input, Back/Home/Recent and power control.\n- Verify Android→PC automatic clipboard, Copy button and Ctrl+C.\n- Verify PC→Android automatic clipboard, Paste/Ctrl+V and Type; normal typing must remain usable.\n- Disconnect/reconnect and confirm clipboard/session state does not leak.\n- Transfer a file in both directions.\n- Leave a session active through Windows display-off and sleep/resume, then verify video/control reconnect.\n- Run a multi-hour soak and confirm no orphan `DroidWebDisplay.exe`, `adb.exe` or stale service remains after exit.\n- On at least one Android 16 device, confirm either normal operation or the explicit upstream display-listener classification.\n- If video is black with controls active, record browser version, GPU renderer and the result of toggling browser hardware acceleration.\n''', encoding="utf-8") + +print("Windows package hardening patch applied") From 7ab6de981614de237dc54c80dd8e762074fdb89a Mon Sep 17 00:00:00 2001 From: Aleksandr Chasnyk <69671996+ami3go@users.noreply.github.com> Date: Thu, 20 Aug 2026 09:16:46 +0300 Subject: [PATCH 02/30] chore: apply Windows package hardening --- .../apply-windows-package-hardening.yml | 81 +++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 .github/workflows/apply-windows-package-hardening.yml diff --git a/.github/workflows/apply-windows-package-hardening.yml b/.github/workflows/apply-windows-package-hardening.yml new file mode 100644 index 0000000..4347d78 --- /dev/null +++ b/.github/workflows/apply-windows-package-hardening.yml @@ -0,0 +1,81 @@ +name: Apply Windows Package Hardening + +on: + push: + branches: [agent/windows-package-hardening] + +permissions: + contents: write + +concurrency: + group: apply-windows-package-hardening + cancel-in-progress: true + +jobs: + apply: + if: ${{ github.actor != 'github-actions[bot]' }} + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + ref: agent/windows-package-hardening + fetch-depth: 0 + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: '3.11.15' + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: '22.23.2' + cache: npm + cache-dependency-path: | + apps/web-client/package-lock.json + packages/scrcpy-protocol/package-lock.json + - name: Apply focused hardening patch + run: python tools/apply_windows_package_hardening.py + - name: Build protocol package + working-directory: packages/scrcpy-protocol + run: | + npm ci + npm run build + - name: Build and test web client + working-directory: apps/web-client + run: | + npm ci + npm test + - name: Python syntax check + run: python -m compileall -q droid_web_display tools + - name: Validate patch + run: | + git diff --check + test -s packaging/windows/droidwebdisplay.ico + test -f packaging/pyinstaller/DroidWebDisplayOnedir.spec + grep -q 'upx=False' packaging/pyinstaller/DroidWebDisplay.spec + grep -q 'windows-x86_64.zip' .github/workflows/release.yml + - name: Commit hardened implementation + shell: bash + run: | + set -euo pipefail + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git rm tools/apply_windows_package_hardening.py .github/workflows/apply-windows-package-hardening.yml + git add -- \ + packaging/pyinstaller/DroidWebDisplay.spec \ + packaging/pyinstaller/DroidWebDisplayOnedir.spec \ + packaging/windows/droidwebdisplay.ico \ + tools/desktop_entry.py \ + apps/web-client/src/browser-support.ts \ + apps/web-client/src/controller.ts \ + apps/web-client/src/main.ts \ + apps/web-client/static/index.html \ + apps/web-client/tests/browser-support.test.mjs \ + apps/web-client/dist \ + apps/web-client/dist-manifest.json \ + droid_web_display/scrcpy/session.py \ + tests/regression/test_scrcpy_android16_failure_classification.py \ + tests/packaging/test_windows_package_hardening.py \ + .github/workflows/ci.yml \ + .github/workflows/release.yml \ + docs/WINDOWS_PACKAGING.md \ + docs/WINDOWS_RELEASE_HIL.md + git commit -m "fix: harden Windows packaging and diagnostics" + git push origin HEAD:agent/windows-package-hardening From 0aa835daed9fc7db749a2a7cc18429e6f61b98d0 Mon Sep 17 00:00:00 2001 From: Aleksandr Chasnyk <69671996+ami3go@users.noreply.github.com> Date: Thu, 20 Aug 2026 09:18:11 +0300 Subject: [PATCH 03/30] chore: trigger Windows hardening apply workflow --- tools/.windows-hardening-trigger | 1 + 1 file changed, 1 insertion(+) create mode 100644 tools/.windows-hardening-trigger diff --git a/tools/.windows-hardening-trigger b/tools/.windows-hardening-trigger new file mode 100644 index 0000000..5c33b15 --- /dev/null +++ b/tools/.windows-hardening-trigger @@ -0,0 +1 @@ +trigger From ad35db5af72a75a8548e47b8841bc8936a31b6f5 Mon Sep 17 00:00:00 2001 From: Aleksandr Chasnyk <69671996+ami3go@users.noreply.github.com> Date: Thu, 20 Aug 2026 09:21:19 +0300 Subject: [PATCH 04/30] chore: run Windows hardening workflow on PR --- .github/workflows/apply-windows-package-hardening.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/apply-windows-package-hardening.yml b/.github/workflows/apply-windows-package-hardening.yml index 4347d78..92ed50c 100644 --- a/.github/workflows/apply-windows-package-hardening.yml +++ b/.github/workflows/apply-windows-package-hardening.yml @@ -3,6 +3,8 @@ name: Apply Windows Package Hardening on: push: branches: [agent/windows-package-hardening] + pull_request: + branches: [main] permissions: contents: write @@ -20,7 +22,7 @@ jobs: with: ref: agent/windows-package-hardening fetch-depth: 0 - - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + - uses: actions/setup-python@a26af69be951a213d495a4e3e4022e16d87065 # v5 with: python-version: '3.11.15' - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 @@ -58,6 +60,7 @@ jobs: git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" git rm tools/apply_windows_package_hardening.py .github/workflows/apply-windows-package-hardening.yml + git rm -f tools/.windows-hardening-trigger 2>/dev/null || true git add -- \ packaging/pyinstaller/DroidWebDisplay.spec \ packaging/pyinstaller/DroidWebDisplayOnedir.spec \ From 1bce6e6a4c9c2856c88a62e89d02ae0fbc9b1b4d Mon Sep 17 00:00:00 2001 From: Aleksandr Chasnyk <69671996+ami3go@users.noreply.github.com> Date: Thu, 20 Aug 2026 09:23:12 +0300 Subject: [PATCH 05/30] chore: remove Windows hardening trigger --- tools/.windows-hardening-trigger | 1 - 1 file changed, 1 deletion(-) delete mode 100644 tools/.windows-hardening-trigger diff --git a/tools/.windows-hardening-trigger b/tools/.windows-hardening-trigger deleted file mode 100644 index 5c33b15..0000000 --- a/tools/.windows-hardening-trigger +++ /dev/null @@ -1 +0,0 @@ -trigger From 1c433bfd2b517479747d6dc9ab26c9ab68991be9 Mon Sep 17 00:00:00 2001 From: Aleksandr Chasnyk <69671996+ami3go@users.noreply.github.com> Date: Thu, 20 Aug 2026 09:23:24 +0300 Subject: [PATCH 06/30] chore: remove Windows hardening helper --- tools/apply_windows_package_hardening.py | 699 ----------------------- 1 file changed, 699 deletions(-) delete mode 100644 tools/apply_windows_package_hardening.py diff --git a/tools/apply_windows_package_hardening.py b/tools/apply_windows_package_hardening.py deleted file mode 100644 index f7b87e4..0000000 --- a/tools/apply_windows_package_hardening.py +++ /dev/null @@ -1,699 +0,0 @@ -#!/usr/bin/env python3 -from __future__ import annotations - -from pathlib import Path -import struct - -ROOT = Path(__file__).resolve().parents[1] - - -def replace_once(path: str, old: str, new: str) -> None: - target = ROOT / path - text = target.read_text(encoding="utf-8") - if old not in text: - raise SystemExit(f"Expected text not found in {path}: {old[:120]!r}") - target.write_text(text.replace(old, new, 1), encoding="utf-8") - - -def append_once(path: str, marker: str, addition: str) -> None: - target = ROOT / path - text = target.read_text(encoding="utf-8") - if addition.strip() in text: - return - if marker not in text: - raise SystemExit(f"Append marker not found in {path}: {marker!r}") - target.write_text(text.replace(marker, marker + addition, 1), encoding="utf-8") - - -def make_ico(path: Path) -> None: - """Generate a deterministic multi-size ICO matching the existing phone favicon.""" - - def image(size: int) -> bytes: - width = height = size - rgba = bytearray(width * height * 4) - - def blend_pixel(x: int, y: int, r: int, g: int, b: int, a: int = 255) -> None: - if 0 <= x < width and 0 <= y < height: - i = (y * width + x) * 4 - rgba[i : i + 4] = bytes((b, g, r, a)) - - def rounded_rect(x0: float, y0: float, x1: float, y1: float, radius: float, color: tuple[int, int, int, int]) -> None: - for y in range(height): - py = y + 0.5 - for x in range(width): - px = x + 0.5 - cx = min(max(px, x0 + radius), x1 - radius) - cy = min(max(py, y0 + radius), y1 - radius) - if (px - cx) ** 2 + (py - cy) ** 2 <= radius ** 2 and x0 <= px <= x1 and y0 <= py <= y1: - blend_pixel(x, y, *color) - - s = size / 64.0 - rounded_rect(12*s, 2*s, 52*s, 62*s, 9*s, (93, 124, 255, 255)) - rounded_rect(17*s, 10*s, 47*s, 50*s, 4*s, (10, 13, 19, 255)) - rounded_rect(26*s, 6*s, 38*s, 8.5*s, 1.25*s, (220, 228, 255, 255)) - cx, cy, rr = 32*s, 56*s, 2.5*s - for y in range(height): - for x in range(width): - if (x + 0.5 - cx) ** 2 + (y + 0.5 - cy) ** 2 <= rr ** 2: - blend_pixel(x, y, 243, 246, 251, 255) - - # ICO BMP stores rows bottom-up. Height is doubled to include the 1-bit AND mask. - dib = struct.pack( - " {\n', - ' this.#deviceListRefreshedAt = Date.now();\n this.updateConnectAvailability();\n\n' - ' const readyDevices = response.devices.filter((device) => device.ready);\n' - ' if (!readyDevices.length) {\n' - ' const blocked = response.devices.find((device) => device.authorizationRequired);\n' - ' const noPermissions = response.devices.find((device) => device.state === "no permissions");\n' - ' const offline = response.devices.find((device) => device.state === "offline");\n' - ' if (blocked) {\n' - ' this.setStatus("USB authorization required", `Unlock ${blocked.model ?? "the Android device"}, accept “Allow USB debugging?”, then refresh devices.`);\n' - ' } else if (noPermissions) {\n' - ' this.setStatus("ADB access blocked", "The device is visible but ADB cannot access it. On Windows, install/update the phone OEM USB driver and reconnect the cable.");\n' - ' } else if (offline) {\n' - ' this.setStatus("ADB device offline", "Reconnect USB, unlock the phone, and toggle USB debugging if the device remains offline.");\n' - ' } else if (!response.devices.length) {\n' - ' this.setStatus("No Android device", "Connect the phone by USB with USB debugging enabled. Windows may require the manufacturer USB driver.");\n' - ' } else {\n' - ' this.setStatus("ADB device not ready", `Device state: ${response.devices[0]!.state}. Resolve the Android/USB state and refresh devices.`);\n' - ' }\n' - ' } else {\n' - ' const android16 = readyDevices.find((device) => Number.parseInt(device.android_version?.split(".")[0] ?? "", 10) >= 16);\n' - ' if (android16) {\n' - ' this.setStatus("Ready · Android 16", `${android16.model ?? android16.serial} is connected. If scrcpy reports IDisplayWindowListener/AbstractMethodError, DWD will classify it as an upstream Android 16 compatibility failure.`);\n' - ' }\n' - ' }\n' - ' }\n\n public async connect(): Promise {\n', -) - -# --------------------------------------------------------------------------- -# Browser/GPU diagnostics for black-video reports. -# --------------------------------------------------------------------------- -(ROOT / "apps/web-client/src/browser-support.ts").write_text(r'''export interface BrowserCapabilityReport { - readonly supported: boolean; - readonly missing: readonly string[]; - readonly userAgent: string; - readonly browserName: string; - readonly platform: string; - readonly hardwareConcurrency: number | null; - readonly secureContext: boolean | null; - readonly gpuRenderer: string; - readonly audioSupported: boolean; - readonly missingAudio: readonly string[]; -} - -export interface BrowserCapabilityScope { - readonly WebSocket?: unknown; - readonly ReadableStream?: unknown; - readonly WritableStream?: unknown; - readonly VideoDecoder?: unknown; - readonly EncodedVideoChunk?: unknown; - readonly AudioDecoder?: unknown; - readonly EncodedAudioChunk?: unknown; - readonly AudioContext?: unknown; - readonly navigator?: { - readonly userAgent?: string; - readonly platform?: string; - readonly hardwareConcurrency?: number; - }; - readonly isSecureContext?: boolean; - readonly document?: Document; -} - -export function browserName(userAgent: string): string { - const matchers: readonly [RegExp, string][] = [ - [/Edg\/([0-9.]+)/, "Edge"], - [/Chrome\/([0-9.]+)/, "Chrome"], - [/Firefox\/([0-9.]+)/, "Firefox"], - [/Version\/([0-9.]+).*Safari\//, "Safari"], - ]; - for (const [pattern, name] of matchers) { - const match = pattern.exec(userAgent); - if (match) return `${name} ${match[1]}`; - } - return userAgent === "unknown" ? "unknown" : "Other browser"; -} - -function inspectGpuRenderer(scope: BrowserCapabilityScope): string { - try { - const canvas = scope.document?.createElement("canvas") as HTMLCanvasElement | undefined; - const gl = canvas?.getContext("webgl") ?? canvas?.getContext("experimental-webgl"); - if (!gl || !(gl instanceof WebGLRenderingContext)) return "unavailable"; - const debug = gl.getExtension("WEBGL_debug_renderer_info") as { readonly UNMASKED_RENDERER_WEBGL: number } | null; - const renderer = gl.getParameter(debug?.UNMASKED_RENDERER_WEBGL ?? gl.RENDERER); - return typeof renderer === "string" && renderer.trim() ? renderer.trim() : "available"; - } catch { - return "unavailable"; - } -} - -export function inspectBrowserCapabilities(scope: BrowserCapabilityScope = globalThis): BrowserCapabilityReport { - const required = ["WebSocket", "ReadableStream", "WritableStream", "VideoDecoder", "EncodedVideoChunk"] as const; - const missing = required.filter((name) => typeof scope[name] === "undefined"); - const audio = ["AudioDecoder", "EncodedAudioChunk", "AudioContext"] as const; - const missingAudio = audio.filter((name) => typeof scope[name] === "undefined"); - const userAgent = scope.navigator?.userAgent ?? "unknown"; - const concurrency = scope.navigator?.hardwareConcurrency; - return { - supported: missing.length === 0, - missing, - userAgent, - browserName: browserName(userAgent), - platform: scope.navigator?.platform || "unknown", - hardwareConcurrency: typeof concurrency === "number" && Number.isFinite(concurrency) ? concurrency : null, - secureContext: typeof scope.isSecureContext === "boolean" ? scope.isSecureContext : null, - gpuRenderer: inspectGpuRenderer(scope), - audioSupported: missingAudio.length === 0, - missingAudio, - }; -} -''', encoding="utf-8") - -replace_once( - "apps/web-client/src/main.ts", - ' const capabilities = inspectBrowserCapabilities();\n const unsupported = required("#unsupported");\n', - ' const capabilities = inspectBrowserCapabilities();\n' - ' required("#diagnostic-browser").textContent = `${capabilities.browserName} · ${capabilities.platform}`;\n' - ' required("#diagnostic-webcodecs").textContent = capabilities.supported ? "VideoDecoder ready" : `Missing ${capabilities.missing.join(", ")}`;\n' - ' const gpu = required("#diagnostic-gpu");\n' - ' gpu.textContent = capabilities.gpuRenderer.length > 70 ? `${capabilities.gpuRenderer.slice(0, 67)}…` : capabilities.gpuRenderer;\n' - ' gpu.title = capabilities.gpuRenderer;\n' - ' required("#diagnostic-environment").textContent = `${capabilities.hardwareConcurrency ?? "?"} logical CPU · ${capabilities.secureContext === false ? "non-secure context" : "secure/local context"}`;\n' - ' const unsupported = required("#unsupported");\n', -) -replace_once( - "apps/web-client/static/index.html", - '
Free RAM
\n \n
No video statistics
\n', - '
Free RAM
\n' - '
Browser
\n' - '
WebCodecs
\n' - '
GPU
\n' - '
Environment
\n' - ' \n' - '
No video statistics
\n' - '

Black video while controls still work: update Chrome/Edge and the GPU driver; if it persists, disable browser hardware acceleration, restart the browser, and reconnect.

\n', -) - -append_once( - "apps/web-client/tests/browser-support.test.mjs", - 'import { inspectBrowserCapabilities } from "../dist/assets/browser-support.js";\n', - 'import { browserName } from "../dist/assets/browser-support.js";\n', -) -append_once( - "apps/web-client/tests/browser-support.test.mjs", - 'test("reports all mandatory Chromium/WebCodecs capabilities", () => {\n', - '''\ntest("extracts useful browser identity for Windows diagnostics", () => {\n assert.equal(browserName("Mozilla/5.0 Chrome/150.0.0.0 Safari/537.36"), "Chrome 150.0.0.0");\n assert.equal(browserName("Mozilla/5.0 Chrome/150.0.0.0 Safari/537.36 Edg/150.0.0.0"), "Edge 150.0.0.0");\n});\n\n''', -) - -# --------------------------------------------------------------------------- -# Android 16 upstream server failure classification. -# --------------------------------------------------------------------------- -replace_once( - "droid_web_display/scrcpy/session.py", - 'from typing import Awaitable, Callable\n', - 'from typing import Awaitable, Callable, Iterable\n', -) -replace_once( - "droid_web_display/scrcpy/session.py", - 'MAX_RETAINED_TERMINATED_SESSIONS = 20\n\n\n\nclass PrefixedStreamReader:', - 'MAX_RETAINED_TERMINATED_SESSIONS = 20\n' - 'ANDROID16_DISPLAY_LISTENER_FAILURE = "android16_display_listener_incompatibility"\n\n\n' - 'def classify_scrcpy_server_failure(lines: Iterable[str]) -> str | None:\n' - ' text = "\\n".join(lines)\n' - ' if "AbstractMethodError" in text and "IDisplayWindowListener" in text:\n' - ' return ANDROID16_DISPLAY_LISTENER_FAILURE\n' - ' return None\n\n\n' - 'class PrefixedStreamReader:', -) -replace_once( - "droid_web_display/scrcpy/session.py", - ' except Exception as exc:\n' - ' session.state = SessionState.FAILED\n' - ' session.error = str(exc)\n' - ' session.stop_reason = "start_failed"\n' - ' await self._cleanup_session_resources(session)\n' - ' await self._retire_session(session)\n' - ' if isinstance(exc, SessionError):\n' - ' raise\n' - ' raise SessionError(\n' - ' f"Unable to start scrcpy session: {exc}",\n' - ' details={\n' - ' "sessionId": session.session_id,\n' - ' "serial": session.serial,\n' - ' "serverArguments": list(session.server_arguments),\n' - ' "serverLog": list(session.server_log),\n' - ' "classification": session.virtual_display_failure_classification,\n' - ' },\n' - ' ) from exc\n', - ' except Exception as exc:\n' - ' server_log = list(session.server_log)\n' - ' server_failure = classify_scrcpy_server_failure(server_log)\n' - ' session.state = SessionState.FAILED\n' - ' session.error = str(exc)\n' - ' session.stop_reason = "start_failed"\n' - ' await self._cleanup_session_resources(session)\n' - ' await self._retire_session(session)\n' - ' if isinstance(exc, SessionError):\n' - ' raise\n' - ' if server_failure == ANDROID16_DISPLAY_LISTENER_FAILURE:\n' - ' raise SessionError(\n' - ' "scrcpy server hit a known Android 16 display-listener incompatibility",\n' - ' details={\n' - ' "sessionId": session.session_id,\n' - ' "serial": session.serial,\n' - ' "classification": server_failure,\n' - ' "serverLog": server_log,\n' - ' "guidance": "This is an upstream scrcpy server compatibility failure, not a Windows renderer failure. Preserve the current DWD adapter and use a device/Android build that is not affected until a newer scrcpy adapter is promoted through the DWD compatibility gate.",\n' - ' },\n' - ' ) from exc\n' - ' raise SessionError(\n' - ' f"Unable to start scrcpy session: {exc}",\n' - ' details={\n' - ' "sessionId": session.session_id,\n' - ' "serial": session.serial,\n' - ' "serverArguments": list(session.server_arguments),\n' - ' "serverLog": server_log,\n' - ' "classification": session.virtual_display_failure_classification,\n' - ' },\n' - ' ) from exc\n', -) - -(ROOT / "tests/regression/test_scrcpy_android16_failure_classification.py").write_text('''from droid_web_display.scrcpy.session import (\n ANDROID16_DISPLAY_LISTENER_FAILURE,\n classify_scrcpy_server_failure,\n)\n\n\ndef test_android16_display_listener_failure_is_classified() -> None:\n log = [\n "[server] ERROR: Exception on binder thread",\n "java.lang.AbstractMethodError: android.view.IDisplayWindowListener.onDisplayAnimationsDisabledChanged",\n ]\n assert classify_scrcpy_server_failure(log) == ANDROID16_DISPLAY_LISTENER_FAILURE\n\n\ndef test_unrelated_server_failure_is_not_misclassified() -> None:\n assert classify_scrcpy_server_failure(["ERROR: encoder unavailable"]) is None\n''', encoding="utf-8") - -# --------------------------------------------------------------------------- -# CI: build and smoke both Windows distribution forms, verify PE metadata, -# execute bundled ADB, and exercise repeated start/stop cleanup. -# --------------------------------------------------------------------------- -old_windows = ''' - name: Build Windows executable\n run: python -m PyInstaller --noconfirm --clean packaging/pyinstaller/DroidWebDisplay.spec\n - name: Smoke-test Windows desktop host\n shell: pwsh\n run: |\n $process = Start-Process -FilePath ".\\dist\\DroidWebDisplay.exe" -ArgumentList "--desktop-smoke" -Wait -PassThru\n if ($process.ExitCode -ne 0) { exit $process.ExitCode }\n - name: Smoke-test Windows executable CLI\n shell: pwsh\n run: |\n $process = Start-Process -FilePath ".\\dist\\DroidWebDisplay.exe" -ArgumentList "--help" -Wait -PassThru\n if ($process.ExitCode -ne 0) { exit $process.ExitCode }\n - name: Smoke-test Windows executable service\n run: python tools/smoke_desktop_package.py .\\dist\\DroidWebDisplay.exe --timeout 60\n - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4\n with:\n name: windows-package-smoke\n path: dist/DroidWebDisplay.exe\n if-no-files-found: error\n''' -new_windows = ''' - name: Build Windows portable executable\n run: python -m PyInstaller --noconfirm --clean packaging/pyinstaller/DroidWebDisplay.spec\n - name: Build Windows stable onedir package\n run: python -m PyInstaller --noconfirm --clean packaging/pyinstaller/DroidWebDisplayOnedir.spec\n - name: Verify Windows metadata and bundled ADB\n shell: pwsh\n run: |\n $version = (Get-Content VERSION -Raw).Trim()\n $packages = @(\n ".\\dist\\DroidWebDisplay.exe",\n ".\\dist\\DroidWebDisplayOnedir\\DroidWebDisplay.exe"\n )\n foreach ($path in $packages) {\n $info = (Get-Item $path).VersionInfo\n if ($info.ProductName -ne "DroidWebDisplay") { throw "ProductName missing from $path" }\n if (-not $info.FileVersion.StartsWith($version)) { throw "FileVersion $($info.FileVersion) does not match $version in $path" }\n if (-not $info.ProductVersion.StartsWith($version)) { throw "ProductVersion $($info.ProductVersion) does not match $version in $path" }\n if ($info.OriginalFilename -ne "DroidWebDisplay.exe") { throw "OriginalFilename missing from $path" }\n }\n $adb = Get-ChildItem ".\\dist\\DroidWebDisplayOnedir" -Recurse -Filter adb.exe | Select-Object -First 1\n if (-not $adb) { throw "Bundled adb.exe missing from onedir package" }\n & $adb.FullName version\n if ($LASTEXITCODE -ne 0) { throw "Bundled adb.exe failed to execute" }\n - name: Smoke-test Windows portable desktop, CLI and ADB\n shell: pwsh\n run: |\n foreach ($arg in @("--desktop-smoke", "--help", "--adb-smoke")) {\n $process = Start-Process -FilePath ".\\dist\\DroidWebDisplay.exe" -ArgumentList $arg -Wait -PassThru\n if ($process.ExitCode -ne 0) { throw "Portable smoke $arg failed: $($process.ExitCode)" }\n }\n - name: Smoke-test Windows onedir desktop, CLI and ADB\n shell: pwsh\n run: |\n $exe = ".\\dist\\DroidWebDisplayOnedir\\DroidWebDisplay.exe"\n foreach ($arg in @("--desktop-smoke", "--help", "--adb-smoke")) {\n $process = Start-Process -FilePath $exe -ArgumentList $arg -Wait -PassThru\n if ($process.ExitCode -ne 0) { throw "Onedir smoke $arg failed: $($process.ExitCode)" }\n }\n - name: Repeat Windows service start-stop smoke\n shell: pwsh\n run: |\n 1..3 | ForEach-Object { python tools/smoke_desktop_package.py .\\dist\\DroidWebDisplay.exe --timeout 60 }\n 1..3 | ForEach-Object { python tools/smoke_desktop_package.py .\\dist\\DroidWebDisplayOnedir\\DroidWebDisplay.exe --timeout 60 }\n - name: Build Windows onedir ZIP\n shell: pwsh\n run: Compress-Archive -Path ".\\dist\\DroidWebDisplayOnedir\\*" -DestinationPath ".\\dist\\DroidWebDisplay-windows-x86_64.zip" -CompressionLevel Optimal\n - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4\n with:\n name: windows-package-smoke\n path: |\n dist/DroidWebDisplay.exe\n dist/DroidWebDisplay-windows-x86_64.zip\n if-no-files-found: error\n''' -replace_once(".github/workflows/ci.yml", old_windows, new_windows) - -replace_once( - ".github/workflows/release.yml", - ' windows=$(find artifacts/windows -type f -name \'DroidWebDisplay.exe\' -print -quit)\n' - ' linux=$(find artifacts/linux -type f -name \'*.AppImage\' -print -quit)\n' - ' test -n "$windows"\n' - ' test -n "$linux"\n\n' - ' cp "$windows" "release-assets/DroidWebDisplay-v${RELEASE_VERSION}-windows-x86_64.exe"\n' - ' cp "$linux" "release-assets/DroidWebDisplay-v${RELEASE_VERSION}-linux-x86_64.AppImage"\n', - ' windows=$(find artifacts/windows -type f -name \'DroidWebDisplay.exe\' -print -quit)\n' - ' windows_zip=$(find artifacts/windows -type f -name \'DroidWebDisplay-windows-x86_64.zip\' -print -quit)\n' - ' linux=$(find artifacts/linux -type f -name \'*.AppImage\' -print -quit)\n' - ' test -n "$windows"\n' - ' test -n "$windows_zip"\n' - ' test -n "$linux"\n\n' - ' cp "$windows" "release-assets/DroidWebDisplay-v${RELEASE_VERSION}-windows-x86_64.exe"\n' - ' cp "$windows_zip" "release-assets/DroidWebDisplay-v${RELEASE_VERSION}-windows-x86_64.zip"\n' - ' cp "$linux" "release-assets/DroidWebDisplay-v${RELEASE_VERSION}-linux-x86_64.AppImage"\n', -) -replace_once( - ".github/workflows/release.yml", - ' release-assets/DroidWebDisplay-v${RELEASE_VERSION}-windows-x86_64.exe \\\n release-assets/DroidWebDisplay-v${RELEASE_VERSION}-linux-x86_64.AppImage \\\n', - ' release-assets/DroidWebDisplay-v${RELEASE_VERSION}-windows-x86_64.exe \\\n release-assets/DroidWebDisplay-v${RELEASE_VERSION}-windows-x86_64.zip \\\n release-assets/DroidWebDisplay-v${RELEASE_VERSION}-linux-x86_64.AppImage \\\n', -) - -# --------------------------------------------------------------------------- -# Regression guards and operator documentation. -# --------------------------------------------------------------------------- -(ROOT / "tests/packaging/test_windows_package_hardening.py").write_text(r'''from pathlib import Path - - -ROOT = Path(__file__).resolve().parents[2] - - -def test_windows_pyinstaller_targets_are_uncompressed_and_versioned() -> None: - portable = (ROOT / "packaging/pyinstaller/DroidWebDisplay.spec").read_text(encoding="utf-8") - onedir = (ROOT / "packaging/pyinstaller/DroidWebDisplayOnedir.spec").read_text(encoding="utf-8") - for text in (portable, onedir): - assert "upx=False" in text - assert "droidwebdisplay.ico" in text - assert "ProductName" in text - assert "DroidWebDisplay contributors" in text - assert "runtime_tmpdir=None" in portable - assert 'name="DroidWebDisplayOnedir"' in onedir - - -def test_windows_ci_builds_and_smokes_portable_and_onedir_packages() -> None: - workflow = (ROOT / ".github/workflows/ci.yml").read_text(encoding="utf-8") - assert "DroidWebDisplayOnedir.spec" in workflow - assert "DroidWebDisplay-windows-x86_64.zip" in workflow - assert "--adb-smoke" in workflow - assert "Repeat Windows service start-stop smoke" in workflow - assert "ProductName" in workflow - - -def test_release_pipeline_publishes_stable_windows_zip() -> None: - workflow = (ROOT / ".github/workflows/release.yml").read_text(encoding="utf-8") - assert "DroidWebDisplay-v${RELEASE_VERSION}-windows-x86_64.zip" in workflow - - -def test_windows_adb_and_browser_failure_guidance_is_present() -> None: - desktop = (ROOT / "tools/desktop_entry.py").read_text(encoding="utf-8") - controller = (ROOT / "apps/web-client/src/controller.ts").read_text(encoding="utf-8") - html = (ROOT / "apps/web-client/static/index.html").read_text(encoding="utf-8") - assert '"--adb-smoke"' in desktop - assert "USB authorization required" in controller - assert "manufacturer USB driver" in controller - assert "Android 16" in controller - assert "disable browser hardware acceleration" in html - assert 'id="diagnostic-gpu"' in html -''', encoding="utf-8") - -(ROOT / "docs/WINDOWS_PACKAGING.md").write_text('''# Windows packaging and troubleshooting\n\nDroidWebDisplay ships two unsigned Windows distribution forms. Code signing is intentionally deferred; these packaging rules are independent of signing.\n\n## Distribution forms\n\n- **Portable EXE** — `DroidWebDisplay-vX.Y.Z-windows-x86_64.exe`. Convenient single-file launch. PyInstaller extracts this form to a temporary runtime directory while it is running.\n- **Stable onedir ZIP** — `DroidWebDisplay-vX.Y.Z-windows-x86_64.zip`. Recommended for long-running installations because dependencies live in a stable directory instead of a temporary `_MEI...` extraction tree. Extract the ZIP and run `DroidWebDisplay.exe`.\n\nBoth forms include the verified Android platform-tools ADB executable and DLLs, the verified scrcpy server, the web client, licenses, and identical application version metadata. UPX compression is disabled.\n\n## USB / ADB states\n\nDroidWebDisplay surfaces ADB states explicitly. `unauthorized`/`authorizing` means the phone must be unlocked and the **Allow USB debugging?** prompt accepted. `offline` usually needs a USB reconnect or USB-debugging reset. `no permissions` means host access is blocked; on Windows, install or update the phone manufacturer's USB driver.\n\n## Black video with working controls\n\nDroidWebDisplay uses browser WebCodecs rather than scrcpy's native Windows renderer. Diagnostics reports the browser, WebCodecs availability, platform and visible WebGL GPU renderer. If controls work but the picture remains black: update Chrome/Edge, update the GPU driver, then try disabling browser hardware acceleration and restart the browser.\n\n## Android 16 compatibility\n\nThe protected stable adapter remains scrcpy 4.1. Some Android 16 builds have produced an upstream `AbstractMethodError` involving `IDisplayWindowListener`. DroidWebDisplay classifies that server signature explicitly so it is not mistaken for a Windows packaging or GPU failure. The stable scrcpy adapter must not be replaced until the normal DWD compatibility and HIL gates prove an equal-or-better update.\n''', encoding="utf-8") - -(ROOT / "docs/WINDOWS_RELEASE_HIL.md").write_text('''# Windows release HIL checklist\n\nRun this checklist on the exact packaged Windows artifact before promoting a release when hardware is available. CI covers package startup, repeated shutdown, bundled ADB execution, PE version metadata and service availability; these hardware/browser checks remain real-device qualification.\n\n- Start both the portable EXE and extracted onedir ZIP.\n- Verify the connected Android device transitions through ADB authorization correctly.\n- Connect physical display and confirm the first video frame without requiring Rotate.\n- Rotate twice and confirm video recovers without reconnecting.\n- Verify mouse/touch controls, PC keyboard input, Back/Home/Recent and power control.\n- Verify Android→PC automatic clipboard, Copy button and Ctrl+C.\n- Verify PC→Android automatic clipboard, Paste/Ctrl+V and Type; normal typing must remain usable.\n- Disconnect/reconnect and confirm clipboard/session state does not leak.\n- Transfer a file in both directions.\n- Leave a session active through Windows display-off and sleep/resume, then verify video/control reconnect.\n- Run a multi-hour soak and confirm no orphan `DroidWebDisplay.exe`, `adb.exe` or stale service remains after exit.\n- On at least one Android 16 device, confirm either normal operation or the explicit upstream display-listener classification.\n- If video is black with controls active, record browser version, GPU renderer and the result of toggling browser hardware acceleration.\n''', encoding="utf-8") - -print("Windows package hardening patch applied") From 947424712da87b69738424d78b703c1b9ef479d2 Mon Sep 17 00:00:00 2001 From: Aleksandr Chasnyk <69671996+ami3go@users.noreply.github.com> Date: Thu, 20 Aug 2026 09:23:37 +0300 Subject: [PATCH 07/30] chore: remove Windows hardening workflow --- .../apply-windows-package-hardening.yml | 84 ------------------- 1 file changed, 84 deletions(-) delete mode 100644 .github/workflows/apply-windows-package-hardening.yml diff --git a/.github/workflows/apply-windows-package-hardening.yml b/.github/workflows/apply-windows-package-hardening.yml deleted file mode 100644 index 92ed50c..0000000 --- a/.github/workflows/apply-windows-package-hardening.yml +++ /dev/null @@ -1,84 +0,0 @@ -name: Apply Windows Package Hardening - -on: - push: - branches: [agent/windows-package-hardening] - pull_request: - branches: [main] - -permissions: - contents: write - -concurrency: - group: apply-windows-package-hardening - cancel-in-progress: true - -jobs: - apply: - if: ${{ github.actor != 'github-actions[bot]' }} - runs-on: ubuntu-24.04 - steps: - - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - with: - ref: agent/windows-package-hardening - fetch-depth: 0 - - uses: actions/setup-python@a26af69be951a213d495a4e3e4022e16d87065 # v5 - with: - python-version: '3.11.15' - - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 - with: - node-version: '22.23.2' - cache: npm - cache-dependency-path: | - apps/web-client/package-lock.json - packages/scrcpy-protocol/package-lock.json - - name: Apply focused hardening patch - run: python tools/apply_windows_package_hardening.py - - name: Build protocol package - working-directory: packages/scrcpy-protocol - run: | - npm ci - npm run build - - name: Build and test web client - working-directory: apps/web-client - run: | - npm ci - npm test - - name: Python syntax check - run: python -m compileall -q droid_web_display tools - - name: Validate patch - run: | - git diff --check - test -s packaging/windows/droidwebdisplay.ico - test -f packaging/pyinstaller/DroidWebDisplayOnedir.spec - grep -q 'upx=False' packaging/pyinstaller/DroidWebDisplay.spec - grep -q 'windows-x86_64.zip' .github/workflows/release.yml - - name: Commit hardened implementation - shell: bash - run: | - set -euo pipefail - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git rm tools/apply_windows_package_hardening.py .github/workflows/apply-windows-package-hardening.yml - git rm -f tools/.windows-hardening-trigger 2>/dev/null || true - git add -- \ - packaging/pyinstaller/DroidWebDisplay.spec \ - packaging/pyinstaller/DroidWebDisplayOnedir.spec \ - packaging/windows/droidwebdisplay.ico \ - tools/desktop_entry.py \ - apps/web-client/src/browser-support.ts \ - apps/web-client/src/controller.ts \ - apps/web-client/src/main.ts \ - apps/web-client/static/index.html \ - apps/web-client/tests/browser-support.test.mjs \ - apps/web-client/dist \ - apps/web-client/dist-manifest.json \ - droid_web_display/scrcpy/session.py \ - tests/regression/test_scrcpy_android16_failure_classification.py \ - tests/packaging/test_windows_package_hardening.py \ - .github/workflows/ci.yml \ - .github/workflows/release.yml \ - docs/WINDOWS_PACKAGING.md \ - docs/WINDOWS_RELEASE_HIL.md - git commit -m "fix: harden Windows packaging and diagnostics" - git push origin HEAD:agent/windows-package-hardening From 6ed8b29ca2450f1bdd5ea64e29c09bb8c47c5f9a Mon Sep 17 00:00:00 2001 From: Aleksandr Chasnyk <69671996+ami3go@users.noreply.github.com> Date: Thu, 20 Aug 2026 09:24:51 +0300 Subject: [PATCH 08/30] fix: add hardened Windows portable package --- .../pyinstaller/DroidWebDisplayWindows.spec | 114 ++++++++++++++++++ 1 file changed, 114 insertions(+) create mode 100644 packaging/pyinstaller/DroidWebDisplayWindows.spec diff --git a/packaging/pyinstaller/DroidWebDisplayWindows.spec b/packaging/pyinstaller/DroidWebDisplayWindows.spec new file mode 100644 index 0000000..1599c3c --- /dev/null +++ b/packaging/pyinstaller/DroidWebDisplayWindows.spec @@ -0,0 +1,114 @@ +# -*- mode: python ; coding: utf-8 -*- +from pathlib import Path +import os +import sys + +from PyInstaller.utils.hooks import collect_submodules +from PyInstaller.utils.win32.versioninfo import ( + FixedFileInfo, + StringFileInfo, + StringStruct, + StringTable, + VarFileInfo, + VarStruct, + VSVersionInfo, +) + +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) + +version_info = VSVersionInfo( + ffi=FixedFileInfo( + filevers=numeric_version, + prodvers=numeric_version, + mask=0x3F, + flags=0x0, + OS=0x40004, + fileType=0x1, + subtype=0x0, + date=(0, 0), + ), + kids=[ + StringFileInfo([ + StringTable("040904B0", [ + StringStruct("CompanyName", "DroidWebDisplay contributors"), + StringStruct("FileDescription", "DroidWebDisplay Android web display"), + StringStruct("FileVersion", VERSION), + StringStruct("InternalName", "DroidWebDisplay"), + StringStruct("LegalCopyright", "Copyright DroidWebDisplay contributors"), + StringStruct("OriginalFilename", "DroidWebDisplay.exe"), + StringStruct("ProductName", "DroidWebDisplay"), + StringStruct("ProductVersion", VERSION), + ]) + ]), + VarFileInfo([VarStruct("Translation", [1033, 1200])]), + ], +) + +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")], + pathex=[str(ROOT), str(ROOT / "tools")], + binaries=adb_binaries, + datas=datas, + hiddenimports=hiddenimports, + hookspath=[], + hooksconfig={}, + runtime_hooks=[], + excludes=[], + noarchive=False, + optimize=0, +) +pyz = PYZ(a.pure) +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, + icon=str(ROOT / "packaging" / "windows" / "droidwebdisplay.ico"), + version=version_info, +) From b789b6a185af807c51aa1b8e9f98dd007f6047fa Mon Sep 17 00:00:00 2001 From: Aleksandr Chasnyk <69671996+ami3go@users.noreply.github.com> Date: Thu, 20 Aug 2026 09:25:06 +0300 Subject: [PATCH 09/30] fix: add stable Windows onedir package --- .../DroidWebDisplayWindowsOnedir.spec | 120 ++++++++++++++++++ 1 file changed, 120 insertions(+) create mode 100644 packaging/pyinstaller/DroidWebDisplayWindowsOnedir.spec diff --git a/packaging/pyinstaller/DroidWebDisplayWindowsOnedir.spec b/packaging/pyinstaller/DroidWebDisplayWindowsOnedir.spec new file mode 100644 index 0000000..b5a872b --- /dev/null +++ b/packaging/pyinstaller/DroidWebDisplayWindowsOnedir.spec @@ -0,0 +1,120 @@ +# -*- mode: python ; coding: utf-8 -*- +from pathlib import Path +import os +import sys + +from PyInstaller.utils.hooks import collect_submodules +from PyInstaller.utils.win32.versioninfo import ( + FixedFileInfo, + StringFileInfo, + StringStruct, + StringTable, + VarFileInfo, + VarStruct, + VSVersionInfo, +) + +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) + +version_info = VSVersionInfo( + ffi=FixedFileInfo( + filevers=numeric_version, + prodvers=numeric_version, + mask=0x3F, + flags=0x0, + OS=0x40004, + fileType=0x1, + subtype=0x0, + date=(0, 0), + ), + kids=[ + StringFileInfo([ + StringTable("040904B0", [ + StringStruct("CompanyName", "DroidWebDisplay contributors"), + StringStruct("FileDescription", "DroidWebDisplay Android web display"), + StringStruct("FileVersion", VERSION), + StringStruct("InternalName", "DroidWebDisplay"), + StringStruct("LegalCopyright", "Copyright DroidWebDisplay contributors"), + StringStruct("OriginalFilename", "DroidWebDisplay.exe"), + StringStruct("ProductName", "DroidWebDisplay"), + StringStruct("ProductVersion", VERSION), + ]) + ]), + VarFileInfo([VarStruct("Translation", [1033, 1200])]), + ], +) + +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")], + pathex=[str(ROOT), str(ROOT / "tools")], + binaries=adb_binaries, + datas=datas, + hiddenimports=hiddenimports, + hookspath=[], + hooksconfig={}, + runtime_hooks=[], + excludes=[], + noarchive=False, + optimize=0, +) +pyz = PYZ(a.pure) +exe = EXE( + pyz, + a.scripts, + [], + exclude_binaries=True, + name="DroidWebDisplay", + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=False, + console=False, + disable_windowed_traceback=False, + argv_emulation=False, + target_arch=None, + codesign_identity=None, + entitlements_file=None, + icon=str(ROOT / "packaging" / "windows" / "droidwebdisplay.ico"), + version=version_info, +) +coll = COLLECT( + exe, + a.binaries, + a.datas, + strip=False, + upx=False, + upx_exclude=[], + name="DroidWebDisplayWindowsOnedir", +) From fba6962be53475e7ff0e529cd290cac4ecf7fa94 Mon Sep 17 00:00:00 2001 From: Aleksandr Chasnyk <69671996+ami3go@users.noreply.github.com> Date: Thu, 20 Aug 2026 09:25:43 +0300 Subject: [PATCH 10/30] chore: stage Windows application icon --- packaging/windows/droidwebdisplay.ico.base64 | 1 + 1 file changed, 1 insertion(+) create mode 100644 packaging/windows/droidwebdisplay.ico.base64 diff --git a/packaging/windows/droidwebdisplay.ico.base64 b/packaging/windows/droidwebdisplay.ico.base64 new file mode 100644 index 0000000..f703156 --- /dev/null +++ b/packaging/windows/droidwebdisplay.ico.base64 @@ -0,0 +1 @@ +AAABAAEAICAAAAEAIACoEAAAFgAAACgAAAAgAAAAQAAAAAEAIAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/3xd//98Xf//fF3//3xd//98Xf//fF3//3xd//98Xf//fF3//3xd//98Xf//fF3//3xd//98Xf//fF3//3xd/wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP98Xf//fF3//3xd//98Xf//fF3//3xd//98Xf//fF3//3xd//98Xf//fF3//3xd//98Xf//fF3//3xd//98Xf//fF3//3xd/wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/fF3//3xd//98Xf//fF3//3xd//98Xf//fF3//3xd//98Xf/79vP/+/bz//98Xf//fF3//3xd//98Xf//fF3//3xd//98Xf//fF3//3xd/wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP98Xf//fF3//3xd//98Xf//fF3//3xd//98Xf//fF3//3xd//v28//79vP//3xd//98Xf//fF3//3xd//98Xf//fF3//3xd//98Xf//fF3/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/3xd//98Xf//fF3//3xd//98Xf//fF3//3xd//98Xf//fF3//3xd//98Xf//fF3//3xd//98Xf//fF3//3xd//98Xf//fF3//3xd//98Xf8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/fF3//3xd//98Xf//fF3//3xd//98Xf//fF3//3xd//98Xf//fF3//3xd//98Xf//fF3//3xd//98Xf//fF3//3xd//98Xf//fF3//3xd/wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP98Xf//fF3//3xd/xMNCv8TDQr/Ew0K/xMNCv8TDQr/Ew0K/xMNCv8TDQr/Ew0K/xMNCv8TDQr/Ew0K/xMNCv8TDQr//3xd//98Xf//fF3/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/3xd//98Xf//fF3/Ew0K/xMNCv8TDQr/Ew0K/xMNCv8TDQr/Ew0K/xMNCv8TDQr/Ew0K/xMNCv8TDQr/Ew0K/xMNCv//fF3//3xd//98Xf8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/fF3//3xd/xMNCv8TDQr/Ew0K/xMNCv8TDQr/Ew0K/xMNCv8TDQr/Ew0K/xMNCv8TDQr/Ew0K/xMNCv8TDQr/Ew0K/xMNCv//fF3//3xd/wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP98Xf//fF3/Ew0K/xMNCv8TDQr/Ew0K/xMNCv8TDQr/Ew0K/xMNCv8TDQr/Ew0K/xMNCv8TDQr/Ew0K/xMNCv8TDQr/Ew0K//98Xf//fF3/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/3xd//98Xf8TDQr/Ew0K/xMNCv8TDQr/Ew0K/xMNCv8TDQr/Ew0K/xMNCv8TDQr/Ew0K/xMNCv8TDQr/Ew0K/xMNCv8TDQr//3xd//98Xf8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/fF3//3xd/xMNCv8TDQr/Ew0K/xMNCv8TDQr/Ew0K/xMNCv8TDQr/Ew0K/xMNCv8TDQr/Ew0K/xMNCv8TDQr/Ew0K/xMNCv//fF3//3xd/wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP98Xf//fF3/Ew0K/xMNCv8TDQr/Ew0K/xMNCv8TDQr/Ew0K/xMNCv8TDQr/Ew0K/xMNCv8TDQr/Ew0K/xMNCv8TDQr/Ew0K//98Xf//fF3/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/3xd//98Xf8TDQr/Ew0K/xMNCv8TDQr/Ew0K/xMNCv8TDQr/Ew0K/xMNCv8TDQr/Ew0K/xMNCv8TDQr/Ew0K/xMNCv8TDQr//3xd//98Xf8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/fF3//3xd/xMNCv8TDQr/Ew0K/xMNCv8TDQr/Ew0K/xMNCv8TDQr/Ew0K/xMNCv8TDQr/Ew0K/xMNCv8TDQr/Ew0K/xMNCv//fF3//3xd/wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP98Xf//fF3/Ew0K/xMNCv8TDQr/Ew0K/xMNCv8TDQr/Ew0K/xMNCv8TDQr/Ew0K/xMNCv8TDQr/Ew0K/xMNCv8TDQr/Ew0K//98Xf//fF3/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/3xd//98Xf8TDQr/Ew0K/xMNCv8TDQr/Ew0K/xMNCv8TDQr/Ew0K/xMNCv8TDQr/Ew0K/xMNCv8TDQr/Ew0K/xMNCv8TDQr//3xd//98Xf8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/fF3//3xd/xMNCv8TDQr/Ew0K/xMNCv8TDQr/Ew0K/xMNCv8TDQr/Ew0K/xMNCv8TDQr/Ew0K/xMNCv8TDQr/Ew0K/xMNCv//fF3//3xd/wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP98Xf//fF3/Ew0K/xMNCv8TDQr/Ew0K/xMNCv8TDQr/Ew0K/xMNCv8TDQr/Ew0K/xMNCv8TDQr/Ew0K/xMNCv8TDQr/Ew0K//98Xf//fF3/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/3xd//98Xf8TDQr/Ew0K/xMNCv8TDQr/Ew0K/xMNCv8TDQr/Ew0K/xMNCv8TDQr/Ew0K/xMNCv8TDQr/Ew0K/xMNCv8TDQr//3xd//98Xf8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/fF3//3xd/xMNCv8TDQr/Ew0K/xMNCv8TDQr/Ew0K/xMNCv8TDQr/Ew0K/xMNCv8TDQr/Ew0K/xMNCv8TDQr/Ew0K/xMNCv//fF3//3xd/wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP98Xf//fF3/Ew0K/xMNCv8TDQr/Ew0K/xMNCv8TDQr/Ew0K/xMNCv8TDQr/Ew0K/xMNCv8TDQr/Ew0K/xMNCv8TDQr/Ew0K//98Xf//fF3/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/3xd//98Xf8TDQr/Ew0K/xMNCv8TDQr/Ew0K/xMNCv8TDQr/Ew0K/xMNCv8TDQr/Ew0K/xMNCv8TDQr/Ew0K/xMNCv8TDQr//3xd//98Xf8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/fF3//3xd/xMNCv8TDQr/Ew0K/xMNCv8TDQr/Ew0K/xMNCv8TDQr/Ew0K/xMNCv8TDQr/Ew0K/xMNCv8TDQr/Ew0K/xMNCv//fF3//3xd/wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP98Xf//fF3//3xd/xMNCv8TDQr/Ew0K/xMNCv8TDQr/Ew0K/xMNCv8TDQr/Ew0K/xMNCv8TDQr/Ew0K/xMNCv8TDQr//3xd//98Xf//fF3/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/3xd//98Xf//fF3/Ew0K/xMNCv8TDQr/Ew0K/xMNCv8TDQr/Ew0K/xMNCv8TDQr/Ew0K/xMNCv8TDQr/Ew0K/xMNCv//fF3//3xd//98Xf8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/fF3//3xd//98Xf//fF3//3xd//98Xf//fF3//3xd//98Xf//fF3//3xd//98Xf//fF3//3xd//98Xf//fF3//3xd//98Xf//fF3//3xd/wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP98Xf//fF3//3xd//98Xf//fF3//3xd//98Xf//5Nz//+Tc///k3P//5Nz//+Tc///k3P//fF3//3xd//98Xf//fF3//3xd//98Xf//fF3/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP98Xf//fF3//3xd//98Xf//fF3//3xd//98Xf//fF3//3xd//98Xf//fF3//3xd//98Xf//fF3//3xd//98Xf//fF3//3xd/wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP98Xf//fF3//3xd//98Xf//fF3//3xd//98Xf//fF3//3xd//98Xf//fF3//3xd//98Xf//fF3//3xd//98Xf8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= \ No newline at end of file From a2aecf24c70b5d403b2215f71d3580876c3000bc Mon Sep 17 00:00:00 2001 From: Aleksandr Chasnyk <69671996+ami3go@users.noreply.github.com> Date: Thu, 20 Aug 2026 09:26:21 +0300 Subject: [PATCH 11/30] fix: validate bundled ADB from Windows package --- tools/desktop_entry.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/tools/desktop_entry.py b/tools/desktop_entry.py index 7a3cbc3..f20791a 100644 --- a/tools/desktop_entry.py +++ b/tools/desktop_entry.py @@ -4,6 +4,7 @@ import argparse import os from pathlib import Path +import subprocess import sys @@ -35,9 +36,29 @@ def _desktop_parser() -> argparse.ArgumentParser: ) parser.add_argument("--start-minimized", action="store_true", help="Start the desktop host minimized") parser.add_argument("--desktop-smoke", action="store_true", help=argparse.SUPPRESS) + parser.add_argument("--adb-smoke", action="store_true", help=argparse.SUPPRESS) return parser +def _bundled_adb_smoke(paths) -> int: # type: ignore[no-untyped-def] + adb = paths.adb_executable + if not isinstance(adb, Path) or not adb.is_file(): + return 3 + kwargs: dict[str, object] = { + "stdout": subprocess.DEVNULL, + "stderr": subprocess.DEVNULL, + "check": False, + "timeout": 15, + } + if os.name == "nt": + kwargs["creationflags"] = getattr(subprocess, "CREATE_NO_WINDOW", 0) + try: + result = subprocess.run([str(adb), "version"], **kwargs) + except (OSError, subprocess.SubprocessError): + return 4 + return int(result.returncode) + + def main(argv: list[str] | None = None) -> int: resource_root = _resource_root() @@ -48,6 +69,8 @@ def main(argv: list[str] | None = None) -> int: install_output_logging(paths.logs_root) args, server_args = _desktop_parser().parse_known_args(argv) + if args.adb_smoke: + return _bundled_adb_smoke(paths) from run_bridge_service import BridgeServiceRuntime, main as bridge_main From dfdac5baff58cf785a50c5a1d5ae12f07fde24d8 Mon Sep 17 00:00:00 2001 From: Aleksandr Chasnyk <69671996+ami3go@users.noreply.github.com> Date: Thu, 20 Aug 2026 09:26:39 +0300 Subject: [PATCH 12/30] fix: generate Windows icon during package build --- packaging/pyinstaller/DroidWebDisplayWindows.spec | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packaging/pyinstaller/DroidWebDisplayWindows.spec b/packaging/pyinstaller/DroidWebDisplayWindows.spec index 1599c3c..10d51bb 100644 --- a/packaging/pyinstaller/DroidWebDisplayWindows.spec +++ b/packaging/pyinstaller/DroidWebDisplayWindows.spec @@ -1,5 +1,6 @@ # -*- mode: python ; coding: utf-8 -*- from pathlib import Path +import base64 import os import sys @@ -25,6 +26,9 @@ 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"))) + version_info = VSVersionInfo( ffi=FixedFileInfo( filevers=numeric_version, @@ -109,6 +113,6 @@ exe = EXE( target_arch=None, codesign_identity=None, entitlements_file=None, - icon=str(ROOT / "packaging" / "windows" / "droidwebdisplay.ico"), + icon=str(ICON), version=version_info, ) From fd1aaa5ffea7882f4684c24fa644378ff36bed14 Mon Sep 17 00:00:00 2001 From: Aleksandr Chasnyk <69671996+ami3go@users.noreply.github.com> Date: Thu, 20 Aug 2026 09:27:05 +0300 Subject: [PATCH 13/30] fix: generate Windows icon for onedir build --- packaging/pyinstaller/DroidWebDisplayWindowsOnedir.spec | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packaging/pyinstaller/DroidWebDisplayWindowsOnedir.spec b/packaging/pyinstaller/DroidWebDisplayWindowsOnedir.spec index b5a872b..b4fbf17 100644 --- a/packaging/pyinstaller/DroidWebDisplayWindowsOnedir.spec +++ b/packaging/pyinstaller/DroidWebDisplayWindowsOnedir.spec @@ -1,5 +1,6 @@ # -*- mode: python ; coding: utf-8 -*- from pathlib import Path +import base64 import os import sys @@ -25,6 +26,9 @@ 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"))) + version_info = VSVersionInfo( ffi=FixedFileInfo( filevers=numeric_version, @@ -106,7 +110,7 @@ exe = EXE( target_arch=None, codesign_identity=None, entitlements_file=None, - icon=str(ROOT / "packaging" / "windows" / "droidwebdisplay.ico"), + icon=str(ICON), version=version_info, ) coll = COLLECT( From 1d30c63edf8a402f94ae68bd11f32af6dc6a3815 Mon Sep 17 00:00:00 2001 From: Aleksandr Chasnyk <69671996+ami3go@users.noreply.github.com> Date: Thu, 20 Aug 2026 09:27:29 +0300 Subject: [PATCH 14/30] fix: classify known Android 16 scrcpy display failure --- droid_web_display/scrcpy/virtual_display.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/droid_web_display/scrcpy/virtual_display.py b/droid_web_display/scrcpy/virtual_display.py index b9a8fdf..9569376 100644 --- a/droid_web_display/scrcpy/virtual_display.py +++ b/droid_web_display/scrcpy/virtual_display.py @@ -17,6 +17,10 @@ "attempted to set ime policy to an untrusted virtual display", "display ime policy", ) +_ANDROID16_DISPLAY_LISTENER_MARKERS = ( + "abstractmethoderror", + "idisplaywindowlistener", +) def samsung_local_ime_policy_risk(device: AndroidDevice) -> bool: @@ -46,6 +50,8 @@ def apply_device_virtual_display_compatibility( def classify_virtual_display_failure(lines: list[str] | tuple[str, ...]) -> str: text = "\n".join(lines).lower() + if all(marker in text for marker in _ANDROID16_DISPLAY_LISTENER_MARKERS): + return "android16-display-listener-incompatibility" if "stack corruption detected" in text or "-fstack-protector" in text: return "app-process-stack-corruption" if any(marker in text for marker in _IME_POLICY_FAILURE_MARKERS): @@ -116,6 +122,11 @@ def virtual_display_capabilities( "Local virtual-display IME policy is disabled for this Samsung Android build; " "use default or fallback routing." ) + if sdk >= 36: + warnings.append( + "Android 16 compatibility is device-build dependent with scrcpy 4.1. " + "If startup fails with AbstractMethodError/IDisplayWindowListener, DWD classifies it as an upstream display-listener incompatibility." + ) if requested_package and package_installed is False: warnings.append(f"Application is not installed: {requested_package}") codecs = supported_codecs or ["h264"] From 9deb882cdc5243238ef92351c8b67ffc5bcd1c66 Mon Sep 17 00:00:00 2001 From: Aleksandr Chasnyk <69671996+ami3go@users.noreply.github.com> Date: Thu, 20 Aug 2026 09:28:36 +0300 Subject: [PATCH 15/30] ci: harden Windows package validation --- .github/workflows/ci.yml | 62 +++++++++++++++++++++++++++++++--------- 1 file changed, 48 insertions(+), 14 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 817a516..35ed4f5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,7 +30,7 @@ jobs: libxcb-render-util0 \ libxcb-shape0 \ libxcb-xkb1 - - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + - uses: actions/setup-python@a26af69be951a213d495a4e3e4e4022e16d87065 # v5 with: python-version: '3.11.15' - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 @@ -68,7 +68,7 @@ jobs: runs-on: windows-2025 steps: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + - uses: actions/setup-python@a26af69be951a213d495a4e3e4e4022e16d87065 # v5 with: python-version: '3.11.9' - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 @@ -99,24 +99,58 @@ jobs: Expand-Archive -Path platform-tools.zip -DestinationPath platform-tools $adb = (Resolve-Path "platform-tools/platform-tools").Path "DWD_ADB_DIR=$adb" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 - - name: Build Windows executable - run: python -m PyInstaller --noconfirm --clean packaging/pyinstaller/DroidWebDisplay.spec - - name: Smoke-test Windows desktop host + - name: Build Windows portable executable + run: python -m PyInstaller --noconfirm --clean packaging/pyinstaller/DroidWebDisplayWindows.spec + - name: Build Windows stable onedir package + run: python -m PyInstaller --noconfirm --clean packaging/pyinstaller/DroidWebDisplayWindowsOnedir.spec + - name: Verify Windows PE metadata and bundled ADB + shell: pwsh + run: | + $version = (Get-Content VERSION -Raw).Trim() + $packages = @( + ".\dist\DroidWebDisplay.exe", + ".\dist\DroidWebDisplayWindowsOnedir\DroidWebDisplay.exe" + ) + foreach ($path in $packages) { + $info = (Get-Item $path).VersionInfo + if ($info.ProductName -ne "DroidWebDisplay") { throw "ProductName missing from $path" } + if (-not $info.FileVersion.StartsWith($version)) { throw "FileVersion $($info.FileVersion) does not match $version in $path" } + if (-not $info.ProductVersion.StartsWith($version)) { throw "ProductVersion $($info.ProductVersion) does not match $version in $path" } + if ($info.OriginalFilename -ne "DroidWebDisplay.exe") { throw "OriginalFilename missing from $path" } + } + $adb = Get-ChildItem ".\dist\DroidWebDisplayWindowsOnedir" -Recurse -Filter adb.exe | Select-Object -First 1 + if (-not $adb) { throw "Bundled adb.exe missing from onedir package" } + & $adb.FullName version + if ($LASTEXITCODE -ne 0) { throw "Bundled adb.exe failed to execute" } + - name: Smoke-test Windows portable desktop, CLI and bundled ADB shell: pwsh run: | - $process = Start-Process -FilePath ".\dist\DroidWebDisplay.exe" -ArgumentList "--desktop-smoke" -Wait -PassThru - if ($process.ExitCode -ne 0) { exit $process.ExitCode } - - name: Smoke-test Windows executable CLI + foreach ($arg in @("--desktop-smoke", "--help", "--adb-smoke")) { + $process = Start-Process -FilePath ".\dist\DroidWebDisplay.exe" -ArgumentList $arg -Wait -PassThru + if ($process.ExitCode -ne 0) { throw "Portable smoke $arg failed: $($process.ExitCode)" } + } + - name: Smoke-test Windows onedir desktop, CLI and bundled ADB shell: pwsh run: | - $process = Start-Process -FilePath ".\dist\DroidWebDisplay.exe" -ArgumentList "--help" -Wait -PassThru - if ($process.ExitCode -ne 0) { exit $process.ExitCode } - - name: Smoke-test Windows executable service - run: python tools/smoke_desktop_package.py .\dist\DroidWebDisplay.exe --timeout 60 + $exe = ".\dist\DroidWebDisplayWindowsOnedir\DroidWebDisplay.exe" + foreach ($arg in @("--desktop-smoke", "--help", "--adb-smoke")) { + $process = Start-Process -FilePath $exe -ArgumentList $arg -Wait -PassThru + if ($process.ExitCode -ne 0) { throw "Onedir smoke $arg failed: $($process.ExitCode)" } + } + - name: Repeat Windows service start-stop smoke + shell: pwsh + run: | + 1..3 | ForEach-Object { python tools/smoke_desktop_package.py .\dist\DroidWebDisplay.exe --timeout 60 } + 1..3 | ForEach-Object { python tools/smoke_desktop_package.py .\dist\DroidWebDisplayWindowsOnedir\DroidWebDisplay.exe --timeout 60 } + - name: Build Windows stable onedir ZIP + shell: pwsh + run: Compress-Archive -Path ".\dist\DroidWebDisplayWindowsOnedir\*" -DestinationPath ".\dist\DroidWebDisplay-windows-x86_64.zip" -CompressionLevel Optimal - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: windows-package-smoke - path: dist/DroidWebDisplay.exe + path: | + dist/DroidWebDisplay.exe + dist/DroidWebDisplay-windows-x86_64.zip if-no-files-found: error linux-appimage-smoke: @@ -136,7 +170,7 @@ jobs: libxcb-render-util0 \ libxcb-shape0 \ libxcb-xkb1 - - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + - uses: actions/setup-python@a26af69be951a213d495a4e3e4e4022e16d87065 # v5 with: python-version: '3.11.15' - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 From 407ad32682f09af057d5a246b08c19dc46da0c26 Mon Sep 17 00:00:00 2001 From: Aleksandr Chasnyk <69671996+ami3go@users.noreply.github.com> Date: Thu, 20 Aug 2026 09:29:07 +0300 Subject: [PATCH 16/30] ci: publish stable Windows onedir ZIP --- .github/workflows/release.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 48dbdb7..73df4bd 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -171,11 +171,14 @@ jobs: gh run download "$run_id" --repo "$GITHUB_REPOSITORY" --name linux-appimage-smoke --dir artifacts/linux windows=$(find artifacts/windows -type f -name 'DroidWebDisplay.exe' -print -quit) + windows_zip=$(find artifacts/windows -type f -name 'DroidWebDisplay-windows-x86_64.zip' -print -quit) linux=$(find artifacts/linux -type f -name '*.AppImage' -print -quit) test -n "$windows" + test -n "$windows_zip" test -n "$linux" cp "$windows" "release-assets/DroidWebDisplay-v${RELEASE_VERSION}-windows-x86_64.exe" + cp "$windows_zip" "release-assets/DroidWebDisplay-v${RELEASE_VERSION}-windows-x86_64.zip" cp "$linux" "release-assets/DroidWebDisplay-v${RELEASE_VERSION}-linux-x86_64.AppImage" chmod +x "release-assets/DroidWebDisplay-v${RELEASE_VERSION}-linux-x86_64.AppImage" (cd release-assets && sha256sum DroidWebDisplay-* > SHA256SUMS.txt) @@ -195,5 +198,6 @@ jobs: --title "DroidWebDisplay $tag" \ --notes-file ".github/releases/${tag}.md" \ release-assets/DroidWebDisplay-v${RELEASE_VERSION}-windows-x86_64.exe \ + release-assets/DroidWebDisplay-v${RELEASE_VERSION}-windows-x86_64.zip \ release-assets/DroidWebDisplay-v${RELEASE_VERSION}-linux-x86_64.AppImage \ release-assets/SHA256SUMS.txt From 447d7fd5ee803c3d4d3496a00796adef356bfe90 Mon Sep 17 00:00:00 2001 From: Aleksandr Chasnyk <69671996+ami3go@users.noreply.github.com> Date: Thu, 20 Aug 2026 09:29:39 +0300 Subject: [PATCH 17/30] test: protect Windows packaging hardening --- .../test_windows_package_hardening.py | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 tests/packaging/test_windows_package_hardening.py diff --git a/tests/packaging/test_windows_package_hardening.py b/tests/packaging/test_windows_package_hardening.py new file mode 100644 index 0000000..1896942 --- /dev/null +++ b/tests/packaging/test_windows_package_hardening.py @@ -0,0 +1,46 @@ +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[2] + + +def test_windows_pyinstaller_targets_disable_upx_and_embed_metadata() -> None: + portable = (ROOT / "packaging/pyinstaller/DroidWebDisplayWindows.spec").read_text(encoding="utf-8") + onedir = (ROOT / "packaging/pyinstaller/DroidWebDisplayWindowsOnedir.spec").read_text(encoding="utf-8") + for text in (portable, onedir): + assert "upx=False" in text + assert "ProductName" in text + assert "ProductVersion" in text + assert "OriginalFilename" in text + assert "droidwebdisplay.ico.base64" in text + assert "runtime_tmpdir=None" in portable + assert 'name="DroidWebDisplayWindowsOnedir"' in onedir + + +def test_windows_icon_source_is_present() -> None: + icon = ROOT / "packaging/windows/droidwebdisplay.ico.base64" + assert icon.is_file() + assert len(icon.read_text(encoding="ascii").strip()) > 1024 + + +def test_windows_ci_builds_and_smokes_both_distribution_forms() -> None: + workflow = (ROOT / ".github/workflows/ci.yml").read_text(encoding="utf-8") + assert "DroidWebDisplayWindows.spec" in workflow + assert "DroidWebDisplayWindowsOnedir.spec" in workflow + assert "DroidWebDisplay-windows-x86_64.zip" in workflow + assert "--adb-smoke" in workflow + assert "Repeat Windows service start-stop smoke" in workflow + assert "Verify Windows PE metadata and bundled ADB" in workflow + + +def test_future_releases_publish_stable_windows_zip() -> None: + workflow = (ROOT / ".github/workflows/release.yml").read_text(encoding="utf-8") + assert 'DroidWebDisplay-v${RELEASE_VERSION}-windows-x86_64.exe' in workflow + assert 'DroidWebDisplay-v${RELEASE_VERSION}-windows-x86_64.zip' in workflow + + +def test_desktop_package_has_bundled_adb_self_test() -> None: + entry = (ROOT / "tools/desktop_entry.py").read_text(encoding="utf-8") + assert '"--adb-smoke"' in entry + assert 'subprocess.run([str(adb), "version"]' in entry + assert "CREATE_NO_WINDOW" in entry From 8f991b059b7b0cfccc5282d74d7c19980d9bb619 Mon Sep 17 00:00:00 2001 From: Aleksandr Chasnyk <69671996+ami3go@users.noreply.github.com> Date: Thu, 20 Aug 2026 09:29:56 +0300 Subject: [PATCH 18/30] test: classify Android 16 scrcpy display-listener failure --- .../test_android16_scrcpy_compatibility.py | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 tests/regression/test_android16_scrcpy_compatibility.py diff --git a/tests/regression/test_android16_scrcpy_compatibility.py b/tests/regression/test_android16_scrcpy_compatibility.py new file mode 100644 index 0000000..c706259 --- /dev/null +++ b/tests/regression/test_android16_scrcpy_compatibility.py @@ -0,0 +1,24 @@ +from droid_web_display.models import AndroidDevice +from droid_web_display.scrcpy.virtual_display import ( + classify_virtual_display_failure, + virtual_display_capabilities, +) + + +def test_android16_display_listener_failure_is_classified() -> None: + lines = [ + "java.lang.AbstractMethodError: Receiver class does not define or inherit an implementation", + "android.view.IDisplayWindowListener.onDisplayAnimationsDisabledChanged", + ] + assert classify_virtual_display_failure(lines) == "android16-display-listener-incompatibility" + + +def test_android16_capability_probe_surfaces_upstream_warning() -> None: + device = AndroidDevice(serial="test", state="device", manufacturer="Samsung", sdk=36) + result = virtual_display_capabilities(device) + assert any("Android 16" in warning for warning in result["warnings"]) + assert any("IDisplayWindowListener" in warning for warning in result["warnings"]) + + +def test_unrelated_failure_keeps_existing_classification() -> None: + assert classify_virtual_display_failure(["encoder failed"]) == "encoder-initialization-failed" From ba478c47103024285b2f445a9cf0524041a6cd03 Mon Sep 17 00:00:00 2001 From: Aleksandr Chasnyk <69671996+ami3go@users.noreply.github.com> Date: Thu, 20 Aug 2026 09:30:21 +0300 Subject: [PATCH 19/30] docs: add Windows packaging and troubleshooting guide --- docs/WINDOWS_PACKAGING.md | 68 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 docs/WINDOWS_PACKAGING.md diff --git a/docs/WINDOWS_PACKAGING.md b/docs/WINDOWS_PACKAGING.md new file mode 100644 index 0000000..fb88646 --- /dev/null +++ b/docs/WINDOWS_PACKAGING.md @@ -0,0 +1,68 @@ +# Windows packaging and troubleshooting + +DroidWebDisplay ships Windows packages without code signing for now. The hardening in this document is independent of signing. + +## Distribution forms + +Future releases publish two Windows x86-64 forms from the same source commit: + +- **Portable EXE** — `DroidWebDisplay-vX.Y.Z-windows-x86_64.exe`. Convenient single-file launch. PyInstaller extracts this form into a temporary runtime directory while it is running. +- **Stable onedir ZIP** — `DroidWebDisplay-vX.Y.Z-windows-x86_64.zip`. Recommended for long-running use. Extract it to a normal folder and run `DroidWebDisplay.exe`; dependencies remain in a stable directory instead of a temporary `_MEI...` extraction tree. + +Both targets: + +- use the same application version from `VERSION` for Windows PE FileVersion/ProductVersion metadata; +- include ProductName/FileDescription/OriginalFilename metadata; +- include a DroidWebDisplay application icon; +- bundle the verified Android platform-tools `adb.exe` and Windows ADB DLLs; +- bundle the verified scrcpy server and current web client; +- disable UPX compression to remove an unnecessary packaging/AV compatibility variable. + +The portable EXE remains supported because it is useful for ad-hoc use. The onedir ZIP is the preferred form for machines where DroidWebDisplay stays open for many hours or days. + +## USB and ADB troubleshooting + +The Windows package contains ADB, but Windows still needs a working USB driver for the connected Android device. + +Common states from `adb devices -l`: + +- `unauthorized` or `authorizing` — unlock the phone and accept **Allow USB debugging?**. If the prompt does not appear, revoke USB debugging authorizations on Android, reconnect USB, and authorize again. +- `offline` — reconnect the USB cable, unlock the device, and toggle USB debugging if the state persists. +- `no permissions` / device absent — on Windows, install or update the phone manufacturer's/OEM USB driver and check Device Manager. The bundled ADB executable cannot replace a missing kernel USB driver. + +The packaged application has a hidden `--adb-smoke` self-test used by CI to prove that its bundled `adb.exe` and DLLs can execute from both the portable and onedir layouts. + +## Black video while controls still work + +DroidWebDisplay renders H.264 in the browser through WebCodecs; it does not use scrcpy's native Windows Direct3D renderer. A black picture with working controls can therefore be browser/GPU-driver specific. + +Recommended support sequence: + +1. Update Chrome or Edge. +2. Update the Intel/AMD/NVIDIA display driver from the PC/GPU vendor. +3. Restart the browser and reconnect DroidWebDisplay. +4. If the picture is still black, disable browser hardware acceleration, restart the browser, and test again. +5. Record browser version, Windows version, GPU model/driver and whether disabling hardware acceleration changes the result. + +This is intentionally treated as a browser/rendering diagnostic rather than as evidence that the scrcpy transport failed. + +## Android 16 / scrcpy 4.1 + +The protected stable adapter remains scrcpy 4.1. Some Android 16 builds have reported an upstream `AbstractMethodError` involving `IDisplayWindowListener`. DroidWebDisplay now classifies that signature as `android16-display-listener-incompatibility` in virtual-display diagnostics and surfaces an Android 16 compatibility warning from the capability probe. + +Do not replace the stable scrcpy adapter merely to mask this failure. A newer upstream adapter/server should be promoted only through the normal DWD compatibility, regression and hardware-in-loop gates so clipboard, control, physical display and virtual display behavior are not degraded. + +## CI coverage + +The Windows 2025 package gate now builds both distribution forms and verifies: + +- PE ProductName, FileVersion, ProductVersion and OriginalFilename; +- bundled ADB exists and `adb version` executes; +- desktop-host smoke test; +- CLI startup; +- packaged ADB self-test; +- embedded HTTP service startup and shutdown; +- three repeated service start/stop cycles for each distribution form; +- creation of the stable onedir ZIP used by future GitHub releases. + +Real USB devices, browser GPU behavior, sleep/resume and long-duration operation still require HIL testing. \ No newline at end of file From 84eff3422d8f26fb1cace9d00775079111e650fe Mon Sep 17 00:00:00 2001 From: Aleksandr Chasnyk <69671996+ami3go@users.noreply.github.com> Date: Thu, 20 Aug 2026 09:30:36 +0300 Subject: [PATCH 20/30] docs: add Windows release HIL checklist --- docs/WINDOWS_RELEASE_HIL.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 docs/WINDOWS_RELEASE_HIL.md diff --git a/docs/WINDOWS_RELEASE_HIL.md b/docs/WINDOWS_RELEASE_HIL.md new file mode 100644 index 0000000..b3e2621 --- /dev/null +++ b/docs/WINDOWS_RELEASE_HIL.md @@ -0,0 +1,19 @@ +# Windows release HIL checklist + +Run this checklist on the exact packaged Windows artifact before promoting a release when hardware is available. CI covers package startup, repeated shutdown, bundled ADB execution, PE version metadata and service availability; the checks below qualify the real Windows + browser + Android path. + +- Start both the portable EXE and extracted stable onedir ZIP. +- Verify an unauthorized phone is reported by ADB and becomes ready after accepting **Allow USB debugging?**. +- Connect physical display and confirm the first video frame appears without requiring Rotate. +- Rotate twice and confirm video remains/reconnects correctly. +- Verify mouse/touch controls, PC keyboard input, Back/Home/Recent and display power control. +- Verify Android → PC automatic clipboard, Copy button and Ctrl+C. +- Verify PC → Android automatic clipboard, Paste/Ctrl+V and Type; normal PC typing must remain usable. +- Disconnect/reconnect and confirm clipboard/session state does not leak between sessions. +- Transfer a file Android → PC and PC → Android. +- Leave a session active through Windows display-off and sleep/resume, then verify service, video and control recovery. +- Run a multi-hour soak and confirm no orphan `DroidWebDisplay.exe`, `adb.exe`, server process, occupied service port or unexpected `_MEI...` directory remains after exit. +- On at least one Android 16 device, confirm normal operation or capture the explicit `android16-display-listener-incompatibility` classification when the upstream scrcpy signature occurs. +- For any black-video report with working controls, record Chrome/Edge version, GPU and driver version, and the result of disabling browser hardware acceleration. + +A release must not claim this HIL coverage unless it was actually performed on the packaged artifact. \ No newline at end of file From f8ab1b038dec92855939b4244756033751a598bb Mon Sep 17 00:00:00 2001 From: Aleksandr Chasnyk <69671996+ami3go@users.noreply.github.com> Date: Thu, 20 Aug 2026 09:31:12 +0300 Subject: [PATCH 21/30] fix: expose browser diagnostics for Windows video issues --- apps/web-client/src/browser-support.ts | 30 ++++++++++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/apps/web-client/src/browser-support.ts b/apps/web-client/src/browser-support.ts index 797c310..f5c9b31 100644 --- a/apps/web-client/src/browser-support.ts +++ b/apps/web-client/src/browser-support.ts @@ -2,6 +2,9 @@ export interface BrowserCapabilityReport { readonly supported: boolean; readonly missing: readonly string[]; readonly userAgent: string; + readonly browserName: string; + readonly platform: string; + readonly hardwareConcurrency: number | null; readonly audioSupported: boolean; readonly missingAudio: readonly string[]; } @@ -15,7 +18,25 @@ export interface BrowserCapabilityScope { readonly AudioDecoder?: unknown; readonly EncodedAudioChunk?: unknown; readonly AudioContext?: unknown; - readonly navigator?: { readonly userAgent?: string }; + readonly navigator?: { + readonly userAgent?: string; + readonly platform?: string; + readonly hardwareConcurrency?: number; + }; +} + +export function browserName(userAgent: string): string { + const matchers: readonly [RegExp, string][] = [ + [/Edg\/([0-9.]+)/, "Edge"], + [/Chrome\/([0-9.]+)/, "Chrome"], + [/Firefox\/([0-9.]+)/, "Firefox"], + [/Version\/([0-9.]+).*Safari\//, "Safari"], + ]; + for (const [pattern, name] of matchers) { + const match = pattern.exec(userAgent); + if (match) return `${name} ${match[1]}`; + } + return userAgent === "unknown" ? "unknown" : "Other browser"; } export function inspectBrowserCapabilities(scope: BrowserCapabilityScope = globalThis): BrowserCapabilityReport { @@ -23,10 +44,15 @@ export function inspectBrowserCapabilities(scope: BrowserCapabilityScope = globa const missing = required.filter((name) => typeof scope[name] === "undefined"); const audio = ["AudioDecoder", "EncodedAudioChunk", "AudioContext"] as const; const missingAudio = audio.filter((name) => typeof scope[name] === "undefined"); + const userAgent = scope.navigator?.userAgent ?? "unknown"; + const concurrency = scope.navigator?.hardwareConcurrency; return { supported: missing.length === 0, missing, - userAgent: scope.navigator?.userAgent ?? "unknown", + userAgent, + browserName: browserName(userAgent), + platform: scope.navigator?.platform || "unknown", + hardwareConcurrency: typeof concurrency === "number" && Number.isFinite(concurrency) ? concurrency : null, audioSupported: missingAudio.length === 0, missingAudio, }; From d8d1026c473bce391984b478e46ad5ef55050329 Mon Sep 17 00:00:00 2001 From: Aleksandr Chasnyk <69671996+ami3go@users.noreply.github.com> Date: Thu, 20 Aug 2026 09:32:40 +0300 Subject: [PATCH 22/30] fix: surface Windows browser and ADB diagnostics --- apps/web-client/src/main.ts | 79 ++++++++++++++++++++++++++++++++++++- 1 file changed, 78 insertions(+), 1 deletion(-) diff --git a/apps/web-client/src/main.ts b/apps/web-client/src/main.ts index de29bff..d146766 100644 --- a/apps/web-client/src/main.ts +++ b/apps/web-client/src/main.ts @@ -85,6 +85,79 @@ function bindAndroidCopyWriteThrough(): void { }); } +function browserGpuRenderer(): string { + try { + const canvas = document.createElement("canvas"); + const gl = canvas.getContext("webgl"); + if (!gl) return "WebGL unavailable"; + const debug = gl.getExtension("WEBGL_debug_renderer_info") as { readonly UNMASKED_RENDERER_WEBGL: number } | null; + const value = gl.getParameter(debug?.UNMASKED_RENDERER_WEBGL ?? gl.RENDERER); + return typeof value === "string" && value.trim() ? value.trim() : "WebGL available"; + } catch { + return "GPU renderer unavailable"; + } +} + +function installBrowserDiagnostics(capabilities: ReturnType): void { + const statistics = required("#statistics"); + const gpu = browserGpuRenderer(); + const webCodecs = capabilities.supported ? "WebCodecs ready" : `missing ${capabilities.missing.join(", ")}`; + const cpu = capabilities.hardwareConcurrency === null ? "CPU ?" : `${capabilities.hardwareConcurrency} logical CPU`; + const summary = `${capabilities.browserName} · ${capabilities.platform} · ${webCodecs} · ${cpu} · GPU ${gpu}`; + statistics.textContent = summary; + statistics.dataset.browserDiagnostics = summary; + statistics.title = `${summary}. If controls work but video is black: update Chrome/Edge and the GPU driver; if needed disable browser hardware acceleration, restart the browser, then reconnect.`; +} + +function bindAdbDeviceGuidance(): void { + const device = required("#device"); + const status = required("#status"); + const details = required("#details"); + const statusContainer = required("#connection-status"); + const guidanceTitles = new Set([ + "USB authorization required", + "ADB device offline", + "ADB access blocked", + "No Android device", + "ADB device not ready", + ]); + + const update = (): void => { + const options = [...device.options]; + if (options.some((option) => Boolean(option.value) && !option.disabled)) { + if (guidanceTitles.has(status.textContent?.trim() ?? "")) { + status.textContent = "Ready"; + details.textContent = "An authorized Android device is available. Select it and connect."; + statusContainer.setAttribute("aria-label", "disconnected: Ready. An authorized Android device is available."); + } + return; + } + + const labels = options.map((option) => option.textContent?.toLowerCase() ?? "").join(" "); + if (labels.includes("unauthorized") || labels.includes("authorizing")) { + status.textContent = "USB authorization required"; + details.textContent = "Unlock the Android device, accept “Allow USB debugging?”, then refresh devices."; + } else if (labels.includes("no permissions")) { + status.textContent = "ADB access blocked"; + details.textContent = "The phone is visible but ADB cannot access it. On Windows, install/update the phone OEM USB driver and reconnect USB."; + } else if (labels.includes("offline")) { + status.textContent = "ADB device offline"; + details.textContent = "Reconnect USB, unlock the phone, and toggle USB debugging if the device remains offline."; + } else if (!options.length) { + status.textContent = "No Android device"; + details.textContent = "Connect the phone with USB debugging enabled. Windows may require the manufacturer/OEM USB driver."; + } else { + status.textContent = "ADB device not ready"; + details.textContent = `Connected device state: ${options[0]?.textContent?.trim() || "unknown"}. Resolve the USB/Android state and refresh devices.`; + } + statusContainer.setAttribute("aria-label", `disconnected: ${status.textContent}. ${details.textContent}`); + }; + + new MutationObserver(update).observe(device, { childList: true, subtree: true, attributes: true, attributeFilter: ["disabled"] }); + device.addEventListener("change", update); + update(); +} + async function bootstrap(): Promise { const auth = new AuthController({ gate: required("#auth-gate"), @@ -124,6 +197,7 @@ async function bootstrap(): Promise { unsupported.textContent = `This browser is unsupported. Missing: ${capabilities.missing.join(", ")}. Use a current Chromium browser with WebCodecs.`; } else { app.hidden = false; + installBrowserDiagnostics(capabilities); const networkController = new NetworkAccessController({ card: required("#network-card"), badge: required("#network-mode-badge"), @@ -274,7 +348,10 @@ async function bootstrap(): Promise { }); window.addEventListener("beforeunload", () => { controller.stopOnUnload(); runningAppController.close(); transferController.close(); autoDownloadController.close(); }); void controller.initialize() - .then(() => Promise.all([transferController.initialize(), autoDownloadController.initialize(), runningAppController.initialize()])) + .then(() => { + bindAdbDeviceGuidance(); + return Promise.all([transferController.initialize(), autoDownloadController.initialize(), runningAppController.initialize()]); + }) .catch((error: unknown) => { required("#status").textContent = "Initialization failed"; required("#details").textContent = error instanceof Error ? error.message : String(error); From 5c14c7022e4e12168e8b1bfb4743cb4683fd5d3e Mon Sep 17 00:00:00 2001 From: Aleksandr Chasnyk <69671996+ami3go@users.noreply.github.com> Date: Thu, 20 Aug 2026 09:32:59 +0300 Subject: [PATCH 23/30] build: update browser diagnostics runtime --- .../web-client/dist/assets/browser-support.js | 21 ++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/apps/web-client/dist/assets/browser-support.js b/apps/web-client/dist/assets/browser-support.js index 1a820e1..c4d97ee 100644 --- a/apps/web-client/dist/assets/browser-support.js +++ b/apps/web-client/dist/assets/browser-support.js @@ -1,12 +1,31 @@ +export function browserName(userAgent) { + const matchers = [ + [/Edg\/([0-9.]+)/, "Edge"], + [/Chrome\/([0-9.]+)/, "Chrome"], + [/Firefox\/([0-9.]+)/, "Firefox"], + [/Version\/([0-9.]+).*Safari\//, "Safari"], + ]; + for (const [pattern, name] of matchers) { + const match = pattern.exec(userAgent); + if (match) + return `${name} ${match[1]}`; + } + return userAgent === "unknown" ? "unknown" : "Other browser"; +} export function inspectBrowserCapabilities(scope = globalThis) { const required = ["WebSocket", "ReadableStream", "WritableStream", "VideoDecoder", "EncodedVideoChunk"]; const missing = required.filter((name) => typeof scope[name] === "undefined"); const audio = ["AudioDecoder", "EncodedAudioChunk", "AudioContext"]; const missingAudio = audio.filter((name) => typeof scope[name] === "undefined"); + const userAgent = scope.navigator?.userAgent ?? "unknown"; + const concurrency = scope.navigator?.hardwareConcurrency; return { supported: missing.length === 0, missing, - userAgent: scope.navigator?.userAgent ?? "unknown", + userAgent, + browserName: browserName(userAgent), + platform: scope.navigator?.platform || "unknown", + hardwareConcurrency: typeof concurrency === "number" && Number.isFinite(concurrency) ? concurrency : null, audioSupported: missingAudio.length === 0, missingAudio, }; From a501f9c7bfdf772e38916c7a0ff0e016e3fec2ec Mon Sep 17 00:00:00 2001 From: Aleksandr Chasnyk <69671996+ami3go@users.noreply.github.com> Date: Thu, 20 Aug 2026 09:33:43 +0300 Subject: [PATCH 24/30] build: update Windows diagnostics runtime --- apps/web-client/dist/assets/main.js | 79 ++++++++++++++++++++++++++++- 1 file changed, 78 insertions(+), 1 deletion(-) diff --git a/apps/web-client/dist/assets/main.js b/apps/web-client/dist/assets/main.js index 6a57ce4..ca3f66b 100644 --- a/apps/web-client/dist/assets/main.js +++ b/apps/web-client/dist/assets/main.js @@ -86,6 +86,79 @@ function bindAndroidCopyWriteThrough() { void finishCopy(); }); } +function browserGpuRenderer() { + try { + const canvas = document.createElement("canvas"); + const gl = canvas.getContext("webgl"); + if (!gl) + return "WebGL unavailable"; + const debug = gl.getExtension("WEBGL_debug_renderer_info"); + const value = gl.getParameter(debug?.UNMASKED_RENDERER_WEBGL ?? gl.RENDERER); + return typeof value === "string" && value.trim() ? value.trim() : "WebGL available"; + } + catch { + return "GPU renderer unavailable"; + } +} +function installBrowserDiagnostics(capabilities) { + const statistics = required("#statistics"); + const gpu = browserGpuRenderer(); + const webCodecs = capabilities.supported ? "WebCodecs ready" : `missing ${capabilities.missing.join(", ")}`; + const cpu = capabilities.hardwareConcurrency === null ? "CPU ?" : `${capabilities.hardwareConcurrency} logical CPU`; + const summary = `${capabilities.browserName} · ${capabilities.platform} · ${webCodecs} · ${cpu} · GPU ${gpu}`; + statistics.textContent = summary; + statistics.dataset.browserDiagnostics = summary; + statistics.title = `${summary}. If controls work but video is black: update Chrome/Edge and the GPU driver; if needed disable browser hardware acceleration, restart the browser, then reconnect.`; +} +function bindAdbDeviceGuidance() { + const device = required("#device"); + const status = required("#status"); + const details = required("#details"); + const statusContainer = required("#connection-status"); + const guidanceTitles = new Set([ + "USB authorization required", + "ADB device offline", + "ADB access blocked", + "No Android device", + "ADB device not ready", + ]); + const update = () => { + const options = [...device.options]; + if (options.some((option) => Boolean(option.value) && !option.disabled)) { + if (guidanceTitles.has(status.textContent?.trim() ?? "")) { + status.textContent = "Ready"; + details.textContent = "An authorized Android device is available. Select it and connect."; + statusContainer.setAttribute("aria-label", "disconnected: Ready. An authorized Android device is available."); + } + return; + } + const labels = options.map((option) => option.textContent?.toLowerCase() ?? "").join(" "); + if (labels.includes("unauthorized") || labels.includes("authorizing")) { + status.textContent = "USB authorization required"; + details.textContent = "Unlock the Android device, accept “Allow USB debugging?”, then refresh devices."; + } + else if (labels.includes("no permissions")) { + status.textContent = "ADB access blocked"; + details.textContent = "The phone is visible but ADB cannot access it. On Windows, install/update the phone OEM USB driver and reconnect USB."; + } + else if (labels.includes("offline")) { + status.textContent = "ADB device offline"; + details.textContent = "Reconnect USB, unlock the phone, and toggle USB debugging if the device remains offline."; + } + else if (!options.length) { + status.textContent = "No Android device"; + details.textContent = "Connect the phone with USB debugging enabled. Windows may require the manufacturer/OEM USB driver."; + } + else { + status.textContent = "ADB device not ready"; + details.textContent = `Connected device state: ${options[0]?.textContent?.trim() || "unknown"}. Resolve the USB/Android state and refresh devices.`; + } + statusContainer.setAttribute("aria-label", `disconnected: ${status.textContent}. ${details.textContent}`); + }; + new MutationObserver(update).observe(device, { childList: true, subtree: true, attributes: true, attributeFilter: ["disabled"] }); + device.addEventListener("change", update); + update(); +} async function bootstrap() { const auth = new AuthController({ gate: required("#auth-gate"), @@ -125,6 +198,7 @@ async function bootstrap() { } else { app.hidden = false; + installBrowserDiagnostics(capabilities); const networkController = new NetworkAccessController({ card: required("#network-card"), badge: required("#network-mode-badge"), @@ -274,7 +348,10 @@ async function bootstrap() { }); window.addEventListener("beforeunload", () => { controller.stopOnUnload(); runningAppController.close(); transferController.close(); autoDownloadController.close(); }); void controller.initialize() - .then(() => Promise.all([transferController.initialize(), autoDownloadController.initialize(), runningAppController.initialize()])) + .then(() => { + bindAdbDeviceGuidance(); + return Promise.all([transferController.initialize(), autoDownloadController.initialize(), runningAppController.initialize()]); + }) .catch((error) => { required("#status").textContent = "Initialization failed"; required("#details").textContent = error instanceof Error ? error.message : String(error); From 1bc7c39f06975357b6c6f5b7026ccd94803ed9b7 Mon Sep 17 00:00:00 2001 From: Aleksandr Chasnyk <69671996+ami3go@users.noreply.github.com> Date: Thu, 20 Aug 2026 09:34:10 +0300 Subject: [PATCH 25/30] test: cover browser diagnostics --- apps/web-client/tests/browser-support.test.mjs | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/apps/web-client/tests/browser-support.test.mjs b/apps/web-client/tests/browser-support.test.mjs index 13ea1f3..012824a 100644 --- a/apps/web-client/tests/browser-support.test.mjs +++ b/apps/web-client/tests/browser-support.test.mjs @@ -1,6 +1,6 @@ import test from "node:test"; import assert from "node:assert/strict"; -import { inspectBrowserCapabilities } from "../dist/assets/browser-support.js"; +import { browserName, inspectBrowserCapabilities } from "../dist/assets/browser-support.js"; import { decoderBacklogAction } from "../dist/assets/video-renderer.js"; test("reports all mandatory Chromium/WebCodecs capabilities", () => { @@ -13,12 +13,22 @@ test("reports all mandatory Chromium/WebCodecs capabilities", () => { AudioDecoder: class {}, EncodedAudioChunk: class {}, AudioContext: class {}, - navigator: { userAgent: "Chromium test" }, + navigator: { userAgent: "Mozilla/5.0 Chrome/150.0.0.0 Safari/537.36", platform: "Win32", hardwareConcurrency: 8 }, }); assert.equal(report.supported, true); assert.deepEqual(report.missing, []); assert.equal(report.audioSupported, true); assert.deepEqual(report.missingAudio, []); + assert.equal(report.browserName, "Chrome 150.0.0.0"); + assert.equal(report.platform, "Win32"); + assert.equal(report.hardwareConcurrency, 8); +}); + +test("extracts Edge before the embedded Chromium token", () => { + assert.equal( + browserName("Mozilla/5.0 Chrome/150.0.0.0 Safari/537.36 Edg/150.0.0.0"), + "Edge 150.0.0.0", + ); }); test("rejects a browser without WebCodecs", () => { @@ -32,7 +42,6 @@ test("rejects a browser without WebCodecs", () => { assert.deepEqual(report.missing, ["VideoDecoder"]); }); - test("reports optional audio independently from mandatory video support", () => { const report = inspectBrowserCapabilities({ WebSocket: class {}, From 767958af52f8dd95e33a390971f958ccccfe2628 Mon Sep 17 00:00:00 2001 From: Aleksandr Chasnyk <69671996+ami3go@users.noreply.github.com> Date: Thu, 20 Aug 2026 09:36:51 +0300 Subject: [PATCH 26/30] ci: prebuild web client before integrity gate --- .github/workflows/ci.yml | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 35ed4f5..691f7d7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -50,10 +50,16 @@ jobs: test "$(.tools/uv/uv --version | awk '{print $1 " " $2}')" = "uv 0.12.4" - name: Sync locked Python environment run: .tools/uv/uv sync --locked --extra dev - - working-directory: packages/scrcpy-protocol - run: npm ci - - working-directory: apps/web-client - run: npm ci + - name: Build protocol package before integrity checks + working-directory: packages/scrcpy-protocol + run: | + npm ci + npm run build + - name: Build web client before integrity checks + working-directory: apps/web-client + run: | + npm ci + npm run build - name: Run release gate run: .tools/uv/uv run python tools/release_gate.py --require-web-client-build --output /tmp/release-gate.json - name: Generate build SBOM and license inventory From c477409ebdc5cfd5ade7f7c0f259a57099a9e87c Mon Sep 17 00:00:00 2001 From: Aleksandr Chasnyk <69671996+ami3go@users.noreply.github.com> Date: Thu, 20 Aug 2026 09:37:24 +0300 Subject: [PATCH 27/30] test: protect Windows user diagnostics --- tests/packaging/test_windows_package_hardening.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/tests/packaging/test_windows_package_hardening.py b/tests/packaging/test_windows_package_hardening.py index 1896942..d11d653 100644 --- a/tests/packaging/test_windows_package_hardening.py +++ b/tests/packaging/test_windows_package_hardening.py @@ -31,6 +31,7 @@ def test_windows_ci_builds_and_smokes_both_distribution_forms() -> None: assert "--adb-smoke" in workflow assert "Repeat Windows service start-stop smoke" in workflow assert "Verify Windows PE metadata and bundled ADB" in workflow + assert "Build web client before integrity checks" in workflow def test_future_releases_publish_stable_windows_zip() -> None: @@ -44,3 +45,15 @@ def test_desktop_package_has_bundled_adb_self_test() -> None: assert '"--adb-smoke"' in entry assert 'subprocess.run([str(adb), "version"]' in entry assert "CREATE_NO_WINDOW" in entry + + +def test_web_client_surfaces_windows_adb_and_black_video_guidance() -> None: + main = (ROOT / "apps/web-client/src/main.ts").read_text(encoding="utf-8") + browser = (ROOT / "apps/web-client/src/browser-support.ts").read_text(encoding="utf-8") + assert "USB authorization required" in main + assert "OEM USB driver" in main + assert "ADB device offline" in main + assert "browserGpuRenderer" in main + assert "disable browser hardware acceleration" in main + assert "browserName" in browser + assert "hardwareConcurrency" in browser From 1e097784513e9daa1b8bd3976d2e3e0c0c9f7ebe Mon Sep 17 00:00:00 2001 From: Aleksandr Chasnyk <69671996+ami3go@users.noreply.github.com> Date: Thu, 20 Aug 2026 09:41:00 +0300 Subject: [PATCH 28/30] ci: update pinned setup-python action --- .github/workflows/ci.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 691f7d7..9cec4d2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,7 +30,7 @@ jobs: libxcb-render-util0 \ libxcb-shape0 \ libxcb-xkb1 - - uses: actions/setup-python@a26af69be951a213d495a4e3e4e4022e16d87065 # v5 + - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 with: python-version: '3.11.15' - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 @@ -74,7 +74,7 @@ jobs: runs-on: windows-2025 steps: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - - uses: actions/setup-python@a26af69be951a213d495a4e3e4e4022e16d87065 # v5 + - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 with: python-version: '3.11.9' - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 @@ -176,7 +176,7 @@ jobs: libxcb-render-util0 \ libxcb-shape0 \ libxcb-xkb1 - - uses: actions/setup-python@a26af69be951a213d495a4e3e4e4022e16d87065 # v5 + - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 with: python-version: '3.11.15' - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 From 2dbf9f8ea80c4852092759407f441b09a99ef458 Mon Sep 17 00:00:00 2001 From: Aleksandr Chasnyk <69671996+ami3go@users.noreply.github.com> Date: Thu, 20 Aug 2026 09:43:17 +0300 Subject: [PATCH 29/30] test: match generated Windows icon source path --- tests/packaging/test_windows_package_hardening.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/packaging/test_windows_package_hardening.py b/tests/packaging/test_windows_package_hardening.py index d11d653..ca6de82 100644 --- a/tests/packaging/test_windows_package_hardening.py +++ b/tests/packaging/test_windows_package_hardening.py @@ -12,7 +12,7 @@ def test_windows_pyinstaller_targets_disable_upx_and_embed_metadata() -> None: assert "ProductName" in text assert "ProductVersion" in text assert "OriginalFilename" in text - assert "droidwebdisplay.ico.base64" in text + assert 'with_suffix(".ico.base64")' in text assert "runtime_tmpdir=None" in portable assert 'name="DroidWebDisplayWindowsOnedir"' in onedir From 3378d4906dfc5711b30027a7df211d83f04bc3db Mon Sep 17 00:00:00 2001 From: Aleksandr Chasnyk <69671996+ami3go@users.noreply.github.com> Date: Thu, 20 Aug 2026 09:43:35 +0300 Subject: [PATCH 30/30] ci: lock setup-python v6 action --- packaging/supply-chain-lock.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packaging/supply-chain-lock.json b/packaging/supply-chain-lock.json index 6cf872b..a512934 100644 --- a/packaging/supply-chain-lock.json +++ b/packaging/supply-chain-lock.json @@ -15,8 +15,8 @@ }, "setupPython": { "repository": "actions/setup-python", - "ref": "a26af69be951a213d495a4c3e4e4022e16d87065", - "label": "v5" + "ref": "ece7cb06caefa5fff74198d8649806c4678c61a1", + "label": "v6" }, "setupNode": { "repository": "actions/setup-node",