| author | |
| committer | |
| log | 38571328a4bc3c30c56fc0cae299437fe9b53898 |
| tree | b43e427da4d79c22a62e9be28033e3bc2594b36c |
| parent | 140ee145ac33653ac2c36f7940e4f54f6e92497f |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>21 files changed, 584 insertions(+), 13 deletions(-)
apps/desktop/src/app/DesktopClerk.test.ts+59-1| ... | @@ -35,9 +35,16 @@ import * as ElectronApp from "../electron/ElectronApp.ts"; | ... | @@ -35,9 +35,16 @@ import * as ElectronApp from "../electron/ElectronApp.ts"; |
| 35 | import * as ElectronShell from "../electron/ElectronShell.ts"; | 35 | import * as ElectronShell from "../electron/ElectronShell.ts"; |
| 36 | import * as ElectronWindow from "../electron/ElectronWindow.ts"; | 36 | import * as ElectronWindow from "../electron/ElectronWindow.ts"; |
| 37 | import * as DesktopClerk from "./DesktopClerk.ts"; | 37 | import * as DesktopClerk from "./DesktopClerk.ts"; |
| 38 | import * as DesktopWebLinks from "./DesktopWebLinks.ts"; | ||
| 38 | import * as DesktopEnvironment from "./DesktopEnvironment.ts"; | 39 | import * as DesktopEnvironment from "./DesktopEnvironment.ts"; |
| 39 | import * as DesktopPreReadyFileSystem from "./DesktopPreReadyFileSystem.ts"; | 40 | import * as DesktopPreReadyFileSystem from "./DesktopPreReadyFileSystem.ts"; |
| 40 | 41 | ||
| 42 | /** Clerk forwards web links; tests that are not about links ignore them. */ | ||
| 43 | const ignoreWebLinks = DesktopWebLinks.DesktopWebLinks.of({ | ||
| 44 | receive: () => Effect.void, | ||
| 45 | setRendererReady: () => Effect.void, | ||
| 46 | }); | ||
| 47 | |||
| 41 | const layerDesktopClerk = ( | 48 | const layerDesktopClerk = ( |
| 42 | isDevelopment = true, | 49 | isDevelopment = true, |
| 43 | events: string[] = [], | 50 | events: string[] = [], |
| ... | @@ -220,11 +227,12 @@ describe("DesktopClerk", () => { | ... | @@ -220,11 +227,12 @@ describe("DesktopClerk", () => { |
| 220 | 227 | ||
| 221 | assert.isTrue(Exit.isSuccess(exit)); | 228 | assert.isTrue(Exit.isSuccess(exit)); |
| 222 | assert.equal(quit.mock.calls.length, 0); | 229 | assert.equal(quit.mock.calls.length, 0); |
| 223 | assert.deepEqual(registeredEvents, ["open-url", "second-instance"]); | 230 | assert.deepEqual(registeredEvents, ["open-url", "open-file", "second-instance"]); |
| 224 | }).pipe( | 231 | }).pipe( |
| 225 | Effect.provide(layerDesktopClerk()), | 232 | Effect.provide(layerDesktopClerk()), |
| 226 | Effect.provideService(ElectronApp.ElectronApp, electronApp), | 233 | Effect.provideService(ElectronApp.ElectronApp, electronApp), |
| 227 | Effect.provideService(ElectronWindow.ElectronWindow, electronWindow), | 234 | Effect.provideService(ElectronWindow.ElectronWindow, electronWindow), |
| 235 | Effect.provideService(DesktopWebLinks.DesktopWebLinks, ignoreWebLinks), | ||
| 228 | ); | 236 | ); |
| 229 | }); | 237 | }); |
| 230 | 238 | ||
| ... | @@ -253,6 +261,7 @@ describe("DesktopClerk", () => { | ... | @@ -253,6 +261,7 @@ describe("DesktopClerk", () => { |
| 253 | Effect.provide(layerDesktopClerk()), | 261 | Effect.provide(layerDesktopClerk()), |
| 254 | Effect.provideService(ElectronApp.ElectronApp, electronApp), | 262 | Effect.provideService(ElectronApp.ElectronApp, electronApp), |
| 255 | Effect.provideService(ElectronWindow.ElectronWindow, electronWindow), | 263 | Effect.provideService(ElectronWindow.ElectronWindow, electronWindow), |
| 264 | Effect.provideService(DesktopWebLinks.DesktopWebLinks, ignoreWebLinks), | ||
| 256 | ); | 265 | ); |
| 257 | }); | 266 | }); |
| 258 | }); | 267 | }); |
| ... | @@ -299,6 +308,7 @@ it.effect( | ... | @@ -299,6 +308,7 @@ it.effect( |
| 299 | Effect.provide(layerDesktopClerk()), | 308 | Effect.provide(layerDesktopClerk()), |
| 300 | Effect.provideService(ElectronApp.ElectronApp, electronApp), | 309 | Effect.provideService(ElectronApp.ElectronApp, electronApp), |
| 301 | Effect.provideService(ElectronWindow.ElectronWindow, electronWindow), | 310 | Effect.provideService(ElectronWindow.ElectronWindow, electronWindow), |
| 311 | Effect.provideService(DesktopWebLinks.DesktopWebLinks, ignoreWebLinks), | ||
| 302 | ); | 312 | ); |
| 303 | }, | 313 | }, |
| 304 | ); | 314 | ); |
| ... | @@ -381,6 +391,54 @@ it.effect.each(["startup", "open-url"] as const)( | ... | @@ -381,6 +391,54 @@ it.effect.each(["startup", "open-url"] as const)( |
| 381 | ElectronWindow.ElectronWindow, | 391 | ElectronWindow.ElectronWindow, |
| 382 | {} as ElectronWindow.ElectronWindow["Service"], | 392 | {} as ElectronWindow.ElectronWindow["Service"], |
| 383 | ), | 393 | ), |
| 394 | Effect.provideService(DesktopWebLinks.DesktopWebLinks, ignoreWebLinks), | ||
| 384 | ); | 395 | ); |
| 385 | }).pipe(Effect.scoped), | 396 | }).pipe(Effect.scoped), |
| 386 | ); | 397 | ); |
| 398 | |||
| 399 | it.effect("hands a web link to the renderer and leaves other links alone", () => | ||
| 400 | Effect.gen(function* () { | ||
| 401 | storageMock.mockReturnValue(storageAdapter); | ||
| 402 | createClerkBridgeMock.mockReturnValue({ cleanup: vi.fn(), isPrimaryInstance: true }); | ||
| 403 | const listeners = new Map<string, (...args: unknown[]) => void>(); | ||
| 404 | const electronApp = { | ||
| 405 | whenReady: Effect.void, | ||
| 406 | on: (name: string, listener: (...args: unknown[]) => void) => | ||
| 407 | Effect.sync(() => { | ||
| 408 | listeners.set(name, listener); | ||
| 409 | }), | ||
| 410 | } as unknown as ElectronApp.ElectronApp["Service"]; | ||
| 411 | const received: Array<string> = []; | ||
| 412 | yield* Effect.gen(function* () { | ||
| 413 | const clerk = yield* DesktopClerk.DesktopClerk; | ||
| 414 | yield* clerk.configure; | ||
| 415 | const open = (url: string) => { | ||
| 416 | const event = { preventDefault: vi.fn() }; | ||
| 417 | listeners.get("open-url")!(event, url); | ||
| 418 | return event.preventDefault.mock.calls.length; | ||
| 419 | }; | ||
| 420 | // macOS hands the default browser every web link. | ||
| 421 | assert.strictEqual(open("https://example.com/page"), 1); | ||
| 422 | assert.strictEqual(open("http://localhost:3000/"), 1); | ||
| 423 | // Anything else is not a web page; Electron keeps its own handling. | ||
| 424 | assert.strictEqual(open("mailto:hello@example.com"), 0); | ||
| 425 | yield* Effect.yieldNow; | ||
| 426 | assert.deepStrictEqual(received, ["https://example.com/page", "http://localhost:3000/"]); | ||
| 427 | }).pipe( | ||
| 428 | Effect.provide(layerDesktopClerk(true, [], "darwin")), | ||
| 429 | Effect.provideService(HostProcess.Arguments, ["t3"]), | ||
| 430 | Effect.provideService(ElectronApp.ElectronApp, electronApp), | ||
| 431 | Effect.provideService( | ||
| 432 | ElectronWindow.ElectronWindow, | ||
| 433 | {} as ElectronWindow.ElectronWindow["Service"], | ||
| 434 | ), | ||
| 435 | Effect.provideService( | ||
| 436 | DesktopWebLinks.DesktopWebLinks, | ||
| 437 | DesktopWebLinks.DesktopWebLinks.of({ | ||
| 438 | receive: (url) => Effect.sync(() => void received.push(url)), | ||
| 439 | setRendererReady: () => Effect.void, | ||
| 440 | }), | ||
| 441 | ), | ||
| 442 | ); | ||
| 443 | }).pipe(Effect.scoped), | ||
| 444 | ); |
apps/desktop/src/app/DesktopClerk.ts+21-2| ... | @@ -1,4 +1,5 @@ | ... | @@ -1,4 +1,5 @@ |
| 1 | import { createClerkBridge } from "@clerk/electron"; | 1 | import { createClerkBridge } from "@clerk/electron"; |
| 2 | import * as NodeURL from "node:url"; | ||
| 2 | import { storage } from "@clerk/electron/storage"; | 3 | import { storage } from "@clerk/electron/storage"; |
| 3 | import * as Context from "effect/Context"; | 4 | import * as Context from "effect/Context"; |
| 4 | import * as Effect from "effect/Effect"; | 5 | import * as Effect from "effect/Effect"; |
| ... | @@ -18,6 +19,7 @@ import * as ElectronProtocol from "../electron/ElectronProtocol.ts"; | ... | @@ -18,6 +19,7 @@ import * as ElectronProtocol from "../electron/ElectronProtocol.ts"; |
| 18 | import * as ElectronWindow from "../electron/ElectronWindow.ts"; | 19 | import * as ElectronWindow from "../electron/ElectronWindow.ts"; |
| 19 | import * as DesktopUserData from "./DesktopUserData.ts"; | 20 | import * as DesktopUserData from "./DesktopUserData.ts"; |
| 20 | import * as DesktopEnvironment from "./DesktopEnvironment.ts"; | 21 | import * as DesktopEnvironment from "./DesktopEnvironment.ts"; |
| 22 | import * as DesktopWebLinks from "./DesktopWebLinks.ts"; | ||
| 21 | 23 | ||
| 22 | declare const __T3CODE_BUILD_CLERK_PUBLISHABLE_KEY__: string | undefined; | 24 | declare const __T3CODE_BUILD_CLERK_PUBLISHABLE_KEY__: string | undefined; |
| 23 | 25 | ||
| ... | @@ -53,7 +55,10 @@ export class DesktopClerk extends Context.Service< | ... | @@ -53,7 +55,10 @@ export class DesktopClerk extends Context.Service< |
| 53 | readonly configure: Effect.Effect< | 55 | readonly configure: Effect.Effect< |
| 54 | void, | 56 | void, |
| 55 | never, | 57 | never, |
| 56 | ElectronApp.ElectronApp | ElectronWindow.ElectronWindow | Scope.Scope | 58 | | ElectronApp.ElectronApp |
| 59 | | ElectronWindow.ElectronWindow | ||
| 60 | | DesktopWebLinks.DesktopWebLinks | ||
| 61 | | Scope.Scope | ||
| 57 | >; | 62 | >; |
| 58 | } | 63 | } |
| 59 | >()("@t3tools/desktop/app/DesktopClerk") {} | 64 | >()("@t3tools/desktop/app/DesktopClerk") {} |
| ... | @@ -125,6 +130,7 @@ export const make = Effect.gen(function* () { | ... | @@ -125,6 +130,7 @@ export const make = Effect.gen(function* () { |
| 125 | configure: Effect.gen(function* () { | 130 | configure: Effect.gen(function* () { |
| 126 | const electronApp = yield* ElectronApp.ElectronApp; | 131 | const electronApp = yield* ElectronApp.ElectronApp; |
| 127 | const electronWindow = yield* ElectronWindow.ElectronWindow; | 132 | const electronWindow = yield* ElectronWindow.ElectronWindow; |
| 133 | const webLinks = yield* DesktopWebLinks.DesktopWebLinks; | ||
| 128 | const context = yield* Effect.context<ElectronWindow.ElectronWindow>(); | 134 | const context = yield* Effect.context<ElectronWindow.ElectronWindow>(); |
| 129 | const runPromise = Effect.runPromiseWith(context); | 135 | const runPromise = Effect.runPromiseWith(context); |
| 130 | 136 | ||
| ... | @@ -184,8 +190,21 @@ export const make = Effect.gen(function* () { | ... | @@ -184,8 +190,21 @@ export const make = Effect.gen(function* () { |
| 184 | }; | 190 | }; |
| 185 | const args = yield* HostProcess.Arguments; | 191 | const args = yield* HostProcess.Arguments; |
| 186 | args.some((value) => startProviderAuthHandoff(value)); | 192 | args.some((value) => startProviderAuthHandoff(value)); |
| 193 | // As the default browser, macOS hands T3 Code every web link through the same event. | ||
| 194 | const openWebLink = (url: string) => { | ||
| 195 | if (!DesktopWebLinks.isWebLink(url)) return false; | ||
| 196 | void runPromise(webLinks.receive(url)); | ||
| 197 | return true; | ||
| 198 | }; | ||
| 187 | yield* electronApp.on("open-url", (event: { preventDefault: () => void }, url: string) => { | 199 | yield* electronApp.on("open-url", (event: { preventDefault: () => void }, url: string) => { |
| 188 | if (startProviderAuthHandoff(url) || resumeProviderAuth(url)) event.preventDefault(); | 200 | if (startProviderAuthHandoff(url) || resumeProviderAuth(url) || openWebLink(url)) |
| 201 | event.preventDefault(); | ||
| 202 | }); | ||
| 203 | // A browser opens HTML files too, which macOS hands over by path. | ||
| 204 | yield* electronApp.on("open-file", (event: { preventDefault: () => void }, path: string) => { | ||
| 205 | if (!DesktopWebLinks.isWebPageFile(path)) return; | ||
| 206 | event.preventDefault(); | ||
| 207 | void runPromise(webLinks.receive(NodeURL.pathToFileURL(path).href)); | ||
| 189 | }); | 208 | }); |
| 190 | yield* electronApp.on("second-instance", (_event: unknown, argv: readonly string[]) => { | 209 | yield* electronApp.on("second-instance", (_event: unknown, argv: readonly string[]) => { |
| 191 | if (argv?.some((value) => startProviderAuthHandoff(value) || resumeProviderAuth(value))) | 210 | if (argv?.some((value) => startProviderAuthHandoff(value) || resumeProviderAuth(value))) |
apps/desktop/src/app/DesktopWebLinks.test.ts created+93| ... | @@ -0,0 +1,93 @@ | ||
| 1 | // @effect-diagnostics nodeBuiltinImport:off - Stands in for an Electron webContents. | ||
| 2 | import { assert, describe, it } from "@effect/vitest"; | ||
| 3 | import * as NodeEvents from "node:events"; | ||
| 4 | import * as Effect from "effect/Effect"; | ||
| 5 | import * as Layer from "effect/Layer"; | ||
| 6 | import * as Option from "effect/Option"; | ||
| 7 | |||
| 8 | import * as ElectronWindow from "../electron/ElectronWindow.ts"; | ||
| 9 | import { WEB_LINK_OPEN_CHANNEL } from "../ipc/channels.ts"; | ||
| 10 | import * as DesktopWebLinks from "./DesktopWebLinks.ts"; | ||
| 11 | |||
| 12 | const makeWindow = () => { | ||
| 13 | const sent: Array<string> = []; | ||
| 14 | const revealed: Array<unknown> = []; | ||
| 15 | const webContents = Object.assign(new NodeEvents.EventEmitter(), { | ||
| 16 | isDestroyed: () => false, | ||
| 17 | send: (channel: string, url: string) => { | ||
| 18 | if (channel === WEB_LINK_OPEN_CHANNEL) sent.push(url); | ||
| 19 | }, | ||
| 20 | }); | ||
| 21 | const window = { webContents } as unknown as Electron.BrowserWindow; | ||
| 22 | /** The web app starts loading a new page, as a reload does. */ | ||
| 23 | const reload = () => | ||
| 24 | webContents.emit("did-start-navigation", { isMainFrame: true, isSameDocument: false }); | ||
| 25 | const layer = DesktopWebLinks.layer.pipe( | ||
| 26 | Layer.provide( | ||
| 27 | Layer.succeed( | ||
| 28 | ElectronWindow.ElectronWindow, | ||
| 29 | ElectronWindow.ElectronWindow.of({ | ||
| 30 | currentMainOrFirst: Effect.succeed(Option.some(window)), | ||
| 31 | reveal: (target: Electron.BrowserWindow) => Effect.sync(() => void revealed.push(target)), | ||
| 32 | } as unknown as ElectronWindow.ElectronWindow["Service"]), | ||
| 33 | ), | ||
| 34 | ), | ||
| 35 | ); | ||
| 36 | return { sent, revealed, layer, reload }; | ||
| 37 | }; | ||
| 38 | |||
| 39 | describe("DesktopWebLinks", () => { | ||
| 40 | it("treats only http and https as web links", () => { | ||
| 41 | assert.isTrue(DesktopWebLinks.isWebLink("https://example.com/")); | ||
| 42 | assert.isTrue(DesktopWebLinks.isWebLink("http://localhost:3000/a?b=1")); | ||
| 43 | assert.isFalse(DesktopWebLinks.isWebLink("t3code://app/welcome")); | ||
| 44 | assert.isFalse(DesktopWebLinks.isWebLink("mailto:hello@example.com")); | ||
| 45 | assert.isFalse(DesktopWebLinks.isWebLink("not a url")); | ||
| 46 | }); | ||
| 47 | |||
| 48 | it("opens HTML files the way a browser does, and nothing else by path", () => { | ||
| 49 | assert.isTrue(DesktopWebLinks.isWebPageFile("/Users/me/report.html")); | ||
| 50 | assert.isTrue(DesktopWebLinks.isWebPageFile("/tmp/page.HTM")); | ||
| 51 | assert.isTrue(DesktopWebLinks.isWebPageFile("/tmp/page.xhtml")); | ||
| 52 | assert.isFalse(DesktopWebLinks.isWebPageFile("/tmp/notes.md")); | ||
| 53 | assert.isFalse(DesktopWebLinks.isWebPageFile("/tmp/html")); | ||
| 54 | }); | ||
| 55 | |||
| 56 | it.effect("holds links until the renderer listens, then delivers them in order", () => { | ||
| 57 | const { sent, revealed, layer } = makeWindow(); | ||
| 58 | return Effect.gen(function* () { | ||
| 59 | const webLinks = yield* DesktopWebLinks.DesktopWebLinks; | ||
| 60 | // The link that launched the app arrives before the web app has loaded. | ||
| 61 | yield* webLinks.receive("https://example.com/first"); | ||
| 62 | yield* webLinks.receive("https://example.com/second"); | ||
| 63 | assert.deepStrictEqual(sent, []); | ||
| 64 | |||
| 65 | yield* webLinks.setRendererReady(true); | ||
| 66 | assert.deepStrictEqual(sent, ["https://example.com/first", "https://example.com/second"]); | ||
| 67 | assert.strictEqual(revealed.length, 1); | ||
| 68 | |||
| 69 | yield* webLinks.receive("https://example.com/third"); | ||
| 70 | assert.deepStrictEqual(sent.at(-1), "https://example.com/third"); | ||
| 71 | |||
| 72 | // A reloading web app stops listening; links wait for it again. | ||
| 73 | yield* webLinks.setRendererReady(false); | ||
| 74 | yield* webLinks.receive("https://example.com/fourth"); | ||
| 75 | assert.strictEqual(sent.length, 3); | ||
| 76 | }).pipe(Effect.provide(layer)); | ||
| 77 | }); | ||
| 78 | |||
| 79 | it.effect("holds links that arrive while the web app reloads until it listens again", () => { | ||
| 80 | const { sent, layer, reload } = makeWindow(); | ||
| 81 | return Effect.gen(function* () { | ||
| 82 | const webLinks = yield* DesktopWebLinks.DesktopWebLinks; | ||
| 83 | yield* webLinks.setRendererReady(true); | ||
| 84 | // The old page never says it stopped; the reload itself ends its listening. | ||
| 85 | reload(); | ||
| 86 | yield* webLinks.receive("https://example.com/during-reload"); | ||
| 87 | assert.deepStrictEqual(sent, []); | ||
| 88 | |||
| 89 | yield* webLinks.setRendererReady(true); | ||
| 90 | assert.deepStrictEqual(sent, ["https://example.com/during-reload"]); | ||
| 91 | }).pipe(Effect.provide(layer)); | ||
| 92 | }); | ||
| 93 | }); | ||
apps/desktop/src/app/DesktopWebLinks.ts created+97| ... | @@ -0,0 +1,97 @@ | ||
| 1 | import * as Context from "effect/Context"; | ||
| 2 | import * as Effect from "effect/Effect"; | ||
| 3 | import * as Layer from "effect/Layer"; | ||
| 4 | import * as Option from "effect/Option"; | ||
| 5 | |||
| 6 | import * as ElectronWindow from "../electron/ElectronWindow.ts"; | ||
| 7 | import { WEB_LINK_OPEN_CHANNEL } from "../ipc/channels.ts"; | ||
| 8 | |||
| 9 | /** An http(s) link the operating system handed to T3 Code as the default browser. */ | ||
| 10 | export const isWebLink = (value: string) => { | ||
| 11 | if (!URL.canParse(value)) return false; | ||
| 12 | const { protocol } = new URL(value); | ||
| 13 | return protocol === "http:" || protocol === "https:"; | ||
| 14 | }; | ||
| 15 | |||
| 16 | /** An HTML file the operating system asked T3 Code to open, as browsers are asked to. */ | ||
| 17 | export const isWebPageFile = (path: string) => /\.x?html?$/i.test(path); | ||
| 18 | |||
| 19 | /** | ||
| 20 | * Web links and HTML files macOS opens with T3 Code once it is the default | ||
| 21 | * browser. Files arrive as file:// URLs. They can | ||
| 22 | * arrive before the app is ready (the link that launched it) or while the web | ||
| 23 | * app reloads, so they wait until the renderer says it is listening. | ||
| 24 | */ | ||
| 25 | export class DesktopWebLinks extends Context.Service< | ||
| 26 | DesktopWebLinks, | ||
| 27 | { | ||
| 28 | /** Queues a link and delivers it as soon as the renderer is listening. */ | ||
| 29 | readonly receive: (url: string) => Effect.Effect<void>; | ||
| 30 | /** The renderer started or stopped listening; listening flushes queued links. */ | ||
| 31 | readonly setRendererReady: (ready: boolean) => Effect.Effect<void>; | ||
| 32 | } | ||
| 33 | >()("@t3tools/desktop/app/DesktopWebLinks") {} | ||
| 34 | |||
| 35 | const make = Effect.gen(function* () { | ||
| 36 | const electronWindow = yield* ElectronWindow.ElectronWindow; | ||
| 37 | const pending: Array<string> = []; | ||
| 38 | /** The page that said it is listening; a reload or crash replaces it, and it must say so again. */ | ||
| 39 | let listening: Electron.WebContents | null = null; | ||
| 40 | let detachListening: (() => void) | null = null; | ||
| 41 | |||
| 42 | const stopListening = () => { | ||
| 43 | detachListening?.(); | ||
| 44 | detachListening = null; | ||
| 45 | listening = null; | ||
| 46 | }; | ||
| 47 | |||
| 48 | const flush = Effect.gen(function* () { | ||
| 49 | if (listening === null || pending.length === 0) return; | ||
| 50 | const window = yield* electronWindow.currentMainOrFirst; | ||
| 51 | if (Option.isNone(window) || window.value.webContents !== listening) return; | ||
| 52 | if (listening.isDestroyed()) { | ||
| 53 | stopListening(); | ||
| 54 | return; | ||
| 55 | } | ||
| 56 | for (const url of pending.splice(0)) listening.send(WEB_LINK_OPEN_CHANNEL, url); | ||
| 57 | yield* electronWindow.reveal(window.value); | ||
| 58 | }); | ||
| 59 | |||
| 60 | const startListening = Effect.gen(function* () { | ||
| 61 | const window = yield* electronWindow.currentMainOrFirst; | ||
| 62 | if (Option.isNone(window) || window.value.webContents.isDestroyed()) return; | ||
| 63 | const webContents = window.value.webContents; | ||
| 64 | if (listening === webContents) return; | ||
| 65 | stopListening(); | ||
| 66 | listening = webContents; | ||
| 67 | // Links that arrive while a new page loads wait for it to listen again. | ||
| 68 | const onNavigation = ( | ||
| 69 | event: Electron.Event<Electron.WebContentsDidStartNavigationEventParams>, | ||
| 70 | ) => { | ||
| 71 | if (event.isMainFrame && !event.isSameDocument) stopListening(); | ||
| 72 | }; | ||
| 73 | webContents.on("did-start-navigation", onNavigation); | ||
| 74 | webContents.on("render-process-gone", stopListening); | ||
| 75 | webContents.once("destroyed", stopListening); | ||
| 76 | detachListening = () => { | ||
| 77 | webContents.removeListener("did-start-navigation", onNavigation); | ||
| 78 | webContents.removeListener("render-process-gone", stopListening); | ||
| 79 | webContents.removeListener("destroyed", stopListening); | ||
| 80 | }; | ||
| 81 | }); | ||
| 82 | |||
| 83 | return DesktopWebLinks.of({ | ||
| 84 | receive: (url) => | ||
| 85 | Effect.suspend(() => { | ||
| 86 | pending.push(url); | ||
| 87 | return flush; | ||
| 88 | }).pipe(Effect.withSpan("DesktopWebLinks.receive")), | ||
| 89 | setRendererReady: (ready) => | ||
| 90 | (ready ? startListening : Effect.sync(stopListening)).pipe( | ||
| 91 | Effect.andThen(flush), | ||
| 92 | Effect.withSpan("DesktopWebLinks.setRendererReady"), | ||
| 93 | ), | ||
| 94 | }); | ||
| 95 | }); | ||
| 96 | |||
| 97 | export const layer = Layer.effect(DesktopWebLinks, make); | ||
apps/desktop/src/ipc/DesktopIpcHandlers.ts+2| ... | @@ -70,6 +70,7 @@ import { | ... | @@ -70,6 +70,7 @@ import { |
| 70 | } from "./methods/snapShot.ts"; | 70 | } from "./methods/snapShot.ts"; |
| 71 | import * as PreviewIpc from "./methods/preview.ts"; | 71 | import * as PreviewIpc from "./methods/preview.ts"; |
| 72 | import * as AppActivationIpc from "./methods/appActivation.ts"; | 72 | import * as AppActivationIpc from "./methods/appActivation.ts"; |
| 73 | import * as WebLinksIpc from "./methods/webLinks.ts"; | ||
| 73 | import { | 74 | import { |
| 74 | completeLegacyLocalStorage, | 75 | completeLegacyLocalStorage, |
| 75 | takeLegacyLocalStorage, | 76 | takeLegacyLocalStorage, |
| ... | @@ -88,6 +89,7 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" | ... | @@ -88,6 +89,7 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" |
| 88 | 89 | ||
| 89 | yield* ipc.handle(AppActivationIpc.setReady); | 90 | yield* ipc.handle(AppActivationIpc.setReady); |
| 90 | yield* ipc.handle(AppActivationIpc.complete); | 91 | yield* ipc.handle(AppActivationIpc.complete); |
| 92 | yield* ipc.handle(WebLinksIpc.setReady); | ||
| 91 | 93 | ||
| 92 | yield* ipc.handleSync(takeLegacyLocalStorage); | 94 | yield* ipc.handleSync(takeLegacyLocalStorage); |
| 93 | yield* ipc.handle(completeLegacyLocalStorage); | 95 | yield* ipc.handle(completeLegacyLocalStorage); |
apps/desktop/src/ipc/channels.ts+2| ... | @@ -10,6 +10,8 @@ export const PROBE_REMOTE_EDITORS_CHANNEL = "desktop:probe-remote-editors"; | ... | @@ -10,6 +10,8 @@ export const PROBE_REMOTE_EDITORS_CHANNEL = "desktop:probe-remote-editors"; |
| 10 | export const MENU_ACTION_CHANNEL = "desktop:menu-action"; | 10 | export const MENU_ACTION_CHANNEL = "desktop:menu-action"; |
| 11 | export const PASTE_AS_TEXT_CHANNEL = "desktop:paste-as-text"; | 11 | export const PASTE_AS_TEXT_CHANNEL = "desktop:paste-as-text"; |
| 12 | export const SNAP_SHOT_EVENT_CHANNEL = "desktop:snap-shot-event"; | 12 | export const SNAP_SHOT_EVENT_CHANNEL = "desktop:snap-shot-event"; |
| 13 | export const WEB_LINK_OPEN_CHANNEL = "desktop:web-link-open"; | ||
| 14 | export const WEB_LINK_READY_CHANNEL = "desktop:web-link-ready"; | ||
| 13 | export const QUIT_SHORTCUT_CHANNEL = "desktop:quit-shortcut"; | 15 | export const QUIT_SHORTCUT_CHANNEL = "desktop:quit-shortcut"; |
| 14 | export const TRACKPAD_SCROLL_END_CHANNEL = "desktop:trackpad-scroll-end"; | 16 | export const TRACKPAD_SCROLL_END_CHANNEL = "desktop:trackpad-scroll-end"; |
| 15 | export const GET_WINDOW_FULLSCREEN_STATE_CHANNEL = "desktop:get-window-fullscreen-state"; | 17 | export const GET_WINDOW_FULLSCREEN_STATE_CHANNEL = "desktop:get-window-fullscreen-state"; |
apps/desktop/src/ipc/methods/webLinks.ts created+16| ... | @@ -0,0 +1,16 @@ | ||
| 1 | import * as Effect from "effect/Effect"; | ||
| 2 | import * as Schema from "effect/Schema"; | ||
| 3 | |||
| 4 | import * as DesktopWebLinks from "../../app/DesktopWebLinks.ts"; | ||
| 5 | import * as IpcChannels from "../channels.ts"; | ||
| 6 | import * as DesktopIpc from "../DesktopIpc.ts"; | ||
| 7 | |||
| 8 | export const setReady = DesktopIpc.makeIpcMethod({ | ||
| 9 | channel: IpcChannels.WEB_LINK_READY_CHANNEL, | ||
| 10 | payload: Schema.Boolean, | ||
| 11 | result: Schema.Void, | ||
| 12 | handler: Effect.fn("desktop.ipc.webLinks.setReady")(function* (ready) { | ||
| 13 | const webLinks = yield* DesktopWebLinks.DesktopWebLinks; | ||
| 14 | yield* webLinks.setRendererReady(ready); | ||
| 15 | }), | ||
| 16 | }); | ||
apps/desktop/src/main.ts+2| ... | @@ -35,6 +35,7 @@ import * as ElectronUpdater from "./electron/ElectronUpdater.ts"; | ... | @@ -35,6 +35,7 @@ import * as ElectronUpdater from "./electron/ElectronUpdater.ts"; |
| 35 | import * as ElectronWindow from "./electron/ElectronWindow.ts"; | 35 | import * as ElectronWindow from "./electron/ElectronWindow.ts"; |
| 36 | import * as DesktopApp from "./app/DesktopApp.ts"; | 36 | import * as DesktopApp from "./app/DesktopApp.ts"; |
| 37 | import * as DesktopAppActivation from "./app/DesktopAppActivation.ts"; | 37 | import * as DesktopAppActivation from "./app/DesktopAppActivation.ts"; |
| 38 | import * as DesktopWebLinks from "./app/DesktopWebLinks.ts"; | ||
| 38 | import * as DesktopAppIdentity from "./app/DesktopAppIdentity.ts"; | 39 | import * as DesktopAppIdentity from "./app/DesktopAppIdentity.ts"; |
| 39 | import * as DesktopConnectionCatalogStore from "./app/DesktopConnectionCatalogStore.ts"; | 40 | import * as DesktopConnectionCatalogStore from "./app/DesktopConnectionCatalogStore.ts"; |
| 40 | import * as DesktopClerk from "./app/DesktopClerk.ts"; | 41 | import * as DesktopClerk from "./app/DesktopClerk.ts"; |
| ... | @@ -214,6 +215,7 @@ const layerDesktopLocalEnvironmentAuth = DesktopLocalEnvironmentAuth.layer.pipe( | ... | @@ -214,6 +215,7 @@ const layerDesktopLocalEnvironmentAuth = DesktopLocalEnvironmentAuth.layer.pipe( |
| 214 | const layerDesktopApplication = Layer.mergeAll( | 215 | const layerDesktopApplication = Layer.mergeAll( |
| 215 | DesktopLifecycle.layer, | 216 | DesktopLifecycle.layer, |
| 216 | layerDesktopAppActivation, | 217 | layerDesktopAppActivation, |
| 218 | DesktopWebLinks.layer, | ||
| 217 | DesktopApplicationMenu.layer, | 219 | DesktopApplicationMenu.layer, |
| 218 | DesktopLinuxUrlHandler.layer, | 220 | DesktopLinuxUrlHandler.layer, |
| 219 | DesktopCliCommand.layer, | 221 | DesktopCliCommand.layer, |
apps/desktop/src/preload.ts+12| ... | @@ -285,6 +285,18 @@ contextBridge.exposeInMainWorld("desktopBridge", { | ... | @@ -285,6 +285,18 @@ contextBridge.exposeInMainWorld("desktopBridge", { |
| 285 | ipcRenderer.removeListener(IpcChannels.UPDATE_STATE_CHANNEL, wrappedListener); | 285 | ipcRenderer.removeListener(IpcChannels.UPDATE_STATE_CHANNEL, wrappedListener); |
| 286 | }; | 286 | }; |
| 287 | }, | 287 | }, |
| 288 | webLinks: { | ||
| 289 | setReady: (ready) => ipcRenderer.invoke(IpcChannels.WEB_LINK_READY_CHANNEL, ready), | ||
| 290 | onOpen: (listener) => { | ||
| 291 | const wrappedListener = (_event: Electron.IpcRendererEvent, url: unknown) => { | ||
| 292 | if (typeof url === "string") listener(url); | ||
| 293 | }; | ||
| 294 | ipcRenderer.on(IpcChannels.WEB_LINK_OPEN_CHANNEL, wrappedListener); | ||
| 295 | return () => { | ||
| 296 | ipcRenderer.removeListener(IpcChannels.WEB_LINK_OPEN_CHANNEL, wrappedListener); | ||
| 297 | }; | ||
| 298 | }, | ||
| 299 | }, | ||
| 288 | appActivation: { | 300 | appActivation: { |
| 289 | setReady: (ready) => | 301 | setReady: (ready) => |
| 290 | ipcRenderer.invoke(IpcChannels.DESKTOP_APP_ACTIVATION_READY_CHANNEL, ready), | 302 | ipcRenderer.invoke(IpcChannels.DESKTOP_APP_ACTIVATION_READY_CHANNEL, ready), |
apps/web/src/components/ChatView.tsx+7| ... | @@ -6056,6 +6056,13 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -6056,6 +6056,13 @@ export default function ChatView(props: ChatViewProps) { |
| 6056 | if (!activeThreadRef) return; | 6056 | if (!activeThreadRef) return; |
| 6057 | useRightPanelStore.getState().toggleThreadPanel(activeThreadRef, threadPanelPresentation); | 6057 | useRightPanelStore.getState().toggleThreadPanel(activeThreadRef, threadPanelPresentation); |
| 6058 | }, [activeThreadRef, threadPanelPresentation]); | 6058 | }, [activeThreadRef, threadPanelPresentation]); |
| 6059 | // A thread started for a link the OS opened shows its browser maximized, like a browser window. | ||
| 6060 | useEffect(() => { | ||
| 6061 | if (!canMaximizeRightPanel) return; | ||
| 6062 | if (useRightPanelStore.getState().consumeMaximizeRequest(routeThreadRef)) { | ||
| 6063 | setMaximizedRightPanelThreadKey(routeThreadKey); | ||
| 6064 | } | ||
| 6065 | }, [canMaximizeRightPanel, routeThreadKey, routeThreadRef]); | ||
| 6059 | const toggleRightPanelMaximized = useCallback(() => { | 6066 | const toggleRightPanelMaximized = useCallback(() => { |
| 6060 | if (!canMaximizeRightPanel) return; | 6067 | if (!canMaximizeRightPanel) return; |
| 6061 | setMaximizedRightPanelThreadKey((threadKey) => | 6068 | setMaximizedRightPanelThreadKey((threadKey) => |
apps/web/src/components/chat/DraftHeroHeadline.tsx+12-4| ... | @@ -1,5 +1,5 @@ | ... | @@ -1,5 +1,5 @@ |
| 1 | import type { DraftId } from "~/composerDraftStore"; | 1 | import type { DraftId } from "~/composerDraftStore"; |
| 2 | import { useComposerDraftStore } from "~/composerDraftStore"; | 2 | import { composerDraftHasUserContent, useComposerDraftStore } from "~/composerDraftStore"; |
| 3 | import { resolveEnvironmentMachineKind, type ScopedProjectRef } from "@t3tools/contracts"; | 3 | import { resolveEnvironmentMachineKind, type ScopedProjectRef } from "@t3tools/contracts"; |
| 4 | import { scopedProjectKey, scopeProjectRef } from "@t3tools/client-runtime/environment"; | 4 | import { scopedProjectKey, scopeProjectRef } from "@t3tools/client-runtime/environment"; |
| 5 | import { isScratchProject } from "@t3tools/client-runtime/state/projects"; | 5 | import { isScratchProject } from "@t3tools/client-runtime/state/projects"; |
| ... | @@ -11,6 +11,7 @@ import { openCommandPalette } from "~/commandPaletteBus"; | ... | @@ -11,6 +11,7 @@ import { openCommandPalette } from "~/commandPaletteBus"; |
| 11 | import { shortcutLabelForCommand } from "~/keybindings"; | 11 | import { shortcutLabelForCommand } from "~/keybindings"; |
| 12 | import { projectIconColorClassName } from "~/projectIconColors"; | 12 | import { projectIconColorClassName } from "~/projectIconColors"; |
| 13 | import { primaryServerKeybindingsAtom } from "~/state/server"; | 13 | import { primaryServerKeybindingsAtom } from "~/state/server"; |
| 14 | import { useNewThreadHandler } from "~/hooks/useHandleNewThread"; | ||
| 14 | import { useScratchProject } from "~/hooks/useScratchProject"; | 15 | import { useScratchProject } from "~/hooks/useScratchProject"; |
| 15 | import { useClientSettings } from "~/hooks/useSettings"; | 16 | import { useClientSettings } from "~/hooks/useSettings"; |
| 16 | import { hasExplicitComposerModelSelection } from "~/lib/chatThreadActions"; | 17 | import { hasExplicitComposerModelSelection } from "~/lib/chatThreadActions"; |
| ... | @@ -69,6 +70,7 @@ export function DraftHeroHeadline({ | ... | @@ -69,6 +70,7 @@ export function DraftHeroHeadline({ |
| 69 | const setModelSelection = useComposerDraftStore((store) => store.setModelSelection); | 70 | const setModelSelection = useComposerDraftStore((store) => store.setModelSelection); |
| 70 | const openAddProject = useCallback(() => openCommandPalette({ open: "add-project" }), []); | 71 | const openAddProject = useCallback(() => openCommandPalette({ open: "add-project" }), []); |
| 71 | const { scratchEnvironmentId, scratchWorkspaceRootFor, openScratchProject } = useScratchProject(); | 72 | const { scratchEnvironmentId, scratchWorkspaceRootFor, openScratchProject } = useScratchProject(); |
| 73 | const openProjectDraft = useNewThreadHandler(); | ||
| 72 | const keybindings = useAtomValue(primaryServerKeybindingsAtom); | 74 | const keybindings = useAtomValue(primaryServerKeybindingsAtom); |
| 73 | 75 | ||
| 74 | const environmentLabelById = useMemo( | 76 | const environmentLabelById = useMemo( |
| ... | @@ -172,9 +174,11 @@ export function DraftHeroHeadline({ | ... | @@ -172,9 +174,11 @@ export function DraftHeroHeadline({ |
| 172 | useEffect(() => { | 174 | useEffect(() => { |
| 173 | latestTargetRef.current = { draftId, activeProjectKey, scratchTargetEnvironmentId }; | 175 | latestTargetRef.current = { draftId, activeProjectKey, scratchTargetEnvironmentId }; |
| 174 | }, [activeProjectKey, scratchTargetEnvironmentId, draftId]); | 176 | }, [activeProjectKey, scratchTargetEnvironmentId, draftId]); |
| 175 | // Project selection changes the target of the open draft in place. The | 177 | // With a prompt typed, project selection changes the target of the open |
| 176 | // prompt stays in the same composer session, so the sidebar only gets a | 178 | // draft in place, so the prompt stays in the same composer session. An empty |
| 177 | // draft row if the user later navigates away. | 179 | // draft instead opens the chosen project's own draft, like starting a new |
| 180 | // thread there: moving it would replace that draft and strand whatever it | ||
| 181 | // holds, such as the browser tabs of the no-project draft. | ||
| 178 | const selectProject = (project: (typeof projects)[number], logicalProjectKey: string) => { | 182 | const selectProject = (project: (typeof projects)[number], logicalProjectKey: string) => { |
| 179 | if (!draftId) { | 183 | if (!draftId) { |
| 180 | return; | 184 | return; |
| ... | @@ -185,6 +189,10 @@ export function DraftHeroHeadline({ | ... | @@ -185,6 +189,10 @@ export function DraftHeroHeadline({ |
| 185 | scratchTargetEnvironmentId: project.environmentId, | 189 | scratchTargetEnvironmentId: project.environmentId, |
| 186 | }; | 190 | }; |
| 187 | const currentDraft = getComposerDraft(draftId); | 191 | const currentDraft = getComposerDraft(draftId); |
| 192 | if (!composerDraftHasUserContent(currentDraft)) { | ||
| 193 | void openProjectDraft(scopeProjectRef(project.environmentId, project.id)); | ||
| 194 | return; | ||
| 195 | } | ||
| 188 | setLogicalProjectDraftThreadId( | 196 | setLogicalProjectDraftThreadId( |
| 189 | logicalProjectKey, | 197 | logicalProjectKey, |
| 190 | scopeProjectRef(project.environmentId, project.id), | 198 | scopeProjectRef(project.environmentId, project.id), |
apps/web/src/components/desktop/DesktopWebLinkCoordinator.tsx created+146| ... | @@ -0,0 +1,146 @@ | ||
| 1 | import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment"; | ||
| 2 | import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; | ||
| 3 | import type { EnvironmentId, ScopedThreadRef } from "@t3tools/contracts"; | ||
| 4 | import { useRouter } from "@tanstack/react-router"; | ||
| 5 | import { useEffect, useEffectEvent, useRef } from "react"; | ||
| 6 | |||
| 7 | import { openFileInPreview, openUrlInPreview } from "../../browser/openFileInPreview"; | ||
| 8 | import { isPreviewAvailableFor } from "../../browser/previewRuntime"; | ||
| 9 | import { useComposerDraftStore } from "../../composerDraftStore"; | ||
| 10 | import { useNewThreadHandler } from "../../hooks/useHandleNewThread"; | ||
| 11 | import { useScratchProject } from "../../hooks/useScratchProject"; | ||
| 12 | import { selectActiveRightPanelSurface, useRightPanelStore } from "../../rightPanelStore"; | ||
| 13 | import { resolveThreadRouteTarget } from "../../threadRoutes"; | ||
| 14 | import { assetEnvironment } from "../../state/assets"; | ||
| 15 | import { useEnvironmentHttpBaseUrl, usePrimaryEnvironment } from "../../state/environments"; | ||
| 16 | import { previewEnvironment } from "../../state/preview"; | ||
| 17 | import { useAtomCommand } from "../../state/use-atom-command"; | ||
| 18 | import { useAtomQueryRunner } from "../../state/use-atom-query-runner"; | ||
| 19 | import { stackedThreadToast, toastManager } from "../ui/toast"; | ||
| 20 | |||
| 21 | /** | ||
| 22 | * The thread on screen when its browser panel is showing, so a link opens as | ||
| 23 | * another tab there, the way a browser window takes new links. HTML files are | ||
| 24 | * served by this machine's environment, so only its threads can take them. | ||
| 25 | */ | ||
| 26 | function threadShowingBrowser( | ||
| 27 | routeParams: Record<string, string | undefined>, | ||
| 28 | fileEnvironmentId: EnvironmentId | null, | ||
| 29 | ): ScopedThreadRef | null { | ||
| 30 | const target = resolveThreadRouteTarget(routeParams); | ||
| 31 | if (target === null) return null; | ||
| 32 | let threadRef = target.kind === "server" ? target.threadRef : null; | ||
| 33 | if (target.kind === "draft") { | ||
| 34 | const session = useComposerDraftStore.getState().getDraftSession(target.draftId); | ||
| 35 | if (!session || session.promotedTo) return null; | ||
| 36 | threadRef = scopeThreadRef(session.environmentId, session.threadId); | ||
| 37 | } | ||
| 38 | if (threadRef === null || !isPreviewAvailableFor(threadRef.environmentId)) return null; | ||
| 39 | if (fileEnvironmentId !== null && threadRef.environmentId !== fileEnvironmentId) return null; | ||
| 40 | const surface = selectActiveRightPanelSurface( | ||
| 41 | useRightPanelStore.getState().byThreadKey, | ||
| 42 | threadRef, | ||
| 43 | ); | ||
| 44 | return surface?.kind === "preview" ? threadRef : null; | ||
| 45 | } | ||
| 46 | |||
| 47 | /** | ||
| 48 | * Opens each web link or HTML file the OS hands T3 Code as the default browser | ||
| 49 | * (macOS). With a browser panel on screen the page opens as a new tab there; | ||
| 50 | * otherwise it starts a thread without a project, its browser panel maximized. | ||
| 51 | */ | ||
| 52 | export function DesktopWebLinkCoordinator() { | ||
| 53 | const router = useRouter(); | ||
| 54 | const primaryEnvironment = usePrimaryEnvironment(); | ||
| 55 | const { scratchEnvironmentId, openScratchProject } = useScratchProject(); | ||
| 56 | const openThread = useNewThreadHandler(); | ||
| 57 | const openPreview = useAtomCommand(previewEnvironment.open, { reportFailure: false }); | ||
| 58 | const createAssetUrl = useAtomQueryRunner(assetEnvironment.createUrl, { | ||
| 59 | reportFailure: false, | ||
| 60 | refresh: true, | ||
| 61 | }); | ||
| 62 | const httpBaseUrl = useEnvironmentHttpBaseUrl(primaryEnvironment?.environmentId ?? null); | ||
| 63 | const queueRef = useRef(Promise.resolve()); | ||
| 64 | const webLinks = window.desktopBridge?.webLinks; | ||
| 65 | const ready = | ||
| 66 | webLinks !== undefined && | ||
| 67 | primaryEnvironment?.connection.phase === "connected" && | ||
| 68 | primaryEnvironment.serverConfig !== null; | ||
| 69 | |||
| 70 | const reportFailure = (url: string, description = url) => | ||
| 71 | toastManager.add( | ||
| 72 | stackedThreadToast({ type: "error", title: "Could not open the link", description }), | ||
| 73 | ); | ||
| 74 | |||
| 75 | // The OS hands each link over once, so a thread that could not start says so. | ||
| 76 | const startThread = async (url: string): Promise<ScopedThreadRef | null> => { | ||
| 77 | const environmentId = scratchEnvironmentId(primaryEnvironment?.environmentId ?? null); | ||
| 78 | if (environmentId === null) { | ||
| 79 | reportFailure(url); | ||
| 80 | return null; | ||
| 81 | } | ||
| 82 | // Reports its own failure. | ||
| 83 | const project = await openScratchProject(environmentId, "Could not open the link"); | ||
| 84 | if (!project) return null; | ||
| 85 | const opened = await openThread(scopeProjectRef(project.environmentId, project.id)); | ||
| 86 | if (!opened) { | ||
| 87 | reportFailure(url); | ||
| 88 | return null; | ||
| 89 | } | ||
| 90 | const threadRef = scopeThreadRef(project.environmentId, opened.threadId); | ||
| 91 | useRightPanelStore.getState().requestMaximize(threadRef); | ||
| 92 | return threadRef; | ||
| 93 | }; | ||
| 94 | |||
| 95 | const openLink = useEffectEvent(async (url: string) => { | ||
| 96 | const isFile = url.startsWith("file:"); | ||
| 97 | const routeParams = router.state.matches.at(-1)?.params ?? {}; | ||
| 98 | const threadRef = | ||
| 99 | threadShowingBrowser( | ||
| 100 | routeParams, | ||
| 101 | isFile ? (primaryEnvironment?.environmentId ?? null) : null, | ||
| 102 | ) ?? (await startThread(url)); | ||
| 103 | if (threadRef === null) return; | ||
| 104 | // An HTML file is served from the environment, which runs on this machine. | ||
| 105 | const result = isFile | ||
| 106 | ? httpBaseUrl === null | ||
| 107 | ? null | ||
| 108 | : await openFileInPreview({ | ||
| 109 | threadRef, | ||
| 110 | filePath: decodeURIComponent(new URL(url).pathname), | ||
| 111 | workspaceRoot: undefined, | ||
| 112 | httpBaseUrl, | ||
| 113 | createAssetUrl, | ||
| 114 | openPreview, | ||
| 115 | }) | ||
| 116 | : await openUrlInPreview({ threadRef, url, openPreview }); | ||
| 117 | if (result === null) { | ||
| 118 | reportFailure(url); | ||
| 119 | return; | ||
| 120 | } | ||
| 121 | if (result._tag === "Failure") { | ||
| 122 | const error = squashAtomCommandFailure(result); | ||
| 123 | reportFailure(url, error instanceof Error ? error.message : url); | ||
| 124 | } | ||
| 125 | }); | ||
| 126 | |||
| 127 | useEffect(() => { | ||
| 128 | if (!ready || webLinks === undefined) return; | ||
| 129 | let subscribed = true; | ||
| 130 | // Links open one at a time, in the order they came. | ||
| 131 | const unsubscribe = webLinks.onOpen((url) => { | ||
| 132 | queueRef.current = queueRef.current.then(() => openLink(url)).catch(() => undefined); | ||
| 133 | }); | ||
| 134 | // Skip readiness if React runs cleanup before this subscription can receive links. | ||
| 135 | queueMicrotask(() => { | ||
| 136 | if (subscribed) void webLinks.setReady(true).catch(() => undefined); | ||
| 137 | }); | ||
| 138 | return () => { | ||
| 139 | subscribed = false; | ||
| 140 | void webLinks.setReady(false).catch(() => undefined); | ||
| 141 | unsubscribe(); | ||
| 142 | }; | ||
| 143 | }, [ready, webLinks]); | ||
| 144 | |||
| 145 | return null; | ||
| 146 | } | ||
apps/web/src/composerDraftStore.test.ts+25| ... | @@ -89,6 +89,7 @@ import { | ... | @@ -89,6 +89,7 @@ import { |
| 89 | type TerminalContextDraft, | 89 | type TerminalContextDraft, |
| 90 | } from "./lib/terminalContext"; | 90 | } from "./lib/terminalContext"; |
| 91 | import { createDeferredStorage } from "./lib/storage"; | 91 | import { createDeferredStorage } from "./lib/storage"; |
| 92 | import { applyPreviewServerSnapshot, resetPreviewStateForTests } from "./previewStateStore"; | ||
| 92 | 93 | ||
| 93 | function makeImage(input: { | 94 | function makeImage(input: { |
| 94 | id: string; | 95 | id: string; |
| ... | @@ -1467,6 +1468,30 @@ describe("composerDraftStore project draft thread mapping", () => { | ... | @@ -1467,6 +1468,30 @@ describe("composerDraftStore project draft thread mapping", () => { |
| 1467 | expect(draftByKey(draftId)).toBeUndefined(); | 1468 | expect(draftByKey(draftId)).toBeUndefined(); |
| 1468 | }); | 1469 | }); |
| 1469 | 1470 | ||
| 1471 | it("keeps a draft with an open page when remapping a project to a new draft thread", () => { | ||
| 1472 | const store = useComposerDraftStore.getState(); | ||
| 1473 | store.setProjectDraftThreadId(projectRef, draftId, { threadId }); | ||
| 1474 | // A link another app opened: the composer is empty, but the page is not. | ||
| 1475 | applyPreviewServerSnapshot(scopeThreadRef(TEST_ENVIRONMENT_ID, threadId), { | ||
| 1476 | threadId, | ||
| 1477 | tabId: "tab-link", | ||
| 1478 | navStatus: { _tag: "Success", url: "https://example.com/", title: "Example" }, | ||
| 1479 | canGoBack: false, | ||
| 1480 | canGoForward: false, | ||
| 1481 | updatedAt: "2026-10-09T00:00:00.000Z", | ||
| 1482 | }); | ||
| 1483 | try { | ||
| 1484 | store.setProjectDraftThreadId(projectRef, otherDraftId, { threadId: otherThreadId }); | ||
| 1485 | |||
| 1486 | expect(useComposerDraftStore.getState().getDraftThread(draftId)?.threadId).toBe(threadId); | ||
| 1487 | // It survives a reload too, so the page is still there when the app comes back. | ||
| 1488 | const persisted = partializeComposerDraftStoreState(useComposerDraftStore.getState()); | ||
| 1489 | expect(persisted.draftThreadsByThreadKey[draftId]?.threadId).toBe(threadId); | ||
| 1490 | } finally { | ||
| 1491 | resetPreviewStateForTests(); | ||
| 1492 | } | ||
| 1493 | }); | ||
| 1494 | |||
| 1470 | it("keeps invested composer drafts alive unmapped when remapping a project to a new draft thread", () => { | 1495 | it("keeps invested composer drafts alive unmapped when remapping a project to a new draft thread", () => { |
| 1471 | const store = useComposerDraftStore.getState(); | 1496 | const store = useComposerDraftStore.getState(); |
| 1472 | store.setProjectDraftThreadId(projectRef, draftId, { threadId }); | 1497 | store.setProjectDraftThreadId(projectRef, draftId, { threadId }); |
apps/web/src/composerDraftStore.ts+19-6| ... | @@ -40,6 +40,7 @@ import { DeepMutable } from "effect/Types"; | ... | @@ -40,6 +40,7 @@ import { DeepMutable } from "effect/Types"; |
| 40 | import { createModelSelection, normalizeModelSlug } from "@t3tools/shared/model"; | 40 | import { createModelSelection, normalizeModelSlug } from "@t3tools/shared/model"; |
| 41 | import { useMemo } from "react"; | 41 | import { useMemo } from "react"; |
| 42 | import { getLocalStorageItem } from "./hooks/useLocalStorage"; | 42 | import { getLocalStorageItem } from "./hooks/useLocalStorage"; |
| 43 | import { readThreadPreviewState } from "./previewStateStore"; | ||
| 43 | import { resolveAppModelSelection, resolveAppModelSelectionForInstance } from "./modelSelection"; | 44 | import { resolveAppModelSelection, resolveAppModelSelectionForInstance } from "./modelSelection"; |
| 44 | import { | 45 | import { |
| 45 | DEFAULT_INTERACTION_MODE, | 46 | DEFAULT_INTERACTION_MODE, |
| ... | @@ -1556,6 +1557,16 @@ function getComposerDraftState( | ... | @@ -1556,6 +1557,16 @@ function getComposerDraftState( |
| 1556 | return state.draftsByThreadKey[threadKey] ?? null; | 1557 | return state.draftsByThreadKey[threadKey] ?? null; |
| 1557 | } | 1558 | } |
| 1558 | 1559 | ||
| 1560 | /** | ||
| 1561 | * A draft holding an open page, such as one started for a link another app | ||
| 1562 | * opened, is work in progress even with an empty composer. | ||
| 1563 | */ | ||
| 1564 | function draftThreadHasOpenPreview(draftThread: DraftThreadState | undefined): boolean { | ||
| 1565 | if (draftThread === undefined) return false; | ||
| 1566 | const ref = scopeThreadRef(draftThread.environmentId, draftThread.threadId); | ||
| 1567 | return Object.keys(readThreadPreviewState(ref).sessions).length > 0; | ||
| 1568 | } | ||
| 1569 | |||
| 1559 | function isComposerThreadKeyInUse(mappings: Record<string, string>, threadKey: string): boolean { | 1570 | function isComposerThreadKeyInUse(mappings: Record<string, string>, threadKey: string): boolean { |
| 1560 | return Object.values(mappings).includes(threadKey); | 1571 | return Object.values(mappings).includes(threadKey); |
| 1561 | } | 1572 | } |
| ... | @@ -2174,10 +2185,10 @@ export function partializeComposerDraftStoreState( | ... | @@ -2174,10 +2185,10 @@ export function partializeComposerDraftStoreState( |
| 2174 | state: ComposerDraftStoreState, | 2185 | state: ComposerDraftStoreState, |
| 2175 | ): PersistedComposerDraftStoreState { | 2186 | ): PersistedComposerDraftStoreState { |
| 2176 | // Draft sessions worth persisting: mapped (a new-thread flow targets | 2187 | // Draft sessions worth persisting: mapped (a new-thread flow targets |
| 2177 | // them), promoting (mid-send), or holding real user content (they back a | 2188 | // them), promoting (mid-send), holding real user content (they back a |
| 2178 | // sidebar row). Everything else is a zombie — and its composer blob must | 2189 | // sidebar row), or holding an open page. Everything else is a zombie — and |
| 2179 | // be dropped WITH it, or model/mode-only entries would persist forever | 2190 | // its composer blob must be dropped WITH it, or model/mode-only entries |
| 2180 | // keyed to a session that no longer exists. | 2191 | // would persist forever keyed to a session that no longer exists. |
| 2181 | const mappedDraftKeys = new Set( | 2192 | const mappedDraftKeys = new Set( |
| 2182 | Object.values(state.logicalProjectDraftThreadKeyByLogicalProjectKey), | 2193 | Object.values(state.logicalProjectDraftThreadKeyByLogicalProjectKey), |
| 2183 | ); | 2194 | ); |
| ... | @@ -2187,7 +2198,8 @@ export function partializeComposerDraftStoreState( | ... | @@ -2187,7 +2198,8 @@ export function partializeComposerDraftStoreState( |
| 2187 | ([threadKey, draftThread]) => | 2198 | ([threadKey, draftThread]) => |
| 2188 | mappedDraftKeys.has(threadKey) || | 2199 | mappedDraftKeys.has(threadKey) || |
| 2189 | isDraftThreadPromoting(draftThread) || | 2200 | isDraftThreadPromoting(draftThread) || |
| 2190 | composerDraftHasUserContent(state.draftsByThreadKey[threadKey]), | 2201 | composerDraftHasUserContent(state.draftsByThreadKey[threadKey]) || |
| 2202 | draftThreadHasOpenPreview(draftThread), | ||
| 2191 | ) | 2203 | ) |
| 2192 | .map(([threadKey]) => threadKey), | 2204 | .map(([threadKey]) => threadKey), |
| 2193 | ); | 2205 | ); |
| ... | @@ -2792,7 +2804,8 @@ const composerDraftStore = create<ComposerDraftStoreState>()( | ... | @@ -2792,7 +2804,8 @@ const composerDraftStore = create<ComposerDraftStoreState>()( |
| 2792 | !isDraftThreadPromoting(previousDraftThread) && | 2804 | !isDraftThreadPromoting(previousDraftThread) && |
| 2793 | !composerDraftHasUserContent( | 2805 | !composerDraftHasUserContent( |
| 2794 | state.draftsByThreadKey[previousThreadKeyForLogicalProject], | 2806 | state.draftsByThreadKey[previousThreadKeyForLogicalProject], |
| 2795 | ) | 2807 | ) && |
| 2808 | !draftThreadHasOpenPreview(previousDraftThread) | ||
| 2796 | ) { | 2809 | ) { |
| 2797 | delete nextDraftThreadsByThreadKey[previousThreadKeyForLogicalProject]; | 2810 | delete nextDraftThreadsByThreadKey[previousThreadKeyForLogicalProject]; |
| 2798 | if (state.draftsByThreadKey[previousThreadKeyForLogicalProject] !== undefined) { | 2811 | if (state.draftsByThreadKey[previousThreadKeyForLogicalProject] !== undefined) { |
apps/web/src/rightPanelStore.ts+14| ... | @@ -121,6 +121,13 @@ export interface ThreadPanelVisibility { | ... | @@ -121,6 +121,13 @@ export interface ThreadPanelVisibility { |
| 121 | 121 | ||
| 122 | interface RightPanelStoreState { | 122 | interface RightPanelStoreState { |
| 123 | byThreadKey: Record<string, ThreadRightPanelState>; | 123 | byThreadKey: Record<string, ThreadRightPanelState>; |
| 124 | /** | ||
| 125 | * A thread whose right panel should open maximized the next time it shows, | ||
| 126 | * such as one started for a link the OS opened. Its view consumes it once. | ||
| 127 | */ | ||
| 128 | pendingMaximizeThreadKey: string | null; | ||
| 129 | requestMaximize: (ref: ScopedThreadRef) => void; | ||
| 130 | consumeMaximizeRequest: (ref: ScopedThreadRef) => boolean; | ||
| 124 | threadPanelVisibilityByThreadKey: Record<string, ThreadPanelVisibility>; | 131 | threadPanelVisibilityByThreadKey: Record<string, ThreadPanelVisibility>; |
| 125 | /** Session-only count of user panel choices per thread. Automatic updates do not advance it. */ | 132 | /** Session-only count of user panel choices per thread. Automatic updates do not advance it. */ |
| 126 | userActionRevisionByThreadKey: Record<string, number>; | 133 | userActionRevisionByThreadKey: Record<string, number>; |
| ... | @@ -590,6 +597,13 @@ export const useRightPanelStore = create<RightPanelStoreState>()( | ... | @@ -590,6 +597,13 @@ export const useRightPanelStore = create<RightPanelStoreState>()( |
| 590 | persist( | 597 | persist( |
| 591 | (set, get) => ({ | 598 | (set, get) => ({ |
| 592 | byThreadKey: {}, | 599 | byThreadKey: {}, |
| 600 | pendingMaximizeThreadKey: null, | ||
| 601 | requestMaximize: (ref) => set({ pendingMaximizeThreadKey: scopedThreadKey(ref) }), | ||
| 602 | consumeMaximizeRequest: (ref) => { | ||
| 603 | if (get().pendingMaximizeThreadKey !== scopedThreadKey(ref)) return false; | ||
| 604 | set({ pendingMaximizeThreadKey: null }); | ||
| 605 | return true; | ||
| 606 | }, | ||
| 593 | threadPanelVisibilityByThreadKey: {}, | 607 | threadPanelVisibilityByThreadKey: {}, |
| 594 | userActionRevisionByThreadKey: {}, | 608 | userActionRevisionByThreadKey: {}, |
| 595 | closeRevisionByThreadKey: {}, | 609 | closeRevisionByThreadKey: {}, |
apps/web/src/routes/__root.tsx+2| ... | @@ -27,6 +27,7 @@ import { RelayClientInstallDialog } from "../components/cloud/RelayClientInstall | ... | @@ -27,6 +27,7 @@ import { RelayClientInstallDialog } from "../components/cloud/RelayClientInstall |
| 27 | import { SshPasswordPromptDialog } from "../components/desktop/SshPasswordPromptDialog"; | 27 | import { SshPasswordPromptDialog } from "../components/desktop/SshPasswordPromptDialog"; |
| 28 | import { SnapShotCoordinator } from "../components/desktop/SnapShotCoordinator"; | 28 | import { SnapShotCoordinator } from "../components/desktop/SnapShotCoordinator"; |
| 29 | import { DesktopAppActivationCoordinator } from "../components/desktop/DesktopAppActivationCoordinator"; | 29 | import { DesktopAppActivationCoordinator } from "../components/desktop/DesktopAppActivationCoordinator"; |
| 30 | import { DesktopWebLinkCoordinator } from "../components/desktop/DesktopWebLinkCoordinator"; | ||
| 30 | import { RunningThreadKeepAlive } from "../components/desktop/RunningThreadKeepAlive"; | 31 | import { RunningThreadKeepAlive } from "../components/desktop/RunningThreadKeepAlive"; |
| 31 | import { ProviderUpdateLaunchNotification } from "../components/ProviderUpdateLaunchNotification"; | 32 | import { ProviderUpdateLaunchNotification } from "../components/ProviderUpdateLaunchNotification"; |
| 32 | import { NightlyMobileBetaNotice } from "../components/NightlyMobileBeta"; | 33 | import { NightlyMobileBetaNotice } from "../components/NightlyMobileBeta"; |
| ... | @@ -228,6 +229,7 @@ function RootRouteView() { | ... | @@ -228,6 +229,7 @@ function RootRouteView() { |
| 228 | > | 229 | > |
| 229 | {primaryEnvironmentAuthenticated ? <AuthenticatedTracingBootstrap /> : null} | 230 | {primaryEnvironmentAuthenticated ? <AuthenticatedTracingBootstrap /> : null} |
| 230 | {primaryEnvironmentAuthenticated ? <DesktopAppActivationCoordinator /> : null} | 231 | {primaryEnvironmentAuthenticated ? <DesktopAppActivationCoordinator /> : null} |
| 232 | {primaryEnvironmentAuthenticated ? <DesktopWebLinkCoordinator /> : null} | ||
| 231 | {isElectron ? <RunningThreadKeepAlive /> : null} | 233 | {isElectron ? <RunningThreadKeepAlive /> : null} |
| 232 | <RelayClientInstallDialog /> | 234 | <RelayClientInstallDialog /> |
| 233 | <ConnectOnboardingDialog /> | 235 | <ConnectOnboardingDialog /> |
docs/README.md+1| ... | @@ -14,6 +14,7 @@ | ... | @@ -14,6 +14,7 @@ |
| 14 | - [SnapShots](./user/snap-shot.md) | 14 | - [SnapShots](./user/snap-shot.md) |
| 15 | - [Visual replies](./user/html-renders.md) | 15 | - [Visual replies](./user/html-renders.md) |
| 16 | - [Import browser sessions](./user/browser-import.md) | 16 | - [Import browser sessions](./user/browser-import.md) |
| 17 | - [Use T3 Code as your default browser](./user/default-browser.md) | ||
| 17 | - [Devices](./user/devices.md) | 18 | - [Devices](./user/devices.md) |
| 18 | - [Usage and limits](./user/usage.md) | 19 | - [Usage and limits](./user/usage.md) |
| 19 | - [Product usage data](./user/telemetry.md) | 20 | - [Product usage data](./user/telemetry.md) |
docs/user/default-browser.md created+13| ... | @@ -0,0 +1,13 @@ | ||
| 1 | # Use T3 Code as your default browser | ||
| 2 | |||
| 3 | On macOS, the desktop app can be your default web browser. Links you open from other apps then | ||
| 4 | open in T3 Code. When the thread on screen shows its browser panel, the link opens there as a new | ||
| 5 | tab. Otherwise it starts a new thread without a project, with the browser panel maximized on the | ||
| 6 | page, so you can ask an agent about it right away. | ||
| 7 | |||
| 8 | Choose **T3 Code** under **System Settings → Desktop & Dock → Default web browser**. To go back, | ||
| 9 | choose another browser there. | ||
| 10 | |||
| 11 | HTML files you open from Finder open the same way. | ||
| 12 | |||
| 13 | A link that launches T3 Code waits until the app has connected to its environment, then opens. | ||
packages/contracts/src/ipc.ts+8| ... | @@ -1246,6 +1246,14 @@ export interface DesktopBridge { | ... | @@ -1246,6 +1246,14 @@ export interface DesktopBridge { |
| 1246 | uninstall: () => Promise<DesktopCliCommandState>; | 1246 | uninstall: () => Promise<DesktopCliCommandState>; |
| 1247 | }; | 1247 | }; |
| 1248 | /** Present when the desktop shell accepts `t3 app` activation requests. */ | 1248 | /** Present when the desktop shell accepts `t3 app` activation requests. */ |
| 1249 | /** | ||
| 1250 | * Web links the OS opens with T3 Code as the default browser (macOS). The | ||
| 1251 | * renderer reports when it listens; links queue in the main process until then. | ||
| 1252 | */ | ||
| 1253 | webLinks?: { | ||
| 1254 | setReady: (ready: boolean) => Promise<void>; | ||
| 1255 | onOpen: (listener: (url: string) => void) => () => void; | ||
| 1256 | }; | ||
| 1249 | appActivation?: { | 1257 | appActivation?: { |
| 1250 | setReady: (ready: boolean) => Promise<void>; | 1258 | setReady: (ready: boolean) => Promise<void>; |
| 1251 | complete: (response: DesktopAppActivationResponse) => Promise<void>; | 1259 | complete: (response: DesktopAppActivationResponse) => Promise<void>; |
scripts/build-desktop-artifact.test.ts+16| ... | @@ -698,6 +698,21 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { | ... | @@ -698,6 +698,21 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { |
| 698 | assert.deepStrictEqual((linux.linux as Record<string, unknown>).protocols, [ | 698 | assert.deepStrictEqual((linux.linux as Record<string, unknown>).protocols, [ |
| 699 | { name: "T3 Code", schemes: ["t3code", "t3code-dev"] }, | 699 | { name: "T3 Code", schemes: ["t3code", "t3code-dev"] }, |
| 700 | ]); | 700 | ]); |
| 701 | // macOS also offers itself as a web browser, so it can be chosen as the default. | ||
| 702 | assert.deepStrictEqual((mac.mac as Record<string, unknown>).protocols, [ | ||
| 703 | { name: "T3 Code", schemes: ["t3code", "t3code-dev"] }, | ||
| 704 | { name: "Web site URL", schemes: ["http", "https"], role: "Viewer" }, | ||
| 705 | ]); | ||
| 706 | // macOS lists a default browser only when it also opens web pages as documents. | ||
| 707 | const macInfo = (mac.mac as { extendInfo: Record<string, unknown> }).extendInfo; | ||
| 708 | assert.deepStrictEqual(macInfo.CFBundleDocumentTypes, [ | ||
| 709 | { | ||
| 710 | CFBundleTypeName: "Web page", | ||
| 711 | CFBundleTypeRole: "Viewer", | ||
| 712 | LSHandlerRank: "Alternate", | ||
| 713 | LSItemContentTypes: ["public.html", "public.xhtml"], | ||
| 714 | }, | ||
| 715 | ]); | ||
| 701 | assert.deepStrictEqual(linux.toolsets, { appimage: "1.0.3" }); | 716 | assert.deepStrictEqual(linux.toolsets, { appimage: "1.0.3" }); |
| 702 | assert.notProperty(mac, "toolsets"); | 717 | assert.notProperty(mac, "toolsets"); |
| 703 | assert.notProperty(win, "toolsets"); | 718 | assert.notProperty(win, "toolsets"); |
| ... | @@ -2082,6 +2097,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { | ... | @@ -2082,6 +2097,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { |
| 2082 | assert.match(String(mac.sign), /[\\/]scripts[\\/]sign-macos\.ts$/); | 2097 | assert.match(String(mac.sign), /[\\/]scripts[\\/]sign-macos\.ts$/); |
| 2083 | assert.deepStrictEqual(mac.protocols, [ | 2098 | assert.deepStrictEqual(mac.protocols, [ |
| 2084 | { name: "T3 Code", schemes: ["t3code", "t3code-dev"] }, | 2099 | { name: "T3 Code", schemes: ["t3code", "t3code-dev"] }, |
| 2100 | { name: "Web site URL", schemes: ["http", "https"], role: "Viewer" }, | ||
| 2085 | ]); | 2101 | ]); |
| 2086 | }).pipe(Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env: {} })))), | 2102 | }).pipe(Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env: {} })))), |
| 2087 | ); | 2103 | ); |
scripts/build-desktop-artifact.ts+17| ... | @@ -2813,12 +2813,29 @@ export const createBuildConfig = Effect.fn("createBuildConfig")(function* ( | ... | @@ -2813,12 +2813,29 @@ export const createBuildConfig = Effect.fn("createBuildConfig")(function* ( |
| 2813 | extendInfo: { | 2813 | extendInfo: { |
| 2814 | NSScreenCaptureUsageDescription: | 2814 | NSScreenCaptureUsageDescription: |
| 2815 | "T3 Code captures the active window when you use the window capture shortcut.", | 2815 | "T3 Code captures the active window when you use the window capture shortcut.", |
| 2816 | // macOS lists an app under Default web browser only when it opens web | ||
| 2817 | // pages as documents as well as http and https links (see protocols). | ||
| 2818 | CFBundleDocumentTypes: [ | ||
| 2819 | { | ||
| 2820 | CFBundleTypeName: "Web page", | ||
| 2821 | CFBundleTypeRole: "Viewer", | ||
| 2822 | LSHandlerRank: "Alternate", | ||
| 2823 | LSItemContentTypes: ["public.html", "public.xhtml"], | ||
| 2824 | }, | ||
| 2825 | ], | ||
| 2816 | }, | 2826 | }, |
| 2817 | protocols: [ | 2827 | protocols: [ |
| 2818 | { | 2828 | { |
| 2819 | name: "T3 Code", | 2829 | name: "T3 Code", |
| 2820 | schemes: ["t3code", "t3code-dev"], | 2830 | schemes: ["t3code", "t3code-dev"], |
| 2821 | }, | 2831 | }, |
| 2832 | // Lets people choose T3 Code as their default web browser, which opens | ||
| 2833 | // each link in a new thread's browser panel. | ||
| 2834 | { | ||
| 2835 | name: "Web site URL", | ||
| 2836 | schemes: ["http", "https"], | ||
| 2837 | role: "Viewer", | ||
| 2838 | }, | ||
| 2822 | ], | 2839 | ], |
| 2823 | ...(signed ? { sign: path.join(repoRoot, "scripts/sign-macos.ts") } : {}), | 2840 | ...(signed ? { sign: path.join(repoRoot, "scripts/sign-macos.ts") } : {}), |
| 2824 | ...(macPasskeySigning | 2841 | ...(macPasskeySigning |