From 329ca6ebb95e78a98e877ef9f5c1d7e28d0bd4ff Mon Sep 17 00:00:00 2001 From: Charles Date: Sat, 28 Mar 2026 20:14:53 +0800 Subject: [PATCH 1/2] Revert "Merge branch 'run_training_native' into run_training_native" Not good engouth quality code,need recheck This reverts commit 2970184743d8fa2463b9b219f4df52c89c3c0e66, reversing changes made to b1d7f74b65c9720d7a5f4ab1fffc70011c07d34c. --- .../config/training-config.json | 5 - external-resources/dlc/dlc-index.json | 26 - package.json | 2 +- src/main/cmd/index.ts | 56 +- src/main/configs/index.ts | 2 - src/main/configs/training-config.ts | 119 -- src/main/configs/type-info.ts | 9 - src/main/dlc/index.ts | 416 +----- src/main/dlc/type-info.ts | 22 - src/main/launcher-update/index.ts | 1148 ++++------------- .../obsidian-voice-service/index.ts | 23 +- src/main/local-service/rts-service/index.ts | 31 +- src/main/native-script/index.ts | 170 ++- src/main/native-script/type-info.ts | 6 +- src/main/native-training-service/index.ts | 129 +- src/main/native-training-service/type-info.ts | 4 - src/main/preload.ts | 57 - .../index.tsx | 31 +- src/renderer/pages/hello/index.tsx | 84 +- src/renderer/pages/lm-service/index.tsx | 170 +-- .../pages/native-ai-service/Tools_Icon.png | Bin 0 -> 1369 bytes .../pages/native-ai-service/index.tsx | 29 +- src/renderer/pages/obsidian-app/index.tsx | 196 ++- 23 files changed, 584 insertions(+), 2151 deletions(-) delete mode 100644 external-resources/config/training-config.json delete mode 100644 src/main/configs/training-config.ts create mode 100644 src/renderer/pages/native-ai-service/Tools_Icon.png diff --git a/external-resources/config/training-config.json b/external-resources/config/training-config.json deleted file mode 100644 index 03be3b8..0000000 --- a/external-resources/config/training-config.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "env": { - "UNLOCK_ALL_SECTION": false - } -} \ No newline at end of file diff --git a/external-resources/dlc/dlc-index.json b/external-resources/dlc/dlc-index.json index 529ad2a..d7edfc7 100644 --- a/external-resources/dlc/dlc-index.json +++ b/external-resources/dlc/dlc-index.json @@ -19,27 +19,6 @@ "id": "TRAINING_COURSE", "name": "学科培训课程数据", "versions": { - "2.0.8": { - "magnet": "magnet:?xt=urn:btih:09e4424598ef26d1b9e1cb0ee91527aa9823ebf4&dn=course10v2.zip&tr=wss%3A%2F%2Flearning.panchuantech.cn%2Fannounce&tr=ws%3A%2F%2F121.40.137.135%3A8200", - "comment": "修复所有课程", - "require": { - "TRAINING_TAR": "^2.0.0" - } - }, - "2.0.7": { - "magnet": "magnet:?xt=urn:btih:943d91e3692893147ca89d8331f90aec3d6f443d&dn=course10.zip&tr=wss%3A%2F%2Flearning.panchuantech.cn%2Fannounce&tr=ws%3A%2F%2F121.40.137.135%3A8200", - "comment": "新结构", - "require": { - "TRAINING_TAR": "^2.0.0" - } - }, - "2.0.6": { - "magnet": "magnet:?xt=urn:btih:b9f708c919039a5fe035dc97437e6eac4a778f8c&dn=10course.zip&tr=wss%3A%2F%2Flearning.panchuantech.cn%2Fannounce&tr=ws%3A%2F%2F121.40.137.135%3A8200", - "comment": "10门课", - "require": { - "TRAINING_TAR": "^2.0.0" - } - }, "2.0.5": { "magnet": "magnet:?xt=urn:btih:c7e6ceac3bce2a2884ee86d6ac5d50fd85d8ae27&dn=8course.zip&tr=wss%3A%2F%2Flearning.panchuantech.cn%2Fannounce&tr=wss%3A%2F%2F114.66.58.95%3A17853%2Fannounce&tr=ws%3A%2F%2F121.40.137.135%3A8200", "comment": "第三个版本", @@ -74,11 +53,6 @@ "id": "AI_LEARNING_ASSISTANT_LAUNCHER", "name": "AI学习助手启动器", "versions": { - "2.1.0-test": { - "magnet": "magnet:?xt=urn:btih:72cadee7da364ce360d38c2c2ff93c7951b3a441&dn=AI-Learning-Assistant-Launcher-win32-x64-2.1.0-test.zip&tr=wss%3A%2F%2Flearning.panchuantech.cn%2Fannounce&tr=ws%3A%2F%2F121.40.137.135%3A8200&tr=udp%3A%2F%2Ftracker.leechers-paradise.org%3A6969&tr=udp%3A%2F%2Ftracker.coppersurfer.tk%3A6969&tr=udp%3A%2F%2Ftracker.opentrackr.org%3A1337&tr=udp%3A%2F%2Fexplodie.org%3A6969&tr=udp%3A%2F%2Ftracker.empire-js.us%3A1337&tr=wss%3A%2F%2Ftracker.btorrent.xyz&tr=wss%3A%2F%2Ftracker.openwebtorrent.com", - "comment": "修复一些问题", - "require": {} - }, "2.0.2": { "magnet": "magnet:?xt=urn:btih:30469d14ed0eaf65f88d6a2c51cea9888c4a2ff7&dn=AI-Learning-Assistant-Launcher-win32-x64-2.0.2-p2p.zip&tr=wss%3A%2F%2Flearning.panchuantech.cn%2Fannounce&tr=wss%3A%2F%2F114.66.58.95%3A17853%2Fannounce", "comment": "测试更新使用版本", diff --git a/package.json b/package.json index 17bf7bd..447330f 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "ai-learning-assistant-launcher", "productName": "AI-Learning-Assistant-Launcher", - "version": "2.1.0-test-5", + "version": "2.0.1-p2p", "description": "My Electron application description", "main": ".webpack/main", "scripts": { diff --git a/src/main/cmd/index.ts b/src/main/cmd/index.ts index 118a4d1..629e8cb 100644 --- a/src/main/cmd/index.ts +++ b/src/main/cmd/index.ts @@ -9,7 +9,7 @@ import { } from '../configs'; import { MESSAGE_TYPE, MessageData } from '../ipc-data-type'; import path from 'node:path'; -import fs from 'node:fs'; +import { statSync } from 'node:fs'; import { ensurePodmanWorks, getPodmanCli, @@ -80,7 +80,11 @@ async function executeWithRetry( retry * WEBTORRENT_CONFIG.RETRY_INCREMENT; await new Promise((resolve) => setTimeout(resolve, waitTime)); + console.debug( + `[${logPrefix}] 尝试执行安装程序 (${retry + 1}/${maxRetries}),等待了 ${waitTime}ms`, + ); const result = await commandLine.exec(exePath, args); + console.debug(`[${logPrefix}] 安装程序执行成功:`, result); return true; } catch (e) { if (isEBUSYError(e) && retry < maxRetries - 1) { @@ -140,33 +144,7 @@ async function installFromP2POrFallback( const { dlcKey, installerArgs, logPrefix, fallbackInstallerPath } = options; try { - // 首先直接在 DLC 目录下检查是否有已下载的 exe 文件(不使用版本号子目录) - const dlcDir = path.join(appPath, 'external-resources', 'dlc', dlcKey); - - if (fs.existsSync(dlcDir)) { - const allItems = fs.readdirSync(dlcDir); - - // 直接在 DLC 目录下查找 exe 文件 - const exeFile = allItems.find( - (f: string) => f.endsWith('.exe') && !f.endsWith('.downloading'), - ); - - if (exeFile) { - const exePath = path.join(dlcDir, exeFile); - - if (fs.existsSync(exePath)) { - const stats = fs.statSync(exePath); - - return await executeWithRetry({ - exePath, - args: installerArgs, - logPrefix, - }); - } - } - } - - // 如果 HTTPS 下载目录没有文件,尝试从 P2P 下载(保留旧逻辑兼容) + // 首先检查是否有P2P下载的文件 const { getDLCFromDLCIndex, destroyWebtorrentForInstall } = await import( '../dlc' ); @@ -174,7 +152,6 @@ async function installFromP2POrFallback( if (dlcInfo) { const latestVersion = Object.keys(dlcInfo.versions).sort().pop(); - if (latestVersion) { const versionInfo = dlcInfo.versions[latestVersion]; @@ -201,9 +178,9 @@ async function installFromP2POrFallback( latestVersion, ); + const fs = await import('fs'); if (fs.existsSync(downloadPath)) { const files = fs.readdirSync(downloadPath); - const exeFile = files.find((f: string) => f.endsWith('.exe')); if (exeFile) { @@ -218,18 +195,19 @@ async function installFromP2POrFallback( } } } catch (error) { - console.error(`[${logPrefix}] 尝试使用下载包失败,使用默认方式:`, error); + console.error(`[${logPrefix}] 尝试使用P2P下载包失败,使用默认方式:`, error); } // 降级到原有的本地安装包方式 - const fallbackPath = path.join( - appPath, - 'external-resources', - 'ai-assistant-backend', - fallbackInstallerPath, + const result = await commandLine.exec( + path.join( + appPath, + 'external-resources', + 'ai-assistant-backend', + fallbackInstallerPath, + ), + installerArgs, ); - - const result = await commandLine.exec(fallbackPath, installerArgs); console.debug(`[${logPrefix}] 本地安装包执行结果:`, result); return true; } @@ -712,7 +690,7 @@ export async function isObsidianInstall() { try { obsidianPath = replaceVarInPath(obsidianPath); console.debug('getObsidianConfig', obsidianPath); - const stat = fs.statSync(obsidianPath); + const stat = statSync(obsidianPath); if (stat.isFile()) { return true; } else { diff --git a/src/main/configs/index.ts b/src/main/configs/index.ts index 603ac1c..2bd61c1 100644 --- a/src/main/configs/index.ts +++ b/src/main/configs/index.ts @@ -22,7 +22,6 @@ import { ChatAnthropic } from "@langchain/anthropic"; import { ChatGoogleGenerativeAI } from "@langchain/google-genai"; import { ChatOllama } from "@langchain/ollama"; import { ChatDeepSeek } from '@langchain/deepseek'; -import { initTrainingConfig } from './training-config'; // 临时文件操作记录 interface FileOperation { @@ -305,7 +304,6 @@ export default async function init(ipcMain: IpcMain) { } }, ); - await initTrainingConfig(ipcMain); } // 添加保存大模型配置的函数 diff --git a/src/main/configs/training-config.ts b/src/main/configs/training-config.ts deleted file mode 100644 index 238feeb..0000000 --- a/src/main/configs/training-config.ts +++ /dev/null @@ -1,119 +0,0 @@ -import { IpcMain } from 'electron'; -import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs'; -import path from 'node:path'; -import { ipcHandle } from '../ipc-util'; -import { - queryNativeTrainingConfigHandle, - setNativeTrainingConfigHandle, - TrainingConfig, -} from './type-info'; -import { appPath } from '../exec'; - -// Training 配置文件路径 -const trainingConfigPath = path.join( - appPath, - 'external-resources', - 'config', - 'training-config.json', -); - -// 默认的 Training 配置 -const defaultTrainingConfig: TrainingConfig = { - env: { - UNLOCK_ALL_SECTION: false, - }, -}; - -// 内存中的 Training 配置缓存 -let currentTrainingConfig: TrainingConfig = { ...defaultTrainingConfig }; - -export async function initTrainingConfig(ipcMain: IpcMain) { - // 初始化时加载配置 - loadTrainingConfig(); - - ipcHandle(ipcMain, queryNativeTrainingConfigHandle, async (_event) => - queryTrainingConfig(), - ); - ipcHandle( - ipcMain, - setNativeTrainingConfigHandle, - async (_event, config: TrainingConfig) => setTrainingConfig(config), - ); -} - -/** - * 从文件加载 Training 配置 - */ -function loadTrainingConfig(): void { - try { - if (existsSync(trainingConfigPath)) { - const configString = readFileSync(trainingConfigPath, { - encoding: 'utf8', - }); - const config = JSON.parse(configString) as TrainingConfig; - currentTrainingConfig = { ...defaultTrainingConfig, ...config }; - } else { - // 配置文件不存在,创建默认配置文件 - saveTrainingConfig(defaultTrainingConfig); - } - } catch (error) { - console.error('读取 Training 配置失败:', error); - currentTrainingConfig = { ...defaultTrainingConfig }; - } -} - -/** - * 保存 Training 配置到文件 - */ -function saveTrainingConfig(config: TrainingConfig): void { - try { - // 确保配置目录存在 - const configDir = path.dirname(trainingConfigPath); - if (!existsSync(configDir)) { - mkdirSync(configDir, { recursive: true }); - } - - writeFileSync(trainingConfigPath, JSON.stringify(config, null, 2), { - encoding: 'utf8', - }); - } catch (error) { - console.error('保存 Training 配置文件失败:', error); - } -} - -/** - * 查询 Training 配置 - */ -export async function queryTrainingConfig(): Promise { - loadTrainingConfig(); - return { ...currentTrainingConfig }; -} - -/** - * 设置 Training 配置 - */ -export async function setTrainingConfig( - config: TrainingConfig, -): Promise { - // 合并配置,保留未指定的默认值 - currentTrainingConfig = { - ...defaultTrainingConfig, - ...config, - env: { - ...defaultTrainingConfig.env, - ...config.env, - }, - }; - - // 保存到文件 - saveTrainingConfig(currentTrainingConfig); - - return { ...currentTrainingConfig }; -} - -/** - * 导出获取当前 Training 配置的函数,供其他模块使用 - */ -export function getCurrentTrainingConfig(): TrainingConfig { - return { ...currentTrainingConfig }; -} diff --git a/src/main/configs/type-info.ts b/src/main/configs/type-info.ts index da89177..a0f4bb2 100644 --- a/src/main/configs/type-info.ts +++ b/src/main/configs/type-info.ts @@ -84,12 +84,3 @@ export interface LLMConfig { models: CustomModel[]; } -export const queryNativeTrainingConfigHandle = `${channel}queryNativeTrainingConfig`; - -export const setNativeTrainingConfigHandle = `${channel}setNativeTrainingConfig`; - -export interface TrainingConfig { - env: { - UNLOCK_ALL_SECTION: boolean; - }; -} diff --git a/src/main/dlc/index.ts b/src/main/dlc/index.ts index 7319388..3846ce9 100644 --- a/src/main/dlc/index.ts +++ b/src/main/dlc/index.ts @@ -11,12 +11,6 @@ import { setUploadEnabledHandle, getUploadEnabledHandle, getUploadStatsHandle, - startHttpsDownloadHandle, - queryHttpsDownloadHandle, - cancelHttpsDownloadHandle, - checkHttpsDownloadFileHandle, - HttpsDownloadProgress, - HttpsDownloadState, } from './type-info'; import { ipcHandle } from '../ipc-util'; import WebTorrent, * as allExports from 'webtorrent'; @@ -28,9 +22,8 @@ import fs, { readFileSync, unlinkSync, writeFileSync, - promises as fsPromises, } from 'fs'; - +import { promises as fsPromises } from 'fs'; import http from 'http'; import https from 'https'; import { compare } from 'semver'; @@ -161,25 +154,6 @@ export default async function init(ipcMain: IpcMain) { getUploadEnabled(), ); ipcHandle(ipcMain, getUploadStatsHandle, async (_event) => getUploadStats()); - // HTTPS 多源下载 - ipcHandle( - ipcMain, - startHttpsDownloadHandle, - async (_event, dlcId: DLCId, urls: string[], version: string) => - startHttpsDownload(dlcId, urls, version), - ); - ipcHandle(ipcMain, queryHttpsDownloadHandle, async (_event) => - queryHttpsDownload(), - ); - ipcHandle(ipcMain, cancelHttpsDownloadHandle, async (_event, dlcId: DLCId) => - cancelHttpsDownload(dlcId), - ); - ipcHandle( - ipcMain, - checkHttpsDownloadFileHandle, - async (_event, dlcId: DLCId, version: string) => - checkHttpsDownloadFile(dlcId, version), - ); // 检查种子文件和对应的实际文件,如果有文件,就添加到webtorrent中 await restoreTorrentsFromFiles(); @@ -209,7 +183,7 @@ export async function startWebtorrent(magnet: string) { const onMetadata = () => { torrent.removeListener('metadata', onMetadata); saveTorrentFile(filePath, torrent); - }; + } torrent.on('metadata', onMetadata); console.debug('种子文件已经存在,继续下载'); torrent.resume(); @@ -829,389 +803,3 @@ export async function updateDLCIndex(): Promise { }); } -// ==================== HTTPS 多源下载功能 ==================== - -// HTTPS 下载状态存储 -const httpsDownloadState: HttpsDownloadState = {}; - -// 当前活跃的下载请求(用于取消) -const activeHttpsRequests: Map = new Map(); - -/** - * 从多个 URL 中尝试下载文件 - * 会按顺序尝试每个 URL,直到成功或全部失败 - * 文件直接下载到 dlcId 目录下,不再使用版本号子目录 - */ -export async function startHttpsDownload( - dlcId: DLCId, - urls: string[], - version: string, -): Promise<{ success: boolean; error?: string }> { - if (!urls || urls.length === 0) { - return { success: false, error: '没有提供下载链接' }; - } - - // 初始化下载状态 - httpsDownloadState[dlcId] = { - dlcId, - version, - progress: 0, - downloadedBytes: 0, - totalBytes: 0, - speed: 0, - status: 'downloading', - }; - - // 创建下载目录(不再包含版本号) - const downloadDir = path.join(appPath, 'external-resources', 'dlc', dlcId); - - if (!existsSync(downloadDir)) { - mkdirSync(downloadDir, { recursive: true }); - } - - // 尝试从多个源下载 - for (let i = 0; i < urls.length; i++) { - const url = urls[i]; - - try { - const result = await downloadFromUrl(dlcId, url, downloadDir, version); - if (result.success) { - httpsDownloadState[dlcId].status = 'completed'; - httpsDownloadState[dlcId].progress = 1; - httpsDownloadState[dlcId].filePath = result.filePath; - return { success: true }; - } - } catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); - console.warn(`[HTTPS Download] 源 ${i + 1} 下载失败: ${errorMsg}`); - - // 如果是用户取消的,直接返回 - if (httpsDownloadState[dlcId]?.status === 'cancelled') { - return { success: false, error: '下载已取消' }; - } - - // 如果还有其他源,继续尝试 - if (i < urls.length - 1) { - // 重置进度以便下一个源重新开始 - httpsDownloadState[dlcId].downloadedBytes = 0; - httpsDownloadState[dlcId].progress = 0; - } - } - } - - // 所有源都失败了 - httpsDownloadState[dlcId].status = 'error'; - httpsDownloadState[dlcId].error = '所有下载源都失败'; - return { success: false, error: '所有下载源都失败' }; -} - -// 最大重定向次数 -const MAX_REDIRECTS = 5; - -/** - * 从单个 URL 下载文件 - * @param redirectCount 当前重定向次数,用于防止循环重定向导致栈溢出 - */ -function downloadFromUrl( - dlcId: DLCId, - url: string, - downloadDir: string, - version: string, - redirectCount = 0, -): Promise<{ success: boolean; filePath?: string }> { - return new Promise((resolve, reject) => { - const protocol = url.startsWith('https://') ? https : http; - - // 从 URL 中提取文件名,如果无法提取则使用默认名称 - let fileName = path.basename(new URL(url).pathname); - if (!fileName || fileName === '' || !fileName.includes('.')) { - fileName = `${dlcId}-${version}.exe`; - } - - const filePath = path.join(downloadDir, fileName); - const tempFilePath = filePath + '.downloading'; - - // 如果已存在完整的下载文件,直接返回成功 - if (existsSync(filePath)) { - resolve({ success: true, filePath }); - return; - } - - // 检查是否有未完成的下载,用于断点续传 - let downloadedBytes = 0; - if (existsSync(tempFilePath)) { - const stats = fs.statSync(tempFilePath); - downloadedBytes = stats.size; - } - - const options: https.RequestOptions = { - headers: { - 'User-Agent': 'AI-Learning-Assistant-Launcher', - ...(downloadedBytes > 0 ? { Range: `bytes=${downloadedBytes}-` } : {}), - }, - }; - - const request = protocol.get(url, options, (response) => { - // 处理重定向 - if (response.statusCode === 301 || response.statusCode === 302) { - const redirectUrl = response.headers.location; - if (redirectUrl) { - if (redirectCount >= MAX_REDIRECTS) { - reject( - new Error( - `超过最大重定向次数限制 (${MAX_REDIRECTS}),可能存在循环重定向`, - ), - ); - return; - } - downloadFromUrl( - dlcId, - redirectUrl, - downloadDir, - version, - redirectCount + 1, - ) - .then(resolve) - .catch(reject); - } else { - reject(new Error('重定向URL为空')); - } - return; - } - - // 检查是否支持断点续传 - const isPartialContent = response.statusCode === 206; - const contentLength = parseInt( - response.headers['content-length'] || '0', - 10, - ); - - let totalBytes: number; - if (isPartialContent) { - // 从 Content-Range 头获取总大小 - const contentRange = response.headers['content-range']; - if (contentRange) { - const match = contentRange.match(/\/(\d+)/); - totalBytes = match - ? parseInt(match[1], 10) - : downloadedBytes + contentLength; - } else { - totalBytes = downloadedBytes + contentLength; - } - } else { - totalBytes = contentLength; - downloadedBytes = 0; // 不支持断点续传,从头开始 - } - - httpsDownloadState[dlcId].totalBytes = totalBytes; - - if (response.statusCode !== 200 && response.statusCode !== 206) { - reject(new Error(`HTTP 状态码: ${response.statusCode}`)); - return; - } - - // 打开文件用于写入 - const writeFlags = isPartialContent ? 'a' : 'w'; - const fileStream = fs.createWriteStream(tempFilePath, { - flags: writeFlags, - }); - - let lastTime = Date.now(); - let lastBytes = downloadedBytes; - - response.on('data', (chunk: Buffer) => { - // 检查是否被取消 - if (httpsDownloadState[dlcId]?.status === 'cancelled') { - request.destroy(); - fileStream.close(() => { - // 删除临时文件,避免下次错误触发断点续传 - if (existsSync(tempFilePath)) { - try { - unlinkSync(tempFilePath); - } catch (err) { - console.warn( - `[HTTPS Download] 删除临时文件失败: ${tempFilePath}`, - err, - ); - } - } - }); - return; - } - - downloadedBytes += chunk.length; - - // 计算下载速度(每秒更新一次) - const now = Date.now(); - const timeDiff = now - lastTime; - if (timeDiff >= 1000) { - const bytesDiff = downloadedBytes - lastBytes; - httpsDownloadState[dlcId].speed = Math.round( - (bytesDiff / timeDiff) * 1000, - ); - lastTime = now; - lastBytes = downloadedBytes; - } - - // 更新进度 - httpsDownloadState[dlcId].downloadedBytes = downloadedBytes; - if (totalBytes > 0) { - httpsDownloadState[dlcId].progress = downloadedBytes / totalBytes; - } - }); - - response.pipe(fileStream); - - fileStream.on('finish', () => { - fileStream.close(); - // 下载完成,重命名临时文件 - try { - if (existsSync(filePath)) { - unlinkSync(filePath); - } - fs.renameSync(tempFilePath, filePath); - resolve({ success: true, filePath }); - } catch (err) { - reject(err); - } - }); - - fileStream.on('error', (err) => { - fileStream.close(); - reject(err); - }); - }); - - // 保存请求以便取消 - activeHttpsRequests.set(dlcId, request); - - request.on('error', (err) => { - activeHttpsRequests.delete(dlcId); - reject(err); - }); - - request.on('close', () => { - activeHttpsRequests.delete(dlcId); - }); - }); -} - -/** - * 查询 HTTPS 下载状态 - */ -export function queryHttpsDownload(): HttpsDownloadState { - return { ...httpsDownloadState }; -} - -/** - * 取消 HTTPS 下载 - */ -export function cancelHttpsDownload(dlcId: DLCId): { success: boolean } { - if (httpsDownloadState[dlcId]) { - httpsDownloadState[dlcId].status = 'cancelled'; - } - - const request = activeHttpsRequests.get(dlcId); - if (request) { - request.destroy(); - activeHttpsRequests.delete(dlcId); - } - - // 清理临时文件,避免下次错误触发断点续传 - const downloadDir = path.join(appPath, 'external-resources', 'dlc', dlcId); - if (existsSync(downloadDir)) { - try { - const files = fs.readdirSync(downloadDir); - for (const file of files) { - if (file.endsWith('.downloading')) { - const tempFilePath = path.join(downloadDir, file); - unlinkSync(tempFilePath); - } - } - } catch (err) { - console.warn(`[HTTPS Download] 清理临时文件失败:`, err); - } - } - - return { success: true }; -} - -/** - * 检查 HTTPS 下载是否已完成 - */ -export function isHttpsDownloadComplete(dlcId: DLCId): boolean { - const state = httpsDownloadState[dlcId]; - return state?.status === 'completed' && state?.progress >= 1; -} - -/** - * 获取 HTTPS 下载的文件路径 - */ -export function getHttpsDownloadFilePath( - dlcId: DLCId, - version: string, -): string | null { - const state = httpsDownloadState[dlcId]; - if (state?.filePath) { - return state.filePath; - } - - // 尝试从目录中查找已下载的文件 - const downloadDir = path.join( - appPath, - 'external-resources', - 'dlc', - dlcId, - version, - ); - - if (existsSync(downloadDir)) { - const files = fs.readdirSync(downloadDir); - const exeFile = files.find( - (f) => f.endsWith('.exe') && !f.endsWith('.downloading'), - ); - if (exeFile) { - return path.join(downloadDir, exeFile); - } - } - - return null; -} - -/** - * 检查指定 DLC 是否已有下载完成的文件 - * @returns 如果已有下载好的文件,返回文件路径;否则返回 null - */ -export function checkHttpsDownloadFile( - dlcId: DLCId, - version: string, -): { exists: boolean; filePath: string | null } { - // 直接在 dlcId 目录下查找,不再使用版本号子目录 - const downloadDir = path.join(appPath, 'external-resources', 'dlc', dlcId); - - if (existsSync(downloadDir)) { - const files = fs.readdirSync(downloadDir); - - const exeFile = files.find( - (f) => f.endsWith('.exe') && !f.endsWith('.downloading'), - ); - - if (exeFile) { - const filePath = path.join(downloadDir, exeFile); - // 更新下载状态为已完成 - httpsDownloadState[dlcId] = { - dlcId, - version, - progress: 1, - downloadedBytes: 0, - totalBytes: 0, - speed: 0, - status: 'completed', - filePath, - }; - return { exists: true, filePath }; - } - } - - return { exists: false, filePath: null }; -} diff --git a/src/main/dlc/type-info.ts b/src/main/dlc/type-info.ts index c1e04cb..7770c7f 100644 --- a/src/main/dlc/type-info.ts +++ b/src/main/dlc/type-info.ts @@ -19,28 +19,6 @@ export const getUploadEnabledHandle = `${channel}getUploadEnabled`; export const getUploadStatsHandle = `${channel}getUploadStats`; -// HTTPS 多源下载相关 -export const startHttpsDownloadHandle = `${channel}httpsStart`; -export const queryHttpsDownloadHandle = `${channel}httpsQuery`; -export const cancelHttpsDownloadHandle = `${channel}httpsCancel`; -export const checkHttpsDownloadFileHandle = `${channel}httpsCheckFile`; - -// HTTPS 下载进度信息 -export interface HttpsDownloadProgress { - dlcId: DLCId; - version: string; - progress: number; // 0-1 - downloadedBytes: number; - totalBytes: number; - speed: number; // bytes/s - status: 'idle' | 'downloading' | 'completed' | 'error' | 'cancelled'; - error?: string; - filePath?: string; -} - -// HTTPS 下载状态存储(key 为 dlcId) -export type HttpsDownloadState = Record; - export const dLCIds = [ 'PDF_TAR', 'VOICE_TAR', diff --git a/src/main/launcher-update/index.ts b/src/main/launcher-update/index.ts index 8e7cb48..3423050 100644 --- a/src/main/launcher-update/index.ts +++ b/src/main/launcher-update/index.ts @@ -14,105 +14,26 @@ import { import semver from 'semver'; import path from 'path'; import { appPath } from '../exec'; -import { - existsSync, - mkdirSync, - rmSync, - realpathSync, - readFileSync, - writeFileSync, - unlinkSync, -} from 'fs'; - +import { existsSync, mkdirSync, rmSync } from 'fs'; import { readdir, writeFile, appendFile, + access, + rename, stat, unlink, - rename, - access, constants, } from 'fs/promises'; -import { spawn, execFile } from 'child_process'; +import { spawn } from 'child_process'; import packageJson from '../../../package.json'; -import { setForceQuit } from '../joint-build'; const currentVersion = packageJson.version; -// ========== 两阶段更新配置 ========== -/** - * 更新配置接口 - * 用于在两阶段更新中传递更新状态 - */ -interface UpdateConfig { - pendingUpdate: boolean; - updatePath: string; - version: string; - timestamp: number; -} - -// 更新配置文件路径(存储在用户数据目录) -const UPDATE_CONFIG_PATH = path.join( - app.getPath('userData'), - 'update-config.json', -); -// 待更新的 ZIP 包路径 -const PENDING_UPDATE_PATH = path.join( - app.getPath('userData'), - 'pending-update.zip', -); - -/** - * 获取更新临时目录的完整路径 - * 使用 realpathSync 将 Windows 8.3 短路径转换为完整路径 - * 例如:C:\Users\ADMINI~1 -> C:\Users\Administrator - * - * 关键改进: - * 1. 创建目录后再进行 realpathSync(因为 realpathSync 要求路径存在) - * 2. 转换失败时抛出异常,而不是静默回落到短路径 - * 3. 确保返回的路径始终为完整长路径 - */ -function getUpdateTempDir(): string { - const baseTempDir = app.getPath('temp'); - - // 添加备用方案 - const fallbackDir = path.join(app.getPath('userData'), 'update-temp'); - - try { - if (!existsSync(baseTempDir)) { - mkdirSync(baseTempDir, { recursive: true }); - } - - const fullBaseTempDir = realpathSync(baseTempDir); - const tempDir = path.join(fullBaseTempDir, 'launcher-update'); - - if (!existsSync(tempDir)) { - mkdirSync(tempDir, { recursive: true }); - } - - return realpathSync(tempDir); - } catch (err) { - // 使用备用目录 - console.warn('[getUpdateTempDir] 使用备用目录', fallbackDir); - if (!existsSync(fallbackDir)) { - mkdirSync(fallbackDir, { recursive: true }); - } - return fallbackDir; - } -} - // 更新日志文件路径 const updateLogPath = path.join(appPath, 'launcher-update.log'); const updateLogBackupPath = path.join(appPath, 'launcher-update.log.old'); -// PowerShell 脚本日志文件路径(与主日志放在同一目录,便于查看) -const scriptLogPath = path.join(appPath, 'launcher-update-script.log'); -const scriptLogBackupPath = path.join( - appPath, - 'launcher-update-script.log.old', -); - // 日志滚动配置 const LOG_ROTATION_CONFIG = { MAX_SIZE_BYTES: 1 * 1024 * 1024, // 1MB @@ -164,46 +85,34 @@ async function writeUpdateLog( } /** - * 对单个日志文件执行滚动 - * @param logPath 日志文件路径 - * @param backupPath 备份文件路径 + * 初始化日志文件(检查大小并执行滚动) + * 当日志文件超过 MAX_SIZE_BYTES 时,将旧日志重命名为 .old 文件 */ -async function rotateLogFile(logPath: string, backupPath: string) { +async function initUpdateLog() { try { - if (existsSync(logPath)) { - const stats = await stat(logPath); + // 检查日志文件是否存在及其大小 + if (existsSync(updateLogPath)) { + const stats = await stat(updateLogPath); if (stats.size >= LOG_ROTATION_CONFIG.MAX_SIZE_BYTES) { // 删除旧的备份文件(如果存在) try { - await unlink(backupPath); + await unlink(updateLogBackupPath); } catch { // 备份文件不存在,忽略 } // 将当前日志重命名为备份 - await rename(logPath, backupPath); + await rename(updateLogPath, updateLogBackupPath); console.log( - `[rotateLogFile] 日志文件已滚动: ${logPath},旧日志大小: ${(stats.size / 1024).toFixed(2)} KB`, + `[initUpdateLog] 日志文件已滚动,旧日志大小: ${(stats.size / 1024).toFixed(2)} KB`, ); } } } catch (err) { - console.error(`[rotateLogFile] 日志滚动检查失败 (${logPath}):`, err); + console.error('[initUpdateLog] 日志滚动检查失败:', err); } -} - -/** - * 初始化日志文件(检查大小并执行滚动) - * 当日志文件超过 MAX_SIZE_BYTES 时,将旧日志重命名为 .old 文件 - */ -async function initUpdateLog() { - // 滚动主日志文件 - await rotateLogFile(updateLogPath, updateLogBackupPath); - - // 滚动脚本日志文件 - await rotateLogFile(scriptLogPath, scriptLogBackupPath); // 添加分隔符 const separator = ` @@ -218,96 +127,6 @@ ${'='.repeat(60)} } } -/** - * 检查并清理残留的更新配置 - * 在应用启动时调用,清理可能由于更新失败而残留的配置文件 - * - * 注意:实际的文件替换操作由 PowerShell 脚本在主进程退出后执行 - * 此函数仅用于清理残留配置,不会执行文件替换 - * - * @returns 始终返回 false(不会触发重启) - */ -export async function checkAndApplyPendingUpdate(): Promise { - // 初始化日志 - await initUpdateLog(); - - await writeUpdateLog( - 'INFO', - '[checkAndApplyPendingUpdate] 检查残留的更新配置...', - ); - - // 检查配置文件是否存在 - if (!existsSync(UPDATE_CONFIG_PATH)) { - await writeUpdateLog( - 'DEBUG', - '[checkAndApplyPendingUpdate] 没有残留的更新配置', - ); - return false; - } - - // 如果配置文件存在,说明之前的更新可能失败了,清理它 - try { - const configContent = readFileSync(UPDATE_CONFIG_PATH, 'utf-8'); - const config: UpdateConfig = JSON.parse(configContent); - - await writeUpdateLog( - 'WARN', - '[checkAndApplyPendingUpdate] 发现残留的更新配置,可能是之前更新失败', - { - version: config.version, - timestamp: config.timestamp, - }, - ); - - // 清理配置文件 - try { - unlinkSync(UPDATE_CONFIG_PATH); - await writeUpdateLog( - 'INFO', - '[checkAndApplyPendingUpdate] 已清理残留配置文件', - ); - } catch (err) { - await writeUpdateLog( - 'WARN', - '[checkAndApplyPendingUpdate] 清理配置文件失败', - { - error: err instanceof Error ? err.message : String(err), - }, - ); - } - - // 清理残留的更新包 - if (existsSync(config.updatePath)) { - try { - unlinkSync(config.updatePath); - await writeUpdateLog( - 'INFO', - '[checkAndApplyPendingUpdate] 已清理残留更新包', - ); - } catch (err) { - await writeUpdateLog( - 'WARN', - '[checkAndApplyPendingUpdate] 清理更新包失败', - { - error: err instanceof Error ? err.message : String(err), - }, - ); - } - } - } catch (error) { - await writeUpdateLog( - 'ERROR', - '[checkAndApplyPendingUpdate] 清理残留配置时出错', - { - error: error instanceof Error ? error.message : String(error), - }, - ); - } - - // 始终返回 false,让应用正常启动 - return false; -} - export default async function init(ipcMain: IpcMain) { ipcHandle(ipcMain, checkLauncherUpdateHandle, async (_event) => checkLauncherUpdate(), @@ -325,7 +144,7 @@ export default async function init(ipcMain: IpcMain) { * 当已是最新版本时调用,清理之前更新留下的临时文件 */ async function cleanupUpdateTempDir() { - const tempDir = getUpdateTempDir(); + const tempDir = path.join(app.getPath('temp'), 'launcher-update'); try { if (existsSync(tempDir)) { @@ -477,493 +296,276 @@ export async function downloadLauncherUpdate() { } } -// 防止重复调用的标志 -let isInstallInProgress = false; - /** - * 生成 PowerShell 更新脚本 - * 脚本功能:等待主进程退出 -> 解压更新包 -> 保留用户数据 -> 启动新版本 + * 生成自定义更新批处理脚本 + * 脚本功能:等待应用退出、解压更新包、校验完整性、备份旧版本、替换文件、恢复数据、启动新版本 + * + * 安全策略:采用"解压 -> 校验 -> 备份 -> 替换"模式 + * 1. 先在临时目录完整解压并校验 + * 2. 校验失败则直接启动旧版本,不做任何修改 + * 3. 校验成功后备份整个旧版本目录 + * 4. 替换失败时可从备份恢复 */ -async function generatePowerShellUpdateScript( +async function generateUpdateScript( zipPath: string, appDir: string, - version: string, - exePath: string, + tempDir: string, + newVersion: string, ): Promise { - const tempDir = path.join(app.getPath('userData'), 'update-temp'); - const scriptPath = path.join(tempDir, `update-${Date.now()}.ps1`); - // 脚本日志文件放在 appPath 下,与主日志文件同目录,便于查看和管理 - // 使用固定文件名,避免日志分散 - const logPath = scriptLogPath; + const scriptContent = `@echo off +chcp 65001 >nul +setlocal enabledelayedexpansion + +set "ZIP_PATH=${zipPath.replace(/\//g, '\\')}" +set "APP_DIR=${appDir.replace(/\//g, '\\')}" +set "TEMP_DIR=${tempDir.replace(/\//g, '\\')}" +set "BACKUP_DIR=${tempDir.replace(/\//g, '\\')}\\external-resources-backup" +set "APP_BACKUP_DIR=${tempDir.replace(/\//g, '\\')}\\app-backup" +set "EXTRACT_DIR=${tempDir.replace(/\//g, '\\')}\\extracted" +set "UPDATE_LOG=${tempDir.replace(/\//g, '\\')}\\update-script.log" + +:: 日志函数 +call :log "========== 更新脚本开始 ==========" + +call :log "等待应用退出..." +:waitloop +tasklist /FI "IMAGENAME eq AI Learning Assistant Launcher.exe" 2>NUL | find /I "AI Learning Assistant Launcher.exe" >NUL +if not errorlevel 1 ( + timeout /t 1 /nobreak >nul + goto waitloop +) - // 确保临时目录存在 - if (!existsSync(tempDir)) { - mkdirSync(tempDir, { recursive: true }); - } +call :log "应用已退出,开始更新..." + +:: ========== 阶段1: 准备和解压 ========== +call :log "[阶段1] 准备临时目录..." +if exist "%TEMP_DIR%" rd /s /q "%TEMP_DIR%" 2>nul +mkdir "%TEMP_DIR%" +mkdir "%EXTRACT_DIR%" - // 获取当前进程 ID 和可执行文件路径 - const currentPid = process.pid; - const exeName = 'AI-Learning-Assistant-Launcher.exe'; - const parentExePath = exePath.replace(/\\/g, '\\\\'); // 转义路径分隔符用于 PowerShell - - const scriptContent = ` -# PowerShell Update Script - UTF-8 Encoding -# Using "Polling Rename + Temporary File Replacement" to ensure file handles are fully released -[Console]::OutputEncoding = [System.Text.Encoding]::UTF8 -$ErrorActionPreference = "Stop" - -$LogFile = "${logPath.replace(/\\/g, '\\\\')}" -$ZipPath = "${zipPath.replace(/\\/g, '\\\\')}" -$AppDir = "${appDir.replace(/\\/g, '\\\\')}" -$ExeName = "${exeName}" -$Version = "${version}" -$ParentPid = ${currentPid} -$ParentExePath = "${parentExePath}" -$MaxWaitSeconds = 60 -$MaxRenameRetries = 120 # Max rename retries (500ms each, up to 60 seconds) - -# 需要保留的日志文件模式(不会在更新时被删除) -$PreserveLogPatterns = @( - "launcher-update.log", - "launcher-update.log.old", - "launcher-update-script.log", - "launcher-update-script.log.old", - "launcher.log", - "launcher.log.old", - "launcher.old.log" +call :log "[阶段1] 解压更新包到临时目录..." +powershell -Command "try { Expand-Archive -Path '%ZIP_PATH%' -DestinationPath '%EXTRACT_DIR%' -Force -ErrorAction Stop; exit 0 } catch { Write-Host $_.Exception.Message; exit 1 }" +if errorlevel 1 ( + call :log "[错误] 解压失败!启动旧版本..." + goto :launch_old_version ) -function Write-Log { - param([string]$Message) - $timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss" - $logMessage = "[$timestamp] $Message" - Write-Host $logMessage - Add-Content -Path $LogFile -Value $logMessage -Encoding UTF8 -} +:: 查找解压后的目录(ZIP 内可能有一层目录) +set "SOURCE_DIR=" +for /d %%D in ("%EXTRACT_DIR%\\*") do set "SOURCE_DIR=%%D" +if not defined SOURCE_DIR set "SOURCE_DIR=%EXTRACT_DIR%" -# Initialize log file with separator (for distinguishing different update sessions) -function Initialize-Log { - $separator = [char]10 + ("=" * 60) + [char]10 + "[$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')] PowerShell Update Script Session Started" + [char]10 + ("=" * 60) - Add-Content -Path $LogFile -Value $separator -Encoding UTF8 -} +call :log "[阶段1] 解压完成,源目录: %SOURCE_DIR%" -# Polling rename function - Core method -# If file can be renamed, it means we have control of the file (handle released) -function Wait-FileUnlocked { - param( - [string]$FilePath, - [int]$MaxRetries = $MaxRenameRetries - ) - - if (-not (Test-Path $FilePath)) { - return $true - } - - $oldPath = "$FilePath.old" - $retryCount = 0 - - while ($retryCount -lt $MaxRetries) { - try { - # Try to rename file - This is an atomic operation - # If successful, it means file handle is fully released - Rename-Item -Path $FilePath -NewName "$($([System.IO.Path]::GetFileName($FilePath))).old" -Force -ErrorAction Stop - Write-Log "[Rename] Successfully renamed: $FilePath -> .old (Attempt $($retryCount + 1))" - return $true - } catch { - $retryCount++ - if ($retryCount % 20 -eq 0) { - Write-Log "[Rename] Waiting for file unlock: $FilePath (Tried $retryCount times)" - } - Start-Sleep -Milliseconds 500 - } - } - - Write-Log "[ERROR] File rename timeout: $FilePath" - return $false -} +:: ========== 阶段2: 校验解压完整性 ========== +call :log "[阶段2] 校验更新包完整性..." -# Clean up .old files -function Remove-OldFiles { - param([string]$Directory) - - Get-ChildItem -Path $Directory -Filter "*.old" -Recurse -ErrorAction SilentlyContinue | ForEach-Object { - try { - Remove-Item -Path $_.FullName -Force -ErrorAction Stop - Write-Log "[Cleanup] Deleted: $($_.FullName)" - } catch { - Write-Log "[WARN] Failed to delete .old file: $($_.FullName)" - } - } -} +:: 检查关键文件是否存在 +if not exist "%SOURCE_DIR%\\AI Learning Assistant Launcher.exe" ( + call :log "[错误] 校验失败:主程序文件不存在!启动旧版本..." + goto :launch_old_version +) -try { - # Initialize log file with session separator - Initialize-Log - - # ===== Self-Healing Phase: Ensure script has fully detached from parent process control ===== - Write-Log "========== Launcher Update Script Started ==========" - Write-Log "Log file location: $LogFile" - Write-Log "Script has entered execution phase (self-healing check)" - Write-Log "Target Version: $Version" - Write-Log "ZIP Path: $ZipPath" - Write-Log "App Directory: $AppDir" - Write-Log "Parent PID: $ParentPid" - Write-Log "Update Strategy: Polling Rename + Temporary File Replacement" - Write-Log "Preserved log files: $($PreserveLogPatterns -join ', ')" - - # Initial wait: Ensure Electron main process completes exit sequence - Write-Log "Initial delay: waiting for Electron to complete exit sequence..." - Start-Sleep -Seconds 2 - - # ===== Self-Healing Code: Force terminate parent if still alive ===== - # This is a fail-safe mechanism to handle cases where main process failed to exit properly - Write-Log "Self-healing check: verifying parent process status..." - try { - $parentProc = Get-Process -Id $ParentPid -ErrorAction SilentlyContinue - if ($null -ne $parentProc) { - Write-Log "[Self-Healing] Parent process still alive, attempting force termination PID: $ParentPid" - Write-Log "[Self-Healing] Process Name: $($parentProc.ProcessName), StartTime: $($parentProc.StartTime)" - - # Try graceful close first - try { - $parentProc.CloseMainWindow() | Out-Null - Start-Sleep -Seconds 2 - } catch { - Write-Log "[Self-Healing] CloseMainWindow failed, will force kill" - } - - # Check if already exited - $parentProc = Get-Process -Id $ParentPid -ErrorAction SilentlyContinue - if ($null -ne $parentProc) { - Write-Log "[Self-Healing] Force terminating parent process..." - Stop-Process -Id $ParentPid -Force -ErrorAction SilentlyContinue - Start-Sleep -Seconds 1 - } - - Write-Log "[Self-Healing] Parent process termination completed" - } else { - Write-Log "[Self-Healing] Parent process already exited, good" - } - } catch { - Write-Log "[Self-Healing] Exception during parent check (likely already exited): $($_.Exception.Message)" - } +if not exist "%SOURCE_DIR%\\resources" ( + call :log "[错误] 校验失败:resources 目录不存在!启动旧版本..." + goto :launch_old_version +) - # ===== Capture parent process identity to prevent PID reuse false positive ===== - $ExpectedProcessName = "AI-Learning-Assistant-Launcher" - $ParentStartTime = $null - $ParentPath = $ParentExePath - try { - $initialParent = Get-Process -Id $ParentPid -ErrorAction SilentlyContinue - if ($null -ne $initialParent) { - $ParentStartTime = $initialParent.StartTime - # Also capture actual path from process as fallback - if ($null -eq $ParentPath -or $ParentPath -eq "") { - try { - $ParentPath = $initialParent.Path - } catch { - Write-Log "[WARN] Could not get process path: $($_.Exception.Message)" - } - } - Write-Log "Captured parent process identity - Name: $($initialParent.ProcessName), StartTime: $ParentStartTime, Path: $ParentPath" - } - } catch { - Write-Log "[WARN] Could not capture parent process identity: $($_.Exception.Message)" - } - - # ===== Formally wait for parent process to exit (with PID reuse protection) ===== - Write-Log "Waiting for main process to fully exit..." - $waitCount = 0 - $confirmedExit = $false - while ($waitCount -lt $MaxWaitSeconds) { - try { - $proc = Get-Process -Id $ParentPid -ErrorAction SilentlyContinue - if ($null -eq $proc) { - # PID not found - process likely exited - Write-Log "Main process confirmed exited (PID not found)" - $confirmedExit = $true - break - } - - # PID reuse protection: verify process identity matches expected parent - # If any identity attribute differs, it's a different process (PID reused) - $isSameProcess = $true - - # 1. Check start time - if ($null -ne $ParentStartTime) { - try { - $currentStartTime = $proc.StartTime - if ($currentStartTime -ne $ParentStartTime) { - $isSameProcess = $false - Write-Log "[PID Reuse Detected] Start time mismatch - Expected: $ParentStartTime, Current: $currentStartTime" - } - } catch { - Write-Log "[WARN] Could not compare start times: $($_.Exception.Message)" - } - } - - # 2. Check process name - if ($proc.ProcessName -notlike "*$ExpectedProcessName*") { - $isSameProcess = $false - Write-Log "[PID Reuse Detected] Process name mismatch - Expected: $ExpectedProcessName, Current: $($proc.ProcessName)" - } - - # 3. Check executable path (most reliable identifier) - if ($null -ne $ParentPath -and $ParentPath -ne "") { - try { - $currentPath = $proc.Path - # Use case-insensitive comparison for Windows paths - if ($currentPath -and $currentPath.ToLower() -ne $ParentPath.ToLower()) { - $isSameProcess = $false - Write-Log "[PID Reuse Detected] Executable path mismatch - Expected: $ParentPath, Current: $currentPath" - } - } catch { - Write-Log "[WARN] Could not compare executable paths: $($_.Exception.Message)" - } - } - - if (-not $isSameProcess) { - Write-Log "[PID Reuse] PID $ParentPid now belongs to a different process, original process has exited" - $confirmedExit = $true - break - } - - # If still alive after 10 seconds, try force kill again - if ($waitCount -eq 10) { - Write-Log "[Fallback] Process still alive after 10s, force killing..." - Stop-Process -Id $ParentPid -Force -ErrorAction SilentlyContinue - } - } catch { - Write-Log "Main process exited (exception detected)" - $confirmedExit = $true - break - } - Start-Sleep -Seconds 1 - $waitCount++ - if ($waitCount % 10 -eq 0) { - Write-Log "Still waiting for main process to exit... ($waitCount seconds)" - } - } - - if (-not $confirmedExit -and $waitCount -ge $MaxWaitSeconds) { - Write-Log "[WARN] Timeout waiting for main process to exit, but proceeding with caution..." - # Add extra safety delay when timeout occurred - Write-Log "Adding extra 3-second safety delay due to timeout..." - Start-Sleep -Seconds 3 - } +call :log "[阶段2] 校验通过,更新包完整" - # ===== Polling rename critical files ===== - # Confirm file handles are fully released by attempting rename - Write-Log "Starting to poll for file handle release..." - - $exePath = Join-Path $AppDir $ExeName - $criticalFiles = @( - $exePath, - (Join-Path $AppDir "libEGL.dll"), - (Join-Path $AppDir "libGLESv2.dll"), - (Join-Path $AppDir "ffmpeg.dll") +:: ========== 阶段3: 备份用户数据和旧版本 ========== +call :log "[阶段3] 备份用户数据 external-resources..." +if exist "%APP_DIR%\\external-resources" ( + xcopy "%APP_DIR%\\external-resources" "%BACKUP_DIR%\\" /E /I /H /Y /Q >nul 2>&1 + if errorlevel 1 ( + call :log "[警告] 用户数据备份可能不完整" + ) else ( + call :log "[阶段3] 用户数据备份完成" ) - - # Check app.asar (if exists) - $asarPath = Join-Path $AppDir "resources\\app.asar" - if (Test-Path $asarPath) { - $criticalFiles += $asarPath - } - - $allUnlocked = $true - foreach ($file in $criticalFiles) { - if (Test-Path $file) { - Write-Log "Checking file: $file" - $unlocked = Wait-FileUnlocked -FilePath $file - if (-not $unlocked) { - $allUnlocked = $false - Write-Log "[ERROR] File still locked: $file" - } - } - } - - if (-not $allUnlocked) { - Write-Log "[ERROR] Some files cannot be unlocked, update aborted" - # Try to restore renamed files - Get-ChildItem -Path $AppDir -Filter "*.old" -Recurse -ErrorAction SilentlyContinue | ForEach-Object { - $originalName = $_.FullName -replace '\\.old$', '' - try { - Rename-Item -Path $_.FullName -NewName ([System.IO.Path]::GetFileName($originalName)) -Force - } catch {} - } - exit 1 - } - - Write-Log "All critical file handles released" - - # Verify ZIP file exists - if (-not (Test-Path $ZipPath)) { - Write-Log "[ERROR] ZIP file does not exist: $ZipPath" - exit 1 - } +) - # Backup external-resources - $externalResources = Join-Path $AppDir "external-resources" - $backupDir = Join-Path $env:TEMP "launcher-external-resources-backup" - - if (Test-Path $externalResources) { - Write-Log "Backing up user data..." - if (Test-Path $backupDir) { - Remove-Item -Path $backupDir -Recurse -Force - } - Copy-Item -Path $externalResources -Destination $backupDir -Recurse -Force - Write-Log "User data backup completed" - } +call :log "[阶段3] 备份旧版本应用..." +mkdir "%APP_BACKUP_DIR%" 2>nul +xcopy "%APP_DIR%\\*" "%APP_BACKUP_DIR%\\" /E /I /H /Y /Q >nul 2>&1 +if errorlevel 1 ( + call :log "[警告] 旧版本备份可能不完整,但继续更新" +) else ( + call :log "[阶段3] 旧版本备份完成" +) - # Extract to temporary directory - $extractDir = Join-Path $env:TEMP "launcher-update-extract" - if (Test-Path $extractDir) { - Remove-Item -Path $extractDir -Recurse -Force - } - - Write-Log "Extracting update package..." - Expand-Archive -Path $ZipPath -DestinationPath $extractDir -Force - Write-Log "Extraction completed" - - # Find directory containing exe - $sourceDir = $extractDir - $exeFile = Get-ChildItem -Path $extractDir -Filter $ExeName -Recurse -File | Select-Object -First 1 - if ($null -ne $exeFile) { - $sourceDir = $exeFile.DirectoryName - Write-Log "Found source directory: $sourceDir" - } else { - Write-Log "[ERROR] Could not find $ExeName" - exit 1 - } +:: ========== 阶段4: 替换文件 ========== +call :log "[阶段4] 清理旧版本文件(保留 external-resources)..." +for /d %%D in ("%APP_DIR%\\*") do ( + if /I not "%%~nxD"=="external-resources" ( + rd /s /q "%%D" 2>nul + ) +) +for %%F in ("%APP_DIR%\\*") do ( + del /f /q "%%F" 2>nul +) - # ===== Perform replacement ===== - # Files are now renamed to .old, safe to copy new files - Write-Log "Starting to install new version..." - - # Delete renamed .old files and other old files (preserve external-resources and log files) - Write-Log "Cleaning up old version files..." - Get-ChildItem -Path $AppDir | Where-Object { - $_.Name -ne "external-resources" -and - $_.Name -notin $PreserveLogPatterns - } | ForEach-Object { - try { - if ($_.PSIsContainer) { - Remove-Item -Path $_.FullName -Recurse -Force -ErrorAction Stop - } else { - Remove-Item -Path $_.FullName -Force -ErrorAction Stop - } - } catch { - Write-Log "[WARN] Deletion failed: $($_.FullName) - $($_.Exception.Message)" - } - } +call :log "[阶段4] 安装新版本..." +xcopy "%SOURCE_DIR%\\*" "%APP_DIR%\\" /E /I /H /Y /Q >nul 2>&1 +if errorlevel 1 ( + call :log "[错误] 复制新文件失败!尝试从备份恢复..." + goto :restore_from_backup +) - # Copy new files (exclude external-resources) - Write-Log "Copying new version files..." - Get-ChildItem -Path $sourceDir | Where-Object { $_.Name -ne "external-resources" } | ForEach-Object { - $destPath = Join-Path $AppDir $_.Name - Copy-Item -Path $_.FullName -Destination $destPath -Recurse -Force - } - Write-Log "File copy completed" - - # Restore user data - if (Test-Path $backupDir) { - if (-not (Test-Path $externalResources)) { - Write-Log "Restoring user data..." - Copy-Item -Path $backupDir -Destination $externalResources -Recurse -Force - Write-Log "User data restoration completed" - } - # Clean up backup - Remove-Item -Path $backupDir -Recurse -Force -ErrorAction SilentlyContinue - } +:: 验证安装结果 +if not exist "%APP_DIR%\\AI Learning Assistant Launcher.exe" ( + call :log "[错误] 安装验证失败:主程序不存在!尝试从备份恢复..." + goto :restore_from_backup +) - # Verify installation - $newExePath = Join-Path $AppDir $ExeName - if (-not (Test-Path $newExePath)) { - Write-Log "[ERROR] Installation verification failed: $ExeName does not exist" - exit 1 - } - Write-Log "Installation verification passed" +call :log "[阶段4] 新版本安装完成" - # Clean up temporary files - Write-Log "Cleaning up temporary files..." - Remove-Item -Path $extractDir -Recurse -Force -ErrorAction SilentlyContinue - - # Clean up update config - $configPath = Join-Path $env:APPDATA "ai-learning-assistant-launcher\\update-config.json" - if (Test-Path $configPath) { - Remove-Item -Path $configPath -Force -ErrorAction SilentlyContinue - } - $pendingZip = Join-Path $env:APPDATA "ai-learning-assistant-launcher\\pending-update.zip" - if (Test-Path $pendingZip) { - Remove-Item -Path $pendingZip -Force -ErrorAction SilentlyContinue - } +:: ========== 阶段5: 恢复用户数据 ========== +call :log "[阶段5] 恢复用户数据..." +if exist "%BACKUP_DIR%" ( + xcopy "%BACKUP_DIR%\\*" "%APP_DIR%\\external-resources\\" /E /I /H /Y /Q >nul 2>&1 + call :log "[阶段5] 用户数据恢复完成" +) - Write-Log "========== Update completed, starting new version ==========" - - # Start new version - Start-Process -FilePath $newExePath -WorkingDirectory $AppDir +:: ========== 阶段6: 清理和启动 ========== +call :log "[阶段6] 更新成功!清理临时文件..." +:: 延迟删除临时目录,避免影响日志 +start /b cmd /c "timeout /t 5 /nobreak >nul & rd /s /q "%TEMP_DIR%" 2>nul" + +call :log "[阶段6] 启动新版本..." +call :log "========== 更新脚本结束 ==========" +start "" "%APP_DIR%\\AI Learning Assistant Launcher.exe" +exit + +:: ========== 错误处理: 从备份恢复 ========== +:restore_from_backup +call :log "[恢复] 开始从备份恢复旧版本..." +if exist "%APP_BACKUP_DIR%\\AI Learning Assistant Launcher.exe" ( + :: 清理可能的残留 + for /d %%D in ("%APP_DIR%\\*") do rd /s /q "%%D" 2>nul + for %%F in ("%APP_DIR%\\*") do del /f /q "%%F" 2>nul - Write-Log "New version started" - exit 0 + :: 恢复备份 + xcopy "%APP_BACKUP_DIR%\\*" "%APP_DIR%\\" /E /I /H /Y /Q >nul 2>&1 + if exist "%APP_DIR%\\AI Learning Assistant Launcher.exe" ( + call :log "[恢复] 旧版本恢复成功" + goto :launch_old_version + ) +) +call :log "[恢复] 恢复失败,请手动重新安装应用" +pause +exit + +:: ========== 错误处理: 启动旧版本 ========== +:launch_old_version +call :log "[回退] 启动旧版本..." +if exist "%APP_DIR%\\AI Learning Assistant Launcher.exe" ( + start "" "%APP_DIR%\\AI Learning Assistant Launcher.exe" +) else ( + call :log "[错误] 旧版本也不存在,请手动重新安装" + pause +) +exit -} catch { - Write-Log "[ERROR] Update failed: $($_.Exception.Message)" - Write-Log "[ERROR] Stack trace: $($_.ScriptStackTrace)" - - # Try to restore .old files - Write-Log "Attempting to restore renamed files..." - Get-ChildItem -Path $AppDir -Filter "*.old" -Recurse -ErrorAction SilentlyContinue | ForEach-Object { - $originalName = $_.FullName -replace '\\.old$', '' - try { - if (-not (Test-Path $originalName)) { - Rename-Item -Path $_.FullName -NewName ([System.IO.Path]::GetFileName($originalName)) -Force - Write-Log "[Restore] Restored: $originalName" - } - } catch { - Write-Log "[WARN] Restoration failed: $($_.FullName)" - } - } - - # Try to start old version - $oldExe = Join-Path $AppDir $ExeName - if (Test-Path $oldExe) { - Write-Log "Attempting to start old version..." - Start-Process -FilePath $oldExe -WorkingDirectory $AppDir - } - exit 1 -} +:: ========== 日志函数 ========== +:log +echo [%date% %time%] %~1 +echo [%date% %time%] %~1 >> "%UPDATE_LOG%" 2>nul +exit /b 0 `; - // 写入脚本文件,添加 UTF-8 BOM 防止中文路径或特殊字符导致解析失败 - const BOM = '\uFEFF'; - await writeFile(scriptPath, BOM + scriptContent, { encoding: 'utf8' }); + // 确保临时目录存在 + if (!existsSync(tempDir)) { + mkdirSync(tempDir, { recursive: true }); + } + + const scriptPath = path.join(tempDir, 'update.bat'); + await writeFile(scriptPath, scriptContent, { encoding: 'utf8' }); - await writeUpdateLog('INFO', '[generatePowerShellUpdateScript] 脚本已生成', { + await writeUpdateLog('INFO', '[generateUpdateScript] 更新脚本已生成', { scriptPath, - logPath, + zipPath, + appDir, + tempDir, + newVersion, }); return scriptPath; } -export async function installLauncherUpdate() { - // 防止重复点击 - if (isInstallInProgress) { +/** + * 等待文件句柄释放的重试配置 + */ +const FILE_RELEASE_CONFIG = { + MAX_RETRIES: 10, + INITIAL_DELAY_MS: 200, + MAX_DELAY_MS: 2000, +}; + +/** + * 检测文件是否可访问(句柄已释放) + * 通过尝试 rename 操作来验证文件是否被占用 + * @param filePath 文件路径 + * @returns 是否可访问 + */ +async function isFileAccessible(filePath: string): Promise { + try { + // 检查文件是否可读 + await access(filePath, constants.R_OK); + + // 尝试 rename 到自身(Windows 下被占用的文件无法 rename) + // 这是检测文件句柄是否释放的可靠方法 + const tempPath = filePath + '.tmp_check'; + await rename(filePath, tempPath); + await rename(tempPath, filePath); + + return true; + } catch { + return false; + } +} + +/** + * 等待文件句柄释放,使用指数退避重试机制 + * @param filePath 文件路径 + * @returns 文件是否可访问 + */ +async function waitForFileRelease(filePath: string): Promise { + let delay = FILE_RELEASE_CONFIG.INITIAL_DELAY_MS; + + for (let attempt = 1; attempt <= FILE_RELEASE_CONFIG.MAX_RETRIES; attempt++) { + const accessible = await isFileAccessible(filePath); + + if (accessible) { + await writeUpdateLog('DEBUG', '[waitForFileRelease] 文件句柄已释放', { + filePath, + attempt, + }); + return; + } + await writeUpdateLog( - 'WARN', - '[installLauncherUpdate] 更新已在进行中,忽略重复调用', + 'DEBUG', + '[waitForFileRelease] 文件仍被占用,等待重试', + { + filePath, + attempt, + nextDelayMs: delay, + }, ); - return { - success: false, - message: '更新已在进行中,请勿重复点击', - }; - } - isInstallInProgress = true; + await new Promise((resolve) => setTimeout(resolve, delay)); - await writeUpdateLog( - 'INFO', - '[installLauncherUpdate] 开始执行两阶段更新(阶段一)', + // 指数退避,但不超过最大延迟 + delay = Math.min(delay * 2, FILE_RELEASE_CONFIG.MAX_DELAY_MS); + } + + throw new Error( + `文件句柄释放超时: ${filePath},已重试 ${FILE_RELEASE_CONFIG.MAX_RETRIES} 次`, ); +} + +export async function installLauncherUpdate() { + await writeUpdateLog('INFO', '[installLauncherUpdate] 开始执行自定义更新'); // 如果是开发模式,不执行更新 const isPackaged = app.isPackaged; @@ -972,7 +574,6 @@ export async function installLauncherUpdate() { 'WARN', '[installLauncherUpdate] 开发模式下不支持自动更新', ); - isInstallInProgress = false; return { success: false, message: '开发模式下不支持自动更新', @@ -1048,22 +649,30 @@ export async function installLauncherUpdate() { ); await destroyWebtorrentForInstall(magnet); - // 等待一小段时间确保文件句柄释放 - await new Promise((resolve) => setTimeout(resolve, 1000)); + // 使用重试机制等待文件句柄释放 + await waitForFileRelease(zipPath); + await writeUpdateLog('DEBUG', '[installLauncherUpdate] 文件句柄已释放'); + + // 获取应用目录和临时目录 + const exePath = app.getPath('exe'); + const appDir = path.dirname(exePath); + const tempDir = path.join(app.getPath('temp'), 'launcher-update'); + + await writeUpdateLog('DEBUG', '[installLauncherUpdate] 应用目录:', appDir); + await writeUpdateLog('DEBUG', '[installLauncherUpdate] 临时目录:', tempDir); // 显示更新确认对话框 const result = await dialog.showMessageBox({ type: 'info', title: '准备更新', - message: '启动器将重启并应用更新', - detail: `当前版本: ${currentVersion}\n新版本: ${version}\n\n点击"确定"后程序将重启,重启过程中会自动应用更新。`, + message: '启动器将在关闭后自动更新', + detail: `当前版本: ${currentVersion}\n新版本: ${version}\n\n点击"确定"后程序将关闭并自动更新,更新完成后会自动重启。`, buttons: ['确定', '取消'], defaultId: 0, cancelId: 1, }); if (result.response !== 0) { - isInstallInProgress = false; await writeUpdateLog('INFO', '[installLauncherUpdate] 用户取消更新'); return { success: false, @@ -1073,254 +682,49 @@ export async function installLauncherUpdate() { await writeUpdateLog( 'INFO', - '[installLauncherUpdate] 用户确认更新,开始准备更新文件', + '[installLauncherUpdate] 用户确认更新,准备执行自定义更新', ); - // 获取应用目录 - const exePath = app.getPath('exe'); - const appDir = path.dirname(exePath); - - // ===== 生成并启动 PowerShell 更新脚本 ===== - await writeUpdateLog('INFO', '[installLauncherUpdate] 生成更新脚本...'); - const scriptPath = await generatePowerShellUpdateScript( + // 生成更新脚本 + const scriptPath = await generateUpdateScript( zipPath, appDir, + tempDir, version, - exePath, ); - // 验证脚本文件是否成功生成且可读 - try { - await access(scriptPath, constants.R_OK); - await writeUpdateLog('INFO', '[installLauncherUpdate] 脚本文件验证通过', { - scriptPath, - }); - } catch (err) { - await writeUpdateLog( - 'ERROR', - '[installLauncherUpdate] 脚本文件验证失败,文件不存在或不可读', - { - scriptPath, - error: err instanceof Error ? err.message : String(err), - }, - ); - throw new Error(`脚本文件验证失败: ${scriptPath}`); - } - await writeUpdateLog( 'INFO', - '[installLauncherUpdate] 启动更新脚本 (使用 Start-Process 脱离 Job Object)...', - ); - - // ===== 关键:使用 PowerShell Start-Process 脱离 Job Object ===== - // Windows 会将子进程绑定到父进程的 Job Object 中,父进程退出时子进程也会被终止 - // 通过 PowerShell 的 Start-Process 启动的进程不会继承 Job Object,完全独立 - const isDebugMode = process.env.UPDATE_DEBUG === '1'; - const windowStyle = isDebugMode ? 'Normal' : 'Hidden'; - - // 构建 Start-Process 命令,启动一个完全独立的 PowerShell 进程 - // -PassThru 返回进程对象以便获取 PID - // 转义路径中的单引号 - const escapedScriptPath = scriptPath.replace(/'/g, "''"); - // PID 文件路径,作为 stdout 管道的可靠回退 - const pidFilePath = path.join( - app.getPath('userData'), - 'update-temp', - 'update-pid.txt', - ); - const escapedPidFilePath = pidFilePath - .replace(/\\/g, '\\\\') - .replace(/'/g, "''"); - - // 单行命令,避免换行符导致 -Command 解析异常 - // 同时将 PID 写入文件作为回退(stdout 管道在 detached 模式下不可靠) - const startProcessCommand = `$ErrorActionPreference='Stop'; $p = Start-Process -FilePath 'powershell.exe' -ArgumentList @('-NoProfile','-ExecutionPolicy','Bypass','-WindowStyle','${windowStyle}','-File','${escapedScriptPath}') -WindowStyle ${windowStyle} -PassThru; $p.Id | Out-File -FilePath '${escapedPidFilePath}' -NoNewline -Encoding UTF8; Write-Output $p.Id`; - - const args = [ - '-NoProfile', - '-ExecutionPolicy', - 'Bypass', - '-Command', - startProcessCommand, - ]; - - await writeUpdateLog( - 'DEBUG', - `[installLauncherUpdate] 启动参数: 调试模式=${isDebugMode}`, - { - command: 'powershell.exe', - startProcessCommand, - windowStyle, - pidFilePath, - }, + '[installLauncherUpdate] 启动更新脚本:', + scriptPath, ); - // ===== 使用 execFile 替代 spawn + pipe ===== - // execFile 更可靠地捕获 stdout/stderr,避免 detached+pipe 组合的管道丢失问题 - // 外层 PowerShell 只需运行 Start-Process 并返回 PID,不需要 detached - // 真正的脱离 Job Object 由内层 Start-Process 完成 - const { stdout: stdoutData, stderr: stderrData } = await new Promise<{ - stdout: string; - stderr: string; - }>((resolve, reject) => { - const timeout = setTimeout(() => { - reject(new Error('启动更新进程超时(15秒)')); - }, 15000); - - execFile( - 'powershell.exe', - args, - { - timeout: 15000, - windowsHide: true, - encoding: 'utf8', - }, - (error, stdout, stderr) => { - clearTimeout(timeout); - if (error) { - writeUpdateLog('ERROR', '[installLauncherUpdate] execFile 错误', { - message: error.message, - code: (error as any).code, - killed: (error as any).killed, - stdout, - stderr, - }); - reject(error); - } else { - resolve({ stdout: stdout || '', stderr: stderr || '' }); - } - }, - ); - }); - - await writeUpdateLog('DEBUG', '[installLauncherUpdate] PowerShell 输出', { - stdout: stdoutData.trim(), - stderr: stderrData.trim(), + // 启动更新脚本 + const updateProcess = spawn('cmd.exe', ['/c', scriptPath], { + detached: true, + stdio: 'ignore', + windowsHide: true, }); - - // 解析输出获取新进程 PID - let launchedPid = parseInt(stdoutData.trim(), 10); - - // 如果 stdout 解析失败,尝试从 PID 文件回退读取 - if (isNaN(launchedPid) || launchedPid <= 0) { - await writeUpdateLog( - 'WARN', - '[installLauncherUpdate] stdout 解析 PID 失败,尝试从文件回退读取', - { stdout: stdoutData, pidFilePath }, - ); - try { - if (existsSync(pidFilePath)) { - const pidFromFile = readFileSync(pidFilePath, 'utf-8').trim(); - // 移除 UTF-8 BOM(如果存在) - const cleanPid = pidFromFile.replace(/^\uFEFF/, ''); - launchedPid = parseInt(cleanPid, 10); - await writeUpdateLog( - 'INFO', - '[installLauncherUpdate] 从文件回退读取 PID 成功', - { pid: launchedPid, raw: pidFromFile }, - ); - } - } catch (pidFileErr) { - await writeUpdateLog( - 'ERROR', - '[installLauncherUpdate] 读取 PID 文件失败', - { - error: - pidFileErr instanceof Error - ? pidFileErr.message - : String(pidFileErr), - }, - ); - } - } - - // 清理 PID 文件 - try { - if (existsSync(pidFilePath)) { - unlinkSync(pidFilePath); - } - } catch { - // 忽略清理失败 - } - - if (isNaN(launchedPid) || launchedPid <= 0) { - await writeUpdateLog( - 'ERROR', - '[installLauncherUpdate] 无法获取更新脚本进程 PID(stdout 和文件回退均失败)', - { stdout: stdoutData, stderr: stderrData, pidFilePath }, - ); - throw new Error('无法获取更新脚本进程 PID'); - } - - await writeUpdateLog('INFO', '[installLauncherUpdate] 独立更新进程已启动', { - launchedPid, - scriptPath, - }); - - // ===== 验证独立进程确实在运行 ===== - const maxVerifyAttempts = 10; - const verifyIntervalMs = 300; - let verified = false; - - for (let i = 0; i < maxVerifyAttempts; i++) { - await new Promise((resolve) => setTimeout(resolve, verifyIntervalMs)); - - try { - // signal 0 只检查进程是否存在,不发送信号 - process.kill(launchedPid, 0); - verified = true; - await writeUpdateLog( - 'DEBUG', - `[installLauncherUpdate] 进程验证成功 (尝试 ${i + 1}/${maxVerifyAttempts})`, - { pid: launchedPid }, - ); - break; - } catch { - await writeUpdateLog( - 'WARN', - `[installLauncherUpdate] 进程验证中 (尝试 ${i + 1}/${maxVerifyAttempts})`, - { pid: launchedPid }, - ); - } - } - - if (!verified) { - await writeUpdateLog( - 'ERROR', - '[installLauncherUpdate] 更新进程验证失败,进程可能未成功启动', - ); - throw new Error('更新进程启动验证失败'); - } + updateProcess.unref(); await writeUpdateLog( 'INFO', - '[installLauncherUpdate] 更新进程验证通过,准备退出主进程', + '[installLauncherUpdate] 更新脚本已启动,准备退出应用', ); - // 额外等待确保 PowerShell 脚本开始执行 - await new Promise((resolve) => setTimeout(resolve, 1000)); - - await writeUpdateLog('INFO', '[installLauncherUpdate] 准备退出应用...'); - - // 设置 forceQuit 标志,绕过托盘最小化拦截,确保应用能正常退出 - setForceQuit(true); - - // 使用 app.exit(0) 而不是 app.quit() - // app.quit() 会发出 before-quit 等事件并等待所有窗口关闭,可能因为 onbeforeunload 拦截而挂起 - // app.exit(0) 更彻底,不触发窗口关闭逻辑 + // 延迟退出应用 setTimeout(() => { - app.exit(0); - }, 500); + app.quit(); + }, 1000); return { success: true, message: '正在更新启动器...', }; } catch (error) { - isInstallInProgress = false; await writeUpdateLog( 'ERROR', - '[installLauncherUpdate] 两阶段更新(阶段一)失败', + '[installLauncherUpdate] 自定义更新失败', error instanceof Error ? { message: error.message, stack: error.stack } : error, diff --git a/src/main/local-service/obsidian-voice-service/index.ts b/src/main/local-service/obsidian-voice-service/index.ts index bef9324..4e7f00d 100644 --- a/src/main/local-service/obsidian-voice-service/index.ts +++ b/src/main/local-service/obsidian-voice-service/index.ts @@ -1,4 +1,7 @@ +import { execFile } from 'child_process'; +import { promisify } from 'util'; import path from 'path'; +const exec = promisify(execFile); import { IpcMain } from 'electron'; import { ipcHandle } from '../../ipc-util'; import { @@ -7,11 +10,7 @@ import { runObsidianVoiceServiceHandle, stopObsidianVoiceServiceHandle, } from './type-info'; -import { appPath, Exec } from '../../exec'; -import { loggerFactory } from '../../terminal-log'; - -const commandLine = new Exec(); -const terminalLogger = loggerFactory('NATIVE_OBSIDIAN_VOICE'); +import { appPath } from '../../exec'; export default function init(ipcMain: IpcMain): void { ipcHandle( @@ -37,7 +36,7 @@ const psDir = path.join( export async function getObsidianVoiceServiceStatus(): Promise { try { - const { stdout } = await commandLine.exec( + const { stdout } = await exec( 'powershell', [ '-ExecutionPolicy', @@ -57,7 +56,7 @@ export async function getObsidianVoiceServiceStatus(): Promise { export async function installObsidianVoiceService(): Promise { try { - const { stdout } = await commandLine.exec( + const { stdout } = await exec( 'powershell', [ '-ExecutionPolicy', @@ -65,7 +64,7 @@ export async function installObsidianVoiceService(): Promise { '-Command', `cd "${psDir}"; .\\install.ps1`, ], - { encoding: 'utf8', logger: terminalLogger }, + { encoding: 'utf8' }, ); return stdout.trim(); } catch (e: any) { @@ -77,7 +76,7 @@ export async function installObsidianVoiceService(): Promise { export async function runObsidianVoiceService(): Promise { try { - const { stdout } = await commandLine.exec( + const { stdout } = await exec( 'powershell', [ '-ExecutionPolicy', @@ -85,7 +84,7 @@ export async function runObsidianVoiceService(): Promise { '-Command', `cd "${psDir}"; .\\run.ps1`, ], - { encoding: 'utf8', logger: terminalLogger }, + { encoding: 'utf8' }, ); return stdout.trim(); } catch (e: any) { @@ -97,7 +96,7 @@ export async function runObsidianVoiceService(): Promise { export async function stopObsidianVoiceService(): Promise { try { - const { stdout } = await commandLine.exec( + const { stdout } = await exec( 'powershell', [ '-ExecutionPolicy', @@ -105,7 +104,7 @@ export async function stopObsidianVoiceService(): Promise { '-Command', `cd "${psDir}"; .\\stop.ps1`, ], - { encoding: 'utf8', logger: terminalLogger }, + { encoding: 'utf8' }, ); return stdout.trim(); } catch (e: any) { diff --git a/src/main/local-service/rts-service/index.ts b/src/main/local-service/rts-service/index.ts index 8a32303..0479ca7 100644 --- a/src/main/local-service/rts-service/index.ts +++ b/src/main/local-service/rts-service/index.ts @@ -1,3 +1,5 @@ +import { spawn, execFile } from 'child_process'; +import { promisify } from 'util'; import path from 'path'; import fs from 'fs'; import net from 'net'; @@ -12,11 +14,7 @@ import { rtsProgressChannel, RTSProgressInfo, } from './type-info'; -import { appPath, Exec } from '../../exec'; -import { spawn } from 'cross-spawn'; -import { loggerFactory } from '../../terminal-log'; - -const commandLine = new Exec(); +import { appPath } from '../../exec'; // 日志前缀标签 const LOG_TAG = '[RTS-Service]'; @@ -26,17 +24,13 @@ function timestamp(): string { return new Date().toISOString().substring(11, 23); } -const terminalLogger = loggerFactory('NATIVE_TRAINING_RTS'); - // 统一日志函数 function logInfo(message: string, ...args: unknown[]): void { console.log(`${timestamp()} ${LOG_TAG} ${message}`, ...args); - terminalLogger.log(`${timestamp()} ${LOG_TAG} ${message}`, ...args); } function logError(message: string, ...args: unknown[]): void { console.error(`${timestamp()} ${LOG_TAG} ERROR: ${message}`, ...args); - terminalLogger.error(`${timestamp()} ${LOG_TAG} ERROR: ${message}`, ...args); } // 缓存上次状态,避免重复日志 @@ -258,7 +252,6 @@ export async function installRTSService(): Promise { child.stdout.on('data', (data: Buffer) => { const text = data.toString(); stdoutData += text; - terminalLogger.log(text); // 解析每一行检查是否有进度信息 const lines = text.split('\n'); @@ -280,7 +273,6 @@ export async function installRTSService(): Promise { child.stderr.on('data', (data: Buffer) => { const text = data.toString(); stderrData += text; - terminalLogger.error(text); }); child.on('close', (code) => { @@ -320,13 +312,7 @@ export async function runRTSService(): Promise { const child = spawn( 'powershell', ['-ExecutionPolicy', 'Bypass', '-Command', `cd "${psDir}"; .\\run.ps1`], - { - shell: true, - env: { - ...process.env, - LLM_STREAM_URL: 'http://localhost:7100/api/ai-chat/chat/stream', - }, - }, + { shell: true }, ); let stdoutData = ''; @@ -335,7 +321,6 @@ export async function runRTSService(): Promise { child.stdout.on('data', (data: Buffer) => { const text = data.toString(); stdoutData += text; - terminalLogger.log(text); // 解析每一行检查是否有进度信息 const lines = text.split('\n'); @@ -376,7 +361,6 @@ export async function runRTSService(): Promise { child.stderr.on('data', (data: Buffer) => { const text = data.toString(); stderrData += text; - terminalLogger.error(text); }); child.on('close', (code) => { @@ -410,13 +394,10 @@ export async function stopRTSService(): Promise { const startTime = Date.now(); try { - const { stdout } = await commandLine.exec( + const { stdout } = await exec( 'powershell', ['-ExecutionPolicy', 'Bypass', '-Command', `cd "${psDir}"; .\\stop.ps1`], - { - encoding: 'utf8', - logger: terminalLogger, - }, + { encoding: 'utf8' }, ); const status = extractStatus(stdout); diff --git a/src/main/native-script/index.ts b/src/main/native-script/index.ts index 5b95b68..e5c5246 100644 --- a/src/main/native-script/index.ts +++ b/src/main/native-script/index.ts @@ -12,26 +12,74 @@ import { loggerFactory } from '../terminal-log'; import { existsSync, readFileSync, mkdirSync, rmSync, cpSync } from 'fs'; import { CancellationTokenSourceImpl } from '../exec/cancellation-token'; import http from 'http'; +import { getLatestVersion, startWebtorrent, waitTorrentDone } from '../dlc'; import { llmConfigPath } from '../configs'; -import { queryTrainingConfig } from '../configs/training-config'; const commandLine = new Exec(); -// 根据进程名终止进程 -async function killProcessByName(processName: string): Promise { +// 检查进程是否是关键系统进程(Windows) +async function isCriticalProcess(pid: string): Promise { + if (!isWindows()) { + return false; // 非Windows系统暂不检查 + } + try { - if (isWindows()) { - // Windows: 使用taskkill根据进程名终止进程 - await commandLine.exec('taskkill', ['/F', '/IM', processName]); - console.debug(`已终止进程: ${processName}`); - } else { - // macOS/Linux: 使用pkill根据进程名终止进程 - await commandLine.exec('pkill', ['-9', '-f', processName]); - console.debug(`已终止进程: ${processName}`); + // 获取进程名称 + const tasklistResult = await commandLine.exec('tasklist', [ + '/FI', + `PID eq ${pid}`, + '/FO', + 'CSV', + '/NH', + ]); + const lines = tasklistResult.stdout.trim().split('\n'); + + if (lines.length === 0 || lines[0] === '') { + return false; // 进程不存在 } + + // CSV格式: "进程名","PID","会话名","会话#","内存使用" + const match = lines[0].match( + /"([^"]+)","([^"]+)","([^"]+)","([^"]+)","([^"]+)"/, + ); + if (!match) { + return false; + } + + const processName = match[1].toLowerCase(); + + // Windows关键系统进程列表 + const criticalProcesses = [ + 'system', + 'registry', + 'smss.exe', + 'csrss.exe', + 'wininit.exe', + 'services.exe', + 'lsass.exe', + 'svchost.exe', + 'explorer.exe', + 'taskhostw.exe', + 'dwm.exe', + 'ctfmon.exe', + 'winlogon.exe', + 'spoolsv.exe', + 'taskeng.exe', + 'conhost.exe', + 'sihost.exe', + 'runtimebroker.exe', + 'searchindexer.exe', + 'searchui.exe', + 'shellexperiencehost.exe', + ]; + + // 检查是否是关键进程 + return criticalProcesses.some((critical) => + processName.includes(critical.toLowerCase()), + ); } catch (error) { - // 如果命令执行失败(例如没有找到该进程),忽略错误 - console.debug(`没有找到进程 ${processName} 或无法终止:`, error); + console.warn(`无法检查进程 ${pid} 的信息:`, error); + return true; // 如果无法检查,安全起见不终止该进程 } } @@ -63,7 +111,7 @@ async function killProcessOnPort(port: number): Promise { for (const pid of pids) { try { // 安全检查:不终止关键系统进程 - const isCritical = Number(pid) == 0; + const isCritical = await isCriticalProcess(pid); if (isCritical) { console.warn(`跳过关键系统进程 PID: ${pid} (端口: ${port})`); continue; @@ -178,7 +226,6 @@ export async function installService( serviceName: NativeServiceName, ): Promise { if (serviceName === 'NATIVE_TRAINING') { - console.debug('开始清除旧版本'); try { rmSync(trainingServerSourcePath, { recursive: true }); } catch (e) { @@ -196,17 +243,11 @@ export async function installService( console.debug('开始下载程序'); - try { - await gitClone( - 'https://gitee.com/shiftonetothree/ai-learning-assistant-training-server.git', - trainingServerSourcePath, - 'refactor', - ); - } catch (e) { - console.error(e); - console.error('下载程序失败'); - return { state: 'not_install', version: '0.0.0' }; - } + await gitClone( + 'https://gitee.com/shiftonetothree/ai-learning-assistant-training-server.git', + trainingServerSourcePath, + 'refactor', + ); console.debug('开始编译程序'); await commandLine.exec('bun install', [], { @@ -215,24 +256,11 @@ export async function installService( cwd: trainingServerSourcePath, }); - // 检查package.json文件是否存在 - const packageJsonPath = path.join(trainingServerSourcePath, 'package.json'); - if (existsSync(packageJsonPath)) { - const packageJsonContent = readFileSync(packageJsonPath, 'utf-8'); - const packageJson = JSON.parse(packageJsonContent); - const frontendDistGit = packageJson.frontendDistGit; - try { - await gitClone( - frontendDistGit.url, - trainingFrontendPath, - frontendDistGit.branch, - ); - } catch (e) { - console.error(e); - console.error('下载界面程序失败'); - return { state: 'not_install', version: '0.0.0' }; - } - } + await gitClone( + 'https://gitee.com/shiftonetothree/ai-learning-assistant-training-front-dist.git', + trainingFrontendPath, + 'master', + ); cpSync(trainingFrontendPath, trainingServerSourcePublicPath, { recursive: true, @@ -247,7 +275,6 @@ export async function monitorStateIsRuning( serviceName: NativeServiceName, ): Promise { if (serviceName === 'NATIVE_TRAINING') { - let retryCounter = 30; console.debug('checking health', serviceName); return new Promise((resolve, reject) => { const interval = setInterval(async () => { @@ -263,11 +290,6 @@ export async function monitorStateIsRuning( } } else { // do nothing - if (retryCounter > 0) { - retryCounter--; - } else { - reject('服务超时还未启动'); - } } } else { clearInterval(interval); @@ -285,12 +307,6 @@ export async function uninstallService(serviceName: NativeServiceName) { } catch (e) { console.warn(e); } - // 终止bun.exe进程 - try { - await killProcessByName('bun.exe'); - } catch (e) { - console.warn(e); - } rmSync(trainingServerSourcePath, { recursive: true }); try { rmSync(trainingFrontendPath, { recursive: true }); @@ -304,33 +320,21 @@ export async function startService( ): Promise { if (serviceName === 'NATIVE_TRAINING') { const info = await getServiceInfo(serviceName); - const trainingConfig = await queryTrainingConfig(); if (info.state !== 'running') { const tokenSource = new CancellationTokenSourceImpl(); - commandLine - .exec( - `set PORT=${TRAINING_PORT} && set "ALA_LLM_CONFIG_PATH=${llmConfigPath}" && set "UNLOCK_ALL_SECTION=${trainingConfig.env.UNLOCK_ALL_SECTION}" && bun dev`, - [], - { - shell: true, - encoding: 'utf8', - logger: loggerFactory(serviceName), - cwd: trainingServerSourcePath, - token: tokenSource.token, - }, - ) - .catch((e) => { - console.warn(e); - console.debug('学科培训服务停止了'); - }); - } - try { - await monitorStateIsRuning(serviceName); - } catch (e) { - console.error(e); - await stopService(serviceName); + commandLine.exec( + `set PORT=${TRAINING_PORT} && set "ALA_LLM_CONFIG_PATH=${llmConfigPath}" && bun dev`, + [], + { + shell: true, + encoding: 'utf8', + logger: loggerFactory(serviceName), + cwd: trainingServerSourcePath, + token: tokenSource.token, + }, + ); } - + await monitorStateIsRuning(serviceName); return getServiceInfo(serviceName); } return { state: 'running', version: '1.0.0' }; @@ -342,12 +346,6 @@ export async function stopService(serviceName: NativeServiceName) { } catch (e) { console.warn(e); } - // 终止bun.exe进程 - try { - await killProcessByName('bun.exe'); - } catch (e) { - console.warn(e); - } } } diff --git a/src/main/native-script/type-info.ts b/src/main/native-script/type-info.ts index 6750777..b2b240b 100644 --- a/src/main/native-script/type-info.ts +++ b/src/main/native-script/type-info.ts @@ -1,8 +1,4 @@ -const nativeServiceNames = [ - 'NATIVE_TRAINING', - 'NATIVE_TRAINING_RTS', - 'NATIVE_OBSIDIAN_VOICE', -] as const; +const nativeServiceNames = ['NATIVE_TRAINING'] as const; export type NativeServiceName = (typeof nativeServiceNames)[number]; diff --git a/src/main/native-training-service/index.ts b/src/main/native-training-service/index.ts index a0b3048..d8f9fa3 100644 --- a/src/main/native-training-service/index.ts +++ b/src/main/native-training-service/index.ts @@ -8,8 +8,6 @@ import { updateCourseNativeTrainingServiceHandle, trainingWebURL, courseHaveNewVersionNativeTrainingServiceHandle, - haveNewVersionNativeTrainingServiceHandle, - updateNativeTrainingServiceHandle, } from './type-info'; import { ipcHandle } from '../ipc-util'; import { @@ -34,9 +32,6 @@ import { appPath, Exec } from '../exec'; import { CancellationTokenSourceImpl } from '../exec/cancellation-token'; import { loggerFactory } from '../terminal-log'; import { existsSync, readFileSync, writeFileSync } from 'node:fs'; -import git from 'isomorphic-git'; -import http from 'isomorphic-git/http/node'; -import fs from 'fs'; const commandLine = new Exec(); @@ -59,14 +54,6 @@ export default async function init(ipcMain: IpcMain) { ipcHandle(ipcMain, logsNativeTrainingServiceHandle, async (_event) => logsTrainingService(), ); - ipcHandle(ipcMain, updateNativeTrainingServiceHandle, async (_event) => - updateTrainingService(), - ); - ipcHandle( - ipcMain, - haveNewVersionNativeTrainingServiceHandle, - async (_event) => haveNewVersionTrainingService(), - ); ipcHandle(ipcMain, updateCourseNativeTrainingServiceHandle, async (_event) => updateCourseTrainingService(), ); @@ -161,17 +148,11 @@ export async function updateCourseTrainingService() { console.debug('开始下载课程数据'); const latestVersion = getLatestVersion('TRAINING_COURSE'); await startWebtorrent(latestVersion.dlcInfo.magnet); - let coursePath = ''; - try { - const torrent = await waitTorrentDone( - 'TRAINING_COURSE', - latestVersion.version, - ); - coursePath = path.join(torrent.path, torrent.files[0].name); - } catch (e) { - console.error(e); - return { someData: 'data1' }; - } + const torrent = await waitTorrentDone( + 'TRAINING_COURSE', + latestVersion.version, + ); + const coursePath = path.join(torrent.path, torrent.files[0].name); console.debug('将课程导入到学科培训'); await stopService('NATIVE_TRAINING'); await startService('NATIVE_TRAINING'); @@ -242,103 +223,3 @@ export async function courseHaveNewVersionTrainingService() { haveNew: currentVersion != latestVersion.version, }; } - -// 远程仓库URL和分支 -const TRAINING_REPO_URL = - 'https://gitee.com/shiftonetothree/ai-learning-assistant-training-server.git'; -const TRAINING_REPO_BRANCH = 'refactor'; - -/** - * 检查本地版本是否落后于远程版本 - * 使用 git 对比本地 HEAD 和远程分支的最新 commit - * @returns 如果本地落后则返回 true,否则返回 false - */ -export async function haveNewVersionTrainingService(): Promise<{ - haveNew: boolean; - localCommit?: string; - remoteCommit?: string; - error?: string; -}> { - try { - // 检查本地目录是否存在 - if (!existsSync(trainingServerSourcePath)) { - return { haveNew: false, error: '本地服务未安装' }; - } - - // 检查是否是 git 仓库 - const gitDir = path.join(trainingServerSourcePath, '.git'); - if (!existsSync(gitDir)) { - return { haveNew: false, error: '本地目录不是 git 仓库' }; - } - - // 获取本地 HEAD commit - let localCommit: string; - try { - const commits = await git.log({ - fs, - dir: trainingServerSourcePath, - depth: 1, - }); - if (commits.length === 0) { - return { haveNew: false, error: '无法获取本地 commit' }; - } - localCommit = commits[0].oid; - } catch (e) { - console.warn('获取本地 commit 失败:', e); - return { haveNew: false, error: '获取本地 commit 失败' }; - } - - // 获取远程分支的最新 commit - let remoteCommit: string; - try { - const remoteInfo = await git.getRemoteInfo({ - http, - url: TRAINING_REPO_URL, - }); - - // 查找目标分支的引用 - const refs = remoteInfo.refs?.heads; - if (refs && refs[TRAINING_REPO_BRANCH]) { - remoteCommit = refs[TRAINING_REPO_BRANCH]; - } else { - return { - haveNew: false, - error: `无法找到远程分支 ${TRAINING_REPO_BRANCH}`, - }; - } - } catch (e) { - console.warn('获取远程 commit 失败:', e); - return { haveNew: false, error: '获取远程 commit 失败,可能是网络问题' }; - } - - // 对比本地和远程 commit - const haveNew = localCommit !== remoteCommit; - - console.debug( - `版本检查: 本地 commit=${localCommit}, 远程 commit=${remoteCommit}, 有新版本=${haveNew}`, - ); - - return { - haveNew, - localCommit, - remoteCommit, - }; - } catch (e) { - console.warn('检查新版本时发生错误:', e); - return { haveNew: false, error: '检查新版本时发生未知错误' }; - } -} - -/** - * 更新 Training Service - * 执行 stopService -> installService,返回 installService 的结果 - */ -export async function updateTrainingService() { - // 先停止服务 - await stopService('NATIVE_TRAINING'); - - // 执行安装服务(会重新克隆并安装) - const result = await installTrainingService(); - - return result; -} diff --git a/src/main/native-training-service/type-info.ts b/src/main/native-training-service/type-info.ts index 792a988..f78b7cd 100644 --- a/src/main/native-training-service/type-info.ts +++ b/src/main/native-training-service/type-info.ts @@ -10,10 +10,6 @@ export const startNativeTrainingServiceHandle = `${channel}start`; export const removeNativeTrainingServiceHandle = `${channel}remove`; -export const updateNativeTrainingServiceHandle = `${channel}update`; - -export const haveNewVersionNativeTrainingServiceHandle = `${channel}haveNewVersion`; - export const updateCourseNativeTrainingServiceHandle = `${channel}updateCourse`; export const courseHaveNewVersionNativeTrainingServiceHandle = `${channel}courseHaveNewVersion`; diff --git a/src/main/preload.ts b/src/main/preload.ts index 79a76df..a908cbb 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -38,11 +38,6 @@ import { setUploadEnabledHandle, getUploadEnabledHandle, getUploadStatsHandle, - startHttpsDownloadHandle, - queryHttpsDownloadHandle, - cancelHttpsDownloadHandle, - checkHttpsDownloadFileHandle, - HttpsDownloadState, } from './dlc/type-info'; import { checkLauncherUpdateHandle, @@ -51,14 +46,12 @@ import { } from './launcher-update/type-info'; import { courseHaveNewVersionNativeTrainingServiceHandle, - haveNewVersionNativeTrainingServiceHandle, installNativeTrainingServiceHandle, logsNativeTrainingServiceHandle, queryNativeTrainingServiceHandle, removeNativeTrainingServiceHandle, startNativeTrainingServiceHandle, updateCourseNativeTrainingServiceHandle, - updateNativeTrainingServiceHandle, } from './native-training-service/type-info'; import { NativeServiceInfo } from './native-script/type-info'; import { @@ -75,16 +68,8 @@ import { runObsidianVoiceServiceHandle, stopObsidianVoiceServiceHandle, } from './local-service/obsidian-voice-service/type-info'; -import { - queryNativeTrainingConfigHandle, - setNativeTrainingConfigHandle, - TrainingConfig, -} from './configs/type-info'; const electronHandler = { - // 系统信息 - platform: process.platform, // 'win32' | 'darwin' | 'linux' - arch: process.arch, // 'x64' | 'arm64' | etc. ipcRenderer: { sendMessage( channel: Channels, @@ -182,12 +167,6 @@ const mainHandle = { logsTrainingServiceHandle, ); }, - queryNativeTrainingConfigHandle: async () => { - return ipcInvoke(queryNativeTrainingConfigHandle); - }, - setNativeTrainingConfigHandle: async (config: TrainingConfig) => { - return ipcInvoke(setNativeTrainingConfigHandle, config); - }, queryNativeTrainingServiceHandle: async () => { return ipcInvoke(queryNativeTrainingServiceHandle); }, @@ -210,16 +189,6 @@ const mainHandle = { haveNew: boolean; }>(courseHaveNewVersionNativeTrainingServiceHandle); }, - updateNativeTrainingServiceHandle: async () => { - return ipcInvoke(updateNativeTrainingServiceHandle); - }, - haveNewVersionNativeTrainingServiceHandle: async () => { - return ipcInvoke<{ - currentVersion: string; - latestVersion: string; - haveNew: boolean; - }>(haveNewVersionNativeTrainingServiceHandle); - }, logsNativeTrainingServiceHandle: async () => { return ipcInvoke<{ imageId: string; logs: string }>( logsNativeTrainingServiceHandle, @@ -328,32 +297,6 @@ const mainHandle = { activeTorrents: number; }>(getUploadStatsHandle); }, - // HTTPS 多源下载 - startHttpsDownloadHandle: async ( - dlcId: DLCId, - urls: string[], - version: string, - ) => { - return ipcInvoke<{ success: boolean; error?: string }>( - startHttpsDownloadHandle, - dlcId, - urls, - version, - ); - }, - queryHttpsDownloadHandle: async () => { - return ipcInvoke(queryHttpsDownloadHandle); - }, - cancelHttpsDownloadHandle: async (dlcId: DLCId) => { - return ipcInvoke<{ success: boolean }>(cancelHttpsDownloadHandle, dlcId); - }, - checkHttpsDownloadFileHandle: async (dlcId: DLCId, version: string) => { - return ipcInvoke<{ exists: boolean; filePath: string | null }>( - checkHttpsDownloadFileHandle, - dlcId, - version, - ); - }, // RTS 进度事件监听 onRtsProgress: (callback: (progress: RTSProgressInfo) => void) => { const handler = (_event: IpcRendererEvent, progress: RTSProgressInfo) => { diff --git a/src/renderer/containers/use-native-training-service-shortcut/index.tsx b/src/renderer/containers/use-native-training-service-shortcut/index.tsx index 2743297..32f43f4 100644 --- a/src/renderer/containers/use-native-training-service-shortcut/index.tsx +++ b/src/renderer/containers/use-native-training-service-shortcut/index.tsx @@ -9,17 +9,7 @@ export function useNativeTrainingServiceShortcut() { const [serviceInfo, setServiceInfo] = useState({ state: 'not_install', }); - const [courseVersionInfo, setCourseVersionInfo] = useState<{ - currentVersion: string; - latestVersion: string; - haveNew: boolean; - }>({ - currentVersion: '0.0.0', - latestVersion: '0.0.0', - haveNew: false, - }); - - const [programVersionInfo, setProgramVersionInfo] = useState<{ + const [versionInfo, setVersionInfo] = useState<{ currentVersion: string; latestVersion: string; haveNew: boolean; @@ -35,10 +25,7 @@ export function useNativeTrainingServiceShortcut() { setServiceInfo(serviceInfo); const versionInfo = await window.mainHandle.courseHaveNewVersionNativeTrainingServiceHandle(); - setCourseVersionInfo(versionInfo); - const pVersionInfo = - await window.mainHandle.haveNewVersionNativeTrainingServiceHandle(); - setProgramVersionInfo(pVersionInfo); + setVersionInfo(versionInfo); }, [setServiceInfo]); useEffect(() => { @@ -75,16 +62,6 @@ export function useNativeTrainingServiceShortcut() { serviceInfo.state = 'updating'; await window.mainHandle.updateCourseNativeTrainingServiceHandle(); } - await queryServiceInfo(); - }; - - const update = async () => { - await queryServiceInfo(); - if (serviceInfo.state !== 'not_install') { - serviceInfo.state = 'updating'; - await window.mainHandle.updateNativeTrainingServiceHandle(); - } - await queryServiceInfo(); }; const downloadLogs = async () => { @@ -111,10 +88,8 @@ export function useNativeTrainingServiceShortcut() { state: serviceInfo.state, start, remove, - courseVersionInfo, + versionInfo, updateCourse, - programVersionInfo, - update, downloadLogs, }; } diff --git a/src/renderer/pages/hello/index.tsx b/src/renderer/pages/hello/index.tsx index e0e8abc..2918f19 100644 --- a/src/renderer/pages/hello/index.tsx +++ b/src/renderer/pages/hello/index.tsx @@ -8,7 +8,7 @@ import { Progress, } from 'antd'; import { NavLink } from 'react-router-dom'; -import { useEffect, useState, useRef, useCallback } from 'react'; // 添加 useRef 导入 +import { useEffect, useState, useRef } from 'react'; // 添加 useRef 导入 import obsidianLogo from './2023_Obsidian_logo.png'; import llmIcon from './LLM_Icon.png'; import heroImage from './Frame 2.png'; @@ -27,8 +27,6 @@ import { useRtsService } from '../../containers/use-rts-service'; import { TorrentProgress } from '../../containers/torrent-progress'; import { TerminalLogScreen } from '../../containers/terminal-log-screen'; import toolsIcon from './Tools_Icon.png'; -import Checkbox, { CheckboxChangeEvent } from 'antd/es/checkbox/Checkbox'; -import { TrainingConfig } from '../../../main/configs/type-info'; export default function Hello() { const trainingShortcut = useNativeTrainingServiceShortcut(); @@ -269,16 +267,6 @@ export default function Hello() { setTrainingServiceStarting(true); setTrainingServiceRemoving(true); await trainingShortcut.updateCourse(); - message.success('学科培训课程更新成功'); - setTrainingServiceStarting(false); - setTrainingServiceRemoving(false); - }; - - const updateTrainingService = async () => { - setShowTerminalLog(true); - setTrainingServiceStarting(true); - setTrainingServiceRemoving(true); - await trainingShortcut.update(); message.success('学科培训更新成功'); setTrainingServiceStarting(false); setTrainingServiceRemoving(false); @@ -402,27 +390,6 @@ export default function Hello() { } }; - const [trainingConfig, setTrainingConfig] = useState( - null, - ); - - useEffect(() => { - window.mainHandle - .queryNativeTrainingConfigHandle() - .then((res) => setTrainingConfig(res)); - }); - - const handleTrainingConfigChange = useCallback( - async (e: CheckboxChangeEvent) => { - window.mainHandle.setNativeTrainingConfigHandle({ - env: { - UNLOCK_ALL_SECTION: e.target.checked, - }, - }); - }, - [], - ); - return (
{showTerminalLog ? ( -
- -
+ ) : (
@@ -589,21 +550,13 @@ export default function Hello() {

AI辅助的学科知识培训,学员建档设立目标,帮助补齐技能知识短板。 {trainingShortcut.state !== 'not_install' && - `当前版本:${trainingShortcut.courseVersionInfo.currentVersion}`} - - 进行非线性学习 - + `当前版本:${trainingShortcut.versionInfo.currentVersion}`}

{trainingShortcut.state === 'updating' && ( )}
@@ -611,8 +564,7 @@ export default function Hello() { {!( (trainingShortcut.state === 'stopped' || trainingShortcut.state === 'updating') && - (trainingShortcut.courseVersionInfo.haveNew || - trainingShortcut.programVersionInfo.haveNew) + trainingShortcut.versionInfo.haveNew ) && ( - )} {trainingShortcut.state !== 'not_install' && ( ), voiceState !== 'running' && ( @@ -78,7 +79,7 @@ export default function NativeAiService() { } onClick={runVoiceService} > - 启动 + 启动语音服务 ), (voiceState === 'running' || voiceState === 'starting') && ( @@ -93,7 +94,7 @@ export default function NativeAiService() { } onClick={stopVoiceService} > - 停止 + 停止语音服务 ), )}
From 86e47ab3dd63df2f71eaf9b3564fa2d72c190fd5 Mon Sep 17 00:00:00 2001 From: Charles Date: Sat, 28 Mar 2026 20:17:55 +0800 Subject: [PATCH 2/2] Revert "Merge pull request #77 from mnyon/run_training_native" need recheck This reverts commit 32b56339ad48b9e289163513251afbebbeb12d41, reversing changes made to 930eb7208aba88f1cfec23135990d487ff279070. --- .gitignore | 19 - AGENTS.md | 350 ------------------ README.md | 102 +++-- package.json | 13 +- playwright.config.ts | 51 --- src/main/joint-build/index.ts | 19 +- src/main/local-service/rts-service/index.ts | 141 +------ tests/README.md | 245 ------------ tests/global-setup.ts | 27 -- tests/main/README.md | 111 ------ tests/main/cmd/wsl.test.ts | 85 ----- tests/main/exec-util.test.ts | 16 - tests/main/ipc-data-type.test.ts | 63 ---- tests/main/ipc-util.test.ts | 84 ----- tests/main/util.test.ts | 82 ---- tests/renderer/README.md | 240 ------------ tests/renderer/basic.spec.ts | 65 ---- .../navigation/00-all-navigation.spec.ts | 142 ------- .../renderer/navigation/01-hello-home.spec.ts | 65 ---- .../navigation/02-reader-obsidian.spec.ts | 97 ----- .../navigation/03-toolbox-native-ai.spec.ts | 96 ----- .../navigation/04-llm-service.spec.ts | 95 ----- .../navigation/05-subject-training.spec.ts | 154 -------- tests/renderer/navigation/test-helpers.ts | 211 ----------- tests/renderer/simple.spec.ts | 40 -- tests/renderer/smoke.spec.ts | 60 --- tests/renderer/test-exe.spec.ts | 36 -- vitest.config.ts | 25 -- 28 files changed, 82 insertions(+), 2652 deletions(-) delete mode 100644 AGENTS.md delete mode 100644 playwright.config.ts delete mode 100644 tests/README.md delete mode 100644 tests/global-setup.ts delete mode 100644 tests/main/README.md delete mode 100644 tests/main/cmd/wsl.test.ts delete mode 100644 tests/main/exec-util.test.ts delete mode 100644 tests/main/ipc-data-type.test.ts delete mode 100644 tests/main/ipc-util.test.ts delete mode 100644 tests/main/util.test.ts delete mode 100644 tests/renderer/README.md delete mode 100644 tests/renderer/basic.spec.ts delete mode 100644 tests/renderer/navigation/00-all-navigation.spec.ts delete mode 100644 tests/renderer/navigation/01-hello-home.spec.ts delete mode 100644 tests/renderer/navigation/02-reader-obsidian.spec.ts delete mode 100644 tests/renderer/navigation/03-toolbox-native-ai.spec.ts delete mode 100644 tests/renderer/navigation/04-llm-service.spec.ts delete mode 100644 tests/renderer/navigation/05-subject-training.spec.ts delete mode 100644 tests/renderer/navigation/test-helpers.ts delete mode 100644 tests/renderer/simple.spec.ts delete mode 100644 tests/renderer/smoke.spec.ts delete mode 100644 tests/renderer/test-exe.spec.ts delete mode 100644 vitest.config.ts diff --git a/.gitignore b/.gitignore index cc5b569..eb2af0e 100644 --- a/.gitignore +++ b/.gitignore @@ -127,22 +127,3 @@ external-resources/local-ai-service/rts-service/flask.out external-resources/local-ai-service/rts-service/service-status.json external-resources/local-ai-service/rts-service/repo.zip external-resources/local-ai-service/rts-service/rtc-backend/ - -# Test artifacts -/tests-results/ -/playwright-report/ -/playwright/.cache/ -/blob-report/ -/coverage/ -*.lcov -.nyc_output - -# Vitest -vitest.config.ts.timestamp-* - -# Playwright trace and video -**/test-results/ -**/playwright/.auth/ -*.webm -*.png -!/tests/**/*.png # Keep reference screenshots if using visual regression diff --git a/AGENTS.md b/AGENTS.md deleted file mode 100644 index e779a82..0000000 --- a/AGENTS.md +++ /dev/null @@ -1,350 +0,0 @@ -# AI Learning Assistant Launcher (AI学习助手启动器) - -## Project Overview - -This is an **Electron-based desktop application** that serves as a launcher for the AI Learning Assistant. It automates the installation and configuration of complex AI model deployments, enabling users to run AI learning assistants in a completely offline environment without technical barriers. - -The application manages: -- WSL (Windows Subsystem for Linux) installation -- Podman virtual machine setup -- Container management for AI services -- Obsidian plugin integration -- LM Studio integration for local LLM management -- P2P content distribution via WebTorrent - -## Technology Stack - -### Core Framework -- **Electron 36.5.0** - Cross-platform desktop application framework -- **React 19.1.0** - Frontend UI library -- **TypeScript 4.5.4** - Primary programming language -- **Webpack 5** - Module bundling (via Electron Forge) - -### Build Tools -- **Electron Forge 7.8.1** - Application building and packaging -- **Webpack + TypeScript Loader** - Code compilation and bundling -- **ESLint + Prettier** - Code linting and formatting -- **Sass/SCSS** - CSS preprocessing - -### Key Dependencies -- **Ant Design 5.26.1** - UI component library -- **React Router DOM 7.6.2** - Client-side routing -- **LangChain** - LLM integration (@langchain/openai, @langchain/ollama, etc.) -- **Dockerode** - Docker/Podman container management -- **WebTorrent** - P2P content distribution -- **electron-log** - Application logging -- **isomorphic-git** - Git operations - -## Project Structure - -``` -ai-learning-assistant-launcher/ -├── src/ -│ ├── main/ # Main process (Node.js/Electron backend) -│ │ ├── index.ts # Application entry point -│ │ ├── preload.ts # Preload script for IPC exposure -│ │ ├── ipc-data-type.ts # IPC message type definitions -│ │ ├── ipc-util.ts # IPC utility functions -│ │ ├── menu.ts # Application menu configuration -│ │ ├── util.ts # Utility functions -│ │ ├── backup/ # Log backup service -│ │ ├── cmd/ # Command-line operations (WSL checks, etc.) -│ │ ├── configs/ # Configuration file management -│ │ ├── dlc/ # Downloadable Content management (WebTorrent) -│ │ ├── exec/ # Command execution utilities -│ │ ├── external-url/ # External URL handling -│ │ ├── git/ # Git operations -│ │ ├── joint-build/ # Joint build (共建计划) features -│ │ ├── launcher-update/ # Launcher self-update -│ │ ├── lm-studio/ # LM Studio integration -│ │ ├── local-service/ # Local AI services -│ │ │ ├── obsidian-voice-service/ # Voice service for Obsidian -│ │ │ └── rts-service/ # Real-time service -│ │ ├── logger/ # Logging infrastructure -│ │ ├── native-script/ # Native script execution -│ │ ├── native-training-service/ # Training service management -│ │ ├── obsidian-plugin/ # Obsidian plugin management -│ │ ├── pdf-convert/ # PDF conversion utilities -│ │ ├── podman-desktop/ # Podman/Docker container management -│ │ ├── terminal-log/ # Terminal logging -│ │ ├── training-service/ # Container-based training service -│ │ └── workspace/ # Workspace management -│ │ -│ └── renderer/ # Renderer process (React frontend) -│ ├── index.tsx # React entry point -│ ├── app.tsx # Main App component with routing -│ ├── app.css # Global styles -│ ├── containers/ # React container components (business logic) -│ │ ├── backup/ # Backup UI container -│ │ ├── container-logs/ # Container log viewer -│ │ ├── example-container/ -│ │ ├── obsidian-plugin/ -│ │ ├── terminal-log-screen/ -│ │ ├── torrent-progress/ # P2P download progress -│ │ ├── use-cmd/ # Command execution hooks -│ │ ├── use-configs/ # Configuration hooks -│ │ ├── use-docker/ # Docker/Podman hooks -│ │ ├── use-lm-studio/ # LM Studio hooks -│ │ ├── use-native-training-service-shortcut/ -│ │ ├── use-obsidian-voice-service/ -│ │ ├── use-rts-service/ -│ │ ├── use-training-service-shortcut/ -│ │ ├── use-vm/ # VM management hooks -│ │ └── use-workspace/ # Workspace hooks -│ │ -│ ├── pages/ # Page components (routes) -│ │ ├── hello/ # Home/welcome page -│ │ ├── ai-service/ # AI service toolbox -│ │ ├── native-ai-service/ # Native AI services -│ │ ├── lm-service/ # LM Studio management -│ │ ├── llm-api-config/ # LLM API configuration -│ │ ├── obsidian-app/ # Obsidian app settings -│ │ ├── obsidian-plugin/# Obsidian plugin settings -│ │ ├── asr-config/ # ASR configuration -│ │ ├── tts-config/ # TTS configuration -│ │ ├── voice-rtc-config/ # Voice RTC configuration -│ │ ├── pdf-config/ # PDF configuration -│ │ ├── pdf-convert/ # PDF conversion page -│ │ ├── joint-build/ # Joint build page -│ │ ├── p2p-test/ # P2P testing page -│ │ ├── workspace-manage/ # Workspace management -│ │ ├── example-page/ # Example/template page -│ │ └── welcome/ # Welcome modal -│ │ -│ ├── logger/ # Renderer-side logging -│ ├── web-utils.ts # Web utilities -│ └── preload.d.ts # TypeScript declarations for preload -│ -├── external-resources/ # External resources (not bundled) -│ ├── ai-assistant-backend/ # VM installers, container images, configs -│ ├── config/ # Launcher configuration files -│ ├── dlc/ # Downloadable content (via WebTorrent) -│ ├── local-ai-service/ # Local AI service binaries -│ ├── native-runtime/ # Native runtime (bun, uv) -│ ├── native-training/ # Training service files -│ ├── obsidian-plugins-template/ # Obsidian plugin templates -│ └── user-workspace/ # User workspace data -│ -├── icons/ # Application icons -├── locales/ # i18n locale files -├── resources/ # Static resources -├── forge.config.ts # Electron Forge configuration -├── webpack.main.config.ts # Main process webpack config -├── webpack.renderer.config.ts # Renderer process webpack config -├── webpack.rules.ts # Webpack loader rules -├── webpack.plugins.ts # Webpack plugins -└── tsconfig.json # TypeScript configuration -``` - -## Build and Development Commands - -### Installation -```bash -npm install -``` - -### Development -```bash -# Start the application in development mode -# Note: Must restart after modifying main process code -# Renderer code changes apply immediately (HMR) -npm run start -``` - -### Building -```bash -# Package without compression (for testing) -npm run package - -# Build distributable with all dependencies (~1.4GB) -npm run make - -# Build mini version without dependencies (~100MB) -npm run make-mini - -# Publish release -npm run publish -``` - -### Code Quality -```bash -# Lint code -npm run lint - -# Fix linting issues -npm run lint:fix -``` - -## Architecture Overview - -### Electron Process Model - -This application follows Electron's multi-process architecture: - -1. **Main Process** (`src/main/`) - - Runs in Node.js environment - - Has full access to OS APIs - - Manages application lifecycle, windows, system integration - - Executes commands, manages containers, handles file system operations - -2. **Renderer Process** (`src/renderer/`) - - Runs in Chromium browser environment - - React-based UI with restricted access to Node.js APIs - - Communicates with main process via IPC (Inter-Process Communication) - -3. **Preload Script** (`src/main/preload.ts`) - - Bridges main and renderer processes - - Exposes safe APIs via `contextBridge` - - Defines type-safe IPC channels - -### IPC Communication Pattern - -The application uses a structured IPC system defined in `src/main/ipc-data-type.ts`: - -**Message Types:** -- `ERROR` - Blocking error (stops UI loading states) -- `INFO` - Result info (stops UI loading states) -- `WARNING` - Non-blocking warning -- `DATA` - Data transmission -- `PROGRESS` - Progress updates -- `PROGRESS_ERROR` - Non-blocking error - -**Channels:** -- `docker` - Container operations -- `cmd` - Command execution -- `wsl` - WSL management -- `configs` - Configuration management -- `obsidian-plugin` - Plugin operations -- `lm-studio` - LM Studio integration -- `training-service` - Training service management -- `webtorrent` - P2P downloads -- `launcher-update` - Self-updates -- And more... - -### Module Conventions - -Each feature module in `src/main/` follows this structure: -``` -module-name/ -├── index.ts # Main implementation with init function -└── type-info.ts # Type definitions, action/service names -``` - -Example from `src/main/cmd/`: -```typescript -// type-info.ts -export type ActionName = 'isWSLInstalled'; -export type ServiceName = 'wsl'; - -// index.ts -export default function initCmd(ipcMain: IpcMain) { - ipcMain.on('cmd', async (event, action, serviceName, ...args) => { - // Handle actions... - }); -} -``` - -## Code Style Guidelines - -### TypeScript Conventions -- Target: ES6 -- Module: CommonJS -- Strict type checking disabled (`noImplicitAny: false`) -- JSX: `react-jsx` transform - -### ESLint Configuration -- Extends: ESLint recommended, TypeScript, Import, React, Prettier -- Single quotes for strings -- Prettier integration for formatting - -### Naming Conventions -- **Files**: kebab-case for multi-word files (e.g., `ipc-data-type.ts`) -- **Components**: PascalCase (e.g., `Hello.tsx`, `AiService.tsx`) -- **Functions/Variables**: camelCase -- **Types/Interfaces**: PascalCase with descriptive names -- **IPC Channels**: kebab-case or camelCase strings - -### CSS/SCSS -- Module CSS supported (`.module.scss`) -- Global styles in `app.css` -- Component-scoped styles in page/component folders - -## Key Features and Modules - -### 1. AI Service Management -- **Container-based**: Uses Podman/Docker to run AI services -- **Native services**: Direct execution of local AI binaries -- **Service types**: Training, TTS, ASR, Voice RTC, PDF processing - -### 2. Obsidian Integration -- Plugin template management -- Workspace management -- Vault synchronization -- Voice service integration - -### 3. P2P Content Distribution -- WebTorrent integration for DLC downloads -- Torrent file management in `external-resources/dlc/` -- Upload/download statistics tracking - -### 4. LM Studio Integration -- Local LLM server management -- Model discovery and configuration - -### 5. Joint Build (共建计划) -- Collaborative build system -- Folder selection and disk info -- Tray integration - -## Important Development Notes - -### Admin Privileges Required -**Windows**: First-time installation of AI tools requires administrator mode. -- Development: Run `npm run start` from an admin CMD -- Production: Users must run the app with admin privileges - -### Hot Reload Behavior -- **Main process changes**: Must restart the application -- **Renderer process changes**: Hot-reload applies immediately - -### Platform Compatibility -- Currently supports **Windows 10/11 x64** only -- Uses offline Podman installer (not compatible with other platforms) -- WSL2 is a prerequisite on Windows - -### External Resources -Content in `external-resources/` is: -- Copied during packaging (see `forge.config.ts` hooks) -- Not bundled by webpack -- Excluded from git (see `.gitignore`) -- DLC large files excluded from main package - -### Security -- Context isolation enabled -- Preload script exposes limited APIs via `contextBridge` -- Fuses plugin configures Electron security settings -- CSP (Content Security Policy) configured for WebTorrent - -## Environment Variables - -- `NODE_ENV` - `development` or `production` -- `MAKE_MINI` - Set to `true` for mini build (excludes large dependencies) -- `npm_package_version` - Injected at build time - -## Build-time Constants - -- `__COMMIT_HASH__` - Current Git commit hash -- `__NPM_PACKAGE_VERSION__` - Version from package.json - -## Troubleshooting - -### Common Issues -1. **WSL installation fails** - Ensure Windows is in admin mode -2. **Podman not found** - Check if external-resources were copied correctly -3. **Renderer not updating** - Main process changes require restart -4. **IPC errors** - Check type definitions in `type-info.ts` files - -### Logs -- Main process: `launcher.log`, `launcher.old.log` -- Chrome: `chrome.log` -- Updates: `launcher-update.log` diff --git a/README.md b/README.md index c6d376c..339feba 100644 --- a/README.md +++ b/README.md @@ -1,61 +1,83 @@ # AI学习助手启动器 +这个项目的作用是帮助用户更快的安装和使用AI学习助手。 -AI模型本地部署比较复杂,所以把这些安装配置过程写成了自动化程序放在了这个启动器里,以降低门槛实现在离线环境中使用AI学习助手。 +因为AI模型本地部署比较复杂,所以我们把这些安装配置过程写成了自动化程序放在了这个AI学习助手启动器里,让所有人能没有门槛的完全在离线环境中使用AI学习助手。 -## 与开发相关的命令 +## !!!特别注意!!! +开发时修改main代码后手动停止程序然再重启程序才能看到效果,修改renderer代码后不需要重启程序即可立即看到效果 +## 开发本项目基本命令 ```shell -# 安装依赖 +#安装依赖 npm install -# 启动 +#启动 npm run start -# 打包-不压缩包 +#打包-不压缩包 npm run package -# 打包-压缩包-包含依赖软件,最终包体积大小约为1.4GB +#打包-压缩包-包含依赖软件,最终包体积大小约为1.4GB npm run make -# 打包-压缩包-不包含依赖软件,最终包体积大约100MB +#打包-压缩包-不包含依赖软件,最终包体积大约100MB npm run make-mini ``` -## 项目结构 - +## 目录结构 ``` external-resources 存放要被启动器管理的其他程序的资源的位置 - |--ai-assistant-backend 虚拟机安装包,容器镜像,容器配置文件 - |--config 启动器配置文件 - |--obsidian-plugins-template obsidian插件代码 - |--user-workspace obsidian用户数据 -tests 测试 - |-- READMD.md 单元测试和集成测试文档 +|--ai-assistant-backend 虚拟机安装包,容器镜像,容器配置文件 +|--config 启动器配置文件 +|--obsidian-plugins-template obsidian插件代码 +|--user-workspace obsidian用户数据 src 启动器源码 - |--main 启动器跑在node部分的代码,作用和网站的后端相同,只是跑在客户的本机node中 - |--cmd 需要用命令行实现的业务代码 - |--configs 用于读取external-resources目录中的配置文件的代码 - |--exec 在用户机器上执行命令行的核心代码,最好不要动它 - |--git 提供git操作的工具代码 - |--lm-studio 操作LM Studio的代码 - |--logger 日志 - |--obsidian-plugin obsidian插件管理代码 - |--podman-desktop podman虚拟机的接口代码,从podman-desktop复制过来的的 - |--workspace 工作区操作代码 - |--renderer 前端代码 - |--containers 业务逻辑组件 - |--pages 页面代码 - |--ai-service 学习助手工具箱 - |--asr-config ASR设置页面 - |--example-page 示例代码 - |--lm-service LM Studio管理页 - |--obsidian-app Obsidian设置页面 - |--obsidian-plugin Obsidian插件设置页面 - |--tts-config TTS设置页面 - |--workspace-manage 工作区管理页面 +|--main 启动器跑在node部分的代码,作用和网站的后端相同,只是跑在客户的本机node中 + |--cmd 需要用命令行实现的业务代码 + |--configs 用于读取external-resources目录中的配置文件的代码 + |--exec 在用户机器上执行命令行的核心代码,最好不要动它 + |--git 提供git操作的工具代码 + |--lm-studio 操作LM Studio的代码 + |--logger 日志 + |--obsidian-plugin obsidian插件管理代码 + |--podman-desktop podman虚拟机的接口代码,从podman-desktop复制过来的的 + |--workspace 工作区操作代码 + +|--renderer 前端代码 + |--containers 业务逻辑组件 + |--pages 页面代码 + |--ai-service 学习助手工具箱 + |--asr-config ASR设置页面 + |--example-page 示例代码 + |--lm-service LM Studio管理页 + |--obsidian-app Obsidian设置页面 + |--obsidian-plugin Obsidian插件设置页面 + |--tts-config TTS设置页面 + |--workspace-manage 工作区管理页面 forge.config.ts 打包器配置文件 ``` -# 帮助 +## !!!注意!!! +### Windows中首次安装AI工具箱需要管理员模式 +开发者需要先在管理员模式的cmd中运行npm run start,分发的包需要用户在兼容性设置里设置为以管理员模式运行,因为sudo-prompt停止维护了,而wsl必须在管理员模式安装。 + +### 为了提升软件安装速度,podman的安装过程采用了离线安装包,所以目前只兼容Windows 10/11 X86_64的操作系统 -1. 开发时,修改`main`代码后手动停止程序然再重启程序才能看到效果,修改`renderer`代码后不需要重启程序即可立即看到效果。 +## 安装本地AI服务的流程 -2. (Windows)首次安装AI工具箱需要管理员模式 +|序号| 操作 | 时间 | +|-|------|-----| +|0|解压安装包 | 1分钟 | +|1|安装WSL系统组件 | 5分钟 | +|2|安装Podman程序 | 5分钟 | +|3|初始化Podman虚拟机 | 5分钟 | +|4|在Podman虚拟机内安装nvidia-container-toolkit | 1分钟 | +|5|导入镜像 | 5分钟 | +|6|创建容器 | 0.5分钟 | -开发者需要先在管理员模式的`cmd`中运行`npm run start`,分发的包需要用户在兼容性设置里设置为以管理员模式运行,因为`sudo-prompt`停止维护了,而`wsl`必须在管理员模式安装。 +### 用户操作的实际执行对应上表的过程 +|操作|经过的操作|最终状态| +|----|---------|---| +|安装WSL| 1 |1 2 之间| +|安装第一个服务|2 3 4 5 6| 6 之后| +|安装第二个服务|6| 6 之后| +|删除服务| 6 | 5 6 之间| +|删除了所有服务| 6 5 | 4 5 之间| +|删除了所有服务和缓存|6 3| 2 3 之间| +|删除了所有服务和缓存之后安装一个服务| 3 4 5 6| 6 之后| \ No newline at end of file diff --git a/package.json b/package.json index 447330f..68a4403 100644 --- a/package.json +++ b/package.json @@ -11,15 +11,7 @@ "make-mini": "cross-env NODE_ENV=production MAKE_MINI=true electron-forge make --target @electron-forge/maker-zip", "publish": "cross-env NODE_ENV=production electron-forge publish", "lint": "eslint --ext .ts,.tsx .", - "lint:fix": "eslint --fix --ext .ts,.tsx .", - "test": "npm run test:main && npm run test:renderer", - "test:main": "vitest run", - "test:main:watch": "vitest", - "test:main:coverage": "vitest run --coverage", - "test:renderer": "playwright test", - "test:renderer:ui": "playwright test --ui", - "test:renderer:debug": "playwright test --debug", - "test:install": "playwright install chromium" + "lint:fix": "eslint --fix --ext .ts,.tsx ." }, "keywords": [], "author": { @@ -38,7 +30,6 @@ "@electron-forge/plugin-fuses": "^7.8.1", "@electron-forge/plugin-webpack": "^7.8.1", "@electron/fuses": "^1.8.0", - "@playwright/test": "^1.58.2", "@stdlib/utils-convert-path": "^0.2.2", "@svgr/webpack": "^8.1.0", "@types/adm-zip": "^0.5.7", @@ -53,7 +44,6 @@ "@typescript-eslint/eslint-plugin": "^5.62.0", "@typescript-eslint/parser": "^5.62.0", "@vercel/webpack-asset-relocator-loader": "1.7.3", - "@vitest/coverage-v8": "^4.1.1", "@xterm/xterm": "^5.5.0", "antd": "^5.26.1", "copy-webpack-plugin": "^13.0.1", @@ -89,7 +79,6 @@ "ts-node": "^10.9.2", "typescript": "~4.5.4", "uuid": "^11.1.0", - "vitest": "^4.1.1", "webtorrent": "^2.8.5", "xterm-theme": "^1.1.0" }, diff --git a/playwright.config.ts b/playwright.config.ts deleted file mode 100644 index cdb45aa..0000000 --- a/playwright.config.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { defineConfig } from '@playwright/test'; -import { join } from 'path'; - -// Electron 可执行文件路径 (可选,Playwright 会自动查找 node_modules 中的 electron) -// const electronExecutable = join(__dirname, 'node_modules/electron/dist/electron.exe'); -// 应用入口路径(asar 归档中的主进程入口) -const appEntryPath = join(__dirname, 'out/AI-Learning-Assistant-Launcher-win32-x64/resources/app.asar/.webpack/main/index.js'); - -export default defineConfig({ - testDir: './tests/renderer', - fullyParallel: false, - forbidOnly: !!process.env.CI, - retries: 0, - workers: 1, - reporter: [['list'], ['html', { open: 'never' }]], - - use: { - trace: 'on-first-retry', - screenshot: 'only-on-failure', - video: 'retain-on-failure', - }, - - projects: [ - { - name: 'electron', - use: { - // executablePath: electronExecutable, // 让 Playwright 自动查找 Electron - launchOptions: { - args: [ - appEntryPath, - '--test-mode', - '--disable-gpu', - '--no-sandbox', - ], - env: { - ...process.env, - NODE_ENV: 'production', - }, - timeout: 20000, - }, - }, - }, - ], - - timeout: 60 * 1000, - expect: { - timeout: 15 * 1000, - }, - - globalSetup: './tests/global-setup.ts', -}); diff --git a/src/main/joint-build/index.ts b/src/main/joint-build/index.ts index 8f3a18a..cf606e0 100644 --- a/src/main/joint-build/index.ts +++ b/src/main/joint-build/index.ts @@ -204,18 +204,10 @@ function destroyTray(): void { // 设置窗口关闭行为 export function setupWindowCloseHandler(mainWindow: BrowserWindow): void { - const isTestMode = process.argv.includes('--test-mode') || process.env.TEST_MODE === 'true'; - - // 测试模式下不创建托盘,允许正常关闭 - if (!isTestMode) { - createTray(); - } + // 确保托盘已创建 + createTray(); mainWindow.on('close', (event) => { - // 测试模式下允许正常关闭 - if (isTestMode) { - return; - } // 始终最小化到托盘,除非是强制退出 if (!forceQuit) { event.preventDefault(); @@ -234,12 +226,7 @@ export function setForceQuit(value: boolean): void { forceQuit = value; } -// 获取托盘启用状态(始终启用托盘最小化,测试模式除外) +// 获取托盘启用状态(始终启用托盘最小化) export function isTrayEnabled(): boolean { - // 测试模式下禁用托盘,确保应用可以正常退出 - // 检测命令行参数 --test-mode 或环境变量 TEST_MODE - if (process.argv.includes('--test-mode') || process.env.TEST_MODE === 'true') { - return false; - } return true; } diff --git a/src/main/local-service/rts-service/index.ts b/src/main/local-service/rts-service/index.ts index 0479ca7..e314c7c 100644 --- a/src/main/local-service/rts-service/index.ts +++ b/src/main/local-service/rts-service/index.ts @@ -1,9 +1,6 @@ import { spawn, execFile } from 'child_process'; import { promisify } from 'util'; import path from 'path'; -import fs from 'fs'; -import net from 'net'; -const exec = promisify(execFile); import { IpcMain, BrowserWindow } from 'electron'; import { ipcHandle } from '../../ipc-util'; import { @@ -50,50 +47,6 @@ const psDir = path.join( 'rts-service', ); -// RTS服务配置(必须与PowerShell脚本保持一致) -// install.ps1 和 run.ps1 中的 $extractedDir = "rtc-backend" -const RTS_CONFIG = { - extractedDir: 'rtc-backend', - statusFile: 'service-status.json', - port: 8989, // 与 run.ps1 中的 $Port 保持一致 -}; - -// 检查端口是否被占用 -function checkPortInUse(port: number): Promise { - return new Promise((resolve) => { - const socket = new net.Socket(); - - socket.setTimeout(1000); - - socket.on('connect', () => { - socket.destroy(); - resolve(true); // 端口被占用 - }); - - socket.on('error', () => { - resolve(false); // 端口空闲 - }); - - socket.on('timeout', () => { - socket.destroy(); - resolve(false); - }); - - socket.connect(port, '127.0.0.1'); - }); -} - -// 检查进程是否存在 -function isProcessRunning(pid: number): boolean { - try { - // signal 0 不会真的杀死进程,只是测试进程是否存在 - process.kill(pid, 0); - return true; - } catch { - return false; - } -} - // Extract last non-empty line from stdout (filter out debug output) function extractStatus(stdout: string): string { const lines = stdout @@ -137,89 +90,27 @@ function parseProgressLine( } /* - 单次获取RTS服务状态(TypeScript实现,替代PowerShell脚本) + 单次获取RTS服务状态 */ export async function getRTSServiceStatus(): Promise { try { - const backendDir = path.join(psDir, RTS_CONFIG.extractedDir); - const statusFilePath = path.join(psDir, RTS_CONFIG.statusFile); - - // 1. 检查后端目录是否存在 - if (!fs.existsSync(backendDir)) { - const status = 'not_installed'; - if (status !== lastLoggedStatus) { - logInfo('RTS服务状态:', status); - lastLoggedStatus = status; - } - return status; - } - - // 2. 检查状态文件是否存在 - if (!fs.existsSync(statusFilePath)) { - const status = 'stopped'; - if (status !== lastLoggedStatus) { - logInfo('RTS服务状态:', status, '(文件不存在:', statusFilePath, ')'); - lastLoggedStatus = status; - } - return status; - } - - // 3. 读取并解析状态文件 - let statusData: { status?: string; pid?: number }; - try { - let fileContent = fs.readFileSync(statusFilePath, 'utf8'); - // 去除 BOM (Byte Order Mark) - PowerShell 写入的 UTF8 文件可能带有 BOM - fileContent = fileContent.replace(/^\uFEFF/, ''); - statusData = JSON.parse(fileContent); - } catch (e: any) { - logError('读取或解析状态文件失败:', e.message); - const status = 'error'; - if (status !== lastLoggedStatus) { - logInfo('RTS服务状态:', status); - lastLoggedStatus = status; - } - return status; - } - - // 4. 如果状态是 starting,检查端口是否已被占用 - if (statusData.status === 'starting') { - const isPortInUse = await checkPortInUse(RTS_CONFIG.port); - const status = isPortInUse ? 'running' : 'starting'; - if (status !== lastLoggedStatus) { - logInfo('RTS服务状态:', status); - lastLoggedStatus = status; - } - return status; - } - - // 5. 如果存在 PID,检查进程是否存在 - // 注意:PowerShell 中 pid 可能是 null,需要检查 - if (statusData.pid && typeof statusData.pid === 'number' && statusData.pid > 0) { - if (!isProcessRunning(statusData.pid)) { - const status = 'stopped'; - if (status !== lastLoggedStatus) { - logInfo('RTS服务状态:', status, '(PID', statusData.pid, '不存在)'); - lastLoggedStatus = status; - } - return status; - } - } - - // 6. 检查端口是否被占用 - const isPortInUse = await checkPortInUse(RTS_CONFIG.port); - if (isPortInUse) { - const status = 'running'; - if (status !== lastLoggedStatus) { - logInfo('RTS服务状态:', status); - lastLoggedStatus = status; - } - return status; - } + const { stdout } = await commandLine.exec( + 'powershell', + [ + '-ExecutionPolicy', + 'Bypass', + '-Command', + `cd "${psDir}"; .\\get-service-status.ps1`, + ], + { + encoding: 'utf8', + }, + ); - // 7. 返回状态文件中的状态或默认 stopped - const status = statusData.status || 'stopped'; + const status = extractStatus(stdout); + // 只在状态变化时打印日志 if (status !== lastLoggedStatus) { - logInfo('RTS服务状态:', status, '(来自文件)'); + logInfo('RTS服务状态:', status); lastLoggedStatus = status; } return status; diff --git a/tests/README.md b/tests/README.md deleted file mode 100644 index 19bf0db..0000000 --- a/tests/README.md +++ /dev/null @@ -1,245 +0,0 @@ -## 测试的办法和例子 - -**单元测试主进程** - Vitest(Running on Node.js 环境) - -> Vitest(类似Jest). - -**集成测试渲染进程**- Playwright(Electron E2E) - -### 测试工具与其依赖 - -```bash -# Vitest单元测试工具 和 覆盖率工具 -npm install -D vitest @vitest/coverage-v8 - -# 用户UI交互的集成测试工具playwright -npm install -D @playwright/test - -# 安装 Playwright 浏览器二进制文件 -npx playwright install chromium -``` - -## 使用示例 - -### 执行UI E2E测试 - -```bash -# 打包应用(必须)⚠️ 渲染进程测试使用打包后的应用 -npm run package - -# 只执行UI测试 -npm run test:renderer -``` - -### 执行主进程单元测试-主要面向后端模块 - -``` bash -# 仅运行主进程单元测试(无需构建) -npm run test:main - -# 主进程测试监视模式(开发时使用),这会持续执行单元测试保持逻辑回归 -npm run test:main:watch - -# 生成覆盖率报告 -npm run test:main:coverage -``` - -### 运行所有测试 - -```bash -# 运行所有测试 -npm test -``` - -### 测试相关的命令一览 - -```json -{ - "test": "npm run test:main && npm run test:renderer", - "test:main": "vitest run", - "test:main:watch": "vitest", - "test:main:coverage": "vitest run --coverage", - "test:renderer": "playwright test", - "test:renderer:ui": "playwright test --ui", - "test:renderer:debug": "playwright test --debug", - "test:install": "playwright install chromium" -} -``` - - - -## 和测试有关的配置文件 - - - -### 覆盖率阈值 - -在 `vitest.config.ts` 中添加: - -```typescript -coverage: { - thresholds: { - lines: 80, - functions: 80, - branches: 80, - statements: 80, - }, -} -``` - -### 测试代码的组织 - -``` -tests/ -├── main/ # 主进程单元测试(真实导入源代码) -│ ├── exec-util.test.ts # src/main/exec/util.ts 平台检测 -│ └── README.md # 主进程测试指南 -├── renderer/ # 渲染进程集成测试 -│ ├── smoke.spec.ts # 冒烟测试,只测试成功启动 -│ └── README.md # 渲染测试指南 -├── README.md # 本文件 -├── global-setup.ts # Playwright 全局设置 -├── vitest.config.ts # Vitest 配置(项目根目录) -└── playwright.config.ts # Playwright 配置(项目根目录) -``` - ---- - -#### 添加新主进程测试 - -在 `tests/main/` 创建 `.test.ts` 文件 - -#### 添加新的渲染进程测试 - -在 `tests/renderer/` 创建 `.spec.ts` 文件 - -##### 相关命令-使用Playwright编写测试 - -``` bash -# Playwright的UI模式,可视化的编写UI测试脚本 -npm run test:renderer:ui - -# Playwright和渲染进程同时启动,并且开启就是断点状态,相当于开启WebDevtools配合编写脚本 -npm run test:renderer:debug -``` - - - -## Help Q&A - -1. 主进程测试**模块未找到**: 检查 `vitest.config.ts` 中的别名配置 - -2. 渲染进程测试**窗口立即关闭**: - 1. 检查 `.webpack/main/index.js` 是否存在 - 2. 尝试运行 `npx electron .webpack/main/index.js` 手动启动看报错 -3. **"共建计划"弹窗阻塞退出进程测试**: 在测试前设置 localStorage 跳过欢迎弹窗,这是因为如果一个进程实例不被关闭就无法进行下一个测试用例 - -```typescript -await window.evaluate(() => { - localStorage.setItem('ai_learning_assistant_welcome_shown', 'true'); -}); -await window.reload(); -``` - -4. **环境变量**:测试和环境变量 `process.env`有关,单个测试用例修改后使用 `afterEach` 中恢复原本值来进行下一个测试用例。 - -5. **缓存机制和测试用例的情形**:使用 `import`的时候可能会发生缓存的现象,考虑下面模块的形式: - -```typescript -const createModuleWithConstant = (initialPlatform: string) => { - // 这相当于模块中的:const platform = os.platform(); - const platform = initialPlatform; - - return { - getPlatform: () => platform, - isWindows: () => platform === 'win32', - isMac: () => platform === 'darwin', - isLinux: () => platform === 'linux', - }; -} -``` - -这种类似工厂函数的引用每次都可以通过传递新的参数得到新的对象,但是某些时候我们会遇到下面的情况: - -```bash -export createModuleWithConstant("windows") -``` - -这种时候我们得到的就是值不会改变的对象,反复的`import` 得到的也是同一个对象,所以可以通过 `vi.resetModules()`刷新`import`的结果。 - -## 更多测试常见情形 - -### 测试纯函数 - -对于不依赖 electron 的纯函数,直接导入测试: - -```typescript -import { describe, it, expect } from 'vitest'; -import { myFunction } from '../../src/main/my-module'; - -describe('myFunction', () => { - it('应该正确执行', () => { - expect(myFunction('input')).toBe('output'); - }); -}); -``` - -### 测试依赖 electron 的模块 - -使用 `vi.mock()` 模拟 electron: - -```typescript -import { describe, it, expect, vi } from 'vitest'; - -vi.mock('electron', () => ({ - app: { getPath: vi.fn().mockReturnValue('/mock/path') }, -})); - -import { myFunction } from '../../src/main/my-module'; - -describe('myFunction', () => { - it('应该正确执行', () => { - // 测试代码 - }); -}); -``` - -### 测试依赖 Node.js 内置模块的函数 - -使用 `vi.mock()` 模拟内置模块: - -```typescript -import { vi } from 'vitest'; -import * as os from 'os'; - -vi.mock('os', () => ({ - platform: vi.fn().mockReturnValue('win32'), -})); - -// 测试代码 -``` - -## CI/CD 集成 - -> 没试过这个。 - -GitHub Actions 示例: - -```yaml -name: Test - -on: [push, pull_request] - -jobs: - test: - runs-on: windows-latest - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: - node-version: 20 - - run: npm ci - - run: npm run test:install - - run: npm test -``` - diff --git a/tests/global-setup.ts b/tests/global-setup.ts deleted file mode 100644 index c237ef2..0000000 --- a/tests/global-setup.ts +++ /dev/null @@ -1,27 +0,0 @@ -/** - * Playwright 全局设置 - * 检查打包应用资源是否存在 - */ -import { existsSync } from 'fs'; -import { join } from 'path'; - -async function globalSetup() { - // 主进程入口在 asar 归档中 - const asarPath = join( - process.cwd(), - 'out/AI-Learning-Assistant-Launcher-win32-x64/resources/app.asar' - ); - - if (!existsSync(asarPath)) { - console.error('\n❌ 错误: 未找到打包应用'); - console.error(`路径: ${asarPath}`); - console.error('\n请先运行以下命令构建应用:\n'); - console.error(' npm run package # 打包应用\n'); - throw new Error('应用未构建,无法运行测试'); - } - - console.log('✅ 检测到打包应用,准备运行测试...'); - console.log(` 应用: ${asarPath}\n`); -} - -export default globalSetup; diff --git a/tests/main/README.md b/tests/main/README.md deleted file mode 100644 index 30d140e..0000000 --- a/tests/main/README.md +++ /dev/null @@ -1,111 +0,0 @@ -# 主进程单元测试 - -本目录包含 Vitest 编写的单元测试,**真实导入** `src/main/` 中的源代码。 - -## 测试文件说明 - -| 文件 | 测试目标 | 说明 | -|------|----------|------| -| `util.test.ts` | `src/main/util.ts` | `wait`, `onlyAlphaNumericLine`, `resolveHtmlPath` | -| `ipc-data-type.test.ts` | `src/main/ipc-data-type.ts` | `MESSAGE_TYPE` 枚举, `MessageData` 类 | -| `ipc-util.test.ts` | `src/main/ipc-util.ts` | `ipcHandle` 函数 (mock electron) | -| `exec-util.test.ts` | `src/main/exec/util.ts` | 占位测试(模块依赖 electron) | -| `cmd/wsl.test.ts` | `src/main/cmd/is-wsl-install.ts` | WSL 版本解析逻辑 | - -## 运行测试 - -```bash -# 运行所有主进程测试 -npm run test:main - -# 运行单个测试文件 -npx vitest run tests/main/util.test.ts - -# 监视模式(开发时使用) -npm run test:main:watch - -# 生成覆盖率报告 -npm run test:main:coverage -``` - -## 编写新测试 - -### 1. 测试纯函数 - -对于不依赖 electron 的纯函数,直接导入测试: - -```typescript -import { describe, it, expect } from 'vitest'; -import { myFunction } from '../../src/main/my-module'; - -describe('myFunction', () => { - it('应该正确执行', () => { - expect(myFunction('input')).toBe('output'); - }); -}); -``` - -### 2. 测试依赖 electron 的模块 - -使用 `vi.mock()` 模拟 electron: - -```typescript -import { describe, it, expect, vi } from 'vitest'; - -vi.mock('electron', () => ({ - app: { getPath: vi.fn().mockReturnValue('/mock/path') }, -})); - -import { myFunction } from '../../src/main/my-module'; - -describe('myFunction', () => { - it('应该正确执行', () => { - // 测试代码 - }); -}); -``` - -### 3. 测试依赖 Node.js 内置模块的函数 - -使用 `vi.mock()` 模拟内置模块: - -```typescript -import { vi } from 'vitest'; -import * as os from 'os'; - -vi.mock('os', () => ({ - platform: vi.fn().mockReturnValue('win32'), -})); - -// 测试代码 -``` - -## 注意事项 - -1. **Electron 依赖**:许多主进程模块依赖 `electron`,需要使用 `vi.mock()` 模拟 -2. **模块缓存**:修改 mock 后可能需要使用 `vi.resetModules()` 重新导入 -3. **异步测试**:对于 async 函数,使用 `async/await` 或返回 Promise -4. **环境变量**:可以在测试中修改 `process.env`,但记得在 `afterEach` 中恢复 - -## 扩展测试 - -要添加新的测试文件: - -1. 在 `tests/main/` 目录下创建 `.test.ts` 文件 -2. 从 `src/main/` 导入要测试的函数 -3. 使用 `vi.mock()` 模拟必要的依赖 -4. 编写测试用例 - -示例: - -```typescript -// tests/main/my-feature.test.ts -import { describe, it, expect } from 'vitest'; -import { myPureFunction } from '../../src/main/my-feature'; - -describe('myPureFunction', () => { - it('应该正确处理输入', () => { - expect(myPureFunction('input')).toBe('expected output'); - }); -}); -``` diff --git a/tests/main/cmd/wsl.test.ts b/tests/main/cmd/wsl.test.ts deleted file mode 100644 index d138033..0000000 --- a/tests/main/cmd/wsl.test.ts +++ /dev/null @@ -1,85 +0,0 @@ -/** - * WSL 检测功能测试 - * 由于 is-wsl-install.ts 依赖 electron 的 Exec 类 - * 这里我们测试 WSL 版本解析逻辑 - */ -import { describe, it, expect } from 'vitest'; - -// 从 is-wsl-install.ts 提取的版本解析逻辑 -function parseWSLVersion(output: string): { version: string; kernel: string } | null { - const versionMatch = output.match(/WSL version:\s*([\d.]+)/); - const kernelMatch = output.match(/Kernel version:\s*([\d.]+)/); - - if (!versionMatch) return null; - - return { - version: versionMatch[1], - kernel: kernelMatch ? kernelMatch[1] : 'unknown', - }; -} - -function parseVTStatus(output: string): boolean { - if ( - output.indexOf('Virtualization Enabled In Firmware: No') >= 0 || - output.indexOf('固件中已启用虚拟化: 否') >= 0 - ) { - return false; - } - return true; -} - -describe('WSL 工具函数', () => { - describe('parseWSLVersion', () => { - it('应该正确解析 WSL 版本信息', () => { - const mockOutput = ` -WSL version: 2.0.14.0 -Kernel version: 5.15.133.1-1 -WSLg version: 1.0.59 -MSRDC version: 1.2.4677 -Windows version: 10.0.22631.3737 - `; - - const result = parseWSLVersion(mockOutput); - expect(result).toEqual({ - version: '2.0.14.0', - kernel: '5.15.133.1', - }); - }); - - it('应该在无版本信息时返回 null', () => { - const result = parseWSLVersion('Command not recognized'); - expect(result).toBeNull(); - }); - - it('应该处理缺少 kernel 版本的情况', () => { - const mockOutput = 'WSL version: 1.0.0'; - const result = parseWSLVersion(mockOutput); - expect(result).toEqual({ - version: '1.0.0', - kernel: 'unknown', - }); - }); - }); - - describe('parseVTStatus', () => { - it('应该检测虚拟化已启用', () => { - const output = 'Virtualization Enabled In Firmware: Yes'; - expect(parseVTStatus(output)).toBe(true); - }); - - it('应该检测虚拟化未启用(英文)', () => { - const output = 'Virtualization Enabled In Firmware: No'; - expect(parseVTStatus(output)).toBe(false); - }); - - it('应该检测虚拟化未启用(中文)', () => { - const output = '固件中已启用虚拟化: 否'; - expect(parseVTStatus(output)).toBe(false); - }); - - it('应该在没有明确信息时假设已启用', () => { - const output = 'Some other system info'; - expect(parseVTStatus(output)).toBe(true); - }); - }); -}); diff --git a/tests/main/exec-util.test.ts b/tests/main/exec-util.test.ts deleted file mode 100644 index e68610d..0000000 --- a/tests/main/exec-util.test.ts +++ /dev/null @@ -1,16 +0,0 @@ -/** - * Exec 工具函数测试 - * 平台检测函数测试 - * - * 注意:由于 exec/util.ts 在导入时就访问 electron app 模块, - * 此测试文件需要使用不同的方式。这里我们只测试可独立测试的部分。 - */ -import { describe, it, expect } from 'vitest'; - -describe('exec/util.ts 占位测试', () => { - it('平台检测函数需要在集成环境中测试', () => { - // exec/util.ts 依赖 electron app 模块 - // 实际的平台检测测试可以在集成测试中进行 - expect(true).toBe(true); - }); -}); diff --git a/tests/main/ipc-data-type.test.ts b/tests/main/ipc-data-type.test.ts deleted file mode 100644 index e861792..0000000 --- a/tests/main/ipc-data-type.test.ts +++ /dev/null @@ -1,63 +0,0 @@ -/** - * IPC 数据类型测试 - * 真实导入 src/main/ipc-data-type.ts 中的类型和类 - */ -import { describe, it, expect } from 'vitest'; -import { MESSAGE_TYPE, MessageData } from '../../src/main/ipc-data-type'; - -describe('ipc-data-type.ts', () => { - describe('MESSAGE_TYPE 枚举', () => { - it('应该包含所有消息类型', () => { - expect(MESSAGE_TYPE.ERROR).toBe('error'); - expect(MESSAGE_TYPE.INFO).toBe('info'); - expect(MESSAGE_TYPE.WARNING).toBe('warning'); - expect(MESSAGE_TYPE.DATA).toBe('data'); - expect(MESSAGE_TYPE.PROGRESS).toBe('progress'); - expect(MESSAGE_TYPE.PROGRESS_ERROR).toBe('progress_error'); - }); - - it('应该能通过值反向查找', () => { - const values = Object.values(MESSAGE_TYPE); - expect(values).toContain('error'); - expect(values).toContain('data'); - expect(values).toHaveLength(6); - }); - }); - - describe('MessageData 类', () => { - it('应该正确创建消息对象', () => { - const message = new MessageData('test-action', 'test-service', { foo: 'bar' }); - - expect(message.action).toBe('test-action'); - expect(message.service).toBe('test-service'); - expect(message.data).toEqual({ foo: 'bar' }); - }); - - it('应该正确序列化为字符串', () => { - const message = new MessageData('action', 'service', { key: 'value' }); - const str = message.toString(); - - expect(str).toBe('action,service,{"key":"value"}'); - }); - - it('应该处理复杂数据类型', () => { - const complexData = { - array: [1, 2, 3], - nested: { a: 1, b: 2 }, - null: null, - }; - const message = new MessageData('complex', 'service', complexData); - - expect(message.data).toEqual(complexData); - expect(message.toString()).toContain('complex'); - expect(message.toString()).toContain('service'); - }); - - it('应该处理字符串数据', () => { - const message = new MessageData('string-action', 'string-service', 'raw string'); - - expect(message.data).toBe('raw string'); - expect(message.toString()).toBe('string-action,string-service,"raw string"'); - }); - }); -}); diff --git a/tests/main/ipc-util.test.ts b/tests/main/ipc-util.test.ts deleted file mode 100644 index c5bcb5b..0000000 --- a/tests/main/ipc-util.test.ts +++ /dev/null @@ -1,84 +0,0 @@ -/** - * IPC 工具模块测试 - * 真实导入 src/main/ipc-util.ts 并 mock electron - */ -import { describe, it, expect, vi, beforeEach } from 'vitest'; - -// Mock electron 模块 -const mockIpcMain = { - handle: vi.fn(), -}; - -vi.mock('electron', () => ({ - IpcMain: vi.fn(), - ipcMain: mockIpcMain, -})); - -// 在 mock 之后导入被测函数 -import { ipcHandle } from '../../src/main/ipc-util'; - -describe('ipc-util.ts', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - describe('ipcHandle', () => { - it('应该注册 IPC 处理器', () => { - const listener = vi.fn().mockResolvedValue('success'); - - ipcHandle(mockIpcMain as any, 'test-channel', listener); - - expect(mockIpcMain.handle).toHaveBeenCalledWith('test-channel', expect.any(Function)); - }); - - it('处理器应该返回成功结果', async () => { - const listener = vi.fn().mockResolvedValue('test-result'); - - ipcHandle(mockIpcMain as any, 'test-channel', listener); - - // 获取注册的处理器函数 - const registeredHandler = mockIpcMain.handle.mock.calls[0][1]; - const result = await registeredHandler({}, 'arg1', 'arg2'); - - expect(result).toEqual({ result: 'test-result' }); - expect(listener).toHaveBeenCalledWith({}, 'arg1', 'arg2'); - }); - - it('处理器应该捕获错误并返回错误对象', async () => { - const error = new Error('test error'); - const listener = vi.fn().mockRejectedValue(error); - - ipcHandle(mockIpcMain as any, 'test-channel', listener); - - const registeredHandler = mockIpcMain.handle.mock.calls[0][1]; - const result = await registeredHandler({}); - - expect(result).toHaveProperty('error'); - expect(result.error).toBeInstanceOf(Error); - expect(result.error.message).toBe('test error'); - }); - - it('处理器应该处理非 Error 类型的异常', async () => { - const listener = vi.fn().mockRejectedValue('string error'); - - ipcHandle(mockIpcMain as any, 'test-channel', listener); - - const registeredHandler = mockIpcMain.handle.mock.calls[0][1]; - const result = await registeredHandler({}); - - expect(result).toHaveProperty('error'); - expect(result.error).toEqual({ message: 'string error' }); - }); - - it('处理器应该处理同步函数', async () => { - const listener = vi.fn().mockReturnValue('sync-result'); - - ipcHandle(mockIpcMain as any, 'test-channel', listener); - - const registeredHandler = mockIpcMain.handle.mock.calls[0][1]; - const result = await registeredHandler({}); - - expect(result).toEqual({ result: 'sync-result' }); - }); - }); -}); diff --git a/tests/main/util.test.ts b/tests/main/util.test.ts deleted file mode 100644 index 1ed4a22..0000000 --- a/tests/main/util.test.ts +++ /dev/null @@ -1,82 +0,0 @@ -/** - * 主进程工具函数单元测试 - * 真实导入 src/main/util.ts 中的函数 - */ -import { describe, it, expect } from 'vitest'; -import { wait, onlyAlphaNumericLine, resolveHtmlPath } from '../../src/main/util'; - -describe('util.ts 工具函数', () => { - describe('wait', () => { - it('应该在指定时间后 resolve', async () => { - const start = Date.now(); - await wait(100); - const elapsed = Date.now() - start; - expect(elapsed).toBeGreaterThanOrEqual(90); // 允许小误差 - expect(elapsed).toBeLessThan(200); - }); - - it('应该能等待 0ms', async () => { - const start = Date.now(); - await wait(0); - const elapsed = Date.now() - start; - expect(elapsed).toBeLessThan(50); - }); - }); - - describe('onlyAlphaNumericLine', () => { - it('应该过滤非字母数字字符', () => { - expect(onlyAlphaNumericLine('hello world!')).toBe('helloworld'); - expect(onlyAlphaNumericLine('test@#$%123')).toBe('test123'); - expect(onlyAlphaNumericLine('中文测试abc')).toBe('abc'); - }); - - it('应该保留路径分隔符和下划线', () => { - // 正则 [^a-zA-Z0-9_/\\] 保留字母数字、下划线、正斜杠、反斜杠 - expect(onlyAlphaNumericLine('/path/to/file')).toBe('/path/to/file'); - expect(onlyAlphaNumericLine('/path_with_underscore')).toBe('/path_with_underscore'); - expect(onlyAlphaNumericLine('file_name_123')).toBe('file_name_123'); - // 反斜杠和正斜杠 - expect(onlyAlphaNumericLine('path\\to\\file')).toBe('path\\to\\file'); - expect(onlyAlphaNumericLine('path/to/file')).toBe('path/to/file'); - }); - - it('应该处理空字符串', () => { - expect(onlyAlphaNumericLine('')).toBe(''); - }); - - it('应该处理纯特殊字符', () => { - expect(onlyAlphaNumericLine('!@#$%^&*()')).toBe(''); - }); - }); - - describe('resolveHtmlPath', () => { - const originalEnv = process.env.NODE_ENV; - const originalPort = process.env.PORT; - - afterEach(() => { - process.env.NODE_ENV = originalEnv; - process.env.PORT = originalPort; - }); - - it('生产环境应该返回 file:// 协议路径', () => { - process.env.NODE_ENV = 'production'; - const result = resolveHtmlPath('index.html'); - expect(result).toMatch(/^file:\/\//); - expect(result).toContain('index.html'); - }); - - it('开发环境应该返回 localhost URL', () => { - process.env.NODE_ENV = 'development'; - process.env.PORT = '3000'; - const result = resolveHtmlPath('index.html'); - expect(result).toMatch(/^http:\/\/localhost:3000\/index\.html$/); - }); - - it('开发环境应该使用默认端口 1212', () => { - process.env.NODE_ENV = 'development'; - delete process.env.PORT; - const result = resolveHtmlPath('index.html'); - expect(result).toContain(':1212'); - }); - }); -}); diff --git a/tests/renderer/README.md b/tests/renderer/README.md deleted file mode 100644 index 2b572b7..0000000 --- a/tests/renderer/README.md +++ /dev/null @@ -1,240 +0,0 @@ -# 渲染进程集成测试 - -目录包含使用 Playwright 编写的 Electron 应用 E2E/集成测试。 - -## 目录结构 - -> 如果你要编写后端的工具函数的测试用例,应该在上层目录的`main` - -``` -tests/renderer/ -├── smoke.spec.ts # 冒烟测试(最简启动测试) -├── basic.spec.ts # 基础功能测试(导航等) -└── README.md # 本文件 -``` - -## 常见的要处理的细节 - -> 虽然模拟的使用户的行为,然而仍然不免要考虑到启动器自己的许多弹窗等等的情况要处理,算是编写测试时的Dirty Work了,相当的Hack。 - -1. **处理欢迎弹窗**:始终在 `beforeEach` 中设置 localStorage 跳过弹窗 -2. **使用 force: true 关闭**:防止应用关闭时卡住 -3. **缩短超时**:配置合理的超时时间,避免长时间等待 -4. **串行执行**:Electron 测试建议 `workers: 1` 避免冲突 - -### 欢迎弹窗处理 - -应用首次启动会显示"加入共建计划"弹窗(`WelcomeModal`),会阻塞测试。 - -**解决方案**:在 `beforeEach` 中预设置 localStorage: - -```typescript -const window = await electronApp.firstWindow(); -await window.evaluate(() => { - localStorage.setItem('ai_learning_assistant_welcome_shown', 'true'); -}); -await window.reload(); // 刷新使设置生效 -``` - -参考 `basic.spec.ts` 的实现。 - -# 开始编写新测试 - -## 基础模板 - -```typescript -import { test, expect, _electron as electron } from '@playwright/test'; -import { join } from 'path'; - -let electronApp: Awaited>; - -test.beforeEach(async () => { - electronApp = await electron.launch({ - args: [ - join(__dirname, '../../.webpack/main/index.js'), - '--disable-gpu', - '--no-sandbox', - ], - env: { - ...process.env, - NODE_ENV: 'development', - }, - }); - - // 跳过欢迎弹窗 - const window = await electronApp.firstWindow(); - await window.evaluate(() => { - localStorage.setItem('ai_learning_assistant_welcome_shown', 'true'); - }); - await window.reload(); - await window.waitForLoadState('domcontentloaded'); -}); - -test.afterEach(async () => { - await electronApp.close({ force: true }); -}); - -test.describe('页面名称', () => { - test('测试描述', async () => { - const window = await electronApp.firstWindow(); - // 你的测试逻辑 - }); -}); -``` - -## 常用操作 - -```typescript -// 导航到特定页面 -await window.evaluate(() => { - window.location.hash = '#/your-page'; -}); - -// 等待元素 -await window.locator('.your-selector').waitFor(); - -// 点击元素 -await window.click('.your-button'); - -// 输入文本 -await window.fill('.your-input', 'text'); - -// 获取元素文本 -const text = await window.locator('.your-element').textContent(); - -// 截图 -await window.screenshot({ path: 'screenshot.png' }); - -// 执行 IPC 调用 -const result = await window.evaluate(async () => { - // 渲染进程代码 - return await window.electronAPI.yourMethod(); -}); -``` - -## 调试技巧 - -```typescript -// 启用可见窗口进行调试 -electronApp = await electron.launch({ - args: [join(__dirname, '../../.webpack/main/index.js')], - headless: false, // 显示窗口 - slowMo: 100, // 慢动作执行 -}); - -// 监听控制台日志 -window.on('console', (msg) => console.log(msg.text())); - -// 监听页面错误 -window.on('pageerror', (error) => console.error(error)); -``` - -## 导航到各个页面的脚本例子 - -navigation目录包含了一系列基础的 Playwright 测试脚本,用于验证从首页(hello)进入各个子功能页面的导航是否正常。 - -### ⚠️ 重要说明 - -由于 Playwright 1.58.2 与 Electron 36.5.0 存在兼容性问题,测试使用了 **CDP (Chrome DevTools Protocol)** 方式连接应用,而不是直接使用 `electron.launch`。 - -### 📁 测试文件列表 - -| 序号 | 脚本文件 | 测试目标 | 路由路径 | 功能描述 | -| :--: | ------------------------------ | -------- | -------------------- | -------------------------------- | -| 00 | `00-all-navigation.spec.ts` | 所有页面 | 全部 | 综合导航测试,按顺序测试所有页面 | -| 01 | `01-hello-home.spec.ts` | 首页 | `/hello` | 应用启动、四个功能入口存在性 | -| 02 | `02-reader-obsidian.spec.ts` | 阅读器 | `/obsidian-app` | Obsidian 管理页面导航 | -| 03 | `03-toolbox-native-ai.spec.ts` | 工具箱 | `/native-ai-service` | AI 工具箱及子页面(TTS/ASR/PDF) | -| 04 | `04-llm-service.spec.ts` | 大模型 | `/lm-service` | LM Studio 管理及 API 配置 | -| 05 | `05-subject-training.spec.ts` | 学科培训 | 首页内 | 学科培训卡片、按钮状态验证 | - -### 🚀 运行测试 - -```bash -# 运行所有导航测试 -npx playwright test tests/renderer/navigation/ - -# 运行单个测试文件 -npx playwright test tests/renderer/navigation/01-hello-home.spec.ts - -# 带 UI 界面运行 -npx playwright test tests/renderer/navigation/ --ui - -# 调试模式 -npx playwright test tests/renderer/navigation/02-reader-obsidian.spec.ts --debug - -# 只运行综合测试 -npx playwright test tests/renderer/navigation/00-all-navigation.spec.ts -``` - -### 📝 参考模板 - -#### 基础模板(用于创建新测试) - -```typescript -import { test, expect, chromium } from '@playwright/test'; -import { join } from 'path'; -import { spawn, ChildProcess } from 'child_process'; - -const exePath = join(__dirname, '../../../out/AI-Learning-Assistant-Launcher-win32-x64/AI-Learning-Assistant-Launcher.exe'); - -async function launchApp(): Promise<{ proc: ChildProcess; page: any; cleanup: () => Promise }> { - // 启动应用 - const proc = spawn(exePath, ['--test-mode', '--remote-debugging-port=9222'], { - env: { ...process.env, NODE_ENV: 'production' }, - }); - - // 等待应用启动 - await new Promise(resolve => setTimeout(resolve, 3000)); - - // 连接 CDP - const browser = await chromium.connectOverCDP('http://localhost:9222'); - const context = browser.contexts()[0]; - const page = context.pages()[0]; - - const cleanup = async () => { - await browser.close(); - proc.kill(); - }; - - return { proc, page, cleanup }; -} - -test('测试示例', async () => { - const { page, cleanup } = await launchApp(); - - try { - // 你的测试代码 - await expect(page.locator('body')).toBeAttached(); - } finally { - await cleanup(); - } -}); -``` - -### 🗺️ 路由对照表 - -根据 `src/renderer/app.tsx` 中的路由定义: - -| 路由 | 页面 | 说明 | -| -------------------- | ------------------------ | ------------------- | -| `/` | Hello (首页) | 默认首页 | -| `/hello` | Hello (首页) | 首页 | -| `/obsidian-app` | ObsidianApp (阅读器) | Obsidian 阅读器管理 | -| `/native-ai-service` | NativeAiService (工具箱) | AI 工具箱 | -| `/lm-service` | LMService (大模型) | LM Studio 管理 | -| `/ai-service` | AiService (AI服务) | 容器版 AI 服务 | -| `/TTS-config` | TTSConfig | 文字转语音配置 | -| `/ASR-config` | ASRConfig | 语音转文字配置 | -| `/PDF-config` | PdfConfig | PDF 配置 | -| `/pdf-convert` | PdfConvert | PDF 转换 | -| `/llm-api-config` | LLMConfig | LLM API 配置 | - -### 🛠️ 辅助工具 - -`test-helpers.ts` 提供了可复用的辅助函数: - -- `ROUTES` - 所有路由常量 -- `launchApp()` - 启动应用 -- `navigateTo()` - 导航到指定路由 -- `HOME_PAGE_SELECTORS` - 首页选择器 diff --git a/tests/renderer/basic.spec.ts b/tests/renderer/basic.spec.ts deleted file mode 100644 index 6f81f49..0000000 --- a/tests/renderer/basic.spec.ts +++ /dev/null @@ -1,65 +0,0 @@ -/** - * 基础功能测试 - 每个测试独立启动 - */ -import { test, expect, _electron as electron } from '@playwright/test'; -import { join } from 'path'; - -const appEntryPath = join(__dirname, '../../out/AI-Learning-Assistant-Launcher-win32-x64/resources/app.asar/.webpack/main/index.js'); - -async function launchApp() { - return await electron.launch({ - args: [ - appEntryPath, - '--test-mode', - '--disable-gpu', - '--no-sandbox', - ], - env: { - ...process.env, - NODE_ENV: 'production', - }, - }); -} - -async function closeApp(app: Awaited>) { - await app.close().catch(() => {}); -} - -test.describe('基础功能', () => { - test('应该显示主页面', async () => { - const app = await launchApp(); - - try { - const window = await app.firstWindow({ timeout: 20000 }); - await window.waitForLoadState('domcontentloaded', { timeout: 20000 }); - - const body = window.locator('body'); - await expect(body).toBeAttached({ timeout: 10000 }); - - const url = window.url(); - console.log(`✅ 当前页面: ${url}`); - } finally { - await closeApp(app); - } - }); - - test('应该能导航到 AI Service 页面', async () => { - const app = await launchApp(); - - try { - const window = await app.firstWindow({ timeout: 20000 }); - await window.waitForLoadState('domcontentloaded', { timeout: 20000 }); - - await window.evaluate(() => { - window.location.hash = '#/ai-service'; - }); - await window.waitForTimeout(300); - - const url = window.url(); - console.log(`✅ 导航成功: ${url}`); - expect(url).toContain('#/ai-service'); - } finally { - await closeApp(app); - } - }); -}); diff --git a/tests/renderer/navigation/00-all-navigation.spec.ts b/tests/renderer/navigation/00-all-navigation.spec.ts deleted file mode 100644 index 6b416bc..0000000 --- a/tests/renderer/navigation/00-all-navigation.spec.ts +++ /dev/null @@ -1,142 +0,0 @@ -/** - * 综合导航测试 - 所有主要页面 - * - * 这个测试文件按顺序测试所有主要功能页面的导航 - * 适合作为完整的冒烟测试运行 - * - * 测试顺序: - * 1. 首页 (Hello) - / - * 2. 阅读器 (Obsidian) - /obsidian-app - * 3. 工具箱 (AI Toolbox) - /native-ai-service - * 4. 大模型 (LLM Service) - /lm-service - * 5. 学科培训 (Subject Training) - 首页功能 - * - * ⚠️ 注意:使用 CDP 方式连接应用,因为 Playwright electron.launch 与 Electron 36.5.0 不兼容 - */ -import { test, expect } from '@playwright/test'; -import { launchApp, navigateTo, ROUTES, HOME_PAGE_SELECTORS } from './test-helpers'; - -test.describe('🧭 全页面导航综合测试', () => { - test('1️⃣ 首页应该正确加载', async () => { - const { page, cleanup } = await launchApp(); - - try { - // 验证 body 元素存在 - await expect(page.locator('body')).toBeAttached({ timeout: 10000 }); - - // 验证四个主要功能入口都存在(使用 feature-title 类精确匹配) - await expect(page.locator(HOME_PAGE_SELECTORS.READER_TITLE)).toBeVisible({ timeout: 5000 }); - await expect(page.locator(HOME_PAGE_SELECTORS.TOOLBOX_TITLE)).toBeVisible({ timeout: 5000 }); - await expect(page.locator(HOME_PAGE_SELECTORS.LLM_TITLE)).toBeVisible({ timeout: 5000 }); - await expect(page.locator(HOME_PAGE_SELECTORS.TRAINING_TITLE)).toBeVisible({ timeout: 5000 }); - - console.log('✅ 首页加载完成,四个功能入口均存在'); - } finally { - await cleanup(); - } - }); - - test('2️⃣ 应该能导航到阅读器页面', async () => { - const { page, cleanup } = await launchApp(); - - try { - await navigateTo(page, ROUTES.READER.path); - - const url = page.url(); - expect(url).toContain('#/obsidian-app'); - console.log('✅ 阅读器页面导航成功'); - } finally { - await cleanup(); - } - }); - - test('3️⃣ 应该能导航到工具箱页面', async () => { - const { page, cleanup } = await launchApp(); - - try { - await navigateTo(page, ROUTES.TOOLBOX.path); - - const url = page.url(); - expect(url).toContain('#/native-ai-service'); - console.log('✅ 工具箱页面导航成功'); - } finally { - await cleanup(); - } - }); - - test('4️⃣ 应该能导航到大模型页面', async () => { - const { page, cleanup } = await launchApp(); - - try { - await navigateTo(page, ROUTES.LLM_SERVICE.path); - - const url = page.url(); - expect(url).toContain('#/lm-service'); - console.log('✅ 大模型页面导航成功'); - } finally { - await cleanup(); - } - }); - - test('5️⃣ 应该能返回首页', async () => { - const { page, cleanup } = await launchApp(); - - try { - await navigateTo(page, ROUTES.HOME.path); - - const url = page.url(); - expect(url).toContain('#/hello'); - console.log('✅ 返回首页成功'); - } finally { - await cleanup(); - } - }); - - test('6️⃣ 首页学科培训功能应该可用', async () => { - const { page, cleanup } = await launchApp(); - - try { - // 确保在首页 - await navigateTo(page, ROUTES.HOME.path); - - // 验证学科培训卡片存在 - const trainingCard = page.locator(HOME_PAGE_SELECTORS.TRAINING_CARD); - await expect(trainingCard).toBeVisible({ timeout: 5000 }); - - // 验证有操作按钮 - const buttons = trainingCard.locator('button'); - const buttonCount = await buttons.count(); - expect(buttonCount).toBeGreaterThan(0); - - console.log(`✅ 学科培训功能验证完成,找到 ${buttonCount} 个按钮`); - } finally { - await cleanup(); - } - }); -}); - -test.describe('🔄 快速路由切换测试', () => { - test('应该能快速切换不同页面', async () => { - const { page, cleanup } = await launchApp(); - - try { - const routes = [ - ROUTES.HOME.path, - ROUTES.READER.path, - ROUTES.TOOLBOX.path, - ROUTES.LLM_SERVICE.path, - ROUTES.HOME.path, - ]; - - for (const route of routes) { - await navigateTo(page, route, 500); - const url = page.url(); - expect(url).toContain(route.replace('#', '')); - } - - console.log('✅ 快速路由切换测试通过'); - } finally { - await cleanup(); - } - }); -}); diff --git a/tests/renderer/navigation/01-hello-home.spec.ts b/tests/renderer/navigation/01-hello-home.spec.ts deleted file mode 100644 index 582df5d..0000000 --- a/tests/renderer/navigation/01-hello-home.spec.ts +++ /dev/null @@ -1,65 +0,0 @@ -/** - * 测试: 首页 (Hello Page) 基础加载测试 - * - * 目标页面: 首页 /hello - * 功能描述: 这是应用的首页,展示四个主要功能入口:阅读器、工具箱、大模型、学科培训 - * - * 验证内容: - * - 应用能正常启动 - * - 首页能正确加载 - * - 页面包含四个主要功能卡片 - * - * ⚠️ 注意:使用 CDP 方式连接应用 - */ -import { test, expect } from '@playwright/test'; -import { launchApp, HOME_PAGE_SELECTORS } from './test-helpers'; - -test.describe('首页 (Hello Page) 基础测试', () => { - test('应用应该能正常启动并显示窗口', async () => { - const { page, cleanup } = await launchApp(); - - try { - // 验证 body 元素存在 - await expect(page.locator('body')).toBeAttached({ timeout: 10000 }); - - // 获取并打印窗口标题 - const title = await page.title(); - console.log(`✅ 应用启动成功,页面标题: "${title}"`); - } finally { - await cleanup(); - } - }); - - test('首页应该包含四个主要功能入口', async () => { - const { page, cleanup } = await launchApp(); - - try { - // 验证页面包含四个功能标题(使用 .feature-title 类选择器精确匹配) - await expect(page.locator(HOME_PAGE_SELECTORS.READER_TITLE)).toBeVisible({ timeout: 5000 }); - await expect(page.locator(HOME_PAGE_SELECTORS.TOOLBOX_TITLE)).toBeVisible({ timeout: 5000 }); - await expect(page.locator(HOME_PAGE_SELECTORS.LLM_TITLE)).toBeVisible({ timeout: 5000 }); - await expect(page.locator(HOME_PAGE_SELECTORS.TRAINING_TITLE)).toBeVisible({ timeout: 5000 }); - - console.log('✅ 首页四个功能入口均已找到:阅读器、工具箱、大模型、学科培训'); - } finally { - await cleanup(); - } - }); - - test('首页 URL 应该正确', async () => { - const { page, cleanup } = await launchApp(); - - try { - // 等待页面加载完成 - await page.waitForTimeout(500); - - const url = page.url(); - console.log(`✅ 当前页面 URL: ${url}`); - - // 首页可能是 / 或 /hello 或 /#/hello - expect(url).toMatch(/\/(hello|index.html)?(#\/)?$/); - } finally { - await cleanup(); - } - }); -}); diff --git a/tests/renderer/navigation/02-reader-obsidian.spec.ts b/tests/renderer/navigation/02-reader-obsidian.spec.ts deleted file mode 100644 index 2255646..0000000 --- a/tests/renderer/navigation/02-reader-obsidian.spec.ts +++ /dev/null @@ -1,97 +0,0 @@ -/** - * 测试: 阅读器页面 (Obsidian Reader) 导航测试 - * - * 目标页面: Obsidian 阅读器管理页面 - * 路由路径: /obsidian-app - * 功能描述: - * - 管理 Obsidian 阅读器的仓库和插件 - * - 启动 Obsidian 应用 - * - 配置 Obsidian 插件 - * - * 验证内容: - * - 能从首页导航到阅读器页面 - * - 阅读器页面能正确加载 - * - URL 包含 /obsidian-app - * - * ⚠️ 注意:使用 CDP 方式连接应用 - */ -import { test, expect } from '@playwright/test'; -import { launchApp, navigateTo, ROUTES, HOME_PAGE_SELECTORS } from './test-helpers'; - -test.describe('阅读器 (Obsidian) 页面导航测试', () => { - test('应该能从首页导航到阅读器页面', async () => { - const { page, cleanup } = await launchApp(); - - try { - // 通过修改 location.hash 导航 - await navigateTo(page, ROUTES.READER.path); - - // 验证 URL 正确 - const url = page.url(); - console.log(`✅ 导航成功,当前 URL: ${url}`); - expect(url).toContain('#/obsidian-app'); - } finally { - await cleanup(); - } - }); - - test('阅读器页面应该包含关键元素', async () => { - const { page, cleanup } = await launchApp(); - - try { - // 导航到阅读器页面 - await navigateTo(page, ROUTES.READER.path); - - // 验证页面 body 存在 - await expect(page.locator('body')).toBeAttached({ timeout: 10000 }); - - // 等待页面内容加载(Obsidian 页面需要一些时间初始化) - await page.waitForTimeout(2000); - - // 验证页面包含 Obsidian 相关的文本(使用 text 选择器更可靠) - // 这些文本是 Obsidian 页面必定会显示的 - await expect(page.locator('text=Obsidian').first()).toBeVisible({ timeout: 5000 }); - - // 尝试验证"使用本地安装包"元素(如果 Obsidian 未安装则会显示) - // 使用更通用的选择器来查找这个文本 - const pageContent = await page.content(); - const hasLocalInstall = pageContent.includes('使用本地安装包'); - - if (hasLocalInstall) { - console.log('✅ 阅读器页面已加载,找到"使用本地安装包"选项'); - } else { - console.log('✅ 阅读器页面已加载("使用本地安装包"选项未显示,可能 Obsidian 已安装)'); - } - } finally { - await cleanup(); - } - }); - - test('应该能通过点击首页的"开始"按钮导航到阅读器页面', async () => { - const { page, cleanup } = await launchApp(); - - try { - // 先回到首页 - await navigateTo(page, ROUTES.HOME.path); - - // 找到"阅读器"卡片中的"开始"按钮并点击 - const readerCard = page.locator(HOME_PAGE_SELECTORS.READER_CARD); - const startButton = readerCard.locator('button:has-text("开始")'); - - // 如果按钮存在则点击(用于演示交互方式) - if (await startButton.isVisible().catch(() => false)) { - await startButton.click(); - await page.waitForTimeout(1000); - - const url = page.url(); - console.log(`✅ 点击后 URL: ${url}`); - expect(url).toContain('#/obsidian-app'); - } else { - console.log('⚠️ 开始按钮未找到,跳过点击测试'); - test.skip(); - } - } finally { - await cleanup(); - } - }); -}); diff --git a/tests/renderer/navigation/03-toolbox-native-ai.spec.ts b/tests/renderer/navigation/03-toolbox-native-ai.spec.ts deleted file mode 100644 index 8b155ea..0000000 --- a/tests/renderer/navigation/03-toolbox-native-ai.spec.ts +++ /dev/null @@ -1,96 +0,0 @@ -/** - * 测试: 工具箱页面 (AI Toolbox) 导航测试 - * - * 目标页面: AI 工具箱页面 - * 路由路径: /native-ai-service - * 功能描述: - * - 一站式管理多种实用 AI 工具 - * - 包含功能:文字转语音(TTS)、语音转文字(ASR)、PDF 转 Markdown - * - 让技术操作变得简单快捷 - * - * 验证内容: - * - 能从首页导航到工具箱页面 - * - 工具箱页面能正确加载 - * - URL 包含 /native-ai-service - * - * ⚠️ 注意:使用 CDP 方式连接应用 - */ -import { test, expect } from '@playwright/test'; -import { launchApp, navigateTo, ROUTES, HOME_PAGE_SELECTORS } from './test-helpers'; - -test.describe('工具箱 (AI Toolbox) 页面导航测试', () => { - test('应该能直接导航到工具箱页面', async () => { - const { page, cleanup } = await launchApp(); - - try { - // 通过修改 location.hash 导航 - await navigateTo(page, ROUTES.TOOLBOX.path); - - // 验证 URL 正确 - const url = page.url(); - console.log(`✅ 导航成功,当前 URL: ${url}`); - expect(url).toContain('#/native-ai-service'); - } finally { - await cleanup(); - } - }); - - test('工具箱页面应该加载成功', async () => { - const { page, cleanup } = await launchApp(); - - try { - // 导航到工具箱页面 - await navigateTo(page, ROUTES.TOOLBOX.path); - - // 验证页面 body 存在 - await expect(page.locator('body')).toBeAttached({ timeout: 5000 }); - - console.log('✅ 工具箱页面已加载'); - } finally { - await cleanup(); - } - }); - - test('应该能从首页通过"工具箱"入口导航', async () => { - const { page, cleanup } = await launchApp(); - - try { - // 先回到首页 - await navigateTo(page, ROUTES.HOME.path); - - // 验证首页有"工具箱"文字 - await expect(page.locator(HOME_PAGE_SELECTORS.TOOLBOX_TITLE)).toBeVisible({ timeout: 5000 }); - - // 找到"工具箱"卡片 - await expect(page.locator(HOME_PAGE_SELECTORS.TOOLBOX_CARD)).toBeVisible({ timeout: 5000 }); - - console.log('✅ 首页工具箱入口验证成功'); - } finally { - await cleanup(); - } - }); - - test('工具箱子功能页面导航测试', async () => { - const { page, cleanup } = await launchApp(); - - try { - // 工具箱包含以下子功能,测试能否导航到这些页面 - const subPages = [ - { route: ROUTES.TTS_CONFIG, name: '文字转语音(TTS)' }, - { route: ROUTES.ASR_CONFIG, name: '语音转文字(ASR)' }, - { route: ROUTES.PDF_CONFIG, name: 'PDF配置' }, - ]; - - for (const pageInfo of subPages) { - // 导航到子页面 - await navigateTo(page, pageInfo.route.path); - - const url = page.url(); - expect(url).toContain(pageInfo.route.path.replace('#', '')); - console.log(`✅ ${pageInfo.name} 页面导航成功: ${url}`); - } - } finally { - await cleanup(); - } - }); -}); diff --git a/tests/renderer/navigation/04-llm-service.spec.ts b/tests/renderer/navigation/04-llm-service.spec.ts deleted file mode 100644 index 6bd2bb0..0000000 --- a/tests/renderer/navigation/04-llm-service.spec.ts +++ /dev/null @@ -1,95 +0,0 @@ -/** - * 测试: 大模型页面 (LLM Service) 导航测试 - * - * 目标页面: LM Studio 大模型管理页面 - * 路由路径: /lm-service - * 功能描述: - * - 统一管理本地与在线 AI 模型的 API - * - 为 Obsidian Copilot 等应用设置密钥 - * - 省去繁琐的配置步骤 - * - * 验证内容: - * - 能从首页导航到大模型页面 - * - 大模型页面能正确加载 - * - URL 包含 /lm-service - * - * ⚠️ 注意:使用 CDP 方式连接应用 - */ -import { test, expect } from '@playwright/test'; -import { launchApp, navigateTo, ROUTES, HOME_PAGE_SELECTORS } from './test-helpers'; - -test.describe('大模型 (LLM Service) 页面导航测试', () => { - test('应该能直接导航到大模型页面', async () => { - const { page, cleanup } = await launchApp(); - - try { - // 通过修改 location.hash 导航 - await navigateTo(page, ROUTES.LLM_SERVICE.path); - - // 验证 URL 正确 - const url = page.url(); - console.log(`✅ 导航成功,当前 URL: ${url}`); - expect(url).toContain('#/lm-service'); - } finally { - await cleanup(); - } - }); - - test('大模型页面应该加载成功', async () => { - const { page, cleanup } = await launchApp(); - - try { - // 导航到大模型页面 - await navigateTo(page, ROUTES.LLM_SERVICE.path); - - // 验证页面 body 存在 - await expect(page.locator('body')).toBeAttached({ timeout: 5000 }); - - console.log('✅ 大模型页面已加载'); - } finally { - await cleanup(); - } - }); - - test('应该能从首页通过"大模型"入口导航', async () => { - const { page, cleanup } = await launchApp(); - - try { - // 先回到首页 - await navigateTo(page, ROUTES.HOME.path); - - // 验证首页有"大模型"标题(使用 .feature-title 类选择器避免匹配到欢迎弹窗的描述) - await expect(page.locator(HOME_PAGE_SELECTORS.LLM_TITLE)).toBeVisible({ timeout: 5000 }); - - // 找到"大模型"卡片 - await expect(page.locator(HOME_PAGE_SELECTORS.LLM_CARD)).toBeVisible({ timeout: 5000 }); - - console.log('✅ 首页大模型入口验证成功'); - } finally { - await cleanup(); - } - }); - - test('大模型相关配置页面导航测试', async () => { - const { page, cleanup } = await launchApp(); - - try { - // 大模型相关的配置页面 - const configPages = [ - { route: ROUTES.LLM_SERVICE, name: 'LM Studio 服务' }, - { route: ROUTES.LLM_CONFIG, name: 'LLM API 配置' }, - ]; - - for (const pageInfo of configPages) { - // 导航到配置页面 - await navigateTo(page, pageInfo.route.path); - - const url = page.url(); - expect(url).toContain(pageInfo.route.path.replace('#', '')); - console.log(`✅ ${pageInfo.name} 页面导航成功: ${url}`); - } - } finally { - await cleanup(); - } - }); -}); diff --git a/tests/renderer/navigation/05-subject-training.spec.ts b/tests/renderer/navigation/05-subject-training.spec.ts deleted file mode 100644 index d219e35..0000000 --- a/tests/renderer/navigation/05-subject-training.spec.ts +++ /dev/null @@ -1,154 +0,0 @@ -/** - * 测试: 学科培训页面 (Subject Training) 导航测试 - * - * 目标功能: 学科培训服务 - * 路由路径: 无独立页面,在首页显示终端日志 - * 功能描述: - * - AI 辅助的学科知识培训 - * - 学员建档设立目标 - * - 帮助补齐技能知识短板 - * - 版本管理和更新功能 - * - * 验证内容: - * - 首页包含学科培训入口 - * - "开始"按钮存在(用于启动培训服务) - * - 版本信息显示正确 - * - * 注意: 学科培训没有独立的页面路由,点击"开始"按钮后会在首页显示终端日志界面 - * - * ⚠️ 注意:使用 CDP 方式连接应用 - */ -import { test, expect } from '@playwright/test'; -import { launchApp, navigateTo, ROUTES, HOME_PAGE_SELECTORS } from './test-helpers'; - -test.describe('学科培训 (Subject Training) 功能测试', () => { - test('首页应该包含学科培训入口', async () => { - const { page, cleanup } = await launchApp(); - - try { - // 确保在首页 - await navigateTo(page, ROUTES.HOME.path); - - // 验证"学科培训"文字存在 - await expect(page.locator(HOME_PAGE_SELECTORS.TRAINING_TITLE)).toBeVisible({ timeout: 5000 }); - - console.log('✅ 首页学科培训入口已找到'); - } finally { - await cleanup(); - } - }); - - test('学科培训卡片应该包含功能描述', async () => { - const { page, cleanup } = await launchApp(); - - try { - // 确保在首页 - await navigateTo(page, ROUTES.HOME.path); - - // 找到"学科培训"卡片 - const trainingCard = page.locator(HOME_PAGE_SELECTORS.TRAINING_CARD); - - // 验证卡片存在 - await expect(trainingCard).toBeVisible({ timeout: 5000 }); - - // 验证描述文字存在(根据实际内容调整) - const description = trainingCard.locator('text=AI辅助'); - await expect(description).toBeVisible({ timeout: 5000 }).catch(() => { - console.log('⚠️ AI辅助描述文字未找到,可能页面结构不同'); - }); - - console.log('✅ 学科培训卡片验证成功'); - } finally { - await cleanup(); - } - }); - - test('学科培训应该包含操作按钮', async () => { - const { page, cleanup } = await launchApp(); - - try { - // 确保在首页 - await navigateTo(page, ROUTES.HOME.path); - - // 找到"学科培训"卡片 - const trainingCard = page.locator(HOME_PAGE_SELECTORS.TRAINING_CARD); - - // 验证卡片内有按钮(可能是"安装"、"开始"或"更新课程") - const buttons = trainingCard.locator('button'); - const buttonCount = await buttons.count(); - - console.log(`✅ 学科培训卡片中找到 ${buttonCount} 个按钮`); - expect(buttonCount).toBeGreaterThan(0); - - // 列出找到的按钮文字 - for (let i = 0; i < buttonCount; i++) { - const buttonText = await buttons.nth(i).textContent(); - console.log(` - 按钮 ${i + 1}: "${buttonText}"`); - } - } finally { - await cleanup(); - } - }); - - test('学科培训按钮状态应该正确显示', async () => { - const { page, cleanup } = await launchApp(); - - try { - // 确保在首页 - await navigateTo(page, ROUTES.HOME.path); - - // 找到"学科培训"卡片 - const trainingCard = page.locator(HOME_PAGE_SELECTORS.TRAINING_CARD); - - // 检查按钮文字(根据服务状态可能是"安装"、"开始"或"更新课程") - const possibleButtons = ['安装', '开始', '更新课程', '日志', '卸载']; - - for (const buttonText of possibleButtons) { - const button = trainingCard.locator(`button:has-text("${buttonText}")`); - const isVisible = await button.isVisible().catch(() => false); - - if (isVisible) { - console.log(`✅ 找到按钮: "${buttonText}"`); - } - } - } finally { - await cleanup(); - } - }); - - test('点击学科培训的"开始/安装"按钮应该显示终端日志', async () => { - const { page, cleanup } = await launchApp(); - - try { - // 确保在首页 - await navigateTo(page, ROUTES.HOME.path); - - // 找到"学科培训"卡片 - const trainingCard = page.locator(HOME_PAGE_SELECTORS.TRAINING_CARD); - - // 查找"开始"或"安装"按钮 - const startButton = trainingCard.locator('button:has-text("开始")'); - const installButton = trainingCard.locator('button:has-text("安装")'); - - // 优先点击"开始"按钮,如果不存在则点击"安装" - let buttonToClick = null; - if (await startButton.isVisible().catch(() => false)) { - buttonToClick = startButton; - console.log('找到"开始"按钮'); - } else if (await installButton.isVisible().catch(() => false)) { - buttonToClick = installButton; - console.log('找到"安装"按钮'); - } - - if (buttonToClick) { - // 注意:这里只是演示按钮存在,实际点击可能会触发服务启动 - // 在生产测试中可能需要 mock 相关服务调用 - console.log('✅ 学科培训启动按钮存在(跳过实际点击以避免启动服务)'); - } else { - console.log('⚠️ 未找到启动按钮,可能服务状态不同'); - } - } finally { - await cleanup(); - } - }); -}); diff --git a/tests/renderer/navigation/test-helpers.ts b/tests/renderer/navigation/test-helpers.ts deleted file mode 100644 index 3d6f651..0000000 --- a/tests/renderer/navigation/test-helpers.ts +++ /dev/null @@ -1,211 +0,0 @@ -/** - * 导航测试辅助工具函数 - * - * 这个文件包含了一些常用的辅助函数,可以在编写测试时复用 - */ - -import { Page, chromium } from '@playwright/test'; -import { join } from 'path'; -import { spawn, ChildProcess } from 'child_process'; - -/** - * 应用可执行文件路径 - */ -export const APP_EXE_PATH = join( - __dirname, - '../../../out/AI-Learning-Assistant-Launcher-win32-x64/AI-Learning-Assistant-Launcher.exe' -); - -/** - * 应用路由配置 - * 对应 src/renderer/app.tsx 中的路由定义 - */ -export const ROUTES = { - // 首页 - HOME: { path: '#/hello', name: '首页', description: '应用主页面' }, - - // 阅读器 - READER: { path: '#/obsidian-app', name: '阅读器', description: 'Obsidian 阅读器管理' }, - - // 工具箱 - TOOLBOX: { path: '#/native-ai-service', name: '工具箱', description: 'AI 工具箱(TTS/ASR/PDF)' }, - - // 大模型 - LLM_SERVICE: { path: '#/lm-service', name: '大模型服务', description: 'LM Studio 管理' }, - LLM_CONFIG: { path: '#/llm-api-config', name: 'LLM API配置', description: 'API 密钥配置' }, - - // AI 服务(容器版) - AI_SERVICE: { path: '#/ai-service', name: 'AI服务', description: '容器版 AI 服务' }, - - // 工具箱子页面 - TTS_CONFIG: { path: '#/TTS-config', name: 'TTS配置', description: '文字转语音' }, - ASR_CONFIG: { path: '#/ASR-config', name: 'ASR配置', description: '语音转文字' }, - PDF_CONFIG: { path: '#/PDF-config', name: 'PDF配置', description: 'PDF 转换配置' }, - PDF_CONVERT: { path: '#/pdf-convert', name: 'PDF转换', description: 'PDF 转 Markdown' }, - VOICE_RTC_CONFIG: { path: '#/VOICE-RTC-config', name: '语音RTC配置', description: '实时语音配置' }, - - // 工作区 - WORKSPACE_MANAGE: { path: '#/workspace-manage/default', name: '工作区管理', description: '工作区管理' }, - - // 插件 - OBSIDIAN_PLUGIN: { path: '#/obsidian-plugin/default', name: 'Obsidian插件', description: '插件管理' }, - - // 其他 - JOINT_BUILD: { path: '#/joint-build', name: '共建计划', description: 'P2P共建计划' }, - P2P_TEST: { path: '#/p2p-test', name: 'P2P测试', description: 'P2P测试页面' }, - EXAMPLE: { path: '#/example', name: '示例页面', description: '示例/模板页面' }, -} as const; - -/** - * 启动应用并返回 page 对象 - * @returns {Promise<{ proc: ChildProcess; page: Page; cleanup: () => Promise }>} - * - * 示例用法: - * ```typescript - * const { page, cleanup } = await launchApp(); - * try { - * // 你的测试代码 - * } finally { - * await cleanup(); - * } - * ``` - */ -export async function launchApp(): Promise<{ - proc: ChildProcess; - page: Page; - cleanup: () => Promise; -}> { - // 启动应用 - const proc = spawn(APP_EXE_PATH, ['--test-mode', '--remote-debugging-port=9222'], { - env: { ...process.env, NODE_ENV: 'production' }, - }); - - // 等待应用启动 - await new Promise((resolve) => setTimeout(resolve, 3000)); - - // 连接 CDP - const browser = await chromium.connectOverCDP('http://localhost:9222'); - const context = browser.contexts()[0]; - const page = context.pages()[0]; - - const cleanup = async () => { - await browser.close(); - proc.kill(); - }; - - return { proc, page, cleanup }; -} - -/** - * 导航到指定路由 - * @param page - Playwright Page 对象 - * @param route - 路由路径(如 '#/hello') - * @param waitTime - 等待时间(毫秒) - */ -export async function navigateTo( - page: Page, - route: string, - waitTime: number = 800 -): Promise { - await page.evaluate((r) => { - window.location.hash = r; - }, route); - await page.waitForTimeout(waitTime); -} - -/** - * 获取当前 URL - * @param page - Playwright Page 对象 - */ -export async function getCurrentUrl(page: Page): Promise { - return page.url(); -} - -/** - * 验证当前页面 URL 是否包含指定路径 - * @param page - Playwright Page 对象 - * @param expectedPath - 期望的路径 - */ -export async function verifyUrlContains( - page: Page, - expectedPath: string -): Promise { - const url = await getCurrentUrl(page); - return url.includes(expectedPath.replace('#', '')); -} - -/** - * 首页功能卡片选择器 - */ -export const HOME_PAGE_SELECTORS = { - // 功能卡片 - READER_CARD: '.feature-card:has-text("阅读器")', - TOOLBOX_CARD: '.feature-card:has-text("工具箱")', - LLM_CARD: '.feature-card:has-text("大模型")', - TRAINING_CARD: '.feature-card:has-text("学科培训")', - - // 功能标题 - READER_TITLE: '.feature-title:has-text("阅读器")', - TOOLBOX_TITLE: '.feature-title:has-text("工具箱")', - LLM_TITLE: '.feature-title:has-text("大模型")', - TRAINING_TITLE: '.feature-title:has-text("学科培训")', - - // 按钮 - START_BUTTON: 'button:has-text("开始")', - INSTALL_BUTTON: 'button:has-text("安装")', - UPDATE_BUTTON: 'button:has-text("更新课程")', - LOG_BUTTON: 'button:has-text("日志")', - UNINSTALL_BUTTON: 'button:has-text("卸载")', - - // 其他 - CAROUSEL: '.carousel-container', - FOOTER: '.hello-footer', -}; - -/** - * 打印测试分隔线 - * @param title - 标题 - */ -export function printSeparator(title: string): void { - console.log('\n' + '='.repeat(50)); - console.log(` ${title}`); - console.log('='.repeat(50)); -} - -/** - * 打印导航结果 - * @param routeName - 路由名称 - * @param url - 当前 URL - * @param success - 是否成功 - */ -export function printNavigationResult( - routeName: string, - url: string, - success: boolean -): void { - const status = success ? '✅' : '❌'; - console.log(`${status} ${routeName}: ${url}`); -} - -/** - * 示例:使用辅助函数的测试模板 - * - * ```typescript - * import { test, expect } from '@playwright/test'; - * import { launchApp, navigateTo, ROUTES } from './test-helpers'; - * - * test('示例测试', async () => { - * const { page, cleanup } = await launchApp(); - * try { - * // 导航到工具箱 - * await navigateTo(page, ROUTES.TOOLBOX.path); - * - * // 验证 URL - * const url = page.url(); - * expect(url).toContain('#/native-ai-service'); - * } finally { - * await cleanup(); - * } - * }); - * ``` - */ diff --git a/tests/renderer/simple.spec.ts b/tests/renderer/simple.spec.ts deleted file mode 100644 index 6016bb4..0000000 --- a/tests/renderer/simple.spec.ts +++ /dev/null @@ -1,40 +0,0 @@ -/** - * 极简测试 - 验证应用能启动 - */ -import { test, expect, _electron as electron } from '@playwright/test'; -import { join } from 'path'; - -// Electron 可执行文件路径 -const electronExecutable = join(__dirname, '../../node_modules/electron/dist/electron.exe'); -// 应用入口路径 -const appEntryPath = join(__dirname, '../../out/AI-Learning-Assistant-Launcher-win32-x64/resources/app.asar/.webpack/main/index.js'); - -test('应用应该能正常启动', async () => { - const electronApp = await electron.launch({ - // executablePath: electronExecutable, // 让 Playwright 自动查找 - args: [ - appEntryPath, - '--test-mode', - '--disable-gpu', - '--no-sandbox', - ], - env: { - ...process.env, - NODE_ENV: 'production', - }, - }); - - try { - const window = await electronApp.firstWindow({ timeout: 20000 }); - await window.waitForLoadState('domcontentloaded', { timeout: 20000 }); - - const body = window.locator('body'); - await expect(body).toBeAttached({ timeout: 10000 }); - - const url = window.url(); - console.log(`✅ 应用启动成功: ${url}`); - - } finally { - await electronApp.close().catch(() => {}); - } -}); diff --git a/tests/renderer/smoke.spec.ts b/tests/renderer/smoke.spec.ts deleted file mode 100644 index 687757a..0000000 --- a/tests/renderer/smoke.spec.ts +++ /dev/null @@ -1,60 +0,0 @@ -/** - * 冒烟测试 - 验证应用能启动和导航 - */ -import { test, expect, _electron as electron } from '@playwright/test'; -import { join } from 'path'; - -let electronApp: Awaited> | null = null; - -const appEntryPath = join(__dirname, '../../out/AI-Learning-Assistant-Launcher-win32-x64/resources/app.asar/.webpack/main/index.js'); - -test.beforeAll(async () => { - electronApp = await electron.launch({ - args: [ - appEntryPath, - '--test-mode', - '--disable-gpu', - '--no-sandbox', - ], - env: { - ...process.env, - NODE_ENV: 'production', - }, - }); - - const window = await electronApp.firstWindow({ timeout: 20000 }); - await window.waitForLoadState('domcontentloaded', { timeout: 20000 }); - await window.waitForTimeout(500); -}); - -test.afterAll(async () => { - if (electronApp) { - await electronApp.close().catch(() => {}); - } -}); - -test.describe('冒烟测试', () => { - test('应用应该能正常启动和显示窗口', async () => { - const window = await electronApp!.firstWindow(); - - const body = window.locator('body'); - await expect(body).toBeAttached({ timeout: 10000 }); - - const title = await window.title(); - console.log(`✅ 应用启动成功,窗口标题: "${title}"`); - }); - - test('应该能导航到 AI Service 页面', async () => { - const window = await electronApp!.firstWindow(); - - await window.evaluate(() => { - window.location.hash = '#/ai-service'; - }); - - await window.waitForTimeout(300); - - const url = window.url(); - console.log(`✅ 导航成功: ${url}`); - expect(url).toContain('#/ai-service'); - }); -}); diff --git a/tests/renderer/test-exe.spec.ts b/tests/renderer/test-exe.spec.ts deleted file mode 100644 index c76627f..0000000 --- a/tests/renderer/test-exe.spec.ts +++ /dev/null @@ -1,36 +0,0 @@ -/** - * 测试使用应用程序的可执行文件启动 - */ -import { test, expect } from '@playwright/test'; -import { join } from 'path'; -import { spawn } from 'child_process'; -import { chromium } from '@playwright/test'; - -test('测试使用应用程序 EXE 启动', async () => { - const exePath = join(__dirname, '../../out/AI-Learning-Assistant-Launcher-win32-x64/AI-Learning-Assistant-Launcher.exe'); - - console.log('启动应用程序:', exePath); - - // 启动应用程序 - const proc = spawn(exePath, ['--test-mode', '--remote-debugging-port=9222'], { - env: { ...process.env, NODE_ENV: 'production' }, - }); - - // 等待应用程序启动 - await new Promise(resolve => setTimeout(resolve, 5000)); - - try { - // 使用 Chromium 连接到远程调试端口 - const browser = await chromium.connectOverCDP('http://localhost:9222'); - const context = browser.contexts()[0]; - const page = context.pages()[0]; - - console.log('页面 URL:', page.url()); - await expect(page.locator('body')).toBeAttached({ timeout: 5000 }); - - await browser.close(); - console.log('✅ 测试通过!'); - } finally { - proc.kill(); - } -}); diff --git a/vitest.config.ts b/vitest.config.ts deleted file mode 100644 index 25c3df1..0000000 --- a/vitest.config.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { defineConfig } from 'vitest/config'; -import path from 'path'; - -export default defineConfig({ - test: { - name: 'main-process', - environment: 'node', - globals: true, - include: ['tests/main/**/*.test.ts'], - coverage: { - provider: 'v8', - reporter: ['text', 'json', 'html'], - include: ['src/main/**/*.ts'], - exclude: ['src/main/**/*.d.ts', 'src/main/preload.ts'], - }, - // 设置测试超时 - testTimeout: 10000, - }, - resolve: { - alias: { - '@main': path.resolve(__dirname, './src/main'), - '@renderer': path.resolve(__dirname, './src/renderer'), - }, - }, -});