mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-08 00:50:06 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aed49e8fe8 | ||
|
|
7a6a7a2bc3 | ||
|
|
f143fa16a0 |
No files matched your search
@@ -0,0 +1,87 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import React from "react";
|
||||
import { act, create, type ReactTestRenderer } from "react-test-renderer";
|
||||
import { useAppStartupEffects } from "./useAppStartupEffects.ts";
|
||||
import { sftpTransferCenterStore } from "../state/sftpTransferCenterStore.ts";
|
||||
import { netcattyBridge } from "../../infrastructure/services/netcattyBridge.ts";
|
||||
|
||||
for (const accept of [false, true]) {
|
||||
test(`app Quit with an unfinished transfer: ${accept ? "confirm quits" : "declining reports \"transfers\" before resuming only transferring and queued tasks"}`, async (t) => {
|
||||
const previous = Object.getOwnPropertyDescriptor(globalThis, "IS_REACT_ACT_ENVIRONMENT");
|
||||
Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", { configurable: true, value: true });
|
||||
const g = globalThis as unknown as { window?: { confirm?: (m: string) => boolean } };
|
||||
const hadWindow = "window" in g; const previousConfirm = g.window?.confirm;
|
||||
if (!g.window) g.window = {};
|
||||
g.window.confirm = () => accept;
|
||||
let renderer: ReactTestRenderer | undefined;
|
||||
let releaseResume!: () => void;
|
||||
const resumeGate = new Promise<void>((resolve) => { releaseResume = resolve; });
|
||||
t.after(async () => {
|
||||
releaseResume();
|
||||
await act(async () => renderer?.unmount());
|
||||
if (hadWindow && g.window) g.window.confirm = previousConfirm; else delete g.window;
|
||||
if (previous) Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", previous);
|
||||
else Reflect.deleteProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT");
|
||||
});
|
||||
const calls: string[] = [];
|
||||
const reports: Array<[boolean, string | undefined]> = [];
|
||||
let resumeSettled = false;
|
||||
let query!: () => Promise<void>;
|
||||
t.mock.method(sftpTransferCenterStore, "getSnapshot", () => ({
|
||||
tasks: [
|
||||
{ id: "running", status: "transferring" },
|
||||
{ id: "queued", status: "queued" },
|
||||
{ id: "already-paused", status: "paused" },
|
||||
{ id: "pausing", status: "pausing" },
|
||||
{ id: "interrupted", status: "interrupted" },
|
||||
{ id: "child", parentTaskId: "running", status: "transferring" },
|
||||
],
|
||||
}) as unknown as ReturnType<typeof sftpTransferCenterStore.getSnapshot>);
|
||||
t.mock.method(sftpTransferCenterStore, "pause", async (id: string) => { calls.push(`pause:${id}`); });
|
||||
t.mock.method(sftpTransferCenterStore, "resume", async (id: string) => {
|
||||
calls.push(`resume:${id}`);
|
||||
await resumeGate;
|
||||
resumeSettled = true;
|
||||
});
|
||||
t.mock.method(netcattyBridge, "get", () => ({
|
||||
onCheckDirtyEditors: (callback: typeof query) => { query = callback; return () => {}; },
|
||||
reportDirtyEditorsResult: (dirty: boolean, reason?: string) => {
|
||||
calls.push("report");
|
||||
reports.push([dirty, reason]);
|
||||
},
|
||||
}));
|
||||
function Probe() {
|
||||
useAppStartupEffects({ enabled: false, updateState: {}, sessions: [], hosts: [], keys: [], identities: [], knownHosts: [], proxyProfiles: [], groupConfigs: [], workspaces: [], portForwardingRules: [], t: (key: string) => key });
|
||||
return null;
|
||||
}
|
||||
await act(async () => { renderer = create(React.createElement(Probe)); });
|
||||
const paused = [
|
||||
"pause:running",
|
||||
"pause:queued",
|
||||
"pause:already-paused",
|
||||
"pause:pausing",
|
||||
"pause:interrupted",
|
||||
];
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
await Promise.race([
|
||||
query(),
|
||||
new Promise((_, reject) => {
|
||||
timer = setTimeout(() => reject(new Error("dirty check waited on resume")), 1000);
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
assert.equal(resumeSettled, false, "the dirty-check reply must not wait for resume");
|
||||
if (accept) {
|
||||
assert.deepEqual(reports, [[false, undefined]]);
|
||||
assert.deepEqual(calls, [...paused, "report"], "accepted quit leaves every prompted task paused");
|
||||
} else {
|
||||
assert.deepEqual(reports, [[true, "transfers"]], "main must be told the prompt was already answered");
|
||||
assert.deepEqual(calls, [...paused, "report", "resume:running", "resume:queued"],
|
||||
"report precedes resume, and only transferring/queued rows the prompt paused are resumed");
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -356,30 +356,51 @@ export function useAppStartupEffects(ctx: StartupEffectsContext) {
|
||||
// on a slow quit and then quitting anyway after the timeout is
|
||||
// exactly the same outcome.
|
||||
let hasDirty = false;
|
||||
let reason: "editors" | "transfers" | undefined;
|
||||
// Captured before pause. Only transferring/queued rows were active
|
||||
// because of this prompt.
|
||||
let tasksToResume: { id: string }[] = [];
|
||||
try {
|
||||
// Pending handoffs retain source contents; accepted handoffs clear them.
|
||||
hasDirty = editorTabStore.getTabs().some((tab) => tab.content !== tab.baselineContent);
|
||||
if (hasDirty) toast.warning(t('sftp.editor.quitBlockedByDirty'), 'SFTP');
|
||||
if (hasDirty) {
|
||||
reason = "editors";
|
||||
toast.warning(t('sftp.editor.quitBlockedByDirty'), 'SFTP');
|
||||
}
|
||||
if (!hasDirty) {
|
||||
const unfinishedTasks = sftpTransferCenterStore.getSnapshot().tasks.filter((task) => (
|
||||
!task.parentTaskId && !["completed", "failed", "cancelled"].includes(task.status)
|
||||
));
|
||||
if (unfinishedTasks.length > 0) {
|
||||
const resumable = unfinishedTasks.filter((task) => (
|
||||
task.status === "transferring" || task.status === "queued"
|
||||
));
|
||||
await Promise.allSettled(unfinishedTasks.map((task) => sftpTransferCenterStore.pause(task.id)));
|
||||
hasDirty = !window.confirm(t('sftp.transferCenter.quitConfirm', { count: unfinishedTasks.length }));
|
||||
if (hasDirty) {
|
||||
// The user chose to stay. Main must hear that before any resume:
|
||||
// resume can outlive the 5s dirty-check timeout, and a late reply
|
||||
// lets the app quit anyway. The synchronous dialog is also skipped
|
||||
// because this prompt was already answered.
|
||||
reason = "transfers";
|
||||
tasksToResume = resumable;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[App] dirty-editors check failed:', err);
|
||||
}
|
||||
try {
|
||||
bridge.reportDirtyEditorsResult?.(hasDirty);
|
||||
bridge.reportDirtyEditorsResult?.(hasDirty, reason);
|
||||
} catch (err) {
|
||||
// Reporting itself shouldn't throw, but if the IPC bridge is in a
|
||||
// bad state we'd rather log than bubble out of the listener and
|
||||
// disable the quit guard for the rest of the session.
|
||||
console.error('[App] reportDirtyEditorsResult failed:', err);
|
||||
}
|
||||
if (tasksToResume.length > 0) {
|
||||
void Promise.allSettled(tasksToResume.map((task) => sftpTransferCenterStore.resume(task.id)));
|
||||
}
|
||||
});
|
||||
return unsub;
|
||||
}, [enabled, t]);
|
||||
|
||||
@@ -63,7 +63,10 @@ function queryDirtyEditors(webContents, timeoutMs, options = {}) {
|
||||
// consume the listener slot and let the real reply fall through. A
|
||||
// missing/falsy sender is anomalous and treated as a wrong-window reply.
|
||||
if (evt?.sender !== webContents) return;
|
||||
settle(payload?.hasDirty === true);
|
||||
// "transfers": the user already declined the renderer's unfinished-
|
||||
// transfer quit prompt. Still truthy, so existing callers keep quit
|
||||
// cancelled; handleBeforeQuit uses it to skip the editor dialog.
|
||||
settle(payload?.hasDirty === true ? (payload?.reason === "transfers" ? "transfers" : true) : false);
|
||||
}
|
||||
ipcMain.on("app:dirty-editors-result", onResult);
|
||||
|
||||
|
||||
@@ -221,3 +221,15 @@ test("resolves false when no ipcMain can be resolved", async () => {
|
||||
assert.equal(await queryDirtyEditors(wc, 5000), false);
|
||||
assert.deepEqual(wc.sent, []);
|
||||
});
|
||||
|
||||
test("carries a declined unfinished-transfer prompt as a truthy \"transfers\" result", async () => {
|
||||
const ipcMain = makeIpcMain();
|
||||
const wc = makeWebContents();
|
||||
const promise = queryDirtyEditors(wc, 5000, { ipcMain });
|
||||
ipcMain.emit("app:dirty-editors-result", { sender: wc }, { hasDirty: true, reason: "transfers" });
|
||||
assert.equal(await promise, "transfers");
|
||||
// A clean reply that names a reason is still clean.
|
||||
const clean = queryDirtyEditors(wc, 5000, { ipcMain });
|
||||
ipcMain.emit("app:dirty-editors-result", { sender: wc }, { hasDirty: false, reason: "transfers" });
|
||||
assert.equal(await clean, false);
|
||||
});
|
||||
@@ -155,6 +155,7 @@ async function handleBeforeQuit({
|
||||
setQuitConfirmed,
|
||||
commitQuit,
|
||||
cancelPendingUpdateInstall,
|
||||
dialog: injectedDialog,
|
||||
}) {
|
||||
if (quitConfirmed) return { committed: false, skipped: "quit-confirmed" };
|
||||
if (quitGuardChannelBusy) {
|
||||
@@ -207,6 +208,7 @@ async function handleBeforeQuit({
|
||||
queryDirtyEditors(win.webContents, timeoutMs, { ipcMain }).then((hasDirty) => ({
|
||||
win,
|
||||
hasDirty: Boolean(hasDirty),
|
||||
transfersOnly: hasDirty === "transfers",
|
||||
})),
|
||||
),
|
||||
);
|
||||
@@ -249,18 +251,24 @@ async function handleBeforeQuit({
|
||||
// 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
|
||||
// Skip it when every refusal came from the renderer's unfinished-transfer
|
||||
// prompt: the user already answered, and this synchronous box would block
|
||||
// the main process (and every running transfer) behind a wrong message.
|
||||
const dirtyEditorsRefused = dirtyResults.some((result) => result.hasDirty && !result.transfersOnly);
|
||||
if (dirtyEditorsRefused) {
|
||||
try {
|
||||
const dialog = injectedDialog || require("electron").dialog;
|
||||
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?.()) {
|
||||
|
||||
@@ -444,3 +444,35 @@ test("handleBeforeQuit focuses only dirty windows when multiple renderers reply"
|
||||
"focus",
|
||||
]);
|
||||
});
|
||||
|
||||
test("handleBeforeQuit does not raise the blocking editor dialog when only the transfer quit prompt was declined", async () => {
|
||||
const run = async (dirtyValue) => {
|
||||
const calls = [];
|
||||
const win = { isDestroyed: () => false, webContents: { isDestroyed: () => false, isCrashed: () => false, id: 1 } };
|
||||
const result = await handleBeforeQuit({
|
||||
event: { preventDefault() { calls.push("preventDefault"); } },
|
||||
mainWindows: [win],
|
||||
queryDirtyEditors: async () => dirtyValue,
|
||||
appLockController: { setLocked(reason) { calls.push(`lock:${reason}`); } },
|
||||
windowManager: { showAndFocusMainWindow() { calls.push("showAndFocus"); } },
|
||||
app: { quit() { calls.push("app.quit"); } },
|
||||
ipcMain: {},
|
||||
quitConfirmed: false,
|
||||
quitGuardChannelBusy: false,
|
||||
timeoutMs: 10,
|
||||
setQuitGuardChannelBusy() {},
|
||||
setQuitConfirmed(value) { calls.push(`quitConfirmed:${value}`); },
|
||||
dialog: { showMessageBoxSync() { calls.push("dialog"); return 0; } },
|
||||
});
|
||||
return { calls, result };
|
||||
};
|
||||
|
||||
const declined = await run("transfers");
|
||||
assert.equal(declined.result.skipped, "dirty", "the declined prompt still cancels quit");
|
||||
assert.ok(!declined.calls.includes("dialog"), "no synchronous native dialog after the user already answered");
|
||||
assert.ok(!declined.calls.includes("app.quit"));
|
||||
|
||||
const dirty = await run(true);
|
||||
assert.equal(dirty.result.skipped, "dirty");
|
||||
assert.deepEqual(dirty.calls.filter((c) => c === "dialog"), ["dialog"], "real unsaved editors keep the dialog");
|
||||
});
|
||||
@@ -1462,7 +1462,7 @@ function createPreloadApi(ctx) {
|
||||
return () => ipcRenderer.removeListener("app:query-dirty-editors", handler);
|
||||
},
|
||||
// Renderer reports the dirty-check result back to the main process.
|
||||
reportDirtyEditorsResult: (hasDirty) => ipcRenderer.send("app:dirty-editors-result", { hasDirty }),
|
||||
reportDirtyEditorsResult: (hasDirty, reason) => ipcRenderer.send("app:dirty-editors-result", { hasDirty, reason }),
|
||||
|
||||
// Port Forwarding API
|
||||
startPortForward: async (options) => {
|
||||
|
||||
+1
-1
@@ -298,7 +298,7 @@ declare global {
|
||||
// Listener is called with no arguments; return value is an unsubscribe function.
|
||||
onCheckDirtyEditors?(listener: () => void): () => void;
|
||||
// Report the dirty-check result back to the main process.
|
||||
reportDirtyEditorsResult?(hasDirty: boolean): void;
|
||||
reportDirtyEditorsResult?(hasDirty: boolean, reason?: "editors" | "transfers"): void;
|
||||
|
||||
onLanguageChanged?(cb: (language: string) => void): () => void;
|
||||
|
||||
|
||||
Reference in new issue
Block a user