diff --git a/v2/README.md b/v2/README.md index 0c18e1e..fdfebf7 100644 --- a/v2/README.md +++ b/v2/README.md @@ -198,3 +198,13 @@ Default in the plan is **Svelte**. Override before running `create-tauri-app` if - [PLAN.md](PLAN.md) — phased porting roadmap with milestones - [`../docs/FEATURES.md`](../docs/FEATURES.md) — behavior spec (the source of truth) - v1: `Shield-Optimizer.ps1` at repo root + +## Third-party components + +The Remote tab's low-latency input channel bundles the +[scrcpy](https://github.com/Genymobile/scrcpy) server +(`src-tauri/resources/scrcpy-server-v3.1`, © Genymobile, licensed under the +[Apache License 2.0](https://github.com/Genymobile/scrcpy/blob/master/LICENSE)). +It is pushed to the device's temp storage (`/data/local/tmp`) and runs with +shell privileges only while a Remote session is open; the process exits when +the session closes. diff --git a/v2/package-lock.json b/v2/package-lock.json index 38e0847..2f4b7f9 100644 --- a/v2/package-lock.json +++ b/v2/package-lock.json @@ -1,12 +1,12 @@ { "name": "shield-optimizer-v2", - "version": "2.0.0-beta.13", + "version": "2.0.0-beta.14", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "shield-optimizer-v2", - "version": "2.0.0-beta.13", + "version": "2.0.0-beta.14", "license": "MIT", "dependencies": { "@tauri-apps/api": "^2", diff --git a/v2/src-tauri/resources/scrcpy-server-v3.1 b/v2/src-tauri/resources/scrcpy-server-v3.1 new file mode 100644 index 0000000..e80ea2a Binary files /dev/null and b/v2/src-tauri/resources/scrcpy-server-v3.1 differ diff --git a/v2/src-tauri/src/adb/driver.rs b/v2/src-tauri/src/adb/driver.rs index ce3a3af..f694129 100644 --- a/v2/src-tauri/src/adb/driver.rs +++ b/v2/src-tauri/src/adb/driver.rs @@ -94,6 +94,17 @@ pub trait AdbDriver: Send + Sync { "binary capture not supported by this driver", ))) } + + /// Spawn `adb ` as a long-lived child WITHOUT awaiting completion, + /// handing the caller the `Child` to own (configured `kill_on_drop(true)`). + /// Unlike `raw`/`shell`, the process is expected to keep running — for + /// resident helpers like the scrcpy control server. Default reports + /// unsupported so mocks need no extra wiring. + async fn spawn(&self, _args: &[&str]) -> AdbResult { + Err(AdbError::Io(std::io::Error::other( + "process spawn not supported by this driver", + ))) + } } /// The standard subprocess-backed driver. Wraps `tokio::process::Command`. @@ -242,6 +253,30 @@ impl AdbDriver for SubprocessAdb { Ok(output.stdout) } + + async fn spawn(&self, args: &[&str]) -> AdbResult { + if !self.binary.exists() { + return Err(AdbError::BinaryMissing { + path: self.binary.display().to_string(), + }); + } + + debug!(adb = ?self.binary, ?args, "adb spawn (long-lived)"); + + let mut cmd = Command::new(&self.binary); + cmd.args(args).kill_on_drop(true); + // Verified on device: the device-side `app_process` aborts at startup + // (exit 134, no Java output) when the adb client's stdin is fully + // *closed*. It needs an open fd — /dev/null works. A GUI app's inherited + // stdin is unreliable, so pin it to null explicitly. stdout/stderr are + // nulled too so the resident child can never block on a full pipe. + cmd.stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()); + super::hide_console_window(&mut cmd); + + cmd.spawn().map_err(AdbError::Io) + } } /// Locate an adb binary by checking the standard installation locations. diff --git a/v2/src-tauri/src/adb/mod.rs b/v2/src-tauri/src/adb/mod.rs index b138045..72ab4c0 100644 --- a/v2/src-tauri/src/adb/mod.rs +++ b/v2/src-tauri/src/adb/mod.rs @@ -7,6 +7,7 @@ pub mod driver; pub mod install; pub mod parse; +pub mod remote_input; pub mod scan; use tokio::process::Command; @@ -38,4 +39,5 @@ pub use parse::{ parse_total_pss_by_process, parse_usage_stats, AppUsage, DisplayMode, FileEntry, RamInfo, StorageInfo, }; +pub use remote_input::RemoteInputSession; pub use scan::{local_subnet_prefix, scan_subnet, ScanHit}; diff --git a/v2/src-tauri/src/adb/parse.rs b/v2/src-tauri/src/adb/parse.rs index b103acf..dcbf97b 100644 --- a/v2/src-tauri/src/adb/parse.rs +++ b/v2/src-tauri/src/adb/parse.rs @@ -48,7 +48,11 @@ pub fn parse_device_list(adb_devices_output: &str) -> Vec { let Some(status) = DeviceStatus::from_adb_str(status_str) else { continue; }; - let connection = if IP_PORT.is_match(serial) { + // Network if it's an `ip:port` serial OR an mDNS wireless-debugging + // serial (Android 11+ pairs over `_adb-tls-connect._tcp` etc.; those + // never look like `ip:port` but always carry the `_tcp` service tag). + // USB serials are plain hardware ids and contain neither. + let connection = if IP_PORT.is_match(serial) || serial.contains("._tcp") { ConnectionType::Network } else { ConnectionType::Usb @@ -534,6 +538,17 @@ mod tests { assert_eq!(entries[3].status, DeviceStatus::Offline); } + #[test] + fn mdns_wireless_debugging_serial_is_network_not_usb() { + // Android 11+ wireless debugging registers over mDNS; the serial is the + // service name, not an ip:port. It must still classify as Network. + let input = "List of devices attached\n\ + adb-58040DLCH005YV-jBeCEe._adb-tls-connect._tcp\tdevice\n"; + let entries = parse_device_list(input); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].connection, ConnectionType::Network); + } + #[test] fn ignores_header_and_blank_lines() { let input = "List of devices attached\n\n\n"; diff --git a/v2/src-tauri/src/adb/remote_input.rs b/v2/src-tauri/src/adb/remote_input.rs new file mode 100644 index 0000000..f7c3b44 --- /dev/null +++ b/v2/src-tauri/src/adb/remote_input.rs @@ -0,0 +1,586 @@ +//! Persistent scrcpy control-socket session for low-latency remote input. +//! +//! Today each remote key press shells out `adb shell "input keyevent N"`, which +//! cold-starts a JVM on the TV (~690 ms per press). This module replaces the +//! transport with scrcpy's control-only server: push the jar once, run it +//! resident via `app_process`, and stream fixed binary control messages over a +//! forwarded TCP socket — dropping per-press cost to network RTT. +//! +//! Layering note: every adb invocation (push / forward / the `app_process` +//! spawn) goes through the `AdbDriver` trait, per the one-wrapper rule. The raw +//! `TcpStream` to the forwarded local port is a DOCUMENTED exception to that +//! rule — the same kind of exception `scan.rs` makes for its route/ip probes. +//! There is no way to carry scrcpy's binary control protocol over the driver's +//! line-oriented `shell`; the socket is the protocol. + +use std::path::Path; +use std::sync::atomic::{AtomicU32, Ordering}; +use std::sync::Arc; + +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::TcpStream; +use tokio::process::Child; +use tracing::{debug, warn}; + +use crate::adb::AdbDriver; + +/// The scrcpy protocol version this jar implements. MUST equal the version +/// baked into `resources/scrcpy-server-v3.1` or the server aborts on launch. +const SCRCPY_VERSION: &str = "3.1"; + +/// Where the server jar is pushed on the device. +const DEVICE_JAR_PATH: &str = "/data/local/tmp/shieldopt-scrcpy-server.jar"; + +/// Bundled jar location, relative to both the Tauri resource root (for +/// `BaseDirectory::Resource`) and the crate root (dev fallback). Resolved at +/// runtime by `commands::state::resolve_scrcpy_server_jar`. +pub const SERVER_JAR_RESOURCE_PATH: &str = "resources/scrcpy-server-v3.1"; + +/// scrcpy control message type bytes. +const TYPE_INJECT_KEYCODE: u8 = 0; +const TYPE_INJECT_TEXT: u8 = 1; + +/// Android `KeyEvent` actions. +const ACTION_DOWN: u8 = 0; +const ACTION_UP: u8 = 1; + +/// scrcpy caps a single INJECT_TEXT at 300 chars; longer strings are clamped. +const MAX_TEXT_CHARS: usize = 300; + +/// How many times to retry the TCP connect before giving up. With +/// `tunnel_forward=true` the server LISTENS on the localabstract socket and we +/// connect after `adb forward`, so there is a brief startup race. +const CONNECT_ATTEMPTS: usize = 10; +const CONNECT_RETRY_DELAY: std::time::Duration = std::time::Duration::from_millis(150); + +/// Encode an INJECT_KEYCODE control message (always 14 bytes, big-endian): +/// `[type=0][action][u32 keycode][u32 repeat][u32 metaState]`. +pub fn encode_inject_keycode(action: u8, keycode: u32, repeat: u32, meta_state: u32) -> Vec { + let mut buf = Vec::with_capacity(14); + buf.push(TYPE_INJECT_KEYCODE); + buf.push(action); + buf.extend_from_slice(&keycode.to_be_bytes()); + buf.extend_from_slice(&repeat.to_be_bytes()); + buf.extend_from_slice(&meta_state.to_be_bytes()); + buf +} + +/// Encode an INJECT_TEXT control message: `[type=1][u32 byte-len][UTF-8 bytes]`. +/// Text is clamped to 300 chars (scrcpy's cap) before encoding; the length +/// prefix is the UTF-8 byte length of the clamped string. +pub fn encode_inject_text(text: &str) -> Vec { + let clamped: String = text.chars().take(MAX_TEXT_CHARS).collect(); + let bytes = clamped.as_bytes(); + let mut buf = Vec::with_capacity(5 + bytes.len()); + buf.push(TYPE_INJECT_TEXT); + buf.extend_from_slice(&(bytes.len() as u32).to_be_bytes()); + buf.extend_from_slice(bytes); + buf +} + +/// Format a scid as scrcpy expects: 8 lowercase hex digits, masked to 31 bits +/// (the high bit must be clear — the server parses scid as a positive int and +/// treats a negative value as "no scid"). +fn format_scid(value: u32) -> String { + format!("{:08x}", value & 0x7fff_ffff) +} + +/// Process-wide counter mixed into the time seed so two sessions started within +/// the same millisecond still get distinct scids. +static SCID_COUNTER: AtomicU32 = AtomicU32::new(0); + +fn next_scid() -> String { + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.subsec_nanos()) + .unwrap_or(0); + let bump = SCID_COUNTER.fetch_add(1, Ordering::Relaxed); + format_scid(nanos ^ bump.wrapping_mul(2654435761)) +} + +/// `adb -s forward tcp: localabstract:scrcpy_` argument +/// vector. The `-s` is load-bearing: without it, `adb forward` errors with +/// "more than one device/emulator" whenever a second device is connected. +fn forward_args(serial: &str, port: u16, scid: &str) -> Vec { + vec![ + "-s".to_string(), + serial.to_string(), + "forward".to_string(), + format!("tcp:{port}"), + format!("localabstract:scrcpy_{scid}"), + ] +} + +/// `adb -s forward --remove tcp:` argument vector. +fn forward_remove_args(serial: &str, port: u16) -> Vec { + vec![ + "-s".to_string(), + serial.to_string(), + "forward".to_string(), + "--remove".to_string(), + format!("tcp:{port}"), + ] +} + +/// `adb -s push ` argument vector. +fn push_args(serial: &str, local_jar: &str) -> Vec { + vec![ + "-s".to_string(), + serial.to_string(), + "push".to_string(), + local_jar.to_string(), + DEVICE_JAR_PATH.to_string(), + ] +} + +/// The full `adb` argument vector that launches the resident server: +/// `-s shell CLASSPATH= app_process / com.genymobile.scrcpy.Server scid=… …`. +/// Pure + deterministic given `serial`/`scid` so it can be asserted byte-for-byte. +fn server_spawn_args(serial: &str, scid: &str) -> Vec { + vec![ + "-s".to_string(), + serial.to_string(), + "shell".to_string(), + format!("CLASSPATH={DEVICE_JAR_PATH}"), + "app_process".to_string(), + "/".to_string(), + "com.genymobile.scrcpy.Server".to_string(), + SCRCPY_VERSION.to_string(), + format!("scid={scid}"), + "log_level=info".to_string(), + "video=false".to_string(), + "audio=false".to_string(), + "control=true".to_string(), + "tunnel_forward=true".to_string(), + "send_device_meta=false".to_string(), + "send_dummy_byte=true".to_string(), + ] +} + +/// Reserve a free local TCP port by binding to `:0` and reading back the +/// kernel-assigned port. There's a small TOCTOU window between drop and `adb +/// forward`, accepted as standard practice. +fn pick_free_local_port() -> Result { + let listener = std::net::TcpListener::bind("127.0.0.1:0") + .map_err(|e| format!("scrcpy: could not reserve a local port: {e}"))?; + let port = listener + .local_addr() + .map_err(|e| format!("scrcpy: could not read local port: {e}"))? + .port(); + Ok(port) +} + +/// Connect to the forwarded port AND complete the handshake, retrying the +/// whole sequence. The retry must cover the dummy-byte read, not just the +/// connect: with `adb forward`, the local adb daemon accepts the TCP +/// connection immediately even while the device-side socket isn't listening +/// yet, then closes it (EOF). Only a successful 0x00 read proves the server +/// is actually on the other end — same dance scrcpy's own client does. +async fn connect_with_retry(port: u16) -> Result { + let mut last_err = String::new(); + for attempt in 0..CONNECT_ATTEMPTS { + match TcpStream::connect(("127.0.0.1", port)).await { + Ok(mut stream) => { + let mut dummy = [0u8; 1]; + match tokio::time::timeout( + std::time::Duration::from_secs(2), + stream.read_exact(&mut dummy), + ) + .await + { + Ok(Ok(_)) if dummy[0] == 0x00 => return Ok(stream), + Ok(Ok(_)) => { + return Err(format!( + "scrcpy: unexpected handshake byte {:#04x} (expected 0x00)", + dummy[0] + )) + } + Ok(Err(e)) => last_err = format!("handshake read: {e}"), + Err(_) => last_err = "handshake read timed out".to_string(), + } + } + Err(e) => last_err = e.to_string(), + } + if attempt + 1 < CONNECT_ATTEMPTS { + tokio::time::sleep(CONNECT_RETRY_DELAY).await; + } + } + Err(format!( + "scrcpy: control channel never came up on 127.0.0.1:{port} after \ + {CONNECT_ATTEMPTS} attempts: {last_err}" + )) +} + +/// A live scrcpy control session bound to one device serial. Holds the open +/// control socket and the resident server child for the lifetime of the Remote +/// tab; the server exits the instant the socket closes, so the session must +/// keep both alive and tear down explicitly. +pub struct RemoteInputSession { + stream: TcpStream, + /// Kept alive for the session; `kill_on_drop(true)` reaps the server. + _child: Child, + scid: String, + port: u16, + serial: String, + /// Driver retained for async teardown (`adb forward --remove`). + adb: Arc, +} + +impl RemoteInputSession { + /// Push the server jar, forward a fresh local port to its control socket, + /// spawn the resident server, connect, and consume the handshake dummy byte. + pub async fn start( + adb: Arc, + jar_path: &Path, + serial: &str, + ) -> Result { + let local_jar = jar_path + .to_str() + .ok_or_else(|| "scrcpy: server jar path is not valid UTF-8".to_string())?; + + let push = push_args(serial, local_jar); + adb.raw_transfer(&as_str_args(&push)) + .await + .map_err(|e| format!("scrcpy: push server jar: {e}"))?; + + let scid = next_scid(); + let port = pick_free_local_port()?; + + let forward = forward_args(serial, port, &scid); + adb.raw(&as_str_args(&forward)) + .await + .map_err(|e| format!("scrcpy: adb forward tcp:{port}: {e}"))?; + + let spawn = server_spawn_args(serial, &scid); + let child = match adb.spawn(&as_str_args(&spawn)).await { + Ok(child) => child, + Err(e) => { + // Don't leak the forward we just created. + let _ = adb + .raw(&as_str_args(&forward_remove_args(serial, port))) + .await; + return Err(format!("scrcpy: spawn control server: {e}")); + } + }; + + let session = Self { + // Connect failure tears down explicitly (forward-remove + child + // kill) rather than leaking the resources start() just created. + stream: match connect_with_retry(port).await { + Ok(stream) => stream, + Err(e) => { + let mut child = child; + let _ = child.start_kill(); + let _ = adb + .raw(&as_str_args(&forward_remove_args(serial, port))) + .await; + return Err(e); + } + }, + _child: child, + scid, + port, + serial: serial.to_string(), + adb, + }; + + // The handshake dummy byte was already consumed inside + // connect_with_retry — the stream is ready for control messages. + debug!( + serial = %session.serial, + scid = %session.scid, + port = session.port, + "scrcpy control session established" + ); + Ok(session) + } + + /// Inject a single key-down or key-up event. + pub async fn send_key(&mut self, keycode: u32, down: bool) -> Result<(), String> { + let action = if down { ACTION_DOWN } else { ACTION_UP }; + let msg = encode_inject_keycode(action, keycode, 0, 0); + self.write_all(&msg).await + } + + /// Inject a full key press (down then up). + pub async fn send_key_press(&mut self, keycode: u32) -> Result<(), String> { + self.send_key(keycode, true).await?; + self.send_key(keycode, false).await + } + + /// Inject UTF-8 text (clamped to 300 chars). + pub async fn send_text(&mut self, text: &str) -> Result<(), String> { + let msg = encode_inject_text(text); + self.write_all(&msg).await + } + + async fn write_all(&mut self, bytes: &[u8]) -> Result<(), String> { + self.stream + .write_all(bytes) + .await + .map_err(|e| format!("scrcpy: control socket write failed: {e}"))?; + self.stream + .flush() + .await + .map_err(|e| format!("scrcpy: control socket flush failed: {e}")) + } + + /// Graceful teardown. Order matters (verified on device): close the control + /// socket FIRST — the server exits on its own the instant the socket drops — + /// then a belt-and-suspenders `pkill` for any lingering server process, then + /// reap the local child and remove the adb forward. `Drop` is the + /// best-effort backstop; prefer this. + pub async fn close(mut self) { + let _ = self.stream.shutdown().await; + // Match our jar's name, not "com.genymobile.scrcpy" — the broad pattern + // would also kill a desktop scrcpy session the user has open against + // this same device. + let _ = self + .adb + .shell(&self.serial, "pkill -f shieldopt-scrcpy-server") + .await; + let _ = self._child.start_kill(); + let _ = self + .adb + .raw(&as_str_args(&forward_remove_args(&self.serial, self.port))) + .await; + } +} + +impl Drop for RemoteInputSession { + fn drop(&mut self) { + // `_child` has kill_on_drop(true), so the server dies and the control + // socket closes here. Best-effort removal of the adb forward entry if a + // tokio runtime is live (e.g. app exit dropping the session registry). + let adb = self.adb.clone(); + let serial = self.serial.clone(); + let port = self.port; + if let Ok(handle) = tokio::runtime::Handle::try_current() { + handle.spawn(async move { + let args = forward_remove_args(&serial, port); + let _ = adb.raw(&as_str_args(&args)).await; + }); + } else { + warn!( + port, + "scrcpy: no runtime in Drop; adb forward entry may leak" + ); + } + } +} + +/// Borrow a `Vec` as the `&[&str]` the driver wants. +fn as_str_args(args: &[String]) -> Vec<&str> { + args.iter().map(String::as_str).collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + + #[test] + fn inject_keycode_down_select_is_exact_14_bytes() { + // SELECT/center = 23, action down, repeat 0, meta 0. + let got = encode_inject_keycode(ACTION_DOWN, 23, 0, 0); + assert_eq!( + got, + vec![ + 0x00, 0x00, 0x00, 0x00, 0x00, 0x17, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 + ] + ); + assert_eq!(got.len(), 14); + } + + #[test] + fn inject_keycode_up_dpad_with_repeat_and_meta() { + // D-pad up = 19 (0x13), action up = 1, repeat = 2, meta = 0x1001. + let got = encode_inject_keycode(ACTION_UP, 19, 2, 0x0000_1001); + assert_eq!( + got, + vec![ + 0x00, // type + 0x01, // action up + 0x00, 0x00, 0x00, 0x13, // keycode 19 + 0x00, 0x00, 0x00, 0x02, // repeat 2 + 0x00, 0x00, 0x10, 0x01, // meta + ] + ); + } + + #[test] + fn inject_text_encodes_type_len_then_utf8() { + let got = encode_inject_text("hi"); + assert_eq!(got, vec![0x01, 0x00, 0x00, 0x00, 0x02, b'h', b'i']); + } + + #[test] + fn inject_text_length_is_utf8_byte_count_not_char_count() { + // "é" is 2 UTF-8 bytes — the length prefix must be 2, not 1. + let got = encode_inject_text("é"); + assert_eq!(got[0], TYPE_INJECT_TEXT); + assert_eq!(&got[1..5], &[0x00, 0x00, 0x00, 0x02]); + assert_eq!(&got[5..], "é".as_bytes()); + } + + #[test] + fn inject_text_clamps_to_300_chars() { + let long = "a".repeat(500); + let got = encode_inject_text(&long); + // 1 type byte + 4 length bytes + 300 payload bytes. + assert_eq!(got.len(), 5 + 300); + assert_eq!(&got[1..5], &300u32.to_be_bytes()); + } + + #[test] + fn format_scid_is_8_hex_digits_with_high_bit_cleared() { + assert_eq!(format_scid(0), "00000000"); + assert_eq!(format_scid(0x0000_00ff), "000000ff"); + // High bit is masked off. + assert_eq!(format_scid(0xffff_ffff), "7fffffff"); + assert_eq!(format_scid(0x8000_0001), "00000001"); + } + + #[test] + fn server_spawn_args_match_phase0_launch_line() { + assert_eq!( + server_spawn_args("ABC123", "0a1b2c3d"), + vec![ + "-s", + "ABC123", + "shell", + "CLASSPATH=/data/local/tmp/shieldopt-scrcpy-server.jar", + "app_process", + "/", + "com.genymobile.scrcpy.Server", + "3.1", + "scid=0a1b2c3d", + "log_level=info", + "video=false", + "audio=false", + "control=true", + "tunnel_forward=true", + "send_device_meta=false", + "send_dummy_byte=true", + ] + ); + } + + #[test] + fn forward_and_push_args_are_well_formed() { + assert_eq!( + forward_args("ABC123", 41000, "0a1b2c3d"), + vec![ + "-s", + "ABC123", + "forward", + "tcp:41000", + "localabstract:scrcpy_0a1b2c3d" + ] + ); + assert_eq!( + forward_remove_args("ABC123", 41000), + vec!["-s", "ABC123", "forward", "--remove", "tcp:41000"] + ); + assert_eq!( + push_args("ABC123", "/tmp/scrcpy-server-v3.1"), + vec![ + "-s", + "ABC123", + "push", + "/tmp/scrcpy-server-v3.1", + "/data/local/tmp/shieldopt-scrcpy-server.jar", + ] + ); + } + + #[test] + fn pick_free_local_port_returns_a_usable_port() { + let port = pick_free_local_port().expect("should reserve a port"); + assert!(port > 0); + } + + // Honest error-path coverage: with a driver that can't spawn a child + // (MockAdb uses the trait's default `spawn`, which returns unsupported), + // start() must surface the spawn failure rather than hang or fake success. + // The live socket path is left for the lead's on-device check. + #[tokio::test] + async fn start_fails_when_driver_cannot_spawn() { + use crate::commands::test_support::MockAdb; + let adb: Arc = Arc::new(MockAdb::default()); + let jar = std::path::PathBuf::from("/tmp/scrcpy-server-v3.1"); + let result = RemoteInputSession::start(adb, &jar, "SERIAL123").await; + match result { + Ok(_) => panic!("start must fail when the driver cannot spawn the server"), + Err(err) => assert!( + err.contains("spawn control server"), + "unexpected error: {err}" + ), + } + } + + // Live end-to-end test against a real device — ignored in CI, run by hand: + // SHIELD_TEST_SERIAL=192.168.x.x:5555 cargo test remote_input_live -- --ignored --nocapture + // Starts a real session, injects SLEEP (223) and verifies via `dumpsys + // power` that the device went to sleep, then WAKEUP (224) and verifies it + // woke, then tears down and checks no server process is left behind. + #[tokio::test] + #[ignore] + async fn remote_input_live_roundtrip() { + let Ok(serial) = std::env::var("SHIELD_TEST_SERIAL") else { + panic!("set SHIELD_TEST_SERIAL to run this live test"); + }; + let adb: Arc = Arc::new( + crate::adb::SubprocessAdb::discover().expect("adb binary required for live test"), + ); + let jar = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join(super::SERVER_JAR_RESOURCE_PATH); + + async fn wakefulness(adb: &dyn AdbDriver, serial: &str) -> String { + adb.shell(serial, "dumpsys power | grep -m1 mWakefulness") + .await + .map(|o| o.stdout.trim().rsplit('=').next().unwrap_or("").to_string()) + .unwrap_or_default() + } + + let mut session = RemoteInputSession::start(adb.clone(), &jar, &serial) + .await + .expect("session start"); + + let t0 = std::time::Instant::now(); + session.send_key_press(223).await.expect("inject SLEEP"); + let mut state = String::new(); + for _ in 0..40 { + state = wakefulness(adb.as_ref(), &serial).await; + if state == "Asleep" { + break; + } + } + println!( + "SLEEP -> {state} in {:?} (incl. dumpsys polling)", + t0.elapsed() + ); + assert_eq!(state, "Asleep", "device should have gone to sleep"); + + tokio::time::sleep(std::time::Duration::from_millis(800)).await; + session.send_key_press(224).await.expect("inject WAKEUP"); + tokio::time::sleep(std::time::Duration::from_millis(1500)).await; + assert_eq!( + wakefulness(adb.as_ref(), &serial).await, + "Awake", + "device should have woken" + ); + + session.close().await; + tokio::time::sleep(std::time::Duration::from_millis(800)).await; + let leftovers = adb + .shell(&serial, "ps -A | grep shieldopt-scrcpy || true") + .await + .map(|o| o.stdout.trim().to_string()) + .unwrap_or_default(); + assert!( + leftovers.is_empty(), + "server process left behind after close(): {leftovers}" + ); + } +} diff --git a/v2/src-tauri/src/commands/apps.rs b/v2/src-tauri/src/commands/apps.rs index ce1b338..02616d2 100644 --- a/v2/src-tauri/src/commands/apps.rs +++ b/v2/src-tauri/src/commands/apps.rs @@ -414,7 +414,12 @@ pub async fn set_app_op_impl( }); } let mode = if allow { "allow" } else { "deny" }; - run(state, serial, &format!("cmd appops set {package} {op} {mode}")).await + run( + state, + serial, + &format!("cmd appops set {package} {op} {mode}"), + ) + .await } /// `get_app_op` — reads the current appops mode for ` `. @@ -435,7 +440,10 @@ pub async fn get_app_op_impl( package: &str, op: &str, ) -> Result { - if !is_valid_package_name(package) || !op.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') || op.is_empty() { + if !is_valid_package_name(package) + || !op.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') + || op.is_empty() + { return Ok("missing".to_string()); } let adb = state.adb_snapshot().await; diff --git a/v2/src-tauri/src/commands/devices.rs b/v2/src-tauri/src/commands/devices.rs index 4e15cd9..9ea9232 100644 --- a/v2/src-tauri/src/commands/devices.rs +++ b/v2/src-tauri/src/commands/devices.rs @@ -137,6 +137,9 @@ pub async fn disconnect_device( state: State<'_, AppState>, serial: String, ) -> Result { + // A live remote-input session holds an open socket + forward to this + // device — tear it down before dropping the connection. + state.drop_remote_session(&serial).await; let adb = state.adb_snapshot().await; let out = adb .raw(&["disconnect", &serial]) @@ -249,7 +252,7 @@ async fn harvest_properties(adb: &dyn AdbDriver, serial: &str) -> DeviceProperti getprop ro.product.model; getprop ro.product.device; \ getprop ro.product.manufacturer; getprop ro.build.version.release; \ getprop ro.build.version.sdk; getprop ro.build.id; \ - getprop ro.board.platform"; + getprop ro.board.platform; getprop ro.build.characteristics"; let Ok(out) = adb.shell(serial, cmd).await else { return DeviceProperties::default(); @@ -285,6 +288,7 @@ async fn harvest_properties(adb: &dyn AdbDriver, serial: &str) -> DeviceProperti sdk_level: get(6), build_id: get(7), board_platform: get(8), + characteristics: get(9), } } diff --git a/v2/src-tauri/src/commands/input.rs b/v2/src-tauri/src/commands/input.rs index 1931f84..7e0825f 100644 --- a/v2/src-tauri/src/commands/input.rs +++ b/v2/src-tauri/src/commands/input.rs @@ -1,15 +1,37 @@ -//! Send text to the TV — `input text` over ADB, for typing Wi-Fi passwords -//! and searches from a real keyboard instead of the on-screen D-pad grid. +//! Remote input — keys and text to the TV. +//! +//! Two transports, tried in order: +//! 1. The persistent scrcpy control channel (`adb::remote_input`) — per-press +//! cost is network RTT (~ms). Lazily started on first use; full UTF-8 text. +//! 2. `adb shell input …` — the slow (~690 ms/press, ASCII-only) but +//! universally-available fallback when the channel can't start or its +//! socket dies. A failed channel send drops the session so the next press +//! retries a fresh start. use serde::Serialize; use tauri::State; +use super::state::resolve_scrcpy_server_jar; use super::AppState; #[derive(Serialize)] pub struct SendTextResult { pub ok: bool, pub message: String, + /// Which transport served this request: "channel" (scrcpy control socket) + /// or "shell" (legacy `input` fallback). The Remote tab shows a live cue. + pub transport: &'static str, +} + +/// Make sure the scrcpy session for `serial` is up (starting it if needed). +async fn channel_ready( + state: &AppState, + app: &tauri::AppHandle, + serial: &str, +) -> Result<(), String> { + let jar = resolve_scrcpy_server_jar(app)?; + let adb = state.adb_snapshot().await; + state.ensure_remote_session(adb, &jar, serial).await } const MAX_TEXT_LEN: usize = 500; @@ -42,15 +64,63 @@ fn encode_input_text(text: &str) -> Result { } /// `send_text` — type `text` into whatever input field has focus on the TV. +/// Channel first (full UTF-8, instant); `input text` (ASCII-only) fallback. #[tauri::command] pub async fn send_text( + app: tauri::AppHandle, state: State<'_, AppState>, serial: String, text: String, + // When true, skip the fast channel and use `input text` directly — the + // Remote tab's "Force compatible mode" escape hatch for devices where the + // channel starts but misbehaves. + force_shell: bool, ) -> Result { + if text.is_empty() { + return Ok(SendTextResult { + ok: false, + message: "Nothing to send.".to_string(), + transport: "none", + }); + } + + let channel = if force_shell { + Err("compatibility mode forced".to_string()) + } else { + match channel_ready(&state, &app, &serial).await { + Ok(()) => match state.remote_send_text(&serial, &text).await { + Ok(()) => Ok(()), + Err(e) => { + state.drop_remote_session(&serial).await; + Err(e) + } + }, + Err(e) => Err(e), + } + }; + if channel.is_ok() { + return Ok(SendTextResult { + ok: true, + message: format!( + "Sent {} character(s) to the focused field.", + text.chars().count() + ), + transport: "channel", + }); + } + if !force_shell { + tracing::warn!(error = ?channel, %serial, "scrcpy channel unavailable; using input text"); + } + let encoded = match encode_input_text(&text) { Ok(e) => e, - Err(message) => return Ok(SendTextResult { ok: false, message }), + Err(message) => { + return Ok(SendTextResult { + ok: false, + message, + transport: "shell", + }) + } }; let adb = state.adb_snapshot().await; let out = adb @@ -63,11 +133,13 @@ pub async fn send_text( Ok(SendTextResult { ok: true, message: format!("Sent {} character(s) to the focused field.", text.len()), + transport: "shell", }) } else { Ok(SendTextResult { ok: false, message: noise, + transport: "shell", }) } } @@ -83,6 +155,7 @@ fn keycode_for(key: &str) -> Option { "select" => 23, "back" => 4, "home" => 3, + "recents" => 187, "play_pause" => 85, "rewind" => 89, "fast_forward" => 90, @@ -100,17 +173,47 @@ fn keycode_for(key: &str) -> Option { }) } -/// `send_key` — one remote button press (`input keyevent `). Used by -/// the Remote panel's D-pad and by live typing for Backspace/Enter. +/// `send_key` — one remote button press. Channel first (instant, real +/// down/up); `input keyevent` fallback. Used by the Remote panel's D-pad and +/// by live typing for Backspace/Enter. #[tauri::command] pub async fn send_key( + app: tauri::AppHandle, state: State<'_, AppState>, serial: String, key: String, + // See `send_text` — forces the slow `input keyevent` path. + force_shell: bool, ) -> Result { let Some(code) = keycode_for(&key) else { return Err(format!("Unknown remote key: {key:?}")); }; + + let channel = if force_shell { + Err("compatibility mode forced".to_string()) + } else { + match channel_ready(&state, &app, &serial).await { + Ok(()) => match state.remote_send_key_press(&serial, code).await { + Ok(()) => Ok(()), + Err(e) => { + state.drop_remote_session(&serial).await; + Err(e) + } + }, + Err(e) => Err(e), + } + }; + if channel.is_ok() { + return Ok(SendTextResult { + ok: true, + message: format!("Sent {key}."), + transport: "channel", + }); + } + if !force_shell { + tracing::warn!(error = ?channel, %serial, "scrcpy channel unavailable; using input keyevent"); + } + let adb = state.adb_snapshot().await; let out = adb .shell(&serial, &format!("input keyevent {code}")) @@ -124,6 +227,36 @@ pub async fn send_key( } else { noise }, + transport: "shell", + }) +} + +/// `open_settings` — launch the system Settings activity. The Shield remote's +/// hamburger/gear button opens Settings via an *intent*, not a keycode — which +/// is why `KEYCODE_SETTINGS` (176) and `KEYCODE_MENU` (82) both no-op when +/// injected. `am start` reliably opens Settings on Android TV / Google TV. +#[tauri::command] +pub async fn open_settings( + state: State<'_, AppState>, + serial: String, +) -> Result { + let adb = state.adb_snapshot().await; + let out = adb + .shell(&serial, "am start -a android.settings.SETTINGS") + .await + .map_err(|e| format!("am start: {e}"))?; + // `am start` prints "Starting: Intent { ... }" on success and an + // "Error"/"Exception" line on failure. + let combined = out.combined(); + let ok = !combined.contains("Error") && !combined.contains("Exception"); + Ok(SendTextResult { + ok, + message: if ok { + "Opened Settings.".to_string() + } else { + combined.trim().to_string() + }, + transport: "shell", }) } @@ -160,6 +293,7 @@ mod tests { assert_eq!(keycode_for("up"), Some(19)); assert_eq!(keycode_for("select"), Some(23)); assert_eq!(keycode_for("back"), Some(4)); + assert_eq!(keycode_for("recents"), Some(187)); assert_eq!(keycode_for("delete"), Some(67)); assert_eq!(keycode_for("wakeup"), Some(224)); assert_eq!(keycode_for("power"), Some(26)); diff --git a/v2/src-tauri/src/commands/state.rs b/v2/src-tauri/src/commands/state.rs index 93c0f91..321a85d 100644 --- a/v2/src-tauri/src/commands/state.rs +++ b/v2/src-tauri/src/commands/state.rs @@ -1,13 +1,14 @@ //! Shared application state held across Tauri command invocations. use std::collections::HashMap; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::sync::Arc; -use tokio::sync::RwLock; +use tokio::sync::{Mutex, RwLock}; use crate::adb::driver::discover_adb_binary; -use crate::adb::{AdbDriver, AdbError, AdbOutput, AdbResult, SubprocessAdb}; +use crate::adb::remote_input::SERVER_JAR_RESOURCE_PATH; +use crate::adb::{AdbDriver, AdbError, AdbOutput, AdbResult, RemoteInputSession, SubprocessAdb}; use crate::engine::AppListBundle; /// State managed by Tauri's state store. Held by `tauri::Builder::manage`. @@ -28,6 +29,9 @@ pub struct AppState { /// ID. There's no cheap way to read an app's label over adb, so this is a /// curated map loaded from `data/app-lists/known-names.json`. pub known_names: HashMap, + /// Live scrcpy control sessions, keyed by device serial. Lazily started on + /// the first remote key and held open for the Remote tab's lifetime. + pub remote_sessions: Mutex>, } impl AppState { @@ -38,6 +42,7 @@ impl AppState { snapshot_dir: data_dir.join("snapshots"), data_dir, known_names: HashMap::new(), + remote_sessions: Mutex::new(HashMap::new()), } } @@ -76,6 +81,100 @@ impl AppState { pub async fn replace_adb(&self, new_driver: Arc) { *self.adb.write().await = new_driver; } + + /// Get-or-start the scrcpy control session for `serial`. The slow `start()` + /// (push + forward + spawn + connect) runs OUTSIDE the registry lock so a + /// cold start can't block other commands; the lock is only taken for the + /// fast presence check and the final insert. If two callers race, the loser + /// tears its extra session down. + pub async fn ensure_remote_session( + &self, + adb: Arc, + jar_path: &Path, + serial: &str, + ) -> Result<(), String> { + if self.remote_sessions.lock().await.contains_key(serial) { + return Ok(()); + } + let session = RemoteInputSession::start(adb, jar_path, serial).await?; + let mut guard = self.remote_sessions.lock().await; + if guard.contains_key(serial) { + drop(guard); + session.close().await; + } else { + guard.insert(serial.to_string(), session); + } + Ok(()) + } + + /// Inject a single key-down / key-up via the live session. Errors if no + /// session exists — Phase 3 calls `ensure_remote_session` first, and on a + /// write error should `drop_remote_session` and fall back to `input`. + pub async fn remote_send_key( + &self, + serial: &str, + keycode: u32, + down: bool, + ) -> Result<(), String> { + let mut guard = self.remote_sessions.lock().await; + let session = guard + .get_mut(serial) + .ok_or_else(|| "no active remote session".to_string())?; + session.send_key(keycode, down).await + } + + /// Inject a full key press (down + up) via the live session. + pub async fn remote_send_key_press(&self, serial: &str, keycode: u32) -> Result<(), String> { + let mut guard = self.remote_sessions.lock().await; + let session = guard + .get_mut(serial) + .ok_or_else(|| "no active remote session".to_string())?; + session.send_key_press(keycode).await + } + + /// Inject UTF-8 text via the live session. + pub async fn remote_send_text(&self, serial: &str, text: &str) -> Result<(), String> { + let mut guard = self.remote_sessions.lock().await; + let session = guard + .get_mut(serial) + .ok_or_else(|| "no active remote session".to_string())?; + session.send_text(text).await + } + + /// Tear down and forget the session for `serial`, if any. Removes it from + /// the registry first, then closes outside the lock. + pub async fn drop_remote_session(&self, serial: &str) { + let session = self.remote_sessions.lock().await.remove(serial); + if let Some(session) = session { + session.close().await; + } + } +} + +/// Resolve the on-disk path of the bundled scrcpy server jar. Prefers the +/// Tauri resource directory (production install); falls back to the +/// crate-relative `resources/` dir for `cargo run` / `cargo test`. +/// +/// Phase 3: the remote-input command calls this with its `AppHandle` to get the +/// jar path, then hands it to `AppState::ensure_remote_session`. +pub fn resolve_scrcpy_server_jar(app: &tauri::AppHandle) -> Result { + use tauri::Manager; + if let Ok(p) = app.path().resolve( + SERVER_JAR_RESOURCE_PATH, + tauri::path::BaseDirectory::Resource, + ) { + if p.is_file() { + return Ok(p); + } + } + let dev = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(SERVER_JAR_RESOURCE_PATH); + if dev.is_file() { + return Ok(dev); + } + Err(format!( + "scrcpy server jar not found (looked in the Tauri resource dir and {})", + dev.display() + )) } /// Driver used when no adb binary could be discovered at startup. Every call diff --git a/v2/src-tauri/src/engine/detection.rs b/v2/src-tauri/src/engine/detection.rs index 266ec38..a326d82 100644 --- a/v2/src-tauri/src/engine/detection.rs +++ b/v2/src-tauri/src/engine/detection.rs @@ -37,8 +37,17 @@ pub fn detect_device_type(props: &DeviceProperties) -> DeviceType { let model = props.model.to_ascii_lowercase(); let device = props.device_codename.to_ascii_lowercase(); let manufacturer = props.manufacturer.to_ascii_lowercase(); - - // Shield: any signal from Nvidia or known Shield codenames. + // `ro.build.characteristics` carries `tv` on Android TV / Google TV. It is + // the signal that separates a TV from a phone or tablet sharing the same + // brand — a Google Pixel is `brand == "google"` too, so brand alone is not + // enough to call something a TV. + let is_tv = props + .characteristics + .split(',') + .any(|c| c.trim().eq_ignore_ascii_case("tv")); + + // Shield: any signal from Nvidia or known Shield codenames. Shield boxes + // are never phones, so these strong signals don't need the `tv` gate. if brand == "nvidia" || manufacturer == "nvidia" || model.contains("shield") @@ -47,13 +56,10 @@ pub fn detect_device_type(props: &DeviceProperties) -> DeviceType { return DeviceType::Shield; } - // Google TV: Onn (Walmart), Google-branded, or device codename matching - // known Google TV products. Amlogic-based Onn boxes (`ott_...`) and the - // newer Chromecast / Streamer codenames (`sabrina`, `boreal`) belong here. + // Google TV by strong product-specific signals: Onn (Walmart) boxes + // (`ott_...`), Chromecast, and the newer Streamer codenames are TV-only + // products, so they stand on their own. if brand == "onn" - || brand == "google" - || manufacturer == "google" - || manufacturer == "amlogic" || model.contains("onn") || model.contains("chromecast") || model.contains("sabrina") @@ -64,6 +70,19 @@ pub fn detect_device_type(props: &DeviceProperties) -> DeviceType { return DeviceType::GoogleTv; } + // Google / Amlogic branding only means Google TV when the device actually + // reports the `tv` characteristic — otherwise it's a phone or tablet (e.g. + // a Google Pixel) that happens to share the brand. + if is_tv && (brand == "google" || manufacturer == "google" || manufacturer == "amlogic") { + return DeviceType::GoogleTv; + } + + // Any other device that reports itself as a TV: classify as Google TV so it + // gets the Android-TV app list rather than nothing. + if is_tv { + return DeviceType::GoogleTv; + } + DeviceType::Unknown } @@ -93,6 +112,19 @@ mod tests { } } + fn props_ch( + brand: &str, + model: &str, + device: &str, + manufacturer: &str, + characteristics: &str, + ) -> DeviceProperties { + DeviceProperties { + characteristics: characteristics.to_string(), + ..props(brand, model, device, manufacturer) + } + } + #[test] fn detects_shield_by_brand() { assert_eq!( @@ -134,6 +166,32 @@ mod tests { ); } + #[test] + fn google_branded_phone_is_not_google_tv() { + // A Google Pixel phone shares brand/manufacturer "google" but reports + // no `tv` characteristic — it must not be classified as Google TV. + assert_eq!( + detect_device_type(&props_ch("google", "Pixel 10 Pro", "blazer", "Google", "")), + DeviceType::Unknown + ); + } + + #[test] + fn google_branded_tv_with_tv_characteristic_is_google_tv() { + assert_eq!( + detect_device_type(&props_ch("google", "Some TV", "generic", "Google", "tv")), + DeviceType::GoogleTv + ); + } + + #[test] + fn generic_box_reporting_tv_characteristic_is_google_tv() { + assert_eq!( + detect_device_type(&props_ch("Generic", "TV Box", "rk3328", "Generic", "tv")), + DeviceType::GoogleTv + ); + } + #[test] fn unknown_when_no_signals() { assert_eq!( diff --git a/v2/src-tauri/src/engine/types.rs b/v2/src-tauri/src/engine/types.rs index 09fc162..016921d 100644 --- a/v2/src-tauri/src/engine/types.rs +++ b/v2/src-tauri/src/engine/types.rs @@ -55,6 +55,11 @@ pub struct DeviceProperties { pub build_id: String, /// `getprop ro.board.platform`. pub board_platform: String, + /// `getprop ro.build.characteristics` — comma-separated list that includes + /// `tv` on Android TV / Google TV devices. The signal that distinguishes a + /// TV from a phone or tablet sharing the same brand (e.g. Google Pixel). + #[serde(default)] + pub characteristics: String, } /// A connected device — what the device list shows and what every action targets. diff --git a/v2/src-tauri/src/lib.rs b/v2/src-tauri/src/lib.rs index 01058f9..e42c4e3 100644 --- a/v2/src-tauri/src/lib.rs +++ b/v2/src-tauri/src/lib.rs @@ -106,6 +106,7 @@ pub fn run() { apps::get_app_op, input::send_text, input::send_key, + input::open_settings, sideload::install_apk, sideload::list_apks_in_folder, backup::backup_apk, diff --git a/v2/src-tauri/tauri.conf.json b/v2/src-tauri/tauri.conf.json index e5bead6..2ae87fd 100644 --- a/v2/src-tauri/tauri.conf.json +++ b/v2/src-tauri/tauri.conf.json @@ -45,6 +45,9 @@ "icons/icon.icns", "icons/icon.ico" ], + "resources": [ + "resources/scrcpy-server-v3.1" + ], "windows": { "wix": { "version": "2.0.314" diff --git a/v2/src/lib/api.ts b/v2/src/lib/api.ts index 89270fd..3dfc3dd 100644 --- a/v2/src/lib/api.ts +++ b/v2/src/lib/api.ts @@ -128,10 +128,12 @@ export const api = { invoke>("app_usage_map", { serial }), safetyInfo: (pkg: string) => invoke("safety_info", { package: pkg }), trimCaches: (serial: string) => invoke("trim_caches", { serial }), - sendText: (serial: string, text: string) => - invoke("send_text", { serial, text }), - sendKey: (serial: string, key: string) => - invoke("send_key", { serial, key }), + sendText: (serial: string, text: string, forceShell = false) => + invoke("send_text", { serial, text, forceShell }), + sendKey: (serial: string, key: string, forceShell = false) => + invoke("send_key", { serial, key, forceShell }), + openSettings: (serial: string) => + invoke("open_settings", { serial }), installApk: (serial: string, apkPath: string, reinstall = true) => invoke("install_apk", { serial, apkPath, reinstall }), diff --git a/v2/src/lib/components/RemoteTab.svelte b/v2/src/lib/components/RemoteTab.svelte index 566798d..1be45fe 100644 --- a/v2/src/lib/components/RemoteTab.svelte +++ b/v2/src/lib/components/RemoteTab.svelte @@ -1,19 +1,39 @@
-

Remote

+
+

Remote

+ {#if transport} + + {transport === "channel" ? "● instant" : "○ compatible (slower)"} + + {/if} + +

Live typing

Click below and type — keystrokes go straight to whatever field has - focus on the TV, including Backspace and Enter. Each press is an ADB - round-trip, so it feels like typing over SSH. + focus on the TV, including Backspace and Enter.

Buttons

+
- + - + - + - +
+ +
+
+
@@ -153,6 +228,33 @@ margin: 0 0 0.8rem; font-size: 1.1rem; } + .remote-header { + display: flex; + align-items: baseline; + gap: 0.8rem; + flex-wrap: wrap; + } + .transport { + font-size: 0.74rem; + color: var(--fg-muted); + cursor: default; + } + .compat-toggle { + margin-left: auto; + display: flex; + align-items: center; + gap: 0.35rem; + font-size: 0.78rem; + color: var(--fg-muted); + cursor: pointer; + } + .compat-toggle input { + accent-color: var(--accent); + cursor: pointer; + } + .transport.live { + color: var(--ok); + } .card h3 { margin: 1rem 0 0.4rem; font-size: 1rem; diff --git a/v2/src/lib/prefs.ts b/v2/src/lib/prefs.ts index aa97852..0b114e7 100644 --- a/v2/src/lib/prefs.ts +++ b/v2/src/lib/prefs.ts @@ -10,3 +10,18 @@ export function setAutoUpdate(enabled: boolean): void { localStorage.setItem(KEY, String(enabled)); } } + +const REMOTE_COMPAT_KEY = "shieldopt.remoteForceShell"; + +/// When true, the Remote tab skips the fast scrcpy channel and uses the slow +/// `input` transport — the escape hatch for devices where the channel misbehaves. +export function getRemoteForceShell(): boolean { + if (typeof localStorage === "undefined") return false; + return localStorage.getItem(REMOTE_COMPAT_KEY) === "true"; +} + +export function setRemoteForceShell(enabled: boolean): void { + if (typeof localStorage !== "undefined") { + localStorage.setItem(REMOTE_COMPAT_KEY, String(enabled)); + } +} diff --git a/v2/src/lib/types.ts b/v2/src/lib/types.ts index 254940d..47113a9 100644 --- a/v2/src/lib/types.ts +++ b/v2/src/lib/types.ts @@ -17,6 +17,7 @@ export interface DeviceProperties { sdk_level: string; build_id: string; board_platform: string; + characteristics?: string; } export interface Device { @@ -208,6 +209,9 @@ export interface ScreenshotResult { export interface SendTextResult { ok: boolean; message: string; + /// "channel" = scrcpy control socket (instant), "shell" = legacy `input` + /// fallback (~700 ms/press), "none" = nothing was sent. + transport: "channel" | "shell" | "none"; } export interface FileEntry {