Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 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/channels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ export const SET_THEME_CHANNEL = "desktop:set-theme";
export const CONTEXT_MENU_CHANNEL = "desktop:context-menu";
export const OPEN_EXTERNAL_CHANNEL = "desktop:open-external";
export const MENU_ACTION_CHANNEL = "desktop:menu-action";
export const QUIT_SHORTCUT_CHANNEL = "desktop:quit-shortcut";
export const GET_WINDOW_FULLSCREEN_STATE_CHANNEL = "desktop:get-window-fullscreen-state";
export const WINDOW_FULLSCREEN_STATE_CHANNEL = "desktop:window-fullscreen-state";
export const UPDATE_STATE_CHANNEL = "desktop:update-state";
Expand Down
12 changes: 12 additions & 0 deletions apps/desktop/src/ipc/methods/wsl.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,11 @@ import * as DesktopLifecycle from "../../app/DesktopLifecycle.ts";
import * as DesktopShutdown from "../../app/DesktopShutdown.ts";
import * as DesktopState from "../../app/DesktopState.ts";
import * as ElectronApp from "../../electron/ElectronApp.ts";
import * as ElectronDialog from "../../electron/ElectronDialog.ts";
import * as ElectronTheme from "../../electron/ElectronTheme.ts";
import * as ElectronWindow from "../../electron/ElectronWindow.ts";
import * as DesktopAppSettings from "../../settings/DesktopAppSettings.ts";
import * as DesktopClientSettings from "../../settings/DesktopClientSettings.ts";
import * as DesktopWindow from "../../window/DesktopWindow.ts";
import * as DesktopWslBackend from "../../wsl/DesktopWslBackend.ts";
import * as DesktopWslEnvironment from "../../wsl/DesktopWslEnvironment.ts";
Expand Down Expand Up @@ -70,6 +73,15 @@ const unusedLifecycleRuntimeLayer = Layer.mergeAll(
ElectronTheme.ElectronTheme,
ElectronTheme.ElectronTheme.of({} as ElectronTheme.ElectronTheme["Service"]),
),
Layer.succeed(
ElectronDialog.ElectronDialog,
ElectronDialog.ElectronDialog.of({} as ElectronDialog.ElectronDialog["Service"]),
),
Layer.succeed(
ElectronWindow.ElectronWindow,
ElectronWindow.ElectronWindow.of({} as ElectronWindow.ElectronWindow["Service"]),
),
DesktopClientSettings.layerTest(),
);

describe("WSL IPC", () => {
Expand Down
11 changes: 11 additions & 0 deletions apps/desktop/src/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,17 @@ contextBridge.exposeInMainWorld("desktopBridge", {
ipcRenderer.removeListener(IpcChannels.MENU_ACTION_CHANNEL, wrappedListener);
};
},
onQuitShortcut: (listener) => {
const wrappedListener = (_event: Electron.IpcRendererEvent, state: unknown) => {
if (state !== "down" && state !== "up") return;
listener(state);
};

ipcRenderer.on(IpcChannels.QUIT_SHORTCUT_CHANNEL, wrappedListener);
return () => {
ipcRenderer.removeListener(IpcChannels.QUIT_SHORTCUT_CHANNEL, wrappedListener);
};
},
getWindowFullscreenState: () =>
ipcRenderer.sendSync(IpcChannels.GET_WINDOW_FULLSCREEN_STATE_CHANNEL) === true,
onWindowFullscreenStateChange: (listener) => {
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/settings/DesktopClientSettings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
import * as DesktopClientSettings from "./DesktopClientSettings.ts";

const clientSettings: ClientSettings = {
confirmQuit: true,
confirmThreadArchive: true,
confirmThreadDelete: false,
dismissedProviderUpdateNotificationKeys: [],
Expand Down
14 changes: 14 additions & 0 deletions apps/desktop/src/window/DesktopWindow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ import * as DesktopConfig from "../app/DesktopConfig.ts";
import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
import * as DesktopState from "../app/DesktopState.ts";
import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts";
import * as DesktopClientSettings from "../settings/DesktopClientSettings.ts";
import * as ElectronApp from "../electron/ElectronApp.ts";
import * as ElectronMenu from "../electron/ElectronMenu.ts";
import * as ElectronShell from "../electron/ElectronShell.ts";
import * as ElectronTheme from "../electron/ElectronTheme.ts";
Expand Down Expand Up @@ -122,6 +124,14 @@ function makeFakeBrowserWindow() {
};
}

const desktopClientSettingsLayer = Layer.mock(DesktopClientSettings.DesktopClientSettings)({
get: Effect.succeed(Option.none()),
});

const electronAppLayer = Layer.mock(ElectronApp.ElectronApp)({
quit: Effect.void,
});

const desktopAssetsLayer = Layer.succeed(DesktopAssets.DesktopAssets, {
iconPaths: Effect.succeed({
ico: Option.none<string>(),
Expand Down Expand Up @@ -246,8 +256,10 @@ function makeTestLayer(input: {
desktopAssetsLayer,
desktopEnvironmentLayer,
desktopAppSettingsLayer,
desktopClientSettingsLayer,
desktopServerExposureLayer,
DesktopState.layer,
electronAppLayer,
electronMenuLayer,
Layer.succeed(ElectronShell.ElectronShell, {
openExternal: (url) =>
Expand Down Expand Up @@ -345,7 +357,9 @@ const makeSplashScenario = (createOutcomes: readonly (Electron.BrowserWindow | n
desktopAssetsLayer,
desktopEnvironmentLayer,
DesktopAppSettings.layerTest(),
desktopClientSettingsLayer,
desktopServerExposureLayer,
electronAppLayer,
electronMenuLayer,
Layer.succeed(ElectronShell.ElectronShell, {
openExternal: () => Effect.succeed(true),
Expand Down
40 changes: 39 additions & 1 deletion apps/desktop/src/window/DesktopWindow.ts
Comment thread
Bil0000 marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import * as Ref from "effect/Ref";

import * as Electron from "electron";

import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts";

import * as DesktopAssets from "../app/DesktopAssets.ts";
import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
import { makeComponentLogger } from "../app/DesktopObservability.ts";
Expand All @@ -16,9 +18,16 @@ import { getDesktopUrl } from "../electron/ElectronProtocol.ts";
import * as ElectronShell from "../electron/ElectronShell.ts";
import * as ElectronTheme from "../electron/ElectronTheme.ts";
import * as ElectronWindow from "../electron/ElectronWindow.ts";
import { MENU_ACTION_CHANNEL, WINDOW_FULLSCREEN_STATE_CHANNEL } from "../ipc/channels.ts";
import {
MENU_ACTION_CHANNEL,
QUIT_SHORTCUT_CHANNEL,
WINDOW_FULLSCREEN_STATE_CHANNEL,
} from "../ipc/channels.ts";
import * as PreviewManager from "../preview/Manager.ts";
import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts";
import * as DesktopClientSettings from "../settings/DesktopClientSettings.ts";
import * as ElectronApp from "../electron/ElectronApp.ts";
import { makeQuitHoldHandler } from "./QuitHold.ts";

const TITLEBAR_HEIGHT = 40;
const TITLEBAR_COLOR = "#01000000"; // #00000000 does not work correctly on Linux
Expand Down Expand Up @@ -51,6 +60,8 @@ type DesktopWindowRuntimeServices =
| DesktopEnvironment.DesktopEnvironment
| DesktopAssets.DesktopAssets
| DesktopAppSettings.DesktopAppSettings
| DesktopClientSettings.DesktopClientSettings
| ElectronApp.ElectronApp
| ElectronMenu.ElectronMenu
| ElectronShell.ElectronShell
| ElectronTheme.ElectronTheme
Expand Down Expand Up @@ -261,6 +272,8 @@ export const make = Effect.gen(function* () {
const electronWindow = yield* ElectronWindow.ElectronWindow;
const previewManager = yield* PreviewManager.PreviewManager;
const desktopSettings = yield* DesktopAppSettings.DesktopAppSettings;
const clientSettings = yield* DesktopClientSettings.DesktopClientSettings;
const electronApp = yield* ElectronApp.ElectronApp;
// Window-side latch for the primary backend's readiness. Set by
// handleBackendReady (driven by the pool's onReady callback), cleared
// by handleBackendNotReady (driven by onShutdown). Only consumed by
Expand Down Expand Up @@ -533,7 +546,32 @@ export const make = Effect.gen(function* () {
// close-terminal shortcut can outlive the terminal that handled its first
// press, so reject repeats before they reach the native window accelerator.
// Deliberate presses still flow through the renderer or native menu.
// Chrome-style hold-to-quit: intercept the quit accelerator before the
// native menu sees it and only quit after the shortcut is held. The
// renderer shows the "Hold to Quit" hint via QUIT_SHORTCUT_CHANNEL.
const quitHoldHandler = makeQuitHoldHandler({
platform: environment.platform,
isEnabled: () =>
runPromise(
Effect.map(
clientSettings.get,
Option.match({
onNone: () => DEFAULT_CLIENT_SETTINGS.confirmQuit,
onSome: (settings) => settings.confirmQuit,
}),
),
),
notify: (state) => {
if (!window.isDestroyed()) {
window.webContents.send(QUIT_SHORTCUT_CHANNEL, state);
}
},
quit: () => {
void runPromise(electronApp.quit);
},
});
window.webContents.on("before-input-event", (event, input) => {
quitHoldHandler(event, input);
if (input.type !== "keyDown" || !input.isAutoRepeat) return;
const modifier = environment.platform === "darwin" ? input.meta : input.control;
if (modifier && !input.alt && !input.shift && input.key.toLowerCase() === "w") {
Expand Down
101 changes: 101 additions & 0 deletions apps/desktop/src/window/QuitHold.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";

import { makeQuitHoldHandler, QUIT_HOLD_DURATION_MS } from "./QuitHold.ts";
import type { QuitHoldKeyInput, QuitHoldState } from "./QuitHold.ts";

function makeInput(overrides: Partial<QuitHoldKeyInput>): QuitHoldKeyInput {
return {
type: "keyDown",
key: "q",
meta: true,
control: false,
alt: false,
shift: false,
isAutoRepeat: false,
...overrides,
};
}

function makeHarness(options?: { enabled?: boolean; platform?: NodeJS.Platform }) {
const notifications: Array<QuitHoldState> = [];
const quit = vi.fn();
const handler = makeQuitHoldHandler({
platform: options?.platform ?? "darwin",
isEnabled: () => Promise.resolve(options?.enabled ?? true),
notify: (state) => notifications.push(state),
quit,
});
const preventDefault = vi.fn();
const send = async (input: QuitHoldKeyInput) => {
handler({ preventDefault }, input);
// Let the isEnabled promise settle.
await Promise.resolve();
await Promise.resolve();
};
return { notifications, quit, preventDefault, send };
}

describe("makeQuitHoldHandler", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});

it("shows the hint on a tap without quitting", async () => {
const harness = makeHarness();
await harness.send(makeInput({}));
expect(harness.preventDefault).toHaveBeenCalledTimes(1);
expect(harness.notifications).toEqual(["down"]);

await harness.send(makeInput({ type: "keyUp" }));
expect(harness.notifications).toEqual(["down", "up"]);
vi.advanceTimersByTime(QUIT_HOLD_DURATION_MS * 2);
expect(harness.quit).not.toHaveBeenCalled();
});

it("quits after the shortcut is held for the full duration", async () => {
const harness = makeHarness();
await harness.send(makeInput({}));
await harness.send(makeInput({ isAutoRepeat: true }));
vi.advanceTimersByTime(QUIT_HOLD_DURATION_MS - 1);
expect(harness.quit).not.toHaveBeenCalled();
vi.advanceTimersByTime(1);
expect(harness.quit).toHaveBeenCalledTimes(1);
// Only one hint for the whole hold, despite auto-repeats.
expect(harness.notifications).toEqual(["down"]);
});

it("cancels the hold when the modifier is released first", async () => {
const harness = makeHarness();
await harness.send(makeInput({}));
await harness.send(makeInput({ type: "keyUp", key: "Meta", meta: false }));
expect(harness.notifications).toEqual(["down", "up"]);
vi.advanceTimersByTime(QUIT_HOLD_DURATION_MS * 2);
expect(harness.quit).not.toHaveBeenCalled();
});

it("quits immediately on a single press when disabled", async () => {
const harness = makeHarness({ enabled: false });
await harness.send(makeInput({}));
expect(harness.quit).toHaveBeenCalledTimes(1);
});

it("ignores other shortcuts", async () => {
const harness = makeHarness();
await harness.send(makeInput({ key: "w" }));
await harness.send(makeInput({ shift: true }));
await harness.send(makeInput({ meta: false }));
expect(harness.preventDefault).not.toHaveBeenCalled();
expect(harness.notifications).toEqual([]);
});

it("uses control on non-mac platforms", async () => {
const harness = makeHarness({ platform: "linux" });
await harness.send(makeInput({ meta: false, control: true }));
expect(harness.preventDefault).toHaveBeenCalledTimes(1);
vi.advanceTimersByTime(QUIT_HOLD_DURATION_MS);
expect(harness.quit).toHaveBeenCalledTimes(1);
});
});
82 changes: 82 additions & 0 deletions apps/desktop/src/window/QuitHold.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
// Chrome-style hold-to-quit. The quit accelerator is intercepted in
// before-input-event (which runs before the native menu accelerator), and the
// app only quits once the shortcut has been held for QUIT_HOLD_DURATION_MS.
// A quick tap just shows the renderer's "Hold to Quit" hint. Quitting from the
// application menu itself is untouched and quits immediately.
export const QUIT_HOLD_DURATION_MS = 1500;

export type QuitHoldState = "down" | "up";

export interface QuitHoldKeyInput {
readonly type: string;
readonly key: string;
readonly meta: boolean;
readonly control: boolean;
readonly alt: boolean;
readonly shift: boolean;
readonly isAutoRepeat: boolean;
}

export interface QuitHoldOptions {
readonly platform: NodeJS.Platform;
readonly isEnabled: () => Promise<boolean>;
readonly notify: (state: QuitHoldState) => void;
readonly quit: () => void;
}

export function makeQuitHoldHandler(
options: QuitHoldOptions,
): (event: { preventDefault: () => void }, input: QuitHoldKeyInput) => void {
const modifierKey = options.platform === "darwin" ? "meta" : "control";
let holdTimer: NodeJS.Timeout | undefined;
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Outdated
let holding = false;

const release = () => {
if (!holding) return;
holding = false;
if (holdTimer !== undefined) {
clearTimeout(holdTimer);
holdTimer = undefined;
}
options.notify("up");
};
Comment thread
Bil0000 marked this conversation as resolved.

return (event, input) => {
const key = input.key.toLowerCase();
if (input.type === "keyUp") {
if (key === "q" || key === modifierKey) release();
return;
}
if (input.type !== "keyDown") return;

const modifierDown = options.platform === "darwin" ? input.meta : input.control;
if (!modifierDown || input.alt || input.shift || key !== "q") return;
Comment thread
Bil0000 marked this conversation as resolved.
Outdated

event.preventDefault();
if (input.isAutoRepeat || holding) return;

holding = true;
options.notify("down");
void options.isEnabled().then(
(enabled) => {
if (!enabled) {
// Hold-to-quit disabled: a single press quits immediately.
holding = false;
options.quit();
return;
}
if (!holding) return;
holdTimer = setTimeout(() => {
holdTimer = undefined;
holding = false;
options.quit();
}, QUIT_HOLD_DURATION_MS);
},
// A failed settings read must never strand the quit request.
() => {
holding = false;
options.quit();
},
);
};
}
4 changes: 3 additions & 1 deletion apps/web/src/AppRoot.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { describe, expect, it } from "vite-plus/test";

import { ElectronBrowserHost } from "./browser/ElectronBrowserHost";
import { PreviewAutomationHosts } from "./components/preview/PreviewAutomationHosts";
import { QuitHoldOverlay } from "./components/QuitHoldOverlay";
import { AppAtomRegistryProvider } from "./rpc/atomRegistry";
import type { AppRouter } from "./router";
import { AppRoot } from "./AppRoot";
Expand All @@ -16,9 +17,10 @@ describe("AppRoot", () => {
const children = Children.toArray(
(root as ReactElement<{ readonly children: ReactNode }>).props.children,
);
expect(children).toHaveLength(3);
expect(children).toHaveLength(4);
expect(isValidElement(children[0]) && children[0].type).toBe(RouterProvider);
expect(isValidElement(children[1]) && children[1].type).toBe(PreviewAutomationHosts);
expect(isValidElement(children[2]) && children[2].type).toBe(ElectronBrowserHost);
expect(isValidElement(children[3]) && children[3].type).toBe(QuitHoldOverlay);
});
});
Loading
Loading