perf: optimize app startup and background lifecycles

This commit is contained in:
inman committed 2026-08-19 00:56:15 +08:00
1 parent 31332f6d76
commit 927e13349b
121 files changed
+4359 -1400

No files matched your search

+168 -84
View File
@@ -2,7 +2,7 @@
* Electron Main Process Entry
* Manages window creation, system tray, and IPC handlers
*/
import { app, BrowserWindow, nativeImage, shell } from 'electron';
import { app, BrowserWindow, nativeImage, safeStorage, shell } from 'electron';
import type { Server } from 'node:http';
import { createServer } from 'node:http';
import { join } from 'node:path';
@@ -22,8 +22,6 @@ import {
createProjectStore,
type OpencodeProjectStore,
} from '../opencode/project-store';
import { readProjectConfig } from '../opencode/project-config';
import { warmupOpencodeRuntime } from '../opencode/startup-warmup';
import { registerIpcHandlers } from './ipc-handlers';
import { createTray } from './tray';
import { createMenu } from './menu';
@@ -32,7 +30,6 @@ import { getNativeWindowMaterialOptions } from './window-material';
import { appUpdater, registerUpdateHandlers } from './updater';
import { logger } from '../utils/logger';
import { warmupNetworkOptimization } from '../utils/uv-env';
import { resolvePythonRuntime, resolveUvRuntime } from '../utils/python-runtime';
import { initTelemetry } from '../utils/telemetry';
@@ -62,13 +59,22 @@ import {
} from './relaunch-on-replaced-app';
import { createSignalQuitHandler } from './signal-quit';
import { acquireProcessInstanceFileLock } from './process-instance-lock';
import { BackgroundLifecycleController } from './background-lifecycle';
import {
readGpuFallbackState,
recordGpuCrash,
shouldUseSoftwareRendering,
} from './gpu-fallback';
import { getHostApiToken, startHostApiServer } from '../api/server';
import type { HostApiContext } from '../api/context';
import { HostEventBus } from '../api/event-bus';
import { AgentBrowserModule, ElectronAgentBrowserAdapter } from '../agent-browser';
import { browserOAuthManager } from '../utils/browser-oauth';
import { createProjectProgressSync } from '../services/project-progress-sync';
import { createWorksSubmissionBindingStore } from '../services/works-submission-binding';
import { ReleaseJobManager } from '../services/release-job';
import { createReleaseUtilityPreparer } from '../services/release-utility-process';
import { createStaticArtifactSnapshot } from '../services/static-release-server';
import {
consumeWorksSquareStartupRuntimeCleanupRequired,
@@ -78,6 +84,7 @@ import {
subscribeWorksSquareSession,
type WorksSquareSessionChangeReason,
} from '../services/works-square-session';
import { shouldUseSecureWorksSquareSessionPersistence } from '../services/works-square-session-persistence-policy';
import { clearManagedWorksSquareRuntimeBestEffort } from '../services/works-square-runtime';
import { getPort } from '../utils/config';
import { initializeMeowaGameAssetsCredential } from '../api/routes/meowa-game-assets';
@@ -91,6 +98,9 @@ import type { DesignWorkspaceModule } from '../image-workspace/module';
const WINDOWS_APP_USER_MODEL_ID = 'app.niancode.desktop';
const isE2EMode = process.env.NIANCODE_E2E === '1';
const requestedUserDataDir = process.env.NIANCODE_USER_DATA_DIR?.trim();
const useSecureWorksSquareSessionPersistence = shouldUseSecureWorksSquareSessionPersistence(
app.isPackaged,
);
async function buildMakeloreOpencodeRuntimeConfig() {
return await buildOpencodeRuntimeConfigFromNianCodeProviders({
@@ -100,50 +110,34 @@ async function buildMakeloreOpencodeRuntimeConfig() {
});
}
function scheduleOpencodeRuntimeWarmup(): void {
if (isE2EMode) return;
void warmupOpencodeRuntime({
hasAuthenticatedSession: () => Boolean(getWorksSquareSessionSnapshot()),
getStatus: () => opencodeManager.getStatus(),
getActiveProject: () => opencodeProjectStore.getActiveProject(),
readProjectConfig,
getConfiguredProviderCount: async () => {
const runtime = await buildMakeloreOpencodeRuntimeConfig();
return Object.keys(runtime.config.provider).length;
},
start: () => opencodeManager.start(),
onError: (error, phase) => {
logger.warn(`[opencode-runtime] Startup warmup ${phase} failed`, error);
},
}).then((result) => {
if (result.started) {
logger.info('[opencode-runtime] Startup warmup completed');
}
}).catch((error) => {
logger.warn('[opencode-runtime] Startup warmup could not be scheduled', error);
});
}
if (isE2EMode && requestedUserDataDir) {
app.setPath('userData', requestedUserDataDir);
}
// Disable GPU hardware acceleration globally for maximum stability across
// all GPU configurations (no GPU, integrated, discrete).
//
// Rationale (following VS Code's philosophy):
// - Page/file loading is async data fetching — zero GPU dependency.
// - The original per-platform GPU branching was added to avoid CPU rendering
// competing with sync I/O on Windows, but all file I/O is now async
// (fs/promises), so that concern no longer applies.
// - Software rendering is deterministic across all hardware; GPU compositing
// behaviour varies between vendors (Intel, AMD, NVIDIA, Apple Silicon) and
// driver versions, making it the #1 source of rendering bugs in Electron.
//
// Users who want GPU acceleration can pass `--enable-gpu` on the CLI or
// set `"disable-hardware-acceleration": false` in the app config (future).
app.disableHardwareAcceleration();
// Hardware compositing is the fast path for Makelore's opaque light surface.
// Keep an explicit escape hatch for driver-specific failures rather than
// forcing every installation through Chromium's software compositor.
const gpuFallbackPath = join(app.getPath('userData'), 'gpu-fallback.json');
const disableHardwareAcceleration = shouldUseSoftwareRendering({
envDisabled: process.env.MAKELORE_DISABLE_GPU === '1',
cliDisabled: process.argv.includes('--disable-gpu'),
state: readGpuFallbackState(gpuFallbackPath),
});
if (disableHardwareAcceleration) {
app.disableHardwareAcceleration();
}
app.on('child-process-gone', (_event, details) => {
if (details.type !== 'GPU' || !['crashed', 'abnormal-exit', 'oom'].includes(details.reason)) {
return;
}
try {
const state = recordGpuCrash(gpuFallbackPath, `${details.reason}:${details.exitCode}`);
logger.warn('[gpu] GPU process failure recorded; software rendering will be used after repeated failures', state);
} catch (error) {
logger.warn('[gpu] Failed to persist GPU process failure state', error);
}
});
// On Linux, set CHROME_DESKTOP so Chromium can find the correct .desktop file.
// On Wayland this maps the running window to niancode.desktop (→ icon + app grouping);
@@ -201,6 +195,8 @@ let worksSubmissionBinding: ReturnType<typeof createWorksSubmissionBindingStore>
let agentBrowser: AgentBrowserModule | null = null;
let applicationInitialization: Promise<void> | null = null;
let imageWorkspaceModule: DesignWorkspaceModule | null = null;
let backgroundLifecycle!: BackgroundLifecycleController;
let releaseJobs: ReleaseJobManager | null = null;
const mainWindowFocusState = createMainWindowFocusState();
const quitLifecycleState = createQuitLifecycleState();
const launchDeepLinkUrl = findNianCodeDeepLinkUrl(process.argv);
@@ -430,6 +426,25 @@ function createMainWindow(): BrowserWindow {
}
});
win.on('hide', () => {
backgroundLifecycle?.setActivity({
...backgroundLifecycle.getActivity(),
visible: false,
});
// The browser is a disposable renderer and must not survive a hidden
// application window. Active cloud tasks are reconciled when reopened.
void agentBrowser?.close().catch((error) => {
logger.warn('Failed to close Agent Browser after the main window was hidden:', error);
});
});
win.on('show', () => {
backgroundLifecycle?.setActivity({
...backgroundLifecycle.getActivity(),
visible: true,
});
});
win.on('closed', () => {
unsubscribeAuthSession();
const browser = agentBrowser;
@@ -465,32 +480,14 @@ async function initialize(): Promise<void> {
handleAppDeepLinkActivation(launchDeepLinkUrl);
}
if (!isE2EMode) {
// Warm up network optimization (non-blocking)
void warmupNetworkOptimization();
// Initialize Telemetry early
await initTelemetry();
// Apply persisted proxy settings before creating windows or network requests.
await applyProxySettings();
const meowaCredential = await initializeMeowaGameAssetsCredential();
if (meowaCredential.source !== 'none') {
logger.info(`Meowa game-assets credential initialized via ${meowaCredential.source}`);
}
await syncLaunchAtStartupSettingFromStore();
} else {
if (isE2EMode) {
logger.info('Running in E2E mode: startup side effects minimized');
}
// Restore authentication before any background service or renderer can request a token.
await initializeWorksSquareSession();
// Only local storage and the lightweight workspace object are needed to
// construct the first window. Authentication and remote setup are restored
// after the renderer has had a chance to paint.
opencodeProjectStore = createProjectStore(await createElectronProjectStorage());
if (!isE2EMode) {
projectProgressSync = createProjectProgressSync(opencodeProjectStore);
}
worksSubmissionBinding = createWorksSubmissionBindingStore(opencodeProjectStore);
const localImageWorkspaceEnabled = isLocalImageWorkspaceDevelopmentEnabled({
@@ -502,12 +499,6 @@ async function initialize(): Promise<void> {
? new LocalImageWorkspace({ userDataDir: app.getPath('userData') })
: new WorksSquareDesignWorkspace();
imageWorkspaceModule = imageWorkspace;
if (consumeWorksSquareStartupRuntimeCleanupRequired()) {
await clearManagedWorksSquareRuntimeBestEffort({
opencodeManager,
imageWorkspace,
}, 'expired persisted session during startup');
}
if (localImageWorkspaceEnabled) {
logger.info('AI painting workspace is using local development storage');
} else {
@@ -520,10 +511,6 @@ async function initialize(): Promise<void> {
// Create the main window
const window = createMainWindow();
agentBrowser = new AgentBrowserModule(new ElectronAgentBrowserAdapter(window));
if (!isE2EMode) {
void projectProgressSync?.start();
void worksSubmissionBinding.start();
}
// Create system tray
if (!isE2EMode) {
@@ -531,9 +518,33 @@ async function initialize(): Promise<void> {
}
// Register IPC handlers
registerIpcHandlers(undefined, opencodeManager, undefined, window);
hostApiServer = startHostApiServer({
backgroundLifecycle = new BackgroundLifecycleController({
onSleep: () => {
if (!window.isDestroyed() && !window.webContents.isDestroyed()) {
window.webContents.send('lifecycle:sleep');
}
},
onStopRuntime: () => opencodeManager.stop(),
});
const releaseUtilityPreparer = createReleaseUtilityPreparer({
runtimeContext: {
isPackaged: app.isPackaged,
resourcesPath: process.resourcesPath,
npmCacheDir: join(app.getPath('userData'), 'npm-cache'),
},
});
releaseJobs = new ReleaseJobManager({
prepare: releaseUtilityPreparer.prepare,
dispose: releaseUtilityPreparer.dispose,
acquireLease: (lease) => backgroundLifecycle.acquireLease(lease),
});
releaseJobs.on('status', (status) => {
hostEventBus.emit('release-job:status', status);
if (!window.isDestroyed() && !window.webContents.isDestroyed()) {
window.webContents.send('release-job:status', status);
}
});
const hostApiContext: HostApiContext = {
opencodeManager,
opencodeProjectStore,
eventBus: hostEventBus,
@@ -541,7 +552,12 @@ async function initialize(): Promise<void> {
agentBrowser,
worksSubmissionBinding: worksSubmissionBinding ?? undefined,
imageWorkspace,
});
lifecycle: backgroundLifecycle,
releaseJobs,
};
registerIpcHandlers(undefined, opencodeManager, undefined, window, backgroundLifecycle, hostApiContext);
hostApiServer = startHostApiServer(hostApiContext);
// Register update handlers
registerUpdateHandlers(appUpdater, window);
@@ -549,6 +565,70 @@ async function initialize(): Promise<void> {
// Note: Auto-check for updates is driven by the renderer (update store init)
// so it respects the user's "Auto-check for updates" setting.
const initializeBackgroundServices = async (): Promise<void> => {
const runDeferred = async (label: string, task: () => Promise<void>): Promise<void> => {
try {
await task();
} catch (error) {
logger.warn(`Deferred ${label} initialization failed:`, error);
}
};
if (!isE2EMode) {
// Apply the proxy before any external request made by background
// services, but never before the first local paint.
await runDeferred('proxy', applyProxySettings);
await runDeferred('telemetry', initTelemetry);
await runDeferred('game-assets credential', async () => {
const meowaCredential = await initializeMeowaGameAssetsCredential();
if (meowaCredential.source !== 'none') {
logger.info(`Meowa game-assets credential initialized via ${meowaCredential.source}`);
}
});
await runDeferred('launch-at-startup setting', syncLaunchAtStartupSettingFromStore);
}
if (!useSecureWorksSquareSessionPersistence) {
logger.info('[works-square-session] Unpackaged development keeps credentials in memory; OS secure storage is disabled');
}
await runDeferred('Works Square session', async () => {
await initializeWorksSquareSession({
secureStorage: useSecureWorksSquareSessionPersistence ? safeStorage : null,
});
});
if (consumeWorksSquareStartupRuntimeCleanupRequired()) {
await runDeferred('expired runtime cleanup', async () => {
await clearManagedWorksSquareRuntimeBestEffort({
opencodeManager,
imageWorkspace,
}, 'expired persisted session during startup');
});
}
if (!isE2EMode) {
projectProgressSync = createProjectProgressSync(opencodeProjectStore);
const activateProgrammingServices = (): void => {
void projectProgressSync?.start()
.then(() => projectProgressSync?.activate())
.catch((error) => logger.warn('Failed to activate project progress sync:', error));
void worksSubmissionBinding?.start()
.catch((error) => logger.warn('Failed to migrate active submission binding:', error));
};
backgroundLifecycle.on('activity', (activity) => {
if (activity.visible && activity.module === 'programming') {
activateProgrammingServices();
} else {
projectProgressSync?.stop();
}
});
const activity = backgroundLifecycle.getActivity();
if (activity.visible && activity.module === 'programming') {
activateProgrammingServices();
}
// Project watchers and binding migration now start from module
// activation; the idle shell does not scan every project.
}
};
opencodeManager.on('status', (status) => {
logger.info(
`opencode runtime status: state=${status.state}, port=${status.port}, pid=${status.pid ?? 'n/a'}, url=${status.url ?? 'n/a'}${status.error ? `, error=${status.error}` : ''}`,
@@ -580,10 +660,12 @@ async function initialize(): Promise<void> {
hostEventBus.emit('oauth:error', error);
});
// Start the local Code runtime in the background once the Main process has
// restored session state and registered the Host API. Chat still retains
// its lazy-start fallback for first-run and failed-warmup cases.
scheduleOpencodeRuntimeWarmup();
// OpenCode is started by the Code module on demand. Keeping the runtime
// cold for Canvas/Learning/Robot sessions avoids a resident child process
// and its project event stream on every launch.
void initializeBackgroundServices().catch((error) => {
logger.warn('Deferred background initialization failed:', error);
});
}
if (gotTheLock) {
@@ -736,6 +818,8 @@ if (gotTheLock) {
hostEventBus.closeAll();
hostApiServer?.close();
projectProgressSync?.stop();
backgroundLifecycle?.dispose();
releaseJobs?.dispose();
const stopOpencodePromise = opencodeManager.stop().catch((err) => {
logger.warn('opencodeManager.stop() error during quit:', err);