Compare commits

..

10 Commits

Author SHA1 Message Date
手瓜一十雪
67691dd58a Merge branch 'main' into feat-plugin-npm 2026-02-20 21:54:13 +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
时瑾
5edafeed3e feat: 插件系统引入npm 2026-02-12 15:24:58 +08:00
28 changed files with 1612 additions and 431 deletions

View File

@@ -0,0 +1,314 @@
/**
* npm 注册表工具模块
* 提供从 npm registry 获取包信息和下载 tarball 的能力
* 适用于 Electron 环境,不依赖系统安装的 npm CLI
*
* 设计目标:
* - 通过 HTTP API 直接与 npm registry 交互
* - 支持多个 registry 镜像源
* - 下载 tarball 并解压到指定目录(与现有 zip 安装流程一致)
*/
import * as fs from 'fs';
import * as path from 'path';
import { pipeline } from 'stream/promises';
import { createWriteStream } from 'fs';
// ============== npm Registry 镜像源 ==============
/**
* npm Registry 镜像列表
* 按优先级排序,优先使用国内镜像
*/
export const NPM_REGISTRY_MIRRORS = [
'https://registry.npmmirror.com', // 淘宝镜像(国内首选)
'https://registry.npmjs.org', // 官方源
];
// ============== 类型定义 ==============
/** npm 包的简要版本信息 */
export interface NpmPackageVersionInfo {
name: string;
version: string;
description?: string;
author?: string | { name: string; email?: string; url?: string };
homepage?: string;
repository?: string | { type: string; url: string };
keywords?: string[];
dist: {
tarball: string;
shasum?: string;
integrity?: string;
fileCount?: number;
unpackedSize?: number;
};
/** 插件扩展字段 */
napcat?: {
tags?: string[];
minVersion?: string;
displayName?: string;
};
}
/** npm 包的完整元数据(简化版) */
export interface NpmPackageMetadata {
name: string;
description?: string;
'dist-tags': Record<string, string>;
versions: Record<string, NpmPackageVersionInfo>;
time?: Record<string, string>;
readme?: string;
homepage?: string;
repository?: string | { type: string; url: string };
author?: string | { name: string; email?: string; url?: string };
keywords?: string[];
}
// ============== 缓存 ==============
interface MetadataCache {
data: NpmPackageMetadata;
timestamp: number;
}
const metadataCache: Map<string, MetadataCache> = new Map();
const METADATA_CACHE_TTL = 5 * 60 * 1000; // 5 分钟
// ============== 核心功能 ==============
/**
* 从 npm registry 获取包的元数据
* @param packageName 包名(如 "napcat-plugin-example"
* @param registry 指定的 registry URL可选
* @param forceRefresh 强制跳过缓存
*/
export async function fetchNpmPackageMetadata (
packageName: string,
registry?: string,
forceRefresh: boolean = false
): Promise<NpmPackageMetadata> {
// 检查缓存
const cacheKey = `${registry || 'auto'}:${packageName}`;
if (!forceRefresh) {
const cached = metadataCache.get(cacheKey);
if (cached && (Date.now() - cached.timestamp) < METADATA_CACHE_TTL) {
return cached.data;
}
}
const registries = registry ? [registry] : NPM_REGISTRY_MIRRORS;
const errors: string[] = [];
for (const reg of registries) {
try {
const url = `${reg.replace(/\/$/, '')}/${encodeURIComponent(packageName)}`;
const response = await fetch(url, {
headers: {
Accept: 'application/json',
'User-Agent': 'NapCat-PluginManager',
},
signal: AbortSignal.timeout(15000),
});
if (response.status === 404) {
throw new Error(`包 "${packageName}" 在 npm 上不存在`);
}
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json() as NpmPackageMetadata;
// 更新缓存
metadataCache.set(cacheKey, { data, timestamp: Date.now() });
return data;
} catch (e: any) {
errors.push(`${reg}: ${e.message}`);
// 如果是 404直接抛出不再尝试其他镜像
if (e.message.includes('不存在')) {
throw e;
}
}
}
throw new Error(`获取 npm 包 "${packageName}" 信息失败:\n${errors.join('\n')}`);
}
/**
* 获取包的最新版本信息
*/
export async function fetchNpmLatestVersion (
packageName: string,
registry?: string
): Promise<NpmPackageVersionInfo> {
const metadata = await fetchNpmPackageMetadata(packageName, registry);
const latestTag = metadata['dist-tags']?.['latest'];
if (!latestTag) {
throw new Error(`包 "${packageName}" 没有 latest 标签`);
}
const versionInfo = metadata.versions[latestTag];
if (!versionInfo) {
throw new Error(`包 "${packageName}" 的 latest 版本 (${latestTag}) 信息不存在`);
}
return versionInfo;
}
/**
* 获取包的指定版本信息
*/
export async function fetchNpmVersionInfo (
packageName: string,
version: string,
registry?: string
): Promise<NpmPackageVersionInfo> {
const metadata = await fetchNpmPackageMetadata(packageName, registry);
const versionInfo = metadata.versions[version];
if (!versionInfo) {
const availableVersions = Object.keys(metadata.versions);
throw new Error(
`版本 "${version}" 不存在。可用版本: ${availableVersions.slice(-5).join(', ')}`
);
}
return versionInfo;
}
/**
* 下载 npm tarball 文件
* @param tarballUrl tarball 下载地址
* @param destPath 保存路径
* @param registry 用于替换 tarball URL 中的 registry 域名(镜像加速)
* @param onProgress 进度回调
* @param timeout 超时时间(毫秒)
*/
export async function downloadNpmTarball (
tarballUrl: string,
destPath: string,
registry?: string,
onProgress?: (percent: number, downloaded: number, total: number, speed: number) => void,
timeout: number = 120000
): Promise<void> {
// 如果指定了 registry替换 tarball URL 中的域名
let downloadUrl = tarballUrl;
if (registry) {
// tarball URL 通常是 https://registry.npmjs.org/package/-/package-1.0.0.tgz
// 替换为 https://registry.npmmirror.com/package/-/package-1.0.0.tgz
try {
const tarballUrlObj = new URL(tarballUrl);
const registryUrlObj = new URL(registry);
tarballUrlObj.hostname = registryUrlObj.hostname;
tarballUrlObj.protocol = registryUrlObj.protocol;
if (registryUrlObj.port) {
tarballUrlObj.port = registryUrlObj.port;
}
downloadUrl = tarballUrlObj.toString();
} catch {
// URL 解析失败,使用原始 URL
}
}
try {
// 确保目标目录存在
const destDir = path.dirname(destPath);
if (!fs.existsSync(destDir)) {
fs.mkdirSync(destDir, { recursive: true });
}
if (onProgress) {
onProgress(0, 0, 0, 0);
}
const response = await fetch(downloadUrl, {
headers: {
'User-Agent': 'NapCat-PluginManager',
},
signal: AbortSignal.timeout(timeout),
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
if (!response.body) {
throw new Error('Response body is null');
}
const totalLength = Number(response.headers.get('content-length')) || 0;
let downloaded = 0;
let lastTime = Date.now();
let lastDownloaded = 0;
// 进度监控
// eslint-disable-next-line @stylistic/generator-star-spacing
const progressMonitor = async function* (source: any) {
for await (const chunk of source) {
downloaded += chunk.length;
const now = Date.now();
const elapsed = now - lastTime;
if (elapsed >= 500 || (totalLength && downloaded === totalLength)) {
const percent = totalLength ? Math.round((downloaded / totalLength) * 100) : 0;
const speed = (downloaded - lastDownloaded) / (elapsed / 1000);
if (onProgress) {
onProgress(percent, downloaded, totalLength, speed);
}
lastTime = now;
lastDownloaded = downloaded;
}
yield chunk;
}
};
const fileStream = createWriteStream(destPath);
await pipeline(progressMonitor(response.body), fileStream);
} catch (e: any) {
if (fs.existsSync(destPath)) {
fs.unlinkSync(destPath);
}
throw new Error(`下载 npm 包失败: ${e.message}`);
}
}
/**
* 从 npm 包的元数据中提取作者名
*/
export function extractAuthorName (
author?: string | { name: string; email?: string; url?: string }
): string {
if (!author) return 'unknown';
if (typeof author === 'string') return author;
return author.name || 'unknown';
}
/**
* 从 npm 包的元数据中提取 homepage
*/
export function extractHomepage (
homepage?: string,
repository?: string | { type: string; url: string }
): string | undefined {
if (homepage) return homepage;
if (!repository) return undefined;
if (typeof repository === 'string') return repository;
// 转换 git+https://github.com/xxx/yyy.git → https://github.com/xxx/yyy
return repository.url
?.replace(/^git\+/, '')
?.replace(/\.git$/, '');
}
/**
* 清除 npm 元数据缓存
*/
export function clearNpmMetadataCache (): void {
metadataCache.clear();
}

View File

@@ -7,11 +7,11 @@
"packetServer": "",
"o3HookMode": 1,
"bypass": {
"hook": true,
"window": true,
"module": true,
"process": true,
"container": true,
"js": true
"hook": false,
"window": false,
"module": false,
"process": false,
"container": false,
"js": false
}
}

View File

@@ -1,7 +1,10 @@
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 }),
@@ -25,6 +28,26 @@ export const NapcatConfigSchema = Type.Object({
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

@@ -2,10 +2,8 @@ import { NapCatPathWrapper } from 'napcat-common/src/path';
import { InitWebUi, WebUiConfig, webUiRuntimePort } from 'napcat-webui-backend/index';
import { NapCatAdapterManager } from 'napcat-adapter';
import { NativePacketHandler } from 'napcat-core/packet/handler/client';
import { Napi2NativeLoader, BypassOptions } from 'napcat-core/packet/handler/napi2nativeLoader';
import path from 'path';
import fs from 'fs';
import json5 from 'json5';
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';
@@ -45,30 +43,14 @@ 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') {
// 读取 napcat.json 配置
let bypassOptions: BypassOptions = {
hook: false,
window: false,
module: false,
process: false,
container: false,
js: false,
};
try {
const configFile = path.join(pathWrapper.configPath, 'napcat.json');
if (fs.existsSync(configFile)) {
const content = fs.readFileSync(configFile, 'utf-8');
const config = json5.parse(content);
if (config.bypass && typeof config.bypass === 'object') {
bypassOptions = { ...bypassOptions, ...config.bypass };
}
}
} catch (e) {
logger.logWarn('[NapCat] 读取 napcat.json bypass 配置失败,已全部禁用:', e);
}
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已通过环境变量禁用');
@@ -76,7 +58,7 @@ export async function NCoreInitFramework (
// 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

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

View File

@@ -20,7 +20,6 @@ import { hostname, systemVersion } from 'napcat-common/src/system';
import path from 'path';
import fs from 'fs';
import os from 'os';
import json5 from 'json5';
import { LoginListItem, NodeIKernelLoginService } from 'napcat-core/services';
import qrcode from 'napcat-qrcode/lib/main';
import { NapCatAdapterManager } from 'napcat-adapter';
@@ -31,7 +30,8 @@ import { NodeIO3MiscListener } from 'napcat-core/listeners/NodeIO3MiscListener';
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, BypassOptions } from 'napcat-core/packet/handler/napi2nativeLoader';
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';
@@ -39,60 +39,6 @@ import { statusHelperSubscription } from '@/napcat-core/helper/status';
import { applyPendingUpdates } from '@/napcat-webui-backend/src/api/UpdateNapCat';
import { connectToNamedPipe } from './pipe';
/**
* 读取 napcat.json 配置中的 bypass 选项,并根据分步禁用级别覆盖
*
* 分步禁用级别 (NAPCAT_BYPASS_DISABLE_LEVEL):
* 0: 使用配置文件原始值(全部启用或用户自定义)
* 1: 强制禁用 hook
* 2: 强制禁用 hook + module
* 3: 强制禁用全部 bypass
*/
function loadBypassConfig (configPath: string, logger: LogWrapper): BypassOptions {
const defaultOptions: BypassOptions = {
hook: true,
window: true,
module: true,
process: true,
container: true,
js: true,
};
let options = { ...defaultOptions };
try {
const configFile = path.join(configPath, 'napcat.json');
if (fs.existsSync(configFile)) {
const content = fs.readFileSync(configFile, 'utf-8');
const config = json5.parse(content);
if (config.bypass && typeof config.bypass === 'object') {
options = { ...defaultOptions, ...config.bypass };
}
}
} catch (e) {
logger.logWarn('[NapCat] 读取 bypass 配置失败,使用默认值:', e);
}
// 根据分步禁用级别覆盖配置
const disableLevel = parseInt(process.env['NAPCAT_BYPASS_DISABLE_LEVEL'] || '0', 10);
if (disableLevel > 0) {
const levelDescriptions = ['全部启用', '禁用 hook', '禁用 hook + module', '全部禁用 bypass'];
logger.logWarn(`[NapCat] 崩溃恢复:当前 bypass 禁用级别 ${disableLevel} (${levelDescriptions[disableLevel] ?? '未知'})`);
if (disableLevel >= 1) {
options.hook = false;
}
if (disableLevel >= 2) {
options.module = false;
}
if (disableLevel >= 3) {
options.hook = false;
options.window = false;
options.module = false;
options.process = false;
options.container = false;
options.js = false;
}
}
return options;
}
// NapCat Shell App ES 入口文件
async function handleUncaughtExceptions (logger: LogWrapper) {
process.on('uncaughtException', (err) => {
@@ -448,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);
@@ -457,12 +402,15 @@ 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 bypassOptions = loadBypassConfig(pathWrapper.configPath, logger);
const bypassOptions = napcatConfig.bypass ?? {};
logger.logDebug('[NapCat] Bypass 配置:', bypassOptions);
const bypassEnabled = napi2nativeLoader.nativeExports.enableAllBypasses?.(bypassOptions);
if (bypassEnabled) {

View File

@@ -65,15 +65,6 @@ const recentCrashTimestamps: number[] = [];
const CRASH_TIME_WINDOW = 10000; // 10秒时间窗口
const MAX_CRASHES_IN_WINDOW = 3; // 最大崩溃次数
// 分步禁用策略:记录当前禁用级别 (0-3)
// 0: 全部启用
// 1: 禁用 hook
// 2: 禁用 hook + module
// 3: 全部禁用
let bypassDisableLevel = 0;
// 是否已登录成功(登录后不再使用分步禁用策略)
let isLoggedIn = false;
/**
* 获取进程类型名称(用于日志)
@@ -164,8 +155,6 @@ async function cleanupOrphanedProcesses (excludePids: number[]): Promise<void> {
*/
export async function restartWorker (secretKey?: string, port?: number): Promise<void> {
isRestarting = true;
isLoggedIn = false;
bypassDisableLevel = 0;
if (!currentWorker) {
logger.logWarn('[NapCat] [Process] 没有运行中的Worker进程');
@@ -258,7 +247,6 @@ async function startWorker (passQuickLogin: boolean = true, secretKey?: string,
NAPCAT_WORKER_PROCESS: '1',
...(secretKey ? { NAPCAT_WEBUI_JWT_SECRET_KEY: secretKey } : {}),
...(preferredPort ? { NAPCAT_WEBUI_PREFERRED_PORT: String(preferredPort) } : {}),
...(bypassDisableLevel > 0 ? { NAPCAT_BYPASS_DISABLE_LEVEL: String(bypassDisableLevel) } : {}),
},
stdio: isElectron ? 'pipe' : ['inherit', 'pipe', 'pipe', 'ipc'],
});
@@ -289,7 +277,6 @@ async function startWorker (passQuickLogin: boolean = true, secretKey?: string,
logger.logError(`[NapCat] [${processType}] 重启Worker进程失败:`, e);
});
} else if (message.type === 'login-success') {
isLoggedIn = true;
logger.log(`[NapCat] [${processType}] Worker进程已登录成功切换到正常重试策略`);
}
}
@@ -313,34 +300,13 @@ async function startWorker (passQuickLogin: boolean = true, secretKey?: string,
// 记录本次崩溃
recentCrashTimestamps.push(now);
// 登录前:使用分步禁用策略
if (!isLoggedIn) {
// 每次崩溃提升禁用级别
bypassDisableLevel = Math.min(bypassDisableLevel + 1, 3);
const levelDescriptions = [
'全部启用',
'禁用 hook',
'禁用 hook + module',
'全部禁用 bypass'
];
if (bypassDisableLevel >= 3 && 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}),切换到禁用级别 ${bypassDisableLevel}: ${levelDescriptions[bypassDisableLevel]},正在尝试重新拉起...`);
} else {
// 登录后:使用正常重试策略
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}),正在尝试重新拉起...`);
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

@@ -5,24 +5,17 @@ import { webUiPathWrapper } from '@/napcat-webui-backend/index';
import { sendError, sendSuccess } from '@/napcat-webui-backend/src/utils/response';
import json5 from 'json5';
// NapCat 配置默认值
const defaultNapcatConfig = {
fileLog: false,
consoleLog: true,
fileLogLevel: 'debug',
consoleLogLevel: 'info',
packetBackend: 'auto',
packetServer: '',
o3HookMode: 1,
bypass: {
hook: true,
window: true,
module: true,
process: true,
container: true,
js: true,
},
};
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 配置文件路径
@@ -39,12 +32,12 @@ function readNapcatConfig (): Record<string, unknown> {
try {
if (existsSync(configPath)) {
const content = readFileSync(configPath, 'utf-8');
return { ...defaultNapcatConfig, ...json5.parse(content) };
return { ...getDefaultNapcatConfig(), ...json5.parse(content) };
}
} catch (_e) {
// 读取失败,使用默认值
}
return { ...defaultNapcatConfig };
return { ...getDefaultNapcatConfig() };
}
/**

View File

@@ -1,12 +1,21 @@
import { RequestHandler } from 'express';
import { sendError, sendSuccess } from '@/napcat-webui-backend/src/utils/response';
import { PluginStoreList } from '@/napcat-webui-backend/src/types/PluginStore';
import { PluginStoreList, PluginStoreItem } from '@/napcat-webui-backend/src/types/PluginStore';
import * as fs from 'fs';
import * as path from 'path';
import { pipeline } from 'stream/promises';
import { createWriteStream } from 'fs';
import compressing from 'compressing';
import { findAvailableDownloadUrl, GITHUB_RAW_MIRRORS } from 'napcat-common/src/mirror';
import {
fetchNpmPackageMetadata,
fetchNpmLatestVersion,
fetchNpmVersionInfo,
downloadNpmTarball,
extractAuthorName,
extractHomepage,
NPM_REGISTRY_MIRRORS,
} from 'napcat-common/src/npm-registry';
import { webUiPathWrapper } from '@/napcat-webui-backend/index';
import { WebUiDataRuntime } from '@/napcat-webui-backend/src/helper/Data';
import { NapCatOneBot11Adapter } from '@/napcat-onebot/index';
@@ -287,6 +296,92 @@ async function extractPlugin (zipPath: string, pluginId: string): Promise<void>
console.log('[extractPlugin] Extracted files:', files);
}
/**
* 解压 npm tarball (.tgz) 到指定目录
* npm tarball 解压后通常有一个 "package/" 前缀目录,需要去掉
*/
async function extractNpmTarball (tgzPath: string, pluginId: string): Promise<void> {
const safeId = validatePluginId(pluginId);
const PLUGINS_DIR = getPluginsDir();
const pluginDir = path.join(PLUGINS_DIR, safeId);
const dataDir = path.join(pluginDir, 'data');
const tempDataDir = path.join(PLUGINS_DIR, `${safeId}.data.backup`);
const tempExtractDir = path.join(PLUGINS_DIR, `${safeId}.npm.temp`);
console.log(`[extractNpmTarball] pluginId: ${safeId}, tgz: ${tgzPath}`);
if (!fs.existsSync(PLUGINS_DIR)) {
fs.mkdirSync(PLUGINS_DIR, { recursive: true });
}
// 备份 data 目录
let hasDataBackup = false;
if (fs.existsSync(pluginDir)) {
if (fs.existsSync(dataDir)) {
if (fs.existsSync(tempDataDir)) {
fs.rmSync(tempDataDir, { recursive: true, force: true });
}
fs.renameSync(dataDir, tempDataDir);
hasDataBackup = true;
}
fs.rmSync(pluginDir, { recursive: true, force: true });
}
// 创建临时解压目录
if (fs.existsSync(tempExtractDir)) {
fs.rmSync(tempExtractDir, { recursive: true, force: true });
}
fs.mkdirSync(tempExtractDir, { recursive: true });
try {
// 解压 tgznpm tarball 格式)
await compressing.tgz.uncompress(tgzPath, tempExtractDir);
// npm tarball 解压后通常有 "package/" 目录
const extractedItems = fs.readdirSync(tempExtractDir);
let sourceDir = tempExtractDir;
if (extractedItems.length === 1 && extractedItems[0]) {
const singleDir = path.join(tempExtractDir, extractedItems[0]);
if (fs.statSync(singleDir).isDirectory()) {
sourceDir = singleDir;
}
}
// 移动到目标目录
fs.renameSync(sourceDir, pluginDir);
// 清理临时目录
if (sourceDir !== tempExtractDir && fs.existsSync(tempExtractDir)) {
fs.rmSync(tempExtractDir, { recursive: true, force: true });
}
// 恢复 data 目录
if (hasDataBackup && fs.existsSync(tempDataDir)) {
if (fs.existsSync(dataDir)) {
fs.rmSync(dataDir, { recursive: true, force: true });
}
fs.renameSync(tempDataDir, dataDir);
}
console.log(`[extractNpmTarball] Extracted npm package to: ${pluginDir}`);
} catch (e) {
if (fs.existsSync(tempExtractDir)) {
fs.rmSync(tempExtractDir, { recursive: true, force: true });
}
if (hasDataBackup && fs.existsSync(tempDataDir)) {
if (!fs.existsSync(pluginDir)) {
fs.mkdirSync(pluginDir, { recursive: true });
}
if (fs.existsSync(dataDir)) {
fs.rmSync(dataDir, { recursive: true, force: true });
}
fs.renameSync(tempDataDir, dataDir);
}
throw e;
}
}
/**
* 获取插件商店列表
*/
@@ -322,10 +417,11 @@ export const GetPluginStoreDetailHandler: RequestHandler = async (req, res) => {
/**
* 安装插件(从商店)- 普通 POST 接口
* 支持 npm 和 github 两种来源
*/
export const InstallPluginFromStoreHandler: RequestHandler = async (req, res) => {
try {
const { id: rawId, mirror } = req.body;
const { id: rawId, mirror, registry } = req.body;
if (!rawId) {
return sendError(res, 'Plugin ID is required');
@@ -350,41 +446,62 @@ export const InstallPluginFromStoreHandler: RequestHandler = async (req, res) =>
}
}
// 下载插件
const PLUGINS_DIR = getPluginsDir();
const tempZipPath = path.join(PLUGINS_DIR, `${id}.temp.zip`);
const isNpmSource = plugin.source === 'npm' && plugin.npmPackage;
try {
await downloadFile(plugin.downloadUrl, tempZipPath, mirror, undefined, 300000);
if (isNpmSource) {
// npm 安装流程
const tempTgzPath = path.join(PLUGINS_DIR, `${id}.temp.tgz`);
try {
const versionInfo = await fetchNpmLatestVersion(plugin.npmPackage!, registry);
await downloadNpmTarball(versionInfo.dist.tarball, tempTgzPath, registry, undefined, 300000);
await extractNpmTarball(tempTgzPath, id);
fs.unlinkSync(tempTgzPath);
// 解压插件
await extractPlugin(tempZipPath, id);
// 删除临时文件
fs.unlinkSync(tempZipPath);
// 如果 pluginManager 存在,立即注册或重载插件
const pluginManager = getPluginManager();
if (pluginManager) {
// 如果插件已存在,则重载以刷新版本信息;否则注册新插件
if (pluginManager.getPluginInfo(id)) {
await pluginManager.reloadPlugin(id);
} else {
await pluginManager.loadPluginById(id);
const pluginManager = getPluginManager();
if (pluginManager) {
if (pluginManager.getPluginInfo(id)) {
await pluginManager.reloadPlugin(id);
} else {
await pluginManager.loadPluginById(id);
}
}
}
return sendSuccess(res, {
message: 'Plugin installed successfully',
plugin,
installPath: path.join(PLUGINS_DIR, id),
});
} catch (downloadError: any) {
// 清理临时文件
if (fs.existsSync(tempZipPath)) {
fs.unlinkSync(tempZipPath);
return sendSuccess(res, {
message: 'Plugin installed successfully (npm)',
plugin,
installPath: path.join(PLUGINS_DIR, id),
});
} catch (e: any) {
if (fs.existsSync(tempTgzPath)) fs.unlinkSync(tempTgzPath);
throw e;
}
} else {
// GitHub 安装流程(向后兼容)
const tempZipPath = path.join(PLUGINS_DIR, `${id}.temp.zip`);
try {
await downloadFile(plugin.downloadUrl, tempZipPath, mirror, undefined, 300000);
await extractPlugin(tempZipPath, id);
fs.unlinkSync(tempZipPath);
const pluginManager = getPluginManager();
if (pluginManager) {
if (pluginManager.getPluginInfo(id)) {
await pluginManager.reloadPlugin(id);
} else {
await pluginManager.loadPluginById(id);
}
}
return sendSuccess(res, {
message: 'Plugin installed successfully',
plugin,
installPath: path.join(PLUGINS_DIR, id),
});
} catch (downloadError: any) {
if (fs.existsSync(tempZipPath)) fs.unlinkSync(tempZipPath);
throw downloadError;
}
throw downloadError;
}
} catch (e: any) {
return sendError(res, 'Failed to install plugin: ' + e.message);
@@ -393,9 +510,10 @@ export const InstallPluginFromStoreHandler: RequestHandler = async (req, res) =>
/**
* 安装插件(从商店)- SSE 版本,实时推送进度
* 支持 npm 和 github 两种来源
*/
export const InstallPluginFromStoreSSEHandler: RequestHandler = async (req, res) => {
const { id: rawId, mirror } = req.query;
const { id: rawId, mirror, registry } = req.query;
if (!rawId || typeof rawId !== 'string') {
res.status(400).json({ error: 'Plugin ID is required' });
@@ -447,71 +565,145 @@ export const InstallPluginFromStoreSSEHandler: RequestHandler = async (req, res)
}
sendProgress(`找到插件: ${plugin.name} v${plugin.version}`, 20);
sendProgress(`下载地址: ${plugin.downloadUrl}`, 25);
if (mirror && typeof mirror === 'string') {
sendProgress(`使用镜像: ${mirror}`, 28);
}
// 下载插件
const isNpmSource = plugin.source === 'npm' && plugin.npmPackage;
const PLUGINS_DIR = getPluginsDir();
const tempZipPath = path.join(PLUGINS_DIR, `${id}.temp.zip`);
try {
sendProgress('正在下载插件...', 30);
await downloadFile(plugin.downloadUrl, tempZipPath, mirror as string | undefined, (percent, downloaded, total, speed) => {
const overallProgress = 30 + Math.round(percent * 0.5);
const downloadedMb = (downloaded / 1024 / 1024).toFixed(1);
const totalMb = total ? (total / 1024 / 1024).toFixed(1) : '?';
const speedMb = (speed / 1024 / 1024).toFixed(2);
const eta = (total > 0 && speed > 0) ? Math.round((total - downloaded) / speed) : -1;
if (isNpmSource) {
// ========== npm 安装流程 ==========
const npmRegistry = (registry && typeof registry === 'string') ? registry : undefined;
sendProgress(`来源: npm (${plugin.npmPackage})`, 25);
sendProgress(`正在下载插件... ${percent}%`, overallProgress, {
downloaded,
total,
speed,
eta,
downloadedStr: `${downloadedMb}MB`,
totalStr: `${totalMb}MB`,
speedStr: `${speedMb}MB/s`,
});
}, 300000);
if (npmRegistry) {
sendProgress(`使用 npm 镜像: ${npmRegistry}`, 28);
}
sendProgress('下载完成,正在解压...', 85);
await extractPlugin(tempZipPath, id);
const tempTgzPath = path.join(PLUGINS_DIR, `${id}.temp.tgz`);
sendProgress('解压完成,正在清理...', 95);
fs.unlinkSync(tempZipPath);
try {
sendProgress('正在从 npm 获取版本信息...', 30);
const versionInfo = await fetchNpmLatestVersion(plugin.npmPackage!, npmRegistry);
sendProgress(`tarball: ${versionInfo.dist.tarball}`, 35);
// 如果 pluginManager 存在,立即注册或重载插件
const pluginManager = getPluginManager();
if (pluginManager) {
// 如果插件已存在,则重载以刷新版本信息;否则注册新插件
if (pluginManager.getPluginInfo(id)) {
sendProgress('正在刷新插件信息...', 95);
await pluginManager.reloadPlugin(id);
} else {
sendProgress('正在注册插件...', 95);
await pluginManager.loadPluginById(id);
sendProgress('正在下载插件包...', 40);
await downloadNpmTarball(
versionInfo.dist.tarball,
tempTgzPath,
npmRegistry,
(percent, downloaded, total, speed) => {
const overallProgress = 40 + Math.round(percent * 0.4);
const downloadedMb = (downloaded / 1024 / 1024).toFixed(1);
const totalMb = total ? (total / 1024 / 1024).toFixed(1) : '?';
const speedMb = (speed / 1024 / 1024).toFixed(2);
const eta = (total > 0 && speed > 0) ? Math.round((total - downloaded) / speed) : -1;
sendProgress(`正在下载插件... ${percent}%`, overallProgress, {
downloaded,
total,
speed,
eta,
downloadedStr: `${downloadedMb}MB`,
totalStr: `${totalMb}MB`,
speedStr: `${speedMb}MB/s`,
});
},
300000,
);
sendProgress('下载完成,正在解压 npm 包...', 85);
await extractNpmTarball(tempTgzPath, id);
sendProgress('解压完成,正在清理...', 95);
fs.unlinkSync(tempTgzPath);
// 注册到 pluginManager
const pluginManager = getPluginManager();
if (pluginManager) {
if (pluginManager.getPluginInfo(id)) {
sendProgress('正在刷新插件信息...', 95);
await pluginManager.reloadPlugin(id);
} else {
sendProgress('正在注册插件...', 95);
await pluginManager.loadPluginById(id);
}
}
sendProgress('安装成功!', 100);
res.write(`data: ${JSON.stringify({
success: true,
message: 'Plugin installed successfully (npm)',
plugin,
installPath: path.join(PLUGINS_DIR, id),
})}\n\n`);
res.end();
} catch (downloadError: any) {
if (fs.existsSync(tempTgzPath)) fs.unlinkSync(tempTgzPath);
sendProgress(`错误: ${downloadError.message}`, 0);
res.write(`data: ${JSON.stringify({ error: downloadError.message })}\n\n`);
res.end();
}
} else {
// ========== GitHub 安装流程(向后兼容)==========
sendProgress(`来源: GitHub`, 25);
sendProgress(`下载地址: ${plugin.downloadUrl}`, 25);
if (mirror && typeof mirror === 'string') {
sendProgress(`使用镜像: ${mirror}`, 28);
}
sendProgress('安装成功!', 100);
res.write(`data: ${JSON.stringify({
success: true,
message: 'Plugin installed successfully',
plugin,
installPath: path.join(PLUGINS_DIR, id),
})}\n\n`);
res.end();
} catch (downloadError: any) {
// 清理临时文件
if (fs.existsSync(tempZipPath)) {
const tempZipPath = path.join(PLUGINS_DIR, `${id}.temp.zip`);
try {
sendProgress('正在下载插件...', 30);
await downloadFile(plugin.downloadUrl, tempZipPath, mirror as string | undefined, (percent, downloaded, total, speed) => {
const overallProgress = 30 + Math.round(percent * 0.5);
const downloadedMb = (downloaded / 1024 / 1024).toFixed(1);
const totalMb = total ? (total / 1024 / 1024).toFixed(1) : '?';
const speedMb = (speed / 1024 / 1024).toFixed(2);
const eta = (total > 0 && speed > 0) ? Math.round((total - downloaded) / speed) : -1;
sendProgress(`正在下载插件... ${percent}%`, overallProgress, {
downloaded,
total,
speed,
eta,
downloadedStr: `${downloadedMb}MB`,
totalStr: `${totalMb}MB`,
speedStr: `${speedMb}MB/s`,
});
}, 300000);
sendProgress('下载完成,正在解压...', 85);
await extractPlugin(tempZipPath, id);
sendProgress('解压完成,正在清理...', 95);
fs.unlinkSync(tempZipPath);
const pluginManager = getPluginManager();
if (pluginManager) {
if (pluginManager.getPluginInfo(id)) {
sendProgress('正在刷新插件信息...', 95);
await pluginManager.reloadPlugin(id);
} else {
sendProgress('正在注册插件...', 95);
await pluginManager.loadPluginById(id);
}
}
sendProgress('安装成功!', 100);
res.write(`data: ${JSON.stringify({
success: true,
message: 'Plugin installed successfully',
plugin,
installPath: path.join(PLUGINS_DIR, id),
})}\n\n`);
res.end();
} catch (downloadError: any) {
if (fs.existsSync(tempZipPath)) fs.unlinkSync(tempZipPath);
sendProgress(`错误: ${downloadError.message}`, 0);
res.write(`data: ${JSON.stringify({ error: downloadError.message })}\n\n`);
res.end();
}
sendProgress(`错误: ${downloadError.message}`, 0);
res.write(`data: ${JSON.stringify({ error: downloadError.message })}\n\n`);
res.end();
}
} catch (e: any) {
sendProgress(`错误: ${e.message}`, 0);
@@ -519,3 +711,277 @@ export const InstallPluginFromStoreSSEHandler: RequestHandler = async (req, res)
res.end();
}
};
/**
* 从 npm 直接安装插件(不依赖商店索引)
* 通过 npm 包名直接安装
*/
export const InstallPluginFromNpmHandler: RequestHandler = async (req, res) => {
try {
const { packageName, version, registry } = req.body;
if (!packageName || typeof packageName !== 'string') {
return sendError(res, 'npm 包名不能为空');
}
// 验证包名格式npm 包名规则)
if (!/^(@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/.test(packageName)) {
return sendError(res, '无效的 npm 包名格式');
}
const PLUGINS_DIR = getPluginsDir();
const tempTgzPath = path.join(PLUGINS_DIR, `${packageName.replace(/\//g, '-')}.temp.tgz`);
try {
// 获取版本信息
let versionInfo;
if (version) {
versionInfo = await fetchNpmVersionInfo(packageName, version, registry);
} else {
versionInfo = await fetchNpmLatestVersion(packageName, registry);
}
const pluginId = versionInfo.name;
// 检查是否已安装相同版本
const pm = getPluginManager();
if (pm) {
const installedInfo = pm.getPluginInfo(pluginId);
if (installedInfo && installedInfo.version === versionInfo.version) {
return sendError(res, '该插件已安装且版本相同,无需重复安装');
}
}
// 下载并解压
await downloadNpmTarball(versionInfo.dist.tarball, tempTgzPath, registry, undefined, 300000);
await extractNpmTarball(tempTgzPath, pluginId);
fs.unlinkSync(tempTgzPath);
// 注册
const pluginManager = getPluginManager();
if (pluginManager) {
if (pluginManager.getPluginInfo(pluginId)) {
await pluginManager.reloadPlugin(pluginId);
} else {
await pluginManager.loadPluginById(pluginId);
}
}
return sendSuccess(res, {
message: 'Plugin installed successfully from npm',
pluginId,
version: versionInfo.version,
installPath: path.join(PLUGINS_DIR, pluginId),
});
} catch (e: any) {
if (fs.existsSync(tempTgzPath)) fs.unlinkSync(tempTgzPath);
throw e;
}
} catch (e: any) {
return sendError(res, '从 npm 安装插件失败: ' + e.message);
}
};
/**
* 从 npm 直接安装插件 - SSE 版本
*/
export const InstallPluginFromNpmSSEHandler: RequestHandler = async (req, res) => {
const { packageName, version, registry } = req.query;
if (!packageName || typeof packageName !== 'string') {
res.status(400).json({ error: 'npm 包名不能为空' });
return;
}
if (!/^(@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/.test(packageName)) {
res.status(400).json({ error: '无效的 npm 包名格式' });
return;
}
// 设置 SSE 响应头
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
res.flushHeaders();
const sendProgress = (message: string, progress?: number, detail?: any) => {
res.write(`data: ${JSON.stringify({ message, progress, ...detail })}\n\n`);
};
const PLUGINS_DIR = getPluginsDir();
const npmRegistry = (registry && typeof registry === 'string') ? registry : undefined;
const tempTgzPath = path.join(PLUGINS_DIR, `${packageName.replace(/\//g, '-')}.temp.tgz`);
try {
sendProgress('正在从 npm 获取包信息...', 10);
let versionInfo;
if (version && typeof version === 'string') {
versionInfo = await fetchNpmVersionInfo(packageName, version, npmRegistry);
} else {
versionInfo = await fetchNpmLatestVersion(packageName, npmRegistry);
}
const pluginId = versionInfo.name;
sendProgress(`找到包: ${pluginId} v${versionInfo.version}`, 20);
// 检查版本
const pm = getPluginManager();
if (pm) {
const installedInfo = pm.getPluginInfo(pluginId);
if (installedInfo && installedInfo.version === versionInfo.version) {
sendProgress('错误: 该插件已安装且版本相同', 0);
res.write(`data: ${JSON.stringify({ error: '该插件已安装且版本相同,无需重复安装' })}\n\n`);
res.end();
return;
}
}
sendProgress(`tarball: ${versionInfo.dist.tarball}`, 25);
if (npmRegistry) {
sendProgress(`使用 npm 镜像: ${npmRegistry}`, 28);
}
sendProgress('正在下载插件包...', 30);
await downloadNpmTarball(
versionInfo.dist.tarball,
tempTgzPath,
npmRegistry,
(percent, downloaded, total, speed) => {
const overallProgress = 30 + Math.round(percent * 0.5);
const downloadedMb = (downloaded / 1024 / 1024).toFixed(1);
const totalMb = total ? (total / 1024 / 1024).toFixed(1) : '?';
const speedMb = (speed / 1024 / 1024).toFixed(2);
const eta = (total > 0 && speed > 0) ? Math.round((total - downloaded) / speed) : -1;
sendProgress(`正在下载... ${percent}%`, overallProgress, {
downloaded, total, speed, eta,
downloadedStr: `${downloadedMb}MB`,
totalStr: `${totalMb}MB`,
speedStr: `${speedMb}MB/s`,
});
},
300000,
);
sendProgress('下载完成,正在解压...', 85);
await extractNpmTarball(tempTgzPath, pluginId);
sendProgress('解压完成,正在清理...', 95);
fs.unlinkSync(tempTgzPath);
const pluginManager = getPluginManager();
if (pluginManager) {
if (pluginManager.getPluginInfo(pluginId)) {
sendProgress('正在刷新插件信息...', 95);
await pluginManager.reloadPlugin(pluginId);
} else {
sendProgress('正在注册插件...', 95);
await pluginManager.loadPluginById(pluginId);
}
}
sendProgress('安装成功!', 100);
res.write(`data: ${JSON.stringify({
success: true,
message: 'Plugin installed successfully from npm',
pluginId,
version: versionInfo.version,
installPath: path.join(PLUGINS_DIR, pluginId),
})}\n\n`);
res.end();
} catch (e: any) {
if (fs.existsSync(tempTgzPath)) fs.unlinkSync(tempTgzPath);
sendProgress(`错误: ${e.message}`, 0);
res.write(`data: ${JSON.stringify({ error: e.message })}\n\n`);
res.end();
}
};
/**
* 搜索 npm 上的 NapCat 插件
* 使用 npm search API 搜索带有特定关键字的包
*/
export const SearchNpmPluginsHandler: RequestHandler = async (req, res) => {
try {
const keyword = (req.query['keyword'] as string) || 'napcat-plugin';
const registry = (req.query['registry'] as string) || NPM_REGISTRY_MIRRORS[0];
const from = parseInt(req.query['from'] as string) || 0;
const size = Math.min(parseInt(req.query['size'] as string) || 20, 50);
// npm search API: /-/v1/search?text=keyword
const searchUrl = `${registry?.replace(/\/$/, '')}/-/v1/search?text=${encodeURIComponent(keyword)}&from=${from}&size=${size}`;
const response = await fetch(searchUrl, {
headers: {
'Accept': 'application/json',
'User-Agent': 'NapCat-PluginManager',
},
signal: AbortSignal.timeout(15000),
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const searchResult = await response.json() as any;
// 转换为 PluginStoreItem 格式
const plugins: PluginStoreItem[] = (searchResult.objects || []).map((obj: any) => {
const pkg = obj.package;
return {
id: pkg.name,
name: pkg.napcat?.displayName || pkg.name,
version: pkg.version,
description: pkg.description || '',
author: extractAuthorName(pkg.author || (pkg.publisher ? pkg.publisher.username : undefined)),
homepage: extractHomepage(pkg.links?.homepage, pkg.links?.repository),
downloadUrl: '', // npm 源不需要 downloadUrl
tags: pkg.keywords || [],
source: 'npm' as const,
npmPackage: pkg.name,
};
});
return sendSuccess(res, {
total: searchResult.total || 0,
plugins,
});
} catch (e: any) {
return sendError(res, '搜索 npm 插件失败: ' + e.message);
}
};
/**
* 获取 npm 包详情版本列表、README 等)
*/
export const GetNpmPluginDetailHandler: RequestHandler = async (req, res) => {
try {
const packageName = req.params['packageName'];
const registry = req.query['registry'] as string | undefined;
if (!packageName) {
return sendError(res, '包名不能为空');
}
const metadata = await fetchNpmPackageMetadata(packageName, registry);
const latestVersion = metadata['dist-tags']?.['latest'] || '';
const latestInfo = latestVersion ? metadata.versions[latestVersion] : null;
return sendSuccess(res, {
name: metadata.name,
description: metadata.description || '',
latestVersion,
author: extractAuthorName(metadata.author),
homepage: extractHomepage(metadata.homepage, metadata.repository),
readme: metadata.readme || '',
versions: Object.keys(metadata.versions).reverse().slice(0, 20),
keywords: metadata.keywords || [],
tarball: latestInfo?.dist?.tarball || '',
unpackedSize: latestInfo?.dist?.unpackedSize,
napcat: latestInfo?.napcat,
});
} catch (e: any) {
return sendError(res, '获取 npm 包详情失败: ' + e.message);
}
};

View File

@@ -18,7 +18,11 @@ import {
GetPluginStoreListHandler,
GetPluginStoreDetailHandler,
InstallPluginFromStoreHandler,
InstallPluginFromStoreSSEHandler
InstallPluginFromStoreSSEHandler,
InstallPluginFromNpmHandler,
InstallPluginFromNpmSSEHandler,
SearchNpmPluginsHandler,
GetNpmPluginDetailHandler,
} from '@/napcat-webui-backend/src/api/PluginStore';
import { WebUiDataRuntime } from '@/napcat-webui-backend/src/helper/Data';
import { NapCatOneBot11Adapter } from '@/napcat-onebot/index';
@@ -75,6 +79,12 @@ router.get('/Store/Detail/:id', GetPluginStoreDetailHandler);
router.post('/Store/Install', InstallPluginFromStoreHandler);
router.get('/Store/Install/SSE', InstallPluginFromStoreSSEHandler);
// npm 插件安装相关路由
router.post('/Npm/Install', InstallPluginFromNpmHandler);
router.get('/Npm/Install/SSE', InstallPluginFromNpmSSEHandler);
router.get('/Npm/Search', SearchNpmPluginsHandler);
router.get('/Npm/Detail/:packageName', GetNpmPluginDetailHandler);
// 插件扩展路由 - 动态挂载插件注册的 API 路由
router.use('/ext/:pluginId', (req, res, next): void => {
const { pluginId } = req.params;

View File

@@ -1,5 +1,8 @@
// 插件商店相关类型定义
/** 插件来源类型 */
export type PluginSourceType = 'npm' | 'github';
export interface PluginStoreItem {
id: string; // 插件唯一标识
name: string; // 插件名称
@@ -7,9 +10,13 @@ export interface PluginStoreItem {
description: string; // 插件描述
author: string; // 作者
homepage?: string; // 主页链接
downloadUrl: string; // 下载地址
downloadUrl: string; // 下载地址GitHub 模式兼容)
tags?: string[]; // 标签
minVersion?: string; // 最低版本要求
/** 插件来源类型,默认 'github' 保持向后兼容 */
source?: PluginSourceType;
/** npm 包名(当 source 为 'npm' 时使用) */
npmPackage?: string;
}
export interface PluginStoreList {

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

@@ -185,6 +185,19 @@ const PluginStoreCard: React.FC<PluginStoreCardProps> = ({
v{version}
</Chip>
{/* 来源标识 */}
{data.source === 'npm' && (
<Chip
size='sm'
variant='flat'
color='danger'
className='h-5 text-xs font-semibold px-0.5'
classNames={{ content: 'px-1' }}
>
npm
</Chip>
)}
{/* Tags with proper truncation and hover */}
{tags?.slice(0, 2).map((tag) => (
<Chip

View File

@@ -164,16 +164,49 @@ export default class PluginManager {
/**
* 从商店安装插件
* @param id 插件 ID
* @param mirror 镜像源
* @param mirror 镜像源GitHub 模式)
* @param registry npm 镜像源npm 模式)
*/
public static async installPluginFromStore (id: string, mirror?: string): Promise<void> {
public static async installPluginFromStore (id: string, mirror?: string, registry?: string): Promise<void> {
await serverRequest.post<ServerResponse<void>>(
'/Plugin/Store/Install',
{ id, mirror },
{ id, mirror, registry },
{ timeout: 300000 } // 5分钟超时
);
}
// ==================== npm 插件安装 ====================
/** npm 搜索结果 */
public static async searchNpmPlugins (
keyword: string = 'napcat-plugin',
registry?: string,
from: number = 0,
size: number = 20,
): Promise<{ total: number; plugins: PluginStoreItem[]; }> {
const params: Record<string, string> = { keyword, from: String(from), size: String(size) };
if (registry) params['registry'] = registry;
const { data } = await serverRequest.get<ServerResponse<{ total: number; plugins: PluginStoreItem[]; }>>('/Plugin/Npm/Search', { params });
return data.data;
}
/** 获取 npm 包详情 */
public static async getNpmPluginDetail (packageName: string, registry?: string): Promise<any> {
const params: Record<string, string> = {};
if (registry) params['registry'] = registry;
const { data } = await serverRequest.get<ServerResponse<any>>(`/Plugin/Npm/Detail/${encodeURIComponent(packageName)}`, { params });
return data.data;
}
/** 从 npm 直接安装插件 */
public static async installPluginFromNpm (packageName: string, version?: string, registry?: string): Promise<void> {
await serverRequest.post<ServerResponse<void>>(
'/Plugin/Npm/Install',
{ packageName, version, registry },
{ timeout: 300000 },
);
}
// ==================== 插件配置 ====================
/**

View File

@@ -15,16 +15,9 @@ interface BypassFormData {
process: boolean;
container: boolean;
js: boolean;
o3HookMode: boolean;
}
const defaultBypass: BypassFormData = {
hook: true,
window: true,
module: true,
process: true,
container: true,
js: true,
};
const BypassConfigCard = () => {
const [loading, setLoading] = useState(true);
@@ -33,21 +26,20 @@ const BypassConfigCard = () => {
handleSubmit,
formState: { isSubmitting },
setValue,
} = useForm<BypassFormData>({
defaultValues: defaultBypass,
});
} = useForm<BypassFormData>();
const loadConfig = async (showTip = false) => {
try {
setLoading(true);
const config = await QQManager.getNapCatConfig();
const bypass = config.bypass ?? defaultBypass;
setValue('hook', bypass.hook ?? true);
setValue('window', bypass.window ?? true);
setValue('module', bypass.module ?? true);
setValue('process', bypass.process ?? true);
setValue('container', bypass.container ?? true);
setValue('js', bypass.js ?? true);
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;
@@ -59,7 +51,8 @@ const BypassConfigCard = () => {
const onSubmit = handleSubmit(async (data) => {
try {
await QQManager.setNapCatConfig({ bypass: data });
const { o3HookMode, ...bypass } = data;
await QQManager.setNapCatConfig({ bypass, o3HookMode: o3HookMode ? 1 : 0 });
toast.success('保存成功,重启后生效');
} catch (error) {
const msg = (error as Error).message;
@@ -90,72 +83,85 @@ const BypassConfigCard = () => {
Napi2Native
</p>
</div>
<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反检测'
/>
)}
/>
<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}

View File

@@ -115,7 +115,7 @@ export default function ConfigPage () {
<BackupConfigCard />
</ConfigPageItem>
</Tab>
<Tab title='Bypass配置' key='bypass'>
<Tab title='反检测' key='bypass'>
<ConfigPageItem>
<BypassConfigCard />
</ConfigPageItem>

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

@@ -115,39 +115,42 @@ 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',
}}
>
{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>
<div className='max-w-full overflow-x-auto overflow-y-hidden pb-1 -mb-1'>
<Tabs
aria-label='Extension Pages'
className='min-w-max'
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}
className='shrink-0'
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 shrink-0'
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 shrink-0'>({tab.pluginName})</span>
</div>
}
/>
))}
</Tabs>
</div>
)}
</div>

View File

@@ -61,54 +61,43 @@ 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>
),
confirmText: '确定卸载',
cancelText: '取消',
onConfirm: async () => {
// Ask for data cleanup
dialog.confirm({
title: '删除配置',
content: (
<div className="flex flex-col gap-2">
<p>{plugin.name}</p>
<div className="text-small text-default-500">
<p>配置目录: config/plugins/{plugin.id}</p>
<p>"确定""取消"</p>
</div>
</div>
),
confirmText: '清理并卸载',
cancelText: '仅卸载',
onConfirm: async () => {
await performUninstall(true);
},
onCancel: async () => {
await performUninstall(false);
}
});
const loadingToast = toast.loading('卸载中...');
try {
await PluginManager.uninstallPlugin(plugin.id, cleanData);
toast.success('卸载成功', { id: loadingToast });
loadPlugins();
resolve();
} catch (e: any) {
toast.error(e.message, { id: loadingToast });
reject(e);
}
},
onCancel: () => {
resolve();
}
});
const performUninstall = async (cleanData: boolean) => {
const loadingToast = toast.loading('卸载中...');
try {
await PluginManager.uninstallPlugin(plugin.id, cleanData);
toast.success('卸载成功', { id: loadingToast });
loadPlugins();
resolve();
} catch (e: any) {
toast.error(e.message, { id: loadingToast });
reject(e);
}
};
});
};

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

@@ -6,11 +6,13 @@ import { Tooltip } from '@heroui/tooltip';
import { Spinner } from '@heroui/spinner';
import { IoMdCheckmarkCircle, IoMdOpen, IoMdDownload } from 'react-icons/io';
import { MdUpdate } from 'react-icons/md';
import { FaNpm } from 'react-icons/fa';
import { useState, useEffect } from 'react';
import { PluginStoreItem } from '@/types/plugin-store';
import { InstallStatus } from '@/components/display_card/plugin_store_card';
import TailwindMarkdown from '@/components/tailwind_markdown';
import PluginManagerController from '@/controllers/plugin_manager';
interface PluginDetailModalProps {
isOpen: boolean;
@@ -124,12 +126,15 @@ export default function PluginDetailModal ({
const [readmeLoading, setReadmeLoading] = useState(false);
const [readmeError, setReadmeError] = useState(false);
// 判断插件来源
const isNpmSource = plugin?.source === 'npm';
// 获取 GitHub 仓库信息(需要在 hooks 之前计算)
const githubRepo = plugin ? extractGitHubRepo(plugin.homepage) : null;
// 当模态框打开且有 GitHub 链接时,获取 README
// 当模态框打开时,获取 READMEnpm 或 GitHub
useEffect(() => {
if (!isOpen || !githubRepo) {
if (!isOpen || !plugin) {
setReadme('');
setReadmeError(false);
return;
@@ -139,9 +144,22 @@ export default function PluginDetailModal ({
setReadmeLoading(true);
setReadmeError(false);
try {
const content = await fetchGitHubReadme(githubRepo.owner, githubRepo.repo);
// 清理 HTML 标签后再设置
setReadme(cleanReadmeHtml(content));
if (isNpmSource && plugin.npmPackage) {
// npm 来源:从后端获取 npm 包详情中的 README
const detail = await PluginManagerController.getNpmPluginDetail(plugin.npmPackage);
if (detail?.readme) {
setReadme(cleanReadmeHtml(detail.readme));
} else {
setReadmeError(true);
}
} else if (githubRepo) {
// GitHub 来源:从 GitHub API 获取 README
const content = await fetchGitHubReadme(githubRepo.owner, githubRepo.repo);
setReadme(cleanReadmeHtml(content));
} else {
// 无可用的 README 来源
setReadme('');
}
} catch (error) {
console.error('Failed to fetch README:', error);
setReadmeError(true);
@@ -151,11 +169,11 @@ export default function PluginDetailModal ({
};
loadReadme();
}, [isOpen, githubRepo?.owner, githubRepo?.repo]);
}, [isOpen, plugin?.id, isNpmSource, plugin?.npmPackage, githubRepo?.owner, githubRepo?.repo]);
if (!plugin) return null;
const { name, version, author, description, tags, homepage, downloadUrl, minVersion } = plugin;
const { name, version, author, description, tags, homepage, downloadUrl, minVersion, npmPackage } = plugin;
const avatarUrl = getAuthorAvatar(homepage, downloadUrl) || `https://avatar.vercel.sh/${encodeURIComponent(name)}`;
return (
@@ -213,6 +231,16 @@ export default function PluginDetailModal ({
<Chip size='sm' color='primary' variant='flat'>
v{version}
</Chip>
{isNpmSource && (
<Chip
size='sm'
color='danger'
variant='flat'
startContent={<FaNpm size={14} />}
>
npm
</Chip>
)}
{tags?.map((tag) => (
<Chip
key={tag}
@@ -281,6 +309,23 @@ export default function PluginDetailModal ({
<span className='text-default-500'> ID:</span>
<span className='font-mono text-xs text-default-900'>{plugin.id}</span>
</div>
{npmPackage && (
<div className='flex justify-between items-center'>
<span className='text-default-500'>npm :</span>
<Button
size='sm'
variant='flat'
color='danger'
as='a'
href={`https://www.npmjs.com/package/${npmPackage}`}
target='_blank'
rel='noreferrer'
startContent={<FaNpm size={14} />}
>
{npmPackage}
</Button>
</div>
)}
{downloadUrl && (
<div className='flex justify-between items-center'>
<span className='text-default-500'>:</span>
@@ -301,8 +346,8 @@ export default function PluginDetailModal ({
</div>
</div>
{/* GitHub README 显示 */}
{githubRepo && (
{/* README 显示(支持 npm 和 GitHub */}
{(githubRepo || isNpmSource) && (
<>
<div className='mt-2'>
<h3 className='text-sm font-semibold text-default-700 mb-3'></h3>
@@ -316,17 +361,19 @@ export default function PluginDetailModal ({
<p className='text-sm text-default-500 mb-3'>
README
</p>
<Button
color='primary'
variant='flat'
as='a'
href={homepage}
target='_blank'
rel='noreferrer'
startContent={<IoMdOpen />}
>
GitHub
</Button>
{homepage && (
<Button
color='primary'
variant='flat'
as='a'
href={homepage}
target='_blank'
rel='noreferrer'
startContent={<IoMdOpen />}
>
{isNpmSource ? '在 npm 查看' : '在 GitHub 查看'}
</Button>
)}
</div>
)}
{!readmeLoading && !readmeError && readme && (

View File

@@ -3,9 +3,11 @@ import { Input } from '@heroui/input';
import { Tab, Tabs } from '@heroui/tabs';
import { Tooltip } from '@heroui/tooltip';
import { Spinner } from '@heroui/spinner';
import { Modal, ModalContent, ModalHeader, ModalBody, ModalFooter } from '@heroui/modal';
import { useEffect, useMemo, useState, useRef } from 'react';
import toast from 'react-hot-toast';
import { IoMdRefresh, IoMdSearch, IoMdSettings } from 'react-icons/io';
import { MdOutlineGetApp } from 'react-icons/md';
import clsx from 'clsx';
import { EventSourcePolyfill } from 'event-source-polyfill';
import { useLocalStorage } from '@uidotdev/usehooks';
@@ -82,6 +84,13 @@ export default function PluginStorePage () {
const [pendingInstallPlugin, setPendingInstallPlugin] = useState<PluginStoreItem | null>(null);
const [selectedDownloadMirror, setSelectedDownloadMirror] = useState<string | undefined>(undefined);
// npm 注册表镜像弹窗状态
const [npmRegistryModalOpen, setNpmRegistryModalOpen] = useState(false);
const [selectedNpmRegistry, setSelectedNpmRegistry] = useState<string | undefined>(undefined);
// npm 直接安装弹窗状态
const [npmInstallModalOpen, setNpmInstallModalOpen] = useState(false);
// 插件详情弹窗状态
const [detailModalOpen, setDetailModalOpen] = useState(false);
const [selectedPlugin, setSelectedPlugin] = useState<PluginStoreItem | null>(null);
@@ -179,12 +188,19 @@ export default function PluginStorePage () {
}, [categorizedPlugins]);
const handleInstall = async (plugin: PluginStoreItem) => {
// 弹窗选择下载镜像
setPendingInstallPlugin(plugin);
setDownloadMirrorModalOpen(true);
const isNpmSource = plugin.source === 'npm' && plugin.npmPackage;
if (isNpmSource) {
// npm 源 → 选择 npm registry 镜像
setPendingInstallPlugin(plugin);
setNpmRegistryModalOpen(true);
} else {
// GitHub 源(默认/向后兼容)→ 选择 GitHub 下载镜像
setPendingInstallPlugin(plugin);
setDownloadMirrorModalOpen(true);
}
};
const installPluginWithSSE = async (pluginId: string, mirror?: string) => {
const installPluginWithSSE = async (pluginId: string, mirror?: string, registry?: string) => {
const loadingToast = toast.loading('正在准备安装...');
try {
@@ -200,6 +216,9 @@ export default function PluginStorePage () {
if (mirror) {
params.append('mirror', mirror);
}
if (registry) {
params.append('registry', registry);
}
const eventSource = new EventSourcePolyfill(
`/api/Plugin/Store/Install/SSE?${params.toString()}`,
@@ -288,6 +307,74 @@ export default function PluginStorePage () {
}
};
const installNpmPackageWithSSE = async (packageName: string, registry?: string) => {
const loadingToast = toast.loading('正在从 npm 安装...');
try {
const token = localStorage.getItem(key.token);
if (!token) {
toast.error('未登录,请先登录', { id: loadingToast });
return;
}
const _token = JSON.parse(token);
const params = new URLSearchParams({ packageName });
if (registry) params.append('registry', registry);
const eventSource = new EventSourcePolyfill(
`/api/Plugin/Npm/Install/SSE?${params.toString()}`,
{
headers: {
Authorization: `Bearer ${_token}`,
Accept: 'text/event-stream',
},
withCredentials: true,
}
);
eventSource.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
if (data.error) {
toast.error(`安装失败: ${data.error}`, { id: loadingToast });
setInstallProgress(prev => ({ ...prev, show: false }));
eventSource.close();
} else if (data.success) {
toast.success('从 npm 安装成功!', { id: loadingToast });
setInstallProgress(prev => ({ ...prev, show: false }));
eventSource.close();
loadPlugins();
} else if (data.message) {
if (typeof data.progress === 'number' && data.progress >= 0 && data.progress <= 100) {
setInstallProgress((prev) => ({
...prev,
show: true,
message: data.message,
progress: data.progress,
speedStr: data.speedStr || (data.message.includes('下载') ? prev.speedStr : undefined),
eta: data.eta !== undefined ? data.eta : (data.message.includes('下载') ? prev.eta : undefined),
downloadedStr: data.downloadedStr || (data.message.includes('下载') ? prev.downloadedStr : undefined),
totalStr: data.totalStr || (data.message.includes('下载') ? prev.totalStr : undefined),
}));
} else {
toast.loading(data.message, { id: loadingToast });
}
}
} catch (e) {
console.error('Failed to parse SSE message:', e);
}
};
eventSource.onerror = () => {
toast.error('连接中断,安装失败', { id: loadingToast });
setInstallProgress(prev => ({ ...prev, show: false }));
eventSource.close();
};
} catch (error: any) {
toast.error(`安装失败: ${error.message || '未知错误'}`, { id: loadingToast });
}
};
const getStoreSourceDisplayName = () => {
if (!currentStoreSource) return '默认源';
try {
@@ -329,6 +416,18 @@ export default function PluginStorePage () {
<IoMdRefresh size={20} />
</Button>
</Tooltip>
<Tooltip content='从 npm 包名安装插件'>
<Button
size='sm'
variant='flat'
className='bg-default-100/50 hover:bg-default-200/50 text-default-700 backdrop-blur-md'
radius='full'
startContent={<MdOutlineGetApp size={18} />}
onPress={() => setNpmInstallModalOpen(true)}
>
npm
</Button>
</Tooltip>
</div>
{/* 顶栏搜索框与列表源 */}
@@ -428,7 +527,7 @@ export default function PluginStorePage () {
type='raw'
/>
{/* 下载镜像选择弹窗 */}
{/* 下载镜像选择弹窗GitHub 源插件使用) */}
<MirrorSelectorModal
isOpen={downloadMirrorModalOpen}
onClose={() => {
@@ -440,7 +539,7 @@ export default function PluginStorePage () {
// 选择后立即开始安装
if (pendingInstallPlugin) {
setDownloadMirrorModalOpen(false);
installPluginWithSSE(pendingInstallPlugin.id, mirror);
installPluginWithSSE(pendingInstallPlugin.id, mirror, undefined);
setPendingInstallPlugin(null);
}
}}
@@ -448,6 +547,24 @@ export default function PluginStorePage () {
type='file'
/>
{/* npm Registry 选择弹窗npm 源插件使用) */}
<NpmRegistrySelectorModal
isOpen={npmRegistryModalOpen}
onClose={() => {
setNpmRegistryModalOpen(false);
setPendingInstallPlugin(null);
}}
onSelect={(registry) => {
setSelectedNpmRegistry(registry);
if (pendingInstallPlugin) {
setNpmRegistryModalOpen(false);
installPluginWithSSE(pendingInstallPlugin.id, undefined, registry);
setPendingInstallPlugin(null);
}
}}
currentRegistry={selectedNpmRegistry}
/>
{/* 插件详情弹窗 */}
<PluginDetailModal
isOpen={detailModalOpen}
@@ -470,6 +587,17 @@ export default function PluginStorePage () {
}}
/>
{/* npm 直接安装弹窗 */}
<NpmDirectInstallModal
isOpen={npmInstallModalOpen}
onClose={() => setNpmInstallModalOpen(false)}
onInstall={(packageName, registry) => {
setNpmInstallModalOpen(false);
// 使用 SSE 安装 npm 包
installNpmPackageWithSSE(packageName, registry);
}}
/>
{/* 插件下载进度条全局居中样式 */}
{installProgress.show && (
<div className='fixed inset-0 flex items-center justify-center z-[9999] animate-in fade-in duration-300'>
@@ -529,3 +657,249 @@ export default function PluginStorePage () {
</>
);
}
// ============== npm Registry 选择弹窗 ==============
const NPM_REGISTRIES = [
{ label: '淘宝镜像(推荐)', value: 'https://registry.npmmirror.com', recommended: true },
{ label: 'npm 官方', value: 'https://registry.npmjs.org' },
];
interface NpmRegistrySelectorModalProps {
isOpen: boolean;
onClose: () => void;
onSelect: (registry: string | undefined) => void;
currentRegistry?: string;
}
function NpmRegistrySelectorModal ({
isOpen,
onClose,
onSelect,
currentRegistry,
}: NpmRegistrySelectorModalProps) {
const [selected, setSelected] = useState<string>(currentRegistry || NPM_REGISTRIES[0]?.value || '');
return (
<Modal
isOpen={isOpen}
onClose={onClose}
size='md'
classNames={{
backdrop: 'z-[200]',
wrapper: 'z-[200]',
}}
>
<ModalContent>
<ModalHeader> npm </ModalHeader>
<ModalBody>
<div className='flex flex-col gap-2'>
{NPM_REGISTRIES.map((reg) => (
<div
key={reg.value}
className={clsx(
'flex items-center justify-between p-3 rounded-lg cursor-pointer transition-all',
'bg-content1 hover:bg-content2 border-2',
selected === reg.value ? 'border-primary' : 'border-transparent',
)}
onClick={() => setSelected(reg.value)}
>
<div>
<p className='font-medium'>{reg.label}</p>
<p className='text-xs text-default-500'>{reg.value}</p>
</div>
{reg.recommended && (
<span className='text-xs bg-primary/10 text-primary px-2 py-0.5 rounded-full'></span>
)}
</div>
))}
</div>
</ModalBody>
<ModalFooter>
<Button variant='light' onPress={onClose}></Button>
<Button color='primary' onPress={() => { onSelect(selected); onClose(); }}>
</Button>
</ModalFooter>
</ModalContent>
</Modal>
);
}
// ============== npm 直接安装弹窗 ==============
interface NpmDirectInstallModalProps {
isOpen: boolean;
onClose: () => void;
onInstall: (packageName: string, registry?: string) => void;
}
function NpmDirectInstallModal ({
isOpen,
onClose,
onInstall,
}: NpmDirectInstallModalProps) {
const [packageName, setPackageName] = useState('');
const [registry, setRegistry] = useState(NPM_REGISTRIES[0]?.value || '');
const [searchKeyword, setSearchKeyword] = useState('');
const [searchResults, setSearchResults] = useState<PluginStoreItem[]>([]);
const [searching, setSearching] = useState(false);
const [activeTab, setActiveTab] = useState<string>('search');
const handleInstall = () => {
if (!packageName.trim()) {
toast.error('请输入 npm 包名');
return;
}
onInstall(packageName.trim(), registry);
setPackageName('');
};
const handleSearch = async () => {
const keyword = searchKeyword.trim() || 'napcat-plugin';
setSearching(true);
try {
const result = await PluginManager.searchNpmPlugins(keyword, registry);
setSearchResults(result.plugins || []);
if (result.plugins.length === 0) {
toast('未找到相关插件', { icon: '🔍' });
}
} catch (error: any) {
toast.error('搜索失败: ' + (error?.message || '未知错误'));
} finally {
setSearching(false);
}
};
return (
<Modal
isOpen={isOpen}
onClose={onClose}
size='2xl'
scrollBehavior='inside'
classNames={{
backdrop: 'z-[200]',
wrapper: 'z-[200]',
}}
>
<ModalContent>
<ModalHeader> npm </ModalHeader>
<ModalBody>
<div className='flex flex-col gap-4'>
{/* npm 镜像源选择 */}
<div className='flex flex-col gap-2'>
<p className='text-sm font-medium'>npm </p>
<div className='flex gap-2'>
{NPM_REGISTRIES.map((reg) => (
<div
key={reg.value}
className={clsx(
'flex-1 flex items-center justify-center p-2 rounded-lg cursor-pointer transition-all text-sm',
'bg-content1 hover:bg-content2 border-2',
registry === reg.value ? 'border-primary' : 'border-transparent',
)}
onClick={() => setRegistry(reg.value)}
>
<span>{reg.label}</span>
</div>
))}
</div>
</div>
{/* 搜索 / 手动输入 切换 */}
<Tabs
selectedKey={activeTab}
onSelectionChange={(key) => setActiveTab(key as string)}
variant='underlined'
color='primary'
>
<Tab key='search' title='搜索插件'>
<div className='flex flex-col gap-3'>
<div className='flex gap-2'>
<Input
placeholder='搜索 napcat 插件...'
value={searchKeyword}
onValueChange={setSearchKeyword}
onKeyDown={(e) => { if (e.key === 'Enter') handleSearch(); }}
startContent={<IoMdSearch className='text-default-400' />}
size='sm'
/>
<Button
color='primary'
size='sm'
onPress={handleSearch}
isLoading={searching}
className='flex-shrink-0'
>
</Button>
</div>
{/* 搜索结果列表 */}
{searchResults.length > 0 && (
<div className='flex flex-col gap-2 max-h-64 overflow-y-auto'>
{searchResults.map((pkg) => (
<div
key={pkg.id}
className='flex items-center justify-between p-3 rounded-lg bg-content1 hover:bg-content2 transition-all'
>
<div className='flex-1 min-w-0'>
<div className='flex items-center gap-2'>
<span className='font-medium text-sm truncate'>{pkg.name}</span>
<span className='text-xs text-default-400'>v{pkg.version}</span>
</div>
<p className='text-xs text-default-500 mt-1 truncate'>
{pkg.description || '暂无描述'}
</p>
{pkg.author && (
<p className='text-xs text-default-400 mt-0.5'>
by {pkg.author}
</p>
)}
</div>
<Button
size='sm'
color='primary'
variant='flat'
onPress={() => {
onInstall(pkg.npmPackage || pkg.id, registry);
}}
className='flex-shrink-0 ml-2'
>
</Button>
</div>
))}
</div>
)}
</div>
</Tab>
<Tab key='manual' title='手动输入'>
<div className='flex flex-col gap-3'>
<p className='text-sm text-default-500'>
npm
</p>
<Input
label='npm 包名'
placeholder='例如: napcat-plugin-example'
value={packageName}
onValueChange={setPackageName}
description='输入完整的 npm 包名'
onKeyDown={(e) => { if (e.key === 'Enter') handleInstall(); }}
/>
</div>
</Tab>
</Tabs>
</div>
</ModalBody>
<ModalFooter>
<Button variant='light' onPress={onClose}></Button>
{activeTab === 'manual' && (
<Button color='primary' onPress={handleInstall} isDisabled={!packageName.trim()}>
</Button>
)}
</ModalFooter>
</ModalContent>
</Modal>
);
}

View File

@@ -1,5 +1,8 @@
// 插件商店相关类型定义
/** 插件来源类型 */
export type PluginSourceType = 'npm' | 'github';
export interface PluginStoreItem {
id: string; // 插件唯一标识
name: string; // 插件名称
@@ -7,9 +10,13 @@ export interface PluginStoreItem {
description: string; // 插件描述
author: string; // 作者
homepage?: string; // 主页链接
downloadUrl: string; // 下载地址
downloadUrl: string; // 下载地址GitHub 模式兼容)
tags?: string[]; // 标签
minVersion?: string; // 最低版本要求
/** 插件来源类型,默认 'github' 保持向后兼容 */
source?: PluginSourceType;
/** npm 包名(当 source 为 'npm' 时使用) */
npmPackage?: string;
}
export interface PluginStoreList {