feat: add Harmony Connector for HarmonyOS 6 USB file transfer
This commit is contained in:
@@ -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/
|
||||
|
||||
@@ -8,6 +8,10 @@ Trove 官方插件库。发现喜欢的 Curio,装进你的工作台,让常
|
||||
|
||||
每个 Curio 专注一件事,用轻巧的独立工具承接日常操作,把更多空间留给手头的工作。
|
||||
|
||||
### Harmony Connector
|
||||
|
||||
让文件,自在往来。一根 USB 数据线,连接 Mac 与鸿蒙设备;浏览文件、收发文档,把照片与灵感带到更大的屏幕。
|
||||
|
||||
## 按你的习惯组合
|
||||
|
||||
按需安装,自由搭配。工具各自更新,也能通过服务相互配合,让你的工作方式逐渐长成自己的样子。
|
||||
|
||||
@@ -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 |
@@ -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.`,
|
||||
);
|
||||
@@ -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
@@ -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"
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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>
|
||||
@@ -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,6 +2,8 @@
|
||||
|
||||
此仓库承载 Trove 正式插件与生产软件源。当前包含用于首批验收的简单插件;生产发布以 `source/index.json` 为准,本地测试材料位于 `dist/`。
|
||||
|
||||
正式插件 **Harmony Connector** 的使用、构建、发布和真机验收步骤见 [Harmony Connector](harmony-connector.md)。它采用独立实现和构建命令,不属于下面的 QA 套件。
|
||||
|
||||
## 底座测试套件
|
||||
|
||||
15 个插件工程(14 个插件 ID,其中升级插件有两个版本),覆盖 UI、Host 服务、Rust 原生后端、跨插件服务、可选/必需依赖、版本约束、生命周期、故障恢复和升级回滚。另有 22 个故意无效的安装包和 4 个异常软件源。
|
||||
|
||||
@@ -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)。
|
||||
@@ -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 读写。
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Binary file not shown.
@@ -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");
|
||||
@@ -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");
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"),
|
||||
);
|
||||
@@ -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"]);
|
||||
Vendored
+12
@@ -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`.
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
Reference in New Issue
Block a user