feat(harmony): add native H.264 mirroring window with direct touch control
This commit is contained in:
@@ -10,7 +10,7 @@ Trove 官方插件库。发现喜欢的 Curio,装进你的工作台,让常
|
||||
|
||||
### Harmony Connector
|
||||
|
||||
让设备,更近一步。连接 Mac 与鸿蒙手机,整夹收发照片与文档,随时掌握传输速度;管理应用、查看手机画面,用鼠标和键盘完成日常操作。
|
||||
让设备,更近一步。连接 Mac 与鸿蒙手机,整夹收发照片与文档,随时掌握传输速度;管理应用,在独立窗口中投屏,用鼠标和键盘完成日常操作。
|
||||
|
||||
## 按你的习惯组合
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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`.
|
||||
|
||||
+201
-1
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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<Option<PathBuf>>,
|
||||
settings: Option<PathBuf>,
|
||||
busy: Arc<AtomicBool>,
|
||||
@@ -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<Self>, method: &str, value: Value) -> Result<Value> {
|
||||
@@ -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)
|
||||
|
||||
@@ -38,14 +38,14 @@ fn response(value: Value) -> Result<Value> {
|
||||
.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<Self> {
|
||||
pub(super) fn connect(port: u16) -> Result<Self> {
|
||||
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<Value> {
|
||||
pub(super) fn driver(&mut self, api: &str, args: Value) -> Result<Value> {
|
||||
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<Value> {
|
||||
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<Vec<(String, String)>> {
|
||||
let pids = tool.run_output(&["-t", target, "shell", "pidof uitest"], 2)?;
|
||||
pub(super) fn existing_agents(tool: &Tool, target: &str) -> Result<Vec<(String, String)>> {
|
||||
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<Vec<(String, String)>> {
|
||||
}
|
||||
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 {
|
||||
|
||||
@@ -16,6 +16,9 @@ pub(super) fn busy() -> Fault {
|
||||
)
|
||||
}
|
||||
pub(super) fn forwards(tool: &Tool, target: &str) -> Result<Vec<String>> {
|
||||
forwards_socket(tool, target, SOCKET)
|
||||
}
|
||||
pub(super) fn forwards_socket(tool: &Tool, target: &str, socket: &str) -> Result<Vec<String>> {
|
||||
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<Vec<String>> {
|
||||
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::<Value>(&previous) {
|
||||
if let Some(local) = value["forward"].as_str().filter(|v| {
|
||||
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::<u16>().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) {
|
||||
}) 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::<Vec<_>>(),
|
||||
)
|
||||
}
|
||||
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::<Vec<_>>() })).unwrap();
|
||||
self.file.seek(SeekFrom::Start(0))?;
|
||||
self.file.write_all(&bytes)?;
|
||||
self.file.set_len(bytes.len() as u64)?;
|
||||
|
||||
@@ -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<String>,
|
||||
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<u16> {
|
||||
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<VecDeque<Event>>,
|
||||
changed: Condvar,
|
||||
}
|
||||
pub(super) struct Recorder {
|
||||
pub cancel: Arc<AtomicBool>,
|
||||
pub port: u16,
|
||||
pub keyframe: AtomicBool,
|
||||
pub error: Mutex<Option<Fault>>,
|
||||
dimensions: Arc<Mutex<Dimensions>>,
|
||||
queue: Arc<Queue>,
|
||||
worker: Mutex<Option<thread::JoinHandle<()>>>,
|
||||
resources: Mutex<Option<Resources>>,
|
||||
}
|
||||
impl Recorder {
|
||||
pub fn open(tool: Tool, target: String) -> Result<Arc<Self>> {
|
||||
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<String>,
|
||||
queue: &Queue,
|
||||
cancel: &AtomicBool,
|
||||
dimensions: &Mutex<Dimensions>,
|
||||
) -> 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
|
||||
}
|
||||
@@ -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<String, Parameter>,
|
||||
}
|
||||
#[derive(Clone, PartialEq, Message)]
|
||||
struct Parameter {
|
||||
#[prost(oneof = "parameter::Value", tags = "1, 2, 3, 4, 5, 6")]
|
||||
value: Option<parameter::Value>,
|
||||
}
|
||||
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<u8>),
|
||||
#[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<u8>) -> 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<Bytes>, 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)?
|
||||
}
|
||||
@@ -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<ChildStdin>);
|
||||
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<Pipe>,
|
||||
recorder: Arc<Recorder>,
|
||||
target: String,
|
||||
worker: Option<thread::JoinHandle<()>>,
|
||||
}
|
||||
#[derive(Default)]
|
||||
pub(super) struct Window {
|
||||
current: Mutex<Option<Viewer>>,
|
||||
}
|
||||
#[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<Value> {
|
||||
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::<Value>(&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::<usize>() 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<Value> {
|
||||
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
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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'):
|
||||
|
||||
@@ -215,101 +215,19 @@
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
<section
|
||||
id="screen-panel"
|
||||
class="management-card screen-card"
|
||||
aria-label="投屏与控制"
|
||||
hidden
|
||||
>
|
||||
<section id="screen-panel" class="management-card" aria-label="投屏与控制" hidden>
|
||||
<div class="management-heading">
|
||||
<div>
|
||||
<h2>投屏与控制</h2>
|
||||
<p id="screen-status" role="status">未开始投屏</p>
|
||||
<p id="screen-status" role="status">让手机,来到桌面。</p>
|
||||
</div>
|
||||
<div class="management-actions">
|
||||
<button id="screen-expand" class="secondary">放大画面</button
|
||||
><button id="screen-save" class="secondary">保存画面</button
|
||||
><button id="screen-stop" class="secondary">停止投屏</button
|
||||
><button id="screen-start" class="primary">开始投屏</button>
|
||||
<button id="screen-stop" class="secondary" disabled>停止投屏</button>
|
||||
<button id="screen-start" class="primary">打开投屏窗口</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="screen-layout">
|
||||
<div
|
||||
id="screen-stage"
|
||||
class="screen-stage"
|
||||
tabindex="0"
|
||||
role="region"
|
||||
aria-label="手机画面,启用控制后可以点击、长按和拖动"
|
||||
>
|
||||
<img
|
||||
id="screen-image"
|
||||
alt="当前手机屏幕"
|
||||
draggable="false"
|
||||
hidden
|
||||
/>
|
||||
<div id="screen-placeholder">
|
||||
<span aria-hidden="true">▯</span>
|
||||
<p>让手机,来到桌面。</p>
|
||||
<small>选择已授权的设备,开始投屏。</small>
|
||||
</div>
|
||||
</div>
|
||||
<div id="screen-inputs" class="screen-inputs">
|
||||
<label class="control-switch"
|
||||
><input id="screen-control" type="checkbox" />
|
||||
启用鼠标与键盘控制</label
|
||||
>
|
||||
<div class="screen-nav">
|
||||
<button
|
||||
class="secondary"
|
||||
data-screen-key="back"
|
||||
aria-label="手机返回"
|
||||
>
|
||||
← 返回</button
|
||||
><button
|
||||
class="secondary"
|
||||
data-screen-key="home"
|
||||
aria-label="手机主页"
|
||||
>
|
||||
⌂ 主页
|
||||
</button>
|
||||
</div>
|
||||
<label for="screen-gesture">鼠标手势</label
|
||||
><select id="screen-gesture">
|
||||
<option value="click">点击 / 滑动</option>
|
||||
<option value="doubleClick">双击</option>
|
||||
<option value="longClick">长按</option>
|
||||
<option value="drag">拖拽</option>
|
||||
</select>
|
||||
<p class="muted">
|
||||
拖动可滑动,滚轮可滚动,按住可长按。选中画面后,方向键、回车和退格作用于手机。
|
||||
</p>
|
||||
<label for="screen-text">发送文字</label
|
||||
><textarea
|
||||
id="screen-text"
|
||||
rows="4"
|
||||
maxlength="4096"
|
||||
placeholder="在这里输入或粘贴文字"
|
||||
spellcheck="false"
|
||||
></textarea
|
||||
><button id="screen-send-text" class="secondary">
|
||||
发送文字 · ⌘↵
|
||||
</button>
|
||||
<div class="screen-nav">
|
||||
<button class="secondary" data-screen-key="volumeDown">
|
||||
音量 −</button
|
||||
><button class="secondary" data-screen-key="volumeUp">
|
||||
音量 +
|
||||
</button>
|
||||
</div>
|
||||
<p id="screen-input-status" class="muted" role="status">
|
||||
默认仅观看,启用控制后才会发送操作。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p id="screen-stats" class="muted"></p>
|
||||
<p class="screen-note muted">
|
||||
画面连续采集,刷新速度以设备响应为准。切换工作区、隐藏窗口或断开连接会停止投屏。
|
||||
</p>
|
||||
<p class="muted">在独立窗口中点击、长按和拖动,直接操控手机。滚轮滑动,右键返回,中键回到主页。</p>
|
||||
<p class="muted">⌘F 全屏 · ⌘V 粘贴文字 · ⌘R 切换仅观看 · ⌘W 关闭投屏</p>
|
||||
</section>
|
||||
<div id="apps-panel" class="management-card" hidden>
|
||||
<div class="management-heading">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { screen } from "./screen.js";
|
||||
import { screen } from "./screen-window.js";
|
||||
import { bytes } from "./format.js";
|
||||
|
||||
export function management({ native, host, notice, openUrl }) {
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
export function screen({ call, notice, onBusy }) {
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const state = { target: "", available: false, blocked: false, working: false, active: false, disposed: false };
|
||||
let timer, error;
|
||||
function render() {
|
||||
$("screen-start").disabled = state.working || state.blocked || !state.available;
|
||||
$("screen-start").textContent = state.working ? "正在连接…" : state.active ? "显示投屏窗口" : "打开投屏窗口";
|
||||
$("screen-stop").disabled = state.working || !state.active;
|
||||
$("screen-status").textContent = state.active ? "正在独立窗口中投屏 · 默认鼠标控制" : "让手机,来到桌面。";
|
||||
}
|
||||
async function status() {
|
||||
if (state.disposed) return;
|
||||
try {
|
||||
const result = await call("screenWindowStatus", {});
|
||||
if (state.disposed) return;
|
||||
if (!state.working) state.active = result.active;
|
||||
if (result.error && result.error.message !== error) {
|
||||
error = result.error.message;
|
||||
notice(error);
|
||||
}
|
||||
render();
|
||||
} catch { /* Closing the plugin also closes the native session. */ }
|
||||
if (!state.disposed) timer = setTimeout(status, 1000);
|
||||
}
|
||||
async function action(method, args) {
|
||||
if (state.working) return;
|
||||
state.working = true;
|
||||
onBusy(true);
|
||||
render();
|
||||
try {
|
||||
const result = await call(method, args);
|
||||
state.active = result.active;
|
||||
error = undefined;
|
||||
} catch (error) { notice(error.message || String(error)); }
|
||||
finally {
|
||||
state.working = false;
|
||||
onBusy(false);
|
||||
if (!state.disposed) render();
|
||||
}
|
||||
}
|
||||
$("screen-start").onclick = () => {
|
||||
if (!state.available || state.blocked) return;
|
||||
const name = $("hdc-target").selectedOptions[0]?.textContent || "Harmony";
|
||||
return action("screenWindowOpen", { target: state.target, name });
|
||||
};
|
||||
$("screen-stop").onclick = () => action("screenWindowStop", {});
|
||||
window.addEventListener("pagehide", () => { state.disposed = true; clearTimeout(timer); });
|
||||
void status();
|
||||
render();
|
||||
return {
|
||||
configure(target, available, blocked) {
|
||||
Object.assign(state, { target, available, blocked });
|
||||
render();
|
||||
},
|
||||
hide() { /* The independent window owns its session until closed. */ },
|
||||
};
|
||||
}
|
||||
@@ -1,526 +0,0 @@
|
||||
import {
|
||||
screenPoint,
|
||||
screenImageStyle,
|
||||
gesture,
|
||||
scrollGesture,
|
||||
screenKeys,
|
||||
} from "./screen-geometry.js";
|
||||
|
||||
export function screen({ call, host, notice, onBusy }) {
|
||||
const document = window.document;
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const state = {
|
||||
target: "",
|
||||
available: false,
|
||||
blocked: false,
|
||||
session: null,
|
||||
active: false,
|
||||
starting: false,
|
||||
stopping: false,
|
||||
control: false,
|
||||
controlRevision: 0,
|
||||
supported: [],
|
||||
frame: null,
|
||||
revision: 0,
|
||||
pending: null,
|
||||
timer: null,
|
||||
flight: null,
|
||||
inputFlight: null,
|
||||
pointer: null,
|
||||
wheel: null,
|
||||
wheelTimer: null,
|
||||
displayedAt: 0,
|
||||
samples: [],
|
||||
disposed: false,
|
||||
};
|
||||
function layoutImage() {
|
||||
if (state.frame)
|
||||
Object.assign(
|
||||
$("screen-image").style,
|
||||
screenImageStyle(
|
||||
$("screen-stage").getBoundingClientRect(),
|
||||
state.frame,
|
||||
),
|
||||
);
|
||||
}
|
||||
const resizeObserver = window.ResizeObserver
|
||||
? new window.ResizeObserver(layoutImage)
|
||||
: null;
|
||||
resizeObserver?.observe($("screen-stage"));
|
||||
function controls() {
|
||||
$("screen-start").disabled =
|
||||
!state.available ||
|
||||
state.blocked ||
|
||||
state.active ||
|
||||
state.starting ||
|
||||
state.stopping;
|
||||
$("screen-stop").disabled = !state.active || state.stopping;
|
||||
$("screen-control").disabled =
|
||||
!state.active ||
|
||||
!state.frame ||
|
||||
state.supported.length === 0 ||
|
||||
state.stopping;
|
||||
$("screen-control").checked = state.control;
|
||||
const enabled =
|
||||
state.active && state.control && !!state.frame && !state.stopping;
|
||||
for (const el of $("screen-inputs").querySelectorAll(
|
||||
"button,textarea,select",
|
||||
))
|
||||
el.disabled = !enabled;
|
||||
$("screen-send-text").disabled =
|
||||
!enabled || !state.supported.includes("text") || !$("screen-text").value;
|
||||
$("screen-text").disabled = !enabled || !state.supported.includes("text");
|
||||
for (const el of $("screen-inputs").querySelectorAll("[data-screen-key]"))
|
||||
el.disabled = !enabled || !state.supported.includes("keyEvent");
|
||||
for (const option of $("screen-gesture").options)
|
||||
option.disabled = !state.supported.includes(option.value);
|
||||
$("screen-save").disabled = !state.active || !state.frame || state.stopping;
|
||||
$("screen-stage").classList.toggle("controlling", enabled);
|
||||
$("screen-status").textContent = state.stopping
|
||||
? "正在停止…"
|
||||
: state.starting
|
||||
? "正在连接屏幕…"
|
||||
: state.active
|
||||
? state.frame
|
||||
? state.control
|
||||
? "正在投屏 · 可控制"
|
||||
: "正在投屏 · 仅观看"
|
||||
: "等待手机画面…"
|
||||
: "未开始投屏";
|
||||
}
|
||||
function clearPending() {
|
||||
state.pending = null;
|
||||
state.pointer = null;
|
||||
state.wheel = null;
|
||||
clearTimeout(state.wheelTimer);
|
||||
}
|
||||
function clearFrame() {
|
||||
state.frame = null;
|
||||
state.control = false;
|
||||
clearPending();
|
||||
state.samples = [];
|
||||
$("screen-image").removeAttribute("src");
|
||||
$("screen-image").hidden = true;
|
||||
$("screen-placeholder").hidden = false;
|
||||
$("screen-stats").textContent = "";
|
||||
$("screen-text").value = "";
|
||||
$("screen-input-status").textContent =
|
||||
"默认仅观看,启用控制后才会发送操作。";
|
||||
}
|
||||
async function stop() {
|
||||
const session = state.session;
|
||||
state.revision++;
|
||||
state.active = false;
|
||||
state.session = null;
|
||||
state.stopping = !!session;
|
||||
clearTimeout(state.timer);
|
||||
clearFrame();
|
||||
controls();
|
||||
try {
|
||||
if (session) await call("screenStop", { session });
|
||||
} catch (error) {
|
||||
if (!state.disposed) notice(error.message || String(error));
|
||||
} finally {
|
||||
await Promise.allSettled([state.flight, state.inputFlight]);
|
||||
state.stopping = false;
|
||||
if (state.disposed) return;
|
||||
if (!state.starting) onBusy(false);
|
||||
controls();
|
||||
}
|
||||
}
|
||||
function schedule(delay = 0) {
|
||||
clearTimeout(state.timer);
|
||||
if (state.active && !state.disposed) state.timer = setTimeout(pump, delay);
|
||||
}
|
||||
async function pump() {
|
||||
if (!state.active || state.flight || state.disposed) return;
|
||||
const revision = state.revision;
|
||||
state.flight = (async () => {
|
||||
if (!state.active || revision !== state.revision) return;
|
||||
const controlRevision = state.controlRevision;
|
||||
const frame = await call("screenFrame", {
|
||||
session: state.session,
|
||||
after: state.frame?.sequence || 0,
|
||||
});
|
||||
if (!state.active || revision !== state.revision) return;
|
||||
if (frame.unchanged) {
|
||||
if (!state.frame || frame.geometry !== state.frame.geometry)
|
||||
throw new Error("手机画面状态无效。");
|
||||
state.displayedAt = Date.now();
|
||||
if (controlRevision === state.controlRevision)
|
||||
state.control = !!frame.control;
|
||||
updateStats(frame);
|
||||
controls();
|
||||
return;
|
||||
}
|
||||
if (
|
||||
!/^data:image\/jpeg;base64,/.test(frame.dataUrl) ||
|
||||
!Number.isInteger(frame.width) ||
|
||||
!Number.isInteger(frame.height) ||
|
||||
frame.width < 1 ||
|
||||
frame.height < 1
|
||||
)
|
||||
throw new Error("手机画面格式无效。");
|
||||
const image = $("screen-image");
|
||||
image.src = frame.dataUrl;
|
||||
if (image.decode) await image.decode();
|
||||
if (window.requestAnimationFrame)
|
||||
await new Promise((resolve) => window.requestAnimationFrame(resolve));
|
||||
if (!state.active || revision !== state.revision) {
|
||||
image.removeAttribute("src");
|
||||
return;
|
||||
}
|
||||
const changed = state.frame && state.frame.geometry !== frame.geometry;
|
||||
if (changed) {
|
||||
state.controlRevision++;
|
||||
state.control = false;
|
||||
clearPending();
|
||||
$("screen-input-status").textContent =
|
||||
"屏幕方向已变化,请重新启用控制。";
|
||||
}
|
||||
state.frame = frame;
|
||||
layoutImage();
|
||||
if (!changed && controlRevision === state.controlRevision)
|
||||
state.control = !!frame.control;
|
||||
state.displayedAt = Date.now();
|
||||
state.samples.push(performance.now());
|
||||
while (
|
||||
state.samples.length &&
|
||||
performance.now() - state.samples[0] > 1000
|
||||
)
|
||||
state.samples.shift();
|
||||
updateStats(frame);
|
||||
image.hidden = false;
|
||||
$("screen-placeholder").hidden = true;
|
||||
controls();
|
||||
})();
|
||||
let failure;
|
||||
try {
|
||||
await state.flight;
|
||||
} catch (error) {
|
||||
failure = error;
|
||||
} finally {
|
||||
state.flight = null;
|
||||
}
|
||||
if (revision !== state.revision || state.disposed) return;
|
||||
if (failure) {
|
||||
notice(failure.message || String(failure));
|
||||
await stop();
|
||||
} else schedule(0);
|
||||
}
|
||||
function updateStats(frame) {
|
||||
const received = Number.isFinite(frame.receivedFps)
|
||||
? frame.receivedFps.toFixed(0)
|
||||
: "—";
|
||||
const displayed = state.samples.filter(
|
||||
(t) => performance.now() - t <= 1000,
|
||||
).length;
|
||||
const bitrate = Number.isFinite(frame.mbps)
|
||||
? ` · ${frame.mbps.toFixed(1)} Mbps`
|
||||
: "";
|
||||
$("screen-stats").textContent =
|
||||
`${frame.width} × ${frame.height} · 接收 ${received} 帧/秒 · 显示 ${displayed} 帧/秒${bitrate}`;
|
||||
}
|
||||
async function flushInput() {
|
||||
if (state.inputFlight || !state.pending || !canControl()) return;
|
||||
const pending = state.pending;
|
||||
state.pending = null;
|
||||
if (Date.now() - pending.created > 1500) return;
|
||||
const revision = state.revision;
|
||||
const before = performance.now();
|
||||
state.inputFlight = call("screenInput", pending.request);
|
||||
try {
|
||||
await state.inputFlight;
|
||||
if (revision === state.revision && !state.disposed)
|
||||
$("screen-input-status").textContent =
|
||||
`操作已发送 · ${Math.round(performance.now() - before)} ms`;
|
||||
} catch (error) {
|
||||
if (revision === state.revision && !state.disposed) {
|
||||
state.controlRevision++;
|
||||
state.control = false;
|
||||
clearPending();
|
||||
controls();
|
||||
notice(error.message || String(error));
|
||||
}
|
||||
} finally {
|
||||
state.inputFlight = null;
|
||||
if (revision === state.revision && !state.disposed) void flushInput();
|
||||
}
|
||||
}
|
||||
async function start() {
|
||||
if (
|
||||
state.starting ||
|
||||
state.active ||
|
||||
state.stopping ||
|
||||
state.blocked ||
|
||||
!state.available
|
||||
)
|
||||
return;
|
||||
const revision = ++state.revision;
|
||||
state.starting = true;
|
||||
onBusy(true);
|
||||
clearFrame();
|
||||
controls();
|
||||
notice("");
|
||||
try {
|
||||
const opened = await call("screenStart", { target: state.target });
|
||||
if (revision !== state.revision || state.disposed) {
|
||||
await call("screenStop", { session: opened.session });
|
||||
return;
|
||||
}
|
||||
state.session = opened.session;
|
||||
state.supported = opened.inputCapabilities;
|
||||
state.active = true;
|
||||
$("screen-input-status").textContent = state.supported.length
|
||||
? "启用控制后,可在画面中点击、长按或拖动。"
|
||||
: "设备仅开放画面采集,未开放输入控制。";
|
||||
schedule(0);
|
||||
} catch (error) {
|
||||
if (!state.disposed && revision === state.revision)
|
||||
notice(error.message || String(error));
|
||||
} finally {
|
||||
state.starting = false;
|
||||
if (!state.disposed) {
|
||||
onBusy(state.active || state.stopping);
|
||||
controls();
|
||||
}
|
||||
}
|
||||
}
|
||||
function canControl() {
|
||||
return (
|
||||
state.active &&
|
||||
state.control &&
|
||||
state.frame &&
|
||||
Date.now() - state.displayedAt < 3000 &&
|
||||
!state.stopping
|
||||
);
|
||||
}
|
||||
function send(input, frame = state.frame) {
|
||||
if (
|
||||
!input ||
|
||||
!canControl() ||
|
||||
!frame ||
|
||||
frame.geometry !== state.frame.geometry
|
||||
)
|
||||
return;
|
||||
if (
|
||||
!state.supported.includes(input.kind === "key" ? "keyEvent" : input.kind)
|
||||
)
|
||||
return;
|
||||
if (state.pending) {
|
||||
$("screen-input-status").textContent = "上一项操作仍在等待,请稍候。";
|
||||
return;
|
||||
}
|
||||
state.pending = {
|
||||
created: Date.now(),
|
||||
request: {
|
||||
session: state.session,
|
||||
geometry: frame.geometry,
|
||||
sequence: frame.sequence,
|
||||
input,
|
||||
},
|
||||
};
|
||||
$("screen-input-status").textContent = "等待发送操作…";
|
||||
void flushInput();
|
||||
}
|
||||
function point(event) {
|
||||
return state.frame
|
||||
? screenPoint(
|
||||
$("screen-stage").getBoundingClientRect(),
|
||||
state.frame.width,
|
||||
state.frame.height,
|
||||
event.clientX,
|
||||
event.clientY,
|
||||
)
|
||||
: null;
|
||||
}
|
||||
$("screen-start").onclick = start;
|
||||
$("screen-stop").onclick = stop;
|
||||
$("screen-control").onchange = async () => {
|
||||
const revision = state.revision;
|
||||
const controlRevision = ++state.controlRevision;
|
||||
const enabled = $("screen-control").checked;
|
||||
if (!enabled) {
|
||||
state.control = false;
|
||||
clearPending();
|
||||
controls();
|
||||
}
|
||||
try {
|
||||
const result = await call("screenControl", {
|
||||
session: state.session,
|
||||
enabled,
|
||||
geometry: state.frame?.geometry,
|
||||
});
|
||||
if (
|
||||
revision !== state.revision ||
|
||||
controlRevision !== state.controlRevision ||
|
||||
state.disposed
|
||||
)
|
||||
return;
|
||||
state.control = !!result.control;
|
||||
} catch (error) {
|
||||
if (revision !== state.revision || state.disposed) return;
|
||||
state.control = false;
|
||||
notice(error.message || String(error));
|
||||
}
|
||||
controls();
|
||||
};
|
||||
const stage = $("screen-stage");
|
||||
stage.onpointerdown = (event) => {
|
||||
if (!canControl() || ![0, 2].includes(event.button)) return;
|
||||
const from = point(event);
|
||||
if (!from) return;
|
||||
event.preventDefault();
|
||||
stage.focus();
|
||||
stage.setPointerCapture?.(event.pointerId);
|
||||
state.pointer = {
|
||||
from,
|
||||
frame: state.frame,
|
||||
time: performance.now(),
|
||||
id: event.pointerId,
|
||||
mode: event.button === 2 ? "longClick" : $("screen-gesture").value,
|
||||
};
|
||||
};
|
||||
stage.onpointerup = (event) => {
|
||||
const pointer = state.pointer;
|
||||
state.pointer = null;
|
||||
if (!pointer || pointer.id !== event.pointerId) return;
|
||||
send(
|
||||
gesture(
|
||||
pointer.from,
|
||||
point(event),
|
||||
performance.now() - pointer.time,
|
||||
pointer.mode,
|
||||
),
|
||||
pointer.frame,
|
||||
);
|
||||
if (stage.hasPointerCapture?.(event.pointerId))
|
||||
stage.releasePointerCapture(event.pointerId);
|
||||
};
|
||||
stage.onpointercancel = () => {
|
||||
state.pointer = null;
|
||||
};
|
||||
stage.oncontextmenu = (event) => {
|
||||
if (canControl()) event.preventDefault();
|
||||
};
|
||||
stage.addEventListener(
|
||||
"wheel",
|
||||
(event) => {
|
||||
if (!canControl() || !state.supported.includes("swipe")) return;
|
||||
const at = point(event);
|
||||
if (!at) return;
|
||||
event.preventDefault();
|
||||
const scale =
|
||||
event.deltaMode === 1 ? 20 : event.deltaMode === 2 ? 300 : 2;
|
||||
const wheel = state.wheel || {
|
||||
point: at,
|
||||
frame: state.frame,
|
||||
dx: 0,
|
||||
dy: 0,
|
||||
};
|
||||
wheel.dx += event.deltaX * scale;
|
||||
wheel.dy += event.deltaY * scale;
|
||||
state.wheel = wheel;
|
||||
clearTimeout(state.wheelTimer);
|
||||
state.wheelTimer = setTimeout(() => {
|
||||
state.wheel = null;
|
||||
send(
|
||||
scrollGesture(
|
||||
wheel.point,
|
||||
wheel.dx,
|
||||
wheel.dy,
|
||||
wheel.frame.width,
|
||||
wheel.frame.height,
|
||||
),
|
||||
wheel.frame,
|
||||
);
|
||||
}, 120);
|
||||
},
|
||||
{ passive: false },
|
||||
);
|
||||
stage.onkeydown = (event) => {
|
||||
if (
|
||||
!canControl() ||
|
||||
event.isComposing ||
|
||||
event.ctrlKey ||
|
||||
event.metaKey ||
|
||||
event.altKey
|
||||
)
|
||||
return;
|
||||
if (screenKeys[event.key] && state.supported.includes("keyEvent")) {
|
||||
event.preventDefault();
|
||||
if (!event.repeat) send({ kind: "key", key: screenKeys[event.key] });
|
||||
} else if (event.key.length === 1 && state.supported.includes("text")) {
|
||||
event.preventDefault();
|
||||
$("screen-text").focus();
|
||||
$("screen-text").value += event.key;
|
||||
controls();
|
||||
}
|
||||
};
|
||||
for (const el of $("screen-inputs").querySelectorAll("[data-screen-key]"))
|
||||
el.onclick = () => send({ kind: "key", key: el.dataset.screenKey });
|
||||
$("screen-text").oninput = controls;
|
||||
$("screen-send-text").onclick = () =>
|
||||
send({ kind: "text", text: $("screen-text").value });
|
||||
$("screen-text").onkeydown = (event) => {
|
||||
if (
|
||||
event.key === "Enter" &&
|
||||
(event.metaKey || event.ctrlKey) &&
|
||||
!event.isComposing
|
||||
) {
|
||||
event.preventDefault();
|
||||
$("screen-send-text").click();
|
||||
}
|
||||
};
|
||||
$("screen-expand").onclick = () => {
|
||||
const expanded = $("screen-panel").classList.toggle("expanded");
|
||||
$("screen-expand").textContent = expanded ? "恢复大小" : "放大画面";
|
||||
};
|
||||
$("screen-save").onclick = async () => {
|
||||
const revision = state.revision;
|
||||
try {
|
||||
const { path } = await host("openDirectory", {
|
||||
title: "保存当前手机画面",
|
||||
});
|
||||
if (
|
||||
path &&
|
||||
revision === state.revision &&
|
||||
state.active &&
|
||||
!state.disposed
|
||||
) {
|
||||
const result = await call("screenSave", {
|
||||
session: state.session,
|
||||
directory: path,
|
||||
});
|
||||
if (revision === state.revision && !state.disposed)
|
||||
$("screen-input-status").textContent = `画面已保存到 ${result.path}`;
|
||||
}
|
||||
} catch (error) {
|
||||
if (revision === state.revision && !state.disposed)
|
||||
notice(error.message || String(error));
|
||||
}
|
||||
};
|
||||
document.addEventListener("visibilitychange", () => {
|
||||
if (document.hidden) stop();
|
||||
});
|
||||
window.addEventListener("pagehide", () => {
|
||||
state.disposed = true;
|
||||
resizeObserver?.disconnect();
|
||||
stop();
|
||||
});
|
||||
controls();
|
||||
return {
|
||||
configure(target, available, blocked) {
|
||||
if (state.target !== target && (state.active || state.starting)) stop();
|
||||
state.target = target;
|
||||
state.available = available;
|
||||
state.blocked = blocked;
|
||||
controls();
|
||||
},
|
||||
hide() {
|
||||
if (state.active || state.starting) stop();
|
||||
$("screen-panel").classList.remove("expanded");
|
||||
$("screen-expand").textContent = "放大画面";
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import AppKit
|
||||
import CoreVideo
|
||||
|
||||
final class ViewerApp: NSObject, NSApplicationDelegate, NSWindowDelegate {
|
||||
private var window: NSWindow!
|
||||
private var view: ScreenView!
|
||||
private var samples: [TimeInterval] = []
|
||||
private let pendingLock = NSLock()
|
||||
private var pending: (CVPixelBuffer, ScreenFrame)?
|
||||
private var scheduled = false
|
||||
private var geometry: UInt64 = 0
|
||||
private let deviceName = CommandLine.arguments.dropFirst().first ?? "Harmony"
|
||||
|
||||
func applicationDidFinishLaunching(_ notification: Notification) {
|
||||
window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 380, height: 760), styleMask: [.titled, .closable, .miniaturizable, .resizable], backing: .buffered, defer: false)
|
||||
window.title = deviceName
|
||||
window.minSize = NSSize(width: 220, height: 260)
|
||||
window.backgroundColor = .black
|
||||
window.isReleasedWhenClosed = false
|
||||
window.delegate = self
|
||||
view = ScreenView(frame: window.contentLayoutRect)
|
||||
window.contentView = view
|
||||
window.center()
|
||||
window.makeKeyAndOrderFront(nil)
|
||||
window.makeFirstResponder(view)
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
let menu = NSMenu()
|
||||
let item = NSMenuItem()
|
||||
let actions = NSMenu()
|
||||
actions.addItem(withTitle: "关闭投屏", action: #selector(NSWindow.performClose(_:)), keyEquivalent: "w")
|
||||
actions.addItem(withTitle: "全屏", action: #selector(NSWindow.toggleFullScreen(_:)), keyEquivalent: "f")
|
||||
actions.addItem(withTitle: "仅观看", action: #selector(toggleControl(_:)), keyEquivalent: "r").target = self
|
||||
item.submenu = actions
|
||||
menu.addItem(item)
|
||||
NSApp.mainMenu = menu
|
||||
DispatchQueue.global(qos: .userInteractive).async { [weak self] in self?.receive() }
|
||||
}
|
||||
|
||||
@objc private func toggleControl(_ item: NSMenuItem) {
|
||||
view.readOnly.toggle()
|
||||
item.state = view.readOnly ? .on : .off
|
||||
}
|
||||
|
||||
private func present(_ image: CVPixelBuffer, _ frame: ScreenFrame) {
|
||||
pendingLock.lock()
|
||||
pending = (image, frame)
|
||||
let enqueue = !scheduled
|
||||
scheduled = true
|
||||
pendingLock.unlock()
|
||||
guard enqueue else { return }
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self else { return }
|
||||
self.pendingLock.lock()
|
||||
let current = self.pending
|
||||
self.pending = nil
|
||||
self.scheduled = false
|
||||
self.pendingLock.unlock()
|
||||
guard let (image, frame) = current else { return }
|
||||
self.view.display(image, frame: frame)
|
||||
if self.geometry != frame.geometry {
|
||||
let initial = self.geometry == 0
|
||||
self.geometry = frame.geometry
|
||||
if !self.window.styleMask.contains(.fullScreen) {
|
||||
let visible = self.window.screen?.visibleFrame ?? NSRect(x: 0, y: 0, width: 1000, height: 900)
|
||||
let scale = min(820 / CGFloat(max(frame.width, frame.height)), (visible.width - 100) / CGFloat(frame.width), (visible.height - 100) / CGFloat(frame.height))
|
||||
self.window.setContentSize(NSSize(width: CGFloat(frame.width) * scale, height: CGFloat(frame.height) * scale))
|
||||
if initial { self.window.center() }
|
||||
}
|
||||
}
|
||||
let now = ProcessInfo.processInfo.systemUptime
|
||||
self.samples.append(now)
|
||||
self.samples.removeAll { now - $0 > 1 }
|
||||
self.window.title = "\(self.deviceName) · \(self.samples.count) fps · \(String(format: "%.1f", frame.mbps)) Mbps"
|
||||
}
|
||||
}
|
||||
|
||||
private func receive() {
|
||||
let decoder = VideoDecoder()
|
||||
decoder.output = { [weak self] in self?.present($0, $1) }
|
||||
decoder.failure = { ViewerWire.send(["event": "decoderError"]) }
|
||||
do {
|
||||
while true {
|
||||
let (message, bytes) = try ViewerWire.read()
|
||||
if message.kind == "video", let frame = message.frame {
|
||||
do { try decoder.decode(bytes, frame: frame) }
|
||||
catch { decoder.recover() }
|
||||
} else if message.kind == "focus" {
|
||||
DispatchQueue.main.async { [weak self] in self?.window.makeKeyAndOrderFront(nil); NSApp.activate(ignoringOtherApps: true) }
|
||||
} else if message.kind == "error" {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.window.title = message.message ?? "连接已结束"
|
||||
self?.view.readOnly = true
|
||||
let alert = NSAlert()
|
||||
alert.messageText = "投屏已停止"
|
||||
alert.informativeText = message.message ?? "请检查 USB 连接后重新开始。"
|
||||
if let window = self?.window { alert.beginSheetModal(for: window) { _ in window.close() } }
|
||||
}
|
||||
} else if message.kind == "close" {
|
||||
DispatchQueue.main.async { NSApp.terminate(nil) }
|
||||
return
|
||||
} else { throw CocoaError(.fileReadCorruptFile) }
|
||||
}
|
||||
} catch {
|
||||
DispatchQueue.main.async { NSApp.terminate(nil) }
|
||||
}
|
||||
}
|
||||
|
||||
func windowDidResignKey(_ notification: Notification) { view.releaseTouch() }
|
||||
func applicationWillTerminate(_ notification: Notification) { view.releaseTouch() }
|
||||
func windowWillClose(_ notification: Notification) { view.releaseTouch(); NSApp.terminate(nil) }
|
||||
func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { true }
|
||||
}
|
||||
|
||||
@main struct Main {
|
||||
static func main() {
|
||||
signal(SIGPIPE, SIG_IGN)
|
||||
let app = NSApplication.shared
|
||||
app.setActivationPolicy(.regular)
|
||||
let delegate = ViewerApp()
|
||||
app.delegate = delegate
|
||||
withExtendedLifetime(delegate) { app.run() }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import Foundation
|
||||
|
||||
struct ScreenFrame: Decodable {
|
||||
let sequence: UInt64
|
||||
let geometry: UInt64
|
||||
let width: Int
|
||||
let height: Int
|
||||
let captureWidth: Int
|
||||
let captureHeight: Int
|
||||
let receivedFps: Double
|
||||
let mbps: Double
|
||||
}
|
||||
|
||||
struct ViewerMessage: Decodable {
|
||||
let kind: String
|
||||
let frame: ScreenFrame?
|
||||
let message: String?
|
||||
}
|
||||
|
||||
enum ViewerWire {
|
||||
private static let outputLock = NSLock()
|
||||
|
||||
static func send(_ value: [String: Any]) {
|
||||
guard var data = try? JSONSerialization.data(withJSONObject: value) else { return }
|
||||
data.append(10)
|
||||
outputLock.lock()
|
||||
defer { outputLock.unlock() }
|
||||
try? FileHandle.standardOutput.write(contentsOf: data)
|
||||
}
|
||||
|
||||
private static func readExactly(_ length: Int) throws -> Data {
|
||||
var result = Data()
|
||||
while result.count < length {
|
||||
guard let chunk = try FileHandle.standardInput.read(upToCount: length - result.count),
|
||||
!chunk.isEmpty else { throw CocoaError(.fileReadCorruptFile) }
|
||||
result.append(chunk)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
static func read() throws -> (ViewerMessage, Data) {
|
||||
let header = try readExactly(8)
|
||||
func integer(_ offset: Int) -> Int {
|
||||
header[offset..<(offset + 4)].reduce(0) { ($0 << 8) | Int($1) }
|
||||
}
|
||||
let metadataLength = integer(0), payloadLength = integer(4)
|
||||
guard (1...65536).contains(metadataLength), (0...(8 * 1024 * 1024)).contains(payloadLength) else {
|
||||
throw CocoaError(.fileReadCorruptFile)
|
||||
}
|
||||
let message = try JSONDecoder().decode(ViewerMessage.self, from: readExactly(metadataLength))
|
||||
return (message, try readExactly(payloadLength))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import AppKit
|
||||
import AVFoundation
|
||||
import QuartzCore
|
||||
|
||||
final class ScreenView: NSView {
|
||||
private let picture = AVSampleBufferDisplayLayer()
|
||||
private var frameInfo: ScreenFrame?
|
||||
private var displaySize = CGSize(width: 1, height: 2)
|
||||
private var pressed: CGPoint?
|
||||
private var wheelEnd: DispatchWorkItem?
|
||||
var readOnly = false { willSet { if newValue { releaseTouch() } } }
|
||||
|
||||
override var acceptsFirstResponder: Bool { true }
|
||||
override var isFlipped: Bool { true }
|
||||
|
||||
override init(frame: NSRect) {
|
||||
super.init(frame: frame)
|
||||
wantsLayer = true
|
||||
layer?.backgroundColor = NSColor.black.cgColor
|
||||
picture.videoGravity = .resizeAspect
|
||||
layer?.addSublayer(picture)
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) { fatalError("init(coder:) is unavailable") }
|
||||
|
||||
func display(_ image: CVPixelBuffer, frame: ScreenFrame) {
|
||||
if frameInfo?.geometry != frame.geometry { releaseTouch() }
|
||||
displaySize = CGSize(width: CVPixelBufferGetWidth(image), height: CVPixelBufferGetHeight(image))
|
||||
let aspectMatches = abs(log((displaySize.width / displaySize.height) / (CGFloat(frame.width) / CGFloat(frame.height)))) < 0.025
|
||||
if !aspectMatches { releaseTouch() }
|
||||
frameInfo = aspectMatches ? frame : nil
|
||||
var format: CMVideoFormatDescription?
|
||||
guard CMVideoFormatDescriptionCreateForImageBuffer(allocator: kCFAllocatorDefault, imageBuffer: image, formatDescriptionOut: &format) == noErr, let format else { return }
|
||||
var timing = CMSampleTimingInfo(duration: .invalid, presentationTimeStamp: .zero, decodeTimeStamp: .invalid)
|
||||
var sample: CMSampleBuffer?
|
||||
guard CMSampleBufferCreateReadyWithImageBuffer(allocator: kCFAllocatorDefault, imageBuffer: image, formatDescription: format, sampleTiming: &timing, sampleBufferOut: &sample) == noErr, let sample else { return }
|
||||
if let attachments = CMSampleBufferGetSampleAttachmentsArray(sample, createIfNecessary: true) as? [NSMutableDictionary] {
|
||||
attachments.first?[kCMSampleAttachmentKey_DisplayImmediately] = true
|
||||
}
|
||||
CATransaction.begin()
|
||||
CATransaction.setDisableActions(true)
|
||||
picture.frame = imageRect()
|
||||
CATransaction.commit()
|
||||
if picture.status == .failed { picture.flush() }
|
||||
picture.enqueue(sample)
|
||||
}
|
||||
|
||||
private func imageRect() -> CGRect {
|
||||
let scale = min(bounds.width / displaySize.width, bounds.height / displaySize.height)
|
||||
let size = CGSize(width: displaySize.width * scale, height: displaySize.height * scale)
|
||||
return CGRect(x: (bounds.width - size.width) / 2, y: (bounds.height - size.height) / 2, width: size.width, height: size.height)
|
||||
}
|
||||
|
||||
override func layout() {
|
||||
super.layout()
|
||||
CATransaction.begin()
|
||||
CATransaction.setDisableActions(true)
|
||||
picture.frame = imageRect()
|
||||
CATransaction.commit()
|
||||
}
|
||||
|
||||
private func point(_ event: NSEvent) -> CGPoint? {
|
||||
guard !readOnly, let frame = frameInfo else { return nil }
|
||||
let position = convert(event.locationInWindow, from: nil), rect = imageRect()
|
||||
guard rect.contains(position), rect.width > 0, rect.height > 0 else { return nil }
|
||||
return CGPoint(x: floor((position.x - rect.minX) * CGFloat(frame.width) / rect.width),
|
||||
y: floor((position.y - rect.minY) * CGFloat(frame.height) / rect.height))
|
||||
}
|
||||
|
||||
private func input(_ value: [String: Any], frame: ScreenFrame? = nil) {
|
||||
guard !readOnly, let frame = frame ?? frameInfo else { return }
|
||||
ViewerWire.send(["event": "input", "sequence": frame.sequence, "geometry": frame.geometry, "input": value])
|
||||
}
|
||||
|
||||
override func mouseDown(with event: NSEvent) {
|
||||
window?.makeFirstResponder(self)
|
||||
releaseTouch()
|
||||
guard let point = point(event) else { return }
|
||||
pressed = point
|
||||
touch("touchDown", point)
|
||||
}
|
||||
|
||||
override func mouseDragged(with event: NSEvent) {
|
||||
guard pressed != nil, let point = point(event) else { return }
|
||||
pressed = point
|
||||
touch("touchMove", point)
|
||||
}
|
||||
|
||||
override func mouseUp(with event: NSEvent) {
|
||||
if pressed != nil, let point = point(event) { pressed = point }
|
||||
releaseTouch()
|
||||
}
|
||||
|
||||
private func touch(_ kind: String, _ point: CGPoint) {
|
||||
input(["kind": kind, "x": Int(point.x), "y": Int(point.y)])
|
||||
}
|
||||
|
||||
func releaseTouch() {
|
||||
wheelEnd?.cancel(); wheelEnd = nil
|
||||
if let point = pressed { touch("touchUp", point) }
|
||||
pressed = nil
|
||||
}
|
||||
|
||||
override func rightMouseUp(with event: NSEvent) { input(["kind": "key", "key": "back"]) }
|
||||
override func otherMouseUp(with event: NSEvent) { if event.buttonNumber == 2 { input(["kind": "key", "key": "home"]) } }
|
||||
|
||||
override func scrollWheel(with event: NSEvent) {
|
||||
guard let location = point(event), let frame = frameInfo else { return }
|
||||
if pressed == nil { pressed = location; touch("touchDown", location) }
|
||||
guard let point = pressed else { return }
|
||||
let factor: CGFloat = event.hasPreciseScrollingDeltas ? 3 : 30
|
||||
let next = CGPoint(x: max(0, min(CGFloat(frame.width - 1), point.x + event.scrollingDeltaX * factor)),
|
||||
y: max(0, min(CGFloat(frame.height - 1), point.y + event.scrollingDeltaY * factor)))
|
||||
pressed = next
|
||||
touch("touchMove", next)
|
||||
wheelEnd?.cancel()
|
||||
let end = DispatchWorkItem { [weak self] in self?.releaseTouch() }
|
||||
wheelEnd = end
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.12, execute: end)
|
||||
}
|
||||
|
||||
override func keyDown(with event: NSEvent) {
|
||||
guard !readOnly else { return }
|
||||
if event.modifierFlags.contains(.command) {
|
||||
if event.charactersIgnoringModifiers == "v", let text = NSPasteboard.general.string(forType: .string) {
|
||||
input(["kind": "text", "text": text])
|
||||
} else { super.keyDown(with: event) }
|
||||
return
|
||||
}
|
||||
let keys: [UInt16: String] = [53: "back", 36: "enter", 51: "backspace", 117: "delete", 48: "tab", 123: "left", 124: "right", 125: "down", 126: "up", 115: "home"]
|
||||
if let key = keys[event.keyCode] { input(["kind": "key", "key": key]) }
|
||||
else if let text = event.characters, !text.isEmpty { input(["kind": "text", "text": text]) }
|
||||
}
|
||||
|
||||
override func resignFirstResponder() -> Bool { releaseTouch(); return true }
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import Foundation
|
||||
import VideoToolbox
|
||||
import CoreMedia
|
||||
|
||||
/// Decode every reference frame; the view keeps only the latest decoded image.
|
||||
final class VideoDecoder {
|
||||
private var sps = Data(), pps = Data()
|
||||
private var format: CMVideoFormatDescription?
|
||||
private var session: VTDecompressionSession?
|
||||
private let slots = DispatchSemaphore(value: 3)
|
||||
var output: ((CVPixelBuffer, ScreenFrame) -> Void)?
|
||||
private let recoveryLock = NSLock()
|
||||
private var needsKeyframe = false
|
||||
var failure: (() -> Void)?
|
||||
|
||||
private final class Context {
|
||||
let frame: ScreenFrame
|
||||
let owner: VideoDecoder
|
||||
init(_ frame: ScreenFrame, _ owner: VideoDecoder) { self.frame = frame; self.owner = owner }
|
||||
}
|
||||
|
||||
deinit { if let session { VTDecompressionSessionInvalidate(session) } }
|
||||
|
||||
private func configure() throws {
|
||||
guard !sps.isEmpty, !pps.isEmpty else { return }
|
||||
if let session {
|
||||
VTDecompressionSessionWaitForAsynchronousFrames(session)
|
||||
VTDecompressionSessionInvalidate(session)
|
||||
self.session = nil
|
||||
}
|
||||
let status = sps.withUnsafeBytes { s in pps.withUnsafeBytes { p in
|
||||
let pointers = [s.bindMemory(to: UInt8.self).baseAddress!, p.bindMemory(to: UInt8.self).baseAddress!]
|
||||
let lengths = [s.count, p.count]
|
||||
return CMVideoFormatDescriptionCreateFromH264ParameterSets(allocator: kCFAllocatorDefault, parameterSetCount: 2, parameterSetPointers: pointers, parameterSetSizes: lengths, nalUnitHeaderLength: 4, formatDescriptionOut: &format)
|
||||
} }
|
||||
guard status == noErr, let format else { throw CocoaError(.fileReadCorruptFile) }
|
||||
var callback = VTDecompressionOutputCallbackRecord(decompressionOutputCallback: { _, source, status, _, image, _, _ in
|
||||
guard let source else { return }
|
||||
let context = Unmanaged<Context>.fromOpaque(source).takeRetainedValue()
|
||||
defer { context.owner.slots.signal() }
|
||||
if status == noErr, let image { context.owner.output?(image, context.frame) }
|
||||
else if status != noErr { context.owner.recover() }
|
||||
}, decompressionOutputRefCon: nil)
|
||||
let attributes = [kCVPixelBufferPixelFormatTypeKey: kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange,
|
||||
kCVPixelBufferIOSurfacePropertiesKey: [:]] as CFDictionary
|
||||
let specification = [kVTVideoDecoderSpecification_EnableHardwareAcceleratedVideoDecoder: true] as CFDictionary
|
||||
guard VTDecompressionSessionCreate(allocator: kCFAllocatorDefault, formatDescription: format, decoderSpecification: specification, imageBufferAttributes: attributes, outputCallback: &callback, decompressionSessionOut: &session) == noErr, let session else { throw CocoaError(.featureUnsupported) }
|
||||
VTSessionSetProperty(session, key: kVTDecompressionPropertyKey_RealTime, value: kCFBooleanTrue)
|
||||
}
|
||||
|
||||
func recover() {
|
||||
recoveryLock.lock()
|
||||
let notify = !needsKeyframe
|
||||
needsKeyframe = true
|
||||
recoveryLock.unlock()
|
||||
if notify { failure?() }
|
||||
}
|
||||
|
||||
func decode(_ data: Data, frame: ScreenFrame) throws {
|
||||
let units = annexB(data)
|
||||
guard !units.isEmpty else { throw CocoaError(.fileReadCorruptFile) }
|
||||
recoveryLock.lock()
|
||||
let recovering = needsKeyframe
|
||||
if recovering && units.contains(where: { $0[$0.startIndex] & 31 == 5 }) { needsKeyframe = false }
|
||||
let waiting = needsKeyframe
|
||||
recoveryLock.unlock()
|
||||
var accessUnit = Data(), changed = recovering
|
||||
for unit in units {
|
||||
switch unit[unit.startIndex] & 31 {
|
||||
case 7: if sps != unit { sps = unit; changed = true }
|
||||
case 8: if pps != unit { pps = unit; changed = true }
|
||||
case 9: break // Access-unit delimiter has no decoding payload.
|
||||
default:
|
||||
var length = UInt32(unit.count).bigEndian
|
||||
withUnsafeBytes(of: &length) { accessUnit.append(contentsOf: $0) }
|
||||
accessUnit.append(unit)
|
||||
}
|
||||
}
|
||||
if changed { try configure() }
|
||||
if waiting { return }
|
||||
guard units.contains(where: { (1...5).contains($0[$0.startIndex] & 31) }) else { return }
|
||||
guard !accessUnit.isEmpty, let session, let format else { recover(); return }
|
||||
var block: CMBlockBuffer?
|
||||
guard CMBlockBufferCreateWithMemoryBlock(allocator: kCFAllocatorDefault, memoryBlock: nil, blockLength: accessUnit.count, blockAllocator: kCFAllocatorDefault, customBlockSource: nil, offsetToData: 0, dataLength: accessUnit.count, flags: 0, blockBufferOut: &block) == noErr, let block else { throw CocoaError(.fileReadCorruptFile) }
|
||||
let copied = accessUnit.withUnsafeBytes { CMBlockBufferReplaceDataBytes(with: $0.baseAddress!, blockBuffer: block, offsetIntoDestination: 0, dataLength: $0.count) }
|
||||
guard copied == noErr else { throw CocoaError(.fileReadCorruptFile) }
|
||||
var sample: CMSampleBuffer?
|
||||
var size = accessUnit.count
|
||||
guard CMSampleBufferCreateReady(allocator: kCFAllocatorDefault, dataBuffer: block, formatDescription: format, sampleCount: 1, sampleTimingEntryCount: 0, sampleTimingArray: nil, sampleSizeEntryCount: 1, sampleSizeArray: &size, sampleBufferOut: &sample) == noErr, let sample else { throw CocoaError(.fileReadCorruptFile) }
|
||||
slots.wait()
|
||||
let context = Unmanaged.passRetained(Context(frame, self))
|
||||
let status = VTDecompressionSessionDecodeFrame(session, sampleBuffer: sample, flags: [._EnableAsynchronousDecompression], frameRefcon: context.toOpaque(), infoFlagsOut: nil)
|
||||
if status != noErr {
|
||||
context.release()
|
||||
slots.signal()
|
||||
throw CocoaError(.fileReadCorruptFile)
|
||||
}
|
||||
}
|
||||
|
||||
private func annexB(_ data: Data) -> [Data] {
|
||||
let bytes = [UInt8](data)
|
||||
var result: [Data] = [], start: Int?, i = 0
|
||||
while i + 2 < bytes.count {
|
||||
var prefix = 0
|
||||
if bytes[i] == 0, bytes[i + 1] == 0 {
|
||||
if bytes[i + 2] == 1 { prefix = 3 }
|
||||
else if i + 3 < bytes.count, bytes[i + 2] == 0, bytes[i + 3] == 1 { prefix = 4 }
|
||||
}
|
||||
if prefix > 0 {
|
||||
if let start, i > start { result.append(Data(bytes[start..<i])) }
|
||||
i += prefix; start = i
|
||||
} else { i += 1 }
|
||||
}
|
||||
if let start, start < bytes.count { result.append(Data(bytes[start...])) }
|
||||
return result
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
# Harmony Connector
|
||||
|
||||
插件 ID:`dev.trove.harmony-connector`。当前版本:`0.4.0`。
|
||||
插件 ID:`dev.trove.harmony-connector`。当前版本:`0.5.0`。
|
||||
|
||||
macOS 上的鸿蒙设备连接工具。通过 USB 收发文件与完整文件夹;使用官方 HDC 管理应用、保存截图、导出日志和调试应用数据。功能按连接工具、设备实际能力和授权开放,不按 HarmonyOS 大版本或机型名称限制。
|
||||
|
||||
@@ -65,17 +65,17 @@ macOS 上的鸿蒙设备连接工具。通过 USB 收发文件与完整文件夹
|
||||
|
||||
## 投屏与控制
|
||||
|
||||
在「设备与应用」选择已授权的 HDC 目标,点击「开始投屏」。默认仅观看;画面出现后勾选「启用鼠标与键盘控制」。功能依设备的 `uitest` 帮助和实际操作结果开放。
|
||||
在「设备与应用」选择已授权的 HDC 目标,点击「打开投屏窗口」。投屏使用独立的 macOS 窗口,默认可以直接通过画面控制手机。
|
||||
|
||||
- 连续显示手机画面,显示真实分辨率、接收/显示帧率和码率。可放大面板,将当前画面保存为 JPEG。
|
||||
- 点击、双击、长按、滑动、拖拽与滚轮滚动;坐标按实际手机尺寸映射,忽略画面周围留白。
|
||||
- 返回、主页、音量、方向键、回车、退格等按键。快捷键只在手机画面获得焦点时生效。
|
||||
- 用专用文字框发送文本,支持输入法;不读取系统剪贴板。文字能否进入目标输入框,以手机焦点和输入权限为准。
|
||||
- 一个屏幕会话只保留一项待发送操作。停止、切换工作区、页面隐藏、断线时清空队列和旧画面,不在重连后重放输入。横竖屏变化后需重新启用控制。
|
||||
- 鼠标按下、拖动、松开即时发送触摸动作;按住即可长按,滚轮滑动,右键返回,中键回到主页。
|
||||
- ⌘F 切换全屏,⌘V 向手机粘贴剪贴板文字,⌘R 切换仅观看,⌘W 关闭投屏。方向键、回车、退格直接作用于手机。
|
||||
- 窗口标题显示显示帧率和接收码率。只保留最新解码画面,避免积累播放延迟。
|
||||
- 切换管理页和文件页、隐藏管理窗口不停止投屏。关闭投屏窗口或点击「停止投屏」会释放设备会话;退出插件也会停止。
|
||||
- 失焦、关闭窗口和画面方向变化时释放当前触摸;移动事件合并,按下和松开保持顺序,不在重连后重放输入。
|
||||
|
||||
0.4.0 通过 HDC 部署官方 Hypium 代理,持续接收 JPEG 画面,并通过独立连接发送输入。用户无需开发者账号、编译、签名或安装手机应用。拖动在松开时发送完整手势。停止会删除本会话的代理和转发,再次启动会恢复插件自身的残留会话;其他调试工具占用设备时会提示。真机已通过三轮连续启动、收图、停止及重连;动态性能验收状态见[开发验证](harmony-realtime-development.md)。
|
||||
0.5.0 使用官方 Hypium 26 的 Unix H.264 recorder 和独立控制代理,经 HDC 转发视频及触摸通道。手机端编码,Mac 端 VideoToolbox 解码并立即显示。无需开发者账号、签名、手机应用或完整 SDK;用户只需官方 HDC 和已授权的设备 USB 调试。编码配置目标为 60 fps、8 Mbps、半尺寸,实际流畅度取决于设备,当前版本尚未进行真机性能验收。
|
||||
|
||||
JPEG 采用半尺寸采集,坐标按手机原始尺寸计算。画面不变化时保留显示,通过独立控制连接检查存活;方向变化、会话过期或输入失败时会阻止控制。受保护画面和锁屏遵循系统限制,不自动解锁。自动化测试和真机结果分别记录,详见[HDC 持续投屏调研](research/harmony-hdc-only-screen.md)。
|
||||
组件与传输依据见[官方视频与触控调研](research/harmony-screen-performance.md)。旧 JPEG 后端接口仅保留用于诊断;管理页已使用独立视频窗口。
|
||||
|
||||
## 重连问题与 debug
|
||||
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
# Harmony Connector 持续投屏开发验证
|
||||
# Harmony Connector 投屏开发记录
|
||||
|
||||
2026-09-10:0.5.0 改为独立 AppKit 窗口、官方 Unix H.264 视频流和默认实时触控。按用户要求,本轮只完成编码与构建,不运行测试、真机跑分或远程源安装验证。60 fps 是编码配置目标,不能作为已达到的性能结果。官方接口依据见[视频性能调研](research/harmony-screen-performance.md)。
|
||||
|
||||
以下为旧版本历史记录。
|
||||
|
||||
运行路径为:已授权的 HDC USB 连接 → 官方 Hypium 设备代理 → 持续 JPEG 推送 → 插件显示。用户无需开发者账号、AppGallery Connect、HAP 安装、编译或签名。代理由插件自动部署到本次会话的临时文件,停止时删除。
|
||||
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
# Harmony Connector 实时投屏性能:官方资料与协议
|
||||
|
||||
调研日期:2026-09-10。范围:macOS、用户已授权的 USB 调试/HDC、官方 UITest 扩展;不要求开发者账号、HAP 安装、root 或修改设备安全策略。本报告仅做官方资料及官方分发包的静态分析,没有启动设备代理或测量手机帧率。用户要求优先完成编码,因而不在本报告中承诺实测 60 fps。
|
||||
|
||||
## 结论
|
||||
|
||||
应把实时画面从逐帧 JPEG 改为官方 Hypium recorder 的 H.264 视频流,再由 macOS 的持久视频图层显示。Hypium **26.0.0.500** 官方包确实提供已带签名块的 Unix socket recorder,可通过 HDC 的 `localabstract` 转发实现设备端无 TCP 监听的连接。此前检查的 PyPI 6.1.0.210 包缺少这些 Unix recorder,不能据此判断鸿蒙没有这条路径。[官方 Hypium 下载页](https://developer.huawei.com/consumer/cn/download/deveco-testing-hypium)、[官方 26.0.0 发布说明](https://developer.huawei.com/consumer/cn/doc/harmonyos-releases/deveco-testing-releasenotes-2600)
|
||||
|
||||
**容易接错的 socket:实时 `-m 0` 使用 `scrcpy_grpc_socket`;文件录制 `-m 1` 使用 `screen_record_grpc_socket`。** 官方 Python 的 `record_agent.py` 是文件录制封装,照抄它的 socket 名并把 mode 改为 0,会连不到实时服务。此区别直接来自 26 包 `libscrcpy_server_unix_6.6-20260629.z.so` 的 `RpcServer::InitServer`,见下文精确位置。
|
||||
|
||||
## 官方分发包和本地证据
|
||||
|
||||
来源是官方下载页当前列出的 **DevEco Testing Hypium 26.0.0.500,118.7 MB,2026/08/29**。通过用户现有网页登录状态点击正常下载取得官方 CDN 文件,未要求新账号或签名授权。临时签名下载 URL 不写入仓库。
|
||||
|
||||
研究文件保存在忽略目录 `dist/harmony-screen-performance-research/`;不是产品运行时路径。
|
||||
|
||||
| 文件 | 本地计算的 SHA256 |
|
||||
| --- | --- |
|
||||
| `devecotesting-hypium-26.0.0.500.zip` | `1c66fb288a291e3cd1743a4bf82708752bcef58c230748088194182f304a097f` |
|
||||
| `hypium26/python/xdevice_devicetest-26.0.0.500-py3-none-any.whl` | `810b2d33bb86a6fe91c5f24fe9bc33cc3937e3af84ce1d760e3257bb648f9c06` |
|
||||
|
||||
外层 zip 包含 `hypium-26.0.0.500.zip`、`hypium-driver-js-26.0.0500.zip` 和 PyCharm 插件 zip。Python zip 包含 hypium、xdevice、xdevice_ohos、xdevice_devicetest 四个 wheel、API 文档和安装脚本。只解压读取,没有运行安装脚本。以上是下载原件的本地校验摘要,不声称已经另行比对官网公布的 SHA256。
|
||||
|
||||
以下资源的完整本地目录为:
|
||||
|
||||
```text
|
||||
dist/harmony-screen-performance-research/hypium26/python/
|
||||
xdevice_devicetest/devicetest/res/recorder/
|
||||
```
|
||||
|
||||
| Unix recorder 原件 | 字节 | SHA256 |
|
||||
| --- | ---: | --- |
|
||||
| `libscrcpy_server_unix_6.3.1-20260113.z.so` | 272567 | `3e29e3a1d23ea6dbd4e6fb3b8f094ca5d4b15b4c580f421fba3d0e1f5429e3fe` |
|
||||
| `libscrcpy_server_unix_6.4-20260113.z.so` | 264374 | `b698b41ae1351337eeea3dcc87bf0160552196d669ba321b4963c342fa5a47f4` |
|
||||
| `libscrcpy_server_unix_6.5-20260313.z.so` | 268469 | `2c8f1ef94d581281bf8e86d54f5939af9b205d5db5aa2cfb27f2562215c87b0a` |
|
||||
| `libscrcpy_server_unix_6.6-20260418.z.so` | 268471 | `98bbc964fef1c4d17b446ec717102b855d09224b23fc1872bf70e1f928d010dd` |
|
||||
| `libscrcpy_server_unix_6.6-20260629.z.so` | 260278 | `b24231188ab0fffa84c2bd71cab6948c8ffad9d661c80301f32eaf580177ce8e` |
|
||||
|
||||
各 Unix 原件均含 `elf sign block`;旧 recorder 也带该块及 Huawei 软件签名证书链字符串。这证明分发文件带有签名结构,不等于本报告独立完成签名密码学验证,也不保证每个系统版本均接受每个原件。必须完整保留原件字节,不能 strip、改监听地址或重签名再声称是官方原件。
|
||||
|
||||
同 wheel 还提供 `devicetest/res/prototype/native/uitest_agent_v1.2.3.so`,以及 1.1.3、1.1.5、1.1.12 和 x86_64 资源。wheel 的 `xdevice_devicetest-26.0.0.500.dist-info/licenses/LICENSE` 是 Apache License 2.0;相关 Python 文件带 Huawei Device Co., Ltd. 的 Apache 2.0 版权头。打包时保留对应许可和来源,不把另一个独立下载包的授权凭空套用到这些文件。
|
||||
|
||||
`uitest_agent_v1.2.3.so` 原件为 **600246 字节**,SHA256 为 `5dfbaca7e274a85bece8f225148ceb759b94d7319b956b9a7ea40d37944e4a9b`。该 wheel 的 METADATA 明列 `License-File: LICENSE`;解包中未发现另外的 NOTICE 或逐二进制许可文件。产品随两种原件保留这份 LICENSE,并在自己的第三方声明中记录 Huawei 官方分发包、版本、原件路径、摘要及未修改状态;不要将自己补写的来源说明说成上游附带 NOTICE。
|
||||
|
||||
## 官方代理选择规则
|
||||
|
||||
直接证据文件:
|
||||
|
||||
```text
|
||||
hypium26/python/xdevice_devicetest/devicetest/controllers/tools/recorder/record_agent.py
|
||||
hypium26/python/xdevice_ohos/ohos/environment/device.py
|
||||
```
|
||||
|
||||
`RecordAgent._start_scrcpy_server` 首先读取 UITest 版本,要求至少 4.1.4.6,随后按 `device.check_if_unix_socket_mode()` 决定传输。后者实际代码判断为 UITest 版本元组大于 `6.0.2.1` 且架构不含 `x86_64`。旁边注释写的是“大于 6.0.2.2”,与代码不完全一致;客户端应使用实际能力和数值版本解析,不照抄字符串元组比较的问题。
|
||||
|
||||
Unix 分支只尝试文件名含 `unix` 的资源;非 Unix 分支只尝试不含 `unix` 的资源。官方按 `os.walk` 文件列表倒序逐个启动,**没有把文件名的 6.3.1 / 6.6 映射为 HarmonyOS 产品版本**。插件应固定已支持的原件,或采用明确的兼容性候选顺序;不能声称手机必须运行“鸿蒙 6.6”。
|
||||
|
||||
官方会覆盖 `/data/local/tmp/libscreen_recorder.z.so`,以进程命令和 `/proc/net/unix` 检查启动是否成功。产品应为自己的扩展文件与进程记录所有权,不复用或结束别的 UI 调试会话。Unix socket 名在原件中固定,因此冲突时应明确报告,而不是杀掉所有 `uitest` 进程。
|
||||
|
||||
## 实时 H.264 的最小连接流程
|
||||
|
||||
这是根据官方启动脚本和原件参数解析重建的编码接口,未在本研究中对手机执行。`HDC`、`SERIAL`、`LOCAL_PORT`、`AGENT` 为客户端已校验的值;远端扩展文件名使用本插件自己的固定名称。
|
||||
|
||||
```sh
|
||||
"$HDC" -t "$SERIAL" file send "$AGENT" /data/local/tmp/libharmony_connector_screen.z.so
|
||||
"$HDC" -t "$SERIAL" fport "tcp:$LOCAL_PORT" localabstract:scrcpy_grpc_socket
|
||||
"$HDC" -t "$SERIAL" shell /system/bin/uitest start-daemon singleness \
|
||||
--extension-name libharmony_connector_screen.z.so \
|
||||
-m 0 -scale 2 -frameRate 60 -bitRate 8000000 -iFrameInterval 1000 -screenId 0
|
||||
```
|
||||
|
||||
随后连接主机端 `127.0.0.1:LOCAL_PORT`,以 HTTP/2 gRPC 调用 `/ScrcpyService/onStart`,请求为 `Empty`,持续读取 server stream。无需创建 Driver、申请开发者证书、安装 HAP 或另发初始化 RPC。控制连接独立使用 UITest agent,避免视频读循环被输入 RPC 阻塞。
|
||||
|
||||
`RpcServer::InitServer` 在最新 Unix 原件中的入口是 `0x1a960`:
|
||||
|
||||
- `0x1aa40–0x1aa74` 默认初始化 `scrcpy_grpc_socket`,只在 mode 等于 1 时跳转。
|
||||
- `0x1ada8–0x1adb8` 将 mode 1 的名字替换为 `screen_record_grpc_socket`。
|
||||
- 字符串地址 `0xe001` 是 `scrcpy_grpc_socket`,`0xe014` 是 `screen_record_grpc_socket`,`0xeeca` 是 `unix-abstract:`。
|
||||
- `0x1aaf0–0x1ab0c` 拼接 `unix-abstract:` 后调用 gRPC `AddListeningPort`。没有在这个路径构建 IP:port。
|
||||
|
||||
官方 Python 的 `-p 5001 -m 1` 是录文件场景。新版 Unix 参数日志将 port 标记为 `[deprecated]`;实时启动不必配置设备 TCP 端口。
|
||||
|
||||
设备端抽象 Unix socket 不在 WLAN IP 上监听;主机端仍需确保本地转发仅被期望的本机客户端访问。HDC 本身若被另行配置成网络调试,则不能仅因使用 `localabstract` 就宣称整个 HDC 会话来自 USB。
|
||||
|
||||
### 视频与控制同时运行
|
||||
|
||||
最简单的官方组合是 **两个独立 `singleness` daemon**,各加载一个扩展。`ExecuteExtension` 只读取第一个 `--extension-name` 并 `dlopen` 一个文件,没有一次加载两个代理的参数语义。[官方扩展加载源码](https://github.com/openharmony/testfwk_arkxtest/blob/b04e30c40cf266c9abfbd52933e43a0456eb99a2/uitest/addon/extension_executor.cpp#L353)
|
||||
|
||||
`server_main.cpp::StartDaemon` 在 `token == "singleness"` 时直接执行扩展,该分支不调用普通 peer 连接的 `InitAndConnectPeer`,也没有以 singleness 命名的全局互斥锁。这里的字符串不是“设备只能有一个 UITest 进程”的代码证据。[官方 daemon 源码](https://github.com/openharmony/testfwk_arkxtest/blob/b04e30c40cf266c9abfbd52933e43a0456eb99a2/uitest/server/server_main.cpp#L471)
|
||||
|
||||
官方 Hypium 26 的 `ohos/proxy/ui_proxy.py::start_uitest` 启动 `uitest start-daemon singleness`,默认加载 `/data/local/tmp/agent.so` 并转发 `uitest_socket`;`RecordAgent` 另外启动带 `--extension-name libscreen_recorder.z.so` 的 daemon。其 UI 进程查找还特意排除命令中带 `--extension-name` 的进程,以区分录屏。`devicetest/core/variables.py::_start_record_step_screen` 在测试步骤期间启动 recorder,说明官方设计就是 UI 自动化和录制并行,而不是两个功能择一。
|
||||
|
||||
因此插件应采用:
|
||||
|
||||
```text
|
||||
控制:uitest start-daemon singleness --extension-name <本插件的控制原件文件名>
|
||||
HDC localabstract:uitest_socket → 主机控制端口
|
||||
视频:uitest start-daemon singleness --extension-name <本插件的视频原件文件名> -m 0 ...
|
||||
HDC localabstract:scrcpy_grpc_socket → 主机视频端口
|
||||
```
|
||||
|
||||
产品自己的会话检查必须识别这两个进程同属一个会话;不能看到任何其他 `uitest` 进程就拒绝。固定 socket 被其他应用占用时仍属于真实冲突,应保持现有不抢占策略。上述是官方源码和分发包的并行设计证据,不额外声称已对所有商业设备系统实现完成兼容测试。
|
||||
|
||||
### 参数
|
||||
|
||||
下表来自最新 Unix 原件 `RpcServer::parse_params` (`0x19c70`) 的静态分支,不是面向第三方的长期 API 稳定性承诺。
|
||||
|
||||
| 参数 | 已确认规则 |
|
||||
| --- | --- |
|
||||
| `-m` | 1 为 MP4 录制;其他值归为 0,实时回传编码 buffer |
|
||||
| `-scale` | 整数 1–5;小于等于 0 回 1,大于 5 限为 5。不是 JPEG 接口的 0.5 浮点比例 |
|
||||
| `-frameRate` | 最新 Unix 6.6-20260629 接受 15、30、60;内部计算 `120 / value` 的虚拟屏刷新间隔。120 会回默认 60,不能沿用旧 recorder4 接受 120 的结论 |
|
||||
| `-bitRate` | 至少 1048576 bit/s,否则恢复默认 31457280;按负载选择码率,不要把最大值当低延迟默认 |
|
||||
| `-iFrameInterval` | 非负整数;非法值回 2000;需与编码器间隔单位契约一致 |
|
||||
| `-screenId` | 非负显示 ID,通常主屏为 0 |
|
||||
| `-repeatInterval` | 新参数,接受 8、16、33,其他值恢复 0;引用 `OH_MD_KEY_VIDEO_ENCODER_REPEAT_PREVIOUS_FRAME_AFTER` 和 `...MAX_COUNT`。它用于重复之前的编码帧,不代表多采到了新画面 |
|
||||
|
||||
原件也存在 appPid、windowsId 和其他内部参数。当前需求不需要绕开安全窗口,不能把这些参数用作获取受保护内容的手段。
|
||||
|
||||
## gRPC 消息
|
||||
|
||||
官方文件位于 `.../controllers/tools/recorder/proto/scrcpy_pb2.py` 和 `scrcpy_pb2_grpc.py`。26.0.0.500 的 `scrcpy_pb2.py` 与下载到的 6.1.0.210 版本内容完全相同。
|
||||
|
||||
```proto
|
||||
syntax = "proto3";
|
||||
|
||||
service ScrcpyService {
|
||||
rpc onStart(Empty) returns (stream ReplyMessage);
|
||||
rpc onEnd(Empty) returns (ReplyEndMessage);
|
||||
rpc onRequestIDRFrame(Empty) returns (ReplyEndMessage);
|
||||
}
|
||||
message Empty {}
|
||||
message ReplyEndMessage { int32 result = 1; }
|
||||
message ReplyMessage {
|
||||
string data = 1;
|
||||
int32 reply_type = 2;
|
||||
map<string, ParamValue> payload = 3;
|
||||
}
|
||||
message ParamValue {
|
||||
oneof values {
|
||||
int64 val_int = 1;
|
||||
double val_double = 2;
|
||||
string val_string = 3;
|
||||
bool val_bool = 4;
|
||||
bytes val_bytes = 5;
|
||||
float val_float = 6;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
没有 protobuf package 前缀。三个路径分别为 `/ScrcpyService/onStart`、`/ScrcpyService/onEnd`、`/ScrcpyService/onRequestIDRFrame`。
|
||||
|
||||
官方 recorder4 的 `ReplyVideoData` 和静态全局键初始化能确认:`payload["data"].val_bytes` 才是编码数据;`len`、`flags`、`pts` 使用 `val_int`。外层 string `data` 不是视频 bytes。最新 Unix 原件仍有同签名的 `ReplyVideoData` 和同一协议;客户端应验证字段类型、长度和包大小,不根据日志字符串找图像。
|
||||
|
||||
`reply_type` 在 proto 中是普通 int32,没有枚举定义。本研究不猜测完整枚举含义。`flags` 来自媒体输出 buffer 属性,应按编码配置、关键帧和 EOS 标志处理,而不是把所有 payload 都当普通视频帧。
|
||||
|
||||
旧 recorder4 的 `OnNeedOutputData` 在 mode 0 里取 `OH_AVMemory_GetAddr + offset`、长度和 flags,调用 `ReplyVideoData`;mode 1 才调用 `OH_AVMuxer_WriteSample`。它发送的 pts 来自输出时的 `system_clock.now()`,静态除数和 SDK chrono 单位指向毫秒,而不是直接复制编码器 `attr.pts`。**不要未经归一化将它当微秒或主机单调时钟;首版实时显示可采用立即显示,不等待设备绝对时间。** 最新原件实际时间语义仍应通过客户端观察校准,不在这里声称已测过。
|
||||
|
||||
停止时,保持 stream reader 存活,用同一 channel 发 `onEnd(Empty)`,然后释放流、关闭本插件通道并删除本插件 fport。官方 Python 将 `ReplyEndMessage.result` 用作录制帧数;不应把它一概当布尔成功码。`onRequestIDRFrame` 可以用于解码器重新同步;不要每帧请求 IDR。异常时只清理本插件拥有的进程。
|
||||
|
||||
官方 `rpc_manager.py` 建立 insecure gRPC channel,未设置应用层 token;其 5 秒 `onStart` 超时适合官方录制封装,不能直接复制为持续投屏生命周期。其最大消息大小 option 拼写为 `grpc_max_receive_message_length`,使用其他客户端时应按对应 gRPC 库的正确配置项设置大小上限。
|
||||
|
||||
## 为什么不采用旧 TCP recorder
|
||||
|
||||
已下载的官方 PyPI 6.1.0.210 资源只有 `libscrcpy_server1.z.so` 至 `4.z.so`。其中 recorder4 的 `InitServer` 使用固定 `0.0.0.0` 和 `grpc::InsecureServerCredentials()`;完整参数解析没有 host、listen address 或 Unix 参数。它不是仅绑定 USB 的服务,HDC 转发本身不会把设备上已经存在的 TCP listener 限制到 USB。
|
||||
|
||||
未找到说明该无鉴权 gRPC 端口必然被系统网络隔离的官方保证。不能依赖这种假设作为默认生产方案,也不能修改签名原件或安全策略。26 包的 Unix 版本已经给出更合适的连接机制。[官方 PyPI 分发原件](https://files.pythonhosted.org/packages/a2/b5/55bdfe4d669558331499848dbc68269506abb9b4a771e3524a65f9845127/xdevice_devicetest-6.1.0.210-py3-none-any.whl)
|
||||
|
||||
## 真正的按下、移动、抬起
|
||||
|
||||
在 26 官方包的 `uitest_agent_v1.2.3.so` 中,`OHOS::uitest::HandleGesture` (`0x28c34`) 直接确认了以下内层请求:
|
||||
|
||||
```json
|
||||
{"api":"touchDown","args":{"x":120,"y":360}}
|
||||
{"api":"touchMove","args":{"x":125,"y":370}}
|
||||
{"api":"touchUp","args":{"x":125,"y":370}}
|
||||
```
|
||||
|
||||
沿用现有 UITest RPC 外层 envelope 和 `method: "Gestures"`,不要把这些请求发往视频 gRPC 服务。
|
||||
|
||||
证据链:
|
||||
|
||||
- `0x28ca0` 取 `api`,确认是字符串;`0x28cc4` 取 `args`,确认是对象。
|
||||
- `0x28fd4–0x29028` 的内联字节比较包含 `touchDown` 与 `touchMove`;`0x28e88–0x28ec0` 检查 `touchUp`。
|
||||
- 三者汇入 `0x28ec4`,读取 `x` (`0xdee2`) 和 `y` (`0xdd4a`);均要求整数类型,然后调用 `EasyJSON::GetInteger`。
|
||||
- `0x29700–0x2977c` 将 down/move/up 分别映射为动作 1/2/3,经 `Modules::GetUiTestLowLevelFunctions()` 的触控函数注入。
|
||||
- 成功路径调用结果 callback,结果对象保持 `SetNull()`,不是截图或坐标返回值。错误路径包括 `Illegal gesture api` 和 `Illegal touch point coordination`。
|
||||
|
||||
**没有发现 touchCancel 分支,也不能依赖断开连接自动抬起。** 同原件的 `GestureHandleSessionDeathBroadcast` 指向 `0x1d8f4`,该入口只有一条 `ret`。客户端应持有自己按下的触点状态,在 mouse up、窗口失焦、取消和停止时尽力发送最后坐标的 touchUp,再关闭控制连接。只合并尚未发送的 move,不丢 down/up 或跨越它们重排。使用显示内容区域的坐标映射,扣除黑边并处理旋转,避免将窗口坐标直接当设备坐标。
|
||||
|
||||
该证据针对 1.2.3 原件;不凭版本相近就声称所有旧 agent 的行为完全一致。研究输出 `.../res/prototype/native/gestures-key.disasm` 保留了相关汇编。
|
||||
|
||||
## JPEG 路径的性能上限来自哪里
|
||||
|
||||
OpenHarmony 官方 UITest `screen_copy.cpp` 的生产循环先调用 `DisplayManager::GetScreenshot` 取得全屏 PixelMap,再比较像素是否变化,再缩放并 JPEG 编码。缩放函数持有共享 frameLock;JPEG 质量设置为 75。缩小 scale 能减少缩放后编码和传输成本,但不省掉原始全屏截图读回。源码的 50 ms sleep 位于屏幕关闭处理,不是正常推流固定 20 fps 限制。[官方源码固定版本](https://github.com/openharmony/testfwk_arkxtest/blob/b04e30c40cf266c9abfbd52933e43a0456eb99a2/uitest/addon/screen_copy.cpp)
|
||||
|
||||
因此,仅改浏览器刷新周期、缩图或提高标称 fps 无法把逐帧截图机制变为 scrcpy 的编码视频链路。这里的开源实现解释了 UITest 公开截图机制,不声称商业 Hypium agent 每个内部细节都与该源码相同。
|
||||
|
||||
官方 recorder 则引用 `OH_VideoEncoder_CreateByMime`、H.264 MIME、`OH_VideoEncoder_GetSurface` 和虚拟显示 Surface,输出已经编码的媒体 buffer;这才是和 scrcpy 对应的性能方向。实际流畅度仍受设备编码器、虚拟屏刷新、USB/HDC、主机解码与呈现共同影响。
|
||||
|
||||
## 对照 scrcpy 的实现原则
|
||||
|
||||
Genymobile 官方文档和源码中,Surface 进入 MediaCodec,视频由独立连接传输,控制由独立线程处理;默认立即显示解码后的帧,额外视频缓冲是显式选项。帧率随画面变化,`max-fps` 是上限而不是静止画面也必须重复达到的最低值。[scrcpy 开发文档](https://github.com/Genymobile/scrcpy/blob/19c1261d2e2cbf2b5e6a71a8b64cc1dd3ede06ac/doc/develop.md)、[视频选项](https://github.com/Genymobile/scrcpy/blob/19c1261d2e2cbf2b5e6a71a8b64cc1dd3ede06ac/doc/video.md)
|
||||
|
||||
其 `frame_buffer.c` 用 pending/tmp 交换,允许新解码帧覆盖未消费帧;先取得新引用,失败时保留旧帧。这适合用于修复画面闪烁和积压:维持同一个显示 surface,在下一帧确实可呈现时替换,不能先清空旧画面。[scrcpy 帧缓冲源码](https://github.com/Genymobile/scrcpy/blob/19c1261d2e2cbf2b5e6a71a8b64cc1dd3ede06ac/app/src/frame_buffer.c)
|
||||
|
||||
可移植到插件的设计建议:
|
||||
|
||||
1. HDC + gRPC reader 独立运行,及时取出编码包,不在同一循环更新 UI 或等待触控。
|
||||
2. H.264 按解码依赖顺序送解码器;不能像独立 JPEG 那样随意丢 P 帧。解码后只保留最新可显示帧;编码队列溢出时明确重同步并请求 IDR。
|
||||
3. 持久 AppKit 视频视图,避免每帧重建窗口、WebView 图像节点、object URL 或黑底占位。
|
||||
4. 配置变更/旋转后重建必要的格式描述,普通帧不重建渲染层。停止期间保留最后有效画面直到状态视图接管。
|
||||
5. 分开统计收到编码帧、成功解码帧、实际显示帧、丢帧和队列长度。静止桌面的低更新数不等于卡顿;不能用接收包数冒充显示 fps。
|
||||
|
||||
这些是从官方机制推导的实现建议,不是本研究的真机性能结果。
|
||||
|
||||
## macOS 呈现接口
|
||||
|
||||
`AVSampleBufferDisplayLayer` 能呈现压缩或未压缩视频。H.264 buffer 应正确组成 `CMSampleBuffer`,维护 SPS/PPS 和格式描述,交由系统视频解码/呈现;若使用 VideoToolbox 解码,可设置实时属性并检查实际是否使用硬件解码。[Apple 图层文档](https://developer.apple.com/documentation/avfoundation/avsamplebufferdisplaylayer)、[VideoToolbox 解压属性](https://developer.apple.com/documentation/videotoolbox/decompression-properties)
|
||||
|
||||
`kCMSampleAttachmentKey_DisplayImmediately = true` 应设置在 **sample attachments array**,用于不按时间戳等待的实时显示。不是对样本调用普通 `CMSetAttachment` 就等价。新样本到达后立即呈现,有助于避免设备绝对时间与主机时间基不一致产生的积压。[Apple DisplayImmediately](https://developer.apple.com/documentation/coremedia/kcmsampleattachmentkey_displayimmediately)、[Apple enqueue](https://developer.apple.com/documentation/avfoundation/avsamplebufferdisplaylayer/enqueue(_:))
|
||||
|
||||
macOS 14 及以上可使用该层的 sampleBufferRenderer,保留项目要求的旧系统兼容路径。`requestMediaDataWhenReady` 必须与停止请求成对管理,窗口销毁后不能继续向已释放视图派发渲染工作。
|
||||
|
||||
## 证据边界
|
||||
|
||||
- 已完成:官方 26 包获取、静态解包、资源摘要、启动/选型脚本、proto 对比、Unix 绑定与触控参数的汇编核查。
|
||||
- 未完成且未声称完成:最新 Unix 原件在用户手机的加载兼容性、连续视频解码结果、流畅度/耗电/延迟测量、旧 1.2.2 agent 的触控全量契约复核。
|
||||
- 不需要为采用本路径继续下载完整 IDE、创建 HAP 或申请开发者签名;用户的实际前置条件是可用 HDC 与设备调试授权。原件与系统 ABI 的兼容性仍需清楚报告,不能失败后偷偷切回慢截图却继续显示“实时视频”。
|
||||
+17
-2
@@ -2,7 +2,7 @@
|
||||
"schemaVersion": 1,
|
||||
"id": "trove-official",
|
||||
"name": "Trove 官方软件源",
|
||||
"generatedAt": "2026-09-10T08:42:44.362Z",
|
||||
"generatedAt": "2026-09-10T09:25:53.244Z",
|
||||
"curios": [
|
||||
{
|
||||
"id": "dev.trove.qa.host",
|
||||
@@ -198,8 +198,23 @@
|
||||
{
|
||||
"id": "dev.trove.harmony-connector",
|
||||
"name": "Harmony Connector",
|
||||
"description": "连接鸿蒙设备,收发完整文件夹、管理应用,查看手机画面并通过键鼠操作。",
|
||||
"description": "连接鸿蒙设备,收发完整文件夹、管理应用,在独立窗口中投屏并通过键鼠操作。",
|
||||
"versions": [
|
||||
{
|
||||
"version": "0.5.0",
|
||||
"engine": {
|
||||
"trove": ">=0.1.0 <1.0.0"
|
||||
},
|
||||
"releasedAt": "2026-09-10T09:25:53.244Z",
|
||||
"artifacts": [
|
||||
{
|
||||
"target": "aarch64-apple-darwin",
|
||||
"url": "packages/dev.trove.harmony-connector/0.5.0/aarch64-apple-darwin.curio",
|
||||
"sha256": "e92ae6269ad80906ee030dafba549913862e53696c1d508fa1b397da1e5ee038",
|
||||
"size": 4278225
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "0.4.0",
|
||||
"engine": {
|
||||
|
||||
Binary file not shown.
@@ -61,42 +61,7 @@ const entries = [
|
||||
modified: 1788998400,
|
||||
},
|
||||
];
|
||||
let screenId = 0,
|
||||
screenSequence = 0,
|
||||
screenControl = false;
|
||||
let lastInput = "等待操作";
|
||||
function screenFrame() {
|
||||
if (!attached) throw new Error("设备已断开(模拟)");
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = 600;
|
||||
canvas.height = 1200;
|
||||
const context = canvas.getContext("2d");
|
||||
const gradient = context.createLinearGradient(0, 0, 600, 1200);
|
||||
gradient.addColorStop(0, "#183958");
|
||||
gradient.addColorStop(1, "#3d1d52");
|
||||
context.fillStyle = gradient;
|
||||
context.fillRect(0, 0, 600, 1200);
|
||||
context.fillStyle = "#fff";
|
||||
context.textAlign = "center";
|
||||
context.font = "bold 70px sans-serif";
|
||||
context.fillText("Harmony", 300, 260);
|
||||
context.font = "26px sans-serif";
|
||||
context.fillText("投屏界面预览 · 模拟画面", 300, 320);
|
||||
context.fillStyle = "#e9dcff";
|
||||
context.fillText(lastInput, 300, 600, 550);
|
||||
context.fillText(`画面 ${++screenSequence}`, 300, 670);
|
||||
context.fillStyle = "#fff";
|
||||
context.fillRect(225, 1150, 150, 6);
|
||||
return {
|
||||
dataUrl: canvas.toDataURL("image/jpeg", 0.8),
|
||||
width: 600,
|
||||
height: 1200,
|
||||
geometry: 1,
|
||||
sequence: screenSequence,
|
||||
captureMs: 180,
|
||||
control: screenControl,
|
||||
};
|
||||
}
|
||||
let screenActive = false;
|
||||
window.__TROVE_CONTEXT__ = {
|
||||
curioId: "dev.trove.harmony-connector",
|
||||
version: "0.3.0",
|
||||
@@ -104,37 +69,9 @@ window.__TROVE_CONTEXT__ = {
|
||||
platform: "macos",
|
||||
};
|
||||
window.__TROVE_BRIDGE__ = createMockBridge({
|
||||
"@self.hdc.screenStart": () => {
|
||||
screenControl = false;
|
||||
return {
|
||||
session: ++screenId,
|
||||
control: false,
|
||||
inputCapabilities: [
|
||||
"click",
|
||||
"doubleClick",
|
||||
"longClick",
|
||||
"swipe",
|
||||
"drag",
|
||||
"text",
|
||||
"keyEvent",
|
||||
],
|
||||
};
|
||||
},
|
||||
"@self.hdc.screenFrame": screenFrame,
|
||||
"@self.hdc.screenStop": () => ({ stopped: true }),
|
||||
"@self.hdc.screenControl": ({ enabled }) => ({
|
||||
control: (screenControl = enabled),
|
||||
}),
|
||||
"@self.hdc.screenInput": ({ input }) => {
|
||||
lastInput =
|
||||
input.kind === "text"
|
||||
? input.text
|
||||
: input.kind === "key"
|
||||
? input.key
|
||||
: `${input.kind} · ${input.x}, ${input.y}`;
|
||||
return { executed: true };
|
||||
},
|
||||
"@self.hdc.screenSave": () => ({ path: "/ui-fixture/screen.jpg" }),
|
||||
"@self.hdc.screenWindowOpen": () => ({ active: (screenActive = true) }),
|
||||
"@self.hdc.screenWindowStop": () => ({ active: (screenActive = false) }),
|
||||
"@self.hdc.screenWindowStatus": () => ({ active: screenActive }),
|
||||
"@self.hdc.status": () => ({ busy: false, job: null }),
|
||||
"@self.hdc.tool": () => ({
|
||||
available: true,
|
||||
|
||||
+54
-194
@@ -53,13 +53,10 @@ async function app(t) {
|
||||
job: null,
|
||||
busy: false,
|
||||
monitorWait: null,
|
||||
screenSession: 0,
|
||||
screenSequence: 0,
|
||||
screenGeometry: 1,
|
||||
screenControl: false,
|
||||
screenFrameWait: null,
|
||||
screenFrameError: null,
|
||||
screenUnchanged: false,
|
||||
screenActive: false,
|
||||
screenTarget: "",
|
||||
screenError: null,
|
||||
screenOpenWait: null,
|
||||
openedUrls: [],
|
||||
selectedDirectory: "/fixture/folder",
|
||||
configureError: null,
|
||||
@@ -83,53 +80,15 @@ async function app(t) {
|
||||
};
|
||||
window.__TROVE_BRIDGE__ = createMockBridge(
|
||||
Object.fromEntries([
|
||||
handler("hdc.screenStart", () => ({
|
||||
session: ++model.screenSession,
|
||||
inputCapabilities: [
|
||||
"click",
|
||||
"doubleClick",
|
||||
"longClick",
|
||||
"swipe",
|
||||
"drag",
|
||||
"text",
|
||||
"keyEvent",
|
||||
],
|
||||
control: false,
|
||||
})),
|
||||
handler("hdc.screenStop", () => {
|
||||
model.screenControl = false;
|
||||
return { stopped: true };
|
||||
handler("hdc.screenWindowOpen", async ({target}) => {
|
||||
if (model.screenOpenWait) await model.screenOpenWait;
|
||||
if (model.screenError) throw model.screenError;
|
||||
model.screenActive = true;
|
||||
model.screenTarget = target;
|
||||
return { active: true, target };
|
||||
}),
|
||||
handler("hdc.screenControl", ({ enabled }) => ({
|
||||
control: (model.screenControl = enabled),
|
||||
})),
|
||||
handler("hdc.screenFrame", async () => {
|
||||
if (model.screenFrameWait) await model.screenFrameWait;
|
||||
if (model.screenFrameError) throw model.screenFrameError;
|
||||
if (model.screenUnchanged && model.screenSequence)
|
||||
return {
|
||||
unchanged: true,
|
||||
width: 1200,
|
||||
height: 2400,
|
||||
sequence: model.screenSequence,
|
||||
geometry: model.screenGeometry,
|
||||
control: model.screenControl,
|
||||
receivedFps: 0,
|
||||
mbps: 0,
|
||||
};
|
||||
return {
|
||||
dataUrl: "data:image/jpeg;base64,/9j/2Q==",
|
||||
width: 1200,
|
||||
height: 2400,
|
||||
receivedFps: 27,
|
||||
mbps: 3.2,
|
||||
sequence: ++model.screenSequence,
|
||||
geometry: model.screenGeometry,
|
||||
control: model.screenControl,
|
||||
};
|
||||
}),
|
||||
handler("hdc.screenInput", () => ({ executed: true })),
|
||||
handler("hdc.screenSave", () => ({ path: "/fixture/screen.jpg" })),
|
||||
handler("hdc.screenWindowStop", () => ({ active: (model.screenActive = false) })),
|
||||
handler("hdc.screenWindowStatus", () => ({ active: model.screenActive, target: model.screenTarget, error: model.screenError })),
|
||||
handler("hdc.status", () => ({ busy: false, job: null })),
|
||||
handler("hdc.tool", () => ({
|
||||
available: model.hdcAvailable,
|
||||
@@ -486,161 +445,62 @@ test("device disconnect clears app details and prevents stale target operations"
|
||||
assert.equal($("app-launch").disabled, true);
|
||||
});
|
||||
|
||||
test("screen viewing starts read-only, keeps refreshing and reports stream throughput", async (t) => {
|
||||
const { $, click, tick, model } = await app(t);
|
||||
test("screen opens a separate window with default pointer control", async (t) => {
|
||||
const { $, click, model } = await app(t);
|
||||
model.hdcAvailable = true;
|
||||
await click("tab-management");
|
||||
await click("screen-start");
|
||||
assert.equal(model.screenActive, true);
|
||||
assert.equal($("screen-start").textContent, "显示投屏窗口");
|
||||
assert.match($("screen-status").textContent, /默认鼠标控制/);
|
||||
assert.equal($("screen-stop").disabled, false);
|
||||
assert.ok(!model.calls.includes("hdc.screenFrame"));
|
||||
});
|
||||
|
||||
test("switching management tabs and hiding the page leaves the independent window open", async (t) => {
|
||||
const { click, model, tick } = await app(t);
|
||||
model.hdcAvailable = true;
|
||||
await click("tab-management");
|
||||
await click("screen-start");
|
||||
await click("tab-files");
|
||||
window.document.dispatchEvent(new window.Event("visibilitychange"));
|
||||
await tick();
|
||||
assert.equal(model.screenActive, true);
|
||||
assert.ok(!model.calls.includes("hdc.screenWindowStop"));
|
||||
});
|
||||
|
||||
test("explicitly stopping a window permits reconnecting without a USB replug", async (t) => {
|
||||
const { $, click, model } = await app(t);
|
||||
model.hdcAvailable = true;
|
||||
await click("tab-management");
|
||||
await click("screen-start");
|
||||
await click("screen-stop");
|
||||
assert.equal(model.screenActive, false);
|
||||
assert.equal($("screen-start").disabled, false);
|
||||
await click("screen-start");
|
||||
await tick();
|
||||
assert.equal($("screen-image").hidden, false);
|
||||
assert.match($("screen-status").textContent, /仅观看/);
|
||||
assert.match(
|
||||
$("screen-stats").textContent,
|
||||
/1200 × 2400.*接收 27 帧\/秒.*3.2 Mbps/,
|
||||
);
|
||||
assert.equal($("screen-control").checked, false);
|
||||
assert.equal($("app-install").disabled, true);
|
||||
const before = model.screenSequence;
|
||||
await tick();
|
||||
assert.ok(model.screenSequence > before);
|
||||
assert.ok(!model.calls.includes("hdc.screenInput"));
|
||||
assert.equal(model.screenActive, true);
|
||||
});
|
||||
|
||||
test("screen text and navigation require enabled control and use the displayed session", async (t) => {
|
||||
const { $, click, tick, model } = await app(t);
|
||||
test("closing the native window is reflected by the launcher", async (t) => {
|
||||
const { $, click, model, tick } = await app(t);
|
||||
model.hdcAvailable = true;
|
||||
await click("tab-management");
|
||||
await click("screen-start");
|
||||
model.screenActive = false;
|
||||
await tick();
|
||||
await click("screen-control");
|
||||
$("screen-text").value = "你好,daniel's phone";
|
||||
$("screen-text").dispatchEvent(new window.Event("input"));
|
||||
await click("screen-send-text");
|
||||
await tick();
|
||||
const sent = model.parameters.find(([name]) => name === "hdc.screenInput")[1];
|
||||
assert.deepEqual(sent, {
|
||||
session: 1,
|
||||
sequence: 1,
|
||||
geometry: 1,
|
||||
input: { kind: "text", text: "你好,daniel's phone" },
|
||||
});
|
||||
await click("screen-stop");
|
||||
assert.equal($("screen-image").hidden, true);
|
||||
assert.equal($("screen-image").hasAttribute("src"), false);
|
||||
assert.equal($("screen-text").disabled, true);
|
||||
assert.equal($("app-install").disabled, false);
|
||||
assert.equal($("screen-stop").disabled, true);
|
||||
assert.equal($("screen-start").textContent, "打开投屏窗口");
|
||||
});
|
||||
|
||||
test("an unchanged live screen preserves its image and does not count repeated frames", async (t) => {
|
||||
const { $, click, tick, model } = await app(t);
|
||||
test("a failed window start leaves the launcher available to retry", async (t) => {
|
||||
const { $, click, model } = await app(t);
|
||||
model.hdcAvailable = true;
|
||||
await click("tab-management");
|
||||
model.screenError = new Error("视频连接失败");
|
||||
await click("screen-start");
|
||||
await tick();
|
||||
await click("screen-control");
|
||||
const source = $("screen-image").src;
|
||||
const sequence = model.screenSequence;
|
||||
model.screenUnchanged = true;
|
||||
await tick();
|
||||
await tick();
|
||||
assert.equal($("screen-image").src, source);
|
||||
assert.equal($("screen-image").hidden, false);
|
||||
assert.equal(model.screenSequence, sequence);
|
||||
assert.equal($("screen-control").checked, true);
|
||||
assert.match($("screen-stats").textContent, /接收 0 帧\/秒/);
|
||||
assert.ok(!model.calls.includes("hdc.screenStop"));
|
||||
});
|
||||
|
||||
test("control does not wait behind a pending video frame", async (t) => {
|
||||
const { $, click, tick, model } = await app(t);
|
||||
model.hdcAvailable = true;
|
||||
await click("tab-management");
|
||||
assert.equal($("screen-start").disabled, false);
|
||||
assert.equal($("screen-stop").disabled, true);
|
||||
model.screenError = null;
|
||||
await click("screen-start");
|
||||
await tick();
|
||||
await click("screen-control");
|
||||
let release;
|
||||
model.screenFrameWait = new Promise((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
await tick();
|
||||
$("screen-inputs").querySelector('[data-screen-key="home"]').click();
|
||||
await settle();
|
||||
assert.ok(
|
||||
model.parameters.some(
|
||||
([name, params]) =>
|
||||
name === "hdc.screenInput" && params.input.key === "home",
|
||||
),
|
||||
);
|
||||
release();
|
||||
model.screenFrameWait = null;
|
||||
await settle();
|
||||
await click("screen-stop");
|
||||
});
|
||||
|
||||
test("stopping during a pending frame discards that response and permits a fresh screen session", async (t) => {
|
||||
const { $, click, tick, model } = await app(t);
|
||||
model.hdcAvailable = true;
|
||||
await click("tab-management");
|
||||
let release;
|
||||
model.screenFrameWait = new Promise((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
await click("screen-start");
|
||||
await tick();
|
||||
await click("screen-stop");
|
||||
assert.equal($("screen-image").hidden, true);
|
||||
assert.ok(model.calls.includes("hdc.screenStop"));
|
||||
release();
|
||||
model.screenFrameWait = null;
|
||||
await settle();
|
||||
assert.equal($("screen-image").hasAttribute("src"), false);
|
||||
await click("screen-start");
|
||||
await tick();
|
||||
assert.equal(model.screenSession, 2);
|
||||
assert.equal($("screen-image").hidden, false);
|
||||
});
|
||||
|
||||
test("capture failure clears the screen and queued input instead of showing an old live frame", async (t) => {
|
||||
const { $, click, tick, model } = await app(t);
|
||||
model.hdcAvailable = true;
|
||||
await click("tab-management");
|
||||
await click("screen-start");
|
||||
await tick();
|
||||
model.screenFrameError = new Error("Device disconnected");
|
||||
await tick();
|
||||
assert.equal($("screen-image").hidden, true);
|
||||
assert.match($("screen-status").textContent, /未开始/);
|
||||
assert.equal($("screen-control").checked, false);
|
||||
});
|
||||
|
||||
test("leaving management stops refresh and control; returning does not resume them", async (t) => {
|
||||
const { $, click, tick, model } = await app(t);
|
||||
model.hdcAvailable = true;
|
||||
await click("tab-management");
|
||||
await click("screen-start");
|
||||
await tick();
|
||||
await click("screen-control");
|
||||
await click("tab-files");
|
||||
const before = model.screenSequence;
|
||||
await tick();
|
||||
await click("tab-management");
|
||||
await tick();
|
||||
assert.equal(model.screenSequence, before);
|
||||
assert.equal($("screen-image").hidden, true);
|
||||
assert.equal($("screen-control").checked, false);
|
||||
});
|
||||
|
||||
test("rotation disables control and clears a gesture waiting to be sent", async (t) => {
|
||||
const { $, click, tick, model } = await app(t);
|
||||
model.hdcAvailable = true;
|
||||
await click("tab-management");
|
||||
await click("screen-start");
|
||||
await tick();
|
||||
await click("screen-control");
|
||||
model.screenGeometry++;
|
||||
model.screenControl = false;
|
||||
await tick();
|
||||
assert.equal($("screen-control").checked, false);
|
||||
assert.match($("screen-input-status").textContent, /方向已变化/);
|
||||
assert.equal(model.screenActive, true);
|
||||
});
|
||||
|
||||
+177
@@ -0,0 +1,177 @@
|
||||
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
Binary file not shown.
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"package": "xdevice-devicetest",
|
||||
"version": "26.0.0.500",
|
||||
"source": "https://developer.huawei.com/consumer/cn/deveco-testing/",
|
||||
"license": "Apache-2.0",
|
||||
"files": [
|
||||
{
|
||||
"file": "uitest_agent_v1.2.3.so",
|
||||
"member": "devicetest/res/prototype/native/uitest_agent_v1.2.3.so",
|
||||
"sha256": "5dfbaca7e274a85bece8f225148ceb759b94d7319b956b9a7ea40d37944e4a9b",
|
||||
"modified": false
|
||||
},
|
||||
{
|
||||
"file": "libscrcpy_server_unix_6.6-20260629.z.so",
|
||||
"member": "devicetest/res/recorder/libscrcpy_server_unix_6.6-20260629.z.so",
|
||||
"sha256": "b24231188ab0fffa84c2bd71cab6948c8ffad9d661c80301f32eaf580177ce8e",
|
||||
"modified": false
|
||||
}
|
||||
]
|
||||
}
|
||||
Binary file not shown.
Reference in New Issue
Block a user