Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,33 @@ jobs:
"$APPDIR" \
"release/DroidWebDisplay-ci-linux-x86_64.AppImage"
chmod +x release/DroidWebDisplay-ci-linux-x86_64.AppImage
- name: Verify AppImage version metadata and bundled ADB
shell: bash
run: |
set -euo pipefail
# The Windows job verifies PE FileVersion/ProductVersion against VERSION.
# Without an equivalent here, release.yml renames this artifact to
# DroidWebDisplay-v<version>-linux-x86_64.AppImage and publishes a
# filename asserting a version nothing checked.
version=$(tr -d '[:space:]' < VERSION)
appimage="$PWD/release/DroidWebDisplay-ci-linux-x86_64.AppImage"
workdir=$(mktemp -d)
cd "$workdir"
"$appimage" --appimage-extract > /dev/null
root="$workdir/squashfs-root/usr/lib/droidwebdisplay"
test -d "$root"
bundled=$(find "$root" -maxdepth 3 -name VERSION -type f | head -n 1)
test -n "$bundled"
got=$(tr -d '[:space:]' < "$bundled")
if [ "$got" != "$version" ]; then
echo "AppImage bundles VERSION $got but the tree says $version" >&2
exit 1
fi
adb=$(find "$root" -name adb -type f | head -n 1)
test -n "$adb"
echo "AppImage version $got verified, bundled adb at ${adb#$root/}"
cd - > /dev/null
rm -rf "$workdir"
- name: Smoke-test AppImage desktop host
run: QT_QPA_PLATFORM=offscreen APPIMAGE_EXTRACT_AND_RUN=1 ./release/DroidWebDisplay-ci-linux-x86_64.AppImage --desktop-smoke
- name: Smoke-test AppImage CLI
Expand Down
4 changes: 2 additions & 2 deletions apps/web-client/dist-manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,8 @@
},
{
"path": "assets/auth-controller.js",
"bytes": 8041,
"sha256": "34e6bb0595008cbe7786dc7f634da286379e46e16e4c1494aa24744c3b9156c0"
"bytes": 9543,
"sha256": "4df111874929f709190dd278da49e40c3153321b8e54ad8f2f7336bf9d2254d2"
},
{
"path": "assets/auth-controller.js.map",
Expand Down
42 changes: 38 additions & 4 deletions apps/web-client/dist/assets/auth-controller.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

40 changes: 36 additions & 4 deletions apps/web-client/src/auth-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ function customSeconds(value: number, unit: string): number {
export class AuthController {
readonly #api: BridgeApi;
#status: AuthStatusDto | null = null;
#reopening = false;

public constructor(private readonly elements: AuthElements, api = new BridgeApi()) {
this.#api = api;
Expand All @@ -51,10 +52,7 @@ export class AuthController {
elements.logout.addEventListener("click", () => { void this.logout(); });
elements.changePin.addEventListener("click", () => { void this.changePin(); });
elements.revokeAll.addEventListener("click", () => { void this.revokeAll(); });
globalThis.addEventListener("droidwebdisplay-auth-required", () => {
this.elements.gate.hidden = false;
this.elements.securityStatus.textContent = "Session expired or revoked. Authenticate again.";
});
globalThis.addEventListener("droidwebdisplay-auth-required", () => { void this.#reopenGate(); });
}

public async ensureAuthenticated(): Promise<AuthStatusDto> {
Expand Down Expand Up @@ -118,6 +116,40 @@ export class AuthController {
}
}

/** Re-open the gate after a session expires or is revoked.

This used to only unhide the gate, which left whatever form was rendered
last on screen. After first-run setup that is the setup form, so a lock
later in the same page session showed "Create bridge PIN" with the
Confirm PIN box still visible, even though the PIN already exists and the
submit would perform a login. The server is the authority on whether a
PIN is configured, so re-read it. */
async #reopenGate(): Promise<void> {
this.elements.securityStatus.textContent = "Session expired or revoked. Authenticate again.";
// Several in-flight requests can each answer 401 at once. Re-rendering per
// event would clear the PIN field under someone already typing into it.
if (!this.elements.gate.hidden || this.#reopening) return;
this.#reopening = true;
try {
let configured = this.#status?.configured ?? false;
try {
const status = await this.#api.authStatus();
this.#status = status;
if (status.authenticated) {
this.#showAuthenticated(status);
return;
}
configured = status.configured;
} catch {
// Status is unreachable; fall back to the last known value rather than
// leaving the user with no way back in.
}
this.#renderGate(configured);
} finally {
this.#reopening = false;
}
}

#renderGate(configured: boolean): void {
this.elements.gate.hidden = false;
this.elements.title.textContent = configured ? "Unlock DroidWebDisplay" : "Create bridge PIN";
Expand Down
15 changes: 15 additions & 0 deletions apps/web-client/tests/layout.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -358,3 +358,18 @@ test("Files drawer uses Explorer-only transfers with custom PC destination", ()
assert.match(html, /id="duplicate-policy"/);
assert.match(transferSource, /destinationPath/);
});

test("re-locking re-reads whether a PIN exists instead of reusing the setup form", async () => {
const auth = await readFile(resolve(root, "src/auth-controller.ts"), "utf8");
// The gate renders two different forms: setup (Confirm PIN visible) and
// unlock. Only unhiding it on droidwebdisplay-auth-required left the setup
// form on screen after a lock later in the same page session.
assert.match(auth, /addEventListener\("droidwebdisplay-auth-required".*#reopenGate\(\)/);
const reopen = auth.slice(auth.indexOf("async #reopenGate"), auth.indexOf("#renderGate(configured: boolean)"));
assert.match(reopen, /await this\.#api\.authStatus\(\)/);
assert.match(reopen, /this\.#renderGate\(configured\)/);
// A burst of 401s must not clear the PIN box under someone mid-typing.
assert.match(reopen, /if \(!this\.elements\.gate\.hidden \|\| this\.#reopening\) return;/);
// The listener must not simply unhide the gate any more.
assert.doesNotMatch(auth, /"droidwebdisplay-auth-required", \(\) => \{\s*this\.elements\.gate\.hidden = false;/);
});
1 change: 1 addition & 0 deletions packaging/linux/droidwebdisplay.desktop.in
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,6 @@ Type=Application
Name=DroidWebDisplay
Comment=Open the local DroidWebDisplay browser interface
Exec=@LAUNCHER@
Icon=droidwebdisplay
Terminal=false
Categories=Development;Utility;
8 changes: 7 additions & 1 deletion packaging/linux/install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ INSTALL_ROOT="${DROID_WEB_DISPLAY_INSTALL_ROOT:-$HOME/.local/share/droidwebdispl
BIN_DIR="${XDG_BIN_HOME:-$HOME/.local/bin}"
SYSTEMD_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user"
DESKTOP_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/applications"
ICON_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/icons/hicolor/scalable/apps"

SOURCE_REAL="$(cd "$SOURCE" && pwd)"
mkdir -p "$INSTALL_ROOT"
Expand All @@ -23,7 +24,7 @@ for candidate in "$INSTALL_ROOT/runtime/python/bin/python3" "$INSTALL_ROOT/runti
fi
done

mkdir -p "$INSTALL_ROOT" "$BIN_DIR" "$SYSTEMD_DIR" "$DESKTOP_DIR"
mkdir -p "$INSTALL_ROOT" "$BIN_DIR" "$SYSTEMD_DIR" "$DESKTOP_DIR" "$ICON_DIR"
for state in data downloads logs; do mkdir -p "$INSTALL_ROOT/$state"; done

# Refresh executable application files while preserving runtime state and any
Expand Down Expand Up @@ -95,6 +96,11 @@ chmod +x "$BIN_DIR/droidwebdisplay-stop"

sed "s|@INSTALL_ROOT@|$INSTALL_ROOT|g" "$INSTALL_ROOT/packaging/linux/droidwebdisplay.service.in" > "$SYSTEMD_DIR/droidwebdisplay.service"
sed "s|@LAUNCHER@|$BIN_DIR/droidwebdisplay|g" "$INSTALL_ROOT/packaging/linux/droidwebdisplay.desktop.in" > "$DESKTOP_DIR/droidwebdisplay.desktop"
# The desktop entry names Icon=droidwebdisplay, so the SVG has to be on the
# icon search path or the launcher shows a generic placeholder. The AppImage
# ships its own copy; a system install had none.
install -m 0644 "$INSTALL_ROOT/packaging/linux/droidwebdisplay.svg" "$ICON_DIR/droidwebdisplay.svg"
gtk-update-icon-cache -f -t "${XDG_DATA_HOME:-$HOME/.local/share}/icons/hicolor" >/dev/null 2>&1 || true
systemctl --user daemon-reload >/dev/null 2>&1 || true
printf 'Installed DroidWebDisplay to %s\n' "$INSTALL_ROOT"
printf 'Start now: systemctl --user start droidwebdisplay.service\n'
Expand Down
3 changes: 2 additions & 1 deletion packaging/linux/uninstall.sh
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,11 @@ INSTALL_ROOT="${DROID_WEB_DISPLAY_INSTALL_ROOT:-$HOME/.local/share/droidwebdispl
BIN_DIR="${XDG_BIN_HOME:-$HOME/.local/bin}"
SYSTEMD_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user"
DESKTOP_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/applications"
ICON_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/icons/hicolor/scalable/apps"
PURGE=0
[[ "${1:-}" == "--purge-data" ]] && PURGE=1
systemctl --user disable --now droidwebdisplay.service >/dev/null 2>&1 || true
rm -f "$SYSTEMD_DIR/droidwebdisplay.service" "$BIN_DIR/droidwebdisplay" "$BIN_DIR/droidwebdisplay-stop" "$DESKTOP_DIR/droidwebdisplay.desktop"
rm -f "$SYSTEMD_DIR/droidwebdisplay.service" "$BIN_DIR/droidwebdisplay" "$BIN_DIR/droidwebdisplay-stop" "$DESKTOP_DIR/droidwebdisplay.desktop" "$ICON_DIR/droidwebdisplay.svg"
systemctl --user daemon-reload >/dev/null 2>&1 || true
if [[ $PURGE -eq 1 ]]; then
rm -rf "$INSTALL_ROOT"
Expand Down
124 changes: 45 additions & 79 deletions packaging/pyinstaller/DroidWebDisplay.spec
Original file line number Diff line number Diff line change
@@ -1,43 +1,27 @@
# -*- mode: python ; coding: utf-8 -*-
from pathlib import Path
import os
import sys

from PyInstaller.utils.hooks import collect_submodules
sys.path.insert(0, SPECPATH)
import _dwd_common as common

ROOT = Path(SPECPATH).resolve().parents[1]
ADB_DIR = Path(os.environ["DWD_ADB_DIR"]).resolve()

adb_names = ["adb.exe", "AdbWinApi.dll", "AdbWinUsbApi.dll"] if sys.platform == "win32" else ["adb"]
adb_binaries = [(str(ADB_DIR / name), "adb") for name in adb_names if (ADB_DIR / name).is_file()]
if not any(Path(source).name.lower() in {"adb", "adb.exe"} for source, _ in adb_binaries):
raise SystemExit(f"ADB executable missing from {ADB_DIR}")

server_dir = ROOT / "server"
if not server_dir.is_dir():
raise SystemExit("server directory is missing; run tools/download_server.py first")

hiddenimports = sorted(set(collect_submodules("uvicorn") + collect_submodules("websockets")))
if sys.platform == "win32":
# Windows has dedicated specs that attach the icon and the VS version
# resource. This one produces neither, so falling back to it here builds
# exactly the binary ci.yml's PE metadata check exists to reject.
raise SystemExit(
"Use packaging/pyinstaller/DroidWebDisplayWindows.spec or "
"DroidWebDisplayWindowsOnedir.spec on Windows"
)

datas = [
(str(ROOT / "apps" / "web-client" / "dist"), "apps/web-client/dist"),
(str(ROOT / "apps" / "web-client" / "dist-manifest.json"), "apps/web-client"),
(str(ROOT / "packages" / "scrcpy-protocol" / "dist"), "packages/scrcpy-protocol/dist"),
(str(ROOT / "packages" / "scrcpy-protocol" / "package.json"), "packages/scrcpy-protocol"),
(str(ROOT / "compatibility"), "compatibility"),
(str(server_dir), "server"),
(str(ROOT / "VERSION"), "."),
(str(ROOT / "LICENSE"), "."),
(str(ROOT / "THIRD_PARTY_NOTICES.md"), "."),
(str(ROOT / "SECURITY.md"), "."),
]
ROOT = common.repo_root(SPECPATH)

a = Analysis(
[str(ROOT / "tools" / "desktop_entry.py")],
[str(ROOT.joinpath(*common.ENTRY_SCRIPT))],
pathex=[str(ROOT), str(ROOT / "tools")],
binaries=adb_binaries,
datas=datas,
hiddenimports=hiddenimports,
binaries=common.adb_binaries(common.adb_dir(), windows=False),
datas=common.bundle_datas(ROOT),
hiddenimports=common.hidden_imports(),
hookspath=[],
hooksconfig={},
runtime_hooks=[],
Expand All @@ -47,51 +31,33 @@ a = Analysis(
)
pyz = PYZ(a.pure)

if sys.platform == "win32":
exe = EXE(
pyz,
a.scripts,
a.binaries,
a.datas,
[],
name="DroidWebDisplay",
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=True,
upx_exclude=[],
runtime_tmpdir=None,
console=False,
disable_windowed_traceback=False,
argv_emulation=False,
target_arch=None,
codesign_identity=None,
entitlements_file=None,
)
else:
exe = EXE(
pyz,
a.scripts,
[],
exclude_binaries=True,
name="DroidWebDisplay",
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=True,
console=True,
disable_windowed_traceback=False,
argv_emulation=False,
target_arch=None,
codesign_identity=None,
entitlements_file=None,
)
coll = COLLECT(
exe,
a.binaries,
a.datas,
strip=False,
upx=True,
upx_exclude=[],
name="DroidWebDisplay",
)
# upx is intentionally disabled, matching both Windows specs. It was never
# installed on the Linux runner, so upx=True silently did nothing -- and would
# have activated unreviewed the moment upx appeared, which on Qt binaries is a
# known source of corrupt executables and antivirus false positives.
exe = EXE(
pyz,
a.scripts,
[],
exclude_binaries=True,
name="DroidWebDisplay",
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=False,
console=True,
disable_windowed_traceback=False,
argv_emulation=False,
target_arch=None,
codesign_identity=None,
entitlements_file=None,
)
coll = COLLECT(
exe,
a.binaries,
a.datas,
strip=False,
upx=False,
upx_exclude=[],
name="DroidWebDisplay",
)
Loading
Loading