aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-client/src/main.js
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-09 14:28:40 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-09 14:28:40 +0200
commit7e2d078fe1870d256ae47781bee6ac4f454edf24 (patch)
tree05689049fd48f01ebbdb9995d5189cba15cee052 /packages/meshbay-client/src/main.js
parent813d18424ec57963bb56e6f40824a2db0ccce50d (diff)
parente6f895c473a0b19e7b186889c1836d3945bc880b (diff)
downloadmeshbay-7e2d078fe1870d256ae47781bee6ac4f454edf24.tar.gz
Merge branch 'fix/large-download-paths'
Concurrent-transfer limits, with the queue, the pause and the flag day. A node now caps how many transfers it runs at once (8 downloads, 8 uploads, node-wide) and how many one member may run in one group (2 by default, operator-signed). Beyond that the node answers "queued" and the client waits its turn, visibly, in the transfers panel — and a slot that frees starts whatever is next, skipping past a member who is at their own cap rather than letting them stall everyone behind them. Browsing is never subject to a slot: not the poster grid, not the covers, not opening a photo to look at it. That is structural — a transfer is what the transfers widget shows — and the exemption is bounded rather than open, at two files in flight per session, because an exemption with no bound is a leaseless branch under another name. Transfers can be cancelled, and now paused and resumed. A paused one holds nothing: its slot goes back at once and resuming rejoins the queue at the tail. Uploads survive the connection that started them and resume where the node stopped, asked for inside the seal rather than on a clear message. What they leave behind when they are abandoned is reaped, which closes a disk leak that predates this work. MNP 3.0 makes the lease compulsory and refuses 2.x at the handshake, with the desktop client checking `client.minimum` before connecting so an un-updated one says "update" instead of failing every connection in a protocol vocabulary. Fourteen defects were found on the way, eight of them by a person clicking Download and pasting a console — none of which 2075 tests could reach. Section 12 of ~/next/improve-downloads.md is that report, including the three this work introduced itself and the one that turned out to be caused by an instruction to hard-reload after each deployment. Node suite 1209 passed, hub suite 866 passed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HCGdheDLxGReuKHga3BtST
Diffstat (limited to 'packages/meshbay-client/src/main.js')
-rw-r--r--packages/meshbay-client/src/main.js110
1 files changed, 105 insertions, 5 deletions
diff --git a/packages/meshbay-client/src/main.js b/packages/meshbay-client/src/main.js
index 0a5723b..ab579a1 100644
--- a/packages/meshbay-client/src/main.js
+++ b/packages/meshbay-client/src/main.js
@@ -734,6 +734,19 @@ function registerBridge() {
const completedPaths = new Map();
let sinkId = 0;
+ // A clean quit still has to tidy up: the `.part` convention above means a
+ // crash leaves an obviously-unfinished file rather than a plausible one, but
+ // quitting deliberately should leave nothing at all. Synchronous on purpose —
+ // `before-quit` does not wait for promises, and an async cleanup here would
+ // race the process exiting and finish nothing.
+ app.on('before-quit', () => {
+ for (const [id, sink] of sinks) {
+ try { sink.stream.destroy(); } catch { /* already closed */ }
+ try { fs.unlinkSync(sink.partial); } catch { /* already gone */ }
+ sinks.delete(id);
+ }
+ });
+
/** `name`, or the first "name (n).ext" that is not taken — never an overwrite. */
function freeName(dir, filename) {
if (!fs.existsSync(path.join(dir, filename))) return filename;
@@ -826,8 +839,17 @@ function registerBridge() {
target = result.filePath;
}
+ // Written to `<target>.part` and renamed on completion, never straight to
+ // the final name. `save:abort` already deleted a cancelled download, but
+ // nothing covered the app being quit, killed or crashing mid-transfer: the
+ // stream was simply abandoned and a truncated file kept the final name,
+ // which is the exact thing save:abort's own comment says is worse than no
+ // file at all — it looks complete to whoever opens it next. A leftover
+ // `.part` is self-evidently unfinished, and it is the same convention the
+ // node already uses for uploads (`_do_file_upload`).
const id = String(++sinkId);
- sinks.set(id, { stream: fs.createWriteStream(target), path: target });
+ const partial = target + '.part';
+ sinks.set(id, { stream: fs.createWriteStream(partial), path: target, partial });
return { id, name: path.basename(target), path: target };
});
@@ -847,8 +869,16 @@ function registerBridge() {
const sink = sinks.get(String(id));
if (!sink) return false;
sinks.delete(String(id));
- completedPaths.set(String(id), sink.path);
await new Promise((resolve) => sink.stream.end(resolve));
+ // The rename is what publishes the download. Only after the stream has
+ // flushed, or the file bearing the final name would still be short.
+ try {
+ fs.renameSync(sink.partial, sink.path);
+ } catch (err) {
+ console.error('[MeshBay] could not finalise download:', err.message);
+ return false;
+ }
+ completedPaths.set(String(id), sink.path);
return true;
});
@@ -865,8 +895,10 @@ function registerBridge() {
sinks.delete(String(id));
await new Promise((resolve) => sink.stream.close(resolve));
// A cancelled download leaves a truncated file, which is worse than none:
- // it looks like a complete one to whoever opens it next.
- try { fs.unlinkSync(sink.path); } catch { /* already gone */ }
+ // it looks like a complete one to whoever opens it next. Only the `.part`
+ // exists at this stage — the final name is only taken by the rename in
+ // save:end — so this removes that.
+ try { fs.unlinkSync(sink.partial); } catch { /* already gone */ }
return true;
});
@@ -1669,6 +1701,71 @@ function describeUnreachable(url, error) {
return `Could not reach ${url}: ${detail}`;
}
+// ── The version gate ────────────────────────────────────────────────────────
+
+/** Compare two dotted versions. -1, 0 or 1; unreadable sorts as equal. */
+function compareVersions(a, b) {
+ const parse = (v) => String(v || '').split('.').map((n) => parseInt(n, 10));
+ const [x, y] = [parse(a), parse(b)];
+ if (x.some(Number.isNaN) || y.some(Number.isNaN)) return 0;
+ for (let i = 0; i < Math.max(x.length, y.length); i++) {
+ const d = (x[i] || 0) - (y[i] || 0);
+ if (d) return d < 0 ? -1 : 1;
+ }
+ return 0;
+}
+
+/**
+ * Refuse to start when this build is older than the hub will talk to.
+ *
+ * The reason this exists rather than letting the handshake do it: the SPA is
+ * served by the hub and picks up a new client on reload, but **this
+ * application ships its own interface**. On the MNP 3.0 flag day an
+ * un-updated one can still sign in, still list groups, and then fail every
+ * connection with `version_too_old` — a refusal in a protocol vocabulary,
+ * surfacing as a node that will not talk, with nothing anyone can act on.
+ *
+ * So the question is asked once, up front, of `/v1/hub/version`, which has
+ * carried `client.minimum` since before there was a client to check it.
+ *
+ * **Unreachable is not too old.** A hub that is down, a laptop with no network,
+ * a captive portal: none of those are a reason to refuse to open the
+ * application, and treating them as one would make an offline start impossible
+ * for ever. Only a definite answer, saying in so many words that this version
+ * is below the minimum, stops anything.
+ */
+async function refuseIfTooOld() {
+ const base = String(config.hubBase || '').replace(/\/+$/, '');
+ if (!base) return false; // First run: there is no hub to ask yet.
+ let info;
+ try {
+ const r = await fetch(`${base}/v1/hub/version`,
+ { signal: AbortSignal.timeout(10000) });
+ if (!r.ok) return false;
+ info = await r.json();
+ } catch {
+ return false;
+ }
+ const minimum = info && info.client && info.client.minimum;
+ if (!minimum) return false;
+ const mine = app.getVersion();
+ if (compareVersions(mine, minimum) >= 0) return false;
+
+ const { response } = await dialog.showMessageBox({
+ type: 'warning',
+ title: 'Update required',
+ message: 'This version of MeshBay can no longer connect',
+ detail: `This application is version ${mine}, and ${base} now requires `
+ + `${minimum} or later.\n\nDownload the current version and install it `
+ + 'over this one — your groups, keys and settings are kept.',
+ buttons: ['Download the update', 'Quit'],
+ defaultId: 0,
+ cancelId: 1,
+ });
+ if (response === 0) await shell.openExternal(base);
+ return true;
+}
+
// ── Lifecycle ───────────────────────────────────────────────────────────────
// One instance. Two would fight over the config file and the secrets blob, and
@@ -1680,7 +1777,10 @@ if (!app.requestSingleInstanceLock()) {
showFromTray();
});
- app.whenReady().then(() => {
+ app.whenReady().then(async () => {
+ // Before anything else is built. A window that opens and then cannot
+ // connect is the failure this replaces.
+ if (await refuseIfTooOld()) { app.quit(); return; }
registerUiProtocol();
// Before ensureTray: buildTrayMenu reads `nodeService`, which registerBridge
// assigns, so creating the tray after it means the Start/Stop entry is on