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 <noreply@anthropic.com>
This commit is contained in:
Jokin
2026-07-11 23:49:36 +02:00
co-authored by Claude Fable 5
parent fbbde16f35
commit a0fc96fc44
2 changed files with 68 additions and 2 deletions
+52 -2
View File
@@ -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<string, string>();
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)));
+16
View File
@@ -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()) {