Files
moreminimore-vibe/src/main.ts
Mohamed Aziz Mejri 9d33f3757d logging and presenting cpu/memory usage when app is force-closed (#1894)
closes #1803 











<!-- This is an auto-generated description by cubic. -->
---
## Summary by cubic
Detects when the app was force-closed and shows a dialog with the last
known CPU and memory usage. Adds background performance monitoring so we
can surface metrics on next launch.

- **New Features**
- Start a performance monitor at app launch; captures process and system
memory/CPU every 30s and on quit.
- Persist metrics in settings.lastKnownPerformance and track
settings.isRunning to detect improper shutdowns.
- On startup, if the previous run was force-closed, send a
"force-close-detected" IPC event after the window loads.
  - Add ForceCloseDialog to display timestamped process/system metrics.
- Whitelist the new IPC channel in preload and listen for it on the home
page.

<sup>Written for commit 0543cdc234da7f94024e8506749aaa9ca36ef916.
Summary will update automatically on new commits.</sup>

<!-- End of auto-generated description by cubic. -->
2025-12-14 21:07:56 +01:00

470 lines
14 KiB
TypeScript

import { app, BrowserWindow, dialog, Menu } from "electron";
import * as path from "node:path";
import { registerIpcHandlers } from "./ipc/ipc_host";
import dotenv from "dotenv";
// @ts-ignore
import started from "electron-squirrel-startup";
import { updateElectronApp, UpdateSourceType } from "update-electron-app";
import log from "electron-log";
import {
getSettingsFilePath,
readSettings,
writeSettings,
} from "./main/settings";
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 { getDatabasePath, initializeDatabase } from "./db";
import { UserSettings } from "./lib/schemas";
import { handleNeonOAuthReturn } from "./neon_admin/neon_return_handler";
import {
AddMcpServerConfigSchema,
AddMcpServerPayload,
AddPromptDataSchema,
AddPromptPayload,
} from "./ipc/deep_link_data";
import {
startPerformanceMonitoring,
stopPerformanceMonitoring,
} from "./utils/performance_monitor";
import fs from "fs";
log.errorHandler.startCatching();
log.eventLogger.startLogging();
log.scope.labelPadding = false;
const logger = log.scope("main");
// Load environment variables from .env file
dotenv.config();
// Register IPC handlers before app is ready
registerIpcHandlers();
// Handle creating/removing shortcuts on Windows when installing/uninstalling.
if (started) {
app.quit();
}
// 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() {
try {
const backupManager = new BackupManager({
settingsFile: getSettingsFilePath(),
dbFile: getDatabasePath(),
});
await backupManager.initialize();
} catch (e) {
logger.error("Error initializing backup manager", e);
}
initializeDatabase();
const settings = readSettings();
// Check if app was force-closed
if (settings.isRunning) {
logger.warn("App was force-closed on previous run");
// Store performance data to send after window is created
if (settings.lastKnownPerformance) {
logger.warn("Last known performance:", settings.lastKnownPerformance);
pendingForceCloseData = settings.lastKnownPerformance;
}
}
// Set isRunning to true at startup
writeSettings({ isRunning: true });
// Start performance monitoring
startPerformanceMonitoring();
await onFirstRunMaybe(settings);
createWindow();
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);
updateElectronApp({
logger,
updateSource: {
type: UpdateSourceType.ElectronPublicUpdateService,
repo: "dyad-sh/dyad",
host,
},
}); // additional configuration options available
}
}
export async function onFirstRunMaybe(settings: UserSettings) {
if (!settings.hasRunBefore) {
await promptMoveToApplicationsFolder();
writeSettings({
hasRunBefore: true,
});
}
if (IS_TEST_BUILD) {
writeSettings({
isTestMode: true,
});
}
}
/**
* 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;
let pendingForceCloseData: any = null;
const createWindow = () => {
// Create the browser window.
mainWindow = new BrowserWindow({
width: process.env.NODE_ENV === "development" ? 1280 : 960,
minWidth: 800,
height: 700,
minHeight: 500,
titleBarStyle: "hidden",
titleBarOverlay: false,
trafficLightPosition: {
x: 10,
y: 8,
},
webPreferences: {
nodeIntegration: false,
contextIsolation: true,
preload: path.join(__dirname, "preload.js"),
// transparent: true,
},
// backgroundColor: "#00000001",
// frame: false,
});
// and load the index.html of the app.
if (MAIN_WINDOW_VITE_DEV_SERVER_URL) {
mainWindow.loadURL(MAIN_WINDOW_VITE_DEV_SERVER_URL);
} else {
mainWindow.loadFile(
path.join(__dirname, "../renderer/main_window/index.html"),
);
}
if (process.env.NODE_ENV === "development") {
// Open the DevTools.
mainWindow.webContents.openDevTools();
}
// Send force-close event if it was detected
if (pendingForceCloseData) {
mainWindow.webContents.once("did-finish-load", () => {
mainWindow?.webContents.send("force-close-detected", {
performanceData: pendingForceCloseData,
});
pendingForceCloseData = null;
});
}
// Enable native context menu on right-click
mainWindow.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 {
mainWindow?.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: () =>
mainWindow?.webContents.inspectElement(params.x, params.y),
},
);
}
const menu = Menu.buildFromTemplate(template);
menu.popup({ window: mainWindow! });
});
};
const gotTheLock = app.requestSingleInstanceLock();
if (!gotTheLock) {
app.quit();
} else {
app.on("second-instance", (_event, commandLine, _workingDirectory) => {
// Someone tried to run a second instance, we should focus our window.
if (mainWindow) {
if (mainWindow.isMinimized()) mainWindow.restore();
mainWindow.focus();
}
// the commandLine is array of strings in which last element is deep link url
handleDeepLinkReturn(commandLine.pop()!);
});
app.whenReady().then(onReady);
}
// Handle the protocol. In this case, we choose to show an Error Box.
app.on("open-url", (event, url) => {
handleDeepLinkReturn(url);
});
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;
}
handleNeonOAuthReturn({ token, refreshToken, expiresIn });
// Send message to renderer to trigger re-render
mainWindow?.webContents.send("deep-link-received", {
type: parsed.hostname,
});
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;
}
handleSupabaseOAuthReturn({ token, refreshToken, expiresIn });
// Send message to renderer to trigger re-render
mainWindow?.webContents.send("deep-link-received", {
type: parsed.hostname,
});
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;
}
handleDyadProReturn({
apiKey,
});
// Send message to renderer to trigger re-render
mainWindow?.webContents.send("deep-link-received", {
type: parsed.hostname,
});
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);
}
// 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", () => {
if (process.platform !== "darwin") {
app.quit();
}
});
// Only set isRunning to false when the app is properly quit by the user
app.on("will-quit", () => {
logger.info("App is quitting, setting isRunning to false");
// Stop performance monitoring and capture final metrics
stopPerformanceMonitoring();
writeSettings({ isRunning: false });
});
app.on("activate", () => {
// 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 (BrowserWindow.getAllWindows().length === 0) {
createWindow();
}
});
// 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.