1900 lines · 59.3 KB · TypeScript
RawDownload
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,  clipboard,  dialog,  ipcMain,  Menu,  nativeImage,  nativeTheme,  protocol,  safeStorage,  shell,} from "electron";import type { MenuItemConstructorOptions } from "electron";import type {  ClientSettings,  DesktopTheme,  DesktopServerExposureMode,  DesktopServerExposureState,  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 { DEFAULT_DESKTOP_BACKEND_PORT, resolveDesktopBackendPort } from "./backendPort";import {  DEFAULT_DESKTOP_SETTINGS,  readDesktopSettings,  setDesktopServerExposurePreference,  writeDesktopSettings,} from "./desktopSettings";import {  readClientSettings,  readSavedEnvironmentRegistry,  readSavedEnvironmentSecret,  removeSavedEnvironmentSecret,  writeClientSettings,  writeSavedEnvironmentRegistry,  writeSavedEnvironmentSecret,} from "./clientPersistence";import { isBackendReadinessAborted, waitForHttpReady } from "./backendReadiness";import { showDesktopConfirmDialog } from "./confirmDialog";import { resolveDesktopServerExposure } from "./serverExposure";import { syncShellEnvironment } from "./syncShellEnvironment";import { getAutoUpdateDisabledReason, shouldBroadcastDownloadProgress } from "./updateState";import {  createInitialDesktopUpdateState,  reduceDesktopUpdateStateOnCheckFailure,  reduceDesktopUpdateStateOnCheckStart,  reduceDesktopUpdateStateOnDownloadComplete,  reduceDesktopUpdateStateOnDownloadFailure,  reduceDesktopUpdateStateOnDownloadProgress,  reduceDesktopUpdateStateOnDownloadStart,  reduceDesktopUpdateStateOnInstallFailure,  reduceDesktopUpdateStateOnNoUpdate,  reduceDesktopUpdateStateOnUpdateAvailable,} from "./updateMachine";import { isArm64HostRunningIntelBuild, resolveDesktopRuntimeInfo } from "./runtimeArch"; 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_DOWNLOAD_CHANNEL = "desktop:update-download";const UPDATE_INSTALL_CHANNEL = "desktop:update-install";const UPDATE_CHECK_CHANNEL = "desktop:update-check";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 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);const APP_DISPLAY_NAME = isDevelopment ? "T3 Code (Dev)" : "T3 Code (Alpha)";const APP_USER_MODEL_ID = "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;const DESKTOP_UPDATE_CHANNEL = "latest";const DESKTOP_UPDATE_ALLOW_PRERELEASE = false;const DESKTOP_LOOPBACK_HOST = "127.0.0.1"; 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 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);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); 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;  return env;} function getDesktopServerExposureState(): DesktopServerExposureState {  return {    mode: desktopServerExposureMode,    endpointUrl: backendEndpointUrl,    advertisedHost: backendAdvertisedHost,  };} 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;} 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();} 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)}`,        );      })      .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;} async function waitForBackendHttpReady(baseUrl: string): Promise<void> {  cancelBackendReadinessWait();  const controller = new AbortController();  backendReadinessAbortController = controller;   try {    await waitForHttpReady(baseUrl, {      signal: controller.signal,    });  } finally {    if (backendReadinessAbortController === controller) {      backendReadinessAbortController = null;    }  }} function cancelBackendReadinessWait(): void {  backendReadinessAbortController?.abort();  backendReadinessAbortController = null;} function writeDesktopStreamChunk(  streamName: "stdout" | "stderr",  chunk: unknown,  encoding: BufferEncoding | undefined,): void {  if (!desktopLogSink) return;  const buffer = Buffer.isBuffer(chunk)    ? chunk    : Buffer.from(String(chunk), typeof chunk === "string" ? encoding : undefined);  desktopLogSink.write(`[${logTimestamp()}] [${logScope(streamName)}] `);  desktopLogSink.write(buffer);  if (buffer.length === 0 || buffer[buffer.length - 1] !== 0x0a) {    desktopLogSink.write("\n");  }} function installStdIoCapture(): void {  if (!app.isPackaged || desktopLogSink === null || restoreStdIoCapture !== null) {    return;  }   const originalStdoutWrite = process.stdout.write.bind(process.stdout);  const originalStderrWrite = process.stderr.write.bind(process.stderr);   const patchWrite =    (streamName: "stdout" | "stderr", originalWrite: typeof process.stdout.write) =>    (      chunk: string | Uint8Array,      encodingOrCallback?: BufferEncoding | ((error?: Error | null) => void),      callback?: (error?: Error | null) => void,    ): boolean => {      const encoding = typeof encodingOrCallback === "string" ? encodingOrCallback : undefined;      writeDesktopStreamChunk(streamName, chunk, encoding);      if (typeof encodingOrCallback === "function") {        return originalWrite(chunk, encodingOrCallback);      }      if (callback !== undefined) {        return originalWrite(chunk, encoding, callback);      }      if (encoding !== undefined) {        return originalWrite(chunk, encoding);      }      return originalWrite(chunk);    };   process.stdout.write = patchWrite("stdout", originalStdoutWrite);  process.stderr.write = patchWrite("stderr", originalStderrWrite);   restoreStdIoCapture = () => {    process.stdout.write = originalStdoutWrite;    process.stderr.write = originalStderrWrite;    restoreStdIoCapture = null;  };} function initializePackagedLogging(): void {  if (!app.isPackaged) return;  try {    desktopLogSink = new RotatingFileSink({      filePath: Path.join(LOG_DIR, "desktop-main.log"),      maxBytes: LOG_FILE_MAX_BYTES,      maxFiles: LOG_FILE_MAX_FILES,    });    backendLogSink = new RotatingFileSink({      filePath: Path.join(LOG_DIR, "server-child.log"),      maxBytes: LOG_FILE_MAX_BYTES,      maxFiles: LOG_FILE_MAX_FILES,    });    installStdIoCapture();    writeDesktopLogHeader(`runtime log capture enabled logDir=${LOG_DIR}`);  } catch (error) {    // Logging setup should never block app startup.    console.error("[desktop] failed to initialize packaged logging", error);  }} function captureBackendOutput(child: ChildProcess.ChildProcess): void {  if (!app.isPackaged || backendLogSink === null) return;  const writeChunk = (chunk: unknown): void => {    if (!backendLogSink) return;    const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk), "utf8");    backendLogSink.write(buffer);  };  child.stdout?.on("data", writeChunk);  child.stderr?.on("data", writeChunk);} initializePackagedLogging(); if (process.platform === "linux") {  app.commandLine.appendSwitch("class", LINUX_WM_CLASS);} function getDestructiveMenuIcon(): Electron.NativeImage | undefined {  if (process.platform !== "darwin") return undefined;  if (destructiveMenuIconCache !== undefined) {    return destructiveMenuIconCache ?? undefined;  }  try {    const icon = nativeImage.createFromNamedImage("trash").resize({      width: 14,      height: 14,    });    if (icon.isEmpty()) {      destructiveMenuIconCache = null;      return undefined;    }    icon.setTemplateImage(true);    destructiveMenuIconCache = icon;    return icon;  } catch {    destructiveMenuIconCache = null;    return undefined;  }}let updatePollTimer: ReturnType<typeof setInterval> | null = null;let updateStartupTimer: ReturnType<typeof setTimeout> | null = null;let updateCheckInFlight = false;let updateDownloadInFlight = false;let updateInstallInFlight = false;let updaterConfigured = false;let updateState: DesktopUpdateState = initialUpdateState(); function resolveUpdaterErrorContext(): DesktopUpdateErrorContext {  if (updateInstallInFlight) return "install";  if (updateDownloadInFlight) return "download";

Lines 1–500 of 1900.