Initial clean project import
This commit is contained in:
@@ -0,0 +1,292 @@
|
||||
/**
|
||||
* Electron 防破解模块
|
||||
* 禁用开发者工具、快捷键、右键菜单
|
||||
*/
|
||||
|
||||
import { BrowserWindow, globalShortcut, Menu } from 'electron';
|
||||
import { isDev } from './env';
|
||||
|
||||
export class AntiDebugManager {
|
||||
private static instance: AntiDebugManager;
|
||||
private disabledShortcuts: string[] = [];
|
||||
private originalOpenDevTools: Map<BrowserWindow, Function> = new Map();
|
||||
private originalToggleDevTools: Map<BrowserWindow, Function> = new Map();
|
||||
|
||||
private constructor() {
|
||||
console.log('[AntiDebugManager] 初始化');
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取单例实例
|
||||
*/
|
||||
static getInstance(): AntiDebugManager {
|
||||
if (!AntiDebugManager.instance) {
|
||||
AntiDebugManager.instance = new AntiDebugManager();
|
||||
}
|
||||
return AntiDebugManager.instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* 禁用开发者工具
|
||||
* @param window 目标窗口
|
||||
*/
|
||||
disableDevTools(window: BrowserWindow): void {
|
||||
try {
|
||||
// 💾 保存原始方法,以便后续恢复
|
||||
this.originalOpenDevTools.set(window, window.webContents.openDevTools.bind(window.webContents));
|
||||
this.originalToggleDevTools.set(window, window.webContents.toggleDevTools.bind(window.webContents));
|
||||
|
||||
// 禁用 openDevTools 方法
|
||||
window.webContents.openDevTools = () => {
|
||||
console.warn('[AntiDebugManager] 尝试打开开发者工具被拦截');
|
||||
};
|
||||
|
||||
// 禁用 toggleDevTools 方法
|
||||
window.webContents.toggleDevTools = () => {
|
||||
console.warn('[AntiDebugManager] 尝试切换开发者工具被拦截');
|
||||
};
|
||||
|
||||
console.log('[AntiDebugManager] ✅ 已禁用开发者工具');
|
||||
} catch (error) {
|
||||
console.error('[AntiDebugManager] 禁用开发者工具失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 禁用快捷键
|
||||
* 包括:F12、Ctrl+Shift+I、Ctrl+Shift+C、Ctrl+Shift+J、Ctrl+I 等
|
||||
*/
|
||||
disableDebugShortcuts(): void {
|
||||
try {
|
||||
const shortcuts = [
|
||||
'F12', // 开发者工具
|
||||
'Ctrl+Shift+I', // 开发者工具 (Windows/Linux)
|
||||
'Cmd+Option+I', // 开发者工具 (macOS)
|
||||
'Ctrl+Shift+C', // 检查元素
|
||||
'Cmd+Option+C', // 检查元素 (macOS)
|
||||
'Ctrl+Shift+J', // 控制台
|
||||
'Cmd+Option+J', // 控制台 (macOS)
|
||||
'Ctrl+I', // 开发者工具 (某些浏览器)
|
||||
'Cmd+Option+U', // 查看源代码 (macOS)
|
||||
'Ctrl+U', // 查看源代码 (Windows/Linux)
|
||||
'Ctrl+Shift+K', // 控制台 (某些浏览器)
|
||||
'Ctrl+Shift+D', // 某些调试快捷键
|
||||
];
|
||||
|
||||
shortcuts.forEach(shortcut => {
|
||||
try {
|
||||
globalShortcut.register(shortcut, () => {
|
||||
console.warn(`[AntiDebugManager] 快捷键 "${shortcut}" 被拦截`);
|
||||
return true; // 阻止默认行为
|
||||
});
|
||||
this.disabledShortcuts.push(shortcut);
|
||||
} catch (error) {
|
||||
console.warn(`[AntiDebugManager] 注册快捷键 "${shortcut}" 失败:`, error);
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`[AntiDebugManager] ✅ 已禁用 ${this.disabledShortcuts.length} 个调试快捷键`);
|
||||
} catch (error) {
|
||||
console.error('[AntiDebugManager] 禁用快捷键失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 禁用右键菜单
|
||||
* @param window 目标窗口
|
||||
*/
|
||||
disableContextMenu(window: BrowserWindow): void {
|
||||
try {
|
||||
window.webContents.on('context-menu', (e) => {
|
||||
e.preventDefault();
|
||||
console.warn('[AntiDebugManager] 右键菜单被拦截');
|
||||
});
|
||||
|
||||
console.log('[AntiDebugManager] ✅ 已禁用右键菜单');
|
||||
} catch (error) {
|
||||
console.error('[AntiDebugManager] 禁用右键菜单失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 禁用鼠标右键
|
||||
* @param window 目标窗口
|
||||
*/
|
||||
disableMouseRightClick(window: BrowserWindow): void {
|
||||
try {
|
||||
window.webContents.on('before-input-event', (event, input) => {
|
||||
// 检查是否是鼠标右键(某些情况下)
|
||||
if (input.type === 'mouseUp' && input.button === 2) {
|
||||
event.preventDefault();
|
||||
}
|
||||
});
|
||||
|
||||
console.log('[AntiDebugManager] ✅ 已禁用鼠标右键');
|
||||
} catch (error) {
|
||||
console.error('[AntiDebugManager] 禁用鼠标右键失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 拦截快捷键(键盘事件)
|
||||
* @param window 目标窗口
|
||||
*/
|
||||
interceptDebugHotkeys(window: BrowserWindow): void {
|
||||
try {
|
||||
window.webContents.on('before-input-event', (event, input) => {
|
||||
// 拦截 F12
|
||||
if (input.key.toLowerCase() === 'f12') {
|
||||
event.preventDefault();
|
||||
console.warn('[AntiDebugManager] F12 被拦截');
|
||||
return;
|
||||
}
|
||||
|
||||
// 拦截 Ctrl+Shift+I (Windows/Linux)
|
||||
if (input.control && input.shift && input.key.toLowerCase() === 'i') {
|
||||
event.preventDefault();
|
||||
console.warn('[AntiDebugManager] Ctrl+Shift+I 被拦截');
|
||||
return;
|
||||
}
|
||||
|
||||
// 拦截 Ctrl+Shift+C (Windows/Linux)
|
||||
if (input.control && input.shift && input.key.toLowerCase() === 'c') {
|
||||
event.preventDefault();
|
||||
console.warn('[AntiDebugManager] Ctrl+Shift+C 被拦截');
|
||||
return;
|
||||
}
|
||||
|
||||
// 拦截 Ctrl+Shift+J (Windows/Linux)
|
||||
if (input.control && input.shift && input.key.toLowerCase() === 'j') {
|
||||
event.preventDefault();
|
||||
console.warn('[AntiDebugManager] Ctrl+Shift+J 被拦截');
|
||||
return;
|
||||
}
|
||||
|
||||
// 拦截 Cmd+Option+I (macOS)
|
||||
if (input.meta && input.alt && input.key.toLowerCase() === 'i') {
|
||||
event.preventDefault();
|
||||
console.warn('[AntiDebugManager] Cmd+Option+I 被拦截');
|
||||
return;
|
||||
}
|
||||
|
||||
// 拦截 Cmd+Option+C (macOS)
|
||||
if (input.meta && input.alt && input.key.toLowerCase() === 'c') {
|
||||
event.preventDefault();
|
||||
console.warn('[AntiDebugManager] Cmd+Option+C 被拦截');
|
||||
return;
|
||||
}
|
||||
|
||||
// 拦截 Ctrl+U (查看源代码)
|
||||
if (input.control && input.key.toLowerCase() === 'u') {
|
||||
event.preventDefault();
|
||||
console.warn('[AntiDebugManager] Ctrl+U 被拦截');
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
console.log('[AntiDebugManager] ✅ 已拦截调试快捷键');
|
||||
} catch (error) {
|
||||
console.error('[AntiDebugManager] 快捷键拦截失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 禁用菜单栏(可选)
|
||||
* @param window 目标窗口
|
||||
*/
|
||||
disableMenuBar(window: BrowserWindow): void {
|
||||
try {
|
||||
Menu.setApplicationMenu(null);
|
||||
console.log('[AntiDebugManager] ✅ 已禁用菜单栏');
|
||||
} catch (error) {
|
||||
console.error('[AntiDebugManager] 禁用菜单栏失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 完整的防破解方案
|
||||
* @param window 目标窗口
|
||||
* @param options 选项
|
||||
*/
|
||||
enableFullProtection(window: BrowserWindow, options: {
|
||||
disableDevTools?: boolean;
|
||||
disableShortcuts?: boolean;
|
||||
disableContextMenu?: boolean;
|
||||
disableMenuBar?: boolean;
|
||||
interceptHotkeys?: boolean;
|
||||
} = {}): void {
|
||||
const {
|
||||
disableDevTools = true,
|
||||
disableShortcuts = true,
|
||||
disableContextMenu = true,
|
||||
disableMenuBar = true,
|
||||
interceptHotkeys = true
|
||||
} = options;
|
||||
|
||||
console.log('[AntiDebugManager] 启用完整防破解保护...');
|
||||
console.log('[AntiDebugManager] 环境:', isDev ? '开发' : '生产');
|
||||
|
||||
if (disableDevTools) {
|
||||
this.disableDevTools(window);
|
||||
}
|
||||
|
||||
if (disableShortcuts) {
|
||||
this.disableDebugShortcuts();
|
||||
}
|
||||
|
||||
if (disableContextMenu) {
|
||||
this.disableContextMenu(window);
|
||||
}
|
||||
|
||||
if (disableMenuBar) {
|
||||
this.disableMenuBar(window);
|
||||
}
|
||||
|
||||
if (interceptHotkeys) {
|
||||
this.interceptDebugHotkeys(window);
|
||||
}
|
||||
|
||||
console.log('[AntiDebugManager] ✅ 防破解保护已启用');
|
||||
}
|
||||
|
||||
/**
|
||||
* 临时启用开发者工具(用于隐藏调试快捷键)
|
||||
* @param window 目标窗口
|
||||
*/
|
||||
restoreDevTools(window: BrowserWindow): void {
|
||||
try {
|
||||
const originalMethod = this.originalOpenDevTools.get(window);
|
||||
if (originalMethod && typeof originalMethod === 'function') {
|
||||
console.log('[AntiDebugManager] 使用保存的原始 openDevTools 方法打开 DevTools');
|
||||
// 调用保存的原始方法
|
||||
originalMethod({ mode: 'detach', activate: true });
|
||||
console.log('[AntiDebugManager] ✅ DevTools 已通过原始方法打开');
|
||||
} else {
|
||||
console.warn('[AntiDebugManager] 未找到保存的原始方法,尝试直接调用');
|
||||
// 备选方案:直接调用(可能被拦截,但尽量尝试)
|
||||
window.webContents.openDevTools({ mode: 'detach', activate: true });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[AntiDebugManager] 恢复 DevTools 失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 卸载防破解(开发时使用)
|
||||
*/
|
||||
unload(): void {
|
||||
try {
|
||||
// 注销所有快捷键
|
||||
this.disabledShortcuts.forEach(shortcut => {
|
||||
globalShortcut.unregister(shortcut);
|
||||
});
|
||||
this.disabledShortcuts = [];
|
||||
|
||||
console.log('[AntiDebugManager] ✅ 防破解已卸载');
|
||||
} catch (error) {
|
||||
console.error('[AntiDebugManager] 卸载防破解失败:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default AntiDebugManager.getInstance();
|
||||
@@ -0,0 +1,25 @@
|
||||
import Apps from "../mapi/app";
|
||||
|
||||
export type ResultType<T> = {
|
||||
// should follow the rules:
|
||||
// <0 business error
|
||||
// =0 success
|
||||
// 10000 error ( network error, server error, etc. )
|
||||
code: number;
|
||||
msg: string;
|
||||
data?: T;
|
||||
};
|
||||
|
||||
export const post = async (url: string, data: any) => {
|
||||
data = data || {};
|
||||
const userAgent = Apps.getUserAgent();
|
||||
data["AppManagerUserAgent"] = userAgent;
|
||||
return await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"User-Agent": userAgent,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,268 @@
|
||||
/**
|
||||
* 浏览器路径工具
|
||||
* 统一管理所有模块的浏览器可执行文件路径
|
||||
* 支持自定义安装路径和离线使用
|
||||
*/
|
||||
|
||||
import { app } from 'electron';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import { AppEnv } from '../mapi/env';
|
||||
import { getRuntimeBundlePath } from './resource-path';
|
||||
|
||||
/**
|
||||
* 获取Playwright Chromium浏览器可执行文件路径
|
||||
* 优先使用打包的浏览器,支持自定义安装路径
|
||||
*/
|
||||
function getPlatformChromePaths(): string[] {
|
||||
if (process.platform === 'win32') {
|
||||
return ['chrome-win64/chrome.exe', 'chrome-win/chrome.exe'];
|
||||
} else if (process.platform === 'darwin') {
|
||||
return [
|
||||
'chrome-mac/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing',
|
||||
'chrome-mac-arm64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing',
|
||||
'chrome-mac/Chromium.app/Contents/MacOS/Chromium',
|
||||
'chrome-mac/chrome',
|
||||
];
|
||||
} else {
|
||||
return ['chrome-linux/chrome'];
|
||||
}
|
||||
}
|
||||
|
||||
function getPlatformCacheBase(): string[] {
|
||||
const runtimeCandidates = [
|
||||
getRuntimeBundlePath('playwright'),
|
||||
AppEnv.resourceBundleRoot ? path.join(AppEnv.resourceBundleRoot, 'playwright') : '',
|
||||
AppEnv.dataRoot ? path.join(AppEnv.dataRoot, 'playwright') : '',
|
||||
].filter(Boolean);
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
return [
|
||||
...runtimeCandidates,
|
||||
path.join(process.env.LOCALAPPDATA || '', 'ms-playwright'),
|
||||
path.join(process.env.APPDATA || '', 'ms-playwright'),
|
||||
];
|
||||
} else if (process.platform === 'darwin') {
|
||||
return [
|
||||
...runtimeCandidates,
|
||||
path.join(process.env.HOME || '', 'Library/Caches/ms-playwright'),
|
||||
];
|
||||
} else {
|
||||
return [
|
||||
...runtimeCandidates,
|
||||
path.join(process.env.HOME || '', '.cache/ms-playwright'),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
export function getPlaywrightChromiumPath(): string | undefined {
|
||||
const runtimePath = getRuntimeBundlePath('playwright');
|
||||
const basePaths = [
|
||||
runtimePath,
|
||||
path.join(process.resourcesPath || app.getAppPath(), 'extra/common/playwright'),
|
||||
path.join(app.getAppPath(), 'resources/extra/common/playwright'),
|
||||
path.join(app.getAppPath(), 'electron/resources/extra/common/playwright'),
|
||||
].filter(Boolean);
|
||||
|
||||
const chromiumVersions = ['chromium-1200'];
|
||||
const chromePaths = getPlatformChromePaths();
|
||||
|
||||
for (const basePath of basePaths) {
|
||||
for (const version of chromiumVersions) {
|
||||
for (const chromePath of chromePaths) {
|
||||
const browserPath = path.join(basePath, version, chromePath);
|
||||
if (fs.existsSync(browserPath)) {
|
||||
console.log('✅ [Browser] 使用打包的浏览器:', browserPath);
|
||||
return browserPath;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const playwrightCacheBase = getPlatformCacheBase();
|
||||
|
||||
// 也尝试查找任何可用的 chromium 版本(仅用于开发环境回退)
|
||||
for (const cacheBase of playwrightCacheBase) {
|
||||
if (fs.existsSync(cacheBase)) {
|
||||
const allDirs = fs.readdirSync(cacheBase);
|
||||
const chromiumDirs = allDirs.filter(d => d.startsWith('chromium-')).sort().reverse();
|
||||
for (const version of chromiumDirs) {
|
||||
for (const chromePath of chromePaths) {
|
||||
const browserPath = path.join(cacheBase, version, chromePath);
|
||||
if (fs.existsSync(browserPath)) {
|
||||
console.log('⚠️ [Browser] 使用系统缓存的浏览器:', browserPath);
|
||||
return browserPath;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log('⚠️ [Browser] 警告:未找到Playwright浏览器');
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户数据目录(用于持久化浏览器数据)
|
||||
*/
|
||||
export function getBrowserUserDataDir(contextName: string): string {
|
||||
return path.join(app.getPath('userData'), 'browser-data', contextName);
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用的浏览器启动参数
|
||||
* 参考 aigc-human 项目的反爬和性能优化配置
|
||||
*/
|
||||
export const COMMON_BROWSER_ARGS = [
|
||||
// === 核心反爬与沙箱配置 ===
|
||||
'--disable-blink-features=AutomationControlled',
|
||||
'--no-sandbox',
|
||||
'--disable-setuid-sandbox',
|
||||
'--disable-dev-shm-usage',
|
||||
'--disable-infobars',
|
||||
|
||||
// === 启动速度优化 ===
|
||||
'--no-first-run',
|
||||
'--no-default-browser-check',
|
||||
'--disable-breakpad', // 禁用崩溃报告
|
||||
'--disable-component-update', // 禁用组件自动更新
|
||||
'--disable-sync', // 禁用同步
|
||||
'--disable-default-apps',
|
||||
'--disable-popup-blocking',
|
||||
|
||||
// === 后台性能优化 (防止页面被浏览器通过节流策略降速) ===
|
||||
'--disable-background-networking',
|
||||
'--disable-background-timer-throttling',
|
||||
'--disable-renderer-backgrounding',
|
||||
'--disable-backgrounding-occluded-windows',
|
||||
|
||||
// === 媒体与渲染优化 (针对视频发布场景) ===
|
||||
'--enable-gpu',
|
||||
'--enable-webgl',
|
||||
'--ignore-gpu-blacklist',
|
||||
'--enable-accelerated-video-decode',
|
||||
'--enable-gpu-rasterization',
|
||||
'--enable-proprietary-codecs', // 启用H.264等编解码器
|
||||
|
||||
// === 自动播放策略 ===
|
||||
'--autoplay-policy=no-user-gesture-required',
|
||||
|
||||
// === 安全 ===
|
||||
'--disable-features=IsolateOrigins,site-per-process',
|
||||
];
|
||||
|
||||
/**
|
||||
* 应该忽略的默认启动参数
|
||||
* 移除 Chromium 的自动化标识,避免被平台检测
|
||||
*/
|
||||
export const BROWSER_IGNORE_DEFAULT_ARGS = [
|
||||
'--enable-automation',
|
||||
];
|
||||
|
||||
/**
|
||||
* 反检测注入脚本
|
||||
* 在每个页面加载前执行,隐藏 Playwright/webdriver 痕迹
|
||||
*/
|
||||
export const STEALTH_INIT_SCRIPT = `
|
||||
// 隐藏 webdriver 标识
|
||||
Object.defineProperty(navigator, 'webdriver', {
|
||||
get: () => undefined,
|
||||
configurable: true
|
||||
});
|
||||
|
||||
// 伪造 chrome.runtime(完善方法)
|
||||
window.chrome = {
|
||||
runtime: {
|
||||
PlatformOs: { MAC: 'mac', WIN: 'win', ANDROID: 'android', CROS: 'cros', LINUX: 'linux', OPENBSD: 'openbsd' },
|
||||
PlatformArch: { ARM: 'arm', X86_32: 'x86-32', X86_64: 'x86-64', MIPS: 'mips', MIPS64: 'mips64' },
|
||||
PlatformNaclArch: { ARM: 'arm', X86_32: 'x86-32', X86_64: 'x86-64', MIPS: 'mips', MIPS64: 'mips64' },
|
||||
RequestUpdateCheckStatus: { THROTTLED: 'throttled', NO_UPDATE: 'no_update', UPDATE_AVAILABLE: 'update_available' },
|
||||
OnInstalledReason: { INSTALL: 'install', UPDATE: 'update', CHROME_UPDATE: 'chrome_update', SHARED_MODULE_UPDATE: 'shared_module_update' },
|
||||
OnRestartRequiredReason: { APP_UPDATE: 'app_update', OS_UPDATE: 'os_update', PERIODIC: 'periodic' },
|
||||
connect: function() { return { onDisconnect: { addListener: function() {} }, onMessage: { addListener: function() {} }, postMessage: function() {}, disconnect: function() {} }; },
|
||||
sendMessage: function() {},
|
||||
id: undefined
|
||||
},
|
||||
loadTimes: function() { return { firstPaintTime: 0, startLoadTime: 0, commitLoadTime: 0, finishDocumentLoadTime: 0, finishLoadTime: 0, firstPaintAfterLoadTime: 0, navigationType: 'Other', requestTime: Date.now() / 1000 }; },
|
||||
csi: function() { return { startE: Date.now(), onloadT: Date.now(), pageT: Math.random() * 1000 + 500, tran: 15 }; },
|
||||
app: { isInstalled: false, InstallState: { DISABLED: 'disabled', INSTALLED: 'installed', NOT_INSTALLED: 'not_installed' }, RunningState: { CANNOT_RUN: 'cannot_run', READY_TO_RUN: 'ready_to_run', RUNNING: 'running' } },
|
||||
};
|
||||
|
||||
// 伪造 navigator.plugins
|
||||
Object.defineProperty(navigator, 'plugins', {
|
||||
get: () => {
|
||||
return [{
|
||||
name: 'Chrome PDF Plugin',
|
||||
description: 'Portable Document Format',
|
||||
filename: 'internal-pdf-viewer',
|
||||
length: 1,
|
||||
}, {
|
||||
name: 'Chrome PDF Viewer',
|
||||
description: '',
|
||||
filename: 'mhjfbmdgcfjbbpaeojofohoefgiehjai',
|
||||
length: 1,
|
||||
}, {
|
||||
name: 'Native Client',
|
||||
description: '',
|
||||
filename: 'internal-nacl-plugin',
|
||||
length: 2,
|
||||
}];
|
||||
},
|
||||
configurable: true
|
||||
});
|
||||
|
||||
// 伪造 navigator.languages
|
||||
Object.defineProperty(navigator, 'languages', {
|
||||
get: () => ['zh-CN', 'zh', 'en'],
|
||||
configurable: true
|
||||
});
|
||||
|
||||
// 伪造 navigator.hardwareConcurrency
|
||||
Object.defineProperty(navigator, 'hardwareConcurrency', {
|
||||
get: () => 8,
|
||||
configurable: true
|
||||
});
|
||||
|
||||
// 伪造 navigator.connection
|
||||
if (!navigator.connection) {
|
||||
Object.defineProperty(navigator, 'connection', {
|
||||
get: () => ({ effectiveType: '4g', rtt: 50, downlink: 10, saveData: false }),
|
||||
configurable: true
|
||||
});
|
||||
}
|
||||
|
||||
// 伪造 navigator.getBattery
|
||||
if (!navigator.getBattery) {
|
||||
navigator.getBattery = () => Promise.resolve({
|
||||
charging: true,
|
||||
chargingTime: 0,
|
||||
dischargingTime: Infinity,
|
||||
level: 1,
|
||||
addEventListener: function() {},
|
||||
removeEventListener: function() {},
|
||||
dispatchEvent: function() { return true; }
|
||||
});
|
||||
}
|
||||
|
||||
// 清理 Playwright 特征
|
||||
delete window.__playwright;
|
||||
delete window.__pw_manual;
|
||||
|
||||
// 清理自动化工具特征
|
||||
delete window._REACT_DEVTOOLS_GLOBAL_HOOK__;
|
||||
delete window.__selenium_unwrapped;
|
||||
delete window.__webdriver_evaluate;
|
||||
delete window.__selenium_evaluate;
|
||||
delete window.__fxdriver_evaluate;
|
||||
delete window.__driver_unwrapped;
|
||||
delete window.__driver_evaluate;
|
||||
delete window.__phantomas;
|
||||
delete window.__nightmare;
|
||||
delete window.callSelenium;
|
||||
delete window._phantom;
|
||||
delete window.callPhantom;
|
||||
delete window.Buffer;
|
||||
delete window.emit;
|
||||
delete window.spawn;
|
||||
delete window.webdriver;
|
||||
`;
|
||||
@@ -0,0 +1,281 @@
|
||||
/**
|
||||
* Electron 主进程加密模块
|
||||
* 实现 RSA + AES 混合加密
|
||||
* 不会被代码混淆破坏
|
||||
*/
|
||||
|
||||
import crypto from 'crypto';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { app } from 'electron';
|
||||
|
||||
export class CryptoManager {
|
||||
private static instance: CryptoManager;
|
||||
private publicKeyPath: string;
|
||||
private privateKeyPath: string;
|
||||
private publicKey: string | null = null;
|
||||
private privateKey: string | null = null;
|
||||
|
||||
private constructor() {
|
||||
// 密钥存储路径
|
||||
const keyDir = path.join(app.getPath('userData'), 'keys');
|
||||
if (!fs.existsSync(keyDir)) {
|
||||
fs.mkdirSync(keyDir, { recursive: true });
|
||||
}
|
||||
|
||||
this.publicKeyPath = path.join(keyDir, 'public.pem');
|
||||
this.privateKeyPath = path.join(keyDir, 'private.pem');
|
||||
|
||||
console.log('[CryptoManager] 初始化,密钥路径:', this.publicKeyPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取单例实例
|
||||
*/
|
||||
static getInstance(): CryptoManager {
|
||||
if (!CryptoManager.instance) {
|
||||
CryptoManager.instance = new CryptoManager();
|
||||
}
|
||||
return CryptoManager.instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化 RSA 密钥对
|
||||
* 如果密钥不存在则生成新的
|
||||
*/
|
||||
async initializeKeys(): Promise<void> {
|
||||
try {
|
||||
// 检查密钥是否已存在
|
||||
if (fs.existsSync(this.publicKeyPath) && fs.existsSync(this.privateKeyPath)) {
|
||||
this.publicKey = fs.readFileSync(this.publicKeyPath, 'utf-8');
|
||||
this.privateKey = fs.readFileSync(this.privateKeyPath, 'utf-8');
|
||||
console.log('[CryptoManager] ✅ 已加载现有 RSA 密钥对');
|
||||
return;
|
||||
}
|
||||
|
||||
// 生成新的 RSA 密钥对
|
||||
console.log('[CryptoManager] 生成新的 RSA 密钥对...');
|
||||
const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', {
|
||||
modulusLength: 2048,
|
||||
publicKeyEncoding: {
|
||||
type: 'spki',
|
||||
format: 'pem'
|
||||
},
|
||||
privateKeyEncoding: {
|
||||
type: 'pkcs8',
|
||||
format: 'pem'
|
||||
}
|
||||
});
|
||||
|
||||
// 保存到文件
|
||||
fs.writeFileSync(this.publicKeyPath, publicKey, 'utf-8');
|
||||
fs.writeFileSync(this.privateKeyPath, privateKey, 'utf-8');
|
||||
|
||||
this.publicKey = publicKey;
|
||||
this.privateKey = privateKey;
|
||||
|
||||
console.log('[CryptoManager] ✅ RSA 密钥对生成完成');
|
||||
} catch (error) {
|
||||
console.error('[CryptoManager] ❌ 密钥初始化失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取公钥(用于前端加密)
|
||||
*/
|
||||
getPublicKey(): string {
|
||||
if (!this.publicKey) {
|
||||
throw new Error('公钥未初始化,请先调用 initializeKeys()');
|
||||
}
|
||||
return this.publicKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* RSA 加密(用公钥)
|
||||
* @param data 要加密的数据
|
||||
* @returns Base64 编码的密文
|
||||
*/
|
||||
encryptRSA(data: string): string {
|
||||
try {
|
||||
if (!this.publicKey) {
|
||||
throw new Error('公钥未初始化');
|
||||
}
|
||||
|
||||
const encrypted = crypto.publicEncrypt(
|
||||
{
|
||||
key: this.publicKey,
|
||||
padding: crypto.constants.RSA_PKCS1_OAEP_PADDING
|
||||
},
|
||||
Buffer.from(data, 'utf-8')
|
||||
);
|
||||
|
||||
return encrypted.toString('base64');
|
||||
} catch (error) {
|
||||
console.error('[CryptoManager] RSA 加密失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* RSA 解密(用私钥)
|
||||
* @param encryptedData Base64 编码的密文
|
||||
* @returns 解密后的明文
|
||||
*/
|
||||
decryptRSA(encryptedData: string): string {
|
||||
try {
|
||||
if (!this.privateKey) {
|
||||
throw new Error('私钥未初始化');
|
||||
}
|
||||
|
||||
const decrypted = crypto.privateDecrypt(
|
||||
{
|
||||
key: this.privateKey,
|
||||
padding: crypto.constants.RSA_PKCS1_OAEP_PADDING
|
||||
},
|
||||
Buffer.from(encryptedData, 'base64')
|
||||
);
|
||||
|
||||
return decrypted.toString('utf-8');
|
||||
} catch (error) {
|
||||
console.error('[CryptoManager] RSA 解密失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* AES-256-GCM 加密(改进版,替代 AES-128-ECB)
|
||||
* @param data 要加密的数据
|
||||
* @param key 加密密钥(32字节/256位)
|
||||
* @returns 包含 iv 和密文的 JSON 字符串(Base64 编码)
|
||||
*/
|
||||
encryptAES(data: string, key: string): string {
|
||||
try {
|
||||
// 生成随机 IV
|
||||
const iv = crypto.randomBytes(16);
|
||||
|
||||
// 确保密钥长度为 32 字节
|
||||
const keyBuffer = Buffer.alloc(32);
|
||||
Buffer.from(key, 'utf-8').copy(keyBuffer);
|
||||
|
||||
// 创建加密器
|
||||
const cipher = crypto.createCipheriv('aes-256-gcm', keyBuffer, iv);
|
||||
|
||||
// 加密数据
|
||||
let encrypted = cipher.update(data, 'utf-8', 'hex');
|
||||
encrypted += cipher.final('hex');
|
||||
|
||||
// 获取认证标签
|
||||
const authTag = cipher.getAuthTag();
|
||||
|
||||
// 返回格式:{iv: string, encryptedData: string, authTag: string}
|
||||
const result = {
|
||||
iv: iv.toString('base64'),
|
||||
encryptedData: encrypted,
|
||||
authTag: authTag.toString('base64')
|
||||
};
|
||||
|
||||
return Buffer.from(JSON.stringify(result)).toString('base64');
|
||||
} catch (error) {
|
||||
console.error('[CryptoManager] AES 加密失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* AES-256-GCM 解密
|
||||
* @param encryptedData Base64 编码的加密数据
|
||||
* @param key 解密密钥(32字节/256位)
|
||||
* @returns 解密后的明文
|
||||
*/
|
||||
decryptAES(encryptedData: string, key: string): string {
|
||||
try {
|
||||
// 解析加密数据
|
||||
const result = JSON.parse(Buffer.from(encryptedData, 'base64').toString('utf-8'));
|
||||
|
||||
// 确保密钥长度为 32 字节
|
||||
const keyBuffer = Buffer.alloc(32);
|
||||
Buffer.from(key, 'utf-8').copy(keyBuffer);
|
||||
|
||||
// 创建解密器
|
||||
const decipher = crypto.createDecipheriv(
|
||||
'aes-256-gcm',
|
||||
keyBuffer,
|
||||
Buffer.from(result.iv, 'base64')
|
||||
);
|
||||
|
||||
// 设置认证标签
|
||||
decipher.setAuthTag(Buffer.from(result.authTag, 'base64'));
|
||||
|
||||
// 解密数据
|
||||
let decrypted = decipher.update(result.encryptedData, 'hex', 'utf-8');
|
||||
decrypted += decipher.final('utf-8');
|
||||
|
||||
return decrypted;
|
||||
} catch (error) {
|
||||
console.error('[CryptoManager] AES 解密失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* SHA256 哈希(安全的哈希替代 MD5)
|
||||
* @param data 要哈希的数据
|
||||
* @returns 哈希值
|
||||
*/
|
||||
sha256(data: string): string {
|
||||
return crypto.createHash('sha256').update(data).digest('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成随机密钥
|
||||
* @param length 密钥长度(字节数)
|
||||
* @returns Base64 编码的随机密钥
|
||||
*/
|
||||
generateRandomKey(length: number = 32): string {
|
||||
return crypto.randomBytes(length).toString('base64');
|
||||
}
|
||||
|
||||
/**
|
||||
* 签名(用私钥)
|
||||
* @param data 要签名的数据
|
||||
* @returns Base64 编码的签名
|
||||
*/
|
||||
sign(data: string): string {
|
||||
try {
|
||||
if (!this.privateKey) {
|
||||
throw new Error('私钥未初始化');
|
||||
}
|
||||
|
||||
const sign = crypto.createSign('sha256');
|
||||
sign.update(data);
|
||||
return sign.sign(this.privateKey, 'base64');
|
||||
} catch (error) {
|
||||
console.error('[CryptoManager] 签名失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证签名(用公钥)
|
||||
* @param data 原始数据
|
||||
* @param signature Base64 编码的签名
|
||||
* @returns 签名是否有效
|
||||
*/
|
||||
verify(data: string, signature: string): boolean {
|
||||
try {
|
||||
if (!this.publicKey) {
|
||||
throw new Error('公钥未初始化');
|
||||
}
|
||||
|
||||
const verify = crypto.createVerify('sha256');
|
||||
verify.update(data);
|
||||
return verify.verify(this.publicKey, signature, 'base64');
|
||||
} catch (error) {
|
||||
console.error('[CryptoManager] 签名验证失败:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default CryptoManager.getInstance();
|
||||
@@ -0,0 +1,101 @@
|
||||
import {BrowserView, BrowserWindow, screen} from "electron";
|
||||
import {isDev, shouldEnableDevTools} from "./env";
|
||||
import {WindowConfig} from "../config/window";
|
||||
|
||||
export const DevToolsManager = {
|
||||
enable: true,
|
||||
rowCount: 4,
|
||||
colCount: 3,
|
||||
windows: new Map<BrowserWindow | BrowserView, BrowserWindow>(),
|
||||
setEnable(enable: boolean) {
|
||||
DevToolsManager.enable = enable;
|
||||
},
|
||||
getWindow(win: BrowserWindow | BrowserView) {
|
||||
return this.windows.get(win);
|
||||
},
|
||||
getOrCreateWindow(name: string, win: BrowserWindow | BrowserView) {
|
||||
if (this.windows.has(win)) {
|
||||
return this.windows.get(win);
|
||||
}
|
||||
const {x, y, width, height} = this.getDisplayPosition();
|
||||
// console.log('DevToolsManager', name, {x, y, width, height})
|
||||
const devtools = new BrowserWindow({
|
||||
show: true,
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
title: name,
|
||||
});
|
||||
devtools.on("closed", (e) => {
|
||||
// console.log('DevToolsManager', 'close', name)
|
||||
this.windows.delete(win);
|
||||
})
|
||||
// console.log('DevToolsManager', name, {x, y})
|
||||
win.webContents.setDevToolsWebContents(devtools.webContents);
|
||||
win.webContents.on("destroyed", () => {
|
||||
// console.log('DevToolsManager', 'destroyed', name)
|
||||
devtools.destroy();
|
||||
});
|
||||
devtools.webContents.on("dom-ready", () => {
|
||||
setTimeout(() => {
|
||||
if (!devtools.isDestroyed()) {
|
||||
devtools.setTitle(name);
|
||||
}
|
||||
}, 1000);
|
||||
});
|
||||
this.windows.set(win, devtools);
|
||||
return devtools;
|
||||
},
|
||||
getLargestDisplay(): Electron.Display {
|
||||
const displays = screen.getAllDisplays();
|
||||
return displays.reduce((max, display) => {
|
||||
const {width, height} = display.size;
|
||||
const maxResolution = max.size.width * max.size.height;
|
||||
const currentResolution = width * height;
|
||||
return currentResolution > maxResolution ? display : max;
|
||||
});
|
||||
},
|
||||
getDisplayPosition(): {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
} {
|
||||
const display = this.getLargestDisplay();
|
||||
const {x, y, width, height} = display.workArea;
|
||||
// console.log('DevToolsManager', 'getDisplayPosition', {x, y, width, height})
|
||||
if (width < 1300) {
|
||||
this.rowCount = 3;
|
||||
this.colCount = 2;
|
||||
}
|
||||
const itemWidth = Math.floor(width / this.rowCount);
|
||||
const itemHeight = Math.floor(height / this.colCount);
|
||||
const maxRow = Math.floor(width / itemWidth);
|
||||
const row = this.windows.size % maxRow;
|
||||
const col = Math.floor(this.windows.size / maxRow);
|
||||
return {
|
||||
x: x + row * itemWidth,
|
||||
y: y + col * itemHeight,
|
||||
width: itemWidth,
|
||||
height: itemHeight,
|
||||
};
|
||||
},
|
||||
register(name: string, win: BrowserWindow | BrowserView) {
|
||||
if (!shouldEnableDevTools || !DevToolsManager.enable) {
|
||||
return;
|
||||
}
|
||||
this.getOrCreateWindow(name, win);
|
||||
},
|
||||
autoShow(win: BrowserWindow | BrowserView) {
|
||||
if (!shouldEnableDevTools || !DevToolsManager.enable) {
|
||||
return;
|
||||
}
|
||||
if (WindowConfig.alwaysOpenDevTools) {
|
||||
win.webContents.openDevTools({
|
||||
mode: "detach",
|
||||
activate: false,
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,150 @@
|
||||
import url, {fileURLToPath} from "node:url";
|
||||
import {BrowserView, BrowserWindow} from "electron";
|
||||
import {isPackaged} from "./env";
|
||||
import path, {join} from "node:path";
|
||||
import {Log} from "../mapi/log/main";
|
||||
import {existsSync} from "node:fs";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
// 🔧 计算应用根目录 - 使用多重降级方案确保兼容 ASAR 打包
|
||||
function getAppRoot(): string {
|
||||
try {
|
||||
// 开发环境:从当前文件目录往上两级
|
||||
if (!isPackaged) {
|
||||
const root = path.join(__dirname, "../..");
|
||||
if (root && typeof root === 'string') {
|
||||
return root;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[getAppRoot] 开发环境路径计算失败:', error);
|
||||
}
|
||||
|
||||
// 生产环境或计算失败:尝试使用 process.resourcesPath(最可靠)
|
||||
try {
|
||||
if (process.resourcesPath && typeof process.resourcesPath === 'string') {
|
||||
return process.resourcesPath;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[getAppRoot] process.resourcesPath 获取失败:', error);
|
||||
}
|
||||
|
||||
// 最后的降级方案:使用可执行文件目录
|
||||
try {
|
||||
const execPath = process.execPath;
|
||||
if (execPath && typeof execPath === 'string') {
|
||||
return path.dirname(execPath);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[getAppRoot] process.execPath 获取失败:', error);
|
||||
}
|
||||
|
||||
// 如果都失败,返回当前目录(最后防线)
|
||||
return __dirname;
|
||||
}
|
||||
|
||||
const appRoot = getAppRoot();
|
||||
process.env.APP_ROOT = appRoot;
|
||||
|
||||
console.log('[env-main] APP_ROOT 已设置:', process.env.APP_ROOT);
|
||||
|
||||
// 🔧 处理 ASAR 打包:当使用 asar 打包时,应用代码在 app.asar 内
|
||||
// 根据是否为 asar 打包来调整路径
|
||||
let MAIN_DIST: string;
|
||||
if (isPackaged && appRoot === process.resourcesPath) {
|
||||
// 生产环境且使用了 asar 打包
|
||||
// preload 和主进程代码在 app.asar 内,但资源在 app.asar.unpacked 或 resources 根目录
|
||||
try {
|
||||
const appAsarPath = path.join(appRoot, 'app.asar');
|
||||
if (existsSync(appAsarPath)) {
|
||||
MAIN_DIST = path.join(appAsarPath, "dist-electron");
|
||||
} else {
|
||||
MAIN_DIST = path.join(appRoot, "dist-electron");
|
||||
}
|
||||
} catch (e) {
|
||||
MAIN_DIST = path.join(appRoot, "dist-electron");
|
||||
}
|
||||
} else {
|
||||
// 开发环境或非 asar 打包
|
||||
MAIN_DIST = path.join(appRoot, "dist-electron");
|
||||
}
|
||||
|
||||
// 🔧 渲染进程文件也在 app.asar 内
|
||||
let RENDERER_DIST: string;
|
||||
if (isPackaged && appRoot === process.resourcesPath) {
|
||||
try {
|
||||
const appAsarPath = path.join(appRoot, 'app.asar');
|
||||
if (existsSync(appAsarPath)) {
|
||||
RENDERER_DIST = path.join(appAsarPath, "dist");
|
||||
} else {
|
||||
RENDERER_DIST = path.join(appRoot, "dist");
|
||||
}
|
||||
} catch (e) {
|
||||
RENDERER_DIST = path.join(appRoot, "dist");
|
||||
}
|
||||
} else {
|
||||
RENDERER_DIST = path.join(appRoot, "dist");
|
||||
}
|
||||
|
||||
export const VITE_DEV_SERVER_URL = process.env.VITE_DEV_SERVER_URL;
|
||||
|
||||
process.env.VITE_PUBLIC = VITE_DEV_SERVER_URL ? path.join(appRoot, "public") : RENDERER_DIST;
|
||||
|
||||
console.log('[env-main] MAIN_DIST:', MAIN_DIST);
|
||||
console.log('[env-main] RENDERER_DIST:', RENDERER_DIST);
|
||||
|
||||
export { MAIN_DIST, RENDERER_DIST };
|
||||
export const preloadDefault = path.join(MAIN_DIST, "preload/index.js");
|
||||
|
||||
console.log('[env-main] preloadDefault:', preloadDefault);
|
||||
|
||||
export const rendererLoadPath = (window: BrowserWindow | BrowserView, fileName: string) => {
|
||||
if (!isPackaged && process.env.VITE_DEV_SERVER_URL) {
|
||||
const x = new url.URL(rendererDistPath(fileName));
|
||||
if (window instanceof BrowserView) {
|
||||
window.webContents.loadURL(x.toString());
|
||||
} else {
|
||||
window.loadURL(x.toString());
|
||||
}
|
||||
} else {
|
||||
if (window instanceof BrowserView) {
|
||||
window.webContents.loadFile(rendererDistPath(fileName));
|
||||
} else {
|
||||
window.loadFile(rendererDistPath(fileName));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const rendererDistPath = (fileName: string) => {
|
||||
if (!isPackaged && process.env.VITE_DEV_SERVER_URL) {
|
||||
return `${process.env.VITE_DEV_SERVER_URL.replace(/\/+$/, "")}/${fileName}`;
|
||||
}
|
||||
return join(RENDERER_DIST, fileName);
|
||||
};
|
||||
|
||||
export const rendererIsUrl = (url: string) => {
|
||||
return url.startsWith("http://") || url.startsWith("https://");
|
||||
};
|
||||
|
||||
export const getGpuInfo = async () => {
|
||||
const list = [] as {
|
||||
id: string;
|
||||
name: string;
|
||||
size: number;
|
||||
}[];
|
||||
try {
|
||||
// @ts-ignore
|
||||
const si = await import("systeminformation");
|
||||
const graphics = await si.graphics();
|
||||
graphics.controllers.forEach((controller, index) => {
|
||||
const size = Math.ceil(controller.vram / 1024);
|
||||
let id = index + "";
|
||||
const name = controller.model;
|
||||
list.push({id, name, size});
|
||||
});
|
||||
} catch (e) {
|
||||
Log.error("getGpuInfo", e);
|
||||
}
|
||||
return list;
|
||||
};
|
||||
@@ -0,0 +1,151 @@
|
||||
import {execSync} from "child_process";
|
||||
import {resolve} from "node:path";
|
||||
import fs from "node:fs";
|
||||
import os from "os";
|
||||
import {Log} from "../mapi/log";
|
||||
import FileIndex from "../mapi/file";
|
||||
|
||||
export const isPackaged = ["true"].includes(process.env.IS_PACKAGED);
|
||||
|
||||
export const isDev = !isPackaged;
|
||||
|
||||
// 🆕 调试模式开关(支持环境变量和特殊标志)
|
||||
export const isDebugMode =
|
||||
process.env.ELECTRON_DEBUG_MODE === 'true' ||
|
||||
process.env.ELECTRON_FORCE_DEVTOOLS === '1';
|
||||
|
||||
// 🆕 是否应该启用DevTools(开发环境或调试模式)
|
||||
export const shouldEnableDevTools = isDev || isDebugMode;
|
||||
|
||||
export const isWin = process.platform === "win32";
|
||||
|
||||
export const isMac = process.platform === "darwin";
|
||||
|
||||
export const isLinux = process.platform === "linux";
|
||||
|
||||
export const isMain = process.type === "browser";
|
||||
|
||||
export const isRender = process.type === "renderer";
|
||||
|
||||
export const platformName = (): "win" | "osx" | "linux" | null => {
|
||||
if (isWin) return "win";
|
||||
if (isMac) return "osx";
|
||||
if (isLinux) return "linux";
|
||||
return null;
|
||||
};
|
||||
|
||||
export const memoryInfo = () => {
|
||||
return {
|
||||
total: os.totalmem(),
|
||||
free: os.freemem(),
|
||||
};
|
||||
};
|
||||
|
||||
const tryFirst = (functionList: (() => any)[]) => {
|
||||
for (const fun of functionList) {
|
||||
try {
|
||||
return fun();
|
||||
} catch (e) {
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
let platformVersionCache: string | null = null;
|
||||
export const platformVersion = () => {
|
||||
if (null === platformVersionCache) {
|
||||
const functionList: any[] = [];
|
||||
if (isWin) {
|
||||
functionList.push(() => execSync("wmic os get Version").toString().split("\n")[1].trim());
|
||||
functionList.push(() =>
|
||||
execSync(
|
||||
"powershell -command \"(Get-ItemProperty 'HKLM:\\SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion').ReleaseId\""
|
||||
)
|
||||
.toString()
|
||||
.trim()
|
||||
);
|
||||
} else if (isMac) {
|
||||
functionList.push(() => execSync("sw_vers -productVersion").toString().trim());
|
||||
} else if (isLinux) {
|
||||
functionList.push(() =>
|
||||
execSync("cat /etc/os-release | grep VERSION_ID").toString().split("=")[1].trim().replace(/"/g, "")
|
||||
);
|
||||
}
|
||||
platformVersionCache = tryFirst(functionList);
|
||||
if (!platformVersionCache) {
|
||||
Log.error("env.platformVersion.error");
|
||||
platformVersionCache = "0.0.0";
|
||||
}
|
||||
}
|
||||
return platformVersionCache;
|
||||
};
|
||||
|
||||
export const platformArch = (): "x86" | "arm64" | null => {
|
||||
switch (os.arch()) {
|
||||
case "x64":
|
||||
return "x86";
|
||||
case "arm64":
|
||||
return "arm64";
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
let platformUUIDCache: string | null = null;
|
||||
export const platformUUID = () => {
|
||||
if (null === platformUUIDCache) {
|
||||
const functionList: any[] = [];
|
||||
if (isWin) {
|
||||
functionList.push(() => execSync("wmic csproduct get UUID").toString().split("\n")[1].trim());
|
||||
functionList.push(() =>
|
||||
execSync('powershell -command "(Get-WmiObject Win32_ComputerSystemProduct).UUID"').toString().trim()
|
||||
);
|
||||
} else if (isMac) {
|
||||
functionList.push(() =>
|
||||
execSync("system_profiler SPHardwareDataType | grep UUID").toString().split(": ")[1].trim()
|
||||
);
|
||||
} else if (isLinux) {
|
||||
functionList.push(() => execSync("cat /var/lib/dbus/machine-id").toString().trim().toUpperCase());
|
||||
}
|
||||
platformUUIDCache = tryFirst(functionList);
|
||||
if (!platformUUIDCache) {
|
||||
Log.error("env.platformUUID.error");
|
||||
platformUUIDCache = "000000";
|
||||
}
|
||||
}
|
||||
return platformUUIDCache;
|
||||
};
|
||||
|
||||
export const buildResolve = (value: string): string => {
|
||||
const basePath = isPackaged ? process.resourcesPath : "electron/resources";
|
||||
return resolve(basePath, "build", value);
|
||||
};
|
||||
|
||||
export const binResolve = (value: string): string => {
|
||||
return resolve(process.resourcesPath, "bin", value);
|
||||
};
|
||||
|
||||
export const extraResolve = (filePath: string): string => {
|
||||
const basePath = isPackaged ? process.resourcesPath : "electron/resources";
|
||||
return resolve(basePath, "extra", filePath);
|
||||
};
|
||||
|
||||
export const extraResolveBin = (filePath: string): string => {
|
||||
const originalFilePath = filePath;
|
||||
if (isWin) {
|
||||
if (!filePath.endsWith(".exe")) {
|
||||
filePath += ".exe";
|
||||
}
|
||||
}
|
||||
const dir = [platformName(), platformArch()].join("-");
|
||||
const p = [dir, filePath].join("/");
|
||||
const binaryPath = extraResolve(p);
|
||||
if (!fs.existsSync(binaryPath)) {
|
||||
// 对于 ffmpeg 和 ffprobe,回退到系统PATH
|
||||
if (originalFilePath === 'ffmpeg' || originalFilePath === 'ffprobe') {
|
||||
Log.info(`本地${originalFilePath}未找到,尝试使用系统PATH中的${originalFilePath}`);
|
||||
return originalFilePath;
|
||||
}
|
||||
throw new Error(`错误:未找到${originalFilePath},请先安装${originalFilePath}并添加到系统环境变量`);
|
||||
}
|
||||
return binaryPath;
|
||||
};
|
||||
@@ -0,0 +1,40 @@
|
||||
import {BrowserView, BrowserWindow} from "electron";
|
||||
import {AppsMain} from "../mapi/app/main";
|
||||
|
||||
type HookType = never | "Show" | "Hide" | "EnterFullScreen" | "LeaveFullScreen" | "ShowQuitConfirmDialog";
|
||||
|
||||
export const executeHooks = async (win: BrowserWindow, hook: HookType, data?: any) => {
|
||||
const evalJs = `
|
||||
if(window.__page && window.__page.hooks && typeof window.__page.hooks.on${hook} === 'function' ) {
|
||||
try {
|
||||
window.__page.hooks.on${hook}(${JSON.stringify(data)});
|
||||
} catch(e) {
|
||||
console.log('executeHooks.on${hook}.error', e);
|
||||
}
|
||||
}`;
|
||||
return win.webContents?.executeJavaScript(evalJs);
|
||||
};
|
||||
|
||||
export const executeDarkMode = async (
|
||||
view: BrowserWindow | BrowserView,
|
||||
data: {
|
||||
isSystem: boolean;
|
||||
}
|
||||
) => {
|
||||
data = Object.assign(
|
||||
{
|
||||
isSystem: false,
|
||||
},
|
||||
data
|
||||
);
|
||||
if (await AppsMain.shouldDarkMode()) {
|
||||
// body and html
|
||||
view.webContents.executeJavaScript(`
|
||||
document.body.setAttribute('data-theme', 'dark');
|
||||
document.documentElement.setAttribute('data-theme', 'dark');
|
||||
`);
|
||||
if (data.isSystem) {
|
||||
view.webContents.executeJavaScript(`document.body.setAttribute('arco-theme', 'dark');`);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,41 @@
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import * as fs from 'fs';
|
||||
|
||||
export function normalizePath(filePath: string): string {
|
||||
if (!filePath) return filePath;
|
||||
return path.normalize(filePath);
|
||||
}
|
||||
|
||||
export function normalizeOutputPath(filePath: string): string {
|
||||
if (!filePath) return filePath;
|
||||
|
||||
if (os.platform() !== 'win32') return filePath;
|
||||
|
||||
const dir = path.dirname(filePath);
|
||||
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
return path.normalize(filePath);
|
||||
}
|
||||
|
||||
export function normalizePaths(filePaths: string[]): string[] {
|
||||
return filePaths.map(p => normalizePath(p));
|
||||
}
|
||||
|
||||
export function quotePath(filePath: string): string {
|
||||
if (filePath.includes(' ') && !filePath.startsWith('"')) {
|
||||
return `"${filePath}"`;
|
||||
}
|
||||
return filePath;
|
||||
}
|
||||
|
||||
export function createSafeTempPath(extension: string): string {
|
||||
const tempDir = os.tmpdir();
|
||||
const timestamp = Date.now();
|
||||
const random = Math.random().toString(36).substring(2, 8);
|
||||
const filename = `temp_${timestamp}_${random}.${extension}`;
|
||||
return path.join(tempDir, filename);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import {isMac} from "./env";
|
||||
|
||||
let nodeMacPermissions = null;
|
||||
if (isMac) {
|
||||
(async () => {
|
||||
try {
|
||||
nodeMacPermissions = await import("node-mac-permissions");
|
||||
nodeMacPermissions = nodeMacPermissions.default;
|
||||
// console.log('nodeMacPermissions',nodeMacPermissions);
|
||||
} catch (e) {}
|
||||
})();
|
||||
}
|
||||
|
||||
export const Permissions = {
|
||||
async checkAccessibilityAccess(): Promise<boolean> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (isMac) {
|
||||
const status = nodeMacPermissions.getAuthStatus("accessibility");
|
||||
resolve(status === "authorized");
|
||||
} else {
|
||||
resolve(true);
|
||||
}
|
||||
});
|
||||
},
|
||||
async askAccessibilityAccess() {
|
||||
nodeMacPermissions.askForAccessibilityAccess();
|
||||
},
|
||||
async checkScreenCaptureAccess(): Promise<boolean> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (isMac) {
|
||||
const status = nodeMacPermissions.getAuthStatus("screen");
|
||||
resolve(status === "authorized");
|
||||
} else {
|
||||
resolve(true);
|
||||
}
|
||||
});
|
||||
},
|
||||
async askScreenCaptureAccess() {
|
||||
nodeMacPermissions.askForScreenCaptureAccess(true);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
import PinyinMatch from "pinyin-match";
|
||||
|
||||
export const PinyinUtil = {
|
||||
match(input, keywords) {
|
||||
const index = PinyinMatch.match(input, keywords);
|
||||
let inputMark = input;
|
||||
let similarity = 0;
|
||||
if (index) {
|
||||
const indexStart = index[0];
|
||||
const indexEnd = index[1];
|
||||
inputMark =
|
||||
input.substring(0, indexStart) +
|
||||
"<mark>" +
|
||||
input.substring(indexStart, indexEnd + 1) +
|
||||
"</mark>" +
|
||||
input.substring(indexEnd + 1);
|
||||
similarity = (indexEnd - indexStart + 1) / input.length;
|
||||
}
|
||||
return {
|
||||
matched: !!index,
|
||||
inputMark,
|
||||
similarity,
|
||||
};
|
||||
},
|
||||
mark(text) {
|
||||
return `<mark>${text}</mark>`;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,131 @@
|
||||
import { exec } from "child_process";
|
||||
import { promisify } from "util";
|
||||
import path from "node:path";
|
||||
import Log from "../mapi/log/main";
|
||||
import { getRuntimeBundleRoot, getRuntimeRoot, isDevelopment } from "./resource-path";
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
export class ProcessCleanupManager {
|
||||
private static childProcesses: Set<number> = new Set();
|
||||
|
||||
static registerChildProcess(pid: number) {
|
||||
this.childProcesses.add(pid);
|
||||
Log.info(`[ProcessCleanup] Registered child process: ${pid}`);
|
||||
}
|
||||
|
||||
static unregisterChildProcess(pid: number) {
|
||||
this.childProcesses.delete(pid);
|
||||
Log.info(`[ProcessCleanup] Unregistered child process: ${pid}`);
|
||||
}
|
||||
|
||||
static getChildProcesses(): number[] {
|
||||
return Array.from(this.childProcesses);
|
||||
}
|
||||
|
||||
private static async killProcess(pid: number): Promise<boolean> {
|
||||
try {
|
||||
if (process.platform === "win32") {
|
||||
await execAsync(`taskkill /F /PID ${pid} /T`);
|
||||
} else {
|
||||
await execAsync(`kill -9 ${pid}`);
|
||||
}
|
||||
Log.info(`[ProcessCleanup] Killed process: ${pid}`);
|
||||
return true;
|
||||
} catch (error: any) {
|
||||
Log.error(`[ProcessCleanup] Failed to kill process ${pid}:`, error.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static escapePowerShellString(value: string): string {
|
||||
return value.replace(/'/g, "''");
|
||||
}
|
||||
|
||||
private static getCleanupRoots(): string[] {
|
||||
const roots = new Set<string>();
|
||||
const candidates = [
|
||||
path.dirname(process.execPath),
|
||||
getRuntimeRoot(),
|
||||
getRuntimeBundleRoot(),
|
||||
];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (!candidate) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
roots.add(path.resolve(candidate));
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(roots);
|
||||
}
|
||||
|
||||
private static async killProcessesUnderAppRoots(): Promise<number> {
|
||||
if (process.platform !== "win32" || isDevelopment()) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const roots = this.getCleanupRoots();
|
||||
if (roots.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const rootList = roots.map(root => `'${this.escapePowerShellString(root)}'`).join(", ");
|
||||
const command =
|
||||
`powershell -NoProfile -ExecutionPolicy Bypass -Command ` +
|
||||
`"$roots = @(${rootList}); ` +
|
||||
`$roots = $roots | Where-Object { $_ } | ForEach-Object { [System.IO.Path]::GetFullPath($_) }; ` +
|
||||
`Get-Process -ErrorAction SilentlyContinue | ` +
|
||||
`Where-Object { $_.Id -ne ${process.pid} -and $_.Path } | ` +
|
||||
`Where-Object { ` +
|
||||
`try { ` +
|
||||
`$path = [System.IO.Path]::GetFullPath($_.Path); ` +
|
||||
`foreach ($root in $roots) { ` +
|
||||
`if ($path.StartsWith($root, [System.StringComparison]::OrdinalIgnoreCase)) { return $true } ` +
|
||||
`}; ` +
|
||||
`$false ` +
|
||||
`} catch { $false } ` +
|
||||
`} | ` +
|
||||
`ForEach-Object { try { Stop-Process -Id $_.Id -Force -ErrorAction Stop; Write-Output $_.Id } catch {} }"`;
|
||||
|
||||
try {
|
||||
const { stdout } = await execAsync(command);
|
||||
const count = stdout
|
||||
.split(/\r?\n/)
|
||||
.map(line => line.trim())
|
||||
.filter(Boolean).length;
|
||||
if (count > 0) {
|
||||
Log.info("[ProcessCleanup] Killed app-owned processes under install roots", { count, roots });
|
||||
}
|
||||
return count;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
static async cleanupAllProcesses(): Promise<boolean> {
|
||||
Log.info("[ProcessCleanup] Starting cleanup...");
|
||||
|
||||
const registeredPids = this.getChildProcesses();
|
||||
for (const pid of registeredPids) {
|
||||
await this.killProcess(pid);
|
||||
this.unregisterChildProcess(pid);
|
||||
}
|
||||
|
||||
await this.killProcessesUnderAppRoots();
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
await this.killProcessesUnderAppRoots();
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
|
||||
Log.info("[ProcessCleanup] Cleanup completed");
|
||||
return true;
|
||||
}
|
||||
|
||||
static getRunningTaskCount(): number {
|
||||
return this.childProcesses.size;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
/** 在主进程中获取关键信息存储到环境变量中,从而在预加载脚本中及渲染进程中使用 */
|
||||
import {app} from "electron";
|
||||
|
||||
/** 注意: app.isPackaged 可能被被某些方法改变所以请将该文件放到 main.js 必须位于非依赖项的顶部 */
|
||||
if (process.platform === "darwin") {
|
||||
const fixPath = require("fix-path");
|
||||
fixPath();
|
||||
}
|
||||
|
||||
process.env.IS_PACKAGED = String(app.isPackaged);
|
||||
|
||||
process.env.DESKTOP_PATH = app.getPath("desktop");
|
||||
|
||||
process.env.CWD = process.cwd();
|
||||
|
||||
export const isDummy = false;
|
||||
@@ -0,0 +1,182 @@
|
||||
import { app } from 'electron';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import { exec, execSync } from 'child_process';
|
||||
import { getRuntimeBundlePath } from './resource-path';
|
||||
|
||||
const SETUP_VERSION = '2';
|
||||
|
||||
function getSetupFlagPath(): string {
|
||||
return path.join(app.getPath('userData'), '.python-setup-done');
|
||||
}
|
||||
|
||||
function getPythonInstallDir(): string {
|
||||
return getRuntimeBundlePath('python-runtime');
|
||||
}
|
||||
|
||||
function getPythonBinPath(): string {
|
||||
return path.join(getPythonInstallDir(), 'python', 'bin', 'python3');
|
||||
}
|
||||
|
||||
function getPipBinPath(): string {
|
||||
return path.join(getPythonInstallDir(), 'python', 'bin', 'pip3');
|
||||
}
|
||||
|
||||
function getLocalResourcePath(filename: string): string | null {
|
||||
const runtimeBundlePath = path.join(getRuntimeBundlePath('python-runtime'), filename);
|
||||
if (fs.existsSync(runtimeBundlePath)) {
|
||||
return runtimeBundlePath;
|
||||
}
|
||||
|
||||
const isDev = __dirname.includes('electron') && !__dirname.includes('dist-electron');
|
||||
const base = isDev
|
||||
? path.join(app.getAppPath(), 'electron', 'resources', 'extra', 'osx')
|
||||
: path.join(process.resourcesPath, 'extra', 'osx');
|
||||
const fullPath = path.join(base, filename);
|
||||
return fs.existsSync(fullPath) ? fullPath : null;
|
||||
}
|
||||
|
||||
function execAsync(cmd: string, opts?: any): Promise<{ code: number; stdout: string; stderr: string }> {
|
||||
return new Promise((resolve) => {
|
||||
exec(cmd, { timeout: 300000, ...opts }, (err, stdout, stderr) => {
|
||||
resolve({ code: err ? (err.code as number) || 1 : 0, stdout: stdout || '', stderr: stderr || '' });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function isSetupComplete(): boolean {
|
||||
if (process.platform === 'win32') return true;
|
||||
if (!fs.existsSync(getSetupFlagPath())) return false;
|
||||
try {
|
||||
const v = fs.readFileSync(getSetupFlagPath(), 'utf-8').trim();
|
||||
if (v !== SETUP_VERSION) return false;
|
||||
return fs.existsSync(getPythonBinPath());
|
||||
} catch { return false; }
|
||||
}
|
||||
|
||||
function markSetupComplete(): void {
|
||||
fs.writeFileSync(getSetupFlagPath(), SETUP_VERSION);
|
||||
}
|
||||
|
||||
async function extractPython(): Promise<string> {
|
||||
const installDir = getPythonInstallDir();
|
||||
const pythonBin = getPythonBinPath();
|
||||
|
||||
if (fs.existsSync(pythonBin)) {
|
||||
console.log('[PythonSetup] Python 已解压:', pythonBin);
|
||||
return pythonBin;
|
||||
}
|
||||
|
||||
fs.mkdirSync(installDir, { recursive: true });
|
||||
|
||||
const arch = os.arch();
|
||||
const tarName = arch === 'arm64' ? 'python-mac-arm64.tar.gz' : 'python-mac-x64.tar.gz';
|
||||
const tarPath = getLocalResourcePath(tarName);
|
||||
|
||||
if (!tarPath) {
|
||||
throw new Error(`未找到 Python 包: ${tarName}`);
|
||||
}
|
||||
|
||||
console.log('[PythonSetup] 解压 Python:', tarPath);
|
||||
const result = await execAsync(`tar -xzf "${tarPath}" -C "${installDir}"`, { timeout: 120000 });
|
||||
if (result.code !== 0) {
|
||||
throw new Error('解压 Python 失败: ' + result.stderr.substring(0, 200));
|
||||
}
|
||||
|
||||
try {
|
||||
const entries = fs.readdirSync(installDir);
|
||||
const pythonEntry = entries.find(e => e.startsWith('python'));
|
||||
if (pythonEntry && pythonEntry !== 'python') {
|
||||
fs.renameSync(path.join(installDir, pythonEntry), path.join(installDir, 'python'));
|
||||
}
|
||||
} catch {}
|
||||
|
||||
if (!fs.existsSync(pythonBin)) {
|
||||
throw new Error('解压后找不到 python3');
|
||||
}
|
||||
|
||||
try { fs.chmodSync(pythonBin, 0o755); } catch {}
|
||||
console.log('[PythonSetup] ✅ Python 解压完成');
|
||||
return pythonBin;
|
||||
}
|
||||
|
||||
async function installWheels(pythonBin: string): Promise<void> {
|
||||
const wheelsDir = getLocalResourcePath('wheels');
|
||||
if (!wheelsDir) {
|
||||
throw new Error('未找到 wheels 目录');
|
||||
}
|
||||
|
||||
const wheelFiles = fs.readdirSync(wheelsDir).filter(f => f.endsWith('.whl') || f.endsWith('.tar.gz'));
|
||||
if (wheelFiles.length === 0) {
|
||||
throw new Error('wheels 目录为空');
|
||||
}
|
||||
|
||||
console.log(`[PythonSetup] 离线安装 ${wheelFiles.length} 个包...`);
|
||||
|
||||
const allFiles = wheelFiles.map(f => `"${path.join(wheelsDir, f)}"`).join(' ');
|
||||
|
||||
const result = await execAsync(
|
||||
`"${pythonBin}" -m pip install --no-index --no-deps ${allFiles}`,
|
||||
{ timeout: 300000 }
|
||||
);
|
||||
|
||||
if (result.code !== 0) {
|
||||
console.warn('[PythonSetup] 部分 pip install 失败,尝试逐个安装...');
|
||||
for (const f of wheelFiles) {
|
||||
const filePath = path.join(wheelsDir, f);
|
||||
const r = await execAsync(`"${pythonBin}" -m pip install --no-index "${filePath}"`, { timeout: 120000 });
|
||||
if (r.code !== 0) {
|
||||
console.warn(`[PythonSetup] 安装失败: ${f}`, r.stderr.substring(0, 100));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log('[PythonSetup] ✅ 依赖安装完成');
|
||||
}
|
||||
|
||||
async function verifyAll(pythonBin: string): Promise<boolean> {
|
||||
const code = `
|
||||
import importlib
|
||||
for mod in ['cv2','numpy','PIL','rembg','onnxruntime','jieba','loguru']:
|
||||
try: importlib.import_module(mod)
|
||||
except: exit(1)
|
||||
print('OK')
|
||||
`;
|
||||
const result = await execAsync(`"${pythonBin}" -c '${code.replace(/\n/g, ' ')}'`, { timeout: 30000 });
|
||||
return result.code === 0 && result.stdout.trim() === 'OK';
|
||||
}
|
||||
|
||||
export async function ensurePythonSetup(): Promise<string | null> {
|
||||
if (process.platform === 'win32') return null;
|
||||
|
||||
const pythonBin = getPythonBinPath();
|
||||
if (isSetupComplete() && fs.existsSync(pythonBin)) {
|
||||
return pythonBin;
|
||||
}
|
||||
|
||||
console.log('[PythonSetup] 开始自动初始化(完全离线)...');
|
||||
|
||||
try {
|
||||
const pyBin = await extractPython();
|
||||
await installWheels(pyBin);
|
||||
|
||||
const ok = await verifyAll(pyBin);
|
||||
if (!ok) {
|
||||
console.warn('[PythonSetup] ⚠️ 部分依赖验证失败,但继续运行');
|
||||
}
|
||||
|
||||
markSetupComplete();
|
||||
console.log('[PythonSetup] ✅ Python 环境初始化完成');
|
||||
return pyBin;
|
||||
} catch (err) {
|
||||
console.error('[PythonSetup] ❌ 初始化失败:', err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function getMacOSPythonPath(): string | null {
|
||||
if (process.platform === 'win32') return null;
|
||||
const p = getPythonBinPath();
|
||||
return fs.existsSync(p) ? p : null;
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* Python 进程启动工具
|
||||
* 统一处理中文路径问题
|
||||
*/
|
||||
|
||||
import { spawn, SpawnOptions } from 'child_process';
|
||||
import { normalizePath } from './path-util';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import logger from '../mapi/log/main';
|
||||
|
||||
export interface PythonSpawnOptions extends SpawnOptions {
|
||||
/**
|
||||
* 是否转换所有路径参数(默认 true)
|
||||
*/
|
||||
normalizePaths?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动 Python 进程,自动处理中文路径
|
||||
* @param pythonPath Python 可执行文件路径
|
||||
* @param args Python 脚本和参数
|
||||
* @param options spawn 选项
|
||||
* @returns ChildProcess
|
||||
*/
|
||||
export function spawnPython(
|
||||
pythonPath: string,
|
||||
args: string[],
|
||||
options?: PythonSpawnOptions
|
||||
) {
|
||||
const platform = os.platform();
|
||||
const shouldNormalize = options?.normalizePaths !== false && platform === 'win32';
|
||||
|
||||
// Windows 平台:转换所有路径
|
||||
if (shouldNormalize) {
|
||||
// 转换 Python 可执行文件路径
|
||||
const normalizedPythonPath = normalizePath(pythonPath);
|
||||
|
||||
// 转换参数中的路径
|
||||
const normalizedArgs = args.map((arg, index) => {
|
||||
// 检查是否是文件路径(包含路径分隔符或扩展名)
|
||||
if (arg.includes('/') || arg.includes('\\') ||
|
||||
(arg.includes('.') && !arg.startsWith('-'))) {
|
||||
const normalized = normalizePath(arg);
|
||||
|
||||
if (normalized !== arg) {
|
||||
logger.info('[PythonSpawn] 路径转换', {
|
||||
index,
|
||||
original: arg.substring(0, 100),
|
||||
normalized: normalized.substring(0, 100)
|
||||
});
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
return arg;
|
||||
});
|
||||
|
||||
logger.info('[PythonSpawn] 启动 Python 进程', {
|
||||
pythonPath: normalizedPythonPath.substring(0, 100),
|
||||
argsCount: normalizedArgs.length,
|
||||
cwd: options?.cwd
|
||||
});
|
||||
|
||||
return spawn(normalizedPythonPath, normalizedArgs, options);
|
||||
}
|
||||
|
||||
// macOS/Linux:直接启动
|
||||
return spawn(pythonPath, args, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行 Python 脚本并返回 Promise
|
||||
* @param pythonPath Python 可执行文件路径
|
||||
* @param args Python 脚本和参数
|
||||
* @param options spawn 选项
|
||||
* @returns Promise<{stdout: string, stderr: string, exitCode: number}>
|
||||
*/
|
||||
export function execPython(
|
||||
pythonPath: string,
|
||||
args: string[],
|
||||
options?: PythonSpawnOptions
|
||||
): Promise<{stdout: string, stderr: string, exitCode: number}> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawnPython(pythonPath, args, options);
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
|
||||
child.stdout?.on('data', (data) => {
|
||||
stdout += data.toString();
|
||||
});
|
||||
|
||||
child.stderr?.on('data', (data) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
|
||||
child.on('close', (exitCode) => {
|
||||
resolve({
|
||||
stdout,
|
||||
stderr,
|
||||
exitCode: exitCode || 0
|
||||
});
|
||||
});
|
||||
|
||||
child.on('error', (error) => {
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建临时配置文件(确保在纯英文路径)
|
||||
* @param config 配置对象
|
||||
* @param prefix 文件名前缀
|
||||
* @returns 配置文件路径
|
||||
*/
|
||||
export function createTempConfig(config: any, prefix: string = 'config'): string {
|
||||
const fs = require('fs');
|
||||
|
||||
// 使用系统临时目录(通常是纯英文路径)
|
||||
const tempDir = os.tmpdir();
|
||||
const timestamp = Date.now();
|
||||
const random = Math.random().toString(36).substring(2, 8);
|
||||
const configPath = path.join(tempDir, `${prefix}_${timestamp}_${random}.json`);
|
||||
|
||||
fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8');
|
||||
|
||||
logger.info('[PythonSpawn] 创建临时配置文件', {
|
||||
path: configPath,
|
||||
size: JSON.stringify(config).length
|
||||
});
|
||||
|
||||
return configPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理临时配置文件
|
||||
* @param configPath 配置文件路径
|
||||
*/
|
||||
export function cleanupTempConfig(configPath: string): void {
|
||||
try {
|
||||
const fs = require('fs');
|
||||
if (fs.existsSync(configPath)) {
|
||||
fs.unlinkSync(configPath);
|
||||
logger.info('[PythonSpawn] 清理临时配置文件', { path: configPath });
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn('[PythonSpawn] 清理临时配置文件失败', {
|
||||
path: configPath,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* Python工具模块
|
||||
* 提供Python可执行文件路径查找和验证
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import {spawn} from 'child_process';
|
||||
import { getPythonExecutablePath, resourceExists, isDevelopment } from './resource-path';
|
||||
|
||||
/**
|
||||
* 获取Python可执行文件路径
|
||||
* ⚠️ 只使用程序自带的 Python,不依赖系统环境
|
||||
* 如果找不到自带的 Python,会抛出错误
|
||||
*/
|
||||
export function getPythonPath(): string {
|
||||
const bundledPythonPath = getPythonExecutablePath();
|
||||
const isDev = isDevelopment();
|
||||
|
||||
console.log('[getPythonPath]', {
|
||||
isDev,
|
||||
bundledPythonPath,
|
||||
exists: resourceExists(bundledPythonPath)
|
||||
});
|
||||
|
||||
if (resourceExists(bundledPythonPath)) {
|
||||
console.log('[getPythonPath] ✅ 找到程序自带的 Python:', bundledPythonPath);
|
||||
return bundledPythonPath;
|
||||
}
|
||||
|
||||
// ❌ 找不到自带的 Python - 这是严重错误,应该报错
|
||||
const errorMsg = `❌ 程序自带的 Python 不存在: ${bundledPythonPath}\n` +
|
||||
`环境: ${isDev ? '开发环境' : '生产环境'}\n` +
|
||||
`请确认程序是否正确安装,或重新安装程序。`;
|
||||
console.error('[getPythonPath]', errorMsg);
|
||||
throw new Error(errorMsg);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证Python是否可用
|
||||
* @returns Promise<boolean>
|
||||
*/
|
||||
export async function verifyPython(): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const pythonPath = getPythonPath();
|
||||
const process = spawn(pythonPath, ['--version']);
|
||||
|
||||
process.on('close', (code) => {
|
||||
resolve(code === 0);
|
||||
});
|
||||
|
||||
process.on('error', () => {
|
||||
resolve(false);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取Python版本信息
|
||||
* @returns Promise<string | null>
|
||||
*/
|
||||
export async function getPythonVersion(): Promise<string | null> {
|
||||
return new Promise((resolve) => {
|
||||
const pythonPath = getPythonPath();
|
||||
const process = spawn(pythonPath, ['--version']);
|
||||
|
||||
let output = '';
|
||||
|
||||
process.stdout.on('data', (data) => {
|
||||
output += data.toString();
|
||||
});
|
||||
|
||||
process.stderr.on('data', (data) => {
|
||||
output += data.toString();
|
||||
});
|
||||
|
||||
process.on('close', (code) => {
|
||||
if (code === 0 && output) {
|
||||
resolve(output.trim());
|
||||
} else {
|
||||
resolve(null);
|
||||
}
|
||||
});
|
||||
|
||||
process.on('error', () => {
|
||||
resolve(null);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
/**
|
||||
* 资源路径工具模块
|
||||
* 统一管理开发环境和生产环境的资源路径
|
||||
* ⚠️ 确保所有依赖都能在不同环境下正确识别
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { app } from 'electron';
|
||||
import { AppEnv } from '../mapi/env';
|
||||
|
||||
/**
|
||||
* 判断是否为开发环境
|
||||
*/
|
||||
export function isDevelopment(): boolean {
|
||||
const appRoot = process.env.APP_ROOT || process.env.ELECTRON_RESOURCES_PATH || process.cwd();
|
||||
// 修复:dist-release目录不是开发环境
|
||||
if (appRoot.includes('dist-release') || appRoot.includes('dist-electron')) {
|
||||
return false;
|
||||
}
|
||||
// 🔧 修复:不硬编码项目文件夹名,改用检查开发目录结构是否存在
|
||||
// 开发环境特征:项目根目录有 electron/resources/extra 目录
|
||||
const devResourcePath = path.join(appRoot, 'electron', 'resources', 'extra');
|
||||
if (fs.existsSync(devResourcePath)) {
|
||||
return true;
|
||||
}
|
||||
// 回退:检查旧的文件夹名(兼容)
|
||||
return appRoot.includes('aigcpanel-main');
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取资源根目录
|
||||
*/
|
||||
export function getResourceRoot(): string {
|
||||
const isDev = isDevelopment();
|
||||
|
||||
if (isDev) {
|
||||
// 开发环境:项目根目录/electron/resources/extra
|
||||
const appRoot = process.env.APP_ROOT || process.cwd();
|
||||
return path.join(appRoot, 'electron', 'resources', 'extra');
|
||||
} else {
|
||||
// 生产环境:使用 process.resourcesPath 获取 resources 目录
|
||||
// process.resourcesPath 指向 E:\xxx\resources
|
||||
// 资源文件在 E:\xxx\resources\extra
|
||||
// 关键修复:直接使用 process.resourcesPath 而不是 APP_ROOT
|
||||
// 因为 APP_ROOT 被设置为 resources 的父目录,会导致路径错误
|
||||
const resourcesPath = process.resourcesPath || process.env.ELECTRON_RESOURCES_PATH;
|
||||
if (resourcesPath) {
|
||||
return path.join(resourcesPath, 'extra');
|
||||
}
|
||||
// 回退:使用 APP_ROOT + resources/extra(仅当 process.resourcesPath 不可用时)
|
||||
const appRoot = process.env.APP_ROOT || process.cwd();
|
||||
return path.join(appRoot, 'resources', 'extra');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取通用资源路径(common 目录下的资源)
|
||||
* @param subPath 子路径(相对于 extra/common)
|
||||
*/
|
||||
export function getCommonResourcePath(subPath: string): string {
|
||||
const resourceRoot = getResourceRoot();
|
||||
return path.join(resourceRoot, 'common', subPath);
|
||||
}
|
||||
|
||||
export function getRuntimeRoot(...segments: string[]): string {
|
||||
const baseRoot = AppEnv.installRoot || process.env.APP_ROOT || process.cwd();
|
||||
return path.join(baseRoot, ...segments);
|
||||
}
|
||||
|
||||
export function getRuntimeDataPath(...segments: string[]): string {
|
||||
const dataRoot = AppEnv.dataRoot || getRuntimeRoot('data');
|
||||
return path.join(dataRoot, ...segments);
|
||||
}
|
||||
|
||||
export function getRuntimeBundleRoot(): string {
|
||||
return AppEnv.resourceBundleRoot || getRuntimeRoot('resources-bundles');
|
||||
}
|
||||
|
||||
export function getRuntimeBundlePath(bundleName: string, ...segments: string[]): string {
|
||||
return path.join(getRuntimeBundleRoot(), bundleName, ...segments);
|
||||
}
|
||||
|
||||
export function getResourceStatePath(...segments: string[]): string {
|
||||
const root = AppEnv.resourceStateRoot || getRuntimeDataPath('resource-state');
|
||||
return path.join(root, ...segments);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取平台特定资源路径
|
||||
* @param subPath 子路径(相对于 extra/{platform})
|
||||
*/
|
||||
export function getPlatformResourcePath(subPath: string): string {
|
||||
const resourceRoot = getResourceRoot();
|
||||
const platform = getPlatformDir();
|
||||
return path.join(resourceRoot, platform, subPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取平台目录名
|
||||
*/
|
||||
export function getPlatformDir(): string {
|
||||
const isWin = process.platform === 'win32';
|
||||
const isMac = process.platform === 'darwin';
|
||||
|
||||
if (isWin) return 'win-x86';
|
||||
if (isMac) return 'osx';
|
||||
return 'linux';
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查资源文件是否存在
|
||||
* @param resourcePath 资源路径
|
||||
* @returns 文件是否存在
|
||||
*/
|
||||
export function resourceExists(resourcePath: string): boolean {
|
||||
return fs.existsSync(resourcePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取并验证资源路径
|
||||
* @param resourcePath 资源路径
|
||||
* @param errorMessage 文件不存在时的错误消息
|
||||
* @returns 验证后的资源路径
|
||||
* @throws 如果资源不存在则抛出错误
|
||||
*/
|
||||
export function getAndVerifyResource(resourcePath: string, errorMessage?: string): string {
|
||||
if (!fs.existsSync(resourcePath)) {
|
||||
const error = errorMessage || `资源文件不存在: ${resourcePath}`;
|
||||
throw new Error(error);
|
||||
}
|
||||
return resourcePath;
|
||||
}
|
||||
|
||||
/**
|
||||
* 快捷方法:获取 Python 可执行文件路径
|
||||
*/
|
||||
export function getPythonExecutablePath(): string {
|
||||
const isWin = process.platform === 'win32';
|
||||
const pythonFileName = isWin ? 'python.exe' : 'python3';
|
||||
const runtimePath = getRuntimeBundlePath('python-runtime', pythonFileName);
|
||||
if (fs.existsSync(runtimePath)) {
|
||||
return runtimePath;
|
||||
}
|
||||
return getCommonResourcePath(path.join('python', pythonFileName));
|
||||
}
|
||||
|
||||
/**
|
||||
* 快捷方法:获取 FFmpeg 可执行文件路径
|
||||
*/
|
||||
export function getFFmpegExecutablePath(): string {
|
||||
const isWin = process.platform === 'win32';
|
||||
const isMac = process.platform === 'darwin';
|
||||
|
||||
let ffmpegFileName = 'ffmpeg';
|
||||
if (isWin) ffmpegFileName = 'ffmpeg.exe';
|
||||
|
||||
const runtimePath = getRuntimeBundlePath('ffmpeg', ffmpegFileName);
|
||||
if (fs.existsSync(runtimePath)) {
|
||||
return runtimePath;
|
||||
}
|
||||
|
||||
return getPlatformResourcePath(ffmpegFileName);
|
||||
}
|
||||
|
||||
/**
|
||||
* 快捷方法:获取 FFprobe 可执行文件路径
|
||||
*/
|
||||
export function getFFprobeExecutablePath(): string {
|
||||
const isWin = process.platform === 'win32';
|
||||
const isMac = process.platform === 'darwin';
|
||||
|
||||
let ffprobeFileName = 'ffprobe';
|
||||
if (isWin) ffprobeFileName = 'ffprobe.exe';
|
||||
|
||||
const runtimePath = getRuntimeBundlePath('ffmpeg', ffprobeFileName);
|
||||
if (fs.existsSync(runtimePath)) {
|
||||
return runtimePath;
|
||||
}
|
||||
|
||||
return getPlatformResourcePath(ffprobeFileName);
|
||||
}
|
||||
|
||||
/**
|
||||
* 快捷方法:获取模型文件路径
|
||||
* @param modelFileName 模型文件名(如 'u2net.onnx')
|
||||
*/
|
||||
export function getModelPath(modelFileName: string): string {
|
||||
const runtimePath = getRuntimeBundlePath('models', modelFileName);
|
||||
if (fs.existsSync(runtimePath)) {
|
||||
return runtimePath;
|
||||
}
|
||||
return getCommonResourcePath(path.join('models', modelFileName));
|
||||
}
|
||||
|
||||
/**
|
||||
* 快捷方法:获取 Python 脚本路径
|
||||
* @param scriptFileName Python 脚本文件名
|
||||
*/
|
||||
export function getPythonScriptPath(scriptFileName: string): string {
|
||||
const runtimePath = getRuntimeBundlePath('python-runtime', scriptFileName);
|
||||
if (fs.existsSync(runtimePath)) {
|
||||
return runtimePath;
|
||||
}
|
||||
|
||||
const appRoot = process.env.APP_ROOT || process.env.ELECTRON_RESOURCES_PATH || process.cwd();
|
||||
const isDev = isDevelopment();
|
||||
|
||||
if (isDev) {
|
||||
return path.join(appRoot, 'python', scriptFileName);
|
||||
} else {
|
||||
const asarUnpackedPath = path.join(appRoot, 'app.asar.unpacked', 'python', scriptFileName);
|
||||
if (fs.existsSync(asarUnpackedPath)) {
|
||||
return asarUnpackedPath;
|
||||
}
|
||||
return path.join(appRoot, 'python', scriptFileName);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 诊断信息:打印所有资源路径
|
||||
*/
|
||||
export function printResourceDiagnostics(): void {
|
||||
const isDev = isDevelopment();
|
||||
const resourceRoot = getResourceRoot();
|
||||
|
||||
console.log('\n========== 资源路径诊断 ==========');
|
||||
console.log('环境模式:', isDev ? '开发环境' : '生产环境');
|
||||
console.log('App Root:', process.env.APP_ROOT || process.cwd());
|
||||
console.log('资源根目录:', resourceRoot);
|
||||
console.log('\n关键资源路径:');
|
||||
console.log(' Python:', getPythonExecutablePath(), '存在:', resourceExists(getPythonExecutablePath()));
|
||||
console.log(' FFmpeg:', getFFmpegExecutablePath(), '存在:', resourceExists(getFFmpegExecutablePath()));
|
||||
console.log(' FFprobe:', getFFprobeExecutablePath(), '存在:', resourceExists(getFFprobeExecutablePath()));
|
||||
console.log(' U2Net模型:', getModelPath('u2net.onnx'), '存在:', resourceExists(getModelPath('u2net.onnx')));
|
||||
console.log('=====================================\n');
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* 🔐 Store Manager - 用于持久化存储激活信息和设备指纹
|
||||
* 使用 JSON 文件存储,位于用户数据目录
|
||||
*/
|
||||
|
||||
import { app } from 'electron';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import Log from '../mapi/log/main';
|
||||
|
||||
interface StoreData {
|
||||
activation?: any;
|
||||
deviceFingerprint?: string;
|
||||
}
|
||||
|
||||
export class Store {
|
||||
private static instance: Store | null = null;
|
||||
private storePath: string;
|
||||
private data: StoreData = {};
|
||||
|
||||
private constructor() {
|
||||
const userDataPath = app.getPath('userData');
|
||||
this.storePath = path.join(userDataPath, 'store.json');
|
||||
this.load();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 Store 单例实例(延迟初始化)
|
||||
*/
|
||||
static getInstance(): Store {
|
||||
if (!Store.instance) {
|
||||
Store.instance = new Store();
|
||||
}
|
||||
return Store.instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从文件加载数据
|
||||
*/
|
||||
private load(): void {
|
||||
try {
|
||||
if (fs.existsSync(this.storePath)) {
|
||||
const content = fs.readFileSync(this.storePath, 'utf-8');
|
||||
this.data = JSON.parse(content);
|
||||
Log.info('[Store] 已从文件加载持久化数据:', {
|
||||
hasActivation: !!this.data.activation,
|
||||
hasDeviceFingerprint: !!this.data.deviceFingerprint
|
||||
});
|
||||
} else {
|
||||
this.data = {};
|
||||
Log.info('[Store] Store 文件不存在,使用空数据');
|
||||
}
|
||||
} catch (error) {
|
||||
Log.error('[Store] 加载文件失败:', error);
|
||||
this.data = {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存数据到文件
|
||||
*/
|
||||
private save(): void {
|
||||
try {
|
||||
const dir = path.dirname(this.storePath);
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
fs.writeFileSync(this.storePath, JSON.stringify(this.data, null, 2), 'utf-8');
|
||||
Log.info('[Store] 数据已保存到文件');
|
||||
} catch (error) {
|
||||
Log.error('[Store] 保存文件失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取激活信息
|
||||
*/
|
||||
getActivation(): any {
|
||||
Log.info('[Store] 获取激活信息:', !!this.data.activation);
|
||||
return this.data.activation || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存激活信息
|
||||
*/
|
||||
setActivation(data: any): void {
|
||||
Log.info('[Store] 保存激活信息');
|
||||
this.data.activation = data;
|
||||
this.save();
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除激活信息
|
||||
*/
|
||||
clearActivation(): void {
|
||||
Log.info('[Store] 清除激活信息');
|
||||
delete this.data.activation;
|
||||
this.save();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取设备指纹
|
||||
*/
|
||||
getDeviceFingerprint(): string | null {
|
||||
Log.info('[Store] 获取设备指纹:', !!this.data.deviceFingerprint);
|
||||
return this.data.deviceFingerprint || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存设备指纹
|
||||
*/
|
||||
setDeviceFingerprint(fingerprint: string): void {
|
||||
Log.info('[Store] 保存设备指纹');
|
||||
this.data.deviceFingerprint = fingerprint;
|
||||
this.save();
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除设备指纹
|
||||
*/
|
||||
clearDeviceFingerprint(): void {
|
||||
Log.info('[Store] 清除设备指纹');
|
||||
delete this.data.deviceFingerprint;
|
||||
this.save();
|
||||
}
|
||||
}
|
||||
|
||||
// 延迟初始化:只有在第一次调用时才创建 Store 实例
|
||||
// 这样确保 AppEnv 已经初始化完成
|
||||
export default {
|
||||
getActivation: () => Store.getInstance().getActivation(),
|
||||
setActivation: (data: any) => Store.getInstance().setActivation(data),
|
||||
clearActivation: () => Store.getInstance().clearActivation(),
|
||||
getDeviceFingerprint: () => Store.getInstance().getDeviceFingerprint(),
|
||||
setDeviceFingerprint: (fingerprint: string) => Store.getInstance().setDeviceFingerprint(fingerprint),
|
||||
clearDeviceFingerprint: () => Store.getInstance().clearDeviceFingerprint(),
|
||||
};
|
||||
@@ -0,0 +1,865 @@
|
||||
import chardet from "chardet";
|
||||
import dayjs from "dayjs";
|
||||
import iconvLite from "iconv-lite";
|
||||
import {Base64} from "js-base64";
|
||||
import * as crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import Showdown from "showdown";
|
||||
// import {Iconv} from "iconv"
|
||||
import {isMac, isWin} from "./env";
|
||||
import FileIndex from "../mapi/file";
|
||||
|
||||
export const EncodeUtil = {
|
||||
base32Alphabet: "abcdefghijklmnopqrstuvwxyz234567",
|
||||
base32Encode(str: string) {
|
||||
const buffer = Buffer.from(str, "utf8");
|
||||
let bits = "";
|
||||
let output = "";
|
||||
// 将每个字节转为8位二进制
|
||||
for (let i = 0; i < buffer.length; i++) {
|
||||
const byte = buffer[i];
|
||||
bits += byte.toString(2).padStart(8, "0");
|
||||
}
|
||||
// 每5位一组,转为 Base32 字符
|
||||
for (let i = 0; i < bits.length; i += 5) {
|
||||
const chunk = bits.slice(i, i + 5);
|
||||
const paddedChunk = chunk.padEnd(5, "0"); // 不足5位补0
|
||||
const index = parseInt(paddedChunk, 2);
|
||||
output += EncodeUtil.base32Alphabet[index];
|
||||
}
|
||||
return output;
|
||||
},
|
||||
base32Decode(str: string) {
|
||||
const base32Alphabet = "abcdefghijklmnopqrstuvwxyz234567";
|
||||
let bits = "";
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
const char = str[i];
|
||||
const index = base32Alphabet.indexOf(char);
|
||||
if (index === -1) {
|
||||
throw new Error("Invalid Base32 character: " + char);
|
||||
}
|
||||
bits += index.toString(2).padStart(5, "0");
|
||||
}
|
||||
|
||||
const bytes: number[] = [];
|
||||
for (let i = 0; i + 8 <= bits.length; i += 8) {
|
||||
const byte = bits.slice(i, i + 8);
|
||||
bytes.push(parseInt(byte, 2));
|
||||
}
|
||||
|
||||
return Buffer.from(bytes).toString("utf8");
|
||||
},
|
||||
base64Encode(str: string) {
|
||||
return Base64.encode(str);
|
||||
},
|
||||
base64Decode(str: string) {
|
||||
return Base64.decode(str);
|
||||
},
|
||||
md5(str: string) {
|
||||
return crypto.createHash("md5").update(str).digest("hex");
|
||||
},
|
||||
aesEncode(str: string, key: string) {
|
||||
const cipher = crypto.createCipheriv("aes-128-ecb", key, "");
|
||||
let crypted = cipher.update(str, "utf8", "base64");
|
||||
crypted += cipher.final("base64");
|
||||
return crypted;
|
||||
},
|
||||
aesDecode(str: string, key: string) {
|
||||
const decipher = crypto.createDecipheriv("aes-128-ecb", key, "");
|
||||
let dec = decipher.update(str, "base64", "utf8");
|
||||
dec += decipher.final("utf8");
|
||||
return dec;
|
||||
},
|
||||
async fileXzipEncode(pathname: string): Promise<string> {
|
||||
if (!fs.existsSync(pathname)) {
|
||||
throw new Error(`Input file not found: ${pathname}`);
|
||||
}
|
||||
|
||||
// Generate new filepath with .xzip extension
|
||||
const basePath = pathname.substring(0, pathname.lastIndexOf("."));
|
||||
const outputPath = basePath + ".xzip";
|
||||
|
||||
// Get file info
|
||||
const fileStats = fs.statSync(pathname);
|
||||
const fileSize = fileStats.size;
|
||||
const fileExt = pathname.split(".").pop() || "";
|
||||
|
||||
// Generate random 16-character key
|
||||
const encryptionKey = StrUtil.randomString(16);
|
||||
|
||||
// Create metadata
|
||||
const filemeta = {
|
||||
version: 1,
|
||||
format: fileExt,
|
||||
size: fileSize,
|
||||
key: encryptionKey,
|
||||
};
|
||||
|
||||
// Convert metadata to JSON and then base64 encode
|
||||
const metaJson = JSON.stringify(filemeta);
|
||||
const metaB64 = Buffer.from(metaJson, "utf-8").toString("base64");
|
||||
const metaLength = metaB64.length;
|
||||
|
||||
// Prepare encryption key
|
||||
const keyBytes = Buffer.from(encryptionKey, "utf-8");
|
||||
const keyLength = keyBytes.length;
|
||||
|
||||
// Stream processing: read, encrypt and write in chunks
|
||||
const inputStream = fs.createReadStream(pathname);
|
||||
const outputStream = fs.createWriteStream(outputPath);
|
||||
|
||||
// Write metadata length (4 bytes, little-endian)
|
||||
const metaLengthBuffer = Buffer.allocUnsafe(4);
|
||||
metaLengthBuffer.writeUInt32LE(metaLength, 0);
|
||||
outputStream.write(metaLengthBuffer);
|
||||
|
||||
// Write base64 encoded metadata
|
||||
outputStream.write(Buffer.from(metaB64, "utf-8"));
|
||||
|
||||
// Stream encrypt the file content
|
||||
let bytesProcessed = 0;
|
||||
return new Promise((resolve, reject) => {
|
||||
inputStream.on("data", (chunk: Buffer) => {
|
||||
// XOR encrypt the chunk
|
||||
const encryptedChunk = Buffer.alloc(chunk.length);
|
||||
for (let i = 0; i < chunk.length; i++) {
|
||||
encryptedChunk[i] = chunk[i] ^ keyBytes[bytesProcessed % keyLength];
|
||||
bytesProcessed++;
|
||||
}
|
||||
|
||||
// Write encrypted chunk
|
||||
outputStream.write(encryptedChunk);
|
||||
});
|
||||
|
||||
inputStream.on("end", () => {
|
||||
outputStream.end();
|
||||
resolve(outputPath);
|
||||
});
|
||||
|
||||
inputStream.on("error", error => {
|
||||
outputStream.destroy();
|
||||
reject(error);
|
||||
});
|
||||
|
||||
outputStream.on("error", error => {
|
||||
inputStream.destroy();
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
},
|
||||
async fileXzipDecode(pathname: string): Promise<string> {
|
||||
if (!fs.existsSync(pathname)) {
|
||||
throw new Error(`Input file not found: ${pathname}`);
|
||||
}
|
||||
|
||||
if (!pathname.endsWith(".xzip")) {
|
||||
return pathname; // Not an xzip file, return as is
|
||||
}
|
||||
|
||||
let outputPath = pathname.replace(/\.xzip$/, "");
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const inputStream = fs.createReadStream(pathname);
|
||||
let metadataRead = false;
|
||||
let filemeta: any = null;
|
||||
let keyBytes: Buffer;
|
||||
let bytesProcessed = 0;
|
||||
let outputStream: fs.WriteStream;
|
||||
let remainingMetaBytes = 0;
|
||||
let metaBuffer = Buffer.alloc(0);
|
||||
|
||||
inputStream.on("data", (chunk: Buffer) => {
|
||||
let chunkOffset = 0;
|
||||
|
||||
if (!metadataRead) {
|
||||
if (remainingMetaBytes === 0) {
|
||||
// Read metadata length (first 4 bytes)
|
||||
if (chunk.length < 4) {
|
||||
reject(new Error("Invalid xzip file: insufficient data for metadata length"));
|
||||
return;
|
||||
}
|
||||
const metaLength = chunk.readUInt32LE(0);
|
||||
remainingMetaBytes = metaLength;
|
||||
chunkOffset = 4;
|
||||
}
|
||||
|
||||
// Read metadata
|
||||
const availableMetaBytes = Math.min(remainingMetaBytes, chunk.length - chunkOffset);
|
||||
const metaChunk = chunk.subarray(chunkOffset, chunkOffset + availableMetaBytes);
|
||||
metaBuffer = Buffer.concat([metaBuffer, metaChunk] as readonly Uint8Array[]);
|
||||
remainingMetaBytes -= availableMetaBytes;
|
||||
chunkOffset += availableMetaBytes;
|
||||
|
||||
if (remainingMetaBytes === 0) {
|
||||
// Parse metadata
|
||||
try {
|
||||
const metaB64 = metaBuffer.toString("utf-8");
|
||||
const metaJson = Buffer.from(metaB64, "base64").toString("utf-8");
|
||||
filemeta = JSON.parse(metaJson);
|
||||
keyBytes = Buffer.from(filemeta.key, "utf-8");
|
||||
|
||||
// Create output file with correct extension
|
||||
const finalOutputPath = outputPath + (filemeta.format ? "." + filemeta.format : "");
|
||||
outputStream = fs.createWriteStream(finalOutputPath);
|
||||
|
||||
metadataRead = true;
|
||||
|
||||
// Set the final output path for resolution
|
||||
outputPath = finalOutputPath;
|
||||
} catch (error) {
|
||||
reject(new Error("Invalid xzip file: corrupted metadata"));
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (metadataRead && chunkOffset < chunk.length) {
|
||||
// Decrypt remaining chunk data
|
||||
const encryptedChunk = chunk.subarray(chunkOffset);
|
||||
const decryptedChunk = Buffer.alloc(encryptedChunk.length);
|
||||
const keyLength = keyBytes.length;
|
||||
|
||||
for (let i = 0; i < encryptedChunk.length; i++) {
|
||||
decryptedChunk[i] = encryptedChunk[i] ^ keyBytes[bytesProcessed % keyLength];
|
||||
bytesProcessed++;
|
||||
}
|
||||
|
||||
outputStream.write(decryptedChunk);
|
||||
}
|
||||
});
|
||||
|
||||
inputStream.on("end", () => {
|
||||
if (outputStream) {
|
||||
outputStream.end();
|
||||
resolve(outputPath);
|
||||
} else {
|
||||
reject(new Error("Invalid xzip file: incomplete metadata"));
|
||||
}
|
||||
});
|
||||
|
||||
inputStream.on("error", error => {
|
||||
if (outputStream) {
|
||||
outputStream.destroy();
|
||||
}
|
||||
reject(error);
|
||||
});
|
||||
|
||||
if (outputStream) {
|
||||
outputStream.on("error", error => {
|
||||
inputStream.destroy();
|
||||
reject(error);
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export const IconvUtil = {
|
||||
convert(str: string, to?: string, from?: string) {
|
||||
if (!from) {
|
||||
from = chardet.detect(Buffer.from(str));
|
||||
}
|
||||
to = to || "utf8";
|
||||
const buffer = iconvLite.encode(str, from);
|
||||
return iconvLite.decode(buffer, to);
|
||||
},
|
||||
bufferToUtf8(buffer: Buffer) {
|
||||
const encoding = chardet.detect(buffer);
|
||||
// if ('ISO-2022-CN' === encoding) {
|
||||
// const iconvInstance = new Iconv('ISO-2022-CN', 'UTF-8//TRANSLIT//IGNORE');
|
||||
// return iconvInstance.convert(buffer).toString()
|
||||
// }
|
||||
return iconvLite.decode(buffer, encoding).toString();
|
||||
},
|
||||
detect(buffer: Uint8Array) {
|
||||
// detect str encoding
|
||||
return chardet.detect(buffer);
|
||||
},
|
||||
};
|
||||
|
||||
export const StrUtil = {
|
||||
randomString(len: number = 32) {
|
||||
const chars = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
|
||||
let result = "";
|
||||
for (let i = len; i > 0; --i) {
|
||||
result += chars[Math.floor(Math.random() * chars.length)];
|
||||
}
|
||||
return result;
|
||||
},
|
||||
uuid() {
|
||||
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, function (c) {
|
||||
const r = (Math.random() * 16) | 0;
|
||||
const v = c === "x" ? r : (r & 0x3) | 0x8;
|
||||
return v.toString(16);
|
||||
});
|
||||
},
|
||||
hashCode(str: string, length: number = 8) {
|
||||
let hash = 0;
|
||||
if (str.length === 0) return hash + "";
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
const char = str.charCodeAt(i);
|
||||
hash = (hash << 5) - hash + char;
|
||||
hash = hash & hash;
|
||||
}
|
||||
let result = Math.abs(hash).toString(16);
|
||||
if (result.length < length) {
|
||||
result = "0".repeat(length - result.length) + result;
|
||||
}
|
||||
return result;
|
||||
},
|
||||
hashCodeWithDuplicateCheck(str: string, check: string[], length: number = 8) {
|
||||
let code = this.hashCode(str, length);
|
||||
while (check.includes(code)) {
|
||||
code = this.uuid().substring(0, length);
|
||||
}
|
||||
return code;
|
||||
},
|
||||
bigIntegerId() {
|
||||
return [Date.now(), (Math.floor(Math.random() * 1000000) + "").padStart(6, "0")].join("");
|
||||
},
|
||||
ucFirst(str: string) {
|
||||
if (!str) return "";
|
||||
return str.charAt(0).toUpperCase() + str.slice(1);
|
||||
},
|
||||
};
|
||||
|
||||
export const TimeUtil = {
|
||||
timestampInMs() {
|
||||
return Date.now();
|
||||
},
|
||||
timestamp() {
|
||||
return Math.floor(Date.now() / 1000);
|
||||
},
|
||||
format(time: number, format: string = "YYYY-MM-DD HH:mm:ss") {
|
||||
return dayjs(time).format(format);
|
||||
},
|
||||
formatDate(time: number) {
|
||||
return dayjs(time).format("YYYY-MM-DD");
|
||||
},
|
||||
dateString() {
|
||||
return dayjs().format("YYYYMMDD");
|
||||
},
|
||||
datetimeString() {
|
||||
return dayjs().format("YYYYMMDD_HHmmss_SSS");
|
||||
},
|
||||
timestampDayStart(msTimestamp?: number) {
|
||||
let date = msTimestamp ? new Date(msTimestamp) : new Date();
|
||||
date.setHours(0, 0, 0, 0);
|
||||
return Math.floor(date.getTime() / 1000);
|
||||
},
|
||||
replacePattern(text: string) {
|
||||
// @ts-ignore
|
||||
return text.replaceAll("{year}", dayjs().format("YYYY"))
|
||||
.replaceAll("{month}", dayjs().format("MM"))
|
||||
.replaceAll("{day}", dayjs().format("DD"))
|
||||
.replaceAll("{hour}", dayjs().format("HH"))
|
||||
.replaceAll("{minute}", dayjs().format("mm"))
|
||||
.replaceAll("{second}", dayjs().format("ss"));
|
||||
},
|
||||
};
|
||||
|
||||
export const FileUtil = {
|
||||
MIME_TYPES: {
|
||||
html: "text/html",
|
||||
htm: "text/html",
|
||||
js: "application/javascript",
|
||||
css: "text/css",
|
||||
json: "application/json",
|
||||
png: "image/png",
|
||||
jpg: "image/jpeg",
|
||||
jpeg: "image/jpeg",
|
||||
gif: "image/gif",
|
||||
svg: "image/svg+xml",
|
||||
webp: "image/webp",
|
||||
woff: "font/woff",
|
||||
woff2: "font/woff2",
|
||||
ttf: "font/ttf",
|
||||
otf: "font/otf",
|
||||
mp3: "audio/mpeg",
|
||||
mp4: "video/mp4",
|
||||
wav: "audio/wav",
|
||||
wasm: "application/wasm",
|
||||
eot: "application/vnd.ms-fontobject",
|
||||
},
|
||||
getMimeByExt(ext: string, defaultMime: string = ""): string {
|
||||
ext = ext.toLowerCase();
|
||||
if (ext.startsWith(".")) {
|
||||
ext = ext.substring(1);
|
||||
}
|
||||
return FileUtil.MIME_TYPES[ext] || defaultMime;
|
||||
},
|
||||
getMimeByPath(p: string, defaultMime: string = ""): string {
|
||||
const extension = p.split(".").pop().toLowerCase();
|
||||
return FileUtil.getMimeByExt(extension, defaultMime);
|
||||
},
|
||||
streamToBase64(stream: NodeJS.ReadableStream): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const chunks = [];
|
||||
stream.on("data", chunk => {
|
||||
chunks.push(chunk);
|
||||
});
|
||||
stream.on("end", () => {
|
||||
const buffer = Buffer.concat(chunks);
|
||||
resolve(buffer.toString("base64"));
|
||||
});
|
||||
stream.on("error", error => {
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
},
|
||||
bufferToBase64(buffer: Buffer) {
|
||||
let binary = "";
|
||||
let bytes = new Uint8Array(buffer);
|
||||
let len = bytes.byteLength;
|
||||
for (let i = 0; i < len; i++) {
|
||||
binary += String.fromCharCode(bytes[i]);
|
||||
}
|
||||
return EncodeUtil.base64Encode(binary);
|
||||
},
|
||||
base64ToBuffer(base64: string): Buffer {
|
||||
if (base64.startsWith("data:")) {
|
||||
base64 = base64.split("base64,")[1];
|
||||
}
|
||||
return Buffer.from(base64, "base64");
|
||||
},
|
||||
formatSize(size: number) {
|
||||
if (size < 1024) {
|
||||
return size + "B";
|
||||
} else if (size < 1024 * 1024) {
|
||||
return (size / 1024).toFixed(2) + "KB";
|
||||
} else if (size < 1024 * 1024 * 1024) {
|
||||
return (size / 1024 / 1024).toFixed(2) + "MB";
|
||||
} else {
|
||||
return (size / 1024 / 1024 / 1024).toFixed(2) + "GB";
|
||||
}
|
||||
},
|
||||
async md5(filePath: string) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const hash = crypto.createHash("md5");
|
||||
const stream = fs.createReadStream(filePath);
|
||||
stream.on("data", data => {
|
||||
hash.update(data);
|
||||
});
|
||||
stream.on("end", () => {
|
||||
resolve(hash.digest("hex"));
|
||||
});
|
||||
stream.on("error", error => {
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export const JsonUtil = {
|
||||
stringifyOrdered(obj: any) {
|
||||
return JSON.stringify(obj, Object.keys(obj).sort(), 4);
|
||||
},
|
||||
stringifyValueOrdered(obj: any) {
|
||||
const sortedData = Object.fromEntries(
|
||||
Object.entries(obj).sort(([, a], [, b]) => {
|
||||
// @ts-ignore
|
||||
return ((a as any) - b) as any;
|
||||
})
|
||||
);
|
||||
return JSON.stringify(sortedData, null, 4);
|
||||
},
|
||||
};
|
||||
|
||||
export const ImportUtil = {
|
||||
async loadCommonJs(cjsPath: string, forceReload: boolean = true) {
|
||||
let tempPath = cjsPath;
|
||||
if (forceReload) {
|
||||
const md5 = await FileUtil.md5(cjsPath);
|
||||
tempPath = path.join(
|
||||
await FileIndex.tempDir('commonJs'),
|
||||
`${md5}.cjs`,
|
||||
)
|
||||
if (!fs.existsSync(tempPath)) {
|
||||
fs.copyFileSync(cjsPath, tempPath);
|
||||
}
|
||||
}
|
||||
const backend = await import(/* @vite-ignore */ `file://${tempPath}`);
|
||||
// console.log('loadCommonJs', `${cjsPath}?t=${md5}`)
|
||||
return backend.default;
|
||||
},
|
||||
};
|
||||
|
||||
export const MemoryCacheUtil = {
|
||||
pool: {} as {
|
||||
[key: string]: {
|
||||
value: any;
|
||||
expire: number;
|
||||
};
|
||||
},
|
||||
_gc() {
|
||||
const now = TimeUtil.timestamp();
|
||||
for (const key in this.pool) {
|
||||
if (this.pool[key].expire < now) {
|
||||
delete this.pool[key];
|
||||
}
|
||||
}
|
||||
},
|
||||
async remember(key: string, callback: () => Promise<any>, ttl: number = 60) {
|
||||
if (this.pool[key] && this.pool[key].expire > TimeUtil.timestamp()) {
|
||||
return this.pool[key].value;
|
||||
}
|
||||
const value = await callback();
|
||||
this.pool[key] = {
|
||||
value,
|
||||
expire: TimeUtil.timestamp() + ttl,
|
||||
};
|
||||
this._gc();
|
||||
return value;
|
||||
},
|
||||
get(key: string) {
|
||||
if (this.pool[key] && this.pool[key].expire > TimeUtil.timestamp()) {
|
||||
return this.pool[key].value;
|
||||
}
|
||||
this._gc();
|
||||
return null;
|
||||
},
|
||||
set(key: string, value: any, ttl: number = 86400) {
|
||||
this.pool[key] = {
|
||||
value,
|
||||
expire: TimeUtil.timestamp() + ttl,
|
||||
};
|
||||
this._gc();
|
||||
},
|
||||
forget(key: string) {
|
||||
delete this.pool[key];
|
||||
},
|
||||
};
|
||||
|
||||
export const MemoryMapCacheUtil = {
|
||||
pool: {} as {
|
||||
[group: string]: {
|
||||
[key: string]: {
|
||||
value: any;
|
||||
expire: number;
|
||||
};
|
||||
};
|
||||
},
|
||||
_gc() {
|
||||
const now = TimeUtil.timestamp();
|
||||
for (const group in this.pool) {
|
||||
for (const key in this.pool[group]) {
|
||||
if (this.pool[group][key].expire < now) {
|
||||
delete this.pool[group][key];
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
get(group: string, key: string) {
|
||||
if (this.pool[group] && this.pool[group][key] && this.pool[group][key].expire > TimeUtil.timestamp()) {
|
||||
return this.pool[group][key].value;
|
||||
}
|
||||
this._gc();
|
||||
return null;
|
||||
},
|
||||
set(group: string, key: string, value: any, ttl: number = 86400) {
|
||||
if (!this.pool[group]) {
|
||||
this.pool[group] = {};
|
||||
}
|
||||
this.pool[group][key] = {
|
||||
value,
|
||||
expire: TimeUtil.timestamp() + ttl,
|
||||
};
|
||||
this._gc();
|
||||
},
|
||||
forget(group: string, key: string) {
|
||||
if (this.pool[group]) {
|
||||
delete this.pool[group][key];
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
export const ShellUtil = {
|
||||
quotaPath(p: string) {
|
||||
return `"${p}"`;
|
||||
},
|
||||
parseCommandArgs(command: string) {
|
||||
let args = [];
|
||||
let arg = "";
|
||||
let quote = "";
|
||||
let escape = false;
|
||||
for (let i = 0; i < command.length; i++) {
|
||||
const c = command[i];
|
||||
if (escape) {
|
||||
arg += c;
|
||||
escape = false;
|
||||
continue;
|
||||
}
|
||||
if ("\\" === c) {
|
||||
escape = true;
|
||||
arg += c;
|
||||
continue;
|
||||
}
|
||||
if ("" === quote && (" " === c || "\t" === c)) {
|
||||
if (arg) {
|
||||
args.push(arg);
|
||||
arg = "";
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if ("" === quote && ('"' === c || "'" === c)) {
|
||||
quote = c;
|
||||
arg += c;
|
||||
continue;
|
||||
}
|
||||
if ('"' === quote && '"' === c) {
|
||||
quote = "";
|
||||
arg += c;
|
||||
continue;
|
||||
}
|
||||
if ("'" === quote && "'" === c) {
|
||||
quote = "";
|
||||
arg += c;
|
||||
continue;
|
||||
}
|
||||
arg += c;
|
||||
}
|
||||
if (arg) {
|
||||
args.push(arg);
|
||||
}
|
||||
return args;
|
||||
},
|
||||
};
|
||||
|
||||
export const VersionUtil = {
|
||||
/**
|
||||
* 检测版本是否匹配
|
||||
* @param v string
|
||||
* @param match string 如 * 或 >=1.0.0 或 >1.0.0 或 <1.0.0 或 <=1.0.0 或 1.0.0
|
||||
*/
|
||||
match(v: string, match: string) {
|
||||
if (match === "*") {
|
||||
return true;
|
||||
}
|
||||
if (match.startsWith(">=")) {
|
||||
if (this.ge(v, match.substring(2))) {
|
||||
return true;
|
||||
}
|
||||
} else if (match.startsWith("<=")) {
|
||||
if (this.le(v, match.substring(2))) {
|
||||
return true;
|
||||
}
|
||||
} else if (match.startsWith(">")) {
|
||||
if (this.gt(v, match.substring(1))) {
|
||||
return true;
|
||||
}
|
||||
} else if (match.startsWith("<")) {
|
||||
if (this.lt(v, match.substring(1))) {
|
||||
return true;
|
||||
}
|
||||
} else {
|
||||
return this.eq(v, match);
|
||||
}
|
||||
return false;
|
||||
},
|
||||
compare(v1: string, v2: string) {
|
||||
const v1Arr = v1.split(".");
|
||||
const v2Arr = v2.split(".");
|
||||
for (let i = 0; i < v1Arr.length; i++) {
|
||||
const v1Num = parseInt(v1Arr[i]);
|
||||
const v2Num = parseInt(v2Arr[i]);
|
||||
if (v1Num > v2Num) {
|
||||
return 1;
|
||||
} else if (v1Num < v2Num) {
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
},
|
||||
gt(v1: string, v2: string) {
|
||||
return VersionUtil.compare(v1, v2) > 0;
|
||||
},
|
||||
ge(v1: string, v2: string) {
|
||||
return VersionUtil.compare(v1, v2) >= 0;
|
||||
},
|
||||
lt(v1: string, v2: string) {
|
||||
return VersionUtil.compare(v1, v2) < 0;
|
||||
},
|
||||
le: (v1: string, v2: string) => {
|
||||
return VersionUtil.compare(v1, v2) <= 0;
|
||||
},
|
||||
eq: (v1: string, v2: string) => {
|
||||
return VersionUtil.compare(v1, v2) === 0;
|
||||
},
|
||||
};
|
||||
|
||||
export const UIUtil = {
|
||||
sizeToPx(size: string, sizeFull: number) {
|
||||
if (/^\d+$/.test(size)) {
|
||||
// 纯数字
|
||||
return parseInt(size);
|
||||
} else if (size.endsWith("%")) {
|
||||
// 百分比
|
||||
let result = Math.floor((sizeFull * parseInt(size)) / 100);
|
||||
result = Math.min(result, sizeFull);
|
||||
return result;
|
||||
} else {
|
||||
throw "UnsupportSizeString";
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
export const ReUtil = {
|
||||
match(regex: string, text: string) {
|
||||
if ("" === regex || null === regex) {
|
||||
return false;
|
||||
}
|
||||
if (regex.startsWith("/")) {
|
||||
const index = regex.lastIndexOf("/");
|
||||
const source = regex.slice(1, index);
|
||||
const flags = regex.slice(index + 1);
|
||||
return new RegExp(source, flags).test(text);
|
||||
}
|
||||
return new RegExp(regex).test(text);
|
||||
},
|
||||
};
|
||||
|
||||
const converter = new Showdown.Converter({
|
||||
tables: true,
|
||||
});
|
||||
export const MarkdownUtil = {
|
||||
toHtml(markdown: string): string {
|
||||
return converter.makeHtml(markdown);
|
||||
},
|
||||
};
|
||||
|
||||
type HotkeyModifierType = "Control" | "Option" | "Command" | "Ctrl" | "Alt" | "Win" | "Meta" | "Shift";
|
||||
type HotkeyType = { key: string; modifiers: HotkeyModifierType[] };
|
||||
|
||||
export const HotKeyUtil = {
|
||||
orderModifiers(modifiers: HotkeyModifierType[]) {
|
||||
const order = ["Control", "Ctrl", "Command", "Meta", "Win", "Option", "Alt", "Shift"];
|
||||
return modifiers.sort((a, b) => {
|
||||
return order.indexOf(a) - order.indexOf(b);
|
||||
});
|
||||
},
|
||||
unifyObject(hotkey: HotkeyType) {
|
||||
return {
|
||||
key: hotkey.key.toUpperCase(),
|
||||
modifiers: this.orderModifiers(hotkey.modifiers.map(modifier => StrUtil.ucFirst(modifier))),
|
||||
};
|
||||
},
|
||||
unifyString(hotkey: string): HotkeyType {
|
||||
const parts = hotkey.split("+");
|
||||
const key = parts.pop() || "";
|
||||
const modifiers: any[] = [];
|
||||
parts.forEach(part => {
|
||||
modifiers.push(StrUtil.ucFirst(part.trim()));
|
||||
});
|
||||
return this.unifyObject({key, modifiers});
|
||||
},
|
||||
unify(hotkeys: string | string[] | HotkeyType | HotkeyType[]): HotkeyType[] {
|
||||
if (typeof hotkeys === "string") {
|
||||
return [this.unifyString(hotkeys)];
|
||||
} else if (Array.isArray(hotkeys)) {
|
||||
return hotkeys.map(hotkey => {
|
||||
if (typeof hotkey === "string") {
|
||||
return this.unifyString(hotkey);
|
||||
} else {
|
||||
return this.unifyObject(hotkey);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
return [this.unifyObject(hotkeys)];
|
||||
}
|
||||
},
|
||||
getFromEvent(event: any): HotkeyType | null {
|
||||
const valid = [
|
||||
"A",
|
||||
"B",
|
||||
"C",
|
||||
"D",
|
||||
"E",
|
||||
"F",
|
||||
"G",
|
||||
"H",
|
||||
"I",
|
||||
"J",
|
||||
"K",
|
||||
"L",
|
||||
"M",
|
||||
"N",
|
||||
"O",
|
||||
"P",
|
||||
"Q",
|
||||
"R",
|
||||
"S",
|
||||
"T",
|
||||
"U",
|
||||
"V",
|
||||
"W",
|
||||
"X",
|
||||
"Y",
|
||||
"Z",
|
||||
"1",
|
||||
"2",
|
||||
"3",
|
||||
"4",
|
||||
"5",
|
||||
"6",
|
||||
"7",
|
||||
"8",
|
||||
"9",
|
||||
"0",
|
||||
"Space",
|
||||
];
|
||||
const key = (event.key || "").toUpperCase();
|
||||
if (!event || !event.key || !valid.includes(key)) {
|
||||
return null;
|
||||
}
|
||||
const modifiers: HotkeyModifierType[] = [];
|
||||
if (isWin) {
|
||||
if (event.ctrlKey || event.control) {
|
||||
modifiers.push("Ctrl");
|
||||
}
|
||||
if (event.altKey || event.alt) {
|
||||
modifiers.push("Alt");
|
||||
}
|
||||
if (event.metaKey || event.meta) {
|
||||
modifiers.push("Win");
|
||||
}
|
||||
} else if (isMac) {
|
||||
if (event.ctrlKey || event.control) {
|
||||
modifiers.push("Control");
|
||||
}
|
||||
if (event.altKey || event.alt) {
|
||||
modifiers.push("Option");
|
||||
}
|
||||
if (event.metaKey || event.meta) {
|
||||
modifiers.push("Command");
|
||||
}
|
||||
} else {
|
||||
if (event.ctrlKey || event.control) {
|
||||
modifiers.push("Ctrl");
|
||||
}
|
||||
if (event.altKey || event.alt) {
|
||||
modifiers.push("Alt");
|
||||
}
|
||||
if (event.metaKey || event.meta) {
|
||||
modifiers.push("Meta");
|
||||
}
|
||||
}
|
||||
if (event.shiftKey || event.shift) {
|
||||
modifiers.push("Shift");
|
||||
}
|
||||
return this.unifyObject({key, modifiers});
|
||||
},
|
||||
match(hotkeysForMatch: HotkeyType[], hotkey: HotkeyType): boolean {
|
||||
if (!hotkeysForMatch || !hotkey) {
|
||||
return false;
|
||||
}
|
||||
const hotKeyStr = hotkey.modifiers.join("+") + "+" + hotkey.key;
|
||||
for (const key of hotkeysForMatch) {
|
||||
const keyStr = key.modifiers.join("+") + "+" + key.key;
|
||||
if (keyStr === hotKeyStr) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user