diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 817a516..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@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 with: python-version: '3.11.15' - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 @@ -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 @@ -68,7 +74,7 @@ jobs: runs-on: windows-2025 steps: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 with: python-version: '3.11.9' - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 @@ -99,24 +105,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 +176,7 @@ jobs: libxcb-render-util0 \ libxcb-shape0 \ libxcb-xkb1 - - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 with: python-version: '3.11.15' - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 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 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, }; 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); 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, }; 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); 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 {}, 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 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 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"] diff --git a/packaging/pyinstaller/DroidWebDisplayWindows.spec b/packaging/pyinstaller/DroidWebDisplayWindows.spec new file mode 100644 index 0000000..10d51bb --- /dev/null +++ b/packaging/pyinstaller/DroidWebDisplayWindows.spec @@ -0,0 +1,118 @@ +# -*- mode: python ; coding: utf-8 -*- +from pathlib import Path +import base64 +import os +import sys + +from PyInstaller.utils.hooks import collect_submodules +from PyInstaller.utils.win32.versioninfo import ( + FixedFileInfo, + StringFileInfo, + 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) + +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, + 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(ICON), + version=version_info, +) diff --git a/packaging/pyinstaller/DroidWebDisplayWindowsOnedir.spec b/packaging/pyinstaller/DroidWebDisplayWindowsOnedir.spec new file mode 100644 index 0000000..b4fbf17 --- /dev/null +++ b/packaging/pyinstaller/DroidWebDisplayWindowsOnedir.spec @@ -0,0 +1,124 @@ +# -*- mode: python ; coding: utf-8 -*- +from pathlib import Path +import base64 +import os +import sys + +from PyInstaller.utils.hooks import collect_submodules +from PyInstaller.utils.win32.versioninfo import ( + FixedFileInfo, + StringFileInfo, + 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) + +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, + 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(ICON), + version=version_info, +) +coll = COLLECT( + exe, + a.binaries, + a.datas, + strip=False, + upx=False, + upx_exclude=[], + name="DroidWebDisplayWindowsOnedir", +) 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", 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 diff --git a/tests/packaging/test_windows_package_hardening.py b/tests/packaging/test_windows_package_hardening.py new file mode 100644 index 0000000..ca6de82 --- /dev/null +++ b/tests/packaging/test_windows_package_hardening.py @@ -0,0 +1,59 @@ +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 'with_suffix(".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 + assert "Build web client before integrity checks" 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 + + +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 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" 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