fix(openclaw): stabilize windows install and pairing

This commit is contained in:
晴天
2026-06-07 03:28:10 +08:00
parent a458f77c35
commit e16ff2baee
21 changed files with 912 additions and 131 deletions

View File

@@ -249,21 +249,25 @@ export function showUpgradeModal(title) {
let _finished = false
let _taskBar = null
let _progressLabels = null
let _closed = false
// 重新打开弹窗(从任务状态栏点击时)
function reopenModal() {
_closed = false
if (_taskBar) { _taskBar.remove(); _taskBar = null }
document.body.appendChild(overlay)
}
// 关闭弹窗:未完成时显示任务状态栏
function closeModal() {
if (_closed) return
_closed = true
overlay.remove()
if (!_finished) {
showTaskBar()
} else {
if (_taskBar) { _taskBar.remove(); _taskBar = null }
_onClose?.()
setTimeout(() => _onClose?.(), 0)
}
}
@@ -290,6 +294,11 @@ export function showUpgradeModal(title) {
}
closeBtn.onclick = closeModal
closeBtn.addEventListener('click', (e) => {
e.preventDefault()
e.stopPropagation()
closeModal()
})
overlay.addEventListener('keydown', (e) => {
if (e.key === 'Escape') closeModal()
})

View File

@@ -49,6 +49,20 @@ function readBoundCliPath(panelConfig) {
return String(panelConfig?.openclawCliPath || '').trim()
}
function quoteCommandPath(path) {
return String(path || '').replace(/"/g, '\\"')
}
function openclawInstallDirForCommand(path) {
const value = String(path || '').trim()
if (!value) return ''
const normalized = value.replace(/\\/g, '/')
if (/\/openclaw(?:\.cmd|\.exe|\.bat|\.ps1|\.js)?$/i.test(normalized)) {
return value.replace(/[\\/][^\\/]+$/, '')
}
return value
}
let _foreignGatewayPromptKey = ''
export function isForeignGatewayService(service) {
@@ -236,14 +250,15 @@ export async function showGatewayConflictGuidance({ error = null, service = null
/** 根据安装来源返回卸载命令 */
function uninstallCommandForSource(source, path) {
const isWin = navigator.platform?.startsWith('Win') || navigator.userAgent?.includes('Windows')
if (source === 'standalone') {
const isWin = navigator.platform?.startsWith('Win') || navigator.userAgent?.includes('Windows')
const p = escapeHtml(path || '')
const p = quoteCommandPath(openclawInstallDirForCommand(path))
return isWin ? `rmdir /s /q "${p}"` : `rm -rf "${p}"`
}
if (source === 'npm-official' || source === 'official') return 'npm uninstall -g openclaw'
// npm-zh, npm-global, and others
return 'npm uninstall -g @qingchencloud/openclaw-zh'
if (source === 'npm-zh' || source === 'npm-global') return 'npm uninstall -g @qingchencloud/openclaw-zh'
const p = quoteCommandPath(path)
return isWin ? `del /f /q "${p}"` : `rm -f "${p}"`
}
/**
@@ -259,6 +274,11 @@ export async function showInstallationCleanup({ onRefresh = null } = {}) {
const installations = dedupeOpenclawInstallations(Array.isArray(versionInfo?.all_installations) ? versionInfo.all_installations : [])
const boundPath = readBoundCliPath(panelConfig)
const currentPath = versionInfo?.cli_path || ''
const hasActiveBoundInstall = installations.some(inst =>
inst?.active
&& boundPath
&& openclawInstallationIdentity({ path: inst.path }) === openclawInstallationIdentity({ path: boundPath })
)
const sourceLabel = (src) => cliSourceLabel(src)
@@ -277,16 +297,21 @@ export async function showInstallationCleanup({ onRefresh = null } = {}) {
const uninstallCmd = uninstallCommandForSource(inst.source, inst.path)
// 操作区:非活跃安装显示卸载命令 + 复制按钮;活跃的显示绑定按钮
// 操作区:所有未绑定安装都可以绑定;非活跃安装额外显示清理命令。
let actions = ''
if (isActive && !isBound) {
actions = `<button class="btn btn-primary btn-xs cleanup-bind-btn" data-path="${escapeHtml(inst.path)}" style="margin-top:8px">${t('services.cleanupBindThis')}</button>`
} else if (!isActive) {
const shouldOfferBind = !isBound && (!hasActiveBoundInstall || isActive)
const bindButton = shouldOfferBind
? `<button class="btn btn-primary btn-xs cleanup-bind-btn" data-path="${escapeHtml(inst.path)}">${t('services.cleanupBindThis')}</button>`
: ''
if (!isActive) {
actions = `
<div style="margin-top:8px;display:flex;gap:6px;align-items:center;flex-wrap:wrap">
${bindButton}
<code style="flex:1;min-width:0;font-size:11px;padding:4px 8px;background:var(--bg-tertiary);border-radius:4px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;user-select:all" title="${escapeHtml(uninstallCmd)}">${escapeHtml(uninstallCmd)}</code>
<button class="btn btn-secondary btn-xs cleanup-copy-cmd" data-cmd="${escapeHtml(uninstallCmd)}" style="flex-shrink:0">${t('services.cleanupCopyCmd')}</button>
</div>`
} else if (bindButton) {
actions = `<div style="margin-top:8px">${bindButton}</div>`
}
return `

View File

@@ -590,17 +590,10 @@ export class WsClient {
const result = await api.autoPairDevice()
console.log('[ws] 配对结果:', result)
// 配对后桌面端需要 reload Gateway 使 allowedOrigins 生效Web/headless 不能隐式重载反代后的服务
if (isTauriRuntime()) {
try {
await api.reloadGateway()
console.log('[ws] Gateway 已重载')
} catch (e) {
console.warn('[ws] reloadGateway 失败(非致命):', e)
}
} else {
console.log('[ws] Web/headless 模式跳过自动 reload Gateway')
}
// 这里只修配对文件,不自动重启 Gateway
// Windows 上手动启动的 Gateway 会被 restart/stop 打断,表现为“启动后一会就停止”。
// Gateway 对设备配对文件按连接读取;如遇 origin 配置变更,交由用户手动重启。
console.log('[ws] 自动配对文件已修复,跳过自动重启 Gateway')
// 修复 #160: 不调用 reconnect()(它会重置 _autoPairAttempts 导致无限循环),
// 而是直接重连一次。如果仍然失败_autoPairAttempts 不会被重置,不会再次触发自动修复。

View File

@@ -2282,13 +2282,13 @@
"tooOldForProtocol": "Gateway kernel is too old and does not support the handshake protocol used by this ClawPanel. Please upgrade the OpenClaw kernel to the recommended version ({recommended}) and retry. You can upgrade from \"Services → OpenClaw → Upgrade\"."
},
"cliConflict": {
"title": "Detected {count} possibly conflicting OpenClaw installation(s)",
"desc": "Your PATH has OpenClaw installations not managed by ClawPanel (e.g. Cherry Studio bundled, legacy npm global). They can cause terminal commands to pick up old versions, triggering schema mismatches and doctor --fix hangs.",
"title": "Detected {count} stale OpenClaw PATH entries",
"desc": "The current ClawPanel binding is not affected, but PATH still contains an old or unmanaged openclaw. Terminal commands or third-party tools that resolve openclaw through PATH may still pick up the old version.",
"viewDetails": "View details",
"hideDetails": "Hide details",
"quarantineAll": "Quarantine all",
"quarantineAll": "Quarantine PATH entries",
"quarantining": "Quarantining…",
"quarantineOne": "Quarantine",
"quarantineOne": "Quarantine this entry",
"dismiss": "Dismiss",
"dismissedHint": "Dismissed for this session. Next launch will scan again.",
"quarantineOk": "Quarantined {count} item(s)",

View File

@@ -189,10 +189,10 @@ export default {
cleanupBindSuccess: _('已绑定 CLI冲突已解决', 'CLI bound, conflict resolved', '已綁定 CLI衝突已解決'),
cleanupBindFailed: _('绑定失败', 'Bind failed', '綁定失敗'),
cleanupNoInstalls: _('未检测到 OpenClaw 安装', 'No OpenClaw installations detected', '未檢測到 OpenClaw 安裝'),
cleanupMultiSummary: _('检测到 {count} 个 OpenClaw 安装,重复安装是 99% 问题的根源。建议只保留一个,卸载其余副本。', '{count} OpenClaw installations detected. Duplicate installs cause 99% of issues. Keep one and uninstall the rest.', '檢測到 {count} 個 OpenClaw 安裝,重複安裝是 99% 問題的根源。建議只保留一個,卸載其餘副本。'),
cleanupMultiSummary: _('检测到 {count} 个 OpenClaw 安装。当前绑定版本可以正常使用;其余旧安装如果仍在 PATH 中,可能影响终端或第三方工具。', '{count} OpenClaw installations detected. The bound version can be used normally; stale PATH entries may still affect terminals or third-party tools.', '檢測到 {count} 個 OpenClaw 安裝。目前綁定版本可以正常使用;其餘舊安裝如果仍在 PATH 中,可能影響終端或第三方工具。'),
cleanupSingleSummary: _('只检测到 1 个 OpenClaw 安装,状态正常。', 'Only 1 OpenClaw installation detected. Status is normal.', '只檢測到 1 個 OpenClaw 安裝,狀態正常。'),
cleanupHowTo: _('如何清理?', 'How to clean up?', '如何清理?'),
cleanupHowToDesc: _('1. 先点击「绑定此安装」锁定你要保留的版本2. 复制其余安装的卸载命令,在终端中执行3. 刷新状态确认清理完成。', '1. Click "Bind this installation" to lock the version you want to keep. 2. Copy the uninstall commands for the others and run them in your terminal. 3. Refresh status to confirm cleanup.', '1. 先點擊「綁定此安裝」鎖定你要保留的版本2. 複製其餘安裝的卸載命令,在終端中執行3. 重新整理狀態確認清理完成。'),
cleanupHowToDesc: _('1. 如果没有“已绑定”版本,先绑定要保留的安装2. 对旧 PATH 残留,复制清理命令或在横幅中隔离3. 刷新状态确认只剩目标版本生效。', '1. If no version is bound, bind the installation you want to keep. 2. For stale PATH entries, copy the cleanup command or quarantine them from the banner. 3. Refresh status to confirm only the target version remains active.', '1. 如果沒有「已綁定」版本,先綁定要保留的安裝2. 對舊 PATH 殘留,複製清理命令或在橫幅中隔離3. 重新整理狀態確認只剩目標版本生效。'),
cleanupInstallationsTitle: _('已检测到的安装 ({count})', 'Detected installations ({count})', '已檢測到的安裝 ({count})'),
cleanupDangerZone: _('危险操作:全量卸载', 'Danger zone: Full uninstall', '危險操作:全量卸載'),
cleanupDangerDesc: _('以下操作将卸载所有 OpenClaw 安装(包括当前使用的)。通常不需要这样做,除非你想彻底清理后重新安装。', 'The following will uninstall ALL OpenClaw installations (including the active one). This is usually unnecessary unless you want to start completely fresh.', '以下操作將卸載所有 OpenClaw 安裝(包括目前使用的)。通常不需要這樣做,除非你想徹底清理後重新安裝。'),

View File

@@ -2365,13 +2365,13 @@
"tooOldForProtocol": "Gateway 内核版本过旧,不兼容当前 ClawPanel 使用的握手协议。请把 OpenClaw 内核升级到推荐版本({recommended})后重试。可在「服务管理 → OpenClaw → 一键升级」中完成升级。"
},
"cliConflict": {
"title": "检测到 {count} 处可能冲突的 OpenClaw 安装",
"desc": "系统 PATH 中存在非 ClawPanel 管理的 OpenClaw(如 Cherry Studio 内嵌、旧 npm 全局),可能导致终端命令拿到版本,引发 schema 不兼容、doctor --fix 卡死等问题。",
"title": "检测到 {count} 处 PATH 残留的 OpenClaw",
"desc": "ClawPanel 当前绑定不受影响;但系统 PATH 中仍有旧版或非面板管理的 openclaw。终端直接执行 openclaw、或第三方工具按 PATH 调用时,可能拿到版本。",
"viewDetails": "查看详情",
"hideDetails": "收起详情",
"quarantineAll": "一键隔离",
"quarantineAll": "隔离 PATH 残留",
"quarantining": "正在隔离…",
"quarantineOne": "隔离",
"quarantineOne": "隔离此项",
"dismiss": "暂时忽略",
"dismissedHint": "已忽略本次检测。下次启动会重新扫描。",
"quarantineOk": "已隔离 {count} 个冲突项",

View File

@@ -872,6 +872,7 @@ async function boot() {
setDefaultRoute('/setup')
navigate('/setup')
}
window.dispatchEvent(new CustomEvent('openclaw:runtime-changed'))
}
await listen('upgrade-done', refreshAfterTask)
await listen('upgrade-error', refreshAfterTask)
@@ -891,17 +892,12 @@ async function autoConnectWebSocket() {
const rawPassword = config?.gateway?.auth?.password
const password = (typeof rawPassword === 'string') ? rawPassword : ''
// 启动前先确保设备已配对 + allowedOrigins 已写入,无需用户手动操作
// 启动前先确保设备已配对 + allowedOrigins 已写入,无需用户手动操作
// 不在这里自动重启 GatewayWindows 上手动启动的 Gateway 会被 stop/restart 打断。
let needReload = false
try {
const pairResult = await api.autoPairDevice()
console.log('[main] 设备配对 + origins 已就绪:', pairResult)
// 仅在配置实际变更时才需要 reloaddev-api 返回 {changed}Tauri 返回字符串)
if (typeof pairResult === 'object' && pairResult.changed) {
needReload = true
} else if (typeof pairResult === 'string' && pairResult !== '设备已配对') {
needReload = true
}
} catch (pairErr) {
console.warn('[main] autoPairDevice 失败(非致命):', pairErr)
}

View File

@@ -592,6 +592,7 @@ async function doInstall(page, title, source, version) {
unlistenDone = await listen('upgrade-done', (e) => {
cleanup()
modal.setDone(typeof e.payload === 'string' ? e.payload : t('about.operationDone'))
loadData(page)
})
unlistenError = await listen('upgrade-error', async (e) => {
@@ -617,6 +618,8 @@ async function doInstall(page, title, source, version) {
const msg = await api.upgradeOpenclaw(source, version)
modal.setDone(typeof msg === 'string' ? msg : (msg?.message || t('about.operationDone')))
cleanup()
window.dispatchEvent(new CustomEvent('openclaw:runtime-changed'))
loadData(page)
}
} catch (e) {
cleanup()

View File

@@ -1260,7 +1260,17 @@ async function connectGateway() {
const gw = config?.gateway || {}
const host = isTauriRuntime() ? `127.0.0.1:${gw.port || 18789}` : location.host
const token = gw.auth?.token || gw.authToken || ''
wsClient.connect(host, token)
const password = typeof gw.auth?.password === 'string' ? gw.auth.password : ''
// 聊天页可能比 main.js 的全局自动连接更早发起 WS。
// 新机器首次启动时如果这里直接连接Gateway 会在配对字段生效前拒绝 operator 角色。
try {
await api.autoPairDevice()
} catch (pairErr) {
console.warn('[chat] autoPairDevice 失败(非致命):', pairErr)
}
wsClient.connect(host, token, { password })
} catch (e) {
toast(`${t('common.loadFailed')}: ${e.message}`, 'error')
}

View File

@@ -16,6 +16,7 @@ let _unsubGw = null
let _dashboardLoadChain = Promise.resolve()
let _lastGwChangeLoad = 0
let _detachCliConflict = null
let _dashboardRuntimeRefreshHandler = null
export async function render() {
const page = document.createElement('div')
@@ -86,12 +87,25 @@ export async function render() {
loadDashboardData(page)
})
if (_dashboardRuntimeRefreshHandler) window.removeEventListener('openclaw:runtime-changed', _dashboardRuntimeRefreshHandler)
_dashboardRuntimeRefreshHandler = () => {
_dashboardInitialized = false
_dashboardVersionCache = null
_dashboardStatusSummaryCache = null
loadDashboardData(page, true).catch(() => {})
}
window.addEventListener('openclaw:runtime-changed', _dashboardRuntimeRefreshHandler)
return page
}
export function cleanup() {
if (_unsubGw) { _unsubGw(); _unsubGw = null }
if (_detachCliConflict) { try { _detachCliConflict() } catch (_) {} _detachCliConflict = null }
if (_dashboardRuntimeRefreshHandler) {
window.removeEventListener('openclaw:runtime-changed', _dashboardRuntimeRefreshHandler)
_dashboardRuntimeRefreshHandler = null
}
}
function openclawInstallationIdentity(installation) {

View File

@@ -1327,7 +1327,7 @@ async function importClientConfigs(page, state) {
${candidates.map((c, idx) => {
const models = candidateModels(c)
const status = c.apiKeyStatus === 'found' ? t('models.importKeyFound') : (c.apiKeyStatus === 'missing' ? t('models.importKeyMissing') : t('models.importKeyNone'))
const disabled = !c.importable || !models.length
const disabled = !c.importable || c.apiKeyStatus === 'missing' || !models.length
const checked = !disabled && c.apiKeyStatus !== 'missing'
return `
<label style="display:flex;gap:10px;align-items:flex-start;padding:10px 12px;border:1px solid var(--border-color);border-radius:var(--radius-md);background:var(--bg-tertiary);opacity:${disabled ? '0.65' : '1'}">
@@ -1366,7 +1366,7 @@ async function importClientConfigs(page, state) {
overlay.querySelector('[data-action="import"]').onclick = () => {
const selected = [...overlay.querySelectorAll('input[type="checkbox"]:checked')]
.map(input => candidates[Number(input.dataset.index)])
.filter(Boolean)
.filter(candidate => candidate && candidate.importable && candidate.apiKeyStatus !== 'missing')
if (!selected.length) { toast(t('models.importNoneSelected'), 'warning'); return }
pushUndo(state)
if (!state.config.models) state.config.models = { mode: 'replace', providers: {} }