diff --git a/apps/desktop/src/app/DesktopApp.ts b/apps/desktop/src/app/DesktopApp.ts index fd86c5f05d2..e91e57a4d84 100644 --- a/apps/desktop/src/app/DesktopApp.ts +++ b/apps/desktop/src/app/DesktopApp.ts @@ -12,6 +12,7 @@ import * as ElectronProtocol from "../electron/ElectronProtocol.ts"; import { installDesktopIpcHandlers } from "../ipc/DesktopIpcHandlers.ts"; import * as DesktopAppIdentity from "./DesktopAppIdentity.ts"; import * as DesktopClerk from "./DesktopClerk.ts"; +import * as DesktopDeepLinks from "./DesktopDeepLinks.ts"; import * as DesktopApplicationMenu from "../window/DesktopApplicationMenu.ts"; import * as DesktopWindow from "../window/DesktopWindow.ts"; import * as DesktopBackendPool from "../backend/DesktopBackendPool.ts"; @@ -20,6 +21,7 @@ import * as DesktopLifecycle from "./DesktopLifecycle.ts"; import * as DesktopObservability from "./DesktopObservability.ts"; import * as DesktopShutdown from "./DesktopShutdown.ts"; import * as DesktopServerExposure from "../backend/DesktopServerExposure.ts"; +import { readLiveExistingBackend } from "../backend/DesktopExistingBackend.ts"; import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; import * as DesktopShellEnvironment from "../shell/DesktopShellEnvironment.ts"; import * as DesktopState from "./DesktopState.ts"; @@ -151,7 +153,18 @@ const bootstrap = Effect.gen(function* () { return yield* new DesktopDevelopmentBackendPortRequiredError(); } - const backendPortSelection = yield* resolveDesktopBackendPort(environment.configuredBackendPort); + const existingBackend = readLiveExistingBackend(environment.stateDir); + const existingBackendPort = existingBackend + ? Option.some(new URL(existingBackend.httpBaseUrl).port).pipe( + Option.flatMap((port) => { + const parsed = Number.parseInt(port, 10); + return Number.isSafeInteger(parsed) ? Option.some(parsed) : Option.none(); + }), + ) + : Option.none(); + const backendPortSelection = yield* resolveDesktopBackendPort( + Option.orElse(existingBackendPort, () => environment.configuredBackendPort), + ); const backendPort = backendPortSelection.port; yield* logBootstrapInfo( backendPortSelection.selectedByScan @@ -162,6 +175,13 @@ const bootstrap = Effect.gen(function* () { ...(backendPortSelection.selectedByScan ? { startPort: DEFAULT_DESKTOP_BACKEND_PORT } : {}), }, ); + if (existingBackend) { + yield* logBootstrapInfo("reusing existing backend for shared T3 home", { + pid: existingBackend.pid, + httpBaseUrl: existingBackend.httpBaseUrl, + stateDir: existingBackend.stateDir, + }); + } const settings = yield* desktopSettings.get; if (settings.serverExposureMode !== environment.defaultDesktopSettings.serverExposureMode) { @@ -213,6 +233,12 @@ const bootstrap = Effect.gen(function* () { // slow first wsl.exe spawn. yield* Effect.forkScoped(wslBackend.reconcile); } + + // Catalog + window services are usable; flush any deep link captured from + // initial argv / open-url during single-instance setup. + const deepLinks = yield* DesktopDeepLinks.DesktopDeepLinks; + yield* deepLinks.start; + yield* logBootstrapInfo("bootstrap deep links ready"); }).pipe(Effect.withSpan("desktop.bootstrap")); const startup = Effect.gen(function* () { diff --git a/apps/desktop/src/app/DesktopClerk.test.ts b/apps/desktop/src/app/DesktopClerk.test.ts index 9b5ed56d1f3..2e1058b878a 100644 --- a/apps/desktop/src/app/DesktopClerk.test.ts +++ b/apps/desktop/src/app/DesktopClerk.test.ts @@ -2,6 +2,7 @@ import { assert, describe, it } from "@effect/vitest"; import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Ref from "effect/Ref"; import { beforeEach, vi } from "vite-plus/test"; const { createClerkBridgeMock, storageAdapter, storageMock } = vi.hoisted(() => ({ @@ -22,13 +23,16 @@ vi.mock("@clerk/electron/storage", () => ({ storage: storageMock, })); +import * as ElectronApp from "../electron/ElectronApp.ts"; import * as DesktopClerk from "./DesktopClerk.ts"; +import * as DesktopDeepLinks from "./DesktopDeepLinks.ts"; import * as DesktopEnvironment from "./DesktopEnvironment.ts"; -const makeDesktopClerkLayer = (isDevelopment = true) => { +const makeDesktopClerkLayer = (isDevelopment = true, isPackaged = false) => { const environment = DesktopEnvironment.DesktopEnvironment.of({ stateDir: "/tmp/t3-state", isDevelopment, + isPackaged, } as unknown as DesktopEnvironment.DesktopEnvironment["Service"]); return DesktopClerk.layer.pipe( @@ -146,4 +150,150 @@ describe("DesktopClerk", () => { storageMock.mockClear(); createClerkBridgeMock.mockClear(); }); + + it.effect( + "wires second-instance argv into deep links and registers the protocol when packaged", + () => { + storageMock.mockReturnValue(storageAdapter); + createClerkBridgeMock.mockReturnValue({ cleanup: vi.fn() }); + + return Effect.gen(function* () { + const handledArgv = yield* Ref.make>([]); + const handledUrls = yield* Ref.make([]); + const listeners = new Map void>(); + let protocolClientRegistered = false; + + const deepLinksLayer = Layer.succeed(DesktopDeepLinks.DesktopDeepLinks, { + handleArgv: (argv) => + Ref.update(handledArgv, (items) => [...items, argv]).pipe(Effect.asVoid), + handleUrl: (url) => + Ref.update(handledUrls, (items) => [...items, url]).pipe(Effect.asVoid), + start: Effect.void, + } satisfies DesktopDeepLinks.DesktopDeepLinks["Service"]); + + const electronAppLayer = Layer.succeed(ElectronApp.ElectronApp, { + metadata: Effect.die("unexpected metadata"), + name: Effect.succeed("T3 Code"), + whenReady: Effect.void, + quit: Effect.void, + exit: () => Effect.void, + relaunch: () => Effect.void, + setPath: () => Effect.void, + setName: () => Effect.void, + setAboutPanelOptions: () => Effect.void, + setAppUserModelId: () => Effect.void, + requestSingleInstanceLock: Effect.succeed(true), + isDefaultProtocolClient: () => Effect.succeed(false), + setAsDefaultProtocolClient: (protocol: string) => + Effect.sync(() => { + protocolClientRegistered = protocol === "t3code"; + return true; + }), + setDesktopName: () => Effect.void, + setDockIcon: () => Effect.void, + appendCommandLineSwitch: () => Effect.void, + on: >( + eventName: string, + listener: (...args: Args) => void, + ) => + Effect.sync(() => { + listeners.set(eventName, listener as (...args: readonly unknown[]) => void); + }).pipe(Effect.asVoid), + } as unknown as ElectronApp.ElectronApp["Service"]); + + const runtimeLayer = Layer.mergeAll( + makeDesktopClerkLayer(false, true), + deepLinksLayer, + electronAppLayer, + ); + + yield* Effect.scoped( + Effect.gen(function* () { + const clerk = yield* DesktopClerk.DesktopClerk; + yield* clerk.configure; + + assert.isTrue(protocolClientRegistered); + assert.isTrue(listeners.has("second-instance")); + assert.isTrue(listeners.has("open-url")); + + // Initial process.argv is captured during configure. + const initialHandled = yield* Ref.get(handledArgv); + assert.isTrue(initialHandled.length >= 1); + + const secondInstance = listeners.get("second-instance"); + assert.isDefined(secondInstance); + secondInstance?.({}, [ + "t3code", + "t3code://open/thread?connection=t3vm&thread=ebf3a84d-7f60-4809-a5e0-bbd574275463", + ]); + // Allow the fire-and-forget runPromise callback to settle. + yield* Effect.yieldNow; + yield* Effect.yieldNow; + + const afterSecond = yield* Ref.get(handledArgv); + assert.isTrue( + afterSecond.some((argv) => + argv.some((entry) => entry.startsWith("t3code://open/thread")), + ), + ); + + const openUrl = listeners.get("open-url"); + assert.isDefined(openUrl); + const preventDefault = vi.fn(); + openUrl?.( + { preventDefault }, + "t3code://open/thread?connection=t3vm&thread=aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + ); + yield* Effect.yieldNow; + yield* Effect.yieldNow; + assert.equal(preventDefault.mock.calls.length, 1); + const urls = yield* Ref.get(handledUrls); + assert.deepEqual(urls, [ + "t3code://open/thread?connection=t3vm&thread=aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + ]); + }).pipe(Effect.provide(runtimeLayer)), + ); + }); + }, + ); + + it.effect("does not register the OS protocol client in development", () => { + storageMock.mockReturnValue(storageAdapter); + createClerkBridgeMock.mockReturnValue({ cleanup: vi.fn() }); + + return Effect.gen(function* () { + let protocolClientRegistered = false; + + const deepLinksLayer = Layer.succeed(DesktopDeepLinks.DesktopDeepLinks, { + handleArgv: () => Effect.void, + handleUrl: () => Effect.void, + start: Effect.void, + } satisfies DesktopDeepLinks.DesktopDeepLinks["Service"]); + + const electronAppLayer = Layer.succeed(ElectronApp.ElectronApp, { + requestSingleInstanceLock: Effect.succeed(true), + setAsDefaultProtocolClient: () => + Effect.sync(() => { + protocolClientRegistered = true; + return true; + }), + on: () => Effect.void, + quit: Effect.void, + } as unknown as ElectronApp.ElectronApp["Service"]); + + const runtimeLayer = Layer.mergeAll( + makeDesktopClerkLayer(true, false), + deepLinksLayer, + electronAppLayer, + ); + + yield* Effect.scoped( + Effect.gen(function* () { + const clerk = yield* DesktopClerk.DesktopClerk; + yield* clerk.configure; + assert.isFalse(protocolClientRegistered); + }).pipe(Effect.provide(runtimeLayer)), + ); + }); + }); }); diff --git a/apps/desktop/src/app/DesktopClerk.ts b/apps/desktop/src/app/DesktopClerk.ts index 0e283f8dd0c..8aa1fac2a94 100644 --- a/apps/desktop/src/app/DesktopClerk.ts +++ b/apps/desktop/src/app/DesktopClerk.ts @@ -3,14 +3,13 @@ import { storage } from "@clerk/electron/storage"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; -import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; import { clerkFrontendApiHostnameFromPublishableKey } from "@t3tools/shared/relayAuth"; import * as ElectronApp from "../electron/ElectronApp.ts"; import * as ElectronProtocol from "../electron/ElectronProtocol.ts"; -import * as ElectronWindow from "../electron/ElectronWindow.ts"; +import * as DesktopDeepLinks from "./DesktopDeepLinks.ts"; import * as DesktopEnvironment from "./DesktopEnvironment.ts"; declare const __T3CODE_BUILD_CLERK_PUBLISHABLE_KEY__: string | undefined; @@ -47,7 +46,7 @@ export class DesktopClerk extends Context.Service< readonly configure: Effect.Effect< void, never, - ElectronApp.ElectronApp | ElectronWindow.ElectronWindow | Scope.Scope + DesktopDeepLinks.DesktopDeepLinks | ElectronApp.ElectronApp | Scope.Scope >; } >()("@t3tools/desktop/app/DesktopClerk") {} @@ -109,8 +108,9 @@ export const make = Effect.gen(function* () { return DesktopClerk.of({ configure: Effect.gen(function* () { const electronApp = yield* ElectronApp.ElectronApp; - const electronWindow = yield* ElectronWindow.ElectronWindow; - const context = yield* Effect.context(); + const deepLinks = yield* DesktopDeepLinks.DesktopDeepLinks; + // Capture ambient services for Electron event callbacks, which cannot yield. + const context = yield* Effect.context(); const runPromise = Effect.runPromiseWith(context); if (!(yield* electronApp.requestSingleInstanceLock)) { @@ -118,16 +118,27 @@ export const make = Effect.gen(function* () { return yield* Effect.interrupt; } - yield* electronApp.on("second-instance", () => { - void runPromise( - Effect.gen(function* () { - const mainWindow = yield* electronWindow.currentMainOrFirst; - if (Option.isSome(mainWindow)) { - yield* electronWindow.reveal(mainWindow.value); - } - }), - ); + // Register before readiness so cold-start and second-instance deep links + // are not dropped. Deep-link processing itself queues until start(). + yield* electronApp.on("second-instance", (_event: unknown, argv: readonly string[] = []) => { + void runPromise(deepLinks.handleArgv(argv)); + }); + + // macOS delivers custom URL scheme activations through open-url. + yield* electronApp.on("open-url", (event: { preventDefault?: () => void }, url: string) => { + event.preventDefault?.(); + void runPromise(deepLinks.handleUrl(url)); }); + + // Packaged builds own the OS protocol handler. Skip in development so a + // local electron binary does not replace the installed t3code handler. + if (environment.isPackaged && !environment.isDevelopment) { + yield* electronApp.setAsDefaultProtocolClient(DesktopDeepLinks.DESKTOP_EXTERNAL_PROTOCOL); + } + + // Initial argv may already contain a deep link (direct CLI invocation or + // protocol launch on Linux/Windows). + yield* deepLinks.handleArgv(process.argv); }).pipe(Effect.withSpan("desktop.clerk.configure")), }); }); diff --git a/apps/desktop/src/app/DesktopDeepLinks.test.ts b/apps/desktop/src/app/DesktopDeepLinks.test.ts new file mode 100644 index 00000000000..d96d3b4619c --- /dev/null +++ b/apps/desktop/src/app/DesktopDeepLinks.test.ts @@ -0,0 +1,363 @@ +import { assert, describe, it } from "@effect/vitest"; +import { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Ref from "effect/Ref"; + +import * as DesktopWindow from "../window/DesktopWindow.ts"; +import * as DesktopConnectionCatalogStore from "./DesktopConnectionCatalogStore.ts"; +import * as DesktopDeepLinks from "./DesktopDeepLinks.ts"; + +const THREAD_ID = ThreadId.make("ebf3a84d-7f60-4809-a5e0-bbd574275463"); +const ENVIRONMENT_ID = EnvironmentId.make("db6d1813-ace4-42bd-9bce-e04ee27e97ff"); +const VALID_DEEP_LINK = + "t3code://open/thread?connection=t3vm&thread=ebf3a84d-7f60-4809-a5e0-bbd574275463"; + +function catalogJson(targets: readonly { label: string; environmentId: string }[]): string { + return JSON.stringify({ + schemaVersion: 1, + targets: targets.map((target) => ({ + _tag: "BearerConnectionTarget", + environmentId: target.environmentId, + label: target.label, + connectionId: `bearer:${target.environmentId}`, + })), + profiles: [], + credentials: [], + remoteDpopTokens: [], + }); +} + +describe("DesktopDeepLinks parsing", () => { + it("parses a valid thread deep link from mixed argv", () => { + const parsed = DesktopDeepLinks.findDesktopThreadDeepLinkInArgv([ + "/usr/bin/t3code", + "--enable-features=Foo", + VALID_DEEP_LINK, + "--some-flag", + ]); + assert.isTrue(Option.isSome(parsed)); + assert.equal(parsed._tag === "Some" ? parsed.value.connectionLabel : null, "t3vm"); + assert.equal( + parsed._tag === "Some" ? parsed.value.threadId : null, + "ebf3a84d-7f60-4809-a5e0-bbd574275463", + ); + }); + + it("percent-decodes the connection label exactly once", () => { + const parsed = DesktopDeepLinks.parseDesktopThreadDeepLink( + "t3code://open/thread?connection=t3%2Fvm&thread=ebf3a84d-7f60-4809-a5e0-bbd574275463", + ); + assert.isTrue(Option.isSome(parsed)); + assert.equal(parsed._tag === "Some" ? parsed.value.connectionLabel : null, "t3/vm"); + }); + + it("parses project jumps with short or full repository names", () => { + const latest = DesktopDeepLinks.parseDesktopProjectDeepLink( + "t3code://open/project?project=macs-holding%2Fscanner&action=latest", + ); + assert.deepEqual(Option.getOrNull(latest), { + project: "macs-holding/scanner", + action: "latest", + }); + const reveal = DesktopDeepLinks.parseDesktopProjectDeepLink( + "t3code://open/project?project=scanner", + ); + assert.deepEqual(Option.getOrNull(reveal), { project: "scanner", action: "reveal" }); + }); + + it("rejects invalid project jump actions and duplicate project values", () => { + assert.isTrue( + Option.isNone( + DesktopDeepLinks.parseDesktopProjectDeepLink( + "t3code://open/project?project=scanner&action=remove", + ), + ), + ); + assert.isTrue( + Option.isNone( + DesktopDeepLinks.parseDesktopProjectDeepLink( + "t3code://open/project?project=scanner&project=configurator", + ), + ), + ); + }); + + it("parses URL-like environment hosts in short and FQDN forms", () => { + for (const [raw, expectedLabel] of [ + ["t3code://t3vm?thread=ebf3a84d-7f60-4809-a5e0-bbd574275463", "t3vm"], + ["t3code://t3vm.long.host?thread=ebf3a84d-7f60-4809-a5e0-bbd574275463", "t3vm.long.host"], + ] as const) { + const parsed = DesktopDeepLinks.parseDesktopThreadDeepLink(raw); + assert.isTrue(Option.isSome(parsed)); + assert.equal(parsed._tag === "Some" ? parsed.value.connectionLabel : null, expectedLabel); + } + }); + + it("rejects wrong schemes, hosts, paths, missing/duplicate values, controls, and oversized values", () => { + const rejects = [ + "https://open/thread?connection=t3vm&thread=ebf3a84d-7f60-4809-a5e0-bbd574275463", + "t3code://close/thread?connection=t3vm&thread=ebf3a84d-7f60-4809-a5e0-bbd574275463", + "t3code://open/settings?connection=t3vm&thread=ebf3a84d-7f60-4809-a5e0-bbd574275463", + "t3code://open/thread?thread=ebf3a84d-7f60-4809-a5e0-bbd574275463", + "t3code://open/thread?connection=t3vm", + "t3code://open/thread?connection=t3vm&connection=other&thread=ebf3a84d-7f60-4809-a5e0-bbd574275463", + "t3code://open/thread?connection=t3vm&thread=a&thread=b", + "t3code://t3vm?connection=other&thread=ebf3a84d-7f60-4809-a5e0-bbd574275463", + "t3code://t3vm/path?thread=ebf3a84d-7f60-4809-a5e0-bbd574275463", + "t3code://open/thread?connection=t3vm&thread=ebf3a84d-7f60-4809-a5e0-bbd574275463\u0000", + `t3code://open/thread?connection=${"x".repeat(300)}&thread=ebf3a84d-7f60-4809-a5e0-bbd574275463`, + "not-a-url", + "t3code://app/", + ]; + for (const raw of rejects) { + assert.isTrue( + Option.isNone(DesktopDeepLinks.parseDesktopThreadDeepLink(raw)), + `expected rejection for ${JSON.stringify(raw)}`, + ); + } + }); + + it("builds a hash-history navigation URL on the desktop origin", () => { + assert.equal( + DesktopDeepLinks.buildDesktopThreadNavigationUrl({ + isDevelopment: false, + environmentId: ENVIRONMENT_ID, + threadId: THREAD_ID, + }), + "t3code://app/#/db6d1813-ace4-42bd-9bce-e04ee27e97ff/ebf3a84d-7f60-4809-a5e0-bbd574275463", + ); + assert.equal( + DesktopDeepLinks.buildDesktopThreadNavigationUrl({ + isDevelopment: true, + environmentId: ENVIRONMENT_ID, + threadId: THREAD_ID, + }), + "t3code-dev://app/#/db6d1813-ace4-42bd-9bce-e04ee27e97ff/ebf3a84d-7f60-4809-a5e0-bbd574275463", + ); + }); +}); + +describe("DesktopDeepLinks catalog resolution", () => { + it("resolves a unique connection label to its environment id", () => { + const resolution = DesktopDeepLinks.resolveEnvironmentIdForConnectionLabel( + catalogJson([ + { label: "t3vm", environmentId: "db6d1813-ace4-42bd-9bce-e04ee27e97ff" }, + { label: "other", environmentId: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" }, + ]), + "t3vm", + ); + assert.equal(resolution._tag, "resolved"); + if (resolution._tag === "resolved") { + assert.equal(resolution.environmentId, "db6d1813-ace4-42bd-9bce-e04ee27e97ff"); + } + }); + + it("resolves short and fully qualified host labels in either direction", () => { + const fqdnTarget = DesktopDeepLinks.resolveEnvironmentIdForConnectionLabel( + catalogJson([ + { label: "t3vm.long.host", environmentId: "db6d1813-ace4-42bd-9bce-e04ee27e97ff" }, + ]), + "t3vm", + ); + assert.equal(fqdnTarget._tag, "resolved"); + + const shortTarget = DesktopDeepLinks.resolveEnvironmentIdForConnectionLabel( + catalogJson([{ label: "t3vm", environmentId: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" }]), + "t3vm.long.host", + ); + assert.equal(shortTarget._tag, "resolved"); + }); + + it("prefers an exact configured label over a short-host fallback", () => { + const resolution = DesktopDeepLinks.resolveEnvironmentIdForConnectionLabel( + catalogJson([ + { label: "t3vm.long.host", environmentId: "db6d1813-ace4-42bd-9bce-e04ee27e97ff" }, + { label: "t3vm", environmentId: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" }, + ]), + "t3vm", + ); + assert.equal(resolution._tag, "resolved"); + if (resolution._tag === "resolved") { + assert.equal(resolution.environmentId, "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"); + } + }); + + it("keeps ambiguous short matches ambiguous and does not equate distinct FQDNs", () => { + const catalog = catalogJson([ + { label: "t3vm.one.host", environmentId: "db6d1813-ace4-42bd-9bce-e04ee27e97ff" }, + { label: "t3vm.two.host", environmentId: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" }, + ]); + assert.equal( + DesktopDeepLinks.resolveEnvironmentIdForConnectionLabel(catalog, "t3vm")._tag, + "ambiguous", + ); + assert.equal( + DesktopDeepLinks.resolveEnvironmentIdForConnectionLabel(catalog, "t3vm.three.host")._tag, + "missing", + ); + }); + + it("refuses missing and duplicate labels", () => { + assert.equal( + DesktopDeepLinks.resolveEnvironmentIdForConnectionLabel( + catalogJson([{ label: "other", environmentId: "db6d1813-ace4-42bd-9bce-e04ee27e97ff" }]), + "t3vm", + )._tag, + "missing", + ); + assert.equal( + DesktopDeepLinks.resolveEnvironmentIdForConnectionLabel( + catalogJson([ + { label: "t3vm", environmentId: "db6d1813-ace4-42bd-9bce-e04ee27e97ff" }, + { label: "t3vm", environmentId: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" }, + ]), + "t3vm", + )._tag, + "ambiguous", + ); + }); +}); + +describe("DesktopDeepLinks service delivery", () => { + const makeHarness = Effect.gen(function* () { + const navigations = yield* Ref.make>([]); + const projectNavigations = yield* Ref.make< + Array<{ project: string; action: "reveal" | "latest" | "new" }> + >([]); + const activations = yield* Ref.make(0); + const catalogJsonRef = yield* Ref.make( + Option.some( + catalogJson([ + { + label: "t3vm", + environmentId: "db6d1813-ace4-42bd-9bce-e04ee27e97ff", + }, + ]), + ), + ); + + const windowLayer = Layer.succeed(DesktopWindow.DesktopWindow, { + createMain: Effect.die("unexpected createMain"), + ensureMain: Effect.die("unexpected ensureMain"), + revealOrCreateMain: Effect.die("unexpected revealOrCreateMain"), + activate: Ref.update(activations, (count) => count + 1), + createMainIfBackendReady: Effect.void, + showConnectingSplash: Effect.void, + handleBackendReady: () => Effect.void, + handleBackendNotReady: Effect.void, + flushMainWindowBounds: Effect.void, + dispatchMenuAction: () => Effect.void, + syncAppearance: Effect.void, + navigateToThread: (input: { readonly environmentId: string; readonly threadId: string }) => + Ref.update(navigations, (items) => [ + ...items, + { environmentId: input.environmentId, threadId: input.threadId }, + ]), + navigateToProject: (input: { + readonly project: string; + readonly action: "reveal" | "latest" | "new"; + }) => Ref.update(projectNavigations, (items) => [...items, input]), + } as unknown as DesktopWindow.DesktopWindow["Service"]); + + const catalogLayer = Layer.succeed( + DesktopConnectionCatalogStore.DesktopConnectionCatalogStore, + { + get: Ref.get(catalogJsonRef), + set: () => Effect.succeed(true), + clear: Effect.void, + } satisfies DesktopConnectionCatalogStore.DesktopConnectionCatalogStore["Service"], + ); + + const layer = DesktopDeepLinks.layer.pipe( + Layer.provide(windowLayer), + Layer.provide(catalogLayer), + ); + + return { layer, navigations, projectNavigations, activations, catalogJsonRef } as const; + }); + + it.effect("queues initial-launch delivery until start, then opens the thread", () => + Effect.gen(function* () { + const harness = yield* makeHarness; + yield* Effect.gen(function* () { + const deepLinks = yield* DesktopDeepLinks.DesktopDeepLinks; + yield* deepLinks.handleArgv(["t3code", VALID_DEEP_LINK]); + assert.deepEqual(yield* Ref.get(harness.navigations), []); + yield* deepLinks.start; + assert.deepEqual(yield* Ref.get(harness.navigations), [ + { + environmentId: "db6d1813-ace4-42bd-9bce-e04ee27e97ff", + threadId: "ebf3a84d-7f60-4809-a5e0-bbd574275463", + }, + ]); + }).pipe(Effect.provide(harness.layer)); + }), + ); + + it.effect("delivers running-instance deep links through handleArgv after start", () => + Effect.gen(function* () { + const harness = yield* makeHarness; + yield* Effect.gen(function* () { + const deepLinks = yield* DesktopDeepLinks.DesktopDeepLinks; + yield* deepLinks.start; + yield* deepLinks.handleArgv([ + "t3code", + "t3code://open/thread?connection=t3vm&thread=aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + ]); + assert.deepEqual(yield* Ref.get(harness.navigations), [ + { + environmentId: "db6d1813-ace4-42bd-9bce-e04ee27e97ff", + threadId: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + }, + ]); + }).pipe(Effect.provide(harness.layer)); + }), + ); + + it.effect("delivers a project jump without resolving an environment in Desktop", () => + Effect.gen(function* () { + const harness = yield* makeHarness; + yield* Effect.gen(function* () { + const deepLinks = yield* DesktopDeepLinks.DesktopDeepLinks; + yield* deepLinks.start; + yield* deepLinks.handleUrl( + "t3code://open/project?project=macs-holding%2Fscanner&action=new", + ); + assert.deepEqual(yield* Ref.get(harness.projectNavigations), [ + { project: "macs-holding/scanner", action: "new" }, + ]); + }).pipe(Effect.provide(harness.layer)); + }), + ); + + it.effect("preserves reveal-only behavior for ordinary second launches", () => + Effect.gen(function* () { + const harness = yield* makeHarness; + yield* Effect.gen(function* () { + const deepLinks = yield* DesktopDeepLinks.DesktopDeepLinks; + yield* deepLinks.start; + yield* deepLinks.handleArgv(["t3code"]); + assert.equal(yield* Ref.get(harness.activations), 1); + assert.deepEqual(yield* Ref.get(harness.navigations), []); + }).pipe(Effect.provide(harness.layer)); + }), + ); + + it.effect("reveals without navigating when the connection label is missing", () => + Effect.gen(function* () { + const harness = yield* makeHarness; + yield* Effect.gen(function* () { + const deepLinks = yield* DesktopDeepLinks.DesktopDeepLinks; + yield* deepLinks.start; + yield* deepLinks.handleArgv([ + "t3code", + "t3code://open/thread?connection=missing&thread=ebf3a84d-7f60-4809-a5e0-bbd574275463", + ]); + assert.equal(yield* Ref.get(harness.activations), 1); + assert.deepEqual(yield* Ref.get(harness.navigations), []); + }).pipe(Effect.provide(harness.layer)); + }), + ); +}); diff --git a/apps/desktop/src/app/DesktopDeepLinks.ts b/apps/desktop/src/app/DesktopDeepLinks.ts new file mode 100644 index 00000000000..924ec93c355 --- /dev/null +++ b/apps/desktop/src/app/DesktopDeepLinks.ts @@ -0,0 +1,422 @@ +import { + ConnectionCatalogDocument, + type ConnectionCatalogDocument as ConnectionCatalogDocumentType, +} from "@t3tools/client-runtime/platform"; +import { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; + +import { buildDesktopThreadNavigationUrl } from "../electron/ElectronProtocol.ts"; +import * as DesktopWindow from "../window/DesktopWindow.ts"; +import * as DesktopConnectionCatalogStore from "./DesktopConnectionCatalogStore.ts"; +import { makeComponentLogger } from "./DesktopObservability.ts"; + +export { buildDesktopThreadNavigationUrl }; + +export const DESKTOP_EXTERNAL_PROTOCOL = "t3code"; +export const DESKTOP_THREAD_DEEP_LINK_HOST = "open"; +export const DESKTOP_THREAD_DEEP_LINK_PATH = "/thread"; +export const DESKTOP_PROJECT_DEEP_LINK_PATH = "/project"; + +/** Reject oversized query values (connection labels and thread ids). */ +const MAX_DEEP_LINK_VALUE_LENGTH = 256; + +const CONTROL_CHARACTER_PATTERN = /[\u0000-\u001f\u007f]/; + +export type DesktopThreadDeepLink = { + readonly connectionLabel: string; + readonly threadId: ThreadId; +}; + +export type DesktopProjectDeepLink = { + readonly project: string; + readonly action: "reveal" | "latest" | "new"; +}; + +export type DesktopConnectionLabelResolution = + | { readonly _tag: "resolved"; readonly environmentId: EnvironmentId } + | { readonly _tag: "missing" } + | { readonly _tag: "ambiguous" } + | { readonly _tag: "invalidCatalog"; readonly cause: unknown }; + +type PendingDeepLinkAction = + | { readonly _tag: "reveal" } + | { readonly _tag: "openThread"; readonly deepLink: DesktopThreadDeepLink } + | { readonly _tag: "openProject"; readonly deepLink: DesktopProjectDeepLink }; + +const { logInfo: logDeepLinkInfo, logWarning: logDeepLinkWarning } = + makeComponentLogger("desktop-deep-links"); + +/** + * Parses a desktop thread deep link in either explicit or host shorthand form: + * + * - `t3code://open/thread?connection=&thread=` + * - `t3code://?thread=` + */ +export function parseDesktopThreadDeepLink(raw: string): Option.Option { + if (typeof raw !== "string" || raw.length === 0 || raw.length > 2_048) { + return Option.none(); + } + if (CONTROL_CHARACTER_PATTERN.test(raw)) { + return Option.none(); + } + + let url: URL; + try { + url = new URL(raw); + } catch { + return Option.none(); + } + + if (url.protocol !== `${DESKTOP_EXTERNAL_PROTOCOL}:`) { + return Option.none(); + } + // Normalize trailing slashes so `/thread` and `/thread/` both work. + const pathname = + url.pathname.length > 1 && url.pathname.endsWith("/") + ? url.pathname.slice(0, -1) + : url.pathname; + + const connectionValues = url.searchParams.getAll("connection"); + const threadValues = url.searchParams.getAll("thread"); + if (threadValues.length !== 1) { + return Option.none(); + } + + const isExplicitForm = + url.hostname === DESKTOP_THREAD_DEEP_LINK_HOST && pathname === DESKTOP_THREAD_DEEP_LINK_PATH; + const isHostShorthand = (pathname === "" || pathname === "/") && url.hostname.length > 0; + if ( + (!isExplicitForm && !isHostShorthand) || + (isExplicitForm && connectionValues.length !== 1) || + (isHostShorthand && connectionValues.length !== 0) + ) { + return Option.none(); + } + + // URLSearchParams percent-decodes once; reject empties, controls, and oversized values. + const connectionLabel = isExplicitForm ? (connectionValues[0] ?? "") : url.hostname; + const threadRaw = threadValues[0] ?? ""; + if ( + connectionLabel.length === 0 || + threadRaw.length === 0 || + connectionLabel.length > MAX_DEEP_LINK_VALUE_LENGTH || + threadRaw.length > MAX_DEEP_LINK_VALUE_LENGTH || + CONTROL_CHARACTER_PATTERN.test(connectionLabel) || + CONTROL_CHARACTER_PATTERN.test(threadRaw) + ) { + return Option.none(); + } + + let threadId: ThreadId; + try { + threadId = Schema.decodeUnknownSync(ThreadId)(threadRaw); + } catch { + return Option.none(); + } + + return Option.some({ + connectionLabel, + threadId, + }); +} + +export function parseDesktopProjectDeepLink(raw: string): Option.Option { + if (typeof raw !== "string" || raw.length === 0 || raw.length > 2_048) { + return Option.none(); + } + if (CONTROL_CHARACTER_PATTERN.test(raw)) { + return Option.none(); + } + + let url: URL; + try { + url = new URL(raw); + } catch { + return Option.none(); + } + + const pathname = + url.pathname.length > 1 && url.pathname.endsWith("/") + ? url.pathname.slice(0, -1) + : url.pathname; + if ( + url.protocol !== `${DESKTOP_EXTERNAL_PROTOCOL}:` || + url.hostname !== DESKTOP_THREAD_DEEP_LINK_HOST || + pathname !== DESKTOP_PROJECT_DEEP_LINK_PATH + ) { + return Option.none(); + } + + const projectValues = url.searchParams.getAll("project"); + const actionValues = url.searchParams.getAll("action"); + const project = projectValues[0]?.trim() ?? ""; + if ( + projectValues.length !== 1 || + actionValues.length > 1 || + project.length === 0 || + project.length > MAX_DEEP_LINK_VALUE_LENGTH || + CONTROL_CHARACTER_PATTERN.test(project) + ) { + return Option.none(); + } + + const action = actionValues[0] ?? "reveal"; + if (action !== "reveal" && action !== "latest" && action !== "new") { + return Option.none(); + } + return Option.some({ project, action }); +} + +/** + * Scans argv for the first valid thread deep link. Electron may inject + * platform-specific flags, so the URL is not assumed to be at a fixed index. + */ +export function findDesktopThreadDeepLinkInArgv( + argv: readonly string[], +): Option.Option { + for (const entry of argv) { + const parsed = parseDesktopThreadDeepLink(entry); + if (Option.isSome(parsed)) { + return parsed; + } + } + return Option.none(); +} + +function isShortAndFqdnMatch(left: string, right: string): boolean { + const leftIsFqdn = left.includes("."); + const rightIsFqdn = right.includes("."); + if (leftIsFqdn === rightIsFqdn) { + return false; + } + const shortLabel = leftIsFqdn ? right : left; + const fqdnLabel = leftIsFqdn ? left : right; + return fqdnLabel.slice(0, fqdnLabel.indexOf(".")) === shortLabel; +} + +/** + * Resolves a configured deep-link connection label to a unique environment id. + * + * The link producer's configured override is already represented by + * `connectionLabel`. Exact catalog labels win; only a missing exact match may + * fall back to matching a short host label with its FQDN form. + */ +export function resolveEnvironmentIdForConnectionLabel( + catalogJson: string, + connectionLabel: string, +): DesktopConnectionLabelResolution { + let document: ConnectionCatalogDocumentType; + try { + document = Schema.decodeUnknownSync(Schema.fromJsonString(ConnectionCatalogDocument))( + catalogJson, + ); + } catch (cause) { + return { _tag: "invalidCatalog", cause }; + } + + const exactMatches = document.targets.filter((target) => target.label === connectionLabel); + const matches = + exactMatches.length > 0 + ? exactMatches + : document.targets.filter((target) => isShortAndFqdnMatch(target.label, connectionLabel)); + if (matches.length === 0) { + return { _tag: "missing" }; + } + if (matches.length > 1) { + return { _tag: "ambiguous" }; + } + return { + _tag: "resolved", + environmentId: matches[0]!.environmentId, + }; +} + +export class DesktopDeepLinks extends Context.Service< + DesktopDeepLinks, + { + /** + * Handle a launch argv (initial process.argv or second-instance commandLine). + * Queues until `start` if the navigation services are not ready yet. + */ + readonly handleArgv: (argv: readonly string[]) => Effect.Effect; + /** + * Handle a macOS `open-url` deep link. + */ + readonly handleUrl: (url: string) => Effect.Effect; + /** + * Begin processing deep links. Flushes any action queued before readiness. + * Newest queued action wins. + */ + readonly start: Effect.Effect; + } +>()("@t3tools/desktop/app/DesktopDeepLinks") {} + +export const make = Effect.gen(function* () { + const desktopWindow = yield* DesktopWindow.DesktopWindow; + const catalogStore = yield* DesktopConnectionCatalogStore.DesktopConnectionCatalogStore; + const startedRef = yield* Ref.make(false); + const pendingActionRef = yield* Ref.make>(Option.none()); + + const revealOnly = desktopWindow.activate.pipe( + Effect.catch((error) => + logDeepLinkWarning("failed to reveal desktop window for deep link", { + message: error.message, + }), + ), + Effect.asVoid, + ); + + const resolveDeepLinkFromCatalog = ( + deepLink: DesktopThreadDeepLink, + ): Effect.Effect => + catalogStore.get.pipe( + Effect.catch((cause) => + logDeepLinkWarning("connection catalog unavailable for deep link resolution", { + reason: cause._tag, + }).pipe(Effect.as(Option.none())), + ), + Effect.map((catalog) => { + if (Option.isNone(catalog)) { + return { _tag: "missing" } as const; + } + return resolveEnvironmentIdForConnectionLabel(catalog.value, deepLink.connectionLabel); + }), + ); + + const openResolvedThread = (deepLink: DesktopThreadDeepLink): Effect.Effect => + Effect.gen(function* () { + const resolution = yield* resolveDeepLinkFromCatalog(deepLink); + switch (resolution._tag) { + case "resolved": { + yield* logDeepLinkInfo("opening thread from deep link", { + connectionLabel: deepLink.connectionLabel, + // Log only that an environment was resolved — never catalog contents. + resolved: true, + }); + yield* desktopWindow + .navigateToThread({ + environmentId: resolution.environmentId, + threadId: deepLink.threadId, + }) + .pipe( + Effect.catch((error) => + logDeepLinkWarning("failed to navigate to deep-linked thread", { + message: error.message, + }), + ), + ); + return; + } + case "missing": { + yield* logDeepLinkWarning("deep link connection label not found", { + connectionLabel: deepLink.connectionLabel, + }); + yield* revealOnly; + return; + } + case "ambiguous": { + yield* logDeepLinkWarning("deep link connection label is ambiguous", { + connectionLabel: deepLink.connectionLabel, + }); + yield* revealOnly; + return; + } + case "invalidCatalog": { + yield* logDeepLinkWarning("deep link connection catalog could not be decoded", { + reason: "invalidCatalog", + }); + yield* revealOnly; + return; + } + } + }).pipe(Effect.withSpan("desktop.deepLinks.openResolvedThread")); + + const processAction = (action: PendingDeepLinkAction): Effect.Effect => + Effect.gen(function* () { + switch (action._tag) { + case "reveal": + yield* revealOnly; + return; + case "openThread": + yield* openResolvedThread(action.deepLink); + return; + case "openProject": + yield* desktopWindow.navigateToProject(action.deepLink).pipe( + Effect.catch((error) => + logDeepLinkWarning("failed to navigate to deep-linked project", { + message: error.message, + }), + ), + ); + return; + } + }).pipe(Effect.withSpan("desktop.deepLinks.processAction")); + + const enqueueOrProcess = (action: PendingDeepLinkAction): Effect.Effect => + Effect.gen(function* () { + const started = yield* Ref.get(startedRef); + if (!started) { + // Newest action wins while startup is still settling. + yield* Ref.set(pendingActionRef, Option.some(action)); + return; + } + yield* processAction(action); + }); + + const handleArgv = (argv: readonly string[]): Effect.Effect => + Effect.gen(function* () { + for (const entry of argv) { + const projectDeepLink = parseDesktopProjectDeepLink(entry); + if (Option.isSome(projectDeepLink)) { + yield* enqueueOrProcess({ _tag: "openProject", deepLink: projectDeepLink.value }); + return; + } + } + const deepLink = findDesktopThreadDeepLinkInArgv(argv); + if (Option.isSome(deepLink)) { + yield* enqueueOrProcess({ _tag: "openThread", deepLink: deepLink.value }); + return; + } + yield* enqueueOrProcess({ _tag: "reveal" }); + }).pipe(Effect.withSpan("desktop.deepLinks.handleArgv")); + + const handleUrl = (url: string): Effect.Effect => + Effect.gen(function* () { + const projectDeepLink = parseDesktopProjectDeepLink(url); + if (Option.isSome(projectDeepLink)) { + yield* enqueueOrProcess({ _tag: "openProject", deepLink: projectDeepLink.value }); + return; + } + const deepLink = parseDesktopThreadDeepLink(url); + if (Option.isSome(deepLink)) { + yield* enqueueOrProcess({ _tag: "openThread", deepLink: deepLink.value }); + return; + } + yield* logDeepLinkWarning("ignored unsupported open-url payload"); + yield* enqueueOrProcess({ _tag: "reveal" }); + }).pipe(Effect.withSpan("desktop.deepLinks.handleUrl")); + + const start = Effect.gen(function* () { + const alreadyStarted = yield* Ref.getAndSet(startedRef, true); + if (alreadyStarted) { + return; + } + const pending = yield* Ref.getAndSet(pendingActionRef, Option.none()); + if (Option.isSome(pending)) { + yield* processAction(pending.value); + } + }).pipe(Effect.withSpan("desktop.deepLinks.start")); + + return DesktopDeepLinks.of({ + handleArgv, + handleUrl, + start, + }); +}); + +export const layer = Layer.effect(DesktopDeepLinks, make); diff --git a/apps/desktop/src/backend/DesktopBackendPool.test.ts b/apps/desktop/src/backend/DesktopBackendPool.test.ts index 9aaac689c34..8980b0263c1 100644 --- a/apps/desktop/src/backend/DesktopBackendPool.test.ts +++ b/apps/desktop/src/backend/DesktopBackendPool.test.ts @@ -86,6 +86,8 @@ function makePoolLayer( flushMainWindowBounds: Effect.void, dispatchMenuAction: () => Effect.die("unexpected menu action"), syncAppearance: Effect.void, + navigateToThread: () => Effect.void, + navigateToProject: () => Effect.void, } satisfies DesktopWindow.DesktopWindow["Service"]), ), ), @@ -121,9 +123,7 @@ describe("DesktopBackendPool", () => { it.effect("layerTest dies when no instances are supplied", () => Effect.exit( - Effect.gen(function* () { - yield* DesktopBackendPool.DesktopBackendPool; - }).pipe(Effect.provide(DesktopBackendPool.layerTest([]))), + DesktopBackendPool.DesktopBackendPool.pipe(Effect.provide(DesktopBackendPool.layerTest([]))), ).pipe(Effect.map((exit) => assert.equal(exit._tag, "Failure"))), ); diff --git a/apps/desktop/src/electron/ElectronProtocol.ts b/apps/desktop/src/electron/ElectronProtocol.ts index 03c7ef64fd7..a80e459953d 100644 --- a/apps/desktop/src/electron/ElectronProtocol.ts +++ b/apps/desktop/src/electron/ElectronProtocol.ts @@ -24,6 +24,34 @@ export function getDesktopUrl(isDevelopment: boolean): string { return `${getDesktopOrigin(isDevelopment)}/`; } +/** + * Builds the desktop renderer URL for a canonical thread route. + * + * The Electron client uses hash history, so the path is carried after `#/`. + */ +export function buildDesktopThreadNavigationUrl(input: { + readonly isDevelopment: boolean; + readonly environmentId: string; + readonly threadId: string; +}): string { + const origin = getDesktopOrigin(input.isDevelopment); + const environmentSegment = encodeURIComponent(input.environmentId); + const threadSegment = encodeURIComponent(input.threadId); + return `${origin}/#/${environmentSegment}/${threadSegment}`; +} + +export function buildDesktopProjectNavigationUrl(input: { + readonly isDevelopment: boolean; + readonly project: string; + readonly action: "reveal" | "latest" | "new"; +}): string { + const search = new URLSearchParams({ project: input.project }); + if (input.action !== "reveal") { + search.set("action", input.action); + } + return `${getDesktopOrigin(input.isDevelopment)}/#/jump?${search.toString()}`; +} + export class ElectronProtocolRegistrationError extends Schema.TaggedErrorClass()( "ElectronProtocolRegistrationError", { diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 26e5d3ac412..575bea91352 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -14,8 +14,27 @@ import * as Option from "effect/Option"; import * as Electron from "electron"; -import * as NetService from "@t3tools/shared/Net"; import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess"; + +// Support `t3code --version` (and -v) early so directory ("dir") installs can +// query the version of the *files on disk* at the install location. The deploy +// scripts rsync new builds into place; a running instance can then detect that +// a newer version is present without relying on network auto-updates. +if (process.argv.includes("--version") || process.argv.includes("-v")) { + try { + process.stdout.write(Electron.app.getVersion() + "\n"); + } finally { + process.exit(0); + } +} + +const hostProcessPlatform = Effect.runSync(HostProcessPlatform); + +if (hostProcessPlatform === "linux") { + Electron.app.commandLine.appendSwitch("password-store", "gnome-libsecret"); +} + +import * as NetService from "@t3tools/shared/Net"; import { resolveRemoteT3CliPackageSpec } from "@t3tools/ssh/command"; import type { RemoteT3RunnerOptions } from "@t3tools/ssh/tunnel"; import serverPackageJson from "../../server/package.json" with { type: "json" }; @@ -35,6 +54,7 @@ import * as DesktopApp from "./app/DesktopApp.ts"; import * as DesktopAppIdentity from "./app/DesktopAppIdentity.ts"; import * as DesktopConnectionCatalogStore from "./app/DesktopConnectionCatalogStore.ts"; import * as DesktopClerk from "./app/DesktopClerk.ts"; +import * as DesktopDeepLinks from "./app/DesktopDeepLinks.ts"; import * as DesktopApplicationMenu from "./window/DesktopApplicationMenu.ts"; import * as DesktopAssets from "./app/DesktopAssets.ts"; import * as DesktopBackendConfiguration from "./backend/DesktopBackendConfiguration.ts"; @@ -91,7 +111,7 @@ const resolveDesktopSshCliRunner = ( } return { packageSpec: resolveRemoteT3CliPackageSpec({ - appVersion: environment.appVersion, + appVersion: serverPackageJson.version, updateChannel: settings.updateChannel, isDevelopment: environment.isDevelopment, }), @@ -153,6 +173,11 @@ const desktopWindowLayer = DesktopWindow.layer.pipe( Layer.provideMerge(desktopPreviewLayer), ); +const desktopDeepLinksLayer = DesktopDeepLinks.layer.pipe( + Layer.provideMerge(desktopWindowLayer), + Layer.provideMerge(desktopFoundationLayer), +); + // Pool layer instantiates the backend factory once for the Windows // primary instance and exposes it via pool.primary. Consumers go through // the pool now; the legacy DesktopBackendManager service is gone. The @@ -183,6 +208,7 @@ const desktopApplicationLayer = Layer.mergeAll( DesktopOpenWith.layer, desktopSshLayer, ).pipe( + Layer.provideMerge(desktopDeepLinksLayer), Layer.provideMerge(DesktopUpdates.layer), Layer.provideMerge(desktopWslBackendLayer), Layer.provideMerge(desktopLocalEnvironmentAuthLayer), diff --git a/apps/desktop/src/window/DesktopApplicationMenu.test.ts b/apps/desktop/src/window/DesktopApplicationMenu.test.ts index 149caa79747..6eb39656424 100644 --- a/apps/desktop/src/window/DesktopApplicationMenu.test.ts +++ b/apps/desktop/src/window/DesktopApplicationMenu.test.ts @@ -80,6 +80,8 @@ const makeDesktopWindowLayer = (selectedAction: Deferred.Deferred) => flushMainWindowBounds: Effect.void, dispatchMenuAction: (action) => Deferred.succeed(selectedAction, action).pipe(Effect.asVoid), syncAppearance: Effect.void, + navigateToThread: () => Effect.void, + navigateToProject: () => Effect.void, } satisfies DesktopWindow.DesktopWindow["Service"]); const makeElectronMenuLayer = ( diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts index 587da8d4431..ddbde5d5939 100644 --- a/apps/desktop/src/window/DesktopWindow.test.ts +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -32,6 +32,8 @@ vi.mock("electron", async (importOriginal) => ({ }, })); +import { EnvironmentId, ThreadId } from "@t3tools/contracts"; + import * as DesktopAssets from "../app/DesktopAssets.ts"; import * as DesktopConfig from "../app/DesktopConfig.ts"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; @@ -1096,4 +1098,92 @@ describe("DesktopWindow", () => { }).pipe(Effect.provide(scenario.layer)); }), ); + + it.effect("queues navigation before the main window is ready and applies it on create", () => + Effect.gen(function* () { + const fakeWindow = makeFakeBrowserWindow(); + const createCount = yield* Ref.make(0); + const mainWindow = yield* Ref.make>(Option.none()); + const layer = makeTestLayer({ + window: fakeWindow.window, + createCount, + mainWindow, + }); + + yield* Effect.gen(function* () { + const desktopWindow = yield* DesktopWindow.DesktopWindow; + yield* desktopWindow.navigateToThread({ + environmentId: EnvironmentId.make("db6d1813-ace4-42bd-9bce-e04ee27e97ff"), + threadId: ThreadId.make("ebf3a84d-7f60-4809-a5e0-bbd574275463"), + }); + assert.equal(yield* Ref.get(createCount), 0); + assert.equal(fakeWindow.loadURL.mock.calls.length, 0); + + yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); + assert.equal(yield* Ref.get(createCount), 1); + assert.deepEqual(fakeWindow.loadURL.mock.calls[0], [ + "t3code-dev://app/#/db6d1813-ace4-42bd-9bce-e04ee27e97ff/ebf3a84d-7f60-4809-a5e0-bbd574275463", + ]); + }).pipe(Effect.provide(layer)); + }), + ); + + it.effect("navigates an existing main window to the encoded canonical route", () => + Effect.gen(function* () { + const fakeWindow = makeFakeBrowserWindow(); + const createCount = yield* Ref.make(0); + const mainWindow = yield* Ref.make>(Option.none()); + const layer = makeTestLayer({ + window: fakeWindow.window, + createCount, + mainWindow, + }); + + yield* Effect.gen(function* () { + const desktopWindow = yield* DesktopWindow.DesktopWindow; + yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); + fakeWindow.loadURL.mockClear(); + + yield* desktopWindow.navigateToThread({ + environmentId: EnvironmentId.make("db6d1813-ace4-42bd-9bce-e04ee27e97ff"), + threadId: ThreadId.make("ebf3a84d-7f60-4809-a5e0-bbd574275463"), + }); + + assert.equal(yield* Ref.get(createCount), 1); + assert.deepEqual(fakeWindow.loadURL.mock.calls, [ + [ + "t3code-dev://app/#/db6d1813-ace4-42bd-9bce-e04ee27e97ff/ebf3a84d-7f60-4809-a5e0-bbd574275463", + ], + ]); + }).pipe(Effect.provide(layer)); + }), + ); + + it.effect("navigates an existing main window to a project jump route", () => + Effect.gen(function* () { + const fakeWindow = makeFakeBrowserWindow(); + const createCount = yield* Ref.make(0); + const mainWindow = yield* Ref.make>(Option.none()); + const layer = makeTestLayer({ + window: fakeWindow.window, + createCount, + mainWindow, + }); + + yield* Effect.gen(function* () { + const desktopWindow = yield* DesktopWindow.DesktopWindow; + yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); + fakeWindow.loadURL.mockClear(); + + yield* desktopWindow.navigateToProject({ + project: "macs-holding/scanner", + action: "latest", + }); + + assert.deepEqual(fakeWindow.loadURL.mock.calls, [ + ["t3code-dev://app/#/jump?project=macs-holding%2Fscanner&action=latest"], + ]); + }).pipe(Effect.provide(layer)); + }), + ); }); diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index db4b698434d..887b03d1faa 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -1,3 +1,4 @@ +import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; @@ -11,7 +12,11 @@ import * as DesktopAssets from "../app/DesktopAssets.ts"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; import { makeComponentLogger } from "../app/DesktopObservability.ts"; import * as ElectronMenu from "../electron/ElectronMenu.ts"; -import { getDesktopUrl } from "../electron/ElectronProtocol.ts"; +import { + buildDesktopProjectNavigationUrl, + buildDesktopThreadNavigationUrl, + getDesktopUrl, +} from "../electron/ElectronProtocol.ts"; import * as ElectronShell from "../electron/ElectronShell.ts"; import * as ElectronTheme from "../electron/ElectronTheme.ts"; import * as ElectronWindow from "../electron/ElectronWindow.ts"; @@ -81,6 +86,20 @@ export class DesktopWindow extends Context.Service< readonly flushMainWindowBounds: Effect.Effect; readonly dispatchMenuAction: (action: string) => Effect.Effect; readonly syncAppearance: Effect.Effect; + /** + * Navigate the main window to the canonical thread route + * (`#/$environmentId/$threadId`). Reuses or creates the main window, + * preserves backend-readiness/splash behavior, and retains one pending + * target when the renderer is not yet navigable (newest wins). + */ + readonly navigateToThread: (input: { + readonly environmentId: EnvironmentId; + readonly threadId: ThreadId; + }) => Effect.Effect; + readonly navigateToProject: (input: { + readonly project: string; + readonly action: "reveal" | "latest" | "new"; + }) => Effect.Effect; } >()("@t3tools/desktop/window/DesktopWindow") {} @@ -255,11 +274,46 @@ export const make = Effect.gen(function* () { // The transient "Connecting to WSL" splash window, tracked separately so it // is never mistaken for the real main window. const splashWindowRef = yield* Ref.make>(Option.none()); + // Single pending deep-link route. Newest wins when several arrive during + // startup or while the main window is still loading. + const pendingNavigationUrlRef = yield* Ref.make>(Option.none()); const context = yield* Effect.context(); const runFork = Effect.runForkWith(context); const runPromise = Effect.runPromiseWith(context); let flushMainWindowBounds: Effect.Effect = Effect.void; + const loadPendingNavigationOnWindow = (window: Electron.BrowserWindow): void => { + if (window.isDestroyed()) { + return; + } + const apply = () => { + void runPromise( + Effect.gen(function* () { + if (window.isDestroyed()) { + return; + } + const pending = yield* Ref.get(pendingNavigationUrlRef); + if (Option.isNone(pending)) { + return; + } + void window.loadURL(pending.value).catch(() => undefined); + // Clear only the route we applied so a newer pending target is kept. + yield* Ref.update(pendingNavigationUrlRef, (current) => { + if (Option.isSome(current) && current.value === pending.value) { + return Option.none(); + } + return current; + }); + }), + ); + }; + if (window.webContents.isLoadingMainFrame()) { + window.webContents.once("did-finish-load", apply); + return; + } + apply(); + }; + const dismissConnectingSplash = Effect.gen(function* () { const splash = yield* Ref.getAndSet(splashWindowRef, Option.none()); if (Option.isSome(splash) && !splash.value.isDestroyed()) { @@ -548,7 +602,27 @@ export const make = Effect.gen(function* () { if (window.isDestroyed()) { return; } - void window.loadURL(applicationUrl).catch(() => undefined); + void runPromise( + Effect.gen(function* () { + const pending = yield* Ref.get(pendingNavigationUrlRef); + const url = Option.getOrElse(pending, () => applicationUrl); + if (window.isDestroyed()) { + return; + } + void window.loadURL(url).catch(() => undefined); + if (Option.isNone(pending)) { + return; + } + // Drop only the route we just loaded. A newer deep link that arrived + // after we read pending must stay queued. + yield* Ref.update(pendingNavigationUrlRef, (current) => { + if (Option.isSome(current) && current.value === pending.value) { + return Option.none(); + } + return current; + }); + }), + ); }; const scheduleDevelopmentLoadRetry = () => { if (developmentLoadRetryFiber !== undefined || window.isDestroyed()) { @@ -792,6 +866,72 @@ export const make = Effect.gen(function* () { syncWindowAppearance(window, shouldUseDarkColors, environment.platform), ); }).pipe(Effect.withSpan("desktop.window.syncAppearance")), + navigateToThread: Effect.fn("desktop.window.navigateToThread")(function* (input) { + yield* Effect.annotateCurrentSpan({ + environmentId: input.environmentId, + threadId: input.threadId, + }); + // Newest deep link wins if several arrive before navigation applies. + yield* Ref.set( + pendingNavigationUrlRef, + Option.some( + buildDesktopThreadNavigationUrl({ + isDevelopment: environment.isDevelopment, + environmentId: input.environmentId, + threadId: input.threadId, + }), + ), + ); + + const existingWindow = yield* currentMainWindow; + if (Option.isSome(existingWindow)) { + const window = existingWindow.value; + loadPendingNavigationOnWindow(window); + yield* electronWindow.reveal(window); + yield* logWindowInfo("navigated main window to deep-linked thread"); + return; + } + + // No real main window yet. Keep the pending route so createWindow's + // initial load uses the thread URL. Create immediately when the backend + // is ready; otherwise handleBackendReady / createMainIfBackendReady will + // open the window and pick up the pending target. + yield* createMainIfBackendReady; + const createdWindow = yield* currentMainWindow; + if (Option.isSome(createdWindow)) { + // createWindow already scheduled the pending URL via loadApplication. + yield* electronWindow.reveal(createdWindow.value); + yield* logWindowInfo("created main window for deep-linked thread"); + return; + } + + yield* logWindowInfo("queued deep-linked thread until main window is navigable"); + }), + navigateToProject: Effect.fn("desktop.window.navigateToProject")(function* (input) { + yield* Ref.set( + pendingNavigationUrlRef, + Option.some( + buildDesktopProjectNavigationUrl({ + isDevelopment: environment.isDevelopment, + project: input.project, + action: input.action, + }), + ), + ); + + const existingWindow = yield* currentMainWindow; + if (Option.isSome(existingWindow)) { + loadPendingNavigationOnWindow(existingWindow.value); + yield* electronWindow.reveal(existingWindow.value); + return; + } + + yield* createMainIfBackendReady; + const createdWindow = yield* currentMainWindow; + if (Option.isSome(createdWindow)) { + yield* electronWindow.reveal(createdWindow.value); + } + }), }); }); diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 543a5b3e604..8508b8e8fd9 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -187,6 +187,7 @@ import { } from "./ui/sidebar"; import { useThreadSelectionStore } from "../threadSelectionStore"; import { openCommandPalette } from "../commandPaletteBus"; +import { subscribeToProjectReveal } from "../projectJump"; import { archiveSelectedThreadEntries, buildMultiSelectThreadContextMenuItems, @@ -2617,7 +2618,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec const SidebarProjectListRow = memo(function SidebarProjectListRow(props: SidebarProjectItemProps) { return ( - + ); @@ -2855,6 +2856,7 @@ function SortableProjectItem({ } ${isOver && !isDragging ? "ring-1 ring-primary/40" : ""}`} data-sidebar="menu-item" data-slot="sidebar-menu-item" + data-project-key={projectId} > {children({ attributes, listeners, setActivatorNodeRef })} @@ -4844,6 +4846,25 @@ export default function Sidebar() { }); }, []); + useEffect( + () => + subscribeToProjectReveal(({ environmentId, projectId }) => { + const physicalProjectKey = `${environmentId}:${projectId}`; + const projectKey = physicalToLogicalKey.get(physicalProjectKey) ?? physicalProjectKey; + if (!sidebarProjectByKey.has(projectKey)) return; + expandThreadListForProject(projectKey); + requestAnimationFrame(() => { + const rows = document.querySelectorAll("[data-project-key]"); + for (const row of rows) { + if (row.dataset.projectKey !== projectKey) continue; + row.scrollIntoView({ behavior: "smooth", block: "nearest" }); + break; + } + }); + }), + [expandThreadListForProject, physicalToLogicalKey, sidebarProjectByKey], + ); + return ( <> {prewarmedSidebarThreadRefs.map((threadRef) => ( diff --git a/apps/web/src/components/SidebarV2.tsx b/apps/web/src/components/SidebarV2.tsx index 59dbdd3673a..b673558b172 100644 --- a/apps/web/src/components/SidebarV2.tsx +++ b/apps/web/src/components/SidebarV2.tsx @@ -84,6 +84,7 @@ import { useThreadSelectionStore } from "../threadSelectionStore"; import { useThreadActions } from "../hooks/useThreadActions"; import { useHandleNewThread } from "../hooks/useHandleNewThread"; import { openCommandPalette } from "../commandPaletteBus"; +import { subscribeToProjectReveal } from "../projectJump"; import { startNewThreadFromContext } from "../lib/chatThreadActions"; import { useClientSettings, useUpdateClientSettings } from "../hooks/useSettings"; import { useCopyToClipboard } from "../hooks/useCopyToClipboard"; @@ -1168,6 +1169,20 @@ export default function SidebarV2() { // Project scope: one menu above the list. Scoping filters the list without // making the header width depend on the number or length of project names. const [projectScopeKey, setProjectScopeKey] = useState(null); + useEffect( + () => + subscribeToProjectReveal(({ environmentId, projectId }) => { + const projectGroup = projectGroups.find((project) => + project.memberProjectRefs.some( + (ref) => ref.environmentId === environmentId && ref.projectId === projectId, + ), + ); + if (projectGroup !== undefined) { + setProjectScopeKey(projectGroup.projectKey); + } + }), + [projectGroups], + ); const scopedProjectGroup = useMemo( () => projectScopeKey === null diff --git a/apps/web/src/projectJump.test.ts b/apps/web/src/projectJump.test.ts new file mode 100644 index 00000000000..0af7fc74648 --- /dev/null +++ b/apps/web/src/projectJump.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { EnvironmentId, ProjectId, ThreadId } from "@t3tools/contracts"; +import type { + EnvironmentProject, + EnvironmentThreadShell, +} from "@t3tools/client-runtime/state/models"; +import { parseProjectJumpAction, resolveProjectJumpTarget } from "./projectJump"; + +const environmentId = EnvironmentId.make("local"); +const project = { + id: ProjectId.make("scanner-project"), + environmentId, + title: "Scanner", + workspaceRoot: "/work/scanner", + repositoryIdentity: { + canonicalKey: "github.com/macs-holding/scanner", + locator: { source: "git-remote", remoteName: "origin", remoteUrl: "git@example/scanner.git" }, + owner: "macs-holding", + name: "scanner", + }, + defaultModelSelection: null, + scripts: [], + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-02T00:00:00.000Z", +} as EnvironmentProject; + +describe("project jumps", () => { + it("matches short names and owner/repository names", () => { + expect(resolveProjectJumpTarget("scanner", [project], [])?.project).toBe(project); + expect(resolveProjectJumpTarget("macs-holding/scanner", [project], [])?.project).toBe(project); + }); + + it("prefers the environment with the latest thread activity", () => { + const newerProject = { + ...project, + id: ProjectId.make("scanner-project-remote"), + environmentId: EnvironmentId.make("remote"), + }; + const threads = [ + { + id: ThreadId.make("latest"), + projectId: newerProject.id, + environmentId: newerProject.environmentId, + archivedAt: null, + updatedAt: "2026-04-01T00:00:00.000Z", + }, + ] as EnvironmentThreadShell[]; + + expect(resolveProjectJumpTarget("scanner", [project, newerProject], threads)?.project).toBe( + newerProject, + ); + }); + + it("defaults unknown actions to reveal", () => { + expect(parseProjectJumpAction(undefined)).toBe("reveal"); + expect(parseProjectJumpAction("latest")).toBe("latest"); + expect(parseProjectJumpAction("new")).toBe("new"); + }); +}); diff --git a/apps/web/src/projectJump.ts b/apps/web/src/projectJump.ts new file mode 100644 index 00000000000..dd7c660e4fb --- /dev/null +++ b/apps/web/src/projectJump.ts @@ -0,0 +1,121 @@ +import type { + EnvironmentProject, + EnvironmentThreadShell, +} from "@t3tools/client-runtime/state/models"; + +export type ProjectJumpAction = "reveal" | "latest" | "new"; + +export interface ProjectJumpTarget { + readonly project: EnvironmentProject; + readonly latestThread: EnvironmentThreadShell | null; +} + +function normalizeProjectName(value: string): string { + return decodeURIComponent(value) + .trim() + .replace(/\\/gu, "/") + .replace(/\/+$/gu, "") + .replace(/\.git$/iu, "") + .toLocaleLowerCase(); +} + +function projectNames(project: EnvironmentProject): ReadonlySet { + const identity = project.repositoryIdentity; + const names = [ + project.title, + project.workspaceRoot + .replace(/[\\/]+$/u, "") + .split(/[\\/]/u) + .at(-1), + identity?.canonicalKey, + identity?.displayName, + identity?.name, + identity?.owner && identity.name ? `${identity.owner}/${identity.name}` : undefined, + ...(identity?.remotes?.flatMap((remote) => [ + remote.canonicalKey, + remote.name, + remote.owner && remote.name ? `${remote.owner}/${remote.name}` : undefined, + ]) ?? []), + ]; + + return new Set(names.flatMap((name) => (name ? [normalizeProjectName(name)] : []))); +} + +function latestThreadForProject( + project: EnvironmentProject, + threads: readonly EnvironmentThreadShell[], +): EnvironmentThreadShell | null { + return ( + threads + .filter( + (thread) => + thread.environmentId === project.environmentId && + thread.projectId === project.id && + thread.archivedAt === null, + ) + .toSorted((left, right) => { + const timestampDifference = Date.parse(right.updatedAt) - Date.parse(left.updatedAt); + return timestampDifference !== 0 ? timestampDifference : right.id.localeCompare(left.id); + })[0] ?? null + ); +} + +export function parseProjectJumpAction(value: unknown): ProjectJumpAction { + return value === "latest" || value === "new" ? value : "reveal"; +} + +export function resolveProjectJumpTarget( + rawProjectName: string, + projects: readonly EnvironmentProject[], + threads: readonly EnvironmentThreadShell[], +): ProjectJumpTarget | null { + const projectName = normalizeProjectName(rawProjectName); + if (!projectName) return null; + + const matches = projects + .filter((project) => projectNames(project).has(projectName)) + .map((project) => ({ + project, + latestThread: latestThreadForProject(project, threads), + })); + + return ( + matches.toSorted((left, right) => { + const leftTimestamp = Date.parse(left.latestThread?.updatedAt ?? left.project.updatedAt); + const rightTimestamp = Date.parse(right.latestThread?.updatedAt ?? right.project.updatedAt); + return rightTimestamp - leftTimestamp; + })[0] ?? null + ); +} + +const PROJECT_REVEAL_EVENT = "t3code:reveal-project"; +type ProjectRevealDetail = { readonly environmentId: string; readonly projectId: string }; +let pendingProjectReveal: ProjectRevealDetail | null = null; + +export function revealProjectInSidebar(project: EnvironmentProject): void { + pendingProjectReveal = { environmentId: project.environmentId, projectId: project.id }; + window.dispatchEvent( + new CustomEvent(PROJECT_REVEAL_EVENT, { + detail: pendingProjectReveal, + }), + ); +} + +export function subscribeToProjectReveal( + listener: (detail: ProjectRevealDetail) => void, +): () => void { + const handleEvent = (event: Event) => { + if (!(event instanceof CustomEvent)) return; + const detail = event.detail as { environmentId?: unknown; projectId?: unknown }; + if (typeof detail.environmentId !== "string" || typeof detail.projectId !== "string") return; + pendingProjectReveal = null; + listener({ environmentId: detail.environmentId, projectId: detail.projectId }); + }; + window.addEventListener(PROJECT_REVEAL_EVENT, handleEvent); + if (pendingProjectReveal !== null) { + const detail = pendingProjectReveal; + pendingProjectReveal = null; + listener(detail); + } + return () => window.removeEventListener(PROJECT_REVEAL_EVENT, handleEvent); +} diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index 609c3b79641..f865f4c0ed0 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -23,6 +23,7 @@ import { Route as SettingsConnectionsRouteImport } from './routes/settings.conne import { Route as SettingsBetaRouteImport } from './routes/settings.beta' import { Route as SettingsArchivedRouteImport } from './routes/settings.archived' import { Route as ConnectCallbackRouteImport } from './routes/connect_.callback' +import { Route as ChatJumpRouteImport } from './routes/_chat.jump' import { Route as ChatBoardRouteImport } from './routes/_chat.board' import { Route as ChatDraftDraftIdRouteImport } from './routes/_chat.draft.$draftId' import { Route as ChatEnvironmentIdThreadIdRouteImport } from './routes/_chat.$environmentId.$threadId' @@ -96,6 +97,11 @@ const ConnectCallbackRoute = ConnectCallbackRouteImport.update({ path: '/connect/callback', getParentRoute: () => rootRouteImport, } as any) +const ChatJumpRoute = ChatJumpRouteImport.update({ + id: '/jump', + path: '/jump', + getParentRoute: () => ChatRoute, +} as any) const ChatBoardRoute = ChatBoardRouteImport.update({ id: '/board', path: '/board', @@ -119,6 +125,7 @@ export interface FileRoutesByFullPath { '/pair': typeof PairRoute '/settings': typeof SettingsRouteWithChildren '/board': typeof ChatBoardRoute + '/jump': typeof ChatJumpRoute '/connect/callback': typeof ConnectCallbackRoute '/settings/archived': typeof SettingsArchivedRoute '/settings/beta': typeof SettingsBetaRoute @@ -136,6 +143,7 @@ export interface FileRoutesByTo { '/pair': typeof PairRoute '/settings': typeof SettingsRouteWithChildren '/board': typeof ChatBoardRoute + '/jump': typeof ChatJumpRoute '/connect/callback': typeof ConnectCallbackRoute '/settings/archived': typeof SettingsArchivedRoute '/settings/beta': typeof SettingsBetaRoute @@ -156,6 +164,7 @@ export interface FileRoutesById { '/pair': typeof PairRoute '/settings': typeof SettingsRouteWithChildren '/_chat/board': typeof ChatBoardRoute + '/_chat/jump': typeof ChatJumpRoute '/connect_/callback': typeof ConnectCallbackRoute '/settings/archived': typeof SettingsArchivedRoute '/settings/beta': typeof SettingsBetaRoute @@ -177,6 +186,7 @@ export interface FileRouteTypes { | '/pair' | '/settings' | '/board' + | '/jump' | '/connect/callback' | '/settings/archived' | '/settings/beta' @@ -194,6 +204,7 @@ export interface FileRouteTypes { | '/pair' | '/settings' | '/board' + | '/jump' | '/connect/callback' | '/settings/archived' | '/settings/beta' @@ -213,6 +224,7 @@ export interface FileRouteTypes { | '/pair' | '/settings' | '/_chat/board' + | '/_chat/jump' | '/connect_/callback' | '/settings/archived' | '/settings/beta' @@ -335,6 +347,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ConnectCallbackRouteImport parentRoute: typeof rootRouteImport } + '/_chat/jump': { + id: '/_chat/jump' + path: '/jump' + fullPath: '/jump' + preLoaderRoute: typeof ChatJumpRouteImport + parentRoute: typeof ChatRoute + } '/_chat/board': { id: '/_chat/board' path: '/board' @@ -361,6 +380,7 @@ declare module '@tanstack/react-router' { interface ChatRouteChildren { ChatBoardRoute: typeof ChatBoardRoute + ChatJumpRoute: typeof ChatJumpRoute ChatIndexRoute: typeof ChatIndexRoute ChatEnvironmentIdThreadIdRoute: typeof ChatEnvironmentIdThreadIdRoute ChatDraftDraftIdRoute: typeof ChatDraftDraftIdRoute @@ -368,6 +388,7 @@ interface ChatRouteChildren { const ChatRouteChildren: ChatRouteChildren = { ChatBoardRoute: ChatBoardRoute, + ChatJumpRoute: ChatJumpRoute, ChatIndexRoute: ChatIndexRoute, ChatEnvironmentIdThreadIdRoute: ChatEnvironmentIdThreadIdRoute, ChatDraftDraftIdRoute: ChatDraftDraftIdRoute, diff --git a/apps/web/src/routes/_chat.jump.tsx b/apps/web/src/routes/_chat.jump.tsx new file mode 100644 index 00000000000..270454a8155 --- /dev/null +++ b/apps/web/src/routes/_chat.jump.tsx @@ -0,0 +1,87 @@ +import { scopeProjectRef } from "@t3tools/client-runtime/environment"; +import { createFileRoute, useNavigate } from "@tanstack/react-router"; +import { useEffect, useMemo, useRef } from "react"; + +import { Button } from "../components/ui/button"; +import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "../components/ui/empty"; +import { SidebarInset } from "../components/ui/sidebar"; +import { useNewThreadHandler } from "../hooks/useHandleNewThread"; +import { + parseProjectJumpAction, + resolveProjectJumpTarget, + revealProjectInSidebar, +} from "../projectJump"; +import { + useAllEnvironmentShellsBootstrapped, + useProjects, + useThreadShells, +} from "../state/entities"; +import { buildThreadRouteParams } from "../threadRoutes"; + +function ProjectJumpRoute() { + const search = Route.useSearch(); + const projects = useProjects(); + const threads = useThreadShells(); + const bootstrapped = useAllEnvironmentShellsBootstrapped(); + const handleNewThread = useNewThreadHandler(); + const navigate = useNavigate(); + const handledKeyRef = useRef(null); + const action = parseProjectJumpAction(search.action); + const target = useMemo( + () => resolveProjectJumpTarget(search.project ?? "", projects, threads), + [projects, search.project, threads], + ); + const handledKey = `${search.project ?? ""}:${action}`; + + useEffect(() => { + if (!bootstrapped || target === null || handledKeyRef.current === handledKey) return; + handledKeyRef.current = handledKey; + + if (action === "latest" && target.latestThread !== null) { + void navigate({ + to: "/$environmentId/$threadId", + params: buildThreadRouteParams({ + environmentId: target.latestThread.environmentId, + threadId: target.latestThread.id, + }), + replace: true, + }); + return; + } + + if (action === "new" || action === "latest") { + void handleNewThread(scopeProjectRef(target.project.environmentId, target.project.id), { + replace: true, + }); + return; + } + + revealProjectInSidebar(target.project); + }, [action, bootstrapped, handleNewThread, handledKey, navigate, target]); + + if (!bootstrapped || target !== null) return null; + + return ( + + + + Project not found + + No project matches “{search.project || "(empty)"}” in a connected environment. + + + + + + ); +} + +export const Route = createFileRoute("/_chat/jump")({ + validateSearch: (search: Record) => ({ + project: typeof search.project === "string" ? search.project : undefined, + action: typeof search.action === "string" ? search.action : undefined, + }), + component: ProjectJumpRoute, +}); diff --git a/docs/project-jump-links.md b/docs/project-jump-links.md new file mode 100644 index 00000000000..0c069aaaba6 --- /dev/null +++ b/docs/project-jump-links.md @@ -0,0 +1,31 @@ +# Project jump links + +T3 Code accepts project jump links in Desktop and on the web: + +```text +t3code://open/project?project=scanner +t3code://open/project?project=macs-holding%2Fscanner&action=latest +t3code://open/project?project=configurator&action=new +https:///jump?project=t3code&action=new +``` + +The `project` value is matched case-insensitively against the project title, workspace directory +name, repository name, and `owner/repository` identity. If the repository is available in multiple +environments, T3 Code selects the environment with the most recently updated thread. + +`action` is optional: + +- omitted or `reveal`: reveal and expand the project in the original sidebar, or select its project + scope in Sidebar V2; +- `latest`: open the project's most recently updated non-archived thread, falling back to a new + thread when the project has no thread; +- `new`: open the new-thread composer. The normal composer defaults apply, including the last-used + environment mode. + +For launchers such as wlr-which-key, invoke the URI through the desktop URL opener: + +```sh +xdg-open 't3code://open/project?project=scanner&action=latest' +``` + +Percent-encode repository slashes (`macs-holding%2Fscanner`) when assembling the URI manually. diff --git a/scripts/build-desktop-artifact.ts b/scripts/build-desktop-artifact.ts index 8a1099a9ed0..0880337709f 100644 --- a/scripts/build-desktop-artifact.ts +++ b/scripts/build-desktop-artifact.ts @@ -102,7 +102,10 @@ const PLATFORM_CONFIG: Record = { }, linux: { cliFlag: "--linux", - defaultTarget: "AppImage", + // Local dev default: `dir` produces an unpacked app for the no-sudo + // home-directory install (fastest startup, rsync-deployable). CI/release + // pass `--target AppImage` explicitly, so this default doesn't affect them. + defaultTarget: "dir", archChoices: ["x64", "arm64"], }, win: { @@ -569,7 +572,13 @@ interface StagePackageJson { readonly private: true; readonly packageManager: string; readonly description: string; - readonly author: string; + // fpm-based Linux targets (pacman/deb/rpm) require a homepage and an author + // email; AppImage never read either, so both were previously omitted. + readonly homepage: string; + readonly author: { + readonly name: string; + readonly email: string; + }; readonly main: string; readonly build: Record; readonly dependencies: Record; @@ -1447,12 +1456,29 @@ export const createBuildConfig = Effect.fn("createBuildConfig")(function* ( executableName: "t3code", icon: "icons", category: "Development", + // fpm-based targets (pacman/deb/rpm) require a maintainer; AppImage did not. + maintainer: "T3 Tools ", desktop: { entry: { StartupWMClass: "t3code", + // Register the external deep-link scheme so xdg-open can launch T3. + // electron-builder keeps %U on Exec when MimeType is present. + MimeType: "x-scheme-handler/t3code;", }, }, + protocols: [ + { + name: "T3 Code", + schemes: ["t3code"], + }, + ], }; + // fpm auto-detects shared-library dependencies and emits Debian-style + // package names (e.g. "http-parser") that don't exist on Arch, so pacman + // refuses to install. Declare no dependencies — Electron bundles its own + // libs and the required system libs (glibc/gtk3/nss) are present on any + // desktop Arch install. + buildConfig.pacman = { depends: [] }; } if (platform === "win") { @@ -1761,7 +1787,8 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( private: true, packageManager: rootPackageJson.packageManager, description: "T3 Code desktop build", - author: "T3 Tools", + homepage: "https://t3.tools", + author: { name: "T3 Tools", email: "support@t3.tools" }, main: "apps/desktop/dist-electron/main.cjs", build: yield* createBuildConfig( options.platform, @@ -1905,11 +1932,26 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( for (const entry of stageEntries) { const from = path.join(stageDistDir, entry); const stat = yield* fs.stat(from).pipe(Effect.orElseSucceed(() => null)); - if (!stat || stat.type !== "File") continue; + if (!stat) continue; const to = path.join(options.outputDir, entry); - yield* fs.copyFile(from, to); - copiedArtifacts.push(to); + if (stat.type === "File") { + yield* fs.copyFile(from, to); + copiedArtifacts.push(to); + } else if ( + stat.type === "Directory" && + entry === "linux-unpacked" && + options.target === "dir" + ) { + // The `dir` target emits an unpacked app directory instead of a single + // installer file, so ship it too. This is what the no-sudo home-directory + // deploy rsyncs into ~/.local/opt. Other targets also leave a + // linux-unpacked intermediate here — skip it for them (target !== "dir") + // so we don't copy hundreds of MB alongside their real artifact. + yield* fs.remove(to, { recursive: true }).pipe(Effect.ignore); + yield* fs.copy(from, to); + copiedArtifacts.push(to); + } } if (copiedArtifacts.length === 0) {