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:
209
.github/workflows/release.yml
vendored
Normal file
209
.github/workflows/release.yml
vendored
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
name: Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- 'v*'
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
tag:
|
||||||
|
description: 'Tag to publish (e.g. v2.1.0). Leave empty to use the most recent tag.'
|
||||||
|
required: false
|
||||||
|
default: ''
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
portable-windows:
|
||||||
|
name: Portable Windows zip
|
||||||
|
runs-on: windows-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Read OpenClaw version
|
||||||
|
id: openclaw_version
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
version=$(tr -d '[:space:]' < OPENCLAW_VERSION)
|
||||||
|
echo "version=$version" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '22'
|
||||||
|
|
||||||
|
- name: Run portable setup (Windows)
|
||||||
|
shell: cmd
|
||||||
|
run: |
|
||||||
|
cd portable
|
||||||
|
call setup.bat
|
||||||
|
|
||||||
|
- name: Stage portable folder
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
mkdir -p dist
|
||||||
|
tag_name="${GITHUB_REF_NAME:-${{ github.event.inputs.tag }}}"
|
||||||
|
stage_dir="dist/u-claw-portable-windows-${tag_name}"
|
||||||
|
mkdir -p "$stage_dir"
|
||||||
|
cp -R portable/. "$stage_dir/"
|
||||||
|
# Drop dev-only large files we don't ship
|
||||||
|
rm -rf "$stage_dir/data/logs" 2>/dev/null || true
|
||||||
|
|
||||||
|
- name: Zip portable
|
||||||
|
shell: pwsh
|
||||||
|
run: |
|
||||||
|
$tag = if ($env:GITHUB_REF_NAME) { $env:GITHUB_REF_NAME } else { '${{ github.event.inputs.tag }}' }
|
||||||
|
$stage = "dist/u-claw-portable-windows-$tag"
|
||||||
|
$out = "dist/u-claw-portable-windows-$tag.zip"
|
||||||
|
Compress-Archive -Path "$stage/*" -DestinationPath $out -CompressionLevel Optimal
|
||||||
|
|
||||||
|
- uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: portable-windows
|
||||||
|
path: dist/u-claw-portable-windows-*.zip
|
||||||
|
|
||||||
|
portable-mac:
|
||||||
|
name: Portable macOS zip
|
||||||
|
runs-on: macos-14
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '22'
|
||||||
|
|
||||||
|
- name: Run portable setup (mac)
|
||||||
|
run: |
|
||||||
|
cd portable
|
||||||
|
bash setup.sh
|
||||||
|
|
||||||
|
- name: Stage portable folder
|
||||||
|
run: |
|
||||||
|
mkdir -p dist
|
||||||
|
tag_name="${GITHUB_REF_NAME:-${{ github.event.inputs.tag }}}"
|
||||||
|
stage_dir="dist/u-claw-portable-mac-${tag_name}"
|
||||||
|
mkdir -p "$stage_dir"
|
||||||
|
cp -R portable/. "$stage_dir/"
|
||||||
|
rm -rf "$stage_dir/data/logs" 2>/dev/null || true
|
||||||
|
|
||||||
|
- name: Zip portable
|
||||||
|
run: |
|
||||||
|
tag_name="${GITHUB_REF_NAME:-${{ github.event.inputs.tag }}}"
|
||||||
|
cd dist
|
||||||
|
zip -qry "u-claw-portable-mac-${tag_name}.zip" "u-claw-portable-mac-${tag_name}"
|
||||||
|
|
||||||
|
- uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: portable-mac
|
||||||
|
path: dist/u-claw-portable-mac-*.zip
|
||||||
|
|
||||||
|
desktop-windows:
|
||||||
|
name: Electron Windows installer
|
||||||
|
runs-on: windows-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '22'
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
shell: cmd
|
||||||
|
run: |
|
||||||
|
cd u-claw-app
|
||||||
|
npm install --registry=https://registry.npmmirror.com
|
||||||
|
|
||||||
|
- name: Build Windows installer
|
||||||
|
shell: cmd
|
||||||
|
run: |
|
||||||
|
cd u-claw-app
|
||||||
|
npm run build:win
|
||||||
|
env:
|
||||||
|
# Skip code signing — we ship unsigned. README documents the SmartScreen workaround.
|
||||||
|
CSC_IDENTITY_AUTO_DISCOVERY: 'false'
|
||||||
|
|
||||||
|
- uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: desktop-windows
|
||||||
|
path: |
|
||||||
|
u-claw-app/release/*.exe
|
||||||
|
u-claw-app/release/*.exe.blockmap
|
||||||
|
|
||||||
|
desktop-mac:
|
||||||
|
name: Electron macOS DMG
|
||||||
|
runs-on: macos-14
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '22'
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: |
|
||||||
|
cd u-claw-app
|
||||||
|
npm install --registry=https://registry.npmmirror.com
|
||||||
|
|
||||||
|
- name: Build macOS DMG
|
||||||
|
run: |
|
||||||
|
cd u-claw-app
|
||||||
|
npm run build:mac-arm64
|
||||||
|
env:
|
||||||
|
CSC_IDENTITY_AUTO_DISCOVERY: 'false'
|
||||||
|
|
||||||
|
- uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: desktop-mac
|
||||||
|
path: |
|
||||||
|
u-claw-app/release/*.dmg
|
||||||
|
u-claw-app/release/*.dmg.blockmap
|
||||||
|
|
||||||
|
publish:
|
||||||
|
name: Publish GitHub Release
|
||||||
|
needs: [portable-windows, portable-mac, desktop-windows, desktop-mac]
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
path: artifacts
|
||||||
|
|
||||||
|
- name: Resolve tag
|
||||||
|
id: tag
|
||||||
|
run: |
|
||||||
|
if [ -n "${{ github.event.inputs.tag }}" ]; then
|
||||||
|
tag="${{ github.event.inputs.tag }}"
|
||||||
|
else
|
||||||
|
tag="${GITHUB_REF_NAME}"
|
||||||
|
fi
|
||||||
|
echo "tag=$tag" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Read OpenClaw version
|
||||||
|
id: openclaw_version
|
||||||
|
run: |
|
||||||
|
version=$(tr -d '[:space:]' < OPENCLAW_VERSION)
|
||||||
|
echo "version=$version" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Create or update release
|
||||||
|
uses: softprops/action-gh-release@v2
|
||||||
|
with:
|
||||||
|
tag_name: ${{ steps.tag.outputs.tag }}
|
||||||
|
name: U-Claw ${{ steps.tag.outputs.tag }}
|
||||||
|
body: |
|
||||||
|
## U-Claw ${{ steps.tag.outputs.tag }}
|
||||||
|
|
||||||
|
- 内置虾盘云:首次启动自动绑定本机/U盘指纹,生成 `sk-...` apiKey
|
||||||
|
- OpenClaw runtime: `${{ steps.openclaw_version.outputs.version }}`
|
||||||
|
- 充值入口:https://u-claw.org/cloud.html
|
||||||
|
|
||||||
|
> Windows 安装包未做代码签名,首次运行需在 SmartScreen 选择"仍要运行"。
|
||||||
|
> macOS DMG 未做公证,首次启动需 `xattr -rd com.apple.quarantine /Applications/U-Claw.app` 或右键打开。
|
||||||
|
files: |
|
||||||
|
artifacts/portable-windows/*
|
||||||
|
artifacts/portable-mac/*
|
||||||
|
artifacts/desktop-windows/*
|
||||||
|
artifacts/desktop-mac/*
|
||||||
|
fail_on_unmatched_files: false
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
1
OPENCLAW_VERSION
Normal file
1
OPENCLAW_VERSION
Normal file
@@ -0,0 +1 @@
|
|||||||
|
2026.4.29
|
||||||
25
README.md
25
README.md
@@ -22,6 +22,18 @@ U-Claw(虾盘)是一个**制作教程 + 全套源代码**,教你把 [OpenC
|
|||||||
|
|
||||||
> 📖 **[完整教程](https://u-claw.org/tutorial.html)** — 从零开始的手工安装指南、模型配置、聊天平台接入,小白也能看懂。
|
> 📖 **[完整教程](https://u-claw.org/tutorial.html)** — 从零开始的手工安装指南、模型配置、聊天平台接入,小白也能看懂。
|
||||||
|
|
||||||
|
### 🔗 内置虾盘云:开箱即用
|
||||||
|
|
||||||
|
每个 U-Claw 实例首次启动会**自动**根据当前 U 盘 / 硬盘的指纹生成一个 `sk-...` 形式的虾盘云 apiKey,并写入 `data/.openclaw/openclaw.json`。打开 Config.html 就能看到这张「已绑定虾盘云」横幅(指纹来源、Key、余额)。
|
||||||
|
|
||||||
|
- **不送 token**:余额初始为 0,需自行充值([u-claw.org/cloud.html](https://u-claw.org/cloud.html))
|
||||||
|
- **指纹规则**:
|
||||||
|
- 从 U 盘根目录启动 → 绑定该 U 盘的硬件指纹(USB Serial + PNPDeviceID)
|
||||||
|
- 从硬盘启动(电脑安装版 / Electron App)→ 绑定主板 + 系统盘指纹
|
||||||
|
- Mac/Linux 走 Hardware UUID / `/etc/machine-id` + 启动盘 UUID
|
||||||
|
- **换机 / 换盘**:换 U 盘或换电脑后,新的指纹会生成新的 Key;旧 Key 的余额仍归属原指纹(在 Config.html 点「解绑」可触发重新绑定)
|
||||||
|
- **隐私**:指纹只用于本地生成 Key,不上传,无登录
|
||||||
|
|
||||||
### 一键安装(推荐)
|
### 一键安装(推荐)
|
||||||
|
|
||||||
不需要 U 盘,一行命令直接装到电脑:
|
不需要 U 盘,一行命令直接装到电脑:
|
||||||
@@ -108,6 +120,19 @@ npm run build:mac-arm64 # 打包 → release/*.dmg
|
|||||||
npm run build:win # 打包 → release/*.exe
|
npm run build:win # 打包 → release/*.exe
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### 直接下载发行版
|
||||||
|
|
||||||
|
[GitHub Releases](https://github.com/dongsheng123132/u-claw/releases) 提供四种打包好的产物:
|
||||||
|
|
||||||
|
- `u-claw-portable-windows-vX.Y.Z.zip` — Windows 便携版(解压即用)
|
||||||
|
- `u-claw-portable-mac-vX.Y.Z.zip` — Mac 便携版
|
||||||
|
- `U-Claw Setup vX.Y.Z.exe` — Windows 桌面安装包
|
||||||
|
- `U-Claw-vX.Y.Z-arm64.dmg` — Mac 桌面安装包
|
||||||
|
|
||||||
|
> ⚠️ 安装包未签名:
|
||||||
|
> - **Windows**:双击 `.exe` 时 SmartScreen 会拦,点「更多信息」→「仍要运行」
|
||||||
|
> - **Mac**:首次启动如被 Gatekeeper 拦,执行 `xattr -rd com.apple.quarantine /Applications/U-Claw.app` 或在 Finder 里右键→打开
|
||||||
|
|
||||||
### 支持的 AI 模型
|
### 支持的 AI 模型
|
||||||
|
|
||||||
**国产模型(无需翻墙):**
|
**国产模型(无需翻墙):**
|
||||||
|
|||||||
@@ -176,13 +176,14 @@ if [ -d "$CORE_DIR/node_modules/openclaw" ]; then
|
|||||||
echo -e " ${GREEN}✓${NC} OpenClaw 已安装,跳过"
|
echo -e " ${GREEN}✓${NC} OpenClaw 已安装,跳过"
|
||||||
else
|
else
|
||||||
if [ ! -f "$CORE_DIR/package.json" ]; then
|
if [ ! -f "$CORE_DIR/package.json" ]; then
|
||||||
cat > "$CORE_DIR/package.json" << 'PKGJSON'
|
OPENCLAW_VERSION="2026.4.29"
|
||||||
|
cat > "$CORE_DIR/package.json" << PKGJSON
|
||||||
{
|
{
|
||||||
"name": "u-claw-core",
|
"name": "u-claw-core",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"openclaw": "latest"
|
"openclaw": "$OPENCLAW_VERSION"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
PKGJSON
|
PKGJSON
|
||||||
|
|||||||
@@ -151,6 +151,26 @@ input:focus { border-color: #ff6b35; }
|
|||||||
<h1><span class="lobster">🦞</span> U-Claw Pro</h1>
|
<h1><span class="lobster">🦞</span> U-Claw Pro</h1>
|
||||||
<p class="subtitle">便携版 — 选择模型,填入 API Key,一键启动</p>
|
<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 -->
|
<!-- Steps -->
|
||||||
<div class="steps">
|
<div class="steps">
|
||||||
<div class="step active" data-step="1"><span class="num">1</span>选模型</div>
|
<div class="step active" data-step="1"><span class="num">1</span>选模型</div>
|
||||||
@@ -388,6 +408,50 @@ input:focus { border-color: #ff6b35; }
|
|||||||
<div class="toast" id="toast"></div>
|
<div class="toast" id="toast"></div>
|
||||||
|
|
||||||
<script>
|
<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 ---
|
// --- State ---
|
||||||
let selectedProvider = null;
|
let selectedProvider = null;
|
||||||
let selectedBase = '';
|
let selectedBase = '';
|
||||||
|
|||||||
@@ -106,6 +106,11 @@ if [ ! -d "$CORE_DIR/node_modules" ]; then
|
|||||||
echo ""
|
echo ""
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# ---- 7b. Bind device fingerprint and inject Xiapan Cloud apiKey ----
|
||||||
|
echo -e " ${CYAN}Binding device fingerprint to Xiapan Cloud...${NC}"
|
||||||
|
UCLAW_APP_ROOT="$UCLAW_DIR" "$NODE_BIN" "$UCLAW_DIR/lib/bootstrap-xiapan.mjs" "$CONFIG_FILE" || true
|
||||||
|
echo ""
|
||||||
|
|
||||||
# ---- 8. Find available port ----
|
# ---- 8. Find available port ----
|
||||||
PORT=18789
|
PORT=18789
|
||||||
while lsof -i :$PORT >/dev/null 2>&1; do
|
while lsof -i :$PORT >/dev/null 2>&1; do
|
||||||
|
|||||||
@@ -72,6 +72,12 @@ if not exist "%CORE_DIR%\node_modules" (
|
|||||||
echo.
|
echo.
|
||||||
)
|
)
|
||||||
|
|
||||||
|
REM Bind device fingerprint and inject Xiapan Cloud apiKey into openclaw.json
|
||||||
|
echo Binding device fingerprint to Xiapan Cloud...
|
||||||
|
set "UCLAW_APP_ROOT=%UCLAW_DIR%"
|
||||||
|
"%NODE_BIN%" "%UCLAW_DIR%lib\bootstrap-xiapan.mjs" "%STATE_DIR%\openclaw.json"
|
||||||
|
echo.
|
||||||
|
|
||||||
REM Auto-install WeChat plugin if available
|
REM Auto-install WeChat plugin if available
|
||||||
set "WECHAT_PLUGIN_SRC=%APP_DIR%\extensions\openclaw-weixin"
|
set "WECHAT_PLUGIN_SRC=%APP_DIR%\extensions\openclaw-weixin"
|
||||||
set "WECHAT_PLUGIN_DST=%USERPROFILE%\.openclaw\extensions\openclaw-weixin"
|
set "WECHAT_PLUGIN_DST=%USERPROFILE%\.openclaw\extensions\openclaw-weixin"
|
||||||
|
|||||||
@@ -391,6 +391,71 @@ const server = http.createServer((req, res) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// API: Xiapan Cloud status (fingerprint + apiKey + balance)
|
||||||
|
if (req.url === '/api/xiapan/status' && req.method === 'GET') {
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const fpMod = await import('../lib/fingerprint.mjs');
|
||||||
|
const xpMod = await import('../lib/xiapan-client.mjs');
|
||||||
|
const portableRoot = path.join(__dirname, '..');
|
||||||
|
const fp = await fpMod.getFingerprint(portableRoot);
|
||||||
|
const apiKey = xpMod.buildApiKey(fp.fingerprint);
|
||||||
|
const balance = await xpMod.getBalance(apiKey);
|
||||||
|
const rechargeUrl = xpMod.getRechargeUrl(apiKey);
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({
|
||||||
|
source: fp.source,
|
||||||
|
apiKey,
|
||||||
|
rechargeUrl,
|
||||||
|
balance,
|
||||||
|
}));
|
||||||
|
} catch (err) {
|
||||||
|
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ error: err.message }));
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// API: Re-run bootstrap to inject the uclaw-cloud provider
|
||||||
|
if (req.url === '/api/xiapan/bind' && req.method === 'POST') {
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const mod = await import('../lib/bootstrap-xiapan.mjs');
|
||||||
|
const portableRoot = path.join(__dirname, '..');
|
||||||
|
const result = await mod.bootstrapXiapan({
|
||||||
|
configPath: CONFIG_PATH,
|
||||||
|
appRoot: portableRoot,
|
||||||
|
});
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify(result));
|
||||||
|
} catch (err) {
|
||||||
|
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ error: err.message }));
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// API: Remove uclaw-cloud provider so the next bind regenerates it
|
||||||
|
if (req.url === '/api/xiapan/unbind' && req.method === 'POST') {
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(CONFIG_PATH)) {
|
||||||
|
const config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8'));
|
||||||
|
if (config.models && config.models.providers && config.models.providers['uclaw-cloud']) {
|
||||||
|
delete config.models.providers['uclaw-cloud'];
|
||||||
|
fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ ok: true }));
|
||||||
|
} catch (err) {
|
||||||
|
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ error: err.message }));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// API: Save config
|
// API: Save config
|
||||||
if (req.url === '/api/config' && req.method === 'POST') {
|
if (req.url === '/api/config' && req.method === 'POST') {
|
||||||
let body = '';
|
let body = '';
|
||||||
|
|||||||
143
portable/lib/bootstrap-xiapan.mjs
Normal file
143
portable/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
portable/lib/fingerprint.mjs
Normal file
359
portable/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
portable/lib/xiapan-client.mjs
Normal file
91
portable/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`;
|
||||||
|
}
|
||||||
@@ -105,8 +105,14 @@ if exist "%CORE_DIR%\node_modules\openclaw" goto skip_openclaw_install
|
|||||||
echo [INSTALL] Installing OpenClaw...
|
echo [INSTALL] Installing OpenClaw...
|
||||||
if not exist "%CORE_DIR%" mkdir "%CORE_DIR%" 2>nul
|
if not exist "%CORE_DIR%" mkdir "%CORE_DIR%" 2>nul
|
||||||
|
|
||||||
|
REM Read pinned OpenClaw version from repo root
|
||||||
|
set "OPENCLAW_VERSION_FILE=%~dp0..\OPENCLAW_VERSION"
|
||||||
|
set "OPENCLAW_VERSION=2026.4.29"
|
||||||
|
if exist "%OPENCLAW_VERSION_FILE%" (
|
||||||
|
for /f "usebackq delims=" %%v in ("%OPENCLAW_VERSION_FILE%") do set "OPENCLAW_VERSION=%%v"
|
||||||
|
)
|
||||||
if not exist "%CORE_DIR%\package.json" (
|
if not exist "%CORE_DIR%\package.json" (
|
||||||
echo { "name": "u-claw-core", "version": "1.0.0", "private": true, "dependencies": { "openclaw": "latest" } } > "%CORE_DIR%\package.json"
|
echo { "name": "u-claw-core", "version": "1.0.0", "private": true, "dependencies": { "openclaw": "%OPENCLAW_VERSION%" } } > "%CORE_DIR%\package.json"
|
||||||
)
|
)
|
||||||
|
|
||||||
cd /d "%CORE_DIR%"
|
cd /d "%CORE_DIR%"
|
||||||
|
|||||||
@@ -204,16 +204,21 @@ if ($AllPlatforms) {
|
|||||||
|
|
||||||
$packageJsonPath = Join-Path $coreDir "package.json"
|
$packageJsonPath = Join-Path $coreDir "package.json"
|
||||||
if (-not (Test-Path -Path $packageJsonPath -PathType Leaf)) {
|
if (-not (Test-Path -Path $packageJsonPath -PathType Leaf)) {
|
||||||
$packageJson = @'
|
$openclawVersionFile = Join-Path $PSScriptRoot "..\OPENCLAW_VERSION"
|
||||||
|
$openclawVersion = "2026.4.29"
|
||||||
|
if (Test-Path -Path $openclawVersionFile -PathType Leaf) {
|
||||||
|
$openclawVersion = (Get-Content -Path $openclawVersionFile -Raw).Trim()
|
||||||
|
}
|
||||||
|
$packageJson = @"
|
||||||
{
|
{
|
||||||
"name": "u-claw-core",
|
"name": "u-claw-core",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"openclaw": "latest"
|
"openclaw": "$openclawVersion"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
'@
|
"@
|
||||||
$packageJson | Out-File -FilePath $packageJsonPath -Encoding utf8
|
$packageJson | Out-File -FilePath $packageJsonPath -Encoding utf8
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -109,15 +109,20 @@ else
|
|||||||
echo -e " ${CYAN}↓${NC} 安装 OpenClaw..."
|
echo -e " ${CYAN}↓${NC} 安装 OpenClaw..."
|
||||||
mkdir -p "$CORE_DIR"
|
mkdir -p "$CORE_DIR"
|
||||||
|
|
||||||
# Init package.json if not exists
|
# Init package.json if not exists (pinned OpenClaw version from OPENCLAW_VERSION)
|
||||||
|
OPENCLAW_VERSION_FILE="$(dirname "$0")/../OPENCLAW_VERSION"
|
||||||
|
OPENCLAW_VERSION="2026.4.29"
|
||||||
|
if [ -f "$OPENCLAW_VERSION_FILE" ]; then
|
||||||
|
OPENCLAW_VERSION="$(tr -d '[:space:]' < "$OPENCLAW_VERSION_FILE")"
|
||||||
|
fi
|
||||||
if [ ! -f "$CORE_DIR/package.json" ]; then
|
if [ ! -f "$CORE_DIR/package.json" ]; then
|
||||||
cat > "$CORE_DIR/package.json" << 'PKGJSON'
|
cat > "$CORE_DIR/package.json" << PKGJSON
|
||||||
{
|
{
|
||||||
"name": "u-claw-core",
|
"name": "u-claw-core",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"openclaw": "latest"
|
"openclaw": "$OPENCLAW_VERSION"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
PKGJSON
|
PKGJSON
|
||||||
|
|||||||
680
portable/充值.html
680
portable/充值.html
@@ -1,680 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="zh-CN">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>虾盘云 — 充值</title>
|
|
||||||
<style>
|
|
||||||
*{margin:0;padding:0;box-sizing:border-box}
|
|
||||||
:root{
|
|
||||||
--bg:#f5f5f7;
|
|
||||||
--surface:#ffffff;
|
|
||||||
--border:#e5e5e5;
|
|
||||||
--border-hover:#d0d0d0;
|
|
||||||
--text:#1a1a1a;
|
|
||||||
--text-muted:#6b7280;
|
|
||||||
--text-dim:#9ca3af;
|
|
||||||
--blue:#2563eb;
|
|
||||||
--blue-light:#eff6ff;
|
|
||||||
--blue-border:#bfdbfe;
|
|
||||||
--green:#16a34a;
|
|
||||||
--green-light:#f0fdf4;
|
|
||||||
--green-border:#bbf7d0;
|
|
||||||
--orange:#ea580c;
|
|
||||||
--orange-light:#fff7ed;
|
|
||||||
--orange-border:#fed7aa;
|
|
||||||
--red:#dc2626;
|
|
||||||
--yellow:#d97706;
|
|
||||||
--radius:14px;
|
|
||||||
--shadow:0 1px 3px rgba(0,0,0,0.08),0 1px 2px rgba(0,0,0,0.04);
|
|
||||||
--shadow-hover:0 4px 12px rgba(0,0,0,0.1),0 2px 4px rgba(0,0,0,0.06);
|
|
||||||
}
|
|
||||||
body{font-family:-apple-system,"Microsoft YaHei","PingFang SC",sans-serif;background:var(--bg);color:var(--text);min-height:100vh}
|
|
||||||
|
|
||||||
/* Header */
|
|
||||||
.header{
|
|
||||||
background:var(--surface);
|
|
||||||
border-bottom:1px solid var(--border);
|
|
||||||
padding:0 28px;height:56px;
|
|
||||||
display:flex;align-items:center;justify-content:space-between;
|
|
||||||
position:sticky;top:0;z-index:10;
|
|
||||||
}
|
|
||||||
.header-left{display:flex;align-items:center;gap:10px}
|
|
||||||
.logo{width:28px;height:28px;border-radius:7px;border:1px solid var(--border)}
|
|
||||||
.header-title{font-size:.95em;font-weight:700;color:var(--text)}
|
|
||||||
.header-badge{
|
|
||||||
font-size:.68em;padding:2px 8px;border-radius:20px;font-weight:600;
|
|
||||||
background:var(--orange-light);color:var(--orange);border:1px solid var(--orange-border);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Layout */
|
|
||||||
.container{max-width:520px;margin:0 auto;padding:28px 20px 80px}
|
|
||||||
|
|
||||||
/* Section toggle */
|
|
||||||
.section{display:none}
|
|
||||||
.section.active{display:block}
|
|
||||||
|
|
||||||
/* Key 手动输入 */
|
|
||||||
.key-input-card{
|
|
||||||
background:var(--surface);border:1.5px solid var(--border);
|
|
||||||
border-radius:var(--radius);padding:20px;margin-bottom:16px;
|
|
||||||
box-shadow:var(--shadow);
|
|
||||||
}
|
|
||||||
.key-input-title{font-size:.88em;font-weight:600;color:var(--text-muted);margin-bottom:10px}
|
|
||||||
.key-input-wrap{display:flex;gap:8px}
|
|
||||||
.key-input-wrap input{
|
|
||||||
flex:1;padding:9px 12px;
|
|
||||||
background:var(--bg);border:1.5px solid var(--border);
|
|
||||||
border-radius:9px;color:var(--text);font-size:.86em;
|
|
||||||
outline:none;transition:border-color .15s;font-family:inherit;
|
|
||||||
}
|
|
||||||
.key-input-wrap input:focus{border-color:var(--orange)}
|
|
||||||
.key-confirm-btn{
|
|
||||||
padding:9px 16px;border:none;border-radius:9px;
|
|
||||||
background:var(--blue);color:#fff;font-size:.84em;font-weight:600;
|
|
||||||
cursor:pointer;white-space:nowrap;font-family:inherit;transition:background .15s;
|
|
||||||
}
|
|
||||||
.key-confirm-btn:hover{background:#1d4ed8}
|
|
||||||
|
|
||||||
/* 账户面板 */
|
|
||||||
.xp-panel{
|
|
||||||
background:var(--surface);
|
|
||||||
border:1.5px solid var(--orange-border);
|
|
||||||
border-radius:var(--radius);
|
|
||||||
overflow:hidden;
|
|
||||||
box-shadow:var(--shadow);
|
|
||||||
margin-bottom:16px;
|
|
||||||
}
|
|
||||||
.xp-panel-head{
|
|
||||||
background:var(--orange-light);
|
|
||||||
padding:14px 18px;
|
|
||||||
border-bottom:1px solid var(--orange-border);
|
|
||||||
display:flex;align-items:center;justify-content:space-between;gap:8px;
|
|
||||||
}
|
|
||||||
.xp-panel-head-left{display:flex;align-items:center;gap:8px}
|
|
||||||
.xp-panel-title{font-size:.9em;font-weight:700;color:var(--orange)}
|
|
||||||
.xp-panel-body{padding:16px 18px;display:flex;flex-direction:column;gap:10px}
|
|
||||||
|
|
||||||
/* Key 行 */
|
|
||||||
.xp-key-box{
|
|
||||||
background:var(--bg);border:1px solid var(--border);
|
|
||||||
border-radius:9px;padding:10px 12px;
|
|
||||||
}
|
|
||||||
.xp-key-label{font-size:.68em;color:var(--text-dim);margin-bottom:4px;font-weight:600;letter-spacing:.04em}
|
|
||||||
.xp-key-inner{display:flex;align-items:center;gap:6px}
|
|
||||||
.xp-key-val{
|
|
||||||
flex:1;font-size:.72em;color:var(--text-muted);
|
|
||||||
font-family:"Consolas","Courier New",monospace;
|
|
||||||
word-break:break-all;line-height:1.4;
|
|
||||||
}
|
|
||||||
.xp-copy-btn{
|
|
||||||
background:var(--surface);border:1px solid var(--border);
|
|
||||||
color:var(--text-muted);border-radius:6px;
|
|
||||||
padding:4px 10px;font-size:.7em;font-weight:600;
|
|
||||||
cursor:pointer;white-space:nowrap;font-family:inherit;
|
|
||||||
transition:all .15s;flex-shrink:0;
|
|
||||||
}
|
|
||||||
.xp-copy-btn:hover{border-color:var(--blue);color:var(--blue)}
|
|
||||||
|
|
||||||
/* 余额行 */
|
|
||||||
.xp-bal-box{
|
|
||||||
background:var(--bg);border:1px solid var(--border);
|
|
||||||
border-radius:9px;padding:10px 12px;
|
|
||||||
display:flex;align-items:center;gap:8px;
|
|
||||||
}
|
|
||||||
.xp-bal-label{font-size:.72em;color:var(--text-dim);flex-shrink:0}
|
|
||||||
.xp-bal-val{flex:1;font-size:.95em;font-weight:700;color:var(--green)}
|
|
||||||
.xp-bal-val.warn{color:var(--yellow)}
|
|
||||||
.xp-bal-val.low{color:var(--red)}
|
|
||||||
.xp-bal-val.dim{color:var(--text-dim);font-weight:400;font-size:.82em}
|
|
||||||
.xp-refresh-btn{
|
|
||||||
background:none;border:1px solid var(--border);
|
|
||||||
color:var(--text-dim);border-radius:6px;
|
|
||||||
padding:3px 9px;font-size:.68em;cursor:pointer;
|
|
||||||
font-family:inherit;transition:all .15s;white-space:nowrap;flex-shrink:0;
|
|
||||||
}
|
|
||||||
.xp-refresh-btn:hover{border-color:var(--border-hover);color:var(--text)}
|
|
||||||
.xp-refresh-btn:disabled{opacity:.45;cursor:default}
|
|
||||||
|
|
||||||
/* 充值表单卡片 */
|
|
||||||
.form-card{
|
|
||||||
background:var(--surface);border:1.5px solid var(--border);
|
|
||||||
border-radius:var(--radius);padding:20px;margin-bottom:16px;
|
|
||||||
box-shadow:var(--shadow);
|
|
||||||
}
|
|
||||||
.form-card-title{
|
|
||||||
font-size:.9em;font-weight:700;color:var(--text);
|
|
||||||
margin-bottom:16px;display:flex;align-items:center;gap:8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 金额网格 */
|
|
||||||
.amt-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:6px;margin-bottom:12px}
|
|
||||||
.amt-btn{
|
|
||||||
background:var(--bg);border:1.5px solid var(--border);
|
|
||||||
border-radius:10px;padding:12px 6px;text-align:center;
|
|
||||||
cursor:pointer;transition:all .15s;
|
|
||||||
}
|
|
||||||
.amt-btn:hover{border-color:var(--border-hover);box-shadow:var(--shadow-hover)}
|
|
||||||
.amt-btn.sel{border-color:var(--orange);background:var(--orange-light);box-shadow:0 0 0 1px var(--orange)}
|
|
||||||
.amt-cny{font-size:1em;font-weight:700;color:var(--text)}
|
|
||||||
.amt-quota{font-size:.62em;color:var(--text-dim);margin-top:2px}
|
|
||||||
.amt-btn.sel .amt-cny{color:var(--orange)}
|
|
||||||
.amt-btn.sel .amt-quota{color:rgba(234,88,12,.6)}
|
|
||||||
|
|
||||||
.custom-wrap{display:none;margin-bottom:8px}
|
|
||||||
.custom-inner{display:flex;align-items:center;gap:6px}
|
|
||||||
.custom-prefix{font-size:.9em;color:var(--text-muted)}
|
|
||||||
input[type="number"],input[type="text"]{
|
|
||||||
flex:1;padding:9px 12px;
|
|
||||||
background:var(--bg);border:1.5px solid var(--border);
|
|
||||||
border-radius:9px;color:var(--text);font-size:.86em;
|
|
||||||
outline:none;transition:border-color .15s;font-family:inherit;
|
|
||||||
-moz-appearance:textfield;
|
|
||||||
}
|
|
||||||
input[type="number"]::-webkit-outer-spin-button,
|
|
||||||
input[type="number"]::-webkit-inner-spin-button{-webkit-appearance:none}
|
|
||||||
input:focus{border-color:var(--orange)}
|
|
||||||
.input-hint{font-size:.74em;color:var(--text-dim);margin-top:5px}
|
|
||||||
|
|
||||||
/* 分割线 */
|
|
||||||
.divider{height:1px;background:var(--border);margin:16px 0}
|
|
||||||
|
|
||||||
/* QR */
|
|
||||||
.qr-tabs{display:flex;gap:5px;margin-bottom:10px}
|
|
||||||
.qr-tab{
|
|
||||||
flex:1;padding:7px;border-radius:8px;
|
|
||||||
font-size:.78em;font-weight:600;
|
|
||||||
cursor:pointer;border:1.5px solid var(--border);
|
|
||||||
background:transparent;color:var(--text-muted);
|
|
||||||
font-family:inherit;transition:all .15s;text-align:center;
|
|
||||||
}
|
|
||||||
.qr-tab.active{background:var(--green-light);border-color:var(--green-border);color:var(--green)}
|
|
||||||
.qr-box{display:none;text-align:center}
|
|
||||||
.qr-box.active{display:block}
|
|
||||||
.qr-img{
|
|
||||||
width:168px;height:168px;object-fit:contain;
|
|
||||||
border-radius:10px;border:1px solid var(--border);
|
|
||||||
background:var(--bg);margin:0 auto 8px;display:block;
|
|
||||||
}
|
|
||||||
.qr-placeholder{
|
|
||||||
width:168px;height:168px;border-radius:10px;
|
|
||||||
border:1.5px dashed var(--border);background:var(--bg);
|
|
||||||
margin:0 auto 8px;display:flex;align-items:center;justify-content:center;
|
|
||||||
flex-direction:column;gap:6px;color:var(--text-dim);font-size:.78em;
|
|
||||||
}
|
|
||||||
.qr-name{font-size:.82em;color:var(--text-muted);font-weight:600}
|
|
||||||
.qr-sub{font-size:.7em;color:var(--text-dim);margin-top:2px;line-height:1.5}
|
|
||||||
|
|
||||||
/* 截图上传 */
|
|
||||||
.upload-area{
|
|
||||||
border:1.5px dashed var(--border);border-radius:10px;
|
|
||||||
padding:22px 16px;text-align:center;cursor:pointer;
|
|
||||||
transition:all .15s;position:relative;overflow:hidden;
|
|
||||||
background:var(--bg);
|
|
||||||
}
|
|
||||||
.upload-area:hover{border-color:var(--border-hover);background:var(--surface)}
|
|
||||||
.upload-area.has{border-color:var(--green);border-style:solid;background:var(--green-light)}
|
|
||||||
.upload-area input{position:absolute;inset:0;opacity:0;cursor:pointer;width:100%;height:100%}
|
|
||||||
.upload-icon{font-size:1.8em;margin-bottom:6px;color:var(--text-dim)}
|
|
||||||
.upload-text{font-size:.82em;color:var(--text-muted)}
|
|
||||||
.upload-sub{font-size:.72em;color:var(--text-dim);margin-top:3px}
|
|
||||||
.upload-preview{max-width:100%;max-height:160px;border-radius:8px;object-fit:contain;display:none;margin:0 auto}
|
|
||||||
|
|
||||||
label{display:block;font-size:.8em;color:var(--text-muted);font-weight:600;margin-bottom:8px}
|
|
||||||
|
|
||||||
/* 提交按钮 */
|
|
||||||
.submit-btn{
|
|
||||||
width:100%;padding:13px;border:none;border-radius:10px;
|
|
||||||
font-size:.94em;font-weight:700;cursor:pointer;
|
|
||||||
font-family:inherit;transition:all .15s;
|
|
||||||
background:var(--orange);color:#fff;
|
|
||||||
box-shadow:0 2px 8px rgba(234,88,12,0.25);
|
|
||||||
}
|
|
||||||
.submit-btn:hover{background:#c2410c;box-shadow:0 4px 14px rgba(234,88,12,0.3);transform:translateY(-1px)}
|
|
||||||
.submit-btn:active{transform:none}
|
|
||||||
.submit-btn:disabled{background:var(--text-dim);cursor:not-allowed;box-shadow:none;transform:none}
|
|
||||||
|
|
||||||
/* 成功页 */
|
|
||||||
.success-view{
|
|
||||||
background:var(--surface);border:1.5px solid var(--green-border);
|
|
||||||
border-radius:var(--radius);padding:40px 24px;
|
|
||||||
text-align:center;box-shadow:var(--shadow);
|
|
||||||
}
|
|
||||||
.success-icon{font-size:3em;margin-bottom:16px}
|
|
||||||
.success-title{font-size:1.2em;font-weight:700;color:var(--green);margin-bottom:8px}
|
|
||||||
.success-desc{color:var(--text-muted);font-size:.86em;line-height:1.8;margin-bottom:20px}
|
|
||||||
.success-tip{
|
|
||||||
background:var(--green-light);border:1px solid var(--green-border);
|
|
||||||
border-radius:10px;padding:14px 16px;
|
|
||||||
font-size:.8em;color:var(--text-muted);line-height:1.8;text-align:left;
|
|
||||||
}
|
|
||||||
.back-btn{
|
|
||||||
display:inline-flex;align-items:center;gap:5px;
|
|
||||||
margin-top:20px;padding:9px 20px;
|
|
||||||
background:var(--bg);border:1.5px solid var(--border);
|
|
||||||
border-radius:8px;color:var(--text-muted);font-size:.84em;
|
|
||||||
cursor:pointer;font-family:inherit;transition:all .15s;
|
|
||||||
}
|
|
||||||
.back-btn:hover{border-color:var(--border-hover);color:var(--text)}
|
|
||||||
|
|
||||||
/* Toast */
|
|
||||||
.toast{
|
|
||||||
position:fixed;top:20px;right:20px;
|
|
||||||
background:var(--green);color:#fff;
|
|
||||||
padding:10px 18px;border-radius:8px;
|
|
||||||
font-size:.82em;font-weight:500;
|
|
||||||
opacity:0;transform:translateY(-8px);
|
|
||||||
transition:all .25s;z-index:999;pointer-events:none;
|
|
||||||
box-shadow:0 4px 12px rgba(0,0,0,0.15);
|
|
||||||
}
|
|
||||||
.toast.show{opacity:1;transform:translateY(0)}
|
|
||||||
.toast.error{background:var(--red)}
|
|
||||||
.toast.info{background:var(--blue)}
|
|
||||||
|
|
||||||
@media(max-width:480px){
|
|
||||||
.amt-grid{grid-template-columns:repeat(2,1fr)}
|
|
||||||
.header{padding:0 16px}
|
|
||||||
.container{padding:20px 14px 80px}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
|
|
||||||
<div class="header">
|
|
||||||
<div class="header-left">
|
|
||||||
<img class="logo" src="assets/logo.png" alt="" onerror="this.style.display='none'">
|
|
||||||
<span class="header-title">虾盘云 — 充值</span>
|
|
||||||
</div>
|
|
||||||
<span class="header-badge">虾盘出品</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="container">
|
|
||||||
|
|
||||||
<!-- Key 手动输入(未检测到时显示) -->
|
|
||||||
<div class="key-input-card" id="keyInputCard" style="display:none">
|
|
||||||
<div class="key-input-title">未检测到 API Key,请手动粘贴</div>
|
|
||||||
<div class="key-input-wrap">
|
|
||||||
<input type="text" id="manualKeyInput" placeholder="sk-xp-... 或 sk-...">
|
|
||||||
<button class="key-confirm-btn" onclick="confirmManualKey()">确认</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 主表单 -->
|
|
||||||
<div class="section active" id="mainSection">
|
|
||||||
|
|
||||||
<!-- 账户面板 -->
|
|
||||||
<div class="xp-panel">
|
|
||||||
<div class="xp-panel-head">
|
|
||||||
<div class="xp-panel-head-left">
|
|
||||||
<span style="font-size:1.1em">🦐</span>
|
|
||||||
<span class="xp-panel-title">虾盘云 · 账户</span>
|
|
||||||
</div>
|
|
||||||
<button class="xp-refresh-btn" id="refreshBtn" onclick="loadBalance()">刷新</button>
|
|
||||||
</div>
|
|
||||||
<div class="xp-panel-body">
|
|
||||||
<div class="xp-key-box">
|
|
||||||
<div class="xp-key-label">我的专属 KEY</div>
|
|
||||||
<div class="xp-key-inner">
|
|
||||||
<div class="xp-key-val" id="keyDisplay">检测中...</div>
|
|
||||||
<button class="xp-copy-btn" onclick="copyKey()">复制</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="xp-bal-box">
|
|
||||||
<span class="xp-bal-label">当前余额</span>
|
|
||||||
<span class="xp-bal-val dim" id="balanceVal">查询中...</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 充值表单 -->
|
|
||||||
<div class="form-card">
|
|
||||||
<div class="form-card-title">选择充值金额</div>
|
|
||||||
|
|
||||||
<div class="amt-grid">
|
|
||||||
<div class="amt-btn" onclick="selectAmt(20)" data-amt="20">
|
|
||||||
<div class="amt-cny">¥20</div>
|
|
||||||
<div class="amt-quota">+1000万</div>
|
|
||||||
</div>
|
|
||||||
<div class="amt-btn" onclick="selectAmt(50)" data-amt="50">
|
|
||||||
<div class="amt-cny">¥50</div>
|
|
||||||
<div class="amt-quota">+2500万</div>
|
|
||||||
</div>
|
|
||||||
<div class="amt-btn" onclick="selectAmt(100)" data-amt="100">
|
|
||||||
<div class="amt-cny">¥100</div>
|
|
||||||
<div class="amt-quota">+5000万</div>
|
|
||||||
</div>
|
|
||||||
<div class="amt-btn" onclick="selectAmt(0)" data-amt="0">
|
|
||||||
<div class="amt-cny">自定</div>
|
|
||||||
<div class="amt-quota">输入金额</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="custom-wrap" id="customWrap">
|
|
||||||
<div class="custom-inner">
|
|
||||||
<span class="custom-prefix">¥</span>
|
|
||||||
<input type="number" id="customAmt" placeholder="最低 ¥10" min="10" max="9999" oninput="onCustomInput()">
|
|
||||||
</div>
|
|
||||||
<div class="input-hint">¥1 = 50万 quota,¥10 起充</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="divider"></div>
|
|
||||||
|
|
||||||
<label>扫码付款</label>
|
|
||||||
<div class="qr-tabs">
|
|
||||||
<button class="qr-tab active" onclick="switchQr('alipay',this)">支付宝</button>
|
|
||||||
<button class="qr-tab" onclick="switchQr('wechat',this)">微信支付</button>
|
|
||||||
</div>
|
|
||||||
<div class="qr-box active" id="qr-alipay">
|
|
||||||
<div class="qr-placeholder" id="qr-alipay-ph">
|
|
||||||
<div style="font-size:1.8em">📱</div>
|
|
||||||
<div>支付宝收款码</div>
|
|
||||||
<div style="font-size:.68em;margin-top:2px;opacity:.6">(管理员配置后显示)</div>
|
|
||||||
</div>
|
|
||||||
<img class="qr-img" id="qr-alipay-img" src="" alt="支付宝" style="display:none"
|
|
||||||
onerror="this.style.display='none';document.getElementById('qr-alipay-ph').style.display='flex'">
|
|
||||||
<div class="qr-name">支付宝</div>
|
|
||||||
<div class="qr-sub">扫码付款 · 备注填写您的 Key 后8位</div>
|
|
||||||
</div>
|
|
||||||
<div class="qr-box" id="qr-wechat">
|
|
||||||
<div class="qr-placeholder" id="qr-wechat-ph">
|
|
||||||
<div style="font-size:1.8em">💚</div>
|
|
||||||
<div>微信收款码</div>
|
|
||||||
<div style="font-size:.68em;margin-top:2px;opacity:.6">(管理员配置后显示)</div>
|
|
||||||
</div>
|
|
||||||
<img class="qr-img" id="qr-wechat-img" src="" alt="微信" style="display:none"
|
|
||||||
onerror="this.style.display='none';document.getElementById('qr-wechat-ph').style.display='flex'">
|
|
||||||
<div class="qr-name">微信支付</div>
|
|
||||||
<div class="qr-sub">扫码付款 · 备注填写您的 Key 后8位</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="divider"></div>
|
|
||||||
|
|
||||||
<label>上传付款截图 <span style="color:var(--text-dim);font-weight:400">(可选,有图更快审核)</span></label>
|
|
||||||
<div class="upload-area" id="uploadArea" onclick="document.getElementById('screenshotInput').click()">
|
|
||||||
<input type="file" id="screenshotInput" accept="image/*" style="display:none" onchange="onFileSelect(event)">
|
|
||||||
<div id="uploadPh">
|
|
||||||
<div class="upload-icon">📸</div>
|
|
||||||
<div class="upload-text">点击上传付款截图</div>
|
|
||||||
<div class="upload-sub">支持 JPG / PNG,不超过 5MB</div>
|
|
||||||
</div>
|
|
||||||
<img id="uploadPreview" class="upload-preview">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="divider"></div>
|
|
||||||
|
|
||||||
<label style="margin-top:4px">备注 <span style="color:var(--text-dim);font-weight:400">(可选)</span></label>
|
|
||||||
<input type="text" id="noteInput" placeholder="如有问题可备注,管理员会看到">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button class="submit-btn" id="submitBtn" onclick="submitRecharge()">提交充值申请 →</button>
|
|
||||||
|
|
||||||
</div><!-- /mainSection -->
|
|
||||||
|
|
||||||
<!-- 成功页 -->
|
|
||||||
<div class="section" id="successSection">
|
|
||||||
<div class="success-view">
|
|
||||||
<div class="success-icon">✅</div>
|
|
||||||
<div class="success-title">申请已提交!</div>
|
|
||||||
<div class="success-desc">
|
|
||||||
您的充值申请已成功提交。<br>
|
|
||||||
管理员会在微信收到通知,一般 <strong style="color:var(--orange)">10分钟内</strong> 完成审核。
|
|
||||||
</div>
|
|
||||||
<div class="success-tip">
|
|
||||||
<strong style="color:var(--text)">审核完成后:</strong><br>
|
|
||||||
· 余额自动到账,无需任何操作<br>
|
|
||||||
· 点"刷新"即可看到最新余额<br>
|
|
||||||
· 如超过1小时未到账,请联系管理员
|
|
||||||
</div>
|
|
||||||
<button class="back-btn" onclick="goBack()">← 返回</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div><!-- /container -->
|
|
||||||
|
|
||||||
<div class="toast" id="toast"></div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
const API_BASE = 'https://api.u-claw.org';
|
|
||||||
const RECHARGE_API = API_BASE + '/recharge';
|
|
||||||
|
|
||||||
let currentKey = '';
|
|
||||||
let selectedAmt = -1;
|
|
||||||
let screenshotB64 = '';
|
|
||||||
let screenshotMime = 'image/jpeg';
|
|
||||||
|
|
||||||
window.addEventListener('DOMContentLoaded', () => {
|
|
||||||
initKey();
|
|
||||||
loadQrCodes();
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Key 检测 ─────────────────────────────────────────────
|
|
||||||
function initKey() {
|
|
||||||
const params = new URLSearchParams(window.location.search);
|
|
||||||
let key = params.get('key') || params.get('api_key') || '';
|
|
||||||
if (!key) key = localStorage.getItem('xiapan_key') || '';
|
|
||||||
if (key) { setKey(key); return; }
|
|
||||||
fetchKeyFromConfigServer().then(k => {
|
|
||||||
if (k) setKey(k);
|
|
||||||
else showKeyInput();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchKeyFromConfigServer() {
|
|
||||||
const ports = [18799, 18798, 18797, 18796, 18789];
|
|
||||||
for (const port of ports) {
|
|
||||||
try {
|
|
||||||
const r = await fetch(`http://localhost:${port}/config`, {signal: AbortSignal.timeout(800)});
|
|
||||||
if (r.ok) {
|
|
||||||
const cfg = await r.json();
|
|
||||||
const k = cfg?.providers?.xiapanyun?.api_key || '';
|
|
||||||
if (k) return k;
|
|
||||||
}
|
|
||||||
} catch(e) {}
|
|
||||||
}
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
function setKey(key) {
|
|
||||||
currentKey = key;
|
|
||||||
localStorage.setItem('xiapan_key', key);
|
|
||||||
const masked = key.length > 14 ? key.slice(0, 10) + '...' + key.slice(-6) : key;
|
|
||||||
document.getElementById('keyDisplay').textContent = masked;
|
|
||||||
document.getElementById('keyInputCard').style.display = 'none';
|
|
||||||
loadBalance();
|
|
||||||
}
|
|
||||||
|
|
||||||
function showKeyInput() {
|
|
||||||
document.getElementById('keyInputCard').style.display = 'block';
|
|
||||||
document.getElementById('keyDisplay').textContent = '未检测到';
|
|
||||||
document.getElementById('balanceVal').textContent = '—';
|
|
||||||
document.getElementById('balanceVal').className = 'xp-bal-val dim';
|
|
||||||
}
|
|
||||||
|
|
||||||
function confirmManualKey() {
|
|
||||||
const val = document.getElementById('manualKeyInput').value.trim();
|
|
||||||
if (val.length < 8) { showToast('Key 格式不对,请重新确认', 'error'); return; }
|
|
||||||
setKey(val);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 余额查询 ─────────────────────────────────────────────
|
|
||||||
async function loadBalance() {
|
|
||||||
if (!currentKey) return;
|
|
||||||
const btn = document.getElementById('refreshBtn');
|
|
||||||
const valEl = document.getElementById('balanceVal');
|
|
||||||
btn.disabled = true;
|
|
||||||
valEl.textContent = '查询中...';
|
|
||||||
valEl.className = 'xp-bal-val dim';
|
|
||||||
|
|
||||||
try {
|
|
||||||
const d = new Date();
|
|
||||||
const monthStart = d.getFullYear() + '-' + String(d.getMonth()+1).padStart(2,'0') + '-01';
|
|
||||||
const hdrs = {'Authorization': 'Bearer ' + currentKey};
|
|
||||||
const [subRes, usageRes] = await Promise.all([
|
|
||||||
fetch(API_BASE + '/v1/dashboard/billing/subscription', {headers: hdrs}),
|
|
||||||
fetch(API_BASE + '/v1/dashboard/billing/usage?start_date=' + monthStart, {headers: hdrs})
|
|
||||||
]);
|
|
||||||
if (!subRes.ok) throw new Error('Key 无效');
|
|
||||||
const sub = await subRes.json();
|
|
||||||
const usage = usageRes.ok ? await usageRes.json() : {total_usage: 0};
|
|
||||||
|
|
||||||
const total = sub.hard_limit_usd || 0;
|
|
||||||
const used = (usage.total_usage || 0) / 100;
|
|
||||||
const remaining = Math.max(0, total - used);
|
|
||||||
const remCny = remaining * 7.2;
|
|
||||||
|
|
||||||
let cls = '';
|
|
||||||
if (remCny < 5) cls = 'low';
|
|
||||||
else if (remCny < 20) cls = 'warn';
|
|
||||||
|
|
||||||
valEl.textContent = '¥' + remCny.toFixed(2);
|
|
||||||
valEl.className = 'xp-bal-val' + (cls ? ' ' + cls : '');
|
|
||||||
} catch(e) {
|
|
||||||
valEl.textContent = '查询失败';
|
|
||||||
valEl.className = 'xp-bal-val dim';
|
|
||||||
} finally {
|
|
||||||
btn.disabled = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 二维码 ─────────────────────────────────────────────
|
|
||||||
async function loadQrCodes() {
|
|
||||||
try {
|
|
||||||
const r = await fetch(RECHARGE_API + '/config', {signal: AbortSignal.timeout(3000)});
|
|
||||||
if (r.ok) {
|
|
||||||
const cfg = await r.json();
|
|
||||||
if (cfg.qr_alipay) showQrImg('alipay', cfg.qr_alipay);
|
|
||||||
if (cfg.qr_wechat) showQrImg('wechat', cfg.qr_wechat);
|
|
||||||
}
|
|
||||||
} catch(e) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
function showQrImg(type, src) {
|
|
||||||
const img = document.getElementById('qr-' + type + '-img');
|
|
||||||
const ph = document.getElementById('qr-' + type + '-ph');
|
|
||||||
img.src = src;
|
|
||||||
img.style.display = 'block';
|
|
||||||
ph.style.display = 'none';
|
|
||||||
}
|
|
||||||
|
|
||||||
function switchQr(type, btn) {
|
|
||||||
document.querySelectorAll('.qr-tab').forEach(t => t.classList.remove('active'));
|
|
||||||
document.querySelectorAll('.qr-box').forEach(b => b.classList.remove('active'));
|
|
||||||
btn.classList.add('active');
|
|
||||||
document.getElementById('qr-' + type).classList.add('active');
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 金额选择 ─────────────────────────────────────────────
|
|
||||||
function selectAmt(amt) {
|
|
||||||
document.querySelectorAll('.amt-btn').forEach(b => b.classList.remove('sel'));
|
|
||||||
document.querySelector(`.amt-btn[data-amt="${amt}"]`).classList.add('sel');
|
|
||||||
selectedAmt = amt;
|
|
||||||
document.getElementById('customWrap').style.display = amt === 0 ? 'block' : 'none';
|
|
||||||
if (amt !== 0) document.getElementById('customAmt').value = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
function onCustomInput() {
|
|
||||||
selectedAmt = parseFloat(document.getElementById('customAmt').value) || 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getEffectiveAmt() {
|
|
||||||
if (selectedAmt === 0) return parseFloat(document.getElementById('customAmt').value) || 0;
|
|
||||||
return selectedAmt;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 截图上传 ─────────────────────────────────────────────
|
|
||||||
function onFileSelect(e) {
|
|
||||||
const file = e.target.files[0];
|
|
||||||
if (!file) return;
|
|
||||||
if (file.size > 5 * 1024 * 1024) { showToast('图片不超过5MB', 'error'); return; }
|
|
||||||
screenshotMime = file.type || 'image/jpeg';
|
|
||||||
const reader = new FileReader();
|
|
||||||
reader.onload = ev => {
|
|
||||||
const dataUrl = ev.target.result;
|
|
||||||
screenshotB64 = dataUrl.split(',')[1];
|
|
||||||
const preview = document.getElementById('uploadPreview');
|
|
||||||
preview.src = dataUrl;
|
|
||||||
preview.style.display = 'block';
|
|
||||||
document.getElementById('uploadPh').style.display = 'none';
|
|
||||||
document.getElementById('uploadArea').classList.add('has');
|
|
||||||
};
|
|
||||||
reader.readAsDataURL(file);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 提交 ─────────────────────────────────────────────
|
|
||||||
async function submitRecharge() {
|
|
||||||
if (!currentKey) { showToast('请先输入您的 API Key', 'error'); return; }
|
|
||||||
const amount = getEffectiveAmt();
|
|
||||||
if (amount < 10) { showToast('充值金额最低 ¥10', 'error'); return; }
|
|
||||||
|
|
||||||
const btn = document.getElementById('submitBtn');
|
|
||||||
btn.disabled = true;
|
|
||||||
btn.textContent = '提交中...';
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await fetch(RECHARGE_API + '/submit', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {'Content-Type': 'application/json'},
|
|
||||||
body: JSON.stringify({
|
|
||||||
api_key: currentKey,
|
|
||||||
amount_cny: amount,
|
|
||||||
screenshot_b64: screenshotB64,
|
|
||||||
screenshot_mime: screenshotMime,
|
|
||||||
note: document.getElementById('noteInput').value.trim()
|
|
||||||
})
|
|
||||||
});
|
|
||||||
const data = await res.json();
|
|
||||||
if (!res.ok) throw new Error(data.error || '服务器错误');
|
|
||||||
|
|
||||||
document.getElementById('mainSection').classList.remove('active');
|
|
||||||
document.getElementById('successSection').classList.add('active');
|
|
||||||
} catch(e) {
|
|
||||||
showToast('提交失败:' + e.message, 'error');
|
|
||||||
btn.disabled = false;
|
|
||||||
btn.textContent = '提交充值申请 →';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function goBack() {
|
|
||||||
document.getElementById('successSection').classList.remove('active');
|
|
||||||
document.getElementById('mainSection').classList.add('active');
|
|
||||||
screenshotB64 = '';
|
|
||||||
document.getElementById('uploadPreview').style.display = 'none';
|
|
||||||
document.getElementById('uploadPh').style.display = 'block';
|
|
||||||
document.getElementById('uploadArea').classList.remove('has');
|
|
||||||
document.getElementById('screenshotInput').value = '';
|
|
||||||
document.getElementById('noteInput').value = '';
|
|
||||||
document.querySelectorAll('.amt-btn').forEach(b => b.classList.remove('sel'));
|
|
||||||
document.getElementById('customWrap').style.display = 'none';
|
|
||||||
document.getElementById('submitBtn').disabled = false;
|
|
||||||
document.getElementById('submitBtn').textContent = '提交充值申请 →';
|
|
||||||
selectedAmt = -1;
|
|
||||||
loadBalance();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 工具 ─────────────────────────────────────────────
|
|
||||||
function copyKey() {
|
|
||||||
if (!currentKey) return;
|
|
||||||
navigator.clipboard.writeText(currentKey)
|
|
||||||
.then(() => showToast('Key 已复制'))
|
|
||||||
.catch(() => {
|
|
||||||
const el = document.createElement('textarea');
|
|
||||||
el.value = currentKey;
|
|
||||||
document.body.appendChild(el);
|
|
||||||
el.select();
|
|
||||||
document.execCommand('copy');
|
|
||||||
document.body.removeChild(el);
|
|
||||||
showToast('Key 已复制');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
let toastTimer;
|
|
||||||
function showToast(msg, type='') {
|
|
||||||
const t = document.getElementById('toast');
|
|
||||||
t.textContent = msg;
|
|
||||||
t.className = 'toast show' + (type ? ' ' + type : '');
|
|
||||||
clearTimeout(toastTimer);
|
|
||||||
toastTimer = setTimeout(() => t.classList.remove('show'), 3000);
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -7,13 +7,14 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"homepage": "https://u-claw.org",
|
"homepage": "https://u-claw.org",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "electron .",
|
"sync-lib": "node scripts/sync-lib.js",
|
||||||
"dev": "electron . --dev",
|
"start": "npm run sync-lib && electron .",
|
||||||
"build:mac": "electron-builder --mac",
|
"dev": "npm run sync-lib && electron . --dev",
|
||||||
"build:mac-arm64": "electron-builder --mac --arm64",
|
"build:mac": "npm run sync-lib && electron-builder --mac",
|
||||||
"build:mac-x64": "electron-builder --mac --x64",
|
"build:mac-arm64": "npm run sync-lib && electron-builder --mac --arm64",
|
||||||
"build:win": "electron-builder --win",
|
"build:mac-x64": "npm run sync-lib && electron-builder --mac --x64",
|
||||||
"build:all": "electron-builder --mac --win",
|
"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"
|
"postinstall": "electron-builder install-app-deps"
|
||||||
},
|
},
|
||||||
"build": {
|
"build": {
|
||||||
|
|||||||
@@ -48,16 +48,51 @@ h1 .lobster { color: #ff6b35; }
|
|||||||
.buy-link:hover { text-decoration: underline; }
|
.buy-link:hover { text-decoration: underline; }
|
||||||
|
|
||||||
/* Forms */
|
/* Forms */
|
||||||
.form-group { margin-bottom: 16px; }
|
.form-group { display: block; margin-bottom: 16px; clear: both; }
|
||||||
label { display: block; color: #ccc; margin-bottom: 6px; font-size: 0.9em; }
|
.form-group label {
|
||||||
input[type="text"], input[type="password"] {
|
display: block;
|
||||||
width: 100%; padding: 11px 14px; background: #222; border: 1px solid #444; border-radius: 8px;
|
width: auto;
|
||||||
color: #fff; font-size: 0.95em; outline: none; transition: border-color 0.2s;
|
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:focus { border-color: #ff6b35; }
|
||||||
.input-wrap { position: relative; }
|
.input-wrap { position: relative; display: block; width: 100%; min-width: 0; }
|
||||||
.input-wrap input { padding-right: 44px; }
|
.input-wrap input { padding-right: 64px; }
|
||||||
.toggle-pw { position: absolute; right: 12px; top: 50%; transform: translateY(-50%); background: none; border: none; color: #888; cursor: pointer; font-size: 0.85em; }
|
.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; }
|
.toggle-pw:hover { color: #ccc; }
|
||||||
.hint { color: #888; font-size: 0.8em; margin-top: 6px; line-height: 1.5; }
|
.hint { color: #888; font-size: 0.8em; margin-top: 6px; line-height: 1.5; }
|
||||||
.hint a { color: #ff6b35; text-decoration: none; }
|
.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>
|
<h1><span class="lobster">🦞</span> U-Claw Pro</h1>
|
||||||
<p class="subtitle">便携版 — 选择模型,填入 API Key,一键启动</p>
|
<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 -->
|
<!-- Steps -->
|
||||||
<div class="steps">
|
<div class="steps">
|
||||||
<div class="step active" data-step="1"><span class="num">1</span>选模型</div>
|
<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>
|
<div class="toast" id="toast"></div>
|
||||||
|
|
||||||
<script>
|
<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 ---
|
// --- State ---
|
||||||
let selectedProvider = null;
|
let selectedProvider = null;
|
||||||
let selectedBase = '';
|
let selectedBase = '';
|
||||||
let selectedModel = '';
|
let selectedModel = '';
|
||||||
|
|
||||||
// --- Model cards ---
|
// --- Model cards ---
|
||||||
document.querySelectorAll('.model-card').forEach(card => {
|
document.querySelectorAll('#step1 .model-card').forEach(card => {
|
||||||
card.addEventListener('click', (e) => {
|
card.addEventListener('click', (e) => {
|
||||||
if (e.target.closest('.buy-link')) return; // don't select when clicking buy link
|
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');
|
card.classList.add('selected');
|
||||||
selectedProvider = card.dataset.provider;
|
selectedProvider = card.dataset.provider;
|
||||||
selectedBase = card.dataset.base;
|
selectedBase = card.dataset.base;
|
||||||
@@ -375,7 +474,7 @@ document.getElementById('nextStep1').addEventListener('click', () => goStep(2));
|
|||||||
|
|
||||||
// --- Steps ---
|
// --- Steps ---
|
||||||
function goStep(n) {
|
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');
|
document.getElementById('step' + n).classList.add('active');
|
||||||
// Keep skills section visible
|
// Keep skills section visible
|
||||||
document.querySelectorAll('.steps .step').forEach((s, i) => {
|
document.querySelectorAll('.steps .step').forEach((s, i) => {
|
||||||
@@ -393,7 +492,7 @@ function setupStep2() {
|
|||||||
|
|
||||||
const providerNames = {
|
const providerNames = {
|
||||||
minimax: 'MiniMax', kimi: 'Kimi (月之暗面)', deepseek: 'DeepSeek',
|
minimax: 'MiniMax', kimi: 'Kimi (月之暗面)', deepseek: 'DeepSeek',
|
||||||
zhipu: '智谱 GLM', qwen: '通义千问', doubao: '豆包',
|
zai: '智谱 GLM', qwen: '通义千问', doubao: '豆包',
|
||||||
openai: 'OpenAI', anthropic: 'Anthropic Claude', groq: 'Groq',
|
openai: 'OpenAI', anthropic: 'Anthropic Claude', groq: 'Groq',
|
||||||
siliconflow: '硅基流动', custom: '自定义'
|
siliconflow: '硅基流动', custom: '自定义'
|
||||||
};
|
};
|
||||||
@@ -401,7 +500,7 @@ function setupStep2() {
|
|||||||
document.getElementById('step2desc').textContent = `请填写 ${name} 的 API Key`;
|
document.getElementById('step2desc').textContent = `请填写 ${name} 的 API Key`;
|
||||||
|
|
||||||
// Buy links
|
// 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 buyLink = card ? card.querySelector('.buy-link') : null;
|
||||||
const linksDiv = document.getElementById('providerLinks');
|
const linksDiv = document.getElementById('providerLinks');
|
||||||
linksDiv.innerHTML = '';
|
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() {
|
function getConfig() {
|
||||||
try {
|
try {
|
||||||
return JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
return JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
||||||
@@ -445,6 +459,7 @@ app.whenReady().then(async () => {
|
|||||||
|
|
||||||
// Setup
|
// Setup
|
||||||
ensureConfig();
|
ensureConfig();
|
||||||
|
await bindXiapanCloud();
|
||||||
createMenu();
|
createMenu();
|
||||||
setupIPC();
|
setupIPC();
|
||||||
createWindow();
|
createWindow();
|
||||||
|
|||||||
Reference in New Issue
Block a user