Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ jobs:
- name: Electron Builder (unsigned)
run: npx electron-builder --mac dir --publish never
env:
CSC_IDENTITY_AUTO_DISCOVERY: "false"
CSC_IDENTITY_AUTO_DISCOVERY: 'false'

- name: Upload macOS artifact
uses: actions/upload-artifact@v7
Expand Down Expand Up @@ -158,13 +158,15 @@ jobs:
- name: Electron Builder
run: npx electron-builder --win nsis --publish never
env:
CSC_IDENTITY_AUTO_DISCOVERY: "false"
CSC_IDENTITY_AUTO_DISCOVERY: 'false'

- name: Upload Windows artifact
uses: actions/upload-artifact@v7
with:
name: open-cowork-windows
path: |
release/*.exe
release/*.cmd
release/*.ps1
retention-days: 7
if-no-files-found: warn
4 changes: 3 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ jobs:
- name: Electron Builder (unsigned)
run: npx electron-builder --mac dmg --publish never
env:
CSC_IDENTITY_AUTO_DISCOVERY: "false"
CSC_IDENTITY_AUTO_DISCOVERY: 'false'

- name: Upload to GitHub Release
uses: softprops/action-gh-release@v2
Expand Down Expand Up @@ -111,4 +111,6 @@ jobs:
with:
files: |
release/*.exe
release/*.cmd
release/*.ps1
draft: true
16 changes: 3 additions & 13 deletions electron-builder.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ directories:
buildResources: resources

npmRebuild: true
beforeBuild: ./scripts/stage-mcp-resources.js
afterPack: ./scripts/after-pack.js
afterAllArtifactBuild: ./scripts/compress-dmg.js

Expand Down Expand Up @@ -45,14 +46,9 @@ files:
- '!**/.eslintrc*'
- '!**/tsconfig.json'

# MCP servers (compiled JavaScript files, cross-platform)
# NOTE: Platform-specific extraResources OVERRIDE this global section,
# so dist-mcp must also be listed in each platform's extraResources.
extraResources:
- from: dist-mcp
to: mcp

# Skills are extracted via extraResources (per-platform) to avoid asar symlink issues.
# MCP servers are copied in afterPack from dist-mcp-stage to avoid Windows EBUSY
# failures while electron-builder is copying extraResources into win-unpacked.
# Sharp native modules must also be unpacked for image processing.
# Note: MCP servers are bundled with esbuild (all deps inlined), so no need to unpack MCP SDK
asarUnpack:
Expand All @@ -73,8 +69,6 @@ win:
artifactName: ${productName}-${version}-win-${arch}.${ext}
signAndEditExecutable: false
extraResources:
- from: dist-mcp
to: mcp
- from: resources/node/win32-x64
to: node
- from: dist-wsl-agent
Expand All @@ -99,8 +93,6 @@ mac:
NSScreenCaptureUsageDescription: 'Open Cowork needs screen recording access for GUI automation.'
NSAccessibilityUsageDescription: 'Open Cowork needs accessibility access for GUI automation.'
extraResources:
- from: dist-mcp
to: mcp
- from: resources/node/darwin-${arch}
to: node
# Bundled Python runtime + site-packages for GUI automation helpers (Pillow, pyobjc/Quartz)
Expand All @@ -122,8 +114,6 @@ linux:
- x64
artifactName: ${productName}-${version}-linux-${arch}.${ext}
extraResources:
- from: dist-mcp
to: mcp
- from: resources/node/linux-x64
to: node
- from: resources/python/linux-${arch}
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
"bench:api": "node scripts/bench-api.mjs",
"rebuild": "node -e \"const {execSync}=require('node:child_process'); const v=require('electron/package.json').version; execSync('npm rebuild better-sqlite3 --runtime=electron --target='+v+' --disturl=https://electronjs.org/headers',{stdio:'inherit'})\"",
"typecheck": "tsc --noEmit",
"clean": "rimraf dist dist-electron dist-mcp dist-wsl-agent dist-lima-agent release",
"clean": "rimraf dist dist-electron dist-mcp dist-mcp-stage dist-wsl-agent dist-lima-agent release",
"deploy:local": "bash scripts/deploy-local.sh"
},
"dependencies": {
Expand Down
49 changes: 48 additions & 1 deletion resources/installer.nsh
Original file line number Diff line number Diff line change
@@ -1,9 +1,56 @@
Var OpenCoworkCleanupDir
Var OpenCoworkCleanupCmd

Function OpenCoworkPrepareLegacyCleanupTool
StrCpy $OpenCoworkCleanupDir "$TEMP\Open-Cowork-Legacy-Cleanup"
StrCpy $OpenCoworkCleanupCmd ""

CreateDirectory "$OpenCoworkCleanupDir"
IfErrors cleanup_failed 0

ClearErrors
File "/oname=$OpenCoworkCleanupDir\Open-Cowork-Legacy-Cleanup.cmd" "${BUILD_RESOURCES_DIR}\windows\Open-Cowork-Legacy-Cleanup.cmd"
IfErrors cleanup_failed 0

ClearErrors
File "/oname=$OpenCoworkCleanupDir\Open-Cowork-Legacy-Cleanup.ps1" "${BUILD_RESOURCES_DIR}\windows\Open-Cowork-Legacy-Cleanup.ps1"
IfErrors cleanup_failed 0

IfFileExists "$OpenCoworkCleanupDir\Open-Cowork-Legacy-Cleanup.cmd" 0 cleanup_failed
StrCpy $OpenCoworkCleanupCmd "$OpenCoworkCleanupDir\Open-Cowork-Legacy-Cleanup.cmd"
DetailPrint `Prepared embedded legacy cleanup helper: $OpenCoworkCleanupCmd`
Return

cleanup_failed:
DetailPrint `Could not prepare embedded legacy cleanup helper in $OpenCoworkCleanupDir`
StrCpy $OpenCoworkCleanupCmd ""
FunctionEnd

Function OpenCoworkShowLegacyUninstallHelp
Exch $0
DetailPrint `Legacy Open Cowork uninstall failed: $0`

Call OpenCoworkPrepareLegacyCleanupTool
StrCmp $OpenCoworkCleanupCmd "" check_external_cleanup_tool embedded_cleanup_tool

embedded_cleanup_tool:
MessageBox MB_YESNO|MB_ICONEXCLAMATION "Open Cowork could not remove the previously installed version.$\r$\n$\r$\nThis usually means the legacy Windows uninstaller is damaged.$\r$\n$\r$\nThe installer has extracted an embedded cleanup tool here:$\r$\n$OpenCoworkCleanupCmd$\r$\n$\r$\nSelect Yes to launch it now. Select No to close this installer and run it yourself later.$\r$\n$\r$\nThe cleanup tool may request administrator approval if machine-wide leftovers are present.$\r$\nAdd -RemoveAppData only if you also want to clear local settings." IDYES launch_embedded_cleanup
SetErrorLevel 2
Quit

launch_embedded_cleanup:
ExecShell "open" "$OpenCoworkCleanupCmd"
SetErrorLevel 2
Quit

check_external_cleanup_tool:
IfFileExists "$EXEDIR\Open-Cowork-Legacy-Cleanup.cmd" 0 no_cleanup_tool
MessageBox MB_OK|MB_ICONEXCLAMATION "Open Cowork could not remove the previously installed version.$\r$\n$\r$\nThis usually means the legacy Windows uninstaller is damaged.$\r$\n$\r$\nNext steps:$\r$\n1. Close all Open Cowork windows.$\r$\n2. Run:$\r$\n$EXEDIR\Open-Cowork-Legacy-Cleanup.cmd$\r$\n3. Start this installer again.$\r$\n$\r$\nAdd -RemoveAppData to the cleanup tool only if you also want to clear local settings."
MessageBox MB_YESNO|MB_ICONEXCLAMATION "Open Cowork could not remove the previously installed version.$\r$\n$\r$\nThis usually means the legacy Windows uninstaller is damaged.$\r$\n$\r$\nNext steps:$\r$\n1. Close all Open Cowork windows.$\r$\n2. Run:$\r$\n$EXEDIR\Open-Cowork-Legacy-Cleanup.cmd$\r$\n3. Start this installer again.$\r$\n$\r$\nSelect Yes to launch it now. Select No to close this installer and run it yourself later.$\r$\n$\r$\nThe cleanup tool may request administrator approval if machine-wide leftovers are present.$\r$\nAdd -RemoveAppData only if you also want to clear local settings." IDYES launch_external_cleanup
SetErrorLevel 2
Quit

launch_external_cleanup:
ExecShell "open" "$EXEDIR\Open-Cowork-Legacy-Cleanup.cmd"
SetErrorLevel 2
Quit

Expand Down
108 changes: 108 additions & 0 deletions resources/windows/Open-Cowork-Legacy-Cleanup.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,12 @@ function Write-Step {
Write-Host "[Open Cowork Cleanup] $Message"
}

function Test-IsAdministrator {
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
$principal = [Security.Principal.WindowsPrincipal]::new($identity)
return $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
}

function Add-UniquePath {
param(
[System.Collections.Generic.List[string]]$List,
Expand Down Expand Up @@ -96,6 +102,101 @@ function Stop-OpenCoworkProcesses {
}
}

function Test-RegistryEntryRequiresAdministrator {
param($Entry)

if ($null -eq $Entry -or [string]::IsNullOrWhiteSpace($Entry.PSPath)) {
return $false
}

return $Entry.PSPath -like "Microsoft.PowerShell.Core\Registry::HKEY_LOCAL_MACHINE\*"
}

function Test-PathRequiresAdministrator {
param([string]$PathValue)

if ([string]::IsNullOrWhiteSpace($PathValue)) {
return $false
}

$expanded = [Environment]::ExpandEnvironmentVariables($PathValue.Trim().Trim('"')).TrimEnd('\')
if ([string]::IsNullOrWhiteSpace($expanded)) {
return $false
}

$protectedRoots = @(
$env:ProgramData,
$env:ProgramFiles,
${env:ProgramFiles(x86)}
) | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | ForEach-Object {
$_.TrimEnd('\')
}

foreach ($root in $protectedRoots) {
if ($expanded.StartsWith($root, [System.StringComparison]::OrdinalIgnoreCase)) {
return $true
}
}

return $false
}

function Test-CleanupRequiresAdministrator {
param(
[object[]]$RegistryEntries,
[System.Collections.Generic.List[string]]$InstallPaths
)

foreach ($entry in $RegistryEntries) {
if (Test-RegistryEntryRequiresAdministrator -Entry $entry) {
return $true
}
}

foreach ($pathValue in $InstallPaths) {
if (Test-PathRequiresAdministrator -PathValue $pathValue) {
return $true
}
}

return $false
}

function Invoke-SelfElevatedCleanup {
param(
[switch]$RemoveAppData,
[switch]$Silent
)

$argumentList = @(
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
"-File",
$PSCommandPath
)

if ($RemoveAppData) {
$argumentList += "-RemoveAppData"
}

if ($Silent) {
$argumentList += "-Silent"
}

try {
$process = Start-Process -FilePath "powershell.exe" -ArgumentList $argumentList -Verb RunAs -Wait -PassThru
if ($null -ne $process) {
exit $process.ExitCode
}

exit 0
} catch [System.ComponentModel.Win32Exception] {
Write-Warning "Elevation request was cancelled. Cleanup did not run."
exit 1
}
}

$registryEntries = @(Get-OpenCoworkRegistryEntries)
$installPaths = [System.Collections.Generic.List[string]]::new()

Expand All @@ -121,6 +222,13 @@ Add-UniquePath -List $appDataPaths -PathValue (Join-Path $env:APPDATA "open-cowo
Add-UniquePath -List $appDataPaths -PathValue (Join-Path $env:LOCALAPPDATA "Open Cowork")
Add-UniquePath -List $appDataPaths -PathValue (Join-Path $env:LOCALAPPDATA "open-cowork")

$requiresAdministrator = Test-CleanupRequiresAdministrator -RegistryEntries $registryEntries -InstallPaths $installPaths
if ($requiresAdministrator -and -not (Test-IsAdministrator)) {
Write-Host ""
Write-Step "Administrative cleanup is required for machine-wide leftovers. Requesting elevation..."
Invoke-SelfElevatedCleanup -RemoveAppData:$RemoveAppData -Silent:$Silent
}

Write-Host ""
Write-Step "This tool removes broken Open Cowork Windows install leftovers."
Write-Step "Install directories and uninstall registry entries will be removed."
Expand Down
74 changes: 74 additions & 0 deletions scripts/after-pack.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,66 @@

const fs = require('fs');
const path = require('path');
const { stageMcpResources } = require('./stage-mcp-resources');

const RETRYABLE_ERROR_CODES = new Set(['EBUSY', 'EPERM', 'ENOTEMPTY', 'EMFILE']);

function sleep(ms) {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}

async function copyDirWithRetry(sourceDir, targetDir, maxAttempts = 6, retryDelayMs = 200) {
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
try {
fs.rmSync(targetDir, { recursive: true, force: true });
fs.mkdirSync(path.dirname(targetDir), { recursive: true });
fs.cpSync(sourceDir, targetDir, { recursive: true });
return attempt + 1;
} catch (error) {
const retryable = error && RETRYABLE_ERROR_CODES.has(error.code);
if (!retryable || attempt === maxAttempts - 1) {
throw error;
}

const delay = retryDelayMs * Math.pow(2, attempt);
console.warn(
` [after-pack] MCP copy failed (${error.code}) attempt ${attempt + 1}/${maxAttempts}. Retrying in ${delay}ms...`
);
await sleep(delay);
}
}

return maxAttempts;
}

function listRelativeFiles(rootDir) {
if (!fs.existsSync(rootDir)) {
return [];
}

const results = [];
const stack = [''];

while (stack.length > 0) {
const relativePath = stack.pop();
const absolutePath = path.join(rootDir, relativePath);
const entries = fs.readdirSync(absolutePath, { withFileTypes: true });

for (const entry of entries) {
const childRelativePath = relativePath ? path.join(relativePath, entry.name) : entry.name;
if (entry.isDirectory()) {
stack.push(childRelativePath);
} else {
results.push(childRelativePath);
}
}
}

results.sort();
return results;
}

/**
* Map electron-builder arch names to koffi directory names.
Expand Down Expand Up @@ -95,6 +155,20 @@ module.exports = async function afterPack(context) {
// We primarily work on the unpacked directory
const nmUnpacked = path.join(appAsarUnpacked, 'node_modules');

// Copy bundled MCP servers after packaging so we control retries and avoid
// electron-builder's Windows extraResources copy occasionally hitting EBUSY.
const projectRoot = context.packager.projectDir;
const existingStageDir = path.join(projectRoot, 'dist-mcp-stage');
const existingStagedMcpFiles = listRelativeFiles(existingStageDir);
const { stageDir, files: stagedMcpFiles } = existingStagedMcpFiles.length > 0
? { stageDir: existingStageDir, files: existingStagedMcpFiles }
: await stageMcpResources({ projectRoot });
const bundledMcpDir = path.join(resourcesDir, 'mcp');
const mcpCopyAttempts = await copyDirWithRetry(stageDir, bundledMcpDir);
console.log(
` MCP: copied ${stagedMcpFiles.length} staged bundle(s) into resources/mcp in ${mcpCopyAttempts} attempt(s)`
);

// --- 1. koffi: remove non-target platform binaries ---
const koffiKeep = getKoffiPlatformDir(platform, archName);
const koffiBuildDirs = findDirs(resourcesDir, 'koffi');
Expand Down
Loading
Loading