mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2026-07-06 14:01:30 +08:00
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:
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
230
resources/scripts/install-openclaw.js
Normal file
230
resources/scripts/install-openclaw.js
Normal 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)
|
||||
})
|
||||
@@ -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
90
src/main/services/__tests__/OpenClawService.test.ts
Normal file
90
src/main/services/__tests__/OpenClawService.test.ts
Normal 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",
|
||||
}
|
||||
`)
|
||||
})
|
||||
})
|
||||
18
src/main/services/utils/openClawParsers.ts
Normal file
18
src/main/services/utils/openClawParsers.ts
Normal 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
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -2595,6 +2595,11 @@
|
||||
},
|
||||
"install_progress": "Progression de l'installation",
|
||||
"installed_at": "OpenClaw installé à",
|
||||
"migration": {
|
||||
"description": "Une version obsolète d’OpenClaw 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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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')}
|
||||
|
||||
103
src/renderer/src/pages/openclaw/components/UpdateButton.tsx
Normal file
103
src/renderer/src/pages/openclaw/components/UpdateButton.tsx
Normal 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
|
||||
@@ -13,8 +13,6 @@ export interface ChannelInfo {
|
||||
export interface HealthInfo {
|
||||
status: 'healthy' | 'unhealthy'
|
||||
gatewayPort: number
|
||||
uptime?: number
|
||||
version?: string
|
||||
}
|
||||
|
||||
export interface OpenClawState {
|
||||
|
||||
Reference in New Issue
Block a user