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