diff --git a/.coderabbit.config.ts b/.coderabbit.config.ts index 5a9505c70276..2394e757b72d 100644 --- a/.coderabbit.config.ts +++ b/.coderabbit.config.ts @@ -1,21 +1,7 @@ import { defineConfig, type CodeRabbitContext } from "@coderabbitai/config"; -const approvability = `Fail when a maintainer should read this pull request before CodeRabbit approves it, and name the rule and file. Fail if it: - -- Changes a product default: a setting's default value, or what users get without opting in. Making a feature do what it already promises is a bug fix, not a default change. -- Adds or broadens a directive that disables or suppresses a lint, type-checker, LSP, or other static-analysis diagnostic, including file-level, line-level, and configuration-level overrides. -- Adds a subsystem or user workflow, or is a large refactor across apps or packages. -- Changes packages/contracts or persisted data in a way that existing clients or stored data might not accept. -- Changes authentication, pairing, credentials, secrets, or remote connection trust. -- Adds or changes an external side effect, such as acting on GitHub, publishing a release, or calling a webhook. -- Adds, upgrades, or patches a dependency. -- Changes CI or release configuration, agent or contributor instructions, or any review tool's configuration, including .github/, AGENTS.md, CONTRIBUTING.md, .agents/, and .coderabbit.config.ts. - -Otherwise pass. A focused bug fix, copy or layout fix, revert, or docs-only or test-only change passes unless a rule above applies. If you cannot decide, fail rather than report inconclusive. When failing, say that the pull request needs a maintainer's review. -`; - // Org members and collaborators merge their own pull requests. On anyone else's, CodeRabbit -// requests changes until its comments are resolved and its checks pass, then approves. +// requests changes until its comments are resolved, then approves. const UNGATED_AUTHORS = new Set(["OWNER", "MEMBER", "COLLABORATOR"]); const isGated = ({ pr }: CodeRabbitContext) => !UNGATED_AUTHORS.has(pr?.authorAssociation ?? ""); @@ -31,13 +17,6 @@ export default defineConfig((ctx) => ({ pre_merge_checks: { docstrings: { mode: "off" }, override_requested_reviewers_only: isGated(ctx), - custom_checks: [ - { - name: "Approvability", - mode: isGated(ctx) ? "error" : "off", - instructions: approvability, - }, - ], }, path_filters: [ // Vendored read-only reference checkouts of upstream Effect and Alchemy diff --git a/apps/desktop/package.json b/apps/desktop/package.json index ddef3ca9eb14..15bbbda6c6df 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -24,7 +24,7 @@ "@t3tools/tailscale": "workspace:*", "dbus-next": "0.10.2", "effect": "catalog:", - "electron": "44.4.2", + "electron": "44.4.5", "electron-store": "^8.2.0", "electron-updater": "^6.8.9", "ffi-rs": "1.3.2", diff --git a/apps/desktop/src/app/DesktopAppIdentity.test.ts b/apps/desktop/src/app/DesktopAppIdentity.test.ts index dae68f46887e..cc0277d3ed10 100644 --- a/apps/desktop/src/app/DesktopAppIdentity.test.ts +++ b/apps/desktop/src/app/DesktopAppIdentity.test.ts @@ -217,7 +217,7 @@ describe("DesktopAppIdentity", () => { const identity = yield* DesktopAppIdentity.DesktopAppIdentity; yield* identity.configure; - assert.deepEqual(calls.setName, ["T3 Code (Alpha)"]); + assert.deepEqual(calls.setName, ["T3 Code Alpha"]); assert.equal(calls.setAboutPanelOptions[0]?.applicationName, "T3 Code (Alpha)"); assert.equal(calls.setAboutPanelOptions[0]?.applicationVersion, "1.2.3"); assert.equal(calls.setAboutPanelOptions[0]?.version, "0123456789ab"); @@ -237,6 +237,39 @@ describe("DesktopAppIdentity", () => { ); }); + it.effect.each([ + { stage: "Alpha", environment: {} }, + { + stage: "Nightly", + environment: { appVersion: "0.0.43-nightly.20260929.2428" }, + }, + { + stage: "Dev", + environment: { env: { VITE_DEV_SERVER_URL: "http://localhost:5173" } }, + }, + ])("uses a valid native User-Agent product name for $stage", ({ stage, environment }) => { + const calls: ElectronAppCalls = { + setAboutPanelOptions: [], + setDockIcon: [], + setName: [], + }; + + return withIdentity( + Effect.gen(function* () { + const identity = yield* DesktopAppIdentity.DesktopAppIdentity; + yield* identity.configure; + + const runtimeName = calls.setName[0]; + assert.isDefined(runtimeName); + assert.equal(runtimeName, `T3 Code ${stage}`); + // RFC 9110's token grammar, after Electron removes ASCII spaces. + assert.match(runtimeName.replaceAll(" ", ""), /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/); + assert.equal(calls.setAboutPanelOptions[0]?.applicationName, `T3 Code (${stage})`); + }), + { calls, environment }, + ); + }); + it.effect("sets the dock icon only when running unpackaged", () => { const calls: ElectronAppCalls = { setAboutPanelOptions: [], diff --git a/apps/desktop/src/app/DesktopAppIdentity.ts b/apps/desktop/src/app/DesktopAppIdentity.ts index 69fce083d4fb..395c06141d64 100644 --- a/apps/desktop/src/app/DesktopAppIdentity.ts +++ b/apps/desktop/src/app/DesktopAppIdentity.ts @@ -91,7 +91,12 @@ export const make = Effect.gen(function* () { const configure = Effect.gen(function* () { const commitHash = yield* resolveAboutCommitHash; - yield* electronApp.setName(environment.displayName); + // Electron removes spaces from this name to build the native User-Agent + // product token, but leaves parentheses intact. Keep the runtime name valid + // without rewriting preview sessions (which breaks Turnstile, #7110). + yield* electronApp.setName( + `${environment.branding.baseName} ${environment.branding.stageLabel}`, + ); yield* electronApp.setAboutPanelOptions({ applicationName: environment.displayName, applicationVersion: environment.appVersion, diff --git a/apps/desktop/src/app/DesktopCliCommand.test.ts b/apps/desktop/src/app/DesktopCliCommand.test.ts index dc2e7fb962d1..b2305fe9840a 100644 --- a/apps/desktop/src/app/DesktopCliCommand.test.ts +++ b/apps/desktop/src/app/DesktopCliCommand.test.ts @@ -1,5 +1,5 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; -import { expect, it } from "@effect/vitest"; +import { afterEach, beforeEach, expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Option from "effect/Option"; @@ -85,6 +85,15 @@ const fakePowerShell = (initial: string) => { return { registry, spawner }; }; +// The machine's own PATH may already hold a t3; each test sets the PATH it means. +const machinePath = process.env.PATH; +beforeEach(() => { + process.env.PATH = ""; +}); +afterEach(() => { + process.env.PATH = machinePath; +}); + it.layer(NodeServices.layer)("DesktopCliCommand", (it) => { it.effect("links the launcher onto PATH and removes only that link", () => Effect.gen(function* () { @@ -165,7 +174,7 @@ it.layer(NodeServices.layer)("DesktopCliCommand", (it) => { }).pipe(Effect.scoped), ); - it.effect("reports when another t3 earlier on PATH would run instead", () => + it.effect("refuses to install behind another t3 that runs first", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -173,16 +182,23 @@ it.layer(NodeServices.layer)("DesktopCliCommand", (it) => { const shadow = path.join(home, "shadow"); yield* fs.makeDirectory(shadow); yield* fs.writeFileString(path.join(shadow, "t3"), "#!/bin/sh\n", { mode: 0o755 }); - const previous = process.env.PATH; process.env.PATH = [shadow, path.join(home, ".local", "bin")].join(":"); - yield* Effect.addFinalizer(() => Effect.sync(() => (process.env.PATH = previous))); const command = yield* commandIn({ home }); + const theirs = path.join(shadow, "t3"); + expect((yield* command.state).shadowedBy).toBe(theirs); + // A link behind it would never run, so nothing is created. + const error = yield* Effect.flip(command.install); + expect(error.message).toContain(theirs); + expect(yield* fs.exists(path.join(home, ".local", "bin", "t3"))).toBe(false); + + yield* fs.remove(theirs); const installed = yield* command.install; - expect(installed.installedPath).toBe(path.join(home, ".local", "bin", "t3")); - expect(installed.onPath).toBe(false); - yield* fs.remove(path.join(shadow, "t3")); - expect((yield* command.state).onPath).toBe(true); + expect(installed).toMatchObject({ + onPath: true, + installedPath: path.join(home, ".local", "bin", "t3"), + }); + expect(installed.shadowedBy).toBeUndefined(); }).pipe(Effect.scoped), ); diff --git a/apps/desktop/src/app/DesktopCliCommand.ts b/apps/desktop/src/app/DesktopCliCommand.ts index 534af3716db7..d79cab92f7cc 100644 --- a/apps/desktop/src/app/DesktopCliCommand.ts +++ b/apps/desktop/src/app/DesktopCliCommand.ts @@ -146,6 +146,14 @@ export const make = Effect.gen(function* () { return Option.none(); }); + /** The `t3` a new shell runs when it is not this app's, on Unix. */ + const foreignFirstOnPath = Effect.gen(function* () { + if (windows) return Option.none(); + const first = yield* firstOnPath; + if (Option.isNone(first) || (yield* isOurLink(first.value))) return Option.none(); + return first; + }); + /** Where this app's command is installed now, if anywhere. */ const installedAt = Effect.gen(function* () { if (windows) { @@ -167,12 +175,16 @@ export const make = Effect.gen(function* () { return { supported: false, installedPath: null, onPath: false } as const; } const installed = yield* installedAt; - if (Option.isNone(installed)) return { supported: true, installedPath: null, onPath: false }; + const shadowedBy = yield* foreignFirstOnPath; + const shadow = Option.isSome(shadowedBy) ? { shadowedBy: shadowedBy.value } : {}; + if (Option.isNone(installed)) { + return { supported: true, installedPath: null, onPath: false, ...shadow }; + } // On Windows only terminals opened after the change see it. On Unix the // first `t3` on PATH must be ours; a `t3` earlier on PATH would shadow it. const first = yield* firstOnPath; const onPath = windows || (Option.isSome(first) && (yield* isOurLink(first.value))); - return { supported: true, installedPath: installed.value, onPath }; + return { supported: true, installedPath: installed.value, onPath, ...shadow }; }).pipe(Effect.orElseSucceed(() => ({ supported: false, installedPath: null, onPath: false }))); /** Writes the launcher if the app has not yet, e.g. when no local backend runs. */ @@ -209,6 +221,13 @@ export const make = Effect.gen(function* () { .remove(existing.value) .pipe(Effect.mapError(() => fail(`Could not replace ${existing.value}.`))); } + // A link behind another `t3` never runs, so installing one would only hide the problem. + const shadowedBy = yield* foreignFirstOnPath; + if (Option.isSome(shadowedBy)) { + return yield* fail( + `Another t3 at ${shadowedBy.value} runs first in a new terminal. Remove it, or run the launcher directly at ${launcher}.`, + ); + } const onPath = pathEntries(process.env.PATH, ":"); const candidates = unixCandidates(environment.homeDirectory, environment.platform); // Prefer a folder already on PATH that the user can write to without admin rights. diff --git a/apps/desktop/src/app/DesktopLifecycle.test.ts b/apps/desktop/src/app/DesktopLifecycle.test.ts index 98e9cf364690..5caa18a53aab 100644 --- a/apps/desktop/src/app/DesktopLifecycle.test.ts +++ b/apps/desktop/src/app/DesktopLifecycle.test.ts @@ -96,6 +96,7 @@ function layerDesktopWindow( dispatchMenuAction: () => Effect.void, dispatchSnapShotEvent: () => Effect.void, zoomMain: () => Effect.void, + runMainContentsCommand: () => Effect.void, syncAppearance: Effect.void, }); } diff --git a/apps/desktop/src/backend/DesktopBackendPool.test.ts b/apps/desktop/src/backend/DesktopBackendPool.test.ts index 44ede122474d..d8d7813e7972 100644 --- a/apps/desktop/src/backend/DesktopBackendPool.test.ts +++ b/apps/desktop/src/backend/DesktopBackendPool.test.ts @@ -100,6 +100,7 @@ function layerPool(labelRef: Ref.Ref): Layer.Layer Effect.die("unexpected menu action"), dispatchSnapShotEvent: () => Effect.void, zoomMain: () => Effect.die("unexpected zoom"), + runMainContentsCommand: () => Effect.die("unexpected main contents command"), syncAppearance: Effect.void, } satisfies DesktopWindow.DesktopWindow["Service"]), ), diff --git a/apps/desktop/src/electron/ElectronDialog.ts b/apps/desktop/src/electron/ElectronDialog.ts index 18280333754c..b48953f117d3 100644 --- a/apps/desktop/src/electron/ElectronDialog.ts +++ b/apps/desktop/src/electron/ElectronDialog.ts @@ -95,8 +95,10 @@ export class ElectronDialog extends Context.Service< readonly pickFiles: ( input: ElectronDialogPickFilesInput, ) => Effect.Effect; + /** Pass `owner` to attach the box to that window so it cannot fall behind it. */ readonly showMessageBox: ( options: Electron.MessageBoxOptions, + owner?: Electron.BrowserWindow, ) => Effect.Effect; readonly showErrorBox: (title: string, content: string) => Effect.Effect; } @@ -164,9 +166,12 @@ export const make = ElectronDialog.of({ }); return result.canceled ? [] : result.filePaths; }), - showMessageBox: (options) => + showMessageBox: (options, owner) => Effect.tryPromise({ - try: () => Electron.dialog.showMessageBox(options), + try: () => + owner === undefined + ? Electron.dialog.showMessageBox(options) + : Electron.dialog.showMessageBox(owner, options), catch: (cause) => new ElectronDialogShowMessageBoxError({ type: options.type ?? null, diff --git a/apps/desktop/src/electron/ElectronShell.test.ts b/apps/desktop/src/electron/ElectronShell.test.ts index 8ba8082f0e57..580a8b95684d 100644 --- a/apps/desktop/src/electron/ElectronShell.test.ts +++ b/apps/desktop/src/electron/ElectronShell.test.ts @@ -106,6 +106,20 @@ describe("ElectronShell", () => { }).pipe(Effect.provide(ElectronShell.layer)), ); + it.effect("opens the JetBrains Toolbox ssh deep link", () => + Effect.gen(function* () { + openExternalMock.mockResolvedValue(undefined); + + const url = + "jetbrains://gateway/ssh/environment?h=example.com&launchIde=true&ideHint=IU&projectHint=%2Fhome%2Fuser%2Fproject"; + const electronShell = yield* ElectronShell.ElectronShell; + const result = yield* electronShell.openExternal(url); + + assert.equal(result, true); + assert.deepEqual(openExternalMock.mock.calls, [[url]]); + }).pipe(Effect.provide(ElectronShell.layer)), + ); + it.effect("does not open editor URLs that mix up link shapes", () => Effect.gen(function* () { openExternalMock.mockResolvedValue(undefined); @@ -114,9 +128,12 @@ describe("ElectronShell", () => { const results = yield* Effect.all([ electronShell.openExternal("zed://extension/attacker"), electronShell.openExternal("vscode://ssh/example.com/home/user/project"), + electronShell.openExternal("jetbrains://idea/navigate/reference?project=x&path=y"), + electronShell.openExternal("jetbrains://gateway/ssh/environment?launchIde=true"), + electronShell.openExternal("jetbrains://gateway/com.example.provider/connect?h=x"), ]); - assert.deepEqual(results, [false, false]); + assert.deepEqual(results, [false, false, false, false, false]); assert.equal(openExternalMock.mock.calls.length, 0); }).pipe(Effect.provide(ElectronShell.layer)), ); @@ -134,9 +151,10 @@ describe("ElectronShell", () => { "vscode://:secret@vscode-remote/ssh-remote+example.com/home/user/project", ), electronShell.openExternal("zed://ssh/user@example.com/home/user/project"), + electronShell.openExternal("jetbrains://user@gateway/ssh/environment?h=example.com"), ]); - assert.deepEqual(results, [false, false, false]); + assert.deepEqual(results, [false, false, false, false]); assert.equal(openExternalMock.mock.calls.length, 0); }).pipe(Effect.provide(ElectronShell.layer)), ); diff --git a/apps/desktop/src/electron/ElectronShell.ts b/apps/desktop/src/electron/ElectronShell.ts index 9be6d4ecf55b..5504125ec202 100644 --- a/apps/desktop/src/electron/ElectronShell.ts +++ b/apps/desktop/src/electron/ElectronShell.ts @@ -12,8 +12,8 @@ import * as Option from "effect/Option"; import * as Electron from "electron"; // Remote open-in-editor deep links (`vscode://vscode-remote/ssh-remote+…`, -// `zed://ssh//`) must reach the OS handler; every other non-web -// scheme stays blocked. +// `zed://ssh//`, `jetbrains://gateway/ssh/environment?h=…`) must +// reach the OS handler; every other non-web scheme stays blocked. const SAFE_WEB_PROTOCOLS = new Set(["http:", "https:"]); const REMOTE_EDITOR_PROTOCOLS = new Set( REMOTE_CAPABLE_EDITOR_IDS.flatMap((id) => { @@ -31,9 +31,13 @@ const isRemoteEditorUrl = (url: URL) => url.password.length === 0 && (url.protocol === "zed:" ? url.host === "ssh" && ZED_SSH_PATHNAME.test(url.pathname) - : url.host === "vscode-remote" && - url.pathname.startsWith("/ssh-remote+") && - url.pathname.length > "/ssh-remote+".length); + : url.protocol === "jetbrains:" + ? url.host === "gateway" && + url.pathname === "/ssh/environment" && + (url.searchParams.get("h") ?? "").length > 0 + : url.host === "vscode-remote" && + url.pathname.startsWith("/ssh-remote+") && + url.pathname.length > "/ssh-remote+".length); export function parseSafeExternalUrl(rawUrl: unknown): Option.Option { if (typeof rawUrl !== "string") { diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index 5b078314ac49..e6b936c100ba 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -109,6 +109,7 @@ export const PREVIEW_RECORDING_SAVE_CHANNEL = "desktop:preview-recording-save"; export const PREVIEW_RECORDING_FRAME_CHANNEL = "desktop:preview-recording-frame"; export const PREVIEW_STATE_CHANGE_CHANNEL = "desktop:preview-state-change"; export const PREVIEW_POINTER_EVENT_CHANNEL = "desktop:preview-pointer-event"; +export const PREVIEW_OPEN_LINK_CHANNEL = "desktop:preview-open-link"; export const MAC_PERMISSION_HELPER_CHANNEL = "desktop:mac-permission-helper"; diff --git a/apps/desktop/src/ipc/methods/preview.ts b/apps/desktop/src/ipc/methods/preview.ts index 4afc61d6f76f..94c56464f7c4 100644 --- a/apps/desktop/src/ipc/methods/preview.ts +++ b/apps/desktop/src/ipc/methods/preview.ts @@ -54,6 +54,9 @@ export const installPreviewEventForwarding = Effect.fn( yield* manager.subscribePointerEvents((event) => electronWindow.sendAll(IpcChannels.PREVIEW_POINTER_EVENT_CHANNEL, event), ); + yield* manager.subscribeOpenLinks((event) => + electronWindow.sendAll(IpcChannels.PREVIEW_OPEN_LINK_CHANNEL, event), + ); }); export const setForwardedShortcuts = DesktopIpc.makeIpcMethod({ diff --git a/apps/desktop/src/ipc/methods/window.test.ts b/apps/desktop/src/ipc/methods/window.test.ts index 41d93ecc4969..72f631d162d1 100644 --- a/apps/desktop/src/ipc/methods/window.test.ts +++ b/apps/desktop/src/ipc/methods/window.test.ts @@ -341,6 +341,6 @@ it.effect.skipIf(HostProcessPlatform.defaultValue() === "win32")( ); assert.include(editors, "cursor"); assert.include(editors, "vscode"); - assert.notInclude(editors, "webstorm"); + assert.include(editors, "webstorm"); }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 0dd9604f9865..12b68f186089 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -1,5 +1,6 @@ import type { DesktopBridge, + DesktopPreviewOpenLinkEvent, DesktopPreviewPointerEvent, DesktopPreviewRecordingInputEvent, DesktopPreviewRecordingFrame, @@ -416,5 +417,14 @@ contextBridge.exposeInMainWorld("desktopBridge", { return () => ipcRenderer.removeListener(IpcChannels.PREVIEW_POINTER_EVENT_CHANNEL, wrappedListener); }, + onOpenLink: (listener) => { + const wrappedListener = (_event: Electron.IpcRendererEvent, linkEvent: unknown) => { + if (typeof linkEvent !== "object" || linkEvent === null) return; + listener(linkEvent as DesktopPreviewOpenLinkEvent); + }; + ipcRenderer.on(IpcChannels.PREVIEW_OPEN_LINK_CHANNEL, wrappedListener); + return () => + ipcRenderer.removeListener(IpcChannels.PREVIEW_OPEN_LINK_CHANNEL, wrappedListener); + }, }, } satisfies DesktopBridge); diff --git a/apps/desktop/src/preview/BrowserSession.test.ts b/apps/desktop/src/preview/BrowserSession.test.ts index 805c684dfbec..9799d88fdc9c 100644 --- a/apps/desktop/src/preview/BrowserSession.test.ts +++ b/apps/desktop/src/preview/BrowserSession.test.ts @@ -21,15 +21,35 @@ const { fromPartition, sessions } = vi.hoisted(() => ({ >(), })); +const { fromWebContents } = vi.hoisted(() => ({ + fromWebContents: vi.fn<(webContents: unknown) => unknown>(() => null), +})); + vi.mock("electron", () => ({ + BrowserWindow: { fromWebContents }, session: { fromPartition, }, })); +import * as ElectronDialog from "../electron/ElectronDialog.ts"; import * as BrowserSession from "./BrowserSession.ts"; -const layer = BrowserSession.layer.pipe(Layer.provide(NodeServices.layer)); +const { showMessageBox } = vi.hoisted(() => ({ + showMessageBox: vi.fn< + (options: Electron.MessageBoxOptions, owner?: Electron.BrowserWindow) => number + >(() => 1), +})); +const layerDialog = Layer.succeed(ElectronDialog.ElectronDialog, { + pickFolder: () => Effect.die("unused"), + pickFiles: () => Effect.die("unused"), + showMessageBox: (options, owner) => + Effect.sync(() => ({ response: showMessageBox(options, owner), checkboxChecked: false })), + showErrorBox: () => Effect.void, +}); +const layer = BrowserSession.layer.pipe( + Layer.provide(Layer.merge(NodeServices.layer, layerDialog)), +); describe("BrowserSession", () => { beforeEach(() => { @@ -168,6 +188,7 @@ describe("BrowserSession", () => { "clipboard-sanitized-write", "notifications", "geolocation", + "fullscreen", ]) { assert.isTrue(requestAllows(permission), `request handler should allow ${permission}`); assert.isTrue( @@ -189,6 +210,73 @@ describe("BrowserSession", () => { }).pipe(Effect.provide(layer)), ); + it.effect("opens custom-scheme links externally only after the user confirms", () => + Effect.gen(function* () { + const browserSessions = yield* BrowserSession.BrowserSession; + const partition = yield* browserSessions.getPartition("scope-a"); + yield* browserSessions.getSession("scope-a"); + const requestHandler = + sessions.get(partition)?.setPermissionRequestHandler.mock.calls[0]?.[0]; + assert.isFunction(requestHandler); + + const request = (externalURL: string) => + Effect.callback((resume) => { + requestHandler( + null, + "openExternal", + (granted: boolean) => resume(Effect.succeed(granted)), + { externalURL }, + ); + }); + + showMessageBox.mockReset(); + showMessageBox.mockReturnValueOnce(0).mockReturnValueOnce(1); + assert.isTrue(yield* request("slack://open?team=T1")); + assert.isFalse(yield* request("zoommtg://zoom.us/join")); + assert.strictEqual(showMessageBox.mock.calls.length, 2); + + for (const url of ["file:///etc/passwd", "javascript:alert(1)", "data:text/html,x"]) { + assert.isFalse(yield* request(url), `${url} must never open externally`); + } + assert.strictEqual(showMessageBox.mock.calls.length, 2); + }).pipe(Effect.provide(layer)), + ); + + // A prompt without an owner can open behind the app window, leaving the one + // prompt slot taken and every later link silently denied. + it.effect("attaches the open-externally prompt to the window hosting the preview", () => + Effect.gen(function* () { + const browserSessions = yield* BrowserSession.BrowserSession; + const partition = yield* browserSessions.getPartition("scope-a"); + yield* browserSessions.getSession("scope-a"); + const requestHandler = + sessions.get(partition)?.setPermissionRequestHandler.mock.calls[0]?.[0]; + assert.isFunction(requestHandler); + + const hostWindow = { id: 7 } as unknown as Electron.BrowserWindow; + const host = { isDestroyed: () => false }; + const guest = { isDestroyed: () => false, hostWebContents: host }; + fromWebContents.mockImplementation((webContents) => + webContents === host ? hostWindow : null, + ); + showMessageBox.mockReset(); + showMessageBox.mockReturnValueOnce(1); + + yield* Effect.callback((resume) => { + requestHandler( + guest, + "openExternal", + (granted: boolean) => resume(Effect.succeed(granted)), + { + externalURL: "slack://open?team=T1", + }, + ); + }); + + assert.strictEqual(showMessageBox.mock.calls[0]?.[1], hostWindow); + }).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({ @@ -218,7 +306,11 @@ describe("BrowserSession", () => { "Failed to derive a desktop preview browser partition for scope environment-a.", ); assert.notInclude(error.message, nativeCause.message); - }).pipe(Effect.provide(BrowserSession.layer.pipe(Layer.provide(layerFailingCrypto)))); + }).pipe( + Effect.provide( + BrowserSession.layer.pipe(Layer.provide(Layer.merge(layerFailingCrypto, layerDialog))), + ), + ); }); it.effect("preserves session scope, partition, and the Electron failure", () => diff --git a/apps/desktop/src/preview/BrowserSession.ts b/apps/desktop/src/preview/BrowserSession.ts index 9681be83fdbb..10e2a2575f38 100644 --- a/apps/desktop/src/preview/BrowserSession.ts +++ b/apps/desktop/src/preview/BrowserSession.ts @@ -1,5 +1,5 @@ -import type { Session } from "electron"; -import { session } from "electron"; +import type { Session, WebContents } from "electron"; +import { BrowserWindow, session } from "electron"; import * as Context from "effect/Context"; import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; @@ -9,6 +9,8 @@ import * as PlatformError from "effect/PlatformError"; import * as Schema from "effect/Schema"; import * as SynchronizedRef from "effect/SynchronizedRef"; +import * as ElectronDialog from "../electron/ElectronDialog.ts"; + const PREVIEW_PARTITION_PREFIX = "persist:t3code-preview-"; /** * Incognito partitions deliberately omit the `persist:` prefix, which is what @@ -33,12 +35,51 @@ const ALLOWED_PREVIEW_PERMISSIONS: ReadonlySet = new Set([ "clipboard-sanitized-write", "notifications", "geolocation", + // The Fullscreen API. `DesktopWindow` disables HTML fullscreen window resizing + // for the app window and every preview guest, so a page that goes fullscreen + // fills its own webview instead of taking over the whole T3 window. + "fullscreen", // Deliberately NOT local-fonts: preview sessions run untrusted web content, // and silently granting it would hand every page the user's installed-font // fingerprint (and font file bytes via FontData.blob()). The app's own font // picker runs in the main window session, which is unaffected by this list. ]); +/** + * Schemes a preview page may never hand to the OS, even with the user's + * consent: they read local files, run script, or are web pages that belong in + * the preview itself. + */ +const NEVER_EXTERNAL_PROTOCOLS: ReadonlySet = new Set([ + "about:", + "blob:", + "chrome:", + "data:", + "devtools:", + "file:", + "filesystem:", + "http:", + "https:", + "javascript:", + "view-source:", +]); + +/** + * The URL to offer the user when a preview page navigates to a custom scheme + * such as `slack://` or `zoom://`, or `null` when it must stay denied. + */ +const externalProtocolPromptUrl = (rawUrl: string | undefined): string | null => { + if (!rawUrl) return null; + try { + const url = new URL(rawUrl); + return NEVER_EXTERNAL_PROTOCOLS.has(url.protocol) ? null : url.href; + } catch { + return null; + } +}; + +const MAX_PROMPT_URL_LENGTH = 300; + export class BrowserSessionPartitionDerivationError extends Schema.TaggedError()( "BrowserSessionPartitionDerivationError", { @@ -159,9 +200,68 @@ const encodeScopeForDigest = (scope: string): Uint8Array => ), ); +/** + * The window showing a preview guest. A `` guest has no window of its + * own, so look it up through the page that embeds it. + */ +const previewHostWindow = (guest: WebContents | null): BrowserWindow | undefined => { + if (guest === null || guest.isDestroyed()) return undefined; + const host = guest.hostWebContents ?? guest; + if (host.isDestroyed()) return undefined; + return BrowserWindow.fromWebContents(host) ?? undefined; +}; + /** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* BrowserSessionMake() { const crypto = yield* Crypto.Crypto; + const electronDialog = yield* ElectronDialog.ElectronDialog; + const runFork = Effect.runForkWith(yield* Effect.context()); + // One prompt at a time, so a page cannot stack dialogs by looping a deep link. + let externalPromptOpen = false; + /** + * Electron grants an external-protocol navigation through the `openExternal` + * permission and then launches the OS handler itself. Ask first: previews run + * untrusted pages, and a silent grant would let any of them launch apps. + */ + const confirmOpenExternal = ( + requester: WebContents | null, + rawUrl: string | undefined, + callback: (granted: boolean) => void, + ): void => { + const url = externalProtocolPromptUrl(rawUrl); + if (url === null || externalPromptOpen) { + callback(false); + return; + } + externalPromptOpen = true; + const settle = (granted: boolean) => + Effect.sync(() => { + externalPromptOpen = false; + callback(granted); + }); + const shownUrl = + url.length > MAX_PROMPT_URL_LENGTH ? `${url.slice(0, MAX_PROMPT_URL_LENGTH)}…` : url; + runFork( + electronDialog + .showMessageBox( + { + type: "question", + buttons: ["Open", "Cancel"], + defaultId: 0, + cancelId: 1, + message: `Open this ${new URL(url).protocol.slice(0, -1)} link?`, + detail: `A page in the browser wants to open another application:\n\n${shownUrl}`, + }, + previewHostWindow(requester), + ) + .pipe( + Effect.map(({ response }) => response === 0), + Effect.orElseSucceed(() => false), + Effect.flatMap(settle), + Effect.onInterrupt(() => settle(false)), + ), + ); + }; const sessionsRef = yield* SynchronizedRef.make>(new Map()); const getPartition = Effect.fn("BrowserSession.getPartition")(function* ( @@ -203,9 +303,19 @@ export const make = Effect.gen(function* BrowserSessionMake() { // the challenge every few seconds, so logins behind it never complete // (#5002). Re-setting the unchanged native string is harmless, so it // is the rewritten string itself that trips the check. - browserSession.setPermissionRequestHandler((_webContents, permission, callback) => { - callback(ALLOWED_PREVIEW_PERMISSIONS.has(permission)); - }); + browserSession.setPermissionRequestHandler( + (webContents, permission, callback, details) => { + if (permission === "openExternal") { + confirmOpenExternal( + webContents, + "externalURL" in details ? details.externalURL : undefined, + callback, + ); + return; + } + callback(ALLOWED_PREVIEW_PERMISSIONS.has(permission)); + }, + ); browserSession.setPermissionCheckHandler((_webContents, permission) => ALLOWED_PREVIEW_PERMISSIONS.has(permission), ); diff --git a/apps/desktop/src/preview/DesktopBrowserHost.test.ts b/apps/desktop/src/preview/DesktopBrowserHost.test.ts index eff9b98b105e..81ea040701f0 100644 --- a/apps/desktop/src/preview/DesktopBrowserHost.test.ts +++ b/apps/desktop/src/preview/DesktopBrowserHost.test.ts @@ -119,4 +119,33 @@ describe("DesktopBrowserHost", () => { expect(paths).toEqual(["/srv/downloads/guid-1"]); }), ); + + it.effect("tells a download the person clicked from one the agent's input started", () => + Effect.gen(function* () { + const host = yield* DesktopBrowserHost.make; + const debuggee = makeDebuggee(); + host.attach(key, debuggee.tab); + // Reading the page is not acting on it. + yield* host.handleCommandLine( + encodeJson({ + type: "cdp", + ...key, + message: encodeJson({ id: 1, method: "DOM.getDocument", params: {} }), + }), + ); + expect(host.humanStartedDownload(debuggee.tab.webContents)).toBe(true); + yield* host.handleCommandLine( + encodeJson({ + type: "cdp", + ...key, + message: encodeJson({ + id: 2, + method: "Input.dispatchMouseEvent", + params: { type: "mousePressed", x: 1, y: 1 }, + }), + }), + ); + expect(host.humanStartedDownload(debuggee.tab.webContents)).toBe(false); + }), + ); }); diff --git a/apps/desktop/src/preview/DesktopBrowserHost.ts b/apps/desktop/src/preview/DesktopBrowserHost.ts index e6349d06b916..cfb1ff753c47 100644 --- a/apps/desktop/src/preview/DesktopBrowserHost.ts +++ b/apps/desktop/src/preview/DesktopBrowserHost.ts @@ -28,6 +28,11 @@ import { createCdpRelayConnection, type CdpRelayConnection } from "./CdpRelay.ts const encodeEvent = Schema.encodeSync(Schema.fromJsonString(DesktopBrowserEvent)); const decodeCommand = Schema.decodeUnknownOption(Schema.fromJsonString(DesktopBrowserCommand)); const lineEncoder = new TextEncoder(); +/** CDP commands through which an agent clicks, types, or navigates the page. */ +const AGENT_INPUT_COMMAND = + /"method":"(?:Input\.|Page\.navigate"|Runtime\.(?:evaluate|callFunctionOn)")/; +/** How long after an agent's input a download still counts as the agent's. */ +const AGENT_DOWNLOAD_WINDOW_MS = 5_000; export interface DesktopBrowserTabKey { readonly threadId: string; @@ -50,6 +55,8 @@ interface AttachedTab { downloadDirectory: string | null; /** The guid CDP gave the download that is about to start. */ pendingDownloadGuid: string | null; + /** When the server last sent this page input, which only an agent does. */ + agentInputAt: number; readonly onMessage: ( event: Electron.Event, method: string, @@ -72,6 +79,11 @@ export class DesktopBrowserHost extends Context.Service< readonly attach: (key: DesktopBrowserTabKey, debuggee: DesktopBrowserTabDebugger) => void; /** Withdraws it: closed, swapped, crashed, or devtools needs the debugger. */ readonly detach: (key: DesktopBrowserTabKey) => void; + /** + * Whether a server tab's download came from the person at this desktop: + * the server sent the page no input just before it started. + */ + readonly humanStartedDownload: (source: Electron.WebContents) => boolean; /** Points a server tab's download at the server; false for any other download. */ readonly placeDownload: (source: Electron.WebContents, item: Electron.DownloadItem) => boolean; /** The agent's cursor positions for attached tabs, keyed by their server tab. */ @@ -161,6 +173,7 @@ export const make = Effect.gen(function* () { relay: null, downloadDirectory: null, pendingDownloadGuid: null, + agentInputAt: Number.NEGATIVE_INFINITY, onMessage: (_event, method, params, sessionId) => { if (method === "Browser.downloadWillBegin") { const guid = (params as { guid?: unknown } | undefined)?.guid; @@ -190,9 +203,15 @@ export const make = Effect.gen(function* () { tab.relay = null; return; } + if (AGENT_INPUT_COMMAND.test(command.value.message)) tab.agentInputAt = performance.now(); relayFor(tab).receive(command.value.message); }); + const humanStartedDownload = (source: Electron.WebContents) => { + const tab = [...tabs.values()].find((candidate) => candidate.debuggee.webContents === source); + return tab !== undefined && performance.now() - tab.agentInputAt > AGENT_DOWNLOAD_WINDOW_MS; + }; + // Read when a backend starts, not when the host is built. const announceAll = Effect.suspend(() => Effect.forEach( @@ -219,6 +238,7 @@ export const make = Effect.gen(function* () { attach, detach, placeDownload, + humanStartedDownload, }); }); diff --git a/apps/desktop/src/preview/Manager.test.ts b/apps/desktop/src/preview/Manager.test.ts index 11ee2d83e481..84c6273aad62 100644 --- a/apps/desktop/src/preview/Manager.test.ts +++ b/apps/desktop/src/preview/Manager.test.ts @@ -158,21 +158,31 @@ describe("previewWindowOpenAction", () => { ).toBe("popup"); }); - it("keeps target=_blank links in the preview tab", () => { + it("opens target=_blank links as a new tab", () => { expect(PreviewManager.previewWindowOpenAction(details({ disposition: "foreground-tab" }))).toBe( - "navigate", + "new-tab", ); expect(PreviewManager.previewWindowOpenAction(details({ disposition: "background-tab" }))).toBe( - "navigate", + "new-tab", ); }); + it("keeps a form POST with a body on the in-place path", () => { + // A new tab can only reopen the URL as a GET, which would drop the body. + const postBody = { data: [], contentType: "application/x-www-form-urlencoded" }; + expect( + PreviewManager.previewWindowOpenAction({ + ...details({ disposition: "foreground-tab" }), + postBody, + }), + ).toBe("navigate"); + }); + it("does not hand a window to schemes that cannot be hardened", () => { // A popup skips the `will-attach-webview` hardening, so it only gets a window // when its preferences can be overridden. Chromium copies the guest's // preferences for `about:blank` and forbids overriding them. for (const url of [ - "about:blank", "javascript:alert(1)", "file:///etc/passwd", "vscode://vscode-remote/ssh-remote+box/tmp", @@ -181,6 +191,16 @@ describe("previewWindowOpenAction", () => { expect(PreviewManager.previewWindowOpenAction(details({ url }))).toBe("navigate"); } }); + + it("denies a blank window instead of blanking the opener", () => { + // MSAL opens `about:blank` first and falls back to a redirect on `null`; + // loading the blank page into the tab would destroy that fallback. + for (const url of ["about:blank", ""]) { + for (const disposition of ["new-window", "foreground-tab"] as const) { + expect(PreviewManager.previewWindowOpenAction(details({ url, disposition }))).toBe("deny"); + } + } + }); }); const { @@ -355,7 +375,7 @@ const makeTestPreviewWebContents = ( getType: () => "webview", getURL: () => "https://example.com", getTitle: () => "Example", - isLoading: () => false, + isLoadingMainFrame: () => false, getZoomFactor: () => 1, setZoomFactor: vi.fn(), setAudioMuted: vi.fn(), @@ -460,7 +480,7 @@ const makeFaviconWebContents = (options?: { getType: () => "webview", getURL: () => currentUrl, getTitle: () => "Preview", - isLoading: () => loading, + isLoadingMainFrame: () => loading, isDevToolsOpened: () => false, getZoomFactor: () => 1, setZoomFactor: vi.fn(), @@ -572,6 +592,40 @@ describe("PreviewManager", () => { webviewSend.mockClear(); }); + effectIt.effect("opens a target=_blank link as a new tab without navigating the opener", () => + withManager((manager) => + Effect.gen(function* () { + const preview = makeFaviconWebContents(); + fromId.mockReturnValue(preview.webContents); + const opened: Array<{ tabId: string; url: string; background: boolean }> = []; + yield* manager.subscribeOpenLinks((event) => Effect.sync(() => void opened.push(event))); + yield* manager.createTab("tab_links"); + yield* manager.registerWebview("tab_links", 42); + const openHandler = (preview.webContents as Electron.WebContents).setWindowOpenHandler as + | ReturnType + | undefined; + const handler = openHandler?.mock.lastCall?.[0] as ( + details: Partial, + ) => { action: string }; + + expect( + handler({ url: "https://github.com/issues/1", disposition: "foreground-tab" }), + ).toEqual({ action: "deny" }); + expect(handler({ url: "about:blank", disposition: "new-window" })).toEqual({ + action: "deny", + }); + handler({ url: "https://github.com/issues/2", disposition: "background-tab" }); + yield* settle(() => opened.length === 2); + + expect(preview.loadURL).not.toHaveBeenCalled(); + expect(opened).toEqual([ + { tabId: "tab_links", url: "https://github.com/issues/1", background: false }, + { tabId: "tab_links", url: "https://github.com/issues/2", background: true }, + ]); + }), + ), + ); + effectIt.effect("keeps preview shortcuts out of the host window", () => withManager((manager) => Effect.gen(function* () { @@ -885,7 +939,7 @@ describe("PreviewManager", () => { getType: () => "webview", getURL: () => "about:blank", getTitle: () => "", - isLoading: () => false, + isLoadingMainFrame: () => false, getZoomFactor: () => 1, setZoomFactor: vi.fn(), setAudioMuted: vi.fn(), @@ -978,7 +1032,7 @@ describe("PreviewManager", () => { getType: () => "webview", getURL: () => "http://localhost:3200/", getTitle: () => "Preview", - isLoading: () => false, + isLoadingMainFrame: () => false, isDevToolsOpened: () => false, getZoomFactor: () => 1, setZoomFactor: vi.fn(), @@ -1481,7 +1535,7 @@ describe("PreviewManager", () => { getType: () => "webview", getURL: () => url, getTitle: () => "Example", - isLoading: () => false, + isLoadingMainFrame: () => false, getZoomFactor: () => { if (!zoomReadable) throw new Error("zoom unavailable"); return effectiveZoom; @@ -1551,7 +1605,7 @@ describe("PreviewManager", () => { getType: () => "webview", getURL: () => url, getTitle: () => "Example", - isLoading: () => false, + isLoadingMainFrame: () => false, getZoomFactor: () => 1, setZoomFactor: replacementSetZoomFactor, setAudioMuted: vi.fn(), @@ -1592,7 +1646,7 @@ describe("PreviewManager", () => { getType: () => "webview", getURL: () => "https://example.com", getTitle: () => "Example", - isLoading: () => false, + isLoadingMainFrame: () => false, getZoomFactor: () => 1, setZoomFactor, setAudioMuted: vi.fn(), @@ -1639,7 +1693,7 @@ describe("PreviewManager", () => { getType: () => "webview", getURL: () => "https://example.com", getTitle: () => "Example", - isLoading: () => false, + isLoadingMainFrame: () => false, getZoomFactor: () => 1, setZoomFactor, setAudioMuted: vi.fn(), @@ -1695,7 +1749,7 @@ describe("PreviewManager", () => { getType: () => "webview", getURL: () => "https://example.com", getTitle: () => "Example", - isLoading: () => false, + isLoadingMainFrame: () => false, getZoomFactor: () => 1, setZoomFactor: vi.fn(), setAudioMuted: vi.fn(), @@ -1790,7 +1844,7 @@ describe("PreviewManager", () => { getType: () => "webview", getURL: () => "http://localhost:5173/README.md", getTitle: () => "README.md", - isLoading: () => true, + isLoadingMainFrame: () => true, getZoomFactor: () => 1, setZoomFactor: vi.fn(), setAudioMuted: vi.fn(), @@ -1915,7 +1969,7 @@ describe("PreviewManager", () => { getType: () => "webview", getURL: () => "https://example.com", getTitle: () => "Example", - isLoading: () => false, + isLoadingMainFrame: () => false, getZoomFactor: () => 1, setZoomFactor: vi.fn(), setAudioMuted, @@ -2258,7 +2312,7 @@ describe("PreviewManager", () => { getType: () => "webview", getURL: () => url, getTitle: () => "localhost:5733", - isLoading: () => loading, + isLoadingMainFrame: () => loading, getZoomFactor: () => 1, setZoomFactor: vi.fn(), setAudioMuted: vi.fn(), @@ -2339,6 +2393,28 @@ describe("PreviewManager", () => { ), ); + effectIt.effect("ignores a late cross-origin iframe load once the page has loaded", () => + withManager((manager) => + Effect.gen(function* () { + const preview = makeFaviconWebContents(); + // The frame tree is still loading the iframe; the main frame is done. + Object.assign(preview.webContents, { isLoading: () => true }); + fromId.mockReturnValue(preview.webContents); + const states: PreviewManager.PreviewTabState[] = []; + yield* manager.subscribeStateChanges((_tabId, state) => + Effect.sync(() => void states.push(state)), + ); + yield* manager.createTab("tab_iframe"); + yield* manager.registerWebview("tab_iframe", 42); + + preview.listeners.get("did-start-loading")?.(); + preview.listeners.get("did-stop-loading")?.(); + yield* settle(() => states.length > 2); + expect(states.at(-1)?.navStatus.kind).toBe("Success"); + }), + ), + ); + effectIt.effect("captures a PNG screenshot into browser artifacts", () => withManager((manager) => Effect.gen(function* () { @@ -2351,7 +2427,7 @@ describe("PreviewManager", () => { getType: () => "webview", getURL: () => "https://example.com:8443/path?query=value", getTitle: () => "Example", - isLoading: () => false, + isLoadingMainFrame: () => false, getZoomFactor: () => 1, setZoomFactor: vi.fn(), setAudioMuted: vi.fn(), @@ -2919,7 +2995,7 @@ describe("PreviewManager", () => { getType: () => "webview", getURL: () => `https://example.com/${id}`, getTitle: () => `Example ${id}`, - isLoading: () => false, + isLoadingMainFrame: () => false, getZoomFactor: () => 1, setZoomFactor: vi.fn(), setAudioMuted: vi.fn(), @@ -3381,7 +3457,7 @@ describe("PreviewManager", () => { getType: () => "webview", getURL: () => "https://example.com", getTitle: () => "Example", - isLoading: () => false, + isLoadingMainFrame: () => false, getZoomFactor: () => 1, setZoomFactor: vi.fn(), setAudioMuted: vi.fn(), @@ -3880,7 +3956,7 @@ describe("PreviewManager", () => { getType: () => "webview", getURL: () => "https://example.com", getTitle: () => "Example", - isLoading: () => false, + isLoadingMainFrame: () => false, isFocused: () => true, getZoomFactor: () => 1, setZoomFactor: vi.fn(), @@ -4026,7 +4102,7 @@ describe("PreviewManager", () => { getType: () => "webview", getURL: () => "https://example.com", getTitle: () => "Example", - isLoading: () => false, + isLoadingMainFrame: () => false, isFocused: () => true, getZoomFactor: () => 1, setZoomFactor: vi.fn(), @@ -4095,6 +4171,69 @@ describe("PreviewManager", () => { ), ); + effectIt.effect("crops the picked area out of a full-page capture at device scale", () => + withManager((manager) => + Effect.gen(function* () { + let onPicked: ((event: unknown, ...args: unknown[]) => void) | undefined; + const crop = vi.fn((rect: Electron.Rectangle) => ({ + getSize: () => ({ width: rect.width, height: rect.height }), + toDataURL: () => "data:image/png;base64,cropped", + })); + const capturePage = vi.fn(async () => ({ + getSize: () => ({ width: 2000, height: 1200 }), + crop, + })); + fromId.mockReturnValue( + Object.assign(makeTestPreviewWebContents(capturePage as never), { + isFocused: () => true, + once: vi.fn(), + ipc: { + on: vi.fn((channel: string, listener: typeof onPicked) => { + if (channel === "preview:element-picked") onPicked = listener; + }), + off: vi.fn(), + removeListener: vi.fn(), + }, + }), + ); + + yield* manager.createTab("tab_1"); + yield* manager.registerWebview("tab_1", 42); + const pick = yield* manager.pickElement("tab_1").pipe(Effect.forkChild); + yield* Effect.yieldNow; + onPicked?.( + {}, + { + id: "annotation_1", + pageUrl: "https://example.com", + pageTitle: "Example", + comment: "Tighten this spacing", + elements: [], + regions: [{ id: "region_1", rect: { x: 5, y: 6, width: 20, height: 30 } }], + strokes: [], + styleChanges: [], + screenshot: null, + createdAt: "2026-06-11T00:00:00.000Z", + }, + { x: 5, y: 6, width: 20, height: 30 }, + "attach", + 2, + ); + + const result = yield* Fiber.join(pick); + expect(capturePage).toHaveBeenCalledWith(); + expect(crop).toHaveBeenCalledWith({ x: 10, y: 12, width: 40, height: 60 }); + expect(result?.screenshotFailed).toBeUndefined(); + expect(result?.annotation.screenshot).toEqual({ + dataUrl: "data:image/png;base64,cropped", + width: 40, + height: 60, + cropRect: { x: 5, y: 6, width: 20, height: 30 }, + }); + }), + ), + ); + effectIt.effect("a stale capture from a replaced pick never touches the next pick", () => withManager((manager) => Effect.gen(function* () { @@ -4105,7 +4244,7 @@ describe("PreviewManager", () => { getType: () => "webview", getURL: () => "https://example.com", getTitle: () => "Example", - isLoading: () => false, + isLoadingMainFrame: () => false, isFocused: () => true, getZoomFactor: () => 1, setZoomFactor: vi.fn(), @@ -4190,7 +4329,7 @@ describe("PreviewManager", () => { getType: () => "webview", getURL: () => "https://example.com", getTitle: () => "Example", - isLoading: () => false, + isLoadingMainFrame: () => false, getZoomFactor: () => 1, setZoomFactor: vi.fn(), setAudioMuted: vi.fn(), diff --git a/apps/desktop/src/preview/Manager.ts b/apps/desktop/src/preview/Manager.ts index f98694145b67..123943f70f9a 100644 --- a/apps/desktop/src/preview/Manager.ts +++ b/apps/desktop/src/preview/Manager.ts @@ -13,6 +13,7 @@ import type { DesktopPreviewAnnotationTheme, DesktopPreviewColorScheme, DesktopPreviewFavicon, + DesktopPreviewOpenLinkEvent, DesktopPreviewPointerEvent, PreviewAnnotationPayload, PreviewAnnotationRect, @@ -30,6 +31,7 @@ import { normalizePreviewUrl } from "@t3tools/shared/preview"; import { BrowserWindow, ClipboardItem, + app, type Session, clipboard, nativeImage, @@ -284,61 +286,36 @@ const normalizeCaptureRect = (value: unknown): PreviewAnnotationRect | null => { }; }; -/** `capturePage` never settles when the guest's compositor is wedged. */ -const ANNOTATION_SCREENSHOT_TIMEOUT = "5 seconds"; - /** - * Crops the guest for a picked annotation. A stalled `capturePage` resolves to - * `null` after the timeout: the annotation is still sendable without its - * screenshot, and the pick session must settle either way. + * Crops a full-page capture to a picked annotation. `cropRect` is in CSS px; + * the capture's pixels are CSS px × the guest's `devicePixelRatio`. */ -const captureAnnotationScreenshot = ( - tabId: string, - wc: Electron.WebContents, +const cropAnnotationScreenshot = ( + image: Electron.NativeImage, cropRect: PreviewAnnotationRect | null, -): Effect.Effect => - Effect.tryPromise({ - // The unused abort signal is what makes this interruptible, and therefore - // what lets the timeout below fire. Drop the parameter and a stalled - // capture strands the pick session again. - try: (_signal) => - wc.capturePage( - cropRect - ? { - x: cropRect.x, - y: cropRect.y, - width: cropRect.width, - height: cropRect.height, - } - : undefined, - ), - catch: (cause) => - new PreviewOperationError({ - operation: "captureAnnotationScreenshot", - tabId, - webContentsId: wc.id, - cause, - }), - }).pipe( - Effect.map((image): PreviewAnnotationPayload["screenshot"] => { - const size = image.getSize(); - return { - dataUrl: image.toDataURL(), - width: size.width, - height: size.height, - cropRect: cropRect ?? { x: 0, y: 0, width: size.width, height: size.height }, - }; - }), - Effect.timeoutOption(ANNOTATION_SCREENSHOT_TIMEOUT), - Effect.flatMap((screenshot) => - Option.isSome(screenshot) - ? Effect.succeed(screenshot.value) - : Effect.logWarning("preview annotation screenshot timed out").pipe( - Effect.annotateLogs({ tabId, webContentsId: wc.id }), - Effect.as(null), - ), - ), - ); + devicePixelRatio: number, +): PreviewAnnotationPayload["screenshot"] => { + const full = image.getSize(); + if (!cropRect) { + return { + dataUrl: image.toDataURL(), + width: full.width, + height: full.height, + cropRect: { x: 0, y: 0, width: full.width, height: full.height }, + }; + } + const scale = Number.isFinite(devicePixelRatio) && devicePixelRatio > 0 ? devicePixelRatio : 1; + const x = Math.min(full.width - 1, Math.max(0, Math.floor(cropRect.x * scale))); + const y = Math.min(full.height - 1, Math.max(0, Math.floor(cropRect.y * scale))); + const cropped = image.crop({ + x, + y, + width: Math.max(1, Math.min(full.width - x, Math.ceil(cropRect.width * scale))), + height: Math.max(1, Math.min(full.height - y, Math.ceil(cropRect.height * scale))), + }); + const size = cropped.getSize(); + return { dataUrl: cropped.toDataURL(), width: size.width, height: size.height, cropRect }; +}; const findZoomStep = (current: number): number => { const index = ZOOM_LEVELS.findIndex( @@ -437,6 +414,8 @@ type RecordingInputListener = (event: DesktopPreviewRecordingInputEvent) => Effe type PointerEventListener = (event: DesktopPreviewPointerEvent) => Effect.Effect; +type OpenLinkListener = (event: DesktopPreviewOpenLinkEvent) => Effect.Effect; + /** * Protocols a preview page may open in a real popup window. * @@ -483,14 +462,29 @@ const POPUP_WINDOW_OPTIONS = { * navigating the preview tab instead destroys the opener the popup has to * `postMessage` its result back to. * - * `target="_blank"` links arrive as a tab disposition and keep loading in the - * preview tab, which is what people expect from a link inside a preview. + * `"new-tab"` hands a `target="_blank"` link (a tab disposition) to the web + * app, which opens it as another preview tab so the page that held the link + * stays put. Schemes a popup cannot be hardened for keep loading in place, and + * so does a form POST with a body, which a new tab could only reopen as a GET. + * + * `"deny"` is for a blank window (`""` or `about:blank`). It cannot get a real + * window (see `POPUP_PROTOCOLS`), and loading it into the preview tab would + * replace the opener with an empty page. Denying makes `window.open()` return + * `null`, which SDKs such as MSAL treat as a blocked popup and fall back from. */ export const previewWindowOpenAction = (details: { readonly url: string; readonly disposition: Electron.HandlerDetails["disposition"]; -}): "popup" | "navigate" => - details.disposition === "new-window" && isPopupUrl(details.url) ? "popup" : "navigate"; + readonly postBody?: Electron.PostBody; +}): "popup" | "new-tab" | "navigate" | "deny" => { + if (details.url === "" || details.url === "about:blank") return "deny"; + if (!isPopupUrl(details.url)) return "navigate"; + if (details.disposition === "new-window") return "popup"; + return !details.postBody && + (details.disposition === "foreground-tab" || details.disposition === "background-tab") + ? "new-tab" + : "navigate"; +}; export const isPreviewRefreshShortcut = (input: Electron.Input): boolean => input.type === "keyDown" && @@ -566,6 +560,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function const attachedRef = yield* Ref.make>(new Map()); const listenersRef = yield* Ref.make>(new Set()); const pointerEventListenersRef = yield* Ref.make>(new Set()); + const openLinkListenersRef = yield* Ref.make>(new Set()); const recordingInputListenersRef = yield* Ref.make>( new Set(), ); @@ -836,7 +831,12 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function }); const deliverEvent = ( - eventKind: "state-change" | "recording-frame" | "recording-input" | "pointer-event", + eventKind: + | "state-change" + | "recording-frame" + | "recording-input" + | "pointer-event" + | "open-link", tabId: string, delivery: () => Effect.Effect, ) => @@ -1241,7 +1241,10 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function const url = wc.getURL(); const title = wc.getTitle(); if (url === "" || url === "about:blank") return { kind: "Idle" }; - if (wc.isLoading()) return { kind: "Loading", url, title }; + // Main frame only. `isLoading()` covers the whole frame tree, so a + // cross-origin iframe that loads after the page can leave it true with no + // later event to clear it, and the tab's loading bar never finishes. + if (wc.isLoadingMainFrame()) return { kind: "Loading", url, title }; return { kind: "Success", url, title }; }; @@ -1607,9 +1610,21 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function wc.ipc.on(RECORDING_INPUT_CHANNEL, recordingInput); wc.ipc.on(MOUSE_NAVIGATE_CHANNEL, mouseNavigate); wc.setWindowOpenHandler((details) => { - if (previewWindowOpenAction(details) === "popup") { + const action = previewWindowOpenAction(details); + if (action === "popup") { return { action: "allow", overrideBrowserWindowOptions: POPUP_WINDOW_OPTIONS }; } + if (action === "deny") return { action: "deny" }; + if (action === "new-tab") { + runFork( + emitOpenLink({ + tabId, + url: details.url, + background: details.disposition === "background-tab", + }), + ); + return { action: "deny" }; + } runFork( attemptPromise({ operation: "openPreviewWindow", tabId, webContentsId: wc.id }, () => wc.loadURL(details.url), @@ -2234,8 +2249,23 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function const cropRect = normalizeCaptureRect(args[1]); const submission = args[2] === "send" && annotationSendEnabled.get(tabId) === true ? "send" : "attach"; + const devicePixelRatio = typeof args[3] === "number" ? args[3] : 1; runFork( - captureAnnotationScreenshot(tabId, wc, cropRect).pipe( + // The full-page capture with retries is what the screenshot button + // uses. A single cropped `capturePage(rect)` with a fixed timeout + // dropped the crop on pages where that capture was merely slow. + capturePageWithRetry( + { operation: "captureAnnotationScreenshot", tabId, webContentsId: wc.id }, + tabId, + wc, + ).pipe( + Effect.map((image) => cropAnnotationScreenshot(image, cropRect, devicePixelRatio)), + Effect.tapError((error) => + Effect.logWarning("preview annotation screenshot failed").pipe( + Effect.annotateLogs({ tabId, webContentsId: wc.id, error: error.message }), + ), + ), + Effect.withSpan("PreviewManager.captureAnnotationScreenshot"), // The renderer cannot tell a dropped crop from a comment-only // pick by the null alone, so a failed or timed-out capture is // flagged on the result. @@ -3320,6 +3350,16 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function ); }); + const emitOpenLink = Effect.fn("PreviewManager.emitOpenLink")(function* ( + event: DesktopPreviewOpenLinkEvent, + ) { + const listeners = yield* Ref.get(openLinkListenersRef); + yield* Effect.forEach( + listeners, + (listener) => deliverEvent("open-link", event.tabId, () => listener(event)), + { discard: true }, + ); + }); const subscribe = ( ref: Ref.Ref>, listener: A, @@ -3341,6 +3381,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function [ Ref.set(listenersRef, new Set()), Ref.set(pointerEventListenersRef, new Set()), + Ref.set(openLinkListenersRef, new Set()), Ref.set(recordingFrameListenersRef, new Set()), Ref.set(recordingInputListenersRef, new Set()), ], @@ -3386,6 +3427,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function stopRecording, subscribePointerEvents: (listener: PointerEventListener) => subscribe(pointerEventListenersRef, listener), + subscribeOpenLinks: (listener: OpenLinkListener) => subscribe(openLinkListenersRef, listener), subscribeRecordingInputs: (listener: RecordingInputListener) => subscribe(recordingInputListenersRef, listener), subscribeRecordingFrames: (listener: RecordingFrameListener) => @@ -3627,6 +3669,9 @@ export class PreviewManager extends Context.Service< readonly subscribePointerEvents: ( listener: PointerEventListener, ) => Effect.Effect; + readonly subscribeOpenLinks: ( + listener: OpenLinkListener, + ) => Effect.Effect; readonly subscribeRecordingInputs: ( listener: RecordingInputListener, ) => Effect.Effect; @@ -3642,13 +3687,48 @@ export const make = Effect.gen(function* PreviewManagerMake() { const browserSession = yield* BrowserSession.BrowserSession; const browserHost = yield* DesktopBrowserHost.DesktopBrowserHost; const downloadSessions = new WeakSet(); + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const runForkDownload = Effect.runForkWith(yield* Effect.context()); + /** Puts a copy in Downloads under a name that never replaces a file already there. */ + const copyToDownloads = Effect.fn("PreviewManager.copyToDownloads")(function* ( + source: string, + fileName: string, + ) { + const directory = app.getPath("downloads"); + const extension = path.extname(fileName); + const stem = path.basename(fileName, extension) || "download"; + for (let attempt = 0; attempt < 100; attempt += 1) { + const target = path.join( + directory, + attempt === 0 ? `${stem}${extension}` : `${stem} (${attempt})${extension}`, + ); + if (yield* fileSystem.exists(target)) continue; + yield* fileSystem.copyFile(source, target); + shell.showItemInFolder(target); + return; + } + }); // Server tabs save downloads where the server's engine reads them. Downloads // the person starts in a tab the server is not driving keep Electron's dialog. const placeServerDownloads = (session: Electron.Session) => { if (downloadSessions.has(session)) return; downloadSessions.add(session); session.on("will-download", (_event, item, source) => { - browserHost.placeDownload(source, item); + // Decided before the server's path is set: placing it consumes the guid. + const human = browserHost.humanStartedDownload(source); + if (!browserHost.placeDownload(source, item) || !human) return; + // The server keeps its copy for agents; the person who clicked gets theirs. + item.once("done", (_doneEvent, state) => { + if (state !== "completed") return; + runForkDownload( + copyToDownloads(item.getSavePath(), item.getFilename()).pipe( + Effect.catch((error) => + Effect.logWarning("preview download copy failed", { cause: error }), + ), + ), + ); + }); }); }; @@ -3732,6 +3812,7 @@ export const make = Effect.gen(function* PreviewManagerMake() { saveRecording: operations.saveRecording, subscribeStateChanges: operations.subscribeStateChanges, subscribePointerEvents: operations.subscribePointerEvents, + subscribeOpenLinks: operations.subscribeOpenLinks, subscribeRecordingFrames: operations.subscribeRecordingFrames, subscribeRecordingInputs: operations.subscribeRecordingInputs, }); diff --git a/apps/desktop/src/preview/PickPreload.ts b/apps/desktop/src/preview/PickPreload.ts index a9155677ad38..a656070f87b4 100644 --- a/apps/desktop/src/preview/PickPreload.ts +++ b/apps/desktop/src/preview/PickPreload.ts @@ -1410,6 +1410,8 @@ function startAnnotation(sendEnabled: boolean): void { annotation, screenshotRect, submission === "send" && !sendEnabled ? "attach" : submission, + // Main crops a full-page capture, whose pixels are CSS px × this. + window.devicePixelRatio, ); }) .catch(() => { diff --git a/apps/desktop/src/window/DesktopApplicationMenu.test.ts b/apps/desktop/src/window/DesktopApplicationMenu.test.ts index f7290d017124..f111dac25db8 100644 --- a/apps/desktop/src/window/DesktopApplicationMenu.test.ts +++ b/apps/desktop/src/window/DesktopApplicationMenu.test.ts @@ -87,6 +87,8 @@ const layerDesktopWindow = (selectedAction: Deferred.Deferred) => dispatchSnapShotEvent: () => Effect.void, zoomMain: (direction) => Deferred.succeed(selectedAction, `zoom-${direction}`).pipe(Effect.asVoid), + runMainContentsCommand: (command) => + Deferred.succeed(selectedAction, `main-${command}`).pipe(Effect.asVoid), syncAppearance: Effect.void, } satisfies DesktopWindow.DesktopWindow["Service"]); @@ -103,6 +105,7 @@ const layerElectronMenu = ( const configureMenu = ( selectedAction: Deferred.Deferred, applicationMenuTemplate: Deferred.Deferred, + environment: Partial = {}, ) => Effect.gen(function* () { const menu = yield* DesktopApplicationMenu.DesktopApplicationMenu; @@ -116,7 +119,7 @@ const configureMenu = ( Layer.provideMerge(layerElectronDialog), Layer.provideMerge(layerElectronApp), Layer.provideMerge( - DesktopEnvironment.layer(environmentInput).pipe( + DesktopEnvironment.layer({ ...environmentInput, ...environment }).pipe( Layer.provide(Layer.mergeAll(NodeServices.layer, DesktopConfig.layerTest({}))), ), ), @@ -125,6 +128,39 @@ const configureMenu = ( ); describe("DesktopApplicationMenu", () => { + it.effect("keeps display branding in the macOS application menu", () => + Effect.gen(function* () { + const selectedAction = yield* Deferred.make(); + const applicationMenuTemplate = + yield* Deferred.make(); + + yield* configureMenu(selectedAction, applicationMenuTemplate, { + platform: "darwin", + appVersion: "0.0.43-nightly.20260929.2428", + }); + + const template = yield* Deferred.await(applicationMenuTemplate); + const applicationMenu = template[0]; + assert.isDefined(applicationMenu); + assert.equal(applicationMenu.label, "T3 Code (Nightly)"); + if (!Array.isArray(applicationMenu.submenu)) { + throw new Error("Expected application menu submenu to be an array."); + } + assert.equal( + applicationMenu.submenu.find((item) => item.role === "about")?.label, + "About T3 Code (Nightly)", + ); + assert.equal( + applicationMenu.submenu.find((item) => item.role === "hide")?.label, + "Hide T3 Code (Nightly)", + ); + assert.equal( + applicationMenu.submenu.find((item) => item.role === "quit")?.label, + "Quit T3 Code (Nightly)", + ); + }), + ); + it.effect("installs the native menu and routes Settings through DesktopWindow", () => Effect.gen(function* () { const selectedAction = yield* Deferred.make(); @@ -231,7 +267,9 @@ describe("DesktopApplicationMenu", () => { } assert.isUndefined( - viewMenu.submenu.find((item) => item.role?.toLowerCase().includes("zoom")), + viewMenu.submenu.find((item) => + ["zoom", "reload", "devtools"].some((role) => item.role?.toLowerCase().includes(role)), + ), ); const zoomIn = viewMenu.submenu.find((item) => item.label === "Zoom In"); @@ -245,4 +283,28 @@ describe("DesktopApplicationMenu", () => { assert.equal(yield* Deferred.await(selectedAction), "zoom-in"); }), ); + + it.effect("reloads the main window even while a browser page has focus", () => + Effect.gen(function* () { + const selectedAction = yield* Deferred.make(); + const applicationMenuTemplate = + yield* Deferred.make(); + + yield* configureMenu(selectedAction, applicationMenuTemplate); + + const template = yield* Deferred.await(applicationMenuTemplate); + const viewMenu = template.find((item) => item.label === "View"); + if (!Array.isArray(viewMenu?.submenu)) { + throw new Error("Expected View menu submenu to be an array."); + } + const reload = viewMenu.submenu.find((item) => item.label === "Reload"); + assert.equal(reload?.accelerator, "CmdOrCtrl+R"); + if (typeof reload?.click !== "function") { + throw new Error("Expected Reload menu item to have a click handler."); + } + + reload.click({} as Electron.MenuItem, {} as Electron.BrowserWindow, {} as KeyboardEvent); + assert.equal(yield* Deferred.await(selectedAction), "main-reload"); + }), + ); }); diff --git a/apps/desktop/src/window/DesktopApplicationMenu.ts b/apps/desktop/src/window/DesktopApplicationMenu.ts index d3b8db895352..d0363fb01a27 100644 --- a/apps/desktop/src/window/DesktopApplicationMenu.ts +++ b/apps/desktop/src/window/DesktopApplicationMenu.ts @@ -7,7 +7,6 @@ import * as Schema from "effect/Schema"; import type * as Electron from "electron"; import { makeComponentLogger } from "../app/DesktopObservability.ts"; -import * as ElectronApp from "../electron/ElectronApp.ts"; import * as ElectronDialog from "../electron/ElectronDialog.ts"; import * as ElectronMenu from "../electron/ElectronMenu.ts"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; @@ -58,6 +57,13 @@ const zoomMainWindow = Effect.fn("desktop.menu.zoomMainWindow")(function* ( yield* desktopWindow.zoomMain(direction); }); +const runMainContentsCommand = Effect.fn("desktop.menu.runMainContentsCommand")(function* ( + command: DesktopWindow.MainWindowContentsCommand, +): Effect.fn.Return { + const desktopWindow = yield* DesktopWindow.DesktopWindow; + yield* desktopWindow.runMainContentsCommand(command); +}); + const checkForUpdatesFromMenu = Effect.gen(function* () { const updates = yield* DesktopUpdates.DesktopUpdates; const electronDialog = yield* ElectronDialog.ElectronDialog; @@ -107,10 +113,8 @@ const handleCheckForUpdatesMenuClick = Effect.gen(function* () { /** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* () { - const electronApp = yield* ElectronApp.ElectronApp; const electronMenu = yield* ElectronMenu.ElectronMenu; const environment = yield* DesktopEnvironment.DesktopEnvironment; - const appName = yield* electronApp.name; const context = yield* Effect.context(); const runPromise = Effect.runPromiseWith(context); @@ -153,13 +157,16 @@ export const make = Effect.gen(function* () { const zoomClick = (direction: DesktopWindow.MainWindowZoomDirection) => () => { runMenuEffect(`zoom-${direction}`, zoomMainWindow(direction)); }; + const mainContentsClick = (command: DesktopWindow.MainWindowContentsCommand) => () => { + runMenuEffect(command, runMainContentsCommand(command)); + }; const template: Electron.MenuItemConstructorOptions[] = []; if (environment.platform === "darwin") { template.push({ - label: appName, + label: environment.displayName, submenu: [ - { role: "about" }, + { role: "about", label: `About ${environment.displayName}` }, { label: "Check for Updates...", click: checkForUpdatesClick, @@ -173,11 +180,11 @@ export const make = Effect.gen(function* () { { type: "separator" }, { role: "services" }, { type: "separator" }, - { role: "hide" }, + { role: "hide", label: `Hide ${environment.displayName}` }, { role: "hideOthers" }, { role: "unhide" }, { type: "separator" }, - { role: "quit" }, + { role: "quit", label: `Quit ${environment.displayName}` }, ], }); } @@ -230,16 +237,24 @@ export const make = Effect.gen(function* () { { label: "View", submenu: [ - { role: "reload" }, - { role: "forceReload" }, - { role: "toggleDevTools" }, - { type: "separator" }, /* - Not the zoom roles: those act on the focused webContents, so with - an embedded preview WebContentsView focused they zoom the guest - page and the app UI appears stuck. These always zoom the main - window (see DesktopWindow.zoomMain). + Not the reload, DevTools or zoom roles: those act on the focused + webContents, so with a browser page focused they reload or zoom + the guest page and the app UI appears stuck. These always target + the main window (see DesktopWindow.zoomMain). */ + { label: "Reload", accelerator: "CmdOrCtrl+R", click: mainContentsClick("reload") }, + { + label: "Force Reload", + accelerator: "Shift+CmdOrCtrl+R", + click: mainContentsClick("forceReload"), + }, + { + label: "Toggle Developer Tools", + accelerator: environment.platform === "darwin" ? "Alt+Command+I" : "Ctrl+Shift+I", + click: mainContentsClick("toggleDevTools"), + }, + { type: "separator" }, { label: "Actual Size", accelerator: "CmdOrCtrl+0", click: zoomClick("reset") }, { label: "Zoom In", accelerator: "CmdOrCtrl+=", click: zoomClick("in") }, { diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts index cb374b8103de..10c3cb8adee9 100644 --- a/apps/desktop/src/window/DesktopWindow.test.ts +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -90,10 +90,12 @@ function makeFakeBrowserWindow() { once: vi.fn<(eventName: string, listener: (...args: readonly unknown[]) => void) => void>(), openDevTools: vi.fn(), reload: vi.fn(), + reloadIgnoringCache: vi.fn(), replaceMisspelling: vi.fn(), send: vi.fn(), setBackgroundThrottling: vi.fn(), setWindowOpenHandler: vi.fn(), + toggleDevTools: vi.fn(), }; const window = { @@ -138,6 +140,8 @@ function makeFakeBrowserWindow() { maximize: window.maximize, openDevTools: webContents.openDevTools, reload: webContents.reload, + reloadIgnoringCache: webContents.reloadIgnoringCache, + toggleDevTools: webContents.toggleDevTools, send: webContents.send, setZoomLevel: webContents.setZoomLevel, setWindowButtonPosition: window.setWindowButtonPosition, @@ -227,6 +231,7 @@ function layerTest(input: { readonly onPopupTemplate?: (input: ElectronMenu.ElectronMenuTemplateInput) => Effect.Effect; readonly previewZoomReapplies?: number[]; readonly onReveal?: (window: Electron.BrowserWindow) => void; + readonly focusedWindow?: Electron.BrowserWindow; }) { let desktopSettings = input.desktopSettings ?? DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS; const layerDesktopAppSettings = Layer.succeed(DesktopAppSettings.DesktopAppSettings, { @@ -273,7 +278,10 @@ function layerTest(input: { ), main: Ref.get(input.mainWindow), currentMainOrFirst: Ref.get(input.mainWindow), - focusedMainOrFirst: Ref.get(input.mainWindow), + focusedMainOrFirst: + input.focusedWindow === undefined + ? Ref.get(input.mainWindow) + : Effect.succeedSome(input.focusedWindow), setMain: (window) => Ref.set(input.mainWindow, Option.some(window)), clearMain: () => Ref.set(input.mainWindow, Option.none()), prepareReveal: () => Effect.succeed(false), @@ -782,6 +790,37 @@ describe("DesktopWindow", () => { }), ); + it.effect("reloads and inspects the main window even when a popup has focus", () => + Effect.gen(function* () { + const fakeWindow = makeFakeBrowserWindow(); + const popup = makeFakeBrowserWindow(); + const createCount = yield* Ref.make(0); + const mainWindow = yield* Ref.make>(Option.none()); + const layer = layerTest({ + window: fakeWindow.window, + createCount, + mainWindow, + focusedWindow: popup.window, + }); + + yield* Effect.gen(function* () { + const desktopWindow = yield* DesktopWindow.DesktopWindow; + yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); + + yield* desktopWindow.runMainContentsCommand("reload"); + yield* desktopWindow.runMainContentsCommand("forceReload"); + yield* desktopWindow.runMainContentsCommand("toggleDevTools"); + + assert.equal(fakeWindow.reload.mock.calls.length, 1); + assert.equal(fakeWindow.reloadIgnoringCache.mock.calls.length, 1); + assert.equal(fakeWindow.toggleDevTools.mock.calls.length, 1); + assert.equal(popup.reload.mock.calls.length, 0); + assert.equal(popup.reloadIgnoringCache.mock.calls.length, 0); + assert.equal(popup.toggleDevTools.mock.calls.length, 0); + }).pipe(Effect.provide(layer)); + }), + ); + it.effect("keeps macOS window buttons centered when zooming and leaving fullscreen", () => Effect.gen(function* () { const fakeWindow = makeFakeBrowserWindow(); diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index 4e8fb0969ad3..6dede819356a 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -93,6 +93,7 @@ export type DesktopWindowError = | PreviewManager.PreviewManagerError; export type MainWindowZoomDirection = "in" | "out" | "reset"; +export type MainWindowContentsCommand = "reload" | "forceReload" | "toggleDevTools"; export class DesktopWindow extends Context.Service< DesktopWindow, @@ -137,6 +138,10 @@ export class DesktopWindow extends Context.Service< // guest page instead of the app UI. The menu routes here to always target // the main window. readonly zoomMain: (direction: MainWindowZoomDirection) => Effect.Effect; + // Reload and DevTools for the main window's own webContents, for the same + // reason as zoomMain: the Electron roles act on the focused webContents, + // which is a preview guest whenever a browser page has focus. + readonly runMainContentsCommand: (command: MainWindowContentsCommand) => Effect.Effect; readonly syncAppearance: Effect.Effect; } >()("@t3tools/desktop/window/DesktopWindow") {} @@ -420,6 +425,10 @@ export const make = Effect.gen(function* () { nodeIntegration: false, sandbox: true, webviewTag: true, + // A preview guest's fullscreen request is mirrored onto this embedder, + // which would otherwise put the whole window into OS fullscreen. With + // both sides opted out the page fills its webview and the window stays. + disableHtmlFullscreenWindowResize: true, }, }); @@ -527,6 +536,7 @@ export const make = Effect.gen(function* () { webPreferences.nodeIntegration = false; webPreferences.nodeIntegrationInSubFrames = false; webPreferences.contextIsolation = false; + webPreferences.disableHtmlFullscreenWindowResize = true; }); const contextMenuContents = new WeakSet(); @@ -1027,6 +1037,17 @@ export const make = Effect.gen(function* () { // own zoom, so put each guest back where the preview left it. yield* previewManager.reapplyZoom(); }), + runMainContentsCommand: Effect.fn("desktop.window.runMainContentsCommand")(function* (command) { + yield* Effect.annotateCurrentSpan({ command }); + // The registered main window, never the focused one: with an OAuth popup + // focused, Reload would otherwise reload the popup mid sign-in. + const window = yield* electronWindow.main; + if (Option.isNone(window) || window.value.isDestroyed()) return; + const webContents = window.value.webContents; + if (command === "reload") webContents.reload(); + else if (command === "forceReload") webContents.reloadIgnoringCache(); + else webContents.toggleDevTools(); + }), syncAppearance: Effect.gen(function* () { const shouldUseDarkColors = yield* electronTheme.shouldUseDarkColors; yield* electronWindow.syncAllAppearance((window) => diff --git a/apps/marketing/src/layouts/Layout.astro b/apps/marketing/src/layouts/Layout.astro index 39fa8588333a..aad20935b79c 100644 --- a/apps/marketing/src/layouts/Layout.astro +++ b/apps/marketing/src/layouts/Layout.astro @@ -78,7 +78,15 @@ const canonicalUrl = new URL(Astro.url.pathname, Astro.site);