feat(openclaw): binary download install, auto update check, and gateway refactor (#13440)

### What this PR does

Refactors OpenClaw installation to use direct binary downloads instead
of npm, adds auto update detection, and delegates gateway lifecycle to
OS service manager CLI commands.

**Binary download install (replaces npm install):**
- Download pre-built binaries from GitHub releases (or gitcode.com
mirror for China users)
- Install to `~/.cherrystudio/bin/` — no npm/Node.js dependency required
- Uninstall by simply deleting the binary file
- Detect old npm-installed versions and prompt migration

**Auto update check:**
- Automatically check for updates via `openclaw update status` when
entering the OpenClaw page
- Show info alert with version details when an update is available
- Perform update via `openclaw update` command (stops gateway first if
running)
- Version parsing extracted to `services/utils/openClawParsers.ts` with
inline snapshot tests

**Gateway lifecycle refactor:**
- Gateway lifecycle delegated to OS service manager via `openclaw
gateway` CLI commands
- Removes in-process `ChildProcess` management, `node:net` Socket
probing, and `killProcess` helper
- Health checks use `openclaw gateway health` CLI command
- `getStatus` is now async and probes health to detect
externally-started gateways
- Return type unified to discriminated union `OperationResult`

**Windows-specific handling:**
- Skip `gateway install/uninstall` (scheduled task integration has
upstream bugs)
- Gateway started via `openclaw gateway start --force`
- Port conflict detection before startup with clear error messages

**UI improvements:**
- Auto update alert with "Reinstall" button when new version detected
- Copy button on install/uninstall log container and error alerts

### Why we need it and why it was done in this way

- Binary download is faster, more reliable, and removes the npm/Node.js
dependency for end users
- `openclaw update` leverages the tool's built-in update mechanism
rather than re-downloading
- Delegating to OS service management (launchd/systemd) is more robust —
the gateway survives app restarts
- Version parsers are pure functions with snapshot tests to prevent
regressions

### Breaking changes

None. Internal refactoring only — no user-facing API changes.

### Special notes for your reviewer

- New IPC channels: `OpenClaw_CheckUpdate`, `OpenClaw_PerformUpdate`
- New file: `src/main/services/utils/openClawParsers.ts` (pure parsing
functions)
- New test: `src/main/services/__tests__/OpenClawService.test.ts` (16
snapshot tests)
- i18n: added `openclaw.update.*` and
`openclaw.quick_actions.check_update` keys (all locales synced)
- Windows: no service registration, gateway started with `--force`, port
checked before startup
- macOS/Linux: full service lifecycle via CLI commands

### Checklist

- [x] PR: The PR description is expressive enough and will help future
contributors
- [x] Code: [Write code that humans can
understand](https://en.wikiquote.org/wiki/Martin_Fowler#code-for-humans)
and [Keep it simple](https://en.wikipedia.org/wiki/KISS_principle)
- [x] Refactor: You have [left the code cleaner than you found it (Boy
Scout
Rule)](https://learning.oreilly.com/library/view/97-things-every/9780596809515/ch08.html)
- [ ] Upgrade: Impact of this change on upgrade flows was considered and
addressed if required
- [ ] Documentation: A [user-guide update](https://docs.cherry-ai.com)
was considered and is present (link) or not required.
- [x] Self-review: I have reviewed my own code before requesting review
from others

### Release note

```release-note
feat(openclaw): switch to binary download install and add auto update check
fix(openclaw): gateway start or stop exception
```

---------

Co-authored-by: suyao <sy20010504@gmail.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
亢奋猫
2026-03-13 18:12:21 +08:00
committed by GitHub
parent 4aef249151
commit 97011c8bb2
23 changed files with 1121 additions and 683 deletions

View File

@@ -412,10 +412,6 @@ export enum IpcChannel {
// OpenClaw
OpenClaw_CheckInstalled = 'openclaw:check-installed',
OpenClaw_CheckNodeVersion = 'openclaw:check-node-version',
OpenClaw_CheckGitAvailable = 'openclaw:check-git-available',
OpenClaw_GetNodeDownloadUrl = 'openclaw:get-node-download-url',
OpenClaw_GetGitDownloadUrl = 'openclaw:get-git-download-url',
OpenClaw_Install = 'openclaw:install',
OpenClaw_Uninstall = 'openclaw:uninstall',
OpenClaw_InstallProgress = 'openclaw:install-progress',
@@ -427,6 +423,8 @@ export enum IpcChannel {
OpenClaw_GetDashboardUrl = 'openclaw:get-dashboard-url',
OpenClaw_SyncConfig = 'openclaw:sync-config',
OpenClaw_GetChannels = 'openclaw:get-channels',
OpenClaw_CheckUpdate = 'openclaw:check-update',
OpenClaw_PerformUpdate = 'openclaw:perform-update',
// Analytics
Analytics_TrackTokenUsage = 'analytics:track-token-usage'

View File

@@ -4,10 +4,7 @@ import type { ProcessingStatus } from '@types'
// OpenClaw IPC Types
// =============================================================================
export type NodeCheckResult =
| { status: 'not_found' }
| { status: 'version_low'; version: string; path: string }
| { status: 'ok'; version: string; path: string }
export type OperationResult = { success: true } | { success: false; message: string }
export type LoaderReturn = {
entriesAdded: number

View File

@@ -0,0 +1,230 @@
const fs = require('fs')
const path = require('path')
const os = require('os')
const https = require('https')
const { execSync } = require('child_process')
const StreamZip = require('node-stream-zip')
const { downloadWithRedirects } = require('./download')
// Download sources
const GITCODE_RELEASE_BASE_URL = 'https://gitcode.com/CherryHQ/openclaw-releases/releases/download'
const GITHUB_RELEASE_BASE_URL = 'https://github.com/CherryHQ/openclaw/releases/download'
const GITHUB_API_LATEST_RELEASE = 'https://api.github.com/repos/CherryHQ/openclaw/releases/latest'
const DEFAULT_VERSION = 'v2026.3.11'
const API_TIMEOUT_MS = 5000
/**
* Fetches the latest release version from GitHub API with timeout
* @param {number} timeoutMs Timeout in milliseconds
* @returns {Promise<string>} The latest version tag or DEFAULT_VERSION on failure
*/
async function getLatestVersion(timeoutMs = API_TIMEOUT_MS) {
return new Promise((resolve) => {
const request = https.get(
GITHUB_API_LATEST_RELEASE,
{
headers: {
'User-Agent': 'cherry-studio-installer',
Accept: 'application/vnd.github.v3+json'
},
timeout: timeoutMs
},
(res) => {
if (res.statusCode !== 200) {
console.warn(`GitHub API returned status ${res.statusCode}, using default version`)
resolve(DEFAULT_VERSION)
return
}
let data = ''
res.on('data', (chunk) => {
data += chunk
})
res.on('end', () => {
try {
const json = JSON.parse(data)
if (json.tag_name) {
console.log(`Found latest version from GitHub: ${json.tag_name}`)
resolve(json.tag_name)
} else {
console.warn('No tag_name in GitHub response, using default version')
resolve(DEFAULT_VERSION)
}
} catch (e) {
console.warn(`Failed to parse GitHub response: ${e.message}, using default version`)
resolve(DEFAULT_VERSION)
}
})
}
)
request.on('timeout', () => {
console.warn(`GitHub API request timed out after ${timeoutMs}ms, using default version`)
request.destroy()
resolve(DEFAULT_VERSION)
})
request.on('error', (err) => {
console.warn(`GitHub API request failed: ${err.message}, using default version`)
resolve(DEFAULT_VERSION)
})
})
}
// Mapping of platform+arch to binary package name
const OPENCLAW_PACKAGES = {
'darwin-arm64': 'openclaw-darwin-arm64.tar.gz',
'darwin-x64': 'openclaw-darwin-x64.tar.gz',
'win32-arm64': 'openclaw-windows-arm64.zip',
'win32-x64': 'openclaw-windows-x64.zip',
'linux-arm64': 'openclaw-linux-arm64.tar.gz',
'linux-x64': 'openclaw-linux-x64.tar.gz'
}
/**
* Downloads and extracts the openclaw binary for the specified platform and architecture
* @param {string} platform Platform to download for
* @param {string} arch Architecture to download for
* @param {string} version Version to download
* @param {boolean} useMirror Whether to use gitcode mirror (for China users)
*/
async function downloadOpenClawBinary(platform, arch, version = DEFAULT_VERSION, useMirror = false) {
const platformKey = `${platform}-${arch}`
const packageName = OPENCLAW_PACKAGES[platformKey]
if (!packageName) {
console.error(`No binary available for ${platformKey}`)
return 101
}
const binDir = path.join(os.homedir(), '.cherrystudio', 'bin')
fs.mkdirSync(binDir, { recursive: true })
const baseUrl = useMirror ? GITCODE_RELEASE_BASE_URL : GITHUB_RELEASE_BASE_URL
const downloadUrl = `${baseUrl}/${version}/${packageName}`
const tempdir = os.tmpdir()
const tempFilename = path.join(tempdir, packageName)
const isTarGz = packageName.endsWith('.tar.gz')
try {
console.log(`Downloading openclaw ${version} for ${platformKey}...`)
console.log(`URL: ${downloadUrl}`)
await downloadWithRedirects(downloadUrl, tempFilename)
console.log(`Extracting ${packageName} to ${binDir}...`)
if (isTarGz) {
const tempExtractDir = path.join(tempdir, `openclaw-extract-${Date.now()}`)
fs.mkdirSync(tempExtractDir, { recursive: true })
execSync(`tar -xzf "${tempFilename}" -C "${tempExtractDir}"`, { stdio: 'inherit' })
// Find and move files to binDir
const findAndMoveFiles = (dir) => {
const entries = fs.readdirSync(dir, { withFileTypes: true })
for (const entry of entries) {
const fullPath = path.join(dir, entry.name)
if (entry.isDirectory()) {
// Handle lib/ sidecar directory specially
if (entry.name === 'lib') {
const destLibDir = path.join(binDir, 'lib')
fs.mkdirSync(destLibDir, { recursive: true })
const libEntries = fs.readdirSync(fullPath)
for (const libFile of libEntries) {
const srcPath = path.join(fullPath, libFile)
const destPath = path.join(destLibDir, libFile)
fs.copyFileSync(srcPath, destPath)
console.log(`Extracted lib/${libFile} -> ${destPath}`)
}
} else {
findAndMoveFiles(fullPath)
}
} else {
const filename = path.basename(entry.name)
const outputPath = path.join(binDir, filename)
fs.copyFileSync(fullPath, outputPath)
console.log(`Extracted ${entry.name} -> ${outputPath}`)
fs.chmodSync(outputPath, 0o755)
}
}
}
findAndMoveFiles(tempExtractDir)
fs.rmSync(tempExtractDir, { recursive: true })
} else {
// Use StreamZip for zip files (Windows)
const zip = new StreamZip.async({ file: tempFilename })
const entries = await zip.entries()
for (const entry of Object.values(entries)) {
if (!entry.isDirectory) {
const filename = path.basename(entry.name)
const outputPath = path.join(binDir, filename)
console.log(`Extracting ${entry.name} -> ${filename}`)
await zip.extract(entry.name, outputPath)
console.log(`Extracted ${entry.name} -> ${outputPath}`)
}
}
await zip.close()
}
fs.unlinkSync(tempFilename)
console.log(`Successfully installed openclaw ${version} for ${platform}-${arch}`)
return 0
} catch (error) {
let retCode = 103
console.error(`Error installing openclaw for ${platformKey}: ${error.message}`)
if (fs.existsSync(tempFilename)) {
fs.unlinkSync(tempFilename)
}
try {
const files = fs.readdirSync(binDir)
if (files.length === 0) {
fs.rmSync(binDir, { recursive: true })
console.log(`Removed empty directory: ${binDir}`)
}
} catch (cleanupError) {
console.warn(`Warning: Failed to clean up directory: ${cleanupError.message}`)
retCode = 104
}
return retCode
}
}
/**
* Main function to install openclaw
*/
async function installOpenClaw() {
const version = await getLatestVersion()
const platform = os.platform()
const arch = os.arch()
// Check for mirror flag from environment variable
const useMirror = process.env.OPENCLAW_USE_MIRROR === '1'
console.log(`Installing openclaw ${version} for ${platform}-${arch}${useMirror ? ' (mirror)' : ''}...`)
return await downloadOpenClawBinary(platform, arch, version, useMirror)
}
// Run the installation
installOpenClaw()
.then((retCode) => {
if (retCode === 0) {
console.log('Installation successful')
process.exit(0)
} else {
console.error('Installation failed')
process.exit(retCode)
}
})
.catch((error) => {
console.error('Installation failed:', error)
process.exit(100)
})

View File

@@ -10,7 +10,6 @@ import anthropicService from '@main/services/AnthropicService'
import { getIpCountry } from '@main/utils/ipService'
import {
autoDiscoverGitBash,
checkGitAvailable,
getBinaryPath,
getGitBashPathInfo,
isBinaryExists,
@@ -1160,10 +1159,6 @@ export async function registerIpc(mainWindow: BrowserWindow, app: Electron.App)
// OpenClaw
ipcMain.handle(IpcChannel.OpenClaw_CheckInstalled, openClawService.checkInstalled)
ipcMain.handle(IpcChannel.OpenClaw_CheckNodeVersion, openClawService.checkNodeVersion)
ipcMain.handle(IpcChannel.OpenClaw_CheckGitAvailable, checkGitAvailable)
ipcMain.handle(IpcChannel.OpenClaw_GetNodeDownloadUrl, openClawService.getNodeDownloadUrl)
ipcMain.handle(IpcChannel.OpenClaw_GetGitDownloadUrl, openClawService.getGitDownloadUrl)
ipcMain.handle(IpcChannel.OpenClaw_Install, openClawService.install)
ipcMain.handle(IpcChannel.OpenClaw_Uninstall, openClawService.uninstall)
ipcMain.handle(IpcChannel.OpenClaw_StartGateway, openClawService.startGateway)
@@ -1174,6 +1169,8 @@ export async function registerIpc(mainWindow: BrowserWindow, app: Electron.App)
ipcMain.handle(IpcChannel.OpenClaw_GetDashboardUrl, openClawService.getDashboardUrl)
ipcMain.handle(IpcChannel.OpenClaw_SyncConfig, openClawService.syncProviderConfig)
ipcMain.handle(IpcChannel.OpenClaw_GetChannels, openClawService.getChannelStatus)
ipcMain.handle(IpcChannel.OpenClaw_CheckUpdate, openClawService.checkUpdate)
ipcMain.handle(IpcChannel.OpenClaw_PerformUpdate, openClawService.performUpdate)
// Analytics
ipcMain.handle(IpcChannel.Analytics_TrackTokenUsage, (_, data: TokenUsageData) =>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,90 @@
import { describe, expect, it } from 'vitest'
import { parseCurrentVersion, parseUpdateStatus } from '../utils/openClawParsers'
describe('parseCurrentVersion', () => {
const cases = [
{ name: 'standard version output', input: 'OpenClaw 2026.3.9 (fe96034)', expected: '2026.3.9' },
{ name: 'version without commit hash', input: 'OpenClaw 2026.3.11', expected: '2026.3.11' },
{ name: 'lowercase prefix', input: 'openclaw 1.0.0 (abc1234)', expected: '1.0.0' },
{ name: 'semver format', input: 'OpenClaw 0.12.3 (deadbeef)', expected: '0.12.3' },
{ name: 'empty string', input: '', expected: null },
{ name: 'unrelated output', input: 'some random text', expected: null },
{ name: 'version with extra whitespace', input: ' OpenClaw 2026.3.9 ', expected: '2026.3.9' }
]
it.each(cases)('$name: "$input"', ({ input, expected }) => {
expect(parseCurrentVersion(input)).toBe(expected)
})
it('snapshot: all cases', () => {
const results = Object.fromEntries(cases.map((c) => [c.name, parseCurrentVersion(c.input)]))
expect(results).toMatchInlineSnapshot(`
{
"empty string": null,
"lowercase prefix": "1.0.0",
"semver format": "0.12.3",
"standard version output": "2026.3.9",
"unrelated output": null,
"version with extra whitespace": "2026.3.9",
"version without commit hash": "2026.3.11",
}
`)
})
})
describe('parseUpdateStatus', () => {
const cases = [
{
name: 'npm update available',
input: 'Update available (npm 2026.3.11). Run: openclaw update',
expected: '2026.3.11'
},
{
name: 'pkg update available',
input: 'Update available · pkg · npm update 2026.3.11',
expected: '2026.3.11'
},
{
name: 'update available with semver',
input: 'Update available (npm 1.2.3). Run: openclaw update',
expected: '1.2.3'
},
{
name: 'full table output with update',
input: [
'OpenClaw update status',
'┌──────────┬─────────────────────────────────┐',
'│ Install │ binary (~/.cherrystudio/bin) │',
'│ Channel │ stable (default) │',
'│ Update │ available · pkg · npm update 2026.3.11 │',
'└──────────┴─────────────────────────────────┘',
'',
'Update available (npm 2026.3.11). Run: openclaw update'
].join('\n'),
expected: '2026.3.11'
},
{ name: 'no update available', input: 'Already up to date', expected: null },
{ name: 'empty string', input: '', expected: null },
{ name: 'unrelated output', input: 'some random text', expected: null }
]
it.each(cases)('$name', ({ input, expected }) => {
expect(parseUpdateStatus(input)).toBe(expected)
})
it('snapshot: all cases', () => {
const results = Object.fromEntries(cases.map((c) => [c.name, parseUpdateStatus(c.input)]))
expect(results).toMatchInlineSnapshot(`
{
"empty string": null,
"full table output with update": "2026.3.11",
"no update available": null,
"npm update available": "2026.3.11",
"pkg update available": "2026.3.11",
"unrelated output": null,
"update available with semver": "1.2.3",
}
`)
})
})

View File

@@ -0,0 +1,18 @@
/**
* Parse the current version from `openclaw --version` output.
* Example input: "OpenClaw 2026.3.9 (fe96034)"
*/
export function parseCurrentVersion(versionOutput: string): string | null {
const match = versionOutput.match(/OpenClaw\s+([\d.]+)/i)
return match?.[1] ?? null
}
/**
* Parse the update status from `openclaw update status` output.
* Returns the latest version string if an update is available, otherwise null.
* Example input: "Update available (npm 2026.3.11). Run: openclaw update"
*/
export function parseUpdateStatus(statusOutput: string): string | null {
const match = statusOutput.match(/Update available.*?(\d[\d.]+)/i)
return match?.[1] ?? null
}

View File

@@ -1,8 +1,10 @@
import { loggerService } from '@logger'
import type { GitBashPathInfo, GitBashPathSource } from '@shared/config/constant'
import { HOME_CHERRY_DIR } from '@shared/config/constant'
import chardet from 'chardet'
import { type ChildProcess, execFileSync, spawn, type SpawnOptions } from 'child_process'
import fs from 'fs'
import iconv from 'iconv-lite'
import os from 'os'
import path from 'path'
@@ -13,13 +15,13 @@ import getShellEnv, { refreshShellEnv } from './shell-env'
const logger = loggerService.withContext('Utils:Process')
export function runInstallScript(scriptPath: string): Promise<void> {
export function runInstallScript(scriptPath: string, extraEnv?: Record<string, string>): Promise<void> {
return new Promise<void>((resolve, reject) => {
const installScriptPath = path.join(getResourcePath(), 'scripts', scriptPath)
logger.info(`Running script at: ${installScriptPath}`)
const nodeProcess = spawn(process.execPath, [installScriptPath], {
env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' }
env: { ...process.env, ELECTRON_RUN_AS_NODE: '1', ...extraEnv }
})
nodeProcess.stdout.on('data', (data) => {
@@ -241,11 +243,12 @@ export function findExecutable(name: string, options?: FindExecutableOptions): s
try {
// Search without extension - where.exe returns all matches (npm, npm.cmd, npm.exe, etc.)
// We then filter by allowed extensions below for security and precision
const result = execFileSync('where.exe', [name], {
encoding: 'utf8',
const resultBuf = execFileSync('where.exe', [name], {
stdio: ['pipe', 'pipe', 'pipe'],
env: options?.env
})
// where.exe output is file paths (ASCII-safe), decode as utf8
const result = resultBuf.toString('utf8')
// Handle both Windows (\r\n) and Unix (\n) line endings
const paths = result.trim().split(/\r?\n/).filter(Boolean)
@@ -279,8 +282,11 @@ export function findExecutable(name: string, options?: FindExecutableOptions): s
}
return null
} catch (error) {
logger.debug(`where.exe ${name} failed`, { error })
} catch (error: unknown) {
// On Chinese Windows, where.exe stderr is GBK-encoded and gets garbled as UTF-8.
// Log only the exit code to avoid mojibake in logs.
const code = error instanceof Error && 'status' in error ? (error as { status: unknown }).status : undefined
logger.debug(`where.exe ${name} not found (exit code ${code})`)
return null
}
}
@@ -355,13 +361,12 @@ export function findViaMise(name: string, env: Record<string, string>): string |
*/
function findMiseExecutable(env: Record<string, string>): string | null {
try {
const result = execFileSync('where.exe', ['mise'], {
encoding: 'utf8',
const resultBuf = execFileSync('where.exe', ['mise'], {
timeout: MISE_TIMEOUT_MS,
stdio: ['pipe', 'pipe', 'pipe'],
env
})
const firstLine = result.trim().split(/\r?\n/)[0]?.trim()
const firstLine = resultBuf.toString('utf8').trim().split(/\r?\n/)[0]?.trim()
if (firstLine && firstLine.toLowerCase().endsWith('.exe')) {
return firstLine
}
@@ -417,11 +422,35 @@ export function crossPlatformSpawn(
options: SpawnOptions & { env: Record<string, string> }
): ChildProcess {
if (isWin && !command.toLowerCase().endsWith('.exe')) {
return spawn(command, args, { ...options, shell: true, stdio: options.stdio ?? 'pipe' })
// When shell: true, Node passes the command to cmd.exe as:
// cmd /d /s /c "command arg1 arg2"
// If the command path contains spaces (e.g. C:\Program Files\nodejs\npm.cmd),
// cmd.exe splits on the space. Wrapping in quotes fixes this:
// cmd /d /s /c ""C:\Program Files\nodejs\npm.cmd" arg1 arg2"
const quotedCommand = command.includes(' ') && !command.startsWith('"') ? `"${command}"` : command
return spawn(quotedCommand, args, { ...options, shell: true, stdio: options.stdio ?? 'pipe' })
}
return spawn(command, args, { ...options, stdio: options.stdio ?? 'pipe' })
}
/**
* Decode a Buffer from a shell process.
* On Chinese Windows, cmd.exe outputs in the OEM code page (typically GBK/CP936).
* Uses chardet to detect the actual encoding and iconv-lite to decode.
*/
export function decodeBufferFromShell(buf: Buffer): string {
if (!isWin) return buf.toString('utf8')
const detected = chardet.detect(buf)
if (detected && detected !== 'UTF-8') {
try {
return iconv.decode(buf, detected)
} catch {
return buf.toString('utf8')
}
}
return buf.toString('utf8')
}
/**
* Execute a command and return its output.
* Uses crossPlatformSpawn internally for proper Windows .cmd handling.

View File

@@ -12,7 +12,7 @@ import type {
LanHandshakeAckMessage,
LocalTransferConnectPayload,
LocalTransferState,
NodeCheckResult,
OperationResult,
WebviewKeyEvent
} from '@shared/config/types'
import type { MCPServerLogEntry } from '@shared/config/types'
@@ -71,8 +71,6 @@ type OpenClawGatewayStatus = 'stopped' | 'starting' | 'running' | 'error'
interface OpenClawHealthInfo {
status: 'healthy' | 'unhealthy'
gatewayPort: number
uptime?: number
version?: string
}
interface OpenClawChannelInfo {
@@ -675,28 +673,28 @@ const api = {
cancelTransfer: (): Promise<void> => ipcRenderer.invoke(IpcChannel.LocalTransfer_CancelTransfer)
},
openclaw: {
checkInstalled: (): Promise<{ installed: boolean; path: string | null }> =>
checkInstalled: (): Promise<{ installed: boolean; path: string | null; needsMigration: boolean }> =>
ipcRenderer.invoke(IpcChannel.OpenClaw_CheckInstalled),
checkNodeVersion: (): Promise<NodeCheckResult> => ipcRenderer.invoke(IpcChannel.OpenClaw_CheckNodeVersion),
checkGitAvailable: (): Promise<{ available: boolean; path: string | null }> =>
ipcRenderer.invoke(IpcChannel.OpenClaw_CheckGitAvailable),
getNodeDownloadUrl: (): Promise<string> => ipcRenderer.invoke(IpcChannel.OpenClaw_GetNodeDownloadUrl),
getGitDownloadUrl: (): Promise<string> => ipcRenderer.invoke(IpcChannel.OpenClaw_GetGitDownloadUrl),
install: (): Promise<{ success: boolean; message: string }> => ipcRenderer.invoke(IpcChannel.OpenClaw_Install),
uninstall: (): Promise<{ success: boolean; message: string }> => ipcRenderer.invoke(IpcChannel.OpenClaw_Uninstall),
startGateway: (port?: number): Promise<{ success: boolean; message: string }> =>
install: (): Promise<OperationResult> => ipcRenderer.invoke(IpcChannel.OpenClaw_Install),
uninstall: (): Promise<OperationResult> => ipcRenderer.invoke(IpcChannel.OpenClaw_Uninstall),
startGateway: (port?: number): Promise<OperationResult> =>
ipcRenderer.invoke(IpcChannel.OpenClaw_StartGateway, port),
stopGateway: (): Promise<{ success: boolean; message: string }> =>
ipcRenderer.invoke(IpcChannel.OpenClaw_StopGateway),
restartGateway: (): Promise<{ success: boolean; message: string }> =>
ipcRenderer.invoke(IpcChannel.OpenClaw_RestartGateway),
stopGateway: (): Promise<OperationResult> => ipcRenderer.invoke(IpcChannel.OpenClaw_StopGateway),
restartGateway: (): Promise<OperationResult> => ipcRenderer.invoke(IpcChannel.OpenClaw_RestartGateway),
getStatus: (): Promise<{ status: OpenClawGatewayStatus; port: number }> =>
ipcRenderer.invoke(IpcChannel.OpenClaw_GetStatus),
checkHealth: (): Promise<OpenClawHealthInfo> => ipcRenderer.invoke(IpcChannel.OpenClaw_CheckHealth),
getDashboardUrl: (): Promise<string> => ipcRenderer.invoke(IpcChannel.OpenClaw_GetDashboardUrl),
syncConfig: (provider: Provider, primaryModel: Model): Promise<{ success: boolean; message: string }> =>
syncConfig: (provider: Provider, primaryModel: Model): Promise<OperationResult> =>
ipcRenderer.invoke(IpcChannel.OpenClaw_SyncConfig, provider, primaryModel),
getChannels: (): Promise<OpenClawChannelInfo[]> => ipcRenderer.invoke(IpcChannel.OpenClaw_GetChannels)
getChannels: (): Promise<OpenClawChannelInfo[]> => ipcRenderer.invoke(IpcChannel.OpenClaw_GetChannels),
checkUpdate: (): Promise<{
hasUpdate: boolean
currentVersion: string | null
latestVersion: string | null
message?: string
}> => ipcRenderer.invoke(IpcChannel.OpenClaw_CheckUpdate),
performUpdate: (): Promise<OperationResult> => ipcRenderer.invoke(IpcChannel.OpenClaw_PerformUpdate)
},
analytics: {
trackTokenUsage: (data: TokenUsageData) => ipcRenderer.invoke(IpcChannel.Analytics_TrackTokenUsage, data)

View File

@@ -2595,6 +2595,11 @@
},
"install_progress": "Install Progress",
"installed_at": "OpenClaw installed at",
"migration": {
"description": "An outdated version of OpenClaw installed via npm has been detected. Please reinstall to get the latest official version.",
"install_button": "Reinstall OpenClaw",
"title": "OpenClaw Needs Update"
},
"model_config": {
"auth_token": "Auth Token",
"auth_token_hint": "Token for gateway authentication (required). Will be auto-generated if empty.",
@@ -2630,6 +2635,7 @@
"windows_title": "Windows"
},
"quick_actions": {
"check_update": "Check for Updates",
"open_dashboard": "Open Dashboard",
"title": "Quick Actions",
"uninstall": "Uninstall",
@@ -2656,6 +2662,16 @@
"uninstalling": {
"description": "Please wait while OpenClaw is being uninstalled...",
"title": "Uninstalling OpenClaw"
},
"update": {
"available": "New version available: v{{latest}} (current: v{{current}})",
"checking": "Checking for updates...",
"confirm_button": "Update Now",
"failed": "Failed to check for updates",
"modal_title": "OpenClaw Update",
"success": "Update completed successfully!",
"up_to_date": "Already up to date (v{{current}})",
"updating": "Updating..."
}
},
"ovms": {

View File

@@ -2595,6 +2595,11 @@
},
"install_progress": "安装进度",
"installed_at": "OpenClaw 安装路径",
"migration": {
"description": "检测到通过 npm 安装的旧版本 OpenClaw请重新安装以获取最新官方版本。",
"install_button": "重新安装 OpenClaw",
"title": "OpenClaw 需要更新"
},
"model_config": {
"auth_token": "认证令牌",
"auth_token_hint": "网关认证令牌(必填)。留空时将自动生成。",
@@ -2630,6 +2635,7 @@
"windows_title": "Windows"
},
"quick_actions": {
"check_update": "检查更新",
"open_dashboard": "打开控制面板",
"title": "快捷操作",
"uninstall": "卸载",
@@ -2656,6 +2662,16 @@
"uninstalling": {
"description": "正在卸载 OpenClaw请稍候...",
"title": "正在卸载 OpenClaw"
},
"update": {
"available": "发现新版本v{{latest}}当前版本v{{current}}",
"checking": "正在检查更新...",
"confirm_button": "立即更新",
"failed": "检查更新失败",
"modal_title": "OpenClaw 更新",
"success": "更新成功!",
"up_to_date": "已是最新版本v{{current}}",
"updating": "更新中..."
}
},
"ovms": {

View File

@@ -2595,6 +2595,11 @@
},
"install_progress": "安裝進度",
"installed_at": "OpenClaw 安裝路徑",
"migration": {
"description": "偵測到透過 npm 安裝的舊版本 OpenClaw請重新安裝以取得最新官方版本。",
"install_button": "重新安裝 OpenClaw",
"title": "OpenClaw 需要更新"
},
"model_config": {
"auth_token": "認證令牌",
"auth_token_hint": "閘道認證令牌(必填)。留空時將自動產生。",
@@ -2630,6 +2635,7 @@
"windows_title": "Windows"
},
"quick_actions": {
"check_update": "檢查更新",
"open_dashboard": "開啟控制面板",
"title": "快捷操作",
"uninstall": "解除安裝",
@@ -2656,6 +2662,16 @@
"uninstalling": {
"description": "請稍候,正在解除安裝 OpenClaw...",
"title": "解除安裝 OpenClaw"
},
"update": {
"available": "發現新版本v{{latest}}目前版本v{{current}}",
"checking": "正在檢查更新...",
"confirm_button": "立即更新",
"failed": "檢查更新失敗",
"modal_title": "OpenClaw 更新",
"success": "更新成功!",
"up_to_date": "已是最新版本v{{current}}",
"updating": "更新中..."
}
},
"ovms": {

View File

@@ -2595,6 +2595,11 @@
},
"install_progress": "Installationsfortschritt",
"installed_at": "OpenClaw installiert unter",
"migration": {
"description": "Eine veraltete Version von OpenClaw, die über npm installiert wurde, wurde erkannt. Bitte installiere sie erneut, um die neueste offizielle Version zu erhalten.",
"install_button": "OpenClaw neu installieren",
"title": "OpenClaw benötigt ein Update"
},
"model_config": {
"auth_token": "Auth-Token",
"auth_token_hint": "Token für Gateway-Authentifizierung. Leer lassen, um die Authentifizierung zu deaktivieren.",
@@ -2630,6 +2635,7 @@
"windows_title": "Windows"
},
"quick_actions": {
"check_update": "Nach Updates suchen",
"open_dashboard": "Dashboard öffnen",
"title": "Schnellaktionen",
"uninstall": "Deinstallieren",
@@ -2656,6 +2662,16 @@
"uninstalling": {
"description": "Bitte warten Sie, während OpenClaw deinstalliert wird...",
"title": "Deinstallation von OpenClaw"
},
"update": {
"available": "Neue Version verfügbar: v{{latest}} (aktuell: v{{current}})",
"checking": "Nach Updates wird gesucht...",
"confirm_button": "Jetzt aktualisieren",
"failed": "Fehler bei der Überprüfung auf Updates",
"modal_title": "OpenClaw-Update",
"success": "Aktualisierung erfolgreich abgeschlossen!",
"up_to_date": "Bereits auf dem neuesten Stand (v{{current}})",
"updating": "Aktualisierung..."
}
},
"ovms": {

View File

@@ -2595,6 +2595,11 @@
},
"install_progress": "Πρόοδος Εγκατάστασης",
"installed_at": "Το OpenClaw εγκαταστάθηκε στο",
"migration": {
"description": "Έχει εντοπιστεί παλιά έκδοση του OpenClaw που εγκαταστάθηκε μέσω npm. Παρακαλούμε επανεγκαταστήστε για να αποκτήσετε την τελευταία επίσημη έκδοση.",
"install_button": "Επανεγκαταστήστε το OpenClaw",
"title": "Το OpenClaw χρειάζεται ενημέρωση"
},
"model_config": {
"auth_token": "Διακριτικό Πιστοποίησης",
"auth_token_hint": "Διακριτικό για έλεγχο ταυτότητας πύλης. Αφήστε το κενό για να απενεργοποιήσετε τον έλεγχο ταυτότητας.",
@@ -2630,6 +2635,7 @@
"windows_title": "Windows"
},
"quick_actions": {
"check_update": "Έλεγχος για ενημερώσεις",
"open_dashboard": "Άνοιγμα Πίνακα Ελέγχου",
"title": "Γρήγορες Ενέργειες",
"uninstall": "Απεγκατάσταση",
@@ -2656,6 +2662,16 @@
"uninstalling": {
"description": "Παρακαλώ περιμένετε ενώ γίνεται η απεγκατάσταση του OpenClaw...",
"title": "Απεγκατάσταση του OpenClaw"
},
"update": {
"available": "Νέα έκδοση διαθέσιμη: v{{latest}} (τρέχουσα: v{{current}})",
"checking": "Έλεγχος για ενημερώσεις...",
"confirm_button": "Ενημέρωση Τώρα",
"failed": "Αποτυχία ελέγχου για ενημερώσεις",
"modal_title": "Ενημέρωση OpenClaw",
"success": "Η ενημέρωση ολοκληρώθηκε με επιτυχία!",
"up_to_date": "Ήδη ενημερωμένο (v{{current}})",
"updating": "Ενημέρωση..."
}
},
"ovms": {

View File

@@ -2595,6 +2595,11 @@
},
"install_progress": "Progreso de instalación",
"installed_at": "OpenClaw instalado en",
"migration": {
"description": "Se ha detectado una versión desactualizada de OpenClaw instalada a través de npm. Por favor, reinstala para obtener la última versión oficial.",
"install_button": "Reinstalar OpenClaw",
"title": "OpenClaw Necesita Actualización"
},
"model_config": {
"auth_token": "Token de autenticación",
"auth_token_hint": "Token para autenticación de puerta de enlace. Dejar vacío para desactivar la autenticación.",
@@ -2630,6 +2635,7 @@
"windows_title": "Windows"
},
"quick_actions": {
"check_update": "Buscar actualizaciones",
"open_dashboard": "Abrir Panel de Control",
"title": "Acciones Rápidas",
"uninstall": "Desinstalar",
@@ -2656,6 +2662,16 @@
"uninstalling": {
"description": "Por favor espera mientras se desinstala OpenClaw...",
"title": "Desinstalando OpenClaw"
},
"update": {
"available": "Nueva versión disponible: v{{latest}} (actual: v{{current}})",
"checking": "Comprobando actualizaciones...",
"confirm_button": "Actualizar ahora",
"failed": "Error al verificar actualizaciones",
"modal_title": "Actualización de OpenClaw",
"success": "¡Actualización completada con éxito!",
"up_to_date": "Ya está actualizado (v{{current}})",
"updating": "Actualizando..."
}
},
"ovms": {

View File

@@ -2595,6 +2595,11 @@
},
"install_progress": "Progression de l'installation",
"installed_at": "OpenClaw installé à",
"migration": {
"description": "Une version obsolète dOpenClaw installée via npm a été détectée. Veuillez réinstaller pour obtenir la dernière version officielle.",
"install_button": "Réinstaller OpenClaw",
"title": "OpenClaw nécessite une mise à jour"
},
"model_config": {
"auth_token": "Jeton d'authentification",
"auth_token_hint": "Jeton d'authentification de la passerelle. Laissez vide pour désactiver l'authentification.",
@@ -2630,6 +2635,7 @@
"windows_title": "Windows"
},
"quick_actions": {
"check_update": "Vérifier les mises à jour",
"open_dashboard": "Ouvrir le tableau de bord",
"title": "Actions rapides",
"uninstall": "Désinstaller",
@@ -2656,6 +2662,16 @@
"uninstalling": {
"description": "Veuillez patienter pendant la désinstallation d'OpenClaw...",
"title": "Désinstallation d'OpenClaw"
},
"update": {
"available": "Nouvelle version disponible : v{{latest}} (actuelle : v{{current}})",
"checking": "Vérification des mises à jour...",
"confirm_button": "Mettre à jour maintenant",
"failed": "Échec de la vérification des mises à jour",
"modal_title": "Mise à jour d'OpenClaw",
"success": "Mise à jour terminée avec succès !",
"up_to_date": "Déjà à jour (v{{current}})",
"updating": "Mise à jour..."
}
},
"ovms": {

View File

@@ -2595,6 +2595,11 @@
},
"install_progress": "インストール進行状況",
"installed_at": "OpenClawのインストール先",
"migration": {
"description": "npm経由でインストールされた古いバージョンのOpenClawが検出されました。最新の公式バージョンを入手するために、再インストールをお願いします。",
"install_button": "OpenClawを再インストール",
"title": "OpenClawはアップデートが必要"
},
"model_config": {
"auth_token": "認証トークン",
"auth_token_hint": "ゲートウェイ認証用のトークン。認証を無効にする場合は空のままにしてください。",
@@ -2630,6 +2635,7 @@
"windows_title": "Windows"
},
"quick_actions": {
"check_update": "アップデートを確認",
"open_dashboard": "ダッシュボードを開く",
"title": "クイックアクション",
"uninstall": "アンインストール",
@@ -2656,6 +2662,16 @@
"uninstalling": {
"description": "OpenClawがアンインストールされるまでお待ちください...",
"title": "OpenClawのアンインストール"
},
"update": {
"available": "新しいバージョンが利用可能です: v{{latest}}(現在: v{{current}}",
"checking": "アップデートを確認中...",
"confirm_button": "今すぐアップデート",
"failed": "アップデートの確認に失敗しました",
"modal_title": "OpenClawアップデート",
"success": "アップデートが正常に完了しました!",
"up_to_date": "すでに最新です (v{{current}})",
"updating": "更新中..."
}
},
"ovms": {

View File

@@ -2595,6 +2595,11 @@
},
"install_progress": "Progresso da Instalação",
"installed_at": "OpenClaw instalado em",
"migration": {
"description": "Foi detectada uma versão desatualizada do OpenClaw instalada via npm. Por favor, reinstale para obter a versão oficial mais recente.",
"install_button": "Reinstale o OpenClaw",
"title": "OpenClaw Precisa de Atualização"
},
"model_config": {
"auth_token": "Token de Autenticação",
"auth_token_hint": "Token para autenticação do gateway. Deixe vazio para desativar a autenticação.",
@@ -2630,6 +2635,7 @@
"windows_title": "Windows"
},
"quick_actions": {
"check_update": "Verificar Atualizações",
"open_dashboard": "Abrir Painel",
"title": "Ações Rápidas",
"uninstall": "Desinstalar",
@@ -2656,6 +2662,16 @@
"uninstalling": {
"description": "Aguarde enquanto o OpenClaw está sendo desinstalado...",
"title": "Desinstalando OpenClaw"
},
"update": {
"available": "Nova versão disponível: v{{latest}} (atual: v{{current}})",
"checking": "Verificando atualizações...",
"confirm_button": "Atualizar agora",
"failed": "Falha ao verificar atualizações",
"modal_title": "Atualização do OpenClaw",
"success": "Atualização concluída com sucesso!",
"up_to_date": "Já está atualizado (v{{current}})",
"updating": "Atualizando..."
}
},
"ovms": {

View File

@@ -7,7 +7,9 @@
"invalid_agent": "Agent invalid"
},
"model": {
"tooltip": "Momentan, doar modelele care acceptă endpoint-uri Anthropic sunt disponibile pentru funcția Agent."
"supported_providers": "Furnizori Acceptați",
"tooltip": "Momentan, doar modelele care acceptă endpoint-uri Anthropic sunt disponibile pentru funcția Agent.",
"view_providers": "Vezi furnizorii acceptați"
},
"title": "Adaugă agent",
"type": {
@@ -2593,6 +2595,11 @@
},
"install_progress": "Progresul instalării",
"installed_at": "OpenClaw instalat la",
"migration": {
"description": "A fost detectată o versiune învechită a OpenClaw instalată prin npm. Te rugăm să reinstalezi pentru a obține cea mai recentă versiune oficială.",
"install_button": "Reinstalați OpenClaw",
"title": "OpenClaw Necesită Actualizare"
},
"model_config": {
"auth_token": "Token de autentificare",
"auth_token_hint": "Token pentru autentificarea gateway-ului. Lăsați gol pentru a dezactiva autentificarea.",
@@ -2628,6 +2635,7 @@
"windows_title": "Windows"
},
"quick_actions": {
"check_update": "Verifică actualizările",
"open_dashboard": "Deschide Tabloul de Bord",
"title": "Acțiuni rapide",
"uninstall": "Dezinstalare",
@@ -2654,6 +2662,16 @@
"uninstalling": {
"description": "Vă rugăm să așteptați în timp ce OpenClaw este dezinstalat...",
"title": "Dezinstalarea OpenClaw"
},
"update": {
"available": "Versiune nouă disponibilă: v{{latest}} (versiunea curentă: v{{current}})",
"checking": "Se verifică actualizările...",
"confirm_button": "Actualizează acum",
"failed": "Nu s-a reușit verificarea actualizărilor",
"modal_title": "Actualizare OpenClaw",
"success": "Actualizare finalizată cu succes!",
"up_to_date": "Deja actualizat (v{{current}})",
"updating": "Se actualizează..."
}
},
"ovms": {
@@ -5029,6 +5047,11 @@
},
"docs_check": "Verifică",
"docs_more_details": "pentru mai multe detalii",
"filter": {
"agent": "Asistat de agent",
"all": "Toți furnizorii"
},
"filter_agent": "Furnizori acceptați de Agentul de filtrare",
"get_api_key": "Obține cheie API",
"misc": "Altele",
"no_models_for_check": "Nu există modele disponibile pentru verificare (de ex. modele chat)",

View File

@@ -2595,6 +2595,11 @@
},
"install_progress": "Прогресс установки",
"installed_at": "OpenClaw установлен в",
"migration": {
"description": "Обнаружена устаревшая версия OpenClaw, установленная через npm. Пожалуйста, переустановите её, чтобы получить последнюю официальную версию.",
"install_button": "Переустановите OpenClaw",
"title": "OpenClaw требует обновления"
},
"model_config": {
"auth_token": "Токен аутентификации",
"auth_token_hint": "Токен для аутентификации шлюза. Оставьте пустым, чтобы отключить аутентификацию.",
@@ -2630,6 +2635,7 @@
"windows_title": "Windows"
},
"quick_actions": {
"check_update": "Проверить обновления",
"open_dashboard": "Открыть панель управления",
"title": "Быстрые действия",
"uninstall": "Удалить",
@@ -2656,6 +2662,16 @@
"uninstalling": {
"description": "Пожалуйста, подождите, пока OpenClaw удаляется...",
"title": "Удаление OpenClaw"
},
"update": {
"available": "Новая версия доступна: v{{latest}} (текущая: v{{current}})",
"checking": "Проверка обновлений...",
"confirm_button": "Обновить сейчас",
"failed": "Не удалось проверить наличие обновлений",
"modal_title": "Обновление OpenClaw",
"success": "Обновление завершено успешно!",
"up_to_date": "Уже актуально (v{{current}})",
"updating": "Обновление..."
}
},
"ovms": {

View File

@@ -13,7 +13,6 @@ import {
setLastHealthCheck,
setSelectedModelUniqId
} from '@renderer/store/openclaw'
import type { NodeCheckResult } from '@shared/config/types'
import { IpcChannel } from '@shared/IpcChannel'
import { Alert, Avatar, Button, Result, Space, Spin } from 'antd'
import { Download, ExternalLink, Play, RefreshCw, Square } from 'lucide-react'
@@ -21,19 +20,12 @@ import type { FC } from 'react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import UpdateButton from './components/UpdateButton'
const logger = loggerService.withContext('OpenClawPage')
const DEFAULT_DOCS_URL = 'https://docs.openclaw.ai/'
type NodeStatus = { status: 'not_found' } | { status: 'version_low'; version: string } | { status: 'ok' }
/** Map the IPC result to the UI-side NodeStatus (drops the `path` field the renderer doesn't need). */
function toNodeStatus(result: NodeCheckResult): NodeStatus {
if (result.status === 'version_low') return { status: 'version_low', version: result.version }
if (result.status === 'ok') return { status: 'ok' }
return { status: 'not_found' }
}
interface TitleSectionProps {
title: string
description: string
@@ -77,10 +69,11 @@ const OpenClawPage: FC = () => {
return DEFAULT_DOCS_URL
}, [i18n.language])
const { gatewayStatus, gatewayPort, selectedModelUniqId, lastHealthCheck } = useAppSelector((state) => state.openclaw)
const { gatewayStatus, gatewayPort, selectedModelUniqId } = useAppSelector((state) => state.openclaw)
const [error, setError] = useState<string | null>(null)
const [isInstalled, setIsInstalled] = useState<boolean | null>(null) // null = unknown, checking in background
const [needsMigration, setNeedsMigration] = useState(false)
const [installPath, setInstallPath] = useState<string | null>(null)
const [installError, setInstallError] = useState<string | null>(null)
@@ -95,59 +88,7 @@ const OpenClawPage: FC = () => {
const [installLogs, setInstallLogs] = useState<Array<{ message: string; type: 'info' | 'warn' | 'error' }>>([])
const [showLogs, setShowLogs] = useState(false)
const [uninstallSuccess, setUninstallSuccess] = useState(false)
const [nodeStatus, setNodeStatus] = useState<NodeStatus | null>(null)
const [gitMissing, setGitMissing] = useState(false)
const [nodeDownloadUrl, setNodeDownloadUrl] = useState<string>('https://nodejs.org/')
const [gitDownloadUrl, setGitDownloadUrl] = useState<string>('https://git-scm.com/downloads')
// Fetch Node.js download URL and poll node availability when node issue is shown
useEffect(() => {
if (!nodeStatus || nodeStatus.status === 'ok') return
// Fetch the download URL from main process
window.api.openclaw
.getNodeDownloadUrl()
.then(setNodeDownloadUrl)
.catch(() => {})
// Poll node version availability
const pollInterval = setInterval(async () => {
try {
const result = await window.api.openclaw.checkNodeVersion()
if (result.status === 'ok') {
setNodeStatus({ status: 'ok' })
}
} catch {
// Ignore errors during polling
}
}, 3000)
return () => clearInterval(pollInterval)
}, [nodeStatus])
// Fetch Git download URL and poll git availability when gitMissing is shown
useEffect(() => {
if (!gitMissing) return
// Fetch the download URL from main process
window.api.openclaw
.getGitDownloadUrl()
.then(setGitDownloadUrl)
.catch(() => {})
const pollInterval = setInterval(async () => {
try {
const gitCheck = await window.api.openclaw.checkGitAvailable()
if (gitCheck.available) {
setGitMissing(false)
}
} catch {
// Ignore errors during polling
}
}, 3000)
return () => clearInterval(pollInterval)
}, [gitMissing])
const [isOpenClawUpdating, setIsOpenClawUpdating] = useState(false)
const noApiKeyProviders = ['ollama', 'lmstudio', 'gpustack']
const availableProviders = providers.filter((p) => p.enabled && (p.apiKey || noApiKeyProviders.includes(p.type)))
@@ -184,20 +125,9 @@ const OpenClawPage: FC = () => {
try {
const result = await window.api.openclaw.checkInstalled()
setIsInstalled(result.installed)
setNeedsMigration(result.needsMigration)
setShowLogs(false)
setInstallPath(result.path)
// If not installed, check node version and git availability in parallel
if (!result.installed) {
const [nodeResult, gitResult] = await Promise.allSettled([
window.api.openclaw.checkNodeVersion(),
window.api.openclaw.checkGitAvailable()
])
if (nodeResult.status === 'fulfilled') {
setNodeStatus(toNodeStatus(nodeResult.value))
}
if (gitResult.status === 'fulfilled') setGitMissing(!gitResult.value.available)
}
} catch (err) {
logger.debug('Failed to check installation', err as Error)
setIsInstalled(false)
@@ -205,28 +135,6 @@ const OpenClawPage: FC = () => {
}, [])
const handleInstall = useCallback(async () => {
// Check node version and git availability in parallel before installing
const [nodeResult, gitResult] = await Promise.allSettled([
window.api.openclaw.checkNodeVersion(),
window.api.openclaw.checkGitAvailable()
])
if (nodeResult.status === 'rejected' || gitResult.status === 'rejected') {
logger.error('Failed to check tool availability')
return
}
const nodeCheck = toNodeStatus(nodeResult.value)
if (nodeCheck.status !== 'ok') {
setNodeStatus(nodeCheck)
return
}
if (!gitResult.value.available) {
setGitMissing(true)
return
}
setNodeStatus(nodeCheck)
setGitMissing(false)
setIsInstalling(true)
setInstallError(null)
setInstallLogs([])
@@ -350,6 +258,7 @@ const OpenClawPage: FC = () => {
const syncResult = await window.api.openclaw.syncConfig(selectedProvider, selectedModel)
if (!syncResult.success) {
setError(syncResult.message)
setIsStarting(false)
return
}
@@ -357,6 +266,7 @@ const OpenClawPage: FC = () => {
const startResult = await window.api.openclaw.startGateway(gatewayPort)
if (!startResult.success) {
setError(startResult.message)
setIsStarting(false)
return
}
@@ -461,8 +371,8 @@ const OpenClawPage: FC = () => {
<div className="mx-auto min-h-fit w-130 shrink-0">
<Result
icon={<Avatar src={OpenClawLogo} size={64} shape="square" style={{ borderRadius: 12 }} />}
title={t('openclaw.not_installed.title')}
subTitle={t('openclaw.not_installed.description')}
title={t(needsMigration ? 'openclaw.migration.title' : 'openclaw.not_installed.title')}
subTitle={t(needsMigration ? 'openclaw.migration.description' : 'openclaw.not_installed.description')}
extra={
<Space>
<Button
@@ -471,7 +381,7 @@ const OpenClawPage: FC = () => {
disabled={isInstalling}
onClick={handleInstall}
loading={isInstalling}>
{t('openclaw.not_installed.install_button')}
{t(needsMigration ? 'openclaw.migration.install_button' : 'openclaw.not_installed.install_button')}
</Button>
<Button
icon={<ExternalLink size={16} />}
@@ -482,86 +392,6 @@ const OpenClawPage: FC = () => {
</Space>
}
/>
<div className="mt-4 space-y-3" style={{ width: 580, marginLeft: -30 }}>
{nodeStatus?.status === 'not_found' && (
<Alert
message={t('openclaw.node_missing.title')}
description={
<div>
<p>{t('openclaw.node_missing.description')}</p>
<Space style={{ marginTop: 8 }}>
<Button
type="primary"
icon={<Download size={16} />}
onClick={() => window.open(nodeDownloadUrl, '_blank')}>
{t('openclaw.node_missing.download_button')}
</Button>
</Space>
<p className="mt-3 text-xs" style={{ color: 'var(--color-text-3)' }}>
{t('openclaw.node_missing.hint')}
</p>
</div>
}
type="warning"
showIcon
closable
onClose={() => setNodeStatus(null)}
className="rounded-lg!"
/>
)}
{nodeStatus?.status === 'version_low' && (
<Alert
message={t('openclaw.node_version_low.title')}
description={
<div>
<p>{t('openclaw.node_version_low.description', { version: nodeStatus.version })}</p>
<Space style={{ marginTop: 8 }}>
<Button
type="primary"
icon={<Download size={16} />}
onClick={() => window.open(nodeDownloadUrl, '_blank')}>
{t('openclaw.node_missing.download_button')}
</Button>
</Space>
<p className="mt-3 text-xs" style={{ color: 'var(--color-text-3)' }}>
{t('openclaw.node_version_low.hint')}
</p>
</div>
}
type="warning"
showIcon
closable
onClose={() => setNodeStatus(null)}
className="rounded-lg!"
/>
)}
{gitMissing && (
<Alert
message={t('openclaw.git_missing.title')}
description={
<div>
<p>{t('openclaw.git_missing.description')}</p>
<Space style={{ marginTop: 8 }}>
<Button
type="primary"
icon={<Download size={16} />}
onClick={() => window.open(gitDownloadUrl, '_blank')}>
{t('openclaw.git_missing.download_button')}
</Button>
</Space>
<p className="mt-3 text-xs" style={{ color: 'var(--color-text-3)' }}>
{t('openclaw.git_missing.hint')}
</p>
</div>
}
type="warning"
showIcon
closable
onClose={() => setGitMissing(false)}
className="rounded-lg!"
/>
)}
</div>
{installError && (
<Alert
message={installError}
@@ -590,7 +420,7 @@ const OpenClawPage: FC = () => {
style={{ background: 'var(--color-background-soft)', color: 'var(--color-text-3)' }}>
<div className="min-w-0 shrink overflow-hidden">
<div className="mb-1">{t('openclaw.installed_at')}</div>
<div className="flex gap-2">
<div className="flex items-center gap-2">
<div className="truncate text-xs" title={installPath}>
{installPath}
</div>
@@ -609,6 +439,7 @@ const OpenClawPage: FC = () => {
}
}}
/>
<UpdateButton onUpdateComplete={checkInstallation} onUpdatingChange={setIsOpenClawUpdating} />
</div>
</div>
<span
@@ -630,11 +461,6 @@ const OpenClawPage: FC = () => {
<span className="font-medium text-sm" style={{ color: 'var(--color-text-1)' }}>
{t('openclaw.status.running')}
</span>
{lastHealthCheck?.version && (
<span className="text-xs" style={{ color: 'var(--color-text-3)' }}>
v{lastHealthCheck.version}
</span>
)}
<span className="font-mono text-[13px]" style={{ color: 'var(--color-text-3)' }}>
:{gatewayPort}
</span>
@@ -666,7 +492,31 @@ const OpenClawPage: FC = () => {
{/* Error Alert */}
{error && (
<div className="mb-6">
<Alert message={error} type="error" closable onClose={() => setError(null)} className="rounded-lg!" />
<Alert
message={
<div className="flex items-start justify-between gap-2">
<span className="flex-1">{error}</span>
<Button
type="link"
className="h-auto! w-3! shrink-0 p-0!"
aria-label={t('common.copy')}
icon={<CopyIcon className="size-3!" />}
onClick={async () => {
try {
await navigator.clipboard.writeText(error)
window.toast.success(t('common.copied'))
} catch (err) {
window.toast.error(t('common.copy_failed'))
}
}}
/>
</div>
}
type="error"
closable
onClose={() => setError(null)}
className="rounded-lg!"
/>
</div>
)}
@@ -712,7 +562,9 @@ const OpenClawPage: FC = () => {
icon={<Play size={16} />}
onClick={handleStartGateway}
loading={isStarting || gatewayStatus === 'starting'}
disabled={!selectedProvider || !selectedModel || isStarting || gatewayStatus === 'starting'}
disabled={
!selectedProvider || !selectedModel || isStarting || gatewayStatus === 'starting' || isOpenClawUpdating
}
size="large"
block>
{t('openclaw.gateway.start')}

View File

@@ -0,0 +1,103 @@
import { loggerService } from '@renderer/services/LoggerService'
import { ArrowUpCircle, Loader2 } from 'lucide-react'
import type { FC } from 'react'
import { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
const logger = loggerService.withContext('UpdateButton')
export interface UpdateInfo {
hasUpdate: boolean
currentVersion: string | null
latestVersion: string | null
message?: string
}
interface UpdateButtonProps {
onUpdateComplete?: () => void
onUpdatingChange?: (isUpdating: boolean) => void
}
const UpdateButton: FC<UpdateButtonProps> = ({ onUpdateComplete, onUpdatingChange }) => {
const { t } = useTranslation()
const [updateInfo, setUpdateInfo] = useState<UpdateInfo | null>(null)
const [isUpdating, setIsUpdating] = useState(false)
// Notify parent when updating state changes
useEffect(() => {
onUpdatingChange?.(isUpdating)
}, [isUpdating, onUpdatingChange])
const checkUpdate = useCallback(async () => {
try {
const result = await window.api.openclaw.checkUpdate()
setUpdateInfo(result)
} catch (err) {
logger.error('Failed to check for updates', err as Error)
}
}, [])
const performUpdate = useCallback(async () => {
setIsUpdating(true)
try {
const result = await window.api.openclaw.performUpdate()
if (result.success) {
setUpdateInfo(null)
window.toast.success(t('openclaw.update.success'))
onUpdateComplete?.()
} else {
window.toast.error(result.message)
}
} catch (err) {
logger.error('Failed to update OpenClaw', err as Error)
window.toast.error(err instanceof Error ? err.message : String(err))
} finally {
setIsUpdating(false)
}
}, [onUpdateComplete, t])
// Check for updates on mount
useEffect(() => {
checkUpdate()
}, [checkUpdate])
const handleClick = () => {
if (isUpdating) return
window.modal.confirm({
title: t('openclaw.update.modal_title'),
content: t('openclaw.update.available', {
latest: updateInfo?.latestVersion,
current: updateInfo?.currentVersion
}),
okText: t('openclaw.update.confirm_button'),
cancelText: t('common.cancel'),
centered: true,
onOk: () => {
// Start update without waiting, modal closes immediately
performUpdate()
}
})
}
// Don't render if no update available and not updating
if (!updateInfo?.hasUpdate && !isUpdating) {
return null
}
return (
<span className="inline-flex cursor-pointer items-center gap-1" onClick={handleClick}>
{isUpdating ? (
<Loader2 className="size-3! animate-spin" style={{ color: 'var(--color-primary)' }} />
) : (
<ArrowUpCircle className="size-3!" color="var(--color-primary)" />
)}
<span className="text-xs" style={{ color: 'var(--color-primary)' }}>
{isUpdating ? t('openclaw.update.updating') : `v${updateInfo?.latestVersion}`}
</span>
</span>
)
}
export default UpdateButton

View File

@@ -13,8 +13,6 @@ export interface ChannelInfo {
export interface HealthInfo {
status: 'healthy' | 'unhealthy'
gatewayPort: number
uptime?: number
version?: string
}
export interface OpenClawState {