feat: add Harmony Connector for HarmonyOS 6 USB file transfer

This commit is contained in:
2026-09-10 10:03:42 +08:00
parent 72272eeb1a
commit 6ddafc3e16
35 changed files with 4006 additions and 2 deletions
+4
View File
@@ -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/
+4
View File
@@ -8,6 +8,10 @@ Trove 官方插件库。发现喜欢的 Curio,装进你的工作台,让常
每个 Curio 专注一件事,用轻巧的独立工具承接日常操作,把更多空间留给手头的工作。
### Harmony Connector
让文件,自在往来。一根 USB 数据线,连接 Mac 与鸿蒙设备;浏览文件、收发文档,把照片与灵感带到更大的屏幕。
## 按你的习惯组合
按需安装,自由搭配。工具各自更新,也能通过服务相互配合,让你的工作方式逐渐长成自己的样子。
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 128 128"><defs><linearGradient id="a" x2="1" y2="1"><stop stop-color="#7696ff"/><stop offset="1" stop-color="#3e60d4"/></linearGradient></defs><rect x="4" y="4" width="120" height="120" rx="32" fill="url(#a)"/><g fill="none" stroke="white" stroke-linecap="round" stroke-linejoin="round" stroke-width="7"><path d="M30 48h63L78 33M98 80H35l15 15"/><path d="m81 36 14 12-14 12M47 68 33 80l14 12"/></g></svg>

After

Width:  |  Height:  |  Size: 483 B

+221
View File
@@ -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.`,
);
+21
View File
@@ -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"] }
}
@@ -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/<library-name>` 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.
+242
View File
@@ -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"
@@ -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"
+19
View File
@@ -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");
}
@@ -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<AtomicBool>);
impl Drop for Gate {
fn drop(&mut self) {
self.0.store(false, Ordering::Release);
}
}
struct Session {
device: Box<dyn DeviceIo>,
}
struct Job {
id: u64,
progress: Arc<Progress>,
state: &'static str,
result: Option<Value>,
error: Option<Fault>,
}
pub struct Connector {
gate: Arc<AtomicBool>,
session: Mutex<Option<Session>>,
connection: Mutex<Value>,
job: Mutex<Option<Job>>,
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<T: DeserializeOwned>(value: Value) -> Result<T> {
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::<Vec<_>>().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<Self> {
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<Gate> {
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<Self>, method: &str, value: Value) -> Result<Value> {
match method {
"info" => {
return Ok(
json!({"curioId":"dev.trove.harmony-connector","version":env!("CARGO_PKG_VERSION"),"pid":std::process::id(),"transport":"mtp","libmtp":mtp::version(),"connection":self.connection.lock().unwrap().clone(),"busy":self.busy()}),
)
}
"transferStatus" => return Ok(self.status()),
"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<Value> {
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<Self>, transfer: Transfer, gate: Gate) -> Result<Value> {
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<dyn DeviceIo>) -> Arc<Self> {
let connector = Self::new();
*connector.session.lock().unwrap() = Some(Session { device });
connector
}
}
fn run_transfer(
device: &mut dyn DeviceIo,
transfer: Transfer,
progress: &Progress,
) -> Result<Value> {
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;
@@ -0,0 +1,419 @@
use super::*;
use std::{
io::{Read, Write},
time::{Duration, Instant},
};
#[derive(Default)]
struct Memory {
entries: Vec<(Entry, Vec<u8>)>,
fail: bool,
truncate: bool,
slow: bool,
collide: Option<std::path::PathBuf>,
}
struct Device(Arc<Mutex<Memory>>);
impl DeviceIo for Device {
fn storages(&mut self) -> Result<Vec<Storage>> {
Ok(vec![Storage {
id: 1,
name: "内部存储".into(),
capacity: u64::MAX,
free: u64::MAX,
}])
}
fn list(&mut self, storage: u32, parent: u32) -> Result<Vec<Entry>> {
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<Entry> {
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<u32> {
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<u32> {
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<Connector>, Arc<Mutex<Memory>>) {
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<Connector>) -> 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<Connector>, directory: &Path) -> Value {
connector
.call(
"transfer",
json!({"direction":"download","object":1,"directory":directory}),
)
.unwrap();
wait(connector)
}
fn directory_names(directory: &Path) -> Vec<String> {
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());
}
@@ -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())
}
@@ -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<String>) -> 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<std::io::Error> for Fault {
fn from(error: std::io::Error) -> Self {
Self::new("file.io_error", format!("无法读写 Mac 文件:{error}"))
}
}
pub type Result<T> = std::result::Result<T, Fault>;
#[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,
},
}
+265
View File
@@ -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<Vec<Storage>>;
fn list(&mut self, storage: u32, parent: u32) -> Result<Vec<Entry>>;
fn metadata(&mut self, object: u32) -> Result<Entry>;
fn upload(
&mut self,
file: &File,
name: &str,
size: u64,
storage: u32,
parent: u32,
progress: &Progress,
) -> Result<u32>;
fn download(&mut self, object: u32, file: &File, progress: &Progress) -> Result<()>;
fn mkdir(&mut self, name: &str, storage: u32, parent: u32) -> Result<u32>;
}
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<DeviceInfo>) };
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<Storage>) }.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<Entry>) }.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<Vec<DeviceInfo>> {
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<c_void>);
// 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<Self> {
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<Vec<Storage>> {
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<Vec<Entry>> {
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<Entry> {
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<u32> {
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<u32> {
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)
}
}
@@ -0,0 +1,109 @@
/* Thin ownership boundary over libmtp. All calls are serialized by the Rust session. */
#include <libmtp.h>
#include <stdint.h>
#include <stdlib.h>
#include <stdio.h>
#include <string.h>
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;
}
+148
View File
@@ -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<Mutex<UnixStream>>);
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<Value>) {
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<Value> {
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<Connector>) -> 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(())
}
+403
View File
@@ -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
? '<svg width="22" height="20" viewBox="0 0 24 20" fill="currentColor"><path d="M2 2h7l2 3h11v12a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V4a2 2 0 0 1 1-2Z" opacity=".8"/></svg>'
: '<svg width="19" height="23" viewBox="0 0 20 24" fill="none" stroke="currentColor" stroke-width="1.3"><path d="M4 1h8l5 5v16H3V1Z"/><path d="M12 1v6h5M6 12h8M6 16h6"/></svg>';
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();
});
+24
View File
@@ -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),
);
}
@@ -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: "<script>.txt" }];
assert.deepEqual(filterEntries(entries, " final "), [entries[0]]);
assert.deepEqual(filterEntries(entries, "设计"), [entries[0]]);
assert.equal(filterEntries(entries, "<script>")[0].name, "<script>.txt");
assert.equal(entries.length, 2);
});
+194
View File
@@ -0,0 +1,194 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="color-scheme" content="light dark" />
<title>Harmony Connector</title>
<link rel="stylesheet" href="./style.css" />
</head>
<body>
<div class="app">
<aside class="sidebar">
<div class="brand">
<div class="brand-mark" aria-hidden="true">↔</div>
<div>
<strong>Harmony<br />Connector</strong><span>让文件,自在往来</span>
</div>
</div>
<div class="section-label">设备</div>
<div class="device-card">
<span class="device-icon" aria-hidden="true">▯</span>
<div>
<strong id="device-name">等待连接</strong
><span id="device-state">USB 文件传输</span>
</div>
<i id="device-dot"></i>
</div>
<button id="disconnect" class="subtle" hidden>断开连接</button>
<div class="section-label storage-label">存储位置</div>
<nav id="storages" aria-label="设备存储">
<p class="muted">连接后查看可用存储</p>
</nav>
<div class="sidebar-bottom">
<span class="small-badge">HarmonyOS 6</span>
<p>文件留在你的设备之间。<br />通过 USB 直接传输。</p>
<button id="guide-open" class="text-button">
连接与使用帮助 <span aria-hidden="true">↗</span>
</button>
</div>
</aside>
<main>
<header class="topbar">
<div>
<p class="eyebrow">YOUR FILES, CONNECTED</p>
<h1 id="page-title">连接你的鸿蒙设备</h1>
</div>
<button id="scan" class="secondary">扫描设备</button>
</header>
<div id="notice" class="notice" role="alert" hidden>
<span id="notice-text"></span
><button id="notice-close" aria-label="关闭提示">×</button>
</div>
<section id="welcome" class="welcome">
<div class="connection-art" aria-hidden="true">
<div class="mac">
<div><span>↗</span></div>
<i></i>
</div>
<div class="connection-line"><i></i><i></i><i></i></div>
<div class="phone"><span></span><b>↙</b><i></i></div>
</div>
<span class="small-badge">MAC ↔ HARMONY</span>
<h2>一根数据线,<br />连接两个世界。</h2>
<p class="intro">
照片、文档、灵感碎片。<br />在 Mac 与鸿蒙设备之间,轻松收发。
</p>
<ol class="steps">
<li><b>1</b>解锁手机并连接 USB 数据线</li>
<li><b>2</b>在手机上选择「传输文件」</li>
<li><b>3</b>扫描设备,开始浏览</li>
</ol>
<div id="device-picker" hidden>
<label for="device-select">选择设备</label>
<div class="device-picker-row">
<select id="device-select"></select
><button id="connect" class="primary">连接设备</button>
</div>
</div>
<p id="scan-status" class="muted" role="status">
无需开发者模式,也无需安装手机端应用。
</p>
</section>
<section id="browser" class="browser" hidden>
<div class="toolbar">
<nav id="breadcrumbs" aria-label="当前位置"></nav>
<div class="actions">
<button id="refresh" class="secondary" title="刷新当前文件夹">
刷新</button
><button id="mkdir" class="secondary">新建文件夹</button
><button id="upload" class="primary">↑ 发送文件</button>
</div>
</div>
<div class="list-heading">
<label class="search"
><span aria-hidden="true">⌕</span
><input
id="search"
type="search"
placeholder="筛选已加载的文件"
aria-label="筛选已加载的文件" /></label
><span id="file-count" class="muted"></span>
</div>
<div class="file-table-wrap">
<table>
<thead>
<tr>
<th>名称</th>
<th>大小</th>
<th>修改时间</th>
<th><span class="sr-only">操作</span></th>
</tr>
</thead>
<tbody id="files"></tbody>
</table>
<p id="empty" class="empty" hidden>
这个文件夹还是空的,发送第一个文件吧。
</p>
<button id="load-more" class="secondary load-more" hidden>
加载更多
</button>
</div>
<p class="browser-footnote">
接收保留原文件名,同名文件不会被覆盖。一次传输一个文件。
</p>
</section>
<section id="transfer" class="transfer" aria-label="文件传输" hidden>
<div class="transfer-top">
<div>
<strong id="transfer-title">正在传输</strong>
<p id="transfer-detail" role="status"></p>
</div>
<button id="cancel" class="secondary">取消传输</button>
</div>
<progress
id="progress"
max="100"
value="0"
aria-label="传输进度"
></progress>
</section>
<footer>
<span><i class="status-dot"></i> 本地直连</span
><span id="footer-status" role="status">准备就绪</span>
</footer>
</main>
</div>
<dialog id="guide">
<div class="dialog-header">
<h2>连接,开始传输</h2>
<button id="guide-close" aria-label="关闭帮助">×</button>
</div>
<p>适用于 HarmonyOS 6 开放的 USB 文件传输存储。</p>
<ol>
<li>使用支持数据传输的 USB 线连接 Mac 与手机,并保持手机解锁。</li>
<li>在手机 USB 选项中选择「传输文件」,按提示允许访问。</li>
<li>
点击「扫描设备」,选择手机并连接。打开文件夹后可发送文件,或点击文件旁的「接收」。
</li>
</ol>
<h3>没有发现设备?</h3>
<p>
重新插拔数据线,检查手机 USB
模式。退出可能占用手机的其他文件传输应用,再扫描一次。
</p>
<h3>照片与图库</h3>
<p>
HarmonyOS 6
的「传输文件」模式可导出图库内容;导入图库需「传输图片」模式。当前版本仅支持文件传输模式,向普通文件夹发送照片不等于导入图库。
</p>
<h3>取消与关闭</h3>
<p>
取消需要等待手机响应,随后请重新连接。手机可能保留未完成的上传文件;可在手机上检查并清理。请等待传输结束再关闭插件。拔线、强制退出或系统断电时,Mac
接收文件夹可能留下 .harmony-*.part 临时文件。
</p>
<p class="muted">
文件夹请先压缩后发送。不同机型开放的目录与访问权限以手机为准。
</p>
</dialog>
<dialog id="folder-dialog">
<form id="folder-form">
<h2>新建文件夹</h2>
<label for="folder-name">文件夹名称</label
><input id="folder-name" required maxlength="100" autocomplete="off" />
<p id="folder-error" class="field-error" role="alert"></p>
<div class="dialog-actions">
<button type="button" id="folder-cancel" class="secondary">
取消</button
><button type="submit" class="primary">创建</button>
</div>
</form>
</dialog>
<script type="module" src="./app.js"></script>
</body>
</html>
+808
View File
@@ -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;
}
}
+2
View File
@@ -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 个异常软件源。
+83
View File
@@ -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)。
+114
View File
@@ -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 <device> shell <command>
hdc -t <device> file send <local-file> <remote-file>
hdc -t <device> file recv <remote-file> <local-file>
hdc tconn <ip>:<port>
```
这些命令来自 [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 读写。
+3
View File
@@ -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",
+23 -1
View File
@@ -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
}
]
}
]
}
]
}
+120
View File
@@ -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: "<script>alert(1)</script>.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");
+47
View File
@@ -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");
+2 -1
View File
@@ -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) {
+45
View File
@@ -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"),
);
+28
View File
@@ -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"]);
+12
View File
@@ -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`.
Binary file not shown.
Binary file not shown.