feat(openfang): add bundled OpenFang runtime support
- Add prepare-openfang-runtime.mjs script for cross-platform binary download - Update lib.rs to support binary runtime (fallback to Node.js for legacy) - Add openfang.cmd/sh launcher scripts - Update runtime-manifest.json for binary-based runtime - Add README documentation for bundled runtime architecture OpenFang binary is downloaded during build, supporting: - Windows x64/ARM64 (.zip) - macOS Intel/Apple Silicon (.tar.gz) - Linux x64/ARM64 (.tar.gz) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
11
desktop/.gitignore
vendored
11
desktop/.gitignore
vendored
@@ -11,6 +11,16 @@ node_modules
|
|||||||
dist
|
dist
|
||||||
dist-ssr
|
dist-ssr
|
||||||
*.local
|
*.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
|
# Editor directories and files
|
||||||
.vscode/*
|
.vscode/*
|
||||||
@@ -22,3 +32,4 @@ dist-ssr
|
|||||||
*.njsproj
|
*.njsproj
|
||||||
*.sln
|
*.sln
|
||||||
*.sw?
|
*.sw?
|
||||||
|
desktop/src-tauri/resources/openfang-runtime/openfang.exe
|
||||||
|
|||||||
@@ -7,7 +7,17 @@
|
|||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc && vite build",
|
"build": "tsc && vite build",
|
||||||
"preview": "vite preview",
|
"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": {
|
"dependencies": {
|
||||||
"@tauri-apps/api": "^2",
|
"@tauri-apps/api": "^2",
|
||||||
@@ -15,6 +25,7 @@
|
|||||||
"lucide-react": "^0.577.0",
|
"lucide-react": "^0.577.0",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.1.0",
|
||||||
|
"tweetnacl": "^1.0.3",
|
||||||
"zustand": "^5.0.11"
|
"zustand": "^5.0.11"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
423
desktop/scripts/prepare-openfang-runtime.mjs
Normal file
423
desktop/scripts/prepare-openfang-runtime.mjs
Normal file
@@ -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();
|
||||||
76
desktop/src-tauri/resources/openfang-runtime/README.md
Normal file
76
desktop/src-tauri/resources/openfang-runtime/README.md
Normal file
@@ -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`)
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
@echo off
|
||||||
|
REM OpenFang Agent OS - Bundled Binary Launcher
|
||||||
|
"%~dp0openfang.exe" %*
|
||||||
BIN
desktop/src-tauri/resources/openfang-runtime/openfang.exe
Normal file
BIN
desktop/src-tauri/resources/openfang-runtime/openfang.exe
Normal file
Binary file not shown.
4
desktop/src-tauri/resources/openfang-runtime/openfang.sh
Normal file
4
desktop/src-tauri/resources/openfang-runtime/openfang.sh
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# OpenFang Agent OS - Bundled Binary Launcher
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
exec "$SCRIPT_DIR/openfang.exe" "$@"
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String>,
|
||||||
|
runtime_path: Option<String>,
|
||||||
|
service_label: Option<String>,
|
||||||
|
service_loaded: bool,
|
||||||
|
service_status: Option<String>,
|
||||||
|
config_ok: bool,
|
||||||
|
port: Option<u16>,
|
||||||
|
port_status: Option<String>,
|
||||||
|
probe_url: Option<String>,
|
||||||
|
listener_pids: Vec<u32>,
|
||||||
|
error: Option<String>,
|
||||||
|
raw: Value,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct LocalGatewayAuth {
|
||||||
|
config_path: Option<String>,
|
||||||
|
gateway_token: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct LocalGatewayPrepareResult {
|
||||||
|
config_path: Option<String>,
|
||||||
|
origins_updated: bool,
|
||||||
|
gateway_restarted: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct LocalGatewayPairingApprovalResult {
|
||||||
|
approved: bool,
|
||||||
|
request_id: Option<String>,
|
||||||
|
device_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct OpenFangRuntime {
|
||||||
|
source: String,
|
||||||
|
executable: PathBuf,
|
||||||
|
pre_args: Vec<String>,
|
||||||
|
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<OpenFangRuntime>, 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<OpenFangRuntime> {
|
||||||
|
// 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<String> {
|
||||||
|
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<OpenFangRuntime> {
|
||||||
|
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<OpenFangRuntime> {
|
||||||
|
// 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<OpenFangRuntime>, 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<OpenFangRuntime> {
|
||||||
|
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<PathBuf> {
|
||||||
|
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<LocalGatewayAuth, String> {
|
||||||
|
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<String> {
|
||||||
|
// 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<String> = 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::<Vec<_>>()
|
||||||
|
.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::<Vec<_>>()
|
||||||
|
.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<LocalGatewayPrepareResult, String> {
|
||||||
|
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<LocalGatewayPairingApprovalResult, String> {
|
||||||
|
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::<Vec<_>>();
|
||||||
|
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<OpenFangCommandOutput, String> {
|
||||||
|
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<Value, String> {
|
||||||
|
if let Ok(raw) = serde_json::from_str::<Value>(stdout) {
|
||||||
|
return Ok(raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(index) = stdout.find('{') {
|
||||||
|
let trimmed = &stdout[index..];
|
||||||
|
return serde_json::from_str::<Value>(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::<Vec<u32>>()
|
||||||
|
})
|
||||||
|
.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<LocalGatewayStatus, String> {
|
||||||
|
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]
|
#[tauri::command]
|
||||||
fn greet(name: &str) -> String {
|
fn openfang_status(app: AppHandle) -> Result<LocalGatewayStatus, String> {
|
||||||
format!("Hello, {}! You've been greeted from Rust!", name)
|
read_gateway_status(&app)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Start OpenFang Kernel
|
||||||
|
#[tauri::command]
|
||||||
|
fn openfang_start(app: AppHandle) -> Result<LocalGatewayStatus, String> {
|
||||||
|
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<LocalGatewayStatus, String> {
|
||||||
|
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<LocalGatewayStatus, String> {
|
||||||
|
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<LocalGatewayAuth, String> {
|
||||||
|
read_local_gateway_auth()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Prepare OpenFang for Tauri (update allowed origins)
|
||||||
|
#[tauri::command]
|
||||||
|
fn openfang_prepare_for_tauri(app: AppHandle) -> Result<LocalGatewayPrepareResult, String> {
|
||||||
|
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<String>,
|
||||||
|
) -> Result<LocalGatewayPairingApprovalResult, String> {
|
||||||
|
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<String, String> {
|
||||||
|
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<LocalGatewayStatus, String> {
|
||||||
|
openfang_status(app)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn gateway_start(app: AppHandle) -> Result<LocalGatewayStatus, String> {
|
||||||
|
openfang_start(app)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn gateway_stop(app: AppHandle) -> Result<LocalGatewayStatus, String> {
|
||||||
|
openfang_stop(app)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn gateway_restart(app: AppHandle) -> Result<LocalGatewayStatus, String> {
|
||||||
|
openfang_restart(app)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn gateway_local_auth() -> Result<LocalGatewayAuth, String> {
|
||||||
|
openfang_local_auth()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn gateway_prepare_for_tauri(app: AppHandle) -> Result<LocalGatewayPrepareResult, String> {
|
||||||
|
openfang_prepare_for_tauri(app)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn gateway_approve_device_pairing(
|
||||||
|
app: AppHandle,
|
||||||
|
device_id: String,
|
||||||
|
public_key_base64: String,
|
||||||
|
url: Option<String>,
|
||||||
|
) -> Result<LocalGatewayPairingApprovalResult, String> {
|
||||||
|
openfang_approve_device_pairing(app, device_id, public_key_base64, url)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn gateway_doctor(app: AppHandle) -> Result<String, String> {
|
||||||
|
openfang_doctor(app)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
pub fn run() {
|
pub fn run() {
|
||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
.plugin(tauri_plugin_opener::init())
|
.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!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running tauri application");
|
.expect("error while running tauri application");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "desktop",
|
"productName": "ZClaw",
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"identifier": "com.szend.desktop",
|
"identifier": "com.zclaw.desktop",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "pnpm dev",
|
"beforeDevCommand": "pnpm dev",
|
||||||
"devUrl": "http://localhost:1420",
|
"devUrl": "http://localhost:1420",
|
||||||
@@ -12,9 +12,11 @@
|
|||||||
"app": {
|
"app": {
|
||||||
"windows": [
|
"windows": [
|
||||||
{
|
{
|
||||||
"title": "desktop",
|
"title": "ZClaw - OpenFang Desktop",
|
||||||
"width": 800,
|
"width": 1200,
|
||||||
"height": 600
|
"height": 800,
|
||||||
|
"minWidth": 900,
|
||||||
|
"minHeight": 600
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"security": {
|
"security": {
|
||||||
@@ -23,7 +25,11 @@
|
|||||||
},
|
},
|
||||||
"bundle": {
|
"bundle": {
|
||||||
"active": true,
|
"active": true,
|
||||||
"targets": "all",
|
"targets": "nsis",
|
||||||
|
"useLocalToolsDir": true,
|
||||||
|
"resources": [
|
||||||
|
"resources/openfang-runtime/"
|
||||||
|
],
|
||||||
"icon": [
|
"icon": [
|
||||||
"icons/32x32.png",
|
"icons/32x32.png",
|
||||||
"icons/128x128.png",
|
"icons/128x128.png",
|
||||||
|
|||||||
Reference in New Issue
Block a user