forked from boos/BoosAPI
feat: implement macOS notarization and ad-hoc signing in afterSign script
feat: add run-server script to manage server execution and compatibility checks feat: create main application logic for desktop with window management, server handling, and auto-updater test: add unit tests for desktop runtime helpers feat: implement desktop server environment configuration and health check test: add tests for server configuration and desktop routes feat: add native module guard for better-sqlite3 compatibility test: add tests for native module guard functionality test: add comprehensive tests for account verification feedback handling feat: implement account verification feedback helpers chore: add TypeScript configuration for desktop builds
This commit is contained in:
@@ -39,7 +39,7 @@ jobs:
|
||||
run: npm run build
|
||||
|
||||
build-packages:
|
||||
name: Build Package (${{ matrix.os }})
|
||||
name: Build Desktop Package (${{ matrix.os }})
|
||||
needs: verify
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 30
|
||||
@@ -61,70 +61,31 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
- name: Build desktop artifacts
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
CSC_LINK: ${{ secrets.CSC_LINK }}
|
||||
CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
|
||||
CSC_IDENTITY_AUTO_DISCOVERY: true
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
run: npm run dist:desktop
|
||||
|
||||
- name: Prune dev dependencies
|
||||
run: npm prune --omit=dev
|
||||
|
||||
- name: Assemble archive (Unix)
|
||||
if: runner.os != 'Windows'
|
||||
shell: bash
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
ASSET_OS="$(echo "${RUNNER_OS}" | tr '[:upper:]' '[:lower:]')"
|
||||
ASSET_ARCH="$(echo "${RUNNER_ARCH}" | tr '[:upper:]' '[:lower:]')"
|
||||
ASSET_NAME="metapi-${GITHUB_REF_NAME}-${ASSET_OS}-${ASSET_ARCH}.tar.gz"
|
||||
|
||||
PACKAGE_DIR="release/metapi"
|
||||
mkdir -p "$PACKAGE_DIR"
|
||||
|
||||
cp -R dist node_modules drizzle "$PACKAGE_DIR"/
|
||||
cp package.json "$PACKAGE_DIR"/
|
||||
[ -f .env.example ] && cp .env.example "$PACKAGE_DIR"/
|
||||
[ -f LICENSE ] && cp LICENSE "$PACKAGE_DIR"/
|
||||
cp scripts/release/start.sh "$PACKAGE_DIR"/start.sh
|
||||
chmod +x "$PACKAGE_DIR"/start.sh
|
||||
|
||||
tar -czf "release/${ASSET_NAME}" -C release metapi
|
||||
echo "ASSET_NAME=${ASSET_NAME}" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Assemble archive (Windows)
|
||||
if: runner.os == 'Windows'
|
||||
shell: pwsh
|
||||
run: |
|
||||
$assetOs = $env:RUNNER_OS.ToLower()
|
||||
$assetArch = $env:RUNNER_ARCH.ToLower()
|
||||
$assetName = "metapi-$env:GITHUB_REF_NAME-$assetOs-$assetArch.zip"
|
||||
|
||||
$packageDir = "release\\metapi"
|
||||
New-Item -ItemType Directory -Path $packageDir -Force | Out-Null
|
||||
|
||||
Copy-Item -Recurse -Force dist $packageDir
|
||||
Copy-Item -Recurse -Force node_modules $packageDir
|
||||
Copy-Item -Recurse -Force drizzle $packageDir
|
||||
Copy-Item -Force package.json $packageDir
|
||||
if (Test-Path ".env.example") { Copy-Item -Force .env.example $packageDir }
|
||||
if (Test-Path "LICENSE") { Copy-Item -Force LICENSE $packageDir }
|
||||
Copy-Item -Force scripts\\release\\start.bat "$packageDir\\start.bat"
|
||||
|
||||
Compress-Archive -Path $packageDir -DestinationPath "release\\$assetName" -Force
|
||||
"ASSET_NAME=$assetName" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
|
||||
|
||||
- name: Upload package (Unix)
|
||||
if: runner.os != 'Windows'
|
||||
- name: Upload desktop package
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: metapi-${{ github.ref_name }}-${{ runner.os }}-${{ runner.arch }}
|
||||
path: release/*.tar.gz
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Upload package (Windows)
|
||||
if: runner.os == 'Windows'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: metapi-${{ github.ref_name }}-${{ runner.os }}-${{ runner.arch }}
|
||||
path: release/*.zip
|
||||
name: metapi-desktop-${{ github.ref_name }}-${{ runner.os }}-${{ runner.arch }}
|
||||
path: |
|
||||
release/*.exe
|
||||
release/*.dmg
|
||||
release/*.zip
|
||||
release/*.AppImage
|
||||
release/*.deb
|
||||
release/*.blockmap
|
||||
release/latest*.yml
|
||||
release/latest*.json
|
||||
release/builder-debug.yml
|
||||
if-no-files-found: error
|
||||
|
||||
publish-release:
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
node_modules/
|
||||
dist/
|
||||
release/
|
||||
data/*
|
||||
!data/.gitkeep
|
||||
.env
|
||||
*.log
|
||||
*.tar.gz
|
||||
logs/
|
||||
tmp/*
|
||||
!tmp/.gitkeep
|
||||
|
||||
@@ -247,7 +247,7 @@ docker run -d --name metapi -p 4000:4000 \
|
||||
> [!IMPORTANT]
|
||||
> 请务必修改 `AUTH_TOKEN` 和 `PROXY_TOKEN`,不要使用默认值。
|
||||
|
||||
📖 **[完整部署文档](https://metapi.cita777.me/deployment)** — Zeabur 一键部署 / Docker Compose / Release 包 / 反向代理 / 升级回滚
|
||||
📖 **[完整部署文档](https://metapi.cita777.me/deployment)** — Zeabur 一键部署 / Docker Compose / 桌面版安装包 / 反向代理 / 升级回滚
|
||||
|
||||
📖 **[环境变量与配置](https://metapi.cita777.me/configuration)** — 全部环境变量、路由参数、通知渠道
|
||||
|
||||
|
||||
+11
-32
@@ -330,44 +330,23 @@ After starting, visit `http://localhost:4000` and log in with your `AUTH_TOKEN`!
|
||||
> If running outside Compose without explicitly setting `AUTH_TOKEN`, the default is `change-me-admin-token` (for local debugging only).
|
||||
> If you change the admin token in the Settings panel, use the new token for subsequent logins.
|
||||
|
||||
### Release Package (Linux / macOS / Windows)
|
||||
### Desktop App (Windows / macOS / Linux)
|
||||
|
||||
If you prefer not to use Docker, download a pre-built Release package:
|
||||
For personal-computer deployments, download the desktop installer from [Releases](https://github.com/cita-777/metapi/releases):
|
||||
|
||||
1. Go to [Releases](https://github.com/cita-777/metapi/releases) and download the archive for your platform
|
||||
2. Extract and enter the directory
|
||||
3. Set environment variables and start
|
||||
1. Download the installer for your platform
|
||||
2. Install and launch Metapi Desktop
|
||||
3. The desktop shell starts the local service automatically and stores data under the app data directory
|
||||
|
||||
Linux / macOS:
|
||||
Desktop builds include:
|
||||
|
||||
```bash
|
||||
export AUTH_TOKEN=your-admin-token
|
||||
export PROXY_TOKEN=your-proxy-sk-token
|
||||
export PORT=4000
|
||||
export DATA_DIR=./data
|
||||
./start.sh
|
||||
```
|
||||
|
||||
Windows (PowerShell):
|
||||
|
||||
```powershell
|
||||
$env:AUTH_TOKEN="your-admin-token"
|
||||
$env:PROXY_TOKEN="your-proxy-sk-token"
|
||||
$env:PORT="4000"
|
||||
$env:DATA_DIR="./data"
|
||||
.\start.bat
|
||||
```
|
||||
|
||||
`start.sh` / `start.bat` will:
|
||||
|
||||
- Check whether `better-sqlite3` matches the current Node.js ABI
|
||||
- If ABI mismatch is detected, automatically run `npm rebuild better-sqlite3`
|
||||
- If rebuild fails, fall back to `npm ci --omit=dev` to reinstall runtime dependencies
|
||||
- Then run database migrations and start the service
|
||||
- Embedded local Metapi service, started automatically by Electron
|
||||
- Tray menu for reopen / restart backend / launch at login
|
||||
- In-app update checks backed by GitHub Releases
|
||||
|
||||
> [!NOTE]
|
||||
> The Release package requires Node.js installed locally (supports 20+, recommends 22 LTS).
|
||||
> If your local Node major version differs from the one used during packaging (for example, package built with Node 22 while local is Node 24), first startup may trigger automatic rebuild and requires network access.
|
||||
> Server deployments are now Docker-first.
|
||||
> GitHub Release assets are intended for the desktop app rather than raw Node.js server archives.
|
||||
|
||||
### Upgrade
|
||||
|
||||
|
||||
+13
-40
@@ -83,54 +83,27 @@ docker run -d --name metapi \
|
||||
> - `./data:/app/data` — 相对路径,数据存到当前目录下的 `data` 文件夹
|
||||
> - 也可以使用绝对路径:`/your/custom/path:/app/data`
|
||||
|
||||
## Release 包部署(免 Docker)
|
||||
## 桌面版部署(Windows / macOS / Linux)
|
||||
|
||||
适用于没有 Docker 环境的服务器,或 Windows / macOS 桌面使用场景。
|
||||
个人电脑本地部署请直接使用 [Releases](https://github.com/cita-777/metapi/releases) 中的 Electron 安装包:
|
||||
|
||||
### 前置条件
|
||||
1. 下载与你系统匹配的桌面安装包
|
||||
2. 安装并启动 Metapi Desktop
|
||||
3. 桌面壳会自动启动本地服务并将数据保存到应用数据目录
|
||||
|
||||
- Node.js 20+(推荐 22 LTS)
|
||||
桌面版特性:
|
||||
|
||||
### 步骤
|
||||
|
||||
1. 从 [Releases](https://github.com/cita-777/metapi/releases) 下载与你系统匹配的压缩包(Linux / Windows / macOS)
|
||||
2. 解压后进入目录
|
||||
|
||||
Linux / macOS:
|
||||
|
||||
```bash
|
||||
export AUTH_TOKEN=your-admin-token
|
||||
export PROXY_TOKEN=your-proxy-sk-token
|
||||
export PORT=4000
|
||||
export DATA_DIR=./data
|
||||
./start.sh
|
||||
```
|
||||
|
||||
Windows(PowerShell):
|
||||
|
||||
```powershell
|
||||
$env:AUTH_TOKEN="your-admin-token"
|
||||
$env:PROXY_TOKEN="your-proxy-sk-token"
|
||||
$env:PORT="4000"
|
||||
$env:DATA_DIR="./data"
|
||||
.\start.bat
|
||||
```
|
||||
|
||||
`start.sh` / `start.bat` 的作用是:
|
||||
|
||||
- 先检查 `better-sqlite3` 与当前 Node.js ABI 是否兼容
|
||||
- 若检测到 ABI 不匹配,会自动尝试 `npm rebuild better-sqlite3`
|
||||
- 若重建失败,会回退到 `npm ci --omit=dev` 重新安装运行时依赖
|
||||
- 最后执行数据库迁移并启动服务
|
||||
- 内置本地 Metapi 服务,无需手动准备 Node.js 运行环境
|
||||
- 托盘菜单支持重新打开窗口、重启后端、开机自启
|
||||
- 支持基于 GitHub Releases 的应用内更新检查
|
||||
|
||||
> [!NOTE]
|
||||
> 如果本机 Node 主版本与打包时不同(例如包内依赖基于 Node 22,而本机是 Node 24),首次启动可能触发自动重建,需联网。
|
||||
> 服务器部署不再提供裸 Node.js Release 压缩包,统一推荐 Docker / Docker Compose。
|
||||
|
||||
### Release 包升级
|
||||
### 桌面版升级
|
||||
|
||||
1. 下载新版本的 Release 包
|
||||
2. 解压覆盖旧文件(`data/` 目录不受影响)
|
||||
3. 重新启动即可
|
||||
1. 通过应用内更新提示安装新版本,或从 Releases 下载最新安装包覆盖安装
|
||||
2. 用户数据目录会保留,升级后自动继续使用原有数据
|
||||
|
||||
---
|
||||
|
||||
|
||||
+5
-5
@@ -80,14 +80,14 @@ docker compose up -d
|
||||
|
||||
或使用 `.env` 文件。
|
||||
|
||||
### Q: Release 包启动失败
|
||||
### Q: 桌面版启动失败
|
||||
|
||||
**A:** 排查步骤:
|
||||
|
||||
1. 确认 Node.js 已安装且版本 ≥ 20:`node --version`
|
||||
2. 首次启动时如果报 `better-sqlite3` 相关错误,启动脚本会尝试自动重建,需要网络连接
|
||||
3. 如果本机 Node 主版本与打包时不同(例如包内依赖基于 Node 22,而本机是 Node 24),首次启动需联网执行 `npm rebuild`
|
||||
4. Windows 用户确认使用 PowerShell 而非 CMD 运行 `start.bat`
|
||||
1. 确认下载的是与你系统匹配的桌面安装包
|
||||
2. Windows 首次运行若出现“未知发布者”,先确认安装包来自官方 Releases
|
||||
3. 如果桌面版提示后端启动失败,优先查看托盘菜单中的日志目录
|
||||
4. 如需部署到服务器,请改用 Docker / Docker Compose,而不是桌面安装包
|
||||
|
||||
---
|
||||
|
||||
|
||||
+6
-24
@@ -60,34 +60,16 @@ docker compose up -d
|
||||
> 如果未显式设置(非 Compose 场景),默认值为 `change-me-admin-token`(仅建议本地调试)。
|
||||
> 若你在后台「设置」里修改过管理员令牌,后续登录请使用新令牌。
|
||||
|
||||
## 方式二:Release 包启动(Linux / macOS / Windows)
|
||||
## 方式二:桌面版启动(Windows / macOS / Linux)
|
||||
|
||||
如果不想用 Docker,可以直接下载预打包的 Release 产物运行:
|
||||
如果你是在个人电脑上本地使用,请直接下载桌面版安装包:
|
||||
|
||||
1. 打开 [Releases](https://github.com/cita-777/metapi/releases) 下载与你系统匹配的压缩包
|
||||
2. 解压后进入目录
|
||||
3. 设置环境变量并启动
|
||||
|
||||
Linux / macOS:
|
||||
|
||||
```bash
|
||||
export AUTH_TOKEN=your-admin-token
|
||||
export PROXY_TOKEN=your-proxy-sk-token
|
||||
./start.sh
|
||||
```
|
||||
|
||||
Windows(PowerShell):
|
||||
|
||||
```powershell
|
||||
$env:AUTH_TOKEN="your-admin-token"
|
||||
$env:PROXY_TOKEN="your-proxy-sk-token"
|
||||
.\start.bat
|
||||
```
|
||||
|
||||
`start.sh` / `start.bat` 会自动检查 `better-sqlite3` ABI 兼容性、执行数据库迁移并启动服务。
|
||||
1. 打开 [Releases](https://github.com/cita-777/metapi/releases) 下载与你系统匹配的桌面安装包
|
||||
2. 安装并启动 Metapi Desktop
|
||||
3. 桌面壳会自动启动本地服务并保存数据,无需手动准备 Node.js 环境
|
||||
|
||||
> [!NOTE]
|
||||
> Release 包依赖本机安装 Node.js(支持 20+,推荐 22 LTS)。
|
||||
> 服务器部署统一推荐 Docker / Docker Compose,不再提供裸 Node.js 的 Release 压缩包。
|
||||
|
||||
## 方式三:本地开发启动
|
||||
|
||||
|
||||
@@ -23,10 +23,10 @@ metapi/
|
||||
├── scripts/ # 项目脚本(按场景分组)
|
||||
│ ├── dev/
|
||||
│ │ └── restart.bat # Windows 开发环境快捷重启
|
||||
│ └── release/
|
||||
│ ├── start.sh # Release 包启动脚本(Linux/macOS)
|
||||
│ └── start.bat # Release 包启动脚本(Windows)
|
||||
│ └── desktop/
|
||||
│ └── afterSign.mjs # Electron macOS 签名后处理 / 可选公证
|
||||
├── src/
|
||||
│ ├── desktop/ # Electron 主进程与桌面运行时
|
||||
│ ├── server/ # Fastify 后端服务
|
||||
│ └── web/ # React 前端应用
|
||||
├── .github/workflows/ # CI/CD 工作流
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
appId: me.cita777.metapi.desktop
|
||||
productName: Metapi
|
||||
artifactName: ${name}-${version}-${os}-${arch}.${ext}
|
||||
directories:
|
||||
output: release
|
||||
files:
|
||||
- dist/**/*
|
||||
- drizzle/**/*
|
||||
- package.json
|
||||
asarUnpack:
|
||||
- node_modules/better-sqlite3/**/*
|
||||
mac:
|
||||
target:
|
||||
- dmg
|
||||
- zip
|
||||
category: public.app-category.developer-tools
|
||||
hardenedRuntime: false
|
||||
gatekeeperAssess: false
|
||||
entitlementsInherit: null
|
||||
win:
|
||||
target:
|
||||
- nsis
|
||||
- zip
|
||||
linux:
|
||||
target:
|
||||
- AppImage
|
||||
- deb
|
||||
publish:
|
||||
provider: github
|
||||
owner: cita-777
|
||||
repo: metapi
|
||||
releaseType: release
|
||||
publishAutoUpdate: true
|
||||
nsis:
|
||||
oneClick: false
|
||||
perMachine: false
|
||||
allowToChangeInstallationDirectory: true
|
||||
afterSign: scripts/desktop/afterSign.mjs
|
||||
Generated
+4641
-84
File diff suppressed because it is too large
Load Diff
+17
-3
@@ -2,6 +2,7 @@
|
||||
"name": "metapi",
|
||||
"version": "1.2.0",
|
||||
"description": "Meta-layer management and unified proxy for AI API aggregation platforms",
|
||||
"main": "dist/desktop/main.js",
|
||||
"keywords": [
|
||||
"ai",
|
||||
"api-gateway",
|
||||
@@ -22,14 +23,19 @@
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "concurrently \"tsx watch src/server/index.ts\" \"vite\"",
|
||||
"dev:server": "tsx scripts/dev/run-server.ts --watch",
|
||||
"dev": "concurrently \"npm run dev:server\" \"vite\"",
|
||||
"dev:desktop": "concurrently \"npm run dev:server\" \"vite\" \"tsc -p tsconfig.desktop.json --watch --preserveWatchOutput\" \"wait-on tcp:127.0.0.1:4000 http://127.0.0.1:5173 file:dist/desktop/main.js && cross-env METAPI_DESKTOP_DEV_SERVER_URL=http://127.0.0.1:5173 METAPI_DESKTOP_EXTERNAL_SERVER_URL=http://127.0.0.1:4000 electron dist/desktop/main.js\"",
|
||||
"build:web": "vite build",
|
||||
"build:server": "tsc -p tsconfig.server.json",
|
||||
"build": "npm run build:web && npm run build:server",
|
||||
"build:desktop": "tsc -p tsconfig.desktop.json",
|
||||
"build": "npm run build:web && npm run build:server && npm run build:desktop",
|
||||
"dist:desktop": "npm run build && electron-builder --config electron-builder.yml --publish never",
|
||||
"docs:dev": "vitepress dev docs --host 0.0.0.0 --port 4173",
|
||||
"docs:build": "vitepress build docs",
|
||||
"docs:preview": "vitepress preview docs --host 0.0.0.0 --port 4173",
|
||||
"start": "node dist/server/index.js",
|
||||
"start:desktop": "electron dist/desktop/main.js",
|
||||
"db:generate": "drizzle-kit generate",
|
||||
"db:migrate": "tsx src/server/db/migrate.ts",
|
||||
"smoke:db": "tsx scripts/dev/db-smoke.ts",
|
||||
@@ -49,7 +55,10 @@
|
||||
"better-sqlite3": "^11.3.0",
|
||||
"dotenv": "^16.4.5",
|
||||
"drizzle-orm": "^0.36.4",
|
||||
"electron-log": "^5.2.4",
|
||||
"electron-updater": "^6.6.2",
|
||||
"fastify": "^5.7.4",
|
||||
"get-port": "^7.1.0",
|
||||
"minimatch": "^10.2.4",
|
||||
"minimist": "^1.2.8",
|
||||
"mysql2": "^3.15.3",
|
||||
@@ -68,8 +77,12 @@
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"@electron/notarize": "^3.1.0",
|
||||
"concurrently": "^9.1.0",
|
||||
"cross-env": "^7.0.3",
|
||||
"drizzle-kit": "^0.28.1",
|
||||
"electron": "^35.0.1",
|
||||
"electron-builder": "^26.0.12",
|
||||
"mermaid": "^11.12.3",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
@@ -82,7 +95,8 @@
|
||||
"vite": "^6.0.3",
|
||||
"vitepress": "^1.6.4",
|
||||
"vitepress-plugin-mermaid": "^2.0.17",
|
||||
"vitest": "^2.1.8"
|
||||
"vitest": "^2.1.8",
|
||||
"wait-on": "^8.0.3"
|
||||
},
|
||||
"overrides": {
|
||||
"minimist": "^1.2.8"
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { notarize } from '@electron/notarize';
|
||||
import { execFile } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
async function hasSignature(appPath) {
|
||||
try {
|
||||
await execFileAsync('codesign', ['--verify', '--deep', '--strict', appPath]);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function adHocSign(appPath) {
|
||||
await execFileAsync('codesign', ['--force', '--deep', '--sign', '-', appPath]);
|
||||
}
|
||||
|
||||
export default async function afterSign(context) {
|
||||
if (context.electronPlatformName !== 'darwin') return;
|
||||
|
||||
const appPath = `${context.appOutDir}/${context.packager.appInfo.productFilename}.app`;
|
||||
const appleId = process.env.APPLE_ID || process.env.APPLEID;
|
||||
const appleIdPassword = process.env.APPLE_APP_SPECIFIC_PASSWORD || process.env.APPLEIDPASS;
|
||||
const teamId = process.env.APPLE_TEAM_ID;
|
||||
|
||||
const signed = await hasSignature(appPath);
|
||||
if (!signed) {
|
||||
console.log('[metapi-desktop] No macOS signing identity detected, applying ad-hoc signature.');
|
||||
await adHocSign(appPath);
|
||||
}
|
||||
|
||||
if (!appleId || !appleIdPassword || !teamId) {
|
||||
console.log('[metapi-desktop] Skipping notarization: APPLE_ID / APPLE_APP_SPECIFIC_PASSWORD / APPLE_TEAM_ID not fully configured.');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[metapi-desktop] Notarizing macOS build...');
|
||||
await notarize({
|
||||
appPath,
|
||||
appleId,
|
||||
appleIdPassword,
|
||||
teamId,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
import { join } from 'node:path';
|
||||
import {
|
||||
buildBetterSqliteRebuildCommand,
|
||||
isNodeModuleVersionMismatch,
|
||||
} from '../../src/server/nativeModuleGuard.js';
|
||||
|
||||
function run(command: string, args: string[]) {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const child = spawn(command, args, {
|
||||
cwd: process.cwd(),
|
||||
stdio: 'inherit',
|
||||
shell: false,
|
||||
env: process.env,
|
||||
});
|
||||
|
||||
child.once('exit', (code, signal) => {
|
||||
if (code === 0) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
reject(new Error(`Command failed: ${command} ${args.join(' ')} (code=${code ?? 'null'}, signal=${signal ?? 'null'})`));
|
||||
});
|
||||
|
||||
child.once('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
async function ensureBetterSqliteCompatible() {
|
||||
try {
|
||||
await import('better-sqlite3');
|
||||
return;
|
||||
} catch (error) {
|
||||
if (!isNodeModuleVersionMismatch(error)) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
const rebuild = buildBetterSqliteRebuildCommand();
|
||||
console.log('[metapi] Detected better-sqlite3 ABI mismatch. Rebuilding for the current Node.js runtime...');
|
||||
await run(rebuild.command, rebuild.args);
|
||||
|
||||
try {
|
||||
await import('better-sqlite3');
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`better-sqlite3 is still incompatible after rebuild: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const isWatchMode = process.argv.includes('--watch');
|
||||
await ensureBetterSqliteCompatible();
|
||||
|
||||
if (isWatchMode) {
|
||||
const tsxCli = join(process.cwd(), 'node_modules', 'tsx', 'dist', 'cli.mjs');
|
||||
const child = spawn(process.execPath, [tsxCli, 'watch', 'src/server/index.ts'], {
|
||||
cwd: process.cwd(),
|
||||
stdio: 'inherit',
|
||||
env: process.env,
|
||||
});
|
||||
child.once('exit', (code) => {
|
||||
process.exit(code ?? 0);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const child = spawn(process.execPath, ['dist/server/index.js'], {
|
||||
cwd: process.cwd(),
|
||||
stdio: 'inherit',
|
||||
env: process.env,
|
||||
});
|
||||
child.once('exit', (code) => {
|
||||
process.exit(code ?? 0);
|
||||
});
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,31 +0,0 @@
|
||||
@echo off
|
||||
setlocal
|
||||
|
||||
rem Check whether better-sqlite3 matches the current Node.js ABI.
|
||||
node -e "try{require('better-sqlite3');process.exit(0);}catch(err){if(err&&err.code==='ERR_DLOPEN_FAILED'&&String(err.message||'').includes('NODE_MODULE_VERSION')){process.exit(42);}console.error(err);process.exit(1);}"
|
||||
set "NATIVE_CHECK_CODE=%errorlevel%"
|
||||
|
||||
if %NATIVE_CHECK_CODE% EQU 42 (
|
||||
echo [metapi] Detected Node.js/native module ABI mismatch. Rebuilding better-sqlite3...
|
||||
call npm rebuild better-sqlite3 --no-audit --fund=false
|
||||
if errorlevel 1 (
|
||||
echo [metapi] npm rebuild failed. Running npm ci --omit=dev as fallback...
|
||||
call npm ci --omit=dev --no-audit --fund=false
|
||||
if errorlevel 1 exit /b %errorlevel%
|
||||
)
|
||||
|
||||
node -e "try{require('better-sqlite3');process.exit(0);}catch(err){console.error(err);process.exit(1);}"
|
||||
if errorlevel 1 (
|
||||
echo [metapi] better-sqlite3 is still incompatible with current Node.js.
|
||||
echo [metapi] Recommended: use Node.js 22 LTS or run Docker image 1467078763/metapi:latest.
|
||||
exit /b %errorlevel%
|
||||
)
|
||||
) else if NOT %NATIVE_CHECK_CODE% EQU 0 (
|
||||
exit /b %NATIVE_CHECK_CODE%
|
||||
)
|
||||
|
||||
node dist/server/db/migrate.js
|
||||
if errorlevel 1 exit /b %errorlevel%
|
||||
|
||||
node dist/server/index.js
|
||||
exit /b %errorlevel%
|
||||
@@ -1,23 +0,0 @@
|
||||
#!/usr/bin/env sh
|
||||
set -eu
|
||||
|
||||
# Check whether better-sqlite3 matches the current Node.js ABI.
|
||||
set +e
|
||||
node -e "try{require('better-sqlite3');process.exit(0);}catch(err){if(err&&err.code==='ERR_DLOPEN_FAILED'&&String(err.message||'').includes('NODE_MODULE_VERSION')){process.exit(42);}console.error(err);process.exit(1);}"
|
||||
native_check_code=$?
|
||||
set -e
|
||||
|
||||
if [ "$native_check_code" -eq 42 ]; then
|
||||
echo "[metapi] Detected Node.js/native module ABI mismatch. Rebuilding better-sqlite3..."
|
||||
if ! npm rebuild better-sqlite3 --no-audit --fund=false; then
|
||||
echo "[metapi] npm rebuild failed. Running npm ci --omit=dev as fallback..."
|
||||
npm ci --omit=dev --no-audit --fund=false
|
||||
fi
|
||||
|
||||
node -e "try{require('better-sqlite3');process.exit(0);}catch(err){console.error(err);process.exit(1);}"
|
||||
elif [ "$native_check_code" -ne 0 ]; then
|
||||
exit "$native_check_code"
|
||||
fi
|
||||
|
||||
node dist/server/db/migrate.js
|
||||
exec node dist/server/index.js
|
||||
@@ -0,0 +1,451 @@
|
||||
import {
|
||||
app,
|
||||
BrowserWindow,
|
||||
Menu,
|
||||
Tray,
|
||||
dialog,
|
||||
nativeImage,
|
||||
shell,
|
||||
} from 'electron';
|
||||
import log from 'electron-log';
|
||||
import { autoUpdater } from 'electron-updater';
|
||||
import getPort, { portNumbers } from 'get-port';
|
||||
import { spawn, type ChildProcess } from 'node:child_process';
|
||||
import { existsSync, mkdirSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import {
|
||||
buildDesktopServerEnv,
|
||||
createDesktopHealthUrl,
|
||||
createDesktopServerUrl,
|
||||
isFatalServerExit,
|
||||
waitForServerReady,
|
||||
} from './runtime.js';
|
||||
|
||||
let mainWindow: BrowserWindow | null = null;
|
||||
let tray: Tray | null = null;
|
||||
let serverProcess: ChildProcess | null = null;
|
||||
let serverUrl = '';
|
||||
let isQuitting = false;
|
||||
let isRestartingBackend = false;
|
||||
|
||||
log.initialize();
|
||||
|
||||
function getUserDataDir() {
|
||||
return join(app.getPath('userData'), 'data');
|
||||
}
|
||||
|
||||
function getLogsDir() {
|
||||
return join(app.getPath('userData'), 'logs');
|
||||
}
|
||||
|
||||
function ensureDesktopDirs() {
|
||||
mkdirSync(getUserDataDir(), { recursive: true });
|
||||
mkdirSync(getLogsDir(), { recursive: true });
|
||||
}
|
||||
|
||||
function getServerEntryPath() {
|
||||
return join(app.getAppPath(), 'dist', 'server', 'index.js');
|
||||
}
|
||||
|
||||
function getTrayIconPath() {
|
||||
return join(app.getAppPath(), 'dist', 'web', 'favicon.png');
|
||||
}
|
||||
|
||||
function getWindowIconPath() {
|
||||
return getTrayIconPath();
|
||||
}
|
||||
|
||||
function resolveFrontendUrl() {
|
||||
const devUrl = (process.env.METAPI_DESKTOP_DEV_SERVER_URL || '').trim();
|
||||
if (devUrl) return devUrl;
|
||||
return serverUrl;
|
||||
}
|
||||
|
||||
function resolveExternalServerUrl() {
|
||||
return (process.env.METAPI_DESKTOP_EXTERNAL_SERVER_URL || '').trim();
|
||||
}
|
||||
|
||||
async function resolveServerPort() {
|
||||
const forcedPort = Number.parseInt(process.env.METAPI_DESKTOP_SERVER_PORT || '', 10);
|
||||
if (Number.isFinite(forcedPort) && forcedPort > 0) return forcedPort;
|
||||
return getPort({ port: portNumbers(4310, 4399) });
|
||||
}
|
||||
|
||||
function showMainWindow() {
|
||||
if (!mainWindow) return;
|
||||
if (mainWindow.isMinimized()) mainWindow.restore();
|
||||
mainWindow.show();
|
||||
mainWindow.focus();
|
||||
}
|
||||
|
||||
function buildTrayMenu() {
|
||||
return Menu.buildFromTemplate([
|
||||
{
|
||||
label: 'Open Metapi',
|
||||
click: () => showMainWindow(),
|
||||
},
|
||||
{
|
||||
label: 'Restart Backend',
|
||||
click: () => {
|
||||
void restartBackend();
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Launch at Login',
|
||||
type: 'checkbox',
|
||||
checked: app.getLoginItemSettings().openAtLogin,
|
||||
click: (menuItem) => {
|
||||
app.setLoginItemSettings({ openAtLogin: menuItem.checked });
|
||||
tray?.setContextMenu(buildTrayMenu());
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Check for Updates',
|
||||
click: () => {
|
||||
void checkForUpdates(true);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Open Logs Folder',
|
||||
click: () => {
|
||||
void shell.openPath(getLogsDir());
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Quit',
|
||||
click: () => {
|
||||
isQuitting = true;
|
||||
app.quit();
|
||||
},
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
function setupTray() {
|
||||
if (tray) return;
|
||||
const trayImage = nativeImage.createFromPath(getTrayIconPath());
|
||||
tray = new Tray(trayImage);
|
||||
tray.setToolTip('Metapi');
|
||||
tray.setContextMenu(buildTrayMenu());
|
||||
tray.on('double-click', () => showMainWindow());
|
||||
}
|
||||
|
||||
async function createMainWindow() {
|
||||
if (mainWindow) return mainWindow;
|
||||
|
||||
mainWindow = new BrowserWindow({
|
||||
width: 1480,
|
||||
height: 960,
|
||||
minWidth: 1180,
|
||||
minHeight: 760,
|
||||
show: false,
|
||||
icon: existsSync(getWindowIconPath()) ? getWindowIconPath() : undefined,
|
||||
autoHideMenuBar: true,
|
||||
webPreferences: {
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
},
|
||||
});
|
||||
|
||||
mainWindow.on('close', (event) => {
|
||||
if (!isQuitting) {
|
||||
event.preventDefault();
|
||||
mainWindow?.hide();
|
||||
}
|
||||
});
|
||||
|
||||
mainWindow.once('ready-to-show', () => {
|
||||
mainWindow?.show();
|
||||
});
|
||||
|
||||
await mainWindow.loadURL(resolveFrontendUrl());
|
||||
return mainWindow;
|
||||
}
|
||||
|
||||
function attachServerLogs(processHandle: ChildProcess) {
|
||||
processHandle.stdout?.on('data', (chunk) => {
|
||||
log.info(`[server] ${String(chunk).trimEnd()}`);
|
||||
});
|
||||
processHandle.stderr?.on('data', (chunk) => {
|
||||
log.error(`[server] ${String(chunk).trimEnd()}`);
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForManagedServerReady(url: string) {
|
||||
await waitForServerReady({
|
||||
url,
|
||||
timeoutMs: 45_000,
|
||||
intervalMs: 250,
|
||||
});
|
||||
}
|
||||
|
||||
async function startManagedBackend() {
|
||||
ensureDesktopDirs();
|
||||
const serverEntryPath = getServerEntryPath();
|
||||
const port = await resolveServerPort();
|
||||
serverUrl = createDesktopServerUrl(port);
|
||||
|
||||
const env = buildDesktopServerEnv({
|
||||
inheritedEnv: process.env,
|
||||
userDataDir: getUserDataDir(),
|
||||
logsDir: getLogsDir(),
|
||||
port,
|
||||
});
|
||||
|
||||
const child = spawn(process.execPath, [serverEntryPath], {
|
||||
cwd: app.getAppPath(),
|
||||
env: {
|
||||
...env,
|
||||
ELECTRON_RUN_AS_NODE: '1',
|
||||
NODE_ENV: app.isPackaged ? 'production' : 'development',
|
||||
},
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
windowsHide: true,
|
||||
});
|
||||
serverProcess = child;
|
||||
|
||||
attachServerLogs(child);
|
||||
|
||||
child.once('exit', (code, signal) => {
|
||||
const fatal = isFatalServerExit({ code, signal });
|
||||
serverProcess = null;
|
||||
if (!fatal || isQuitting || isRestartingBackend) return;
|
||||
void handleServerCrash(code);
|
||||
});
|
||||
|
||||
await waitForManagedServerReady(createDesktopHealthUrl(port));
|
||||
}
|
||||
|
||||
async function connectToExternalBackend() {
|
||||
const externalServerUrl = resolveExternalServerUrl();
|
||||
serverUrl = externalServerUrl;
|
||||
await waitForServerReady({
|
||||
url: `${externalServerUrl}/api/desktop/health`,
|
||||
timeoutMs: 45_000,
|
||||
intervalMs: 250,
|
||||
});
|
||||
}
|
||||
|
||||
async function stopManagedBackend() {
|
||||
if (!serverProcess) return;
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
const current = serverProcess;
|
||||
if (!current) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
current.kill('SIGKILL');
|
||||
}, 10_000);
|
||||
|
||||
current.once('exit', () => {
|
||||
clearTimeout(timeout);
|
||||
resolve();
|
||||
});
|
||||
|
||||
current.kill();
|
||||
});
|
||||
|
||||
serverProcess = null;
|
||||
}
|
||||
|
||||
async function handleServerCrash(code: number | null) {
|
||||
mainWindow?.hide();
|
||||
const result = await dialog.showMessageBox({
|
||||
type: 'error',
|
||||
title: 'Metapi backend stopped',
|
||||
message: `The local Metapi backend exited unexpectedly${typeof code === 'number' ? ` (code ${code})` : ''}.`,
|
||||
detail: 'You can restart the backend now or quit the desktop app.',
|
||||
buttons: ['Restart Backend', 'Quit'],
|
||||
defaultId: 0,
|
||||
cancelId: 1,
|
||||
});
|
||||
|
||||
if (result.response === 0) {
|
||||
await restartBackend();
|
||||
return;
|
||||
}
|
||||
|
||||
isQuitting = true;
|
||||
app.quit();
|
||||
}
|
||||
|
||||
async function restartBackend() {
|
||||
if (isRestartingBackend) return;
|
||||
isRestartingBackend = true;
|
||||
try {
|
||||
await stopManagedBackend();
|
||||
if (resolveExternalServerUrl()) {
|
||||
await connectToExternalBackend();
|
||||
} else {
|
||||
await startManagedBackend();
|
||||
}
|
||||
if (mainWindow) {
|
||||
await mainWindow.loadURL(resolveFrontendUrl());
|
||||
showMainWindow();
|
||||
}
|
||||
} catch (error) {
|
||||
log.error('Failed to restart backend', error);
|
||||
await dialog.showMessageBox({
|
||||
type: 'error',
|
||||
title: 'Restart failed',
|
||||
message: 'Metapi could not restart the local backend.',
|
||||
detail: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
} finally {
|
||||
isRestartingBackend = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function checkForUpdates(isManual: boolean) {
|
||||
if (!app.isPackaged) {
|
||||
if (isManual) {
|
||||
await dialog.showMessageBox({
|
||||
type: 'info',
|
||||
title: 'Updates unavailable',
|
||||
message: 'Auto-update is only available for packaged desktop builds.',
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await autoUpdater.checkForUpdates();
|
||||
} catch (error) {
|
||||
log.error('Failed to check for updates', error);
|
||||
if (isManual) {
|
||||
await dialog.showMessageBox({
|
||||
type: 'error',
|
||||
title: 'Update check failed',
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function setupAutoUpdater() {
|
||||
if (!app.isPackaged) return;
|
||||
|
||||
autoUpdater.autoDownload = false;
|
||||
autoUpdater.logger = log;
|
||||
|
||||
autoUpdater.on('update-available', async (info) => {
|
||||
const result = await dialog.showMessageBox({
|
||||
type: 'info',
|
||||
title: 'Update available',
|
||||
message: `Metapi ${info.version} is available.`,
|
||||
detail: 'Download and install it after the current session?',
|
||||
buttons: ['Download', 'Later'],
|
||||
defaultId: 0,
|
||||
cancelId: 1,
|
||||
});
|
||||
|
||||
if (result.response === 0) {
|
||||
await autoUpdater.downloadUpdate();
|
||||
}
|
||||
});
|
||||
|
||||
autoUpdater.on('update-not-available', async () => {
|
||||
log.info('No desktop updates available');
|
||||
});
|
||||
|
||||
autoUpdater.on('update-downloaded', async () => {
|
||||
const result = await dialog.showMessageBox({
|
||||
type: 'info',
|
||||
title: 'Update ready',
|
||||
message: 'The new Metapi desktop update is ready to install.',
|
||||
buttons: ['Install and Restart', 'Later'],
|
||||
defaultId: 0,
|
||||
cancelId: 1,
|
||||
});
|
||||
|
||||
if (result.response === 0) {
|
||||
isQuitting = true;
|
||||
autoUpdater.quitAndInstall();
|
||||
}
|
||||
});
|
||||
|
||||
autoUpdater.on('error', (error) => {
|
||||
log.error('Auto-update error', error);
|
||||
});
|
||||
|
||||
void checkForUpdates(false);
|
||||
}
|
||||
|
||||
async function bootDesktopApp() {
|
||||
if (resolveExternalServerUrl()) {
|
||||
await connectToExternalBackend();
|
||||
} else {
|
||||
await startManagedBackend();
|
||||
}
|
||||
|
||||
setupTray();
|
||||
await createMainWindow();
|
||||
setupAutoUpdater();
|
||||
app.setLoginItemSettings({ openAtLogin: app.getLoginItemSettings().openAtLogin });
|
||||
}
|
||||
|
||||
const hasSingleInstanceLock = app.requestSingleInstanceLock();
|
||||
|
||||
if (!hasSingleInstanceLock) {
|
||||
app.quit();
|
||||
} else {
|
||||
app.on('second-instance', () => {
|
||||
showMainWindow();
|
||||
});
|
||||
|
||||
app.whenReady()
|
||||
.then(async () => {
|
||||
try {
|
||||
await bootDesktopApp();
|
||||
} catch (error) {
|
||||
log.error('Failed to boot Metapi desktop', error);
|
||||
const result = await dialog.showMessageBox({
|
||||
type: 'error',
|
||||
title: 'Metapi failed to start',
|
||||
message: 'The desktop shell could not start the local Metapi service.',
|
||||
detail: error instanceof Error ? error.message : String(error),
|
||||
buttons: ['Retry', 'Quit'],
|
||||
defaultId: 0,
|
||||
cancelId: 1,
|
||||
});
|
||||
|
||||
if (result.response === 0) {
|
||||
await bootDesktopApp();
|
||||
return;
|
||||
}
|
||||
|
||||
isQuitting = true;
|
||||
app.quit();
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
log.error('Unhandled startup error', error);
|
||||
isQuitting = true;
|
||||
app.quit();
|
||||
});
|
||||
}
|
||||
|
||||
app.on('activate', () => {
|
||||
if (!mainWindow) {
|
||||
void createMainWindow();
|
||||
return;
|
||||
}
|
||||
showMainWindow();
|
||||
});
|
||||
|
||||
app.on('before-quit', () => {
|
||||
isQuitting = true;
|
||||
});
|
||||
|
||||
app.on('will-quit', () => {
|
||||
tray?.destroy();
|
||||
tray = null;
|
||||
void stopManagedBackend();
|
||||
});
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
// Keep the tray resident app alive until the user explicitly quits.
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
buildDesktopServerEnv,
|
||||
createDesktopServerUrl,
|
||||
isFatalServerExit,
|
||||
waitForServerReady,
|
||||
} from './runtime.js';
|
||||
|
||||
describe('desktop runtime helpers', () => {
|
||||
it('builds desktop server env with loopback host and app directories', () => {
|
||||
const env = buildDesktopServerEnv({
|
||||
inheritedEnv: {
|
||||
AUTH_TOKEN: 'admin-token',
|
||||
PROXY_TOKEN: 'proxy-token',
|
||||
},
|
||||
userDataDir: '/tmp/metapi-data',
|
||||
logsDir: '/tmp/metapi-logs',
|
||||
port: 4312,
|
||||
});
|
||||
|
||||
expect(env.HOST).toBe('127.0.0.1');
|
||||
expect(env.PORT).toBe('4312');
|
||||
expect(env.DATA_DIR).toBe('/tmp/metapi-data');
|
||||
expect(env.METAPI_LOG_DIR).toBe('/tmp/metapi-logs');
|
||||
expect(env.AUTH_TOKEN).toBe('admin-token');
|
||||
expect(env.PROXY_TOKEN).toBe('proxy-token');
|
||||
});
|
||||
|
||||
it('creates the browser URL from the local desktop port', () => {
|
||||
expect(createDesktopServerUrl(4312)).toBe('http://127.0.0.1:4312');
|
||||
});
|
||||
|
||||
it('waits until the health probe returns ok', async () => {
|
||||
const fetcher = vi.fn()
|
||||
.mockRejectedValueOnce(new Error('offline'))
|
||||
.mockResolvedValueOnce({ ok: false })
|
||||
.mockResolvedValueOnce({ ok: true });
|
||||
|
||||
await expect(waitForServerReady({
|
||||
url: 'http://127.0.0.1:4312/api/desktop/health',
|
||||
fetcher,
|
||||
timeoutMs: 250,
|
||||
intervalMs: 1,
|
||||
})).resolves.toBeUndefined();
|
||||
|
||||
expect(fetcher).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('fails when the health probe never becomes ready', async () => {
|
||||
const fetcher = vi.fn().mockResolvedValue({ ok: false });
|
||||
|
||||
await expect(waitForServerReady({
|
||||
url: 'http://127.0.0.1:4312/api/desktop/health',
|
||||
fetcher,
|
||||
timeoutMs: 10,
|
||||
intervalMs: 1,
|
||||
})).rejects.toThrow('Timed out waiting for metapi desktop server');
|
||||
});
|
||||
|
||||
it('treats non-zero non-signal exits as fatal', () => {
|
||||
expect(isFatalServerExit({ code: 1, signal: null })).toBe(true);
|
||||
expect(isFatalServerExit({ code: 0, signal: null })).toBe(false);
|
||||
expect(isFatalServerExit({ code: null, signal: 'SIGTERM' })).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
type DesktopServerEnvInput = {
|
||||
inheritedEnv?: NodeJS.ProcessEnv;
|
||||
userDataDir: string;
|
||||
logsDir: string;
|
||||
port: number;
|
||||
};
|
||||
|
||||
type WaitForServerReadyInput = {
|
||||
url: string;
|
||||
fetcher?: (input: string, init?: RequestInit) => Promise<{ ok: boolean }>;
|
||||
timeoutMs?: number;
|
||||
intervalMs?: number;
|
||||
};
|
||||
|
||||
type ServerExitState = {
|
||||
code: number | null;
|
||||
signal: NodeJS.Signals | null;
|
||||
};
|
||||
|
||||
const DEFAULT_READY_TIMEOUT_MS = 30_000;
|
||||
const DEFAULT_READY_INTERVAL_MS = 250;
|
||||
|
||||
function delay(ms: number) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
export function buildDesktopServerEnv(input: DesktopServerEnvInput): NodeJS.ProcessEnv {
|
||||
return {
|
||||
...(input.inheritedEnv || {}),
|
||||
HOST: '127.0.0.1',
|
||||
PORT: String(input.port),
|
||||
DATA_DIR: input.userDataDir,
|
||||
METAPI_DESKTOP: '1',
|
||||
METAPI_LOG_DIR: input.logsDir,
|
||||
};
|
||||
}
|
||||
|
||||
export function createDesktopServerUrl(port: number): string {
|
||||
return `http://127.0.0.1:${port}`;
|
||||
}
|
||||
|
||||
export function createDesktopHealthUrl(port: number): string {
|
||||
return `${createDesktopServerUrl(port)}/api/desktop/health`;
|
||||
}
|
||||
|
||||
export async function waitForServerReady(input: WaitForServerReadyInput): Promise<void> {
|
||||
const fetcher = input.fetcher || ((url: string, init?: RequestInit) => fetch(url, init));
|
||||
const timeoutMs = input.timeoutMs ?? DEFAULT_READY_TIMEOUT_MS;
|
||||
const intervalMs = input.intervalMs ?? DEFAULT_READY_INTERVAL_MS;
|
||||
const startedAt = Date.now();
|
||||
|
||||
while (Date.now() - startedAt <= timeoutMs) {
|
||||
try {
|
||||
const response = await fetcher(input.url, { method: 'GET' });
|
||||
if (response.ok) return;
|
||||
} catch {
|
||||
// Retry until timeout.
|
||||
}
|
||||
await delay(intervalMs);
|
||||
}
|
||||
|
||||
throw new Error('Timed out waiting for metapi desktop server');
|
||||
}
|
||||
|
||||
export function isFatalServerExit(exitState: ServerExitState): boolean {
|
||||
return exitState.code !== null && exitState.code !== 0 && !exitState.signal;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { buildConfig } from './config.js';
|
||||
|
||||
describe('buildConfig', () => {
|
||||
it('defaults to external listen host for server deployments', () => {
|
||||
const config = buildConfig({});
|
||||
|
||||
expect(config.listenHost).toBe('0.0.0.0');
|
||||
expect(config.port).toBe(4000);
|
||||
expect(config.dataDir).toBe('./data');
|
||||
});
|
||||
|
||||
it('keeps desktop deployments bound to loopback', () => {
|
||||
const config = buildConfig({
|
||||
HOST: '0.0.0.0',
|
||||
METAPI_DESKTOP: '1',
|
||||
PORT: '4312',
|
||||
DATA_DIR: '/tmp/metapi-data',
|
||||
});
|
||||
|
||||
expect(config.listenHost).toBe('127.0.0.1');
|
||||
expect(config.port).toBe(4312);
|
||||
expect(config.dataDir).toBe('/tmp/metapi-data');
|
||||
});
|
||||
|
||||
it('honors explicit loopback host outside desktop mode', () => {
|
||||
const config = buildConfig({
|
||||
HOST: '127.0.0.1',
|
||||
});
|
||||
|
||||
expect(config.listenHost).toBe('127.0.0.1');
|
||||
});
|
||||
});
|
||||
+56
-42
@@ -28,46 +28,60 @@ function parseDbType(value: string | undefined): 'sqlite' | 'mysql' | 'postgres'
|
||||
return 'sqlite';
|
||||
}
|
||||
|
||||
const dataDir = process.env.DATA_DIR || './data';
|
||||
function parseListenHost(env: NodeJS.ProcessEnv): string {
|
||||
const requestedHost = (env.HOST || '0.0.0.0').trim() || '0.0.0.0';
|
||||
if (parseBoolean(env.METAPI_DESKTOP, false)) {
|
||||
return '127.0.0.1';
|
||||
}
|
||||
return requestedHost;
|
||||
}
|
||||
|
||||
export const config = {
|
||||
authToken: process.env.AUTH_TOKEN || 'change-me-admin-token',
|
||||
proxyToken: process.env.PROXY_TOKEN || 'change-me-proxy-sk-token',
|
||||
accountCredentialSecret: process.env.ACCOUNT_CREDENTIAL_SECRET || process.env.AUTH_TOKEN || 'change-me-admin-token',
|
||||
checkinCron: process.env.CHECKIN_CRON || '0 8 * * *',
|
||||
balanceRefreshCron: process.env.BALANCE_REFRESH_CRON || '0 * * * *',
|
||||
webhookUrl: process.env.WEBHOOK_URL || '',
|
||||
barkUrl: process.env.BARK_URL || '',
|
||||
webhookEnabled: parseBoolean(process.env.WEBHOOK_ENABLED, true),
|
||||
barkEnabled: parseBoolean(process.env.BARK_ENABLED, true),
|
||||
serverChanEnabled: parseBoolean(process.env.SERVERCHAN_ENABLED, true),
|
||||
serverChanKey: process.env.SERVERCHAN_KEY || '',
|
||||
telegramEnabled: parseBoolean(process.env.TELEGRAM_ENABLED, false),
|
||||
telegramBotToken: process.env.TELEGRAM_BOT_TOKEN || '',
|
||||
telegramChatId: process.env.TELEGRAM_CHAT_ID || '',
|
||||
smtpEnabled: parseBoolean(process.env.SMTP_ENABLED, false),
|
||||
smtpHost: process.env.SMTP_HOST || '',
|
||||
smtpPort: parseInt(process.env.SMTP_PORT || '587'),
|
||||
smtpSecure: parseBoolean(process.env.SMTP_SECURE, false),
|
||||
smtpUser: process.env.SMTP_USER || '',
|
||||
smtpPass: process.env.SMTP_PASS || '',
|
||||
smtpFrom: process.env.SMTP_FROM || '',
|
||||
smtpTo: process.env.SMTP_TO || '',
|
||||
notifyCooldownSec: Math.max(0, Math.trunc(parseNumber(process.env.NOTIFY_COOLDOWN_SEC, 300))),
|
||||
adminIpAllowlist: parseCsvList(process.env.ADMIN_IP_ALLOWLIST),
|
||||
port: Math.trunc(parseNumber(process.env.PORT, 4000)),
|
||||
dataDir,
|
||||
dbType: parseDbType(process.env.DB_TYPE),
|
||||
dbUrl: (process.env.DB_URL || '').trim(),
|
||||
routingFallbackUnitCost: Math.max(1e-6, parseNumber(process.env.ROUTING_FALLBACK_UNIT_COST, 1)),
|
||||
tokenRouterCacheTtlMs: Math.max(100, Math.trunc(parseNumber(process.env.TOKEN_ROUTER_CACHE_TTL_MS, 1_500))),
|
||||
proxyLogRetentionDays: Math.max(0, Math.trunc(parseNumber(process.env.PROXY_LOG_RETENTION_DAYS, 30))),
|
||||
proxyLogRetentionPruneIntervalMinutes: Math.max(1, Math.trunc(parseNumber(process.env.PROXY_LOG_RETENTION_PRUNE_INTERVAL_MINUTES, 30))),
|
||||
routingWeights: {
|
||||
baseWeightFactor: parseNumber(process.env.BASE_WEIGHT_FACTOR, 0.5),
|
||||
valueScoreFactor: parseNumber(process.env.VALUE_SCORE_FACTOR, 0.5),
|
||||
costWeight: parseNumber(process.env.COST_WEIGHT, 0.4),
|
||||
balanceWeight: parseNumber(process.env.BALANCE_WEIGHT, 0.3),
|
||||
usageWeight: parseNumber(process.env.USAGE_WEIGHT, 0.3),
|
||||
},
|
||||
};
|
||||
export function buildConfig(env: NodeJS.ProcessEnv) {
|
||||
const dataDir = env.DATA_DIR || './data';
|
||||
|
||||
return {
|
||||
authToken: env.AUTH_TOKEN || 'change-me-admin-token',
|
||||
proxyToken: env.PROXY_TOKEN || 'change-me-proxy-sk-token',
|
||||
accountCredentialSecret: env.ACCOUNT_CREDENTIAL_SECRET || env.AUTH_TOKEN || 'change-me-admin-token',
|
||||
checkinCron: env.CHECKIN_CRON || '0 8 * * *',
|
||||
balanceRefreshCron: env.BALANCE_REFRESH_CRON || '0 * * * *',
|
||||
webhookUrl: env.WEBHOOK_URL || '',
|
||||
barkUrl: env.BARK_URL || '',
|
||||
webhookEnabled: parseBoolean(env.WEBHOOK_ENABLED, true),
|
||||
barkEnabled: parseBoolean(env.BARK_ENABLED, true),
|
||||
serverChanEnabled: parseBoolean(env.SERVERCHAN_ENABLED, true),
|
||||
serverChanKey: env.SERVERCHAN_KEY || '',
|
||||
telegramEnabled: parseBoolean(env.TELEGRAM_ENABLED, false),
|
||||
telegramBotToken: env.TELEGRAM_BOT_TOKEN || '',
|
||||
telegramChatId: env.TELEGRAM_CHAT_ID || '',
|
||||
smtpEnabled: parseBoolean(env.SMTP_ENABLED, false),
|
||||
smtpHost: env.SMTP_HOST || '',
|
||||
smtpPort: parseInt(env.SMTP_PORT || '587'),
|
||||
smtpSecure: parseBoolean(env.SMTP_SECURE, false),
|
||||
smtpUser: env.SMTP_USER || '',
|
||||
smtpPass: env.SMTP_PASS || '',
|
||||
smtpFrom: env.SMTP_FROM || '',
|
||||
smtpTo: env.SMTP_TO || '',
|
||||
notifyCooldownSec: Math.max(0, Math.trunc(parseNumber(env.NOTIFY_COOLDOWN_SEC, 300))),
|
||||
adminIpAllowlist: parseCsvList(env.ADMIN_IP_ALLOWLIST),
|
||||
port: Math.trunc(parseNumber(env.PORT, 4000)),
|
||||
listenHost: parseListenHost(env),
|
||||
dataDir,
|
||||
dbType: parseDbType(env.DB_TYPE),
|
||||
dbUrl: (env.DB_URL || '').trim(),
|
||||
dbSsl: parseBoolean(env.DB_SSL, false),
|
||||
routingFallbackUnitCost: Math.max(1e-6, parseNumber(env.ROUTING_FALLBACK_UNIT_COST, 1)),
|
||||
tokenRouterCacheTtlMs: Math.max(100, Math.trunc(parseNumber(env.TOKEN_ROUTER_CACHE_TTL_MS, 1_500))),
|
||||
proxyLogRetentionDays: Math.max(0, Math.trunc(parseNumber(env.PROXY_LOG_RETENTION_DAYS, 30))),
|
||||
proxyLogRetentionPruneIntervalMinutes: Math.max(1, Math.trunc(parseNumber(env.PROXY_LOG_RETENTION_PRUNE_INTERVAL_MINUTES, 30))),
|
||||
routingWeights: {
|
||||
baseWeightFactor: parseNumber(env.BASE_WEIGHT_FACTOR, 0.5),
|
||||
valueScoreFactor: parseNumber(env.VALUE_SCORE_FACTOR, 0.5),
|
||||
costWeight: parseNumber(env.COST_WEIGHT, 0.4),
|
||||
balanceWeight: parseNumber(env.BALANCE_WEIGHT, 0.3),
|
||||
usageWeight: parseNumber(env.USAGE_WEIGHT, 0.3),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const config = buildConfig(process.env);
|
||||
|
||||
+16
-3
@@ -532,7 +532,11 @@ function initMysqlDb(): AppDb {
|
||||
if (!config.dbUrl) {
|
||||
throw new Error('DB_URL is required when DB_TYPE=mysql');
|
||||
}
|
||||
mysqlPool = mysql.createPool(config.dbUrl);
|
||||
const poolOptions: mysql.PoolOptions = { uri: config.dbUrl };
|
||||
if (config.dbSsl) {
|
||||
poolOptions.ssl = { rejectUnauthorized: false };
|
||||
}
|
||||
mysqlPool = mysql.createPool(poolOptions);
|
||||
|
||||
const rawDb = drizzleMysqlProxy(
|
||||
(sqlText, params, method) => mysqlProxyQuery(mysqlPool!, sqlText, params, method as SqlMethod),
|
||||
@@ -564,7 +568,11 @@ function initPostgresDb(): AppDb {
|
||||
if (!config.dbUrl) {
|
||||
throw new Error('DB_URL is required when DB_TYPE=postgres');
|
||||
}
|
||||
pgPool = new pg.Pool({ connectionString: config.dbUrl });
|
||||
const poolOptions: pg.PoolConfig = { connectionString: config.dbUrl };
|
||||
if (config.dbSsl) {
|
||||
poolOptions.ssl = { rejectUnauthorized: false };
|
||||
}
|
||||
pgPool = new pg.Pool(poolOptions);
|
||||
|
||||
const rawDb = drizzlePgProxy(
|
||||
(sqlText, params, method) => pgProxyQuery(pgPool!, sqlText, params, method as SqlMethod),
|
||||
@@ -622,16 +630,20 @@ export async function closeDbConnections(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
export async function switchRuntimeDatabase(nextDialect: RuntimeDbDialect, nextDbUrl: string): Promise<void> {
|
||||
export async function switchRuntimeDatabase(nextDialect: RuntimeDbDialect, nextDbUrl: string, nextDbSsl?: boolean): Promise<void> {
|
||||
const previousDialect = runtimeDbDialect;
|
||||
const previousDbUrl = config.dbUrl;
|
||||
const previousConfigDialect = config.dbType;
|
||||
const previousDbSsl = config.dbSsl;
|
||||
|
||||
await closeDbConnections();
|
||||
|
||||
runtimeDbDialect = nextDialect;
|
||||
config.dbType = nextDialect;
|
||||
config.dbUrl = nextDbUrl;
|
||||
if (nextDbSsl !== undefined) {
|
||||
config.dbSsl = nextDbSsl;
|
||||
}
|
||||
|
||||
try {
|
||||
activeDb = initDb();
|
||||
@@ -640,6 +652,7 @@ export async function switchRuntimeDatabase(nextDialect: RuntimeDbDialect, nextD
|
||||
runtimeDbDialect = previousDialect;
|
||||
config.dbType = previousConfigDialect;
|
||||
config.dbUrl = previousDbUrl;
|
||||
config.dbSsl = previousDbSsl;
|
||||
activeDb = initDb();
|
||||
throw error;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import Fastify from 'fastify';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { isPublicApiRoute, registerDesktopRoutes } from './desktop.js';
|
||||
|
||||
describe('desktop server routes', () => {
|
||||
it('marks only the desktop health route as public', () => {
|
||||
expect(isPublicApiRoute('/api/desktop/health')).toBe(true);
|
||||
expect(isPublicApiRoute('/api/stats/dashboard')).toBe(false);
|
||||
});
|
||||
|
||||
it('registers a public desktop health probe', async () => {
|
||||
const app = Fastify();
|
||||
await registerDesktopRoutes(app);
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/desktop/health',
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toEqual({ ok: true });
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
|
||||
const DESKTOP_HEALTH_ROUTE = '/api/desktop/health';
|
||||
|
||||
export function isPublicApiRoute(url: string): boolean {
|
||||
return url === DESKTOP_HEALTH_ROUTE;
|
||||
}
|
||||
|
||||
export async function registerDesktopRoutes(app: FastifyInstance) {
|
||||
app.get(DESKTOP_HEALTH_ROUTE, async () => ({ ok: true }));
|
||||
}
|
||||
+14
-8
@@ -22,6 +22,7 @@ import { startScheduler } from './services/checkinScheduler.js';
|
||||
import { startProxyLogRetentionService, stopProxyLogRetentionService } from './services/proxyLogRetentionService.js';
|
||||
import { buildStartupSummaryLines } from './services/startupInfo.js';
|
||||
import { repairStoredCreatedAtValues } from './services/storedTimestampRepairService.js';
|
||||
import { isPublicApiRoute, registerDesktopRoutes } from './desktop.js';
|
||||
import { existsSync } from 'fs';
|
||||
import { normalize, resolve, sep } from 'path';
|
||||
import { db, runtimeDbDialect, schema, switchRuntimeDatabase, type RuntimeDbDialect } from './db/index.js';
|
||||
@@ -74,14 +75,19 @@ function validateSavedDbUrl(dialect: RuntimeDbDialect, value: unknown): string |
|
||||
return null;
|
||||
}
|
||||
|
||||
function extractSavedRuntimeDatabaseConfig(settingsMap: Map<string, string>): { dialect: RuntimeDbDialect; dbUrl: string } | null {
|
||||
function extractSavedRuntimeDatabaseConfig(settingsMap: Map<string, string>): { dialect: RuntimeDbDialect; dbUrl: string; ssl: boolean } | null {
|
||||
const rawType = parseSettingFromMap<unknown>(settingsMap, 'db_type');
|
||||
const rawUrl = parseSettingFromMap<unknown>(settingsMap, 'db_url');
|
||||
const rawSsl = parseSettingFromMap<boolean>(settingsMap, 'db_ssl');
|
||||
const dialect = normalizeSavedDbType(rawType);
|
||||
if (!dialect) return null;
|
||||
const dbUrl = validateSavedDbUrl(dialect, rawUrl);
|
||||
if (!dbUrl) return null;
|
||||
return { dialect, dbUrl };
|
||||
return {
|
||||
dialect,
|
||||
dbUrl,
|
||||
ssl: typeof rawSsl === 'boolean' ? rawSsl : false,
|
||||
};
|
||||
}
|
||||
|
||||
function applyRuntimeSettings(settingsMap: Map<string, string>) {
|
||||
@@ -171,9 +177,9 @@ try {
|
||||
const initialMap = toSettingsMap(initialRows);
|
||||
const savedDbConfig = extractSavedRuntimeDatabaseConfig(initialMap);
|
||||
const activeDbUrl = (config.dbUrl || '').trim();
|
||||
if (savedDbConfig && (savedDbConfig.dialect !== runtimeDbDialect || savedDbConfig.dbUrl !== activeDbUrl)) {
|
||||
if (savedDbConfig && (savedDbConfig.dialect !== runtimeDbDialect || savedDbConfig.dbUrl !== activeDbUrl || savedDbConfig.ssl !== config.dbSsl)) {
|
||||
try {
|
||||
await switchRuntimeDatabase(savedDbConfig.dialect, savedDbConfig.dbUrl);
|
||||
await switchRuntimeDatabase(savedDbConfig.dialect, savedDbConfig.dbUrl, savedDbConfig.ssl);
|
||||
console.log(`Loaded runtime DB config from settings: ${savedDbConfig.dialect}`);
|
||||
} catch (error) {
|
||||
console.warn(`Failed to switch runtime DB from settings: ${(error as Error)?.message || 'unknown error'}`);
|
||||
@@ -194,12 +200,13 @@ await app.register(cors);
|
||||
|
||||
// Auth middleware for /api routes
|
||||
app.addHook('onRequest', async (request, reply) => {
|
||||
if (request.url.startsWith('/api/')) {
|
||||
if (request.url.startsWith('/api/') && !isPublicApiRoute(request.url)) {
|
||||
await authMiddleware(request, reply);
|
||||
}
|
||||
});
|
||||
|
||||
// Register API routes
|
||||
await app.register(registerDesktopRoutes);
|
||||
await app.register(sitesRoutes);
|
||||
await app.register(accountsRoutes);
|
||||
await app.register(checkinRoutes);
|
||||
@@ -254,11 +261,10 @@ app.addHook('onClose', async () => {
|
||||
|
||||
// Start server
|
||||
try {
|
||||
const listenHost = '0.0.0.0';
|
||||
await app.listen({ port: config.port, host: listenHost });
|
||||
await app.listen({ port: config.port, host: config.listenHost });
|
||||
const summaryLines = buildStartupSummaryLines({
|
||||
port: config.port,
|
||||
host: listenHost,
|
||||
host: config.listenHost,
|
||||
authToken: config.authToken,
|
||||
proxyToken: config.proxyToken,
|
||||
});
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
buildBetterSqliteRebuildCommand,
|
||||
isNodeModuleVersionMismatch,
|
||||
} from './nativeModuleGuard.js';
|
||||
|
||||
describe('nativeModuleGuard', () => {
|
||||
it('detects better-sqlite3 ABI mismatch errors', () => {
|
||||
const error = Object.assign(
|
||||
new Error('NODE_MODULE_VERSION 133. This version of Node.js requires NODE_MODULE_VERSION 137.'),
|
||||
{ code: 'ERR_DLOPEN_FAILED' },
|
||||
);
|
||||
|
||||
expect(isNodeModuleVersionMismatch(error)).toBe(true);
|
||||
});
|
||||
|
||||
it('ignores unrelated native module errors', () => {
|
||||
const error = Object.assign(
|
||||
new Error('Cannot find module'),
|
||||
{ code: 'MODULE_NOT_FOUND' },
|
||||
);
|
||||
|
||||
expect(isNodeModuleVersionMismatch(error)).toBe(false);
|
||||
});
|
||||
|
||||
it('builds a rebuild command for the current npm client', () => {
|
||||
const command = buildBetterSqliteRebuildCommand({
|
||||
npmExecPath: '/tmp/npm-cli.js',
|
||||
});
|
||||
|
||||
expect(command.command).toBe(process.execPath);
|
||||
expect(command.args).toEqual([
|
||||
'/tmp/npm-cli.js',
|
||||
'rebuild',
|
||||
'better-sqlite3',
|
||||
'--no-audit',
|
||||
'--fund=false',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
type RebuildCommandInput = {
|
||||
npmExecPath?: string;
|
||||
};
|
||||
|
||||
type RebuildCommand = {
|
||||
command: string;
|
||||
args: string[];
|
||||
};
|
||||
|
||||
export function isNodeModuleVersionMismatch(error: unknown): boolean {
|
||||
if (!error || typeof error !== 'object') return false;
|
||||
const code = 'code' in error ? String((error as { code?: unknown }).code || '') : '';
|
||||
const message = 'message' in error ? String((error as { message?: unknown }).message || '') : '';
|
||||
return code === 'ERR_DLOPEN_FAILED' && message.includes('NODE_MODULE_VERSION');
|
||||
}
|
||||
|
||||
export function buildBetterSqliteRebuildCommand(input: RebuildCommandInput = {}): RebuildCommand {
|
||||
const npmExecPath = input.npmExecPath || process.env.npm_execpath;
|
||||
if (npmExecPath && npmExecPath.length > 0) {
|
||||
return {
|
||||
command: process.execPath,
|
||||
args: [
|
||||
npmExecPath,
|
||||
'rebuild',
|
||||
'better-sqlite3',
|
||||
'--no-audit',
|
||||
'--fund=false',
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm';
|
||||
return {
|
||||
command: npmCommand,
|
||||
args: ['rebuild', 'better-sqlite3', '--no-audit', '--fund=false'],
|
||||
};
|
||||
}
|
||||
@@ -207,6 +207,53 @@ describe('accounts health refresh runtime state', () => {
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
it('skips runtime refresh for proxy-only accounts', async () => {
|
||||
const site = await db.insert(schema.sites).values({
|
||||
name: 'Proxy Site',
|
||||
url: 'https://proxy.example.com',
|
||||
platform: 'new-api',
|
||||
}).returning().get();
|
||||
|
||||
const account = await db.insert(schema.accounts).values({
|
||||
siteId: site.id,
|
||||
username: null,
|
||||
accessToken: 'api-key-token',
|
||||
status: 'expired',
|
||||
extraConfig: JSON.stringify({
|
||||
credentialMode: 'apikey',
|
||||
}),
|
||||
}).returning().get();
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/accounts/health/refresh',
|
||||
payload: { accountId: account.id, wait: true },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
const body = response.json() as {
|
||||
success: boolean;
|
||||
summary: {
|
||||
total: number;
|
||||
skipped: number;
|
||||
failed: number;
|
||||
};
|
||||
results: Array<{ status: string; state: string; message: string }>;
|
||||
};
|
||||
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.summary).toMatchObject({
|
||||
total: 1,
|
||||
skipped: 1,
|
||||
failed: 0,
|
||||
});
|
||||
expect(body.results[0]).toMatchObject({
|
||||
status: 'skipped',
|
||||
state: 'unknown',
|
||||
});
|
||||
expect(refreshBalanceMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('finishes the background refresh-all task after the 10 second site timeout instead of staying in progress', async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
|
||||
@@ -47,6 +47,8 @@ type AccountCapabilities = {
|
||||
proxyOnly: boolean;
|
||||
};
|
||||
|
||||
type VerifyFailureReason = 'needs-user-id' | 'invalid-user-id' | 'shield-blocked' | null;
|
||||
|
||||
function hasSessionTokenValue(value: string | null | undefined): boolean {
|
||||
return typeof value === 'string' && value.trim().length > 0;
|
||||
}
|
||||
@@ -126,6 +128,8 @@ type LoginFailureInfo = {
|
||||
};
|
||||
|
||||
const ACCOUNT_HEALTH_REFRESH_TIMEOUT_MS = 10_000;
|
||||
const ACCOUNT_VERIFY_TIMEOUT_MS = 10_000;
|
||||
const ACCOUNT_VERIFY_DIAG_TIMEOUT_MS = 2_500;
|
||||
|
||||
function normalizeLoginFailure(message: string | null | undefined): LoginFailureInfo {
|
||||
const raw = (message || '').trim();
|
||||
@@ -184,12 +188,53 @@ async function withTimeout<T>(fn: () => Promise<T>, timeoutMs: number, timeoutMe
|
||||
}
|
||||
}
|
||||
|
||||
function isVerificationTimeoutError(error: unknown): boolean {
|
||||
const name = typeof error === 'object' && error && 'name' in error
|
||||
? String((error as { name?: unknown }).name || '')
|
||||
: '';
|
||||
const message = typeof error === 'object' && error && 'message' in error
|
||||
? String((error as { message?: unknown }).message || '')
|
||||
: String(error || '');
|
||||
const lowered = `${name} ${message}`.toLowerCase();
|
||||
return lowered.includes('timeout') || lowered.includes('timed out') || lowered.includes('abort');
|
||||
}
|
||||
|
||||
function resolveUserIdFailureReason(message: string, hasProvidedUserId: boolean): VerifyFailureReason {
|
||||
const lowered = String(message || '').trim().toLowerCase();
|
||||
if (!lowered) return null;
|
||||
|
||||
if (
|
||||
lowered.includes('mismatch')
|
||||
|| lowered.includes('not match')
|
||||
|| lowered.includes('invalid user id')
|
||||
|| lowered.includes('wrong user id')
|
||||
) {
|
||||
return 'invalid-user-id';
|
||||
}
|
||||
|
||||
if (
|
||||
lowered.includes('missing new-api-user')
|
||||
|| lowered.includes('new-api-user required')
|
||||
|| lowered.includes('requires user id')
|
||||
|| lowered.includes('missing user id')
|
||||
) {
|
||||
return 'needs-user-id';
|
||||
}
|
||||
|
||||
if (lowered.includes('new-api-user') || lowered.includes('user id')) {
|
||||
return hasProvidedUserId ? 'invalid-user-id' : 'needs-user-id';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function refreshRuntimeHealthForRow(row: AccountWithSiteRow): Promise<AccountHealthRefreshResult> {
|
||||
const accountId = row.accounts.id;
|
||||
const username = row.accounts.username;
|
||||
const siteName = row.sites.name;
|
||||
|
||||
if ((row.accounts.status || 'active') === 'disabled' || (row.sites.status || 'active') === 'disabled') {
|
||||
const accountId = row.accounts.id;
|
||||
const username = row.accounts.username;
|
||||
const siteName = row.sites.name;
|
||||
const capabilities = buildCapabilitiesForAccount(row.accounts);
|
||||
|
||||
if ((row.accounts.status || 'active') === 'disabled' || (row.sites.status || 'active') === 'disabled') {
|
||||
setAccountRuntimeHealth(accountId, {
|
||||
state: 'disabled',
|
||||
reason: '账号或站点已禁用',
|
||||
@@ -202,7 +247,18 @@ async function refreshRuntimeHealthForRow(row: AccountWithSiteRow): Promise<Acco
|
||||
status: 'skipped',
|
||||
state: 'disabled',
|
||||
message: '账号或站点已禁用',
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
if (capabilities.proxyOnly) {
|
||||
return {
|
||||
accountId,
|
||||
username,
|
||||
siteName,
|
||||
status: 'skipped',
|
||||
state: 'unknown',
|
||||
message: '仅代理账号不支持会话健康检查',
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -218,6 +274,7 @@ async function refreshRuntimeHealthForRow(row: AccountWithSiteRow): Promise<Acco
|
||||
accountStatus: refreshedAccount?.status || row.accounts.status,
|
||||
siteStatus: row.sites.status,
|
||||
extraConfig: refreshedAccount?.extraConfig ?? row.accounts.extraConfig,
|
||||
sessionCapable: capabilities.canRefreshBalance,
|
||||
});
|
||||
|
||||
return {
|
||||
@@ -333,6 +390,10 @@ export async function accountsRoutes(app: FastifyInstance) {
|
||||
accountStatus: r.accounts.status,
|
||||
siteStatus: r.sites.status,
|
||||
extraConfig: r.accounts.extraConfig,
|
||||
sessionCapable: buildCapabilitiesFromCredentialMode(
|
||||
credentialMode,
|
||||
hasSessionTokenValue(r.accounts.accessToken),
|
||||
).canRefreshBalance,
|
||||
}),
|
||||
};
|
||||
});
|
||||
@@ -464,9 +525,119 @@ export async function accountsRoutes(app: FastifyInstance) {
|
||||
const adapter = getAdapter(site.platform);
|
||||
if (!adapter) return { success: false, message: `婵炴垶鎸哥粔鐢稿极椤曗偓楠炴劖鎷呴悜妯兼殸濡ょ姷鍋涢崯鑳亹? ${site.platform}` };
|
||||
|
||||
const normalizedPlatform = String(adapter.platformName || site.platform || '').trim().toLowerCase();
|
||||
const hasProvidedUserId = Number.isFinite(platformUserId) && platformUserId > 0;
|
||||
const skipRawShieldDetection = normalizedPlatform === 'new-api' || normalizedPlatform === 'anyrouter';
|
||||
const diagnoseVerificationFailure = async (): Promise<VerifyFailureReason> => {
|
||||
const parseFailureReason = (bodyText: string, contentType: string): VerifyFailureReason => {
|
||||
const text = bodyText || '';
|
||||
const ct = (contentType || '').toLowerCase();
|
||||
if (
|
||||
!skipRawShieldDetection
|
||||
&& ct.includes('text/html')
|
||||
&& /var\s+arg1\s*=|acw_sc__v2|cdn_sec_tc|<script/i.test(text)
|
||||
) {
|
||||
return 'shield-blocked';
|
||||
}
|
||||
|
||||
try {
|
||||
const body = JSON.parse(text) as any;
|
||||
const message = typeof body?.message === 'string' ? body.message : '';
|
||||
const userIdReason = resolveUserIdFailureReason(message, hasProvidedUserId);
|
||||
if (userIdReason) return userIdReason;
|
||||
if (!skipRawShieldDetection && /shield|challenge|captcha|acw_sc__v2|arg1/i.test(message)) {
|
||||
return 'shield-blocked';
|
||||
}
|
||||
} catch { }
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
try {
|
||||
const { fetch } = await import('undici');
|
||||
const candidates = new Set<string>();
|
||||
const raw = accessToken.startsWith('Bearer ') ? accessToken.slice(7).trim() : accessToken;
|
||||
if (raw) {
|
||||
if (raw.includes('=')) candidates.add(raw);
|
||||
candidates.add(`session=${raw}`);
|
||||
candidates.add(`token=${raw}`);
|
||||
}
|
||||
|
||||
const diagnosticUserId = hasProvidedUserId ? String(platformUserId) : '0';
|
||||
const headerVariants: Record<string, string>[] = [
|
||||
{ Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json', 'New-Api-User': diagnosticUserId },
|
||||
];
|
||||
|
||||
for (const cookie of candidates) {
|
||||
headerVariants.push({
|
||||
Cookie: cookie,
|
||||
'Content-Type': 'application/json',
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
...(hasProvidedUserId ? { 'New-Api-User': diagnosticUserId } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
for (const headers of headerVariants) {
|
||||
try {
|
||||
const testRes = await fetch(
|
||||
`${site.url}/api/user/self`,
|
||||
withExplicitProxyRequestInit(site.proxyUrl, {
|
||||
headers,
|
||||
signal: AbortSignal.timeout(ACCOUNT_VERIFY_DIAG_TIMEOUT_MS),
|
||||
}),
|
||||
);
|
||||
const bodyText = await testRes.text();
|
||||
const contentType = testRes.headers.get('content-type') || '';
|
||||
const reason = parseFailureReason(bodyText, contentType);
|
||||
if (reason) return reason;
|
||||
} catch { }
|
||||
}
|
||||
} catch { }
|
||||
|
||||
return null;
|
||||
};
|
||||
const buildVerificationFailureResponse = (failureReason: VerifyFailureReason) => {
|
||||
if (failureReason === 'needs-user-id') {
|
||||
return {
|
||||
success: false,
|
||||
needsUserId: true,
|
||||
message: 'This site requires a user ID. Please fill in your site user ID.',
|
||||
};
|
||||
}
|
||||
|
||||
if (failureReason === 'invalid-user-id') {
|
||||
return {
|
||||
success: false,
|
||||
invalidUserId: true,
|
||||
message: 'The provided user ID does not match this token. Please check your site user ID.',
|
||||
};
|
||||
}
|
||||
|
||||
if (failureReason === 'shield-blocked') {
|
||||
return {
|
||||
success: false,
|
||||
shieldBlocked: true,
|
||||
message: 'This site is shielded by anti-bot challenge. Create an API key on the target site and import that key.',
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
if (!platformUserId && (normalizedPlatform === 'new-api' || normalizedPlatform === 'anyrouter')) {
|
||||
const preflightReason = await diagnoseVerificationFailure();
|
||||
if (preflightReason === 'needs-user-id') {
|
||||
return buildVerificationFailureResponse(preflightReason);
|
||||
}
|
||||
}
|
||||
|
||||
if (credentialMode === 'apikey') {
|
||||
try {
|
||||
const models = await adapter.getModels(site.url, accessToken, platformUserId);
|
||||
const models = await withTimeout(
|
||||
() => adapter.getModels(site.url, accessToken, platformUserId),
|
||||
ACCOUNT_VERIFY_TIMEOUT_MS,
|
||||
`Token verification timed out (${Math.max(1, Math.round(ACCOUNT_VERIFY_TIMEOUT_MS / 1000))}s)`,
|
||||
);
|
||||
const availableModels = Array.isArray(models) ? models.filter((item) => typeof item === 'string' && item.trim().length > 0) : [];
|
||||
if (availableModels.length === 0) {
|
||||
return {
|
||||
@@ -481,6 +652,10 @@ export async function accountsRoutes(app: FastifyInstance) {
|
||||
models: availableModels.slice(0, 10),
|
||||
};
|
||||
} catch (err: any) {
|
||||
if (isVerificationTimeoutError(err)) {
|
||||
const failure = buildVerificationFailureResponse(await diagnoseVerificationFailure());
|
||||
if (failure) return failure;
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
message: err?.message || 'API Key 验证失败',
|
||||
@@ -490,8 +665,16 @@ export async function accountsRoutes(app: FastifyInstance) {
|
||||
|
||||
let result: any;
|
||||
try {
|
||||
result = await adapter.verifyToken(site.url, accessToken, platformUserId);
|
||||
result = await withTimeout(
|
||||
() => adapter.verifyToken(site.url, accessToken, platformUserId),
|
||||
ACCOUNT_VERIFY_TIMEOUT_MS,
|
||||
`Token verification timed out (${Math.max(1, Math.round(ACCOUNT_VERIFY_TIMEOUT_MS / 1000))}s)`,
|
||||
);
|
||||
} catch (err: any) {
|
||||
if (isVerificationTimeoutError(err)) {
|
||||
const failure = buildVerificationFailureResponse(await diagnoseVerificationFailure());
|
||||
if (failure) return failure;
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
message: appendSessionTokenRebindHint(err?.message || 'Token 验证失败'),
|
||||
@@ -524,12 +707,6 @@ export async function accountsRoutes(app: FastifyInstance) {
|
||||
}
|
||||
|
||||
// Try to explain unknown failures: missing user id vs anti-bot challenge page.
|
||||
const normalizedPlatform = String(adapter.platformName || site.platform || '').trim().toLowerCase();
|
||||
// New-API family already runs shield-aware probing inside adapters.
|
||||
// Raw fallback probe below does not include challenge-solving and can
|
||||
// misclassify valid Cookie/Session flows as shield-blocked.
|
||||
const skipRawShieldDetection = normalizedPlatform === 'new-api' || normalizedPlatform === 'anyrouter';
|
||||
type VerifyFailureReason = 'needs-user-id' | 'shield-blocked' | null;
|
||||
const detectVerifyFailureReason = async (): Promise<VerifyFailureReason> => {
|
||||
const parseFailureReason = (bodyText: string, contentType: string): VerifyFailureReason => {
|
||||
const text = bodyText || '';
|
||||
@@ -545,7 +722,8 @@ export async function accountsRoutes(app: FastifyInstance) {
|
||||
try {
|
||||
const body = JSON.parse(text) as any;
|
||||
const message = typeof body?.message === 'string' ? body.message : '';
|
||||
if (/mismatch|new-api-user|user id/i.test(message)) return 'needs-user-id';
|
||||
const userIdReason = resolveUserIdFailureReason(message, hasProvidedUserId);
|
||||
if (userIdReason) return userIdReason;
|
||||
if (!skipRawShieldDetection && /shield|challenge|captcha|acw_sc__v2|arg1/i.test(message)) {
|
||||
return 'shield-blocked';
|
||||
}
|
||||
@@ -564,8 +742,9 @@ export async function accountsRoutes(app: FastifyInstance) {
|
||||
candidates.add(`token=${raw}`);
|
||||
}
|
||||
|
||||
const diagnosticUserId = hasProvidedUserId ? String(platformUserId) : '0';
|
||||
const headerVariants: Record<string, string>[] = [
|
||||
{ Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json', 'New-Api-User': '0' },
|
||||
{ Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json', 'New-Api-User': diagnosticUserId },
|
||||
];
|
||||
|
||||
for (const cookie of candidates) {
|
||||
@@ -573,12 +752,19 @@ export async function accountsRoutes(app: FastifyInstance) {
|
||||
Cookie: cookie,
|
||||
'Content-Type': 'application/json',
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
...(hasProvidedUserId ? { 'New-Api-User': diagnosticUserId } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
for (const headers of headerVariants) {
|
||||
try {
|
||||
const testRes = await fetch(`${site.url}/api/user/self`, withExplicitProxyRequestInit(site.proxyUrl, { headers }));
|
||||
const testRes = await fetch(
|
||||
`${site.url}/api/user/self`,
|
||||
withExplicitProxyRequestInit(site.proxyUrl, {
|
||||
headers,
|
||||
signal: AbortSignal.timeout(ACCOUNT_VERIFY_DIAG_TIMEOUT_MS),
|
||||
}),
|
||||
);
|
||||
const bodyText = await testRes.text();
|
||||
const contentType = testRes.headers.get('content-type') || '';
|
||||
const reason = parseFailureReason(bodyText, contentType);
|
||||
@@ -599,6 +785,14 @@ export async function accountsRoutes(app: FastifyInstance) {
|
||||
};
|
||||
}
|
||||
|
||||
if (failureReason === 'invalid-user-id') {
|
||||
return {
|
||||
success: false,
|
||||
invalidUserId: true,
|
||||
message: 'The provided user ID does not match this token. Please check your site user ID.',
|
||||
};
|
||||
}
|
||||
|
||||
if (failureReason === 'shield-blocked') {
|
||||
return {
|
||||
success: false,
|
||||
|
||||
@@ -187,4 +187,114 @@ describe('accounts verify-token shield detection', () => {
|
||||
});
|
||||
expect(undiciFetchMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('falls back to needsUserId diagnosis when verifyToken hangs', async () => {
|
||||
vi.useFakeTimers();
|
||||
verifyTokenMock.mockImplementationOnce(() => new Promise(() => {}));
|
||||
undiciFetchMock.mockResolvedValue({
|
||||
text: async () => JSON.stringify({ success: false, message: 'missing New-Api-User' }),
|
||||
headers: {
|
||||
get: (name: string) => (name.toLowerCase() === 'content-type' ? 'application/json; charset=utf-8' : null),
|
||||
},
|
||||
});
|
||||
|
||||
const site = await db.insert(schema.sites).values({
|
||||
name: 'Needs User Id',
|
||||
url: 'https://needs-user-id.example.com',
|
||||
platform: 'new-api',
|
||||
}).returning().get();
|
||||
|
||||
const responsePromise = app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/accounts/verify-token',
|
||||
payload: {
|
||||
siteId: site.id,
|
||||
accessToken: 'slow-session-token',
|
||||
},
|
||||
});
|
||||
|
||||
await vi.advanceTimersByTimeAsync(10_100);
|
||||
const response = await responsePromise;
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toMatchObject({
|
||||
success: false,
|
||||
needsUserId: true,
|
||||
});
|
||||
expect(undiciFetchMock).toHaveBeenCalled();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('short-circuits to needsUserId before heavy verification when probe already confirms it', async () => {
|
||||
verifyTokenMock.mockResolvedValueOnce({
|
||||
tokenType: 'session',
|
||||
userInfo: { username: 'should-not-run' },
|
||||
balance: null,
|
||||
apiToken: null,
|
||||
});
|
||||
undiciFetchMock.mockResolvedValue({
|
||||
text: async () => JSON.stringify({ success: false, message: 'missing New-Api-User' }),
|
||||
headers: {
|
||||
get: (name: string) => (name.toLowerCase() === 'content-type' ? 'application/json; charset=utf-8' : null),
|
||||
},
|
||||
});
|
||||
|
||||
const site = await db.insert(schema.sites).values({
|
||||
name: 'Needs User Id Fast',
|
||||
url: 'https://needs-user-id-fast.example.com',
|
||||
platform: 'new-api',
|
||||
}).returning().get();
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/accounts/verify-token',
|
||||
payload: {
|
||||
siteId: site.id,
|
||||
accessToken: 'token-without-user-id',
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toMatchObject({
|
||||
success: false,
|
||||
needsUserId: true,
|
||||
});
|
||||
expect(verifyTokenMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns invalidUserId when the provided user id does not match the token', async () => {
|
||||
verifyTokenMock.mockResolvedValueOnce({ tokenType: 'unknown' });
|
||||
undiciFetchMock.mockResolvedValue({
|
||||
text: async () => JSON.stringify({ success: false, message: 'user id mismatch' }),
|
||||
headers: {
|
||||
get: (name: string) => (name.toLowerCase() === 'content-type' ? 'application/json; charset=utf-8' : null),
|
||||
},
|
||||
});
|
||||
|
||||
const site = await db.insert(schema.sites).values({
|
||||
name: 'Wrong User Id',
|
||||
url: 'https://wrong-user-id.example.com',
|
||||
platform: 'new-api',
|
||||
}).returning().get();
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/accounts/verify-token',
|
||||
payload: {
|
||||
siteId: site.id,
|
||||
accessToken: 'token-with-wrong-user-id',
|
||||
platformUserId: 2,
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toMatchObject({
|
||||
success: false,
|
||||
invalidUserId: true,
|
||||
});
|
||||
expect(response.json()).not.toMatchObject({
|
||||
needsUserId: true,
|
||||
});
|
||||
expect(undiciFetchMock).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -122,4 +122,69 @@ describe('settings database runtime config api', () => {
|
||||
expect(body.success).toBe(false);
|
||||
expect(body.message || '').toContain('SQLite');
|
||||
});
|
||||
|
||||
it('saves and loads ssl setting correctly', async () => {
|
||||
const putResponse = await app.inject({
|
||||
method: 'PUT',
|
||||
url: '/api/settings/database/runtime',
|
||||
payload: {
|
||||
dialect: 'mysql',
|
||||
connectionString: 'mysql://root:pass@tidb.example.com:4000/db',
|
||||
ssl: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(putResponse.statusCode).toBe(200);
|
||||
const putBody = putResponse.json() as {
|
||||
success?: boolean;
|
||||
saved?: { dialect?: string; ssl?: boolean };
|
||||
};
|
||||
expect(putBody.success).toBe(true);
|
||||
expect(putBody.saved?.ssl).toBe(true);
|
||||
|
||||
const dbSsl = await db.select().from(schema.settings).where(eq(schema.settings.key, 'db_ssl')).get();
|
||||
expect(dbSsl?.value).toBe(JSON.stringify(true));
|
||||
|
||||
const getResponse = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/settings/database/runtime',
|
||||
});
|
||||
const getBody = getResponse.json() as {
|
||||
success?: boolean;
|
||||
saved?: { ssl?: boolean };
|
||||
};
|
||||
expect(getBody.success).toBe(true);
|
||||
expect(getBody.saved?.ssl).toBe(true);
|
||||
});
|
||||
|
||||
it('defaults ssl to false when not provided', async () => {
|
||||
const putResponse = await app.inject({
|
||||
method: 'PUT',
|
||||
url: '/api/settings/database/runtime',
|
||||
payload: {
|
||||
dialect: 'postgres',
|
||||
connectionString: 'postgres://user:pass@db.example.com:5432/mydb',
|
||||
},
|
||||
});
|
||||
|
||||
expect(putResponse.statusCode).toBe(200);
|
||||
const putBody = putResponse.json() as {
|
||||
success?: boolean;
|
||||
saved?: { ssl?: boolean };
|
||||
};
|
||||
expect(putBody.success).toBe(true);
|
||||
expect(putBody.saved?.ssl).toBe(false);
|
||||
});
|
||||
|
||||
it('includes ssl in active runtime state', async () => {
|
||||
const response = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/settings/database/runtime',
|
||||
});
|
||||
|
||||
const body = response.json() as {
|
||||
active?: { ssl?: boolean };
|
||||
};
|
||||
expect(typeof body.active?.ssl).toBe('boolean');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -50,16 +50,19 @@ interface DatabaseMigrationBody {
|
||||
dialect?: unknown;
|
||||
connectionString?: unknown;
|
||||
overwrite?: unknown;
|
||||
ssl?: unknown;
|
||||
}
|
||||
|
||||
type RuntimeDatabaseConfig = {
|
||||
dialect: MigrationDialect;
|
||||
connectionString: string;
|
||||
ssl: boolean;
|
||||
};
|
||||
|
||||
const PROXY_TOKEN_PREFIX = 'sk-';
|
||||
const DB_TYPE_SETTING_KEY = 'db_type';
|
||||
const DB_URL_SETTING_KEY = 'db_url';
|
||||
const DB_SSL_SETTING_KEY = 'db_ssl';
|
||||
|
||||
function isValidProxyToken(value: string): boolean {
|
||||
return value.startsWith(PROXY_TOKEN_PREFIX) && value.length >= 6;
|
||||
@@ -316,6 +319,7 @@ async function loadSavedRuntimeDatabaseConfig(): Promise<RuntimeDatabaseConfig |
|
||||
const map = new Map(settingsRows.map((row) => [row.key, row.value]));
|
||||
const rawDialect = parseJsonValue(map.get(DB_TYPE_SETTING_KEY));
|
||||
const rawConnection = parseJsonValue(map.get(DB_URL_SETTING_KEY));
|
||||
const rawSsl = parseJsonValue(map.get(DB_SSL_SETTING_KEY));
|
||||
if (typeof rawDialect !== 'string' || typeof rawConnection !== 'string') {
|
||||
return null;
|
||||
}
|
||||
@@ -324,10 +328,12 @@ async function loadSavedRuntimeDatabaseConfig(): Promise<RuntimeDatabaseConfig |
|
||||
const normalized = normalizeMigrationInput({
|
||||
dialect: rawDialect,
|
||||
connectionString: rawConnection,
|
||||
ssl: rawSsl,
|
||||
});
|
||||
return {
|
||||
dialect: normalized.dialect,
|
||||
connectionString: normalized.connectionString,
|
||||
ssl: normalized.ssl,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
@@ -337,19 +343,24 @@ async function loadSavedRuntimeDatabaseConfig(): Promise<RuntimeDatabaseConfig |
|
||||
function buildRuntimeDatabaseState(saved: RuntimeDatabaseConfig | null) {
|
||||
const activeDialect = runtimeDbDialect;
|
||||
const activeConnection = (config.dbUrl || '').trim();
|
||||
const activeSsl = config.dbSsl;
|
||||
const restartRequired = !!saved && (
|
||||
saved.dialect !== activeDialect || saved.connectionString.trim() !== activeConnection
|
||||
saved.dialect !== activeDialect ||
|
||||
saved.connectionString.trim() !== activeConnection ||
|
||||
saved.ssl !== activeSsl
|
||||
);
|
||||
|
||||
return {
|
||||
active: {
|
||||
dialect: activeDialect,
|
||||
connection: maskRuntimeConnection(activeDialect, activeConnection),
|
||||
ssl: activeSsl,
|
||||
},
|
||||
saved: saved
|
||||
? {
|
||||
dialect: saved.dialect,
|
||||
connection: maskRuntimeConnection(saved.dialect, saved.connectionString),
|
||||
ssl: saved.ssl,
|
||||
}
|
||||
: null,
|
||||
restartRequired,
|
||||
@@ -690,16 +701,18 @@ export async function settingsRoutes(app: FastifyInstance) {
|
||||
const normalized = normalizeMigrationInput(request.body || {});
|
||||
await upsertSetting(DB_TYPE_SETTING_KEY, normalized.dialect);
|
||||
await upsertSetting(DB_URL_SETTING_KEY, normalized.connectionString);
|
||||
await upsertSetting(DB_SSL_SETTING_KEY, normalized.ssl);
|
||||
|
||||
await appendSettingsEvent({
|
||||
type: 'status',
|
||||
title: '数据库运行配置已更新',
|
||||
message: `已保存运行数据库配置:${normalized.dialect}(重启后生效)`,
|
||||
message: `已保存运行数据库配置:${normalized.dialect}${normalized.ssl ? ' (SSL)' : ''}(重启后生效)`,
|
||||
});
|
||||
|
||||
const saved: RuntimeDatabaseConfig = {
|
||||
dialect: normalized.dialect,
|
||||
connectionString: normalized.connectionString,
|
||||
ssl: normalized.ssl,
|
||||
};
|
||||
|
||||
return {
|
||||
|
||||
@@ -20,6 +20,21 @@ describe('accountHealthService', () => {
|
||||
expect(health.state).toBe('unhealthy');
|
||||
});
|
||||
|
||||
it('does not reuse expired session-token health for proxy-only accounts', () => {
|
||||
const health = buildRuntimeHealthForAccount({
|
||||
accountStatus: 'expired',
|
||||
siteStatus: 'active',
|
||||
extraConfig: null,
|
||||
sessionCapable: false,
|
||||
});
|
||||
|
||||
expect(health).toMatchObject({
|
||||
state: 'unknown',
|
||||
source: 'none',
|
||||
});
|
||||
expect(health.reason).not.toContain('访问令牌已过期');
|
||||
});
|
||||
|
||||
it('returns stored runtime health from extra config when available', () => {
|
||||
const health = buildRuntimeHealthForAccount({
|
||||
accountStatus: 'active',
|
||||
|
||||
@@ -86,6 +86,7 @@ export function buildRuntimeHealthForAccount(input: {
|
||||
accountStatus?: string | null;
|
||||
siteStatus?: string | null;
|
||||
extraConfig?: string | null;
|
||||
sessionCapable?: boolean;
|
||||
}): RuntimeHealthInfo {
|
||||
const accountStatus = (input.accountStatus || 'active').toLowerCase();
|
||||
const siteStatus = (input.siteStatus || 'active').toLowerCase();
|
||||
@@ -99,7 +100,7 @@ export function buildRuntimeHealthForAccount(input: {
|
||||
};
|
||||
}
|
||||
|
||||
if (accountStatus === 'expired') {
|
||||
if (accountStatus === 'expired' && input.sessionCapable !== false) {
|
||||
return {
|
||||
state: 'unhealthy',
|
||||
reason: '访问令牌已过期',
|
||||
|
||||
@@ -13,6 +13,7 @@ describe('databaseMigrationService', () => {
|
||||
dialect: 'postgres',
|
||||
connectionString: 'postgres://user:pass@db.example.com:5432/metapi',
|
||||
overwrite: true,
|
||||
ssl: false,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -24,6 +25,7 @@ describe('databaseMigrationService', () => {
|
||||
|
||||
expect(normalized.dialect).toBe('mysql');
|
||||
expect(normalized.overwrite).toBe(true);
|
||||
expect(normalized.ssl).toBe(false);
|
||||
});
|
||||
|
||||
it('accepts sqlite file migration target path', () => {
|
||||
@@ -37,6 +39,7 @@ describe('databaseMigrationService', () => {
|
||||
dialect: 'sqlite',
|
||||
connectionString: './data/target.db',
|
||||
overwrite: false,
|
||||
ssl: false,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -58,5 +61,40 @@ describe('databaseMigrationService', () => {
|
||||
const masked = maskConnectionString('postgres://admin:super-secret@db.example.com:5432/metapi');
|
||||
expect(masked).toBe('postgres://admin:***@db.example.com:5432/metapi');
|
||||
});
|
||||
|
||||
it('normalizes ssl boolean from input', () => {
|
||||
const normalized = normalizeMigrationInput({
|
||||
dialect: 'mysql',
|
||||
connectionString: 'mysql://user:pass@tidb.example.com:4000/db',
|
||||
ssl: true,
|
||||
});
|
||||
expect(normalized.ssl).toBe(true);
|
||||
});
|
||||
|
||||
it('defaults ssl to false when not provided', () => {
|
||||
const normalized = normalizeMigrationInput({
|
||||
dialect: 'postgres',
|
||||
connectionString: 'postgres://user:pass@db.example.com:5432/metapi',
|
||||
});
|
||||
expect(normalized.ssl).toBe(false);
|
||||
});
|
||||
|
||||
it('parses ssl from string values', () => {
|
||||
const normalized = normalizeMigrationInput({
|
||||
dialect: 'mysql',
|
||||
connectionString: 'mysql://user:pass@host:3306/db',
|
||||
ssl: '1',
|
||||
});
|
||||
expect(normalized.ssl).toBe(true);
|
||||
});
|
||||
|
||||
it('parses ssl false from string "0"', () => {
|
||||
const normalized = normalizeMigrationInput({
|
||||
dialect: 'mysql',
|
||||
connectionString: 'mysql://user:pass@host:3306/db',
|
||||
ssl: '0',
|
||||
});
|
||||
expect(normalized.ssl).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -11,12 +11,14 @@ export interface DatabaseMigrationInput {
|
||||
dialect?: unknown;
|
||||
connectionString?: unknown;
|
||||
overwrite?: unknown;
|
||||
ssl?: unknown;
|
||||
}
|
||||
|
||||
export interface NormalizedDatabaseMigrationInput {
|
||||
dialect: MigrationDialect;
|
||||
connectionString: string;
|
||||
overwrite: boolean;
|
||||
ssl: boolean;
|
||||
}
|
||||
|
||||
type BackupSnapshot = {
|
||||
@@ -173,6 +175,7 @@ export function normalizeMigrationInput(input: DatabaseMigrationInput): Normaliz
|
||||
dialect,
|
||||
connectionString,
|
||||
overwrite: input.overwrite === undefined ? true : asBoolean(input.overwrite, true),
|
||||
ssl: asBoolean(input.ssl, false),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -224,8 +227,12 @@ async function toBackupSnapshot(): Promise<BackupSnapshot> {
|
||||
};
|
||||
}
|
||||
|
||||
async function createPostgresClient(connectionString: string): Promise<SqlClient> {
|
||||
const client = new pg.Client({ connectionString });
|
||||
async function createPostgresClient(connectionString: string, ssl: boolean): Promise<SqlClient> {
|
||||
const clientOptions: pg.ClientConfig = { connectionString };
|
||||
if (ssl) {
|
||||
clientOptions.ssl = { rejectUnauthorized: false };
|
||||
}
|
||||
const client = new pg.Client(clientOptions);
|
||||
await client.connect();
|
||||
|
||||
return {
|
||||
@@ -244,8 +251,12 @@ async function createPostgresClient(connectionString: string): Promise<SqlClient
|
||||
};
|
||||
}
|
||||
|
||||
async function createMySqlClient(connectionString: string): Promise<SqlClient> {
|
||||
const connection = await mysql.createConnection(connectionString);
|
||||
async function createMySqlClient(connectionString: string, ssl: boolean): Promise<SqlClient> {
|
||||
const connectionOptions: mysql.ConnectionOptions = { uri: connectionString };
|
||||
if (ssl) {
|
||||
connectionOptions.ssl = { rejectUnauthorized: false };
|
||||
}
|
||||
const connection = await mysql.createConnection(connectionOptions);
|
||||
|
||||
return {
|
||||
dialect: 'mysql',
|
||||
@@ -291,8 +302,8 @@ async function createSqliteClient(connectionString: string): Promise<SqlClient>
|
||||
}
|
||||
|
||||
async function createClient(input: NormalizedDatabaseMigrationInput): Promise<SqlClient> {
|
||||
if (input.dialect === 'postgres') return createPostgresClient(input.connectionString);
|
||||
if (input.dialect === 'mysql') return createMySqlClient(input.connectionString);
|
||||
if (input.dialect === 'postgres') return createPostgresClient(input.connectionString, input.ssl);
|
||||
if (input.dialect === 'mysql') return createMySqlClient(input.connectionString, input.ssl);
|
||||
return createSqliteClient(input.connectionString);
|
||||
}
|
||||
|
||||
|
||||
+3
-3
@@ -203,17 +203,17 @@ export const api = {
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
getRuntimeDatabaseConfig: () => request('/api/settings/database/runtime'),
|
||||
updateRuntimeDatabaseConfig: (data: { dialect: 'sqlite' | 'mysql' | 'postgres'; connectionString: string }) =>
|
||||
updateRuntimeDatabaseConfig: (data: { dialect: 'sqlite' | 'mysql' | 'postgres'; connectionString: string; ssl?: boolean }) =>
|
||||
request('/api/settings/database/runtime', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
testExternalDatabaseConnection: (data: { dialect: 'sqlite' | 'mysql' | 'postgres'; connectionString: string; overwrite?: boolean }) =>
|
||||
testExternalDatabaseConnection: (data: { dialect: 'sqlite' | 'mysql' | 'postgres'; connectionString: string; ssl?: boolean }) =>
|
||||
request('/api/settings/database/test-connection', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
migrateExternalDatabase: (data: { dialect: 'sqlite' | 'mysql' | 'postgres'; connectionString: string; overwrite?: boolean }) =>
|
||||
migrateExternalDatabase: (data: { dialect: 'sqlite' | 'mysql' | 'postgres'; connectionString: string; overwrite?: boolean; ssl?: boolean }) =>
|
||||
request('/api/settings/database/migrate', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
|
||||
@@ -352,6 +352,7 @@ export const zhToEnSupplemental: Record<string, string> = {
|
||||
'其他': 'other',
|
||||
'其他未归类的模型': 'Other uncategorized models',
|
||||
'启用': 'enable',
|
||||
'启用 SSL/TLS 加密连接': 'Enable SSL/TLS encrypted connection',
|
||||
'启用站点后再试': 'Try again after enabling the site',
|
||||
'签到': 'Sign in',
|
||||
'签到 Cron': 'Sign in Cron',
|
||||
|
||||
+22
-12
@@ -5,6 +5,11 @@ import { useToast } from '../components/Toast.js';
|
||||
import ModernSelect from '../components/ModernSelect.js';
|
||||
import { useAnimatedVisibility } from '../components/useAnimatedVisibility.js';
|
||||
import { getAccountsAddPanelStyle } from './helpers/accountsPanelStyle.js';
|
||||
import {
|
||||
buildAddAccountPrereqHint,
|
||||
buildVerifyFailureHint,
|
||||
normalizeVerifyFailureMessage,
|
||||
} from './helpers/accountVerifyFeedback.js';
|
||||
import { clearFocusParams, readFocusAccountIntent } from './helpers/navigationFocus.js';
|
||||
import { tr } from '../i18n.js';
|
||||
import { buildCustomReorderUpdates, sortItemsForDisplay, type SortMode } from './helpers/listSorting.js';
|
||||
@@ -80,6 +85,8 @@ export default function Accounts() {
|
||||
() => sortItemsForDisplay(accounts, sortMode, (account) => account.balance || 0),
|
||||
[accounts, sortMode],
|
||||
);
|
||||
const verifyFailureHint = buildVerifyFailureHint(verifyResult);
|
||||
const addAccountPrereqHint = buildAddAccountPrereqHint(verifyResult);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
@@ -134,11 +141,11 @@ export default function Accounts() {
|
||||
toast.success(`Session 验证成功: ${result.userInfo?.username || '未知用户'}`);
|
||||
}
|
||||
} else {
|
||||
toast.error(result.message || 'Token 无效');
|
||||
toast.error(normalizeVerifyFailureMessage(result.message || 'Token 无效'));
|
||||
}
|
||||
} catch (e: any) {
|
||||
toast.error(e.message || '验证失败');
|
||||
setVerifyResult({ success: false, message: e.message });
|
||||
toast.error(normalizeVerifyFailureMessage(e?.message));
|
||||
setVerifyResult({ success: false, message: e?.message });
|
||||
} finally {
|
||||
setVerifying(false);
|
||||
}
|
||||
@@ -223,9 +230,10 @@ export default function Accounts() {
|
||||
};
|
||||
|
||||
const resolveRuntimeHealth = (account: any) => {
|
||||
const capabilities = resolveAccountCapabilities(account);
|
||||
const fallbackState = account.status === 'disabled' || account.site?.status === 'disabled'
|
||||
? 'disabled'
|
||||
: (account.status === 'expired' ? 'unhealthy' : 'unknown');
|
||||
: (!capabilities.proxyOnly && account.status === 'expired' ? 'unhealthy' : 'unknown');
|
||||
const state = account.runtimeHealth?.state || fallbackState;
|
||||
const cfg = runtimeHealthMap[state] || runtimeHealthMap.unknown;
|
||||
const reason = account.runtimeHealth?.reason
|
||||
@@ -378,11 +386,11 @@ export default function Accounts() {
|
||||
} else if (result.success && result.tokenType !== 'session') {
|
||||
toast.error('当前是 API Key,不是 Session Token');
|
||||
} else {
|
||||
toast.error(result.message || 'Token 无效');
|
||||
toast.error(normalizeVerifyFailureMessage(result.message || 'Token 无效'));
|
||||
}
|
||||
} catch (e: any) {
|
||||
toast.error(e.message || '验证失败');
|
||||
setRebindVerifyResult({ success: false, message: e.message });
|
||||
toast.error(normalizeVerifyFailureMessage(e?.message));
|
||||
setRebindVerifyResult({ success: false, message: e?.message });
|
||||
} finally {
|
||||
setRebindVerifying(false);
|
||||
}
|
||||
@@ -436,7 +444,7 @@ export default function Accounts() {
|
||||
setHighlightAccountId((current) => (current === accountId ? null : current));
|
||||
}, 2200);
|
||||
|
||||
if (openRebind && target.status === 'expired') {
|
||||
if (openRebind && target.status === 'expired' && !resolveAccountCapabilities(target).proxyOnly) {
|
||||
setShowAdd(false);
|
||||
if (!rebindTarget || rebindTarget.id !== target.id) {
|
||||
openRebindPanel(target);
|
||||
@@ -645,9 +653,11 @@ export default function Accounts() {
|
||||
{verifyResult && !verifyResult.success && !verifyResult.needsUserId && (
|
||||
<div className="alert alert-error animate-scale-in">
|
||||
<div className="alert-title">
|
||||
{verifyResult.message || 'Token 无效或已过期'}
|
||||
{normalizeVerifyFailureMessage(verifyResult.message) || 'Token 无效或已过期'}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginTop: 4 }}>
|
||||
{verifyFailureHint || '请检查 Token 是否正确'}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginTop: 4 }}>请检查 Token 是否正确</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -663,7 +673,7 @@ export default function Accounts() {
|
||||
</div>
|
||||
{!verifyResult?.success && (
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
|
||||
请先点击“验证 Token”,验证成功后才能添加账号
|
||||
{addAccountPrereqHint}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -949,7 +959,7 @@ export default function Accounts() {
|
||||
{actionLoading[`checkin-${a.id}`] ? <span className="spinner spinner-sm" /> : '签到'}
|
||||
</button>
|
||||
)}
|
||||
{a.status === 'expired' && (
|
||||
{a.status === 'expired' && !capabilities.proxyOnly && (
|
||||
<button
|
||||
onClick={() => openRebindPanel(a)}
|
||||
className="btn btn-link btn-link-warning"
|
||||
|
||||
@@ -85,10 +85,12 @@ type RuntimeDatabaseState = {
|
||||
active: {
|
||||
dialect: DbDialect;
|
||||
connection: string;
|
||||
ssl: boolean;
|
||||
};
|
||||
saved: {
|
||||
dialect: DbDialect;
|
||||
connection: string;
|
||||
ssl: boolean;
|
||||
} | null;
|
||||
restartRequired: boolean;
|
||||
};
|
||||
@@ -200,6 +202,7 @@ export default function Settings() {
|
||||
database: 'postgres',
|
||||
});
|
||||
const [migrationOverwrite, setMigrationOverwrite] = useState(true);
|
||||
const [migrationSsl, setMigrationSsl] = useState(false);
|
||||
const [testingMigrationConnection, setTestingMigrationConnection] = useState(false);
|
||||
const [migratingDatabase, setMigratingDatabase] = useState(false);
|
||||
const [savingRuntimeDatabase, setSavingRuntimeDatabase] = useState(false);
|
||||
@@ -391,11 +394,13 @@ export default function Settings() {
|
||||
active: {
|
||||
dialect: (runtimeDatabaseInfo?.active?.dialect || 'sqlite') as DbDialect,
|
||||
connection: String(runtimeDatabaseInfo?.active?.connection || ''),
|
||||
ssl: !!runtimeDatabaseInfo?.active?.ssl,
|
||||
},
|
||||
saved: runtimeDatabaseInfo?.saved
|
||||
? {
|
||||
dialect: runtimeDatabaseInfo.saved.dialect as DbDialect,
|
||||
connection: String(runtimeDatabaseInfo.saved.connection || ''),
|
||||
ssl: !!runtimeDatabaseInfo.saved.ssl,
|
||||
}
|
||||
: null,
|
||||
restartRequired: !!runtimeDatabaseInfo?.restartRequired,
|
||||
@@ -701,7 +706,7 @@ export default function Settings() {
|
||||
const res = await api.testExternalDatabaseConnection({
|
||||
dialect: migrationDialect,
|
||||
connectionString: effectiveMigrationConnectionString,
|
||||
overwrite: migrationOverwrite,
|
||||
ssl: migrationSsl,
|
||||
});
|
||||
toast.success(`Connection success: ${res.connection || migrationDialect}`);
|
||||
} catch (err: any) {
|
||||
@@ -734,6 +739,7 @@ export default function Settings() {
|
||||
dialect: migrationDialect,
|
||||
connectionString: effectiveMigrationConnectionString,
|
||||
overwrite: migrationOverwrite,
|
||||
ssl: migrationSsl,
|
||||
});
|
||||
setMigrationSummary(res);
|
||||
toast.success(res?.message || 'Database migration completed');
|
||||
@@ -761,16 +767,19 @@ export default function Settings() {
|
||||
const res = await api.updateRuntimeDatabaseConfig({
|
||||
dialect: migrationDialect,
|
||||
connectionString: effectiveMigrationConnectionString,
|
||||
ssl: migrationSsl,
|
||||
});
|
||||
setRuntimeDatabaseState({
|
||||
active: {
|
||||
dialect: (res?.active?.dialect || 'sqlite') as DbDialect,
|
||||
connection: String(res?.active?.connection || ''),
|
||||
ssl: !!res?.active?.ssl,
|
||||
},
|
||||
saved: res?.saved
|
||||
? {
|
||||
dialect: res.saved.dialect as DbDialect,
|
||||
connection: String(res.saved.connection || ''),
|
||||
ssl: !!res.saved.ssl,
|
||||
}
|
||||
: null,
|
||||
restartRequired: !!res?.restartRequired,
|
||||
@@ -1164,6 +1173,18 @@ export default function Settings() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{migrationDialect !== 'sqlite' && (
|
||||
<label style={{ display: 'inline-flex', alignItems: 'center', gap: 8, marginBottom: 12, fontSize: 12, color: 'var(--color-text-secondary)' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={migrationSsl}
|
||||
onChange={(e) => setMigrationSsl(e.target.checked)}
|
||||
style={{ width: 14, height: 14, accentColor: 'var(--color-primary)' }}
|
||||
/>
|
||||
启用 SSL/TLS 加密连接
|
||||
</label>
|
||||
)}
|
||||
|
||||
<label style={{ display: 'inline-flex', alignItems: 'center', gap: 8, marginBottom: 12, fontSize: 12, color: 'var(--color-text-secondary)' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -1202,11 +1223,11 @@ export default function Settings() {
|
||||
|
||||
{runtimeDatabaseState && (
|
||||
<div style={{ border: '1px solid var(--color-border-light)', borderRadius: 'var(--radius-sm)', padding: 10, fontSize: 12, color: 'var(--color-text-secondary)', lineHeight: 1.8, marginBottom: migrationSummary ? 12 : 0 }}>
|
||||
<div>当前运行:{runtimeDatabaseState.active.dialect}({runtimeDatabaseState.active.connection || '(empty)' })</div>
|
||||
<div>当前运行:{runtimeDatabaseState.active.dialect}({runtimeDatabaseState.active.connection || '(empty)' }){runtimeDatabaseState.active.ssl && ' [SSL]'}</div>
|
||||
<div>
|
||||
已保存待生效:
|
||||
{runtimeDatabaseState.saved
|
||||
? ` ${runtimeDatabaseState.saved.dialect}(${runtimeDatabaseState.saved.connection})`
|
||||
? ` ${runtimeDatabaseState.saved.dialect}(${runtimeDatabaseState.saved.connection})${runtimeDatabaseState.saved.ssl ? ' [SSL]' : ''}`
|
||||
: ' 未保存'}
|
||||
</div>
|
||||
{runtimeDatabaseState.restartRequired && (
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { act, create, type ReactTestInstance } from 'react-test-renderer';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { ToastProvider } from '../components/Toast.js';
|
||||
import Accounts from './Accounts.js';
|
||||
|
||||
const { apiMock } = vi.hoisted(() => ({
|
||||
apiMock: {
|
||||
getAccounts: vi.fn(),
|
||||
getSites: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../api.js', () => ({
|
||||
api: apiMock,
|
||||
}));
|
||||
|
||||
function collectText(node: ReactTestInstance): string {
|
||||
const children = node.children || [];
|
||||
return children.map((child) => {
|
||||
if (typeof child === 'string') return child;
|
||||
return collectText(child);
|
||||
}).join('');
|
||||
}
|
||||
|
||||
async function flushMicrotasks() {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
describe('Accounts proxy-only expired state', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('does not show session-expired health copy or rebind action for proxy-only accounts', async () => {
|
||||
apiMock.getAccounts.mockResolvedValue([
|
||||
{
|
||||
id: 1,
|
||||
username: '',
|
||||
balance: 0,
|
||||
balanceUsed: 0,
|
||||
todayReward: 0,
|
||||
todaySpend: 0,
|
||||
accessToken: 'api-key-only-token',
|
||||
status: 'expired',
|
||||
checkinEnabled: false,
|
||||
capabilities: {
|
||||
canCheckin: false,
|
||||
canRefreshBalance: false,
|
||||
proxyOnly: true,
|
||||
},
|
||||
siteId: 10,
|
||||
site: { id: 10, name: '小呆api', status: 'active', url: 'https://example.com' },
|
||||
},
|
||||
]);
|
||||
apiMock.getSites.mockResolvedValue([{ id: 10, name: '小呆api', platform: 'new-api', status: 'active' }]);
|
||||
|
||||
let root: ReturnType<typeof create> | null = null;
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MemoryRouter initialEntries={['/accounts']}>
|
||||
<ToastProvider>
|
||||
<Accounts />
|
||||
</ToastProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const rendered = JSON.stringify(root.toJSON());
|
||||
expect(rendered).toContain('仅代理');
|
||||
expect(rendered).not.toContain('访问令牌已过期');
|
||||
|
||||
const actionTexts = root.root.findAll((node) => node.type === 'button').map((node) => collectText(node));
|
||||
expect(actionTexts).not.toContain('重新绑定');
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { act, create, type ReactTestInstance } from 'react-test-renderer';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { ToastProvider } from '../components/Toast.js';
|
||||
import ModernSelect from '../components/ModernSelect.js';
|
||||
import Accounts from './Accounts.js';
|
||||
import {
|
||||
buildAddAccountPrereqHint,
|
||||
buildVerifyFailureHint,
|
||||
normalizeVerifyFailureMessage,
|
||||
} from './helpers/accountVerifyFeedback.js';
|
||||
|
||||
const { apiMock } = vi.hoisted(() => ({
|
||||
apiMock: {
|
||||
getAccounts: vi.fn(),
|
||||
getSites: vi.fn(),
|
||||
verifyToken: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../api.js', () => ({
|
||||
api: apiMock,
|
||||
}));
|
||||
|
||||
function collectText(node: ReactTestInstance): string {
|
||||
const children = node.children || [];
|
||||
return children.map((child) => {
|
||||
if (typeof child === 'string') return child;
|
||||
return collectText(child);
|
||||
}).join('');
|
||||
}
|
||||
|
||||
async function flushMicrotasks() {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
describe('Accounts verify feedback', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('shows connectivity guidance instead of blaming the token when verification request fails', async () => {
|
||||
apiMock.getAccounts.mockResolvedValue([]);
|
||||
apiMock.getSites.mockResolvedValue([
|
||||
{ id: 10, name: 'Demo Site', platform: 'new-api', status: 'active' },
|
||||
]);
|
||||
apiMock.verifyToken.mockRejectedValueOnce(new Error('Failed to fetch'));
|
||||
|
||||
let root: ReturnType<typeof create> | null = null;
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MemoryRouter initialEntries={['/accounts']}>
|
||||
<ToastProvider>
|
||||
<Accounts />
|
||||
</ToastProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const addButton = root.root.find((node) => (
|
||||
node.type === 'button'
|
||||
&& typeof node.props.onClick === 'function'
|
||||
&& typeof node.props.className === 'string'
|
||||
&& node.props.className.includes('btn btn-primary')
|
||||
));
|
||||
|
||||
await act(async () => {
|
||||
addButton.props.onClick();
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const selects = root.root.findAllByType(ModernSelect);
|
||||
expect(selects.length).toBeGreaterThan(1);
|
||||
|
||||
await act(async () => {
|
||||
selects[1]!.props.onChange('10');
|
||||
});
|
||||
|
||||
const textareas = root.root.findAll((node) => node.type === 'textarea');
|
||||
expect(textareas.length).toBeGreaterThan(0);
|
||||
|
||||
await act(async () => {
|
||||
textareas[0]!.props.onChange({ target: { value: 'demo-token' } });
|
||||
});
|
||||
|
||||
const verifyButton = root.root.find((node) => (
|
||||
node.type === 'button'
|
||||
&& typeof node.props.onClick === 'function'
|
||||
&& typeof node.props.className === 'string'
|
||||
&& node.props.className.includes('btn btn-ghost')
|
||||
&& collectText(node).includes('Token')
|
||||
));
|
||||
|
||||
await act(async () => {
|
||||
await verifyButton.props.onClick();
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const rendered = JSON.stringify(root.toJSON());
|
||||
expect(rendered).toContain(normalizeVerifyFailureMessage('Failed to fetch'));
|
||||
expect(rendered).toContain(buildVerifyFailureHint({ success: false, message: 'Failed to fetch' })!);
|
||||
expect(rendered).toContain(buildAddAccountPrereqHint({ success: false, message: 'Failed to fetch' }));
|
||||
expect(rendered).not.toContain('请检查 Token 是否正确');
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { act, create, type ReactTestInstance } from 'react-test-renderer';
|
||||
import { ToastProvider } from '../components/Toast.js';
|
||||
import Dashboard from './Dashboard.js';
|
||||
|
||||
const { apiMock } = vi.hoisted(() => ({
|
||||
apiMock: {
|
||||
getDashboard: vi.fn(),
|
||||
getSiteDistribution: vi.fn(),
|
||||
getSiteTrend: vi.fn(),
|
||||
getSites: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../api.js', () => ({
|
||||
api: apiMock,
|
||||
}));
|
||||
|
||||
function collectText(node: ReactTestInstance): string {
|
||||
return (node.children || []).map((child) => {
|
||||
if (typeof child === 'string') return child;
|
||||
return collectText(child);
|
||||
}).join('');
|
||||
}
|
||||
|
||||
async function flushMicrotasks() {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
describe('Dashboard site speed buttons', () => {
|
||||
const originalDocument = globalThis.document;
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
apiMock.getDashboard.mockResolvedValue({
|
||||
totalBalance: 0,
|
||||
totalUsed: 0,
|
||||
todaySpend: 0,
|
||||
todayReward: 0,
|
||||
activeAccounts: 0,
|
||||
totalAccounts: 0,
|
||||
todayCheckin: { success: 0, total: 0 },
|
||||
proxy24h: { success: 0, total: 0, totalTokens: 0 },
|
||||
modelAnalysis: null,
|
||||
});
|
||||
apiMock.getSiteDistribution.mockResolvedValue({ distribution: [] });
|
||||
apiMock.getSiteTrend.mockResolvedValue({ trend: [] });
|
||||
apiMock.getSites.mockResolvedValue([
|
||||
{ id: 1, name: 'Demo Site', url: 'https://example.com', status: 'active' },
|
||||
]);
|
||||
|
||||
globalThis.document = {
|
||||
visibilityState: 'visible',
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
getElementById: vi.fn(() => null),
|
||||
} as unknown as Document;
|
||||
globalThis.fetch = vi.fn().mockResolvedValue({ ok: true }) as unknown as typeof fetch;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
globalThis.document = originalDocument;
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
it('updates site speed status without imperative document lookups', async () => {
|
||||
let root: ReturnType<typeof create> | null = null;
|
||||
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<ToastProvider>
|
||||
<Dashboard />
|
||||
</ToastProvider>,
|
||||
);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const speedButton = root!.root.find((node) => (
|
||||
node.type === 'button'
|
||||
&& typeof node.props.onClick === 'function'
|
||||
&& collectText(node).includes('娴嬮€?')
|
||||
));
|
||||
|
||||
await act(async () => {
|
||||
await speedButton.props.onClick();
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(globalThis.document.getElementById).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
buildAddAccountPrereqHint,
|
||||
buildVerifyFailureHint,
|
||||
normalizeVerifyFailureMessage,
|
||||
} from './accountVerifyFeedback.js';
|
||||
|
||||
describe('account verify feedback', () => {
|
||||
it('treats fetch failures as connectivity issues instead of token mistakes', () => {
|
||||
expect(normalizeVerifyFailureMessage('Failed to fetch')).not.toBe('Failed to fetch');
|
||||
expect(buildVerifyFailureHint({ success: false, message: 'Failed to fetch' })).not.toBeNull();
|
||||
expect(buildAddAccountPrereqHint({ success: false, message: 'Failed to fetch' })).toMatch(/metapi|站点|代理/i);
|
||||
});
|
||||
|
||||
it('treats timeout failures as reachability issues instead of token mistakes', () => {
|
||||
const timeoutMessage = 'Token verification timed out (10s)';
|
||||
expect(normalizeVerifyFailureMessage(timeoutMessage)).toBe(timeoutMessage);
|
||||
expect(buildVerifyFailureHint({ success: false, message: timeoutMessage })).toMatch(/Token|站点|代理|超时/i);
|
||||
expect(buildAddAccountPrereqHint({ success: false, message: timeoutMessage })).toMatch(/站点|代理|超时/i);
|
||||
});
|
||||
|
||||
it('keeps token guidance for actual credential failures', () => {
|
||||
const invalidHint = buildVerifyFailureHint({ success: false, message: 'Token invalid' });
|
||||
expect(invalidHint).toMatch(/Token/i);
|
||||
expect(buildAddAccountPrereqHint({ success: false, message: 'Token invalid' })).toMatch(/Token/i);
|
||||
});
|
||||
|
||||
it('normalizes invalid user-id mismatch messages for display', () => {
|
||||
expect(normalizeVerifyFailureMessage('The provided user ID does not match this token. Please check your site user ID.'))
|
||||
.toMatch(/ID/i);
|
||||
expect(normalizeVerifyFailureMessage('The provided user ID does not match this token. Please check your site user ID.'))
|
||||
.not.toContain('does not match this token');
|
||||
});
|
||||
|
||||
it('prioritizes user-id guidance when the site requires it', () => {
|
||||
expect(buildVerifyFailureHint({ success: false, needsUserId: true, message: 'missing New-Api-User' })).toBeNull();
|
||||
expect(buildAddAccountPrereqHint({ success: false, needsUserId: true, message: 'missing New-Api-User' })).toMatch(/ID/i);
|
||||
});
|
||||
|
||||
it('treats invalid user-id as a mismatch instead of a missing-value hint', () => {
|
||||
const invalidUserIdHint = buildVerifyFailureHint({ success: false, invalidUserId: true, message: 'user id mismatch' });
|
||||
const genericTokenHint = buildVerifyFailureHint({ success: false, message: 'Token invalid' });
|
||||
const invalidUserIdPrereq = buildAddAccountPrereqHint({ success: false, invalidUserId: true, message: 'user id mismatch' });
|
||||
const genericPrereq = buildAddAccountPrereqHint({ success: false, message: 'Token invalid' });
|
||||
|
||||
expect(invalidUserIdHint).toMatch(/ID/i);
|
||||
expect(invalidUserIdHint).not.toBe(genericTokenHint);
|
||||
expect(invalidUserIdPrereq).toMatch(/ID/i);
|
||||
expect(invalidUserIdPrereq).not.toBe(genericPrereq);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
type VerifyResultLike = {
|
||||
success?: boolean;
|
||||
needsUserId?: boolean;
|
||||
invalidUserId?: boolean;
|
||||
message?: string | null;
|
||||
} | null | undefined;
|
||||
|
||||
function normalizeMessageText(message: unknown): string {
|
||||
return typeof message === 'string' ? message.trim() : '';
|
||||
}
|
||||
|
||||
export function isNetworkFailureMessage(message: unknown): boolean {
|
||||
const lowered = normalizeMessageText(message).toLowerCase();
|
||||
return lowered.includes('failed to fetch')
|
||||
|| lowered.includes('fetch failed')
|
||||
|| lowered.includes('networkerror')
|
||||
|| lowered.includes('load failed');
|
||||
}
|
||||
|
||||
export function isTimeoutFailureMessage(message: unknown): boolean {
|
||||
const lowered = normalizeMessageText(message).toLowerCase();
|
||||
return lowered.includes('timed out')
|
||||
|| lowered.includes('timeout')
|
||||
|| lowered.includes('请求超时');
|
||||
}
|
||||
|
||||
export function normalizeVerifyFailureMessage(message: unknown): string {
|
||||
const text = normalizeMessageText(message);
|
||||
if (!text) return '验证失败';
|
||||
const lowered = text.toLowerCase();
|
||||
if (isNetworkFailureMessage(text)) {
|
||||
return '无法连接到 metapi 服务端,请检查服务状态或网络连接';
|
||||
}
|
||||
if (lowered.includes('user id mismatch') || lowered.includes('does not match this token')) {
|
||||
return '填写的用户 ID 与当前 Token / Cookie 不匹配';
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
export function buildVerifyFailureHint(result: VerifyResultLike): string | null {
|
||||
if (!result || result.success || result.needsUserId) return null;
|
||||
if (result.invalidUserId) {
|
||||
return '这不是 Token 错误判断。请检查填写的用户 ID 是否与当前 Token / Cookie 属于同一账号。';
|
||||
}
|
||||
if (isNetworkFailureMessage(result.message)) {
|
||||
return '这不是 Token 错误判断。请检查 metapi 服务是否在线,以及目标站点或代理是否可达。';
|
||||
}
|
||||
if (isTimeoutFailureMessage(result.message)) {
|
||||
return '这不是 Token 错误判断。目标站点响应超时,请稍后重试或检查代理/网络。';
|
||||
}
|
||||
return '请检查 Token 是否正确';
|
||||
}
|
||||
|
||||
export function buildAddAccountPrereqHint(result: VerifyResultLike): string {
|
||||
if (!result) {
|
||||
return '请先点击“验证 Token”,验证成功后才能添加账号。';
|
||||
}
|
||||
if (result.success) return '';
|
||||
if (result.needsUserId) {
|
||||
return '请先补充用户 ID 并重新验证,验证成功后才能添加账号。';
|
||||
}
|
||||
if (result.invalidUserId) {
|
||||
return '请先修正用户 ID 并重新验证,验证成功后才能添加账号。';
|
||||
}
|
||||
if (isNetworkFailureMessage(result.message)) {
|
||||
return '验证请求未成功完成,请先检查 metapi 服务、站点网络或代理配置。';
|
||||
}
|
||||
if (isTimeoutFailureMessage(result.message)) {
|
||||
return '验证请求超时,请先检查站点或代理连通性后再添加账号。';
|
||||
}
|
||||
return '请先点击“验证 Token”,验证成功后才能添加账号。';
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"outDir": "dist/desktop",
|
||||
"rootDir": "src/desktop",
|
||||
"lib": ["ES2022", "DOM"],
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/desktop/**/*.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user