diff --git a/.gitignore b/.gitignore index aff9a41..c26ba5a 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,7 @@ target/ !/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/ diff --git a/README.md b/README.md index d99ee9a..6c23e5e 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,10 @@ Trove 官方插件库。发现喜欢的 Curio,装进你的工作台,让常 每个 Curio 专注一件事,用轻巧的独立工具承接日常操作,把更多空间留给手头的工作。 +### Harmony Connector + +让文件,自在往来。一根 USB 数据线,连接 Mac 与鸿蒙设备;浏览文件、收发文档,把照片与灵感带到更大的屏幕。 + ## 按你的习惯组合 按需安装,自由搭配。工具各自更新,也能通过服务相互配合,让你的工作方式逐渐长成自己的样子。 diff --git a/curios/harmony-connector/assets/icon.svg b/curios/harmony-connector/assets/icon.svg new file mode 100644 index 0000000..26a13f3 --- /dev/null +++ b/curios/harmony-connector/assets/icon.svg @@ -0,0 +1 @@ + diff --git a/curios/harmony-connector/build.mjs b/curios/harmony-connector/build.mjs new file mode 100644 index 0000000..c8594d4 --- /dev/null +++ b/curios/harmony-connector/build.mjs @@ -0,0 +1,221 @@ +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"], + }, +]; +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 identity = `${process.arch}:macos13:${source.sha256}`; + const previous = await readFile(stamp, "utf8").catch(() => ""); + if (previous !== identity) { + await rm(directory, { recursive: true, force: true }); + run("tar", ["-xf", archive, "-C", build]); + 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 (process.argv.includes("--dependencies-only")) process.exit(0); +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 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, + ...libraries.map((name) => path.join(backend, "lib", name)), +]) { + const isExecutable = file === executable; + 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 new file mode 100644 index 0000000..cf3d0dd --- /dev/null +++ b/curios/harmony-connector/curio.json @@ -0,0 +1,21 @@ +{ + "schemaVersion": 1, + "id": "dev.trove.harmony-connector", + "name": "Harmony Connector", + "description": "通过 USB 连接鸿蒙设备,在 Mac 与手机之间浏览、发送和接收文件。", + "version": "0.1.0", + "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" }] }, + "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 new file mode 100644 index 0000000..c8c2921 --- /dev/null +++ b/curios/harmony-connector/legal/THIRD-PARTY.md @@ -0,0 +1,54 @@ +# Harmony Connector — Third-party software + +Harmony Connector dynamically links the following libraries. This distribution +includes their original license texts and complete, unmodified source archives. + +| 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. + +## 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. No changes are made to the upstream sources. +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 new file mode 100644 index 0000000..f260b91 --- /dev/null +++ b/curios/harmony-connector/native/Cargo.lock @@ -0,0 +1,242 @@ +# 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 = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[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 = "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 = "find-msvc-tools" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d" + +[[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 = "harmony-connector" +version = "0.1.0" +dependencies = [ + "anyhow", + "cc", + "serde", + "serde_json", + "tempfile", +] + +[[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 = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[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 = "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", +] + +[[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 = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[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 = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[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 = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/curios/harmony-connector/native/Cargo.toml b/curios/harmony-connector/native/Cargo.toml new file mode 100644 index 0000000..babba13 --- /dev/null +++ b/curios/harmony-connector/native/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "harmony-connector" +version = "0.1.0" +edition = "2021" +publish = false + +[dependencies] +anyhow = "1" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +tempfile = "3" + +[build-dependencies] +cc = "1" diff --git a/curios/harmony-connector/native/build.rs b/curios/harmony-connector/native/build.rs new file mode 100644 index 0000000..61a861a --- /dev/null +++ b/curios/harmony-connector/native/build.rs @@ -0,0 +1,19 @@ +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:rerun-if-env-changed=HARMONY_NATIVE_PREFIX"); + println!("cargo:rerun-if-changed=src/mtp_bridge.c"); +} diff --git a/curios/harmony-connector/native/src/connector.rs b/curios/harmony-connector/native/src/connector.rs new file mode 100644 index 0000000..7e798c9 --- /dev/null +++ b/curios/harmony-connector/native/src/connector.rs @@ -0,0 +1,456 @@ +use crate::{ + model::*, + mtp::{self, DeviceIo, NativeDevice, ROOT}, +}; +use serde::{de::DeserializeOwned, Deserialize}; +use serde_json::{json, Value}; +use std::{ + fs::File, + 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, +} +struct Job { + id: u64, + progress: Arc, + state: &'static str, + result: Option, + error: Option, +} +pub struct Connector { + 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", "请先连接设备。") +} +fn cancelled() -> Fault { + Fault::new( + "transfer.cancelled", + "传输已取消。发送到手机的未完成文件可能需要在手机上清理。", + ) +} +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(()) +} +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(()) +} +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 { + 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 clear(&self) { + *self.session.lock().unwrap() = None; + *self.connection.lock().unwrap() = Value::Null; + } + pub fn stop(&self) { + 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 { + 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()), + "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 == "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 operation(&self, method: &str, value: Value) -> Result { + if method == "devices" { + return Ok(json!({"devices":mtp::scan()?})); + } + if method == "disconnect" { + self.clear(); + return Ok(json!({"disconnected":true})); + } + if method == "connect" { + let id: DeviceId = params(value)?; + self.clear(); + let 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)?; + let storages = device.storages()?; + if storages.is_empty() { + return Err(Fault::new( + "mtp.storage_unavailable", + "手机尚未开放存储。请解锁手机并选择“传输文件”。", + )); + } + let connection = json!({"device":info,"storages":storages}); + *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 result = { + let mut session = connector.session.lock().unwrap(); + run_transfer( + session.as_mut().unwrap().device.as_mut(), + transfer, + &progress, + ) + }; + // 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(),"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 + } +} + +fn run_transfer( + device: &mut dyn DeviceIo, + transfer: Transfer, + progress: &Progress, +) -> Result { + if progress.cancelled() { + return Err(cancelled()); + } + match transfer { + Transfer::Upload { + path, + storage, + parent, + } => { + let path = Path::new(&path); + if !path.is_absolute() { + return Err(Fault::new("file.invalid_path", "请选择 Mac 上的文件。")); + } + let name = path + .file_name() + .and_then(|s| s.to_str()) + .ok_or_else(|| Fault::new("file.invalid_name", "文件名不是有效的 UTF-8。"))?; + valid_name(name)?; + let file = File::open(path)?; + let metadata = file.metadata()?; + if !metadata.is_file() { + return Err(Fault::new( + "file.not_regular", + "一次只能发送一个普通文件。文件夹请先压缩。", + )); + } + parent_exists(device, storage, parent)?; + available_name(device, storage, parent, name)?; + let capacity = device + .storages()? + .into_iter() + .find(|s| s.id == storage) + .ok_or_else(disconnected)?; + if capacity.free != u64::MAX && metadata.len() > capacity.free { + return Err(Fault::new("storage.full", "手机剩余空间不足。")); + } + progress.update(0, metadata.len()); + if progress.cancelled() { + return Err(cancelled()); + } + let outcome = device.upload(&file, name, metadata.len(), storage, parent, progress); + if progress.cancelled() { + return Err(cancelled()); + } + let object = outcome?; + let received = device.metadata(object)?; + if received.size != metadata.len() || file.metadata()?.len() != metadata.len() { + return Err(Fault::new( + "mtp.size_mismatch", + "传输后文件大小不一致,请检查手机上的文件后重试。", + )); + } + progress.update(metadata.len(), metadata.len()); + Ok(json!({"object":object,"name":name,"bytes":metadata.len()})) + } + Transfer::Download { object, directory } => { + let entry = device.metadata(object)?; + if entry.folder { + return Err(Fault::new("file.is_folder", "请选择单个文件接收。")); + } + valid_name(&entry.name)?; + let directory = Path::new(&directory); + if !directory.is_absolute() || !directory.is_dir() { + return Err(Fault::new( + "file.invalid_directory", + "请选择 Mac 上的接收文件夹。", + )); + } + let destination = directory.join(&entry.name); + if destination.symlink_metadata().is_ok() { + return Err(Fault::new( + "file.exists", + "Mac 上已有同名文件。请选择其他文件夹。", + )); + } + let temporary = tempfile::Builder::new() + .prefix(".harmony-") + .suffix(".part") + .tempfile_in(directory)?; + progress.update(0, entry.size); + if progress.cancelled() { + return Err(cancelled()); + } + let outcome = device.download(object, temporary.as_file(), progress); + if progress.cancelled() { + return Err(cancelled()); + } + outcome?; + if temporary.as_file().metadata()?.len() != entry.size { + return Err(Fault::new( + "mtp.size_mismatch", + "接收到的文件不完整,请重新连接后重试。", + )); + } + temporary.as_file().sync_all()?; + // Atomic no-clobber also closes the race where another app creates the destination mid-transfer. + temporary.persist_noclobber(&destination).map_err(|e| { + Fault::new( + "file.save_failed", + format!("无法保存文件(不会覆盖已有文件):{}", e.error), + ) + })?; + progress.update(entry.size, entry.size); + Ok(json!({"path":destination,"name":entry.name,"bytes":entry.size})) + } + } +} + +#[cfg(test)] +mod tests; diff --git a/curios/harmony-connector/native/src/connector/tests.rs b/curios/harmony-connector/native/src/connector/tests.rs new file mode 100644 index 0000000..a7a9165 --- /dev/null +++ b/curios/harmony-connector/native/src/connector/tests.rs @@ -0,0 +1,419 @@ +use super::*; +use std::{ + io::{Read, Write}, + time::{Duration, Instant}, +}; + +#[derive(Default)] +struct Memory { + entries: Vec<(Entry, Vec)>, + fail: bool, + truncate: bool, + slow: bool, + collide: Option, +} +struct Device(Arc>); +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); + self.0.lock().unwrap().entries.push(( + Entry { + id: 1000, + parent, + storage, + name: name.into(), + size: bytes.len() as u64, + modified: 0, + folder: false, + }, + bytes, + )); + Ok(1000) + } + 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 { + self.0.lock().unwrap().entries.push(( + Entry { + id: 999, + parent, + storage, + name: name.into(), + size: 0, + modified: 0, + folder: true, + }, + vec![], + )); + Ok(999) + } +} +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() +} + +#[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()); +} diff --git a/curios/harmony-connector/native/src/main.rs b/curios/harmony-connector/native/src/main.rs new file mode 100644 index 0000000..d7de006 --- /dev/null +++ b/curios/harmony-connector/native/src/main.rs @@ -0,0 +1,15 @@ +mod connector; +mod model; +mod mtp; +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(()); + } + 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 new file mode 100644 index 0000000..6f06636 --- /dev/null +++ b/curios/harmony-connector/native/src/model.rs @@ -0,0 +1,87 @@ +use serde::{Deserialize, Serialize}; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; + +#[derive(Debug, Clone, Serialize)] +#[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, +} +#[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(Default)] +pub struct Progress { + pub sent: AtomicU64, + pub total: AtomicU64, + pub cancel: AtomicBool, +} +impl Progress { + pub fn update(&self, sent: u64, total: u64) { + self.sent.store(sent, Ordering::Relaxed); + self.total.store(total, Ordering::Relaxed); + } + pub fn cancelled(&self) -> bool { + self.cancel.load(Ordering::Relaxed) + } +} +#[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 new file mode 100644 index 0000000..fdceae8 --- /dev/null +++ b/curios/harmony-connector/native/src/mtp.rs @@ -0,0 +1,265 @@ +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; +extern "C" { + fn hc_init(); + fn hc_version() -> *const c_char; + fn hc_scan(callback: DeviceCallback, context: *mut c_void) -> c_int; + 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), + }); +} +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 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 new file mode 100644 index 0000000..f45e037 --- /dev/null +++ b/curios/harmony-connector/native/src/mtp_bridge.c @@ -0,0 +1,109 @@ +/* Thin ownership boundary over libmtp. All calls are serialized by the Rust session. */ +#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); + +void hc_init(void) { LIBMTP_Init(); } +const char *hc_version(void) { return LIBMTP_VERSION_STRING; } + +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) 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]; + callback(context, raw->bus_location, raw->devnum, raw->device_entry.vendor_id, + raw->device_entry.product_id, raw->device_entry.vendor, raw->device_entry.product); + } + free(devices); + return 0; +} + +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/wire.rs b/curios/harmony-connector/native/src/wire.rs new file mode 100644 index 0000000..b32c5b7 --- /dev/null +++ b/curios/harmony-connector/native/src/wire.rs @@ -0,0 +1,148 @@ +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/ui/app.js b/curios/harmony-connector/ui/app.js new file mode 100644 index 0000000..ad0e58e --- /dev/null +++ b/curios/harmony-connector/ui/app.js @@ -0,0 +1,403 @@ +import { trove } from "./sdk/index.js"; +import { ROOT, bytes, date, percent, filterEntries } from "./format.js"; + +const $ = (id) => document.getElementById(id); +const state = { + connection: null, + storage: null, + path: [], + entries: [], + total: 0, + busy: false, + job: null, + timer: null, +}; +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.job?.state === "running"; + for (const id of [ + "scan", + "connect", + "disconnect", + "refresh", + "mkdir", + "upload", + "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; + $("footer-status").textContent = state.busy + ? "正在与设备通信…" + : state.job?.state === "running" + ? "传输中 · 请保持连接" + : state.connection + ? "已连接 · USB 文件传输" + : "准备就绪"; +} +async function action(operation) { + if (state.busy || state.job?.state === "running") return; + state.busy = true; + controls(); + notice(""); + try { + 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; + $("disconnect").hidden = !connection; + $("device-name").textContent = + connection?.device.name || (connection ? "鸿蒙设备" : "等待连接"); + $("device-state").textContent = connection ? "已连接 · USB" : "USB 文件传输"; + $("device-dot").classList.toggle("connected", !!connection); + $("page-title").textContent = 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); + } +} +async function syncConnection() { + const info = await native("info"); + state.connection = info.connection; + if (!state.connection) { + state.storage = null; + state.path = []; + state.entries = []; + } else if (!state.connection.storages.some((s) => s.id === state.storage)) { + state.storage = state.connection.storages[0]?.id; + state.path = []; + } + renderConnection(); + controls(); +} +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"); + if (!entry.folder) { + 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)}` + : "正在准备文件…"); + $("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); + state.timer = setTimeout(poll, 400); +} +async function poll() { + try { + state.job = await native("transferStatus"); + renderTransfer(); + if (state.job?.state === "running") schedulePoll(); + else { + await syncConnection(); + if (state.connection) await action(() => load()); + } + } catch (error) { + state.job = { + ...state.job, + state: "failed", + error: { message: "连接已中断,请重新打开插件确认设备状态。" }, + }; + renderTransfer(); + notice(error.message || String(error)); + } +} +$("scan").addEventListener("click", () => + action(async () => { + $("scan-status").textContent = "正在扫描 USB 设备…"; + $("device-picker").hidden = true; + try { + const { devices } = await native("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); + } + $("device-picker").hidden = !devices.length; + $("scan-status").textContent = devices.length + ? `发现 ${devices.length} 台设备,选择后连接。` + : "未发现设备。请检查数据线,解锁手机并选择「传输文件」。"; + } catch (error) { + $("scan-status").textContent = "扫描未完成,请检查连接后重试。"; + throw error; + } + }), +); +$("connect").addEventListener("click", () => + action(async () => { + state.connection = await native("connect", { + id: $("device-select").value, + }); + state.storage = state.connection.storages[0].id; + state.path = []; + state.job = null; + renderConnection(); + renderTransfer(); + await load(); + }), +); +$("disconnect").addEventListener("click", () => + action(async () => { + await native("disconnect"); + await syncConnection(); + $("device-picker").hidden = true; + $("scan-status").textContent = "连接已断开,可重新扫描设备。"; + }), +); +$("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(), + }); + }), +); +$("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(); + }); +}); +window.addEventListener("pagehide", () => clearTimeout(state.timer)); +await action(async () => { + await trove.ready(); + await syncConnection(); + state.job = await native("transferStatus"); + renderTransfer(); + if (state.job?.state === "running") schedulePoll(); + else if (state.connection) await load(); +}); diff --git a/curios/harmony-connector/ui/format.js b/curios/harmony-connector/ui/format.js new file mode 100644 index 0000000..bd58d7b --- /dev/null +++ b/curios/harmony-connector/ui/format.js @@ -0,0 +1,24 @@ +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), + ); +} diff --git a/curios/harmony-connector/ui/format.test.js b/curios/harmony-connector/ui/format.test.js new file mode 100644 index 0000000..55467a3 --- /dev/null +++ b/curios/harmony-connector/ui/format.test.js @@ -0,0 +1,19 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { bytes, date, percent, filterEntries } 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/style.css b/curios/harmony-connector/ui/style.css new file mode 100644 index 0000000..ea7e1ea --- /dev/null +++ b/curios/harmony-connector/ui/style.css @@ -0,0 +1,808 @@ +: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-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; + } +} diff --git a/docs/development.md b/docs/development.md index 733533c..3eb8635 100644 --- a/docs/development.md +++ b/docs/development.md @@ -2,6 +2,8 @@ 此仓库承载 Trove 正式插件与生产软件源。当前包含用于首批验收的简单插件;生产发布以 `source/index.json` 为准,本地测试材料位于 `dist/`。 +正式插件 **Harmony Connector** 的使用、构建、发布和真机验收步骤见 [Harmony Connector](harmony-connector.md)。它采用独立实现和构建命令,不属于下面的 QA 套件。 + ## 底座测试套件 15 个插件工程(14 个插件 ID,其中升级插件有两个版本),覆盖 UI、Host 服务、Rust 原生后端、跨插件服务、可选/必需依赖、版本约束、生命周期、故障恢复和升级回滚。另有 22 个故意无效的安装包和 4 个异常软件源。 diff --git a/docs/harmony-connector.md b/docs/harmony-connector.md new file mode 100644 index 0000000..bd98e21 --- /dev/null +++ b/docs/harmony-connector.md @@ -0,0 +1,83 @@ +# Harmony Connector + +插件 ID:`dev.trove.harmony-connector`。版本:`0.1.0`。 + +面向 HarmonyOS 6 的 USB 文件传输插件,提供设备选择、存储与目录浏览、分页、名称筛选、新建目录,以及单文件发送/接收、字节进度与取消。使用 libmtp 1.1.23 和 libusb 1.0.30,无需手机开发者模式、HDC、ADB 或 Homebrew 运行依赖。 + +## 安装与使用 + +在 Trove 添加 Git 软件源: + +- 仓库地址:`https://git.render.ink/trove/curios.git` +- 唯一 ID:`trove-official`(仓库 `source/index.json` 顶层 `id`)。 +- 显示名称:可以自定义,例如「Trove 官方软件源」。 + +刷新源后安装 **Harmony Connector**。私有仓库使用本机 Git 凭据。当前发布的是 Apple Silicon (`aarch64-apple-darwin`) 包,部署目标为 macOS 13 及以上;Intel 需要在对应 Mac 上构建并追加该架构产物,未发布未经验证的 Intel 包。 + +1. 解锁手机,用支持数据传输的 USB 线连接 Mac。 +2. 在手机 USB 选项中选择「传输文件」,允许访问。 +3. 扫描、选择设备并连接,打开目标文件夹。 +4. 点击「发送文件」从 Mac 选择一个文件;点击文件旁的「接收」选择 Mac 接收文件夹。 + +本插件只访问手机通过 MTP 开放的存储。目录来自设备对象 ID,不映射为 Android 路径。HarmonyOS 6 在文件传输模式下允许导出图库内容,导入图库需要图片传输模式;本版未实现 PTP 图库导入。[华为 HarmonyOS 6 官方说明](https://consumer.huawei.com/cn/support/content/zh-cn16090699/) + +没有发现设备时,检查数据线和 USB 模式、保持手机解锁,退出占用手机的其他传输应用后重试。取消或 USB 操作失败会释放会话,请重新连接。取消是协作式的,需要等待手机或 USB 超时返回,不保证立即完成。 + +## 文件保护与边界 + +- 一次传输一个普通文件;目录请先压缩。不提供删除手机内容的操作。 +- 拒绝同名覆盖。接收使用目标目录内的隐藏临时文件,校验长度并同步后通过原子无覆盖操作保存。发送会校验目标名称、存储空间和传输后设备报告的长度。 +- 长度校验不是内容哈希验证。实际设备验收需回传文件并比较 SHA-256。 +- 接收失败或正常取消会清理本次临时文件。手机端可能保留未完成的上传对象,需要在手机上检查处理。 +- 不在传输过程中关闭插件。强制结束进程、USB 长时间阻塞或断电时可能留下 `.harmony-*.part`;正常退出请求最多等待约 3.5 秒,再由底座回收进程。重新启动不会扫描或删除用户目录里的历史临时文件。 +- 目前不提供无线、批量/递归目录传输、断点续传或图库导入。跨存储、机型文件权限与大文件限制以实际设备为准。 + +## 构建、验证与发布 + +要求 macOS、Xcode Command Line Tools、Rust/Cargo、Node.js、pnpm、make、pkg-config。源码归档及哈希固定在 `vendor/harmony-connector/`。构建不需要另装 libmtp。 + +```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 +``` + +以上命令只构建 Harmony Connector,发布时保留生产索引中其他插件和历史版本。索引和不可变 `.curio` 包必须同一提交推送,不能覆盖已发布的同版本同架构文件。 + +推送后验证真实 Git 软件源: + +```sh +TROVE_APP_DIR=/absolute/path/to/app \ + pnpm run verify --remote-source https://git.render.ink/trove/curios.git \ + --source-id trove-official --source-kind git +``` + +界面测试使用独立的显式模拟设备,预览脚本和数据不进入安装包: + +```sh +pnpm run harmony:build +node tools/preview-harmony.mjs +# 打开 http://127.0.0.1:18770 +``` + +## 代码与接口 + +| 位置 | 职责 | +| --- | --- | +| `curios/harmony-connector/ui/` | 独立界面,通过版本化 Trove SDK 调用 Host 文件选择器与 `@self` | +| `native/src/connector.rs` | 会话串行化、文件校验、后台传输任务、取消及完成状态 | +| `native/src/mtp.rs`、`mtp_bridge.c` | libmtp 所有权、结构转换、文件描述符传输 | +| `native/src/wire.rs` | Trove Wire v1 认证握手、请求分发与退出 | +| `build.mjs`、`legal/` | 固定依赖构建、动态库路径修正、签名与第三方声明 | + +`@self` 接口:`info`、`devices`、`connect({id})`、`disconnect`、`list({storage,parent,offset?})`、`mkdir({storage,parent,name})`、`transfer(...)`、`transferStatus`、`cancelTransfer({id})`。根目录为 `0xffffffff`;目录响应每页最多 500 个条目,传输异步返回任务 ID,界面轮询实际进度。后端同一时刻仅执行一个 USB 操作;进度查询和取消不等待 USB 锁。重新连接后必须重新获取对象列表。 + +## 验证记录与真机验收 + +开发机的原生 USB 扫描正常返回空设备列表。完成的自动测试包含 10 项传输行为测试、2 项界面数据测试,以及 Trove 的握手、并发响应、退出协议测试;传输行为测试使用独立内存设备并实际读写临时目录。浏览器中验证模拟设备选择、导航、中文目录、筛选、不可信文件名显示、进度、完成和取消状态。 + +**这些结果不能代替 HarmonyOS 6 真机兼容性验收。** 当前未记录任何机型的真实 USB 往返或吞吐量。连接实际手机后,按[调研文档验收清单](research/harmony-connector.md#验收与尚未证明的部分)补充完整机型/系统版本、空文件/中文/二进制/大文件往返 SHA-256、图库导出、取消、拔线、重新连接及退出的结果。 + +调研依据与备选 HDC 方案见[技术调研](research/harmony-connector.md)。依赖构建与替换说明见[第三方声明](../curios/harmony-connector/legal/THIRD-PARTY.md)。 diff --git a/docs/research/harmony-connector.md b/docs/research/harmony-connector.md new file mode 100644 index 0000000..55c9bb3 --- /dev/null +++ b/docs/research/harmony-connector.md @@ -0,0 +1,114 @@ +# 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/package.json b/package.json index e3b45a8..d3c8d8a 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,9 @@ "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", diff --git a/source/index.json b/source/index.json index 0d911f2..ad3e0fa 100644 --- a/source/index.json +++ b/source/index.json @@ -2,7 +2,7 @@ "schemaVersion": 1, "id": "trove-official", "name": "Trove 官方软件源", - "generatedAt": "2026-09-09T12:17:24.404Z", + "generatedAt": "2026-09-10T02:03:28.662Z", "curios": [ { "id": "dev.trove.qa.host", @@ -194,6 +194,28 @@ ] } ] + }, + { + "id": "dev.trove.harmony-connector", + "name": "Harmony Connector", + "description": "通过 USB 连接鸿蒙设备,在 Mac 与手机之间浏览、发送和接收文件。", + "versions": [ + { + "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 + } + ] + } + ] } ] } 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 new file mode 100644 index 0000000..3ed7628 Binary files /dev/null and b/source/packages/dev.trove.harmony-connector/0.1.0/aarch64-apple-darwin.curio differ diff --git a/tools/fixtures/harmony-ui.mjs b/tools/fixtures/harmony-ui.mjs new file mode 100644 index 0000000..c215a50 --- /dev/null +++ b/tools/fixtures/harmony-ui.mjs @@ -0,0 +1,120 @@ +// 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: "HarmonyOS 6 · 界面测试", + manufacturer: "模拟设备", +}; +const storages = [ + { id: 1, name: "内部存储", capacity: 256 * 1024 ** 3, free: 128 * 1024 ** 3 }, +]; +let 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, + }, +]; +window.__TROVE_CONTEXT__ = { + curioId: "dev.trove.harmony-connector", + version: "0.1.0", + instanceId: "ui-fixture", + platform: "macos", +}; +window.__TROVE_BRIDGE__ = createMockBridge({ + "@self.info": () => ({ connection, busy: false }), + "@self.devices": () => ({ devices: [device] }), + "@self.connect": () => (connection = { device, storages }), + "@self.disconnect": () => { + connection = 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; + 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); +await import("./app.js"); diff --git a/tools/pack-harmony.mjs b/tools/pack-harmony.mjs new file mode 100644 index 0000000..e07298a --- /dev/null +++ b/tools/pack-harmony.mjs @@ -0,0 +1,47 @@ +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/prepare-release.mjs b/tools/prepare-release.mjs index 656965e..5db526e 100644 --- a/tools/prepare-release.mjs +++ b/tools/prepare-release.mjs @@ -8,6 +8,7 @@ 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)) { @@ -31,7 +32,7 @@ for (const spec of specifications) { 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.join(output, 'suite.json'), 'utf8')); +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) { diff --git a/tools/preview-harmony.mjs b/tools/preview-harmony.mjs new file mode 100644 index 0000000..37c0417 --- /dev/null +++ b/tools/preview-harmony.mjs @@ -0,0 +1,45 @@ +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/test-harmony.mjs b/tools/test-harmony.mjs new file mode 100644 index 0000000..29a4440 --- /dev/null +++ b/tools/test-harmony.mjs @@ -0,0 +1,28 @@ +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(troveBin, [ + "protocol", + "test", + "--command", + path.join(plugin, "backend/harmony-connector"), +]); +run(path.join(plugin, "backend/harmony-connector"), ["--diagnose"]); diff --git a/vendor/harmony-connector/SOURCES.md b/vendor/harmony-connector/SOURCES.md new file mode 100644 index 0000000..1aa2dbb --- /dev/null +++ b/vendor/harmony-connector/SOURCES.md @@ -0,0 +1,12 @@ +# 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/libmtp-1.1.23.tar.gz b/vendor/harmony-connector/libmtp-1.1.23.tar.gz new file mode 100644 index 0000000..dd40e72 Binary files /dev/null and b/vendor/harmony-connector/libmtp-1.1.23.tar.gz differ diff --git a/vendor/harmony-connector/libusb-1.0.30.tar.bz2 b/vendor/harmony-connector/libusb-1.0.30.tar.bz2 new file mode 100644 index 0000000..339e90c Binary files /dev/null and b/vendor/harmony-connector/libusb-1.0.30.tar.bz2 differ