Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/desktop/src/ipc/methods/preview.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ describe("preview IPC methods", () => {
),
Effect.provideService(DesktopClientSettings.DesktopClientSettings, {
get: Effect.succeedNone,
peek: Effect.succeedNone,
set: () => Effect.void,
}),
),
Expand Down
81 changes: 81 additions & 0 deletions apps/desktop/src/settings/DesktopClientSettings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@ import {
DEFAULT_CLIENT_SETTINGS,
type ClientSettings,
} from "@t3tools/contracts";
import * as Deferred from "effect/Deferred";
import * as Effect from "effect/Effect";
import * as Fiber from "effect/Fiber";
import * as FileSystem from "effect/FileSystem";
import * as Layer from "effect/Layer";
import * as Option from "effect/Option";
Expand Down Expand Up @@ -153,6 +155,7 @@ describe("DesktopClientSettings", () => {
onboardingCompletedAt: "2026-09-05T12:00:00.000Z",
};
yield* settings.set(savedSettings);
assert.deepEqual(yield* settings.peek, Option.some(savedSettings));
const savedContents = yield* fileSystem.readFileString(environment.clientSettingsPath);
const cause = PlatformError.systemError({
_tag: failure.reason,
Expand All @@ -179,13 +182,91 @@ describe("DesktopClientSettings", () => {
assert.equal(error.operation, "read-file");
assert.equal(error.path, environment.clientSettingsPath);
assert.strictEqual(error.cause, cause);
assert.deepEqual(yield* retryableSettings.peek, Option.none());
assert.equal(
yield* fileSystem.readFileString(environment.clientSettingsPath),
savedContents,
);

failRead = false;
assert.deepEqual(yield* retryableSettings.get, Option.some(savedSettings));
assert.deepEqual(yield* retryableSettings.peek, Option.some(savedSettings));
}),
),
);

it.effect("keeps a newer write when an older read finishes after it", () =>
withClientSettings(
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const settings = yield* DesktopClientSettings.DesktopClientSettings;
const holdSettings = { ...clientSettings, confirmQuit: "hold" as const };
const offSettings = { ...clientSettings, confirmQuit: "off" as const };
yield* settings.set(holdSettings);
const readStarted = yield* Deferred.make<void>();
const releaseRead = yield* Deferred.make<void>();
let delayRead = true;
const raceSettings = yield* DesktopClientSettings.make.pipe(
Effect.provideService(
FileSystem.FileSystem,
FileSystem.FileSystem.of({
...fileSystem,
readFileString: (path) =>
fileSystem
.readFileString(path)
.pipe(
Effect.tap(() =>
delayRead
? Deferred.succeed(readStarted, undefined).pipe(
Effect.andThen(Deferred.await(releaseRead)),
)
: Effect.void,
),
),
}),
),
);

const staleRead = yield* Effect.forkChild(raceSettings.get);
yield* Deferred.await(readStarted);
delayRead = false;
yield* raceSettings.set(offSettings);
yield* Deferred.succeed(releaseRead, undefined);

assert.deepEqual(yield* Fiber.join(staleRead), Option.some(holdSettings));
assert.deepEqual(yield* raceSettings.peek, Option.some(offSettings));
}),
),
);

it.effect("keeps the cached settings when a write fails", () =>
withClientSettings(
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const settings = yield* DesktopClientSettings.DesktopClientSettings;
yield* settings.set(clientSettings);
const failingSettings = yield* DesktopClientSettings.make.pipe(
Effect.provideService(
FileSystem.FileSystem,
FileSystem.FileSystem.of({
...fileSystem,
writeFileString: (path) =>
Effect.fail(
PlatformError.systemError({
_tag: "PermissionDenied",
module: "FileSystem",
method: "writeFileString",
pathOrDescriptor: path,
}),
),
}),
),
);
yield* failingSettings.get;

yield* failingSettings.set({ ...clientSettings, confirmQuit: "off" }).pipe(Effect.flip);

assert.deepEqual(yield* failingSettings.peek, Option.some(clientSettings));
}),
),
);
Expand Down
61 changes: 42 additions & 19 deletions apps/desktop/src/settings/DesktopClientSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,8 @@ export class DesktopClientSettings extends Context.Service<
DesktopClientSettings,
{
readonly get: Effect.Effect<Option.Option<ClientSettings>, DesktopClientSettingsReadError>;
/** Latest settings read or written, for callers that must decide synchronously. */
readonly peek: Effect.Effect<Option.Option<ClientSettings>>;
readonly set: (
settings: ClientSettings,
) => Effect.Effect<void, DesktopClientSettingsWriteError>;
Expand Down Expand Up @@ -189,30 +191,50 @@ export const make = Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const crypto = yield* Crypto.Crypto;
const latestRef = yield* Ref.make(Option.none<ClientSettings>());
// Each write starts a generation; a read or write publishes only if no newer write began.
const generationRef = yield* Ref.make(0);
const publish = (generation: number, settings: Option.Option<ClientSettings>) =>
Ref.get(generationRef).pipe(
Effect.flatMap((current) =>
current === generation ? Ref.set(latestRef, settings) : Effect.void,
),
);

return DesktopClientSettings.of({
get: readClientSettings(fileSystem, environment.clientSettingsPath).pipe(
get: Ref.get(generationRef).pipe(
Effect.flatMap((generation) =>
readClientSettings(fileSystem, environment.clientSettingsPath).pipe(
Effect.tap((settings) => publish(generation, settings)),
),
),
Effect.withSpan("desktop.clientSettings.get"),
),
peek: Ref.get(latestRef),
set: Effect.fn("desktop.clientSettings.set")(function (settings) {
return crypto.randomUUIDv4().pipe(
Effect.map((uuid) => uuid.replace(/-/g, "")),
Effect.mapError(
(cause) =>
new DesktopClientSettingsWriteError({
operation: "create-temporary-file-name",
path: environment.clientSettingsPath,
cause,
}),
),
Effect.flatMap((suffix) =>
writeClientSettings({
fileSystem,
path,
settingsPath: environment.clientSettingsPath,
settings,
suffix,
}),
return Ref.updateAndGet(generationRef, (generation) => generation + 1).pipe(
Effect.flatMap((generation) =>
crypto.randomUUIDv4().pipe(
Effect.map((uuid) => uuid.replace(/-/g, "")),
Effect.mapError(
(cause) =>
new DesktopClientSettingsWriteError({
operation: "create-temporary-file-name",
path: environment.clientSettingsPath,
cause,
}),
),
Effect.flatMap((suffix) =>
writeClientSettings({
fileSystem,
path,
settingsPath: environment.clientSettingsPath,
settings,
suffix,
}),
),
Effect.andThen(publish(generation, Option.some(settings))),
),
),
);
}),
Expand All @@ -228,6 +250,7 @@ export const layerTest = (initialSettings: Option.Option<ClientSettings> = Optio
const settingsRef = yield* Ref.make(initialSettings);
return DesktopClientSettings.of({
get: Ref.get(settingsRef),
peek: Ref.get(settingsRef),
set: Effect.fn("DesktopClientSettings.set")(function (settings) {
return Ref.set(settingsRef, Option.some(settings));
}),
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/snapShot/DesktopSnapShot.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -503,6 +503,7 @@ const layerTest = (
DesktopClientSettings.DesktopClientSettings,
DesktopClientSettings.DesktopClientSettings.of({
get: settingsGet,
peek: Effect.succeedNone,
set: () => Effect.void,
}),
),
Expand Down
21 changes: 21 additions & 0 deletions apps/desktop/src/window/DesktopApplicationMenu.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,27 @@ describe("DesktopApplicationMenu", () => {
}),
);

it.effect("leaves Ctrl+Q to the quit shortcut setting on Windows and Linux", () =>
Effect.gen(function* () {
for (const platform of ["linux", "win32"] as const) {
const applicationMenuTemplate =
yield* Deferred.make<readonly Electron.MenuItemConstructorOptions[]>();
yield* configureMenu(yield* Deferred.make<string>(), applicationMenuTemplate, {
platform,
});

const fileMenu = (yield* Deferred.await(applicationMenuTemplate)).find(
(item) => item.label === "File",
);
if (!Array.isArray(fileMenu?.submenu)) {
throw new Error("Expected File menu submenu to be an array.");
}
const quitItem = fileMenu.submenu.find((item) => item.role === "quit");
assert.isFalse(quitItem?.registerAccelerator, platform);
}
}),
);

it.effect("reloads the main window even while a browser page has focus", () =>
Effect.gen(function* () {
const selectedAction = yield* Deferred.make<string>();
Expand Down
3 changes: 2 additions & 1 deletion apps/desktop/src/window/DesktopApplicationMenu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -203,7 +203,8 @@ export const make = Effect.gen(function* () {
// Cmd/Ctrl+W belongs to the renderer's tab close; it must never close the window.
environment.platform === "darwin"
? { label: "Close Window", click: (_item, window) => window?.close() }
: { role: "quit" },
: // Linux's role accelerator would bypass the quit shortcut setting; QuitHold owns Ctrl+Q.
{ role: "quit", registerAccelerator: false },
],
},
{
Expand Down
4 changes: 4 additions & 0 deletions apps/desktop/src/window/DesktopWindow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -302,6 +302,7 @@ export const make = Effect.gen(function* () {
const context = yield* Effect.context<DesktopWindowRuntimeServices>();
const runFork = Effect.runForkWith(context);
const runPromise = Effect.runPromiseWith(context);
const runSync = Effect.runSyncWith(context);
let flushMainWindowBounds: Effect.Effect<void> = Effect.void;

const dismissConnectingSplash = Effect.gen(function* () {
Expand Down Expand Up @@ -619,6 +620,8 @@ export const make = Effect.gen(function* () {
}
});

// Fills clientSettings.peek before the first key press.
runFork(Effect.ignore(clientSettings.get));
const quitShortcutHandler = makeQuitShortcutHandler({
platform: environment.platform,
getMode: () =>
Expand All @@ -631,6 +634,7 @@ export const make = Effect.gen(function* () {
}),
),
),
peekMode: () => Option.getOrUndefined(runSync(clientSettings.peek))?.confirmQuit,
notify: (hint) => {
if (!window.isDestroyed()) {
window.webContents.send(QUIT_SHORTCUT_CHANNEL, hint);
Expand Down
55 changes: 55 additions & 0 deletions apps/desktop/src/window/QuitHold.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,13 +28,15 @@ function makeHarness(options?: {
mode?: QuitConfirmationMode;
platform?: NodeJS.Platform;
getMode?: () => Promise<QuitConfirmationMode>;
peekMode?: () => QuitConfirmationMode | undefined;
}) {
const notifications: Array<QuitShortcutHintEvent> = [];
const concealWindow = vi.fn();
const quit = vi.fn();
const handler = makeQuitShortcutHandler({
platform: options?.platform ?? "darwin",
getMode: options?.getMode ?? (() => Promise.resolve(options?.mode ?? "hold")),
peekMode: options?.peekMode ?? (() => undefined),
notify: (event) => notifications.push(event),
concealWindow,
quit,
Expand Down Expand Up @@ -160,4 +162,57 @@ describe("makeQuitShortcutHandler", () => {
expect(harness.quit).not.toHaveBeenCalled();
expect(harness.notifications).toEqual([DOUBLE_CLICK_DOWN, UP, DOUBLE_CLICK_DOWN]);
});

it.each(["win32", "linux"] as const)(
"passes Ctrl+Q through without quitting when Off on %s",
async (platform) => {
const harness = makeHarness({ mode: "off", peekMode: () => "off", platform });
const ctrlQ = { meta: false, control: true };
await harness.send(makeInput(ctrlQ));
await harness.holdFor(QUIT_HOLD_DURATION_MS + 200, ctrlQ);
await harness.send(makeInput({ ...ctrlQ, type: "keyUp" }));
await harness.send(makeInput(ctrlQ));

expect(harness.preventDefault).not.toHaveBeenCalled();
expect(harness.quit).not.toHaveBeenCalled();
expect(harness.notifications).toEqual([]);
},
);

it("keeps Cmd+Q from reaching the app menu when Off on macOS", async () => {
const harness = makeHarness({ mode: "off", peekMode: () => "off", platform: "darwin" });
await harness.send(makeInput({}));
await harness.send(makeInput({}));

expect(harness.preventDefault).toHaveBeenCalled();
expect(harness.quit).not.toHaveBeenCalled();
expect(harness.notifications).toEqual([]);
});

it("does not quit on a quick second press once Off is known, even with a read pending", async () => {
let peeked: QuitConfirmationMode = "hold";
const ctrlQ = { meta: false, control: true };
const harness = makeHarness({
platform: "win32",
getMode: () => new Promise(() => undefined),
peekMode: () => peeked,
});
await harness.send(makeInput(ctrlQ));
await harness.send(makeInput({ ...ctrlQ, type: "keyUp" }));
peeked = "off";
vi.advanceTimersByTime(QUIT_DOUBLE_PRESS_MS - 100);
await harness.send(makeInput(ctrlQ));

expect(harness.quit).not.toHaveBeenCalled();
expect(harness.preventDefault).toHaveBeenCalledTimes(1);
});

it("does not quit when the mode read settles as Off", async () => {
const harness = makeHarness({ mode: "off", platform: "win32" });
await harness.send(makeInput({ meta: false, control: true }));
await harness.holdFor(QUIT_HOLD_DURATION_MS + 200, { meta: false, control: true });

expect(harness.quit).not.toHaveBeenCalled();
expect(harness.notifications).toEqual([]);
});
});
16 changes: 16 additions & 0 deletions apps/desktop/src/window/QuitHold.ts

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High

if (previousPressAt !== 0 && now - previousPressAt <= QUIT_DOUBLE_PRESS_MS) {

Two presses within QUIT_DOUBLE_PRESS_MS call quitNow() while getMode() is still pending, so Off can still quit the app instead of swallowing the shortcut. This branch runs before the resolved Off check; defer quitting until a current mode read confirms an enabled mode.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/desktop/src/window/QuitHold.ts around line 173:

Two presses within `QUIT_DOUBLE_PRESS_MS` call `quitNow()` while `getMode()` is still pending, so `Off` can still quit the app instead of swallowing the shortcut. This branch runs before the resolved `Off` check; defer quitting until a current mode read confirms an enabled mode.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in dc366ef. The handler now takes a synchronous peekMode() backed by DesktopClientSettings.peek, the latest settings read or written; set updates it before writing. The Off check runs before the quick-second-press branch, so once Off is saved, a second press within QUIT_DOUBLE_PRESS_MS passes through instead of quitting, with or without a pending getMode(). If no settings have been read yet, peek is empty and the existing path applies, but a press whose read settles as Off never quits.

Covered by "does not quit on a quick second press once Off is known, even with a mode read still pending" in QuitHold.test.ts. #9657's no-wait second press is unchanged for the enabled modes.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry, I'm unable to act on this request because you do not have permissions within this repository.

Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ export interface QuitHoldKeyInput {
export interface QuitShortcutOptions {
readonly platform: NodeJS.Platform;
readonly getMode: () => Promise<QuitConfirmationMode>;
/** Latest known mode; preventDefault must be decided before getMode settles. */
readonly peekMode: () => QuitConfirmationMode | undefined;
readonly notify: (event: QuitShortcutHintEvent) => void;
readonly concealWindow: () => void;
readonly quit: () => void;
Expand Down Expand Up @@ -129,6 +131,16 @@ export function makeQuitShortcutHandler(
return;
}

if (options.peekMode() === "off") {
// Passing Cmd+Q through would reach the macOS app menu's Quit accelerator.
if (options.platform === "darwin") event.preventDefault();
if (!input.isAutoRepeat) {
lastPressAt = 0;
release();
}
return;
}

event.preventDefault();

if (input.isAutoRepeat) {
Expand Down Expand Up @@ -158,6 +170,10 @@ export function makeQuitShortcutHandler(
void options.getMode().then(
(resolvedMode) => {
if (generation !== pressGeneration) return;
if (resolvedMode === "off") {
release();
return;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
}
if (resolvedMode === "direct") {
quitNow();
return;
Expand Down
Loading
Loading