From e8f6c83f49e23325ef842db044da810f67f7720f Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 5 May 2026 21:37:08 -0700 Subject: [PATCH] Add desktop workspace open flow - launch the installed desktop app from `t3 --app` - forward workspace paths from Electron to the web UI - bootstrap new projects when opening a missing workspace --- apps/desktop/src/main.ts | 136 +++++++-- apps/desktop/src/preload.ts | 12 + apps/server/src/cli.ts | 29 +- apps/server/src/desktopAppLauncher.test.ts | 216 ++++++++++++++ apps/server/src/desktopAppLauncher.ts | 264 ++++++++++++++++++ .../settings/SettingsPanels.browser.tsx | 1 + apps/web/src/localApi.test.ts | 1 + apps/web/src/routes/__root.tsx | 125 ++++++++- packages/contracts/src/ipc.ts | 1 + 9 files changed, 746 insertions(+), 39 deletions(-) create mode 100644 apps/server/src/desktopAppLauncher.test.ts create mode 100644 apps/server/src/desktopAppLauncher.ts diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 9c097fc9bd1..4eb42f34e9c 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -93,6 +93,7 @@ const SET_THEME_CHANNEL = "desktop:set-theme"; const CONTEXT_MENU_CHANNEL = "desktop:context-menu"; const OPEN_EXTERNAL_CHANNEL = "desktop:open-external"; const MENU_ACTION_CHANNEL = "desktop:menu-action"; +const OPEN_WORKSPACE_CHANNEL = "desktop:open-workspace"; const UPDATE_STATE_CHANNEL = "desktop:update-state"; const UPDATE_GET_STATE_CHANNEL = "desktop:update-get-state"; const UPDATE_SET_CHANNEL_CHANNEL = "desktop:update-set-channel"; @@ -118,6 +119,7 @@ const DESKTOP_SETTINGS_PATH = Path.join(STATE_DIR, "desktop-settings.json"); const CLIENT_SETTINGS_PATH = Path.join(STATE_DIR, "client-settings.json"); const SAVED_ENVIRONMENT_REGISTRY_PATH = Path.join(STATE_DIR, "saved-environments.json"); const DESKTOP_SCHEME = "t3"; +const OPEN_WORKSPACE_ARG = "--t3-open-path"; const ROOT_DIR = Path.resolve(__dirname, "../../.."); const isDevelopment = Boolean(process.env.VITE_DEV_SERVER_URL); // Dev-only SSH launcher override. Set this to an absolute path on the SSH host @@ -239,6 +241,7 @@ let restoreStdIoCapture: (() => void) | null = null; let backendObservabilitySettings = readPersistedBackendObservabilitySettings(); let desktopSettings = readDesktopSettings(DESKTOP_SETTINGS_PATH, app.getVersion()); let desktopServerExposureMode: DesktopServerExposureMode = desktopSettings.serverExposureMode; +let pendingOpenWorkspacePath = resolveOpenWorkspaceArg(process.argv.slice(1)); let destructiveMenuIconCache: Electron.NativeImage | null | undefined; const expectedBackendExitChildren = new WeakSet(); @@ -471,6 +474,23 @@ function formatErrorMessage(error: unknown): string { return String(error); } +function resolveOpenWorkspaceArg(argv: ReadonlyArray): string | null { + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (arg === OPEN_WORKSPACE_ARG) { + const workspacePath = argv[index + 1]?.trim(); + return workspacePath ? Path.resolve(workspacePath) : null; + } + + if (arg?.startsWith(`${OPEN_WORKSPACE_ARG}=`)) { + const workspacePath = arg.slice(OPEN_WORKSPACE_ARG.length + 1).trim(); + return workspacePath ? Path.resolve(workspacePath) : null; + } + } + + return null; +} + function getSafeExternalUrl(rawUrl: unknown): string | null { if (typeof rawUrl !== "string" || rawUrl.length === 0) { return null; @@ -567,6 +587,32 @@ function ensureInitialBackendWindowOpen(): void { backendInitialWindowOpenInFlight = nextOpen; } +function sendOpenWorkspace(window: BrowserWindow, workspacePath: string): void { + const send = () => { + if (window.isDestroyed()) return; + window.webContents.send(OPEN_WORKSPACE_CHANNEL, workspacePath); + revealWindow(window); + }; + + if (window.webContents.isLoadingMainFrame()) { + window.webContents.once("did-finish-load", send); + return; + } + + send(); +} + +function dispatchOpenWorkspace(workspacePath: string): void { + const existingWindow = mainWindow ?? BrowserWindow.getAllWindows()[0] ?? null; + if (existingWindow) { + sendOpenWorkspace(existingWindow, workspacePath); + return; + } + + pendingOpenWorkspacePath = workspacePath; + ensureInitialBackendWindowOpen(); +} + function writeDesktopStreamChunk( streamName: "stdout" | "stderr", chunk: unknown, @@ -1464,6 +1510,8 @@ function startBackend(): void { return; } + const bootstrapWorkspacePath = pendingOpenWorkspacePath; + pendingOpenWorkspacePath = null; const captureBackendLogs = !isDevelopment; const child = ChildProcess.spawn(process.execPath, [backendEntry, "--bootstrap-fd", "3"], { cwd: resolveBackendCwd(), @@ -1487,6 +1535,9 @@ function startBackend(): void { t3Home: BASE_DIR, host: backendBindHost, desktopBootstrapToken: backendBootstrapToken, + ...(bootstrapWorkspacePath + ? { cwd: bootstrapWorkspacePath, autoBootstrapProjectFromCwd: true } + : {}), tailscaleServeEnabled: desktopSettings.tailscaleServeEnabled, tailscaleServePort: desktopSettings.tailscaleServePort, ...(backendObservabilitySettings.otlpTracesUrl @@ -2135,6 +2186,12 @@ function createWindow(): BrowserWindow { void window.loadURL(backendHttpUrl); } + if (pendingOpenWorkspacePath) { + const workspacePath = pendingOpenWorkspacePath; + pendingOpenWorkspacePath = null; + sendOpenWorkspace(window, workspacePath); + } + window.on("closed", () => { desktopSshEnvironmentBridge.cancelPendingPasswordPrompts( "SSH authentication was cancelled because the app window closed.", @@ -2234,38 +2291,59 @@ app.on("before-quit", () => { restoreStdIoCapture?.(); }); -app - .whenReady() - .then(() => { - writeDesktopLogHeader("app ready"); - configureAppIdentity(); - configureApplicationMenu(); - registerDesktopProtocol(); - configureAutoUpdater(); - void bootstrap().catch((error) => { - if (isBackendReadinessAborted(error) && isQuitting) { - return; - } - handleFatalStartupError("bootstrap", error); - }); +const hasSingleInstanceLock = app.requestSingleInstanceLock(); +if (!hasSingleInstanceLock) { + app.quit(); +} else { + app.on("second-instance", (_event, commandLine) => { + const workspacePath = resolveOpenWorkspaceArg(commandLine); + if (workspacePath) { + dispatchOpenWorkspace(workspacePath); + return; + } - app.on("activate", () => { - const existingWindow = mainWindow ?? BrowserWindow.getAllWindows()[0]; - if (existingWindow) { - revealWindow(existingWindow); - return; - } - if (isDevelopment) { - mainWindow = createWindow(); - return; - } - ensureInitialBackendWindowOpen(); - }); - }) - .catch((error) => { - handleFatalStartupError("whenReady", error); + const existingWindow = mainWindow ?? BrowserWindow.getAllWindows()[0]; + if (existingWindow) { + revealWindow(existingWindow); + return; + } + + ensureInitialBackendWindowOpen(); }); + app + .whenReady() + .then(() => { + writeDesktopLogHeader("app ready"); + configureAppIdentity(); + configureApplicationMenu(); + registerDesktopProtocol(); + configureAutoUpdater(); + void bootstrap().catch((error) => { + if (isBackendReadinessAborted(error) && isQuitting) { + return; + } + handleFatalStartupError("bootstrap", error); + }); + + app.on("activate", () => { + const existingWindow = mainWindow ?? BrowserWindow.getAllWindows()[0]; + if (existingWindow) { + revealWindow(existingWindow); + return; + } + if (isDevelopment) { + mainWindow = createWindow(); + return; + } + ensureInitialBackendWindowOpen(); + }); + }) + .catch((error) => { + handleFatalStartupError("whenReady", error); + }); +} + app.on("window-all-closed", () => { if (process.platform !== "darwin" && !isQuitting) { app.quit(); diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index b3b553fe214..5d1cccaab4a 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -7,6 +7,7 @@ const SET_THEME_CHANNEL = "desktop:set-theme"; const CONTEXT_MENU_CHANNEL = "desktop:context-menu"; const OPEN_EXTERNAL_CHANNEL = "desktop:open-external"; const MENU_ACTION_CHANNEL = "desktop:menu-action"; +const OPEN_WORKSPACE_CHANNEL = "desktop:open-workspace"; const UPDATE_STATE_CHANNEL = "desktop:update-state"; const UPDATE_GET_STATE_CHANNEL = "desktop:update-get-state"; const UPDATE_SET_CHANNEL_CHANNEL = "desktop:update-set-channel"; @@ -128,6 +129,17 @@ contextBridge.exposeInMainWorld("desktopBridge", { ipcRenderer.removeListener(MENU_ACTION_CHANNEL, wrappedListener); }; }, + onOpenWorkspace: (listener) => { + const wrappedListener = (_event: Electron.IpcRendererEvent, workspacePath: unknown) => { + if (typeof workspacePath !== "string" || workspacePath.length === 0) return; + listener(workspacePath); + }; + + ipcRenderer.on(OPEN_WORKSPACE_CHANNEL, wrappedListener); + return () => { + ipcRenderer.removeListener(OPEN_WORKSPACE_CHANNEL, wrappedListener); + }; + }, getUpdateState: () => ipcRenderer.invoke(UPDATE_GET_STATE_CHANNEL), setUpdateChannel: (channel) => ipcRenderer.invoke(UPDATE_SET_CHANNEL_CHANNEL, channel), checkForUpdate: () => ipcRenderer.invoke(UPDATE_CHECK_CHANNEL), diff --git a/apps/server/src/cli.ts b/apps/server/src/cli.ts index bcf8bee861c..ff16d301c09 100644 --- a/apps/server/src/cli.ts +++ b/apps/server/src/cli.ts @@ -65,6 +65,7 @@ import { } from "./serverRuntimeState.ts"; import { WorkspacePaths } from "./workspace/Services/WorkspacePaths.ts"; import { WorkspacePathsLive } from "./workspace/Layers/WorkspacePaths.ts"; +import { openDesktopAppOrPrompt } from "./desktopAppLauncher.ts"; const PortSchema = Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 65535 })); @@ -139,6 +140,10 @@ const tailscaleServePortFlag = Flag.integer("tailscale-serve-port").pipe( Flag.withDescription("HTTPS port for Tailscale Serve when --tailscale-serve is enabled."), Flag.optional, ); +const appFlag = Flag.boolean("app").pipe( + Flag.withDescription("Open or focus the installed T3 Code desktop app."), + Flag.withDefault(false), +); const EnvServerConfig = Config.all({ logLevel: Config.logLevel("T3CODE_LOG_LEVEL").pipe(Config.withDefault("Info")), @@ -1150,11 +1155,21 @@ const runServerCommand = ( readonly forceAutoBootstrapProjectFromCwd?: boolean; }, ) => - Effect.gen(function* () { - const logLevel = yield* GlobalFlag.LogLevel; - const config = yield* resolveServerConfig(flags, logLevel, options); - return yield* runServer.pipe(Effect.provideService(ServerConfig, config)); - }); + Effect.service(GlobalFlag.LogLevel).pipe( + Effect.flatMap((logLevel) => resolveServerConfig(flags, logLevel, options)), + Effect.flatMap((config) => Effect.provideService(ServerConfig, config)(runServer)), + ); + +const runDesktopCommand = (flags: CliServerFlags) => + Effect.service(GlobalFlag.LogLevel).pipe( + Effect.flatMap((logLevel) => + resolveServerConfig(flags, logLevel, { forceAutoBootstrapProjectFromCwd: true }), + ), + Effect.flatMap((config) => openDesktopAppOrPrompt(process.platform, config.cwd)), + Effect.flatMap((result) => + result._tag === "use-web-ui" ? runServerCommand(flags) : Effect.void, + ), + ); const startCommand = Command.make("start", { ...sharedServerCommandFlags }).pipe( Command.withDescription("Run the T3 Code server."), @@ -1173,8 +1188,8 @@ const serveCommand = Command.make("serve", { ...sharedServerCommandFlags }).pipe ), ); -export const cli = Command.make("t3", { ...sharedServerCommandFlags }).pipe( +export const cli = Command.make("t3", { ...sharedServerCommandFlags, app: appFlag }).pipe( Command.withDescription("Run the T3 Code server."), - Command.withHandler((flags) => runServerCommand(flags)), + Command.withHandler((flags) => (flags.app ? runDesktopCommand(flags) : runServerCommand(flags))), Command.withSubcommands([startCommand, serveCommand, authCommand, projectCommand]), ); diff --git a/apps/server/src/desktopAppLauncher.test.ts b/apps/server/src/desktopAppLauncher.test.ts new file mode 100644 index 00000000000..2b21927d321 --- /dev/null +++ b/apps/server/src/desktopAppLauncher.test.ts @@ -0,0 +1,216 @@ +import { + Console, + Effect, + FileSystem, + Layer, + Option, + Path, + Queue, + Sink, + Stream, + Terminal, +} from "effect"; +import { TestConsole } from "effect/testing"; +import { describe, expect, it } from "@effect/vitest"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; + +import { + DESKTOP_RELEASES_URL, + desktopAppLaunchCommands, + openDesktopAppOrPrompt, +} from "./desktopAppLauncher.ts"; + +const makeUserInput = (name: string): Terminal.UserInput => ({ + input: Option.some(name), + key: { name, ctrl: false, meta: false, shift: false }, +}); + +const makeTerminalLayer = (keys: ReadonlyArray) => + Layer.effect( + Terminal.Terminal, + Effect.gen(function* () { + const input = yield* Queue.unbounded(); + yield* Queue.offerAll(input, keys.map(makeUserInput)); + + return Terminal.make({ + columns: Effect.succeed(80), + display: (text) => Console.log(text), + readInput: Effect.succeed(Queue.asDequeue(input)), + readLine: Effect.succeed(""), + }); + }), + ); + +const makeHandle = (exitCode: number) => + ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(1), + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(exitCode)), + isRunning: Effect.succeed(false), + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + stdin: Sink.drain, + stdout: Stream.empty, + stderr: Stream.empty, + all: Stream.empty, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }); + +const makeSpawnerLayer = ( + calls: Array<{ command: string; args: ReadonlyArray }>, + exitCodes: ReadonlyArray, +) => + Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make((command) => + Effect.sync(() => { + const standardCommand = command as ChildProcess.StandardCommand; + calls.push({ command: standardCommand.command, args: standardCommand.args }); + return makeHandle(exitCodes[calls.length - 1] ?? 0); + }), + ), + ); + +const makeTestLayer = ( + calls: Array<{ command: string; args: ReadonlyArray }>, + exitCodes: ReadonlyArray, + keys: ReadonlyArray = [], +) => + Layer.mergeAll( + TestConsole.layer, + FileSystem.layerNoop({}), + Path.layer, + makeTerminalLayer(keys), + makeSpawnerLayer(calls, exitCodes), + ); + +describe("desktopAppLaunchCommands", () => { + it("tries Alpha and Nightly app names on macOS", () => { + expect(desktopAppLaunchCommands("darwin")).toEqual([ + { command: "open", args: ["-a", "T3 Code (Alpha)"] }, + { command: "open", args: ["-a", "T3 Code (Nightly)"] }, + ]); + }); + + it("passes the workspace path through macOS app launches", () => { + expect(desktopAppLaunchCommands("darwin", "/repo/app")).toEqual([ + { + command: "open", + args: ["-a", "T3 Code (Alpha)", "--args", "--t3-open-path", "/repo/app"], + }, + { + command: "open", + args: ["-a", "T3 Code (Nightly)", "--args", "--t3-open-path", "/repo/app"], + }, + ]); + }); + + it("tries the Linux desktop entry before the executable", () => { + expect(desktopAppLaunchCommands("linux")).toEqual([ + { command: "gtk-launch", args: ["t3code.desktop"] }, + { command: "t3code", args: [] }, + ]); + }); + + it("uses the Linux executable directly when passing a workspace path", () => { + expect(desktopAppLaunchCommands("linux", "/repo/app")).toEqual([ + { command: "t3code", args: ["--t3-open-path", "/repo/app"] }, + ]); + }); +}); + +describe("openDesktopAppOrPrompt", () => { + it.effect("opens the installed app without prompting for releases", () => + Effect.gen(function* () { + const calls: Array<{ command: string; args: ReadonlyArray }> = []; + + const result = yield* openDesktopAppOrPrompt("darwin", "/repo/app").pipe( + Effect.provide(makeTestLayer(calls, [0])), + ); + const logs = yield* TestConsole.logLines; + + expect(result).toEqual({ _tag: "opened-app" }); + expect(calls).toEqual([ + { + command: "open", + args: ["-a", "T3 Code (Alpha)", "--args", "--t3-open-path", "/repo/app"], + }, + ]); + expect(logs).toContain("Opened T3 Code."); + }), + ); + + it.effect("opens Nightly when Alpha is not installed", () => + Effect.gen(function* () { + const calls: Array<{ command: string; args: ReadonlyArray }> = []; + + const result = yield* openDesktopAppOrPrompt("darwin").pipe( + Effect.provide(makeTestLayer(calls, [1, 0])), + ); + const logs = yield* TestConsole.logLines; + + expect(result).toEqual({ _tag: "opened-app" }); + expect(calls).toEqual([ + { command: "open", args: ["-a", "T3 Code (Alpha)"] }, + { command: "open", args: ["-a", "T3 Code (Nightly)"] }, + ]); + expect(logs).toContain("Opened T3 Code."); + }), + ); + + it.effect("prompts and opens GitHub releases when app launch fails", () => + Effect.gen(function* () { + const calls: Array<{ command: string; args: ReadonlyArray }> = []; + + const result = yield* openDesktopAppOrPrompt("darwin").pipe( + Effect.provide(makeTestLayer(calls, [1, 1, 0], ["enter"])), + ); + const logs = yield* TestConsole.logLines; + + expect(result).toEqual({ _tag: "opened-releases" }); + expect(calls).toEqual([ + { command: "open", args: ["-a", "T3 Code (Alpha)"] }, + { command: "open", args: ["-a", "T3 Code (Nightly)"] }, + { command: "open", args: [DESKTOP_RELEASES_URL] }, + ]); + expect(logs.some((line) => String(line).includes("Yes, open github"))).toBe(true); + expect(logs).toContain(`Opened ${DESKTOP_RELEASES_URL}`); + }), + ); + + it.effect("returns use-web-ui without opening releases when selected", () => + Effect.gen(function* () { + const calls: Array<{ command: string; args: ReadonlyArray }> = []; + + const result = yield* openDesktopAppOrPrompt("darwin").pipe( + Effect.provide(makeTestLayer(calls, [1, 1], ["down", "enter"])), + ); + const logs = yield* TestConsole.logLines; + + expect(result).toEqual({ _tag: "use-web-ui" }); + expect(calls).toEqual([ + { command: "open", args: ["-a", "T3 Code (Alpha)"] }, + { command: "open", args: ["-a", "T3 Code (Nightly)"] }, + ]); + expect(logs).toContain("Starting the web UI."); + }), + ); + + it.effect("prints the releases URL without opening a browser when exiting", () => + Effect.gen(function* () { + const calls: Array<{ command: string; args: ReadonlyArray }> = []; + + const result = yield* openDesktopAppOrPrompt("darwin").pipe( + Effect.provide(makeTestLayer(calls, [1, 1], ["down", "down", "enter"])), + ); + const logs = yield* TestConsole.logLines; + + expect(result).toEqual({ _tag: "exit" }); + expect(calls).toEqual([ + { command: "open", args: ["-a", "T3 Code (Alpha)"] }, + { command: "open", args: ["-a", "T3 Code (Nightly)"] }, + ]); + expect(logs).toContain(`Download T3 Code from ${DESKTOP_RELEASES_URL}`); + }), + ); +}); diff --git a/apps/server/src/desktopAppLauncher.ts b/apps/server/src/desktopAppLauncher.ts new file mode 100644 index 00000000000..10af8d01805 --- /dev/null +++ b/apps/server/src/desktopAppLauncher.ts @@ -0,0 +1,264 @@ +import { Console, Data, Duration, Effect, Option, Stream } from "effect"; +import { Prompt } from "effect/unstable/cli"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; + +const DESKTOP_APP_NAMES = ["T3 Code (Alpha)", "T3 Code (Nightly)"] as const; +const DESKTOP_APP_NAME = "T3 Code"; +const DESKTOP_LINUX_DESKTOP_ENTRY = "t3code.desktop"; +const DESKTOP_LINUX_EXECUTABLE = "t3code"; +export const DESKTOP_RELEASES_URL = "https://github.com/pingdotgg/t3code/releases/latest"; + +type Platform = NodeJS.Platform; + +export interface DesktopLaunchCommand { + readonly command: string; + readonly args: ReadonlyArray; +} + +export class DesktopAppLaunchError extends Data.TaggedError("DesktopAppLaunchError")<{ + readonly command: string; + readonly message: string; + readonly exitCode?: number | undefined; + readonly cause?: unknown; +}> {} + +export type DesktopAppFallbackChoice = "open-github" | "use-web-ui" | "exit"; + +export interface DesktopAppFallbackPromptChoice { + readonly title: string; + readonly value: DesktopAppFallbackChoice; +} + +export const desktopAppFallbackPromptChoices: ReadonlyArray = [ + { title: "Yes, open github", value: "open-github" }, + { title: "No, use web UI", value: "use-web-ui" }, + { title: "No, exit", value: "exit" }, +]; + +export type DesktopAppLaunchResult = + | { readonly _tag: "opened-app" } + | { readonly _tag: "opened-releases" } + | { readonly _tag: "use-web-ui" } + | { readonly _tag: "exit" }; + +const desktopAppLaunchArgs = (workspacePath: string | undefined): ReadonlyArray => + workspacePath ? ["--t3-open-path", workspacePath] : []; + +function quotePowerShellSingle(value: string): string { + return `'${value.replaceAll("'", "''")}'`; +} + +export function desktopAppLaunchCommands( + platform: Platform, + workspacePath?: string, +): ReadonlyArray { + const appArgs = desktopAppLaunchArgs(workspacePath); + if (platform === "darwin") { + return DESKTOP_APP_NAMES.map((appName) => ({ + command: "open", + args: appArgs.length > 0 ? ["-a", appName, "--args", ...appArgs] : ["-a", appName], + })); + } + + if (platform === "win32") { + const argumentList = + appArgs.length > 0 + ? ` -ArgumentList @(${appArgs.map(quotePowerShellSingle).join(", ")})` + : ""; + const script = [ + "$names = @('T3 Code (Alpha)', 'T3 Code (Nightly)', 'T3 Code')", + "$roots = @($env:LOCALAPPDATA, $env:ProgramFiles, ${env:ProgramFiles(x86)}) | Where-Object { $_ }", + '$candidates = foreach ($root in $roots) { foreach ($name in $names) { Join-Path (Join-Path $root \'Programs\') (Join-Path $name "$name.exe"); Join-Path $root (Join-Path $name "$name.exe") } }', + "$target = $candidates | Where-Object { Test-Path $_ } | Select-Object -First 1", + "if (-not $target) { exit 1 }", + `Start-Process -FilePath $target${argumentList}`, + ].join("; "); + + return [ + { + command: "powershell.exe", + args: ["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", script], + }, + ]; + } + + if (platform === "linux") { + if (appArgs.length > 0) { + return [{ command: DESKTOP_LINUX_EXECUTABLE, args: appArgs }]; + } + + return [ + { command: "gtk-launch", args: [DESKTOP_LINUX_DESKTOP_ENTRY] }, + { command: DESKTOP_LINUX_EXECUTABLE, args: [] }, + ]; + } + + return []; +} + +function openReleasesCommand(platform: Platform): DesktopLaunchCommand { + if (platform === "darwin") { + return { command: "open", args: [DESKTOP_RELEASES_URL] }; + } + + if (platform === "win32") { + return { + command: "powershell.exe", + args: [ + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-Command", + `Start-Process ${JSON.stringify(DESKTOP_RELEASES_URL)}`, + ], + }; + } + + return { command: "xdg-open", args: [DESKTOP_RELEASES_URL] }; +} + +function commandLabel(command: string, args: ReadonlyArray): string { + return [command, ...args].join(" "); +} + +const runDesktopCommand = ( + command: string, + args: ReadonlyArray, +): Effect.Effect => + Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const label = commandLabel(command, args); + + const exitCode = yield* Effect.scoped( + Effect.gen(function* () { + const child = yield* spawner + .spawn( + ChildProcess.make(command, [...args], { + env: process.env, + }), + ) + .pipe( + Effect.mapError( + (cause) => + new DesktopAppLaunchError({ + command: label, + message: `Failed to run ${label}.`, + cause, + }), + ), + ); + + yield* Effect.addFinalizer(() => child.kill().pipe(Effect.ignore)); + + const [, , code] = yield* Effect.all( + [Stream.runDrain(child.stdout), Stream.runDrain(child.stderr), child.exitCode], + { concurrency: "unbounded" }, + ).pipe( + Effect.mapError( + (cause) => + new DesktopAppLaunchError({ + command: label, + message: `Failed to collect result for ${label}.`, + cause, + }), + ), + ); + + return code; + }).pipe( + Effect.timeoutOption(Duration.millis(10_000)), + Effect.flatMap((result) => + Option.match(result, { + onSome: Effect.succeed, + onNone: () => + Effect.fail( + new DesktopAppLaunchError({ + command: label, + message: `${label} timed out.`, + }), + ), + }), + ), + ), + ); + + if (exitCode !== 0) { + return yield* new DesktopAppLaunchError({ + command: label, + message: `${label} exited with code ${exitCode}.`, + exitCode, + }); + } + }); + +const chooseFallback = ( + message: string, +): Effect.Effect => + Prompt.select({ + message, + choices: desktopAppFallbackPromptChoices, + }).pipe( + Prompt.run, + Effect.mapError( + (cause) => + new DesktopAppLaunchError({ + command: "desktop fallback prompt", + message: "Desktop fallback prompt was cancelled.", + cause, + }), + ), + ); + +function tryLaunchDesktopApp( + platform: Platform, + workspacePath: string | undefined, +): Effect.Effect { + return Effect.gen(function* () { + for (const launchCommand of desktopAppLaunchCommands(platform, workspacePath)) { + const launched = yield* runDesktopCommand(launchCommand.command, launchCommand.args).pipe( + Effect.as(true), + Effect.catchCause(() => Effect.succeed(false)), + ); + if (launched) { + return true; + } + } + + return false; + }); +} + +export function openDesktopAppOrPrompt( + platform: Platform, + workspacePath?: string, +): Effect.Effect< + DesktopAppLaunchResult, + DesktopAppLaunchError, + ChildProcessSpawner.ChildProcessSpawner | Prompt.Environment +> { + return Effect.gen(function* () { + if (yield* tryLaunchDesktopApp(platform, workspacePath)) { + yield* Console.log(`Opened ${DESKTOP_APP_NAME}.`); + return { _tag: "opened-app" } satisfies DesktopAppLaunchResult; + } + + const fallbackChoice = yield* chooseFallback( + `${DESKTOP_APP_NAME} does not appear to be installed.`, + ); + + if (fallbackChoice === "use-web-ui") { + yield* Console.log("Starting the web UI."); + return { _tag: "use-web-ui" } satisfies DesktopAppLaunchResult; + } + + if (fallbackChoice === "exit") { + yield* Console.log(`Download ${DESKTOP_APP_NAME} from ${DESKTOP_RELEASES_URL}`); + return { _tag: "exit" } satisfies DesktopAppLaunchResult; + } + + const command = openReleasesCommand(platform); + yield* runDesktopCommand(command.command, command.args); + yield* Console.log(`Opened ${DESKTOP_RELEASES_URL}`); + return { _tag: "opened-releases" } satisfies DesktopAppLaunchResult; + }); +} diff --git a/apps/web/src/components/settings/SettingsPanels.browser.tsx b/apps/web/src/components/settings/SettingsPanels.browser.tsx index 85c16b70155..07f5f132306 100644 --- a/apps/web/src/components/settings/SettingsPanels.browser.tsx +++ b/apps/web/src/components/settings/SettingsPanels.browser.tsx @@ -378,6 +378,7 @@ const createDesktopBridgeStub = (overrides?: { showContextMenu: vi.fn().mockResolvedValue(null), openExternal: vi.fn().mockResolvedValue(true), onMenuAction: () => () => {}, + onOpenWorkspace: () => () => {}, getUpdateState: vi.fn().mockResolvedValue(idleUpdateState), setUpdateChannel: overrides?.setUpdateChannel ?? diff --git a/apps/web/src/localApi.test.ts b/apps/web/src/localApi.test.ts index 895163cf109..5c74da01a2d 100644 --- a/apps/web/src/localApi.test.ts +++ b/apps/web/src/localApi.test.ts @@ -225,6 +225,7 @@ function makeDesktopBridge(overrides: Partial = {}): DesktopBridg showContextMenu: async () => null, openExternal: async () => true, onMenuAction: () => () => undefined, + onOpenWorkspace: () => () => undefined, getUpdateState: async () => { throw new Error("getUpdateState not implemented in test"); }, diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 58617589dfa..b3900500cb9 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -1,5 +1,11 @@ -import { type ServerLifecycleWelcomePayload } from "@t3tools/contracts"; -import { scopedProjectKey, scopeProjectRef } from "@t3tools/client-runtime"; +import { + DEFAULT_MODEL, + DEFAULT_PROVIDER_INTERACTION_MODE, + DEFAULT_RUNTIME_MODE, + ProviderInstanceId, + type ServerLifecycleWelcomePayload, +} from "@t3tools/contracts"; +import { scopedProjectKey, scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime"; import { Outlet, createRootRouteWithContext, @@ -28,7 +34,9 @@ import { } from "../components/ui/toast"; import { resolveAndPersistPreferredEditor } from "../editorPreferences"; import { readLocalApi } from "../localApi"; +import { readEnvironmentApi } from "../environmentApi"; import { useSettings } from "../hooks/useSettings"; +import { useNewThreadHandler } from "../hooks/useHandleNewThread"; import { deriveLogicalProjectKeyFromSettings, derivePhysicalProjectKeyFromPath, @@ -41,8 +49,12 @@ import { useServerConfigUpdatedSubscription, useServerWelcomeSubscription, } from "../rpc/serverState"; -import { useStore } from "../store"; +import { selectSidebarThreadsForProjectRef, useStore } from "../store"; import { useUiStateStore } from "../uiStateStore"; +import { getLatestThreadForProject } from "../lib/threadSort"; +import { inferProjectTitleFromPath, findProjectByPath } from "../lib/projectPaths"; +import { newCommandId, newProjectId, newThreadId } from "../lib/utils"; +import { buildThreadRouteParams } from "../threadRoutes"; import { syncBrowserChromeTheme } from "../hooks/useTheme"; import { ensureEnvironmentConnectionBootstrapped, @@ -136,6 +148,7 @@ function RootRouteView() { {primaryEnvironmentAuthenticated ? : null} + {primaryEnvironmentAuthenticated ? : null} {primaryEnvironmentAuthenticated ? : null} {primaryEnvironmentAuthenticated ? : null} {primaryEnvironmentAuthenticated ? ( @@ -148,6 +161,112 @@ function RootRouteView() { ); } +function DesktopOpenWorkspaceBootstrap() { + const navigate = useNavigate(); + const serverConfig = useServerConfig(); + const sidebarThreadSortOrder = useSettings((settings) => settings.sidebarThreadSortOrder); + const defaultThreadEnvMode = useSettings((settings) => settings.defaultThreadEnvMode); + const { handleNewThread } = useNewThreadHandler(); + + const openWorkspace = useEffectEvent(async (rawWorkspacePath: string) => { + const workspacePath = rawWorkspacePath.trim(); + if (!workspacePath) return; + + const environmentId = + serverConfig?.environment.environmentId ?? getPrimaryKnownEnvironment()?.environmentId; + if (!environmentId) return; + + await ensureEnvironmentConnectionBootstrapped(environmentId); + const api = readEnvironmentApi(environmentId); + if (!api) return; + + const environmentState = useStore.getState().environmentStateById[environmentId]; + const projects = Object.values(environmentState?.projectById ?? {}); + const existingProject = findProjectByPath(projects, workspacePath); + + const openThread = async (projectId: (typeof projects)[number]["id"]) => { + const projectRef = scopeProjectRef(environmentId, projectId); + const latestThread = getLatestThreadForProject( + selectSidebarThreadsForProjectRef(useStore.getState(), projectRef), + projectId, + sidebarThreadSortOrder, + ); + if (latestThread) { + await navigate({ + to: "/$environmentId/$threadId", + params: buildThreadRouteParams( + scopeThreadRef(latestThread.environmentId, latestThread.id), + ), + }); + return; + } + + await handleNewThread(projectRef, { + envMode: defaultThreadEnvMode, + }); + }; + + if (existingProject) { + await openThread(existingProject.id); + return; + } + + const createdAt = new Date().toISOString(); + const projectId = newProjectId(); + const threadId = newThreadId(); + const defaultModelSelection = { + instanceId: ProviderInstanceId.make("codex"), + model: DEFAULT_MODEL, + }; + + await api.orchestration.dispatchCommand({ + type: "project.create", + commandId: newCommandId(), + projectId, + title: inferProjectTitleFromPath(workspacePath), + workspaceRoot: workspacePath, + createWorkspaceRootIfMissing: true, + defaultModelSelection, + createdAt, + }); + await api.orchestration.dispatchCommand({ + type: "thread.create", + commandId: newCommandId(), + threadId, + projectId, + title: "New thread", + modelSelection: defaultModelSelection, + runtimeMode: DEFAULT_RUNTIME_MODE, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + branch: null, + worktreePath: null, + createdAt, + }); + await navigate({ + to: "/$environmentId/$threadId", + params: buildThreadRouteParams(scopeThreadRef(environmentId, threadId)), + }); + }); + + useEffect(() => { + const unsubscribe = window.desktopBridge?.onOpenWorkspace?.((workspacePath) => { + void openWorkspace(workspacePath).catch((error) => { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Unable to open workspace", + description: error instanceof Error ? error.message : "Unknown error.", + }), + ); + }); + }); + + return unsubscribe ?? undefined; + }, []); + + return null; +} + function HostedStaticEnvironmentBootstrap() { const savedEnvironmentCount = useSavedEnvironmentRegistryStore( (state) => Object.keys(state.byId).length, diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index f480912920f..181c8edfe9d 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -242,6 +242,7 @@ export interface DesktopBridge { ) => Promise; openExternal: (url: string) => Promise; onMenuAction: (listener: (action: string) => void) => () => void; + onOpenWorkspace: (listener: (workspacePath: string) => void) => () => void; getUpdateState: () => Promise; setUpdateChannel: (channel: DesktopUpdateChannel) => Promise; checkForUpdate: () => Promise;