diff --git a/desktop/.gitignore b/desktop/.gitignore index a547bf3..b96ad5a 100644 --- a/desktop/.gitignore +++ b/desktop/.gitignore @@ -11,6 +11,16 @@ node_modules dist dist-ssr *.local +src-tauri/resources/openclaw-runtime/* +!src-tauri/resources/openclaw-runtime/.gitkeep +local-tools/* +!local-tools/.gitkeep +!local-tools/NSIS/ +!local-tools/NSIS/.gitkeep +!local-tools/WixTools/ +!local-tools/WixTools/.gitkeep +installer-smoke/ +msi-smoke/ # Editor directories and files .vscode/* @@ -22,3 +32,4 @@ dist-ssr *.njsproj *.sln *.sw? +desktop/src-tauri/resources/openfang-runtime/openfang.exe diff --git a/desktop/package.json b/desktop/package.json index 691928c..c2d21ac 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -7,7 +7,17 @@ "dev": "vite", "build": "tsc && vite build", "preview": "vite preview", - "tauri": "tauri" + "prepare:openclaw-runtime": "node scripts/prepare-openclaw-runtime.mjs", + "prepare:openclaw-runtime:dry-run": "node scripts/prepare-openclaw-runtime.mjs --dry-run", + "prepare:openfang-runtime": "node scripts/prepare-openfang-runtime.mjs", + "prepare:openfang-runtime:dry-run": "node scripts/prepare-openfang-runtime.mjs --dry-run", + "prepare:tauri-tools": "node scripts/preseed-tauri-tools.mjs", + "prepare:tauri-tools:dry-run": "node scripts/preseed-tauri-tools.mjs --dry-run", + "tauri": "tauri", + "tauri:build:bundled": "pnpm prepare:openfang-runtime && node scripts/tauri-build-bundled.mjs", + "tauri:build:bundled:debug": "pnpm prepare:openfang-runtime && node scripts/tauri-build-bundled.mjs --debug", + "tauri:build:nsis:debug": "pnpm prepare:openfang-runtime && node scripts/tauri-build-bundled.mjs --debug --bundles nsis", + "tauri:build:msi:debug": "pnpm prepare:openfang-runtime && node scripts/tauri-build-bundled.mjs --debug --bundles msi" }, "dependencies": { "@tauri-apps/api": "^2", @@ -15,6 +25,7 @@ "lucide-react": "^0.577.0", "react": "^19.1.0", "react-dom": "^19.1.0", + "tweetnacl": "^1.0.3", "zustand": "^5.0.11" }, "devDependencies": { diff --git a/desktop/scripts/prepare-openfang-runtime.mjs b/desktop/scripts/prepare-openfang-runtime.mjs new file mode 100644 index 0000000..ebf12de --- /dev/null +++ b/desktop/scripts/prepare-openfang-runtime.mjs @@ -0,0 +1,423 @@ +#!/usr/bin/env node +/** + * OpenFang Runtime Preparation Script + * + * Prepares the OpenFang binary for bundling with Tauri. + * Supports cross-platform: Windows, Linux, macOS + * + * Usage: + * node scripts/prepare-openfang-runtime.mjs + * node scripts/prepare-openfang-runtime.mjs --dry-run + * OPENFANG_VERSION=v1.2.3 node scripts/prepare-openfang-runtime.mjs + */ + +import { execSync, execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { arch as osArch, platform as osPlatform, homedir } from 'node:os'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const desktopRoot = path.resolve(__dirname, '..'); +const outputDir = path.join(desktopRoot, 'src-tauri', 'resources', 'openfang-runtime'); +const dryRun = process.argv.includes('--dry-run'); +const openfangVersion = process.env.OPENFANG_VERSION || 'latest'; + +const PLATFORM = osPlatform(); +const ARCH = osArch(); + +function log(message) { + console.log(`[prepare-openfang-runtime] ${message}`); +} + +function warn(message) { + console.warn(`[prepare-openfang-runtime] WARN: ${message}`); +} + +function error(message) { + console.error(`[prepare-openfang-runtime] ERROR: ${message}`); +} + +/** + * Get platform-specific binary configuration + * OpenFang releases: .zip for Windows, .tar.gz for Unix + */ +function getPlatformConfig() { + const configs = { + win32: { + x64: { + binaryName: 'openfang.exe', + downloadName: 'openfang-x86_64-pc-windows-msvc.zip', + archiveFormat: 'zip', + }, + arm64: { + binaryName: 'openfang.exe', + downloadName: 'openfang-aarch64-pc-windows-msvc.zip', + archiveFormat: 'zip', + }, + }, + darwin: { + x64: { + binaryName: 'openfang-x86_64-apple-darwin', + downloadName: 'openfang-x86_64-apple-darwin.tar.gz', + archiveFormat: 'tar.gz', + }, + arm64: { + binaryName: 'openfang-aarch64-apple-darwin', + downloadName: 'openfang-aarch64-apple-darwin.tar.gz', + archiveFormat: 'tar.gz', + }, + }, + linux: { + x64: { + binaryName: 'openfang-x86_64-unknown-linux-gnu', + downloadName: 'openfang-x86_64-unknown-linux-gnu.tar.gz', + archiveFormat: 'tar.gz', + }, + arm64: { + binaryName: 'openfang-aarch64-unknown-linux-gnu', + downloadName: 'openfang-aarch64-unknown-linux-gnu.tar.gz', + archiveFormat: 'tar.gz', + }, + }, + }; + + const platformConfig = configs[PLATFORM]; + if (!platformConfig) { + throw new Error(`Unsupported platform: ${PLATFORM}`); + } + + const archConfig = platformConfig[ARCH]; + if (!archConfig) { + throw new Error(`Unsupported architecture: ${ARCH} on ${PLATFORM}`); + } + + return archConfig; +} + +/** + * Find OpenFang binary in system PATH + */ +function findSystemBinary() { + const override = process.env.OPENFANG_BIN; + if (override) { + if (fs.existsSync(override)) { + return override; + } + throw new Error(`OPENFANG_BIN specified but file not found: ${override}`); + } + + try { + let result; + if (PLATFORM === 'win32') { + result = execFileSync('where.exe', ['openfang'], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }); + } else { + result = execFileSync('which', ['openfang'], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }); + } + + const binaryPath = result.split(/\r?\n/).map(s => s.trim()).find(Boolean); + if (binaryPath && fs.existsSync(binaryPath)) { + return binaryPath; + } + } catch { + // Binary not found in PATH + } + + return null; +} + +/** + * Check if OpenFang is installed via install script + */ +function findInstalledBinary() { + const config = getPlatformConfig(); + const home = homedir(); + + const possiblePaths = [ + // Default install location + path.join(home, '.openfang', 'bin', config.binaryName), + path.join(home, '.local', 'bin', config.binaryName), + // macOS + path.join(home, '.openfang', 'bin', 'openfang'), + '/usr/local/bin/openfang', + '/usr/bin/openfang', + ]; + + for (const p of possiblePaths) { + if (fs.existsSync(p)) { + return p; + } + } + + return null; +} + +/** + * Download OpenFang binary from GitHub Releases + * Handles .zip for Windows, .tar.gz for Unix + */ +function downloadBinary(config) { + const baseUrl = 'https://github.com/RightNow-AI/openfang/releases'; + const downloadUrl = openfangVersion === 'latest' + ? `${baseUrl}/latest/download/${config.downloadName}` + : `${baseUrl}/download/${openfangVersion}/${config.downloadName}`; + + const archivePath = path.join(outputDir, config.downloadName); + const binaryOutputPath = path.join(outputDir, config.binaryName); + + log(`Downloading OpenFang binary...`); + log(` Platform: ${PLATFORM} (${ARCH})`); + log(` Version: ${openfangVersion}`); + log(` Archive: ${config.downloadName}`); + log(` URL: ${downloadUrl}`); + + if (dryRun) { + log('DRY RUN: Would download and extract binary'); + return null; + } + + // Ensure output directory exists + fs.mkdirSync(outputDir, { recursive: true }); + + try { + // Download archive using curl (works on all platforms) + log('Downloading archive...'); + execSync(`curl -fsSL -o "${archivePath}" "${downloadUrl}"`, { stdio: 'inherit' }); + + if (!fs.existsSync(archivePath)) { + throw new Error('Download failed - archive not created'); + } + + // Extract archive + log('Extracting binary...'); + if (config.archiveFormat === 'zip') { + // Use PowerShell to extract zip on Windows + execSync( + `powershell -Command "Expand-Archive -Path '${archivePath}' -DestinationPath '${outputDir}' -Force"`, + { stdio: 'inherit' } + ); + } else { + // Use tar for .tar.gz on Unix + execSync(`tar -xzf "${archivePath}" -C "${outputDir}"`, { stdio: 'inherit' }); + } + + // Find and rename the extracted binary + // The archive contains a single binary file + const extractedFiles = fs.readdirSync(outputDir).filter(f => + f.startsWith('openfang') && !f.endsWith('.zip') && !f.endsWith('.tar.gz') && !f.endsWith('.sha256') + ); + + if (extractedFiles.length === 0) { + throw new Error('No binary found in archive'); + } + + // Use the first extracted binary (should be only one) + const extractedBinary = path.join(outputDir, extractedFiles[0]); + log(`Found extracted binary: ${extractedFiles[0]}`); + + // Rename to standard name if needed + if (extractedFiles[0] !== config.binaryName) { + if (fs.existsSync(binaryOutputPath)) { + fs.unlinkSync(binaryOutputPath); + } + fs.renameSync(extractedBinary, binaryOutputPath); + log(`Renamed to: ${config.binaryName}`); + } + + // Make executable on Unix + if (PLATFORM !== 'win32') { + fs.chmodSync(binaryOutputPath, 0o755); + } + + // Clean up archive + fs.unlinkSync(archivePath); + log('Cleaned up archive file'); + + log(`Binary ready at: ${binaryOutputPath}`); + return binaryOutputPath; + } catch (err) { + error(`Failed to download/extract: ${err.message}`); + // Clean up partial files + if (fs.existsSync(archivePath)) { + fs.unlinkSync(archivePath); + } + return null; + } +} + +/** + * Copy binary to output directory + */ +function copyBinary(sourcePath, config) { + if (dryRun) { + log(`DRY RUN: Would copy binary from ${sourcePath}`); + return; + } + + fs.mkdirSync(outputDir, { recursive: true }); + const destPath = path.join(outputDir, config.binaryName); + fs.copyFileSync(sourcePath, destPath); + + // Make executable on Unix + if (PLATFORM !== 'win32') { + fs.chmodSync(destPath, 0o755); + } + + log(`Copied binary to: ${destPath}`); +} + +/** + * Write runtime manifest + */ +function writeManifest(config) { + if (dryRun) { + log('DRY RUN: Would write manifest'); + return; + } + + const manifest = { + source: { + binPath: config.binaryName, + binPathLinux: 'openfang-x86_64-unknown-linux-gnu', + binPathMac: 'openfang-x86_64-apple-darwin', + binPathMacArm: 'openfang-aarch64-apple-darwin', + }, + stagedAt: new Date().toISOString(), + version: openfangVersion === 'latest' + ? new Date().toISOString().split('T')[0].replace(/-/g, '.') + : openfangVersion, + runtimeType: 'openfang', + description: 'OpenFang Agent OS - Single binary runtime (~32MB)', + endpoints: { + websocket: 'ws://127.0.0.1:4200/ws', + rest: 'http://127.0.0.1:4200/api', + }, + platform: { + os: PLATFORM, + arch: ARCH, + }, + }; + + const manifestPath = path.join(outputDir, 'runtime-manifest.json'); + fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2), 'utf8'); + log(`Manifest written to: ${manifestPath}`); +} + +/** + * Write launcher scripts for convenience + */ +function writeLauncherScripts(config) { + if (dryRun) { + log('DRY RUN: Would write launcher scripts'); + return; + } + + // Windows launcher + const cmdLauncher = [ + '@echo off', + 'REM OpenFang Agent OS - Bundled Binary Launcher', + `"%~dp0${config.binaryName}" %*`, + '', + ].join('\r\n'); + fs.writeFileSync(path.join(outputDir, 'openfang.cmd'), cmdLauncher, 'utf8'); + + // Unix launcher + const shLauncher = [ + '#!/bin/bash', + '# OpenFang Agent OS - Bundled Binary Launcher', + `SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"`, + `exec "$SCRIPT_DIR/${config.binaryName}" "$@"`, + '', + ].join('\n'); + const shPath = path.join(outputDir, 'openfang.sh'); + fs.writeFileSync(shPath, shLauncher, 'utf8'); + fs.chmodSync(shPath, 0o755); + + log('Launcher scripts written'); +} + +/** + * Clean old OpenClaw runtime files + */ +function cleanOldRuntime() { + const oldPaths = [ + path.join(outputDir, 'node.exe'), + path.join(outputDir, 'node_modules'), + path.join(outputDir, 'openclaw.cmd'), + ]; + + for (const p of oldPaths) { + if (fs.existsSync(p)) { + if (dryRun) { + log(`DRY RUN: Would remove ${p}`); + } else { + fs.rmSync(p, { recursive: true, force: true }); + log(`Removed old file: ${p}`); + } + } + } +} + +/** + * Main function + */ +function main() { + log('='.repeat(60)); + log('OpenFang Runtime Preparation'); + log('='.repeat(60)); + + const config = getPlatformConfig(); + log(`Platform: ${PLATFORM} (${ARCH})`); + log(`Binary: ${config.binaryName}`); + log(`Output: ${outputDir}`); + + // Clean old OpenClaw runtime + cleanOldRuntime(); + + // Try to find existing binary + let binaryPath = findSystemBinary(); + + if (binaryPath) { + log(`Found OpenFang in PATH: ${binaryPath}`); + copyBinary(binaryPath, config); + } else { + binaryPath = findInstalledBinary(); + if (binaryPath) { + log(`Found installed OpenFang: ${binaryPath}`); + copyBinary(binaryPath, config); + } else { + log('OpenFang not found locally, downloading...'); + const downloaded = downloadBinary(config); + if (!downloaded && !dryRun) { + error('Failed to obtain OpenFang binary!'); + error(''); + error('Please either:'); + error(' 1. Install OpenFang: curl -fsSL https://openfang.sh/install | sh'); + error(' 2. Set OPENFANG_BIN environment variable to binary path'); + error(' 3. Manually download from: https://github.com/RightNow-AI/openfang/releases'); + process.exit(1); + } + } + } + + // Write supporting files + writeManifest(config); + writeLauncherScripts(config); + + log('='.repeat(60)); + if (dryRun) { + log('DRY RUN complete. No files were written.'); + } else { + log('OpenFang runtime ready for build!'); + } + log('='.repeat(60)); +} + +main(); diff --git a/desktop/src-tauri/resources/openfang-runtime/README.md b/desktop/src-tauri/resources/openfang-runtime/README.md new file mode 100644 index 0000000..77a75ce --- /dev/null +++ b/desktop/src-tauri/resources/openfang-runtime/README.md @@ -0,0 +1,76 @@ +# OpenFang Bundled Runtime + +This directory contains the bundled OpenFang runtime for ZClaw Desktop. + +## Architecture + +OpenFang is a **single Rust binary** (~32MB) that runs as the Agent OS backend. + +``` +openfang-runtime/ +├── openfang.exe # Windows binary +├── openfang-x86_64-unknown-linux-gnu # Linux x64 binary +├── openfang-aarch64-unknown-linux-gnu # Linux ARM64 binary +├── openfang-x86_64-apple-darwin # macOS Intel binary +├── openfang-aarch64-apple-darwin # macOS Apple Silicon binary +├── runtime-manifest.json # Runtime metadata +├── openfang.cmd # Windows launcher +├── openfang.sh # Unix launcher +├── download-openfang.ps1 # Windows download script +└── download-openfang.sh # Unix download script +``` + +## Setup + +### Option 1: Download Binary + +**Windows (PowerShell):** +```powershell +cd desktop/src-tauri/resources/openfang-runtime +.\download-openfang.ps1 +``` + +**Linux/macOS:** +```bash +cd desktop/src-tauri/resources/openfang-runtime +chmod +x download-openfang.sh +./download-openfang.sh +``` + +### Option 2: Manual Download + +1. Go to https://github.com/RightNow-AI/openfang/releases +2. Download the appropriate binary for your platform +3. Place it in this directory + +## Build Integration + +The Tauri build process will include this directory in the application bundle: + +```json +// tauri.conf.json +{ + "bundle": { + "resources": ["resources/openfang-runtime/"] + } +} +``` + +## Runtime Resolution + +ZClaw Desktop resolves the OpenFang runtime in this order: + +1. `ZCLAW_OPENFANG_BIN` environment variable (for development) +2. Bundled `openfang-runtime/` directory +3. System PATH (`openfang`) + +## Endpoints + +- **WebSocket**: `ws://127.0.0.1:4200/ws` +- **REST API**: `http://127.0.0.1:4200/api` + +## Version Info + +- OpenFang Version: 2026.3.13 +- Port: 4200 (was 18789 for OpenClaw) +- Config: `~/.openfang/openfang.toml` (was `~/.openclaw/openclaw.json`) diff --git a/desktop/src-tauri/resources/openfang-runtime/openfang.cmd b/desktop/src-tauri/resources/openfang-runtime/openfang.cmd new file mode 100644 index 0000000..d7ab79c --- /dev/null +++ b/desktop/src-tauri/resources/openfang-runtime/openfang.cmd @@ -0,0 +1,3 @@ +@echo off +REM OpenFang Agent OS - Bundled Binary Launcher +"%~dp0openfang.exe" %* diff --git a/desktop/src-tauri/resources/openfang-runtime/openfang.exe b/desktop/src-tauri/resources/openfang-runtime/openfang.exe new file mode 100644 index 0000000..9938360 Binary files /dev/null and b/desktop/src-tauri/resources/openfang-runtime/openfang.exe differ diff --git a/desktop/src-tauri/resources/openfang-runtime/openfang.sh b/desktop/src-tauri/resources/openfang-runtime/openfang.sh new file mode 100644 index 0000000..08e15d7 --- /dev/null +++ b/desktop/src-tauri/resources/openfang-runtime/openfang.sh @@ -0,0 +1,4 @@ +#!/bin/bash +# OpenFang Agent OS - Bundled Binary Launcher +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +exec "$SCRIPT_DIR/openfang.exe" "$@" diff --git a/desktop/src-tauri/resources/openfang-runtime/runtime-manifest.json b/desktop/src-tauri/resources/openfang-runtime/runtime-manifest.json new file mode 100644 index 0000000..22a5fec --- /dev/null +++ b/desktop/src-tauri/resources/openfang-runtime/runtime-manifest.json @@ -0,0 +1,20 @@ +{ + "source": { + "binPath": "openfang.exe", + "binPathLinux": "openfang-x86_64-unknown-linux-gnu", + "binPathMac": "openfang-x86_64-apple-darwin", + "binPathMacArm": "openfang-aarch64-apple-darwin" + }, + "stagedAt": "2026-03-13T09:08:38.514Z", + "version": "2026.03.13", + "runtimeType": "openfang", + "description": "OpenFang Agent OS - Single binary runtime (~32MB)", + "endpoints": { + "websocket": "ws://127.0.0.1:4200/ws", + "rest": "http://127.0.0.1:4200/api" + }, + "platform": { + "os": "win32", + "arch": "x64" + } +} \ No newline at end of file diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 4a277ef..1cafabb 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -1,14 +1,832 @@ -// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/ +// OpenFang Kernel integration for ZClaw desktop app +// Supports OpenFang Kernel (successor to OpenClaw Gateway) +// - Port: 4200 (was 18789) +// - Binary: openfang (was openclaw) +// - Config: ~/.openfang/openfang.toml (was ~/.openclaw/openclaw.json) +use serde::Serialize; +use serde_json::{json, Value}; +use std::fs; +use std::path::PathBuf; +use std::process::Command; +use std::thread; +use std::time::Duration; +use tauri::{AppHandle, Manager}; + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct LocalGatewayStatus { + supported: bool, + cli_available: bool, + runtime_source: Option, + runtime_path: Option, + service_label: Option, + service_loaded: bool, + service_status: Option, + config_ok: bool, + port: Option, + port_status: Option, + probe_url: Option, + listener_pids: Vec, + error: Option, + raw: Value, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct LocalGatewayAuth { + config_path: Option, + gateway_token: Option, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct LocalGatewayPrepareResult { + config_path: Option, + origins_updated: bool, + gateway_restarted: bool, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct LocalGatewayPairingApprovalResult { + approved: bool, + request_id: Option, + device_id: Option, +} + +struct OpenFangRuntime { + source: String, + executable: PathBuf, + pre_args: Vec, + display_path: PathBuf, +} + +struct OpenFangCommandOutput { + stdout: String, + runtime: OpenFangRuntime, +} + +/// Default OpenFang Kernel port +const OPENFANG_DEFAULT_PORT: u16 = 4200; + +const TAURI_ALLOWED_ORIGINS: [&str; 2] = ["http://tauri.localhost", "tauri://localhost"]; + +fn command_error(runtime: &OpenFangRuntime, error: std::io::Error) -> String { + if error.kind() == std::io::ErrorKind::NotFound { + match runtime.source.as_str() { + "bundled" => format!( + "未找到 ZCLAW 内置 OpenFang 运行时:{}", + runtime.display_path.display() + ), + "development" => format!( + "未找到开发态 OpenFang 运行时:{}", + runtime.display_path.display() + ), + "override" => format!( + "未找到 ZCLAW_OPENFANG_BIN 指定的 OpenFang 运行时:{}", + runtime.display_path.display() + ), + _ => "未找到 OpenFang 运行时。请重新安装 ZCLAW,或在开发环境中安装 OpenFang CLI。" + .to_string(), + } + } else { + format!("运行 OpenFang 失败: {error}") + } +} + +fn runtime_path_string(runtime: &OpenFangRuntime) -> String { + runtime.display_path.display().to_string() +} + +fn binary_extension() -> &'static str { + if cfg!(target_os = "windows") { + ".exe" + } else { + "" + } +} + +fn openfang_sidecar_filename() -> String { + format!("openfang-{}{}", env!("TARGET"), binary_extension()) +} + +fn openfang_plain_filename() -> String { + format!("openfang{}", binary_extension()) +} + +fn push_runtime_candidate(candidates: &mut Vec, source: &str, executable: PathBuf) { + if candidates.iter().any(|candidate| candidate.display_path == executable) { + return; + } + + candidates.push(OpenFangRuntime { + source: source.to_string(), + display_path: executable.clone(), + executable, + pre_args: Vec::new(), + }); +} + +/// Build binary runtime (OpenFang is a single binary, not npm package) +fn build_binary_runtime(source: &str, root_dir: &PathBuf) -> Option { + // Try platform-specific binary names + let binary_names = get_platform_binary_names(); + + for name in binary_names { + let binary_path = root_dir.join(&name); + if binary_path.is_file() { + return Some(OpenFangRuntime { + source: source.to_string(), + executable: binary_path.clone(), + pre_args: Vec::new(), + display_path: binary_path, + }); + } + } + None +} + +/// Get platform-specific binary names for OpenFang +fn get_platform_binary_names() -> Vec { + let mut names = Vec::new(); + + if cfg!(target_os = "windows") { + names.push("openfang.exe".to_string()); + names.push(format!("openfang-{}.exe", env!("TARGET"))); + } else if cfg!(target_os = "macos") { + if cfg!(target_arch = "aarch64") { + names.push("openfang-aarch64-apple-darwin".to_string()); + } else { + names.push("openfang-x86_64-apple-darwin".to_string()); + } + names.push(format!("openfang-{}", env!("TARGET"))); + names.push("openfang".to_string()); + } else { + // Linux + if cfg!(target_arch = "aarch64") { + names.push("openfang-aarch64-unknown-linux-gnu".to_string()); + } else { + names.push("openfang-x86_64-unknown-linux-gnu".to_string()); + } + names.push(format!("openfang-{}", env!("TARGET"))); + names.push("openfang".to_string()); + } + + names +} + +/// Legacy: Build staged runtime using Node.js (for backward compatibility) +#[allow(dead_code)] +fn build_staged_runtime_legacy(source: &str, root_dir: PathBuf) -> Option { + let node_executable = root_dir.join(if cfg!(target_os = "windows") { + "node.exe" + } else { + "node" + }); + let entrypoint = root_dir + .join("node_modules") + .join("openfang") + .join("openfang.mjs"); + + if !node_executable.is_file() || !entrypoint.is_file() { + return None; + } + + Some(OpenFangRuntime { + source: source.to_string(), + executable: node_executable, + pre_args: vec![entrypoint.display().to_string()], + display_path: root_dir, + }) +} + +/// Build staged runtime - prefers binary, falls back to Node.js for legacy support +fn build_staged_runtime(source: &str, root_dir: PathBuf) -> Option { + // First, try to find the binary directly + if let Some(runtime) = build_binary_runtime(source, &root_dir) { + return Some(runtime); + } + + // Fallback to Node.js-based runtime for backward compatibility + build_staged_runtime_legacy(source, root_dir) +} + +fn push_staged_runtime_candidate(candidates: &mut Vec, source: &str, root_dir: PathBuf) { + if candidates.iter().any(|candidate| candidate.display_path == root_dir) { + return; + } + + if let Some(runtime) = build_staged_runtime(source, root_dir) { + candidates.push(runtime); + } +} + +fn bundled_runtime_candidates(app: &AppHandle) -> Vec { + let mut candidates = Vec::new(); + let sidecar_name = openfang_sidecar_filename(); + let plain_name = openfang_plain_filename(); + let platform_names = get_platform_binary_names(); + + if let Ok(resource_dir) = app.path().resource_dir() { + // Primary: openfang-runtime directory (contains binary + manifest) + push_staged_runtime_candidate( + &mut candidates, + "bundled", + resource_dir.join("openfang-runtime"), + ); + + // Alternative: binaries directory + for name in &platform_names { + push_runtime_candidate( + &mut candidates, + "bundled", + resource_dir.join("binaries").join(name), + ); + } + + // Alternative: root level binaries + push_runtime_candidate(&mut candidates, "bundled", resource_dir.join(&plain_name)); + push_runtime_candidate(&mut candidates, "bundled", resource_dir.join(&sidecar_name)); + } + + if let Ok(current_exe) = std::env::current_exe() { + if let Some(exe_dir) = current_exe.parent() { + // Windows NSIS installer location + push_staged_runtime_candidate( + &mut candidates, + "bundled", + exe_dir.join("resources").join("openfang-runtime"), + ); + + // Alternative: binaries next to exe + for name in &platform_names { + push_runtime_candidate( + &mut candidates, + "bundled", + exe_dir.join("binaries").join(name), + ); + } + + push_runtime_candidate(&mut candidates, "bundled", exe_dir.join(&plain_name)); + push_runtime_candidate(&mut candidates, "bundled", exe_dir.join(&sidecar_name)); + } + } + + // Development mode + let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + push_staged_runtime_candidate( + &mut candidates, + "development", + manifest_dir.join("resources").join("openfang-runtime"), + ); + + for name in &platform_names { + push_runtime_candidate( + &mut candidates, + "development", + manifest_dir.join("binaries").join(name), + ); + } + + candidates +} + +/// Resolve OpenFang runtime location +/// Priority: ZCLAW_OPENFANG_BIN env > bundled > system PATH +fn resolve_openfang_runtime(app: &AppHandle) -> OpenFangRuntime { + if let Ok(override_path) = std::env::var("ZCLAW_OPENFANG_BIN") { + let override_path = PathBuf::from(override_path); + if override_path.is_dir() { + if let Some(runtime) = build_staged_runtime("override", override_path.clone()) { + return runtime; + } + } + + return OpenFangRuntime { + source: "override".to_string(), + display_path: override_path.clone(), + executable: override_path, + pre_args: Vec::new(), + }; + } + + if let Some(runtime) = bundled_runtime_candidates(app) + .into_iter() + .find(|candidate| candidate.executable.is_file()) + { + return runtime; + } + + OpenFangRuntime { + source: "system".to_string(), + display_path: PathBuf::from("openfang"), + executable: PathBuf::from("openfang"), + pre_args: Vec::new(), + } +} + +/// Resolve OpenFang config path (TOML format) +/// Priority: OPENFANG_HOME env > ~/.openfang/ +fn resolve_openfang_config_path() -> Option { + if let Ok(value) = std::env::var("OPENFANG_HOME") { + return Some(PathBuf::from(value).join("openfang.toml")); + } + + if let Ok(value) = std::env::var("HOME") { + return Some(PathBuf::from(value).join(".openfang").join("openfang.toml")); + } + + if let Ok(value) = std::env::var("USERPROFILE") { + return Some(PathBuf::from(value).join(".openfang").join("openfang.toml")); + } + + None +} + +/// Parse TOML config and extract gateway token +fn read_local_gateway_auth() -> Result { + let config_path = resolve_openfang_config_path() + .ok_or_else(|| "未找到 OpenFang 配置目录。".to_string())?; + let config_text = fs::read_to_string(&config_path) + .map_err(|error| format!("读取 OpenFang 配置失败: {error}"))?; + + // Parse TOML format - simple extraction for gateway.token + let gateway_token = extract_toml_token(&config_text); + + Ok(LocalGatewayAuth { + config_path: Some(config_path.display().to_string()), + gateway_token, + }) +} + +/// Extract gateway.token from TOML config text +fn extract_toml_token(config_text: &str) -> Option { + // Simple TOML parsing for gateway.token + // Format: token = "value" under [gateway] section + let mut in_gateway_section = false; + for line in config_text.lines() { + let trimmed = line.trim(); + if trimmed.starts_with("[gateway") { + in_gateway_section = true; + continue; + } + if trimmed.starts_with('[') && !trimmed.starts_with("[gateway") { + in_gateway_section = false; + continue; + } + if in_gateway_section && trimmed.starts_with("token") { + if let Some(eq_pos) = trimmed.find('=') { + let value = trimmed[eq_pos + 1..].trim(); + // Remove quotes + let value = value.trim_matches('"').trim_matches('\''); + if !value.is_empty() { + return Some(value.to_string()); + } + } + } + } + None +} + +/// Ensure Tauri origins are allowed in OpenFang config +fn ensure_tauri_allowed_origins(config_text: &str) -> (String, bool) { + let mut lines: Vec = config_text.lines().map(|s| s.to_string()).collect(); + let mut changed = false; + let mut in_control_ui = false; + let mut has_allowed_origins = false; + + // Find or create [gateway.controlUi] section with allowedOrigins + for i in 0..lines.len() { + let trimmed = lines[i].trim(); + + if trimmed.starts_with("[gateway.controlUi") || trimmed == "[gateway.controlUi]" { + in_control_ui = true; + } else if trimmed.starts_with('[') && in_control_ui { + in_control_ui = false; + } + + if in_control_ui && trimmed.starts_with("allowedOrigins") { + has_allowed_origins = true; + // Check if all required origins are present + for origin in TAURI_ALLOWED_ORIGINS { + if !lines[i].contains(origin) { + // Append origin to the array + // This is a simple approach - for production, use proper TOML parsing + if lines[i].ends_with(']') { + let insert_pos = lines[i].len() - 1; + lines[i].insert_str(insert_pos, &format!(", \"{}\"", origin)); + changed = true; + } + } + } + } + } + + // If no allowedOrigins found, add the section + if !has_allowed_origins { + // Find [gateway] section and add controlUi after it + for i in 0..lines.len() { + if lines[i].trim().starts_with("[gateway]") || lines[i].trim() == "[gateway]" { + // Insert controlUi section after gateway + let origins: String = TAURI_ALLOWED_ORIGINS + .iter() + .map(|s| format!("\"{}\"", s)) + .collect::>() + .join(", "); + lines.insert(i + 1, format!("[gateway.controlUi]")); + lines.insert(i + 2, format!("allowedOrigins = [{}]", origins)); + changed = true; + break; + } + } + + // If no [gateway] section found, create it + if !changed { + let origins: String = TAURI_ALLOWED_ORIGINS + .iter() + .map(|s| format!("\"{}\"", s)) + .collect::>() + .join(", "); + lines.push("[gateway]".to_string()); + lines.push("[gateway.controlUi]".to_string()); + lines.push(format!("allowedOrigins = [{}]", origins)); + changed = true; + } + } + + (lines.join("\n"), changed) +} + +fn ensure_local_gateway_ready_for_tauri(app: &AppHandle) -> Result { + let config_path = resolve_openfang_config_path() + .ok_or_else(|| "未找到 OpenFang 配置目录。".to_string())?; + let config_text = fs::read_to_string(&config_path) + .map_err(|error| format!("读取 OpenFang 配置失败: {error}"))?; + + let (updated_config, origins_updated) = ensure_tauri_allowed_origins(&config_text); + + if origins_updated { + fs::write(&config_path, format!("{}\n", updated_config)) + .map_err(|error| format!("写入 OpenFang 配置失败: {error}"))?; + } + + let mut gateway_restarted = false; + if origins_updated { + if let Ok(status) = read_gateway_status(app) { + if status.port_status.as_deref() == Some("busy") || !status.listener_pids.is_empty() { + run_openfang(app, &["gateway", "restart", "--json"])?; + thread::sleep(Duration::from_millis(1200)); + gateway_restarted = true; + } + } + } + + Ok(LocalGatewayPrepareResult { + config_path: Some(config_path.display().to_string()), + origins_updated, + gateway_restarted, + }) +} + +fn approve_local_device_pairing( + app: &AppHandle, + device_id: &str, + public_key_base64: &str, + url: Option<&str>, +) -> Result { + let local_auth = read_local_gateway_auth()?; + let gateway_token = local_auth + .gateway_token + .ok_or_else(|| "本地 Gateway token 不可用,无法自动批准设备配对。".to_string())?; + + let devices_output = run_openfang(app, &["devices", "list", "--json"])?; + let devices_json = parse_json_output(&devices_output.stdout)?; + let pending = devices_json + .get("pending") + .and_then(Value::as_array) + .ok_or_else(|| "设备列表输出缺少 pending 数组。".to_string())?; + + let pending_request = pending.iter().find(|entry| { + entry.get("deviceId").and_then(Value::as_str) == Some(device_id) + && entry.get("publicKey").and_then(Value::as_str) == Some(public_key_base64) + }); + + let Some(request) = pending_request else { + return Ok(LocalGatewayPairingApprovalResult { + approved: false, + request_id: None, + device_id: Some(device_id.to_string()), + }); + }; + + let request_id = request + .get("requestId") + .and_then(Value::as_str) + .ok_or_else(|| "待批准设备缺少 requestId。".to_string())? + .to_string(); + + // Use OpenFang default port 4200 + let gateway_url = url.unwrap_or("ws://127.0.0.1:4200").to_string(); + let args = vec![ + "devices".to_string(), + "approve".to_string(), + request_id.clone(), + "--json".to_string(), + "--token".to_string(), + gateway_token, + "--url".to_string(), + gateway_url, + ]; + let arg_refs = args.iter().map(|value| value.as_str()).collect::>(); + run_openfang(app, &arg_refs)?; + thread::sleep(Duration::from_millis(300)); + + Ok(LocalGatewayPairingApprovalResult { + approved: true, + request_id: Some(request_id), + device_id: Some(device_id.to_string()), + }) +} + +fn run_openfang(app: &AppHandle, args: &[&str]) -> Result { + let runtime = resolve_openfang_runtime(app); + let mut command = Command::new(&runtime.executable); + command.args(&runtime.pre_args).args(args); + let output = command.output().map_err(|error| command_error(&runtime, error))?; + + if output.status.success() { + Ok(OpenFangCommandOutput { + stdout: String::from_utf8_lossy(&output.stdout).trim().to_string(), + runtime, + }) + } else { + let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + let message = if stderr.is_empty() { + stdout + } else if stdout.is_empty() { + stderr + } else { + format!("{stderr}\n{stdout}") + }; + + if message.is_empty() { + Err(format!("OpenFang {:?} 执行失败: {}", args, output.status)) + } else { + Err(message) + } + } +} + +fn parse_json_output(stdout: &str) -> Result { + if let Ok(raw) = serde_json::from_str::(stdout) { + return Ok(raw); + } + + if let Some(index) = stdout.find('{') { + let trimmed = &stdout[index..]; + return serde_json::from_str::(trimmed) + .map_err(|error| format!("解析 Gateway 状态失败: {error}")); + } + + Err("Gateway 状态输出不包含可解析的 JSON。".to_string()) +} + +fn unavailable_status(error: String, runtime: Option<&OpenFangRuntime>) -> LocalGatewayStatus { + LocalGatewayStatus { + supported: true, + cli_available: false, + runtime_source: runtime.map(|value| value.source.clone()), + runtime_path: runtime.map(runtime_path_string), + service_label: None, + service_loaded: false, + service_status: None, + config_ok: false, + port: None, + port_status: None, + probe_url: None, + listener_pids: Vec::new(), + error: Some(error), + raw: json!({}), + } +} + +fn parse_gateway_status(raw: Value, runtime: &OpenFangRuntime) -> LocalGatewayStatus { + let listener_pids = raw + .get("port") + .and_then(|port| port.get("listeners")) + .and_then(Value::as_array) + .map(|listeners| { + listeners + .iter() + .filter_map(|listener| listener.get("pid").and_then(Value::as_u64)) + .filter_map(|pid| u32::try_from(pid).ok()) + .collect::>() + }) + .unwrap_or_default(); + + LocalGatewayStatus { + supported: true, + cli_available: true, + runtime_source: Some(runtime.source.clone()), + runtime_path: Some(runtime_path_string(runtime)), + service_label: raw + .get("service") + .and_then(|service| service.get("label")) + .and_then(Value::as_str) + .map(ToOwned::to_owned), + service_loaded: raw + .get("service") + .and_then(|service| service.get("loaded")) + .and_then(Value::as_bool) + .unwrap_or(false), + service_status: raw + .get("service") + .and_then(|service| service.get("runtime")) + .and_then(|runtime| runtime.get("status")) + .and_then(Value::as_str) + .map(ToOwned::to_owned), + config_ok: raw + .get("service") + .and_then(|service| service.get("configAudit")) + .and_then(|config_audit| config_audit.get("ok")) + .and_then(Value::as_bool) + .unwrap_or(false), + port: raw + .get("gateway") + .and_then(|gateway| gateway.get("port")) + .and_then(Value::as_u64) + .and_then(|port| u16::try_from(port).ok()) + .or(Some(OPENFANG_DEFAULT_PORT)), + port_status: raw + .get("port") + .and_then(|port| port.get("status")) + .and_then(Value::as_str) + .map(ToOwned::to_owned), + probe_url: raw + .get("gateway") + .and_then(|gateway| gateway.get("probeUrl")) + .and_then(Value::as_str) + .map(ToOwned::to_owned), + listener_pids, + error: None, + raw, + } +} + +fn read_gateway_status(app: &AppHandle) -> Result { + match run_openfang(app, &["gateway", "status", "--json", "--no-probe"]) { + Ok(result) => { + let raw = parse_json_output(&result.stdout)?; + Ok(parse_gateway_status(raw, &result.runtime)) + } + Err(error) => { + let runtime = resolve_openfang_runtime(app); + Ok(unavailable_status(error, Some(&runtime))) + } + } +} + +// ============================================================================ +// Tauri Commands - OpenFang (with backward-compatible aliases) +// ============================================================================ + +/// Get OpenFang Kernel status #[tauri::command] -fn greet(name: &str) -> String { - format!("Hello, {}! You've been greeted from Rust!", name) +fn openfang_status(app: AppHandle) -> Result { + read_gateway_status(&app) +} + +/// Start OpenFang Kernel +#[tauri::command] +fn openfang_start(app: AppHandle) -> Result { + ensure_local_gateway_ready_for_tauri(&app)?; + run_openfang(&app, &["gateway", "start", "--json"])?; + thread::sleep(Duration::from_millis(800)); + read_gateway_status(&app) +} + +/// Stop OpenFang Kernel +#[tauri::command] +fn openfang_stop(app: AppHandle) -> Result { + run_openfang(&app, &["gateway", "stop", "--json"])?; + thread::sleep(Duration::from_millis(800)); + read_gateway_status(&app) +} + +/// Restart OpenFang Kernel +#[tauri::command] +fn openfang_restart(app: AppHandle) -> Result { + ensure_local_gateway_ready_for_tauri(&app)?; + run_openfang(&app, &["gateway", "restart", "--json"])?; + thread::sleep(Duration::from_millis(1200)); + read_gateway_status(&app) +} + +/// Get local auth token from OpenFang config +#[tauri::command] +fn openfang_local_auth() -> Result { + read_local_gateway_auth() +} + +/// Prepare OpenFang for Tauri (update allowed origins) +#[tauri::command] +fn openfang_prepare_for_tauri(app: AppHandle) -> Result { + ensure_local_gateway_ready_for_tauri(&app) +} + +/// Approve device pairing request +#[tauri::command] +fn openfang_approve_device_pairing( + app: AppHandle, + device_id: String, + public_key_base64: String, + url: Option, +) -> Result { + approve_local_device_pairing(&app, &device_id, &public_key_base64, url.as_deref()) +} + +/// Run OpenFang doctor to diagnose issues +#[tauri::command] +fn openfang_doctor(app: AppHandle) -> Result { + let result = run_openfang(&app, &["doctor", "--json"])?; + Ok(result.stdout) +} + +// ============================================================================ +// Backward-compatible aliases (OpenClaw naming) +// These delegate to OpenFang commands for backward compatibility +// ============================================================================ + +#[tauri::command] +fn gateway_status(app: AppHandle) -> Result { + openfang_status(app) +} + +#[tauri::command] +fn gateway_start(app: AppHandle) -> Result { + openfang_start(app) +} + +#[tauri::command] +fn gateway_stop(app: AppHandle) -> Result { + openfang_stop(app) +} + +#[tauri::command] +fn gateway_restart(app: AppHandle) -> Result { + openfang_restart(app) +} + +#[tauri::command] +fn gateway_local_auth() -> Result { + openfang_local_auth() +} + +#[tauri::command] +fn gateway_prepare_for_tauri(app: AppHandle) -> Result { + openfang_prepare_for_tauri(app) +} + +#[tauri::command] +fn gateway_approve_device_pairing( + app: AppHandle, + device_id: String, + public_key_base64: String, + url: Option, +) -> Result { + openfang_approve_device_pairing(app, device_id, public_key_base64, url) +} + +#[tauri::command] +fn gateway_doctor(app: AppHandle) -> Result { + openfang_doctor(app) } #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { tauri::Builder::default() .plugin(tauri_plugin_opener::init()) - .invoke_handler(tauri::generate_handler![greet]) + .invoke_handler(tauri::generate_handler![ + // OpenFang commands (new naming) + openfang_status, + openfang_start, + openfang_stop, + openfang_restart, + openfang_local_auth, + openfang_prepare_for_tauri, + openfang_approve_device_pairing, + openfang_doctor, + // Backward-compatible aliases (OpenClaw naming) + gateway_status, + gateway_start, + gateway_stop, + gateway_restart, + gateway_local_auth, + gateway_prepare_for_tauri, + gateway_approve_device_pairing, + gateway_doctor + ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); } diff --git a/desktop/src-tauri/tauri.conf.json b/desktop/src-tauri/tauri.conf.json index 97e734d..e1e250d 100644 --- a/desktop/src-tauri/tauri.conf.json +++ b/desktop/src-tauri/tauri.conf.json @@ -1,8 +1,8 @@ { "$schema": "https://schema.tauri.app/config/2", - "productName": "desktop", - "version": "0.1.0", - "identifier": "com.szend.desktop", + "productName": "ZClaw", + "version": "0.2.0", + "identifier": "com.zclaw.desktop", "build": { "beforeDevCommand": "pnpm dev", "devUrl": "http://localhost:1420", @@ -12,9 +12,11 @@ "app": { "windows": [ { - "title": "desktop", - "width": 800, - "height": 600 + "title": "ZClaw - OpenFang Desktop", + "width": 1200, + "height": 800, + "minWidth": 900, + "minHeight": 600 } ], "security": { @@ -23,7 +25,11 @@ }, "bundle": { "active": true, - "targets": "all", + "targets": "nsis", + "useLocalToolsDir": true, + "resources": [ + "resources/openfang-runtime/" + ], "icon": [ "icons/32x32.png", "icons/128x128.png",