src/main.ts
Rama main · réplica de lectura del repositorio original.
import { stopClaudeProcesses } from "./ipc/services/claude_code/runtime";
import { windowsResourceHelper } from "./utils/windows_resource_helper";
import { isPreviewCookieIsolationEnabled } from "./ipc/services/preview_cookie_isolation";
import {
app,
autoUpdater,
BrowserWindow,
dialog,
Menu,
protocol,
net,
nativeImage,
crashReporter,
type Event as ElectronEvent,
} from "electron";
import * as path from "node:path";
import { randomUUID } from "node:crypto";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { registerIpcHandlers } from "./ipc/ipc_host";
import dotenv from "dotenv";
import { updateElectronApp, UpdateSourceType } from "update-electron-app";
import log from "electron-log";
import {
getSettingsFilePath,
tryWriteSettings,
readSettings,
readEffectiveSettings,
claimCrashSentinel,
clearCrashSentinel,
recordRendererCrash,
readRendererCrashRecord,
clearRendererCrashRecord,
setInitialLoadIsFirstSession,
rewriteRecoveredSafeStorageSecretsAfterKeychainUnlock,
} from "./main/settings";
import {
recoveryNeedsKeychainUnlock,
retryRecoveryWithKeychainUnlock,
setSafeStorageRecoveryFailureListener,
} from "./main/safe_storage_legacy";
import { recordUpdaterError } from "./main/updater_state";
import { recordPreviousSessionCrash } from "./main/previous_session_crash";
import {
sendTelemetryEvent,
sendTelemetryEventToWindow,
} from "./ipc/utils/telemetry";
import { handleSupabaseOAuthReturn } from "./supabase_admin/supabase_return_handler";
import { handleDyadProReturn } from "./main/pro";
import { IS_TEST_BUILD } from "./ipc/utils/test_utils";
import { BackupManager } from "./backup_manager";
import { db, getDatabasePath, initializeDatabase } from "./db";
import { apps } from "./db/schema";
import { eq } from "drizzle-orm";
import { reconcileOrphanTestBranches } from "./ipc/utils/neon_test_branch";
import { reconcileOrphanTestUsers } from "./ipc/utils/supabase_test_user";
import { reconcileOrphanE2eTestWorkspaces } from "./ipc/services/e2e_test_workspace";
import { stopAllAppTestsSync } from "./ipc/handlers/tests_handlers";
import { UserSettings } from "./lib/schemas";
import { handleNeonOAuthReturn } from "./neon_admin/neon_return_handler";
import {
disposeConnectionFlowsForShutdown,
connectionFlowRegistry,
runOAuthReturnExchange,
} from "./ipc/handlers/connection_flow_handlers";
import { parseOAuthCallbackInvocationRef } from "./connection_flow/oauth_deep_link";
import type { ConnectionFlowInvocationRef } from "./connection_flow/state";
import {
AddMcpServerConfigSchema,
AddMcpServerPayload,
AddPromptDataSchema,
AddPromptPayload,
} from "./ipc/deep_link_data";
import {
startPerformanceMonitoring,
stopPerformanceMonitoring,
} from "./utils/performance_monitor";
import { queryWindowsPagefileSettings } from "./utils/windows_pagefile";
import {
browserCrashAttribution,
parseMinidumpSummary,
type MinidumpSummary,
} from "./utils/minidump_summary";
import {
listDumpFilesRecursive,
MAX_RETAINED_DUMPS,
moveDump,
pruneDumps,
RETAINED_DUMPS_DIR,
} from "./utils/crash_dumps";
import {
crashAnnotationEventFields,
crashPerformanceEventFields,
} from "./utils/crash_telemetry_fields";
import { appSizeEventFields } from "./shared/app_size_telemetry";
import {
claimPreviousSessionAppSize,
getPreviousSessionAppSize,
} from "./main/last_session_store";
import { classifyOom } from "./utils/oom_classifier";
import {
stopAllAppsSync,
stopAppGarbageCollection,
} from "./ipc/utils/process_manager";
import { cleanupOldAiMessagesJson } from "./pro/main/ipc/handlers/local_agent/ai_messages_cleanup";
import { cleanupStaleBuildSnapshots } from "./pro/main/ipc/handlers/local_agent/tools/run_build";
import {
startChatSearchIndexer,
stopChatSearchIndexer,
} from "./pro/main/ipc/handlers/local_agent/chat_search_indexer";
import { recoverInterruptedSubagents } from "./pro/main/ipc/handlers/local_agent/subagents/subagent_manager";
import { cleanupOldMediaFiles } from "./ipc/utils/media_cleanup";
import { scrubTokensFromRemotes } from "./ipc/utils/git_remote_token_scrub";
import { encryptStoredMcpSecrets } from "./ipc/utils/mcp_secret_encryption";
import { reconcileCloudSandboxes } from "./ipc/utils/cloud_sandbox_provider";
import fs from "fs";
import { gitAddSafeDirectory } from "./ipc/utils/git_utils";
import {
getDyadAppsBaseDirectory,
getDyadAppPath,
getUserDataPath,
} from "./paths/paths";
import { createDeepLinkQueue } from "./main/deep_link_queue";
import { DeepLinkWindowReadiness } from "./main/deep_link_window_readiness";
import { appRelaunchRequest } from "./main/app_relaunch_request";
import {
shouldCreateWindowOnActivate,
shouldRequestRelaunchOnActivate,
shouldRetainClosedWindowForActivation,
shouldQuitAfterAllWindowsClosed,
} from "./main/window_lifecycle_policy";
import { registerDyadProtocolLinux } from "./main/linux_protocol_registration";
import {
applyManagedPnpmToProcessPath,
getManagedPnpmBinDir,
getManagedPnpmInstallDir,
} from "./ipc/utils/socket_firewall";
import {
applyManagedNodeToProcessPath,
getManagedNodeVersion,
maybeUpgradeManagedNode,
} from "./ipc/utils/managed_node";
import { createDyadMediaProtocolHandler } from "./main/dyad_media_protocol";
import {
createPlatformThumbnailFromPath,
getMediaThumbnailCacheRoot,
} from "./ipc/utils/media_thumbnail";
import {
createMcpBeforeQuitHandler,
disposeMcpClientsForShutdown,
} from "./ipc/utils/mcp_shutdown";
import { disposeMcpOAuthForShutdown } from "./ipc/utils/mcp_oauth_flow";
import { remoteMachineHost } from "./ipc/services/distributed_machine_host";
import { configureTrustedRenderer } from "./ipc/utils/renderer_security";
import {
getWindowOpenHandlerResponse,
securePreviewPopupOptions,
shouldBlockMainWindowNavigation,
} from "./main/window_security";
import { pathToFileURL } from "node:url";
import { windowRegistry } from "./window_infrastructure/main/window_registry";
import {
PRIMARY_WINDOW_SESSION_ID,
type WindowSessionId,
} from "./window_infrastructure/types";
import {
awaitProductWindowRenderer,
configureWindowProductController,
} from "./window_infrastructure/main/window_product_controller";
import {
clearLegacyWindowSessionPersistence,
MAX_PRODUCT_WINDOWS,
restorableVisibleEntity,
type WindowSessionDescriptor,
} from "./window_infrastructure/main/window_session";
import { DyadError, DyadErrorKind, isDyadError } from "./errors/dyad_error";
import {
formatErrorBanner,
formatExitBanner,
formatPreviousSessionBanner,
formatStartBanner,
} from "./main/session_banner";
const LAUNCH_TIME = Date.now();
// Above the dev block so a failure there is still reported. Only registers
// process handlers; it writes nothing, so it can't fix the log directory early.
log.errorHandler.startCatching({
// Runs before electron-log logs the error itself. Must not return false:
// that suppresses both the stack trace and the error dialog.
onError: ({ errorName }) => {
const kind =
errorName === "Unhandled rejection"
? "unhandledRejection"
: "uncaughtException";
log.error(formatErrorBanner(process.pid, kind, Date.now() - LAUNCH_TIME));
},
});
// In dev, keep minidumps and logs under the project's ./userData, not the OS
// one. Must run before crashReporter.start and before the first log call, when
// electron-log caches its dir. macOS logs ignore userData: ~/Library/Logs/dyad.
if (process.env.NODE_ENV === "development") {
const devUserData = getUserDataPath();
fs.mkdirSync(devUserData, { recursive: true });
app.setPath("userData", devUserData);
const devCrashDumps = path.join(devUserData, "Crashpad");
fs.mkdirSync(devCrashDumps, { recursive: true });
app.setPath("crashDumps", devCrashDumps);
}
log.eventLogger.startLogging();
log.scope.labelPadding = false;
// First line of every session. See session_banner.ts.
log.info(
formatStartBanner({
pid: process.pid,
version: app.getVersion(),
platform: process.platform,
arch: process.arch,
electron: process.versions.electron ?? "unknown",
node: process.versions.node ?? "unknown",
}),
);
const execFileAsync = promisify(execFile);
// Prefer the Dyad-managed pnpm (if installed) for everything spawned from the
// main process. Runs after all module imports, so it wins over the shell PATH
// that fixPath() restores at app_runtime_service load time.
applyManagedPnpmToProcessPath();
async function resolveStartupExecutablePath(
command: string,
): Promise<string | null> {
try {
const { stdout } =
process.platform === "win32"
? await execFileAsync("where.exe", [command], {
encoding: "utf8",
env: process.env,
})
: await execFileAsync("which", [command], {
encoding: "utf8",
env: process.env,
});
return (
stdout
.split(/\r?\n/)
.find((line) => line.trim())
?.trim() ?? null
);
} catch {
return null;
}
}
async function logStartupExecutablePaths(): Promise<void> {
const [node, npm, pnpm] = await Promise.all([
resolveStartupExecutablePath("node"),
resolveStartupExecutablePath("npm"),
resolveStartupExecutablePath("pnpm"),
]);
// Resolves after module evaluation, so in a short-lived second instance this
// lands after that process's exit banner. The pid keeps it attributable.
log.info("Startup executable paths", {
pid: process.pid,
node,
npm,
pnpm,
managedPnpmBinDir: getManagedPnpmBinDir(),
managedPnpmInstallDir: getManagedPnpmInstallDir(),
});
}
void logStartupExecutablePaths();
// Capture native crashes (main/renderer/GPU/utility) as local minidumps. Not
// uploaded; parsed on the next launch into a summary we send as telemetry.
// Must start before the app is ready. globalExtra annotates every dump with
// static context; dynamic GPU status is added in onReady.
crashReporter.start({
uploadToServer: false,
compress: true,
globalExtra: {
app_version: app.getVersion(),
electron_version: process.versions.electron ?? "unknown",
chrome_version: process.versions.chrome ?? "unknown",
os: process.platform,
arch: process.arch,
},
});
const logger = log.scope("main");
let nativeCrashDumpsProcessed = false;
let pendingNativeBrowserCrash: {
summary: MinidumpSummary;
attribution: "ptype" | "sentinel";
dumpFile: string; // its name in the retain dir
} | null = null;
// Summarize each new minidump (signal, faulting module + offset, process type —
// no memory). A main-process crash is the app crash, so its summary is stashed
// and attached to app:crash_detected; other (survived child) crashes are left
// to their own paths. Each dump is then kept under a timestamped name for later
// examination. Runs once per session.
function processNativeCrashDumps(): void {
if (nativeCrashDumpsProcessed) {
return;
}
nativeCrashDumpsProcessed = true;
const crashDumpsDir = app.getPath("crashDumps");
// Where we keep dumps we've already reported, for later examination. Under
// userData (not inside crashDumpsDir) so it stays separate from Crashpad's
// own dump dirs and isn't picked up by the scan above.
const retainDir = path.join(app.getPath("userData"), RETAINED_DUMPS_DIR);
try {
fs.mkdirSync(retainDir, { recursive: true });
} catch (error) {
// Without the retain dir, moving a dump would fail and drop it. Leave the
// dumps in place and try again on the next launch.
logger.warn("Could not create crash reports directory:", error);
return;
}
for (const file of listDumpFilesRecursive(crashDumpsDir)) {
const retainedName = crashReportName(file);
const summary = parseMinidumpSummary(file);
if (summary) {
logger.info(
"Native crash:",
summary.ptype,
summary.crashReason,
summary.faultingModule,
summary.faultingOffset,
);
// The sentinel check in onReady runs before the window exists, so
// pendingCrashDetected already reflects it by the time this runs
// from did-finish-load.
const attribution = browserCrashAttribution(
summary,
pendingCrashDetected,
);
// A ptype dump outranks a sentinel-attributed one, so a stripped
// child dump cannot claim the crash when the labeled browser dump
// is also in the scan.
const outranks =
attribution === "ptype" &&
pendingNativeBrowserCrash?.attribution === "sentinel";
if (attribution && (!pendingNativeBrowserCrash || outranks)) {
pendingNativeBrowserCrash = {
summary,
attribution,
dumpFile: retainedName,
};
// A browser-process dump is direct evidence of a main-process crash,
// so report it even if the crash sentinel wasn't written (e.g. a
// crash during early startup).
pendingCrashDetected = true;
}
}
// Retain (timestamp-named) so it can be examined/exported and isn't
// re-summarized next launch; this also drains Crashpad's dump dirs.
moveDump(file, path.join(retainDir, retainedName));
}
pruneDumps(retainDir, MAX_RETAINED_DUMPS);
}
// A readable, sortable dump name from the crash time (the dump's mtime), with a
// short id suffix to avoid collisions: crash-2026-06-07T20-41-55-123Z-9ac4d4e8.dmp
function crashReportName(dumpPath: string): string {
let mtimeMs = Date.now();
try {
mtimeMs = fs.statSync(dumpPath).mtimeMs;
} catch {
// fall back to now
}
const ts = new Date(mtimeMs).toISOString().replace(/[:.]/g, "-");
const id = path.basename(dumpPath).slice(0, 8);
return `crash-${ts}-${id}.dmp`;
}
const deepLinkQueue = createDeepLinkQueue(handleDeepLinkReturn);
const deepLinkWindowReadiness = new DeepLinkWindowReadiness<BrowserWindow>(
deepLinkQueue,
);
let crashRecoveryWindowReadiness: DeepLinkWindowReadiness<BrowserWindow>;
crashRecoveryWindowReadiness = new DeepLinkWindowReadiness<BrowserWindow>({
markReady: () => {
const target = crashRecoveryWindowReadiness.getTarget();
if (target) deliverPendingCrashRecovery(target);
},
markNotReady: () => undefined,
});
// Load environment variables from .env file
dotenv.config();
// Register IPC handlers before app is ready
registerIpcHandlers();
// Decide the git directory depending on environment
function resolveLocalGitDirectory() {
if (!app.isPackaged) {
// Dev: app.getAppPath() is the project root
return path.join(app.getAppPath(), "node_modules/dugite/git");
}
// Packaged app: git is bundled via extraResource
return path.join(process.resourcesPath, "git");
}
const gitDir = resolveLocalGitDirectory();
if (fs.existsSync(gitDir)) {
process.env.LOCAL_GIT_DIRECTORY = gitDir;
}
// https://www.electronjs.org/docs/latest/tutorial/launch-app-from-url-in-another-app#main-process-mainjs
if (process.defaultApp) {
if (process.argv.length >= 2) {
app.setAsDefaultProtocolClient("dyad", process.execPath, [
path.resolve(process.argv[1]),
]);
}
} else {
app.setAsDefaultProtocolClient("dyad");
}
export async function onReady() {
// Take over the sentinel before any startup work that can crash. Migrations,
// the keychain and git all run below; if one of them kills us, a sentinel
// still naming the previous session would report this crash as that one.
const previousSession = claimCrashSentinel(LAUNCH_TIME);
// Cookie isolation changes require restarting Dyad, not individual apps.
isPreviewCookieIsolationEnabled();
// Claimed here, before anything can overwrite it, so the record always
// describes the session that just ended. Runs whether or not that session
// crashed: both populations have to be measured the same way.
claimPreviousSessionAppSize();
// Linux: claim the dyad:// scheme for this build (best-effort, see module).
// setAsDefaultProtocolClient above is unreliable on Linux. Pass this instance's
// userData so a browser-launched deep link forwards here, not a second window.
void registerDyadProtocolLinux(app.getPath("userData"));
// React DevTools extension loading is intentionally disabled. In Electron it
// can spam startup logs with:
// "Unchecked runtime.lastError: Could not establish connection. Receiving end does not exist."
// from chrome-extension://fmkadmapgofadopljbjfkapdkoienihi/main.html.
try {
const backupManager = new BackupManager({
settingsFile: getSettingsFilePath(),
dbFile: getDatabasePath(),
});
await backupManager.initialize();
} catch (e) {
logger.error("Error initializing backup manager", e);
}
try {
initializeDatabase();
} catch (error) {
logger.error("Failed to initialize database", error);
const message = error instanceof Error ? error.message : String(error);
dialog.showErrorBox(
"Database Migration Failed",
`Dyad could not initialize its local database. ${message}`,
);
app.quit();
return;
}
void recoverInterruptedSubagents().catch((error) =>
logger.error("Failed to reconcile interrupted sub-agents", error),
);
void cleanupStaleBuildSnapshots().catch((error) =>
logger.error("Failed to clean stale production build snapshots", error),
);
// Reconcile any Neon test branches / Supabase test users leaked by a previous
// session that crashed mid test-run. Fire-and-forget: best-effort cleanup
// must not block startup.
void reconcileOrphanTestBranches();
void reconcileOrphanTestUsers();
// Also prunes retained test artifacts whose app no longer exists — nothing
// else ever removes them, and the user has no surface that shows they exist.
// Read when the prune is about to run, not now: the sandbox sweep it happens
// after can take a long time, and an app created in the meantime must not
// have its first run's artifacts deleted as orphans.
void reconcileOrphanE2eTestWorkspaces({
refreshKnownAppIds: async () =>
new Set(
(await db.query.apps.findMany({ columns: { id: true } })).map(
(row) => row.id,
),
),
}).catch((error) =>
logger.error("Failed to reconcile abandoned E2E test workspaces", error),
);
// Cleanup old ai_messages_json entries to prevent database bloat
cleanupOldAiMessagesJson();
// Start the chat-search FTS index maintenance (backfill runs in the
// background; never blocks startup)
startChatSearchIndexer();
// Cleanup old media files to reclaim disk space
cleanupOldMediaFiles();
// Remove GitHub tokens that older versions embedded in git remote URLs
scrubTokensFromRemotes();
// Encrypt MCP headers and env vars that are still stored as
// plaintext. Awaited so no MCP read can see a row the pass is about
// to correct. It returns rather than throwing on failure, and does
// no work at all once there are no plaintext values left.
await encryptStoredMcpSecrets();
const settings = await readEffectiveSettings();
// Handler registration happens before ready. Reconciliation can decrypt
// settings, so start it here after the runtime and database are ready.
void reconcileCloudSandboxes().catch((error) => {
logger.warn("Failed to reconcile cloud sandboxes on startup:", error);
});
// Add dyad-apps directory to git safe.directory (required for Windows).
// The trailing /* allows access to all repositories under the named directory.
// See: https://git-scm.com/docs/git-config#Documentation/git-config.txt-safedirectory
// Don't need to await because this only needs to run before
// the user starts interacting with Dyad app and uses a git-related feature.
gitAddSafeDirectory(`${getDyadAppsBaseDirectory()}/*`);
// Check if app was force-closed by checking for the crash sentinel file.
// The sentinel is written at startup and deleted in before-quit on clean exit.
// If it exists at startup, the previous session ended without a clean quit.
//
// Migration fallback: builds prior to the sentinel approach used a
// settings.isRunning flag to detect crashes. On first launch of a new build
// after a force-close of an old build, the sentinel won't exist yet but
// settings.isRunning may still be true. Honour it once, then clear it so
// subsequent runs rely solely on the sentinel.
const legacyIsRunningCrash = settings.isRunning === true;
// previousSession.previous is null for the legacy bare-timestamp sentinel,
// which still counts as existing.
if (previousSession.existed || legacyIsRunningCrash) {
// Scoped and at warn, unlike this session's own banners. Deliberate: warn is
// the only level that survives the filtered log tail users paste into bug
// reports (see readAppLogs in debug_handlers), and this is the line that
// tells us their last session crashed.
logger.warn(
formatPreviousSessionBanner(
previousSession.previous?.ts,
settings.lastKnownPerformance?.timestamp,
),
);
pendingCrashDetected = true;
// Store performance data to send after window is created
if (settings.lastKnownPerformance) {
logger.warn("Last known performance:", settings.lastKnownPerformance);
pendingForceCloseData = settings.lastKnownPerformance;
}
// The chat that was streaming when the crash happened, if any, so the
// dialog can offer a one-click upload of it.
pendingActiveChatId = previousSession.previous?.activeChatId ?? null;
}
// TODO: Remove legacyIsRunningCrash migration path after a few releases
// once existing users have launched at least once on the sentinel build.
if (legacyIsRunningCrash) {
tryWriteSettings(
{ isRunning: false },
"clearing the legacy isRunning crash flag",
);
}
// Record the GPU's feature status (is compositing / WebGL hardware-accelerated)
// on every dump written from here on. It's useful context when reading a dump,
// since the GPU driver can be involved in a crash. This can't go in
// crashReporter.start's globalExtra because the status isn't known until the
// app is ready; addExtraParameter adds it to all later dumps.
try {
const gpu = app.getGPUFeatureStatus();
crashReporter.addExtraParameter(
"gpu_compositing",
String(gpu?.gpu_compositing ?? "unknown"),
);
crashReporter.addExtraParameter(
"gpu_webgl",
String(gpu?.webgl ?? "unknown"),
);
} catch (error) {
logger.warn("Could not read GPU status for crash context:", error);
}
logger.info("Crash dumps directory:", app.getPath("crashDumps"));
// Read the pagefile configuration once, in the background, so the
// heartbeat and app:initial-load can report it. A no-op off Windows.
void queryWindowsPagefileSettings();
// Start performance monitoring. The registry lookup is passed in rather
// than imported there: window registry internals stay inside main-only
// modules (see the boundaries test).
startPerformanceMonitoring({
isProductWebContents: (webContentsId) =>
windowRegistry.sessionForWebContents(webContentsId) !== undefined,
});
// Handle dyad-media:// requests. Media-library tiles use bounded, cached
// derivatives while explicit previews continue to receive the source file.
protocol.handle(
"dyad-media",
createDyadMediaProtocolHandler({
cacheRoot: getMediaThumbnailCacheRoot(app.getPath("sessionData")),
resolveAppPath: getDyadAppPath,
resolveAppId: async (appId) => {
const appRecord = await db.query.apps.findFirst({
where: eq(apps.id, appId),
columns: { path: true },
});
return appRecord ? getDyadAppPath(appRecord.path) : null;
},
fetchFile: (url) => net.fetch(url),
createThumbnailFromPath: (sourcePath, size) =>
createPlatformThumbnailFromPath(nativeImage, sourcePath, size),
}),
);
const shouldUseManagedNode =
settings.nodeRuntimePreference === "managed" && !settings.customNodePath;
const managedNodeVersion = await getManagedNodeVersion();
if (shouldUseManagedNode) {
if (managedNodeVersion) {
applyManagedNodeToProcessPath();
} else {
logger.warn(
"Managed Node.js is selected, but no usable managed runtime is installed.",
);
}
void maybeUpgradeManagedNode();
}
await onFirstRunMaybe(settings);
await createFreshStartupWindow();
createApplicationMenu();
sendTelemetryEvent("runtime_source", {
runtime_source: settings.customNodePath
? "custom"
: shouldUseManagedNode && managedNodeVersion
? "managed"
: "system",
managed_node_installed: !!managedNodeVersion,
managed_node_version: managedNodeVersion,
});
logger.info("Auto-update enabled=", settings.enableAutoUpdate);
if (settings.enableAutoUpdate) {
// Technically we could just pass the releaseChannel directly to the host,
// but this is more explicit and falls back to stable if there's an unknown
// release channel.
const postfix = settings.releaseChannel === "beta" ? "beta" : "stable";
const host = `https://api.dyad.sh/v1/update/${postfix}`;
logger.info("Auto-update release channel=", postfix);
// update-electron-app logs updater errors at info level, which the
// warn-filtered bug-report logs drop — leaving only the orphaned stack
// trace tail. Log at error level and record for debug bundles.
autoUpdater.on("error", (error) => {
logger.error("Auto-updater error:", error);
recordUpdaterError(error);
});
updateElectronApp({
logger,
updateInterval: "60 minutes",
updateSource: {
type: UpdateSourceType.ElectronPublicUpdateService,
repo: "dyad-sh/dyad",
host,
},
}); // additional configuration options available
}
}
function scheduleSafeStorageKeychainUnlockRetryAfterRendererLoad(): void {
setSafeStorageRecoveryFailureListener(
scheduleSafeStorageKeychainUnlockRetryAfterRendererLoad,
);
if (safeStorageKeychainUnlockRetryScheduled) {
return;
}
safeStorageKeychainUnlockRetryScheduled = true;
setTimeout(() => {
try {
if (recoveryNeedsKeychainUnlock() && retryRecoveryWithKeychainUnlock()) {
rewriteRecoveredSafeStorageSecretsAfterKeychainUnlock();
}
} finally {
safeStorageKeychainUnlockRetryScheduled = false;
}
}, 0);
}
export async function onFirstRunMaybe(settings: UserSettings) {
const isFirstSession = settings.hasRunBefore === false;
setInitialLoadIsFirstSession(isFirstSession);
if (isFirstSession) {
await promptMoveToApplicationsFolder();
tryWriteSettings({ hasRunBefore: true }, "marking first run complete");
}
if (IS_TEST_BUILD) {
tryWriteSettings({ isTestMode: true }, "marking test mode");
}
}
/**
* Ask the user if the app should be moved to the
* applications folder.
*/
async function promptMoveToApplicationsFolder(): Promise<void> {
// Why not in e2e tests?
// There's no way to stub this dialog in time, so we just skip it
// in e2e testing mode.
if (IS_TEST_BUILD) return;
if (process.platform !== "darwin") return;
if (app.isInApplicationsFolder()) return;
logger.log("Prompting user to move to applications folder");
const { response } = await dialog.showMessageBox({
type: "question",
buttons: ["Move to Applications Folder", "Do Not Move"],
defaultId: 0,
message: "Move to Applications Folder? (required for auto-update)",
});
if (response === 0) {
logger.log("User chose to move to applications folder");
app.moveToApplicationsFolder();
} else {
logger.log("User chose not to move to applications folder");
}
}
declare global {
const MAIN_WINDOW_VITE_DEV_SERVER_URL: string;
}
let mainWindow: BrowserWindow | null = null;
const productWindows = new Map<WindowSessionId, BrowserWindow>();
const productWindowDescriptors = new Map<
WindowSessionId,
WindowSessionDescriptor
>();
let lastClosedWindowSession: WindowSessionDescriptor | undefined;
let hasCreatedInitialWindow = false;
let pendingForceCloseData: any = null;
let pendingActiveChatId: number | null = null;
let pendingCrashDetected = false;
let isAppQuitting = false;
let safeStorageKeychainUnlockRetryScheduled = false;
const lifecycleLogger = log.scope("app_lifecycle");
const lifecycleStartedAt = Date.now();
let lifecycleSequence = 0;
function logLifecycle(
event: string,
details: Record<string, unknown> = {},
): void {
let windows: Array<{
id: number;
destroyed: boolean;
visible: boolean;
focused: boolean;
webContentsDestroyed: boolean;
}> = [];
try {
windows = BrowserWindow.getAllWindows().map((window) => ({
id: window.id,
destroyed: window.isDestroyed(),
visible: window.isVisible(),
focused: window.isFocused(),
webContentsDestroyed: window.webContents.isDestroyed(),
}));
} catch (error) {
lifecycleLogger.warn("snapshot-failed", event, error);
}
lifecycleLogger.info(event, {
sequence: ++lifecycleSequence,
elapsedMs: Date.now() - lifecycleStartedAt,
isAppQuitting,
hasCreatedInitialWindow,
mainWindowId:
mainWindow && !mainWindow.isDestroyed() ? mainWindow.id : null,
productWindowCount: productWindows.size,
windows,
...details,
});
}
async function observeLifecycleCleanup(
name: string,
cleanup: () => Promise<void>,
): Promise<void> {
const startedAt = Date.now();
logLifecycle("cleanup:start", { name });
try {
await cleanup();
logLifecycle("cleanup:settled", {
name,
durationMs: Date.now() - startedAt,
outcome: "fulfilled",
});
} catch (error) {
logLifecycle("cleanup:settled", {
name,
durationMs: Date.now() - startedAt,
outcome: "rejected",
error: error instanceof Error ? error.message : String(error),
});
throw error;
}
}
function requestRelaunchAfterQuit(deepLinkUrl?: string): void {
if (appRelaunchRequest.request({ deepLinkUrl })) {
logger.info("Reopen requested during shutdown; relaunching after cleanup");
logLifecycle("relaunch:requested", { hasDeepLink: !!deepLinkUrl });
}
}
function deliverPendingCrashRecovery(target: BrowserWindow): void {
if (!pendingCrashDetected || target !== mainWindow || target.isDestroyed()) {
return;
}
target.webContents.send("force-close-detected", {
...(pendingForceCloseData && {
performanceData: pendingForceCloseData,
}),
...(pendingActiveChatId != null && {
activeChatId: pendingActiveChatId,
}),
});
const nativeCrash = pendingNativeBrowserCrash?.summary ?? null;
const nativeCrashAttribution = pendingNativeBrowserCrash?.attribution ?? null;
// A report started from the crash dialog names this dump.
recordPreviousSessionCrash(pendingNativeBrowserCrash?.dumpFile ?? null);
pendingNativeBrowserCrash = null;
const oom = classifyOom({
nativeCrash,
performance: pendingForceCloseData,
});
sendTelemetryEventToWindow(target, "app:crash_detected", {
// Mark as error so renderer PostHog before_send sampling does not
// drop 90% of events for non-Pro users (see src/renderer.tsx).
error: true,
has_performance_data: !!pendingForceCloseData,
...(pendingForceCloseData &&
crashPerformanceEventFields(pendingForceCloseData)),
// "native" when a minidump was attributed to this crash, else
// "unknown" (no dump: force-kill / OOM-kill / power loss / missed).
crash_cause: nativeCrash ? "native" : "unknown",
...(nativeCrash && {
// "ptype" when the dump named the browser process itself; "sentinel"
// when an annotation-stripped dump was correlated with the crash
// sentinel instead (see browserCrashAttribution). Sentinel
// attribution is weaker: the dump could belong to a child process
// that also lost its labels.
crash_attribution: nativeCrashAttribution,
crash_reason: nativeCrash.crashReason,
exception_code: nativeCrash.exceptionCode,
fault_address: nativeCrash.faultAddress,
access_type: nativeCrash.accessType,
in_page_error_status: nativeCrash.inPageErrorStatus,
oom_allocation_size_bytes: nativeCrash.oomAllocationSizeBytes,
fast_fail_code: nativeCrash.fastFailCode,
faulting_module: nativeCrash.faultingModule,
faulting_offset: nativeCrash.faultingOffset,
faulting_debug_file: nativeCrash.faultingDebugFile,
faulting_debug_id: nativeCrash.faultingDebugId,
}),
...(nativeCrash?.annotations &&
crashAnnotationEventFields(nativeCrash.annotations)),
// The OOM verdict and the signals behind it (see classifyOom).
// Comma-joined: crash event properties stay scalar for PostHog.
oom_verdict: oom.verdict,
...(oom.signals.length > 0 && { oom_signals: oom.signals.join(",") }),
// How big the app was that this session died on. Same helper as
// app:initial-load, so both events carry identical fields.
...appSizeEventFields(getPreviousSessionAppSize()),
});
pendingForceCloseData = null;
pendingActiveChatId = null;
pendingCrashDetected = false;
}
const createWindow = ({
windowSessionId = randomUUID() as WindowSessionId,
visibleEntity,
}: Partial<WindowSessionDescriptor> = {}): {
windowSessionId: WindowSessionId;
browserWindow: BrowserWindow;
rendererLoad: Promise<void>;
} => {
if (isAppQuitting) {
throw new DyadError("Dyad is shutting down", DyadErrorKind.Precondition);
}
// Create the browser window.
const browserWindow = new BrowserWindow({
width: process.env.NODE_ENV === "development" ? 1280 : 960,
minWidth: 800,
height: 700,
minHeight: 500,
titleBarStyle: "hidden",
titleBarOverlay: false,
trafficLightPosition: {
x: 13,
y: 13,
},
webPreferences: {
nodeIntegration: false,
contextIsolation: true,
preload: path.join(__dirname, "preload.js"),
// transparent: true,
},
icon: path.join(app.getAppPath(), "assets/icon/logo.png"),
// backgroundColor: "#00000001",
// frame: false,
});
mainWindow = browserWindow;
deepLinkWindowReadiness.setTarget(browserWindow);
crashRecoveryWindowReadiness.setTarget(browserWindow);
productWindows.set(windowSessionId, browserWindow);
productWindowDescriptors.set(windowSessionId, {
windowSessionId,
visibleEntity,
});
logLifecycle("window:created", {
windowId: browserWindow.id,
windowSessionId,
});
windowRegistry.register(browserWindow.webContents, windowSessionId);
browserWindow.on("focus", () => {
mainWindow = browserWindow;
deepLinkWindowReadiness.setTarget(browserWindow);
crashRecoveryWindowReadiness.setTarget(browserWindow);
windowRegistry.setFocused(windowSessionId);
});
browserWindow.once("closed", () => {
const descriptor = productWindowDescriptors.get(windowSessionId);
const retainForActivation = shouldRetainClosedWindowForActivation({
isAppQuitting,
openWindowCountBeforeClose: productWindows.size,
});
productWindows.delete(windowSessionId);
productWindowDescriptors.delete(windowSessionId);
if (retainForActivation && descriptor) {
lastClosedWindowSession = descriptor;
}
if (mainWindow === browserWindow) {
mainWindow =
[...productWindows.values()].find((window) => window.isFocused()) ??
[...productWindows.values()].at(-1) ??
null;
deepLinkWindowReadiness.setTarget(mainWindow);
crashRecoveryWindowReadiness.setTarget(mainWindow);
}
logLifecycle("window:closed", {
windowId: browserWindow.id,
windowSessionId,
retainedForActivation: retainForActivation,
});
});
browserWindow.on("close", (event) => {
logLifecycle("window:close", {
windowId: browserWindow.id,
windowSessionId,
defaultPrevented: event.defaultPrevented,
});
});
browserWindow.on("unresponsive", () => {
logLifecycle("window:unresponsive", {
windowId: browserWindow.id,
windowSessionId,
});
});
browserWindow.on("responsive", () => {
logLifecycle("window:responsive", {
windowId: browserWindow.id,
windowSessionId,
});
});
browserWindow.webContents.on("will-prevent-unload", (event) => {
logLifecycle("renderer:will-prevent-unload", {
windowId: browserWindow.id,
windowSessionId,
defaultPrevented: event.defaultPrevented,
});
});
const packagedRendererUrl = pathToFileURL(
path.join(__dirname, "../renderer/main_window/index.html"),
).href;
const allowedDevServerUrl = MAIN_WINDOW_VITE_DEV_SERVER_URL || undefined;
configureTrustedRenderer({
devServerUrl: allowedDevServerUrl,
packagedRendererUrl,
});
const rejectUnexpectedNavigation = (
event: ElectronEvent<{ isMainFrame: boolean; url: string }>,
) => {
if (
shouldBlockMainWindowNavigation(
event.url,
event.isMainFrame,
allowedDevServerUrl,
packagedRendererUrl,
)
) {
event.preventDefault();
logger.warn("Blocked unexpected main-window navigation:", event.url);
}
};
browserWindow.webContents.on("will-navigate", rejectUnexpectedNavigation);
browserWindow.webContents.on("will-redirect", rejectUnexpectedNavigation);
const previewPopupWindows = new Set<BrowserWindow>();
browserWindow.webContents.setWindowOpenHandler((details) => {
const response = getWindowOpenHandlerResponse(details, allowedDevServerUrl);
if (response.action === "deny") {
logger.warn("Blocked unexpected child window:", details.url);
return response;
}
logger.debug("Allowing sandboxed preview child window:", details.url);
return {
...response,
createWindow: (options) => {
const popup = new BrowserWindow(securePreviewPopupOptions(options));
previewPopupWindows.add(popup);
popup.once("closed", () => previewPopupWindows.delete(popup));
popup.webContents.setWindowOpenHandler(() => ({ action: "deny" }));
return popup.webContents;
},
};
});
browserWindow.webContents.on(
"did-start-navigation",
(_event, _url, isInPlace, isMainFrame) => {
if (!isMainFrame || isInPlace) return;
deepLinkWindowReadiness.markNotReady(browserWindow);
crashRecoveryWindowReadiness.markNotReady(browserWindow);
},
);
// and load the index.html of the app.
let initialLoad: Promise<void>;
if (MAIN_WINDOW_VITE_DEV_SERVER_URL) {
initialLoad = browserWindow.loadURL(MAIN_WINDOW_VITE_DEV_SERVER_URL);
} else {
initialLoad = browserWindow.loadFile(
path.join(__dirname, "../renderer/main_window/index.html"),
);
}
void initialLoad.catch((error) => {
logger.error("Product window renderer failed to load:", error);
});
// Handle force-close message and development reload coordination
let devToolsReloadedCount = 0;
browserWindow.webContents.on("did-finish-load", () => {
// Mark every completed load ready before development-only coordination so
// transport readiness does not depend on the DevTools reload heuristic.
deepLinkWindowReadiness.markReady(browserWindow);
if (process.env.NODE_ENV === "development") {
// In dev, wait until AFTER the DevTools-triggered reload before sending the message
if (devToolsReloadedCount === 0) {
devToolsReloadedCount++;
return; // Ignore first load, we will reload momentarily
}
}
// Summarize native crash minidumps before sending app:crash_detected. If
// the main process crashed natively, that summary becomes the crash cause
// reported in app:crash_detected.
processNativeCrashDumps();
// Crash recovery follows the focused/current product window and waits for
// its post-DevTools-reload renderer. Background product windows cannot
// consume the one-shot dialog or telemetry event.
crashRecoveryWindowReadiness.markReady(browserWindow);
// Forward any pending renderer crash recorded on a previous load. We send
// this from `did-finish-load` rather than `render-process-gone` because the
// renderer (which owns the PostHog client) is dead at crash time.
const rendererCrash = readRendererCrashRecord();
if (rendererCrash) {
const perf = rendererCrash.performance;
sendTelemetryEventToWindow(browserWindow, "renderer:crash_detected", {
// Mark as error so renderer PostHog before_send sampling does not
// drop 90% of events for non-Pro users (see src/renderer.tsx).
error: true,
reason: rendererCrash.reason,
exit_code: rendererCrash.exitCode,
crash_count: rendererCrash.count,
crash_timestamp: rendererCrash.timestamp,
ms_since_crash: Date.now() - rendererCrash.timestamp,
// Mirror the `app:crash_detected` performance fields so the two
// events can be unioned in PostHog without per-event field mapping.
has_performance_data: !!perf,
...(perf && crashPerformanceEventFields(perf)),
});
clearRendererCrashRecord();
}
scheduleSafeStorageKeychainUnlockRetryAfterRendererLoad();
});
// Start the development-only DevTools reload after the initial renderer load
// succeeds. Explicit new-window creation can safely await `initialLoad`
// without that intentional reload aborting its promise.
if (process.env.NODE_ENV === "development") {
void initialLoad.then(
() => {
if (browserWindow.isDestroyed()) return;
browserWindow.webContents.once("devtools-opened", () => {
setTimeout(() => {
if (!browserWindow.isDestroyed()) {
browserWindow.webContents.reloadIgnoringCache();
}
}, 300);
});
browserWindow.webContents.openDevTools();
},
() => undefined,
);
}
// Persist any non-clean renderer-process termination so we can report it on
// the next successful renderer load. We deliberately do nothing here besides
// writing the record: triggering reloads/dialogs is out of scope for the
// telemetry hook.
browserWindow.webContents.on("render-process-gone", (_event, details) => {
if (isAppQuitting) {
return;
}
if (details.reason === "clean-exit") {
return;
}
logger.error(
"Renderer process gone:",
details.reason,
"exitCode=",
details.exitCode,
);
// Capture the latest heartbeat snapshot synchronously so the record pins
// the pre-crash performance state, matching the semantics of
// `app:crash_detected`. `readSettings` returns `DEFAULT_SETTINGS` on any
// I/O or parse failure rather than throwing, so this is safe to inline.
recordRendererCrash({
reason: details.reason,
exitCode: details.exitCode,
performance: readSettings().lastKnownPerformance,
});
});
// Enable native context menu on right-click
browserWindow.webContents.on("context-menu", (event, params) => {
// Prevent any default behavior and show our own menu
event.preventDefault();
const template: Electron.MenuItemConstructorOptions[] = [];
if (params.isEditable) {
template.push(
{ role: "undo" },
{ role: "redo" },
{ type: "separator" },
{ role: "cut" },
{ role: "copy" },
{ role: "paste" },
{ role: "delete" },
);
if (params.misspelledWord) {
const suggestions: Electron.MenuItemConstructorOptions[] =
params.dictionarySuggestions.slice(0, 5).map((suggestion) => ({
label: suggestion,
click: () => {
try {
browserWindow.webContents.replaceMisspelling(suggestion);
} catch (error) {
logger.error("Failed to replace misspelling:", error);
}
},
}));
template.push(
{ type: "separator" },
{
type: "submenu",
label: `Correct "${params.misspelledWord}"`,
submenu: suggestions,
},
);
}
template.push({ type: "separator" }, { role: "selectAll" });
} else {
if (params.selectionText && params.selectionText.length > 0) {
template.push({ role: "copy" });
}
template.push({ role: "selectAll" });
}
if (process.env.NODE_ENV === "development") {
template.push(
{ type: "separator" },
{
label: "Inspect Element",
click: () =>
browserWindow.webContents.inspectElement(params.x, params.y),
},
);
}
const menu = Menu.buildFromTemplate(template);
menu.popup({ window: browserWindow });
});
return { windowSessionId, browserWindow, rendererLoad: initialLoad };
};
async function createFreshStartupWindow(): Promise<void> {
try {
await clearLegacyWindowSessionPersistence(app.getPath("userData"));
} catch (error) {
logger.error(
"Failed to clear legacy window sessions; continuing with one fresh window:",
error,
);
}
if (isAppQuitting) {
logger.info("Skipping initial window creation during shutdown");
return;
}
createWindow({ windowSessionId: PRIMARY_WINDOW_SESSION_ID });
hasCreatedInitialWindow = true;
}
configureWindowProductController({
openEntityInNewWindow: async (entity) => {
if (productWindows.size >= MAX_PRODUCT_WINDOWS) {
throw new DyadError(
`Dyad supports up to ${MAX_PRODUCT_WINDOWS} open windows`,
DyadErrorKind.Precondition,
);
}
try {
const created = createWindow({
windowSessionId: randomUUID() as WindowSessionId,
visibleEntity: entity,
});
return await awaitProductWindowRenderer({
rendererLoad: created.rendererLoad,
result: created.windowSessionId,
rollback: () => {
const {
windowSessionId: failedSessionId,
browserWindow: failedWindow,
} = created;
const failedWebContentsId = failedWindow.webContents.id;
productWindows.delete(failedSessionId);
productWindowDescriptors.delete(failedSessionId);
if (!failedWindow.isDestroyed()) failedWindow.destroy();
windowRegistry.unregister(failedWebContentsId);
},
});
} catch (error) {
if (isDyadError(error)) throw error;
const detail = error instanceof Error ? error.message : String(error);
throw new DyadError(
`Failed to open a new window: ${detail}`,
DyadErrorKind.External,
{ cause: error },
);
}
},
initialEntityForSession: (windowSessionId) =>
productWindowDescriptors.get(windowSessionId)?.visibleEntity,
setVisibleEntities: (windowSessionId, entities) => {
const descriptor = productWindowDescriptors.get(windowSessionId);
if (!descriptor) return;
productWindowDescriptors.set(windowSessionId, {
...descriptor,
visibleEntity: restorableVisibleEntity(entities),
});
},
mayMigrateLegacyChatTabSession: (windowSessionId) =>
productWindows.keys().next().value === windowSessionId,
restorableWindowSessionIds: () => [...productWindows.keys()],
});
/**
* Create application menu with Edit shortcuts (Undo, Redo, Cut, Copy, Paste, etc.)
* This enables standard keyboard shortcuts like Cmd/Ctrl+C, Cmd/Ctrl+V, etc.
*/
const createApplicationMenu = () => {
const isMac = process.platform === "darwin";
const template: Electron.MenuItemConstructorOptions[] = [
// App menu (macOS only)
...(isMac
? [
{
label: app.name,
submenu: [
{ role: "about" as const },
{ type: "separator" as const },
{ role: "services" as const },
{ type: "separator" as const },
{ role: "hide" as const },
{ role: "hideOthers" as const },
{ role: "unhide" as const },
{ type: "separator" as const },
{ role: "quit" as const },
],
},
]
: []),
// Edit menu - enables keyboard shortcuts for clipboard operations
{
label: "Edit",
submenu: [
{ role: "undo" as const },
{ role: "redo" as const },
{ type: "separator" as const },
{ role: "cut" as const },
{ role: "copy" as const },
{ role: "paste" as const },
{ role: "delete" as const },
{ type: "separator" as const },
{ role: "selectAll" as const },
],
},
// View menu
{
label: "View",
submenu: [
{
label: "Reload Dyad",
click: () => BrowserWindow.getFocusedWindow()?.reload(),
},
{
label: "Force Reload Dyad",
click: () =>
BrowserWindow.getFocusedWindow()?.webContents.reloadIgnoringCache(),
},
{ type: "separator" as const },
...(process.env.NODE_ENV === "development"
? [
{ role: "toggleDevTools" as const },
{ type: "separator" as const },
]
: []),
{ role: "togglefullscreen" as const },
],
},
// Window menu
{
label: "Window",
submenu: [
{ role: "minimize" as const },
{ role: "zoom" as const },
...(isMac
? [
{ type: "separator" as const },
{ role: "front" as const },
{ type: "separator" as const },
{ role: "window" as const },
]
: [{ role: "close" as const }]),
],
},
];
const appMenu = Menu.buildFromTemplate(template);
Menu.setApplicationMenu(appMenu);
};
// Register dyad-media:// protocol for serving persistent media attachments.
// Must be called before app.whenReady().
protocol.registerSchemesAsPrivileged([
{
scheme: "dyad-media",
privileges: {
standard: true,
secure: true,
supportFetchAPI: true,
stream: true,
},
},
]);
// A cold-start protocol URL arrives in argv before any renderer is ready.
// Queue it in both production and E2E builds; the latter skips only the
// singleton lock so parallel test processes can coexist.
const initialDeepLink = process.argv.find((arg) => arg.startsWith("dyad://"));
if (initialDeepLink) {
deepLinkQueue.handle(initialDeepLink);
}
// Skip singleton lock for E2E test builds to allow parallel test execution.
// Deep link handling still works via the 'open-url' event registered below.
// The 'second-instance' handler is intentionally omitted since it requires the singleton lock.
if (IS_TEST_BUILD) {
startAppWhenReady();
} else {
const gotTheLock = app.requestSingleInstanceLock();
if (!gotTheLock) {
// This instance already logged a start banner above, so terminate it.
log.info(formatExitBanner(process.pid, "duplicate instance"));
app.quit();
} else {
app.on("second-instance", (_event, commandLine, _workingDirectory) => {
const url = commandLine.find((arg) => arg.startsWith("dyad://"));
if (isAppQuitting) {
requestRelaunchAfterQuit(url);
return;
}
// Someone tried to run a second instance, we should focus our window.
if (mainWindow) {
if (mainWindow.isMinimized()) mainWindow.restore();
mainWindow.focus();
}
if (url) {
deepLinkQueue.handle(url);
}
});
startAppWhenReady();
}
}
// Handle the protocol. In this case, we choose to show an Error Box.
app.on("open-url", (event, url) => {
if (isAppQuitting) {
requestRelaunchAfterQuit(url);
return;
}
deepLinkQueue.handle(url);
});
function startAppWhenReady() {
app.whenReady().then(onReady);
}
function getErrorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
function showDeepLinkSettingsError(action: string, error: unknown): void {
logger.error(`Failed to ${action}:`, error);
dialog.showErrorBox(
"Unable to Save Settings",
`Failed to ${action}: ${getErrorMessage(error)}`,
);
}
/**
* Surfaces a Neon/Supabase deep-link callback that did not claim an active
* flow. The proxy's success page keeps an "Open Dyad" link that replays the
* same callback, so a repeat of one Dyad already accepted is logged and
* dropped quietly; only unknown, stale, or expired callbacks get the dialog.
* Neither path writes credentials.
*/
function reportUnclaimedOAuthReturn(
provider: "neon" | "supabase",
label: string,
invocationRef: ConnectionFlowInvocationRef,
error: unknown,
) {
if (
connectionFlowRegistry.isDuplicateOfAcceptedReturn(provider, invocationRef)
) {
logger.info(
`Ignoring repeat ${provider} OAuth callback for an already accepted connection flow.`,
);
return;
}
logger.warn(`Rejected unmatched ${provider} OAuth callback:`, error);
dialog.showErrorBox(
"Sign-in Could Not Be Verified",
`This ${label} sign-in expired or no longer matches the connection started by Dyad. Please connect again.`,
);
}
async function handleDeepLinkReturn(url: string) {
// example url: "dyad://supabase-oauth-return?token=a&refreshToken=b"
let parsed: URL;
try {
parsed = new URL(url);
} catch {
log.info("Invalid deep link URL", url);
return;
}
// Intentionally do NOT log the full URL which may contain sensitive tokens.
log.log(
"Handling deep link: protocol",
parsed.protocol,
"hostname",
parsed.hostname,
);
if (parsed.protocol !== "dyad:") {
dialog.showErrorBox(
"Invalid Protocol",
`Expected dyad://, got ${parsed.protocol}. Full URL: ${url}`,
);
return;
}
if (parsed.hostname === "neon-oauth-return") {
const token = parsed.searchParams.get("token");
const refreshToken = parsed.searchParams.get("refreshToken");
const expiresIn = Number(parsed.searchParams.get("expiresIn"));
if (!token || !refreshToken || !expiresIn) {
dialog.showErrorBox(
"Invalid URL",
"Expected token, refreshToken, and expiresIn",
);
return;
}
const expectedInvocationRef = parseOAuthCallbackInvocationRef(
"neon",
parsed.searchParams.get("state"),
);
if (!expectedInvocationRef) {
dialog.showErrorBox(
"Invalid URL",
"This Neon sign-in could not be verified. Please connect again from Dyad.",
);
return;
}
{
// Runs the token write through the connection flow machine. Only the
// exact active invocation may advance and persist credentials.
const outcome = await runOAuthReturnExchange(
"neon",
() => {
handleNeonOAuthReturn({ token, refreshToken, expiresIn });
},
{ expectedInvocationRef },
);
if (!outcome.ok) {
if (!outcome.claimed) {
reportUnclaimedOAuthReturn(
"neon",
"Neon",
expectedInvocationRef,
outcome.error,
);
}
return;
}
}
return;
}
if (parsed.hostname === "supabase-oauth-return") {
const token = parsed.searchParams.get("token");
const refreshToken = parsed.searchParams.get("refreshToken");
const expiresIn = Number(parsed.searchParams.get("expiresIn"));
if (!token || !refreshToken || !expiresIn) {
dialog.showErrorBox(
"Invalid URL",
"Expected token, refreshToken, and expiresIn",
);
return;
}
const expectedInvocationRef = parseOAuthCallbackInvocationRef(
"supabase",
parsed.searchParams.get("state"),
);
if (!expectedInvocationRef) {
dialog.showErrorBox(
"Invalid URL",
"This Supabase sign-in could not be verified. Please connect again from Dyad.",
);
return;
}
{
// See the neon-oauth-return branch above for why the token write is
// wrapped by the connection flow machine.
const outcome = await runOAuthReturnExchange(
"supabase",
async () => {
await handleSupabaseOAuthReturn({ token, refreshToken, expiresIn });
},
{ expectedInvocationRef },
);
if (!outcome.ok) {
if (!outcome.claimed) {
reportUnclaimedOAuthReturn(
"supabase",
"Supabase",
expectedInvocationRef,
outcome.error,
);
}
return;
}
}
return;
}
// dyad://dyad-pro-return?key=123&budget_reset_at=2025-05-26T16:31:13.492000Z&max_budget=100
if (parsed.hostname === "dyad-pro-return") {
const apiKey = parsed.searchParams.get("key");
if (!apiKey) {
dialog.showErrorBox("Invalid URL", "Expected key");
return;
}
try {
handleDyadProReturn({
apiKey,
});
} catch (error) {
showDeepLinkSettingsError("save Dyad Pro settings", error);
return;
}
// Send message to renderer to trigger re-render
mainWindow?.webContents.send("deep-link-received", {
type: parsed.hostname,
});
return;
}
// Fired by the OAuth callback page to hand focus back to Dyad
// after consent. Tokens land via the loopback listener; focusing
// the window is the only side-effect needed here.
if (parsed.hostname === "chatgpt-connected") {
if (mainWindow?.isMinimized()) mainWindow.restore();
mainWindow?.focus();
mainWindow?.webContents.send("deep-link-received", {
type: "chatgpt-connected",
});
return;
}
if (parsed.hostname === "mcp-oauth-return") {
if (mainWindow) {
if (mainWindow.isMinimized()) mainWindow.restore();
mainWindow.focus();
}
return;
}
// dyad://add-mcp-server?name=Chrome%20DevTools&config=eyJjb21tYW5kIjpudWxsLCJ0eXBlIjoic3RkaW8ifQ%3D%3D
if (parsed.hostname === "add-mcp-server") {
const name = parsed.searchParams.get("name");
const config = parsed.searchParams.get("config");
if (!name || !config) {
dialog.showErrorBox("Invalid URL", "Expected name and config");
return;
}
try {
const decodedConfigJson = atob(config);
const decodedConfig = JSON.parse(decodedConfigJson);
const parsedConfig = AddMcpServerConfigSchema.parse(decodedConfig);
mainWindow?.webContents.send("deep-link-received", {
type: parsed.hostname,
payload: {
name,
config: parsedConfig,
} as AddMcpServerPayload,
});
} catch (error) {
logger.error("Failed to parse add-mcp-server deep link:", error);
dialog.showErrorBox(
"Invalid MCP Server Configuration",
"The deep link contains malformed configuration data. Please check the URL and try again.",
);
}
return;
}
// dyad://add-prompt?data=<base64-encoded-json>
if (parsed.hostname === "add-prompt") {
const data = parsed.searchParams.get("data");
if (!data) {
dialog.showErrorBox("Invalid URL", "Expected data parameter");
return;
}
try {
const decodedJson = atob(data);
const decoded = JSON.parse(decodedJson);
const parsedData = AddPromptDataSchema.parse(decoded);
mainWindow?.webContents.send("deep-link-received", {
type: parsed.hostname,
payload: parsedData as AddPromptPayload,
});
} catch (error) {
logger.error("Failed to parse add-prompt deep link:", error);
dialog.showErrorBox(
"Invalid Prompt Data",
"The deep link contains malformed data. Please check the URL and try again.",
);
}
return;
}
dialog.showErrorBox("Invalid deep link URL", url);
}
// Report unexpected utility process deaths (tsc worker, code explorer).
// These do not take down the app, so we can report them right away.
// Skip clean-exit and killed: routine teardown stops these workers with
// kill(), and reporting that would flood telemetry with non-crashes.
app.on("child-process-gone", (_event, details) => {
if (details.type !== "Utility" || isAppQuitting) {
return;
}
if (details.reason === "clean-exit" || details.reason === "killed") {
return;
}
logger.error(
"Utility process gone:",
details.serviceName,
details.reason,
"exitCode=",
details.exitCode,
);
sendTelemetryEvent("utility_process:crash_detected", {
// Mark as error so renderer PostHog before_send sampling does not
// drop 90% of events for non-Pro users (see src/renderer.tsx).
error: true,
reason: details.reason,
exit_code: details.exitCode,
service_name: details.serviceName,
process_name: details.name,
});
});
// Quit when all windows are closed, except on macOS. There, it's common
// for applications and their menu bar to stay active until the user quits
// explicitly with Cmd + Q.
app.on("window-all-closed", () => {
const shouldQuit = shouldQuitAfterAllWindowsClosed(
process.platform,
isAppQuitting,
);
logLifecycle("app:window-all-closed", { shouldQuit });
if (shouldQuit) {
app.quit();
}
});
// Clear the crash sentinel as early as possible on clean exit so that slow
// cleanup in will-quit cannot race against OS-imposed termination timeouts
// (e.g. Windows WM_ENDSESSION) and leave the sentinel behind as a false positive.
const handleMcpBeforeQuit = createMcpBeforeQuitHandler({
quit: () => {
logLifecycle("app:quit-resume");
appRelaunchRequest.finish({
currentArgs: process.argv.slice(1),
relaunch: (options) => {
logLifecycle("app:relaunch", {
argumentCount: options?.args?.length ?? 0,
});
app.relaunch(options);
},
quit: () => {
logLifecycle("app:quit-reentered");
app.quit();
},
});
},
cleanup: async () => {
await Promise.all([
observeLifecycleCleanup("mcp-clients", () =>
disposeMcpClientsForShutdown(),
),
observeLifecycleCleanup("mcp-oauth", () => disposeMcpOAuthForShutdown()),
observeLifecycleCleanup("connection-flows", () =>
disposeConnectionFlowsForShutdown(),
),
observeLifecycleCleanup("remote-machine-host", () =>
remoteMachineHost.dispose(),
),
]);
},
});
app.on("before-quit", (event) => {
logLifecycle("app:before-quit", {
defaultPreventedBeforeHandler: event.defaultPrevented,
});
isAppQuitting = true;
clearCrashSentinel();
handleMcpBeforeQuit(event);
logLifecycle("app:before-quit-handler-complete", {
defaultPreventedAfterHandler: event.defaultPrevented,
});
});
// IMPORTANT: This handler must be synchronous because Electron's EventEmitter
// does not await async callbacks — the returned Promise would be silently ignored.
app.on("will-quit", () => {
windowsResourceHelper.dispose();
stopClaudeProcesses();
logLifecycle("app:will-quit");
logger.info("App is quitting");
// Stop the garbage collection timer
stopAppGarbageCollection();
// Synchronously send kill signals to all running apps (fire-and-forget).
// We cannot use async/await here because Electron won't wait for it.
stopAllAppTestsSync();
stopAllAppsSync();
// Stop performance monitoring and capture final metrics
stopPerformanceMonitoring();
// Stop the chat-search index maintenance timers
stopChatSearchIndexer();
});
app.on("quit", (_event, exitCode) => {
logLifecycle("app:quit", { exitCode });
// Unscoped and at info, matching this session's start banner. The
// previous-session banner is deliberately scoped and at warn; see onReady.
log.info(
formatExitBanner(
process.pid,
`exitCode=${exitCode}`,
Date.now() - LAUNCH_TIME,
),
);
});
app.on("activate", () => {
const activationContext = {
isAppQuitting,
hasCreatedInitialWindow,
openWindowCount: BrowserWindow.getAllWindows().length,
};
const shouldRequestRelaunch =
shouldRequestRelaunchOnActivate(activationContext);
const shouldCreateWindow = shouldCreateWindowOnActivate(activationContext);
logLifecycle("app:activate", {
shouldRequestRelaunch,
shouldCreateWindow,
});
if (shouldRequestRelaunch) {
requestRelaunchAfterQuit();
}
if (isAppQuitting) return;
// On OS X it's common to re-create a window in the app when the
// dock icon is clicked and there are no other windows open.
if (shouldCreateWindow) {
const descriptor = lastClosedWindowSession ?? {
windowSessionId: PRIMARY_WINDOW_SESSION_ID,
};
lastClosedWindowSession = undefined;
createWindow(descriptor);
}
});
// In this file you can include the rest of your app's specific main process
// code. You can also put them in separate files and import them here.