mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-07 10:17:43 +02:00
[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:
@@ -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("@", "")
|
||||
|
||||
+21056
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,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
+116746
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
@@ -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
|
||||
|
||||
@@ -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__/**"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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…</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
@@ -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
@@ -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"
|
||||
|
||||
@@ -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 ""
|
||||
|
||||
@@ -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 ""
|
||||
|
||||
Reference in New Issue
Block a user