1
0
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:
cita
2026-03-06 16:24:41 +08:00
parent acdc846b13
commit 2a810facb8
45 changed files with 6712 additions and 405 deletions
+22 -12
View File
@@ -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"
+24 -3
View File
@@ -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”,验证成功后才能添加账号。';
}