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