Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
136 changes: 107 additions & 29 deletions apps/desktop/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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
Expand Down Expand Up @@ -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<ChildProcess.ChildProcess>();
Expand Down Expand Up @@ -471,6 +474,23 @@ function formatErrorMessage(error: unknown): string {
return String(error);
}

function resolveOpenWorkspaceArg(argv: ReadonlyArray<string>): 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;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -1464,6 +1510,8 @@ function startBackend(): void {
return;
}

const bootstrapWorkspacePath = pendingOpenWorkspacePath;
pendingOpenWorkspacePath = null;
const captureBackendLogs = !isDevelopment;
Comment on lines +1513 to 1515
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium src/main.ts:1513

pendingOpenWorkspacePath is cleared to null at line 1514 before the backend process is confirmed to be running. If the backend crashes immediately and triggers a restart (lines 1589 or 1610), the workspace path is already lost, so the restarted backend opens without the user's intended workspace. Consider only clearing pendingOpenWorkspacePath after the backend successfully signals it is listening.

-  const bootstrapWorkspacePath = pendingOpenWorkspacePath;
-  pendingOpenWorkspacePath = null;
   const captureBackendLogs = !isDevelopment;
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file apps/desktop/src/main.ts around lines 1513-1515:

`pendingOpenWorkspacePath` is cleared to `null` at line 1514 before the backend process is confirmed to be running. If the backend crashes immediately and triggers a restart (lines 1589 or 1610), the workspace path is already lost, so the restarted backend opens without the user's intended workspace. Consider only clearing `pendingOpenWorkspacePath` after the backend successfully signals it is listening.

Evidence trail:
apps/desktop/src/main.ts lines 1513-1514: pendingOpenWorkspacePath captured and cleared. Lines 1538-1540: value written to bootstrap stream. Lines 1575-1590 (child.on('error')): calls scheduleBackendRestart(). Lines 1592-1611 (child.on('exit')): calls scheduleBackendRestart(). Lines 1460-1470 (scheduleBackendRestart): calls startBackend() after delay. Line 244: initial assignment from CLI args. Line 612: assignment in dispatchOpenWorkspace(). No other assignments — nothing in the restart path restores the value.

const child = ChildProcess.spawn(process.execPath, [backendEntry, "--bootstrap-fd", "3"], {
cwd: resolveBackendCwd(),
Expand All @@ -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
Expand Down Expand Up @@ -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.",
Expand Down Expand Up @@ -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();
Expand Down
12 changes: 12 additions & 0 deletions apps/desktop/src/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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),
Expand Down
29 changes: 22 additions & 7 deletions apps/server/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }));

Expand Down Expand Up @@ -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")),
Expand Down Expand Up @@ -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."),
Expand All @@ -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]),
);
Loading
Loading