diff --git a/app/src/App.svelte b/app/src/App.svelte index a154256..6fb4710 100644 --- a/app/src/App.svelte +++ b/app/src/App.svelte @@ -7,12 +7,13 @@ import Controls from "./lib/Controls.svelte"; import Preview from "./lib/Preview.svelte"; import Settings from "./lib/Settings.svelte"; + import Phones from "./lib/Phones.svelte"; import Onboarding from "./lib/Onboarding.svelte"; import UpdateDialog from "./lib/UpdateDialog.svelte"; import { releasesSince, type Release } from "./lib/releaseNotes"; let snap = $state(null); - let screen = $state<"main" | "settings" | "wizard">("main"); + let screen = $state<"main" | "settings" | "phones" | "wizard">("main"); let update = $state({ phase: "idle", version: null, notes: null, progress: null, error: null, portable: false }); let dialog = $state<{ mode: "update" } | { mode: "whatsNew"; releases: Release[] } | null>(null); @@ -59,7 +60,9 @@ function waitingStage(s: Snapshot) { const kind = s.status?.kind; - if (s.device?.state === "device" && kind !== "error" && kind !== "noPicture") return { title: t("preview.waiting"), hint: "" }; + if (s.device?.state === "device" && kind !== "error" && kind !== "noPicture" && kind !== "reconnecting") { + return { title: t("preview.waiting"), hint: "" }; + } const info = statusInfo(s); return { title: info.title, hint: info.hint }; } @@ -89,8 +92,15 @@ onwizard={() => (screen = "wizard")} onupdate={() => (dialog = { mode: "update" })} onwhatsnew={showWhatsNew} /> + {:else if screen === "phones"} + (screen = "main")} /> {:else} - (screen = "settings")} onupdate={() => (dialog = { mode: "update" })} /> + (screen = "settings")} + onphones={() => (screen = "phones")} + onupdate={() => (dialog = { mode: "update" })} /> {/if} diff --git a/app/src/lib/Controls.svelte b/app/src/lib/Controls.svelte index f8fe572..dee0878 100644 --- a/app/src/lib/Controls.svelte +++ b/app/src/lib/Controls.svelte @@ -18,6 +18,7 @@ Download, ShieldCheck, LoaderCircle, + ChevronRight, } from "@lucide/svelte"; import * as api from "./api"; import type { Snapshot, CameraView, UpdateView } from "./api"; @@ -33,8 +34,9 @@ snap, update, onsettings, + onphones, onupdate, - }: { snap: Snapshot; update: UpdateView; onsettings: () => void; onupdate: () => void } = $props(); + }: { snap: Snapshot; update: UpdateView; onsettings: () => void; onphones: () => void; onupdate: () => void } = $props(); let busy = $state<"starting" | "stopping" | null>(null); let adding = $state(false); @@ -109,17 +111,21 @@
-
-
- {snap.device?.name ?? snap.device?.model ?? "Plugcam"} -
- {#if snap.device} -
- {#if snap.device.wifi}{:else}{/if} - {t(snap.device.wifi ? "conn.wifi" : "conn.usb")} -
- {/if} -
+ {#if update.version} + {/if}
@@ -319,6 +328,49 @@ .phone { flex: 1; min-width: 0; + display: flex; + align-items: center; + gap: 4px; + margin: -4px 0 -4px -6px; + padding: 4px 6px; + border: 0; + border-radius: 6px; + background: none; + color: var(--text); + font: inherit; + text-align: start; + cursor: pointer; + transition-property: background-color; + transition-duration: 150ms; + } + .phone:hover { + background: var(--control-hover); + } + .phone > :global(svg) { + color: var(--text-2); + } + .phone-text { + flex: 1; + display: flex; + flex-direction: column; + min-width: 0; + } + .wifi-link { + display: inline-flex; + align-items: center; + gap: 4px; + align-self: flex-start; + margin-top: 2px; + padding: 0; + border: 0; + background: none; + color: var(--accent-text); + font: inherit; + font-size: 12px; + cursor: pointer; + } + .wifi-link:hover { + text-decoration: underline; } .name { font-size: 16px; diff --git a/app/src/lib/Phones.svelte b/app/src/lib/Phones.svelte new file mode 100644 index 0000000..90595b8 --- /dev/null +++ b/app/src/lib/Phones.svelte @@ -0,0 +1,468 @@ + + +
+
+ +

{t("phones.title")}

+
+ + {#if snap.devices.length > 0 || offline.length > 0} +
+

{t("phones.list")}

+
+ {#each snap.devices as d (d.serial)} + {@const wifiPhone = remembered.get(d.serial)} +
+ + {#if wifiPhone} + + {/if} +
+ {/each} + {#each offline as p (p.id)} +
+
+ + + {p.name ?? p.id} + {t(p.plain ? "phones.offline.plain" : "phones.offline")} + +
+ +
+ {/each} +
+ {#if snap.devices.filter((d) => d.state === "device").length > 1} + + {/if} + {#if snap.wifiPhones.length > 0}

{t("phones.forget.note")}

{/if} +
+ {/if} + +
+

{t("wifi.add")}

+ + +
+ {#if method === "qr"} +

{t("wifi.qr.steps")}

+ {#if qrSvg && !qrExpired} + +

{t("wifi.qr.waiting")}

+ {:else if qrExpired} + {#if !error && !done}

{t("wifi.err.qrTimeout")}

{/if} + + {/if} + {:else if method === "code"} +

{t("wifi.code.steps")}

+
{ + e.preventDefault(); + run(() => api.wifiPairCode(code, pairAddress || null)); + }}> + + + +
+ {:else} +

{t("wifi.cable.steps")}

+

{t("wifi.cable.warning")}

+ + {#if !usbPhone}

{t("wifi.err.noUsbPhone")}

{/if} + {/if} + + {#if error} + + {:else if done} +

{done}

+ {/if} +
+
+ +
+ {t("wifi.address.title")} +
{ + e.preventDefault(); + run(() => api.wifiConnect(address)); + }}> +

{t("wifi.address.hint")}

+ + +
+
+ +
+

{t("wifi.tips")}

+
    +
  • {t("wifi.tip.network")}
  • +
  • {t("wifi.tip.always")}
  • +
  • {t("wifi.tip.guest")}
  • +
  • {t("wifi.tip.hotspot")}
  • +
  • {t("wifi.tip.vpn")}
  • +
  • {t("wifi.tip.firewall")}
  • +
+
+
+ + diff --git a/app/src/lib/api.ts b/app/src/lib/api.ts index 24032db..e14c58c 100644 --- a/app/src/lib/api.ts +++ b/app/src/lib/api.ts @@ -26,6 +26,8 @@ export interface Settings { checkUpdates: boolean; /** The version whose "What's new" was last shown; `null` on a fresh install. */ lastSeenVersion: string | null; + /** The picked phone's serial; `null` = the first ready one, USB first. */ + phone: string | null; /** Write a detailed log to a file for bug reports. */ detailedLog: boolean; } @@ -42,6 +44,7 @@ export type Status = | { kind: "waitingForDevice" } | { kind: "connecting"; serial: string } | { kind: "streaming"; serial: string; device: string; width: number; height: number } + | { kind: "reconnecting"; serial: string; attempt: number; tries: number } | { kind: "noPicture"; serial: string } | { kind: "error"; message: string } | { kind: "stopped" }; @@ -54,6 +57,16 @@ export interface DeviceView { state: string; } +/** A phone remembered for Wi-Fi. */ +export interface WifiPhoneView { + id: string; + name: string | null; + /** The serial it is connected under, when it is connected. */ + serial: string | null; + /** Switched over from the cable: not encrypted, gone after the phone restarts. */ + plain: boolean; +} + export interface CameraView { id: string; facing: string; @@ -95,6 +108,11 @@ export interface Snapshot { highSpeedOffered: boolean; torch: boolean; fps: number; + /** Bits per second the phone streams at; 0 when not streaming. */ + bitrate: number; + /** Every phone adb lists. */ + devices: DeviceView[]; + wifiPhones: WifiPhoneView[]; /** As the phone last reported it. */ zoom: number; zoomRange: [number, number] | null; @@ -123,6 +141,16 @@ export const setPreviewActive = (active: boolean) => invoke("set_preview_a /** How wide the picture is shown, in physical pixels, so no wider JPEGs are made. */ export const setPreviewWidth = (width: number) => invoke("set_preview_width", { width }); export const setCameraRegistered = (on: boolean) => invoke("set_camera_registered", { on }); +export const selectPhone = (serial: string | null) => invoke("select_phone", { serial }); +/** A new QR code for pairing over Wi-Fi, as SVG. */ +export const wifiQrStart = () => invoke("wifi_qr_start"); +/** Resolves with the phone's serial once it scanned the code and paired; rejects with an error code. */ +export const wifiQrWait = () => invoke("wifi_qr_wait"); +export const wifiQrCancel = () => invoke("wifi_qr_cancel"); +export const wifiPairCode = (code: string, address: string | null) => invoke("wifi_pair_code", { code, address }); +export const wifiConnect = (address: string) => invoke("wifi_connect", { address }); +export const wifiFromCable = () => invoke("wifi_from_cable"); +export const wifiForget = (id: string) => invoke("wifi_forget", { id }); /** Saves a bug report to Downloads and opens the new-issue form; returns the file's path. */ export const saveReport = () => invoke("save_report"); export const openLogFolder = () => invoke("open_log_folder"); diff --git a/app/src/lib/i18n.ts b/app/src/lib/i18n.ts index ff3fb97..73c5793 100644 --- a/app/src/lib/i18n.ts +++ b/app/src/lib/i18n.ts @@ -40,6 +40,11 @@ export function setLanguage(code: string) { document.documentElement.lang = lang.code; } +/** For keys built at run time, e.g. from an error code. */ +export function hasKey(key: string): key is Key { + return key in en; +} + export function t(key: Key, params: Record = {}): string { return dict[key].replace(/\{(\w+)\}/g, (_, k) => String(params[k] ?? "")); } diff --git a/app/src/lib/locales/de.ts b/app/src/lib/locales/de.ts index 93ed7c6..67dea6f 100644 --- a/app/src/lib/locales/de.ts +++ b/app/src/lib/locales/de.ts @@ -4,7 +4,7 @@ const de: Messages = { "app.settings": "Einstellungen", "app.back": "Zurück", "status.noPhone": "Smartphone anschließen", - "status.noPhone.hint": "Per USB-Kabel, mit aktiviertem USB-Debugging", + "status.noPhone.hint": "Per USB-Kabel mit aktiviertem USB-Debugging oder über WLAN", "status.unauthorized": "Debugging auf dem Smartphone erlauben", "status.unauthorized.hint": "Tippe auf dem Smartphone auf „Zulassen“ und setze „Immer zulassen“", "status.offline": "Das Smartphone antwortet nicht", @@ -12,6 +12,8 @@ const de: Messages = { "status.ready": "Bereit", "status.waiting": "Warte auf das Smartphone", "status.connecting": "Verbinde…", + "status.reconnecting": "Verbinde erneut über WLAN ({attempt} von {tries})", + "status.reconnecting.hint": "Apps zeigen das letzte Bild, bis das Smartphone wieder da ist", "status.streaming": "Überträgt", "status.noPicture": "Dieses Objektiv liefert kein Bild", "status.error": "Fehler", @@ -142,6 +144,52 @@ const de: Messages = { "wiz.3.found": "{name} ist verbunden", "wiz.3.driver": "Smartphone wird nicht gefunden? USB-Treiber installieren", "wiz.why": "Auf dem Smartphone muss keine App installiert werden: Plugcam arbeitet über USB-Debugging.", + "phones.title": "Smartphones und WLAN", + "phones.connectWifi": "Über WLAN verbinden", + "phones.list": "Smartphones", + "phones.auto": "Automatisch wählen, USB zuerst", + "phones.usb": "USB-Kabel", + "phones.wifi": "WLAN", + "phones.wifi.plain": "WLAN, unverschlüsselt", + "phones.offline": "Nicht im Netzwerk. Plugcam verbindet es, sobald es auftaucht.", + "phones.offline.plain": "Nicht verbunden. Nach einem Neustart braucht das Smartphone wieder das Kabel.", + "phones.forget": "Vergessen", + "phones.forget.note": "„Vergessen“ hebt die Kopplung nicht auf. Um das Smartphone ganz zu entfernen, lösche diesen Computer auf dem Smartphone unter „Kabelloses Debugging“ → „Gekoppelte Geräte“.", + "wifi.add": "Über WLAN verbinden", + "wifi.method.qr": "QR-Code", + "wifi.method.code": "Kopplungscode", + "wifi.method.cable": "Über Kabel", + "wifi.qr.steps": "Öffne auf dem Smartphone Einstellungen → Entwickleroptionen → Kabelloses Debugging, schalte es ein und tippe auf „Gerät über QR-Code koppeln“. Scanne dann diesen Code.", + "wifi.qr.waiting": "Warte, bis das Smartphone den Code scannt…", + "wifi.qr.new": "Neuer Code", + "wifi.code.steps": "Öffne auf dem Smartphone Einstellungen → Entwickleroptionen → Kabelloses Debugging und tippe auf „Gerät mit Kopplungscode koppeln“. Gib die sechs angezeigten Ziffern ein.", + "wifi.code.label": "Kopplungscode", + "wifi.code.address": "IP-Adresse und Port", + "wifi.code.address.hint": "Nur wenn Plugcam das Smartphone nicht selbst findet: die Adresse unter dem Code", + "wifi.pair": "Koppeln", + "wifi.pairing": "Wird gekoppelt…", + "wifi.cable.steps": "Das Smartphone hängt am Kabel und ist im selben WLAN wie dieser Computer. Plugcam stellt es auf WLAN um, danach kannst du das Kabel abziehen.", + "wifi.cable.warning": "Diese Verbindung ist nicht verschlüsselt und endet, wenn das Smartphone neu startet. Nutze sie nur in deinem eigenen Netzwerk.", + "wifi.cable.go": "Zu WLAN wechseln", + "wifi.address.title": "Per Adresse verbinden", + "wifi.address.hint": "Für ein gekoppeltes Smartphone, das die Suche nicht findet: IP-Adresse und Port oben in „Kabelloses Debugging“.", + "wifi.connect": "Verbinden", + "wifi.done": "{name} ist über WLAN verbunden", + "wifi.tips": "Wenn das Smartphone nicht gefunden wird", + "wifi.tip.network": "Smartphone und Computer müssen im selben WLAN sein. Mit 5 GHz läuft das Bild flüssiger.", + "wifi.tip.always": "Wenn das Smartphone fragt, ob kabelloses Debugging erlaubt werden soll, setze „In diesem Netzwerk immer zulassen“.", + "wifi.tip.guest": "Gastnetzwerke und Netzwerke, die Geräte voneinander trennen, verbergen das Smartphone. Verbinde per Adresse oder nutze das Kabel.", + "wifi.tip.hotspot": "Es funktioniert nicht, wenn der Computer das Internet über den Hotspot des Smartphones bezieht.", + "wifi.tip.vpn": "Ein VPN auf dem Smartphone versteckt es und lässt es eine falsche Adresse anzeigen. Nutze die WLAN-IP des Smartphones (Einstellungen → Über das Telefon → Status → IP-Adresse) mit dem Port aus „Kabelloses Debugging“ oder schalte das VPN beim Koppeln aus.", + "wifi.tip.firewall": "Beim ersten Mal fragt Windows nach adb.exe: Erlaube es in privaten Netzwerken.", + "wifi.err.badCode": "Der Kopplungscode hat sechs Ziffern", + "wifi.err.noPairingPhone": "Smartphone nicht gefunden. Lass den Kopplungscode auf dem Display oder gib die IP-Adresse und den Port ein, die dabei stehen.", + "wifi.err.qrTimeout": "Der Code ist abgelaufen", + "wifi.err.pairedNotFound": "Gekoppelt, aber das Smartphone hat sich nicht im Netzwerk gemeldet (das kann an einem VPN auf dem Smartphone liegen). Gib den Port ein, der auf dem Smartphone unter „IP-Adresse & Port“ steht.", + "wifi.err.badAddress": "Gib die Adresse als IP:Port ein, zum Beispiel 192.168.1.20:37215", + "wifi.err.noUsbPhone": "Schließe das Smartphone zuerst per Kabel an", + "wifi.err.phoneNotOnWifi": "Das Smartphone ist nicht im WLAN. Schalte WLAN auf dem Smartphone ein und versuche es erneut.", + "wifi.err.failed": "Verbindung fehlgeschlagen: {message}", }; export default de; diff --git a/app/src/lib/locales/en.ts b/app/src/lib/locales/en.ts index 79a6345..47a0aec 100644 --- a/app/src/lib/locales/en.ts +++ b/app/src/lib/locales/en.ts @@ -4,7 +4,7 @@ const en = { "app.settings": "Settings", "app.back": "Back", "status.noPhone": "Connect your phone", - "status.noPhone.hint": "With a USB cable, USB debugging turned on", + "status.noPhone.hint": "With a USB cable and USB debugging on, or over Wi-Fi", "status.unauthorized": "Allow debugging on the phone", "status.unauthorized.hint": "Tap “Allow” on the phone and tick “Always allow”", "status.offline": "The phone is not responding", @@ -12,6 +12,8 @@ const en = { "status.ready": "Ready", "status.waiting": "Waiting for the phone", "status.connecting": "Connecting…", + "status.reconnecting": "Reconnecting over Wi-Fi ({attempt} of {tries})", + "status.reconnecting.hint": "Apps keep the last picture until the phone is back", "status.streaming": "Streaming", "status.noPicture": "This lens gives no picture", "status.error": "Error", @@ -142,6 +144,52 @@ const en = { "wiz.3.found": "{name} is connected", "wiz.3.driver": "Phone not found? Install the USB driver", "wiz.why": "No app to install on the phone: Plugcam works through USB debugging.", + "phones.title": "Phones and Wi-Fi", + "phones.connectWifi": "Connect over Wi-Fi", + "phones.list": "Phones", + "phones.auto": "Pick automatically, USB first", + "phones.usb": "USB cable", + "phones.wifi": "Wi-Fi", + "phones.wifi.plain": "Wi-Fi, not encrypted", + "phones.offline": "Not on the network. Plugcam connects it when it shows up.", + "phones.offline.plain": "Not connected. After the phone restarts it needs the cable again.", + "phones.forget": "Forget", + "phones.forget.note": "Forget does not unpair the phone. To remove it completely, delete this computer on the phone under “Wireless debugging” → “Paired devices”.", + "wifi.add": "Connect over Wi-Fi", + "wifi.method.qr": "QR code", + "wifi.method.code": "Pairing code", + "wifi.method.cable": "From cable", + "wifi.qr.steps": "On the phone open Settings → Developer options → Wireless debugging, turn it on and tap “Pair device with QR code”. Then scan this code.", + "wifi.qr.waiting": "Waiting for the phone to scan the code…", + "wifi.qr.new": "New code", + "wifi.code.steps": "On the phone open Settings → Developer options → Wireless debugging and tap “Pair device with pairing code”. Enter the six digits it shows.", + "wifi.code.label": "Pairing code", + "wifi.code.address": "IP address and port", + "wifi.code.address.hint": "Only if Plugcam does not find the phone by itself: the address shown under the code", + "wifi.pair": "Pair", + "wifi.pairing": "Pairing…", + "wifi.cable.steps": "The phone is on the cable and on the same Wi-Fi as this computer. Plugcam moves it to Wi-Fi, then you can unplug the cable.", + "wifi.cable.warning": "This connection is not encrypted and ends when the phone restarts. Use it only on your own network.", + "wifi.cable.go": "Switch to Wi-Fi", + "wifi.address.title": "Connect by address", + "wifi.address.hint": "For a paired phone that the search cannot see: the IP address and port at the top of “Wireless debugging”.", + "wifi.connect": "Connect", + "wifi.done": "{name} is connected over Wi-Fi", + "wifi.tips": "If the phone is not found", + "wifi.tip.network": "The phone and this computer must be on the same Wi-Fi. 5 GHz gives a smoother picture.", + "wifi.tip.always": "When the phone asks to allow wireless debugging, tick “Always allow on this network”.", + "wifi.tip.guest": "Guest networks and networks that keep devices apart hide the phone. Connect by address or use the cable.", + "wifi.tip.hotspot": "It does not work when the computer gets internet from the phone’s hotspot.", + "wifi.tip.vpn": "A VPN on the phone hides it and makes it show a wrong address. Use the phone’s Wi-Fi IP (Settings → About phone → Status → IP address) with the port from “Wireless debugging”, or turn the VPN off while pairing.", + "wifi.tip.firewall": "The first time, Windows asks about adb.exe: allow it on private networks.", + "wifi.err.badCode": "The pairing code has six digits", + "wifi.err.noPairingPhone": "The phone was not found. Keep the pairing code on its screen, or enter the IP address and port shown with it.", + "wifi.err.qrTimeout": "The code has expired", + "wifi.err.pairedNotFound": "Paired, but the phone did not announce itself on the network (a VPN on the phone can cause this). Enter the port shown under “IP address & Port” on the phone.", + "wifi.err.badAddress": "Enter the address as IP:port, for example 192.168.1.20:37215", + "wifi.err.noUsbPhone": "Connect the phone by cable first", + "wifi.err.phoneNotOnWifi": "The phone is not on Wi-Fi. Turn Wi-Fi on on the phone and try again.", + "wifi.err.failed": "Could not connect: {message}", }; export type Key = keyof typeof en; diff --git a/app/src/lib/locales/es.ts b/app/src/lib/locales/es.ts index 7e19d72..d54c85c 100644 --- a/app/src/lib/locales/es.ts +++ b/app/src/lib/locales/es.ts @@ -4,7 +4,7 @@ const es: Messages = { "app.settings": "Ajustes", "app.back": "Atrás", "status.noPhone": "Conecta tu teléfono", - "status.noPhone.hint": "Con un cable USB y la depuración USB activada", + "status.noPhone.hint": "Con un cable USB y la depuración USB activada, o por Wi-Fi", "status.unauthorized": "Permite la depuración en el teléfono", "status.unauthorized.hint": "En el teléfono, toca «Permitir» y marca «Permitir siempre»", "status.offline": "El teléfono no responde", @@ -12,6 +12,8 @@ const es: Messages = { "status.ready": "Listo", "status.waiting": "Esperando al teléfono", "status.connecting": "Conectando…", + "status.reconnecting": "Reconectando por Wi-Fi ({attempt} de {tries})", + "status.reconnecting.hint": "Las apps mantienen la última imagen hasta que vuelva el teléfono", "status.streaming": "Transmitiendo", "status.noPicture": "Esta lente no da imagen", "status.error": "Error", @@ -142,6 +144,52 @@ const es: Messages = { "wiz.3.found": "{name} está conectado", "wiz.3.driver": "¿No encuentra el teléfono? Instala el controlador USB", "wiz.why": "No hay que instalar ninguna app en el teléfono: Plugcam funciona mediante la depuración USB.", + "phones.title": "Teléfonos y Wi-Fi", + "phones.connectWifi": "Conectar por Wi-Fi", + "phones.list": "Teléfonos", + "phones.auto": "Elegir automáticamente, primero USB", + "phones.usb": "Cable USB", + "phones.wifi": "Wi-Fi", + "phones.wifi.plain": "Wi-Fi, sin cifrar", + "phones.offline": "No está en la red. Plugcam lo conecta cuando aparezca.", + "phones.offline.plain": "No conectado. Tras reiniciarse, el teléfono vuelve a necesitar el cable.", + "phones.forget": "Olvidar", + "phones.forget.note": "«Olvidar» no desvincula el teléfono. Para quitarlo del todo, elimina este ordenador en el teléfono, en «Depuración inalámbrica» → «Dispositivos vinculados».", + "wifi.add": "Conectar por Wi-Fi", + "wifi.method.qr": "Código QR", + "wifi.method.code": "Código de sincronización", + "wifi.method.cable": "Desde el cable", + "wifi.qr.steps": "En el teléfono abre Ajustes → Opciones para desarrolladores → Depuración inalámbrica, actívala y toca «Vincular dispositivo con código QR». Luego escanea este código.", + "wifi.qr.waiting": "Esperando a que el teléfono escanee el código…", + "wifi.qr.new": "Código nuevo", + "wifi.code.steps": "En el teléfono abre Ajustes → Opciones para desarrolladores → Depuración inalámbrica y toca «Vincular dispositivo con código de sincronización». Introduce los seis dígitos que aparecen.", + "wifi.code.label": "Código de sincronización", + "wifi.code.address": "Dirección IP y puerto", + "wifi.code.address.hint": "Solo si Plugcam no encuentra el teléfono por sí solo: la dirección que aparece bajo el código", + "wifi.pair": "Vincular", + "wifi.pairing": "Vinculando…", + "wifi.cable.steps": "El teléfono está conectado por cable y a la misma Wi-Fi que este ordenador. Plugcam lo pasa a Wi-Fi y después puedes desconectar el cable.", + "wifi.cable.warning": "Esta conexión no está cifrada y termina cuando el teléfono se reinicia. Úsala solo en tu propia red.", + "wifi.cable.go": "Pasar a Wi-Fi", + "wifi.address.title": "Conectar por dirección", + "wifi.address.hint": "Para un teléfono vinculado que la búsqueda no ve: la dirección IP y el puerto que aparecen arriba en «Depuración inalámbrica».", + "wifi.connect": "Conectar", + "wifi.done": "{name} está conectado por Wi-Fi", + "wifi.tips": "Si no se encuentra el teléfono", + "wifi.tip.network": "El teléfono y este ordenador deben estar en la misma Wi-Fi. Con 5 GHz la imagen es más fluida.", + "wifi.tip.always": "Cuando el teléfono pida permitir la depuración inalámbrica, marca «Permitir siempre en esta red».", + "wifi.tip.guest": "Las redes de invitados y las que aíslan los dispositivos ocultan el teléfono. Conecta por dirección o usa el cable.", + "wifi.tip.hotspot": "No funciona si el ordenador se conecta a internet mediante el punto de acceso del teléfono.", + "wifi.tip.vpn": "Una VPN en el teléfono lo oculta y hace que muestre una dirección incorrecta. Usa la IP Wi-Fi del teléfono (Ajustes → Información del teléfono → Estado → Dirección IP) con el puerto de «Depuración inalámbrica», o desactiva la VPN mientras lo vinculas.", + "wifi.tip.firewall": "La primera vez, Windows pregunta por adb.exe: permítelo en redes privadas.", + "wifi.err.badCode": "El código de sincronización tiene seis dígitos", + "wifi.err.noPairingPhone": "No se encontró el teléfono. Deja el código de sincronización en su pantalla o introduce la dirección IP y el puerto que aparecen con él.", + "wifi.err.qrTimeout": "El código ha caducado", + "wifi.err.pairedNotFound": "Vinculado, pero el teléfono no se anunció en la red (puede deberse a una VPN en el teléfono). Escribe el puerto que aparece en «Dirección IP y puerto» en el teléfono.", + "wifi.err.badAddress": "Introduce la dirección como IP:puerto, por ejemplo 192.168.1.20:37215", + "wifi.err.noUsbPhone": "Primero conecta el teléfono por cable", + "wifi.err.phoneNotOnWifi": "El teléfono no está conectado a Wi-Fi. Activa el Wi-Fi en el teléfono y vuelve a intentarlo.", + "wifi.err.failed": "No se pudo conectar: {message}", }; export default es; diff --git a/app/src/lib/locales/fr.ts b/app/src/lib/locales/fr.ts index 136d4b3..0259ed1 100644 --- a/app/src/lib/locales/fr.ts +++ b/app/src/lib/locales/fr.ts @@ -4,7 +4,7 @@ const fr: Messages = { "app.settings": "Paramètres", "app.back": "Retour", "status.noPhone": "Branchez votre téléphone", - "status.noPhone.hint": "Avec un câble USB, débogage USB activé", + "status.noPhone.hint": "Avec un câble USB, débogage USB activé, ou en Wi-Fi", "status.unauthorized": "Autorisez le débogage sur le téléphone", "status.unauthorized.hint": "Sur le téléphone, appuyez sur « Autoriser » et cochez « Toujours autoriser »", "status.offline": "Le téléphone ne répond pas", @@ -12,6 +12,8 @@ const fr: Messages = { "status.ready": "Prêt", "status.waiting": "En attente du téléphone", "status.connecting": "Connexion…", + "status.reconnecting": "Reconnexion en Wi-Fi ({attempt} sur {tries})", + "status.reconnecting.hint": "Les applis gardent la dernière image jusqu’au retour du téléphone", "status.streaming": "Diffusion en cours", "status.noPicture": "Cet objectif ne donne pas d’image", "status.error": "Erreur", @@ -142,6 +144,52 @@ const fr: Messages = { "wiz.3.found": "{name} est connecté", "wiz.3.driver": "Téléphone introuvable ? Installez le pilote USB", "wiz.why": "Aucune application à installer sur le téléphone : Plugcam passe par le débogage USB.", + "phones.title": "Téléphones et Wi-Fi", + "phones.connectWifi": "Connecter en Wi-Fi", + "phones.list": "Téléphones", + "phones.auto": "Choisir automatiquement, USB d’abord", + "phones.usb": "Câble USB", + "phones.wifi": "Wi-Fi", + "phones.wifi.plain": "Wi-Fi, non chiffré", + "phones.offline": "Absent du réseau. Plugcam le connecte dès qu’il apparaît.", + "phones.offline.plain": "Non connecté. Après un redémarrage, le téléphone a de nouveau besoin du câble.", + "phones.forget": "Oublier", + "phones.forget.note": "« Oublier » ne dissocie pas le téléphone. Pour le retirer complètement, supprimez cet ordinateur sur le téléphone dans « Débogage sans fil » → « Appareils associés ».", + "wifi.add": "Connecter en Wi-Fi", + "wifi.method.qr": "Code QR", + "wifi.method.code": "Code d’association", + "wifi.method.cable": "Par câble", + "wifi.qr.steps": "Sur le téléphone, ouvrez Paramètres → Options pour les développeurs → Débogage sans fil, activez-le et appuyez sur « Associer l’appareil avec un code QR ». Puis scannez ce code.", + "wifi.qr.waiting": "En attente du scan du code par le téléphone…", + "wifi.qr.new": "Nouveau code", + "wifi.code.steps": "Sur le téléphone, ouvrez Paramètres → Options pour les développeurs → Débogage sans fil et appuyez sur « Associer l’appareil avec un code d’association ». Saisissez les six chiffres affichés.", + "wifi.code.label": "Code d’association", + "wifi.code.address": "Adresse IP et port", + "wifi.code.address.hint": "Seulement si Plugcam ne trouve pas le téléphone tout seul : l’adresse affichée sous le code", + "wifi.pair": "Associer", + "wifi.pairing": "Association…", + "wifi.cable.steps": "Le téléphone est branché par câble et sur le même Wi-Fi que cet ordinateur. Plugcam le passe en Wi-Fi, puis vous pouvez débrancher le câble.", + "wifi.cable.warning": "Cette connexion n’est pas chiffrée et se coupe au redémarrage du téléphone. Utilisez-la uniquement sur votre propre réseau.", + "wifi.cable.go": "Passer en Wi-Fi", + "wifi.address.title": "Connecter par adresse", + "wifi.address.hint": "Pour un téléphone associé que la recherche ne voit pas : l’adresse IP et le port en haut de « Débogage sans fil ».", + "wifi.connect": "Connecter", + "wifi.done": "{name} est connecté en Wi-Fi", + "wifi.tips": "Si le téléphone est introuvable", + "wifi.tip.network": "Le téléphone et cet ordinateur doivent être sur le même Wi-Fi. Le 5 GHz donne une image plus fluide.", + "wifi.tip.always": "Quand le téléphone demande d’autoriser le débogage sans fil, cochez « Toujours autoriser sur ce réseau ».", + "wifi.tip.guest": "Les réseaux invités et ceux qui isolent les appareils cachent le téléphone. Connectez-le par adresse ou utilisez le câble.", + "wifi.tip.hotspot": "Cela ne marche pas quand l’ordinateur accède à Internet via le point d’accès du téléphone.", + "wifi.tip.vpn": "Un VPN sur le téléphone le masque et lui fait afficher une mauvaise adresse. Utilisez l’adresse IP Wi-Fi du téléphone (Paramètres → À propos du téléphone → État → Adresse IP) avec le port de « Débogage sans fil », ou coupez le VPN pendant l’association.", + "wifi.tip.firewall": "La première fois, Windows pose une question sur adb.exe : autorisez-le sur les réseaux privés.", + "wifi.err.badCode": "Le code d’association comporte six chiffres", + "wifi.err.noPairingPhone": "Téléphone introuvable. Laissez le code d’association affiché à l’écran, ou saisissez l’adresse IP et le port indiqués avec lui.", + "wifi.err.qrTimeout": "Le code a expiré", + "wifi.err.pairedNotFound": "Associé, mais le téléphone ne s’est pas annoncé sur le réseau (un VPN sur le téléphone peut en être la cause). Saisissez le port indiqué sous « Adresse IP et port » sur le téléphone.", + "wifi.err.badAddress": "Saisissez l’adresse au format IP:port, par exemple 192.168.1.20:37215", + "wifi.err.noUsbPhone": "Branchez d’abord le téléphone par câble", + "wifi.err.phoneNotOnWifi": "Le téléphone n’est pas en Wi-Fi. Activez le Wi-Fi sur le téléphone et réessayez.", + "wifi.err.failed": "Connexion impossible : {message}", }; export default fr; diff --git a/app/src/lib/locales/it.ts b/app/src/lib/locales/it.ts index cffab1f..5407794 100644 --- a/app/src/lib/locales/it.ts +++ b/app/src/lib/locales/it.ts @@ -4,7 +4,7 @@ const it: Messages = { "app.settings": "Impostazioni", "app.back": "Indietro", "status.noPhone": "Collega il telefono", - "status.noPhone.hint": "Con un cavo USB e il debug USB attivo", + "status.noPhone.hint": "Con un cavo USB e il debug USB attivo, oppure via Wi-Fi", "status.unauthorized": "Consenti il debug sul telefono", "status.unauthorized.hint": "Sul telefono tocca «Consenti» e spunta «Consenti sempre»", "status.offline": "Il telefono non risponde", @@ -12,6 +12,8 @@ const it: Messages = { "status.ready": "Pronto", "status.waiting": "In attesa del telefono", "status.connecting": "Connessione…", + "status.reconnecting": "Riconnessione via Wi-Fi ({attempt} di {tries})", + "status.reconnecting.hint": "Le app mantengono l’ultima immagine finché il telefono non torna", "status.streaming": "In trasmissione", "status.noPicture": "Questo obiettivo non dà immagine", "status.error": "Errore", @@ -142,6 +144,52 @@ const it: Messages = { "wiz.3.found": "{name} è collegato", "wiz.3.driver": "Il telefono non viene trovato? Installa il driver USB", "wiz.why": "Nessuna app da installare sul telefono: Plugcam funziona tramite il debug USB.", + "phones.title": "Telefoni e Wi-Fi", + "phones.connectWifi": "Collega via Wi-Fi", + "phones.list": "Telefoni", + "phones.auto": "Scegli automaticamente, prima USB", + "phones.usb": "Cavo USB", + "phones.wifi": "Wi-Fi", + "phones.wifi.plain": "Wi-Fi, non crittografato", + "phones.offline": "Non è sulla rete. Plugcam lo collega appena compare.", + "phones.offline.plain": "Non collegato. Dopo il riavvio il telefono ha di nuovo bisogno del cavo.", + "phones.forget": "Dimentica", + "phones.forget.note": "«Dimentica» non annulla l’accoppiamento. Per rimuovere del tutto il telefono, elimina questo computer sul telefono in «Debug wireless» → «Dispositivi accoppiati».", + "wifi.add": "Collega via Wi-Fi", + "wifi.method.qr": "Codice QR", + "wifi.method.code": "Codice di accoppiamento", + "wifi.method.cable": "Dal cavo", + "wifi.qr.steps": "Sul telefono apri Impostazioni → Opzioni sviluppatore → Debug wireless, attivalo e tocca «Accoppia dispositivo con codice QR». Poi inquadra questo codice.", + "wifi.qr.waiting": "In attesa che il telefono inquadri il codice…", + "wifi.qr.new": "Nuovo codice", + "wifi.code.steps": "Sul telefono apri Impostazioni → Opzioni sviluppatore → Debug wireless e tocca «Accoppia dispositivo con codice di accoppiamento». Inserisci le sei cifre mostrate.", + "wifi.code.label": "Codice di accoppiamento", + "wifi.code.address": "Indirizzo IP e porta", + "wifi.code.address.hint": "Solo se Plugcam non trova il telefono da solo: l’indirizzo mostrato sotto il codice", + "wifi.pair": "Accoppia", + "wifi.pairing": "Accoppiamento…", + "wifi.cable.steps": "Il telefono è collegato con il cavo e sulla stessa Wi-Fi di questo computer. Plugcam lo passa al Wi-Fi, poi puoi scollegare il cavo.", + "wifi.cable.warning": "Questa connessione non è crittografata e termina al riavvio del telefono. Usala solo sulla tua rete.", + "wifi.cable.go": "Passa al Wi-Fi", + "wifi.address.title": "Collega tramite indirizzo", + "wifi.address.hint": "Per un telefono accoppiato che la ricerca non vede: l’indirizzo IP e la porta in alto in «Debug wireless».", + "wifi.connect": "Collega", + "wifi.done": "{name} è collegato via Wi-Fi", + "wifi.tips": "Se il telefono non viene trovato", + "wifi.tip.network": "Il telefono e questo computer devono essere sulla stessa Wi-Fi. Con i 5 GHz l’immagine è più fluida.", + "wifi.tip.always": "Quando il telefono chiede di consentire il debug wireless, spunta «Consenti sempre su questa rete».", + "wifi.tip.guest": "Le reti ospiti e quelle che isolano i dispositivi nascondono il telefono. Collegalo tramite indirizzo o usa il cavo.", + "wifi.tip.hotspot": "Non funziona se il computer si connette a internet tramite l’hotspot del telefono.", + "wifi.tip.vpn": "Una VPN sul telefono lo nasconde e gli fa mostrare un indirizzo sbagliato. Usa l’IP Wi-Fi del telefono (Impostazioni → Informazioni sul telefono → Stato → Indirizzo IP) con la porta di «Debug wireless», oppure disattiva la VPN durante l’accoppiamento.", + "wifi.tip.firewall": "La prima volta Windows chiede di adb.exe: consentilo sulle reti private.", + "wifi.err.badCode": "Il codice di accoppiamento ha sei cifre", + "wifi.err.noPairingPhone": "Telefono non trovato. Lascia il codice di accoppiamento sullo schermo oppure inserisci l’indirizzo IP e la porta mostrati insieme.", + "wifi.err.qrTimeout": "Il codice è scaduto", + "wifi.err.pairedNotFound": "Accoppiato, ma il telefono non si è annunciato sulla rete (può dipendere da una VPN sul telefono). Inserisci la porta indicata in «Indirizzo IP e porta» sul telefono.", + "wifi.err.badAddress": "Inserisci l’indirizzo come IP:porta, ad esempio 192.168.1.20:37215", + "wifi.err.noUsbPhone": "Prima collega il telefono con il cavo", + "wifi.err.phoneNotOnWifi": "Il telefono non è connesso al Wi-Fi. Attiva il Wi-Fi sul telefono e riprova.", + "wifi.err.failed": "Impossibile collegarsi: {message}", }; export default it; diff --git a/app/src/lib/locales/ja.ts b/app/src/lib/locales/ja.ts index 6b38fe0..333339c 100644 --- a/app/src/lib/locales/ja.ts +++ b/app/src/lib/locales/ja.ts @@ -4,7 +4,7 @@ const ja: Messages = { "app.settings": "設定", "app.back": "戻る", "status.noPhone": "スマートフォンを接続してください", - "status.noPhone.hint": "USB ケーブルで接続し、USB デバッグをオンにしてください", + "status.noPhone.hint": "USB ケーブルで接続して USB デバッグをオンにするか、Wi-Fi で接続してください", "status.unauthorized": "スマートフォンでデバッグを許可してください", "status.unauthorized.hint": "スマートフォンで「許可」をタップし、「常に許可」にチェックを入れてください", "status.offline": "スマートフォンが応答しません", @@ -12,6 +12,8 @@ const ja: Messages = { "status.ready": "準備完了", "status.waiting": "スマートフォンを待っています", "status.connecting": "接続中…", + "status.reconnecting": "Wi-Fi で再接続しています({attempt} / {tries})", + "status.reconnecting.hint": "スマートフォンが戻るまで、アプリには最後の映像が表示されたままになります", "status.streaming": "配信中", "status.noPicture": "このレンズは映像を出しません", "status.error": "エラー", @@ -142,6 +144,52 @@ const ja: Messages = { "wiz.3.found": "{name} が接続されました", "wiz.3.driver": "スマートフォンが見つからない場合は USB ドライバをインストール", "wiz.why": "スマートフォンにアプリを入れる必要はありません。Plugcam は USB デバッグで動作します。", + "phones.title": "スマートフォンと Wi-Fi", + "phones.connectWifi": "Wi-Fi で接続", + "phones.list": "スマートフォン", + "phones.auto": "自動で選択(USB を優先)", + "phones.usb": "USB ケーブル", + "phones.wifi": "Wi-Fi", + "phones.wifi.plain": "Wi-Fi(暗号化なし)", + "phones.offline": "ネットワーク上にありません。見つかると Plugcam が自動で接続します。", + "phones.offline.plain": "接続されていません。スマートフォンを再起動した後は、もう一度ケーブルが必要です。", + "phones.forget": "削除", + "phones.forget.note": "「削除」してもペア設定は解除されません。完全に削除するには、スマートフォンの「ワイヤレス デバッグ」→「ペア設定済みのデバイス」でこのパソコンを削除してください。", + "wifi.add": "Wi-Fi で接続", + "wifi.method.qr": "QR コード", + "wifi.method.code": "ペア設定コード", + "wifi.method.cable": "ケーブルから", + "wifi.qr.steps": "スマートフォンで 設定 → 開発者向けオプション → ワイヤレス デバッグ を開いてオンにし、「QR コードによるデバイスのペア設定」をタップします。次に、このコードをスキャンしてください。", + "wifi.qr.waiting": "スマートフォンがコードをスキャンするのを待っています…", + "wifi.qr.new": "新しいコード", + "wifi.code.steps": "スマートフォンで 設定 → 開発者向けオプション → ワイヤレス デバッグ を開き、「ペア設定コードによるデバイスのペア設定」をタップします。表示された 6 桁の数字を入力してください。", + "wifi.code.label": "ペア設定コード", + "wifi.code.address": "IP アドレスとポート", + "wifi.code.address.hint": "Plugcam がスマートフォンを自動で見つけられない場合のみ:コードの下に表示されるアドレス", + "wifi.pair": "ペア設定", + "wifi.pairing": "ペア設定中…", + "wifi.cable.steps": "スマートフォンをケーブルで接続し、このパソコンと同じ Wi-Fi につないでおきます。Plugcam が Wi-Fi 接続に切り替えたら、ケーブルを抜いてかまいません。", + "wifi.cable.warning": "この接続は暗号化されず、スマートフォンを再起動すると切れます。自分のネットワークでのみ使ってください。", + "wifi.cable.go": "Wi-Fi に切り替える", + "wifi.address.title": "アドレスで接続", + "wifi.address.hint": "ペア設定済みなのに検索で見つからないスマートフォン向け:「ワイヤレス デバッグ」の上部に表示される IP アドレスとポートを入力します。", + "wifi.connect": "接続", + "wifi.done": "{name} が Wi-Fi で接続されました", + "wifi.tips": "スマートフォンが見つからない場合", + "wifi.tip.network": "スマートフォンとこのパソコンは同じ Wi-Fi に接続する必要があります。5 GHz のほうが映像がなめらかです。", + "wifi.tip.always": "スマートフォンでワイヤレス デバッグの許可を求められたら、「このネットワークで常に許可する」にチェックを入れてください。", + "wifi.tip.guest": "ゲストネットワークや、デバイス同士を分離するネットワークではスマートフォンが見えません。アドレスで接続するか、ケーブルを使ってください。", + "wifi.tip.hotspot": "パソコンがスマートフォンのテザリングでインターネットにつないでいる場合は使えません。", + "wifi.tip.vpn": "スマートフォンで VPN を使っていると見つからず、違うアドレスが表示されます。スマートフォンの Wi-Fi の IP アドレス(設定 → デバイス情報 → ステータス → IP アドレス)と「ワイヤレス デバッグ」のポートを使うか、ペア設定の間だけ VPN をオフにしてください。", + "wifi.tip.firewall": "初回は Windows が adb.exe について確認します。プライベート ネットワークで許可してください。", + "wifi.err.badCode": "ペア設定コードは 6 桁です", + "wifi.err.noPairingPhone": "スマートフォンが見つかりません。ペア設定コードを画面に表示したままにするか、一緒に表示される IP アドレスとポートを入力してください。", + "wifi.err.qrTimeout": "コードの有効期限が切れました", + "wifi.err.pairedNotFound": "ペア設定は完了しましたが、スマートフォンがネットワーク上に見つかりません(スマートフォンの VPN が原因の場合があります)。スマートフォンの「IP アドレスとポート」に表示されているポート番号を入力してください。", + "wifi.err.badAddress": "アドレスは IP:ポート の形式で入力してください(例: 192.168.1.20:37215)", + "wifi.err.noUsbPhone": "まずスマートフォンをケーブルで接続してください", + "wifi.err.phoneNotOnWifi": "スマートフォンが Wi-Fi に接続されていません。スマートフォンの Wi-Fi をオンにして、もう一度お試しください。", + "wifi.err.failed": "接続できませんでした: {message}", }; export default ja; diff --git a/app/src/lib/locales/ko.ts b/app/src/lib/locales/ko.ts index fb729a0..c9c4c8e 100644 --- a/app/src/lib/locales/ko.ts +++ b/app/src/lib/locales/ko.ts @@ -4,7 +4,7 @@ const ko: Messages = { "app.settings": "설정", "app.back": "뒤로", "status.noPhone": "휴대폰을 연결하세요", - "status.noPhone.hint": "USB 케이블로 연결하고 USB 디버깅을 켜세요", + "status.noPhone.hint": "USB 케이블로 연결하고 USB 디버깅을 켜거나, Wi-Fi로 연결하세요", "status.unauthorized": "휴대폰에서 디버깅을 허용하세요", "status.unauthorized.hint": "휴대폰에서 ‘허용’을 누르고 ‘항상 허용’을 선택하세요", "status.offline": "휴대폰이 응답하지 않습니다", @@ -12,6 +12,8 @@ const ko: Messages = { "status.ready": "준비됨", "status.waiting": "휴대폰을 기다리는 중", "status.connecting": "연결 중…", + "status.reconnecting": "Wi-Fi로 다시 연결하는 중 ({attempt}/{tries})", + "status.reconnecting.hint": "휴대폰이 다시 연결될 때까지 앱에는 마지막 화면이 그대로 표시됩니다", "status.streaming": "송출 중", "status.noPicture": "이 렌즈는 화면이 나오지 않습니다", "status.error": "오류", @@ -142,6 +144,52 @@ const ko: Messages = { "wiz.3.found": "{name} 연결됨", "wiz.3.driver": "휴대폰이 보이지 않나요? USB 드라이버 설치", "wiz.why": "휴대폰에 앱을 설치할 필요가 없습니다. Plugcam은 USB 디버깅으로 작동합니다.", + "phones.title": "휴대폰과 Wi-Fi", + "phones.connectWifi": "Wi-Fi로 연결", + "phones.list": "휴대폰", + "phones.auto": "자동 선택(USB 우선)", + "phones.usb": "USB 케이블", + "phones.wifi": "Wi-Fi", + "phones.wifi.plain": "Wi-Fi, 암호화 안 됨", + "phones.offline": "네트워크에 없습니다. 휴대폰이 나타나면 Plugcam이 알아서 연결합니다.", + "phones.offline.plain": "연결되어 있지 않습니다. 휴대폰을 다시 시작한 뒤에는 케이블이 다시 필요합니다.", + "phones.forget": "삭제", + "phones.forget.note": "‘삭제’해도 페어링은 해제되지 않습니다. 완전히 지우려면 휴대폰의 ‘무선 디버깅’ → ‘페어링된 기기’에서 이 컴퓨터를 삭제하세요.", + "wifi.add": "Wi-Fi로 연결", + "wifi.method.qr": "QR 코드", + "wifi.method.code": "페어링 코드", + "wifi.method.cable": "케이블에서", + "wifi.qr.steps": "휴대폰에서 설정 → 개발자 옵션 → 무선 디버깅을 열어 켜고 ‘QR 코드로 기기 페어링’을 누르세요. 그런 다음 이 코드를 스캔하세요.", + "wifi.qr.waiting": "휴대폰이 코드를 스캔하기를 기다리는 중…", + "wifi.qr.new": "새 코드", + "wifi.code.steps": "휴대폰에서 설정 → 개발자 옵션 → 무선 디버깅을 열고 ‘페어링 코드로 기기 페어링’을 누르세요. 표시된 여섯 자리 숫자를 입력하세요.", + "wifi.code.label": "페어링 코드", + "wifi.code.address": "IP 주소 및 포트", + "wifi.code.address.hint": "Plugcam이 휴대폰을 자동으로 찾지 못할 때만: 코드 아래에 표시된 주소", + "wifi.pair": "페어링", + "wifi.pairing": "페어링 중…", + "wifi.cable.steps": "휴대폰을 케이블로 연결하고 이 컴퓨터와 같은 Wi-Fi에 연결해 두세요. Plugcam이 Wi-Fi로 전환하면 케이블을 뽑아도 됩니다.", + "wifi.cable.warning": "이 연결은 암호화되지 않으며 휴대폰을 다시 시작하면 끊깁니다. 내 네트워크에서만 사용하세요.", + "wifi.cable.go": "Wi-Fi로 전환", + "wifi.address.title": "주소로 연결", + "wifi.address.hint": "페어링했지만 검색에 나타나지 않는 휴대폰용: ‘무선 디버깅’ 맨 위에 있는 IP 주소와 포트를 입력하세요.", + "wifi.connect": "연결", + "wifi.done": "{name}이(가) Wi-Fi로 연결됨", + "wifi.tips": "휴대폰을 찾을 수 없다면", + "wifi.tip.network": "휴대폰과 이 컴퓨터가 같은 Wi-Fi에 연결되어 있어야 합니다. 5GHz를 쓰면 화면이 더 부드럽습니다.", + "wifi.tip.always": "휴대폰에서 무선 디버깅을 허용할지 물으면 ‘이 네트워크에서 항상 허용’을 선택하세요.", + "wifi.tip.guest": "게스트 네트워크나 기기끼리 분리하는 네트워크에서는 휴대폰이 보이지 않습니다. 주소로 연결하거나 케이블을 사용하세요.", + "wifi.tip.hotspot": "컴퓨터가 휴대폰의 핫스팟으로 인터넷에 연결되어 있으면 작동하지 않습니다.", + "wifi.tip.vpn": "휴대폰에서 VPN을 사용하면 휴대폰이 보이지 않고 잘못된 주소가 표시됩니다. 휴대폰의 Wi-Fi IP(설정 → 휴대전화 정보 → 상태 → IP 주소)와 ‘무선 디버깅’의 포트를 사용하거나, 페어링하는 동안 VPN을 끄세요.", + "wifi.tip.firewall": "처음에 Windows가 adb.exe에 대해 물으면 개인 네트워크에서 허용하세요.", + "wifi.err.badCode": "페어링 코드는 여섯 자리입니다", + "wifi.err.noPairingPhone": "휴대폰을 찾을 수 없습니다. 휴대폰 화면에 페어링 코드를 띄워 두거나, 함께 표시된 IP 주소와 포트를 입력하세요.", + "wifi.err.qrTimeout": "코드가 만료되었습니다", + "wifi.err.pairedNotFound": "페어링했지만 휴대폰이 네트워크에 나타나지 않았습니다(휴대폰의 VPN 때문일 수 있습니다). 휴대폰의 ‘IP 주소 및 포트’에 표시된 포트를 입력하세요.", + "wifi.err.badAddress": "주소를 IP:포트 형식으로 입력하세요(예: 192.168.1.20:37215)", + "wifi.err.noUsbPhone": "먼저 휴대폰을 케이블로 연결하세요", + "wifi.err.phoneNotOnWifi": "휴대폰이 Wi-Fi에 연결되어 있지 않습니다. 휴대폰에서 Wi-Fi를 켜고 다시 시도하세요.", + "wifi.err.failed": "연결하지 못했습니다: {message}", }; export default ko; diff --git a/app/src/lib/locales/pl.ts b/app/src/lib/locales/pl.ts index d8dd8e6..423d659 100644 --- a/app/src/lib/locales/pl.ts +++ b/app/src/lib/locales/pl.ts @@ -4,7 +4,7 @@ const pl: Messages = { "app.settings": "Ustawienia", "app.back": "Wstecz", "status.noPhone": "Podłącz telefon", - "status.noPhone.hint": "Kablem USB, z włączonym debugowaniem USB", + "status.noPhone.hint": "Kablem USB, z włączonym debugowaniem USB, lub przez Wi-Fi", "status.unauthorized": "Zezwól na debugowanie w telefonie", "status.unauthorized.hint": "Na telefonie stuknij „Zezwól” i zaznacz „Zawsze zezwalaj”", "status.offline": "Telefon nie odpowiada", @@ -12,6 +12,8 @@ const pl: Messages = { "status.ready": "Gotowe", "status.waiting": "Czekam na telefon", "status.connecting": "Łączenie…", + "status.reconnecting": "Ponowne łączenie przez Wi-Fi ({attempt} z {tries})", + "status.reconnecting.hint": "Aplikacje pokazują ostatni obraz, dopóki telefon nie wróci", "status.streaming": "Trwa transmisja", "status.noPicture": "Ten obiektyw nie daje obrazu", "status.error": "Błąd", @@ -142,6 +144,52 @@ const pl: Messages = { "wiz.3.found": "{name} jest podłączony", "wiz.3.driver": "Nie widać telefonu? Zainstaluj sterownik USB", "wiz.why": "Na telefonie nie trzeba instalować aplikacji: Plugcam działa przez debugowanie USB.", + "phones.title": "Telefony i Wi-Fi", + "phones.connectWifi": "Połącz przez Wi-Fi", + "phones.list": "Telefony", + "phones.auto": "Wybieraj automatycznie, najpierw USB", + "phones.usb": "Kabel USB", + "phones.wifi": "Wi-Fi", + "phones.wifi.plain": "Wi-Fi, bez szyfrowania", + "phones.offline": "Poza siecią. Plugcam połączy go, gdy się pojawi.", + "phones.offline.plain": "Niepołączony. Po ponownym uruchomieniu telefon znowu potrzebuje kabla.", + "phones.forget": "Zapomnij", + "phones.forget.note": "„Zapomnij” nie usuwa parowania. Aby całkiem usunąć telefon, usuń ten komputer na telefonie w „Debugowanie bezprzewodowe” → „Sparowane urządzenia”.", + "wifi.add": "Połącz przez Wi-Fi", + "wifi.method.qr": "Kod QR", + "wifi.method.code": "Kod parowania", + "wifi.method.cable": "Z kabla", + "wifi.qr.steps": "Na telefonie otwórz Ustawienia → Opcje programisty → Debugowanie bezprzewodowe, włącz je i stuknij „Sparuj urządzenie za pomocą kodu QR”. Potem zeskanuj ten kod.", + "wifi.qr.waiting": "Czekam, aż telefon zeskanuje kod…", + "wifi.qr.new": "Nowy kod", + "wifi.code.steps": "Na telefonie otwórz Ustawienia → Opcje programisty → Debugowanie bezprzewodowe i stuknij „Sparuj urządzenie za pomocą kodu parowania”. Wpisz sześć cyfr z ekranu.", + "wifi.code.label": "Kod parowania", + "wifi.code.address": "Adres IP i port", + "wifi.code.address.hint": "Tylko jeśli Plugcam sam nie znajdzie telefonu: adres widoczny pod kodem", + "wifi.pair": "Sparuj", + "wifi.pairing": "Parowanie…", + "wifi.cable.steps": "Telefon jest podłączony kablem i jest w tej samej sieci Wi-Fi co ten komputer. Plugcam przełączy go na Wi-Fi, potem możesz odłączyć kabel.", + "wifi.cable.warning": "To połączenie nie jest szyfrowane i kończy się po ponownym uruchomieniu telefonu. Używaj go tylko we własnej sieci.", + "wifi.cable.go": "Przełącz na Wi-Fi", + "wifi.address.title": "Połącz po adresie", + "wifi.address.hint": "Dla sparowanego telefonu, którego nie widać w wyszukiwaniu: adres IP i port u góry ekranu „Debugowanie bezprzewodowe”.", + "wifi.connect": "Połącz", + "wifi.done": "{name} jest połączony przez Wi-Fi", + "wifi.tips": "Jeśli telefon nie zostanie znaleziony", + "wifi.tip.network": "Telefon i ten komputer muszą być w tej samej sieci Wi-Fi. W paśmie 5 GHz obraz jest płynniejszy.", + "wifi.tip.always": "Gdy telefon zapyta o zezwolenie na debugowanie bezprzewodowe, zaznacz „Zawsze zezwalaj w tej sieci”.", + "wifi.tip.guest": "W sieciach dla gości i sieciach izolujących urządzenia telefon jest niewidoczny. Połącz po adresie albo użyj kabla.", + "wifi.tip.hotspot": "Nie działa, gdy komputer korzysta z internetu przez hotspot tego telefonu.", + "wifi.tip.vpn": "VPN na telefonie ukrywa go i sprawia, że pokazuje zły adres. Użyj adresu IP Wi-Fi telefonu (Ustawienia → Informacje o telefonie → Stan → Adres IP) z portem z „Debugowania bezprzewodowego” albo wyłącz VPN na czas parowania.", + "wifi.tip.firewall": "Za pierwszym razem Windows zapyta o adb.exe: zezwól na dostęp w sieciach prywatnych.", + "wifi.err.badCode": "Kod parowania ma sześć cyfr", + "wifi.err.noPairingPhone": "Nie znaleziono telefonu. Nie zamykaj kodu parowania na jego ekranie albo wpisz adres IP i port widoczne obok.", + "wifi.err.qrTimeout": "Kod wygasł", + "wifi.err.pairedNotFound": "Sparowano, ale telefon nie ogłosił się w sieci (może to powodować VPN na telefonie). Wpisz port podany na telefonie w polu „Adres IP i port”.", + "wifi.err.badAddress": "Wpisz adres w formacie IP:port, na przykład 192.168.1.20:37215", + "wifi.err.noUsbPhone": "Najpierw podłącz telefon kablem", + "wifi.err.phoneNotOnWifi": "Telefon nie jest połączony z Wi-Fi. Włącz Wi-Fi na telefonie i spróbuj ponownie.", + "wifi.err.failed": "Nie udało się połączyć: {message}", }; export default pl; diff --git a/app/src/lib/locales/pt-BR.ts b/app/src/lib/locales/pt-BR.ts index dbf7b56..4e28740 100644 --- a/app/src/lib/locales/pt-BR.ts +++ b/app/src/lib/locales/pt-BR.ts @@ -4,7 +4,7 @@ const ptBR: Messages = { "app.settings": "Configurações", "app.back": "Voltar", "status.noPhone": "Conecte seu celular", - "status.noPhone.hint": "Com um cabo USB e a depuração USB ativada", + "status.noPhone.hint": "Com um cabo USB e a depuração USB ativada, ou por Wi-Fi", "status.unauthorized": "Permita a depuração no celular", "status.unauthorized.hint": "No celular, toque em “Permitir” e marque “Sempre permitir”", "status.offline": "O celular não responde", @@ -12,6 +12,8 @@ const ptBR: Messages = { "status.ready": "Pronto", "status.waiting": "Aguardando o celular", "status.connecting": "Conectando…", + "status.reconnecting": "Reconectando por Wi-Fi ({attempt} de {tries})", + "status.reconnecting.hint": "Os apps mantêm a última imagem até o celular voltar", "status.streaming": "Transmitindo", "status.noPicture": "Esta lente não dá imagem", "status.error": "Erro", @@ -142,6 +144,52 @@ const ptBR: Messages = { "wiz.3.found": "{name} está conectado", "wiz.3.driver": "O celular não aparece? Instale o driver USB", "wiz.why": "Não é preciso instalar app no celular: o Plugcam funciona pela depuração USB.", + "phones.title": "Celulares e Wi-Fi", + "phones.connectWifi": "Conectar por Wi-Fi", + "phones.list": "Celulares", + "phones.auto": "Escolher automaticamente, USB primeiro", + "phones.usb": "Cabo USB", + "phones.wifi": "Wi-Fi", + "phones.wifi.plain": "Wi-Fi, sem criptografia", + "phones.offline": "Fora da rede. O Plugcam conecta o celular quando ele aparecer.", + "phones.offline.plain": "Desconectado. Depois que o celular reiniciar, ele precisa do cabo de novo.", + "phones.forget": "Esquecer", + "phones.forget.note": "“Esquecer” não desfaz o pareamento. Para remover o celular de vez, apague este computador no celular em “Depuração por Wi-Fi” → “Dispositivos pareados”.", + "wifi.add": "Conectar por Wi-Fi", + "wifi.method.qr": "Código QR", + "wifi.method.code": "Código de pareamento", + "wifi.method.cable": "Pelo cabo", + "wifi.qr.steps": "No celular, abra Configurações → Opções do desenvolvedor → Depuração por Wi-Fi, ative e toque em “Parear o dispositivo com um código QR”. Depois, leia este código.", + "wifi.qr.waiting": "Aguardando o celular ler o código…", + "wifi.qr.new": "Novo código", + "wifi.code.steps": "No celular, abra Configurações → Opções do desenvolvedor → Depuração por Wi-Fi e toque em “Parear o dispositivo com um código de pareamento”. Digite os seis dígitos exibidos.", + "wifi.code.label": "Código de pareamento", + "wifi.code.address": "Endereço IP e porta", + "wifi.code.address.hint": "Só se o Plugcam não encontrar o celular sozinho: o endereço que aparece abaixo do código", + "wifi.pair": "Parear", + "wifi.pairing": "Pareando…", + "wifi.cable.steps": "O celular está no cabo e no mesmo Wi-Fi que este computador. O Plugcam passa o celular para o Wi-Fi e depois você pode desconectar o cabo.", + "wifi.cable.warning": "Esta conexão não é criptografada e termina quando o celular reinicia. Use só na sua própria rede.", + "wifi.cable.go": "Mudar para Wi-Fi", + "wifi.address.title": "Conectar pelo endereço", + "wifi.address.hint": "Para um celular pareado que a busca não encontra: o endereço IP e a porta no topo de “Depuração por Wi-Fi”.", + "wifi.connect": "Conectar", + "wifi.done": "{name} está conectado por Wi-Fi", + "wifi.tips": "Se o celular não for encontrado", + "wifi.tip.network": "O celular e este computador precisam estar no mesmo Wi-Fi. Com 5 GHz a imagem fica mais fluida.", + "wifi.tip.always": "Quando o celular pedir para permitir a depuração por Wi-Fi, marque “Sempre permitir nesta rede”.", + "wifi.tip.guest": "Redes de convidados e redes que isolam os dispositivos escondem o celular. Conecte pelo endereço ou use o cabo.", + "wifi.tip.hotspot": "Não funciona quando o computador usa a internet do ponto de acesso do celular.", + "wifi.tip.vpn": "Uma VPN no celular o esconde e faz ele mostrar um endereço errado. Use o IP do Wi-Fi do celular (Configurações → Sobre o telefone → Status → Endereço IP) com a porta da “Depuração por Wi-Fi”, ou desligue a VPN durante o pareamento.", + "wifi.tip.firewall": "Na primeira vez, o Windows pergunta sobre o adb.exe: permita em redes privadas.", + "wifi.err.badCode": "O código de pareamento tem seis dígitos", + "wifi.err.noPairingPhone": "O celular não foi encontrado. Deixe o código de pareamento na tela dele ou digite o endereço IP e a porta que aparecem junto.", + "wifi.err.qrTimeout": "O código expirou", + "wifi.err.pairedNotFound": "Pareado, mas o celular não se anunciou na rede (uma VPN no celular pode causar isso). Digite a porta mostrada em “Endereço IP e porta” no celular.", + "wifi.err.badAddress": "Digite o endereço como IP:porta, por exemplo 192.168.1.20:37215", + "wifi.err.noUsbPhone": "Primeiro conecte o celular pelo cabo", + "wifi.err.phoneNotOnWifi": "O celular não está no Wi-Fi. Ative o Wi-Fi no celular e tente de novo.", + "wifi.err.failed": "Não foi possível conectar: {message}", }; export default ptBR; diff --git a/app/src/lib/locales/ru.ts b/app/src/lib/locales/ru.ts index e4780ae..dd918b3 100644 --- a/app/src/lib/locales/ru.ts +++ b/app/src/lib/locales/ru.ts @@ -4,7 +4,7 @@ const ru: Messages = { "app.settings": "Настройки", "app.back": "Назад", "status.noPhone": "Подключите телефон", - "status.noPhone.hint": "Кабелем USB, с включённой отладкой по USB", + "status.noPhone.hint": "Кабелем USB с включённой отладкой по USB или по Wi-Fi", "status.unauthorized": "Разрешите отладку на телефоне", "status.unauthorized.hint": "На экране телефона нажмите «Разрешить» и отметьте «Всегда разрешать»", "status.offline": "Телефон не отвечает", @@ -12,6 +12,8 @@ const ru: Messages = { "status.ready": "Готово", "status.waiting": "Ждём телефон", "status.connecting": "Подключение…", + "status.reconnecting": "Переподключение по Wi-Fi ({attempt} из {tries})", + "status.reconnecting.hint": "Пока телефон возвращается, в программах остаётся последний кадр", "status.streaming": "Идёт трансляция", "status.noPicture": "Объектив не даёт картинку", "status.error": "Ошибка", @@ -142,6 +144,52 @@ const ru: Messages = { "wiz.3.found": "Телефон {name} подключён", "wiz.3.driver": "Телефон не находится? Установите USB-драйвер", "wiz.why": "Своё приложение на телефон ставить не нужно: Plugcam работает через отладку по USB.", + "phones.title": "Телефоны и Wi-Fi", + "phones.connectWifi": "Подключить по Wi-Fi", + "phones.list": "Телефоны", + "phones.auto": "Выбирать автоматически, сначала USB", + "phones.usb": "Кабель USB", + "phones.wifi": "Wi-Fi", + "phones.wifi.plain": "Wi-Fi без шифрования", + "phones.offline": "Не в сети. Plugcam подключит его, когда он появится.", + "phones.offline.plain": "Не подключён. После перезагрузки телефона снова нужен кабель.", + "phones.forget": "Забыть", + "phones.forget.note": "«Забыть» не отменяет сопряжение. Чтобы удалить телефон полностью, удалите этот компьютер на телефоне: «Отладка по Wi-Fi» → «Сопряжённые устройства».", + "wifi.add": "Подключить по Wi-Fi", + "wifi.method.qr": "QR-код", + "wifi.method.code": "Код", + "wifi.method.cable": "С кабеля", + "wifi.qr.steps": "На телефоне откройте Настройки → Для разработчиков → Отладка по Wi-Fi, включите её и нажмите «Подключить устройство с помощью QR-кода». Затем отсканируйте этот код.", + "wifi.qr.waiting": "Ждём, пока телефон отсканирует код…", + "wifi.qr.new": "Новый код", + "wifi.code.steps": "На телефоне откройте Настройки → Для разработчиков → Отладка по Wi-Fi и нажмите «Подключить устройство с помощью кода подключения». Введите шесть цифр с экрана.", + "wifi.code.label": "Код подключения", + "wifi.code.address": "IP-адрес и порт", + "wifi.code.address.hint": "Только если Plugcam не найдёт телефон сам: адрес под кодом", + "wifi.pair": "Подключить", + "wifi.pairing": "Подключаем…", + "wifi.cable.steps": "Телефон подключён кабелем и находится в той же сети Wi-Fi, что и компьютер. Plugcam переведёт его на Wi-Fi, после этого кабель можно отключить.", + "wifi.cable.warning": "Такое соединение не шифруется и пропадает после перезагрузки телефона. Используйте его только в своей сети.", + "wifi.cable.go": "Перейти на Wi-Fi", + "wifi.address.title": "Подключить по адресу", + "wifi.address.hint": "Для сопряжённого телефона, которого не видно в поиске: IP-адрес и порт вверху экрана «Отладка по Wi-Fi».", + "wifi.connect": "Подключить", + "wifi.done": "{name} подключён по Wi-Fi", + "wifi.tips": "Если телефон не находится", + "wifi.tip.network": "Телефон и компьютер должны быть в одной сети Wi-Fi. В сети 5 ГГц картинка плавнее.", + "wifi.tip.always": "Когда телефон спросит, разрешить ли отладку по Wi-Fi, отметьте «Всегда разрешать в этой сети».", + "wifi.tip.guest": "В гостевых сетях и сетях с изоляцией устройств телефон не виден. Подключите его по адресу или кабелем.", + "wifi.tip.hotspot": "Не работает, если компьютер выходит в интернет через точку доступа на этом телефоне.", + "wifi.tip.vpn": "VPN на телефоне скрывает его и заставляет показывать не тот адрес. Используйте Wi-Fi-адрес телефона (Настройки → О телефоне → Статус → IP-адрес) и порт из «Отладки по Wi-Fi» или выключите VPN на время сопряжения.", + "wifi.tip.firewall": "В первый раз Windows спросит про adb.exe: разрешите доступ в частных сетях.", + "wifi.err.badCode": "В коде подключения шесть цифр", + "wifi.err.noPairingPhone": "Телефон не найден. Не закрывайте окно с кодом на телефоне или введите IP-адрес и порт, указанные под кодом.", + "wifi.err.qrTimeout": "Срок действия кода истёк", + "wifi.err.pairedNotFound": "Сопряжено, но телефон не объявил себя в сети (так бывает, если на телефоне включён VPN). Введите порт из строки «IP-адрес и порт» на телефоне.", + "wifi.err.badAddress": "Введите адрес в виде IP:порт, например 192.168.1.20:37215", + "wifi.err.noUsbPhone": "Сначала подключите телефон кабелем", + "wifi.err.phoneNotOnWifi": "Телефон не подключён к Wi-Fi. Включите Wi-Fi на телефоне и попробуйте ещё раз.", + "wifi.err.failed": "Не удалось подключиться: {message}", }; export default ru; diff --git a/app/src/lib/locales/tr.ts b/app/src/lib/locales/tr.ts index ae17876..8d7a781 100644 --- a/app/src/lib/locales/tr.ts +++ b/app/src/lib/locales/tr.ts @@ -4,7 +4,7 @@ const tr: Messages = { "app.settings": "Ayarlar", "app.back": "Geri", "status.noPhone": "Telefonunuzu bağlayın", - "status.noPhone.hint": "USB kablosuyla, USB hata ayıklama açıkken", + "status.noPhone.hint": "USB kablosuyla, USB hata ayıklama açıkken veya Wi-Fi üzerinden", "status.unauthorized": "Telefonda hata ayıklamaya izin verin", "status.unauthorized.hint": "Telefonda “İzin ver”e dokunun ve “Her zaman izin ver”i işaretleyin", "status.offline": "Telefon yanıt vermiyor", @@ -12,6 +12,8 @@ const tr: Messages = { "status.ready": "Hazır", "status.waiting": "Telefon bekleniyor", "status.connecting": "Bağlanıyor…", + "status.reconnecting": "Wi-Fi üzerinden yeniden bağlanıyor ({attempt}/{tries})", + "status.reconnecting.hint": "Telefon geri gelene kadar uygulamalarda son görüntü kalır", "status.streaming": "Yayında", "status.noPicture": "Bu lens görüntü vermiyor", "status.error": "Hata", @@ -142,6 +144,52 @@ const tr: Messages = { "wiz.3.found": "{name} bağlandı", "wiz.3.driver": "Telefon bulunamıyor mu? USB sürücüsünü yükleyin", "wiz.why": "Telefona uygulama yüklemeniz gerekmez: Plugcam USB hata ayıklama üzerinden çalışır.", + "phones.title": "Telefonlar ve Wi-Fi", + "phones.connectWifi": "Wi-Fi ile bağlan", + "phones.list": "Telefonlar", + "phones.auto": "Otomatik seç, önce USB", + "phones.usb": "USB kablosu", + "phones.wifi": "Wi-Fi", + "phones.wifi.plain": "Wi-Fi, şifrelenmemiş", + "phones.offline": "Ağda değil. Göründüğünde Plugcam onu bağlar.", + "phones.offline.plain": "Bağlı değil. Telefon yeniden başladıktan sonra yine kablo gerekir.", + "phones.forget": "Unut", + "phones.forget.note": "“Unut” eşlemeyi kaldırmaz. Telefonu tamamen kaldırmak için telefonda “Kablosuz hata ayıklama” → “Eşlenen cihazlar” altından bu bilgisayarı silin.", + "wifi.add": "Wi-Fi ile bağlan", + "wifi.method.qr": "QR kodu", + "wifi.method.code": "Eşleme kodu", + "wifi.method.cable": "Kablodan", + "wifi.qr.steps": "Telefonda Ayarlar → Geliştirici seçenekleri → Kablosuz hata ayıklama’ya gidin, özelliği açın ve “Cihazı QR kodu ile eşle”ye dokunun. Ardından bu kodu tarayın.", + "wifi.qr.waiting": "Telefonun kodu taraması bekleniyor…", + "wifi.qr.new": "Yeni kod", + "wifi.code.steps": "Telefonda Ayarlar → Geliştirici seçenekleri → Kablosuz hata ayıklama’ya gidin ve “Cihazı eşleme kodu ile eşle”ye dokunun. Ekranda görünen altı haneyi girin.", + "wifi.code.label": "Eşleme kodu", + "wifi.code.address": "IP adresi ve bağlantı noktası", + "wifi.code.address.hint": "Yalnızca Plugcam telefonu kendisi bulamazsa: kodun altında görünen adres", + "wifi.pair": "Eşle", + "wifi.pairing": "Eşleniyor…", + "wifi.cable.steps": "Telefon kabloyla bağlı ve bu bilgisayarla aynı Wi-Fi ağında. Plugcam onu Wi-Fi’ye geçirir, ardından kabloyu çıkarabilirsiniz.", + "wifi.cable.warning": "Bu bağlantı şifrelenmez ve telefon yeniden başladığında sona erer. Yalnızca kendi ağınızda kullanın.", + "wifi.cable.go": "Wi-Fi’ye geç", + "wifi.address.title": "Adresle bağlan", + "wifi.address.hint": "Aramada görünmeyen eşlenmiş bir telefon için: “Kablosuz hata ayıklama” ekranının üstündeki IP adresi ve bağlantı noktası.", + "wifi.connect": "Bağlan", + "wifi.done": "{name}, Wi-Fi üzerinden bağlandı", + "wifi.tips": "Telefon bulunamazsa", + "wifi.tip.network": "Telefon ve bu bilgisayar aynı Wi-Fi ağında olmalı. 5 GHz daha akıcı bir görüntü verir.", + "wifi.tip.always": "Telefon kablosuz hata ayıklamaya izin verip vermeyeceğinizi sorduğunda “Bu ağda her zaman izin ver”i işaretleyin.", + "wifi.tip.guest": "Misafir ağları ve cihazları birbirinden ayıran ağlar telefonu gizler. Adresle bağlanın veya kablo kullanın.", + "wifi.tip.hotspot": "Bilgisayar internete telefonun hotspot’u üzerinden bağlanıyorsa çalışmaz.", + "wifi.tip.vpn": "Telefondaki bir VPN onu gizler ve yanlış adres göstermesine yol açar. Telefonun Wi-Fi IP adresini (Ayarlar → Telefon hakkında → Durum → IP adresi) “Kablosuz hata ayıklama” ekranındaki bağlantı noktasıyla kullanın veya eşleme sırasında VPN’i kapatın.", + "wifi.tip.firewall": "Windows ilk seferde adb.exe için izin ister: özel ağlarda izin verin.", + "wifi.err.badCode": "Eşleme kodu altı hanelidir", + "wifi.err.noPairingPhone": "Telefon bulunamadı. Eşleme kodunu telefonun ekranında açık tutun veya yanında gösterilen IP adresini ve bağlantı noktasını girin.", + "wifi.err.qrTimeout": "Kodun süresi doldu", + "wifi.err.pairedNotFound": "Eşlendi, ancak telefon kendini ağda duyurmadı (telefondaki bir VPN buna yol açabilir). Telefonda “IP adresi ve bağlantı noktası” altında görünen bağlantı noktasını girin.", + "wifi.err.badAddress": "Adresi IP:bağlantı noktası biçiminde girin, örneğin 192.168.1.20:37215", + "wifi.err.noUsbPhone": "Önce telefonu kabloyla bağlayın", + "wifi.err.phoneNotOnWifi": "Telefon Wi-Fi’ye bağlı değil. Telefonda Wi-Fi’yi açıp tekrar deneyin.", + "wifi.err.failed": "Bağlanılamadı: {message}", }; export default tr; diff --git a/app/src/lib/locales/uk.ts b/app/src/lib/locales/uk.ts index 583e709..f7e241b 100644 --- a/app/src/lib/locales/uk.ts +++ b/app/src/lib/locales/uk.ts @@ -4,7 +4,7 @@ const uk: Messages = { "app.settings": "Налаштування", "app.back": "Назад", "status.noPhone": "Підключіть телефон", - "status.noPhone.hint": "Кабелем USB, з увімкненим налагодженням по USB", + "status.noPhone.hint": "Кабелем USB, з увімкненим налагодженням по USB, або через Wi-Fi", "status.unauthorized": "Дозвольте налагодження на телефоні", "status.unauthorized.hint": "На екрані телефона натисніть «Дозволити» й позначте «Завжди дозволяти»", "status.offline": "Телефон не відповідає", @@ -12,6 +12,8 @@ const uk: Messages = { "status.ready": "Готово", "status.waiting": "Чекаємо на телефон", "status.connecting": "Підключення…", + "status.reconnecting": "Перепідключення через Wi-Fi ({attempt} з {tries})", + "status.reconnecting.hint": "Поки телефон не повернеться, у програмах лишається останній кадр", "status.streaming": "Іде трансляція", "status.noPicture": "Об’єктив не дає зображення", "status.error": "Помилка", @@ -142,6 +144,52 @@ const uk: Messages = { "wiz.3.found": "Телефон {name} підключено", "wiz.3.driver": "Телефон не знаходиться? Установіть USB-драйвер", "wiz.why": "Окремий застосунок на телефон ставити не треба: Plugcam працює через налагодження по USB.", + "phones.title": "Телефони і Wi-Fi", + "phones.connectWifi": "Підключити через Wi-Fi", + "phones.list": "Телефони", + "phones.auto": "Вибирати автоматично, спершу USB", + "phones.usb": "Кабель USB", + "phones.wifi": "Wi-Fi", + "phones.wifi.plain": "Wi-Fi без шифрування", + "phones.offline": "Не в мережі. Plugcam підключить його, щойно він з’явиться.", + "phones.offline.plain": "Не підключено. Після перезавантаження телефона знову потрібен кабель.", + "phones.forget": "Забути", + "phones.forget.note": "«Забути» не скасовує сполучення. Щоб повністю прибрати телефон, видаліть цей комп’ютер на телефоні: «Бездротове налагодження» → «Сполучені пристрої».", + "wifi.add": "Підключити через Wi-Fi", + "wifi.method.qr": "QR-код", + "wifi.method.code": "Код", + "wifi.method.cable": "Через кабель", + "wifi.qr.steps": "На телефоні відкрийте Налаштування → Параметри розробника → Бездротове налагодження, увімкніть його й натисніть «Підключити пристрій за допомогою QR-коду». Потім відскануйте цей код.", + "wifi.qr.waiting": "Чекаємо, поки телефон відсканує код…", + "wifi.qr.new": "Новий код", + "wifi.code.steps": "На телефоні відкрийте Налаштування → Параметри розробника → Бездротове налагодження й натисніть «Підключити пристрій за допомогою коду підключення». Введіть шість цифр з екрана.", + "wifi.code.label": "Код підключення", + "wifi.code.address": "IP-адреса й порт", + "wifi.code.address.hint": "Лише якщо Plugcam не знайде телефон сам: адреса під кодом", + "wifi.pair": "Підключити", + "wifi.pairing": "Підключаємо…", + "wifi.cable.steps": "Телефон під’єднано кабелем, і він у тій самій мережі Wi-Fi, що й комп’ютер. Plugcam переведе його на Wi-Fi, після цього кабель можна від’єднати.", + "wifi.cable.warning": "Таке з’єднання не шифрується й зникає після перезавантаження телефона. Використовуйте його лише у своїй мережі.", + "wifi.cable.go": "Перейти на Wi-Fi", + "wifi.address.title": "Підключити за адресою", + "wifi.address.hint": "Для сполученого телефона, якого не видно в пошуку: IP-адреса й порт угорі екрана «Бездротове налагодження».", + "wifi.connect": "Підключити", + "wifi.done": "Телефон {name} підключено через Wi-Fi", + "wifi.tips": "Якщо телефон не знаходиться", + "wifi.tip.network": "Телефон і комп’ютер мають бути в одній мережі Wi-Fi. У мережі 5 ГГц зображення плавніше.", + "wifi.tip.always": "Коли телефон запитає, чи дозволити бездротове налагодження, позначте «Завжди дозволяти в цій мережі».", + "wifi.tip.guest": "У гостьових мережах і мережах з ізоляцією пристроїв телефон не видно. Підключіть його за адресою або кабелем.", + "wifi.tip.hotspot": "Не працює, якщо комп’ютер виходить в інтернет через точку доступу цього телефона.", + "wifi.tip.vpn": "VPN на телефоні приховує його й змушує показувати не ту адресу. Використовуйте Wi-Fi-адресу телефона (Налаштування → Про телефон → Стан → IP-адреса) з портом із «Бездротового налагодження» або вимкніть VPN на час сполучення.", + "wifi.tip.firewall": "Першого разу Windows запитає про adb.exe: дозвольте доступ у приватних мережах.", + "wifi.err.badCode": "Код підключення має шість цифр", + "wifi.err.noPairingPhone": "Телефон не знайдено. Не закривайте вікно з кодом на телефоні або введіть IP-адресу й порт, указані під кодом.", + "wifi.err.qrTimeout": "Термін дії коду минув", + "wifi.err.pairedNotFound": "Сполучено, але телефон не оголосив себе в мережі (так буває, якщо на телефоні ввімкнено VPN). Введіть порт із рядка «IP-адреса та порт» на телефоні.", + "wifi.err.badAddress": "Введіть адресу у форматі IP:порт, наприклад 192.168.1.20:37215", + "wifi.err.noUsbPhone": "Спочатку під’єднайте телефон кабелем", + "wifi.err.phoneNotOnWifi": "Телефон не підключено до Wi-Fi. Увімкніть Wi-Fi на телефоні й спробуйте ще раз.", + "wifi.err.failed": "Не вдалося підключитися: {message}", }; export default uk; diff --git a/app/src/lib/locales/zh-CN.ts b/app/src/lib/locales/zh-CN.ts index 9ae9c03..b470c4e 100644 --- a/app/src/lib/locales/zh-CN.ts +++ b/app/src/lib/locales/zh-CN.ts @@ -4,7 +4,7 @@ const zhCN: Messages = { "app.settings": "设置", "app.back": "返回", "status.noPhone": "请连接手机", - "status.noPhone.hint": "使用 USB 数据线,并开启 USB 调试", + "status.noPhone.hint": "使用 USB 数据线并开启 USB 调试,或通过 Wi-Fi 连接", "status.unauthorized": "请在手机上允许调试", "status.unauthorized.hint": "在手机上点按“允许”,并勾选“一律允许”", "status.offline": "手机没有响应", @@ -12,6 +12,8 @@ const zhCN: Messages = { "status.ready": "就绪", "status.waiting": "正在等待手机", "status.connecting": "正在连接…", + "status.reconnecting": "正在通过 Wi-Fi 重新连接(第 {attempt} 次,共 {tries} 次)", + "status.reconnecting.hint": "手机恢复连接前,应用中会保留最后一帧画面", "status.streaming": "正在传输", "status.noPicture": "此镜头没有画面", "status.error": "错误", @@ -142,6 +144,52 @@ const zhCN: Messages = { "wiz.3.found": "{name} 已连接", "wiz.3.driver": "找不到手机?请安装 USB 驱动", "wiz.why": "手机上无需安装任何应用:Plugcam 通过 USB 调试工作。", + "phones.title": "手机和 Wi-Fi", + "phones.connectWifi": "通过 Wi-Fi 连接", + "phones.list": "手机", + "phones.auto": "自动选择,优先 USB", + "phones.usb": "USB 数据线", + "phones.wifi": "Wi-Fi", + "phones.wifi.plain": "Wi-Fi,未加密", + "phones.offline": "不在网络中。手机出现后 Plugcam 会自动连接。", + "phones.offline.plain": "未连接。手机重启后需要重新连接数据线。", + "phones.forget": "移除", + "phones.forget.note": "“移除”不会取消配对。如需彻底移除,请在手机的“无线调试”→“已配对的设备”中删除这台电脑。", + "wifi.add": "通过 Wi-Fi 连接", + "wifi.method.qr": "二维码", + "wifi.method.code": "配对码", + "wifi.method.cable": "通过数据线", + "wifi.qr.steps": "在手机上打开 设置 → 开发者选项 → 无线调试,开启后点按“使用二维码配对设备”,然后扫描此二维码。", + "wifi.qr.waiting": "正在等待手机扫描二维码…", + "wifi.qr.new": "新二维码", + "wifi.code.steps": "在手机上打开 设置 → 开发者选项 → 无线调试,点按“使用配对码配对设备”,然后输入显示的六位数字。", + "wifi.code.label": "配对码", + "wifi.code.address": "IP 地址和端口", + "wifi.code.address.hint": "仅在 Plugcam 无法自动找到手机时填写:配对码下方显示的地址", + "wifi.pair": "配对", + "wifi.pairing": "正在配对…", + "wifi.cable.steps": "手机已通过数据线连接,并与这台电脑连接同一个 Wi-Fi。Plugcam 会将它切换到 Wi-Fi,之后即可拔下数据线。", + "wifi.cable.warning": "此连接未加密,手机重启后会断开。请仅在自己的网络中使用。", + "wifi.cable.go": "切换到 Wi-Fi", + "wifi.address.title": "通过地址连接", + "wifi.address.hint": "适用于已配对但搜索不到的手机:输入“无线调试”页面顶部显示的 IP 地址和端口。", + "wifi.connect": "连接", + "wifi.done": "{name} 已通过 Wi-Fi 连接", + "wifi.tips": "找不到手机?", + "wifi.tip.network": "手机和这台电脑必须连接同一个 Wi-Fi。使用 5 GHz 画面更流畅。", + "wifi.tip.always": "手机询问是否允许无线调试时,请勾选“一律允许在此网络上进行调试”。", + "wifi.tip.guest": "访客网络和隔离设备的网络会让手机不可见。请通过地址连接或使用数据线。", + "wifi.tip.hotspot": "如果电脑通过手机热点上网,则无法使用。", + "wifi.tip.vpn": "手机上的 VPN 会让它无法被找到,并显示错误的地址。请使用手机的 Wi-Fi IP(设置 → 关于手机 → 状态信息 → IP 地址)和“无线调试”中的端口,或在配对时关闭 VPN。", + "wifi.tip.firewall": "首次使用时 Windows 会询问 adb.exe 的访问权限:请在专用网络上允许。", + "wifi.err.badCode": "配对码为六位数字", + "wifi.err.noPairingPhone": "找不到手机。请让配对码保持显示在手机屏幕上,或输入与其一同显示的 IP 地址和端口。", + "wifi.err.qrTimeout": "二维码已过期", + "wifi.err.pairedNotFound": "已配对,但手机没有在网络上广播自己(手机上的 VPN 可能导致这种情况)。请输入手机上“IP 地址和端口”中显示的端口。", + "wifi.err.badAddress": "请按 IP:端口 格式输入地址,例如 192.168.1.20:37215", + "wifi.err.noUsbPhone": "请先用数据线连接手机", + "wifi.err.phoneNotOnWifi": "手机未连接 Wi-Fi。请在手机上开启 Wi-Fi 后重试。", + "wifi.err.failed": "无法连接:{message}", }; export default zhCN; diff --git a/app/src/lib/status.ts b/app/src/lib/status.ts index 76e148c..5417d6a 100644 --- a/app/src/lib/status.ts +++ b/app/src/lib/status.ts @@ -29,6 +29,12 @@ export function statusInfo(s: Snapshot): StatusInfo { return { tone: "live", title: t("status.streaming"), hint: "" }; case "connecting": return { tone: "busy", title: t("status.connecting"), hint: "" }; + case "reconnecting": + return { + tone: "busy", + title: t("status.reconnecting", { attempt: s.status.attempt, tries: s.status.tries }), + hint: t("status.reconnecting.hint"), + }; case "waitingForDevice": case "stopped": return { tone: "warn", title: t("status.waiting"), hint: t("status.noPhone.hint") }; diff --git a/docs/SPEC.md b/docs/SPEC.md index 3f9ec50..2964c1a 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -117,12 +117,32 @@ ### 2.4. Wi-Fi -Два способа, оба на встроенной беспроводной отладке ADB. +Всё на встроенной беспроводной отладке ADB, на телефон ничего не ставится (`src-tauri/src/wifi.rs`). Окно «Телефоны и Wi-Fi» в боковой панели предлагает три способа. -1. **«Перейти на Wi-Fi», пока телефон подключён кабелем:** программа выполняет `adb tcpip 5555`, узнаёт IP телефона и делает `adb connect ip:5555`. Это надёжно, но после перезагрузки телефона повторяется через кабель. -2. **Без кабеля (Android 11+), через QR:** программа показывает QR-код `WIFI:T:ADB;S:<имя>;P:<пароль>;;`. Пользователь сканирует его в «Отладка по Wi-Fi → Подключить устройство с помощью QR-кода». Программа находит телефон через `adb mdns services` (службы `_adb-tls-pairing._tcp` и `_adb-tls-connect._tcp`), затем выполняет `adb pair` и `adb connect`. Это стандартная схема Android Studio. +1. **QR-код (Android 11+).** Программа показывает QR `WIFI:T:ADB;S:plugcam-<8 символов>;P:<10 символов>;;` — формат Android Studio. Пользователь сканирует его в «Отладка по Wi-Fi → Подключить устройство с помощью QR-кода». Программа ждёт службу `_adb-tls-pairing._tcp` с этим именем в `adb mdns services`, выполняет `adb pair`, затем находит `_adb-tls-connect._tcp` и делает `adb connect`. +2. **Код сопряжения (Android 11+).** Шесть цифр из «Подключить устройство с помощью кода». Адрес телефона программа ищет через mDNS сама; если сеть его не показывает, пользователь вводит IP:порт, который телефон показывает рядом с кодом. +3. **С кабеля.** Телефон подключён кабелем и сидит в той же сети: `adb tcpip 5555`, IP телефона из `ip addr show wlan0` (запасной вариант — `ip route`), `adb connect ip:5555` (до 6 попыток). Соединение не шифруется и живёт до перезагрузки телефона — интерфейс об этом предупреждает. -Программа запоминает сопряжённые телефоны и в следующий раз подключается сама. +Ещё есть «Подключиться по адресу» — для сопряжённого телефона, которого не видно через mDNS (гостевые сети, изоляция клиентов). + +**VPN на телефоне.** С включённым VPN телефон часто не объявляет `_adb-tls-connect._tcp`, а экран «Отладка по Wi-Fi» показывает адрес VPN (например `26.26.26.1:37075`), до которого ПК не достучаться; порт при этом верный. Поэтому: + +- если после сопряжения телефон не нашёлся, окно открывает «Подключиться по адресу» с уже подставленным Wi-Fi-IP, на котором прошло сопряжение (`:`), — остаётся ввести порт с телефона; +- если `adb pair`/`adb connect` по введённому адресу не удались, программа пробует тот же порт на других IP этого телефона: у службы mDNS того же типа с тем же портом, у сопряжённого в этом сеансе телефона и у телефона на кабеле (если он один или его серийник есть в mDNS-имени сопряжения). Каждая попытка ограничена 5 с; +- в подсказках: VPN на телефоне прячет его и показывает не тот адрес — взять Wi-Fi-IP из «О телефоне → Статус» или выключить VPN на время сопряжения. + +**Выбор телефона.** По умолчанию берётся первый готовый, USB раньше Wi-Fi. Можно закрепить конкретный телефон; выбор передаётся в конвейер явно. Wi-Fi-телефон запоминается по постоянному id (mDNS-имя `adb-…`, для «с кабеля» — `ip:5555`), а не по серийнику `ip:порт`: порт меняется при каждом перезапуске отладки, и программа каждый раз находит текущий серийник по id. `adb devices -l` разбирается с учётом пробелов в mDNS-именах (`adb-XXXX (2)._adb-tls-connect._tcp`). Сопряжённые телефоны запоминаются в настройках; телефон, которого нет в сети, показан как «не в сети», и программа подключает его, когда он появится. «Забыть» отключает телефон, переводит камеру на другой и больше его не подключает и не выбирает автоматически (список забытых id хранится в настройках и переживает перезапуск; телефон возвращается, если его выбрать или добавить снова), но сопряжение не снимает: для этого на телефоне нужно удалить ПК в «Отладка по Wi-Fi → Сопряжённые устройства» (окно об этом говорит). + +**Поток по Wi-Fi** (`pipeline.rs`): + +- ключевой кадр каждую секунду (`video_codec_options=i-frame-interval=1`), чтобы догнать поток можно было меньше чем за секунду; +- стартовый битрейт 6 Мбит/с (или меньше, если пользователь задал меньше). После зависания (если телефон при этом отвечает) или частых догонов следующая сессия получает 0,7 от битрейта, но не меньше 2 Мбит/с; после 5 минут трансляции без проблем следующая сессия получает шаг обратно, но не выше стартового; +- **догон:** программа сравнивает время кадра (PTS) со временем прихода. Если картинка отстала больше чем на 500 мс, кадры пропускаются до ключевого, пока отставание не упадёт до 250 мс. Пропуск длится не дольше 3 с: если не успевает сам ПК, новая задержка принимается за норму, а не догоняется бесконечно. Три догона за минуту означают, что сеть не тянет битрейт, и он понижается; +- таймауты длиннее, чем по USB: первый кадр — 10 с (USB — 6 с), зависание — 5 с (USB — 3 с). «Нет картинки» (камера не отдаёт кадры) отличается от сетевой задержки: по Wi-Fi пустой старт считается проблемой сети, если в логе сервера нет ошибки камеры. + +**Переподключение.** Если поток оборвался, программа проверяет телефон `adb shell echo ok` (4 с): мёртвое Wi-Fi-соединение долго висит в списке как `device`. Если телефон не отвечает — `adb disconnect`, 3 с ждём, что adb переподключит mDNS-телефон сам, потом ищем текущий адрес через mDNS (порт меняется при каждом перезапуске отладки) и делаем `adb connect`. До 5 попыток, статус «Переподключение по Wi-Fi (N из 5)»; `adb connect`/`disconnect` здесь ограничены 5 с, а остановка камеры прерывает переподключение между шагами. Если телефон вернулся под другим серийником (mDNS-имя → `ip:порт`), конвейер сообщает об этом: id телефона переносится на новый серийник, и выбранный телефон (он хранится по id) находится под ним; конвейер переходит на новый серийник, только если шёл именно этот телефон. При переходе на другой телефон битрейт и счётчик неудач начинаются заново. Сессии короче 10 с считаются неудачными: вторая подряд показывает ошибку, паузы растут 2 → 4 → 8 → 15 с. Виртуальная камера всё это время отдаёт последний кадр, поэтому видеосвязь не теряет устройство. + +**Подсказки про сеть** в окне: одна сеть у ПК и телефона (5 ГГц плавнее), «Всегда разрешать в этой сети», гостевые сети и изоляция клиентов, раздача интернета с телефона не подходит, разрешить adb.exe в брандмауэре для частных сетей. --- @@ -130,7 +150,7 @@ **Главный экран — одно окно примерно 420×560, тёмная и светлая тема как в системе, фон Mica на Windows 11:** -- Карточка телефона: модель, способ подключения (USB или Wi-Fi) и цветной статус: «Подключите телефон» / «Готово» / «Идёт трансляция» / «Ошибка — что делать». +- Карточка телефона: модель, способ подключения (USB или Wi-Fi) и цветной статус (по Wi-Fi ещё «Переподключение»): «Подключите телефон» / «Готово» / «Идёт трансляция» / «Ошибка — что делать». - Большой предпросмотр. - **Одна главная кнопка:** «Включить камеру» / «Выключить». - Под ней ряд небольших переключателей: основная/фронтальная камера (плюс выбор объектива, если их несколько; нерабочие камеры скрыты, см. 1.5), зеркало, фонарик, зум − / +. diff --git a/docs/releases/v0.2.0.md b/docs/releases/v0.2.0.md new file mode 100644 index 0000000..9dd03e8 --- /dev/null +++ b/docs/releases/v0.2.0.md @@ -0,0 +1,35 @@ +Plugcam now works over Wi-Fi, no cable needed. + +## What's new + +- **Wi-Fi.** Open *Phones and Wi-Fi* in the side panel and pick a way: + - **QR code** (Android 11+): on the phone, *Developer options → Wireless debugging → Pair device with QR code*, then scan the code Plugcam shows. + - **Pairing code** (Android 11+): enter the six digits from *Pair device with pairing code*. + - **From cable**: with the phone on the cable and on the same Wi-Fi, one click moves it to Wi-Fi and you can unplug. This link is not encrypted and ends when the phone restarts. +- **Remembered phones.** A paired phone connects by itself next time it shows up on the network. You can also pin which phone to use; by default Plugcam takes the first ready one, USB first. +- **No growing delay.** Over Wi-Fi the phone sends a key frame every second. When the picture falls more than half a second behind, Plugcam skips ahead to the next one instead of playing the backlog. If that keeps happening, it lowers the bit rate. +- **Reconnects on its own.** If the phone drops off the network, Plugcam tries to bring it back up to five times. Apps keep showing the last picture meanwhile, so a call doesn't lose the camera. +- Tips in the Wi-Fi window for when the phone is not found: same network, guest networks, the phone's hotspot, the Windows firewall prompt for adb.exe. + +## Install + +- **Already have Plugcam?** It offers this update by itself; click *Update*. To check right away: *Settings → Updates → Check now*. +- **Installer:** download `Plugcam_0.2.0_x64-setup.exe` below and run it. +- **Portable:** download `Plugcam_0.2.0_x64-portable.zip`, extract it anywhere and run `Plugcam.exe`. + +The files are not code-signed yet, so SmartScreen may warn you: *More info → Run anyway*. The `.sha256` files next to +them have the checksums. + +--- + + +## По-русски + +Plugcam теперь работает по Wi-Fi, без кабеля. + +- **Wi-Fi.** Откройте «Телефоны и Wi-Fi» в боковой панели и выберите способ: QR-код или код сопряжения (Android 11+, «Для разработчиков → Отладка по Wi-Fi»), либо «С кабеля» — одна кнопка переводит подключённый кабелем телефон на Wi-Fi. Последний способ не шифруется и работает до перезагрузки телефона. +- **Запомненные телефоны** подключаются сами, когда появляются в сети. Телефон можно закрепить; по умолчанию берётся первый готовый, USB раньше Wi-Fi. +- **Задержка не копится.** Если картинка отстала больше чем на полсекунды, Plugcam перескакивает к свежему кадру, а при частых отставаниях снижает битрейт. +- **Сам переподключается**, если телефон пропал из сети: до пяти попыток, приложения всё это время видят последний кадр. + +Если у вас уже стоит Plugcam, он сам предложит обновление — нажмите «Обновить». diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 1f21cbc..7dd1e9a 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2904,10 +2904,12 @@ version = "0.1.1" dependencies = [ "env_logger", "fast_image_resize", + "getrandom 0.3.4", "jiff", "jpeg-encoder", "libloading 0.8.9", "log", + "qrcode", "serde", "serde_json", "sha2 0.11.0", @@ -3067,6 +3069,12 @@ version = "0.1.30" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" +[[package]] +name = "qrcode" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d68782463e408eb1e668cf6152704bd856c78c5b6417adaee3203d8f4c1fc9ec" + [[package]] name = "quick-xml" version = "0.42.0" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 8aa5b45..8e4bf6a 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -19,10 +19,12 @@ path = "src/main.rs" [dependencies] env_logger = "0.11.11" fast_image_resize = "6.1.0" +getrandom = "0.3.4" jiff = { version = "0.2.37", default-features = false, features = ["std"] } jpeg-encoder = { version = "0.7.1", features = ["simd"] } libloading = "0.8" log = "0.4.34" +qrcode = { version = "0.14.1", default-features = false } serde = { version = "1.0.229", features = ["derive"] } serde_json = "1.0.151" sha2 = "0.11.0" diff --git a/src-tauri/examples/plugcam-cli.rs b/src-tauri/examples/plugcam-cli.rs index 5864f1a..f1e479d 100644 --- a/src-tauri/examples/plugcam-cli.rs +++ b/src-tauri/examples/plugcam-cli.rs @@ -154,6 +154,8 @@ fn run(args: Args) -> Result<(), String> { dump: args.dump, preview: None, on_zoom: None, + on_serial: None, + wanted: None, }, vcam, |_| {}, diff --git a/src-tauri/src/adb.rs b/src-tauri/src/adb.rs index 729af56..9ae1c82 100644 --- a/src-tauri/src/adb.rs +++ b/src-tauri/src/adb.rs @@ -1,14 +1,18 @@ -//! Thin wrapper around adb.exe: device list, push, tunnels and shell commands. +//! Thin wrapper around adb.exe: device list, push, tunnels, shell commands and the Wi-Fi +//! side (pairing, connecting, mDNS discovery). -use std::io; +use std::io::{self, Read}; use std::os::windows::process::CommandExt; use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; +use std::time::{Duration, Instant}; use crate::resources; /// Keeps adb from flashing a console window when called from the GUI app. const CREATE_NO_WINDOW: u32 = 0x0800_0000; +/// Longer than any normal command takes, even pushing the server over slow Wi-Fi. +const RUN_TIMEOUT: Duration = Duration::from_secs(30); #[derive(Debug, thiserror::Error)] pub enum AdbError { @@ -18,6 +22,14 @@ pub enum AdbError { Io(#[from] io::Error), #[error("adb {args} failed: {output}")] Failed { args: String, output: String }, + #[error("adb {args} did not finish within {after:?}")] + Timeout { args: String, after: Duration }, +} + +pub struct Output { + pub success: bool, + pub stdout: String, + pub stderr: String, } #[derive(Debug, Clone)] @@ -75,19 +87,56 @@ impl Adb { /// Runs adb to completion; a non-zero exit becomes `AdbError::Failed` with its output. pub fn run(&self, serial: Option<&str>, args: &[&str]) -> Result { - let out = self.command(serial).args(args).output()?; - let stdout = String::from_utf8_lossy(&out.stdout).into_owned(); - if out.status.success() { - Ok(stdout) + self.run_within(serial, args, RUN_TIMEOUT) + } + + /// `run` with its own time limit. + pub fn run_within(&self, serial: Option<&str>, args: &[&str], timeout: Duration) -> Result { + let out = self.output(serial, args, timeout)?; + if out.success { + Ok(out.stdout) } else { - let stderr = String::from_utf8_lossy(&out.stderr); Err(AdbError::Failed { args: args.join(" "), - output: format!("{}{}", stderr.trim(), stdout.trim()), + output: format!("{}{}", out.stderr.trim(), out.stdout.trim()), }) } } + /// Runs adb and collects its output, killing it after `timeout`: a command for a Wi-Fi + /// phone that has just left the network can otherwise wait for minutes. + pub fn output(&self, serial: Option<&str>, args: &[&str], timeout: Duration) -> Result { + let mut child = self.command(serial).args(args).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn()?; + let read_all = |pipe: Option>| { + std::thread::spawn(move || { + let mut buf = Vec::new(); + if let Some(mut p) = pipe { + let _ = p.read_to_end(&mut buf); + } + String::from_utf8_lossy(&buf).into_owned() + }) + }; + let stdout = read_all(child.stdout.take().map(|p| Box::new(p) as Box)); + let stderr = read_all(child.stderr.take().map(|p| Box::new(p) as Box)); + let deadline = Instant::now() + timeout; + let status = loop { + if let Some(status) = child.try_wait()? { + break status; + } + if Instant::now() > deadline { + let _ = child.kill(); + let _ = child.wait(); + return Err(AdbError::Timeout { args: args.join(" "), after: timeout }); + } + std::thread::sleep(Duration::from_millis(10)); + }; + Ok(Output { + success: status.success(), + stdout: stdout.join().unwrap_or_default(), + stderr: stderr.join().unwrap_or_default(), + }) + } + pub fn devices(&self) -> Result, AdbError> { Ok(parse_devices(&self.run(None, &["devices", "-l"])?)) } @@ -116,32 +165,197 @@ impl Adb { pub fn shell(&self, serial: &str, command: &str) -> Result { self.run(Some(serial), &["shell", command]) } + + /// `adb connect host:port`. Older adb versions exit 0 even when it fails, so the output + /// decides. + pub fn connect(&self, address: &str) -> Result<(), AdbError> { + self.connect_within(address, RUN_TIMEOUT) + } + + /// `connect` that gives up after `timeout`; a phone that left the network can keep adb + /// trying for a long time. + pub fn connect_within(&self, address: &str, timeout: Duration) -> Result<(), AdbError> { + let out = self.run_within(None, &["connect", address], timeout)?; + if out.contains("connected to") && !out.contains("failed") && !out.contains("cannot") { + Ok(()) + } else { + Err(AdbError::Failed { args: format!("connect {address}"), output: out.trim().to_string() }) + } + } + + pub fn disconnect(&self, serial: &str) -> Result<(), AdbError> { + self.disconnect_within(serial, RUN_TIMEOUT) + } + + pub fn disconnect_within(&self, serial: &str, timeout: Duration) -> Result<(), AdbError> { + self.run_within(None, &["disconnect", serial], timeout).map(drop) + } + + /// Pairs with a phone in "Pair device" mode (Android 11+). Returns the phone's mDNS name + /// (`adb--`), which stays the same across networks and restarts. + pub fn pair(&self, address: &str, code: &str) -> Result, AdbError> { + self.pair_within(address, code, RUN_TIMEOUT) + } + + pub fn pair_within(&self, address: &str, code: &str, timeout: Duration) -> Result, AdbError> { + let fail = |output: String| AdbError::Failed { args: format!("pair {address}"), output }; + let out = self.run_within(None, &["pair", address, code], timeout).map_err(|e| match e { + AdbError::Failed { output, .. } => fail(output), + e => e, + })?; + if !out.contains("Successfully paired") { + return Err(fail(out.trim().to_string())); + } + Ok(out.split_once("[guid=").and_then(|(_, g)| g.split(']').next()).map(str::to_string)) + } + + /// Phones announcing wireless debugging on the local network. + pub fn mdns_services(&self) -> Result, AdbError> { + self.mdns_services_within(RUN_TIMEOUT) + } + + pub fn mdns_services_within(&self, timeout: Duration) -> Result, AdbError> { + Ok(parse_mdns_services(&self.run_within(None, &["mdns", "services"], timeout)?)) + } + + /// Switches a phone connected by cable to plain TCP adb on `port` (lost on reboot). + pub fn tcpip(&self, serial: &str, port: u16) -> Result<(), AdbError> { + self.run(Some(serial), &["tcpip", &port.to_string()]).map(drop) + } + + /// The phone's IPv4 address on Wi-Fi, if it is on Wi-Fi. + pub fn wifi_ip(&self, serial: &str) -> Option { + let addr = self.shell(serial, "ip -f inet addr show wlan0").ok().and_then(|o| parse_inet(&o)); + addr.or_else(|| self.shell(serial, "ip route").ok().and_then(|o| parse_route_src(&o))) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum MdnsKind { + /// `_adb-tls-pairing._tcp`: the phone shows a pairing code or scanned a QR code. + Pairing, + /// `_adb-tls-connect._tcp`: a phone with wireless debugging on, ready for `adb connect`. + Connect, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MdnsService { + /// `adb--` for phones, or the name in our QR code while pairing by QR. + pub name: String, + pub kind: MdnsKind, + /// `ip:port`. + pub address: String, +} + +impl MdnsService { + pub fn ip(&self) -> &str { + self.address.rsplit_once(':').map_or(&self.address, |(ip, _)| ip) + } +} + +/// Parses `adb mdns services`: `nametypeip:port` per line. +pub fn parse_mdns_services(output: &str) -> Vec { + output + .lines() + .filter_map(|line| { + // Names may contain spaces; the type and the address never do. + let (rest, address) = line.trim_end().rsplit_once(char::is_whitespace)?; + let (name, kind) = rest.trim_end().rsplit_once(char::is_whitespace)?; + let kind = match kind.trim_end_matches('.') { + "_adb-tls-pairing._tcp" => MdnsKind::Pairing, + "_adb-tls-connect._tcp" => MdnsKind::Connect, + _ => return None, + }; + let name = name.trim(); + (!name.is_empty() && address.contains(':')).then(|| MdnsService { name: name.into(), kind, address: address.into() }) + }) + .collect() +} + +/// `inet 192.168.1.20/24 brd …` → `192.168.1.20`. +fn parse_inet(output: &str) -> Option { + output.split_whitespace().skip_while(|w| *w != "inet").nth(1).and_then(|a| a.split('/').next()).map(str::to_string) +} + +/// `192.168.1.0/24 dev wlan0 proto kernel scope link src 192.168.1.20` → `192.168.1.20`. +fn parse_route_src(output: &str) -> Option { + let line = output.lines().find(|l| l.contains("wlan"))?; + line.split_whitespace().skip_while(|w| *w != "src").nth(1).map(str::to_string) +} + +/// The phone to use: the preferred one if it is ready, else the first ready one on USB, else +/// the first ready one on Wi-Fi. +pub fn choose<'a>(devices: impl IntoIterator, preferred: Option<&str>) -> Option<&'a Device> { + let ready: Vec<&Device> = devices.into_iter().filter(|d| d.is_ready()).collect(); + preferred + .and_then(|p| ready.iter().find(|d| d.serial == p)) + .or_else(|| ready.iter().find(|d| !d.is_wifi())) + .or(ready.first()) + .copied() +} + +/// States `adb devices` prints after the serial. mDNS serials can contain spaces +/// (`adb-R58N-AbCd (2)._adb-tls-connect._tcp`), so the state word is what ends the serial. +const STATES: [&str; 11] = [ + "device", "offline", "unauthorized", "authorizing", "connecting", "bootloader", "recovery", + "sideload", "rescue", "host", "unknown", +]; + /// Parses `adb devices -l`. pub fn parse_devices(output: &str) -> Vec { output .lines() .skip_while(|l| !l.starts_with("List of devices attached")) .skip(1) - .filter_map(|line| { - let mut words = line.split_whitespace(); - let serial = words.next()?.to_string(); - // "no permissions (...)" has spaces; the state is everything up to the first key:value. - let mut state = Vec::new(); - let mut model = None; - for w in words { - if let Some(m) = w.strip_prefix("model:") { - model = Some(m.replace('_', " ")); - } else if !w.contains(':') && model.is_none() && state.len() < 8 { - state.push(w); - } - } - Some(Device { serial, state: state.join(" "), model }) - }) + .filter_map(parse_device_line) .collect() } +fn parse_device_line(line: &str) -> Option { + let words = words_with_offsets(line); + // The first word is always part of the serial, even if it happens to be "device". + let at = (1..words.len()).find(|&i| { + let w = words[i].1; + STATES.contains(&w) || (w == "no" && words.get(i + 1).is_some_and(|n| n.1 == "permissions")) + })?; + let serial = line[..words[at].0].trim().to_string(); + // "no permissions (...)" has spaces; the state is everything up to the first key:value. + let mut state = Vec::new(); + let mut model = None; + for &(_, w) in &words[at..] { + if let Some(m) = w.strip_prefix("model:") { + model = Some(m.replace('_', " ")); + } else if !is_property(w) && model.is_none() && state.len() < 8 { + state.push(w); + } + } + Some(Device { serial, state: state.join(" "), model }) +} + +/// `usb:1-1`, `product:x`, `transport_id:7`… +fn is_property(word: &str) -> bool { + word.split_once(':').is_some_and(|(k, _)| !k.is_empty() && k.bytes().all(|b| b.is_ascii_lowercase() || b == b'_')) +} + +fn words_with_offsets(line: &str) -> Vec<(usize, &str)> { + let mut words = Vec::new(); + let mut start = None; + for (i, c) in line.char_indices() { + match (c.is_whitespace(), start) { + (true, Some(s)) => { + words.push((s, &line[s..i])); + start = None; + } + (false, None) => start = Some(i), + _ => {} + } + } + if let Some(s) = start { + words.push((s, &line[s..])); + } + words +} + #[cfg(test)] mod tests { use super::*; @@ -165,6 +379,68 @@ mod tests { assert!(!devices[2].is_ready()); } + #[test] + fn mdns_serials_with_spaces() { + let out = "List of devices attached + adb-834ee2d7-Xy1AbC (2)._adb-tls-connect._tcp device product:PHK110 model:PHK110 device:OP5913L1 transport_id:3 + adb-834ee2d7-Xy1AbC._adb-tls-connect._tcp offline transport_id:4 + device device product:p model:Odd_Serial transport_id:5 + ABC123 no permissions (missing udev rules? user is in the plugdev group); see [http://x] usb:1-2 transport_id:6 +"; + let devices = parse_devices(out); + assert_eq!(devices.len(), 4); + assert_eq!(devices[0].serial, "adb-834ee2d7-Xy1AbC (2)._adb-tls-connect._tcp"); + assert!(devices[0].is_ready() && devices[0].is_wifi()); + assert_eq!(devices[0].model.as_deref(), Some("PHK110")); + assert_eq!(devices[1].serial, "adb-834ee2d7-Xy1AbC._adb-tls-connect._tcp"); + assert_eq!(devices[1].state, "offline"); + assert_eq!(devices[2].serial, "device"); + assert!(devices[2].is_ready()); + assert_eq!(devices[3].serial, "ABC123"); + assert!(devices[3].state.starts_with("no permissions")); + assert!(!devices[3].is_ready()); + } + + #[test] + fn mdns_list() { + let out = "List of discovered mdns services\n\ + adb-834ee2d7-Xy1AbC\t_adb-tls-connect._tcp\t192.168.1.20:37899\n\ + plugcam-k3j9\t_adb-tls-pairing._tcp.\t192.168.1.20:41234\n\ + adb-834ee2d7-Xy1AbC (2)\t_adb-tls-connect._tcp\t192.168.1.21:5555\n\ + something\t_other._tcp\t1.2.3.4:1\n"; + let s = parse_mdns_services(out); + assert_eq!(s.len(), 3); + assert_eq!( + s[0], + MdnsService { name: "adb-834ee2d7-Xy1AbC".into(), kind: MdnsKind::Connect, address: "192.168.1.20:37899".into() } + ); + assert_eq!(s[1].kind, MdnsKind::Pairing); + assert_eq!(s[1].name, "plugcam-k3j9"); + assert_eq!(s[1].ip(), "192.168.1.20"); + assert_eq!(s[2].name, "adb-834ee2d7-Xy1AbC (2)"); + assert!(parse_mdns_services("List of discovered mdns services\n").is_empty()); + } + + #[test] + fn phone_ip() { + let addr = "37: wlan0: mtu 1500\n inet 192.168.1.20/24 brd 192.168.1.255 scope global wlan0\n"; + assert_eq!(parse_inet(addr).as_deref(), Some("192.168.1.20")); + assert_eq!(parse_inet(""), None); + let route = "10.0.0.0/8 dev rmnet0 src 10.1.2.3\n192.168.1.0/24 dev wlan0 proto kernel scope link src 192.168.1.20\n"; + assert_eq!(parse_route_src(route).as_deref(), Some("192.168.1.20")); + } + + #[test] + fn choose_prefers_the_chosen_ready_phone() { + let d = |serial: &str, state: &str| Device { serial: serial.into(), state: state.into(), model: None }; + let list = [d("1.2.3.4:5555", "device"), d("abc", "unauthorized"), d("usb1", "device")]; + assert_eq!(choose(&list, None).unwrap().serial, "usb1"); + assert_eq!(choose(&list, Some("1.2.3.4:5555")).unwrap().serial, "1.2.3.4:5555"); + assert_eq!(choose(&list, Some("abc")).unwrap().serial, "usb1"); + assert_eq!(choose(&list, Some("gone")).unwrap().serial, "usb1"); + assert!(choose(&list[1..2], None).is_none()); + } + #[test] fn empty_list() { assert!(parse_devices("List of devices attached\n\n").is_empty()); diff --git a/src-tauri/src/app/controller.rs b/src-tauri/src/app/controller.rs index d096577..d1775fc 100644 --- a/src-tauri/src/app/controller.rs +++ b/src-tauri/src/app/controller.rs @@ -6,8 +6,9 @@ //! overlap: otherwise a second start could try to create the camera while the first pipeline //! still owns it. -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::path::PathBuf; +use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::mpsc::{self, Sender}; use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; @@ -15,7 +16,7 @@ use std::time::{Duration, Instant}; use serde::Serialize; use tauri::{AppHandle, Emitter}; -use crate::adb::{Adb, Device}; +use crate::adb::{self, Adb, Device, MdnsKind}; use crate::pipeline::{Pipeline, PipelineConfig, Status}; use crate::preview::PreviewSlot; use crate::scrcpy::cameras::{self, CameraInfo, CaptureMode, FpsOption}; @@ -23,6 +24,7 @@ use crate::scrcpy::protocol::ControlMessage; use crate::scrcpy::{server, zoom}; use crate::settings::{Settings, VCAM_SIZES}; use crate::vcam::VirtualCamera; +use crate::wifi::{self, QrPairing}; use super::i18n; use crate::{platform, portable, resources}; @@ -30,6 +32,16 @@ const DEVICE_POLL: Duration = Duration::from_millis(1500); /// After this long without zoom messages, the zoom the phone reported is trusted over the /// steps counted here (a message sent before the camera was ready is dropped by the server). const ZOOM_RESYNC: Duration = Duration::from_secs(1); +/// How often remembered Wi-Fi phones that are not connected are looked for. +const AUTO_CONNECT_PERIOD: Duration = Duration::from_secs(10); +/// How long the QR code waits for the phone to scan it. +const QR_WAIT: Duration = Duration::from_secs(180); +/// After pairing, how long to wait for the phone to show up for connecting. +const PAIRED_CONNECT_WAIT: Duration = Duration::from_secs(15); +/// `adb pair` per address tried; the key exchange can take a few seconds on a slow network. +const PAIR_TIMEOUT: Duration = Duration::from_secs(10); +/// Our QR codes announce themselves as `plugcam-…` while pairing; phones as `adb-…`. +const QR_NAME_PREFIX: &str = "plugcam-"; /// What the window needs to draw itself. Sent whole on every change (it is small). #[derive(Debug, Clone, Serialize, PartialEq)] @@ -54,6 +66,12 @@ pub struct Snapshot { pub high_speed_offered: bool, pub torch: bool, pub fps: f32, + /// Bit rate the phone streams at, in bits per second; 0 when not streaming. + pub bitrate: u32, + /// Every phone adb lists, to pick one from. + pub devices: Vec, + /// Phones remembered for Wi-Fi and whether each is connected now. + pub wifi_phones: Vec, /// Camera zoom as the phone last reported it. pub zoom: f32, /// Zoom range of the running lens, when known. @@ -86,6 +104,17 @@ pub struct DeviceView { pub state: String, } +#[derive(Debug, Clone, Serialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct WifiPhoneView { + pub id: String, + pub name: Option, + /// The serial it is connected under, when it is connected. + pub serial: Option, + /// Switched over from the cable (`adb tcpip`): not encrypted, gone after the phone restarts. + pub plain: bool, +} + #[derive(Debug, Clone, Serialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct CameraView { @@ -108,6 +137,8 @@ pub enum Notice { enum Event { Status(Status), Zoom(f32), + /// A Wi-Fi phone came back under another serial. + Serial { old: String, new: String }, } /// Per-phone facts fetched once per connection. @@ -134,6 +165,11 @@ struct State { fetching: Vec, torch: bool, fps: f32, + bitrate: u32, + /// Remembered Wi-Fi phone ids of connected `ip:port` serials (mDNS serials carry theirs). + wifi_ids: HashMap, + last_auto_connect: Option, + auto_connecting: bool, /// Reported by the phone; also passed to the next pipeline so a restart keeps it. zoom: f32, /// The step counted after the last zoom messages, and when they were sent. @@ -154,6 +190,16 @@ pub struct Controller { server_file: Result, settings_path: PathBuf, status_tx: Sender<(u64, Event)>, + /// The QR code on screen, tagged so a wait for an older one gives up. + qr: Mutex>, + qr_counter: AtomicU64, + /// Wi-Fi phones the user forgot: neither shown as the active phone nor picked or reconnected + /// by pipelines. Not in `state`, since pipelines read it (see the locking rule); taken after + /// `state` when both are needed. + forgotten: Arc>, + /// The last phone paired in this session: its mDNS name (when adb gave it) and the Wi-Fi IP + /// it was paired at. Used when the address the phone shows does not work (a VPN on it). + paired: Mutex, String)>>, mica: bool, /// Windows locale, e.g. `ru-RU`. system_locale: String, @@ -162,6 +208,7 @@ pub struct Controller { impl Controller { pub fn new(app: AppHandle, settings_path: PathBuf) -> Arc { let settings = Settings::load(&settings_path); + let forgotten = Forgotten { ids: settings.forgotten_wifi.iter().cloned().collect(), serials: HashSet::new() }; let adb = Adb::locate().map_err(|e| log::error!("{e}")).ok(); let server_file = server::server_file().map_err(|e| e.to_string()); let registered = platform::vcam_registered_path().is_some(); @@ -192,6 +239,10 @@ impl Controller { fetching: Vec::new(), torch: false, fps: 0.0, + bitrate: 0, + wifi_ids: HashMap::new(), + last_auto_connect: None, + auto_connecting: false, zoom: 1.0, zoom_sent: None, zoom_range: None, @@ -205,6 +256,10 @@ impl Controller { server_file, settings_path, status_tx, + qr: Mutex::new(None), + qr_counter: AtomicU64::new(0), + forgotten: Arc::new(Mutex::new(forgotten)), + paired: Mutex::new(None), mica: platform::windows_build() >= 22000, system_locale: sys_locale::get_locale().unwrap_or_default(), }); @@ -217,6 +272,7 @@ impl Controller { match event { Event::Status(status) => c.on_pipeline_status(generation, status), Event::Zoom(z) => c.on_zoom(generation, z), + Event::Serial { old, new } => c.on_serial_change(&old, &new), } } }) @@ -282,7 +338,7 @@ impl Controller { /// Phone name and Android version for the new-issue form, when a phone is connected. pub fn phone_summary(&self) -> Option { let st = self.state.lock().unwrap(); - let d = active_device(&st.devices)?; + let d = st.active(&self.forgotten.lock().unwrap())?; let phone = st.phones.get(&d.serial); let name = phone.and_then(|p| p.name.clone()).or_else(|| d.model.clone())?; Some(match phone.and_then(|p| p.android.as_deref()) { @@ -292,14 +348,15 @@ impl Controller { } fn build_snapshot(&self, st: &State) -> Snapshot { - let device = active_device(&st.devices).map(|d| DeviceView { + let view = |d: &Device| DeviceView { serial: d.serial.clone(), model: d.model.clone().unwrap_or_else(|| d.serial.clone()), name: st.phones.get(&d.serial).and_then(|p| p.name.clone()), wifi: d.is_wifi(), state: d.state.clone(), - }); - let usable = usable_cameras(st); + }; + let device = st.active(&self.forgotten.lock().unwrap()).map(view); + let usable = usable_cameras(st, &self.forgotten.lock().unwrap()); let selected = selected_camera(&st.settings, &usable); let s = &st.settings; let (resolutions, fps_options) = match selected { @@ -334,6 +391,19 @@ impl Controller { high_speed_offered: selected.is_some_and(|c| cameras::sixty_needs_high_speed(c, (s.vcam_width, s.vcam_height))), torch: st.torch, fps: st.fps, + bitrate: st.bitrate, + devices: st.devices.iter().map(view).collect(), + wifi_phones: st + .settings + .wifi_phones + .iter() + .map(|p| WifiPhoneView { + id: p.id.clone(), + name: p.name.clone(), + serial: connected_serial(st, &p.id), + plain: is_plain(&p.id), + }) + .collect(), zoom: st.zoom, zoom_range: st.zoom_range, problem: st.problem.clone(), @@ -427,7 +497,7 @@ impl Controller { } let mut params = st.settings.camera_params(); - let usable = usable_cameras(st); + let usable = usable_cameras(st, &self.forgotten.lock().unwrap()); let selected = selected_camera(&st.settings, &usable); let zoom_range = selected.and_then(|c| c.zoom); // Without the lens list the server picks the lens by facing, at a size every phone has. @@ -451,10 +521,12 @@ impl Controller { let generation = st.generation; let tx = self.status_tx.clone(); let zoom_tx = self.status_tx.clone(); + let serial_tx = self.status_tx.clone(); + let forgotten = self.forgotten.clone(); let config = PipelineConfig { adb, server_file, - serial: None, + serial: st.picked_serial(), camera: params, mirror: st.settings.mirror, rotation: st.settings.rotation, @@ -464,6 +536,10 @@ impl Controller { on_zoom: Some(Arc::new(move |z| { let _ = zoom_tx.send((generation, Event::Zoom(z))); })), + on_serial: Some(Arc::new(move |old, new| { + let _ = serial_tx.send((generation, Event::Serial { old: old.into(), new: new.into() })); + })), + wanted: Some(Arc::new(move |serial| !forgotten.lock().unwrap().contains(serial))), }; let vcam = st.vcam.take().expect("vcam created above"); st.status = Some(Status::WaitingForDevice); @@ -495,11 +571,32 @@ impl Controller { } if !matches!(status, Status::Streaming { .. }) { st.fps = 0.0; + st.bitrate = 0; } st.status = Some(status); self.publish(&mut st); } + /// Keeps a phone's Wi-Fi id when a pipeline got it back under a new serial, so the picked + /// phone (stored by id) still resolves to it in the window and in the running pipeline. + fn on_serial_change(&self, old: &str, new: &str) { + let mut st = self.state.lock().unwrap(); + let id = st.wifi_ids.remove(old).or_else(|| wifi::mdns_name(old).map(str::to_string)); + if let Some(id) = id { + st.wifi_ids.insert(new.to_string(), id); + } + self.follow_picked(&st); + self.publish(&mut st); + } + + /// Points the running pipeline at the picked phone's current serial once it is connected; + /// its address may have changed since the pipeline started. + fn follow_picked(&self, st: &State) { + if let (Some(p), Some(serial)) = (&st.pipeline, st.picked_ready()) { + p.set_preferred(Some(serial)); + } + } + fn on_zoom(&self, generation: u64, z: f32) { let mut st = self.state.lock().unwrap(); if generation == st.generation && st.camera_on { @@ -533,7 +630,7 @@ impl Controller { if old.detailed_log != new.detailed_log { crate::diag::set_detailed(new.detailed_log); } - let usable = usable_cameras(&st); + let usable = usable_cameras(&st, &self.forgotten.lock().unwrap()); let mode = |s: &Settings| selected_camera(s, &usable).and_then(|c| capture_mode(s, c)); let needs_restart = old.facing != new.facing || old.camera_id != new.camera_id @@ -649,10 +746,16 @@ impl Controller { let list: Vec = devices.iter().map(|d| format!("{} {}", d.serial, d.state)).collect(); log::info!("phones: {}", if list.is_empty() { "none".into() } else { list.join(", ") }); } - let was_ready = active_device(&st.devices).is_some_and(Device::is_ready); + let was_ready = st.active(&self.forgotten.lock().unwrap()).is_some_and(Device::is_ready); st.phones.retain(|serial, _| devices.iter().any(|d| &d.serial == serial && d.is_ready())); + // Kept while the phone is off the network, so it is known again when it comes back. + let State { wifi_ids, settings, .. } = &mut *st; + wifi_ids.retain(|_, id| settings.wifi_phones.iter().any(|p| &p.id == id)); st.devices = devices; - let now_ready = active_device(&st.devices).filter(|d| d.is_ready()).cloned(); + let now_ready = st.active(&self.forgotten.lock().unwrap()).filter(|d| d.is_ready()).cloned(); + self.follow_picked(&st); + self.name_wifi_phones(&mut st); + self.auto_connect(&mut st, &adb); if let Some(d) = &now_ready { if !st.phones.contains_key(&d.serial) && !st.fetching.contains(&d.serial) { @@ -662,7 +765,9 @@ impl Controller { } } if let Some(p) = st.pipeline.as_ref() { - st.fps = if matches!(st.status, Some(Status::Streaming { .. })) { p.stats().fps } else { 0.0 }; + let stats = p.stats(); + let streaming = matches!(st.status, Some(Status::Streaming { .. })); + (st.fps, st.bitrate) = if streaming { (stats.fps, stats.bitrate) } else { (0.0, 0) }; } let auto_start = !was_ready && now_ready.is_some() && st.settings.auto_start_camera && !st.camera_on; self.publish(&mut st); @@ -674,6 +779,367 @@ impl Controller { } } + /// Keeps the names of remembered Wi-Fi phones up to date once they are fetched. + fn name_wifi_phones(&self, st: &mut State) { + let mut changed = false; + for d in st.devices.iter().filter(|d| d.is_ready()) { + let (Some(id), Some(name)) = (wifi_id_of(st, d), st.phones.get(&d.serial).and_then(|p| p.name.clone())) else { + continue; + }; + if let Some(p) = st.settings.wifi_phones.iter_mut().find(|p| p.id == id && p.name.as_ref() != Some(&name)) { + p.name = Some(name); + changed = true; + } + } + if changed { + self.save(&st.settings); + } + } + + /// Now and then, connects remembered Wi-Fi phones that are on the network but not connected. + /// Paired phones are found by their mDNS name, so a new address does not matter. + fn auto_connect(self: &Arc, st: &mut State, adb: &Adb) { + if st.auto_connecting || st.last_auto_connect.is_some_and(|t| t.elapsed() < AUTO_CONNECT_PERIOD) { + return; + } + let missing: Vec = + st.settings.wifi_phones.iter().filter(|p| connected_serial(st, &p.id).is_none()).map(|p| p.id.clone()).collect(); + st.last_auto_connect = Some(Instant::now()); + if missing.is_empty() { + return; + } + st.auto_connecting = true; + let (c, adb) = (self.clone(), adb.clone()); + std::thread::spawn(move || { + let connected: Vec<(String, String)> = + missing.into_iter().filter_map(|id| connect_known(&adb, &id).map(|serial| (serial, id))).collect(); + let mut unwanted = Vec::new(); + let mut st = c.state.lock().unwrap(); + for (serial, id) in connected { + if st.settings.wifi_phones.iter().any(|p| p.id == id) { + log::info!("connected {id} over Wi-Fi as {serial}"); + st.wifi_ids.insert(serial, id); + } else { + unwanted.push(serial); // forgotten while it was being connected + } + } + st.auto_connecting = false; + st.last_auto_connect = Some(Instant::now()); + drop(st); + for serial in unwanted { + let _ = adb.disconnect(&serial); + } + }); + } + + fn adb(&self) -> Result { + self.adb.clone().ok_or_else(|| "adb.exe not found".to_string()) + } + + /// Picks the phone to use (`None`: the first ready one, USB first). A Wi-Fi phone is stored + /// by its id, so the choice holds when its address changes. + pub fn select_phone(&self, serial: Option) { + let restart = { + let mut st = self.state.lock().unwrap(); + let key = serial.as_deref().map(|s| match st.devices.iter().find(|d| d.serial == s) { + Some(d) => phone_key(d, &st.wifi_ids), + None => wifi::mdns_name(s).unwrap_or(s).to_string(), + }); + if let (Some(key), Some(serial)) = (&key, &serial) { + // Picking a forgotten phone that adb connected by itself takes it back. + st.settings.forgotten_wifi.retain(|i| i != key); + let mut forgotten = self.forgotten.lock().unwrap(); + forgotten.remove(key); + forgotten.remove(serial); + } + let changed = st.settings.phone != key; + st.settings.phone = key; + self.save(&st.settings); + let target = st.picked_serial(); + if let Some(p) = &st.pipeline { + p.set_preferred(target.clone()); + } + self.publish(&mut st); + // A picked phone the pipeline is on already (found automatically) needs no restart; + // one it is not on does, even when picked again, so a click brings it back. + let on_it = target.is_some() && st.status.as_ref().and_then(status_serial) == target.as_deref(); + st.camera_on && if target.is_some() { !on_it } else { changed } + }; + if restart { + self.restart(); + } + } + + /// A new QR code for pairing; the phone scans it under Wireless debugging → "Pair device + /// with QR code". Returns it as SVG. + pub fn wifi_qr_start(&self) -> String { + let pairing = QrPairing::new(); + let svg = pairing.svg(); + let id = self.qr_counter.fetch_add(1, Ordering::Relaxed) + 1; + *self.qr.lock().unwrap() = Some((id, pairing)); + svg + } + + pub fn wifi_qr_cancel(&self) { + *self.qr.lock().unwrap() = None; + } + + /// Waits for the phone to scan the QR code on screen, then pairs and connects. Returns the + /// phone's serial. Fails with `cancelled` when the code was closed or replaced. + pub fn wifi_qr_wait(&self) -> Result { + let adb = self.adb()?; + let (id, pairing) = self.qr.lock().unwrap().clone().ok_or("cancelled")?; + let current = || self.qr.lock().unwrap().as_ref().is_some_and(|(i, _)| *i == id); + let until = Instant::now() + QR_WAIT; + loop { + if !current() { + return Err("cancelled".into()); + } + if Instant::now() > until { + return Err("qrTimeout".into()); + } + let found = adb + .mdns_services() + .unwrap_or_default() + .into_iter() + .find(|s| s.kind == MdnsKind::Pairing && s.name == pairing.name); + if let Some(service) = found { + let guid = adb.pair(&service.address, &pairing.password).map_err(|e| e.to_string()); + if current() { + *self.qr.lock().unwrap() = None; + } + return self.finish_pairing(&adb, guid?, service.ip()); + } + std::thread::sleep(Duration::from_secs(1)); + } + } + + /// Pairs with the six-digit code the phone shows under Wireless debugging → "Pair device with + /// pairing code". Without an address the phone is looked up on the network. + pub fn wifi_pair_code(&self, code: &str, address: Option<&str>) -> Result { + let adb = self.adb()?; + let code: String = code.chars().filter(char::is_ascii_digit).collect(); + if code.len() != 6 { + return Err("badCode".into()); + } + let address = match address.map(str::trim).filter(|a| !a.is_empty()) { + Some(a) => a.to_string(), + None => find_pairing_phone(&adb).ok_or("noPairingPhone")?, + }; + let (address, guid) = + self.try_addresses(&adb, &address, MdnsKind::Pairing, |a| adb.pair_within(a, &code, PAIR_TIMEOUT))?; + let ip = address.rsplit_once(':').map_or(address.as_str(), |(ip, _)| ip).to_string(); + self.finish_pairing(&adb, guid, &ip) + } + + /// After pairing: connects the phone (adb usually does it by itself once the phone announces + /// itself on mDNS), remembers it and switches to it. + /// Fails with `pairedNotFound:` when the phone does not announce itself for connecting + /// (a VPN on the phone does that): the window then asks for the port at that IP. + fn finish_pairing(&self, adb: &Adb, guid: Option, ip: &str) -> Result { + log::info!("paired with {guid:?} at {ip}"); + *self.paired.lock().unwrap() = Some((guid.clone(), ip.to_string())); + let until = Instant::now() + PAIRED_CONNECT_WAIT; + let serial = loop { + let devices = adb.devices().unwrap_or_default(); + let by_mdns = devices.iter().find(|d| d.is_ready() && guid.is_some() && wifi::mdns_name(&d.serial) == guid.as_deref()); + if let Some(d) = by_mdns { + break d.serial.clone(); + } + let service = adb.mdns_services().unwrap_or_default().into_iter().find(|s| { + s.kind == MdnsKind::Connect && guid.as_ref().map_or(s.ip() == ip, |g| &s.name == g) + }); + if let Some(s) = service + && adb.connect(&s.address).is_ok() + { + break s.address; + } + if Instant::now() > until { + return Err(format!("pairedNotFound:{ip}")); + } + std::thread::sleep(Duration::from_secs(1)); + }; + let id = guid.unwrap_or_else(|| serial.clone()); + self.use_wifi_phone(&serial, &id, None); + Ok(serial) + } + + /// Connects to `ip:port` from the Wireless debugging screen, for networks where phones cannot + /// be found by name. The phone must have been paired before. + pub fn wifi_connect(&self, address: &str) -> Result { + let adb = self.adb()?; + let address = address.trim(); + if !address.contains(':') { + return Err("badAddress".into()); + } + let (address, ()) = + self.try_addresses(&adb, address, MdnsKind::Connect, |a| { + // A failed connect can leave an `offline` transport behind (an address that + // answers but is not the phone, e.g. the phone's VPN address); drop it. + adb.connect_within(a, wifi::QUICK_TIMEOUT).inspect_err(|_| { + let _ = adb.disconnect_within(a, wifi::QUICK_TIMEOUT); + }) + })?; + // A paired phone is remembered by its mDNS name when the network lets us see it, or by + // the name it was just paired under at this IP (a VPN on the phone hides it from mDNS). + let paired_name = self + .paired + .lock() + .unwrap() + .clone() + .and_then(|(name, ip)| name.filter(|_| address.rsplit_once(':').is_some_and(|(a, _)| a == ip))); + let id = adb + .mdns_services() + .unwrap_or_default() + .into_iter() + .find(|s| s.kind == MdnsKind::Connect && s.address == address) + .map(|s| s.name) + .or(paired_name) + .unwrap_or_else(|| address.clone()); + self.use_wifi_phone(&address, &id, None); + Ok(address) + } + + /// Runs `attempt` (pair or connect) on the address the user typed; if that fails, on the + /// same port at other IPs this phone may have (see `other_addresses`). Returns the address + /// that worked, or the first error. + fn try_addresses( + &self, + adb: &Adb, + address: &str, + kind: MdnsKind, + attempt: impl Fn(&str) -> Result, + ) -> Result<(String, T), String> { + let first = match attempt(address) { + Ok(v) => return Ok((address.to_string(), v)), + Err(e) => e.to_string(), + }; + for other in self.other_addresses(adb, address, kind) { + log::info!("{address} failed, trying {other}"); + if let Ok(v) = attempt(&other) { + return Ok((other, v)); + } + } + Err(first) + } + + /// With a VPN on, the phone shows its VPN address, which the PC cannot reach; the port is + /// right. IPs worth trying with that port: a phone announcing the same port on the network, + /// the IP the last phone was paired at, and the Wi-Fi IP of the phone on the cable when it + /// is plausibly the same phone. + fn other_addresses(&self, adb: &Adb, address: &str, kind: MdnsKind) -> Vec { + let port = address.rsplit_once(':').map(|(_, p)| p).unwrap_or_default(); + let mut ips: Vec = adb + .mdns_services_within(wifi::QUICK_TIMEOUT) + .unwrap_or_default() + .into_iter() + .filter(|s| s.kind == kind && s.address.rsplit_once(':').is_some_and(|(_, p)| p == port)) + .map(|s| s.ip().to_string()) + .collect(); + let paired = self.paired.lock().unwrap().clone(); + if let Some((_, ip)) = &paired { + ips.push(ip.clone()); + } + let usb: Vec = { + let st = self.state.lock().unwrap(); + st.devices.iter().filter(|d| d.is_ready() && !d.is_wifi()).map(|d| d.serial.clone()).collect() + }; + let usb: Vec<&str> = usb.iter().map(String::as_str).collect(); + let guid = paired.as_ref().and_then(|(g, _)| g.as_deref()); + if let Some(ip) = usb_phone_for(guid, &usb).and_then(|serial| adb.wifi_ip(serial)) { + ips.push(ip); + } + let mut out: Vec = Vec::new(); + for other in ips.iter().filter_map(|ip| with_ip(address, ip)) { + if !out.contains(&other) { + out.push(other); + } + } + out + } + + /// "From cable to Wi-Fi": moves the phone on the cable to plain TCP adb and connects over the + /// network. Not encrypted; lasts until the phone restarts. + pub fn wifi_from_cable(&self) -> Result { + let adb = self.adb()?; + let (serial, name) = { + let st = self.state.lock().unwrap(); + let usb = st.devices.iter().filter(|d| d.is_ready() && !d.is_wifi()); + let chosen = usb.clone().find(|d| st.settings.phone.as_ref() == Some(&d.serial)).or(usb.clone().next()); + let d = chosen.ok_or("noUsbPhone")?; + (d.serial.clone(), st.phones.get(&d.serial).and_then(|p| p.name.clone())) + }; + let address = wifi::switch_from_cable(&adb, &serial).map_err(|e| match e { + wifi::SwitchError::NoWifi => "phoneNotOnWifi".to_string(), + e => e.to_string(), + })?; + self.use_wifi_phone(&address, &address, name); + Ok(address) + } + + /// Remembers a phone just connected over Wi-Fi and makes it the one in use. + fn use_wifi_phone(&self, serial: &str, id: &str, name: Option) { + let restart = { + let mut st = self.state.lock().unwrap(); + { + let mut forgotten = self.forgotten.lock().unwrap(); + forgotten.remove(serial); + forgotten.remove(id); + forgotten.remove(&wifi::mdns_serial(id)); + } + st.wifi_ids.insert(serial.to_string(), id.to_string()); + st.settings.remember_wifi_phone(id, name); + st.settings.phone = Some(id.to_string()); + self.save(&st.settings); + if let Some(p) = &st.pipeline { + p.set_preferred(Some(serial.to_string())); + } + self.publish(&mut st); + st.camera_on && st.status.as_ref().and_then(status_serial) != Some(serial) + }; + if restart { + self.restart(); + } + } + + /// Forgets a Wi-Fi phone and disconnects it. If the camera uses it, it moves to another + /// phone. The phone stays paired with this PC; only the phone can remove that. + pub fn wifi_forget(&self, id: &str) { + let (listed, restart) = { + let mut st = self.state.lock().unwrap(); + // Every serial it may have now or after coming back. + let mut serials: HashSet = + st.devices.iter().filter(|d| wifi_id_of(&st, d).as_deref() == Some(id)).map(|d| d.serial.clone()).collect(); + serials.extend(st.wifi_ids.iter().filter(|(_, i)| *i == id).map(|(s, _)| s.clone())); + serials.insert(id.to_string()); + serials.insert(wifi::mdns_serial(id)); + st.wifi_ids.retain(|_, i| i != id); + st.settings.forget_wifi_phone(id); + let picked = st.settings.phone.as_ref().is_some_and(|s| serials.contains(s)); + if picked { + st.settings.phone = None; + } + let in_use = st.status.as_ref().and_then(status_serial).is_some_and(|s| serials.contains(s)); + let listed: Vec = st.devices.iter().filter(|d| serials.contains(&d.serial)).map(|d| d.serial.clone()).collect(); + { + let mut forgotten = self.forgotten.lock().unwrap(); + forgotten.ids.insert(id.to_string()); + forgotten.serials.extend(serials); + } + self.save(&st.settings); + self.publish(&mut st); + (listed, st.camera_on && (picked || in_use)) + }; + if let Some(adb) = &self.adb { + for serial in &listed { + let _ = adb.disconnect(serial); + } + } + if restart { + self.restart(); + } + } + fn fetch_phone(&self, adb: &Adb, serial: &str) { let name = adb .shell(serial, "echo $(getprop ro.product.manufacturer) $(getprop ro.product.marketname) $(getprop ro.product.model)") @@ -695,16 +1161,114 @@ impl Controller { } } -/// The phone the app works with: first ready one (USB first), else any listed one so the -/// window can say "allow USB debugging". -fn active_device(devices: &[Device]) -> Option<&Device> { - let ready: Vec<&Device> = devices.iter().filter(|d| d.is_ready()).collect(); - ready.iter().find(|d| !d.is_wifi()).or(ready.first()).copied().or(devices.first()) +impl State { + /// The serial the picked phone has now (see `resolve_phone`). + fn picked_serial(&self) -> Option { + self.settings.phone.as_deref().map(|p| resolve_phone(&self.devices, &self.wifi_ids, p)) + } + + /// The picked phone's serial when it is connected and ready. + fn picked_ready(&self) -> Option { + let phone = self.settings.phone.as_deref()?; + find_phone(&self.devices, &self.wifi_ids, phone).map(|d| d.serial.clone()) + } + + /// The phone the app works with, leaving out forgotten ones. + fn active(&self, forgotten: &Forgotten) -> Option<&Device> { + active_device(&self.devices, self.picked_serial().as_deref(), |s| !forgotten.contains(s)) + } +} + +/// Wi-Fi phones the user forgot: by id (saved in the settings) and by the serials they had. +#[derive(Debug, Default)] +struct Forgotten { + ids: HashSet, + serials: HashSet, +} + +impl Forgotten { + fn contains(&self, serial: &str) -> bool { + self.serials.contains(serial) + || self.ids.contains(serial) + || wifi::mdns_name(serial).is_some_and(|name| self.ids.contains(name)) + } + + /// Takes back a phone, by id or serial. + fn remove(&mut self, key: &str) { + self.ids.remove(key); + self.serials.remove(key); + if let Some(name) = wifi::mdns_name(key) { + self.ids.remove(name); + } + } +} + +/// The phone the app works with: the picked one if it is ready, else the first ready one (USB +/// first), else any listed one so the window can say "allow USB debugging". Phones that are not +/// `wanted` are left out. +fn active_device<'a>(devices: &'a [Device], preferred: Option<&str>, wanted: impl Fn(&str) -> bool) -> Option<&'a Device> { + let usable = || devices.iter().filter(|d| wanted(&d.serial)); + adb::choose(usable(), preferred).or_else(|| usable().next()) +} + +/// The remembered Wi-Fi phone a device is, by its mDNS name or the address we connected to. +fn wifi_id(d: &Device, wifi_ids: &HashMap) -> Option { + if !d.is_wifi() { + return None; + } + let id = wifi::mdns_name(&d.serial).map(str::to_string).or_else(|| wifi_ids.get(&d.serial).cloned()); + Some(id.unwrap_or_else(|| d.serial.clone())) +} + +fn wifi_id_of(st: &State, d: &Device) -> Option { + wifi_id(d, &st.wifi_ids) +} + +/// What `settings.phone` stores for a device: a Wi-Fi phone's id, a USB phone's serial. +fn phone_key(d: &Device, wifi_ids: &HashMap) -> String { + wifi_id(d, wifi_ids).unwrap_or_else(|| d.serial.clone()) +} + +/// The ready device a `settings.phone` value means now. Older settings may hold an mDNS serial +/// instead of the id. +fn find_phone<'a>(devices: &'a [Device], wifi_ids: &HashMap, phone: &str) -> Option<&'a Device> { + // An id saved with adb's " (2)" suffix still means the same phone. + let serial = wifi::mdns_serial(phone); + let key = wifi::mdns_name(phone).or_else(|| wifi::mdns_name(&serial)).unwrap_or(phone); + devices.iter().find(|d| d.is_ready() && (d.serial == phone || wifi_id(d, wifi_ids).as_deref() == Some(key))) +} + +/// The serial a `settings.phone` value has now: the ready device it is, else the serial adb +/// gives it by itself (an mDNS phone's mDNS serial, or the address or USB serial as it is). +fn resolve_phone(devices: &[Device], wifi_ids: &HashMap, phone: &str) -> String { + if let Some(d) = find_phone(devices, wifi_ids, phone) { + return d.serial.clone(); + } + if wifi::mdns_name(phone).is_none() && phone.starts_with("adb-") && !phone.contains(':') { + wifi::mdns_serial(phone) + } else { + phone.to_string() + } +} + +/// The phone a pipeline status is about. +fn status_serial(status: &Status) -> Option<&str> { + match status { + Status::Connecting { serial } + | Status::Streaming { serial, .. } + | Status::Reconnecting { serial, .. } + | Status::NoPicture { serial } => Some(serial), + Status::WaitingForDevice | Status::Error { .. } | Status::Stopped => None, + } +} + +fn connected_serial(st: &State, id: &str) -> Option { + st.devices.iter().find(|d| d.is_ready() && wifi_id_of(st, d).as_deref() == Some(id)).map(|d| d.serial.clone()) } /// Lenses of the active phone, without the ones that turned out broken. -fn usable_cameras(st: &State) -> Vec<&CameraInfo> { - let Some(device) = active_device(&st.devices) else { return Vec::new() }; +fn usable_cameras<'a>(st: &'a State, forgotten: &Forgotten) -> Vec<&'a CameraInfo> { + let Some(device) = st.active(forgotten) else { return Vec::new() }; let model = device.model.as_deref().unwrap_or_default(); st.phones .get(&device.serial) @@ -727,6 +1291,65 @@ fn selected_camera<'a>(settings: &Settings, usable: &[&'a CameraInfo]) -> Option .copied() } +/// Connects a remembered Wi-Fi phone if it is on the network. Returns its serial. +fn connect_known(adb: &Adb, id: &str) -> Option { + let address = if id.contains(':') { + id.to_string() + } else { + let services = adb.mdns_services().ok()?; + services.into_iter().find(|s| s.kind == MdnsKind::Connect && s.name == id)?.address + }; + adb.connect(&address).ok().map(|()| address) +} + +/// The address of the phone showing "Pair device with pairing code", if exactly one does. +fn find_pairing_phone(adb: &Adb) -> Option { + let until = Instant::now() + Duration::from_secs(5); + loop { + let phones: Vec = adb + .mdns_services() + .unwrap_or_default() + .into_iter() + .filter(|s| s.kind == MdnsKind::Pairing && !s.name.starts_with(QR_NAME_PREFIX)) + .map(|s| s.address) + .collect(); + if phones.len() == 1 { + return phones.into_iter().next(); + } + if Instant::now() > until { + return None; + } + std::thread::sleep(Duration::from_millis(500)); + } +} + +/// Whether a remembered Wi-Fi phone is on plain TCP adb ("from cable", port 5555) rather than +/// paired TLS, which is remembered by its mDNS name or by an address on a random port. +fn is_plain(id: &str) -> bool { + !id.starts_with("adb-") && id.rsplit_once(':').is_some_and(|(_, port)| port == wifi::TCPIP_PORT.to_string()) +} + +/// `address` with its IP replaced by `ip`, keeping the port; `None` when there is no port or +/// the IP is the same. +fn with_ip(address: &str, ip: &str) -> Option { + let (host, port) = address.rsplit_once(':')?; + let host = host.trim_start_matches('[').trim_end_matches(']'); + let valid = !ip.is_empty() && !port.is_empty() && port.bytes().all(|b| b.is_ascii_digit()); + (valid && host != ip).then(|| format!("{ip}:{port}")) +} + +/// The phone on the cable that is the one paired over Wi-Fi: the one named in its mDNS name +/// (`adb--…`), or without a name, the only phone on the cable. +fn usb_phone_for<'a>(mdns_name: Option<&str>, usb: &[&'a str]) -> Option<&'a str> { + match mdns_name { + Some(name) => { + let rest = name.strip_prefix("adb-")?; + usb.iter().copied().find(|s| rest.strip_prefix(s).is_some_and(|r| r.starts_with('-'))) + } + None => (usb.len() == 1).then(|| usb[0]), + } +} + /// `OnePlus PHK110` → `OnePlus PHK110`; drops the model when the marketing name has it. fn pretty_name(raw: &str) -> String { let mut words: Vec<&str> = Vec::new(); @@ -748,11 +1371,77 @@ mod tests { #[test] fn active_device_prefers_ready_usb() { + let all = |_: &str| true; let list = [device("1.2.3.4:5555", "device"), device("abc", "unauthorized"), device("usb1", "device")]; - assert_eq!(active_device(&list).unwrap().serial, "usb1"); + assert_eq!(active_device(&list, None, all).unwrap().serial, "usb1"); + assert_eq!(active_device(&list, Some("1.2.3.4:5555"), all).unwrap().serial, "1.2.3.4:5555"); + assert_eq!(active_device(&list, Some("gone"), all).unwrap().serial, "usb1"); let list = [device("abc", "unauthorized")]; - assert_eq!(active_device(&list).unwrap().serial, "abc"); - assert!(active_device(&[]).is_none()); + assert_eq!(active_device(&list, None, all).unwrap().serial, "abc"); + assert!(active_device(&[], None, all).is_none()); + } + + #[test] + fn forgotten_phones_are_never_active() { + let forgotten = Forgotten { ids: ["adb-1-x".to_string()].into(), serials: ["10.0.0.2:4000".to_string()].into() }; + let wanted = |s: &str| !forgotten.contains(s); + let list = [device("adb-1-x._adb-tls-connect._tcp", "device"), device("10.0.0.2:4000", "device")]; + assert!(active_device(&list, None, wanted).is_none()); + let list = [device("adb-1-x._adb-tls-connect._tcp", "device"), device("10.0.0.3:5555", "device")]; + assert_eq!(active_device(&list, Some("adb-1-x._adb-tls-connect._tcp"), wanted).unwrap().serial, "10.0.0.3:5555"); + let mut forgotten = forgotten; + forgotten.remove("adb-1-x._adb-tls-connect._tcp"); + assert!(!forgotten.contains("adb-1-x._adb-tls-connect._tcp") && forgotten.contains("10.0.0.2:4000")); + } + + #[test] + fn picked_wifi_phone_follows_its_address() { + let mut ids = HashMap::new(); + let usb = device("usb1", "device"); + let mdns = device("adb-1-x._adb-tls-connect._tcp", "device"); + let by_address = device("10.0.0.2:4001", "device"); + // Stored by id, found under whichever serial it has now. + assert_eq!(phone_key(&mdns, &ids), "adb-1-x"); + assert_eq!(phone_key(&usb, &ids), "usb1"); + assert_eq!(resolve_phone(&[usb.clone(), mdns.clone()], &ids, "adb-1-x"), mdns.serial); + ids.insert(by_address.serial.clone(), "adb-1-x".to_string()); + assert_eq!(phone_key(&by_address, &ids), "adb-1-x"); + assert_eq!(resolve_phone(&[usb.clone(), by_address.clone()], &ids, "adb-1-x"), "10.0.0.2:4001"); + // Older settings stored the mDNS serial. + assert_eq!(resolve_phone(std::slice::from_ref(&by_address), &ids, &mdns.serial), "10.0.0.2:4001"); + // Not connected: the serial adb gives it by itself. + let only_usb = std::slice::from_ref(&usb); + assert_eq!(resolve_phone(only_usb, &ids, "adb-1-x"), mdns.serial); + assert!(find_phone(only_usb, &ids, "adb-1-x").is_none()); + assert_eq!(resolve_phone(&[], &ids, "10.0.0.9:5555"), "10.0.0.9:5555"); + assert_eq!(resolve_phone(only_usb, &ids, "usb1"), "usb1"); + assert!(find_phone(&[device("usb1", "offline")], &ids, "usb1").is_none()); + } + + #[test] + fn only_tcpip_phones_are_plain() { + assert!(is_plain("192.168.3.237:5555")); + assert!(!is_plain("192.168.3.237:37075")); + assert!(!is_plain("adb-834ee2d7-nJE3Xk")); + } + + #[test] + fn other_ip_same_port() { + assert_eq!(with_ip("26.26.26.1:37075", "192.168.3.237").as_deref(), Some("192.168.3.237:37075")); + assert_eq!(with_ip("192.168.3.237:37075", "192.168.3.237"), None); + assert_eq!(with_ip("26.26.26.1", "192.168.3.237"), None); + assert_eq!(with_ip("26.26.26.1:", "192.168.3.237"), None); + assert_eq!(with_ip("26.26.26.1:37075", ""), None); + } + + #[test] + fn cable_phone_is_the_paired_one() { + assert_eq!(usb_phone_for(Some("adb-834ee2d7-nJE3Xk"), &["R58N", "834ee2d7"]), Some("834ee2d7")); + assert_eq!(usb_phone_for(Some("adb-834ee2d7-nJE3Xk"), &["834ee2"]), None); + assert_eq!(usb_phone_for(Some("adb-834ee2d7-nJE3Xk"), &["R58N"]), None); + assert_eq!(usb_phone_for(None, &["R58N"]), Some("R58N")); + assert_eq!(usb_phone_for(None, &["R58N", "834ee2d7"]), None); + assert_eq!(usb_phone_for(None, &[]), None); } #[test] diff --git a/src-tauri/src/app/mod.rs b/src-tauri/src/app/mod.rs index 1eeea46..5c3a97b 100644 --- a/src-tauri/src/app/mod.rs +++ b/src-tauri/src/app/mod.rs @@ -88,6 +88,14 @@ pub fn run() { set_preview_width, open_url, set_camera_registered, + select_phone, + wifi_qr_start, + wifi_qr_wait, + wifi_qr_cancel, + wifi_pair_code, + wifi_connect, + wifi_from_cable, + wifi_forget, save_report, open_log_folder, updates::update_state, @@ -227,6 +235,56 @@ async fn set_camera_registered(c: Ctl<'_>, on: bool) -> Result<(), String> { tauri::async_runtime::spawn_blocking(move || c.set_camera_registered(on)).await.map_err(|e| e.to_string())? } +#[tauri::command] +async fn select_phone(c: Ctl<'_>, serial: Option) -> Result<(), String> { + let c = c.inner().clone(); + tauri::async_runtime::spawn_blocking(move || c.select_phone(serial)).await.map_err(|e| e.to_string()) +} + +/// A new QR code for Wi-Fi pairing, as SVG. +#[tauri::command] +fn wifi_qr_start(c: Ctl) -> String { + c.wifi_qr_start() +} + +/// Waits for the phone to scan the QR code (minutes), then pairs; returns the phone's serial. +#[tauri::command] +async fn wifi_qr_wait(c: Ctl<'_>) -> Result { + let c = c.inner().clone(); + tauri::async_runtime::spawn_blocking(move || c.wifi_qr_wait()).await.map_err(|e| e.to_string())? +} + +#[tauri::command] +fn wifi_qr_cancel(c: Ctl) { + c.wifi_qr_cancel() +} + +#[tauri::command] +async fn wifi_pair_code(c: Ctl<'_>, code: String, address: Option) -> Result { + let c = c.inner().clone(); + tauri::async_runtime::spawn_blocking(move || c.wifi_pair_code(&code, address.as_deref())) + .await + .map_err(|e| e.to_string())? +} + +#[tauri::command] +async fn wifi_connect(c: Ctl<'_>, address: String) -> Result { + let c = c.inner().clone(); + tauri::async_runtime::spawn_blocking(move || c.wifi_connect(&address)).await.map_err(|e| e.to_string())? +} + +#[tauri::command] +async fn wifi_from_cable(c: Ctl<'_>) -> Result { + let c = c.inner().clone(); + tauri::async_runtime::spawn_blocking(move || c.wifi_from_cable()).await.map_err(|e| e.to_string())? +} + +#[tauri::command] +async fn wifi_forget(c: Ctl<'_>, id: String) -> Result<(), String> { + let c = c.inner().clone(); + tauri::async_runtime::spawn_blocking(move || c.wifi_forget(&id)).await.map_err(|e| e.to_string()) +} + /// Opens help links (driver download, docs) in the default browser; https only. #[tauri::command] fn open_url(url: String) -> Result<(), String> { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 5215d21..32f0025 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -11,3 +11,4 @@ pub mod resources; pub mod scrcpy; pub mod settings; pub mod vcam; +pub mod wifi; diff --git a/src-tauri/src/pipeline.rs b/src-tauri/src/pipeline.rs index 79b55d9..f7271bc 100644 --- a/src-tauri/src/pipeline.rs +++ b/src-tauri/src/pipeline.rs @@ -1,6 +1,12 @@ //! Phone → "Plugcam Camera": waits for a phone, starts the server, decodes, converts and pushes //! frames, and starts over whenever something breaks, until stopped. +//! +//! Over Wi-Fi the link is adb over TCP: a hiccup does not lose frames, it queues them, and the +//! picture falls further and further behind. So a Wi-Fi phone gets frequent key frames and a +//! lower bit rate, frames that arrive late are skipped up to the next key frame, and a phone +//! that dropped off the network is connected again while the camera keeps the last picture. +use std::collections::VecDeque; use std::fs::File; use std::io::{self, BufReader, Read, Write}; use std::net::{Shutdown, TcpStream}; @@ -10,26 +16,56 @@ use std::sync::{Arc, Mutex}; use std::thread::JoinHandle; use std::time::{Duration, Instant}; -use crate::adb::Adb; +use crate::adb::{self, Adb, Device}; use crate::decode::H264Decoder; use crate::frame::{ColorAdjust, FrameConverter}; use crate::preview::PreviewSlot; use crate::scrcpy::protocol::{self, ControlMessage, Packet}; use crate::scrcpy::server::{self, CameraParams, Session}; use crate::vcam::VirtualCamera; +use crate::wifi; /// Some cameras are listed but never deliver a frame (camera 4 on the OnePlus 11R). const FIRST_FRAME_TIMEOUT: Duration = Duration::from_secs(6); +/// Over Wi-Fi the first frame can also be late because the network is slow. +const FIRST_FRAME_TIMEOUT_WIFI: Duration = Duration::from_secs(10); /// A camera streams continuously; this much silence means the link is gone. const STALL_TIMEOUT: Duration = Duration::from_secs(3); +/// Wi-Fi goes quiet for a second or two now and then without being gone. +const STALL_TIMEOUT_WIFI: Duration = Duration::from_secs(5); const RETRY_DELAY: Duration = Duration::from_secs(2); const NO_PICTURE_RETRY_DELAY: Duration = Duration::from_secs(15); const STATS_PERIOD: Duration = Duration::from_secs(5); +/// Seconds between key frames over Wi-Fi, so skipping ahead after a hiccup takes under a second. +const WIFI_KEY_FRAME_INTERVAL: u32 = 1; +/// Starting bit rate over Wi-Fi (the "Economy" one), unless the user's is lower. +const WIFI_START_BITRATE: u32 = 6_000_000; +/// Each time the stream stalls or keeps falling behind, the next session gets this share of +/// the bit rate, down to the floor. +const WIFI_SLOWDOWN: f64 = 0.7; +const WIFI_MIN_BITRATE: u32 = 2_000_000; +/// After this much streaming without trouble, the next session gets one step of the bit rate +/// back, up to where it started. +const WIFI_SPEEDUP_AFTER: Duration = Duration::from_secs(300); +/// A Wi-Fi session shorter than this counts as failed; failures in a row wait longer and longer +/// before the next try, up to `NO_PICTURE_RETRY_DELAY`. +const SHORT_SESSION: Duration = Duration::from_secs(10); +/// Behind real time by more than this, frames are skipped up to the next key frame. +const MAX_LAG: Duration = Duration::from_millis(500); +/// Skip at most this long, even if key frames are late too (a PC too slow to decode in real +/// time); after that the delay is accepted as the new normal. +const MAX_SKIP: Duration = Duration::from_secs(3); +/// This many catch-ups within the window mean the network cannot carry the bit rate. +const CONGESTION_SKIPS: usize = 3; +const CONGESTION_WINDOW: Duration = Duration::from_secs(60); +/// Tries at bringing back a Wi-Fi phone that dropped off the network. +pub const RECONNECT_TRIES: u32 = 5; + pub struct PipelineConfig { pub adb: Adb, pub server_file: PathBuf, - /// Pick this phone; otherwise the first ready one, USB preferred. + /// Prefer this phone; otherwise (or when it is not ready) the first ready one, USB first. pub serial: Option, pub camera: CameraParams, pub mirror: bool, @@ -43,14 +79,26 @@ pub struct PipelineConfig { pub preview: Option>, /// Told the camera zoom whenever the phone sets it. pub on_zoom: Option>, + /// Told `(old, new)` when a Wi-Fi phone came back under another serial (an mDNS phone + /// connected again by address). + pub on_serial: Option, + /// Whether a phone may still be used; `false` for one the user forgot, so it is neither + /// picked nor connected again. + pub wanted: Option, } +pub type OnSerial = Arc; +pub type Wanted = Arc bool + Send + Sync>; + #[derive(Debug, Clone, PartialEq, serde::Serialize)] #[serde(tag = "kind", rename_all = "camelCase")] pub enum Status { WaitingForDevice, Connecting { serial: String }, Streaming { serial: String, device: String, width: u32, height: u32 }, + /// A Wi-Fi phone stopped answering; the camera keeps the last picture while it is + /// connected again. + Reconnecting { serial: String, attempt: u32, tries: u32 }, /// The selected camera streams nothing; retrying will not help (and may upset the /// phone's camera service for a while, docs/stage2.md). NoPicture { serial: String }, @@ -63,6 +111,8 @@ pub struct Stats { pub fps: f32, /// Decode + convert + send, averaged over the last period. pub process_ms: f32, + /// Bit rate asked of the phone, in bits per second. + pub bitrate: u32, } struct Shared { @@ -71,6 +121,8 @@ struct Shared { rotation: AtomicU16, /// `ColorAdjust::to_bits`. color: AtomicU32, + /// The phone to use when it is ready; can change without restarting the stream. + preferred: Mutex>, video: Mutex>, control: Mutex>, stats: Mutex, @@ -88,6 +140,7 @@ impl Pipeline { mirror: AtomicBool::new(config.mirror), rotation: AtomicU16::new(config.rotation), color: AtomicU32::new(config.color.to_bits()), + preferred: Mutex::new(config.serial.clone()), video: Mutex::new(None), control: Mutex::new(None), stats: Mutex::new(Stats::default()), @@ -120,6 +173,11 @@ impl Pipeline { self.shared.color.store(adjust.to_bits(), Ordering::Relaxed); } + /// The phone to go back to after the stream breaks; the running stream is left alone. + pub fn set_preferred(&self, serial: Option) { + *self.shared.preferred.lock().unwrap() = serial; + } + pub fn stats(&self) -> Stats { *self.shared.stats.lock().unwrap() } @@ -167,9 +225,17 @@ fn run(config: PipelineConfig, vcam: VirtualCamera, shared: &Shared, on_status: let mut converter = FrameConverter::new(vcam.width(), vcam.height()); vcam.send_frame(converter.output()); // black until the phone is there + let wanted = |serial: &str| config.wanted.as_ref().is_none_or(|w| w(serial)); + let mut wifi_rate = WifiRate::new(config.camera.bit_rate); + // Wi-Fi sessions in a row that ended before `SHORT_SESSION`. + let mut wifi_failures = 0u32; + // The phone the two above are about; another phone starts both over. + let mut last_serial: Option = None; + while !stopped() { - let serial = match pick_device(&config) { - Ok(Some(serial)) => serial, + let preferred = shared.preferred.lock().unwrap().clone(); + let device = match pick_device(&config.adb, preferred.as_deref(), &wanted) { + Ok(Some(device)) => device, Ok(None) => { status(Status::WaitingForDevice); pause(Duration::from_secs(1)); @@ -181,44 +247,116 @@ fn run(config: PipelineConfig, vcam: VirtualCamera, shared: &Shared, on_status: continue; } }; + let serial = device.serial.clone(); + let wifi = device.is_wifi(); + if last_serial.as_deref() != Some(serial.as_str()) { + wifi_rate = WifiRate::new(config.camera.bit_rate); + wifi_failures = 0; + last_serial = Some(serial.clone()); + } + let mut camera = config.camera.clone(); + if wifi { + camera.key_frame_interval = Some(WIFI_KEY_FRAME_INTERVAL); + camera.bit_rate = Some(wifi_rate.current); + } status(Status::Connecting { serial: serial.clone() }); - let session = match server::start(&config.adb, &serial, &config.server_file, &config.camera) { - Ok(s) => s, - Err(e) => { - status(Status::Error { message: e.to_string() }); - pause(RETRY_DELAY); - continue; + let started = Instant::now(); + let mut frames = 0; + let result = match server::start(&config.adb, &serial, &config.server_file, &camera) { + Ok(session) => { + *shared.video.lock().unwrap() = session.video.try_clone().ok(); + *shared.control.lock().unwrap() = session.control.try_clone().ok(); + if let Some(on_zoom) = &config.on_zoom { + let on_zoom = on_zoom.clone(); + session.log.on_zoom(move |z| on_zoom(z)); + } + shared.stats.lock().unwrap().bitrate = camera.bit_rate.unwrap_or_default(); + + let link = Link { wifi, can_slow_down: wifi && wifi_rate.can_slow_down() }; + let result = stream(&session, &serial, link, &config, &vcam, &mut converter, shared, &mut status, &mut frames); + + *shared.video.lock().unwrap() = None; + *shared.control.lock().unwrap() = None; + let problems = session.log.problems(); + session.close(); + *shared.stats.lock().unwrap() = Stats::default(); + result.map_err(|e| match e { + StreamEnd::Broken(e) => StreamEnd::Broken(format!("{e}{problems}")), + e => e, + }) } + Err(e) => Err(StreamEnd::Broken(e.to_string())), }; - *shared.video.lock().unwrap() = session.video.try_clone().ok(); - *shared.control.lock().unwrap() = session.control.try_clone().ok(); - if let Some(on_zoom) = &config.on_zoom { - let on_zoom = on_zoom.clone(); - session.log.on_zoom(move |z| on_zoom(z)); - } - - let result = stream(&session, &serial, &config, &vcam, &mut converter, shared, &mut status); - - *shared.video.lock().unwrap() = None; - *shared.control.lock().unwrap() = None; - let problems = session.log.problems(); - session.close(); - *shared.stats.lock().unwrap() = Stats::default(); if stopped() { break; } - vcam.send_frame(converter.dim()); // make the frozen picture look disconnected + if !wifi { + vcam.send_frame(converter.dim()); // make the frozen picture look disconnected + match result { + Ok(()) | Err(StreamEnd::Congested) => pause(RETRY_DELAY), + Err(StreamEnd::NoPicture) => { + status(Status::NoPicture { serial }); + pause(NO_PICTURE_RETRY_DELAY); + } + Err(StreamEnd::Broken(e)) => { + status(Status::Error { message: e }); + pause(RETRY_DELAY); + } + } + continue; + } + + // Wi-Fi: keep the last picture while trying to get the stream back. + let streamed = if frames > 0 { started.elapsed() } else { Duration::ZERO }; + wifi_failures = if streamed >= SHORT_SESSION { 0 } else { wifi_failures + 1 }; + // A phone that left the network says nothing about what the network can carry. A phone + // that is no longer wanted is not asked. + let alive = !matches!(result, Err(StreamEnd::Broken(_))) || (wanted(&serial) && wifi::is_alive(&config.adb, &serial)); + let congested = match &result { + Err(StreamEnd::Congested) => true, + Err(StreamEnd::Broken(_)) => frames > 0 && alive, // it stalled after streaming + _ => false, + }; + wifi_rate.session_ended(streamed, congested); match result { - Ok(()) => pause(RETRY_DELAY), + Ok(()) | Err(StreamEnd::Congested) => {} Err(StreamEnd::NoPicture) => { + vcam.send_frame(converter.dim()); status(Status::NoPicture { serial }); pause(NO_PICTURE_RETRY_DELAY); } + Err(StreamEnd::Broken(_)) if !alive => { + let still_wanted = || !stopped() && wanted(&serial); + match bring_back(&config.adb, &serial, &mut status, &pause, &still_wanted) { + Some(back) => { + follow_serial(&mut shared.preferred.lock().unwrap(), &serial, &back); + last_serial = Some(back.clone()); // the same phone: keep its bit rate + if back != serial + && let Some(on_serial) = &config.on_serial + { + on_serial(&serial, &back); + } + if wifi_failures >= 2 { + pause(retry_delay(wifi_failures)); + } + } + None => { + // Wait for it like for any phone; the app keeps looking for it. + vcam.send_frame(converter.dim()); + status(Status::WaitingForDevice); + } + } + } Err(StreamEnd::Broken(e)) => { - status(Status::Error { message: format!("{e}{problems}") }); - pause(RETRY_DELAY); + if wifi_failures >= 2 { + vcam.send_frame(converter.dim()); + status(Status::Error { message: e }); + } else { + log::info!("Wi-Fi stream ended, starting again: {e}"); + } + pause(retry_delay(wifi_failures)); } } } @@ -226,8 +364,97 @@ fn run(config: PipelineConfig, vcam: VirtualCamera, shared: &Shared, on_status: vcam } +/// Connects a Wi-Fi phone that stopped answering again, a few times, as long as it is still +/// wanted. Returns its serial once it is back. +fn bring_back( + adb: &Adb, + serial: &str, + status: &mut impl FnMut(Status), + pause: &impl Fn(Duration), + still_wanted: &impl Fn() -> bool, +) -> Option { + for attempt in 1..=RECONNECT_TRIES { + if !still_wanted() { + return None; + } + status(Status::Reconnecting { serial: serial.to_string(), attempt, tries: RECONNECT_TRIES }); + if let Some(back) = wifi::reconnect(adb, serial, still_wanted) { + if !still_wanted() { + // Forgotten while it was being connected. + let _ = adb.disconnect_within(&back, wifi::QUICK_TIMEOUT); + return None; + } + log::info!("{serial} is back as {back}"); + return Some(back); + } + pause(RETRY_DELAY); + } + None +} + +/// A phone came back as `new`: if it was the preferred one, it still is. Any other preferred +/// phone (or none: automatic) stays as it was. +fn follow_serial(preferred: &mut Option, old: &str, new: &str) { + if preferred.as_deref() == Some(old) { + *preferred = Some(new.to_string()); + } +} + +/// The bit rate Wi-Fi sessions ask for: a step down whenever the network cannot carry it, a step +/// back up after `WIFI_SPEEDUP_AFTER` of streaming without trouble. +struct WifiRate { + current: u32, + /// The user's bit rate or `WIFI_START_BITRATE`, whichever is lower. + top: u32, + /// Streaming time since the last change. + clean: Duration, +} + +impl WifiRate { + fn new(user: Option) -> Self { + let top = user.unwrap_or(WIFI_START_BITRATE).min(WIFI_START_BITRATE); + Self { current: top, top, clean: Duration::ZERO } + } + + fn can_slow_down(&self) -> bool { + self.current > WIFI_MIN_BITRATE + } + + /// After a session that gave a picture for `streamed`; `congested` when it ended because + /// the network could not keep up. + fn session_ended(&mut self, streamed: Duration, congested: bool) { + let old = self.current; + if congested { + self.clean = Duration::ZERO; + if self.can_slow_down() { + self.current = ((self.current as f64 * WIFI_SLOWDOWN).round() as u32).max(WIFI_MIN_BITRATE); + } + } else { + self.clean += streamed; + if self.clean >= WIFI_SPEEDUP_AFTER && self.current < self.top { + self.clean = Duration::ZERO; + self.current = ((self.current as f64 / WIFI_SLOWDOWN).round() as u32).min(self.top); + } + } + if self.current != old { + log::info!("Wi-Fi bit rate {:.1} → {:.1} Mbit/s", old as f32 / 1e6, self.current as f32 / 1e6); + } + } +} + +/// How long to wait before the next Wi-Fi session after `failures` short ones in a row: at +/// once for a single hiccup, then longer and longer. +fn retry_delay(failures: u32) -> Duration { + match failures { + 0 | 1 => Duration::from_millis(500), + n => RETRY_DELAY.saturating_mul(1 << (n - 2).min(8)).min(NO_PICTURE_RETRY_DELAY), + } +} + enum StreamEnd { NoPicture, + /// Frames kept arriving late: the network cannot carry this bit rate. + Congested, Broken(String), } @@ -237,30 +464,36 @@ impl From for StreamEnd { } } -fn pick_device(config: &PipelineConfig) -> Result, String> { - let devices = config.adb.devices().map_err(|e| e.to_string())?; - let ready = devices.iter().filter(|d| d.is_ready()); - Ok(match &config.serial { - Some(serial) => ready.into_iter().find(|d| &d.serial == serial).map(|d| d.serial.clone()), - None => { - let ready: Vec<_> = ready.collect(); - ready.iter().find(|d| !d.is_wifi()).or(ready.first()).map(|d| d.serial.clone()) - } - }) +fn pick_device(adb: &Adb, preferred: Option<&str>, wanted: &impl Fn(&str) -> bool) -> Result, String> { + let mut devices = adb.devices().map_err(|e| e.to_string())?; + devices.retain(|d| wanted(&d.serial)); + Ok(adb::choose(&devices, preferred).cloned()) +} + +#[derive(Clone, Copy)] +struct Link { + wifi: bool, + /// The bit rate can still go down, so falling behind again and again ends the session. + can_slow_down: bool, } /// Reads the whole stream of one session. Returns `Ok` when stopped on request. +#[allow(clippy::too_many_arguments)] fn stream( session: &Session, serial: &str, + link: Link, config: &PipelineConfig, vcam: &VirtualCamera, converter: &mut FrameConverter, shared: &Shared, status: &mut impl FnMut(Status), + frames: &mut u64, ) -> Result<(), StreamEnd> { + let (first_timeout, stall_timeout) = + if link.wifi { (FIRST_FRAME_TIMEOUT_WIFI, STALL_TIMEOUT_WIFI) } else { (FIRST_FRAME_TIMEOUT, STALL_TIMEOUT) }; let socket = session.video.try_clone().map_err(|e| e.to_string())?; - socket.set_read_timeout(Some(FIRST_FRAME_TIMEOUT)).map_err(|e| e.to_string())?; + socket.set_read_timeout(Some(first_timeout)).map_err(|e| e.to_string())?; let dump = match &config.dump { Some(path) => Some(File::create(path).map_err(|e| format!("{}: {e}", path.display()))?), None => None, @@ -268,12 +501,12 @@ fn stream( let mut reader = BufReader::with_capacity(1 << 20, Tee { inner: &socket, copy: dump }); let mut decoder: Option = None; - let mut frames = 0u64; let mut period_start = Instant::now(); let mut period_frames = 0u32; let mut period_busy = Duration::ZERO; let mut frame_error = None; let mut stall_timeout_set = false; + let mut catch_up = link.wifi.then(CatchUp::default); loop { // A stop that came while the server was starting had no socket to shut down, and a @@ -284,8 +517,10 @@ fn stream( let packet = match protocol::read_packet(&mut reader) { Ok(p) => p, Err(_) if shared.stop.load(Ordering::SeqCst) => return Ok(()), - // Timed out, or the server gave up (some listed lenses never produce a frame). - Err(_) if frames == 0 => return Err(StreamEnd::NoPicture), + // Over USB, no first frame means the lens gives no picture (some listed lenses + // never do). Over Wi-Fi it may just be the network, unless the server says so. + Err(_) if *frames == 0 && (!link.wifi || session.log.camera_failed()) => return Err(StreamEnd::NoPicture), + Err(e) if *frames == 0 => return Err(format!("no video from the phone: {e}").into()), Err(e) => return Err(format!("video stream ended: {e}").into()), }; @@ -294,6 +529,9 @@ fn stream( Packet::Session { width, height } => { log::info!("session {width}x{height}"); decoder = Some(H264Decoder::new(width, height).map_err(|e| e.to_string())?); + if let Some(c) = catch_up.as_mut() { + c.lag.reset(); + } status(Status::Streaming { serial: serial.to_string(), device: session.device_name.clone(), width, height }); } Packet::Config(data) => { @@ -301,8 +539,16 @@ fn stream( d.decode(&data, 0, &mut |_| {}).map_err(|e| e.to_string())?; } } - Packet::Frame { pts_us, data, .. } => { + Packet::Frame { pts_us, key_frame, data } => { let Some(d) = decoder.as_mut() else { continue }; + if let Some(c) = catch_up.as_mut() { + match c.check(pts_us, key_frame, started) { + Verdict::Show => {} + Verdict::Skip => continue, + Verdict::Congested if link.can_slow_down => return Err(StreamEnd::Congested), + Verdict::Congested => continue, + } + } converter.set_mirror(shared.mirror.load(Ordering::Relaxed)); converter.set_rotation(shared.rotation.load(Ordering::Relaxed)); converter.set_color(ColorAdjust::from_bits(shared.color.load(Ordering::Relaxed))); @@ -312,7 +558,7 @@ fn stream( if let Some(p) = &config.preview { p.offer(bgr, vcam.width(), vcam.height()); } - frames += 1; + *frames += 1; period_frames += 1; } Err(e) => frame_error = Some(e.to_string()), @@ -321,8 +567,8 @@ fn stream( if let Some(e) = frame_error.take() { return Err(e.into()); } - if frames > 0 && !stall_timeout_set { - socket.set_read_timeout(Some(STALL_TIMEOUT)).map_err(|e| e.to_string())?; + if *frames > 0 && !stall_timeout_set { + socket.set_read_timeout(Some(stall_timeout)).map_err(|e| e.to_string())?; stall_timeout_set = true; } } @@ -331,17 +577,94 @@ fn stream( let elapsed = period_start.elapsed(); if elapsed >= STATS_PERIOD { - let stats = Stats { - fps: period_frames as f32 / elapsed.as_secs_f32(), - process_ms: period_busy.as_secs_f32() * 1000.0 / period_frames.max(1) as f32, - }; + let mut stats = shared.stats.lock().unwrap(); + stats.fps = period_frames as f32 / elapsed.as_secs_f32(); + stats.process_ms = period_busy.as_secs_f32() * 1000.0 / period_frames.max(1) as f32; log::info!("{:.1} fps, {:.1} ms per frame", stats.fps, stats.process_ms); - *shared.stats.lock().unwrap() = stats; (period_start, period_frames, period_busy) = (Instant::now(), 0, Duration::ZERO); } } } +/// How far behind the phone the picture is: time passed on the PC since the reference frame +/// minus time passed on the phone's clock. Frames that sat in a queue make the difference grow. +#[derive(Default)] +struct LagMeter { + /// PC time and phone time of the reference frame. + base: Option<(Instant, u64)>, + /// The smallest difference seen, in microseconds: the frame that came through fastest. + min_offset: i64, + last: Option, +} + +impl LagMeter { + fn lag(&mut self, pts_us: u64, now: Instant) -> Duration { + let (t0, p0) = *self.base.get_or_insert((now, pts_us)); + let offset = now.duration_since(t0).as_micros() as i64 - (pts_us as i64 - p0 as i64); + // Let the minimum rise by 1 ms per second, so a slow drift between the two clocks + // (a fraction of a second per hour) is never taken for lag. + let leak = self.last.map_or(0, |l| now.duration_since(l).as_micros() as i64 / 1000); + self.min_offset = (self.min_offset + leak).min(offset); + self.last = Some(now); + Duration::from_micros((offset - self.min_offset).max(0) as u64) + } + + fn reset(&mut self) { + *self = Self::default(); + } +} + +enum Verdict { + Show, + Skip, + /// Skipping ahead keeps being needed. + Congested, +} + +/// Skips frames up to the next key frame once the picture is `MAX_LAG` behind. +#[derive(Default)] +struct CatchUp { + lag: LagMeter, + skipping_since: Option, + skipped: u32, + /// When each recent catch-up started. + recent: VecDeque, +} + +impl CatchUp { + fn check(&mut self, pts_us: u64, key_frame: bool, now: Instant) -> Verdict { + let behind = self.lag.lag(pts_us, now); + let Some(since) = self.skipping_since else { + if behind <= MAX_LAG { + return Verdict::Show; + } + log::info!("{} ms behind, skipping to the next key frame", behind.as_millis()); + self.skipping_since = Some(now); + self.skipped = 1; + self.recent.retain(|t| now.duration_since(*t) < CONGESTION_WINDOW); + self.recent.push_back(now); + if self.recent.len() >= CONGESTION_SKIPS { + self.recent.clear(); + return Verdict::Congested; + } + return Verdict::Skip; + }; + let caught_up = behind <= MAX_LAG / 2; + if key_frame && (caught_up || now.duration_since(since) > MAX_SKIP) { + log::info!("skipped {} frames, {} ms behind now", self.skipped, behind.as_millis()); + if !caught_up { + // The PC cannot keep up; measure from here instead of skipping forever. + self.lag.reset(); + self.lag.lag(pts_us, now); + } + self.skipping_since = None; + return Verdict::Show; + } + self.skipped += 1; + Verdict::Skip + } +} + /// Copies everything read into an optional file. struct Tee<'a> { inner: &'a TcpStream, @@ -357,3 +680,146 @@ impl Read for Tee<'_> { Ok(n) } } + +#[cfg(test)] +mod tests { + use super::*; + + const MS: u64 = 1000; + + fn clock() -> impl Fn(u64) -> Instant { + let t0 = Instant::now(); + move |ms| t0 + Duration::from_millis(ms) + } + + #[test] + fn lag_is_how_much_later_than_the_fastest_frame() { + let at = clock(); + let mut m = LagMeter::default(); + assert_eq!(m.lag(1_000 * MS, at(0)), Duration::ZERO); + assert!((6..=7).contains(&m.lag(1_033 * MS, at(40)).as_millis())); + // A frame that came through faster becomes the reference. + assert_eq!(m.lag(1_066 * MS, at(60)), Duration::ZERO); + // Frames stuck for a second. + assert!(m.lag(1_100 * MS, at(1_100)).as_millis() >= 900); + } + + #[test] + fn slow_clock_drift_is_not_lag() { + let at = clock(); + let mut m = LagMeter::default(); + // The phone's clock runs 100 ppm slow for an hour: 0.36 s apart by the end. + let mut worst = Duration::ZERO; + for s in 0..3600u64 { + let pts = s * 1_000_000 - s * 100; + worst = worst.max(m.lag(pts, at(s * 1000))); + } + assert!(worst < Duration::from_millis(5), "{worst:?}"); + } + + #[test] + fn catches_up_at_the_next_key_frame() { + let at = clock(); + let mut c = CatchUp::default(); + assert!(matches!(c.check(0, true, at(0)), Verdict::Show)); + assert!(matches!(c.check(33 * MS, false, at(33)), Verdict::Show)); + // Two seconds of frames arrive at once after a hiccup. + assert!(matches!(c.check(66 * MS, false, at(2_000)), Verdict::Skip)); + assert!(matches!(c.check(1_000 * MS, true, at(2_001)), Verdict::Skip)); // key frame, still late + assert!(matches!(c.check(1_500 * MS, false, at(2_002)), Verdict::Skip)); + assert!(matches!(c.check(2_000 * MS, true, at(2_010)), Verdict::Show)); // caught up + assert!(matches!(c.check(2_033 * MS, false, at(2_043)), Verdict::Show)); + } + + #[test] + fn gives_up_skipping_when_the_pc_is_too_slow() { + let at = clock(); + let mut c = CatchUp::default(); + c.check(0, true, at(0)); + assert!(matches!(c.check(33 * MS, false, at(1_000)), Verdict::Skip)); + assert!(matches!(c.check(1_000 * MS, true, at(3_000)), Verdict::Skip)); + assert!(matches!(c.check(2_000 * MS, true, at(4_100)), Verdict::Show)); // 3 s passed + assert!(matches!(c.check(2_033 * MS, false, at(4_133)), Verdict::Show)); + } + + #[test] + fn repeated_catch_ups_mean_congestion() { + let at = clock(); + let mut c = CatchUp::default(); + let mut verdicts = Vec::new(); + let mut now = 0; + for _ in 0..3 { + c.check(now * MS, true, at(now)); + // One second stuck, then a key frame that is on time. + verdicts.push(c.check((now + 33) * MS, false, at(now + 1_000))); + now += 1_000; + assert!(matches!(c.check(now * MS, true, at(now)), Verdict::Show)); + now += 5_000; + } + assert!(matches!(verdicts[0], Verdict::Skip)); + assert!(matches!(verdicts[1], Verdict::Skip)); + assert!(matches!(verdicts[2], Verdict::Congested)); + } + + #[test] + fn wifi_bit_rate_steps_down_and_back_up() { + let min = Duration::from_secs(60); + let mut r = WifiRate::new(Some(12_000_000)); + assert_eq!(r.current, WIFI_START_BITRATE); + r.session_ended(min, true); + assert_eq!(r.current, 4_200_000); + r.session_ended(min, true); + r.session_ended(min, true); + assert_eq!(r.current, 2_058_000); + r.session_ended(min, true); + assert_eq!(r.current, WIFI_MIN_BITRATE); + assert!(!r.can_slow_down()); + r.session_ended(min, true); + assert_eq!(r.current, WIFI_MIN_BITRATE); + // Clean streaming adds up across sessions (e.g. the phone left the network in between). + r.session_ended(4 * min, false); + assert_eq!(r.current, WIFI_MIN_BITRATE); + r.session_ended(min, false); + assert_eq!(r.current, 2_857_143); + for _ in 0..3 { + r.session_ended(5 * min, false); + } + assert_eq!(r.current, WIFI_START_BITRATE); + r.session_ended(60 * min, false); + assert_eq!(r.current, WIFI_START_BITRATE); + // Trouble starts the clean time over. + r.session_ended(4 * min, false); + r.session_ended(Duration::ZERO, true); + r.session_ended(4 * min, false); + assert_eq!(r.current, 4_200_000); + } + + #[test] + fn wifi_bit_rate_never_above_the_users() { + let mut r = WifiRate::new(Some(4_000_000)); + assert_eq!(r.current, 4_000_000); + r.session_ended(Duration::ZERO, true); + r.session_ended(Duration::from_secs(600), false); + assert_eq!(r.current, 4_000_000); + } + + #[test] + fn only_the_preferred_phone_is_followed_to_a_new_serial() { + let mut p = Some("adb-1._adb-tls-connect._tcp".to_string()); + follow_serial(&mut p, "adb-1._adb-tls-connect._tcp", "10.0.0.2:4000"); + assert_eq!(p.as_deref(), Some("10.0.0.2:4000")); + let mut p = Some("usb1".to_string()); + follow_serial(&mut p, "10.0.0.2:4000", "10.0.0.2:4001"); + assert_eq!(p.as_deref(), Some("usb1")); + let mut p = None; + follow_serial(&mut p, "10.0.0.2:4000", "10.0.0.2:4001"); + assert_eq!(p, None); + } + + #[test] + fn failed_sessions_back_off() { + let delays: Vec = (0..9).map(|n| retry_delay(n).as_millis() as u64).collect(); + assert_eq!(delays, [500, 500, 2_000, 4_000, 8_000, 15_000, 15_000, 15_000, 15_000]); + assert_eq!(retry_delay(u32::MAX), NO_PICTURE_RETRY_DELAY); + } +} diff --git a/src-tauri/src/scrcpy/server.rs b/src-tauri/src/scrcpy/server.rs index 6b8d5a2..f750d86 100644 --- a/src-tauri/src/scrcpy/server.rs +++ b/src-tauri/src/scrcpy/server.rs @@ -23,6 +23,7 @@ pub const SERVER_SHA256: &str = "deacb991ed2509715160ffdc7907e47b4160eb30d156621 const DEVICE_SERVER_PATH: &str = "/data/local/tmp/plugcam-server.jar"; const DEVICE_LIST_PATH: &str = "/data/local/tmp/plugcam-list.jar"; const CONNECT_TIMEOUT: Duration = Duration::from_secs(10); +const LIST_TIMEOUT: Duration = Duration::from_secs(30); /// A server left over from a crashed session keeps the camera busy for a moment after it is /// killed ("Too many other clients connecting"), see docs/stage0.md. const STALE_SERVER_GRACE: Duration = Duration::from_secs(3); @@ -79,6 +80,9 @@ pub struct CameraParams { pub orientation: u16, pub torch: bool, pub zoom: Option, + /// Seconds between key frames; `None` keeps scrcpy's 10. Over Wi-Fi a short interval lets + /// the PC skip ahead to real time quickly after a hiccup. + pub key_frame_interval: Option, } impl Default for CameraParams { @@ -93,6 +97,7 @@ impl Default for CameraParams { orientation: 0, torch: false, zoom: None, + key_frame_interval: None, } } } @@ -124,6 +129,9 @@ impl CameraParams { if let Some(zoom) = self.zoom { args.push(format!("camera_zoom={zoom}")); } + if let Some(secs) = self.key_frame_interval { + args.push(format!("video_codec_options=i-frame-interval={secs}")); + } args } } @@ -163,6 +171,13 @@ impl ServerLog { zoom.1 = Some(Box::new(hook)); } + /// The camera itself failed (e.g. `Camera capture failed: frame 0` from a lens that is listed + /// but gives no picture), as opposed to the connection being slow. + pub fn camera_failed(&self) -> bool { + let lines = self.lines.lock().unwrap(); + lines.iter().any(|l| l.contains("Camera capture failed") || (l.contains("ERROR") && l.contains("amera"))) + } + /// Distinct error and warning lines (at most 3), formatted to be appended to a message. pub fn problems(&self) -> String { let lines = self.lines.lock().unwrap(); @@ -290,13 +305,11 @@ pub fn list_cameras(adb: &Adb, serial: &str, server: &Path) -> Result, + /// The phone picked in the app: a USB phone's serial, or a Wi-Fi phone's id (see + /// `WifiPhone::id`), which stays the same when its address changes. `None` = the first + /// ready one, USB first. + pub phone: Option, + /// Phones used over Wi-Fi, connected again whenever they are on the network. + #[serde(deserialize_with = "skip_bad_entries")] + pub wifi_phones: Vec, + /// Ids of Wi-Fi phones the user forgot. They stay paired, so adb may connect them by + /// itself; the app then leaves them alone until they are picked or added again. + #[serde(deserialize_with = "skip_bad_entries")] + pub forgotten_wifi: Vec, /// Write a detailed log to a file for bug reports (see `crate::diag`). pub detailed_log: bool, } +/// A list where entries that do not parse are dropped, rather than the whole settings file. +fn skip_bad_entries<'de, D, T>(deserializer: D) -> Result, D::Error> +where + D: serde::Deserializer<'de>, + T: serde::de::DeserializeOwned, +{ + let value = serde_json::Value::deserialize(deserializer)?; + let items = match value { + serde_json::Value::Array(items) => items, + _ => Vec::new(), + }; + Ok(items.into_iter().filter_map(|v| serde_json::from_value(v).ok()).collect()) +} + +/// A phone remembered for Wi-Fi. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WifiPhone { + /// Its mDNS name (`adb--`) when paired, which survives new addresses; `ip:port` + /// when it was switched over from the cable. + pub id: String, + /// Human name, e.g. `OnePlus PHK110`, once known. + pub name: Option, +} + impl Default for Settings { fn default() -> Self { Self { @@ -74,6 +110,9 @@ impl Default for Settings { broken_cameras: BTreeMap::new(), check_updates: true, last_seen_version: None, + phone: None, + wifi_phones: Vec::new(), + forgotten_wifi: Vec::new(), detailed_log: false, } } @@ -132,9 +171,30 @@ impl Settings { if self.language.as_deref().is_some_and(|l| !crate::app::i18n::SUPPORTED_LANGUAGES.contains(&l)) { self.language = None; } + let mut seen = std::collections::HashSet::new(); + self.wifi_phones.retain(|p| !p.id.is_empty() && seen.insert(p.id.clone())); + let mut seen = std::collections::HashSet::new(); + self.forgotten_wifi.retain(|id| !id.is_empty() && seen.insert(id.clone())); self } + /// Drops a Wi-Fi phone and remembers not to use it. + pub fn forget_wifi_phone(&mut self, id: &str) { + self.wifi_phones.retain(|p| p.id != id); + if !self.forgotten_wifi.iter().any(|i| i == id) { + self.forgotten_wifi.push(id.to_string()); + } + } + + /// Adds a Wi-Fi phone, or refreshes its name if it is known. + pub fn remember_wifi_phone(&mut self, id: &str, name: Option) { + self.forgotten_wifi.retain(|i| i != id); + match self.wifi_phones.iter_mut().find(|p| p.id == id) { + Some(p) => p.name = name.or(p.name.take()), + None => self.wifi_phones.push(WifiPhone { id: id.to_string(), name }), + } + } + /// Bit rate for the phone's encoder: the user's choice, or one that suits the resolution /// and frame rate (a third more for 60 fps). pub fn bitrate(&self) -> u32 { @@ -162,6 +222,7 @@ impl Settings { orientation: 0, // rotation is done on the PC, see `PipelineConfig::rotation` torch: false, zoom: None, + key_frame_interval: None, } } @@ -203,11 +264,56 @@ mod tests { assert_eq!(s.rotation, 0); assert_eq!((s.vcam_width, s.vcam_height), (1920, 1080)); + std::fs::write(&path, r#"{"wifiPhones": [{"id": "a"}, {"id": "a", "name": "x"}, {"id": ""}]}"#).unwrap(); + assert_eq!(Settings::load(&path).wifi_phones, [WifiPhone { id: "a".into(), name: None }]); + + // A bad entry costs only itself. + std::fs::write( + &path, + r#"{"mirror": true, "wifiPhones": [{"name": "no id"}, "text", {"id": 5}, {"id": "b", "name": 7}, {"id": "c"}]}"#, + ) + .unwrap(); + let s = Settings::load(&path); + assert!(s.mirror); + assert_eq!(s.wifi_phones, [WifiPhone { id: "c".into(), name: None }]); + std::fs::write(&path, r#"{"mirror": true, "wifiPhones": {"id": "c"}}"#).unwrap(); + let s = Settings::load(&path); + assert!(s.mirror && s.wifi_phones.is_empty()); + std::fs::write(&path, "not json").unwrap(); assert_eq!(Settings::load(&path), Settings::default()); std::fs::remove_dir_all(dir).unwrap(); } + #[test] + fn remembers_wifi_phones_once() { + let mut s = Settings::default(); + s.remember_wifi_phone("adb-1-x", None); + s.remember_wifi_phone("adb-1-x", Some("Pixel 8".into())); + s.remember_wifi_phone("adb-1-x", None); + s.remember_wifi_phone("10.0.0.2:5555", None); + assert_eq!(s.wifi_phones.len(), 2); + assert_eq!(s.wifi_phones[0].name.as_deref(), Some("Pixel 8")); + } + + #[test] + fn forgotten_wifi_phones() { + let mut s = Settings::default(); + s.remember_wifi_phone("adb-1-x", None); + s.forget_wifi_phone("adb-1-x"); + s.forget_wifi_phone("adb-1-x"); + assert!(s.wifi_phones.is_empty()); + assert_eq!(s.forgotten_wifi, ["adb-1-x"]); + s.remember_wifi_phone("adb-1-x", None); + assert!(s.forgotten_wifi.is_empty()); + + let s: Settings = + serde_json::from_str(r#"{"forgottenWifi": ["adb-1-x", 5, "", "adb-1-x", "10.0.0.2:5555"]}"#).unwrap(); + assert_eq!(s.sanitized().forgotten_wifi, ["adb-1-x", "10.0.0.2:5555"]); + let s: Settings = serde_json::from_str(r#"{"mirror": true, "forgottenWifi": "adb-1-x"}"#).unwrap(); + assert!(s.mirror && s.forgotten_wifi.is_empty()); + } + #[test] fn bitrate_follows_resolution_and_fps_unless_set() { let mut s = Settings::default(); diff --git a/src-tauri/src/wifi.rs b/src-tauri/src/wifi.rs new file mode 100644 index 0000000..e849bb4 --- /dev/null +++ b/src-tauri/src/wifi.rs @@ -0,0 +1,214 @@ +//! Phones over Wi-Fi: the pairing QR code, finding phones through mDNS, reconnecting a phone +//! that dropped off the network and moving a cable-connected phone to Wi-Fi. +//! +//! All of it is adb's own wireless debugging; nothing is installed on the phone. + +use std::time::{Duration, Instant}; + +use crate::adb::{Adb, MdnsKind}; + +/// Port for `adb tcpip`, the one Android Studio and scrcpy use. +pub const TCPIP_PORT: u16 = 5555; +/// mDNS serials end with this: `adb--._adb-tls-connect._tcp`. +const MDNS_CONNECT_SUFFIX: &str = "._adb-tls-connect._tcp"; +/// adb reconnects a paired phone by itself once it sees it on mDNS again; this is how long +/// to give it before connecting by address. +const AUTO_RECONNECT_WAIT: Duration = Duration::from_secs(3); +/// `adb tcpip` restarts adbd on the phone; it listens again after about a second. +const TCPIP_RESTART_WAIT: Duration = Duration::from_secs(1); +const TCPIP_CONNECT_TRIES: u32 = 6; +/// A phone on the same network answers `echo` in well under a second. +const ALIVE_TIMEOUT: Duration = Duration::from_secs(4); +/// For `adb connect`, `disconnect` and `mdns services` while reconnecting, so stopping the +/// camera is not held up by a phone that is gone. +pub const QUICK_TIMEOUT: Duration = Duration::from_secs(5); +/// Characters that are safe in the `WIFI:` QR payload without escaping. +const QR_ALPHABET: &[u8] = b"abcdefghijkmnpqrstuvwxyz23456789"; + +/// The phone's mDNS name inside an adb serial, if the serial is an mDNS one. adb appends +/// " (2)", " (3)"… when a stale transport still holds the name; that is dropped, so the name +/// stays the same phone's and matches `adb mdns services`. +pub fn mdns_name(serial: &str) -> Option<&str> { + let name = serial.strip_suffix(MDNS_CONNECT_SUFFIX)?; + let base = name + .strip_suffix(')') + .and_then(|s| s.rsplit_once(" (")) + .filter(|(_, n)| !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit())) + .map(|(base, _)| base); + Some(base.unwrap_or(name)) +} + +/// The adb serial of a phone adb connected by its mDNS name. +pub fn mdns_serial(name: &str) -> String { + format!("{name}{MDNS_CONNECT_SUFFIX}") +} + +/// What the QR code on the PC tells the phone: which mDNS name to announce while pairing and +/// the password to pair with. Same format as Android Studio's. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct QrPairing { + pub name: String, + pub password: String, +} + +impl QrPairing { + pub fn new() -> Self { + Self { name: format!("plugcam-{}", random_text(8)), password: random_text(10) } + } + + pub fn payload(&self) -> String { + format!("WIFI:T:ADB;S:{};P:{};;", self.name, self.password) + } + + /// The QR code as an SVG image drawn with `currentColor` on a transparent background. + pub fn svg(&self) -> String { + let code = qrcode::QrCode::new(self.payload().as_bytes()).expect("short payload always fits"); + let width = code.width(); + let quiet = 2; + let size = width + 2 * quiet; + let mut path = String::new(); + for (i, dark) in code.to_colors().iter().enumerate() { + if *dark == qrcode::Color::Dark { + let (x, y) = (i % width + quiet, i / width + quiet); + path.push_str(&format!("M{x} {y}h1v1h-1z")); + } + } + format!( + r#""# + ) + } +} + +impl Default for QrPairing { + fn default() -> Self { + Self::new() + } +} + +/// From the OS random generator. The alphabet has 32 characters, so `byte % 32` is unbiased. +fn random_text(len: usize) -> String { + let mut bytes = vec![0u8; len]; + getrandom::fill(&mut bytes).expect("the OS random generator works"); + bytes.iter().map(|b| QR_ALPHABET[*b as usize % QR_ALPHABET.len()] as char).collect() +} + +/// Where to `adb connect` a phone we knew under `serial`: `ip:port` serials are the address; +/// mDNS ones are looked up again, since the phone picks a new port whenever wireless debugging +/// restarts. +pub fn current_address(adb: &Adb, serial: &str) -> Option { + match mdns_name(serial) { + None => serial.contains(':').then(|| serial.to_string()), + Some(name) => adb + .mdns_services_within(QUICK_TIMEOUT) + .ok()? + .into_iter() + .find(|s| s.kind == MdnsKind::Connect && s.name == name) + .map(|s| s.address), + } +} + +/// Drops a Wi-Fi phone's adb connection and connects again, so a link that went stale on a +/// network hiccup is replaced by a fresh one. Returns the phone's serial once it is back, which +/// may differ from the old one: an mDNS phone connected by address shows up as `ip:port`. +/// Gives up between steps once `go_on` says so (the camera was stopped, the phone forgotten). +pub fn reconnect(adb: &Adb, serial: &str, go_on: &impl Fn() -> bool) -> Option { + let _ = adb.disconnect_within(serial, QUICK_TIMEOUT); + if mdns_name(serial).is_some() { + let until = Instant::now() + AUTO_RECONNECT_WAIT; + while Instant::now() < until { + if !go_on() { + return None; + } + if is_ready(adb, serial) { + return Some(serial.to_string()); + } + std::thread::sleep(Duration::from_millis(250)); + } + } + let address = current_address(adb, serial)?; + if !go_on() { + return None; + } + match adb.connect_within(&address, QUICK_TIMEOUT) { + Ok(()) => Some(address), + Err(e) => { + log::info!("reconnecting {serial}: {e}"); + None + } + } +} + +/// Whether adb still reaches the phone. A Wi-Fi link that died quietly stays listed as +/// `device` for a while, but commands on it hang. +pub fn is_alive(adb: &Adb, serial: &str) -> bool { + adb.output(Some(serial), &["shell", "echo", "ok"], ALIVE_TIMEOUT).is_ok_and(|o| o.success && o.stdout.trim() == "ok") +} + +fn is_ready(adb: &Adb, serial: &str) -> bool { + adb.devices().is_ok_and(|list| list.iter().any(|d| d.serial == serial && d.is_ready())) +} + +#[derive(Debug, thiserror::Error)] +pub enum SwitchError { + /// The phone has no Wi-Fi address: Wi-Fi is off or it is on mobile data only. + #[error("the phone is not on Wi-Fi")] + NoWifi, + #[error(transparent)] + Adb(#[from] crate::adb::AdbError), +} + +/// "From cable to Wi-Fi": restarts adb on a cable-connected phone in TCP mode and connects to +/// it over the network. Returns the new serial (`ip:5555`). The link is not encrypted and +/// lasts until the phone restarts. +pub fn switch_from_cable(adb: &Adb, serial: &str) -> Result { + let ip = adb.wifi_ip(serial).ok_or(SwitchError::NoWifi)?; + adb.tcpip(serial, TCPIP_PORT)?; + std::thread::sleep(TCPIP_RESTART_WAIT); + let address = format!("{ip}:{TCPIP_PORT}"); + let mut last = None; + for _ in 0..TCPIP_CONNECT_TRIES { + match adb.connect(&address) { + Ok(()) => return Ok(address), + Err(e) => last = Some(e), + } + std::thread::sleep(Duration::from_millis(700)); + } + Err(last.expect("at least one try").into()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn mdns_serials() { + assert_eq!(mdns_name("adb-834ee2d7-Xy1AbC._adb-tls-connect._tcp"), Some("adb-834ee2d7-Xy1AbC")); + assert_eq!(mdns_name("adb-834ee2d7-Xy1AbC (2)._adb-tls-connect._tcp"), Some("adb-834ee2d7-Xy1AbC")); + assert_eq!(mdns_name("adb-834ee2d7-Xy1AbC (12)._adb-tls-connect._tcp"), Some("adb-834ee2d7-Xy1AbC")); + assert_eq!(mdns_name("adb-834ee2d7-Xy1AbC (x)._adb-tls-connect._tcp"), Some("adb-834ee2d7-Xy1AbC (x)")); + assert_eq!(mdns_name("192.168.1.20:5555"), None); + assert_eq!(mdns_name("834ee2d7"), None); + } + + #[test] + fn qr_payload_is_android_studio_format() { + let q = QrPairing { name: "plugcam-abcd2345".into(), password: "pass23word".into() }; + assert_eq!(q.payload(), "WIFI:T:ADB;S:plugcam-abcd2345;P:pass23word;;"); + let svg = q.svg(); + assert!(svg.starts_with("")); + } + + #[test] + fn random_names_differ_and_are_safe() { + let (a, b) = (QrPairing::new(), QrPairing::new()); + assert_ne!(a, b); + assert!(a.name.starts_with("plugcam-") && a.name.len() == 16); + assert!(a.password.bytes().all(|c| QR_ALPHABET.contains(&c))); + assert_eq!(QR_ALPHABET.len(), 32); + } + + #[test] + fn mdns_serial_round_trip() { + assert_eq!(mdns_name(&mdns_serial("adb-834ee2d7-Xy1AbC")), Some("adb-834ee2d7-Xy1AbC")); + } +}