Add Registry20 GUID management and DPAPI support

Introduce tools and UI to read, write, backup and restore QQ Registry20 GUIDs using Windows DPAPI. Adds a Python CLI (guid_tool.py) for local Registry20 operations and a new TypeScript package napcat-dpapi with native bindings for DPAPI. Implements Registry20Utils in the webui-backend to protect/unprotect Registry20, plus backup/restore/delete helpers. Exposes new backend API handlers and routes (get/set GUID, backups, restore, reset, restart) and integrates frontend GUIDManager component and qq_manager controller methods. Propagates QQ data path via WebUiDataRuntime (setter/getter) and wires it up in framework/shell; updates Vite alias and package.json to include the new dpapi workspace. Includes native addon binaries for win32 x64/arm64 and basic tsconfig/readme/license for the new package.
This commit is contained in:
手瓜一十雪
2026-02-12 17:08:24 +08:00
parent 82d0c51716
commit 2f8569f30c
21 changed files with 776 additions and 1 deletions

View 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.

View File

@@ -0,0 +1,4 @@
# @primno/dpapi
## 协议与说明
全部遵守原仓库要求

View 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;

View 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"
}
}

View File

@@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "."
},
"include": [
"./**/*.ts"
]
}

View File

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

View File

@@ -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');

View File

@@ -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'),

View File

@@ -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"
},

View File

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

View File

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

View File

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

View File

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

View 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);
}
}
}

View 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;

View File

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

View File

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

View File

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

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