diff --git a/.gitea/workflows/sync-release.yml b/.gitea/workflows/sync-release.yml new file mode 100644 index 0000000..cfa6836 --- /dev/null +++ b/.gitea/workflows/sync-release.yml @@ -0,0 +1,32 @@ +name: Register plugin release +on: + workflow_dispatch: + inputs: + repository: + description: Plugin repository (owner/name) + required: true + type: string + tag: + description: Published release tag + required: true + type: string +jobs: + register: + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: https://github.com/actions/checkout@v4 + - uses: https://github.com/actions/setup-node@v4 + with: + node-version: '22' + - run: corepack enable && corepack pnpm install --frozen-lockfile + - name: Register completed release + run: node tools/sync-release.mjs --publish + env: + GITEA_SERVER_URL: ${{ gitea.server_url }} + GITEA_TOKEN: ${{ secrets.PLUGIN_RELEASE_TOKEN }} + CATALOG_WRITE_TOKEN: ${{ secrets.GITEA_TOKEN }} + CATALOG_REPOSITORY: ${{ gitea.repository }} + RELEASE_REPOSITORY: ${{ inputs.repository }} + RELEASE_TAG: ${{ inputs.tag }} diff --git a/.gitignore b/.gitignore index e7c9ba2..b63ec40 100644 --- a/.gitignore +++ b/.gitignore @@ -1,17 +1,11 @@ node_modules/ dist/ -target/ -.trove/ .env .env.* *.curio -!/source/packages/**/*.curio .DS_Store -/curios/*/backend/ -/curios/harmony-connector/legal/sources/ -/curios/harmony-connector/legal/*-COPYING -/curios/harmony-connector/legal/build.mjs -/curios/harmony-connector/legal/rust/ -/curios/harmony-connector/legal/hypium/ -# Keep local credentials from the retired companion experiment private. -/curios/harmony-connector/companion/ +target/ +.trove/ +/curios/ +/shared/ +/vendor/ diff --git a/README.md b/README.md index a67efc2..984457e 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ Trove 官方插件库。发现喜欢的 Curio,装进你的工作台,让常 每个 Curio 专注一件事,用轻巧的独立工具承接日常操作,把更多空间留给手头的工作。 -### Harmony Connector +### [Harmony Connector](https://git.render.ink/trove-plugins/harmony-connector) 让设备,更近一步。连接 Mac 与鸿蒙手机,整夹收发照片与文档,随时掌握传输速度;管理应用,在独立窗口中投屏,用鼠标和键盘完成日常操作。 diff --git a/curios/.gitkeep b/curios/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/curios/harmony-connector/assets/icon.svg b/curios/harmony-connector/assets/icon.svg deleted file mode 100644 index 26a13f3..0000000 --- a/curios/harmony-connector/assets/icon.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/curios/harmony-connector/build.mjs b/curios/harmony-connector/build.mjs deleted file mode 100644 index 133b1fc..0000000 --- a/curios/harmony-connector/build.mjs +++ /dev/null @@ -1,262 +0,0 @@ -import { - readFile, - writeFile, - mkdir, - cp, - copyFile, - rm, - readdir, -} from "node:fs/promises"; -import { createHash } from "node:crypto"; -import { spawnSync } from "node:child_process"; -import { fileURLToPath } from "node:url"; -import path from "node:path"; - -const plugin = path.dirname(fileURLToPath(import.meta.url)); -const root = path.resolve(plugin, "../.."); -const build = path.join(plugin, "dist/native-deps"); -const prefix = path.join(build, "install"); -const sources = [ - { - name: "libusb-1.0.30", - archive: "libusb-1.0.30.tar.bz2", - sha256: "fea36f34f9156400209595e300840767ab1a385ede1dc7ee893015aea9c6dbaf", - options: [], - }, - { - name: "libmtp-1.1.23", - archive: "libmtp-1.1.23.tar.gz", - sha256: "74a2b6e8cb4a0304e95b995496ea3ac644c29371649b892b856e22f12a0bdeed", - options: ["--disable-mtpz"], - patch: "libmtp-close-timeout.patch", - }, -]; -function run(command, args, cwd = plugin, env = {}) { - const result = spawnSync(command, args, { - cwd, - stdio: "inherit", - env: { ...process.env, ...env }, - }); - if (result.error) throw result.error; - if (result.status !== 0) - throw new Error(`${command} exited with ${result.status}`); -} -if (process.platform !== "darwin") - throw new Error("Harmony Connector native builds require macOS."); -const env = { - MACOSX_DEPLOYMENT_TARGET: "13.0", - PKG_CONFIG_PATH: path.join(prefix, "lib/pkgconfig"), - HARMONY_NATIVE_PREFIX: prefix, -}; -await mkdir(build, { recursive: true }); -await mkdir(path.join(plugin, "legal/sources"), { recursive: true }); -for (const source of sources) { - const archive = path.join(root, "vendor/harmony-connector", source.archive); - const bytes = await readFile(archive); - if (createHash("sha256").update(bytes).digest("hex") !== source.sha256) - throw new Error(`Source checksum mismatch: ${source.archive}`); - const directory = path.join(build, source.name); - const stamp = path.join(directory, ".harmony-built"); - const patchFile = - source.patch && path.join(root, "vendor/harmony-connector", source.patch); - const patchBytes = patchFile ? await readFile(patchFile) : null; - const patchHash = patchBytes - ? createHash("sha256").update(patchBytes).digest("hex") - : ""; - const identity = `${process.arch}:macos13:${source.sha256}${patchHash ? ":" + patchHash : ""}`; - const previous = await readFile(stamp, "utf8").catch(() => ""); - if (previous !== identity) { - await rm(directory, { recursive: true, force: true }); - run("tar", ["-xf", archive, "-C", build]); - if (patchFile) run("patch", ["--batch", "-p1", "-i", patchFile], directory); - run( - "./configure", - [ - `--prefix=${prefix}`, - "--disable-static", - "--enable-shared", - "--disable-dependency-tracking", - ...source.options, - ], - directory, - env, - ); - run("make", ["-j4"], directory, env); - run("make", ["install"], directory, env); - await writeFile(stamp, identity); - } - await copyFile( - path.join(directory, "COPYING"), - path.join(plugin, "legal", `${source.name}-COPYING`), - ); - await copyFile(archive, path.join(plugin, "legal/sources", source.archive)); - if (patchFile) - await copyFile(patchFile, path.join(plugin, "legal/sources", source.patch)); -} -if (process.argv.includes("--dependencies-only")) process.exit(0); -const agentDirectory = path.join(root, "vendor/harmony-connector/hypium"); -const agentProvenance = JSON.parse( - await readFile(path.join(agentDirectory, "provenance.json"), "utf8"), -); -const agentBytes = await readFile( - path.join(agentDirectory, "uitest_agent_v1.2.2.so"), -); -if ( - createHash("sha256").update(agentBytes).digest("hex") !== - agentProvenance.sha256 -) - throw new Error("Hypium device agent checksum mismatch"); -await mkdir(path.join(plugin, "legal/hypium"), { recursive: true }); -for (const name of ["LICENSE", "provenance.json"]) - await copyFile( - path.join(agentDirectory, name), - path.join(plugin, "legal/hypium", name), - ); -const latestAgents = JSON.parse(await readFile(path.join(agentDirectory, "provenance-26.json"), "utf8")); -for (const entry of latestAgents.files) { - const bytes = await readFile(path.join(agentDirectory, entry.file)); - if (createHash("sha256").update(bytes).digest("hex") !== entry.sha256) - throw new Error(`Official device component checksum mismatch: ${entry.file}`); -} -for (const name of ["LICENSE-26", "provenance-26.json"]) - await copyFile(path.join(agentDirectory, name), path.join(plugin, "legal/hypium", name)); -run( - "cargo", - ["build", "--release", "--locked", "--manifest-path", "native/Cargo.toml"], - plugin, - env, -); -const backend = path.join(plugin, "backend"); -await mkdir(path.join(backend, "lib"), { recursive: true }); -const executable = path.join(backend, "harmony-connector"); -await copyFile( - path.join(plugin, "native/target/release/harmony-connector"), - executable, -); -const viewer = path.join(backend, "harmony-screen"); -run("xcrun", ["swiftc", "-O", "-target", `${process.arch === "arm64" ? "arm64" : "x86_64"}-apple-macosx13.0`, - "-framework", "AppKit", "-framework", "QuartzCore", "-framework", "AVFoundation", "-framework", "VideoToolbox", - "viewer/Protocol.swift", "viewer/VideoDecoder.swift", "viewer/ScreenView.swift", "viewer/Main.swift", "-o", viewer]); -const libraries = ["libmtp.9.dylib", "libusb-1.0.0.dylib"]; -for (const library of libraries) - await copyFile( - path.join(prefix, "lib", library), - path.join(backend, "lib", library), - ); -for (const file of [ - executable, - viewer, - ...libraries.map((name) => path.join(backend, "lib", name)), -]) { - const isExecutable = file === executable || file === viewer; - const listing = spawnSync("otool", ["-L", file], { encoding: "utf8" }); - if (listing.status !== 0) - throw new Error("Cannot inspect Mach-O dependencies"); - for (const line of listing.stdout.split("\n").slice(1)) { - const dependency = line.trim().split(" (")[0]; - if (libraries.includes(path.basename(dependency)) && dependency !== file) { - run("install_name_tool", [ - "-change", - dependency, - `${isExecutable ? "@executable_path/lib" : "@loader_path"}/${path.basename(dependency)}`, - file, - ]); - } else if ( - dependency && - !dependency.startsWith("/System/") && - !dependency.startsWith("/usr/lib/") - ) { - throw new Error(`Unbundled native dependency: ${dependency}`); - } - } - if (!isExecutable) - run("install_name_tool", [ - "-id", - `@loader_path/${path.basename(file)}`, - file, - ]); - run("codesign", ["--force", "--sign", "-", file]); -} -const ui = path.join(plugin, "dist/ui"); -await rm(ui, { recursive: true, force: true }); -await cp(path.join(plugin, "ui"), ui, { - recursive: true, - filter: (source) => !source.endsWith(".test.js"), -}); -await cp( - path.dirname(fileURLToPath(import.meta.resolve("@trove/plugin-sdk"))), - path.join(ui, "sdk"), - { recursive: true }, -); -await copyFile( - path.join(plugin, "build.mjs"), - path.join(plugin, "legal", "build.mjs"), -); -const metadata = spawnSync( - "cargo", - [ - "metadata", - "--locked", - "--format-version", - "1", - "--filter-platform", - `${process.arch === "arm64" ? "aarch64" : "x86_64"}-apple-darwin`, - "--manifest-path", - "native/Cargo.toml", - ], - { cwd: plugin, encoding: "utf8", maxBuffer: 10 * 1024 * 1024 }, -); -if (metadata.status !== 0) - throw new Error("Cannot collect Rust dependency notices"); -const graph = JSON.parse(metadata.stdout); -const manifest = JSON.parse( - await readFile(path.join(plugin, "curio.json"), "utf8"), -); -if ( - graph.packages.find((pkg) => pkg.id === graph.resolve.root)?.version !== - manifest.version -) - throw new Error( - "curio.json and native/Cargo.toml must declare the same version", - ); -await rm(path.join(plugin, "legal/rust"), { recursive: true, force: true }); -for (const dependency of graph.packages.filter((pkg) => pkg.source)) { - const directory = path.dirname(dependency.manifest_path); - const destination = path.join( - plugin, - "legal/rust", - `${dependency.name}-${dependency.version}`, - ); - await mkdir(destination, { recursive: true }); - await writeFile( - path.join(destination, "package.json"), - JSON.stringify( - { - name: dependency.name, - version: dependency.version, - license: dependency.license, - repository: dependency.repository, - }, - null, - 2, - ) + "\n", - ); - let noticeCount = 0; - for (const entry of await readdir(directory, { withFileTypes: true })) { - if ( - entry.isFile() && - /^(license|licence|copying|copyright|notice)([.-]|$)/i.test(entry.name) - ) { - await copyFile( - path.join(directory, entry.name), - path.join(destination, entry.name), - ); - noticeCount++; - } - } - if (!noticeCount) - throw new Error(`Missing license notice: ${dependency.name}`); -} -console.log( - `Built Harmony Connector for ${process.arch}; USB libraries and their corresponding source are bundled.`, -); diff --git a/curios/harmony-connector/curio.json b/curios/harmony-connector/curio.json deleted file mode 100644 index 5bb59c1..0000000 --- a/curios/harmony-connector/curio.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "schemaVersion": 1, - "id": "dev.trove.harmony-connector", - "name": "Harmony Connector", - "description": "连接鸿蒙设备,收发完整文件夹、管理应用,在独立窗口中投屏并通过键鼠操作。", - "version": "0.5.2", - "engine": { "trove": ">=0.1.0 <1.0.0" }, - "icon": "assets/icon.svg", - "ui": { - "entry": "dist/ui/index.html", - "window": { "title": "Harmony Connector", "width": 1080, "height": 760, "minWidth": 760, "minHeight": 580, "resizable": true } - }, - "backend": { - "entry": "backend/harmony-connector", - "lifecycle": "on-demand", "transport": "unix", "startWithUi": true, - "startupTimeoutMs": 5000, "shutdownGraceMs": 5000, "idleTimeoutMs": 0 - }, - "services": { "requires": [{ "id": "host.dialog", "version": "^1.0.0" }, { "id": "host.runtime", "version": "^1.0.0" }] }, - "build": { "command": ["node", "build.mjs"] }, - "package": { "files": ["dist/ui", "backend", "assets", "legal"] } -} diff --git a/curios/harmony-connector/legal/THIRD-PARTY.md b/curios/harmony-connector/legal/THIRD-PARTY.md deleted file mode 100644 index 09bbd06..0000000 --- a/curios/harmony-connector/legal/THIRD-PARTY.md +++ /dev/null @@ -1,82 +0,0 @@ -# Harmony Connector — Third-party software - -Harmony Connector dynamically links the following libraries. This distribution -includes their original license texts, complete upstream source archives and -the patches applied to those sources. - -| Library | Version | Source | License text in this package | -| --- | --- | --- | --- | -| libmtp | 1.1.23 | https://github.com/libmtp/libmtp | `libmtp-1.1.23-COPYING` | -| libusb | 1.0.30 | https://github.com/libusb/libusb | `libusb-1.0.30-COPYING` | - -Copyright belongs to the authors identified in the corresponding source files. -libmtp's distributed COPYING is LGPL 2.1; individual source headers retain their -original version 2 or later notices. libusb is LGPL 2.1 or later. These notices -do not replace the original terms. The source archives are in `sources/`. - -The native executable also uses Rust crates listed in `rust/`, with the license, -copyright and notice files provided by each crate. Trove's UI SDK is distributed -from the versioned SDK archive in the source repository. - -The executable also embeds the unchanged `uitest_agent_v1.2.2.so` distributed in -`xdevice-devicetest` 6.1.0.210. Its package includes the Apache License 2.0; that -license and the original download URL, wheel member and SHA-256 checksums are in -`hypium/`. The binary is not modified or stripped. It is copied to a temporary -device file for an authorized HDC session and removed when that session closes. -The upstream wheel does not include this native agent's source; embedding this -binary does not make it independently rebuildable from this repository. - -The native viewer additionally uses the unmodified Huawei components distributed -in `xdevice-devicetest` 26.0.0.500 (Hypium 26): - -- `devicetest/res/prototype/native/uitest_agent_v1.2.3.so` for touch control. -- `devicetest/res/recorder/libscrcpy_server_unix_*.z.so` for H.264 video: - 6.6-20260629, 6.6-20260418, 6.5-20260313, 6.4-20260113 and 6.3.1-20260113. - These original official variants are tried in that order when a device cannot - start the previous variant; all use a device Unix socket. - -Their wheel license is preserved in `hypium/LICENSE-26`; file identities and -SHA-256 checksums are in `hypium/provenance-26.json`. Upstream copyright belongs -to Huawei Device Co., Ltd. and the authors identified in the official package. -These signed device binaries are neither modified nor stripped. The package -contains no separate NOTICE for them. This attribution is written by Trove; -it is not an upstream NOTICE. The macOS viewer is built from this repository's -Swift sources and uses the macOS AppKit, VideoToolbox and AVFoundation frameworks. - -## Building the distributed libraries - -The exact build commands and SHA-256 pins are in the accompanying `build.mjs`. -The source repository contains the same script at -`curios/harmony-connector/build.mjs` and archives under `vendor/harmony-connector/`. -Requires macOS, Xcode Command Line Tools, make and pkg-config. Libraries are -compiled with deployment target macOS 13.0, shared linkage, no static library, -and libmtp's MTPZ support disabled. `sources/libmtp-close-timeout.patch` bounds -CloseSession to two seconds without reducing file-transfer timeouts. The build -cache includes the patch hash; the existing release/reset/close sequence is retained. -Mach-O install names are adjusted after compilation; libraries are ad-hoc signed. - -From the source repository, run: - -```sh -node curios/harmony-connector/build.mjs --dependencies-only -``` - -The resulting libraries are under -`curios/harmony-connector/dist/native-deps/install/lib/`. - -## Replacing or modifying the libraries - -You may modify and replace these libraries with ABI-compatible versions. No -restriction is imposed on reverse engineering needed to debug such modifications. -Copy compatible libraries to the plugin's `backend/lib/`, retaining their names -`libmtp.9.dylib` and `libusb-1.0.0.dylib`. Use `install_name_tool` to set the library -IDs and inter-library references to `@loader_path/` as the build -script does. Ad-hoc sign each changed Mach-O using `codesign --force --sign -`. - -Use the source project and Trove's local development/install workflow for modified -packages. Change the plugin version before packing a modified release: published -source artifacts are immutable, and changing their checksum in place is not the -way to install a modified library. The executable has no library validation or -publisher signature restriction that prevents loading a compatible replacement. - -The LGPL libraries are provided without warranty, as detailed in their licenses. diff --git a/curios/harmony-connector/native/Cargo.lock b/curios/harmony-connector/native/Cargo.lock deleted file mode 100644 index 822bd88..0000000 --- a/curios/harmony-connector/native/Cargo.lock +++ /dev/null @@ -1,769 +0,0 @@ -# This file is automatically @generated by Cargo. -# It is not intended for manual editing. -version = 4 - -[[package]] -name = "adler2" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" - -[[package]] -name = "anyhow" -version = "1.0.104" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" - -[[package]] -name = "arbitrary" -version = "1.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" -dependencies = [ - "derive_arbitrary", -] - -[[package]] -name = "atomic-waker" -version = "1.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" - -[[package]] -name = "autocfg" -version = "1.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" - -[[package]] -name = "base64" -version = "0.22.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" - -[[package]] -name = "bitflags" -version = "2.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" - -[[package]] -name = "block-buffer" -version = "0.10.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" -dependencies = [ - "generic-array", -] - -[[package]] -name = "bumpalo" -version = "3.20.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" - -[[package]] -name = "bytemuck" -version = "1.25.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" - -[[package]] -name = "byteorder-lite" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" - -[[package]] -name = "bytes" -version = "1.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" - -[[package]] -name = "cc" -version = "1.4.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "005ec2760ca554fae18df7a11195552ec576cd665632a881bc011d5bb2fd4d80" -dependencies = [ - "find-msvc-tools", - "shlex", -] - -[[package]] -name = "cfg-if" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" - -[[package]] -name = "cpufeatures" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" -dependencies = [ - "libc", -] - -[[package]] -name = "crc32fast" -version = "1.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8498c871161e1742aaa9d52551b2d6ebdd4c3d45a3be423e3728f33b955be550" -dependencies = [ - "cfg-if", -] - -[[package]] -name = "crypto-common" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" -dependencies = [ - "generic-array", - "typenum", -] - -[[package]] -name = "derive_arbitrary" -version = "1.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "digest" -version = "0.10.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" -dependencies = [ - "block-buffer", - "crypto-common", -] - -[[package]] -name = "either" -version = "1.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" - -[[package]] -name = "equivalent" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" - -[[package]] -name = "errno" -version = "0.3.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" -dependencies = [ - "libc", - "windows-sys", -] - -[[package]] -name = "fastrand" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" - -[[package]] -name = "fdeflate" -version = "0.3.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" -dependencies = [ - "simd-adler32", -] - -[[package]] -name = "find-msvc-tools" -version = "0.1.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d" - -[[package]] -name = "flate2" -version = "1.1.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" -dependencies = [ - "crc32fast", - "miniz_oxide 0.9.1", - "zlib-rs", -] - -[[package]] -name = "fnv" -version = "1.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" - -[[package]] -name = "futures-core" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" - -[[package]] -name = "futures-sink" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" - -[[package]] -name = "generic-array" -version = "0.14.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" -dependencies = [ - "typenum", - "version_check", -] - -[[package]] -name = "getrandom" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" -dependencies = [ - "cfg-if", - "libc", - "r-efi", -] - -[[package]] -name = "h2" -version = "0.4.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16" -dependencies = [ - "atomic-waker", - "bytes", - "fnv", - "futures-core", - "futures-sink", - "http", - "indexmap", - "slab", - "tokio", - "tokio-util", - "tracing", -] - -[[package]] -name = "harmony-connector" -version = "0.5.2" -dependencies = [ - "anyhow", - "base64", - "bytes", - "cc", - "h2", - "http", - "image", - "libc", - "png", - "prost", - "serde", - "serde_json", - "sha2", - "tempfile", - "tokio", - "zip", -] - -[[package]] -name = "hashbrown" -version = "0.17.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" - -[[package]] -name = "http" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" -dependencies = [ - "bytes", - "itoa", -] - -[[package]] -name = "image" -version = "0.25.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" -dependencies = [ - "bytemuck", - "byteorder-lite", - "moxcms", - "num-traits", - "png", - "zune-core", - "zune-jpeg", -] - -[[package]] -name = "indexmap" -version = "2.14.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" -dependencies = [ - "equivalent", - "hashbrown", -] - -[[package]] -name = "itertools" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" -dependencies = [ - "either", -] - -[[package]] -name = "itoa" -version = "1.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" - -[[package]] -name = "libc" -version = "0.2.189" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" - -[[package]] -name = "linux-raw-sys" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" - -[[package]] -name = "log" -version = "0.4.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" - -[[package]] -name = "memchr" -version = "2.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" - -[[package]] -name = "miniz_oxide" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" -dependencies = [ - "adler2", - "simd-adler32", -] - -[[package]] -name = "miniz_oxide" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" -dependencies = [ - "adler2", - "simd-adler32", -] - -[[package]] -name = "mio" -version = "1.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" -dependencies = [ - "libc", - "wasi", - "windows-sys", -] - -[[package]] -name = "moxcms" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" -dependencies = [ - "num-traits", - "pxfm", -] - -[[package]] -name = "num-traits" -version = "0.2.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" -dependencies = [ - "autocfg", -] - -[[package]] -name = "once_cell" -version = "1.21.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" - -[[package]] -name = "pin-project-lite" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" - -[[package]] -name = "png" -version = "0.18.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" -dependencies = [ - "bitflags", - "crc32fast", - "fdeflate", - "flate2", - "miniz_oxide 0.8.9", -] - -[[package]] -name = "proc-macro2" -version = "1.0.107" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "prost" -version = "0.14.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "528ac67416ff8646872a3c02cad9cc4ee5dc9f9540c9b10771855c95cb2e5ae1" -dependencies = [ - "bytes", - "prost-derive", -] - -[[package]] -name = "prost-derive" -version = "0.14.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf" -dependencies = [ - "anyhow", - "itertools", - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "pxfm" -version = "0.1.30" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" - -[[package]] -name = "quote" -version = "1.0.47" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" -dependencies = [ - "proc-macro2", -] - -[[package]] -name = "r-efi" -version = "6.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" - -[[package]] -name = "rustix" -version = "1.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" -dependencies = [ - "bitflags", - "errno", - "libc", - "linux-raw-sys", - "windows-sys", -] - -[[package]] -name = "serde" -version = "1.0.229" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" -dependencies = [ - "serde_core", - "serde_derive", -] - -[[package]] -name = "serde_core" -version = "1.0.229" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" -dependencies = [ - "serde_derive", -] - -[[package]] -name = "serde_derive" -version = "1.0.229" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.5", -] - -[[package]] -name = "serde_json" -version = "1.0.151" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" -dependencies = [ - "itoa", - "memchr", - "serde", - "serde_core", - "zmij", -] - -[[package]] -name = "sha2" -version = "0.10.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" -dependencies = [ - "cfg-if", - "cpufeatures", - "digest", -] - -[[package]] -name = "shlex" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" - -[[package]] -name = "simd-adler32" -version = "0.3.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" - -[[package]] -name = "slab" -version = "0.4.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" - -[[package]] -name = "socket2" -version = "0.6.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" -dependencies = [ - "libc", - "windows-sys", -] - -[[package]] -name = "syn" -version = "2.0.119" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "syn" -version = "3.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "tempfile" -version = "3.27.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" -dependencies = [ - "fastrand", - "getrandom", - "once_cell", - "rustix", - "windows-sys", -] - -[[package]] -name = "tokio" -version = "1.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" -dependencies = [ - "bytes", - "libc", - "mio", - "pin-project-lite", - "socket2", - "tokio-macros", - "windows-sys", -] - -[[package]] -name = "tokio-macros" -version = "2.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.5", -] - -[[package]] -name = "tokio-util" -version = "0.7.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" -dependencies = [ - "bytes", - "futures-core", - "futures-sink", - "libc", - "pin-project-lite", - "tokio", -] - -[[package]] -name = "tracing" -version = "0.1.44" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" -dependencies = [ - "pin-project-lite", - "tracing-core", -] - -[[package]] -name = "tracing-core" -version = "0.1.36" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" -dependencies = [ - "once_cell", -] - -[[package]] -name = "typenum" -version = "1.20.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" - -[[package]] -name = "unicode-ident" -version = "1.0.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" - -[[package]] -name = "version_check" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" - -[[package]] -name = "wasi" -version = "0.11.1+wasi-snapshot-preview1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" - -[[package]] -name = "windows-link" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" - -[[package]] -name = "windows-sys" -version = "0.61.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" -dependencies = [ - "windows-link", -] - -[[package]] -name = "zip" -version = "4.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1" -dependencies = [ - "arbitrary", - "crc32fast", - "flate2", - "indexmap", - "memchr", - "zopfli", -] - -[[package]] -name = "zlib-rs" -version = "0.6.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12" - -[[package]] -name = "zmij" -version = "1.0.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" - -[[package]] -name = "zopfli" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" -dependencies = [ - "bumpalo", - "crc32fast", - "log", - "simd-adler32", -] - -[[package]] -name = "zune-core" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d56377fd46368984a170bc5aac5567e52ca5da874caa60bea39fcbca78fb658b" - -[[package]] -name = "zune-jpeg" -version = "0.5.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" -dependencies = [ - "zune-core", -] diff --git a/curios/harmony-connector/native/Cargo.toml b/curios/harmony-connector/native/Cargo.toml deleted file mode 100644 index ba2c416..0000000 --- a/curios/harmony-connector/native/Cargo.toml +++ /dev/null @@ -1,25 +0,0 @@ -[package] -name = "harmony-connector" -version = "0.5.2" -edition = "2021" -publish = false - -[dependencies] -anyhow = "1" -serde = { version = "1", features = ["derive"] } -serde_json = "1" -tempfile = "3" -libc = "0.2" -sha2 = "0.10" -zip = { version = "4.6", default-features = false, features = ["deflate"] } -png = "0.18" -image = { version = "0.25", default-features = false, features = ["jpeg", "png"] } -base64 = "0.22" -bytes = "1" -h2 = "0.4" -http = "1" -prost = "0.14" -tokio = { version = "1", features = ["rt", "net", "time", "sync", "macros"] } - -[build-dependencies] -cc = "1" diff --git a/curios/harmony-connector/native/build.rs b/curios/harmony-connector/native/build.rs deleted file mode 100644 index ed72123..0000000 --- a/curios/harmony-connector/native/build.rs +++ /dev/null @@ -1,20 +0,0 @@ -use std::{env, path::PathBuf}; -fn main() { - let prefix = PathBuf::from( - env::var("HARMONY_NATIVE_PREFIX") - .expect("Build native dependencies with node ../build.mjs first"), - ); - cc::Build::new() - .file("src/mtp_bridge.c") - .include(prefix.join("include")) - .warnings(true) - .compile("harmony_mtp_bridge"); - println!( - "cargo:rustc-link-search=native={}", - prefix.join("lib").display() - ); - println!("cargo:rustc-link-lib=dylib=mtp"); - println!("cargo:rustc-link-lib=dylib=usb-1.0"); - println!("cargo:rerun-if-env-changed=HARMONY_NATIVE_PREFIX"); - println!("cargo:rerun-if-changed=src/mtp_bridge.c"); -} diff --git a/curios/harmony-connector/native/src/command.rs b/curios/harmony-connector/native/src/command.rs deleted file mode 100644 index 02d6fe4..0000000 --- a/curios/harmony-connector/native/src/command.rs +++ /dev/null @@ -1,186 +0,0 @@ -use crate::model::{Fault, Result}; -use std::{ - io::{ErrorKind, Read}, - os::{fd::AsRawFd, unix::process::CommandExt}, - path::Path, - process::{Command, Stdio}, - sync::atomic::{AtomicBool, Ordering}, - thread, - time::{Duration, Instant}, -}; - -fn nonblocking(stream: &impl AsRawFd) -> std::io::Result<()> { - let fd = stream.as_raw_fd(); - let flags = unsafe { libc::fcntl(fd, libc::F_GETFL) }; - if flags < 0 || unsafe { libc::fcntl(fd, libc::F_SETFL, flags | libc::O_NONBLOCK) } < 0 { - return Err(std::io::Error::last_os_error()); - } - Ok(()) -} -fn drain(stream: &mut impl Read, output: &mut Vec, limit: usize) -> Result { - let mut buffer = [0; 32 * 1024]; - loop { - match stream.read(&mut buffer) { - Ok(0) => return Ok(true), - Ok(size) => { - if output.len().saturating_add(size) > limit { - return Err(Fault::new( - "hdc.output_too_large", - "工具输出超过限制,操作已停止。", - )); - } - output.extend_from_slice(&buffer[..size]); - } - Err(e) if e.kind() == ErrorKind::WouldBlock => return Ok(false), - Err(e) if e.kind() == ErrorKind::Interrupted => continue, - Err(e) => return Err(e.into()), - } - } -} -pub fn run( - executable: &Path, - args: &[String], - timeout: Duration, - cancel: &AtomicBool, - limit: usize, -) -> Result { - if cancel.load(Ordering::Acquire) { - return Err(Fault::new("operation.cancelled", "操作已取消。")); - } - let mut child = Command::new(executable) - .args(args) - .stdin(Stdio::null()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .process_group(0) - .spawn() - .map_err(|e| { - Fault::new( - "hdc.tool_unavailable", - format!("无法运行工具:{e}。请保留官方工具旁的依赖文件。"), - ) - })?; - let result = (|| { - let mut out = child.stdout.take().unwrap(); - let mut err = child.stderr.take().unwrap(); - nonblocking(&out)?; - nonblocking(&err)?; - let (mut stdout, mut stderr) = (Vec::new(), Vec::new()); - let started = Instant::now(); - let status = loop { - if cancel.load(Ordering::Acquire) { - return Err(Fault::new( - "operation.cancelled", - "操作已取消;设备端已完成的更改不会自动撤销。", - )); - } - if started.elapsed() >= timeout { - return Err(Fault::new( - "hdc.timeout", - "设备操作超时,请检查手机授权和连接状态。", - )); - } - let out_done = drain(&mut out, &mut stdout, limit)?; - let err_done = drain(&mut err, &mut stderr, limit)?; - if let Some(status) = child.try_wait()? { - if out_done && err_done { - break status; - } - } - thread::sleep(Duration::from_millis(20)); - }; - let stdout = String::from_utf8_lossy(&stdout).into_owned(); - let stderr = String::from_utf8_lossy(&stderr); - if !status.success() { - return Err(Fault::new( - "hdc.command_failed", - format!("{}{}", stdout, stderr) - .chars() - .take(4000) - .collect::(), - )); - } - check_remote_errors(&stderr)?; - Ok(stdout) - })(); - if result.is_err() { - unsafe { - libc::kill(-(child.id() as i32), libc::SIGKILL); - } - let _ = child.wait(); - } - result -} -// HDC does not consistently propagate remote exit codes. Inspect structured command output, -// but do not interpret log contents as commands failing. -pub fn check_remote_errors(output: &str) -> Result<()> { - if output.lines().any(|line| { - let line = line.trim().to_lowercase(); - line.starts_with("[fail]") - || line.starts_with("error:") - || line.starts_with("failed ") - || line.starts_with("/bin/sh:") - || line.starts_with("sh:") - || line == "permission denied" - }) { - return Err(Fault::new( - "hdc.command_failed", - output.chars().take(4000).collect::(), - )); - } - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - use std::os::unix::fs::PermissionsExt; - fn script(text: &str) -> (tempfile::TempDir, std::path::PathBuf) { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("tool"); - std::fs::write(&path, format!("#!/bin/sh\n{text}\n")).unwrap(); - std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o700)).unwrap(); - (dir, path) - } - #[test] - fn bounds_output_timeout_and_remote_errors_without_a_phone() { - let cancel = AtomicBool::new(false); - let (_dir, path) = script("printf '[Fail] device unauthorized'; exit 0"); - assert_eq!( - check_remote_errors(&run(&path, &[], Duration::from_secs(1), &cancel, 1024).unwrap()) - .unwrap_err() - .code, - "hdc.command_failed" - ); - let (_dir, path) = script("sleep 10 & exit 0"); - assert_eq!( - run(&path, &[], Duration::from_millis(80), &cancel, 1024) - .unwrap_err() - .code, - "hdc.timeout" - ); - let (_dir, path) = script("while true; do printf '0123456789'; done"); - assert_eq!( - run(&path, &[], Duration::from_secs(5), &cancel, 32) - .unwrap_err() - .code, - "hdc.output_too_large" - ); - } - #[test] - fn passes_arguments_literally_without_a_host_shell() { - let (_dir, path) = script("printf '%s' \"$1\""); - let value = "a; echo surprise $(pwd)".to_string(); - assert_eq!( - run( - &path, - &[value.clone()], - Duration::from_secs(1), - &AtomicBool::new(false), - 1024 - ) - .unwrap(), - value - ); - } -} diff --git a/curios/harmony-connector/native/src/connector.rs b/curios/harmony-connector/native/src/connector.rs deleted file mode 100644 index 0c7cab4..0000000 --- a/curios/harmony-connector/native/src/connector.rs +++ /dev/null @@ -1,424 +0,0 @@ -use crate::{ - model::*, - mtp::{self, DeviceIo, NativeDevice, ROOT}, -}; -use serde::{de::DeserializeOwned, Deserialize}; -use serde_json::{json, Value}; -use std::{ - path::{Component, Path}, - sync::{ - atomic::{AtomicBool, AtomicU64, Ordering}, - Arc, Mutex, - }, - thread, -}; - -struct Gate(Arc); -impl Drop for Gate { - fn drop(&mut self) { - self.0.store(false, Ordering::Release); - } -} -struct Session { - device: Box, -} -fn close_in_background(session: Session, gate: Gate) { - thread::spawn(move || { - let _gate = gate; - drop(session); - }); -} -struct Job { - id: u64, - progress: Arc, - state: &'static str, - result: Option, - error: Option, -} -pub struct Connector { - management: Arc, - gate: Arc, - session: Mutex>, - connection: Mutex, - job: Mutex>, - next_id: AtomicU64, - stopped: AtomicBool, -} -#[derive(Deserialize)] -#[serde(deny_unknown_fields)] -struct Folder { - storage: u32, - parent: u32, - #[serde(default)] - offset: usize, -} -#[derive(Deserialize)] -#[serde(deny_unknown_fields)] -struct NewFolder { - storage: u32, - parent: u32, - name: String, -} -#[derive(Deserialize)] -#[serde(deny_unknown_fields)] -struct DeviceId { - id: String, -} -#[derive(Deserialize)] -#[serde(deny_unknown_fields)] -struct JobId { - id: u64, -} -fn params(value: Value) -> Result { - serde_json::from_value(value).map_err(|_| { - Fault::new( - "request.invalid_params", - "参数格式无效,请重新打开插件后重试。", - ) - }) -} -fn disconnected() -> Fault { - Fault::new("device.disconnected", "请先连接设备。") -} -pub fn valid_name(name: &str) -> Result<()> { - if name.is_empty() - || name.len() > 255 - || name.contains(['/', '\\', ':']) - || name.chars().any(char::is_control) - || !matches!( - Path::new(name).components().collect::>().as_slice(), - [Component::Normal(_)] - ) - { - return Err(Fault::new( - "file.invalid_name", - "文件名不可为空、超过 255 字节,或包含路径分隔符、冒号和控制字符。", - )); - } - Ok(()) -} -pub(crate) fn parent_exists(device: &mut dyn DeviceIo, storage: u32, parent: u32) -> Result<()> { - if !device.storages()?.iter().any(|s| s.id == storage) { - return Err(Fault::new( - "storage.not_found", - "存储位置已不可用,请重新连接设备。", - )); - } - if parent != ROOT { - let folder = device.metadata(parent)?; - if !folder.folder || folder.storage != storage { - return Err(Fault::new( - "folder.not_found", - "目标文件夹已不可用,请刷新列表。", - )); - } - } - Ok(()) -} -pub(crate) fn available_name( - device: &mut dyn DeviceIo, - storage: u32, - parent: u32, - name: &str, -) -> Result<()> { - // Case-insensitive comparison also protects stores backed by case-insensitive filesystems. - if device - .list(storage, parent)? - .iter() - .any(|entry| entry.name.to_lowercase() == name.to_lowercase()) - { - return Err(Fault::new( - "file.exists", - "目标文件夹已有同名文件。请改名或选择其他文件夹。", - )); - } - Ok(()) -} - -impl Connector { - pub fn new() -> Arc { - Arc::new(Self { - management: crate::hdc::Manager::new(), - gate: Arc::new(AtomicBool::new(false)), - session: Mutex::new(None), - connection: Mutex::new(Value::Null), - job: Mutex::new(None), - next_id: AtomicU64::new(1), - stopped: AtomicBool::new(false), - }) - } - fn enter(&self) -> Result { - if self.stopped.load(Ordering::Acquire) { - return Err(Fault::new("device.stopping", "连接正在关闭。")); - } - self.gate - .compare_exchange(false, true, Ordering::Acquire, Ordering::Relaxed) - .map_err(|_| Fault::new("device.busy", "设备正在处理上一项操作,请稍候。"))?; - Ok(Gate(self.gate.clone())) - } - fn detach(&self) -> Option { - *self.connection.lock().unwrap() = Value::Null; - self.session.lock().unwrap().take() - } - fn clear(&self) { - drop(self.detach()); - } - pub fn stop(&self) { - self.management.stop(); - self.stopped.store(true, Ordering::Release); - if let Some(job) = self.job.lock().unwrap().as_ref() { - job.progress.cancel.store(true, Ordering::Release); - } - if let Ok(_gate) = self - .gate - .compare_exchange(false, true, Ordering::Acquire, Ordering::Relaxed) - .map(|_| Gate(self.gate.clone())) - { - self.clear(); - } - } - pub fn busy(&self) -> bool { - self.gate.load(Ordering::Acquire) - } - pub fn call(self: &Arc, method: &str, value: Value) -> Result { - if let Some(method) = method.strip_prefix("hdc.") { - if self.stopped.load(Ordering::Acquire) { - return Err(Fault::new("device.stopping", "连接正在关闭。")); - } - return self.management.call(method, value); - } - match method { - "info" => { - return Ok( - json!({"curioId":"dev.trove.harmony-connector","version":env!("CARGO_PKG_VERSION"),"pid":std::process::id(),"transport":"mtp","libmtp":mtp::version(),"connection":self.connection.lock().unwrap().clone(),"busy":self.busy()}), - ) - } - "transferStatus" => return Ok(self.status()), - "monitor" => return self.monitor_devices(mtp::present, mtp::scan), - "cancelTransfer" => { - let id: JobId = params(value)?; - let jobs = self.job.lock().unwrap(); - let job = jobs - .as_ref() - .filter(|j| j.id == id.id) - .ok_or_else(|| Fault::new("transfer.not_found", "该传输已不存在。"))?; - if job.state == "running" { - job.progress.cancel.store(true, Ordering::Release); - } - return Ok(json!({"requested":job.state == "running"})); - } - _ => {} - } - let gate = self.enter()?; - if method == "disconnect" { - let session = self.detach(); - *self.job.lock().unwrap() = None; - let busy = session.is_some(); - if let Some(session) = session { - close_in_background(session, gate); - } - return Ok(json!({"disconnected":true,"busy":busy})); - } - if method == "transfer" { - return self.start(params(value)?, gate); - } - let result = self.operation(method, value); - if result - .as_ref() - .err() - .is_some_and(|e| e.code.starts_with("mtp.")) - { - self.clear(); - } - result - } - fn monitor_devices( - &self, - present: impl FnOnce(&DeviceInfo) -> Result, - scan: impl FnOnce() -> Result>, - ) -> Result { - let snapshot = self.connection.lock().unwrap().clone(); - if !snapshot.is_null() { - let info: DeviceInfo = serde_json::from_value(snapshot["device"].clone()) - .map_err(|_| Fault::new("device.invalid_state", "连接信息无效,请重新连接。"))?; - if !present(&info)? { - let mut current = self.connection.lock().unwrap(); - // A newer session must not be invalidated by an older USB snapshot. - if *current == snapshot { - *current = Value::Null; - if let Some(job) = self - .job - .lock() - .unwrap() - .as_ref() - .filter(|job| job.state == "running") - { - job.progress.disconnected.store(true, Ordering::Release); - job.progress.cancel.store(true, Ordering::Release); - } - } - } - } - let connection = self.connection.lock().unwrap().clone(); - if !connection.is_null() || self.busy() { - return Ok(json!({"connection":connection,"devices":[],"busy":self.busy()})); - } - let gate = match self.enter() { - Ok(gate) => gate, - Err(error) if error.code == "device.busy" => { - return Ok( - json!({"connection":self.connection.lock().unwrap().clone(),"devices":[],"busy":true}), - ) - } - Err(error) => return Err(error), - }; - // Recheck after acquiring the gate: connect may have completed between the two snapshots. - let connection = self.connection.lock().unwrap().clone(); - if !connection.is_null() { - return Ok(json!({"connection":connection,"devices":[],"busy":false})); - } - if let Some(session) = self.detach() { - close_in_background(session, gate); - return Ok(json!({"connection":null,"devices":[],"busy":true})); - } - Ok(json!({"connection":null,"devices":scan()?,"busy":false})) - } - fn operation(&self, method: &str, value: Value) -> Result { - if method == "devices" { - return Ok(json!({"devices":mtp::scan()?})); - } - if method == "connect" { - let id: DeviceId = params(value)?; - self.clear(); - let mut info = mtp::scan()? - .into_iter() - .find(|d| d.id == id.id) - .ok_or_else(|| Fault::new("device.not_found", "设备已断开,请重新扫描。"))?; - let mut device = NativeDevice::open(&info)?; - device.identify(&mut info); - let storages = device.storages()?; - if storages.is_empty() { - return Err(Fault::new( - "mtp.storage_unavailable", - "手机尚未开放存储。请解锁手机并选择“传输文件”。", - )); - } - let connection = json!({"device":info,"storages":storages,"generation":self.next_id.fetch_add(1, Ordering::Relaxed)}); - *self.session.lock().unwrap() = Some(Session { - device: Box::new(device), - }); - *self.connection.lock().unwrap() = connection.clone(); - return Ok(connection); - } - if !matches!(method, "list" | "mkdir") { - return Err(Fault::new( - "service.method_not_found", - "未知的 Harmony Connector 操作。", - )); - } - let mut session = self.session.lock().unwrap(); - let device = session.as_mut().ok_or_else(disconnected)?.device.as_mut(); - match method { - "list" => { - let folder: Folder = params(value)?; - parent_exists(device, folder.storage, folder.parent)?; - let mut entries = device.list(folder.storage, folder.parent)?; - entries.sort_by(|a, b| { - b.folder - .cmp(&a.folder) - .then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase())) - .then(a.id.cmp(&b.id)) - }); - let total = entries.len(); - let page: Vec<_> = entries.into_iter().skip(folder.offset).take(500).collect(); - Ok(json!({"entries":page,"total":total,"offset":folder.offset})) - } - "mkdir" => { - let folder: NewFolder = params(value)?; - valid_name(&folder.name)?; - parent_exists(device, folder.storage, folder.parent)?; - available_name(device, folder.storage, folder.parent, &folder.name)?; - Ok(json!({"id":device.mkdir(&folder.name, folder.storage, folder.parent)?})) - } - _ => unreachable!(), - } - } - fn start(self: &Arc, transfer: Transfer, gate: Gate) -> Result { - if self.session.lock().unwrap().is_none() { - return Err(disconnected()); - } - let id = self.next_id.fetch_add(1, Ordering::Relaxed); - let progress = Arc::new(Progress::default()); - *self.job.lock().unwrap() = Some(Job { - id, - progress: progress.clone(), - state: "running", - result: None, - error: None, - }); - let connector = self.clone(); - thread::spawn(move || { - let _gate = gate; - if connector.stopped.load(Ordering::Acquire) { - progress.cancel.store(true, Ordering::Release); - } - let mut result = { - let mut session = connector.session.lock().unwrap(); - crate::file_transfer::run( - session.as_mut().unwrap().device.as_mut(), - transfer, - &progress, - ) - }; - progress.finish(); - if progress.disconnected.load(Ordering::Acquire) && result.is_err() { - result = Err(Fault::new( - "device.disconnected", - "设备已断开,传输未完成。请重新连接后重试。", - )); - } - // Cancelled/failed USB transactions cannot safely reuse object handles or the session. - if progress.cancelled() - || connector.stopped.load(Ordering::Acquire) - || result - .as_ref() - .err() - .is_some_and(|e| e.code.starts_with("mtp.")) - { - connector.clear(); - } - // Release the operation gate before publishing completion so the next UI refresh can run. - drop(_gate); - let mut jobs = connector.job.lock().unwrap(); - if let Some(job) = jobs.as_mut().filter(|job| job.id == id) { - match result { - Ok(result) => { - job.state = "completed"; - job.result = Some(result); - } - Err(error) => { - job.state = if error.code == "transfer.cancelled" { - "cancelled" - } else { - "failed" - }; - job.error = Some(error); - } - } - } - }); - Ok(json!({"id":id})) - } - fn status(&self) -> Value { - self.job.lock().unwrap().as_ref().map(|job| json!({"id":job.id,"state":job.state,"sent":job.progress.sent.load(Ordering::Relaxed),"total":job.progress.total.load(Ordering::Relaxed),"cancelling":job.progress.cancelled(),"statistics":job.progress.statistics(),"result":job.result,"error":job.error})).unwrap_or(Value::Null) - } - #[cfg(test)] - pub fn with_device(device: Box) -> Arc { - let connector = Self::new(); - *connector.session.lock().unwrap() = Some(Session { device }); - connector - } -} - -#[cfg(test)] -mod tests; diff --git a/curios/harmony-connector/native/src/connector/tests.rs b/curios/harmony-connector/native/src/connector/tests.rs deleted file mode 100644 index 4074021..0000000 --- a/curios/harmony-connector/native/src/connector/tests.rs +++ /dev/null @@ -1,712 +0,0 @@ -use super::*; -use std::{ - fs::File, - io::{Read, Write}, - time::{Duration, Instant}, -}; - -#[derive(Default)] -struct Memory { - entries: Vec<(Entry, Vec)>, - fail: bool, - truncate: bool, - slow: bool, - collide: Option, - release: Option>, -} -struct Device(Arc>); -impl Drop for Device { - fn drop(&mut self) { - let release = self.0.lock().unwrap().release.take(); - if let Some(release) = release { - let _ = release.recv(); - } - } -} -fn connection_info() -> Value { - json!({"device":{"id":"7:3","bus":7,"address":3,"vendor":4817,"product":4353,"manufacturer":"HUAWEI","name":"daniel","model":"HUAWEI Pura 70 Ultra"},"storages":[{"id":1,"name":"内部存储","free":1000000,"capacity":2000000}],"generation":1}) -} -impl DeviceIo for Device { - fn storages(&mut self) -> Result> { - Ok(vec![Storage { - id: 1, - name: "内部存储".into(), - capacity: u64::MAX, - free: u64::MAX, - }]) - } - fn list(&mut self, storage: u32, parent: u32) -> Result> { - Ok(self - .0 - .lock() - .unwrap() - .entries - .iter() - .filter(|(e, _)| e.storage == storage && e.parent == parent) - .map(|(e, _)| e.clone()) - .collect()) - } - fn metadata(&mut self, id: u32) -> Result { - self.0 - .lock() - .unwrap() - .entries - .iter() - .find(|(e, _)| e.id == id) - .map(|(e, _)| e.clone()) - .ok_or_else(|| Fault::new("mtp.not_found", "gone")) - } - fn upload( - &mut self, - file: &File, - name: &str, - size: u64, - storage: u32, - parent: u32, - progress: &Progress, - ) -> Result { - let mut bytes = vec![]; - file.try_clone()?.read_to_end(&mut bytes)?; - progress.update(bytes.len() as u64, size); - let mut memory = self.0.lock().unwrap(); - let id = memory - .entries - .iter() - .map(|(e, _)| e.id) - .max() - .unwrap_or(0) - .max(999) - + 1; - memory.entries.push(( - Entry { - id, - parent, - storage, - name: name.into(), - size: bytes.len() as u64, - modified: 0, - folder: false, - }, - bytes, - )); - Ok(id) - } - fn download(&mut self, id: u32, file: &File, progress: &Progress) -> Result<()> { - let (bytes, fail, truncate, slow, collide) = { - let state = self.0.lock().unwrap(); - ( - state - .entries - .iter() - .find(|(e, _)| e.id == id) - .unwrap() - .1 - .clone(), - state.fail, - state.truncate, - state.slow, - state.collide.clone(), - ) - }; - let mut output = file.try_clone()?; - for (i, chunk) in bytes.chunks(1024).enumerate() { - if progress.cancelled() { - return Err(Fault::new("mtp.cancelled", "cancelled")); - } - output.write_all(chunk)?; - progress.update(((i + 1) * 1024).min(bytes.len()) as u64, bytes.len() as u64); - if fail { - return Err(Fault::new("mtp.disconnected", "USB detached")); - } - if truncate { - break; - } - if slow { - thread::sleep(Duration::from_millis(2)); - } - } - if let Some(path) = collide { - std::fs::write(path, b"existing file")?; - } - Ok(()) - } - fn mkdir(&mut self, name: &str, storage: u32, parent: u32) -> Result { - let mut memory = self.0.lock().unwrap(); - let id = memory - .entries - .iter() - .map(|(e, _)| e.id) - .max() - .unwrap_or(0) - .max(998) - + 1; - memory.entries.push(( - Entry { - id, - parent, - storage, - name: name.into(), - size: 0, - modified: 0, - folder: true, - }, - vec![], - )); - Ok(id) - } -} -fn setup(name: &str, bytes: &[u8]) -> (Arc, Arc>) { - let state = Arc::new(Mutex::new(Memory { - entries: vec![( - Entry { - id: 1, - parent: ROOT, - storage: 1, - name: name.into(), - size: bytes.len() as u64, - modified: 0, - folder: false, - }, - bytes.into(), - )], - ..Default::default() - })); - ( - Connector::with_device(Box::new(Device(state.clone()))), - state, - ) -} -fn wait(connector: &Arc) -> Value { - let deadline = Instant::now() + Duration::from_secs(5); - loop { - let status = connector.call("transferStatus", json!({})).unwrap(); - if status["state"] != "running" { - return status; - } - assert!(Instant::now() < deadline, "Transfer did not finish"); - thread::sleep(Duration::from_millis(2)); - } -} -fn download(connector: &Arc, directory: &Path) -> Value { - connector - .call( - "transfer", - json!({"direction":"download","object":1,"directory":directory}), - ) - .unwrap(); - wait(connector) -} -fn directory_names(directory: &Path) -> Vec { - std::fs::read_dir(directory) - .unwrap() - .map(|f| f.unwrap().file_name().to_string_lossy().into()) - .collect() -} -fn wait_idle(connector: &Connector) { - let deadline = Instant::now() + Duration::from_secs(1); - while connector.busy() { - assert!( - Instant::now() < deadline, - "USB cleanup did not release the operation gate" - ); - thread::yield_now(); - } -} - -#[test] -fn round_trip_binary_unicode_and_empty_files() { - for bytes in [vec![], (0..150_000).map(|i| (i % 251) as u8).collect()] { - let source = tempfile::tempdir().unwrap(); - let destination = tempfile::tempdir().unwrap(); - let name = "设计稿 🎨 final.bin"; - let file = source.path().join(name); - std::fs::write(&file, &bytes).unwrap(); - let (connector, memory) = setup("other", &[]); - connector - .call( - "transfer", - json!({"direction":"upload","path":file,"storage":1,"parent":ROOT}), - ) - .unwrap(); - assert_eq!(wait(&connector)["state"], "completed"); - assert_eq!(memory.lock().unwrap().entries.last().unwrap().1, bytes); - connector - .call( - "transfer", - json!({"direction":"download","object":1000,"directory":destination.path()}), - ) - .unwrap(); - let status = wait(&connector); - assert_eq!(status["state"], "completed"); - assert_eq!(status["sent"], bytes.len()); - assert_eq!(std::fs::read(destination.path().join(name)).unwrap(), bytes); - assert_eq!(directory_names(destination.path()), vec![name]); - } -} -#[test] -fn rejects_overwriting_existing_mac_file() { - let (connector, _) = setup("same.txt", b"replacement"); - let directory = tempfile::tempdir().unwrap(); - std::fs::write(directory.path().join("same.txt"), b"original").unwrap(); - assert_eq!( - download(&connector, directory.path())["error"]["code"], - "file.exists" - ); - assert_eq!( - std::fs::read(directory.path().join("same.txt")).unwrap(), - b"original" - ); -} -#[test] -fn protects_file_created_during_transfer() { - let (connector, memory) = setup("same.txt", b"replacement"); - let directory = tempfile::tempdir().unwrap(); - memory.lock().unwrap().collide = Some(directory.path().join("same.txt")); - assert_eq!( - download(&connector, directory.path())["error"]["code"], - "file.save_failed" - ); - assert_eq!( - std::fs::read(directory.path().join("same.txt")).unwrap(), - b"existing file" - ); - assert_eq!(directory_names(directory.path()), vec!["same.txt"]); -} -#[test] -fn refuses_device_path_traversal_and_control_characters() { - for name in [ - "../escape", - "/tmp/escape", - "..", - ".", - "a/b", - "a\\b", - "a:b", - "a\0b", - "a\nb", - "", - ] { - let (connector, _) = setup(name, b"data"); - let directory = tempfile::tempdir().unwrap(); - assert_eq!( - download(&connector, directory.path())["error"]["code"], - "file.invalid_name", - "{name:?}" - ); - assert!(directory_names(directory.path()).is_empty()); - } -} -#[test] -fn failure_and_short_download_remove_partial_and_disconnect() { - for truncate in [false, true] { - let (connector, memory) = setup("partial.bin", &[1; 4096]); - { - let mut state = memory.lock().unwrap(); - state.fail = !truncate; - state.truncate = truncate; - } - let directory = tempfile::tempdir().unwrap(); - assert_eq!(download(&connector, directory.path())["state"], "failed"); - assert!(directory_names(directory.path()).is_empty()); - assert_eq!( - connector - .call("list", json!({"storage":1,"parent":ROOT})) - .unwrap_err() - .code, - "device.disconnected" - ); - } -} -#[test] -fn cancellation_works_while_busy_and_cleans_partial_file() { - let (connector, memory) = setup("large.bin", &[3; 512 * 1024]); - memory.lock().unwrap().slow = true; - let directory = tempfile::tempdir().unwrap(); - let transfer = connector - .call( - "transfer", - json!({"direction":"download","object":1,"directory":directory.path()}), - ) - .unwrap(); - assert_eq!( - connector.call("disconnect", json!({})).unwrap_err().code, - "device.busy" - ); - assert_eq!( - connector - .call( - "transfer", - json!({"direction":"download","object":1,"directory":directory.path()}) - ) - .unwrap_err() - .code, - "device.busy" - ); - let deadline = Instant::now() + Duration::from_secs(2); - while connector.call("transferStatus", json!({})).unwrap()["sent"] - .as_u64() - .unwrap() - == 0 - { - assert!(Instant::now() < deadline); - thread::sleep(Duration::from_millis(2)); - } - connector.call("cancelTransfer", transfer).unwrap(); - assert_eq!(wait(&connector)["state"], "cancelled"); - assert!(directory_names(directory.path()).is_empty()); -} -#[test] -fn upload_collision_preserves_device_file() { - let (connector, memory) = setup("same.txt", b"original"); - let directory = tempfile::tempdir().unwrap(); - let path = directory.path().join("SAME.txt"); - std::fs::write(&path, b"replacement").unwrap(); - connector - .call( - "transfer", - json!({"direction":"upload","path":path,"storage":1,"parent":ROOT}), - ) - .unwrap(); - assert_eq!(wait(&connector)["error"]["code"], "file.exists"); - assert_eq!(memory.lock().unwrap().entries.len(), 1); - assert_eq!(memory.lock().unwrap().entries[0].1, b"original"); -} -#[test] -fn validates_storage_folder_and_request_shape() { - let (connector, _) = setup("file.txt", b"data"); - assert_eq!( - connector - .call("list", json!({"storage":2,"parent":ROOT})) - .unwrap_err() - .code, - "storage.not_found" - ); - assert_eq!( - connector - .call("mkdir", json!({"storage":1,"parent":1,"name":"folder"})) - .unwrap_err() - .code, - "folder.not_found" - ); - assert_eq!( - connector - .call( - "transfer", - json!({"direction":"download","object":1,"directory":"/tmp","unexpected":true}) - ) - .unwrap_err() - .code, - "request.invalid_params" - ); - assert_eq!( - connector.call("unknown", json!({})).unwrap_err().code, - "service.method_not_found" - ); -} -#[test] -fn creates_and_browses_folders_with_bounded_pages() { - let (connector, memory) = setup("z.txt", b"data"); - connector - .call("mkdir", json!({"storage":1,"parent":ROOT,"name":"资料"})) - .unwrap(); - for i in 0..600 { - memory.lock().unwrap().entries.push(( - Entry { - id: 2000 + i, - parent: ROOT, - storage: 1, - name: format!("{i:04}.txt"), - size: 0, - modified: 0, - folder: false, - }, - vec![], - )); - } - let first = connector - .call("list", json!({"storage":1,"parent":ROOT})) - .unwrap(); - assert_eq!(first["entries"][0]["name"], "资料"); - assert_eq!(first["entries"].as_array().unwrap().len(), 500); - assert_eq!(first["total"], 602); - assert_eq!( - connector - .call("list", json!({"storage":1,"parent":ROOT,"offset":500})) - .unwrap()["entries"] - .as_array() - .unwrap() - .len(), - 102 - ); -} -#[test] -fn shutdown_cancels_transfer_and_rejects_new_operations() { - let (connector, memory) = setup("large.bin", &[3; 512 * 1024]); - memory.lock().unwrap().slow = true; - let directory = tempfile::tempdir().unwrap(); - connector - .call( - "transfer", - json!({"direction":"download","object":1,"directory":directory.path()}), - ) - .unwrap(); - connector.stop(); - assert_eq!(wait(&connector)["state"], "cancelled"); - assert_eq!( - connector.call("devices", json!({})).unwrap_err().code, - "device.stopping" - ); - assert!(directory_names(directory.path()).is_empty()); -} - -#[test] -fn monitoring_an_attached_session_does_not_reopen_or_rescan_mtp() { - let (connector, _) = setup("file.txt", b"data"); - *connector.connection.lock().unwrap() = connection_info(); - let snapshot = connector - .monitor_devices( - |device| { - assert_eq!(device.name, "daniel"); - Ok(true) - }, - || panic!("A connected session must not be rescanned"), - ) - .unwrap(); - assert_eq!(snapshot["connection"]["device"]["name"], "daniel"); - assert_eq!(snapshot["busy"], false); -} - -#[test] -fn monitoring_unplug_clears_the_cached_session_and_stale_handles() { - let (connector, _) = setup("file.txt", b"data"); - *connector.connection.lock().unwrap() = connection_info(); - let snapshot = connector - .monitor_devices(|_| Ok(false), || Ok(vec![])) - .unwrap(); - assert!(snapshot["connection"].is_null()); - wait_idle(&connector); - assert_eq!( - connector - .call("list", json!({"storage":1,"parent":ROOT})) - .unwrap_err() - .code, - "device.disconnected" - ); -} - -#[test] -fn unplug_during_transfer_reports_disconnection_and_removes_partial_file() { - let (connector, memory) = setup("large.bin", &[3; 512 * 1024]); - *connector.connection.lock().unwrap() = connection_info(); - memory.lock().unwrap().slow = true; - let directory = tempfile::tempdir().unwrap(); - connector - .call( - "transfer", - json!({"direction":"download","object":1,"directory":directory.path()}), - ) - .unwrap(); - let snapshot = connector - .monitor_devices(|_| Ok(false), || Ok(vec![])) - .unwrap(); - assert!(snapshot["connection"].is_null()); - let status = wait(&connector); - assert_eq!(status["state"], "failed"); - assert_eq!(status["error"]["code"], "device.disconnected"); - assert!(directory_names(directory.path()).is_empty()); -} - -#[test] -fn usb_query_failure_does_not_claim_that_the_device_was_unplugged() { - let (connector, _) = setup("file.txt", b"data"); - *connector.connection.lock().unwrap() = connection_info(); - assert!(connector - .monitor_devices( - |_| Err(Fault::new("usb.discovery_failed", "unavailable")), - || Ok(vec![]) - ) - .is_err()); - assert_eq!( - connector.call("info", json!({})).unwrap()["connection"]["device"]["name"], - "daniel" - ); -} - -#[test] -fn old_presence_snapshot_cannot_invalidate_a_new_session() { - let (connector, _) = setup("file.txt", b"data"); - *connector.connection.lock().unwrap() = connection_info(); - let snapshot = connector - .monitor_devices( - |_| { - let mut new_session = connection_info(); - new_session["generation"] = json!(2); - *connector.connection.lock().unwrap() = new_session; - Ok(false) - }, - || panic!("New session must be retained"), - ) - .unwrap(); - assert_eq!(snapshot["connection"]["generation"], 2); -} - -#[test] -fn disconnect_returns_and_clears_state_before_slow_usb_release() { - let (connector, memory) = setup("file.txt", b"data"); - *connector.connection.lock().unwrap() = connection_info(); - let (release, wait_release) = std::sync::mpsc::channel(); - memory.lock().unwrap().release = Some(wait_release); - let (response, receive) = std::sync::mpsc::channel(); - let worker = { - let connector = connector.clone(); - thread::spawn(move || { - response - .send(connector.call("disconnect", json!({}))) - .unwrap() - }) - }; - let response = receive.recv_timeout(Duration::from_millis(200)); - let info = connector.call("info", json!({})).unwrap(); - let busy = connector.call("devices", json!({})).unwrap_err().code; - release.send(()).unwrap(); - worker.join().unwrap(); - assert!(response.is_ok(), "Disconnect waited for USB release"); - assert_eq!(response.unwrap().unwrap()["disconnected"], true); - assert!(info["connection"].is_null()); - assert_eq!(info["busy"], true); - assert_eq!(busy, "device.busy"); - wait_idle(&connector); -} - -#[test] -fn transfers_whole_folders_preserving_tree_binary_content_and_empty_directories() { - let source = tempfile::tempdir().unwrap(); - let selected = source.path().join("资料 🎨"); - std::fs::create_dir_all(selected.join("嵌套/空文件夹")).unwrap(); - std::fs::write(selected.join("empty.txt"), []).unwrap(); - let payload: Vec = (0..80_001).map(|i| (i % 251) as u8).collect(); - std::fs::write(selected.join("嵌套/binary.bin"), &payload).unwrap(); - let (connector, _) = setup("other", &[]); - connector - .call( - "transfer", - json!({"direction":"upload","path":selected,"storage":1,"parent":ROOT}), - ) - .unwrap(); - let uploaded = wait(&connector); - assert_eq!(uploaded["state"], "completed", "{uploaded}"); - assert_eq!(uploaded["statistics"]["filesCompleted"], 2); - assert_eq!(uploaded["sent"], payload.len()); - let destination = tempfile::tempdir().unwrap(); - connector.call("transfer", json!({"direction":"download","object":uploaded["result"]["object"],"directory":destination.path()})).unwrap(); - let downloaded = wait(&connector); - assert_eq!(downloaded["state"], "completed", "{downloaded}"); - let result = destination.path().join("资料 🎨"); - assert!(result.join("嵌套/空文件夹").is_dir()); - assert_eq!(std::fs::read(result.join("empty.txt")).unwrap(), b""); - assert_eq!( - std::fs::read(result.join("嵌套/binary.bin")).unwrap(), - payload - ); - assert_eq!(directory_names(destination.path()), ["资料 🎨"]); -} -#[test] -fn preflights_folder_upload_before_writing_and_never_follows_symlinks() { - let source = tempfile::tempdir().unwrap(); - let selected = source.path().join("folder"); - std::fs::create_dir(&selected).unwrap(); - std::fs::write(selected.join("a.txt"), b"data").unwrap(); - std::os::unix::fs::symlink(source.path(), selected.join("cycle")).unwrap(); - let (connector, memory) = setup("other", &[]); - connector - .call( - "transfer", - json!({"direction":"upload","path":selected,"storage":1,"parent":ROOT}), - ) - .unwrap(); - assert_eq!(wait(&connector)["error"]["code"], "file.not_regular"); - assert_eq!(memory.lock().unwrap().entries.len(), 1); -} -fn setup_folder() -> (Arc, Arc>) { - let (connector, memory) = setup("folder", &[]); - { - let mut state = memory.lock().unwrap(); - state.entries[0].0.folder = true; - state.entries.push(( - Entry { - id: 2, - parent: 1, - storage: 1, - name: "data.bin".into(), - size: 128 * 1024, - modified: 0, - folder: false, - }, - vec![7; 128 * 1024], - )); - } - (connector, memory) -} -#[test] -fn folder_download_failure_and_cancel_leave_no_published_or_partial_tree() { - for cancel in [false, true] { - let (connector, memory) = setup_folder(); - { - let mut m = memory.lock().unwrap(); - m.fail = !cancel; - m.slow = cancel; - } - let destination = tempfile::tempdir().unwrap(); - let job = connector - .call( - "transfer", - json!({"direction":"download","object":1,"directory":destination.path()}), - ) - .unwrap(); - if cancel { - connector.call("cancelTransfer", job).unwrap(); - } - assert_eq!( - wait(&connector)["state"], - if cancel { "cancelled" } else { "failed" } - ); - assert!(directory_names(destination.path()).is_empty()); - } -} -#[test] -fn folder_download_does_not_merge_existing_or_racing_destination() { - for racing in [false, true] { - let (connector, memory) = setup_folder(); - let destination = tempfile::tempdir().unwrap(); - let output = destination.path().join("folder"); - if racing { - memory.lock().unwrap().collide = Some(output.clone()); - } else { - std::fs::create_dir(&output).unwrap(); - std::fs::write(output.join("original"), b"keep").unwrap(); - } - assert_eq!(download(&connector, destination.path())["state"], "failed"); - assert_eq!(directory_names(destination.path()), ["folder"]); - if racing { - assert_eq!(std::fs::read(output).unwrap(), b"existing file"); - } else { - assert_eq!(directory_names(&output), ["original"]); - } - } -} -#[test] -fn rejects_invalid_remote_folder_tree_before_creating_local_content() { - for invalid_name in ["../escape", "folder"] { - let (connector, memory) = setup_folder(); - { - let mut state = memory.lock().unwrap(); - state.entries[1].0.name = invalid_name.into(); - if invalid_name == "folder" { - state.entries[1].0.id = 1; - } - } - let destination = tempfile::tempdir().unwrap(); - assert_eq!(download(&connector, destination.path())["state"], "failed"); - assert!(directory_names(destination.path()).is_empty()); - } -} diff --git a/curios/harmony-connector/native/src/diagnostics.rs b/curios/harmony-connector/native/src/diagnostics.rs deleted file mode 100644 index 6127d96..0000000 --- a/curios/harmony-connector/native/src/diagnostics.rs +++ /dev/null @@ -1,143 +0,0 @@ -use crate::mtp::{self, DeviceIo, NativeDevice, ROOT}; -use serde_json::json; -use std::time::{Duration, Instant}; - -// Explicitly invoked, read-only session exercise. Never run during a user's plugin session. -pub fn reconnect() -> anyhow::Result<()> { - for cycle in 1..=3 { - // A USB reset may temporarily remove the device while macOS re-enumerates it. - let discovery = Instant::now(); - let devices = loop { - let devices = mtp::scan()?; - if !devices.is_empty() || discovery.elapsed() >= Duration::from_secs(10) { - break devices; - } - std::thread::sleep(Duration::from_millis(250)); - }; - anyhow::ensure!( - devices.len() == 1, - "重连验收需要恰好一台可用的 USB 文件传输设备,当前发现 {} 台。", - devices.len() - ); - println!( - "{}", - json!({"cycle":cycle,"stage":"opening","device":devices[0].id,"discoverySeconds":discovery.elapsed().as_secs_f64()}) - ); - let opened = Instant::now(); - let mut device = NativeDevice::open(&devices[0])?; - let storages = device.storages()?; - anyhow::ensure!(!storages.is_empty(), "设备没有返回存储位置。"); - let mut entries = 0; - for storage in &storages { - entries += device.list(storage.id, ROOT)?.len(); - } - println!( - "{}", - json!({"cycle":cycle,"stage":"readable","storages":storages.len(),"rootEntries":entries,"seconds":opened.elapsed().as_secs_f64()}) - ); - println!("{}", json!({"cycle":cycle,"stage":"closing"})); - let closed = Instant::now(); - drop(device); - println!( - "{}", - json!({"cycle":cycle,"stage":"closed","seconds":closed.elapsed().as_secs_f64()}) - ); - std::thread::sleep(std::time::Duration::from_secs(1)); - } - println!("{}", json!({"passed":true,"cycles":3,"writes":false})); - Ok(()) -} - -// Uses the same management entry points as the UI. It never enables control or sends input. -pub fn screen(args: &[String], continuous: bool) -> anyhow::Result<()> { - anyhow::ensure!( - !args.is_empty() && args.len() <= 3, - "用法:--check-screen /absolute/path/to/hdc [设备 ID] [保存目录]" - ); - let manager = crate::hdc::Manager::new(); - let tool = manager.call("configure", json!({"path":args[0]}))?; - println!("{}", json!({"stage":"tool","version":tool["version"]})); - let devices = manager.call("devices", json!({}))?; - let connected: Vec<_> = devices["devices"] - .as_array() - .unwrap() - .iter() - .filter(|d| d["state"] == "Connected") - .collect(); - let target = if let Some(target) = args.get(1) { - target.clone() - } else { - anyhow::ensure!( - connected.len() == 1, - "需要恰好一台已授权 HDC 设备,或明确指定设备 ID。当前已连接 {} 台。", - connected.len() - ); - connected[0]["id"].as_str().unwrap().to_owned() - }; - let opened = manager.call("screenStart", json!({"target":target}))?; - let id = json!({"session":opened["session"]}); - println!( - "{}", - json!({"stage":"opened","mode":opened["mode"],"inputCapabilities":opened["inputCapabilities"],"control":false}) - ); - let started = Instant::now(); - let result = (|| -> anyhow::Result<()> { - let mut after = 0u64; - let mut observations = 0; - let mut unique = 0; - let mut decoded = 0; - let mut report_at = Instant::now(); - while if continuous { - started.elapsed() < Duration::from_secs(20) - } else { - observations < 3 - } { - let frame = manager.call( - "screenFrame", - json!({"session":opened["session"],"after":after}), - )?; - observations += 1; - if frame["unchanged"] != true { - use base64::Engine; - let url = frame["dataUrl"] - .as_str() - .ok_or_else(|| anyhow::anyhow!("Missing JPEG"))?; - let jpeg = base64::engine::general_purpose::STANDARD.decode( - url.strip_prefix("data:image/jpeg;base64,") - .ok_or_else(|| anyhow::anyhow!("Invalid JPEG URL"))?, - )?; - image::load_from_memory(&jpeg)?; - decoded += 1; - unique += 1; - } - after = frame["sequence"].as_u64().unwrap(); - if !continuous || report_at.elapsed() >= Duration::from_secs(1) { - println!( - "{}", - json!({"stage":"frame","sequence":after,"width":frame["width"],"height":frame["height"],"frameAgeMs":frame["frameAgeMs"],"receivedFps":frame["receivedFps"],"mbps":frame["mbps"],"uniqueFrames":unique}) - ); - report_at = Instant::now(); - } - } - if let Some(directory) = args.get(2) { - let saved = manager.call( - "screenSave", - json!({"session":opened["session"],"directory":directory}), - )?; - println!( - "{}", - json!({"stage":"saved","path":saved["path"],"bytes":saved["bytes"]}) - ); - } - println!( - "{}", - json!({"connectionPassed":decoded>0,"uniqueFrames":unique,"decodedFrames":decoded,"seconds":started.elapsed().as_secs_f64(),"consumerFps":if continuous {Some(unique as f64/started.elapsed().as_secs_f64())} else {None},"inputSent":false,"performanceRequiresMovingScreen":true}) - ); - Ok(()) - })(); - let stopped = manager.call("screenStop", id); - manager.stop(); - result?; - stopped?; - Ok(()) -} diff --git a/curios/harmony-connector/native/src/file_transfer.rs b/curios/harmony-connector/native/src/file_transfer.rs deleted file mode 100644 index 7b2e0fe..0000000 --- a/curios/harmony-connector/native/src/file_transfer.rs +++ /dev/null @@ -1,366 +0,0 @@ -use crate::connector::{available_name, parent_exists, valid_name}; -use crate::{model::*, mtp::DeviceIo}; -use serde_json::{json, Value}; -use std::{ - collections::{HashMap, HashSet}, - ffi::CString, - fs::{self, OpenOptions}, - os::{unix::ffi::OsStrExt, unix::fs::OpenOptionsExt}, - path::{Path, PathBuf}, -}; - -const MAX_ENTRIES: usize = 100_000; -const MAX_DEPTH: usize = 128; -struct Node { - relative: PathBuf, - folder: bool, - size: u64, - object: u32, -} -fn cancelled(progress: &Progress) -> Result<()> { - if progress.cancelled() { - Err(Fault::new( - "transfer.cancelled", - "传输已取消。手机可能保留已发送的文件,请检查后再重试。", - )) - } else { - Ok(()) - } -} -fn bound(nodes: &[Node], depth: usize) -> Result<()> { - if nodes.len() >= MAX_ENTRIES || depth > MAX_DEPTH { - return Err(Fault::new( - "folder.too_large", - "文件夹条目过多或层级过深,请分批传输。", - )); - } - Ok(()) -} -fn name(path: &Path) -> Result<&str> { - let name = path - .file_name() - .and_then(|s| s.to_str()) - .ok_or_else(|| Fault::new("file.invalid_name", "文件名不是有效的 UTF-8。"))?; - valid_name(name)?; - Ok(name) -} -fn local_plan( - root: &Path, - relative: &Path, - nodes: &mut Vec, - progress: &Progress, -) -> Result<()> { - cancelled(progress)?; - bound(nodes, relative.components().count())?; - let path = if relative.as_os_str().is_empty() { - root.to_path_buf() - } else { - root.join(relative) - }; - let metadata = path.symlink_metadata()?; - if !metadata.is_file() && !metadata.is_dir() { - return Err(Fault::new( - "file.not_regular", - "文件夹包含符号链接或特殊文件,请移除这些条目后再传输。", - )); - } - if !relative.as_os_str().is_empty() { - name(relative)?; - } - nodes.push(Node { - relative: relative.into(), - folder: metadata.is_dir(), - size: if metadata.is_file() { - metadata.len() - } else { - 0 - }, - object: 0, - }); - if metadata.is_dir() { - let mut children = fs::read_dir(path)?.collect::>>()?; - children.sort_by_key(|entry| entry.file_name()); - let mut names = HashSet::new(); - for child in children { - let relative = relative.join(child.file_name()); - if !names.insert(name(&relative)?.to_lowercase()) { - return Err(Fault::new( - "file.exists", - "文件夹中有仅大小写不同的同名条目,无法安全传输。", - )); - } - local_plan(root, &relative, nodes, progress)?; - } - } - Ok(()) -} -fn remote_plan( - device: &mut dyn DeviceIo, - entry: Entry, - relative: PathBuf, - nodes: &mut Vec, - seen: &mut HashSet, - progress: &Progress, -) -> Result<()> { - cancelled(progress)?; - bound(nodes, relative.components().count())?; - valid_name(&entry.name)?; - if !seen.insert(entry.id) { - return Err(Fault::new( - "folder.invalid_tree", - "设备返回了重复或循环的文件夹条目。", - )); - } - nodes.push(Node { - relative: relative.clone(), - folder: entry.folder, - size: if entry.folder { 0 } else { entry.size }, - object: entry.id, - }); - if entry.folder { - let mut names = HashSet::new(); - for child in device.list(entry.storage, entry.id)? { - valid_name(&child.name)?; - if child.parent != entry.id - || child.storage != entry.storage - || !names.insert(child.name.to_lowercase()) - { - return Err(Fault::new( - "folder.invalid_tree", - "设备返回了冲突或不属于当前目录的条目。", - )); - } - let path = relative.join(&child.name); - remote_plan(device, child, path, nodes, seen, progress)?; - } - } - Ok(()) -} -fn totals(nodes: &[Node]) -> Result<(u64, u64)> { - let total = nodes - .iter() - .try_fold(0u64, |sum, n| sum.checked_add(n.size)) - .ok_or_else(|| Fault::new("folder.too_large", "文件夹总大小超出支持范围。"))?; - Ok((total, nodes.iter().filter(|n| !n.folder).count() as u64)) -} - -pub fn run(device: &mut dyn DeviceIo, transfer: Transfer, progress: &Progress) -> Result { - cancelled(progress)?; - match transfer { - Transfer::Upload { - path, - storage, - parent, - } => upload(device, Path::new(&path), storage, parent, progress), - Transfer::Download { object, directory } => { - download(device, object, Path::new(&directory), progress) - } - } -} -fn upload( - device: &mut dyn DeviceIo, - selected: &Path, - storage: u32, - parent: u32, - progress: &Progress, -) -> Result { - if !selected.is_absolute() { - return Err(Fault::new( - "file.invalid_path", - "请选择 Mac 上的文件或文件夹。", - )); - } - let label = name(selected)?.to_owned(); - if selected.symlink_metadata()?.file_type().is_symlink() { - return Err(Fault::new( - "file.not_regular", - "请选择原始文件或文件夹,不支持符号链接。", - )); - } - let source = selected.canonicalize()?; - parent_exists(device, storage, parent)?; - available_name(device, storage, parent, &label)?; - let mut nodes = Vec::new(); - local_plan(&source, Path::new(""), &mut nodes, progress)?; - let (total, files) = totals(&nodes)?; - let capacity = device - .storages()? - .into_iter() - .find(|s| s.id == storage) - .ok_or_else(|| Fault::new("storage.not_found", "存储位置已不可用。"))?; - if capacity.free != u64::MAX && total > capacity.free { - return Err(Fault::new("storage.full", "手机剩余空间不足。")); - } - progress.prepare(total, files); - let mut folders = HashMap::new(); - let mut root_object = 0; - for node in &nodes { - cancelled(progress)?; - let is_root = node.relative.as_os_str().is_empty(); - let target_parent = if is_root { - parent - } else { - *folders - .get(node.relative.parent().unwrap()) - .ok_or_else(|| Fault::new("folder.invalid_tree", "无法定位目标文件夹。"))? - }; - let label = if is_root { - label.as_str() - } else { - name(&node.relative)? - }; - // Check each destination again immediately before mutation; never merge existing trees. - available_name(device, storage, target_parent, label)?; - let object = if node.folder { - let id = device.mkdir(label, storage, target_parent)?; - folders.insert(node.relative.clone(), id); - id - } else { - let path = if is_root { - source.clone() - } else { - source.join(&node.relative) - }; - let file = OpenOptions::new() - .read(true) - .custom_flags(libc::O_NOFOLLOW_ANY) - .open(path)?; - let metadata = file.metadata()?; - if !metadata.is_file() || metadata.len() != node.size { - return Err(Fault::new("file.changed", "源文件已改变,请重新开始传输。")); - } - progress.begin_file(&if is_root { - label.to_owned() - } else { - node.relative.display().to_string() - }); - let outcome = device.upload(&file, label, node.size, storage, target_parent, progress); - cancelled(progress)?; - let object = outcome?; - if device.metadata(object)?.size != node.size || file.metadata()?.len() != node.size { - return Err(Fault::new( - "mtp.size_mismatch", - "传输后文件大小不一致,请检查手机上的文件。", - )); - } - progress.update(node.size, node.size); - progress.complete_file(); - object - }; - if is_root { - root_object = object; - } - } - Ok( - json!({"object":root_object,"name":label,"bytes":total,"files":files,"folder":nodes[0].folder}), - ) -} -fn save_file( - device: &mut dyn DeviceIo, - node: &Node, - destination: &Path, - progress: &Progress, -) -> Result<()> { - cancelled(progress)?; - let temporary = tempfile::Builder::new() - .prefix(".harmony-") - .suffix(".part") - .tempfile_in(destination.parent().unwrap())?; - progress.begin_file(&node.relative.display().to_string()); - let outcome = device.download(node.object, temporary.as_file(), progress); - cancelled(progress)?; - outcome?; - if temporary.as_file().metadata()?.len() != node.size { - return Err(Fault::new( - "mtp.size_mismatch", - "接收到的文件不完整,请重新连接后重试。", - )); - } - temporary.as_file().sync_all()?; - temporary.persist_noclobber(destination).map_err(|e| { - Fault::new( - "file.save_failed", - format!("无法保存文件(不会覆盖已有文件):{}", e.error), - ) - })?; - progress.update(node.size, node.size); - progress.complete_file(); - Ok(()) -} -fn download( - device: &mut dyn DeviceIo, - object: u32, - selected: &Path, - progress: &Progress, -) -> Result { - if !selected.is_absolute() || !selected.is_dir() { - return Err(Fault::new( - "file.invalid_directory", - "请选择 Mac 上的接收文件夹。", - )); - } - let directory = selected.canonicalize()?; - let entry = device.metadata(object)?; - valid_name(&entry.name)?; - let destination = directory.join(&entry.name); - if destination.symlink_metadata().is_ok() { - return Err(Fault::new( - "file.exists", - "Mac 上已有同名条目。请选择其他接收文件夹。", - )); - } - let folder = entry.folder; - let label = entry.name.clone(); - let mut nodes = Vec::new(); - remote_plan( - device, - entry, - if folder { - PathBuf::new() - } else { - PathBuf::from(&label) - }, - &mut nodes, - &mut HashSet::new(), - progress, - )?; - let (total, files) = totals(&nodes)?; - progress.prepare(total, files); - if folder { - // Publish only a complete directory. Failure/cancellation drops this staging tree. - let staging = tempfile::Builder::new() - .prefix(".harmony-") - .suffix(".part") - .tempdir_in(&directory)?; - for node in nodes.iter().skip(1) { - cancelled(progress)?; - let path = staging.path().join(&node.relative); - if node.folder { - fs::create_dir(&path)?; - } else { - save_file(device, node, &path, progress)?; - } - } - cancelled(progress)?; - publish_directory(staging.path(), &destination)?; - } else { - save_file(device, &nodes[0], &destination, progress)?; - } - Ok(json!({"path":destination,"name":label,"bytes":total,"files":files,"folder":folder})) -} -pub fn publish_directory(source: &Path, destination: &Path) -> Result<()> { - let from = CString::new(source.as_os_str().as_bytes()) - .map_err(|_| Fault::new("file.invalid_path", "路径无效。"))?; - let to = CString::new(destination.as_os_str().as_bytes()) - .map_err(|_| Fault::new("file.invalid_path", "路径无效。"))?; - if unsafe { libc::renamex_np(from.as_ptr(), to.as_ptr(), libc::RENAME_EXCL) } != 0 { - return Err(Fault::new( - "file.save_failed", - format!( - "无法保存文件夹(不会覆盖已有条目):{}", - std::io::Error::last_os_error() - ), - )); - } - Ok(()) -} diff --git a/curios/harmony-connector/native/src/hdc.rs b/curios/harmony-connector/native/src/hdc.rs deleted file mode 100644 index 439da87..0000000 --- a/curios/harmony-connector/native/src/hdc.rs +++ /dev/null @@ -1,644 +0,0 @@ -mod files; -mod screen; -mod screen_agent; -mod screen_daemon; -mod screen_input; -mod screen_lease; -mod screen_protocol; -mod screen_recorder; -mod screen_video; -mod screen_window; -use crate::{ - command, hdc_data, - model::{Fault, Result}, -}; -use serde::Deserialize; -use serde_json::{json, Value}; -use std::{ - env, fs, - os::unix::fs::PermissionsExt, - path::{Path, PathBuf}, - sync::{ - atomic::{AtomicBool, AtomicU64, Ordering}, - Arc, Mutex, - }, - thread, - time::Duration, -}; - -struct Guard(Arc); -impl Drop for Guard { - fn drop(&mut self) { - self.0.store(false, Ordering::Release); - } -} -pub struct Manager { - screen: screen::Screen, - window: screen_window::Window, - path: Mutex>, - settings: Option, - busy: Arc, - job: Mutex, - cancel: Arc, - next: AtomicU64, - stopped: AtomicBool, -} -#[derive(Deserialize)] -#[serde(deny_unknown_fields)] -struct Target { - target: String, -} -#[derive(Deserialize)] -#[serde(deny_unknown_fields)] -struct Application { - target: String, - bundle: String, -} -#[derive(Deserialize)] -#[serde(deny_unknown_fields)] -struct LocalPath { - path: String, -} -#[derive(Clone, Deserialize)] -#[serde(tag = "kind", rename_all = "camelCase", deny_unknown_fields)] -enum Action { - Install { - target: String, - path: String, - sha256: String, - }, - Uninstall { - target: String, - bundle: String, - }, - Launch { - target: String, - bundle: String, - module: String, - ability: String, - }, - Stop { - target: String, - bundle: String, - }, - Screenshot { - target: String, - directory: String, - }, - Logs { - target: String, - directory: String, - }, - Report { - target: String, - directory: String, - }, - ExportDebug { - target: String, - bundle: String, - directory: String, - }, -} -fn parse(value: Value) -> Result { - serde_json::from_value(value) - .map_err(|_| Fault::new("request.invalid_params", "管理操作参数无效。")) -} -fn executable(path: &Path) -> bool { - path.is_absolute() - && path.is_file() - && path - .metadata() - .is_ok_and(|m| m.permissions().mode() & 0o111 != 0) -} -fn selected_tool(path: &Path) -> Option { - std::iter::once(path.to_path_buf()) - .chain( - [ - "hdc", - "toolchains/hdc", - "openharmony/toolchains/hdc", - "sdk/default/openharmony/toolchains/hdc", - "command-line-tools/sdk/default/openharmony/toolchains/hdc", - "Contents/sdk/default/openharmony/toolchains/hdc", - ] - .into_iter() - .map(|suffix| path.join(suffix)), - ) - .find(|p| executable(p)) - .and_then(|p| p.canonicalize().ok()) -} -fn tool_version(path: &Path, cancel: &AtomicBool) -> Result { - let version = command::run(path, &["-v".into()], Duration::from_secs(5), cancel, 8192)?; - if !version.contains("Ver:") && !version.to_lowercase().contains("hdc") { - return Err(Fault::new( - "hdc.invalid_tool", - "所选工具没有返回 HDC 版本信息。", - )); - } - Ok(version.trim().to_owned()) -} -fn installed_toolkits(home: &Path) -> Vec { - let root = home.join("Library/HarmonyConnector/tools"); - let mut versions: Vec<_> = fs::read_dir(&root) - .into_iter() - .flatten() - .flatten() - .filter(|entry| entry.path().is_dir()) - .filter_map(|entry| { - let version = entry - .file_name() - .to_str()? - .split('.') - .map(str::parse::) - .collect::, _>>() - .ok()?; - Some((version, entry.path())) - }) - .collect(); - versions.sort_by(|a, b| b.cmp(a)); - let mut candidates: Vec<_> = versions - .into_iter() - .filter_map(|(_, path)| selected_tool(&path)) - .collect(); - candidates.extend(selected_tool(&root)); - if !candidates.is_empty() { - return candidates; - } - // Only inspect named, extracted toolkits directly under Downloads. ZIPs - // and unfinished browser downloads are not executable installations. - let mut downloads: Vec<_> = fs::read_dir(home.join("Downloads")) - .into_iter() - .flatten() - .flatten() - .filter(|entry| { - let name = entry.file_name().to_string_lossy().to_ascii_lowercase(); - entry.path().is_dir() - && (name.starts_with("command-line-tools") || name.starts_with("commandline-tools")) - }) - .map(|entry| entry.path()) - .collect(); - downloads.sort(); - candidates.extend( - downloads - .into_iter() - .filter_map(|path| selected_tool(&path)), - ); - candidates -} -fn discover() -> Option { - let mut candidates = Vec::new(); - if let Some(path) = env::var_os("PATH") { - candidates.extend(env::split_paths(&path).map(|p| p.join("hdc"))); - } - if let Some(path) = candidates.iter().find(|p| executable(p)) { - return path.canonicalize().ok(); - } - if let Some(home) = env::var_os("HOME") { - candidates.extend(installed_toolkits(Path::new(&home))); - } - for root in [ - Some(PathBuf::from("/Applications")), - env::var_os("HOME").map(|p| PathBuf::from(p).join("Applications")), - ] - .into_iter() - .flatten() - { - if let Ok(entries) = fs::read_dir(root) { - for entry in entries.flatten().filter(|e| { - e.file_name() - .to_string_lossy() - .to_lowercase() - .contains("deveco") - }) { - candidates.push( - entry - .path() - .join("Contents/sdk/default/openharmony/toolchains/hdc"), - ); - } - } - } - candidates - .into_iter() - .find(|p| executable(p)) - .and_then(|p| p.canonicalize().ok()) -} -struct Tool { - path: PathBuf, - cancel: Arc, -} -impl Tool { - fn run(&self, args: &[&str], seconds: u64) -> Result { - let output = self.run_output(args, seconds)?; - command::check_remote_errors(&output)?; - Ok(output) - } - fn run_output(&self, args: &[&str], seconds: u64) -> Result { - command::run( - &self.path, - &args.iter().map(|s| (*s).into()).collect::>(), - Duration::from_secs(seconds), - &self.cancel, - 16 * 1024 * 1024, - ) - } - fn shell(&self, target: &str, command: &str) -> Result { - let output = self.run(&["-t", hdc_data::target(target)?, "shell", command], 15)?; - if output.len() > 2 * 1024 * 1024 { - return Err(Fault::new("hdc.output_too_large", "设备信息超过支持范围。")); - } - Ok(output) - } - fn targets(&self) -> Result> { - hdc_data::devices(&self.run(&["list", "targets", "-v"], 10)?) - } - fn connected(&self, target: &str) -> Result<()> { - hdc_data::target(target)?; - let device = self - .targets()? - .into_iter() - .find(|d| d["id"] == target) - .ok_or_else(|| Fault::new("hdc.device_missing", "管理设备已断开,请刷新列表。"))?; - if device["state"] == "Unauthorized" { - return Err(Fault::new( - "hdc.unauthorized", - "请解锁手机并允许这台 Mac 的 USB 调试。", - )); - } - if device["state"] != "Connected" { - return Err(Fault::new( - "hdc.device_offline", - "管理设备尚未就绪,请检查手机调试连接。", - )); - } - Ok(()) - } - fn apps(&self, target: &str) -> Result> { - hdc_data::apps(&self.shell(target, "bm dump -a")?) - } - fn app(&self, target: &str, bundle: &str) -> Result { - let bundle = hdc_data::identifier(bundle)?; - hdc_data::app( - &self.shell(target, &format!("bm dump -n {bundle}"))?, - bundle, - ) - } -} -impl Manager { - pub fn new() -> Arc { - let settings = - env::var_os("TROVE_DATA_DIR").map(|p| PathBuf::from(p).join("harmony-tools.json")); - let path = settings - .as_ref() - .and_then(|p| fs::read(p).ok()) - .and_then(|b| serde_json::from_slice::(&b).ok()) - .and_then(|v| v["hdcPath"].as_str().map(PathBuf::from)) - .filter(|p| executable(p)) - .or_else(discover); - Arc::new(Self { - screen: screen::Screen::default(), - window: screen_window::Window::default(), - path: Mutex::new(path), - settings, - busy: Arc::new(AtomicBool::new(false)), - job: Mutex::new(Value::Null), - cancel: Arc::new(AtomicBool::new(false)), - next: AtomicU64::new(1), - stopped: AtomicBool::new(false), - }) - } - fn tool(&self) -> Result { - let path = self - .path - .lock() - .unwrap() - .clone() - .filter(|p| executable(p)) - .ok_or_else(|| { - Fault::new( - "hdc.not_configured", - "请先选择 DevEco Studio 或官方 Command Line Tools 中的 hdc。", - ) - })?; - Ok(Tool { - path, - cancel: self.cancel.clone(), - }) - } - fn enter(&self) -> Result { - self.busy - .compare_exchange(false, true, Ordering::AcqRel, Ordering::Relaxed) - .map_err(|_| Fault::new("hdc.busy", "上一项管理操作尚未结束。"))?; - let guard = Guard(self.busy.clone()); - self.cancel.store(false, Ordering::Release); - if self.stopped.load(Ordering::Acquire) { - self.cancel.store(true, Ordering::Release); - return Err(Fault::new("device.stopping", "管理连接正在关闭。")); - } - Ok(guard) - } - pub fn stop(&self) { - self.stopped.store(true, Ordering::Release); - self.cancel.store(true, Ordering::Release); - let _ = self.window.stop(); - let _ = self.screen.close(None); - } - pub fn call(self: &Arc, method: &str, value: Value) -> Result { - if method == "status" { - return Ok( - json!({"busy":self.busy.load(Ordering::Acquire),"job":self.job.lock().unwrap().clone()}), - ); - } - if method == "cancel" { - let id = value["id"] - .as_u64() - .ok_or_else(|| Fault::new("request.invalid_params", "缺少任务标识。"))?; - let job = self.job.lock().unwrap(); - if job["id"] != id { - return Err(Fault::new("operation.not_found", "任务已不存在。")); - } - if job["state"] == "running" { - self.cancel.store(true, Ordering::Release); - } - return Ok(json!({"requested":job["state"] == "running"})); - } - if method == "screenWindowStatus" { - return Ok(self.window.status()); - } - if method == "screenWindowStop" { - return self.window.stop(); - } - if method == "screenStop" { - let r: screen::Id = parse(value)?; - return self.screen.close(Some(r.session)); - } - if method == "screenControl" { - return self.screen.control(parse(value)?); - } - if method == "screenSave" { - return self.screen.save(parse(value)?); - } - if method == "screenFrame" { - return self.screen.frame(parse(value)?); - } - if method == "screenInput" { - return self.screen.input(parse(value)?); - } - let guard = self.enter()?; - match method { - "screenWindowOpen" => self.window.open(self.tool()?, parse(value)?), - "screenStart" => { - let r: Target = parse(value)?; - self.screen.start(self.tool()?, r.target) - } - "tool" => { - let path = { - let mut path = self.path.lock().unwrap(); - if path.is_none() { - *path = discover(); - } - path.clone() - }; - let Some(path) = path else { - return Ok(json!({"available":false})); - }; - if !executable(&path) { - return Ok( - json!({"available":false,"path":path,"reason":"此前选择的 HDC 已移动、删除或不可执行,请重新选择。"}), - ); - } - match tool_version(&path, &self.cancel) { - Ok(version) => Ok(json!({"available":true,"path":path,"version":version})), - Err(error) => Ok( - json!({"available":false,"path":path,"reason":format!("HDC 无法运行:{} 请检查下载版本与同目录依赖文件,或重新选择工具。", error.message)}), - ), - } - } - "configure" => { - let selected: LocalPath = parse(value)?; - let path = selected_tool(Path::new(&selected.path)).ok_or_else(|| { - Fault::new( - "hdc.invalid_tool", - "未找到可执行的 HDC。请先完整解压官方 Command Line Tools,选择其目录或 toolchains 中的 hdc 文件;不能选择 ZIP 压缩包。", - ) - })?; - let version = tool_version(&path, &self.cancel)?; - if let Some(settings) = &self.settings { - fs::create_dir_all(settings.parent().unwrap())?; - let temporary = settings.with_extension("tmp"); - fs::write( - &temporary, - serde_json::to_vec(&json!({"hdcPath":path})).unwrap(), - )?; - fs::rename(temporary, settings)?; - } - *self.path.lock().unwrap() = Some(path.clone()); - Ok(json!({"available":true,"path":path,"version":version.trim()})) - } - "devices" => Ok(json!({"devices":self.tool()?.targets()?})), - "inspect" => { - let request: Target = parse(value)?; - let tool = self.tool()?; - tool.connected(&request.target)?; - let mut capabilities = serde_json::Map::new(); - for (key, cmd, needle) in [ - ("apps", "bm help", "dump"), - ("launch", "aa help", "start"), - ("screenshot", "uitest help", "screenCap"), - ("logs", "hilog -h", "-x"), - ] { - let result = tool.shell(&request.target, cmd); - let (available, reason) = match result { - Ok(text) if text.contains(needle) => (true, String::new()), - Ok(_) => (false, "设备工具未声明此能力".into()), - Err(e) => (false, e.message), - }; - capabilities.insert(key.into(), json!({"available":available,"reason":reason})); - } - let info = tool.shell(&request.target, "hidumper -c base"); - let info = match info { - Ok(text) if !text.trim().is_empty() => json!({"available":true,"text":text}), - Ok(_) => json!({"available":false,"reason":"设备未返回基本信息"}), - Err(e) => json!({"available":false,"reason":e.message}), - }; - let report_help = tool.run(&["-h", "verbose"], 5).unwrap_or_default(); - capabilities.insert( - "report".into(), - json!({"available":report_help.contains("bugreport"),"reason":""}), - ); - Ok(json!({"target":request.target,"capabilities":capabilities,"info":info})) - } - "apps" => { - let r: Target = parse(value)?; - let tool = self.tool()?; - tool.connected(&r.target)?; - Ok(json!({"apps":tool.apps(&r.target)?})) - } - "app" => { - let r: Application = parse(value)?; - let tool = self.tool()?; - tool.connected(&r.target)?; - tool.app(&r.target, &r.bundle) - } - "package" => { - let r: LocalPath = parse(value)?; - files::package(Path::new(&r.path)) - } - "start" => { - let action: Action = parse(value)?; - let tool = self.tool()?; - let id = self.next.fetch_add(1, Ordering::Relaxed); - *self.job.lock().unwrap() = - json!({"id":id,"state":"running","kind":value_kind(&action)}); - let owner = self.clone(); - thread::spawn(move || { - let _guard = guard; - let result = tool.execute(action); - *owner.job.lock().unwrap() = match result { - Ok(value) => json!({"id":id,"state":"completed","result":value}), - Err(error) => { - json!({"id":id,"state":if error.code == "operation.cancelled" {"cancelled"} else {"failed"},"error":error}) - } - }; - }); - Ok(json!({"id":id})) - } - _ => Err(Fault::new("service.method_not_found", "未知管理操作。")), - } - } -} -fn value_kind(action: &Action) -> &'static str { - match action { - Action::Install { .. } => "install", - Action::Uninstall { .. } => "uninstall", - Action::Launch { .. } => "launch", - Action::Stop { .. } => "stop", - Action::Screenshot { .. } => "screenshot", - Action::Logs { .. } => "logs", - Action::Report { .. } => "report", - Action::ExportDebug { .. } => "exportDebug", - } -} -impl Tool { - fn execute(&self, action: Action) -> Result { - let target = match &action { - Action::Install { target, .. } - | Action::Uninstall { target, .. } - | Action::Launch { target, .. } - | Action::Stop { target, .. } - | Action::Screenshot { target, .. } - | Action::Logs { target, .. } - | Action::Report { target, .. } - | Action::ExportDebug { target, .. } => target, - }; - self.connected(target)?; - match action { - Action::Install { - target, - path, - sha256, - } => { - let snapshot = tempfile::Builder::new() - .prefix("harmony-install-") - .tempdir()?; - let source = Path::new(&path); - files::package(source)?; - let copy = snapshot.path().join("application.hap"); - fs::copy(source, ©)?; - let metadata = files::package(©)?; - if metadata["sha256"] != sha256 { - return Err(Fault::new( - "file.changed", - "安装包已改变,请重新选择并确认。", - )); - } - self.run(&["-t", &target, "install", copy.to_str().unwrap()], 300)?; - let bundle = metadata["bundle"].as_str().unwrap(); - let installed = self.app(&target, bundle)?; - if installed["versionCode"] != metadata["versionCode"] { - return Err(Fault::new( - "hdc.install_unverified", - "安装后版本与所选包不一致,请检查手机上的实际状态。", - )); - } - Ok(json!({"message":"应用已安装","application":installed})) - } - Action::Uninstall { target, bundle } => { - let details = self.app(&target, &bundle)?; - if details["details"]["applicationInfo"]["isSystemApp"] == true { - return Err(Fault::new("hdc.system_app", "不支持移除系统应用。")); - } - self.run( - &["-t", &target, "uninstall", hdc_data::identifier(&bundle)?], - 90, - )?; - if self.apps(&target)?.contains(&bundle) { - return Err(Fault::new( - "hdc.uninstall_unverified", - "应用仍出现在设备列表中,请检查手机上的状态。", - )); - } - Ok(json!({"message":"应用已卸载","bundle":bundle})) - } - Action::Launch { - target, - bundle, - module, - ability, - } => { - hdc_data::identifier(&module)?; - hdc_data::identifier(&ability)?; - let details = self.app(&target, &bundle)?; - if !details["abilities"] - .as_array() - .unwrap() - .iter() - .any(|a| a["name"] == ability && a["module"] == module) - { - return Err(Fault::new( - "hdc.ability_unavailable", - "该应用未开放所选启动组件。", - )); - } - let output = self.shell( - &target, - &format!("aa start -b {bundle} -a {ability} -m {module}"), - )?; - if !output.to_lowercase().contains("success") { - return Err(Fault::new( - "hdc.launch_unverified", - format!("设备未确认启动成功:{output}"), - )); - } - Ok(json!({"message":"启动命令已成功执行","bundle":bundle})) - } - Action::Stop { target, bundle } => { - hdc_data::identifier(&bundle)?; - self.app(&target, &bundle)?; - let output = self.shell(&target, &format!("aa force-stop {bundle}"))?; - if !output.to_lowercase().contains("success") { - return Err(Fault::new( - "hdc.stop_unverified", - format!("设备未确认停止成功:{output}"), - )); - } - Ok(json!({"message":"应用已强制停止","bundle":bundle})) - } - Action::Screenshot { target, directory } => { - files::screenshot(self, &target, Path::new(&directory)) - } - Action::Logs { target, directory } => files::logs(self, &target, Path::new(&directory)), - Action::Report { target, directory } => { - files::report(self, &target, Path::new(&directory)) - } - Action::ExportDebug { - target, - bundle, - directory, - } => files::export_debug(self, &target, &bundle, Path::new(&directory)), - } - } -} - -#[cfg(test)] -mod tests; diff --git a/curios/harmony-connector/native/src/hdc/files.rs b/curios/harmony-connector/native/src/hdc/files.rs deleted file mode 100644 index f02a918..0000000 --- a/curios/harmony-connector/native/src/hdc/files.rs +++ /dev/null @@ -1,298 +0,0 @@ -use super::*; -use sha2::{Digest, Sha256}; -use std::{ - io::{Read, Seek, Write}, - time::{SystemTime, UNIX_EPOCH}, -}; - -fn hash(path: &Path) -> Result { - let mut file = fs::File::open(path)?; - hash_reader(&mut file) -} -fn hash_reader(file: &mut impl Read) -> Result { - let mut digest = Sha256::new(); - let mut buffer = [0u8; 64 * 1024]; - loop { - let size = file.read(&mut buffer)?; - if size == 0 { - break; - } - digest.update(&buffer[..size]); - } - Ok(format!("{:x}", digest.finalize())) -} -pub(super) fn package(path: &Path) -> Result { - if !path.is_absolute() - || !path.is_file() - || path - .extension() - .and_then(|v| v.to_str()) - .is_none_or(|v| !v.eq_ignore_ascii_case("hap")) - { - return Err(Fault::new( - "hdc.invalid_package", - "请选择已签名的 .hap 安装包。", - )); - } - let mut file = fs::File::open(path)?; - let size = file.metadata()?.len(); - let digest = hash_reader(&mut file)?; - file.rewind()?; - let mut zip = zip::ZipArchive::new(file) - .map_err(|_| Fault::new("hdc.invalid_package", "安装包不是有效的 HAP 归档。"))?; - let module = zip - .by_name("module.json") - .map_err(|_| Fault::new("hdc.invalid_package", "HAP 中没有可识别的 module.json。"))?; - if module.size() > 1024 * 1024 { - return Err(Fault::new("hdc.invalid_package", "安装包清单过大。")); - } - let mut bytes = Vec::new(); - module.take(1024 * 1024 + 1).read_to_end(&mut bytes)?; - let metadata: Value = serde_json::from_slice(&bytes) - .map_err(|_| Fault::new("hdc.invalid_package", "无法读取 HAP 应用清单。"))?; - let app = &metadata["app"]; - let bundle = app["bundleName"] - .as_str() - .ok_or_else(|| Fault::new("hdc.invalid_package", "HAP 缺少应用包名。"))?; - hdc_data::identifier(bundle)?; - let version = app["versionCode"] - .as_u64() - .ok_or_else(|| Fault::new("hdc.invalid_package", "HAP 缺少版本号。"))?; - Ok( - json!({"bundle":bundle,"versionCode":version,"versionName":app["versionName"],"sha256":digest,"size":size}), - ) -} -fn directory(path: &Path) -> Result { - if !path.is_absolute() || !path.is_dir() { - return Err(Fault::new( - "file.invalid_directory", - "请选择 Mac 上的保存文件夹。", - )); - } - Ok(path.canonicalize()?) -} -fn stamp() -> String { - format!( - "{}", - SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_millis() - ) -} -fn save(mut temporary: tempfile::NamedTempFile, destination: &Path) -> Result { - let size = temporary.as_file().metadata()?.len(); - if size == 0 { - return Err(Fault::new( - "hdc.empty_result", - "设备没有返回文件内容,未生成成功记录。", - )); - } - temporary.flush()?; - temporary.as_file().sync_all()?; - let digest = hash(temporary.path())?; - temporary.persist_noclobber(destination).map_err(|e| { - Fault::new( - "file.save_failed", - format!("无法保存文件(不会覆盖已有文件):{}", e.error), - ) - })?; - Ok(json!({"path":destination,"bytes":size,"sha256":digest})) -} -pub(super) fn logs(tool: &Tool, target: &str, selected: &Path) -> Result { - let directory = directory(selected)?; - let content = tool.run_output(&["-t", target, "shell", "hilog -x"], 15)?; - let mut temporary = tempfile::NamedTempFile::new_in(&directory)?; - temporary.write_all(content.as_bytes())?; - save( - temporary, - &directory.join(format!("harmony-logs-{}.txt", stamp())), - ) -} -pub(super) fn report(tool: &Tool, target: &str, selected: &Path) -> Result { - let directory = directory(selected)?; - let temporary = tempfile::NamedTempFile::new_in(&directory)?; - tool.run( - &[ - "-t", - target, - "bugreport", - temporary.path().to_str().unwrap(), - ], - 180, - )?; - save( - temporary, - &directory.join(format!("harmony-report-{}.txt", stamp())), - ) -} -pub(super) fn screenshot(tool: &Tool, target: &str, selected: &Path) -> Result { - let directory = directory(selected)?; - let temporary = tempfile::NamedTempFile::new_in(&directory)?; - // Tempfile's random suffix is also used to avoid clobbering a preexisting device screenshot. - let nonce = temporary - .path() - .file_name() - .unwrap() - .to_string_lossy() - .replace('.', ""); - let remote = format!("/data/local/tmp/harmony-{}-{nonce}.png", stamp()); - let result = (|| { - tool.shell(target, &format!("uitest screenCap -p {remote}"))?; - tool.run( - &[ - "-t", - target, - "file", - "recv", - &remote, - temporary.path().to_str().unwrap(), - ], - 90, - )?; - let decoder = png::Decoder::new(std::io::BufReader::new(fs::File::open(temporary.path())?)); - let mut reader = decoder - .read_info() - .map_err(|_| Fault::new("hdc.invalid_screenshot", "设备没有返回有效 PNG 截图。"))?; - let size = reader - .output_buffer_size() - .filter(|size| *size <= 64 * 1024 * 1024) - .ok_or_else(|| Fault::new("hdc.invalid_screenshot", "截图尺寸超过支持范围。"))?; - reader.next_frame(&mut vec![0; size]).map_err(|_| { - Fault::new( - "hdc.invalid_screenshot", - "截图数据不完整,未保存为成功结果。", - ) - })?; - save( - temporary, - &directory.join(format!("harmony-screenshot-{}.png", stamp())), - ) - })(); - // Cleanup only the file created by this operation. Cancellation must not block cleanup. - let cleanup = Tool { - path: tool.path.clone(), - cancel: Arc::new(AtomicBool::new(false)), - }; - let _ = cleanup.shell(target, &format!("rm -f {remote}")); - result -} -fn manifest_files( - root: &Path, - path: &Path, - entries: &mut Vec, - cancel: &AtomicBool, -) -> Result<()> { - if cancel.load(Ordering::Acquire) { - return Err(Fault::new("operation.cancelled", "数据导出已取消。")); - } - if entries.len() >= 100_000 || path.strip_prefix(root).unwrap().components().count() > 128 { - return Err(Fault::new( - "hdc.backup_too_large", - "数据条目过多,请改用按文件夹导出。", - )); - } - let metadata = path.symlink_metadata()?; - if metadata.is_dir() { - entries.push(json!({"path":path.strip_prefix(root).unwrap().to_string_lossy(),"folder":true,"size":0})); - for entry in fs::read_dir(path)? { - manifest_files(root, &entry?.path(), entries, cancel)?; - } - } else if metadata.is_file() { - entries.push(json!({"path":path.strip_prefix(root).unwrap().to_string_lossy(),"size":metadata.len(),"sha256":hash(path)?})); - } else { - return Err(Fault::new( - "hdc.invalid_backup", - "导出结果包含符号链接或特殊文件,未保存为完整副本。", - )); - } - Ok(()) -} -pub(super) fn export_debug( - tool: &Tool, - target: &str, - bundle: &str, - selected: &Path, -) -> Result { - let directory = directory(selected)?; - let application = tool.app(target, bundle)?; - if application["debug"] != true { - return Err(Fault::new( - "hdc.backup_unavailable", - "该应用不是调试签名应用,官方 HDC 不开放其私有数据。可在手机系统备份中处理。", - )); - } - const REMOTE: &str = "data/storage/el2/base"; - tool.run( - &[ - "-t", - target, - "shell", - "-b", - bundle, - "ls data/storage/el2/base/", - ], - 15, - )?; - let staging = tempfile::Builder::new() - .prefix(".harmony-backup-") - .tempdir_in(&directory)?; - tool.run( - &[ - "-t", - target, - "file", - "recv", - "-b", - bundle, - REMOTE, - staging.path().to_str().unwrap(), - ], - 600, - )?; - let content = staging.path().join("base"); - if !content.is_dir() { - return Err(Fault::new( - "hdc.invalid_backup", - "未收到完整的应用数据目录,未生成备份。", - )); - } - let mut entries = Vec::new(); - manifest_files(staging.path(), &content, &mut entries, &tool.cancel)?; - entries.sort_by(|a, b| a["path"].as_str().cmp(&b["path"].as_str())); - let bytes: u64 = entries.iter().map(|v| v["size"].as_u64().unwrap()).sum(); - let manifest = json!({"schemaVersion":1,"kind":"harmony-debug-data-export","target":target,"application":application,"sourcePath":REMOTE,"createdAtUnixMillis":stamp(),"consistency":"live-file-copy","files":entries}); - fs::write( - staging.path().join("manifest.json"), - serde_json::to_vec_pretty(&manifest).unwrap(), - )?; - let destination = directory.join(format!("{bundle}-debug-data-{}", stamp())); - crate::file_transfer::publish_directory(staging.path(), &destination)?; - Ok( - json!({"path":destination,"bytes":bytes,"files":entries.iter().filter(|e| e["folder"] != true).count(),"message":"调试应用数据副本已保存;不包含全部应用状态,不保证运行中数据库的一致性。"}), - ) -} - -#[cfg(test)] -mod tests { - use super::*; - #[test] - fn reads_hap_identity_before_installation_and_rejects_unrelated_archives() { - let directory = tempfile::tempdir().unwrap(); - let path = directory.path().join("app.hap"); - let mut zip = zip::ZipWriter::new(fs::File::create(&path).unwrap()); - zip.start_file("module.json", zip::write::SimpleFileOptions::default()) - .unwrap(); - zip.write_all( - br#"{"app":{"bundleName":"com.example.app","versionCode":3,"versionName":"1.2"}}"#, - ) - .unwrap(); - zip.finish().unwrap(); - let metadata = package(&path).unwrap(); - assert_eq!(metadata["bundle"], "com.example.app"); - assert_eq!(metadata["versionCode"], 3); - assert_eq!(metadata["sha256"].as_str().unwrap().len(), 64); - fs::write(&path, b"not a hap").unwrap(); - assert!(package(&path).is_err()); - } -} diff --git a/curios/harmony-connector/native/src/hdc/screen.rs b/curios/harmony-connector/native/src/hdc/screen.rs deleted file mode 100644 index de61f63..0000000 --- a/curios/harmony-connector/native/src/hdc/screen.rs +++ /dev/null @@ -1,294 +0,0 @@ -use super::screen_input::{self, Input}; -use super::*; -use base64::{engine::general_purpose::STANDARD, Engine}; -use std::{ - collections::HashSet, - io::Write, - time::{Instant, SystemTime, UNIX_EPOCH}, -}; - -const LEASE: Duration = Duration::from_secs(15); -struct Frame { - jpeg: Arc>, - width: u32, - height: u32, - sequence: u64, - geometry: u64, - captured: Instant, - previous_sequence: u64, -} -struct Session { - id: u64, - target: String, - path: PathBuf, - cancel: Arc, - control: bool, - supported: HashSet, - frame: Option, - touched: Instant, - stream: Arc, -} -pub(super) struct Screen { - current: Mutex>, - next: AtomicU64, - lifecycle: Mutex<()>, - directory: PathBuf, -} -impl Default for Screen { - fn default() -> Self { - let directory = env::var_os("HOME") - .map(PathBuf::from) - .map(|home| home.join("Library/HarmonyConnector/sessions")) - .unwrap_or_else(|| { - env::temp_dir().join(format!("harmony-screen-{}", unsafe { libc::geteuid() })) - }); - Self::new(directory) - } -} -#[derive(Deserialize)] -#[serde(deny_unknown_fields)] -pub(super) struct Id { - pub session: u64, -} -#[derive(Deserialize)] -#[serde(deny_unknown_fields)] -pub(super) struct FrameRequest { - pub session: u64, - #[serde(default)] - pub after: u64, -} -#[derive(Deserialize)] -#[serde(deny_unknown_fields)] -pub(super) struct Control { - pub session: u64, - pub enabled: bool, - pub geometry: Option, -} -#[derive(Deserialize)] -#[serde(deny_unknown_fields)] -pub(super) struct Event { - pub session: u64, - pub geometry: u64, - pub sequence: u64, - pub input: Input, -} -#[derive(Deserialize)] -#[serde(deny_unknown_fields)] -pub(super) struct Save { - pub session: u64, - pub directory: PathBuf, -} -fn missing() -> Fault { - Fault::new("screen.closed", "投屏已停止,请重新开始。") -} -impl Screen { - pub fn new(directory: PathBuf) -> Self { - Self { - current: Mutex::new(None), - next: AtomicU64::new(0), - lifecycle: Mutex::new(()), - directory, - } - } - pub fn close(&self, id: Option) -> Result { - let _lifecycle = self.lifecycle.lock().unwrap(); - let session = { - let mut current = self.current.lock().unwrap(); - if let Some(id) = id { - if current.as_ref().is_some_and(|s| s.id != id) { - return Err(missing()); - } - } - current.take() - }; - if let Some(session) = session { - session.cancel.store(true, Ordering::Release); - session.stream.stop()?; - } - Ok(json!({"stopped":true})) - } - pub fn start(&self, tool: Tool, target: String) -> Result { - let _lifecycle = self.lifecycle.lock().unwrap(); - hdc_data::target(&target)?; - { - let mut current = self.current.lock().unwrap(); - if current - .as_ref() - .is_some_and(|s| s.touched.elapsed() < LEASE) - { - return Err(Fault::new("screen.active", "请先停止当前投屏。")); - } - if let Some(session) = current.take() { - session.stream.stop()?; - } - } - tool.connected(&target)?; - let help = tool - .shell(&target, "uitest uiInput help") - .unwrap_or_default(); - let mut supported = screen_input::capabilities(&help); - let stream = Arc::new(screen_agent::Stream::open(&tool, &target, &self.directory)?); - for input in [ - "click", - "doubleClick", - "longClick", - "swipe", - "drag", - "keyEvent", - ] { - supported.insert(input.into()); - } - let id = self.next.fetch_add(1, Ordering::Relaxed) + 1; - let mut capabilities: Vec<_> = supported.iter().cloned().collect(); - capabilities.sort(); - if tool.cancel.load(Ordering::Acquire) { - return Err(missing()); - } - *self.current.lock().unwrap() = Some(Session { - id, - target: target.clone(), - path: tool.path, - cancel: Arc::new(AtomicBool::new(false)), - control: false, - supported, - frame: None, - touched: Instant::now(), - stream, - }); - Ok( - json!({"session":id,"target":target,"inputCapabilities":capabilities,"control":false,"mode":"hypium-stream"}), - ) - } - fn access(&self, id: u64, f: impl FnOnce(&mut Session) -> Result) -> Result { - let mut current = self.current.lock().unwrap(); - let s = current - .as_mut() - .filter(|s| s.id == id && !s.cancel.load(Ordering::Acquire)) - .ok_or_else(missing)?; - if s.touched.elapsed() > LEASE { - s.control = false; - s.cancel.store(true, Ordering::Release); - return Err(Fault::new( - "screen.expired", - "投屏连接已暂停过久,请重新开始。", - )); - } - s.touched = Instant::now(); - f(s) - } - pub fn control(&self, request: Control) -> Result { - self.access(request.session, |s| { - if request.enabled && request.geometry != s.frame.as_ref().map(|f| f.geometry) { - return Err(Fault::new( - "screen.stale_frame", - "画面方向已变化,请等待新画面后启用控制。", - )); - } - if request.enabled && (s.supported.is_empty() || s.frame.is_none()) { - return Err(Fault::new( - "screen.not_ready", - "先等待手机画面,并确认设备开放输入能力。", - )); - } - s.control = request.enabled; - Ok(json!({"control":s.control})) - }) - } - pub fn frame(&self, request: FrameRequest) -> Result { - let stream = self.access(request.session, |s| Ok(s.stream.clone()))?; - let result = (|| { - let (frame, fps, mbps) = stream.frame(request.after)?; - self.access(request.session, |s| { - let changed = s.frame.as_ref().is_some_and(|f| f.geometry != frame.geometry); - let previous = s.frame.as_ref().map_or(0, |f| if f.sequence != frame.sequence {f.sequence} else {f.previous_sequence}); - if changed { s.control = false; } - let mut value = json!({"session":s.id,"sequence":frame.sequence,"geometry":frame.geometry, - "width":frame.width,"height":frame.height, - "imageWidth":frame.image_width,"imageHeight":frame.image_height, - "captureWidth":frame.width.div_ceil(2),"captureHeight":frame.height.div_ceil(2),"frameAgeMs":frame.received.elapsed().as_millis(), - "receivedFps":fps,"mbps":mbps,"control":s.control,"rotated":changed,"unchanged":frame.sequence==request.after}); - if frame.sequence != request.after { - value["dataUrl"] = format!("data:image/jpeg;base64,{}",STANDARD.encode(frame.jpeg.as_slice())).into(); - } - s.frame = Some(Frame {jpeg:frame.jpeg,width:frame.width,height:frame.height, - sequence:frame.sequence,geometry:frame.geometry,captured:Instant::now(),previous_sequence:previous}); - Ok(value) - }) - })(); - if result.is_err() { - let _ = self.close(Some(request.session)); - } - result - } - pub fn input(&self, request: Event) -> Result { - let (tool, target, stream, cmd) = self.access(request.session, |s| { - if !s.control { - return Err(Fault::new("screen.read_only", "请先启用控制。")); - } - let frame = s.frame.as_ref().ok_or_else(missing)?; - if frame.geometry != request.geometry - || s.stream.current()?.geometry != frame.geometry - || request.sequence == 0 - || ![frame.sequence, frame.previous_sequence].contains(&request.sequence) - || frame.captured.elapsed() > Duration::from_secs(3) - { - return Err(Fault::new( - "screen.stale_frame", - "画面已变化,请等待新画面后再操作。", - )); - } - let cmd = request - .input - .command(frame.width, frame.height, &s.supported)?; - Ok(( - Tool { - path: s.path.clone(), - cancel: s.cancel.clone(), - }, - s.target.clone(), - s.stream.clone(), - cmd, - )) - })?; - let result = match request.input.rpc() { - Some((api, args)) => stream.driver(api, args).map(|_| ()), - None => tool.shell(&target, &cmd).map(|_| ()), - }; - if result.is_err() { - self.access(request.session, |s| { - s.control = false; - Ok(()) - }) - .ok(); - } - result.map(|_| json!({"executed":true})) - } - pub fn save(&self, request: Save) -> Result { - let jpeg = self.access(request.session, |s| { - let frame = s - .frame - .as_ref() - .ok_or_else(|| Fault::new("screen.not_ready", "请等待手机画面后再保存。"))?; - Ok(frame.jpeg.clone()) - })?; - if !request.directory.is_absolute() || !request.directory.is_dir() { - return Err(Fault::new( - "file.invalid_directory", - "请选择保存画面的目录。", - )); - } - let directory = request.directory.canonicalize()?; - let mut temporary = tempfile::NamedTempFile::new_in(&directory)?; - temporary.write_all(&jpeg)?; - temporary.as_file().sync_all()?; - let stamp = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_millis(); - let destination = directory.join(format!("harmony-screen-{stamp}.jpg")); - temporary - .persist_noclobber(&destination) - .map_err(|e| Fault::new("file.save_failed", e.error.to_string()))?; - Ok(json!({"path":destination,"bytes":jpeg.len()})) - } -} diff --git a/curios/harmony-connector/native/src/hdc/screen_agent.rs b/curios/harmony-connector/native/src/hdc/screen_agent.rs deleted file mode 100644 index bb0e988..0000000 --- a/curios/harmony-connector/native/src/hdc/screen_agent.rs +++ /dev/null @@ -1,613 +0,0 @@ -//! One HDC-owned UITest daemon and two sockets: image push and control RPC. -//! Only the latest image is retained. Closing a session releases its own daemon, -//! extension file and forwarding rule; other debugging sessions are untouched. -use super::screen_protocol::{self, Decoder, Packet}; -use super::*; -use image::{ImageFormat, ImageReader}; -use std::{ - collections::VecDeque, - io::{Cursor, Read, Write}, - net::{Shutdown, TcpListener, TcpStream}, - sync::Condvar, - time::Instant, -}; - -const AGENT: &[u8] = - include_bytes!("../../../../../vendor/harmony-connector/hypium/uitest_agent_v1.2.2.so"); -use super::screen_lease::{self, Lease as DeviceLease, SOCKET}; -const LEASE: Duration = Duration::from_secs(15); - -fn fault(message: impl Into) -> Fault { - Fault::new("screen.connection_failed", message) -} -fn io(error: std::io::Error) -> Fault { - fault(format!("投屏连接已中断:{error}")) -} -fn response(value: Value) -> Result { - if value.get("exception").is_some_and(|v| !v.is_null()) { - return Err(Fault::new( - "screen.remote_error", - format!( - "设备未能执行操作:{}", - value["exception"]["message"].as_str().unwrap_or("未知错误") - ), - )); - } - value - .get("result") - .cloned() - .ok_or_else(|| fault("设备没有返回有效操作结果。")) -} -pub(super) struct Rpc { - socket: TcpStream, - decoder: Decoder, - sequence: u32, - driver: String, -} -impl Rpc { - pub(super) fn connect(port: u16) -> Result { - let socket = - TcpStream::connect_timeout(&([127, 0, 0, 1], port).into(), Duration::from_secs(2)) - .map_err(io)?; - socket.set_nodelay(true).map_err(io)?; - socket - .set_read_timeout(Some(Duration::from_secs(2))) - .map_err(io)?; - socket - .set_write_timeout(Some(Duration::from_secs(1))) - .map_err(io)?; - Ok(Self { - socket, - decoder: Decoder::default(), - sequence: 0, - driver: String::new(), - }) - } - fn send(&mut self, method: &str, params: Value) -> Result<()> { - self.sequence = self.sequence.wrapping_add(1); - let value = json!({"module":"com.ohos.devicetest.hypiumApiHelper","method":method,"params":params,"request_id":self.sequence.to_string()}); - self.socket - .write_all(&screen_protocol::request(&value, self.sequence)) - .map_err(io) - } - fn call(&mut self, method: &str, params: Value) -> Result { - let result = self.exchange(method, params); - if result - .as_ref() - .is_err_and(|error| error.code != "screen.remote_error") - { - // A timed-out response has no request ID on this protocol version. - // Never let a later command consume that ambiguous response. - let _ = self.socket.shutdown(Shutdown::Both); - } - result - } - fn exchange(&mut self, method: &str, params: Value) -> Result { - self.send(method, params)?; - let deadline = Instant::now() + Duration::from_secs(3); - let mut bytes = [0; 8192]; - loop { - if let Some(packet) = self.decoder.next()? { - return match packet { - Packet::Json(v) => response(v), - Packet::Jpeg(_) => Err(fault("控制通道返回了意外画面。")), - }; - } - if Instant::now() > deadline { - return Err(fault("手机控制响应超时。")); - } - let size = self.socket.read(&mut bytes).map_err(io)?; - if size == 0 { - return Err(fault("手机已关闭投屏连接。")); - } - self.decoder.push(&bytes[..size])?; - } - } - pub(super) fn driver(&mut self, api: &str, args: Value) -> Result { - self.call( - "callHypiumApi", - json!({"api":api,"this":self.driver,"args":args,"message_type":"hypium"}), - ) - } - pub(super) fn create_driver(&mut self) -> Result<()> { - self.driver = "Driver#0".into(); - let value = self.driver("Driver.create", json!([]))?; - self.driver = value - .as_str() - .filter(|s| s.starts_with("Driver#") && s.len() < 64) - .ok_or_else(|| fault("设备未能启动控制组件。"))? - .into(); - Ok(()) - } - pub(super) fn gesture(&mut self, api: &str, x: u32, y: u32) -> Result { - self.call("Gestures", json!({"api":api,"args":{"x":x,"y":y}})) - } - fn stop_capture(&mut self) -> Result<()> { - // Captures.stopCaptureScreen has no guaranteed response on this agent. - self.send("Captures", json!({"api":"stopCaptureScreen","args":{}})) - } - pub(super) fn dimensions(&mut self) -> Result<(u32, u32)> { - let value = self.driver("Driver.getDisplaySize", json!([]))?; - let dimension = |key: &str| { - value[key] - .as_u64() - .filter(|n| (1..=8192).contains(n)) - .map(|n| n as u32) - .ok_or_else(|| fault("设备屏幕尺寸无效。")) - }; - Ok((dimension("x")?, dimension("y")?)) - } -} -pub(super) fn processes(tool: &Tool, target: &str) -> Result> { - let pids = tool.run_output(&["-t", target, "shell", "pidof uitest xdevice_scrcpy"], 2)?; - let mut result = Vec::new(); - for pid in pids.split_whitespace() { - if !pid.bytes().all(|b| b.is_ascii_digit()) { - return Err(fault("无法检查设备调试会话。")); - } - let cmdline = tool.run_output( - &["-t", target, "shell", &format!("cat /proc/{pid}/cmdline")], - 2, - )?; - if !cmdline.contains("No such file") { - result.push((pid.to_owned(), cmdline)); - } - } - Ok(result) -} -pub(super) fn existing_agents(tool: &Tool, target: &str) -> Result> { - let mut owned = Vec::new(); - for (pid, cmdline) in processes(tool, target)? { - let args: Vec<_> = cmdline.split('\0').filter(|s| !s.is_empty()).collect(); - let is_uitest = args - .first() - .is_some_and(|s| Path::new(s).file_name().is_some_and(|n| n == "uitest")); - if is_uitest && args.get(1) != Some(&"start-daemon") { - continue; - } - let name = args - .windows(2) - .find(|pair| pair[0] == "--extension-name") - .map(|pair| pair[1]); - let ours = name - .and_then(|name| name.strip_prefix("trove-screen-")) - .and_then(|name| name.strip_suffix(".so")) - .is_some_and(|name| name.len() == 6 && name.bytes().all(|b| b.is_ascii_alphanumeric())); - if !is_uitest || args.get(2) != Some(&"singleness") || !ours { - return Err(Fault::new( - "screen.debugger_busy", - "设备的 UI 调试服务由其他工具启动,请先结束该调试会话。", - )); - } - owned.push((pid.to_owned(), cmdline)); - } - Ok(owned) -} -pub(super) fn terminate_agent(tool: &Tool, target: &str, pid: &str, expected: &str) -> Result<()> { - // Recheck identity before signalling: a PID may have been reused. - let command = format!("cat /proc/{pid}/cmdline"); - if tool.run_output(&["-t", target, "shell", &command], 2)? != expected { - return Ok(()); - } - // Official xdevice uses SIGKILL. Commercial firmware may reject SIGTERM. - tool.run(&["-t", target, "shell", &format!("kill -9 {pid}")], 2)?; - let deadline = Instant::now() + Duration::from_secs(1); - while tool.run_output(&["-t", target, "shell", &command], 2)? == expected { - if Instant::now() >= deadline { - return Err(Fault::new( - "screen.cleanup_failed", - "未能结束插件的投屏代理,请重新连接后重试。", - )); - } - thread::sleep(Duration::from_millis(20)); - } - Ok(()) -} -struct DeviceSession { - tool: Tool, - target: String, - uploaded: Option, - forward: Option, - lease: DeviceLease, -} -impl DeviceSession { - fn close(&mut self) -> Result<()> { - let cleanup = Tool { - path: self.tool.path.clone(), - cancel: Arc::new(AtomicBool::new(false)), - }; - let mut failure = None; - if let Some(local) = self.forward.take() { - let result = cleanup - .run(&["-t", &self.target, "fport", "rm", &local, SOCKET], 2) - .and_then(|_| { - if screen_lease::forwards(&cleanup, &self.target)?.contains(&local) { - return Err(fault("未能释放投屏连接,请重试。")); - } - self.lease.record(None) - }); - if let Err(error) = result { - failure = Some(error); - } - } - if let Some(name) = self.uploaded.take() { - let result = (|| { - // Only our exact extension belongs to this session. - for (pid, cmdline) in existing_agents(&cleanup, &self.target)? { - if cmdline.split('\0').any(|arg| arg == name) { - terminate_agent(&cleanup, &self.target, &pid, &cmdline)?; - } - } - cleanup.run( - &[ - "-t", - &self.target, - "shell", - &format!("rm -f /data/local/tmp/{name}"), - ], - 2, - )?; - Ok(()) - })(); - if let Err(error) = result { - failure.get_or_insert(error); - } - } - failure.map_or(Ok(()), Err) - } -} -impl Drop for DeviceSession { - fn drop(&mut self) { - let _ = self.close(); - } -} -#[derive(Clone)] -pub(super) struct Frame { - pub jpeg: Arc>, - pub width: u32, - pub height: u32, - pub image_width: u32, - pub image_height: u32, - pub sequence: u64, - pub geometry: u64, - pub received: Instant, -} -struct State { - latest: Option, - error: Option, - cleanup_error: Option, - touched: Instant, - samples: VecDeque, - bytes: VecDeque<(Instant, usize)>, -} -struct Shared { - state: Mutex, - changed: Condvar, - cancel: Arc, -} -pub(super) struct Stream { - shared: Arc, - rpc: Arc>, - worker: Mutex>>, -} -impl Stream { - pub fn open(tool: &Tool, target: &str, directory: &Path) -> Result { - let lease = DeviceLease::acquire(directory, tool, target)?; - let existing = existing_agents(tool, target)?; - if !screen_lease::forwards(tool, target)?.is_empty() { - return Err(screen_lease::busy()); - } - for (pid, cmdline) in existing { - terminate_agent(tool, target, &pid, &cmdline)?; - } - let mut resources = DeviceSession { - tool: Tool { - path: tool.path.clone(), - cancel: tool.cancel.clone(), - }, - target: target.into(), - uploaded: None, - forward: None, - lease, - }; - { - let mut file = tempfile::Builder::new() - .prefix("trove-screen-") - .suffix(".so") - .tempfile()?; - file.write_all(AGENT)?; - let name = file - .path() - .file_name() - .unwrap() - .to_str() - .unwrap() - .to_owned(); - resources.uploaded = Some(name.clone()); - tool.run( - &[ - "-t", - target, - "file", - "send", - file.path().to_str().unwrap(), - &format!("/data/local/tmp/{name}"), - ], - 10, - )?; - tool.run( - &[ - "-t", - target, - "shell", - &format!("uitest start-daemon singleness --extension-name {name}"), - ], - 5, - )?; - } - let reservation = TcpListener::bind(("127.0.0.1", 0)).map_err(io)?; - let port = reservation.local_addr().map_err(io)?.port(); - drop(reservation); - let local = format!("tcp:{port}"); - tool.run(&["-t", target, "fport", &local, SOCKET], 3)?; - resources.forward = Some(local.clone()); - resources.lease.record(Some(&local))?; - let deadline = Instant::now() + Duration::from_secs(5); - let (mut control, driver) = loop { - if tool.cancel.load(Ordering::Acquire) { - return Err(fault("投屏连接已取消。")); - } - let attempt = (|| { - let mut control = Rpc::connect(port)?; - control.driver = "Driver#0".into(); - let driver = control.driver("Driver.create", json!([]))?; - Ok((control, driver)) - })(); - match attempt { - Ok(result) => break result, - Err(error) if Instant::now() >= deadline => return Err(error), - Err(_) => thread::sleep(Duration::from_millis(100)), - } - }; - control.driver = driver - .as_str() - .filter(|s| s.starts_with("Driver#") && s.len() < 64) - .ok_or_else(|| fault("设备未能启动投屏控制组件。"))? - .into(); - let dimensions = control.dimensions()?; - let mut video = Rpc::connect(port)?; - video.send( - "Captures", - json!({"api":"startCaptureScreen","args":{"options":{"displayId":0,"scale":0.5}}}), - )?; - video - .socket - .set_read_timeout(Some(Duration::from_millis(100))) - .map_err(io)?; - let rpc = Arc::new(Mutex::new(control)); - let shared = Arc::new(Shared { - state: Mutex::new(State { - latest: None, - error: None, - cleanup_error: None, - touched: Instant::now(), - samples: VecDeque::new(), - bytes: VecDeque::new(), - }), - changed: Condvar::new(), - cancel: Arc::new(AtomicBool::new(false)), - }); - let worker_shared = shared.clone(); - let worker_rpc = rpc.clone(); - let parent_cancel = tool.cancel.clone(); - let worker = thread::spawn(move || { - let result = receive( - &mut video, - &worker_rpc, - &worker_shared, - dimensions, - &parent_cancel, - ); - if let Err(error) = result { - worker_shared.state.lock().unwrap().error = Some(error); - } - worker_shared.cancel.store(true, Ordering::Release); - worker_shared.changed.notify_all(); - let _ = video.stop_capture(); - let _ = video.socket.shutdown(Shutdown::Both); - let _ = worker_rpc.lock().unwrap().socket.shutdown(Shutdown::Both); - if let Err(error) = resources.close() { - worker_shared.state.lock().unwrap().cleanup_error = Some(error); - } - }); - Ok(Self { - shared, - rpc, - worker: Mutex::new(Some(worker)), - }) - } - pub fn frame(&self, after: u64) -> Result<(Frame, f64, f64)> { - let mut state = self.shared.state.lock().unwrap(); - state.touched = Instant::now(); - let timeout = if state.latest.is_none() { - Duration::from_secs(5) - } else { - Duration::from_millis(100) - }; - let (state, _) = self - .shared - .changed - .wait_timeout_while(state, timeout, |s| { - s.error.is_none() - && !self.shared.cancel.load(Ordering::Acquire) - && s.latest.as_ref().is_none_or(|f| f.sequence <= after) - }) - .unwrap(); - if let Some(error) = &state.error { - return Err(error.clone()); - } - if self.shared.cancel.load(Ordering::Acquire) { - return Err(fault("投屏连接已结束,请重新开始。")); - } - let frame = state - .latest - .clone() - .ok_or_else(|| fault("手机没有返回画面,请解锁手机并检查 USB 调试授权。"))?; - let recent = |time: &&Instant| time.elapsed() < Duration::from_secs(1); - let fps = state.samples.iter().filter(recent).count() as f64; - let mbps = state - .bytes - .iter() - .filter(|(t, _)| t.elapsed() < Duration::from_secs(1)) - .map(|(_, n)| *n as f64) - .sum::() - * 8.0 - / 1_000_000.0; - Ok((frame, fps, mbps)) - } - pub fn current(&self) -> Result { - let state = self.shared.state.lock().unwrap(); - if let Some(error) = &state.error { - return Err(error.clone()); - } - if self.shared.cancel.load(Ordering::Acquire) { - return Err(fault("投屏连接已结束。")); - } - state - .latest - .clone() - .ok_or_else(|| fault("请等待手机画面。")) - } - pub fn driver(&self, api: &str, args: Value) -> Result { - self.current()?; - self.rpc.lock().unwrap().driver(api, args) - } - pub fn stop(&self) -> Result<()> { - self.shared.cancel.store(true, Ordering::Release); - self.shared.changed.notify_all(); - if let Some(worker) = self.worker.lock().unwrap().take() { - let _ = worker.join(); - } - self.shared - .state - .lock() - .unwrap() - .cleanup_error - .clone() - .map_or(Ok(()), Err) - } -} -impl Drop for Stream { - fn drop(&mut self) { - let _ = self.stop(); - } -} -fn receive( - video: &mut Rpc, - control: &Mutex, - shared: &Shared, - mut dimensions: (u32, u32), - parent_cancel: &AtomicBool, -) -> Result<()> { - let mut buffer = [0; 65536]; - let mut heartbeat = Instant::now(); - let mut encoded_size = None; - loop { - if shared.cancel.load(Ordering::Acquire) || parent_cancel.load(Ordering::Acquire) { - return Ok(()); - } - if shared.state.lock().unwrap().touched.elapsed() > LEASE { - return Err(fault("投屏窗口已停止响应,连接已关闭。")); - } - if heartbeat.elapsed() >= Duration::from_secs(1) { - dimensions = control.lock().unwrap().dimensions()?; - heartbeat = Instant::now(); - } - match video.socket.read(&mut buffer) { - Ok(0) => return Err(fault("手机已断开投屏连接。")), - Ok(n) => video.decoder.push(&buffer[..n])?, - Err(e) - if matches!( - e.kind(), - std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut - ) => - { - continue - } - Err(e) => return Err(io(e)), - } - while let Some(packet) = video.decoder.next()? { - let jpeg = match packet { - Packet::Json(v) => { - response(v)?; - continue; - } - Packet::Jpeg(bytes) => bytes, - }; - let size = ImageReader::with_format(Cursor::new(&jpeg), ImageFormat::Jpeg) - .into_dimensions() - .map_err(|_| fault("设备返回了无效 JPEG 画面。"))?; - if size.0 == 0 || size.1 == 0 || size.0 > 8192 || size.1 > 8192 { - return Err(fault("画面尺寸超过支持范围。")); - } - if encoded_size.is_some_and(|previous| previous != size) { - dimensions = control.lock().unwrap().dimensions()?; - } - encoded_size = Some(size); - // A display change must be observed before coordinates can be used. - if !capture_size_matches(dimensions, size) { - dimensions = control.lock().unwrap().dimensions()?; - } - if !capture_size_matches(dimensions, size) { - return Err(fault(format!( - "手机画面与屏幕尺寸不一致:画面 {}×{},屏幕 {}×{}。请重新开始投屏。", - size.0, size.1, dimensions.0, dimensions.1 - ))); - } - let now = Instant::now(); - let mut state = shared.state.lock().unwrap(); - let sequence = state.latest.as_ref().map_or(1, |f| f.sequence + 1); - let geometry = state.latest.as_ref().map_or(1, |f| { - f.geometry + u64::from((f.width, f.height) != dimensions) - }); - state.samples.push_back(now); - state.bytes.push_back((now, jpeg.len())); - while state - .samples - .front() - .is_some_and(|t| t.elapsed() > Duration::from_secs(1)) - { - state.samples.pop_front(); - } - while state - .bytes - .front() - .is_some_and(|(t, _)| t.elapsed() > Duration::from_secs(1)) - { - state.bytes.pop_front(); - } - state.latest = Some(Frame { - jpeg: Arc::new(jpeg), - width: dimensions.0, - height: dimensions.1, - image_width: size.0, - image_height: size.1, - sequence, - geometry, - received: now, - }); - shared.changed.notify_all(); - } - } -} - -// The native capture buffer can include trailing row/column alignment padding. -// Keep the logical half-size viewport separate from the JPEG storage size. -fn capture_size_matches(display: (u32, u32), image: (u32, u32)) -> bool { - [(display.0, image.0), (display.1, image.1)] - .into_iter() - .all(|(screen, encoded)| { - let visible = screen.div_ceil(2); - encoded == visible || encoded == visible.div_ceil(16) * 16 - }) -} diff --git a/curios/harmony-connector/native/src/hdc/screen_daemon.rs b/curios/harmony-connector/native/src/hdc/screen_daemon.rs deleted file mode 100644 index 97f3f4d..0000000 --- a/curios/harmony-connector/native/src/hdc/screen_daemon.rs +++ /dev/null @@ -1,338 +0,0 @@ -//! Device-agent startup is complete only when its process and Unix socket exist. -//! Candidate versions are official ABI variants, not HarmonyOS product versions. -use super::screen_agent::{self, Rpc}; -use super::screen_lease::{self, Lease, SOCKET}; -use super::*; -use sha2::{Digest, Sha256}; -use std::{collections::VecDeque, io::Write, net::TcpListener, time::Instant}; - -const CONTROL: &[u8] = - include_bytes!("../../../../../vendor/harmony-connector/hypium/uitest_agent_v1.2.3.so"); -const VIDEO_SOCKET: &str = "localabstract:scrcpy_grpc_socket"; -const VIDEO_ARGS: &str = - "-m 0 -scale 2 -frameRate 60 -bitRate 8000000 -iFrameInterval 1000 -screenId 0"; -macro_rules! recorder { - ($version:literal) => { - ( - $version, - include_bytes!(concat!( - "../../../../../vendor/harmony-connector/hypium/libscrcpy_server_unix_", - $version, - ".z.so" - )) as &'static [u8], - ) - }; -} -const VIDEOS: &[(&str, &[u8])] = &[ - recorder!("6.6-20260629"), - recorder!("6.6-20260418"), - recorder!("6.5-20260313"), - recorder!("6.4-20260113"), - recorder!("6.3.1-20260113"), -]; - -pub(super) struct Resources { - pub tool: Tool, - pub target: String, - files: Vec, - forwards: Vec<(String, String)>, - lease: Lease, - video: Option<(usize, String)>, - profile: PathBuf, - candidates: VecDeque, -} -impl Resources { - pub fn open(tool: Tool, target: String) -> Result { - tool.connected(&target)?; - let root = env::var_os("HOME") - .map(PathBuf::from) - .unwrap_or_else(env::temp_dir) - .join("Library/HarmonyConnector/sessions"); - let lease = Lease::acquire(&root, &tool, &target)?; - let existing = screen_agent::existing_agents(&tool, &target)?; - for socket in [SOCKET, VIDEO_SOCKET] { - if !screen_lease::forwards_socket(&tool, &target, socket)?.is_empty() { - return Err(screen_lease::busy()); - } - } - for (pid, cmdline) in existing { - screen_agent::terminate_agent(&tool, &target, &pid, &cmdline)?; - } - let profile = root - .join("profiles") - .join(format!("{:x}.json", Sha256::digest(target.as_bytes()))); - let preferred = fs::read(&profile) - .ok() - .filter(|bytes| bytes.len() <= 4096) - .and_then(|bytes| serde_json::from_slice::(&bytes).ok()) - .and_then(|value| { - VIDEOS - .iter() - .position(|(version, _)| value["videoAgent"] == *version) - }); - let candidates = preferred - .into_iter() - .chain((0..VIDEOS.len()).filter(|index| Some(*index) != preferred)) - .collect(); - Ok(Self { - tool, - target, - files: Vec::new(), - forwards: Vec::new(), - lease, - video: None, - profile, - candidates, - }) - } - fn launch(&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, - )?; - // Keep loader errors from the daemon; a successful hdc shell exit is not readiness. - let result = self.tool.run_output(&["-t", &self.target, "shell", - &format!("uitest start-daemon singleness --extension-name {name} {arguments} > /data/local/tmp/{name}.log 2>&1")], 5); - match result { - Ok(_) => Ok(name), - Err(e) if e.code == "hdc.command_failed" => Ok(name), - Err(e) => Err(e), - } - } - fn ready(&self, name: &str, socket: &str) -> Result { - let process = screen_agent::existing_agents(&self.tool, &self.target)? - .iter() - .any(|(_, cmdline)| cmdline.split('\0').any(|arg| arg == name)); - if !process { - return Ok(false); - } - let sockets = self - .tool - .run(&["-t", &self.target, "shell", "cat /proc/net/unix"], 2)?; - let name = socket.strip_prefix("localabstract:").unwrap(); - Ok(sockets.lines().any(|line| { - line.split_whitespace() - .last() - .is_some_and(|path| path.trim_start_matches('@') == name) - })) - } - fn wait_ready(&self, name: &str, socket: &str, version: &str) -> Result<()> { - let deadline = Instant::now() + Duration::from_secs(3); - loop { - if self.tool.cancel.load(Ordering::Acquire) { - return Err(Fault::new("operation.cancelled", "投屏连接已取消。")); - } - if self.ready(name, socket)? { - return Ok(()); - } - if Instant::now() >= deadline { - let output = self - .tool - .run_output( - &[ - "-t", - &self.target, - "shell", - &format!("cat /data/local/tmp/{name}.log"), - ], - 2, - ) - .unwrap_or_default(); - let detail: String = output - .chars() - .filter(|c| !c.is_control() || matches!(c, '\n' | '\t')) - .take(800) - .collect(); - return Err(Fault::new( - "screen.agent_start_failed", - format!("设备投屏组件 {version} 未启动监听。{}", detail.trim()), - )); - } - thread::sleep(Duration::from_millis(100)); - } - } - fn forward(&mut self, socket: &str) -> Result { - let listener = TcpListener::bind(("127.0.0.1", 0)).map_err(|e| { - Fault::new( - "screen.forward_failed", - format!("无法分配本机投屏端口:{e}"), - ) - })?; - let port = listener - .local_addr() - .map_err(|e| Fault::new("screen.forward_failed", e.to_string()))? - .port(); - drop(listener); - let local = format!("tcp:{port}"); - self.forwards.push((local.clone(), socket.into())); - self.lease.record_forwards(&self.forwards)?; - self.tool - .run(&["-t", &self.target, "fport", &local, socket], 3)?; - if !screen_lease::forwards_socket(&self.tool, &self.target, socket)?.contains(&local) { - return Err(Fault::new( - "screen.forward_failed", - format!("HDC 未建立投屏转发:{socket}。"), - )); - } - Ok(port) - } - fn remove_forward(&mut self, socket: &str) -> Result<()> { - let registered = screen_lease::forwards_socket(&self.tool, &self.target, socket)?; - for (local, remote) in self.forwards.iter().filter(|(_, remote)| remote == socket) { - if registered.contains(local) { - self.tool - .run(&["-t", &self.target, "fport", "rm", local, remote], 3)?; - if screen_lease::forwards_socket(&self.tool, &self.target, remote)?.contains(local) - { - return Err(Fault::new( - "screen.forward_failed", - "HDC 未能释放旧的投屏转发。", - )); - } - } - } - self.forwards.retain(|(_, remote)| remote != socket); - self.lease.record_forwards(&self.forwards) - } - fn remove_agent(&mut self, name: &str) -> Result<()> { - // Only the exact file generated by this session can be terminated. - for (pid, cmdline) in screen_agent::processes(&self.tool, &self.target)? { - if cmdline.split('\0').any(|arg| arg == name) { - screen_agent::terminate_agent(&self.tool, &self.target, &pid, &cmdline)?; - } - } - self.tool.run( - &[ - "-t", - &self.target, - "shell", - &format!("rm -f /data/local/tmp/{name} /data/local/tmp/{name}.log"), - ], - 2, - )?; - self.files.retain(|file| file != name); - Ok(()) - } - pub fn control(&mut self) -> Result<(u16, Rpc)> { - let name = self.launch(CONTROL, "")?; - self.wait_ready(&name, SOCKET, "UITest 1.2.3")?; - let mut last = None; - for _ in 0..3 { - self.remove_forward(SOCKET)?; - let port = self.forward(SOCKET)?; - let deadline = Instant::now() + Duration::from_secs(1); - loop { - match Rpc::connect(port).and_then(|mut rpc| { - rpc.create_driver()?; - Ok(rpc) - }) { - Ok(rpc) => return Ok((port, rpc)), - Err(e) if e.code == "screen.remote_error" => return Err(e), - Err(e) => last = Some(e), - } - if Instant::now() >= deadline { - break; - } - thread::sleep(Duration::from_millis(100)); - } - } - Err(Fault::new( - "screen.control_connect_failed", - format!( - "设备控制组件已启动,但 HDC 控制通道无法连接:{}", - last.unwrap().message - ), - )) - } - pub fn video(&mut self) -> Result { - let mut failures = Vec::new(); - while let Some(index) = self.candidates.pop_front() { - let (version, bytes) = VIDEOS[index]; - let name = self.launch(bytes, VIDEO_ARGS)?; - match self.wait_ready(&name, VIDEO_SOCKET, version) { - Ok(()) => { - self.video = Some((index, name)); - return self.forward(VIDEO_SOCKET); - } - Err(error) if error.code == "screen.agent_start_failed" => { - failures.push(error.message); - self.remove_agent(&name)?; - } - Err(error) => return Err(error), - } - } - Err(Fault::new( - "screen.video_agent_unavailable", - format!("官方视频组件均未能在设备上启动。{}", failures.join("\n")), - )) - } - pub fn reconnect_video(&mut self) -> Result { - self.remove_forward(VIDEO_SOCKET)?; - let (_, name) = self - .video - .clone() - .ok_or_else(|| Fault::new("screen.closed", "视频组件已停止。"))?; - if self.ready(&name, VIDEO_SOCKET)? { - return self.forward(VIDEO_SOCKET); - } - self.remove_agent(&name)?; - self.video = None; - self.video() - } - pub fn remember_video(&self) -> Result<()> { - let Some((index, _)) = &self.video else { - return Ok(()); - }; - let directory = self.profile.parent().unwrap(); - fs::create_dir_all(directory)?; - let mut file = tempfile::NamedTempFile::new_in(directory)?; - file.write_all(&serde_json::to_vec(&json!({"videoAgent":VIDEOS[*index].0})).unwrap())?; - file.persist(&self.profile) - .map_err(|e| Fault::from(e.error))?; - Ok(()) - } - pub fn close(&mut self) -> Result<()> { - if self.files.is_empty() && self.forwards.is_empty() { - return Ok(()); - } - // Cleanup must remain possible when the caller cancelled startup. - self.tool.cancel = Arc::new(AtomicBool::new(false)); - let mut error = None; - for socket in [VIDEO_SOCKET, SOCKET] { - if let Err(e) = self.remove_forward(socket) { - error.get_or_insert(e); - } - } - for name in self.files.clone() { - if let Err(e) = self.remove_agent(&name) { - error.get_or_insert(e); - } - } - error.map_or(Ok(()), Err) - } -} -impl Drop for Resources { - fn drop(&mut self) { - let _ = self.close(); - } -} diff --git a/curios/harmony-connector/native/src/hdc/screen_input.rs b/curios/harmony-connector/native/src/hdc/screen_input.rs deleted file mode 100644 index 807c255..0000000 --- a/curios/harmony-connector/native/src/hdc/screen_input.rs +++ /dev/null @@ -1,190 +0,0 @@ -use crate::model::{Fault, Result}; -use serde::Deserialize; -use serde_json::{json, Value}; -use std::collections::HashSet; - -#[derive(Deserialize)] -#[serde( - tag = "kind", - rename_all = "camelCase", - rename_all_fields = "camelCase", - deny_unknown_fields -)] -pub(super) enum Input { - Click { - x: u32, - y: u32, - }, - DoubleClick { - x: u32, - y: u32, - }, - LongClick { - x: u32, - y: u32, - }, - Swipe { - x: u32, - y: u32, - to_x: u32, - to_y: u32, - speed: u32, - }, - Drag { - x: u32, - y: u32, - to_x: u32, - to_y: u32, - speed: u32, - }, - Text { - text: String, - }, - Key { - key: Key, - }, -} -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -pub(super) enum Key { - Back, - Home, - Enter, - Backspace, - Delete, - Tab, - Up, - Down, - Left, - Right, - Escape, - VolumeUp, - VolumeDown, -} -impl Key { - fn code(&self) -> &'static str { - match self { - Self::Back => "Back", - Self::Home => "Home", - Self::Enter => "2054", - Self::Backspace => "2055", - Self::Delete => "2071", - Self::Tab => "2049", - Self::Up => "2012", - Self::Down => "2013", - Self::Left => "2014", - Self::Right => "2015", - Self::Escape => "2070", - Self::VolumeUp => "16", - Self::VolumeDown => "17", - } - } -} -pub(super) fn capabilities(help: &str) -> HashSet { - let words: HashSet<&str> = help.split(|c: char| !c.is_ascii_alphanumeric()).collect(); - [ - "click", - "doubleClick", - "longClick", - "swipe", - "drag", - "text", - "keyEvent", - ] - .into_iter() - .filter(|v| words.contains(v)) - .map(String::from) - .collect() -} -impl Input { - pub fn rpc(&self) -> Option<(&'static str, Value)> { - Some(match self { - Self::Click { x, y } => ("Driver.click", json!([x, y])), - Self::DoubleClick { x, y } => ("Driver.doubleClick", json!([x, y])), - Self::LongClick { x, y } => ("Driver.longClick", json!([x, y])), - Self::Swipe { - x, - y, - to_x, - to_y, - speed, - } => ("Driver.swipe", json!([x, y, to_x, to_y, speed])), - Self::Drag { - x, - y, - to_x, - to_y, - speed, - } => ("Driver.drag", json!([x, y, to_x, to_y, speed])), - Self::Key { key: Key::Home } => ("Driver.pressHome", json!([])), - Self::Key { key: Key::Back } => ("Driver.pressBack", json!([])), - Self::Key { key } => ( - "Driver.triggerKey", - json!([key.code().parse::().ok()?]), - ), - Self::Text { .. } => return None, - }) - } - pub fn command(&self, width: u32, height: u32, supported: &HashSet) -> Result { - let point = |x, y| { - if x < width && y < height { - Ok(format!("{x} {y}")) - } else { - Err(Fault::new("screen.outside", "操作位置超出当前屏幕。")) - } - }; - let (name, args) = match self { - Self::Click { x, y } => ("click", point(*x, *y)?), - Self::DoubleClick { x, y } => ("doubleClick", point(*x, *y)?), - Self::LongClick { x, y } => ("longClick", point(*x, *y)?), - Self::Swipe { - x, - y, - to_x, - to_y, - speed, - } - | Self::Drag { - x, - y, - to_x, - to_y, - speed, - } => { - if !(200..=40_000).contains(speed) { - return Err(Fault::new("screen.invalid_speed", "滑动速度超出支持范围。")); - } - ( - if matches!(self, Self::Drag { .. }) { - "drag" - } else { - "swipe" - }, - format!("{} {} {speed}", point(*x, *y)?, point(*to_x, *to_y)?), - ) - } - Self::Text { text } => { - if text.is_empty() - || text.len() > 4096 - || text - .chars() - .any(|c| c.is_control() && !matches!(c, '\n' | '\t')) - { - return Err(Fault::new( - "screen.invalid_text", - "请输入不超过 4096 字节且不含控制字符的文本。", - )); - } - ("text", format!("'{}'", text.replace('\'', "'\"'\"'"))) - } - Self::Key { key } => ("keyEvent", key.code().into()), - }; - if !supported.contains(name) { - return Err(Fault::new( - "screen.input_unavailable", - "设备未开放此输入操作。", - )); - } - Ok(format!("uitest uiInput {name} {args}")) - } -} diff --git a/curios/harmony-connector/native/src/hdc/screen_lease.rs b/curios/harmony-connector/native/src/hdc/screen_lease.rs deleted file mode 100644 index 26e9062..0000000 --- a/curios/harmony-connector/native/src/hdc/screen_lease.rs +++ /dev/null @@ -1,141 +0,0 @@ -//! A per-device host lock prevents two plugin windows from using the singleton -//! capture service. The journal retains only our forward for crash recovery. -use super::*; -use sha2::{Digest, Sha256}; -use std::{ - fs::{File, OpenOptions}, - io::{Read, Seek, SeekFrom, Write}, - os::{fd::AsRawFd, unix::fs::OpenOptionsExt}, -}; - -pub(super) const SOCKET: &str = "localabstract:uitest_socket"; -pub(super) fn busy() -> Fault { - Fault::new( - "screen.active", - "已有投屏连接正在使用这台设备,请先停止该连接。", - ) -} -pub(super) fn forwards(tool: &Tool, target: &str) -> Result> { - forwards_socket(tool, target, SOCKET) -} -pub(super) fn forwards_socket(tool: &Tool, target: &str, socket: &str) -> Result> { - let output = tool.run(&["-t", target, "fport", "ls"], 3)?; - Ok(output - .lines() - .filter_map(|line| { - let words: Vec<_> = line.split_whitespace().collect(); - if words.len() == 4 - && words[0] == target - && words[2] == socket - && words[3] == "[Forward]" - { - Some(words[1].to_owned()) - } else { - None - } - }) - .collect()) -} -pub(super) struct Lease { - file: File, -} -impl Lease { - pub fn acquire(directory: &Path, tool: &Tool, target: &str) -> Result { - fs::create_dir_all(directory)?; - let path = directory.join(format!("{:x}.json", Sha256::digest(target.as_bytes()))); - let file = OpenOptions::new() - .read(true) - .write(true) - .create(true) - .truncate(false) - .mode(0o600) - .open(path)?; - if unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) } != 0 { - let error = std::io::Error::last_os_error(); - return Err(if error.kind() == std::io::ErrorKind::WouldBlock { - busy() - } else { - error.into() - }); - } - let mut lease = Self { file }; - let mut previous = String::new(); - (&mut lease.file).take(4097).read_to_string(&mut previous)?; - if previous.len() > 4096 { - return Err(Fault::new( - "screen.invalid_session", - "本机投屏会话记录无效。", - )); - } - if let Ok(value) = serde_json::from_str::(&previous) { - let mut forwards = value["forwards"].as_array().cloned().unwrap_or_default(); - if let Some(local) = value["forward"].as_str() { - forwards.push(json!({"local":local,"remote":SOCKET})); - } - for forward in forwards { - let Some(local) = forward["local"].as_str().filter(|v| { - v.strip_prefix("tcp:") - .is_some_and(|p| p.parse::().is_ok_and(|p| p > 0)) - }) else { - continue; - }; - let Some(remote) = forward["remote"] - .as_str() - .filter(|s| [SOCKET, "localabstract:scrcpy_grpc_socket"].contains(s)) - else { - continue; - }; - if forwards_socket(tool, target, remote)? - .iter() - .any(|active| active == local) - { - tool.run(&["-t", target, "fport", "rm", local, remote], 3)?; - if forwards_socket(tool, target, remote)? - .iter() - .any(|active| active == local) - { - return Err(busy()); - } - } - } - } - lease.record(None)?; - Ok(lease) - } - pub fn record(&mut self, local: Option<&str>) -> Result<()> { - self.record_forwards( - &local - .into_iter() - .map(|local| (local.to_owned(), SOCKET.to_owned())) - .collect::>(), - ) - } - pub fn record_forwards(&mut self, forwards: &[(String, String)]) -> Result<()> { - let bytes = serde_json::to_vec(&json!({"forwards":forwards.iter().map(|(local, remote)| json!({"local":local,"remote":remote})).collect::>() })).unwrap(); - self.file.seek(SeekFrom::Start(0))?; - self.file.write_all(&bytes)?; - self.file.set_len(bytes.len() as u64)?; - self.file.sync_data()?; - Ok(()) - } -} - -#[cfg(test)] -mod tests { - use super::*; - #[test] - fn only_one_owner_per_device_even_across_plugin_instances() { - let directory = tempfile::tempdir().unwrap(); - let tool = Tool { - path: "/unused".into(), - cancel: Arc::new(AtomicBool::new(false)), - }; - let first = Lease::acquire(directory.path(), &tool, "phone").unwrap(); - assert!( - matches!(Lease::acquire(directory.path(),&tool,"phone"),Err(e) if e.code=="screen.active") - ); - assert!(Lease::acquire(directory.path(), &tool, "another-phone").is_ok()); - drop(first); - assert!(Lease::acquire(directory.path(), &tool, "phone").is_ok()); - } -} diff --git a/curios/harmony-connector/native/src/hdc/screen_protocol.rs b/curios/harmony-connector/native/src/hdc/screen_protocol.rs deleted file mode 100644 index 21aa9ed..0000000 --- a/curios/harmony-connector/native/src/hdc/screen_protocol.rs +++ /dev/null @@ -1,243 +0,0 @@ -//! Hypium agent wire format. JPEG segments are parsed by length, so EXIF/ICC -//! bytes containing an EOI marker cannot truncate a frame. -use crate::model::{Fault, Result}; -use serde_json::Value; - -const HEAD: &[u8] = b"_uitestkit_rpc_message_head_"; -const TAIL: &[u8] = b"_uitestkit_rpc_message_tail_"; -pub const MAX_FRAME: usize = 2 * 1024 * 1024; -const MAX_JSON: usize = 64 * 1024; - -fn invalid() -> Fault { - Fault::new( - "screen.invalid_stream", - "设备返回了无效的投屏数据,连接已停止。", - ) -} -pub fn request(body: &Value, sequence: u32) -> Vec { - let body = serde_json::to_vec(body).unwrap(); - let mut bytes = Vec::with_capacity(HEAD.len() + body.len() + TAIL.len() + 8); - bytes.extend_from_slice(HEAD); - bytes.extend_from_slice(&sequence.to_be_bytes()); - bytes.extend_from_slice(&(body.len() as u32).to_be_bytes()); - bytes.extend_from_slice(&body); - bytes.extend_from_slice(TAIL); - bytes -} -#[derive(Debug)] -pub enum Packet { - Json(Value), - Jpeg(Vec), -} -#[derive(Default)] -pub struct Decoder { - bytes: Vec, -} -impl Decoder { - pub fn push(&mut self, bytes: &[u8]) -> Result<()> { - if self.bytes.len() + bytes.len() > MAX_FRAME + 65536 { - return Err(invalid()); - } - self.bytes.extend_from_slice(bytes); - Ok(()) - } - pub fn next(&mut self) -> Result> { - let whitespace = self - .bytes - .iter() - .take_while(|b| matches!(b, b'\n' | b'\r')) - .count(); - self.bytes.drain(..whitespace); - if self.bytes.is_empty() { - return Ok(None); - } - let (start, length, consumed) = if self.bytes.starts_with(HEAD) { - let start = HEAD.len() + 8; - if self.bytes.len() < start { - return Ok(None); - } - let length = - u32::from_be_bytes(self.bytes[start - 4..start].try_into().unwrap()) as usize; - if length == 0 || length > MAX_FRAME { - return Err(invalid()); - } - let consumed = start + length + TAIL.len(); - if self.bytes.len() < consumed { - return Ok(None); - } - if &self.bytes[start + length..consumed] != TAIL { - return Err(invalid()); - } - (start, length, consumed) - } else if self.bytes.starts_with(b"{") { - let Some(end) = self.bytes.iter().position(|b| *b == b'\n') else { - if self.bytes.len() > MAX_JSON { - return Err(invalid()); - } - return Ok(None); - }; - (0, end, end + 1) - } else if self.bytes.starts_with(b"\xff\xd8") { - let Some(length) = jpeg_length(&self.bytes)? else { - if self.bytes.len() > MAX_FRAME { - return Err(invalid()); - } - return Ok(None); - }; - (0, length, length) - } else if HEAD.starts_with(&self.bytes) || b"\xff\xd8".starts_with(&self.bytes) { - return Ok(None); - } else { - return Err(invalid()); - }; - if length > MAX_FRAME { - return Err(invalid()); - } - let body = &self.bytes[start..start + length]; - let packet = if body.starts_with(b"\xff\xd8") { - if jpeg_length(body)? != Some(body.len()) { - return Err(invalid()); - } - Packet::Jpeg(body.to_vec()) - } else { - if body.len() > MAX_JSON { - return Err(invalid()); - } - Packet::Json(serde_json::from_slice(body).map_err(|_| invalid())?) - }; - self.bytes.drain(..consumed); - Ok(Some(packet)) - } -} -fn jpeg_length(bytes: &[u8]) -> Result> { - let mut position = 2; - let mut entropy = false; - loop { - if entropy { - while position < bytes.len() && bytes[position] != 0xff { - position += 1; - } - } - if position >= bytes.len() { - return Ok(None); - } - if bytes[position] != 0xff { - return Err(invalid()); - } - while position < bytes.len() && bytes[position] == 0xff { - position += 1; - } - if position >= bytes.len() { - return Ok(None); - } - let marker = bytes[position]; - position += 1; - if entropy && (marker == 0 || (0xd0..=0xd7).contains(&marker)) { - continue; - } - if marker == 0xd9 { - return Ok(Some(position)); - } - if marker == 0 || marker == 0xd8 { - return Err(invalid()); - } - if marker == 1 { - continue; - } - if bytes.len() < position + 2 { - return Ok(None); - } - let length = u16::from_be_bytes(bytes[position..position + 2].try_into().unwrap()) as usize; - if length < 2 { - return Err(invalid()); - } - if bytes.len() < position + length { - return Ok(None); - } - position += length; - entropy = marker == 0xda; - } -} - -#[cfg(test)] -mod tests { - use super::*; - use image::codecs::jpeg::JpegEncoder; - use serde_json::json; - fn jpeg() -> Vec { - let mut bytes = Vec::new(); - JpegEncoder::new(&mut bytes) - .encode_image(&image::RgbImage::new(40, 80)) - .unwrap(); - bytes.splice(2..2, [0xff, 0xe1, 0, 6, 0xff, 0xd9, 0, 0]); - bytes - } - #[test] - fn decodes_fragmented_mixed_responses_and_real_jpegs_without_truncating_exif() { - let jpeg = jpeg(); - let bytes = [ - b"{\"result\":true}\n".as_slice(), - &jpeg, - b"\n", - &jpeg, - b"\n", - ] - .concat(); - for chunk_size in [1, 7, 512, 65536] { - let mut decoder = Decoder::default(); - let mut images = 0; - let mut responses = 0; - for chunk in bytes.chunks(chunk_size) { - decoder.push(chunk).unwrap(); - while let Some(packet) = decoder.next().unwrap() { - match packet { - Packet::Json(v) => { - assert_eq!(v["result"], true); - responses += 1; - } - Packet::Jpeg(bytes) => { - assert_eq!(bytes, jpeg); - assert_eq!(image::load_from_memory(&bytes).unwrap().height(), 80); - images += 1; - } - } - } - } - assert_eq!((responses, images), (1, 2)); - } - } - #[test] - fn rejects_oversized_malformed_and_corrupt_framed_data() { - let mut decoder = Decoder::default(); - assert!(decoder.push(&vec![0; MAX_FRAME + 65537]).is_err()); - for bytes in [ - b"not a frame".to_vec(), - b"{invalid}\n".to_vec(), - b"\xff\xd8\xff\xe1\0\0".to_vec(), - [ - HEAD, - &0u32.to_be_bytes(), - &((MAX_FRAME + 1) as u32).to_be_bytes(), - ] - .concat(), - ] { - let mut decoder = Decoder::default(); - decoder.push(&bytes).unwrap(); - assert!(decoder.next().is_err()); - } - let mut bytes = request(&json!({"result":true}), 3); - *bytes.last_mut().unwrap() = b'!'; - let mut decoder = Decoder::default(); - decoder.push(&bytes).unwrap(); - assert!(decoder.next().is_err()); - } - #[test] - fn accepts_framed_json_and_waits_for_partial_data() { - let bytes = request(&json!({"result":{"x":1260,"y":2844}}), 7); - let mut decoder = Decoder::default(); - decoder.push(&bytes[..bytes.len() - 1]).unwrap(); - assert!(decoder.next().unwrap().is_none()); - decoder.push(&bytes[bytes.len() - 1..]).unwrap(); - assert!(matches!(decoder.next().unwrap(), Some(Packet::Json(_)))); - } -} diff --git a/curios/harmony-connector/native/src/hdc/screen_recorder.rs b/curios/harmony-connector/native/src/hdc/screen_recorder.rs deleted file mode 100644 index a3498e1..0000000 --- a/curios/harmony-connector/native/src/hdc/screen_recorder.rs +++ /dev/null @@ -1,276 +0,0 @@ -//! Owns the official video and touch daemons as one USB debugging session. -use super::screen_agent::Rpc; -use super::screen_daemon::Resources; -use super::*; -use std::{ - collections::{HashSet, VecDeque}, - sync::Condvar, - time::Instant, -}; - -#[derive(Clone, Copy)] -pub(super) struct Dimensions { - pub width: u32, - pub height: u32, - pub geometry: u64, -} -struct Event { - value: Value, - time: Instant, -} -struct Queue { - events: Mutex>, - changed: Condvar, -} -pub(super) struct Recorder { - pub cancel: Arc, - pub port: u16, - pub keyframe: AtomicBool, - pub ready: AtomicBool, - pub error: Mutex>, - dimensions: Arc>, - queue: Arc, - worker: Mutex>>, - resources: Mutex>, -} -impl Recorder { - pub fn open(tool: Tool, target: String, progress: impl Fn(&str)) -> Result> { - progress("正在连接设备…"); - let mut resources = Resources::open(tool, target)?; - progress("正在准备鼠标控制…"); - let (control_port, mut control) = resources.control()?; - let (width, height) = control.dimensions()?; - progress("正在连接手机画面…"); - let port = resources.video()?; - let tool = Tool { - path: resources.tool.path.clone(), - cancel: resources.tool.cancel.clone(), - }; - 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 cancel = resources.tool.cancel.clone(); - let owner = Arc::new(Self { - cancel, - port, - keyframe: AtomicBool::new(false), - ready: 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 mark_ready(&self) { - if !self.ready.swap(true, Ordering::AcqRel) { - if let Some(resources) = self.resources.lock().unwrap().as_ref() { - // This is an optional startup optimization; a cache write must not stop video. - let _ = resources.remember_video(); - } - } - } - pub fn reconnect_video(&self) -> Result { - if self.cancel.load(Ordering::Acquire) { - return Err(Fault::new("screen.closed", "投屏已停止。")); - } - self.resources - .lock() - .unwrap() - .as_mut() - .ok_or_else(|| Fault::new("screen.closed", "投屏已停止。"))? - .reconnect_video() - } - pub fn dimensions(&self) -> Dimensions { - *self.dimensions.lock().unwrap() - } - pub fn push(&self, value: Value) -> Result<()> { - if self.cancel.load(Ordering::Acquire) { - return Err(Fault::new("screen.closed", "投屏已停止。")); - } - let mut queue = self.queue.events.lock().unwrap(); - if value["input"]["kind"] == "touchMove" - && queue - .back() - .is_some_and(|e| e.value["input"]["kind"] == "touchMove") - { - queue.pop_back(); - } - if queue.len() >= 64 { - self.cancel.store(true, Ordering::Release); - return Err(Fault::new( - "screen.input_backlog", - "手机控制响应过慢,请重新连接。", - )); - } - queue.push_back(Event { - value, - time: Instant::now(), - }); - self.queue.changed.notify_one(); - Ok(()) - } - pub fn stop(&self) -> Result<()> { - self.cancel.store(true, Ordering::Release); - self.queue.changed.notify_all(); - if let Some(worker) = self.worker.lock().unwrap().take() { - let _ = worker.join(); - } - if let Some(mut resources) = self.resources.lock().unwrap().take() { - resources.close()?; - } - Ok(()) - } -} -impl Drop for Recorder { - fn drop(&mut self) { - let _ = self.stop(); - } -} - -fn control_loop( - rpc: &mut Rpc, - port: u16, - tool: &Tool, - target: &str, - supported: &HashSet, - queue: &Queue, - cancel: &AtomicBool, - dimensions: &Mutex, -) -> Result<()> { - let mut touch = None; - let mut heartbeat = Instant::now(); - let result = (|| { - loop { - if cancel.load(Ordering::Acquire) { - return Ok(()); - } - if heartbeat.elapsed() >= Duration::from_secs(1) { - let (width, height) = rpc.dimensions()?; - let mut current = dimensions.lock().unwrap(); - if (current.width, current.height) != (width, height) { - if let Some((x, y)) = touch.take() { - rpc.gesture("touchUp", x, y)?; - } - *current = Dimensions { - width, - height, - geometry: current.geometry + 1, - }; - } - heartbeat = Instant::now(); - } - let mut events = queue.events.lock().unwrap(); - if events.is_empty() { - events = queue - .changed - .wait_timeout(events, Duration::from_millis(50)) - .unwrap() - .0; - } - let event = events.pop_front(); - drop(events); - let Some(event) = event else { continue }; - let kind = event.value["input"]["kind"].as_str().unwrap_or(""); - // Release always uses the last acknowledged point, even after rotation or stale input. - if kind == "touchUp" { - if let Some((x, y)) = touch.take() { - rpc.gesture("touchUp", x, y)?; - } - continue; - } - let current = *dimensions.lock().unwrap(); - if event.value["geometry"] != current.geometry - || event.time.elapsed() > Duration::from_secs(1) - { - if let Some((x, y)) = touch.take() { - rpc.gesture("touchUp", x, y)?; - } - continue; - } - if ["touchDown", "touchMove"].contains(&kind) { - let point = |name, limit| { - event.value["input"][name] - .as_u64() - .filter(|n| *n < limit as u64) - .map(|n| n as u32) - }; - let (Some(x), Some(y)) = (point("x", current.width), point("y", current.height)) - else { - continue; - }; - if kind == "touchMove" && touch.is_none() { - continue; - } - if kind == "touchDown" { - if let Some((x, y)) = touch.take() { - rpc.gesture("touchUp", x, y)?; - } - } - // Track before exchanging: the device may accept down even if its reply is lost. - touch = Some((x, y)); - rpc.gesture(kind, x, y)?; - } else { - let input: super::screen_input::Input = parse(event.value["input"].clone())?; - let command = input.command(current.width, current.height, supported)?; - if let Some((api, args)) = input.rpc() { - rpc.driver(api, args)?; - } else { - tool.shell(target, &command)?; - } - } - } - })(); - if let Some((x, y)) = touch { - if rpc.gesture("touchUp", x, y).is_err() { - // A timed-out RPC socket is intentionally discarded; release on a fresh channel. - if let Ok(mut release) = Rpc::connect(port) { - let _ = release.gesture("touchUp", x, y); - } - } - } - result -} diff --git a/curios/harmony-connector/native/src/hdc/screen_tests.rs b/curios/harmony-connector/native/src/hdc/screen_tests.rs deleted file mode 100644 index 2ca0a14..0000000 --- a/curios/harmony-connector/native/src/hdc/screen_tests.rs +++ /dev/null @@ -1,452 +0,0 @@ -use super::*; -use base64::{engine::general_purpose::STANDARD, Engine}; - -fn phone() -> (tempfile::TempDir, Arc) { - let (directory, manager) = fixture(false); - fs::write( - directory.path().join("hdc"), - include_str!("../../tests/hypium_device.py"), - ) - .unwrap(); - frame_file(&directory, 600, 1200, [30, 80, 120]); - (directory, manager) -} -fn frame_file(directory: &tempfile::TempDir, width: u32, height: u32, rgb: [u8; 3]) { - fs::write( - directory.path().join("hdc.dimensions"), - serde_json::to_vec(&json!({"x":width,"y":height})).unwrap(), - ) - .unwrap(); - let temporary = directory.path().join("frame.jpg"); - image::RgbImage::from_pixel(width / 2, height / 2, image::Rgb(rgb)) - .save(&temporary) - .unwrap(); - fs::rename(temporary, directory.path().join("hdc.jpg")).unwrap(); -} -fn start(manager: &Arc) -> Value { - let opened = manager - .call("screenStart", json!({"target":"phone"})) - .unwrap(); - assert_eq!(opened["control"], false); - json!({"session":opened["session"]}) -} -fn input(id: &Value, frame: &Value, event: Value) -> Value { - json!({"session":id["session"],"sequence":frame["sequence"],"geometry":frame["geometry"],"input":event}) -} -fn enable(manager: &Arc, id: &Value, frame: &Value) { - manager - .call( - "screenControl", - json!({"session":id["session"],"enabled":true,"geometry":frame["geometry"]}), - ) - .unwrap(); -} - -#[test] -fn shows_real_decodable_frames_and_saves_latest_without_overwriting() { - let (directory, manager) = phone(); - let id = start(&manager); - let first = manager.call("screenFrame", id.clone()).unwrap(); - assert_eq!( - (first["width"].as_u64(), first["height"].as_u64()), - (Some(600), Some(1200)) - ); - let decode = |v: &Value| { - STANDARD - .decode( - v["dataUrl"] - .as_str() - .unwrap() - .strip_prefix("data:image/jpeg;base64,") - .unwrap(), - ) - .unwrap() - }; - let jpeg = decode(&first); - assert_eq!(image::load_from_memory(&jpeg).unwrap().height(), 600); - frame_file(&directory, 600, 1200, [220, 60, 30]); - let next = manager - .call( - "screenFrame", - json!({"session":id["session"],"after":first["sequence"]}), - ) - .unwrap(); - assert!(next["sequence"].as_u64() > first["sequence"].as_u64()); - assert_ne!(decode(&next), jpeg); - let destination = tempfile::tempdir().unwrap(); - fs::write(destination.path().join("keep.jpg"), b"existing").unwrap(); - let saved = manager - .call( - "screenSave", - json!({"session":id["session"],"directory":destination.path()}), - ) - .unwrap(); - assert_eq!( - fs::read(saved["path"].as_str().unwrap()).unwrap(), - decode(&next) - ); - assert_eq!( - fs::read(destination.path().join("keep.jpg")).unwrap(), - b"existing" - ); - manager.call("screenStop", id).unwrap(); -} - -#[test] -fn only_explicit_control_of_current_geometry_can_send_input() { - let (directory, manager) = phone(); - let id = start(&manager); - let frame = manager.call("screenFrame", id.clone()).unwrap(); - let request = input(&id, &frame, json!({"kind":"click","x":40,"y":80})); - assert_eq!( - manager - .call("screenInput", request.clone()) - .unwrap_err() - .code, - "screen.read_only" - ); - enable(&manager, &id, &frame); - assert_eq!( - manager.call("screenInput", request.clone()).unwrap()["executed"], - true - ); - frame_file(&directory, 1200, 600, [30, 80, 120]); - let rotated = manager - .call( - "screenFrame", - json!({"session":id["session"],"after":frame["sequence"]}), - ) - .unwrap(); - assert_eq!(rotated["control"], false); - enable(&manager, &id, &rotated); - assert_eq!( - manager.call("screenInput", request).unwrap_err().code, - "screen.stale_frame" - ); - let calls = fs::read_to_string(directory.path().join("hdc.calls")).unwrap(); - assert_eq!( - calls - .lines() - .filter(|line| line.contains("Driver.click")) - .count(), - 1 - ); -} - -#[test] -fn rejects_out_of_bounds_unsupported_and_old_events_without_sending_them() { - let (directory, manager) = phone(); - let id = start(&manager); - let first = manager.call("screenFrame", id.clone()).unwrap(); - enable(&manager, &id, &first); - for event in [ - json!({"kind":"click","x":600,"y":10}), - json!({"kind":"click","x":-1,"y":10}), - json!({"kind":"shell","command":"reboot"}), - json!({"kind":"swipe","x":0,"y":0,"toX":1,"toY":1,"speed":1}), - ] { - assert!(manager - .call("screenInput", input(&id, &first, event)) - .is_err()); - } - let mut after = first["sequence"].clone(); - for red in [110, 120] { - frame_file(&directory, 600, 1200, [red, 80, 120]); - after = manager - .call( - "screenFrame", - json!({"session":id["session"],"after":after}), - ) - .unwrap()["sequence"] - .clone(); - } - assert_eq!( - manager - .call( - "screenInput", - input(&id, &first, json!({"kind":"key","key":"home"})) - ) - .unwrap_err() - .code, - "screen.stale_frame" - ); - let calls = fs::read_to_string(directory.path().join("hdc.calls")).unwrap(); - assert!(!calls - .lines() - .any(|line| line.contains("uiInput ") && !line.ends_with("help"))); -} - -#[test] -fn text_remains_one_remote_shell_argument_including_chinese_quotes_and_metacharacters() { - let (directory, manager) = phone(); - let id = start(&manager); - let frame = manager.call("screenFrame", id.clone()).unwrap(); - enable(&manager, &id, &frame); - let content = "你好,daniel's phone; $(echo not-executed) `echo not-executed`\n下一行"; - manager - .call( - "screenInput", - input(&id, &frame, json!({"kind":"text","text":content})), - ) - .unwrap(); - let calls = fs::read_to_string(directory.path().join("hdc.calls")).unwrap(); - let command = calls - .split("-t phone shell uitest uiInput text ") - .nth(1) - .unwrap() - .trim_end(); - // Parse the generated remote argument with an actual POSIX shell; it must remain literal data. - let parsed = std::process::Command::new("/bin/sh") - .arg("-c") - .arg(format!("printf '%s' {command}")) - .output() - .unwrap(); - assert!(parsed.status.success()); - assert_eq!(String::from_utf8(parsed.stdout).unwrap(), content); -} - -#[test] -fn stop_cancels_pending_capture_and_old_ids_cannot_control_a_restarted_session() { - let (directory, manager) = phone(); - let id = start(&manager); - fs::write(directory.path().join("hdc.slow"), b"").unwrap(); - let capturing = manager.clone(); - let pending_id = id.clone(); - let pending = thread::spawn(move || capturing.call("screenFrame", pending_id)); - let deadline = Instant::now() + Duration::from_secs(3); - while !directory.path().join("hdc.capturing").exists() { - assert!(Instant::now() < deadline); - thread::sleep(Duration::from_millis(10)); - } - let stopped = Instant::now(); - manager.call("screenStop", id.clone()).unwrap(); - assert!(stopped.elapsed() < Duration::from_millis(500)); - assert!(pending.join().unwrap().is_err()); - assert!(stopped.elapsed() < Duration::from_secs(2)); - fs::remove_file(directory.path().join("hdc.slow")).unwrap(); - let next = start(&manager); - assert_ne!(id, next); - assert_eq!( - manager.call("screenFrame", id).unwrap_err().code, - "screen.closed" - ); - assert!(manager.call("screenFrame", next).is_ok()); -} - -#[test] -fn invalid_capture_closes_the_session_and_remote_input_failure_disables_control() { - let (directory, manager) = phone(); - let id = start(&manager); - let frame = manager.call("screenFrame", id.clone()).unwrap(); - enable(&manager, &id, &frame); - fs::write(directory.path().join("hdc.fail-input"), b"").unwrap(); - assert!(manager - .call( - "screenInput", - input(&id, &frame, json!({"kind":"key","key":"back"})) - ) - .is_err()); - assert_eq!( - manager.call("screenFrame", id.clone()).unwrap()["control"], - false - ); - fs::write(directory.path().join("hdc.jpg"), b"success but no image").unwrap(); - assert_eq!( - manager - .call( - "screenFrame", - json!({"session":id["session"],"after":frame["sequence"]}) - ) - .unwrap_err() - .code, - "screen.invalid_stream" - ); - assert_eq!( - manager - .call( - "screenControl", - json!({"session":id["session"],"enabled":true}) - ) - .unwrap_err() - .code, - "screen.closed" - ); -} - -#[test] -fn static_stream_remains_connected_without_inventing_frames() { - let (_directory, manager) = phone(); - let id = start(&manager); - let first = manager.call("screenFrame", id.clone()).unwrap(); - enable(&manager, &id, &first); - for _ in 0..12 { - let next = manager - .call( - "screenFrame", - json!({"session":id["session"],"after":first["sequence"]}), - ) - .unwrap(); - assert_eq!(next["unchanged"], true); - assert_eq!(next["sequence"], first["sequence"]); - assert!(next.get("dataUrl").is_none()); - assert_eq!(next["control"], true); - } - manager - .call( - "screenInput", - input(&id, &first, json!({"kind":"key","key":"home"})), - ) - .unwrap(); - manager.call("screenStop", id).unwrap(); -} - -#[test] -fn refuses_to_take_over_another_debugging_session() { - let (directory, manager) = phone(); - fs::write( - directory.path().join("hdc.daemon"), - "another-tools-agent.so", - ) - .unwrap(); - assert_eq!( - manager - .call("screenStart", json!({"target":"phone"})) - .unwrap_err() - .code, - "screen.debugger_busy" - ); - let calls = fs::read_to_string(directory.path().join("hdc.calls")).unwrap(); - assert!( - !calls.contains("file send") && !calls.contains("shell kill") && !calls.contains("fport") - ); - assert!(directory.path().join("hdc.daemon").exists()); -} - -#[test] -fn recovers_its_orphaned_agent_and_reconnects_after_stop() { - let (directory, manager) = phone(); - fs::write( - directory.path().join("hdc.daemon"), - "trove-screen-old123.so", - ) - .unwrap(); - for _ in 0..2 { - let id = start(&manager); - assert!(manager.call("screenFrame", id.clone()).unwrap()["dataUrl"].is_string()); - manager.call("screenStop", id).unwrap(); - assert!(!directory.path().join("hdc.daemon").exists()); - assert!(!directory.path().join("hdc.pid").exists()); - } -} - -#[test] -fn reports_cleanup_failure_without_mislabeling_its_agent_as_another_tool() { - let (directory, manager) = phone(); - let id = start(&manager); - manager.call("screenFrame", id.clone()).unwrap(); - fs::write(directory.path().join("hdc.deny-kill"), b"").unwrap(); - assert!(manager.call("screenStop", id).is_err()); - assert!(directory.path().join("hdc.daemon").exists()); - let error = manager - .call("screenStart", json!({"target":"phone"})) - .unwrap_err(); - assert_ne!(error.code, "screen.debugger_busy"); - fs::remove_file(directory.path().join("hdc.deny-kill")).unwrap(); - let id = start(&manager); - assert!(manager.call("screenFrame", id.clone()).is_ok()); - manager.call("screenStop", id).unwrap(); -} - -#[test] -fn aligned_capture_keeps_physical_coordinates_and_excludes_padding_from_viewport() { - let (directory, manager) = phone(); - frame_file(&directory, 1260, 2844, [30, 80, 120]); - image::RgbImage::from_pixel(640, 1422, image::Rgb([30u8, 80, 120])) - .save(directory.path().join("hdc.jpg")) - .unwrap(); - let id = start(&manager); - let frame = manager.call("screenFrame", id.clone()).unwrap(); - assert_eq!(frame["width"], 1260); - assert_eq!(frame["height"], 2844); - assert_eq!(frame["captureWidth"], 630); - assert_eq!(frame["captureHeight"], 1422); - assert_eq!(frame["imageWidth"], 640); - enable(&manager, &id, &frame); - manager - .call( - "screenInput", - input(&id, &frame, json!({"kind":"click","x":1259,"y":2843})), - ) - .unwrap(); - manager.call("screenStop", id).unwrap(); -} - -#[test] -fn mismatched_capture_is_rejected_before_input_can_be_enabled() { - let (directory, manager) = phone(); - image::RgbImage::from_pixel(600, 300, image::Rgb([30u8, 80, 120])) - .save(directory.path().join("hdc.jpg")) - .unwrap(); - let id = start(&manager); - assert!(manager.call("screenFrame", id.clone()).is_err()); - assert!(manager - .call( - "screenControl", - json!({"session":id["session"],"enabled":true,"geometry":1}) - ) - .is_err()); -} - -#[test] -fn recovers_only_the_forward_recorded_by_its_previous_crashed_session() { - let (directory, manager) = phone(); - let tool = manager.tool().unwrap(); - let socket = std::net::TcpListener::bind(("127.0.0.1", 0)).unwrap(); - let local = format!("tcp:{}", socket.local_addr().unwrap().port()); - drop(socket); - let mut lease = - screen_lease::Lease::acquire(&directory.path().join("screen-sessions"), &tool, "phone") - .unwrap(); - tool.run(&["-t", "phone", "fport", &local, screen_lease::SOCKET], 3) - .unwrap(); - lease.record(Some(&local)).unwrap(); - drop(lease); - fs::write( - directory.path().join("hdc.daemon"), - "trove-screen-old123.so", - ) - .unwrap(); - let id = start(&manager); - assert!(manager.call("screenFrame", id.clone()).is_ok()); - manager.call("screenStop", id).unwrap(); - assert!(!directory.path().join("hdc.forward").exists()); -} - -#[test] -fn does_not_interrupt_an_active_forward_it_does_not_own() { - let (directory, manager) = phone(); - let listener = std::net::TcpListener::bind(("127.0.0.1", 0)).unwrap(); - let local = format!("tcp:{}", listener.local_addr().unwrap().port()); - // Model another client's live listener and the HDC listing independently. - fs::write(directory.path().join("hdc.forward"), &local).unwrap(); - fs::write( - directory.path().join("hdc.daemon"), - "trove-screen-old123.so", - ) - .unwrap(); - assert_eq!( - manager - .call("screenStart", json!({"target":"phone"})) - .unwrap_err() - .code, - "screen.active" - ); - let calls = fs::read_to_string(directory.path().join("hdc.calls")).unwrap(); - assert!(!calls.contains("shell kill") && !calls.contains("fport rm")); - assert!(directory.path().join("hdc.daemon").exists()); - assert_eq!( - fs::read_to_string(directory.path().join("hdc.forward")).unwrap(), - local - ); - assert!(std::net::TcpStream::connect(listener.local_addr().unwrap()).is_ok()); -} diff --git a/curios/harmony-connector/native/src/hdc/screen_video.rs b/curios/harmony-connector/native/src/hdc/screen_video.rs deleted file mode 100644 index 813a52f..0000000 --- a/curios/harmony-connector/native/src/hdc/screen_video.rs +++ /dev/null @@ -1,197 +0,0 @@ -//! The official recorder's server-streaming gRPC over an HDC Unix-socket forward. -//! Encoded packets must stay ordered; frame dropping happens after decoding. -use super::*; -use bytes::{Buf, Bytes, BytesMut}; -use prost::Message; -use std::collections::HashMap; - -const MAX_PACKET: usize = 8 * 1024 * 1024; - -#[derive(Clone, PartialEq, Message)] -struct Reply { - #[prost(string, tag = "1")] - data: String, - #[prost(int32, tag = "2")] - reply_type: i32, - #[prost(map = "string, message", tag = "3")] - payload: HashMap, -} -#[derive(Clone, PartialEq, Message)] -struct Parameter { - #[prost(oneof = "parameter::Value", tags = "1, 2, 3, 4, 5, 6")] - value: Option, -} -mod parameter { - #[derive(Clone, PartialEq, prost::Oneof)] - pub enum Value { - #[prost(int64, tag = "1")] - Int(i64), - #[prost(double, tag = "2")] - Double(f64), - #[prost(string, tag = "3")] - String(String), - #[prost(bool, tag = "4")] - Bool(bool), - #[prost(bytes, tag = "5")] - Bytes(Vec), - #[prost(float, tag = "6")] - Float(f32), - } -} -fn fault(error: impl std::fmt::Display) -> Fault { - Fault::new("screen.video_failed", format!("视频连接已结束:{error}")) -} - -pub(super) fn receive( - port: u16, - cancel: &AtomicBool, - keyframe: &AtomicBool, - mut output: impl FnMut(Vec) -> Result<()>, -) -> Result<()> { - tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - .map_err(fault)? - .block_on(async { - let socket = tokio::time::timeout( - Duration::from_secs(2), - tokio::net::TcpStream::connect(("127.0.0.1", port)), - ) - .await - .map_err(|e| { - Fault::new( - "screen.video_connect_failed", - format!("连接 HDC 视频转发超时:{e}"), - ) - })? - .map_err(|e| { - Fault::new( - "screen.video_connect_failed", - format!("无法连接 HDC 视频转发端口 {port}:{e}"), - ) - })?; - socket.set_nodelay(true).map_err(fault)?; - let (mut sender, connection) = h2::client::handshake(socket).await.map_err(fault)?; - let connection = tokio::spawn(connection); - let result = async { - let request = http::Request::builder() - .method("POST") - .uri(format!("http://localhost:{port}/ScrcpyService/onStart")) - .header("content-type", "application/grpc") - .header("te", "trailers") - .body(()) - .map_err(fault)?; - let (response, mut request_body) = - sender.send_request(request, false).map_err(fault)?; - // Uncompressed Empty protobuf message: compression byte + uint32 length. - request_body - .send_data(Bytes::from_static(&[0; 5]), true) - .map_err(fault)?; - let response = tokio::time::timeout(Duration::from_secs(5), response) - .await - .map_err(fault)? - .map_err(fault)?; - if response.status() != http::StatusCode::OK { - return Err(fault(response.status())); - } - let mut body = response.into_body(); - let mut pending = BytesMut::new(); - let started = std::time::Instant::now(); - let mut received = false; - let mut last_idr = started - Duration::from_secs(2); - loop { - if cancel.load(Ordering::Acquire) { - let _ = unary(&mut sender, port, "onEnd").await; - return Ok(()); - } - if keyframe.swap(false, Ordering::AcqRel) - && last_idr.elapsed() >= Duration::from_secs(1) - { - unary(&mut sender, port, "onRequestIDRFrame").await?; - last_idr = std::time::Instant::now(); - } - if !received && started.elapsed() > Duration::from_secs(8) { - return Err(fault("手机未返回视频画面,请解锁后重新开始投屏")); - } - let data = - match tokio::time::timeout(Duration::from_millis(100), body.data()).await { - Err(_) => continue, - Ok(Some(data)) => data.map_err(fault)?, - Ok(None) => { - let trailers = body.trailers().await.map_err(fault)?; - return Err(fault( - trailers - .as_ref() - .and_then(|t| t.get("grpc-message")) - .and_then(|m| m.to_str().ok()) - .unwrap_or("手机停止了视频流"), - )); - } - }; - let size = data.len(); - pending.extend_from_slice(&data); - body.flow_control().release_capacity(size).map_err(fault)?; - while pending.len() >= 5 { - let length = u32::from_be_bytes(pending[1..5].try_into().unwrap()) as usize; - if pending[0] != 0 || length > MAX_PACKET { - return Err(fault("不支持的视频包格式")); - } - if pending.len() < length + 5 { - break; - } - pending.advance(5); - let mut reply = Reply::decode(pending.split_to(length)).map_err(fault)?; - if let Some(Parameter { - value: Some(parameter::Value::Bytes(bytes)), - }) = reply.payload.remove("data") - { - if let Some(Parameter { - value: Some(parameter::Value::Int(length)), - }) = reply.payload.get("len") - { - if *length < 0 || *length as usize != bytes.len() { - return Err(fault("视频数据长度不一致")); - } - } - if !bytes.is_empty() { - received = true; - output(bytes)?; - } - } - } - } - } - .await; - connection.abort(); - result - }) -} - -async fn unary(sender: &mut h2::client::SendRequest, port: u16, method: &str) -> Result<()> { - let request = http::Request::builder() - .method("POST") - .uri(format!("http://localhost:{port}/ScrcpyService/{method}")) - .header("content-type", "application/grpc") - .header("te", "trailers") - .body(()) - .map_err(fault)?; - let (response, mut body) = sender.send_request(request, false).map_err(fault)?; - body.send_data(Bytes::from_static(&[0; 5]), true) - .map_err(fault)?; - tokio::time::timeout(Duration::from_secs(1), async { - let response = response.await.map_err(fault)?; - if !response.status().is_success() { - return Err(fault(response.status())); - } - let mut body = response.into_body(); - while let Some(data) = body.data().await { - let data = data.map_err(fault)?; - body.flow_control() - .release_capacity(data.len()) - .map_err(fault)?; - } - Ok(()) - }) - .await - .map_err(fault)? -} diff --git a/curios/harmony-connector/native/src/hdc/screen_window.rs b/curios/harmony-connector/native/src/hdc/screen_window.rs deleted file mode 100644 index edf0cf0..0000000 --- a/curios/harmony-connector/native/src/hdc/screen_window.rs +++ /dev/null @@ -1,278 +0,0 @@ -//! A private binary pipe connects the backend to the bundled AppKit viewer. -use super::screen_recorder::Recorder; -use super::*; -use std::{ - collections::VecDeque, - io::{BufRead, BufReader, Read, Write}, - os::fd::AsRawFd, - process::{Child, ChildStdin, Command, Stdio}, - time::Instant, -}; - -struct Pipe(Mutex); -impl Pipe { - fn send(&self, metadata: Value, bytes: &[u8]) -> Result<()> { - let metadata = serde_json::to_vec(&metadata).unwrap(); - let mut pipe = self.0.lock().unwrap(); - let mut packet = Vec::with_capacity(8 + metadata.len() + bytes.len()); - packet.extend_from_slice(&(metadata.len() as u32).to_be_bytes()); - packet.extend_from_slice(&(bytes.len() as u32).to_be_bytes()); - packet.extend_from_slice(&metadata); - packet.extend_from_slice(bytes); - let deadline = Instant::now() + Duration::from_secs(2); - let mut rest = packet.as_slice(); - while !rest.is_empty() { - match pipe.write(rest) { - Ok(0) => return Err(Fault::new("screen.window_closed", "投屏窗口已关闭。")), - Ok(n) => rest = &rest[n..], - Err(error) - if error.kind() == std::io::ErrorKind::WouldBlock - && Instant::now() < deadline => - { - thread::sleep(Duration::from_millis(2)) - } - Err(error) => return Err(error.into()), - } - } - Ok(()) - } -} -struct Session { - cancel: Arc, - recorder: Mutex>>, - phase: Mutex, - error: Mutex>, - cleanup_error: Mutex>, -} -impl Session { - fn recorder(&self) -> Option> { - self.recorder.lock().unwrap().clone() - } - fn status(&self, target: &str) -> Value { - json!({"active":!self.cancel.load(Ordering::Acquire),"target":target, - "ready":self.recorder().is_some_and(|r| r.ready.load(Ordering::Acquire)), - "phase":self.phase.lock().unwrap().clone(),"error":self.error.lock().unwrap().clone()}) - } - fn progress(&self, pipe: &Pipe, text: &str) { - *self.phase.lock().unwrap() = text.into(); - let _ = pipe.send(json!({"kind":"progress","message":text}), &[]); - } -} -struct Viewer { - child: Child, - pipe: Arc, - session: Arc, - target: String, - worker: Option>, -} -#[derive(Default)] -pub(super) struct Window { - current: Mutex>, -} -#[derive(Deserialize)] -#[serde(deny_unknown_fields)] -pub(super) struct Open { - pub target: String, - #[serde(default)] - pub name: String, -} -impl Window { - pub fn status(&self) -> Value { - self.current - .lock() - .unwrap() - .as_ref() - .map_or(json!({"active":false}), |v| v.session.status(&v.target)) - } - pub fn open(&self, mut tool: Tool, request: Open) -> Result { - hdc_data::target(&request.target)?; - let mut current = self.current.lock().unwrap(); - if let Some(viewer) = current.as_mut() { - if !viewer.session.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(viewer.session.status(&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 name = if request.name.trim().is_empty() { - "Harmony" - } else { - &request.name - }; - // Show the native window before any USB discovery, upload or compatibility negotiation. - let mut child = Command::new(executable) - .arg(name) - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::null()) - .spawn() - .map_err(|e| Fault::new("screen.window_failed", format!("无法打开投屏窗口:{e}")))?; - 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 session = Arc::new(Session { - cancel: Arc::new(AtomicBool::new(false)), - recorder: Mutex::new(None), - phase: Mutex::new("正在连接设备…".into()), - error: Mutex::new(None), - cleanup_error: Mutex::new(None), - }); - // A viewer's cancellation belongs to that viewer, not the management page's next action. - tool.cancel = session.cancel.clone(); - let stdout = child.stdout.take().unwrap(); - let reader_session = session.clone(); - thread::spawn(move || { - let mut reader = BufReader::new(stdout); - loop { - let mut line = String::new(); - match reader.by_ref().take(16385).read_line(&mut line) { - Ok(0) | Err(_) => break, - Ok(_) if line.len() > 16384 || !line.ends_with('\n') => break, - _ => {} - } - let Ok(value) = serde_json::from_str::(&line) else { - break; - }; - let Some(recorder) = reader_session.recorder() else { - continue; - }; - if value["event"] == "input" { - if let Err(error) = recorder.push(value) { - *reader_session.error.lock().unwrap() = Some(error); - break; - } - } else if value["event"] == "presented" { - recorder.mark_ready(); - } else if value["event"] == "decoderError" { - recorder.keyframe.store(true, Ordering::Release); - } - } - reader_session.cancel.store(true, Ordering::Release); - }); - let worker_session = session.clone(); - let worker_pipe = pipe.clone(); - let target = request.target.clone(); - let worker = thread::spawn(move || { - let result = (|| { - let recorder = Recorder::open(tool, target, |phase| { - worker_session.progress(&worker_pipe, phase) - })?; - *worker_session.recorder.lock().unwrap() = Some(recorder.clone()); - if worker_session.cancel.load(Ordering::Acquire) { - return Ok(()); - } - worker_session.progress(&worker_pipe, "等待手机画面…"); - receive(&recorder, &worker_pipe) - })(); - if let Err(error) = result { - if error.code != "operation.cancelled" { - worker_session.error.lock().unwrap().get_or_insert(error); - } - } - if let Some(recorder) = worker_session.recorder() { - if let Some(error) = recorder.error.lock().unwrap().clone() { - worker_session.error.lock().unwrap().get_or_insert(error); - } - if let Err(error) = recorder.stop() { - *worker_session.cleanup_error.lock().unwrap() = Some(error.clone()); - worker_session.error.lock().unwrap().get_or_insert(error); - } - } - worker_session.cancel.store(true, Ordering::Release); - if let Some(error) = worker_session.error.lock().unwrap().clone() { - let _ = worker_pipe.send(json!({"kind":"error","message":error.message}), &[]); - } - }); - let status = session.status(&request.target); - *current = Some(Viewer { - child, - pipe, - session, - target: request.target, - worker: Some(worker), - }); - Ok(status) - } - pub fn stop(&self) -> Result { - if let Some(viewer) = self.current.lock().unwrap().take() { - close(viewer)?; - } - Ok(json!({"active":false})) - } -} -fn receive(recorder: &Recorder, pipe: &Pipe) -> Result<()> { - let mut sequence = 0u64; - let mut samples = VecDeque::new(); - let mut port = recorder.port; - let mut retries = 0; - loop { - let result = super::screen_video::receive( - port, - &recorder.cancel, - &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 = recorder.dimensions(); - pipe.send(json!({"kind":"video","frame":{ - "sequence":sequence,"geometry":dimensions.geometry,"width":dimensions.width,"height":dimensions.height, - "captureWidth":dimensions.width.div_ceil(2),"captureHeight":dimensions.height.div_ceil(2), - "receivedFps":samples.len(),"mbps":samples.iter().map(|(_,bytes)| *bytes).sum::() as f64 * 8.0 / 1_000_000.0 - }}), &bytes) - }, - ); - if result.is_ok() || sequence > 0 || retries >= 2 || recorder.cancel.load(Ordering::Acquire) - { - return result; - } - retries += 1; - port = recorder.reconnect_video()?; - thread::sleep(Duration::from_millis(100)); - } -} -fn close(mut viewer: Viewer) -> Result<()> { - viewer.session.cancel.store(true, Ordering::Release); - let _ = viewer.pipe.send(json!({"kind":"close"}), &[]); - 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(); - // The same worker owns startup and cleanup, including cancellation during an upload. - if let Some(worker) = viewer.worker.take() { - let _ = worker.join(); - } - let error = viewer.session.cleanup_error.lock().unwrap().clone(); - error.map_or(Ok(()), Err) -} diff --git a/curios/harmony-connector/native/src/hdc/tests.rs b/curios/harmony-connector/native/src/hdc/tests.rs deleted file mode 100644 index 3690c02..0000000 --- a/curios/harmony-connector/native/src/hdc/tests.rs +++ /dev/null @@ -1,280 +0,0 @@ -use super::*; -use std::{io::Write, time::Instant}; - -#[path = "screen_tests.rs"] -mod screen_tests; - -fn version_tool(path: &Path) { - fs::create_dir_all(path.parent().unwrap()).unwrap(); - fs::write(path, "#!/bin/sh\nprintf 'Ver: fixture\\n'\n").unwrap(); - fs::set_permissions(path, fs::Permissions::from_mode(0o700)).unwrap(); -} - -#[test] -fn configures_extracted_official_toolkit_and_preserves_selection_on_error() { - let (directory, mut manager) = fixture(false); - let settings = directory.path().join("data/harmony-tools.json"); - Arc::get_mut(&mut manager).unwrap().settings = Some(settings.clone()); - let root = directory.path().join("download"); - let hdc = root.join("command-line-tools/sdk/default/openharmony/toolchains/hdc"); - version_tool(&hdc); - let result = manager.call("configure", json!({"path": root})).unwrap(); - assert_eq!(result["available"], true); - assert_eq!(result["path"], json!(hdc.canonicalize().unwrap())); - let saved: Value = serde_json::from_slice(&fs::read(&settings).unwrap()).unwrap(); - assert_eq!(saved["hdcPath"], result["path"]); - assert!(manager - .call( - "configure", - json!({"path": directory.path().join("missing")}) - ) - .is_err()); - assert_eq!( - manager.call("tool", json!({})).unwrap()["path"], - result["path"] - ); - fs::remove_file(&hdc).unwrap(); - assert_eq!(manager.call("tool", json!({})).unwrap()["available"], false); -} - -#[test] -fn rechecks_hdc_installed_after_manager_start() { - const CHILD: &str = "HARMONY_TOOL_DISCOVERY_TEST"; - if let Some(root) = env::var_os(CHILD) { - let root = PathBuf::from(root); - let manager = Manager::new(); - // Reproduce the running plugin's unconfigured state without depending - // on whether the test machine also has a system-wide DevEco install. - *manager.path.lock().unwrap() = None; - let hdc = root.join("bin/hdc"); - version_tool(&hdc); - let result = manager.call("tool", json!({})).unwrap(); - assert_eq!(result["available"], true, "{result}"); - assert_eq!(result["path"], json!(hdc.canonicalize().unwrap())); - return; - } - let root = tempfile::tempdir().unwrap(); - let output = std::process::Command::new(env::current_exe().unwrap()) - .args([ - "--exact", - "hdc::tests::rechecks_hdc_installed_after_manager_start", - "--nocapture", - ]) - .env(CHILD, root.path()) - .env("HOME", root.path()) - .env("PATH", root.path().join("bin")) - .env("TROVE_DATA_DIR", root.path().join("data")) - .output() - .unwrap(); - assert!( - output.status.success(), - "{}\n{}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); -} - -#[test] -fn automatically_discovers_downloaded_toolkit_without_manual_configuration() { - const CHILD: &str = "HARMONY_DOWNLOADED_TOOL_TEST"; - if let Some(root) = env::var_os(CHILD) { - let root = PathBuf::from(root); - let installations = root.join("Library/HarmonyConnector/tools"); - let previous = installations.join("9.9.0.1/toolchains/hdc"); - let current = installations.join("26.0.0.821/toolchains/hdc"); - version_tool(&previous); - version_tool(¤t); - // An incomplete, newer extraction must not hide a usable installation. - fs::create_dir_all(installations.join("27.0.0.1/toolchains")).unwrap(); - let manager = Manager::new(); - let result = manager.call("tool", json!({})).unwrap(); - assert_eq!(result["available"], true, "{result}"); - assert_eq!(result["path"], json!(current.canonicalize().unwrap())); - assert!(!root.join("data/harmony-tools.json").exists()); - // A user's explicit selection still wins over automatic discovery. - let chosen = root.join("chosen/hdc"); - version_tool(&chosen); - manager.call("configure", json!({"path": chosen})).unwrap(); - assert_eq!( - Manager::new().call("tool", json!({})).unwrap()["path"], - json!(chosen.canonicalize().unwrap()) - ); - fs::remove_file(chosen).unwrap(); - fs::remove_dir_all(installations).unwrap(); - // Also support a normal Finder extraction in Downloads, retaining the - // official command-line-tools/sdk/default/openharmony layout. - let downloaded = root.join("Downloads/commandline-tools-mac-arm64-26.0.0.821/command-line-tools/sdk/default/openharmony/toolchains/hdc"); - version_tool(&downloaded); - let result = Manager::new().call("tool", json!({})).unwrap(); - assert_eq!(result["available"], true); - assert_eq!(result["path"], json!(downloaded.canonicalize().unwrap())); - return; - } - let root = tempfile::tempdir().unwrap(); - let output = std::process::Command::new(env::current_exe().unwrap()) - .args([ - "--exact", - "hdc::tests::automatically_discovers_downloaded_toolkit_without_manual_configuration", - "--nocapture", - ]) - .env(CHILD, root.path()) - .env("HOME", root.path()) - .env("PATH", root.path().join("bin")) - .env("TROVE_DATA_DIR", root.path().join("data")) - .output() - .unwrap(); - assert!( - output.status.success(), - "{}\n{}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); -} - -fn fixture(debug: bool) -> (tempfile::TempDir, Arc) { - let directory = tempfile::tempdir().unwrap(); - let path = directory.path().join("hdc"); - let app = json!({"name":"com.example.app","versionCode":3,"versionName":"1.2","applicationInfo":{"appProvisionType":if debug {"debug"} else {"release"}},"hapModuleInfos":[{"name":"entry","abilityInfos":[{"bundleName":"com.example.app","moduleName":"entry","name":"Home","type":1,"visible":true}]}]}); - fs::write(&path, format!(r#"#!/bin/sh -printf '%s\n' "$*" >> "$0.calls" -case "$*" in - 'list targets -v') printf 'phone USB Connected device\n' ;; - '-t phone shell bm dump -a') printf 'ID: 100:\n com.example.app\n' ;; - '-t phone shell bm dump -n com.example.app') printf '%s\n' 'com.example.app:' '{app}' ;; - '-t phone shell aa start -b com.example.app -a Home -m entry') printf 'start ability successfully.\n' ;; - '-t phone shell aa force-stop com.example.app') printf 'force stop process successfully.\n' ;; - '-t phone shell hilog -x') printf 'error: user log content, not command failure\n' ;; - '-t phone install '*|'-t phone uninstall '*) printf 'success\n' ;; - '-t phone shell -b com.example.app ls data/storage/el2/base/') printf 'files\n' ;; - '-t phone file recv -b '*) printf 'success but no actual files\n' ;; - *) printf '[Fail] unsupported\n' ;; -esac -"#)).unwrap(); - fs::set_permissions(&path, fs::Permissions::from_mode(0o700)).unwrap(); - let manager = Arc::new(Manager { - window: screen_window::Window::default(), - screen: screen::Screen::new(directory.path().join("screen-sessions")), - path: Mutex::new(Some(path)), - settings: None, - busy: Arc::new(AtomicBool::new(false)), - job: Mutex::new(Value::Null), - cancel: Arc::new(AtomicBool::new(false)), - next: AtomicU64::new(1), - stopped: AtomicBool::new(false), - }); - (directory, manager) -} -fn wait(manager: &Arc, action: Value) -> Value { - manager.call("start", action).unwrap(); - let deadline = Instant::now() + Duration::from_secs(5); - loop { - let status = manager.call("status", json!({})).unwrap(); - if status["busy"] == false { - return status["job"].clone(); - } - assert!(Instant::now() < deadline); - thread::sleep(Duration::from_millis(5)); - } -} -fn hap(path: &Path) { - let mut zip = zip::ZipWriter::new(fs::File::create(path).unwrap()); - zip.start_file("module.json", zip::write::SimpleFileOptions::default()) - .unwrap(); - zip.write_all( - br#"{"app":{"bundleName":"com.example.app","versionCode":3,"versionName":"1.2"}}"#, - ) - .unwrap(); - zip.finish().unwrap(); -} -#[test] -fn installs_confirmed_package_and_verifies_actual_device_version() { - let (directory, manager) = fixture(false); - let path = directory.path().join("signed.hap"); - hap(&path); - let package = manager.call("package", json!({"path":path})).unwrap(); - let action = json!({"kind":"install","target":"phone","path":path,"sha256":package["sha256"]}); - let result = wait(&manager, action.clone()); - assert_eq!(result["state"], "completed", "{result}"); - assert_eq!(result["result"]["application"]["versionCode"], 3); - fs::write(&path, b"changed package").unwrap(); - assert_eq!(wait(&manager, action)["state"], "failed"); - let calls = fs::read_to_string(directory.path().join("hdc.calls")).unwrap(); - assert_eq!( - calls - .lines() - .filter(|line| line.starts_with("-t phone install ")) - .count(), - 1 - ); -} -#[test] -fn failed_uninstall_postcondition_is_not_reported_as_success() { - let (_directory, manager) = fixture(false); - let result = wait( - &manager, - json!({"kind":"uninstall","target":"phone","bundle":"com.example.app"}), - ); - assert_eq!(result["state"], "failed"); - assert_eq!(result["error"]["code"], "hdc.uninstall_unverified"); -} -#[test] -fn launch_uses_real_exported_component_and_rejects_invented_or_injected_names() { - let (directory, manager) = fixture(false); - let action = |ability| json!({"kind":"launch","target":"phone","bundle":"com.example.app","module":"entry","ability":ability}); - assert_eq!(wait(&manager, action("Home"))["state"], "completed"); - assert_eq!( - wait(&manager, action("EntryAbility"))["error"]["code"], - "hdc.ability_unavailable" - ); - assert_eq!(wait(&manager, action("Home;reboot"))["state"], "failed"); - let calls = fs::read_to_string(directory.path().join("hdc.calls")).unwrap(); - assert_eq!( - calls - .lines() - .filter(|line| line.contains("aa start")) - .count(), - 1 - ); -} -#[test] -fn exports_log_content_without_treating_log_messages_as_remote_exit_status() { - let (directory, manager) = fixture(false); - let destination = tempfile::tempdir().unwrap(); - let result = wait( - &manager, - json!({"kind":"logs","target":"phone","directory":destination.path()}), - ); - assert_eq!(result["state"], "completed", "{result}"); - assert!( - fs::read_to_string(result["result"]["path"].as_str().unwrap()) - .unwrap() - .starts_with("error: user log") - ); - assert!(directory.path().join("hdc.calls").is_file()); -} -#[test] -fn blocks_release_app_private_export_and_rejects_zero_exit_without_received_data() { - for debug in [false, true] { - let (directory, manager) = fixture(debug); - let destination = tempfile::tempdir().unwrap(); - let result = wait( - &manager, - json!({"kind":"exportDebug","target":"phone","bundle":"com.example.app","directory":destination.path()}), - ); - assert_eq!(result["state"], "failed"); - assert_eq!( - result["error"]["code"], - if debug { - "hdc.invalid_backup" - } else { - "hdc.backup_unavailable" - } - ); - assert_eq!(fs::read_dir(destination.path()).unwrap().count(), 0); - if !debug { - assert!(!fs::read_to_string(directory.path().join("hdc.calls")) - .unwrap() - .contains("file recv")); - } - } -} diff --git a/curios/harmony-connector/native/src/hdc_data.rs b/curios/harmony-connector/native/src/hdc_data.rs deleted file mode 100644 index d6ebb1f..0000000 --- a/curios/harmony-connector/native/src/hdc_data.rs +++ /dev/null @@ -1,178 +0,0 @@ -use crate::model::{Fault, Result}; -use serde_json::{json, Value}; - -pub fn identifier(value: &str) -> Result<&str> { - if value.is_empty() - || value.len() > 256 - || value.starts_with('-') - || !value - .bytes() - .all(|b| b.is_ascii_alphanumeric() || b"._-".contains(&b)) - { - return Err(Fault::new("request.invalid_params", "应用或组件标识无效。")); - } - Ok(value) -} -pub fn target(value: &str) -> Result<&str> { - if value.is_empty() - || value.len() > 256 - || value.starts_with('-') - || !value - .bytes() - .all(|b| b.is_ascii_alphanumeric() || b"._-:[]".contains(&b)) - { - return Err(Fault::new("request.invalid_params", "设备标识无效。")); - } - Ok(value) -} -pub fn devices(output: &str) -> Result> { - if output.trim() == "[Empty]" { - return Ok(Vec::new()); - } - let mut rows = Vec::new(); - for line in output.lines().filter(|line| !line.trim().is_empty()) { - let fields: Vec<_> = line.split_whitespace().collect(); - if fields.len() < 3 || !matches!(fields[1], "USB" | "TCP") { - return Err(Fault::new( - "hdc.invalid_output", - "无法识别设备列表,请检查 HDC 版本。", - )); - } - target(fields[0])?; - let status = fields - .iter() - .find(|s| { - matches!( - **s, - "Unknown" | "Ready" | "Connected" | "Offline" | "Unauthorized" - ) - }) - .ok_or_else(|| Fault::new("hdc.invalid_output", "设备列表缺少连接状态。"))?; - rows.push( - json!({"id":fields[0],"transport":fields[1],"state":status,"description":line.trim()}), - ); - } - if rows.is_empty() { - return Err(Fault::new( - "hdc.invalid_output", - "HDC 没有返回可识别的设备状态。", - )); - } - Ok(rows) -} -pub fn apps(output: &str) -> Result> { - let mut rows = Vec::new(); - let mut header = false; - for line in output - .lines() - .map(str::trim) - .filter(|line| !line.is_empty()) - { - if let Some(id) = line.strip_prefix("ID:") { - if id - .trim() - .trim_end_matches(':') - .trim() - .parse::() - .is_ok() - { - header = true; - continue; - } - } - identifier(line).map_err(|_| { - Fault::new( - "hdc.invalid_output", - "无法识别应用列表,未将错误输出作为空列表。", - ) - })?; - if !line.contains('.') { - return Err(Fault::new("hdc.invalid_output", "应用列表返回了未知格式。")); - } - rows.push(line.into()); - } - if rows.is_empty() && !header { - return Err(Fault::new("hdc.invalid_output", "设备没有返回应用列表。")); - } - rows.sort(); - rows.dedup(); - Ok(rows) -} -pub fn app(output: &str, bundle: &str) -> Result { - identifier(bundle)?; - let start = output - .find('{') - .ok_or_else(|| Fault::new("hdc.invalid_output", "应用详情没有返回 JSON。"))?; - let raw: Value = serde_json::from_str(output[start..].trim()) - .map_err(|_| Fault::new("hdc.invalid_output", "无法解析应用详情。"))?; - if raw["name"] != bundle { - return Err(Fault::new( - "hdc.invalid_output", - "应用详情与所选应用不一致。", - )); - } - let mut abilities = Vec::new(); - if let Some(modules) = raw["hapModuleInfos"].as_array() { - for module in modules { - for ability in module["abilityInfos"].as_array().into_iter().flatten() { - if ability["type"] != 1 - || ability["visible"] != true - || ability["enabled"] == false - || ability["bundleName"] != bundle - { - continue; - } - let Some(name) = ability["name"].as_str().filter(|s| identifier(s).is_ok()) else { - continue; - }; - let Some(module_name) = ability["moduleName"] - .as_str() - .or_else(|| module["moduleName"].as_str()) - .filter(|s| identifier(s).is_ok()) - else { - continue; - }; - abilities.push(json!({"name":name,"module":module_name})); - } - } - } - Ok( - json!({"bundle":bundle,"versionName":raw["versionName"],"versionCode":raw["versionCode"],"debug":raw["applicationInfo"]["appProvisionType"] == "debug","abilities":abilities,"details":raw}), - ) -} - -#[cfg(test)] -mod tests { - use super::*; - #[test] - fn distinguishes_empty_unauthorized_and_unrecognized_device_lists() { - assert!(devices("[Empty]\n").unwrap().is_empty()); - assert_eq!( - devices("abc123 USB Unauthorized HUAWEI\n").unwrap()[0]["state"], - "Unauthorized" - ); - assert_eq!( - devices("127.0.0.1:5555 TCP Connected hdc3\n").unwrap()[0]["transport"], - "TCP" - ); - assert!(devices("unknown future format").is_err()); - assert!(devices("").is_err()); - } - #[test] - fn parses_bundle_manager_formats_and_does_not_invent_launch_components() { - assert_eq!( - apps("ID: 100:\n\tcom.example.a\n\tcom.example.b\n").unwrap(), - vec!["com.example.a", "com.example.b"] - ); - assert!(apps("permission denied").is_err()); - let text = r#"com.example.a: - {"name":"com.example.a","applicationInfo":{"appProvisionType":"debug"},"hapModuleInfos":[{"moduleName":"entry","abilityInfos":[{"name":"Main","bundleName":"com.example.a","type":1,"visible":true},{"name":"Private","bundleName":"com.example.a","type":1,"visible":false}]}]}"#; - let result = app(text, "com.example.a").unwrap(); - assert_eq!(result["debug"], true); - assert_eq!(result["abilities"].as_array().unwrap().len(), 1); - assert_eq!(result["abilities"][0]["name"], "Main"); - assert!(app(text, "com.example.b").is_err()); - assert!(identifier("com.app;rm").is_err()); - assert!(target("dev$(id)").is_err()); - } -} diff --git a/curios/harmony-connector/native/src/main.rs b/curios/harmony-connector/native/src/main.rs deleted file mode 100644 index f0d43ca..0000000 --- a/curios/harmony-connector/native/src/main.rs +++ /dev/null @@ -1,31 +0,0 @@ -mod command; -mod connector; -mod diagnostics; -mod file_transfer; -mod hdc; -mod hdc_data; -mod model; -mod mtp; -mod progress; -mod wire; -fn main() -> anyhow::Result<()> { - if std::env::args().any(|arg| arg == "--diagnose") { - println!( - "{}", - serde_json::json!({"transport":"mtp","libmtp":mtp::version(),"devices":mtp::scan()?}) - ); - return Ok(()); - } - if std::env::args().any(|arg| arg == "--check-reconnect") { - return diagnostics::reconnect(); - } - let args: Vec<_> = std::env::args().collect(); - if args.get(1).is_some_and(|s| s == "--check-screen") { - return diagnostics::screen(&args[2..], false); - } - if args.get(1).is_some_and(|s| s == "--check-screen-stream") { - return diagnostics::screen(&args[2..], true); - } - let _ = mtp::version(); - wire::run(connector::Connector::new()) -} diff --git a/curios/harmony-connector/native/src/model.rs b/curios/harmony-connector/native/src/model.rs deleted file mode 100644 index b838ebd..0000000 --- a/curios/harmony-connector/native/src/model.rs +++ /dev/null @@ -1,73 +0,0 @@ -pub use crate::progress::Progress; -use serde::{Deserialize, Serialize}; - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct DeviceInfo { - pub id: String, - pub bus: u32, - pub address: u8, - pub vendor: u16, - pub product: u16, - pub manufacturer: String, - pub name: String, - pub model: Option, -} -#[derive(Debug, Clone, Serialize)] -pub struct Storage { - pub id: u32, - pub name: String, - pub capacity: u64, - pub free: u64, -} -#[derive(Debug, Clone, Serialize)] -pub struct Entry { - pub id: u32, - pub parent: u32, - pub storage: u32, - pub name: String, - pub size: u64, - pub modified: i64, - pub folder: bool, -} -#[derive(Debug, Clone, Serialize)] -pub struct Fault { - pub code: String, - pub message: String, - pub retryable: bool, -} -impl Fault { - pub fn new(code: &str, message: impl Into) -> Self { - Self { - code: code.into(), - message: message.into(), - retryable: false, - } - } -} -impl std::fmt::Display for Fault { - fn fmt(&self, out: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(out, "{}: {}", self.code, self.message) - } -} -impl std::error::Error for Fault {} -impl From for Fault { - fn from(error: std::io::Error) -> Self { - Self::new("file.io_error", format!("无法读写 Mac 文件:{error}")) - } -} -pub type Result = std::result::Result; - -#[derive(Debug, Clone, Deserialize)] -#[serde(tag = "direction", rename_all = "lowercase", deny_unknown_fields)] -pub enum Transfer { - Upload { - path: String, - storage: u32, - parent: u32, - }, - Download { - object: u32, - directory: String, - }, -} diff --git a/curios/harmony-connector/native/src/mtp.rs b/curios/harmony-connector/native/src/mtp.rs deleted file mode 100644 index 6c6da74..0000000 --- a/curios/harmony-connector/native/src/mtp.rs +++ /dev/null @@ -1,311 +0,0 @@ -use crate::model::{DeviceInfo, Entry, Fault, Progress, Result, Storage}; -use std::{ - ffi::{c_char, c_int, c_void, CStr, CString}, - fs::File, - os::fd::AsRawFd, - ptr::NonNull, - sync::Once, -}; - -pub const ROOT: u32 = u32::MAX; -pub trait DeviceIo: Send { - fn storages(&mut self) -> Result>; - fn list(&mut self, storage: u32, parent: u32) -> Result>; - fn metadata(&mut self, object: u32) -> Result; - fn upload( - &mut self, - file: &File, - name: &str, - size: u64, - storage: u32, - parent: u32, - progress: &Progress, - ) -> Result; - fn download(&mut self, object: u32, file: &File, progress: &Progress) -> Result<()>; - fn mkdir(&mut self, name: &str, storage: u32, parent: u32) -> Result; -} -type DeviceCallback = extern "C" fn(*mut c_void, u32, u8, u16, u16, *const c_char, *const c_char); -type StorageCallback = extern "C" fn(*mut c_void, u32, *const c_char, u64, u64); -type FileCallback = extern "C" fn(*mut c_void, u32, u32, u32, *const c_char, u64, i64, c_int); -type ProgressCallback = extern "C" fn(u64, u64, *const c_void) -> c_int; -type IdentityCallback = extern "C" fn(*mut c_void, *const c_char, *const c_char, *const c_char); -extern "C" { - fn hc_init(); - fn hc_version() -> *const c_char; - fn hc_scan(callback: DeviceCallback, context: *mut c_void) -> c_int; - fn hc_present(bus: u32, address: u8, vendor: u16, product: u16) -> c_int; - fn hc_identity(device: *mut c_void, callback: IdentityCallback, context: *mut c_void); - fn hc_open(bus: u32, address: u8) -> *mut c_void; - fn hc_close(device: *mut c_void); - fn hc_error(device: *mut c_void, buffer: *mut c_char, length: usize); - fn hc_storages(device: *mut c_void, callback: StorageCallback, context: *mut c_void) -> c_int; - fn hc_list( - device: *mut c_void, - storage: u32, - parent: u32, - callback: FileCallback, - context: *mut c_void, - ) -> c_int; - fn hc_metadata( - device: *mut c_void, - object: u32, - callback: FileCallback, - context: *mut c_void, - ) -> c_int; - fn hc_upload( - device: *mut c_void, - fd: c_int, - name: *const c_char, - size: u64, - storage: u32, - parent: u32, - callback: ProgressCallback, - context: *const c_void, - object: *mut u32, - ) -> c_int; - fn hc_download( - device: *mut c_void, - object: u32, - fd: c_int, - callback: ProgressCallback, - context: *const c_void, - ) -> c_int; - fn hc_mkdir(device: *mut c_void, name: *const c_char, storage: u32, parent: u32) -> u32; -} -fn initialize() { - static ONCE: Once = Once::new(); - ONCE.call_once(|| unsafe { hc_init() }); -} -fn text(value: *const c_char) -> String { - if value.is_null() { - return String::new(); - } - unsafe { CStr::from_ptr(value) } - .to_string_lossy() - .into_owned() -} -extern "C" fn device( - context: *mut c_void, - bus: u32, - address: u8, - vendor: u16, - product: u16, - manufacturer: *const c_char, - name: *const c_char, -) { - let rows = unsafe { &mut *(context as *mut Vec) }; - rows.push(DeviceInfo { - id: format!("{bus}:{address}"), - bus, - address, - vendor, - product, - manufacturer: text(manufacturer), - name: text(name), - model: None, - }); -} -extern "C" fn identity( - context: *mut c_void, - friendly: *const c_char, - model: *const c_char, - manufacturer: *const c_char, -) { - let info = unsafe { &mut *(context as *mut DeviceInfo) }; - let clean = |value| { - text(value) - .trim() - .chars() - .filter(|c| !c.is_control()) - .collect::() - }; - let (friendly, model, manufacturer) = (clean(friendly), clean(model), clean(manufacturer)); - if !model.is_empty() { - info.model = Some(model.clone()); - } - if !manufacturer.is_empty() { - info.manufacturer = manufacturer; - } - if !friendly.is_empty() { - info.name = friendly; - } else if !model.is_empty() { - info.name = model; - } -} -// Presence checks enumerate descriptors only, on a separate libusb context; no MTP handle is accessed. -pub fn present(info: &DeviceInfo) -> Result { - initialize(); - match unsafe { hc_present(info.bus, info.address, info.vendor, info.product) } { - 0 => Ok(false), - 1 => Ok(true), - _ => Err(Fault::new( - "usb.discovery_failed", - "暂时无法检查 USB 连接。", - )), - } -} -extern "C" fn storage( - context: *mut c_void, - id: u32, - name: *const c_char, - capacity: u64, - free: u64, -) { - unsafe { &mut *(context as *mut Vec) }.push(Storage { - id, - name: text(name), - capacity, - free, - }); -} -extern "C" fn entry( - context: *mut c_void, - id: u32, - parent: u32, - storage: u32, - name: *const c_char, - size: u64, - modified: i64, - folder: c_int, -) { - unsafe { &mut *(context as *mut Vec) }.push(Entry { - id, - parent, - storage, - name: text(name), - size, - modified, - folder: folder != 0, - }); -} -extern "C" fn progress(sent: u64, total: u64, context: *const c_void) -> c_int { - let progress = unsafe { &*(context as *const Progress) }; - progress.update(sent, total); - i32::from(progress.cancelled()) -} -pub fn version() -> String { - initialize(); - text(unsafe { hc_version() }) -} -// Caller holds the connector operation gate: libmtp enumeration and sessions never run concurrently. -pub fn scan() -> Result> { - initialize(); - let mut rows = Vec::new(); - let status = unsafe { hc_scan(device, &mut rows as *mut _ as *mut c_void) }; - if status != 0 { - return Err(Fault::new( - "mtp.discovery_failed", - "无法扫描 USB 设备。请检查数据线、手机授权及其他文件传输应用是否正在占用设备。", - )); - } - Ok(rows) -} -pub struct NativeDevice(NonNull); -// The C handle has one owner. The connector gate and session mutex serialize every access. -unsafe impl Send for NativeDevice {} -impl NativeDevice { - pub fn identify(&mut self, info: &mut DeviceInfo) { - unsafe { hc_identity(self.0.as_ptr(), identity, info as *mut _ as *mut c_void) }; - } - pub fn open(device: &DeviceInfo) -> Result { - initialize(); - NonNull::new(unsafe { hc_open(device.bus, device.address) }).map(Self) - .ok_or_else(|| Fault::new("mtp.connection_failed", "无法打开设备。请解锁手机、选择“传输文件”,并退出正在占用设备的 HiSuite、OpenMTP 或其他传输应用。")) - } - fn checked(&self, code: c_int) -> Result<()> { - if code == 0 { - return Ok(()); - } - let mut buffer = [0i8; 1024]; - unsafe { hc_error(self.0.as_ptr(), buffer.as_mut_ptr(), buffer.len()) }; - Err(Fault::new( - "mtp.operation_failed", - format!("设备未完成操作:{}", text(buffer.as_ptr())), - )) - } -} -impl Drop for NativeDevice { - fn drop(&mut self) { - unsafe { hc_close(self.0.as_ptr()) }; - } -} -impl DeviceIo for NativeDevice { - fn storages(&mut self) -> Result> { - let mut rows = Vec::new(); - self.checked(unsafe { - hc_storages(self.0.as_ptr(), storage, &mut rows as *mut _ as *mut c_void) - })?; - Ok(rows) - } - fn list(&mut self, storage: u32, parent: u32) -> Result> { - let mut rows = Vec::new(); - self.checked(unsafe { - hc_list( - self.0.as_ptr(), - storage, - parent, - entry, - &mut rows as *mut _ as *mut c_void, - ) - })?; - Ok(rows) - } - fn metadata(&mut self, object: u32) -> Result { - let mut rows = Vec::new(); - self.checked(unsafe { - hc_metadata( - self.0.as_ptr(), - object, - entry, - &mut rows as *mut _ as *mut c_void, - ) - })?; - rows.pop() - .ok_or_else(|| Fault::new("mtp.not_found", "该文件已不存在,请刷新列表。")) - } - fn upload( - &mut self, - file: &File, - name: &str, - size: u64, - storage: u32, - parent: u32, - state: &Progress, - ) -> Result { - let name = CString::new(name) - .map_err(|_| Fault::new("file.invalid_name", "文件名包含无效字符。"))?; - let mut object = 0; - self.checked(unsafe { - hc_upload( - self.0.as_ptr(), - file.as_raw_fd(), - name.as_ptr(), - size, - storage, - parent, - progress, - state as *const _ as *const c_void, - &mut object, - ) - })?; - Ok(object) - } - fn download(&mut self, object: u32, file: &File, state: &Progress) -> Result<()> { - self.checked(unsafe { - hc_download( - self.0.as_ptr(), - object, - file.as_raw_fd(), - progress, - state as *const _ as *const c_void, - ) - }) - } - fn mkdir(&mut self, name: &str, storage: u32, parent: u32) -> Result { - let name = CString::new(name) - .map_err(|_| Fault::new("file.invalid_name", "文件夹名称包含无效字符。"))?; - let id = unsafe { hc_mkdir(self.0.as_ptr(), name.as_ptr(), storage, parent) }; - self.checked(if id == 0 { -1 } else { 0 })?; - Ok(id) - } -} diff --git a/curios/harmony-connector/native/src/mtp_bridge.c b/curios/harmony-connector/native/src/mtp_bridge.c deleted file mode 100644 index ce8c649..0000000 --- a/curios/harmony-connector/native/src/mtp_bridge.c +++ /dev/null @@ -1,158 +0,0 @@ -/* MTP calls are serialized by the Rust session; USB presence uses its own context. */ -#include -#include -#include -#include -#include -#include - -typedef void (*device_cb)(void *, uint32_t, uint8_t, uint16_t, uint16_t, const char *, const char *); -typedef void (*storage_cb)(void *, uint32_t, const char *, uint64_t, uint64_t); -typedef void (*file_cb)(void *, uint32_t, uint32_t, uint32_t, const char *, uint64_t, int64_t, int); -typedef void (*identity_cb)(void *, const char *, const char *, const char *); - -void hc_init(void) { LIBMTP_Init(); } -const char *hc_version(void) { return LIBMTP_VERSION_STRING; } - -/* Enumeration does not claim interfaces or open an MTP session. It also works while a transfer is active. */ -static int hc_usb(uint32_t bus, uint8_t address, uint16_t vendor, uint16_t product, - char *manufacturer, char *name, size_t length) { - libusb_context *context = NULL; - if (libusb_init(&context) != 0) return -1; - libusb_device **devices = NULL; - ssize_t count = libusb_get_device_list(context, &devices); - int found = count < 0 ? -1 : 0; - for (ssize_t i = 0; i < count; i++) { - struct libusb_device_descriptor descriptor; - if (libusb_get_bus_number(devices[i]) != bus || libusb_get_device_address(devices[i]) != address || - libusb_get_device_descriptor(devices[i], &descriptor) != 0 || - descriptor.idVendor != vendor || descriptor.idProduct != product) continue; - found = 1; - if (name && manufacturer) { - libusb_device_handle *handle = NULL; - if (libusb_open(devices[i], &handle) == 0) { - int size = descriptor.iProduct ? libusb_get_string_descriptor_ascii(handle, descriptor.iProduct, (unsigned char *)name, (int)length - 1) : -1; - name[size > 0 && (size_t)size < length ? (size_t)size : 0] = 0; - size = descriptor.iManufacturer ? libusb_get_string_descriptor_ascii(handle, descriptor.iManufacturer, (unsigned char *)manufacturer, (int)length - 1) : -1; - manufacturer[size > 0 && (size_t)size < length ? (size_t)size : 0] = 0; - libusb_close(handle); - } - } - break; - } - if (devices) libusb_free_device_list(devices, 1); - libusb_exit(context); - return found; -} -int hc_present(uint32_t bus, uint8_t address, uint16_t vendor, uint16_t product) { - return hc_usb(bus, address, vendor, product, NULL, NULL, 0); -} - -int hc_scan(device_cb callback, void *context) { - LIBMTP_raw_device_t *devices = NULL; - int count = 0; - LIBMTP_error_number_t result = LIBMTP_Detect_Raw_Devices(&devices, &count); - if (result == LIBMTP_ERROR_NO_DEVICE_ATTACHED) { free(devices); return 0; } - if (result != LIBMTP_ERROR_NONE) { free(devices); return -1; } - for (int i = 0; i < count; i++) { - LIBMTP_raw_device_t *raw = &devices[i]; - char manufacturer[256] = {0}, name[256] = {0}; - hc_usb(raw->bus_location, raw->devnum, raw->device_entry.vendor_id, - raw->device_entry.product_id, manufacturer, name, sizeof(name)); - /* An ID table entry is a driver match, not a trustworthy model name. */ - callback(context, raw->bus_location, raw->devnum, raw->device_entry.vendor_id, - raw->device_entry.product_id, *manufacturer ? manufacturer : raw->device_entry.vendor, - *name ? name : "MTP 设备"); - } - free(devices); - return 0; -} -void hc_identity(void *device, identity_cb callback, void *context) { - char *friendly = LIBMTP_Get_Friendlyname(device); - char *model = LIBMTP_Get_Modelname(device); - char *manufacturer = LIBMTP_Get_Manufacturername(device); - callback(context, friendly, model, manufacturer); - free(friendly); free(model); free(manufacturer); - LIBMTP_Clear_Errorstack(device); -} - -void *hc_open(uint32_t bus, uint8_t address) { - LIBMTP_raw_device_t *devices = NULL; - int count = 0; - if (LIBMTP_Detect_Raw_Devices(&devices, &count) != LIBMTP_ERROR_NONE) { free(devices); return NULL; } - LIBMTP_mtpdevice_t *device = NULL; - for (int i = 0; i < count; i++) { - if (devices[i].bus_location == bus && devices[i].devnum == address) { - device = LIBMTP_Open_Raw_Device_Uncached(&devices[i]); - break; - } - } - free(devices); - return device; -} -void hc_close(void *device) { if (device) LIBMTP_Release_Device(device); } -void hc_error(void *device, char *buffer, size_t length) { - LIBMTP_error_t *error = LIBMTP_Get_Errorstack(device); - snprintf(buffer, length, "%s", error && error->error_text ? error->error_text : "设备没有完成操作"); -} -int hc_storages(void *device, storage_cb callback, void *context) { - LIBMTP_mtpdevice_t *mtp = device; - LIBMTP_Clear_Errorstack(mtp); - if (LIBMTP_Get_Storage(mtp, LIBMTP_STORAGE_SORTBY_NOTSORTED) != 0) return -1; - for (LIBMTP_devicestorage_t *storage = mtp->storage; storage; storage = storage->next) { - callback(context, storage->id, storage->StorageDescription, storage->MaxCapacity, storage->FreeSpaceInBytes); - } - return 0; -} -static void hc_emit_file(LIBMTP_file_t *file, file_cb callback, void *context) { - callback(context, file->item_id, file->parent_id, file->storage_id, file->filename, - file->filesize, (int64_t)file->modificationdate, file->filetype == LIBMTP_FILETYPE_FOLDER); -} -int hc_list(void *device, uint32_t storage, uint32_t parent, file_cb callback, void *context) { - LIBMTP_Clear_Errorstack(device); - LIBMTP_file_t *files = LIBMTP_Get_Files_And_Folders(device, storage, parent); - int result = LIBMTP_Get_Errorstack(device) ? -1 : 0; - while (files) { - LIBMTP_file_t *next = files->next; - hc_emit_file(files, callback, context); - LIBMTP_destroy_file_t(files); - files = next; - } - return result; -} -int hc_metadata(void *device, uint32_t object, file_cb callback, void *context) { - LIBMTP_Clear_Errorstack(device); - LIBMTP_file_t *file = LIBMTP_Get_Filemetadata(device, object); - if (!file) return -1; - hc_emit_file(file, callback, context); - LIBMTP_destroy_file_t(file); - return 0; -} -int hc_upload(void *device, int fd, const char *name, uint64_t size, uint32_t storage, uint32_t parent, - LIBMTP_progressfunc_t progress, const void *context, uint32_t *object) { - LIBMTP_Clear_Errorstack(device); - LIBMTP_file_t *file = LIBMTP_new_file_t(); - if (!file) return -1; - file->filename = strdup(name); - if (!file->filename) { LIBMTP_destroy_file_t(file); return -1; } - file->filesize = size; - file->filetype = LIBMTP_FILETYPE_UNKNOWN; - file->storage_id = storage; - file->parent_id = parent == LIBMTP_FILES_AND_FOLDERS_ROOT ? 0 : parent; - int result = LIBMTP_Send_File_From_File_Descriptor(device, fd, file, progress, context); - *object = file->item_id; - LIBMTP_destroy_file_t(file); - return result; -} -int hc_download(void *device, uint32_t object, int fd, LIBMTP_progressfunc_t progress, const void *context) { - LIBMTP_Clear_Errorstack(device); - return LIBMTP_Get_File_To_File_Descriptor(device, object, fd, progress, context); -} -uint32_t hc_mkdir(void *device, const char *name, uint32_t storage, uint32_t parent) { - LIBMTP_Clear_Errorstack(device); - char *copy = strdup(name); - if (!copy) return 0; - uint32_t id = LIBMTP_Create_Folder(device, copy, parent == LIBMTP_FILES_AND_FOLDERS_ROOT ? 0 : parent, storage); - free(copy); - return id; -} diff --git a/curios/harmony-connector/native/src/progress.rs b/curios/harmony-connector/native/src/progress.rs deleted file mode 100644 index 5bf2c90..0000000 --- a/curios/harmony-connector/native/src/progress.rs +++ /dev/null @@ -1,154 +0,0 @@ -use serde_json::{json, Value}; -use std::{ - collections::VecDeque, - sync::{ - atomic::{AtomicBool, AtomicU64, Ordering}, - Mutex, - }, - time::{Duration, Instant}, -}; - -struct Meter { - started: Instant, - finished: Option, - samples: VecDeque<(Duration, u64)>, - name: String, -} -impl Default for Meter { - fn default() -> Self { - Self { - started: Instant::now(), - finished: None, - samples: VecDeque::from([(Duration::ZERO, 0)]), - name: String::new(), - } - } -} -impl Meter { - fn sample(&mut self, time: Duration, sent: u64) { - if self.samples.len() > 1 - && time.as_millis() / 200 == self.samples.back().unwrap().0.as_millis() / 200 - { - self.samples.pop_back(); - } - self.samples.push_back((time, sent)); - self.trim(time); - } - fn trim(&mut self, time: Duration) { - let cutoff = time.saturating_sub(Duration::from_secs(3)); - while self.samples.len() > 1 && self.samples[1].0 <= cutoff { - self.samples.pop_front(); - } - } - fn rates(&mut self, time: Duration, sent: u64, total: u64) -> (f64, f64, Option) { - self.trim(time); - let (at, bytes) = self.samples.front().copied().unwrap_or_default(); - let seconds = time.saturating_sub(at).as_secs_f64(); - let speed = if seconds >= 0.1 { - sent.saturating_sub(bytes) as f64 / seconds - } else { - 0.0 - }; - let average = if time.as_secs_f64() >= 0.1 { - sent as f64 / time.as_secs_f64() - } else { - 0.0 - }; - let remaining = (speed > 0.0 && total > sent).then(|| (total - sent) as f64 / speed); - (speed, average, remaining) - } -} - -#[derive(Default)] -pub struct Progress { - pub sent: AtomicU64, - pub total: AtomicU64, - pub cancel: AtomicBool, - pub disconnected: AtomicBool, - base: AtomicU64, - files: AtomicU64, - done: AtomicU64, - meter: Mutex, -} -impl Progress { - pub fn prepare(&self, total: u64, files: u64) { - self.total.store(total, Ordering::Relaxed); - self.files.store(files, Ordering::Relaxed); - *self.meter.lock().unwrap() = Meter::default(); - } - pub fn begin_file(&self, name: &str) { - self.base - .store(self.sent.load(Ordering::Relaxed), Ordering::Relaxed); - self.meter.lock().unwrap().name = name.into(); - } - pub fn update(&self, sent: u64, _file_total: u64) { - let sent = self - .base - .load(Ordering::Relaxed) - .saturating_add(sent) - .min(self.total.load(Ordering::Relaxed)); - self.sent.store(sent, Ordering::Relaxed); - let mut meter = self.meter.lock().unwrap(); - let elapsed = meter.started.elapsed(); - meter.sample(elapsed, sent); - } - pub fn complete_file(&self) { - self.done.fetch_add(1, Ordering::Relaxed); - } - pub fn finish(&self) { - let mut meter = self.meter.lock().unwrap(); - meter.finished = Some(meter.started.elapsed()); - } - pub fn cancelled(&self) -> bool { - self.cancel.load(Ordering::Relaxed) - } - pub fn statistics(&self) -> Value { - let sent = self.sent.load(Ordering::Relaxed); - let total = self.total.load(Ordering::Relaxed); - let mut meter = self.meter.lock().unwrap(); - let elapsed = meter.finished.unwrap_or_else(|| meter.started.elapsed()); - let (speed, average, remaining) = meter.rates(elapsed, sent, total); - json!({"bytesPerSecond":speed,"averageBytesPerSecond":average,"elapsedSeconds":elapsed.as_secs_f64(),"remainingSeconds":remaining,"currentFile":meter.name,"filesCompleted":self.done.load(Ordering::Relaxed),"filesTotal":self.files.load(Ordering::Relaxed)}) - } -} - -#[cfg(test)] -mod tests { - use super::*; - #[test] - fn measures_rate_and_eta_and_stops_claiming_throughput_when_stalled() { - let mut meter = Meter::default(); - meter.sample(Duration::from_secs(1), 1024); - meter.sample(Duration::from_secs(2), 2048); - assert_eq!( - meter.rates(Duration::from_secs(2), 2048, 4096), - (1024.0, 1024.0, Some(2.0)) - ); - let (speed, _, remaining) = meter.rates(Duration::from_secs(6), 2048, 4096); - assert_eq!(speed, 0.0); - assert_eq!(remaining, None); - assert_eq!( - Meter::default().rates(Duration::ZERO, 0, 0), - (0.0, 0.0, None) - ); - } - #[test] - fn folder_progress_accumulates_file_offsets_and_counts_empty_files() { - let progress = Progress::default(); - progress.prepare(150, 3); - for (name, size) in [("a", 100), ("b", 0), ("c", 50)] { - progress.begin_file(name); - progress.update(size, size); - progress.complete_file(); - } - progress.finish(); - assert_eq!(progress.sent.load(Ordering::Relaxed), 150); - let stats = progress.statistics(); - assert_eq!(stats["filesCompleted"], 3); - assert_eq!(stats["currentFile"], "c"); - assert_eq!( - stats["elapsedSeconds"], - progress.statistics()["elapsedSeconds"] - ); - } -} diff --git a/curios/harmony-connector/native/src/wire.rs b/curios/harmony-connector/native/src/wire.rs deleted file mode 100644 index b32c5b7..0000000 --- a/curios/harmony-connector/native/src/wire.rs +++ /dev/null @@ -1,148 +0,0 @@ -use crate::{connector::Connector, model::Fault}; -use anyhow::{bail, Context, Result}; -use serde_json::{json, Value}; -use std::{ - collections::HashSet, - env, fs, - io::{Read, Write}, - os::unix::net::UnixStream, - sync::{Arc, Mutex}, - thread, - time::{Duration, Instant}, -}; -const MAX_FRAME: usize = 8 * 1024 * 1024; -#[derive(Clone)] -struct Writer(Arc>); -impl Writer { - fn send(&self, value: Value) -> Result<()> { - let bytes = serde_json::to_vec(&value)?; - if bytes.len() > MAX_FRAME { - bail!("Response exceeds frame limit"); - } - let mut stream = self.0.lock().unwrap(); - stream.write_all(&(bytes.len() as u32).to_be_bytes())?; - stream.write_all(&bytes)?; - stream.flush()?; - Ok(()) - } - fn response(&self, id: Value, result: crate::model::Result) { - let value = match result { - Ok(value) => json!({"v":1,"type":"response","id":id,"result":value}), - Err(error) => json!({"v":1,"type":"response","id":id,"error":error}), - }; - if let Err(error) = self.send(value) { - eprintln!("Cannot send response: {error}"); - } - } -} -fn read(stream: &mut UnixStream) -> Result { - let mut header = [0; 4]; - stream.read_exact(&mut header)?; - let length = u32::from_be_bytes(header) as usize; - if length == 0 || length > MAX_FRAME { - bail!("Invalid frame length"); - } - let mut bytes = vec![0; length]; - stream.read_exact(&mut bytes)?; - let value: Value = serde_json::from_slice(&bytes)?; - if !value.is_object() || value["v"] != 1 { - bail!("Invalid wire version"); - } - Ok(value) -} -fn session() -> Result<(String, String)> { - if let Ok(path) = env::var("TROVE_DEV_SESSION_FILE") { - let value: Value = serde_json::from_slice(&fs::read(path)?)?; - return Ok(( - value["socketPath"] - .as_str() - .context("Missing socketPath")? - .into(), - value["sessionToken"] - .as_str() - .context("Missing sessionToken")? - .into(), - )); - } - Ok(( - env::var("TROVE_SOCKET_PATH").context("Open this plugin from Trove")?, - env::var("TROVE_SESSION_TOKEN")?, - )) -} -pub fn run(connector: Arc) -> Result<()> { - let (path, token) = session()?; - let mut reader = UnixStream::connect(path)?; - reader.set_read_timeout(Some(Duration::from_secs(5)))?; - reader.set_write_timeout(Some(Duration::from_secs(5)))?; - let writer = Writer(Arc::new(Mutex::new(reader.try_clone()?))); - writer.send(json!({"v":1,"type":"hello","client":"backend","sessionToken":token,"protocol":{"min":1,"max":1},"features":["call"],"implementation":{"name":"harmony-connector","version":env!("CARGO_PKG_VERSION"),"language":"rust"}}))?; - let welcome = read(&mut reader)?; - if welcome["type"] != "welcome" || welcome["protocol"] != 1 { - bail!("Expected Wire v1 welcome"); - } - writer.send(json!({"v":1,"type":"ready"}))?; - reader.set_read_timeout(None)?; - let active = Arc::new(Mutex::new(HashSet::new())); - let mut shutdown = false; - while let Ok(message) = read(&mut reader) { - let id = message["id"].clone(); - match message["type"].as_str() { - Some("request") => { - let key = id.to_string(); - { - let mut calls = active.lock().unwrap(); - if !id.is_string() && !id.is_number() { - bail!("Invalid request ID"); - } - if calls.contains(&key) { - bail!("Duplicate request ID"); - } - if calls.len() >= 16 { - writer.response( - id, - Err(Fault::new( - "request.too_many_in_flight", - "操作过于频繁,请稍后重试。", - )), - ); - continue; - } - calls.insert(key.clone()); - } - let (connector, active, writer) = - (connector.clone(), active.clone(), writer.clone()); - thread::spawn(move || { - let result = if message["service"] != "@self" { - Err(Fault::new("service.not_found", "未知服务。")) - } else { - connector.call( - message["method"].as_str().unwrap_or(""), - message.get("params").cloned().unwrap_or_else(|| json!({})), - ) - }; - writer.response(id, result); - active.lock().unwrap().remove(&key); - }); - } - Some("shutdown") => { - shutdown = true; - break; - } - Some("protocolError") => break, - // Transfer cancellation is an explicit job operation; no Wire cancel feature is advertised. - _ => {} - } - } - connector.stop(); - let deadline = Instant::now() + Duration::from_millis(3500); - while connector.busy() && Instant::now() < deadline { - thread::sleep(Duration::from_millis(20)); - } - if shutdown { - writer.send(json!({"v":1,"type":"goodbye"}))?; - reader.set_read_timeout(Some(Duration::from_millis(250)))?; - let mut eof = [0]; - let _ = reader.read(&mut eof); - } - Ok(()) -} diff --git a/curios/harmony-connector/native/tests/hypium_device.py b/curios/harmony-connector/native/tests/hypium_device.py deleted file mode 100644 index 8ebb43a..0000000 --- a/curios/harmony-connector/native/tests/hypium_device.py +++ /dev/null @@ -1,109 +0,0 @@ -#!/usr/bin/env python3 -"""Local test peer for the public HDC and Hypium protocol. Never contacts USB.""" -import json -import os -from pathlib import Path -import signal -import socket -import struct -import subprocess -import sys -import threading -import time - -ROOT = Path(__file__).parent -HEAD = b'_uitestkit_rpc_message_head_' -TAIL = b'_uitestkit_rpc_message_tail_' -def path(name): return ROOT / ('hdc.' + name) -def log(value): - with path('calls').open('a') as f: f.write(value + '\n') -def exact(sock, length): - data = bytearray() - while len(data) < length: - chunk = sock.recv(length-len(data)) - if not chunk: raise EOFError() - data.extend(chunk) - return bytes(data) -def client(sock): - done = threading.Event() - write_lock = threading.Lock() - def send(data): - with write_lock: sock.sendall(data) - def video(): - previous = None - try: - while not done.wait(0.01) and ROOT.exists(): - if path('slow').exists(): - path('capturing').touch() - continue - jpeg = path('jpg').read_bytes() - if jpeg != previous: - send(jpeg+b'\n') - previous=jpeg - except (OSError, EOFError): pass - try: - while True: - assert exact(sock,len(HEAD)) == HEAD - _,length=struct.unpack('>II',exact(sock,8)) - assert 0 < length <= 65536 - message=json.loads(exact(sock,length)) - assert exact(sock,len(TAIL)) == TAIL - params=message['params']; api=params['api'] - log(json.dumps({'api':api,'args':params['args']})) - result=None - if api=='Driver.create': result='Driver#fixture' - elif api=='Driver.getDisplaySize': result=json.loads(path('dimensions').read_text()) - elif api=='startCaptureScreen': result=True - elif api=='stopCaptureScreen': result=True;done.set() - elif path('fail-input').exists(): - send(b'{"exception":{"code":17000005,"message":"input not permitted"}}\n') - continue - send(json.dumps({'result':result}).encode()+b'\n') - if api=='startCaptureScreen': threading.Thread(target=video,daemon=True).start() - except (OSError,EOFError): pass - finally: done.set();sock.close() -def serve(port): - with socket.socket() as listener: - listener.bind(('127.0.0.1',port));listener.listen() - path('ready').touch() - while True: - sock,_=listener.accept() - threading.Thread(target=client,args=(sock,),daemon=True).start() -if sys.argv[1:2]==['server']: - serve(int(sys.argv[2]));sys.exit() -args=sys.argv[1:] -log(' '.join(args)) -if args[:2]==['-t','phone']: args=args[2:] -if args==['list','targets','-v']: print('phone USB Connected device') -elif args==['shell','uitest uiInput help']: print('click doubleClick longClick swipe drag text keyEvent') -elif args in (['shell','pidof uitest'], ['shell','pidof uitest xdevice_scrcpy']): - if path('daemon').exists(): print('4242') -elif args[:2]==['file','send']: print('FileTransfer finish') -elif args[0]=='shell' and args[1].startswith('uitest start-daemon'): - path('daemon').write_text(args[1].split()[-1]) -elif args[0]=='shell' and args[1]=='cat /proc/4242/cmdline': - if path('daemon').exists(): sys.stdout.write('uitest\0start-daemon\0singleness\0--extension-name\0'+path('daemon').read_text()+'\0') - else: print('cat: No such file or directory') -elif args[:2]==['shell','kill -9 4242']: - if path('deny-kill').exists(): print('/bin/sh: kill: 4242: Permission denied') - else: path('daemon').unlink(missing_ok=True) -elif args[0]=='shell' and args[1].startswith('rm -f /data/local/tmp/'): pass -elif args[0]=='shell' and args[1].startswith('uitest uiInput text '): print('completed') -elif args==['fport','ls']: - if path('forward').exists(): print('phone '+path('forward').read_text()+' localabstract:uitest_socket [Forward]') - else: print('[Empty]') -elif args[:2]==['fport','rm']: - pid=int(path('pid').read_text());os.kill(pid,signal.SIGTERM) - path('pid').unlink();path('ready').unlink(missing_ok=True);path('forward').unlink(missing_ok=True) - print('Remove forward ruler success') -elif args[0]=='fport': - path('forward').write_text(args[1]) - process=subprocess.Popen([sys.executable,__file__,'server',args[1].split(':')[1]], - stdin=subprocess.DEVNULL,stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL,start_new_session=True) - path('pid').write_text(str(process.pid)) - until=time.monotonic()+3 - while not path('ready').exists(): - if time.monotonic()>until: raise RuntimeError('Fixture listener not ready') - time.sleep(0.01) - print('Forwardport result:OK') -else: print('[Fail] unexpected invocation') diff --git a/curios/harmony-connector/native/tests/mtp_bridge_test.c b/curios/harmony-connector/native/tests/mtp_bridge_test.c deleted file mode 100644 index 3ed4c4d..0000000 --- a/curios/harmony-connector/native/tests/mtp_bridge_test.c +++ /dev/null @@ -1,64 +0,0 @@ -/* Hardware-boundary regression: USB identity can differ from libmtp's ID table. */ -#include -#include -#include -#include -#include -static int attached = 1; -int libusb_init(libusb_context **context) { *context = (void *)1; return 0; } -void libusb_exit(libusb_context *context) { (void)context; } -ssize_t libusb_get_device_list(libusb_context *context, libusb_device ***list) { - (void)context; - *list = calloc(2, sizeof(**list)); - if (attached) (*list)[0] = (void *)2; - return attached; -} -void libusb_free_device_list(libusb_device **list, int unref) { (void)unref; free(list); } -uint8_t libusb_get_bus_number(libusb_device *device) { (void)device; return 7; } -uint8_t libusb_get_device_address(libusb_device *device) { (void)device; return 3; } -int libusb_get_device_descriptor(libusb_device *device, struct libusb_device_descriptor *out) { - (void)device; memset(out, 0, sizeof(*out)); out->idVendor = 0x12d1; out->idProduct = 0x1101; out->iManufacturer = 1; out->iProduct = 2; return 0; -} -int libusb_open(libusb_device *device, libusb_device_handle **handle) { (void)device; *handle = (void *)3; return 0; } -void libusb_close(libusb_device_handle *handle) { (void)handle; } -int libusb_get_string_descriptor_ascii(libusb_device_handle *handle, uint8_t index, unsigned char *out, int length) { - (void)handle; return snprintf((char *)out, length, "%s", index == 1 ? "HUAWEI" : "HUAWEI Pura 70 Ultra"); -} -LIBMTP_error_number_t LIBMTP_Detect_Raw_Devices(LIBMTP_raw_device_t **devices, int *count) { - *devices = calloc(1, sizeof(**devices)); *count = 1; - **devices = (LIBMTP_raw_device_t) { .bus_location = 7, .devnum = 3, - .device_entry = { .vendor = "Huawei", .vendor_id = 0x12d1, .product = "Nova14", .product_id = 0x1101 } }; - return LIBMTP_ERROR_NONE; -} -char *LIBMTP_Get_Friendlyname(LIBMTP_mtpdevice_t *device) { (void)device; return strdup("daniel"); } -char *LIBMTP_Get_Modelname(LIBMTP_mtpdevice_t *device) { (void)device; return strdup("HUAWEI Pura 70 Ultra"); } -char *LIBMTP_Get_Manufacturername(LIBMTP_mtpdevice_t *device) { (void)device; return strdup("HUAWEI"); } -void LIBMTP_Clear_Errorstack(LIBMTP_mtpdevice_t *device) { (void)device; } -static char name[256]; -static void scanned(void *ctx, uint32_t bus, uint8_t address, uint16_t vendor, uint16_t product, const char *manufacturer, const char *value) { - (void)ctx; (void)bus; (void)address; (void)vendor; (void)product; (void)manufacturer; - snprintf(name, sizeof(name), "%s", value); -} -extern int hc_scan(void (*callback)(void *, uint32_t, uint8_t, uint16_t, uint16_t, const char *, const char *), void *); -extern int hc_present(uint32_t, uint8_t, uint16_t, uint16_t); -extern void hc_identity(void *, void (*)(void *, const char *, const char *, const char *), void *); -static int identity_ok; -static void identity(void *context, const char *friendly, const char *model, const char *manufacturer) { - (void)context; - identity_ok = !strcmp(friendly, "daniel") && !strcmp(model, "HUAWEI Pura 70 Ultra") && !strcmp(manufacturer, "HUAWEI"); -} -int main(void) { - if (hc_scan(scanned, NULL) != 0) return 1; - if (strcmp(name, "HUAWEI Pura 70 Ultra")) { - fprintf(stderr, "FAIL scan name: expected device USB identity HUAWEI Pura 70 Ultra; got %s\n", name); return 1; - } - puts("PASS scan uses device identity instead of libmtp model table"); - hc_identity(NULL, identity, NULL); - if (!identity_ok) { fputs("FAIL actual friendly name/model\n", stderr); return 1; } - puts("PASS connected device reports daniel and its actual model"); - if (hc_present(7, 3, 0x12d1, 0x1101) != 1 || hc_present(7, 3, 0xabcd, 0x1101) != 0) return 1; - attached = 0; - if (hc_present(7, 3, 0x12d1, 0x1101) != 0) return 1; - puts("PASS unplug is detected without opening an MTP session"); - return 0; -} diff --git a/curios/harmony-connector/ui/app.js b/curios/harmony-connector/ui/app.js deleted file mode 100644 index 2552320..0000000 --- a/curios/harmony-connector/ui/app.js +++ /dev/null @@ -1,558 +0,0 @@ -import { management } from "./management.js"; -import { trove } from "./sdk/index.js"; -import { - ROOT, - bytes, - date, - percent, - filterEntries, - transferMetrics, -} from "./format.js"; - -const $ = (id) => document.getElementById(id); -const state = { - view: "files", - connection: null, - storage: null, - path: [], - entries: [], - total: 0, - busy: false, - job: null, - timer: null, - monitorTimer: null, - monitorTask: null, - backendBusy: false, - devices: [], - revision: 0, - disposed: false, -}; -const native = (method, params = {}) => - trove.self.call(method, params, { timeoutMs: 120_000 }); -const host = (method, params) => - trove.call("host.dialog", method, params, { timeoutMs: 300_000 }); -const parent = () => state.path.at(-1)?.id ?? ROOT; -function notice(message) { - $("notice-text").textContent = message; - $("notice").hidden = !message; -} -function text(tag, value, className) { - const element = document.createElement(tag); - element.textContent = value; - if (className) element.className = className; - return element; -} -function button(label, className, action) { - const element = text("button", label, className); - element.type = "button"; - element.addEventListener("click", action); - return element; -} -function controls() { - const locked = - state.busy || state.backendBusy || state.job?.state === "running"; - for (const id of [ - "scan", - "connect", - "disconnect", - "refresh", - "mkdir", - "upload", - "upload-folder", - "load-more", - ]) - $(id).disabled = locked; - for (const container of ["files", "storages", "breadcrumbs"]) - for (const button of $(container).querySelectorAll("button")) - button.disabled = locked; - $("device-select").disabled = locked; - $("connect").disabled = locked || !$("device-select").value; - $("footer-status").textContent = state.busy - ? "正在与设备通信…" - : state.backendBusy && !state.connection - ? "设备已断开,正在结束上一项操作…" - : state.job?.state === "running" - ? "传输中 · 请保持连接" - : state.connection - ? "已连接 · USB 文件传输" - : "准备就绪"; -} -async function action(operation) { - if (state.busy || state.backendBusy || state.job?.state === "running") return; - state.revision++; - state.busy = true; - controls(); - notice(""); - try { - await state.monitorTask; - if (state.disposed) return; - await operation(); - } catch (error) { - notice(error.message || String(error)); - await syncConnection().catch(() => {}); - } finally { - state.busy = false; - controls(); - } -} -function renderConnection() { - const connection = state.connection; - $("welcome").hidden = !!connection; - $("browser").hidden = !connection; - $("scan").hidden = !!connection || state.view !== "files"; - $("disconnect").hidden = !connection; - $("device-name").textContent = - connection?.device.name || (connection ? "鸿蒙设备" : "等待连接"); - $("device-name").title = connection?.device.name || ""; - $("device-model").textContent = connection?.device.model || ""; - $("device-model").title = connection?.device.model || ""; - $("device-model").hidden = - !connection?.device.model || - connection.device.model === connection.device.name; - $("device-state").textContent = connection ? "已连接 · USB" : "USB 文件传输"; - $("device-dot").classList.toggle("connected", !!connection); - $("page-title").textContent = - state.view === "management" - ? "设备与应用" - : connection - ? "设备中的文件" - : "连接你的鸿蒙设备"; - $("storages").replaceChildren(); - if (!connection) { - $("storages").append(text("p", "连接后查看可用存储", "muted")); - return; - } - for (const storage of connection.storages) { - const item = button("", storage.id === state.storage ? "active" : "", () => - action(async () => { - state.storage = storage.id; - state.path = []; - $("search").value = ""; - renderConnection(); - await load(); - }), - ); - item.append( - text("span", storage.name || "设备存储", "storage-title"), - text( - "span", - `${bytes(storage.free)} 可用 / ${bytes(storage.capacity)}`, - "storage-capacity", - ), - ); - item.setAttribute( - "aria-current", - storage.id === state.storage ? "page" : "false", - ); - $("storages").append(item); - } -} -function resetBrowser() { - state.storage = null; - state.path = []; - state.entries = []; - state.total = 0; - $("search").value = ""; - $("files").replaceChildren(); - $("breadcrumbs").replaceChildren(); - $("file-count").textContent = ""; - $("load-more").hidden = true; - if ($("folder-dialog").open) $("folder-dialog").close(); -} -function applyConnection(connection) { - if (!connection && state.job?.state === "completed") { - state.job = null; - renderTransfer(); - } - if ( - !connection || - connection.generation !== state.connection?.generation || - connection.device.id !== state.connection?.device.id - ) - resetBrowser(); - if (state.connection && !connection) renderDevices([]); - state.connection = connection; - if (connection && !connection.storages.some((s) => s.id === state.storage)) { - state.storage = state.connection.storages[0]?.id; - state.path = []; - } - renderConnection(); - controls(); -} -async function syncConnection(revision = state.revision) { - const info = await native("info"); - if (state.disposed || revision !== state.revision) return; - state.backendBusy = info.busy; - applyConnection(info.connection); -} -function renderDevices(devices) { - const previous = $("device-select").value; - if (JSON.stringify(devices) !== JSON.stringify(state.devices)) { - $("device-select").replaceChildren(); - for (const device of devices) { - const option = text( - "option", - `${device.name || device.manufacturer || "MTP 设备"} · ${device.id}`, - ); - option.value = device.id; - $("device-select").append(option); - } - if (devices.some((device) => device.id === previous)) - $("device-select").value = previous; - } - state.devices = devices; - $("device-picker").hidden = !devices.length; - $("scan-status").textContent = devices.length - ? `发现 ${devices.length} 台设备,选择后连接。` - : "正在自动寻找设备。请解锁手机并选择「传输文件」。"; - controls(); -} -async function scan() { - $("scan-status").textContent = "正在扫描 USB 设备…"; - const { devices } = await native("devices"); - if (!state.disposed) renderDevices(devices); -} -function scheduleMonitor() { - clearTimeout(state.monitorTimer); - if (!state.disposed) state.monitorTimer = setTimeout(monitor, 2000); -} -async function monitor() { - if (state.disposed) return; - if (state.busy || state.monitorTask) { - scheduleMonitor(); - return; - } - const revision = state.revision; - state.monitorTask = (async () => { - try { - const snapshot = await native("monitor"); - if (state.disposed || revision !== state.revision) return; - state.backendBusy = snapshot.busy; - if ( - JSON.stringify(snapshot.connection) !== JSON.stringify(state.connection) - ) - applyConnection(snapshot.connection); - if (!snapshot.connection) renderDevices(snapshot.devices); - controls(); - } catch (error) { - if (state.disposed || revision !== state.revision) return; - $("scan-status").textContent = - `自动检测暂时不可用:${error.message || String(error)}`; - if ( - error.code?.startsWith("bridge.") || - error.code?.startsWith("provider.") - ) { - state.backendBusy = false; - applyConnection(null); - renderDevices([]); - } - } - })(); - try { - await state.monitorTask; - } finally { - state.monitorTask = null; - scheduleMonitor(); - } -} -function breadcrumbs() { - $("breadcrumbs").replaceChildren(); - const root = - state.connection.storages.find((s) => s.id === state.storage)?.name || - "设备存储"; - for (const [index, item] of [{ name: root }, ...state.path].entries()) { - if (index) $("breadcrumbs").append(text("span", "/")); - const crumb = button(item.name, "", () => - action(async () => { - state.path = state.path.slice(0, index); - $("search").value = ""; - await load(); - }), - ); - crumb.title = item.name; - if (index === state.path.length) crumb.setAttribute("aria-current", "page"); - $("breadcrumbs").append(crumb); - } -} -async function load(append = false) { - const result = await native("list", { - storage: state.storage, - parent: parent(), - offset: append ? state.entries.length : 0, - }); - state.entries = append - ? state.entries.concat(result.entries) - : result.entries; - state.total = result.total; - breadcrumbs(); - renderFiles(); -} -function icon(folder) { - const span = document.createElement("span"); - span.className = `file-icon${folder ? " folder" : ""}`; - span.setAttribute("aria-hidden", "true"); - // Only constant SVG paths enter innerHTML. Device-provided names always use textContent. - span.innerHTML = folder - ? '' - : ''; - return span; -} -function renderFiles() { - const entries = filterEntries(state.entries, $("search").value); - $("files").replaceChildren(); - for (const entry of entries) { - const row = document.createElement("tr"); - const nameCell = document.createElement("td"); - const name = entry.folder - ? button("", "entry-name", () => - action(async () => { - const previous = [...state.path]; - state.path.push({ id: entry.id, name: entry.name }); - $("search").value = ""; - try { - await load(); - } catch (error) { - state.path = previous; - throw error; - } - }), - ) - : text("div", "", "entry-name"); - name.title = entry.name; - name.append(icon(entry.folder), text("span", entry.name)); - nameCell.append(name); - const operations = document.createElement("td"); - { - const receive = button("接收", "download-button", () => - action(async () => { - const { path } = await host("openDirectory", { - title: `接收「${entry.name}」到…`, - }); - if (path) - await start({ - direction: "download", - object: entry.id, - directory: path, - }); - }), - ); - receive.setAttribute("aria-label", `接收 ${entry.name}`); - operations.append(receive); - } - row.append( - nameCell, - text("td", entry.folder ? "—" : bytes(entry.size)), - text("td", date(entry.modified)), - operations, - ); - $("files").append(row); - } - $("file-count").textContent = $("search").value - ? `${entries.length} 个匹配 · 已加载 ${state.entries.length}` - : `${state.total} 个项目`; - $("empty").hidden = entries.length > 0; - $("empty").textContent = state.entries.length - ? "已加载的文件中没有匹配项。" - : "这个文件夹还是空的,发送第一个文件吧。"; - $("load-more").hidden = state.entries.length >= state.total; - controls(); -} -async function start(params) { - const job = await native("transfer", params); - state.job = { ...job, state: "running", sent: 0, total: 0 }; - renderTransfer(); - schedulePoll(); -} -function renderTransfer() { - const job = state.job; - $("transfer").hidden = !job; - if (!job) return; - const running = job.state === "running"; - $("transfer-title").textContent = - job.cancelling && running - ? "正在取消,请等待设备响应…" - : { - running: "正在传输", - completed: "传输完成", - cancelled: "传输已取消", - failed: "传输未完成", - }[job.state]; - $("transfer-detail").textContent = - job.error?.message || - (job.result?.path - ? `已保存到 ${job.result.path}` - : job.result?.name - ? `${job.result.name} · ${bytes(job.result.bytes)}` - : job.total - ? `${bytes(job.sent)} / ${bytes(job.total)}` - : "正在准备文件…"); - $("transfer-metrics").textContent = transferMetrics(job); - $("transfer-current").textContent = running - ? job.statistics?.currentFile || "" - : ""; - $("cancel").hidden = !running; - $("cancel").disabled = !!job.cancelling; - $("progress").value = - job.state === "completed" ? 100 : percent(job.sent, job.total); - $("progress").hidden = !running && job.state !== "completed"; - controls(); -} -function schedulePoll() { - clearTimeout(state.timer); - if (!state.disposed) state.timer = setTimeout(poll, 400); -} -async function poll() { - const revision = state.revision; - try { - const job = await native("transferStatus"); - if (state.disposed || revision !== state.revision) return; - state.job = job; - renderTransfer(); - if (state.job?.state === "running") schedulePoll(); - else { - await syncConnection(revision); - if (state.disposed || revision !== state.revision) return; - if (state.connection) await action(() => load()); - } - } catch (error) { - if (state.disposed || revision !== state.revision) return; - state.backendBusy = false; - applyConnection(null); - state.job = { - ...state.job, - state: "failed", - error: { message: "连接已中断,请重新打开插件确认设备状态。" }, - }; - renderTransfer(); - notice(error.message || String(error)); - } -} -$("scan").addEventListener("click", () => action(scan)); -$("connect").addEventListener("click", () => - action(async () => { - const connection = await native("connect", { - id: $("device-select").value, - }); - applyConnection(connection); - state.job = null; - renderTransfer(); - await load(); - }), -); -$("disconnect").addEventListener("click", () => - action(async () => { - const result = await native("disconnect"); - clearTimeout(state.timer); - state.job = null; - state.backendBusy = !!result.busy; - applyConnection(null); - renderTransfer(); - if (!state.backendBusy) await scan(); - }), -); -$("refresh").addEventListener("click", () => action(() => load())); -$("load-more").addEventListener("click", () => action(() => load(true))); -$("search").addEventListener("input", renderFiles); -$("upload").addEventListener("click", () => - action(async () => { - const { path } = await host("openFile", { - title: "选择要发送到手机的文件", - }); - if (path) - await start({ - direction: "upload", - path, - storage: state.storage, - parent: parent(), - }); - }), -); -$("upload-folder").addEventListener("click", () => - action(async () => { - const { path } = await host("openDirectory", { - title: "选择要发送到手机的文件夹", - }); - if (path) - await start({ - direction: "upload", - path, - storage: state.storage, - parent: parent(), - }); - }), -); -$("cancel").addEventListener("click", async () => { - $("cancel").disabled = true; - try { - await native("cancelTransfer", { id: state.job.id }); - state.job.cancelling = true; - renderTransfer(); - } catch (error) { - notice(error.message || String(error)); - $("cancel").disabled = false; - } -}); -$("notice-close").addEventListener("click", () => notice("")); -$("guide-open").addEventListener("click", () => $("guide").showModal()); -$("guide-close").addEventListener("click", () => $("guide").close()); -$("mkdir").addEventListener("click", () => { - $("folder-name").value = ""; - $("folder-error").textContent = ""; - $("folder-dialog").showModal(); - $("folder-name").focus(); -}); -$("folder-cancel").addEventListener("click", () => $("folder-dialog").close()); -$("folder-form").addEventListener("submit", (event) => { - event.preventDefault(); - const name = $("folder-name").value; - if ( - !name.trim() || - name === "." || - name === ".." || - /[/\\:\x00-\x1f\x7f]/.test(name) - ) { - $("folder-error").textContent = "请输入有效的文件夹名称。"; - return; - } - $("folder-dialog").close(); - action(async () => { - await native("mkdir", { name, storage: state.storage, parent: parent() }); - await load(); - }); -}); -const openManagement = management({ - native, - host, - notice, - openUrl: (url) => trove.call("host.runtime", "openUrl", { url }), -}); -for (const view of ["files", "management"]) - $("tab-" + view).onclick = () => { - state.view = view; - $("file-panel").hidden = view !== "files"; - $("management").hidden = view !== "management"; - for (const tab of ["files", "management"]) - $("tab-" + tab).setAttribute( - "aria-current", - tab === view ? "page" : "false", - ); - renderConnection(); - openManagement(view === "management"); - }; -window.addEventListener("pagehide", () => { - state.disposed = true; - state.revision++; - clearTimeout(state.timer); - clearTimeout(state.monitorTimer); -}); -await action(async () => { - await trove.ready(); - await syncConnection(); - state.job = await native("transferStatus"); - if (!state.connection && state.job?.state === "completed") state.job = null; - renderTransfer(); - if (state.job?.state === "running") schedulePoll(); - else if (state.connection) await load(); - else await scan(); -}); -scheduleMonitor(); diff --git a/curios/harmony-connector/ui/format.js b/curios/harmony-connector/ui/format.js deleted file mode 100644 index 8ac616a..0000000 --- a/curios/harmony-connector/ui/format.js +++ /dev/null @@ -1,51 +0,0 @@ -export const ROOT = 0xffffffff; -export function bytes(value) { - if (!Number.isFinite(value) || value < 0 || value >= 2 ** 64 - 1) - return "未知"; - if (value < 1024) return `${value} B`; - const unit = Math.min(Math.floor(Math.log(value) / Math.log(1024)), 4); - return `${(value / 1024 ** unit).toFixed(unit > 1 ? 1 : 0)} ${["B", "KB", "MB", "GB", "TB"][unit]}`; -} -export function date(seconds) { - if (!seconds) return "—"; - const value = new Date(seconds * 1000); - return Number.isNaN(value.getTime()) - ? "—" - : value.toLocaleDateString("zh-CN"); -} -export function percent(sent, total) { - return total > 0 ? Math.min(100, Math.max(0, (sent / total) * 100)) : 0; -} -export function filterEntries(entries, query) { - const term = query.trim().toLocaleLowerCase(); - return entries.filter((entry) => - entry.name.toLocaleLowerCase().includes(term), - ); -} -export function duration(seconds) { - if (!Number.isFinite(seconds) || seconds < 0) return "—"; - const value = Math.ceil(seconds); - if (value < 60) return `${value} 秒`; - if (value < 3600) return `${Math.floor(value / 60)} 分 ${value % 60} 秒`; - return `${Math.floor(value / 3600)} 小时 ${Math.floor((value % 3600) / 60)} 分`; -} -export function transferMetrics(job) { - const stats = job?.statistics; - if (!stats) return "正在计算速度…"; - const parts = - job.state === "running" - ? [ - `${bytes(stats.bytesPerSecond)}/s`, - `平均 ${bytes(stats.averageBytesPerSecond)}/s`, - `已用 ${duration(stats.elapsedSeconds)}`, - ] - : [ - `平均 ${bytes(stats.averageBytesPerSecond)}/s`, - `用时 ${duration(stats.elapsedSeconds)}`, - ]; - if (job.state === "running" && stats.remainingSeconds != null) - parts.push(`剩余约 ${duration(stats.remainingSeconds)}`); - if (stats.filesTotal != null) - parts.push(`${stats.filesCompleted} / ${stats.filesTotal} 个文件`); - return parts.join(" · "); -} diff --git a/curios/harmony-connector/ui/format.test.js b/curios/harmony-connector/ui/format.test.js deleted file mode 100644 index bf337e9..0000000 --- a/curios/harmony-connector/ui/format.test.js +++ /dev/null @@ -1,46 +0,0 @@ -import { test } from "node:test"; -import assert from "node:assert/strict"; -import { - bytes, - date, - percent, - filterEntries, - duration, - transferMetrics, -} from "./format.js"; -test("renders byte counts, large files, unknown capacity and empty transfers", () => { - assert.equal(bytes(0), "0 B"); - assert.equal(bytes(5 * 1024 ** 3), "5.0 GB"); - assert.equal(bytes(2 ** 64 - 1), "未知"); - assert.equal(percent(2, 0), 0); - assert.equal(percent(5, 4), 100); - assert.equal(percent(1, 4), 25); - assert.equal(date(0), "—"); -}); -test("filters Unicode and case-insensitive names without changing originals", () => { - const entries = [{ name: "设计 FINAL.png" }, { name: " - - diff --git a/curios/harmony-connector/ui/management.js b/curios/harmony-connector/ui/management.js deleted file mode 100644 index c0bbcc7..0000000 --- a/curios/harmony-connector/ui/management.js +++ /dev/null @@ -1,452 +0,0 @@ -import { screen } from "./screen-window.js"; -import { bytes } from "./format.js"; - -export function management({ native, host, notice, openUrl }) { - const $ = (id) => document.getElementById(id); - const call = (method, params = {}) => native(`hdc.${method}`, params); - let screenPanel; - const state = { - screenBusy: false, - visible: false, - available: false, - monitorTimer: null, - ready: false, - working: false, - busy: false, - devices: [], - target: "", - capabilities: {}, - apps: [], - app: null, - job: null, - timer: null, - disposed: false, - }; - const label = (tag, value) => { - const el = document.createElement(tag); - el.textContent = value; - return el; - }; - function controls() { - const locked = state.working || state.busy || state.screenBusy; - for (const button of $("management").querySelectorAll("button")) - if (!button.closest("#screen-panel")) button.disabled = locked; - $("hdc-target").disabled = - locked || !state.available || !state.devices.length; - const connected = state.devices.some( - (d) => d.id === state.target && d.state === "Connected", - ); - for (const [id, capability] of [ - ["apps-refresh", "apps"], - ["app-install", "apps"], - ["device-screenshot", "screenshot"], - ["device-logs", "logs"], - ["device-report", "report"], - ]) { - $(id).disabled = - locked || !connected || !state.capabilities[capability]?.available; - $(id).title = state.capabilities[capability]?.reason || ""; - } - $("app-launch").disabled = - locked || - !state.app || - !$("app-ability").value || - !state.capabilities.launch?.available; - $("app-stop").disabled = - locked || !state.app || !state.capabilities.launch?.available; - $("app-uninstall").disabled = - locked || !state.app || !!state.app.details?.applicationInfo?.isSystemApp; - $("app-export").disabled = locked || !state.app?.debug; - screenPanel?.configure( - state.target, - connected && !!state.capabilities.screenshot?.available, - state.working || state.busy, - ); - $("hdc-cancel").disabled = - state.job?.state !== "running" || !!state.job?.cancelling; - } - async function action(fn) { - if (state.working || state.busy || state.screenBusy || state.disposed) - return; - state.working = true; - controls(); - notice(""); - try { - await fn(); - } catch (error) { - notice(error.message || String(error)); - } finally { - state.working = false; - controls(); - } - } - function clearDevice() { - state.app = null; - state.apps = []; - state.capabilities = {}; - $("app-detail").hidden = true; - $("apps-list").replaceChildren(); - $("apps-count").textContent = ""; - $("device-info").textContent = ""; - $("hdc-capabilities").replaceChildren(); - } - function showTool(tool) { - state.available = !!tool.available; - $("hdc-setup").hidden = state.available; - for (const id of ["hdc-connection", "screen-panel", "apps-panel"]) - $(id).hidden = !state.available; - $("hdc-tool-status").textContent = tool.available - ? `${tool.version} · ${tool.path}` - : tool.reason || "未发现 HDC 连接工具,请先完成以下准备。"; - if (!state.available) { - clearDevice(); - state.devices = []; - state.target = ""; - $("hdc-target").replaceChildren(); - $("hdc-connection-status").textContent = "配置官方工具后检查手机连接。"; - } - } - async function refreshTool() { - showTool(await call("tool")); - if (state.available) await refresh(); - } - function renderApps() { - const filter = $("apps-search").value.trim().toLowerCase(); - const apps = state.apps.filter((bundle) => - bundle.toLowerCase().includes(filter), - ); - $("apps-count").textContent = - `${apps.length} / ${state.apps.length} 个应用`; - $("apps-list").replaceChildren(); - for (const bundle of apps) { - const button = label("button", bundle); - button.className = "app-row"; - button.title = bundle; - button.setAttribute("aria-pressed", String(state.app?.bundle === bundle)); - button.onclick = () => - action(async () => { - state.app = null; - $("app-detail").hidden = true; - state.app = await call("app", { target: state.target, bundle }); - $("app-bundle").textContent = state.app.bundle; - $("app-version").textContent = - `${state.app.versionName || "版本未知"} (${state.app.versionCode ?? "—"}) · ${state.app.debug ? "调试签名" : "发布应用"}`; - $("app-ability").replaceChildren(); - for (const [index, ability] of state.app.abilities.entries()) { - const option = label( - "option", - `${ability.module} / ${ability.name}`, - ); - option.value = String(index); - $("app-ability").append(option); - } - $("app-ability-label").hidden = !state.app.abilities.length; - $("app-export-note").textContent = state.app.debug - ? "可尝试导出已启动应用的调试数据;能否读取以设备授权为准。" - : "发布应用的私有数据未开放导出。此处不提供整机或全应用备份。"; - $("app-detail").hidden = false; - renderApps(); - }); - $("apps-list").append(button); - } - controls(); - } - async function loadApps() { - state.apps = (await call("apps", { target: state.target })).apps; - renderApps(); - } - async function inspect() { - clearDevice(); - const device = state.devices.find((d) => d.id === state.target); - if (device?.state !== "Connected") { - $("hdc-connection-status").textContent = - device?.state === "Unauthorized" - ? "请解锁手机并允许这台 Mac 的 USB 调试。" - : "尚无可用的管理连接。"; - return; - } - $("hdc-connection-status").textContent = "正在读取设备能力…"; - const result = await call("inspect", { target: state.target }); - state.capabilities = result.capabilities; - $("device-info").textContent = - result.info.text || result.info.reason || "设备未返回基本信息"; - const names = { - apps: "应用管理", - launch: "应用启动", - screenshot: "截图", - logs: "日志", - report: "诊断报告", - }; - for (const [key, value] of Object.entries(state.capabilities)) { - const badge = label( - "span", - `${names[key] || key} · ${value.available ? "可用" : "未开放"}`, - ); - badge.className = "small-badge"; - badge.title = value.reason || ""; - $("hdc-capabilities").append(badge); - } - $("hdc-connection-status").textContent = - `已连接 ${state.target} · 操作权限以设备响应为准`; - if (state.capabilities.apps?.available) await loadApps(); - } - async function refresh(devices) { - clearDevice(); - state.devices = devices || (await call("devices")).devices; - $("hdc-target").replaceChildren(); - for (const device of state.devices) { - const option = label( - "option", - `${device.id} · ${device.transport} · ${device.state}`, - ); - option.value = device.id; - $("hdc-target").append(option); - } - if (state.devices.some((d) => d.id === state.target)) - $("hdc-target").value = state.target; - state.target = $("hdc-target").value; - await inspect(); - } - function scheduleMonitor() { - clearTimeout(state.monitorTimer); - if (!state.disposed && state.visible) - state.monitorTimer = setTimeout(monitor, 3000); - } - async function monitor() { - if (state.disposed || !state.visible) return; - if (!state.working && !state.busy && !state.screenBusy && state.available) { - state.working = true; - controls(); - try { - const devices = (await call("devices")).devices; - if ( - !state.disposed && - JSON.stringify(devices) !== JSON.stringify(state.devices) - ) - await refresh(devices); - } catch (error) { - clearDevice(); - state.devices = []; - state.target = ""; - $("hdc-target").replaceChildren(); - $("hdc-connection-status").textContent = - `连接检测失败:${error.message || String(error)}`; - } finally { - state.working = false; - controls(); - } - } - scheduleMonitor(); - } - function renderJob() { - const job = state.job; - $("hdc-job").hidden = !job; - if (!job) return; - $("hdc-job-title").textContent = job.cancelling - ? "正在取消…" - : { - running: "正在执行,请保持连接…", - completed: "操作完成", - cancelled: "操作已取消", - failed: "操作未完成", - }[job.state]; - $("hdc-job-detail").textContent = - job.error?.message || - [ - job.result?.message, - job.result?.path, - job.result?.bytes != null ? bytes(job.result.bytes) : "", - ] - .filter(Boolean) - .join(" · "); - $("hdc-cancel").hidden = job.state !== "running"; - controls(); - } - function schedule() { - clearTimeout(state.timer); - if (!state.disposed) state.timer = setTimeout(poll, 500); - } - async function poll() { - try { - const snapshot = await call("status"); - if (state.disposed) return; - state.busy = snapshot.busy; - state.job = snapshot.job - ? { - ...snapshot.job, - cancelling: - snapshot.job.state === "running" && state.job?.cancelling, - } - : null; - renderJob(); - if (state.busy || state.job?.state === "running") schedule(); - else if (state.job?.state === "completed") await action(() => refresh()); - } catch (error) { - if (!state.disposed) { - state.busy = false; - notice(error.message || String(error)); - controls(); - } - } - } - async function start(kind, extra = {}) { - const job = await call("start", { kind, target: state.target, ...extra }); - state.job = { ...job, state: "running" }; - state.busy = true; - renderJob(); - schedule(); - } - const confirm = async (title, message) => - (await host("confirm", { title, message })).confirmed; - $("hdc-download").onclick = () => - action(async () => { - await openUrl( - "https://developer.huawei.com/consumer/cn/download/command-line-tools-for-hmos", - ); - $("hdc-download-status").textContent = - "已在浏览器打开华为官方下载页。下载并解压后,返回此处选择工具目录。"; - }); - $("hdc-directory").onclick = () => - action(async () => { - const { path } = await host("openDirectory", { - title: "选择已解压的 Command Line Tools 目录", - }); - if (path) { - showTool(await call("configure", { path })); - await refresh(); - } - }); - $("hdc-configure").onclick = () => - action(async () => { - const { path } = await host("openFile", { - title: "选择官方 SDK 中的 hdc(保留同目录依赖)", - }); - if (path) { - showTool(await call("configure", { path })); - await refresh(); - } - }); - $("hdc-refresh").onclick = () => action(refreshTool); - $("hdc-target").onchange = () => - action(async () => { - state.target = $("hdc-target").value; - await inspect(); - }); - $("apps-search").oninput = renderApps; - $("apps-refresh").onclick = () => action(loadApps); - $("app-ability").onchange = controls; - $("app-install").onclick = () => - action(async () => { - const { path } = await host("openFile", { - title: "选择已签名的 .hap 安装包", - }); - if (!path) return; - const pkg = await call("package", { path }); - if ( - await confirm( - "安装或更新应用", - `设备:${state.target}\n应用:${pkg.bundle}\n版本:${pkg.versionName} (${pkg.versionCode})\n大小:${bytes(pkg.size)}\nSHA-256:${pkg.sha256}\n\n同包名应用将被更新,可能改变现有应用数据。继续安装?`, - ) - ) - await start("install", { path, sha256: pkg.sha256 }); - }); - $("app-launch").onclick = () => - action(async () => { - const ability = state.app.abilities[Number($("app-ability").value)]; - if (ability) - await start("launch", { - bundle: state.app.bundle, - module: ability.module, - ability: ability.name, - }); - }); - $("app-stop").onclick = () => - action(async () => { - const bundle = state.app.bundle; - if ( - await confirm( - "强制停止应用", - `在 ${state.target} 上停止 ${bundle}?未保存的内容可能丢失。`, - ) - ) - await start("stop", { bundle }); - }); - $("app-uninstall").onclick = () => - action(async () => { - const bundle = state.app.bundle; - if ( - await confirm( - "卸载应用", - `从 ${state.target} 卸载 ${bundle}?应用及其本地数据将被删除。`, - ) - ) - await start("uninstall", { bundle }); - }); - $("app-export").onclick = () => - action(async () => { - const bundle = state.app.bundle; - if ( - !(await confirm( - "导出调试应用数据", - `从 ${state.target} 导出 ${bundle} 的调试数据?\n仅复制当前可访问的数据文件,包含校验清单。运行中数据库可能不一致;这不是可直接恢复的整机备份。`, - )) - ) - return; - const { path } = await host("openDirectory", { - title: "保存调试数据到…", - }); - if (path) await start("exportDebug", { bundle, directory: path }); - }); - for (const kind of ["screenshot", "logs", "report"]) - $("device-" + kind).onclick = () => - action(async () => { - const { path } = await host("openDirectory", { title: "选择保存位置" }); - if (path) await start(kind, { directory: path }); - }); - $("hdc-cancel").onclick = async () => { - try { - await call("cancel", { id: state.job.id }); - state.job.cancelling = true; - renderJob(); - } catch (error) { - notice(error.message || String(error)); - } - }; - window.addEventListener("pagehide", () => { - state.disposed = true; - clearTimeout(state.timer); - clearTimeout(state.monitorTimer); - }); - screenPanel = screen({ - call, - host, - notice, - onBusy: (busy) => { - state.screenBusy = busy; - controls(); - }, - }); - controls(); - return (visible) => { - state.visible = visible; - clearTimeout(state.monitorTimer); - if (!visible) { - screenPanel.hide(); - return; - } - action(async () => { - if (state.ready) return; - const snapshot = await call("status"); - state.busy = snapshot.busy; - state.job = snapshot.job; - renderJob(); - if (state.busy) { - state.ready = true; - schedule(); - return; - } - await refreshTool(); - state.ready = true; - }).finally(scheduleMonitor); - }; -} diff --git a/curios/harmony-connector/ui/screen-geometry.js b/curios/harmony-connector/ui/screen-geometry.js deleted file mode 100644 index 2d2a8a4..0000000 --- a/curios/harmony-connector/ui/screen-geometry.js +++ /dev/null @@ -1,103 +0,0 @@ -// Map the displayed, letterboxed image back to the phone's physical screen. -export function screenPoint(rect, width, height, clientX, clientY) { - if ( - ![rect.width, rect.height, width, height].every( - (n) => Number.isFinite(n) && n > 0, - ) - ) - return null; - const scale = Math.min(rect.width / width, rect.height / height); - const left = rect.left + (rect.width - width * scale) / 2; - const top = rect.top + (rect.height - height * scale) / 2; - const x = (clientX - left) / scale, - y = (clientY - top) / scale; - if ( - !Number.isFinite(x) || - !Number.isFinite(y) || - x < 0 || - y < 0 || - x >= width || - y >= height - ) - return null; - return { x: Math.floor(x), y: Math.floor(y) }; -} -export function gesture(from, to, elapsedMs, mode = "click") { - if (!from || !to) return null; - const distance = Math.hypot(to.x - from.x, to.y - from.y); - if (distance >= 12) - return { - kind: mode === "drag" ? "drag" : "swipe", - x: from.x, - y: from.y, - toX: to.x, - toY: to.y, - speed: Math.round( - Math.min( - 40000, - Math.max(200, distance / Math.max(0.1, elapsedMs / 1000)), - ), - ), - }; - return { - kind: - elapsedMs >= 550 || mode === "longClick" - ? "longClick" - : mode === "doubleClick" - ? "doubleClick" - : "click", - ...to, - }; -} -export function scrollGesture(point, dx, dy, width, height) { - if (!point || ![dx, dy].every(Number.isFinite)) return null; - const toX = Math.round( - Math.max( - 0, - Math.min( - width - 1, - point.x - Math.max(-width * 0.6, Math.min(width * 0.6, dx)), - ), - ), - ); - const toY = Math.round( - Math.max( - 0, - Math.min( - height - 1, - point.y - Math.max(-height * 0.6, Math.min(height * 0.6, dy)), - ), - ), - ); - if (toX === point.x && toY === point.y) return null; - return { kind: "swipe", ...point, toX, toY, speed: 1500 }; -} -export const screenKeys = { - Escape: "back", - Home: "home", - Enter: "enter", - Backspace: "backspace", - Delete: "delete", - Tab: "tab", - ArrowUp: "up", - ArrowDown: "down", - ArrowLeft: "left", - ArrowRight: "right", -}; - -// Center the visible phone pixels; crop trailing JPEG alignment padding without -// decoding and re-encoding every frame in the native backend. -export function screenImageStyle(rect, frame) { - const width = frame.captureWidth || frame.width; - const height = frame.captureHeight || frame.height; - const imageWidth = frame.imageWidth || width; - const imageHeight = frame.imageHeight || height; - const scale = Math.min(rect.width / width, rect.height / height); - return { - left: `${(rect.width - width * scale) / 2}px`, - top: `${(rect.height - height * scale) / 2}px`, - width: `${imageWidth * scale}px`, - height: `${imageHeight * scale}px`, - clipPath: `inset(0 ${(imageWidth - width) * scale}px ${(imageHeight - height) * scale}px 0)`, - }; -} diff --git a/curios/harmony-connector/ui/screen-geometry.test.js b/curios/harmony-connector/ui/screen-geometry.test.js deleted file mode 100644 index 3fc5812..0000000 --- a/curios/harmony-connector/ui/screen-geometry.test.js +++ /dev/null @@ -1,83 +0,0 @@ -import { test } from "node:test"; -import assert from "node:assert/strict"; -import { - screenPoint, - screenImageStyle, - gesture, - scrollGesture, -} from "./screen-geometry.js"; - -test("portrait phone maps the displayed image and ignores side letterboxes", () => { - const rect = { left: 20, top: 30, width: 800, height: 600 }; - assert.deepEqual(screenPoint(rect, 1200, 2400, 420, 330), { - x: 600, - y: 1200, - }); - assert.equal(screenPoint(rect, 1200, 2400, 100, 330), null); - assert.equal(screenPoint(rect, 1200, 2400, 571, 330), null); - assert.equal(screenPoint(rect, 1200, 2400, 420, 631), null); -}); -test("rotated phone uses physical landscape coordinates with top letterboxing", () => { - const rect = { left: 0, top: 0, width: 600, height: 800 }; - assert.deepEqual(screenPoint(rect, 2400, 1200, 300, 400), { - x: 1200, - y: 600, - }); - assert.equal(screenPoint(rect, 2400, 1200, 300, 200), null); - assert.equal(screenPoint(rect, 2400, 1200, Infinity, 400), null); -}); -test("gestures distinguish taps, long presses and drag paths with bounded speed", () => { - const from = { x: 200, y: 400 }, - to = { x: 500, y: 800 }; - assert.equal(gesture(from, from, 100).kind, "click"); - assert.equal(gesture(from, from, 700).kind, "longClick"); - assert.equal(gesture(from, from, 100, "doubleClick").kind, "doubleClick"); - assert.deepEqual(gesture(from, to, 1000), { - kind: "swipe", - x: 200, - y: 400, - toX: 500, - toY: 800, - speed: 500, - }); - assert.equal(gesture(from, to, 1000, "drag").kind, "drag"); - assert.equal(gesture(from, null, 100), null); -}); -test("wheel gestures remain inside the physical phone screen", () => { - assert.deepEqual(scrollGesture({ x: 10, y: 20 }, 9999, -9999, 600, 1200), { - kind: "swipe", - x: 10, - y: 20, - toX: 0, - toY: 740, - speed: 1500, - }); - assert.equal(scrollGesture({ x: 10, y: 20 }, 0, 0, 600, 1200), null); -}); - -test("JPEG padding is clipped while visible corners keep physical input coordinates", () => { - const rect = { left: 0, top: 0, width: 1000, height: 1422 }; - const frame = { - width: 1260, - height: 2844, - captureWidth: 630, - captureHeight: 1422, - imageWidth: 640, - imageHeight: 1422, - }; - assert.deepEqual(screenImageStyle(rect, frame), { - left: "185px", - top: "0px", - width: "640px", - height: "1422px", - clipPath: "inset(0 10px 0px 0)", - }); - assert.deepEqual( - screenPoint(rect, frame.width, frame.height, 814.5, 1421.5), - { x: 1259, y: 2843 }, - ); - assert.equal(screenPoint(rect, frame.width, frame.height, 820, 700), null); - const resized = screenImageStyle({ ...rect, width: 500, height: 711 }, frame); - assert.equal(resized.width, "320px"); - assert.equal(resized.clipPath, "inset(0 5px 0px 0)"); -}); diff --git a/curios/harmony-connector/ui/screen-window.js b/curios/harmony-connector/ui/screen-window.js deleted file mode 100644 index 776d7c9..0000000 --- a/curios/harmony-connector/ui/screen-window.js +++ /dev/null @@ -1,66 +0,0 @@ -export function screen({ call, notice, onBusy }) { - const $ = (id) => document.getElementById(id); - const state = { target: "", available: false, blocked: false, working: false, active: false, ready: false, phase: "", disposed: false }; - let timer, error, reportedBusy = false; - function reportBusy() { - const busy = state.working || (state.active && !state.ready); - if (busy === reportedBusy) return; - reportedBusy = busy; - onBusy(busy); - } - 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 ? state.ready ? "正在独立窗口中投屏 · 默认鼠标控制" : state.phase || "正在建立视频连接…" : "让手机,来到桌面。"; - } - async function status() { - if (state.disposed) return; - try { - const result = await call("screenWindowStatus", {}); - if (state.disposed) return; - if (!state.working) { state.active = result.active; state.ready = !!result.ready; state.phase = result.phase || ""; } - if (result.error && result.error.message !== error) { - error = result.error.message; - notice(error); - } - reportBusy(); - 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; - reportBusy(); - render(); - try { - const result = await call(method, args); - state.active = result.active; - state.ready = !!result.ready; - state.phase = result.phase || ""; - error = undefined; - } catch (error) { notice(error.message || String(error)); } - finally { - state.working = false; - reportBusy(); - 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. */ }, - }; -} diff --git a/curios/harmony-connector/ui/style.css b/curios/harmony-connector/ui/style.css deleted file mode 100644 index ae8593d..0000000 --- a/curios/harmony-connector/ui/style.css +++ /dev/null @@ -1,1081 +0,0 @@ -:root { - font-family: - -apple-system, BlinkMacSystemFont, "SF Pro Text", "PingFang SC", sans-serif; - color: #222c3c; - background: #f8f9fc; - font-size: 13px; - font-synthesis: none; - -webkit-font-smoothing: antialiased; - --bg: #fff; - --ink: #222c3c; - --muted: #7b8596; - --line: #e9edf3; - --blue: #4269e2; - --soft: #eef2fd; - --side: #f3f5fa; - --hover: #f6f8fd; -} -* { - box-sizing: border-box; -} -body { - margin: 0; -} -button, -input, -select { - font: inherit; -} -button { - cursor: pointer; - min-height: 40px; - border: 0; - border-radius: 10px; - padding: 0 14px; - color: inherit; - background: transparent; - transition: - background 0.15s, - transform 0.15s, - opacity 0.15s; -} -button:active:not(:disabled) { - transform: scale(0.97); -} -button:disabled { - cursor: default; - opacity: 0.42; -} -button:focus-visible, -input:focus-visible, -select:focus-visible { - outline: 3px solid #8ba6ff; - outline-offset: 2px; -} -button:hover:not(:disabled) { - background: var(--soft); -} -[hidden] { - display: none !important; -} -.primary { - background: var(--blue); - color: white; - box-shadow: 0 3px 7px #4269e21a; -} -.primary:hover:not(:disabled) { - background: #345bd1; -} -.secondary { - background: var(--bg); - border: 1px solid var(--line); - box-shadow: 0 2px 3px #15213a03; -} -.subtle { - color: var(--muted); - font-size: 12px; -} -.text-button { - color: var(--blue); - padding: 0; - font-size: 12px; -} -.app { - display: flex; - min-height: 100dvh; -} -.sidebar { - width: 232px; - flex-shrink: 0; - background: var(--side); - border-right: 1px solid var(--line); - padding: 34px 22px 20px; - display: flex; - flex-direction: column; - gap: 10px; -} -.brand { - display: flex; - align-items: center; - gap: 12px; - margin-bottom: 32px; -} -.brand-mark { - background: linear-gradient(140deg, #7392fb, #3f61d7); - color: #fff; - width: 44px; - height: 44px; - display: grid; - place-items: center; - border-radius: 14px; - font-size: 29px; - box-shadow: 0 4px 10px #4163d722; -} -.brand strong { - font-size: 17px; - letter-spacing: -0.45px; - line-height: 1.12; -} -.brand span { - display: block; - color: var(--muted); - font-size: 10px; - margin-top: 6px; - letter-spacing: 1px; -} -.section-label { - font-size: 10px; - color: var(--muted); - letter-spacing: 1.4px; - font-weight: 600; - margin: 8px 8px; -} -.device-card { - display: flex; - align-items: center; - gap: 10px; - background: var(--bg); - padding: 14px 12px; - border-radius: 13px; - box-shadow: 0 2px 8px #25305305; -} -.device-card > div { - min-width: 0; - flex: 1; -} -.device-card strong { - display: block; - font-size: 12px; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} -.device-card span:not(.device-icon) { - display: block; - color: var(--muted); - font-size: 10px; - margin-top: 5px; -} -.device-icon { - font-size: 30px; - line-height: 1; - color: var(--blue); -} -#device-model { - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} -.device-card i, -.status-dot { - width: 6px; - height: 6px; - border-radius: 100%; - display: inline-block; - background: #b5bfcd; -} -.device-card i.connected, -.status-dot { - background: #70b496; -} -.storage-label { - margin-top: 28px; -} -#storages .muted { - font-size: 11px; - margin: 5px 8px; -} -#storages button { - text-align: left; - width: 100%; - padding: 12px; - margin-bottom: 4px; -} -#storages button.active { - background: #e6ecfc; - color: var(--blue); -} -.storage-title { - display: block; - font-weight: 600; - font-size: 12px; -} -.storage-capacity { - display: block; - font-size: 10px; - margin-top: 7px; - color: var(--muted); -} -.sidebar-bottom { - margin-top: auto; - padding-top: 50px; -} -.small-badge { - display: inline-block; - background: var(--soft); - color: var(--blue); - font-size: 10px; - letter-spacing: 0.8px; - padding: 6px 9px; - border-radius: 6px; - font-weight: 600; -} -.sidebar-bottom p { - font-size: 11px; - color: var(--muted); - line-height: 1.9; - margin: 15px 0 6px; -} -main { - display: flex; - flex-direction: column; - flex: 1; - min-width: 0; - min-height: 100dvh; - background: var(--bg); -} -.topbar { - padding: 32px 36px 25px; - display: flex; - justify-content: space-between; - align-items: center; - gap: 16px; - border-bottom: 1px solid var(--line); -} -.eyebrow { - font-size: 9px; - letter-spacing: 1.8px; - color: var(--muted); - margin: 0 0 9px; -} -h1 { - font-size: 21px; - margin: 0; - font-weight: 600; - letter-spacing: -0.5px; -} -h2 { - text-wrap: balance; -} -p { - line-height: 1.7; -} -.muted { - color: var(--muted); - font-size: 12px; -} -.welcome { - margin: auto; - padding: 36px 24px 22px; - text-align: center; - width: 100%; - max-width: 620px; -} -.welcome h2 { - font-size: 34px; - letter-spacing: -1px; - line-height: 1.45; - font-weight: 600; - margin: 18px 0 12px; -} -.intro { - color: var(--muted); - font-size: 13px; - margin: 0 0 26px; -} -.connection-art { - display: flex; - align-items: center; - justify-content: center; - height: 110px; - margin: 0 0 24px; - gap: 18px; -} -.mac { - width: 123px; - position: relative; -} -.mac > div { - height: 78px; - border: 3px solid #acbde5; - background: linear-gradient(140deg, #f1f5ff, #e4ebfc); - border-radius: 9px; - display: grid; - place-items: center; - box-shadow: 0 6px 20px #466cd014; -} -.mac span { - font-size: 31px; - color: #8aa5ed; -} -.mac > i { - display: block; - height: 5px; - background: #b6c5e8; - margin: 4px -10px 0; - border-radius: 2px 2px 6px 6px; -} -.phone { - width: 45px; - height: 87px; - border: 3px solid #7998e6; - background: linear-gradient(150deg, #eef3ff, #d7e3ff); - border-radius: 11px; - position: relative; - display: grid; - place-items: center; - transform: rotate(6deg); - box-shadow: 0 7px 17px #466cd019; -} -.phone > span { - position: absolute; - top: 4px; - background: #91abeb; - width: 12px; - height: 3px; - border-radius: 3px; -} -.phone > b { - font-size: 24px; - color: #7394e4; - font-weight: 400; -} -.phone > i { - position: absolute; - bottom: 4px; - width: 16px; - height: 2px; - background: #92aaea; - border-radius: 2px; -} -.connection-line { - display: flex; - gap: 7px; -} -.connection-line i { - width: 4px; - height: 4px; - border-radius: 50%; - background: #b2c2e7; -} -.steps { - list-style: none; - display: grid; - gap: 12px; - padding: 0; - margin: 0 auto 23px; - width: max-content; - max-width: 100%; - text-align: left; - color: #64718a; - font-size: 12px; -} -.steps li { - display: flex; - gap: 10px; - align-items: center; -} -.steps b { - background: var(--side); - font-size: 10px; - font-weight: 500; - width: 21px; - height: 21px; - display: grid; - place-items: center; - border-radius: 50%; - color: var(--muted); -} -#device-picker { - background: var(--side); - padding: 14px; - border-radius: 13px; - max-width: 370px; - margin: 20px auto; - text-align: left; -} -label { - font-size: 12px; -} -.device-picker-row { - display: flex; - gap: 8px; - margin-top: 9px; -} -select, -input:not([type="search"]) { - height: 42px; - background: var(--bg); - color: var(--ink); - border: 1px solid var(--line); - border-radius: 8px; - padding: 0 11px; - min-width: 0; -} -select { - flex: 1; -} -#scan-status { - font-size: 11px; - min-height: 20px; -} -.notice { - display: flex; - align-items: center; - justify-content: space-between; - background: #fff6e8; - color: #8a5b23; - border: 1px solid #f8e3c2; - border-radius: 10px; - padding: 5px 8px 5px 16px; - margin: 16px 28px 0; - font-size: 12px; - gap: 8px; - line-height: 1.7; -} -.notice button { - font-size: 22px; -} -.browser { - padding: 24px 28px 0; - flex: 1; - display: flex; - flex-direction: column; - min-height: 0; -} -.toolbar { - display: flex; - align-items: center; - justify-content: space-between; - gap: 10px; - flex-wrap: wrap; -} -.actions { - display: flex; - gap: 7px; -} -#breadcrumbs { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 2px; - min-width: 0; - max-width: 100%; - font-size: 12px; -} -#breadcrumbs button { - padding: 0 8px; - max-width: 170px; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} -#breadcrumbs span { - color: #bbc3d1; -} -.list-heading { - display: flex; - align-items: center; - justify-content: space-between; - gap: 15px; - margin: 22px 0 14px; -} -.search { - display: flex; - align-items: center; - gap: 8px; - color: var(--muted); - border-radius: 9px; - background: var(--side); - padding: 0 12px; - height: 36px; - max-width: 270px; - flex: 1; -} -.search span { - font-size: 24px; -} -.search input { - border: 0; - background: transparent; - outline: none; - min-width: 0; - color: var(--ink); - font-size: 12px; - width: 100%; -} -.file-table-wrap { - border: 1px solid var(--line); - border-radius: 12px; - overflow: auto; - max-height: calc(100dvh - 330px); - min-height: 200px; - flex: 1; -} -table { - width: 100%; - border-collapse: collapse; - table-layout: fixed; -} -th { - background: var(--hover); - text-align: left; - color: var(--muted); - font-size: 10px; - font-weight: 500; - height: 34px; - position: sticky; - top: 0; - z-index: 1; -} -th:first-child { - width: auto; - padding-left: 17px; -} -th:nth-child(2) { - width: 82px; -} -th:nth-child(3) { - width: 116px; -} -th:last-child { - width: 68px; -} -td { - height: 52px; - border-top: 1px solid var(--line); - font-size: 11px; - color: var(--muted); - font-variant-numeric: tabular-nums; - padding-right: 8px; -} -td:first-child { - padding-left: 10px; - color: var(--ink); -} -tbody tr:hover { - background: var(--hover); -} -.entry-name { - display: flex; - align-items: center; - gap: 10px; - width: 100%; - text-align: left; - padding: 0 7px; - overflow: hidden; - font-size: 12px; -} -.entry-name span:last-child { - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} -.file-icon { - color: #99a8c8; - flex-shrink: 0; - display: grid; - place-items: center; - width: 22px; - height: 28px; -} -.file-icon.folder { - color: #d6aa59; -} -.download-button { - padding: 0 8px; - color: var(--blue); - font-size: 11px; -} -.empty { - text-align: center; - padding: 54px 20px; - color: var(--muted); - font-size: 12px; -} -.load-more { - display: block; - margin: 14px auto; -} -.browser-footnote { - font-size: 10px; - color: var(--muted); - margin: 13px 0 18px; -} -.transfer { - margin: 0 28px 18px; - background: var(--soft); - padding: 16px 18px; - border-radius: 12px; -} -.transfer-top { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; -} -.transfer strong { - font-size: 12px; -} -.transfer p { - font-size: 11px; - color: var(--muted); - margin: 6px 0 0; - overflow-wrap: anywhere; - font-variant-numeric: tabular-nums; -} -progress { - appearance: none; - width: 100%; - height: 4px; - display: block; - margin-top: 13px; - border: 0; - border-radius: 6px; - background: #dce4fa; - overflow: hidden; -} -progress::-webkit-progress-bar { - background: #dce4fa; -} -progress::-webkit-progress-value { - background: var(--blue); - border-radius: 6px; -} -progress::-moz-progress-bar { - background: var(--blue); -} -footer { - display: flex; - justify-content: space-between; - color: var(--muted); - font-size: 10px; - padding: 14px 28px; - border-top: 1px solid var(--line); - gap: 12px; -} -footer span:first-child { - display: flex; - gap: 6px; - align-items: center; -} -dialog { - color: var(--ink); - background: var(--bg); - border: 1px solid var(--line); - border-radius: 18px; - padding: 26px; - max-width: 520px; - width: calc(100% - 48px); - box-shadow: 0 20px 70px #13204422; - max-height: 85dvh; -} -dialog::backdrop { - background: #13204444; - backdrop-filter: blur(3px); -} -dialog h2 { - font-size: 20px; - margin: 0 0 16px; -} -dialog h3 { - font-size: 13px; - margin: 22px 0 6px; -} -dialog p, -dialog li { - font-size: 12px; - color: var(--muted); - line-height: 1.9; -} -dialog ol { - padding-left: 18px; -} -dialog li { - margin: 9px 0; -} -.dialog-header { - display: flex; - justify-content: space-between; - align-items: center; -} -.dialog-header button { - font-size: 24px; - min-width: 40px; -} -.dialog-header h2 { - margin: 0; -} -#folder-dialog { - max-width: 380px; -} -#folder-name { - display: block; - width: 100%; - margin-top: 10px; -} -.field-error { - color: #bd4d41; - font-size: 12px; - min-height: 20px; -} -.dialog-actions { - display: flex; - justify-content: flex-end; - gap: 9px; -} -.sr-only { - position: absolute; - width: 1px; - height: 1px; - overflow: hidden; - clip: rect(0, 0, 0, 0); - white-space: nowrap; -} -@media (max-width: 850px) { - .sidebar { - width: 195px; - padding: 26px 16px 16px; - } - .topbar { - padding: 26px; - } - .browser { - padding: 20px; - } - .actions button { - padding: 0 10px; - } - .toolbar { - align-items: flex-start; - } - .welcome h2 { - font-size: 29px; - } - th:nth-child(3), - td:nth-child(3) { - display: none; - } - .transfer { - margin: 0 20px 16px; - } - .brand strong { - font-size: 16px; - } -} -@media (prefers-color-scheme: dark) { - :root { - --bg: #20252e; - --ink: #e6eaf3; - --muted: #98a3b6; - --line: #333b49; - --blue: #92abff; - --soft: #2e3a56; - --side: #1a1f28; - --hover: #262e3b; - color: var(--ink); - background: var(--bg); - } - .primary { - background: #4e73ea; - color: white; - } - #storages button.active { - background: #2e3a56; - } - .steps { - color: #a5b3ca; - } - .notice { - background: #3c3223; - border-color: #57472e; - color: #edc594; - } - .mac > div { - background: linear-gradient(140deg, #293e65, #3a527c); - } - .phone { - background: linear-gradient(150deg, #374e7b, #273b63); - } - .brand-mark { - color: #fff; - } -} -@media (prefers-reduced-motion: reduce) { - *, - *::before, - *::after { - transition: none !important; - } - button:active:not(:disabled) { - transform: none; - } -} - -.workspace-tabs { - display: flex; - gap: 8px; - padding: 0 32px 16px; - border-bottom: 1px solid var(--line); -} -.workspace-tabs button { - background: transparent; - color: var(--muted); - border: 0; - padding: 8px 18px; - border-radius: 9px; -} -.workspace-tabs button[aria-current="page"] { - color: var(--blue); - background: var(--soft); -} -.management { - padding: 24px 32px; -} -.management-card { - padding: 20px; - margin-bottom: 18px; - border: 1px solid var(--line); - border-radius: 14px; -} -.management-heading { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - flex-wrap: wrap; -} -.management-heading h2 { - margin: 0; - font-size: 19px; -} -.management p { - font-size: 13px; - line-height: 1.65; - overflow-wrap: anywhere; -} -.management-actions, -.capabilities { - display: flex; - flex-wrap: wrap; - gap: 8px; - margin: 12px 0; -} -.management pre { - font: - 12px/1.6 ui-monospace, - monospace; - white-space: pre-wrap; - overflow-wrap: anywhere; - max-height: 220px; - overflow: auto; -} -.management summary { - cursor: pointer; - font-size: 13px; - color: var(--muted); -} -.tool-setup { - border-top: 1px solid var(--line); - margin-top: 18px; - padding-top: 6px; -} -.tool-setup h3 { - font-size: 18px; - font-weight: 600; - margin-bottom: 8px; -} -.tool-steps { - padding-left: 23px; - display: grid; - gap: 20px; - margin: 24px 0; -} -.tool-steps li { - padding-left: 6px; -} -.tool-steps li::marker { - color: var(--blue); - font-weight: 600; -} -.tool-steps p { - color: var(--muted); - margin: 7px 0 12px; -} -#hdc-download-status:empty { - display: none; -} -.application-layout { - display: grid; - grid-template-columns: minmax(180px, 1fr) minmax(200px, 1fr); - gap: 16px; -} -.apps-list { - max-height: 340px; - overflow: auto; -} -.app-row { - display: block; - width: 100%; - text-align: left; - padding: 12px; - background: transparent; - border: 0; - border-bottom: 1px solid var(--line); - overflow-wrap: anywhere; -} -.app-row:hover, -.app-row[aria-pressed="true"] { - background: var(--soft); - color: var(--blue); -} -.app-detail { - padding: 0 12px; -} -.app-detail h3 { - font-size: 15px; - overflow-wrap: anywhere; -} -.app-detail label { - font-size: 12px; - color: var(--muted); -} -.app-detail select { - display: block; - width: 100%; - margin-top: 8px; -} -#transfer-metrics { - font-variant-numeric: tabular-nums; -} -@media (max-width: 850px) { - .management { - padding: 20px; - } - .application-layout { - grid-template-columns: 1fr; - } - .app-detail { - border-top: 1px solid var(--line); - } - .actions { - flex-wrap: wrap; - justify-content: flex-end; - } -} - -.screen-card h2 { - text-wrap: balance; -} -.screen-card.expanded { - position: fixed; - inset: 12px; - z-index: 20; - overflow: auto; - background: var(--bg); - margin: 0; - box-shadow: 0 8px 70px #0006; -} -.screen-layout { - display: grid; - grid-template-columns: minmax(0, 1fr) 220px; - gap: 22px; -} -.screen-stage { - position: relative; - height: 510px; - min-height: 240px; - background: #111318; - border-radius: 12px; - display: grid; - place-items: center; - overflow: hidden; - outline: 1px solid rgba(0, 0, 0, 0.1); -} -.screen-stage.controlling { - cursor: crosshair; - touch-action: none; - user-select: none; -} -.screen-stage:focus-visible { - outline: 3px solid var(--blue); - outline-offset: 3px; -} -#screen-image { - position: absolute; - width: 100%; - height: 100%; - object-fit: fill; - pointer-events: none; -} -#screen-placeholder { - text-align: center; - color: #a4afc3; - padding: 24px; -} -#screen-placeholder > span { - font-size: 64px; - font-weight: 200; -} -#screen-placeholder p { - font-size: 18px; - color: #d6deeb; -} -.screen-inputs { - display: flex; - flex-direction: column; - gap: 10px; - align-self: start; -} -.screen-inputs label { - font-size: 12px; -} -.control-switch { - display: flex; - align-items: center; - min-height: 40px; - gap: 6px; -} -.screen-inputs textarea { - width: 100%; - resize: vertical; - min-height: 90px; - font: inherit; - line-height: 1.6; - padding: 10px; - color: var(--ink); - background: var(--bg); - border: 1px solid var(--line); - border-radius: 9px; -} -.screen-nav { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 8px; -} -.screen-inputs p { - margin: 0; -} -.screen-card button:active:not(:disabled) { - transform: scale(0.96); -} -#screen-stats { - font-variant-numeric: tabular-nums; - min-height: 22px; -} -@media (max-width: 960px) { - .screen-layout { - grid-template-columns: 1fr; - } - .screen-inputs { - max-width: 100%; - } - .screen-stage { - height: 440px; - } -} -@media (prefers-color-scheme: dark) { - .screen-stage { - outline-color: rgba(255, 255, 255, 0.1); - } -} diff --git a/curios/harmony-connector/viewer/Main.swift b/curios/harmony-connector/viewer/Main.swift deleted file mode 100644 index 7efef38..0000000 --- a/curios/harmony-connector/viewer/Main.swift +++ /dev/null @@ -1,157 +0,0 @@ -import AppKit -import CoreVideo - -final class ViewerApp: NSObject, NSApplicationDelegate, NSWindowDelegate { - private var window: NSWindow! - private var view: ScreenView! - private var samples: [TimeInterval] = [] - private var loading: NSStackView! - private let loadingLabel = NSTextField(labelWithString: "正在连接设备…") - private let spinner = NSProgressIndicator() - private let pendingLock = NSLock() - private var pending: (CVPixelBuffer, ScreenFrame)? - private var scheduled = false - private var geometry: UInt64 = 0 - private var announced = false - 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 - spinner.style = .spinning - spinner.controlSize = .regular - spinner.startAnimation(nil) - loadingLabel.textColor = .secondaryLabelColor - loadingLabel.font = .systemFont(ofSize: 13) - loading = NSStackView(views: [spinner, loadingLabel]) - loading.orientation = .vertical - loading.spacing = 16 - loading.appearance = NSAppearance(named: .darkAqua) - loading.translatesAutoresizingMaskIntoConstraints = false - view.addSubview(loading) - NSLayoutConstraint.activate([ - loading.centerXAnchor.constraint(equalTo: view.centerXAnchor), - loading.centerYAnchor.constraint(equalTo: view.centerYAnchor), - ]) - 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.announced { - self.announced = true - self.loading.isHidden = true - self.spinner.stopAnimation(nil) - ViewerWire.send(["event": "presented"]) - } - 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 == "progress" { - DispatchQueue.main.async { [weak self] in - guard let self, !self.announced else { return } - let text = message.message ?? "正在连接设备…" - self.loadingLabel.stringValue = text - self.window.title = "\(self.deviceName) · \(text)" - } - } 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 - self?.spinner.stopAnimation(nil) - self?.loading.isHidden = 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() } - } -} diff --git a/curios/harmony-connector/viewer/Protocol.swift b/curios/harmony-connector/viewer/Protocol.swift deleted file mode 100644 index 3d58f43..0000000 --- a/curios/harmony-connector/viewer/Protocol.swift +++ /dev/null @@ -1,53 +0,0 @@ -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)) - } -} diff --git a/curios/harmony-connector/viewer/ScreenView.swift b/curios/harmony-connector/viewer/ScreenView.swift deleted file mode 100644 index d1bda74..0000000 --- a/curios/harmony-connector/viewer/ScreenView.swift +++ /dev/null @@ -1,136 +0,0 @@ -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 } -} diff --git a/curios/harmony-connector/viewer/VideoDecoder.swift b/curios/harmony-connector/viewer/VideoDecoder.swift deleted file mode 100644 index a199dbb..0000000 --- a/curios/harmony-connector/viewer/VideoDecoder.swift +++ /dev/null @@ -1,117 +0,0 @@ -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.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..=0.1.0 <1.0.0" - }, - "backend": { - "entry": "backend/probe", - "args": [], - "env": { - "QA_LABEL": "test-autostart" - }, - "lifecycle": "app-start", - "transport": "unix", - "startWithUi": false, - "startupTimeoutMs": 5000, - "shutdownGraceMs": 500, - "idleTimeoutMs": 0 - }, - "services": { - "requires": [ - { - "id": "host.storage", - "version": "^1.0.0" - } - ], - "provides": [ - { - "id": "dev.trove.qa.autostart.probe", - "version": "1.0.0", - "contract": "contracts/probe.json" - } - ] - }, - "build": { - "command": [ - "node", - "../../tools/build.mjs", - "test-autostart" - ] - }, - "package": { - "files": [ - "backend", - "contracts" - ] - } -} diff --git a/curios/test-bad-handshake/curio.json b/curios/test-bad-handshake/curio.json deleted file mode 100644 index 7102db1..0000000 --- a/curios/test-bad-handshake/curio.json +++ /dev/null @@ -1,69 +0,0 @@ -{ - "schemaVersion": 1, - "id": "dev.trove.qa.bad-handshake", - "name": "故障 · 握手认证失败", - "description": "发送错误会话令牌,期望底座拒绝该连接。", - "version": "1.0.0", - "engine": { - "trove": ">=0.1.0 <1.0.0" - }, - "ui": { - "entry": "dist/ui/index.html", - "window": { - "title": "故障 · 握手认证失败", - "width": 960, - "height": 760, - "minWidth": 600, - "minHeight": 400, - "resizable": true - } - }, - "backend": { - "entry": "backend/probe", - "args": [ - "--bad-token" - ], - "env": { - "QA_LABEL": "test-bad-handshake" - }, - "lifecycle": "on-demand", - "transport": "unix", - "startWithUi": false, - "startupTimeoutMs": 1000, - "shutdownGraceMs": 500, - "idleTimeoutMs": 0 - }, - "services": { - "requires": [ - { - "id": "host.runtime", - "version": "^1.0.0" - }, - { - "id": "host.services", - "version": "^1.0.0" - }, - { - "id": "host.storage", - "version": "^1.0.0" - }, - { - "id": "host.fs", - "version": "^1.0.0" - } - ] - }, - "build": { - "command": [ - "node", - "../../tools/build.mjs", - "test-bad-handshake" - ] - }, - "package": { - "files": [ - "dist/ui", - "backend" - ] - } -} diff --git a/curios/test-consumer/curio.json b/curios/test-consumer/curio.json deleted file mode 100644 index 6876e20..0000000 --- a/curios/test-consumer/curio.json +++ /dev/null @@ -1,57 +0,0 @@ -{ - "schemaVersion": 1, - "id": "dev.trove.qa.consumer", - "name": "05 · 跨插件调用", - "description": "依赖公共服务;验证发现、契约、事件与后端回调。", - "version": "1.0.0", - "engine": { - "trove": ">=0.1.0 <1.0.0" - }, - "ui": { - "entry": "dist/ui/index.html", - "window": { - "title": "05 · 跨插件调用", - "width": 960, - "height": 760, - "minWidth": 600, - "minHeight": 400, - "resizable": true - } - }, - "services": { - "requires": [ - { - "id": "host.runtime", - "version": "^1.0.0" - }, - { - "id": "host.services", - "version": "^1.0.0" - }, - { - "id": "host.storage", - "version": "^1.0.0" - }, - { - "id": "dev.trove.qa.provider.probe", - "version": "^1.0.0" - }, - { - "id": "host.fs", - "version": "^1.0.0" - } - ] - }, - "build": { - "command": [ - "node", - "../../tools/build.mjs", - "test-consumer" - ] - }, - "package": { - "files": [ - "dist" - ] - } -} diff --git a/curios/test-crash/curio.json b/curios/test-crash/curio.json deleted file mode 100644 index a0ed347..0000000 --- a/curios/test-crash/curio.json +++ /dev/null @@ -1,69 +0,0 @@ -{ - "schemaVersion": 1, - "id": "dev.trove.qa.crash", - "name": "故障 · 崩溃与恢复", - "description": "手动令自己的后端以 42 退出,检查隔离、重试与熔断。", - "version": "1.0.0", - "engine": { - "trove": ">=0.1.0 <1.0.0" - }, - "ui": { - "entry": "dist/ui/index.html", - "window": { - "title": "故障 · 崩溃与恢复", - "width": 960, - "height": 760, - "minWidth": 600, - "minHeight": 400, - "resizable": true - } - }, - "backend": { - "entry": "backend/probe", - "args": [ - "--allow-crash" - ], - "env": { - "QA_LABEL": "test-crash" - }, - "lifecycle": "on-demand", - "transport": "unix", - "startWithUi": true, - "startupTimeoutMs": 5000, - "shutdownGraceMs": 500, - "idleTimeoutMs": 0 - }, - "services": { - "requires": [ - { - "id": "host.runtime", - "version": "^1.0.0" - }, - { - "id": "host.services", - "version": "^1.0.0" - }, - { - "id": "host.storage", - "version": "^1.0.0" - }, - { - "id": "host.fs", - "version": "^1.0.0" - } - ] - }, - "build": { - "command": [ - "node", - "../../tools/build.mjs", - "test-crash" - ] - }, - "package": { - "files": [ - "dist/ui", - "backend" - ] - } -} diff --git a/curios/test-host/curio.json b/curios/test-host/curio.json deleted file mode 100644 index e7ea7a2..0000000 --- a/curios/test-host/curio.json +++ /dev/null @@ -1,83 +0,0 @@ -{ - "schemaVersion": 1, - "id": "dev.trove.qa.host", - "name": "01 · Host 能力检查", - "description": "UI、SDK 桥接、服务发现、存储、文件和桌面交互。", - "version": "1.0.0", - "engine": { - "trove": ">=0.1.0 <1.0.0" - }, - "ui": { - "entry": "dist/ui/index.html", - "window": { - "title": "01 · Host 能力检查", - "width": 960, - "height": 760, - "minWidth": 600, - "minHeight": 400, - "resizable": true - } - }, - "services": { - "requires": [ - { - "id": "host.runtime", - "version": "^1.0.0" - }, - { - "id": "host.services", - "version": "^1.0.0" - }, - { - "id": "host.storage", - "version": "^1.0.0" - }, - { - "id": "host.fs", - "version": "^1.0.0" - }, - { - "id": "host.window", - "version": "^1.0.0", - "optional": true - }, - { - "id": "host.clipboard", - "version": "^1.0.0", - "optional": true - }, - { - "id": "host.dialog", - "version": "^1.0.0", - "optional": true - }, - { - "id": "host.notification", - "version": "^1.0.0", - "optional": true - }, - { - "id": "host.shortcut", - "version": "^1.0.0", - "optional": true - }, - { - "id": "host.application", - "version": "^1.0.0", - "optional": true - } - ] - }, - "build": { - "command": [ - "node", - "../../tools/build.mjs", - "test-host" - ] - }, - "package": { - "files": [ - "dist" - ] - } -} diff --git a/curios/test-incompatible/curio.json b/curios/test-incompatible/curio.json deleted file mode 100644 index 494587a..0000000 --- a/curios/test-incompatible/curio.json +++ /dev/null @@ -1,57 +0,0 @@ -{ - "schemaVersion": 1, - "id": "dev.trove.qa.incompatible", - "name": "故障 · 服务版本不兼容", - "description": "要求 provider.probe 2.x;已安装提供方仅支持 1.x。", - "version": "1.0.0", - "engine": { - "trove": ">=0.1.0 <1.0.0" - }, - "ui": { - "entry": "dist/ui/index.html", - "window": { - "title": "故障 · 服务版本不兼容", - "width": 960, - "height": 760, - "minWidth": 600, - "minHeight": 400, - "resizable": true - } - }, - "services": { - "requires": [ - { - "id": "host.runtime", - "version": "^1.0.0" - }, - { - "id": "host.services", - "version": "^1.0.0" - }, - { - "id": "host.storage", - "version": "^1.0.0" - }, - { - "id": "dev.trove.qa.provider.probe", - "version": "^2.0.0" - }, - { - "id": "host.fs", - "version": "^1.0.0" - } - ] - }, - "build": { - "command": [ - "node", - "../../tools/build.mjs", - "test-incompatible" - ] - }, - "package": { - "files": [ - "dist" - ] - } -} diff --git a/curios/test-isolation/curio.json b/curios/test-isolation/curio.json deleted file mode 100644 index 830b1a2..0000000 --- a/curios/test-isolation/curio.json +++ /dev/null @@ -1,83 +0,0 @@ -{ - "schemaVersion": 1, - "id": "dev.trove.qa.isolation", - "name": "02 · 数据隔离对照", - "description": "与 Host 检查使用相同存储键,验证插件间独立命名空间。", - "version": "1.0.0", - "engine": { - "trove": ">=0.1.0 <1.0.0" - }, - "ui": { - "entry": "dist/ui/index.html", - "window": { - "title": "02 · 数据隔离对照", - "width": 960, - "height": 760, - "minWidth": 600, - "minHeight": 400, - "resizable": true - } - }, - "services": { - "requires": [ - { - "id": "host.runtime", - "version": "^1.0.0" - }, - { - "id": "host.services", - "version": "^1.0.0" - }, - { - "id": "host.storage", - "version": "^1.0.0" - }, - { - "id": "host.fs", - "version": "^1.0.0" - }, - { - "id": "host.window", - "version": "^1.0.0", - "optional": true - }, - { - "id": "host.clipboard", - "version": "^1.0.0", - "optional": true - }, - { - "id": "host.dialog", - "version": "^1.0.0", - "optional": true - }, - { - "id": "host.notification", - "version": "^1.0.0", - "optional": true - }, - { - "id": "host.shortcut", - "version": "^1.0.0", - "optional": true - }, - { - "id": "host.application", - "version": "^1.0.0", - "optional": true - } - ] - }, - "build": { - "command": [ - "node", - "../../tools/build.mjs", - "test-isolation" - ] - }, - "package": { - "files": [ - "dist" - ] - } -} diff --git a/curios/test-optional/curio.json b/curios/test-optional/curio.json deleted file mode 100644 index f97f756..0000000 --- a/curios/test-optional/curio.json +++ /dev/null @@ -1,58 +0,0 @@ -{ - "schemaVersion": 1, - "id": "dev.trove.qa.optional", - "name": "06 · 可选依赖缺失", - "description": "缺失可选服务时 UI 仍可工作,调用返回 service.not_found。", - "version": "1.0.0", - "engine": { - "trove": ">=0.1.0 <1.0.0" - }, - "ui": { - "entry": "dist/ui/index.html", - "window": { - "title": "06 · 可选依赖缺失", - "width": 960, - "height": 760, - "minWidth": 600, - "minHeight": 400, - "resizable": true - } - }, - "services": { - "requires": [ - { - "id": "host.runtime", - "version": "^1.0.0" - }, - { - "id": "host.services", - "version": "^1.0.0" - }, - { - "id": "host.storage", - "version": "^1.0.0" - }, - { - "id": "dev.trove.qa.absent.probe", - "version": "^1.0.0", - "optional": true - }, - { - "id": "host.fs", - "version": "^1.0.0" - } - ] - }, - "build": { - "command": [ - "node", - "../../tools/build.mjs", - "test-optional" - ] - }, - "package": { - "files": [ - "dist" - ] - } -} diff --git a/curios/test-provider/contracts/probe.json b/curios/test-provider/contracts/probe.json deleted file mode 100644 index 89ec0bc..0000000 --- a/curios/test-provider/contracts/probe.json +++ /dev/null @@ -1,208 +0,0 @@ -{ - "schemaVersion": 1, - "service": "dev.trove.qa.provider.probe", - "version": "1.0.0", - "description": "Trove QA service; fault methods are intentionally opt-in.", - "methods": { - "echo": { - "params": { - "type": [ - "null", - "boolean", - "object", - "array", - "number", - "string" - ] - }, - "result": { - "type": [ - "null", - "boolean", - "object", - "array", - "number", - "string" - ] - } - }, - "info": { - "params": { - "type": "object", - "properties": {}, - "required": [], - "additionalProperties": false - }, - "result": { - "type": "object", - "required": [ - "pid", - "curioId", - "version", - "label", - "args", - "activeRequests", - "subscriptions" - ] - } - }, - "delay": { - "params": { - "type": "object", - "properties": { - "ms": { - "type": "integer", - "minimum": 0, - "maximum": 10000 - }, - "value": { - "type": [ - "null", - "boolean", - "object", - "array", - "number", - "string" - ] - } - }, - "required": [ - "ms" - ], - "additionalProperties": false - }, - "result": { - "type": [ - "null", - "boolean", - "object", - "array", - "number", - "string" - ] - } - }, - "fail": { - "params": { - "type": "object", - "properties": {}, - "required": [], - "additionalProperties": false - }, - "result": { - "type": [ - "null", - "boolean", - "object", - "array", - "number", - "string" - ] - } - }, - "invalidResult": { - "params": { - "type": "object", - "properties": {}, - "required": [], - "additionalProperties": false - }, - "result": { - "type": "string" - } - }, - "storageRoundtrip": { - "params": { - "type": "object", - "properties": { - "key": { - "type": "string" - }, - "value": { - "type": [ - "null", - "boolean", - "object", - "array", - "number", - "string" - ] - } - }, - "required": [ - "key", - "value" - ], - "additionalProperties": false - }, - "result": { - "type": "object", - "required": [ - "found", - "value" - ] - } - }, - "uppercase": { - "params": { - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": [ - "text" - ], - "additionalProperties": false - }, - "result": { - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": [ - "text" - ], - "additionalProperties": false - } - } - }, - "events": { - "progress": { - "params": { - "type": "object", - "properties": { - "taskId": { - "type": "string", - "minLength": 1 - } - }, - "required": [ - "taskId" - ], - "additionalProperties": false - }, - "payload": { - "type": "object", - "properties": { - "taskId": { - "type": "string" - }, - "progress": { - "type": "number", - "minimum": 0, - "maximum": 1 - } - }, - "required": [ - "taskId", - "progress" - ], - "additionalProperties": false - } - } - } -} diff --git a/curios/test-provider/curio.json b/curios/test-provider/curio.json deleted file mode 100644 index 1cc7a23..0000000 --- a/curios/test-provider/curio.json +++ /dev/null @@ -1,51 +0,0 @@ -{ - "schemaVersion": 1, - "id": "dev.trove.qa.provider", - "name": "04 · 公共服务提供方", - "description": "无 UI,按需启动;20 秒空闲后退出。", - "version": "1.0.0", - "engine": { - "trove": ">=0.1.0 <1.0.0" - }, - "backend": { - "entry": "backend/probe", - "args": [], - "env": { - "QA_LABEL": "test-provider" - }, - "lifecycle": "on-demand", - "transport": "unix", - "startWithUi": false, - "startupTimeoutMs": 5000, - "shutdownGraceMs": 500, - "idleTimeoutMs": 20000 - }, - "services": { - "requires": [ - { - "id": "host.storage", - "version": "^1.0.0" - } - ], - "provides": [ - { - "id": "dev.trove.qa.provider.probe", - "version": "1.0.0", - "contract": "contracts/probe.json" - } - ] - }, - "build": { - "command": [ - "node", - "../../tools/build.mjs", - "test-provider" - ] - }, - "package": { - "files": [ - "backend", - "contracts" - ] - } -} diff --git a/curios/test-required/curio.json b/curios/test-required/curio.json deleted file mode 100644 index 44759a0..0000000 --- a/curios/test-required/curio.json +++ /dev/null @@ -1,58 +0,0 @@ -{ - "schemaVersion": 1, - "id": "dev.trove.qa.required", - "name": "故障 · 必需依赖缺失", - "description": "声明不存在的必需服务,观察底座与调用错误。", - "version": "1.0.0", - "engine": { - "trove": ">=0.1.0 <1.0.0" - }, - "ui": { - "entry": "dist/ui/index.html", - "window": { - "title": "故障 · 必需依赖缺失", - "width": 960, - "height": 760, - "minWidth": 600, - "minHeight": 400, - "resizable": true - } - }, - "services": { - "requires": [ - { - "id": "host.runtime", - "version": "^1.0.0" - }, - { - "id": "host.services", - "version": "^1.0.0" - }, - { - "id": "host.storage", - "version": "^1.0.0" - }, - { - "id": "dev.trove.qa.absent.probe", - "version": "^1.0.0", - "optional": false - }, - { - "id": "host.fs", - "version": "^1.0.0" - } - ] - }, - "build": { - "command": [ - "node", - "../../tools/build.mjs", - "test-required" - ] - }, - "package": { - "files": [ - "dist" - ] - } -} diff --git a/curios/test-self/curio.json b/curios/test-self/curio.json deleted file mode 100644 index 4d6f69c..0000000 --- a/curios/test-self/curio.json +++ /dev/null @@ -1,67 +0,0 @@ -{ - "schemaVersion": 1, - "id": "dev.trove.qa.self", - "name": "03 · 原生后端检查", - "description": "UI → @self → 原生 Rust 后端 → Host;并发、取消、事件。", - "version": "1.0.0", - "engine": { - "trove": ">=0.1.0 <1.0.0" - }, - "ui": { - "entry": "dist/ui/index.html", - "window": { - "title": "03 · 原生后端检查", - "width": 960, - "height": 760, - "minWidth": 600, - "minHeight": 400, - "resizable": true - } - }, - "backend": { - "entry": "backend/probe", - "args": [], - "env": { - "QA_LABEL": "test-self" - }, - "lifecycle": "on-demand", - "transport": "unix", - "startWithUi": true, - "startupTimeoutMs": 5000, - "shutdownGraceMs": 500, - "idleTimeoutMs": 0 - }, - "services": { - "requires": [ - { - "id": "host.runtime", - "version": "^1.0.0" - }, - { - "id": "host.services", - "version": "^1.0.0" - }, - { - "id": "host.storage", - "version": "^1.0.0" - }, - { - "id": "host.fs", - "version": "^1.0.0" - } - ] - }, - "build": { - "command": [ - "node", - "../../tools/build.mjs", - "test-self" - ] - }, - "package": { - "files": [ - "dist/ui", - "backend" - ] - } -} diff --git a/curios/test-start-timeout/curio.json b/curios/test-start-timeout/curio.json deleted file mode 100644 index 0af90e9..0000000 --- a/curios/test-start-timeout/curio.json +++ /dev/null @@ -1,69 +0,0 @@ -{ - "schemaVersion": 1, - "id": "dev.trove.qa.start-timeout", - "name": "故障 · 启动超时", - "description": "握手 welcome 后不发送 ready,期望启动在 1 秒内超时。", - "version": "1.0.0", - "engine": { - "trove": ">=0.1.0 <1.0.0" - }, - "ui": { - "entry": "dist/ui/index.html", - "window": { - "title": "故障 · 启动超时", - "width": 960, - "height": 760, - "minWidth": 600, - "minHeight": 400, - "resizable": true - } - }, - "backend": { - "entry": "backend/probe", - "args": [ - "--no-ready" - ], - "env": { - "QA_LABEL": "test-start-timeout" - }, - "lifecycle": "on-demand", - "transport": "unix", - "startWithUi": false, - "startupTimeoutMs": 1000, - "shutdownGraceMs": 500, - "idleTimeoutMs": 0 - }, - "services": { - "requires": [ - { - "id": "host.runtime", - "version": "^1.0.0" - }, - { - "id": "host.services", - "version": "^1.0.0" - }, - { - "id": "host.storage", - "version": "^1.0.0" - }, - { - "id": "host.fs", - "version": "^1.0.0" - } - ] - }, - "build": { - "command": [ - "node", - "../../tools/build.mjs", - "test-start-timeout" - ] - }, - "package": { - "files": [ - "dist/ui", - "backend" - ] - } -} diff --git a/curios/test-stubborn/curio.json b/curios/test-stubborn/curio.json deleted file mode 100644 index debbe82..0000000 --- a/curios/test-stubborn/curio.json +++ /dev/null @@ -1,69 +0,0 @@ -{ - "schemaVersion": 1, - "id": "dev.trove.qa.stubborn", - "name": "故障 · 拒绝优雅退出", - "description": "忽略 shutdown,检查底座在宽限期后清理进程。", - "version": "1.0.0", - "engine": { - "trove": ">=0.1.0 <1.0.0" - }, - "ui": { - "entry": "dist/ui/index.html", - "window": { - "title": "故障 · 拒绝优雅退出", - "width": 960, - "height": 760, - "minWidth": 600, - "minHeight": 400, - "resizable": true - } - }, - "backend": { - "entry": "backend/probe", - "args": [ - "--ignore-shutdown" - ], - "env": { - "QA_LABEL": "test-stubborn" - }, - "lifecycle": "on-demand", - "transport": "unix", - "startWithUi": true, - "startupTimeoutMs": 5000, - "shutdownGraceMs": 500, - "idleTimeoutMs": 0 - }, - "services": { - "requires": [ - { - "id": "host.runtime", - "version": "^1.0.0" - }, - { - "id": "host.services", - "version": "^1.0.0" - }, - { - "id": "host.storage", - "version": "^1.0.0" - }, - { - "id": "host.fs", - "version": "^1.0.0" - } - ] - }, - "build": { - "command": [ - "node", - "../../tools/build.mjs", - "test-stubborn" - ] - }, - "package": { - "files": [ - "dist/ui", - "backend" - ] - } -} diff --git a/curios/test-upgrade-v1/curio.json b/curios/test-upgrade-v1/curio.json deleted file mode 100644 index b763580..0000000 --- a/curios/test-upgrade-v1/curio.json +++ /dev/null @@ -1,53 +0,0 @@ -{ - "schemaVersion": 1, - "id": "dev.trove.qa.upgrade", - "name": "08 · 升级与数据保留", - "description": "1.0.0 基线;写入标记后安装 1.1.0 检查数据保留。", - "version": "1.0.0", - "engine": { - "trove": ">=0.1.0 <1.0.0" - }, - "ui": { - "entry": "dist/ui/index.html", - "window": { - "title": "08 · 升级与数据保留", - "width": 960, - "height": 760, - "minWidth": 600, - "minHeight": 400, - "resizable": true - } - }, - "services": { - "requires": [ - { - "id": "host.runtime", - "version": "^1.0.0" - }, - { - "id": "host.services", - "version": "^1.0.0" - }, - { - "id": "host.storage", - "version": "^1.0.0" - }, - { - "id": "host.fs", - "version": "^1.0.0" - } - ] - }, - "build": { - "command": [ - "node", - "../../tools/build.mjs", - "test-upgrade-v1" - ] - }, - "package": { - "files": [ - "dist" - ] - } -} diff --git a/curios/test-upgrade-v2/curio.json b/curios/test-upgrade-v2/curio.json deleted file mode 100644 index c729335..0000000 --- a/curios/test-upgrade-v2/curio.json +++ /dev/null @@ -1,53 +0,0 @@ -{ - "schemaVersion": 1, - "id": "dev.trove.qa.upgrade", - "name": "08 · 升级与数据保留", - "description": "1.1.0 更新;显示版本并读取基线标记。", - "version": "1.1.0", - "engine": { - "trove": ">=0.1.0 <1.0.0" - }, - "ui": { - "entry": "dist/ui/index.html", - "window": { - "title": "08 · 升级与数据保留", - "width": 960, - "height": 760, - "minWidth": 600, - "minHeight": 400, - "resizable": true - } - }, - "services": { - "requires": [ - { - "id": "host.runtime", - "version": "^1.0.0" - }, - { - "id": "host.services", - "version": "^1.0.0" - }, - { - "id": "host.storage", - "version": "^1.0.0" - }, - { - "id": "host.fs", - "version": "^1.0.0" - } - ] - }, - "build": { - "command": [ - "node", - "../../tools/build.mjs", - "test-upgrade-v2" - ] - }, - "package": { - "files": [ - "dist" - ] - } -} diff --git a/docs/development.md b/docs/development.md index 3eb8635..a039f16 100644 --- a/docs/development.md +++ b/docs/development.md @@ -1,62 +1,47 @@ -# Curio 开发与验收 +# 官方软件源的维护与发布 -此仓库承载 Trove 正式插件与生产软件源。当前包含用于首批验收的简单插件;生产发布以 `source/index.json` 为准,本地测试材料位于 `dist/`。 +`trove/curios` 保存官方软件源索引、插件仓库登记和发布工具。底座在 `trove/app`;插件源码与安装包分别位于 `trove-plugins` 下各仓库的代码和 Releases。 -正式插件 **Harmony Connector** 的使用、构建、发布和真机验收步骤见 [Harmony Connector](harmony-connector.md)。它采用独立实现和构建命令,不属于下面的 QA 套件。 +## 仓库职责 -## 底座测试套件 +- `source/repositories.json`:登记插件 ID 与发布仓库的对应关系。 +- `source/index.json`:底座消费的 Source Index v1,保留版本、兼容范围、平台、Release 附件 URL、大小与 SHA-256。 +- `tools/prepare-release.mjs`:手动构建后生成发布目录。 +- `tools/publish-release.mjs`:可选的命令行上传工具,上传全部附件后发布 Release。 +- `tools/notify-release.mjs`:由插件仓库的 Linux workflow 在 Release 发布后触发源收录。 +- `tools/sync-release.mjs`:校验登记关系和附件,合并版本,通过文件 SHA 比较后更新远端索引。 +- `.gitea/workflows/sync-release.yml`:在 Linux runner 上执行收录。 -15 个插件工程(14 个插件 ID,其中升级插件有两个版本),覆盖 UI、Host 服务、Rust 原生后端、跨插件服务、可选/必需依赖、版本约束、生命周期、故障恢复和升级回滚。另有 22 个故意无效的安装包和 4 个异常软件源。 +安装包不进入 Git,所有 `*.curio` 都被忽略。Git 软件源仍指向 `https://git.render.ink/trove/curios.git`。 -```sh -pnpm install --frozen-lockfile +## 发布协议 -# 已安装 trove 命令时可省略此变量;也可指向本机 app 仓库构建产物。 -export TROVE_BIN=/absolute/path/to/app/target/debug/trove -pnpm run pack +插件仓库根目录的 `release.json` 声明仓库、插件 ID 和是否为共享测试套件。普通插件 tag 为 `v`;测试套件独立打 tag,各测试插件保留自身版本。 -# 可选:使用真实底座组件在临时目录自动验证。 -export TROVE_APP_DIR=/absolute/path/to/app -pnpm run verify -``` +Release 先以草稿创建,上传每个平台的 `.curio` 和 `trove-release.json` 后再发布。描述文件包含仓库、tag、源码 commit、插件 Manifest 摘要,以及附件名、平台、大小和 SHA-256。插件图标以小型图片 Data URL 收录,私有图片也无需额外鉴权。 -打包需要 macOS、Rust/Cargo、Node.js 22+、pnpm、Python 3 和 Trove CLI。原生包只面向当前构建机器的架构;安装后的插件无需 Node.js、Python 或 Cargo。SDK 使用仓库内的版本化 `@trove/plugin-sdk@0.1.0` 包,离线复制到 UI 中。 +本机手动生成发布目录,上传全部附件并发布 Release 后,插件的 Linux workflow 使用 `CATALOG_TOKEN` 调用源仓库的 `sync-release.yml`,传入 `repository` 与 `tag`。收录只接受登记仓库及其所属插件 ID,并下载附件校验大小和 SHA-256。 -生成结果: +源 workflow 使用 `PLUGIN_RELEASE_TOKEN` 读取私有插件 Release,使用本任务的 `GITEA_TOKEN` 更新自身索引。写入以 Gitea Contents API 的当前文件 SHA 为前提,并发冲突后重新读取、重新合并,不覆盖其他发布。 -- `dist/packages/`:15 个可安装 `.curio` 包。 -- `dist/index.json`:常规测试源,8 个插件 ID / 9 个版本包。 -- `dist/faults-index.json`:6 个故障/异常依赖插件,按需安装。 -- `dist/invalid/`:22 个无效包、4 个异常源及预期错误码。 -- `dist/verification.json`:自动验证的逐项结果、环境和观察项。 +相同插件 ID、版本和平台的产物不可变。重复收录没有副作用;相同版本的 URL、大小或 SHA-256 发生变化会报错。 -在 Trove 添加**本地软件源**,选择 `dist/index.json` 的绝对路径,即可安装常规测试插件。若要测试 HTTP 下载: +## 新增插件 -```sh -pnpm run serve -# 在底座添加 HTTP 软件源:http://127.0.0.1:8765/index.json -# 用 QA_HTTP_PORT 环境变量更换端口,Ctrl-C 停止。 -``` +1. 在 `trove-plugins` 创建独立仓库,添加源码、`curio.json`、`release.json` 和发布 workflow。 +2. 将仓库与 ID 登记到 `source/repositories.json`,授予收录机器人读取该仓库的代码与 Release 的权限。 +3. 在对应平台准备构建环境与 Trove CLI,提交源码,运行插件仓库的手动发布脚本并上传 Release。 -完整步骤、覆盖矩阵与当前限制见 [测试指南](testing.md)。 +现有插件仓库中的发布 workflow 可作为模板。发布工具从源仓库检出,统一维护。 -插件各自通过 `curio.json.version` 管理版本;公开服务契约版本与底座兼容范围分别维护。历史发布索引只追加版本、禁止覆盖同版本产物,详见 [版本与发布](versioning.md)。`pnpm run release:prepare` 将安装包与索引准备到 `source/`,提交并推送后即可通过 Git 仓库源安装。`pnpm run test:release` 验证版本约束。 +## 失败恢复 -## 目录 +Release 成功但收录失败时,在插件 Actions 手动运行发布 workflow,填写已有 tag;该 workflow 使用 Linux runner,只重试收录,不重新构建或替换附件。也可在源仓库直接运行收录 workflow。 -- `curios//`:各插件独立 Manifest 与公共服务契约。 -- `shared/ui/`、`shared/backend/`:测试面板和原生后端源码;构建时复制到各插件。 -- `source/index.json`:生产软件源与历史版本索引。 -- `tools/`:构建、打包、测试源和真实底座验证适配器。 -- `vendor/`:版本化 SDK 分发包与来源说明。 +## 私有源 -项目依赖使用 pnpm;插件通过版本化的 `@trove/plugin-sdk` 引用 SDK,不通过 `../app` 引用源文件。仅自动验证适配器通过显式的 `TROVE_APP_DIR` 编译待测底座组件,插件构建与运行不依赖该路径。App 仓库保留协议规范和教学模板。 +当前仓库保持私有。底座用本机 Git 凭据读取索引;下载同一 Git 服务器上的私有 Release 附件时,在服务器要求鉴权后查询 Git HTTPS credential helper。账户必须能读取源仓库和对应插件仓库。SSH 密钥仅用于 Git,附件下载还需要本机 HTTPS 凭据。凭据不写进索引或安装包。 -## 发布约定 +## 自动化身份 -1. 各插件独立声明版本、平台、依赖与权限。 -2. 构建并执行 trove curio check / pack。macOS 原生后端使用 Mac Runner 构建。 -3. 执行 `pnpm run release:prepare`,检查 `source/index.json` 的新增版本与 `source/packages/` 中的不可变版本包。 -4. 将索引和安装包在同一提交中推送,再使用 Git 仓库地址完成远程安装与运行验证。私有仓库沿用本机 Git 认证。 - -官网:https://trove.render.ink +`trove-publisher` 负责跨仓库通信:可以读取源代码、触发源 Actions,并读取已登记插件的 Release;没有底座仓库访问权限,也不能直接改写源代码。源索引写入由源 workflow 自身的任务令牌完成。 diff --git a/docs/harmony-connector.md b/docs/harmony-connector.md deleted file mode 100644 index 2c87246..0000000 --- a/docs/harmony-connector.md +++ /dev/null @@ -1,140 +0,0 @@ -# Harmony Connector - -插件 ID:`dev.trove.harmony-connector`。当前版本:`0.5.2`。 - -macOS 上的鸿蒙设备连接工具。通过 USB 收发文件与完整文件夹;使用官方 HDC 管理应用、保存截图、导出日志和调试应用数据。功能按连接工具、设备实际能力和授权开放,不按 HarmonyOS 大版本或机型名称限制。 - -## 安装 - -在 Trove 添加 Git 软件源:仓库 `https://git.render.ink/trove/curios.git`,唯一 ID `trove-official`(对应 `source/index.json` 顶层 `id`),显示名称可自行填写。刷新后安装 Harmony Connector。私有仓库使用本机 Git 凭据。 - -发布包适用于 Apple Silicon、macOS 13 及以上,内含 libmtp 1.1.23 和 libusb 1.0.30。Intel 需在对应 Mac 构建并单独验证后发布,当前未提供 Intel 包。 - -## 文件与文件夹 - -1. 解锁手机,用支持数据传输的 USB 线连接 Mac,在手机上选择「传输文件」并允许访问。 -2. 插件启动后自动发现设备,选择手机并连接。也可手动扫描。 -3. 选择存储位置,进入目标目录。「发送文件」选择文件,「发送文件夹」选择整个目录;文件和文件夹旁的「接收」均可下载到 Mac。 - -文件功能不需要 HDC、ADB、Homebrew 或开发者模式。设备每约 2 秒自动检测一次;连接后仅检查 USB 存在性,不重复打开会话。手动断开后不会自行重新连接。 - -文件夹会保留层级、中文名称、空文件和空目录。整棵目录先检查再传输;总进度汇总全部文件,显示当前文件、已完成文件数、当前/平均速度、耗时和预计剩余时间。速度来自实际字节进度,不是预设数值。 - -- 不覆盖或合并同名条目。上传还会检查设备剩余容量,每次写入前再次检查目标名称。 -- 下载先写入所选目录下的隐藏临时文件夹,全部文件长度校验成功后,原子保存为最终文件夹。失败或正常取消会清理此次临时内容。 -- 不跟随符号链接,不传输特殊文件;拒绝路径穿越、循环、重名目录和超过 100,000 条目或 128 层的目录树。 -- 上传中断时手机可能保留已创建的目录和文件。不会自动删除这些内容,请在手机上检查后再重试。底层 USB 事务取消需等待设备响应。 -- 长度校验不等于端到端内容哈希校验;实际验收应将上传文件下载回来比较 SHA-256。 -- 强制结束进程或断电可能留下 `.harmony-*.part` 临时内容。插件不会扫描或删除用户目录中的历史临时文件。 - -手机只开放 MTP 可见的存储,实际目录、访问权限和图库行为由设备决定;向普通目录发送照片不保证导入图库。本版不提供断点续传、无线 MTP、图库导入或全盘访问。 - -扫描名称取自 USB 描述符;连接后使用 MTP 友好名称并单独显示设备报告的型号。它可能不同于手机设置中的自定义名称。0.1.0 将 libmtp 匹配表中的 `Nova14` 误用于展示,0.1.1 已修复,不再把驱动匹配名称当作机型。 - -## 设备与应用 - -首次进入「设备与应用」,如果没有可用 HDC,页面会显示安装引导: - -1. 点击「前往华为官网下载」,在浏览器打开 [Command Line Tools 官方下载页](https://developer.huawei.com/consumer/cn/download/command-line-tools-for-hmos)。Apple Silicon 选择 Mac (ARM64),Intel 选择 Mac (x86),按官网提示登录华为账号并下载。 -2. 完整解压 ZIP,点击「选择已解压的工具目录」。可选择包含 `command-line-tools` 的解压目录、`command-line-tools` 本身,或其 `toolchains` 目录;插件定位 HDC、运行版本检查,成功后保存路径。错误选择和取消不会覆盖已有配置。 -3. 手机连接 USB,启用开发者模式与 USB 调试,解锁并允许这台 Mac 的调试请求。配置成功后自动检查设备;「刷新连接」也会重新检查工具,支持启动后才安装到 PATH 的 HDC。 - -已有 DevEco Studio 或 SDK 的用户也可点击「选择 HDC 文件」。插件保留手动选择的路径;未配置时,按 PATH、`~/Library/HarmonyConnector/tools` 中的工具安装、`~/Downloads` 中已解压的 Command Line Tools、`/Applications` 和 `~/Applications` 中的 DevEco Studio 依次查找。工具安装目录按数字版本从新到旧检查,跳过未包含可执行 HDC 的目录;Downloads 只检查名称以 `command-line-tools` 或 `commandline-tools` 开头的直接子目录及 SDK 的标准位置,不递归扫描用户文件、不将 ZIP 视为已安装工具。典型路径: - -```text -/Applications/DevEco-Studio.app/Contents/sdk/default/openharmony/toolchains/hdc -``` - -请保留完整的工具目录及同目录依赖库,插件不重打包官方 SDK。选择路径保存在插件自己的数据目录中;工具被移动、删除或无法运行时重新显示安装引导和原因。工具准备完成前隐藏空设备列表及不可用的管理面板,文件传输仍可直接使用。管理页明确选择 HDC 设备,不根据 MTP USB 地址猜测二者对应关系。 - -| 操作 | 行为与边界 | -| --- | --- | -| 设备连接与信息 | 显示 HDC 目标、USB/TCP 类型、授权/连接状态;检查 `bm`、`aa`、`uitest`、`hilog` 等实际工具帮助。未知格式报错,不伪装成无设备。 | -| 应用列表与详情 | 按包名筛选,读取版本、签名类型和设备公开的可启动组件。不根据包名猜测 `EntryAbility`。 | -| 安装/更新 | 选择已签名 `.hap`;展示包名、版本、大小、SHA-256 并确认。执行前复制到临时快照并重新校验哈希;安装后读取实际版本。签名或权限错误不会触发自动卸载。 | -| 卸载 | 用户确认后操作;不移除系统应用,结束后检查应用是否确实消失。 | -| 启动/强制停止 | 从实际公开的组件选择启动入口;强制停止需要确认,报告设备返回结果。 | -| 截图 | 使用 UITest 捕获,下载并解码验证 PNG 后保存。只清理本次创建的远端临时图片。 | -| 日志 | 导出 `hilog -x` 快照到用户选择的目录。日志正文中的错误信息不会被误判为命令失败。 | -| 诊断报告 | 使用 HDC `bugreport` 保存报告,拒绝把空结果作为成功。 | -| 调试数据导出 | 仅对调试签名且设备允许访问的应用开放;复制 `data/storage/el2/base`,附带路径、大小、SHA-256 清单。应用通常需已启动。 | - -调试数据导出是运行中文件的副本,不保证数据库事务一致性,不包含全部应用状态,不提供一键恢复。HDC 对空目录及空子目录的行为也不同于 MTP 目录传输。普通发布应用私有数据、整机备份/恢复和受保护系统数据没有可供该插件普遍使用的公开权限;不能因为 `backup_tool` 退出码为 0 就宣称备份成功。 - -管理操作使用独立串行任务,支持取消与超时;取消不回滚设备端已经完成的更改。操作开始前重新确认所选设备在线。功能有命令支持仍可能被单个应用、对象或系统权限拒绝,失败会显示设备返回的原因。 - -## 投屏与控制 - -在「设备与应用」选择已授权的 HDC 目标,点击「打开投屏窗口」。投屏使用独立的 macOS 窗口,默认可以直接通过画面控制手机。 - -- 鼠标按下、拖动、松开即时发送触摸动作;按住即可长按,滚轮滑动,右键返回,中键回到主页。 -- ⌘F 切换全屏,⌘V 向手机粘贴剪贴板文字,⌘R 切换仅观看,⌘W 关闭投屏。方向键、回车、退格直接作用于手机。 -- 窗口标题显示显示帧率和接收码率。只保留最新解码画面,避免积累播放延迟。 -- 切换管理页和文件页、隐藏管理窗口不停止投屏。关闭投屏窗口或点击「停止投屏」会释放设备会话;退出插件也会停止。 -- 失焦、关闭窗口和画面方向变化时释放当前触摸;移动事件合并,按下和松开保持顺序,不在重连后重放输入。 - -0.5.0 使用官方 Hypium 26 的 Unix H.264 recorder 和独立控制代理,经 HDC 转发视频及触摸通道。手机端编码,Mac 端 VideoToolbox 解码并立即显示。无需开发者账号、签名、手机应用或完整 SDK;用户只需官方 HDC 和已授权的设备 USB 调试。编码配置目标为 60 fps、8 Mbps、半尺寸,实际流畅度取决于设备,当前版本尚未进行真机性能验收。 - -0.5.1 在设备代理进程及 Unix socket 就绪后才创建转发;按官方资源版本依次尝试兼容代理。首次连接失败会重新建立本插件的 HDC 转发。窗口收到首张解码画面之前显示连接中,失败区分组件启动、转发和视频连接,不再把网络错误归为 Mac 文件错误。 - -0.5.2 点击后先打开独立窗口,设备准备在后台进行,并显示连接阶段;准备中可直接关闭窗口取消。收到首张解码画面后,插件在 Mac 上按设备保存成功的代理版本,下次优先使用;原版本失效时仍自动尝试其他官方组件。首次匹配仍可能等待,后续不再从头扫描全部候选。 - -组件与传输依据见[官方视频与触控调研](research/harmony-screen-performance.md)。旧 JPEG 后端接口仅保留用于诊断;管理页已使用独立视频窗口。 - -## 重连问题与 debug - -0.1.1 真机记录:HUAWEI Pura 70 Ultra,USB `12d1:1101`,USB 名称 `HDC Device`,MTP 友好名称 `HUAWEI Pura 70 Ultra`,型号代码 `HBP-AL00`。设备未通过 MTP 返回自定义名称 `daniel`。连接后可读取存储,断开请求约 0.27 毫秒返回,USB 释放约 61 秒后结束;**这不证明随后能重新打开会话**。用户报告仍需拔线。 - -0.3.0 真机复现了 CloseSession 等待 60 秒的问题。固定 libmtp 依赖增加一个小补丁,仅把 CloseSession 的超时设为 2 秒,文件传输超时、接口释放、现有 USB reset 与关闭顺序保持不变。macOS 重枚举有短暂离线窗口,诊断程序最多等 10 秒重新发现设备,再实际打开并读取存储和目录。 - -2026-09-10,在同一 USB 插接周期,开发构建与发布构建各完成 3 轮只读重连,每轮返回 1 个存储和 9 个根目录条目。开发构建关闭约 2.28–2.33 秒;发布构建约 2.13–7.28 秒,包含 USB 重枚举耗时。设备仍不正常响应 CloseSession,恢复由随后的 USB 清理/reset 完成;不能将 2 秒超时宣传为所有设备都固定 2 秒完成断开。该结果覆盖这台手机,不代表全部固件。见[会话恢复调研](research/harmony-mtp-session.md)。 - -关闭插件窗口,保留一台解锁且处于「传输文件」模式的手机,然后执行: - -```sh -# 仅枚举 -curios/harmony-connector/backend/harmony-connector --diagnose - -# 只读执行三轮打开、存储/目录读取、关闭与重新打开;不要在插件占用 USB 时运行 -curios/harmony-connector/backend/harmony-connector --check-reconnect -``` - -`--check-reconnect` 逐阶段输出 JSON、耗时和实际目录读取结果,不修改手机文件。没有恰好一台设备或任何一轮失败时以非零状态结束;CloseSession 时限为 2 秒,USB 清理和重枚举仍可能增加耗时。不能仅凭扫描发现设备、按钮可用或 reset 返回成功判定验收通过。 - -只读验证投屏(先完成手机 USB 调试授权): - -```sh -curios/harmony-connector/backend/harmony-connector --check-screen /absolute/path/to/hdc -# 多台设备时可指定目标;最后的可选目录用于保存最终画面 -curios/harmony-connector/backend/harmony-connector --check-screen /absolute/path/to/hdc DEVICE_ID /absolute/save/directory -``` - -该命令走 UI 相同的后端接口,采集并解码 3 帧,输出耗时,最后关闭会话;不启用控制、不发送输入。首次 HDC 连接显示 Unauthorized 时,在手机上允许 USB 调试。`hdc -v` 能运行但管理失败时,先用 `hdc list targets -v` 检查授权状态。 - -0.3.0 自动化验证通过 39 项原生测试、27 项 UI/格式/坐标测试、3 项 USB C 桥接检查及 3 项底座协议检查。浏览器另外验证了持续显示模拟画面、启用控制、中文输入、放大和停止清空;模拟数据不会包含在发布包内。官方 Command Line Tools 26.0.0.821(Mac ARM)的 HDC 返回 `Ver: 3.2.0f`,已识别真机,但当次状态为 Unauthorized,故投屏和输入的真机效果尚未验收,不能将上述替身测试当作真机通过。 - -0.3.1 的安装引导验证通过 41 项原生测试、30 项 UI/格式/坐标测试和 6 项 USB/协议检查(共 77 项)。新增回归覆盖官网下载入口、选择已解压目录、取消/错误选择保留配置、启动后安装工具、工具丢失后返回引导。浏览器以明确标记的模拟工具检查了引导和配置后的界面切换。使用已下载的官方工具目录调用实际发布后端,成功定位 HDC 并返回 `Ver: 3.2.0f`;设备当时仍为 Unauthorized,后续投屏验收因无已授权设备而停止。 - -0.3.2 补上工具安装目录和 Downloads 的自动发现。回归在独立 HOME、空 PATH 工具目录、未保存任何设置的进程中运行真实 `Manager::new → hdc.tool`,验证数字版本选择、未完成解压跳过、手动路径优先和官方解压目录布局。实际 Mac 验证通过 Wire 请求 `hdc.tool`,使用空插件数据目录和系统 PATH,未调用 `configure`;0.3.1 返回 `available: false`,0.3.2 自动找到之前解压的工具并返回 `Ver: 3.2.0f`。USB 目标仍为 Unauthorized。该版 42 项原生测试、30 项 UI/格式/坐标测试及 6 项 USB/协议检查全部通过。 - -0.4.0 的 HDC 持续采集与会话恢复通过 54 项原生、33 项 UI/格式/坐标、6 项 USB/协议检查(共 93 项)。真机连续三轮开始、接收并解码 JPEG、停止和重新开始全部成功,每轮均确认代理和转发清理,无需拔线。新增回归覆盖自身孤儿进程恢复、结束失败不误报其他工具、多个窗口的同设备锁、仅恢复自身转发以及 JPEG 对齐填充的显示/输入坐标。浏览器确认填充裁切与放大布局。该轮没有发送真机输入或测量动态性能,详见[开发验证](harmony-realtime-development.md)。 - -## 构建与发布 - -要求 macOS、Xcode Command Line Tools、Rust/Cargo、Node.js、pnpm、make、pkg-config。固定源码归档与哈希位于 `vendor/harmony-connector/`。 - -```sh -pnpm install --frozen-lockfile -export TROVE_BIN=/absolute/path/to/app/target/debug/trove -pnpm run harmony:test -pnpm run harmony:pack -pnpm run release:prepare --entries dist/harmony-release.json -``` - -发布创建新的不可变版本产物,保留旧版本、哈希和生产源的已有条目。推送后必须以 Git 仓库为源执行安装验证。生产 README 保持产品介绍;技术约束与验收记录在本文和 `docs/research/`。 - -代码职责:`native/src/file_transfer.rs` 管理目录计划与落盘,`progress.rs` 统计进度,`connector.rs` 管理 MTP 会话与任务,`mtp.rs` / `mtp_bridge.c` 封装固定依赖,`hdc.rs` / `hdc_data.rs` / `hdc/files.rs` 管理官方工具与输出校验,`hdc/screen.rs` / `screen_input.rs` 管理屏幕会话与输入,`command.rs` 控制进程、输出、取消和时限。UI 的文件与管理工作区分开实现。 - -验证包括文件夹往返与失败清理、会话状态、工具命令和解析、安装确认及后置条件、页面交互、USB 桥接和底座协议。HDC 测试使用明确的工具替身,不代表商业手机已经完成安装、备份或截图验收。实际文件吞吐量和 USB 往返 SHA-256 仍需真机记录。 - -参考:[设备管理能力](research/harmony-device-management.md)、[会话恢复](research/harmony-mtp-session.md)、[投屏与控制](research/harmony-screen-control.md)、[第三方声明](../curios/harmony-connector/legal/THIRD-PARTY.md)。 diff --git a/docs/harmony-realtime-development.md b/docs/harmony-realtime-development.md deleted file mode 100644 index 6e613a0..0000000 --- a/docs/harmony-realtime-development.md +++ /dev/null @@ -1,56 +0,0 @@ -# Harmony Connector 投屏开发记录 - -2026-09-10:用户反馈 0.5.1「很流畅」,但打开耗时较长。0.5.2 将窗口创建提前到设备探测之前,后台准备支持进度显示和取消;只在首张画面成功解码显示后保存设备的可用视频代理,下次优先使用,失败时保留自动回退。启动期间暂停管理页的设备轮询,避免争用。首次匹配耗时未承诺降低到固定数值;本轮按用户要求仅构建打包,未跑测试、真机计时或远程安装验证。 - - -2026-09-10:0.5.1 针对用户报告的 `无法读写 Mac 文件:Connection refused (os error 61)` 修改启动流程。代码可确认该提示来自视频 TCP 连接使用了通用文件 I/O 错误转换;此前没有确认设备代理/Unix socket 就绪,且只重试同一端口。现在增加官方代理候选、就绪检查、仅重建自身转发、启动日志和首帧状态。具体设备上的代理兼容性与连接恢复效果仍等待用户反馈;本轮只构建打包,未运行测试或真机验证。 - - -2026-09-10:0.5.0 改为独立 AppKit 窗口、官方 Unix H.264 视频流和默认实时触控。按用户要求,本轮只完成编码与构建,不运行测试、真机跑分或远程源安装验证。60 fps 是编码配置目标,不能作为已达到的性能结果。官方接口依据见[视频性能调研](research/harmony-screen-performance.md)。 - -以下为旧版本历史记录。 - -运行路径为:已授权的 HDC USB 连接 → 官方 Hypium 设备代理 → 持续 JPEG 推送 → 插件显示。用户无需开发者账号、AppGallery Connect、HAP 安装、编译或签名。代理由插件自动部署到本次会话的临时文件,停止时删除。 - -## 当前状态 - -2026-09-10:0.4.0 改为 HDC 持续 JPEG 推送,修复投屏残留会话误报占用及重连失败。0.3.2 的逐张截图链路为 696、803、802 ms,约 1.3 fps;新版本的动态帧率仍须独立测量。 - -官方 `xdevice-devicetest` 6.1.0.210 的 `uitest_agent_v1.2.2.so` 已在 HUAWEI Pura 70 Ultra 的商业系统上成功加载。独立探针收到真实 JPEG、屏幕尺寸 1260 × 2844,并成功调用尺寸、旋转和唤醒接口。设备端文件为 600245 字节,未改动签名原件。没有安装 HAP,也没有账号或系统共享确认步骤。 - -生产后端已在同一台真机上连续通过三轮“开始 → 接收并解码 → 停止 → 再次开始”,全程未拔 USB,每轮均确认 `pidof uitest` 为空、`fport ls` 无残留。每轮收到 2–3 张独立画面,也验证了静止画面返回 unchanged 的行为。独立探针另收到可查看的实际手机内容;这项连通/重连检查没有发送输入,不能作为动态帧率、跟手程度或端到端延迟达标的证据。 - -本次错误来自插件自身的残留 `trove-screen-*.so` 会话。手机的普通 `kill` 返回 `Permission denied`,旧代码忽略失败并把任意 UITest 进程视作其他工具。官方 xdevice 的清理使用 `kill -9`;当前手机实测接受该命令。修复会检查完整命令行身份,只清理本插件代理并确认进程退出;显式停止时清理失败会返回错误。其他工具的代理和未经本机记录的活动转发保持不动。主机按设备加文件锁,并记录自己的转发供异常退出后恢复。 - -手机正常画面实测为 640 × 1422,逻辑半尺寸为 630 × 1422,右侧含对齐填充。接收器兼容 16 像素边界的尾部填充,保留逻辑视口和 JPEG 存储尺寸;UI 直接裁去填充,不重新编码 JPEG,输入仍映射到 1260 × 2844。旋转或无法匹配的尺寸仍会关闭控制。 - -完整自动化检查通过 93 项:54 项原生、33 项 UI/格式/坐标、6 项 USB 桥接与底座协议检查。浏览器另外确认模拟画面的尾部彩色填充被裁去,放大时可见区域保持居中;这些界面测试不等同于真机性能测试。 - -之前的 HAP 实验源码和签名流程已移除。本机签名材料不属于插件或发行包。原方案的研究过程保留在[历史记录](research/harmony-realtime-stream.md),当前方案及来源见[HDC 持续投屏调研](research/harmony-hdc-only-screen.md)。 - -## 构建和验证 - -```sh -node curios/harmony-connector/build.mjs -node tools/test-harmony.mjs - -# 手机开启 USB 调试并授权 Mac;多设备时在末尾加目标 ID。 -curios/harmony-connector/backend/harmony-connector --check-screen /absolute/path/to/hdc - -# 连续 20 秒,通过与 UI 相同的后端接口接收并实际解码 JPEG。 -curios/harmony-connector/backend/harmony-connector --check-screen-stream /absolute/path/to/hdc -``` - -动态测量时保持手机解锁,并连续滑动桌面或滚动页面。`receivedFps` 与 `mbps` 是最近一秒收到的帧数和码率;`consumerFps` 是此验证程序在完整观察期解码的不同帧数除以时长,不是 WebView 呈现帧率。`frameAgeMs` 是收到画面后经过的时间,不是端到端延迟。静止画面不重复计帧。 - -上述命令默认不保存画面、不启用控制,也不发送输入。基础连通命令的第三个参数可以是用户指定的保存目录。真实交互仍需在插件中检验:启用控制、点击、拖动、返回/主页、文字输入、停止后重新开始,以及拔线后旧画面清空。 - -## 会话与协议 - -- 插件只保留最新一张 JPEG,不积压视频队列。UI 用上一帧序号等待变化;静止时返回状态,不重复发送图片。 -- 图像与控制使用两个独立 socket,共用本会话的 HDC 转发。按键和手势通过持久 RPC 发送,文字使用已验证的 `uitest uiInput text`。 -- 代理回包包括 JSON 行与 JPEG;解析 JPEG 段长度,避免把 EXIF 数据中的字节误认作图像结尾。单帧上限 2 MiB,JSON 上限 64 KiB。 -- 静止画面不视为断线;独立控制连接查询尺寸检查存活。UI 停止请求超过 15 秒会结束会话。屏幕几何变化会关闭输入控制。 -- 每次部署使用唯一文件名,以完整命令行重新确认进程身份后结束代理,并验证退出。设备锁与转发记录支持异常退出恢复;其他工具的代理和活动转发会阻止启动。停止时删除自己的临时文件和转发。 - -运行 HDC 不需要完整 SDK:本机独立验证了 `hdc` 与同目录 `libusb_shared.dylib` 的版本命令,总计约 6.4 MiB。完整工具包用于开发构建,不应成为普通投屏用户的依赖。本次尚未新增精简 HDC 发行包;插件继续使用已配置或自动发现的官方工具。 diff --git a/docs/research/harmony-connector.md b/docs/research/harmony-connector.md deleted file mode 100644 index 55c9bb3..0000000 --- a/docs/research/harmony-connector.md +++ /dev/null @@ -1,114 +0,0 @@ -# Harmony Connector 技术调研 - -调研日期:2026-09-10。目标是 macOS 与 HarmonyOS 6 设备之间浏览、上传和下载用户文件。本文区分官方说明、源码证据与实现推断;尚无本项目的 HarmonyOS 6 真机传输验收结果。 - -## 决定:首版使用 USB MTP - -采用 **libmtp 1.1.23 + libusb 1.0.30**,由 Curio 原生后端管理 USB 会话,界面提供设备选择、存储与文件夹浏览、双向文件传输及进度。依赖随插件发布,用户无需另装开发 SDK。首版聚焦 USB,不同时实现 HDC、ADB 或华为分享。 - -选择依据:华为为 HarmonyOS 6.0/6.1 提供“解锁设备 → 数据线连接 → 选择传输文件”的官方使用流程;其 Mac 支持说明也引导使用 MTP 工具。该消费者流程没有要求开启开发者选项。OpenHarmony 的设备端源码实现了标准 MTP 操作,libmtp 当前发布版已加入较新的华为设备标识。这些证据足以支持开发路线,**不等于特定商业机型已验证兼容**。[华为 HarmonyOS 6 USB 文件传输](https://consumer.huawei.com/cn/support/content/zh-cn16090699/)、[华为 Mac MTP 指引入口](https://consumer.huawei.com/cn/support/content/zh-cn16083899/)、[libmtp 1.1.23 发布](https://github.com/libmtp/libmtp/releases/tag/v1.1.23) - -## HarmonyOS 6 的用户流程与边界 - -1. 使用支持数据传输的 USB 线连接 Mac 和手机,解锁手机。 -2. 在手机 USB 弹窗或通知卡片选择“传输文件”,在 Harmony Connector 刷新设备并连接。 -3. 浏览设备公开的存储,选择文件上传或下载。手机端仍决定哪些内容可见、可写。 - -华为的 6.0/6.1 说明区分两种模式:“传输文件”可访问文件管理中的内容,图库在此模式下可导出,但不能向图库导入照片或视频;图库双向操作应切换手机的“传输图片”模式。首版不能把图库导入失败当作普通文档上传失败,也不能承诺 PTP 模式已被本插件验收。[华为 HarmonyOS 6 USB 文件传输](https://consumer.huawei.com/cn/support/content/zh-cn16090699/) - -MTP 公开的是存储和对象层次,不能据此访问所有应用沙箱或系统目录。实现应直接显示设备返回的对象与存储,而不是硬编码 `/sdcard`、`/data` 等路径。[libmtp 对象、存储与文件接口](https://github.com/libmtp/libmtp/blob/v1.1.23/src/libmtp.h.in) - -Mac 上如有其他 MTP 应用占用设备,应关闭占用者后重试。libusb 官方说明:未被内核驱动独占的设备通常可直接访问,无需 root;驱动独占是另一种限制,不能通过插件静默提权、卸载驱动来掩盖。[libusb macOS 访问说明](https://github.com/libusb/libusb/wiki/FAQ#how-can-i-run-libusb-applications-under-macos-if-there-is-already-a-kernel-extension-installed-for-the-device-and-claim-exclusive-access)、[libmtp 已知设备占用问题](https://github.com/libmtp/libmtp/blob/v1.1.23/README) - -## 系统与协议比较 - -| 设备/路线 | 第一方证据 | 对本插件的意义 | -| --- | --- | --- | -| HarmonyOS 3/4.x 的 USB 文件传输 | 华为文档明确 MTP,并介绍 Mac 侧文件传输工具。[来源](https://consumer.huawei.com/cn/support/content/zh-cn00407259/) | 后续可按 MTP 实测扩展;不以“鸿蒙”名称判断底层协议。 | -| HarmonyOS 5.0/5.1 | 华为 USB 文档指定手机至少 5.0.0.123;“传输文件”模式不访问图库,图库使用另一模式。[来源](https://consumer.huawei.com/cn/support/content/zh-cn16059258/) | 不能泛称所有早期 NEXT 系统均可用,也不能把 6.x 的图库行为回推至 5.x。 | -| HarmonyOS 6.0/6.1 | 官方描述 USB 文件/图片传输流程。[来源](https://consumer.huawei.com/cn/support/content/zh-cn16090699/) | 本次主要目标,按公开对象浏览与传输设计。 | -| ADB 与 HDC | 华为开发工具的平台说明分别将 ADB 对应 4.0 及以下、HDC 对应 5.0 及以上。[来源](https://developer.huawei.com/consumer/cn/doc/tools-guides/overview-0000001050741459) | ADB 不是 NEXT/5/6 的通用连接方案;消费者文件传输不应强制引入调试工具。 | -| HiSuite | 当前官网明确 HarmonyOS 5 及以上暂不支持在 macOS 上连接使用。[来源](https://consumer.huawei.com/cn/support/hisuite/) | 不作为插件底层,也不建议用户安装它以解决本插件连接。 | -| 华为分享 | 华为支持页列出手机/平板 6.0.0.112+ 与 Mac 10.15+,Mac 需运行“鸿蒙星河互联”,开启 WLAN/蓝牙。[来源](https://consumer.huawei.com/cn/support/content/zh-cn16093657/) | 已有官方无线方案,但本文未找到可嵌入的公开 macOS SDK/协议授权;不冒充官方分享客户端。 | - -华为支持页中称为“MTP Tool”的工具,不应未经确认就指向 App Store 某个同名产品。该支持页下游链接在调研时跳转到支持搜索;可确认的是华为推荐 Mac 使用 MTP 工具,无法从此确认某个二进制实现、签名或私有握手。[华为原始指引](https://consumer.huawei.com/cn/support/content/zh-cn16083899/) - -## 标准 MTP 与库选择的源码证据 - -OpenHarmony 第一方 `multimedia_media_library` 的 `media_mtp` 使用 `OpenSession`、`GetObjectHandles`、`GetObject`、`SendObjectInfo`、`SendObject` 等标准操作与对应操作码,存在会话建立、目录对象查询和双向传输的直接实现。所查实现未显示普通文件传输必须先进行 HiSuite 专有认证。**这是对所查开源代码的观察,不是商业 HarmonyOS 固件完全等同该源码的断言。**[操作定义](https://github.com/openharmony/multimedia_media_library/blob/4a411de74cfaf694c14820a51a0e900b04241fe6/services/media_mtp/include/mtp_constants.h)、[设备端操作处理](https://github.com/openharmony/multimedia_media_library/blob/4a411de74cfaf694c14820a51a0e900b04241fe6/services/media_mtp/src/mtp_operation.cpp) - -libmtp 1.1.23 发布说明包含 Huawei Nova14,设备表为 `VID 0x12d1 / PID 0x1101`,使用已有兼容标志。1.1.22 的设备表尚无该条目;因此首版选择 1.1.23。设备表条目证明上游已收录设备 ID,不能证明本插件在这台设备完成过读写。[1.1.23 发布说明](https://github.com/libmtp/libmtp/releases/tag/v1.1.23)、[1.1.23 设备表](https://github.com/libmtp/libmtp/blob/v1.1.23/src/music-players.h)、[1.1.22 设备表](https://github.com/libmtp/libmtp/blob/v1.1.22/src/music-players.h) - -libmtp 是基于 libusb 的 MTP 主机库,有现成 C 接口;libusb 明确支持 macOS 及 Apple Silicon,官方提供双架构构建说明。由项目固定版本编译 arm64/x86_64 产物,避免依赖用户电脑中的 Homebrew 路径。[libmtp 架构](https://github.com/libmtp/libmtp/blob/v1.1.23/README)、[libusb 平台支持](https://github.com/libusb/libusb/blob/v1.0.30/README)、[libusb Apple Silicon](https://github.com/libusb/libusb/wiki/FAQ#does-libusb-support-apple-silicon-based-mac) - -OpenMTP 可用作 macOS MTP 产品与集成的参考,但它当前 Kalam 内核使用 `go-mtpx`/`go-mtpfs`,不是直接复用 libmtp。仓库提供 Intel 和 ARM64 版本说明;其项目支持范围不能直接继承为 Harmony Connector 的支持范围。首版用 libmtp 以复用现有原生后端和 C ABI,避免另外加入 Go、Electron 及完整应用依赖。[OpenMTP 源码说明](https://github.com/ganeshrvel/openmtp)、[go-mtpx 模块依赖](https://github.com/ganeshrvel/go-mtpx/blob/18f12db021cc4256396748dbd2996d15853d7033/go.mod) - -## 原生后端实现契约 - -以下是基于库接口的实现建议,不是设备兼容性承诺: - -| 能力 | libmtp API / 规则 | -| --- | --- | -| 枚举与连接 | `LIBMTP_Detect_Raw_Devices`;用户选定后 `LIBMTP_Open_Raw_Device_Uncached`,避免连接即扫描全部文件。 | -| 存储与浏览 | `LIBMTP_Get_Storage`;`LIBMTP_Get_Files_And_Folders(device, storageId, parentId)`。根对象参数使用库的 `LIBMTP_FILES_AND_FOLDERS_ROOT`。 | -| 下载 | `LIBMTP_Get_File_To_File_Descriptor`,按 object ID 获取数据。 | -| 上传 | `LIBMTP_Send_File_From_File_Descriptor`,明确文件名、字节长度、目标 storage ID 和 parent ID。 | -| 进度/取消 | 库回调返回累计字节及总字节;返回非零值可请求取消当前传输。 | -| 失败与释放 | 每次操作读取并清理 error stack,断开时 `LIBMTP_Release_Device`。 | - -接口依据:[libmtp 1.1.23 公开头文件](https://github.com/libmtp/libmtp/blob/v1.1.23/src/libmtp.h.in)。 - -工程约束: - -- 设备会话和对象 ID 有生命周期;重连后重新枚举,不复用旧句柄。每个会话串行执行命令,传输时允许取消请求但不另开同设备读写。 -- 界面通过结构化消息接收条目与进度,不解析 `mtp-*` 示例程序的输出。库本身也说明其命令行程序是示例。[libmtp 示例说明](https://github.com/libmtp/libmtp/blob/v1.1.23/README) -- 用本地文件描述符传输;下载先写独立临时文件,只有成功后才提交到用户选定目标,失败清理本次临时文件。默认拒绝同名覆盖或让用户明确选择。 -- 设备返回的名字仍是不可信输入,拒绝路径分隔符、`..`、NUL 等会越出本地目标目录的名字;Unicode 和空格按原名处理。 -- 取消是协作式的,不承诺设备卡死时立即停止;不要强杀执行 USB 调用的线程。阻塞请求交给有超时和退出边界的原生进程处理。[libusb 同步 I/O 限制](https://github.com/libusb/libusb/wiki/FAQ#what-are-the-differences-between-synchronous-and-asynchronous-device-io) -- 不自动删除手机现有文件,不清空存储,不更改 USB 系统设置,不绕过手机锁屏或授权。 - -## HDC 备选方案记录 - -HDC 可在 macOS 运行,通过 USB 或 TCP 调试连接,需设备启用调试并在信任提示中授权。官方获取入口是 DevEco Studio / Command Line Tools,独立运行需保留相邻的 `libusb_shared` 依赖。目标列表可显示未授权状态;TCP 在所查文档中被标注为测试用途。它适合开发者诊断,不作为本次消费者首版依赖。[HDC 官方文档](https://github.com/openharmony/docs/blob/master/zh-cn/application-dev/dfx/hdc.md) - -```text -hdc list targets -v -hdc -t shell -hdc -t file send -hdc -t file recv -hdc tconn : -``` - -这些命令来自 [HDC 第一方仓库使用说明](https://github.com/openharmony/developtools_hdc/blob/e93f663afa159d3e601cad1e1869d70326731af6/README_zh.md)。可访问性仍由设备限制;不能把 `/data/local/tmp` 调试目录当成手机文件管理的下载目录。应用沙箱 `-b` 仅适用于可调试应用;媒体目录也有 API 版本与操作限制。[HDC 文件与沙箱访问](https://github.com/openharmony/docs/blob/master/zh-cn/application-dev/dfx/hdc.md#文件传输) - -若将来加入 HDC 浏览,应先探测设备命令能力,用受控脚本、严格参数转义和明确分隔的字段输出,避免按空格拆 `ls -l`。OpenHarmony 的 Toybox `stat` 提供格式化字段,但不同固件需实测。[Toybox stat 实现](https://github.com/openharmony/third_party_toybox/blob/master/toys/other/stat.c) - -HDC 开源仓库采用 Apache-2.0;这不自动授权重分发华为下载站的整个 SDK 或任意预编译工具。若采用该路线,优先使用用户已装官方工具,或对确切开源依赖自行构建并保存声明。[HDC LICENSE](https://github.com/openharmony/developtools_hdc/blob/e93f663afa159d3e601cad1e1869d70326731af6/LICENSE)、[华为 Command Line Tools 下载](https://developer.huawei.com/consumer/cn/download/command-line-tools-for-hmos) - -## 依赖与发布许可 - -libmtp 发布的 `COPYING` 为 LGPL 2.1,部分源文件头部保留 version 2 or later 表述;libusb 明确采用 LGPL 2.1 or later。应保留上游实际文本和版权,不自行统一改写原声明。[libmtp COPYING](https://github.com/libmtp/libmtp/blob/v1.1.23/COPYING)、[libmtp 头文件声明](https://github.com/libmtp/libmtp/blob/v1.1.23/src/libmtp.h.in)、[libusb COPYING](https://github.com/libusb/libusb/blob/v1.0.30/COPYING) - -对本次发布,选择可审查的具体做法: - -1. 动态链接插件内的库,在第三方声明中标出名称、版本、原项目和许可证,随包提供完整 `COPYING`。 -2. 与对应二进制一起提供完整上游源码归档、固定校验、实际补丁以及构建脚本。若归档独立发布,接收二进制的用户应能在同一分发位置以等价方式获取;只给上游主页不作为交付方案。 -3. 保留用户用兼容修改版库替换、重新构建和运行插件的路径,并说明 macOS 重新签名/本地安装步骤;插件条款不得禁止为调试此类修改而逆向。 -4. `.curio` 安装包与依赖源码都保持版本固定。上游源码或编译选项有变化时重新记录来源并发布新插件版本。 - -第 1—3 项直接对应 LGPL 2.1 第 4 节、第 6 节与第 6(b) 项;动态链接并不免除“已随包分发的库”自身的对应源码交付要求。[LGPL 2.1 随 libmtp 的完整许可](https://github.com/libmtp/libmtp/blob/v1.1.23/COPYING)、[LGPL 2.1 随 libusb 的完整许可](https://github.com/libusb/libusb/blob/v1.0.30/COPYING) - -## 验收与尚未证明的部分 - -本调研完成了官方流程、第一方设备端代码、库 API 与许可核查。没有将真实设备复制记录、吞吐量或机型兼容性伪装成已完成结果。当前开发主机尚未检测到华为 USB 设备(由主任务本机检查提供)。 - -正式标记某机型可用前,记录 Mac 型号/架构、macOS 版本、手机型号、HarmonyOS 完整版本及 USB 模式,并执行: - -- 未连接、锁屏、仅充电、文件传输、拒绝授权、设备被其他工具占用等状态。 -- 枚举多个设备与存储,选择后浏览,不访问未选中的设备。 -- 单文件上传和下载:空文件、中文/空格/emoji 名称、二进制和大文件,回传后比较 SHA-256。 -- 同名目标处理、磁盘空间不足、只读目录、权限拒绝、传输中拔线和取消,确认既有文件未被破坏。 -- 连接关闭、重连、插件退出、底座退出后 USB 会话正常释放。 -- HarmonyOS 6 图库导入限制有可理解反馈,不用文档目录成功代替图库验收。 - -通过库级测试、模拟传输、无设备枚举或包安装测试,只能证明对应层的行为,不能替代上述真实 USB 读写。 diff --git a/docs/research/harmony-device-management.md b/docs/research/harmony-device-management.md deleted file mode 100644 index 21f5000..0000000 --- a/docs/research/harmony-device-management.md +++ /dev/null @@ -1,268 +0,0 @@ -# Harmony Connector 手机管理能力调研 - -调研日期:2026-09-10。承接 [USB 文件传输调研](harmony-connector.md),评估 Harmony Connector 从文件工具扩展为手机助手。以调研时的最新官方文档设计能力,不以 HarmonyOS 6 为硬上限;HUAWEI Pura 70 Ultra 是待验收设备,不能凭系统名称或文档命令表宣称真机已支持。 - -本文只做资料核查和本机工具定位,没有连接手机、安装应用、读取手机数据或改变设备状态。华为商业 HarmonyOS 文档与 OpenHarmony 开源资料分别标注。后者能证明对应源码/命令契约,不能直接证明商业固件的编译选项、授权策略与运行行为。 - -## 结论与实现优先级 - -建议保留 MTP 用户文件通道,并增加可选的本机官方 HDC 通道。HDC 支持 Mac,适合应用管理、调试文件和诊断;华为要求先开启并授权 USB/无线调试。普通文件传输不应因为新增管理能力而强制进入开发者模式。[华为 HDC](https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/hdc)、[华为开发者选项](https://developer.huawei.com/consumer/cn/doc/HarmonyOS-Guides/ide-developer-mode)、[华为 USB 文件传输](https://consumer.huawei.com/cn/support/content/zh-cn16090699/) - -以下优先级是本项目的工程建议,表示可实现范围,尚非真机验收结果: - -| 优先级 | 功能 | 启用条件与产品表达 | -| --- | --- | --- | -| P0 | HDC 工具选择、版本和连接状态;设备基本信息 | 找到可运行工具,用户选择目标,授权成功;失败分清工具缺失、未授权、离线、命令不支持。 | -| P0 | 应用列表、应用详情、签名 HAP 安装、单应用卸载 | 包管理命令可用;安装预览包信息和覆盖影响;卸载显示具体包和数据损失。 | -| P0 | 日志快照、系统报告导出 | 用户触发,限时/限量,保存到用户选择的位置。 | -| P1 | 截图、应用启动、强制停止 | 先探测设备工具和真实组件;动作只针对用户选定对象。 | -| P1 | 调试应用数据导出;导出本机已有的安装包 | 沙箱导出逐应用检查调试签名与权限;安装包归档只处理用户拥有的原包,不等同从设备提取所有应用。 | -| P1 | 用户文件备份任务 | 明确选择范围、清单、校验和、失败项;仅称“所选文件备份”。 | -| P2 | 调试应用数据恢复、无线调试、实时日志/进程、UI 控制 | 探测成功后开放;恢复/控制/无线切换属于改变设备状态的动作。 | -| 暂不承诺 | 全机镜像、所有第三方应用数据备份、联系人/短信数据库直接读写、任意已安装应用导出并可恢复 | 未找到面向普通第三方 macOS 助手的官方公共接口;不能由 MTP 或 HDC shell 推导得到。 | - -## 工具获取、发现和授权 - -华为官方获取路径为 DevEco Studio 所带 SDK,或 Command Line Tools 的 SDK;macOS 下典型位置为 `DevEco-Studio.app/Contents/sdk/default/openharmony/toolchains/hdc`。独立运行还需保留相邻 `libusb_shared` 依赖。这说明可以使用已安装的官方工具,不能据此推断可以重新打包分发华为整个 SDK。[华为 HDC 环境准备](https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/hdc)、[官方工具下载入口](https://developer.huawei.com/consumer/cn/download/) - -实现建议:先读用户保存的绝对路径,再查应用自身继承的 `PATH`,最后检查 `/Applications`、`~/Applications` 的 DevEco Studio 应用包及用户选定的 Command Line Tools 目录。解析软链接,验证是文件且可执行,显示最终路径。不要全盘扫描、执行下载来的未知工具或自动修改 shell 配置;GUI 的 PATH 不一定与交互终端相同。 - -本次本机只读发现:`PATH` 中无 `hdc`;在 `/Applications`、`~/Applications` 和存在的 `~/Library/OpenHarmony`、`~/Library/Huawei`、`~/Library/DevEcoStudio`、`~/ohos-sdk` 范围未找到候选工具。这不是“整台电脑绝无 HDC”的结论。没有安装 SDK,也没有执行设备命令。 - -首次开启开发者选项与随后打开调试开关是不同步骤:华为手机/平板指引要求在设备名称页连续点击软件版本七次,确认并按需输入 PIN,首次开启可能触发重启。之后 USB/无线调试需在手机确认计算机授权。插件应引导用户操作设备,不绕过锁屏、信任弹窗、企业调试管控。[华为开发者选项](https://developer.huawei.com/consumer/cn/doc/HarmonyOS-Guides/ide-developer-mode) - -OpenHarmony HDC 仓库采用 Apache-2.0。若未来需要随插件交付 HDC,可评估从确切开源提交自行构建并保留许可证、版权与依赖声明;商业 Huawei 下载包的分发权需核对其实际许可,本次未确认该授权。不能把仓库许可扩展至 SDK 的其他组件或使用华为商标暗示官方产品。[OpenHarmony HDC LICENSE](https://github.com/openharmony/developtools_hdc/blob/e93f663afa159d3e601cad1e1869d70326731af6/LICENSE) - -## CLI 调用契约 - -以下表格中 `key` 是用户选定的 `connect-key`,`bundle`/`ability`/`module` 来自设备已返回且验证过的数据;`local` 是用户选择的绝对本地路径。数组是传给 `Command::new(hdc).args(...)` 的 argv,省略可执行文件本身。带尖括号的值只作占位,不能原样执行。 - -宿主不要使用 `sh -c` 拼接 HDC 命令。`hdc shell` 的最后一个参数仍会由设备 shell 解释,必须用固定模板与严格参数验证/单引号转义;宿主 argv 安全不等于远端 shell 安全。文件名、应用名和设备返回内容都不是可信 shell 代码。 - -### 只读探测与设备信息 - -| 功能 | argv | 证据与处理 | -| --- | --- | --- | -| 客户端版本 | `["-v"]` | 解析 `Ver:` 字段,保留未知版本原文。 | -| 客户端/服务端版本 | `["checkserver"]` | 记录两端版本,不能认为刚选择的二进制已经替换后台旧服务。 | -| 设备状态 | `["list", "targets", "-v"]` | 识别 `USB/TCP` 和 `Unknown/Ready/Connected/Offline/Unauthorized`;`[Empty]` 表示没有目标。 | -| HDC 帮助 | `["-h", "verbose"]` | 作为本机命令存在性的证据。 | -| 包管理能力 | `["-t", key, "shell", "bm help"]` | 成功识别帮助内容后再开放相关操作。 | -| 组件管理能力 | `["-t", key, "shell", "aa help"]` | 不通过实际启动/停止应用来探测。 | -| 截图工具能力 | `["-t", key, "shell", "uitest help"]` | 要求返回可识别 `screenCap` 帮助。 | -| 日志工具能力 | `["-t", key, "shell", "hilog -h"]` | 确认 `-x` 可用。 | -| 基本设备信息 | `["-t", key, "shell", "hidumper -c base"]` | 华为文档展示 BuildId、ReleaseType、OsVersion、DeviceType 等字段。 | -| 参数读取 | `["-t", key, "shell", "param get "]` | 单项读取,键需要固件实际返回/探测;未知键不应显示成空字符串的正常值。 | - -HDC 命令和状态依据 [华为 HDC](https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/hdc);设备信息依据 [华为 HiDumper](https://developer.huawei.com/consumer/cn/doc/doccenter-capabilities/hidumper);`param get` 语法依据 [OpenHarmony param 文档](https://github.com/openharmony/docs/blob/f41b9345badd47c7ab0c263344cd7f4b5a549afb/zh-cn/application-dev/tools/param-tool.md)。`hdc -t key shell hdcd -v` 是守护程序版本探测,不能当作手机完整系统版本。 - -实现应把“工具不存在”“没有授权”“权限不足”“输出无法识别”“版本不支持”与“数据为空”区分开。每个能力使用 `unknown / available / unavailable` 加原因;仅凭全局 HDC 连接成功不能一次性点亮所有管理功能。连接阶段不要执行 `kill -r`、`reconnect`、`tmode`、重启或任何包管理写操作。 - -### 应用列表、安装、卸载与运行 - -| 功能 | argv | 条件 | -| --- | --- | --- | -| 包名列表 | `["-t", key, "shell", "bm dump -a"]` | 华为官方 FAQ 直接采用该命令查询已安装应用。 | -| 包详情 | `["-t", key, "shell", "bm dump -n "]` | 华为 HiDumper 指引用详情中的 `appProvisionType` 判断 debug。 | -| 调试包列表 | `["-t", key, "shell", "bm dump -g"]` | OpenHarmony 最新文档支持;需以本机帮助/实际输出确认。 | -| 应用显示名 | `["-t", key, "shell", "bm dump -n -l"]` | OpenHarmony 文档标注 API 20 起;失败仍可显示包名。 | -| 安装签名 HAP | `["-t", key, "install", local]` | 本地绝对路径,成功后重新查询包版本。 | -| 单应用卸载 | `["-t", key, "uninstall", bundle]` | 指定包名;拒绝自动级联卸载依赖。 | -| 启动组件 | `["-t", key, "shell", "aa start -b -a -m "]` | 从详情找可启动、已导出的真实组件;不能硬编码所有应用都有 `EntryAbility`。 | -| 强制停止 | `["-t", key, "shell", "aa force-stop "]` | 与正常退出不同,可能丢失尚未保存的工作。 | - -查询依据:[华为应用市场错误排查](https://developer.huawei.com/consumer/en/doc/harmonyos-references/errorcode-appgallery)、[华为 HiDumper](https://developer.huawei.com/consumer/cn/doc/doccenter-capabilities/hidumper)。`bm` 详细参数与 user/root 限制依据 [OpenHarmony bm 文档](https://github.com/openharmony/docs/blob/f41b9345badd47c7ab0c263344cd7f4b5a549afb/zh-cn/application-dev/tools/bm-tool.md)。`aa` 的显式启动、exported 限制、强停和响应依据 [OpenHarmony aa 文档](https://github.com/openharmony/docs/blob/f41b9345badd47c7ab0c263344cd7f4b5a549afb/zh-cn/application-dev/tools/aa-tool.md);商业华为 HDC 将 aa 列为配套工具,但本次华为 aa 独立页面未成功抓取,故实际能力仍需设备探测。 - -安装不是“有 HAP 就能装”:华为要求真实设备使用签名包,自动签名会绑定调试设备信息。debug/release 签名、应用标识、版本和多模块一致性受安装器校验;不能把 OpenHarmony 的公开测试证书当作商业设备通用签名,也不能从 HDC 命令存在推导任意 release 包均可安装。[华为开发入门](https://developer.huawei.com/consumer/cn/develop-novice-guide/)、[华为自动签名](https://developer.huawei.com/consumer/cn/doc/HarmonyOS-Guides/ide-signing-auto)、[华为安装与更新一致性校验](https://developer.huawei.com/consumer/cn/doc/doccenter-getting-started/install-and-update-consistency-verification) - -华为 HDC 说明 `install` 默认就是覆盖安装,省略 `-r` 不能防止覆盖;安装路径支持 HAP/应用内 HSP,APP 包从 API 22 起。`-w` 等组合参数有特殊解析约定,初版建议先只用必要参数。用户操作之前展示即将安装的应用与已装版本;签名冲突或系统拒绝时保留错误码,不能自动先卸载再安装。卸载共享库可能因其他应用依赖而被拒绝。[华为 HDC 安装说明](https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/hdc)、[华为共享包依赖错误 9568312](https://developer.huawei.com/consumer/cn/doc/doccenter-dev-faq/faqs-technology-32) - -包名清单是行文本,详情可能有标签/前缀加 JSON 内容,不能将所有 `bm` 命令输出当作同一个稳定 JSON API。建议仅接受明确命令对应的格式:列表验证包名,详情解析完整 JSON 对象并校验预期字段与查询包名;无法解析则显示原始诊断并禁用依赖该字段的操作。显示名不能作为包名、权限或可卸载性的替代。安装和卸载需同时判断进程结果、工具成功/失败信息及操作后的目标状态,不能只看是否含 `AppMod finish`。 - -### 截图、日志和系统报告 - -1. 探测 `uitest help` 后,为每次截图生成不可预测且只含安全字符的文件名。 -2. 调用 `["-t", key, "shell", "uitest screenCap -p /data/local/tmp/curios-.png"]`。 -3. 调用 `["-t", key, "file", "recv", "/data/local/tmp/curios-.png", localTemp]`。 -4. 校验下载文件确实是可解码 PNG,再提交到用户目标位置。只删除本次创建的远端文件;失败时不要清理整个临时目录。 - -`uitest screenCap -p`、只能存到 `/data/local/tmp/` 的限制,以及 `-d` 多屏参数从 API 20 开始,均来自 [OpenHarmony UITest 官方文档](https://github.com/openharmony/docs/blob/f41b9345badd47c7ab0c263344cd7f4b5a549afb/zh-cn/application-dev/application-test/uitest-guidelines.md)。华为官方也提供 HiSmartPerf 的截图采集/文件导出流程,但本次没有将 `snapshot_display` 的商业固件可用性或任何投屏帧率作为已确认能力。[华为 HiSmartPerf](https://developer.huawei.com/consumer/cn/doc/doccenter-testing/smartperf-guidelines) - -| 导出 | argv | 实现建议 | -| --- | --- | --- | -| 日志快照 | `["-t", key, "shell", "hilog -x"]` | `-x` 非阻塞读完退出,宿主将 stdout 写到独立临时文件。 | -| 实时日志 | `["-t", key, "shell", "hilog"]` | 持续读取,提供停止按钮和大小限制;不要视为普通短请求。 | -| 系统报告 | `["-t", key, "bugreport", localTemp]` | FILE 是本地路径;内容可能较多,预留更长但有限的超时。 | -| 已有落盘日志 | `["-t", key, "file", "recv", "/data/log/hilog", localDir]` | 只有权限与路径确认后才能导出,不需先开启新的落盘任务。 | - -`hilog -x` 语义依据 [OpenHarmony HiLog](https://github.com/openharmony/docs/blob/f41b9345badd47c7ab0c263344cd7f4b5a549afb/zh-cn/application-dev/dfx/hilog.md);`bugreport` 与现有落盘日志路径依据 [华为 HDC](https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/hdc)。日志与系统报告可能包含应用活动和设备标识;默认只本地保存,不自动上传。不要为普通日志导出自动执行清日志、关闭隐私过滤或开启持续日志落盘。 - -## 备份和恢复:需要分别命名的三种能力 - -### 用户文件副本 - -MTP 面向设备公开的存储对象。选择文件夹后递归复制、生成大小/校验和清单、记录失败项,是可以实现的“所选文件备份”;没有通过 MTP 暴露的沙箱、系统设置、账号和数据库不在范围内。图库模式限制沿用原调研,不能把普通文件夹可写推导为可任意恢复图库数据库。[libmtp 对象/存储 API](https://github.com/libmtp/libmtp/blob/v1.1.23/src/libmtp.h.in)、[华为 USB 文件传输](https://consumer.huawei.com/cn/support/content/zh-cn16090699/) - -### 调试应用数据导出与导入 - -华为明确 `shell -b`、`file send/recv -b` 只用于“调试证书签名且已经在设备上启动”的应用;不是任意应用的沙箱读取入口。`-b` 从 API 15 引入。先查询 `appProvisionType`,再对具体目录进行只读访问探测,不应单凭包名出现在应用列表就启用备份。[华为 HDC 沙箱条件](https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/hdc)、[华为 HiDumper 调试类型判定](https://developer.huawei.com/consumer/cn/doc/doccenter-capabilities/hidumper) - -```text -查询指定调试应用目录: -["-t", key, "shell", "-b", bundle, "ls data/storage/el2/base/"] - -导出用户选择的沙箱文件或目录: -["-t", key, "file", "recv", "-b", bundle, "", ""] - -向同一调试应用导入已选择的数据: -["-t", key, "file", "send", "-b", bundle, "", ""] -``` - -这些是官方 `-b` 文件操作语法,remote path 应在该应用实际可见的根目录内,不使用 `/data/app/...` 猜测另一个应用的路径,不接受越界 `..`。导出清单应记录设备/系统、包名、版本、签名类型、所选路径、时间、实际复制文件及校验结果。正在运行的应用可能继续写入数据库;原始文件副本不自动获得事务一致性,也不保证跨设备的密钥、登录状态或应用版本可恢复。建议界面名称为“导出调试应用数据”,完整备份成功必须有明确的范围与验证。 - -恢复是覆盖写入,不应在枚举或探测时尝试。只允许用户选择明确的备份和目标应用,核对包名/版本/范围,展示将修改的文件后执行。未经该应用可恢复性验证,不提供一键“全部应用恢复”。不要通过重新签名现有商业应用或卸载重装来制造沙箱访问权限。 - -### 官方系统备份 - -华为应用备份框架允许应用接入 `BackupExtensionAbility` 并声明可备份范围,但普通应用自身不能触发系统备份;触发功能面向系统应用。官方最新验证指引使用数据克隆,由新旧设备配对触发。应用可不接入,也可限制备份内容。因此额外装一个普通手机伴随应用,并不自然获得其他应用全部数据的备份权。[华为备份概述](https://developer.huawei.com/consumer/en/doc/harmonyos-guides-V13/app-file-backup-overview-V13)、[华为应用接入](https://developer.huawei.com/consumer/cn/doc/doccenter-capabilities/app-file-backup-extension)、[华为数据克隆验证](https://developer.huawei.com/consumer/cn/doc/doccenter-capabilities/app-file-backup-dataclone) - -特别检查了 OpenHarmony `filemanagement_app_file_service` 提交 `589be5d84d3c23967be62ca7f0317690237a0041`: - -- `backup_tool/BUILD.gn` 仅在 `build_variant == "root"` 编入真实操作,且 `install_enable = false`。 -- 未定义 `BACKUP_TOOL_ENABLE` 时,`main()` 直接返回 0,不执行备份。 -- 备份服务检查 BACKUP 权限;HAP 调用还检查系统应用身份,shell 调用亦受 UID 条件限制。 - -这是开源实现证据,不能证明商业设备使用完全相同实现;却直接说明“找到 backup_tool 且退出码为 0”不是备份成功证据。本轮不将该工具作为商业设备备份后端,也不自动尝试备份/恢复命令。[OpenHarmony 构建约束](https://github.com/openharmony/filemanagement_app_file_service/blob/589be5d84d3c23967be62ca7f0317690237a0041/tools/backup_tool/BUILD.gn)、[无操作入口](https://github.com/openharmony/filemanagement_app_file_service/blob/589be5d84d3c23967be62ca7f0317690237a0041/tools/backup_tool/src/main.cpp)、[服务权限检查](https://github.com/openharmony/filemanagement_app_file_service/blob/589be5d84d3c23967be62ca7f0317690237a0041/services/backup_sa/src/module_ipc/service.cpp) - -## 与官方助手的能力对照 - -| 官方路线 | 已确认范围 | 对本插件的结论 | -| --- | --- | --- | -| HiSuite / 华为手机助手 | 当前官网针对 HarmonyOS 5+ 列出基础数据、部分第三方应用及数据、部分设置备份;受系统/应用兼容性限制。明确 5+ 暂不支持 macOS 连接。 | Windows 官方能力不等于 macOS 第三方插件可调用的协议/API。不能用旧 Android/HDB 教程保证原生鸿蒙兼容。 | -| 手机“备份和恢复 → 外部存储” | 官方 5+ 指引支持第三方应用和部分应用数据等,并需备份密码;范围以实际设备为准。 | 可以提供官方操作指引,或归档用户已经生成的备份文件;尚无公开证据支持插件在 Mac 直接触发/解密其备份。 | -| 数据克隆 | 最新开发者指引通过两台设备触发已接入应用的数据迁移验证。 | 是系统侧迁移流程,不是通用电脑备份 CLI。 | -| 华为分享 + 鸿蒙星河互联 | 官方提供与 Mac 的图片、视频、文档无线互传流程。 | 可链接官方方案;没有据此获得可嵌入分享 SDK、应用安装或全机备份能力。 | - -分别依据:[华为 HiSuite 当前支持范围](https://consumer.huawei.com/cn/support/hisuite/)、[华为外部存储备份](https://consumer.huawei.com/cn/support/content/zh-cn16024725/)、[华为数据克隆验证](https://developer.huawei.com/consumer/cn/doc/doccenter-capabilities/app-file-backup-dataclone)、[华为与 Mac 互传](https://consumer.huawei.com/cn/support/content/zh-cn16093657/)。产品可以覆盖“文件、应用、设备、诊断、备份”模块;不同模块应展示实际通道与数据范围,不把官方系统级功能画成已经可执行的按钮。 - -## 无线与其他能力 - -用户先在手机开启无线调试,输入手机显示的 IP 和端口,然后调用 `["tconn", ":"]`;断开用 `["tconn", ":", "-remove"]`。官方仍将 TCP 调试标为测试场景。`tmode port` 会重启设备守护进程并断开会话,连接探测不应使用它。端口转发、重启、UI 输入可以作为后续显式操作,不默认启用。[华为 HDC 无线和连接管理](https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/hdc) - -最新文档版本提示用于解释能力差异,不作为 HarmonyOS 大版本硬编码:`-b` 与 API 15/HDC 3.1.0e 配套;API 20/HDC 3.2.0b 增加部分网络能力;APP 安装 API 22;安装自动授权 `-g` API 24;沙箱无命令交互式 shell、reconnect、子服务器等 API 26 新能力仍需两端支持。实现按本机帮助和设备实际结果逐项判定,不自动调用新命令来“修复”连接。[华为 HDC 版本表](https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/hdc) - -## 输出解析、操作风险与验收 - -建议为 HDC 建立独立执行适配器:固定可执行路径、每次携带目标、有限并发、逐设备串行写入、进程组取消、stdout/stderr 大小上限、操作超时。长日志和文件任务走流式输出,UI 接收结构化事件,不在前端解释 shell 字符串。不要为了隔离无条件另开 USB server,也不要在退出插件时杀掉用户 DevEco Studio 的共享 HDC 服务。 - -最小验收应覆盖可观察行为: - -- 无工具、工具依赖缺失、未连接、拒绝授权、多设备、未知输出格式、版本不匹配,都不得显示假空列表或操作成功。 -- 应用列表中中文显示名与包名分离;详情解析失败不影响其他条目;安装拒绝/签名冲突/包格式错误保留真实结果;成功安装/卸载后刷新验证。 -- 签名 HAP 安装只能使用用户专门选取的测试包;卸载只能操作明确选择的应用。验证不得卸载用户日常应用来测试失败路径。 -- 截图下载后能解码,报告文件非空且包含实际内容,日志导出有限且可取消;中断不会把临时文件报告为完成。 -- 调试沙箱必须验证 release 应用被限制、debug 未启动的处理、权限失败、部分传输、中文/空格路径;导出实际文件校验和对应清单。恢复只在专门测试应用验证,并检查恢复后的应用行为。 -- 系统备份无法实现时明确展示边界,不执行空壳 `backup_tool`,不以退出码 0、模拟输出、无设备测试或构建通过替代真机验收。 - -本调研最终产物仅为本文。文档命令来自第一方资料,设备兼容性仍需按实际固件验证;本机未找到 HDC 的范围见上文,未对手机执行任何命令。 - -## 补充:应用输出解析与真实入口选择 - -这一节按 2026-09-10 获取的 OpenHarmony 源码追踪输出生产链:Bundle Framework `e0b9be9be36186e040385895f7f764e6e111a809` 与 bm CLI `6b9b9ad316b764f472194a962081c0fb99c32fdf`。下面是**由源码生成规则还原、省略无关字段的代表样例**,不是 HUAWEI Pura 70 Ultra 的实测输出。商业设备返回新结构时应报告格式未知,不能假装已经得到相同数据。 - -### `bm dump -a` / `bm dump -g` - -成功输出是按用户分组的纯文本,不是 JSON;当前 CLI 直接转发服务结果,没有额外 `all bundle names:` 标题。样例中的应用名是占位数据,缩进实际为制表符。服务支持多个用户段;CLI 默认查询当前用户,指定不活跃 `-u` 可能产生前置警告并退回当前用户。[BMS 列表生成](https://github.com/openharmony/bundlemanager_bundle_framework/blob/e0b9be9be36186e040385895f7f764e6e111a809/services/bundlemgr/src/bundle_mgr_host_impl.cpp#L2999)、[bm CLI 查询和转发](https://github.com/openharmony/bundlemanager_bundle_tool/blob/6b9b9ad316b764f472194a962081c0fb99c32fdf/frameworks/src/bundle_command.cpp#L1153) - -```text -ID: 100: - com.example.notes - com.example.reader -``` - -解析建议:保留 userId 分组;只在已识别 `ID: :` 段内读取并验证包名;允许空段和空行;未知诊断行不能被当作应用。不要把任意含点字符串提取成包名,也不要把错误文本归一化成零应用。空结果或查询失败时,当前 CLI 会输出下面的错误,且 dump 分支仍可能返回成功退出码。[bm 空结果处理](https://github.com/openharmony/bundlemanager_bundle_tool/blob/6b9b9ad316b764f472194a962081c0fb99c32fdf/frameworks/src/bundle_command.cpp#L1246)、[缺包查询官方系统测试](https://github.com/openharmony/bundlemanager_bundle_tool/blob/6b9b9ad316b764f472194a962081c0fb99c32fdf/test/systemtest/bm/bm_command_dump_system_test.cpp#L106) - -```text -error: failed to get information and the parameters may be wrong. -``` - -### `bm dump -n ` 的字段 - -服务先输出包名和冒号,再输出完整 JSON 对象。它显式移除顶层 `abilityInfos`,组件应从 `hapModuleInfos[].abilityInfos[]` 读取;不能把 manifest 的 `abilities` 字段直接当作 dump 格式。[BMS 详情组装](https://github.com/openharmony/bundlemanager_bundle_framework/blob/e0b9be9be36186e040385895f7f764e6e111a809/services/bundlemgr/src/bundle_mgr_host_impl.cpp#L3094)、[模块序列化](https://github.com/openharmony/bundlemanager_bundle_framework/blob/e0b9be9be36186e040385895f7f764e6e111a809/interfaces/inner_api/appexecfwk_base/src/hap_module_info.cpp#L927) - -```text -com.example.notes: -{ - "name": "com.example.notes", - "versionCode": 1000000, - "versionName": "1.0.0", - "entryModuleName": "entry", - "applicationInfo": { - "bundleName": "com.example.notes", - "appProvisionType": "debug" - }, - "hapModuleInfos": [ - { - "moduleName": "entry", - "mainAbility": "NotesAbility", - "mainElementName": "NotesAbility", - "isStageBasedModel": true, - "abilityInfos": [ - { - "bundleName": "com.example.notes", - "moduleName": "entry", - "name": "NotesAbility", - "type": 1, - "visible": true, - "enabled": true, - "isStageBasedModel": true, - "isLauncherAbility": false, - "skills": [ - { - "actions": ["ohos.want.action.home"], - "entities": ["entity.system.home"] - } - ] - } - ] - } - ] -} -``` - -准确映射与限制: - -| 需要的信息 | dump 字段与解释 | -| --- | --- | -| 应用身份/版本 | 顶层 `name`、`versionCode`、`versionName`;并校验 `applicationInfo.bundleName` 与查询目标一致。 | -| 调试签名 | `applicationInfo.appProvisionType`;调试值 `debug`,缺失是未知。 | -| 入口模块 | 顶层 `entryModuleName`;模块使用 `moduleName`,不是模块显示用的 `name`。 | -| 模块主组件 | `hapModuleInfos[].mainElementName`、`mainAbility`;当前 Stage 解析都来源于 manifest 的 `mainElement`。 | -| 实际组件 | `hapModuleInfos[].abilityInfos[]`,每项携带 `bundleName`、`moduleName`、`name`。 | -| 可见/导出状态 | dump 使用布尔 `visible`。Stage manifest 的 `exported` 写入内部 `visible`,且比旧 `visible` 配置优先;dump 不使用 `exported` 字段。 | -| UI 组件类型 | `type` 是数字枚举:`UNKNOWN=0`、`PAGE=1`、`SERVICE=2`、`DATA=3`、`FORM=4`、`EXTENSION=5`;Stage UIAbility 解析为 PAGE。 | -| 是否可用 | `enabled` 是布尔;显式 false 时不能作为启动候选。缺字段不应凭空生成 true。 | -| 桌面入口 skill | 同一项 `skills[]` 中,`actions` 含 `ohos.want.action.home` 或 `action.system.home`,且 `entities` 含 `entity.system.home`。 | -| `isLauncherAbility` | 不是普通应用“可从桌面启动”的通用标志。当前实现只在特殊 launcher entity、预装且系统应用等条件下设置。不能要求普通入口它必须为 true。 | - -这些映射依据:[BundleInfo 序列化](https://github.com/openharmony/bundlemanager_bundle_framework/blob/e0b9be9be36186e040385895f7f764e6e111a809/interfaces/inner_api/appexecfwk_base/src/bundle_info.cpp#L898)、[ApplicationInfo 序列化](https://github.com/openharmony/bundlemanager_bundle_framework/blob/e0b9be9be36186e040385895f7f764e6e111a809/interfaces/inner_api/appexecfwk_base/src/application_info.cpp#L1064)、[组件 JSON 字段](https://github.com/openharmony/bundlemanager_bundle_framework/blob/e0b9be9be36186e040385895f7f764e6e111a809/interfaces/inner_api/appexecfwk_base/include/common_json_converter.h#L669)、[组件类型枚举](https://github.com/openharmony/bundlemanager_bundle_framework/blob/e0b9be9be36186e040385895f7f764e6e111a809/interfaces/inner_api/appexecfwk_base/include/ability_info.h#L57)、[exported 解析](https://github.com/openharmony/bundlemanager_bundle_framework/blob/e0b9be9be36186e040385895f7f764e6e111a809/services/bundlemgr/src/module_profile.cpp#L636)、[Stage 类型赋值](https://github.com/openharmony/bundlemanager_bundle_framework/blob/e0b9be9be36186e040385895f7f764e6e111a809/services/bundlemgr/src/module_profile.cpp#L2702)、[模块入口赋值](https://github.com/openharmony/bundlemanager_bundle_framework/blob/e0b9be9be36186e040385895f7f764e6e111a809/services/bundlemgr/src/inner_bundle_info.cpp#L1800)、[HOME skill 与 launcher 判定](https://github.com/openharmony/bundlemanager_bundle_framework/blob/e0b9be9be36186e040385895f7f764e6e111a809/services/bundlemgr/src/module_profile.cpp#L3005)、[HOME 字符串常量](https://github.com/openharmony/bundlemanager_bundle_framework/blob/e0b9be9be36186e040385895f7f764e6e111a809/interfaces/inner_api/appexecfwk_base/include/bundle_constants.h#L174)。 - -实现建议:从合法详情中选择 `type == 1 && visible == true`,排除明确禁用项,并校验组件包名/模块名;优先具有 HOME skill 的组件,再用入口模块和主组件字段消除歧义。没有唯一可确定入口时展示实际组件选择,不自动启动任意 exported 页面。`visible`、`type` 缺失时不能直接宣称可启动;旧格式适配应增加明确来源与 fixture。权限仍由系统决定,元数据筛选通过不保证启动一定成功。以上代表样例对应 argv 为 `["-t", key, "shell", "aa start -b com.example.notes -a NotesAbility -m entry"]`。 - -解析 JSON 时应先识别查询包名的前缀,再对完整对象解析;校验身份和字段类型,限制输入大小/嵌套深度。不要从错误文本中随意截取第一个到最后一个花括号,或在详情中递归搜同名 `name` 来猜应用身份。没有完整有效详情时保留应用条目和错误,禁用依赖组件/调试状态的操作。 - -## 补充:`hdc file recv -b` 目录层级 - -OpenHarmony HDC `e93f663afa159d3e601cad1e1869d70326731af6` 的收件端根据**传输开始时本地目标是否存在**决定是否保留源目录 basename;`-b` 在设备端解析沙箱路径,本地主机仍走同一文件接收逻辑。以下以已确认可读的 `data/storage/el2/base` 目录中存在 `files/a.txt` 为例。[HDC 源目录任务生成及沙箱处理](https://github.com/openharmony/developtools_hdc/blob/e93f663afa159d3e601cad1e1869d70326731af6/src/common/file.cpp#L266)、[接收端目标目录和文件名处理](https://github.com/openharmony/developtools_hdc/blob/e93f663afa159d3e601cad1e1869d70326731af6/src/common/transfer.cpp#L724) - -| 调用前本地状态 | 接收目标 argv 最后一个参数 | 最终文件位置 | -| --- | --- | --- | -| `/exports/job` 已存在且为目录 | `/exports/job` | `/exports/job/base/files/a.txt` | -| `/exports/job` 不存在 | `/exports/job` | `/exports/job/files/a.txt` | -| `/exports/job` 已存在且为普通文件 | `/exports/job` | 目录模式报错,不能作为合法备份目标。 | - -源路径加尾部 `/` 不构成“只复制内容”的开关;源 basename 提取前会去除尾部分隔符。目标不存在时接收器先建目标,再从相对任务路径剥掉第一层;目标已经存在则保留第一层。不要用 rsync 的尾斜杠规则推断 HDC。[basename 实现](https://github.com/openharmony/developtools_hdc/blob/e93f663afa159d3e601cad1e1869d70326731af6/src/common/base.cpp#L1823)、[CheckFilename 首层处理](https://github.com/openharmony/developtools_hdc/blob/e93f663afa159d3e601cad1e1869d70326731af6/src/common/transfer.cpp#L801) - -建议先创建唯一、空的 staging 父目录,例如 `/exports/.curios-job-`,再传 `["-t", key, "file", "recv", "-b", bundle, "data/storage/el2/base", "/exports/.curios-job-"]`;验收期望位于 `staging/base/...` 的输出,记录 `remoteRoot` 与 `localRelativeRoot: "base"`。完成后按清单提交用户目标。恢复必须依清单映射,不能将整个 `base` 再送入设备已经存在的 `base`,制造 `base/base`。若想把目录内容直接放入目标,可使用尚不存在的目标路径,但实现必须控制初始状态,并在对应本机 HDC 版本做行为验收。 - -该源码递归任务以文件为单位,纯空源目录直接报 `Operation failed, because the source folder is empty.`;空子目录也不能按文件任务数量证明完整保留。不能把这类报错改写为完整备份成功。若产品承诺保留空目录,应另行枚举并记录空目录、处理权限失败,或将“不保留空目录”列入导出范围。源码不替代当前华为 SDK 二进制的行为测试。[目录遍历实现](https://github.com/openharmony/developtools_hdc/blob/e93f663afa159d3e601cad1e1869d70326731af6/src/common/transfer.cpp#L658)、[空目录官方测试](https://github.com/openharmony/developtools_hdc/blob/e93f663afa159d3e601cad1e1869d70326731af6/test/scripts/testModule/test_hdc_file.py#L502) diff --git a/docs/research/harmony-hdc-only-screen.md b/docs/research/harmony-hdc-only-screen.md deleted file mode 100644 index 79e0475..0000000 --- a/docs/research/harmony-hdc-only-screen.md +++ /dev/null @@ -1,156 +0,0 @@ -# Harmony Connector:仅通过 HDC 的连续投屏 - -调研日期:2026-09-10。产品约束:最终用户只启用 USB 调试并授权 Mac,不注册开发者账号、不安装 HAP、不修改手机安全策略。目标是可交互的连续画面,不限定 H.264;帧率和延迟必须在真机上测量。本篇研究未操作设备。 - -## 当前结论 - -找到了一条此前漏查的公开分发路线:**使用华为 Hypium 的公开 PyPI 依赖包中原样分发的 UITest agent,由设备内置 `uitest` 加载,通过 HDC 转发持续 JPEG 与触控 RPC。** 该路线无需用户编译、签名或安装手机应用;它仍会向设备临时推送一个 agent 文件,作用类似 scrcpy 的临时设备端服务。不能把自编 unsigned ELF 或 addon 被 XPM 拒绝,推广为所有官方签名 agent 均无法加载。 - -Hypium 的公开说明支持 USB/Wi-Fi 调试连接,并明确 Driver 模式可作为 SDK 集成到其他工具。其 `xdevice-devicetest` 包公开包含原生 agent;对应的 `xdevice-ohos` 包提供选择、推送、启动和转发实现。[Hypium 官方发布页](https://pypi.org/project/hypium/6.1.0.210/)、[设备测试资源包](https://pypi.org/project/xdevice-devicetest/6.1.0.210/)、[设备连接包](https://pypi.org/project/xdevice-ohos/6.1.0.210/) - -**主任务真机进展:**官方 `v1.2.2.so` 使用唯一 `--extension-name` 已成功加载,Unix socket 能返回 JPEG,`Driver.create`、屏幕尺寸和旋转查询成功;不需要安装 HAP、开发者账号或录屏确认弹窗。初次收到的是 630 × 1422 黑帧,首帧约 195–235 ms,15 秒仅收到 3 帧;当时可能处于锁屏状态,**这些数字只证明链路连通,不能作为动态画面流畅度或端到端延迟的结论**。本文作者未操作手机,真机结果来自主任务当轮报告。 - -## 精确来源与许可证据 - -本轮从 PyPI 元数据取得 wheel 下载地址,在内存读取并校验 SHA256,没有安装或执行其中代码。 - -| 资源 | 版本、路径及 SHA256 | -| --- | --- | -| xdevice-devicetest wheel | `6.1.0.210`;`1c80f0da8d497d4e034f9873e966e8b2d8ef23ecc376c64c839c1493a813980e` | -| 新版 ARM64 agent | `devicetest/res/prototype/native/uitest_agent_v1.2.2.so`;`e1b8e75fad983aa29640784ee4b457fe7ac1c916a15f72c8b899d5a7716da651` | -| 旧版 ARM64 agent | `devicetest/res/prototype/native/uitest_agent_v1.1.10.so`;`1c9286456fb003ee15d86fef04e8c93f004027349e6dd2ef972c792e0a6d4bf8` | -| xdevice-ohos wheel | `6.1.0.210`;`548a09bb6fc9fcfd92a7dbb5b69180de0159ce8959057e133dec34774054bb12` | - -资源包的 `METADATA` 明确列出 `License-File: LICENSE`,`xdevice_devicetest-6.1.0.210.dist-info/LICENSE` 是完整 Apache License 2.0;本次清单未发现额外 EULA、NOTICE 或对 agent 的另行许可文件。Apache 2.0 提供商业使用及 object form 再分发条件;打包时保留该许可证、来源版本和校验值,保留现有版权声明,修改自有封装代码应说明。不要以第三方仓库的许可证替代原始发布包的许可。[原始 wheel](https://files.pythonhosted.org/packages/a2/b5/55bdfe4d669558331499848dbc68269506abb9b4a771e3524a65f9845127/xdevice_devicetest-6.1.0.210-py3-none-any.whl)、[Apache 2.0 条款](https://www.apache.org/licenses/LICENSE-2.0) - -原始 `v1.2.2.so` 尾部包含 ELF 签名块标记,并含 Huawei CBG Software Signing Service CA 证书链字符串。这里只确认原件附带签名结构,**没有进行独立密码学验签,也没有据此声称目标设备必然接受**。部署必须保持字节不变,不 strip、不重链接、不截掉 ELF 尾部。该 wheel 没有提供 agent 的 C++ 源码,因此应称“公开分发的官方 agent”,不能称为设备端也能完全自行重建的开源实现。 - -## 真实选择与启动路径 - -当前手机 UITest 版本由主任务报告为 `6.0.2.3`。官方 `xdevice-ohos 6.1.0.210` 内: - -- `ohos/environment/device.py::check_if_unix_socket_mode`:UITest 版本高于 `6.0.2.1` 且设备不是 x86_64 时使用 Unix socket。 -- `ohos/proxy/proxy_base.py::_init_so_resource`:此分支选择 `uitest_agent_v1.2.2.so`;旧分支才使用 v1.1.10/v1.1.5/v1.1.3。 -- `ohos/proxy/ui_proxy.py`:默认推送到 `/data/local/tmp/agent.so`,运行 `/system/bin/uitest start-daemon singleness`,连接 `localabstract:uitest_socket`。旧协议为设备 TCP 8012。 - -因此不能把“设备 6.x”硬编码为旧 TCP 8012,也不能只依据文件名相似选择第三方 agent。[对应公开 wheel](https://files.pythonhosted.org/packages/78/a6/8ac621bb958813cde648d515069c30188939b492ffcdd29e8d1e234550cf/xdevice_ohos-6.1.0.210-py3-none-any.whl) - -以下最小命令形态已由主任务用本轮官方原件在当前手机验证;参数仍必须来自所选设备与本机会话: - -```text -hdc -t file send /data/local/tmp/ -hdc -t shell uitest start-daemon singleness --extension-name -hdc -t fport tcp: localabstract:uitest_socket -``` - -独有 `--extension-name` 来自 OpenHarmony UITest 的扩展加载器:它将名称解析到 `/data/local/tmp/` 并加载扩展。官方 Python 默认使用 `agent.so`,独有名已在本轮商业手机上验证,其他固件仍需兼容验证。不要覆盖其他工具已有的 `agent.so`;官方框架内部的通配清理、强杀其他进程不应照搬到插件。[扩展加载器](https://github.com/openharmony/testfwk_arkxtest/blob/b04e30c40cf266c9abfbd52933e43a0456eb99a2/uitest/addon/extension_executor.cpp) - -## 持续 JPEG 与控制协议 - -公开客户端已有实际代码实现,不依赖教程中的口头描述。本节以 `devhelmkit` 锁定提交 `05c9bc0f7f9ea763d61c8b4b5d1cfbb076626b00` 为协议参考,设备 agent 原件仍从官方 wheel 获取。该项目的 `device/hdc.py`、`rpc/proxy_v2.py`、`screenshot_stream.py` 分别实现传输、RPC 和连续画面。[传输实现](https://github.com/yabi-zzh/devhelmkit/blob/05c9bc0f7f9ea763d61c8b4b5d1cfbb076626b00/src/devhelmkit/harmony/device/hdc.py)、[RPC](https://github.com/yabi-zzh/devhelmkit/blob/05c9bc0f7f9ea763d61c8b4b5d1cfbb076626b00/src/devhelmkit/harmony/rpc/proxy_v2.py)、[连续帧](https://github.com/yabi-zzh/devhelmkit/blob/05c9bc0f7f9ea763d61c8b4b5d1cfbb076626b00/src/devhelmkit/harmony/screenshot_stream.py) - -客户端发送请求的 TCP 封包顺序: - -```text -ASCII "_uitestkit_rpc_message_head_" -u32 big-endian session ID -u32 big-endian UTF-8 JSON byte length -JSON body -ASCII "_uitestkit_rpc_message_tail_" -``` - -初始化和控制使用独立连接。调用 `Driver.create` 后使用返回的实际引用值,不能把 `Driver#0` 当成所有连接的固定保证。外层 `request_id` 为本次唯一字符串,transport session ID 是另一字段。 - -```json -{ - "module": "com.ohos.devicetest.hypiumApiHelper", - "method": "callHypiumApi", - "params": { - "api": "Driver.create", - "this": null, - "args": [], - "message_type": "hypium" - }, - "request_id": "" -} -``` - -后续只替换 `params` 中的 API、引用与参数,外层保持相同。以下是主任务确认的**解析后结果值**,不代表复制完整回包结构: - -| API | `this` / `args` | 当前手机结果 | -| --- | --- | --- | -| `Driver.create` | `null` / `[]` | `"Driver#0"` | -| `Driver.getDisplaySize` | 返回的 Driver 引用 / `[]` | `{"displayId":0,"x":1260,"y":2844}` | -| `Driver.getDisplayRotation` | 返回的 Driver 引用 / `[]` | `0` | -| `Driver.wakeUpDisplay` | 返回的 Driver 引用 / `[]` | `null` | -| `Driver.getDeviceInfo` | 返回的 Driver 引用 / `[]` | `No handler found`,当前手机不可用,不用于探活。 | - -物理输入坐标应依据屏幕尺寸与旋转转换,不能直接使用 JPEG 缩小后的像素坐标。`getDisplaySize` 可作为独立控制连接的低频探活;无新图不等于连接已断。 - -在独立采集 socket 发送: - -```json -{ - "module": "com.ohos.devicetest.hypiumApiHelper", - "method": "Captures", - "params": { - "api": "startCaptureScreen", - "args": { "options": { "displayId": 0, "scale": 0.5 } } - }, - "request_id": "" -} -``` - -注意 `Captures.args` 是对象,与 `callHypiumApi.args` 数组不同。JPEG 被推送到发送采集请求的 socket。**当前官方 v1.2.2 真机回包实测为 JSON 行 `{"result":true,"pts":...}\n` 与裸 JPEG `FF D8 … FF D9` 加换行,不使用上述 HEAD/TAIL 请求封包包住 JPEG。**不能只实现带 HEAD/TAIL 的接收器。参考客户端兼容协议封装与裸 JPEG;实现应依据实际格式使用有界长度及完整帧解析,不能假设一次 `recv` 对应一张图,也不能忽略 ACK/错误后无限等待。[采集请求与分帧](https://github.com/yabi-zzh/devhelmkit/blob/05c9bc0f7f9ea763d61c8b4b5d1cfbb076626b00/src/devhelmkit/harmony/screenshot_stream.py#L677) - -实时触控使用相同外层与 `method: "Gestures"`,内层为 `api: "touchDown" | "touchMove" | "touchUp"`、`args: {"x": ..., "y": ...}`。客户端实现按下、移动、抬起接口,适合替换拖动后才发送整段 swipe 的延迟;必须限制 move 排队,并保证取消、失焦时结束本会话已按下触点。键盘、文字可继续使用现有已验证的 UITest 输入路径。[触控实现](https://github.com/yabi-zzh/devhelmkit/blob/05c9bc0f7f9ea763d61c8b4b5d1cfbb076626b00/src/devhelmkit/harmony/driver.py#L687) - -未找到 `Gestures.keyDown`、`Gestures.keyUp` 或 `Gestures.text` 的公开调用依据。普通按键使用 `callHypiumApi`:`Driver.triggerKey` 的 `args` 为 `[keyCode]`;组合键 `Driver.triggerCombineKeys` 为 `[key1,key2]` 或三个键;Home/Back 分别是 `Driver.pressHome` / `Driver.pressBack`、`args:[]`。不要使用不存在的 `Driver.pressKey`。官方 `hypium/uidriver/arkuidriver.py` 声明 `triggerKey`、`triggerCombineKeys`、`pressBack`;其 Home 包装存在 `UiDriver.pressHome` 命名差异,因此仍应按当前设备能力验证客户端使用的 `Driver.pressHome`。[官方 Hypium wheel](https://files.pythonhosted.org/packages/b4/dc/f3b0abf2c518d11d57d8e9487026c133cc7eb0e3479af4b90d13c6db6169/hypium-6.1.0.210-py3-none-any.whl)、[客户端按键调用](https://github.com/yabi-zzh/devhelmkit/blob/05c9bc0f7f9ea763d61c8b4b5d1cfbb076626b00/src/devhelmkit/harmony/driver.py#L712) - -官方 Python 的 `Driver.inputText` 参数为 `[{"x":x,"y":y},"text"]`,有定位输入的语义;它并不是已确认的 `Gestures` 当前光标文字接口。公开客户端对当前焦点使用 `uitest uiInput text `。当前阶段保留已有验证的文字通道,可避免引入未经验证的光标、追加与清空语义。[当前焦点输入](https://github.com/yabi-zzh/devhelmkit/blob/05c9bc0f7f9ea763d61c8b4b5d1cfbb076626b00/src/devhelmkit/harmony/driver.py#L1487) - -停止时在采集连接发送 `method: "Captures"`、`params: {"api":"stopCaptureScreen","args":{}}`,随后关闭 socket、停止本机接收线程、移除本会话转发。若启用设备守护进程清理,仅结束自己创建并持有身份的进程,不使用 `killall uitest`。主机解码与渲染只保留最新完整帧;静止画面没有新帧不应误判断线,可用独立控制通道探活。[停止实现](https://github.com/yabi-zzh/devhelmkit/blob/05c9bc0f7f9ea763d61c8b4b5d1cfbb076626b00/src/devhelmkit/harmony/screenshot_stream.py#L565) - -`startCaptureScreen` 是 agent 提供的 RPC 名称,**不是**设备 `uitest help` 中能直接运行的 CLI 子命令。设备帮助没有它,不证明公开 agent 不能提供它。 - -## 画面变化、帧率与保活 - -公开 UITest `screen_copy.cpp` 的采集线程调用 `DisplayManager::GetScreenshot`,通过 `memcmp` 比较前后像素,只有变化才通知 JPEG 编码线程。屏幕关闭时提供黑色画面并暂停常规采集,50 ms 的 `usleep` 仅用于检查关闭的屏幕是否重新亮起,**不能据此推算正常采集被限制在 20 fps**。常规采集循环没有显式 fps 计时上限,编码线程取最新帧。商业 agent 未提供其 C++ 源码,此处解释公开 UITest 实现,不能当作官方 v1.2.2 每一项内部行为均相同的证明;本机静止画面不连续发帧与这套行为一致。[变化检测与屏幕关闭](https://github.com/openharmony/testfwk_arkxtest/blob/b04e30c40cf266c9abfbd52933e43a0456eb99a2/uitest/addon/screen_copy.cpp#L153)、[消费最新帧](https://github.com/openharmony/testfwk_arkxtest/blob/b04e30c40cf266c9abfbd52933e43a0456eb99a2/uitest/addon/screen_copy.cpp#L245) - -核查的公开扩展与客户端只暴露 `displayId`、`scale`,没有可据以实现的 fps 或 JPEG quality 参数。`scale` 在获取原屏截图后缩放像素,能降低输出尺寸与编码、传输成本,但不消除原屏截图成本。Devhelmkit 的 `MAX_RECORD_FPS=30` 只约束事后 MP4 合成,与实时采集上限无关。不要把虚构的 `fps:60` 参数加入产品后声称已提速。[缩放位置](https://github.com/openharmony/testfwk_arkxtest/blob/b04e30c40cf266c9abfbd52933e43a0456eb99a2/uitest/addon/screen_copy.cpp#L221)、[实际采集参数](https://github.com/yabi-zzh/devhelmkit/blob/05c9bc0f7f9ea763d61c8b4b5d1cfbb076626b00/src/devhelmkit/harmony/screenshot_stream.py#L670) - -参考客户端的采集 socket 连接后设为阻塞读取,没有周期发送采集心跳;尚无“必须在采集 socket 定期发送心跳”的源码证据。可保留本机读取超时用来检查取消状态,但超时本身不应标为失败;用独立控制连接的 `Driver.getDisplaySize` 区分静态画面与设备失联。若控制连接也无响应、TCP 关闭或 HDC 确认失联,再终止会话。[socket 建立与持续读取](https://github.com/yabi-zzh/devhelmkit/blob/05c9bc0f7f9ea763d61c8b4b5d1cfbb076626b00/src/devhelmkit/harmony/screenshot_stream.py#L651) - -## 两个开源客户端的来源核对 - -| 项目 | 本轮源码与文件核对 | 结论 | -| --- | --- | --- | -| `yabi-zzh/devhelmkit` | Apache 2.0;有连续 JPEG、独立控制 socket、down/move/up 和采集帧率统计;仓库 `agent_v2.so` SHA256 与本次官方 wheel 的 agent 不一致。 | 可参考其客户端协议,不把其未核清来源的 blob 作为发行原件。 | -| `yiyifred/Cloud-Phone` | Apache 2.0;包含 `uitest_agent_v1.1.10.so`,其 SHA256 **完全匹配**官方 wheel v1.1.10;`Gestures` 代码注释中的 ECHO/hdckit 指同类 RPC 操作,并不是设备原生内置 H.264 服务。 | 证明实际客户端采用 Hypium agent;其旧 agent/TCP 假设不能直接用于当前设备。 | - -[devhelmkit 许可](https://github.com/yabi-zzh/devhelmkit/blob/05c9bc0f7f9ea763d61c8b4b5d1cfbb076626b00/LICENSE)、[Cloud-Phone agent](https://github.com/yiyifred/Cloud-Phone/blob/98d130b20474cf8b18d51161d3bb0b65472c4778/backend/assets/harmony/uitest_agent_v1.1.10.so)、[Cloud-Phone RPC](https://github.com/yiyifred/Cloud-Phone/blob/98d130b20474cf8b18d51161d3bb0b65472c4778/backend/node/src/services/harmony-cast/uitest-rpc.js)、[Cloud-Phone 许可](https://github.com/yiyifred/Cloud-Phone/blob/98d130b20474cf8b18d51161d3bb0b65472c4778/LICENSE) - -## H.264 与其他路径的边界 - -同一官方资源 wheel 包含 `devicetest/res/recorder/libscrcpy_server1.z.so` 到 `libscrcpy_server4.z.so`。其 `controllers/tools/recorder/record_agent.py` 推送为 `/data/local/tmp/libscreen_recorder.z.so`,通过 UITest extension 启动,参数为 `-p -m 1 -screenId `,默认 gRPC 端口 5001;对应 `rpc_manager.py` 和生成的 protobuf 定义含 `ScrcpyService/onStart`、`onEnd`、`onRequestIDRFrame`。当前 Python 调用方主要用于步骤视频,停止后拉取 `mytest.mp4`。**仅凭资源名不能认定它是可直接使用的实时 H.264 投屏服务**;格式、stream payload 与当前固件兼容性仍需专门验证。它不是本轮 JPEG 路线的前置条件。[官方资源 wheel](https://pypi.org/project/xdevice-devicetest/6.1.0.210/) - -设备内置 UITest 公开 CLI 的 screenCap 是单张 PNG,uiRecord 是操作记录,start-daemon 是测试服务入口;不提供已确认的直接视频 stdout 命令。官方系统录屏组件的公开流程是录制后导出文件。对已经确认不存在的 `screenrecord`,不应反复尝试 Android 参数。[UITest 命令](https://github.com/openharmony/docs/blob/f41b9345badd47c7ab0c263344cd7f4b5a549afb/zh-cn/application-dev/application-test/uitest-guidelines.md)、[官方系统录屏](https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/ide-screen-recording) - -自编 ELF 的执行权限错误与自编 addon 的 XPM 加载拒绝已由主任务复现;它们不应作为继续尝试关闭 XPM、SELinux、root 或借用私人签名的理由。公开 Hypium 原件是另一条正常工具分发与加载链路,需按原件、版本、协议重新验证。 - -## 产品验收与明确未知 - -- 当前证据确认公开来源、许可文件、官方选择和加载方式,以及开源客户端连续帧/触控协议;主任务已验证当前手机原件加载、Unix socket、Driver 初始化、JPEG 解码与屏幕尺寸查询。 -- 初测黑帧只证明连通,仍需设备解锁后的真实内容与交互画面验证;不得把收到 JPEG 等同于已达到 scrcpy 的流畅体验。 -- 用手机实际动画/滑动测量收到与呈现的帧率,独立记录排队及输入往返;静止画面频率不能当作最大帧率。没有实际结果前不承诺 30/60 fps。 -- 验证拖动跟手、旋转后的物理坐标、拒绝/断开、停止后不再采集与控制,以及重复启动清理;不影响其他使用 UITest 的工具。 -- 未确认 `Captures/Gestures` 跨版本稳定性;应固定已验收的 agent,报告能力与错误,避免无提示降为旧截图模式。 - -最终用户流程可以保持“连接 USB → 允许调试 → 开始投屏”。官方 agent 的获取、校验、部署、转发和清理应由插件完成,不能再把开发者账号或 HAP 签名作为该流程的要求。 - - -## 2026-09-10:主任务重连验证补充 - -实际商业手机拒绝普通 `kill `,返回 `Permission denied`;同一身份的 `kill -9 ` 成功,进程随即消失。此前残留代理能响应 Driver RPC,却无法恢复采集,因此不将无条件复用残留代理作为恢复策略。官方 `xdevice-ohos 6.1.0.210` 的 `ohos/proxy/ui_proxy.py::stop_ui_rpc` 与 `proxy_base.py::_kill_uitest` 均使用 `kill -9`;默认销毁代理对象可不杀进程,显式重连则先停止。插件只借鉴结束自己代理的方式,绝不照搬按名称批量结束所有工具进程的逻辑。[官方连接包](https://pypi.org/project/xdevice-ohos/6.1.0.210/) - -生产后端现已连续通过三轮真机开始、解码 JPEG、停止和重新开始,每轮都验证代理和转发清理完成。正常画面 JPEG 宽度实测含尾部对齐填充(640 × 1422 对应逻辑半尺寸 630 × 1422),UI 应裁掉该部分,不能按 JPEG 宽度直接映射物理触点。该结果只证明本轮设备的连通与重连,不代表动态帧率、实时手势或其他固件兼容性全部验收。 diff --git a/docs/research/harmony-mtp-session.md b/docs/research/harmony-mtp-session.md deleted file mode 100644 index 8877f41..0000000 --- a/docs/research/harmony-mtp-session.md +++ /dev/null @@ -1,116 +0,0 @@ -# Harmony Connector:MTP 会话恢复与能力探测 - -调研日期:2026-09-10。本文补充 [初始技术调研](harmony-connector.md),只记录源码、公开问题和建议验证路线,不声称完成了真机修复。支持范围按实际 MTP 能力判定,不以 HarmonyOS 版本、机型字符串或 USB product 字符串限制。 - -本次任务提供的现象:macOS、libmtp 1.1.23、libusb 1.0.30;Pura 70 Ultra 的 USB 标识为 `12d1:1101`,USB product 为 `HDC Device`,MTP friendly name 为 `HUAWEI Pura70Ultra`,MTP model 为 `HBP-AL00`。0.1.1 在 `LIBMTP_Release_Device` 的 CloseSession 等待约 61 秒后超时;界面已异步处理断开,但用户报告需要拔插才能重连。上述为主任务提供的真机信息,本研究代理未操作 USB。 - -## 可用于实现决策的结论 - -1. **libmtp 现有路径已经会 reset,不能把“新增 reset”当成修复。** `12d1:1101` 表项使用 `DEVICE_FLAGS_ANDROID_BUGS`,其中同时包含 `LONG_TIMEOUT` 和 `FORCE_RESET_ON_CLOSE`。前者选择 60 秒 USB 超时,后者在释放接口后、关闭 USB handle 前 reset。约 61 秒现象与该等待路径吻合,但仍须日志确认实际失败阶段。[设备表](https://github.com/libmtp/libmtp/blob/v1.1.23/src/music-players.h#L3290)、[标志定义](https://github.com/libmtp/libmtp/blob/v1.1.23/src/device-flags.h#L320)、[USB 实现](https://github.com/libmtp/libmtp/blob/v1.1.23/src/libusb1-glue.c#L2087) -2. **OpenHarmony 开源代码存在能产生 CloseSession 无响应的明确控制流。** 它把协议成功码 `0x2001` 当成内部函数返回值,但组包调用方要求 `0`;因此在写响应前退出。该路径也未清除会话状态。它能解释同类症状,尚不能证明这台商业固件包含相同代码。完整链条见下节。 -3. **`LIBMTP_Reset_Device` 不是 USB reset。** 它仅在设备广告 `ResetDevice(0x1010)` 时发送 MTP 操作;上述 OpenHarmony 实现又把该操作路由到 CloseSession 的同一实现。不能用它替代 USB 层恢复。[libmtp 实现](https://github.com/libmtp/libmtp/blob/v1.1.23/src/libmtp.c#L3633)、[OpenHarmony 路由](https://github.com/openharmony/multimedia_media_library/blob/4a411de74cfaf694c14820a51a0e900b04241fe6/services/media_mtp/src/mtp_operation.cpp#L249) -4. **建议优先验证“短时限关闭 → 释放接口 → 一次 USB reset → 关闭 handle → 新枚举重连”。** 必须记录每阶段结果,不能静默保留会话并显示已断开。单纯更换 session ID、再加既有标志或一直重试,均没有充分依据。 - -## OpenHarmony CloseSession 的具体问题 - -此次 GitHub API 查询到的 `openharmony/multimedia_media_library` 当前提交为 `4a411de74cfaf694c14820a51a0e900b04241fe6`。以下结论固定到该提交,描述其开源实现,不将 OpenHarmony 与商业 HarmonyOS 固件等同。 - -### 响应没有写入的调用链 - -| 层次 | 所查行为 | 影响 | -| --- | --- | --- | -| 内部成功码 | `MTP_SUCCESS = 0`。 | C++ 内部处理成功应返回 0。[定义](https://github.com/openharmony/multimedia_media_library/blob/4a411de74cfaf694c14820a51a0e900b04241fe6/frameworks/utils/include/medialibrary_errno.h#L30) | -| 协议成功码 | `MTP_OK_CODE = 0x2001`。 | 这是 MTP 响应容器的 response code。[定义](https://github.com/openharmony/multimedia_media_library/blob/4a411de74cfaf694c14820a51a0e900b04241fe6/services/media_mtp/include/mtp_constants.h#L413) | -| CloseSession payload | `Parser` 在 session 已开时返回 `MTP_OK_CODE`;`Maker` 写入该数值后也返回 `MTP_OK_CODE`。 | 返回值与调用层的内部成功码约定不一致。[实现](https://github.com/openharmony/multimedia_media_library/blob/4a411de74cfaf694c14820a51a0e900b04241fe6/services/media_mtp/src/payload_data/close_session_data.cpp#L34) | -| MtpPacket | `ParserPayload` 传播返回值;`MakerPayload`、`Maker` 只有返回 `MTP_SUCCESS` 才继续。 | CloseSession 的组包结果为 `0x2001`,被当作失败返回。[实现](https://github.com/openharmony/multimedia_media_library/blob/4a411de74cfaf694c14820a51a0e900b04241fe6/services/media_mtp/src/mtp_packet.cpp#L166) | -| MtpOperation | 收包失败也尝试 `SendMakeResponsePacket`,但该方法在 `responsePacketPtr_->Maker(false) != MTP_SUCCESS` 时提前返回。 | 无论从正常还是错误响应路径进入,都会在 `Write` 之前结束。[实现](https://github.com/openharmony/multimedia_media_library/blob/4a411de74cfaf694c14820a51a0e900b04241fe6/services/media_mtp/src/mtp_operation.cpp#L129) | - -这是静态控制流分析:在所查代码和正常对象初始化前提下,可以追踪到“收到关闭请求,主机却收不到关闭响应”的路径;并非手机端日志或固件反编译结果。`CalculateSize` 也调用这个返回协议码的 `Maker`,不过无需依赖长度问题,调用方对返回值的检查已足以解释写入被跳过。 - -### 会话状态与重新打开 - -`DealRequest` 在 OpenSession 时检查 `sessionOpen`,未打开时保存参数中的 `tempSessionID`,已打开时返回 SessionAlreadyOpen;`GetOpenSession` 在成功响应时设置 `sessionOpen=true`。CloseSession 的 `Parser` 只检查已有会话,`GetCloseSession` 只创建 payload,没有将 `sessionOpen` 设为 false 或将 `sessionID` 清零;`ResetOperation` 只重置当前命令、transaction ID 和数据阶段。所查 `services/media_mtp` 源文件未找到关闭时清除这些会话字段的其他赋值。[打开处理与每次操作重置](https://github.com/openharmony/multimedia_media_library/blob/4a411de74cfaf694c14820a51a0e900b04241fe6/services/media_mtp/src/mtp_operation.cpp#L213)、[会话响应处理](https://github.com/openharmony/multimedia_media_library/blob/4a411de74cfaf694c14820a51a0e900b04241fe6/services/media_mtp/src/mtp_operation_utils.cpp#L668)、[初始状态](https://github.com/openharmony/multimedia_media_library/blob/4a411de74cfaf694c14820a51a0e900b04241fe6/services/media_mtp/include/mtp_operation_context.h#L49) - -因此,新的非零 session ID 不会自动绕过 `sessionOpen` 检查。设备端 `ResetDevice` 和 CloseSession 又使用同一个 payload 与响应处理,不能从 OperationsSupported 含 `0x1010` 推断它真的重置了会话。[payload 工厂](https://github.com/openharmony/multimedia_media_library/blob/4a411de74cfaf694c14820a51a0e900b04241fe6/services/media_mtp/src/packet_payload_factory.cpp#L65) - -## libmtp 的关闭、打开与身份语义 - -1.1.23 的 `LIBMTP_Release_Device` 返回类型是 `void`:它调用 `close_device`,然后清错误栈、释放 `PTP_USB`、`PTPParams`、存储和 device 对象。因而 Release 返回后,旧 `device`、`usbinfo` 和 USB handle 都不可继续使用;仅靠该 API 的返回值也拿不到关闭是否成功。[释放实现](https://github.com/libmtp/libmtp/blob/v1.1.23/src/libmtp.c#L2513) - -关闭顺序为 `ptp_closesession → close_usb`;后者默认执行 `clear_stall → libusb_release_interface → 按标志 libusb_reset_device → libusb_close`。reset 和 release 的返回值在此没有向调用者报告。连接路径先以 5 秒时限尝试 OpenSession,遇 `PTP_ERROR_IO` 已会 reset、关闭 USB、重新初始化并重试;接受 `OK` 和 `SessionAlreadyOpened`。若回到 retry,正常设备超时可能又是 60 秒。[1.1.23 USB 流程](https://github.com/libmtp/libmtp/blob/v1.1.23/src/libusb1-glue.c#L2304) - -截至本次调研的 libmtp 当前提交 `9de46078d15d290ac5c46039937b1164b918dbf0`,CloseSession 和 OpenSession 的上述恢复路径仍存在,未发现可通过直接升级主分支获得的对应修复。[当前代码](https://github.com/libmtp/libmtp/blob/9de46078d15d290ac5c46039937b1164b918dbf0/src/libusb1-glue.c) - -libmtp 使用 session 参数 `1`。`ptp_opensession` 自行将 OpenSession transaction ID 归零,并在事务后将本地 session ID 设为传入值。修改随机 ID 必须连同调用约定理解;它不会修复无响应或设备端 `sessionOpen` 不清除。代码中 InvalidTransactionID 分支先加 10,再调用会归零的 `ptp_opensession`,因此也不能把该分支视为可靠的 ID 恢复保证。[PTP 实现](https://github.com/libmtp/libmtp/blob/v1.1.23/src/ptp.c#L1364) - -`12d1:1101` 的上游记录来自 Nova14 的 ID 报告,只证明这个 USB ID 已入表;它不是对 Pura 70 Ultra 的型号鉴定。USB product `HDC Device` 也不否定同一复合设备上的 MTP 接口。应用应展示实际 MTP friendly/model 信息,使用选中 USB 实例及实际接口建立会话;不能将表中的 Nova14 名称硬编码为真实机型。[上游 issue #334](https://github.com/libmtp/libmtp/issues/334)、[设备表](https://github.com/libmtp/libmtp/blob/v1.1.23/src/music-players.h#L3290) - -libmtp README 本身记录了设备 CloseSession 实现不佳、首次打开成功而后续失败的历史问题;这是固件兼容问题的先例,不是当前设备根因的独立证明。[libmtp README](https://github.com/libmtp/libmtp/blob/v1.1.23/README) - -## macOS USB reset 的限制与调用位置 - -`libusb_reset_device` 的公开契约是 USB port reset,并尝试恢复先前 configuration/alternate settings;如果重枚举后的设备改变而返回 `LIBUSB_ERROR_NOT_FOUND`,调用者应关闭旧 handle 并重新发现设备。它不是“手机已经接受新的 MTP 会话”的保证。[libusb API](https://libusb.sourceforge.io/api-1.0/group__libusb__dev.html#gafee9c4638f1713ca5faa867948878111) - -libusb 1.0.30 Darwin 后端在普通、未 capture 的 macOS 路径调用 `USBDeviceReEnumerate`,等待重枚举,再恢复状态;源码说明 10.11 起旧 `ResetDevice` 路径不再有效。它有专门的重枚举等待和返回值,不能省略检查,也不能与另一线程的 close/open/reset 并发。[Darwin reset 实现](https://github.com/libusb/libusb/blob/v1.0.30/libusb/os/darwin_usb.c#L2139) - -2026-08 的上游 [issue #1923](https://github.com/libusb/libusb/issues/1923) 报告 Darwin 在接口仍 claimed 时 reset、再 claim 的旧接口泄漏,以及 restore 失败后 claim 位丢失。对应 [PR #1942](https://github.com/libusb/libusb/pull/1942) 在本次查询时仍未合并。报告包含 macOS 实验,但设备是 SEGGER J-Link、部分失败来自故障注入,不能当成本项目手机已命中该问题。它足以说明:**不能因为其他库先 reset 就无条件把本项目 reset 移到 release_interface 之前。** - -建议的调用位置: - -| 位置 | 判定 | -| --- | --- | -| 传输中、另一个线程仍执行 MTP 时 | 不使用;先串行收束当前事务,避免重置仍在使用的句柄。 | -| CloseSession 失败后、USB handle 尚有效 | 可以由拥有该 handle 的底层实现处理;优先释放接口后 reset,检查结果,再 close。 | -| `LIBMTP_Release_Device` 返回后,拿旧 `usbinfo` reset | 错误:对象和 handle 已释放。 | -| Release 完成后,新枚举并打开同一 USB 实例的全新 libusb handle,再 reset | 可作为受控恢复路线;这时不要先 OpenSession 再 reset。需要准确定位设备、检查返回值并重新枚举,不能仅按 VID/PID 匹配第一台。 | -| 正常关闭也无条件反复 reset | 不作为额外策略;现有 quirk 已可能 reset,重复执行增加重枚举和占用干扰。 | - -在本项目固定版本的 C 边界内,关闭超时和原始 reset 状态若无法经公开 API取得,可用小范围、可重现的依赖补丁返回状态;避免在 Rust/前端按内存偏移解包私有结构。若用新 handle 做恢复,不应复用旧 bus/device address 作为永久身份;重枚举可能使它失效。[libusb 枚举与 handle 说明](https://libusb.sourceforge.io/api-1.0/group__libusb__dev.html) - -## 其他 macOS MTP 工具能提供什么证据 - -| 实现 | 观察 | 可借鉴与边界 | -| --- | --- | --- | -| OpenMTP 的 go-mtpx/go-mtpfs | `go-mtpx` 固定到 `go-mtpfs` 提交 `1c3302b3c476`。`Close` 遇 CloseSession 错误会先 USB Reset,再 ReleaseInterface、Close;`Configure` 遇 SessionAlreadyOpened 先尝试 CloseSession/OpenSession,其余失败 reset、close、等约一秒、重新打开。 | 证明一种现实恢复路线存在,但不是这台设备或当前 libusb 1.0.30 的验证。不要直接继承“约一秒足够”的假设。[依赖](https://github.com/ganeshrvel/go-mtpx/blob/18f12db021cc4256396748dbd2996d15853d7033/go.mod)、[关闭和配置](https://github.com/ganeshrvel/go-mtpfs/blob/1c3302b3c476f69e787a0339122a804dd518a007/mtp/mtp.go#L94) | -| go-mtpfs session ID | 每次选择非零、非 `0xffffffff` 的随机 session ID。 | 是它自身的实现选择;没有证明随机化能解决 OpenHarmony 的状态未清除。[实现](https://github.com/ganeshrvel/go-mtpfs/blob/1c3302b3c476f69e787a0339122a804dd518a007/mtp/ops.go#L16) | -| Android File Transfer for Linux/macOS(whoozle) | Session Close 发 CloseSession 并等响应;通用 Device Open 有可选 reset,但其 Darwin backend 的 `Device::Reset()` 是空实现。 | 不能从工具支持 macOS 或方法名推断它进行了有效 USB reset。这是开源独立项目,非 Google 的同名 Android File Transfer。[项目](https://github.com/whoozle/android-file-transfer-linux)、[session](https://github.com/whoozle/android-file-transfer-linux/blob/88926930db41238c7b4d7237fc5849b9586cc7b8/mtp/ptp/Session.cpp#L105)、[Darwin 空实现](https://github.com/whoozle/android-file-transfer-linux/blob/88926930db41238c7b4d7237fc5849b9586cc7b8/mtp/backend/darwin/usb/Device.cpp#L38) | - -本次未取得 Google 闭源 Android File Transfer 的第一方 host 源码,因此不声称其关闭顺序或私有恢复方式。OpenMTP 的 Huawei P30 Pro [公开问题 #452](https://github.com/ganeshrvel/openmtp/issues/452) 报告的是目录/传输故障,也不能作为 HarmonyOS 当前设备的重连成功证明。 - -## 按实际设备能力开放功能 - -建立会话后读取设备 `OperationsSupported`、`DevicePropertiesSupported`,对象功能还要结合格式、属性描述、存储写权限和操作实际返回值。广告支持不等于本次操作成功,也不等于所有对象类型都可用。OpenHarmony 所查代码声明了 delete、move、copy、thumbnail、battery 和对象属性,但实际商业设备返回值才是应用开关依据。[OpenHarmony DeviceInfo 声明](https://github.com/openharmony/multimedia_media_library/blob/4a411de74cfaf694c14820a51a0e900b04241fe6/services/media_mtp/src/payload_data/get_device_info_data.cpp#L32) - -以下为 libmtp 1.1.23 的公开 API 映射;操作码由其 PTP 定义核对。[公开头文件](https://github.com/libmtp/libmtp/blob/v1.1.23/src/libmtp.h.in)、[PTP 操作定义](https://github.com/libmtp/libmtp/blob/v1.1.23/src/ptp.h) - -| 功能 | 公开 API | 探测与实现边界 | -| --- | --- | --- | -| 改名 | `LIBMTP_Set_File_Name`、`LIBMTP_Set_Folder_Name` | 按实际格式查询 `ObjectFileName(0xdc07)`;要求 `GetObjectPropDesc(0x9802)` 的 GetSet 为可写,并有可用的 `SetObjectPropValue(0x9804)` 或 SetObjectPropList。库的改名 helper 自己检查 GetSet。不要新用已弃用的 `LIBMTP_Set_Object_Filename`。 | -| 删除 | `LIBMTP_Delete_Object` | `DeleteObject(0x100b)`;仍处理 ObjectWriteProtected、AccessDenied 等对象级拒绝。目录递归由应用计划与执行,库文档不保证删除目录会删除全部子对象。 | -| 移动 | `LIBMTP_Check_Capability(...MoveObject)`、`LIBMTP_Move_Object` | `MoveObject(0x1019)`;目标根 parent 参数是 `0`。目录和跨存储行为须验证。标准命令无字节进度,不显示虚假百分比。 | -| 设备内复制 | `LIBMTP_Check_Capability(...CopyObject)`、`LIBMTP_Copy_Object` | `CopyObject(0x101a)`;无字节进度;API 仅返回成功/失败,不返回新对象 ID,完成后重新枚举目标目录。不支持时不可把移动偷偷变成复制后删除。 | -| 缩略图 | `LIBMTP_Get_Thumbnail` | `GetThumb(0x100a)`,并按对象是否有缩略图处理。失败显示普通图标;它不是整图预览保证。RepresentativeSample 是另一组属性/API。 | -| 电量 | `LIBMTP_Get_Batterylevel` | `BatteryLevel(0x5001)` 在设备属性列表中;API还检查 broken battery quirk。只有调用成功且最大值有效时换算,失败/不支持显示未知,不能显示 0%。 | -| 对象属性读取 | `LIBMTP_Is_Property_Supported`、`LIBMTP_Get_String_From_Object`、`LIBMTP_Get_u64/u32/u16/u8_From_Object` | `GetObjectPropsSupported(0x9801)` 按格式,`GetObjectPropDesc(0x9802)`、`GetObjectPropValue(0x9803)` 按具体属性。`Is_Property_Supported` 返回正数/0/负数分别为支持/不支持/查询失败,不能把 -1 当成 true。 | -| 对象属性写入 | `LIBMTP_Set_Object_String/u32/u16/u8`、`LIBMTP_Get_Allowed_Property_Values` | 存在不等于可写;遵循 GetSet、数据类型、枚举/范围。后一个 API 返回合法值,不直接暴露 GetSet;不能据它推断写权限。 | -| 目录下载 | `LIBMTP_Get_Files_And_Folders` + `LIBMTP_Get_File_To_File_Descriptor` | 主机遍历对象树,使用各 storage ID/parent ID;不是单一“递归传输”操作。按实际项汇总大小、进度和失败。 | -| 目录上传 | `LIBMTP_Create_Folder` + `LIBMTP_Send_File_From_File_Descriptor` | 以 association/folder 对象建立层级,再逐文件传输;基础操作为 SendObjectInfo/SendObject 或设备支持的 SendObjectPropList 路径。保留空目录、逐项处理重名和失败。 | - -公开 `LIBMTP_Check_Capability` 仅覆盖 GetPartialObject、SendPartialObject、EditObjects、MoveObject、CopyObject,不能拿它询问任意操作码。若前端需要全部 OperationsSupported,应在与固定 libmtp 一起编译的 C 桥接层提供只读投影,或给依赖增加受控 API,不从 UI 解析 `mtp-detect` 文本。[能力实现](https://github.com/libmtp/libmtp/blob/v1.1.23/src/libmtp.c#L4132) - -GetObjectPropList 也要遵循 libmtp 的已知兼容标志,不能为了“一次取全树”强制启用。对于当前 ID,上游已有 BROKEN_MTPGETOBJPROPLIST/BROKEN_SET_OBJECT_PROPLIST/BROKEN_SEND_OBJECT_PROPLIST;这些是库的兼容策略,不表示设备完全不能改名或传输。[兼容标志](https://github.com/libmtp/libmtp/blob/v1.1.23/src/device-flags.h#L320) - -递归删除宜先枚举用户选中的子树,再从叶到根执行。复制/移动目录时,不能仅凭标准操作支持就承诺任意层级、跨存储、同名覆盖或取消的原子性。libmtp 文档明确警告目录操作语义与耗时;应用级递归必须保留部分成功状态,不能自动删除原文件作为未说明的降级。[删除/移动/复制契约](https://github.com/libmtp/libmtp/blob/v1.1.23/src/libmtp.c#L6922) - -## 最小验证路线 - -以下是建议给主任务执行的实验,不是本文已经执行的验证。 - -1. 串行记录一次连接、只读目录、断开、重连全过程。区分 CloseSession 写请求、等响应、release_interface、USB reset、USB close 的耗时与返回码;确认旧后端进程/线程是否退出、旧 handle 是否完成释放。不要把设备名可枚举当成 MTP 会话已恢复。 -2. 将关闭独立于正常传输超时:CloseSession 超时后继续清理,避免反复调用它;不改变大文件传输的合理超时。先保留当前 release→reset 顺序,检查这次 reset 是否真的完成。若产品需要状态回传,在固定库边界内显式提供结果。 -3. 旧 handle 全部关闭后,按新枚举结果重连,实际完成 GetStorageIDs 与至少一次目录读取。只观察 UI 按钮恢复、扫描返回设备、reset 返回 0 均不足以通过。 -4. 只在基线有完整日志后比较候选方案:释放接口后 reset;必要时用全新 raw USB handle 做独立恢复。一次实验只变一处,避免短超时、随机 ID、不同 reset 顺序、额外库补丁同时变化而无法定位。 -5. 若 reset 返回 NOT_FOUND,按 API 重新发现设备;若 reset 无响应或设备不重新出现,显示恢复失败和真实状态,不无限循环。硬件重新插拔能恢复,不足以证明软件重枚举一定具备等价效果。 -6. 修复通过后,在同一 USB 插接周期连续完成多次 connect→browse→disconnect→connect,并包含插件关闭/退出再打开。写操作能力在专用测试目录验证:改名、删除、移动、复制、中文/emoji、空文件、空目录、嵌套传输,下载回传比对哈希;缩略图和电量失败应保留其余浏览能力。 - -本研究实际完成:读取项目固定依赖源码;在线查询并固定第一方仓库提交;核对 OpenHarmony 会话调用链、libmtp API、libusb Darwin reset、OpenMTP 与 whoozle 实现;查询相关公开 issue/PR 状态。未修改实现、未打开或重置 USB、未把开源静态分析标为商业设备实测根因。 diff --git a/docs/research/harmony-realtime-stream.md b/docs/research/harmony-realtime-stream.md deleted file mode 100644 index 6ebb676..0000000 --- a/docs/research/harmony-realtime-stream.md +++ /dev/null @@ -1,161 +0,0 @@ -# Harmony Connector 实时视频流调研 - -> 历史方案记录:本文的 H.264/HAP 实验已停止,实验源码和账号签名流程已移除。当前采用无需用户开发者账号的[官方 Hypium/HDC 路线](harmony-hdc-only-screen.md),状态及验证命令见[持续投屏开发验证](../harmony-realtime-development.md)。下文的早期“无安装”失败仅涉及自编未签名原件,不能推导为必须安装 HAP。 - -调研日期:2026-09-10。承接 [投屏与控制调研](harmony-screen-control.md),本篇只核查替换逐张截图链路所需的公开 API、进程授权和构建条件。文献研究与本机集成验证分别记录;尚未验证真机 H.264 视频性能。 - -## 结论与实现顺序 - -真正的视频流应为 **AVScreenCapture → VideoEncoder 输入 Surface → H.264 输出回调 → 持续 socket → 主机解码**。公开 C API 足以表达这个管线,包含系统录屏授权、帧率控制和编码输出;HDC 只负责连接和转发。`OH_ENCODED_STREAM` 虽然存在于枚举,但当前文档明确尚不支持,不能只修改枚举便取得 H.264。[采集数据类型](https://github.com/openharmony/docs/blob/f41b9345badd47c7ab0c263344cd7f4b5a549afb/zh-cn/application-dev/reference/apis-media-kit/capi-native-avscreen-capture-base-h.md)、[Surface 视频编码](https://github.com/openharmony/docs/blob/f41b9345badd47c7ab0c263344cd7f4b5a549afb/zh-cn/application-dev/media/avcodec/video-encoding.md) - -最小验证先尝试无音频、限时停止的普通 shell 原生程序。**没有查到“C API 必须有 UIAbilityContext”的限制;也没有官方文档保证商业手机允许 shell 进程完成录屏授权。** 这必须由实际返回码、系统弹窗和首个可解码帧判定,不能把“未验证”写成“必须安装 HAP”。公开接口不接受 UIAbilityContext;OpenHarmony 媒体服务会自行启动授权窗口。本次集成测试在程序执行阶段已遇到权限拒绝,详见下文,尚未到达 API 授权阶段。[公共 C API](https://github.com/openharmony/multimedia_player_framework/blob/91ee2a20fb294d0d05e8c48d0c0cc96af68f43e6/interfaces/kits/c/native_avscreen_capture.h)、[授权窗口调用](https://github.com/openharmony/multimedia_player_framework/blob/91ee2a20fb294d0d05e8c48d0c0cc96af68f43e6/services/services/screen_capture/server/screen_capture_server.cpp#L2174) - -UITest addon 的公开源码扩展仍仅提供连续 JPEG,没有 H.264 入口。可以验证其长连接 JPEG 是否明显改善当前每帧启动截图命令和下载文件的开销,但不得称为 H.264 视频通道。[扩展 ABI](https://github.com/openharmony/testfwk_arkxtest/blob/b04e30c40cf266c9abfbd52933e43a0456eb99a2/uitest/addon/extension_c_api.h)、[采集分派](https://github.com/openharmony/testfwk_arkxtest/blob/b04e30c40cf266c9abfbd52933e43a0456eb99a2/uitest/addon/extension_executor.cpp#L305) - -## 本地 SDK 核查 - -本机下载的官方 `commandline-tools-mac-arm64-26.0.0.821.zip` 包含以下公共文件,研究直接读取 ZIP 中的头文件核查声明,不依赖论坛示例: - -```text -command-line-tools/sdk/default/openharmony/native/sysroot/usr/include/ - multimedia/player_framework/native_avscreen_capture.h - multimedia/player_framework/native_avscreen_capture_base.h - multimedia/player_framework/native_avcodec_videoencoder.h - multimedia/player_framework/native_avcodec_base.h - multimedia/player_framework/native_avformat.h - multimedia/player_framework/native_avbuffer.h - multimedia/player_framework/native_avbuffer_info.h -``` - -头文件及 aarch64 stub 库存在,只证明可以编译和链接,不证明目标手机提供全部 API 或允许该进程采集。应优先使用满足管线的较早公开 API,通过实际返回值检查能力;不能仅因 SDK 是 26.0.0 就要求手机同版本。 - -| 能力 | 公共接口 | 起始 API | -| --- | --- | --- | -| 创建、初始化、停止、释放 | `OH_AVScreenCapture_Create/Init/StopScreenCapture/Release` | 10 | -| 状态、错误事件 | `OH_AVScreenCapture_SetStateCallback/SetErrorCallback` | 12 | -| 向编码器 Surface 采集 | `OH_AVScreenCapture_StartScreenCaptureWithSurface` | 12 | -| 限制采集帧率 | `OH_AVScreenCapture_SetMaxVideoFrameRate`,启动后调用,最高 60 | 14 | -| 编码器输出 buffer 回调 | `OH_VideoEncoder_RegisterCallback`、`OH_AVCodecCallback` | 11 | -| 编码器输入 Surface | `OH_VideoEncoder_GetSurface` | 9 | -| 编码 B 帧开关 | `OH_MD_KEY_VIDEO_ENCODER_ENABLE_B_FRAME`,默认关闭 | 20 | -| 手机、平板可选屏幕 Picker | `OH_AVScreenCapture_StrategyForPickerPopUp` | 23 | - -以上声明可对照 [采集 API](https://github.com/openharmony/docs/blob/f41b9345badd47c7ab0c263344cd7f4b5a549afb/zh-cn/application-dev/reference/apis-media-kit/capi-native-avscreen-capture-h.md)、[视频编码器 API](https://github.com/openharmony/docs/blob/f41b9345badd47c7ab0c263344cd7f4b5a549afb/zh-cn/application-dev/reference/apis-avcodec-kit/capi-native-avcodec-videoencoder-h.md)、[华为 Picker 条件](https://developer.huawei.com/consumer/cn/doc/doccenter-capabilities/using-avscreencapture-for-file-with-window)。普通录屏授权弹窗与 API 23 的屏幕/窗口选择器是两项能力;不能以设备没有后者判断无法授权录屏。 - -## 授权:先验证 shell,不绕过系统授权 - -本地 SDK 的 `SetStateCallback` 声明要求在启动前注册;说明录屏开始前由系统请求用户同意,实际启动成功回调 `OH_SCREEN_CAPTURE_STATE_STARTED`,拒绝回调 `OH_SCREEN_CAPTURE_STATE_CANCELED`。即便 `StartScreenCaptureWithSurface` 返回成功,也只能表示启动请求被接受,必须继续等待状态和编码数据。[状态回调 API](https://github.com/openharmony/docs/blob/f41b9345badd47c7ab0c263344cd7f4b5a549afb/zh-cn/application-dev/reference/apis-media-kit/capi-native-avscreen-capture-h.md#oh_avscreencapture_setstatecallback) - -OpenHarmony 当前源码调用链提供了可检验的依据: - -1. `ScreenCaptureImpl::StartScreenCaptureWithSurface` 在 `ORIGINAL_STREAM` 模式内部启用隐私授权,再进入服务端。无需调用内部的 `SetPrivacyAuthorityEnabled`,它不是本地公共 C 头文件中的接口。[客户端实现](https://github.com/openharmony/multimedia_player_framework/blob/91ee2a20fb294d0d05e8c48d0c0cc96af68f43e6/frameworks/native/screen_capture/screen_capture_impl.cpp#L397) -2. 服务端从 IPC 取得真实 UID、PID、token,并根据 UID 查询应用包名。普通 UID 进入用户授权逻辑;启用授权时请求系统窗口,未启用授权时才检查 `CAPTURE_SCREEN`。不是要求应用预先拥有免弹窗权限。[调用者与权限](https://github.com/openharmony/multimedia_player_framework/blob/91ee2a20fb294d0d05e8c48d0c0cc96af68f43e6/services/services/screen_capture/server/screen_capture_server.cpp#L1288) -3. 授权请求带 `sessionId`、`callerUid`、`appLabel`,由媒体服务连接系统扩展组件,调用传入的应用 token 为 `nullptr`。已核查的链路未见必须是 HAP token 的检查。[窗口参数](https://github.com/openharmony/multimedia_player_framework/blob/91ee2a20fb294d0d05e8c48d0c0cc96af68f43e6/services/services/screen_capture/server/screen_capture_server.cpp#L2058) - -因此 shell 程序在 API 形态上可尝试,**商业固件能否为 shell UID 建立授权弹窗、通知和实际采集仍未知**。最小验证应记录 create/init/configure/getSurface/start 的逐步返回、状态回调、编码输出数量,以及停止结果;设置有限等待与退出。遇到权限拒绝即停止,不使用 root、系统签名、权限白名单或调用系统内部免授权接口补救。 - -若 shell 路径失败,可把同一 C++ 管线放入普通 HAP 的 native 模块,从手机页面按钮发起。这样调用者具有正常包名和应用生命周期,符合官方示例的应用使用方式,但仍需真机验收,不能从源码推导所有商业设备必然成功。[华为取流实践](https://developer.huawei.com/consumer/cn/doc/doccenter-dev-faq/faqs-media-46) - -## 最小采集与编码契约 - -以下是本项目组装的流程,不是已运行的完整示例。每个返回值均需检查;回调中的停止和释放应派发到管理线程,避免同步销毁回调所属对象。 - -```text -encoder = OH_VideoEncoder_CreateByMime(OH_AVCODEC_MIMETYPE_VIDEO_AVC) -OH_VideoEncoder_RegisterCallback(encoder, callbacks, session) -format = OH_AVFormat_Create() -设置目标宽高、RGBA surface 输入、帧率、CBR、码率、GOP -OH_VideoEncoder_Configure(encoder, format) -OH_VideoEncoder_GetSurface(encoder, &window) -OH_VideoEncoder_Prepare(encoder) -OH_VideoEncoder_Start(encoder) - -capture = OH_AVScreenCapture_Create() -OH_AVScreenCapture_SetStateCallback(capture, onState, session) -OH_AVScreenCapture_SetErrorCallback(capture, onError, session) -config = 全部初始化为零 -config.captureMode = OH_CAPTURE_HOME_SCREEN -config.dataType = OH_ORIGINAL_STREAM -config.videoInfo.videoCapInfo.videoSource = OH_VIDEO_SOURCE_SURFACE_RGBA -config.videoInfo.videoCapInfo.videoFrameWidth/Height = 目标宽高 -OH_AVScreenCapture_SetMicrophoneEnabled(capture, false) -OH_AVScreenCapture_Init(capture, config) -OH_AVScreenCapture_StartScreenCaptureWithSurface(capture, window) -等待 onState(STARTED),再 OH_AVScreenCapture_SetMaxVideoFrameRate(capture, 30) -``` - -Surface 模式无需 `OnNeedInputBuffer` 填充像素,无需每帧 PNG 文件和截图下载。Surface 来自编码器并必须在 `Configure` 之后、`Prepare` 之前获取;窗口生命周期由应用管理。音频采集采样率及通道数同时为零表示忽略音频,首版明确关闭麦克风,不索取麦克风权限。[Surface 编码流程](https://github.com/openharmony/docs/blob/f41b9345badd47c7ab0c263344cd7f4b5a549afb/zh-cn/application-dev/media/avcodec/video-encoding.md)、[无音频条件](https://github.com/openharmony/docs/blob/f41b9345badd47c7ab0c263344cd7f4b5a549afb/zh-cn/application-dev/reference/apis-media-kit/capi-avscreencapture-oh-audiocaptureinfo.md) - -输出回调为 `onNewOutputBuffer(OH_AVCodec*, uint32_t index, OH_AVBuffer*, void*)`。通过 `OH_AVBuffer_GetBufferAttr/GetAddr/GetCapacity` 检查 offset、size、PTS、flags;复制合法范围的数据后尽快 `OH_VideoEncoder_FreeOutputBuffer(encoder,index)`。参数集、同步帧和普通帧按原始顺序传输,不能随便丢弃依赖帧;遇到积压丢弃到下一关键帧并请求 IDR。`OH_AVCodecBufferAttr.pts` 是微秒,不能与网络到达时间混为端到端时延。[编码数据读取](https://github.com/openharmony/docs/blob/f41b9345badd47c7ab0c263344cd7f4b5a549afb/zh-cn/application-dev/media/avcodec/video-encoding.md)、[buffer 属性](https://github.com/openharmony/docs/blob/f41b9345badd47c7ab0c263344cd7f4b5a549afb/zh-cn/application-dev/reference/apis-avcodec-kit/capi-core-oh-avcodecbufferattr.md) - -推荐链接公共库 `libnative_avscreen_capture.so`、`libnative_media_venc.so`、`libnative_media_codecbase.so`、`libnative_media_core.so`、`libnative_window.so`。由实际使用符号补充 native buffer 依赖;不复制设备私有库或 DevEco 私有 agent。[采集依赖](https://github.com/openharmony/docs/blob/f41b9345badd47c7ab0c263344cd7f4b5a549afb/zh-cn/application-dev/media/media/avscreencapture-c-basic-process.md)、[编码依赖](https://github.com/openharmony/docs/blob/f41b9345badd47c7ab0c263344cd7f4b5a549afb/zh-cn/application-dev/media/avcodec/video-encoding-configuration-typical-scenarios.md) - -停止顺序应先停止采集输入,通知编码器 EOS 并有界等待,释放采集和编码器、销毁 NativeWindow,再关闭 socket 和本会话转发;拒绝、错误、断线也走相同可重入清理路径。不同旋转尺寸先更新主机几何信息,暂停旧坐标输入,再重建需要变更尺寸的编码器。 - -## 低延迟配置与实际指标 - -官方把短距投屏列为低时延编码场景,推荐 CBR、根据目标分辨率/帧率设码率,并支持运行期通过 `OH_MD_KEY_REQUEST_I_FRAME` 请求 IDR。这里建议先从最长边 1280、30 fps、H.264、CBR 4 Mbps、1 秒 GOP 开始测量;这些数值是原型起点,不是官方对该手机的性能保证。[典型场景编码配置](https://github.com/openharmony/docs/blob/f41b9345badd47c7ab0c263344cd7f4b5a549afb/zh-cn/application-dev/media/avcodec/video-encoding-configuration-typical-scenarios.md) - -`OH_MD_KEY_VIDEO_ENABLE_LOW_LATENCY` 名字易产生误解:公共 SDK 将其定义为**视频解码**配置,本研究未确认它对编码器有效。编码 B 帧默认关闭,应保留此行为以避免显示顺序重排。API 26 新增的 CBRHQ 应先通过编码能力查询,不能直接强制较旧设备使用。[编解码参数声明](https://github.com/openharmony/docs/blob/f41b9345badd47c7ab0c263344cd7f4b5a549afb/zh-cn/application-dev/reference/apis-avcodec-kit/capi-native-avcodec-base-h.md) - -工程上应将采集、编码回调和 socket 发送分开:发送线程使用有界队列、超时与取消;不能在编码回调等待网络。主机也需要持续解码并只渲染最新可用画面,避免 30 fps 视频经慢速 JSON/base64 轮询再次积压。指标分别记录编码帧率、收到帧率、呈现帧率、码率、排队时长、首帧时间;没有时钟同步或可视运动实验时,不把采集耗时称为端到端延迟。 - -## UITest 扩展:可优化的 JPEG 路线 - -当前锁定的 OpenHarmony UITest 提交与上一轮一致:`b04e30c40cf266c9abfbd52933e43a0456eb99a2`。 - -- `startCapture("copyScreen", callback, options)` 读取 `scale`、`displayId`,内部将 screenshot PixelMap 转为 JPEG。`scale` 对外检查严格 `0 < scale < 1`。 -- `screen_copy.cpp` 使用独立截图和编码线程,以最近帧替换前帧,只在图像变化时回调。源码中的 50 ms sleep 用于熄屏轮询,**不是运行态固定 20 fps 上限**。 -- 回调参数是临时 `Text`,执行完立即 `free(data)`,异步发送必须先复制;不能缓存裸指针。 -- `LowLevelFunctions` 没有获取原始 PixelMap、编码器 Surface 或 H.264 的函数。额外直接调用 AVScreenCapture 属于独立的公共 API 路线,不是 UITest 已提供 H.264。 - -[JPEG 实现](https://github.com/openharmony/testfwk_arkxtest/blob/b04e30c40cf266c9abfbd52933e43a0456eb99a2/uitest/addon/screen_copy.cpp)、[回调生命周期](https://github.com/openharmony/testfwk_arkxtest/blob/b04e30c40cf266c9abfbd52933e43a0456eb99a2/uitest/addon/extension_executor.cpp#L305)、[ABI](https://github.com/openharmony/testfwk_arkxtest/blob/b04e30c40cf266c9abfbd52933e43a0456eb99a2/uitest/addon/extension_c_api.h) - -## 若需伴随 HAP:最小应用职责 - -ArkTS 仅负责正常应用入口、开始/停止共享、授权状态和后台任务;C++ 负责采集、编码及 loopback TCP。录屏授权不赋予任意输入注入权,控制继续复用已授权 HDC 的 UITest。若使用 socket,应用声明普通 `ohos.permission.INTERNET`;仅监听 loopback,并要求本会话随机 token。不要为了投屏申请系统的免授权采集或输入注入权限。 - -持续录屏退后台需要合法长时任务,否则应用可能挂起。官方任务类型表将 `AUDIO_RECORDING`/`audioRecording` 对应录音、录屏退后台,声明 `ohos.permission.KEEP_BACKGROUND_RUNNING` 并在共享期间保持可见通知,停止时结束任务;`taskKeeping` 在手机上有额外受限权限条件,不适合作为默认捷径。[长时任务规范](https://github.com/openharmony/docs/blob/f41b9345badd47c7ab0c263344cd7f4b5a549afb/zh-cn/application-dev/task-management/continuous-task.md)、[华为取流与后台说明](https://developer.huawei.com/consumer/cn/doc/doccenter-dev-faq/faqs-media-46) - -真机 HAP 安装必须签名。调试 profile 绑定包名、证书和允许调试的设备;DevEco Studio 自动签名或 AGC 手动签名均有官方流程。下载 Command Line Tools 和网页登录本身没有生成可安装的签名 HAP;不得用系统样例证书假装适用于商业手机。需要 HAP 时先完成可构建源代码,再配置有效调试签名并进行真机授权测试。[自动签名](https://developer.huawei.com/consumer/cn/doc/HarmonyOS-Guides/ide-signing-auto)、[手动签名](https://developer.huawei.com/consumer/cn/doc/doccenter-deveco-studio/ide-signing-manual) - -### 官方 CLI 自动签名路径 - -华为官网推荐 `@deveco/deveco-cli@stable`,1.3.0 版本新增自动调试签名;本次查询 npm stable 标签为 `1.3.0-stable`。可以在隔离的工具目录安装,使用 Node 22 及以上,配置 `DEVECO_CLI_CLT_PATH` 为完整解压的 Command Line Tools 根目录。只解压 HDC 与 native SDK 不构成该 CLI 所需完整工具链。[官网工具入口](https://developer.huawei.com/consumer/cn/deveco-studio)、[1.3.0 版本说明](https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/ide-deveco-cli-releasenote)、[官方 npm 包](https://www.npmjs.com/package/@deveco/deveco-cli) - -官方包 README 给出的流程如下;本轮已尝试签名,结果见集成记录: - -```text -devecocli auth status -devecocli auth login # 未登录时打开浏览器授权 -devecocli create --app-name HarmonyConnector --bundle-name <本项目包名> --project-path <工程目录> -# 在完成代码的工程目录: -devecocli signature generate --product default -devecocli build --modules entry --build-mode debug -``` - -`signature generate` 生成 p12、csr、p7b、cer 并写工程 `build-profile.json5`。默认使用用户自身团队;需要团队时指定已查询的 team-id,不虚构;已有签名无需加 `--force`。CLI 登录目前不支持海外账户。它的脚手架只提供 Empty Ability,native 模块仍需项目自行配置。[官方 CLI 文档](https://www.npmjs.com/package/@deveco/deveco-cli) - -## 本次集成验证的已知边界 - -同一任务的真机执行方报告:原链路连续 3 帧耗时约 696、803、802 ms,约为 1.3 fps;此为采集周期,不是端到端时延。使用公共 NDK 编译的 `aarch64-linux-ohos` 采集原型被传到本会话独有的 `/data/local/tmp/` 路径并设置执行权限,直接启动返回 `Permission denied`。HDC 自身退出状态为 0,不代表设备端命令成功。程序未执行任何采集 API,未显示授权弹窗,因此本次证据只定位到执行权限边界,不能证明 AVScreenCapture 被拒绝。 - -UITest addon 未进入 `OnInit`。随后读取设备日志确认:MUSL-LDSO 报 `Xpm check failed`、`errno for mmap is:13`,UiTestKit_Addon 的 `dlopen` 返回 `Permission denied`。这将本次失败定位到临时未签名模块的加载校验,不能解释为 ABI 不兼容;没有尝试关闭或绕过 XPM。临时程序、模块和手机上的本会话文件均已清理。 - -华为公开 LLDB 调试矩阵将 user 镜像、SELinux 开启场景的支持范围列为签名 debug HAP;并区分其与 root 镜像上的普通可执行文件调试。它与上述限制相符,但不能仅凭该文档判定具体拒绝来自 SELinux、代码签名还是其他策略。当时选择了普通应用签名实验;该路线随后已停止,当前改用官方分发且无需用户签名的 Hypium 代理。[华为 Native 调试条件](https://developer.huawei.com/consumer/cn/doc/doccenter-capabilities/debug-lldb) - -## 最小验收 - -1. 普通 shell 程序是否能创建采集和编码器、显示系统授权;分别验证同意、拒绝和超时。 -2. 取得 H.264 参数集和关键帧,并在 Mac 成功解码连续变化画面。 -3. 验证屏幕旋转、锁屏、隐私画面、断线和用户主动停止,保留系统遮挡行为。 -4. 持续运行时记录呈现帧率与排队增长;同时验证输入后画面及时变化。 -5. 会话结束不再采集、不再注入输入;清理本会话的临时程序/文件和端口转发。 - -公开源码和 SDK 只能支撑上述实现契约,不能替代 Pura 70 Ultra 的实际授权、解码与延迟验收。 - -## 伴随应用构建进展 - -集成方已创建普通手机应用 `ink.render.trove.harmonyconnector`,实现系统授权的 Surface 采集、H.264 编码、带会话校验的 loopback TCP 和停止收尾。官方 CLI 已成功构建 ArkTS 与 arm64-v8a native 模块并生成未签名 HAP。独立 Mac 接收器的 8 项测试通过,其中生成的 20 帧视频经过真实 H.264 解码全部返回。 - -最初签名预检被账号认证状态阻塞;用户认证并刷新官方授权后,曾成功生成正常调试签名、构建并安装 HAP,收到首个 H.264 帧。接收器在第一帧统计时发生除零错误,故没有取得持续帧率或端到端延迟结果。随后按用户要求停止 HAP 路线,实验源码已从主分支移除;历史实现保留于提交 `ca382e6`。当前无需账号的实现和验证状态见[开发验证说明](../harmony-realtime-development.md)。 diff --git a/docs/research/harmony-screen-control.md b/docs/research/harmony-screen-control.md deleted file mode 100644 index 5fe3f2a..0000000 --- a/docs/research/harmony-screen-control.md +++ /dev/null @@ -1,134 +0,0 @@ -# Harmony Connector 投屏与键鼠控制调研 - -调研日期:2026-09-10。范围是 macOS 上的 Harmony Connector,目标包括原生 HarmonyOS 和 OpenHarmony,按最新公开资料评估,不以某个系统大版本作硬上限。承接 [设备管理调研](harmony-device-management.md)。本轮仅阅读官方文档及项目源码,未安装工具、执行手机命令或改变设备状态;以下命令是实现契约/待验证方案,不是真机验收记录。 - -## 结论 - -**官方 scrcpy 不能直接作为原生鸿蒙投屏后端。** 它的设备端是 Android framework 上的 Java/Dex 程序,经 ADB 启动;把可执行文件名从 adb 换成 hdc,不能替代 Android 运行时、屏幕采集和输入 API。官方 scrcpy 当前 README 为 v4.1,设备要求 Android API 21+,macOS 是支持的电脑平台。只有设备确实提供兼容 Android 的运行时、ADB shell 及相应屏幕/输入能力时,才适合另外启用 scrcpy 通道;手机品牌、能运行某些 APK 或存在兼容容器都不是充分条件。[scrcpy 官方项目](https://github.com/Genymobile/scrcpy)、[服务端架构](https://github.com/Genymobile/scrcpy/blob/master/doc/develop.md) - -本项目可按以下顺序推进,优先级是工程建议: - -| 路线 | 画面与输入 | 本项目建议 | -| --- | --- | --- | -| HDC + UITest CLI | 单次截图;可轮询预览。点击、长按、滑动、指定文本、常用键。 | P0:复用现有 HDC 后端;显示“屏幕预览与操作”,不承诺视频级流畅度。 | -| 官方 DevEco Studio / DevEco Testing | 官方已提供设备投屏、点击/滑动/字符输入等。 | P0:提供官方工具入口及指引;未查到可嵌入第三方插件的公开投屏协议。 | -| 官方系统录屏 CLI | 录制后导出 MP4。 | P1:作为“录屏导出”独立功能,不把 MP4 文件导出当实时镜像流。 | -| 自建正常签名的鸿蒙伴随应用 + AVScreenCapture + HDC 转发 | 经手机用户同意采集,编码后持续传输;输入可另走 UITest。 | P2:真实视频流的优先独立原型,必须验证商业设备授权、后台持续采集和编码器。 | -| OpenHarmony UITest addon | 自建 native 扩展,JPEG 帧回调及原子输入 API。 | P2:开源实现可研究,商业固件可加载性/ABI 未验证。 | -| 鸿镜 / OpenHarmony_Scrcpy | 已有连续编码视频及控制实现。 | 面向可配置 OpenHarmony 系统的参考,当前系统权限/root 要求不适合作为普通华为手机的默认方案。 | -| 官方 scrcpy | Android 视频流及键鼠控制。 | 仅为已验证 Android/ADB 设备提供独立可选入口。 | - -## scrcpy 依赖与直接集成边界 - -scrcpy 客户端推送 server 到设备并启动,视频、音频、控制使用分离 socket。server 使用 Android `app_process` 加载 Dex,屏幕关联 Surface 经 `MediaCodec` 编码,输入通过 Android `InputManager.injectInputEvent()` 等方法处理;其中包含通过反射调用的隐藏 API。客户端解码使用 FFmpeg、窗口输入使用 SDL。项目明确客户端和 server 必须版本一致,其内部协议不保证跨版本兼容。[官方开发说明](https://github.com/Genymobile/scrcpy/blob/master/doc/develop.md) - -因此 `hdc fport` 只能替代传输中的一小部分,不能运行 Android server;要复用 scrcpy UI/解码需要替换连接、启动器、设备端采集/编码/输入,并协调协议,属于移植工程。不能声称下载官方 scrcpy 后便支持原生 HarmonyOS。对具有 Android 兼容能力的设备,按实际授权 ADB 目标识别,运行受控的原生 scrcpy 会话验收;不要根据 HarmonyOS 名称硬编码“可用/不可用”。[scrcpy 连接方式](https://github.com/Genymobile/scrcpy/blob/master/doc/connection.md)、[服务端约束](https://github.com/Genymobile/scrcpy/blob/master/doc/develop.md) - -若用户已经安装官方 scrcpy 和 adb,候选 Android 通道的只读预检可使用 `scrcpy --version`、`adb devices -l`、`adb -s shell getprop ro.build.version.sdk`。实际会话由用户点击后执行以下 argv;macOS 上优先启动独立窗口,减少嵌入原生渲染的成本。此处参数组合是本项目建议,不是鸿蒙兼容证明。[官方连接参数](https://github.com/Genymobile/scrcpy/blob/master/doc/connection.md)、[视频参数](https://github.com/Genymobile/scrcpy/blob/master/doc/video.md)、[控制参数](https://github.com/Genymobile/scrcpy/blob/master/doc/control.md) - -```text -scrcpy argv: ["--serial", serial, "--no-audio", "--max-size=1280", "--max-fps=30"] -仅观看时再添加: ["--no-control"] -``` - -scrcpy 的 `--otg` 通过 Android Open Accessory 模拟 USB HID,不依赖 ADB,但它**没有投屏**。不能将 OTG 模式宣传成绕开鸿蒙设备端视频支持的办法,也不能未验证便承诺手机实现相应 USB accessory 协议。[官方 OTG 文档](https://github.com/Genymobile/scrcpy/blob/master/doc/otg.md) - -macOS 官方文档提供预编译包和包管理方式。集成可优先使用用户已安装工具;本轮没有下载运行任何发布二进制。scrcpy 为 Apache-2.0,重新分发还需审核所选构建中的第三方依赖及其声明;这不授予 Huawei SDK 的重打包许可。[macOS 获取方式](https://github.com/Genymobile/scrcpy/blob/master/doc/macos.md)、[scrcpy LICENSE](https://github.com/Genymobile/scrcpy/blob/master/LICENSE) - -## 可立即实现:HDC 屏幕预览和操作 - -HDC 需设备开启开发者调试并信任电脑。以下固定使用用户选择的 `key`;宿主用 argv 调用,不执行本机 `sh -c`。设备端 shell 参数仍需严格转义,坐标和速度仅接受有界数字。[华为 HDC](https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/hdc) - -| 操作 | HDC argv(省略 hdc 本身) | -| --- | --- | -| 版本 | `["-t", key, "shell", "uitest --version"]` | -| 工具帮助 | `["-t", key, "shell", "uitest help"]` | -| 输入帮助 | `["-t", key, "shell", "uitest uiInput help"]` | -| 截图 | `["-t", key, "shell", "uitest screenCap -p /data/local/tmp/curios-screen-.png"]` | -| 下载截图 | `["-t", key, "file", "recv", remoteTemp, localTemp]` | -| 点击 | `["-t", key, "shell", "uitest uiInput click 160 240"]` | -| 长按 | `["-t", key, "shell", "uitest uiInput longClick 160 240"]` | -| 滑动 | `["-t", key, "shell", "uitest uiInput swipe 160 720 160 320 600"]` | -| 拖拽 | `["-t", key, "shell", "uitest uiInput drag 160 240 480 640 600"]` | -| 当前焦点文本 | `["-t", key, "shell", "uitest uiInput text "]` | -| 指定输入框文本 | `["-t", key, "shell", "uitest uiInput inputText 160 240 "]` | -| 返回/主页 | `["-t", key, "shell", "uitest uiInput keyEvent Back"]` / `Home` | - -精确 CLI 来源是 [OpenHarmony UITest 指引](https://github.com/openharmony/docs/blob/f41b9345badd47c7ab0c263344cd7f4b5a549afb/zh-cn/application-dev/application-test/uitest-guidelines.md)。截图 `-p` 只支持 `/data/local/tmp/`;`-d ` 从 API 20 起。`swipe`/`drag` 最后参数是速度 px/s,范围 200–40000,**不是毫秒时长**。`text` 从 API 18 起,只有当前焦点支持输入时才有实际效果。键码取 OpenHarmony `KeyCode`,不能照搬 Android keycode 或 macOS keycode;特殊 `Back/Home/Power` 不支持组合键。[UITest 操作定义](https://github.com/openharmony/testfwk_arkxtest/blob/b04e30c40cf266c9abfbd52933e43a0456eb99a2/README_zh.md)、[OpenHarmony KeyCode](https://github.com/openharmony/docs/blob/f41b9345badd47c7ab0c263344cd7f4b5a549afb/zh-cn/application-dev/reference/apis-input-kit/js-apis-keycode.md) - -工程实现建议: - -- 查看预览和启用控制分开;进入页面不自动注入按键。开始预览后串行截图、下载、解码,完成一帧再请求下一帧,避免积压。可先设置约 1 秒的刷新目标,实际显示采集时间,不宣传固定 FPS。 -- 按当前 PNG 真实尺寸和画面显示区域计算坐标,排除留白;旋转/折叠后刷新尺寸,暂停旧坐标输入。鼠标拖动收集起终点后发送一次 swipe/drag,不能伪装成实时多点触控。 -- 每个目标限制一个屏幕会话,输入队列有上限;用户停止、窗口隐藏、设备断开或授权失效时立即停止排队,避免恢复连接后补发旧输入。文本使用单独输入框和发送按钮,不全局捕获电脑键盘。 -- 对中文、空格、引号、emoji、新行和输入法组合做真实验证。不要为此同步读取剪贴板或关闭密码键盘。输出可解析/退出码正常只能表示命令执行结果,截图刷新或实际界面变化才是操作效果证据。 -- 保存截图使用临时文件并校验 PNG;默认帧只在本机短暂缓存。清理只删除本会话生成的远端/本地文件,不碰用户相册。锁屏和受保护画面保持系统行为,不自动解锁或关闭隐私保护。 - -`uiRecord record` 是操作记录,不是视频录屏;其记录可写到 CSV,并不产生 H.264 视频。不要把它接成“录制投屏”。[UITest uiRecord](https://github.com/openharmony/docs/blob/f41b9345badd47c7ab0c263344cd7f4b5a549afb/zh-cn/application-dev/application-test/uitest-guidelines.md) - -## 官方工具:确有投屏,但没有可确认的第三方嵌入协议 - -DevEco Studio 官网当前明确支持连接设备投屏、点击、滑动、字符输入及拖拽包/文件;26.0.0 Release 的官方说明将设备投屏列为新增能力。产品提供 macOS Intel/ARM 版本。它是真实设备投屏能力,不能与“鼠标控制模拟器”的教程混用。[DevEco Studio 官网](https://developer.huawei.com/consumer/cn/deveco-studio)、[26.0.0 官方版本概览](https://developer.huawei.com/consumer/cn/doc/doccenter-release-notes/overview-2600)、[仅针对模拟器的操控屏幕文档](https://developer.huawei.com/consumer/cn/doc/HarmonyOS-Guides/ide-emulator-control-screen) - -DevEco Testing 官方 FAQ 确认 Windows/Mac 客户端、设备投屏等测试工具;要求 HarmonyOS NEXT 设备开启开发者模式和 USB 调试,并能被 HDC 识别。官方工具介绍明确其投屏支持在 PC 上操作设备。最新 26.0.0 兼容表仍将“设备投屏”的模拟器列标为不支持;不要把云手机特定接入指引外推到所有本地模拟器。[DevEco Testing FAQ](https://devecotesting.huawei.com/userPortal/help?jobType=100000102)、[官方工具介绍](https://developer.huawei.com/consumer/cn/doc/harmonyos-guides-V14/devecotesting-V14)、[最新兼容表](https://developer.huawei.com/consumer/cn/doc/doccenter-release-notes/deveco-testing-releasenotes-2600) - -本轮没有从这些第一方资料确认可供第三方插件使用的投屏 SDK、稳定 socket 协议、可重新分发的 device agent 或指定设备投屏 deep link。因此可以引导用户打开已安装的官方应用并选择设备;不能提取其私有 agent 并当作插件依赖。`Device Manager` 只是工具内管理入口的名称,不能据二手教程编造其稳定 CLI。本轮未检视商业工具安装包,不判断其内部实现。 - -## 官方录屏 CLI:结束后导出视频 - -华为官方录屏文档要求设备解锁;录制中锁屏会退出,文档不支持模拟器录屏。其命令流程是启动系统录屏组件、停止、定位媒体、必要时复制到可下载目录、下载文件,未给出实时 stdout 视频流协议。[华为录屏文档](https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/ide-screen-recording) - -```text -开始: -["-t", key, "shell", "aa start -b com.huawei.hmos.screenrecorder -a com.huawei.hmos.screenrecorder.ServiceExtAbility --ps CustomizedFileName curios-record-.mp4"] - -停止(官方写法仍为 aa start,同组件但不传文件名): -["-t", key, "shell", "aa start -b com.huawei.hmos.screenrecorder -a com.huawei.hmos.screenrecorder.ServiceExtAbility"] - -定位录制结果: -["-t", key, "shell", "mediatool query curios-record-.mp4 -u"] - -查询含 uri 时,用查询返回且已验证的 URI 导出: -["-t", key, "shell", "mediatool recv /data/local/tmp"] - -下载工具实际返回的路径: -["-t", key, "file", "recv", returnedRecordFile, localTemp] -``` - -官方说明有 URI 时不能直接下载查询结果里的原始媒体路径;需 `mediatool recv` 得到可下载路径。实现不要只盲取“第几行”,应验证 URI、返回路径和唯一文件名。开始/停止是设备写操作,不能作为能力探测;录屏组件的停止写法也不能无限重试。用会话状态管理、手机通知和最终可解码文件验收,未知状态引导在手机停止。本功能会生成设备媒体文件,默认不能顺手删除相册原件。[华为录屏文件导出流程](https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/ide-screen-recording) - -## 真正视频流:自建伴随应用路线 - -AVScreenCapture 支持将采集结果作为码流交给其他模块,以实现桌面共享。最新 OpenHarmony 指引区分文件输出和码流输出,并说明码流录制开始会出现隐私选择弹窗;受保护内容可被屏蔽。华为最新窗口级录屏文档亦明确 Phone/Tablet 的 Picker 能力和权限条件。因此“普通应用一概不能录屏”不正确;需要区别用户授权录屏与系统免授权采集/输入注入。[OpenHarmony 录屏基础流程](https://github.com/openharmony/docs/blob/f41b9345badd47c7ab0c263344cd7f4b5a549afb/zh-cn/application-dev/media/media/avscreencapture-c-basic-process.md)、[华为窗口级录屏与授权](https://developer.huawei.com/consumer/cn/doc/doccenter-capabilities/using-avscreencapture-for-file-with-window) - -建议独立原型链路为:用户启动已签名 HAP并在手机允许共享 → `OH_AVScreenCapture_Create/Init` → 绑定编码器输入 Surface 后 `OH_AVScreenCapture_StartScreenCaptureWithSurface` → `OH_VideoEncoder` 输出 H.264 → 应用内 loopback TCP → HDC fport → macOS VideoToolbox/受控播放器。采集、编码和传输分别处理错误与背压;音频初版关闭。停止使用 `OH_AVScreenCapture_StopScreenCapture/Release`,并关闭编码器和本会话转发。详细 API 配置从配套 SDK 的官方示例实现,不复制系统免授权权限。[官方采集到编码器 Surface 示例](https://github.com/openharmony/docs/blob/f41b9345badd47c7ab0c263344cd7f4b5a549afb/zh-cn/application-dev/media/media/avscreencapture-c-custom-scenarios.md) - -传输示例为 `["-t", key, "fport", "tcp:", "tcp:"]`,停止后仅移除本次建立的转发 `["-t", key, "fport", "rm", "tcp:", "tcp:"]`;两个端口是自建协议配置,HDC 本身不会生成视频。使用 loopback、每会话随机 token、长度上限和协议版本;需要网络直连时另行设计用户配对与认证,不默认监听 `0.0.0.0`。手机端普通 HAP 不由录屏授权获得任意输入注入权限;键鼠仍可通过已授权 HDC 的 UITest 命令完成。此路线需新建手机工程、签名与真机验证,当前插件未具备。[华为 HDC 端口转发](https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/hdc) - -## OpenHarmony UITest addon:可研究的 native 扩展 - -检查官方 `testfwk_arkxtest` 提交 `b04e30c40cf266c9abfbd52933e43a0456eb99a2`:`uitest/addon/extension_c_api.h` 定义 `UiTestExtension_OnInit`、`UiTestExtension_OnRun`,通过 `UiTestPort.initLowLevelFunctions` 获取 `startCapture/stopCapture`、`atomicTouch`、`atomicMouseAction` 等函数。`startCapture` 名称 `copyScreen` 接收 JSON 选项 `scale`(严格 0 到 1 之间)、`displayId`;回调提供 JPEG 字节,当前开源实现反复 `GetScreenshot` 并编码变化帧,不是 H.264 编码流。[扩展 C ABI](https://github.com/openharmony/testfwk_arkxtest/blob/b04e30c40cf266c9abfbd52933e43a0456eb99a2/uitest/addon/extension_c_api.h)、[调用实现](https://github.com/openharmony/testfwk_arkxtest/blob/b04e30c40cf266c9abfbd52933e43a0456eb99a2/uitest/addon/extension_executor.cpp)、[JPEG 采集实现](https://github.com/openharmony/testfwk_arkxtest/blob/b04e30c40cf266c9abfbd52933e43a0456eb99a2/uitest/addon/screen_copy.cpp) - -源码可导出的启动契约是 `uitest start-daemon singleness --extension-name curios-screen-.so`,加载 `/data/local/tmp/` 下相应文件,默认名 `agent.so`。扩展须自行实现有界传输、停止以及主机认证。**这是源码入口,不是本次确认的商业公共 CLI。** 官方仓库 README 将 start-daemon 标为测试端注入用途;商业固件可能改变加载策略、系统库、ABI 或采集实现。先在自己可控制的 OpenHarmony 测试镜像验证,再专门评估真实商业设备,不应默认为任何手机加载 native 代码。[加载器](https://github.com/openharmony/testfwk_arkxtest/blob/b04e30c40cf266c9abfbd52933e43a0456eb99a2/uitest/addon/extension_executor.cpp#L354)、[命令分派](https://github.com/openharmony/testfwk_arkxtest/blob/b04e30c40cf266c9abfbd52933e43a0456eb99a2/uitest/server/server_main.cpp#L471)、[官方 README](https://github.com/openharmony/testfwk_arkxtest/blob/b04e30c40cf266c9abfbd52933e43a0456eb99a2/README_zh.md) - -不能从论坛提到的 `xdevice_scrcpy` 或 `DevEcoScrcpy` 日志推断商业工具私有协议已公开,也不能把上面的开源 JPEG 路径描述成与商业 H.264 路径完全相同。 - -## 已核查的开源成品 - -| 项目与锁定提交 | 真实机制、macOS、授权边界 | 结论 | -| --- | --- | --- | -| [鸿镜 ohos-scrcpy-app](https://github.com/guoxiucai/ohos-scrcpy-app/tree/1d470ea8f571f19fcbb6883cc6118fafed6fe14f) | Flutter macOS/Windows;H.264、VideoToolbox、鼠标/触摸/按键、录制,编码失败可降级 JPEG。设备端为系统 HAP,作者明确要求 Full SDK、目标系统信任签名和权限白名单。 | 可参考本机解码与协议设计;不能普通签名安装到商业手机便承诺工作。 | -| [OpenHarmony_Scrcpy](https://github.com/luodh0157/OpenHarmony_Scrcpy/tree/85890e693fb5b3e4b3ef50a16af688787ca85070) | 项目声明跨桌面平台,设备端明确要求 OpenHarmony 5+ root 版本;原生 AVScreenCapture + H.264/H.265。安装脚本 remount 并写 `/system/bin` 和 `/system/etc/init`,服务具媒体采集权限。 | 只适合受控开发镜像;不用于本项目普通手机自动安装。 | -| [scrcpy-ohos](https://github.com/l376571926/scrcpy-ohos/tree/5d938ac1d125406705c764d7061192d91c33b9d1) | JavaFX/JDK11;源码 `snapshot_display` 截图下载,操作后刷新,uinput 模拟事件;README 明确非实时。未找到根 LICENSE。 | 不是官方 scrcpy 的鸿蒙兼容层,也不提供连续视频流;可看思路,不能据公开代码便假定可复制分发。 | - -鸿镜设备权限包括 `CAPTURE_SCREEN`、`EXEMPT_CAPTURE_SCREEN_AUTHORIZE`、`INJECT_INPUT_EVENT`,其文档明确需 ACL、系统签名和指纹白名单。仅将 HAP 换成自己的普通调试证书不解决这些限制。[鸿镜签名要求](https://github.com/guoxiucai/ohos-scrcpy-app/blob/1d470ea8f571f19fcbb6883cc6118fafed6fe14f/README.md#签名与系统权限)、[设备权限声明](https://github.com/guoxiucai/ohos-scrcpy-app/blob/1d470ea8f571f19fcbb6883cc6118fafed6fe14f/scrcpy_server/entry/src/main/module.json5) - -鸿镜可复核的传输为 `hdc fport tcp: tcp:53535`,设备只监听 `127.0.0.1:53535`。它使用自定义包:大端 u32 类型 + 大端 u32 payload 长度;`0x02` 配置、`0x03` 视频帧、`0x10` 控制。H.264 帧 payload 是 flags u8、PTS u64、NAL 数据;控制下含触摸/键鼠子类型。它与官方 scrcpy wire protocol 不兼容,不能拿其 server 对接官方 scrcpy client。[鸿镜协议](https://github.com/guoxiucai/ohos-scrcpy-app/blob/1d470ea8f571f19fcbb6883cc6118fafed6fe14f/scrcpy_client_flutter/lib/net/protocol.dart)、[loopback 监听](https://github.com/guoxiucai/ohos-scrcpy-app/blob/1d470ea8f571f19fcbb6883cc6118fafed6fe14f/scrcpy_server/entry/src/main/cpp/TcpServer.cpp) - -许可分别是鸿镜 MIT、OpenHarmony_Scrcpy Apache-2.0;仍需保留声明、审查依赖和自行构建的分发物。root 项目的安装脚本会遍历目标设备并更改系统分区,本项目不能直接调用它。[鸿镜 LICENSE](https://github.com/guoxiucai/ohos-scrcpy-app/blob/1d470ea8f571f19fcbb6883cc6118fafed6fe14f/LICENSE)、[OpenHarmony_Scrcpy LICENSE](https://github.com/luodh0157/OpenHarmony_Scrcpy/blob/85890e693fb5b3e4b3ef50a16af688787ca85070/LICENSE)、[root 服务要求](https://github.com/luodh0157/OpenHarmony_Scrcpy/blob/85890e693fb5b3e4b3ef50a16af688787ca85070/Server/README.md)、[系统分区安装脚本](https://github.com/luodh0157/OpenHarmony_Scrcpy/blob/85890e693fb5b3e4b3ef50a16af688787ca85070/Server/install_ohscrcpy_server.sh) - -## 验收与后续决策 - -先交付可探测的屏幕预览和明确输入,再进行视频流原型。每条能力分别保存 available/unavailable/unknown 及原因;不把 HDC 已连接等同全部投屏/输入可用。最小真机验收需要覆盖截图解码、旋转坐标、点击/滑动效果、中文输入、断线取消、锁屏、拒绝授权、受保护画面、停止采集后不再传帧和本会话文件/转发清理。视频流另外测首帧时延、实际 FPS、CPU/温度、背压、编码器重建与应用后台状态;完成这些前不使用“低延迟 60 FPS”等宣传。 - -本轮明确未验证:Pura 70 Ultra 当前固件的 UITest 输入效果、HDC 版本、官方工具真机投屏、伴随应用采集权限、UITest addon 加载、任何社区 HAP/二进制。已确认的是公开能力与实现边界;没有执行设备动作,也没有修改插件代码。 diff --git a/docs/research/harmony-screen-performance.md b/docs/research/harmony-screen-performance.md deleted file mode 100644 index a5c39a1..0000000 --- a/docs/research/harmony-screen-performance.md +++ /dev/null @@ -1,238 +0,0 @@ -# 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 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 的兼容性仍需清楚报告,不能失败后偷偷切回慢截图却继续显示“实时视频”。 - - -## 0.5.1 启动流程修正 - -用户报告的 `Connection refused` 被 0.5.0 视频 TCP 连接处的通用 `std::io::Error → Fault` 包装成 Mac 文件错误。该代码路径可直接定位,但没有真机复现证据可以把底层拒绝进一步归因为某个代理 ABI。 - -实现补齐官方 `RecordAgent` 已做的进程/Unix socket 就绪判断,按上表最新至最旧顺序选择能够启动的官方 Unix 原件。视频连接失败时移除并重建自身记录的转发;不会借用未知转发或终止外部进程。HDC 转发包含本机监听与对端激活阶段,发出 `fport` 命令本身不能代替端到端连接。[HDC 官方转发源码](https://github.com/openharmony/developtools_hdc/blob/master/src/common/forward.cpp) - -产品错误信息区分设备组件启动、HDC 转发及视频连接;首帧显示前保持“正在建立视频连接”。按用户要求,本轮未跑测试、真机实验或远程安装验证。 diff --git a/docs/testing.md b/docs/testing.md deleted file mode 100644 index 347657f..0000000 --- a/docs/testing.md +++ /dev/null @@ -1,121 +0,0 @@ -# Trove 底座测试指南 - -本套件是可安装、可运行的验收材料。以当前 Manifest v1、Wire Protocol v1 和 `@trove/plugin-sdk@0.1.0` 为基础;插件 ID 均使用 `dev.trove.qa.*`,不占用正式插件命名空间。 - -## 快速验收 - -1. 按[开发文档](development.md)执行 `pnpm install --frozen-lockfile`、`pnpm run pack`。 -2. 在底座添加本地源 `dist/index.json`,先安装「01 · Host 能力检查」「02 · 数据隔离对照」「03 · 原生后端检查」。逐一打开,点击「运行常规检查」。 -3. 安装「04 · 公共服务提供方」,再安装并打开「05 · 跨插件调用」。提供方没有 UI;调用时才启动。 -4. 安装「06 · 可选依赖缺失」,检查缺失服务报错的同时 Host 仍然可调用。 -5. 在 Host 检查页按需执行文件选择、确认对话框、通知、剪贴板、快捷键和窗口操作。结果中「已执行 · 请观察」不代表系统效果已经通过人工验收。 -6. 使用「保存 JSON 报告」将报告写入本插件的数据目录;结果会显示路径。也可展开「原始报告」查看全部错误码和返回值。 - -`pnpm run pack` 会重新构建并覆盖本地生成文件。它不安装插件、不启动桌面应用、不修改官方软件源。源码修改后生成同 ID/版本的新内容,可能被底座的版本不可变校验拒绝;重复开发时先卸载该测试插件,或主动提高版本。 - -## 插件矩阵 - -| 工程 | ID 后缀 / 版本 | 覆盖与预期 | -| --- | --- | --- | -| `test-host` | `host` / 1.0.0 | 无后端 UI;SDK 上下文、Unicode/JSON、32 路并发、服务发现、存储/文件 CRUD、错误码、桌面手动操作 | -| `test-isolation` | `isolation` / 1.0.0 | 与 host 使用同一个持久化键;读到各自 ID,关闭并重新打开后保留 | -| `test-self` | `self` / 1.0.0 | UI 打开时启动后端;`@self`、环境与参数、24 路乱序请求、200 KiB 以上消息、取消/超时、Host 回调、事件 | -| `test-provider` | `provider` / 1.0.0 | Backend-only;服务 `dev.trove.qa.provider.probe@1.0.0`;按需启动、20 秒空闲退出 | -| `test-consumer` | `consumer` / 1.0.0 | 必需依赖 provider 1.x;公开服务调用、事件、输入/输出契约 | -| `test-optional` | `optional` / 1.0.0 | 可选服务不存在;UI 与 Host 正常,缺失服务调用返回 `service.not_found` | -| `test-autostart` | `autostart` / 1.0.0 | Backend-only;重启底座后应自动运行,公开服务为 `dev.trove.qa.autostart.probe` | -| `test-upgrade-v1` | `upgrade` / 1.0.0 | 安装基线,在面板手动写入升级标记 | -| `test-upgrade-v2` | `upgrade` / 1.1.0 | 同 ID 更新;读取 1.0.0 写入的标记,支持回滚验收 | -| `test-required` | `required` / 1.0.0 | 故障源:必需服务不存在;当前底座调用时返回 `service.not_found` | -| `test-incompatible` | `incompatible` / 1.0.0 | 故障源:要求 provider 2.x;安装 provider 1.x 后调用返回 `service.version_mismatch` | -| `test-crash` | `crash` / 1.0.0 | 故障源:按钮让自己的后端 exit 42;观察重启、连续崩溃熔断与手动重试 | -| `test-start-timeout` | `start-timeout` / 1.0.0 | 故障源:收到 welcome 但不发送 ready;返回 `provider.start_timeout` | -| `test-bad-handshake` | `bad-handshake` / 1.0.0 | 故障源:错误会话令牌;Host 拒绝连接,进程启动失败 | -| `test-stubborn` | `stubborn` / 1.0.0 | 故障源:忽略 shutdown;超过 500 ms 宽限期后应被底座终止 | - -每个工程都有自己的 `curio.json`。UI 源文件来自 `shared/ui/`,构建到 `dist/ui/`;Rust 源码在 `shared/backend/`,构建产物复制到各包的 `backend/probe`。生产包包含 SDK、静态资源和所需原生可执行文件,不依赖工作区相邻目录。 - -## 生命周期、状态与数据 - -- **按需启动**:先安装 provider,不进行任何服务调用;在底座看其未启动。打开 consumer 运行检查后应启动;停止调用约 20 秒后退出,再调用得到新 PID。查询 `host.services.getStatus` 不应启动提供方。 -- **随底座启动**:安装 autostart,完全退出并重启 Trove。在进行 `info` 调用前,用 Host 检查页的「检查随底座启动的服务」确认 `Available`,或在管理界面确认进程已运行。单纯调用成功不能证明 app-start 生效。 -- **UI 生命周期**:重复打开同一插件应聚焦已有窗口;关闭后无失效窗口。重开后读取标记仍存在;退出底座应清理所有测试后端、连接和快捷键。 -- **禁用与卸载**:禁用 provider 后 consumer 调用应报告 `provider.disabled`;启用后恢复。卸载后调用应报告 `service.not_found`。 -- **崩溃隔离**:安装 crash,先读取 PID,再触发崩溃,读取新 PID。短时间连续崩溃三次后应得到 `provider.crash_loop`。同时操作 Host 与 self 插件,确认未被连带中断;在底座管理页手动重试恢复。 -- **停止无响应进程**:打开 stubborn,记录 PID,在底座停止或禁用;约 500 ms 宽限期后该 PID 应消失。 -- **存储隔离**:分别运行 host 与 isolation;两者的 `qa.namespace-owner` 值必须是自己的 ID。后端回调 Host storage 的数据归属后端提供方,不能落到 consumer 的命名空间。 - -常规检查只清理自己创建的临时文件/键。`qa.namespace-owner` 与固定的 `qa.backend-roundtrip` 保留供人工检查,报告和升级标记也会保留。存储大小限制测试使用超过 1 MiB 的值;不会填满整个存储配额。剪贴板按钮会替换系统剪贴板;快捷键使用 Cmd+Shift+Option+F9,等待最多 15 秒,并在结束时注销。系统通知是否可见需人工确认。 - -## 升级与回滚 - -常规软件源同时包含 1.0.0 与 1.1.0;默认安装可能直接选到最新版。要测试完整路径,先手动安装基线包: - -```sh -"$TROVE_BIN" curio install dist/packages/dev.trove.qa.upgrade-1.0.0.curio --no-input -``` - -打开插件,点击「写入升级基线标记」,记录 token,再安装更新: - -```sh -"$TROVE_BIN" curio install dist/packages/dev.trove.qa.upgrade-1.1.0.curio --no-input -``` - -运行中更新应正确暂存或切换,不混用两个版本的 UI 资源。关闭并重新打开后应显示 1.1.0,标记仍由 1.0.0 写入、token 不变。使用底座的版本切换/回滚操作回到 1.0.0,验证数据继续保留。卸载时分别验证保留数据和删除数据,再重装读取。 - -## 故意无效的安装材料 - -`dist/invalid/cases.json` 列出每个 `.curio` 和应返回的错误码。覆盖 22 种情况: - -- JSON 损坏、未知字段、schemaVersion 不支持、保留 ID、非法版本、引擎不兼容。 -- UI 缺失、UI/Backend 均未声明、Manifest 路径越界、覆盖保留环境变量。 -- 没有后端却提供服务、服务命名空间错误、后端/契约文件缺失、契约版本不一致、非法 JSON Schema。 -- ZIP 路径越界、绝对路径、反斜杠路径、大小写碰撞、符号链接、损坏压缩包。 - -`dist/invalid/source-cases.json` 列出 4 种合法索引携带异常下载元数据的情况:SHA-256 错误、大小错误、目标架构不匹配、索引与包身份不一致。它们不加入常规软件源。 - -无效安装应有明确错误,已安装列表和当前版本不变化,staging 清理完成。不要将所有 `.curio` 不加区分地批量安装;`dist/packages/` 才是结构合法的测试包。 - -## 自动验证 - -```sh -export TROVE_BIN=/absolute/path/to/app/target/debug/trove -export TROVE_APP_DIR=/absolute/path/to/app -# 可选:复用现有 Cargo 缓存;默认使用 dist/host-target。 -export TROVE_QA_TARGET_DIR=/absolute/path/to/app/target -pnpm run verify -``` - -验证流程: - -1. 编译 `tools/host-check.rs`,链接指定 app 仓库的真实 `PackageManager`、`ServiceBus`、`BackendSupervisor` 和 Unix Gateway,禁用桌面特性。 -2. 用 Trove CLI 的 `protocol test` 检查后端的握手、分片接收、并发请求 ID、结构化错误和优雅退出。 -3. 在 `/tmp/trove-qa-*` 创建隔离数据目录,安装真实包;验证无效材料、软件源本地/HTTP 下载、持久化、升级/回滚和卸载。 -4. 通过测试桥将**真实生产 SDK** 连接至真实 Service Bus。直接运行 `shared/ui/checks.js` 中的检查;后端走真实 Unix socket 和原生子进程,不使用 mock 服务。 -5. 默认策略下检查实际行为,再**单独开启严格契约模式**验证参数和返回值;随后恢复默认模式。测试取消清理、服务禁用、启动超时、认证失败、空闲退出、强制停止、崩溃隔离/熔断。 -6. 写入 `dist/verification.json`,失败时非零退出;正常结束时停止测试 HTTP 服务、后端进程,删除隔离目录。不会触碰用户的 Trove 安装注册表或现有插件数据。 - -首次编译可能需要较长时间。自动结果不等于桌面 WebView/系统交互的完整验收;真实 WebView 注入、窗口视觉、系统权限、快捷键触发、通知展示、app-start 的应用启动调度仍应按上文手动检查。 - -## 远程软件源验收 - -发布完成后,必须使用底座实际访问的 Git 仓库单独验收: - -```sh -TROVE_APP_DIR=/absolute/path/to/app pnpm run verify \ - --remote-source git@git.render.ink:trove/curios.git \ - --source-kind git \ - --source-id trove-official -``` - -此模式不读取工作区的 `.curio`:在新的临时目录中通过真实 PackageManager 获取远程仓库、读取索引、安装该提交中的每个版本,检查 UI 入口,启动安装后的原生后端,再执行 consumer → provider 调用。结果写入 `dist/remote-verification.json`;失败时非零退出,不能视为安装通过。独立 HTTP 源可使用 `--source-kind http` 并传入索引 URL。 - -当前运行探针适用于生产源中的首批验收插件;后续新增产品插件时,应为它们补充对应的运行验收。故障源包含故意无法启动的后端,不能以全部启动成功作为标准。仓库结构、认证和历史发布管理见 [版本与发布](versioning.md)。 - -## 当前能力边界 - -- 当前底座默认未调用 `strict_contracts(true)`。公共服务的无效返回值可能直接透传,因此 consumer 面板的「公共服务返回值契约」会报告失败;这是能力探针,不能将其误判成自动通过。验证脚本把默认策略列为观察项,严格模式断言单独标注,不修改底座源码。 -- 必需依赖与可选依赖目前都在服务调用阶段暴露缺失;不要预设安装器或打开窗口时一定拦截。两个插件用于观察和比较这一行为。 -- 以实际已注册服务为准。规范草案中的 `host.process`、`host.screen`、`host.keychain`、辅助功能、文件 watch 等未被本套件宣称为已实现;没有用模拟服务填补这些能力。 -- macOS 原生构建按当前架构出包;另一架构需在对应机器重新构建。自动测试覆盖目标架构拒绝,不验证跨架构二进制执行、签名、公证、真实公网 TLS、断电原子性或系统资源耗尽。 -- `dist/` 中本地索引包含绝对 `file://` 地址;移动仓库后重新打包生成索引。HTTP 源由 `pnpm run serve` 动态改写为本机地址,仅监听 127.0.0.1。 diff --git a/docs/versioning.md b/docs/versioning.md index 241a288..7d793c8 100644 --- a/docs/versioning.md +++ b/docs/versioning.md @@ -1,73 +1,9 @@ -# 插件版本与发布记录 +# 版本与 Release -仓库是 monorepo:**每个 Curio 独立版本、独立兼容范围、独立更新**。不要求一次提交中的所有插件一起升级。 +每个正式插件独立维护 `curio.json` 的版本,并以 `v` tag 发布对应源码快照。原生项目中的版本必须与 Manifest 一致。 -| 信息 | 唯一来源 | 作用 | -| --- | --- | --- | -| 插件版本 | `curios//curio.json` 的 `version` | 底座安装、更新、切换和回滚的版本 | -| 公共服务版本 | `services.provides[].version`,与 Contract 的 `version` 一致 | 调用方依赖约束;不兼容接口升级主版本 | -| 底座兼容范围 | `engine.trove` | 声明可运行的 Trove 版本;不是插件版本 | -| 已发布版本索引 | `source/index.json` | 保存全部历史版本、平台、URL、大小和 SHA-256 | -| 源码快照 | Git commit;可打 `/v` tag | 从一个已发布插件版本定位对应源代码 | -| 仓库工具版本 | 根 `package.json.version` | 构建/验证工具版本,不用于判断插件是否有更新 | +安装包存放在该插件仓库的 Gitea Release 附件中。附件按插件 ID、版本、平台命名,不使用 `latest` 之类可变下载地址。`trove-release.json` 记录本次发布的所有产物;源索引由收录 workflow 生成。 -例如只修复 `dev.trove.qa.host` 的 UI,应将该插件从 `1.0.0` 升到 `1.0.1`,其他插件保持原版本。服务接口没有变化时,服务版本仍可保持 `1.0.0`。 +相同插件 ID、版本、平台的已发布内容不能覆盖,修复应发布新版本。一个插件在源索引中对应一个条目,版本位于其 `versions` 数组内。 -## 不可变发布 - -一个产物由 **插件 ID + 插件版本 + 平台架构** 唯一标识: - -```text -packages/dev.trove.qa.host/1.0.0/any.curio -packages/dev.trove.qa.self/1.0.0/aarch64-apple-darwin.curio -packages/dev.trove.qa.self/1.0.0/x86_64-apple-darwin.curio -``` - -同一版本可以补齐另一种架构,但不能替换已经发布的同架构内容。发布后发现问题要发新版本,不能重传覆盖旧文件。回滚切换到已有历史版本,不删除或重写坏版本对应的源码记录。服务有破坏性变更时还需要修改服务主版本和使用方依赖。 - -当前 `test-upgrade-v1` 与 `test-upgrade-v2` 是验收专用的双版本样本,共用 `dev.trove.qa.upgrade` ID。普通产品插件应只维护一份当前源码;历史通过 Git 和发布目录保存,不为每次升级复制一个源码文件夹。 - -## 准备发布 - -先修改需要发布的 Manifest,执行 `pnpm run pack` 和验证。`dist/index.json` 是可反复生成的本地开发索引;不能把它当作历史发布记录。 - -```sh -pnpm run release:prepare -pnpm run test:release -``` - -命令会将本次打包的常规插件合并到生产源 `source/index.json`(源 ID 为 `trove-official`),并把不可变安装包复制到 `source/packages/`。索引中的包地址相对于 `source/`,索引与包在同一 Git 提交中发布。`dist/release/` 保留本次发布材料副本。故障注入插件和无效包不会进入生产源。 - -校验规则: - -- 从当前包计算大小和 SHA-256,与打包元数据比较,防止索引指向错误内容。 -- 保留旧版本以及其他架构;不会因为本次只发布部分内容而重建/清空历史。 -- 同 ID、版本、架构的 SHA-256、大小、URL 必须与历史完全一致;不一致会要求升级插件版本。 -- 同一已发布版本的 `engine.trove` 不得悄悄变化。 -- 重复准备相同材料不会改变首次发布日期。 -- 先完整校验本次所有条目,再复制产物和更新本地索引。 - -`--catalog <目录>` 可在隔离目录试运行。正式发布应将 `source/` 的历史索引与安装包提交回仓库。该脚本只准备本地材料,不表示推送或远程安装已经成功。 - -需要独立 HTTP 分发时,可显式传入 `--base-url ` 生成绝对下载地址,并使用 `dist/release/` 上传;已有版本的 URL 仍不能覆盖。 - -## 发布顺序 - -1. 更新需要发布的插件版本,构建并完成验证。 -2. 执行 `pnpm run release:prepare`;审阅本次新增的版本、架构、大小和 SHA-256。 -3. 将源码、`source/index.json` 和 `source/packages/` 在同一提交中推送;需要时为各插件打独立 tag。 -4. 从远程 Git 仓库刷新、安装并运行验证;不能用本地文件安装冒充远程源验收。 - -发布工作串行进行;两种架构先各自构建,再将产物合并至同一版本。若更新 `shared/ui/` 或 `shared/backend/`,所有受影响且需要重新发布的插件都要增加插件版本,未受影响的插件不必修改。 - -## 添加仓库源 - -在 Trove「软件源 → 添加软件源」中填写: - -- 显示名称:Trove 官方软件源 -- 唯一 ID:`trove-official` -- 源类型:Git 仓库 -- 仓库地址:`git@git.render.ink:trove/curios.git` -- 优先级:`0` - -也支持 HTTPS 克隆地址 `https://git.render.ink/trove/curios.git`。私有仓库沿用本机 Git 的 SSH 密钥或 HTTPS credential helper;不要把访问令牌写进 Manifest、索引或 URL。添加后刷新,底座自动读取默认分支的 `source/index.json`,并固定该提交读取仓库内的安装包。用户不需要填写原始 JSON 地址或配置额外的静态下载站点。 +详细流程见 [官方软件源的维护与发布](development.md)。 diff --git a/package.json b/package.json index 20d5038..df1bcd9 100644 --- a/package.json +++ b/package.json @@ -1,25 +1,14 @@ { - "name": "trove-curios", + "name": "trove-catalog", "version": "0.1.0", "private": true, "type": "module", "packageManager": "pnpm@11.23.0", "scripts": { - "build": "node tools/build.mjs", - "harmony:build": "node curios/harmony-connector/build.mjs", - "harmony:pack": "node tools/pack-harmony.mjs", - "harmony:test": "node tools/test-harmony.mjs", - "pack": "node tools/pack.mjs", - "verify": "node tools/verify.mjs", - "serve": "node tools/serve.mjs", - "release:prepare": "node tools/prepare-release.mjs", - "test:release": "node --test tools/release-catalog.test.mjs", - "test": "node tools/verify.mjs" + "release:sync": "node tools/sync-release.mjs", + "release:publish": "node tools/publish-release.mjs" }, "dependencies": { - "@trove/plugin-sdk": "file:vendor/trove-plugin-sdk-0.1.0.tgz" - }, - "devDependencies": { - "jsdom": "26.1.0" + "semver": "7.7.2" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c106393..007b6e4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,349 +8,17 @@ importers: .: dependencies: - '@trove/plugin-sdk': - specifier: file:vendor/trove-plugin-sdk-0.1.0.tgz - version: file:vendor/trove-plugin-sdk-0.1.0.tgz - devDependencies: - jsdom: - specifier: 26.1.0 - version: 26.1.0 + semver: + specifier: 7.7.2 + version: 7.7.2 packages: - '@asamuzakjp/css-color@3.2.0': - resolution: {integrity: sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==} - - '@csstools/color-helpers@5.1.0': - resolution: {integrity: sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==} - engines: {node: '>=18'} - - '@csstools/css-calc@2.1.4': - resolution: {integrity: sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==} - engines: {node: '>=18'} - peerDependencies: - '@csstools/css-parser-algorithms': ^3.0.5 - '@csstools/css-tokenizer': ^3.0.4 - - '@csstools/css-color-parser@3.1.0': - resolution: {integrity: sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==} - engines: {node: '>=18'} - peerDependencies: - '@csstools/css-parser-algorithms': ^3.0.5 - '@csstools/css-tokenizer': ^3.0.4 - - '@csstools/css-parser-algorithms@3.0.5': - resolution: {integrity: sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==} - engines: {node: '>=18'} - peerDependencies: - '@csstools/css-tokenizer': ^3.0.4 - - '@csstools/css-tokenizer@3.0.4': - resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==} - engines: {node: '>=18'} - - '@trove/plugin-sdk@file:vendor/trove-plugin-sdk-0.1.0.tgz': - resolution: {integrity: sha512-jd6JJO3FGogsGkoFHlNYTDDkLu++HUctWI0sUV05Ry7JxfJEgQkz8BjHVs58lU/euoLAVTUh3itGM/gnjwRI+A==, tarball: file:vendor/trove-plugin-sdk-0.1.0.tgz} - version: 0.1.0 - - agent-base@7.1.4: - resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} - engines: {node: '>= 14'} - - cssstyle@4.6.0: - resolution: {integrity: sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==} - engines: {node: '>=18'} - - data-urls@5.0.0: - resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==} - engines: {node: '>=18'} - - debug@4.4.3: - resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} - engines: {node: '>=6.0'} - peerDependencies: - supports-color: '*' - peerDependenciesMeta: - supports-color: - optional: true - - decimal.js@10.6.0: - resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} - - entities@6.0.1: - resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} - engines: {node: '>=0.12'} - - html-encoding-sniffer@4.0.0: - resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==} - engines: {node: '>=18'} - - http-proxy-agent@7.0.2: - resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} - engines: {node: '>= 14'} - - https-proxy-agent@7.0.6: - resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} - engines: {node: '>= 14'} - - iconv-lite@0.6.3: - resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} - engines: {node: '>=0.10.0'} - - is-potential-custom-element-name@1.0.1: - resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} - - jsdom@26.1.0: - resolution: {integrity: sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==} - engines: {node: '>=18'} - peerDependencies: - canvas: ^3.0.0 - peerDependenciesMeta: - canvas: - optional: true - - lru-cache@10.4.3: - resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} - - ms@2.1.3: - resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - - nwsapi@2.2.27: - resolution: {integrity: sha512-gQPNF78qebCQ6tvVFBYrvJdBNOrYZm90ZlXgpIFm06p6qHDHq/XC4TnJftN6OMbxVE0UTBAoRgcsDeJBBooITw==} - - parse5@7.3.0: - resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} - - punycode@2.3.1: - resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} - engines: {node: '>=6'} - - rrweb-cssom@0.8.0: - resolution: {integrity: sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==} - - safer-buffer@2.1.2: - resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} - - saxes@6.0.0: - resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} - engines: {node: '>=v12.22.7'} - - symbol-tree@3.2.4: - resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} - - tldts-core@6.1.86: - resolution: {integrity: sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==} - - tldts@6.1.86: - resolution: {integrity: sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==} + semver@7.7.2: + resolution: {integrity: sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==} + engines: {node: '>=10'} hasBin: true - tough-cookie@5.1.2: - resolution: {integrity: sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==} - engines: {node: '>=16'} - - tr46@5.1.1: - resolution: {integrity: sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==} - engines: {node: '>=18'} - - w3c-xmlserializer@5.0.0: - resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} - engines: {node: '>=18'} - - webidl-conversions@7.0.0: - resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} - engines: {node: '>=12'} - - whatwg-encoding@3.1.1: - resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==} - engines: {node: '>=18'} - deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation - - whatwg-mimetype@4.0.0: - resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} - engines: {node: '>=18'} - - whatwg-url@14.2.0: - resolution: {integrity: sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==} - engines: {node: '>=18'} - - ws@8.21.3: - resolution: {integrity: sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==} - engines: {node: '>=10.0.0'} - peerDependencies: - bufferutil: ^4.0.1 - utf-8-validate: '>=5.0.2' - peerDependenciesMeta: - bufferutil: - optional: true - utf-8-validate: - optional: true - - xml-name-validator@5.0.0: - resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} - engines: {node: '>=18'} - - xmlchars@2.2.0: - resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} - snapshots: - '@asamuzakjp/css-color@3.2.0': - dependencies: - '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) - '@csstools/css-color-parser': 3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) - '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) - '@csstools/css-tokenizer': 3.0.4 - lru-cache: 10.4.3 - - '@csstools/color-helpers@5.1.0': {} - - '@csstools/css-calc@2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': - dependencies: - '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) - '@csstools/css-tokenizer': 3.0.4 - - '@csstools/css-color-parser@3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': - dependencies: - '@csstools/color-helpers': 5.1.0 - '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) - '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) - '@csstools/css-tokenizer': 3.0.4 - - '@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4)': - dependencies: - '@csstools/css-tokenizer': 3.0.4 - - '@csstools/css-tokenizer@3.0.4': {} - - '@trove/plugin-sdk@file:vendor/trove-plugin-sdk-0.1.0.tgz': {} - - agent-base@7.1.4: {} - - cssstyle@4.6.0: - dependencies: - '@asamuzakjp/css-color': 3.2.0 - rrweb-cssom: 0.8.0 - - data-urls@5.0.0: - dependencies: - whatwg-mimetype: 4.0.0 - whatwg-url: 14.2.0 - - debug@4.4.3: - dependencies: - ms: 2.1.3 - - decimal.js@10.6.0: {} - - entities@6.0.1: {} - - html-encoding-sniffer@4.0.0: - dependencies: - whatwg-encoding: 3.1.1 - - http-proxy-agent@7.0.2: - dependencies: - agent-base: 7.1.4 - debug: 4.4.3 - transitivePeerDependencies: - - supports-color - - https-proxy-agent@7.0.6: - dependencies: - agent-base: 7.1.4 - debug: 4.4.3 - transitivePeerDependencies: - - supports-color - - iconv-lite@0.6.3: - dependencies: - safer-buffer: 2.1.2 - - is-potential-custom-element-name@1.0.1: {} - - jsdom@26.1.0: - dependencies: - cssstyle: 4.6.0 - data-urls: 5.0.0 - decimal.js: 10.6.0 - html-encoding-sniffer: 4.0.0 - http-proxy-agent: 7.0.2 - https-proxy-agent: 7.0.6 - is-potential-custom-element-name: 1.0.1 - nwsapi: 2.2.27 - parse5: 7.3.0 - rrweb-cssom: 0.8.0 - saxes: 6.0.0 - symbol-tree: 3.2.4 - tough-cookie: 5.1.2 - w3c-xmlserializer: 5.0.0 - webidl-conversions: 7.0.0 - whatwg-encoding: 3.1.1 - whatwg-mimetype: 4.0.0 - whatwg-url: 14.2.0 - ws: 8.21.3 - xml-name-validator: 5.0.0 - transitivePeerDependencies: - - bufferutil - - supports-color - - utf-8-validate - - lru-cache@10.4.3: {} - - ms@2.1.3: {} - - nwsapi@2.2.27: {} - - parse5@7.3.0: - dependencies: - entities: 6.0.1 - - punycode@2.3.1: {} - - rrweb-cssom@0.8.0: {} - - safer-buffer@2.1.2: {} - - saxes@6.0.0: - dependencies: - xmlchars: 2.2.0 - - symbol-tree@3.2.4: {} - - tldts-core@6.1.86: {} - - tldts@6.1.86: - dependencies: - tldts-core: 6.1.86 - - tough-cookie@5.1.2: - dependencies: - tldts: 6.1.86 - - tr46@5.1.1: - dependencies: - punycode: 2.3.1 - - w3c-xmlserializer@5.0.0: - dependencies: - xml-name-validator: 5.0.0 - - webidl-conversions@7.0.0: {} - - whatwg-encoding@3.1.1: - dependencies: - iconv-lite: 0.6.3 - - whatwg-mimetype@4.0.0: {} - - whatwg-url@14.2.0: - dependencies: - tr46: 5.1.1 - webidl-conversions: 7.0.0 - - ws@8.21.3: {} - - xml-name-validator@5.0.0: {} - - xmlchars@2.2.0: {} + semver@7.7.2: {} diff --git a/shared/backend/Cargo.lock b/shared/backend/Cargo.lock deleted file mode 100644 index eaa53c7..0000000 --- a/shared/backend/Cargo.lock +++ /dev/null @@ -1,112 +0,0 @@ -# This file is automatically @generated by Cargo. -# It is not intended for manual editing. -version = 4 - -[[package]] -name = "anyhow" -version = "1.0.104" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" - -[[package]] -name = "itoa" -version = "1.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" - -[[package]] -name = "memchr" -version = "2.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" - -[[package]] -name = "proc-macro2" -version = "1.0.107" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "quote" -version = "1.0.47" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" -dependencies = [ - "proc-macro2", -] - -[[package]] -name = "serde" -version = "1.0.229" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" -dependencies = [ - "serde_core", -] - -[[package]] -name = "serde_core" -version = "1.0.229" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" -dependencies = [ - "serde_derive", -] - -[[package]] -name = "serde_derive" -version = "1.0.229" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "serde_json" -version = "1.0.151" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" -dependencies = [ - "itoa", - "memchr", - "serde", - "serde_core", - "zmij", -] - -[[package]] -name = "syn" -version = "3.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "trove-qa-backend" -version = "1.0.0" -dependencies = [ - "anyhow", - "serde_json", -] - -[[package]] -name = "unicode-ident" -version = "1.0.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" - -[[package]] -name = "zmij" -version = "1.0.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/shared/backend/Cargo.toml b/shared/backend/Cargo.toml deleted file mode 100644 index 66c225d..0000000 --- a/shared/backend/Cargo.toml +++ /dev/null @@ -1,9 +0,0 @@ -[package] -name = "trove-qa-backend" -version = "1.0.0" -edition = "2021" -publish = false - -[dependencies] -anyhow = "1" -serde_json = "1" diff --git a/shared/backend/src/main.rs b/shared/backend/src/main.rs deleted file mode 100644 index c57b4c0..0000000 --- a/shared/backend/src/main.rs +++ /dev/null @@ -1,492 +0,0 @@ -// Framing and request lifecycle adapted from Trove app/examples/backend-rust (Wire v1). -// This diagnostic backend is test-only; fault modes require explicit manifest args. -use anyhow::{anyhow, Context, Result}; -use serde_json::{json, Value}; -use std::{ - collections::HashMap, - env, fs, - io::{Read, Write}, - os::unix::net::UnixStream, - sync::{ - atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering}, - mpsc, Arc, Mutex, - }, - thread, - time::{Duration, Instant, SystemTime, UNIX_EPOCH}, -}; - -const MAX_FRAME: usize = 8 * 1024 * 1024; -const MAX_IN_FLIGHT: usize = 256; -static ACTIVE_REQUESTS: AtomicUsize = AtomicUsize::new(0); -fn active_count() -> usize { - ACTIVE_REQUESTS.load(Ordering::Relaxed) -} - -type WireResult = std::result::Result; -type Pending = Arc>>>; -type Active = Arc>>>; -type Subscriptions = Arc>>; -struct Subscription { - task_id: String, - caller: Value, - seq: u64, -} -struct Queued { - bytes: Vec, - flushed: Option>, -} -#[derive(Clone)] -struct Writer { - sender: mpsc::SyncSender, - bytes: Arc, -} -impl Writer { - fn new(mut socket: UnixStream) -> Self { - let (sender, receiver) = mpsc::sync_channel::(512); - let bytes = Arc::new(AtomicUsize::new(0)); - let budget = bytes.clone(); - thread::spawn(move || { - for next in receiver { - let result = socket.write_all(&next.bytes).and_then(|_| socket.flush()); - budget.fetch_sub(next.bytes.len(), Ordering::Relaxed); - if result.is_err() { - break; - } - if let Some(flushed) = next.flushed { - let _ = flushed.send(()); - } - } - }); - Self { sender, bytes } - } - fn send(&self, message: Value) -> Result<()> { - let payload = serde_json::to_vec(&message)?; - if payload.is_empty() || payload.len() > MAX_FRAME { - return Err(anyhow!("Frame exceeds 8 MiB")); - } - let size = payload.len() + 4; - if self - .bytes - .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |used| { - (used + size <= 16 * 1024 * 1024).then_some(used + size) - }) - .is_err() - { - return Err(anyhow!("Outbound byte queue is full")); - } - let mut bytes = (payload.len() as u32).to_be_bytes().to_vec(); - bytes.extend(payload); - if let Err(error) = self.sender.try_send(Queued { - bytes, - flushed: None, - }) { - self.bytes.fetch_sub(size, Ordering::Relaxed); - return Err(anyhow!("Outbound queue unavailable: {error}")); - } - Ok(()) - } - fn flush(&self) { - let (tx, rx) = mpsc::channel(); - if self - .sender - .try_send(Queued { - bytes: vec![], - flushed: Some(tx), - }) - .is_ok() - { - let _ = rx.recv_timeout(Duration::from_millis(500)); - } - } - fn response(&self, id: Value, result: WireResult) { - let message = match result { - Ok(result) => json!({"v":1,"type":"response","id":id,"result":result}), - Err(error) => json!({"v":1,"type":"response","id":id,"error":error}), - }; - let _ = self.send(message); - } -} -fn error(code: &str, message: impl ToString) -> Value { - json!({"code":code,"message":message.to_string(),"retryable":false}) -} -fn key(id: &Value) -> String { - id.to_string() -} -fn read(reader: &mut UnixStream) -> Result { - let mut header = [0; 4]; - reader.read_exact(&mut header)?; - let length = u32::from_be_bytes(header) as usize; - if length == 0 || length > MAX_FRAME { - return Err(anyhow!("Invalid frame length")); - } - let mut payload = vec![0; length]; - reader.read_exact(&mut payload)?; - let message: Value = serde_json::from_slice(&payload)?; - if !message.is_object() || message["v"] != 1 { - return Err(anyhow!("Invalid message version")); - } - Ok(message) -} -fn now_ms() -> u64 { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_millis() as u64 -} -fn progress(writer: &Writer, subscriptions: &Subscriptions, context: &Value, amount: f64) { - for (id, sub) in subscriptions.lock().unwrap().iter_mut() { - if sub.caller == context["caller"] { - sub.seq += 1; - let _ = writer.send(json!({"v":1,"type":"event","subscriptionId":id,"event":"progress","seq":sub.seq,"data":{"taskId":sub.task_id,"progress":amount}})); - } - } -} -fn call_host( - writer: &Writer, - pending: &Pending, - ids: &AtomicU64, - service: &str, - method: &str, - params: Value, - context: &Value, - cancelled: &AtomicBool, -) -> WireResult { - let id = format!("rust_{}", ids.fetch_add(1, Ordering::Relaxed)); - let (tx, rx) = mpsc::channel(); - { - let mut pending = pending.lock().unwrap(); - if pending.len() >= MAX_IN_FLIGHT { - return Err(error( - "request.too_many_in_flight", - "Host request limit reached", - )); - } - pending.insert(id.clone(), tx); - } - let timeout_ms = context["deadlineUnixMs"] - .as_u64() - .unwrap_or(now_ms() + 30_000) - .saturating_sub(now_ms()) - .min(30_000); - if let Err(problem) = writer.send(json!({"v":1,"type":"request","id":id,"service":service,"method":method,"params":params,"options":{"timeoutMs":timeout_ms},"context":context})) { - pending.lock().unwrap().remove(&id); return Err(error("transport.backpressure", problem)); - } - let deadline = Instant::now() + Duration::from_millis(timeout_ms); - let result = loop { - if cancelled.load(Ordering::Relaxed) { - break Err(error("request.cancelled", "Request cancelled")); - } - if Instant::now() >= deadline { - break Err(error("request.timeout", "Host request timed out")); - } - match rx.recv_timeout(Duration::from_millis(10)) { - Ok(response) => { - break match response.get("error") { - Some(error) => Err(error.clone()), - None => Ok(response["result"].clone()), - } - } - Err(mpsc::RecvTimeoutError::Timeout) => {} - Err(_) => break Err(error("provider.disconnected", "Host disconnected")), - } - }; - if pending.lock().unwrap().remove(&id).is_some() { - let _ = writer.send(json!({"v":1,"type":"cancel","id":id})); - } - result -} -fn request( - message: &Value, - writer: &Writer, - pending: &Pending, - ids: &AtomicU64, - subscriptions: &Subscriptions, - cancelled: &AtomicBool, -) -> WireResult { - if !matches!( - message["service"].as_str(), - Some(service) if service == "@self" || service == format!("{}.probe", env::var("TROVE_CURIO_ID").unwrap_or_default()) - ) { - return Err(error("service.not_found", "Unknown service")); - } - let context = &message["context"]; - match message["method"].as_str() { - Some("echo") => return Ok(message["params"].clone()), - Some("info") => { - return Ok(json!({ - "pid":std::process::id(), "curioId":env::var("TROVE_CURIO_ID").unwrap_or_default(), - "version":env::var("TROVE_CURIO_VERSION").unwrap_or_default(), - "label":env::var("QA_LABEL").unwrap_or_default(), "args":env::args().skip(1).collect::>(), - "activeRequests":active_count(), "subscriptions":subscriptions.lock().unwrap().len() - })) - } - Some("delay") => { - let ms = message["params"]["ms"] - .as_u64() - .filter(|ms| *ms <= 10_000) - .ok_or_else(|| { - error( - "request.invalid_params", - "ms must be an integer in 0..10000", - ) - })?; - let deadline = Instant::now() + Duration::from_millis(ms); - while Instant::now() < deadline { - if cancelled.load(Ordering::Relaxed) { - return Err(error("request.cancelled", "Delay cancelled")); - } - thread::sleep(Duration::from_millis(5)); - } - return Ok(message["params"]["value"].clone()); - } - Some("fail") => { - return Err( - json!({"code":"qa.intentional_error","message":"Expected QA failure","retryable":false,"data":{"fixture":true}}), - ) - } - Some("invalidResult") => return Ok(json!({"intentionally":"not the contract string"})), - Some("storageRoundtrip") => { - let key = message["params"]["key"] - .as_str() - .filter(|key| key.starts_with("qa.")) - .ok_or_else(|| error("request.invalid_params", "key must start with qa."))?; - call_host( - writer, - pending, - ids, - "host.storage", - "set", - message["params"].clone(), - context, - cancelled, - )?; - return call_host( - writer, - pending, - ids, - "host.storage", - "get", - json!({"key":key}), - context, - cancelled, - ); - } - Some("crash") if env::args().any(|arg| arg == "--allow-crash") => { - eprintln!("qa: deliberate exit 42"); - std::process::exit(42); - } - _ => {} - } - let text = match message["method"].as_str() { - Some("uppercase") => message["params"]["text"] - .as_str() - .ok_or_else(|| error("request.invalid_params", "text must be a string"))? - .to_owned(), - _ => return Err(error("service.method_not_found", "Unknown method")), - }; - progress(writer, subscriptions, context, 0.0); - let total = text.chars().count(); - let mut output = String::new(); - for (index, character) in text.chars().enumerate() { - if cancelled.load(Ordering::Relaxed) { - return Err(error("request.cancelled", "Request cancelled")); - } - output.extend(character.to_uppercase()); - if index % 4096 == 0 { - progress( - writer, - subscriptions, - context, - index as f64 / total.max(1) as f64, - ); - thread::yield_now(); - } - } - if cancelled.load(Ordering::Relaxed) { - return Err(error("request.cancelled", "Request cancelled")); - } - progress(writer, subscriptions, context, 1.0); - Ok(json!({"text":output})) -} -fn session() -> Result<(String, String)> { - if let Ok(path) = env::var("TROVE_DEV_SESSION_FILE") { - let value: Value = serde_json::from_slice(&fs::read(path)?)?; - return Ok(( - value["socketPath"] - .as_str() - .context("missing socketPath")? - .into(), - value["sessionToken"] - .as_str() - .context("missing sessionToken")? - .into(), - )); - } - Ok(( - env::var("TROVE_SOCKET_PATH")?, - env::var("TROVE_SESSION_TOKEN")?, - )) -} -fn main() -> Result<()> { - let (path, mut token) = session()?; - if env::args().any(|arg| arg == "--bad-token") { - token = "qa-deliberately-invalid-token".into(); - } - println!("qa: backend started pid={}", std::process::id()); - eprintln!("qa: label={}", env::var("QA_LABEL").unwrap_or_default()); - let mut reader = UnixStream::connect(path)?; - let writer = Writer::new(reader.try_clone()?); - let pending: Pending = Arc::new(Mutex::new(HashMap::new())); - let active: Active = Arc::new(Mutex::new(HashMap::new())); - let subscriptions: Subscriptions = Arc::new(Mutex::new(HashMap::new())); - let ids = Arc::new(AtomicU64::new(1)); - writer.send(json!({"v":1,"type":"hello","client":"backend","sessionToken":token,"protocol":{"min":1,"max":1},"features":["call","cancel","subscribe"],"implementation":{"name":"trove-qa-backend","version":"1.0.0","language":"rust"}}))?; - let welcome = read(&mut reader)?; - if welcome["type"] != "welcome" || welcome["protocol"] != 1 { - return Err(anyhow!("Expected welcome")); - } - if env::args().any(|arg| arg == "--no-ready") { - // Stay connected but never become ready; the Host must terminate this process. - loop { - thread::sleep(Duration::from_secs(1)); - } - } - writer.send(json!({"v":1,"type":"ready"}))?; - while let Ok(message) = read(&mut reader) { - let id = message["id"].clone(); - match message["type"].as_str() { - Some("request") => { - let cancelled = Arc::new(AtomicBool::new(false)); - { - let mut calls = active.lock().unwrap(); - if calls.contains_key(&key(&id)) { - writer.send(json!({"v":1,"type":"protocolError","error":error("protocol.duplicate_id","Duplicate active request ID")}))?; - continue; - } - if calls.len() >= MAX_IN_FLIGHT { - writer.response( - id, - Err(error( - "request.too_many_in_flight", - "Backend request limit reached", - )), - ); - continue; - } - calls.insert(key(&id), cancelled.clone()); - } - let (writer, pending, ids, subscriptions, active) = ( - writer.clone(), - pending.clone(), - ids.clone(), - subscriptions.clone(), - active.clone(), - ); - ACTIVE_REQUESTS.fetch_add(1, Ordering::Relaxed); - thread::spawn(move || { - let result = request( - &message, - &writer, - &pending, - &ids, - &subscriptions, - &cancelled, - ); - active.lock().unwrap().remove(&key(&id)); - ACTIVE_REQUESTS.fetch_sub(1, Ordering::Relaxed); - writer.response(id, result); - }); - } - Some("response") => { - if let Some(id) = id.as_str() { - if let Some(tx) = pending.lock().unwrap().remove(id) { - let _ = tx.send(message); - } - } - } - Some("cancel") => { - if let Some(cancelled) = active.lock().unwrap().get(&key(&id)) { - cancelled.store(true, Ordering::Relaxed); - } - } - Some("subscribe") => { - if !matches!( - message["service"].as_str(), - Some(service) if service == "@self" || service == format!("{}.probe", env::var("TROVE_CURIO_ID").unwrap_or_default()) - ) || message["topic"] != "progress" - { - writer.response( - id, - Err(error("service.method_not_found", "Unknown event topic")), - ); - continue; - } - let Some(task_id) = message["params"]["taskId"] - .as_str() - .filter(|id| !id.is_empty()) - else { - writer.response( - id, - Err(error( - "request.invalid_params", - "taskId must be a nonempty string", - )), - ); - continue; - }; - let mut subs = subscriptions.lock().unwrap(); - if subs.len() >= MAX_IN_FLIGHT { - writer.response( - id, - Err(error( - "request.too_many_in_flight", - "Subscription limit reached", - )), - ); - continue; - } - let sub_id = format!("rust_sub_{}", ids.fetch_add(1, Ordering::Relaxed)); - subs.insert( - sub_id.clone(), - Subscription { - task_id: task_id.into(), - caller: message["context"]["caller"].clone(), - seq: 0, - }, - ); - writer.response(id, Ok(json!({"subscriptionId":sub_id}))); - } - Some("unsubscribe") => { - let closed = message["subscriptionId"] - .as_str() - .is_some_and(|id| subscriptions.lock().unwrap().remove(id).is_some()); - writer.response(id, Ok(json!({"closed":closed}))); - } - Some("shutdown") => { - if env::args().any(|arg| arg == "--ignore-shutdown") { - eprintln!("qa: deliberately ignoring shutdown"); - loop { - thread::sleep(Duration::from_secs(1)); - } - } - eprintln!("qa: graceful shutdown"); - writer.send(json!({"v":1,"type":"goodbye"}))?; - writer.flush(); - // Keep the read half alive while the Host finishes its side of shutdown. - // The grace bound also allows exit if the Host never sends EOF. - reader.set_read_timeout(Some(Duration::from_millis(250)))?; - let mut eof = [0u8; 1]; - let _ = reader.read(&mut eof); - break; - } - Some("protocolError") => break, - _ => {} - } - } - for cancelled in active.lock().unwrap().values() { - cancelled.store(true, Ordering::Relaxed); - } - subscriptions.lock().unwrap().clear(); - pending.lock().unwrap().clear(); - Ok(()) -} diff --git a/shared/ui/checks.js b/shared/ui/checks.js deleted file mode 100644 index 3f236f8..0000000 --- a/shared/ui/checks.js +++ /dev/null @@ -1,170 +0,0 @@ -export const sleep = ms => new Promise(resolve => setTimeout(resolve, ms)); -export function assert(condition, message) { if (!condition) throw new Error(message); } -export function equal(actual, expected, label = '返回值不匹配') { - // Compare JSON objects independently of property insertion order. - const canonical = value => Array.isArray(value) ? value.map(canonical) : value && typeof value === 'object' - ? Object.fromEntries(Object.keys(value).sort().map(key => [key, canonical(value[key])])) : value; - assert(JSON.stringify(canonical(actual)) === JSON.stringify(canonical(expected)), `${label}: ${JSON.stringify(actual)}`); -} -export async function expectError(action, expected) { - try { await action(); } catch (error) { - assert(error.code === expected, `预期 ${expected},实际 ${error.code ?? error.message}`); - return { expected, actual: error.code, message: error.message }; - } - throw new Error(`预期 ${expected},调用却成功了`); -} - -export function hostChecks(client) { - const call = (service, method, params = {}) => client.call(service, method, params); - return [ - ['SDK 与运行上下文', async () => { - const context = await call('host.runtime', 'getContext'); - equal(context.curioId, client.context.curioId); - equal(context.curioVersion, client.context.curioVersion); - equal(context.apiVersion, 1); - return context; - }], - ['Host 信息与数据目录', async () => { - const info = await call('host.runtime', 'getHostInfo'); - const paths = await call('host.runtime', 'getPaths'); - for (const key of ['data', 'cache', 'logs', 'temp']) assert(paths[key]?.startsWith('/'), `缺少绝对路径 ${key}`); - equal(info.protocol, 1); - return { info, paths }; - }], - ['JSON 与 Unicode 往返', async () => { - const value = { text: '中文 / emoji 🧪 / e\u0301 / \n换行', number: 12.5, null: null, array: [false, {}, ''] }; - equal(await call('host.runtime', 'echo', value), value); - return value; - }], - ['32 路并发响应匹配', async () => { - const values = await Promise.all(Array.from({ length: 32 }, (_, index) => call('host.runtime', 'echo', { index }))); - values.forEach((value, index) => equal(value, { index })); - return { responses: values.length }; - }], - ['服务发现、描述、解析与状态', async () => { - const { services } = await call('host.services', 'list', { prefix: 'host.' }); - for (const id of ['host.runtime', 'host.storage', 'host.fs']) assert(services.some(item => item.id === id), `缺少 ${id}`); - const description = await call('host.services', 'describe', { service: 'host.storage' }); - assert(description.contract.methods.set, '缺少 storage.set 契约'); - await call('host.services', 'resolve', { service: 'host.storage', version: '^1.0.0' }); - equal((await call('host.services', 'getStatus', { service: 'host.storage' })).state, 'Available'); - return services; - }], - ['独立存储、null 与不存在、前缀查询、删除', async () => { - const key = `qa.temporary.${crypto.randomUUID()}`; - try { - equal((await call('host.storage', 'get', { key })).found, false); - await call('host.storage', 'set', { key, value: null }); - equal(await call('host.storage', 'get', { key }), { found: true, value: null }); - const value = { owner: client.context.curioId, unicode: '存储 🧪', array: [1, false] }; - await call('host.storage', 'set', { key, value }); - equal((await call('host.storage', 'get', { key })).value, value); - assert((await call('host.storage', 'list', { prefix: key })).keys.includes(key), '前缀查询缺少测试键'); - await call('host.storage', 'delete', { key }); - equal((await call('host.storage', 'get', { key })).found, false); - return { namespace: client.context.curioId }; - } finally { await call('host.storage', 'delete', { key }); } - }], - ['持久化与同名键隔离标记', async () => { - const key = 'qa.namespace-owner'; - const previous = await call('host.storage', 'get', { key }); - if (previous.found) equal(previous.value, client.context.curioId, '存储命名空间混用'); - await call('host.storage', 'set', { key, value: client.context.curioId }); - return { previous, stored: client.context.curioId, next: '在对照插件运行检查,然后关闭并重新打开本插件再运行。' }; - }], - ['存储单值大小限制', () => expectError(() => call('host.storage', 'set', { key: 'qa.oversize', value: 'x'.repeat(1024 * 1024 + 1) }), 'host.storage.value_too_large')], - ['文件 CRUD、二进制、复制与移动', async () => { - const paths = await call('host.runtime', 'getPaths'); - const directory = `${paths.temp}/qa-${crypto.randomUUID()}`; - const text = `${directory}/中文 空格.txt`; - await call('host.fs', 'createDirectory', { path: directory }); - try { - await call('host.fs', 'writeText', { path: text, content: 'Trove 文件 🧪\n第二行' }); - equal((await call('host.fs', 'readText', { path: text })).content, 'Trove 文件 🧪\n第二行'); - equal((await call('host.fs', 'stat', { path: text })).type, 'file'); - assert((await call('host.fs', 'list', { path: directory })).entries.some(item => item.name === '中文 空格.txt'), '目录列表缺少文件'); - const binary = `${directory}/bytes.bin`; - await call('host.fs', 'writeBinary', { path: binary, data: 'AAECA/7/' }); - equal((await call('host.fs', 'readBinary', { path: binary })).data, 'AAECA/7/'); - await call('host.fs', 'copy', { from: binary, to: `${directory}/copy.bin` }); - await call('host.fs', 'move', { from: `${directory}/copy.bin`, to: `${directory}/moved.bin` }); - equal((await call('host.fs', 'exists', { path: `${directory}/copy.bin` })).exists, false); - equal((await call('host.fs', 'readBinary', { path: `${directory}/moved.bin` })).data, 'AAECA/7/'); - const temporary = await call('host.fs', 'createTempFile'); - await call('host.fs', 'remove', temporary); - return { unicodeFilename: true, binaryBytes: 6, directory }; - } finally { await call('host.fs', 'remove', { path: directory, recursive: true }); } - }], - ['未知服务错误', () => expectError(() => call('dev.trove.qa.absent.probe', 'echo'), 'service.not_found')], - ['未知方法错误', () => expectError(() => call('host.runtime', 'unknownQaMethod'), 'service.method_not_found')], - ['Host 参数契约校验', () => expectError(() => call('host.storage', 'get', { key: 123 }), 'request.invalid_params')], - ['服务版本约束', () => expectError(() => call('host.services', 'resolve', { service: 'host.storage', version: '^99.0.0' }), 'service.version_mismatch')], - ]; -} - -export function backendChecks(client, service) { - const call = (method, params = {}, options) => client.call(service, method, params, options); - return [ - ['后端启动、身份、环境与参数', async () => { - const info = await call('info'); - assert(info.pid > 0 && info.curioId.startsWith('dev.trove.qa.'), '后端身份缺失'); - assert(info.label.startsWith('test-'), 'Manifest 环境变量未传入'); - return info; - }], - ['原生 Unicode 与超过 200 KiB 消息', async () => { - const value = { text: '中🧪'.repeat(32768), null: null, nested: [false, { n: 3 }] }; - equal(await call('echo', value), value); - return { utf8Bytes: new TextEncoder().encode(JSON.stringify(value)).length }; - }], - ['24 路乱序并发响应匹配', async () => { - const responses = await Promise.all(Array.from({ length: 24 }, (_, index) => call('delay', { ms: (23 - index) * 8, value: { index } }))); - responses.forEach((value, index) => equal(value, { index })); - return { responses: responses.length }; - }], - ['结构化业务错误', () => expectError(() => call('fail'), 'qa.intentional_error')], - ['未知后端方法', () => expectError(() => call('unknownQaMethod'), 'service.method_not_found')], - ['请求超时与后续可用性', async () => { - const error = await expectError(() => call('delay', { ms: 1500 }, { timeoutMs: 80 }), 'request.timeout'); - equal(await call('echo', { recovered: true }), { recovered: true }); - return error; - }], - ['AbortSignal 取消与后续可用性', async () => { - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), 60); - try { - const error = await expectError(() => call('delay', { ms: 1500 }, { signal: controller.signal }), 'request.cancelled'); - equal(await call('echo', { recovered: true }), { recovered: true }); - return error; - } finally { clearTimeout(timer); } - }], - ['后端 → Host 存储调用', async () => { - const key = 'qa.backend-roundtrip'; - const value = { unicode: '双向 🧪', caller: client.context.curioId }; - const response = await call('storageRoundtrip', { key, value }); - equal(response, { found: true, value }); - // Backend writes to its own namespace. Fixed small probes are retained there for inspection. - return { key, value, note: '存储所有者为后端提供方;可在插件数据目录检查。' }; - }], - ['事件订阅、顺序、取消订阅', async () => { - const events = []; - const taskId = crypto.randomUUID(); - const subscription = await client.subscribe(service, 'progress', { taskId }, data => events.push(data)); - try { - equal(await call('uppercase', { text: 'hello 中文' }), { text: 'HELLO 中文' }); - const deadline = Date.now() + 1500; - while (!events.some(item => item.progress === 1) && Date.now() < deadline) await sleep(20); - assert(events.length >= 2 && events[0].progress === 0 && events.at(-1).progress === 1, '未收到完整进度序列'); - assert(events.every((item, i) => item.taskId === taskId && (i === 0 || item.progress >= events[i - 1].progress)), '事件身份或顺序不正确'); - } finally { await subscription.close(); } - const before = events.length; - await call('uppercase', { text: 'after unsubscribe' }); - await sleep(100); - equal(events.length, before, '取消订阅后仍收到事件'); - return { events }; - }], - ...(service === '@self' ? [] : [ - ['公共服务参数契约', () => expectError(() => call('uppercase', { text: 123 }), 'request.invalid_params')], - ['公共服务返回值契约', () => expectError(() => call('invalidResult'), 'service.contract_violation')], - ]), - ]; -} diff --git a/shared/ui/index.html b/shared/ui/index.html deleted file mode 100644 index b00f770..0000000 --- a/shared/ui/index.html +++ /dev/null @@ -1,41 +0,0 @@ - - - - - - Trove 插件测试台 - - - -
-

TROVE / PLUGIN TEST LAB

-

插件测试台

-

正在连接 Trove…

-
-
-
-

-
- - -
-

等待连接。

-
-
-

检查结果

-

通过代表断言已满足;需观察的系统交互不会计入自动通过。

-
    -
    -
    -

    手动检查

    -

    这些操作可能打开窗口、改写剪贴板、注册快捷键或终止本插件后端。按需逐项执行。

    -
    -
    -
    - 原始报告 -
    []
    -
    -
    - - - diff --git a/shared/ui/main.js b/shared/ui/main.js deleted file mode 100644 index 4ef0528..0000000 --- a/shared/ui/main.js +++ /dev/null @@ -1,184 +0,0 @@ -import { trove } from './sdk/index.js'; -import config from './config.js'; -import { assert, equal, expectError, hostChecks, backendChecks } from './checks.js'; - -const $ = selector => document.querySelector(selector); -const results = []; -let running = false; -const call = (service, method, params = {}, options) => trove.call(service, method, params, options); -const report = () => ({ schemaVersion: 1, fixture: config, context: trove.context, generatedAt: new Date().toISOString(), results }); -const serializeError = error => ({ code: error.code ?? 'qa.assertion_failed', message: error.message ?? String(error), data: error.data }); - -function refresh() { - $('#results').replaceChildren(...results.map(result => { - const row = document.createElement('li'); - const heading = document.createElement('div'); - heading.className = 'result-heading'; - const title = document.createElement('strong'); - title.className = result.status; - const labels = { pass: '通过', fail: '失败', observed: '已执行 · 请观察', skip: '跳过' }; - title.textContent = `${labels[result.status]} · ${result.name}`; - const duration = document.createElement('small'); - duration.textContent = `${result.durationMs} ms`; - heading.append(title, duration); - const detail = document.createElement('details'); - const summary = document.createElement('summary'); - summary.textContent = result.error?.message || '查看返回值'; - const pre = document.createElement('pre'); - pre.textContent = JSON.stringify(result.error || result.value, null, 2); - detail.append(summary, pre); - row.append(heading, detail); - return row; - })); - $('#report').textContent = JSON.stringify(report(), null, 2); - const counts = status => results.filter(result => result.status === status).length; - $('#status').textContent = `${running ? '运行中' : '已完成'} · 通过 ${counts('pass')} / 失败 ${counts('fail')} / 待观察 ${counts('observed')} / 跳过 ${counts('skip')}`; -} - -async function check(name, action, status = 'pass') { - const start = performance.now(); - try { results.push({ name, status, value: await action(), durationMs: Math.round(performance.now() - start) }); } - catch (error) { results.push({ name, status: 'fail', error: serializeError(error), durationMs: Math.round(performance.now() - start) }); } - refresh(); -} - -async function exclusive(action) { - if (running) return; - running = true; - document.querySelectorAll('button').forEach(button => { button.disabled = true; }); - try { await action(); } - finally { - running = false; - document.querySelectorAll('button').forEach(button => { button.disabled = false; }); - refresh(); - } -} - -function manual(name, action, status = 'observed') { - const button = document.createElement('button'); - button.textContent = name; - button.onclick = () => exclusive(() => check(name, action, status)); - $('#manual').append(button); -} - -const descriptions = { - host: '检查真实 Host 桥接与数据操作。常规检查只在本插件命名空间和临时目录写入测试数据;隔离标记会保留以便复测。', - backend: `检查 ${config.service}:原生进程、并发、超时、取消、Host 回调和事件。跨插件测试需先安装公共服务提供方。公共服务返回值校验要求底座开启严格契约模式;默认未开启时,该项会报告失败。`, - dependency: `此插件声明了 ${config.service}。预期调用错误为 ${config.expectedError};“service.not_found”不能代替版本不兼容错误。`, - fault: '此后端故意无法完成启动。点击运行检查记录实际错误码;恢复与重试应在底座管理界面操作。', - upgrade: '先安装 1.0.0 并写入基线标记,再安装 1.1.0。关闭后重新打开,检查运行版本与数据保留;也可在底座回滚。', -}; -$('#title').textContent = config.name; -document.title = config.name; -$('#description').textContent = descriptions[config.mode]; - -function configureManualActions() { - manual('读取持久化标记', () => call('host.storage', 'get', { key: config.mode === 'upgrade' ? 'qa.upgrade-marker' : 'qa.namespace-owner' }), 'pass'); - if (config.mode === 'host') { - manual('读取本窗口尺寸', () => call('host.window', 'getBounds'), 'pass'); - manual('设置窗口标题', () => call('host.window', 'setTitle', { title: `${config.name} · 标题测试成功` })); - manual('窗口居中', () => call('host.window', 'center')); - manual('文件选择(可取消)', () => call('host.dialog', 'openFile', { title: 'Trove QA:选择文件或取消' }, { timeoutMs: 120000 })); - manual('目录选择(可取消)', () => call('host.dialog', 'openDirectory', { title: 'Trove QA:选择目录或取消' }, { timeoutMs: 120000 })); - manual('保存对话框(仅选择路径)', () => call('host.dialog', 'saveFile', { title: 'Trove QA:只选择路径,不写入文件' }, { timeoutMs: 120000 })); - manual('确认对话框', () => call('host.dialog', 'confirm', { title: 'Trove QA', message: '确认和取消都应返回明确结果。' }, { timeoutMs: 120000 })); - manual('写入并读取测试剪贴板', async () => { - const text = 'Trove QA 剪贴板 🧪'; - await call('host.clipboard', 'writeText', { text }); - equal((await call('host.clipboard', 'readText')).text, text); - return { text, note: '系统剪贴板已被替换为测试文本。' }; - }, 'pass'); - manual('发送测试通知', () => call('host.notification', 'show', { title: 'Trove QA', body: `来自 ${config.id} 的测试通知` })); - manual('快捷键触发(15 秒)', async () => { - const events = []; - let registration; - const subscription = await trove.subscribe('host.shortcut', 'triggered', {}, event => events.push(event)); - try { - registration = await call('host.shortcut', 'register', { shortcut: 'CommandOrControl+Shift+Alt+F9' }); - $('#status').textContent = '请在 15 秒内按 Cmd+Shift+Option+F9(部分键盘需要 Fn)。'; - const end = Date.now() + 15000; - while (!events.length && Date.now() < end) await new Promise(resolve => setTimeout(resolve, 50)); - assert(events.some(event => event.registrationId === registration.registrationId), '15 秒内没有收到匹配的快捷键事件'); - return events; - } finally { - try { if (registration) await call('host.shortcut', 'unregister', registration); } - finally { await subscription.close(); } - } - }, 'pass'); - manual('列出正在运行的应用', () => call('host.application', 'listRunning'), 'pass'); - manual('检查随底座启动的服务', async () => { - const service = 'dev.trove.qa.autostart.probe'; - // Discovery does not start the backend; keep this separate from info(). - return call('host.services', 'getStatus', { service }); - }); - manual('关闭本窗口', () => call('host.window', 'close')); - } - if (config.mode === 'backend') { - manual('读取后端 PID 与活动数', () => call(config.service, 'info'), 'pass'); - if (config.service !== '@self') manual('查询提供方状态(不启动)', () => call('host.services', 'getStatus', { service: config.service })); - if (config.directory === 'test-crash') manual('触发本后端崩溃(exit 42)', async () => { - try { await call('@self', 'crash', {}, { timeoutMs: 6000 }); } - catch (error) { - assert(['provider.disconnected', 'provider.crashed'].includes(error.code), `预期进程断开,实际 ${error.code}`); - return { error: serializeError(error), next: '再次读取 PID 验证恢复;短时间反复触发可观察熔断。' }; - } - throw new Error('后端未断开'); - }); - } - if (config.mode === 'upgrade') { - manual('写入升级基线标记', async () => { - const value = { token: crypto.randomUUID(), writtenByVersion: trove.context.curioVersion, writtenAt: new Date().toISOString() }; - await call('host.storage', 'set', { key: 'qa.upgrade-marker', value }); - return value; - }, 'pass'); - } -} - -$('#run').onclick = () => exclusive(async () => { - results.length = 0; - if (config.mode === 'host') for (const [name, action] of hostChecks(trove)) await check(name, action); - if (config.mode === 'backend') for (const [name, action] of backendChecks(trove, config.service)) await check(name, action); - if (config.mode === 'dependency') { - await check('缺失依赖时 Host 仍可用', async () => equal(await call('host.runtime', 'echo', { alive: true }), { alive: true })); - await check('依赖调用返回预期错误', () => expectError(() => call(config.service, 'echo'), config.expectedError)); - } - if (config.mode === 'fault') { - await check('启动失败被限制在截止时间内', async () => { - try { await call('@self', 'info', {}, { timeoutMs: 6000 }); } - catch (error) { - const expected = config.directory === 'test-start-timeout' - ? ['provider.start_timeout'] - : ['provider.start_failed', 'provider.disconnected', 'provider.crashed', 'provider.start_timeout']; - assert(expected.includes(error.code), `非预期错误 ${error.code}`); - return { actual: serializeError(error), next: '查看底座后端日志确认具体启动原因。' }; - } - throw new Error('故障后端意外启动成功'); - }); - } - if (config.mode === 'upgrade') { - await check('运行版本与打包版本一致', async () => { equal(trove.context.curioVersion, config.version); return trove.context; }); - await check('升级基线标记保留', async () => { - const marker = await call('host.storage', 'get', { key: 'qa.upgrade-marker' }); - assert(marker.found, '尚无基线标记,请在 1.0.0 中写入后再验证升级'); - equal(marker.value.writtenByVersion, '1.0.0', '标记不是由基线版本写入'); - return marker.value; - }); - } -}); - -$('#save-report').onclick = () => exclusive(() => check('保存 JSON 报告', async () => { - const paths = await call('host.runtime', 'getPaths'); - const path = `${paths.data}/qa-report-${Date.now()}.json`; - await call('host.fs', 'writeText', { path, content: JSON.stringify(report(), null, 2) }); - return { path }; -})); - -try { - await trove.ready(); - $('#context').textContent = `${trove.context.curioId} · v${trove.context.curioVersion} · ${trove.context.mode} · ${trove.context.instanceId}`; - configureManualActions(); - document.querySelectorAll('button').forEach(button => { button.disabled = false; }); - $('#status').textContent = '已连接真实 Trove Host,点击运行常规检查。'; -} catch (error) { - $('#status').textContent = `连接失败:${error.code ?? ''} ${error.message}。请从 Trove 安装并打开本插件。`; -} diff --git a/shared/ui/style.css b/shared/ui/style.css deleted file mode 100644 index d457752..0000000 --- a/shared/ui/style.css +++ /dev/null @@ -1,33 +0,0 @@ -:root { color-scheme: light dark; font: 14px/1.6 -apple-system, BlinkMacSystemFont, sans-serif; color: #203129; background: #f3f5f1; } -* { box-sizing: border-box; } -body { max-width: 1080px; margin: auto; padding: 28px; } -header { margin-bottom: 24px; } -.eyebrow { font-size: 11px; font-weight: 650; letter-spacing: 2px; color: #53745e; } -h1 { font-size: 27px; line-height: 1.3; margin: 8px 0; } -h2 { font-size: 16px; margin: 0 0 8px; } -p { margin: 8px 0; } -#context, .hint { color: #626f66; overflow-wrap: anywhere; } -section, details { padding: 20px; background: #fff; border: 1px solid #dce3db; border-radius: 12px; margin-bottom: 16px; } -.actions { display: flex; gap: 8px; flex-wrap: wrap; margin: 14px 0; } -button { font: inherit; color: inherit; background: #f1f5f0; border: 1px solid #cbd7ca; border-radius: 7px; padding: 8px 12px; cursor: pointer; } -#run { background: #286047; border-color: #286047; color: #fff; } -button:hover { filter: brightness(.94); } -button:disabled { opacity: .5; cursor: default; } -button:focus-visible, summary:focus-visible { outline: 3px solid #629d78; outline-offset: 3px; } -ol { list-style: none; margin: 0; padding: 0; } -li { padding: 12px 0; border-bottom: 1px solid #e7ece5; } -li:last-child { border: 0; } -.pass { color: #236b3b; } .fail { color: #ba352d; } .observed, .skip { color: #8c5e15; } -.result-heading { display: flex; gap: 12px; justify-content: space-between; } -small { color: #68746b; } -pre { white-space: pre-wrap; overflow-wrap: anywhere; font: 12px/1.6 ui-monospace, monospace; max-height: 360px; overflow: auto; } -summary { cursor: pointer; } -@media (max-width: 640px) { body { padding: 14px; } section { padding: 14px; } .result-heading { display: block; } } -@media (prefers-color-scheme: dark) { - :root { color: #e1e9df; background: #172019; } - section, details { background: #202b23; border-color: #37473a; } - button { background: #2d3c30; border-color: #506153; } - #context, .hint, small { color: #a4b4a6; } - li { border-color: #37473a; } - .pass { color: #8adb9e; } .fail { color: #ff9c92; } .observed, .skip { color: #edc579; } -} diff --git a/source/index.json b/source/index.json index 0d7d56d..9f0041b 100644 --- a/source/index.json +++ b/source/index.json @@ -2,355 +2,6 @@ "schemaVersion": 1, "id": "trove-official", "name": "Trove 官方软件源", - "generatedAt": "2026-09-10T09:52:52.752Z", - "curios": [ - { - "id": "dev.trove.qa.host", - "name": "01 · Host 能力检查", - "description": "UI、SDK 桥接、服务发现、存储、文件和桌面交互。", - "versions": [ - { - "version": "1.0.0", - "engine": { - "trove": ">=0.1.0 <1.0.0" - }, - "releasedAt": "2026-09-09T12:17:24.404Z", - "artifacts": [ - { - "target": "any", - "url": "packages/dev.trove.qa.host/1.0.0/any.curio", - "sha256": "15a644d1231b237092b3c4ed19ccece12e92e8f78aac681af2279150da65bad1", - "size": 20082 - } - ] - } - ] - }, - { - "id": "dev.trove.qa.isolation", - "name": "02 · 数据隔离对照", - "description": "与 Host 检查使用相同存储键,验证插件间独立命名空间。", - "versions": [ - { - "version": "1.0.0", - "engine": { - "trove": ">=0.1.0 <1.0.0" - }, - "releasedAt": "2026-09-09T12:17:24.404Z", - "artifacts": [ - { - "target": "any", - "url": "packages/dev.trove.qa.isolation/1.0.0/any.curio", - "sha256": "8bfc084b75e91874b6c6c488c8703b2ccd5529a92f9f2cb56ab57f69c491e687", - "size": 20106 - } - ] - } - ] - }, - { - "id": "dev.trove.qa.self", - "name": "03 · 原生后端检查", - "description": "UI → @self → 原生 Rust 后端 → Host;并发、取消、事件。", - "versions": [ - { - "version": "1.0.0", - "engine": { - "trove": ">=0.1.0 <1.0.0" - }, - "releasedAt": "2026-09-09T12:17:24.404Z", - "artifacts": [ - { - "target": "aarch64-apple-darwin", - "url": "packages/dev.trove.qa.self/1.0.0/aarch64-apple-darwin.curio", - "sha256": "328d39a3ce95d33eae8b1575c307a9aec8d3c5319e89c250b04c1389ecea5b5e", - "size": 346745 - } - ] - } - ] - }, - { - "id": "dev.trove.qa.provider", - "name": "04 · 公共服务提供方", - "description": "无 UI,按需启动;20 秒空闲后退出。", - "versions": [ - { - "version": "1.0.0", - "engine": { - "trove": ">=0.1.0 <1.0.0" - }, - "releasedAt": "2026-09-09T12:17:24.404Z", - "artifacts": [ - { - "target": "aarch64-apple-darwin", - "url": "packages/dev.trove.qa.provider/1.0.0/aarch64-apple-darwin.curio", - "sha256": "726f0f47bf50294f9be067437995dbbb59b3b52017b95bff665273165ff2a46d", - "size": 327830 - } - ] - } - ] - }, - { - "id": "dev.trove.qa.consumer", - "name": "05 · 跨插件调用", - "description": "依赖公共服务;验证发现、契约、事件与后端回调。", - "versions": [ - { - "version": "1.0.0", - "engine": { - "trove": ">=0.1.0 <1.0.0" - }, - "releasedAt": "2026-09-09T12:17:24.404Z", - "artifacts": [ - { - "target": "any", - "url": "packages/dev.trove.qa.consumer/1.0.0/any.curio", - "sha256": "503112dd4fc316e370c6f95fdca717f02f23ac68a02e751780a43a721dfb2bd3", - "size": 20079 - } - ] - } - ] - }, - { - "id": "dev.trove.qa.optional", - "name": "06 · 可选依赖缺失", - "description": "缺失可选服务时 UI 仍可工作,调用返回 service.not_found。", - "versions": [ - { - "version": "1.0.0", - "engine": { - "trove": ">=0.1.0 <1.0.0" - }, - "releasedAt": "2026-09-09T12:17:24.404Z", - "artifacts": [ - { - "target": "any", - "url": "packages/dev.trove.qa.optional/1.0.0/any.curio", - "sha256": "ce01d0508b7b1610e835f87095b5f1e45ad1652c924ecb58d113efa4a3c47cc7", - "size": 20101 - } - ] - } - ] - }, - { - "id": "dev.trove.qa.autostart", - "name": "07 · 随底座启动", - "description": "无 UI 的 app-start 后端;重启底座后检查状态和 PID。", - "versions": [ - { - "version": "1.0.0", - "engine": { - "trove": ">=0.1.0 <1.0.0" - }, - "releasedAt": "2026-09-09T12:17:24.404Z", - "artifacts": [ - { - "target": "aarch64-apple-darwin", - "url": "packages/dev.trove.qa.autostart/1.0.0/aarch64-apple-darwin.curio", - "sha256": "38605052d1c7fade9301ea4972b2668a94ab5e563058a1de63c8937b4717d5b7", - "size": 327826 - } - ] - } - ] - }, - { - "id": "dev.trove.qa.upgrade", - "name": "08 · 升级与数据保留", - "description": "1.1.0 更新;显示版本并读取基线标记。", - "versions": [ - { - "version": "1.1.0", - "engine": { - "trove": ">=0.1.0 <1.0.0" - }, - "releasedAt": "2026-09-09T12:17:24.404Z", - "artifacts": [ - { - "target": "any", - "url": "packages/dev.trove.qa.upgrade/1.1.0/any.curio", - "sha256": "500493b4156c3f143c8bc685bad495a41e320f9d7b8346a5ea939141d037a0f2", - "size": 20033 - } - ] - }, - { - "version": "1.0.0", - "engine": { - "trove": ">=0.1.0 <1.0.0" - }, - "releasedAt": "2026-09-09T12:17:24.404Z", - "artifacts": [ - { - "target": "any", - "url": "packages/dev.trove.qa.upgrade/1.0.0/any.curio", - "sha256": "834a1159d18b1d44b5aeb38cf9b60f5a1868df06cdc370d4d08026170e1bb19f", - "size": 20030 - } - ] - } - ] - }, - { - "id": "dev.trove.harmony-connector", - "name": "Harmony Connector", - "description": "连接鸿蒙设备,收发完整文件夹、管理应用,在独立窗口中投屏并通过键鼠操作。", - "versions": [ - { - "version": "0.5.2", - "engine": { - "trove": ">=0.1.0 <1.0.0" - }, - "releasedAt": "2026-09-10T09:52:52.752Z", - "artifacts": [ - { - "target": "aarch64-apple-darwin", - "url": "packages/dev.trove.harmony-connector/0.5.2/aarch64-apple-darwin.curio", - "sha256": "b0b41240ba0a994e61445f77660dee4e4c0aab6b1534128ec08b72acdd71eb58", - "size": 4727424 - } - ] - }, - { - "version": "0.5.1", - "engine": { - "trove": ">=0.1.0 <1.0.0" - }, - "releasedAt": "2026-09-10T09:37:34.602Z", - "artifacts": [ - { - "target": "aarch64-apple-darwin", - "url": "packages/dev.trove.harmony-connector/0.5.1/aarch64-apple-darwin.curio", - "sha256": "1bfed01377ab519c58c71f0a1597c107edd7242c1d96e288979a44929016cb74", - "size": 4718083 - } - ] - }, - { - "version": "0.5.0", - "engine": { - "trove": ">=0.1.0 <1.0.0" - }, - "releasedAt": "2026-09-10T09:25:53.244Z", - "artifacts": [ - { - "target": "aarch64-apple-darwin", - "url": "packages/dev.trove.harmony-connector/0.5.0/aarch64-apple-darwin.curio", - "sha256": "e92ae6269ad80906ee030dafba549913862e53696c1d508fa1b397da1e5ee038", - "size": 4278225 - } - ] - }, - { - "version": "0.4.0", - "engine": { - "trove": ">=0.1.0 <1.0.0" - }, - "releasedAt": "2026-09-10T08:42:44.362Z", - "artifacts": [ - { - "target": "aarch64-apple-darwin", - "url": "packages/dev.trove.harmony-connector/0.4.0/aarch64-apple-darwin.curio", - "sha256": "a203071f9d8218e90bc88190c9bc80acb26b12b81774e55497796d6bb257581d", - "size": 3358482 - } - ] - }, - { - "version": "0.3.2", - "engine": { - "trove": ">=0.1.0 <1.0.0" - }, - "releasedAt": "2026-09-10T06:45:52.251Z", - "artifacts": [ - { - "target": "aarch64-apple-darwin", - "url": "packages/dev.trove.harmony-connector/0.3.2/aarch64-apple-darwin.curio", - "sha256": "eb0ec5d5a8b7f4e3150cd80351eaadd5ef13535d8cbf388e955218b0f60b0f3d", - "size": 2939665 - } - ] - }, - { - "version": "0.3.1", - "engine": { - "trove": ">=0.1.0 <1.0.0" - }, - "releasedAt": "2026-09-10T06:33:16.439Z", - "artifacts": [ - { - "target": "aarch64-apple-darwin", - "url": "packages/dev.trove.harmony-connector/0.3.1/aarch64-apple-darwin.curio", - "sha256": "1509fe112cbbeae11c800ac4f637932813468f9adffc7ef1222e5233053a8d3c", - "size": 3032158 - } - ] - }, - { - "version": "0.3.0", - "engine": { - "trove": ">=0.1.0 <1.0.0" - }, - "releasedAt": "2026-09-10T06:14:13.482Z", - "artifacts": [ - { - "target": "aarch64-apple-darwin", - "url": "packages/dev.trove.harmony-connector/0.3.0/aarch64-apple-darwin.curio", - "sha256": "e773d5583fc6f51508fd41926ba455a35147e8fdf7833a1ff9cc2233f4d93c03", - "size": 3030997 - } - ] - }, - { - "version": "0.2.0", - "engine": { - "trove": ">=0.1.0 <1.0.0" - }, - "releasedAt": "2026-09-10T03:55:38.730Z", - "artifacts": [ - { - "target": "aarch64-apple-darwin", - "url": "packages/dev.trove.harmony-connector/0.2.0/aarch64-apple-darwin.curio", - "sha256": "408dda2e5b0f9525f16bbab9366a6ffc74a0a47ea78d8e55c3f98707cf45aa46", - "size": 2742772 - } - ] - }, - { - "version": "0.1.1", - "engine": { - "trove": ">=0.1.0 <1.0.0" - }, - "releasedAt": "2026-09-10T02:56:51.048Z", - "artifacts": [ - { - "target": "aarch64-apple-darwin", - "url": "packages/dev.trove.harmony-connector/0.1.1/aarch64-apple-darwin.curio", - "sha256": "180a0523d6f73874443777ae77acf0612c30e1fa4083026fb0d9a2a36ab48ad5", - "size": 2334431 - } - ] - }, - { - "version": "0.1.0", - "engine": { - "trove": ">=0.1.0 <1.0.0" - }, - "releasedAt": "2026-09-10T02:03:28.662Z", - "artifacts": [ - { - "target": "aarch64-apple-darwin", - "url": "packages/dev.trove.harmony-connector/0.1.0/aarch64-apple-darwin.curio", - "sha256": "cc01c12c90bf34a76815723beb653cc5ab839e1a3cef5ec1b6a0469d69910e9c", - "size": 2320072 - } - ] - } - ] - } - ] + "generatedAt": "2026-09-11T02:02:41.125650Z", + "curios": [] } diff --git a/source/packages/dev.trove.harmony-connector/0.1.0/aarch64-apple-darwin.curio b/source/packages/dev.trove.harmony-connector/0.1.0/aarch64-apple-darwin.curio deleted file mode 100644 index 3ed7628..0000000 Binary files a/source/packages/dev.trove.harmony-connector/0.1.0/aarch64-apple-darwin.curio and /dev/null differ diff --git a/source/packages/dev.trove.harmony-connector/0.1.1/aarch64-apple-darwin.curio b/source/packages/dev.trove.harmony-connector/0.1.1/aarch64-apple-darwin.curio deleted file mode 100644 index 19b3ce9..0000000 Binary files a/source/packages/dev.trove.harmony-connector/0.1.1/aarch64-apple-darwin.curio and /dev/null differ diff --git a/source/packages/dev.trove.harmony-connector/0.2.0/aarch64-apple-darwin.curio b/source/packages/dev.trove.harmony-connector/0.2.0/aarch64-apple-darwin.curio deleted file mode 100644 index 3ed8ca8..0000000 Binary files a/source/packages/dev.trove.harmony-connector/0.2.0/aarch64-apple-darwin.curio and /dev/null differ diff --git a/source/packages/dev.trove.harmony-connector/0.3.0/aarch64-apple-darwin.curio b/source/packages/dev.trove.harmony-connector/0.3.0/aarch64-apple-darwin.curio deleted file mode 100644 index 5257812..0000000 Binary files a/source/packages/dev.trove.harmony-connector/0.3.0/aarch64-apple-darwin.curio and /dev/null differ diff --git a/source/packages/dev.trove.harmony-connector/0.3.1/aarch64-apple-darwin.curio b/source/packages/dev.trove.harmony-connector/0.3.1/aarch64-apple-darwin.curio deleted file mode 100644 index 5a29aca..0000000 Binary files a/source/packages/dev.trove.harmony-connector/0.3.1/aarch64-apple-darwin.curio and /dev/null differ diff --git a/source/packages/dev.trove.harmony-connector/0.3.2/aarch64-apple-darwin.curio b/source/packages/dev.trove.harmony-connector/0.3.2/aarch64-apple-darwin.curio deleted file mode 100644 index ad9d01c..0000000 Binary files a/source/packages/dev.trove.harmony-connector/0.3.2/aarch64-apple-darwin.curio and /dev/null differ diff --git a/source/packages/dev.trove.harmony-connector/0.4.0/aarch64-apple-darwin.curio b/source/packages/dev.trove.harmony-connector/0.4.0/aarch64-apple-darwin.curio deleted file mode 100644 index d6a03d1..0000000 Binary files a/source/packages/dev.trove.harmony-connector/0.4.0/aarch64-apple-darwin.curio and /dev/null differ diff --git a/source/packages/dev.trove.harmony-connector/0.5.0/aarch64-apple-darwin.curio b/source/packages/dev.trove.harmony-connector/0.5.0/aarch64-apple-darwin.curio deleted file mode 100644 index fde9cad..0000000 Binary files a/source/packages/dev.trove.harmony-connector/0.5.0/aarch64-apple-darwin.curio and /dev/null differ diff --git a/source/packages/dev.trove.harmony-connector/0.5.1/aarch64-apple-darwin.curio b/source/packages/dev.trove.harmony-connector/0.5.1/aarch64-apple-darwin.curio deleted file mode 100644 index 37fe417..0000000 Binary files a/source/packages/dev.trove.harmony-connector/0.5.1/aarch64-apple-darwin.curio and /dev/null differ diff --git a/source/packages/dev.trove.harmony-connector/0.5.2/aarch64-apple-darwin.curio b/source/packages/dev.trove.harmony-connector/0.5.2/aarch64-apple-darwin.curio deleted file mode 100644 index 7f078e9..0000000 Binary files a/source/packages/dev.trove.harmony-connector/0.5.2/aarch64-apple-darwin.curio and /dev/null differ diff --git a/source/packages/dev.trove.qa.autostart/1.0.0/aarch64-apple-darwin.curio b/source/packages/dev.trove.qa.autostart/1.0.0/aarch64-apple-darwin.curio deleted file mode 100644 index 88d2653..0000000 Binary files a/source/packages/dev.trove.qa.autostart/1.0.0/aarch64-apple-darwin.curio and /dev/null differ diff --git a/source/packages/dev.trove.qa.consumer/1.0.0/any.curio b/source/packages/dev.trove.qa.consumer/1.0.0/any.curio deleted file mode 100644 index d72e2d4..0000000 Binary files a/source/packages/dev.trove.qa.consumer/1.0.0/any.curio and /dev/null differ diff --git a/source/packages/dev.trove.qa.host/1.0.0/any.curio b/source/packages/dev.trove.qa.host/1.0.0/any.curio deleted file mode 100644 index 5f5414f..0000000 Binary files a/source/packages/dev.trove.qa.host/1.0.0/any.curio and /dev/null differ diff --git a/source/packages/dev.trove.qa.isolation/1.0.0/any.curio b/source/packages/dev.trove.qa.isolation/1.0.0/any.curio deleted file mode 100644 index e431203..0000000 Binary files a/source/packages/dev.trove.qa.isolation/1.0.0/any.curio and /dev/null differ diff --git a/source/packages/dev.trove.qa.optional/1.0.0/any.curio b/source/packages/dev.trove.qa.optional/1.0.0/any.curio deleted file mode 100644 index 1065008..0000000 Binary files a/source/packages/dev.trove.qa.optional/1.0.0/any.curio and /dev/null differ diff --git a/source/packages/dev.trove.qa.provider/1.0.0/aarch64-apple-darwin.curio b/source/packages/dev.trove.qa.provider/1.0.0/aarch64-apple-darwin.curio deleted file mode 100644 index e49794e..0000000 Binary files a/source/packages/dev.trove.qa.provider/1.0.0/aarch64-apple-darwin.curio and /dev/null differ diff --git a/source/packages/dev.trove.qa.self/1.0.0/aarch64-apple-darwin.curio b/source/packages/dev.trove.qa.self/1.0.0/aarch64-apple-darwin.curio deleted file mode 100644 index 1106f9c..0000000 Binary files a/source/packages/dev.trove.qa.self/1.0.0/aarch64-apple-darwin.curio and /dev/null differ diff --git a/source/packages/dev.trove.qa.upgrade/1.0.0/any.curio b/source/packages/dev.trove.qa.upgrade/1.0.0/any.curio deleted file mode 100644 index 52ce761..0000000 Binary files a/source/packages/dev.trove.qa.upgrade/1.0.0/any.curio and /dev/null differ diff --git a/source/packages/dev.trove.qa.upgrade/1.1.0/any.curio b/source/packages/dev.trove.qa.upgrade/1.1.0/any.curio deleted file mode 100644 index 51218b1..0000000 Binary files a/source/packages/dev.trove.qa.upgrade/1.1.0/any.curio and /dev/null differ diff --git a/source/repositories.json b/source/repositories.json new file mode 100644 index 0000000..79d041f --- /dev/null +++ b/source/repositories.json @@ -0,0 +1,26 @@ +{ + "id": "trove-official", + "name": "Trove 官方软件源", + "repositories": [ + { + "repository": "trove-plugins/harmony-connector", + "ids": [ + "dev.trove.harmony-connector" + ] + }, + { + "repository": "trove-plugins/test-suite", + "suite": true, + "ids": [ + "dev.trove.qa.host", + "dev.trove.qa.isolation", + "dev.trove.qa.self", + "dev.trove.qa.provider", + "dev.trove.qa.consumer", + "dev.trove.qa.optional", + "dev.trove.qa.autostart", + "dev.trove.qa.upgrade" + ] + } + ] +} diff --git a/tools/.gitkeep b/tools/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/tools/build.mjs b/tools/build.mjs deleted file mode 100644 index 89ab332..0000000 --- a/tools/build.mjs +++ /dev/null @@ -1,32 +0,0 @@ -import { copyFile, cp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { root, run, suite } from './suite.mjs'; - -const selected = process.argv[2] ? suite.filter(item => item.directory === process.argv[2]) : suite; -if (!selected.length) throw new Error(`Unknown fixture: ${process.argv[2]}`); -let nativeBuilt = false; -for (const item of selected) { - const directory = path.join(root, 'curios', item.directory); - const manifest = JSON.parse(await readFile(path.join(directory, 'curio.json'), 'utf8')); - if (manifest.backend) { - if (process.platform !== 'darwin') throw new Error('Native packages must be built on macOS for the target architecture.'); - if (!nativeBuilt) { - run('cargo', ['build', '--manifest-path', 'shared/backend/Cargo.toml', '--release', '--locked']); - nativeBuilt = true; - } - const destination = path.join(directory, manifest.backend.entry); - await mkdir(path.dirname(destination), { recursive: true }); - await copyFile(path.join(root, 'shared/backend/target/release/trove-qa-backend'), destination); - } - if (manifest.ui) { - const ui = path.join(directory, 'dist/ui'); - await rm(ui, { recursive: true, force: true }); - await mkdir(ui, { recursive: true }); - await cp(path.join(root, 'shared/ui'), ui, { recursive: true }); - const sdk = path.dirname(fileURLToPath(import.meta.resolve('@trove/plugin-sdk'))); - await cp(sdk, path.join(ui, 'sdk'), { recursive: true }); - await writeFile(path.join(ui, 'config.js'), `export default ${JSON.stringify({ ...item, id: manifest.id, name: manifest.name, version: manifest.version })};\n`); - } - console.log(`Built ${item.directory} (${manifest.version})`); -} diff --git a/tools/fixtures/harmony-ui.mjs b/tools/fixtures/harmony-ui.mjs deleted file mode 100644 index 18f812f..0000000 --- a/tools/fixtures/harmony-ui.mjs +++ /dev/null @@ -1,188 +0,0 @@ -// Explicit browser-only UI fixture. Never included in a .curio package. -import { createMockBridge } from "./sdk/index.js"; -const root = 0xffffffff; -const device = { - id: "fixture", - name: "daniel", - model: "HUAWEI Pura 70 Ultra", - manufacturer: "模拟设备", -}; -const storages = [ - { id: 1, name: "内部存储", capacity: 256 * 1024 ** 3, free: 128 * 1024 ** 3 }, -]; -let attached = true, - connection = null, - job = null, - next = 30; -const entries = [ - { - id: 2, - parent: root, - storage: 1, - name: "Documents", - folder: true, - size: 0, - modified: 0, - }, - { - id: 3, - parent: root, - storage: 1, - name: "DCIM", - folder: true, - size: 0, - modified: 0, - }, - { - id: 4, - parent: root, - storage: 1, - name: "设计稿 🎨 final.zip", - folder: false, - size: 52_423_111, - modified: 1788998400, - }, - { - id: 5, - parent: root, - storage: 1, - name: ".txt", - folder: false, - size: 48, - modified: 1788998400, - }, - { - id: 6, - parent: 2, - storage: 1, - name: "旅行计划.pdf", - folder: false, - size: 1123988, - modified: 1788998400, - }, -]; -let screenActive = false; -window.__TROVE_CONTEXT__ = { - curioId: "dev.trove.harmony-connector", - version: "0.3.0", - instanceId: "ui-fixture", - platform: "macos", -}; -window.__TROVE_BRIDGE__ = createMockBridge({ - "@self.hdc.screenWindowOpen": () => ({ active: (screenActive = true), ready: true }), - "@self.hdc.screenWindowStop": () => ({ active: (screenActive = false) }), - "@self.hdc.screenWindowStatus": () => ({ active: screenActive, ready: screenActive }), - "@self.hdc.status": () => ({ busy: false, job: null }), - "@self.hdc.tool": () => ({ - available: true, - version: "HDC · 模拟数据", - path: "/fixture/official-sdk/hdc", - }), - "@self.hdc.devices": () => ({ - devices: attached - ? [{ id: "fixture-device", transport: "USB", state: "Connected" }] - : [], - }), - "@self.hdc.inspect": () => ({ - capabilities: Object.fromEntries( - ["apps", "launch", "screenshot", "logs", "report"].map((key) => [ - key, - { available: true }, - ]), - ), - info: { text: "HUAWEI Pura 70 Ultra · 界面测试数据" }, - }), - "@self.hdc.apps": () => ({ - apps: ["com.example.gallery", "com.example.notes", "com.example.reader"], - }), - "@self.hdc.app": ({ bundle }) => ({ - bundle, - versionName: "1.2.0", - versionCode: 12, - debug: bundle.endsWith("notes"), - abilities: [{ name: "Home", module: "entry" }], - details: {}, - }), - "@self.info": () => ({ connection, busy: false }), - "@self.devices": () => ({ devices: attached ? [device] : [] }), - "@self.monitor": () => ({ - connection, - devices: attached ? [device] : [], - busy: job?.state === "running", - }), - "@self.connect": () => (connection = { device, storages }), - "@self.disconnect": () => { - connection = null; - job = null; - return { disconnected: true }; - }, - "@self.list": ({ parent, offset = 0 }) => { - const all = entries.filter((e) => e.parent === parent); - return { entries: all.slice(offset, offset + 500), total: all.length }; - }, - "@self.mkdir": ({ name, storage, parent }) => { - entries.push({ - id: next++, - name, - storage, - parent, - folder: true, - size: 0, - modified: 0, - }); - return { id: next - 1 }; - }, - "@self.transfer": () => { - job = { id: 1, state: "running", sent: 0, total: 10 * 1024 ** 2 }; - return { id: 1 }; - }, - "@self.transferStatus": () => { - if (job?.state === "running") { - job.sent += 524288; - job.statistics = { - bytesPerSecond: 1310720, - averageBytesPerSecond: 1210720, - elapsedSeconds: job.sent / 1310720, - remainingSeconds: (job.total - job.sent) / 1310720, - filesTotal: 3, - filesCompleted: Math.floor((job.sent / job.total) * 3), - currentFile: "Documents/设计稿.zip", - }; - if (job.sent >= job.total) { - job.state = "completed"; - job.result = { name: "界面测试文件", bytes: job.total }; - } - } - return job; - }, - "@self.cancelTransfer": () => { - job.state = "cancelled"; - job.error = { message: "模拟取消已完成。" }; - connection = null; - return { requested: true }; - }, - "host.dialog.openFile": () => ({ path: "/ui-fixture/example.txt" }), - "host.dialog.openDirectory": () => ({ path: "/ui-fixture/downloads" }), -}); -const badge = document.createElement("div"); -badge.textContent = "界面测试 · 模拟设备,不执行真实文件传输"; -badge.style.cssText = - "position:fixed;bottom:3px;left:240px;z-index:10;background:#805800;color:white;padding:4px 8px;border-radius:5px;font-size:10px"; -document.body.append(badge); -const toggle = document.createElement("button"); -toggle.textContent = "模拟拔掉 USB"; -toggle.style.cssText = - "position:fixed;bottom:30px;left:16px;z-index:10;background:#805800;color:white"; -toggle.addEventListener("click", () => { - attached = !attached; - if (!attached) { - connection = null; - if (job?.state === "running") { - job.state = "failed"; - job.error = { message: "设备已断开(模拟)" }; - } - } - toggle.textContent = attached ? "模拟拔掉 USB" : "模拟接入 USB"; -}); -document.body.append(toggle); -await import("./app.js"); diff --git a/tools/gitea.mjs b/tools/gitea.mjs new file mode 100644 index 0000000..2fcbe97 --- /dev/null +++ b/tools/gitea.mjs @@ -0,0 +1,82 @@ +import { execFileSync } from 'node:child_process'; + +const origin = new URL(process.env.GITEA_SERVER_URL || 'https://git.render.ink'); +if (origin.protocol !== 'https:' || origin.username || origin.password) throw new Error('Gitea requires an HTTPS origin without credentials'); +export const server = origin.origin; +export const catalogRepository = process.env.CATALOG_REPOSITORY || 'trove/curios'; + +export function repositoryPath(repository) { + if (!/^[a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+$/.test(repository)) throw new Error('Invalid repository name'); + return repository.split('/').map(encodeURIComponent).join('/'); +} + +export async function api(route, { token = process.env.GITEA_TOKEN, method = 'GET', body, missing = false } = {}) { + const auth = authorization(token); + const response = await fetch(`${server}/api/v1${route}`, { + method, redirect: 'error', signal: AbortSignal.timeout(60_000), + headers: { Authorization: auth, ...(body && !(body instanceof FormData) ? { 'Content-Type': 'application/json' } : {}) }, + body: body instanceof FormData ? body : body === undefined ? undefined : JSON.stringify(body), + }); + if (missing && response.status === 404) return null; + if (!response.ok) { + const error = new Error(`Gitea ${method} ${route.split('?')[0]} returned HTTP ${response.status}`); + error.status = response.status; + throw error; + } + if (response.status === 204) return null; + return response.json(); +} + +// Keep credentials on this Gitea origin; release storage may redirect to a CDN. +export async function download(url, token = process.env.GITEA_TOKEN, limit = 512 * 1024 * 1024) { + let current = new URL(url); + for (let redirects = 0; redirects <= 5; redirects++) { + if (current.protocol !== 'https:' || current.username || current.password) throw new Error('Invalid release download URL'); + const response = await fetch(current, { + redirect: 'manual', signal: AbortSignal.timeout(120_000), + headers: current.origin === server ? { Authorization: authorization(token) } : {}, + }); + if ([301, 302, 303, 307, 308].includes(response.status)) { + const location = response.headers.get('location'); + await response.body?.cancel(); + if (!location) throw new Error('Release redirect has no location'); + current = new URL(location, current); + continue; + } + if (!response.ok) throw new Error(`Release download returned HTTP ${response.status}`); + const chunks = []; let size = 0; + for await (const chunk of response.body) { + size += chunk.length; + if (size > limit) throw new Error('Release download exceeds declared size limit'); + chunks.push(chunk); + } + return Buffer.concat(chunks); + } + throw new Error('Too many release download redirects'); +} + +let localAuthorization; +function authorization(token) { + if (token) return `token ${token}`; + if (process.env.GITEA_ACTIONS === 'true') throw new Error('Workflow token is missing'); + if (!localAuthorization) { + const output = execFileSync('git', ['-c', 'credential.interactive=false', 'credential', 'fill'], { + input: `url=${server}\n\n`, encoding: 'utf8', timeout: 10_000, + env: { ...process.env, GIT_TERMINAL_PROMPT: '0' }, stdio: ['pipe', 'pipe', 'ignore'], + }); + const fields = Object.fromEntries(output.trim().split('\n').map(line => { + const separator = line.indexOf('='); return [line.slice(0, separator), line.slice(separator + 1)]; + })); + if (!fields.username || !fields.password) throw new Error('Set GITEA_TOKEN or configure Git HTTPS credentials'); + localAuthorization = `Basic ${Buffer.from(`${fields.username}:${fields.password}`).toString('base64')}`; + } + return localAuthorization; +} + +export async function notifyCatalog(repository, tag) { + await api(`/repos/${repositoryPath(catalogRepository)}/actions/workflows/sync-release.yml/dispatches`, { + token: process.env.CATALOG_TOKEN, + method: 'POST', body: { ref: 'main', inputs: { repository, tag } }, + }); + console.log(`Requested catalog registration: ${repository}@${tag}`); +} diff --git a/tools/harmony-ui.test.mjs b/tools/harmony-ui.test.mjs deleted file mode 100644 index 5f61c1b..0000000 --- a/tools/harmony-ui.test.mjs +++ /dev/null @@ -1,506 +0,0 @@ -import { test, afterEach } from "node:test"; -import assert from "node:assert/strict"; -import { mkdtemp, cp, readFile, rm } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import path from "node:path"; -import { pathToFileURL, fileURLToPath } from "node:url"; -import { setImmediate as flush } from "node:timers/promises"; -import { JSDOM } from "jsdom"; - -const root = fileURLToPath(new URL("../", import.meta.url)); -let cleanup; -afterEach(async () => { - if (cleanup) { - await cleanup(); - cleanup = null; - } -}); -async function settle() { - for (let i = 0; i < 6; i++) await flush(); -} -async function app(t) { - t.mock.timers.enable({ apis: ["setTimeout"] }); - const directory = await mkdtemp(path.join(tmpdir(), "harmony-ui-")); - await cp(path.join(root, "curios/harmony-connector/ui"), directory, { - recursive: true, - }); - await cp( - path.dirname(fileURLToPath(import.meta.resolve("@trove/plugin-sdk"))), - path.join(directory, "sdk"), - { recursive: true }, - ); - const dom = new JSDOM( - await readFile(path.join(directory, "index.html"), "utf8"), - { url: "https://curio.test" }, - ); - globalThis.window = dom.window; - globalThis.document = dom.window.document; - const device = { - id: "7:3", - name: "daniel", - model: "HUAWEI Pura 70 Ultra", - manufacturer: "HUAWEI", - }; - const storages = [{ id: 1, name: "内部存储", free: 1e9, capacity: 2e9 }]; - const model = { - hdcAvailable: false, - hdcConnected: true, - confirmed: false, - parameters: [], - attached: true, - connection: null, - calls: [], - job: null, - busy: false, - monitorWait: null, - screenActive: false, - screenTarget: "", - screenError: null, - screenOpenWait: null, - openedUrls: [], - selectedDirectory: "/fixture/folder", - configureError: null, - }; - const { createMockBridge } = await import( - pathToFileURL(path.join(directory, "sdk/index.js")) - ); - const handler = (method, fn) => [ - `@self.${method}`, - async (params) => { - model.calls.push(method); - model.parameters.push([method, structuredClone(params)]); - return structuredClone(await fn(params)); - }, - ]; - window.__TROVE_CONTEXT__ = { - curioId: "dev.trove.harmony-connector", - version: "0.1.0", - instanceId: "test", - platform: "macos", - }; - window.__TROVE_BRIDGE__ = createMockBridge( - Object.fromEntries([ - handler("hdc.screenWindowOpen", async ({target}) => { - if (model.screenOpenWait) await model.screenOpenWait; - if (model.screenError) throw model.screenError; - model.screenActive = true; - model.screenTarget = target; - return { active: true, ready: true, target }; - }), - handler("hdc.screenWindowStop", () => ({ active: (model.screenActive = false) })), - handler("hdc.screenWindowStatus", () => ({ active: model.screenActive, ready: model.screenActive, target: model.screenTarget, error: model.screenError })), - handler("hdc.status", () => ({ busy: false, job: null })), - handler("hdc.tool", () => ({ - available: model.hdcAvailable, - version: "HDC test", - path: "/fixture/hdc", - })), - handler("hdc.configure", () => { - if (model.configureError) throw model.configureError; - model.hdcAvailable = true; - return { available: true, version: "HDC test", path: "/fixture/hdc" }; - }), - [ - "host.runtime.openUrl", - ({ url }) => { - model.openedUrls.push(url); - return { ok: true }; - }, - ], - handler("hdc.devices", () => ({ - devices: model.hdcConnected - ? [{ id: "phone", transport: "USB", state: "Connected" }] - : [], - })), - handler("hdc.inspect", () => ({ - capabilities: Object.fromEntries( - ["apps", "launch", "logs", "report", "screenshot"].map((key) => [ - key, - { available: true }, - ]), - ), - info: { text: "Test device" }, - })), - handler("hdc.apps", () => ({ apps: ["com.example.app"] })), - handler("hdc.app", () => ({ - bundle: "com.example.app", - versionCode: 1, - versionName: "1.0", - debug: false, - abilities: [{ name: "Home", module: "entry" }], - details: {}, - })), - handler("hdc.package", () => ({ - bundle: "com.example.app", - versionCode: 2, - versionName: "2.0", - sha256: "confirmed-sha256", - size: 100, - })), - handler("hdc.start", () => ({ id: 9 })), - ["host.dialog.confirm", () => ({ confirmed: model.confirmed })], - ["host.dialog.openDirectory", () => ({ path: model.selectedDirectory })], - handler("info", () => ({ connection: model.connection, busy: false })), - handler("devices", () => ({ devices: model.attached ? [device] : [] })), - handler("connect", () => (model.connection = { device, storages })), - handler("disconnect", () => { - model.connection = null; - return { disconnected: true, busy: model.busy }; - }), - handler("list", () => ({ - entries: [ - { - id: 2, - parent: 0xffffffff, - storage: 1, - name: "旅行.pdf", - size: 100, - folder: false, - }, - ], - total: 1, - })), - handler("transferStatus", () => model.job), - handler("transfer", () => { - model.job = { id: 1, state: "running", sent: 0, total: 100 }; - return { id: 1 }; - }), - ["host.dialog.openFile", () => ({ path: "/fixture/file.txt" })], - handler("monitor", async () => { - const snapshot = { - connection: model.connection, - devices: model.attached ? [device] : [], - busy: model.busy, - }; - if (!model.attached) model.connection = null; - if (model.monitorWait) { - await model.monitorWait; - return snapshot; - } - return { ...snapshot, connection: model.connection }; - }), - ]), - ); - cleanup = async () => { - dom.window.dispatchEvent(new dom.window.Event("pagehide")); - dom.window.close(); - delete globalThis.window; - delete globalThis.document; - await rm(directory, { recursive: true, force: true }); - }; - await import(pathToFileURL(path.join(directory, "app.js"))); - await settle(); - const $ = (id) => document.getElementById(id); - const click = async (id) => { - $(id).click(); - await settle(); - }; - const tick = async () => { - t.mock.timers.tick(2500); - await settle(); - }; - const connect = async () => { - await click("scan"); - await click("connect"); - assert.equal($("browser").hidden, false); - }; - return { $, click, tick, connect, model }; -} -test("opening the plugin discovers already attached devices without clicking scan", async (t) => { - const { $ } = await app(t); - assert.equal($("device-picker").hidden, false); - assert.equal($("device-select").options.length, 1); -}); -test("plugging in and unplugging refreshes the picker without manual scanning", async (t) => { - const { $, tick, model } = await app(t); - model.attached = false; - await tick(); - assert.equal($("device-picker").hidden, true); - model.attached = true; - await tick(); - assert.equal($("device-picker").hidden, false); - model.attached = false; - await tick(); - assert.equal($("device-select").options.length, 0); -}); -test("unplugging an idle connected phone clears the browser and connected badge", async (t) => { - const { $, connect, tick, model } = await app(t); - await connect(); - model.attached = false; - await tick(); - assert.equal($("browser").hidden, true); - assert.equal($("device-dot").classList.contains("connected"), false); - assert.equal($("files").children.length, 0); - assert.equal($("device-select").options.length, 0); -}); -test("manual disconnect clears files, filters and completed transfers", async (t) => { - const { $, connect, click, tick, model } = await app(t); - await connect(); - await click("upload"); - model.job = { - id: 1, - state: "completed", - result: { name: "旅行.pdf", bytes: 100 }, - }; - await tick(); - assert.equal($("transfer").hidden, false); - $("search").value = "旅行"; - await click("disconnect"); - await tick(); - assert.equal($("browser").hidden, true); - assert.equal($("files").children.length, 0); - assert.equal($("search").value, ""); - assert.equal($("transfer").hidden, true); - assert.equal($("footer-status").textContent.includes("已连接"), false); -}); -test("unplugging after a completed transfer clears the old success panel", async (t) => { - const { $, connect, click, tick, model } = await app(t); - await connect(); - await click("upload"); - model.job = { - id: 1, - state: "completed", - result: { name: "旅行.pdf", bytes: 100 }, - }; - await tick(); - assert.equal($("transfer").hidden, false); - model.attached = false; - await tick(); - assert.equal($("transfer").hidden, true); -}); -test("connected device displays the friendly name and actual model separately", async (t) => { - const { $, connect } = await app(t); - await connect(); - assert.equal($("device-name").textContent, "daniel"); - assert.equal($("device-model").textContent, "HUAWEI Pura 70 Ultra"); - assert.equal($("device-model").hidden, false); -}); -test("an in-flight USB snapshot cannot restore a manually disconnected session", async (t) => { - const { $, connect, click, tick, model } = await app(t); - await connect(); - let resolve; - model.monitorWait = new Promise((done) => { - resolve = done; - }); - await tick(); - const disconnect = click("disconnect"); - resolve(); - await disconnect; - await settle(); - assert.equal($("browser").hidden, true); - assert.equal($("files").children.length, 0); -}); -test("closing the UI stops automatic scanning", async (t) => { - const { tick, model } = await app(t); - const before = model.calls.length; - window.dispatchEvent(new window.Event("pagehide")); - await tick(); - await tick(); - assert.equal(model.calls.length, before); -}); -test("USB disappearance during a transfer clears the browser while cleanup is pending", async (t) => { - const { $, connect, click, tick, model } = await app(t); - await connect(); - await click("upload"); - model.attached = false; - model.busy = true; - await tick(); - assert.equal($("browser").hidden, true); - assert.equal($("device-dot").classList.contains("connected"), false); - assert.equal($("scan").disabled, true); - assert.equal($("footer-status").textContent.includes("设备已断开"), true); -}); -test("manual disconnect keeps the UI responsive while USB cleanup is pending", async (t) => { - const { $, connect, click, tick, model } = await app(t); - await connect(); - model.busy = true; - const scans = model.calls.filter((method) => method === "devices").length; - await click("disconnect"); - assert.equal($("browser").hidden, true); - assert.equal($("files").children.length, 0); - assert.equal($("scan").disabled, true); - assert.equal($("footer-status").textContent.includes("设备已断开"), true); - assert.equal( - model.calls.filter((method) => method === "devices").length, - scans, - ); - model.busy = false; - await tick(); - assert.equal($("connect").disabled, false); - assert.equal($("browser").hidden, true); -}); - -test("whole folder upload uses the directory picker and exposes transfer metrics", async (t) => { - const { $, click, connect, model, tick } = await app(t); - await connect(); - await click("upload-folder"); - assert.equal( - model.parameters.find(([name]) => name === "transfer")[1].path, - "/fixture/folder", - ); - model.job.statistics = { - bytesPerSecond: 1024, - averageBytesPerSecond: 1024, - elapsedSeconds: 2, - remainingSeconds: 3, - filesCompleted: 1, - filesTotal: 4, - currentFile: "nested/data.bin", - }; - await tick(); - assert.match($("transfer-current").textContent, /nested\/data.bin/); - assert.match($("transfer-metrics").textContent, /KB\/s/); -}); -test("management is optional and missing HDC does not disable file transfers", async (t) => { - const { $, click, connect, model } = await app(t); - assert.ok(!model.calls.some((name) => name.startsWith("hdc."))); - await click("tab-management"); - assert.match($("hdc-tool-status").textContent, /未发现/); - assert.equal($("app-install").disabled, true); - await click("tab-files"); - await connect(); - assert.equal($("browser").hidden, false); -}); -test("missing HDC guides official download and configuring an extracted toolkit", async (t) => { - const { $, click, model } = await app(t); - await click("tab-management"); - assert.equal($("hdc-setup")?.hidden, false); - assert.match($("hdc-setup").textContent, /Mac.*ARM64/); - assert.match($("hdc-setup").textContent, /解压/); - assert.match($("hdc-setup").textContent.replace(/\s+/g, " "), /USB 调试/); - await click("hdc-download"); - assert.deepEqual(model.openedUrls, [ - "https://developer.huawei.com/consumer/cn/download/command-line-tools-for-hmos", - ]); - assert.equal($("hdc-setup").hidden, false); - assert.ok(!model.calls.includes("hdc.devices")); - await click("hdc-directory"); - assert.deepEqual( - model.parameters.find(([name]) => name === "hdc.configure")[1], - { - path: "/fixture/folder", - }, - ); - assert.equal($("hdc-setup").hidden, true); - assert.equal($("hdc-target").options.length, 1); -}); -test("refresh rechecks tool availability and clears stale devices if the tool disappears", async (t) => { - const { $, click, model } = await app(t); - await click("tab-management"); - await click("hdc-refresh"); - assert.ok(!model.calls.includes("hdc.devices")); - model.hdcAvailable = true; - await click("hdc-refresh"); - assert.equal($("hdc-setup").hidden, true); - assert.equal($("screen-start").disabled, false); - model.hdcAvailable = false; - await click("hdc-refresh"); - assert.equal($("hdc-setup").hidden, false); - assert.equal($("hdc-target").options.length, 0); - assert.equal($("screen-start").disabled, true); -}); -test("cancelled or invalid toolkit selection preserves installation guidance", async (t) => { - const { $, click, model } = await app(t); - await click("tab-management"); - model.selectedDirectory = null; - await click("hdc-directory"); - assert.ok(!model.calls.includes("hdc.configure")); - model.selectedDirectory = "/fixture/wrong"; - model.configureError = new Error("未找到 HDC,请选择解压后的工具目录。"); - await click("hdc-directory"); - assert.equal($("hdc-setup").hidden, false); - assert.ok(!model.calls.includes("hdc.devices")); - assert.match(document.body.textContent, /未找到 HDC/); -}); -test("application installation requires confirmation and pins the reviewed package hash", async (t) => { - const { $, click, model } = await app(t); - model.hdcAvailable = true; - await click("tab-management"); - await click("app-install"); - assert.ok(!model.calls.includes("hdc.start")); - model.confirmed = true; - await click("app-install"); - const params = model.parameters.find(([name]) => name === "hdc.start")[1]; - assert.deepEqual(params, { - kind: "install", - target: "phone", - path: "/fixture/file.txt", - sha256: "confirmed-sha256", - }); - assert.equal($("app-install").disabled, true); -}); -test("device disconnect clears app details and prevents stale target operations", async (t) => { - const { $, click, model } = await app(t); - model.hdcAvailable = true; - await click("tab-management"); - $("apps-list").querySelector("button").click(); - await settle(); - assert.equal($("app-detail").hidden, false); - assert.equal($("app-export").disabled, true); - model.hdcConnected = false; - t.mock.timers.tick(3500); - await settle(); - assert.equal($("app-detail").hidden, true); - assert.equal($("app-install").disabled, true); - assert.equal($("app-launch").disabled, true); -}); - -test("screen opens a separate window with default pointer control", async (t) => { - const { $, click, model } = await app(t); - model.hdcAvailable = true; - await click("tab-management"); - await click("screen-start"); - assert.equal(model.screenActive, true); - assert.equal($("screen-start").textContent, "显示投屏窗口"); - assert.match($("screen-status").textContent, /默认鼠标控制/); - assert.equal($("screen-stop").disabled, false); - assert.ok(!model.calls.includes("hdc.screenFrame")); -}); - -test("switching management tabs and hiding the page leaves the independent window open", async (t) => { - const { click, model, tick } = await app(t); - model.hdcAvailable = true; - await click("tab-management"); - await click("screen-start"); - await click("tab-files"); - window.document.dispatchEvent(new window.Event("visibilitychange")); - await tick(); - assert.equal(model.screenActive, true); - assert.ok(!model.calls.includes("hdc.screenWindowStop")); -}); - -test("explicitly stopping a window permits reconnecting without a USB replug", async (t) => { - const { $, click, model } = await app(t); - model.hdcAvailable = true; - await click("tab-management"); - await click("screen-start"); - await click("screen-stop"); - assert.equal(model.screenActive, false); - assert.equal($("screen-start").disabled, false); - await click("screen-start"); - assert.equal(model.screenActive, true); -}); - -test("closing the native window is reflected by the launcher", async (t) => { - const { $, click, model, tick } = await app(t); - model.hdcAvailable = true; - await click("tab-management"); - await click("screen-start"); - model.screenActive = false; - await tick(); - assert.equal($("screen-stop").disabled, true); - assert.equal($("screen-start").textContent, "打开投屏窗口"); -}); - -test("a failed window start leaves the launcher available to retry", async (t) => { - const { $, click, model } = await app(t); - model.hdcAvailable = true; - await click("tab-management"); - model.screenError = new Error("视频连接失败"); - await click("screen-start"); - assert.equal($("screen-start").disabled, false); - assert.equal($("screen-stop").disabled, true); - model.screenError = null; - await click("screen-start"); - assert.equal(model.screenActive, true); -}); diff --git a/tools/host-check.rs b/tools/host-check.rs deleted file mode 100644 index e584c18..0000000 --- a/tools/host-check.rs +++ /dev/null @@ -1,616 +0,0 @@ -//! Test adapter around the real Trove package manager, service bus and UDS supervisor. -//! stdin/stdout carry SDK bridge messages only; all state lives in a temporary directory. -use anyhow::{anyhow, ensure, Context, Result}; -use serde_json::{json, Value}; -use std::{ - collections::HashMap, - future::Future, - path::{Path, PathBuf}, - sync::Arc, - time::Instant, -}; -use tokio::io::{AsyncBufReadExt, BufReader}; -use trove::{ - package::{AppPaths, InstalledPackage, PackageManager, SourceConfig, SourceKind}, - protocol::ProtocolMessage, - runtime::{BackendRegistration, BackendSupervisor}, - service::{ - builtin::{fs::FsService, runtime::RuntimeService, storage::Storage}, - Caller, CurioRegistration, ProviderRef, ServiceBus, ServiceDescriptor, - }, - transport::{uds::UnixGateway, Outbound, Peer}, -}; - -fn emit(value: Value) { - println!("{}", value); -} -fn read_json(path: impl AsRef) -> Result { - Ok(serde_json::from_slice(&std::fs::read(path)?)?) -} -fn source(id: &str, index: &Path) -> SourceConfig { - SourceConfig { - id: id.into(), - name: id.into(), - kind: SourceKind::Local, - index: index.display().to_string(), - enabled: true, - priority: 0, - refresh_interval_seconds: 3600, - } -} -fn target() -> &'static str { - if cfg!(target_arch = "aarch64") { - "aarch64-apple-darwin" - } else { - "x86_64-apple-darwin" - } -} -fn expected_error(result: trove::error::Result, code: &str) -> Result { - match result { - Err(error) => { - ensure!( - error.code == code, - "Expected {code}, got {}: {}", - error.code, - error.message - ); - Ok(json!({"code":error.code})) - } - Ok(_) => Err(anyhow!("Expected {code}, operation succeeded")), - } -} -async fn check>>( - rows: &mut Vec, - name: impl Into, - operation: F, -) { - let name = name.into(); - let start = Instant::now(); - let row = match operation.await { - Ok(value) => { - json!({"name":name,"status":"pass","value":value,"durationMs":start.elapsed().as_millis()}) - } - Err(error) => { - json!({"name":name,"status":"fail","error":{"message":format!("{error:#}")},"durationMs":start.elapsed().as_millis()}) - } - }; - eprintln!("{} {}", row["status"].as_str().unwrap(), name); - rows.push(row); -} - -async fn package_checks( - dist: &Path, - paths: &AppPaths, - packages: &PackageManager, -) -> Result> { - let suite = read_json(dist.join("suite.json"))?; - let mut rows = Vec::new(); - for entry in suite.as_array().context("suite is an array")? { - if entry["directory"] == "test-upgrade-v2" { - continue; - } - check( - &mut rows, - format!("安装并读取 {}", entry["directory"].as_str().unwrap()), - async { - let package = packages - .install_local(Path::new(entry["file"].as_str().unwrap()), true) - .await?; - ensure!( - package.manifest.id == entry["manifest"]["id"], - "Installed ID changed" - ); - ensure!( - package.manifest.development.is_none() - && package.manifest.build.is_none() - && package.manifest.package.is_none(), - "Source-only config leaked into package" - ); - if let Some(ui) = &package.manifest.ui { - let resolved = trove::runtime::assets::resolve_asset( - &package.root, - &package.manifest.id, - &ui.entry, - &format!("/{}/{}", package.manifest.id, ui.entry), - )?; - ensure!( - std::fs::read_to_string(resolved)?.contains("./main.js"), - "UI entry is not the test console" - ); - let base = Path::new(&ui.entry).parent().unwrap(); - for relative in [ - "main.js", - "config.js", - "checks.js", - "style.css", - "sdk/index.js", - "sdk/bridge.js", - ] { - ensure!( - package.root.join(base).join(relative).is_file(), - "Missing UI resource {relative}" - ); - } - expected_error( - trove::runtime::assets::resolve_asset( - &package.root, - &package.manifest.id, - &ui.entry, - &format!("/{}/curio.json", package.manifest.id), - ), - "asset.forbidden", - )?; - } - if let Some(backend) = &package.manifest.backend { - use std::os::unix::fs::PermissionsExt; - ensure!( - std::fs::metadata(package.root.join(&backend.entry))? - .permissions() - .mode() - & 0o111 - != 0, - "Backend isn't executable after install" - ); - } - Ok(json!({"id":package.manifest.id,"version":package.active_version})) - }, - ) - .await; - } - check( - &mut rows, - "重复安装同版本被拒绝且保留当前状态", - async { - let before = std::fs::read(paths.registry_file())?; - let value = expected_error( - packages - .install_local(&dist.join("packages/dev.trove.qa.host-1.0.0.curio"), true) - .await, - "package.version_exists", - )?; - ensure!( - std::fs::read(paths.registry_file())? == before, - "Duplicate install changed registry" - ); - ensure!( - packages.get("dev.trove.qa.host")?.active_version == "1.0.0", - "Duplicate install lost active version" - ); - Ok(value) - }, - ) - .await; - for case in read_json(dist.join("invalid/cases.json"))? - .as_array() - .unwrap() - { - check( - &mut rows, - format!("拒绝无效包 {}", case["name"].as_str().unwrap()), - async { - let before = std::fs::read(paths.registry_file())?; - let result = packages - .install_local(Path::new(case["file"].as_str().unwrap()), true) - .await; - let value = expected_error(result, case["expectedError"].as_str().unwrap())?; - ensure!( - std::fs::read(paths.registry_file())? == before, - "Failed install changed registry" - ); - ensure!( - std::fs::read_dir(paths.support.join(".staging"))? - .next() - .is_none(), - "Failed install leaked staging files" - ); - Ok(value) - }, - ) - .await; - } - for case in read_json(dist.join("invalid/source-cases.json"))? - .as_array() - .unwrap() - { - check( - &mut rows, - format!("拒绝异常软件源 {}", case["name"].as_str().unwrap()), - async { - let sandbox = tempfile::tempdir()?; - let manager = - PackageManager::open(AppPaths::under(sandbox.path()), "0.1.0", target())?; - let id = case["id"].as_str().unwrap(); - manager.add_source(source(id, Path::new(case["file"].as_str().unwrap())))?; - manager.refresh_source(id).await?; - let result = manager - .install_from_source( - id, - case["curioId"].as_str().unwrap_or("dev.trove.qa.host"), - "1.0.0", - true, - ) - .await; - let value = expected_error(result, case["expectedError"].as_str().unwrap())?; - ensure!( - manager.list().is_empty(), - "Invalid source installed a package" - ); - Ok(value) - }, - ) - .await; - } - check( - &mut rows, - "本地软件源刷新、安装、缓存恢复和禁用", - async { - let sandbox = tempfile::tempdir()?; - let source_paths = AppPaths::under(sandbox.path()); - let manager = PackageManager::open(source_paths.clone(), "0.1.0", target())?; - manager.add_source(source("trove-qa", &dist.join("index.json")))?; - manager.refresh_source("trove-qa").await?; - let count = manager.catalog().len(); - ensure!(count == 8, "Expected 8 normal Curio IDs, got {count}"); - let installed = manager - .install_from_source("trove-qa", "dev.trove.qa.host", "1.0.0", true) - .await?; - ensure!( - installed.source_id.as_deref() == Some("trove-qa"), - "Source origin was lost" - ); - drop(manager); - let manager = PackageManager::open(source_paths, "0.1.0", target())?; - ensure!( - manager.catalog().len() == count && manager.list().len() == 1, - "Durable source cache or installation lost" - ); - let mut config = source("trove-qa", &dist.join("index.json")); - config.enabled = false; - manager.add_source(config)?; - expected_error( - manager - .install_from_source("trove-qa", "dev.trove.qa.isolation", "1.0.0", true) - .await, - "source.disabled", - )?; - Ok(json!({"catalogIds":count,"sourceCacheRestored":true})) - }, - ) - .await; - check(&mut rows, "HTTP 软件源刷新、下载和校验安装", async { - let sandbox = tempfile::tempdir()?; - let manager = PackageManager::open(AppPaths::under(sandbox.path()), "0.1.0", target())?; - let mut config = source("trove-qa", &dist.join("index.json")); - config.kind = SourceKind::Http; - config.index = std::env::var("QA_HTTP_SOURCE_URL")?; - manager.add_source(config)?; - manager.refresh_source("trove-qa").await?; - let package = manager - .install_from_source("trove-qa", "dev.trove.qa.host", "1.0.0", true) - .await?; - ensure!( - package.manifest.id == "dev.trove.qa.host", - "HTTP install identity changed" - ); - Ok(json!({"downloaded":true,"hashVerified":true,"version":package.active_version})) - }) - .await; - check(&mut rows, "升级暂存、激活、回滚、卸载与数据保留", async { - let id = "dev.trove.qa.upgrade"; - let data = paths.data_dir(id); - std::fs::create_dir_all(&data)?; - let marker = data.join("qa-upgrade-marker.txt"); - std::fs::write(&marker, "baseline-1.0.0")?; - let file = dist.join("packages/dev.trove.qa.upgrade-1.1.0.curio"); - let pending = packages.install_local(&file, false).await?; - ensure!(pending.active_version == "1.0.0" && pending.pending_version.as_deref() == Some("1.1.0"), "Update wasn't staged"); - ensure!(packages.activate(id, "1.1.0")?.active_version == "1.1.0", "Activation failed"); - ensure!(packages.rollback(id)?.active_version == "1.0.0", "Rollback failed"); - ensure!(std::fs::read_to_string(&marker)? == "baseline-1.0.0", "Update changed data"); - packages.set_enabled(id, false)?; - ensure!(!packages.get(id)?.enabled, "Disable not persisted"); - packages.set_enabled(id, true)?; - packages.uninstall(id, false)?; - ensure!(marker.is_file(), "Keep-data uninstall deleted data"); - packages.install_local(&dist.join("packages/dev.trove.qa.upgrade-1.0.0.curio"), true).await?; - ensure!(std::fs::read_to_string(&marker)? == "baseline-1.0.0", "Reinstall changed data"); - packages.uninstall(id, true)?; - ensure!(!data.exists(), "Remove-data uninstall retained data"); - expected_error(packages.get(id), "package.not_installed")?; - Ok(json!({"staged":true,"activated":true,"rolledBack":true,"reinstalled":true,"dataRemoved":true})) - }).await; - Ok(rows) -} - -fn register( - package: &InstalledPackage, - paths: &AppPaths, - runtime: &Arc, - bus: &Arc, -) -> Result<()> { - let manifest = &package.manifest; - bus.register_curio(CurioRegistration { - curio_id: manifest.id.clone(), - version: manifest.version.clone(), - enabled: package.enabled, - provides: package - .contracts - .iter() - .map(|contract| ServiceDescriptor { - id: contract.service.clone(), - version: contract.version.clone(), - provider: ProviderRef::CurioBackend { - curio_id: manifest.id.clone(), - }, - contract: contract.clone(), - default_timeout_ms: 5000, - }) - .collect(), - requires: manifest - .services - .requires - .iter() - .map(|r| (r.id.clone(), r.version.clone())) - .collect(), - })?; - if let Some(definition) = &manifest.backend { - runtime.register(BackendRegistration { - curio_id: manifest.id.clone(), - version: manifest.version.clone(), - root: package.root.clone(), - definition: definition.clone(), - data_dir: paths.data_dir(&manifest.id), - cache_dir: paths.cache_dir(&manifest.id), - log_dir: paths.log_dir(&manifest.id), - instance_id: None, - enabled: package.enabled, - command: None, - })?; - } - Ok(()) -} - -#[tokio::main] -async fn main() -> Result<()> { - let args: Vec = std::env::args().collect(); - if args.get(1).map(String::as_str) == Some("--remote-source") { - return remote_source_check( - args.get(2).context("remote source URL required")?, - args.get(3).context("remote source ID required")?, - args.get(4).map(String::as_str).unwrap_or("http"), - ) - .await; - } - let dist = PathBuf::from(std::env::args().nth(1).context("dist directory required")?) - .canonicalize()?; - let sandbox = tempfile::Builder::new() - .prefix("trove-qa-") - .tempdir_in("/tmp")?; - let paths = AppPaths::under(sandbox.path()); - let packages = Arc::new(PackageManager::open(paths.clone(), "0.1.0", target())?); - let package_results = package_checks(&dist, &paths, &packages).await?; - let runtime = BackendSupervisor::new(sandbox.path().join("host.sock")); - let bus = ServiceBus::new(runtime.clone()); - bus.register_builtin(Arc::new(RuntimeService { - paths: paths.clone(), - packages: packages.clone(), - }))?; - bus.register_builtin(Arc::new(Arc::new(Storage::new(paths.support.join("data")))))?; - bus.register_builtin(Arc::new(FsService { - paths: paths.clone(), - }))?; - for package in packages.list() { - register(&package, &paths, &runtime, &bus)?; - } - let gateway = - UnixGateway::bind(runtime.socket_path().into(), runtime.clone(), bus.clone()).await?; - emit(json!({"ready":true,"packageResults":package_results,"temporaryRoot":sandbox.path()})); - let mut peers: HashMap> = HashMap::new(); - let mut lines = BufReader::new(tokio::io::stdin()).lines(); - while let Some(line) = lines.next_line().await? { - let envelope: Value = serde_json::from_str(&line)?; - let op = envelope["op"].as_str().unwrap_or(""); - if op == "shutdown" { - break; - } - if op == "send" { - let peer = peers - .get(envelope["connection"].as_str().unwrap_or("")) - .context("Unknown connection")? - .clone(); - let message = ProtocolMessage::from_value(envelope["message"].clone())?; - let bus = bus.clone(); - tokio::spawn(async move { - if let Err(error) = bus.dispatch(peer, message).await { - emit(json!({"fatal":error})); - } - }); - continue; - } - let result: Result = async { - let id = envelope["curioId"].as_str().unwrap_or(""); - match op { - "connect" => { - let package = packages.get(id)?; - let connection = envelope["connection"].as_str().context("connection required")?.to_string(); - let (outbound, mut receiver) = Outbound::channel(512, 16 * 1024 * 1024); - let peer = Arc::new(Peer { id: connection.clone(), caller: Caller { curio_id: Some(id.into()), instance_id: Some(connection.clone()), endpoint: "ui".into() }, outbound, closed: Default::default(), features: vec!["call".into(),"cancel".into(),"subscribe".into()] }); - bus.attach_peer(peer.clone()); - peers.insert(connection.clone(), peer); - let key = connection.clone(); - tokio::spawn(async move { while let Some(outbound) = receiver.recv().await { emit(json!({"connection":key,"message":outbound.message})); } }); - Ok(json!({"curioId":id,"curioVersion":package.active_version,"instanceId":connection,"apiVersion":1,"mode":"production"})) - } - "disconnect" => { - let key = envelope["connection"].as_str().context("connection required")?; - bus.disconnect(key).await; peers.remove(key); Ok(json!({"disconnected":true})) - } - "status" => Ok(runtime.status(id).await), - "diagnostics" => Ok(bus.diagnostics()), - "strictContracts" => { - let enabled = envelope["value"].as_bool().context("strictContracts boolean required")?; - bus.strict_contracts(enabled); - Ok(json!({"strictContracts":enabled})) - } - "start" => { runtime.ensure_connected(id).await?; Ok(runtime.status(id).await) } - "retry" => { runtime.retry(id).await?; Ok(runtime.status(id).await) } - "stop" => { runtime.shutdown(id, "qa-test").await?; Ok(runtime.status(id).await) } - "enabled" => { - let enabled = envelope["value"].as_bool().context("enabled boolean required")?; - runtime.shutdown(id, "qa-toggle").await?; - let package = packages.set_enabled(id, enabled)?; - register(&package, &paths, &runtime, &bus)?; - Ok(json!({"enabled":enabled})) - } - _ => Err(anyhow!("Unknown operation {op}")), - } - }.await; - match result { - Ok(value) => emit(json!({"controlId":envelope["controlId"],"result":value})), - Err(error) => emit( - json!({"controlId":envelope["controlId"],"error":{"message":format!("{error:#}")}}), - ), - } - } - bus.shutdown().await; - runtime.shutdown_all().await; - gateway.shutdown().await; - Ok(()) -} - -async fn remote_source_check(url: &str, source_id: &str, kind: &str) -> Result<()> { - let kind = match kind { - "git" => SourceKind::Git, - "http" => SourceKind::Http, - _ => anyhow::bail!("Remote source kind must be git or http"), - }; - let sandbox = tempfile::Builder::new() - .prefix("trove-remote-qa-") - .tempdir_in("/tmp")?; - let paths = AppPaths::under(sandbox.path()); - let packages = Arc::new(PackageManager::open(paths.clone(), "0.1.0", target())?); - let runtime = BackendSupervisor::new(sandbox.path().join("host.sock")); - let bus = ServiceBus::new(runtime.clone()); - bus.register_builtin(Arc::new(RuntimeService { - paths: paths.clone(), - packages: packages.clone(), - }))?; - bus.register_builtin(Arc::new(Arc::new(Storage::new(paths.support.join("data")))))?; - let gateway = - UnixGateway::bind(runtime.socket_path().into(), runtime.clone(), bus.clone()).await?; - let mut rows = Vec::new(); - check(&mut rows, "从真实远程源读取索引", async { - packages.add_source(SourceConfig { - id: source_id.into(), - name: source_id.into(), - kind, - index: url.into(), - enabled: true, - priority: 0, - refresh_interval_seconds: 3600, - })?; - let refreshed = packages.refresh_source(source_id).await?; - ensure!(!refreshed.stale, "Remote source refresh used stale cache"); - ensure!( - !packages.catalog().is_empty(), - "Remote source has no installable plugins" - ); - Ok(json!({"url":url,"sourceId":source_id,"curioCount":packages.catalog().len()})) - }) - .await; - if rows.iter().all(|row| row["status"] == "pass") { - for entry in packages.catalog() { - // Our publication index puts new releases first. Install history oldest - // first so the runtime check below exercises the newest published build. - for version in entry.package.versions.into_iter().rev() { - check(&mut rows, format!("远程安装 {}@{}", entry.package.id, version.version), async { - let package = packages.install_from_source(source_id, &entry.package.id, &version.version, true).await?; - ensure!(package.source_id.as_deref() == Some(source_id), "Installation wasn't bound to the remote source"); - if let Some(ui) = &package.manifest.ui { - let asset = trove::runtime::assets::resolve_asset(&package.root, &package.manifest.id, &ui.entry, &format!("/{}/{}", package.manifest.id, ui.entry))?; - ensure!(asset.is_file(), "Installed UI entry is missing"); - } - Ok(json!({"id":package.manifest.id,"version":package.active_version,"sourceId":package.source_id})) - }).await; - } - } - for package in packages.list() { - register(&package, &paths, &runtime, &bus)?; - } - for package in packages - .list() - .into_iter() - .filter(|package| package.manifest.backend.is_some()) - { - check( - &mut rows, - format!("运行远程安装后端 {}", package.manifest.id), - async { - let context = trove::service::CallContext::new( - Caller { - curio_id: Some(package.manifest.id.clone()), - instance_id: None, - endpoint: "ui".into(), - }, - "remote-check", - ); - let result = bus - .call(context, "@self", "info", json!({}), Default::default()) - .await?; - ensure!( - result["curioId"] == package.manifest.id - && result["version"] == package.active_version, - "Backend identity doesn't match installed remote version" - ); - ensure!( - result["pid"].as_u64().unwrap_or(0) > 0, - "Backend didn't report a process" - ); - Ok(result) - }, - ) - .await; - } - if packages.get("dev.trove.qa.consumer").is_ok() - && packages.get("dev.trove.qa.provider").is_ok() - { - check(&mut rows, "远程安装插件间的服务调用", async { - let context = trove::service::CallContext::new( - Caller { - curio_id: Some("dev.trove.qa.consumer".into()), - instance_id: None, - endpoint: "ui".into(), - }, - "remote-consumer", - ); - let result = bus - .call( - context, - "dev.trove.qa.provider.probe", - "echo", - json!({"remote":true,"text":"远程安装 🧪"}), - Default::default(), - ) - .await?; - ensure!( - result == json!({"remote":true,"text":"远程安装 🧪"}), - "Cross-plugin response changed" - ); - Ok(result) - }) - .await; - } - } - bus.shutdown().await; - runtime.shutdown_all().await; - gateway.shutdown().await; - sandbox.close()?; - let failed = rows.iter().filter(|row| row["status"] == "fail").count(); - emit(json!({ - "schemaVersion":1,"sourceUrl":url,"sourceId":source_id,"sourceKind":kind,"target":target(), - "scope":"Remote source and packages via real PackageManager; installed native backends via real UDS. Desktop UI rendering is not included.", - "passed":rows.len()-failed,"failed":failed,"temporaryDataRemoved":true,"results":rows, - })); - ensure!(failed == 0, "Remote source verification failed"); - Ok(()) -} diff --git a/tools/invalid-fixtures.py b/tools/invalid-fixtures.py deleted file mode 100644 index 4970593..0000000 --- a/tools/invalid-fixtures.py +++ /dev/null @@ -1,104 +0,0 @@ -"""Generate deliberately invalid .curio archives. Never publish these in the normal source.""" -import copy -import json -from pathlib import Path -import stat -import zipfile - -ROOT = Path(__file__).resolve().parent.parent -OUT = ROOT / 'dist' / 'invalid' -OUT.mkdir(parents=True, exist_ok=True) -BASE = { - 'schemaVersion': 1, 'id': 'dev.trove.qa.invalid', 'name': '故意无效的测试包', - 'version': '1.0.0', 'engine': {'trove': '>=0.1.0 <1.0.0'}, - 'ui': {'entry': 'ui/index.html'}, -} -cases = [] - - -def archive(name, code, mutate=None, entries=None, raw=None): - manifest = copy.deepcopy(BASE) - if mutate: - mutate(manifest) - file = OUT / f'{name}.curio' - with zipfile.ZipFile(file, 'w', zipfile.ZIP_DEFLATED) as bundle: - bundle.writestr('curio.json', raw if raw is not None else json.dumps(manifest)) - bundle.writestr('ui/index.html', 'Invalid fixture') - for entry, content in entries or []: - bundle.writestr(entry, content) - cases.append({'name': name, 'file': str(file), 'expectedError': code}) - - -archive('malformed-manifest', 'package.invalid_manifest', raw='{broken') -archive('unknown-manifest-field', 'package.invalid_manifest', lambda m: m.update(unknown=True)) -archive('unsupported-schema', 'package.invalid_manifest', lambda m: m.update(schemaVersion=2)) -archive('reserved-id', 'package.invalid_id', lambda m: m.update(id='host.qa.invalid')) -archive('invalid-version', 'package.invalid_manifest', lambda m: m.update(version='not-semver')) -archive('incompatible-engine', 'package.engine_incompatible', lambda m: m.update(engine={'trove': '>=99.0.0'})) -archive('missing-ui', 'package.missing_entry', lambda m: m['ui'].update(entry='ui/missing.html')) -archive('no-ui-or-backend', 'package.invalid_manifest', lambda m: m.pop('ui')) -archive('manifest-path-traversal', 'package.invalid_manifest', lambda m: m['ui'].update(entry='../escape.html')) -archive('reserved-backend-env', 'package.invalid_manifest', lambda m: m.update(backend={ - 'entry': 'backend/probe', 'lifecycle': 'on-demand', 'transport': 'unix', 'env': {'TROVE_SESSION_TOKEN': 'override'} -})) -archive('service-without-backend', 'package.invalid_manifest', lambda m: m.update(services={'provides': [ - {'id': m['id'] + '.probe', 'version': '1.0.0', 'contract': 'contracts/probe.json'} -]})) - - -def provider(m): - m['backend'] = {'entry': 'backend/probe', 'lifecycle': 'on-demand', 'transport': 'unix'} - m['services'] = {'provides': [{'id': m['id'] + '.probe', 'version': '1.0.0', 'contract': 'contracts/probe.json'}]} - - -def wrong_namespace(m): - provider(m) - m['services']['provides'][0]['id'] = 'dev.someone.else.probe' - - -archive('service-namespace', 'package.invalid_manifest', wrong_namespace) -archive('missing-backend', 'package.missing_entry', provider) -archive('missing-contract', 'package.missing_entry', provider, [('backend/probe', 'placeholder')]) -contract = {'schemaVersion': 1, 'service': BASE['id'] + '.probe', 'version': '2.0.0', 'methods': {}} -archive('contract-version-mismatch', 'package.contract_mismatch', provider, - [('backend/probe', 'placeholder'), ('contracts/probe.json', json.dumps(contract))]) -contract['version'] = '1.0.0' -contract['methods'] = {'probe': {'params': {'type': 'not-a-json-type'}, 'result': {}}} -archive('invalid-contract-schema', 'package.invalid_contract', provider, - [('backend/probe', 'placeholder'), ('contracts/probe.json', json.dumps(contract))]) -archive('zip-path-traversal', 'package.unsafe_path', entries=[('../qa-escape.txt', 'must not escape')]) -archive('zip-absolute-path', 'package.unsafe_path', entries=[('/tmp/trove-qa-escape.txt', 'must not escape')]) -archive('zip-backslash-path', 'package.unsafe_path', entries=[('ui\\escape.txt', 'must not be accepted')]) -archive('zip-case-collision', 'package.unsafe_archive', entries=[('UI/INDEX.HTML', 'collides on macOS')]) -symlink = zipfile.ZipInfo('ui/link') -symlink.create_system = 3 -symlink.external_attr = (stat.S_IFLNK | 0o777) << 16 -archive('zip-symlink', 'package.unsafe_archive', entries=[(symlink, '../../outside')]) -truncated = OUT / 'truncated-archive.curio' -truncated.write_bytes(b'PK\x03\x04broken') -cases.append({'name': 'truncated-archive', 'file': str(truncated), 'expectedError': 'package.invalid_archive'}) -(OUT / 'cases.json').write_text(json.dumps(cases, ensure_ascii=False, indent=2) + '\n') - -# Valid source schemas carrying wrong download metadata test checks after index parsing. -baseline = json.loads((ROOT / 'dist/index.json').read_text()) -baseline['curios'] = [next(item for item in baseline['curios'] if item['id'] == 'dev.trove.qa.host')] -source_cases = [] -for name, field, value, expected in [ - ('hash-mismatch', 'sha256', '0' * 64, 'package.hash_mismatch'), - ('size-mismatch', 'size', 1, 'package.size_mismatch'), - ('wrong-target', 'target', 'x86_64-apple-darwin' if __import__('platform').machine() == 'arm64' else 'aarch64-apple-darwin', 'package.target_mismatch'), -]: - index = copy.deepcopy(baseline) - index['id'] = 'qa-' + name - index['curios'][0]['versions'][0]['artifacts'][0][field] = value - file = OUT / f'{name}-index.json' - file.write_text(json.dumps(index, ensure_ascii=False, indent=2) + '\n') - source_cases.append({'name': name, 'id': index['id'], 'file': str(file), 'expectedError': expected}) -index = copy.deepcopy(baseline) -index['id'] = 'qa-identity-mismatch' -index['curios'][0]['id'] = 'dev.trove.qa.wrong-identity' -file = OUT / 'identity-mismatch-index.json' -file.write_text(json.dumps(index, ensure_ascii=False, indent=2) + '\n') -source_cases.append({'name': 'identity-mismatch', 'id': index['id'], 'file': str(file), 'expectedError': 'package.identity_mismatch', 'curioId': 'dev.trove.qa.wrong-identity'}) -(OUT / 'source-cases.json').write_text(json.dumps(source_cases, ensure_ascii=False, indent=2) + '\n') -print(f'Generated {len(cases)} invalid archives and {len(source_cases)} adverse sources in {OUT}') diff --git a/tools/notify-release.mjs b/tools/notify-release.mjs new file mode 100644 index 0000000..1082166 --- /dev/null +++ b/tools/notify-release.mjs @@ -0,0 +1,7 @@ +import { api, notifyCatalog, repositoryPath } from './gitea.mjs'; +const repository = process.env.GITEA_REPOSITORY; +const tag = process.env.RELEASE_TAG; +if (!tag) throw new Error('RELEASE_TAG is required'); +const release = await api(`/repos/${repositoryPath(repository)}/releases/tags/${encodeURIComponent(tag)}`); +if (release.draft || !release.assets.some(asset => asset.name === 'trove-release.json')) throw new Error('A completed release with trove-release.json is required'); +await notifyCatalog(repository, tag); diff --git a/tools/pack-harmony.mjs b/tools/pack-harmony.mjs deleted file mode 100644 index e07298a..0000000 --- a/tools/pack-harmony.mjs +++ /dev/null @@ -1,47 +0,0 @@ -import { mkdir, readFile, writeFile } from "node:fs/promises"; -import { createHash } from "node:crypto"; -import { pathToFileURL } from "node:url"; -import path from "node:path"; -import { root, output, run, troveBin } from "./suite.mjs"; - -run(process.execPath, ["curios/harmony-connector/build.mjs"]); -const directory = path.join(root, "curios/harmony-connector"); -const manifest = JSON.parse( - await readFile(path.join(directory, "curio.json"), "utf8"), -); -const file = path.join( - output, - "packages", - `${manifest.id}-${manifest.version}-${process.arch}.curio`, -); -await mkdir(path.dirname(file), { recursive: true }); -run(troveBin, ["curio", "check", directory, "--json"]); -run(troveBin, [ - "curio", - "pack", - directory, - "--out", - file, - "--overwrite", - "--no-input", - "--json", -]); -const data = await readFile(file); -const entries = [ - { - directory: "harmony-connector", - manifest, - file, - artifact: { - target: `${process.arch === "arm64" ? "aarch64" : "x86_64"}-apple-darwin`, - url: pathToFileURL(file).href, - sha256: createHash("sha256").update(data).digest("hex"), - size: data.length, - }, - }, -]; -await writeFile( - path.join(output, "harmony-release.json"), - JSON.stringify(entries, null, 2) + "\n", -); -console.log("Release input: dist/harmony-release.json"); diff --git a/tools/pack.mjs b/tools/pack.mjs deleted file mode 100644 index 0163941..0000000 --- a/tools/pack.mjs +++ /dev/null @@ -1,37 +0,0 @@ -import { mkdir, readFile, writeFile } from 'node:fs/promises'; -import { createHash } from 'node:crypto'; -import { pathToFileURL } from 'node:url'; -import path from 'node:path'; -import { root, run, suite, output, troveBin } from './suite.mjs'; - -run(process.execPath, ['tools/build.mjs']); -await mkdir(path.join(output, 'packages'), { recursive: true }); -const entries = []; -for (const item of suite) { - const directory = path.join(root, 'curios', item.directory); - const manifest = JSON.parse(await readFile(path.join(directory, 'curio.json'), 'utf8')); - run(troveBin, ['curio', 'check', directory, '--json']); - const file = path.join(output, 'packages', `${manifest.id}-${manifest.version}.curio`); - run(troveBin, ['curio', 'pack', directory, '--out', file, '--overwrite', '--no-input', '--json']); - const bytes = await readFile(file); - entries.push({ ...item, manifest, file, artifact: { - target: manifest.backend ? `${process.arch === 'arm64' ? 'aarch64' : 'x86_64'}-apple-darwin` : 'any', - url: pathToFileURL(file).href, - sha256: createHash('sha256').update(bytes).digest('hex'), size: bytes.length, - } }); -} -const date = new Date().toISOString(); -function index(id, name, selected) { - const curios = []; - for (const { manifest: m, artifact } of selected) { - let curio = curios.find(curio => curio.id === m.id); - if (!curio) { curio = { id: m.id, name: m.name, description: m.description, versions: [] }; curios.push(curio); } - curio.versions.unshift({ version: m.version, engine: m.engine, releasedAt: date, artifacts: [artifact] }); - } - return { schemaVersion: 1, id, name, generatedAt: date, curios }; -} -await writeFile(path.join(output, 'index.json'), JSON.stringify(index('trove-qa', 'Trove 常规测试插件', entries.filter(item => !item.fault)), null, 2) + '\n'); -await writeFile(path.join(output, 'faults-index.json'), JSON.stringify(index('trove-qa-faults', 'Trove 故障注入插件(手动安装)', entries.filter(item => item.fault)), null, 2) + '\n'); -await writeFile(path.join(output, 'suite.json'), JSON.stringify(entries, null, 2) + '\n'); -run('python3', ['tools/invalid-fixtures.py']); -console.log(`\n常规软件源:${path.join(output, 'index.json')}\n故障软件源:${path.join(output, 'faults-index.json')}`); diff --git a/tools/prepare-release.mjs b/tools/prepare-release.mjs index 5db526e..af06c4e 100644 --- a/tools/prepare-release.mjs +++ b/tools/prepare-release.mjs @@ -1,87 +1,11 @@ -import { readFile, writeFile, mkdir, copyFile, rename, rm } from 'node:fs/promises'; -import { createHash } from 'node:crypto'; +import { mkdir, writeFile, rm } from 'node:fs/promises'; import { parseArgs } from 'node:util'; -import path from 'node:path'; -import { root, output } from './suite.mjs'; -import { appendRelease, artifactPath } from './release-catalog.mjs'; - -const { values } = parseArgs({ options: { - 'base-url': { type: 'string' }, - catalog: { type: 'string', default: 'source' }, - entries: { type: 'string', default: 'dist/suite.json' }, -} }); -const base = values['base-url'] ? new URL(values['base-url']) : null; -if (base && (base.protocol !== 'https:' || base.username || base.password || base.search || base.hash)) { - throw new Error('Publication base must be an HTTPS directory URL without credentials, query or fragment'); -} -if (base && !base.pathname.endsWith('/')) base.pathname += '/'; -const catalogDirectory = path.resolve(root, values.catalog); -const publishDirectory = path.join(output, 'release'); -const timestamp = new Date().toISOString(); -const specifications = [ - { file: 'index.json', id: 'trove-official', name: 'Trove 官方软件源' }, -]; -const catalogs = new Map(); -for (const spec of specifications) { - let index; - try { index = JSON.parse(await readFile(path.join(catalogDirectory, spec.file), 'utf8')); } - catch (error) { - if (error.code !== 'ENOENT') throw error; - index = { schemaVersion: 1, id: spec.id, name: spec.name, generatedAt: timestamp, curios: [] }; - } - if (index.id !== spec.id || index.schemaVersion !== 1 || !Array.isArray(index.curios)) throw new Error(`Unexpected catalog identity: ${spec.file}`); - catalogs.set(spec.file, index); -} -const entries = JSON.parse(await readFile(path.resolve(root, values.entries), 'utf8')); -const files = []; -// Validate every proposed addition before changing any catalog or copying artifacts. -for (const entry of entries) { - if (entry.fault) continue; - const bytes = await readFile(entry.file); - const sha256 = createHash('sha256').update(bytes).digest('hex'); - if (sha256 !== entry.artifact.sha256 || bytes.length !== entry.artifact.size) throw new Error(`Build metadata doesn't match ${entry.file}; run pack again`); - const relative = artifactPath(entry.manifest, entry.artifact.target); - const url = base ? new URL(relative.split('/').map(encodeURIComponent).join('/'), base).href : relative; - const spec = specifications[0]; - const artifact = { ...entry.artifact, url }; - catalogs.set(spec.file, appendRelease(catalogs.get(spec.file), entry.manifest, artifact, timestamp)); - files.push({ from: entry.file, relative, sha256, size: bytes.length, url }); -} -// Git publication keeps immutable packages alongside the canonical index. -if (!base) { - for (const file of files) { - try { - const existing = await readFile(path.join(catalogDirectory, file.relative)); - if (existing.length !== file.size || createHash('sha256').update(existing).digest('hex') !== file.sha256) { - throw new Error(`Published artifact differs: ${file.relative}; bump the plugin version`); - } - } catch (error) { - if (error.code !== 'ENOENT') throw error; - } - } -} -// Only the current approved publication plan belongs in the generated upload directory. -await rm(publishDirectory, { recursive: true, force: true }); -for (const file of files) { - const destination = path.join(publishDirectory, file.relative); - await mkdir(path.dirname(destination), { recursive: true }); - await copyFile(file.from, destination); - if (!base) { - const published = path.join(catalogDirectory, file.relative); - await mkdir(path.dirname(published), { recursive: true }); - await copyFile(file.from, published); - } -} -await mkdir(catalogDirectory, { recursive: true }); -await mkdir(publishDirectory, { recursive: true }); -for (const [file, index] of catalogs) { - const text = JSON.stringify(index, null, 2) + '\n'; - await writeFile(path.join(publishDirectory, file), text); - const temporary = path.join(catalogDirectory, `${file}.tmp`); - await writeFile(temporary, text); - await rename(temporary, path.join(catalogDirectory, file)); -} -await writeFile(path.join(publishDirectory, 'upload-plan.json'), JSON.stringify({ kind: base ? 'http' : 'git', baseUrl: base?.href, files }, null, 2) + '\n'); -console.log(`Prepared ${files.length} immutable artifacts in ${publishDirectory}`); -console.log(`Catalog history: ${catalogDirectory}`); -console.log(base ? 'Next: upload package files without overwrite, verify remote bytes, then atomically publish index.json.' : 'Next: commit source/index.json and source/packages/ together, push the repository, then verify the Git source.'); +import { releasePlan } from './release-plan.mjs'; +const { values } = parseArgs({ options: { entries: { type: 'string' }, tag: { type: 'string' } } }); +if (!values.entries) throw new Error('--entries is required'); +const { files, descriptor } = await releasePlan(values.entries, values.tag); +await rm('dist/release', { recursive: true, force: true }); +await mkdir('dist/release', { recursive: true }); +for (const [name, bytes] of files) await writeFile(`dist/release/${name}`, bytes); +console.log(`Prepared ${files.size} release files in dist/release/`); +console.log(`Create draft release ${descriptor.tag} at commit ${descriptor.commit}, upload every file, then publish.`); diff --git a/tools/preview-harmony.mjs b/tools/preview-harmony.mjs deleted file mode 100644 index 37c0417..0000000 --- a/tools/preview-harmony.mjs +++ /dev/null @@ -1,45 +0,0 @@ -import { cp, mkdir, readFile, writeFile } from "node:fs/promises"; -import { createServer } from "node:http"; -import path from "node:path"; -import { root, output } from "./suite.mjs"; -const directory = path.join(output, "harmony-ui-preview"); -await mkdir(directory, { recursive: true }); -await cp(path.join(root, "curios/harmony-connector/dist/ui"), directory, { - recursive: true, -}); -await cp( - path.join(root, "tools/fixtures/harmony-ui.mjs"), - path.join(directory, "fixture.mjs"), -); -const html = ( - await readFile(path.join(directory, "index.html"), "utf8") -).replace('src="./app.js"', 'src="./fixture.mjs"'); -await writeFile(path.join(directory, "index.html"), html); -createServer(async (request, response) => { - try { - const pathname = new URL(request.url, "http://localhost").pathname; - const file = path.resolve( - directory, - "." + decodeURIComponent(pathname === "/" ? "/index.html" : pathname), - ); - if (!file.startsWith(directory + path.sep)) { - response.writeHead(403).end(); - return; - } - const data = await readFile(file); - response.setHeader( - "Content-Type", - { - ".html": "text/html", - ".css": "text/css", - ".js": "text/javascript", - ".mjs": "text/javascript", - }[path.extname(file)] || "application/octet-stream", - ); - response.end(data); - } catch { - response.writeHead(404).end(); - } -}).listen(18770, "127.0.0.1", () => - console.log("UI fixture only: http://127.0.0.1:18770"), -); diff --git a/tools/publish-release.mjs b/tools/publish-release.mjs new file mode 100644 index 0000000..056328e --- /dev/null +++ b/tools/publish-release.mjs @@ -0,0 +1,40 @@ +import { readFile } from 'node:fs/promises'; +import { createHash } from 'node:crypto'; +import { api, download, repositoryPath } from './gitea.mjs'; +const config = JSON.parse(await readFile('release.json', 'utf8')); +const descriptorBytes = await readFile('dist/release/trove-release.json'); +const descriptor = JSON.parse(descriptorBytes); +const { repository, tag } = descriptor; +if (descriptor.schemaVersion !== 1 || repository !== config.repository) throw new Error('Unexpected release descriptor'); +const route = `/repos/${repositoryPath(repository)}`; +const files = new Map(); +for (const plugin of descriptor.plugins) { + for (const artifact of plugin.artifacts) { + if (!/^[a-zA-Z0-9._+-]+\.curio$/.test(artifact.asset)) throw new Error('Invalid asset filename'); + const bytes = await readFile(`dist/release/${artifact.asset}`); + if (bytes.length !== artifact.size || createHash('sha256').update(bytes).digest('hex') !== artifact.sha256) throw new Error('Package changed after release preparation'); + files.set(artifact.asset, bytes); + } +} +files.set('trove-release.json', descriptorBytes); +let release = await api(`${route}/releases/tags/${encodeURIComponent(tag)}`, { missing: true }); +if (!release) release = await api(`${route}/releases`, { method: 'POST', body: { + tag_name: tag, target_commitish: descriptor.commit, name: tag, draft: true, + prerelease: config.suite ? false : tag.slice(1).split('+')[0].includes('-'), + body: `${config.name} ${tag}\n\n通过 Trove 软件源安装和更新。`, +} }); +for (const [name, bytes] of files) { + const existing = release.assets.find(asset => asset.name === name); + if (existing) { + const published = await download(existing.browser_download_url, undefined, bytes.length); + if (!published.equals(bytes)) throw new Error(`Release asset is immutable: ${name}; publish a new version`); + continue; + } + if (!release.draft) throw new Error(`Published release is incomplete: ${name}; publish a new version`); + const form = new FormData(); form.append('attachment', new Blob([bytes]), name); + const asset = await api(`${route}/releases/${release.id}/assets?name=${encodeURIComponent(name)}`, { method: 'POST', body: form }); + release.assets.push(asset); +} +if (release.draft) await api(`${route}/releases/${release.id}`, { method: 'PATCH', body: { draft: false } }); +console.log(`Published ${repository}@${tag}`); +console.log('The release workflow will register these packages in the catalog.'); diff --git a/tools/release-catalog.mjs b/tools/release-catalog.mjs index 4c2317a..0eed4b6 100644 --- a/tools/release-catalog.mjs +++ b/tools/release-catalog.mjs @@ -3,7 +3,7 @@ export function appendRelease(catalog, manifest, artifact, releasedAt) { const next = structuredClone(catalog); let curio = next.curios.find(item => item.id === manifest.id); if (!curio) { - curio = { id: manifest.id, name: manifest.name, description: manifest.description ?? '', versions: [] }; + curio = { id: manifest.id, name: manifest.name, description: manifest.description ?? '', ...(manifest.icon ? { icon: manifest.icon } : {}), versions: [] }; next.curios.push(curio); } let version = curio.versions.find(item => item.version === manifest.version); @@ -22,16 +22,9 @@ export function appendRelease(catalog, manifest, artifact, releasedAt) { curio.versions.unshift(version); curio.name = manifest.name; curio.description = manifest.description ?? ''; + if (manifest.icon) curio.icon = manifest.icon; } version.artifacts.push(structuredClone(artifact)); - next.generatedAt = releasedAt; + next.generatedAt = next.generatedAt > releasedAt ? next.generatedAt : releasedAt; return next; } - -export function artifactPath(manifest, target) { - // Validate path components before they become a publication path. - if (!/^[a-z0-9][a-z0-9.-]*[a-z0-9]$/.test(manifest.id)) throw new Error('Invalid Curio ID'); - if (!/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/.test(manifest.version)) throw new Error('Invalid plugin version'); - if (!['any', 'aarch64-apple-darwin', 'x86_64-apple-darwin'].includes(target)) throw new Error('Unsupported artifact target'); - return `packages/${manifest.id}/${manifest.version}/${target}.curio`; -} diff --git a/tools/release-catalog.test.mjs b/tools/release-catalog.test.mjs deleted file mode 100644 index 1428ba7..0000000 --- a/tools/release-catalog.test.mjs +++ /dev/null @@ -1,46 +0,0 @@ -import test from 'node:test'; -import assert from 'node:assert/strict'; -import { appendRelease, artifactPath } from './release-catalog.mjs'; - -const initial = { schemaVersion: 1, id: 'trove-qa', name: 'QA', generatedAt: '2026-09-01T00:00:00Z', curios: [] }; -const manifest = { id: 'dev.trove.qa.example', name: 'Example', version: '1.0.0', engine: { trove: '^0.1.0' } }; -const arm = { target: 'aarch64-apple-darwin', url: 'https://example.org/1.0.0/arm.curio', sha256: 'a'.repeat(64), size: 100 }; -const timestamp = '2026-09-09T00:00:00Z'; -const released = () => appendRelease(initial, manifest, arm, timestamp); - -test('publishing a new version retains all earlier releases', () => { - const original = released(); - const updated = appendRelease(original, { ...manifest, version: '1.1.0' }, { ...arm, url: 'https://example.org/1.1.0/arm.curio', sha256: 'b'.repeat(64) }, '2026-09-10T00:00:00Z'); - assert.equal(updated.curios[0].versions.length, 2); - assert.deepEqual(updated.curios[0].versions[1], original.curios[0].versions[0]); - assert.equal(initial.curios.length, 0); -}); -test('repeating an identical release does not change dates or metadata', () => { - const original = released(); - assert.deepEqual(appendRelease(original, manifest, arm, '2026-10-01T00:00:00Z'), original); -}); -test('one version can add a second architecture without replacing the first', () => { - const original = released(); - const updated = appendRelease(original, manifest, { ...arm, target: 'x86_64-apple-darwin', url: 'https://example.org/1.0.0/intel.curio', sha256: 'c'.repeat(64) }, '2026-09-10T00:00:00Z'); - assert.equal(updated.curios[0].versions.length, 1); - assert.equal(updated.curios[0].versions[0].artifacts.length, 2); - assert.deepEqual(updated.curios[0].versions[0].artifacts[0], arm); - assert.equal(updated.curios[0].versions[0].releasedAt, timestamp); -}); -for (const [key, value] of [['sha256', 'd'.repeat(64)], ['size', 101], ['url', 'https://example.org/replaced.curio']]) { - test(`same version and architecture cannot replace ${key}`, () => { - const original = released(); - assert.throws(() => appendRelease(original, manifest, { ...arm, [key]: value }, timestamp), /bump the plugin version/); - assert.deepEqual(original.curios[0].versions[0].artifacts[0], arm); - }); -} -test('published engine compatibility cannot change silently', () => { - assert.throws(() => appendRelease(released(), { ...manifest, engine: { trove: '^0.2.0' } }, arm, timestamp), /engine range cannot change/); -}); -test('artifact paths distinguish architecture, ID and version and reject traversal', () => { - assert.equal(artifactPath(manifest, 'any'), 'packages/dev.trove.qa.example/1.0.0/any.curio'); - assert.notEqual(artifactPath(manifest, 'aarch64-apple-darwin'), artifactPath(manifest, 'x86_64-apple-darwin')); - assert.throws(() => artifactPath({ ...manifest, id: '../outside' }, 'any')); - assert.throws(() => artifactPath({ ...manifest, version: '../outside' }, 'any')); - assert.throws(() => artifactPath(manifest, '../outside')); -}); diff --git a/tools/release-plan.mjs b/tools/release-plan.mjs new file mode 100644 index 0000000..4f3d75a --- /dev/null +++ b/tools/release-plan.mjs @@ -0,0 +1,45 @@ +import { readFile } from 'node:fs/promises'; +import { createHash } from 'node:crypto'; +import { execFileSync } from 'node:child_process'; +import path from 'node:path'; +import { valid } from 'semver'; + +export async function releasePlan(entriesFile, tag) { + const config = JSON.parse(await readFile('release.json', 'utf8')); + const repository = config.repository; + if (!tag || !/^[a-zA-Z0-9][a-zA-Z0-9._+-]*$/.test(tag)) throw new Error('A release tag is required'); + const commit = execFileSync('git', ['rev-parse', 'HEAD'], { encoding: 'utf8' }).trim(); + const entries = JSON.parse(await readFile(entriesFile, 'utf8')).filter(entry => !entry.fault); + if (!entries.length) throw new Error('No packages to publish'); + const plugins = new Map(); const files = new Map(); + const digest = bytes => createHash('sha256').update(bytes).digest('hex'); + for (const entry of entries) { + const manifest = entry.manifest; + if (!config.ids.includes(manifest.id) || !valid(manifest.version)) throw new Error('Unexpected plugin identity/version'); + if (!config.suite && tag !== `v${manifest.version}`) throw new Error('Release tag must match curio.json'); + if (!['any', 'aarch64-apple-darwin', 'x86_64-apple-darwin'].includes(entry.artifact.target)) throw new Error('Unsupported package target'); + const bytes = await readFile(entry.file); + if (digest(bytes) !== entry.artifact.sha256 || bytes.length !== entry.artifact.size) throw new Error('Package differs from build metadata'); + const asset = `${manifest.id}-${manifest.version}-${entry.artifact.target}.curio`; + if (files.has(asset)) throw new Error(`Duplicate release asset: ${asset}`); + files.set(asset, bytes); + const key = `${manifest.id}@${manifest.version}`; + if (!plugins.has(key)) { + let icon; + if (manifest.icon) { + const file = path.resolve(entry.directory, manifest.icon); + if (!file.startsWith(path.resolve(entry.directory) + path.sep)) throw new Error('Icon escapes package directory'); + const data = await readFile(file); + const type = { '.svg': 'image/svg+xml', '.png': 'image/png', '.jpg': 'image/jpeg', '.webp': 'image/webp' }[path.extname(file)]; + if (type && data.length <= 64 * 1024) icon = `data:${type};base64,${data.toString('base64')}`; + } + plugins.set(key, { id: manifest.id, name: manifest.name, description: manifest.description ?? '', icon, + version: manifest.version, engine: manifest.engine, artifacts: [] }); + } + plugins.get(key).artifacts.push({ target: entry.artifact.target, asset, sha256: entry.artifact.sha256, size: bytes.length }); + } + const descriptor = Buffer.from(JSON.stringify({ schemaVersion: 1, repository, tag, commit, plugins: [...plugins.values()] }, null, 2) + '\n'); + files.set('trove-release.json', descriptor); + return { files, descriptor: JSON.parse(descriptor) }; + +} diff --git a/tools/serve.mjs b/tools/serve.mjs deleted file mode 100644 index ac70125..0000000 --- a/tools/serve.mjs +++ /dev/null @@ -1,51 +0,0 @@ -import { createServer } from 'node:http'; -import { createReadStream } from 'node:fs'; -import { readFile, stat } from 'node:fs/promises'; -import { pipeline } from 'node:stream/promises'; -import { pathToFileURL } from 'node:url'; -import path from 'node:path'; -import { output } from './suite.mjs'; - -// A local-only source server; only the two indexes and package files are exposed. -export async function serve(port = 0) { - let base; - const server = createServer(async (request, response) => { - try { - if (request.method !== 'GET' && request.method !== 'HEAD') { response.writeHead(405).end(); return; } - const pathname = new URL(request.url, 'http://localhost').pathname; - if (['/index.json', '/faults-index.json'].includes(pathname)) { - const index = JSON.parse(await readFile(path.join(output, pathname.slice(1)), 'utf8')); - for (const curio of index.curios) for (const version of curio.versions) for (const artifact of version.artifacts) { - artifact.url = `${base}/packages/${encodeURIComponent(path.basename(new URL(artifact.url).pathname))}`; - } - const body = JSON.stringify(index); - response.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8', 'Content-Length': Buffer.byteLength(body), 'Cache-Control': 'no-store' }); - response.end(request.method === 'HEAD' ? undefined : body); - } else if (/^\/packages\/[a-z0-9][a-z0-9.+-]*\.curio$/.test(pathname)) { - const file = path.join(output, pathname.slice(1)); - const metadata = await stat(file); - response.writeHead(200, { 'Content-Type': 'application/octet-stream', 'Content-Length': metadata.size }); - if (request.method === 'HEAD') response.end(); - else await pipeline(createReadStream(file), response); - } else { response.writeHead(404).end(); } - } catch (error) { - if (!response.headersSent) response.writeHead(error.code === 'ENOENT' ? 404 : 500); - response.end(); - } - }); - await new Promise((resolve, reject) => { - server.once('error', reject); - server.listen(port, '127.0.0.1', resolve); - }); - base = `http://127.0.0.1:${server.address().port}`; - return { base, close: () => new Promise(resolve => server.close(resolve)) }; -} - -if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) { - const port = Number(process.env.QA_HTTP_PORT || 8765); - if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error('QA_HTTP_PORT must be 1..65535'); - const server = await serve(port); - console.log(`常规 HTTP 源:${server.base}/index.json\n故障 HTTP 源:${server.base}/faults-index.json\nCtrl-C 停止服务。`); - process.once('SIGINT', () => { void server.close(); }); - process.once('SIGTERM', () => { void server.close(); }); -} diff --git a/tools/suite.mjs b/tools/suite.mjs deleted file mode 100644 index b181157..0000000 --- a/tools/suite.mjs +++ /dev/null @@ -1,33 +0,0 @@ -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { spawnSync } from 'node:child_process'; - -export const root = fileURLToPath(new URL('../', import.meta.url)); -export const suite = [ - { directory: 'test-host', mode: 'host' }, - { directory: 'test-isolation', mode: 'host' }, - { directory: 'test-self', mode: 'backend', service: '@self' }, - { directory: 'test-provider' }, - { directory: 'test-consumer', mode: 'backend', service: 'dev.trove.qa.provider.probe' }, - { directory: 'test-optional', mode: 'dependency', service: 'dev.trove.qa.absent.probe', expectedError: 'service.not_found' }, - { directory: 'test-required', mode: 'dependency', service: 'dev.trove.qa.absent.probe', expectedError: 'service.not_found', fault: true }, - { directory: 'test-incompatible', mode: 'dependency', service: 'dev.trove.qa.provider.probe', expectedError: 'service.version_mismatch', fault: true }, - { directory: 'test-autostart' }, - { directory: 'test-crash', mode: 'backend', service: '@self', fault: true }, - { directory: 'test-start-timeout', mode: 'fault', service: '@self', fault: true }, - { directory: 'test-bad-handshake', mode: 'fault', service: '@self', fault: true }, - { directory: 'test-stubborn', mode: 'backend', service: '@self', fault: true }, - { directory: 'test-upgrade-v1', mode: 'upgrade' }, - { directory: 'test-upgrade-v2', mode: 'upgrade' }, -]; - -export function run(command, args, options = {}) { - const result = spawnSync(command, args, { cwd: root, stdio: 'inherit', ...options }); - if (result.error) throw result.error; - if (result.status !== 0) throw new Error(`${command} exited with ${result.status ?? result.signal}`); - return result; -} - -// Tooling accepts the installed CLI or an explicit build; plugin packages are self-contained. -export const troveBin = process.env.TROVE_BIN || 'trove'; -export const output = path.join(root, 'dist'); diff --git a/tools/sync-release.mjs b/tools/sync-release.mjs new file mode 100644 index 0000000..673786b --- /dev/null +++ b/tools/sync-release.mjs @@ -0,0 +1,80 @@ +import { readFile, writeFile } from 'node:fs/promises'; +import { createHash } from 'node:crypto'; +import { parseArgs } from 'node:util'; +import { valid, validRange, rcompare } from 'semver'; +import { api, download, server, catalogRepository, repositoryPath } from './gitea.mjs'; +import { appendRelease } from './release-catalog.mjs'; + +const { values } = parseArgs({ options: { + repository: { type: 'string' }, tag: { type: 'string' }, publish: { type: 'boolean', default: false }, +} }); +const repository = values.repository || process.env.RELEASE_REPOSITORY; +const tag = values.tag || process.env.RELEASE_TAG; +const config = JSON.parse(await readFile(new URL('../source/repositories.json', import.meta.url), 'utf8')); +const publisher = config.repositories.find(item => item.repository === repository); +if (!publisher || !tag) throw new Error('Repository is not registered in this catalog, or tag is missing'); +const release = await api(`/repos/${repositoryPath(repository)}/releases/tags/${encodeURIComponent(tag)}`); +if (release.draft) throw new Error('Draft releases cannot enter the catalog'); +const descriptorAsset = release.assets.find(asset => asset.name === 'trove-release.json'); +if (!descriptorAsset) throw new Error('Release has no trove-release.json attachment'); +const descriptor = JSON.parse(await download(descriptorAsset.browser_download_url, undefined, 1024 * 1024)); +if (descriptor.schemaVersion !== 1 || descriptor.repository !== repository || descriptor.tag !== tag || !Array.isArray(descriptor.plugins) || !descriptor.plugins.length) throw new Error('Invalid release descriptor'); +if (!/^[a-f0-9]{40,64}$/.test(descriptor.commit)) throw new Error('Invalid release commit'); +const refs = await api(`/repos/${repositoryPath(repository)}/git/refs/tags/${encodeURIComponent(tag)}`); +let object = (Array.isArray(refs) ? refs.find(ref => ref.ref === `refs/tags/${tag}`) : refs)?.object; +for (let depth = 0; object?.type === 'tag' && depth < 5; depth++) { + object = (await api(`/repos/${repositoryPath(repository)}/git/tags/${object.sha}`)).object; +} +if (object?.type !== 'commit' || object.sha !== descriptor.commit) throw new Error('Release tag differs from package source commit'); +const additions = []; const identities = new Set(); +for (const plugin of descriptor.plugins) { + if (!publisher.ids.includes(plugin.id) || !valid(plugin.version) || !validRange(plugin.engine?.trove) || typeof plugin.name !== 'string' || !plugin.name.trim()) throw new Error('Unexpected plugin identity or compatibility range'); + if (!publisher.suite && tag !== `v${plugin.version}`) throw new Error('Plugin version differs from release tag'); + if (!Array.isArray(plugin.artifacts) || !plugin.artifacts.length) throw new Error('Plugin has no artifacts'); + for (const artifact of plugin.artifacts) { + if (!['any', 'aarch64-apple-darwin', 'x86_64-apple-darwin'].includes(artifact.target) || !/^[a-f0-9]{64}$/.test(artifact.sha256) || !Number.isSafeInteger(artifact.size) || artifact.size < 1 || artifact.size > 512 * 1024 * 1024) throw new Error('Invalid artifact metadata'); + const identity = `${plugin.id}@${plugin.version}/${artifact.target}`; + if (identities.has(identity)) throw new Error('Duplicate artifact identity'); + identities.add(identity); + const asset = release.assets.find(asset => asset.name === artifact.asset); + if (!asset || asset.size !== artifact.size || new URL(asset.browser_download_url).origin !== server) throw new Error('Artifact is not a matching release attachment'); + const bytes = await download(asset.browser_download_url, undefined, artifact.size); + if (bytes.length !== artifact.size || createHash('sha256').update(bytes).digest('hex') !== artifact.sha256) throw new Error(`Release checksum mismatch: ${identity}`); + additions.push({ plugin, artifact: { target: artifact.target, url: asset.browser_download_url, sha256: artifact.sha256, size: artifact.size } }); + } +} + +function merge(index) { + if (index.schemaVersion !== 1 || index.id !== config.id || !Array.isArray(index.curios)) throw new Error('Unexpected source identity'); + for (const { plugin, artifact } of additions) { + index = appendRelease(index, plugin, artifact, release.published_at || release.created_at); + } + index.curios.sort((a, b) => a.id.localeCompare(b.id)); + for (const plugin of index.curios) plugin.versions.sort((a, b) => rcompare(a.version, b.version)); + return JSON.stringify(index, null, 2) + '\n'; +} + +if (!values.publish) { + const file = new URL('../source/index.json', import.meta.url); + await writeFile(file, merge(JSON.parse(await readFile(file, 'utf8')))); + console.log(`Updated local catalog with ${repository}@${tag}`); +} else { + // Gitea's contents SHA provides compare-and-swap. On concurrent publications, + // re-read and merge the latest index instead of overwriting another release. + const route = `/repos/${repositoryPath(catalogRepository)}/contents/source/index.json`; + for (let attempt = 0; attempt < 6; attempt++) { + const file = await api(`${route}?ref=main`, { token: process.env.CATALOG_WRITE_TOKEN }); + const before = Buffer.from(file.content, 'base64').toString('utf8'); + const content = merge(JSON.parse(before)); + if (content === before) { console.log('Release already registered'); break; } + try { + await api(route, { token: process.env.CATALOG_WRITE_TOKEN, method: 'PUT', body: { + branch: 'main', sha: file.sha, content: Buffer.from(content).toString('base64'), + message: `chore(catalog): register ${repository}@${tag}`, + } }); + console.log(`Registered ${repository}@${tag}`); break; + } catch (error) { + if (![409, 422].includes(error.status) || attempt === 5) throw error; + } + } +} diff --git a/tools/test-harmony.mjs b/tools/test-harmony.mjs deleted file mode 100644 index 9905fc0..0000000 --- a/tools/test-harmony.mjs +++ /dev/null @@ -1,43 +0,0 @@ -import path from "node:path"; -import { root, run, troveBin } from "./suite.mjs"; -const plugin = path.join(root, "curios/harmony-connector"); -run(process.execPath, [path.join(plugin, "build.mjs")]); -run( - "cargo", - [ - "test", - "--locked", - "--manifest-path", - path.join(plugin, "native/Cargo.toml"), - ], - { - env: { - ...process.env, - HARMONY_NATIVE_PREFIX: path.join(plugin, "dist/native-deps/install"), - MACOSX_DEPLOYMENT_TARGET: "13.0", - }, - }, -); -run(process.execPath, ["--test", path.join(plugin, "ui/format.test.js")]); -run(process.execPath, ["--test", path.join(plugin, "ui/screen-geometry.test.js")]); -run(process.execPath, ["--test", path.join(root, "tools/harmony-ui.test.mjs")]); -const prefix = path.join(plugin, "dist/native-deps/install"); -const bridgeTest = path.join(plugin, "dist/mtp-bridge-test"); -run("cc", [ - "-I" + path.join(prefix, "include"), - path.join(plugin, "native/tests/mtp_bridge_test.c"), - path.join(plugin, "native/src/mtp_bridge.c"), - "-L" + path.join(prefix, "lib"), - "-lmtp", - "-lusb-1.0", - "-o", - bridgeTest, -]); -run(bridgeTest, []); -run(troveBin, [ - "protocol", - "test", - "--command", - path.join(plugin, "backend/harmony-connector"), -]); -run(path.join(plugin, "backend/harmony-connector"), ["--diagnose"]); diff --git a/tools/verify.mjs b/tools/verify.mjs deleted file mode 100644 index d48db5d..0000000 --- a/tools/verify.mjs +++ /dev/null @@ -1,297 +0,0 @@ -import { spawn, spawnSync } from 'node:child_process'; -import { parseArgs } from 'node:util'; -import { createInterface } from 'node:readline'; -import { mkdir, writeFile, access, rm } from 'node:fs/promises'; -import path from 'node:path'; -import { createTroveClient } from '@trove/plugin-sdk'; -import { root, output, run, troveBin } from './suite.mjs'; -import { assert, equal, expectError, hostChecks, backendChecks, sleep } from '../shared/ui/checks.js'; -import { serve } from './serve.mjs'; - -// Only this optional integration adapter depends on the app checkout. -const { values: args } = parseArgs({ options: { - 'remote-source': { type: 'string' }, - 'source-id': { type: 'string', default: 'trove-official' }, - 'source-kind': { type: 'string', default: 'http' }, -} }); -if (!['git', 'http'].includes(args['source-kind'])) throw new Error('--source-kind must be git or http'); -const reportPath = path.join(output, args['remote-source'] ? 'remote-verification.json' : 'verification.json'); -await rm(reportPath, { force: true }); -const app = process.env.TROVE_APP_DIR; -if (!app) throw new Error('Set TROVE_APP_DIR to the app repository, then run pnpm run verify.'); -if (!args['remote-source']) await access(path.join(output, 'suite.json')).catch(() => { throw new Error('Run pnpm run pack first.'); }); -const harness = path.join(output, 'host-check'); -await mkdir(harness, { recursive: true }); -const manifest = `[package] -name = "trove-qa-host-check" -version = "0.1.0" -edition = "2021" -publish = false - -[workspace] - -[[bin]] -name = "trove-qa-host-check" -path = ${JSON.stringify(path.join(root, 'tools/host-check.rs'))} - -[dependencies] -trove = { path = ${JSON.stringify(path.join(path.resolve(app), 'src-tauri'))}, default-features = false } -anyhow = "1" -serde_json = "1" -tempfile = "3" -tokio = { version = "1", features = ["full"] } -`; -await writeFile(path.join(harness, 'Cargo.toml'), manifest); -const target = process.env.TROVE_QA_TARGET_DIR || path.join(output, 'host-target'); -try { - run('cargo', ['build', '--manifest-path', path.join(harness, 'Cargo.toml'), '--target-dir', target]); - if (args['remote-source']) { - const result = spawnSync(path.join(target, 'debug/trove-qa-host-check'), ['--remote-source', args['remote-source'], args['source-id'], args['source-kind']], { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'inherit'], timeout: 180000 }); - if (result.error) throw result.error; - const report = JSON.parse(result.stdout); - report.generatedAt = new Date().toISOString(); - await writeFile(reportPath, JSON.stringify(report, null, 2) + '\n'); - console.log(`${report.passed} passed, ${report.failed} failed. Report: ${reportPath}`); - process.exit(result.status ?? 1); - } - run(troveBin, ['protocol', 'test', '--command', path.join(root, 'shared/backend/target/release/trove-qa-backend')]); -} catch (error) { - await writeFile(reportPath, JSON.stringify({ - schemaVersion: 1, generatedAt: new Date().toISOString(), passed: 0, failed: 1, observed: 0, - results: [{ name: '编译适配器 / CLI 协议检查', status: 'fail', error: { message: error.message } }], - }, null, 2) + '\n'); - throw error; -} - -const sourceServer = await serve(); -const child = spawn(path.join(target, 'debug/trove-qa-host-check'), [output], { - cwd: root, stdio: ['pipe', 'pipe', 'inherit'], env: { ...process.env, QA_HTTP_SOURCE_URL: `${sourceServer.base}/index.json` }, -}); -const controls = new Map(); -const listeners = new Map(); -const clients = []; -const results = []; -let nextId = 1; -let readyResolve, readyReject; -const ready = new Promise((resolve, reject) => { readyResolve = resolve; readyReject = reject; }); -let exitResolve; -const exited = new Promise(resolve => { exitResolve = resolve; }); -let fatal; -let closing = false; -child.once('error', error => { readyReject(error); }); -child.once('exit', (code, signal) => { - exitResolve({ code, signal }); - if (!closing) { - fatal = new Error(`Host adapter exited unexpectedly: ${code ?? signal}`); - readyReject(fatal); - for (const pending of controls.values()) pending.reject(fatal); - for (const listener of listeners.values()) listener({ v: 1, type: 'goodbye' }); - } -}); -const lines = createInterface({ input: child.stdout }); -lines.on('line', line => { - try { - const message = JSON.parse(line); - if (message.ready) readyResolve(message); - else if (message.controlId) { - const pending = controls.get(message.controlId); - controls.delete(message.controlId); - if (message.error) pending?.reject(new Error(message.error.message)); - else pending?.resolve(message.result); - } else if (message.connection) listeners.get(message.connection)?.(message.message); - else if (message.fatal) { - fatal = new Error(JSON.stringify(message.fatal)); - for (const listener of listeners.values()) listener({ v: 1, type: 'protocolError', error: message.fatal }); - } - } catch (error) { fatal = error; readyReject(error); } -}); -function send(value) { - if (fatal) return Promise.reject(fatal); - return new Promise((resolve, reject) => child.stdin.write(JSON.stringify(value) + '\n', error => error ? reject(error) : resolve())); -} -function control(op, params = {}) { - const controlId = String(nextId++); - return new Promise((resolve, reject) => { - const timer = setTimeout(() => { controls.delete(controlId); reject(new Error(`Control timeout: ${op}`)); }, 15000); - controls.set(controlId, { resolve: value => { clearTimeout(timer); resolve(value); }, reject: error => { clearTimeout(timer); reject(error); } }); - send({ op, ...params, controlId }).catch(error => { controls.get(controlId)?.reject(error); controls.delete(controlId); }); - }); -} -async function client(curioId) { - const connection = `qa-ui-${crypto.randomUUID()}`; - const context = await control('connect', { curioId, connection }); - const instance = createTroveClient({ context, bridge: { - protocolVersion: 1, - connect: async listener => { listeners.set(connection, listener); }, - send: message => send({ op: 'send', connection, message }), - disconnect: () => { listeners.delete(connection); void control('disconnect', { connection }).catch(() => {}); }, - } }); - await instance.ready(); - clients.push(instance); - return instance; -} -async function check(name, action, status = 'pass') { - const start = performance.now(); - try { results.push({ name, status, value: await action(), durationMs: Math.round(performance.now() - start) }); } - catch (error) { results.push({ name, status: 'fail', error: { code: error.code, message: error.message }, durationMs: Math.round(performance.now() - start) }); } - console.log(`${results.at(-1).status.toUpperCase()} ${name}${results.at(-1).error ? `: ${results.at(-1).error.message}` : ''}`); -} -async function waitFor(action, predicate, timeoutMs = 3000) { - const end = Date.now() + timeoutMs; - do { const value = await action(); if (predicate(value)) return value; await sleep(50); } while (Date.now() < end); - throw new Error(`Condition did not settle within ${timeoutMs} ms`); -} -let temporaryRoot; -try { - const readyTimer = setTimeout(() => readyReject(new Error('Package checks exceeded 60 seconds')), 60000); - let initial; - try { initial = await ready; } finally { clearTimeout(readyTimer); } - temporaryRoot = initial.temporaryRoot; - results.push(...initial.packageResults); - const host = await client('dev.trove.qa.host'); - for (const [name, action] of hostChecks(host)) await check(`Host / ${name}`, action); - const isolation = await client('dev.trove.qa.isolation'); - for (const [name, action] of hostChecks(isolation).filter(([name]) => name.includes('持久化'))) await check(`隔离 / ${name}`, action); - await check('隔离 / 两插件同名键相互独立', async () => { - equal((await host.call('host.storage', 'get', { key: 'qa.namespace-owner' })).value, host.context.curioId); - equal((await isolation.call('host.storage', 'get', { key: 'qa.namespace-owner' })).value, isolation.context.curioId); - return { isolated: true }; - }); - await check('持久化 / SDK 断开重连后读取', async () => { - host.disconnect(); - const reopened = await client('dev.trove.qa.host'); - equal((await reopened.call('host.storage', 'get', { key: 'qa.namespace-owner' })).value, reopened.context.curioId); - return { reopened: true }; - }); - const self = await client('dev.trove.qa.self'); - for (const [name, action] of backendChecks(self, '@self')) await check(`私有后端 / ${name}`, action); - const consumer = await client('dev.trove.qa.consumer'); - const service = 'dev.trove.qa.provider.probe'; - await check('公共后端 / 第一次调用前尚未启动', async () => { - const status = await control('status', { curioId: 'dev.trove.qa.provider' }); - equal(status.state, 'stopped'); return status; - }); - await check('公共后端 / 默认返回值契约策略(观察)', async () => { - try { - const result = await consumer.call(service, 'invalidResult'); - return { enforced: false, result, note: '默认底座允许不符合返回值契约的结果;后续单独开启严格模式验证。' }; - } catch (error) { - equal(error.code, 'service.contract_violation'); - return { enforced: true, actualError: error.code }; - } - }, 'observed'); - for (const [name, action] of backendChecks(consumer, service).filter(([name]) => !name.startsWith('公共服务'))) await check(`公共后端 / ${name}`, action); - await control('strictContracts', { value: true }); - try { - for (const [name, action] of backendChecks(consumer, service).filter(([name]) => name.startsWith('公共服务'))) await check(`严格模式 / ${name}`, action); - } finally { await control('strictContracts', { value: false }); } - await check('公共后端 / Host 存储归属于提供方', async () => { - const key = 'qa.provider-namespace'; - await consumer.call(service, 'storageRoundtrip', { key, value: 'provider-only' }); - equal((await consumer.call('host.storage', 'get', { key })).found, false); - const provider = await client('dev.trove.qa.provider'); - equal((await provider.call('host.storage', 'get', { key })).value, 'provider-only'); - await provider.call('host.storage', 'delete', { key }); - return { namespace: 'dev.trove.qa.provider' }; - }); - await check('取消 / 后端活动请求最终归零', async () => { - return waitFor(() => consumer.call(service, 'info'), value => value.activeRequests === 1 && value.subscriptions === 0); - }); - for (const [id, expected] of [['optional', 'service.not_found'], ['required', 'service.not_found'], ['incompatible', 'service.version_mismatch']]) { - await check(`依赖 / ${id}`, async () => { - const instance = await client(`dev.trove.qa.${id}`); - equal(await instance.call('host.runtime', 'echo', { alive: true }), { alive: true }); - return expectError(() => instance.call(id === 'incompatible' ? service : 'dev.trove.qa.absent.probe', 'echo'), expected); - }); - } - await check('启动 / 缺失 ready 触发超时', () => expectError(async () => { - const instance = await client('dev.trove.qa.start-timeout'); await instance.self.call('info'); - }, 'provider.start_timeout')); - await check('启动 / 错误令牌被拒绝', async () => { - const instance = await client('dev.trove.qa.bad-handshake'); - const result = await expectError(() => instance.self.call('info'), 'provider.start_failed'); - equal((await control('status', { curioId: instance.context.curioId })).state, 'failed'); - return result; - }); - await check('禁用 / 提供方拒绝调用并可重新启用', async () => { - await control('enabled', { curioId: 'dev.trove.qa.provider', value: false }); - try { await expectError(() => consumer.call(service, 'echo'), 'provider.disabled'); } - finally { await control('enabled', { curioId: 'dev.trove.qa.provider', value: true }); } - equal(await consumer.call(service, 'echo', { restored: true }), { restored: true }); - return { enabledAgain: true }; - }); - await check('退出 / 优雅关闭后可再次启动', async () => { - const before = await self.self.call('info'); - const stopped = await control('stop', { curioId: self.context.curioId }); - equal(stopped.state, 'stopped'); - const after = await self.self.call('info'); - assert(after.pid !== before.pid, 'Shutdown retained old process'); - return { before: before.pid, after: after.pid }; - }); - await check('退出 / 忽略 shutdown 的进程被回收', async () => { - const instance = await client('dev.trove.qa.stubborn'); - const before = await instance.self.call('info'); - const stopped = await control('stop', { curioId: instance.context.curioId }); - equal(stopped.state, 'stopped'); - let gone = false; - try { process.kill(before.pid, 0); } catch (error) { if (error.code === 'ESRCH') gone = true; else throw error; } - assert(gone, `Process ${before.pid} survived shutdown grace period`); - return { terminatedPid: before.pid }; - }); - await check('崩溃 / 隔离、重启、熔断和手动重试', async () => { - const instance = await client('dev.trove.qa.crash'); - const first = await instance.self.call('info'); - for (let i = 0; i < 3; i++) { - await expectError(() => instance.self.call('crash'), 'provider.disconnected'); - if (i < 2) { - const restarted = await instance.self.call('info'); - assert(restarted.pid !== first.pid, 'Crash recovery retained dead PID'); - } - } - await expectError(() => instance.self.call('info'), 'provider.crash_loop'); - equal(await self.self.call('echo', { unaffected: true }), { unaffected: true }); - await control('retry', { curioId: instance.context.curioId }); - const after = await instance.self.call('info'); - return { oldPid: first.pid, retriedPid: after.pid }; - }); - await check('空闲 / 20 秒无调用后退出并重新按需启动', async () => { - const before = await consumer.call(service, 'info'); - await waitFor(() => control('status', { curioId: 'dev.trove.qa.provider' }), value => value.state === 'stopped', 24000); - const after = await consumer.call(service, 'info'); - assert(before.pid !== after.pid, 'Idle shutdown retained old PID'); - return { before: before.pid, after: after.pid }; - }); - await check('清理 / 无悬挂调用和订阅', async () => { - return waitFor(() => control('diagnostics'), value => value.pendingCalls === 0 && value.incomingCalls === 0 && value.subscriptions === 0); - }); -} catch (error) { - results.push({ name: '验证进程', status: 'fail', error: { message: error.stack || error.message } }); -} finally { - for (const instance of clients) instance.disconnect(); - // A barrier lets all disconnect cleanup finish before shutting down the adapter. - if (!fatal && child.exitCode === null) await control('diagnostics').catch(() => {}); - closing = true; - child.stdin.end(JSON.stringify({ op: 'shutdown' }) + '\n'); - const killTimer = setTimeout(() => child.kill('SIGTERM'), 10000); - const exit = await exited; - clearTimeout(killTimer); - await sourceServer.close(); - if (exit.code !== 0) results.push({ name: 'Host adapter exit', status: 'fail', error: exit }); - if (temporaryRoot) { - let removed = false; - try { await access(temporaryRoot); } catch (error) { if (error.code === 'ENOENT') removed = true; else throw error; } - results.push({ name: '隔离数据目录已清理', status: removed ? 'pass' : 'fail', value: { temporaryRoot } }); - } - const report = { - schemaVersion: 1, generatedAt: new Date().toISOString(), - environment: { platform: process.platform, arch: process.arch, appRepository: path.resolve(app), sdk: '0.1.0' }, - scope: 'Real PackageManager + ServiceBus + BackendSupervisor + UDS + production SDK over a test bridge. Default policy is used except explicitly labeled strict-contract checks. Desktop WebView/native dialogs and app-start orchestration are manual checks.', - passed: results.filter(row => row.status === 'pass').length, - failed: results.filter(row => row.status === 'fail').length, - observed: results.filter(row => row.status === 'observed').length, - results, - }; - await writeFile(path.join(output, 'verification.json'), JSON.stringify(report, null, 2) + '\n'); - console.log(`\n${report.passed} passed, ${report.failed} failed, ${report.observed} observations. Report: ${path.join(output, 'verification.json')}`); - process.exitCode = report.failed ? 1 : 0; -} diff --git a/vendor/README.md b/vendor/README.md deleted file mode 100644 index 00d4510..0000000 --- a/vendor/README.md +++ /dev/null @@ -1,9 +0,0 @@ -# 测试 SDK 分发包 - -`trove-plugin-sdk-0.1.0.tgz` 是 `@trove/plugin-sdk@0.1.0` 的版本化分发包,复制自 Trove app 仓库 `src-tauri/templates/trove-plugin-sdk-0.1.0.tgz`(参考提交 `66a294a`)。 - -SHA-256:`9715052f98ff0121f83c13fd538f2b1d3d05028fc498f75f1efadb86624d0272` - -根 package.json 使用 `file:vendor/trove-plugin-sdk-0.1.0.tgz` 依赖,pnpm-lock.yaml 固定完整性。构建时从该包复制发布的 `dist/` 到每个 UI;不导入相邻 app 仓库的 SDK 源码,也不依赖公共 CDN。替换 SDK 时应同时更新版本、来源、完整性锁和验证记录。 - -`shared/backend/src/main.rs` 的 Wire v1 framing、并发/取消与写队列实现基于同一 app 仓库的 Rust 教学后端,扩展为测试探针和显式故障模式;它作为独立 Rust crate 编译。 diff --git a/vendor/harmony-connector/SOURCES.md b/vendor/harmony-connector/SOURCES.md deleted file mode 100644 index 1aa2dbb..0000000 --- a/vendor/harmony-connector/SOURCES.md +++ /dev/null @@ -1,12 +0,0 @@ -# Harmony Connector native dependency sources - -| Archive | Upstream download | SHA-256 | -| --- | --- | --- | -| `libmtp-1.1.23.tar.gz` | https://downloads.sourceforge.net/project/libmtp/libmtp/1.1.23/libmtp-1.1.23.tar.gz | `74a2b6e8cb4a0304e95b995496ea3ac644c29371649b892b856e22f12a0bdeed` | -| `libusb-1.0.30.tar.bz2` | https://github.com/libusb/libusb/releases/download/v1.0.30/libusb-1.0.30.tar.bz2 | `fea36f34f9156400209595e300840767ab1a385ede1dc7ee893015aea9c6dbaf` | - -Archives were fetched and verified through the Homebrew source formulas on -2026-09-10. They are preserved unmodified. The build independently verifies -these hashes, builds shared libraries, and embeds the exact archives and original -COPYING files into the plugin package. Build script and redistribution notices: -`curios/harmony-connector/build.mjs` and `curios/harmony-connector/legal/THIRD-PARTY.md`. diff --git a/vendor/harmony-connector/hypium/LICENSE b/vendor/harmony-connector/hypium/LICENSE deleted file mode 100644 index f433b1a..0000000 --- a/vendor/harmony-connector/hypium/LICENSE +++ /dev/null @@ -1,177 +0,0 @@ - - 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 diff --git a/vendor/harmony-connector/hypium/LICENSE-26 b/vendor/harmony-connector/hypium/LICENSE-26 deleted file mode 100644 index f433b1a..0000000 --- a/vendor/harmony-connector/hypium/LICENSE-26 +++ /dev/null @@ -1,177 +0,0 @@ - - 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 diff --git a/vendor/harmony-connector/hypium/libscrcpy_server_unix_6.3.1-20260113.z.so b/vendor/harmony-connector/hypium/libscrcpy_server_unix_6.3.1-20260113.z.so deleted file mode 100644 index abd4110..0000000 Binary files a/vendor/harmony-connector/hypium/libscrcpy_server_unix_6.3.1-20260113.z.so and /dev/null differ diff --git a/vendor/harmony-connector/hypium/libscrcpy_server_unix_6.4-20260113.z.so b/vendor/harmony-connector/hypium/libscrcpy_server_unix_6.4-20260113.z.so deleted file mode 100644 index 203590f..0000000 Binary files a/vendor/harmony-connector/hypium/libscrcpy_server_unix_6.4-20260113.z.so and /dev/null differ diff --git a/vendor/harmony-connector/hypium/libscrcpy_server_unix_6.5-20260313.z.so b/vendor/harmony-connector/hypium/libscrcpy_server_unix_6.5-20260313.z.so deleted file mode 100644 index 25d03d6..0000000 Binary files a/vendor/harmony-connector/hypium/libscrcpy_server_unix_6.5-20260313.z.so and /dev/null differ diff --git a/vendor/harmony-connector/hypium/libscrcpy_server_unix_6.6-20260418.z.so b/vendor/harmony-connector/hypium/libscrcpy_server_unix_6.6-20260418.z.so deleted file mode 100644 index 43d8e7b..0000000 Binary files a/vendor/harmony-connector/hypium/libscrcpy_server_unix_6.6-20260418.z.so and /dev/null differ diff --git a/vendor/harmony-connector/hypium/libscrcpy_server_unix_6.6-20260629.z.so b/vendor/harmony-connector/hypium/libscrcpy_server_unix_6.6-20260629.z.so deleted file mode 100644 index 8c28314..0000000 Binary files a/vendor/harmony-connector/hypium/libscrcpy_server_unix_6.6-20260629.z.so and /dev/null differ diff --git a/vendor/harmony-connector/hypium/provenance-26.json b/vendor/harmony-connector/hypium/provenance-26.json deleted file mode 100644 index ea69fee..0000000 --- a/vendor/harmony-connector/hypium/provenance-26.json +++ /dev/null @@ -1,44 +0,0 @@ -{ - "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 - }, - { - "file": "libscrcpy_server_unix_6.6-20260418.z.so", - "member": "devicetest/res/recorder/libscrcpy_server_unix_6.6-20260418.z.so", - "sha256": "98bbc964fef1c4d17b446ec717102b855d09224b23fc1872bf70e1f928d010dd", - "modified": false - }, - { - "file": "libscrcpy_server_unix_6.5-20260313.z.so", - "member": "devicetest/res/recorder/libscrcpy_server_unix_6.5-20260313.z.so", - "sha256": "2c8f1ef94d581281bf8e86d54f5939af9b205d5db5aa2cfb27f2562215c87b0a", - "modified": false - }, - { - "file": "libscrcpy_server_unix_6.4-20260113.z.so", - "member": "devicetest/res/recorder/libscrcpy_server_unix_6.4-20260113.z.so", - "sha256": "b698b41ae1351337eeea3dcc87bf0160552196d669ba321b4963c342fa5a47f4", - "modified": false - }, - { - "file": "libscrcpy_server_unix_6.3.1-20260113.z.so", - "member": "devicetest/res/recorder/libscrcpy_server_unix_6.3.1-20260113.z.so", - "sha256": "3e29e3a1d23ea6dbd4e6fb3b8f094ca5d4b15b4c580f421fba3d0e1f5429e3fe", - "modified": false - } - ] -} diff --git a/vendor/harmony-connector/hypium/provenance.json b/vendor/harmony-connector/hypium/provenance.json deleted file mode 100644 index 18a7d8e..0000000 --- a/vendor/harmony-connector/hypium/provenance.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "package": "xdevice-devicetest", - "version": "6.1.0.210", - "url": "https://files.pythonhosted.org/packages/a2/b5/55bdfe4d669558331499848dbc68269506abb9b4a771e3524a65f9845127/xdevice_devicetest-6.1.0.210-py3-none-any.whl", - "wheelSha256": "1c80f0da8d497d4e034f9873e966e8b2d8ef23ecc376c64c839c1493a813980e", - "member": "devicetest/res/prototype/native/uitest_agent_v1.2.2.so", - "sha256": "e1b8e75fad983aa29640784ee4b457fe7ac1c916a15f72c8b899d5a7716da651", - "license": "Apache-2.0", - "modified": false -} diff --git a/vendor/harmony-connector/hypium/uitest_agent_v1.2.2.so b/vendor/harmony-connector/hypium/uitest_agent_v1.2.2.so deleted file mode 100644 index d54e0a9..0000000 Binary files a/vendor/harmony-connector/hypium/uitest_agent_v1.2.2.so and /dev/null differ diff --git a/vendor/harmony-connector/hypium/uitest_agent_v1.2.3.so b/vendor/harmony-connector/hypium/uitest_agent_v1.2.3.so deleted file mode 100644 index 74aecb5..0000000 Binary files a/vendor/harmony-connector/hypium/uitest_agent_v1.2.3.so and /dev/null differ diff --git a/vendor/harmony-connector/libmtp-1.1.23.tar.gz b/vendor/harmony-connector/libmtp-1.1.23.tar.gz deleted file mode 100644 index dd40e72..0000000 Binary files a/vendor/harmony-connector/libmtp-1.1.23.tar.gz and /dev/null differ diff --git a/vendor/harmony-connector/libmtp-close-timeout.patch b/vendor/harmony-connector/libmtp-close-timeout.patch deleted file mode 100644 index d812af3..0000000 --- a/vendor/harmony-connector/libmtp-close-timeout.patch +++ /dev/null @@ -1,12 +0,0 @@ ---- a/src/libusb1-glue.c -+++ b/src/libusb1-glue.c -@@ -2358,6 +2358,9 @@ - - void close_device (PTP_USB *ptp_usb, PTPParams *params) - { -+ /* Closing is a small command, not a file transfer. A non-responsive -+ * CloseSession must not consume the device's long transfer timeout. */ -+ set_usb_device_timeout(ptp_usb, 2000); - if (ptp_closesession(params)!=PTP_RC_OK) - LIBMTP_ERROR("ERROR: Could not close session!\n"); - close_usb(ptp_usb); diff --git a/vendor/harmony-connector/libusb-1.0.30.tar.bz2 b/vendor/harmony-connector/libusb-1.0.30.tar.bz2 deleted file mode 100644 index 339e90c..0000000 Binary files a/vendor/harmony-connector/libusb-1.0.30.tar.bz2 and /dev/null differ diff --git a/vendor/trove-plugin-sdk-0.1.0.tgz b/vendor/trove-plugin-sdk-0.1.0.tgz deleted file mode 100644 index c74a4d0..0000000 Binary files a/vendor/trove-plugin-sdk-0.1.0.tgz and /dev/null differ