feat: 内置虾盘云 + 设备指纹绑定 + Release 流水线
- 新增 portable/lib/{fingerprint,xiapan-client,bootstrap-xiapan}.mjs:
跨平台设备指纹(Win USB/disk + Mac UUID + Linux machine-id + seed 兜底)
生成 sk-uc-{fingerprint} 形式的虾盘云 apiKey,启动时 merge 到 openclaw.json
- Config.html 顶部新增「已绑定虾盘云」横幅:指纹来源 + Key + 余额 + 充值/解绑
- config-server 增加 /api/xiapan/{status,bind,unbind} 三个端点
- Electron app 在 whenReady 调 bootstrap,新增 sync-lib.js 让 build 前自动同步
- 锁 OpenClaw 版本到 2026.4.29(OPENCLAW_VERSION 单一来源)
- 删除死代码 portable/充值.html
- 新增 .github/workflows/release.yml:tag 触发,出 portable zip + Electron exe/dmg
- README 增加内置虾盘云说明 + 直接下载发行版指引
This commit is contained in:
@@ -7,13 +7,14 @@
|
||||
"license": "MIT",
|
||||
"homepage": "https://u-claw.org",
|
||||
"scripts": {
|
||||
"start": "electron .",
|
||||
"dev": "electron . --dev",
|
||||
"build:mac": "electron-builder --mac",
|
||||
"build:mac-arm64": "electron-builder --mac --arm64",
|
||||
"build:mac-x64": "electron-builder --mac --x64",
|
||||
"build:win": "electron-builder --win",
|
||||
"build:all": "electron-builder --mac --win",
|
||||
"sync-lib": "node scripts/sync-lib.js",
|
||||
"start": "npm run sync-lib && electron .",
|
||||
"dev": "npm run sync-lib && electron . --dev",
|
||||
"build:mac": "npm run sync-lib && electron-builder --mac",
|
||||
"build:mac-arm64": "npm run sync-lib && electron-builder --mac --arm64",
|
||||
"build:mac-x64": "npm run sync-lib && electron-builder --mac --x64",
|
||||
"build:win": "npm run sync-lib && electron-builder --win",
|
||||
"build:all": "npm run sync-lib && electron-builder --mac --win",
|
||||
"postinstall": "electron-builder install-app-deps"
|
||||
},
|
||||
"build": {
|
||||
|
||||
@@ -48,16 +48,51 @@ h1 .lobster { color: #ff6b35; }
|
||||
.buy-link:hover { text-decoration: underline; }
|
||||
|
||||
/* Forms */
|
||||
.form-group { margin-bottom: 16px; }
|
||||
label { display: block; color: #ccc; margin-bottom: 6px; font-size: 0.9em; }
|
||||
input[type="text"], input[type="password"] {
|
||||
width: 100%; padding: 11px 14px; background: #222; border: 1px solid #444; border-radius: 8px;
|
||||
color: #fff; font-size: 0.95em; outline: none; transition: border-color 0.2s;
|
||||
.form-group { display: block; margin-bottom: 16px; clear: both; }
|
||||
.form-group label {
|
||||
display: block;
|
||||
width: auto;
|
||||
float: none;
|
||||
color: #ccc;
|
||||
margin-bottom: 8px;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
.form-group input,
|
||||
input[type="text"],
|
||||
input[type="password"] {
|
||||
display: block;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
min-height: 46px;
|
||||
padding: 11px 14px;
|
||||
background: #222;
|
||||
border: 1px solid #444;
|
||||
border-radius: 8px;
|
||||
color: #fff;
|
||||
font-size: 0.95em;
|
||||
line-height: 1.3;
|
||||
outline: none;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
input:focus { border-color: #ff6b35; }
|
||||
.input-wrap { position: relative; }
|
||||
.input-wrap input { padding-right: 44px; }
|
||||
.toggle-pw { position: absolute; right: 12px; top: 50%; transform: translateY(-50%); background: none; border: none; color: #888; cursor: pointer; font-size: 0.85em; }
|
||||
.input-wrap { position: relative; display: block; width: 100%; min-width: 0; }
|
||||
.input-wrap input { padding-right: 64px; }
|
||||
.toggle-pw {
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
min-width: 38px;
|
||||
height: 30px;
|
||||
background: #2d2d2d;
|
||||
border: 1px solid #444;
|
||||
border-radius: 6px;
|
||||
color: #ccc;
|
||||
cursor: pointer;
|
||||
font-size: 0.82em;
|
||||
line-height: 28px;
|
||||
text-align: center;
|
||||
}
|
||||
.toggle-pw:hover { color: #ccc; }
|
||||
.hint { color: #888; font-size: 0.8em; margin-top: 6px; line-height: 1.5; }
|
||||
.hint a { color: #ff6b35; text-decoration: none; }
|
||||
@@ -116,6 +151,26 @@ 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>
|
||||
@@ -353,16 +408,60 @@ 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 = '';
|
||||
let selectedModel = '';
|
||||
|
||||
// --- Model cards ---
|
||||
document.querySelectorAll('.model-card').forEach(card => {
|
||||
document.querySelectorAll('#step1 .model-card').forEach(card => {
|
||||
card.addEventListener('click', (e) => {
|
||||
if (e.target.closest('.buy-link')) return; // don't select when clicking buy link
|
||||
document.querySelectorAll('.model-card').forEach(c => c.classList.remove('selected'));
|
||||
document.querySelectorAll('#step1 .model-card').forEach(c => c.classList.remove('selected'));
|
||||
card.classList.add('selected');
|
||||
selectedProvider = card.dataset.provider;
|
||||
selectedBase = card.dataset.base;
|
||||
@@ -375,7 +474,7 @@ document.getElementById('nextStep1').addEventListener('click', () => goStep(2));
|
||||
|
||||
// --- Steps ---
|
||||
function goStep(n) {
|
||||
document.querySelectorAll('.section').forEach(s => s.classList.remove('active'));
|
||||
document.querySelectorAll('.section:not(.skills-section)').forEach(s => s.classList.remove('active'));
|
||||
document.getElementById('step' + n).classList.add('active');
|
||||
// Keep skills section visible
|
||||
document.querySelectorAll('.steps .step').forEach((s, i) => {
|
||||
@@ -393,7 +492,7 @@ function setupStep2() {
|
||||
|
||||
const providerNames = {
|
||||
minimax: 'MiniMax', kimi: 'Kimi (月之暗面)', deepseek: 'DeepSeek',
|
||||
zhipu: '智谱 GLM', qwen: '通义千问', doubao: '豆包',
|
||||
zai: '智谱 GLM', qwen: '通义千问', doubao: '豆包',
|
||||
openai: 'OpenAI', anthropic: 'Anthropic Claude', groq: 'Groq',
|
||||
siliconflow: '硅基流动', custom: '自定义'
|
||||
};
|
||||
@@ -401,7 +500,7 @@ function setupStep2() {
|
||||
document.getElementById('step2desc').textContent = `请填写 ${name} 的 API Key`;
|
||||
|
||||
// Buy links
|
||||
const card = document.querySelector(`.model-card[data-provider="${selectedProvider}"]`);
|
||||
const card = document.querySelector(`#step1 .model-card[data-provider="${selectedProvider}"]`);
|
||||
const buyLink = card ? card.querySelector('.buy-link') : null;
|
||||
const linksDiv = document.getElementById('providerLinks');
|
||||
linksDiv.innerHTML = '';
|
||||
|
||||
27
u-claw-app/scripts/sync-lib.js
Normal file
27
u-claw-app/scripts/sync-lib.js
Normal file
@@ -0,0 +1,27 @@
|
||||
// Sync portable/ -> u-claw-app/ before dev/build.
|
||||
// Single source of truth lives under portable/ (used by Windows-Start.bat,
|
||||
// Mac-Start.command, and config-server). The Electron desktop app embeds a
|
||||
// runtime copy because asar packaging cannot reach across siblings.
|
||||
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
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') },
|
||||
];
|
||||
|
||||
for (const { from, to } of COPIES) {
|
||||
if (!fs.existsSync(from)) {
|
||||
console.error(`[sync-lib] Missing ${from}`);
|
||||
process.exit(1);
|
||||
}
|
||||
fs.mkdirSync(path.dirname(to), { recursive: true });
|
||||
fs.copyFileSync(from, to);
|
||||
console.log(`[sync-lib] ${path.relative(APP_DIR, to)}`);
|
||||
}
|
||||
143
u-claw-app/src/lib/bootstrap-xiapan.mjs
Normal file
143
u-claw-app/src/lib/bootstrap-xiapan.mjs
Normal file
@@ -0,0 +1,143 @@
|
||||
// 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';
|
||||
|
||||
const DEFAULT_PROVIDER_TEMPLATE = {
|
||||
baseUrl: 'https://api.u-claw.org/v1',
|
||||
api: 'openai-completions',
|
||||
models: [
|
||||
{ id: 'deepseek-chat', label: 'DeepSeek Chat' },
|
||||
{ id: 'qwen-plus', label: 'Qwen Plus' },
|
||||
{ id: 'qwen-turbo', label: '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;
|
||||
}
|
||||
|
||||
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.`,
|
||||
);
|
||||
return {
|
||||
ok: true,
|
||||
action: 'kept',
|
||||
source: fingerprintInfo.source,
|
||||
apiKey: existing.apiKey,
|
||||
};
|
||||
}
|
||||
if (existing.apiKey === apiKey) {
|
||||
return {
|
||||
ok: true,
|
||||
action: '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?.length ? existing.models : DEFAULT_PROVIDER_TEMPLATE.models,
|
||||
};
|
||||
|
||||
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);
|
||||
});
|
||||
}
|
||||
359
u-claw-app/src/lib/fingerprint.mjs
Normal file
359
u-claw-app/src/lib/fingerprint.mjs
Normal file
@@ -0,0 +1,359 @@
|
||||
// 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);
|
||||
});
|
||||
}
|
||||
91
u-claw-app/src/lib/xiapan-client.mjs
Normal file
91
u-claw-app/src/lib/xiapan-client.mjs
Normal file
@@ -0,0 +1,91 @@
|
||||
// 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`;
|
||||
}
|
||||
@@ -89,6 +89,20 @@ 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'));
|
||||
@@ -445,6 +459,7 @@ app.whenReady().then(async () => {
|
||||
|
||||
// Setup
|
||||
ensureConfig();
|
||||
await bindXiapanCloud();
|
||||
createMenu();
|
||||
setupIPC();
|
||||
createWindow();
|
||||
|
||||
Reference in New Issue
Block a user