From 6b9947ff720e776af58ce46c7f62d59045adf520 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 27 Oct 2026 22:22:51 +0000 Subject: [PATCH 1/4] fix(desktop): middle-click paste follows gtk-enable-primary-paste on Linux Chrome honours `gtk-enable-primary-paste` from chrome/, which Electron does not ship, so the desktop app pasted the PRIMARY selection on middle click even when the user turned it off system-wide. The main process now reads the gsettings key once in the background on Linux/BSD (missing gsettings, schema, errors, or a timeout keep paste on), and the preload asks for it through a sync IPC method. When it is off, a capture-phase mouseup listener prevents the default for the middle button, which is what stops Blink from pasting the selection. Co-Authored-By: Claude Opus 5.5 --- .../src/app/DesktopPrimaryPaste.test.ts | 71 +++++++++++++++++++ apps/desktop/src/app/DesktopPrimaryPaste.ts | 46 ++++++++++++ apps/desktop/src/ipc/DesktopIpcHandlers.ts | 2 + apps/desktop/src/ipc/channels.ts | 1 + apps/desktop/src/ipc/methods/window.ts | 10 +++ apps/desktop/src/main.ts | 2 + apps/desktop/src/preload.ts | 15 ++++ 7 files changed, 147 insertions(+) create mode 100644 apps/desktop/src/app/DesktopPrimaryPaste.test.ts create mode 100644 apps/desktop/src/app/DesktopPrimaryPaste.ts diff --git a/apps/desktop/src/app/DesktopPrimaryPaste.test.ts b/apps/desktop/src/app/DesktopPrimaryPaste.test.ts new file mode 100644 index 0000000000000..3bf5c2567da53 --- /dev/null +++ b/apps/desktop/src/app/DesktopPrimaryPaste.test.ts @@ -0,0 +1,71 @@ +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as PlatformError from "effect/PlatformError"; +import * as Sink from "effect/Sink"; +import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; +import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; + +import { readGtkPrimaryPasteEnabled } from "./DesktopPrimaryPaste.ts"; + +const encoder = new TextEncoder(); + +const spawnerWithStdout = (stdout: Stream.Stream) => + ChildProcessSpawner.make(() => + Effect.succeed( + ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(1), + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(0)), + isRunning: Effect.succeed(false), + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + stdin: Sink.drain, + stdout, + stderr: Stream.empty, + all: Stream.empty, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }), + ), + ); + +const read = (spawner: ChildProcessSpawner.ChildProcessSpawner["Service"]) => + readGtkPrimaryPasteEnabled.pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ); + +describe("readGtkPrimaryPasteEnabled", () => { + it.effect("is disabled only when gsettings prints false", () => + Effect.gen(function* () { + assert.isFalse(yield* read(spawnerWithStdout(Stream.make(encoder.encode("false\n"))))); + assert.isTrue(yield* read(spawnerWithStdout(Stream.make(encoder.encode("true\n"))))); + // A missing schema prints nothing on stdout. + assert.isTrue(yield* read(spawnerWithStdout(Stream.empty))); + }), + ); + + it.effect("stays enabled when gsettings cannot run", () => + Effect.gen(function* () { + const missing = ChildProcessSpawner.make(() => + Effect.fail( + PlatformError.systemError({ + _tag: "NotFound", + module: "ChildProcess", + method: "spawn", + description: "gsettings not found", + }), + ), + ); + assert.isTrue(yield* read(missing)); + }), + ); + + it.effect("stays enabled when gsettings hangs", () => + Effect.gen(function* () { + const fiber = yield* read(spawnerWithStdout(Stream.never)).pipe(Effect.forkChild); + yield* TestClock.adjust("2 seconds"); + assert.isTrue(yield* Fiber.join(fiber)); + }), + ); +}); diff --git a/apps/desktop/src/app/DesktopPrimaryPaste.ts b/apps/desktop/src/app/DesktopPrimaryPaste.ts new file mode 100644 index 0000000000000..42bef91a329b0 --- /dev/null +++ b/apps/desktop/src/app/DesktopPrimaryPaste.ts @@ -0,0 +1,46 @@ +import * as HostProcess from "@t3tools/shared/HostProcess"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Ref from "effect/Ref"; +import * as ChildProcess from "effect/process/ChildProcess"; +import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; + +export class DesktopPrimaryPaste extends Context.Service< + DesktopPrimaryPaste, + { + readonly enabled: Effect.Effect; + } +>()("@t3tools/desktop/app/DesktopPrimaryPaste") {} + +// Chrome applies gtk-enable-primary-paste from chrome/, which Electron does not include. +export const readGtkPrimaryPasteEnabled = Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const output = yield* spawner.string( + ChildProcess.make( + "gsettings", + ["get", "org.gnome.desktop.interface", "gtk-enable-primary-paste"], + { stdin: "ignore", stderr: "ignore" }, + ), + ); + return output.trim() !== "false"; +}).pipe( + Effect.timeout("2 seconds"), + Effect.orElseSucceed(() => true), +); + +/** @public */ +export const make = Effect.gen(function* () { + const platform = yield* HostProcess.Platform; + const enabled = yield* Ref.make(true); + if (platform !== "darwin" && platform !== "win32") { + // Read in the background: sync IPC cannot wait, and the renderer loads long after this. + yield* readGtkPrimaryPasteEnabled.pipe( + Effect.flatMap((value) => Ref.set(enabled, value)), + Effect.forkScoped, + ); + } + return DesktopPrimaryPaste.of({ enabled: Ref.get(enabled) }); +}); + +export const layer = Layer.effect(DesktopPrimaryPaste, make); diff --git a/apps/desktop/src/ipc/DesktopIpcHandlers.ts b/apps/desktop/src/ipc/DesktopIpcHandlers.ts index 2d67aa56e6ccd..7a806e1aef8d7 100644 --- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts +++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts @@ -43,6 +43,7 @@ import { getLocalEnvironmentBearerToken, getRelayTelemetryEnabled, getSystemLocale, + getPrimaryPasteEnabled, getWindowFullscreenState, openExternal, openSystemSettings, @@ -98,6 +99,7 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" yield* ipc.handleSync(getAppBranding); yield* ipc.handleSync(getRelayTelemetryEnabled); yield* ipc.handleSync(getSystemLocale); + yield* ipc.handleSync(getPrimaryPasteEnabled); yield* ipc.handleSync(getWindowFullscreenState); yield* ipc.handleSync(getLocalEnvironmentBootstraps); yield* ipc.handleSync(getLocalEnvironmentEnabled); diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index 9b11a2283a273..754fae0ccb0de 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -32,6 +32,7 @@ export const CLI_COMMAND_UNINSTALL_CHANNEL = "desktop:cli-command-uninstall"; export const GET_APP_BRANDING_CHANNEL = "desktop:get-app-branding"; export const GET_RELAY_TELEMETRY_ENABLED_CHANNEL = "desktop:get-relay-telemetry-enabled"; export const GET_SYSTEM_LOCALE_CHANNEL = "desktop:get-system-locale"; +export const GET_PRIMARY_PASTE_ENABLED_CHANNEL = "desktop:get-primary-paste-enabled"; export const GET_LOCAL_ENVIRONMENT_BOOTSTRAPS_CHANNEL = "desktop:get-local-environment-bootstraps"; export const GET_LOCAL_ENVIRONMENT_ENABLED_CHANNEL = "desktop:get-local-environment-enabled"; export const SET_LOCAL_ENVIRONMENT_ENABLED_CHANNEL = "desktop:set-local-environment-enabled"; diff --git a/apps/desktop/src/ipc/methods/window.ts b/apps/desktop/src/ipc/methods/window.ts index f0d661b58bc9a..1638838b5eb3d 100644 --- a/apps/desktop/src/ipc/methods/window.ts +++ b/apps/desktop/src/ipc/methods/window.ts @@ -27,6 +27,7 @@ import * as DesktopBackendConfiguration from "../../backend/DesktopBackendConfig import * as DesktopBackendPool from "../../backend/DesktopBackendPool.ts"; import * as DesktopLocalEnvironmentAuth from "../../backend/DesktopLocalEnvironmentAuth.ts"; import * as DesktopEnvironment from "../../app/DesktopEnvironment.ts"; +import * as DesktopPrimaryPaste from "../../app/DesktopPrimaryPaste.ts"; import * as DesktopAppSettings from "../../settings/DesktopAppSettings.ts"; import * as DesktopWslBackend from "../../wsl/DesktopWslBackend.ts"; import * as DesktopWslEnvironment from "../../wsl/DesktopWslEnvironment.ts"; @@ -89,6 +90,15 @@ export const getSystemLocale = DesktopIpc.makeSyncIpcMethod({ }), }); +export const getPrimaryPasteEnabled = DesktopIpc.makeSyncIpcMethod({ + channel: IpcChannels.GET_PRIMARY_PASTE_ENABLED_CHANNEL, + result: Schema.Boolean, + handler: Effect.fn("desktop.ipc.window.getPrimaryPasteEnabled")(function* () { + const primaryPaste = yield* DesktopPrimaryPaste.DesktopPrimaryPaste; + return yield* primaryPaste.enabled; + }), +}); + export const getWindowFullscreenState = DesktopIpc.makeSyncIpcMethod({ channel: IpcChannels.GET_WINDOW_FULLSCREEN_STATE_CHANNEL, result: Schema.Boolean, diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 9a3c327e6b86b..63944aa1123eb 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -42,6 +42,7 @@ import * as DesktopClerk from "./app/DesktopClerk.ts"; import * as DesktopCliCommand from "./app/DesktopCliCommand.ts"; import * as DesktopApplicationMenu from "./window/DesktopApplicationMenu.ts"; import * as DesktopAssets from "./app/DesktopAssets.ts"; +import * as DesktopPrimaryPaste from "./app/DesktopPrimaryPaste.ts"; import * as DesktopBackendConfiguration from "./backend/DesktopBackendConfiguration.ts"; import * as DesktopBackendPool from "./backend/DesktopBackendPool.ts"; import * as DesktopLocalEnvironmentAuth from "./backend/DesktopLocalEnvironmentAuth.ts"; @@ -148,6 +149,7 @@ const layerDesktopFoundation = Layer.mergeAll( DesktopClientSettings.layer, DesktopConnectionCatalogStore.layer.pipe(Layer.provideMerge(DesktopSavedEnvironments.layer)), DesktopAssets.layer, + DesktopPrimaryPaste.layer, DesktopObservability.layer, DesktopRendererHistory.layer, ).pipe(Layer.provideMerge(layerDesktopEnvironment)); diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 4d57b30e2139a..e7a7c1eff7506 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -57,6 +57,21 @@ if (clientPlatform === "darwin") { window.addEventListener("resize", syncWindowControlInset); } +if ( + clientPlatform !== "darwin" && + clientPlatform !== "win32" && + ipcRenderer.sendSync(IpcChannels.GET_PRIMARY_PASTE_ENABLED_CHANNEL) === false +) { + // Blink pastes the PRIMARY selection on a middle-click mouseup unless it is default-prevented. + window.addEventListener( + "mouseup", + (event) => { + if (event.button === 1) event.preventDefault(); + }, + true, + ); +} + function unwrapEnsureSshEnvironmentResult(result: unknown) { if ( typeof result === "object" && From 9d6986775c6211d5931c48e8b2f0fcdbb40cc219 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 27 Oct 2026 22:36:51 +0000 Subject: [PATCH 2/4] fix(desktop): primary-paste setting applies from first window, in terminal and previews The gsettings lookup ran in the background with a provisional "enabled", so a window whose preload asked first kept middle-click pasting until reload. The service now resolves the bounded lookup (2 s timeout, fail-open) while it is built and holds a constant. The preload reads it once, falling back to enabled if the sync IPC throws so the desktop bridge is always exposed, and exposes it as `desktopBridge.getPrimaryPasteEnabled`. The Ghostty terminal skips its own selection paste on middle pointerdown when the desktop reports it disabled. Browser preview guests get the same mouseup guard through an `additionalArguments` flag, like the passkey bridge. Co-Authored-By: Claude Opus 5.5 --- .../src/app/DesktopPrimaryPaste.test.ts | 18 ++++++++++++++++++ apps/desktop/src/app/DesktopPrimaryPaste.ts | 16 +++++----------- apps/desktop/src/ipc/methods/window.ts | 2 +- apps/desktop/src/preload.ts | 13 ++++++++----- apps/desktop/src/preview-pick-preload.ts | 16 +++++++++++++++- apps/desktop/src/preview/GuestProtocol.ts | 1 + apps/desktop/src/window/DesktopWindow.test.ts | 3 +++ apps/desktop/src/window/DesktopWindow.ts | 13 ++++++++++++- apps/web/src/terminal/ghostty/surface.test.ts | 18 ++++++++++++++++++ apps/web/src/terminal/ghostty/surface.ts | 4 +++- packages/contracts/src/ipc.ts | 2 ++ 11 files changed, 86 insertions(+), 20 deletions(-) diff --git a/apps/desktop/src/app/DesktopPrimaryPaste.test.ts b/apps/desktop/src/app/DesktopPrimaryPaste.test.ts index 3bf5c2567da53..09b0917f0c387 100644 --- a/apps/desktop/src/app/DesktopPrimaryPaste.test.ts +++ b/apps/desktop/src/app/DesktopPrimaryPaste.test.ts @@ -1,4 +1,5 @@ import { assert, describe, it } from "@effect/vitest"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; import * as PlatformError from "effect/PlatformError"; @@ -7,6 +8,7 @@ import * as Stream from "effect/Stream"; import * as TestClock from "effect/testing/TestClock"; import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; +import * as DesktopPrimaryPaste from "./DesktopPrimaryPaste.ts"; import { readGtkPrimaryPasteEnabled } from "./DesktopPrimaryPaste.ts"; const encoder = new TextEncoder(); @@ -69,3 +71,19 @@ describe("readGtkPrimaryPasteEnabled", () => { }), ); }); + +describe("DesktopPrimaryPaste", () => { + it.effect("reports the gsettings value as soon as the service exists", () => + Effect.gen(function* () { + const primaryPaste = yield* DesktopPrimaryPaste.DesktopPrimaryPaste; + assert.isFalse(primaryPaste.enabled); + }).pipe( + Effect.provide(DesktopPrimaryPaste.layer), + Effect.provideService(HostProcess.Platform, "linux"), + Effect.provideService( + ChildProcessSpawner.ChildProcessSpawner, + spawnerWithStdout(Stream.make(encoder.encode("false\n"))), + ), + ), + ); +}); diff --git a/apps/desktop/src/app/DesktopPrimaryPaste.ts b/apps/desktop/src/app/DesktopPrimaryPaste.ts index 42bef91a329b0..e7c230a49cf70 100644 --- a/apps/desktop/src/app/DesktopPrimaryPaste.ts +++ b/apps/desktop/src/app/DesktopPrimaryPaste.ts @@ -2,14 +2,13 @@ import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; -import * as Ref from "effect/Ref"; import * as ChildProcess from "effect/process/ChildProcess"; import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; export class DesktopPrimaryPaste extends Context.Service< DesktopPrimaryPaste, { - readonly enabled: Effect.Effect; + readonly enabled: boolean; } >()("@t3tools/desktop/app/DesktopPrimaryPaste") {} @@ -32,15 +31,10 @@ export const readGtkPrimaryPasteEnabled = Effect.gen(function* () { /** @public */ export const make = Effect.gen(function* () { const platform = yield* HostProcess.Platform; - const enabled = yield* Ref.make(true); - if (platform !== "darwin" && platform !== "win32") { - // Read in the background: sync IPC cannot wait, and the renderer loads long after this. - yield* readGtkPrimaryPasteEnabled.pipe( - Effect.flatMap((value) => Ref.set(enabled, value)), - Effect.forkScoped, - ); - } - return DesktopPrimaryPaste.of({ enabled: Ref.get(enabled) }); + // Resolved before any window exists so the first preload never sees a provisional value. + const enabled = + platform === "darwin" || platform === "win32" ? true : yield* readGtkPrimaryPasteEnabled; + return DesktopPrimaryPaste.of({ enabled }); }); export const layer = Layer.effect(DesktopPrimaryPaste, make); diff --git a/apps/desktop/src/ipc/methods/window.ts b/apps/desktop/src/ipc/methods/window.ts index 1638838b5eb3d..a17889c2691c9 100644 --- a/apps/desktop/src/ipc/methods/window.ts +++ b/apps/desktop/src/ipc/methods/window.ts @@ -95,7 +95,7 @@ export const getPrimaryPasteEnabled = DesktopIpc.makeSyncIpcMethod({ result: Schema.Boolean, handler: Effect.fn("desktop.ipc.window.getPrimaryPasteEnabled")(function* () { const primaryPaste = yield* DesktopPrimaryPaste.DesktopPrimaryPaste; - return yield* primaryPaste.enabled; + return primaryPaste.enabled; }), }); diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index e7a7c1eff7506..6e50237d05bb3 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -57,11 +57,13 @@ if (clientPlatform === "darwin") { window.addEventListener("resize", syncWindowControlInset); } -if ( - clientPlatform !== "darwin" && - clientPlatform !== "win32" && - ipcRenderer.sendSync(IpcChannels.GET_PRIMARY_PASTE_ENABLED_CHANNEL) === false -) { +let primaryPasteEnabled = true; +try { + primaryPasteEnabled = + ipcRenderer.sendSync(IpcChannels.GET_PRIMARY_PASTE_ENABLED_CHANNEL) !== false; +} catch {} + +if (!primaryPasteEnabled) { // Blink pastes the PRIMARY selection on a middle-click mouseup unless it is default-prevented. window.addEventListener( "mouseup", @@ -113,6 +115,7 @@ contextBridge.exposeInMainWorld("desktopBridge", { return () => ipcRenderer.removeListener(IpcChannels.TRACKPAD_SCROLL_END_CHANNEL, handler); }, revealWindow: () => ipcRenderer.invoke(IpcChannels.REVEAL_WINDOW_CHANNEL), + getPrimaryPasteEnabled: () => primaryPasteEnabled, getSystemLocale: () => { const result = ipcRenderer.sendSync(IpcChannels.GET_SYSTEM_LOCALE_CHANNEL); return typeof result === "string" ? result : null; diff --git a/apps/desktop/src/preview-pick-preload.ts b/apps/desktop/src/preview-pick-preload.ts index 7c4f23e37f508..3511f511321dd 100644 --- a/apps/desktop/src/preview-pick-preload.ts +++ b/apps/desktop/src/preview-pick-preload.ts @@ -1,6 +1,9 @@ import { ipcRenderer } from "electron"; -import { PASSKEY_BRIDGE_ARGUMENT } from "./preview/GuestProtocol.ts"; +import { + PASSKEY_BRIDGE_ARGUMENT, + PRIMARY_PASTE_DISABLED_ARGUMENT, +} from "./preview/GuestProtocol.ts"; import { installPasskeyBridge } from "./preview/PasskeyBridge.ts"; import "./preview/PickPreload.ts"; @@ -8,3 +11,14 @@ import "./preview/PickPreload.ts"; if (process.argv.includes(PASSKEY_BRIDGE_ARGUMENT)) { installPasskeyBridge((channel, publicKey) => ipcRenderer.invoke(channel, publicKey)); } + +if (process.argv.includes(PRIMARY_PASTE_DISABLED_ARGUMENT)) { + // Same guard as the app preload: Blink pastes PRIMARY on an uncancelled middle mouseup. + window.addEventListener( + "mouseup", + (event) => { + if (event.button === 1) event.preventDefault(); + }, + true, + ); +} diff --git a/apps/desktop/src/preview/GuestProtocol.ts b/apps/desktop/src/preview/GuestProtocol.ts index 29011a3db7e01..8b3f9daedc123 100644 --- a/apps/desktop/src/preview/GuestProtocol.ts +++ b/apps/desktop/src/preview/GuestProtocol.ts @@ -14,3 +14,4 @@ export const RECORDING_CONTROLLER_CHANNEL = "preview:recording-controller"; export const PASSKEY_CREATE_CHANNEL = "preview:passkey-create"; export const PASSKEY_GET_CHANNEL = "preview:passkey-get"; export const PASSKEY_BRIDGE_ARGUMENT = "--t3code-preview-passkey-bridge"; +export const PRIMARY_PASTE_DISABLED_ARGUMENT = "--t3code-preview-primary-paste-disabled"; diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts index 8b6ab7c7d8a5e..3cc0245aaa414 100644 --- a/apps/desktop/src/window/DesktopWindow.test.ts +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -36,6 +36,7 @@ vi.mock("electron", async (importOriginal) => ({ import * as DesktopAssets from "../app/DesktopAssets.ts"; import * as DesktopConfig from "../app/DesktopConfig.ts"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; +import * as DesktopPrimaryPaste from "../app/DesktopPrimaryPaste.ts"; import * as DesktopState from "../app/DesktopState.ts"; import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; import * as DesktopClientSettings from "../settings/DesktopClientSettings.ts"; @@ -323,6 +324,7 @@ function layerTest(input: { } satisfies ElectronShell.ElectronShell["Service"]), layerElectronTheme, layerElectronWindow, + Layer.succeed(DesktopPrimaryPaste.DesktopPrimaryPaste, { enabled: true }), Layer.mock(PreviewPasskeys.PreviewPasskeys)({ bridgeEnabled: false, installSessionHandlers: () => {}, @@ -433,6 +435,7 @@ const makeSplashScenario = (createOutcomes: readonly (Electron.BrowserWindow | n } satisfies ElectronShell.ElectronShell["Service"]), layerElectronTheme, Layer.succeed(ElectronWindow.ElectronWindow, electronWindowShape), + Layer.succeed(DesktopPrimaryPaste.DesktopPrimaryPaste, { enabled: true }), Layer.mock(PreviewPasskeys.PreviewPasskeys)({ bridgeEnabled: false, installSessionHandlers: () => {}, diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index ab5fc8be26a92..3bdadaf01832e 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -12,6 +12,7 @@ import { type DesktopSnapShotEvent, DEFAULT_CLIENT_SETTINGS } from "@t3tools/con import * as DesktopAssets from "../app/DesktopAssets.ts"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; +import * as DesktopPrimaryPaste from "../app/DesktopPrimaryPaste.ts"; import { makeComponentLogger } from "../app/DesktopObservability.ts"; import * as DesktopState from "../app/DesktopState.ts"; import * as ElectronMenu from "../electron/ElectronMenu.ts"; @@ -26,7 +27,10 @@ import { TRACKPAD_SCROLL_END_CHANNEL, WINDOW_FULLSCREEN_STATE_CHANNEL, } from "../ipc/channels.ts"; -import { PASSKEY_BRIDGE_ARGUMENT } from "../preview/GuestProtocol.ts"; +import { + PASSKEY_BRIDGE_ARGUMENT, + PRIMARY_PASTE_DISABLED_ARGUMENT, +} from "../preview/GuestProtocol.ts"; import * as PreviewManager from "../preview/Manager.ts"; import * as PreviewPasskeys from "../preview/Passkeys.ts"; import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; @@ -293,6 +297,7 @@ export const make = Effect.gen(function* () { const electronWindow = yield* ElectronWindow.ElectronWindow; const previewManager = yield* PreviewManager.PreviewManager; const previewPasskeys = yield* PreviewPasskeys.PreviewPasskeys; + const primaryPaste = yield* DesktopPrimaryPaste.DesktopPrimaryPaste; const desktopSettings = yield* DesktopAppSettings.DesktopAppSettings; const clientSettings = yield* DesktopClientSettings.DesktopClientSettings; const electronApp = yield* ElectronApp.ElectronApp; @@ -514,6 +519,12 @@ export const make = Effect.gen(function* () { PASSKEY_BRIDGE_ARGUMENT, ]; } + if (!primaryPaste.enabled) { + webPreferences.additionalArguments = [ + ...(webPreferences.additionalArguments ?? []), + PRIMARY_PASTE_DISABLED_ARGUMENT, + ]; + } }); const contextMenuContents = new WeakSet(); diff --git a/apps/web/src/terminal/ghostty/surface.test.ts b/apps/web/src/terminal/ghostty/surface.test.ts index 8816105599b83..6ef2d0c99face 100644 --- a/apps/web/src/terminal/ghostty/surface.test.ts +++ b/apps/web/src/terminal/ghostty/surface.test.ts @@ -279,6 +279,24 @@ describe("GhosttyTerminalSurface visibility", () => { expect(surface.hasSelection()).toBe(false); }); + it("skips middle-click selection paste when the desktop turned primary paste off", async () => { + vi.stubGlobal("navigator", { platform: "Linux x86_64" }); + const harness = createHarness(); + const surface = await harness.create(); + surface.write("echo hi"); + surface.selectAll(); + const pasteFromClipboard = vi.spyOn(surface, "pasteFromClipboard"); + + let primaryPasteEnabled = false; + Object.assign(window, { desktopBridge: { getPrimaryPasteEnabled: () => primaryPasteEnabled } }); + harness.pointer("pointerdown", 5, 4, {}, 1); + expect(pasteFromClipboard).not.toHaveBeenCalled(); + + primaryPasteEnabled = true; + harness.pointer("pointerdown", 5, 4, {}, 1); + expect(pasteFromClipboard).toHaveBeenCalledOnce(); + }); + it("leaves modified history keys with alternate-screen applications", async () => { vi.stubGlobal("navigator", { platform: "Linux x86_64" }); const harness = createHarness(); diff --git a/apps/web/src/terminal/ghostty/surface.ts b/apps/web/src/terminal/ghostty/surface.ts index 321bc0b48cc17..468ad1924baec 100644 --- a/apps/web/src/terminal/ghostty/surface.ts +++ b/apps/web/src/terminal/ghostty/surface.ts @@ -1323,7 +1323,9 @@ export class GhosttyTerminalSurface { } if (event.button === 1 && isMiddleClickPastePlatform()) { // Keep pointerdown uncancelled so compatibility mousedown activates the split pane. - this.pasteTerminalSelection(); + if (window.desktopBridge?.getPrimaryPasteEnabled?.() !== false) { + this.pasteTerminalSelection(); + } return; } if (event.button !== 0) return; diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index b353eca721199..00b1e6022a8bf 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -1022,6 +1022,8 @@ export interface DesktopBridge { * regardless of OS settings. */ getSystemLocale?: () => string | null; + /** False when the OS turned off middle-click PRIMARY paste (`gtk-enable-primary-paste`). */ + getPrimaryPasteEnabled?: () => boolean; getLocalEnvironmentBootstraps: () => readonly DesktopEnvironmentBootstrap[]; getLocalEnvironmentEnabled?: () => boolean; setLocalEnvironmentEnabled?: (enabled: boolean) => Promise; From 0079f39e7a3bb809c04cb80fec58eb8c6ecfb369 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 27 Oct 2026 22:49:53 +0000 Subject: [PATCH 3/4] test(desktop): preview guests receive the primary-paste flag Co-Authored-By: Claude Opus 5.5 --- apps/desktop/src/window/DesktopWindow.test.ts | 49 ++++++++++++++++++- 1 file changed, 47 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts index 3cc0245aaa414..724ccc8a02f02 100644 --- a/apps/desktop/src/window/DesktopWindow.test.ts +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -49,6 +49,10 @@ import * as ElectronWindow from "../electron/ElectronWindow.ts"; import { SNAP_SHOT_EVENT_CHANNEL } from "../ipc/channels.ts"; import * as DesktopServerExposure from "../backend/DesktopServerExposure.ts"; import * as DesktopWindow from "./DesktopWindow.ts"; +import { + PASSKEY_BRIDGE_ARGUMENT, + PRIMARY_PASTE_DISABLED_ARGUMENT, +} from "../preview/GuestProtocol.ts"; import * as PreviewManager from "../preview/Manager.ts"; import * as PreviewPasskeys from "../preview/Passkeys.ts"; @@ -230,6 +234,8 @@ function layerTest(input: { readonly previewZoomReapplies?: number[]; readonly onReveal?: (window: Electron.BrowserWindow) => void; readonly focusedWindow?: Electron.BrowserWindow; + readonly primaryPasteEnabled?: boolean; + readonly previewPasskeyBridgeEnabled?: boolean; }) { let desktopSettings = input.desktopSettings ?? DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS; const layerDesktopAppSettings = Layer.succeed(DesktopAppSettings.DesktopAppSettings, { @@ -324,9 +330,11 @@ function layerTest(input: { } satisfies ElectronShell.ElectronShell["Service"]), layerElectronTheme, layerElectronWindow, - Layer.succeed(DesktopPrimaryPaste.DesktopPrimaryPaste, { enabled: true }), + Layer.succeed(DesktopPrimaryPaste.DesktopPrimaryPaste, { + enabled: input.primaryPasteEnabled ?? true, + }), Layer.mock(PreviewPasskeys.PreviewPasskeys)({ - bridgeEnabled: false, + bridgeEnabled: input.previewPasskeyBridgeEnabled ?? false, installSessionHandlers: () => {}, attachGuest: () => () => {}, }), @@ -955,6 +963,43 @@ describe("DesktopWindow", () => { }), ); + it.effect.each([ + { + primaryPasteEnabled: false, + expected: [PASSKEY_BRIDGE_ARGUMENT, PRIMARY_PASTE_DISABLED_ARGUMENT], + }, + { primaryPasteEnabled: true, expected: [PASSKEY_BRIDGE_ARGUMENT] }, + ])( + "passes the primary-paste setting to preview guests (enabled: $primaryPasteEnabled)", + ({ primaryPasteEnabled, expected }) => + Effect.gen(function* () { + const fakeWindow = makeFakeBrowserWindow(); + const layer = layerTest({ + window: fakeWindow.window, + createCount: yield* Ref.make(0), + mainWindow: yield* Ref.make>(Option.none()), + primaryPasteEnabled, + previewPasskeyBridgeEnabled: true, + }); + + yield* Effect.gen(function* () { + const desktopWindow = yield* DesktopWindow.DesktopWindow; + yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); + + const willAttachWebview = fakeWindow.webContentsListeners.get("will-attach-webview"); + if (!willAttachWebview) { + return yield* Effect.die("will-attach-webview listener was not registered"); + } + const webPreferences: Electron.WebPreferences = {}; + willAttachWebview({ preventDefault: () => {} }, webPreferences, { + partition: "persist:t3code-preview-test", + }); + + assert.deepEqual(webPreferences.additionalArguments, expected); + }).pipe(Effect.provide(layer)); + }), + ); + it.effect("opens safe off-origin renderer navigations in the system browser", () => Effect.gen(function* () { const fakeWindow = makeFakeBrowserWindow(); From 775907bc16f10285861f0210e09c8ce4505b16b8 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 00:46:05 +0000 Subject: [PATCH 4/4] chore(desktop): provide primary-paste service in app test, trim wiring-only test AI-assisted; verified with the tests and tools listed above. --- apps/desktop/src/app/DesktopApp.test.ts | 2 ++ .../src/app/DesktopPrimaryPaste.test.ts | 18 ------------------ apps/desktop/src/app/DesktopPrimaryPaste.ts | 3 +-- 3 files changed, 3 insertions(+), 20 deletions(-) diff --git a/apps/desktop/src/app/DesktopApp.test.ts b/apps/desktop/src/app/DesktopApp.test.ts index 6cb00baf897d1..46203472097b2 100644 --- a/apps/desktop/src/app/DesktopApp.test.ts +++ b/apps/desktop/src/app/DesktopApp.test.ts @@ -58,6 +58,7 @@ import * as DesktopConnectionCatalogStore from "./DesktopConnectionCatalogStore. import * as DesktopIpc from "../ipc/DesktopIpc.ts"; import * as DesktopLocalEnvironmentAuth from "../backend/DesktopLocalEnvironmentAuth.ts"; import * as DesktopManagedPolicy from "./DesktopManagedPolicy.ts"; +import * as DesktopPrimaryPaste from "./DesktopPrimaryPaste.ts"; import * as DesktopSshEnvironment from "../ssh/DesktopSshEnvironment.ts"; import * as DesktopSshPasswordPrompts from "../ssh/DesktopSshPasswordPrompts.ts"; import * as DesktopTelemetryPublisher from "../telemetry/DesktopTelemetryPublisher.ts"; @@ -220,6 +221,7 @@ it.effect.each([false, true])( Layer.mock(DesktopIpc.DesktopIpc, {}), Layer.mock(DesktopLocalEnvironmentAuth.DesktopLocalEnvironmentAuth, {}), Layer.mock(DesktopManagedPolicy.DesktopManagedPolicy, {}), + Layer.succeed(DesktopPrimaryPaste.DesktopPrimaryPaste, { enabled: true }), Layer.mock(DesktopSshEnvironment.DesktopSshEnvironment, {}), Layer.mock(DesktopSshPasswordPrompts.DesktopSshPasswordPrompts, {}), Layer.mock(DesktopTelemetryPublisher.DesktopTelemetryPublisher, {}), diff --git a/apps/desktop/src/app/DesktopPrimaryPaste.test.ts b/apps/desktop/src/app/DesktopPrimaryPaste.test.ts index 09b0917f0c387..3bf5c2567da53 100644 --- a/apps/desktop/src/app/DesktopPrimaryPaste.test.ts +++ b/apps/desktop/src/app/DesktopPrimaryPaste.test.ts @@ -1,5 +1,4 @@ import { assert, describe, it } from "@effect/vitest"; -import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; import * as PlatformError from "effect/PlatformError"; @@ -8,7 +7,6 @@ import * as Stream from "effect/Stream"; import * as TestClock from "effect/testing/TestClock"; import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; -import * as DesktopPrimaryPaste from "./DesktopPrimaryPaste.ts"; import { readGtkPrimaryPasteEnabled } from "./DesktopPrimaryPaste.ts"; const encoder = new TextEncoder(); @@ -71,19 +69,3 @@ describe("readGtkPrimaryPasteEnabled", () => { }), ); }); - -describe("DesktopPrimaryPaste", () => { - it.effect("reports the gsettings value as soon as the service exists", () => - Effect.gen(function* () { - const primaryPaste = yield* DesktopPrimaryPaste.DesktopPrimaryPaste; - assert.isFalse(primaryPaste.enabled); - }).pipe( - Effect.provide(DesktopPrimaryPaste.layer), - Effect.provideService(HostProcess.Platform, "linux"), - Effect.provideService( - ChildProcessSpawner.ChildProcessSpawner, - spawnerWithStdout(Stream.make(encoder.encode("false\n"))), - ), - ), - ); -}); diff --git a/apps/desktop/src/app/DesktopPrimaryPaste.ts b/apps/desktop/src/app/DesktopPrimaryPaste.ts index e7c230a49cf70..0ae8f0d61fcee 100644 --- a/apps/desktop/src/app/DesktopPrimaryPaste.ts +++ b/apps/desktop/src/app/DesktopPrimaryPaste.ts @@ -28,8 +28,7 @@ export const readGtkPrimaryPasteEnabled = Effect.gen(function* () { Effect.orElseSucceed(() => true), ); -/** @public */ -export const make = Effect.gen(function* () { +const make = Effect.gen(function* () { const platform = yield* HostProcess.Platform; // Resolved before any window exists so the first preload never sees a provisional value. const enabled =