diff options
Diffstat (limited to 'packages/meshbay-client')
| -rw-r--r-- | packages/meshbay-client/package.json | 4 | ||||
| -rw-r--r-- | packages/meshbay-client/src/main.js | 110 |
2 files changed, 107 insertions, 7 deletions
diff --git a/packages/meshbay-client/package.json b/packages/meshbay-client/package.json index 00ed8a9..e88b2f0 100644 --- a/packages/meshbay-client/package.json +++ b/packages/meshbay-client/package.json @@ -1,7 +1,7 @@ { "name": "meshbay-client", - "version": "1.0.0", - "description": "MeshBay desktop client — the interface ships with the application, not from the hub", + "version": "0.13.0", + "description": "MeshBay desktop client \u2014 the interface ships with the application, not from the hub", "license": "AGPL-3.0-or-later", "author": "MeshBay Team <team@meshbay.org>", "homepage": "https://meshbay.org", 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 |