[eric] windows shipping: electron-builder win+nsis + Azure Trusted Signing hook, build-app-win.ps1 / build-python-env-win.ps1 mirroring mac, release-windows.yml tag-triggered CI, icon.ico + .env.windows.example, .claude/ + .env.* gitignored. Runtime fixes: PYTHONUTF8=1 (cp1252 UnicodeDecode), python.exe / Lib\site-packages paths, PYTHONPATH path.delimiter, taskkill /T /F process tree, OAuth UA platform branch, sign-vmp postinstall via node wrapper. Backend: _resolve_command PATHEXT probing (fixes uvx not found), UV_PYTHON Windows path. Cross-env added to 9router+electron for NODE_ENV prefix on cmd. Onboarding: poll subscriptions/status while connect step is open, render Connected for active providers (was stuck on "Connect" after 30s OAuth poll timeout).

This commit is contained in:
Eric
2026-04-21 18:15:49 -07:00
parent 87e42d0a3a
commit 9ff07443db
17 changed files with 772 additions and 47 deletions
+18
View File
@@ -0,0 +1,18 @@
# Local Windows signing config — copy to .env.windows (gitignored) and fill in.
# Used by scripts/build-app-win.ps1 when running a signed local Windows build.
# CI uses GitHub Actions Secrets with the same names — see .github/workflows/release-windows.yml.
# Azure Trusted Signing — Microsoft.Trusted.Signing.Client + signtool
AZURE_TENANT_ID=
AZURE_CLIENT_ID=
AZURE_CLIENT_SECRET=
AZURE_SIGNING_ENDPOINT=https://wus2.codesigning.azure.net/
AZURE_SIGNING_ACCOUNT=mist-code-signing
AZURE_SIGNING_CERT_PROFILE=Mist-Windows-Signing
# Optional — only needed if signtool / dlib aren't auto-discovered
# SIGNTOOL_PATH=
# AZURE_SIGNING_DLIB=
# GitHub Releases publish (set if running build-app-win.ps1 -Publish)
GH_TOKEN=
+123
View File
@@ -0,0 +1,123 @@
name: Release (Windows)
# Builds + signs + uploads the Windows installer to the GitHub Release matching
# the app version in electron/package.json. Mac builds stay on the local
# publish.sh flow — this workflow is Windows-only on purpose.
#
# Triggers:
# - Push a tag `v*` (e.g. v1.0.25) → full signed release build, uploaded to
# the release of that tag (creates it in draft if absent).
# - Manual dispatch (workflow_dispatch) with `publish: false` → signed build
# as an artifact, no release upload. Useful for smoke-tests.
#
# Required repository secrets (Settings → Secrets and variables → Actions):
# AZURE_TENANT_ID
# AZURE_CLIENT_ID
# AZURE_CLIENT_SECRET
# AZURE_SIGNING_ENDPOINT e.g. https://wus2.codesigning.azure.net/
# AZURE_SIGNING_ACCOUNT mist-code-signing
# AZURE_SIGNING_CERT_PROFILE Mist-Windows-Signing
on:
push:
tags:
- 'v*'
workflow_dispatch:
inputs:
publish:
description: 'Publish to GitHub Releases (otherwise artifact only)'
required: true
default: 'false'
type: choice
options:
- 'false'
- 'true'
permissions:
contents: write
jobs:
build-windows:
runs-on: windows-latest
timeout-minutes: 60
env:
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }}
AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }}
AZURE_SIGNING_CERT_PROFILE: ${{ secrets.AZURE_SIGNING_CERT_PROFILE }}
PUBLISH_INPUT: ${{ github.event.inputs.publish }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Setup Python (for building bundled python-env)
uses: actions/setup-python@v5
with:
python-version: '3.13'
# The signing hook calls `signtool.exe` directly. signtool ships in the
# Windows 10 SDK, preinstalled on windows-latest runners — we just need
# the dlib for Azure Trusted Signing, pulled via NuGet.
- name: Install Microsoft.Trusted.Signing.Client (dlib for signtool)
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
$dlibDir = Join-Path $env:GITHUB_WORKSPACE 'trusted-signing-client'
New-Item -ItemType Directory -Force -Path $dlibDir | Out-Null
nuget install Microsoft.Trusted.Signing.Client -Version 1.0.60 -OutputDirectory $dlibDir -ExcludeVersion
$dlib = Join-Path $dlibDir 'Microsoft.Trusted.Signing.Client\bin\x64\Azure.CodeSigning.Dlib.dll'
if (-not (Test-Path $dlib)) {
Get-ChildItem -Path $dlibDir -Recurse -Filter 'Azure.CodeSigning.Dlib.dll' | ForEach-Object { Write-Host "Found: $($_.FullName)" }
throw "Azure.CodeSigning.Dlib.dll not found after NuGet install"
}
"AZURE_SIGNING_DLIB=$dlib" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
Write-Host "AZURE_SIGNING_DLIB=$dlib"
- name: Locate signtool.exe on the runner
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
$candidates = Get-ChildItem -Path 'C:\Program Files (x86)\Windows Kits\10\bin' -Recurse -Filter 'signtool.exe' -ErrorAction SilentlyContinue `
| Where-Object { $_.FullName -match '\\x64\\signtool\.exe$' } `
| Sort-Object FullName -Descending
if (-not $candidates) { throw "signtool.exe not found on runner" }
$signtool = $candidates[0].FullName
"SIGNTOOL_PATH=$signtool" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
Write-Host "SIGNTOOL_PATH=$signtool"
- name: Build app
shell: pwsh
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
$ErrorActionPreference = 'Stop'
$shouldPublish = ($env:GITHUB_EVENT_NAME -eq 'push') -or `
($env:GITHUB_EVENT_NAME -eq 'workflow_dispatch' -and $env:PUBLISH_INPUT -eq 'true')
if ($shouldPublish) {
Write-Host "Build mode: PUBLISH"
pwsh -NoProfile -File scripts\build-app-win.ps1 -Publish
} else {
Write-Host "Build mode: SIGN (artifact only)"
pwsh -NoProfile -File scripts\build-app-win.ps1 -Sign
}
if ($LASTEXITCODE -ne 0) { throw "build-app-win.ps1 failed ($LASTEXITCODE)" }
- name: Upload artifact (non-publish runs)
if: github.event_name == 'workflow_dispatch' && github.event.inputs.publish != 'true'
uses: actions/upload-artifact@v4
with:
name: openswarm-windows-x64
path: |
electron/dist/*.exe
electron/dist/latest.yml
if-no-files-found: error
retention-days: 14
+2
View File
@@ -1,6 +1,8 @@
.DS_Store
.worktrees
.env
.env.*
!.env*.example
.local-stash/
backend/data/**
!backend/data/outputs/
+5 -4
View File
@@ -5,11 +5,11 @@
"private": true,
"scripts": {
"dev": "next dev --webpack --port 20128",
"build": "NODE_ENV=production next build --webpack",
"start": "NODE_ENV=production next start",
"build": "cross-env NODE_ENV=production next build --webpack",
"start": "cross-env NODE_ENV=production next start",
"dev:bun": "bun --bun next dev --webpack --port 20128",
"build:bun": "NODE_ENV=production bun --bun next build --webpack",
"start:bun": "NODE_ENV=production bun ./.next/standalone/server.js"
"build:bun": "cross-env NODE_ENV=production bun --bun next build --webpack",
"start:bun": "cross-env NODE_ENV=production bun ./.next/standalone/server.js"
},
"dependencies": {
"@monaco-editor/react": "^4.7.0",
@@ -40,6 +40,7 @@
},
"devDependencies": {
"@tailwindcss/postcss": "^4.1.18",
"cross-env": "^7.0.3",
"eslint": "^9",
"eslint-config-next": "16.1.6",
"postcss": "^8.5.6",
@@ -4,7 +4,7 @@ import os
_SKILL_PATH = os.path.join(os.path.dirname(__file__), "view_builder_skill.md")
with open(_SKILL_PATH) as _f:
with open(_SKILL_PATH, encoding="utf-8") as _f:
VIEW_BUILDER_SKILL = _f.read()
VIEW_TEMPLATE_INDEX = """\
+27 -17
View File
@@ -6,6 +6,7 @@ import re
import logging
import secrets
import shutil
import sys
import time
from contextlib import asynccontextmanager
from typing import Any, Optional
@@ -415,24 +416,26 @@ def _resolve_command(command: str) -> str | None:
found = shutil.which(command)
if found:
return found
# Windows binaries need an extension. shutil.which() handles PATHEXT for
# PATH lookups, but we manually scan _extra_bin_dirs below — replicate
# the suffix probing here so `uvx` finds `uvx.exe`, etc.
if sys.platform == "win32":
suffixes = [""] + os.environ.get("PATHEXT", ".COM;.EXE;.BAT;.CMD").lower().split(os.pathsep)
else:
suffixes = [""]
def _probe(directory: str) -> str | None:
for suffix in suffixes:
candidate = os.path.join(directory, command + suffix)
if os.path.isfile(candidate) and os.access(candidate, os.X_OK):
return candidate
return None
for d in _extra_bin_dirs():
candidate = os.path.join(d, command)
if os.path.isfile(candidate) and os.access(candidate, os.X_OK):
return candidate
hit = _probe(d)
if hit:
return hit
# Check bundled uv-bin directory (ships uv/uvx for non-dev users)
_backend = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
_is_packaged = os.environ.get("OPENSWARM_PACKAGED") == "1"
if _is_packaged:
# In packaged app: <resources>/backend/uv-bin/
candidate = os.path.join(_backend, "uv-bin", command)
if os.path.isfile(candidate) and os.access(candidate, os.X_OK):
return candidate
else:
# In dev: backend/uv-bin/
candidate = os.path.join(_backend, "uv-bin", command)
if os.path.isfile(candidate) and os.access(candidate, os.X_OK):
return candidate
return None
return _probe(os.path.join(_backend, "uv-bin"))
def _augmented_path() -> str:
@@ -552,14 +555,21 @@ def derive_mcp_config(tool: ToolDefinition) -> Optional[dict]:
# Point uv/uvx at our bundled Python — avoids macOS CLT popup on fresh Macs
# and avoids downloading Python at runtime
_is_packaged = os.environ.get("OPENSWARM_PACKAGED") == "1"
_is_windows = sys.platform == "win32"
if _is_packaged:
_resources = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
_bundled_python = os.path.join(_resources, "python-env", "bin", "python3")
if _is_windows:
_bundled_python = os.path.join(_resources, "python-env", "python.exe")
else:
_bundled_python = os.path.join(_resources, "python-env", "bin", "python3")
if os.path.exists(_bundled_python):
env.setdefault("UV_PYTHON", _bundled_python)
else:
_backend = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
_venv_python = os.path.join(_backend, ".venv", "bin", "python3")
if _is_windows:
_venv_python = os.path.join(_backend, ".venv", "Scripts", "python.exe")
else:
_venv_python = os.path.join(_backend, ".venv", "bin", "python3")
if os.path.exists(_venv_python):
env.setdefault("UV_PYTHON", _venv_python)
@@ -1,7 +1,6 @@
{
"name": "notionhq-notion-mcp-server",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
@@ -12,5 +11,6 @@
"type": "commonjs",
"dependencies": {
"@notionhq/notion-mcp-server": "^2.2.1"
}
},
"description": ""
}
@@ -1,7 +1,6 @@
{
"name": "softeria-ms-365-mcp-server",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
@@ -12,5 +11,6 @@
"type": "commonjs",
"dependencies": {
"@softeria/ms-365-mcp-server": "^0.79.6"
}
},
"description": ""
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 279 KiB

+95
View File
@@ -0,0 +1,95 @@
// Custom Windows signing hook for electron-builder.
// Uses Azure Trusted Signing via signtool + Microsoft.Trusted.Signing.Client dlib.
//
// Required env vars (set as GitHub Actions secrets and passed through to the job):
// AZURE_TENANT_ID Directory (tenant) ID of the app registration
// AZURE_CLIENT_ID Application (client) ID of the app registration
// AZURE_CLIENT_SECRET Client secret value
// AZURE_SIGNING_ENDPOINT e.g. https://eus.codesigning.azure.net
// AZURE_SIGNING_ACCOUNT Trusted Signing account name (e.g. mist-code-signing)
// AZURE_SIGNING_CERT_PROFILE Certificate profile name (created inside the account)
//
// Optional env vars:
// AZURE_SIGNING_DLIB Absolute path to Azure.CodeSigning.Dlib.dll (default: resolved from AZURE_SIGNING_DLIB_DIR)
// AZURE_SIGNING_DLIB_DIR Directory containing Azure.CodeSigning.Dlib.dll (default: ./Microsoft.Trusted.Signing.Client/bin/x64)
// SIGNTOOL_PATH Absolute path to signtool.exe (default: "signtool", relying on PATH)
// AZURE_TIMESTAMP_URL Timestamp server (default: http://timestamp.acs.microsoft.com)
// CSC_IDENTITY_AUTO_DISCOVERY Set to "false" to skip signing entirely (dev builds)
const fs = require('fs');
const os = require('os');
const path = require('path');
const { execFileSync } = require('child_process');
const REQUIRED_ENV = [
'AZURE_TENANT_ID',
'AZURE_CLIENT_ID',
'AZURE_CLIENT_SECRET',
'AZURE_SIGNING_ENDPOINT',
'AZURE_SIGNING_ACCOUNT',
'AZURE_SIGNING_CERT_PROFILE',
];
function resolveDlib() {
if (process.env.AZURE_SIGNING_DLIB) return process.env.AZURE_SIGNING_DLIB;
const dir = process.env.AZURE_SIGNING_DLIB_DIR
|| path.join(process.cwd(), 'Microsoft.Trusted.Signing.Client', 'bin', 'x64');
return path.join(dir, 'Azure.CodeSigning.Dlib.dll');
}
exports.default = async function signWindows(configuration) {
const targetPath = configuration && configuration.path;
if (!targetPath) {
console.log('[sign-windows] No target path provided — skipping');
return;
}
if (process.env.CSC_IDENTITY_AUTO_DISCOVERY === 'false') {
console.log(`[sign-windows] CSC_IDENTITY_AUTO_DISCOVERY=false — skipping ${targetPath}`);
return;
}
const missing = REQUIRED_ENV.filter((k) => !process.env[k]);
if (missing.length) {
console.log(`[sign-windows] Skipping ${targetPath} — missing env: ${missing.join(', ')}`);
return;
}
const dlib = resolveDlib();
if (!fs.existsSync(dlib)) {
throw new Error(
`[sign-windows] Azure.CodeSigning.Dlib.dll not found at ${dlib}. `
+ `Install the Microsoft.Trusted.Signing.Client NuGet package or set AZURE_SIGNING_DLIB.`,
);
}
const metadata = {
Endpoint: process.env.AZURE_SIGNING_ENDPOINT,
CodeSigningAccountName: process.env.AZURE_SIGNING_ACCOUNT,
CertificateProfileName: process.env.AZURE_SIGNING_CERT_PROFILE,
ExcludeEnvironmentCredential: 'false',
};
const metadataPath = path.join(os.tmpdir(), `ts-metadata-${process.pid}-${Date.now()}.json`);
fs.writeFileSync(metadataPath, JSON.stringify(metadata, null, 2));
const signtool = process.env.SIGNTOOL_PATH || 'signtool.exe';
const timestampUrl = process.env.AZURE_TIMESTAMP_URL || 'http://timestamp.acs.microsoft.com';
const args = [
'sign',
'/v',
'/fd', 'SHA256',
'/tr', timestampUrl,
'/td', 'SHA256',
'/dlib', dlib,
'/dmdf', metadataPath,
targetPath,
];
try {
console.log(`[sign-windows] signtool ${args.join(' ')}`);
execFileSync(signtool, args, { stdio: 'inherit' });
console.log(`[sign-windows] Signed ${targetPath}`);
} finally {
try { fs.unlinkSync(metadataPath); } catch (_) { /* ignore */ }
}
};
+47 -16
View File
@@ -79,7 +79,9 @@ let cachedUpdateStatus = { status: 'idle', info: null, error: null };
const isPackaged = app.isPackaged;
const isDev = process.env.ELECTRON_DEV === '1';
const iconPath = path.join(__dirname, 'build', 'icon.png');
const iconPath = process.platform === 'win32'
? path.join(__dirname, 'build', 'icon.ico')
: path.join(__dirname, 'build', 'icon.png');
/**
* macOS GUI apps launched from Finder/Dock inherit a minimal PATH from launchd
@@ -164,12 +166,20 @@ function getResourcePath(...segments) {
}
function getPythonPath() {
// python-build-standalone layout differs by OS:
// macOS / Linux: <env>/bin/python3
// Windows: <env>\python.exe (no bin/, no python3)
if (isPackaged) {
const envPath = path.join(process.resourcesPath, 'python-env');
if (process.platform === 'win32') {
return path.join(envPath, 'python.exe');
}
return path.join(envPath, 'bin', 'python3');
}
const venvPython = path.join(__dirname, '..', 'backend', '.venv', 'bin', 'python3');
return venvPython;
if (process.platform === 'win32') {
return path.join(__dirname, '..', 'backend', '.venv', 'Scripts', 'python.exe');
}
return path.join(__dirname, '..', 'backend', '.venv', 'bin', 'python3');
}
function waitForBackend(port, timeoutMs = 60000) {
@@ -212,15 +222,19 @@ async function startBackend() {
OPENSWARM_PORT: String(backendPort),
OPENSWARM_ELECTRON_PATH: process.execPath,
PYTHONDONTWRITEBYTECODE: '1',
// PEP 540 UTF-8 mode: makes open() default to UTF-8 on Windows where
// the locale is otherwise cp1252. Many backend modules read UTF-8
// .md / .json files without an explicit encoding= argument.
PYTHONUTF8: '1',
};
if (isPackaged) {
const pythonEnvSitePackages = path.join(
process.resourcesPath, 'python-env', 'lib',
'python3.13', 'site-packages'
);
// site-packages location differs by OS — Windows has no lib/python3.13/.
const pythonEnvSitePackages = process.platform === 'win32'
? path.join(process.resourcesPath, 'python-env', 'Lib', 'site-packages')
: path.join(process.resourcesPath, 'python-env', 'lib', 'python3.13', 'site-packages');
const debuggerDir = getResourcePath('debugger');
env.PYTHONPATH = [projectRoot, debuggerDir, pythonEnvSitePackages].join(':');
env.PYTHONPATH = [projectRoot, debuggerDir, pythonEnvSitePackages].join(path.delimiter);
}
console.log(`Starting backend: ${pythonPath} on port ${backendPort}`);
@@ -369,12 +383,27 @@ function setupAutoUpdater() {
function killBackend() {
if (backendProcess) {
console.log('Killing backend process...');
backendProcess.kill('SIGTERM');
setTimeout(() => {
if (backendProcess && !backendProcess.killed) {
backendProcess.kill('SIGKILL');
if (process.platform === 'win32') {
// Windows: Node's child.kill() only terminates the direct child, leaving
// grandchildren (e.g. the 9router node process the Python backend
// spawned) as orphans. Use `taskkill /T /F` to walk the process tree.
try {
require('child_process').execFileSync(
'taskkill', ['/PID', String(backendProcess.pid), '/T', '/F'],
{ stdio: 'ignore' },
);
} catch (_) {
// taskkill failed (process may have already exited) — fall back to kill().
try { backendProcess.kill(); } catch (_) {}
}
}, 3000);
} else {
backendProcess.kill('SIGTERM');
setTimeout(() => {
if (backendProcess && !backendProcess.killed) {
backendProcess.kill('SIGKILL');
}
}, 3000);
}
backendProcess = null;
}
}
@@ -497,9 +526,11 @@ app.on('web-contents-created', (_event, contents) => {
mainWindow &&
contents !== mainWindow.webContents
) {
const OAUTH_POPUP_UA =
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 ' +
'(KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36';
const OAUTH_POPUP_UA = process.platform === 'win32'
? 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ' +
'(KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36'
: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 ' +
'(KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36';
contents.setUserAgent(OAUTH_POPUP_UA);
}
+22 -2
View File
@@ -1,12 +1,12 @@
{
"name": "openswarm",
"version": "1.0.23",
"version": "1.0.24",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openswarm",
"version": "1.0.23",
"version": "1.0.24",
"hasInstallScript": true,
"dependencies": {
"electron-updater": "^6.3.0",
@@ -14,6 +14,7 @@
},
"devDependencies": {
"@electron/notarize": "^3.1.1",
"cross-env": "^7.0.3",
"electron": "castlabs/electron-releases#v40.7.0+wvcus",
"electron-builder": "^25.1.0"
}
@@ -1572,6 +1573,25 @@
"node": ">= 10"
}
},
"node_modules/cross-env": {
"version": "7.0.3",
"resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz",
"integrity": "sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==",
"dev": true,
"license": "MIT",
"dependencies": {
"cross-spawn": "^7.0.1"
},
"bin": {
"cross-env": "src/bin/cross-env.js",
"cross-env-shell": "src/bin/cross-env-shell.js"
},
"engines": {
"node": ">=10.14",
"npm": ">=6",
"yarn": ">=1"
}
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"dev": true,
+29 -3
View File
@@ -5,11 +5,13 @@
"main": "main.js",
"scripts": {
"start": "electron .",
"dev": "ELECTRON_DEV=1 electron .",
"postinstall": "bash scripts/sign-vmp.sh",
"sign-vmp": "bash scripts/sign-vmp.sh",
"dev": "cross-env ELECTRON_DEV=1 electron .",
"postinstall": "node scripts/sign-vmp.js",
"sign-vmp": "node scripts/sign-vmp.js",
"dist": "electron-builder --mac --publish never",
"dist:publish": "electron-builder --mac --publish always",
"dist:win": "electron-builder --win --x64 --publish never",
"dist:win:publish": "electron-builder --win --x64 --publish always",
"dist:all": "electron-builder --mac --win --linux"
},
"dependencies": {
@@ -18,6 +20,7 @@
},
"devDependencies": {
"@electron/notarize": "^3.1.1",
"cross-env": "^7.0.3",
"electron": "castlabs/electron-releases#v40.7.0+wvcus",
"electron-builder": "^25.1.0"
},
@@ -58,6 +61,29 @@
}
]
},
"win": {
"icon": "build/icon.ico",
"target": [
{
"target": "nsis",
"arch": ["x64"]
}
],
"artifactName": "OpenSwarm-Setup-${version}-${arch}.${ext}",
"sign": "./build/sign-windows.js",
"signingHashAlgorithms": ["sha256"],
"signDlls": false
},
"nsis": {
"oneClick": true,
"perMachine": false,
"allowToChangeInstallationDirectory": false,
"createDesktopShortcut": true,
"createStartMenuShortcut": true,
"shortcutName": "OpenSwarm",
"deleteAppDataOnUninstall": false,
"artifactName": "OpenSwarm-Setup-${version}-${arch}.${ext}"
},
"extraResources": [
{
"from": "build-staging/frontend",
+14
View File
@@ -0,0 +1,14 @@
// Cross-platform wrapper around sign-vmp.sh.
// VMP signing (CastLabs Widevine) only runs on macOS. On Windows/Linux this
// is a no-op so `npm install` doesn't blow up trying to invoke bash.
const { spawnSync } = require('child_process');
const path = require('path');
if (process.platform !== 'darwin') {
console.log(`[vmp] Skipping VMP signing on ${process.platform} (macOS-only)`);
process.exit(0);
}
const script = path.join(__dirname, 'sign-vmp.sh');
const result = spawnSync('bash', [script], { stdio: 'inherit' });
process.exit(result.status ?? 0);
@@ -135,9 +135,33 @@ const OnboardingModal: React.FC = () => {
const [referralSourceOther, setReferralSourceOther] = useState<string>('');
const [connecting, setConnecting] = useState<string | null>(null);
const [nineRouterReady, setNineRouterReady] = useState<boolean | null>(null);
// Providers the backend says are already authenticated. Used to show a
// "Connected" state on rows where the user has already linked the account.
// Without this the row reverts to "Connect ->" after a 30s OAuth timeout
// even when the backend actually completed the link.
const [connectedProviders, setConnectedProviders] = useState<Set<string>>(new Set());
const pollTimerRef = useRef<any>(null);
const msgHandlerRef = useRef<any>(null);
// Poll subscription status while the connect step is showing so the row
// labels reflect any post-timeout backend success and any prior connections.
useEffect(() => {
if (step !== 'connect' || !open) return;
let cancelled = false;
const refresh = async () => {
try {
const r = await fetch(`${API_BASE}/agents/subscriptions/status`);
const d = await r.json();
if (cancelled) return;
const conns = d?.providers?.connections || [];
setConnectedProviders(new Set(conns.filter((p: any) => p.isActive).map((p: any) => p.provider)));
} catch {}
};
refresh();
const id = setInterval(refresh, 4000);
return () => { cancelled = true; clearInterval(id); };
}, [step, open]);
// Poll for 9Router readiness (it may still be starting when onboarding shows)
useEffect(() => {
let attempts = 0;
+261
View File
@@ -0,0 +1,261 @@
# Master build script for the OpenSwarm desktop app on Windows.
#
# Usage:
# pwsh scripts\build-app-win.ps1 Local dev build (unsigned)
# pwsh scripts\build-app-win.ps1 -Sign Signed build (no publish)
# pwsh scripts\build-app-win.ps1 -Publish Production build (sign + publish to GitHub Releases)
#
# Reads .env.windows (gitignored) for Azure Trusted Signing + GH_TOKEN if -Sign or -Publish.
[CmdletBinding()]
param(
[switch]$Sign,
[switch]$Publish
)
$ErrorActionPreference = 'Stop'
if ($Publish) { $Sign = $true }
$ScriptDir = Split-Path -Parent $PSCommandPath
$ProjectRoot = Split-Path -Parent $ScriptDir
# --- Load .env.windows if present ---
$EnvFile = Join-Path $ProjectRoot '.env.windows'
if (Test-Path $EnvFile) {
Get-Content $EnvFile | ForEach-Object {
$line = $_.Trim()
if ($line -and -not $line.StartsWith('#') -and $line.Contains('=')) {
$idx = $line.IndexOf('=')
$name = $line.Substring(0, $idx).Trim()
$value = $line.Substring($idx + 1).Trim()
if ($value.StartsWith('"') -and $value.EndsWith('"')) {
$value = $value.Substring(1, $value.Length - 2)
}
Set-Item -Path "Env:$name" -Value $value
}
}
}
Write-Host "========================================"
Write-Host " OpenSwarm Desktop App Builder (Windows)"
if ($Publish) { Write-Host " Mode: PRODUCTION (sign + publish to GitHub Releases)" }
elseif ($Sign) { Write-Host " Mode: SIGNED (sign, no publish)" }
else { Write-Host " Mode: LOCAL (unsigned)" }
Write-Host "========================================"
Write-Host ""
# --- Required env validation ---
if ($Sign) {
$required = @(
'AZURE_TENANT_ID','AZURE_CLIENT_ID','AZURE_CLIENT_SECRET',
'AZURE_SIGNING_ENDPOINT','AZURE_SIGNING_ACCOUNT','AZURE_SIGNING_CERT_PROFILE'
)
if ($Publish) { $required += 'GH_TOKEN' }
$missing = $required | Where-Object { -not [Environment]::GetEnvironmentVariable($_) }
if ($missing.Count -gt 0) {
Write-Host "ERROR: Missing required environment variables:" -ForegroundColor Red
$missing | ForEach-Object { Write-Host " - $_" }
Write-Host "Copy .env.windows.example to .env.windows and fill in values."
exit 1
}
}
# --- Step 0: Bundled uv/uvx (Windows zip) ---
$UvBinDir = Join-Path $ProjectRoot 'backend\uv-bin'
if (-not (Test-Path (Join-Path $UvBinDir 'uvx.exe'))) {
Write-Host "[0] Downloading uv/uvx for Windows..."
New-Item -ItemType Directory -Force -Path $UvBinDir | Out-Null
$UvUrl = 'https://github.com/astral-sh/uv/releases/latest/download/uv-x86_64-pc-windows-msvc.zip'
$TmpZip = Join-Path $env:TEMP "uv-win-$([guid]::NewGuid()).zip"
$TmpExtract = Join-Path $env:TEMP "uv-win-extract-$([guid]::NewGuid())"
try {
Invoke-WebRequest -Uri $UvUrl -OutFile $TmpZip -UseBasicParsing
Expand-Archive -Path $TmpZip -DestinationPath $TmpExtract -Force
Get-ChildItem -Path $TmpExtract -Recurse -Filter 'uv.exe' | Select-Object -First 1 | ForEach-Object { Copy-Item $_.FullName (Join-Path $UvBinDir 'uv.exe') -Force }
Get-ChildItem -Path $TmpExtract -Recurse -Filter 'uvx.exe' | Select-Object -First 1 | ForEach-Object { Copy-Item $_.FullName (Join-Path $UvBinDir 'uvx.exe') -Force }
Write-Host "uv/uvx downloaded and bundled."
} finally {
Remove-Item -Force $TmpZip -ErrorAction SilentlyContinue
Remove-Item -Recurse -Force $TmpExtract -ErrorAction SilentlyContinue
}
} else {
Write-Host "[0] uv/uvx binaries already present."
}
Write-Host ""
# --- Step 0b: Bundle reddit-mcp-buddy via esbuild ---
$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..."
$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."
} 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."
return
}
Write-Host "[0c] Installing $PackageName..."
New-Item -ItemType Directory -Force -Path $TargetDir | Out-Null
Push-Location $TargetDir
try {
& npm init -y *>$null
& npm install $PackageName --silent 2>$null
if ($LASTEXITCODE -ne 0) { throw "$PackageName install failed" }
Write-Host "$PackageName installed."
} finally {
Pop-Location
}
}
Install-NpmServer 'softeria-ms-365-mcp-server' '@softeria/ms-365-mcp-server'
Install-NpmServer 'notionhq-notion-mcp-server' '@notionhq/notion-mcp-server'
Write-Host ""
# --- Step 1: Frontend build ---
Write-Host "[1/5] Building frontend..."
Push-Location (Join-Path $ProjectRoot 'frontend')
try {
& npm install
if ($LASTEXITCODE -ne 0) { throw "npm install (frontend) failed" }
& npm run build
if ($LASTEXITCODE -ne 0) { throw "frontend build failed" }
} finally { Pop-Location }
if (-not (Test-Path (Join-Path $ProjectRoot 'frontend\dist\index.html'))) {
throw "Frontend build failed - dist\index.html not found"
}
Write-Host "Frontend build complete."
Write-Host ""
# --- Step 2: Python env ---
$PythonEnv = Join-Path $ProjectRoot 'electron\python-env'
$PythonExe = Join-Path $PythonEnv 'python.exe'
if ((Test-Path $PythonExe) -and -not $env:OPENSWARM_REBUILD_PYTHON) {
Write-Host "[2/5] Python environment already present at $PythonEnv (set `$env:OPENSWARM_REBUILD_PYTHON='1' to force rebuild)."
} else {
Write-Host "[2/5] Building Python environment..."
& (Join-Path $ScriptDir 'build-python-env-win.ps1')
if ($LASTEXITCODE -ne 0) { throw "Python env build failed" }
}
if (-not (Test-Path (Join-Path $ProjectRoot 'electron\python-env'))) {
throw "Python environment not found at electron\python-env\"
}
Write-Host "Python environment ready."
Write-Host ""
# --- Step 3: 9Router build ---
Write-Host "[3/5] Building 9Router..."
Push-Location (Join-Path $ProjectRoot '9router')
try {
& npm install
if ($LASTEXITCODE -ne 0) { throw "npm install (9router) failed" }
& npm run build
if ($LASTEXITCODE -ne 0) { throw "9router build failed" }
} finally { Pop-Location }
$Standalone = Join-Path $ProjectRoot '9router\.next\standalone'
if (-not (Test-Path $Standalone)) { throw "9Router build failed - .next\standalone not found" }
$NextStatic = Join-Path $ProjectRoot '9router\.next\static'
if (Test-Path $NextStatic) {
$StandaloneStatic = Join-Path $Standalone '.next\static'
New-Item -ItemType Directory -Force -Path (Split-Path $StandaloneStatic -Parent) | Out-Null
Copy-Item -Recurse -Force $NextStatic $StandaloneStatic
}
$NextPublic = Join-Path $ProjectRoot '9router\public'
if (Test-Path $NextPublic) {
Copy-Item -Recurse -Force $NextPublic (Join-Path $Standalone 'public')
}
Write-Host "9Router build complete."
Write-Host ""
# --- Step 4: Snapshot source dirs into electron\build-staging\ ---
Write-Host "[4/5] Snapshotting source directories..."
$Staging = Join-Path $ProjectRoot 'electron\build-staging'
if (Test-Path $Staging) { Remove-Item -Recurse -Force $Staging }
New-Item -ItemType Directory -Force -Path $Staging | Out-Null
function Copy-Excluded($Source, $Dest, $Exclude) {
# robocopy: built-in, fast, handles long paths.
$args = @($Source, $Dest, '/E', '/NJH', '/NJS', '/NDL', '/NFL', '/NP', '/MT:8')
foreach ($d in $Exclude.Dirs) { $args += '/XD'; $args += $d }
foreach ($f in $Exclude.Files) { $args += '/XF'; $args += $f }
& robocopy @args | Out-Null
# robocopy exit codes 07 are success
if ($LASTEXITCODE -ge 8) { throw "robocopy failed ($Source -> $Dest, exit $LASTEXITCODE)" }
$global:LASTEXITCODE = 0
}
Copy-Excluded `
(Join-Path $ProjectRoot 'backend') (Join-Path $Staging 'backend') `
@{ Dirs = @('__pycache__','.venv','tools'); Files = @('*.pyc') }
New-Item -ItemType Directory -Force -Path (Join-Path $Staging 'backend\data\tools') | Out-Null
Copy-Excluded `
(Join-Path $ProjectRoot 'debugger') (Join-Path $Staging 'debugger') `
@{ Dirs = @('__pycache__','.venv','node_modules'); Files = @('*.pyc') }
Copy-Item -Recurse -Force (Join-Path $ProjectRoot 'frontend\dist\*') (New-Item -ItemType Directory -Force -Path (Join-Path $Staging 'frontend')).FullName
# 9Router standalone -> staging\9router
Copy-Excluded $Standalone (Join-Path $Staging '9router') @{ Dirs = @(); Files = @() }
if (Test-Path $NextStatic) {
$TargetStatic = Join-Path $Staging '9router\.next\static'
New-Item -ItemType Directory -Force -Path (Split-Path $TargetStatic -Parent) | Out-Null
Copy-Item -Recurse -Force $NextStatic $TargetStatic
}
Write-Host ""
Write-Host "========================================" -BackgroundColor Green -ForegroundColor White
Write-Host " SOURCE SNAPSHOT COMPLETE " -BackgroundColor Green -ForegroundColor White
Write-Host " Safe to modify your codebase now. " -BackgroundColor Green -ForegroundColor White
Write-Host "========================================" -BackgroundColor Green -ForegroundColor White
Write-Host ""
# --- Step 5: Package with electron-builder ---
Write-Host "[5/5] Packaging with electron-builder..."
Push-Location (Join-Path $ProjectRoot 'electron')
try {
& npm install
if ($LASTEXITCODE -ne 0) { throw "npm install (electron) failed" }
if (-not $Sign) {
$env:CSC_IDENTITY_AUTO_DISCOVERY = 'false'
}
if ($Publish) {
& npx electron-builder --win --x64 --publish always
} else {
& npx electron-builder --win --x64 --publish never
}
if ($LASTEXITCODE -ne 0) { throw "electron-builder failed" }
} finally { Pop-Location }
Remove-Item -Recurse -Force $Staging -ErrorAction SilentlyContinue
Write-Host ""
Write-Host "========================================"
Write-Host " Build Complete!"
Write-Host "========================================"
Write-Host ""
Write-Host "Output files:"
Get-ChildItem -Path (Join-Path $ProjectRoot 'electron\dist') -Filter '*.exe' -ErrorAction SilentlyContinue | Format-Table Name, Length, LastWriteTime
Get-ChildItem -Path (Join-Path $ProjectRoot 'electron\dist') -Filter '*.zip' -ErrorAction SilentlyContinue | Format-Table Name, Length, LastWriteTime
+100
View File
@@ -0,0 +1,100 @@
# Build an embedded Windows Python environment for the Electron app.
#
# Downloads a standalone CPython build for Windows from python-build-standalone,
# installs backend deps, and leaves it under electron\python-env\.
# Bundled into the .exe installer by electron-builder via extraResources.
$ErrorActionPreference = 'Stop'
$ScriptDir = Split-Path -Parent $PSCommandPath
$ProjectRoot = Split-Path -Parent $ScriptDir
$ElectronDir = Join-Path $ProjectRoot 'electron'
$PythonEnvDir = Join-Path $ElectronDir 'python-env'
$PythonVersion = '3.13'
$PythonFullVersion = '3.13.2'
$ReleaseTag = '20250212'
$PlatformTag = 'x86_64-pc-windows-msvc-shared'
$Tarball = "cpython-$PythonFullVersion+$ReleaseTag-$PlatformTag-install_only_stripped.tar.gz"
$DownloadUrl = "https://github.com/indygreg/python-build-standalone/releases/download/$ReleaseTag/$Tarball"
Write-Host "=== Building Windows Python Environment ==="
Write-Host "Architecture: x64 ($PlatformTag)"
Write-Host "Python: $PythonFullVersion"
if (Test-Path $PythonEnvDir) {
Write-Host "Removing old python-env..."
Remove-Item -Recurse -Force $PythonEnvDir
}
$TempDir = New-Item -ItemType Directory -Path (Join-Path $env:TEMP "openswarm-pyenv-$([guid]::NewGuid())") -Force
try {
$ArchivePath = Join-Path $TempDir.FullName 'python.tar.gz'
Write-Host "Downloading standalone Python..."
Write-Host "URL: $DownloadUrl"
Invoke-WebRequest -Uri $DownloadUrl -OutFile $ArchivePath -UseBasicParsing
Write-Host "Extracting..."
# tar is built-in on Windows 10+ (bsdtar) and handles .tar.gz natively.
& tar -xzf $ArchivePath -C $TempDir.FullName
if ($LASTEXITCODE -ne 0) { throw "tar extract failed" }
$Extracted = Join-Path $TempDir.FullName 'python'
if (-not (Test-Path $Extracted)) {
Get-ChildItem $TempDir.FullName | Format-Table | Out-String | Write-Host
throw "Expected extracted directory at $Extracted"
}
Move-Item -Path $Extracted -Destination $PythonEnvDir
Write-Host "Python installed to $PythonEnvDir"
} finally {
if (Test-Path $TempDir.FullName) {
Remove-Item -Recurse -Force $TempDir.FullName
}
}
$PythonBin = Join-Path $PythonEnvDir 'python.exe'
if (-not (Test-Path $PythonBin)) { throw "python.exe not found at $PythonBin" }
Write-Host "Python binary: $PythonBin"
& $PythonBin --version
# Ensure pip is present
& $PythonBin -m pip --version 2>$null
if ($LASTEXITCODE -ne 0) {
Write-Host "Installing pip..."
& $PythonBin -m ensurepip --upgrade
if ($LASTEXITCODE -ne 0) { throw "ensurepip failed" }
}
Write-Host "Installing backend dependencies..."
& $PythonBin -m pip install --upgrade pip
if ($LASTEXITCODE -ne 0) { throw "pip upgrade failed" }
& $PythonBin -m pip install -r (Join-Path $ProjectRoot 'backend\requirements.txt')
if ($LASTEXITCODE -ne 0) { throw "pip install requirements failed" }
Write-Host "Installing debugger module..."
& $PythonBin -m pip install (Join-Path $ProjectRoot 'debugger')
if ($LASTEXITCODE -ne 0) { throw "pip install debugger failed" }
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
Write-Host "Cleaning up..."
Get-ChildItem -Path $PythonEnvDir -Recurse -Force -Directory `
| Where-Object { $_.Name -in @('__pycache__','tests','test') } `
| Remove-Item -Recurse -Force -ErrorAction SilentlyContinue
Get-ChildItem -Path $PythonEnvDir -Recurse -Force -Filter '*.pyc' `
| Remove-Item -Force -ErrorAction SilentlyContinue
$Size = (Get-ChildItem -Path $PythonEnvDir -Recurse -File `
| Measure-Object -Property Length -Sum).Sum
$SizeMB = [math]::Round($Size / 1MB, 1)
Write-Host ""
Write-Host "=== Python Environment Ready ==="
Write-Host "Location: $PythonEnvDir"
Write-Host ("Size: {0} MB" -f $SizeMB)
Write-Host ""