Skip to content
Open
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
51 changes: 51 additions & 0 deletions apps/desktop/src/preview/BrowserSession.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ const { fromPartition, sessions } = vi.hoisted(() => ({
readonly clearStorageData: ReturnType<typeof vi.fn>;
readonly getUserAgent: ReturnType<typeof vi.fn>;
readonly setPermissionRequestHandler: ReturnType<typeof vi.fn>;
readonly setPermissionCheckHandler: ReturnType<typeof vi.fn>;
readonly setUserAgent: ReturnType<typeof vi.fn>;
}
>(),
Expand All @@ -40,6 +41,7 @@ describe("BrowserSession", () => {
clearStorageData: vi.fn(() => Promise.resolve()),
getUserAgent: vi.fn(() => "Mozilla/5.0 Electron/41.5.0 t3code/0.0.27"),
setPermissionRequestHandler: vi.fn(),
setPermissionCheckHandler: vi.fn(),
setUserAgent: vi.fn(),
};
sessions.set(partition, browserSession);
Expand All @@ -61,6 +63,55 @@ describe("BrowserSession", () => {
}).pipe(Effect.provide(layer)),
);

it.effect("grants clipboard-sanitized-write through both the request and check handlers", () =>
Effect.gen(function* () {
const browserSessions = yield* BrowserSession.BrowserSession;
const partition = yield* browserSessions.getPartition("scope-a");
yield* browserSessions.getSession("scope-a");

const browserSession = sessions.get(partition);
assert.isDefined(browserSession);

const requestHandler = browserSession.setPermissionRequestHandler.mock.calls[0]?.[0];
const checkHandler = browserSession.setPermissionCheckHandler.mock.calls[0]?.[0];
assert.isFunction(requestHandler);
assert.isFunction(checkHandler);

const requestAllows = (permission: string): boolean => {
let granted: boolean | undefined;
requestHandler(null, permission, (value: boolean) => {
granted = value;
});
assert.isDefined(granted);
return granted;
};

for (const permission of [
"clipboard-read",
"clipboard-sanitized-write",
"notifications",
"geolocation",
]) {
assert.isTrue(requestAllows(permission), `request handler should allow ${permission}`);
assert.isTrue(
checkHandler(null, permission) as boolean,
`check handler should allow ${permission}`,
);
}

// `clipboard-write` is not a real Electron permission — the async write API
// uses `clipboard-sanitized-write` — so the stale name must not be granted,
// and unrelated permissions stay denied.
for (const permission of ["clipboard-write", "midi"]) {
assert.isFalse(requestAllows(permission), `request handler should deny ${permission}`);
assert.isFalse(
checkHandler(null, permission) as boolean,
`check handler should deny ${permission}`,
);
}
}).pipe(Effect.provide(layer)),
);

it.effect("preserves partition scope and the platform failure chain", () => {
const nativeCause = new Error("native digest failed");
const platformCause = PlatformError.systemError({
Expand Down
20 changes: 18 additions & 2 deletions apps/desktop/src/preview/BrowserSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,20 @@ import * as SynchronizedRef from "effect/SynchronizedRef";

const PREVIEW_PARTITION_PREFIX = "persist:t3code-preview-";

// Permissions granted to preview web content. `clipboard-sanitized-write` is the
// Electron permission behind `navigator.clipboard.writeText()` — note it is NOT
// `clipboard-write`, which is not a valid Electron permission name. Async
// clipboard writes are gated by the permission *check* handler (not only the
// request handler), so both handlers must allow it; otherwise built-in "Copy"
// buttons — e.g. the Next.js / Vercel error overlay — fail with
// `Failed to execute 'writeText' on 'Clipboard': Write permission denied`.
const ALLOWED_PREVIEW_PERMISSIONS: ReadonlySet<string> = new Set([
"clipboard-read",
"clipboard-sanitized-write",
"notifications",
"geolocation",
]);

export class BrowserSessionPartitionDerivationError extends Schema.TaggedErrorClass<BrowserSessionPartitionDerivationError>()(
"BrowserSessionPartitionDerivationError",
{
Expand Down Expand Up @@ -120,9 +134,11 @@ export const make = Effect.gen(function* BrowserSessionMake() {
.replace(/\s*t3code\/[\d.]+/, "");
browserSession.setUserAgent(userAgent);
browserSession.setPermissionRequestHandler((_webContents, permission, callback) => {
const allowed = ["clipboard-read", "clipboard-write", "notifications", "geolocation"];
callback(allowed.includes(permission));
callback(ALLOWED_PREVIEW_PERMISSIONS.has(permission));
});
browserSession.setPermissionCheckHandler((_webContents, permission) =>
ALLOWED_PREVIEW_PERMISSIONS.has(permission),
);
const next = new Map(sessions);
next.set(partition, browserSession);
return [browserSession, next] as const;
Expand Down
Loading