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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/desktop/src/app/DesktopApp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,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";
Expand Down Expand Up @@ -222,6 +223,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, {}),
Expand Down
71 changes: 71 additions & 0 deletions apps/desktop/src/app/DesktopPrimaryPaste.test.ts
Original file line number Diff line number Diff line change
@@ -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<Uint8Array>) =>
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));
}),
);
});
39 changes: 39 additions & 0 deletions apps/desktop/src/app/DesktopPrimaryPaste.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
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 ChildProcess from "effect/process/ChildProcess";
import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";

export class DesktopPrimaryPaste extends Context.Service<
DesktopPrimaryPaste,
{
readonly enabled: boolean;
}
>()("@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),
);

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 =
platform === "darwin" || platform === "win32" ? true : yield* readGtkPrimaryPasteEnabled;
return DesktopPrimaryPaste.of({ enabled });
});

export const layer = Layer.effect(DesktopPrimaryPaste, make);
2 changes: 2 additions & 0 deletions apps/desktop/src/ipc/DesktopIpcHandlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import {
installRelayTelemetryEnabled,
getSystemLocale,
getPreferredSystemLanguages,
getPrimaryPasteEnabled,
getWindowFullscreenState,
openExternal,
readMedia,
Expand Down Expand Up @@ -99,6 +100,7 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers"
yield* installRelayTelemetryEnabled();
yield* ipc.handleSync(getSystemLocale);
yield* ipc.handleSync(getPreferredSystemLanguages);
yield* ipc.handleSync(getPrimaryPasteEnabled);
yield* ipc.handleSync(getWindowFullscreenState);
yield* ipc.handleSync(getLocalEnvironmentBootstraps);
yield* ipc.handleSync(getLocalEnvironmentEnabled);
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/ipc/channels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ 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_PREFERRED_SYSTEM_LANGUAGES_CHANNEL = "desktop:get-preferred-system-languages";
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";
Expand Down
10 changes: 10 additions & 0 deletions apps/desktop/src/ipc/methods/window.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import * as DesktopBackendPool from "../../backend/DesktopBackendPool.ts";
import * as DesktopLocalEnvironmentAuth from "../../backend/DesktopLocalEnvironmentAuth.ts";
import * as DesktopEnvironment from "../../app/DesktopEnvironment.ts";
import * as DesktopManagedPolicy from "../../app/DesktopManagedPolicy.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";
Expand Down Expand Up @@ -106,6 +107,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 primaryPaste.enabled;
}),
});

export const getPreferredSystemLanguages = DesktopIpc.makeSyncIpcMethod({
channel: IpcChannels.GET_PREFERRED_SYSTEM_LANGUAGES_CHANNEL,
result: Schema.Array(Schema.String),
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,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";
Expand Down Expand Up @@ -152,6 +153,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));
Expand Down
18 changes: 18 additions & 0 deletions apps/desktop/src/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,23 @@ const syncHtmlFullscreen = () => {
syncHtmlFullscreen();
document.addEventListener("fullscreenchange", syncHtmlFullscreen);

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",
(event) => {
if (event.button === 1) event.preventDefault();
},
true,
);
}

function unwrapEnsureSshEnvironmentResult(result: unknown) {
if (
typeof result === "object" &&
Expand Down Expand Up @@ -116,6 +133,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;
Expand Down
16 changes: 15 additions & 1 deletion apps/desktop/src/preview-pick-preload.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,24 @@
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";

// Electron hands webPreferences.additionalArguments to sandboxed preloads in process.argv.
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,
);
}
1 change: 1 addition & 0 deletions apps/desktop/src/preview/GuestProtocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,4 @@ export const PASSKEY_GET_CHANNEL = "preview:passkey-get";
export const PASSKEY_BRIDGE_ARGUMENT = "--t3code-preview-passkey-bridge";
export const ANNOTATION_VOICE_EVENT_CHANNEL = "preview:annotation-voice-event";
export const ANNOTATION_VOICE_STATE_CHANNEL = "preview:annotation-voice-state";
export const PRIMARY_PASTE_DISABLED_ARGUMENT = "--t3code-preview-primary-paste-disabled";
50 changes: 49 additions & 1 deletion apps/desktop/src/window/DesktopWindow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -48,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";

Expand Down Expand Up @@ -238,6 +243,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, {
Expand Down Expand Up @@ -329,8 +336,11 @@ function layerTest(input: {
} satisfies ElectronShell.ElectronShell["Service"]),
layerElectronTheme,
layerElectronWindow,
Layer.succeed(DesktopPrimaryPaste.DesktopPrimaryPaste, {
enabled: input.primaryPasteEnabled ?? true,
}),
Layer.mock(PreviewPasskeys.PreviewPasskeys)({
bridgeEnabled: false,
bridgeEnabled: input.previewPasskeyBridgeEnabled ?? false,
installSessionHandlers: () => {},
attachGuest: () => () => {},
}),
Expand Down Expand Up @@ -439,6 +449,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: () => {},
Expand Down Expand Up @@ -1183,6 +1194,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.Option<Electron.BrowserWindow>>(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();
Expand Down
13 changes: 12 additions & 1 deletion apps/desktop/src/window/DesktopWindow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,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";
Expand All @@ -29,7 +30,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";
Expand Down Expand Up @@ -331,6 +335,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;
Expand Down Expand Up @@ -561,6 +566,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<Electron.WebContents>();
Expand Down
Loading
Loading