diff --git a/packages/napcat-dpapi/LICENSE b/packages/napcat-dpapi/LICENSE new file mode 100644 index 00000000..cc72d92f --- /dev/null +++ b/packages/napcat-dpapi/LICENSE @@ -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. diff --git a/packages/napcat-dpapi/README.md b/packages/napcat-dpapi/README.md new file mode 100644 index 00000000..d7f8c6af --- /dev/null +++ b/packages/napcat-dpapi/README.md @@ -0,0 +1,4 @@ +# @primno/dpapi + +## 协议与说明 +全部遵守原仓库要求 \ No newline at end of file diff --git a/packages/napcat-dpapi/index.ts b/packages/napcat-dpapi/index.ts new file mode 100644 index 00000000..40dc29f4 --- /dev/null +++ b/packages/napcat-dpapi/index.ts @@ -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; diff --git a/packages/napcat-dpapi/package.json b/packages/napcat-dpapi/package.json new file mode 100644 index 00000000..e41cebd9 --- /dev/null +++ b/packages/napcat-dpapi/package.json @@ -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" + } +} \ No newline at end of file diff --git a/packages/napcat-dpapi/tsconfig.json b/packages/napcat-dpapi/tsconfig.json new file mode 100644 index 00000000..b0b9beb2 --- /dev/null +++ b/packages/napcat-dpapi/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "." + }, + "include": [ + "./**/*.ts" + ] +} \ No newline at end of file diff --git a/packages/napcat-framework/napcat.ts b/packages/napcat-framework/napcat.ts index 882c64bc..5a1c1d3d 100644 --- a/packages/napcat-framework/napcat.ts +++ b/packages/napcat-framework/napcat.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); diff --git a/packages/napcat-native/dpapi/win32-arm64/@primno+dpapi.node b/packages/napcat-native/dpapi/win32-arm64/@primno+dpapi.node new file mode 100644 index 00000000..8edda4f3 Binary files /dev/null and b/packages/napcat-native/dpapi/win32-arm64/@primno+dpapi.node differ diff --git a/packages/napcat-native/dpapi/win32-x64/@primno+dpapi.node b/packages/napcat-native/dpapi/win32-x64/@primno+dpapi.node new file mode 100644 index 00000000..b9c3c50c Binary files /dev/null and b/packages/napcat-native/dpapi/win32-x64/@primno+dpapi.node differ diff --git a/packages/napcat-shell/base.ts b/packages/napcat-shell/base.ts index 81730793..357da189 100644 --- a/packages/napcat-shell/base.ts +++ b/packages/napcat-shell/base.ts @@ -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'); diff --git a/packages/napcat-shell/vite.config.ts b/packages/napcat-shell/vite.config.ts index 6b7daaf7..d0e801f8 100644 --- a/packages/napcat-shell/vite.config.ts +++ b/packages/napcat-shell/vite.config.ts @@ -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'), diff --git a/packages/napcat-webui-backend/package.json b/packages/napcat-webui-backend/package.json index b3b140b7..ce36f97b 100644 --- a/packages/napcat-webui-backend/package.json +++ b/packages/napcat-webui-backend/package.json @@ -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" }, diff --git a/packages/napcat-webui-backend/src/api/QQLogin.ts b/packages/napcat-webui-backend/src/api/QQLogin.ts index 2ce7df04..38b2ad12 100644 --- a/packages/napcat-webui-backend/src/api/QQLogin.ts +++ b/packages/napcat-webui-backend/src/api/QQLogin.ts @@ -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}`); + } +}; + + diff --git a/packages/napcat-webui-backend/src/helper/Data.ts b/packages/napcat-webui-backend/src/helper/Data.ts index cc8092a7..f272b632 100644 --- a/packages/napcat-webui-backend/src/helper/Data.ts +++ b/packages/napcat-webui-backend/src/helper/Data.ts @@ -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; }, diff --git a/packages/napcat-webui-backend/src/router/QQLogin.ts b/packages/napcat-webui-backend/src/router/QQLogin.ts index 81b0fe91..48e7372f 100644 --- a/packages/napcat-webui-backend/src/router/QQLogin.ts +++ b/packages/napcat-webui-backend/src/router/QQLogin.ts @@ -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 }; diff --git a/packages/napcat-webui-backend/src/types/index.ts b/packages/napcat-webui-backend/src/types/index.ts index f19259be..d0464871 100644 --- a/packages/napcat-webui-backend/src/types/index.ts +++ b/packages/napcat-webui-backend/src/types/index.ts @@ -49,6 +49,7 @@ export interface LoginRuntimeType { QQLoginInfo: SelfInfo; QQLoginError: string; QQVersion: string; + QQDataPath: string; onQQLoginStatusChange: (status: boolean) => Promise; onWebUiTokenChange: (token: string) => Promise; onRefreshQRCode: () => Promise; diff --git a/packages/napcat-webui-backend/src/utils/guid.ts b/packages/napcat-webui-backend/src/utils/guid.ts new file mode 100644 index 00000000..1bfd5d7f --- /dev/null +++ b/packages/napcat-webui-backend/src/utils/guid.ts @@ -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); + } + } +} diff --git a/packages/napcat-webui-frontend/src/components/guid_manager.tsx b/packages/napcat-webui-frontend/src/components/guid_manager.tsx new file mode 100644 index 00000000..c92e7cf5 --- /dev/null +++ b/packages/napcat-webui-frontend/src/components/guid_manager.tsx @@ -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 = ({ showRestart = true, compact = false }) => { + const dialog = useDialog(); + const [currentGUID, setCurrentGUID] = useState(''); + const [inputGUID, setInputGUID] = useState(''); + const [backups, setBackups] = useState([]); + 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 ( +
+ +
+ ); + } + + return ( +
+ {/* 当前 GUID 显示 */} +
+
当前设备 GUID
+
+ {currentGUID ? ( + + {currentGUID} + + ) : ( + + 未设置 / 不存在 + + )} + {currentGUID && ( + + )} + +
+
+ + + + {/* 设置 GUID */} +
+
设置 GUID
+
+ 0 && !isValidGUID(inputGUID)} + errorMessage={inputGUID.length > 0 && !isValidGUID(inputGUID) ? '需要32位十六进制字符' : undefined} + classNames={{ + input: 'font-mono text-sm', + }} + maxLength={32} + /> +
+
+ + + +
+
+ 修改或删除 GUID 后需重启 NapCat 才能生效,操作前会自动备份 +
+
+ + {/* 备份恢复 */} + {backups.length > 0 && ( + <> + +
+
+ 备份列表 + (点击恢复) +
+
+ handleRestore(key as string)} + > + {backups.map((name) => ( + } + className='font-mono text-xs' + > + {name} + + ))} + +
+
+ + )} + + {/* 重启 */} + {showRestart && ( + <> + + + + )} +
+ ); +}; + +export default GUIDManager; diff --git a/packages/napcat-webui-frontend/src/controllers/qq_manager.ts b/packages/napcat-webui-frontend/src/controllers/qq_manager.ts index 05dccb44..32ed5d9d 100644 --- a/packages/napcat-webui-frontend/src/controllers/qq_manager.ts +++ b/packages/napcat-webui-frontend/src/controllers/qq_manager.ts @@ -101,4 +101,36 @@ export default class QQManager { passwordMd5, }); } + + public static async resetDeviceID () { + await serverRequest.post>('/QQLogin/ResetDeviceID'); + } + + public static async restartNapCat () { + await serverRequest.post>('/QQLogin/RestartNapCat'); + } + + public static async getDeviceGUID () { + const data = await serverRequest.post>('/QQLogin/GetDeviceGUID'); + return data.data.data; + } + + public static async setDeviceGUID (guid: string) { + await serverRequest.post>('/QQLogin/SetDeviceGUID', { guid }); + } + + public static async getGUIDBackups () { + const data = await serverRequest.post>('/QQLogin/GetGUIDBackups'); + return data.data.data; + } + + public static async restoreGUIDBackup (backupName: string) { + await serverRequest.post>('/QQLogin/RestoreGUIDBackup', { backupName }); + } + + public static async createGUIDBackup () { + const data = await serverRequest.post>('/QQLogin/CreateGUIDBackup'); + return data.data.data; + } } + diff --git a/packages/napcat-webui-frontend/src/pages/dashboard/config/login.tsx b/packages/napcat-webui-frontend/src/pages/dashboard/config/login.tsx index aa1e96e3..bb1e39b9 100644 --- a/packages/napcat-webui-frontend/src/pages/dashboard/config/login.tsx +++ b/packages/napcat-webui-frontend/src/pages/dashboard/config/login.tsx @@ -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 秒后启动新进程 + +
+
设备 GUID 管理
+
+ GUID 是设备登录唯一识别码,存储在 Registry20 文件中。修改后需重启生效。 +
+ +
); }; diff --git a/packages/napcat-webui-frontend/src/pages/qq_login.tsx b/packages/napcat-webui-frontend/src/pages/qq_login.tsx index 1268d806..68744964 100644 --- a/packages/napcat-webui-frontend/src/pages/qq_login.tsx +++ b/packages/napcat-webui-frontend/src/pages/qq_login.tsx @@ -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 () { - +
+ + +
@@ -266,6 +276,15 @@ export default function QQLoginPage () { + {showGUIDManager && ( + } + size='lg' + hideFooter + onClose={() => setShowGUIDManager(false)} + /> + )} ); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bfcbd7eb..9f6d8116 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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