import { spawn, type ChildProcess } from "node:child_process";import { randomBytes } from "node:crypto";import fs from "node:fs";import net from "node:net";import os from "node:os";import path from "node:path"; import { app, BrowserWindow, dialog, ipcMain, Menu, shell } from "electron"; import { showDesktopConfirmDialog } from "./confirmDialog";import { fixPath } from "./fixPath"; fixPath(); const PICK_FOLDER_CHANNEL = "desktop:pick-folder";const CONFIRM_CHANNEL = "desktop:confirm";const CONTEXT_MENU_CHANNEL = "desktop:context-menu";const OPEN_EXTERNAL_CHANNEL = "desktop:open-external";const ROOT_DIR = path.resolve(__dirname, "../../..");const BACKEND_ENTRY = path.join(ROOT_DIR, "apps/server/dist/index.mjs");const WEB_ENTRY = path.join(ROOT_DIR, "apps/web/dist/index.html");const STATE_DIR = path.join(os.homedir(), ".t3", "userdata");const isDevelopment = Boolean(process.env.VITE_DEV_SERVER_URL); let mainWindow: BrowserWindow | null = null;let backendProcess: ChildProcess | null = null;let backendPort = 0;let backendAuthToken = "";let backendWsUrl = "";let restartAttempt = 0;let restartTimer: ReturnType<typeof setTimeout> | null = null;let isQuitting = false; async function reserveLoopbackPort(): Promise<number> { return new Promise((resolve, reject) => { const probe = net.createServer(); probe.listen(0, "127.0.0.1", () => { const address = probe.address(); const port = typeof address === "object" && address !== null ? address.port : 0; probe.close(() => { if (port > 0) { resolve(port); return; } reject(new Error("Failed to reserve backend port")); }); }); probe.on("error", reject); });} function backendEnv(): NodeJS.ProcessEnv { return { ...process.env, T3CODE_MODE: "desktop", T3CODE_NO_BROWSER: "1", T3CODE_PORT: String(backendPort), T3CODE_STATE_DIR: STATE_DIR, T3CODE_AUTH_TOKEN: backendAuthToken, };} function scheduleBackendRestart(reason: string): void { if (isQuitting || restartTimer) return; const delayMs = Math.min(500 * 2 ** restartAttempt, 10_000); restartAttempt += 1; console.error(`[desktop] backend exited unexpectedly (${reason}); restarting in ${delayMs}ms`); restartTimer = setTimeout(() => { restartTimer = null; startBackend(); }, delayMs);} function startBackend(): void { if (isQuitting || backendProcess) return; if (!fs.existsSync(BACKEND_ENTRY)) { scheduleBackendRestart(`missing server entry at ${BACKEND_ENTRY}`); return; } const child = spawn(process.execPath, [BACKEND_ENTRY], { cwd: ROOT_DIR, // In Electron main, process.execPath points to the Electron binary. // Run the child in Node mode so this backend process does not become a GUI app instance. env: { ...backendEnv(), ELECTRON_RUN_AS_NODE: "1", }, stdio: "inherit", }); backendProcess = child; child.once("spawn", () => { restartAttempt = 0; }); child.on("error", (error) => { if (backendProcess === child) { backendProcess = null; } scheduleBackendRestart(error.message); }); child.on("exit", (code, signal) => { if (backendProcess === child) { backendProcess = null; } if (isQuitting) return; const reason = `code=${code ?? "null"} signal=${signal ?? "null"}`; scheduleBackendRestart(reason); });} function stopBackend(): void { if (restartTimer) { clearTimeout(restartTimer); restartTimer = null; } const child = backendProcess; backendProcess = null; if (!child) return; if (!child.killed) { child.kill("SIGTERM"); setTimeout(() => { if (!child.killed) { child.kill("SIGKILL"); } }, 2_000).unref(); }} function registerIpcHandlers(): void { ipcMain.removeHandler(PICK_FOLDER_CHANNEL); ipcMain.handle(PICK_FOLDER_CHANNEL, async () => { const owner = BrowserWindow.getFocusedWindow() ?? mainWindow; const result = owner ? await dialog.showOpenDialog(owner, { properties: ["openDirectory", "createDirectory"], }) : await dialog.showOpenDialog({ properties: ["openDirectory", "createDirectory"], }); if (result.canceled) return null; return result.filePaths[0] ?? null; }); ipcMain.removeHandler(CONFIRM_CHANNEL); ipcMain.handle(CONFIRM_CHANNEL, async (_event, message: unknown) => { if (typeof message !== "string") { return false; } const owner = BrowserWindow.getFocusedWindow() ?? mainWindow; return showDesktopConfirmDialog(message, owner); }); ipcMain.removeHandler(CONTEXT_MENU_CHANNEL); ipcMain.handle(CONTEXT_MENU_CHANNEL, async (_event, items: { id: string; label: string }[]) => { const window = BrowserWindow.getFocusedWindow() ?? mainWindow; if (!window) return null; return new Promise<string | null>((resolve) => { const menu = Menu.buildFromTemplate( items.map((item) => ({ label: item.label, click: () => resolve(item.id), })), ); menu.popup({ window, callback: () => resolve(null), }); }); }); ipcMain.removeHandler(OPEN_EXTERNAL_CHANNEL); ipcMain.handle(OPEN_EXTERNAL_CHANNEL, async (_event, rawUrl: unknown) => { if (typeof rawUrl !== "string" || rawUrl.length === 0) { return false; } let parsedUrl: URL; try { parsedUrl = new URL(rawUrl); } catch { return false; } if (parsedUrl.protocol !== "https:" && parsedUrl.protocol !== "http:") { return false; } try { await shell.openExternal(parsedUrl.toString()); return true; } catch { return false; } });} function createWindow(): BrowserWindow { const window = new BrowserWindow({ width: 1100, height: 780, minWidth: 840, minHeight: 620, show: false, autoHideMenuBar: true, titleBarStyle: "hiddenInset", trafficLightPosition: { x: 16, y: 18 }, webPreferences: { preload: path.join(__dirname, "preload.js"), contextIsolation: true, nodeIntegration: false, sandbox: true, }, }); window.webContents.setWindowOpenHandler(() => ({ action: "deny" })); window.once("ready-to-show", () => { window.show(); }); if (isDevelopment) { void window.loadURL(process.env.VITE_DEV_SERVER_URL as string); window.webContents.openDevTools({ mode: "detach" }); } else { if (!fs.existsSync(WEB_ENTRY)) { throw new Error(`Web bundle missing at ${WEB_ENTRY}`); } void window.loadFile(WEB_ENTRY); } window.on("closed", () => { if (mainWindow === window) { mainWindow = null; } }); return window;} async function bootstrap(): Promise<void> { backendPort = await reserveLoopbackPort(); backendAuthToken = randomBytes(24).toString("hex"); backendWsUrl = `ws://127.0.0.1:${backendPort}/?token=${encodeURIComponent(backendAuthToken)}`; process.env.T3CODE_DESKTOP_WS_URL = backendWsUrl; registerIpcHandlers(); startBackend(); mainWindow = createWindow();} app.on("before-quit", () => { isQuitting = true; stopBackend();}); app.whenReady().then(() => { void bootstrap(); app.on("activate", () => { if (BrowserWindow.getAllWindows().length === 0) { mainWindow = createWindow(); } });}); app.on("window-all-closed", () => { if (process.platform !== "darwin") { app.quit(); }});
import { spawn, type ChildProcess } from "node:child_process";
import { randomBytes } from "node:crypto";
import fs from "node:fs";
import net from "node:net";
import os from "node:os";
import path from "node:path";
import { app, BrowserWindow, dialog, ipcMain, Menu, shell } from "electron";
import { showDesktopConfirmDialog } from "./confirmDialog";
import { fixPath } from "./fixPath";
fixPath();
const PICK_FOLDER_CHANNEL = "desktop:pick-folder";
const CONFIRM_CHANNEL = "desktop:confirm";
const CONTEXT_MENU_CHANNEL = "desktop:context-menu";
const OPEN_EXTERNAL_CHANNEL = "desktop:open-external";
const ROOT_DIR = path.resolve(__dirname, "../../..");
const BACKEND_ENTRY = path.join(ROOT_DIR, "apps/server/dist/index.mjs");
const WEB_ENTRY = path.join(ROOT_DIR, "apps/web/dist/index.html");
const STATE_DIR = path.join(os.homedir(), ".t3", "userdata");
const isDevelopment = Boolean(process.env.VITE_DEV_SERVER_URL);
let mainWindow: BrowserWindow | null = null;
let backendProcess: ChildProcess | null = null;
let backendPort = 0;
let backendAuthToken = "";
let backendWsUrl = "";
let restartAttempt = 0;
let restartTimer: ReturnType<typeof setTimeout> | null = null;
let isQuitting = false;
async function reserveLoopbackPort(): Promise<number> {
return new Promise((resolve, reject) => {
const probe = net.createServer();
probe.listen(0, "127.0.0.1", () => {
const address = probe.address();
const port = typeof address === "object" && address !== null ? address.port : 0;
probe.close(() => {
if (port > 0) {
resolve(port);
return;
}
reject(new Error("Failed to reserve backend port"));
});
});
probe.on("error", reject);
});
}
function backendEnv(): NodeJS.ProcessEnv {
return {
...process.env,
T3CODE_MODE: "desktop",
T3CODE_NO_BROWSER: "1",
T3CODE_PORT: String(backendPort),
T3CODE_STATE_DIR: STATE_DIR,
T3CODE_AUTH_TOKEN: backendAuthToken,
};
}
function scheduleBackendRestart(reason: string): void {
if (isQuitting || restartTimer) return;
const delayMs = Math.min(500 * 2 ** restartAttempt, 10_000);
restartAttempt += 1;
console.error(`[desktop] backend exited unexpectedly (${reason}); restarting in ${delayMs}ms`);
restartTimer = setTimeout(() => {
restartTimer = null;
startBackend();
}, delayMs);
}
function startBackend(): void {
if (isQuitting || backendProcess) return;
if (!fs.existsSync(BACKEND_ENTRY)) {
scheduleBackendRestart(`missing server entry at ${BACKEND_ENTRY}`);
return;
}
const child = spawn(process.execPath, [BACKEND_ENTRY], {
cwd: ROOT_DIR,
// In Electron main, process.execPath points to the Electron binary.
// Run the child in Node mode so this backend process does not become a GUI app instance.
env: {
...backendEnv(),
ELECTRON_RUN_AS_NODE: "1",
},
stdio: "inherit",
});
backendProcess = child;
child.once("spawn", () => {
restartAttempt = 0;
});
child.on("error", (error) => {
if (backendProcess === child) {
backendProcess = null;
}
scheduleBackendRestart(error.message);
});
child.on("exit", (code, signal) => {
if (backendProcess === child) {
backendProcess = null;
}
if (isQuitting) return;
const reason = `code=${code ?? "null"} signal=${signal ?? "null"}`;
scheduleBackendRestart(reason);
});
}
function stopBackend(): void {
if (restartTimer) {
clearTimeout(restartTimer);
restartTimer = null;
}
const child = backendProcess;
backendProcess = null;
if (!child) return;
if (!child.killed) {
child.kill("SIGTERM");
setTimeout(() => {
if (!child.killed) {
child.kill("SIGKILL");
}
}, 2_000).unref();
}
}
function registerIpcHandlers(): void {
ipcMain.removeHandler(PICK_FOLDER_CHANNEL);
ipcMain.handle(PICK_FOLDER_CHANNEL, async () => {
const owner = BrowserWindow.getFocusedWindow() ?? mainWindow;
const result = owner
? await dialog.showOpenDialog(owner, {
properties: ["openDirectory", "createDirectory"],
})
: await dialog.showOpenDialog({
properties: ["openDirectory", "createDirectory"],
});
if (result.canceled) return null;
return result.filePaths[0] ?? null;
});
ipcMain.removeHandler(CONFIRM_CHANNEL);
ipcMain.handle(CONFIRM_CHANNEL, async (_event, message: unknown) => {
if (typeof message !== "string") {
return false;
}
const owner = BrowserWindow.getFocusedWindow() ?? mainWindow;
return showDesktopConfirmDialog(message, owner);
});
ipcMain.removeHandler(CONTEXT_MENU_CHANNEL);
ipcMain.handle(CONTEXT_MENU_CHANNEL, async (_event, items: { id: string; label: string }[]) => {
const window = BrowserWindow.getFocusedWindow() ?? mainWindow;
if (!window) return null;
return new Promise<string | null>((resolve) => {
const menu = Menu.buildFromTemplate(
items.map((item) => ({
label: item.label,
click: () => resolve(item.id),
})),
);
menu.popup({
window,
callback: () => resolve(null),
});
});
});
ipcMain.removeHandler(OPEN_EXTERNAL_CHANNEL);
ipcMain.handle(OPEN_EXTERNAL_CHANNEL, async (_event, rawUrl: unknown) => {
if (typeof rawUrl !== "string" || rawUrl.length === 0) {
return false;
}
let parsedUrl: URL;
try {
parsedUrl = new URL(rawUrl);
} catch {
return false;
}
if (parsedUrl.protocol !== "https:" && parsedUrl.protocol !== "http:") {
return false;
}
try {
await shell.openExternal(parsedUrl.toString());
return true;
} catch {
return false;
}
});
}
function createWindow(): BrowserWindow {
const window = new BrowserWindow({
width: 1100,
height: 780,
minWidth: 840,
minHeight: 620,
show: false,
autoHideMenuBar: true,
titleBarStyle: "hiddenInset",
trafficLightPosition: { x: 16, y: 18 },
webPreferences: {
preload: path.join(__dirname, "preload.js"),
contextIsolation: true,
nodeIntegration: false,
sandbox: true,
},
});
window.webContents.setWindowOpenHandler(() => ({ action: "deny" }));
window.once("ready-to-show", () => {
window.show();
});
if (isDevelopment) {
void window.loadURL(process.env.VITE_DEV_SERVER_URL as string);
window.webContents.openDevTools({ mode: "detach" });
} else {
if (!fs.existsSync(WEB_ENTRY)) {
throw new Error(`Web bundle missing at ${WEB_ENTRY}`);
}
void window.loadFile(WEB_ENTRY);
}
window.on("closed", () => {
if (mainWindow === window) {
mainWindow = null;
}
});
return window;
}
async function bootstrap(): Promise<void> {
backendPort = await reserveLoopbackPort();
backendAuthToken = randomBytes(24).toString("hex");
backendWsUrl = `ws://127.0.0.1:${backendPort}/?token=${encodeURIComponent(backendAuthToken)}`;
process.env.T3CODE_DESKTOP_WS_URL = backendWsUrl;
registerIpcHandlers();
startBackend();
mainWindow = createWindow();
}
app.on("before-quit", () => {
isQuitting = true;
stopBackend();
});
app.whenReady().then(() => {
void bootstrap();
app.on("activate", () => {
if (BrowserWindow.getAllWindows().length === 0) {
mainWindow = createWindow();
}
});
});
app.on("window-all-closed", () => {
if (process.platform !== "darwin") {
app.quit();
}
});