From a0fc96fc4457ec9a4142a31245872f8b8f112a3b Mon Sep 17 00:00:00 2001 From: Jokin Date: Sat, 11 Jul 2026 23:49:36 +0200 Subject: [PATCH] Quit guard during transfers + background completion notifications - before-quit is blocked while jobs are queued/scanning/running; a native message box asks 'N transfers are still running - quit anyway?' (Cancel default) and only an explicit Quit Anyway proceeds via Utils.quit() - when the window is unfocused, finished transfers raise a native notification (silent for completions, sound + error message for failures) - engine.hasActiveJobs()/activeJobCount() (truth table verified headless) Co-Authored-By: Claude Fable 5 --- src/bun/index.ts | 54 +++++++++++++++++++++++++++++++++++-- src/bun/transfers/engine.ts | 16 +++++++++++ 2 files changed, 68 insertions(+), 2 deletions(-) diff --git a/src/bun/index.ts b/src/bun/index.ts index 00f91ce..d014931 100644 --- a/src/bun/index.ts +++ b/src/bun/index.ts @@ -1,4 +1,4 @@ -import { ApplicationMenu, BrowserWindow, Updater } from "electrobun/bun"; +import Electrobun, { ApplicationMenu, BrowserWindow, Updater, Utils } from "electrobun/bun"; import { BRANDING } from "../shared/branding"; import { effectiveConcurrency } from "../shared/types"; import { PanelContextMenu } from "./context-menu"; @@ -69,7 +69,57 @@ const win = new BrowserWindow({ new AppTray(win); contextMenu.onAction = (action) => rpc.send.menuAction({ action }); -engine.onUpdate = (snapshot) => rpc.send.transferUpdate(snapshot); +// notify on finished transfers while the window is in the background +let windowFocused = true; +win.on("focus", () => (windowFocused = true)); +win.on("blur", () => (windowFocused = false)); +const notifiedStates = new Map(); + +engine.onUpdate = (snapshot) => { + rpc.send.transferUpdate(snapshot); + + const previous = notifiedStates.get(snapshot.id); + if (previous !== snapshot.state) { + notifiedStates.set(snapshot.id, snapshot.state); + if (!windowFocused && (snapshot.state === "completed" || snapshot.state === "failed")) { + Utils.showNotification({ + title: snapshot.state === "completed" ? "Transfer complete" : "Transfer failed", + body: + snapshot.state === "completed" + ? snapshot.name + : `${snapshot.name}: ${snapshot.error ?? "unknown error"}`, + silent: snapshot.state === "completed", + }); + } + if (snapshot.state === "completed" || snapshot.state === "failed" || snapshot.state === "cancelled") { + // keep the map from growing over long sessions + setTimeout(() => notifiedStates.delete(snapshot.id), 60_000); + } + } +}; + +// don't quit out from under running transfers +let quitConfirmed = false; +Electrobun.events.on("before-quit", (event) => { + if (quitConfirmed || !engine.hasActiveJobs()) return; + (event as { response: { allow: boolean } }).response = { allow: false }; + void (async () => { + const count = engine.activeJobCount(); + const { response } = await Utils.showMessageBox({ + type: "warning", + title: BRANDING.appName, + message: `${count} transfer${count === 1 ? " is" : "s are"} still running.`, + detail: "Quitting now will abort them.", + buttons: ["Quit Anyway", "Cancel"], + defaultId: 1, + cancelId: 1, + }); + if (response === 0) { + quitConfirmed = true; + Utils.quit(); + } + })(); +}); events.sessionClosed = (info) => rpc.send.sessionClosed(info); events.connectionsChanged = () => void applyApplicationMenu(store); void settings.load().then((s) => engine.setConcurrency(effectiveConcurrency(s))); diff --git a/src/bun/transfers/engine.ts b/src/bun/transfers/engine.ts index db9a0c0..d19ae87 100644 --- a/src/bun/transfers/engine.ts +++ b/src/bun/transfers/engine.ts @@ -146,6 +146,22 @@ export class TransferEngine { if (job && TERMINAL.includes(job.snapshot.state)) this.jobs.delete(jobId); } + /** True while any job is queued, scanning or running (quit guard). */ + hasActiveJobs(): boolean { + for (const job of this.jobs.values()) { + if (!TERMINAL.includes(job.snapshot.state)) return true; + } + return false; + } + + activeJobCount(): number { + let count = 0; + for (const job of this.jobs.values()) { + if (!TERMINAL.includes(job.snapshot.state)) count++; + } + return count; + } + /** Fail every non-terminal job that touches a dead session. */ failSession(sessionId: string, reason: string): void { for (const job of this.jobs.values()) {