mirror of
https://github.com/NapNeko/NapCatQQ.git
synced 2026-03-01 16:20:25 +00:00
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:
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
|
// 启动WebUi
|
||||||
WebUiDataRuntime.setWorkingEnv(NapCatCoreWorkingEnv.Framework);
|
WebUiDataRuntime.setWorkingEnv(NapCatCoreWorkingEnv.Framework);
|
||||||
|
WebUiDataRuntime.setQQDataPath(loaderObject.core.dataPath);
|
||||||
InitWebUi(logger, pathWrapper, logSubscription, statusHelperSubscription).then().catch(e => logger.logError(e));
|
InitWebUi(logger, pathWrapper, logSubscription, statusHelperSubscription).then().catch(e => logger.logError(e));
|
||||||
// 使用 NapCatAdapterManager 统一管理协议适配器
|
// 使用 NapCatAdapterManager 统一管理协议适配器
|
||||||
const adapterManager = new NapCatAdapterManager(loaderObject.core, loaderObject.context, pathWrapper);
|
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);
|
const [dataPath, dataPathGlobal] = getDataPaths(wrapper);
|
||||||
|
WebUiDataRuntime.setQQDataPath(dataPath);
|
||||||
const systemPlatform = getPlatformType();
|
const systemPlatform = getPlatformType();
|
||||||
|
|
||||||
if (!basicInfoWrapper.QQVersionAppid || !basicInfoWrapper.QQVersionQua) throw new Error('QQVersionAppid or QQVersionQua is not defined');
|
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-common': resolve(__dirname, '../napcat-common'),
|
||||||
'@/napcat-onebot': resolve(__dirname, '../napcat-onebot'),
|
'@/napcat-onebot': resolve(__dirname, '../napcat-onebot'),
|
||||||
'@/napcat-pty': resolve(__dirname, '../napcat-pty'),
|
'@/napcat-pty': resolve(__dirname, '../napcat-pty'),
|
||||||
|
'@/napcat-dpapi': resolve(__dirname, '../napcat-dpapi'),
|
||||||
'@/napcat-webui-backend': resolve(__dirname, '../napcat-webui-backend'),
|
'@/napcat-webui-backend': resolve(__dirname, '../napcat-webui-backend'),
|
||||||
'@/napcat-image-size': resolve(__dirname, '../napcat-image-size'),
|
'@/napcat-image-size': resolve(__dirname, '../napcat-image-size'),
|
||||||
'@/napcat-protocol': resolve(__dirname, '../napcat-protocol'),
|
'@/napcat-protocol': resolve(__dirname, '../napcat-protocol'),
|
||||||
|
|||||||
@@ -26,6 +26,7 @@
|
|||||||
"json5": "^2.2.3",
|
"json5": "^2.2.3",
|
||||||
"multer": "^2.0.1",
|
"multer": "^2.0.1",
|
||||||
"napcat-common": "workspace:*",
|
"napcat-common": "workspace:*",
|
||||||
|
"napcat-dpapi": "workspace:*",
|
||||||
"napcat-pty": "workspace:*",
|
"napcat-pty": "workspace:*",
|
||||||
"ws": "^8.18.3"
|
"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 { WebUiConfig } from '@/napcat-webui-backend/index';
|
||||||
import { isEmpty } from '@/napcat-webui-backend/src/utils/check';
|
import { isEmpty } from '@/napcat-webui-backend/src/utils/check';
|
||||||
import { sendError, sendSuccess } from '@/napcat-webui-backend/src/utils/response';
|
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登录二维码
|
// 获取QQ登录二维码
|
||||||
export const QQGetQRcodeHandler: RequestHandler = async (_, res) => {
|
export const QQGetQRcodeHandler: RequestHandler = async (_, res) => {
|
||||||
@@ -147,3 +163,106 @@ export const QQPasswordLoginHandler: RequestHandler = async (req, res) => {
|
|||||||
}
|
}
|
||||||
return sendSuccess(res, null);
|
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: '',
|
QQLoginError: '',
|
||||||
QQVersion: 'unknown',
|
QQVersion: 'unknown',
|
||||||
|
QQDataPath: '',
|
||||||
OneBotContext: null,
|
OneBotContext: null,
|
||||||
onQQLoginStatusChange: async (status: boolean) => {
|
onQQLoginStatusChange: async (status: boolean) => {
|
||||||
LoginRuntime.QQLoginStatus = status;
|
LoginRuntime.QQLoginStatus = status;
|
||||||
@@ -167,6 +168,14 @@ export const WebUiDataRuntime = {
|
|||||||
return LoginRuntime.QQVersion;
|
return LoginRuntime.QQVersion;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
setQQDataPath (dataPath: string) {
|
||||||
|
LoginRuntime.QQDataPath = dataPath;
|
||||||
|
},
|
||||||
|
|
||||||
|
getQQDataPath (): string {
|
||||||
|
return LoginRuntime.QQDataPath;
|
||||||
|
},
|
||||||
|
|
||||||
setWebUiConfigQuickFunction (func: LoginRuntimeType['WebUiConfigQuickFunction']): void {
|
setWebUiConfigQuickFunction (func: LoginRuntimeType['WebUiConfigQuickFunction']): void {
|
||||||
LoginRuntime.WebUiConfigQuickFunction = func;
|
LoginRuntime.WebUiConfigQuickFunction = func;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -11,6 +11,13 @@ import {
|
|||||||
setAutoLoginAccountHandler,
|
setAutoLoginAccountHandler,
|
||||||
QQRefreshQRcodeHandler,
|
QQRefreshQRcodeHandler,
|
||||||
QQPasswordLoginHandler,
|
QQPasswordLoginHandler,
|
||||||
|
QQResetDeviceIDHandler,
|
||||||
|
QQRestartNapCatHandler,
|
||||||
|
QQGetDeviceGUIDHandler,
|
||||||
|
QQSetDeviceGUIDHandler,
|
||||||
|
QQGetGUIDBackupsHandler,
|
||||||
|
QQRestoreGUIDBackupHandler,
|
||||||
|
QQCreateGUIDBackupHandler,
|
||||||
} from '@/napcat-webui-backend/src/api/QQLogin';
|
} from '@/napcat-webui-backend/src/api/QQLogin';
|
||||||
|
|
||||||
const router: Router = Router();
|
const router: Router = Router();
|
||||||
@@ -34,5 +41,19 @@ router.post('/SetQuickLoginQQ', setAutoLoginAccountHandler);
|
|||||||
router.post('/RefreshQRcode', QQRefreshQRcodeHandler);
|
router.post('/RefreshQRcode', QQRefreshQRcodeHandler);
|
||||||
// router:密码登录
|
// router:密码登录
|
||||||
router.post('/PasswordLogin', QQPasswordLoginHandler);
|
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 };
|
export { router as QQLoginRouter };
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ export interface LoginRuntimeType {
|
|||||||
QQLoginInfo: SelfInfo;
|
QQLoginInfo: SelfInfo;
|
||||||
QQLoginError: string;
|
QQLoginError: string;
|
||||||
QQVersion: string;
|
QQVersion: string;
|
||||||
|
QQDataPath: string;
|
||||||
onQQLoginStatusChange: (status: boolean) => Promise<void>;
|
onQQLoginStatusChange: (status: boolean) => Promise<void>;
|
||||||
onWebUiTokenChange: (token: string) => Promise<void>;
|
onWebUiTokenChange: (token: string) => Promise<void>;
|
||||||
onRefreshQRCode: () => 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
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;
|
||||||
@@ -101,4 +101,36 @@ export default class QQManager {
|
|||||||
passwordMd5,
|
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 { Input } from '@heroui/input';
|
||||||
import { Button } from '@heroui/button';
|
import { Button } from '@heroui/button';
|
||||||
|
import { Divider } from '@heroui/divider';
|
||||||
import { useRequest } from 'ahooks';
|
import { useRequest } from 'ahooks';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Controller, useForm } from 'react-hook-form';
|
import { Controller, useForm } from 'react-hook-form';
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
|
|
||||||
import SaveButtons from '@/components/button/save_buttons';
|
import SaveButtons from '@/components/button/save_buttons';
|
||||||
|
import GUIDManager from '@/components/guid_manager';
|
||||||
import PageLoading from '@/components/page_loading';
|
import PageLoading from '@/components/page_loading';
|
||||||
|
|
||||||
import QQManager from '@/controllers/qq_manager';
|
import QQManager from '@/controllers/qq_manager';
|
||||||
@@ -131,6 +133,14 @@ const LoginConfigCard = () => {
|
|||||||
重启进程将关闭当前 Worker 进程,等待 3 秒后启动新进程
|
重启进程将关闭当前 Worker 进程,等待 3 秒后启动新进程
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,8 +6,11 @@ import { useEffect, useRef, useState } from 'react';
|
|||||||
import { toast } from 'react-hot-toast';
|
import { toast } from 'react-hot-toast';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import CryptoJS from 'crypto-js';
|
import CryptoJS from 'crypto-js';
|
||||||
|
import { MdSettings } from 'react-icons/md';
|
||||||
|
|
||||||
import logo from '@/assets/images/logo.png';
|
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 HoverEffectCard from '@/components/effect_card';
|
||||||
import { title } from '@/components/primitives';
|
import { title } from '@/components/primitives';
|
||||||
@@ -174,6 +177,8 @@ export default function QQLoginPage () {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const [showGUIDManager, setShowGUIDManager] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timer = setInterval(() => {
|
const timer = setInterval(() => {
|
||||||
onUpdateQrCode();
|
onUpdateQrCode();
|
||||||
@@ -210,7 +215,12 @@ export default function QQLoginPage () {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</CardHeader>
|
||||||
|
|
||||||
<CardBody className='flex gap-5 p-10 pt-0'>
|
<CardBody className='flex gap-5 p-10 pt-0'>
|
||||||
@@ -266,6 +276,15 @@ export default function QQLoginPage () {
|
|||||||
</HoverEffectCard>
|
</HoverEffectCard>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</PureLayout>
|
</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
|
specifier: ^22.0.1
|
||||||
version: 22.19.1
|
version: 22.19.1
|
||||||
|
|
||||||
|
packages/napcat-dpapi:
|
||||||
|
devDependencies:
|
||||||
|
'@types/node':
|
||||||
|
specifier: ^22.0.1
|
||||||
|
version: 22.19.1
|
||||||
|
|
||||||
packages/napcat-framework:
|
packages/napcat-framework:
|
||||||
dependencies:
|
dependencies:
|
||||||
napcat-adapter:
|
napcat-adapter:
|
||||||
@@ -441,6 +447,9 @@ importers:
|
|||||||
napcat-common:
|
napcat-common:
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../napcat-common
|
version: link:../napcat-common
|
||||||
|
napcat-dpapi:
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../napcat-dpapi
|
||||||
napcat-pty:
|
napcat-pty:
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../napcat-pty
|
version: link:../napcat-pty
|
||||||
|
|||||||
Reference in New Issue
Block a user