chore(portable): 回归纯开源 — 移除指纹/自动开户/崩溃上报

去掉不适合开源的商业逻辑,保持一个纯粹的开源 U 盘工具:

- 删除设备指纹 (fingerprint.mjs)、虾盘云自动开户 (bootstrap-xiapan.mjs /
  xiapan-client.mjs)、自动崩溃上报 (report-bug.mjs),portable 和 Electron 两份都删
- 启动脚本去掉绑定指纹调用 + 全部 --auto 自动上报;保留 bonjour 禁用与括号转义
- config-server 删掉 /api/xiapan/* 和 /api/report-bug 端点
- Config.html:虾盘云改为普通「首选」卡片,强调中转站 / 国内外全部大模型,
  和其它 provider 一样需用户自填 Key(去 u-claw.org/cloud.html 注册),不再自动开户
- 报 Bug 表单改为指向 GitHub Issue,不再上传日志
- README / release notes / CLAUDE.md 文案改为「选模型填 Key,不绑定设备、不打指纹、不上传数据」
- OPENCLAW_VERSION 跟进最新龙虾版本 2026.6.6 → 2026.6.8

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
hfshfg
2026-06-17 11:47:19 +08:00
parent b1468d455c
commit 0d88c9b0cc
20 changed files with 108 additions and 1908 deletions

View File

@@ -151,26 +151,6 @@ input:focus { border-color: #ff6b35; }
<h1><span class="lobster">🦞</span> U-Claw Pro</h1>
<p class="subtitle">便携版 — 选择模型,填入 API Key一键启动</p>
<!-- Xiapan Cloud bound banner: shown when bootstrap has injected uclaw-cloud -->
<div class="xp-banner" id="xpBanner" style="display:none; background:linear-gradient(135deg,#2a1f1f,#1f2a1f); border:1px solid #ff6b35; border-radius:10px; padding:16px 18px; margin-bottom:18px;">
<div style="display:flex; align-items:center; justify-content:space-between; gap:12px; flex-wrap:wrap;">
<div style="flex:1; min-width:240px;">
<div style="font-size:0.95em; color:#ff6b35; font-weight:600;">🔗 已绑定虾盘云 · 开箱即用</div>
<div style="font-size:0.82em; color:#bbb; margin-top:4px;">
指纹来源: <span id="xpSource"></span>
· Key: <code id="xpKeyShort" style="background:#222; padding:2px 6px; border-radius:4px;"></code>
· 余额: <span id="xpBalance"></span>
</div>
<div id="xpHint" style="font-size:0.75em; color:#888; margin-top:4px;">余额为 0 时无法调用 AI请先充值</div>
</div>
<div style="display:flex; gap:8px;">
<button class="btn" id="xpRecharge" style="background:#ff6b35; color:#fff; padding:8px 16px; font-size:0.85em;">前往充值 →</button>
<button class="btn" id="xpRefresh" style="background:#333; color:#ccc; padding:8px 12px; font-size:0.85em;">刷新</button>
<button class="btn" id="xpRebind" style="background:#222; color:#888; padding:8px 12px; font-size:0.8em;" title="清除绑定后重启 U-Claw 即可重新生成 Key">解绑</button>
</div>
</div>
</div>
<!-- Steps -->
<div class="steps">
<div class="step active" data-step="1"><span class="num">1</span>选模型</div>
@@ -181,55 +161,61 @@ input:focus { border-color: #ff6b35; }
<!-- Step 1: Select Model -->
<div class="section active" id="step1">
<h2>选择 AI 模型</h2>
<p class="desc">选择你要使用的模型,推荐国内用户用 MiniMax / Kimi / DeepSeek</p>
<p class="desc">最省心:用「虾盘云」一个 Key 调用国内外全部大模型(中转站)。也可用下面各家自己的官方 Key。</p>
<div class="model-grid">
<div class="model-card" data-provider="minimax" data-base="https://api.minimax.chat/v1" data-model="MiniMax-Text-01">
<div class="model-card" data-provider="uclaw-cloud" data-base="https://api.u-claw.org/v1" data-model="deepseek-v4-flash">
<span class="check"></span>
<h4>MiniMax <span class="tag hot">推荐</span><span class="tag cn">国内</span></h4>
<p>MiniMax-Text-01速度快性价比高</p>
<a class="buy-link" href="https://platform.minimaxi.com/" target="_blank">→ 获取 API Key</a>
</div>
<div class="model-card" data-provider="kimi" data-base="https://api.moonshot.cn/v1" data-model="moonshot-v1-auto">
<span class="check"></span>
<h4>Kimi (月之暗面) <span class="tag cn">国内</span><span class="tag fast"></span></h4>
<p>moonshot-v1-auto智能选模型</p>
<a class="buy-link" href="https://platform.moonshot.cn/" target="_blank">→ 获取 API Key</a>
<h4>虾盘云 <span class="tag hot">首选</span><span class="tag">中转站</span></h4>
<p>一个 Key 用 DeepSeek / Claude / GPT / 通义 等国内外全部大模型,无需翻墙</p>
<a class="buy-link" href="https://u-claw.org/cloud.html" target="_blank">注册并获取 API Key</a>
</div>
<div class="model-card" data-provider="deepseek" data-base="https://api.deepseek.com/v1" data-model="deepseek-chat">
<span class="check"></span>
<h4>DeepSeek <span class="tag cn">国内</span><span class="tag cheap">便宜</span></h4>
<p>deepseek-chat超低价格</p>
<p>deepseek-chat / deepseek-v4,超低价格</p>
<a class="buy-link" href="https://platform.deepseek.com/" target="_blank">→ 获取 API Key</a>
</div>
<div class="model-card" data-provider="minimax" data-base="https://api.minimax.chat/v1" data-model="MiniMax-M2">
<span class="check"></span>
<h4>MiniMax <span class="tag cn">国内</span></h4>
<p>MiniMax-M2速度快性价比高</p>
<a class="buy-link" href="https://platform.minimaxi.com/" target="_blank">→ 获取 API Key</a>
</div>
<div class="model-card" data-provider="kimi" data-base="https://api.moonshot.cn/v1" data-model="kimi-k2-turbo-preview">
<span class="check"></span>
<h4>Kimi (月之暗面) <span class="tag cn">国内</span><span class="tag fast"></span></h4>
<p>kimi-k2长上下文强</p>
<a class="buy-link" href="https://platform.moonshot.cn/" target="_blank">→ 获取 API Key</a>
</div>
<div class="model-card" data-provider="zai" data-base="" data-model="glm-5">
<span class="check"></span>
<h4>智谱 GLM <span class="tag cn">国内</span><span class="tag free">有免费</span></h4>
<p>glm-5zai provider免费额度可用</p>
<a class="buy-link" href="https://open.bigmodel.cn/" target="_blank">→ 获取 API Key</a>
</div>
<div class="model-card" data-provider="qwen" data-base="https://dashscope.aliyuncs.com/compatible-mode/v1" data-model="qwen-turbo">
<div class="model-card" data-provider="qwen" data-base="https://dashscope.aliyuncs.com/compatible-mode/v1" data-model="qwen-plus">
<span class="check"></span>
<h4>通义千问 <span class="tag cn">国内</span><span class="tag free">有免费</span></h4>
<p>qwen-turbo,阿里云出品</p>
<p>qwen-plus / qwen3-max,阿里云出品</p>
<a class="buy-link" href="https://dashscope.console.aliyun.com/" target="_blank">→ 获取 API Key</a>
</div>
<div class="model-card" data-provider="doubao" data-base="https://ark.cn-beijing.volces.com/api/v3" data-model="doubao-1.5-pro-32k">
<div class="model-card" data-provider="doubao" data-base="https://ark.cn-beijing.volces.com/api/v3" data-model="doubao-seed-1-6-250615">
<span class="check"></span>
<h4>豆包 (字节) <span class="tag cn">国内</span><span class="tag fast"></span></h4>
<p>doubao-1.5-pro,字节跳动</p>
<p>doubao-seed-1.6,字节跳动</p>
<a class="buy-link" href="https://console.volcengine.com/ark" target="_blank">→ 获取 API Key</a>
</div>
<div class="model-card" data-provider="openai" data-base="https://api.openai.com/v1" data-model="gpt-4o">
<div class="model-card" data-provider="openai" data-base="https://api.openai.com/v1" data-model="gpt-5.4">
<span class="check"></span>
<h4>OpenAI <span class="tag hot"></span></h4>
<p>GPT-4o,需翻墙或中转</p>
<p>GPT-5.4需翻墙或中转</p>
<a class="buy-link" href="https://platform.openai.com/" target="_blank">→ 获取 API Key</a>
</div>
<div class="model-card" data-provider="anthropic" data-base="https://api.anthropic.com/v1" data-model="claude-sonnet-4-20250514">
<div class="model-card" data-provider="anthropic" data-base="https://api.anthropic.com/v1" data-model="claude-opus-4-6">
<span class="check"></span>
<h4>Claude <span class="tag hot"></span></h4>
<p>Claude Sonnet 4需翻墙或中转</p>
<p>Claude Opus 4.6 / Sonnet 4.6,需翻墙或中转</p>
<a class="buy-link" href="https://console.anthropic.com/" target="_blank">→ 获取 API Key</a>
</div>
<div class="model-card" data-provider="groq" data-base="https://api.groq.com/openai/v1" data-model="llama-3.3-70b-versatile">
@@ -408,50 +394,6 @@ input:focus { border-color: #ff6b35; }
<div class="toast" id="toast"></div>
<script>
// --- Xiapan Cloud banner ---
async function loadXiapanStatus() {
const banner = document.getElementById('xpBanner');
try {
const res = await fetch('/api/xiapan/status');
if (!res.ok) return;
const data = await res.json();
if (!data || !data.apiKey) return;
document.getElementById('xpSource').textContent = ({
usb: 'U 盘', disk: '硬盘', mac: 'Mac', linux: 'Linux', seed: '本机种子', test: '测试',
})[data.source] || data.source;
document.getElementById('xpKeyShort').textContent = data.apiKey.slice(0, 14) + '…';
const bal = data.balance || {};
if (bal.ok) {
const usd = bal.remainingUsd != null ? bal.remainingUsd.toFixed(2) : '0.00';
const tokens = (bal.remainingTokens || 0).toLocaleString();
document.getElementById('xpBalance').textContent = `$${usd} (${tokens} tokens)`;
document.getElementById('xpHint').textContent = bal.remainingUsd > 0
? '可直接使用 deepseek-chat / qwen-plus / qwen-turbo'
: '余额为 0点击右侧前往充值';
} else if (bal.reason && bal.reason.includes('401')) {
document.getElementById('xpBalance').textContent = '未注册';
document.getElementById('xpHint').textContent = '首次使用:点「前往充值」即可在虾盘云页面注册并充值,秒到账';
} else {
document.getElementById('xpBalance').textContent = '查询失败';
document.getElementById('xpHint').textContent = '请检查网络或 api.u-claw.org 服务状态';
}
document.getElementById('xpRecharge').onclick = () => window.open(data.rechargeUrl, '_blank');
document.getElementById('xpRefresh').onclick = () => loadXiapanStatus();
document.getElementById('xpRebind').onclick = async () => {
if (!confirm('解绑后下次启动 U-Claw 会重新生成 Key基于当前设备指纹。\n现有 Key 的余额仍归属当前指纹,更换 U 盘 / 电脑后才需要解绑。\n\n确认解绑')) return;
await fetch('/api/xiapan/unbind', { method: 'POST' });
showToast('已解绑,请重启 U-Claw');
};
banner.style.display = 'block';
} catch (err) {
// network error or endpoint missing — leave banner hidden
}
}
loadXiapanStatus();
// --- State ---
let selectedProvider = null;
let selectedBase = '';
@@ -470,7 +412,9 @@ document.querySelectorAll('#step1 .model-card').forEach(card => {
});
});
document.getElementById('nextStep1').addEventListener('click', () => goStep(2));
document.getElementById('nextStep1').addEventListener('click', () => {
goStep(2);
});
// --- Steps ---
function goStep(n) {
@@ -491,7 +435,7 @@ function setupStep2() {
document.getElementById('customFields').style.display = isCustom ? 'block' : 'none';
const providerNames = {
minimax: 'MiniMax', kimi: 'Kimi (月之暗面)', deepseek: 'DeepSeek',
'uclaw-cloud': '虾盘云', minimax: 'MiniMax', kimi: 'Kimi (月之暗面)', deepseek: 'DeepSeek',
zai: '智谱 GLM', qwen: '通义千问', doubao: '豆包',
openai: 'OpenAI', anthropic: 'Anthropic Claude', groq: 'Groq',
siliconflow: '硅基流动', custom: '自定义'

View File

@@ -10,9 +10,6 @@ const APP_DIR = path.resolve(__dirname, '..');
const PORTABLE_DIR = path.resolve(APP_DIR, '..', 'portable');
const COPIES = [
{ from: path.join(PORTABLE_DIR, 'lib', 'fingerprint.mjs'), to: path.join(APP_DIR, 'src', 'lib', 'fingerprint.mjs') },
{ from: path.join(PORTABLE_DIR, 'lib', 'xiapan-client.mjs'), to: path.join(APP_DIR, 'src', 'lib', 'xiapan-client.mjs') },
{ from: path.join(PORTABLE_DIR, 'lib', 'bootstrap-xiapan.mjs'), to: path.join(APP_DIR, 'src', 'lib', 'bootstrap-xiapan.mjs') },
{ from: path.join(PORTABLE_DIR, 'Config.html'), to: path.join(APP_DIR, 'resources', 'Config.html') },
];

View File

@@ -1,185 +0,0 @@
// Bootstrap: ensure data/.openclaw/openclaw.json contains the uclaw-cloud provider
// pointing to the device-bound apiKey derived from the local fingerprint.
//
// Idempotent: if the provider already exists with the correct apiKey, do nothing.
// If it exists but the apiKey differs (USB swapped, machine changed), leave the
// existing entry alone and log a hint — never overwrite user data silently.
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { getFingerprint } from './fingerprint.mjs';
import { buildApiKey } from './xiapan-client.mjs';
const PROVIDER_ID = 'uclaw-cloud';
// Mirror ClawX commercial schema: keep models[] empty and steer model selection
// via agents.defaults.model.primary + fallbacks. OpenClaw 2026.4.x accepts both
// shapes, but the empty-models form lets the runtime auto-discover model ids
// from /v1/models without us hard-coding a list that drifts from the backend.
const DEFAULT_PROVIDER_TEMPLATE = {
baseUrl: 'https://api.u-claw.org/v1',
api: 'openai-completions',
models: [],
};
const DEFAULT_PRIMARY_MODEL = `${PROVIDER_ID}/deepseek-v4-flash`;
const DEFAULT_FALLBACK_MODELS = [
`${PROVIDER_ID}/deepseek-chat`,
`${PROVIDER_ID}/qwen-plus`,
`${PROVIDER_ID}/qwen-turbo`,
];
function readJsonSafe(filePath) {
if (!existsSync(filePath)) return null;
try {
const raw = readFileSync(filePath, 'utf8');
return JSON.parse(raw);
} catch (err) {
process.stderr.write(`[bootstrap-xiapan] Cannot parse ${filePath}: ${err.message}\n`);
return null;
}
}
function writeJson(filePath, data) {
writeFileSync(filePath, JSON.stringify(data, null, 2) + '\n', 'utf8');
}
function ensureModelsContainer(config) {
if (!config.models || typeof config.models !== 'object') {
config.models = { mode: 'merge', providers: {} };
}
if (!config.models.mode) config.models.mode = 'merge';
if (!config.models.providers || typeof config.models.providers !== 'object') {
config.models.providers = {};
}
return config.models.providers;
}
// Set agents.defaults.model.primary to uclaw-cloud only when the user has not
// configured a primary model already. If they've picked a different provider
// (e.g. DeepSeek BYOK), leave their choice untouched.
function ensureAgentsDefaults(config) {
if (!config.agents || typeof config.agents !== 'object') {
config.agents = {};
}
if (!config.agents.defaults || typeof config.agents.defaults !== 'object') {
config.agents.defaults = {};
}
const defaults = config.agents.defaults;
if (!defaults.model || typeof defaults.model !== 'object') {
defaults.model = {};
}
let changed = false;
if (!defaults.model.primary) {
defaults.model.primary = DEFAULT_PRIMARY_MODEL;
changed = true;
}
if (!Array.isArray(defaults.model.fallbacks) || defaults.model.fallbacks.length === 0) {
defaults.model.fallbacks = [...DEFAULT_FALLBACK_MODELS];
changed = true;
}
return changed;
}
export async function bootstrapXiapan({ configPath, appRoot, log = console } = {}) {
if (!configPath) {
throw new Error('bootstrapXiapan: configPath is required.');
}
const root = appRoot || process.cwd();
let fingerprintInfo;
try {
fingerprintInfo = await getFingerprint(root);
} catch (err) {
log.warn?.(`[bootstrap-xiapan] Fingerprint detection failed: ${err.message}`);
return { ok: false, reason: 'fingerprint-failed' };
}
const apiKey = buildApiKey(fingerprintInfo.fingerprint);
const config = readJsonSafe(configPath) || { gateway: { mode: 'local', auth: { token: 'uclaw' } } };
const providers = ensureModelsContainer(config);
const existing = providers[PROVIDER_ID];
if (existing && typeof existing === 'object') {
if (existing.apiKey && existing.apiKey !== apiKey) {
log.info?.(
`[bootstrap-xiapan] uclaw-cloud apiKey already configured (different fingerprint). `
+ `Current source=${fingerprintInfo.source}. Use Config UI to rebind if needed.`,
);
// Still ensure agents.defaults exists so the runtime knows to use uclaw-cloud
const changedDefaults = ensureAgentsDefaults(config);
if (changedDefaults) writeJson(configPath, config);
return {
ok: true,
action: 'kept',
source: fingerprintInfo.source,
apiKey: existing.apiKey,
};
}
if (existing.apiKey === apiKey) {
const changedDefaults = ensureAgentsDefaults(config);
if (changedDefaults) {
writeJson(configPath, config);
log.info?.('[bootstrap-xiapan] Added agents.defaults to existing config');
}
return {
ok: true,
action: changedDefaults ? 'agents-defaults-added' : 'noop',
source: fingerprintInfo.source,
apiKey,
};
}
}
providers[PROVIDER_ID] = {
...DEFAULT_PROVIDER_TEMPLATE,
...(existing && typeof existing === 'object' ? existing : {}),
baseUrl: existing?.baseUrl || DEFAULT_PROVIDER_TEMPLATE.baseUrl,
api: existing?.api || DEFAULT_PROVIDER_TEMPLATE.api,
apiKey,
models: existing?.models ?? DEFAULT_PROVIDER_TEMPLATE.models,
};
ensureAgentsDefaults(config);
writeJson(configPath, config);
log.info?.(
`[bootstrap-xiapan] Wrote uclaw-cloud provider (source=${fingerprintInfo.source}, key=${apiKey.slice(0, 12)}…)`,
);
return {
ok: true,
action: existing ? 'updated' : 'created',
source: fingerprintInfo.source,
apiKey,
};
}
// CLI:
// node bootstrap-xiapan.mjs <config-path>
// env UCLAW_CONFIG_PATH=... node bootstrap-xiapan.mjs
import { pathToFileURL } from 'node:url';
const isMain = (() => {
try {
if (!process.argv[1]) return false;
return import.meta.url === pathToFileURL(process.argv[1]).href;
} catch {
return false;
}
})();
if (isMain) {
const configPath = process.argv[2] || process.env.UCLAW_CONFIG_PATH;
if (!configPath) {
process.stderr.write('Usage: node bootstrap-xiapan.mjs <openclaw.json path>\n');
process.exit(2);
}
const appRoot = process.env.UCLAW_APP_ROOT || resolve(configPath, '../../..');
bootstrapXiapan({ configPath, appRoot })
.then((res) => {
process.stdout.write(`${JSON.stringify(res)}\n`);
})
.catch((err) => {
process.stderr.write(`bootstrap-xiapan error: ${err.message}\n`);
process.exit(1);
});
}

View File

@@ -1,359 +0,0 @@
// Cross-platform device fingerprint for U-Claw / Xiapan Cloud apiKey binding.
// Output: { source: 'usb' | 'disk' | 'mac' | 'linux' | 'seed' | 'test', fingerprint: '<64-hex>' }
//
// Order of preference:
// Windows: USB drive (when running from a USB volume) -> system disk -> seed file
// Mac: Hardware UUID + boot volume UUID -> seed file
// Linux: /etc/machine-id + lsblk SERIAL of root -> seed file
//
// Adapted from v2/u-clawx-openclaw-dev/electron/utils/{license,disk-fingerprint}.ts
// but simplified: no Ed25519 signing, no .license file, just a stable hash.
import { execFile } from 'node:child_process';
import { createHash, randomBytes } from 'node:crypto';
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { homedir, platform } from 'node:os';
import { dirname, parse, relative, resolve, sep } from 'node:path';
import { pathToFileURL } from 'node:url';
import { promisify } from 'node:util';
const execFileAsync = promisify(execFile);
const POWERSHELL_CANDIDATES = [
'powershell.exe',
'powershell',
'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe',
'C:\\Windows\\Sysnative\\WindowsPowerShell\\v1.0\\powershell.exe',
'pwsh.exe',
];
const TEST_FINGERPRINT_SOURCE = 'TEST:UCLAW_DEVELOPMENT_FIXED_FINGERPRINT';
function sha256Hex(input) {
return createHash('sha256').update(input).digest('hex');
}
function shouldUseTestFingerprint() {
return (
process.env.UCLAW_SKIP_FINGERPRINT === '1'
|| process.env.OPENCLAW_SKIP_USB_CHECK === '1'
|| process.env.CLAWX_SKIP_USB_CHECK === '1'
);
}
function readEnvOverride() {
const override = (process.env.UCLAW_FINGERPRINT_OVERRIDE || '').trim();
if (!override || !/^[0-9a-f]{64}$/i.test(override)) return null;
return { source: 'test', fingerprint: override.toLowerCase() };
}
function getSeedPath(appRoot) {
if (process.env.UCLAW_SEED_PATH) {
return resolve(process.env.UCLAW_SEED_PATH);
}
const home = homedir();
if (home) return resolve(home, '.uclaw', '.usb_seed');
return resolve(appRoot, '.usb_seed');
}
function readOrCreateSeedFingerprint(appRoot) {
const seedPath = getSeedPath(appRoot);
let seedHex;
if (existsSync(seedPath)) {
seedHex = readFileSync(seedPath, 'utf8').trim();
}
if (!seedHex || !/^[0-9a-f]{64}$/i.test(seedHex)) {
seedHex = randomBytes(32).toString('hex');
mkdirSync(dirname(seedPath), { recursive: true });
writeFileSync(seedPath, seedHex + '\n', 'utf8');
}
return { source: 'seed', fingerprint: seedHex.toLowerCase() };
}
async function runPowerShell(script) {
const wrapped = `$OutputEncoding = [Console]::OutputEncoding = [System.Text.Encoding]::UTF8; ${script}`;
let lastError = null;
for (const candidate of POWERSHELL_CANDIDATES) {
try {
const { stdout } = await execFileAsync(candidate, ['-NoProfile', '-Command', wrapped], {
windowsHide: true,
encoding: 'utf8',
maxBuffer: 1024 * 1024,
});
return stdout;
} catch (err) {
if (err && err.code === 'ENOENT') continue;
lastError = err;
}
}
throw lastError || new Error('PowerShell is not available on this system.');
}
function normalizeSerial(value) {
if (!value) return '';
return String(value).trim().replace(/[\s.]+$/g, '').replace(/\s+/g, '').toUpperCase();
}
function getDriveDepth(targetDir) {
const parsed = parse(resolve(targetDir));
if (!parsed.root) return null;
const rel = relative(parsed.root, resolve(targetDir));
const depth = rel.split(sep).map((s) => s.trim()).filter(Boolean).length;
return { driveRoot: parsed.root.replace(/[\\/]$/, '').toUpperCase(), depth };
}
async function tryWindowsUsbFingerprint(appRoot) {
const drive = getDriveDepth(appRoot);
if (!drive) return null;
// Only attempt USB fingerprint when running from a drive root or first-level subfolder
if (drive.depth > 2) return null;
const driveLetter = drive.driveRoot.endsWith(':') ? drive.driveRoot : `${drive.driveRoot}:`;
const driveMappingScript = [
`$p = Get-WmiObject -Query "ASSOCIATORS OF {Win32_LogicalDisk.DeviceID='${driveLetter}'} WHERE AssocClass=Win32_LogicalDiskToPartition"`,
'$p0 = if ($p -is [System.Array]) { $p[0] } else { $p }',
'$d = if ($p0) { Get-WmiObject -Query "ASSOCIATORS OF {Win32_DiskPartition.DeviceID=\'$($p0.DeviceID)\'} WHERE AssocClass=Win32_DiskDriveToDiskPartition" } else { $null }',
'$d0 = if ($d -is [System.Array]) { $d[0] } else { $d }',
"if ($d0) { $d0.PNPDeviceID } else { '' }",
].join('; ');
let targetPnpId = '';
try {
targetPnpId = (await runPowerShell(driveMappingScript)).trim().toUpperCase();
} catch {
targetPnpId = '';
}
let rawDiskJson;
try {
rawDiskJson = await runPowerShell(
'Get-WmiObject Win32_DiskDrive | Select-Object Model, SerialNumber, PNPDeviceID | ConvertTo-Json -Compress',
);
} catch {
return null;
}
let disks;
try {
const parsed = JSON.parse(rawDiskJson.trim() || '[]');
disks = Array.isArray(parsed) ? parsed : [parsed];
} catch {
return null;
}
if (!disks.length) return null;
const exactMatch = targetPnpId
? disks.find((d) => (d.PNPDeviceID || '').toUpperCase() === targetPnpId)
: null;
const usbDisk = exactMatch || disks.find((d) => {
const pnp = (d.PNPDeviceID || '').toUpperCase();
return pnp.includes('USB') || pnp.includes('USBSTOR');
});
if (!usbDisk) return null;
const model = (usbDisk.Model || 'Unknown').trim();
const serial = (usbDisk.SerialNumber || 'Unknown').trim();
const pnp = (usbDisk.PNPDeviceID || 'Unknown').trim();
return {
source: 'usb',
fingerprint: sha256Hex(`${model}:${serial}:${pnp}`),
};
}
async function tryWindowsDiskFingerprint() {
let physicalDiskInfo = null;
try {
const physicalScript = [
"$systemDrive = ($env:SystemDrive -replace ':','')",
"if (-not $systemDrive) { $systemDrive = 'C' }",
'$disk = Get-Partition -DriveLetter $systemDrive -ErrorAction SilentlyContinue | Get-Disk -ErrorAction SilentlyContinue | Select-Object -First 1',
'if (-not $disk) { return }',
'$pd = Get-PhysicalDisk -DeviceNumber $disk.Number -ErrorAction SilentlyContinue | Select-Object -First 1',
'if (-not $pd) { return }',
'[pscustomobject]@{ Serial = $pd.SerialNumber; Model = $pd.FriendlyName; BusType = $pd.BusType } | ConvertTo-Json -Compress',
].join('; ');
const raw = (await runPowerShell(physicalScript)).trim();
if (raw) physicalDiskInfo = JSON.parse(raw);
} catch {
physicalDiskInfo = null;
}
if (!physicalDiskInfo) {
try {
const win32Script = [
"$systemDrive = $env:SystemDrive -replace ':',''",
"if (-not $systemDrive) { $systemDrive = 'C' }",
'$letter = "$systemDrive`:"',
"$lp = Get-WmiObject -Query \"ASSOCIATORS OF {Win32_LogicalDisk.DeviceID='$letter'} WHERE AssocClass=Win32_LogicalDiskToPartition\"",
'$lp0 = if ($lp -is [System.Array]) { $lp[0] } else { $lp }',
'if (-not $lp0) { return }',
"$dd = Get-WmiObject -Query \"ASSOCIATORS OF {Win32_DiskPartition.DeviceID='$($lp0.DeviceID)'} WHERE AssocClass=Win32_DiskDriveToDiskPartition\"",
'$dd0 = if ($dd -is [System.Array]) { $dd[0] } else { $dd }',
'if (-not $dd0) { return }',
'[pscustomobject]@{ Serial = $dd0.SerialNumber; Model = $dd0.Model; BusType = $dd0.InterfaceType } | ConvertTo-Json -Compress',
].join('; ');
const raw = (await runPowerShell(win32Script)).trim();
if (raw) physicalDiskInfo = JSON.parse(raw);
} catch {
physicalDiskInfo = null;
}
}
if (!physicalDiskInfo) return null;
let boardSerial = 'NoBoard';
try {
const raw = await runPowerShell('(Get-CimInstance Win32_BaseBoard | Select-Object -First 1).SerialNumber');
boardSerial = normalizeSerial(raw) || 'NoBoard';
} catch {
// keep default
}
const diskSerial = normalizeSerial(physicalDiskInfo.Serial);
const diskModel = (physicalDiskInfo.Model || 'Unknown').toString().trim();
return {
source: 'disk',
fingerprint: sha256Hex(`DISK:${diskSerial}:${diskModel}:${boardSerial}`),
};
}
async function tryMacFingerprint() {
let hardwareUuid = '';
try {
const { stdout } = await execFileAsync('/usr/sbin/system_profiler', ['SPHardwareDataType'], {
encoding: 'utf8',
maxBuffer: 1024 * 1024,
});
const match = stdout.match(/Hardware UUID:\s*([0-9A-F-]+)/i);
if (match) hardwareUuid = match[1].trim().toUpperCase();
} catch {
hardwareUuid = '';
}
let bootVolumeUuid = '';
try {
const { stdout } = await execFileAsync('/usr/sbin/diskutil', ['info', '/'], {
encoding: 'utf8',
maxBuffer: 1024 * 1024,
});
const match = stdout.match(/Volume UUID:\s*([0-9A-F-]+)/i);
if (match) bootVolumeUuid = match[1].trim().toUpperCase();
} catch {
bootVolumeUuid = '';
}
if (!hardwareUuid && !bootVolumeUuid) return null;
return {
source: 'mac',
fingerprint: sha256Hex(`MAC:${hardwareUuid}:${bootVolumeUuid}`),
};
}
async function tryLinuxFingerprint() {
let machineId = '';
for (const path of ['/etc/machine-id', '/var/lib/dbus/machine-id']) {
try {
machineId = readFileSync(path, 'utf8').trim();
if (machineId) break;
} catch {
// try next
}
}
let rootSerial = '';
try {
const { stdout } = await execFileAsync('/bin/lsblk', ['-no', 'SERIAL,MOUNTPOINT'], {
encoding: 'utf8',
maxBuffer: 1024 * 1024,
});
for (const line of stdout.split('\n')) {
const parts = line.trim().split(/\s+/);
if (parts.length >= 2 && parts[1] === '/') {
rootSerial = parts[0];
break;
}
}
} catch {
rootSerial = '';
}
if (!machineId && !rootSerial) return null;
return {
source: 'linux',
fingerprint: sha256Hex(`LINUX:${machineId}:${rootSerial}`),
};
}
let cachedPromise = null;
export async function getFingerprint(appRoot) {
if (cachedPromise) return cachedPromise;
cachedPromise = computeFingerprint(appRoot || process.cwd()).catch((err) => {
cachedPromise = null;
throw err;
});
return cachedPromise;
}
async function computeFingerprint(appRoot) {
const override = readEnvOverride();
if (override) return override;
if (shouldUseTestFingerprint()) {
return { source: 'test', fingerprint: sha256Hex(TEST_FINGERPRINT_SOURCE) };
}
const plat = platform();
if (plat === 'win32') {
const usb = await tryWindowsUsbFingerprint(appRoot).catch(() => null);
if (usb) return usb;
const disk = await tryWindowsDiskFingerprint().catch(() => null);
if (disk) return disk;
return readOrCreateSeedFingerprint(appRoot);
}
if (plat === 'darwin') {
const mac = await tryMacFingerprint().catch(() => null);
if (mac) return mac;
return readOrCreateSeedFingerprint(appRoot);
}
if (plat === 'linux') {
const linux = await tryLinuxFingerprint().catch(() => null);
if (linux) return linux;
return readOrCreateSeedFingerprint(appRoot);
}
return readOrCreateSeedFingerprint(appRoot);
}
// CLI entrypoint: prints JSON when run directly.
// node fingerprint.mjs -> {"source":"...","fingerprint":"..."}
// node fingerprint.mjs apiKey -> sk-<fingerprint>
const isMain = (() => {
try {
if (!process.argv[1]) return false;
return import.meta.url === pathToFileURL(process.argv[1]).href;
} catch {
return false;
}
})();
if (isMain) {
const appRoot = process.env.UCLAW_APP_ROOT || process.cwd();
getFingerprint(appRoot)
.then((result) => {
const arg = process.argv[2];
if (arg === 'apiKey') {
process.stdout.write(`sk-${result.fingerprint}\n`);
} else {
process.stdout.write(`${JSON.stringify(result)}\n`);
}
})
.catch((err) => {
process.stderr.write(`fingerprint error: ${err && err.message ? err.message : err}\n`);
process.exit(1);
});
}

View File

@@ -1,91 +0,0 @@
// Xiapan Cloud (虾盘云) client for U-Claw open-source edition.
// Provides only: apiKey derivation, balance lookup, recharge URL.
// Intentionally does NOT call /recharge/activate — open-source users do not get free quota.
const DEFAULT_API_BASE = 'https://api.u-claw.org/v1';
const DEFAULT_RECHARGE_PAGE = 'https://u-claw.org/cloud.html';
const QUOTA_PER_USD = 500_000; // 1 USD = 500k tokens (matches new-api convention)
const REQUEST_TIMEOUT_MS = 10_000;
// sk-uc- prefix marks keys generated by the u-claw open-source edition.
// ClawX commercial keys use plain sk-<hash> and the cloud.html flow uses sk-xp-,
// so the three namespaces never collide and the backend can audit by prefix.
export function buildApiKey(fingerprint) {
if (!fingerprint || !/^[0-9a-f]{64}$/i.test(fingerprint)) {
throw new Error('Fingerprint must be 64-character hex.');
}
return `sk-uc-${fingerprint.toLowerCase()}`;
}
function getApiBase() {
return (process.env.UCLAW_CLOUD_API_BASE || DEFAULT_API_BASE).replace(/\/+$/, '');
}
function getRechargePage() {
return process.env.UCLAW_CLOUD_RECHARGE_PAGE || DEFAULT_RECHARGE_PAGE;
}
async function fetchWithTimeout(url, init) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
try {
return await fetch(url, { ...init, signal: controller.signal });
} finally {
clearTimeout(timer);
}
}
export async function getBalance(apiKey) {
if (!apiKey) throw new Error('apiKey is required.');
const base = getApiBase();
const headers = { Authorization: `Bearer ${apiKey}` };
const [subRes, usageRes] = await Promise.all([
fetchWithTimeout(`${base}/dashboard/billing/subscription`, { headers }).catch(() => null),
fetchWithTimeout(
`${base}/dashboard/billing/usage?start_date=2020-01-01&end_date=${new Date().toISOString().slice(0, 10)}`,
{ headers },
).catch(() => null),
]);
if (!subRes || !subRes.ok) {
return {
ok: false,
reason: subRes ? `subscription HTTP ${subRes.status}` : 'subscription request failed',
hardLimitUsd: 0,
usedUsd: 0,
remainingUsd: 0,
remainingTokens: 0,
};
}
const subscription = await subRes.json().catch(() => ({}));
let usedUsd = 0;
if (usageRes && usageRes.ok) {
const usage = await usageRes.json().catch(() => ({}));
// total_usage is in cents (USD * 100), per new-api convention
usedUsd = Number(usage.total_usage || 0) / 100;
}
const hardLimitUsd = Number(subscription.hard_limit_usd || 0);
const remainingUsd = Math.max(0, hardLimitUsd - usedUsd);
const remainingTokens = Math.round(remainingUsd * QUOTA_PER_USD);
return {
ok: true,
reason: null,
hardLimitUsd,
usedUsd,
remainingUsd,
remainingTokens,
};
}
export function getRechargeUrl(apiKey) {
if (!apiKey) throw new Error('apiKey is required.');
const page = getRechargePage();
const url = new URL(page);
url.searchParams.set('key', apiKey);
// Page already has #recharge anchor; preserve it
return `${url.toString()}#recharge`;
}

View File

@@ -89,20 +89,6 @@ function ensureConfig() {
}
}
async function bindXiapanCloud() {
try {
const mod = await import(path.join(__dirname, 'lib/bootstrap-xiapan.mjs'));
const result = await mod.bootstrapXiapan({
configPath,
appRoot: userDataPath,
log: console,
});
console.log(`[${APP_NAME}] xiapan bind: ${result.action || 'noop'} (source=${result.source})`);
} catch (err) {
console.warn(`[${APP_NAME}] xiapan bind failed:`, err.message);
}
}
function getConfig() {
try {
return JSON.parse(fs.readFileSync(configPath, 'utf8'));
@@ -463,7 +449,6 @@ app.whenReady().then(async () => {
// Setup
ensureConfig();
await bindXiapanCloud();
createMenu();
setupIPC();
createWindow();