mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-12 04:37:44 +02:00
[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:
@@ -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
|
||||
@@ -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 ""
|
||||
Reference in New Issue
Block a user