Compare commits

..

27 Commits

Author SHA1 Message Date
手瓜一十雪
896e1c209a Disable plugin upload feature
Comment out the backend import route and corresponding frontend upload UI to disable plugin uploads. Backend: commented out POST /Import route in packages/napcat-webui-backend/src/router/Plugin.ts. Frontend: removed (commented) the upload Button and hidden file input in packages/napcat-webui-frontend/src/pages/dashboard/plugin.tsx. This temporarily prevents users from uploading plugins.
2026-02-22 13:15:42 +08:00
手瓜一十雪
cff07c7ce5 Revise README for new maintainer notice
Some checks failed
Build NapCat Artifacts / Build-Framework (push) Has been cancelled
Build NapCat Artifacts / Build-Shell (push) Has been cancelled
Updated the README to replace the 'New Feature' section with a 'Notice' about seeking new maintainers and removed outdated information.

Signed-off-by: 手瓜一十雪 <nanaeonn@outlook.com>
2026-02-21 19:22:07 +08:00
手瓜一十雪
5c04b799a6 feat: 提高超时检测等待 2026-02-21 16:21:31 +08:00
ud2
1fc4655ae1 refactor(onebot): 精简 WebSocket 适配器实现 (#1644)
* refactor(onebot): 移除 `async-mutex` 依赖

* fix(onebot): 避免重复发送 WebSocket pong
2026-02-21 15:59:12 +08:00
Eric-Terminal
eb07cdb715 feat: 自动登录失败后回退密码登录并补充独立配置 (#1638)
* feat: 自动登录失败后回退密码登录并补充独立配置

改动文件:
- packages/napcat-webui-backend/src/helper/config.ts
- packages/napcat-webui-backend/src/utils/auto_login.ts
- packages/napcat-webui-backend/src/utils/auto_login_config.ts
- packages/napcat-webui-backend/index.ts
- packages/napcat-webui-backend/src/api/QQLogin.ts
- packages/napcat-webui-backend/src/router/QQLogin.ts
- packages/napcat-webui-frontend/src/controllers/qq_manager.ts
- packages/napcat-webui-frontend/src/pages/dashboard/config/login.tsx
- packages/napcat-test/autoPasswordFallback.test.ts

目的:
- 在启动阶段将自动登录流程从“仅快速登录”扩展为“快速登录失败后自动回退密码登录”,并保持二维码兜底。
- 在 WebUI 登录配置页新增独立的自动回退账号/密码配置,密码仅提交与存储 MD5,不回显明文。

效果:
- 后端配置新增 autoPasswordLoginAccount 与 autoPasswordLoginPasswordMd5 字段,并提供读取、更新(空密码不覆盖)和清空能力。
- 新增 QQLogin API:GetAutoPasswordLoginConfig / SetAutoPasswordLoginConfig / ClearAutoPasswordLoginConfig。
- WebUI 登录配置页新增自动回退密码登录区块,支持保存、刷新、清空及“留空不修改密码”交互。
- 新增自动登录回退逻辑单测与配置补丁构造单测,覆盖快速成功、回退成功、回退失败、无密码兜底等场景。

* feat: 精简为环境变量驱动的快速登录失败密码回退

改动目的:
- 按维护者建议将方案收敛为后端环境变量驱动,不新增 WebUI 配置与路由
- 保留“快速登录失败 -> 密码回退 -> 二维码兜底”核心能力
- 兼容快速启动参数场景,降低评审复杂度

主要改动文件:
- packages/napcat-webui-backend/index.ts
- packages/napcat-shell/base.ts
- packages/napcat-webui-backend/src/api/QQLogin.ts
- packages/napcat-webui-backend/src/helper/config.ts
- packages/napcat-webui-backend/src/router/QQLogin.ts
- packages/napcat-webui-frontend/src/controllers/qq_manager.ts
- packages/napcat-webui-frontend/src/pages/dashboard/config/login.tsx
- 删除:packages/napcat-webui-backend/src/utils/auto_login.ts
- 删除:packages/napcat-webui-backend/src/utils/auto_login_config.ts
- 删除:packages/napcat-test/autoPasswordFallback.test.ts

实现细节:
1. WebUI 启动自动登录链路
- 保留 NAPCAT_QUICK_ACCOUNT 优先逻辑
- 快速登录失败后触发密码回退
- 回退密码来源优先级:
  a) NAPCAT_QUICK_PASSWORD_MD5(32 位 MD5)
  b) NAPCAT_QUICK_PASSWORD(运行时自动计算 MD5)
- 未配置回退密码时保持二维码兜底,并输出带 QQ 号的引导日志

2. Shell 快速登录链路
- quickLoginWithUin 失败判定统一基于 result 码 + errMsg
- 覆盖历史账号不存在、凭证失效、快速登录异常等场景
- 失败后统一进入同一密码回退逻辑,再兜底二维码

3. 文案与可运维性
- 日志明确推荐优先使用 ACCOUNT + NAPCAT_QUICK_PASSWORD
- NAPCAT_QUICK_PASSWORD_MD5 作为备用方式

效果:
- 满足自动回退登录需求,且改动面显著缩小
- 不修改 napcat-docker 仓库代码,直接兼容现有容器启动参数
- 便于上游快速审阅与合并

* fix: 修复 napcat-framework 未使用变量导致的 CI typecheck 失败

改动文件:
- packages/napcat-framework/napcat.ts

问题背景:
- 上游代码中声明了变量 bypassEnabled,但后续未使用
- 在 CI 的全量 TypeScript 检查中触发 TS6133(声明但未读取)
- 导致 PR Build 机器人评论显示构建失败(Type check failed)

具体修复:
- 将以下语句从“赋值后未使用”改为“直接调用”
- 原:const bypassEnabled = napi2nativeLoader.nativeExports.enableAllBypasses?.(bypassOptions);
- 现:napi2nativeLoader.nativeExports.enableAllBypasses?.(bypassOptions);

影响与效果:
- 不改变运行时行为(仍会执行 enableAllBypasses)
- 消除 TS6133 报错,恢复 typecheck 可通过

本地验证:
- pnpm run typecheck:通过
- pnpm run build:framework:通过
- pnpm run build:shell:通过

---------

Co-authored-by: 手瓜一十雪 <nanaeonn@outlook.com>
2026-02-21 14:18:34 +08:00
手瓜一十雪
964fd98914 Add spinner during captcha verification
Show a loading spinner and message while captcha is being verified. Imported Spinner and added an optional captchaVerifying prop to PasswordLogin to toggle between the TencentCaptchaModal and a waiting state. In qq_login.tsx introduced captchaVerifying state, set it true before the captcha login request and reset it in finally, and passed the prop down to the PasswordLogin component.
2026-02-21 13:39:35 +08:00
手瓜一十雪
f9764c9559 Improve new-device QR handling and bypass init
Refactor new-device QR flow and streamline bypass init:

- napcat-shell: stop verbose logging and removed check of enableAllBypasses return value; just invoke native enableAllBypasses when not disabled by env.
- backend (QQLogin): simplify extraction of tokens from jumpUrl (use sig and uin-token), return an error if missing, and send oidbRequest directly (removed nested try/catch and regex fallback).
- frontend (new_device_verify): accept result.str_url without requiring bytes_token and pass an empty string to polling when bytes_token is absent.
- frontend (password_login): change render order to show captcha modal before new-device verification UI.
- frontend (qq_manager): normalize GetNewDeviceQRCode response — derive bytes_token from str_url's str_url query param (base64) when bytes_token is missing, and preserve extra status/error fields in the returned object.

These changes improve robustness when OIDB responses omit bytes_token, reduce noisy logs, and ensure the UI and polling still function.
2026-02-21 13:24:56 +08:00
手瓜一十雪
b71a4913eb Add captcha & new-device QQ login flows
Introduce multi-step QQ password login support (captcha and new-device verification) and related OIDB QR handling.

- Change login signature fields in NodeIKernelLoginService to binary (Uint8Array) and add unusualDeviceCheckSig.
- Update shell base to handle additional result codes (captcha required, new-device, abnormal-device), set login status on success, and register three callbacks: captcha, new-device, and password flows. Use TextEncoder for encoding ticket/randstr/sid and newDevicePullQrCodeSig.
- Extend backend WebUiDataRuntime (types and runtime) with set/request methods for captcha and new-device login calls and adjust LoginRuntime types to return richer metadata (needCaptcha, proofWaterUrl, needNewDevice, jumpUrl, newDevicePullQrCodeSig).
- Add backend API handlers: CaptchaLogin, NewDeviceLogin, GetNewDeviceQRCode and PollNewDeviceQR; add oidbRequest helper using https to query oidb.tim.qq.com for QR generation and polling.
- Wire new handlers into QQLogin router and return structured success responses when further steps are required.
- Add frontend components and pages for captcha and new-device verification (new files: 1.html, new_device_verify.tsx, tencent_captcha.tsx) and update existing frontend controllers/pages to integrate the new flows.
- Improve error logging and user-facing messages for the new flows.

This change enables handling of password-login scenarios requiring captcha or device attestation and provides endpoints to obtain and poll OIDB QR codes for new-device verification.
2026-02-21 13:03:40 +08:00
手瓜一十雪
f961830836 Inline plugin icon helper into PluginCard
Some checks failed
Build NapCat Artifacts / Build-Framework (push) Has been cancelled
Build NapCat Artifacts / Build-Shell (push) Has been cancelled
Move getPluginIconUrl from utils/plugin_icon.ts into plugin_card.tsx and remove the now-unused util file. The function behavior is unchanged: it reads webui_token from localStorage and appends it as a query parameter to plugin icon URLs so authenticated icon endpoints can be used as img src.
2026-02-20 23:36:45 +08:00
手瓜一十雪
dd8b5f84a6 Simplify plugin avatar logic
Remove getAuthorAvatar and related homepage/repository parsing. Rely on getPluginIconUrl(icon) (backend-provided URL with token) and fall back to Vercel avatar. Update prop destructuring to drop homepage/repository and streamline avatar selection, removing fragile favicon/GitHub parsing logic.
2026-02-20 23:35:49 +08:00
手瓜一十雪
48ffd5597a Add plugin icon support and caching
Introduce support for plugin icons across backend and frontend. Updates include:

- napcat-onebot: add optional `icon` field to PluginPackageJson.
- Backend (api/Plugin, PluginStore, router): add handlers/utilities to locate and serve plugin icons (`GetPluginIconHandler`, getPluginIconUrl, findPluginIconPath) and wire the route `/api/Plugin/Icon/:pluginId`.
- Cache logic: implement `cachePluginIcon` to fetch GitHub user avatars and store as `data/icon.png` when package.json lacks an icon; invoked after plugin install (regular and SSE flows).
- Frontend: add `icon` to PluginItem, prefer backend-provided icon URL in plugin card (via new getPluginIconUrl util that appends webui_token query param), and add the util to handle token-based image requests.
- Plugin store UI: add a Random category (shuffled), client-side pagination, and reset page on tab/search changes.

These changes let the UI display plugin icons (falling back to author/avatar or Vercel avatars) and cache icons for better UX, while handling auth by passing the token as a query parameter for img src requests.
2026-02-20 23:32:57 +08:00
手瓜一十雪
1b73d68cbf Refactor extension tabs layout and styles
Remove the overflow wrapper around extension tabs and move max-w/full styling to the Tabs component. Simplify classNames (tabList, cursor) and clean up shrinking/truncation classes on tab items and plugin name to improve responsiveness and layout, while preserving the openInNewWindow click behavior.
2026-02-20 23:05:20 +08:00
手瓜一十雪
5fec649425 Load napcat.json, use o3HookMode & bypass
Add loadNapcatConfig helper that reads napcat.json (json5), validates with Ajv and fills defaults for early pre-login use. Change NativePacketHandler.init signature to accept an o3HookMode flag and forward it to native initHook. Update framework and shell to use loadNapcatConfig (remove duplicated file-reading logic) and pass configured o3HookMode and bypass options into Napi2NativeLoader/native packet initialization. Clean up imports (Ajv, path, fs, json5) and remove the old loadBypassConfig helper.
2026-02-20 21:49:19 +08:00
手瓜一十雪
052e7fa2b3 Init nativePacketHandler after loading wrapper && fix #1633
Delay initialization of nativePacketHandler until after loadQQWrapper so that wrapper.node is loaded before initializing hooks. Moved the await nativePacketHandler.init(...) call below loadQQWrapper and added an explanatory comment to ensure native hooks are set up only after the native module is available.
2026-02-20 21:39:50 +08:00
叫我饼干ちゃん
04e425d17a update:汉化 (#1641) 2026-02-20 20:31:36 +08:00
Rinne
cbe0506577 fix(webui): properly center plus icon in add button row (#1642)
Add `flex items-center justify-center` to the plus icon wrapper in `AddButton` so the icon is vertically and horizontally centered. This improves visual alignment and keeps the “新建网络配置” button content consistent.
2026-02-20 20:31:22 +08:00
手瓜一十雪
32ec097f51 Update native binaries for napi2native
Rebuild native artifacts: updated ffmpeg.dll and napi2native Node addons for linux (arm64, x64) and win32 x64. These are binary-only updates (no source changes), likely to refresh builds for compatibility or toolchain/dependency updates.
2026-02-20 19:56:24 +08:00
手瓜一十雪
53f27ea9e2 Refactor Bypass layout and UI text
Some checks failed
Build NapCat Artifacts / Build-Framework (push) Has been cancelled
Build NapCat Artifacts / Build-Shell (push) Has been cancelled
Wrap Bypass SwitchCard controls in a responsive grid for better layout and spacing. Rename the Config tab title from 'Bypass配置' to '反检测'. Clean up WebUI button/label text by removing emoji prefixes (removed 📥, 🔐, ) for a cleaner UI. Files changed: bypass.tsx (layout), index.tsx (tab title), webui.tsx (text cleanup).
2026-02-20 17:11:58 +08:00
手瓜一十雪
41d94cd5e2 Refactor bypass defaults and crash handling
Set bypass defaults to disabled and simplify loading: napcat.json default bypass flags changed to false and code now reads bypass options without merging a prior "all enabled" default. Removed the progressive bypass-disable logic and related environment variable usage, and added a log when Napi2NativeLoader enables bypasses. Web UI/backend adjustments: default NapCat config is now generated from the AJV schema; the bypass settings UI defaults to false, adds an o3HookMode toggle, and submits o3HookMode as 0/1. UX fixes: extension tabs made horizontally scrollable with fixed tab sizing, and plugin uninstall flow updated to a single confirmation dialog with an optional checkbox to remove plugin config. Overall changes aim to use safer defaults, simplify crash/restart behavior, and improve configuration and UI clarity.
2026-02-20 16:36:16 +08:00
手瓜一十雪
285d352bc8 Downgrade json5 in napcat-framework
Some checks failed
Build NapCat Artifacts / Build-Framework (push) Has been cancelled
Build NapCat Artifacts / Build-Shell (push) Has been cancelled
Update packages/napcat-framework/package.json to use json5@^2.2.3 (was ^3.2.2). This change pins json5 to the v2 line, likely for compatibility with other workspace packages or tooling that require the older major version.
2026-02-18 22:21:55 +08:00
手瓜一十雪
a3b3836b8a Add process bypass option and update bypass fields
Introduce a new 'process' bypass option and remove the old 'maps' key, updating all related schemas, types, defaults and validation. Adjusted napcat.json ordering and NapcatConfig defaults, updated BypassOptions schema and TypeScript interfaces, backend validation keys, loader/default parsing logic, and the web UI form mappings/labels to reflect the new field set and ordering.
2026-02-18 22:14:31 +08:00
手瓜一十雪
b9f61cc0ee Add configurable bypass options and UI
Introduce granular "bypass" configuration to control Napi2Native bypass features and expose it in the WebUI.

Key changes:
- Add bypass defaults to packages/napcat-core/external/napcat.json and BypassOptionsSchema in napcat-core helper config.
- Extend Napi2NativeLoader types: enableAllBypasses now accepts BypassOptions.
- Framework & Shell: load napcat.json (via json5), pass parsed bypass options to native loader, and log the applied config. Add json5 dependency.
- Shell: implement loadBypassConfig with a crash-recovery override (NAPCAT_BYPASS_DISABLE_LEVEL) and add master<->worker IPC (login-success) plus progressive bypass-disable strategy to mitigate repeated crashes before login.
- WebUI backend: add GET/Set endpoints for NapCat config (NapCatConfigRouter) with validation and JSON5-aware defaults.
- WebUI frontend: add BypassConfig page, types, and controller methods to get/set bypass config.
- Update package.json to include json5 and update pnpm lockfile; native binaries (.node / ffmpeg.dll) also updated.

This enables operators to tune bypass behavior per-installation and to have an in-UI control for toggling anti-detection features; it also adds progressive fallback behavior to help recover from crashes caused by bypasses.
2026-02-18 22:09:27 +08:00
手瓜一十雪
9998207346 Move plugin data to config path; improve uninstall UI
Some checks failed
Build NapCat Artifacts / Build-Framework (push) Has been cancelled
Build NapCat Artifacts / Build-Shell (push) Has been cancelled
Change plugin data storage to use core.context.pathWrapper.configPath/plugins/<id> instead of pluginPath/data across OB11 plugin managers. Ensure plugin config directory is created when building the plugin context, use the central path for cleanup/uninstall, and update getPluginDataPath accordingly. Update web UI uninstall flow to prompt for cleaning configuration files using dialog.confirm (showing the config path) and performUninstall helper instead of window.confirm. Also include rebuilt native binaries (napi2native) for Linux x64 and arm64.
2026-02-18 16:49:43 +08:00
手瓜一十雪
4f47af233f Update napi2native Linux native binaries
Some checks failed
Build NapCat Artifacts / Build-Framework (push) Has been cancelled
Build NapCat Artifacts / Build-Shell (push) Has been cancelled
Replace prebuilt napi2native native modules for Linux (arm64 and x64) with updated binaries. These updated artifacts ensure the native addon is rebuilt and compatible with current Node/N-API/ABI or dependency changes, restoring compatibility and performance on Linux platforms.
2026-02-16 15:39:44 +08:00
手瓜一十雪
6aadc2402d Enable o3HookMode and update Win32 native binary
Set o3HookMode to 1 in packages/napcat-core/external/napcat.json to enable O3 hook mode. Replace the prebuilt napi2native.win32.x64.node binary in packages/napcat-native/napi2native to include corresponding native changes for Win32 x64.
2026-02-16 14:58:06 +08:00
手瓜一十雪
eb937b29e4 Enable native verbose logging via env var
Some checks failed
Build NapCat Artifacts / Build-Framework (push) Has been cancelled
Build NapCat Artifacts / Build-Shell (push) Has been cancelled
Read NAPCAT_ENABLE_VERBOSE_LOG at startup and call napi2nativeLoader.nativeExports.setVerbose(true) (if available) right after loading the wrapper so native verbose logging can be enabled via environment. Also includes an updated native .node binary.
2026-02-13 19:33:48 +08:00
手瓜一十雪
f44aca9a2f Update napi2native Windows x64 binary
Replace the compiled napi2native.win32.x64.node binary for the Windows x64 build. This updates the native addon artifact (likely rebuilt due to code or build environment changes) so consumers get the latest native implementation.
2026-02-13 19:06:12 +08:00
54 changed files with 2012 additions and 410 deletions

View File

@@ -10,7 +10,7 @@ permissions: write-all
env:
OPENROUTER_API_URL: https://91vip.futureppo.top/v1/chat/completions
OPENROUTER_MODEL: "glm-4.7"
OPENROUTER_MODEL: "deepseek-v3.2-chat"
RELEASE_NAME: "NapCat"
jobs:

View File

@@ -11,15 +11,8 @@ _Modern protocol-side framework implemented based on NTQQ._
---
## New Feature
在 v4.8.115+ 版本开始
1. NapCatQQ 支持 [Stream Api](https://napneko.github.io/develop/file)
2. NapCatQQ 推荐 message_id/user_id/group_id 均使用字符串类型
- [1] 解决 Docker/跨设备/大文件 的多媒体上下传问题
- [2] 采用字符串可以解决扩展到int64的问题同时也可以解决部分语言如JavaScript对大整数支持不佳的问题增加极少成本。
## Notice
NapCat 当前正在寻找新的主要维护者欢迎email到 nanaeonn@outlook.com 在此期不会建立任何公开社区交流群Napcat会保证此期间的正常更新。
## Welcome
@@ -53,12 +46,6 @@ _Modern protocol-side framework implemented based on NTQQ._
| Docs | [![Cloudflare.Pages](https://img.shields.io/badge/docs%20on-Cloudflare.Pages-blue)](https://napneko.pages.dev/) | [![Server.Other](https://img.shields.io/badge/docs%20on-Server.Other-green)](https://napcat.top/) | [![NapCat.Top](https://img.shields.io/badge/docs%20on-NapCat.Top-red)](https://napcat.top/) |
|:-:|:-:|:-:|:-:|
| QQ Group | [![QQ Group#4](https://img.shields.io/badge/QQ%20Group%234-Join-blue)](https://qm.qq.com/q/CMmPbGw0jA) | [![QQ Group#3](https://img.shields.io/badge/QQ%20Group%233-Join-blue)](https://qm.qq.com/q/8zJMLjqy2Y) | [![QQ Group#2](https://img.shields.io/badge/QQ%20Group%232-Join-blue)](https://qm.qq.com/q/CMmPbGw0jA) | [![QQ Group#1](https://img.shields.io/badge/QQ%20Group%231-Join-blue)](https://qm.qq.com/q/I6LU87a0Yq) |
|:-:|:-:|:-:|:-:|:-:|
| Telegram | [![Telegram](https://img.shields.io/badge/Telegram-napcatqq-blue)](https://t.me/napcatqq) |
|:-:|:-:|
| DeepWiki | [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/NapNeko/NapCatQQ) |
|:-:|:-:|

View File

@@ -68,7 +68,7 @@ export class NTQQPacketApi {
this.pkt = new PacketClientSession(this.core);
await this.pkt.init(process.pid, table.recv, table.send);
try {
await this.pkt.operation.FetchRkey(1500);
await this.pkt.operation.FetchRkey(3000);
} catch (error) {
this.logger.logError('测试Packet状态异常', error);
return false;

View File

@@ -5,5 +5,13 @@
"consoleLogLevel": "info",
"packetBackend": "auto",
"packetServer": "",
"o3HookMode": 0
"o3HookMode": 1,
"bypass": {
"hook": false,
"window": false,
"module": false,
"process": false,
"container": false,
"js": false
}
}

View File

@@ -1,7 +1,19 @@
import { ConfigBase } from '@/napcat-core/helper/config-base';
import { NapCatCore } from '@/napcat-core/index';
import { Type, Static } from '@sinclair/typebox';
import { AnySchema } from 'ajv';
import Ajv, { AnySchema } from 'ajv';
import path from 'node:path';
import fs from 'node:fs';
import json5 from 'json5';
export const BypassOptionsSchema = Type.Object({
hook: Type.Boolean({ default: true }),
window: Type.Boolean({ default: true }),
module: Type.Boolean({ default: true }),
process: Type.Boolean({ default: true }),
container: Type.Boolean({ default: true }),
js: Type.Boolean({ default: true }),
});
export const NapcatConfigSchema = Type.Object({
fileLog: Type.Boolean({ default: false }),
@@ -11,10 +23,31 @@ export const NapcatConfigSchema = Type.Object({
packetBackend: Type.String({ default: 'auto' }),
packetServer: Type.String({ default: '' }),
o3HookMode: Type.Number({ default: 0 }),
bypass: Type.Optional(BypassOptionsSchema),
});
export type NapcatConfig = Static<typeof NapcatConfigSchema>;
/**
* 从指定配置目录读取 napcat.json按 NapcatConfigSchema 校验并填充默认值
* 用于登录前(无 NapCatCore 实例时)的早期配置读取
*/
export function loadNapcatConfig (configPath: string): NapcatConfig {
const ajv = new Ajv({ useDefaults: true, coerceTypes: true });
const validate = ajv.compile<NapcatConfig>(NapcatConfigSchema);
let data: Record<string, unknown> = {};
try {
const configFile = path.join(configPath, 'napcat.json');
if (fs.existsSync(configFile)) {
data = json5.parse(fs.readFileSync(configFile, 'utf-8'));
}
} catch {
// 读取失败时使用 schema 默认值
}
validate(data);
return data as NapcatConfig;
}
export class NapCatConfigLoader extends ConfigBase<NapcatConfig> {
constructor (core: NapCatCore, configPath: string, schema: AnySchema) {
super('napcat', core, configPath, schema);

View File

@@ -194,7 +194,7 @@ export class NativePacketHandler {
}
}
async init (version: string): Promise<boolean> {
async init (version: string, o3HookMode: boolean = false): Promise<boolean> {
const version_arch = version + '-' + process.arch;
try {
if (!this.loaded) {
@@ -215,7 +215,7 @@ export class NativePacketHandler {
this.MoeHooExport.exports.initHook?.(send, recv, (type: PacketType, uin: string, cmd: string, seq: number, hex_data: string) => {
this.emitPacket(type, uin, cmd, seq, hex_data);
}, true);
}, o3HookMode);
this.logger.log('[PacketHandler] 初始化成功');
return true;
} catch (error) {

View File

@@ -4,10 +4,19 @@ import fs from 'fs';
import { constants } from 'node:os';
import { LogWrapper } from '../../helper/log';
export interface BypassOptions {
hook?: boolean;
window?: boolean;
module?: boolean;
process?: boolean;
container?: boolean;
js?: boolean;
}
export interface Napi2NativeExportType {
initHook?: (send: string, recv: string) => boolean;
setVerbose?: (verbose: boolean) => void; // 默认关闭日志
enableAllBypasses?: () => void;
enableAllBypasses?: (options?: BypassOptions) => boolean;
}
export class Napi2NativeLoader {

View File

@@ -29,10 +29,11 @@ export interface PasswordLoginArgType {
uin: string;
passwordMd5: string;// passwMD5
step: number;// 猜测是需要二次认证 参数 一次为0
newDeviceLoginSig: string;
proofWaterSig: string;
proofWaterRand: string;
proofWaterSid: string;
newDeviceLoginSig: Uint8Array;
proofWaterSig: Uint8Array;
proofWaterRand: Uint8Array;
proofWaterSid: Uint8Array;
unusualDeviceCheckSig: Uint8Array;
}
export interface LoginListItem {

View File

@@ -3,6 +3,7 @@ import { InitWebUi, WebUiConfig, webUiRuntimePort } from 'napcat-webui-backend/i
import { NapCatAdapterManager } from 'napcat-adapter';
import { NativePacketHandler } from 'napcat-core/packet/handler/client';
import { Napi2NativeLoader } from 'napcat-core/packet/handler/napi2nativeLoader';
import { loadNapcatConfig } from '@/napcat-core/helper/config';
import { FFmpegService } from 'napcat-core/helper/ffmpeg/ffmpeg';
import { logSubscription, LogWrapper } from 'napcat-core/helper/log';
import { QQBasicInfoWrapper } from '@/napcat-core/helper/qq-basic-info';
@@ -42,19 +43,22 @@ export async function NCoreInitFramework (
const wrapper = loadQQWrapper(basicInfoWrapper.QQMainPath, basicInfoWrapper.getFullQQVersion());
const nativePacketHandler = new NativePacketHandler({ logger }); // 初始化 NativePacketHandler 用于后续使用
const napi2nativeLoader = new Napi2NativeLoader({ logger }); // 初始化 Napi2NativeLoader 用于后续使用
const napcatConfig = loadNapcatConfig(pathWrapper.configPath);
//console.log('[NapCat] [Napi2NativeLoader]', napi2nativeLoader.nativeExports.enableAllBypasses?.());
if (process.env['NAPCAT_DISABLE_BYPASS'] !== '1') {
const bypassEnabled = napi2nativeLoader.nativeExports.enableAllBypasses?.();
const bypassOptions = napcatConfig.bypass ?? {};
const bypassEnabled = napi2nativeLoader.nativeExports.enableAllBypasses?.(bypassOptions);
if (bypassEnabled) {
logger.log('[NapCat] Napi2NativeLoader: 已启用Bypass');
}
logger.log('[NapCat] Napi2NativeLoader: Framework模式Bypass配置:', bypassOptions);
} else {
logger.log('[NapCat] Napi2NativeLoader: Bypass已通过环境变量禁用');
}
// nativePacketHandler.onAll((packet) => {
// console.log('[Packet]', packet.uin, packet.cmd, packet.hex_data);
// });
await nativePacketHandler.init(basicInfoWrapper.getFullQQVersion());
await nativePacketHandler.init(basicInfoWrapper.getFullQQVersion(), napcatConfig.o3HookMode === 1 ? true : false);
// 在 init 之后注册监听器
// 初始化 FFmpeg 服务

View File

@@ -22,7 +22,8 @@
"napcat-adapter": "workspace:*",
"napcat-webui-backend": "workspace:*",
"napcat-vite": "workspace:*",
"napcat-qrcode": "workspace:*"
"napcat-qrcode": "workspace:*",
"json5": "^2.2.3"
},
"devDependencies": {
"@types/node": "^22.0.1"

View File

@@ -13,14 +13,12 @@ import { URL } from 'url';
import { ActionName } from '@/napcat-onebot/action/router';
import { OB11HeartbeatEvent } from '@/napcat-onebot/event/meta/OB11HeartbeatEvent';
import { OB11LifeCycleEvent, LifeCycleSubType } from '@/napcat-onebot/event/meta/OB11LifeCycleEvent';
import { Mutex } from 'async-mutex';
export class OB11HttpServerAdapter extends IOB11NetworkAdapter<HttpServerConfig> {
private app: Express | undefined;
private server: http.Server | undefined;
private wsServer?: WebSocketServer;
private wsClients: WebSocket[] = [];
private wsClientsMutex = new Mutex();
private heartbeatIntervalId: NodeJS.Timeout | null = null;
private wsClientWithEvent: WebSocket[] = [];
@@ -30,19 +28,17 @@ export class OB11HttpServerAdapter extends IOB11NetworkAdapter<HttpServerConfig>
override async onEvent<T extends OB11EmitEventContent> (event: T) {
// http server is passive, no need to emit event
this.wsClientsMutex.runExclusive(async () => {
const promises = this.wsClientWithEvent.map((wsClient) => {
return new Promise<void>((resolve, reject) => {
if (wsClient.readyState === WebSocket.OPEN) {
wsClient.send(JSON.stringify(event));
resolve();
} else {
reject(new Error('WebSocket is not open'));
}
});
const promises = this.wsClientWithEvent.map((wsClient) => {
return new Promise<void>((resolve, reject) => {
if (wsClient.readyState === WebSocket.OPEN) {
wsClient.send(JSON.stringify(event));
resolve();
} else {
reject(new Error('WebSocket is not open'));
}
});
await Promise.allSettled(promises);
});
await Promise.allSettled(promises);
}
open () {
@@ -65,13 +61,9 @@ export class OB11HttpServerAdapter extends IOB11NetworkAdapter<HttpServerConfig>
this.server?.close();
this.app = undefined;
this.stopHeartbeat();
await this.wsClientsMutex.runExclusive(async () => {
this.wsClients.forEach((wsClient) => {
wsClient.close();
});
this.wsClients = [];
this.wsClientWithEvent = [];
});
this.wsClients.forEach((wsClient) => wsClient.close());
this.wsClients = [];
this.wsClientWithEvent = [];
this.wsServer?.close();
}
@@ -153,36 +145,29 @@ export class OB11HttpServerAdapter extends IOB11NetworkAdapter<HttpServerConfig>
wsClient.on('message', (message) => {
this.handleWSMessage(wsClient, message).then().catch(e => this.logger.logError(e));
});
wsClient.on('ping', () => {
wsClient.pong();
});
wsClient.on('pong', () => {
// this.logger.logDebug('[OneBot] [HTTP WebSocket] Pong received');
});
wsClient.once('close', () => {
this.wsClientsMutex.runExclusive(async () => {
const NormolIndex = this.wsClients.indexOf(wsClient);
if (NormolIndex !== -1) {
this.wsClients.splice(NormolIndex, 1);
}
const EventIndex = this.wsClientWithEvent.indexOf(wsClient);
if (EventIndex !== -1) {
this.wsClientWithEvent.splice(EventIndex, 1);
}
if (this.wsClientWithEvent.length === 0) {
this.stopHeartbeat();
}
});
});
await this.wsClientsMutex.runExclusive(async () => {
if (!isApiConnect) {
this.wsClientWithEvent.push(wsClient);
const NormolIndex = this.wsClients.indexOf(wsClient);
if (NormolIndex !== -1) {
this.wsClients.splice(NormolIndex, 1);
}
this.wsClients.push(wsClient);
if (this.wsClientWithEvent.length > 0) {
this.startHeartbeat();
const EventIndex = this.wsClientWithEvent.indexOf(wsClient);
if (EventIndex !== -1) {
this.wsClientWithEvent.splice(EventIndex, 1);
}
if (this.wsClientWithEvent.length === 0) {
this.stopHeartbeat();
}
});
if (!isApiConnect) {
this.wsClientWithEvent.push(wsClient);
}
this.wsClients.push(wsClient);
if (this.wsClientWithEvent.length > 0) {
this.startHeartbeat();
}
}).on('error', (err) => this.logger.log('[OneBot] [HTTP WebSocket] Server Error:', err.message));
}
@@ -197,12 +182,10 @@ export class OB11HttpServerAdapter extends IOB11NetworkAdapter<HttpServerConfig>
private startHeartbeat () {
if (this.heartbeatIntervalId) return;
this.heartbeatIntervalId = setInterval(() => {
this.wsClientsMutex.runExclusive(async () => {
this.wsClientWithEvent.forEach((wsClient) => {
if (wsClient.readyState === WebSocket.OPEN) {
wsClient.send(JSON.stringify(new OB11HeartbeatEvent(this.core, 30000, this.core.selfInfo.online ?? true, true)));
}
});
this.wsClientWithEvent.forEach((wsClient) => {
if (wsClient.readyState === WebSocket.OPEN) {
wsClient.send(JSON.stringify(new OB11HeartbeatEvent(this.core, 30000, this.core.selfInfo.online ?? true, true)));
}
});
}, 30000);
}

View File

@@ -196,9 +196,14 @@ export class OB11PluginMangerAdapter extends IOB11NetworkAdapter<PluginConfig> i
* 创建插件上下文
*/
private createPluginContext (entry: PluginEntry): NapCatPluginContext {
const dataPath = path.join(entry.pluginPath, 'data');
const dataPath = path.join(this.core.context.pathWrapper.configPath, 'plugins', entry.id);
const configPath = path.join(dataPath, 'config.json');
// 确保插件配置目录存在
if (!fs.existsSync(dataPath)) {
fs.mkdirSync(dataPath, { recursive: true });
}
// 创建插件专用日志器
const pluginPrefix = `[Plugin: ${entry.id}]`;
const coreLogger = this.logger;
@@ -358,7 +363,7 @@ export class OB11PluginMangerAdapter extends IOB11NetworkAdapter<PluginConfig> i
}
const pluginPath = entry.pluginPath;
const dataPath = path.join(pluginPath, 'data');
const dataPath = path.join(this.core.context.pathWrapper.configPath, 'plugins', pluginId);
if (entry.loaded) {
await this.unloadPlugin(entry);
@@ -372,7 +377,7 @@ export class OB11PluginMangerAdapter extends IOB11NetworkAdapter<PluginConfig> i
fs.rmSync(pluginPath, { recursive: true, force: true });
}
// 清理数据
// 清理插件配置数据
if (cleanData && fs.existsSync(dataPath)) {
fs.rmSync(dataPath, { recursive: true, force: true });
}
@@ -440,11 +445,7 @@ export class OB11PluginMangerAdapter extends IOB11NetworkAdapter<PluginConfig> i
* 获取插件数据目录路径
*/
public getPluginDataPath (pluginId: string): string {
const entry = this.plugins.get(pluginId);
if (!entry) {
throw new Error(`Plugin ${pluginId} not found`);
}
return path.join(entry.pluginPath, 'data');
return path.join(this.core.context.pathWrapper.configPath, 'plugins', pluginId);
}
/**

View File

@@ -173,9 +173,14 @@ export class OB11PluginManager extends IOB11NetworkAdapter<PluginConfig> impleme
* 创建插件上下文
*/
private createPluginContext (entry: PluginEntry): NapCatPluginContext {
const dataPath = path.join(entry.pluginPath, 'data');
const dataPath = path.join(this.core.context.pathWrapper.configPath, 'plugins', entry.id);
const configPath = path.join(dataPath, 'config.json');
// 确保插件配置目录存在
if (!fs.existsSync(dataPath)) {
fs.mkdirSync(dataPath, { recursive: true });
}
// 创建插件专用日志器
const pluginPrefix = `[Plugin: ${entry.id}]`;
const coreLogger = this.logger;
@@ -323,7 +328,7 @@ export class OB11PluginManager extends IOB11NetworkAdapter<PluginConfig> impleme
}
const pluginPath = entry.pluginPath;
const dataPath = path.join(pluginPath, 'data');
const dataPath = path.join(this.core.context.pathWrapper.configPath, 'plugins', pluginId);
// 先卸载插件
await this.unloadPlugin(entry);
@@ -336,7 +341,7 @@ export class OB11PluginManager extends IOB11NetworkAdapter<PluginConfig> impleme
fs.rmSync(pluginPath, { recursive: true, force: true });
}
// 清理数据
// 清理插件配置数据
if (cleanData && fs.existsSync(dataPath)) {
fs.rmSync(dataPath, { recursive: true, force: true });
}
@@ -404,11 +409,7 @@ export class OB11PluginManager extends IOB11NetworkAdapter<PluginConfig> impleme
* 获取插件数据目录路径
*/
public getPluginDataPath (pluginId: string): string {
const entry = this.plugins.get(pluginId);
if (!entry) {
throw new Error(`Plugin ${pluginId} not found`);
}
return path.join(entry.pluginPath, 'data');
return path.join(this.core.context.pathWrapper.configPath, 'plugins', pluginId);
}
/**

View File

@@ -15,6 +15,7 @@ export interface PluginPackageJson {
author?: string;
homepage?: string;
repository?: string | { type: string; url: string; };
icon?: string; // 插件图标文件路径(相对于插件目录),如 "icon.png"
}
// ==================== 插件配置 Schema ====================

View File

@@ -85,9 +85,6 @@ export class OB11WebSocketClientAdapter extends IOB11NetworkAdapter<WebsocketCli
},
});
this.connection.on('ping', () => {
this.connection?.pong();
});
this.connection.on('pong', () => {
// this.logger.logDebug('[OneBot] [WebSocket Client] 收到pong');
});

View File

@@ -1,7 +1,6 @@
import { OB11EmitEventContent, OB11NetworkReloadType } from './index';
import { URL } from 'url';
import { RawData, WebSocket, WebSocketServer } from 'ws';
import { Mutex } from 'async-mutex';
import { OB11Response } from '@/napcat-onebot/action/OneBotAction';
import { ActionName } from '@/napcat-onebot/action/router';
import { NapCatCore } from 'napcat-core';
@@ -17,7 +16,6 @@ import json5 from 'json5';
export class OB11WebSocketServerAdapter extends IOB11NetworkAdapter<WebsocketServerConfig> {
wsServer?: WebSocketServer;
wsClients: WebSocket[] = [];
wsClientsMutex = new Mutex();
private heartbeatIntervalId: NodeJS.Timeout | null = null;
wsClientWithEvent: WebSocket[] = [];
@@ -58,36 +56,29 @@ export class OB11WebSocketServerAdapter extends IOB11NetworkAdapter<WebsocketSer
wsClient.on('message', (message) => {
this.handleMessage(wsClient, message).then().catch(e => this.logger.logError(e));
});
wsClient.on('ping', () => {
wsClient.pong();
});
wsClient.on('pong', () => {
// this.logger.logDebug('[OneBot] [WebSocket Server] Pong received');
});
wsClient.once('close', () => {
this.wsClientsMutex.runExclusive(async () => {
const NormolIndex = this.wsClients.indexOf(wsClient);
if (NormolIndex !== -1) {
this.wsClients.splice(NormolIndex, 1);
}
const EventIndex = this.wsClientWithEvent.indexOf(wsClient);
if (EventIndex !== -1) {
this.wsClientWithEvent.splice(EventIndex, 1);
}
if (this.wsClientWithEvent.length === 0) {
this.stopHeartbeat();
}
});
});
await this.wsClientsMutex.runExclusive(async () => {
if (!isApiConnect) {
this.wsClientWithEvent.push(wsClient);
const NormolIndex = this.wsClients.indexOf(wsClient);
if (NormolIndex !== -1) {
this.wsClients.splice(NormolIndex, 1);
}
this.wsClients.push(wsClient);
if (this.wsClientWithEvent.length > 0) {
this.startHeartbeat();
const EventIndex = this.wsClientWithEvent.indexOf(wsClient);
if (EventIndex !== -1) {
this.wsClientWithEvent.splice(EventIndex, 1);
}
if (this.wsClientWithEvent.length === 0) {
this.stopHeartbeat();
}
});
if (!isApiConnect) {
this.wsClientWithEvent.push(wsClient);
}
this.wsClients.push(wsClient);
if (this.wsClientWithEvent.length > 0) {
this.startHeartbeat();
}
}).on('error', (err) => this.logger.log('[OneBot] [WebSocket Server] Server Error:', err.message));
}
@@ -100,19 +91,17 @@ export class OB11WebSocketServerAdapter extends IOB11NetworkAdapter<WebsocketSer
}
async onEvent<T extends OB11EmitEventContent> (event: T) {
this.wsClientsMutex.runExclusive(async () => {
const promises = this.wsClientWithEvent.map((wsClient) => {
return new Promise<void>((resolve, reject) => {
if (wsClient.readyState === WebSocket.OPEN) {
wsClient.send(JSON.stringify(event));
resolve();
} else {
reject(new Error('WebSocket is not open'));
}
});
const promises = this.wsClientWithEvent.map((wsClient) => {
return new Promise<void>((resolve, reject) => {
if (wsClient.readyState === WebSocket.OPEN) {
wsClient.send(JSON.stringify(event));
resolve();
} else {
reject(new Error('WebSocket is not open'));
}
});
await Promise.allSettled(promises);
});
await Promise.allSettled(promises);
}
open () {
@@ -136,24 +125,18 @@ export class OB11WebSocketServerAdapter extends IOB11NetworkAdapter<WebsocketSer
}
});
this.stopHeartbeat();
await this.wsClientsMutex.runExclusive(async () => {
this.wsClients.forEach((wsClient) => {
wsClient.close();
});
this.wsClients = [];
this.wsClientWithEvent = [];
});
this.wsClients.forEach((wsClient) => wsClient.close());
this.wsClients = [];
this.wsClientWithEvent = [];
}
private startHeartbeat () {
if (this.heartbeatIntervalId || this.config.heartInterval <= 0) return;
this.heartbeatIntervalId = setInterval(() => {
this.wsClientsMutex.runExclusive(async () => {
this.wsClientWithEvent.forEach((wsClient) => {
if (wsClient.readyState === WebSocket.OPEN) {
wsClient.send(JSON.stringify(new OB11HeartbeatEvent(this.core, this.config.heartInterval, this.core.selfInfo.online ?? true, true)));
}
});
this.wsClientWithEvent.forEach((wsClient) => {
if (wsClient.readyState === WebSocket.OPEN) {
wsClient.send(JSON.stringify(new OB11HeartbeatEvent(this.core, this.config.heartInterval, this.core.selfInfo.online ?? true, true)));
}
});
}, this.config.heartInterval);
}

View File

@@ -26,7 +26,6 @@
"express": "^5.0.0",
"ws": "^8.18.3",
"file-type": "^21.0.0",
"async-mutex": "^0.5.0",
"napcat-protobuf": "workspace:*",
"json5": "^2.2.3",
"napcat-core": "workspace:*",

View File

@@ -20,6 +20,7 @@ import { hostname, systemVersion } from 'napcat-common/src/system';
import path from 'path';
import fs from 'fs';
import os from 'os';
import { createHash } from 'node:crypto';
import { LoginListItem, NodeIKernelLoginService } from 'napcat-core/services';
import qrcode from 'napcat-qrcode/lib/main';
import { NapCatAdapterManager } from 'napcat-adapter';
@@ -31,12 +32,14 @@ import { sleep } from 'napcat-common/src/helper';
import { FFmpegService } from '@/napcat-core/helper/ffmpeg/ffmpeg';
import { NativePacketHandler } from 'napcat-core/packet/handler/client';
import { Napi2NativeLoader } from 'napcat-core/packet/handler/napi2nativeLoader';
import { loadNapcatConfig } from '@/napcat-core/helper/config';
import { logSubscription, LogWrapper } from '@/napcat-core/helper/log';
import { proxiedListenerOf } from '@/napcat-core/helper/proxy-handler';
import { QQBasicInfoWrapper } from '@/napcat-core/helper/qq-basic-info';
import { statusHelperSubscription } from '@/napcat-core/helper/status';
import { applyPendingUpdates } from '@/napcat-webui-backend/src/api/UpdateNapCat';
import { connectToNamedPipe } from './pipe';
// NapCat Shell App ES 入口文件
async function handleUncaughtExceptions (logger: LogWrapper) {
process.on('uncaughtException', (err) => {
@@ -192,6 +195,24 @@ async function handleLogin (
return await selfInfo;
}
async function handleLoginInner (context: { isLogined: boolean; }, logger: LogWrapper, loginService: NodeIKernelLoginService, quickLoginUin: string | undefined, historyLoginList: LoginListItem[]) {
const resolveQuickPasswordMd5 = (): string | undefined => {
const quickPasswordMd5 = process.env['NAPCAT_QUICK_PASSWORD_MD5']?.trim();
if (quickPasswordMd5) {
if (/^[a-fA-F0-9]{32}$/.test(quickPasswordMd5)) {
return quickPasswordMd5.toLowerCase();
}
logger.logError('NAPCAT_QUICK_PASSWORD_MD5 格式无效(需为 32 位 MD5');
}
const quickPassword = process.env['NAPCAT_QUICK_PASSWORD'];
if (typeof quickPassword === 'string' && quickPassword.length > 0) {
logger.log('检测到 NAPCAT_QUICK_PASSWORD已在内存中计算 MD5 用于回退登录');
return createHash('md5').update(quickPassword, 'utf8').digest('hex');
}
return undefined;
};
// 注册刷新二维码回调
WebUiDataRuntime.setRefreshQRCodeCallback(async () => {
loginService.getQRCodePicture();
@@ -202,10 +223,12 @@ async function handleLoginInner (context: { isLogined: boolean; }, logger: LogWr
if (uin) {
logger.log('正在快速登录 ', uin);
loginService.quickLoginWithUin(uin).then(res => {
if (res.loginErrorInfo.errMsg) {
WebUiDataRuntime.setQQLoginError(res.loginErrorInfo.errMsg);
const quickLoginSuccess = res.result === '0' && !res.loginErrorInfo?.errMsg;
if (!quickLoginSuccess) {
const errMsg = res.loginErrorInfo?.errMsg || `快速登录失败,错误码: ${res.result}`;
WebUiDataRuntime.setQQLoginError(errMsg);
loginService.getQRCodePicture();
resolve({ result: false, message: res.loginErrorInfo.errMsg });
resolve({ result: false, message: errMsg });
} else {
WebUiDataRuntime.setQQLoginStatus(true);
WebUiDataRuntime.setQQLoginError('');
@@ -232,21 +255,43 @@ async function handleLoginInner (context: { isLogined: boolean; }, logger: LogWr
uin,
passwordMd5,
step: 0,
newDeviceLoginSig: '',
proofWaterSig: '',
proofWaterRand: '',
proofWaterSid: '',
newDeviceLoginSig: new Uint8Array(),
proofWaterSig: new Uint8Array(),
proofWaterRand: new Uint8Array(),
proofWaterSid: new Uint8Array(),
unusualDeviceCheckSig: new Uint8Array(),
}).then(res => {
if (res.result === '140022008') {
const errMsg = '需要验证码,暂不支持';
WebUiDataRuntime.setQQLoginError(errMsg);
loginService.getQRCodePicture();
resolve({ result: false, message: errMsg });
const proofWaterUrl = res.loginErrorInfo?.proofWaterUrl || '';
logger.log('需要验证码, proofWaterUrl: ', proofWaterUrl);
resolve({
result: false,
message: '需要验证码',
needCaptcha: true,
proofWaterUrl,
});
} else if (res.result === '140022010') {
const errMsg = '新设备需要扫码登录,暂不支持';
WebUiDataRuntime.setQQLoginError(errMsg);
loginService.getQRCodePicture();
resolve({ result: false, message: errMsg });
const jumpUrl = res.loginErrorInfo?.jumpUrl || '';
const newDevicePullQrCodeSig = res.loginErrorInfo?.newDevicePullQrCodeSig || '';
logger.log('新设备需要扫码验证, jumpUrl: ', jumpUrl);
resolve({
result: false,
message: '新设备需要扫码验证',
needNewDevice: true,
jumpUrl,
newDevicePullQrCodeSig,
});
} else if (res.result === '140022011') {
const jumpUrl = res.loginErrorInfo?.jumpUrl || '';
const newDevicePullQrCodeSig = res.loginErrorInfo?.newDevicePullQrCodeSig || '';
logger.log('异常设备需要验证, jumpUrl: ', jumpUrl);
resolve({
result: false,
message: '异常设备需要验证',
needNewDevice: true,
jumpUrl,
newDevicePullQrCodeSig,
});
} else if (res.result !== '0') {
const errMsg = res.loginErrorInfo?.errMsg || '密码登录失败';
WebUiDataRuntime.setQQLoginError(errMsg);
@@ -268,21 +313,170 @@ async function handleLoginInner (context: { isLogined: boolean; }, logger: LogWr
}
});
});
const tryPasswordFallbackLogin = async (uin: string): Promise<{ success: boolean, attempted: boolean; }> => {
const quickPasswordMd5 = resolveQuickPasswordMd5();
if (!quickPasswordMd5) {
logger.log(`QQ ${uin} 未配置回退密码环境变量,建议优先使用 ACCOUNT + NAPCAT_QUICK_PASSWORDNAPCAT_QUICK_PASSWORD_MD5 作为备用),将使用二维码登录方式`);
return { success: false, attempted: false };
}
logger.log('正在尝试密码回退登录 ', uin);
const fallbackResult = await WebUiDataRuntime.requestPasswordLogin(uin, quickPasswordMd5);
if (fallbackResult.result) {
logger.log('密码回退登录成功 ', uin);
return { success: true, attempted: true };
}
if (fallbackResult.needCaptcha) {
const captchaTip = fallbackResult.proofWaterUrl
? `密码回退需要验证码,请在 WebUi 中继续完成验证:${fallbackResult.proofWaterUrl}`
: '密码回退需要验证码,请在 WebUi 中继续完成验证';
logger.logWarn(captchaTip);
WebUiDataRuntime.setQQLoginError('密码回退需要验证码,请在 WebUi 中继续完成验证');
return { success: false, attempted: true };
}
if (fallbackResult.needNewDevice) {
const newDeviceTip = fallbackResult.jumpUrl
? `密码回退需要新设备验证,请在 WebUi 中继续完成验证:${fallbackResult.jumpUrl}`
: '密码回退需要新设备验证,请在 WebUi 中继续完成验证';
logger.logWarn(newDeviceTip);
WebUiDataRuntime.setQQLoginError('密码回退需要新设备验证,请在 WebUi 中继续完成验证');
return { success: false, attempted: true };
}
logger.logError('密码回退登录失败:', fallbackResult.message);
return { success: false, attempted: true };
};
// 注册验证码登录回调(密码登录需要验证码时的第二步)
WebUiDataRuntime.setCaptchaLoginCall(async (uin: string, passwordMd5: string, ticket: string, randstr: string, sid: string) => {
return await new Promise((resolve) => {
if (uin && passwordMd5 && ticket) {
logger.log('正在验证码登录 ', uin);
loginService.passwordLogin({
uin,
passwordMd5,
step: 1,
newDeviceLoginSig: new Uint8Array(),
proofWaterSig: new TextEncoder().encode(ticket),
proofWaterRand: new TextEncoder().encode(randstr),
proofWaterSid: new TextEncoder().encode(sid),
unusualDeviceCheckSig: new Uint8Array(),
}).then(res => {
console.log('验证码登录结果: ', res);
if (res.result === '140022010') {
const jumpUrl = res.loginErrorInfo?.jumpUrl || '';
const newDevicePullQrCodeSig = res.loginErrorInfo?.newDevicePullQrCodeSig || '';
logger.log('验证码登录后需要新设备验证, jumpUrl: ', jumpUrl);
resolve({
result: false,
message: '新设备需要扫码验证',
needNewDevice: true,
jumpUrl,
newDevicePullQrCodeSig,
});
} else if (res.result === '140022011') {
const jumpUrl = res.loginErrorInfo?.jumpUrl || '';
const newDevicePullQrCodeSig = res.loginErrorInfo?.newDevicePullQrCodeSig || '';
logger.log('验证码登录后需要异常设备验证, jumpUrl: ', jumpUrl);
resolve({
result: false,
message: '异常设备需要验证',
needNewDevice: true,
jumpUrl,
newDevicePullQrCodeSig,
});
} else if (res.result !== '0') {
const errMsg = res.loginErrorInfo?.errMsg || '验证码登录失败';
WebUiDataRuntime.setQQLoginError(errMsg);
loginService.getQRCodePicture();
resolve({ result: false, message: errMsg });
} else {
WebUiDataRuntime.setQQLoginStatus(true);
WebUiDataRuntime.setQQLoginError('');
resolve({ result: true, message: '' });
}
}).catch((e) => {
logger.logError(e);
WebUiDataRuntime.setQQLoginError('验证码登录发生错误');
loginService.getQRCodePicture();
resolve({ result: false, message: '验证码登录发生错误' });
});
} else {
resolve({ result: false, message: '验证码登录失败:参数不完整' });
}
});
});
// 注册新设备登录回调(密码登录需要新设备验证时的第二步)
WebUiDataRuntime.setNewDeviceLoginCall(async (uin: string, passwordMd5: string, newDevicePullQrCodeSig: string) => {
return await new Promise((resolve) => {
if (uin && passwordMd5 && newDevicePullQrCodeSig) {
logger.log('正在新设备验证登录 ', uin);
loginService.passwordLogin({
uin,
passwordMd5,
step: 2,
newDeviceLoginSig: new TextEncoder().encode(newDevicePullQrCodeSig),
proofWaterSig: new Uint8Array(),
proofWaterRand: new Uint8Array(),
proofWaterSid: new Uint8Array(),
unusualDeviceCheckSig: new Uint8Array(),
}).then(res => {
if (res.result === '140022011') {
const jumpUrl = res.loginErrorInfo?.jumpUrl || '';
const newDevicePullQrCodeSig = res.loginErrorInfo?.newDevicePullQrCodeSig || '';
logger.log('新设备验证后需要异常设备验证, jumpUrl: ', jumpUrl);
resolve({
result: false,
message: '异常设备需要验证',
needNewDevice: true,
jumpUrl,
newDevicePullQrCodeSig,
});
} else if (res.result !== '0') {
const errMsg = res.loginErrorInfo?.errMsg || '新设备验证登录失败';
WebUiDataRuntime.setQQLoginError(errMsg);
loginService.getQRCodePicture();
resolve({ result: false, message: errMsg });
} else {
WebUiDataRuntime.setQQLoginStatus(true);
WebUiDataRuntime.setQQLoginError('');
resolve({ result: true, message: '' });
}
}).catch((e) => {
logger.logError(e);
WebUiDataRuntime.setQQLoginError('新设备验证登录发生错误');
loginService.getQRCodePicture();
resolve({ result: false, message: '新设备验证登录发生错误' });
});
} else {
resolve({ result: false, message: '新设备验证登录失败:参数不完整' });
}
});
});
if (quickLoginUin) {
if (historyLoginList.some(u => u.uin === quickLoginUin)) {
logger.log('正在快速登录 ', quickLoginUin);
loginService.quickLoginWithUin(quickLoginUin)
.then(result => {
if (result.loginErrorInfo.errMsg) {
logger.logError('快速登录错误:', result.loginErrorInfo.errMsg);
WebUiDataRuntime.setQQLoginError(result.loginErrorInfo.errMsg);
if (!context.isLogined) loginService.getQRCodePicture();
.then(async result => {
const quickLoginSuccess = result.result === '0' && !result.loginErrorInfo?.errMsg;
if (!quickLoginSuccess) {
const errMsg = result.loginErrorInfo?.errMsg || `快速登录失败,错误码: ${result.result}`;
logger.logError('快速登录错误:', errMsg);
WebUiDataRuntime.setQQLoginError(errMsg);
const { success, attempted } = await tryPasswordFallbackLogin(quickLoginUin);
if (!success && !attempted && !context.isLogined) loginService.getQRCodePicture();
}
})
.catch();
.catch(async (error) => {
logger.logError('快速登录异常:', error);
WebUiDataRuntime.setQQLoginError('快速登录发生错误');
const { success, attempted } = await tryPasswordFallbackLogin(quickLoginUin);
if (!success && !attempted && !context.isLogined) loginService.getQRCodePicture();
});
} else {
logger.logError('快速登录失败,未找到该 QQ 历史登录记录,将使用二维码登录方式');
if (!context.isLogined) loginService.getQRCodePicture();
logger.logError('快速登录失败,未找到该 QQ 历史登录记录,将尝试密码回退登录');
const { success, attempted } = await tryPasswordFallbackLogin(quickLoginUin);
if (!success && !attempted && !context.isLogined) loginService.getQRCodePicture();
}
} else {
logger.log('没有 -q 指令指定快速登录,将使用二维码登录方式');
@@ -392,7 +586,6 @@ export async function NCoreInitShell () {
const basicInfoWrapper = new QQBasicInfoWrapper({ logger });
const nativePacketHandler = new NativePacketHandler({ logger });
const napi2nativeLoader = new Napi2NativeLoader({ logger });
await nativePacketHandler.init(basicInfoWrapper.getFullQQVersion());
// 初始化 FFmpeg 服务
await FFmpegService.init(pathWrapper.binaryPath, logger);
@@ -401,13 +594,16 @@ export async function NCoreInitShell () {
await connectToNamedPipe(logger).catch(e => logger.logError('命名管道连接失败', e));
}
const wrapper = loadQQWrapper(basicInfoWrapper.QQMainPath, basicInfoWrapper.getFullQQVersion());
// wrapper.node 加载后再初始化 hook按 schema 读取配置
const napcatConfig = loadNapcatConfig(pathWrapper.configPath);
await nativePacketHandler.init(basicInfoWrapper.getFullQQVersion(), napcatConfig.o3HookMode === 1 ? true : false);
if (process.env['NAPCAT_ENABLE_VERBOSE_LOG'] === '1') {
napi2nativeLoader.nativeExports.setVerbose?.(true);
}
// wrapper.node 加载后立刻启用 Bypass可通过环境变量禁用
if (process.env['NAPCAT_DISABLE_BYPASS'] !== '1') {
const bypassEnabled = napi2nativeLoader.nativeExports.enableAllBypasses?.();
if (bypassEnabled) {
logger.log('[NapCat] Napi2NativeLoader: 已启用Bypass');
}
const bypassOptions = napcatConfig.bypass ?? {};
napi2nativeLoader.nativeExports.enableAllBypasses?.(bypassOptions);
} else {
logger.log('[NapCat] Napi2NativeLoader: Bypass已通过环境变量禁用');
}
@@ -461,6 +657,13 @@ export async function NCoreInitShell () {
o3Service.reportAmgomWeather('login', 'a1', [dataTimestape, '0', '0']);
const selfInfo = await handleLogin(loginService, logger, pathWrapper, quickLoginUin, historyLoginList);
// 登录成功后通知 Master 进程(用于切换崩溃重试策略)
if (typeof process.send === 'function') {
process.send({ type: 'login-success' });
logger.log('[NapCat] 已通知主进程登录成功');
}
const amgomDataPiece = 'eb1fd6ac257461580dc7438eb099f23aae04ca679f4d88f53072dc56e3bb1129';
o3Service.setAmgomDataPiece(basicInfoWrapper.QQVersionAppid, new Uint8Array(Buffer.from(amgomDataPiece, 'hex')));

View File

@@ -45,7 +45,7 @@ const ENV = {
// Worker 消息类型
interface WorkerMessage {
type: 'restart' | 'restart-prepare' | 'shutdown';
type: 'restart' | 'restart-prepare' | 'shutdown' | 'login-success';
secretKey?: string;
port?: number;
}
@@ -65,6 +65,7 @@ const recentCrashTimestamps: number[] = [];
const CRASH_TIME_WINDOW = 10000; // 10秒时间窗口
const MAX_CRASHES_IN_WINDOW = 3; // 最大崩溃次数
/**
* 获取进程类型名称(用于日志)
*/
@@ -275,6 +276,8 @@ async function startWorker (passQuickLogin: boolean = true, secretKey?: string,
restartWorker(message.secretKey, message.port).catch(e => {
logger.logError(`[NapCat] [${processType}] 重启Worker进程失败:`, e);
});
} else if (message.type === 'login-success') {
logger.log(`[NapCat] [${processType}] Worker进程已登录成功切换到正常重试策略`);
}
}
});
@@ -297,13 +300,13 @@ async function startWorker (passQuickLogin: boolean = true, secretKey?: string,
// 记录本次崩溃
recentCrashTimestamps.push(now);
// 检查是否超过崩溃阈值
if (recentCrashTimestamps.length >= MAX_CRASHES_IN_WINDOW) {
logger.logError(`[NapCat] [${processType}] Worker进程在 ${CRASH_TIME_WINDOW / 1000} 秒内异常退出 ${MAX_CRASHES_IN_WINDOW} 次,主进程退出`);
process.exit(1);
}
logger.logWarn(`[NapCat] [${processType}] Worker进程意外退出 (${recentCrashTimestamps.length}/${MAX_CRASHES_IN_WINDOW}),正在尝试重新拉起...`);
startWorker(true).catch(e => {
logger.logError(`[NapCat] [${processType}] 重新拉起Worker进程失败:`, e);
});

View File

@@ -25,6 +25,7 @@
"napcat-qrcode": "workspace:*"
},
"devDependencies": {
"json5": "^2.2.3",
"@types/node": "^22.0.1",
"napcat-vite": "workspace:*"
},

View File

@@ -61,20 +61,6 @@ declare module 'yaml' {
export const stringify: (...args: any[]) => any;
}
declare module 'async-mutex' {
export class Mutex {
acquire (): Promise<() => void>;
runExclusive<T> (callback: () => T | Promise<T>): Promise<T>;
}
export class Semaphore {
acquire (): Promise<[() => void, number]>;
runExclusive<T> (callback: () => T | Promise<T>): Promise<T>;
release (): void;
}
const _async_mutex_default: { Mutex: typeof Mutex; Semaphore: typeof Semaphore; };
export default _async_mutex_default;
}
declare module 'napcat-protobuf' {
export class NapProtoMsg<T = any> {
constructor (schema: any);

View File

@@ -35,9 +35,6 @@ const EXTERNAL_TYPE_REPLACEMENTS = {
'ValidateFunction<T>': 'any',
// inversify
'Container': 'any',
// async-mutex
'Mutex': 'any',
'Semaphore': 'any',
// napcat-protobuf
'NapProtoDecodeStructType': 'any',
'NapProtoEncodeStructType': 'any',
@@ -90,15 +87,15 @@ function replaceExternalTypes (content) {
// 使用类型上下文的模式匹配
const typeContextPatterns = [
// : Type
/:\s*(WebSocket|WebSocketServer|RawData|Ajv|AnySchema|ValidateFunction|Container|Mutex|Semaphore|NapProtoDecodeStructType|NapProtoEncodeStructType|Express|Request|Response|NextFunction)(?=\s*[;,)\]\}|&]|$)/g,
/:\s*(WebSocket|WebSocketServer|RawData|Ajv|AnySchema|ValidateFunction|Container|NapProtoDecodeStructType|NapProtoEncodeStructType|Express|Request|Response|NextFunction)(?=\s*[;,)\]\}|&]|$)/g,
// <Type>
/<(WebSocket|WebSocketServer|RawData|Ajv|AnySchema|ValidateFunction|Container|Mutex|Semaphore|NapProtoDecodeStructType|NapProtoEncodeStructType|Express|Request|Response|NextFunction)>/g,
/<(WebSocket|WebSocketServer|RawData|Ajv|AnySchema|ValidateFunction|Container|NapProtoDecodeStructType|NapProtoEncodeStructType|Express|Request|Response|NextFunction)>/g,
// Type[]
/(WebSocket|WebSocketServer|RawData|Ajv|AnySchema|ValidateFunction|Container|Mutex|Semaphore|NapProtoDecodeStructType|NapProtoEncodeStructType|Express|Request|Response|NextFunction)\[\]/g,
/(WebSocket|WebSocketServer|RawData|Ajv|AnySchema|ValidateFunction|Container|NapProtoDecodeStructType|NapProtoEncodeStructType|Express|Request|Response|NextFunction)\[\]/g,
// extends Type
/extends\s+(WebSocket|WebSocketServer|RawData|Ajv|AnySchema|ValidateFunction|Container|Mutex|Semaphore|NapProtoDecodeStructType|NapProtoEncodeStructType|Express|Request|Response|NextFunction)(?=\s*[{,])/g,
/extends\s+(WebSocket|WebSocketServer|RawData|Ajv|AnySchema|ValidateFunction|Container|NapProtoDecodeStructType|NapProtoEncodeStructType|Express|Request|Response|NextFunction)(?=\s*[{,])/g,
// implements Type
/implements\s+(WebSocket|WebSocketServer|RawData|Ajv|AnySchema|ValidateFunction|Container|Mutex|Semaphore|NapProtoDecodeStructType|NapProtoEncodeStructType|Express|Request|Response|NextFunction)(?=\s*[{,])/g,
/implements\s+(WebSocket|WebSocketServer|RawData|Ajv|AnySchema|ValidateFunction|Container|NapProtoDecodeStructType|NapProtoEncodeStructType|Express|Request|Response|NextFunction)(?=\s*[{,])/g,
];
for (const pattern of typeContextPatterns) {

View File

@@ -5,7 +5,7 @@
import express from 'express';
import type { WebUiConfigType } from './src/types';
import { createServer } from 'http';
import { randomUUID } from 'node:crypto';
import { createHash, randomUUID } from 'node:crypto';
import { createServer as createHttpsServer } from 'https';
import { NapCatPathWrapper } from 'napcat-common/src/path';
import { WebUiConfigWrapper } from '@/napcat-webui-backend/src/helper/config';
@@ -156,16 +156,60 @@ export async function InitWebUi (logger: ILogWrapper, pathWrapper: NapCatPathWra
WebUiDataRuntime.setWebUiConfigQuickFunction(
async () => {
const autoLoginAccount = process.env['NAPCAT_QUICK_ACCOUNT'] || WebUiConfig.getAutoLoginAccount();
if (autoLoginAccount) {
try {
const { result, message } = await WebUiDataRuntime.requestQuickLogin(autoLoginAccount);
if (!result) {
throw new Error(message);
const resolveQuickPasswordMd5 = (): string | undefined => {
const quickPasswordMd5FromEnv = process.env['NAPCAT_QUICK_PASSWORD_MD5']?.trim();
if (quickPasswordMd5FromEnv) {
if (/^[a-fA-F0-9]{32}$/.test(quickPasswordMd5FromEnv)) {
return quickPasswordMd5FromEnv.toLowerCase();
}
console.log(`[NapCat] [WebUi] Auto login account: ${autoLoginAccount}`);
} catch (error) {
console.log('[NapCat] [WebUi] Auto login account failed.' + error);
console.log('[NapCat] [WebUi] NAPCAT_QUICK_PASSWORD_MD5 格式无效(需为 32 位 MD5');
}
const quickPassword = process.env['NAPCAT_QUICK_PASSWORD'];
if (typeof quickPassword === 'string' && quickPassword.length > 0) {
console.log('[NapCat] [WebUi] 检测到 NAPCAT_QUICK_PASSWORD已在内存中计算 MD5 用于回退登录');
return createHash('md5').update(quickPassword, 'utf8').digest('hex');
}
return undefined;
};
if (!autoLoginAccount) {
return;
}
const quickPasswordMd5 = resolveQuickPasswordMd5();
try {
const { result, message } = await WebUiDataRuntime.requestQuickLogin(autoLoginAccount);
if (result) {
console.log(`[NapCat] [WebUi] 自动快速登录成功: ${autoLoginAccount}`);
return;
}
console.log(`[NapCat] [WebUi] 自动快速登录失败: ${message || '未知错误'}`);
} catch (error) {
console.log('[NapCat] [WebUi] 自动快速登录异常:' + error);
}
if (!quickPasswordMd5) {
console.log(`[NapCat] [WebUi] QQ ${autoLoginAccount} 未配置回退密码环境变量,建议优先使用 ACCOUNT + NAPCAT_QUICK_PASSWORDNAPCAT_QUICK_PASSWORD_MD5 作为备用),保持二维码登录兜底`);
return;
}
try {
const { result, message, needCaptcha, needNewDevice } = await WebUiDataRuntime.requestPasswordLogin(autoLoginAccount, quickPasswordMd5);
if (result) {
console.log(`[NapCat] [WebUi] 自动密码回退登录成功: ${autoLoginAccount}`);
return;
}
if (needCaptcha) {
console.log(`[NapCat] [WebUi] 自动密码回退登录需要验证码,请在登录页面继续完成: ${autoLoginAccount}`);
return;
}
if (needNewDevice) {
console.log(`[NapCat] [WebUi] 自动密码回退登录需要新设备验证,请在登录页面继续完成: ${autoLoginAccount}`);
return;
}
console.log(`[NapCat] [WebUi] 自动密码回退登录失败: ${message || '未知错误'}`);
} catch (error) {
console.log('[NapCat] [WebUi] 自动密码回退登录异常:' + error);
}
});
// ------------注册中间件------------

View File

@@ -0,0 +1,90 @@
import { RequestHandler } from 'express';
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
import { resolve } from 'node:path';
import { webUiPathWrapper } from '@/napcat-webui-backend/index';
import { sendError, sendSuccess } from '@/napcat-webui-backend/src/utils/response';
import json5 from 'json5';
import Ajv from 'ajv';
import { NapcatConfigSchema } from '@/napcat-core/helper/config';
// 动态获取 NapCat 配置默认值
function getDefaultNapcatConfig (): Record<string, unknown> {
const ajv = new Ajv({ useDefaults: true, coerceTypes: true });
const validate = ajv.compile(NapcatConfigSchema);
const data = {};
validate(data);
return data;
}
/**
* 获取 napcat 配置文件路径
*/
function getNapcatConfigPath (): string {
return resolve(webUiPathWrapper.configPath, './napcat.json');
}
/**
* 读取 napcat 配置
*/
function readNapcatConfig (): Record<string, unknown> {
const configPath = getNapcatConfigPath();
try {
if (existsSync(configPath)) {
const content = readFileSync(configPath, 'utf-8');
return { ...getDefaultNapcatConfig(), ...json5.parse(content) };
}
} catch (_e) {
// 读取失败,使用默认值
}
return { ...getDefaultNapcatConfig() };
}
/**
* 写入 napcat 配置
*/
function writeNapcatConfig (config: Record<string, unknown>): void {
const configPath = resolve(webUiPathWrapper.configPath, './napcat.json');
mkdirSync(webUiPathWrapper.configPath, { recursive: true });
writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8');
}
// 获取 NapCat 配置
export const NapCatGetConfigHandler: RequestHandler = (_, res) => {
try {
const config = readNapcatConfig();
return sendSuccess(res, config);
} catch (e) {
return sendError(res, 'Config Get Error: ' + (e as Error).message);
}
};
// 设置 NapCat 配置
export const NapCatSetConfigHandler: RequestHandler = (req, res) => {
try {
const newConfig = req.body;
if (!newConfig || typeof newConfig !== 'object') {
return sendError(res, 'config is empty or invalid');
}
// 读取当前配置并合并
const currentConfig = readNapcatConfig();
const mergedConfig = { ...currentConfig, ...newConfig };
// 验证 bypass 字段
if (mergedConfig.bypass && typeof mergedConfig.bypass === 'object') {
const bypass = mergedConfig.bypass as Record<string, unknown>;
const validKeys = ['hook', 'window', 'module', 'process', 'container', 'js'];
for (const key of validKeys) {
if (key in bypass && typeof bypass[key] !== 'boolean') {
return sendError(res, `bypass.${key} must be boolean`);
}
}
}
writeNapcatConfig(mergedConfig);
return sendSuccess(res, null);
} catch (e) {
return sendError(res, 'Config Set Error: ' + (e as Error).message);
}
};

View File

@@ -8,6 +8,49 @@ import path from 'path';
import fs from 'fs';
import compressing from 'compressing';
/**
* 获取插件图标 URL
* 优先使用 package.json 中的 icon 字段,否则检查缓存的图标文件
*/
function getPluginIconUrl (pluginId: string, pluginPath: string, iconField?: string): string | undefined {
// 1. 检查 package.json 中指定的 icon 文件
if (iconField) {
const iconPath = path.join(pluginPath, iconField);
if (fs.existsSync(iconPath)) {
return `/api/Plugin/Icon/${encodeURIComponent(pluginId)}`;
}
}
// 2. 检查 config 目录中缓存的图标 (固定 icon.png)
const cachedIcon = path.join(webUiPathWrapper.configPath, 'plugins', pluginId, 'icon.png');
if (fs.existsSync(cachedIcon)) {
return `/api/Plugin/Icon/${encodeURIComponent(pluginId)}`;
}
return undefined;
}
/**
* 查找插件图标文件的实际路径
*/
function findPluginIconPath (pluginId: string, pluginPath: string, iconField?: string): string | undefined {
// 1. 优先使用 package.json 中指定的 icon
if (iconField) {
const iconPath = path.join(pluginPath, iconField);
if (fs.existsSync(iconPath)) {
return iconPath;
}
}
// 2. 检查 config 目录中缓存的图标 (固定 icon.png)
const cachedIcon = path.join(webUiPathWrapper.configPath, 'plugins', pluginId, 'icon.png');
if (fs.existsSync(cachedIcon)) {
return cachedIcon;
}
return undefined;
}
// Helper to get the plugin manager adapter
const getPluginManager = (): OB11PluginMangerAdapter | null => {
const ob11 = WebUiDataRuntime.getOneBotContext() as NapCatOneBot11Adapter;
@@ -77,6 +120,7 @@ export const GetPluginListHandler: RequestHandler = async (_req, res) => {
hasPages: boolean;
homepage?: string;
repository?: string;
icon?: string;
}> = new Array();
// 收集所有插件的扩展页面
@@ -117,7 +161,8 @@ export const GetPluginListHandler: RequestHandler = async (_req, res) => {
homepage: p.packageJson?.homepage,
repository: typeof p.packageJson?.repository === 'string'
? p.packageJson.repository
: p.packageJson?.repository?.url
: p.packageJson?.repository?.url,
icon: getPluginIconUrl(p.id, p.pluginPath, p.packageJson?.icon),
});
// 收集插件的扩展页面
@@ -600,3 +645,24 @@ export const ImportLocalPluginHandler: RequestHandler = async (req, res) => {
return sendError(res, 'Failed to import plugin: ' + e.message);
}
};
/**
* 获取插件图标
*/
export const GetPluginIconHandler: RequestHandler = async (req, res) => {
const pluginId = req.params['pluginId'];
if (!pluginId) return sendError(res, 'Plugin ID is required');
const pluginManager = getPluginManager();
if (!pluginManager) return sendError(res, 'Plugin Manager not found');
const plugin = pluginManager.getPluginInfo(pluginId);
if (!plugin) return sendError(res, 'Plugin not found');
const iconPath = findPluginIconPath(pluginId, plugin.pluginPath, plugin.packageJson?.icon);
if (!iconPath) {
return res.status(404).json({ code: -1, message: 'Icon not found' });
}
return res.sendFile(iconPath);
};

View File

@@ -287,6 +287,95 @@ async function extractPlugin (zipPath: string, pluginId: string): Promise<void>
console.log('[extractPlugin] Extracted files:', files);
}
/**
* 安装后尝试缓存插件图标
* 如果插件 package.json 没有 icon 字段,则尝试从 GitHub 头像获取并缓存到 config 目录
*/
async function cachePluginIcon (pluginId: string, storePlugin: PluginStoreList['plugins'][0]): Promise<void> {
const PLUGINS_DIR = getPluginsDir();
const pluginDir = path.join(PLUGINS_DIR, pluginId);
const configDir = path.join(webUiPathWrapper.configPath, 'plugins', pluginId);
// 检查 package.json 是否已有 icon 字段
const packageJsonPath = path.join(pluginDir, 'package.json');
if (fs.existsSync(packageJsonPath)) {
try {
const pkg = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8'));
if (pkg.icon) {
const iconPath = path.join(pluginDir, pkg.icon);
if (fs.existsSync(iconPath)) {
return; // 已有 icon无需缓存
}
}
} catch {
// 忽略解析错误
}
}
// 检查是否已有缓存的图标 (固定 icon.png)
if (fs.existsSync(path.join(configDir, 'icon.png'))) {
return; // 已有缓存图标
}
// 尝试从 GitHub 获取头像
let avatarUrl: string | undefined;
// 从 downloadUrl 提取 GitHub 用户名
if (storePlugin.downloadUrl) {
try {
const url = new URL(storePlugin.downloadUrl);
if (url.hostname === 'github.com' || url.hostname === 'www.github.com') {
const parts = url.pathname.split('/').filter(Boolean);
if (parts.length >= 1) {
avatarUrl = `https://github.com/${parts[0]}.png?size=128`;
}
}
} catch {
// 忽略
}
}
// 从 homepage 提取
if (!avatarUrl && storePlugin.homepage) {
try {
const url = new URL(storePlugin.homepage);
if (url.hostname === 'github.com' || url.hostname === 'www.github.com') {
const parts = url.pathname.split('/').filter(Boolean);
if (parts.length >= 1) {
avatarUrl = `https://github.com/${parts[0]}.png?size=128`;
}
}
} catch {
// 忽略
}
}
if (!avatarUrl) return;
try {
// 确保 config 目录存在
if (!fs.existsSync(configDir)) {
fs.mkdirSync(configDir, { recursive: true });
}
const response = await fetch(avatarUrl, {
headers: { 'User-Agent': 'NapCat-WebUI' },
signal: AbortSignal.timeout(15000),
redirect: 'follow',
});
if (!response.ok || !response.body) return;
const iconPath = path.join(configDir, 'icon.png');
const fileStream = createWriteStream(iconPath);
await pipeline(response.body as any, fileStream);
console.log(`[cachePluginIcon] Cached icon for ${pluginId} at ${iconPath}`);
} catch (e: any) {
console.warn(`[cachePluginIcon] Failed to cache icon for ${pluginId}:`, e.message);
}
}
/**
* 获取插件商店列表
*/
@@ -374,6 +463,13 @@ export const InstallPluginFromStoreHandler: RequestHandler = async (req, res) =>
}
}
// 安装后尝试缓存插件图标(如果 package.json 没有 icon 字段),失败可跳过
try {
await cachePluginIcon(id, plugin);
} catch (e: any) {
console.warn(`[InstallPlugin] Failed to cache icon for ${id}, skipping:`, e.message);
}
return sendSuccess(res, {
message: 'Plugin installed successfully',
plugin,
@@ -497,6 +593,12 @@ export const InstallPluginFromStoreSSEHandler: RequestHandler = async (req, res)
}
sendProgress('安装成功!', 100);
// 安装后尝试缓存插件图标(如果 package.json 没有 icon 字段)
cachePluginIcon(id, plugin).catch(e => {
console.warn(`[cachePluginIcon] Failed to cache icon for ${id}:`, e.message);
});
res.write(`data: ${JSON.stringify({
success: true,
message: 'Plugin installed successfully',

View File

@@ -1,4 +1,5 @@
import { RequestHandler } from 'express';
import https from 'https';
import { WebUiDataRuntime } from '@/napcat-webui-backend/src/helper/Data';
import { WebUiConfig } from '@/napcat-webui-backend/index';
@@ -7,6 +8,37 @@ import { sendError, sendSuccess } from '@/napcat-webui-backend/src/utils/respons
import { Registry20Utils, MachineInfoUtils } from '@/napcat-webui-backend/src/utils/guid';
import os from 'node:os';
// oidb 新设备验证请求辅助函数
function oidbRequest (uid: string, body: Record<string, unknown>): Promise<Record<string, unknown>> {
return new Promise((resolve, reject) => {
const postData = JSON.stringify(body);
const req = https.request({
hostname: 'oidb.tim.qq.com',
path: `/v3/oidbinterface/oidb_0xc9e_8?uid=${encodeURIComponent(uid)}&getqrcode=1&sdkappid=39998&actype=2`,
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(postData),
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36',
'Accept': 'application/json, text/plain, */*',
},
}, (res) => {
let data = '';
res.on('data', (chunk) => { data += chunk; });
res.on('end', () => {
try {
resolve(JSON.parse(data));
} catch {
reject(new Error('Failed to parse oidb response'));
}
});
});
req.on('error', reject);
req.write(postData);
req.end();
});
}
// 获取 Registry20 路径的辅助函数
const getRegistryPath = () => {
// 优先从 WebUiDataRuntime 获取早期设置的 dataPath
@@ -171,8 +203,62 @@ export const QQPasswordLoginHandler: RequestHandler = async (req, res) => {
}
// 执行密码登录
const { result, message } = await WebUiDataRuntime.requestPasswordLogin(uin, passwordMd5);
const { result, message, needCaptcha, proofWaterUrl, needNewDevice, jumpUrl, newDevicePullQrCodeSig } = await WebUiDataRuntime.requestPasswordLogin(uin, passwordMd5);
if (!result) {
if (needCaptcha && proofWaterUrl) {
return sendSuccess(res, { needCaptcha: true, proofWaterUrl });
}
if (needNewDevice && jumpUrl) {
return sendSuccess(res, { needNewDevice: true, jumpUrl, newDevicePullQrCodeSig });
}
return sendError(res, message);
}
return sendSuccess(res, null);
};
// 验证码登录(密码登录需要验证码时的第二步)
export const QQCaptchaLoginHandler: RequestHandler = async (req, res) => {
const { uin, passwordMd5, ticket, randstr, sid } = req.body;
const isLogin = WebUiDataRuntime.getQQLoginStatus();
if (isLogin) {
return sendError(res, 'QQ Is Logined');
}
if (isEmpty(uin) || isEmpty(passwordMd5)) {
return sendError(res, 'uin or passwordMd5 is empty');
}
if (isEmpty(ticket) || isEmpty(randstr)) {
return sendError(res, 'captcha ticket or randstr is empty');
}
const { result, message, needNewDevice, jumpUrl, newDevicePullQrCodeSig: sig } = await WebUiDataRuntime.requestCaptchaLogin(uin, passwordMd5, ticket, randstr, sid || '');
if (!result) {
if (needNewDevice && jumpUrl) {
return sendSuccess(res, { needNewDevice: true, jumpUrl, newDevicePullQrCodeSig: sig });
}
return sendError(res, message);
}
return sendSuccess(res, null);
};
// 新设备验证登录(密码登录需要新设备验证时的第二步)
export const QQNewDeviceLoginHandler: RequestHandler = async (req, res) => {
const { uin, passwordMd5, newDevicePullQrCodeSig } = req.body;
const isLogin = WebUiDataRuntime.getQQLoginStatus();
if (isLogin) {
return sendError(res, 'QQ Is Logined');
}
if (isEmpty(uin) || isEmpty(passwordMd5)) {
return sendError(res, 'uin or passwordMd5 is empty');
}
if (isEmpty(newDevicePullQrCodeSig)) {
return sendError(res, 'newDevicePullQrCodeSig is empty');
}
const { result, message, needNewDevice, jumpUrl, newDevicePullQrCodeSig: sig } = await WebUiDataRuntime.requestNewDeviceLogin(uin, passwordMd5, newDevicePullQrCodeSig);
if (!result) {
if (needNewDevice && jumpUrl) {
return sendSuccess(res, { needNewDevice: true, jumpUrl, newDevicePullQrCodeSig: sig });
}
return sendError(res, message);
}
return sendSuccess(res, null);
@@ -412,4 +498,61 @@ export const QQResetLinuxDeviceIDHandler: RequestHandler = async (_, res) => {
}
};
// ============================================================
// OIDB 新设备 QR 验证
// ============================================================
// 获取新设备验证二维码 (通过 OIDB 接口)
export const QQGetNewDeviceQRCodeHandler: RequestHandler = async (req, res) => {
const { uin, jumpUrl } = req.body;
if (!uin || !jumpUrl) {
return sendError(res, 'uin and jumpUrl are required');
}
// 从 jumpUrl 中提取参数
// jumpUrl 格式: https://accounts.qq.com/safe/verify?...&uin-token=xxx&sig=yyy
// sig -> str_dev_auth_token, uin-token -> str_uin_token
const url = new URL(jumpUrl);
const strDevAuthToken = url.searchParams.get('sig') || '';
const strUinToken = url.searchParams.get('uin-token') || '';
if (!strDevAuthToken || !strUinToken) {
return sendError(res, 'Failed to get new device QR code: unable to extract sig/uin-token from jumpUrl');
}
const body = {
str_dev_auth_token: strDevAuthToken,
uint32_flag: 1,
uint32_url_type: 0,
str_uin_token: strUinToken,
str_dev_type: 'Windows',
str_dev_name: os.hostname() || 'DESKTOP-NAPCAT',
};
const result = await oidbRequest(uin, body);
return sendSuccess(res, result);
};
// 轮询新设备验证二维码状态
export const QQPollNewDeviceQRHandler: RequestHandler = async (req, res) => {
const { uin, bytesToken } = req.body;
if (!uin || !bytesToken) {
return sendError(res, 'uin and bytesToken are required');
}
try {
const body = {
uint32_flag: 0,
bytes_token: bytesToken, // base64 编码的 token
};
const result = await oidbRequest(uin, body);
// result 应包含 uint32_guarantee_status:
// 0 = 等待扫码, 3 = 已扫码, 1 = 已确认 (包含 str_nt_succ_token)
return sendSuccess(res, result);
} catch (e) {
return sendError(res, `Failed to poll QR status: ${(e as Error).message}`);
}
};

View File

@@ -37,6 +37,12 @@ const LoginRuntime: LoginRuntimeType = {
onPasswordLoginRequested: async () => {
return { result: false, message: '密码登录功能未初始化' };
},
onCaptchaLoginRequested: async () => {
return { result: false, message: '验证码登录功能未初始化' };
},
onNewDeviceLoginRequested: async () => {
return { result: false, message: '新设备登录功能未初始化' };
},
onRestartProcessRequested: async () => {
return { result: false, message: '重启功能未初始化' };
},
@@ -148,6 +154,22 @@ export const WebUiDataRuntime = {
return LoginRuntime.NapCatHelper.onPasswordLoginRequested(uin, passwordMd5);
} as LoginRuntimeType['NapCatHelper']['onPasswordLoginRequested'],
setCaptchaLoginCall (func: LoginRuntimeType['NapCatHelper']['onCaptchaLoginRequested']): void {
LoginRuntime.NapCatHelper.onCaptchaLoginRequested = func;
},
requestCaptchaLogin: function (uin: string, passwordMd5: string, ticket: string, randstr: string, sid: string) {
return LoginRuntime.NapCatHelper.onCaptchaLoginRequested(uin, passwordMd5, ticket, randstr, sid);
} as LoginRuntimeType['NapCatHelper']['onCaptchaLoginRequested'],
setNewDeviceLoginCall (func: LoginRuntimeType['NapCatHelper']['onNewDeviceLoginRequested']): void {
LoginRuntime.NapCatHelper.onNewDeviceLoginRequested = func;
},
requestNewDeviceLogin: function (uin: string, passwordMd5: string, newDevicePullQrCodeSig: string) {
return LoginRuntime.NapCatHelper.onNewDeviceLoginRequested(uin, passwordMd5, newDevicePullQrCodeSig);
} as LoginRuntimeType['NapCatHelper']['onNewDeviceLoginRequested'],
setOnOB11ConfigChanged (func: LoginRuntimeType['NapCatHelper']['onOB11ConfigChanged']): void {
LoginRuntime.NapCatHelper.onOB11ConfigChanged = func;
},

View File

@@ -0,0 +1,12 @@
import { Router } from 'express';
import { NapCatGetConfigHandler, NapCatSetConfigHandler } from '@/napcat-webui-backend/src/api/NapCatConfig';
const router: Router = Router();
// router:获取 NapCat 配置
router.get('/GetConfig', NapCatGetConfigHandler);
// router:设置 NapCat 配置
router.post('/SetConfig', NapCatSetConfigHandler);
export { router as NapCatConfigRouter };

View File

@@ -12,7 +12,8 @@ import {
RegisterPluginManagerHandler,
PluginConfigSSEHandler,
PluginConfigChangeHandler,
ImportLocalPluginHandler
ImportLocalPluginHandler,
GetPluginIconHandler
} from '@/napcat-webui-backend/src/api/Plugin';
import {
GetPluginStoreListHandler,
@@ -67,7 +68,8 @@ router.post('/Config', SetPluginConfigHandler);
router.get('/Config/SSE', PluginConfigSSEHandler);
router.post('/Config/Change', PluginConfigChangeHandler);
router.post('/RegisterManager', RegisterPluginManagerHandler);
router.post('/Import', upload.single('plugin'), ImportLocalPluginHandler);
// router.post('/Import', upload.single('plugin'), ImportLocalPluginHandler); // 禁用插件上传
router.get('/Icon/:pluginId', GetPluginIconHandler);
// 插件商店相关路由
router.get('/Store/List', GetPluginStoreListHandler);

View File

@@ -11,6 +11,10 @@ import {
setAutoLoginAccountHandler,
QQRefreshQRcodeHandler,
QQPasswordLoginHandler,
QQCaptchaLoginHandler,
QQNewDeviceLoginHandler,
QQGetNewDeviceQRCodeHandler,
QQPollNewDeviceQRHandler,
QQResetDeviceIDHandler,
QQRestartNapCatHandler,
QQGetDeviceGUIDHandler,
@@ -50,6 +54,14 @@ router.post('/SetQuickLoginQQ', setAutoLoginAccountHandler);
router.post('/RefreshQRcode', QQRefreshQRcodeHandler);
// router:密码登录
router.post('/PasswordLogin', QQPasswordLoginHandler);
// router:验证码登录(密码登录需要验证码时的第二步)
router.post('/CaptchaLogin', QQCaptchaLoginHandler);
// router:新设备验证登录(密码登录需要新设备验证时的第二步)
router.post('/NewDeviceLogin', QQNewDeviceLoginHandler);
// router:获取新设备验证二维码 (OIDB)
router.post('/GetNewDeviceQRCode', QQGetNewDeviceQRCodeHandler);
// router:轮询新设备验证二维码状态 (OIDB)
router.post('/PollNewDeviceQR', QQPollNewDeviceQRHandler);
// router:重置设备信息
router.post('/ResetDeviceID', QQResetDeviceIDHandler);
// router:重启NapCat

View File

@@ -19,6 +19,7 @@ import DebugRouter from '@/napcat-webui-backend/src/api/Debug';
import { ProcessRouter } from './Process';
import { PluginRouter } from './Plugin';
import { MirrorRouter } from './Mirror';
import { NapCatConfigRouter } from './NapCatConfig';
const router: Router = Router();
@@ -53,5 +54,7 @@ router.use('/Process', ProcessRouter);
router.use('/Plugin', PluginRouter);
// router:镜像管理相关路由
router.use('/Mirror', MirrorRouter);
// router:NapCat配置相关路由
router.use('/NapCatConfig', NapCatConfigRouter);
export { router as ALLRouter };

View File

@@ -57,7 +57,9 @@ export interface LoginRuntimeType {
OneBotContext: any | null; // OneBot 上下文,用于调试功能
NapCatHelper: {
onQuickLoginRequested: (uin: string) => Promise<{ result: boolean; message: string; }>;
onPasswordLoginRequested: (uin: string, passwordMd5: string) => Promise<{ result: boolean; message: string; }>;
onPasswordLoginRequested: (uin: string, passwordMd5: string) => Promise<{ result: boolean; message: string; needCaptcha?: boolean; proofWaterUrl?: string; needNewDevice?: boolean; jumpUrl?: string; newDevicePullQrCodeSig?: string; }>;
onCaptchaLoginRequested: (uin: string, passwordMd5: string, ticket: string, randstr: string, sid: string) => Promise<{ result: boolean; message: string; needNewDevice?: boolean; jumpUrl?: string; newDevicePullQrCodeSig?: string; }>;
onNewDeviceLoginRequested: (uin: string, passwordMd5: string, newDevicePullQrCodeSig: string) => Promise<{ result: boolean; message: string; needNewDevice?: boolean; jumpUrl?: string; newDevicePullQrCodeSig?: string; }>;
onOB11ConfigChanged: (ob11: OneBotConfig) => Promise<void>;
onRestartProcessRequested: () => Promise<{ result: boolean; message: string; }>;
QQLoginList: string[];

View File

@@ -54,7 +54,7 @@ const AddButton: React.FC<AddButtonProps> = (props) => {
textValue='title'
>
<div className='flex items-center gap-2 justify-center'>
<div className='w-5 h-5 -ml-3'>
<div className='w-5 h-5 -ml-3 flex items-center justify-center'>
<PlusIcon />
</div>
<div className='text-primary-400'></div>

View File

@@ -12,43 +12,18 @@ import { useState } from 'react';
import key from '@/const/key';
import { PluginItem } from '@/controllers/plugin_manager';
/** 提取作者头像 URL */
function getAuthorAvatar (homepage?: string, repository?: string): string | undefined {
// 1. 尝试从 repository 提取 GitHub 用户名
if (repository) {
try {
// 处理 git+https://github.com/... 或 https://github.com/...
const repoUrl = repository.replace(/^git\+/, '').replace(/\.git$/, '');
const url = new URL(repoUrl);
if (url.hostname === 'github.com' || url.hostname === 'www.github.com') {
const parts = url.pathname.split('/').filter(Boolean);
if (parts.length >= 1) {
return `https://github.com/${parts[0]}.png`;
}
}
} catch {
// 忽略解析错误
}
function getPluginIconUrl (iconPath?: string): string | undefined {
if (!iconPath) return undefined;
try {
const raw = localStorage.getItem(key.token);
if (!raw) return iconPath;
const token = JSON.parse(raw);
const url = new URL(iconPath, window.location.origin);
url.searchParams.set('webui_token', token);
return url.pathname + url.search;
} catch {
return iconPath;
}
// 2. 尝试从 homepage 提取
if (homepage) {
try {
const url = new URL(homepage);
if (url.hostname === 'github.com' || url.hostname === 'www.github.com') {
const parts = url.pathname.split('/').filter(Boolean);
if (parts.length >= 1) {
return `https://github.com/${parts[0]}.png`;
}
} else {
// 如果是自定义域名,尝试获取 favicon
return `https://api.iowen.cn/favicon/${url.hostname}.png`;
}
} catch {
// 忽略解析错误
}
}
return undefined;
}
export interface PluginDisplayCardProps {
@@ -66,15 +41,15 @@ const PluginDisplayCard: React.FC<PluginDisplayCardProps> = ({
onConfig,
hasConfig = false,
}) => {
const { name, version, author, description, status, homepage, repository } = data;
const { name, version, author, description, status, icon } = data;
const isEnabled = status === 'active';
const [processing, setProcessing] = useState(false);
const [isExpanded, setIsExpanded] = useState(false);
const [backgroundImage] = useLocalStorage<string>(key.backgroundImage, '');
const hasBackground = !!backgroundImage;
// 综合尝试提取头像,最后兜底使用 Vercel 风格头像
const avatarUrl = getAuthorAvatar(homepage, repository) || `https://avatar.vercel.sh/${encodeURIComponent(name)}`;
// 后端已处理 icon前端只需拼接 token无 icon 时兜底 Vercel 风格头像
const avatarUrl = getPluginIconUrl(icon) || `https://avatar.vercel.sh/${encodeURIComponent(name)}`;
const handleToggle = () => {
setProcessing(true);

View File

@@ -0,0 +1,158 @@
import { useEffect, useRef, useState, useCallback } from 'react';
import { Button } from '@heroui/button';
import { Spinner } from '@heroui/spinner';
import { QRCodeSVG } from 'qrcode.react';
import QQManager from '@/controllers/qq_manager';
interface NewDeviceVerifyProps {
/** jumpUrl from loginErrorInfo */
jumpUrl: string;
/** QQ uin for OIDB requests */
uin: string;
/** Called when QR verification is confirmed, passes str_nt_succ_token */
onVerified: (token: string) => void;
/** Called when user cancels */
onCancel?: () => void;
}
type QRStatus = 'loading' | 'waiting' | 'scanned' | 'confirmed' | 'error';
const NewDeviceVerify: React.FC<NewDeviceVerifyProps> = ({
jumpUrl,
uin,
onVerified,
onCancel,
}) => {
const [qrUrl, setQrUrl] = useState<string>('');
const [status, setStatus] = useState<QRStatus>('loading');
const [errorMsg, setErrorMsg] = useState<string>('');
const pollTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const mountedRef = useRef(true);
const stopPolling = useCallback(() => {
if (pollTimerRef.current) {
clearInterval(pollTimerRef.current);
pollTimerRef.current = null;
}
}, []);
const startPolling = useCallback((token: string) => {
stopPolling();
pollTimerRef.current = setInterval(async () => {
if (!mountedRef.current) return;
try {
const result = await QQManager.pollNewDeviceQR(uin, token);
if (!mountedRef.current) return;
const s = result?.uint32_guarantee_status;
if (s === 3) {
setStatus('scanned');
} else if (s === 1) {
stopPolling();
setStatus('confirmed');
const ntToken = result?.str_nt_succ_token || '';
onVerified(ntToken);
}
// s === 0 means still waiting, do nothing
} catch {
// Ignore poll errors, keep polling
}
}, 2500);
}, [uin, onVerified, stopPolling]);
const fetchQRCode = useCallback(async () => {
setStatus('loading');
setErrorMsg('');
try {
const result = await QQManager.getNewDeviceQRCode(uin, jumpUrl);
if (!mountedRef.current) return;
if (result?.str_url) {
setQrUrl(result.str_url);
setStatus('waiting');
// bytes_token 用于轮询,如果 OIDB 未返回则用空字符串
startPolling(result.bytes_token || '');
} else {
setStatus('error');
setErrorMsg('获取二维码失败,请重试');
}
} catch (e) {
if (!mountedRef.current) return;
setStatus('error');
setErrorMsg((e as Error).message || '获取二维码失败');
}
}, [uin, jumpUrl, startPolling]);
useEffect(() => {
mountedRef.current = true;
fetchQRCode();
return () => {
mountedRef.current = false;
stopPolling();
};
}, [fetchQRCode, stopPolling]);
const statusText: Record<QRStatus, string> = {
loading: '正在获取二维码...',
waiting: '请使用手机QQ扫描二维码完成验证',
scanned: '已扫描,请在手机上确认',
confirmed: '验证成功,正在登录...',
error: errorMsg || '获取二维码失败',
};
const statusColor: Record<QRStatus, string> = {
loading: 'text-default-500',
waiting: 'text-warning',
scanned: 'text-primary',
confirmed: 'text-success',
error: 'text-danger',
};
return (
<div className='flex flex-col gap-4 items-center'>
<p className='text-warning text-sm'>
使QQ扫描下方二维码完成验证
</p>
<div className='flex flex-col items-center gap-3' style={{ minHeight: 280 }}>
{status === 'loading' ? (
<div className='flex items-center justify-center' style={{ height: 240 }}>
<Spinner size='lg' />
</div>
) : status === 'error' ? (
<div className='flex flex-col items-center justify-center gap-3' style={{ height: 240 }}>
<p className='text-danger text-sm'>{errorMsg}</p>
<Button color='primary' variant='flat' onPress={fetchQRCode}>
</Button>
</div>
) : (
<div className='p-3 bg-white rounded-lg'>
<QRCodeSVG value={qrUrl} size={220} />
</div>
)}
<p className={`text-sm ${statusColor[status]}`}>
{statusText[status]}
</p>
</div>
<div className='flex gap-3'>
{status === 'waiting' && (
<Button color='default' variant='flat' size='sm' onPress={fetchQRCode}>
</Button>
)}
<Button
variant='light'
color='danger'
size='sm'
onPress={onCancel}
>
</Button>
</div>
</div>
);
};
export default NewDeviceVerify;

View File

@@ -6,17 +6,37 @@ import { Input } from '@heroui/input';
import { useState } from 'react';
import { toast } from 'react-hot-toast';
import { IoChevronDown } from 'react-icons/io5';
import { Spinner } from '@heroui/spinner';
import type { QQItem } from '@/components/quick_login';
import { isQQQuickNewItem } from '@/utils/qq';
import TencentCaptchaModal from '@/components/tencent_captcha';
import type { CaptchaCallbackData } from '@/components/tencent_captcha';
import NewDeviceVerify from '@/components/new_device_verify';
interface PasswordLoginProps {
onSubmit: (uin: string, password: string) => void;
onCaptchaSubmit?: (uin: string, password: string, captchaData: CaptchaCallbackData) => void;
onNewDeviceVerified?: (token: string) => void;
isLoading: boolean;
qqList: (QQItem | LoginListItem)[];
captchaState?: {
needCaptcha: boolean;
proofWaterUrl: string;
uin: string;
password: string;
} | null;
captchaVerifying?: boolean;
newDeviceState?: {
needNewDevice: boolean;
jumpUrl: string;
uin: string;
} | null;
onCaptchaCancel?: () => void;
onNewDeviceCancel?: () => void;
}
const PasswordLogin: React.FC<PasswordLoginProps> = ({ onSubmit, isLoading, qqList }) => {
const PasswordLogin: React.FC<PasswordLoginProps> = ({ onSubmit, onCaptchaSubmit, onNewDeviceVerified, isLoading, qqList, captchaState, captchaVerifying, newDeviceState, onCaptchaCancel, onNewDeviceCancel }) => {
const [uin, setUin] = useState('');
const [password, setPassword] = useState('');
@@ -34,87 +54,128 @@ const PasswordLogin: React.FC<PasswordLoginProps> = ({ onSubmit, isLoading, qqLi
return (
<div className='flex flex-col gap-8'>
<div className='flex justify-center'>
<Image
className='shadow-lg'
height={100}
radius='full'
src={`https://q1.qlogo.cn/g?b=qq&nk=${uin || '0'}&s=100`}
width={100}
alt="QQ Avatar"
{captchaState?.needCaptcha && captchaState.proofWaterUrl ? (
<div className='flex flex-col gap-4 items-center'>
{captchaVerifying ? (
<>
<p className='text-primary text-sm'>...</p>
<div className='flex items-center justify-center py-8 gap-3'>
<Spinner size='lg' />
</div>
</>
) : (
<>
<p className='text-warning text-sm'></p>
<TencentCaptchaModal
proofWaterUrl={captchaState.proofWaterUrl}
onSuccess={(data) => {
onCaptchaSubmit?.(captchaState.uin, captchaState.password, data);
}}
onCancel={onCaptchaCancel}
/>
</>
)}
<Button
variant='light'
color='danger'
size='sm'
onPress={onCaptchaCancel}
>
</Button>
</div>
) : newDeviceState?.needNewDevice && newDeviceState.jumpUrl ? (
<NewDeviceVerify
jumpUrl={newDeviceState.jumpUrl}
uin={newDeviceState.uin}
onVerified={(token) => onNewDeviceVerified?.(token)}
onCancel={onNewDeviceCancel}
/>
</div>
<div className='flex flex-col gap-4'>
<Input
type="text"
label="QQ账号"
placeholder="请输入QQ号"
value={uin}
onValueChange={setUin}
variant="bordered"
size='lg'
autoComplete="off"
endContent={
<Dropdown>
<DropdownTrigger>
<Button isIconOnly variant="light" size="sm" radius="full">
<IoChevronDown size={16} />
</Button>
</DropdownTrigger>
<DropdownMenu
aria-label="QQ Login History"
items={qqList}
onAction={(key) => setUin(key.toString())}
>
{(item) => (
<DropdownItem key={item.uin} textValue={item.uin}>
<div className='flex items-center gap-2'>
<Avatar
alt={item.uin}
className='flex-shrink-0'
size='sm'
src={
isQQQuickNewItem(item)
? item.faceUrl
: `https://q1.qlogo.cn/g?b=qq&nk=${item.uin}&s=1`
}
/>
<div className='flex flex-col'>
{isQQQuickNewItem(item)
? `${item.nickName}(${item.uin})`
: item.uin}
</div>
</div>
</DropdownItem>
)}
</DropdownMenu>
</Dropdown>
}
/>
<Input
type="password"
label="密码"
placeholder="请输入密码"
value={password}
onValueChange={setPassword}
variant="bordered"
size='lg'
autoComplete="new-password"
/>
</div>
<div className='flex justify-center mt-5'>
<Button
className='w-64 max-w-full'
color='primary'
isLoading={isLoading}
radius='full'
size='lg'
variant='shadow'
onPress={handleSubmit}
>
</Button>
</div>
) : (
<>
<div className='flex justify-center'>
<Image
className='shadow-lg'
height={100}
radius='full'
src={`https://q1.qlogo.cn/g?b=qq&nk=${uin || '0'}&s=100`}
width={100}
alt="QQ Avatar"
/>
</div>
<div className='flex flex-col gap-4'>
<Input
type="text"
label="QQ账号"
placeholder="请输入QQ号"
value={uin}
onValueChange={setUin}
variant="bordered"
size='lg'
autoComplete="off"
endContent={
<Dropdown>
<DropdownTrigger>
<Button isIconOnly variant="light" size="sm" radius="full">
<IoChevronDown size={16} />
</Button>
</DropdownTrigger>
<DropdownMenu
aria-label="QQ Login History"
items={qqList}
onAction={(key) => setUin(key.toString())}
>
{(item) => (
<DropdownItem key={item.uin} textValue={item.uin}>
<div className='flex items-center gap-2'>
<Avatar
alt={item.uin}
className='flex-shrink-0'
size='sm'
src={
isQQQuickNewItem(item)
? item.faceUrl
: `https://q1.qlogo.cn/g?b=qq&nk=${item.uin}&s=1`
}
/>
<div className='flex flex-col'>
{isQQQuickNewItem(item)
? `${item.nickName}(${item.uin})`
: item.uin}
</div>
</div>
</DropdownItem>
)}
</DropdownMenu>
</Dropdown>
}
/>
<Input
type="password"
label="密码"
placeholder="请输入密码"
value={password}
onValueChange={setPassword}
variant="bordered"
size='lg'
autoComplete="new-password"
/>
</div>
<div className='flex justify-center mt-5'>
<Button
className='w-64 max-w-full'
color='primary'
isLoading={isLoading}
radius='full'
size='lg'
variant='shadow'
onPress={handleSubmit}
>
</Button>
</div>
</>
)}
</div>
);
};

View File

@@ -0,0 +1,166 @@
import { useEffect, useRef, useCallback } from 'react';
import { Spinner } from '@heroui/spinner';
declare global {
interface Window {
TencentCaptcha: new (
appid: string,
callback: (res: TencentCaptchaResult) => void,
options?: Record<string, unknown>
) => { show: () => void; destroy: () => void; };
}
}
export interface TencentCaptchaResult {
ret: number;
appid?: string;
ticket?: string;
randstr?: string;
errorCode?: number;
errorMessage?: string;
}
export interface CaptchaCallbackData {
ticket: string;
randstr: string;
appid: string;
sid: string;
}
interface TencentCaptchaProps {
/** proofWaterUrl returned from login error, contains uin/sid/aid params */
proofWaterUrl: string;
/** Called when captcha verification succeeds */
onSuccess: (data: CaptchaCallbackData) => void;
/** Called when captcha is cancelled or fails */
onCancel?: () => void;
}
function parseUrlParams (url: string): Record<string, string> {
const params: Record<string, string> = {};
try {
const u = new URL(url);
u.searchParams.forEach((v, k) => { params[k] = v; });
} catch {
const match = url.match(/[?&]([^#]+)/);
if (match) {
match[1].split('&').forEach(pair => {
const [k, v] = pair.split('=');
if (k) params[k] = decodeURIComponent(v || '');
});
}
}
return params;
}
function loadScript (src: string): Promise<void> {
return new Promise((resolve, reject) => {
if (window.TencentCaptcha) {
resolve();
return;
}
const tag = document.createElement('script');
tag.src = src;
tag.onload = () => resolve();
tag.onerror = () => reject(new Error(`Failed to load ${src}`));
document.head.appendChild(tag);
});
}
const TencentCaptchaModal: React.FC<TencentCaptchaProps> = ({
proofWaterUrl,
onSuccess,
onCancel,
}) => {
const captchaRef = useRef<{ destroy: () => void; } | null>(null);
const mountedRef = useRef(true);
const handleResult = useCallback((res: TencentCaptchaResult, sid: string) => {
if (!mountedRef.current) return;
if (res.ret === 0 && res.ticket && res.randstr) {
onSuccess({
ticket: res.ticket,
randstr: res.randstr,
appid: res.appid || '',
sid,
});
} else {
onCancel?.();
}
}, [onSuccess, onCancel]);
useEffect(() => {
mountedRef.current = true;
const params = parseUrlParams(proofWaterUrl);
const appid = params.aid || '2081081773';
const sid = params.sid || '';
const init = async () => {
try {
await loadScript('https://captcha.gtimg.com/TCaptcha.js');
} catch {
try {
await loadScript('https://ssl.captcha.qq.com/TCaptcha.js');
} catch {
// Both CDN failed, generate fallback ticket
if (mountedRef.current) {
handleResult({
ret: 0,
ticket: `terror_1001_${appid}_${Math.floor(Date.now() / 1000)}`,
randstr: '@' + Math.random().toString(36).substring(2),
errorCode: 1001,
errorMessage: 'jsload_error',
}, sid);
}
return;
}
}
if (!mountedRef.current) return;
try {
const captcha = new window.TencentCaptcha(
appid,
(res) => handleResult(res, sid),
{
type: 'popup',
showHeader: false,
login_appid: params.login_appid,
uin: params.uin,
sid: params.sid,
enableAged: true,
}
);
captchaRef.current = captcha;
captcha.show();
} catch {
if (mountedRef.current) {
handleResult({
ret: 0,
ticket: `terror_1001_${appid}_${Math.floor(Date.now() / 1000)}`,
randstr: '@' + Math.random().toString(36).substring(2),
errorCode: 1001,
errorMessage: 'init_error',
}, sid);
}
}
};
init();
return () => {
mountedRef.current = false;
captchaRef.current?.destroy();
captchaRef.current = null;
};
}, [proofWaterUrl, handleResult]);
return (
<div className="flex items-center justify-center py-8 gap-3">
<Spinner size="lg" />
<span className="text-default-500">...</span>
</div>
);
};
export default TencentCaptchaModal;

View File

@@ -26,6 +26,8 @@ export interface PluginItem {
homepage?: string;
/** 仓库链接 */
repository?: string;
/** 插件图标 URL由后端返回 */
icon?: string;
}
/** 扩展页面信息 */

View File

@@ -96,10 +96,93 @@ export default class QQManager {
}
public static async passwordLogin (uin: string, passwordMd5: string) {
await serverRequest.post<ServerResponse<null>>('/QQLogin/PasswordLogin', {
const data = await serverRequest.post<ServerResponse<{
needCaptcha?: boolean;
proofWaterUrl?: string;
needNewDevice?: boolean;
jumpUrl?: string;
newDevicePullQrCodeSig?: string;
} | null>>('/QQLogin/PasswordLogin', {
uin,
passwordMd5,
});
return data.data.data;
}
public static async captchaLogin (uin: string, passwordMd5: string, ticket: string, randstr: string, sid: string) {
const data = await serverRequest.post<ServerResponse<{
needNewDevice?: boolean;
jumpUrl?: string;
newDevicePullQrCodeSig?: string;
} | null>>('/QQLogin/CaptchaLogin', {
uin,
passwordMd5,
ticket,
randstr,
sid,
});
return data.data.data;
}
public static async newDeviceLogin (uin: string, passwordMd5: string, newDevicePullQrCodeSig: string) {
const data = await serverRequest.post<ServerResponse<{
needNewDevice?: boolean;
jumpUrl?: string;
newDevicePullQrCodeSig?: string;
} | null>>('/QQLogin/NewDeviceLogin', {
uin,
passwordMd5,
newDevicePullQrCodeSig,
});
return data.data.data;
}
public static async getNewDeviceQRCode (uin: string, jumpUrl: string) {
const data = await serverRequest.post<ServerResponse<{
str_url?: string;
bytes_token?: string;
uint32_guarantee_status?: number;
ActionStatus?: string;
ErrorCode?: number;
ErrorInfo?: string;
}>>('/QQLogin/GetNewDeviceQRCode', {
uin,
jumpUrl,
});
const result = data.data.data;
if (result?.str_url) {
let bytesToken = result.bytes_token || '';
if (!bytesToken && result.str_url) {
// 只对 str_url 参数值做 base64 编码
try {
const urlObj = new URL(result.str_url);
const strUrlParam = urlObj.searchParams.get('str_url') || '';
bytesToken = strUrlParam ? btoa(strUrlParam) : '';
} catch {
bytesToken = '';
}
}
return {
str_url: result.str_url,
bytes_token: bytesToken,
uint32_guarantee_status: result.uint32_guarantee_status,
ActionStatus: result.ActionStatus,
ErrorCode: result.ErrorCode,
ErrorInfo: result.ErrorInfo,
};
}
return result;
}
public static async pollNewDeviceQR (uin: string, bytesToken: string) {
const data = await serverRequest.post<ServerResponse<{
uint32_guarantee_status?: number;
str_nt_succ_token?: string;
}>>('/QQLogin/PollNewDeviceQR', {
uin,
bytesToken,
});
return data.data.data;
}
public static async resetDeviceID () {
@@ -178,5 +261,23 @@ export default class QQManager {
public static async resetLinuxDeviceID () {
await serverRequest.post<ServerResponse<null>>('/QQLogin/ResetLinuxDeviceID');
}
// ============================================================
// NapCat 配置管理
// ============================================================
public static async getNapCatConfig () {
const { data } = await serverRequest.get<ServerResponse<NapCatConfig>>(
'/NapCatConfig/GetConfig'
);
return data.data;
}
public static async setNapCatConfig (config: Partial<NapCatConfig>) {
await serverRequest.post<ServerResponse<null>>(
'/NapCatConfig/SetConfig',
config
);
}
}

View File

@@ -0,0 +1,175 @@
import { useEffect, useState } from 'react';
import { Controller, useForm } from 'react-hook-form';
import toast from 'react-hot-toast';
import SaveButtons from '@/components/button/save_buttons';
import PageLoading from '@/components/page_loading';
import SwitchCard from '@/components/switch_card';
import QQManager from '@/controllers/qq_manager';
interface BypassFormData {
hook: boolean;
window: boolean;
module: boolean;
process: boolean;
container: boolean;
js: boolean;
o3HookMode: boolean;
}
const BypassConfigCard = () => {
const [loading, setLoading] = useState(true);
const {
control,
handleSubmit,
formState: { isSubmitting },
setValue,
} = useForm<BypassFormData>();
const loadConfig = async (showTip = false) => {
try {
setLoading(true);
const config = await QQManager.getNapCatConfig();
const bypass = config.bypass ?? {} as Partial<BypassOptions>;
setValue('hook', bypass.hook ?? false);
setValue('window', bypass.window ?? false);
setValue('module', bypass.module ?? false);
setValue('process', bypass.process ?? false);
setValue('container', bypass.container ?? false);
setValue('js', bypass.js ?? false);
setValue('o3HookMode', config.o3HookMode === 1);
if (showTip) toast.success('刷新成功');
} catch (error) {
const msg = (error as Error).message;
toast.error(`获取配置失败: ${msg}`);
} finally {
setLoading(false);
}
};
const onSubmit = handleSubmit(async (data) => {
try {
const { o3HookMode, ...bypass } = data;
await QQManager.setNapCatConfig({ bypass, o3HookMode: o3HookMode ? 1 : 0 });
toast.success('保存成功,重启后生效');
} catch (error) {
const msg = (error as Error).message;
toast.error(`保存失败: ${msg}`);
}
});
const onReset = () => {
loadConfig();
};
const onRefresh = async () => {
await loadConfig(true);
};
useEffect(() => {
loadConfig();
}, []);
if (loading) return <PageLoading loading />;
return (
<>
<title> - NapCat WebUI</title>
<div className='flex flex-col gap-1 mb-2'>
<h3 className='text-lg font-semibold text-default-700'></h3>
<p className='text-sm text-default-500'>
Napi2Native
</p>
</div>
<div className='grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3'>
<Controller
control={control}
name='hook'
render={({ field }) => (
<SwitchCard
{...field}
label='Hook'
description='hook特征隐藏'
/>
)}
/>
<Controller
control={control}
name='window'
render={({ field }) => (
<SwitchCard
{...field}
label='Window'
description='窗口伪造'
/>
)}
/>
<Controller
control={control}
name='module'
render={({ field }) => (
<SwitchCard
{...field}
label='Module'
description='加载模块隐藏'
/>
)}
/>
<Controller
control={control}
name='process'
render={({ field }) => (
<SwitchCard
{...field}
label='Process'
description='进程反检测'
/>
)}
/>
<Controller
control={control}
name='container'
render={({ field }) => (
<SwitchCard
{...field}
label='Container'
description='容器反检测'
/>
)}
/>
<Controller
control={control}
name='js'
render={({ field }) => (
<SwitchCard
{...field}
label='JS'
description='JS反检测'
/>
)}
/>
<Controller
control={control}
name='o3HookMode'
render={({ field }) => (
<SwitchCard
{...field}
label='o3HookMode'
description='O3 Hook 模式'
/>
)}
/>
</div>
<SaveButtons
onSubmit={onSubmit}
reset={onReset}
isSubmitting={isSubmitting}
refresh={onRefresh}
/>
</>
);
};
export default BypassConfigCard;

View File

@@ -14,6 +14,7 @@ import SSLConfigCard from './ssl';
import ThemeConfigCard from './theme';
import WebUIConfigCard from './webui';
import BackupConfigCard from './backup';
import BypassConfigCard from './bypass';
export interface ConfigPageProps {
children?: React.ReactNode;
@@ -114,6 +115,11 @@ export default function ConfigPage () {
<BackupConfigCard />
</ConfigPageItem>
</Tab>
<Tab title='反检测' key='bypass'>
<ConfigPageItem>
<BypassConfigCard />
</ConfigPageItem>
</Tab>
</Tabs>
</section>
);

View File

@@ -131,7 +131,7 @@ const WebUIConfigCard = () => {
isLoading={isLoadingOptions}
className='w-fit'
>
{!isLoadingOptions && '📥'}
{!isLoadingOptions}
</Button>
<Button
@@ -225,12 +225,12 @@ const WebUIConfigCard = () => {
disabled={!registrationOptions}
className='w-fit'
>
🔐 Passkey
Passkey
</Button>
</div>
{registrationOptions && (
<div className='text-xs text-green-600'>
</div>
)}
</div>

View File

@@ -1,7 +1,7 @@
import { Tab, Tabs } from '@heroui/tabs';
import { Button } from '@heroui/button';
import { Spinner } from '@heroui/spinner';
import { useEffect, useState, useMemo } from 'react';
import { useEffect, useState, useMemo, useRef, useCallback } from 'react';
import toast from 'react-hot-toast';
import { IoMdRefresh } from 'react-icons/io';
import { MdExtension } from 'react-icons/md';
@@ -93,14 +93,45 @@ export default function ExtensionPage () {
window.open(url, '_blank');
};
// 拖拽滚动支持(鼠标 + 触摸)
const scrollRef = useRef<HTMLDivElement>(null);
const isDragging = useRef(false);
const startX = useRef(0);
const scrollLeft = useRef(0);
const handlePointerDown = useCallback((e: React.PointerEvent) => {
const el = scrollRef.current;
if (!el) return;
isDragging.current = true;
startX.current = e.clientX;
scrollLeft.current = el.scrollLeft;
el.setPointerCapture(e.pointerId);
el.style.cursor = 'grabbing';
el.style.userSelect = 'none';
}, []);
const handlePointerMove = useCallback((e: React.PointerEvent) => {
if (!isDragging.current || !scrollRef.current) return;
const dx = e.clientX - startX.current;
scrollRef.current.scrollLeft = scrollLeft.current - dx;
}, []);
const handlePointerUp = useCallback((e: React.PointerEvent) => {
if (!isDragging.current || !scrollRef.current) return;
isDragging.current = false;
scrollRef.current.releasePointerCapture(e.pointerId);
scrollRef.current.style.cursor = 'grab';
scrollRef.current.style.userSelect = '';
}, []);
return (
<>
<title> - NapCat WebUI</title>
<div className='p-2 md:p-4 relative h-[calc(100vh-6rem)] md:h-[calc(100vh-4rem)] flex flex-col'>
<PageLoading loading={loading} />
<div className='flex mb-4 items-center justify-between gap-4 flex-wrap'>
<div className='flex items-center gap-4'>
<div className='flex mb-4 items-center gap-4 flex-nowrap min-w-0'>
<div className='flex items-center gap-4 shrink-0'>
<div className='flex items-center gap-2 text-default-600'>
<MdExtension size={24} />
<span className='text-lg font-medium'></span>
@@ -115,39 +146,49 @@ export default function ExtensionPage () {
</Button>
</div>
{extensionPages.length > 0 && (
<Tabs
aria-label='Extension Pages'
className='max-w-full'
selectedKey={selectedTab}
onSelectionChange={(key) => setSelectedTab(key as string)}
classNames={{
tabList: 'bg-white/40 dark:bg-black/20 backdrop-blur-md',
cursor: 'bg-white/80 dark:bg-white/10 backdrop-blur-md shadow-sm',
panel: 'hidden',
}}
<div
ref={scrollRef}
className='overflow-x-auto min-w-0 flex-1 scrollbar-thin scrollbar-thumb-default-300 scrollbar-track-transparent cursor-grab touch-pan-x'
style={{ WebkitOverflowScrolling: 'touch' }}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerUp}
>
{tabs.map((tab) => (
<Tab
key={tab.key}
title={
<div className='flex items-center gap-2'>
{tab.icon && <span>{tab.icon}</span>}
<span
className='cursor-pointer hover:underline truncate max-w-[6rem] md:max-w-none'
title={`插件:${tab.pluginName}\n点击在新窗口打开`}
onClick={(e) => {
e.stopPropagation();
openInNewWindow(tab.pluginId, tab.path);
}}
>
{tab.title}
</span>
<span className='text-xs text-default-400 hidden md:inline'>({tab.pluginName})</span>
</div>
}
/>
))}
</Tabs>
<Tabs
aria-label='Extension Pages'
className='w-max min-w-full'
selectedKey={selectedTab}
onSelectionChange={(key) => setSelectedTab(key as string)}
classNames={{
tabList: 'bg-white/40 dark:bg-black/20 backdrop-blur-md flex-nowrap',
cursor: 'bg-white/80 dark:bg-white/10 backdrop-blur-md shadow-sm',
panel: 'hidden',
}}
>
{tabs.map((tab) => (
<Tab
key={tab.key}
title={
<div className='flex items-center gap-2 whitespace-nowrap'>
{tab.icon && <span>{tab.icon}</span>}
<span
className='cursor-pointer hover:underline truncate max-w-[6rem] md:max-w-none'
title={`插件:${tab.pluginName}\n点击在新窗口打开`}
onClick={(e) => {
e.stopPropagation();
openInNewWindow(tab.pluginId, tab.path);
}}
>
{tab.title}
</span>
<span className='text-xs text-default-400 hidden md:inline'>({tab.pluginName})</span>
</div>
}
/>
))}
</Tabs>
</div>
)}
</div>

View File

@@ -61,30 +61,28 @@ export default function PluginPage () {
const handleUninstall = async (plugin: PluginItem) => {
return new Promise<void>((resolve, reject) => {
let cleanData = false;
dialog.confirm({
title: '卸载插件',
content: (
<div className="flex flex-col gap-2">
<p>{plugin.name}? </p>
<p className="text-small text-default-500"></p>
<p className="text-base text-default-800"><span className="font-semibold text-danger">{plugin.name}</span>? </p>
<div className="mt-2 bg-default-100 dark:bg-default-50/10 p-3 rounded-lg flex flex-col gap-1">
<label className="flex items-center gap-2 cursor-pointer w-fit">
<input
type="checkbox"
onChange={(e) => { cleanData = e.target.checked; }}
className="w-4 h-4 cursor-pointer accent-danger"
/>
<span className="text-small font-medium text-default-700"></span>
</label>
<p className="text-xs text-default-500 pl-6 break-all w-full">配置目录: config/plugins/{plugin.id}</p>
</div>
</div>
),
// This 'dialog' utility might not support returning a value from UI interacting.
// We might need to implement a custom confirmation flow if we want a checkbox.
// Alternatively, use two buttons? "Uninstall & Clean", "Uninstall Only"?
// Standard dialog usually has Confirm/Cancel.
// Let's stick to a simpler "Uninstall" and then maybe a second prompt? Or just clean data?
// User requested: "Uninstall prompts whether to clean data".
// Let's use `window.confirm` for the second step or assume `dialog.confirm` is flexible enough?
// I will implement a two-step confirmation or try to modify the dialog hook if visible (not visible here).
// Let's use a standard `window.confirm` for the data cleanup question if the custom dialog doesn't support complex return.
// Better: Inside onConfirm, ask again?
confirmText: '确定卸载',
cancelText: '取消',
onConfirm: async () => {
// Ask for data cleanup
// Since we are in an async callback, we can use another dialog or confirm.
// Native confirm is ugly but works reliably for logic:
const cleanData = window.confirm(`是否同时清理插件「${plugin.name}」的数据文件?\n点击“确定”清理数据点击“取消”仅卸载插件。`);
const loadingToast = toast.loading('卸载中...');
try {
await PluginManager.uninstallPlugin(plugin.id, cleanData);
@@ -184,6 +182,7 @@ export default function PluginPage () {
>
<IoMdRefresh size={24} />
</Button>
{/* 禁用插件上传
<Button
className="bg-primary-100/50 hover:bg-primary-200/50 text-primary-700 backdrop-blur-md"
radius='full'
@@ -199,6 +198,7 @@ export default function PluginPage () {
className="hidden"
onChange={handleFileChange}
/>
*/}
</div>
{pluginManagerNotFound ? (

View File

@@ -374,10 +374,10 @@ export default function PluginConfigModal ({ isOpen, onOpenChange, pluginId }: P
</ModalBody>
<ModalFooter>
<Button color="danger" variant="light" onPress={onClose}>
Close
</Button>
<Button color="primary" onPress={handleSave} isLoading={saving}>
Save
</Button>
</ModalFooter>
</>

View File

@@ -3,7 +3,8 @@ import { Input } from '@heroui/input';
import { Tab, Tabs } from '@heroui/tabs';
import { Tooltip } from '@heroui/tooltip';
import { Spinner } from '@heroui/spinner';
import { useEffect, useMemo, useState, useRef } from 'react';
import { Pagination } from '@heroui/pagination';
import { useEffect, useMemo, useState, useRef, useCallback } from 'react';
import toast from 'react-hot-toast';
import { IoMdRefresh, IoMdSearch, IoMdSettings } from 'react-icons/io';
import clsx from 'clsx';
@@ -19,6 +20,16 @@ import { PluginStoreItem } from '@/types/plugin-store';
import useDialog from '@/hooks/use-dialog';
import key from '@/const/key';
/** Fisher-Yates 洗牌算法,返回新数组 */
function shuffleArray<T> (arr: T[]): T[] {
const shuffled = [...arr];
for (let i = shuffled.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
}
return shuffled;
}
interface EmptySectionProps {
isEmpty: boolean;
}
@@ -86,6 +97,10 @@ export default function PluginStorePage () {
const [detailModalOpen, setDetailModalOpen] = useState(false);
const [selectedPlugin, setSelectedPlugin] = useState<PluginStoreItem | null>(null);
// 分页状态
const ITEMS_PER_PAGE = 20;
const [currentPage, setCurrentPage] = useState(1);
const loadPlugins = async (forceRefresh: boolean = false) => {
setLoading(true);
try {
@@ -145,6 +160,7 @@ export default function PluginStorePage () {
tools: filtered.filter(p => p.tags?.includes('工具')),
entertainment: filtered.filter(p => p.tags?.includes('娱乐')),
other: filtered.filter(p => !p.tags?.some(t => ['官方', '工具', '娱乐'].includes(t))),
random: shuffleArray(filtered),
};
return categories;
@@ -175,9 +191,30 @@ export default function PluginStorePage () {
{ key: 'tools', title: '工具', count: categorizedPlugins.tools?.length || 0 },
{ key: 'entertainment', title: '娱乐', count: categorizedPlugins.entertainment?.length || 0 },
{ key: 'other', title: '其它', count: categorizedPlugins.other?.length || 0 },
{ key: 'random', title: '随机', count: categorizedPlugins.random?.length || 0 },
];
}, [categorizedPlugins]);
// 当前分类的总数和分页数据
const currentCategoryPlugins = useMemo(() => categorizedPlugins[activeTab] || [], [categorizedPlugins, activeTab]);
const totalPages = useMemo(() => Math.max(1, Math.ceil(currentCategoryPlugins.length / ITEMS_PER_PAGE)), [currentCategoryPlugins.length]);
const paginatedPlugins = useMemo(() => {
const start = (currentPage - 1) * ITEMS_PER_PAGE;
return currentCategoryPlugins.slice(start, start + ITEMS_PER_PAGE);
}, [currentCategoryPlugins, currentPage]);
// 切换分类或搜索时重置页码
const handleTabChange = useCallback((key: string) => {
setActiveTab(key);
setCurrentPage(1);
}, []);
// 搜索变化时重置页码
const handleSearchChange = useCallback((value: string) => {
setSearchQuery(value);
setCurrentPage(1);
}, []);
const handleInstall = async (plugin: PluginStoreItem) => {
// 弹窗选择下载镜像
setPendingInstallPlugin(plugin);
@@ -338,7 +375,7 @@ export default function PluginStorePage () {
placeholder='搜索(Ctrl+F)...'
startContent={<IoMdSearch className='text-default-400' />}
value={searchQuery}
onValueChange={setSearchQuery}
onValueChange={handleSearchChange}
className='max-w-xs w-full'
size='sm'
isClearable
@@ -370,7 +407,7 @@ export default function PluginStorePage () {
aria-label='Plugin Store Categories'
className='max-w-full'
selectedKey={activeTab}
onSelectionChange={(key) => setActiveTab(String(key))}
onSelectionChange={(key) => handleTabChange(String(key))}
classNames={{
tabList: 'bg-white/40 dark:bg-black/20 backdrop-blur-md',
cursor: 'bg-white/80 dark:bg-white/10 backdrop-blur-md shadow-sm',
@@ -395,9 +432,9 @@ export default function PluginStorePage () {
</div>
)}
<EmptySection isEmpty={!categorizedPlugins[activeTab]?.length} />
<EmptySection isEmpty={!currentCategoryPlugins.length} />
<div className='grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 justify-start items-stretch gap-4'>
{categorizedPlugins[activeTab]?.map((plugin) => {
{paginatedPlugins.map((plugin) => {
const installInfo = getPluginInstallInfo(plugin);
return (
<PluginStoreCard
@@ -414,6 +451,24 @@ export default function PluginStorePage () {
);
})}
</div>
{/* 分页控件 */}
{totalPages > 1 && (
<div className='flex justify-center mt-6 mb-2'>
<Pagination
total={totalPages}
page={currentPage}
onChange={setCurrentPage}
showControls
showShadow
color='primary'
size='lg'
classNames={{
wrapper: 'backdrop-blur-md bg-white/40 dark:bg-black/20',
}}
/>
</div>
)}
</div>
</div>

View File

@@ -19,6 +19,7 @@ import QrCodeLogin from '@/components/qr_code_login';
import QuickLogin from '@/components/quick_login';
import type { QQItem } from '@/components/quick_login';
import { ThemeSwitch } from '@/components/theme-switch';
import type { CaptchaCallbackData } from '@/components/tencent_captcha';
import QQManager from '@/controllers/qq_manager';
import useDialog from '@/hooks/use-dialog';
@@ -58,6 +59,21 @@ export default function QQLoginPage () {
const [refresh, setRefresh] = useState<boolean>(false);
const [activeTab, setActiveTab] = useState<string>('shortcut');
const firstLoad = useRef<boolean>(true);
const [captchaState, setCaptchaState] = useState<{
needCaptcha: boolean;
proofWaterUrl: string;
uin: string;
password: string;
} | null>(null);
const [captchaVerifying, setCaptchaVerifying] = useState(false);
const [newDeviceState, setNewDeviceState] = useState<{
needNewDevice: boolean;
jumpUrl: string;
newDevicePullQrCodeSig: string;
uin: string;
password: string;
} | null>(null);
// newDevicePullQrCodeSig is kept for step:2 login after QR verification
const onSubmit = async () => {
if (!uinValue) {
toast.error('请选择快捷登录的QQ');
@@ -83,8 +99,28 @@ export default function QQLoginPage () {
try {
// 计算密码的MD5值
const passwordMd5 = CryptoJS.MD5(password).toString();
await QQManager.passwordLogin(uin, passwordMd5);
toast.success('密码登录请求已发送');
const result = await QQManager.passwordLogin(uin, passwordMd5);
if (result?.needCaptcha && result.proofWaterUrl) {
// 需要验证码,显示验证码组件
setCaptchaState({
needCaptcha: true,
proofWaterUrl: result.proofWaterUrl,
uin,
password,
});
toast('需要安全验证,请完成验证码', { icon: '🔒' });
} else if (result?.needNewDevice && result.jumpUrl) {
setNewDeviceState({
needNewDevice: true,
jumpUrl: result.jumpUrl,
newDevicePullQrCodeSig: result.newDevicePullQrCodeSig || '',
uin,
password,
});
toast('检测到新设备,请扫码验证', { icon: '📱' });
} else {
toast.success('密码登录请求已发送');
}
} catch (error) {
const msg = (error as Error).message;
toast.error(`密码登录失败: ${msg}`);
@@ -93,6 +129,75 @@ export default function QQLoginPage () {
}
};
const onCaptchaSubmit = async (uin: string, password: string, captchaData: CaptchaCallbackData) => {
setIsLoading(true);
setCaptchaVerifying(true);
try {
const passwordMd5 = CryptoJS.MD5(password).toString();
const result = await QQManager.captchaLogin(uin, passwordMd5, captchaData.ticket, captchaData.randstr, captchaData.sid);
if (result?.needNewDevice && result.jumpUrl) {
setCaptchaState(null);
setNewDeviceState({
needNewDevice: true,
jumpUrl: result.jumpUrl,
newDevicePullQrCodeSig: result.newDevicePullQrCodeSig || '',
uin,
password,
});
toast('检测到异常设备,请扫码验证', { icon: '📱' });
} else {
toast.success('验证码登录请求已发送');
setCaptchaState(null);
}
} catch (error) {
const msg = (error as Error).message;
toast.error(`验证码登录失败: ${msg}`);
setCaptchaState(null);
} finally {
setIsLoading(false);
setCaptchaVerifying(false);
}
};
const onCaptchaCancel = () => {
setCaptchaState(null);
};
const onNewDeviceVerified = async (token: string) => {
if (!newDeviceState) return;
setIsLoading(true);
try {
const passwordMd5 = CryptoJS.MD5(newDeviceState.password).toString();
// Use the str_nt_succ_token from QR verification as newDevicePullQrCodeSig for step:2
const sig = token || newDeviceState.newDevicePullQrCodeSig;
const result = await QQManager.newDeviceLogin(newDeviceState.uin, passwordMd5, sig);
if (result?.needNewDevice && result.jumpUrl) {
// 新设备验证后又触发了异常设备验证,更新 jumpUrl
setNewDeviceState({
needNewDevice: true,
jumpUrl: result.jumpUrl,
newDevicePullQrCodeSig: result.newDevicePullQrCodeSig || '',
uin: newDeviceState.uin,
password: newDeviceState.password,
});
toast('检测到异常设备,请继续扫码验证', { icon: '📱' });
} else {
toast.success('新设备验证登录请求已发送');
setNewDeviceState(null);
}
} catch (error) {
const msg = (error as Error).message;
toast.error(`新设备验证登录失败: ${msg}`);
setNewDeviceState(null);
} finally {
setIsLoading(false);
}
};
const onNewDeviceCancel = () => {
setNewDeviceState(null);
};
const onUpdateQrCode = async () => {
if (firstLoad.current) setIsLoading(true);
try {
@@ -249,7 +354,14 @@ export default function QQLoginPage () {
<PasswordLogin
isLoading={isLoading}
onSubmit={onPasswordSubmit}
onCaptchaSubmit={onCaptchaSubmit}
onNewDeviceVerified={onNewDeviceVerified}
qqList={qqList}
captchaState={captchaState}
captchaVerifying={captchaVerifying}
newDeviceState={newDeviceState}
onCaptchaCancel={onCaptchaCancel}
onNewDeviceCancel={onNewDeviceCancel}
/>
</Tab>
<Tab key='qrcode' title='扫码登录'>

View File

@@ -0,0 +1,19 @@
interface BypassOptions {
hook: boolean;
window: boolean;
module: boolean;
process: boolean;
container: boolean;
js: boolean;
}
interface NapCatConfig {
fileLog: boolean;
consoleLog: boolean;
fileLogLevel: string;
consoleLogLevel: string;
packetBackend: string;
packetServer: string;
o3HookMode: number;
bypass?: BypassOptions;
}

55
pnpm-lock.yaml generated
View File

@@ -136,6 +136,9 @@ importers:
packages/napcat-framework:
dependencies:
json5:
specifier: ^2.2.3
version: 2.2.3
napcat-adapter:
specifier: workspace:*
version: link:../napcat-adapter
@@ -173,9 +176,6 @@ importers:
ajv:
specifier: ^8.13.0
version: 8.17.1
async-mutex:
specifier: ^0.5.0
version: 0.5.0
cors:
specifier: ^2.8.5
version: 2.8.5
@@ -357,6 +357,9 @@ importers:
'@types/node':
specifier: ^22.0.1
version: 22.19.1
json5:
specifier: ^2.2.3
version: 2.2.3
napcat-vite:
specifier: workspace:*
version: link:../napcat-vite
@@ -1907,89 +1910,105 @@ packages:
resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-arm@1.2.4':
resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==}
cpu: [arm]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-ppc64@1.2.4':
resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-riscv64@1.2.4':
resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-s390x@1.2.4':
resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-x64@1.2.4':
resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linuxmusl-arm64@1.2.4':
resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@img/sharp-libvips-linuxmusl-x64@1.2.4':
resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==}
cpu: [x64]
os: [linux]
libc: [musl]
'@img/sharp-linux-arm64@0.34.5':
resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@img/sharp-linux-arm@0.34.5':
resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm]
os: [linux]
libc: [glibc]
'@img/sharp-linux-ppc64@0.34.5':
resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@img/sharp-linux-riscv64@0.34.5':
resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@img/sharp-linux-s390x@0.34.5':
resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@img/sharp-linux-x64@0.34.5':
resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@img/sharp-linuxmusl-arm64@0.34.5':
resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@img/sharp-linuxmusl-x64@0.34.5':
resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@img/sharp-wasm32@0.34.5':
resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==}
@@ -2740,56 +2759,67 @@ packages:
resolution: {integrity: sha512-EPlb95nUsz6Dd9Qy13fI5kUPXNSljaG9FiJ4YUGU1O/Q77i5DYFW5KR8g1OzTcdZUqQQ1KdDqsTohdFVwCwjqg==}
cpu: [arm]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm-musleabihf@4.53.2':
resolution: {integrity: sha512-BOmnVW+khAUX+YZvNfa0tGTEMVVEerOxN0pDk2E6N6DsEIa2Ctj48FOMfNDdrwinocKaC7YXUZ1pHlKpnkja/Q==}
cpu: [arm]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-arm64-gnu@4.53.2':
resolution: {integrity: sha512-Xt2byDZ+6OVNuREgBXr4+CZDJtrVso5woFtpKdGPhpTPHcNG7D8YXeQzpNbFRxzTVqJf7kvPMCub/pcGUWgBjA==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm64-musl@4.53.2':
resolution: {integrity: sha512-+LdZSldy/I9N8+klim/Y1HsKbJ3BbInHav5qE9Iy77dtHC/pibw1SR/fXlWyAk0ThnpRKoODwnAuSjqxFRDHUQ==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-loong64-gnu@4.53.2':
resolution: {integrity: sha512-8ms8sjmyc1jWJS6WdNSA23rEfdjWB30LH8Wqj0Cqvv7qSHnvw6kgMMXRdop6hkmGPlyYBdRPkjJnj3KCUHV/uQ==}
cpu: [loong64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-ppc64-gnu@4.53.2':
resolution: {integrity: sha512-3HRQLUQbpBDMmzoxPJYd3W6vrVHOo2cVW8RUo87Xz0JPJcBLBr5kZ1pGcQAhdZgX9VV7NbGNipah1omKKe23/g==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-riscv64-gnu@4.53.2':
resolution: {integrity: sha512-fMjKi+ojnmIvhk34gZP94vjogXNNUKMEYs+EDaB/5TG/wUkoeua7p7VCHnE6T2Tx+iaghAqQX8teQzcvrYpaQA==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-riscv64-musl@4.53.2':
resolution: {integrity: sha512-XuGFGU+VwUUV5kLvoAdi0Wz5Xbh2SrjIxCtZj6Wq8MDp4bflb/+ThZsVxokM7n0pcbkEr2h5/pzqzDYI7cCgLQ==}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-s390x-gnu@4.53.2':
resolution: {integrity: sha512-w6yjZF0P+NGzWR3AXWX9zc0DNEGdtvykB03uhonSHMRa+oWA6novflo2WaJr6JZakG2ucsyb+rvhrKac6NIy+w==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-gnu@4.53.2':
resolution: {integrity: sha512-yo8d6tdfdeBArzC7T/PnHd7OypfI9cbuZzPnzLJIyKYFhAQ8SvlkKtKBMbXDxe1h03Rcr7u++nFS7tqXz87Gtw==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-musl@4.53.2':
resolution: {integrity: sha512-ah59c1YkCxKExPP8O9PwOvs+XRLKwh/mV+3YdKqQ5AMQ0r4M4ZDuOrpWkUaqO7fzAHdINzV9tEVu8vNw48z0lA==}
cpu: [x64]
os: [linux]
libc: [musl]
'@rollup/rollup-openharmony-arm64@4.53.2':
resolution: {integrity: sha512-4VEd19Wmhr+Zy7hbUsFZ6YXEiP48hE//KPLCSVNY5RMGX2/7HZ+QkN55a3atM1C/BZCGIgqN+xrVgtdak2S9+A==}
@@ -2864,24 +2894,28 @@ packages:
engines: {node: '>=10'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@swc/core-linux-arm64-musl@1.15.1':
resolution: {integrity: sha512-fKzP9mRQGbhc5QhJPIsqKNNX/jyWrZgBxmo3Nz1SPaepfCUc7RFmtcJQI5q8xAun3XabXjh90wqcY/OVyg2+Kg==}
engines: {node: '>=10'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@swc/core-linux-x64-gnu@1.15.1':
resolution: {integrity: sha512-ZLjMi138uTJxb+1wzo4cB8mIbJbAsSLWRNeHc1g1pMvkERPWOGlem+LEYkkzaFzCNv1J8aKcL653Vtw8INHQeg==}
engines: {node: '>=10'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@swc/core-linux-x64-musl@1.15.1':
resolution: {integrity: sha512-jvSI1IdsIYey5kOITzyajjofXOOySVitmLxb45OPUjoNojql4sDojvlW5zoHXXFePdA6qAX4Y6KbzAOV3T3ctA==}
engines: {node: '>=10'}
cpu: [x64]
os: [linux]
libc: [musl]
'@swc/core-win32-arm64-msvc@1.15.1':
resolution: {integrity: sha512-X/FcDtNrDdY9r4FcXHt9QxUqC/2FbQdvZobCKHlHe8vTSKhUHOilWl5EBtkFVfsEs4D5/yAri9e3bJbwyBhhBw==}
@@ -3246,41 +3280,49 @@ packages:
resolution: {integrity: sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@unrs/resolver-binding-linux-arm64-musl@1.11.1':
resolution: {integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@unrs/resolver-binding-linux-ppc64-gnu@1.11.1':
resolution: {integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@unrs/resolver-binding-linux-riscv64-gnu@1.11.1':
resolution: {integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@unrs/resolver-binding-linux-riscv64-musl@1.11.1':
resolution: {integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@unrs/resolver-binding-linux-s390x-gnu@1.11.1':
resolution: {integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@unrs/resolver-binding-linux-x64-gnu@1.11.1':
resolution: {integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@unrs/resolver-binding-linux-x64-musl@1.11.1':
resolution: {integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==}
cpu: [x64]
os: [linux]
libc: [musl]
'@unrs/resolver-binding-wasm32-wasi@1.11.1':
resolution: {integrity: sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==}
@@ -3518,9 +3560,6 @@ packages:
resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==}
engines: {node: '>= 0.4'}
async-mutex@0.5.0:
resolution: {integrity: sha512-1A94B18jkJ3DYq284ohPxoXbfTA5HsQ7/Mf4DEhcyLx3Bz27Rh59iScbB6EPiP+B+joue6YCxcMXSbFC1tZKwA==}
async@3.2.6:
resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==}
@@ -10398,10 +10437,6 @@ snapshots:
async-function@1.0.0: {}
async-mutex@0.5.0:
dependencies:
tslib: 2.8.1
async@3.2.6: {}
asynckit@0.4.0: {}