fix(harmony): discover devices and release disconnected sessions asynchronously

Use USB descriptors and MTP identity instead of the static libmtp product label. Monitor attachment, discard stale UI responses, clear disconnected views, and release slow MTP sessions in the background while preserving operation serialization.

Publish 0.1.1 with immutable source history. Cover discovery, names, unplugging and slow disconnect with native and UI regressions. Verify real Pura 70 Ultra identity and prompt disconnect; run the newest published backend during Git-source checks.
This commit is contained in:
2026-09-10 10:57:10 +08:00
parent 6ddafc3e16
commit 6bdcfdb44e
22 changed files with 1227 additions and 82 deletions
+259
View File
@@ -0,0 +1,259 @@
import { test, afterEach } from "node:test";
import assert from "node:assert/strict";
import { mkdtemp, cp, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { pathToFileURL, fileURLToPath } from "node:url";
import { setImmediate as flush } from "node:timers/promises";
import { JSDOM } from "jsdom";
const root = fileURLToPath(new URL("../", import.meta.url));
let cleanup;
afterEach(async () => {
if (cleanup) {
await cleanup();
cleanup = null;
}
});
async function settle() {
for (let i = 0; i < 6; i++) await flush();
}
async function app(t) {
t.mock.timers.enable({ apis: ["setTimeout"] });
const directory = await mkdtemp(path.join(tmpdir(), "harmony-ui-"));
await cp(path.join(root, "curios/harmony-connector/ui"), directory, {
recursive: true,
});
await cp(
path.dirname(fileURLToPath(import.meta.resolve("@trove/plugin-sdk"))),
path.join(directory, "sdk"),
{ recursive: true },
);
const dom = new JSDOM(
await readFile(path.join(directory, "index.html"), "utf8"),
{ url: "https://curio.test" },
);
globalThis.window = dom.window;
globalThis.document = dom.window.document;
const device = {
id: "7:3",
name: "daniel",
model: "HUAWEI Pura 70 Ultra",
manufacturer: "HUAWEI",
};
const storages = [{ id: 1, name: "内部存储", free: 1e9, capacity: 2e9 }];
const model = {
attached: true,
connection: null,
calls: [],
job: null,
busy: false,
monitorWait: null,
};
const { createMockBridge } = await import(
pathToFileURL(path.join(directory, "sdk/index.js"))
);
const handler = (method, fn) => [
`@self.${method}`,
async (params) => {
model.calls.push(method);
return structuredClone(await fn(params));
},
];
window.__TROVE_CONTEXT__ = {
curioId: "dev.trove.harmony-connector",
version: "0.1.0",
instanceId: "test",
platform: "macos",
};
window.__TROVE_BRIDGE__ = createMockBridge(
Object.fromEntries([
handler("info", () => ({ connection: model.connection, busy: false })),
handler("devices", () => ({ devices: model.attached ? [device] : [] })),
handler("connect", () => (model.connection = { device, storages })),
handler("disconnect", () => {
model.connection = null;
return { disconnected: true, busy: model.busy };
}),
handler("list", () => ({
entries: [
{
id: 2,
parent: 0xffffffff,
storage: 1,
name: "旅行.pdf",
size: 100,
folder: false,
},
],
total: 1,
})),
handler("transferStatus", () => model.job),
handler("transfer", () => {
model.job = { id: 1, state: "running", sent: 0, total: 100 };
return { id: 1 };
}),
["host.dialog.openFile", () => ({ path: "/fixture/file.txt" })],
handler("monitor", async () => {
const snapshot = {
connection: model.connection,
devices: model.attached ? [device] : [],
busy: model.busy,
};
if (!model.attached) model.connection = null;
if (model.monitorWait) {
await model.monitorWait;
return snapshot;
}
return { ...snapshot, connection: model.connection };
}),
]),
);
cleanup = async () => {
dom.window.dispatchEvent(new dom.window.Event("pagehide"));
dom.window.close();
delete globalThis.window;
delete globalThis.document;
await rm(directory, { recursive: true, force: true });
};
await import(pathToFileURL(path.join(directory, "app.js")));
await settle();
const $ = (id) => document.getElementById(id);
const click = async (id) => {
$(id).click();
await settle();
};
const tick = async () => {
t.mock.timers.tick(2500);
await settle();
};
const connect = async () => {
await click("scan");
await click("connect");
assert.equal($("browser").hidden, false);
};
return { $, click, tick, connect, model };
}
test("opening the plugin discovers already attached devices without clicking scan", async (t) => {
const { $ } = await app(t);
assert.equal($("device-picker").hidden, false);
assert.equal($("device-select").options.length, 1);
});
test("plugging in and unplugging refreshes the picker without manual scanning", async (t) => {
const { $, tick, model } = await app(t);
model.attached = false;
await tick();
assert.equal($("device-picker").hidden, true);
model.attached = true;
await tick();
assert.equal($("device-picker").hidden, false);
model.attached = false;
await tick();
assert.equal($("device-select").options.length, 0);
});
test("unplugging an idle connected phone clears the browser and connected badge", async (t) => {
const { $, connect, tick, model } = await app(t);
await connect();
model.attached = false;
await tick();
assert.equal($("browser").hidden, true);
assert.equal($("device-dot").classList.contains("connected"), false);
assert.equal($("files").children.length, 0);
assert.equal($("device-select").options.length, 0);
});
test("manual disconnect clears files, filters and completed transfers", async (t) => {
const { $, connect, click, tick, model } = await app(t);
await connect();
await click("upload");
model.job = {
id: 1,
state: "completed",
result: { name: "旅行.pdf", bytes: 100 },
};
await tick();
assert.equal($("transfer").hidden, false);
$("search").value = "旅行";
await click("disconnect");
await tick();
assert.equal($("browser").hidden, true);
assert.equal($("files").children.length, 0);
assert.equal($("search").value, "");
assert.equal($("transfer").hidden, true);
assert.equal($("footer-status").textContent.includes("已连接"), false);
});
test("unplugging after a completed transfer clears the old success panel", async (t) => {
const { $, connect, click, tick, model } = await app(t);
await connect();
await click("upload");
model.job = {
id: 1,
state: "completed",
result: { name: "旅行.pdf", bytes: 100 },
};
await tick();
assert.equal($("transfer").hidden, false);
model.attached = false;
await tick();
assert.equal($("transfer").hidden, true);
});
test("connected device displays the friendly name and actual model separately", async (t) => {
const { $, connect } = await app(t);
await connect();
assert.equal($("device-name").textContent, "daniel");
assert.equal($("device-model").textContent, "HUAWEI Pura 70 Ultra");
assert.equal($("device-model").hidden, false);
});
test("an in-flight USB snapshot cannot restore a manually disconnected session", async (t) => {
const { $, connect, click, tick, model } = await app(t);
await connect();
let resolve;
model.monitorWait = new Promise((done) => {
resolve = done;
});
await tick();
const disconnect = click("disconnect");
resolve();
await disconnect;
await settle();
assert.equal($("browser").hidden, true);
assert.equal($("files").children.length, 0);
});
test("closing the UI stops automatic scanning", async (t) => {
const { tick, model } = await app(t);
const before = model.calls.length;
window.dispatchEvent(new window.Event("pagehide"));
await tick();
await tick();
assert.equal(model.calls.length, before);
});
test("USB disappearance during a transfer clears the browser while cleanup is pending", async (t) => {
const { $, connect, click, tick, model } = await app(t);
await connect();
await click("upload");
model.attached = false;
model.busy = true;
await tick();
assert.equal($("browser").hidden, true);
assert.equal($("device-dot").classList.contains("connected"), false);
assert.equal($("scan").disabled, true);
assert.equal($("footer-status").textContent.includes("设备已断开"), true);
});
test("manual disconnect keeps the UI responsive while USB cleanup is pending", async (t) => {
const { $, connect, click, tick, model } = await app(t);
await connect();
model.busy = true;
const scans = model.calls.filter((method) => method === "devices").length;
await click("disconnect");
assert.equal($("browser").hidden, true);
assert.equal($("files").children.length, 0);
assert.equal($("scan").disabled, true);
assert.equal($("footer-status").textContent.includes("设备已断开"), true);
assert.equal(
model.calls.filter((method) => method === "devices").length,
scans,
);
model.busy = false;
await tick();
assert.equal($("connect").disabled, false);
assert.equal($("browser").hidden, true);
});