From 9ff07443db9c7f6419db6600fb60c6da71740ff2 Mon Sep 17 00:00:00 2001 From: Eric Date: Tue, 21 Apr 2026 18:15:49 -0700 Subject: [PATCH] [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). --- .env.windows.example | 18 ++ .github/workflows/release-windows.yml | 123 +++++++++ .gitignore | 2 + 9router/package.json | 9 +- .../apps/outputs/view_builder_templates.py | 2 +- backend/apps/tools_lib/tools_lib.py | 44 +-- .../notionhq-notion-mcp-server/package.json | 4 +- .../softeria-ms-365-mcp-server/package.json | 4 +- electron/build/icon.ico | Bin 0 -> 285478 bytes electron/build/sign-windows.js | 95 +++++++ electron/main.js | 63 +++-- electron/package-lock.json | 24 +- electron/package.json | 32 ++- electron/scripts/sign-vmp.js | 14 + .../src/app/components/OnboardingModal.tsx | 24 ++ scripts/build-app-win.ps1 | 261 ++++++++++++++++++ scripts/build-python-env-win.ps1 | 100 +++++++ 17 files changed, 772 insertions(+), 47 deletions(-) create mode 100644 .env.windows.example create mode 100644 .github/workflows/release-windows.yml create mode 100644 electron/build/icon.ico create mode 100644 electron/build/sign-windows.js create mode 100644 electron/scripts/sign-vmp.js create mode 100644 scripts/build-app-win.ps1 create mode 100644 scripts/build-python-env-win.ps1 diff --git a/.env.windows.example b/.env.windows.example new file mode 100644 index 00000000..9a2e19c8 --- /dev/null +++ b/.env.windows.example @@ -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= diff --git a/.github/workflows/release-windows.yml b/.github/workflows/release-windows.yml new file mode 100644 index 00000000..7dd1d1ae --- /dev/null +++ b/.github/workflows/release-windows.yml @@ -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 diff --git a/.gitignore b/.gitignore index 2265eca5..bf70e327 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,8 @@ .DS_Store .worktrees .env +.env.* +!.env*.example .local-stash/ backend/data/** !backend/data/outputs/ diff --git a/9router/package.json b/9router/package.json index 2e3238d5..0c86d03f 100644 --- a/9router/package.json +++ b/9router/package.json @@ -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", diff --git a/backend/apps/outputs/view_builder_templates.py b/backend/apps/outputs/view_builder_templates.py index 0aada357..8d54a0f3 100644 --- a/backend/apps/outputs/view_builder_templates.py +++ b/backend/apps/outputs/view_builder_templates.py @@ -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 = """\ diff --git a/backend/apps/tools_lib/tools_lib.py b/backend/apps/tools_lib/tools_lib.py index b8063024..aac206ec 100644 --- a/backend/apps/tools_lib/tools_lib.py +++ b/backend/apps/tools_lib/tools_lib.py @@ -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: /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) diff --git a/backend/npm-servers/notionhq-notion-mcp-server/package.json b/backend/npm-servers/notionhq-notion-mcp-server/package.json index f3463e9b..d1b7e7d5 100644 --- a/backend/npm-servers/notionhq-notion-mcp-server/package.json +++ b/backend/npm-servers/notionhq-notion-mcp-server/package.json @@ -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": "" } diff --git a/backend/npm-servers/softeria-ms-365-mcp-server/package.json b/backend/npm-servers/softeria-ms-365-mcp-server/package.json index 23e84819..b2f7dd7f 100644 --- a/backend/npm-servers/softeria-ms-365-mcp-server/package.json +++ b/backend/npm-servers/softeria-ms-365-mcp-server/package.json @@ -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": "" } diff --git a/electron/build/icon.ico b/electron/build/icon.ico new file mode 100644 index 0000000000000000000000000000000000000000..18621b6e6592f90ceb062051215bb58077df42bc GIT binary patch literal 285478 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 caQ0#JvVDXLnQt2(&SPo>jDQg^0y81-4Vxt !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 */ } + } +}; diff --git a/electron/main.js b/electron/main.js index 32d013ac..65b376ab 100644 --- a/electron/main.js +++ b/electron/main.js @@ -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: /bin/python3 + // Windows: \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); } diff --git a/electron/package-lock.json b/electron/package-lock.json index 8c15b62f..46f6003f 100644 --- a/electron/package-lock.json +++ b/electron/package-lock.json @@ -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, diff --git a/electron/package.json b/electron/package.json index b4ccb274..3fa33354 100644 --- a/electron/package.json +++ b/electron/package.json @@ -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", diff --git a/electron/scripts/sign-vmp.js b/electron/scripts/sign-vmp.js new file mode 100644 index 00000000..086f4b3e --- /dev/null +++ b/electron/scripts/sign-vmp.js @@ -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); diff --git a/frontend/src/app/components/OnboardingModal.tsx b/frontend/src/app/components/OnboardingModal.tsx index 0e628fe1..81244d2a 100644 --- a/frontend/src/app/components/OnboardingModal.tsx +++ b/frontend/src/app/components/OnboardingModal.tsx @@ -135,9 +135,33 @@ const OnboardingModal: React.FC = () => { const [referralSourceOther, setReferralSourceOther] = useState(''); const [connecting, setConnecting] = useState(null); const [nineRouterReady, setNineRouterReady] = useState(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>(new Set()); const pollTimerRef = useRef(null); const msgHandlerRef = useRef(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; diff --git a/scripts/build-app-win.ps1 b/scripts/build-app-win.ps1 new file mode 100644 index 00000000..8aa66733 --- /dev/null +++ b/scripts/build-app-win.ps1 @@ -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 0–7 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 diff --git a/scripts/build-python-env-win.ps1 b/scripts/build-python-env-win.ps1 new file mode 100644 index 00000000..a33539ab --- /dev/null +++ b/scripts/build-python-env-win.ps1 @@ -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 ""