diff --git a/README.md b/README.md index 45263a0..a67efc2 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ Trove 官方插件库。发现喜欢的 Curio,装进你的工作台,让常 ### Harmony Connector -让设备,更近一步。连接 Mac 与鸿蒙手机,整夹收发照片与文档,随时掌握传输速度;管理应用、查看手机画面,用鼠标和键盘完成日常操作。 +让设备,更近一步。连接 Mac 与鸿蒙手机,整夹收发照片与文档,随时掌握传输速度;管理应用,在独立窗口中投屏,用鼠标和键盘完成日常操作。 ## 按你的习惯组合 diff --git a/curios/harmony-connector/build.mjs b/curios/harmony-connector/build.mjs index 6bba945..133b1fc 100644 --- a/curios/harmony-connector/build.mjs +++ b/curios/harmony-connector/build.mjs @@ -112,6 +112,14 @@ for (const name of ["LICENSE", "provenance.json"]) path.join(agentDirectory, name), path.join(plugin, "legal/hypium", name), ); +const latestAgents = JSON.parse(await readFile(path.join(agentDirectory, "provenance-26.json"), "utf8")); +for (const entry of latestAgents.files) { + const bytes = await readFile(path.join(agentDirectory, entry.file)); + if (createHash("sha256").update(bytes).digest("hex") !== entry.sha256) + throw new Error(`Official device component checksum mismatch: ${entry.file}`); +} +for (const name of ["LICENSE-26", "provenance-26.json"]) + await copyFile(path.join(agentDirectory, name), path.join(plugin, "legal/hypium", name)); run( "cargo", ["build", "--release", "--locked", "--manifest-path", "native/Cargo.toml"], @@ -125,6 +133,10 @@ await copyFile( path.join(plugin, "native/target/release/harmony-connector"), executable, ); +const viewer = path.join(backend, "harmony-screen"); +run("xcrun", ["swiftc", "-O", "-target", `${process.arch === "arm64" ? "arm64" : "x86_64"}-apple-macosx13.0`, + "-framework", "AppKit", "-framework", "QuartzCore", "-framework", "AVFoundation", "-framework", "VideoToolbox", + "viewer/Protocol.swift", "viewer/VideoDecoder.swift", "viewer/ScreenView.swift", "viewer/Main.swift", "-o", viewer]); const libraries = ["libmtp.9.dylib", "libusb-1.0.0.dylib"]; for (const library of libraries) await copyFile( @@ -133,9 +145,10 @@ for (const library of libraries) ); for (const file of [ executable, + viewer, ...libraries.map((name) => path.join(backend, "lib", name)), ]) { - const isExecutable = file === executable; + const isExecutable = file === executable || file === viewer; const listing = spawnSync("otool", ["-L", file], { encoding: "utf8" }); if (listing.status !== 0) throw new Error("Cannot inspect Mach-O dependencies"); diff --git a/curios/harmony-connector/curio.json b/curios/harmony-connector/curio.json index 73ead85..75cce70 100644 --- a/curios/harmony-connector/curio.json +++ b/curios/harmony-connector/curio.json @@ -2,8 +2,8 @@ "schemaVersion": 1, "id": "dev.trove.harmony-connector", "name": "Harmony Connector", - "description": "连接鸿蒙设备,收发完整文件夹、管理应用,查看手机画面并通过键鼠操作。", - "version": "0.4.0", + "description": "连接鸿蒙设备,收发完整文件夹、管理应用,在独立窗口中投屏并通过键鼠操作。", + "version": "0.5.0", "engine": { "trove": ">=0.1.0 <1.0.0" }, "icon": "assets/icon.svg", "ui": { diff --git a/curios/harmony-connector/legal/THIRD-PARTY.md b/curios/harmony-connector/legal/THIRD-PARTY.md index 08d4fee..5f0100d 100644 --- a/curios/harmony-connector/legal/THIRD-PARTY.md +++ b/curios/harmony-connector/legal/THIRD-PARTY.md @@ -26,6 +26,20 @@ device file for an authorized HDC session and removed when that session closes. The upstream wheel does not include this native agent's source; embedding this binary does not make it independently rebuildable from this repository. +The native viewer additionally uses the unmodified Huawei components distributed +in `xdevice-devicetest` 26.0.0.500 (Hypium 26): + +- `devicetest/res/prototype/native/uitest_agent_v1.2.3.so` for touch control. +- `devicetest/res/recorder/libscrcpy_server_unix_6.6-20260629.z.so` for H.264 video. + +Their wheel license is preserved in `hypium/LICENSE-26`; file identities and +SHA-256 checksums are in `hypium/provenance-26.json`. Upstream copyright belongs +to Huawei Device Co., Ltd. and the authors identified in the official package. +These signed device binaries are neither modified nor stripped. The package +contains no separate NOTICE for them. This attribution is written by Trove; +it is not an upstream NOTICE. The macOS viewer is built from this repository's +Swift sources and uses the macOS AppKit, VideoToolbox and AVFoundation frameworks. + ## Building the distributed libraries The exact build commands and SHA-256 pins are in the accompanying `build.mjs`. diff --git a/curios/harmony-connector/native/Cargo.lock b/curios/harmony-connector/native/Cargo.lock index e4e504d..a44a7e4 100644 --- a/curios/harmony-connector/native/Cargo.lock +++ b/curios/harmony-connector/native/Cargo.lock @@ -23,6 +23,12 @@ dependencies = [ "derive_arbitrary", ] +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + [[package]] name = "autocfg" version = "1.5.1" @@ -68,6 +74,12 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + [[package]] name = "cc" version = "1.4.5" @@ -133,6 +145,12 @@ dependencies = [ "crypto-common", ] +[[package]] +name = "either" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" + [[package]] name = "equivalent" version = "1.0.2" @@ -181,6 +199,24 @@ dependencies = [ "zlib-rs", ] +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + [[package]] name = "generic-array" version = "0.14.7" @@ -202,20 +238,44 @@ dependencies = [ "r-efi", ] +[[package]] +name = "h2" +version = "0.4.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "harmony-connector" -version = "0.4.0" +version = "0.5.0" dependencies = [ "anyhow", "base64", + "bytes", "cc", + "h2", + "http", "image", "libc", "png", + "prost", "serde", "serde_json", "sha2", "tempfile", + "tokio", "zip", ] @@ -225,6 +285,16 @@ version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + [[package]] name = "image" version = "0.25.10" @@ -250,6 +320,15 @@ dependencies = [ "hashbrown", ] +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.18" @@ -300,6 +379,17 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "mio" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" +dependencies = [ + "libc", + "wasi", + "windows-sys", +] + [[package]] name = "moxcms" version = "0.8.1" @@ -325,6 +415,12 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + [[package]] name = "png" version = "0.18.1" @@ -347,6 +443,29 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "prost" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "528ac67416ff8646872a3c02cad9cc4ee5dc9f9540c9b10771855c95cb2e5ae1" +dependencies = [ + "bytes", + "prost-derive", +] + +[[package]] +name = "prost-derive" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf" +dependencies = [ + "anyhow", + "itertools", + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "pxfm" version = "0.1.30" @@ -447,6 +566,22 @@ version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys", +] + [[package]] name = "syn" version = "2.0.119" @@ -482,6 +617,65 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "tokio-macros", + "windows-sys", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "tokio-util" +version = "0.7.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "libc", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + [[package]] name = "typenum" version = "1.20.1" @@ -500,6 +694,12 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + [[package]] name = "windows-link" version = "0.2.1" diff --git a/curios/harmony-connector/native/Cargo.toml b/curios/harmony-connector/native/Cargo.toml index 57757ec..ce2ca9c 100644 --- a/curios/harmony-connector/native/Cargo.toml +++ b/curios/harmony-connector/native/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "harmony-connector" -version = "0.4.0" +version = "0.5.0" edition = "2021" publish = false @@ -15,6 +15,11 @@ zip = { version = "4.6", default-features = false, features = ["deflate"] } png = "0.18" image = { version = "0.25", default-features = false, features = ["jpeg", "png"] } base64 = "0.22" +bytes = "1" +h2 = "0.4" +http = "1" +prost = "0.14" +tokio = { version = "1", features = ["rt", "net", "time", "sync", "macros"] } [build-dependencies] cc = "1" diff --git a/curios/harmony-connector/native/src/hdc.rs b/curios/harmony-connector/native/src/hdc.rs index 67915cf..3c14ab7 100644 --- a/curios/harmony-connector/native/src/hdc.rs +++ b/curios/harmony-connector/native/src/hdc.rs @@ -4,6 +4,9 @@ mod screen_agent; mod screen_input; mod screen_lease; mod screen_protocol; +mod screen_recorder; +mod screen_video; +mod screen_window; use crate::{ command, hdc_data, model::{Fault, Result}, @@ -30,6 +33,7 @@ impl Drop for Guard { } pub struct Manager { screen: screen::Screen, + window: screen_window::Window, path: Mutex>, settings: Option, busy: Arc, @@ -292,6 +296,7 @@ impl Manager { .or_else(discover); Arc::new(Self { screen: screen::Screen::default(), + window: screen_window::Window::default(), path: Mutex::new(path), settings, busy: Arc::new(AtomicBool::new(false)), @@ -334,6 +339,7 @@ impl Manager { pub fn stop(&self) { self.stopped.store(true, Ordering::Release); self.cancel.store(true, Ordering::Release); + let _ = self.window.stop(); let _ = self.screen.close(None); } pub fn call(self: &Arc, method: &str, value: Value) -> Result { @@ -355,6 +361,12 @@ impl Manager { } return Ok(json!({"requested":job["state"] == "running"})); } + if method == "screenWindowStatus" { + return Ok(self.window.status()); + } + if method == "screenWindowStop" { + return self.window.stop(); + } if method == "screenStop" { let r: screen::Id = parse(value)?; return self.screen.close(Some(r.session)); @@ -373,6 +385,7 @@ impl Manager { } let guard = self.enter()?; match method { + "screenWindowOpen" => self.window.open(self.tool()?, parse(value)?), "screenStart" => { let r: Target = parse(value)?; self.screen.start(self.tool()?, r.target) diff --git a/curios/harmony-connector/native/src/hdc/screen_agent.rs b/curios/harmony-connector/native/src/hdc/screen_agent.rs index fed7ae9..aa01dcb 100644 --- a/curios/harmony-connector/native/src/hdc/screen_agent.rs +++ b/curios/harmony-connector/native/src/hdc/screen_agent.rs @@ -38,14 +38,14 @@ fn response(value: Value) -> Result { .cloned() .ok_or_else(|| fault("设备没有返回有效操作结果。")) } -struct Rpc { +pub(super) struct Rpc { socket: TcpStream, decoder: Decoder, sequence: u32, driver: String, } impl Rpc { - fn connect(port: u16) -> Result { + pub(super) fn connect(port: u16) -> Result { let socket = TcpStream::connect_timeout(&([127, 0, 0, 1], port).into(), Duration::from_secs(2)) .map_err(io)?; @@ -103,17 +103,30 @@ impl Rpc { self.decoder.push(&bytes[..size])?; } } - fn driver(&mut self, api: &str, args: Value) -> Result { + pub(super) fn driver(&mut self, api: &str, args: Value) -> Result { self.call( "callHypiumApi", json!({"api":api,"this":self.driver,"args":args,"message_type":"hypium"}), ) } + pub(super) fn create_driver(&mut self) -> Result<()> { + self.driver = "Driver#0".into(); + let value = self.driver("Driver.create", json!([]))?; + self.driver = value + .as_str() + .filter(|s| s.starts_with("Driver#") && s.len() < 64) + .ok_or_else(|| fault("设备未能启动控制组件。"))? + .into(); + Ok(()) + } + pub(super) fn gesture(&mut self, api: &str, x: u32, y: u32) -> Result { + self.call("Gestures", json!({"api":api,"args":{"x":x,"y":y}})) + } fn stop_capture(&mut self) -> Result<()> { // Captures.stopCaptureScreen has no guaranteed response on this agent. self.send("Captures", json!({"api":"stopCaptureScreen","args":{}})) } - fn dimensions(&mut self) -> Result<(u32, u32)> { + pub(super) fn dimensions(&mut self) -> Result<(u32, u32)> { let value = self.driver("Driver.getDisplaySize", json!([]))?; let dimension = |key: &str| { value[key] @@ -125,8 +138,8 @@ impl Rpc { Ok((dimension("x")?, dimension("y")?)) } } -fn existing_agents(tool: &Tool, target: &str) -> Result> { - let pids = tool.run_output(&["-t", target, "shell", "pidof uitest"], 2)?; +pub(super) fn existing_agents(tool: &Tool, target: &str) -> Result> { + let pids = tool.run_output(&["-t", target, "shell", "pidof uitest xdevice_scrcpy"], 2)?; let mut owned = Vec::new(); for pid in pids.split_whitespace() { if !pid.bytes().all(|b| b.is_ascii_digit()) { @@ -164,7 +177,7 @@ fn existing_agents(tool: &Tool, target: &str) -> Result> { } Ok(owned) } -fn terminate_agent(tool: &Tool, target: &str, pid: &str, expected: &str) -> Result<()> { +pub(super) fn terminate_agent(tool: &Tool, target: &str, pid: &str, expected: &str) -> Result<()> { // Recheck identity before signalling: a PID may have been reused. let command = format!("cat /proc/{pid}/cmdline"); if tool.run_output(&["-t", target, "shell", &command], 2)? != expected { diff --git a/curios/harmony-connector/native/src/hdc/screen_lease.rs b/curios/harmony-connector/native/src/hdc/screen_lease.rs index a5bdd40..26e9062 100644 --- a/curios/harmony-connector/native/src/hdc/screen_lease.rs +++ b/curios/harmony-connector/native/src/hdc/screen_lease.rs @@ -16,6 +16,9 @@ pub(super) fn busy() -> Fault { ) } pub(super) fn forwards(tool: &Tool, target: &str) -> Result> { + forwards_socket(tool, target, SOCKET) +} +pub(super) fn forwards_socket(tool: &Tool, target: &str, socket: &str) -> Result> { let output = tool.run(&["-t", target, "fport", "ls"], 3)?; Ok(output .lines() @@ -23,7 +26,7 @@ pub(super) fn forwards(tool: &Tool, target: &str) -> Result> { let words: Vec<_> = line.split_whitespace().collect(); if words.len() == 4 && words[0] == target - && words[2] == SOCKET + && words[2] == socket && words[3] == "[Forward]" { Some(words[1].to_owned()) @@ -65,13 +68,32 @@ impl Lease { )); } if let Ok(value) = serde_json::from_str::(&previous) { - if let Some(local) = value["forward"].as_str().filter(|v| { - v.strip_prefix("tcp:") - .is_some_and(|p| p.parse::().is_ok_and(|p| p > 0)) - }) { - if forwards(tool, target)?.iter().any(|active| active == local) { - tool.run(&["-t", target, "fport", "rm", local, SOCKET], 3)?; - if forwards(tool, target)?.iter().any(|active| active == local) { + let mut forwards = value["forwards"].as_array().cloned().unwrap_or_default(); + if let Some(local) = value["forward"].as_str() { + forwards.push(json!({"local":local,"remote":SOCKET})); + } + for forward in forwards { + let Some(local) = forward["local"].as_str().filter(|v| { + v.strip_prefix("tcp:") + .is_some_and(|p| p.parse::().is_ok_and(|p| p > 0)) + }) else { + continue; + }; + let Some(remote) = forward["remote"] + .as_str() + .filter(|s| [SOCKET, "localabstract:scrcpy_grpc_socket"].contains(s)) + else { + continue; + }; + if forwards_socket(tool, target, remote)? + .iter() + .any(|active| active == local) + { + tool.run(&["-t", target, "fport", "rm", local, remote], 3)?; + if forwards_socket(tool, target, remote)? + .iter() + .any(|active| active == local) + { return Err(busy()); } } @@ -81,7 +103,15 @@ impl Lease { Ok(lease) } pub fn record(&mut self, local: Option<&str>) -> Result<()> { - let bytes = serde_json::to_vec(&json!({"forward":local})).unwrap(); + self.record_forwards( + &local + .into_iter() + .map(|local| (local.to_owned(), SOCKET.to_owned())) + .collect::>(), + ) + } + pub fn record_forwards(&mut self, forwards: &[(String, String)]) -> Result<()> { + let bytes = serde_json::to_vec(&json!({"forwards":forwards.iter().map(|(local, remote)| json!({"local":local,"remote":remote})).collect::>() })).unwrap(); self.file.seek(SeekFrom::Start(0))?; self.file.write_all(&bytes)?; self.file.set_len(bytes.len() as u64)?; diff --git a/curios/harmony-connector/native/src/hdc/screen_recorder.rs b/curios/harmony-connector/native/src/hdc/screen_recorder.rs new file mode 100644 index 0000000..df95da6 --- /dev/null +++ b/curios/harmony-connector/native/src/hdc/screen_recorder.rs @@ -0,0 +1,425 @@ +//! Owns the official video and touch daemons as one USB debugging session. +use super::screen_agent::{self, Rpc}; +use super::screen_lease::{self, Lease, SOCKET}; +use super::*; +use std::{ + collections::{HashSet, VecDeque}, + io::Write, + net::TcpListener, + sync::Condvar, + time::Instant, +}; + +const CONTROL: &[u8] = + include_bytes!("../../../../../vendor/harmony-connector/hypium/uitest_agent_v1.2.3.so"); +const VIDEO: &[u8] = include_bytes!( + "../../../../../vendor/harmony-connector/hypium/libscrcpy_server_unix_6.6-20260629.z.so" +); +const VIDEO_SOCKET: &str = "localabstract:scrcpy_grpc_socket"; + +struct Resources { + tool: Tool, + target: String, + files: Vec, + forwards: Vec<(String, String)>, + lease: Lease, +} +impl Resources { + fn upload(&mut self, bytes: &[u8], arguments: &str) -> Result<()> { + let mut file = tempfile::Builder::new() + .prefix("trove-screen-") + .suffix(".so") + .tempfile()?; + file.write_all(bytes)?; + let name = file + .path() + .file_name() + .unwrap() + .to_str() + .unwrap() + .to_owned(); + self.files.push(name.clone()); + self.tool.run( + &[ + "-t", + &self.target, + "file", + "send", + file.path().to_str().unwrap(), + &format!("/data/local/tmp/{name}"), + ], + 10, + )?; + self.tool.run( + &[ + "-t", + &self.target, + "shell", + &format!("uitest start-daemon singleness --extension-name {name} {arguments}"), + ], + 5, + )?; + Ok(()) + } + fn forward(&mut self, socket: &str) -> Result { + let listener = TcpListener::bind(("127.0.0.1", 0))?; + let port = listener.local_addr()?.port(); + drop(listener); + let local = format!("tcp:{port}"); + self.forwards.push((local.clone(), socket.into())); + // Record before the command, so a crash between forwarding and return is recoverable. + self.lease.record_forwards(&self.forwards)?; + self.tool + .run(&["-t", &self.target, "fport", &local, socket], 3)?; + Ok(port) + } + fn close(&mut self) -> Result<()> { + if self.files.is_empty() && self.forwards.is_empty() { + return Ok(()); + } + let cleanup = Tool { + path: self.tool.path.clone(), + cancel: Arc::new(AtomicBool::new(false)), + }; + let mut error = None; + for (local, remote) in &self.forwards { + if let Err(e) = cleanup.run(&["-t", &self.target, "fport", "rm", local, remote], 2) { + error.get_or_insert(e); + } + } + if error.is_none() { + self.forwards.clear(); + self.lease.record(None)?; + } + // Inspect full command lines before signalling; never kill a foreign daemon. + let pids = cleanup.run_output( + &["-t", &self.target, "shell", "pidof uitest xdevice_scrcpy"], + 2, + )?; + for pid in pids + .split_whitespace() + .filter(|s| s.bytes().all(|b| b.is_ascii_digit())) + { + let cmdline = cleanup.run_output( + &[ + "-t", + &self.target, + "shell", + &format!("cat /proc/{pid}/cmdline"), + ], + 2, + )?; + if cmdline + .split('\0') + .any(|arg| self.files.iter().any(|file| file == arg)) + { + if let Err(e) = screen_agent::terminate_agent(&cleanup, &self.target, pid, &cmdline) + { + error.get_or_insert(e); + } + } + } + for name in self.files.drain(..) { + if let Err(e) = cleanup.run( + &[ + "-t", + &self.target, + "shell", + &format!("rm -f /data/local/tmp/{name}"), + ], + 2, + ) { + error.get_or_insert(e); + } + } + error.map_or(Ok(()), Err) + } +} +impl Drop for Resources { + fn drop(&mut self) { + let _ = self.close(); + } +} + +#[derive(Clone, Copy)] +pub(super) struct Dimensions { + pub width: u32, + pub height: u32, + pub geometry: u64, +} +struct Event { + value: Value, + time: Instant, +} +struct Queue { + events: Mutex>, + changed: Condvar, +} +pub(super) struct Recorder { + pub cancel: Arc, + pub port: u16, + pub keyframe: AtomicBool, + pub error: Mutex>, + dimensions: Arc>, + queue: Arc, + worker: Mutex>>, + resources: Mutex>, +} +impl Recorder { + pub fn open(tool: Tool, target: String) -> Result> { + tool.connected(&target)?; + let root = env::var_os("HOME") + .map(PathBuf::from) + .unwrap_or_else(env::temp_dir) + .join("Library/HarmonyConnector/sessions"); + let lease = Lease::acquire(&root, &tool, &target)?; + let existing = screen_agent::existing_agents(&tool, &target)?; + for socket in [SOCKET, VIDEO_SOCKET] { + if !screen_lease::forwards_socket(&tool, &target, socket)?.is_empty() { + return Err(screen_lease::busy()); + } + } + for (pid, cmdline) in existing { + screen_agent::terminate_agent(&tool, &target, &pid, &cmdline)?; + } + let mut resources = Resources { + tool, + target, + files: Vec::new(), + forwards: Vec::new(), + lease, + }; + resources.upload(CONTROL, "")?; + let control_port = resources.forward(SOCKET)?; + let deadline = Instant::now() + Duration::from_secs(5); + let mut control = loop { + if resources.tool.cancel.load(Ordering::Acquire) { + return Err(Fault::new("screen.cancelled", "投屏连接已取消。")); + } + let attempt = Rpc::connect(control_port).and_then(|mut rpc| { + rpc.create_driver()?; + Ok(rpc) + }); + match attempt { + Ok(rpc) => break rpc, + Err(e) if Instant::now() >= deadline => return Err(e), + Err(_) => thread::sleep(Duration::from_millis(100)), + } + }; + let (width, height) = control.dimensions()?; + resources.upload( + VIDEO, + "-m 0 -scale 2 -frameRate 60 -bitRate 8000000 -iFrameInterval 1000 -screenId 0", + )?; + let port = resources.forward(VIDEO_SOCKET)?; + let tool = Tool { + path: resources.tool.path.clone(), + cancel: Arc::new(AtomicBool::new(false)), + }; + let target = resources.target.clone(); + let help = tool + .shell(&target, "uitest uiInput help") + .unwrap_or_default(); + let mut supported = super::screen_input::capabilities(&help); + for action in [ + "click", + "doubleClick", + "longClick", + "swipe", + "drag", + "keyEvent", + ] { + supported.insert(action.into()); + } + let owner = Arc::new(Self { + cancel: Arc::new(AtomicBool::new(false)), + port, + keyframe: AtomicBool::new(false), + error: Mutex::new(None), + dimensions: Arc::new(Mutex::new(Dimensions { + width, + height, + geometry: 1, + })), + queue: Arc::new(Queue { + events: Mutex::new(VecDeque::new()), + changed: Condvar::new(), + }), + worker: Mutex::new(None), + resources: Mutex::new(Some(resources)), + }); + let weak = Arc::downgrade(&owner); + let queue = owner.queue.clone(); + let cancel = owner.cancel.clone(); + let dimensions = owner.dimensions.clone(); + let worker = thread::spawn(move || { + let result = control_loop( + &mut control, + control_port, + &tool, + &target, + &supported, + &queue, + &cancel, + &dimensions, + ); + if let Err(error) = result { + if let Some(owner) = weak.upgrade() { + *owner.error.lock().unwrap() = Some(error); + } + } + cancel.store(true, Ordering::Release); + }); + *owner.worker.lock().unwrap() = Some(worker); + Ok(owner) + } + pub fn dimensions(&self) -> Dimensions { + *self.dimensions.lock().unwrap() + } + pub fn push(&self, value: Value) -> Result<()> { + if self.cancel.load(Ordering::Acquire) { + return Err(Fault::new("screen.closed", "投屏已停止。")); + } + let mut queue = self.queue.events.lock().unwrap(); + if value["input"]["kind"] == "touchMove" + && queue + .back() + .is_some_and(|e| e.value["input"]["kind"] == "touchMove") + { + queue.pop_back(); + } + if queue.len() >= 64 { + self.cancel.store(true, Ordering::Release); + return Err(Fault::new( + "screen.input_backlog", + "手机控制响应过慢,请重新连接。", + )); + } + queue.push_back(Event { + value, + time: Instant::now(), + }); + self.queue.changed.notify_one(); + Ok(()) + } + pub fn stop(&self) -> Result<()> { + self.cancel.store(true, Ordering::Release); + self.queue.changed.notify_all(); + if let Some(worker) = self.worker.lock().unwrap().take() { + let _ = worker.join(); + } + if let Some(mut resources) = self.resources.lock().unwrap().take() { + resources.close()?; + } + Ok(()) + } +} +impl Drop for Recorder { + fn drop(&mut self) { + let _ = self.stop(); + } +} + +fn control_loop( + rpc: &mut Rpc, + port: u16, + tool: &Tool, + target: &str, + supported: &HashSet, + queue: &Queue, + cancel: &AtomicBool, + dimensions: &Mutex, +) -> Result<()> { + let mut touch = None; + let mut heartbeat = Instant::now(); + let result = (|| { + loop { + if cancel.load(Ordering::Acquire) { + return Ok(()); + } + if heartbeat.elapsed() >= Duration::from_secs(1) { + let (width, height) = rpc.dimensions()?; + let mut current = dimensions.lock().unwrap(); + if (current.width, current.height) != (width, height) { + if let Some((x, y)) = touch.take() { + rpc.gesture("touchUp", x, y)?; + } + *current = Dimensions { + width, + height, + geometry: current.geometry + 1, + }; + } + heartbeat = Instant::now(); + } + let mut events = queue.events.lock().unwrap(); + if events.is_empty() { + events = queue + .changed + .wait_timeout(events, Duration::from_millis(50)) + .unwrap() + .0; + } + let event = events.pop_front(); + drop(events); + let Some(event) = event else { continue }; + let kind = event.value["input"]["kind"].as_str().unwrap_or(""); + // Release always uses the last acknowledged point, even after rotation or stale input. + if kind == "touchUp" { + if let Some((x, y)) = touch.take() { + rpc.gesture("touchUp", x, y)?; + } + continue; + } + let current = *dimensions.lock().unwrap(); + if event.value["geometry"] != current.geometry + || event.time.elapsed() > Duration::from_secs(1) + { + if let Some((x, y)) = touch.take() { + rpc.gesture("touchUp", x, y)?; + } + continue; + } + if ["touchDown", "touchMove"].contains(&kind) { + let point = |name, limit| { + event.value["input"][name] + .as_u64() + .filter(|n| *n < limit as u64) + .map(|n| n as u32) + }; + let (Some(x), Some(y)) = (point("x", current.width), point("y", current.height)) + else { + continue; + }; + if kind == "touchMove" && touch.is_none() { + continue; + } + if kind == "touchDown" { + if let Some((x, y)) = touch.take() { + rpc.gesture("touchUp", x, y)?; + } + } + // Track before exchanging: the device may accept down even if its reply is lost. + touch = Some((x, y)); + rpc.gesture(kind, x, y)?; + } else { + let input: super::screen_input::Input = parse(event.value["input"].clone())?; + let command = input.command(current.width, current.height, supported)?; + if let Some((api, args)) = input.rpc() { + rpc.driver(api, args)?; + } else { + tool.shell(target, &command)?; + } + } + } + })(); + if let Some((x, y)) = touch { + if rpc.gesture("touchUp", x, y).is_err() { + // A timed-out RPC socket is intentionally discarded; release on a fresh channel. + if let Ok(mut release) = Rpc::connect(port) { + let _ = release.gesture("touchUp", x, y); + } + } + } + result +} diff --git a/curios/harmony-connector/native/src/hdc/screen_video.rs b/curios/harmony-connector/native/src/hdc/screen_video.rs new file mode 100644 index 0000000..e364e59 --- /dev/null +++ b/curios/harmony-connector/native/src/hdc/screen_video.rs @@ -0,0 +1,180 @@ +//! The official recorder's server-streaming gRPC over an HDC Unix-socket forward. +//! Encoded packets must stay ordered; frame dropping happens after decoding. +use super::*; +use bytes::{Buf, Bytes, BytesMut}; +use prost::Message; +use std::collections::HashMap; + +const MAX_PACKET: usize = 8 * 1024 * 1024; + +#[derive(Clone, PartialEq, Message)] +struct Reply { + #[prost(string, tag = "1")] + data: String, + #[prost(int32, tag = "2")] + reply_type: i32, + #[prost(map = "string, message", tag = "3")] + payload: HashMap, +} +#[derive(Clone, PartialEq, Message)] +struct Parameter { + #[prost(oneof = "parameter::Value", tags = "1, 2, 3, 4, 5, 6")] + value: Option, +} +mod parameter { + #[derive(Clone, PartialEq, prost::Oneof)] + pub enum Value { + #[prost(int64, tag = "1")] + Int(i64), + #[prost(double, tag = "2")] + Double(f64), + #[prost(string, tag = "3")] + String(String), + #[prost(bool, tag = "4")] + Bool(bool), + #[prost(bytes, tag = "5")] + Bytes(Vec), + #[prost(float, tag = "6")] + Float(f32), + } +} +fn fault(error: impl std::fmt::Display) -> Fault { + Fault::new("screen.video_failed", format!("视频连接已结束:{error}")) +} + +pub(super) fn receive( + port: u16, + cancel: &AtomicBool, + keyframe: &AtomicBool, + mut output: impl FnMut(Vec) -> Result<()>, +) -> Result<()> { + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build()? + .block_on(async { + let socket = tokio::net::TcpStream::connect(("127.0.0.1", port)).await?; + socket.set_nodelay(true)?; + let (mut sender, connection) = h2::client::handshake(socket).await.map_err(fault)?; + let connection = tokio::spawn(connection); + let result = async { + let request = http::Request::builder() + .method("POST") + .uri(format!("http://localhost:{port}/ScrcpyService/onStart")) + .header("content-type", "application/grpc") + .header("te", "trailers") + .body(()) + .map_err(fault)?; + let (response, mut request_body) = + sender.send_request(request, false).map_err(fault)?; + // Uncompressed Empty protobuf message: compression byte + uint32 length. + request_body + .send_data(Bytes::from_static(&[0; 5]), true) + .map_err(fault)?; + let response = tokio::time::timeout(Duration::from_secs(5), response) + .await + .map_err(fault)? + .map_err(fault)?; + if response.status() != http::StatusCode::OK { + return Err(fault(response.status())); + } + let mut body = response.into_body(); + let mut pending = BytesMut::new(); + let started = std::time::Instant::now(); + let mut received = false; + let mut last_idr = started - Duration::from_secs(2); + loop { + if cancel.load(Ordering::Acquire) { + let _ = unary(&mut sender, port, "onEnd").await; + return Ok(()); + } + if keyframe.swap(false, Ordering::AcqRel) + && last_idr.elapsed() >= Duration::from_secs(1) + { + unary(&mut sender, port, "onRequestIDRFrame").await?; + last_idr = std::time::Instant::now(); + } + if !received && started.elapsed() > Duration::from_secs(8) { + return Err(fault("手机未返回视频画面,请解锁后重新开始投屏")); + } + let data = + match tokio::time::timeout(Duration::from_millis(100), body.data()).await { + Err(_) => continue, + Ok(Some(data)) => data.map_err(fault)?, + Ok(None) => { + let trailers = body.trailers().await.map_err(fault)?; + return Err(fault( + trailers + .as_ref() + .and_then(|t| t.get("grpc-message")) + .and_then(|m| m.to_str().ok()) + .unwrap_or("手机停止了视频流"), + )); + } + }; + let size = data.len(); + pending.extend_from_slice(&data); + body.flow_control().release_capacity(size).map_err(fault)?; + while pending.len() >= 5 { + let length = u32::from_be_bytes(pending[1..5].try_into().unwrap()) as usize; + if pending[0] != 0 || length > MAX_PACKET { + return Err(fault("不支持的视频包格式")); + } + if pending.len() < length + 5 { + break; + } + pending.advance(5); + let mut reply = Reply::decode(pending.split_to(length)).map_err(fault)?; + if let Some(Parameter { + value: Some(parameter::Value::Bytes(bytes)), + }) = reply.payload.remove("data") + { + if let Some(Parameter { + value: Some(parameter::Value::Int(length)), + }) = reply.payload.get("len") + { + if *length < 0 || *length as usize != bytes.len() { + return Err(fault("视频数据长度不一致")); + } + } + if !bytes.is_empty() { + received = true; + output(bytes)?; + } + } + } + } + } + .await; + connection.abort(); + result + }) +} + +async fn unary(sender: &mut h2::client::SendRequest, port: u16, method: &str) -> Result<()> { + let request = http::Request::builder() + .method("POST") + .uri(format!("http://localhost:{port}/ScrcpyService/{method}")) + .header("content-type", "application/grpc") + .header("te", "trailers") + .body(()) + .map_err(fault)?; + let (response, mut body) = sender.send_request(request, false).map_err(fault)?; + body.send_data(Bytes::from_static(&[0; 5]), true) + .map_err(fault)?; + tokio::time::timeout(Duration::from_secs(1), async { + let response = response.await.map_err(fault)?; + if !response.status().is_success() { + return Err(fault(response.status())); + } + let mut body = response.into_body(); + while let Some(data) = body.data().await { + let data = data.map_err(fault)?; + body.flow_control() + .release_capacity(data.len()) + .map_err(fault)?; + } + Ok(()) + }) + .await + .map_err(fault)? +} diff --git a/curios/harmony-connector/native/src/hdc/screen_window.rs b/curios/harmony-connector/native/src/hdc/screen_window.rs new file mode 100644 index 0000000..f69a619 --- /dev/null +++ b/curios/harmony-connector/native/src/hdc/screen_window.rs @@ -0,0 +1,226 @@ +//! A private binary pipe connects the backend to the bundled AppKit viewer. +use super::screen_recorder::Recorder; +use super::*; +use std::{ + collections::VecDeque, + io::{BufRead, BufReader, Read, Write}, + os::fd::AsRawFd, + process::{Child, ChildStdin, Command, Stdio}, + time::Instant, +}; + +struct Pipe(Mutex); +impl Pipe { + fn send(&self, metadata: Value, bytes: &[u8]) -> Result<()> { + let metadata = serde_json::to_vec(&metadata).unwrap(); + let mut pipe = self.0.lock().unwrap(); + let mut packet = Vec::with_capacity(8 + metadata.len() + bytes.len()); + packet.extend_from_slice(&(metadata.len() as u32).to_be_bytes()); + packet.extend_from_slice(&(bytes.len() as u32).to_be_bytes()); + packet.extend_from_slice(&metadata); + packet.extend_from_slice(bytes); + let deadline = Instant::now() + Duration::from_secs(2); + let mut rest = packet.as_slice(); + while !rest.is_empty() { + match pipe.write(rest) { + Ok(0) => return Err(Fault::new("screen.window_closed", "投屏窗口已关闭。")), + Ok(n) => rest = &rest[n..], + Err(error) + if error.kind() == std::io::ErrorKind::WouldBlock + && Instant::now() < deadline => + { + thread::sleep(Duration::from_millis(2)) + } + Err(error) => return Err(error.into()), + } + } + Ok(()) + } +} +struct Viewer { + child: Child, + pipe: Arc, + recorder: Arc, + target: String, + worker: Option>, +} +#[derive(Default)] +pub(super) struct Window { + current: Mutex>, +} +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +pub(super) struct Open { + pub target: String, + #[serde(default)] + pub name: String, +} +impl Window { + pub fn status(&self) -> Value { + let current = self.current.lock().unwrap(); + current.as_ref().map_or(json!({"active":false}), |v| { + json!({ + "active":!v.recorder.cancel.load(Ordering::Acquire),"target":v.target, + "error":v.recorder.error.lock().unwrap().clone() + }) + }) + } + pub fn open(&self, tool: Tool, request: Open) -> Result { + let mut current = self.current.lock().unwrap(); + if let Some(viewer) = current.as_mut() { + if !viewer.recorder.cancel.load(Ordering::Acquire) && viewer.child.try_wait()?.is_none() + { + if viewer.target != request.target { + return Err(screen_lease::busy()); + } + viewer.pipe.send(json!({"kind":"focus"}), &[])?; + return Ok(json!({"active":true,"target":request.target})); + } + } + if let Some(viewer) = current.take() { + close(viewer)?; + } + let executable = env::current_exe()?.parent().unwrap().join("harmony-screen"); + if !executable.is_file() { + return Err(Fault::new( + "screen.viewer_missing", + "投屏组件不完整,请更新或重新安装 Harmony Connector。", + )); + } + let recorder = Recorder::open(tool, request.target.clone())?; + let name = if request.name.trim().is_empty() { + "Harmony" + } else { + &request.name + }; + let started = Command::new(executable) + .arg(name) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn(); + let mut child = match started { + Ok(child) => child, + Err(error) => { + let _ = recorder.stop(); + return Err(error.into()); + } + }; + let stdin = child.stdin.take().unwrap(); + let fd = stdin.as_raw_fd(); + unsafe { + libc::fcntl( + fd, + libc::F_SETFL, + libc::fcntl(fd, libc::F_GETFL) | libc::O_NONBLOCK, + ); + } + let pipe = Arc::new(Pipe(Mutex::new(stdin))); + let stdout = child.stdout.take().unwrap(); + let reader_recorder = recorder.clone(); + thread::spawn(move || { + let mut reader = BufReader::new(stdout); + loop { + let mut line = String::new(); + // Every viewer message is bounded independently of the peer's newline behavior. + match reader.by_ref().take(16385).read_line(&mut line) { + Ok(0) | Err(_) => break, + Ok(_) if line.len() > 16384 || !line.ends_with('\n') => break, + _ => {} + } + let Ok(value) = serde_json::from_str::(&line) else { + break; + }; + if value["event"] == "input" { + if let Err(error) = reader_recorder.push(value) { + *reader_recorder.error.lock().unwrap() = Some(error); + break; + } + } else if value["event"] == "decoderError" { + reader_recorder.keyframe.store(true, Ordering::Release); + } + } + reader_recorder.cancel.store(true, Ordering::Release); + }); + let worker_recorder = recorder.clone(); + let worker_pipe = pipe.clone(); + let worker = thread::spawn(move || { + let mut sequence = 0u64; + let mut samples = VecDeque::new(); + let startup = Instant::now(); + let result = loop { + let result = super::screen_video::receive( + worker_recorder.port, + &worker_recorder.cancel, + &worker_recorder.keyframe, + |bytes| { + sequence += 1; + let now = Instant::now(); + samples.push_back((now, bytes.len())); + while samples.front().is_some_and(|(time, _)| { + now.duration_since(*time) > Duration::from_secs(1) + }) { + samples.pop_front(); + } + let dimensions = worker_recorder.dimensions(); + worker_pipe.send(json!({"kind":"video","frame":{ + "sequence":sequence,"geometry":dimensions.geometry, + "width":dimensions.width,"height":dimensions.height, + "captureWidth":dimensions.width.div_ceil(2),"captureHeight":dimensions.height.div_ceil(2), + "receivedFps":samples.len(),"mbps":samples.iter().map(|(_,bytes)| *bytes).sum::() as f64 * 8.0 / 1_000_000.0 + }}), &bytes) + }, + ); + if result.is_ok() + || sequence > 0 + || startup.elapsed() > Duration::from_secs(5) + || worker_recorder.cancel.load(Ordering::Acquire) + { + break result; + } + thread::sleep(Duration::from_millis(100)); + }; + if let Err(error) = result { + worker_recorder.error.lock().unwrap().get_or_insert(error); + } + if let Some(error) = worker_recorder.error.lock().unwrap().clone() { + let _ = worker_pipe.send(json!({"kind":"error","message":error.message}), &[]); + } + if let Err(error) = worker_recorder.stop() { + worker_recorder.error.lock().unwrap().get_or_insert(error); + } + }); + *current = Some(Viewer { + child, + pipe, + recorder, + target: request.target.clone(), + worker: Some(worker), + }); + Ok(json!({"active":true,"target":request.target})) + } + pub fn stop(&self) -> Result { + if let Some(viewer) = self.current.lock().unwrap().take() { + close(viewer)?; + } + Ok(json!({"active":false})) + } +} +fn close(mut viewer: Viewer) -> Result<()> { + viewer.recorder.cancel.store(true, Ordering::Release); + let _ = viewer.pipe.send(json!({"kind":"close"}), &[]); + let result = viewer.recorder.stop(); + let deadline = Instant::now() + Duration::from_secs(1); + while viewer.child.try_wait()?.is_none() { + if Instant::now() >= deadline { + let _ = viewer.child.kill(); + break; + } + thread::sleep(Duration::from_millis(20)); + } + let _ = viewer.child.wait(); + if let Some(worker) = viewer.worker.take() { + let _ = worker.join(); + } + result +} diff --git a/curios/harmony-connector/native/src/hdc/tests.rs b/curios/harmony-connector/native/src/hdc/tests.rs index e7239c3..3690c02 100644 --- a/curios/harmony-connector/native/src/hdc/tests.rs +++ b/curios/harmony-connector/native/src/hdc/tests.rs @@ -152,6 +152,7 @@ esac "#)).unwrap(); fs::set_permissions(&path, fs::Permissions::from_mode(0o700)).unwrap(); let manager = Arc::new(Manager { + window: screen_window::Window::default(), screen: screen::Screen::new(directory.path().join("screen-sessions")), path: Mutex::new(Some(path)), settings: None, diff --git a/curios/harmony-connector/native/tests/hypium_device.py b/curios/harmony-connector/native/tests/hypium_device.py index 2d5415a..8ebb43a 100644 --- a/curios/harmony-connector/native/tests/hypium_device.py +++ b/curios/harmony-connector/native/tests/hypium_device.py @@ -76,7 +76,7 @@ log(' '.join(args)) if args[:2]==['-t','phone']: args=args[2:] if args==['list','targets','-v']: print('phone USB Connected device') elif args==['shell','uitest uiInput help']: print('click doubleClick longClick swipe drag text keyEvent') -elif args==['shell','pidof uitest']: +elif args in (['shell','pidof uitest'], ['shell','pidof uitest xdevice_scrcpy']): if path('daemon').exists(): print('4242') elif args[:2]==['file','send']: print('FileTransfer finish') elif args[0]=='shell' and args[1].startswith('uitest start-daemon'): diff --git a/curios/harmony-connector/ui/index.html b/curios/harmony-connector/ui/index.html index b1c1ae0..0602900 100644 --- a/curios/harmony-connector/ui/index.html +++ b/curios/harmony-connector/ui/index.html @@ -215,101 +215,19 @@ -