History

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

Julius Marminge committed 6 months ago

Commit e8f6c831 parent 91a03e09 files · +746 −39Browse files

Unified · Split

9 files
  1. apps/desktop/src/main.ts +107 −29
  2. apps/desktop/src/preload.ts +12 −0
  3. apps/server/src/cli.ts +22 −7
  4. apps/server/src/desktopAppLauncher.test.ts +216 −0
  5. apps/server/src/desktopAppLauncher.ts +264 −0
  6. apps/web/src/components/settings/SettingsPanels.browser.tsx +1 −0
  7. apps/web/src/localApi.test.ts +1 −0
  8. apps/web/src/routes/__root.tsx +122 −3
  9. packages/contracts/src/ipc.ts +1 −0
apps/desktop/src/main.ts+107−29
@@ -93,6 +93,7 @@ const SET_THEME_CHANNEL = "desktop:set-theme"; · 92 unmodified lines
9393 const CONTEXT_MENU_CHANNEL = "desktop:context-menu";
9494 const OPEN_EXTERNAL_CHANNEL = "desktop:open-external";
9595 const MENU_ACTION_CHANNEL = "desktop:menu-action";
96+const OPEN_WORKSPACE_CHANNEL = "desktop:open-workspace";
9697 const UPDATE_STATE_CHANNEL = "desktop:update-state";
9798 const UPDATE_GET_STATE_CHANNEL = "desktop:update-get-state";
9899 const UPDATE_SET_CHANNEL_CHANNEL = "desktop:update-set-channel";
@@ -118,6 +119,7 @@ const DESKTOP_SETTINGS_PATH = Path.join(STATE_DIR, "desktop-settings.json"); · 19 unmodified lines
118119 const CLIENT_SETTINGS_PATH = Path.join(STATE_DIR, "client-settings.json");
119120 const SAVED_ENVIRONMENT_REGISTRY_PATH = Path.join(STATE_DIR, "saved-environments.json");
120121 const DESKTOP_SCHEME = "t3";
122+const OPEN_WORKSPACE_ARG = "--t3-open-path";
121123 const ROOT_DIR = Path.resolve(__dirname, "../../..");
122124 const isDevelopment = Boolean(process.env.VITE_DEV_SERVER_URL);
123125 // Dev-only SSH launcher override. Set this to an absolute path on the SSH host
@@ -239,6 +241,7 @@ let restoreStdIoCapture: (() => void) | null = null; · 115 unmodified lines
239241 let backendObservabilitySettings = readPersistedBackendObservabilitySettings();
240242 let desktopSettings = readDesktopSettings(DESKTOP_SETTINGS_PATH, app.getVersion());
241243 let desktopServerExposureMode: DesktopServerExposureMode = desktopSettings.serverExposureMode;
244+let pendingOpenWorkspacePath = resolveOpenWorkspaceArg(process.argv.slice(1));
242245
243246 let destructiveMenuIconCache: Electron.NativeImage | null | undefined;
244247 const expectedBackendExitChildren = new WeakSet<ChildProcess.ChildProcess>();
@@ -471,6 +474,23 @@ function formatErrorMessage(error: unknown): string { · 226 unmodified lines
471474 return String(error);
472475 }
473476
477+function resolveOpenWorkspaceArg(argv: ReadonlyArray<string>): string | null {
478+ for (let index = 0; index < argv.length; index += 1) {
479+ const arg = argv[index];
480+ if (arg === OPEN_WORKSPACE_ARG) {
481+ const workspacePath = argv[index + 1]?.trim();
482+ return workspacePath ? Path.resolve(workspacePath) : null;
483+ }
484+
485+ if (arg?.startsWith(`${OPEN_WORKSPACE_ARG}=`)) {
486+ const workspacePath = arg.slice(OPEN_WORKSPACE_ARG.length + 1).trim();
487+ return workspacePath ? Path.resolve(workspacePath) : null;
488+ }
489+ }
490+
491+ return null;
492+}
493+
474494 function getSafeExternalUrl(rawUrl: unknown): string | null {
475495 if (typeof rawUrl !== "string" || rawUrl.length === 0) {
476496 return null;
@@ -567,6 +587,32 @@ function ensureInitialBackendWindowOpen(): void { · 90 unmodified lines
567587 backendInitialWindowOpenInFlight = nextOpen;
568588 }
569589
590+function sendOpenWorkspace(window: BrowserWindow, workspacePath: string): void {
591+ const send = () => {
592+ if (window.isDestroyed()) return;
593+ window.webContents.send(OPEN_WORKSPACE_CHANNEL, workspacePath);
594+ revealWindow(window);
595+ };
596+
597+ if (window.webContents.isLoadingMainFrame()) {
598+ window.webContents.once("did-finish-load", send);
599+ return;
600+ }
601+
602+ send();
603+}
604+
605+function dispatchOpenWorkspace(workspacePath: string): void {
606+ const existingWindow = mainWindow ?? BrowserWindow.getAllWindows()[0] ?? null;
607+ if (existingWindow) {
608+ sendOpenWorkspace(existingWindow, workspacePath);
609+ return;
610+ }
611+
612+ pendingOpenWorkspacePath = workspacePath;
613+ ensureInitialBackendWindowOpen();
614+}
615+
570616 function writeDesktopStreamChunk(
571617 streamName: "stdout" | "stderr",
572618 chunk: unknown,
@@ -1464,6 +1510,8 @@ function startBackend(): void { · 891 unmodified lines
14641510 return;
14651511 }
14661512
1513+ const bootstrapWorkspacePath = pendingOpenWorkspacePath;
1514+ pendingOpenWorkspacePath = null;
14671515 const captureBackendLogs = !isDevelopment;
14681516 const child = ChildProcess.spawn(process.execPath, [backendEntry, "--bootstrap-fd", "3"], {
14691517 cwd: resolveBackendCwd(),
@@ -1487,6 +1535,9 @@ function startBackend(): void { · 17 unmodified lines
14871535 t3Home: BASE_DIR,
14881536 host: backendBindHost,
14891537 desktopBootstrapToken: backendBootstrapToken,
1538+ ...(bootstrapWorkspacePath
1539+ ? { cwd: bootstrapWorkspacePath, autoBootstrapProjectFromCwd: true }
1540+ : {}),
14901541 tailscaleServeEnabled: desktopSettings.tailscaleServeEnabled,
14911542 tailscaleServePort: desktopSettings.tailscaleServePort,
14921543 ...(backendObservabilitySettings.otlpTracesUrl
@@ -2135,6 +2186,12 @@ function createWindow(): BrowserWindow { · 642 unmodified lines
21352186 void window.loadURL(backendHttpUrl);
21362187 }
21372188
2189+ if (pendingOpenWorkspacePath) {
2190+ const workspacePath = pendingOpenWorkspacePath;
2191+ pendingOpenWorkspacePath = null;
2192+ sendOpenWorkspace(window, workspacePath);
2193+ }
2194+
21382195 window.on("closed", () => {
21392196 desktopSshEnvironmentBridge.cancelPendingPasswordPrompts(
21402197 "SSH authentication was cancelled because the app window closed.",
@@ -2234,38 +2291,59 @@ app.on("before-quit", () => { · 93 unmodified lines
22342291 restoreStdIoCapture?.();
22352292 });
22362293
2237−app
2238− .whenReady()
2239− .then(() => {
2240− writeDesktopLogHeader("app ready");
2241− configureAppIdentity();
2242− configureApplicationMenu();
2243− registerDesktopProtocol();
2244− configureAutoUpdater();
2245− void bootstrap().catch((error) => {
2246− if (isBackendReadinessAborted(error) && isQuitting) {
2247− return;
2248− }
2249− handleFatalStartupError("bootstrap", error);
2250− });
2294+const hasSingleInstanceLock = app.requestSingleInstanceLock();
2295+if (!hasSingleInstanceLock) {
2296+ app.quit();
2297+} else {
2298+ app.on("second-instance", (_event, commandLine) => {
2299+ const workspacePath = resolveOpenWorkspaceArg(commandLine);
2300+ if (workspacePath) {
2301+ dispatchOpenWorkspace(workspacePath);
2302+ return;
2303+ }
22512304
2252− app.on("activate", () => {
2253− const existingWindow = mainWindow ?? BrowserWindow.getAllWindows()[0];
2254− if (existingWindow) {
2255− revealWindow(existingWindow);
2256− return;
2257− }
2258− if (isDevelopment) {
2259− mainWindow = createWindow();
2260− return;
2261− }
2262− ensureInitialBackendWindowOpen();
2263− });
2264− })
2265− .catch((error) => {
2266− handleFatalStartupError("whenReady", error);
2305+ const existingWindow = mainWindow ?? BrowserWindow.getAllWindows()[0];
2306+ if (existingWindow) {
2307+ revealWindow(existingWindow);
2308+ return;
2309+ }
2310+
2311+ ensureInitialBackendWindowOpen();
22672312 });
22682313
2314+ app
2315+ .whenReady()
2316+ .then(() => {
2317+ writeDesktopLogHeader("app ready");
2318+ configureAppIdentity();
2319+ configureApplicationMenu();
2320+ registerDesktopProtocol();
2321+ configureAutoUpdater();
2322+ void bootstrap().catch((error) => {
2323+ if (isBackendReadinessAborted(error) && isQuitting) {
2324+ return;
2325+ }
2326+ handleFatalStartupError("bootstrap", error);
2327+ });
2328+
2329+ app.on("activate", () => {
2330+ const existingWindow = mainWindow ?? BrowserWindow.getAllWindows()[0];
2331+ if (existingWindow) {
2332+ revealWindow(existingWindow);
2333+ return;
2334+ }
2335+ if (isDevelopment) {
2336+ mainWindow = createWindow();
2337+ return;
2338+ }
2339+ ensureInitialBackendWindowOpen();
2340+ });
2341+ })
2342+ .catch((error) => {
2343+ handleFatalStartupError("whenReady", error);
2344+ });
2345+}
2346+
22692347 app.on("window-all-closed", () => {
22702348 if (process.platform !== "darwin" && !isQuitting) {
22712349 app.quit();
apps/desktop/src/preload.ts+12−0
@@ -7,6 +7,7 @@ const SET_THEME_CHANNEL = "desktop:set-theme"; · 6 unmodified lines
77 const CONTEXT_MENU_CHANNEL = "desktop:context-menu";
88 const OPEN_EXTERNAL_CHANNEL = "desktop:open-external";
99 const MENU_ACTION_CHANNEL = "desktop:menu-action";
10+const OPEN_WORKSPACE_CHANNEL = "desktop:open-workspace";
1011 const UPDATE_STATE_CHANNEL = "desktop:update-state";
1112 const UPDATE_GET_STATE_CHANNEL = "desktop:update-get-state";
1213 const UPDATE_SET_CHANNEL_CHANNEL = "desktop:update-set-channel";
@@ -128,6 +129,17 @@ contextBridge.exposeInMainWorld("desktopBridge", { · 115 unmodified lines
128129 ipcRenderer.removeListener(MENU_ACTION_CHANNEL, wrappedListener);
129130 };
130131 },
132+ onOpenWorkspace: (listener) => {
133+ const wrappedListener = (_event: Electron.IpcRendererEvent, workspacePath: unknown) => {
134+ if (typeof workspacePath !== "string" || workspacePath.length === 0) return;
135+ listener(workspacePath);
136+ };
137+
138+ ipcRenderer.on(OPEN_WORKSPACE_CHANNEL, wrappedListener);
139+ return () => {
140+ ipcRenderer.removeListener(OPEN_WORKSPACE_CHANNEL, wrappedListener);
141+ };
142+ },
131143 getUpdateState: () => ipcRenderer.invoke(UPDATE_GET_STATE_CHANNEL),
132144 setUpdateChannel: (channel) => ipcRenderer.invoke(UPDATE_SET_CHANNEL_CHANNEL, channel),
133145 checkForUpdate: () => ipcRenderer.invoke(UPDATE_CHECK_CHANNEL),
apps/server/src/cli.ts+22−7
@@ -65,6 +65,7 @@ import { · 64 unmodified lines
6565 } from "./serverRuntimeState.ts";
6666 import { WorkspacePaths } from "./workspace/Services/WorkspacePaths.ts";
6767 import { WorkspacePathsLive } from "./workspace/Layers/WorkspacePaths.ts";
68+import { openDesktopAppOrPrompt } from "./desktopAppLauncher.ts";
6869
6970 const PortSchema = Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 65535 }));
7071
@@ -139,6 +140,10 @@ const tailscaleServePortFlag = Flag.integer("tailscale-serve-port").pipe( · 68 unmodified lines
139140 Flag.withDescription("HTTPS port for Tailscale Serve when --tailscale-serve is enabled."),
140141 Flag.optional,
141142 );
143+const appFlag = Flag.boolean("app").pipe(
144+ Flag.withDescription("Open or focus the installed T3 Code desktop app."),
145+ Flag.withDefault(false),
146+);
142147
143148 const EnvServerConfig = Config.all({
144149 logLevel: Config.logLevel("T3CODE_LOG_LEVEL").pipe(Config.withDefault("Info")),
@@ -1150,11 +1155,21 @@ const runServerCommand = ( · 1005 unmodified lines
11501155 readonly forceAutoBootstrapProjectFromCwd?: boolean;
11511156 },
11521157 ) =>
1153− Effect.gen(function* () {
1154− const logLevel = yield* GlobalFlag.LogLevel;
1155− const config = yield* resolveServerConfig(flags, logLevel, options);
1156− return yield* runServer.pipe(Effect.provideService(ServerConfig, config));
1157− });
1158+ Effect.service(GlobalFlag.LogLevel).pipe(
1159+ Effect.flatMap((logLevel) => resolveServerConfig(flags, logLevel, options)),
1160+ Effect.flatMap((config) => Effect.provideService(ServerConfig, config)(runServer)),
1161+ );
1162+
1163+const runDesktopCommand = (flags: CliServerFlags) =>
1164+ Effect.service(GlobalFlag.LogLevel).pipe(
1165+ Effect.flatMap((logLevel) =>
1166+ resolveServerConfig(flags, logLevel, { forceAutoBootstrapProjectFromCwd: true }),
1167+ ),
1168+ Effect.flatMap((config) => openDesktopAppOrPrompt(process.platform, config.cwd)),
1169+ Effect.flatMap((result) =>
1170+ result._tag === "use-web-ui" ? runServerCommand(flags) : Effect.void,
1171+ ),
1172+ );
11581173
11591174 const startCommand = Command.make("start", { ...sharedServerCommandFlags }).pipe(
11601175 Command.withDescription("Run the T3 Code server."),
@@ -1173,8 +1188,8 @@ const serveCommand = Command.make("serve", { ...sharedServerCommandFlags }).pipe · 12 unmodified lines
11731188 ),
11741189 );
11751190
1176−export const cli = Command.make("t3", { ...sharedServerCommandFlags }).pipe(
1191+export const cli = Command.make("t3", { ...sharedServerCommandFlags, app: appFlag }).pipe(
11771192 Command.withDescription("Run the T3 Code server."),
1178− Command.withHandler((flags) => runServerCommand(flags)),
1193+ Command.withHandler((flags) => (flags.app ? runDesktopCommand(flags) : runServerCommand(flags))),
11791194 Command.withSubcommands([startCommand, serveCommand, authCommand, projectCommand]),
11801195 );
apps/server/src/desktopAppLauncher.test.ts+216−0
@@ -0,0 +1,216 @@
1+import {
2+ Console,
3+ Effect,
4+ FileSystem,
5+ Layer,
6+ Option,
7+ Path,
8+ Queue,
9+ Sink,
10+ Stream,
11+ Terminal,
12+} from "effect";
13+import { TestConsole } from "effect/testing";
14+import { describe, expect, it } from "@effect/vitest";
15+import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
16+
17+import {
18+ DESKTOP_RELEASES_URL,
19+ desktopAppLaunchCommands,
20+ openDesktopAppOrPrompt,
21+} from "./desktopAppLauncher.ts";
22+
23+const makeUserInput = (name: string): Terminal.UserInput => ({
24+ input: Option.some(name),
25+ key: { name, ctrl: false, meta: false, shift: false },
26+});
27+
28+const makeTerminalLayer = (keys: ReadonlyArray<string>) =>
29+ Layer.effect(
30+ Terminal.Terminal,
31+ Effect.gen(function* () {
32+ const input = yield* Queue.unbounded<Terminal.UserInput>();
33+ yield* Queue.offerAll(input, keys.map(makeUserInput));
34+
35+ return Terminal.make({
36+ columns: Effect.succeed(80),
37+ display: (text) => Console.log(text),
38+ readInput: Effect.succeed(Queue.asDequeue(input)),
39+ readLine: Effect.succeed(""),
40+ });
41+ }),
42+ );
43+
44+const makeHandle = (exitCode: number) =>
45+ ChildProcessSpawner.makeHandle({
46+ pid: ChildProcessSpawner.ProcessId(1),
47+ exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(exitCode)),
48+ isRunning: Effect.succeed(false),
49+ kill: () => Effect.void,
50+ unref: Effect.succeed(Effect.void),
51+ stdin: Sink.drain,
52+ stdout: Stream.empty,
53+ stderr: Stream.empty,
54+ all: Stream.empty,
55+ getInputFd: () => Sink.drain,
56+ getOutputFd: () => Stream.empty,
57+ });
58+
59+const makeSpawnerLayer = (
60+ calls: Array<{ command: string; args: ReadonlyArray<string> }>,
61+ exitCodes: ReadonlyArray<number>,
62+) =>
63+ Layer.succeed(
64+ ChildProcessSpawner.ChildProcessSpawner,
65+ ChildProcessSpawner.make((command) =>
66+ Effect.sync(() => {
67+ const standardCommand = command as ChildProcess.StandardCommand;
68+ calls.push({ command: standardCommand.command, args: standardCommand.args });
69+ return makeHandle(exitCodes[calls.length - 1] ?? 0);
70+ }),
71+ ),
72+ );
73+
74+const makeTestLayer = (
75+ calls: Array<{ command: string; args: ReadonlyArray<string> }>,
76+ exitCodes: ReadonlyArray<number>,
77+ keys: ReadonlyArray<string> = [],
78+) =>
79+ Layer.mergeAll(
80+ TestConsole.layer,
81+ FileSystem.layerNoop({}),
82+ Path.layer,
83+ makeTerminalLayer(keys),
84+ makeSpawnerLayer(calls, exitCodes),
85+ );
86+
87+describe("desktopAppLaunchCommands", () => {
88+ it("tries Alpha and Nightly app names on macOS", () => {
89+ expect(desktopAppLaunchCommands("darwin")).toEqual([
90+ { command: "open", args: ["-a", "T3 Code (Alpha)"] },
91+ { command: "open", args: ["-a", "T3 Code (Nightly)"] },
92+ ]);
93+ });
94+
95+ it("passes the workspace path through macOS app launches", () => {
96+ expect(desktopAppLaunchCommands("darwin", "/repo/app")).toEqual([
97+ {
98+ command: "open",
99+ args: ["-a", "T3 Code (Alpha)", "--args", "--t3-open-path", "/repo/app"],
100+ },
101+ {
102+ command: "open",
103+ args: ["-a", "T3 Code (Nightly)", "--args", "--t3-open-path", "/repo/app"],
104+ },
105+ ]);
106+ });
107+
108+ it("tries the Linux desktop entry before the executable", () => {
109+ expect(desktopAppLaunchCommands("linux")).toEqual([
110+ { command: "gtk-launch", args: ["t3code.desktop"] },
111+ { command: "t3code", args: [] },
112+ ]);
113+ });
114+
115+ it("uses the Linux executable directly when passing a workspace path", () => {
116+ expect(desktopAppLaunchCommands("linux", "/repo/app")).toEqual([
117+ { command: "t3code", args: ["--t3-open-path", "/repo/app"] },
118+ ]);
119+ });
120+});
121+
122+describe("openDesktopAppOrPrompt", () => {
123+ it.effect("opens the installed app without prompting for releases", () =>
124+ Effect.gen(function* () {
125+ const calls: Array<{ command: string; args: ReadonlyArray<string> }> = [];
126+
127+ const result = yield* openDesktopAppOrPrompt("darwin", "/repo/app").pipe(
128+ Effect.provide(makeTestLayer(calls, [0])),
129+ );
130+ const logs = yield* TestConsole.logLines;
131+
132+ expect(result).toEqual({ _tag: "opened-app" });
133+ expect(calls).toEqual([
134+ {
135+ command: "open",
136+ args: ["-a", "T3 Code (Alpha)", "--args", "--t3-open-path", "/repo/app"],
137+ },
138+ ]);
139+ expect(logs).toContain("Opened T3 Code.");
140+ }),
141+ );
142+
143+ it.effect("opens Nightly when Alpha is not installed", () =>
144+ Effect.gen(function* () {
145+ const calls: Array<{ command: string; args: ReadonlyArray<string> }> = [];
146+
147+ const result = yield* openDesktopAppOrPrompt("darwin").pipe(
148+ Effect.provide(makeTestLayer(calls, [1, 0])),
149+ );
150+ const logs = yield* TestConsole.logLines;
151+
152+ expect(result).toEqual({ _tag: "opened-app" });
153+ expect(calls).toEqual([
154+ { command: "open", args: ["-a", "T3 Code (Alpha)"] },
155+ { command: "open", args: ["-a", "T3 Code (Nightly)"] },
156+ ]);
157+ expect(logs).toContain("Opened T3 Code.");
158+ }),
159+ );
160+
161+ it.effect("prompts and opens GitHub releases when app launch fails", () =>
162+ Effect.gen(function* () {
163+ const calls: Array<{ command: string; args: ReadonlyArray<string> }> = [];
164+
165+ const result = yield* openDesktopAppOrPrompt("darwin").pipe(
166+ Effect.provide(makeTestLayer(calls, [1, 1, 0], ["enter"])),
167+ );
168+ const logs = yield* TestConsole.logLines;
169+
170+ expect(result).toEqual({ _tag: "opened-releases" });
171+ expect(calls).toEqual([
172+ { command: "open", args: ["-a", "T3 Code (Alpha)"] },
173+ { command: "open", args: ["-a", "T3 Code (Nightly)"] },
174+ { command: "open", args: [DESKTOP_RELEASES_URL] },
175+ ]);
176+ expect(logs.some((line) => String(line).includes("Yes, open github"))).toBe(true);
177+ expect(logs).toContain(`Opened ${DESKTOP_RELEASES_URL}`);
178+ }),
179+ );
180+
181+ it.effect("returns use-web-ui without opening releases when selected", () =>
182+ Effect.gen(function* () {
183+ const calls: Array<{ command: string; args: ReadonlyArray<string> }> = [];
184+
185+ const result = yield* openDesktopAppOrPrompt("darwin").pipe(
186+ Effect.provide(makeTestLayer(calls, [1, 1], ["down", "enter"])),
187+ );
188+ const logs = yield* TestConsole.logLines;
189+
190+ expect(result).toEqual({ _tag: "use-web-ui" });
191+ expect(calls).toEqual([
192+ { command: "open", args: ["-a", "T3 Code (Alpha)"] },
193+ { command: "open", args: ["-a", "T3 Code (Nightly)"] },
194+ ]);
195+ expect(logs).toContain("Starting the web UI.");
196+ }),
197+ );
198+
199+ it.effect("prints the releases URL without opening a browser when exiting", () =>
200+ Effect.gen(function* () {
201+ const calls: Array<{ command: string; args: ReadonlyArray<string> }> = [];
202+
203+ const result = yield* openDesktopAppOrPrompt("darwin").pipe(
204+ Effect.provide(makeTestLayer(calls, [1, 1], ["down", "down", "enter"])),
205+ );
206+ const logs = yield* TestConsole.logLines;
207+
208+ expect(result).toEqual({ _tag: "exit" });
209+ expect(calls).toEqual([
210+ { command: "open", args: ["-a", "T3 Code (Alpha)"] },
211+ { command: "open", args: ["-a", "T3 Code (Nightly)"] },
212+ ]);
213+ expect(logs).toContain(`Download T3 Code from ${DESKTOP_RELEASES_URL}`);
214+ }),
215+ );
216+});
apps/server/src/desktopAppLauncher.ts+264−0
@@ -0,0 +1,264 @@
1+import { Console, Data, Duration, Effect, Option, Stream } from "effect";
2+import { Prompt } from "effect/unstable/cli";
3+import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
4+
5+const DESKTOP_APP_NAMES = ["T3 Code (Alpha)", "T3 Code (Nightly)"] as const;
6+const DESKTOP_APP_NAME = "T3 Code";
7+const DESKTOP_LINUX_DESKTOP_ENTRY = "t3code.desktop";
8+const DESKTOP_LINUX_EXECUTABLE = "t3code";
9+export const DESKTOP_RELEASES_URL = "https://github.com/pingdotgg/t3code/releases/latest";
10+
11+type Platform = NodeJS.Platform;
12+
13+export interface DesktopLaunchCommand {
14+ readonly command: string;
15+ readonly args: ReadonlyArray<string>;
16+}
17+
18+export class DesktopAppLaunchError extends Data.TaggedError("DesktopAppLaunchError")<{
19+ readonly command: string;
20+ readonly message: string;
21+ readonly exitCode?: number | undefined;
22+ readonly cause?: unknown;
23+}> {}
24+
25+export type DesktopAppFallbackChoice = "open-github" | "use-web-ui" | "exit";
26+
27+export interface DesktopAppFallbackPromptChoice {
28+ readonly title: string;
29+ readonly value: DesktopAppFallbackChoice;
30+}
31+
32+export const desktopAppFallbackPromptChoices: ReadonlyArray<DesktopAppFallbackPromptChoice> = [
33+ { title: "Yes, open github", value: "open-github" },
34+ { title: "No, use web UI", value: "use-web-ui" },
35+ { title: "No, exit", value: "exit" },
36+];
37+
38+export type DesktopAppLaunchResult =
39+ | { readonly _tag: "opened-app" }
40+ | { readonly _tag: "opened-releases" }
41+ | { readonly _tag: "use-web-ui" }
42+ | { readonly _tag: "exit" };
43+
44+const desktopAppLaunchArgs = (workspacePath: string | undefined): ReadonlyArray<string> =>
45+ workspacePath ? ["--t3-open-path", workspacePath] : [];
46+
47+function quotePowerShellSingle(value: string): string {
48+ return `'${value.replaceAll("'", "''")}'`;
49+}
50+
51+export function desktopAppLaunchCommands(
52+ platform: Platform,
53+ workspacePath?: string,
54+): ReadonlyArray<DesktopLaunchCommand> {
55+ const appArgs = desktopAppLaunchArgs(workspacePath);
56+ if (platform === "darwin") {
57+ return DESKTOP_APP_NAMES.map((appName) => ({
58+ command: "open",
59+ args: appArgs.length > 0 ? ["-a", appName, "--args", ...appArgs] : ["-a", appName],
60+ }));
61+ }
62+
63+ if (platform === "win32") {
64+ const argumentList =
65+ appArgs.length > 0
66+ ? ` -ArgumentList @(${appArgs.map(quotePowerShellSingle).join(", ")})`
67+ : "";
68+ const script = [
69+ "$names = @('T3 Code (Alpha)', 'T3 Code (Nightly)', 'T3 Code')",
70+ "$roots = @($env:LOCALAPPDATA, $env:ProgramFiles, ${env:ProgramFiles(x86)}) | Where-Object { $_ }",
71+ '$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") } }',
72+ "$target = $candidates | Where-Object { Test-Path $_ } | Select-Object -First 1",
73+ "if (-not $target) { exit 1 }",
74+ `Start-Process -FilePath $target${argumentList}`,
75+ ].join("; ");
76+
77+ return [
78+ {
79+ command: "powershell.exe",
80+ args: ["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", script],
81+ },
82+ ];
83+ }
84+
85+ if (platform === "linux") {
86+ if (appArgs.length > 0) {
87+ return [{ command: DESKTOP_LINUX_EXECUTABLE, args: appArgs }];
88+ }
89+
90+ return [
91+ { command: "gtk-launch", args: [DESKTOP_LINUX_DESKTOP_ENTRY] },
92+ { command: DESKTOP_LINUX_EXECUTABLE, args: [] },
93+ ];
94+ }
95+
96+ return [];
97+}
98+
99+function openReleasesCommand(platform: Platform): DesktopLaunchCommand {
100+ if (platform === "darwin") {
101+ return { command: "open", args: [DESKTOP_RELEASES_URL] };
102+ }
103+
104+ if (platform === "win32") {
105+ return {
106+ command: "powershell.exe",
107+ args: [
108+ "-NoProfile",
109+ "-ExecutionPolicy",
110+ "Bypass",
111+ "-Command",
112+ `Start-Process ${JSON.stringify(DESKTOP_RELEASES_URL)}`,
113+ ],
114+ };
115+ }
116+
117+ return { command: "xdg-open", args: [DESKTOP_RELEASES_URL] };
118+}
119+
120+function commandLabel(command: string, args: ReadonlyArray<string>): string {
121+ return [command, ...args].join(" ");
122+}
123+
124+const runDesktopCommand = (
125+ command: string,
126+ args: ReadonlyArray<string>,
127+): Effect.Effect<void, DesktopAppLaunchError, ChildProcessSpawner.ChildProcessSpawner> =>
128+ Effect.gen(function* () {
129+ const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
130+ const label = commandLabel(command, args);
131+
132+ const exitCode = yield* Effect.scoped(
133+ Effect.gen(function* () {
134+ const child = yield* spawner
135+ .spawn(
136+ ChildProcess.make(command, [...args], {
137+ env: process.env,
138+ }),
139+ )
140+ .pipe(
141+ Effect.mapError(
142+ (cause) =>
143+ new DesktopAppLaunchError({
144+ command: label,
145+ message: `Failed to run ${label}.`,
146+ cause,
147+ }),
148+ ),
149+ );
150+
151+ yield* Effect.addFinalizer(() => child.kill().pipe(Effect.ignore));
152+
153+ const [, , code] = yield* Effect.all(
154+ [Stream.runDrain(child.stdout), Stream.runDrain(child.stderr), child.exitCode],
155+ { concurrency: "unbounded" },
156+ ).pipe(
157+ Effect.mapError(
158+ (cause) =>
159+ new DesktopAppLaunchError({
160+ command: label,
161+ message: `Failed to collect result for ${label}.`,
162+ cause,
163+ }),
164+ ),
165+ );
166+
167+ return code;
168+ }).pipe(
169+ Effect.timeoutOption(Duration.millis(10_000)),
170+ Effect.flatMap((result) =>
171+ Option.match(result, {
172+ onSome: Effect.succeed,
173+ onNone: () =>
174+ Effect.fail(
175+ new DesktopAppLaunchError({
176+ command: label,
177+ message: `${label} timed out.`,
178+ }),
179+ ),
180+ }),
181+ ),
182+ ),
183+ );
184+
185+ if (exitCode !== 0) {
186+ return yield* new DesktopAppLaunchError({
187+ command: label,
188+ message: `${label} exited with code ${exitCode}.`,
189+ exitCode,
190+ });
191+ }
192+ });
193+
194+const chooseFallback = (
195+ message: string,
196+): Effect.Effect<DesktopAppFallbackChoice, DesktopAppLaunchError, Prompt.Environment> =>
197+ Prompt.select({
198+ message,
199+ choices: desktopAppFallbackPromptChoices,
200+ }).pipe(
201+ Prompt.run,
202+ Effect.mapError(
203+ (cause) =>
204+ new DesktopAppLaunchError({
205+ command: "desktop fallback prompt",
206+ message: "Desktop fallback prompt was cancelled.",
207+ cause,
208+ }),
209+ ),
210+ );
211+
212+function tryLaunchDesktopApp(
213+ platform: Platform,
214+ workspacePath: string | undefined,
215+): Effect.Effect<boolean, never, ChildProcessSpawner.ChildProcessSpawner> {
216+ return Effect.gen(function* () {
217+ for (const launchCommand of desktopAppLaunchCommands(platform, workspacePath)) {
218+ const launched = yield* runDesktopCommand(launchCommand.command, launchCommand.args).pipe(
219+ Effect.as(true),
220+ Effect.catchCause(() => Effect.succeed(false)),
221+ );
222+ if (launched) {
223+ return true;
224+ }
225+ }
226+
227+ return false;
228+ });
229+}
230+
231+export function openDesktopAppOrPrompt(
232+ platform: Platform,
233+ workspacePath?: string,
234+): Effect.Effect<
235+ DesktopAppLaunchResult,
236+ DesktopAppLaunchError,
237+ ChildProcessSpawner.ChildProcessSpawner | Prompt.Environment
238+> {
239+ return Effect.gen(function* () {
240+ if (yield* tryLaunchDesktopApp(platform, workspacePath)) {
241+ yield* Console.log(`Opened ${DESKTOP_APP_NAME}.`);
242+ return { _tag: "opened-app" } satisfies DesktopAppLaunchResult;
243+ }
244+
245+ const fallbackChoice = yield* chooseFallback(
246+ `${DESKTOP_APP_NAME} does not appear to be installed.`,
247+ );
248+
249+ if (fallbackChoice === "use-web-ui") {
250+ yield* Console.log("Starting the web UI.");
251+ return { _tag: "use-web-ui" } satisfies DesktopAppLaunchResult;
252+ }
253+
254+ if (fallbackChoice === "exit") {
255+ yield* Console.log(`Download ${DESKTOP_APP_NAME} from ${DESKTOP_RELEASES_URL}`);
256+ return { _tag: "exit" } satisfies DesktopAppLaunchResult;
257+ }
258+
259+ const command = openReleasesCommand(platform);
260+ yield* runDesktopCommand(command.command, command.args);
261+ yield* Console.log(`Opened ${DESKTOP_RELEASES_URL}`);
262+ return { _tag: "opened-releases" } satisfies DesktopAppLaunchResult;
263+ });
264+}
apps/web/src/components/settings/SettingsPanels.browser.tsx+1−0
@@ -378,6 +378,7 @@ const createDesktopBridgeStub = (overrides?: { · 377 unmodified lines
378378 showContextMenu: vi.fn().mockResolvedValue(null),
379379 openExternal: vi.fn().mockResolvedValue(true),
380380 onMenuAction: () => () => {},
381+ onOpenWorkspace: () => () => {},
381382 getUpdateState: vi.fn().mockResolvedValue(idleUpdateState),
382383 setUpdateChannel:
383384 overrides?.setUpdateChannel ??
apps/web/src/localApi.test.ts+1−0
@@ -225,6 +225,7 @@ function makeDesktopBridge(overrides: Partial<DesktopBridge> = {}): DesktopBridg · 224 unmodified lines
225225 showContextMenu: async () => null,
226226 openExternal: async () => true,
227227 onMenuAction: () => () => undefined,
228+ onOpenWorkspace: () => () => undefined,
228229 getUpdateState: async () => {
229230 throw new Error("getUpdateState not implemented in test");
230231 },
apps/web/src/routes/__root.tsx+122−3
@@ -1,5 +1,11 @@
1−import { type ServerLifecycleWelcomePayload } from "@t3tools/contracts";
2−import { scopedProjectKey, scopeProjectRef } from "@t3tools/client-runtime";
1+import {
2+ DEFAULT_MODEL,
3+ DEFAULT_PROVIDER_INTERACTION_MODE,
4+ DEFAULT_RUNTIME_MODE,
5+ ProviderInstanceId,
6+ type ServerLifecycleWelcomePayload,
7+} from "@t3tools/contracts";
8+import { scopedProjectKey, scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime";
39 import {
410 Outlet,
511 createRootRouteWithContext,
@@ -28,7 +34,9 @@ import { · 22 unmodified lines
2834 } from "../components/ui/toast";
2935 import { resolveAndPersistPreferredEditor } from "../editorPreferences";
3036 import { readLocalApi } from "../localApi";
37+import { readEnvironmentApi } from "../environmentApi";
3138 import { useSettings } from "../hooks/useSettings";
39+import { useNewThreadHandler } from "../hooks/useHandleNewThread";
3240 import {
3341 deriveLogicalProjectKeyFromSettings,
3442 derivePhysicalProjectKeyFromPath,
@@ -41,8 +49,12 @@ import { · 6 unmodified lines
4149 useServerConfigUpdatedSubscription,
4250 useServerWelcomeSubscription,
4351 } from "../rpc/serverState";
44−import { useStore } from "../store";
52+import { selectSidebarThreadsForProjectRef, useStore } from "../store";
4553 import { useUiStateStore } from "../uiStateStore";
54+import { getLatestThreadForProject } from "../lib/threadSort";
55+import { inferProjectTitleFromPath, findProjectByPath } from "../lib/projectPaths";
56+import { newCommandId, newProjectId, newThreadId } from "../lib/utils";
57+import { buildThreadRouteParams } from "../threadRoutes";
4658 import { syncBrowserChromeTheme } from "../hooks/useTheme";
4759 import {
4860 ensureEnvironmentConnectionBootstrapped,
@@ -136,6 +148,7 @@ function RootRouteView() { · 87 unmodified lines
136148 <SshPasswordPromptDialog />
137149 <HostedStaticEnvironmentBootstrap />
138150 {primaryEnvironmentAuthenticated ? <EventRouter /> : null}
151+ {primaryEnvironmentAuthenticated ? <DesktopOpenWorkspaceBootstrap /> : null}
139152 {primaryEnvironmentAuthenticated ? <WebSocketConnectionCoordinator /> : null}
140153 {primaryEnvironmentAuthenticated ? <SlowRpcAckToastCoordinator /> : null}
141154 {primaryEnvironmentAuthenticated ? (
@@ -148,6 +161,112 @@ function RootRouteView() { · 6 unmodified lines
148161 );
149162 }
150163
164+function DesktopOpenWorkspaceBootstrap() {
165+ const navigate = useNavigate();
166+ const serverConfig = useServerConfig();
167+ const sidebarThreadSortOrder = useSettings((settings) => settings.sidebarThreadSortOrder);
168+ const defaultThreadEnvMode = useSettings((settings) => settings.defaultThreadEnvMode);
169+ const { handleNewThread } = useNewThreadHandler();
170+
171+ const openWorkspace = useEffectEvent(async (rawWorkspacePath: string) => {
172+ const workspacePath = rawWorkspacePath.trim();
173+ if (!workspacePath) return;
174+
175+ const environmentId =
176+ serverConfig?.environment.environmentId ?? getPrimaryKnownEnvironment()?.environmentId;
177+ if (!environmentId) return;
178+
179+ await ensureEnvironmentConnectionBootstrapped(environmentId);
180+ const api = readEnvironmentApi(environmentId);
181+ if (!api) return;
182+
183+ const environmentState = useStore.getState().environmentStateById[environmentId];
184+ const projects = Object.values(environmentState?.projectById ?? {});
185+ const existingProject = findProjectByPath(projects, workspacePath);
186+
187+ const openThread = async (projectId: (typeof projects)[number]["id"]) => {
188+ const projectRef = scopeProjectRef(environmentId, projectId);
189+ const latestThread = getLatestThreadForProject(
190+ selectSidebarThreadsForProjectRef(useStore.getState(), projectRef),
191+ projectId,
192+ sidebarThreadSortOrder,
193+ );
194+ if (latestThread) {
195+ await navigate({
196+ to: "/$environmentId/$threadId",
197+ params: buildThreadRouteParams(
198+ scopeThreadRef(latestThread.environmentId, latestThread.id),
199+ ),
200+ });
201+ return;
202+ }
203+
204+ await handleNewThread(projectRef, {
205+ envMode: defaultThreadEnvMode,
206+ });
207+ };
208+
209+ if (existingProject) {
210+ await openThread(existingProject.id);
211+ return;
212+ }
213+
214+ const createdAt = new Date().toISOString();
215+ const projectId = newProjectId();
216+ const threadId = newThreadId();
217+ const defaultModelSelection = {
218+ instanceId: ProviderInstanceId.make("codex"),
219+ model: DEFAULT_MODEL,
220+ };
221+
222+ await api.orchestration.dispatchCommand({
223+ type: "project.create",
224+ commandId: newCommandId(),
225+ projectId,
226+ title: inferProjectTitleFromPath(workspacePath),
227+ workspaceRoot: workspacePath,
228+ createWorkspaceRootIfMissing: true,
229+ defaultModelSelection,
230+ createdAt,
231+ });
232+ await api.orchestration.dispatchCommand({
233+ type: "thread.create",
234+ commandId: newCommandId(),
235+ threadId,
236+ projectId,
237+ title: "New thread",
238+ modelSelection: defaultModelSelection,
239+ runtimeMode: DEFAULT_RUNTIME_MODE,
240+ interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
241+ branch: null,
242+ worktreePath: null,
243+ createdAt,
244+ });
245+ await navigate({
246+ to: "/$environmentId/$threadId",
247+ params: buildThreadRouteParams(scopeThreadRef(environmentId, threadId)),
248+ });
249+ });
250+
251+ useEffect(() => {
252+ const unsubscribe = window.desktopBridge?.onOpenWorkspace?.((workspacePath) => {
253+ void openWorkspace(workspacePath).catch((error) => {
254+ toastManager.add(
255+ stackedThreadToast({
256+ type: "error",
257+ title: "Unable to open workspace",
258+ description: error instanceof Error ? error.message : "Unknown error.",
259+ }),
260+ );
261+ });
262+ });
263+
264+ return unsubscribe ?? undefined;
265+ }, []);
266+
267+ return null;
268+}
269+
151270 function HostedStaticEnvironmentBootstrap() {
152271 const savedEnvironmentCount = useSavedEnvironmentRegistryStore(
153272 (state) => Object.keys(state.byId).length,
packages/contracts/src/ipc.ts+1−0
@@ -242,6 +242,7 @@ export interface DesktopBridge { · 241 unmodified lines
242242 ) => Promise<T | null>;
243243 openExternal: (url: string) => Promise<boolean>;
244244 onMenuAction: (listener: (action: string) => void) => () => void;
245+ onOpenWorkspace: (listener: (workspacePath: string) => void) => () => void;
245246 getUpdateState: () => Promise<DesktopUpdateState>;
246247 setUpdateChannel: (channel: DesktopUpdateChannel) => Promise<DesktopUpdateState>;
247248 checkForUpdate: () => Promise<DesktopUpdateCheckResult>;