diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml
index 9bc321dac0de..38a764eab6d7 100644
--- a/.github/ISSUE_TEMPLATE/bug_report.yml
+++ b/.github/ISSUE_TEMPLATE/bug_report.yml
@@ -9,6 +9,7 @@ body:
attributes:
value: |
Use this form for broken behavior, regressions, crashes, or reliability problems.
+ Feature requests belong in [Discussions](https://github.com/pingdotgg/t3code/discussions/categories/ideas).
Search existing issues first and keep the report focused on one problem.
- type: checkboxes
diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml
new file mode 100644
index 000000000000..4f4940ba6655
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/config.yml
@@ -0,0 +1,5 @@
+blank_issues_enabled: false
+contact_links:
+ - name: Feature request
+ url: https://github.com/pingdotgg/t3code/discussions/categories/ideas
+ about: Suggest an improvement or new capability in Discussions.
diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml
deleted file mode 100644
index 3c9424fb322c..000000000000
--- a/.github/ISSUE_TEMPLATE/feature_request.yml
+++ /dev/null
@@ -1,102 +0,0 @@
-name: Feature request
-description: Propose a scoped improvement or new capability.
-title: "[Feature]: "
-labels:
- - enhancement
- - needs-triage
-body:
- - type: markdown
- attributes:
- value: |
- Use this form for new capabilities or meaningful improvements to existing behavior.
- This repo is still early. Small, concrete requests that clearly explain the problem and scope are much easier to evaluate.
-
- - type: checkboxes
- id: checks
- attributes:
- label: Before submitting
- options:
- - label: I searched existing issues and did not find a duplicate.
- required: true
- - label: I am describing a concrete problem or use case, not just a vague idea.
- required: true
-
- - type: dropdown
- id: area
- attributes:
- label: Area
- description: Which part of the project would this change affect?
- options:
- - apps/web
- - apps/server
- - apps/desktop
- - apps/mobile
- - packages/contracts or packages/shared
- - Build, CI, or release tooling
- - Docs
- - Not sure
- validations:
- required: true
-
- - type: textarea
- id: problem
- attributes:
- label: Problem or use case
- description: What are you trying to do? What is hard, slow, or impossible today?
- placeholder: I want to reconnect to an existing provider session after a browser refresh without losing the current thread state.
- validations:
- required: true
-
- - type: textarea
- id: proposal
- attributes:
- label: Proposed solution
- description: Describe the behavior, API, or UX you want.
- placeholder: Persist enough session metadata so the client can discover and reattach to the active provider session on load.
- validations:
- required: true
-
- - type: textarea
- id: value
- attributes:
- label: Why this matters
- description: Who benefits, and what outcome does this unlock?
- placeholder: This would make reconnects predictable during network drops and reduce accidental duplicate sessions.
- validations:
- required: true
-
- - type: textarea
- id: scope
- attributes:
- label: Smallest useful scope
- description: What is the narrowest version of this request that would still solve your problem?
- placeholder: A first pass only needs to support restoring the active session for the current thread.
- validations:
- required: true
-
- - type: textarea
- id: alternatives
- attributes:
- label: Alternatives considered
- description: Workarounds, prior art, or other approaches you considered.
- placeholder: I currently work around this by manually restarting the provider session, but that loses in-flight context.
-
- - type: textarea
- id: tradeoffs
- attributes:
- label: Risks or tradeoffs
- description: What costs, complexity, or edge cases should be considered?
- placeholder: This may require careful handling when the underlying provider session has already exited.
-
- - type: textarea
- id: references
- attributes:
- label: Examples or references
- description: Links, screenshots, mockups, or comparable tools.
-
- - type: checkboxes
- id: contribution
- attributes:
- label: Contribution
- options:
- - label: I would be open to helping implement this.
diff --git a/.github/pr-assets/6424-after.svg b/.github/pr-assets/6424-after.svg
new file mode 100644
index 000000000000..dbeb594a09da
--- /dev/null
+++ b/.github/pr-assets/6424-after.svg
@@ -0,0 +1 @@
+
diff --git a/.github/pr-assets/6424-before.svg b/.github/pr-assets/6424-before.svg
new file mode 100644
index 000000000000..6b365bad6e69
--- /dev/null
+++ b/.github/pr-assets/6424-before.svg
@@ -0,0 +1 @@
+
diff --git a/.github/pr-assets/6503-after.svg b/.github/pr-assets/6503-after.svg
new file mode 100644
index 000000000000..db1c9cb54065
--- /dev/null
+++ b/.github/pr-assets/6503-after.svg
@@ -0,0 +1 @@
+
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index bfce586a5a11..ed9fdf3bfa8a 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -4,7 +4,9 @@
We are not actively accepting contributions right now.
-You can still open an issue or PR, but please do so knowing there is a high chance we close it, defer it forever, or never look at it.
+You can still report a bug or open a PR, but please do so knowing there is a high chance we close it, defer it forever, or never look at it.
+
+Feature requests and proposals belong in [Ideas discussions](https://github.com/pingdotgg/t3code/discussions/categories/ideas), not issues.
If that sounds annoying, that is because it is. This project is still early and we are trying to keep scope, quality, and direction under control.
@@ -50,9 +52,9 @@ If the change depends on motion, timing, transitions, or interaction details, in
If we have to guess what changed, we are much less likely to review it.
-## Issues First
+## Discuss Changes First
-If you are thinking about a non-trivial change, open an issue first.
+If you are thinking about a non-trivial change, start a discussion first. Issues are reserved for bug reports.
That still does not mean we will want the PR, but it gives you a chance to avoid wasting your time.
diff --git a/README.md b/README.md
index a7264ef62e97..8ec101387f67 100644
--- a/README.md
+++ b/README.md
@@ -113,6 +113,8 @@ Checkout their getting started guide for more information: https://viteplus.dev/
vp i
```
-Read [CONTRIBUTING.md](./CONTRIBUTING.md) before opening an issue or PR.
+Read [CONTRIBUTING.md](./CONTRIBUTING.md) before reporting a bug or opening a PR.
+
+Have a feature request? Start an [Ideas discussion](https://github.com/pingdotgg/t3code/discussions/categories/ideas).
Need support? Join the [Discord](https://discord.gg/jn4EGJjrvv).
diff --git a/apps/desktop/src/app/DesktopAppIdentity.test.ts b/apps/desktop/src/app/DesktopAppIdentity.test.ts
index 38bd6d73e063..2a43c35ba97d 100644
--- a/apps/desktop/src/app/DesktopAppIdentity.test.ts
+++ b/apps/desktop/src/app/DesktopAppIdentity.test.ts
@@ -40,6 +40,7 @@ const makeElectronAppLayer = (calls: ElectronAppCalls) =>
Layer.succeed(ElectronApp.ElectronApp, {
metadata: Effect.die("unexpected metadata read"),
name: Effect.succeed("T3 Code"),
+ systemLocale: Effect.succeed("en-US"),
whenReady: Effect.void,
quit: Effect.void,
exit: () => Effect.void,
diff --git a/apps/desktop/src/app/DesktopLifecycle.test.ts b/apps/desktop/src/app/DesktopLifecycle.test.ts
index 62839e6183e7..2c4d16ca8aae 100644
--- a/apps/desktop/src/app/DesktopLifecycle.test.ts
+++ b/apps/desktop/src/app/DesktopLifecycle.test.ts
@@ -21,6 +21,7 @@ describe("DesktopLifecycle", () => {
const electronAppLayer = Layer.succeed(ElectronApp.ElectronApp, {
metadata: Effect.die("unexpected metadata read"),
name: Effect.succeed("T3 Code"),
+ systemLocale: Effect.succeed("en-US"),
whenReady: Effect.void,
quit: Effect.void,
exit: () => Effect.void,
diff --git a/apps/desktop/src/backend/DesktopBackendManager.test.ts b/apps/desktop/src/backend/DesktopBackendManager.test.ts
index 4bf730e84c9c..77a0ff44d63b 100644
--- a/apps/desktop/src/backend/DesktopBackendManager.test.ts
+++ b/apps/desktop/src/backend/DesktopBackendManager.test.ts
@@ -730,6 +730,84 @@ describe("DesktopBackendManager", () => {
),
);
+ it.effect(
+ "re-probes readiness after the first budget expires while the backend is still alive",
+ () =>
+ Effect.scoped(
+ Effect.gen(function* () {
+ const requestUrls: Array = [];
+ let requestCount = 0;
+ let readyCount = 0;
+ let readinessTimeoutCount = 0;
+ const firstProbe = yield* Deferred.make();
+ const childExit = yield* Deferred.make();
+
+ const spawnerLayer = Layer.succeed(
+ ChildProcessSpawner.ChildProcessSpawner,
+ ChildProcessSpawner.make(() =>
+ Effect.succeed(
+ makeProcess({
+ exitCode: Deferred.await(childExit).pipe(
+ Effect.as(ChildProcessSpawner.ExitCode(0)),
+ ),
+ }),
+ ),
+ ),
+ );
+
+ // The backend stays 503 through the first *two* readiness budgets
+ // and only becomes healthy (200) for the third round, i.e. it comes
+ // up well after the initial 50ms budget has expired.
+ const httpLayer = httpClientLayer((request) =>
+ Effect.gen(function* () {
+ requestCount += 1;
+ requestUrls.push(request.url);
+ yield* Deferred.succeed(firstProbe, void 0);
+ return responseForRequest(request, requestCount <= 2 ? 503 : 200);
+ }),
+ );
+
+ const runFiber = yield* DesktopBackendManager.runBackendProcess({
+ ...baseConfig,
+ desktopTelemetryStream: Stream.empty,
+ readinessTimeout: Duration.millis(50),
+ onReady: () =>
+ Effect.sync(() => {
+ readyCount += 1;
+ }),
+ onReadinessFailure: () =>
+ Effect.sync(() => {
+ readinessTimeoutCount += 1;
+ }),
+ }).pipe(Effect.provide(Layer.merge(spawnerLayer, httpLayer)), Effect.forkChild);
+
+ yield* Deferred.await(firstProbe);
+ assert.equal(readyCount, 0);
+ assert.equal(readinessTimeoutCount, 0);
+
+ // The first 50ms readiness budget expires while the backend still
+ // answers 503. The child is alive and may yet become healthy, so the
+ // probe must start a fresh round instead of stopping permanently —
+ // the pre-fix behavior left the app stuck on "Connecting to WSL…"
+ // forever even though the backend kept running.
+ yield* TestClock.adjust(Duration.millis(50));
+ assert.equal(readinessTimeoutCount, 1);
+ assert.equal(readyCount, 0);
+
+ // The second budget also expires (backend still 503), then the third
+ // round connects. The point is the probe persisted across budgets
+ // while the process was alive instead of giving up after the first.
+ yield* TestClock.adjust(Duration.millis(100));
+ assert.equal(readinessTimeoutCount, 2);
+ assert.equal(readyCount, 1);
+ assert.equal(requestUrls.length, 3);
+
+ yield* Deferred.succeed(childExit, void 0);
+ assert.equal((yield* Fiber.join(runFiber)).code.pipe(Option.getOrUndefined), 0);
+ }).pipe(Effect.provide(TestClock.layer())),
+ ),
+ );
+
it.effect("starts the configured backend and closes the scoped process on stop", () =>
Effect.scoped(
Effect.gen(function* () {
diff --git a/apps/desktop/src/backend/DesktopBackendManager.ts b/apps/desktop/src/backend/DesktopBackendManager.ts
index f0e5d2e610e1..61e2200794ef 100644
--- a/apps/desktop/src/backend/DesktopBackendManager.ts
+++ b/apps/desktop/src/backend/DesktopBackendManager.ts
@@ -592,20 +592,33 @@ export const runBackendProcess = Effect.fn("runBackendProcess")(function* (
).pipe(Effect.forkScoped),
);
}
- yield* waitForHttpReady({
- executablePath: options.executablePath,
- entryPath: options.entryPath,
- cwd: options.cwd,
- httpBaseUrl: options.httpBaseUrl,
- timeout: options.readinessTimeout ?? DEFAULT_BACKEND_READINESS_TIMEOUT,
- }).pipe(
- Effect.tap(() => options.onReady?.() ?? Effect.void),
- Effect.catchTags({
- BackendReadinessTimeoutError: (error) => options.onReadinessFailure?.(error) ?? Effect.void,
- }),
- Effect.forkScoped,
+ // Probe readiness in a loop while the backend process is still alive
+ // instead of giving up after the first budget. A slow cold boot (the
+ // WSL bundle loading across /mnt/c, or a first launch right after an
+ // update) can exceed the initial readiness budget while the backend is
+ // about to come up moments later; a one-shot probe left the app stuck
+ // on "Connecting to WSL…" forever even though the backend kept running
+ // and became healthy. Each round gets a fresh budget, and the forked
+ // loop is torn down with the run scope once the child exits.
+ const probeReadiness = Effect.fn("desktop.backendProcess.probeReadiness")(() =>
+ waitForHttpReady({
+ executablePath: options.executablePath,
+ entryPath: options.entryPath,
+ cwd: options.cwd,
+ httpBaseUrl: options.httpBaseUrl,
+ timeout: options.readinessTimeout ?? DEFAULT_BACKEND_READINESS_TIMEOUT,
+ }).pipe(
+ Effect.flatMap(() => options.onReady?.() ?? Effect.void),
+ Effect.as(true),
+ Effect.catchTags({
+ BackendReadinessTimeoutError: (error) =>
+ (options.onReadinessFailure?.(error) ?? Effect.void).pipe(Effect.as(false)),
+ }),
+ ),
);
+ yield* probeReadiness().pipe(Effect.repeat({ while: (ready) => !ready }), Effect.forkScoped);
+
const exit = yield* handle.exitCode.pipe(
Effect.mapError(
(cause) =>
diff --git a/apps/desktop/src/electron/ElectronApp.test.ts b/apps/desktop/src/electron/ElectronApp.test.ts
index 783a8ace5d69..5d7f7458551f 100644
--- a/apps/desktop/src/electron/ElectronApp.test.ts
+++ b/apps/desktop/src/electron/ElectronApp.test.ts
@@ -8,6 +8,7 @@ const {
autoUpdaterRemoveListenerMock,
exitMock,
getAppPathMock,
+ getSystemLocaleMock,
getVersionMock,
isDefaultProtocolClientMock,
onMock,
@@ -30,6 +31,7 @@ const {
autoUpdaterRemoveListenerMock: vi.fn(),
exitMock: vi.fn(),
getAppPathMock: vi.fn(() => "/app"),
+ getSystemLocaleMock: vi.fn(() => "en-GB"),
getVersionMock: vi.fn(() => "1.2.3"),
isDefaultProtocolClientMock: vi.fn(() => false),
onMock: vi.fn(),
@@ -62,6 +64,7 @@ vi.mock("electron", () => ({
setIcon: setDockIconMock,
},
getAppPath: getAppPathMock,
+ getSystemLocale: getSystemLocaleMock,
getVersion: getVersionMock,
isDefaultProtocolClient: isDefaultProtocolClientMock,
isPackaged: true,
@@ -115,6 +118,23 @@ describe("ElectronApp", () => {
}).pipe(Effect.provide(ElectronApp.layer)),
);
+ it.effect("reads the OS locale through the service", () =>
+ Effect.gen(function* () {
+ const electronApp = yield* ElectronApp.ElectronApp;
+
+ assert.strictEqual(yield* electronApp.systemLocale, "en-GB");
+ }).pipe(Effect.provide(ElectronApp.layer)),
+ );
+
+ it.effect("normalizes POSIX-style locale identifiers that Intl rejects", () =>
+ Effect.gen(function* () {
+ getSystemLocaleMock.mockImplementationOnce(() => "en_GB");
+ const electronApp = yield* ElectronApp.ElectronApp;
+
+ assert.strictEqual(yield* electronApp.systemLocale, "en-GB");
+ }).pipe(Effect.provide(ElectronApp.layer)),
+ );
+
it.effect("reports which app metadata property failed", () =>
Effect.gen(function* () {
const cause = new Error("version unavailable");
diff --git a/apps/desktop/src/electron/ElectronApp.ts b/apps/desktop/src/electron/ElectronApp.ts
index 56c06fdff70f..442ddff5a1bf 100644
--- a/apps/desktop/src/electron/ElectronApp.ts
+++ b/apps/desktop/src/electron/ElectronApp.ts
@@ -43,6 +43,13 @@ export class ElectronApp extends Context.Service<
{
readonly metadata: Effect.Effect;
readonly name: Effect.Effect;
+ /**
+ * The OS locale, read from the operating system rather than from Chromium's
+ * resolved application locale — the packaged app ships only the `en-US`
+ * locale pak, so `app.getLocale()` and the renderer's `Intl` default are
+ * pinned to `en-US` however the machine is configured.
+ */
+ readonly systemLocale: Effect.Effect;
readonly whenReady: Effect.Effect;
readonly quit: Effect.Effect;
readonly exit: (code: number) => Effect.Effect;
@@ -120,6 +127,10 @@ export const make = ElectronApp.of({
};
}),
name: Effect.sync(() => Electron.app.name),
+ // macOS derives this from NSLocale, which uses POSIX-style identifiers
+ // (`en_GB`). `Intl` rejects those outright rather than normalizing them, so
+ // the tag is normalized here rather than in the renderer that consumes it.
+ systemLocale: Effect.sync(() => Electron.app.getSystemLocale().replace(/_/g, "-")),
whenReady: Effect.gen(function* () {
const isPackaged = Electron.app.isPackaged;
yield* Effect.tryPromise({
diff --git a/apps/desktop/src/ipc/DesktopIpcHandlers.ts b/apps/desktop/src/ipc/DesktopIpcHandlers.ts
index 8f11d9b4fd70..69adef9f1827 100644
--- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts
+++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts
@@ -34,6 +34,7 @@ import {
getAppBranding,
getLocalEnvironmentBootstraps,
getLocalEnvironmentBearerToken,
+ getSystemLocale,
getWindowFullscreenState,
openExternal,
probeRemoteEditors,
@@ -55,6 +56,7 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers"
yield* PreviewIpc.installPreviewEventForwarding();
yield* ipc.handleSync(getAppBranding);
+ yield* ipc.handleSync(getSystemLocale);
yield* ipc.handleSync(getWindowFullscreenState);
yield* ipc.handleSync(getLocalEnvironmentBootstraps);
yield* ipc.handle(getLocalEnvironmentBearerToken);
diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts
index b6ba65907502..3799a6619e2c 100644
--- a/apps/desktop/src/ipc/channels.ts
+++ b/apps/desktop/src/ipc/channels.ts
@@ -8,6 +8,7 @@ export const RESOLVE_OPEN_WITH_PRESENTATIONS_CHANNEL = "desktop:resolve-open-wit
export const OPEN_WITH_CHANNEL = "desktop:open-with";
export const PROBE_REMOTE_EDITORS_CHANNEL = "desktop:probe-remote-editors";
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";
@@ -17,6 +18,7 @@ export const UPDATE_DOWNLOAD_CHANNEL = "desktop:update-download";
export const UPDATE_INSTALL_CHANNEL = "desktop:update-install";
export const UPDATE_CHECK_CHANNEL = "desktop:update-check";
export const GET_APP_BRANDING_CHANNEL = "desktop:get-app-branding";
+export const GET_SYSTEM_LOCALE_CHANNEL = "desktop:get-system-locale";
export const GET_LOCAL_ENVIRONMENT_BOOTSTRAPS_CHANNEL = "desktop:get-local-environment-bootstraps";
export const GET_LOCAL_ENVIRONMENT_BEARER_TOKEN_CHANNEL =
"desktop:get-local-environment-bearer-token";
diff --git a/apps/desktop/src/ipc/methods/window.ts b/apps/desktop/src/ipc/methods/window.ts
index 16f7a4694afa..0c7e90b95072 100644
--- a/apps/desktop/src/ipc/methods/window.ts
+++ b/apps/desktop/src/ipc/methods/window.ts
@@ -26,6 +26,7 @@ import * as DesktopEnvironment from "../../app/DesktopEnvironment.ts";
import * as DesktopAppSettings from "../../settings/DesktopAppSettings.ts";
import * as DesktopWslBackend from "../../wsl/DesktopWslBackend.ts";
import * as DesktopWslEnvironment from "../../wsl/DesktopWslEnvironment.ts";
+import * as ElectronApp from "../../electron/ElectronApp.ts";
import * as ElectronDialog from "../../electron/ElectronDialog.ts";
import * as ElectronMenu from "../../electron/ElectronMenu.ts";
import * as ElectronShell from "../../electron/ElectronShell.ts";
@@ -64,6 +65,15 @@ export const getAppBranding = DesktopIpc.makeSyncIpcMethod({
}),
});
+export const getSystemLocale = DesktopIpc.makeSyncIpcMethod({
+ channel: IpcChannels.GET_SYSTEM_LOCALE_CHANNEL,
+ result: Schema.String,
+ handler: Effect.fn("desktop.ipc.window.getSystemLocale")(function* () {
+ const electronApp = yield* ElectronApp.ElectronApp;
+ return yield* electronApp.systemLocale;
+ }),
+});
+
export const getWindowFullscreenState = DesktopIpc.makeSyncIpcMethod({
channel: IpcChannels.GET_WINDOW_FULLSCREEN_STATE_CHANNEL,
result: Schema.Boolean,
diff --git a/apps/desktop/src/ipc/methods/wsl.test.ts b/apps/desktop/src/ipc/methods/wsl.test.ts
index 3e07ae7f39bf..38435e286fa7 100644
--- a/apps/desktop/src/ipc/methods/wsl.test.ts
+++ b/apps/desktop/src/ipc/methods/wsl.test.ts
@@ -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";
@@ -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", () => {
diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts
index cae8c889702a..854c22136070 100644
--- a/apps/desktop/src/preload.ts
+++ b/apps/desktop/src/preload.ts
@@ -35,6 +35,10 @@ contextBridge.exposeInMainWorld("desktopBridge", {
}
return result as ReturnType;
},
+ getSystemLocale: () => {
+ const result = ipcRenderer.sendSync(IpcChannels.GET_SYSTEM_LOCALE_CHANNEL);
+ return typeof result === "string" ? result : null;
+ },
getLocalEnvironmentBootstraps: () => {
const result = ipcRenderer.sendSync(IpcChannels.GET_LOCAL_ENVIRONMENT_BOOTSTRAPS_CHANNEL);
if (!Array.isArray(result)) {
@@ -121,6 +125,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) => {
diff --git a/apps/desktop/src/preview/GuestProtocol.ts b/apps/desktop/src/preview/GuestProtocol.ts
index 00616c6a4761..e63597b71efc 100644
--- a/apps/desktop/src/preview/GuestProtocol.ts
+++ b/apps/desktop/src/preview/GuestProtocol.ts
@@ -4,3 +4,4 @@ export const ELEMENT_PICKED_CHANNEL = "preview:element-picked";
export const ANNOTATION_CAPTURED_CHANNEL = "preview:annotation-captured";
export const ANNOTATION_THEME_CHANNEL = "preview:annotation-theme";
export const HUMAN_INPUT_CHANNEL = "preview:human-input";
+export const MOUSE_NAVIGATE_CHANNEL = "preview:mouse-navigate";
diff --git a/apps/desktop/src/preview/Manager.test.ts b/apps/desktop/src/preview/Manager.test.ts
index c24dca802c58..c4297a69c260 100644
--- a/apps/desktop/src/preview/Manager.test.ts
+++ b/apps/desktop/src/preview/Manager.test.ts
@@ -979,7 +979,10 @@ describe("PreviewManager", () => {
),
);
- effectIt.effect("mirrors Electron's effective zoom across registration and navigation", () =>
+ // The guest reports whatever zoom level Chromium handed it from the app
+ // window, so the tab's own zoom is the source of truth in both directions:
+ // asserted onto every guest, never read back off one.
+ effectIt.effect("keeps the tab's own zoom instead of the guest's reported zoom", () =>
withManager((manager) =>
Effect.gen(function* () {
let effectiveZoom = 0.9;
@@ -1025,18 +1028,13 @@ describe("PreviewManager", () => {
yield* manager.createTab("tab_zoom");
yield* manager.registerWebview("tab_zoom", 42);
- expect(states.at(-1)?.zoomFactor).toBe(0.9);
- expect(setZoomFactor).not.toHaveBeenCalled();
+ expect(states.at(-1)?.zoomFactor).toBe(1);
+ expect(setZoomFactor).toHaveBeenCalledWith(1);
- effectiveZoom = 1.25;
- listeners.get("did-navigate")?.();
- yield* Effect.yieldNow;
-
- expect(states.at(-1)?.zoomFactor).toBe(1.25);
- expect(setZoomFactor).not.toHaveBeenCalled();
-
- zoomReadable = false;
- url = "https://example.com/after-zoom-read-failed";
+ // An app zoom leaves the guest reporting the inherited level. Navigating
+ // must not adopt it as the preview's zoom.
+ effectiveZoom = 0.8;
+ url = "https://example.com/after-app-zoom";
listeners.get("did-navigate")?.();
yield* Effect.yieldNow;
@@ -1045,7 +1043,18 @@ describe("PreviewManager", () => {
url,
title: "Example",
});
- expect(states.at(-1)?.zoomFactor).toBe(1.25);
+ expect(states.at(-1)?.zoomFactor).toBe(1);
+
+ // Only the preview's own zoom controls move it.
+ yield* manager.zoomIn("tab_zoom");
+ expect(setZoomFactor).toHaveBeenCalledWith(1.1);
+ expect(states.at(-1)?.zoomFactor).toBe(1.1);
+
+ zoomReadable = false;
+ listeners.get("did-navigate")?.();
+ yield* Effect.yieldNow;
+
+ expect(states.at(-1)?.zoomFactor).toBe(1.1);
const replacementSetZoomFactor = vi.fn();
fromId.mockReturnValue({
@@ -1074,8 +1083,103 @@ describe("PreviewManager", () => {
yield* manager.registerWebview("tab_zoom", 43);
- expect(replacementSetZoomFactor).toHaveBeenCalledWith(1.25);
- expect(states.at(-1)?.zoomFactor).toBe(1.25);
+ expect(replacementSetZoomFactor).toHaveBeenCalledWith(1.1);
+ expect(states.at(-1)?.zoomFactor).toBe(1.1);
+ }),
+ ),
+ );
+
+ // Zooming the app UI pushes the window's zoom level onto every guest, so the
+ // preview has to be put back at the zoom the user gave it.
+ effectIt.effect("re-applies each tab's own zoom when the app window zooms", () =>
+ withManager((manager) =>
+ Effect.gen(function* () {
+ const setZoomFactor = vi.fn();
+ fromId.mockReturnValue({
+ id: 42,
+ isDestroyed: () => false,
+ getType: () => "webview",
+ getURL: () => "https://example.com",
+ getTitle: () => "Example",
+ isLoading: () => false,
+ getZoomFactor: () => 1,
+ setZoomFactor,
+ on: vi.fn(),
+ off: vi.fn(),
+ ipc: { on: vi.fn(), off: vi.fn() },
+ send: webviewSend,
+ navigationHistory: { canGoBack: () => false, canGoForward: () => false },
+ setWindowOpenHandler: vi.fn(),
+ debugger: {
+ isAttached: () => false,
+ attach: vi.fn(),
+ sendCommand: vi.fn(async () => undefined),
+ on: vi.fn(),
+ off: vi.fn(),
+ },
+ } as never);
+
+ yield* manager.createTab("tab_reapply");
+ yield* manager.registerWebview("tab_reapply", 42);
+ yield* manager.zoomIn("tab_reapply");
+ setZoomFactor.mockClear();
+
+ yield* manager.reapplyZoom();
+
+ expect(setZoomFactor).toHaveBeenCalledTimes(1);
+ expect(setZoomFactor).toHaveBeenCalledWith(1.1);
+ }),
+ ),
+ );
+
+ // did-attach and dom-ready both re-register the guest that is already
+ // attached, and a guest that just inherited the app window's zoom needs its
+ // own back — without that round trip republishing tab state.
+ effectIt.effect("re-asserts the tab's zoom when the active guest registers again", () =>
+ withManager((manager) =>
+ Effect.gen(function* () {
+ const setZoomFactor = vi.fn();
+ fromId.mockReturnValue({
+ id: 42,
+ isDestroyed: () => false,
+ getType: () => "webview",
+ getURL: () => "https://example.com",
+ getTitle: () => "Example",
+ isLoading: () => false,
+ getZoomFactor: () => 1,
+ setZoomFactor,
+ on: vi.fn(),
+ off: vi.fn(),
+ ipc: { on: vi.fn(), off: vi.fn() },
+ send: webviewSend,
+ navigationHistory: { canGoBack: () => false, canGoForward: () => false },
+ setWindowOpenHandler: vi.fn(),
+ debugger: {
+ isAttached: () => false,
+ attach: vi.fn(),
+ sendCommand: vi.fn(async () => undefined),
+ on: vi.fn(),
+ off: vi.fn(),
+ },
+ } as never);
+ const states: PreviewManager.PreviewTabState[] = [];
+ yield* manager.subscribeStateChanges((_tabId, state) =>
+ Effect.sync(() => {
+ states.push(state);
+ }),
+ );
+
+ yield* manager.createTab("tab_reregister_zoom");
+ yield* manager.registerWebview("tab_reregister_zoom", 42);
+ yield* manager.zoomIn("tab_reregister_zoom");
+ setZoomFactor.mockClear();
+ const publishedBefore = states.length;
+
+ yield* manager.registerWebview("tab_reregister_zoom", 42);
+
+ expect(setZoomFactor).toHaveBeenCalledWith(1.1);
+ expect(states.length).toBe(publishedBefore);
+ expect(states.at(-1)?.zoomFactor).toBe(1.1);
}),
),
);
@@ -2135,6 +2239,69 @@ describe("PreviewManager", () => {
),
);
+ effectIt.effect("navigates the guest history when the thumb-button ipc fires", () =>
+ withManager((manager) =>
+ Effect.gen(function* () {
+ let mouseNavigate: ((event: unknown, payload: unknown) => void) | undefined;
+ const goBack = vi.fn();
+ const goForward = vi.fn();
+ let canGoBack = true;
+ fromId.mockReturnValue({
+ id: 42,
+ isDestroyed: () => false,
+ getType: () => "webview",
+ getURL: () => "https://example.com",
+ getTitle: () => "Example",
+ isLoading: () => false,
+ getZoomFactor: () => 1,
+ setZoomFactor: vi.fn(),
+ on: vi.fn(),
+ off: vi.fn(),
+ ipc: {
+ on: vi.fn((channel: string, listener: typeof mouseNavigate) => {
+ if (channel === "preview:mouse-navigate") mouseNavigate = listener;
+ }),
+ off: vi.fn(),
+ },
+ send: webviewSend,
+ navigationHistory: {
+ canGoBack: () => canGoBack,
+ canGoForward: () => true,
+ goBack,
+ goForward,
+ },
+ setWindowOpenHandler: vi.fn(),
+ debugger: {
+ isAttached: () => false,
+ attach: vi.fn(),
+ sendCommand: vi.fn(async () => undefined),
+ on: vi.fn(),
+ off: vi.fn(),
+ },
+ } as never);
+
+ yield* manager.createTab("tab_nav");
+ yield* manager.registerWebview("tab_nav", 42);
+ expect(mouseNavigate).toBeDefined();
+
+ mouseNavigate?.({}, { direction: "back" });
+ yield* Effect.yieldNow;
+ expect(goBack).toHaveBeenCalledOnce();
+
+ mouseNavigate?.({}, { direction: "forward" });
+ yield* Effect.yieldNow;
+ expect(goForward).toHaveBeenCalledOnce();
+
+ // Ignores unknown payloads and never navigates when history is exhausted.
+ mouseNavigate?.({}, { direction: "sideways" });
+ canGoBack = false;
+ mouseNavigate?.({}, { direction: "back" });
+ yield* Effect.yieldNow;
+ expect(goBack).toHaveBeenCalledOnce();
+ }),
+ ),
+ );
+
effectIt.effect("reveals only files inside the configured browser artifact directory", () =>
withManager((manager) =>
Effect.gen(function* () {
diff --git a/apps/desktop/src/preview/Manager.ts b/apps/desktop/src/preview/Manager.ts
index 5d4dce17e199..9c680abb64da 100644
--- a/apps/desktop/src/preview/Manager.ts
+++ b/apps/desktop/src/preview/Manager.ts
@@ -58,6 +58,7 @@ import {
CANCEL_PICK_CHANNEL,
ELEMENT_PICKED_CHANNEL,
HUMAN_INPUT_CHANNEL,
+ MOUSE_NAVIGATE_CHANNEL,
START_PICK_CHANNEL,
} from "./GuestProtocol.ts";
import { isPreviewAnnotationPayload } from "./PickedElementPayload.ts";
@@ -649,6 +650,22 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
if (Option.isSome(next)) yield* emit(tabId, next.value);
});
+ /**
+ * Pushes a tab's zoom factor onto whichever guest it currently owns, reading
+ * both at call time. Anything that applies zoom after an await goes through
+ * here: a snapshot taken before the await can be older than a zoom action that
+ * landed in between, and re-applying it would roll that action back.
+ */
+ const assertTabZoom = Effect.fn("PreviewManager.assertTabZoom")(function* (tabId: string) {
+ const tab = (yield* SynchronizedRef.get(tabsRef)).get(tabId);
+ if (!tab || tab.webContentsId == null) return;
+ const wc = webContents.fromId(tab.webContentsId);
+ if (!wc || wc.isDestroyed()) return;
+ yield* attempt({ operation: "assertTabZoom", tabId, webContentsId: wc.id }, () =>
+ wc.setZoomFactor(tab.zoomFactor),
+ ).pipe(Effect.ignore);
+ });
+
const requireWebContents = Effect.fn("PreviewManager.requireWebContents")(function* (
tabId: string,
) {
@@ -1307,10 +1324,6 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
confirmedNavigation = false,
) {
if (wc.isDestroyed()) return;
- const zoomFactor = yield* attempt(
- { operation: "syncWebContentsState.getZoomFactor", tabId, webContentsId: wc.id },
- () => wc.getZoomFactor(),
- ).pipe(Effect.option);
const computedNavStatus = computeNavStatus(wc);
const canGoBack = wc.navigationHistory.canGoBack();
const canGoForward = wc.navigationHistory.canGoForward();
@@ -1340,7 +1353,9 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
navStatus,
canGoBack,
canGoForward,
- ...(Option.isSome(zoomFactor) ? { zoomFactor: zoomFactor.value } : {}),
+ // zoomFactor is deliberately not read back from the guest: Chromium
+ // reports the level it inherited from the app window, so mirroring it
+ // would turn an app zoom into the preview's own zoom.
updatedAt,
};
return [
@@ -1494,6 +1509,22 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
const humanInput = (_event: unknown, rawSignal?: unknown): void => {
runFork(handleHumanInput(rawSignal));
};
+ const mouseNavigate = (_event: unknown, payload?: unknown): void => {
+ const direction =
+ typeof payload === "object" && payload !== null && "direction" in payload
+ ? (payload as { direction?: unknown }).direction
+ : undefined;
+ if (direction !== "back" && direction !== "forward") return;
+ runFork(
+ attempt({ operation: "mouseNavigate", tabId, webContentsId: wc.id }, () => {
+ if (direction === "back") {
+ if (wc.navigationHistory.canGoBack()) wc.navigationHistory.goBack();
+ } else if (wc.navigationHistory.canGoForward()) {
+ wc.navigationHistory.goForward();
+ }
+ }).pipe(Effect.ignore),
+ );
+ };
const forwardShortcut = Effect.fn("PreviewManager.forwardShortcut")(function* (
event: Electron.Event,
input: Electron.Input,
@@ -1540,6 +1571,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
wc.off("did-fail-load", failed as never);
wc.off("before-input-event", beforeInput);
wc.ipc.off(HUMAN_INPUT_CHANNEL, humanInput);
+ wc.ipc.off(MOUSE_NAVIGATE_CHANNEL, mouseNavigate);
}).pipe(Effect.ignore),
);
const install = Effect.fn("PreviewManager.installWebContentsListeners")(function* () {
@@ -1553,6 +1585,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
wc.on("did-stop-loading", sync);
wc.on("did-fail-load", failed as never);
wc.ipc.on(HUMAN_INPUT_CHANNEL, humanInput);
+ wc.ipc.on(MOUSE_NAVIGATE_CHANNEL, mouseNavigate);
wc.setWindowOpenHandler(({ url }) => {
runFork(
attemptPromise({ operation: "openPreviewWindow", tabId, webContentsId: wc.id }, () =>
@@ -1718,11 +1751,10 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
const annotationTheme = yield* Ref.get(annotationThemeRef);
const currentAttachment = attached.get(webContentsId);
if (tab.webContentsId === webContentsId && currentAttachment?.webContents === wc) {
- const zoomFactor = yield* attempt(
- { operation: "registerWebview.getZoomFactor", tabId, webContentsId },
- () => wc.getZoomFactor(),
- );
- yield* update(tabId, { zoomFactor });
+ // The guest we already own re-announced itself, so nothing about the tab
+ // changed. Only push its zoom back down — Chromium may have just handed
+ // this guest the app window's zoom level.
+ yield* assertTabZoom(tabId);
yield* attempt({ operation: "registerWebview.sendTheme", tabId, webContentsId }, () =>
wc.send(ANNOTATION_THEME_CHANNEL, annotationTheme),
);
@@ -1751,18 +1783,13 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
) {
return yield* new PreviewTabNotFoundError({ tabId });
}
- const zoomFactor =
- replacedWebContentsId !== null
- ? yield* attempt(
- { operation: "registerWebview.restoreZoomFactor", tabId, webContentsId },
- () => {
- wc.setZoomFactor(currentTab.zoomFactor);
- return currentTab.zoomFactor;
- },
- )
- : yield* attempt({ operation: "registerWebview.getZoomFactor", tabId, webContentsId }, () =>
- wc.getZoomFactor(),
- );
+ // Always assert the tab's own zoom rather than reading the guest's: a guest
+ // attaching while the app UI is zoomed starts at the embedder's inherited
+ // zoom level, which is not the preview's zoom. Done before the guest is
+ // published so it never paints a frame at the inherited zoom.
+ yield* attempt({ operation: "registerWebview.restoreZoomFactor", tabId, webContentsId }, () =>
+ wc.setZoomFactor(currentTab.zoomFactor),
+ );
yield* attachListeners(tabId, wc);
const registeredAt = yield* currentIso;
const registration = yield* SynchronizedRef.modifyEffect(tabsRef, (tabs) =>
@@ -1786,7 +1813,6 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
navStatus: pendingUrl === null ? computeNavStatus(wc) : current.navStatus,
canGoBack: wc.navigationHistory.canGoBack(),
canGoForward: wc.navigationHistory.canGoForward(),
- zoomFactor,
updatedAt: registeredAt,
};
return [
@@ -1808,6 +1834,9 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
return yield* new PreviewTabNotFoundError({ tabId });
}
const { state: registered, pendingUrl } = registration.value;
+ // A zoom action that landed while this attach was in flight addressed the
+ // guest this one replaced, so settle the new guest on the committed factor.
+ yield* assertTabZoom(tabId);
runFork(restoreControlSession(tabId, wc));
yield* emit(tabId, registered);
yield* attempt({ operation: "registerWebview.sendTheme", tabId, webContentsId }, () =>
@@ -2101,6 +2130,17 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
);
});
+ /**
+ * Chromium hands every guest `` the embedder's zoom level, so zooming
+ * the app UI drags the previewed page along with it. The preview browser owns
+ * its own zoom factor, so re-assert it on each attached guest whenever the main
+ * window's zoom changes (see DesktopWindow.zoomMain).
+ */
+ const reapplyZoom = Effect.fn("PreviewManager.reapplyZoom")(function* () {
+ const tabIds = Array.from((yield* SynchronizedRef.get(tabsRef)).keys());
+ yield* Effect.forEach(tabIds, assertTabZoom, { discard: true });
+ });
+
const applyZoom = Effect.fn("PreviewManager.applyZoom")(function* (
tabId: string,
transform: (current: number) => number,
@@ -3478,6 +3518,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
openPictureInPicture,
openDevTools,
pickElement,
+ reapplyZoom,
refresh,
registerWebview,
resetZoom: (tabId: string) => applyZoom(tabId, () => DEFAULT_ZOOM_FACTOR),
@@ -3776,6 +3817,9 @@ export class PreviewManager extends Context.Service<
readonly zoomIn: (tabId: string) => Effect.Effect;
readonly zoomOut: (tabId: string) => Effect.Effect;
readonly resetZoom: (tabId: string) => Effect.Effect;
+ // Re-applies every attached guest's own zoom factor, undoing the zoom level
+ // Chromium inherits from the embedder when the app UI zooms.
+ readonly reapplyZoom: () => Effect.Effect;
readonly hardReload: (tabId: string) => Effect.Effect;
readonly setColorScheme: (
tabId: string,
@@ -3876,6 +3920,7 @@ export const make = Effect.gen(function* PreviewManagerMake() {
zoomIn: operations.zoomIn,
zoomOut: operations.zoomOut,
resetZoom: operations.resetZoom,
+ reapplyZoom: operations.reapplyZoom,
hardReload: operations.hardReload,
setColorScheme: operations.setColorScheme,
openDevTools: operations.openDevTools,
diff --git a/apps/desktop/src/preview/PickPreload.ts b/apps/desktop/src/preview/PickPreload.ts
index d03673400ab5..f315bdcec738 100644
--- a/apps/desktop/src/preview/PickPreload.ts
+++ b/apps/desktop/src/preview/PickPreload.ts
@@ -22,6 +22,7 @@ import {
CANCEL_PICK_CHANNEL,
ELEMENT_PICKED_CHANNEL,
HUMAN_INPUT_CHANNEL,
+ MOUSE_NAVIGATE_CHANNEL,
START_PICK_CHANNEL,
} from "./GuestProtocol.ts";
const OVERLAY_ATTRIBUTE = "data-t3code-annotation-ui";
@@ -102,6 +103,40 @@ const reportHumanKeyInput = (event: KeyboardEvent): void => {
window.addEventListener("pointerdown", reportHumanPointerInput, true);
window.addEventListener("keydown", reportHumanKeyInput, true);
+// Mouse thumb buttons: `button === 3` is Back, `button === 4` is Forward.
+const MOUSE_BUTTON_BACK = 3;
+const MOUSE_BUTTON_FORWARD = 4;
+
+const navigationDirectionForButton = (button: number): "back" | "forward" | null => {
+ if (button === MOUSE_BUTTON_BACK) return "back";
+ if (button === MOUSE_BUTTON_FORWARD) return "forward";
+ return null;
+};
+
+// Chromium routes thumb-button history navigation to the *focused* WebContents,
+// so hovering this guest without focusing it sends the host app's router back
+// instead of the preview. Suppress Chromium's default here and drive this tab's
+// history explicitly so the buttons always navigate the browser the pointer is
+// over — never the host app.
+const suppressNavigationButton = (event: MouseEvent): void => {
+ if (!event.isTrusted || navigationDirectionForButton(event.button) === null) return;
+ event.preventDefault();
+ event.stopImmediatePropagation();
+};
+
+const requestNavigationForButton = (event: MouseEvent): void => {
+ if (!event.isTrusted) return;
+ const direction = navigationDirectionForButton(event.button);
+ if (direction === null) return;
+ event.preventDefault();
+ event.stopImmediatePropagation();
+ ipcRenderer.send(MOUSE_NAVIGATE_CHANNEL, { direction });
+};
+
+window.addEventListener("mousedown", suppressNavigationButton, true);
+window.addEventListener("mouseup", requestNavigationForButton, true);
+window.addEventListener("auxclick", suppressNavigationButton, true);
+
const nextId = (prefix: string): string => {
idSequence += 1;
return `${prefix}_${idSequence.toString(36)}`;
diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts
index d703dcc6c5ea..daa468281965 100644
--- a/apps/desktop/src/settings/DesktopClientSettings.test.ts
+++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts
@@ -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,
confirmWorktreeRemoval: true,
diff --git a/apps/desktop/src/shell/DesktopShellEnvironment.test.ts b/apps/desktop/src/shell/DesktopShellEnvironment.test.ts
index 831f06f02d35..28955debf7b1 100644
--- a/apps/desktop/src/shell/DesktopShellEnvironment.test.ts
+++ b/apps/desktop/src/shell/DesktopShellEnvironment.test.ts
@@ -152,6 +152,108 @@ describe("DesktopShellEnvironment", () => {
}),
);
+ it.effect("hydrates the locale from the login shell on macOS", () =>
+ Effect.gen(function* () {
+ const env: NodeJS.ProcessEnv = {
+ SHELL: "/bin/zsh",
+ PATH: "/usr/bin",
+ };
+
+ yield* runShellEnvironment({
+ env,
+ platform: "darwin",
+ handler: () =>
+ envOutput({
+ PATH: "/opt/homebrew/bin:/usr/bin",
+ LANG: "de_DE.UTF-8",
+ }),
+ });
+
+ assert.equal(env.LANG, "de_DE.UTF-8");
+ }),
+ );
+
+ it.effect("preserves an inherited locale over the login shell on macOS", () =>
+ Effect.gen(function* () {
+ const env: NodeJS.ProcessEnv = {
+ SHELL: "/bin/zsh",
+ PATH: "/usr/bin",
+ LANG: "en_US.UTF-8",
+ };
+
+ yield* runShellEnvironment({
+ env,
+ platform: "darwin",
+ handler: () =>
+ envOutput({
+ PATH: "/opt/homebrew/bin:/usr/bin",
+ LANG: "de_DE.UTF-8",
+ }),
+ });
+
+ assert.equal(env.LANG, "en_US.UTF-8");
+ }),
+ );
+
+ it.effect("does not mix login-shell locale categories into an inherited locale", () =>
+ Effect.gen(function* () {
+ const env: NodeJS.ProcessEnv = {
+ SHELL: "/bin/zsh",
+ PATH: "/usr/bin",
+ LANG: "en_US.UTF-8",
+ };
+
+ yield* runShellEnvironment({
+ env,
+ platform: "darwin",
+ handler: () =>
+ envOutput({
+ PATH: "/opt/homebrew/bin:/usr/bin",
+ LC_ALL: "de_DE.UTF-8",
+ }),
+ });
+
+ assert.equal(env.LANG, "en_US.UTF-8");
+ assert.equal(env.LC_ALL, undefined);
+ }),
+ );
+
+ it.effect("falls back to a UTF-8 LC_CTYPE when no locale is available on macOS", () =>
+ Effect.gen(function* () {
+ const env: NodeJS.ProcessEnv = {
+ SHELL: "/bin/zsh",
+ PATH: "/usr/bin",
+ };
+
+ yield* runShellEnvironment({
+ env,
+ platform: "darwin",
+ handler: () => envOutput({ PATH: "/opt/homebrew/bin:/usr/bin" }),
+ });
+
+ assert.equal(env.LANG, undefined);
+ assert.equal(env.LC_ALL, undefined);
+ assert.equal(env.LC_CTYPE, "en_US.UTF-8");
+ }),
+ );
+
+ it.effect("does not apply the locale fallback on linux", () =>
+ Effect.gen(function* () {
+ const env: NodeJS.ProcessEnv = {
+ SHELL: "/bin/zsh",
+ PATH: "/usr/bin",
+ };
+
+ yield* runShellEnvironment({
+ env,
+ platform: "linux",
+ handler: () => envOutput({ PATH: "/home/linuxbrew/.linuxbrew/bin:/usr/bin" }),
+ });
+
+ assert.equal(env.LANG, undefined);
+ }),
+ );
+
it.effect("hydrates PATH and missing SSH_AUTH_SOCK from the login shell on linux", () =>
Effect.gen(function* () {
const env: NodeJS.ProcessEnv = {
diff --git a/apps/desktop/src/shell/DesktopShellEnvironment.ts b/apps/desktop/src/shell/DesktopShellEnvironment.ts
index bd8aa6654f79..e065bf55d046 100644
--- a/apps/desktop/src/shell/DesktopShellEnvironment.ts
+++ b/apps/desktop/src/shell/DesktopShellEnvironment.ts
@@ -71,6 +71,9 @@ const LOGIN_SHELL_ENV_NAMES = [
"PATH",
"DBUS_SESSION_BUS_ADDRESS",
"DISPLAY",
+ "LANG",
+ "LC_ALL",
+ "LC_CTYPE",
"SSH_AUTH_SOCK",
"HOMEBREW_PREFIX",
"HOMEBREW_CELLAR",
@@ -84,6 +87,8 @@ const LOGIN_SHELL_ENV_NAMES = [
"WAYLAND_DISPLAY",
] as const;
const WINDOWS_PROFILE_ENV_NAMES = ["PATH", "FNM_DIR", "FNM_MULTISHELL_PATH"] as const;
+const LOCALE_ENV_NAMES = ["LANG", "LC_ALL", "LC_CTYPE"] as const;
+const FALLBACK_LC_CTYPE = "en_US.UTF-8";
const WINDOWS_SHELL_CANDIDATES = ["pwsh.exe", "powershell.exe"] as const;
const LOGIN_SHELL_TIMEOUT = Duration.seconds(5);
const LAUNCHCTL_TIMEOUT = Duration.seconds(2);
@@ -472,6 +477,29 @@ const installPosixEnvironment = Effect.fn("desktop.shellEnvironment.installPosix
}
}
+ // Locale variables form one precedence group: LC_ALL can override an inherited
+ // LANG or LC_CTYPE, so only hydrate the group when the process has none of them.
+ if (
+ config.platform === "darwin" &&
+ LOCALE_ENV_NAMES.every((name) => Option.isNone(trimNonEmpty(config.env[name])))
+ ) {
+ for (const name of LOCALE_ENV_NAMES) {
+ const value = trimNonEmpty(shellEnvironment[name]);
+ if (Option.isSome(value)) {
+ config.env[name] = value.value;
+ }
+ }
+
+ // GUI launches inherit no locale from launchd, so spawned agents land in the C
+ // locale and pbcopy decodes their UTF-8 output as MacRoman. Older supported
+ // macOS releases do not provide C.UTF-8, so set only LC_CTYPE to a UTF-8 locale
+ // available on those releases. Leaving LANG unset keeps C-stable collation and
+ // formatting, so output parsing is unaffected.
+ if (LOCALE_ENV_NAMES.every((name) => Option.isNone(trimNonEmpty(config.env[name])))) {
+ config.env.LC_CTYPE = FALLBACK_LC_CTYPE;
+ }
+ }
+
if (
config.platform === "linux" &&
Option.isNone(trimNonEmpty(config.env.DBUS_SESSION_BUS_ADDRESS))
diff --git a/apps/desktop/src/telemetry/DesktopTelemetryPublisher.test.ts b/apps/desktop/src/telemetry/DesktopTelemetryPublisher.test.ts
index 1cd2ec6327d1..38b0ef7fe2de 100644
--- a/apps/desktop/src/telemetry/DesktopTelemetryPublisher.test.ts
+++ b/apps/desktop/src/telemetry/DesktopTelemetryPublisher.test.ts
@@ -26,6 +26,7 @@ function makeElectronAppLayer(
return Layer.succeed(ElectronApp.ElectronApp, {
metadata: Effect.die("unexpected metadata read"),
name: Effect.succeed("T3 Code"),
+ systemLocale: Effect.succeed("en-US"),
whenReady: Effect.void,
quit: Effect.void,
exit: () => Effect.void,
diff --git a/apps/desktop/src/updates/DesktopUpdates.test.ts b/apps/desktop/src/updates/DesktopUpdates.test.ts
index 1d0bce52864c..b12036db6613 100644
--- a/apps/desktop/src/updates/DesktopUpdates.test.ts
+++ b/apps/desktop/src/updates/DesktopUpdates.test.ts
@@ -122,6 +122,7 @@ function makeHarness(options: UpdatesHarnessOptions = {}) {
runningUnderArm64Translation: false,
}),
name: Effect.succeed("T3 Code"),
+ systemLocale: Effect.succeed("en-US"),
whenReady: Effect.void,
quit: Effect.void,
exit: () => Effect.void,
diff --git a/apps/desktop/src/window/DesktopApplicationMenu.test.ts b/apps/desktop/src/window/DesktopApplicationMenu.test.ts
index c932c5f845ce..b0f17b880369 100644
--- a/apps/desktop/src/window/DesktopApplicationMenu.test.ts
+++ b/apps/desktop/src/window/DesktopApplicationMenu.test.ts
@@ -31,6 +31,7 @@ const environmentInput = {
const electronAppLayer = Layer.succeed(ElectronApp.ElectronApp, {
metadata: Effect.die("unexpected metadata read"),
name: Effect.succeed("T3 Code"),
+ systemLocale: Effect.succeed("en-US"),
whenReady: Effect.void,
quit: Effect.void,
exit: () => Effect.void,
diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts
index ecdeb6db88bf..4037e8441ffb 100644
--- a/apps/desktop/src/window/DesktopWindow.test.ts
+++ b/apps/desktop/src/window/DesktopWindow.test.ts
@@ -39,6 +39,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";
@@ -63,9 +65,14 @@ const environmentInput = {
function makeFakeBrowserWindow() {
const windowListeners = new Map void>();
const webContentsListeners = new Map void>();
+ let zoomLevel = 0;
const webContents = {
copyImageAt: vi.fn(),
getURL: vi.fn(() => "t3code-dev://app/"),
+ getZoomLevel: vi.fn(() => zoomLevel),
+ setZoomLevel: vi.fn((level: number) => {
+ zoomLevel = level;
+ }),
isLoadingMainFrame: vi.fn(() => false),
on: vi.fn((eventName: string, listener: (...args: readonly unknown[]) => void) => {
webContentsListeners.set(eventName, listener);
@@ -118,12 +125,21 @@ function makeFakeBrowserWindow() {
openDevTools: webContents.openDevTools,
reload: webContents.reload,
send: webContents.send,
+ setZoomLevel: webContents.setZoomLevel,
setAutoHideCursor: window.setAutoHideCursor,
webContentsListeners,
windowListeners,
};
}
+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(),
@@ -188,6 +204,7 @@ function makeTestLayer(input: {
bounds: DesktopAppSettings.DesktopWindowBounds,
) => Effect.Effect;
readonly openedExternalUrls?: unknown[];
+ readonly previewZoomReapplies?: number[];
}) {
let desktopSettings = input.desktopSettings ?? DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS;
const desktopAppSettingsLayer = Layer.succeed(DesktopAppSettings.DesktopAppSettings, {
@@ -248,8 +265,10 @@ function makeTestLayer(input: {
desktopAssetsLayer,
desktopEnvironmentLayer,
desktopAppSettingsLayer,
+ desktopClientSettingsLayer,
desktopServerExposureLayer,
DesktopState.layer,
+ electronAppLayer,
electronMenuLayer,
Layer.succeed(ElectronShell.ElectronShell, {
openExternal: (url) =>
@@ -266,6 +285,10 @@ function makeTestLayer(input: {
setMainWindow: () => Effect.void,
isBrowserPartition: (partition) => partition.startsWith("persist:t3code-preview-"),
getBrowserPartition: () => Effect.succeed("persist:t3code-preview-test"),
+ reapplyZoom: () =>
+ Effect.sync(() => {
+ input.previewZoomReapplies?.push(input.window.webContents.getZoomLevel());
+ }),
}),
),
),
@@ -347,7 +370,9 @@ const makeSplashScenario = (createOutcomes: readonly (Electron.BrowserWindow | n
desktopAssetsLayer,
desktopEnvironmentLayer,
DesktopAppSettings.layerTest(),
+ desktopClientSettingsLayer,
desktopServerExposureLayer,
+ electronAppLayer,
electronMenuLayer,
Layer.succeed(ElectronShell.ElectronShell, {
openExternal: () => Effect.succeed(true),
@@ -485,6 +510,42 @@ describe("DesktopWindow", () => {
}),
);
+ // Chromium hands the main window's zoom level down to embedded preview
+ // guests, so every app zoom has to put the preview browser back at its own
+ // zoom or zooming the UI drags the previewed page with it.
+ it.effect("restores the preview browser's own zoom after zooming the app", () =>
+ Effect.gen(function* () {
+ const fakeWindow = makeFakeBrowserWindow();
+ const createCount = yield* Ref.make(0);
+ const mainWindow = yield* Ref.make>(Option.none());
+ const previewZoomReapplies: number[] = [];
+ const layer = makeTestLayer({
+ window: fakeWindow.window,
+ createCount,
+ mainWindow,
+ previewZoomReapplies,
+ });
+
+ yield* Effect.gen(function* () {
+ const desktopWindow = yield* DesktopWindow.DesktopWindow;
+ yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773"));
+
+ yield* desktopWindow.zoomMain("out");
+ yield* desktopWindow.zoomMain("out");
+ yield* desktopWindow.zoomMain("in");
+ yield* desktopWindow.zoomMain("reset");
+
+ assert.deepEqual(
+ fakeWindow.setZoomLevel.mock.calls.map(([level]) => level),
+ [-0.5, -1, -0.5, 0],
+ );
+ // Recorded after the window level moved, so the preview is put back at
+ // its own zoom on every step rather than left on the inherited one.
+ assert.deepEqual(previewZoomReapplies, [-0.5, -1, -0.5, 0]);
+ }).pipe(Effect.provide(layer));
+ }),
+ );
+
it.effect("uses the persisted main window bounds when opening the window", () =>
Effect.gen(function* () {
const fakeWindow = makeFakeBrowserWindow();
diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts
index fc9baf598c4c..064a620402f1 100644
--- a/apps/desktop/src/window/DesktopWindow.ts
+++ b/apps/desktop/src/window/DesktopWindow.ts
@@ -9,6 +9,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";
@@ -21,9 +23,16 @@ import {
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
@@ -56,6 +65,8 @@ type DesktopWindowRuntimeServices =
| DesktopEnvironment.DesktopEnvironment
| DesktopAssets.DesktopAssets
| DesktopAppSettings.DesktopAppSettings
+ | DesktopClientSettings.DesktopClientSettings
+ | ElectronApp.ElectronApp
| ElectronMenu.ElectronMenu
| ElectronShell.ElectronShell
| ElectronTheme.ElectronTheme
@@ -280,6 +291,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
@@ -587,7 +600,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") {
@@ -929,6 +967,10 @@ export const make = Effect.gen(function* () {
webContents.setZoomLevel(
direction === "reset" ? 0 : webContents.getZoomLevel() + (direction === "in" ? 0.5 : -0.5),
);
+ // Chromium pushes the new level down to embedded guests, which would zoom
+ // the previewed page along with the app UI. The preview browser keeps its
+ // own zoom, so put each guest back where the preview left it.
+ yield* previewManager.reapplyZoom();
}),
syncAppearance: Effect.gen(function* () {
const shouldUseDarkColors = yield* electronTheme.shouldUseDarkColors;
diff --git a/apps/desktop/src/window/QuitHold.test.ts b/apps/desktop/src/window/QuitHold.test.ts
new file mode 100644
index 000000000000..c900a865439e
--- /dev/null
+++ b/apps/desktop/src/window/QuitHold.test.ts
@@ -0,0 +1,201 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
+
+import {
+ makeQuitHoldHandler,
+ QUIT_DOUBLE_TAP_MS,
+ QUIT_HOLD_DURATION_MS,
+ QUIT_HOLD_RELEASE_GRACE_MS,
+} from "./QuitHold.ts";
+import type { QuitHoldKeyInput, QuitHoldState } from "./QuitHold.ts";
+
+function makeInput(overrides: Partial): 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;
+ isEnabled?: () => Promise;
+}) {
+ const notifications: Array = [];
+ const quit = vi.fn();
+ const handler = makeQuitHoldHandler({
+ platform: options?.platform ?? "darwin",
+ isEnabled: options?.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();
+ };
+ // Simulates the OS auto-repeating the held shortcut every `intervalMs`.
+ const holdFor = async (
+ durationMs: number,
+ repeatOverrides: Partial = {},
+ intervalMs = 100,
+ ) => {
+ for (let elapsed = 0; elapsed < durationMs; elapsed += intervalMs) {
+ vi.advanceTimersByTime(intervalMs);
+ await send(makeInput({ isAutoRepeat: true, ...repeatOverrides }));
+ }
+ };
+ return { notifications, quit, preventDefault, send, holdFor };
+}
+
+describe("makeQuitHoldHandler", () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ });
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ it("shows the hint on a tap without quitting, even when the release is never seen", async () => {
+ // macOS suppresses the letter's keyUp while Cmd is held, so a tap may
+ // produce no keyUp at all. Quit must still not fire.
+ const harness = makeHarness();
+ await harness.send(makeInput({}));
+ expect(harness.preventDefault).toHaveBeenCalledTimes(1);
+ expect(harness.notifications).toEqual(["down"]);
+
+ vi.advanceTimersByTime(QUIT_HOLD_DURATION_MS + QUIT_HOLD_RELEASE_GRACE_MS);
+ expect(harness.quit).not.toHaveBeenCalled();
+ // The watchdog dismisses the hint once the press is clearly over.
+ expect(harness.notifications).toEqual(["down", "up"]);
+ });
+
+ it("quits once the shortcut auto-repeats past the hold duration", async () => {
+ const harness = makeHarness();
+ await harness.send(makeInput({}));
+ await harness.holdFor(QUIT_HOLD_DURATION_MS - 200);
+ expect(harness.quit).not.toHaveBeenCalled();
+ await harness.holdFor(400);
+ expect(harness.quit).toHaveBeenCalledTimes(1);
+ // Exactly one hint cycle for the whole hold.
+ expect(harness.notifications).toEqual(["down", "up"]);
+ });
+
+ it("does not quit when the hold stops before the duration", async () => {
+ const harness = makeHarness();
+ await harness.send(makeInput({}));
+ await harness.holdFor(500);
+ await harness.send(makeInput({ type: "keyUp" }));
+ expect(harness.notifications).toEqual(["down", "up"]);
+ vi.advanceTimersByTime((QUIT_HOLD_DURATION_MS + QUIT_HOLD_RELEASE_GRACE_MS) * 2);
+ expect(harness.quit).not.toHaveBeenCalled();
+ });
+
+ 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 + QUIT_HOLD_RELEASE_GRACE_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);
+ // The hint is dismissed in case the quit gets cancelled downstream.
+ expect(harness.notifications).toEqual(["down", "up"]);
+ });
+
+ it("discards a stale isEnabled resolution from a superseded press", async () => {
+ // Press #1's isEnabled is still pending when the user releases and
+ // presses again; its late resolution must not act for press #2.
+ const resolvers: Array<(enabled: boolean) => void> = [];
+ const harness = makeHarness({
+ isEnabled: () => new Promise((resolve) => resolvers.push(resolve)),
+ });
+ await harness.send(makeInput({}));
+ await harness.send(makeInput({ type: "keyUp" }));
+ // Outside the double-tap window, so the second press starts a new hold.
+ vi.advanceTimersByTime(QUIT_DOUBLE_TAP_MS + 100);
+ await harness.send(makeInput({}));
+ expect(resolvers).toHaveLength(2);
+
+ // Press #1 resolves late with "disabled" — it must not quit press #2.
+ resolvers[0]?.(false);
+ await Promise.resolve();
+ await Promise.resolve();
+ expect(harness.quit).not.toHaveBeenCalled();
+
+ // Press #2 resolves enabled and completes a full hold.
+ resolvers[1]?.(true);
+ await harness.holdFor(QUIT_HOLD_DURATION_MS + 200);
+ expect(harness.quit).toHaveBeenCalledTimes(1);
+ });
+
+ it("quits on a quick double tap, even when the first release was never seen", async () => {
+ const harness = makeHarness();
+ await harness.send(makeInput({}));
+ vi.advanceTimersByTime(QUIT_DOUBLE_TAP_MS - 100);
+ await harness.send(makeInput({}));
+ expect(harness.quit).toHaveBeenCalledTimes(1);
+ });
+
+ it("treats two slow taps as separate presses", async () => {
+ const harness = makeHarness();
+ await harness.send(makeInput({}));
+ await harness.send(makeInput({ type: "keyUp" }));
+ vi.advanceTimersByTime(QUIT_DOUBLE_TAP_MS + 100);
+ await harness.send(makeInput({}));
+ expect(harness.quit).not.toHaveBeenCalled();
+ expect(harness.notifications).toEqual(["down", "up", "down"]);
+ });
+
+ it("cancels the hold when another key interrupts it", async () => {
+ const harness = makeHarness();
+ await harness.send(makeInput({}));
+ await harness.holdFor(500);
+ // Shift pressed mid-hold breaks the gesture...
+ await harness.send(makeInput({ shift: true }));
+ expect(harness.notifications).toEqual(["down", "up"]);
+ // ...so later repeats past the threshold must not quit.
+ await harness.holdFor(QUIT_HOLD_DURATION_MS);
+ expect(harness.quit).not.toHaveBeenCalled();
+ });
+
+ it("does not count an interrupted press toward a double tap", async () => {
+ const harness = makeHarness();
+ await harness.send(makeInput({}));
+ await harness.send(makeInput({ shift: true }));
+ // A fresh press right after the interruption starts a new hold, not a
+ // double-tap quit.
+ await harness.send(makeInput({}));
+ expect(harness.quit).not.toHaveBeenCalled();
+ expect(harness.notifications).toEqual(["down", "up", "down"]);
+ });
+
+ 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);
+ await harness.holdFor(QUIT_HOLD_DURATION_MS + 200, { meta: false, control: true });
+ expect(harness.quit).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/apps/desktop/src/window/QuitHold.ts b/apps/desktop/src/window/QuitHold.ts
new file mode 100644
index 000000000000..ea2fc7854ac5
--- /dev/null
+++ b/apps/desktop/src/window/QuitHold.ts
@@ -0,0 +1,148 @@
+// @effect-diagnostics globalDate:off globalTimers:off -- Synchronous before-input-event handler; key events must be timed and the watchdog scheduled outside any Effect runtime.
+
+// 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, and a second tap
+// within QUIT_DOUBLE_TAP_MS quits immediately. Quitting from the application
+// menu itself is untouched and quits immediately.
+export const QUIT_HOLD_DURATION_MS = 1200;
+// A second quick tap of the shortcut is the user insisting: quit immediately.
+export const QUIT_DOUBLE_TAP_MS = 500;
+// "Still held" is proven by auto-repeat keydowns, not by the absence of a
+// release: macOS suppresses a letter's keyUp while the command key is down, so
+// a tap's release can go completely unseen and a release-based timer would
+// quit anyway. The press is treated as released once no key event has arrived
+// for QUIT_HOLD_RELEASE_GRACE_MS past the hold duration. Keyboards with
+// auto-repeat disabled cannot hold-to-quit and fall back to the menu's Quit.
+export const QUIT_HOLD_RELEASE_GRACE_MS = 600;
+
+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;
+ 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 watchdog: NodeJS.Timeout | undefined;
+ let holding = false;
+ // Set once isEnabled resolves true; auto-repeats may only quit when armed.
+ let armed = false;
+ let heldSince = 0;
+ let lastPressAt = 0;
+ // Incremented on every new press and every release/quit so a pending
+ // isEnabled() resolution from a superseded press cannot arm (or quit for)
+ // the current one.
+ let generation = 0;
+
+ const clearWatchdog = () => {
+ if (watchdog !== undefined) {
+ clearTimeout(watchdog);
+ watchdog = undefined;
+ }
+ };
+
+ const release = () => {
+ if (!holding) return;
+ generation += 1;
+ holding = false;
+ armed = false;
+ clearWatchdog();
+ options.notify("up");
+ };
+
+ // Dismisses the overlay first: if the quit is cancelled downstream the
+ // renderer must not be left with a stuck "Hold to Quit" hint.
+ const quitNow = () => {
+ release();
+ options.quit();
+ };
+
+ 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") {
+ // Any other key (or an extra modifier) pressed mid-hold breaks the
+ // gesture; without this the hold timer keeps running through the
+ // interruption and the next qualifying repeat would quit early. The
+ // interrupted press also stops counting toward a double tap — but only
+ // here, not in release(), which runs mid-restart on an unseen-release
+ // re-press and must not wipe that press's own tap timestamp.
+ if (holding && !input.isAutoRepeat) {
+ lastPressAt = 0;
+ release();
+ }
+ return;
+ }
+
+ event.preventDefault();
+
+ if (input.isAutoRepeat) {
+ if (armed && Date.now() - heldSince >= QUIT_HOLD_DURATION_MS) {
+ quitNow();
+ }
+ return;
+ }
+
+ const now = Date.now();
+ const previousPressAt = lastPressAt;
+ lastPressAt = now;
+ // A fresh keydown while "holding" means the key came back down after a
+ // release macOS never delivered — so both branches below see real taps.
+ if (previousPressAt !== 0 && now - previousPressAt <= QUIT_DOUBLE_TAP_MS) {
+ quitNow();
+ return;
+ }
+ if (holding) release();
+
+ generation += 1;
+ const pressGeneration = generation;
+ holding = true;
+ heldSince = now;
+ options.notify("down");
+ void options.isEnabled().then(
+ (enabled) => {
+ if (generation !== pressGeneration) return;
+ if (!enabled) {
+ // Hold-to-quit disabled: a single press quits immediately.
+ quitNow();
+ return;
+ }
+ armed = true;
+ // No auto-repeat by then means the key was released (possibly with a
+ // suppressed keyUp) or repeat is disabled; either way, don't quit.
+ watchdog = setTimeout(() => {
+ watchdog = undefined;
+ release();
+ }, QUIT_HOLD_DURATION_MS + QUIT_HOLD_RELEASE_GRACE_MS);
+ },
+ // A failed settings read must never strand the quit request.
+ () => {
+ if (generation !== pressGeneration) return;
+ quitNow();
+ },
+ );
+ };
+}
diff --git a/apps/marketing/src/lib/macArch.test.ts b/apps/marketing/src/lib/macArch.test.ts
new file mode 100644
index 000000000000..24a03f2cffe4
--- /dev/null
+++ b/apps/marketing/src/lib/macArch.test.ts
@@ -0,0 +1,20 @@
+import { describe, expect, it } from "vite-plus/test";
+
+import { macArchFromGpuRenderer } from "./macArch";
+
+describe("macArchFromGpuRenderer", () => {
+ it("detects explicit Apple Silicon renderers", () => {
+ expect(macArchFromGpuRenderer("Apple M4 Pro")).toBe("arm64");
+ });
+
+ it("prefers an Intel GPU marker even when the renderer also mentions Apple", () => {
+ expect(macArchFromGpuRenderer("ANGLE Metal Renderer: Apple, Intel Iris Plus Graphics")).toBe(
+ "x64",
+ );
+ });
+
+ it("uses x64 for ambiguous Safari and unavailable renderer values", () => {
+ expect(macArchFromGpuRenderer("Apple GPU")).toBe("x64");
+ expect(macArchFromGpuRenderer("")).toBe("x64");
+ });
+});
diff --git a/apps/marketing/src/lib/macArch.ts b/apps/marketing/src/lib/macArch.ts
new file mode 100644
index 000000000000..8399c4adbdfa
--- /dev/null
+++ b/apps/marketing/src/lib/macArch.ts
@@ -0,0 +1,16 @@
+const INTEL_GPU_PATTERN = /intel|amd|radeon|nvidia|geforce/i;
+const APPLE_SILICON_GPU_PATTERN = /\bapple\s+m\d/i;
+
+export function macArchFromGpuRenderer(renderer: string): "arm64" | "x64" {
+ if (INTEL_GPU_PATTERN.test(renderer)) {
+ return "x64";
+ }
+ if (APPLE_SILICON_GPU_PATTERN.test(renderer)) {
+ return "arm64";
+ }
+
+ // Generic "Apple GPU" renderers are ambiguous on Safari. x64 is the safe
+ // fallback because Apple Silicon can run it through Rosetta, while Intel
+ // Macs cannot run an arm64 build.
+ return "x64";
+}
diff --git a/apps/marketing/src/pages/index.astro b/apps/marketing/src/pages/index.astro
index 20fae288279c..37ff6b69346c 100644
--- a/apps/marketing/src/pages/index.astro
+++ b/apps/marketing/src/pages/index.astro
@@ -391,14 +391,33 @@ const mobileEndorsementRows = [
',
+ ),
+ ]}
+ />,
+ );
+
+ expect(markup).toContain("<script>globalThis.__t3Xss = 1</script>");
+ expect(markup).toContain(
+ "<img src="x" onerror="globalThis.__t3Xss = 2">",
+ );
+ expect(markup).not.toMatch(/",
+ '
',
+ 'Unsafe link',
+ "",
+ ].join(""),
+ ),
+ ]}
+ />,
+ );
+
+ expect(markup).toContain('data-markdown-details=""');
+ expect(markup).toContain("Safe details");
+ expect(markup).not.toMatch(/