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 00000000..18621b6e Binary files /dev/null and b/electron/build/icon.ico differ diff --git a/electron/build/sign-windows.js b/electron/build/sign-windows.js new file mode 100644 index 00000000..8ac77fca --- /dev/null +++ b/electron/build/sign-windows.js @@ -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 */ } + } +}; 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 ""