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
473 changes: 472 additions & 1 deletion apps/web-client/dist-manifest.json

Large diffs are not rendered by default.

53 changes: 45 additions & 8 deletions apps/web-client/dist/assets/controller.js

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

51 changes: 43 additions & 8 deletions apps/web-client/src/controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,16 @@ import { WebSocketBridgeTransport } from "./websocket-transport.js";
import { WebCodecsAudioPlayer, type AudioStatistics } from "./audio-player.js";

const DEVICE_DROPDOWN_REFRESH_STALE_MS = 1500;
// textInjectionMessages chunks at 300 UTF-8 bytes and sendMessages awaits every
// chunk, so injection cost grows linearly with the text: ~875 sequential round
// trips at the 256 KiB clipboard limit. Above this size the clipboard is still
// synchronized and the user pastes on the device.
const MAX_INJECTED_BYTES = 8 * 1024;
// An unacknowledged automatic sync is deliberately retryable, but the poller
// runs every 1800ms and only skips text it has recorded as sent, so without a
// cap the same text is re-sent every tick for as long as it stays on the PC
// clipboard. Give up after this many attempts and stop asking.
const MAX_UNACKNOWLEDGED_SYNC_ATTEMPTS = 3;

interface ClipboardAckWaiter {
readonly resolve: (acknowledged: boolean) => void;
Expand Down Expand Up @@ -101,6 +111,7 @@ export class DroidWebDisplayController {
#lastConnectValues: DisplayFormValues | null = null;
#lastAndroidClipboard = "";
#lastSentClipboard = "";
#unacknowledgedSync: { readonly text: string; readonly attempts: number } | null = null;
#clipboardPollTimer: number | null = null;
#clipboardReadAllowed = false;
#clipboardPollBusy = false;
Expand Down Expand Up @@ -631,7 +642,11 @@ export class DroidWebDisplayController {
const text = this.elements.clipboardText.value;
if (!text) throw new Error("Enter or paste text into the fallback box first");
const maximum = Math.max(1, Math.min(256, Number(this.elements.clipboardMaxKib.value) || 256)) * 1024;
if (new TextEncoder().encode(text).byteLength > maximum) throw new Error(`Text exceeds the configured ${maximum / 1024} KiB limit`);
const bytes = new TextEncoder().encode(text).byteLength;
if (bytes > maximum) throw new Error(`Text exceeds the configured ${maximum / 1024} KiB limit`);
if (bytes > MAX_INJECTED_BYTES) {
throw new Error(`Text is too large to type into Android (${Math.ceil(bytes / 1024)} KiB). Use Paste, which synchronizes the clipboard instead of typing.`);
}
this.setStatus("Typing", "Injecting the text box directly into the focused Android input field…");
await this.sendMessages(textInjectionMessages(text));
this.setStatus("Text typed", "Text box content was injected directly into Android without using the clipboard.");
Expand All @@ -640,23 +655,31 @@ export class DroidWebDisplayController {
private async pasteText(text: string, source: string): Promise<void> {
const session = this.#protocolSession;
const maximum = Math.max(1, Math.min(256, Number(this.elements.clipboardMaxKib.value) || 256)) * 1024;
if (new TextEncoder().encode(text).byteLength > maximum) throw new Error(`Clipboard text exceeds the configured ${maximum / 1024} KiB limit`);
const bytes = new TextEncoder().encode(text).byteLength;
if (bytes > maximum) throw new Error(`Clipboard text exceeds the configured ${maximum / 1024} KiB limit`);
if (!session) return;
const inject = bytes <= MAX_INJECTED_BYTES;
const sequence = this.#clipboardSequence++;
this.setStatus("Pasting", `Synchronizing ${source} and injecting it into the focused Android input field…`);
this.setStatus("Pasting", inject
? `Synchronizing ${source} and injecting it into the focused Android input field…`
: `Synchronizing ${source} with the Android clipboard…`);
const acknowledgement = this.waitForClipboardAcknowledgement(sequence);
try {
// SetClipboard(paste=true) only proves that Android processed the clipboard
// message; its ACK does not prove KEYCODE_PASTE inserted anything. Keep the
// clipboard synchronized with paste=false, then use scrcpy InjectText as the
// deterministic insertion path (the same strategy as scrcpy legacy paste).
await session.sendControl(clipboardMessage(text, sequence, false));
await this.sendMessages(textInjectionMessages(text));
if (inject) await this.sendMessages(textInjectionMessages(text));
if (await acknowledgement) {
this.#lastSentClipboard = text;
this.setStatus("Text pasted", `${source} was injected directly and the Android clipboard synchronization was acknowledged.`);
this.setStatus(inject ? "Text pasted" : "Clipboard synchronized", inject
? `${source} was injected directly and the Android clipboard synchronization was acknowledged.`
: `${source} is on the Android clipboard. It is too large to type, so paste it on the device.`);
} else {
this.setStatus("Text sent", `${source} was injected directly, but Android clipboard synchronization was not acknowledged.`);
this.setStatus("Text sent", inject
? `${source} was injected directly, but Android clipboard synchronization was not acknowledged.`
: `${source} was sent to the Android clipboard but not acknowledged. If it arrived, paste it on the device.`);
}
} catch (error) {
this.resolveClipboardAcknowledgement(sequence, false);
Expand Down Expand Up @@ -864,9 +887,12 @@ export class DroidWebDisplayController {
}

private resetClipboardSessionState(): void {
// Cached device clipboard state must not leak across sessions. The visible
// text box is user input, not cached state, so it is deliberately left
// alone: clearing it discards text typed while disconnected.
this.#lastAndroidClipboard = "";
this.#lastSentClipboard = "";
this.elements.clipboardText.value = "";
this.#unacknowledgedSync = null;
this.completeAndroidCopyRequest();
}

Expand Down Expand Up @@ -971,9 +997,18 @@ export class DroidWebDisplayController {
await session.sendControl(clipboardMessage(text, sequence, false));
if (await acknowledgement) {
this.#lastSentClipboard = text;
this.#unacknowledgedSync = null;
this.setStatus("Clipboard synchronized", "PC clipboard was acknowledged by Android without pasting into the focused field.");
} else {
this.setStatus("Clipboard sync not confirmed", "PC clipboard update was sent, but Android did not acknowledge it.");
const attempts = this.#unacknowledgedSync?.text === text ? this.#unacknowledgedSync.attempts + 1 : 1;
this.#unacknowledgedSync = { text, attempts };
if (attempts >= MAX_UNACKNOWLEDGED_SYNC_ATTEMPTS) {
// Record it as sent so the poller stops retrying this text.
this.#lastSentClipboard = text;
this.setStatus("Clipboard sync gave up", `Android did not acknowledge the PC clipboard after ${attempts} attempts. Copy again or use Paste to retry.`);
} else {
this.setStatus("Clipboard sync not confirmed", "PC clipboard update was sent, but Android did not acknowledge it. It will be retried.");
}
}
} catch (error) {
this.resolveClipboardAcknowledgement(sequence, false);
Expand Down
8 changes: 2 additions & 6 deletions droid_web_display/adb/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,11 @@
import os
import re
import shutil
import subprocess
from dataclasses import dataclass
from pathlib import Path
from typing import Mapping, Protocol, Sequence

from droid_web_display.process_utils import subprocess_creation_kwargs
from droid_web_display.errors import AdbCommandError, AdbUnavailableError
from droid_web_display.models import AndroidDevice

Expand All @@ -34,11 +34,7 @@ async def _terminate(process: asyncio.subprocess.Process) -> None:

def _subprocess_creation_kwargs(platform_name: str | None = None) -> dict[str, int]:
"""Return platform-specific flags for invisible background child processes."""
if (platform_name or os.name) != "nt":
return {}
# CREATE_NO_WINDOW is 0x08000000. Keep the literal fallback so tests and
# alternate Python runtimes can still validate the Windows launch contract.
return {"creationflags": getattr(subprocess, "CREATE_NO_WINDOW", 0x08000000)}
return subprocess_creation_kwargs(platform_name)


@dataclass(frozen=True)
Expand Down
4 changes: 2 additions & 2 deletions droid_web_display/desktop/controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@

import psutil

from droid_web_display.process_utils import subprocess_creation_kwargs
from droid_web_display.adb.devices import parse_adb_devices
from droid_web_display.network_access import LAN_HTTPS, NetworkConfigStore

Expand Down Expand Up @@ -250,8 +251,7 @@ def _device_summary(self) -> str:
"timeout": 1.25,
"check": False,
}
if os.name == "nt":
kwargs["creationflags"] = getattr(subprocess, "CREATE_NO_WINDOW", 0)
kwargs.update(subprocess_creation_kwargs())
try:
result = subprocess.run(command, **kwargs)
except (OSError, subprocess.SubprocessError):
Expand Down
4 changes: 2 additions & 2 deletions droid_web_display/desktop/support.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
import zipfile

from droid_web_display import __version__
from droid_web_display.process_utils import subprocess_creation_kwargs
from droid_web_display.desktop.controller import DesktopPaths, ServerSnapshot
from droid_web_display.diagnostics import redact_text

Expand Down Expand Up @@ -125,8 +126,7 @@ def _run_version(command: list[str]) -> str:
"timeout": 2.0,
"check": False,
}
if os.name == "nt":
kwargs["creationflags"] = getattr(subprocess, "CREATE_NO_WINDOW", 0)
kwargs.update(subprocess_creation_kwargs())
try:
result = subprocess.run(command, **kwargs)
except (OSError, subprocess.SubprocessError):
Expand Down
13 changes: 12 additions & 1 deletion droid_web_display/network_access.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
import socket
import ssl
import subprocess

from droid_web_display.process_utils import subprocess_creation_kwargs
import tempfile
from typing import Any, Iterable

Expand Down Expand Up @@ -449,7 +451,16 @@ def apply(self, config: NetworkAccessConfig, *, remove: bool = False) -> dict[st
if os.name != "nt":
return {"applied": False, "reason": "Windows-only", "command": argv}
try:
result = subprocess.run(argv, capture_output=True, text=True, timeout=30, check=False)
# Windows-only path: without the creation flags this pops a visible
# PowerShell window every time LAN access is applied or removed.
result = subprocess.run(
argv,
capture_output=True,
text=True,
timeout=30,
check=False,
**subprocess_creation_kwargs(),
)
except OSError as exc:
return {"applied": False, "reason": str(exc), "command": argv}
return {
Expand Down
25 changes: 25 additions & 0 deletions droid_web_display/process_utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
"""Shared subprocess launch policy.

Every child process this app spawns is a background helper the user never
interacts with. On Windows each one would otherwise pop a console window, so
the CREATE_NO_WINDOW flag has to be applied consistently. It previously lived
inline in three modules with two different fallbacks, and the firewall call
had none at all, so it is centralised here.
"""

from __future__ import annotations

import os
import subprocess

# subprocess.CREATE_NO_WINDOW exists on Windows CPython 3.7+. The literal
# keeps the contract testable from non-Windows runtimes, where the attribute
# is absent.
CREATE_NO_WINDOW = getattr(subprocess, "CREATE_NO_WINDOW", 0x08000000)


def subprocess_creation_kwargs(platform_name: str | None = None) -> dict[str, int]:
"""Return platform-specific flags for invisible background child processes."""
if (platform_name or os.name) != "nt":
return {}
return {"creationflags": CREATE_NO_WINDOW}
Loading
Loading