Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
include fmt.mk

.PHONY: all build dev start clean help
.PHONY: build-renderer version build-icons
.PHONY: build-renderer version build-icons build-static
.PHONY: lint lint-fix typecheck static-check
.PHONY: test test-unit test-integration test-watch test-coverage test-e2e
.PHONY: dist dist-mac dist-win dist-linux
Expand Down Expand Up @@ -58,11 +58,11 @@ dev: node_modules/.installed build-main ## Start development server (Vite + Type
"bun x concurrently \"bun x tsc -w -p tsconfig.main.json\" \"bun x tsc-alias -w -p tsconfig.main.json\"" \
"vite"

start: node_modules/.installed build-main build-preload ## Build and start Electron app
start: node_modules/.installed build-main build-preload build-static ## Build and start Electron app
@bun x electron --remote-debugging-port=9222 .

## Build targets (can run in parallel)
build: node_modules/.installed src/version.ts build-renderer build-main build-preload build-icons ## Build all targets
build: node_modules/.installed src/version.ts build-renderer build-main build-preload build-icons build-static ## Build all targets

build-main: node_modules/.installed dist/main.js ## Build main process

Expand All @@ -86,6 +86,11 @@ build-renderer: node_modules/.installed src/version.ts ## Build renderer process
@echo "Building renderer..."
@bun x vite build

build-static: ## Copy static assets to dist
@echo "Copying static assets..."
@mkdir -p dist
@cp static/splash.html dist/splash.html

# Always regenerate version file (marked as .PHONY above)
version: ## Generate version file
@./scripts/generate-version.sh
Expand Down
157 changes: 132 additions & 25 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,19 @@ if (!gotTheLock) {
}

let mainWindow: BrowserWindow | null = null;
let splashWindow: BrowserWindow | null = null;

/**
* Format timestamp as HH:MM:SS.mmm for readable logging
*/
function timestamp(): string {
const now = new Date();
const hours = String(now.getHours()).padStart(2, "0");
const minutes = String(now.getMinutes()).padStart(2, "0");
const seconds = String(now.getSeconds()).padStart(2, "0");
const ms = String(now.getMilliseconds()).padStart(3, "0");
return `${hours}:${minutes}:${seconds}.${ms}`;
}

function createMenu() {
const template: MenuItemConstructorOptions[] = [
Expand Down Expand Up @@ -182,28 +195,101 @@ function createMenu() {
Menu.setApplicationMenu(menu);
}

async function createWindow() {
// Lazy-load Config and IpcMain only when window is created
// This defers loading heavy AI SDK dependencies until actually needed
if (!config || !ipcMain || !loadTokenizerModulesFn) {
/* eslint-disable no-restricted-syntax */
// Dynamic imports are justified here for performance:
// - IpcMain transitively imports the entire AI SDK (ai, @ai-sdk/anthropic, etc.)
// - These are large modules that would block app startup if loaded statically
// - Loading happens once on first window creation, then cached
const [
{ Config: ConfigClass },
{ IpcMain: IpcMainClass },
{ loadTokenizerModules: loadTokenizerFn },
] = await Promise.all([
import("./config"),
import("./services/ipcMain"),
import("./utils/main/tokenizer"),
]);
/* eslint-enable no-restricted-syntax */
config = new ConfigClass();
ipcMain = new IpcMainClass(config);
loadTokenizerModulesFn = loadTokenizerFn;
/**
* Create and show splash screen - instant visual feedback (<100ms)
*
* Shows a lightweight native window with static HTML while services load.
* No IPC, no React, no heavy dependencies - just immediate user feedback.
*/
async function showSplashScreen() {
const startTime = Date.now();
console.log(`[${timestamp()}] Showing splash screen...`);

splashWindow = new BrowserWindow({
width: 400,
height: 300,
frame: false,
transparent: false,
alwaysOnTop: true,
center: true,
resizable: false,
show: false, // Don't show until HTML is loaded
webPreferences: {
nodeIntegration: false,
contextIsolation: true,
},
});

// Wait for splash HTML to load
await splashWindow.loadFile(path.join(__dirname, "splash.html"));

// Wait for the window to actually be shown and rendered before continuing
// This ensures the splash is visible before we block the event loop with heavy work
await new Promise<void>((resolve) => {
splashWindow!.once("show", () => {
const loadTime = Date.now() - startTime;
console.log(`[${timestamp()}] Splash screen shown (${loadTime}ms)`);
// Give one more event loop tick for the window to actually paint
setImmediate(resolve);
});
splashWindow!.show();
});

splashWindow.on("closed", () => {
console.log(`[${timestamp()}] Splash screen closed event`);
splashWindow = null;
});
}

/**
* Close splash screen
*/
function closeSplashScreen() {
if (splashWindow) {
console.log(`[${timestamp()}] Closing splash screen...`);
splashWindow.close();
splashWindow = null;
}
}

/**
* Load backend services (Config, IpcMain, AI SDK, tokenizer)
*
* Heavy initialization (~6-13s) happens here while splash is visible.
* This is the slow part that delays app startup.
*/
async function loadServices(): Promise<void> {
if (config && ipcMain && loadTokenizerModulesFn) return; // Already loaded

const startTime = Date.now();
console.log(`[${timestamp()}] Loading services...`);

/* eslint-disable no-restricted-syntax */
// Dynamic imports are justified here for performance:
// - IpcMain transitively imports the entire AI SDK (ai, @ai-sdk/anthropic, etc.)
// - These are large modules (~6-13s load time) that would block splash from appearing
// - Loading happens once, then cached
const [
{ Config: ConfigClass },
{ IpcMain: IpcMainClass },
{ loadTokenizerModules: loadTokenizerFn },
] = await Promise.all([
import("./config"),
import("./services/ipcMain"),
import("./utils/main/tokenizer"),
]);
/* eslint-enable no-restricted-syntax */
config = new ConfigClass();
ipcMain = new IpcMainClass(config);
loadTokenizerModulesFn = loadTokenizerFn;

const loadTime = Date.now() - startTime;
console.log(`[${timestamp()}] Services loaded in ${loadTime}ms`);
}

function createWindow() {
if (!ipcMain) {
throw new Error("Services must be loaded before creating window");
}

mainWindow = new BrowserWindow({
Expand All @@ -218,11 +304,19 @@ async function createWindow() {
// Hide menu bar on Linux by default (like VS Code)
// User can press Alt to toggle it
autoHideMenuBar: process.platform === "linux",
show: false, // Don't show until ready-to-show event
});

// Register IPC handlers with the main window
ipcMain.register(electronIpcMain, mainWindow);

// Show window once it's ready and close splash
mainWindow.once("ready-to-show", () => {
console.log(`[${timestamp()}] Main window ready to show`);
mainWindow?.show();
closeSplashScreen();
});

// Open all external links in default browser
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
void shell.openExternal(url);
Expand Down Expand Up @@ -278,14 +372,23 @@ if (gotTheLock) {
}

createMenu();
await createWindow();

// Three-phase startup:
// 1. Show splash immediately (<100ms) and wait for it to load
// 2. Load services while splash visible (fast - ~100ms)
// 3. Create window and start loading content (splash stays visible)
// 4. When window ready-to-show: close splash, show main window
await showSplashScreen(); // Wait for splash to actually load
await loadServices();
createWindow();
// Note: splash closes in ready-to-show event handler

// Start loading tokenizer modules in background after window is created
// This ensures accurate token counts for first API calls (especially in e2e tests)
// Loading happens asynchronously and won't block the UI
if (loadTokenizerModulesFn) {
void loadTokenizerModulesFn().then(() => {
console.log("Tokenizer modules loaded");
console.log(`[${timestamp()}] Tokenizer modules loaded`);
});
}
// No need to auto-start workspaces anymore - they start on demand
Expand All @@ -301,7 +404,11 @@ if (gotTheLock) {
// Only create window if app is ready and no window exists
// This prevents "Cannot create BrowserWindow before app is ready" error
if (app.isReady() && mainWindow === null) {
void createWindow();
void (async () => {
await showSplashScreen();
await loadServices();
createWindow();
})();
}
});
}
81 changes: 81 additions & 0 deletions static/splash.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>cmux - Loading</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}

body {
width: 400px;
height: 300px;
/* Match --color-background (hsl(0 0% 12%)) */
background: hsl(0 0% 12%);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
/* Match --color-text (hsl(0 0% 83%)) */
color: hsl(0 0% 83%);
overflow: hidden;
border-radius: 12px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
}

.logo {
font-size: 48px;
font-weight: 700;
letter-spacing: -2px;
margin-bottom: 24px;
/* Gradient from plan mode blue (hsl(210 70% 40%)) to exec mode purple (hsl(268.56deg 94.04% 55.19%)) */
background: linear-gradient(135deg, hsl(210 70% 45%) 0%, hsl(268.56deg 94.04% 55.19%) 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}

.loading-text {
font-size: 16px;
/* Match --color-text-secondary (hsl(0 0% 42%)) */
color: hsl(0 0% 42%);
margin-bottom: 16px;
}

.spinner {
width: 40px;
height: 40px;
/* Match --color-border (hsl(240 2% 25%)) */
border: 3px solid hsl(240 2% 25%);
/* Match --color-plan-mode (hsl(210 70% 40%)) */
border-top-color: hsl(210 70% 40%);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}

@keyframes spin {
to { transform: rotate(360deg); }
}

.version {
position: absolute;
bottom: 20px;
font-size: 11px;
/* Match --color-text-secondary (hsl(0 0% 42%)) */
color: hsl(0 0% 42%);
}
</style>
</head>
<body>
<div class="logo">cmux</div>
<div class="loading-text">Loading services...</div>
<div class="spinner"></div>
<div class="version">coder multiplexer</div>
</body>
</html>

Loading