mirror of
https://github.com/NapNeko/NapCatQQ.git
synced 2026-03-01 08:10:25 +00:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2f8569f30c | ||
|
|
82d0c51716 | ||
|
|
37fb2d68d7 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -10,6 +10,9 @@ devconfig/*
|
||||
!.vscode/extensions.json
|
||||
.idea/*
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
|
||||
# Build
|
||||
*.db
|
||||
checkVersion.sh
|
||||
|
||||
@@ -184,6 +184,35 @@ export function stringifyWithBigInt (obj: any) {
|
||||
);
|
||||
}
|
||||
|
||||
export function parseAppidFromMajorV2 (nodeMajor: string): string | undefined {
|
||||
const marker = Buffer.from('QQAppId/', 'utf-8');
|
||||
const filePath = path.resolve(nodeMajor);
|
||||
const fileContent = fs.readFileSync(filePath);
|
||||
|
||||
let searchPosition = 0;
|
||||
while (true) {
|
||||
const index = fileContent.indexOf(marker, searchPosition);
|
||||
if (index === -1) {
|
||||
break;
|
||||
}
|
||||
|
||||
const start = index + marker.length;
|
||||
const end = fileContent.indexOf(0x00, start);
|
||||
if (end === -1) {
|
||||
break;
|
||||
}
|
||||
const content = fileContent.subarray(start, end);
|
||||
const str = content.toString('utf-8');
|
||||
if (/^\d+$/.test(str)) {
|
||||
return str;
|
||||
}
|
||||
|
||||
searchPosition = end + 1;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function parseAppidFromMajor (nodeMajor: string): string | undefined {
|
||||
const hexSequence = 'A4 09 00 00 00 35';
|
||||
const sequenceBytes = Buffer.from(hexSequence.replace(/ /g, ''), 'hex');
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import fs from 'node:fs';
|
||||
import { systemPlatform } from 'napcat-common/src/system';
|
||||
import { getDefaultQQVersionConfigInfo, getQQPackageInfoPath, getQQVersionConfigPath, parseAppidFromMajor } from 'napcat-common/src/helper';
|
||||
import { getDefaultQQVersionConfigInfo, getQQPackageInfoPath, getQQVersionConfigPath, parseAppidFromMajor, parseAppidFromMajorV2 } from 'napcat-common/src/helper';
|
||||
import AppidTable from '@/napcat-core/external/appid.json';
|
||||
import { LogWrapper } from './log';
|
||||
import { getMajorPath } from '@/napcat-core/index';
|
||||
@@ -107,7 +107,13 @@ export class QQBasicInfoWrapper {
|
||||
if (!this.QQMainPath) {
|
||||
throw new Error('QQMainPath未定义 无法通过Major获取Appid');
|
||||
}
|
||||
const majorPath = getMajorPath(QQVersion, this.QQMainPath);
|
||||
const majorPath = getMajorPath(this.QQMainPath, QQVersion);
|
||||
// 优先通过 QQAppId/ 标记搜索
|
||||
const appidV2 = parseAppidFromMajorV2(majorPath);
|
||||
if (appidV2) {
|
||||
return appidV2;
|
||||
}
|
||||
// 回落到旧方式
|
||||
const appid = parseAppidFromMajor(majorPath);
|
||||
return appid;
|
||||
}
|
||||
|
||||
21
packages/napcat-dpapi/LICENSE
Normal file
21
packages/napcat-dpapi/LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2023 Xavier Monin
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
4
packages/napcat-dpapi/README.md
Normal file
4
packages/napcat-dpapi/README.md
Normal file
@@ -0,0 +1,4 @@
|
||||
# @primno/dpapi
|
||||
|
||||
## 协议与说明
|
||||
全部遵守原仓库要求
|
||||
65
packages/napcat-dpapi/index.ts
Normal file
65
packages/napcat-dpapi/index.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* napcat-dpapi - Windows DPAPI wrapper
|
||||
*
|
||||
* Loads the native @primno+dpapi.node addon from the runtime
|
||||
* native/dpapi/ directory using process.dlopen, consistent
|
||||
* with how other native modules (ffmpeg, packet, pty) are loaded.
|
||||
*/
|
||||
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import path, { dirname } from 'node:path';
|
||||
|
||||
export type DataProtectionScope = 'CurrentUser' | 'LocalMachine';
|
||||
|
||||
export interface DpapiBindings {
|
||||
protectData (dataToEncrypt: Uint8Array, optionalEntropy: Uint8Array | null, scope: DataProtectionScope): Uint8Array;
|
||||
unprotectData (encryptData: Uint8Array, optionalEntropy: Uint8Array | null, scope: DataProtectionScope): Uint8Array;
|
||||
}
|
||||
|
||||
let dpapiBindings: DpapiBindings | null = null;
|
||||
let loadError: Error | null = null;
|
||||
|
||||
function getAddonPath (): string {
|
||||
// At runtime, import.meta.url resolves to dist/ directory.
|
||||
// Native files are at dist/native/dpapi/{platform}-{arch}/@primno+dpapi.node
|
||||
const importDir = dirname(fileURLToPath(import.meta.url));
|
||||
const platform = process.platform; // 'win32'
|
||||
const arch = process.arch; // 'x64' or 'arm64'
|
||||
return path.join(importDir, 'native', 'dpapi', `${platform}-${arch}`, '@primno+dpapi.node');
|
||||
}
|
||||
|
||||
function loadDpapi (): DpapiBindings {
|
||||
if (dpapiBindings) {
|
||||
return dpapiBindings;
|
||||
}
|
||||
if (loadError) {
|
||||
throw loadError;
|
||||
}
|
||||
try {
|
||||
const addonPath = getAddonPath();
|
||||
const nativeModule: { exports: DpapiBindings } = { exports: {} as DpapiBindings };
|
||||
process.dlopen(nativeModule, addonPath);
|
||||
dpapiBindings = nativeModule.exports;
|
||||
return dpapiBindings;
|
||||
} catch (e) {
|
||||
loadError = e as Error;
|
||||
throw new Error(`Failed to load DPAPI native addon: ${(e as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
export const isPlatformSupported = process.platform === 'win32';
|
||||
|
||||
export function protectData (data: Uint8Array, optionalEntropy: Uint8Array | null, scope: DataProtectionScope): Uint8Array {
|
||||
return loadDpapi().protectData(data, optionalEntropy, scope);
|
||||
}
|
||||
|
||||
export function unprotectData (data: Uint8Array, optionalEntropy: Uint8Array | null, scope: DataProtectionScope): Uint8Array {
|
||||
return loadDpapi().unprotectData(data, optionalEntropy, scope);
|
||||
}
|
||||
|
||||
export const Dpapi = {
|
||||
protectData,
|
||||
unprotectData,
|
||||
};
|
||||
|
||||
export default Dpapi;
|
||||
18
packages/napcat-dpapi/package.json
Normal file
18
packages/napcat-dpapi/package.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "napcat-dpapi",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "index.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./index.ts"
|
||||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
}
|
||||
10
packages/napcat-dpapi/tsconfig.json
Normal file
10
packages/napcat-dpapi/tsconfig.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "."
|
||||
},
|
||||
"include": [
|
||||
"./**/*.ts"
|
||||
]
|
||||
}
|
||||
@@ -78,6 +78,7 @@ export async function NCoreInitFramework (
|
||||
|
||||
// 启动WebUi
|
||||
WebUiDataRuntime.setWorkingEnv(NapCatCoreWorkingEnv.Framework);
|
||||
WebUiDataRuntime.setQQDataPath(loaderObject.core.dataPath);
|
||||
InitWebUi(logger, pathWrapper, logSubscription, statusHelperSubscription).then().catch(e => logger.logError(e));
|
||||
// 使用 NapCatAdapterManager 统一管理协议适配器
|
||||
const adapterManager = new NapCatAdapterManager(loaderObject.core, loaderObject.context, pathWrapper);
|
||||
|
||||
BIN
packages/napcat-native/dpapi/win32-arm64/@primno+dpapi.node
Normal file
BIN
packages/napcat-native/dpapi/win32-arm64/@primno+dpapi.node
Normal file
Binary file not shown.
BIN
packages/napcat-native/dpapi/win32-x64/@primno+dpapi.node
Normal file
BIN
packages/napcat-native/dpapi/win32-x64/@primno+dpapi.node
Normal file
Binary file not shown.
@@ -425,6 +425,7 @@ export async function NCoreInitShell () {
|
||||
}
|
||||
}
|
||||
const [dataPath, dataPathGlobal] = getDataPaths(wrapper);
|
||||
WebUiDataRuntime.setQQDataPath(dataPath);
|
||||
const systemPlatform = getPlatformType();
|
||||
|
||||
if (!basicInfoWrapper.QQVersionAppid || !basicInfoWrapper.QQVersionQua) throw new Error('QQVersionAppid or QQVersionQua is not defined');
|
||||
|
||||
@@ -45,6 +45,7 @@ const ShellBaseConfig = (source_map: boolean = false) =>
|
||||
'@/napcat-common': resolve(__dirname, '../napcat-common'),
|
||||
'@/napcat-onebot': resolve(__dirname, '../napcat-onebot'),
|
||||
'@/napcat-pty': resolve(__dirname, '../napcat-pty'),
|
||||
'@/napcat-dpapi': resolve(__dirname, '../napcat-dpapi'),
|
||||
'@/napcat-webui-backend': resolve(__dirname, '../napcat-webui-backend'),
|
||||
'@/napcat-image-size': resolve(__dirname, '../napcat-image-size'),
|
||||
'@/napcat-protocol': resolve(__dirname, '../napcat-protocol'),
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
"json5": "^2.2.3",
|
||||
"multer": "^2.0.1",
|
||||
"napcat-common": "workspace:*",
|
||||
"napcat-dpapi": "workspace:*",
|
||||
"napcat-pty": "workspace:*",
|
||||
"ws": "^8.18.3"
|
||||
},
|
||||
|
||||
@@ -4,6 +4,22 @@ import { WebUiDataRuntime } from '@/napcat-webui-backend/src/helper/Data';
|
||||
import { WebUiConfig } from '@/napcat-webui-backend/index';
|
||||
import { isEmpty } from '@/napcat-webui-backend/src/utils/check';
|
||||
import { sendError, sendSuccess } from '@/napcat-webui-backend/src/utils/response';
|
||||
import { Registry20Utils } from '@/napcat-webui-backend/src/utils/guid';
|
||||
|
||||
// 获取 Registry20 路径的辅助函数
|
||||
const getRegistryPath = () => {
|
||||
// 优先从 WebUiDataRuntime 获取早期设置的 dataPath
|
||||
let dataPath = WebUiDataRuntime.getQQDataPath();
|
||||
if (!dataPath) {
|
||||
// 回退: 从 OneBotContext 获取
|
||||
const oneBotContext = WebUiDataRuntime.getOneBotContext();
|
||||
dataPath = oneBotContext?.core?.dataPath;
|
||||
}
|
||||
if (!dataPath) {
|
||||
throw new Error('QQ data path not available yet');
|
||||
}
|
||||
return Registry20Utils.getRegistryPath(dataPath);
|
||||
};
|
||||
|
||||
// 获取QQ登录二维码
|
||||
export const QQGetQRcodeHandler: RequestHandler = async (_, res) => {
|
||||
@@ -147,3 +163,106 @@ export const QQPasswordLoginHandler: RequestHandler = async (req, res) => {
|
||||
}
|
||||
return sendSuccess(res, null);
|
||||
};
|
||||
|
||||
// 重置设备信息
|
||||
export const QQResetDeviceIDHandler: RequestHandler = async (_, res) => {
|
||||
try {
|
||||
const registryPath = getRegistryPath();
|
||||
// 自动备份
|
||||
try {
|
||||
await Registry20Utils.backup(registryPath);
|
||||
} catch (e) {
|
||||
// 忽略备份错误(例如文件不存在)
|
||||
}
|
||||
|
||||
await Registry20Utils.delete(registryPath);
|
||||
return sendSuccess(res, { message: 'Device ID reset successfully (Registry20 deleted)' });
|
||||
} catch (e) {
|
||||
return sendError(res, `Failed to reset Device ID: ${(e as Error).message}`);
|
||||
}
|
||||
};
|
||||
|
||||
// 获取设备 GUID
|
||||
export const QQGetDeviceGUIDHandler: RequestHandler = async (_, res) => {
|
||||
try {
|
||||
const registryPath = getRegistryPath();
|
||||
const guid = await Registry20Utils.readGuid(registryPath);
|
||||
return sendSuccess(res, { guid });
|
||||
} catch (e) {
|
||||
// 可能是文件不存在,或者非 Windows 平台,或者解密失败
|
||||
return sendError(res, `Failed to get GUID: ${(e as Error).message}`);
|
||||
}
|
||||
};
|
||||
|
||||
// 设置设备 GUID
|
||||
export const QQSetDeviceGUIDHandler: RequestHandler = async (req, res) => {
|
||||
const { guid } = req.body;
|
||||
if (!guid || typeof guid !== 'string' || guid.length !== 32) {
|
||||
return sendError(res, 'Invalid GUID format, must be 32 hex characters');
|
||||
}
|
||||
try {
|
||||
const registryPath = getRegistryPath();
|
||||
// 自动备份
|
||||
try {
|
||||
await Registry20Utils.backup(registryPath);
|
||||
} catch { }
|
||||
|
||||
await Registry20Utils.writeGuid(registryPath, guid);
|
||||
return sendSuccess(res, { message: 'GUID set successfully' });
|
||||
} catch (e) {
|
||||
return sendError(res, `Failed to set GUID: ${(e as Error).message}`);
|
||||
}
|
||||
};
|
||||
|
||||
// 获取备份列表
|
||||
export const QQGetGUIDBackupsHandler: RequestHandler = async (_, res) => {
|
||||
try {
|
||||
const registryPath = getRegistryPath();
|
||||
const backups = Registry20Utils.getBackups(registryPath);
|
||||
return sendSuccess(res, backups);
|
||||
} catch (e) {
|
||||
return sendError(res, `Failed to get backups: ${(e as Error).message}`);
|
||||
}
|
||||
};
|
||||
|
||||
// 恢复备份
|
||||
export const QQRestoreGUIDBackupHandler: RequestHandler = async (req, res) => {
|
||||
const { backupName } = req.body;
|
||||
if (!backupName) {
|
||||
return sendError(res, 'Backup name is required');
|
||||
}
|
||||
try {
|
||||
const registryPath = getRegistryPath();
|
||||
await Registry20Utils.restore(registryPath, backupName);
|
||||
return sendSuccess(res, { message: 'Restored successfully' });
|
||||
} catch (e) {
|
||||
return sendError(res, `Failed to restore: ${(e as Error).message}`);
|
||||
}
|
||||
};
|
||||
|
||||
// 创建备份
|
||||
export const QQCreateGUIDBackupHandler: RequestHandler = async (_, res) => {
|
||||
try {
|
||||
const registryPath = getRegistryPath();
|
||||
const backupPath = await Registry20Utils.backup(registryPath);
|
||||
return sendSuccess(res, { message: 'Backup created', path: backupPath });
|
||||
} catch (e) {
|
||||
return sendError(res, `Failed to backup: ${(e as Error).message}`);
|
||||
}
|
||||
};
|
||||
|
||||
// 重启NapCat
|
||||
export const QQRestartNapCatHandler: RequestHandler = async (_, res) => {
|
||||
try {
|
||||
const result = await WebUiDataRuntime.requestRestartProcess();
|
||||
if (result.result) {
|
||||
return sendSuccess(res, { message: result.message || 'Restart initiated' });
|
||||
} else {
|
||||
return sendError(res, result.message || 'Restart failed');
|
||||
}
|
||||
} catch (e) {
|
||||
return sendError(res, `Restart error: ${(e as Error).message}`);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ const LoginRuntime: LoginRuntimeType = {
|
||||
},
|
||||
QQLoginError: '',
|
||||
QQVersion: 'unknown',
|
||||
QQDataPath: '',
|
||||
OneBotContext: null,
|
||||
onQQLoginStatusChange: async (status: boolean) => {
|
||||
LoginRuntime.QQLoginStatus = status;
|
||||
@@ -167,6 +168,14 @@ export const WebUiDataRuntime = {
|
||||
return LoginRuntime.QQVersion;
|
||||
},
|
||||
|
||||
setQQDataPath (dataPath: string) {
|
||||
LoginRuntime.QQDataPath = dataPath;
|
||||
},
|
||||
|
||||
getQQDataPath (): string {
|
||||
return LoginRuntime.QQDataPath;
|
||||
},
|
||||
|
||||
setWebUiConfigQuickFunction (func: LoginRuntimeType['WebUiConfigQuickFunction']): void {
|
||||
LoginRuntime.WebUiConfigQuickFunction = func;
|
||||
},
|
||||
|
||||
@@ -11,6 +11,13 @@ import {
|
||||
setAutoLoginAccountHandler,
|
||||
QQRefreshQRcodeHandler,
|
||||
QQPasswordLoginHandler,
|
||||
QQResetDeviceIDHandler,
|
||||
QQRestartNapCatHandler,
|
||||
QQGetDeviceGUIDHandler,
|
||||
QQSetDeviceGUIDHandler,
|
||||
QQGetGUIDBackupsHandler,
|
||||
QQRestoreGUIDBackupHandler,
|
||||
QQCreateGUIDBackupHandler,
|
||||
} from '@/napcat-webui-backend/src/api/QQLogin';
|
||||
|
||||
const router: Router = Router();
|
||||
@@ -34,5 +41,19 @@ router.post('/SetQuickLoginQQ', setAutoLoginAccountHandler);
|
||||
router.post('/RefreshQRcode', QQRefreshQRcodeHandler);
|
||||
// router:密码登录
|
||||
router.post('/PasswordLogin', QQPasswordLoginHandler);
|
||||
// router:重置设备信息
|
||||
router.post('/ResetDeviceID', QQResetDeviceIDHandler);
|
||||
// router:重启NapCat
|
||||
router.post('/RestartNapCat', QQRestartNapCatHandler);
|
||||
// router:获取设备GUID
|
||||
router.post('/GetDeviceGUID', QQGetDeviceGUIDHandler);
|
||||
// router:设置设备GUID
|
||||
router.post('/SetDeviceGUID', QQSetDeviceGUIDHandler);
|
||||
// router:获取GUID备份列表
|
||||
router.post('/GetGUIDBackups', QQGetGUIDBackupsHandler);
|
||||
// router:恢复GUID备份
|
||||
router.post('/RestoreGUIDBackup', QQRestoreGUIDBackupHandler);
|
||||
// router:创建GUID备份
|
||||
router.post('/CreateGUIDBackup', QQCreateGUIDBackupHandler);
|
||||
|
||||
export { router as QQLoginRouter };
|
||||
|
||||
@@ -49,6 +49,7 @@ export interface LoginRuntimeType {
|
||||
QQLoginInfo: SelfInfo;
|
||||
QQLoginError: string;
|
||||
QQVersion: string;
|
||||
QQDataPath: string;
|
||||
onQQLoginStatusChange: (status: boolean) => Promise<void>;
|
||||
onWebUiTokenChange: (token: string) => Promise<void>;
|
||||
onRefreshQRCode: () => Promise<void>;
|
||||
|
||||
116
packages/napcat-webui-backend/src/utils/guid.ts
Normal file
116
packages/napcat-webui-backend/src/utils/guid.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
import { protectData, unprotectData } from 'napcat-dpapi';
|
||||
|
||||
const GUID_HEADER = Buffer.from([0x00, 0x00, 0x00, 0x14]);
|
||||
const XOR_KEY = 0x10;
|
||||
|
||||
/**
|
||||
* Unprotects data using Windows DPAPI via napcat-dpapi.
|
||||
*/
|
||||
function dpapiUnprotect (filePath: string): Buffer {
|
||||
const encrypted = fs.readFileSync(filePath);
|
||||
return Buffer.from(unprotectData(encrypted, null, 'CurrentUser'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Protects data using Windows DPAPI and writes to file.
|
||||
*/
|
||||
function dpapiProtectAndWrite (filePath: string, data: Buffer): void {
|
||||
const encrypted = protectData(data, null, 'CurrentUser');
|
||||
fs.writeFileSync(filePath, Buffer.from(encrypted));
|
||||
}
|
||||
|
||||
export class Registry20Utils {
|
||||
static getRegistryPath (dataPath: string): string {
|
||||
return path.join(dataPath, 'nt_qq', 'global', 'nt_data', 'msf', 'Registry20');
|
||||
}
|
||||
|
||||
static readGuid (registryPath: string): string {
|
||||
if (!fs.existsSync(registryPath)) {
|
||||
throw new Error('Registry20 file not found');
|
||||
}
|
||||
if (os.platform() !== 'win32') {
|
||||
throw new Error('Registry20 decryption is only supported on Windows');
|
||||
}
|
||||
|
||||
const decrypted = dpapiUnprotect(registryPath);
|
||||
|
||||
if (decrypted.length < 20) {
|
||||
throw new Error(`Decrypted data too short (got ${decrypted.length} bytes, need 20)`);
|
||||
}
|
||||
|
||||
// Decode payload: header(4) + obfuscated_guid(16)
|
||||
const payload = decrypted.subarray(4, 20);
|
||||
const guidBuf = Buffer.alloc(16);
|
||||
for (let i = 0; i < 16; i++) {
|
||||
const payloadByte = payload[i] ?? 0;
|
||||
guidBuf[i] = (~(payloadByte ^ XOR_KEY)) & 0xFF;
|
||||
}
|
||||
|
||||
return guidBuf.toString('hex');
|
||||
}
|
||||
|
||||
static writeGuid (registryPath: string, guidHex: string): void {
|
||||
if (guidHex.length !== 32) {
|
||||
throw new Error('Invalid GUID length, must be 32 hex chars');
|
||||
}
|
||||
if (os.platform() !== 'win32') {
|
||||
throw new Error('Registry20 encryption is only supported on Windows');
|
||||
}
|
||||
|
||||
const guidBytes = Buffer.from(guidHex, 'hex');
|
||||
const payload = Buffer.alloc(16);
|
||||
for (let i = 0; i < 16; i++) {
|
||||
const guidByte = guidBytes[i] ?? 0;
|
||||
payload[i] = XOR_KEY ^ (~guidByte & 0xFF);
|
||||
}
|
||||
|
||||
const data = Buffer.concat([GUID_HEADER, payload]);
|
||||
|
||||
// Create directory if not exists
|
||||
const dir = path.dirname(registryPath);
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
dpapiProtectAndWrite(registryPath, data);
|
||||
}
|
||||
|
||||
static getBackups (registryPath: string): string[] {
|
||||
const dir = path.dirname(registryPath);
|
||||
const baseName = path.basename(registryPath);
|
||||
if (!fs.existsSync(dir)) return [];
|
||||
|
||||
return fs.readdirSync(dir)
|
||||
.filter(f => f.startsWith(`${baseName}.bak.`))
|
||||
.sort()
|
||||
.reverse();
|
||||
}
|
||||
|
||||
static backup (registryPath: string): string {
|
||||
if (!fs.existsSync(registryPath)) {
|
||||
throw new Error('Registry20 does not exist');
|
||||
}
|
||||
const timestamp = new Date().toISOString().replace(/[-:T.]/g, '').slice(0, 14);
|
||||
const backupPath = `${registryPath}.bak.${timestamp}`;
|
||||
fs.copyFileSync(registryPath, backupPath);
|
||||
return backupPath;
|
||||
}
|
||||
|
||||
static restore (registryPath: string, backupFileName: string): void {
|
||||
const dir = path.dirname(registryPath);
|
||||
const backupPath = path.join(dir, backupFileName);
|
||||
if (!fs.existsSync(backupPath)) {
|
||||
throw new Error('Backup file not found');
|
||||
}
|
||||
fs.copyFileSync(backupPath, registryPath);
|
||||
}
|
||||
|
||||
static delete (registryPath: string): void {
|
||||
if (fs.existsSync(registryPath)) {
|
||||
fs.unlinkSync(registryPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -55,6 +55,7 @@ function getAuthorAvatar (homepage?: string, downloadUrl?: string): string | und
|
||||
export interface PluginStoreCardProps {
|
||||
data: PluginStoreItem;
|
||||
onInstall: () => void;
|
||||
onViewDetail?: () => void;
|
||||
installStatus?: InstallStatus;
|
||||
installedVersion?: string;
|
||||
}
|
||||
@@ -62,6 +63,7 @@ export interface PluginStoreCardProps {
|
||||
const PluginStoreCard: React.FC<PluginStoreCardProps> = ({
|
||||
data,
|
||||
onInstall,
|
||||
onViewDetail,
|
||||
installStatus = 'not-installed',
|
||||
installedVersion,
|
||||
}) => {
|
||||
@@ -91,7 +93,10 @@ const PluginStoreCard: React.FC<PluginStoreCardProps> = ({
|
||||
)}
|
||||
shadow='sm'
|
||||
>
|
||||
<CardBody className='p-4 flex flex-col gap-3'>
|
||||
<CardBody
|
||||
className={clsx('p-4 flex flex-col gap-3', onViewDetail && 'cursor-pointer')}
|
||||
onClick={onViewDetail}
|
||||
>
|
||||
{/* Header: Avatar + Name + Author */}
|
||||
<div className='flex items-start gap-3'>
|
||||
<Avatar
|
||||
@@ -232,7 +237,10 @@ const PluginStoreCard: React.FC<PluginStoreCardProps> = ({
|
||||
</div>
|
||||
</CardBody>
|
||||
|
||||
<CardFooter className='px-4 pb-4 pt-0'>
|
||||
<CardFooter
|
||||
className='px-4 pb-4 pt-0'
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{installStatus === 'installed'
|
||||
? (
|
||||
<Button
|
||||
|
||||
317
packages/napcat-webui-frontend/src/components/guid_manager.tsx
Normal file
317
packages/napcat-webui-frontend/src/components/guid_manager.tsx
Normal file
@@ -0,0 +1,317 @@
|
||||
import { Button } from '@heroui/button';
|
||||
import { Input } from '@heroui/input';
|
||||
import { Divider } from '@heroui/divider';
|
||||
import { Chip } from '@heroui/chip';
|
||||
import { Listbox, ListboxItem } from '@heroui/listbox';
|
||||
import { Spinner } from '@heroui/spinner';
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import toast from 'react-hot-toast';
|
||||
import { MdContentCopy, MdDelete, MdRefresh, MdSave, MdRestorePage, MdBackup } from 'react-icons/md';
|
||||
|
||||
import QQManager from '@/controllers/qq_manager';
|
||||
import useDialog from '@/hooks/use-dialog';
|
||||
|
||||
interface GUIDManagerProps {
|
||||
/** 是否显示重启按钮 */
|
||||
showRestart?: boolean;
|
||||
/** 紧凑模式(用于弹窗场景) */
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
const GUIDManager: React.FC<GUIDManagerProps> = ({ showRestart = true, compact = false }) => {
|
||||
const dialog = useDialog();
|
||||
const [currentGUID, setCurrentGUID] = useState<string>('');
|
||||
const [inputGUID, setInputGUID] = useState<string>('');
|
||||
const [backups, setBackups] = useState<string[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [restarting, setRestarting] = useState(false);
|
||||
|
||||
const isValidGUID = (guid: string) => /^[0-9a-fA-F]{32}$/.test(guid);
|
||||
|
||||
const fetchGUID = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await QQManager.getDeviceGUID();
|
||||
setCurrentGUID(data.guid);
|
||||
setInputGUID(data.guid);
|
||||
} catch (error) {
|
||||
const msg = (error as Error).message;
|
||||
setCurrentGUID('');
|
||||
setInputGUID('');
|
||||
if (!msg.includes('not found')) {
|
||||
toast.error(`获取 GUID 失败: ${msg}`);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchBackups = useCallback(async () => {
|
||||
try {
|
||||
const data = await QQManager.getGUIDBackups();
|
||||
setBackups(data);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchGUID();
|
||||
fetchBackups();
|
||||
}, [fetchGUID, fetchBackups]);
|
||||
|
||||
const handleCopy = () => {
|
||||
if (currentGUID) {
|
||||
navigator.clipboard.writeText(currentGUID);
|
||||
toast.success('已复制到剪贴板');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!isValidGUID(inputGUID)) {
|
||||
toast.error('GUID 格式无效,需要 32 位十六进制字符');
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
await QQManager.setDeviceGUID(inputGUID);
|
||||
setCurrentGUID(inputGUID);
|
||||
toast.success('GUID 已设置,重启后生效');
|
||||
await fetchBackups();
|
||||
} catch (error) {
|
||||
const msg = (error as Error).message;
|
||||
toast.error(`设置 GUID 失败: ${msg}`);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
dialog.confirm({
|
||||
title: '确认删除',
|
||||
content: '删除 Registry20 后,QQ 将在下次启动时生成新的设备标识。确定要删除吗?',
|
||||
confirmText: '删除',
|
||||
cancelText: '取消',
|
||||
onConfirm: async () => {
|
||||
try {
|
||||
await QQManager.resetDeviceID();
|
||||
setCurrentGUID('');
|
||||
setInputGUID('');
|
||||
toast.success('已删除,重启后生效');
|
||||
await fetchBackups();
|
||||
} catch (error) {
|
||||
const msg = (error as Error).message;
|
||||
toast.error(`删除失败: ${msg}`);
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleBackup = async () => {
|
||||
try {
|
||||
await QQManager.createGUIDBackup();
|
||||
toast.success('备份已创建');
|
||||
await fetchBackups();
|
||||
} catch (error) {
|
||||
const msg = (error as Error).message;
|
||||
toast.error(`备份失败: ${msg}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRestore = (backupName: string) => {
|
||||
dialog.confirm({
|
||||
title: '确认恢复',
|
||||
content: `确定要从备份 "${backupName}" 恢复吗?当前的 Registry20 将被覆盖。`,
|
||||
confirmText: '恢复',
|
||||
cancelText: '取消',
|
||||
onConfirm: async () => {
|
||||
try {
|
||||
await QQManager.restoreGUIDBackup(backupName);
|
||||
toast.success('已恢复,重启后生效');
|
||||
await fetchGUID();
|
||||
await fetchBackups();
|
||||
} catch (error) {
|
||||
const msg = (error as Error).message;
|
||||
toast.error(`恢复失败: ${msg}`);
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleRestart = () => {
|
||||
dialog.confirm({
|
||||
title: '确认重启',
|
||||
content: '确定要重启 NapCat 吗?这将导致当前连接断开。',
|
||||
confirmText: '重启',
|
||||
cancelText: '取消',
|
||||
onConfirm: async () => {
|
||||
setRestarting(true);
|
||||
try {
|
||||
await QQManager.restartNapCat();
|
||||
toast.success('重启指令已发送');
|
||||
} catch (error) {
|
||||
const msg = (error as Error).message;
|
||||
toast.error(`重启失败: ${msg}`);
|
||||
} finally {
|
||||
setRestarting(false);
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className='flex items-center justify-center py-8'>
|
||||
<Spinner label='加载中...' />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`flex flex-col gap-${compact ? '3' : '4'}`}>
|
||||
{/* 当前 GUID 显示 */}
|
||||
<div className='flex flex-col gap-2'>
|
||||
<div className='text-sm font-medium text-default-700'>当前设备 GUID</div>
|
||||
<div className='flex items-center gap-2'>
|
||||
{currentGUID ? (
|
||||
<Chip variant='flat' color='primary' className='font-mono text-xs max-w-full'>
|
||||
{currentGUID}
|
||||
</Chip>
|
||||
) : (
|
||||
<Chip variant='flat' color='warning' className='text-xs'>
|
||||
未设置 / 不存在
|
||||
</Chip>
|
||||
)}
|
||||
{currentGUID && (
|
||||
<Button
|
||||
isIconOnly
|
||||
size='sm'
|
||||
variant='light'
|
||||
onPress={handleCopy}
|
||||
aria-label='复制GUID'
|
||||
>
|
||||
<MdContentCopy size={16} />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
isIconOnly
|
||||
size='sm'
|
||||
variant='light'
|
||||
onPress={fetchGUID}
|
||||
aria-label='刷新'
|
||||
>
|
||||
<MdRefresh size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* 设置 GUID */}
|
||||
<div className='flex flex-col gap-2'>
|
||||
<div className='text-sm font-medium text-default-700'>设置 GUID</div>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Input
|
||||
size='sm'
|
||||
variant='bordered'
|
||||
placeholder='输入32位十六进制 GUID'
|
||||
value={inputGUID}
|
||||
onValueChange={setInputGUID}
|
||||
isInvalid={inputGUID.length > 0 && !isValidGUID(inputGUID)}
|
||||
errorMessage={inputGUID.length > 0 && !isValidGUID(inputGUID) ? '需要32位十六进制字符' : undefined}
|
||||
classNames={{
|
||||
input: 'font-mono text-sm',
|
||||
}}
|
||||
maxLength={32}
|
||||
/>
|
||||
</div>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Button
|
||||
size='sm'
|
||||
color='primary'
|
||||
variant='flat'
|
||||
isLoading={saving}
|
||||
isDisabled={!isValidGUID(inputGUID) || inputGUID === currentGUID}
|
||||
onPress={handleSave}
|
||||
startContent={<MdSave size={16} />}
|
||||
>
|
||||
保存 GUID
|
||||
</Button>
|
||||
<Button
|
||||
size='sm'
|
||||
color='danger'
|
||||
variant='flat'
|
||||
isDisabled={!currentGUID}
|
||||
onPress={handleDelete}
|
||||
startContent={<MdDelete size={16} />}
|
||||
>
|
||||
删除 GUID
|
||||
</Button>
|
||||
<Button
|
||||
size='sm'
|
||||
color='secondary'
|
||||
variant='flat'
|
||||
isDisabled={!currentGUID}
|
||||
onPress={handleBackup}
|
||||
startContent={<MdBackup size={16} />}
|
||||
>
|
||||
手动备份
|
||||
</Button>
|
||||
</div>
|
||||
<div className='text-xs text-default-400'>
|
||||
修改或删除 GUID 后需重启 NapCat 才能生效,操作前会自动备份
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 备份恢复 */}
|
||||
{backups.length > 0 && (
|
||||
<>
|
||||
<Divider />
|
||||
<div className='flex flex-col gap-2'>
|
||||
<div className='text-sm font-medium text-default-700'>
|
||||
备份列表
|
||||
<span className='text-xs text-default-400 ml-2'>(点击恢复)</span>
|
||||
</div>
|
||||
<div className='max-h-[160px] overflow-y-auto rounded-lg border border-default-200'>
|
||||
<Listbox
|
||||
aria-label='备份列表'
|
||||
selectionMode='none'
|
||||
onAction={(key) => handleRestore(key as string)}
|
||||
>
|
||||
{backups.map((name) => (
|
||||
<ListboxItem
|
||||
key={name}
|
||||
startContent={<MdRestorePage size={16} className='text-default-400' />}
|
||||
className='font-mono text-xs'
|
||||
>
|
||||
{name}
|
||||
</ListboxItem>
|
||||
))}
|
||||
</Listbox>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 重启 */}
|
||||
{showRestart && (
|
||||
<>
|
||||
<Divider />
|
||||
<Button
|
||||
size='sm'
|
||||
color='warning'
|
||||
variant='flat'
|
||||
isLoading={restarting}
|
||||
onPress={handleRestart}
|
||||
startContent={<MdRefresh size={16} />}
|
||||
>
|
||||
重启 NapCat
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default GUIDManager;
|
||||
@@ -1,43 +1,175 @@
|
||||
import Markdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
|
||||
const TailwindMarkdown: React.FC<{ content: string }> = ({ content }) => {
|
||||
const TailwindMarkdown: React.FC<{ content: string; }> = ({ content }) => {
|
||||
return (
|
||||
<Markdown
|
||||
className='prose prose-sm sm:prose lg:prose-lg xl:prose-xl'
|
||||
className='prose prose-sm sm:prose lg:prose-lg xl:prose-xl max-w-none'
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
h1: ({ node: _node, ...props }) => (
|
||||
<h1 className='text-2xl font-bold' {...props} />
|
||||
<h1
|
||||
className='text-3xl font-bold mt-6 mb-4 pb-2 border-b-2 border-primary/20 text-default-900 first:mt-0'
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
h2: ({ node: _node, ...props }) => (
|
||||
<h2 className='text-xl font-bold' {...props} />
|
||||
<h2
|
||||
className='text-2xl font-bold mt-6 mb-3 pb-2 border-b border-default-200 text-default-800'
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
h3: ({ node: _node, ...props }) => (
|
||||
<h3 className='text-lg font-bold' {...props} />
|
||||
<h3
|
||||
className='text-xl font-semibold mt-5 mb-2 text-default-800'
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
h4: ({ node: _node, ...props }) => (
|
||||
<h4
|
||||
className='text-lg font-semibold mt-4 mb-2 text-default-700'
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
h5: ({ node: _node, ...props }) => (
|
||||
<h5
|
||||
className='text-base font-semibold mt-3 mb-2 text-default-700'
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
h6: ({ node: _node, ...props }) => (
|
||||
<h6
|
||||
className='text-sm font-semibold mt-3 mb-2 text-default-600'
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
p: ({ node: _node, ...props }) => (
|
||||
<p
|
||||
className='my-3 leading-7 text-default-700 first:mt-0 last:mb-0'
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
p: ({ node: _node, ...props }) => <p className='m-0' {...props} />,
|
||||
a: ({ node: _node, ...props }) => (
|
||||
<a
|
||||
className='text-primary-500 inline-block hover:underline'
|
||||
className='text-primary font-medium hover:text-primary-600 underline decoration-primary/30 hover:decoration-primary transition-colors'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
ul: ({ node: _node, ...props }) => (
|
||||
<ul className='list-disc list-inside' {...props} />
|
||||
),
|
||||
ol: ({ node: _node, ...props }) => (
|
||||
<ol className='list-decimal list-inside' {...props} />
|
||||
),
|
||||
blockquote: ({ node: _node, ...props }) => (
|
||||
<blockquote
|
||||
className='border-l-4 border-default-300 pl-4 italic'
|
||||
<ul
|
||||
className='my-3 ml-6 space-y-2 list-disc marker:text-primary'
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
code: ({ node: _node, ...props }) => (
|
||||
<code className='bg-default-100 p-1 rounded text-xs' {...props} />
|
||||
ol: ({ node: _node, ...props }) => (
|
||||
<ol
|
||||
className='my-3 ml-6 space-y-2 list-decimal marker:text-primary marker:font-semibold'
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
li: ({ node: _node, ...props }) => (
|
||||
<li
|
||||
className='leading-7 text-default-700 pl-2'
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
blockquote: ({ node: _node, ...props }) => (
|
||||
<blockquote
|
||||
className='my-4 pl-4 pr-4 py-2 border-l-4 border-primary/50 bg-primary/5 rounded-r-lg italic text-default-600'
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
pre: ({ node: _node, ...props }) => (
|
||||
<pre
|
||||
className='my-4 p-4 bg-default-100 dark:bg-default-50 rounded-xl overflow-x-auto text-sm border border-default-200 shadow-sm'
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
code: ({ node: _node, inline, ...props }: any) => {
|
||||
if (inline) {
|
||||
return (
|
||||
<code
|
||||
className='px-1.5 py-0.5 mx-0.5 bg-primary/10 text-primary-700 dark:text-primary-600 rounded text-sm font-mono border border-primary/20'
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<code
|
||||
className='text-sm font-mono text-default-800'
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
img: ({ node: _node, ...props }) => (
|
||||
<img
|
||||
className='max-w-full h-auto rounded-lg my-4 shadow-md hover:shadow-xl transition-shadow border border-default-200'
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
hr: ({ node: _node, ...props }) => (
|
||||
<hr
|
||||
className='my-8 border-0 h-px bg-gradient-to-r from-transparent via-default-300 to-transparent'
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
table: ({ node: _node, ...props }) => (
|
||||
<div className='my-4 overflow-x-auto rounded-lg border border-default-200 shadow-sm'>
|
||||
<table
|
||||
className='min-w-full divide-y divide-default-200'
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
thead: ({ node: _node, ...props }) => (
|
||||
<thead
|
||||
className='bg-default-100'
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
tbody: ({ node: _node, ...props }) => (
|
||||
<tbody
|
||||
className='divide-y divide-default-200 bg-white dark:bg-default-50'
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
tr: ({ node: _node, ...props }) => (
|
||||
<tr
|
||||
className='hover:bg-default-50 transition-colors'
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
th: ({ node: _node, ...props }) => (
|
||||
<th
|
||||
className='px-4 py-3 text-left text-xs font-semibold text-default-700 uppercase tracking-wider'
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
td: ({ node: _node, ...props }) => (
|
||||
<td
|
||||
className='px-4 py-3 text-sm text-default-700'
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
strong: ({ node: _node, ...props }) => (
|
||||
<strong
|
||||
className='font-bold text-default-900'
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
em: ({ node: _node, ...props }) => (
|
||||
<em
|
||||
className='italic text-default-700'
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
del: ({ node: _node, ...props }) => (
|
||||
<del
|
||||
className='line-through text-default-500'
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -101,4 +101,36 @@ export default class QQManager {
|
||||
passwordMd5,
|
||||
});
|
||||
}
|
||||
|
||||
public static async resetDeviceID () {
|
||||
await serverRequest.post<ServerResponse<null>>('/QQLogin/ResetDeviceID');
|
||||
}
|
||||
|
||||
public static async restartNapCat () {
|
||||
await serverRequest.post<ServerResponse<null>>('/QQLogin/RestartNapCat');
|
||||
}
|
||||
|
||||
public static async getDeviceGUID () {
|
||||
const data = await serverRequest.post<ServerResponse<{ guid: string; }>>('/QQLogin/GetDeviceGUID');
|
||||
return data.data.data;
|
||||
}
|
||||
|
||||
public static async setDeviceGUID (guid: string) {
|
||||
await serverRequest.post<ServerResponse<null>>('/QQLogin/SetDeviceGUID', { guid });
|
||||
}
|
||||
|
||||
public static async getGUIDBackups () {
|
||||
const data = await serverRequest.post<ServerResponse<string[]>>('/QQLogin/GetGUIDBackups');
|
||||
return data.data.data;
|
||||
}
|
||||
|
||||
public static async restoreGUIDBackup (backupName: string) {
|
||||
await serverRequest.post<ServerResponse<null>>('/QQLogin/RestoreGUIDBackup', { backupName });
|
||||
}
|
||||
|
||||
public static async createGUIDBackup () {
|
||||
const data = await serverRequest.post<ServerResponse<{ path: string; }>>('/QQLogin/CreateGUIDBackup');
|
||||
return data.data.data;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { Input } from '@heroui/input';
|
||||
import { Button } from '@heroui/button';
|
||||
import { Divider } from '@heroui/divider';
|
||||
import { useRequest } from 'ahooks';
|
||||
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 GUIDManager from '@/components/guid_manager';
|
||||
import PageLoading from '@/components/page_loading';
|
||||
|
||||
import QQManager from '@/controllers/qq_manager';
|
||||
@@ -131,6 +133,14 @@ const LoginConfigCard = () => {
|
||||
重启进程将关闭当前 Worker 进程,等待 3 秒后启动新进程
|
||||
</div>
|
||||
</div>
|
||||
<Divider className='mt-6' />
|
||||
<div className='flex-shrink-0 w-full mt-4'>
|
||||
<div className='mb-3 text-sm text-default-600'>设备 GUID 管理</div>
|
||||
<div className='text-xs text-default-400 mb-3'>
|
||||
GUID 是设备登录唯一识别码,存储在 Registry20 文件中。修改后需重启生效。
|
||||
</div>
|
||||
<GUIDManager showRestart={false} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,390 @@
|
||||
import { Modal, ModalContent, ModalHeader, ModalBody, ModalFooter } from '@heroui/modal';
|
||||
import { Button } from '@heroui/button';
|
||||
import { Chip } from '@heroui/chip';
|
||||
import { Avatar } from '@heroui/avatar';
|
||||
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 { 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';
|
||||
|
||||
interface PluginDetailModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
plugin: PluginStoreItem | null;
|
||||
installStatus?: InstallStatus;
|
||||
installedVersion?: string;
|
||||
onInstall?: () => void;
|
||||
}
|
||||
|
||||
/** 提取作者头像 URL */
|
||||
function getAuthorAvatar (homepage?: string, downloadUrl?: string): string | undefined {
|
||||
// 1. 尝试从 downloadUrl 提取 GitHub 用户名
|
||||
if (downloadUrl) {
|
||||
try {
|
||||
const url = new URL(downloadUrl);
|
||||
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 {
|
||||
// 忽略解析错误
|
||||
}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
return `https://api.iowen.cn/favicon/${url.hostname}.png`;
|
||||
}
|
||||
} catch {
|
||||
// 忽略解析错误
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** 提取 GitHub 仓库信息 */
|
||||
function extractGitHubRepo (url?: string): { owner: string; repo: string; } | null {
|
||||
if (!url) return null;
|
||||
try {
|
||||
const urlObj = new URL(url);
|
||||
if (urlObj.hostname === 'github.com' || urlObj.hostname === 'www.github.com') {
|
||||
const parts = urlObj.pathname.split('/').filter(Boolean);
|
||||
if (parts.length >= 2) {
|
||||
return { owner: parts[0], repo: parts[1] };
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// 忽略解析错误
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 从 GitHub API 获取 README */
|
||||
async function fetchGitHubReadme (owner: string, repo: string): Promise<string> {
|
||||
const response = await fetch(`https://api.github.com/repos/${owner}/${repo}/readme`, {
|
||||
headers: {
|
||||
Accept: 'application/vnd.github.v3.raw',
|
||||
},
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch README');
|
||||
}
|
||||
return response.text();
|
||||
}
|
||||
|
||||
/** 清理 README 中的 HTML 标签,保留 Markdown */
|
||||
function cleanReadmeHtml (content: string): string {
|
||||
// 移除 HTML 注释
|
||||
let cleaned = content.replace(/<!--[\s\S]*?-->/g, '');
|
||||
|
||||
// 保留常见的 Markdown 友好的 HTML 标签(img, br),其他的移除标签但保留内容
|
||||
// 移除 style 和 script 标签及其内容
|
||||
cleaned = cleaned.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '');
|
||||
cleaned = cleaned.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, '');
|
||||
|
||||
// 将其他 HTML 标签替换为空格或换行(保留内容)
|
||||
// 保留 img 标签(转为 markdown)- 尝试提取 alt 和 src 属性
|
||||
cleaned = cleaned.replace(/<img[^>]*\\bsrc=["']([^"']+)["'][^>]*\\balt=["']([^"']+)["'][^>]*>/gi, '');
|
||||
cleaned = cleaned.replace(/<img[^>]*\\balt=["']([^"']+)["'][^>]*\\bsrc=["']([^"']+)["'][^>]*>/gi, '');
|
||||
cleaned = cleaned.replace(/<img[^>]+src=["']([^"']+)["'][^>]*>/gi, '');
|
||||
|
||||
// 移除其他 HTML 标签,但保留内容
|
||||
cleaned = cleaned.replace(/<\/?[^>]+(>|$)/g, '');
|
||||
|
||||
// 清理多余的空行(超过2个连续换行)
|
||||
cleaned = cleaned.replace(/\n{3,}/g, '\n\n');
|
||||
|
||||
return cleaned.trim();
|
||||
}
|
||||
|
||||
export default function PluginDetailModal ({
|
||||
isOpen,
|
||||
onClose,
|
||||
plugin,
|
||||
installStatus = 'not-installed',
|
||||
installedVersion,
|
||||
onInstall,
|
||||
}: PluginDetailModalProps) {
|
||||
const [readme, setReadme] = useState<string>('');
|
||||
const [readmeLoading, setReadmeLoading] = useState(false);
|
||||
const [readmeError, setReadmeError] = useState(false);
|
||||
|
||||
// 获取 GitHub 仓库信息(需要在 hooks 之前计算)
|
||||
const githubRepo = plugin ? extractGitHubRepo(plugin.homepage) : null;
|
||||
|
||||
// 当模态框打开且有 GitHub 链接时,获取 README
|
||||
useEffect(() => {
|
||||
if (!isOpen || !githubRepo) {
|
||||
setReadme('');
|
||||
setReadmeError(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const loadReadme = async () => {
|
||||
setReadmeLoading(true);
|
||||
setReadmeError(false);
|
||||
try {
|
||||
const content = await fetchGitHubReadme(githubRepo.owner, githubRepo.repo);
|
||||
// 清理 HTML 标签后再设置
|
||||
setReadme(cleanReadmeHtml(content));
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch README:', error);
|
||||
setReadmeError(true);
|
||||
} finally {
|
||||
setReadmeLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadReadme();
|
||||
}, [isOpen, githubRepo?.owner, githubRepo?.repo]);
|
||||
|
||||
if (!plugin) return null;
|
||||
|
||||
const { name, version, author, description, tags, homepage, downloadUrl, minVersion } = plugin;
|
||||
const avatarUrl = getAuthorAvatar(homepage, downloadUrl) || `https://avatar.vercel.sh/${encodeURIComponent(name)}`;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
size='4xl'
|
||||
scrollBehavior='inside'
|
||||
classNames={{
|
||||
backdrop: 'z-[200]',
|
||||
wrapper: 'z-[200]',
|
||||
}}
|
||||
>
|
||||
<ModalContent>
|
||||
{(onModalClose) => (
|
||||
<>
|
||||
<ModalHeader className='flex flex-col gap-3 pb-2'>
|
||||
{/* 插件头部信息 */}
|
||||
<div className='flex items-start gap-4'>
|
||||
<Avatar
|
||||
src={avatarUrl}
|
||||
name={author || '?'}
|
||||
size='lg'
|
||||
isBordered
|
||||
color='primary'
|
||||
radius='lg'
|
||||
className='flex-shrink-0'
|
||||
/>
|
||||
<div className='flex-1 min-w-0'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<h2 className='text-2xl font-bold text-default-900'>{name}</h2>
|
||||
{homepage && (
|
||||
<Tooltip content='访问项目主页'>
|
||||
<Button
|
||||
isIconOnly
|
||||
size='sm'
|
||||
variant='flat'
|
||||
color='primary'
|
||||
as='a'
|
||||
href={homepage}
|
||||
target='_blank'
|
||||
rel='noreferrer'
|
||||
>
|
||||
<IoMdOpen size={18} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
<p className='text-sm text-default-500 mt-1'>
|
||||
by <span className='font-medium'>{author || '未知作者'}</span>
|
||||
</p>
|
||||
|
||||
{/* 标签和版本信息 */}
|
||||
<div className='flex items-center gap-2 mt-2 flex-wrap'>
|
||||
<Chip size='sm' color='primary' variant='flat'>
|
||||
v{version}
|
||||
</Chip>
|
||||
{tags?.map((tag) => (
|
||||
<Chip
|
||||
key={tag}
|
||||
size='sm'
|
||||
variant='flat'
|
||||
className='bg-default-100 text-default-600'
|
||||
>
|
||||
{tag}
|
||||
</Chip>
|
||||
))}
|
||||
{installStatus === 'update-available' && installedVersion && (
|
||||
<Chip
|
||||
size='sm'
|
||||
color='warning'
|
||||
variant='shadow'
|
||||
className='animate-pulse'
|
||||
>
|
||||
可更新
|
||||
</Chip>
|
||||
)}
|
||||
{installStatus === 'installed' && (
|
||||
<Chip
|
||||
size='sm'
|
||||
color='success'
|
||||
variant='flat'
|
||||
startContent={<IoMdCheckmarkCircle size={14} />}
|
||||
>
|
||||
已安装
|
||||
</Chip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ModalHeader>
|
||||
|
||||
<ModalBody className='gap-4'>
|
||||
{/* 插件描述 */}
|
||||
<div>
|
||||
<h3 className='text-sm font-semibold text-default-700 mb-2'>插件描述</h3>
|
||||
<p className='text-sm text-default-600 leading-relaxed'>
|
||||
{description || '暂无描述'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 插件信息 */}
|
||||
<div>
|
||||
<h3 className='text-sm font-semibold text-default-700 mb-3'>插件信息</h3>
|
||||
<div className='grid grid-cols-1 sm:grid-cols-2 gap-3 text-sm'>
|
||||
<div className='flex justify-between items-center'>
|
||||
<span className='text-default-500'>最新版本:</span>
|
||||
<span className='font-medium text-default-900'>v{version}</span>
|
||||
</div>
|
||||
{installedVersion && (
|
||||
<div className='flex justify-between items-center'>
|
||||
<span className='text-default-500'>已安装版本:</span>
|
||||
<span className='font-medium text-default-900'>v{installedVersion}</span>
|
||||
</div>
|
||||
)}
|
||||
{minVersion && (
|
||||
<div className='flex justify-between items-center'>
|
||||
<span className='text-default-500'>最低要求版本:</span>
|
||||
<span className='font-medium text-default-900'>v{minVersion}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className='flex justify-between items-center'>
|
||||
<span className='text-default-500'>插件 ID:</span>
|
||||
<span className='font-mono text-xs text-default-900'>{plugin.id}</span>
|
||||
</div>
|
||||
{downloadUrl && (
|
||||
<div className='flex justify-between items-center'>
|
||||
<span className='text-default-500'>下载地址:</span>
|
||||
<Button
|
||||
size='sm'
|
||||
variant='flat'
|
||||
color='primary'
|
||||
as='a'
|
||||
href={downloadUrl}
|
||||
target='_blank'
|
||||
rel='noreferrer'
|
||||
startContent={<IoMdDownload size={14} />}
|
||||
>
|
||||
下载插件
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* GitHub README 显示 */}
|
||||
{githubRepo && (
|
||||
<>
|
||||
<div className='mt-2'>
|
||||
<h3 className='text-sm font-semibold text-default-700 mb-3'>详情</h3>
|
||||
{readmeLoading && (
|
||||
<div className='flex justify-center items-center py-12'>
|
||||
<Spinner size='lg' />
|
||||
</div>
|
||||
)}
|
||||
{readmeError && (
|
||||
<div className='text-center py-8'>
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
{!readmeLoading && !readmeError && readme && (
|
||||
<div className='rounded-lg border border-default-200 p-4 bg-default-50'>
|
||||
<TailwindMarkdown content={readme} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</ModalBody>
|
||||
|
||||
<ModalFooter>
|
||||
<Button variant='light' onPress={onModalClose}>
|
||||
关闭
|
||||
</Button>
|
||||
{installStatus === 'installed'
|
||||
? (
|
||||
<Button
|
||||
color='success'
|
||||
variant='flat'
|
||||
startContent={<IoMdCheckmarkCircle size={18} />}
|
||||
isDisabled
|
||||
>
|
||||
已安装
|
||||
</Button>
|
||||
)
|
||||
: installStatus === 'update-available'
|
||||
? (
|
||||
<Button
|
||||
color='warning'
|
||||
variant='shadow'
|
||||
startContent={<MdUpdate size={18} />}
|
||||
onPress={() => {
|
||||
onInstall?.();
|
||||
onModalClose();
|
||||
}}
|
||||
>
|
||||
更新到 v{version}
|
||||
</Button>
|
||||
)
|
||||
: (
|
||||
<Button
|
||||
color='primary'
|
||||
variant='shadow'
|
||||
startContent={<IoMdDownload size={18} />}
|
||||
onPress={() => {
|
||||
onInstall?.();
|
||||
onModalClose();
|
||||
}}
|
||||
>
|
||||
立即安装
|
||||
</Button>
|
||||
)}
|
||||
</ModalFooter>
|
||||
</>
|
||||
)}
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -9,10 +9,12 @@ import { IoMdRefresh, IoMdSearch, IoMdSettings } from 'react-icons/io';
|
||||
import clsx from 'clsx';
|
||||
import { EventSourcePolyfill } from 'event-source-polyfill';
|
||||
import { useLocalStorage } from '@uidotdev/usehooks';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
|
||||
import PluginStoreCard, { InstallStatus } from '@/components/display_card/plugin_store_card';
|
||||
import PluginManager, { PluginItem } from '@/controllers/plugin_manager';
|
||||
import MirrorSelectorModal from '@/components/mirror_selector_modal';
|
||||
import PluginDetailModal from '@/pages/dashboard/plugin_detail_modal';
|
||||
import { PluginStoreItem } from '@/types/plugin-store';
|
||||
import useDialog from '@/hooks/use-dialog';
|
||||
import key from '@/const/key';
|
||||
@@ -42,6 +44,7 @@ export default function PluginStorePage () {
|
||||
const [pluginManagerNotFound, setPluginManagerNotFound] = useState(false);
|
||||
const dialog = useDialog();
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
// 快捷键支持: Ctrl+F 聚焦搜索框
|
||||
useEffect(() => {
|
||||
@@ -79,6 +82,10 @@ export default function PluginStorePage () {
|
||||
const [pendingInstallPlugin, setPendingInstallPlugin] = useState<PluginStoreItem | null>(null);
|
||||
const [selectedDownloadMirror, setSelectedDownloadMirror] = useState<string | undefined>(undefined);
|
||||
|
||||
// 插件详情弹窗状态
|
||||
const [detailModalOpen, setDetailModalOpen] = useState(false);
|
||||
const [selectedPlugin, setSelectedPlugin] = useState<PluginStoreItem | null>(null);
|
||||
|
||||
const loadPlugins = async (forceRefresh: boolean = false) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
@@ -100,6 +107,22 @@ export default function PluginStorePage () {
|
||||
loadPlugins();
|
||||
}, [currentStoreSource]);
|
||||
|
||||
// 处理 URL 参数中的插件 ID,自动打开详情
|
||||
useEffect(() => {
|
||||
const pluginId = searchParams.get('pluginId');
|
||||
if (pluginId && plugins.length > 0 && !detailModalOpen) {
|
||||
// 查找对应的插件
|
||||
const targetPlugin = plugins.find(p => p.id === pluginId);
|
||||
if (targetPlugin) {
|
||||
setSelectedPlugin(targetPlugin);
|
||||
setDetailModalOpen(true);
|
||||
// 移除 URL 参数(可选)
|
||||
// searchParams.delete('pluginId');
|
||||
// setSearchParams(searchParams);
|
||||
}
|
||||
}
|
||||
}, [plugins, searchParams, detailModalOpen]);
|
||||
|
||||
// 按标签分类和搜索
|
||||
const categorizedPlugins = useMemo(() => {
|
||||
let filtered = plugins;
|
||||
@@ -383,6 +406,10 @@ export default function PluginStorePage () {
|
||||
installStatus={installInfo.status}
|
||||
installedVersion={installInfo.installedVersion}
|
||||
onInstall={() => { handleInstall(plugin); }}
|
||||
onViewDetail={() => {
|
||||
setSelectedPlugin(plugin);
|
||||
setDetailModalOpen(true);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
@@ -421,6 +448,28 @@ export default function PluginStorePage () {
|
||||
type='file'
|
||||
/>
|
||||
|
||||
{/* 插件详情弹窗 */}
|
||||
<PluginDetailModal
|
||||
isOpen={detailModalOpen}
|
||||
onClose={() => {
|
||||
setDetailModalOpen(false);
|
||||
setSelectedPlugin(null);
|
||||
// 清除 URL 参数
|
||||
if (searchParams.has('pluginId')) {
|
||||
searchParams.delete('pluginId');
|
||||
setSearchParams(searchParams);
|
||||
}
|
||||
}}
|
||||
plugin={selectedPlugin}
|
||||
installStatus={selectedPlugin ? getPluginInstallInfo(selectedPlugin).status : 'not-installed'}
|
||||
installedVersion={selectedPlugin ? getPluginInstallInfo(selectedPlugin).installedVersion : undefined}
|
||||
onInstall={() => {
|
||||
if (selectedPlugin) {
|
||||
handleInstall(selectedPlugin);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 插件下载进度条全局居中样式 */}
|
||||
{installProgress.show && (
|
||||
<div className='fixed inset-0 flex items-center justify-center z-[9999] animate-in fade-in duration-300'>
|
||||
|
||||
@@ -6,8 +6,11 @@ import { useEffect, useRef, useState } from 'react';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import CryptoJS from 'crypto-js';
|
||||
import { MdSettings } from 'react-icons/md';
|
||||
|
||||
import logo from '@/assets/images/logo.png';
|
||||
import GUIDManager from '@/components/guid_manager';
|
||||
import Modal from '@/components/modal';
|
||||
|
||||
import HoverEffectCard from '@/components/effect_card';
|
||||
import { title } from '@/components/primitives';
|
||||
@@ -174,6 +177,8 @@ export default function QQLoginPage () {
|
||||
}
|
||||
};
|
||||
|
||||
const [showGUIDManager, setShowGUIDManager] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => {
|
||||
onUpdateQrCode();
|
||||
@@ -210,7 +215,12 @@ export default function QQLoginPage () {
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<ThemeSwitch className='absolute right-4 top-4' />
|
||||
<div className='absolute right-4 top-4 flex items-center gap-2'>
|
||||
<Button isIconOnly variant="light" aria-label="Settings" onPress={() => setShowGUIDManager(true)}>
|
||||
<MdSettings size={22} />
|
||||
</Button>
|
||||
<ThemeSwitch />
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardBody className='flex gap-5 p-10 pt-0'>
|
||||
@@ -266,6 +276,15 @@ export default function QQLoginPage () {
|
||||
</HoverEffectCard>
|
||||
</motion.div>
|
||||
</PureLayout>
|
||||
{showGUIDManager && (
|
||||
<Modal
|
||||
title='设备 GUID 管理'
|
||||
content={<GUIDManager compact showRestart />}
|
||||
size='lg'
|
||||
hideFooter
|
||||
onClose={() => setShowGUIDManager(false)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
9
pnpm-lock.yaml
generated
9
pnpm-lock.yaml
generated
@@ -128,6 +128,12 @@ importers:
|
||||
specifier: ^22.0.1
|
||||
version: 22.19.1
|
||||
|
||||
packages/napcat-dpapi:
|
||||
devDependencies:
|
||||
'@types/node':
|
||||
specifier: ^22.0.1
|
||||
version: 22.19.1
|
||||
|
||||
packages/napcat-framework:
|
||||
dependencies:
|
||||
napcat-adapter:
|
||||
@@ -441,6 +447,9 @@ importers:
|
||||
napcat-common:
|
||||
specifier: workspace:*
|
||||
version: link:../napcat-common
|
||||
napcat-dpapi:
|
||||
specifier: workspace:*
|
||||
version: link:../napcat-dpapi
|
||||
napcat-pty:
|
||||
specifier: workspace:*
|
||||
version: link:../napcat-pty
|
||||
|
||||
Reference in New Issue
Block a user