authorgravatar for carlosricojr@gmail.comCarlos Rico-Ospina <carlosricojr@gmail.com> 2026-07-20 04:32:44-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-07-20 08:32:44+00:00
log78485e6d50d06180618aacd0d25151e831f666a4
treef6118ae600cc8fca1e3975864c58f599ec09788b
parent63b6b44627d605afba7ec2549f817c23c0f903ad
signature Signed by PGP key B5690EEEBB952194

fix(desktop): allow clipboard writes in the preview browser (#3889)


2 files changed, 69 insertions(+), 2 deletions(-)

apps/desktop/src/preview/BrowserSession.test.ts+51
...@@ -15,6 +15,7 @@ const { fromPartition, sessions } = vi.hoisted(() => ({...@@ -15,6 +15,7 @@ const { fromPartition, sessions } = vi.hoisted(() => ({
15 readonly clearStorageData: ReturnType<typeof vi.fn>;15 readonly clearStorageData: ReturnType<typeof vi.fn>;
16 readonly getUserAgent: ReturnType<typeof vi.fn>;16 readonly getUserAgent: ReturnType<typeof vi.fn>;
17 readonly setPermissionRequestHandler: ReturnType<typeof vi.fn>;17 readonly setPermissionRequestHandler: ReturnType<typeof vi.fn>;
18 readonly setPermissionCheckHandler: ReturnType<typeof vi.fn>;
18 readonly setUserAgent: ReturnType<typeof vi.fn>;19 readonly setUserAgent: ReturnType<typeof vi.fn>;
19 }20 }
20 >(),21 >(),
...@@ -40,6 +41,7 @@ describe("BrowserSession", () => {...@@ -40,6 +41,7 @@ describe("BrowserSession", () => {
40 clearStorageData: vi.fn(() => Promise.resolve()),41 clearStorageData: vi.fn(() => Promise.resolve()),
41 getUserAgent: vi.fn(() => "Mozilla/5.0 Electron/41.5.0 t3code/0.0.27"),42 getUserAgent: vi.fn(() => "Mozilla/5.0 Electron/41.5.0 t3code/0.0.27"),
42 setPermissionRequestHandler: vi.fn(),43 setPermissionRequestHandler: vi.fn(),
44 setPermissionCheckHandler: vi.fn(),
43 setUserAgent: vi.fn(),45 setUserAgent: vi.fn(),
44 };46 };
45 sessions.set(partition, browserSession);47 sessions.set(partition, browserSession);
...@@ -61,6 +63,55 @@ describe("BrowserSession", () => {...@@ -61,6 +63,55 @@ describe("BrowserSession", () => {
61 }).pipe(Effect.provide(layer)),63 }).pipe(Effect.provide(layer)),
62 );64 );
6365
66 it.effect("grants clipboard-sanitized-write through both the request and check handlers", () =>
67 Effect.gen(function* () {
68 const browserSessions = yield* BrowserSession.BrowserSession;
69 const partition = yield* browserSessions.getPartition("scope-a");
70 yield* browserSessions.getSession("scope-a");
71
72 const browserSession = sessions.get(partition);
73 assert.isDefined(browserSession);
74
75 const requestHandler = browserSession.setPermissionRequestHandler.mock.calls[0]?.[0];
76 const checkHandler = browserSession.setPermissionCheckHandler.mock.calls[0]?.[0];
77 assert.isFunction(requestHandler);
78 assert.isFunction(checkHandler);
79
80 const requestAllows = (permission: string): boolean => {
81 let granted: boolean | undefined;
82 requestHandler(null, permission, (value: boolean) => {
83 granted = value;
84 });
85 assert.isDefined(granted);
86 return granted;
87 };
88
89 for (const permission of [
90 "clipboard-read",
91 "clipboard-sanitized-write",
92 "notifications",
93 "geolocation",
94 ]) {
95 assert.isTrue(requestAllows(permission), `request handler should allow ${permission}`);
96 assert.isTrue(
97 checkHandler(null, permission) as boolean,
98 `check handler should allow ${permission}`,
99 );
100 }
101
102 // `clipboard-write` is not a real Electron permission — the async write API
103 // uses `clipboard-sanitized-write` — so the stale name must not be granted,
104 // and unrelated permissions stay denied.
105 for (const permission of ["clipboard-write", "midi"]) {
106 assert.isFalse(requestAllows(permission), `request handler should deny ${permission}`);
107 assert.isFalse(
108 checkHandler(null, permission) as boolean,
109 `check handler should deny ${permission}`,
110 );
111 }
112 }).pipe(Effect.provide(layer)),
113 );
114
64 it.effect("preserves partition scope and the platform failure chain", () => {115 it.effect("preserves partition scope and the platform failure chain", () => {
65 const nativeCause = new Error("native digest failed");116 const nativeCause = new Error("native digest failed");
66 const platformCause = PlatformError.systemError({117 const platformCause = PlatformError.systemError({
apps/desktop/src/preview/BrowserSession.ts+18-2
...@@ -11,6 +11,20 @@ import * as SynchronizedRef from "effect/SynchronizedRef";...@@ -11,6 +11,20 @@ import * as SynchronizedRef from "effect/SynchronizedRef";
1111
12const PREVIEW_PARTITION_PREFIX = "persist:t3code-preview-";12const PREVIEW_PARTITION_PREFIX = "persist:t3code-preview-";
1313
14// Permissions granted to preview web content. `clipboard-sanitized-write` is the
15// Electron permission behind `navigator.clipboard.writeText()` — note it is NOT
16// `clipboard-write`, which is not a valid Electron permission name. Async
17// clipboard writes are gated by the permission *check* handler (not only the
18// request handler), so both handlers must allow it; otherwise built-in "Copy"
19// buttons — e.g. the Next.js / Vercel error overlay — fail with
20// `Failed to execute 'writeText' on 'Clipboard': Write permission denied`.
21const ALLOWED_PREVIEW_PERMISSIONS: ReadonlySet<string> = new Set([
22 "clipboard-read",
23 "clipboard-sanitized-write",
24 "notifications",
25 "geolocation",
26]);
27
14export class BrowserSessionPartitionDerivationError extends Schema.TaggedErrorClass<BrowserSessionPartitionDerivationError>()(28export class BrowserSessionPartitionDerivationError extends Schema.TaggedErrorClass<BrowserSessionPartitionDerivationError>()(
15 "BrowserSessionPartitionDerivationError",29 "BrowserSessionPartitionDerivationError",
16 {30 {
...@@ -120,9 +134,11 @@ export const make = Effect.gen(function* BrowserSessionMake() {...@@ -120,9 +134,11 @@ export const make = Effect.gen(function* BrowserSessionMake() {
120 .replace(/\s*t3code\/[\d.]+/, "");134 .replace(/\s*t3code\/[\d.]+/, "");
121 browserSession.setUserAgent(userAgent);135 browserSession.setUserAgent(userAgent);
122 browserSession.setPermissionRequestHandler((_webContents, permission, callback) => {136 browserSession.setPermissionRequestHandler((_webContents, permission, callback) => {
123 const allowed = ["clipboard-read", "clipboard-write", "notifications", "geolocation"];137 callback(ALLOWED_PREVIEW_PERMISSIONS.has(permission));
124 callback(allowed.includes(permission));
125 });138 });
139 browserSession.setPermissionCheckHandler((_webContents, permission) =>
140 ALLOWED_PREVIEW_PERMISSIONS.has(permission),
141 );
126 const next = new Map(sessions);142 const next = new Map(sessions);
127 next.set(partition, browserSession);143 next.set(partition, browserSession);
128 return [browserSession, next] as const;144 return [browserSession, next] as const;