import * as ChildProcess from "node:child_process";import * as Crypto from "node:crypto";import * as FS from "node:fs";import * as OS from "node:os";import * as Path from "node:path"; import { app, BrowserWindow, type BrowserWindowConstructorOptions, clipboard, dialog, ipcMain, Menu, nativeImage, nativeTheme, protocol, safeStorage, shell,} from "electron";import type { MenuItemConstructorOptions, OpenDialogOptions } from "electron";import type { ClientSettings, DesktopTheme, DesktopAppBranding, DesktopServerExposureMode, DesktopServerExposureState, DesktopUpdateChannel, PersistedSavedEnvironmentRecord, DesktopUpdateActionResult, DesktopUpdateCheckResult, DesktopUpdateState,} from "@t3tools/contracts";import { autoUpdater } from "electron-updater"; import type { ContextMenuItem } from "@t3tools/contracts";import { RotatingFileSink } from "@t3tools/shared/logging";import { parsePersistedServerObservabilitySettings } from "@t3tools/shared/serverSettings";import type { RemoteT3RunnerOptions } from "@t3tools/ssh/tunnel";import { DEFAULT_DESKTOP_BACKEND_PORT, resolveDesktopBackendPort } from "./backendPort.ts";import { type DesktopSettings, DEFAULT_DESKTOP_SETTINGS, readDesktopSettings, setDesktopServerExposurePreference, setDesktopTailscaleServePreference, setDesktopUpdateChannelPreference, writeDesktopSettings,} from "./desktopSettings.ts";import { readClientSettings, readSavedEnvironmentRegistry, readSavedEnvironmentSecret, removeSavedEnvironmentSecret, writeClientSettings, writeSavedEnvironmentRegistry, writeSavedEnvironmentSecret,} from "./clientPersistence.ts";import { isBackendReadinessAborted, waitForHttpReady } from "./backendReadiness.ts";import { showDesktopConfirmDialog } from "./confirmDialog.ts";import { resolveDesktopCoreAdvertisedEndpoints, resolveDesktopServerExposure,} from "./serverExposure.ts";import { DesktopSshEnvironmentBridge, resolveRemoteT3CliPackageSpec } from "./sshEnvironment.ts";import { syncShellEnvironment } from "./syncShellEnvironment.ts";import { waitForBackendStartupReady } from "./backendStartupReadiness.ts";import { getAutoUpdateDisabledReason, shouldBroadcastDownloadProgress } from "./updateState.ts";import { doesVersionMatchDesktopUpdateChannel } from "./updateChannels.ts";import { ServerListeningDetector } from "./serverListeningDetector.ts";import { createInitialDesktopUpdateState, reduceDesktopUpdateStateOnCheckFailure, reduceDesktopUpdateStateOnCheckStart, reduceDesktopUpdateStateOnDownloadComplete, reduceDesktopUpdateStateOnDownloadFailure, reduceDesktopUpdateStateOnDownloadProgress, reduceDesktopUpdateStateOnDownloadStart, reduceDesktopUpdateStateOnInstallFailure, reduceDesktopUpdateStateOnNoUpdate, reduceDesktopUpdateStateOnUpdateAvailable,} from "./updateMachine.ts";import { isArm64HostRunningIntelBuild, resolveDesktopRuntimeInfo } from "./runtimeArch.ts";import { resolveDesktopAppBranding } from "./appBranding.ts";import { bindFirstRevealTrigger, type RevealSubscription } from "./windowReveal.ts";import { resolveTailscaleAdvertisedEndpoints } from "./tailscaleEndpointProvider.ts"; syncShellEnvironment(); const PICK_FOLDER_CHANNEL = "desktop:pick-folder";const CONFIRM_CHANNEL = "desktop:confirm";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 UPDATE_STATE_CHANNEL = "desktop:update-state";const UPDATE_GET_STATE_CHANNEL = "desktop:update-get-state";const UPDATE_SET_CHANNEL_CHANNEL = "desktop:update-set-channel";const UPDATE_DOWNLOAD_CHANNEL = "desktop:update-download";const UPDATE_INSTALL_CHANNEL = "desktop:update-install";const UPDATE_CHECK_CHANNEL = "desktop:update-check";const GET_APP_BRANDING_CHANNEL = "desktop:get-app-branding";const GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL = "desktop:get-local-environment-bootstrap";const GET_CLIENT_SETTINGS_CHANNEL = "desktop:get-client-settings";const SET_CLIENT_SETTINGS_CHANNEL = "desktop:set-client-settings";const GET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL = "desktop:get-saved-environment-registry";const SET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL = "desktop:set-saved-environment-registry";const GET_SAVED_ENVIRONMENT_SECRET_CHANNEL = "desktop:get-saved-environment-secret";const SET_SAVED_ENVIRONMENT_SECRET_CHANNEL = "desktop:set-saved-environment-secret";const REMOVE_SAVED_ENVIRONMENT_SECRET_CHANNEL = "desktop:remove-saved-environment-secret";const GET_SERVER_EXPOSURE_STATE_CHANNEL = "desktop:get-server-exposure-state";const SET_SERVER_EXPOSURE_MODE_CHANNEL = "desktop:set-server-exposure-mode";const SET_TAILSCALE_SERVE_ENABLED_CHANNEL = "desktop:set-tailscale-serve-enabled";const GET_ADVERTISED_ENDPOINTS_CHANNEL = "desktop:get-advertised-endpoints";const BASE_DIR = process.env.T3CODE_HOME?.trim() || Path.join(OS.homedir(), ".t3");const STATE_DIR = Path.join(BASE_DIR, "userdata");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 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// for a built server entry, for example:// "/Users/julius/Development/Work/codething-mvp/apps/server/dist/bin.mjs"const DEV_REMOTE_T3_SERVER_ENTRY_PATH = process.env.T3CODE_DEV_REMOTE_T3_SERVER_ENTRY_PATH?.trim() ?? "";const desktopAppBranding: DesktopAppBranding = resolveDesktopAppBranding({ isDevelopment, appVersion: app.getVersion(),});const APP_DISPLAY_NAME = desktopAppBranding.displayName;const APP_USER_MODEL_ID = isDevelopment ? "com.t3tools.t3code.dev" : "com.t3tools.t3code";const LINUX_DESKTOP_ENTRY_NAME = isDevelopment ? "t3code-dev.desktop" : "t3code.desktop";const LINUX_WM_CLASS = isDevelopment ? "t3code-dev" : "t3code";const USER_DATA_DIR_NAME = isDevelopment ? "t3code-dev" : "t3code";const LEGACY_USER_DATA_DIR_NAME = isDevelopment ? "T3 Code (Dev)" : "T3 Code (Alpha)";const COMMIT_HASH_PATTERN = /^[0-9a-f]{7,40}$/i;const COMMIT_HASH_DISPLAY_LENGTH = 12;const LOG_DIR = Path.join(STATE_DIR, "logs");const LOG_FILE_MAX_BYTES = 10 * 1024 * 1024;const LOG_FILE_MAX_FILES = 10;const APP_RUN_ID = Crypto.randomBytes(6).toString("hex");const SERVER_SETTINGS_PATH = Path.join(STATE_DIR, "settings.json");const AUTO_UPDATE_STARTUP_DELAY_MS = 15_000;const AUTO_UPDATE_POLL_INTERVAL_MS = 4 * 60 * 60 * 1000; function resolvePickFolderDefaultPath(rawOptions: unknown): string | undefined { if (typeof rawOptions !== "object" || rawOptions === null) { return undefined; } const { initialPath } = rawOptions as { initialPath?: unknown }; if (typeof initialPath !== "string") { return undefined; } const trimmedPath = initialPath.trim(); if (trimmedPath.length === 0) { return undefined; } if (trimmedPath === "~") { return OS.homedir(); } if (trimmedPath.startsWith("~/") || trimmedPath.startsWith("~\\")) { return Path.join(OS.homedir(), trimmedPath.slice(2)); } return Path.resolve(trimmedPath);}const DESKTOP_LOOPBACK_HOST = "127.0.0.1";const DESKTOP_REQUIRED_PORT_PROBE_HOSTS = ["0.0.0.0", "::"] as const;const TITLEBAR_HEIGHT = 40;const TITLEBAR_COLOR = "#01000000"; // #00000000 does not work correctly on Linuxconst TITLEBAR_LIGHT_SYMBOL_COLOR = "#1f2937";const TITLEBAR_DARK_SYMBOL_COLOR = "#f8fafc"; function normalizeContextMenuItems(source: readonly ContextMenuItem[]): ContextMenuItem[] { const normalizedItems: ContextMenuItem[] = []; for (const sourceItem of source) { if (typeof sourceItem.id !== "string" || typeof sourceItem.label !== "string") { continue; } const normalizedItem: ContextMenuItem = { id: sourceItem.id, label: sourceItem.label, destructive: sourceItem.destructive === true, disabled: sourceItem.disabled === true, }; if (sourceItem.children) { const normalizedChildren = normalizeContextMenuItems(sourceItem.children); if (normalizedChildren.length === 0) { continue; } normalizedItem.children = normalizedChildren; } normalizedItems.push(normalizedItem); } return normalizedItems;} type WindowTitleBarOptions = Pick< BrowserWindowConstructorOptions, "titleBarOverlay" | "titleBarStyle" | "trafficLightPosition">; type DesktopUpdateErrorContext = DesktopUpdateState["errorContext"];type LinuxDesktopNamedApp = Electron.App & { setDesktopName?: (desktopName: string) => void;}; let mainWindow: BrowserWindow | null = null;let backendProcess: ChildProcess.ChildProcess | null = null;let backendPort = 0;let backendBindHost = DESKTOP_LOOPBACK_HOST;let backendBootstrapToken = "";let backendHttpUrl = "";let backendWsUrl = "";let backendEndpointUrl: string | null = null;let backendAdvertisedHost: string | null = null;let backendReadinessAbortController: AbortController | null = null;let backendInitialWindowOpenInFlight: Promise<void> | null = null;let backendListeningDetector: ServerListeningDetector | null = null;let restartAttempt = 0;let restartTimer: ReturnType<typeof setTimeout> | null = null;let isQuitting = false;let desktopProtocolRegistered = false;let aboutCommitHashCache: string | null | undefined;let desktopLogSink: RotatingFileSink | null = null;let backendLogSink: RotatingFileSink | null = null;let restoreStdIoCapture: (() => void) | null = null;let backendObservabilitySettings = readPersistedBackendObservabilitySettings();let desktopSettings = readDesktopSettings(DESKTOP_SETTINGS_PATH, app.getVersion());let desktopServerExposureMode: DesktopServerExposureMode = desktopSettings.serverExposureMode; let destructiveMenuIconCache: Electron.NativeImage | null | undefined;const expectedBackendExitChildren = new WeakSet<ChildProcess.ChildProcess>();const desktopRuntimeInfo = resolveDesktopRuntimeInfo({ platform: process.platform, processArch: process.arch, runningUnderArm64Translation: app.runningUnderARM64Translation === true,});const initialUpdateState = (): DesktopUpdateState => createInitialDesktopUpdateState( app.getVersion(), desktopRuntimeInfo, desktopSettings.updateChannel, ); function logTimestamp(): string { return new Date().toISOString();} function logScope(scope: string): string { return `${scope} run=${APP_RUN_ID}`;} function sanitizeLogValue(value: string): string { return value.replace(/\s+/g, " ").trim();} function readPersistedBackendObservabilitySettings(): { readonly otlpTracesUrl: string | undefined; readonly otlpMetricsUrl: string | undefined;} { try { if (!FS.existsSync(SERVER_SETTINGS_PATH)) { return { otlpTracesUrl: undefined, otlpMetricsUrl: undefined }; } return parsePersistedServerObservabilitySettings(FS.readFileSync(SERVER_SETTINGS_PATH, "utf8")); } catch (error) { console.warn("[desktop] failed to read persisted backend observability settings", error); return { otlpTracesUrl: undefined, otlpMetricsUrl: undefined }; }} function resolveConfiguredDesktopBackendPort(rawPort: string | undefined): number | undefined { if (!rawPort) { return undefined; } const parsedPort = Number.parseInt(rawPort, 10); if (!Number.isInteger(parsedPort) || parsedPort < 1 || parsedPort > 65_535) { return undefined; } return parsedPort;} function resolveDesktopDevServerUrl(): string { const devServerUrl = process.env.VITE_DEV_SERVER_URL?.trim(); if (!devServerUrl) { throw new Error("VITE_DEV_SERVER_URL is required in desktop development."); } return devServerUrl;} function backendChildEnv(): NodeJS.ProcessEnv { const env = { ...process.env }; delete env.T3CODE_PORT; delete env.T3CODE_MODE; delete env.T3CODE_NO_BROWSER; delete env.T3CODE_HOST; delete env.T3CODE_DESKTOP_WS_URL; delete env.T3CODE_DESKTOP_LAN_ACCESS; delete env.T3CODE_DESKTOP_LAN_HOST; delete env.T3CODE_DESKTOP_HTTPS_ENDPOINTS; delete env.T3CODE_TAILSCALE_SERVE; delete env.T3CODE_TAILSCALE_SERVE_PORT; return env;} function getDesktopServerExposureState(): DesktopServerExposureState { return { mode: desktopServerExposureMode, endpointUrl: backendEndpointUrl, advertisedHost: backendAdvertisedHost, tailscaleServeEnabled: desktopSettings.tailscaleServeEnabled, tailscaleServePort: desktopSettings.tailscaleServePort, };} async function getDesktopAdvertisedEndpoints() { const exposure = resolveDesktopServerExposure({ mode: desktopServerExposureMode, port: backendPort, networkInterfaces: OS.networkInterfaces(), ...(backendAdvertisedHost ? { advertisedHostOverride: backendAdvertisedHost } : {}), }); const coreEndpoints = resolveDesktopCoreAdvertisedEndpoints({ port: backendPort, exposure, customHttpsEndpointUrls: resolveCustomHttpsEndpointUrls(), }); const tailscaleEndpoints = await resolveTailscaleAdvertisedEndpoints({ port: backendPort, serveEnabled: desktopSettings.tailscaleServeEnabled, servePort: desktopSettings.tailscaleServePort, networkInterfaces: OS.networkInterfaces(), }); return [...coreEndpoints, ...tailscaleEndpoints];} function getDesktopSecretStorage() { return { isEncryptionAvailable: () => safeStorage.isEncryptionAvailable(), encryptString: (value: string) => safeStorage.encryptString(value), decryptString: (value: Buffer) => safeStorage.decryptString(value), } as const;} function resolveAdvertisedHostOverride(): string | undefined { const override = process.env.T3CODE_DESKTOP_LAN_HOST?.trim(); return override && override.length > 0 ? override : undefined;} function resolveCustomHttpsEndpointUrls(): readonly string[] { return (process.env.T3CODE_DESKTOP_HTTPS_ENDPOINTS ?? "") .split(",") .map((entry) => entry.trim()) .filter((entry) => entry.length > 0);} async function applyDesktopServerExposureMode( mode: DesktopServerExposureMode, options?: { readonly persist?: boolean; readonly rejectIfUnavailable?: boolean; },): Promise<DesktopServerExposureState> { const advertisedHostOverride = resolveAdvertisedHostOverride(); const requestedMode = mode; let exposure = resolveDesktopServerExposure({ mode, port: backendPort, networkInterfaces: OS.networkInterfaces(), ...(advertisedHostOverride ? { advertisedHostOverride } : {}), }); if (requestedMode === "network-accessible" && exposure.endpointUrl === null) { if (options?.rejectIfUnavailable) { throw new Error("No reachable network address is available for this desktop right now."); } exposure = resolveDesktopServerExposure({ mode: "local-only", port: backendPort, networkInterfaces: OS.networkInterfaces(), ...(advertisedHostOverride ? { advertisedHostOverride } : {}), }); } desktopServerExposureMode = exposure.mode; desktopSettings = setDesktopServerExposurePreference(desktopSettings, requestedMode); backendBindHost = exposure.bindHost; backendHttpUrl = exposure.localHttpUrl; backendWsUrl = exposure.localWsUrl; backendEndpointUrl = exposure.endpointUrl; backendAdvertisedHost = exposure.advertisedHost; if (options?.persist) { writeDesktopSettings(DESKTOP_SETTINGS_PATH, desktopSettings); } return getDesktopServerExposureState();} async function applyDesktopTailscaleServeEnabled( nextSettings: DesktopSettings,): Promise<DesktopServerExposureState> { desktopSettings = nextSettings; writeDesktopSettings(DESKTOP_SETTINGS_PATH, desktopSettings); relaunchDesktopApp( desktopSettings.tailscaleServeEnabled ? "tailscale-serve-enabled" : "tailscale-serve-disabled", ); return getDesktopServerExposureState();} function relaunchDesktopApp(reason: string): void { writeDesktopLogHeader(`desktop relaunch requested reason=${reason}`); setImmediate(() => { isQuitting = true; clearUpdatePollTimer(); cancelBackendReadinessWait(); void stopBackendAndWaitForExit() .catch((error) => { writeDesktopLogHeader( `desktop relaunch backend shutdown warning message=${formatErrorMessage(error)}`, ); }) .then(() => desktopSshEnvironmentBridge.dispose().catch(() => undefined)) .finally(() => { restoreStdIoCapture?.(); if (isDevelopment) { app.exit(75); return; } app.relaunch({ execPath: process.execPath, args: process.argv.slice(1), }); app.exit(0); }); });} function writeDesktopLogHeader(message: string): void { if (!desktopLogSink) return; desktopLogSink.write(`[${logTimestamp()}] [${logScope("desktop")}] ${message}\n`);} function writeBackendSessionBoundary(phase: "START" | "END", details: string): void { if (!backendLogSink) return; const normalizedDetails = sanitizeLogValue(details); backendLogSink.write( `[${logTimestamp()}] ---- APP SESSION ${phase} run=${APP_RUN_ID} ${normalizedDetails} ----\n`, );} function formatErrorMessage(error: unknown): string { if (error instanceof Error) { return error.message; } return String(error);} function getSafeExternalUrl(rawUrl: unknown): string | null { if (typeof rawUrl !== "string" || rawUrl.length === 0) { return null; } let parsedUrl: URL; try { parsedUrl = new URL(rawUrl); } catch { return null; } if (parsedUrl.protocol !== "https:" && parsedUrl.protocol !== "http:") { return null; } return parsedUrl.toString();} function getSafeTheme(rawTheme: unknown): DesktopTheme | null { if (rawTheme === "light" || rawTheme === "dark" || rawTheme === "system") { return rawTheme; } return null;} Lines 1–500 of 2298. Load more
import * as ChildProcess from "node:child_process"; import * as Crypto from "node:crypto"; import * as FS from "node:fs"; import * as OS from "node:os"; import * as Path from "node:path"; import { app, BrowserWindow, type BrowserWindowConstructorOptions, clipboard, dialog, ipcMain, Menu, nativeImage, nativeTheme, protocol, safeStorage, shell, } from "electron"; import type { MenuItemConstructorOptions, OpenDialogOptions } from "electron"; import type { ClientSettings, DesktopTheme, DesktopAppBranding, DesktopServerExposureMode, DesktopServerExposureState, DesktopUpdateChannel, PersistedSavedEnvironmentRecord, DesktopUpdateActionResult, DesktopUpdateCheckResult, DesktopUpdateState, } from "@t3tools/contracts"; import { autoUpdater } from "electron-updater"; import type { ContextMenuItem } from "@t3tools/contracts"; import { RotatingFileSink } from "@t3tools/shared/logging"; import { parsePersistedServerObservabilitySettings } from "@t3tools/shared/serverSettings"; import type { RemoteT3RunnerOptions } from "@t3tools/ssh/tunnel"; import { DEFAULT_DESKTOP_BACKEND_PORT, resolveDesktopBackendPort } from "./backendPort.ts"; import { type DesktopSettings, DEFAULT_DESKTOP_SETTINGS, readDesktopSettings, setDesktopServerExposurePreference, setDesktopTailscaleServePreference, setDesktopUpdateChannelPreference, writeDesktopSettings, } from "./desktopSettings.ts"; import { readClientSettings, readSavedEnvironmentRegistry, readSavedEnvironmentSecret, removeSavedEnvironmentSecret, writeClientSettings, writeSavedEnvironmentRegistry, writeSavedEnvironmentSecret, } from "./clientPersistence.ts"; import { isBackendReadinessAborted, waitForHttpReady } from "./backendReadiness.ts"; import { showDesktopConfirmDialog } from "./confirmDialog.ts"; import { resolveDesktopCoreAdvertisedEndpoints, resolveDesktopServerExposure, } from "./serverExposure.ts"; import { DesktopSshEnvironmentBridge, resolveRemoteT3CliPackageSpec } from "./sshEnvironment.ts"; import { syncShellEnvironment } from "./syncShellEnvironment.ts"; import { waitForBackendStartupReady } from "./backendStartupReadiness.ts"; import { getAutoUpdateDisabledReason, shouldBroadcastDownloadProgress } from "./updateState.ts"; import { doesVersionMatchDesktopUpdateChannel } from "./updateChannels.ts"; import { ServerListeningDetector } from "./serverListeningDetector.ts"; import { createInitialDesktopUpdateState, reduceDesktopUpdateStateOnCheckFailure, reduceDesktopUpdateStateOnCheckStart, reduceDesktopUpdateStateOnDownloadComplete, reduceDesktopUpdateStateOnDownloadFailure, reduceDesktopUpdateStateOnDownloadProgress, reduceDesktopUpdateStateOnDownloadStart, reduceDesktopUpdateStateOnInstallFailure, reduceDesktopUpdateStateOnNoUpdate, reduceDesktopUpdateStateOnUpdateAvailable, } from "./updateMachine.ts"; import { isArm64HostRunningIntelBuild, resolveDesktopRuntimeInfo } from "./runtimeArch.ts"; import { resolveDesktopAppBranding } from "./appBranding.ts"; import { bindFirstRevealTrigger, type RevealSubscription } from "./windowReveal.ts"; import { resolveTailscaleAdvertisedEndpoints } from "./tailscaleEndpointProvider.ts"; syncShellEnvironment(); const PICK_FOLDER_CHANNEL = "desktop:pick-folder"; const CONFIRM_CHANNEL = "desktop:confirm"; 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 UPDATE_STATE_CHANNEL = "desktop:update-state"; const UPDATE_GET_STATE_CHANNEL = "desktop:update-get-state"; const UPDATE_SET_CHANNEL_CHANNEL = "desktop:update-set-channel"; const UPDATE_DOWNLOAD_CHANNEL = "desktop:update-download"; const UPDATE_INSTALL_CHANNEL = "desktop:update-install"; const UPDATE_CHECK_CHANNEL = "desktop:update-check"; const GET_APP_BRANDING_CHANNEL = "desktop:get-app-branding"; const GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL = "desktop:get-local-environment-bootstrap"; const GET_CLIENT_SETTINGS_CHANNEL = "desktop:get-client-settings"; const SET_CLIENT_SETTINGS_CHANNEL = "desktop:set-client-settings"; const GET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL = "desktop:get-saved-environment-registry"; const SET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL = "desktop:set-saved-environment-registry"; const GET_SAVED_ENVIRONMENT_SECRET_CHANNEL = "desktop:get-saved-environment-secret"; const SET_SAVED_ENVIRONMENT_SECRET_CHANNEL = "desktop:set-saved-environment-secret"; const REMOVE_SAVED_ENVIRONMENT_SECRET_CHANNEL = "desktop:remove-saved-environment-secret"; const GET_SERVER_EXPOSURE_STATE_CHANNEL = "desktop:get-server-exposure-state"; const SET_SERVER_EXPOSURE_MODE_CHANNEL = "desktop:set-server-exposure-mode"; const SET_TAILSCALE_SERVE_ENABLED_CHANNEL = "desktop:set-tailscale-serve-enabled"; const GET_ADVERTISED_ENDPOINTS_CHANNEL = "desktop:get-advertised-endpoints"; const BASE_DIR = process.env.T3CODE_HOME?.trim() || Path.join(OS.homedir(), ".t3"); const STATE_DIR = Path.join(BASE_DIR, "userdata"); 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 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 // for a built server entry, for example: // "/Users/julius/Development/Work/codething-mvp/apps/server/dist/bin.mjs" const DEV_REMOTE_T3_SERVER_ENTRY_PATH = process.env.T3CODE_DEV_REMOTE_T3_SERVER_ENTRY_PATH?.trim() ?? ""; const desktopAppBranding: DesktopAppBranding = resolveDesktopAppBranding({ isDevelopment, appVersion: app.getVersion(), }); const APP_DISPLAY_NAME = desktopAppBranding.displayName; const APP_USER_MODEL_ID = isDevelopment ? "com.t3tools.t3code.dev" : "com.t3tools.t3code"; const LINUX_DESKTOP_ENTRY_NAME = isDevelopment ? "t3code-dev.desktop" : "t3code.desktop"; const LINUX_WM_CLASS = isDevelopment ? "t3code-dev" : "t3code"; const USER_DATA_DIR_NAME = isDevelopment ? "t3code-dev" : "t3code"; const LEGACY_USER_DATA_DIR_NAME = isDevelopment ? "T3 Code (Dev)" : "T3 Code (Alpha)"; const COMMIT_HASH_PATTERN = /^[0-9a-f]{7,40}$/i; const COMMIT_HASH_DISPLAY_LENGTH = 12; const LOG_DIR = Path.join(STATE_DIR, "logs"); const LOG_FILE_MAX_BYTES = 10 * 1024 * 1024; const LOG_FILE_MAX_FILES = 10; const APP_RUN_ID = Crypto.randomBytes(6).toString("hex"); const SERVER_SETTINGS_PATH = Path.join(STATE_DIR, "settings.json"); const AUTO_UPDATE_STARTUP_DELAY_MS = 15_000; const AUTO_UPDATE_POLL_INTERVAL_MS = 4 * 60 * 60 * 1000; function resolvePickFolderDefaultPath(rawOptions: unknown): string | undefined { if (typeof rawOptions !== "object" || rawOptions === null) { return undefined; } const { initialPath } = rawOptions as { initialPath?: unknown }; if (typeof initialPath !== "string") { return undefined; } const trimmedPath = initialPath.trim(); if (trimmedPath.length === 0) { return undefined; } if (trimmedPath === "~") { return OS.homedir(); } if (trimmedPath.startsWith("~/") || trimmedPath.startsWith("~\\")) { return Path.join(OS.homedir(), trimmedPath.slice(2)); } return Path.resolve(trimmedPath); } const DESKTOP_LOOPBACK_HOST = "127.0.0.1"; const DESKTOP_REQUIRED_PORT_PROBE_HOSTS = ["0.0.0.0", "::"] as const; const TITLEBAR_HEIGHT = 40; const TITLEBAR_COLOR = "#01000000"; // #00000000 does not work correctly on Linux const TITLEBAR_LIGHT_SYMBOL_COLOR = "#1f2937"; const TITLEBAR_DARK_SYMBOL_COLOR = "#f8fafc"; function normalizeContextMenuItems(source: readonly ContextMenuItem[]): ContextMenuItem[] { const normalizedItems: ContextMenuItem[] = []; for (const sourceItem of source) { if (typeof sourceItem.id !== "string" || typeof sourceItem.label !== "string") { continue; } const normalizedItem: ContextMenuItem = { id: sourceItem.id, label: sourceItem.label, destructive: sourceItem.destructive === true, disabled: sourceItem.disabled === true, }; if (sourceItem.children) { const normalizedChildren = normalizeContextMenuItems(sourceItem.children); if (normalizedChildren.length === 0) { continue; } normalizedItem.children = normalizedChildren; } normalizedItems.push(normalizedItem); } return normalizedItems; } type WindowTitleBarOptions = Pick< BrowserWindowConstructorOptions, "titleBarOverlay" | "titleBarStyle" | "trafficLightPosition" >; type DesktopUpdateErrorContext = DesktopUpdateState["errorContext"]; type LinuxDesktopNamedApp = Electron.App & { setDesktopName?: (desktopName: string) => void; }; let mainWindow: BrowserWindow | null = null; let backendProcess: ChildProcess.ChildProcess | null = null; let backendPort = 0; let backendBindHost = DESKTOP_LOOPBACK_HOST; let backendBootstrapToken = ""; let backendHttpUrl = ""; let backendWsUrl = ""; let backendEndpointUrl: string | null = null; let backendAdvertisedHost: string | null = null; let backendReadinessAbortController: AbortController | null = null; let backendInitialWindowOpenInFlight: Promise<void> | null = null; let backendListeningDetector: ServerListeningDetector | null = null; let restartAttempt = 0; let restartTimer: ReturnType<typeof setTimeout> | null = null; let isQuitting = false; let desktopProtocolRegistered = false; let aboutCommitHashCache: string | null | undefined; let desktopLogSink: RotatingFileSink | null = null; let backendLogSink: RotatingFileSink | null = null; let restoreStdIoCapture: (() => void) | null = null; let backendObservabilitySettings = readPersistedBackendObservabilitySettings(); let desktopSettings = readDesktopSettings(DESKTOP_SETTINGS_PATH, app.getVersion()); let desktopServerExposureMode: DesktopServerExposureMode = desktopSettings.serverExposureMode; let destructiveMenuIconCache: Electron.NativeImage | null | undefined; const expectedBackendExitChildren = new WeakSet<ChildProcess.ChildProcess>(); const desktopRuntimeInfo = resolveDesktopRuntimeInfo({ platform: process.platform, processArch: process.arch, runningUnderArm64Translation: app.runningUnderARM64Translation === true, }); const initialUpdateState = (): DesktopUpdateState => createInitialDesktopUpdateState( app.getVersion(), desktopRuntimeInfo, desktopSettings.updateChannel, ); function logTimestamp(): string { return new Date().toISOString(); } function logScope(scope: string): string { return `${scope} run=${APP_RUN_ID}`; } function sanitizeLogValue(value: string): string { return value.replace(/\s+/g, " ").trim(); } function readPersistedBackendObservabilitySettings(): { readonly otlpTracesUrl: string | undefined; readonly otlpMetricsUrl: string | undefined; } { try { if (!FS.existsSync(SERVER_SETTINGS_PATH)) { return { otlpTracesUrl: undefined, otlpMetricsUrl: undefined }; } return parsePersistedServerObservabilitySettings(FS.readFileSync(SERVER_SETTINGS_PATH, "utf8")); } catch (error) { console.warn("[desktop] failed to read persisted backend observability settings", error); return { otlpTracesUrl: undefined, otlpMetricsUrl: undefined }; } } function resolveConfiguredDesktopBackendPort(rawPort: string | undefined): number | undefined { if (!rawPort) { return undefined; } const parsedPort = Number.parseInt(rawPort, 10); if (!Number.isInteger(parsedPort) || parsedPort < 1 || parsedPort > 65_535) { return undefined; } return parsedPort; } function resolveDesktopDevServerUrl(): string { const devServerUrl = process.env.VITE_DEV_SERVER_URL?.trim(); if (!devServerUrl) { throw new Error("VITE_DEV_SERVER_URL is required in desktop development."); } return devServerUrl; } function backendChildEnv(): NodeJS.ProcessEnv { const env = { ...process.env }; delete env.T3CODE_PORT; delete env.T3CODE_MODE; delete env.T3CODE_NO_BROWSER; delete env.T3CODE_HOST; delete env.T3CODE_DESKTOP_WS_URL; delete env.T3CODE_DESKTOP_LAN_ACCESS; delete env.T3CODE_DESKTOP_LAN_HOST; delete env.T3CODE_DESKTOP_HTTPS_ENDPOINTS; delete env.T3CODE_TAILSCALE_SERVE; delete env.T3CODE_TAILSCALE_SERVE_PORT; return env; } function getDesktopServerExposureState(): DesktopServerExposureState { return { mode: desktopServerExposureMode, endpointUrl: backendEndpointUrl, advertisedHost: backendAdvertisedHost, tailscaleServeEnabled: desktopSettings.tailscaleServeEnabled, tailscaleServePort: desktopSettings.tailscaleServePort, }; } async function getDesktopAdvertisedEndpoints() { const exposure = resolveDesktopServerExposure({ mode: desktopServerExposureMode, port: backendPort, networkInterfaces: OS.networkInterfaces(), ...(backendAdvertisedHost ? { advertisedHostOverride: backendAdvertisedHost } : {}), }); const coreEndpoints = resolveDesktopCoreAdvertisedEndpoints({ port: backendPort, exposure, customHttpsEndpointUrls: resolveCustomHttpsEndpointUrls(), }); const tailscaleEndpoints = await resolveTailscaleAdvertisedEndpoints({ port: backendPort, serveEnabled: desktopSettings.tailscaleServeEnabled, servePort: desktopSettings.tailscaleServePort, networkInterfaces: OS.networkInterfaces(), }); return [...coreEndpoints, ...tailscaleEndpoints]; } function getDesktopSecretStorage() { return { isEncryptionAvailable: () => safeStorage.isEncryptionAvailable(), encryptString: (value: string) => safeStorage.encryptString(value), decryptString: (value: Buffer) => safeStorage.decryptString(value), } as const; } function resolveAdvertisedHostOverride(): string | undefined { const override = process.env.T3CODE_DESKTOP_LAN_HOST?.trim(); return override && override.length > 0 ? override : undefined; } function resolveCustomHttpsEndpointUrls(): readonly string[] { return (process.env.T3CODE_DESKTOP_HTTPS_ENDPOINTS ?? "") .split(",") .map((entry) => entry.trim()) .filter((entry) => entry.length > 0); } async function applyDesktopServerExposureMode( mode: DesktopServerExposureMode, options?: { readonly persist?: boolean; readonly rejectIfUnavailable?: boolean; }, ): Promise<DesktopServerExposureState> { const advertisedHostOverride = resolveAdvertisedHostOverride(); const requestedMode = mode; let exposure = resolveDesktopServerExposure({ mode, port: backendPort, networkInterfaces: OS.networkInterfaces(), ...(advertisedHostOverride ? { advertisedHostOverride } : {}), }); if (requestedMode === "network-accessible" && exposure.endpointUrl === null) { if (options?.rejectIfUnavailable) { throw new Error("No reachable network address is available for this desktop right now."); } exposure = resolveDesktopServerExposure({ mode: "local-only", port: backendPort, networkInterfaces: OS.networkInterfaces(), ...(advertisedHostOverride ? { advertisedHostOverride } : {}), }); } desktopServerExposureMode = exposure.mode; desktopSettings = setDesktopServerExposurePreference(desktopSettings, requestedMode); backendBindHost = exposure.bindHost; backendHttpUrl = exposure.localHttpUrl; backendWsUrl = exposure.localWsUrl; backendEndpointUrl = exposure.endpointUrl; backendAdvertisedHost = exposure.advertisedHost; if (options?.persist) { writeDesktopSettings(DESKTOP_SETTINGS_PATH, desktopSettings); } return getDesktopServerExposureState(); } async function applyDesktopTailscaleServeEnabled( nextSettings: DesktopSettings, ): Promise<DesktopServerExposureState> { desktopSettings = nextSettings; writeDesktopSettings(DESKTOP_SETTINGS_PATH, desktopSettings); relaunchDesktopApp( desktopSettings.tailscaleServeEnabled ? "tailscale-serve-enabled" : "tailscale-serve-disabled", ); return getDesktopServerExposureState(); } function relaunchDesktopApp(reason: string): void { writeDesktopLogHeader(`desktop relaunch requested reason=${reason}`); setImmediate(() => { isQuitting = true; clearUpdatePollTimer(); cancelBackendReadinessWait(); void stopBackendAndWaitForExit() .catch((error) => { writeDesktopLogHeader( `desktop relaunch backend shutdown warning message=${formatErrorMessage(error)}`, ); }) .then(() => desktopSshEnvironmentBridge.dispose().catch(() => undefined)) .finally(() => { restoreStdIoCapture?.(); if (isDevelopment) { app.exit(75); return; } app.relaunch({ execPath: process.execPath, args: process.argv.slice(1), }); app.exit(0); }); }); } function writeDesktopLogHeader(message: string): void { if (!desktopLogSink) return; desktopLogSink.write(`[${logTimestamp()}] [${logScope("desktop")}] ${message}\n`); } function writeBackendSessionBoundary(phase: "START" | "END", details: string): void { if (!backendLogSink) return; const normalizedDetails = sanitizeLogValue(details); backendLogSink.write( `[${logTimestamp()}] ---- APP SESSION ${phase} run=${APP_RUN_ID} ${normalizedDetails} ----\n`, ); } function formatErrorMessage(error: unknown): string { if (error instanceof Error) { return error.message; } return String(error); } function getSafeExternalUrl(rawUrl: unknown): string | null { if (typeof rawUrl !== "string" || rawUrl.length === 0) { return null; } let parsedUrl: URL; try { parsedUrl = new URL(rawUrl); } catch { return null; } if (parsedUrl.protocol !== "https:" && parsedUrl.protocol !== "http:") { return null; } return parsedUrl.toString(); } function getSafeTheme(rawTheme: unknown): DesktopTheme | null { if (rawTheme === "light" || rawTheme === "dark" || rawTheme === "system") { return rawTheme; } return null; }