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
93const CONTEXT_MENU_CHANNEL = "desktop:context-menu";93const CONTEXT_MENU_CHANNEL = "desktop:context-menu";
94const OPEN_EXTERNAL_CHANNEL = "desktop:open-external";94const OPEN_EXTERNAL_CHANNEL = "desktop:open-external";
95const MENU_ACTION_CHANNEL = "desktop:menu-action";95const MENU_ACTION_CHANNEL = "desktop:menu-action";
96const OPEN_WORKSPACE_CHANNEL = "desktop:open-workspace";
96const UPDATE_STATE_CHANNEL = "desktop:update-state";97const UPDATE_STATE_CHANNEL = "desktop:update-state";
97const UPDATE_GET_STATE_CHANNEL = "desktop:update-get-state";98const UPDATE_GET_STATE_CHANNEL = "desktop:update-get-state";
98const UPDATE_SET_CHANNEL_CHANNEL = "desktop:update-set-channel";99const 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
118const CLIENT_SETTINGS_PATH = Path.join(STATE_DIR, "client-settings.json");119const CLIENT_SETTINGS_PATH = Path.join(STATE_DIR, "client-settings.json");
119const SAVED_ENVIRONMENT_REGISTRY_PATH = Path.join(STATE_DIR, "saved-environments.json");120const SAVED_ENVIRONMENT_REGISTRY_PATH = Path.join(STATE_DIR, "saved-environments.json");
120const DESKTOP_SCHEME = "t3";121const DESKTOP_SCHEME = "t3";
122const OPEN_WORKSPACE_ARG = "--t3-open-path";
121const ROOT_DIR = Path.resolve(__dirname, "../../..");123const ROOT_DIR = Path.resolve(__dirname, "../../..");
122const isDevelopment = Boolean(process.env.VITE_DEV_SERVER_URL);124const isDevelopment = Boolean(process.env.VITE_DEV_SERVER_URL);
123// Dev-only SSH launcher override. Set this to an absolute path on the SSH host125// 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
239let backendObservabilitySettings = readPersistedBackendObservabilitySettings();241let backendObservabilitySettings = readPersistedBackendObservabilitySettings();
240let desktopSettings = readDesktopSettings(DESKTOP_SETTINGS_PATH, app.getVersion());242let desktopSettings = readDesktopSettings(DESKTOP_SETTINGS_PATH, app.getVersion());
241let desktopServerExposureMode: DesktopServerExposureMode = desktopSettings.serverExposureMode;243let desktopServerExposureMode: DesktopServerExposureMode = desktopSettings.serverExposureMode;
244let pendingOpenWorkspacePath = resolveOpenWorkspaceArg(process.argv.slice(1));
242 245
243let destructiveMenuIconCache: Electron.NativeImage | null | undefined;246let destructiveMenuIconCache: Electron.NativeImage | null | undefined;
244const expectedBackendExitChildren = new WeakSet<ChildProcess.ChildProcess>();247const expectedBackendExitChildren = new WeakSet<ChildProcess.ChildProcess>();
@@ -471,6 +474,23 @@ function formatErrorMessage(error: unknown): string { · 226 unmodified lines
471 return String(error);474 return String(error);
472}475}
473 476
477function 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
474function getSafeExternalUrl(rawUrl: unknown): string | null {494function getSafeExternalUrl(rawUrl: unknown): string | null {
475 if (typeof rawUrl !== "string" || rawUrl.length === 0) {495 if (typeof rawUrl !== "string" || rawUrl.length === 0) {
476 return null;496 return null;
@@ -567,6 +587,32 @@ function ensureInitialBackendWindowOpen(): void { · 90 unmodified lines
567 backendInitialWindowOpenInFlight = nextOpen;587 backendInitialWindowOpenInFlight = nextOpen;
568}588}
569 589
590function 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
605function 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
570function writeDesktopStreamChunk(616function writeDesktopStreamChunk(
571 streamName: "stdout" | "stderr",617 streamName: "stdout" | "stderr",
572 chunk: unknown,618 chunk: unknown,
@@ -1464,6 +1510,8 @@ function startBackend(): void { · 891 unmodified lines
1464 return;1510 return;
1465 }1511 }
1466 1512
1513 const bootstrapWorkspacePath = pendingOpenWorkspacePath;
1514 pendingOpenWorkspacePath = null;
1467 const captureBackendLogs = !isDevelopment;1515 const captureBackendLogs = !isDevelopment;
1468 const child = ChildProcess.spawn(process.execPath, [backendEntry, "--bootstrap-fd", "3"], {1516 const child = ChildProcess.spawn(process.execPath, [backendEntry, "--bootstrap-fd", "3"], {
1469 cwd: resolveBackendCwd(),1517 cwd: resolveBackendCwd(),
@@ -1487,6 +1535,9 @@ function startBackend(): void { · 17 unmodified lines
1487 t3Home: BASE_DIR,1535 t3Home: BASE_DIR,
1488 host: backendBindHost,1536 host: backendBindHost,
1489 desktopBootstrapToken: backendBootstrapToken,1537 desktopBootstrapToken: backendBootstrapToken,
1538 ...(bootstrapWorkspacePath
1539 ? { cwd: bootstrapWorkspacePath, autoBootstrapProjectFromCwd: true }
1540 : {}),
1490 tailscaleServeEnabled: desktopSettings.tailscaleServeEnabled,1541 tailscaleServeEnabled: desktopSettings.tailscaleServeEnabled,
1491 tailscaleServePort: desktopSettings.tailscaleServePort,1542 tailscaleServePort: desktopSettings.tailscaleServePort,
1492 ...(backendObservabilitySettings.otlpTracesUrl1543 ...(backendObservabilitySettings.otlpTracesUrl
@@ -2135,6 +2186,12 @@ function createWindow(): BrowserWindow { · 642 unmodified lines
2135 void window.loadURL(backendHttpUrl);2186 void window.loadURL(backendHttpUrl);
2136 }2187 }
2137 2188
2189 if (pendingOpenWorkspacePath) {
2190 const workspacePath = pendingOpenWorkspacePath;
2191 pendingOpenWorkspacePath = null;
2192 sendOpenWorkspace(window, workspacePath);
2193 }
2194
2138 window.on("closed", () => {2195 window.on("closed", () => {
2139 desktopSshEnvironmentBridge.cancelPendingPasswordPrompts(2196 desktopSshEnvironmentBridge.cancelPendingPasswordPrompts(
2140 "SSH authentication was cancelled because the app window closed.",2197 "SSH authentication was cancelled because the app window closed.",
@@ -2234,38 +2291,59 @@ app.on("before-quit", () => { · 93 unmodified lines
2234 restoreStdIoCapture?.();2291 restoreStdIoCapture?.();
2235});2292});
2236 2293
2237app2294const hasSingleInstanceLock = app.requestSingleInstanceLock();
2238 .whenReady()2295if (!hasSingleInstanceLock) {
2239 .then(() => {2296 app.quit();
2240 writeDesktopLogHeader("app ready");2297} else {
2241 configureAppIdentity();2298 app.on("second-instance", (_event, commandLine) => {
2242 configureApplicationMenu();2299 const workspacePath = resolveOpenWorkspaceArg(commandLine);
2243 registerDesktopProtocol();2300 if (workspacePath) {
2244 configureAutoUpdater();2301 dispatchOpenWorkspace(workspacePath);
2245 void bootstrap().catch((error) => {2302 return;
2246 if (isBackendReadinessAborted(error) && isQuitting) {2303 }
2247 return;
2248 }
2249 handleFatalStartupError("bootstrap", error);
2250 });
2251 2304
2252 app.on("activate", () => {2305 const existingWindow = mainWindow ?? BrowserWindow.getAllWindows()[0];
2253 const existingWindow = mainWindow ?? BrowserWindow.getAllWindows()[0];2306 if (existingWindow) {
2254 if (existingWindow) {2307 revealWindow(existingWindow);
2255 revealWindow(existingWindow);2308 return;
2256 return;2309 }
2257 }2310
2258 if (isDevelopment) {2311 ensureInitialBackendWindowOpen();
2259 mainWindow = createWindow();
2260 return;
2261 }
2262 ensureInitialBackendWindowOpen();
2263 });
2264 })
2265 .catch((error) => {
2266 handleFatalStartupError("whenReady", error);
2267 });2312 });
2268 2313
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
2269app.on("window-all-closed", () => {2347app.on("window-all-closed", () => {
2270 if (process.platform !== "darwin" && !isQuitting) {2348 if (process.platform !== "darwin" && !isQuitting) {
2271 app.quit();2349 app.quit();
apps/desktop/src/preload.ts+12−0
@@ -7,6 +7,7 @@ const SET_THEME_CHANNEL = "desktop:set-theme"; · 6 unmodified lines
7const CONTEXT_MENU_CHANNEL = "desktop:context-menu";7const CONTEXT_MENU_CHANNEL = "desktop:context-menu";
8const OPEN_EXTERNAL_CHANNEL = "desktop:open-external";8const OPEN_EXTERNAL_CHANNEL = "desktop:open-external";
9const MENU_ACTION_CHANNEL = "desktop:menu-action";9const MENU_ACTION_CHANNEL = "desktop:menu-action";
10const OPEN_WORKSPACE_CHANNEL = "desktop:open-workspace";
10const UPDATE_STATE_CHANNEL = "desktop:update-state";11const UPDATE_STATE_CHANNEL = "desktop:update-state";
11const UPDATE_GET_STATE_CHANNEL = "desktop:update-get-state";12const UPDATE_GET_STATE_CHANNEL = "desktop:update-get-state";
12const UPDATE_SET_CHANNEL_CHANNEL = "desktop:update-set-channel";13const UPDATE_SET_CHANNEL_CHANNEL = "desktop:update-set-channel";
@@ -128,6 +129,17 @@ contextBridge.exposeInMainWorld("desktopBridge", { · 115 unmodified lines
128 ipcRenderer.removeListener(MENU_ACTION_CHANNEL, wrappedListener);129 ipcRenderer.removeListener(MENU_ACTION_CHANNEL, wrappedListener);
129 };130 };
130 },131 },
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 },
131 getUpdateState: () => ipcRenderer.invoke(UPDATE_GET_STATE_CHANNEL),143 getUpdateState: () => ipcRenderer.invoke(UPDATE_GET_STATE_CHANNEL),
132 setUpdateChannel: (channel) => ipcRenderer.invoke(UPDATE_SET_CHANNEL_CHANNEL, channel),144 setUpdateChannel: (channel) => ipcRenderer.invoke(UPDATE_SET_CHANNEL_CHANNEL, channel),
133 checkForUpdate: () => ipcRenderer.invoke(UPDATE_CHECK_CHANNEL),145 checkForUpdate: () => ipcRenderer.invoke(UPDATE_CHECK_CHANNEL),
apps/server/src/cli.ts+22−7
@@ -65,6 +65,7 @@ import { · 64 unmodified lines
65} from "./serverRuntimeState.ts";65} from "./serverRuntimeState.ts";
66import { WorkspacePaths } from "./workspace/Services/WorkspacePaths.ts";66import { WorkspacePaths } from "./workspace/Services/WorkspacePaths.ts";
67import { WorkspacePathsLive } from "./workspace/Layers/WorkspacePaths.ts";67import { WorkspacePathsLive } from "./workspace/Layers/WorkspacePaths.ts";
68import { openDesktopAppOrPrompt } from "./desktopAppLauncher.ts";
68 69
69const PortSchema = Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 65535 }));70const PortSchema = Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 65535 }));
70 71
@@ -139,6 +140,10 @@ const tailscaleServePortFlag = Flag.integer("tailscale-serve-port").pipe( · 68 unmodified lines
139 Flag.withDescription("HTTPS port for Tailscale Serve when --tailscale-serve is enabled."),140 Flag.withDescription("HTTPS port for Tailscale Serve when --tailscale-serve is enabled."),
140 Flag.optional,141 Flag.optional,
141);142);
143const appFlag = Flag.boolean("app").pipe(
144 Flag.withDescription("Open or focus the installed T3 Code desktop app."),
145 Flag.withDefault(false),
146);
142 147
143const EnvServerConfig = Config.all({148const EnvServerConfig = Config.all({
144 logLevel: Config.logLevel("T3CODE_LOG_LEVEL").pipe(Config.withDefault("Info")),149 logLevel: Config.logLevel("T3CODE_LOG_LEVEL").pipe(Config.withDefault("Info")),
@@ -1150,11 +1155,21 @@ const runServerCommand = ( · 1005 unmodified lines
1150 readonly forceAutoBootstrapProjectFromCwd?: boolean;1155 readonly forceAutoBootstrapProjectFromCwd?: boolean;
1151 },1156 },
1152) =>1157) =>
1153 Effect.gen(function* () {1158 Effect.service(GlobalFlag.LogLevel).pipe(
1154 const logLevel = yield* GlobalFlag.LogLevel;1159 Effect.flatMap((logLevel) => resolveServerConfig(flags, logLevel, options)),
1155 const config = yield* resolveServerConfig(flags, logLevel, options);1160 Effect.flatMap((config) => Effect.provideService(ServerConfig, config)(runServer)),
1156 return yield* runServer.pipe(Effect.provideService(ServerConfig, config));1161 );
1157 });1162
1163const 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 );
1158 1173
1159const startCommand = Command.make("start", { ...sharedServerCommandFlags }).pipe(1174const startCommand = Command.make("start", { ...sharedServerCommandFlags }).pipe(
1160 Command.withDescription("Run the T3 Code server."),1175 Command.withDescription("Run the T3 Code server."),
@@ -1173,8 +1188,8 @@ const serveCommand = Command.make("serve", { ...sharedServerCommandFlags }).pipe · 12 unmodified lines
1173 ),1188 ),
1174);1189);
1175 1190
1176export const cli = Command.make("t3", { ...sharedServerCommandFlags }).pipe(1191export const cli = Command.make("t3", { ...sharedServerCommandFlags, app: appFlag }).pipe(
1177 Command.withDescription("Run the T3 Code server."),1192 Command.withDescription("Run the T3 Code server."),
1178 Command.withHandler((flags) => runServerCommand(flags)),1193 Command.withHandler((flags) => (flags.app ? runDesktopCommand(flags) : runServerCommand(flags))),
1179 Command.withSubcommands([startCommand, serveCommand, authCommand, projectCommand]),1194 Command.withSubcommands([startCommand, serveCommand, authCommand, projectCommand]),
1180);1195);
apps/server/src/desktopAppLauncher.test.ts+216−0
@@ -0,0 +1,216 @@
1import {
2 Console,
3 Effect,
4 FileSystem,
5 Layer,
6 Option,
7 Path,
8 Queue,
9 Sink,
10 Stream,
11 Terminal,
12} from "effect";
13import { TestConsole } from "effect/testing";
14import { describe, expect, it } from "@effect/vitest";
15import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
16
17import {
18 DESKTOP_RELEASES_URL,
19 desktopAppLaunchCommands,
20 openDesktopAppOrPrompt,
21} from "./desktopAppLauncher.ts";
22
23const makeUserInput = (name: string): Terminal.UserInput => ({
24 input: Option.some(name),
25 key: { name, ctrl: false, meta: false, shift: false },
26});
27
28const 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
44const 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
59const 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
74const 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
87describe("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
122describe("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 @@
1import { Console, Data, Duration, Effect, Option, Stream } from "effect";
2import { Prompt } from "effect/unstable/cli";
3import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
4
5const DESKTOP_APP_NAMES = ["T3 Code (Alpha)", "T3 Code (Nightly)"] as const;
6const DESKTOP_APP_NAME = "T3 Code";
7const DESKTOP_LINUX_DESKTOP_ENTRY = "t3code.desktop";
8const DESKTOP_LINUX_EXECUTABLE = "t3code";
9export const DESKTOP_RELEASES_URL = "https://github.com/pingdotgg/t3code/releases/latest";
10
11type Platform = NodeJS.Platform;
12
13export interface DesktopLaunchCommand {
14 readonly command: string;
15 readonly args: ReadonlyArray<string>;
16}
17
18export 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
25export type DesktopAppFallbackChoice = "open-github" | "use-web-ui" | "exit";
26
27export interface DesktopAppFallbackPromptChoice {
28 readonly title: string;
29 readonly value: DesktopAppFallbackChoice;
30}
31
32export 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
38export type DesktopAppLaunchResult =
39 | { readonly _tag: "opened-app" }
40 | { readonly _tag: "opened-releases" }
41 | { readonly _tag: "use-web-ui" }
42 | { readonly _tag: "exit" };
43
44const desktopAppLaunchArgs = (workspacePath: string | undefined): ReadonlyArray<string> =>
45 workspacePath ? ["--t3-open-path", workspacePath] : [];
46
47function quotePowerShellSingle(value: string): string {
48 return `'${value.replaceAll("'", "''")}'`;
49}
50
51export 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
99function 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
120function commandLabel(command: string, args: ReadonlyArray<string>): string {
121 return [command, ...args].join(" ");
122}
123
124const 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
194const 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
212function 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
231export 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
378 showContextMenu: vi.fn().mockResolvedValue(null),378 showContextMenu: vi.fn().mockResolvedValue(null),
379 openExternal: vi.fn().mockResolvedValue(true),379 openExternal: vi.fn().mockResolvedValue(true),
380 onMenuAction: () => () => {},380 onMenuAction: () => () => {},
381 onOpenWorkspace: () => () => {},
381 getUpdateState: vi.fn().mockResolvedValue(idleUpdateState),382 getUpdateState: vi.fn().mockResolvedValue(idleUpdateState),
382 setUpdateChannel:383 setUpdateChannel:
383 overrides?.setUpdateChannel ??384 overrides?.setUpdateChannel ??
apps/web/src/localApi.test.ts+1−0
@@ -225,6 +225,7 @@ function makeDesktopBridge(overrides: Partial<DesktopBridge> = {}): DesktopBridg · 224 unmodified lines
225 showContextMenu: async () => null,225 showContextMenu: async () => null,
226 openExternal: async () => true,226 openExternal: async () => true,
227 onMenuAction: () => () => undefined,227 onMenuAction: () => () => undefined,
228 onOpenWorkspace: () => () => undefined,
228 getUpdateState: async () => {229 getUpdateState: async () => {
229 throw new Error("getUpdateState not implemented in test");230 throw new Error("getUpdateState not implemented in test");
230 },231 },
apps/web/src/routes/__root.tsx+122−3
@@ -1,5 +1,11 @@
1import { type ServerLifecycleWelcomePayload } from "@t3tools/contracts";1import {
2import { scopedProjectKey, scopeProjectRef } from "@t3tools/client-runtime";2 DEFAULT_MODEL,
3 DEFAULT_PROVIDER_INTERACTION_MODE,
4 DEFAULT_RUNTIME_MODE,
5 ProviderInstanceId,
6 type ServerLifecycleWelcomePayload,
7} from "@t3tools/contracts";
8import { scopedProjectKey, scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime";
3import {9import {
4 Outlet,10 Outlet,
5 createRootRouteWithContext,11 createRootRouteWithContext,
@@ -28,7 +34,9 @@ import { · 22 unmodified lines
28} from "../components/ui/toast";34} from "../components/ui/toast";
29import { resolveAndPersistPreferredEditor } from "../editorPreferences";35import { resolveAndPersistPreferredEditor } from "../editorPreferences";
30import { readLocalApi } from "../localApi";36import { readLocalApi } from "../localApi";
37import { readEnvironmentApi } from "../environmentApi";
31import { useSettings } from "../hooks/useSettings";38import { useSettings } from "../hooks/useSettings";
39import { useNewThreadHandler } from "../hooks/useHandleNewThread";
32import {40import {
33 deriveLogicalProjectKeyFromSettings,41 deriveLogicalProjectKeyFromSettings,
34 derivePhysicalProjectKeyFromPath,42 derivePhysicalProjectKeyFromPath,
@@ -41,8 +49,12 @@ import { · 6 unmodified lines
41 useServerConfigUpdatedSubscription,49 useServerConfigUpdatedSubscription,
42 useServerWelcomeSubscription,50 useServerWelcomeSubscription,
43} from "../rpc/serverState";51} from "../rpc/serverState";
44import { useStore } from "../store";52import { selectSidebarThreadsForProjectRef, useStore } from "../store";
45import { useUiStateStore } from "../uiStateStore";53import { useUiStateStore } from "../uiStateStore";
54import { getLatestThreadForProject } from "../lib/threadSort";
55import { inferProjectTitleFromPath, findProjectByPath } from "../lib/projectPaths";
56import { newCommandId, newProjectId, newThreadId } from "../lib/utils";
57import { buildThreadRouteParams } from "../threadRoutes";
46import { syncBrowserChromeTheme } from "../hooks/useTheme";58import { syncBrowserChromeTheme } from "../hooks/useTheme";
47import {59import {
48 ensureEnvironmentConnectionBootstrapped,60 ensureEnvironmentConnectionBootstrapped,
@@ -136,6 +148,7 @@ function RootRouteView() { · 87 unmodified lines
136 <SshPasswordPromptDialog />148 <SshPasswordPromptDialog />
137 <HostedStaticEnvironmentBootstrap />149 <HostedStaticEnvironmentBootstrap />
138 {primaryEnvironmentAuthenticated ? <EventRouter /> : null}150 {primaryEnvironmentAuthenticated ? <EventRouter /> : null}
151 {primaryEnvironmentAuthenticated ? <DesktopOpenWorkspaceBootstrap /> : null}
139 {primaryEnvironmentAuthenticated ? <WebSocketConnectionCoordinator /> : null}152 {primaryEnvironmentAuthenticated ? <WebSocketConnectionCoordinator /> : null}
140 {primaryEnvironmentAuthenticated ? <SlowRpcAckToastCoordinator /> : null}153 {primaryEnvironmentAuthenticated ? <SlowRpcAckToastCoordinator /> : null}
141 {primaryEnvironmentAuthenticated ? (154 {primaryEnvironmentAuthenticated ? (
@@ -148,6 +161,112 @@ function RootRouteView() { · 6 unmodified lines
148 );161 );
149}162}
150 163
164function 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
151function HostedStaticEnvironmentBootstrap() {270function HostedStaticEnvironmentBootstrap() {
152 const savedEnvironmentCount = useSavedEnvironmentRegistryStore(271 const savedEnvironmentCount = useSavedEnvironmentRegistryStore(
153 (state) => Object.keys(state.byId).length,272 (state) => Object.keys(state.byId).length,
packages/contracts/src/ipc.ts+1−0
@@ -242,6 +242,7 @@ export interface DesktopBridge { · 241 unmodified lines
242 ) => Promise<T | null>;242 ) => Promise<T | null>;
243 openExternal: (url: string) => Promise<boolean>;243 openExternal: (url: string) => Promise<boolean>;
244 onMenuAction: (listener: (action: string) => void) => () => void;244 onMenuAction: (listener: (action: string) => void) => () => void;
245 onOpenWorkspace: (listener: (workspacePath: string) => void) => () => void;
245 getUpdateState: () => Promise<DesktopUpdateState>;246 getUpdateState: () => Promise<DesktopUpdateState>;
246 setUpdateChannel: (channel: DesktopUpdateChannel) => Promise<DesktopUpdateState>;247 setUpdateChannel: (channel: DesktopUpdateChannel) => Promise<DesktopUpdateState>;
247 checkForUpdate: () => Promise<DesktopUpdateCheckResult>;248 checkForUpdate: () => Promise<DesktopUpdateCheckResult>;