feat(harmony): add native H.264 mirroring window with direct touch control

This commit is contained in:
2026-09-10 17:26:11 +08:00
parent 0a1cefc9fa
commit e53e5050af
33 changed files with 2151 additions and 911 deletions
+1 -1
View File
@@ -10,7 +10,7 @@ Trove 官方插件库。发现喜欢的 Curio,装进你的工作台,让常
### Harmony Connector
让设备,更近一步。连接 Mac 与鸿蒙手机,整夹收发照片与文档,随时掌握传输速度;管理应用、查看手机画面,用鼠标和键盘完成日常操作。
让设备,更近一步。连接 Mac 与鸿蒙手机,整夹收发照片与文档,随时掌握传输速度;管理应用,在独立窗口中投屏,用鼠标和键盘完成日常操作。
## 按你的习惯组合
+14 -1
View File
@@ -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 -2
View File
@@ -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
View File
@@ -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"
+6 -1
View File
@@ -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| {
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) {
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))
}) 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'):
+6 -88
View File
@@ -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 -1
View File
@@ -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. */ },
};
}
-526
View File
@@ -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 = "放大画面";
},
};
}
+123
View File
@@ -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
}
}
+9 -9
View File
@@ -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
+5 -1
View File
@@ -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 安装、编译或签名。代理由插件自动部署到本次会话的临时文件,停止时删除。
+229
View File
@@ -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
View File
@@ -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": {
+4 -67
View File
@@ -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
View File
@@ -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
View File
@@ -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
+20
View File
@@ -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.