Compare commits

..

16 Commits

Author SHA1 Message Date
手瓜一十雪
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
35 changed files with 816 additions and 132 deletions

View File

@@ -5,5 +5,13 @@
"consoleLogLevel": "info",
"packetBackend": "auto",
"packetServer": "",
"o3HookMode": 1
"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

@@ -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

@@ -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

@@ -31,12 +31,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) => {
@@ -392,7 +394,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,12 +402,17 @@ 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?.();
const bypassOptions = napcatConfig.bypass ?? {};
logger.logDebug('[NapCat] Bypass 配置:', bypassOptions);
const bypassEnabled = napi2nativeLoader.nativeExports.enableAllBypasses?.(bypassOptions);
if (bypassEnabled) {
logger.log('[NapCat] Napi2NativeLoader: 已启用Bypass');
}
@@ -463,6 +469,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

@@ -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

@@ -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,
@@ -68,6 +69,7 @@ router.get('/Config/SSE', PluginConfigSSEHandler);
router.post('/Config/Change', PluginConfigChangeHandler);
router.post('/RegisterManager', RegisterPluginManagerHandler);
router.post('/Import', upload.single('plugin'), ImportLocalPluginHandler);
router.get('/Icon/:pluginId', GetPluginIconHandler);
// 插件商店相关路由
router.get('/Store/List', GetPluginStoreListHandler);

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

@@ -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

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

View File

@@ -178,5 +178,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);

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

@@ -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;
}

45
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
@@ -357,6 +360,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 +1913,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 +2762,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 +2897,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 +3283,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==}