From 07d87da3cb3291de8854a352301cbd7bda697f25 Mon Sep 17 00:00:00 2001 From: Bingvis Date: Mon, 30 Mar 2026 09:23:27 +0800 Subject: [PATCH 1/3] fix(windows): improve Windows desktop experience (batch 1) 1. Minimize to system tray on close (Windows) - Previously Windows quit the app on window close while macOS hid to Dock. - Now all platforms minimize to tray on close, consistent UX. 2. Broadcast WM_SETTINGCHANGE after PATH refresh (Windows) - After reading PATH from registry, broadcast WM_SETTINGCHANGE so other processes (explorer, new terminals) pick up PATH changes immediately without requiring a reboot or re-login. - Prefer pwsh (PowerShell 7+) over Windows PowerShell 5.1 for speed. 3. Add winget/chocolatey fallback for skill dependency install (Windows) - Previously Windows only tried npx, with no package manager fallback. - Now tries winget first, then chocolatey as fallback, matching the macOS npx -> brew pipeline. --- electron/main/cli.ts | 69 ++++++++++++++++++++++++++++++++++- electron/main/index.ts | 6 ++- electron/main/ipc-handlers.ts | 45 ++++++++++++++++++++++- 3 files changed, 115 insertions(+), 5 deletions(-) diff --git a/electron/main/cli.ts b/electron/main/cli.ts index 30f76dd..cc37191 100644 --- a/electron/main/cli.ts +++ b/electron/main/cli.ts @@ -3619,6 +3619,57 @@ export async function checkOAuthComplete(providerKey: string): Promise /** * 刷新环境变量,让新安装的程序可以被检测到 */ +/** + * Broadcast WM_SETTINGCHANGE to all top-level windows so other processes + * (explorer, new terminal sessions, etc.) pick up environment variable + * changes (especially PATH) without requiring a reboot or log-out. + * + * Equivalent to: SendMessageTimeout(HWND_BROADCAST, WM_SETTINGCHANGE, 0, "Environment", ...) + */ +async function broadcastWindowsEnvironmentChange(): Promise { + if (process.platform !== 'win32') return + const childProcess = await getSpawnFn() + return new Promise((resolve, reject) => { + const proc = childProcess.spawn( + 'powershell', + [ + '-NoProfile', + '-Command', + [ + 'Add-Type -TypeDefinition @"', + 'using System;', + 'using System.Runtime.InteropServices;', + 'public static class EnvNotify {', + ' [DllImport("user32.dll", SetLastError = true, CharSet = CharSet.Auto)]', + ' public static extern IntPtr SendMessageTimeout(', + ' IntPtr hWnd, uint Msg, UIntPtr wParam, string lParam,', + ' uint fuFlags, uint uTimeout, out UIntPtr lpdwResult);', + ' public static readonly IntPtr HWND_BROADCAST = (IntPtr)0xffff;', + ' public static readonly uint WM_SETTINGCHANGE = 0x001A;', + ' public static readonly uint SMTO_ABORTIFHUNG = 0x0002;', + ' public static void Notify() {', + ' UIntPtr result;', + ' SendMessageTimeout(HWND_BROADCAST, WM_SETTINGCHANGE, UIntPtr.Zero, "Environment", SMTO_ABORTIFHUNG, 5000, out result);', + ' }', + '}', + '"@', + '[EnvNotify]::Notify()', + ].join('\n'), + ], + { + stdio: ['ignore', 'ignore', 'pipe'], + shell: true, + timeout: 15000, + } + ) + proc.on('error', reject) + proc.on('close', (code) => { + if (code === 0) resolve() + else reject(new Error(`broadcastEnvironment failed with exit code ${code}`)) + }) + }) +} + export async function refreshEnvironment(): Promise<{ ok: boolean; newPath?: string }> { const platform = process.platform @@ -3645,12 +3696,26 @@ export async function refreshEnvironment(): Promise<{ ok: boolean; newPath?: str } // Windows: 从注册表读取最新 PATH 并更新当前进程的环境变量 - const result = await runShell('powershell', [ + // Prefer pwsh (PowerShell 7+) for speed, fall back to Windows PowerShell 5.1 + const psCmd = '[System.Environment]::GetEnvironmentVariable("Path","Machine") + ";" + [System.Environment]::GetEnvironmentVariable("Path","User")' + const pwshResult = await runShell('pwsh', [ + '-NoProfile', + '-Command', + psCmd, + ], MAIN_RUNTIME_POLICY.cli.lightweightProbeTimeoutMs, 'env') + + const result = pwshResult.ok ? pwshResult : await runShell('powershell', [ '-NoProfile', '-Command', - '[System.Environment]::GetEnvironmentVariable("Path","Machine") + ";" + [System.Environment]::GetEnvironmentVariable("Path","User")' + psCmd, ], MAIN_RUNTIME_POLICY.cli.lightweightProbeTimeoutMs, 'env') + if (result.ok && result.stdout.trim()) { + // Broadcast WM_SETTINGCHANGE so other processes (e.g. explorer, new terminals) + // pick up the PATH change without requiring a reboot. + await broadcastWindowsEnvironmentChange().catch(() => { + // Best-effort: if broadcast fails, PATH refresh still works for the current process. + }) return commitPath(result.stdout.trim()) } return commitPath(buildCliPathWithCandidates({ diff --git a/electron/main/index.ts b/electron/main/index.ts index e7f0f77..bfd6090 100644 --- a/electron/main/index.ts +++ b/electron/main/index.ts @@ -116,7 +116,9 @@ function createWindow() { }) browserWindow.on('close', (event) => { - if (process.platform !== 'darwin' || isQuitting) return + if (isQuitting) return + // macOS: standard behavior — hide to Dock + // Windows: minimize to system tray instead of quitting event.preventDefault() browserWindow.hide() }) @@ -272,7 +274,7 @@ app.whenReady().then(() => { app.on('window-all-closed', () => { win = null - if (process.platform !== 'darwin') app.quit() + // Keep the app running in the system tray on all platforms }) app.on('before-quit', (event) => { diff --git a/electron/main/ipc-handlers.ts b/electron/main/ipc-handlers.ts index d6d29d4..c92cb1a 100644 --- a/electron/main/ipc-handlers.ts +++ b/electron/main/ipc-handlers.ts @@ -1024,6 +1024,16 @@ export function registerIpcHandlers() { return usrLocalProbe.ok } + const isWingetAvailable = async (): Promise => { + const probe = await runShell('winget', ['--version'], 15_000, 'env-setup') + return probe.ok + } + + const isChocoAvailable = async (): Promise => { + const probe = await runShell('choco', ['--version'], 15_000, 'env-setup') + return probe.ok + } + ipcMain.handle('deps:installBin', async (_e, bin: string) => { return withManagedOperationLock('runtime-install', async () => { const safePackage = normalizeSafeInstallPackageName(bin) @@ -1121,7 +1131,7 @@ export function registerIpcHandlers() { return { ok: true, stdout: '', stderr: '', code: 0 } } - // 2. 尝试 brew install + // 2. Try platform-native package manager (brew on macOS, winget/choco on Windows) if (process.platform === 'darwin') { if (await isBrewAvailable()) { for (const bin of missingBins) { @@ -1146,6 +1156,39 @@ export function registerIpcHandlers() { log('/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"') return { ok: false, stdout: '', stderr: `需要 Homebrew 来安装依赖,请先安装 Homebrew`, code: 1 } } + } else if (process.platform === 'win32') { + // Windows: try winget first, then chocolatey as fallback + const wingetAvailable = await isWingetAvailable() + const chocoAvailable = !wingetAvailable && await isChocoAvailable() + + if (wingetAvailable) { + for (const bin of missingBins) { + if (await verifyBin(bin)) continue + log(`通过 winget 安装 ${bin} ...`) + const installResult = await runShell('winget', ['install', '--id', bin, '-e', '--accept-source-agreements', '--accept-package-agreements'], 120_000, 'env-setup') + if (installResult.ok || await verifyBin(bin)) { + log(`${bin} 已通过 winget 安装`) + } + } + } else if (chocoAvailable) { + for (const bin of missingBins) { + if (await verifyBin(bin)) continue + log(`通过 Chocolatey 安装 ${bin} ...`) + await runShell('choco', ['install', bin, '-y'], 120_000, 'env-setup') + if (await verifyBin(bin)) { + log(`${bin} 已通过 Chocolatey 安装`) + } + } + } else { + log('未检测到 winget 或 Chocolatey,部分依赖无法自动安装') + log('建议安装 winget (Windows 10+ 自带) 或 Chocolatey:') + log('https://winget.run 或 https://chocolatey.org/install') + } + + if (await checkAllResolved(missingBins)) { + log('所有依赖安装完成') + return { ok: true, stdout: '', stderr: '', code: 0 } + } } const stillMissing = [] From 266e415fa14c83b0a914e6a42b91e0c676e1c0dc Mon Sep 17 00:00:00 2001 From: Bingvis Date: Mon, 30 Mar 2026 13:07:13 +0800 Subject: [PATCH 2/3] fix(windows): improve Windows desktop experience (batch 2) 1. Cleanup: kill gateway/node processes before rmdir to avoid file locks - Previously rmdir /s /q would silently fail if gateway was holding file handles, with an unhelpful error message. - Now attempts taskkill on openclaw.exe first, waits 1s for handle release, and provides a user-friendly message if deletion still fails. 2. Permission auto-repair on Windows (icacls) - Previously permission repair was macOS-only (osascript + chown). - Now Windows uses icacls to grant current user full control (OI)(CI)F on blocked directories via UAC elevation. - Provides manual icacls commands in the error message as fallback. 3. Show full Windows paths instead of ~ shorthand - Windows users expect C:\Users\xxx\.openclaw, not ~/.openclaw - formatDisplayPath now returns full path on win32, ~ on macOS/Linux --- electron/main/cli.ts | 13 +++- electron/main/openclaw-paths.ts | 5 ++ .../main/openclaw-permission-auto-repair.ts | 62 +++++++++++++++++++ 3 files changed, 79 insertions(+), 1 deletion(-) diff --git a/electron/main/cli.ts b/electron/main/cli.ts index cc37191..9f43725 100644 --- a/electron/main/cli.ts +++ b/electron/main/cli.ts @@ -3385,6 +3385,17 @@ export async function cleanupOpenClawStateAndData( if (isWin) { if (!officialStateCleanupSucceeded) { + // Windows: first try to gracefully stop any lingering gateway/node processes + // that may hold file locks on the OpenClaw home directory. + await runShell('taskkill', ['/f', '/im', 'openclaw.exe', '/t'], 5_000, 'upgrade').catch(() => { + // openclaw.exe may not be running, ignore + }) + await runShell('taskkill', ['/f', '/im', 'node.exe', '/fi', 'WINDOWTITLE eq openclaw*'], 5_000, 'upgrade').catch(() => { + // No matching node process, ignore + }) + // Short delay to let file handles release + await new Promise((resolve) => setTimeout(resolve, 1000)) + // 旧版 OpenClaw 无官方 uninstall 时,回退到本地目录删除 const rmOpenclaw = await runShell( 'cmd', @@ -3393,7 +3404,7 @@ export async function cleanupOpenClawStateAndData( 'upgrade' ) if (!rmOpenclaw.ok) { - errors.push(`删除 ${targetDisplayHomeDir} 失败: ${rmOpenclaw.stderr}`) + errors.push(`删除 ${targetDisplayHomeDir} 失败。可能存在文件被其他进程占用,请关闭所有 OpenClaw 相关程序后重试。`) } } } else { diff --git a/electron/main/openclaw-paths.ts b/electron/main/openclaw-paths.ts index 7220f91..2f8a7f1 100644 --- a/electron/main/openclaw-paths.ts +++ b/electron/main/openclaw-paths.ts @@ -83,6 +83,11 @@ export function formatDisplayPath( normalizedPath.startsWith(`${normalizedHomeDir}${platform === 'win32' ? '\\' : '/'}`) if (!isMatch) return normalizedPath + + // Windows: show full path — users are more familiar with C:\Users\xxx\.openclaw + // macOS/Linux: use ~ shorthand — standard Unix convention + if (platform === 'win32') return normalizedPath + return `~${normalizedPath.slice(normalizedHomeDir.length)}` } diff --git a/electron/main/openclaw-permission-auto-repair.ts b/electron/main/openclaw-permission-auto-repair.ts index d390467..d964cc5 100644 --- a/electron/main/openclaw-permission-auto-repair.ts +++ b/electron/main/openclaw-permission-auto-repair.ts @@ -360,6 +360,68 @@ async function maybeAttemptPermissionRepair( } } + if (resolvedDependencies.platform === 'win32') { + // Windows: use icacls to grant current user full control on blocked directories + const currentUser = resolvedDependencies.currentUser.username || '%USERNAME%' + const icaclsCommands = repairRoots + .map((repairRoot) => `icacls "${repairRoot}" /grant:r "${currentUser}:(OI)(CI)F" /T /C /Q`) + .join(' && ') + + const repairResult = await resolvedDependencies.runPrivilegedRepair({ + command: icaclsCommands, + prompt: [ + 'Qclaw 检测到 OpenClaw 配置或运行目录权限异常。', + '', + 'Qclaw 需要修复这些目录的访问权限,才能继续当前操作。', + '', + '点击"是"以管理员权限继续。', + ].join('\n'), + controlDomain: context.controlDomain || 'global', + }) + + if (!repairResult.ok) { + return { + attempted: true, + repaired: false, + message: buildRepairFailureMessage({ + blockedProbes: blockedEntries.map((entry) => entry.probe), + repairRoots, + reason: [ + 'Windows 权限修复失败。', + '如需手动修复,请以管理员身份运行 PowerShell 执行:', + ...repairRoots.map((root) => `icacls "${root}" /grant:r "${currentUser}:(OI)(CI)F" /T /C /Q`), + ].join('\n'), + }), + } + } + + const verificationProbes = await Promise.all( + blockedEntries.map(async ({ path }) => ({ + path, + probe: await resolvedDependencies.probePath(path), + })) + ) + const remainingBlocked = verificationProbes.filter( + ({ probe }) => !probe.writable || probe.ownerMatchesCurrentUser === false + ) + if (remainingBlocked.length > 0) { + return { + attempted: true, + repaired: false, + message: buildRepairFailureMessage({ + blockedProbes: remainingBlocked.map((entry) => entry.probe), + repairRoots, + reason: 'Qclaw 已尝试自动修复,但仍有目录权限异常。', + }), + } + } + + return { + attempted: true, + repaired: true, + } + } + if (resolvedDependencies.platform !== 'darwin') { return { attempted: false, From e2f058a6c205b5fa1099c2afdc411de349e0008b Mon Sep 17 00:00:00 2001 From: Bingvis Date: Mon, 30 Mar 2026 13:24:29 +0800 Subject: [PATCH 3/3] fix: remove unnecessary shell:true in broadcastWindowsEnvironmentChange --- electron/main/cli.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/electron/main/cli.ts b/electron/main/cli.ts index 9f43725..2d30832 100644 --- a/electron/main/cli.ts +++ b/electron/main/cli.ts @@ -3669,7 +3669,6 @@ async function broadcastWindowsEnvironmentChange(): Promise { ], { stdio: ['ignore', 'ignore', 'pipe'], - shell: true, timeout: 15000, } )