[Init] Initial commit - NetMesh terminal manager
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
This commit is contained in:
296
electron/main/appLockLifecycle.cjs
Normal file
296
electron/main/appLockLifecycle.cjs
Normal file
@@ -0,0 +1,296 @@
|
||||
"use strict";
|
||||
|
||||
function emitAppLockReopen(windows) {
|
||||
const seen = new Set();
|
||||
for (const win of Array.isArray(windows) ? windows : []) {
|
||||
try {
|
||||
if (!win || win.isDestroyed?.()) continue;
|
||||
const id = win.webContents?.id;
|
||||
if (id && seen.has(id)) continue;
|
||||
if (id) seen.add(id);
|
||||
win.webContents?.send?.("netcatty:app-lock:reopen");
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function shouldBackgroundLockOnHide(appLockController) {
|
||||
return Boolean(appLockController && typeof appLockController.setLocked === "function");
|
||||
}
|
||||
|
||||
function handleAppHide(appLockController) {
|
||||
if (!shouldBackgroundLockOnHide(appLockController)) return false;
|
||||
try {
|
||||
appLockController.setLocked("background");
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* macOS keeps the process alive after the last BrowserWindow closes
|
||||
* (`window-all-closed` only quits on non-darwin). App-lock runtime is shared
|
||||
* for the process lifetime, so an unlock can otherwise survive a full
|
||||
* close → Dock reopen cycle. Re-apply lock with `startup` (same reason as
|
||||
* cold start) so a fresh renderer withholds content until unlock. Prefer this
|
||||
* over `background`, which waits on a reopen signal that a brand-new window
|
||||
* never receives.
|
||||
*/
|
||||
function ensureAppLockForFreshSession(appLockController, reason = "startup") {
|
||||
if (!shouldBackgroundLockOnHide(appLockController)) return false;
|
||||
const nextReason = typeof reason === "string" && reason.trim() !== "" ? reason : "startup";
|
||||
try {
|
||||
appLockController.setLocked(nextReason);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* True when no usable app-content windows remain (main / settings / etc.).
|
||||
* Used to decide whether recreating a main window is a "fresh session" reopen
|
||||
* after the last window was closed, vs. adding another window while one is open.
|
||||
*/
|
||||
function hasNoUsableAppContentWindows(appContentWindows) {
|
||||
if (!Array.isArray(appContentWindows) || appContentWindows.length === 0) return true;
|
||||
return !appContentWindows.some((win) => {
|
||||
try {
|
||||
if (!win || (typeof win.isDestroyed === "function" && win.isDestroyed())) {
|
||||
return false;
|
||||
}
|
||||
// Hidden prewarm windows (e.g. Settings) keep the process alive but are
|
||||
// not a user session — do not block fresh-session re-lock (Codex P2).
|
||||
// Minimized windows also report !isVisible but are still a live session
|
||||
// (Codex P2 on 4e6c3235).
|
||||
if (typeof win.isVisible === "function" && !win.isVisible()) {
|
||||
if (typeof win.isMinimized === "function" && win.isMinimized()) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function handleActivateWithMainWindow({
|
||||
app,
|
||||
mainWindow,
|
||||
globalShortcutBridge,
|
||||
windowManager,
|
||||
reopenWindows,
|
||||
}) {
|
||||
if (!mainWindow || mainWindow.isDestroyed?.()) return false;
|
||||
|
||||
try {
|
||||
if (mainWindow.webContents?.isCrashed?.()) {
|
||||
mainWindow.destroy?.();
|
||||
return false;
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
try {
|
||||
globalShortcutBridge?.clearPendingFullscreenHide?.(mainWindow);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
if (typeof windowManager?.showAndFocusMainWindow === "function") {
|
||||
windowManager.showAndFocusMainWindow(mainWindow);
|
||||
} else {
|
||||
try {
|
||||
if (mainWindow.isMinimized?.()) mainWindow.restore?.();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
try {
|
||||
mainWindow.show?.();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
try {
|
||||
mainWindow.focus?.();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
emitAppLockReopen(reopenWindows);
|
||||
try {
|
||||
app?.focus?.({ steal: true });
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function shouldCommitQuitWithoutDirtyCheck({
|
||||
reachableMainWindows,
|
||||
queryableWebContents,
|
||||
}) {
|
||||
const hasReachableMainWindows = Array.isArray(reachableMainWindows) && reachableMainWindows.length > 0;
|
||||
if (!hasReachableMainWindows) return true;
|
||||
|
||||
const hasQueryableWebContents = Array.isArray(queryableWebContents) && queryableWebContents.length > 0;
|
||||
return !hasQueryableWebContents;
|
||||
}
|
||||
|
||||
async function handleBeforeQuit({
|
||||
event,
|
||||
mainWindows,
|
||||
queryDirtyEditors,
|
||||
appLockController,
|
||||
windowManager,
|
||||
app,
|
||||
ipcMain,
|
||||
quitConfirmed,
|
||||
quitGuardChannelBusy,
|
||||
timeoutMs,
|
||||
setQuitGuardChannelBusy,
|
||||
setQuitConfirmed,
|
||||
commitQuit,
|
||||
cancelPendingUpdateInstall,
|
||||
}) {
|
||||
if (quitConfirmed) return { committed: false, skipped: "quit-confirmed" };
|
||||
if (quitGuardChannelBusy) {
|
||||
event?.preventDefault?.();
|
||||
return { committed: false, skipped: "busy" };
|
||||
}
|
||||
|
||||
// When the caller provides commitQuit (async plugin shutdown before the real
|
||||
// quit), the original quit event must stay cancelled until commitQuit
|
||||
// re-enters app.quit(). commitQuit owns background-lock, isQuitting and
|
||||
// quitConfirmed in that case.
|
||||
const commit = () => {
|
||||
if (typeof commitQuit === "function") {
|
||||
event?.preventDefault?.();
|
||||
commitQuit();
|
||||
return;
|
||||
}
|
||||
if (shouldBackgroundLockOnHide(appLockController)) {
|
||||
appLockController.setLocked("background");
|
||||
}
|
||||
windowManager?.setIsQuitting?.(true);
|
||||
setQuitConfirmed?.(true);
|
||||
app?.quit?.();
|
||||
};
|
||||
|
||||
const reachableMainWindows = (Array.isArray(mainWindows) ? mainWindows : []).filter((candidate) => (
|
||||
candidate && !candidate.isDestroyed?.()
|
||||
));
|
||||
// Keep the window alongside webContents so a dirty result can bring the
|
||||
// owning window forward (hidden-to-tray / unfocused) before aborting quit.
|
||||
const queryableWindows = reachableMainWindows.filter((candidate) => {
|
||||
const wc = candidate.webContents;
|
||||
return wc && !wc.isDestroyed?.() && !wc.isCrashed?.();
|
||||
});
|
||||
const queryableWebContents = queryableWindows
|
||||
.map((candidate) => candidate.webContents)
|
||||
.filter(Boolean);
|
||||
|
||||
if (shouldCommitQuitWithoutDirtyCheck({ reachableMainWindows, queryableWebContents })) {
|
||||
commit();
|
||||
return { committed: true, skipped: "fast-path" };
|
||||
}
|
||||
|
||||
setQuitGuardChannelBusy?.(true);
|
||||
event?.preventDefault?.();
|
||||
|
||||
try {
|
||||
const dirtyResults = await Promise.all(
|
||||
queryableWindows.map((win) =>
|
||||
queryDirtyEditors(win.webContents, timeoutMs, { ipcMain }).then((hasDirty) => ({
|
||||
win,
|
||||
hasDirty: Boolean(hasDirty),
|
||||
})),
|
||||
),
|
||||
);
|
||||
setQuitGuardChannelBusy?.(false);
|
||||
const dirtyWindows = dirtyResults.filter((result) => result.hasDirty).map((result) => result.win);
|
||||
if (dirtyWindows.length === 0) {
|
||||
commit();
|
||||
return { committed: true, skipped: null };
|
||||
}
|
||||
|
||||
// Renderer only surfaces the dirty toast in its own window. Focus every
|
||||
// dirty owner so a tray-hidden or backgrounded window is visible before
|
||||
// we refuse to quit.
|
||||
for (const win of dirtyWindows) {
|
||||
try {
|
||||
if (typeof windowManager?.showAndFocusMainWindow === "function") {
|
||||
windowManager.showAndFocusMainWindow(win);
|
||||
} else {
|
||||
try {
|
||||
if (win.isMinimized?.()) win.restore?.();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
try {
|
||||
win.show?.();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
try {
|
||||
win.focus?.();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
// App Lock overlay sits above renderer toasts (z-index). When the app is
|
||||
// already locked, focusing the dirty window does not make the unsaved
|
||||
// warning visible — surface a native dialog above the lock screen (Codex P2).
|
||||
try {
|
||||
const { dialog } = require("electron");
|
||||
dialog.showMessageBoxSync({
|
||||
type: "warning",
|
||||
buttons: ["OK"],
|
||||
defaultId: 0,
|
||||
message: "Unsaved changes",
|
||||
detail:
|
||||
"One or more editors have unsaved changes. If the app is locked, unlock it, then save or discard before quitting.",
|
||||
});
|
||||
} catch {
|
||||
// ignore missing dialog in tests
|
||||
}
|
||||
|
||||
if (windowManager?.isQuittingForUpdate?.()) {
|
||||
// Cancel the install bridge's in-flight state as well as the
|
||||
// window-manager flag so a cancelled update can be retried immediately
|
||||
// instead of waiting for its watchdog (#1215 review).
|
||||
try {
|
||||
cancelPendingUpdateInstall?.();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
if (windowManager.isQuittingForUpdate?.()) {
|
||||
windowManager.setQuittingForUpdate?.(false);
|
||||
}
|
||||
}
|
||||
return { committed: false, skipped: "dirty" };
|
||||
} catch {
|
||||
setQuitGuardChannelBusy?.(false);
|
||||
commit();
|
||||
return { committed: true, skipped: "error" };
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
emitAppLockReopen,
|
||||
ensureAppLockForFreshSession,
|
||||
handleAppHide,
|
||||
handleActivateWithMainWindow,
|
||||
handleBeforeQuit,
|
||||
hasNoUsableAppContentWindows,
|
||||
shouldBackgroundLockOnHide,
|
||||
shouldCommitQuitWithoutDirtyCheck,
|
||||
};
|
||||
446
electron/main/appLockLifecycle.test.cjs
Normal file
446
electron/main/appLockLifecycle.test.cjs
Normal file
@@ -0,0 +1,446 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
|
||||
const {
|
||||
emitAppLockReopen,
|
||||
ensureAppLockForFreshSession,
|
||||
handleAppHide,
|
||||
handleActivateWithMainWindow,
|
||||
handleBeforeQuit,
|
||||
hasNoUsableAppContentWindows,
|
||||
shouldBackgroundLockOnHide,
|
||||
shouldCommitQuitWithoutDirtyCheck,
|
||||
} = require("./appLockLifecycle.cjs");
|
||||
|
||||
test("shouldBackgroundLockOnHide locks only when app lock controller exists", () => {
|
||||
assert.equal(shouldBackgroundLockOnHide({ setLocked: () => {} }), true);
|
||||
assert.equal(shouldBackgroundLockOnHide(null), false);
|
||||
});
|
||||
|
||||
test("ensureAppLockForFreshSession locks with startup by default", () => {
|
||||
const calls = [];
|
||||
assert.equal(
|
||||
ensureAppLockForFreshSession({
|
||||
setLocked(reason) {
|
||||
calls.push(reason);
|
||||
},
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.deepEqual(calls, ["startup"]);
|
||||
});
|
||||
|
||||
test("ensureAppLockForFreshSession accepts an explicit reason and ignores missing controllers", () => {
|
||||
const calls = [];
|
||||
assert.equal(
|
||||
ensureAppLockForFreshSession({
|
||||
setLocked(reason) {
|
||||
calls.push(reason);
|
||||
},
|
||||
}, "background"),
|
||||
true,
|
||||
);
|
||||
assert.deepEqual(calls, ["background"]);
|
||||
assert.equal(ensureAppLockForFreshSession(null), false);
|
||||
assert.equal(ensureAppLockForFreshSession({}), false);
|
||||
});
|
||||
|
||||
test("hasNoUsableAppContentWindows treats empty or destroyed lists as no windows", () => {
|
||||
assert.equal(hasNoUsableAppContentWindows(undefined), true);
|
||||
assert.equal(hasNoUsableAppContentWindows([]), true);
|
||||
assert.equal(hasNoUsableAppContentWindows([null, { isDestroyed: () => true }]), true);
|
||||
assert.equal(hasNoUsableAppContentWindows([{ isDestroyed: () => false }]), false);
|
||||
assert.equal(hasNoUsableAppContentWindows([{ /* no isDestroyed */ }]), false);
|
||||
// Hidden prewarm windows do not count as a live session.
|
||||
assert.equal(
|
||||
hasNoUsableAppContentWindows([{
|
||||
isDestroyed: () => false,
|
||||
isVisible: () => false,
|
||||
}]),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
hasNoUsableAppContentWindows([{
|
||||
isDestroyed: () => false,
|
||||
isVisible: () => true,
|
||||
}]),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("shouldCommitQuitWithoutDirtyCheck commits when no reachable main windows exist", () => {
|
||||
assert.equal(
|
||||
shouldCommitQuitWithoutDirtyCheck({
|
||||
reachableMainWindows: [],
|
||||
queryableWebContents: [{ id: 1 }],
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("shouldCommitQuitWithoutDirtyCheck commits when no queryable webContents exist", () => {
|
||||
assert.equal(
|
||||
shouldCommitQuitWithoutDirtyCheck({
|
||||
reachableMainWindows: [{ id: 1 }],
|
||||
queryableWebContents: [],
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("shouldCommitQuitWithoutDirtyCheck waits for dirty check when reachable renderers exist", () => {
|
||||
assert.equal(
|
||||
shouldCommitQuitWithoutDirtyCheck({
|
||||
reachableMainWindows: [{ id: 1 }],
|
||||
queryableWebContents: [{ id: 1 }],
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("emitAppLockReopen sends reopen once per live unique webContents", () => {
|
||||
const sent = [];
|
||||
const sharedWebContents = {
|
||||
id: 7,
|
||||
send(channel) {
|
||||
sent.push(channel);
|
||||
},
|
||||
};
|
||||
const windows = [
|
||||
null,
|
||||
{ isDestroyed: () => true, webContents: sharedWebContents },
|
||||
{ isDestroyed: () => false, webContents: sharedWebContents },
|
||||
{ isDestroyed: () => false, webContents: sharedWebContents },
|
||||
{
|
||||
isDestroyed: () => false,
|
||||
webContents: {
|
||||
id: 8,
|
||||
send(channel) {
|
||||
sent.push(channel);
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
emitAppLockReopen(windows);
|
||||
|
||||
assert.deepEqual(sent, [
|
||||
"netcatty:app-lock:reopen",
|
||||
"netcatty:app-lock:reopen",
|
||||
]);
|
||||
});
|
||||
|
||||
test("handleAppHide locks the app in background when controller exists", () => {
|
||||
const calls = [];
|
||||
|
||||
handleAppHide({
|
||||
setLocked(reason) {
|
||||
calls.push(reason);
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(calls, ["background"]);
|
||||
});
|
||||
|
||||
test("handleAppHide ignores missing controllers", () => {
|
||||
assert.doesNotThrow(() => {
|
||||
handleAppHide(null);
|
||||
});
|
||||
});
|
||||
|
||||
test("handleActivateWithMainWindow shows and focuses the main window, then emits reopen", () => {
|
||||
const calls = [];
|
||||
const mainWindow = {
|
||||
isDestroyed: () => false,
|
||||
isMinimized: () => true,
|
||||
restore() {
|
||||
calls.push("restore");
|
||||
},
|
||||
show() {
|
||||
calls.push("show");
|
||||
},
|
||||
focus() {
|
||||
calls.push("focus");
|
||||
},
|
||||
webContents: {
|
||||
id: 99,
|
||||
send(channel) {
|
||||
calls.push(`send:${channel}`);
|
||||
},
|
||||
},
|
||||
};
|
||||
const app = {
|
||||
focus() {
|
||||
calls.push("app.focus");
|
||||
},
|
||||
};
|
||||
const globalShortcutBridge = {
|
||||
clearPendingFullscreenHide(win) {
|
||||
calls.push(`clear:${win === mainWindow}`);
|
||||
},
|
||||
};
|
||||
|
||||
const handled = handleActivateWithMainWindow({
|
||||
app,
|
||||
mainWindow,
|
||||
globalShortcutBridge,
|
||||
reopenWindows: [mainWindow],
|
||||
});
|
||||
|
||||
assert.equal(handled, true);
|
||||
assert.deepEqual(calls, [
|
||||
"clear:true",
|
||||
"restore",
|
||||
"show",
|
||||
"focus",
|
||||
"send:netcatty:app-lock:reopen",
|
||||
"app.focus",
|
||||
]);
|
||||
});
|
||||
|
||||
test("handleActivateWithMainWindow refuses crashed main windows so activate can recreate them", () => {
|
||||
const calls = [];
|
||||
const mainWindow = {
|
||||
isDestroyed: () => false,
|
||||
destroy() {
|
||||
calls.push("destroy");
|
||||
},
|
||||
webContents: {
|
||||
isCrashed: () => true,
|
||||
id: 99,
|
||||
send(channel) {
|
||||
calls.push(`send:${channel}`);
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const handled = handleActivateWithMainWindow({
|
||||
app: {
|
||||
focus() {
|
||||
calls.push("app.focus");
|
||||
},
|
||||
},
|
||||
mainWindow,
|
||||
globalShortcutBridge: {
|
||||
clearPendingFullscreenHide() {
|
||||
calls.push("clear");
|
||||
},
|
||||
},
|
||||
reopenWindows: [mainWindow],
|
||||
});
|
||||
|
||||
assert.equal(handled, false);
|
||||
assert.deepEqual(calls, ["destroy"]);
|
||||
});
|
||||
|
||||
test("handleBeforeQuit commits quit after clean dirty-editor check and locks background", async () => {
|
||||
const calls = [];
|
||||
const mainWindow = {
|
||||
isDestroyed: () => false,
|
||||
isVisible: () => true,
|
||||
isMinimized: () => false,
|
||||
webContents: {
|
||||
isDestroyed: () => false,
|
||||
isCrashed: () => false,
|
||||
id: 1,
|
||||
},
|
||||
};
|
||||
const event = {
|
||||
preventDefault() {
|
||||
calls.push("preventDefault");
|
||||
},
|
||||
};
|
||||
|
||||
await handleBeforeQuit({
|
||||
event,
|
||||
mainWindows: [mainWindow],
|
||||
queryDirtyEditors: async () => false,
|
||||
appLockController: {
|
||||
setLocked(reason) {
|
||||
calls.push(`lock:${reason}`);
|
||||
},
|
||||
},
|
||||
windowManager: {
|
||||
setIsQuitting(value) {
|
||||
calls.push(`setIsQuitting:${value}`);
|
||||
},
|
||||
isQuittingForUpdate() {
|
||||
return false;
|
||||
},
|
||||
},
|
||||
app: {
|
||||
quit() {
|
||||
calls.push("app.quit");
|
||||
},
|
||||
},
|
||||
ipcMain: {},
|
||||
quitConfirmed: false,
|
||||
quitGuardChannelBusy: false,
|
||||
timeoutMs: 10,
|
||||
setQuitGuardChannelBusy(value) {
|
||||
calls.push(`quitGuardBusy:${value}`);
|
||||
},
|
||||
setQuitConfirmed(value) {
|
||||
calls.push(`quitConfirmed:${value}`);
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
"quitGuardBusy:true",
|
||||
"preventDefault",
|
||||
"quitGuardBusy:false",
|
||||
"lock:background",
|
||||
"setIsQuitting:true",
|
||||
"quitConfirmed:true",
|
||||
"app.quit",
|
||||
]);
|
||||
});
|
||||
|
||||
test("handleBeforeQuit cancels quit without locking when dirty editors exist", async () => {
|
||||
const calls = [];
|
||||
const mainWindow = {
|
||||
isDestroyed: () => false,
|
||||
isVisible: () => true,
|
||||
isMinimized: () => false,
|
||||
webContents: {
|
||||
isDestroyed: () => false,
|
||||
isCrashed: () => false,
|
||||
id: 1,
|
||||
},
|
||||
};
|
||||
const event = {
|
||||
preventDefault() {
|
||||
calls.push("preventDefault");
|
||||
},
|
||||
};
|
||||
|
||||
await handleBeforeQuit({
|
||||
event,
|
||||
mainWindows: [mainWindow],
|
||||
queryDirtyEditors: async () => true,
|
||||
appLockController: {
|
||||
setLocked(reason) {
|
||||
calls.push(`lock:${reason}`);
|
||||
},
|
||||
},
|
||||
windowManager: {
|
||||
setIsQuitting(value) {
|
||||
calls.push(`setIsQuitting:${value}`);
|
||||
},
|
||||
isQuittingForUpdate() {
|
||||
return true;
|
||||
},
|
||||
setQuittingForUpdate(value) {
|
||||
calls.push(`setQuittingForUpdate:${value}`);
|
||||
},
|
||||
showAndFocusMainWindow(win) {
|
||||
calls.push(`showAndFocus:${win === mainWindow}`);
|
||||
},
|
||||
},
|
||||
app: {
|
||||
quit() {
|
||||
calls.push("app.quit");
|
||||
},
|
||||
},
|
||||
ipcMain: {},
|
||||
quitConfirmed: false,
|
||||
quitGuardChannelBusy: false,
|
||||
timeoutMs: 10,
|
||||
setQuitGuardChannelBusy(value) {
|
||||
calls.push(`quitGuardBusy:${value}`);
|
||||
},
|
||||
setQuitConfirmed(value) {
|
||||
calls.push(`quitConfirmed:${value}`);
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
"quitGuardBusy:true",
|
||||
"preventDefault",
|
||||
"quitGuardBusy:false",
|
||||
"showAndFocus:true",
|
||||
"setQuittingForUpdate:false",
|
||||
]);
|
||||
});
|
||||
|
||||
test("handleBeforeQuit focuses only dirty windows when multiple renderers reply", async () => {
|
||||
const calls = [];
|
||||
const cleanWindow = {
|
||||
isDestroyed: () => false,
|
||||
webContents: {
|
||||
isDestroyed: () => false,
|
||||
isCrashed: () => false,
|
||||
id: 1,
|
||||
},
|
||||
};
|
||||
const dirtyWindow = {
|
||||
isDestroyed: () => false,
|
||||
isMinimized: () => true,
|
||||
restore() {
|
||||
calls.push("restore");
|
||||
},
|
||||
show() {
|
||||
calls.push("show");
|
||||
},
|
||||
focus() {
|
||||
calls.push("focus");
|
||||
},
|
||||
webContents: {
|
||||
isDestroyed: () => false,
|
||||
isCrashed: () => false,
|
||||
id: 2,
|
||||
},
|
||||
};
|
||||
const event = {
|
||||
preventDefault() {
|
||||
calls.push("preventDefault");
|
||||
},
|
||||
};
|
||||
|
||||
const result = await handleBeforeQuit({
|
||||
event,
|
||||
mainWindows: [cleanWindow, dirtyWindow],
|
||||
queryDirtyEditors: async (wc) => wc.id === 2,
|
||||
appLockController: {
|
||||
setLocked(reason) {
|
||||
calls.push(`lock:${reason}`);
|
||||
},
|
||||
},
|
||||
windowManager: {
|
||||
// No showAndFocusMainWindow — exercise the restore/show/focus fallback
|
||||
// used when the window manager helper is unavailable.
|
||||
setIsQuitting(value) {
|
||||
calls.push(`setIsQuitting:${value}`);
|
||||
},
|
||||
isQuittingForUpdate() {
|
||||
return false;
|
||||
},
|
||||
},
|
||||
app: {
|
||||
quit() {
|
||||
calls.push("app.quit");
|
||||
},
|
||||
},
|
||||
ipcMain: {},
|
||||
quitConfirmed: false,
|
||||
quitGuardChannelBusy: false,
|
||||
timeoutMs: 10,
|
||||
setQuitGuardChannelBusy(value) {
|
||||
calls.push(`quitGuardBusy:${value}`);
|
||||
},
|
||||
setQuitConfirmed(value) {
|
||||
calls.push(`quitConfirmed:${value}`);
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(result.committed, false);
|
||||
assert.equal(result.skipped, "dirty");
|
||||
assert.deepEqual(calls, [
|
||||
"quitGuardBusy:true",
|
||||
"preventDefault",
|
||||
"quitGuardBusy:false",
|
||||
"restore",
|
||||
"show",
|
||||
"focus",
|
||||
]);
|
||||
});
|
||||
45
electron/main/registerBridges.appLock.test.cjs
Normal file
45
electron/main/registerBridges.appLock.test.cjs
Normal file
@@ -0,0 +1,45 @@
|
||||
"use strict";
|
||||
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
|
||||
const {
|
||||
createCloudSyncSessionPasswordReader,
|
||||
} = require("./registerBridges.cjs");
|
||||
|
||||
test("cloud sync session password is hidden while the app is locked", async () => {
|
||||
let persistedReads = 0;
|
||||
const readPassword = createCloudSyncSessionPasswordReader({
|
||||
getAppLockController: () => ({
|
||||
getRuntimeState: () => ({ locked: true }),
|
||||
}),
|
||||
getCachedPassword: () => "cached-secret",
|
||||
setCachedPassword: () => {
|
||||
throw new Error("locked reads must not update the cache");
|
||||
},
|
||||
readPersistedPassword: () => {
|
||||
persistedReads += 1;
|
||||
return "persisted-secret";
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(await readPassword(), null);
|
||||
assert.equal(persistedReads, 0);
|
||||
});
|
||||
|
||||
test("cloud sync session password remains available after unlock", async () => {
|
||||
let cachedPassword = null;
|
||||
const readPassword = createCloudSyncSessionPasswordReader({
|
||||
getAppLockController: () => ({
|
||||
getRuntimeState: () => ({ locked: false }),
|
||||
}),
|
||||
getCachedPassword: () => cachedPassword,
|
||||
setCachedPassword: (password) => {
|
||||
cachedPassword = password;
|
||||
},
|
||||
readPersistedPassword: () => "persisted-secret",
|
||||
});
|
||||
|
||||
assert.equal(await readPassword(), "persisted-secret");
|
||||
assert.equal(cachedPassword, "persisted-secret");
|
||||
});
|
||||
1193
electron/main/registerBridges.cjs
Normal file
1193
electron/main/registerBridges.cjs
Normal file
File diff suppressed because it is too large
Load Diff
35
electron/main/registerBridges.openApplication.test.cjs
Normal file
35
electron/main/registerBridges.openApplication.test.cjs
Normal file
@@ -0,0 +1,35 @@
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert/strict");
|
||||
const { EventEmitter } = require("node:events");
|
||||
const test = require("node:test");
|
||||
const {
|
||||
_waitForApplicationSpawnForTests: waitForApplicationSpawn,
|
||||
} = require("./registerBridges.cjs");
|
||||
|
||||
test("application launch acknowledgement rejects an asynchronous spawn failure", async () => {
|
||||
const child = new EventEmitter();
|
||||
const waiting = waitForApplicationSpawn(child);
|
||||
const error = Object.assign(new Error("spawn ENOENT"), { code: "ENOENT" });
|
||||
process.nextTick(() => child.emit("error", error));
|
||||
await assert.rejects(waiting, { code: "ENOENT" });
|
||||
assert.equal(child.listenerCount("spawn"), 0);
|
||||
});
|
||||
|
||||
test("application launch acknowledgement resolves only after spawn", async () => {
|
||||
const child = new EventEmitter();
|
||||
const waiting = waitForApplicationSpawn(child);
|
||||
process.nextTick(() => child.emit("spawn"));
|
||||
await waiting;
|
||||
assert.equal(child.listenerCount("spawn"), 0);
|
||||
});
|
||||
|
||||
test("macOS launcher acknowledgement rejects a nonzero open command exit", async () => {
|
||||
const child = new EventEmitter();
|
||||
const waiting = waitForApplicationSpawn(child, true);
|
||||
process.nextTick(() => {
|
||||
child.emit("spawn");
|
||||
child.emit("close", 1);
|
||||
});
|
||||
await assert.rejects(waiting, /launcher exited with code 1/i);
|
||||
});
|
||||
Reference in New Issue
Block a user