[eric] make Windows install + boot way faster: splash window so it's not frozen on startup, swap MCP node_modules for tiny esbuild bundles

(138MB → 7MB), pre-compile python bytecode, parallelize Widevine, never silently quit
This commit is contained in:
ciregenz
2026-04-25 03:05:08 -07:00
parent 801268d6b7
commit 94ea07dd0c
15 changed files with 142480 additions and 92 deletions
+28 -4
View File
@@ -517,13 +517,37 @@ def derive_mcp_config(tool: ToolDefinition) -> Optional[dict]:
if pkg_name:
_backend = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
electron_path = os.environ.get("OPENSWARM_ELECTRON_PATH")
# Check for single-file bundle first (e.g. reddit-mcp-buddy)
bundle_path = os.path.join(_backend, "mcp-bundles", f"{pkg_name}.js")
if os.path.isfile(bundle_path) and electron_path:
# Two bundle layouts in mcp-bundles/, checked in priority order:
#
# 1. Multi-file bundle dir: mcp-bundles/<safe>/dist/index.js
# Used when the SDK reads sibling files at runtime.
# Examples: @softeria/ms-365-mcp-server reads
# ../package.json for --version and dist/endpoints.json
# for Graph API definitions; @notionhq/notion-mcp-server
# reads ../scripts/notion-openapi.json. The build script
# ships a stripped package.json (no "type":"module") next
# to dist/ so __dirname/../package.json resolves correctly.
# See scripts/build-app.sh `build_mcp_bundle_dir`.
#
# 2. Single-file bundle: mcp-bundles/<safe>.js
# Used when the SDK is fully self-contained
# (reddit-mcp-buddy).
#
# Scoped names get flattened ("@softeria/ms-365-mcp-server"
# -> "softeria-ms-365-mcp-server") for filesystem safety.
safe_bundle = pkg_name.replace("/", "-").replace("@", "")
bundle_dir_path = os.path.join(_backend, "mcp-bundles", safe_bundle, "dist", "index.js")
bundle_file_path = os.path.join(_backend, "mcp-bundles", f"{safe_bundle}.js")
bundle_path = None
if os.path.isfile(bundle_dir_path):
bundle_path = bundle_dir_path
elif os.path.isfile(bundle_file_path):
bundle_path = bundle_file_path
if bundle_path and electron_path:
config["command"] = electron_path
config["args"] = [bundle_path]
config.setdefault("env", {})["ELECTRON_RUN_AS_NODE"] = "1"
logger.info(f"Using bundled MCP server for {pkg_name}")
logger.info(f"Using bundled MCP server for {pkg_name} ({bundle_path})")
else:
# Check for pre-installed npm package (works in both dev and packaged modes)
safe_dir = pkg_name.replace("/", "-").replace("@", "")
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
{"name":"@notionhq/notion-mcp-server","version":"2.2.1"}
File diff suppressed because it is too large Load Diff
+1
View File
@@ -1,3 +1,4 @@
const __OPENSWARM_IMPORT_META_URL__ = require("url").pathToFileURL(__filename).href;
var __create = Object.create;
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1 @@
{"name":"@softeria/ms-365-mcp-server","version":"0.85.2"}
+257 -19
View File
@@ -77,11 +77,94 @@ let backendProcess = null;
let backendPort = null;
let cachedUpdateStatus = { status: 'idle', info: null, error: null };
// Splash boot UX. Opens immediately on app.whenReady so the user sees
// motion within ~1s of double-click instead of a 30-60s frozen icon
// while Python imports + Defender real-time scans warm up. Closed once
// mainWindow is `ready-to-show`. See electron/splash/splash.html.
let splashWindow = null;
let mainWindowReady = false;
let isQuittingFromSplash = false; // guards against double-quit during error shutdown
const recentBackendStderr = []; // ring buffer (last ~60 lines) for splash error UI
let splashDataUrlCache = null;
const isPackaged = app.isPackaged;
const isDev = process.env.ELECTRON_DEV === '1';
const iconPath = process.platform === 'win32'
? path.join(__dirname, 'build', 'icon.ico')
: path.join(__dirname, 'build', 'icon.png');
// PNG version of the icon for the splash (icon.ico isn't a valid <img src>
// payload across platforms, but icon.png works everywhere).
const iconPngPath = path.join(__dirname, 'build', 'icon.png');
function loadSplashDataUrl() {
if (splashDataUrlCache) return splashDataUrlCache;
try {
const html = fs.readFileSync(path.join(__dirname, 'splash', 'splash.html'), 'utf8');
const iconBytes = fs.readFileSync(iconPngPath);
const iconDataUrl = 'data:image/png;base64,' + iconBytes.toString('base64');
const finalHtml = html.replace('__OPENSWARM_LOGO__', iconDataUrl);
splashDataUrlCache = 'data:text/html;charset=utf-8;base64,' + Buffer.from(finalHtml).toString('base64');
return splashDataUrlCache;
} catch (err) {
console.warn('[splash] failed to load splash payload:', err && err.message);
return null;
}
}
function createSplashWindow() {
const dataUrl = loadSplashDataUrl();
if (!dataUrl) return null;
const w = new BrowserWindow({
width: 460,
height: 340,
frame: false,
resizable: false,
movable: true,
minimizable: false,
maximizable: false,
fullscreenable: false,
skipTaskbar: true, // avoid duplicate taskbar entry next to mainWindow
show: true,
center: true,
backgroundColor: '#0a0a10', // opaque to dodge Windows DWM transparency quirks
title: 'OpenSwarm',
icon: iconPath,
webPreferences: {
// Splash content is fully self-contained (data URL, no remote
// resources) so nodeIntegration here is safe and lets the splash
// listen on ipcRenderer directly without a separate preload.
nodeIntegration: true,
contextIsolation: false,
sandbox: false,
backgroundThrottling: false,
},
});
w.setMenuBarVisibility(false);
w.loadURL(dataUrl);
// If the splash is dismissed BEFORE the main window has shown itself,
// treat that as the user intentionally bailing out of boot. Without
// this, splash.close() would silently leave a backend running with
// no UI, which is confusing and leaks the python process.
// The isQuittingFromSplash guard avoids a double-quit when the user
// clicked the splash's Quit button (which also calls app.quit) — that
// path closes the splash and would re-trigger this branch.
w.on('closed', () => {
splashWindow = null;
if (!mainWindowReady && !isQuittingFromSplash) {
isQuittingFromSplash = true;
console.log('[splash] closed before main window appeared — quitting app');
try { if (!isDev) killBackend(); } catch (_) {}
app.quit();
}
});
return w;
}
function emitSplashStatus(payload) {
if (splashWindow && !splashWindow.isDestroyed() && splashWindow.webContents) {
try { splashWindow.webContents.send('splash:status', payload); } catch (_) {}
}
}
/**
* macOS GUI apps launched from Finder/Dock inherit a minimal PATH from launchd
@@ -182,16 +265,52 @@ function getPythonPath() {
return path.join(__dirname, '..', 'backend', '.venv', 'bin', 'python3');
}
function waitForBackend(port, timeoutMs = 60000) {
// Polls /api/health/check until the backend answers 200, or the spawned
// python process exits non-zero (real failure). Never times out by wall
// clock — on a cold-Defender Windows install this can take several
// minutes the first time, and silently calling app.quit() would leave
// users staring at a vanished icon. Instead we surface progressive
// warnings on the splash so the wait feels intentional.
function waitForBackend(port, opts = {}) {
const proc = opts.process || null;
const start = Date.now();
return new Promise((resolve, reject) => {
let settled = false;
let stillStartingNotified = false;
let actionsShown = false;
const finish = (fn, val) => { if (settled) return; settled = true; fn(val); };
if (proc) {
proc.once('exit', (code) => {
// exit with code === null means we killed it ourselves (normal shutdown).
if (code !== 0 && code !== null) {
finish(reject, new Error(`Backend process exited with code ${code} during startup`));
}
});
}
function check() {
if (Date.now() - start > timeoutMs) {
return reject(new Error('Backend startup timed out'));
if (settled) return;
const elapsed = Date.now() - start;
if (elapsed > 60_000 && !stillStartingNotified) {
stillStartingNotified = true;
emitSplashStatus({
text: 'Still starting (first launch can take 2-3 minutes on Windows while Defender scans)…',
level: 'warning',
});
}
if (elapsed > 180_000 && !actionsShown) {
actionsShown = true;
emitSplashStatus({
text: 'Backend is taking unusually long. You can wait, view logs, or restart.',
level: 'warning',
showActions: true,
logs: recentBackendStderr.slice(-20).join(''),
});
}
const req = http.get(`http://127.0.0.1:${port}/api/health/check`, (res) => {
if (res.statusCode === 200) {
resolve();
finish(resolve);
} else {
setTimeout(check, 500);
}
@@ -206,8 +325,29 @@ function waitForBackend(port, timeoutMs = 60000) {
});
}
// Race a port-range search against a 3-second wall clock. On most machines
// `getPort.makeRange(8324, 8424)` returns within milliseconds, but Windows
// EDR / corp-firewall stacks can intercept the bind() probes and stall each
// attempt for seconds — 100 attempts × multi-second stalls = "OpenSwarm is
// hung at startup." The fallback `getPort({ port: 0 })` lets the OS pick
// any free ephemeral port; we don't actually care about staying inside the
// 8324-range — the renderer reads the port via IPC, no hardcoded assumption.
async function pickBackendPort() {
const PREFERRED_TIMEOUT_MS = 3000;
const preferred = getPort({ port: getPort.makeRange(8324, 8424) });
let timeoutHandle;
const timeout = new Promise((resolve) => {
timeoutHandle = setTimeout(() => resolve(null), PREFERRED_TIMEOUT_MS);
});
const winner = await Promise.race([preferred, timeout]);
clearTimeout(timeoutHandle);
if (winner !== null) return winner;
console.warn(`[boot] getPort.makeRange(8324,8424) stalled past ${PREFERRED_TIMEOUT_MS}ms — falling back to OS-assigned port`);
return await getPort({ port: 0 });
}
async function startBackend() {
backendPort = await getPort({ port: getPort.makeRange(8324, 8424) });
backendPort = await pickBackendPort();
const pythonPath = getPythonPath();
const backendDir = getResourcePath('backend');
@@ -253,11 +393,22 @@ async function startBackend() {
backendProcess.stdout.on('data', (data) => {
const text = data.toString();
process.stdout.write(`[backend] ${text}`);
// uvicorn prints this exact phrase once the ASGI app is live and
// routes are mounted — perfect milestone for the splash to flip
// from "starting backend" to "loading components".
if (text.indexOf('Application startup complete') !== -1) {
emitSplashStatus('Loading components…');
}
});
backendProcess.stderr.on('data', (data) => {
const text = data.toString();
process.stderr.write(`[backend] ${text}`);
// Buffer the most recent stderr lines for the splash error UI so
// when boot fails we can show actionable context inline instead of
// making the user dig through a log file.
recentBackendStderr.push(text);
while (recentBackendStderr.length > 60) recentBackendStderr.shift();
});
backendProcess.on('exit', (code) => {
@@ -269,7 +420,8 @@ async function startBackend() {
}
});
await waitForBackend(backendPort);
emitSplashStatus('Starting backend…');
await waitForBackend(backendPort, { process: backendProcess });
console.log(`Backend ready on port ${backendPort}`);
// Backend writes a per-install auth token file at startup. Read it
@@ -334,6 +486,12 @@ function createWindow() {
title: 'OpenSwarm',
icon: iconPath,
titleBarStyle: 'hiddenInset',
// Stay hidden until the renderer fires `ready-to-show`. The splash
// is what the user looks at; we swap it out for this window only
// once React has actually painted, avoiding the white-flash that
// Electron windows do during initial layout.
show: false,
backgroundColor: '#1a1a1f',
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
nodeIntegration: false,
@@ -516,30 +674,43 @@ app.whenReady().then(async () => {
},
);
// Wait for the Widevine CDM to be downloaded/ready (CastLabs Component
// Updater Service). On first launch this downloads the CDM; subsequent
// launches use the cached version.
// Splash window opens immediately so the user sees motion within ~1s
// of double-clicking. Without this, on a cold-Defender Windows install
// the dock/taskbar icon flashes for 30-60s with nothing visible.
splashWindow = createSplashWindow();
emitSplashStatus('Starting OpenSwarm…');
// Widevine CDM and backend startup are independent — run them
// concurrently. Backend is the long pole on Windows (Defender + Python
// cold start), so we don't want a slow CDM download to add seconds to
// every boot. Webviews that need DRM still wait on `components.whenReady`
// before loading via the existing webview-preload flow, so parallelizing
// here is safe.
let widevinePromise;
if (components && typeof components.whenReady === 'function') {
try {
await components.whenReady();
console.log('Widevine CDM ready');
if (typeof components.status === 'function') {
console.log('CDM component status:', JSON.stringify(components.status()));
}
} catch (err) {
console.warn('Widevine CDM not available:', err.message);
}
widevinePromise = components.whenReady().then(
() => {
console.log('Widevine CDM ready');
if (typeof components.status === 'function') {
console.log('CDM component status:', JSON.stringify(components.status()));
}
},
(err) => { console.warn('Widevine CDM not available:', err && err.message); }
);
} else {
console.log('CastLabs components API not available — using standard Electron (no DRM)');
widevinePromise = Promise.resolve();
}
try {
if (isDev) {
backendPort = parseInt(process.env.OPENSWARM_PORT || '8324', 10);
console.log(`Dev mode: using existing backend on port ${backendPort}`);
emitSplashStatus('Connecting to dev backend…');
} else {
await startBackend();
}
emitSplashStatus('Almost ready…');
createWindow();
if (!isDev) {
setupAutoUpdater();
@@ -551,9 +722,51 @@ app.whenReady().then(async () => {
}
});
}
// Swap splash → main only once React has actually painted. ready-to-show
// fires after the renderer's first frame, eliminating the white-flash
// that would otherwise pop between splash close and React mount.
if (mainWindow) {
const swapToMain = () => {
if (mainWindowReady || mainWindow.isDestroyed()) return;
mainWindowReady = true;
try { mainWindow.show(); mainWindow.focus(); } catch (_) {}
// Tiny delay so the OS gets a chance to bring main to front
// before splash disappears — avoids a single-frame "no window"
// gap on Windows.
setTimeout(() => {
if (splashWindow && !splashWindow.isDestroyed()) {
splashWindow.destroy();
}
splashWindow = null;
}, 120);
};
mainWindow.once('ready-to-show', swapToMain);
// Fallback: if the renderer fails to load (e.g. dev server not
// running on localhost:3000), `ready-to-show` never fires and
// the splash would hang forever. Show main anyway so the dev
// sees the load error in the window itself.
mainWindow.webContents.once('did-fail-load', (_e, errorCode, errorDescription, validatedURL) => {
console.warn('[boot] mainWindow load failed:', errorCode, errorDescription, validatedURL);
if (isDev) swapToMain();
});
}
// Don't block on Widevine; it'll resolve in the background. Logged above.
widevinePromise.catch(() => {});
} catch (err) {
console.error('Failed to start:', err);
app.quit();
// Surface the failure on the splash instead of silently quitting.
// The user picks: view logs, restart, or quit. This eliminates the
// class of "I clicked OpenSwarm and nothing happened" reports.
emitSplashStatus({
text: "OpenSwarm couldn't start: " + (err && err.message ? err.message : String(err)),
level: 'error',
showActions: true,
logs: recentBackendStderr.slice(-30).join(''),
});
// Do NOT call app.quit() here — the user controls the next step
// through the splash action buttons.
}
});
@@ -805,6 +1018,31 @@ app.on('activate', () => {
}
});
// Splash window action buttons. Only meaningful while splashWindow is alive
// (during boot or in the post-failure error state). Sent via ipcRenderer.send
// from electron/splash/splash.html.
ipcMain.on('splash:action', (_event, action) => {
if (action === 'quit') {
isQuittingFromSplash = true;
app.quit();
} else if (action === 'restart') {
// app.relaunch + app.exit is the canonical Electron restart pattern.
// killBackend runs via the will-quit listener so the python child
// gets cleaned up before we re-spawn ourselves.
app.relaunch();
app.exit(0);
} else if (action === 'open-logs') {
// No backend log file is written to disk today; the next-best thing
// is opening the OpenSwarm data dir, where the user can see the
// auth.token file and any future log artifacts. Surfacing the dir
// also lets advanced users self-serve (clear data, etc).
try {
const dataDir = path.dirname(getAuthTokenFilePath());
shell.openPath(dataDir).catch(() => {});
} catch (_) {}
}
});
ipcMain.handle('get-backend-port', () => backendPort);
ipcMain.handle('get-auth-token', () => {
// Re-read the file every time. The backend rotates the token on each
+7 -3
View File
@@ -69,7 +69,7 @@
"arch": ["x64"]
}
],
"artifactName": "OpenSwarm-Setup-${version}-${arch}.${ext}",
"artifactName": "OpenSwarm-Setup-${arch}.${ext}",
"sign": "./build/sign-windows.js",
"signingHashAlgorithms": ["sha256"],
"signDlls": false
@@ -82,7 +82,7 @@
"createStartMenuShortcut": true,
"shortcutName": "OpenSwarm",
"deleteAppDataOnUninstall": false,
"artifactName": "OpenSwarm-Setup-${version}-${arch}.${ext}"
"artifactName": "OpenSwarm-Setup-${arch}.${ext}"
},
"extraResources": [
{
@@ -96,7 +96,11 @@
"from": "build-staging/backend",
"to": "backend",
"filter": [
"**/*"
"**/*",
"!**/.env",
"!**/.env.*",
"!**/tests/**",
"!**/__pycache__/**"
]
},
{
+303
View File
@@ -0,0 +1,303 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta http-equiv="Content-Security-Policy" content="default-src 'self' data: 'unsafe-inline'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src data:;">
<title>OpenSwarm</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body {
width: 100%; height: 100%; overflow: hidden;
background: #0a0a10;
}
body {
-webkit-app-region: drag;
color: #e8e8f4;
font-family: -apple-system, "Segoe UI", system-ui, sans-serif;
user-select: none;
cursor: default;
}
#stage {
position: relative;
width: 100%; height: 100%;
display: flex; align-items: center; justify-content: center;
}
canvas#bg {
position: absolute; inset: 0;
width: 100%; height: 100%;
image-rendering: pixelated;
image-rendering: crisp-edges;
-ms-interpolation-mode: nearest-neighbor;
}
.center {
position: relative; z-index: 1;
display: flex; flex-direction: column;
align-items: center; gap: 14px;
padding: 0 24px;
max-width: 100%;
}
.logo {
width: 84px; height: 84px;
image-rendering: auto;
filter: drop-shadow(0 0 18px rgba(160, 100, 240, 0.55));
opacity: 0;
animation: fadeIn 700ms ease forwards 60ms;
}
.name {
font-size: 14px; font-weight: 600; letter-spacing: 0.32em;
text-transform: uppercase;
color: #f0f0fa;
text-shadow: 0 0 12px rgba(0,0,0,0.6);
opacity: 0;
animation: fadeIn 700ms ease forwards 220ms;
}
.status {
font-size: 11.5px; font-weight: 500;
color: rgba(232, 232, 244, 0.7);
text-align: center; min-height: 16px;
letter-spacing: 0.05em;
text-shadow: 0 0 8px rgba(0,0,0,0.7);
transition: color 240ms ease;
opacity: 0;
animation: fadeIn 700ms ease forwards 360ms;
max-width: 380px;
}
.status.warning { color: rgba(255, 198, 110, 0.92); }
.status.error { color: rgba(255, 118, 118, 0.95); }
.actions {
display: none; gap: 8px; margin-top: 4px;
}
.actions.visible { display: flex; }
button {
-webkit-app-region: no-drag;
background: rgba(140, 100, 220, 0.16);
border: 1px solid rgba(170, 130, 240, 0.45);
color: #e8e8f4;
padding: 6px 14px; border-radius: 4px;
font-family: inherit;
font-size: 10.5px; font-weight: 600;
letter-spacing: 0.08em; text-transform: uppercase;
cursor: pointer;
transition: background 180ms ease, border-color 180ms ease;
}
button:hover {
background: rgba(170, 130, 240, 0.30);
border-color: rgba(190, 150, 255, 0.7);
}
button:active { transform: translateY(1px); }
pre.logs {
-webkit-app-region: no-drag;
display: none;
background: rgba(0, 0, 0, 0.55);
border: 1px solid rgba(255, 118, 118, 0.4);
color: #e8e8f4;
font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
font-size: 9px; line-height: 1.45;
padding: 6px 8px;
width: 360px; max-height: 70px;
overflow: auto; white-space: pre-wrap; word-break: break-all;
border-radius: 3px;
user-select: text; cursor: text;
}
pre.logs.visible { display: block; }
@keyframes fadeIn {
from { opacity: 0; transform: translateY(2px); }
to { opacity: 1; transform: translateY(0); }
}
</style>
</head>
<body>
<div id="stage">
<canvas id="bg"></canvas>
<div class="center">
<img class="logo" src="__OPENSWARM_LOGO__" alt="">
<div class="name">OpenSwarm</div>
<div class="status" id="status">Starting&hellip;</div>
<div class="actions" id="actions">
<button id="logsBtn">View logs</button>
<button id="restartBtn">Restart</button>
<button id="quitBtn">Quit</button>
</div>
<pre class="logs" id="logsPanel"></pre>
</div>
</div>
<script>
(function () {
'use strict';
const { ipcRenderer } = require('electron');
// -----------------------------------------------------------------
// Dither shader background. WebGL-first; falls back to a solid dark
// panel if WebGL init fails (very old GPUs / locked-down enterprise
// VMs). Logo + text remain visible either way.
// -----------------------------------------------------------------
const canvas = document.getElementById('bg');
let renderFrame = function () {}; // no-op until WebGL initializes
function resizeCanvas() {
const dpr = Math.min(window.devicePixelRatio || 1, 1.5);
const w = Math.max(1, Math.floor(canvas.clientWidth * dpr));
const h = Math.max(1, Math.floor(canvas.clientHeight * dpr));
if (canvas.width !== w || canvas.height !== h) {
canvas.width = w; canvas.height = h;
}
}
function initWebGL() {
const gl = canvas.getContext('webgl', { antialias: false, alpha: false, premultipliedAlpha: false });
if (!gl) throw new Error('webgl unavailable');
const vsSrc = [
'attribute vec2 a_pos;',
'void main() { gl_Position = vec4(a_pos, 0.0, 1.0); }'
].join('\n');
// Animated layered fluid (sin/cos blend) → bayer-dithered into 2 colors.
const fsSrc = [
'precision highp float;',
'uniform vec2 u_res;',
'uniform float u_time;',
'uniform sampler2D u_bayer;',
'void main() {',
' vec2 uv = gl_FragCoord.xy / u_res;',
' float t = u_time;',
' float a = sin(uv.x * 5.2 + t * 0.42) * cos(uv.y * 3.8 + t * 0.29);',
' float b = sin((uv.x + uv.y) * 4.6 + t * 0.58) * 0.55;',
' float c = cos(uv.x * 7.1 - uv.y * 5.3 + t * 0.21) * 0.35;',
' float fluid = (a + b + c) * 0.32 + 0.5;',
' vec2 bUv = fract(gl_FragCoord.xy / 4.0);',
' float thresh = texture2D(u_bayer, bUv).r;',
' float lit = step(thresh, fluid);',
' vec3 dark = vec3(0.038, 0.038, 0.062);', // ~#0a0a10
' vec3 lite = vec3(0.522, 0.286, 0.878);', // ~#854ae0 (electric purple)
' gl_FragColor = vec4(mix(dark, lite, lit), 1.0);',
'}'
].join('\n');
function compile(type, src) {
const sh = gl.createShader(type);
gl.shaderSource(sh, src);
gl.compileShader(sh);
if (!gl.getShaderParameter(sh, gl.COMPILE_STATUS)) {
throw new Error('shader: ' + gl.getShaderInfoLog(sh));
}
return sh;
}
const vs = compile(gl.VERTEX_SHADER, vsSrc);
const fs = compile(gl.FRAGMENT_SHADER, fsSrc);
const prog = gl.createProgram();
gl.attachShader(prog, vs); gl.attachShader(prog, fs);
gl.linkProgram(prog);
if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) {
throw new Error('link: ' + gl.getProgramInfoLog(prog));
}
gl.useProgram(prog);
// Fullscreen quad (two triangles).
const quad = new Float32Array([-1,-1, 1,-1, -1,1, -1,1, 1,-1, 1,1]);
const buf = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, buf);
gl.bufferData(gl.ARRAY_BUFFER, quad, gl.STATIC_DRAW);
const aPos = gl.getAttribLocation(prog, 'a_pos');
gl.enableVertexAttribArray(aPos);
gl.vertexAttribPointer(aPos, 2, gl.FLOAT, false, 0, 0);
// 4×4 Bayer ordered-dither matrix as a luminance texture.
const bayer = new Uint8Array([
0, 8, 2, 10,
12, 4, 14, 6,
3, 11, 1, 9,
15, 7, 13, 5
].map(function (v) { return Math.floor(v * 255 / 16); }));
const tex = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, tex);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.LUMINANCE, 4, 4, 0, gl.LUMINANCE, gl.UNSIGNED_BYTE, bayer);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.REPEAT);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.REPEAT);
const uRes = gl.getUniformLocation(prog, 'u_res');
const uTime = gl.getUniformLocation(prog, 'u_time');
const uTex = gl.getUniformLocation(prog, 'u_bayer');
gl.uniform1i(uTex, 0);
renderFrame = function (tSec) {
resizeCanvas();
gl.viewport(0, 0, canvas.width, canvas.height);
gl.uniform2f(uRes, canvas.width, canvas.height);
gl.uniform1f(uTime, tSec);
gl.drawArrays(gl.TRIANGLES, 0, 6);
};
}
try { initWebGL(); } catch (e) {
console.warn('[splash] WebGL fallback:', e && e.message);
}
let running = true;
const start = performance.now();
function loop() {
if (!running) return;
renderFrame((performance.now() - start) * 0.001);
requestAnimationFrame(loop);
}
resizeCanvas();
requestAnimationFrame(loop);
// Free the GPU once main window takes over so we don't burn cycles
// during the user's workflow.
window.addEventListener('beforeunload', function () { running = false; });
document.addEventListener('visibilitychange', function () {
if (document.hidden) running = false;
});
// -----------------------------------------------------------------
// Status updates from main process. payload is either a plain string
// (info) or { text, level, logs?, showActions? }.
// -----------------------------------------------------------------
const statusEl = document.getElementById('status');
const actionsEl = document.getElementById('actions');
const logsPanelEl = document.getElementById('logsPanel');
// Once the action buttons have appeared (long-running boot or hard
// failure), keep them visible across subsequent status updates —
// hiding them would yank a target the user is reading/about to click.
let actionsLatched = false;
ipcRenderer.on('splash:status', function (_e, payload) {
if (typeof payload === 'string') {
statusEl.textContent = payload;
statusEl.className = 'status';
return;
}
const text = (payload && payload.text) || '';
const level = (payload && payload.level) || 'info';
statusEl.textContent = text;
statusEl.className = 'status' + (level !== 'info' ? ' ' + level : '');
if (payload && payload.showActions) {
actionsLatched = true;
actionsEl.classList.add('visible');
} else if (!actionsLatched) {
actionsEl.classList.remove('visible');
}
if (payload && payload.logs) {
logsPanelEl.textContent = payload.logs;
logsPanelEl.classList.add('visible');
} else if (!actionsLatched) {
logsPanelEl.classList.remove('visible');
}
});
document.getElementById('logsBtn').addEventListener('click', function () {
ipcRenderer.send('splash:action', 'open-logs');
});
document.getElementById('restartBtn').addEventListener('click', function () {
ipcRenderer.send('splash:action', 'restart');
});
document.getElementById('quitBtn').addEventListener('click', function () {
ipcRenderer.send('splash:action', 'quit');
});
})();
</script>
</body>
</html>
+110 -27
View File
@@ -83,53 +83,136 @@ if (-not (Test-Path (Join-Path $UvBinDir 'uvx.exe'))) {
}
Write-Host ""
# --- Step 0b: Bundle reddit-mcp-buddy via esbuild ---
# --- Step 0b: Bundle npm MCP servers via esbuild ---
# Each bundle compiles down to a single ~5-15 MB CommonJS file under
# backend\mcp-bundles\, runs on Electron's bundled Node at runtime
# (ELECTRON_RUN_AS_NODE=1), and is preferred by tools_lib.py:521 over
# any pre-installed node_modules tree. Bundling instead of shipping
# node_modules cuts the installer file count from ~28k -> ~9k, which
# is the dominant lever on NSIS install time + Defender scan cost.
$McpBundleDir = Join-Path $ProjectRoot 'backend\mcp-bundles'
New-Item -ItemType Directory -Force -Path $McpBundleDir | Out-Null
$RedditBundle = Join-Path $McpBundleDir 'reddit-mcp-buddy.js'
if (-not (Test-Path $RedditBundle)) {
Write-Host "[0b] Bundling reddit-mcp-buddy..."
# Single-file CJS bundle. Output path: mcp-bundles\<output>.js. Use for
# packages that don't read sibling files at runtime. The import.meta.url
# polyfill is applied uniformly because nearly every modern ESM package
# uses createRequire(import.meta.url) somewhere — without the polyfill,
# esbuild's ESM->CJS transform leaves import.meta.url as undefined and
# the bundle crashes at module load.
function Build-McpBundleSingle($PackageName, $EntrySubpath, $OutputName) {
$OutFile = Join-Path $McpBundleDir $OutputName
if ((Test-Path $OutFile) -and -not $env:OPENSWARM_REBUILD_BUNDLES) {
Write-Host "[0b] $PackageName bundle already present (set `$env:OPENSWARM_REBUILD_BUNDLES='1' to force rebuild)."
return
}
Write-Host "[0b] Bundling $PackageName -> $OutputName ..."
$TmpDir = Join-Path $env:TEMP "openswarm-mcp-$([guid]::NewGuid())"
New-Item -ItemType Directory -Force -Path $TmpDir | Out-Null
Push-Location $TmpDir
try {
& npm install reddit-mcp-buddy --silent 2>$null
& npx esbuild "node_modules/reddit-mcp-buddy/dist/index.js" --bundle --platform=node --format=cjs "--outfile=$RedditBundle"
if ($LASTEXITCODE -ne 0) { throw "esbuild failed" }
Write-Host "reddit-mcp-buddy bundled."
& npm install $PackageName --silent 2>$null
if ($LASTEXITCODE -ne 0) { throw "$PackageName install failed" }
$EntryPath = Join-Path (Join-Path $TmpDir 'node_modules') $EntrySubpath
if (-not (Test-Path $EntryPath)) { throw "$PackageName entry not found at $EntryPath" }
$banner = 'const __OPENSWARM_IMPORT_META_URL__ = require("url").pathToFileURL(__filename).href;'
& npx esbuild $EntryPath --bundle --platform=node --format=cjs --target=node22 --legal-comments=none `
--define:import.meta.url=__OPENSWARM_IMPORT_META_URL__ `
"--banner:js=$banner" `
"--outfile=$OutFile"
if ($LASTEXITCODE -ne 0) { throw "esbuild failed for $PackageName" }
Write-Host "$PackageName bundled."
} finally {
Pop-Location
Remove-Item -Recurse -Force $TmpDir -ErrorAction SilentlyContinue
}
} else {
Write-Host "[0b] reddit-mcp-buddy bundle already present."
}
# --- Step 0c: npm MCP servers that can't be bundled ---
$NpmServersDir = Join-Path $ProjectRoot 'backend\npm-servers'
New-Item -ItemType Directory -Force -Path $NpmServersDir | Out-Null
function Install-NpmServer($SubDir, $PackageName) {
$TargetDir = Join-Path $NpmServersDir $SubDir
$NodeModules = Join-Path $TargetDir 'node_modules'
if (Test-Path $NodeModules) {
Write-Host "[0c] $PackageName already present."
# Multi-file bundle. Output is a directory mcp-bundles\<dir>\ that mirrors the
# upstream SDK's "package_root\dist\index.js + ..\package.json" layout. Use this
# for packages whose source reads __dirname\..\package.json (for --version) or
# other sibling data files (e.g. @softeria\ms-365-mcp-server reads endpoints.json).
function Build-McpBundleDir($PackageName, $EntrySubpath, $OutDirName, $Extras, $External) {
$OutDir = Join-Path $McpBundleDir $OutDirName
$OutBundle = Join-Path (Join-Path $OutDir 'dist') 'index.js'
if ((Test-Path $OutBundle) -and -not $env:OPENSWARM_REBUILD_BUNDLES) {
Write-Host "[0b] $PackageName bundle dir already present."
return
}
Write-Host "[0c] Installing $PackageName..."
New-Item -ItemType Directory -Force -Path $TargetDir | Out-Null
Push-Location $TargetDir
Write-Host "[0b] Bundling $PackageName -> $OutDirName\ ..."
$TmpDir = Join-Path $env:TEMP "openswarm-mcp-$([guid]::NewGuid())"
New-Item -ItemType Directory -Force -Path $TmpDir | Out-Null
if (Test-Path $OutDir) { Remove-Item -Recurse -Force $OutDir }
New-Item -ItemType Directory -Force -Path (Join-Path $OutDir 'dist') | Out-Null
Push-Location $TmpDir
try {
& npm init -y *>$null
& npm install $PackageName --silent 2>$null
if ($LASTEXITCODE -ne 0) { throw "$PackageName install failed" }
Write-Host "$PackageName installed."
$EntryPath = Join-Path (Join-Path $TmpDir 'node_modules') $EntrySubpath
if (-not (Test-Path $EntryPath)) { throw "$PackageName entry not found at $EntryPath" }
# Stripped sibling package.json (omits "type":"module" so Node treats the CJS bundle correctly)
$SdkPkgPath = Join-Path (Join-Path $TmpDir 'node_modules') (Join-Path $PackageName 'package.json')
$SdkPkgJson = Get-Content -Raw $SdkPkgPath | ConvertFrom-Json
$SdkVersion = $SdkPkgJson.version
$StrippedPkg = "{`"name`":`"$PackageName`",`"version`":`"$SdkVersion`"}"
Set-Content -Path (Join-Path $OutDir 'package.json') -Value $StrippedPkg -NoNewline
# Copy sibling data files
if ($Extras) {
foreach ($pair in $Extras) {
$src, $dst = $pair -split '='
$srcAbs = Join-Path (Join-Path $TmpDir 'node_modules') $src
$dstAbs = Join-Path $OutDir $dst
New-Item -ItemType Directory -Force -Path (Split-Path $dstAbs -Parent) | Out-Null
Copy-Item -Force $srcAbs $dstAbs
}
}
$banner = 'const __OPENSWARM_IMPORT_META_URL__ = require("url").pathToFileURL(__filename).href;'
$esbuildArgs = @(
$EntryPath, '--bundle', '--platform=node', '--format=cjs',
'--target=node22', '--legal-comments=none',
'--define:import.meta.url=__OPENSWARM_IMPORT_META_URL__',
"--banner:js=$banner",
"--outfile=$OutBundle"
)
if ($External) {
foreach ($ext in $External) { $esbuildArgs += "--external:$ext" }
}
& npx esbuild @esbuildArgs
if ($LASTEXITCODE -ne 0) { throw "esbuild failed for $PackageName" }
Write-Host "$PackageName bundled."
} finally {
Pop-Location
Remove-Item -Recurse -Force $TmpDir -ErrorAction SilentlyContinue
}
}
Install-NpmServer 'softeria-ms-365-mcp-server' '@softeria/ms-365-mcp-server'
Install-NpmServer 'notionhq-notion-mcp-server' '@notionhq/notion-mcp-server'
Build-McpBundleSingle 'reddit-mcp-buddy' 'reddit-mcp-buddy/dist/index.js' 'reddit-mcp-buddy.js'
Build-McpBundleDir '@notionhq/notion-mcp-server' '@notionhq/notion-mcp-server/bin/cli.mjs' `
'notionhq-notion-mcp-server' `
@('@notionhq/notion-mcp-server/scripts/notion-openapi.json=scripts/notion-openapi.json') `
@()
Build-McpBundleDir '@softeria/ms-365-mcp-server' '@softeria/ms-365-mcp-server/dist/index.js' `
'softeria-ms-365-mcp-server' `
@('@softeria/ms-365-mcp-server/dist/endpoints.json=dist/endpoints.json') `
@('keytar')
# Wipe legacy single-file Notion bundle if the dir-style bundle now supersedes it.
$LegacyNotionFile = Join-Path $McpBundleDir 'notionhq-notion-mcp-server.js'
$NotionDir = Join-Path $McpBundleDir 'notionhq-notion-mcp-server'
if ((Test-Path $LegacyNotionFile) -and (Test-Path $NotionDir)) {
Remove-Item -Force $LegacyNotionFile
}
# Defensively wipe any legacy npm-servers/ tree from prior builds so it
# doesn't ride along into the installer (would re-introduce the ~19k
# files we just removed by switching to bundling).
$LegacyNpmServers = Join-Path $ProjectRoot 'backend\npm-servers'
if (Test-Path $LegacyNpmServers) {
Write-Host "[0b] Removing legacy backend\npm-servers\ (now superseded by mcp-bundles)..."
Remove-Item -Recurse -Force $LegacyNpmServers
}
Write-Host ""
# --- Step 1: Frontend build ---
@@ -197,7 +280,7 @@ function Copy-Excluded($Source, $Dest, $Exclude) {
Copy-Excluded `
(Join-Path $ProjectRoot 'backend') (Join-Path $Staging 'backend') `
@{ Dirs = @('__pycache__','.venv','tools','tests'); Files = @('*.pyc') }
@{ Dirs = @('__pycache__','.venv','tools','tests'); Files = @('*.pyc','.env','.env.*') }
New-Item -ItemType Directory -Force -Path (Join-Path $Staging 'backend\data\tools') | Out-Null
Copy-Excluded `
+128 -35
View File
@@ -87,45 +87,137 @@ else
fi
echo ""
# Step 0b: Bundle npm MCP servers (uses Electron's Node at runtime)
# Step 0b: Bundle npm MCP servers via esbuild
# Each bundle compiles down to a single ~5-15 MB CommonJS file under
# backend/mcp-bundles/, runs on Electron's bundled Node at runtime
# (ELECTRON_RUN_AS_NODE=1), and is preferred by tools_lib.py:521 over
# any pre-installed node_modules tree. Bundling instead of shipping
# node_modules cuts the installer file count from ~28k -> ~9k, the
# dominant lever on NSIS install time + Defender scan cost.
MCP_BUNDLE_DIR="$PROJECT_ROOT/backend/mcp-bundles"
mkdir -p "$MCP_BUNDLE_DIR"
if [[ ! -f "$MCP_BUNDLE_DIR/reddit-mcp-buddy.js" ]]; then
echo "[0b] Bundling reddit-mcp-buddy..."
TMPDIR_MCP=$(mktemp -d)
cd "$TMPDIR_MCP"
npm install reddit-mcp-buddy --silent 2>/dev/null
npx esbuild node_modules/reddit-mcp-buddy/dist/index.js --bundle --platform=node --format=cjs --outfile="$MCP_BUNDLE_DIR/reddit-mcp-buddy.js" 2>/dev/null
rm -rf "$TMPDIR_MCP"
echo "reddit-mcp-buddy bundled."
else
echo "[0b] reddit-mcp-buddy bundle already present."
# Single-file CJS bundles. Output path is mcp-bundles/<output>.js. Use for
# packages that don't read sibling files at runtime. The import.meta.url
# polyfill is applied uniformly because nearly every modern ESM package
# uses createRequire(import.meta.url) somewhere in its dependency tree —
# without the polyfill, esbuild's ESM->CJS transform leaves import.meta.url
# as undefined and the bundle crashes at module load.
build_mcp_bundle_single() {
local pkg_name="$1"
local entry_subpath="$2"
local output_name="$3"
local out_file="$MCP_BUNDLE_DIR/$output_name"
if [[ -f "$out_file" && -z "${OPENSWARM_REBUILD_BUNDLES:-}" ]]; then
echo "[0b] $pkg_name bundle already present (set OPENSWARM_REBUILD_BUNDLES=1 to force rebuild)."
return
fi
echo "[0b] Bundling $pkg_name -> $output_name ..."
local tmp_dir; tmp_dir=$(mktemp -d)
(
cd "$tmp_dir"
npm install "$pkg_name" --silent 2>/dev/null
local entry="node_modules/$entry_subpath"
if [[ ! -f "$entry" ]]; then echo "ERROR: $pkg_name entry not found at $entry" >&2; exit 1; fi
local banner='const __OPENSWARM_IMPORT_META_URL__ = require("url").pathToFileURL(__filename).href;'
npx esbuild "$entry" --bundle --platform=node --format=cjs --target=node22 --legal-comments=none \
--define:import.meta.url=__OPENSWARM_IMPORT_META_URL__ \
"--banner:js=$banner" \
--outfile="$out_file"
)
rm -rf "$tmp_dir"
echo "$pkg_name bundled ($(du -h "$out_file" | cut -f1))."
}
# Multi-file bundle. Output is a directory mcp-bundles/<dir>/ that mirrors the
# upstream SDK's "package_root/dist/index.js + ../package.json" layout. Use this
# for packages whose source reads __dirname/../package.json (for --version) or
# other sibling data files (e.g. @softeria/ms-365-mcp-server reads endpoints.json).
# `extras` is a space-separated list of "src=dst" pairs relative to node_modules
# and the bundle dir respectively (e.g. "@softeria/ms-365-mcp-server/dist/endpoints.json=dist/endpoints.json").
# `external` is a comma-separated list of npm package names to leave unbundled
# (e.g. "keytar" — the SDK gracefully degrades when keytar can't be imported).
build_mcp_bundle_dir() {
local pkg_name="$1"
local entry_subpath="$2"
local out_dir_name="$3"
local extras="$4" # e.g. "@softeria/ms-365-mcp-server/dist/endpoints.json=dist/endpoints.json"
local external="$5" # comma-separated package names
local out_dir="$MCP_BUNDLE_DIR/$out_dir_name"
if [[ -f "$out_dir/dist/index.js" && -z "${OPENSWARM_REBUILD_BUNDLES:-}" ]]; then
echo "[0b] $pkg_name bundle dir already present."
return
fi
echo "[0b] Bundling $pkg_name -> $out_dir_name/ ..."
local tmp_dir; tmp_dir=$(mktemp -d)
rm -rf "$out_dir"
mkdir -p "$out_dir/dist"
(
cd "$tmp_dir"
npm install "$pkg_name" --silent 2>/dev/null
local entry="node_modules/$entry_subpath"
if [[ ! -f "$entry" ]]; then echo "ERROR: $pkg_name entry not found at $entry" >&2; exit 1; fi
# Stripped sibling package.json — the SDK reads packageJson.version.
# Critically OMIT "type":"module" so Node treats the CJS bundle correctly.
local sdk_version
sdk_version=$(node -e "console.log(require('./node_modules/$pkg_name/package.json').version)")
printf '{"name":"%s","version":"%s"}' "$pkg_name" "$sdk_version" > "$out_dir/package.json"
# Copy any sibling data files the SDK reads at runtime
if [[ -n "$extras" ]]; then
for pair in $extras; do
local src="${pair%%=*}"
local dst="${pair##*=}"
mkdir -p "$(dirname "$out_dir/$dst")"
cp "node_modules/$src" "$out_dir/$dst"
done
fi
# Banner polyfills `require` for the import.meta.url polyfill.
local banner='const __OPENSWARM_IMPORT_META_URL__ = require("url").pathToFileURL(__filename).href;'
local external_args=""
if [[ -n "$external" ]]; then
# Portable comma-split (works in bash and zsh) — `read -ra` is bash-only.
local _old_ifs="$IFS"
IFS=','
local ext
for ext in $external; do external_args="$external_args --external:$ext"; done
IFS="$_old_ifs"
fi
npx esbuild "$entry" --bundle --platform=node --format=cjs --target=node22 --legal-comments=none \
--define:import.meta.url=__OPENSWARM_IMPORT_META_URL__ \
"--banner:js=$banner" \
$external_args \
--outfile="$out_dir/dist/index.js"
)
rm -rf "$tmp_dir"
echo "$pkg_name bundled ($(du -sh "$out_dir" | cut -f1))."
}
build_mcp_bundle_single 'reddit-mcp-buddy' 'reddit-mcp-buddy/dist/index.js' 'reddit-mcp-buddy.js'
build_mcp_bundle_dir '@notionhq/notion-mcp-server' '@notionhq/notion-mcp-server/bin/cli.mjs' \
'notionhq-notion-mcp-server' \
'@notionhq/notion-mcp-server/scripts/notion-openapi.json=scripts/notion-openapi.json' \
''
build_mcp_bundle_dir '@softeria/ms-365-mcp-server' '@softeria/ms-365-mcp-server/dist/index.js' \
'softeria-ms-365-mcp-server' \
'@softeria/ms-365-mcp-server/dist/endpoints.json=dist/endpoints.json' \
'keytar'
# Wipe the legacy single-file Notion bundle if the dir bundle now supersedes it.
if [[ -f "$MCP_BUNDLE_DIR/notionhq-notion-mcp-server.js" && -d "$MCP_BUNDLE_DIR/notionhq-notion-mcp-server" ]]; then
rm -f "$MCP_BUNDLE_DIR/notionhq-notion-mcp-server.js"
fi
# Step 0c: Pre-install npm MCP servers that can't be esbuild'd
NPM_SERVERS_DIR="$PROJECT_ROOT/backend/npm-servers"
mkdir -p "$NPM_SERVERS_DIR"
if [[ ! -d "$NPM_SERVERS_DIR/softeria-ms-365-mcp-server/node_modules" ]]; then
echo "[0c] Installing @softeria/ms-365-mcp-server..."
mkdir -p "$NPM_SERVERS_DIR/softeria-ms-365-mcp-server"
cd "$NPM_SERVERS_DIR/softeria-ms-365-mcp-server"
npm init -y > /dev/null 2>&1
npm install @softeria/ms-365-mcp-server --silent 2>/dev/null
echo "@softeria/ms-365-mcp-server installed."
else
echo "[0c] @softeria/ms-365-mcp-server already present."
fi
if [[ ! -d "$NPM_SERVERS_DIR/notionhq-notion-mcp-server/node_modules" ]]; then
echo "[0c] Installing @notionhq/notion-mcp-server..."
mkdir -p "$NPM_SERVERS_DIR/notionhq-notion-mcp-server"
cd "$NPM_SERVERS_DIR/notionhq-notion-mcp-server"
npm init -y > /dev/null 2>&1
npm install @notionhq/notion-mcp-server --silent 2>/dev/null
echo "@notionhq/notion-mcp-server installed."
else
echo "[0c] @notionhq/notion-mcp-server already present."
# Defensively wipe any legacy npm-servers/ tree from prior builds so it
# doesn't ride along into the installer (would re-introduce ~19k files).
LEGACY_NPM_SERVERS="$PROJECT_ROOT/backend/npm-servers"
if [[ -d "$LEGACY_NPM_SERVERS" ]]; then
echo "[0b] Removing legacy backend/npm-servers/ (superseded by mcp-bundles)..."
rm -rf "$LEGACY_NPM_SERVERS"
fi
echo ""
@@ -179,6 +271,7 @@ rsync -a \
--exclude='*.pyc' --exclude='.venv' \
--exclude='data/tools' \
--exclude='tests' --exclude='**/tests' \
--exclude='.env' --exclude='.env.*' --exclude='**/.env' --exclude='**/.env.*' \
"$PROJECT_ROOT/backend/" "$STAGING_DIR/backend/"
# Create empty tools directory so the app has a place to write
mkdir -p "$STAGING_DIR/backend/data/tools"
+34 -2
View File
@@ -81,7 +81,9 @@ Write-Host "Verifying claude-agent-sdk..."
& $PythonBin -c "import claude_agent_sdk; print('claude-agent-sdk installed')"
if ($LASTEXITCODE -ne 0) { throw "claude-agent-sdk verification failed" }
# Cleanup
# Cleanup. Drop test packages and any stale __pycache__/.pyc from the
# upstream tarball — we want our own freshly-compiled bytecode (next
# step), not whatever the upstream build happened to ship.
Write-Host "Cleaning up..."
Get-ChildItem -Path $PythonEnvDir -Recurse -Force -Directory `
| Where-Object { $_.Name -in @('__pycache__','tests','test') } `
@@ -89,12 +91,42 @@ Get-ChildItem -Path $PythonEnvDir -Recurse -Force -Directory `
Get-ChildItem -Path $PythonEnvDir -Recurse -Force -Filter '*.pyc' `
| Remove-Item -Force -ErrorAction SilentlyContinue
# Strip parts of the Python distribution we provably don't use at runtime.
# Each removal here has been individually verified — conservative on purpose.
# NOT removing pip/, babel/locale-data/, pygments lexers, or PIL — each had
# at least one weak import-evidence trail.
Write-Host "Stripping unused Python distribution files..."
$ToStrip = @(
(Join-Path $PythonEnvDir 'include'), # C headers — never used at runtime
(Join-Path $PythonEnvDir 'lib\python3.13\idlelib'), # IDLE editor — headless backend has no GUI
(Join-Path $PythonEnvDir 'lib\python3.13\tkinter'), # Tk GUI toolkit — same
(Join-Path $PythonEnvDir 'lib\python3.13\ensurepip'), # Pip bootstrap — backend never installs at runtime
(Join-Path $PythonEnvDir 'lib\python3.13\turtledemo'), # Educational drawing examples
(Join-Path $PythonEnvDir 'share') # Man pages / desktop integration
)
foreach ($p in $ToStrip) {
if (Test-Path $p) { Remove-Item -Recurse -Force $p -ErrorAction SilentlyContinue }
}
# Pre-compile bytecode so cold backend startup skips parse+compile on
# every imported .py. Worth ~5-10s on Windows under Defender (parsing
# Python source is parser-bound; loading .pyc is just bytes). We cap
# concurrency at 4 — `-j 0` (all cores) is fine on dev boxes but
# unstable on small CI runners. Missing .pyc is non-fatal at runtime
# (Python falls back to in-memory compile), so we warn rather than fail.
Write-Host "Pre-compiling bytecode..."
& $PythonBin -m compileall -q -j 4 (Join-Path $PythonEnvDir 'lib')
if ($LASTEXITCODE -ne 0) {
Write-Host "WARNING: some files failed to compile; runtime will fall back to in-memory compile." -ForegroundColor Yellow
}
$Size = (Get-ChildItem -Path $PythonEnvDir -Recurse -File `
| Measure-Object -Property Length -Sum).Sum
$SizeMB = [math]::Round($Size / 1MB, 1)
$PycCount = (Get-ChildItem -Path $PythonEnvDir -Recurse -File -Filter '*.pyc' | Measure-Object).Count
Write-Host ""
Write-Host "=== Python Environment Ready ==="
Write-Host "Location: $PythonEnvDir"
Write-Host ("Size: {0} MB" -f $SizeMB)
Write-Host ("Size: {0} MB ({1} .pyc files)" -f $SizeMB, $PycCount)
Write-Host ""
+40 -2
View File
@@ -106,16 +106,54 @@ else
echo "WARNING: Claude binary not found at $CLAUDE_BIN"
fi
# Clean up build artifacts to reduce size
# Clean up build artifacts to reduce size. Drop test packages and any
# stale __pycache__/.pyc from the upstream Python tarball — we want our
# own freshly-compiled bytecode (next step), not whatever the upstream
# build happened to ship.
echo "Cleaning up..."
find "$PYTHON_ENV_DIR" -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null || true
find "$PYTHON_ENV_DIR" -name "*.pyc" -delete 2>/dev/null || true
find "$PYTHON_ENV_DIR" -type d -name "tests" -exec rm -rf {} + 2>/dev/null || true
find "$PYTHON_ENV_DIR" -type d -name "test" -exec rm -rf {} + 2>/dev/null || true
# Strip parts of the Python distribution we provably don't use at runtime.
# Each removal here has been individually verified — see the audit notes in
# the project plan. Doing this BEFORE compileall would also work, but doing
# it after means the dirs are already definitely-not-imported (compileall
# would have surfaced any backend code that touches them).
#
# Conservative on purpose. NOT removing pip/, babel/locale-data/, pygments
# lexers, or PIL — each had at least one weak import-evidence trail.
echo "Stripping unused Python distribution files..."
# C headers — only needed when building C extensions, never at runtime.
rm -rf "$PYTHON_ENV_DIR/include"
# IDLE editor + Tk GUI toolkit — embedded headless backend has no UI.
rm -rf "$PYTHON_ENV_DIR/lib/python3.13/idlelib"
rm -rf "$PYTHON_ENV_DIR/lib/python3.13/tkinter"
# Pip bootstrap module — backend never installs packages at runtime.
rm -rf "$PYTHON_ENV_DIR/lib/python3.13/ensurepip"
# Educational drawing examples that ship with stdlib — never imported.
rm -rf "$PYTHON_ENV_DIR/lib/python3.13/turtledemo"
# Man pages / desktop-integration files — embedded Python doesn't read these.
rm -rf "$PYTHON_ENV_DIR/share"
# Pre-compile bytecode so cold backend startup skips the parse+compile
# step on every imported .py. Worth ~5-10s on Windows under Defender
# (parsing Python source is parser-bound; loading .pyc is just bytes).
# Concurrency capped at 4 — `-j 0` (all cores) is fine on dev boxes
# but unstable on small CI runners. Failures on individual files are
# survivable (compileall continues on SyntaxError-tagged files used by
# version-shim packages); a non-zero exit here would rather be visible
# than silent so we don't `|| true` the whole thing — but missing .pyc
# is non-fatal at runtime, so a hard fail isn't warranted either.
echo "Pre-compiling bytecode..."
"$PYTHON_BIN" -m compileall -q -j 4 "$PYTHON_ENV_DIR/lib" || \
echo "WARNING: some files failed to compile; runtime will fall back to in-memory compile."
TOTAL_SIZE=$(du -sh "$PYTHON_ENV_DIR" | cut -f1)
PYC_COUNT=$(find "$PYTHON_ENV_DIR" -name '*.pyc' -type f | wc -l | tr -d ' ')
echo ""
echo "=== Python Environment Ready ==="
echo "Location: $PYTHON_ENV_DIR"
echo "Size: $TOTAL_SIZE"
echo "Size: $TOTAL_SIZE ($PYC_COUNT .pyc files)"
echo ""