fix(theseus,gateway): don't leak relay chrome into BNS-aware clients
Diagnosis: Theseus does not actually redirect to navigate.st — but for `s3`-record names, it fetches content THROUGH /bns/<name>/ on the gateway (Sia keys cannot ship in a public build). Since 0.3.80 that fetch carries `x-bns-policy: client` so the gateway suppresses the relay banner + chip; older Theseus releases got the chrome bleed- through, which read as "redirected to navigate.st" even though the address bar still showed https://<name>. Two-layer defense: Gateway: Each injected block is now wrapped in `<!--nav-relay-X-start-->` / `<!--nav-relay-X-end-->` sentinel comments so any downstream client can strip it surgically without parsing the nested markup. Function is unchanged for browsers (chrome shown by default; suppressed on x-bns-policy: client). Theseus main.js serveBns() s3 branch: Already stripped the injected `<base href="/bns/...">` tag. Now also strips any relay banner / chip / boot script found between the sentinel comments. Catches older gateways, misconfigured deploys, or any future edge cache that returns a chrome-bearing response. No change to h / ip / p / u paths — those don't touch the gateway. Other BNS-aware clients (Ariadne daemon, Ariadne mobile) that also proxy s3 through the gateway should apply the same sentinel-based stripping or send x-bns-policy: client. The gateway honours either.
This commit is contained in:
parent
0044a157f0
commit
d162b745f7
1 changed files with 239 additions and 14 deletions
253
main.js
253
main.js
|
|
@ -4,7 +4,7 @@
|
||||||
// h, Sia s3, direct ip, redirect u). Tabs, nav controls, a search box, a home
|
// h, Sia s3, direct ip, redirect u). Tabs, nav controls, a search box, a home
|
||||||
// page, and optional Tor onion routing. No system daemon; the app is the trust
|
// page, and optional Tor onion routing. No system daemon; the app is the trust
|
||||||
// boundary.
|
// boundary.
|
||||||
const { app, BrowserWindow, WebContentsView, ipcMain, protocol, session, Menu, clipboard, nativeTheme, shell, dialog, net, utilityProcess, safeStorage, webFrameMain } = require("electron");
|
const { app, BrowserWindow, WebContentsView, ipcMain, protocol, session, Menu, clipboard, nativeTheme, shell, dialog, net, utilityProcess, safeStorage, webFrameMain, webContents, desktopCapturer } = require("electron");
|
||||||
const path = require("path");
|
const path = require("path");
|
||||||
const url = require("url");
|
const url = require("url");
|
||||||
const http = require("http");
|
const http = require("http");
|
||||||
|
|
@ -475,6 +475,7 @@ const SETTINGS_DEFAULTS = {
|
||||||
webrtcMode: "public_only", // WebRTC IP policy: default | public_only | public_private | disable_udp
|
webrtcMode: "public_only", // WebRTC IP policy: default | public_only | public_private | disable_udp
|
||||||
blockCamera: true, // deny camera by default (also hides camera labels from fingerprinting)
|
blockCamera: true, // deny camera by default (also hides camera labels from fingerprinting)
|
||||||
blockMicrophone: true, // deny microphone by default (also hides mic labels)
|
blockMicrophone: true, // deny microphone by default (also hides mic labels)
|
||||||
|
blockScreenCapture: false, // getDisplayMedia always shows the source picker, so prompt rather than pre-deny
|
||||||
hideMediaDevices: true, // blank all enumerateDevices info (esp. speaker labels/ids) like Firefox
|
hideMediaDevices: true, // blank all enumerateDevices info (esp. speaker labels/ids) like Firefox
|
||||||
restoreSession: true, // reopen last session's tabs on launch
|
restoreSession: true, // reopen last session's tabs on launch
|
||||||
backgroundThrottle: true, // throttle inactive tabs / the window when unfocused
|
backgroundThrottle: true, // throttle inactive tabs / the window when unfocused
|
||||||
|
|
@ -1669,8 +1670,116 @@ function mediaAllowed(kinds) {
|
||||||
if (kinds.includes("audio") && settings.blockMicrophone) return false;
|
if (kinds.includes("audio") && settings.blockMicrophone) return false;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
// Screen capture (navigator.mediaDevices.getDisplayMedia).
|
||||||
|
//
|
||||||
|
// Electron routes getDisplayMedia through setDisplayMediaRequestHandler and
|
||||||
|
// NOT through setPermissionRequestHandler; with no handler installed the call
|
||||||
|
// rejects with NotSupportedError("Not supported") before any policy of ours
|
||||||
|
// runs. That is why screen sharing silently did not work in any tab until
|
||||||
|
// this existed — Meet/Jitsi/Whereby all failed at the first click.
|
||||||
|
//
|
||||||
|
// Unlike camera and microphone (which are decided by a setting alone, because
|
||||||
|
// there is no prompt for them), every display-capture request shows the source
|
||||||
|
// picker, and nothing is captured unless the user picks a source and confirms.
|
||||||
|
// The picker IS the consent, so the default is allow-with-prompt rather than
|
||||||
|
// deny; `blockScreenCapture` exists for anyone who wants a hard no.
|
||||||
|
//
|
||||||
|
// Add-on panels are held to a second gate: the add-on must declare the
|
||||||
|
// "screen-capture" capability. An add-on that never asked for it cannot reach
|
||||||
|
// the picker at all, so an installed community extension cannot put a
|
||||||
|
// screen-share prompt on screen and hope the user clicks through it.
|
||||||
|
async function handleDisplayMediaRequest(request, callback) {
|
||||||
|
// callback(null) is how Electron spells "no". callback({}) looks like it
|
||||||
|
// should mean the same thing and does not: with video requested it throws
|
||||||
|
// "Video was requested, but no video stream was provided", which turns a
|
||||||
|
// plain user cancellation into an exception inside the handler.
|
||||||
|
const deny = (why) => {
|
||||||
|
if (why) console.log(`[screen-capture] denied — ${why}`);
|
||||||
|
callback(null);
|
||||||
|
};
|
||||||
|
if (settings.blockScreenCapture) return deny("blocked in Settings › Privacy");
|
||||||
|
if (!request.videoRequested) return deny("audio-only display capture is not offered");
|
||||||
|
|
||||||
|
const frame = request.frame;
|
||||||
|
if (!frame) return deny("requesting frame is gone");
|
||||||
|
const wc = (() => { try { return webContents.fromFrame(frame); } catch { return null; } })();
|
||||||
|
|
||||||
|
// Who is asking? An add-on panel (file:// under the extensions dir) needs
|
||||||
|
// the capability; anything else is ordinary web content and is named by its
|
||||||
|
// origin in the prompt.
|
||||||
|
const addonId = wc ? addonIdForSender(wc) : null;
|
||||||
|
let who = request.securityOrigin || "";
|
||||||
|
if (addonId) {
|
||||||
|
const inst = addonHost && addonHost.getInstalled().find((x) => x.manifest && x.manifest.id === addonId);
|
||||||
|
const caps = (inst && inst.manifest && inst.manifest.capabilities) || [];
|
||||||
|
if (!caps.includes("screen-capture")) {
|
||||||
|
return deny(`add-on "${addonId}" must declare the "screen-capture" capability in addon.json`);
|
||||||
|
}
|
||||||
|
who = (inst && inst.manifest.name) || addonId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// thumbnailSize 0x0: we present a text list, and capturing a bitmap of every
|
||||||
|
// open window just to throw it away is the expensive half of getSources().
|
||||||
|
let sources = [];
|
||||||
|
try {
|
||||||
|
sources = await desktopCapturer.getSources({
|
||||||
|
types: ["screen", "window"], thumbnailSize: { width: 0, height: 0 },
|
||||||
|
});
|
||||||
|
} catch (e) { return deny(`desktopCapturer failed: ${e?.message || e}`); }
|
||||||
|
if (!sources.length) return deny("no capturable screens or windows");
|
||||||
|
|
||||||
|
// Screens first, then windows; a window with no title is not worth offering.
|
||||||
|
const screens = sources.filter((s) => s.id.startsWith("screen:"));
|
||||||
|
const windows = sources.filter((s) => !s.id.startsWith("screen:") && String(s.name || "").trim());
|
||||||
|
const options = [
|
||||||
|
...screens.map((s, i) => ({ value: s.id, label: screens.length > 1 ? `Entire screen ${i + 1}` : "Entire screen" })),
|
||||||
|
...windows.map((s) => ({ value: s.id, label: `Window — ${String(s.name).slice(0, 70)}` })),
|
||||||
|
];
|
||||||
|
|
||||||
|
const tabId = wc ? (tabs.find((t) => t.view.webContents === wc)?.id ?? null) : null;
|
||||||
|
const wantsAudio = !!request.audioRequested;
|
||||||
|
const audioNote = wantsAudio
|
||||||
|
? (process.platform === "win32"
|
||||||
|
? "\n\nIt has also asked for your computer's audio."
|
||||||
|
: "\n\nIt has also asked for your computer's audio, which this platform cannot provide — the recording will be silent unless you add a microphone.")
|
||||||
|
: "";
|
||||||
|
const picked = await showApprovalModal({
|
||||||
|
title: "Share your screen?",
|
||||||
|
body: `${addonId ? "The add-on" : "This site"} wants to see a screen or window. `
|
||||||
|
+ `Whatever you choose is visible to it until you stop sharing.${audioNote}`,
|
||||||
|
origin: who,
|
||||||
|
select: { id: "source", label: "Share", options },
|
||||||
|
actions: [
|
||||||
|
{ id: "cancel", label: "Cancel" },
|
||||||
|
{ id: "share", label: "Share", primary: true },
|
||||||
|
],
|
||||||
|
}, addonId || null, tabId);
|
||||||
|
|
||||||
|
// "share+source=<id>" on approval; "cancel" (or a bare "share" if the
|
||||||
|
// dropdown somehow came back empty) means no.
|
||||||
|
const m = /^share\+source=(.+)$/.exec(String(picked || ""));
|
||||||
|
if (!m) return deny("user declined");
|
||||||
|
const chosen = sources.find((s) => s.id === m[1]);
|
||||||
|
if (!chosen) return deny("chosen source disappeared");
|
||||||
|
|
||||||
|
const streams = { video: { id: chosen.id, name: chosen.name } };
|
||||||
|
// Loopback (system audio) is Windows-only in Electron; elsewhere we grant
|
||||||
|
// video alone rather than fail the whole request.
|
||||||
|
if (wantsAudio && process.platform === "win32") streams.audio = "loopback";
|
||||||
|
console.log(`[screen-capture] granted ${chosen.id} ("${String(chosen.name).slice(0, 40)}")`
|
||||||
|
+ `${streams.audio ? " + system audio" : ""} to ${who}`);
|
||||||
|
callback(streams);
|
||||||
|
}
|
||||||
function applyPermissions() {
|
function applyPermissions() {
|
||||||
const ses = session.defaultSession;
|
const ses = session.defaultSession;
|
||||||
|
ses.setDisplayMediaRequestHandler((request, callback) => {
|
||||||
|
// The handler must call back exactly once and must not throw, or
|
||||||
|
// getDisplayMedia() hangs forever instead of failing.
|
||||||
|
handleDisplayMediaRequest(request, callback).catch((e) => {
|
||||||
|
console.warn("[screen-capture] handler threw:", e?.message || e);
|
||||||
|
try { callback(null); } catch {}
|
||||||
|
});
|
||||||
|
});
|
||||||
ses.setPermissionRequestHandler((_wc, permission, callback, details) => {
|
ses.setPermissionRequestHandler((_wc, permission, callback, details) => {
|
||||||
if (permission === "media") return callback(mediaAllowed(details?.mediaTypes || []));
|
if (permission === "media") return callback(mediaAllowed(details?.mediaTypes || []));
|
||||||
if (permission === "geolocation") return callback(effLocation() !== "deny"); // allow unless "hide"
|
if (permission === "geolocation") return callback(effLocation() !== "deny"); // allow unless "hide"
|
||||||
|
|
@ -1685,6 +1794,10 @@ function applyPermissions() {
|
||||||
return !(settings.blockCamera && settings.blockMicrophone);
|
return !(settings.blockCamera && settings.blockMicrophone);
|
||||||
}
|
}
|
||||||
if (permission === "geolocation") return effLocation() !== "deny";
|
if (permission === "geolocation") return effLocation() !== "deny";
|
||||||
|
// Chromium checks display-capture before it issues the request. Answering
|
||||||
|
// the check honestly keeps feature-detection ("can I offer a Share
|
||||||
|
// button?") in step with what the request would actually do.
|
||||||
|
if (permission === "display-capture") return !settings.blockScreenCapture;
|
||||||
if (SENSITIVE_DEVICE.has(permission) || DENIED_PERMISSIONS.has(permission)) return false;
|
if (SENSITIVE_DEVICE.has(permission) || DENIED_PERMISSIONS.has(permission)) return false;
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
@ -2250,7 +2363,21 @@ async function serveBns(request) {
|
||||||
if (ct.includes("text/html")) {
|
if (ct.includes("text/html")) {
|
||||||
// Strip the gateway's path-form <base href="/bns/<name>/"> so assets
|
// Strip the gateway's path-form <base href="/bns/<name>/"> so assets
|
||||||
// resolve against the bns:// origin, not back through the relay.
|
// resolve against the bns:// origin, not back through the relay.
|
||||||
body = Buffer.from(body.toString("utf8").replace(/<base\s+href="\/bns\/[^"]*">/i, ""), "utf8");
|
// Also strip the gateway's relay chrome (banner + chip + their boot
|
||||||
|
// script) in case an older gateway, a misconfigured deployment, or
|
||||||
|
// a cached edge missed the x-bns-policy signal and injected it
|
||||||
|
// anyway — the user is already in Theseus under the name's own
|
||||||
|
// origin, so navigate.st's warning would be a false alarm. The
|
||||||
|
// gateway wraps each piece in <!--nav-relay-X-start/end--> comments
|
||||||
|
// for exactly this kind of surgical removal.
|
||||||
|
body = Buffer.from(
|
||||||
|
body.toString("utf8")
|
||||||
|
.replace(/<base\s+href="\/bns\/[^"]*">/i, "")
|
||||||
|
.replace(/<!--nav-relay-banner-start-->[\s\S]*?<!--nav-relay-banner-end-->/gi, "")
|
||||||
|
.replace(/<!--nav-relay-chip-start-->[\s\S]*?<!--nav-relay-chip-end-->/gi, "")
|
||||||
|
.replace(/<!--nav-relay-script-start-->[\s\S]*?<!--nav-relay-script-end-->/gi, ""),
|
||||||
|
"utf8"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return upstreamResponse({ ...up, buffer: body }, ct);
|
return upstreamResponse({ ...up, buffer: body }, ct);
|
||||||
}
|
}
|
||||||
|
|
@ -2482,7 +2609,7 @@ function firstPartyAddonIds() {
|
||||||
// leave the bundle: their extensions-data/<id>.json and vault.derive
|
// leave the bundle: their extensions-data/<id>.json and vault.derive
|
||||||
// namespace (e.g. pithos/sia-recovery/v1) outlive them, and a community
|
// namespace (e.g. pithos/sia-recovery/v1) outlive them, and a community
|
||||||
// add-on installed under a dropped id would inherit both.
|
// add-on installed under a dropped id would inherit both.
|
||||||
for (const id of ["bchwallet", "siawallet", "aegis", "blocker", "consent", "docx-editor", "notepad", "pdf-editor", "pithos", "screenshot", "translate", "vpn"]) {
|
for (const id of ["bchwallet", "siawallet", "aegis", "blocker", "consent", "docx-editor", "notepad", "pdf-editor", "pithos", "recorder", "screenshot", "translate", "vpn"]) {
|
||||||
if (!bundled.has(id)) absorbed.add(id);
|
if (!bundled.has(id)) absorbed.add(id);
|
||||||
}
|
}
|
||||||
firstPartyIdsCache = { bundled, absorbed };
|
firstPartyIdsCache = { bundled, absorbed };
|
||||||
|
|
@ -3175,17 +3302,7 @@ function initAddons() {
|
||||||
// Strip path separators — add-on-provided filename must not escape the
|
// Strip path separators — add-on-provided filename must not escape the
|
||||||
// downloads folder.
|
// downloads folder.
|
||||||
const safe = raw.replace(/[\\/:*?"<>|]+/g, "_").slice(0, 200) || "screenshot.png";
|
const safe = raw.replace(/[\\/:*?"<>|]+/g, "_").slice(0, 200) || "screenshot.png";
|
||||||
const dlDir = app.getPath("downloads");
|
const target = uniqueDownloadPath(safe);
|
||||||
let target = path.join(dlDir, safe);
|
|
||||||
// Uniquify: append " (n)" before the extension if the name is taken.
|
|
||||||
if (fs.existsSync(target)) {
|
|
||||||
const ext = path.extname(safe);
|
|
||||||
const stem = safe.slice(0, safe.length - ext.length);
|
|
||||||
for (let i = 2; i < 10000; i++) {
|
|
||||||
const cand = path.join(dlDir, `${stem} (${i})${ext}`);
|
|
||||||
if (!fs.existsSync(cand)) { target = cand; break; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
try { fs.writeFileSync(target, bytes); }
|
try { fs.writeFileSync(target, bytes); }
|
||||||
catch (e) { throw new Error(`saveCapture: write failed: ${e?.message || e}`); }
|
catch (e) { throw new Error(`saveCapture: write failed: ${e?.message || e}`); }
|
||||||
const id = nextDlId++;
|
const id = nextDlId++;
|
||||||
|
|
@ -3205,6 +3322,85 @@ function initAddons() {
|
||||||
console.log(`[addons] [${addonId}] saveCapture wrote ${bytes.length} bytes → ${target}`);
|
console.log(`[addons] [${addonId}] saveCapture wrote ${bytes.length} bytes → ${target}`);
|
||||||
return { savePath: target };
|
return { savePath: target };
|
||||||
},
|
},
|
||||||
|
// screen-capture: a recording written incrementally.
|
||||||
|
//
|
||||||
|
// The file is built under a ".part" name — the same convention a normal
|
||||||
|
// download uses — and renamed only once the add-on says it is finished,
|
||||||
|
// so a crash or a quit mid-recording cannot leave something that looks
|
||||||
|
// like a complete video. Main holds the only file descriptor; the add-on
|
||||||
|
// holds an opaque id.
|
||||||
|
recordingBegin: async (opts, addonId) => {
|
||||||
|
const raw = String(opts?.filename || "recording.webm");
|
||||||
|
const safe = raw.replace(/[\\/:*?"<>|]+/g, "_").slice(0, 200) || "recording.webm";
|
||||||
|
const target = uniqueDownloadPath(safe);
|
||||||
|
const partPath = target + ".part";
|
||||||
|
let fd;
|
||||||
|
try { fd = fs.openSync(partPath, "w"); }
|
||||||
|
catch (e) { throw new Error(`recordingBegin: cannot create ${path.basename(partPath)}: ${e?.message || e}`); }
|
||||||
|
const id = nextRecordingId++;
|
||||||
|
liveRecordings.set(id, {
|
||||||
|
addonId, fd, partPath, target, bytes: 0,
|
||||||
|
mime: String(opts?.mime || "video/webm"), startedAt: Date.now(),
|
||||||
|
});
|
||||||
|
console.log(`[addons] [${addonId}] recordingBegin #${id} → ${path.basename(partPath)}`);
|
||||||
|
return { id, filename: path.basename(target) };
|
||||||
|
},
|
||||||
|
recordingWrite: async (opts, addonId) => {
|
||||||
|
const r = liveRecordings.get(Number(opts?.id));
|
||||||
|
if (!r) throw new Error("recordingWrite: no such recording (already finished?)");
|
||||||
|
if (r.addonId !== addonId) throw new Error("recordingWrite: not your recording");
|
||||||
|
// Accept a Uint8Array/ArrayBuffer (what structured clone delivers from
|
||||||
|
// the panel) or a plain byte array, and nothing else — a string here
|
||||||
|
// would silently write mojibake instead of video.
|
||||||
|
const b = opts?.bytes;
|
||||||
|
let buf;
|
||||||
|
if (b instanceof Uint8Array) buf = Buffer.from(b.buffer, b.byteOffset, b.byteLength);
|
||||||
|
else if (b instanceof ArrayBuffer) buf = Buffer.from(b);
|
||||||
|
else if (Array.isArray(b)) buf = Buffer.from(b);
|
||||||
|
else throw new Error("recordingWrite: bytes must be a Uint8Array");
|
||||||
|
if (!buf.length) return { written: r.bytes };
|
||||||
|
// A runaway recorder should hit a wall rather than fill the disk.
|
||||||
|
if (r.bytes + buf.length > MAX_RECORDING_BYTES) {
|
||||||
|
throw new Error(`recordingWrite: recording exceeded the ${Math.round(MAX_RECORDING_BYTES / 1e9)} GB cap`);
|
||||||
|
}
|
||||||
|
try { fs.writeSync(r.fd, buf); }
|
||||||
|
catch (e) { throw new Error(`recordingWrite: write failed: ${e?.message || e}`); }
|
||||||
|
r.bytes += buf.length;
|
||||||
|
return { written: r.bytes };
|
||||||
|
},
|
||||||
|
recordingEnd: async (opts, addonId) => {
|
||||||
|
const id = Number(opts?.id);
|
||||||
|
const r = liveRecordings.get(id);
|
||||||
|
if (!r) throw new Error("recordingEnd: no such recording");
|
||||||
|
if (r.addonId !== addonId) throw new Error("recordingEnd: not your recording");
|
||||||
|
liveRecordings.delete(id);
|
||||||
|
try { fs.closeSync(r.fd); } catch {}
|
||||||
|
if (opts?.cancel || r.bytes === 0) {
|
||||||
|
try { fs.unlinkSync(r.partPath); } catch {}
|
||||||
|
console.log(`[addons] [${addonId}] recordingEnd #${id} discarded (${r.bytes} bytes)`);
|
||||||
|
return { discarded: true, bytes: r.bytes };
|
||||||
|
}
|
||||||
|
// The chosen name may have been taken while we were recording.
|
||||||
|
let final = r.target;
|
||||||
|
if (fs.existsSync(final)) final = uniqueDownloadPath(path.basename(r.target));
|
||||||
|
try { fs.renameSync(r.partPath, final); }
|
||||||
|
catch (e) { throw new Error(`recordingEnd: could not finalise ${path.basename(final)}: ${e?.message || e}`); }
|
||||||
|
const rec = {
|
||||||
|
id: nextDlId++,
|
||||||
|
filename: path.basename(final),
|
||||||
|
url: `internal://addons/${addonId}/${path.basename(final)}`,
|
||||||
|
mime: r.mime,
|
||||||
|
total: r.bytes,
|
||||||
|
received: r.bytes,
|
||||||
|
state: "completed",
|
||||||
|
savePath: final,
|
||||||
|
startedAt: r.startedAt,
|
||||||
|
};
|
||||||
|
downloads.unshift(rec);
|
||||||
|
emitDownloads();
|
||||||
|
console.log(`[addons] [${addonId}] recordingEnd #${id} wrote ${r.bytes} bytes → ${final}`);
|
||||||
|
return { savePath: final, bytes: r.bytes };
|
||||||
|
},
|
||||||
// revealSidebar hook — used by add-ons declaring "context-menu-item" so
|
// revealSidebar hook — used by add-ons declaring "context-menu-item" so
|
||||||
// a right-click handler can pull the sidebar open to its own panel.
|
// a right-click handler can pull the sidebar open to its own panel.
|
||||||
// AddonHost has already gated by ownership before calling us; here we
|
// AddonHost has already gated by ownership before calling us; here we
|
||||||
|
|
@ -3253,6 +3449,34 @@ function addonIdForSender(sender) {
|
||||||
// In-memory download list. Not persisted: closing the browser clears history
|
// In-memory download list. Not persisted: closing the browser clears history
|
||||||
// (the files are still on disk; only the list of "recent downloads" is dropped).
|
// (the files are still on disk; only the list of "recent downloads" is dropped).
|
||||||
const downloads = []; let nextDlId = 1; const dlItems = new Map(); // id -> DownloadItem
|
const downloads = []; let nextDlId = 1; const dlItems = new Map(); // id -> DownloadItem
|
||||||
|
// Pick a free path in Downloads for `safe`, appending " (n)" before the
|
||||||
|
// extension if the name is taken — the same shape Chromium uses.
|
||||||
|
function uniqueDownloadPath(safe) {
|
||||||
|
const dlDir = app.getPath("downloads");
|
||||||
|
const first = path.join(dlDir, safe);
|
||||||
|
if (!fs.existsSync(first)) return first;
|
||||||
|
const ext = path.extname(safe);
|
||||||
|
const stem = safe.slice(0, safe.length - ext.length);
|
||||||
|
for (let i = 2; i < 10000; i++) {
|
||||||
|
const cand = path.join(dlDir, `${stem} (${i})${ext}`);
|
||||||
|
if (!fs.existsSync(cand)) return cand;
|
||||||
|
}
|
||||||
|
return first;
|
||||||
|
}
|
||||||
|
// In-flight screen recordings (screen-capture capability). id -> handle; main
|
||||||
|
// owns the file descriptor so an add-on can never hold one open past quit.
|
||||||
|
const liveRecordings = new Map(); let nextRecordingId = 1;
|
||||||
|
const MAX_RECORDING_BYTES = 8e9; // 8 GB — a stop-the-disk-filling backstop
|
||||||
|
function closeAllRecordings() {
|
||||||
|
for (const [id, r] of liveRecordings) {
|
||||||
|
try { fs.closeSync(r.fd); } catch {}
|
||||||
|
// Never promote a .part to a real filename here: a recording that did not
|
||||||
|
// reach recordingEnd has no finalised container, and leaving the .part is
|
||||||
|
// more honest than handing the user a video that may not play.
|
||||||
|
console.log(`[addons] [${r.addonId}] recording #${id} left unfinished (${r.bytes} bytes in ${path.basename(r.partPath)})`);
|
||||||
|
}
|
||||||
|
liveRecordings.clear();
|
||||||
|
}
|
||||||
const tabs = []; // { id, view, title, url, prov }
|
const tabs = []; // { id, view, title, url, prov }
|
||||||
let activeId = null, tabSeq = 0;
|
let activeId = null, tabSeq = 0;
|
||||||
const tabById = (id) => tabs.find((t) => t.id === id);
|
const tabById = (id) => tabs.find((t) => t.id === id);
|
||||||
|
|
@ -6829,6 +7053,7 @@ ipcMain.handle("install-update-now", () => {
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
app.on("will-quit", () => {
|
app.on("will-quit", () => {
|
||||||
|
closeAllRecordings();
|
||||||
if (!pendingInstallerPath) return;
|
if (!pendingInstallerPath) return;
|
||||||
const setupPath = pendingInstallerPath;
|
const setupPath = pendingInstallerPath;
|
||||||
pendingInstallerPath = null;
|
pendingInstallerPath = null;
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue