mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-07 08:31:00 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bb5c550ac7 |
No files matched your search
@@ -4,6 +4,7 @@ const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const MAX_DOWNLOADS = 500;
|
||||
const PROGRESS_PERSIST_INTERVAL_MS = 1000;
|
||||
const STATUSES = new Set(["waiting", "progressing", "completed", "cancelled", "interrupted"]);
|
||||
|
||||
function normalizeDownload(raw) {
|
||||
@@ -40,11 +41,29 @@ function createDownloadStore(app) {
|
||||
} catch (error) {
|
||||
if (error?.code !== "ENOENT") console.warn("[HostBrowser] Could not read downloads:", error?.message);
|
||||
}
|
||||
let dirty = false;
|
||||
let progressTimer = null;
|
||||
const persist = () => {
|
||||
if (progressTimer !== null) clearTimeout(progressTimer);
|
||||
progressTimer = null;
|
||||
if (!dirty) return;
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||
const temporary = `${file}.${process.pid}.tmp`;
|
||||
fs.writeFileSync(temporary, JSON.stringify(items), { mode: 0o600 });
|
||||
fs.renameSync(temporary, file);
|
||||
dirty = false;
|
||||
};
|
||||
const scheduleProgressPersist = () => {
|
||||
if (progressTimer !== null) return;
|
||||
progressTimer = setTimeout(() => {
|
||||
progressTimer = null;
|
||||
try { persist(); }
|
||||
catch (error) {
|
||||
// Keep dirty state for the next update or shutdown, without a retry loop.
|
||||
console.warn("[HostBrowser] Could not save download progress:", error?.message);
|
||||
}
|
||||
}, PROGRESS_PERSIST_INTERVAL_MS);
|
||||
progressTimer.unref?.();
|
||||
};
|
||||
const isMissing = (item) => item.status === "completed" && (!item.savePath || !fs.existsSync(item.savePath));
|
||||
return {
|
||||
@@ -54,26 +73,36 @@ function createDownloadStore(app) {
|
||||
.map((item) => ({ ...item, missing: isMissing(item) }));
|
||||
},
|
||||
get(hostId, id) { return items.find((item) => item.hostId === hostId && item.id === id) || null; },
|
||||
upsert(raw) {
|
||||
flush: persist,
|
||||
upsert(raw, { deferProgress = false } = {}) {
|
||||
const next = normalizeDownload(raw);
|
||||
const index = items.findIndex((item) => item.id === next.id);
|
||||
if (index >= 0 && items[index].hostId !== next.hostId) throw new Error("Download belongs to another host");
|
||||
const previous = items[index];
|
||||
// Only byte progress is replaceable. New records, state transitions and
|
||||
// recovery metadata remain durable before returning to the caller.
|
||||
const replaceableProgress = deferProgress && previous?.status === "progressing"
|
||||
&& next.status === "progressing"
|
||||
&& ["filename", "url", "savePath", "total", "startedAt", "endedAt"].every((key) => previous[key] === next[key]);
|
||||
if (index >= 0) items[index] = next;
|
||||
else items.unshift(next);
|
||||
items = items.sort((a, b) => b.startedAt - a.startedAt).slice(0, MAX_DOWNLOADS);
|
||||
persist();
|
||||
dirty = true;
|
||||
if (replaceableProgress) scheduleProgressPersist();
|
||||
else persist();
|
||||
return next;
|
||||
},
|
||||
remove(hostId, id) {
|
||||
const next = items.filter((item) => item.hostId !== hostId || item.id !== id || ["waiting", "progressing"].includes(item.status));
|
||||
if (next.length === items.length) return false;
|
||||
items = next;
|
||||
dirty = true;
|
||||
persist();
|
||||
return true;
|
||||
},
|
||||
clearFinished(hostId) {
|
||||
const next = items.filter((item) => item.hostId !== hostId || ["waiting", "progressing"].includes(item.status));
|
||||
if (next.length !== items.length) { items = next; persist(); }
|
||||
if (next.length !== items.length) { items = next; dirty = true; persist(); }
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -58,3 +58,112 @@ test("cleanup removes only its own empty reservation", (t) => {
|
||||
reserved.cleanupEmpty();
|
||||
assert.equal(fs.existsSync(reserved.savePath), true, "a replacement made by someone else is left alone");
|
||||
});
|
||||
|
||||
function progressFixture(t) {
|
||||
const vm = require("node:vm");
|
||||
const directory = fs.mkdtempSync(getTempFilePath("host-browser-progress-"));
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
|
||||
const file = path.join(directory, "host-browser-downloads.json");
|
||||
const timers = new Map();
|
||||
let nextTimer = 0;
|
||||
let writes = 0;
|
||||
let fail = false;
|
||||
const warnings = [];
|
||||
const fakeFs = { ...fs, writeFileSync(...args) {
|
||||
if (fail) throw new Error("disk full");
|
||||
writes++;
|
||||
return fs.writeFileSync(...args);
|
||||
} };
|
||||
const module = { exports: {} };
|
||||
vm.runInNewContext(fs.readFileSync(require.resolve("./downloadStore.cjs"), "utf8"), {
|
||||
module, process, console: { warn: (...args) => warnings.push(args) },
|
||||
require: (name) => name === "node:fs" ? fakeFs : require(name),
|
||||
setTimeout(fn, delay) { assert.equal(delay, 1000); const token = { id: ++nextTimer, unref() {} }; timers.set(token, fn); return token; },
|
||||
clearTimeout(token) { timers.delete(token); },
|
||||
});
|
||||
const store = module.exports.createDownloadStore({ getPath: () => directory });
|
||||
const row = { id: "active", hostId: "a", filename: "file.zip", savePath: path.join(directory, "file.zip"), status: "progressing", startedAt: 1, total: 100, received: 0 };
|
||||
return { store, row, timers, warnings, get writes() { return writes; }, fail(value) { fail = value; },
|
||||
saved: () => JSON.parse(fs.readFileSync(file, "utf8")),
|
||||
tick() { const callbacks = [...timers.values()]; timers.clear(); for (const fn of callbacks) fn(); },
|
||||
};
|
||||
}
|
||||
|
||||
test("progress is immediate in memory and coalesced across downloads, then terminal state is durable", (t) => {
|
||||
const f = progressFixture(t);
|
||||
f.store.upsert(f.row);
|
||||
f.store.upsert({ ...f.row, id: "second" });
|
||||
for (let received = 1; received <= 20; received++) {
|
||||
for (const id of ["active", "second"]) f.store.upsert({ ...f.row, id, received }, { deferProgress: true });
|
||||
}
|
||||
assert.equal(f.writes, 2);
|
||||
assert.equal(f.timers.size, 1);
|
||||
assert.equal(f.store.list("a")[0].received, 20);
|
||||
assert.equal(f.saved()[0].received, 0);
|
||||
f.tick();
|
||||
assert.equal(f.writes, 3);
|
||||
assert.equal(f.saved()[0].received, 20);
|
||||
f.store.upsert({ ...f.row, received: 50 }, { deferProgress: true });
|
||||
f.store.upsert({ ...f.row, status: "completed", received: 100, endedAt: 2 });
|
||||
assert.equal(f.timers.size, 0);
|
||||
assert.equal(f.saved().find((item) => item.id === "active").status, "completed");
|
||||
f.tick();
|
||||
assert.equal(f.writes, 4);
|
||||
});
|
||||
|
||||
test("new records, metadata and interrupted/cancelled transitions never defer", (t) => {
|
||||
const f = progressFixture(t);
|
||||
f.store.upsert(f.row, { deferProgress: true });
|
||||
assert.equal(f.writes, 1);
|
||||
for (const update of [{ savePath: "changed" }, { total: 200 }, { status: "interrupted" }, { status: "cancelled" }]) {
|
||||
const next = { ...f.store.get("a", "active"), ...update };
|
||||
const before = f.writes;
|
||||
f.store.upsert(next, { deferProgress: true });
|
||||
assert.equal(f.writes, before + 1);
|
||||
assert.equal(f.timers.size, 0);
|
||||
}
|
||||
assert.throws(() => f.store.upsert({ ...f.row, hostId: "b" }, { deferProgress: true }), /another host/);
|
||||
assert.equal(f.store.get("a", "active").status, "cancelled");
|
||||
});
|
||||
|
||||
test("flush persists pending progress and removal cannot be resurrected by a pending timer", (t) => {
|
||||
const f = progressFixture(t);
|
||||
f.store.upsert(f.row);
|
||||
f.store.upsert({ ...f.row, received: 10 }, { deferProgress: true });
|
||||
f.store.flush();
|
||||
assert.equal(f.timers.size, 0);
|
||||
assert.equal(f.saved()[0].received, 10);
|
||||
f.store.flush();
|
||||
assert.equal(f.writes, 2);
|
||||
f.store.upsert({ ...f.row, id: "finished", status: "completed" });
|
||||
f.store.upsert({ ...f.row, received: 20 }, { deferProgress: true });
|
||||
assert.equal(f.store.remove("b", "finished"), false);
|
||||
assert.equal(f.store.remove("a", "active"), false);
|
||||
assert.equal(f.store.remove("a", "finished"), true);
|
||||
assert.equal(f.timers.size, 0);
|
||||
f.tick();
|
||||
assert.equal(f.saved().length, 1);
|
||||
assert.equal(f.saved()[0].received, 20);
|
||||
f.store.upsert({ ...f.row, id: "finished", status: "cancelled" });
|
||||
f.store.upsert({ ...f.row, received: 30 }, { deferProgress: true });
|
||||
f.store.clearFinished("a");
|
||||
assert.equal(f.timers.size, 0);
|
||||
assert.equal(f.saved().length, 1);
|
||||
assert.equal(f.saved()[0].received, 30);
|
||||
});
|
||||
|
||||
test("failed deferred writes retain dirty state, preserve disk snapshot and do not spin", (t) => {
|
||||
const f = progressFixture(t);
|
||||
f.store.upsert(f.row);
|
||||
f.store.upsert({ ...f.row, received: 10 }, { deferProgress: true });
|
||||
f.fail(true);
|
||||
assert.doesNotThrow(() => f.tick());
|
||||
assert.equal(f.warnings.length, 1);
|
||||
assert.equal(f.timers.size, 0);
|
||||
assert.equal(f.saved()[0].received, 0);
|
||||
assert.throws(() => f.store.flush(), /disk full/);
|
||||
f.fail(false);
|
||||
f.store.flush();
|
||||
assert.equal(f.saved()[0].received, 10);
|
||||
assert.equal(f.timers.size, 0);
|
||||
});
|
||||
@@ -635,13 +635,14 @@ function createHostBrowserManager({ electron, app, portForwardingBridge }) {
|
||||
let lastProgressAt = 0;
|
||||
item.on("updated", (_updateEvent, status) => {
|
||||
const now = Date.now();
|
||||
if (status === "progressing" && now - lastProgressAt < 200) return;
|
||||
const paused = item.isPaused?.() === true;
|
||||
if (status === "progressing" && !paused && now - lastProgressAt < 200) return;
|
||||
lastProgressAt = now;
|
||||
const previous = downloadStore.get(hostId, id);
|
||||
if (!previous) return;
|
||||
const savePath = item.getSavePath() || previous.savePath;
|
||||
downloadStore.upsert({ ...previous, filename: savePath ? path.basename(savePath) : filename,
|
||||
savePath, status, received: item.getReceivedBytes(), total: item.getTotalBytes() });
|
||||
savePath, status, received: item.getReceivedBytes(), total: item.getTotalBytes() }, { deferProgress: !paused });
|
||||
notifyDownloads(record);
|
||||
});
|
||||
item.once("done", (_doneEvent, status) => {
|
||||
@@ -936,6 +937,7 @@ function createHostBrowserManager({ electron, app, portForwardingBridge }) {
|
||||
return { focus, open, updatePanelLayout, closePanel, openPanelUrl, getPanelState, runCommand, closeAll: () => {
|
||||
unsubscribeTunnelStatus();
|
||||
for (const record of [...windows.values()]) { closeRecord(record); if (!record.embedded && !record.win.isDestroyed()) record.win.destroy(); }
|
||||
downloadStore.flush();
|
||||
} };
|
||||
}
|
||||
|
||||
|
||||
@@ -68,3 +68,52 @@ test("late download notifications ignore closed browser chrome in a live termina
|
||||
});
|
||||
assert.doesNotThrow(() => notify(record));
|
||||
});
|
||||
|
||||
test("manager shutdown flushes download history after closing windows", () => {
|
||||
const source = readFileSync(require.resolve("./hostBrowserManager.cjs"), "utf8");
|
||||
const start = source.indexOf("closeAll: () => {");
|
||||
const end = source.indexOf("} };", start);
|
||||
assert.ok(start > 0 && end > start);
|
||||
const calls = [];
|
||||
const closeAll = vm.runInNewContext(`(() => {${source.slice(start + "closeAll: () => {".length, end)}})`, {
|
||||
unsubscribeTunnelStatus: () => calls.push("unsubscribe"),
|
||||
windows: new Map([[1, { embedded: true }]]),
|
||||
closeRecord: () => calls.push("close"),
|
||||
downloadStore: { flush: () => calls.push("flush") },
|
||||
});
|
||||
closeAll();
|
||||
assert.deepEqual(calls, ["unsubscribe", "close", "flush"]);
|
||||
});
|
||||
|
||||
test("download handler defers only live progress, not pause or completion", () => {
|
||||
const { EventEmitter } = require("node:events");
|
||||
const path = require("node:path");
|
||||
const source = readFileSync(require.resolve("./hostBrowserManager.cjs"), "utf8");
|
||||
const start = source.indexOf("record.downloadHandler =");
|
||||
const end = source.indexOf('pageSession.on("will-download"', start);
|
||||
const calls = [];
|
||||
let previous;
|
||||
let now = 1000;
|
||||
const record = { route: "connected", embedded: true, downloads: new Map(), tabs: new Map([[1, { view: { webContents: "owned" } }]]) };
|
||||
vm.runInNewContext(source.slice(start, end), {
|
||||
record, path, randomUUID: () => "id", hostId: "host", app: { getPath: () => "/downloads" },
|
||||
reserveDownloadFile: () => ({ savePath: "/downloads/file", cleanupEmpty() {} }),
|
||||
downloadStore: { get: () => previous, upsert(row, options) { previous = row; calls.push({ row, options }); } },
|
||||
notifyDownloads() {}, Date: { now: () => now },
|
||||
});
|
||||
const item = Object.assign(new EventEmitter(), {
|
||||
paused: false, isPaused() { return this.paused; }, getFilename: () => "file", getURL: () => "https://example.test/file",
|
||||
getTotalBytes: () => 100, getReceivedBytes: () => 10, getSavePath: () => "/downloads/file", setSavePath() {},
|
||||
});
|
||||
record.downloadHandler({ preventDefault() { assert.fail("rejected owned download"); } }, item, "owned");
|
||||
assert.equal(calls[0].options, undefined);
|
||||
item.emit("updated", {}, "progressing");
|
||||
assert.equal(calls[1].options.deferProgress, true);
|
||||
now += 1;
|
||||
item.paused = true;
|
||||
item.emit("updated", {}, "progressing");
|
||||
assert.equal(calls[2].options.deferProgress, false, "pause bypasses the byte-progress throttle");
|
||||
item.emit("done", {}, "completed");
|
||||
assert.equal(calls[3].options, undefined);
|
||||
assert.equal(calls[3].row.status, "completed");
|
||||
});
|
||||
Reference in new issue
Block a user