NapCatQQ source snapshot
This commit is contained in:
commit
f954252a34
1111 files changed
+139867
No files matched your search
@@ -0,0 +1,877 @@
|
||||
import type { SelfInfo } from 'napcat-core/index';
|
||||
|
||||
import { NodeIKernelLoginListener, NodeIKernelSessionListener } from 'napcat-core/listeners';
|
||||
import { NodeIDependsAdapter, NodeIDispatcherAdapter, NodeIGlobalAdapter } from 'napcat-core/adapters';
|
||||
import { NapCatPathWrapper } from 'napcat-common/src/path';
|
||||
import {
|
||||
genSessionConfig,
|
||||
InstanceContext,
|
||||
loadQQWrapper,
|
||||
NapCatCore,
|
||||
NapCatCoreWorkingEnv,
|
||||
NodeIQQNTStartupSessionWrapper,
|
||||
NodeIQQNTWrapperEngine,
|
||||
NodeIQQNTWrapperSession,
|
||||
PlatformType,
|
||||
WrapperNodeApi,
|
||||
WrapperSessionInitConfig,
|
||||
} from 'napcat-core';
|
||||
import { hostname, systemVersion } from 'napcat-common/src/system';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { LoginListItem, NodeIKernelLoginService } from 'napcat-core/services';
|
||||
import qrcode from 'napcat-qrcode/lib/main';
|
||||
import { NapCatAdapterManager } from 'napcat-adapter';
|
||||
import { InitWebUi } from 'napcat-webui-backend/index';
|
||||
import { WebUiDataRuntime } from 'napcat-webui-backend/src/helper/Data';
|
||||
import { napCatVersion } from 'napcat-common/src/version';
|
||||
import { NodeIO3MiscListener } from 'napcat-core/listeners/NodeIO3MiscListener';
|
||||
import { sleep } from 'napcat-common/src/helper';
|
||||
import { FFmpegService } from '@/napcat-core/helper/ffmpeg/ffmpeg';
|
||||
import { NativePacketHandler } from 'napcat-core/packet/handler/client';
|
||||
import { Napi2NativeLoader } from 'napcat-core/packet/handler/napi2nativeLoader';
|
||||
import { NapProtoMsg } from 'napcat-protobuf';
|
||||
import { OidbSvcTrpcTcpBase, OidbSvcTrpcTcp0XCDE_2RespBody } from '@/napcat-core/packet/transformer/proto';
|
||||
import { loadNapcatConfig } from '@/napcat-core/helper/config';
|
||||
import { logSubscription, LogWrapper } from '@/napcat-core/helper/log';
|
||||
import { proxiedListenerOf } from '@/napcat-core/helper/proxy-handler';
|
||||
import { createOfflineRecovery } from '@/napcat-core/helper/offline-recovery';
|
||||
import { QQBasicInfoWrapper } from '@/napcat-core/helper/qq-basic-info';
|
||||
import { statusHelperSubscription } from '@/napcat-core/helper/status';
|
||||
import { applyPendingUpdates } from '@/napcat-webui-backend/src/api/UpdateNapCat';
|
||||
import { migrateLegacyGlobalPath } from './global-migrate';
|
||||
import { getDataPaths } from './data-path';
|
||||
import { connectToNamedPipe } from './pipe';
|
||||
|
||||
// NapCat Shell App ES 入口文件
|
||||
async function handleUncaughtExceptions (logger: LogWrapper) {
|
||||
process.on('uncaughtException', (err) => {
|
||||
logger.logError('[NapCat] [Error] Unhandled Exception:', err.message);
|
||||
});
|
||||
process.on('unhandledRejection', (reason) => {
|
||||
logger.logError('[NapCat] [Error] unhandledRejection:', reason);
|
||||
});
|
||||
}
|
||||
|
||||
function getPlatformType (): PlatformType {
|
||||
const platformMapping: Partial<Record<NodeJS.Platform, PlatformType>> = {
|
||||
win32: PlatformType.KWINDOWS,
|
||||
darwin: PlatformType.KMAC,
|
||||
linux: PlatformType.KLINUX,
|
||||
};
|
||||
return platformMapping[os.platform()] ?? PlatformType.KWINDOWS;
|
||||
}
|
||||
|
||||
async function initializeEngine (
|
||||
engine: NodeIQQNTWrapperEngine,
|
||||
basicInfoWrapper: QQBasicInfoWrapper,
|
||||
dataPathGlobal: string,
|
||||
systemPlatform: PlatformType,
|
||||
systemVersion: string
|
||||
) {
|
||||
engine.initWithDeskTopConfig(
|
||||
{
|
||||
base_path_prefix: '',
|
||||
platform_type: systemPlatform,
|
||||
app_type: 4,
|
||||
app_version: basicInfoWrapper.getFullQQVersion(),
|
||||
os_version: systemVersion,
|
||||
use_xlog: false,
|
||||
qua: basicInfoWrapper.QQVersionQua ?? '',
|
||||
global_path_config: {
|
||||
desktopGlobalPath: dataPathGlobal,
|
||||
},
|
||||
thumb_config: { maxSide: 324, minSide: 48, longLimit: 6, density: 2 },
|
||||
},
|
||||
new NodeIGlobalAdapter()
|
||||
);
|
||||
}
|
||||
|
||||
async function initializeLoginService (
|
||||
loginService: NodeIKernelLoginService,
|
||||
basicInfoWrapper: QQBasicInfoWrapper,
|
||||
dataPathGlobal: string,
|
||||
systemVersion: string,
|
||||
hostname: string
|
||||
) {
|
||||
loginService.initConfig({
|
||||
machineId: '',
|
||||
appid: basicInfoWrapper.QQVersionAppid ?? '',
|
||||
platVer: systemVersion,
|
||||
commonPath: dataPathGlobal,
|
||||
clientVer: basicInfoWrapper.getFullQQVersion(),
|
||||
hostName: hostname,
|
||||
externalVersion: false,
|
||||
});
|
||||
}
|
||||
|
||||
async function handleLogin (
|
||||
loginService: NodeIKernelLoginService,
|
||||
logger: LogWrapper,
|
||||
pathWrapper: NapCatPathWrapper,
|
||||
quickLoginUin: string | undefined,
|
||||
historyLoginList: LoginListItem[]
|
||||
): Promise<SelfInfo> {
|
||||
const context = { isLogined: false, loginFlowInitialized: false };
|
||||
let inner_resolve: (value: SelfInfo) => void;
|
||||
const selfInfo: Promise<SelfInfo> = new Promise((resolve) => {
|
||||
inner_resolve = resolve;
|
||||
});
|
||||
// 连接服务
|
||||
|
||||
const loginListener = new NodeIKernelLoginListener();
|
||||
loginListener.onUserLoggedIn = (userid: string) => {
|
||||
const tips = `当前账号(${userid})已登录,无法重复登录`;
|
||||
logger.logError(tips);
|
||||
WebUiDataRuntime.setQQLoginError(tips);
|
||||
};
|
||||
loginListener.onQRCodeLoginSucceed = async (loginResult) => {
|
||||
context.isLogined = true;
|
||||
WebUiDataRuntime.setQQLoginStatus(true);
|
||||
WebUiDataRuntime.setQQLoginError('');
|
||||
const oneBotContext = WebUiDataRuntime.getOneBotContext();
|
||||
if (oneBotContext?.core?.selfInfo) {
|
||||
oneBotContext.core.selfInfo.online = true;
|
||||
WebUiDataRuntime.setQQLoginPhase('ready');
|
||||
} else {
|
||||
WebUiDataRuntime.setQQLoginPhase('initializing');
|
||||
}
|
||||
inner_resolve({
|
||||
uid: loginResult.uid,
|
||||
uin: loginResult.uin,
|
||||
nick: '',
|
||||
online: true,
|
||||
});
|
||||
};
|
||||
loginListener.onLoginDisConnected = () => {
|
||||
context.isLogined = false;
|
||||
WebUiDataRuntime.setQQLoginStatus(false);
|
||||
WebUiDataRuntime.setQQLoginPhase('offline');
|
||||
logger.logWarn('[Core] [Login] 登录服务已断开,将允许重新获取二维码');
|
||||
};
|
||||
loginListener.onLogoutSucceed = () => {
|
||||
context.isLogined = false;
|
||||
WebUiDataRuntime.setQQLoginStatus(false);
|
||||
WebUiDataRuntime.setQQLoginPhase('offline');
|
||||
};
|
||||
loginListener.onLoginConnected = () => {
|
||||
waitForNetworkConnection(loginService, logger).then(async () => {
|
||||
if (!context.loginFlowInitialized) {
|
||||
context.loginFlowInitialized = true;
|
||||
await handleLoginInner(context, logger, loginService, quickLoginUin, historyLoginList);
|
||||
return;
|
||||
}
|
||||
if (!context.isLogined) {
|
||||
WebUiDataRuntime.setQQLoginPhase('generating_qrcode');
|
||||
const requested = loginService.getQRCodePicture();
|
||||
logger.logWarn('[Core] [Login] 登录服务重连完成,正在获取二维码', { requested, msfStatus: loginService.getMsfStatus() });
|
||||
}
|
||||
}).catch(e => logger.logError(e));
|
||||
};
|
||||
loginListener.onQRCodeGetPicture = ({ pngBase64QrcodeData, qrcodeUrl }) => {
|
||||
WebUiDataRuntime.setQQLoginQrcodeURL(qrcodeUrl);
|
||||
WebUiDataRuntime.setQQLoginError('');
|
||||
WebUiDataRuntime.setQQLoginPhase('waiting_qrcode');
|
||||
|
||||
const realBase64 = pngBase64QrcodeData.replace(/^data:image\/\w+;base64,/, '');
|
||||
const buffer = Buffer.from(realBase64, 'base64');
|
||||
logger.logWarn('请扫描下面的二维码,然后在手Q上授权登录:');
|
||||
const qrcodePath = path.join(pathWrapper.cachePath, 'qrcode.png');
|
||||
qrcode.generate(qrcodeUrl, { small: true }, (res) => {
|
||||
logger.logWarn([
|
||||
'\n',
|
||||
res,
|
||||
'二维码解码URL: ' + qrcodeUrl,
|
||||
'如果控制台二维码无法扫码,可以复制解码url到二维码生成网站生成二维码再扫码,也可以打开下方的二维码路径图片进行扫码。',
|
||||
].join('\n'));
|
||||
fs.writeFile(qrcodePath, buffer, {}, () => {
|
||||
logger.logWarn('二维码已保存到', qrcodePath);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
loginListener.onQRCodeSessionUserScaned = () => {
|
||||
WebUiDataRuntime.setQQLoginPhase('qrcode_scanned');
|
||||
logger.log('[Core] [Login] 二维码已被扫描,等待确认...');
|
||||
};
|
||||
|
||||
loginListener.onQRCodeSessionFailed = (errType: number, errCode: number) => {
|
||||
if (!context.isLogined) {
|
||||
logger.logError('[Core] [Login] Login Error,ErrType: ', errType, ' ErrCode:', errCode);
|
||||
if (errType === 1 && errCode === 3) {
|
||||
// 仅二维码过期时自动请求下一张二维码。账号被踢下线等其他错误
|
||||
// 需要由用户触发刷新,避免每两秒重复创建原生登录会话。
|
||||
WebUiDataRuntime.setQQLoginError('二维码已过期,请刷新');
|
||||
loginService.getQRCodePicture();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
loginListener.onLoginFailed = (...args) => {
|
||||
const errInfo = JSON.stringify(args);
|
||||
logger.logError('[Core] [Login] Login Error , ErrInfo: ', errInfo);
|
||||
WebUiDataRuntime.setQQLoginError(`登录失败: ${errInfo}`);
|
||||
};
|
||||
|
||||
loginService.addKernelLoginListener(proxiedListenerOf(loginListener, logger));
|
||||
loginService.connect();
|
||||
return await selfInfo;
|
||||
}
|
||||
async function handleLoginInner (context: { isLogined: boolean; loginFlowInitialized: boolean; }, logger: LogWrapper, loginService: NodeIKernelLoginService, quickLoginUin: string | undefined, historyLoginList: LoginListItem[]) {
|
||||
const resolveQuickPasswordMd5 = (): string | undefined => {
|
||||
const quickPasswordMd5 = process.env['NAPCAT_QUICK_PASSWORD_MD5']?.trim();
|
||||
if (quickPasswordMd5) {
|
||||
if (/^[a-fA-F0-9]{32}$/.test(quickPasswordMd5)) {
|
||||
return quickPasswordMd5.toLowerCase();
|
||||
}
|
||||
logger.logError('NAPCAT_QUICK_PASSWORD_MD5 格式无效(需为 32 位 MD5)');
|
||||
}
|
||||
|
||||
const quickPassword = process.env['NAPCAT_QUICK_PASSWORD'];
|
||||
if (typeof quickPassword === 'string' && quickPassword.length > 0) {
|
||||
logger.log('检测到 NAPCAT_QUICK_PASSWORD,已在内存中计算 MD5 用于回退登录');
|
||||
return createHash('md5').update(quickPassword, 'utf8').digest('hex');
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
// 注册刷新二维码回调
|
||||
WebUiDataRuntime.setRefreshQRCodeCallback(async () => {
|
||||
context.isLogined = false;
|
||||
const msfStatus = loginService.getMsfStatus();
|
||||
if (msfStatus === 2) {
|
||||
// KickedOffLine 会在后台负责重启 Worker。这里不能重启:该回调由
|
||||
// RefreshQRcode HTTP 请求触发,重启会中断正在等待响应的请求。
|
||||
WebUiDataRuntime.setQQLoginPhase('reconnecting');
|
||||
throw new Error('QQ_LOGIN_SERVICE_RESTARTING');
|
||||
}
|
||||
const requested = loginService.getQRCodePicture();
|
||||
logger.logWarn('[Core] [Login] 请求刷新二维码', { requested, msfStatus });
|
||||
if (!requested) {
|
||||
throw new Error('QQ 未接受二维码刷新请求');
|
||||
}
|
||||
});
|
||||
|
||||
WebUiDataRuntime.setQuickLoginCall(async (uin: string) => {
|
||||
return await new Promise((resolve) => {
|
||||
if (uin) {
|
||||
logger.log('正在快速登录 ', uin);
|
||||
loginService.quickLoginWithUin(uin).then(res => {
|
||||
const quickLoginSuccess = res.result === '0' && !res.loginErrorInfo?.errMsg;
|
||||
if (!quickLoginSuccess) {
|
||||
const errMsg = res.loginErrorInfo?.errMsg || `快速登录失败,错误码: ${res.result}`;
|
||||
WebUiDataRuntime.setQQLoginError(errMsg);
|
||||
loginService.getQRCodePicture();
|
||||
resolve({ result: false, message: errMsg });
|
||||
} else {
|
||||
WebUiDataRuntime.setQQLoginStatus(true);
|
||||
WebUiDataRuntime.setQQLoginError('');
|
||||
resolve({ result: true, message: '' });
|
||||
}
|
||||
}).catch((e) => {
|
||||
logger.logError(e);
|
||||
WebUiDataRuntime.setQQLoginError('快速登录发生错误');
|
||||
loginService.getQRCodePicture();
|
||||
resolve({ result: false, message: '快速登录发生错误' });
|
||||
});
|
||||
} else {
|
||||
resolve({ result: false, message: '快速登录失败' });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// 注册密码登录回调
|
||||
WebUiDataRuntime.setPasswordLoginCall(async (uin: string, passwordMd5: string) => {
|
||||
return await new Promise((resolve) => {
|
||||
if (uin && passwordMd5) {
|
||||
logger.log('正在密码登录 ', uin);
|
||||
loginService.passwordLogin({
|
||||
uin,
|
||||
passwordMd5,
|
||||
step: 0,
|
||||
newDeviceLoginSig: new Uint8Array(),
|
||||
proofWaterSig: new Uint8Array(),
|
||||
proofWaterRand: new Uint8Array(),
|
||||
proofWaterSid: new Uint8Array(),
|
||||
unusualDeviceCheckSig: new Uint8Array(),
|
||||
}).then(res => {
|
||||
if (res.result === '140022008') {
|
||||
const proofWaterUrl = res.loginErrorInfo?.proofWaterUrl || '';
|
||||
logger.log('需要验证码, proofWaterUrl: ', proofWaterUrl);
|
||||
resolve({
|
||||
result: false,
|
||||
message: '需要验证码',
|
||||
needCaptcha: true,
|
||||
proofWaterUrl,
|
||||
});
|
||||
} else if (res.result === '140022010') {
|
||||
const jumpUrl = res.loginErrorInfo?.jumpUrl || '';
|
||||
const newDevicePullQrCodeSig = res.loginErrorInfo?.newDevicePullQrCodeSig || '';
|
||||
logger.log('新设备需要扫码验证, jumpUrl: ', jumpUrl);
|
||||
resolve({
|
||||
result: false,
|
||||
message: '新设备需要扫码验证',
|
||||
needNewDevice: true,
|
||||
jumpUrl,
|
||||
newDevicePullQrCodeSig,
|
||||
});
|
||||
} else if (res.result === '140022011') {
|
||||
const jumpUrl = res.loginErrorInfo?.jumpUrl || '';
|
||||
const newDevicePullQrCodeSig = res.loginErrorInfo?.newDevicePullQrCodeSig || '';
|
||||
logger.log('异常设备需要验证, jumpUrl: ', jumpUrl);
|
||||
resolve({
|
||||
result: false,
|
||||
message: '异常设备需要验证',
|
||||
needNewDevice: true,
|
||||
jumpUrl,
|
||||
newDevicePullQrCodeSig,
|
||||
});
|
||||
} else if (res.result !== '0') {
|
||||
const errMsg = res.loginErrorInfo?.errMsg || '密码登录失败';
|
||||
WebUiDataRuntime.setQQLoginError(errMsg);
|
||||
loginService.getQRCodePicture();
|
||||
resolve({ result: false, message: errMsg });
|
||||
} else {
|
||||
WebUiDataRuntime.setQQLoginStatus(true);
|
||||
WebUiDataRuntime.setQQLoginError('');
|
||||
resolve({ result: true, message: '' });
|
||||
}
|
||||
}).catch((e) => {
|
||||
logger.logError(e);
|
||||
WebUiDataRuntime.setQQLoginError('密码登录发生错误');
|
||||
loginService.getQRCodePicture();
|
||||
resolve({ result: false, message: '密码登录发生错误' });
|
||||
});
|
||||
} else {
|
||||
resolve({ result: false, message: '密码登录失败:参数不完整' });
|
||||
}
|
||||
});
|
||||
});
|
||||
const tryPasswordFallbackLogin = async (uin: string): Promise<{ success: boolean, attempted: boolean; }> => {
|
||||
const quickPasswordMd5 = resolveQuickPasswordMd5();
|
||||
if (!quickPasswordMd5) {
|
||||
logger.log(`QQ ${uin} 未配置回退密码环境变量,建议优先使用 ACCOUNT + NAPCAT_QUICK_PASSWORD(NAPCAT_QUICK_PASSWORD_MD5 作为备用),将使用二维码登录方式`);
|
||||
return { success: false, attempted: false };
|
||||
}
|
||||
|
||||
logger.log('正在尝试密码回退登录 ', uin);
|
||||
const fallbackResult = await WebUiDataRuntime.requestPasswordLogin(uin, quickPasswordMd5);
|
||||
if (fallbackResult.result) {
|
||||
logger.log('密码回退登录成功 ', uin);
|
||||
return { success: true, attempted: true };
|
||||
}
|
||||
if (fallbackResult.needCaptcha) {
|
||||
const captchaTip = fallbackResult.proofWaterUrl
|
||||
? `密码回退需要验证码,请在 WebUi 中继续完成验证:${fallbackResult.proofWaterUrl}`
|
||||
: '密码回退需要验证码,请在 WebUi 中继续完成验证';
|
||||
logger.logWarn(captchaTip);
|
||||
WebUiDataRuntime.setQQLoginError('密码回退需要验证码,请在 WebUi 中继续完成验证');
|
||||
return { success: false, attempted: true };
|
||||
}
|
||||
if (fallbackResult.needNewDevice) {
|
||||
const newDeviceTip = fallbackResult.jumpUrl
|
||||
? `密码回退需要新设备验证,请在 WebUi 中继续完成验证:${fallbackResult.jumpUrl}`
|
||||
: '密码回退需要新设备验证,请在 WebUi 中继续完成验证';
|
||||
logger.logWarn(newDeviceTip);
|
||||
WebUiDataRuntime.setQQLoginError('密码回退需要新设备验证,请在 WebUi 中继续完成验证');
|
||||
return { success: false, attempted: true };
|
||||
}
|
||||
logger.logError('密码回退登录失败:', fallbackResult.message);
|
||||
return { success: false, attempted: true };
|
||||
};
|
||||
|
||||
// 注册验证码登录回调(密码登录需要验证码时的第二步)
|
||||
WebUiDataRuntime.setCaptchaLoginCall(async (uin: string, passwordMd5: string, ticket: string, randstr: string, sid: string) => {
|
||||
return await new Promise((resolve) => {
|
||||
if (uin && passwordMd5 && ticket) {
|
||||
logger.log('正在验证码登录 ', uin);
|
||||
loginService.passwordLogin({
|
||||
uin,
|
||||
passwordMd5,
|
||||
step: 1,
|
||||
newDeviceLoginSig: new Uint8Array(),
|
||||
proofWaterSig: new TextEncoder().encode(ticket),
|
||||
proofWaterRand: new TextEncoder().encode(randstr),
|
||||
proofWaterSid: new TextEncoder().encode(sid),
|
||||
unusualDeviceCheckSig: new Uint8Array(),
|
||||
}).then(res => {
|
||||
console.log('验证码登录结果: ', res);
|
||||
if (res.result === '140022010') {
|
||||
const jumpUrl = res.loginErrorInfo?.jumpUrl || '';
|
||||
const newDevicePullQrCodeSig = res.loginErrorInfo?.newDevicePullQrCodeSig || '';
|
||||
logger.log('验证码登录后需要新设备验证, jumpUrl: ', jumpUrl);
|
||||
resolve({
|
||||
result: false,
|
||||
message: '新设备需要扫码验证',
|
||||
needNewDevice: true,
|
||||
jumpUrl,
|
||||
newDevicePullQrCodeSig,
|
||||
});
|
||||
} else if (res.result === '140022011') {
|
||||
const jumpUrl = res.loginErrorInfo?.jumpUrl || '';
|
||||
const newDevicePullQrCodeSig = res.loginErrorInfo?.newDevicePullQrCodeSig || '';
|
||||
logger.log('验证码登录后需要异常设备验证, jumpUrl: ', jumpUrl);
|
||||
resolve({
|
||||
result: false,
|
||||
message: '异常设备需要验证',
|
||||
needNewDevice: true,
|
||||
jumpUrl,
|
||||
newDevicePullQrCodeSig,
|
||||
});
|
||||
} else if (res.result !== '0') {
|
||||
const errMsg = res.loginErrorInfo?.errMsg || '验证码登录失败';
|
||||
WebUiDataRuntime.setQQLoginError(errMsg);
|
||||
loginService.getQRCodePicture();
|
||||
resolve({ result: false, message: errMsg });
|
||||
} else {
|
||||
WebUiDataRuntime.setQQLoginStatus(true);
|
||||
WebUiDataRuntime.setQQLoginError('');
|
||||
resolve({ result: true, message: '' });
|
||||
}
|
||||
}).catch((e) => {
|
||||
logger.logError(e);
|
||||
WebUiDataRuntime.setQQLoginError('验证码登录发生错误');
|
||||
loginService.getQRCodePicture();
|
||||
resolve({ result: false, message: '验证码登录发生错误' });
|
||||
});
|
||||
} else {
|
||||
resolve({ result: false, message: '验证码登录失败:参数不完整' });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// 注册新设备登录回调(密码登录需要新设备验证时的第二步)
|
||||
WebUiDataRuntime.setNewDeviceLoginCall(async (uin: string, passwordMd5: string, newDevicePullQrCodeSig: string) => {
|
||||
return await new Promise((resolve) => {
|
||||
if (uin && passwordMd5 && newDevicePullQrCodeSig) {
|
||||
logger.log('正在新设备验证登录 ', uin);
|
||||
loginService.passwordLogin({
|
||||
uin,
|
||||
passwordMd5,
|
||||
step: 2,
|
||||
newDeviceLoginSig: new TextEncoder().encode(newDevicePullQrCodeSig),
|
||||
proofWaterSig: new Uint8Array(),
|
||||
proofWaterRand: new Uint8Array(),
|
||||
proofWaterSid: new Uint8Array(),
|
||||
unusualDeviceCheckSig: new Uint8Array(),
|
||||
}).then(res => {
|
||||
if (res.result === '140022011') {
|
||||
const jumpUrl = res.loginErrorInfo?.jumpUrl || '';
|
||||
const newDevicePullQrCodeSig = res.loginErrorInfo?.newDevicePullQrCodeSig || '';
|
||||
logger.log('新设备验证后需要异常设备验证, jumpUrl: ', jumpUrl);
|
||||
resolve({
|
||||
result: false,
|
||||
message: '异常设备需要验证',
|
||||
needNewDevice: true,
|
||||
jumpUrl,
|
||||
newDevicePullQrCodeSig,
|
||||
});
|
||||
} else if (res.result !== '0') {
|
||||
const errMsg = res.loginErrorInfo?.errMsg || '新设备验证登录失败';
|
||||
WebUiDataRuntime.setQQLoginError(errMsg);
|
||||
loginService.getQRCodePicture();
|
||||
resolve({ result: false, message: errMsg });
|
||||
} else {
|
||||
WebUiDataRuntime.setQQLoginStatus(true);
|
||||
WebUiDataRuntime.setQQLoginError('');
|
||||
resolve({ result: true, message: '' });
|
||||
}
|
||||
}).catch((e) => {
|
||||
logger.logError(e);
|
||||
WebUiDataRuntime.setQQLoginError('新设备验证登录发生错误');
|
||||
loginService.getQRCodePicture();
|
||||
resolve({ result: false, message: '新设备验证登录发生错误' });
|
||||
});
|
||||
} else {
|
||||
resolve({ result: false, message: '新设备验证登录失败:参数不完整' });
|
||||
}
|
||||
});
|
||||
});
|
||||
if (quickLoginUin) {
|
||||
if (historyLoginList.some(u => u.uin === quickLoginUin)) {
|
||||
logger.log('正在快速登录 ', quickLoginUin);
|
||||
loginService.quickLoginWithUin(quickLoginUin)
|
||||
.then(async result => {
|
||||
const quickLoginSuccess = result.result === '0' && !result.loginErrorInfo?.errMsg;
|
||||
if (!quickLoginSuccess) {
|
||||
const errMsg = result.loginErrorInfo?.errMsg || `快速登录失败,错误码: ${result.result}`;
|
||||
logger.logError('快速登录错误:', errMsg);
|
||||
WebUiDataRuntime.setQQLoginError(errMsg);
|
||||
const { success, attempted } = await tryPasswordFallbackLogin(quickLoginUin);
|
||||
if (!success && !attempted && !context.isLogined) loginService.getQRCodePicture();
|
||||
}
|
||||
})
|
||||
.catch(async (error) => {
|
||||
logger.logError('快速登录异常:', error);
|
||||
WebUiDataRuntime.setQQLoginError('快速登录发生错误');
|
||||
const { success, attempted } = await tryPasswordFallbackLogin(quickLoginUin);
|
||||
if (!success && !attempted && !context.isLogined) loginService.getQRCodePicture();
|
||||
});
|
||||
} else {
|
||||
logger.logError('快速登录失败,未找到该 QQ 历史登录记录,将尝试密码回退登录');
|
||||
const { success, attempted } = await tryPasswordFallbackLogin(quickLoginUin);
|
||||
if (!success && !attempted && !context.isLogined) loginService.getQRCodePicture();
|
||||
}
|
||||
} else {
|
||||
logger.log('没有 -q 指令指定快速登录,将使用二维码登录方式');
|
||||
if (historyLoginList.length > 0) {
|
||||
logger.log(`可用于快速登录 of QQ:\n${historyLoginList
|
||||
.map((u, index) => `${index + 1}. ${u.uin} ${u.nickName}`)
|
||||
.join('\n')
|
||||
}`);
|
||||
}
|
||||
let hasAttemptedFallback = false;
|
||||
try {
|
||||
// 检查是否配置了自动登录账号,并尝试 WebUi 快速/密码回退登录。
|
||||
// 这可以避免在已经有自动登录配置的情况下过早输出二维码。
|
||||
const quickLoginList = WebUiDataRuntime.getQQQuickLoginList() || [];
|
||||
const hasAutoLogin = process.env['NAPCAT_QUICK_ACCOUNT'] || (quickLoginList.length > 0); // 假如有历史登录列表
|
||||
if (hasAutoLogin || process.env['NAPCAT_QUICK_PASSWORD'] || process.env['NAPCAT_QUICK_PASSWORD_MD5']) {
|
||||
hasAttemptedFallback = true;
|
||||
}
|
||||
await WebUiDataRuntime.runWebUiConfigQuickFunction();
|
||||
} catch (error) {
|
||||
logger.logError('WebUi 快速登录失败 执行失败', error);
|
||||
}
|
||||
if (!hasAttemptedFallback && !context.isLogined) {
|
||||
loginService.getQRCodePicture();
|
||||
}
|
||||
}
|
||||
|
||||
loginService.getLoginList().then((res) => {
|
||||
// 遍历 res.LocalLoginInfoList[x].isQuickLogin是否可以 res.LocalLoginInfoList[x].uin 转为string 加入string[] 最后遍历完成调用WebUiDataRuntime.setQQQuickLoginList
|
||||
const list = res.LocalLoginInfoList.filter((item) => item.isQuickLogin);
|
||||
WebUiDataRuntime.setQQQuickLoginList(list.map((item) => item.uin.toString()));
|
||||
WebUiDataRuntime.setQQNewLoginList(list);
|
||||
});
|
||||
}
|
||||
|
||||
async function initializeSession (
|
||||
session: NodeIQQNTWrapperSession,
|
||||
sessionConfig: WrapperSessionInitConfig,
|
||||
startupSession: NodeIQQNTStartupSessionWrapper | null
|
||||
) {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const sessionListener = new NodeIKernelSessionListener();
|
||||
sessionListener.onOpentelemetryInit = (info) => {
|
||||
if (info.is_init) {
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error('opentelemetry init failed'));
|
||||
}
|
||||
};
|
||||
session.init(
|
||||
sessionConfig,
|
||||
new NodeIDependsAdapter(),
|
||||
new NodeIDispatcherAdapter(),
|
||||
sessionListener
|
||||
);
|
||||
if (startupSession) {
|
||||
startupSession.start();
|
||||
} else {
|
||||
try {
|
||||
session.startNT(0);
|
||||
} catch {
|
||||
try {
|
||||
session.startNT();
|
||||
} catch (e: unknown) {
|
||||
reject(new Error('init failed ' + (e as Error).message));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
async function handleProxy (session: NodeIQQNTWrapperSession, logger: LogWrapper) {
|
||||
if (process.env['NAPCAT_PROXY_PORT']) {
|
||||
session.getMSFService().setNetworkProxy({
|
||||
userName: '',
|
||||
userPwd: '',
|
||||
address: process.env['NAPCAT_PROXY_ADDRESS'] || '127.0.0.1',
|
||||
port: +process.env['NAPCAT_PROXY_PORT'],
|
||||
proxyType: 2,
|
||||
domain: '',
|
||||
isSocket: true,
|
||||
});
|
||||
logger.logWarn('已设置代理', process.env['NAPCAT_PROXY_ADDRESS'], process.env['NAPCAT_PROXY_PORT']);
|
||||
} else if (process.env['NAPCAT_PROXY_CLOSE']) {
|
||||
session.getMSFService().setNetworkProxy({
|
||||
userName: '',
|
||||
userPwd: '',
|
||||
address: '',
|
||||
port: 0,
|
||||
proxyType: 0,
|
||||
domain: '',
|
||||
isSocket: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForNetworkConnection (loginService: NodeIKernelLoginService, logger: LogWrapper) {
|
||||
let network_ok = false;
|
||||
let _tryCount = 0;
|
||||
while (!network_ok) {
|
||||
network_ok = loginService.getMsfStatus() !== 3;// win 11 0连接 1未连接
|
||||
logger.log('等待网络连接...');
|
||||
await sleep(500);
|
||||
_tryCount++;
|
||||
}
|
||||
logger.log('网络已连接');
|
||||
return network_ok;
|
||||
}
|
||||
|
||||
export async function NCoreInitShell () {
|
||||
console.log('NapCat Shell App Loading...');
|
||||
const pathWrapper = new NapCatPathWrapper();
|
||||
const logger = new LogWrapper(pathWrapper.logsPath);
|
||||
if (process.env['NAPCAT_WORKER_PROCESS'] !== '1' && process.env['NAPCAT_DISABLE_MULTI_PROCESS'] !== '1' && process.env['NAPCAT_DISABLE_MULTIPROCESSING'] !== '1') {
|
||||
logger.setFileLogEnabled(false);
|
||||
}
|
||||
handleUncaughtExceptions(logger);
|
||||
await applyPendingUpdates(pathWrapper, logger);
|
||||
|
||||
// 提前初始化 Native 模块(在登录前加载)
|
||||
const basicInfoWrapper = new QQBasicInfoWrapper({ logger });
|
||||
const nativePacketHandler = new NativePacketHandler({ logger });
|
||||
const napi2nativeLoader = new Napi2NativeLoader({ logger });
|
||||
|
||||
// 初始化 FFmpeg 服务
|
||||
await FFmpegService.init(pathWrapper.binaryPath, logger);
|
||||
|
||||
if (!(process.env['NAPCAT_DISABLE_PIPE'] === '1' || process.env['NAPCAT_WORKER_PROCESS'] === '1')) {
|
||||
await connectToNamedPipe(logger).catch(e => logger.logError('命名管道连接失败', e));
|
||||
}
|
||||
const wrapper = loadQQWrapper(basicInfoWrapper.QQMainPath, basicInfoWrapper.getFullQQVersion());
|
||||
// wrapper.node 加载后再初始化 hook,按 schema 读取配置
|
||||
const napcatConfig = loadNapcatConfig(pathWrapper.configPath);
|
||||
await nativePacketHandler.init(basicInfoWrapper.getFullQQVersion(), napcatConfig.o3HookMode === 1);
|
||||
|
||||
// 登录前监听 OidbSvcTrpcTcp.0xcde_2 数据包,获取数据库 passphrase
|
||||
let dbPassphrase: string | undefined;
|
||||
nativePacketHandler.onCmd('OidbSvcTrpcTcp.0xcde_2', ({ type, hex_data }) => {
|
||||
if (type !== 1) return; // 仅处理接收方向
|
||||
try {
|
||||
const raw = Buffer.from(hex_data, 'hex');
|
||||
const base = new NapProtoMsg(OidbSvcTrpcTcpBase).decode(raw);
|
||||
if (base.body && base.body.length > 0) {
|
||||
const body = new NapProtoMsg(OidbSvcTrpcTcp0XCDE_2RespBody).decode(base.body);
|
||||
if (body.inner?.value) {
|
||||
dbPassphrase = body.inner.value;
|
||||
logger.log('[NapCat] 已启用数据库辅助支持能力');
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
logger.logError('[NapCat] [0xCDE_2] 解析失败:', e);
|
||||
}
|
||||
});
|
||||
|
||||
if (process.env['NAPCAT_ENABLE_VERBOSE_LOG'] === '1') {
|
||||
napi2nativeLoader.nativeExports.setVerbose?.(true);
|
||||
}
|
||||
// wrapper.node 加载后立刻启用 Bypass(可通过环境变量禁用)
|
||||
if (process.env['NAPCAT_DISABLE_BYPASS'] !== '1') {
|
||||
const bypassOptions = napcatConfig.bypass ?? {};
|
||||
napi2nativeLoader.nativeExports.enableAllBypasses?.(bypassOptions);
|
||||
} else {
|
||||
logger.log('[NapCat] Napi2NativeLoader: Bypass已通过环境变量禁用');
|
||||
}
|
||||
|
||||
const o3Service = wrapper.NodeIO3MiscService.get();
|
||||
o3Service.addO3MiscListener(new NodeIO3MiscListener());
|
||||
|
||||
logger.log('[NapCat] [Core] NapCat.Core Version: ' + napCatVersion);
|
||||
WebUiDataRuntime.setWorkingEnv(NapCatCoreWorkingEnv.Shell);
|
||||
InitWebUi(logger, pathWrapper, logSubscription, statusHelperSubscription).then().catch(e => logger.logError(e));
|
||||
const engine = wrapper.NodeIQQNTWrapperEngine.get();
|
||||
const loginService = wrapper.NodeIKernelLoginService.get();
|
||||
let session: NodeIQQNTWrapperSession;
|
||||
let startupSession: NodeIQQNTStartupSessionWrapper | null = null;
|
||||
try {
|
||||
startupSession = wrapper.NodeIQQNTStartupSessionWrapper.create();
|
||||
session = wrapper.NodeIQQNTWrapperSession.getNTWrapperSession('nt_1');
|
||||
} catch (e: unknown) {
|
||||
try {
|
||||
session = wrapper.NodeIQQNTWrapperSession.create();
|
||||
} catch (error) {
|
||||
logger.logError('创建 StartupSession 失败', e);
|
||||
logger.logError('创建 Session 失败', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
const [dataPath, dataPathGlobal] = getDataPaths(wrapper);
|
||||
migrateLegacyGlobalPath(dataPath, dataPathGlobal, logger);
|
||||
WebUiDataRuntime.setQQDataPath(dataPath);
|
||||
const systemPlatform = getPlatformType();
|
||||
|
||||
if (!basicInfoWrapper.QQVersionAppid || !basicInfoWrapper.QQVersionQua) throw new Error('QQVersionAppid or QQVersionQua is not defined');
|
||||
|
||||
await initializeEngine(engine, basicInfoWrapper, dataPathGlobal, systemPlatform, systemVersion);
|
||||
await initializeLoginService(loginService, basicInfoWrapper, dataPathGlobal, systemVersion, hostname);
|
||||
handleProxy(session, logger);
|
||||
|
||||
let quickLoginUin: string | undefined;
|
||||
try {
|
||||
const args = process.argv;
|
||||
const qIndex = args.findIndex(arg => arg === '-q' || arg === '--qq');
|
||||
if (qIndex !== -1 && qIndex + 1 < args.length) {
|
||||
quickLoginUin = args[qIndex + 1];
|
||||
}
|
||||
} catch (error) {
|
||||
logger.logWarn('解析命令行参数失败,无法使用快速登录功能', error);
|
||||
}
|
||||
|
||||
const historyLoginList = (await loginService.getLoginList()).LocalLoginInfoList;
|
||||
|
||||
const dataTimestape = new Date().getTime().toString();
|
||||
o3Service.reportAmgomWeather('login', 'a1', [dataTimestape, '0', '0']);
|
||||
|
||||
const selfInfo = await handleLogin(loginService, logger, pathWrapper, quickLoginUin, historyLoginList);
|
||||
|
||||
// 登录成功后通知 Master 进程(用于切换崩溃重试策略)
|
||||
if (typeof process.send === 'function') {
|
||||
process.send({ type: 'login-success' });
|
||||
logger.log('[NapCat] 已通知主进程登录成功');
|
||||
}
|
||||
|
||||
const amgomDataPiece = 'eb1fd6ac257461580dc7438eb099f23aae04ca679f4d88f53072dc56e3bb1129';
|
||||
o3Service.setAmgomDataPiece(basicInfoWrapper.QQVersionAppid, new Uint8Array(Buffer.from(amgomDataPiece, 'hex')));
|
||||
|
||||
let guid = loginService.getMachineGuid();
|
||||
guid = guid.slice(0, 8) + '-' + guid.slice(8, 12) + '-' + guid.slice(12, 16) + '-' + guid.slice(16, 20) + '-' + guid.slice(20);
|
||||
o3Service.reportAmgomWeather('login', 'a6', [dataTimestape, '184', '329']);
|
||||
|
||||
const sessionConfig = await genSessionConfig(
|
||||
guid,
|
||||
basicInfoWrapper.QQVersionAppid,
|
||||
basicInfoWrapper.getFullQQVersion(),
|
||||
selfInfo.uin,
|
||||
selfInfo.uid,
|
||||
dataPath
|
||||
);
|
||||
|
||||
await initializeSession(session, sessionConfig, startupSession);
|
||||
|
||||
const accountDataPath = path.resolve(dataPath, './NapCat/data');
|
||||
// 判断dataPath是否为根目录 或者 D:/ 之类的盘目录
|
||||
if (dataPath !== '/' && /^[a-zA-Z]:\\$/.test(dataPath) === false) {
|
||||
try {
|
||||
fs.mkdirSync(accountDataPath, { recursive: true });
|
||||
} catch (error) {
|
||||
logger.logError('创建accountDataPath失败', error);
|
||||
}
|
||||
}
|
||||
|
||||
logger.logDebug('本账号数据/缓存目录:', accountDataPath);
|
||||
|
||||
const shell = new NapCatShell(
|
||||
wrapper,
|
||||
session,
|
||||
logger,
|
||||
selfInfo,
|
||||
basicInfoWrapper,
|
||||
pathWrapper,
|
||||
nativePacketHandler,
|
||||
napi2nativeLoader
|
||||
);
|
||||
// 将捕获的 passphrase 设置到 core 上,供 DatabaseApi 使用
|
||||
if (dbPassphrase) {
|
||||
shell.core.dbPassphrase = dbPassphrase;
|
||||
}
|
||||
await shell.InitNapCat();
|
||||
}
|
||||
|
||||
export class NapCatShell {
|
||||
readonly core: NapCatCore;
|
||||
readonly context: InstanceContext;
|
||||
|
||||
constructor (
|
||||
wrapper: WrapperNodeApi,
|
||||
session: NodeIQQNTWrapperSession,
|
||||
logger: LogWrapper,
|
||||
selfInfo: SelfInfo,
|
||||
basicInfoWrapper: QQBasicInfoWrapper,
|
||||
pathWrapper: NapCatPathWrapper,
|
||||
packetHandler: NativePacketHandler,
|
||||
napi2nativeLoader: Napi2NativeLoader
|
||||
) {
|
||||
this.context = {
|
||||
packetHandler,
|
||||
napi2nativeLoader,
|
||||
workingEnv: NapCatCoreWorkingEnv.Shell,
|
||||
wrapper,
|
||||
session,
|
||||
logger,
|
||||
basicInfoWrapper,
|
||||
pathWrapper,
|
||||
};
|
||||
this.core = new NapCatCore(this.context, selfInfo);
|
||||
}
|
||||
|
||||
async InitNapCat () {
|
||||
await this.core.initCore();
|
||||
|
||||
// // 测试 DatabaseApi
|
||||
// if (this.core.dbPassphrase) {
|
||||
// const dbApi = this.core.apis.DatabaseApi;
|
||||
// const sqliteOk = await dbApi.isSqliteAvailable();
|
||||
// if (sqliteOk) {
|
||||
// try {
|
||||
// const result = dbApi.readDatabase('nt_msg.db');
|
||||
// if (result) {
|
||||
// this.context.logger.log('[NapCat] [Database] nt_msg.db:\n' + dbApi.formatResults([result]));
|
||||
// }
|
||||
// } catch (e) {
|
||||
// this.context.logger.logError('[NapCat] [Database] 读取失败:', e);
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
// 监听下线并同步到 WebUI
|
||||
// KickedOffLine:账号被踢下线;SelfOffline:静默离线(服务端会话语义失效但无通知)。
|
||||
// 两者复用同一条恢复链路:同步 WebUI -> 重启 Worker -> 快速登录。
|
||||
const handleOffline = createOfflineRecovery({
|
||||
isOnline: () => this.core.selfInfo.online === true,
|
||||
onOffline: (tips) => {
|
||||
WebUiDataRuntime.setQQLoginStatus(false);
|
||||
// 下线后的二维码已失效,绝不能继续交给页面展示或扫码。
|
||||
WebUiDataRuntime.setQQLoginQrcodeURL('');
|
||||
WebUiDataRuntime.setQQLoginError(tips);
|
||||
WebUiDataRuntime.setQQLoginPhase('reconnecting');
|
||||
},
|
||||
onRecovered: () => {
|
||||
WebUiDataRuntime.setQQLoginStatus(true);
|
||||
WebUiDataRuntime.setQQLoginError('');
|
||||
WebUiDataRuntime.setQQLoginPhase('ready');
|
||||
},
|
||||
restart: async (tips) => {
|
||||
try {
|
||||
const restartResult = await WebUiDataRuntime.requestRestartProcess();
|
||||
this.context.logger.logWarn('[Core] [Login] 账号已离线,正在重启 Worker 以重新创建 QQ 登录服务', restartResult);
|
||||
if (restartResult.result) return true;
|
||||
WebUiDataRuntime.setQQLoginPhase('offline');
|
||||
WebUiDataRuntime.setQQLoginError(`${tips}\n登录服务重启失败:${restartResult.message}`);
|
||||
} catch (error) {
|
||||
WebUiDataRuntime.setQQLoginPhase('offline');
|
||||
WebUiDataRuntime.setQQLoginError(`${tips}\n登录服务重启失败:${(error as Error).message}`);
|
||||
}
|
||||
return false;
|
||||
},
|
||||
});
|
||||
this.core.event.on('KickedOffLine', (tips: string) => handleOffline(tips, false));
|
||||
this.core.event.on('SelfOffline', (tips: string) => handleOffline(tips, true));
|
||||
// 使用 NapCatAdapterManager 统一管理协议适配器
|
||||
const adapterManager = new NapCatAdapterManager(this.core, this.context, this.context.pathWrapper);
|
||||
await adapterManager.initAdapters()
|
||||
.catch(e => this.context.logger.logError('初始化协议适配器失败', e));
|
||||
// 注册 OneBot 适配器到 WebUiDataRuntime,供调试功能使用
|
||||
const oneBotAdapter = adapterManager.getOneBotAdapter();
|
||||
if (oneBotAdapter) {
|
||||
WebUiDataRuntime.setOneBotContext(oneBotAdapter);
|
||||
if (WebUiDataRuntime.getQQLoginStatus()) {
|
||||
WebUiDataRuntime.setQQLoginPhase('ready');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { getQQDataDirOverride } from 'napcat-common/src/path';
|
||||
import type { WrapperNodeApi } from 'napcat-core';
|
||||
|
||||
export function getDataPaths (wrapper: WrapperNodeApi): [string, string] {
|
||||
const platform = os.platform();
|
||||
let dataPath = getQQDataDirOverride();
|
||||
if (dataPath) {
|
||||
// 自定义目录必须在初始化内核前存在;创建失败时不能回退到桌面 QQ。
|
||||
fs.mkdirSync(dataPath, { recursive: true });
|
||||
} else if (platform === 'darwin') {
|
||||
dataPath = path.resolve(os.homedir(), './Library/Application Support/QQ');
|
||||
} else {
|
||||
dataPath = wrapper.NodeQQNTWrapperUtil.getNTUserDataInfoConfig();
|
||||
if (!dataPath) {
|
||||
dataPath = path.resolve(os.homedir(), './.config/QQ');
|
||||
fs.mkdirSync(dataPath, { recursive: true });
|
||||
}
|
||||
}
|
||||
// Linux/macOS: global 直接位于数据目录下;Windows: 位于 nt_qq 子目录下。
|
||||
// 此处不创建 global,以免阻止后续 Linux 旧目录迁移。
|
||||
const dataPathGlobal = platform === 'linux' || platform === 'darwin'
|
||||
? path.resolve(dataPath, 'global')
|
||||
: path.resolve(dataPath, 'nt_qq', 'global');
|
||||
return [dataPath, dataPathGlobal];
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
|
||||
export interface MigrationLogger {
|
||||
log: (message: string) => void;
|
||||
logError: (message: string, error?: unknown) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 旧版 NapCat 在 Linux 上把 global 目录放在 `<dataPath>/nt_qq/global`,
|
||||
* 新版对齐 QQ 官方 Linux 客户端改为 `<dataPath>/global`。
|
||||
*
|
||||
* 仅当新目录不存在、且旧目录存在时搬运一次:
|
||||
* - 服务器仅跑 NapCat 的用户通常只有 `nt_qq/`, 迁移后即可复用原有登录凭据,
|
||||
* 避免重新扫码以及出现两套 login.db / 两个 GUID;
|
||||
* - 若 `QQ/global` 已存在(例如用户同时装了自用 QQ),则保持原样, 不覆盖也不改动,
|
||||
* 避免覆盖两份不同的设备身份, 同时天然保证重复启动时不会二次迁移;
|
||||
* - 两者都不存在时什么都不做, QQ 会在新路径自行重建。
|
||||
*
|
||||
* @param platform 目标平台, 便于测试注入
|
||||
* @returns 是否执行了迁移
|
||||
*/
|
||||
export function migrateLegacyGlobalPath (
|
||||
dataPath: string,
|
||||
dataPathGlobal: string,
|
||||
logger: MigrationLogger,
|
||||
platform: NodeJS.Platform = os.platform()
|
||||
): boolean {
|
||||
if (platform !== 'linux') return false;
|
||||
const legacyGlobalPath = path.resolve(dataPath, './nt_qq/global');
|
||||
if (legacyGlobalPath === dataPathGlobal) return false;
|
||||
// 新目录已存在: 不覆盖、不合并
|
||||
if (fs.existsSync(dataPathGlobal)) return false;
|
||||
// 没有旧数据: QQ 启动时会自行重建
|
||||
if (!fs.existsSync(legacyGlobalPath)) return false;
|
||||
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(dataPathGlobal), { recursive: true });
|
||||
try {
|
||||
fs.renameSync(legacyGlobalPath, dataPathGlobal);
|
||||
} catch {
|
||||
// 跨文件系统时 rename 会失败, 退化为递归复制后删除旧目录
|
||||
fs.cpSync(legacyGlobalPath, dataPathGlobal, { recursive: true });
|
||||
fs.rmSync(legacyGlobalPath, { recursive: true, force: true });
|
||||
}
|
||||
logger.log(`[NapCat] [Core] 已迁移 Linux global 目录: ${legacyGlobalPath} -> ${dataPathGlobal}`);
|
||||
return true;
|
||||
} catch (e) {
|
||||
logger.logError('[NapCat] [Core] 迁移 Linux global 目录失败, 将在新路径重建设备信息:', e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,448 @@
|
||||
import { NCoreInitShell } from './base';
|
||||
import { NapCatPathWrapper } from '@/napcat-common/src/path';
|
||||
import { LogWrapper } from '@/napcat-core/helper/log';
|
||||
import { connectToNamedPipe } from './pipe';
|
||||
import { WebUiDataRuntime } from '@/napcat-webui-backend/src/helper/Data';
|
||||
import { AuthHelper } from '@/napcat-webui-backend/src/helper/SignToken';
|
||||
import { webUiRuntimePort } from '@/napcat-webui-backend/index';
|
||||
import { createProcessManager, type IProcessManager, type IWorkerProcess } from './process-api';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const envPath = path.join(__dirname, 'config', '.env');
|
||||
if (fs.existsSync(envPath)) {
|
||||
try {
|
||||
const data = fs.readFileSync(envPath, 'utf8');
|
||||
data.split(/\r?\n/).forEach(line => {
|
||||
line = line.trim();
|
||||
if (line && !line.startsWith('#')) {
|
||||
const parts = line.split('=');
|
||||
const key = parts[0]?.trim();
|
||||
const value = parts.slice(1).join('=').trim();
|
||||
if (key && value) {
|
||||
process.env[key] = value;
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
console.log('[NapCat] Failed to load .env file:', e);
|
||||
}
|
||||
}
|
||||
const pathWrapper = new NapCatPathWrapper();
|
||||
|
||||
// 环境变量配置
|
||||
const ENV = {
|
||||
isWorkerProcess: process.env['NAPCAT_WORKER_PROCESS'] === '1',
|
||||
isMultiProcessDisabled: process.env['NAPCAT_DISABLE_MULTI_PROCESS'] === '1' || process.env['NAPCAT_DISABLE_MULTIPROCESSING'] === '1',
|
||||
isPipeDisabled: process.env['NAPCAT_DISABLE_PIPE'] === '1',
|
||||
} as const;
|
||||
|
||||
// Worker 消息类型
|
||||
interface WorkerMessage {
|
||||
type: 'restart' | 'restart-prepare' | 'shutdown' | 'login-success';
|
||||
secretKey?: string;
|
||||
port?: number;
|
||||
}
|
||||
|
||||
const logger = new LogWrapper(pathWrapper.logsPath);
|
||||
if (!ENV.isWorkerProcess && !ENV.isMultiProcessDisabled) {
|
||||
logger.setFileLogEnabled(false);
|
||||
}
|
||||
|
||||
// 进程管理器和当前 Worker 进程引用
|
||||
let processManager: IProcessManager | null = null;
|
||||
let currentWorker: IWorkerProcess | null = null;
|
||||
let isElectron = false;
|
||||
let isRestarting = false;
|
||||
let isShuttingDown = false;
|
||||
|
||||
// 进程崩溃保护:记录最近的异常退出时间戳
|
||||
const recentCrashTimestamps: number[] = [];
|
||||
const CRASH_TIME_WINDOW = 10000; // 10秒时间窗口
|
||||
const MAX_CRASHES_IN_WINDOW = 3; // 最大崩溃次数
|
||||
|
||||
/**
|
||||
* 获取进程类型名称(用于日志)
|
||||
*/
|
||||
function getProcessTypeName (): string {
|
||||
return isElectron ? 'UtilityProcess' : 'Fork';
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 Worker 脚本路径
|
||||
*/
|
||||
function getWorkerScriptPath (): string {
|
||||
return __filename.endsWith('.mjs')
|
||||
? path.join(__dirname, 'napcat.mjs')
|
||||
: path.join(__dirname, 'napcat.js');
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查进程是否存在
|
||||
*/
|
||||
function isProcessAlive (pid: number): boolean {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 强制终止进程
|
||||
*/
|
||||
function forceKillProcess (pid: number): void {
|
||||
try {
|
||||
process.kill(pid, 'SIGKILL');
|
||||
} catch (error) {
|
||||
// SIGKILL 失败,在 Windows 上使用 taskkill 兜底
|
||||
if (process.platform === 'win32') {
|
||||
try {
|
||||
require('child_process').execSync(`taskkill /F /PID ${pid}`, { stdio: 'ignore' });
|
||||
} catch {
|
||||
logger.logError(`[NapCat] [Process] 强制终止进程失败: PID ${pid}`);
|
||||
}
|
||||
} else {
|
||||
logger.logError('[NapCat] [Process] 强制终止进程失败:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理进程树中的残留子进程(Electron 模式专用)
|
||||
* 排除当前主进程和新 worker 进程
|
||||
*/
|
||||
async function cleanupOrphanedProcesses (excludePids: number[]): Promise<void> {
|
||||
if (!isElectron) return;
|
||||
|
||||
try {
|
||||
// 使用 Electron 的 app.getAppMetrics() 获取所有相关进程
|
||||
// @ts-ignore - electron 运行时存在但类型声明可能缺失
|
||||
const electron = await import('electron');
|
||||
if (electron.app && typeof electron.app.getAppMetrics === 'function') {
|
||||
const metrics = electron.app.getAppMetrics();
|
||||
const mainPid = process.pid;
|
||||
|
||||
for (const metric of metrics) {
|
||||
const pid = metric.pid;
|
||||
// 排除主进程、新 worker 进程和明确排除的 PID
|
||||
if (pid === mainPid || excludePids.includes(pid)) {
|
||||
continue;
|
||||
}
|
||||
// 尝试终止残留进程
|
||||
try {
|
||||
process.kill(pid, 'SIGTERM');
|
||||
logger.log(`[NapCat] [Process] 已清理残留进程: PID ${pid} (${metric.type})`);
|
||||
} catch {
|
||||
// 进程可能已经不存在,忽略错误
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Electron API 不可用或出错,静默忽略
|
||||
logger.logDebug?.('[NapCat] [Process] 清理残留进程时出错:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 重启 Worker 进程
|
||||
*/
|
||||
export async function restartWorker (secretKey?: string, port?: number): Promise<void> {
|
||||
isRestarting = true;
|
||||
|
||||
if (!currentWorker) {
|
||||
logger.logWarn('[NapCat] [Process] 没有运行中的Worker进程');
|
||||
await startWorker(false);
|
||||
isRestarting = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const workerPid = currentWorker.pid;
|
||||
|
||||
// 1. 通知旧进程准备重启(旧进程会自行退出)
|
||||
currentWorker.postMessage({ type: 'restart-prepare' });
|
||||
|
||||
// 2. 等待进程退出(最多 5 秒,给更多时间让进程自行清理)
|
||||
await new Promise<void>((resolve) => {
|
||||
const timeout = setTimeout(() => {
|
||||
logger.logWarn('[NapCat] [Process] Worker进程未在 5 秒内退出,尝试发送强制关闭信号');
|
||||
currentWorker?.postMessage({ type: 'shutdown' });
|
||||
|
||||
// 再等待 2 秒
|
||||
setTimeout(() => {
|
||||
logger.logWarn('[NapCat] [Process] Worker进程仍未退出,尝试 kill');
|
||||
currentWorker?.kill();
|
||||
resolve();
|
||||
}, 2000);
|
||||
}, 5000);
|
||||
|
||||
currentWorker?.once('exit', () => {
|
||||
clearTimeout(timeout);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
// 3. 二次确认进程是否真的被终止(兜底检查)
|
||||
if (workerPid && isProcessAlive(workerPid)) {
|
||||
logger.logWarn(`[NapCat] [Process] 进程 ${workerPid} 仍在运行,尝试强制杀掉`);
|
||||
forceKillProcess(workerPid);
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
if (isProcessAlive(workerPid)) {
|
||||
logger.logError(`[NapCat] [Process] 进程 ${workerPid} 无法终止,可能需要手动处理`);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 等待后启动新进程
|
||||
await new Promise(resolve => setTimeout(resolve, 3000));
|
||||
|
||||
// 5. 启动新进程(重启模式不传递快速登录参数,传递密钥和端口)
|
||||
await startWorker(false, secretKey, port);
|
||||
|
||||
// 6. Electron 模式下清理可能残留的子进程
|
||||
if (isElectron && currentWorker?.pid) {
|
||||
const excludePids = [process.pid, currentWorker.pid];
|
||||
await cleanupOrphanedProcesses(excludePids);
|
||||
}
|
||||
|
||||
isRestarting = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动 Worker 进程
|
||||
* @param passQuickLogin 是否传递快速登录参数,默认为 true,重启时为 false
|
||||
* @param secretKey WebUI JWT 密钥
|
||||
* @param preferredPort 优先使用的 WebUI 端口
|
||||
*/
|
||||
async function startWorker (passQuickLogin: boolean = true, secretKey?: string, preferredPort?: number): Promise<void> {
|
||||
if (!processManager) {
|
||||
throw new Error('进程管理器未初始化');
|
||||
}
|
||||
|
||||
const workerScript = getWorkerScriptPath();
|
||||
const processType = getProcessTypeName();
|
||||
|
||||
// 只在首次启动时传递 -q 或 --qq 参数给 worker 进程
|
||||
const workerArgs: string[] = [];
|
||||
if (passQuickLogin) {
|
||||
const args = process.argv.slice(2);
|
||||
const qIndex = args.findIndex(arg => arg === '-q' || arg === '--qq');
|
||||
if (qIndex !== -1 && qIndex + 1 < args.length) {
|
||||
const qFlag = args[qIndex];
|
||||
const qValue = args[qIndex + 1];
|
||||
if (qFlag && qValue) {
|
||||
workerArgs.push(qFlag, qValue);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const child = processManager.createWorker(workerScript, workerArgs, {
|
||||
env: {
|
||||
...process.env,
|
||||
NAPCAT_WORKER_PROCESS: '1',
|
||||
...(secretKey ? { NAPCAT_WEBUI_JWT_SECRET_KEY: secretKey } : {}),
|
||||
...(preferredPort ? { NAPCAT_WEBUI_PREFERRED_PORT: String(preferredPort) } : {}),
|
||||
},
|
||||
stdio: isElectron ? 'pipe' : ['inherit', 'pipe', 'pipe', 'ipc'],
|
||||
});
|
||||
|
||||
currentWorker = child;
|
||||
|
||||
// 监听标准输出(直接转发)
|
||||
if (child.stdout) {
|
||||
child.stdout.on('data', (data: Buffer) => {
|
||||
process.stdout.write(data);
|
||||
});
|
||||
}
|
||||
|
||||
// 监听标准错误(直接转发)
|
||||
if (child.stderr) {
|
||||
child.stderr.on('data', (data: Buffer) => {
|
||||
process.stderr.write(data);
|
||||
});
|
||||
}
|
||||
|
||||
// 监听子进程消息
|
||||
child.on('message', (msg: unknown) => {
|
||||
// 处理重启请求
|
||||
if (typeof msg === 'object' && msg !== null && 'type' in msg) {
|
||||
const message = msg as WorkerMessage;
|
||||
if (message.type === 'restart') {
|
||||
restartWorker(message.secretKey, message.port).catch(e => {
|
||||
logger.logError(`[NapCat] [${processType}] 重启Worker进程失败:`, e);
|
||||
});
|
||||
} else if (message.type === 'login-success') {
|
||||
logger.log(`[NapCat] [${processType}] Worker进程已登录成功,切换到正常重试策略`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 监听子进程退出
|
||||
child.on('exit', (code: unknown) => {
|
||||
const exitCode = typeof code === 'number' ? code : 0;
|
||||
if (exitCode !== 0) {
|
||||
logger.logError(`[NapCat] [${processType}] Worker进程退出,退出码: ${exitCode}`);
|
||||
}
|
||||
// 如果不是由于主动重启或关闭引起的退出,尝试自动重新拉起
|
||||
if (!isRestarting && !isShuttingDown) {
|
||||
const now = Date.now();
|
||||
|
||||
// 清理超出时间窗口的崩溃记录
|
||||
while (recentCrashTimestamps.length > 0 && now - recentCrashTimestamps[0]! > CRASH_TIME_WINDOW) {
|
||||
recentCrashTimestamps.shift();
|
||||
}
|
||||
|
||||
// 记录本次崩溃
|
||||
recentCrashTimestamps.push(now);
|
||||
|
||||
if (recentCrashTimestamps.length >= MAX_CRASHES_IN_WINDOW) {
|
||||
logger.logError(`[NapCat] [${processType}] Worker进程在 ${CRASH_TIME_WINDOW / 1000} 秒内异常退出 ${MAX_CRASHES_IN_WINDOW} 次,主进程退出`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
logger.logWarn(`[NapCat] [${processType}] Worker进程意外退出 (${recentCrashTimestamps.length}/${MAX_CRASHES_IN_WINDOW}),正在尝试重新拉起...`);
|
||||
|
||||
startWorker(true).catch(e => {
|
||||
logger.logError(`[NapCat] [${processType}] 重新拉起Worker进程失败:`, e);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 等待进程成功 spawn
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const onSpawn = () => {
|
||||
child.off('error', onError);
|
||||
resolve();
|
||||
};
|
||||
const onError = (...args: unknown[]) => {
|
||||
const err = args[0] as Error;
|
||||
logger.logError(`[NapCat] [${processType}] Worker进程启动失败:`, err);
|
||||
child.off('spawn', onSpawn);
|
||||
reject(err);
|
||||
};
|
||||
child.once('spawn', onSpawn);
|
||||
child.once('error', onError);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动 Master 进程
|
||||
*/
|
||||
async function startMasterProcess (): Promise<void> {
|
||||
// 连接命名管道(可通过环境变量禁用)
|
||||
if (!ENV.isPipeDisabled) {
|
||||
await connectToNamedPipe(logger).catch(e =>
|
||||
logger.logError('命名管道连接失败', e)
|
||||
);
|
||||
}
|
||||
|
||||
// 启动 Worker 进程
|
||||
await startWorker();
|
||||
|
||||
// 优雅关闭处理
|
||||
const shutdown = () => {
|
||||
isShuttingDown = true;
|
||||
if (currentWorker) {
|
||||
currentWorker.postMessage({ type: 'shutdown' });
|
||||
setTimeout(() => {
|
||||
currentWorker?.kill();
|
||||
process.exit(0);
|
||||
}, 1000);
|
||||
} else {
|
||||
process.exit(0);
|
||||
}
|
||||
};
|
||||
|
||||
process.on('SIGINT', () => shutdown());
|
||||
process.on('SIGTERM', () => shutdown());
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动 Worker 进程(子进程入口)
|
||||
*/
|
||||
async function startWorkerProcess (): Promise<void> {
|
||||
if (!processManager) {
|
||||
throw new Error('进程管理器未初始化');
|
||||
}
|
||||
|
||||
// 预加载 Node Addon(如果设置了环境变量)
|
||||
const preloadAddonPath = process.env['NAPCAT_PRELOAD_NODE_ADDON_PATH'];
|
||||
if (preloadAddonPath) {
|
||||
try {
|
||||
const os = await import('os');
|
||||
process.dlopen({ exports: {} }, preloadAddonPath, os.constants.dlopen.RTLD_NOW | os.constants.dlopen.RTLD_GLOBAL);
|
||||
logger.log(`[NapCat] [Worker] 已预加载 Node Addon: ${preloadAddonPath}`);
|
||||
} catch (error) {
|
||||
logger.logError(`[NapCat] [Worker] 预加载 Node Addon 失败: ${preloadAddonPath}`, error);
|
||||
}
|
||||
}
|
||||
|
||||
// 监听来自父进程的消息
|
||||
processManager.onParentMessage((msg: unknown) => {
|
||||
if (typeof msg === 'object' && msg !== null && 'type' in msg) {
|
||||
if (msg.type === 'restart-prepare' || msg.type === 'shutdown') {
|
||||
setTimeout(() => {
|
||||
process.exit(0);
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 注册重启进程函数到 WebUI
|
||||
WebUiDataRuntime.setRestartProcessCall(async () => {
|
||||
try {
|
||||
const success = processManager!.sendToParent({
|
||||
type: 'restart',
|
||||
secretKey: AuthHelper.getSecretKey(),
|
||||
port: webUiRuntimePort,
|
||||
});
|
||||
|
||||
if (success) {
|
||||
return { result: true, message: '进程重启请求已发送' };
|
||||
} else {
|
||||
return { result: false, message: '无法与主进程通信' };
|
||||
}
|
||||
} catch (e) {
|
||||
logger.logError('[NapCat] [Process] 发送重启请求失败:', e);
|
||||
return {
|
||||
result: false,
|
||||
message: '发送重启请求失败: ' + (e as Error).message,
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// 启动 NapCat 核心
|
||||
await NCoreInitShell();
|
||||
}
|
||||
|
||||
/**
|
||||
* 主入口
|
||||
*/
|
||||
async function main (): Promise<void> {
|
||||
// 单进程模式:直接启动核心
|
||||
if (ENV.isMultiProcessDisabled) {
|
||||
await NCoreInitShell();
|
||||
return;
|
||||
}
|
||||
|
||||
// 多进程模式:初始化进程管理器
|
||||
const result = await createProcessManager();
|
||||
processManager = result.manager;
|
||||
isElectron = result.isElectron;
|
||||
|
||||
// 根据进程类型启动
|
||||
if (ENV.isWorkerProcess) {
|
||||
await startWorkerProcess();
|
||||
} else {
|
||||
await startMasterProcess();
|
||||
}
|
||||
}
|
||||
|
||||
// 启动应用
|
||||
main().catch((e: Error) => {
|
||||
logger.logError('[NapCat] [Process] 启动失败:', e);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"name": "napcat-shell",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"build:dev": "vite build --mode development",
|
||||
"typecheck": "tsc --noEmit --skipLibCheck -p tsconfig.json"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./index.ts"
|
||||
},
|
||||
"./*": {
|
||||
"import": "./*"
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"napcat-core": "workspace:*",
|
||||
"napcat-common": "workspace:*",
|
||||
"napcat-adapter": "workspace:*",
|
||||
"napcat-webui-backend": "workspace:*",
|
||||
"napcat-qrcode": "workspace:*",
|
||||
"napcat-protobuf": "workspace:*",
|
||||
"napcat-database": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"json5": "^2.2.3",
|
||||
"@types/node": "^22.0.1",
|
||||
"napcat-vite": "workspace:*"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import { LogWrapper } from 'napcat-core/helper/log';
|
||||
import * as net from 'net';
|
||||
import * as process from 'process';
|
||||
import { Writable } from 'stream';
|
||||
|
||||
/**
|
||||
* 连接到命名管道并重定向stdout
|
||||
* @param logger 日志记录器
|
||||
* @param timeoutMs 连接超时时间(毫秒),默认5000ms
|
||||
* @returns Promise,连接成功时resolve,失败时reject
|
||||
*/
|
||||
export function connectToNamedPipe (logger: LogWrapper, timeoutMs: number = 5000): Promise<{ disconnect: () => void }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (process.platform !== 'win32') {
|
||||
// 非Windows平台不reject,而是返回一个空的disconnect函数
|
||||
return resolve({ disconnect: () => { } });
|
||||
}
|
||||
|
||||
const pid = process.pid;
|
||||
const pipePath = `\\\\.\\pipe\\NapCat_${pid}`;
|
||||
|
||||
// 设置连接超时
|
||||
const timeoutId = setTimeout(() => {
|
||||
reject(new Error(`连接命名管道超时: ${pipePath}`));
|
||||
}, timeoutMs);
|
||||
|
||||
try {
|
||||
const originalStdoutWrite = process.stdout.write.bind(process.stdout);
|
||||
const pipeSocket = net.connect(pipePath, () => {
|
||||
// 清除超时
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
// 优化网络性能设置
|
||||
pipeSocket.setNoDelay(true); // 减少延迟
|
||||
|
||||
// 设置更高的高水位线,允许更多数据缓冲
|
||||
|
||||
logger.log(`[StdOut] 已重定向到命名管道: ${pipePath}`);
|
||||
|
||||
// 创建拥有更优雅背压处理的 Writable 流
|
||||
const pipeWritable = new Writable({
|
||||
highWaterMark: 1024 * 64, // 64KB 高水位线
|
||||
write (chunk, encoding, callback) {
|
||||
if (!pipeSocket.writable) {
|
||||
// 如果管道不可写,退回到原始stdout
|
||||
logger.log('[StdOut] 管道不可写,回退到控制台输出');
|
||||
return originalStdoutWrite(chunk, encoding, callback);
|
||||
}
|
||||
|
||||
// 尝试写入数据到管道
|
||||
const canContinue = pipeSocket.write(chunk, encoding, () => {
|
||||
// 数据已被发送或放入内部缓冲区
|
||||
});
|
||||
|
||||
if (canContinue) {
|
||||
// 如果返回true,表示可以继续写入更多数据
|
||||
// 立即通知写入流可以继续
|
||||
process.nextTick(callback);
|
||||
} else {
|
||||
// 如果返回false,表示内部缓冲区已满
|
||||
// 等待drain事件再恢复写入
|
||||
pipeSocket.once('drain', () => {
|
||||
callback();
|
||||
});
|
||||
}
|
||||
// 明确返回true,表示写入已处理
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
// 重定向stdout
|
||||
process.stdout.write = (
|
||||
chunk: any,
|
||||
encoding?: BufferEncoding | (() => void),
|
||||
cb?: () => void
|
||||
): boolean => {
|
||||
if (typeof encoding === 'function') {
|
||||
cb = encoding;
|
||||
encoding = undefined;
|
||||
}
|
||||
|
||||
// 使用优化的writable流处理写入
|
||||
return pipeWritable.write(chunk, encoding as BufferEncoding, cb as () => void);
|
||||
};
|
||||
|
||||
// 提供断开连接的方法
|
||||
const disconnect = () => {
|
||||
process.stdout.write = originalStdoutWrite;
|
||||
pipeSocket.end();
|
||||
logger.log(`已手动断开命名管道连接: ${pipePath}`);
|
||||
};
|
||||
|
||||
// 返回成功和断开连接的方法
|
||||
resolve({ disconnect });
|
||||
});
|
||||
|
||||
// 管道错误处理
|
||||
pipeSocket.on('error', (err) => {
|
||||
clearTimeout(timeoutId);
|
||||
process.stdout.write = originalStdoutWrite;
|
||||
logger.log(`连接命名管道 ${pipePath} 时出错:`, err);
|
||||
reject(err);
|
||||
});
|
||||
|
||||
// 管道关闭处理
|
||||
pipeSocket.on('end', () => {
|
||||
process.stdout.write = originalStdoutWrite;
|
||||
logger.log('命名管道连接已关闭');
|
||||
});
|
||||
|
||||
// 确保在连接意外关闭时恢复stdout
|
||||
pipeSocket.on('close', () => {
|
||||
process.stdout.write = originalStdoutWrite;
|
||||
logger.log('命名管道连接已关闭');
|
||||
});
|
||||
} catch (error) {
|
||||
clearTimeout(timeoutId);
|
||||
logger.log(`尝试连接命名管道 ${pipePath} 时发生异常:`, error);
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
import type { Readable } from 'stream';
|
||||
import type { fork as forkType } from 'child_process';
|
||||
|
||||
// 扩展 Process 类型以支持 parentPort
|
||||
declare global {
|
||||
namespace NodeJS {
|
||||
interface Process {
|
||||
parentPort?: {
|
||||
on (event: 'message', listener: (e: { data: unknown; }) => void): void;
|
||||
postMessage (message: unknown): void;
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一的进程接口
|
||||
*/
|
||||
export interface IWorkerProcess {
|
||||
readonly pid: number | undefined;
|
||||
readonly stdout: Readable | null;
|
||||
readonly stderr: Readable | null;
|
||||
|
||||
postMessage (message: unknown): void;
|
||||
kill (): boolean;
|
||||
on (event: string, listener: (...args: unknown[]) => void): void;
|
||||
once (event: string, listener: (...args: unknown[]) => void): void;
|
||||
off (event: string, listener: (...args: unknown[]) => void): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 进程创建选项
|
||||
*/
|
||||
export interface ProcessOptions {
|
||||
env: NodeJS.ProcessEnv;
|
||||
stdio: 'pipe' | 'ignore' | 'inherit' | Array<'pipe' | 'ignore' | 'inherit' | 'ipc'>;
|
||||
execArgv?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 进程管理器接口
|
||||
*/
|
||||
export interface IProcessManager {
|
||||
createWorker (modulePath: string, args: string[], options: ProcessOptions): IWorkerProcess;
|
||||
onParentMessage (handler: (message: unknown) => void): void;
|
||||
sendToParent (message: unknown): boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Electron utilityProcess 包装器
|
||||
*/
|
||||
class ElectronProcessManager implements IProcessManager {
|
||||
private utilityProcess: {
|
||||
fork (modulePath: string, args: string[], options: unknown): unknown;
|
||||
};
|
||||
|
||||
constructor (utilityProcess: { fork (modulePath: string, args: string[], options: unknown): unknown; }) {
|
||||
this.utilityProcess = utilityProcess;
|
||||
}
|
||||
|
||||
createWorker (modulePath: string, args: string[], options: ProcessOptions): IWorkerProcess {
|
||||
const child: any = this.utilityProcess.fork(modulePath, args, options);
|
||||
|
||||
return {
|
||||
get pid () { return child.pid as number | undefined; },
|
||||
stdout: child.stdout as Readable | null,
|
||||
stderr: child.stderr as Readable | null,
|
||||
|
||||
postMessage (message: unknown): void {
|
||||
child.postMessage(message);
|
||||
},
|
||||
|
||||
kill (): boolean {
|
||||
return child.kill() as boolean;
|
||||
},
|
||||
|
||||
on (event: string, listener: (...args: unknown[]) => void): void {
|
||||
child.on(event, listener);
|
||||
},
|
||||
|
||||
once (event: string, listener: (...args: unknown[]) => void): void {
|
||||
child.once(event, listener);
|
||||
},
|
||||
|
||||
off (event: string, listener: (...args: unknown[]) => void): void {
|
||||
child.off(event, listener);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
onParentMessage (handler: (message: unknown) => void): void {
|
||||
if (process.parentPort) {
|
||||
process.parentPort.on('message', (e: { data: unknown; }) => {
|
||||
handler(e.data);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
sendToParent (message: unknown): boolean {
|
||||
if (process.parentPort) {
|
||||
process.parentPort.postMessage(message);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Node.js child_process 包装器
|
||||
*/
|
||||
class NodeProcessManager implements IProcessManager {
|
||||
private forkFn: typeof forkType;
|
||||
|
||||
constructor (forkFn: typeof forkType) {
|
||||
this.forkFn = forkFn;
|
||||
}
|
||||
|
||||
createWorker (modulePath: string, args: string[], options: ProcessOptions): IWorkerProcess {
|
||||
let execArgv = options.execArgv;
|
||||
if (execArgv === undefined && process.versions['electron']) {
|
||||
// macOS QQ 的 Electron fork 会自动设置 ELECTRON_RUN_AS_NODE 并使用 QQ Helper,
|
||||
// 即使父进程没有该变量,子进程也只接受 Node 参数。
|
||||
const runsAsNode = process.platform === 'darwin' || options.env['ELECTRON_RUN_AS_NODE'] !== undefined;
|
||||
execArgv = runsAsNode
|
||||
? process.execArgv.filter(arg => arg !== '--no-sandbox')
|
||||
: ['--no-sandbox'];
|
||||
}
|
||||
const child = this.forkFn(modulePath, args, {
|
||||
...options,
|
||||
...(execArgv !== undefined ? { execArgv } : {}),
|
||||
} as any);
|
||||
|
||||
return {
|
||||
get pid () { return child.pid; },
|
||||
stdout: child.stdout,
|
||||
stderr: child.stderr,
|
||||
|
||||
postMessage (message: unknown): void {
|
||||
if (child.send) {
|
||||
child.send(message as any);
|
||||
}
|
||||
},
|
||||
|
||||
kill (): boolean {
|
||||
return child.kill();
|
||||
},
|
||||
|
||||
on (event: string, listener: (...args: unknown[]) => void): void {
|
||||
child.on(event, listener);
|
||||
},
|
||||
|
||||
once (event: string, listener: (...args: unknown[]) => void): void {
|
||||
child.once(event, listener);
|
||||
},
|
||||
|
||||
off (event: string, listener: (...args: unknown[]) => void): void {
|
||||
child.off(event, listener);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
onParentMessage (handler: (message: unknown) => void): void {
|
||||
process.on('message', (message: unknown) => {
|
||||
handler(message);
|
||||
});
|
||||
}
|
||||
|
||||
sendToParent (message: unknown): boolean {
|
||||
if (process.send) {
|
||||
process.send(message as any);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检测运行环境并创建对应的进程管理器
|
||||
*/
|
||||
export async function createProcessManager (): Promise<{
|
||||
manager: IProcessManager;
|
||||
isElectron: boolean;
|
||||
}> {
|
||||
// Electron 的 Node 模式仍保留 versions.electron,但没有 utilityProcess API。
|
||||
const isElectron = process.env['NAPCAT_FORCE_NODE_PROCESS'] !== '1' &&
|
||||
process.env['ELECTRON_RUN_AS_NODE'] === undefined &&
|
||||
typeof process.versions['electron'] !== 'undefined';
|
||||
|
||||
if (isElectron) {
|
||||
// @ts-ignore - electron 运行时存在但类型声明可能缺失
|
||||
const electron = await import('electron');
|
||||
if (electron.app && !electron.app.isReady()) {
|
||||
await electron.app.whenReady();
|
||||
}
|
||||
return {
|
||||
manager: new ElectronProcessManager(electron.utilityProcess),
|
||||
isElectron: true,
|
||||
};
|
||||
} else {
|
||||
const { fork } = await import('child_process');
|
||||
return {
|
||||
manager: new NodeProcessManager(fork),
|
||||
isElectron: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": ".."
|
||||
},
|
||||
"include": [
|
||||
"*.ts",
|
||||
"**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"dist"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import cp from 'vite-plugin-cp';
|
||||
import { defineConfig, PluginOption, UserConfig } from 'vite';
|
||||
import path, { resolve } from 'path';
|
||||
import nodeResolve from '@rollup/plugin-node-resolve';
|
||||
import { builtinModules } from 'module';
|
||||
import napcatVersion from 'napcat-vite/vite-plugin-version.js';
|
||||
import { autoIncludeTSPlugin } from 'napcat-vite/vite-auto-include.js';
|
||||
import react from '@vitejs/plugin-react-swc';
|
||||
|
||||
// 依赖排除
|
||||
const external = [
|
||||
'ws',
|
||||
'express',
|
||||
'electron',
|
||||
];
|
||||
|
||||
const nodeModules = [...builtinModules, builtinModules.map((m) => `node:${m}`), 'node:sqlite'].flat();
|
||||
const ShellBaseConfigPlugin: PluginOption[] = [
|
||||
react({ tsDecorators: true }),
|
||||
autoIncludeTSPlugin({
|
||||
entries: [
|
||||
{ entry: 'napcat.ts', dir: path.resolve(__dirname, '../napcat-core/protocol') },
|
||||
{ entry: 'napcat.ts', dir: path.resolve(__dirname, '../napcat-onebot/action/test') },
|
||||
],
|
||||
}),
|
||||
cp({
|
||||
targets: [
|
||||
{ src: '../napcat-native/', dest: 'dist/native', flatten: false },
|
||||
{ src: '../napcat-webui-frontend/dist/', dest: 'dist/static/', flatten: false },
|
||||
{ src: '../napcat-webui-backend/src/assets/sw_template.js', dest: 'dist/static/' },
|
||||
{ src: '../napcat-core/external/napcat.json', dest: 'dist/config/' },
|
||||
{ src: '../../package.json', dest: 'dist' },
|
||||
{ src: '../napcat-shell-loader', dest: 'dist' },
|
||||
],
|
||||
}),
|
||||
nodeResolve(),
|
||||
napcatVersion(),
|
||||
];
|
||||
const ShellBaseConfig = (source_map: boolean = false) =>
|
||||
defineConfig({
|
||||
resolve: {
|
||||
conditions: ['node', 'default'],
|
||||
alias: {
|
||||
'@/napcat-core': resolve(__dirname, '../napcat-core'),
|
||||
'@/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'),
|
||||
},
|
||||
},
|
||||
build: {
|
||||
sourcemap: source_map,
|
||||
target: 'esnext',
|
||||
minify: source_map ? false : 'esbuild',
|
||||
lib: {
|
||||
entry: {
|
||||
napcat: path.resolve(__dirname, 'napcat.ts'),
|
||||
'worker/conoutSocketWorker': path.resolve(__dirname, '../napcat-pty/worker/conoutSocketWorker.ts'),
|
||||
},
|
||||
formats: ['es'],
|
||||
fileName: (_, entryName) => `${entryName}.mjs`,
|
||||
},
|
||||
rollupOptions: {
|
||||
external: [...nodeModules, ...external],
|
||||
},
|
||||
},
|
||||
});
|
||||
export default defineConfig(({ mode }): UserConfig => {
|
||||
if (mode === 'development') {
|
||||
return {
|
||||
...ShellBaseConfig(true),
|
||||
plugins: [...ShellBaseConfigPlugin],
|
||||
};
|
||||
}
|
||||
return {
|
||||
...ShellBaseConfig(),
|
||||
plugins: [...ShellBaseConfigPlugin],
|
||||
};
|
||||
});
|
||||
Reference in new issue
Block a user