diff --git a/.github/workflows/build-app.yml b/.github/workflows/build-app.yml index cdf1d2542..d0ff0af11 100644 --- a/.github/workflows/build-app.yml +++ b/.github/workflows/build-app.yml @@ -33,7 +33,7 @@ env: # latest:Runtime 有自己的发布节奏,本仓库的改动不应该在没有联调的情况下自动带出一个新 # Runtime。Runtime 不自更新,只随这里构建的安装包整体升级;本仓库如有依赖 Runtime 新行为 # 的改动(T13 系列),必须等 Runtime 一侧先发布对应版本,再手动把这个版本号提上去。 - RUNTIME_VERSION: v0.1.0 + RUNTIME_VERSION: v0.1.3 jobs: @@ -104,6 +104,25 @@ jobs: } shell: pwsh + - name: 校验 Runtime 后台更新协议 + shell: pwsh + run: | + $runtimeExe = Join-Path '${{ github.workspace }}' 'runtime/auto-mas-runtime.exe' + foreach ($probe in @( + @{ Arguments = @('workspace', 'stage', '--version', 'invalid') }, + @{ Arguments = @('bootstrap', '--version', 'invalid', '--if-needed') } + )) { + $probeArguments = $probe.Arguments + $output = @(& $runtimeExe --app-root $env:RUNNER_TEMP --output ndjson --protocol 1 @probeArguments) + $exitCode = $LASTEXITCODE + $result = $output | ForEach-Object { $_ | ConvertFrom-Json } | + Where-Object { $_.type -eq 'result' } | Select-Object -Last 1 + if ($exitCode -ne 2 -or -not $result -or $result.code -ne 'INVALID_VERSION') { + throw 'RUNTIME_VERSION 必须指向包含 T13.13 后台更新能力的已发布 Runtime。' + } + } + exit 0 + - name: 构建应用程序 env: SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} diff --git a/frontend/electron/ipc/initializationHandlers.ts b/frontend/electron/ipc/initializationHandlers.ts index b4a65c86a..58bf95551 100644 --- a/frontend/electron/ipc/initializationHandlers.ts +++ b/frontend/electron/ipc/initializationHandlers.ts @@ -521,6 +521,10 @@ export function registerInitializationHandlers(_mainWindow: BrowserWindow) { return backend.getStatus() }) + ipcMain.handle('check-runtime-backend-update', async () => { + return getBackendService().checkRuntimeBackendUpdate() + }) + // ==================== Runtime 链路的后端更新 ==================== // 标题栏更新入口走哪条链路由 `get-runtime-launch-mode` 决定(off 走原有下载安装包流程, diff --git a/frontend/electron/preload.ts b/frontend/electron/preload.ts index a70ebf271..317e18adb 100644 --- a/frontend/electron/preload.ts +++ b/frontend/electron/preload.ts @@ -259,6 +259,7 @@ contextBridge.exposeInMainWorld('electronAPI', { backendStop: () => ipcRenderer.invoke('backend-stop'), backendRestart: () => ipcRenderer.invoke('backend-restart'), backendStatus: () => ipcRenderer.invoke('backend-status'), + checkRuntimeBackendUpdate: () => ipcRenderer.invoke('check-runtime-backend-update'), // Runtime 链路的后端更新(启动模式复用上面的 getRuntimeLaunchMode) updateBackendViaRuntime: (targetVersion: string) => diff --git a/frontend/electron/services/backendService.test.ts b/frontend/electron/services/backendService.test.ts index 18f4815ea..63f4ec96b 100644 --- a/frontend/electron/services/backendService.test.ts +++ b/frontend/electron/services/backendService.test.ts @@ -12,7 +12,7 @@ import { RUNTIME_EXE_ENV, RUNTIME_MODE_ENV, RuntimeClient } from './runtime' vi.mock('child_process', () => ({ spawn: vi.fn() })) // resolveRuntimeLaunchMode 的构建默认值这一级要读 app.isPackaged;本文件全部用例都显式 // 设置 RUNTIME_MODE_ENV 走环境变量这一级,isPackaged 固定 false 即可,不需要逐用例切换。 -vi.mock('electron', () => ({ app: { isPackaged: false } })) +vi.mock('electron', () => ({ app: { isPackaged: false, getVersion: () => 'v5.5.0-beta.3' } })) vi.mock('../utils/processManager', () => ({ killAllRelatedProcesses: vi.fn(async () => undefined), })) @@ -225,6 +225,7 @@ beforeEach(() => { }) afterEach(() => { + vi.restoreAllMocks() vi.unstubAllGlobals() delete process.env[RUNTIME_MODE_ENV] delete process.env[RUNTIME_EXE_ENV] @@ -690,18 +691,28 @@ describe('development 模式', () => { }) describe('managed 模式', () => { - it('不传 --repo、不先跑 environment ensure,--app-root 就是用户数据根', async () => { + it('启动前按当前后端版本 bootstrap,完成后才 supervise', async () => { process.env[RUNTIME_MODE_ENV] = 'managed' process.env[RUNTIME_EXE_ENV] = EXISTING_EXE const service = createService() mockSpawn() + const run = vi + .spyOn(RuntimeClient.prototype, 'run') + .mockResolvedValueOnce({ + success: true, + result: { details: { healthy: true, version: 'v10.0.0' } }, + } as never) + .mockResolvedValueOnce({ success: true } as never) const pending = service.startBackend() - // managed 的 bootstrap 已包含 uv 准备,第一次 spawn 直接就是 supervise。 const child = await waitForSpawn() child.stdout.feed(helloLine + runningStateLine) expect(await pending).toEqual({ success: true }) + expect(run.mock.calls.map(call => call[0])).toEqual([ + ['workspace', 'check'], + ['bootstrap', '--version', 'v10.0.0', '--if-needed'], + ]) expect(spawnMock).toHaveBeenCalledTimes(1) expect(spawnedArgs().slice(0, 2)).toEqual(['--app-root', appRoot]) expect(spawnedArgs().slice(-4)).toEqual(['backend', 'supervise', '--mode', 'managed']) @@ -712,4 +723,109 @@ describe('managed 模式', () => { child.stdout.feed(stoppedResultLine) child.close(0) }) + + it('启动准备失败时不启动后端', async () => { + process.env[RUNTIME_MODE_ENV] = 'managed' + process.env[RUNTIME_EXE_ENV] = EXISTING_EXE + const run = vi + .spyOn(RuntimeClient.prototype, 'run') + .mockResolvedValueOnce({ + success: true, + result: { details: { healthy: true, version: 'v10.0.0' } }, + } as never) + .mockRejectedValueOnce(new Error('dependency failure')) + const result = await createService().startBackend() + expect(result.success).toBe(false) + expect(result.error).toContain('dependency failure') + expect(run).toHaveBeenCalledTimes(2) + expect(spawnMock).not.toHaveBeenCalled() + }) +}) + +describe('后台同分支更新', () => { + beforeEach(() => { + process.env[RUNTIME_MODE_ENV] = 'managed' + process.env[RUNTIME_EXE_ENV] = EXISTING_EXE + }) + + it.each([ + { success: false, code: 'MUTATION_IN_PROGRESS', staged: false }, + { success: true, code: 'OK', staged: false }, + { success: true, code: 'OK', staged: true }, + ])('只按 stage 成功结果通知就绪:$success/$staged', async expected => { + const run = vi + .spyOn(RuntimeClient.prototype, 'run') + .mockResolvedValueOnce({ + success: true, + result: { + details: { + version: 'v5.5.0-beta.3', + commit: 'old', + remoteCommit: 'new', + updateAvailable: true, + }, + }, + } as never) + .mockResolvedValueOnce({ + success: expected.success, + code: expected.code, + result: { message: 'test', details: { staged: expected.staged, commit: 'prepared' } }, + } as never) + const result = await createService().checkRuntimeBackendUpdate() + expect(result.staged).toBe(expected.staged) + expect(run).toHaveBeenCalledTimes(2) + if (expected.staged) expect(result.remoteCommit).toBe('prepared') + }) + + it('重复检查共享一次在途操作,准备完成后不重复下载', async () => { + let resolveCheck!: (value: never) => void + const run = vi + .spyOn(RuntimeClient.prototype, 'run') + .mockImplementationOnce( + () => + new Promise(resolve => { + resolveCheck = resolve + }) + ) + .mockResolvedValueOnce({ + success: true, + result: { details: { staged: true, commit: 'prepared' } }, + } as never) + const service = createService() + const first = service.checkRuntimeBackendUpdate() + const second = service.checkRuntimeBackendUpdate() + expect(first).toBe(second) + resolveCheck({ + success: true, + result: { details: { version: 'v5.5.0-beta.3', updateAvailable: true } }, + } as never) + await first + expect((await service.checkRuntimeBackendUpdate()).staged).toBe(true) + expect(run).toHaveBeenCalledTimes(2) + }) + + it('关闭时取消检查并禁止启动下一段下载', async () => { + let resolveCheck!: (value: never) => void + const cancel = vi.fn(() => { + resolveCheck({ + success: false, + code: 'OPERATION_CANCELLED', + result: { message: 'cancelled', details: {} }, + } as never) + return 'cancel-id' + }) + const run = vi.spyOn(RuntimeClient.prototype, 'run').mockImplementationOnce( + (_args, options) => + new Promise(resolve => { + resolveCheck = resolve + options?.onStarted?.({ cancel, kill: vi.fn(), sendControl: vi.fn(), pid: 1 }) + }) + ) + const service = createService() + const check = service.checkRuntimeBackendUpdate() + expect(await service.stopBackend()).toEqual({ success: true }) + expect((await check).staged).not.toBe(true) + expect(cancel).toHaveBeenCalledOnce() + expect(run).toHaveBeenCalledOnce() + }) }) diff --git a/frontend/electron/services/backendService.ts b/frontend/electron/services/backendService.ts index db3ec225c..421530a92 100644 --- a/frontend/electron/services/backendService.ts +++ b/frontend/electron/services/backendService.ts @@ -17,6 +17,7 @@ import { RuntimeClient, RuntimeRemediation, RuntimeRunResult, + RuntimeRunControl, RuntimeSuperviseHandle, RuntimeSupervisedLaunchConfig, createRuntimeClient, @@ -26,6 +27,7 @@ import { resolveRuntimeLaunchConfig, resolveRuntimeLaunchMode, } from './runtime' +import { resolveRuntimeTargetVersion } from './runtimeInitializationService' import { getLogger } from './logger' import { observeMainOperation, recordMainCount, recordMainDuration } from './sentry' @@ -98,6 +100,14 @@ export interface BackendStopResult { error?: string } +export interface RuntimeBackendUpdateCheck { + updateAvailable: boolean + staged?: boolean + currentCommit?: string + remoteCommit?: string + error?: string +} + export type BackendStatusCallback = (status: BackendStatus) => void // ==================== 后端服务管理类 ==================== @@ -123,6 +133,11 @@ export class BackendService { // Runtime 监督链路的句柄与就绪地址;旧链路下始终为 null。 private runtimeHandle: RuntimeSuperviseHandle | null = null private runtimeBaseUrl: string | null = null + private runtimeStopping = false + private runtimeUpdateFlight: Promise | null = null + private runtimeUpdateReady: RuntimeBackendUpdateCheck | null = null + private readonly runtimeCommands = new Set() + private readonly runtimeRuns = new Set>() private readonly startupHealthPath = '/api/core/health' @@ -305,8 +320,8 @@ export class BackendService { * 3. 后端地址取事件里的 `details.baseUrl`,不按 `resolveHttpPort()` 自行拼装。 * * `development` 模式在 supervise 之前先跑一次 `environment ensure`:`backend supervise` 本身 - * 不下载 uv,Runtime 根目录没种过 uv 时会直接以 `UV_EXEC_FAILED` 失败;`managed` 模式的 - * `bootstrap` 已包含这一步,不重复。 + * 不下载 uv,Runtime 根目录没种过 uv 时会直接以 `UV_EXEC_FAILED` 失败。managed 每次启动 + * 先 bootstrap,消费已暂存的同分支更新,并在新进程启动前同步依赖。 */ private async startBackendViaRuntime( config: RuntimeSupervisedLaunchConfig, @@ -316,6 +331,10 @@ export class BackendService { logger.info('Runtime 已在监督后端,跳过重复启动') return { success: true } } + if (this.stopFlight || this.forceStopRequested) { + return { success: false, error: '正在关闭后端,取消启动' } + } + this.runtimeStopping = false const runtimePath = config.runtimePath if (!runtimePath) { @@ -349,6 +368,29 @@ export class BackendService { if (config.mode === 'development') { const failure = await this.ensureDevelopmentRuntimeEnvironment(client, config) if (failure) return failure + } else { + try { + const current = await this.runRuntimePreparation(client, ['workspace', 'check']) + if (!current.success) return this.buildRuntimeStartFailure(current, [], []) + const details = current.result.details + const version = + details.healthy === true && typeof details.version === 'string' + ? details.version + : resolveRuntimeTargetVersion() + const prepared = await this.runRuntimePreparation(client, [ + 'bootstrap', + '--version', + version, + '--if-needed', + ]) + if (!prepared.success) return this.buildRuntimeStartFailure(prepared, [], []) + this.runtimeUpdateReady = null + } catch (error) { + return this.buildRuntimeStartFailure(error, [], []) + } + } + if (this.runtimeStopping) { + return { success: false, error: '正在关闭后端,取消启动' } } // 后端 stdout / stderr 由 Runtime 逐行包装成 log 事件转发,这里按流分开累积, @@ -755,7 +797,11 @@ export class BackendService { */ stopBackend(): Promise { if (this.stopFlight) return this.stopFlight - const operation = this.enqueueOperation(() => this.stopBackendInternal()) + const cancelled = this.cancelRuntimePreparations() + const operation = this.enqueueOperation(async () => { + await cancelled + return this.stopBackendInternal() + }) this.stopFlight = operation void operation.then( () => { @@ -769,6 +815,7 @@ export class BackendService { } private async stopBackendInternal(): Promise { + await this.cancelRuntimePreparations() if (this.isRuntimeSupervised()) { return this.stopBackendViaRuntime() } @@ -939,7 +986,9 @@ export class BackendService { forceStopBackend(): Promise { this.forceStopRequested = true if (this.forceStopFlight) return this.forceStopFlight + const cancelled = this.cancelRuntimePreparations() const operation = this.enqueueOperation(async () => { + await cancelled logger.warn('强制结束后端相关进程') try { await killAllRelatedProcesses(this.appRoot) @@ -1028,6 +1077,137 @@ export class BackendService { } } + /** 检查 managed 受管仓库的同分支远端 Commit,并在后台准备可供下次启动启用的更新。 */ + checkRuntimeBackendUpdate(): Promise { + if (this.runtimeStopping || this.startFlight) return Promise.resolve({ updateAvailable: false }) + if (this.runtimeUpdateReady) return Promise.resolve(this.runtimeUpdateReady) + if (this.runtimeUpdateFlight) return this.runtimeUpdateFlight + const operation = this.prepareRuntimeBackendUpdate() + .then(result => { + if (result.staged) this.runtimeUpdateReady = result + if (result.error) logger.debug(`后台后端更新未完成: ${result.error}`) + return result + }) + .finally(() => { + this.runtimeUpdateFlight = null + }) + this.runtimeUpdateFlight = operation + return operation + } + + private async prepareRuntimeBackendUpdate(): Promise { + const config = resolveRuntimeLaunchConfig(this.appRoot) + if (config.mode !== 'managed' || !config.runtimePath) return { updateAvailable: false } + try { + const client = createRuntimeClient({ + runtimePath: config.runtimePath, + appRoot: config.appRoot, + dataRoot: config.dataRoot, + launchMode: config.mode, + }) + const outcome = await this.runRuntimePreparation(client, ['workspace', 'check', '--remote']) + if (!outcome.success) { + return { updateAvailable: false, error: `${outcome.code}: ${outcome.result.message}` } + } + const details = outcome.result.details + const updateAvailable = details.updateAvailable === true + if (updateAvailable) { + const version = typeof details.version === 'string' ? details.version : undefined + if (!version) { + return { + updateAvailable: true, + staged: false, + currentCommit: typeof details.commit === 'string' ? details.commit : undefined, + remoteCommit: + typeof details.remoteCommit === 'string' ? details.remoteCommit : undefined, + error: 'Runtime 远端检查未返回当前版本', + } + } + const stageOutcome = await this.runRuntimePreparation(client, [ + 'workspace', + 'stage', + '--version', + version, + ]) + if (!stageOutcome.success) { + return { + updateAvailable: true, + staged: false, + currentCommit: typeof details.commit === 'string' ? details.commit : undefined, + remoteCommit: + typeof details.remoteCommit === 'string' ? details.remoteCommit : undefined, + error: `${stageOutcome.code}: ${stageOutcome.result.message}`, + } + } + return { + updateAvailable, + staged: stageOutcome.result.details.staged === true, + currentCommit: typeof details.commit === 'string' ? details.commit : undefined, + remoteCommit: + typeof stageOutcome.result.details.commit === 'string' + ? stageOutcome.result.details.commit + : undefined, + } + } + return { + updateAvailable, + staged: false, + currentCommit: typeof details.commit === 'string' ? details.commit : undefined, + remoteCommit: typeof details.remoteCommit === 'string' ? details.remoteCommit : undefined, + } + } catch (error) { + return { + updateAvailable: false, + error: error instanceof Error ? error.message : String(error), + } + } + } + + /** 跟踪一次性准备命令,使退出时的取消覆盖握手和下载两个阶段。 */ + private async runRuntimePreparation( + client: RuntimeClient, + args: string[] + ): Promise { + if (this.runtimeStopping) throw new Error('应用正在关闭,取消后端准备') + let control: RuntimeRunControl | undefined + const operation = client.run(args, { + onStarted: started => { + control = started + this.runtimeCommands.add(started) + if (this.runtimeStopping) started.kill() + }, + onProgress: event => logger.debug(`Runtime ${event.stage}: ${event.message}`), + }) + this.runtimeRuns.add(operation) + try { + return await operation + } finally { + this.runtimeRuns.delete(operation) + if (control) this.runtimeCommands.delete(control) + } + } + + private async cancelRuntimePreparations(): Promise { + this.runtimeStopping = true + if (this.runtimeRuns.size === 0) return + for (const control of this.runtimeCommands) { + try { + control.cancel() + } catch { + control.kill() + } + } + const timer = setTimeout(() => { + for (const control of this.runtimeCommands) control.kill() + }, 5000) + timer.unref?.() + try { + await Promise.allSettled([...this.runtimeRuns]) + } finally { + clearTimeout(timer) + } + } + /** * 设置状态回调 */ diff --git a/frontend/electron/services/initializationService.ts b/frontend/electron/services/initializationService.ts index 7d62ff701..3d9c3bf0f 100644 --- a/frontend/electron/services/initializationService.ts +++ b/frontend/electron/services/initializationService.ts @@ -37,6 +37,8 @@ export interface InitializationProgress { status?: InitializationStageStatus /** 本次进度来自哪条链路;旧链路不产生,界面按 `off` 处理。 */ runtimeMode?: RuntimeLaunchMode + /** 当前阶段没有可靠总量,界面应展示持续活动状态而不是精确百分比。 */ + indeterminate?: boolean details?: { checkInfo?: unknown // 可以是 EnvironmentCheckResult, RepositoryCheckResult, 或 DependencyCheckResult currentMirror?: string @@ -381,6 +383,7 @@ export class InitializationService { message: update.message, status: update.status, runtimeMode, + indeterminate: update.indeterminate, }) } diff --git a/frontend/electron/services/runtimeInitializationService.test.ts b/frontend/electron/services/runtimeInitializationService.test.ts index 6bc8fd15b..d480456d0 100644 --- a/frontend/electron/services/runtimeInitializationService.test.ts +++ b/frontend/electron/services/runtimeInitializationService.test.ts @@ -348,19 +348,30 @@ describe('进度桥接', () => { expect(pythonInstall?.stage).toBe('repository') }) - it('没有 percent 时段内停在 10%,段结束才 100%', () => { + it('没有 percent 时标记为持续活动,有真实 percent 时立即透传', () => { const updates: BootstrapProgressUpdate[] = [] const bridge = new BootstrapProgressBridge(update => updates.push(update)) bridge.observe('uv.download', '正在准备固定版本 uv') bridge.observe('uv.verify', '固定版本 uv 已校验') expect(updates.map(u => u.progress)).toEqual([10, 10]) + expect(updates.every(u => u.indeterminate)).toBe(true) bridge.observe('workspace.clone', '正在同步后端仓库', 42.86) bridge.observe('workspace.clone', '正在接收后端仓库数据', 63.4) expect(updates[2]).toMatchObject({ stage: 'python', status: 'completed', progress: 100 }) - expect(updates[3]).toMatchObject({ stage: 'repository', status: 'started', progress: 10 }) - expect(updates[4]).toMatchObject({ stage: 'repository', status: 'running', progress: 63 }) + expect(updates[3]).toMatchObject({ + stage: 'repository', + status: 'started', + progress: 43, + indeterminate: false, + }) + expect(updates[4]).toMatchObject({ + stage: 'repository', + status: 'running', + progress: 63, + indeterminate: false, + }) }) it('还没进过任何段时不会顺手把前面的段报成完成', () => { @@ -369,7 +380,13 @@ describe('进度桥接', () => { bridge.observe('dependencies.sync', '正在同步锁定依赖') expect(updates).toEqual([ - { stage: 'dependency', status: 'started', progress: 10, message: '正在同步锁定依赖' }, + { + stage: 'dependency', + status: 'started', + progress: 10, + message: '正在同步锁定依赖', + indeterminate: true, + }, ]) }) }) @@ -387,7 +404,11 @@ describe('development 模式跳过', () => { 'repository', 'dependency', ]) - expect(updates.every(u => u.status === 'completed' && u.progress === 100)).toBe(true) + expect( + updates.every( + u => u.status === 'completed' && u.progress === 100 && u.indeterminate === false + ) + ).toBe(true) expect(updates[0].message).toBe('由 Runtime development 模式接管,跳过') }) }) diff --git a/frontend/electron/services/runtimeInitializationService.ts b/frontend/electron/services/runtimeInitializationService.ts index b8240d065..1bb54318c 100644 --- a/frontend/electron/services/runtimeInitializationService.ts +++ b/frontend/electron/services/runtimeInitializationService.ts @@ -231,6 +231,8 @@ export interface BootstrapProgressUpdate { status: InitializationStageStatus progress: number message: string + /** Runtime 没有可靠总量时为 true,界面改用持续活动进度而不是展示伪百分比。 */ + indeterminate?: boolean } /** bootstrap 实际经过的三个界面段,按现有界面的固定先后顺序排列。 */ @@ -246,7 +248,7 @@ export const RUNTIME_TAKEOVER_STAGES: readonly InitializationRunStage[] = ['mirr export const RUNTIME_TAKEOVER_MESSAGE = '由 Runtime 接管' export const RUNTIME_DEVELOPMENT_SKIP_MESSAGE = '由 Runtime development 模式接管,跳过' -/** 段刚开始时的粗略进度。Runtime 不给细粒度百分比时段内一直停在这个值。 */ +/** 兼容旧消费方的段起始值;indeterminate=true 时界面不得把它显示成精确百分比。 */ const STAGE_STARTED_PROGRESS = 10 /** @@ -257,9 +259,9 @@ const STAGE_STARTED_PROGRESS = 10 * `python.*` 都映射到 `python` 段,直接按事件重开段会让界面从「拉取源码」倒退回 * 「安装 Python」。落后于当前段的事件仍会展示 Runtime 自己的文案,只是挂在当前段上。 * - * 进度百分比只用 Runtime 真给的 `percent`:实测整条成功 bootstrap 的 73 条 progress - * 事件没有一条带 `percent` / `current` / `total`,依赖同步阶段更是一条 progress 都没有, - * 所以这里不编造段内百分比,段开始 10%、段结束 100%。 + * 进度百分比只用 Runtime 真给的 `percent`:没有可靠总量时用 `indeterminate` 明确告诉 + * 界面展示持续活动状态。`progress=10` 只为兼容仍要求数字的旧消费方,不再作为精确百分比 + * 呈现;这样既保留当前 IPC 形状,也不会让长耗时阶段看起来卡死在 10%。 */ export class BootstrapProgressBridge { private index = -1 @@ -275,7 +277,13 @@ export class BootstrapProgressBridge { /** 进入 bootstrap:三个没有对应物的段立刻各发一个完成。 */ takeOver(): void { for (const stage of RUNTIME_TAKEOVER_STAGES) { - this.emit({ stage, status: 'completed', progress: 100, message: RUNTIME_TAKEOVER_MESSAGE }) + this.emit({ + stage, + status: 'completed', + progress: 100, + message: RUNTIME_TAKEOVER_MESSAGE, + indeterminate: false, + }) } } @@ -294,8 +302,9 @@ export class BootstrapProgressBridge { this.emit({ stage: RUNTIME_BOOTSTRAP_STAGE_ORDER[target], status: 'started', - progress: STAGE_STARTED_PROGRESS, + progress: percent === undefined ? STAGE_STARTED_PROGRESS : clampPercent(percent), message, + indeterminate: percent === undefined, }) return } @@ -305,6 +314,7 @@ export class BootstrapProgressBridge { status: 'running', progress: percent === undefined ? STAGE_STARTED_PROGRESS : clampPercent(percent), message, + indeterminate: percent === undefined, }) } @@ -320,7 +330,7 @@ export class BootstrapProgressBridge { fail(stage: InitializationRunStage, message: string): void { if (this.closed) return this.closed = true - this.emit({ stage, status: 'failed', progress: 0, message }) + this.emit({ stage, status: 'failed', progress: 0, message, indeterminate: false }) } /** @@ -337,6 +347,7 @@ export class BootstrapProgressBridge { status: 'completed', progress: 100, message, + indeterminate: false, }) } } @@ -806,6 +817,7 @@ export function emitDevelopmentSkipProgress( status: 'completed', progress: 100, message: RUNTIME_DEVELOPMENT_SKIP_MESSAGE, + indeterminate: false, }) } } diff --git a/frontend/src/components/TitleBar.vue b/frontend/src/components/TitleBar.vue index 1bc4c17a9..fe33db155 100644 --- a/frontend/src/components/TitleBar.vue +++ b/frontend/src/components/TitleBar.vue @@ -31,11 +31,20 @@ {{ t('comp.backendUpdateDevUnsupported') }} - {{ t('comp.backendUpdateAvailableClick') }} + {{ + t( + runtimeBackendUpdateAvailable + ? 'comp.backendUpdateReady' + : 'comp.backendUpdateAvailableClick' + ) + }} @@ -145,7 +154,11 @@ import { useI18n } from 'vue-i18n' import { closeApp } from '@/composables/useAppLifecycle' import { useTheme } from '@/composables/useTheme' -import { updateInfo, backendUpdateInfo } from '@/composables/useVersionService' +import { + updateInfo, + backendUpdateInfo, + runtimeBackendUpdateAvailable, +} from '@/composables/useVersionService' import { useUpdateModal } from '@/composables/useUpdateChecker' import { useAppInitialization } from '@/composables/useAppInitialization' import { useUpdateDownload } from '@/composables/useUpdateDownload' @@ -289,12 +302,21 @@ const resolveRuntimeUpdateVersion = (): string => updateInfo.value?.latest_versi const handleBackendUpdateClick = () => { Modal.confirm({ title: t('comp.restartBackendUpdate'), - content: t('comp.backendAboutUpdateWhich'), + content: t( + runtimeBackendUpdateAvailable.value + ? 'comp.backendUpdateReadyConfirm' + : 'comp.backendAboutUpdateWhich' + ), okText: t('comp.confirm'), cancelText: t('comp.cancel'), centered: true, onOk: async () => { - // Runtime 监督链路下走「停机 → bootstrap → 重新监督」,不再跳初始化页整包更新。 + // 同一 release 分支有新 Commit 时,重启应用让启动 bootstrap 在停机窗口完成同步。 + if (isRuntimeManaged.value && runtimeBackendUpdateAvailable.value) { + await window.electronAPI.appRestart() + return + } + // 跨版本更新仍走完整的 Runtime 更新编排。 if (isRuntimeManaged.value) { await startRuntimeUpdate(resolveRuntimeUpdateVersion()) return diff --git a/frontend/src/composables/useVersionService.test.ts b/frontend/src/composables/useVersionService.test.ts new file mode 100644 index 000000000..015deeb0c --- /dev/null +++ b/frontend/src/composables/useVersionService.test.ts @@ -0,0 +1,36 @@ +import { beforeEach, expect, it, vi } from 'vitest' + +const check = vi.fn() +const backendVersion = vi.fn() +vi.mock('@/api', () => ({ Service: { getGitVersionApiInfoVersionPost: backendVersion } })) +vi.mock('./useUpdateChecker', () => ({ + requestUpdateCheck: vi.fn(), + useUpdateChecker: vi.fn(), + useUpdateModal: vi.fn(), +})) + +beforeEach(() => { + vi.resetModules() + vi.clearAllMocks() + vi.stubGlobal('window', { + electronAPI: { + checkRuntimeBackendUpdate: check, + getLogger: () => ({ debug: vi.fn(), error: vi.fn() }), + }, + }) +}) + +it('仅在更新已准备完成时提示,并在版本刷新或检查失败时保留提示', async () => { + const service = await import('./useVersionService') + check.mockResolvedValueOnce({ updateAvailable: true, staged: false }) + await service.checkRuntimeBackendUpdate() + expect(service.runtimeBackendUpdateAvailable.value).toBe(false) + check.mockResolvedValueOnce({ updateAvailable: true, staged: true }) + await service.checkRuntimeBackendUpdate() + backendVersion.mockResolvedValueOnce({ current_hash: 'old' }) + await service.getBackendVersion() + expect(service.runtimeBackendUpdateAvailable.value).toBe(true) + check.mockRejectedValueOnce(new Error('offline')) + await service.checkRuntimeBackendUpdate() + expect(service.runtimeBackendUpdateAvailable.value).toBe(true) +}) diff --git a/frontend/src/composables/useVersionService.ts b/frontend/src/composables/useVersionService.ts index c2456bd26..1da0551f3 100644 --- a/frontend/src/composables/useVersionService.ts +++ b/frontend/src/composables/useVersionService.ts @@ -13,6 +13,7 @@ const logger = window.electronAPI.getLogger('版本服务') // ========== 标题栏版本信息相关 ========== export const updateInfo = ref(null) export const backendUpdateInfo = ref(null) +export const runtimeBackendUpdateAvailable = ref(false) const TITLEBAR_POLL_MS = 10 * 60 * 1000 // 10 分钟 let titlebarPollTimer: number | null = null @@ -45,6 +46,20 @@ export const getBackendVersion = async () => { } } +export const checkRuntimeBackendUpdate = async () => { + try { + const result = await window.electronAPI.checkRuntimeBackendUpdate?.() + if (result?.staged === true) runtimeBackendUpdateAvailable.value = true + else if (result && !result.error) runtimeBackendUpdateAvailable.value = false + return result + } catch (error) { + logger.debug( + `Runtime 后端更新检查失败: ${error instanceof Error ? error.message : String(error)}` + ) + return null + } +} + /** * 执行一次标题栏版本信息检查 */ @@ -53,7 +68,11 @@ const pollTitlebarVersionOnce = async () => { isTitlebarPolling.value = true try { - const [appRes, backendRes] = await Promise.allSettled([getAppVersion(), getBackendVersion()]) + const [appRes, backendRes, runtimeRes] = await Promise.allSettled([ + getAppVersion(), + getBackendVersion(), + checkRuntimeBackendUpdate(), + ]) if (appRes.status === 'rejected') { const errorMsg = @@ -65,6 +84,9 @@ const pollTitlebarVersionOnce = async () => { backendRes.reason instanceof Error ? backendRes.reason.message : String(backendRes.reason) logger.error(`获取后端版本失败: ${errorMsg}`) } + if (runtimeRes.status === 'rejected') { + logger.debug(`Runtime 后端更新检查失败: ${String(runtimeRes.reason)}`) + } } finally { isTitlebarPolling.value = false } diff --git a/frontend/src/i18n/locales/zh-CN.ts b/frontend/src/i18n/locales/zh-CN.ts index 166a50efb..fdaddc5de 100644 --- a/frontend/src/i18n/locales/zh-CN.ts +++ b/frontend/src/i18n/locales/zh-CN.ts @@ -186,6 +186,8 @@ export default { updateHasFinishedDownloading: '更新包已下载完成,是否立即安装?', minimize: '最小化', backendUpdateAvailableClick: '检测到后端更新,点击以更新后端', + backendUpdateReady: '后端更新已就绪,下次启动生效', + backendUpdateReadyConfirm: '后端更新已下载,将在下次启动时应用。现在重启软件?', backendUpdateDevUnsupported: '检测到后端更新,开发模式不支持自动更新', backendUpdateTitle: '更新后端到 {version}', backendUpdateSucceeded: '后端已更新并重新启动', @@ -1300,24 +1302,28 @@ export default { bettergiRetryLimitHint: '超过该次数仍失败则终止', bettergiRunTimeoutHint: '日志长期无变化将判定超时', useAdminLaunch: '以管理员权限启动', - bettergiUseAdminHint: '默认开启(BetterGI 需要管理员权限)。MAS 非管理员运行时,每次启动都会弹一次 UAC,无人值守任务可关闭避免挂在授权上;MAS 已提权时开启也不会重复弹窗', + bettergiUseAdminHint: + '默认开启(BetterGI 需要管理员权限)。MAS 非管理员运行时,每次启动都会弹一次 UAC,无人值守任务可关闭避免挂在授权上;MAS 已提权时开启也不会重复弹窗', bettergiRootPathSaved: 'BetterGI 根目录已保存', bettergiInvalidDirectory: '所选目录无效', bettergiExeNotFound: '所选目录下未找到 {p0},请选择完整的 BetterGI 脚本根目录。', bettergiNotBettergiScript: '脚本类型不是 BetterGI', bettergiConfigure: '配置 BetterGI', - bettergiMasConfigTooltip: '独立配置模式:将打开 BetterGI,请在「一条龙」页面编辑「MAS独立配置」,保存退出后自动回读到该用户。', + bettergiMasConfigTooltip: + '独立配置模式:将打开 BetterGI,请在「一条龙」页面编辑「MAS独立配置」,保存退出后自动回读到该用户。', bettergiConfiguringTitle: '正在进行 BetterGI 设置', bettergiConfiguringDesc: '请在 BetterGI 界面完成设置。', bettergiConfiguringDesc2: '完成后点击「保存设置」结束本次会话。', bettergiUserNameHint: '用于区分用户的名称,相同名称的用户将被视为同一用户进行统计', bettergiAccount: '账户', bettergiEnterAccount: '请输入账户', - bettergiAccountHint: '用于切换账号,无需切换则留空;下拉列表模式填写完整手机号/邮箱,MAS 自动转换为游戏显示的打码形式', + bettergiAccountHint: + '用于切换账号,无需切换则留空;下拉列表模式填写完整手机号/邮箱,MAS 自动转换为游戏显示的打码形式', bettergiAccountUid: '账号 UID', bettergiEnterUid: '请输入 UID(切换账号建议填写)', bettergiUidHint: '可不填;切换账号建议填写,填写后切换前识别一致将不执行切换动作', - bettergiPasswordHint: '没有填写密码时,默认为下拉列表切换账号。如果切换账号使用密码登录,必须填写密码', + bettergiPasswordHint: + '没有填写密码时,默认为下拉列表切换账号。如果切换账号使用密码登录,必须填写密码', bettergiEnterPasswordPlaceholder: '请输入密码(没有填写密码时,默认为下拉列表切换账号)', bettergiGameServer: '游戏服务器', bettergiGameServerHint: '账号所在服务器:官服 / B服 / 亚服 / 欧服 / 美服 / 港澳台服', @@ -1327,16 +1333,22 @@ export default { bettergiServerEurope: '欧服', bettergiServerAmerica: '美服', bettergiServerTwHkMo: '港澳台服', - bettergiTaskConfigHint: '勾选要执行的一条龙内置配置组;选择「脚本直控配置」时由 BetterGI 原生配置决定,不可编辑', - bettergiDirectModeAlert: '当前为「脚本直控配置」,任务配置项不可编辑。请切换到「用户独立配置」,以在本页为该用户配置独立的一条龙任务。', + bettergiTaskConfigHint: + '勾选要执行的一条龙内置配置组;选择「脚本直控配置」时由 BetterGI 原生配置决定,不可编辑', + bettergiDirectModeAlert: + '当前为「脚本直控配置」,任务配置项不可编辑。请切换到「用户独立配置」,以在本页为该用户配置独立的一条龙任务。', bettergiSwitchToMasConfig: '切换到用户独立配置', bettergiMasConfigHowTo: '如何使用「用户独立配置」', - bettergiMasConfigHowTo1a: '该用户的一条龙已走独立配置:MAS 会以「MAS独立配置」这条龙槽位启动。想调整具体任务,点击右上角「配置 BetterGI」打开 BGI,在其「一条龙」页面选择并编辑名为', + bettergiMasConfigHowTo1a: + '该用户的一条龙已走独立配置:MAS 会以「MAS独立配置」这条龙槽位启动。想调整具体任务,点击右上角「配置 BetterGI」打开 BGI,在其「一条龙」页面选择并编辑名为', bettergiMasConfigSlotName: '「MAS独立配置」', - bettergiMasConfigHowTo1b: '的配置,保存退出后 MAS 会自动回读到该用户。请不要修改你原有的一龙实配(如「默认配置」)——独立配置读取的是「MAS独立配置」槽位,同名实配不会被读取、也不受这里编辑影响。', - bettergiMasConfigHowTo2: '下方面板的通用战斗队伍 / 通用战斗策略:留空则使用 BetterGI 现有设置(策略留空=「根据队伍自动选择」);填写后将应用到一条龙里需要战斗的四个任务(自动地脉花、自动秘境、自动首领讨伐、自动幽境危战),替换 BetterGI 对应任务的默认队伍与策略。', + bettergiMasConfigHowTo1b: + '的配置,保存退出后 MAS 会自动回读到该用户。请不要修改你原有的一龙实配(如「默认配置」)——独立配置读取的是「MAS独立配置」槽位,同名实配不会被读取、也不受这里编辑影响。', + bettergiMasConfigHowTo2: + '下方面板的通用战斗队伍 / 通用战斗策略:留空则使用 BetterGI 现有设置(策略留空=「根据队伍自动选择」);填写后将应用到一条龙里需要战斗的四个任务(自动地脉花、自动秘境、自动首领讨伐、自动幽境危战),替换 BetterGI 对应任务的默认队伍与策略。', bettergiOneDragonName: '一条龙名称', - bettergiOneDragonNameHint: '必填。对应 BetterGI 一条龙页面中已保存/将保存的一条龙配置名称,默认为「默认配置」', + bettergiOneDragonNameHint: + '必填。对应 BetterGI 一条龙页面中已保存/将保存的一条龙配置名称,默认为「默认配置」', bettergiPickOneDragonName: '请选择一条龙配置名称', bettergiDailyRewardParty: '领取奖励队伍', bettergiEnterDailyRewardParty: '请输入领取奖励队伍', @@ -1356,12 +1368,15 @@ export default { bettergiGroupDailyReward: '领取每日奖励', bettergiGroupTeapot: '领取尘歌壶奖励', bettergiCustomGroups: '自定义配置组', - bettergiCustomGroupsTip1: '来源:BetterGI 一条龙配置里除 8 个内置组以外的自定义配置组(在 BetterGI 一条龙界面添加),不是下方的「任务配置组」开关。', + bettergiCustomGroupsTip1: + '来源:BetterGI 一条龙配置里除 8 个内置组以外的自定义配置组(在 BetterGI 一条龙界面添加),不是下方的「任务配置组」开关。', bettergiCustomGroupsTip2a: '用法(本表只是一个开关):一条龙里存在但本表未列出的配置组', bettergiCustomGroupsDefaultRun: '默认执行', bettergiCustomGroupsTip2b: ';已加入本表的组按行的开关执行——开启则执行、关闭则不执行。', - bettergiCustomGroupsTip3: '「添加配置组」从 BetterGI 现有配置(独立配置模式下读取「MAS独立配置」槽位)选取要纳入控制的组;未入表的组仍保留在一条龙里,不会因本表而丢失。', - bettergiCustomGroupsDesc: '来源是 BetterGI 一条龙配置里除 8 个内置组以外的自定义配置组;本表只是一个开关——一条龙里有但表里没有的组默认执行,入表的组按行的开关执行(开启执行、关闭不执行)。', + bettergiCustomGroupsTip3: + '「添加配置组」从 BetterGI 现有配置(独立配置模式下读取「MAS独立配置」槽位)选取要纳入控制的组;未入表的组仍保留在一条龙里,不会因本表而丢失。', + bettergiCustomGroupsDesc: + '来源是 BetterGI 一条龙配置里除 8 个内置组以外的自定义配置组;本表只是一个开关——一条龙里有但表里没有的组默认执行,入表的组按行的开关执行(开启执行、关闭不执行)。', bettergiAddGroup: '添加配置组', bettergiDeleteGroupConfirm: '确定删除选中的配置组吗?', bettergiAdding: '添加中...', @@ -1816,15 +1831,31 @@ export default { currentMirror: '当前使用: {mirror}', }, steps: { + environment: '准备运行环境', python: 'Python 安装', pip: 'Pip 安装', git: 'Git 安装', - repository: '源码拉取', - dependency: '依赖安装', - backend: '后端启动', + repository: '同步程序文件', + dependency: '安装运行依赖', + backend: '启动应用', + }, + state: { + waiting: '等待中', + processing: '进行中', + success: '已完成', + failed: '需要处理', }, page: { subtitle: '欢迎使用 AUTO-MAS,正在自动配置您的运行环境', + preparingTitle: '正在准备 AUTO-MAS', + updatingTitle: '正在更新运行环境', + startingTitle: '正在启动 AUTO-MAS', + stageTitle: '启动准备', + firstRunEstimate: '首次准备约需 1–5 分钟', + stageProgress: '当前步骤进度', + progressing: '持续进行中', + progressPercent: '{percent}%', + elapsed: '已用时 {time}', skipModalTitle: '警告', skipModalOk: '我知道我在做什么', skipModalCancel: '取消', @@ -1846,19 +1877,22 @@ export default { backendRunning: '后端服务已启动,PID: {pid}', }, backend: { - title: '启动应用', + title: '正在启动应用', statusCard: '后端服务状态', running: '运行中', pid: '进程 PID', + serviceReady: '后台服务已启动', wsConnected: '已连接', wsConnecting: '连接中...', + versionReady: '版本服务已就绪', + versionPreparing: '正在准备版本服务', versionCheck: '版本检查', started: '已启动', successTitle: '后端启动成功', successSubtitle: '应用已准备就绪,即将进入主界面', failedTitle: '后端启动失败', helpMessage: '如果需要帮助,请截图下方完整日志寻求帮助', - viewDocs: '点此查看文档', + viewDocs: '查看排障文档', preparing: '准备启动后端服务...', starting: '正在启动后端进程...', connectingWs: '正在建立WebSocket连接...', @@ -1913,6 +1947,7 @@ export default { mirrorSection: '镜像源', recommendedUse: '推荐使用', recommended: '推荐', + mirrorHelp: '选择后将使用该线路重新尝试', officialSection: '官方源', officialWarning: '中国大陆连通性不佳', speedUntested: '未测试', diff --git a/frontend/src/types/electron.d.ts b/frontend/src/types/electron.d.ts index cba340de9..633eaecc8 100644 --- a/frontend/src/types/electron.d.ts +++ b/frontend/src/types/electron.d.ts @@ -391,6 +391,13 @@ export interface ElectronAPI { /** 本次生命周期是否走 Runtime 监督链路;true 时后端只能由 Electron 经 Runtime 停止。 */ runtimeSupervised?: boolean }> + checkRuntimeBackendUpdate: () => Promise<{ + updateAvailable: boolean + staged?: boolean + currentCommit?: string + remoteCommit?: string + error?: string + }> // Runtime 链路的后端更新(启动模式统一走上面的 getRuntimeLaunchMode) updateBackendViaRuntime: (targetVersion: string) => Promise @@ -426,6 +433,8 @@ export interface ElectronAPI { status?: 'started' | 'running' | 'completed' | 'failed' /** 本条进度来自哪条链路;旧链路不产生,按 off 处理。 */ runtimeMode?: RuntimeInitMode + /** 当前阶段没有可靠总量,应展示持续活动状态而不是精确百分比。 */ + indeterminate?: boolean }) => void ) => void removeInitializationProgressListener?: () => void diff --git a/frontend/src/views/Initialization/RuntimeInitializationPage.vue b/frontend/src/views/Initialization/RuntimeInitializationPage.vue new file mode 100644 index 000000000..b4894172d --- /dev/null +++ b/frontend/src/views/Initialization/RuntimeInitializationPage.vue @@ -0,0 +1,244 @@ + + + + + diff --git a/frontend/src/views/Initialization/components/BackendStartStep.vue b/frontend/src/views/Initialization/components/BackendStartStep.vue index 275d5dbc2..6cf66f734 100644 --- a/frontend/src/views/Initialization/components/BackendStartStep.vue +++ b/frontend/src/views/Initialization/components/BackendStartStep.vue @@ -1,557 +1,12 @@ - - diff --git a/frontend/src/views/Initialization/components/RuntimeBackendStartPanel.vue b/frontend/src/views/Initialization/components/RuntimeBackendStartPanel.vue new file mode 100644 index 000000000..d5bd63d5a --- /dev/null +++ b/frontend/src/views/Initialization/components/RuntimeBackendStartPanel.vue @@ -0,0 +1,439 @@ + + + + + diff --git a/frontend/src/views/Initialization/components/RuntimeSetupPanel.test.ts b/frontend/src/views/Initialization/components/RuntimeSetupPanel.test.ts new file mode 100644 index 000000000..0ce5ef741 --- /dev/null +++ b/frontend/src/views/Initialization/components/RuntimeSetupPanel.test.ts @@ -0,0 +1,155 @@ +import { readFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { createSSRApp, defineComponent, h } from 'vue' +import { renderToString } from '@vue/server-renderer' +import { createI18n } from 'vue-i18n' +import { describe, expect, it } from 'vitest' +import zhCN from '@/i18n/locales/zh-CN' +import { decideFailureActions } from '@/utils/initializationDecision' +import RuntimeSetupPanel from './RuntimeSetupPanel.vue' + +const i18n = createI18n({ + legacy: false, + locale: 'zh-CN', + fallbackLocale: 'zh-CN', + missingWarn: false, + fallbackWarn: false, + messages: { 'zh-CN': zhCN }, +}) + +const stub = (name: string) => + defineComponent({ + name, + inheritAttrs: false, + setup(_props, { attrs, slots }) { + const propText = ['message', 'description', 'title'].map(key => + attrs[key] === undefined ? null : h('span', null, String(attrs[key])) + ) + return () => h('div', { class: name }, [...propText, slots.default?.()]) + }, + }) + +async function renderPanel(props: Record): Promise { + const app = createSSRApp(RuntimeSetupPanel, { + title: '准备运行环境', + status: 'processing', + message: '正在准备固定版本运行组件', + ...props, + }) + app.use(i18n) + for (const name of ['a-alert', 'a-button', 'a-card', 'a-space', 'a-tag']) { + app.component(name, stub(name)) + } + return renderToString(app) +} + +describe('新版初始化状态面板', () => { + it('有可靠数值时展示当前步骤进度、动作和计时', async () => { + const html = await renderPanel({ + elapsedText: '01:24', + progress: 42, + progressIndeterminate: false, + }) + + expect(html).toContain('正在准备固定版本运行组件') + expect(html).toContain('当前步骤进度') + expect(html).toContain('42%') + expect(html).toContain('首次准备约需 1–5 分钟') + expect(html).toContain('已用时 01:24') + }) + + it('没有可靠总量时展示持续活动进度,不把兼容值当成百分比', async () => { + const html = await renderPanel({ progress: 10, progressIndeterminate: true }) + + expect(html).toContain('持续进行中') + expect(html).toContain('role="progressbar"') + expect(html).not.toContain('10%') + }) + + it('失败时保留 Runtime 给出的恢复动作和日志', async () => { + const plan = decideFailureActions({ + code: 'DEPENDENCY_SYNC_FAILED', + retryable: true, + remediation: ['retry-sync', 'rebuild-environment', 'open-log'], + stage: 'dependency', + runtimeMode: 'managed', + }) + + const html = await renderPanel({ + status: 'failed', + message: '运行依赖同步失败', + failureActions: plan.actions, + failureLogs: '[stderr]\nnetwork unreachable', + }) + + expect(html).toContain('运行依赖同步失败') + expect(html).toContain('重试') + expect(html).toContain('重建环境') + expect(html).toContain('打开日志') + expect(html).toContain('network unreachable') + }) + + it('实际路由保留分阶段布局,两个工作面板都展示当前步骤进度', () => { + const pageSource = readFileSync( + fileURLToPath(new URL('../RuntimeInitializationPage.vue', import.meta.url)), + 'utf8' + ) + const setupSource = readFileSync( + fileURLToPath(new URL('./RuntimeSetupPanel.vue', import.meta.url)), + 'utf8' + ) + const backendSource = readFileSync( + fileURLToPath(new URL('./RuntimeBackendStartPanel.vue', import.meta.url)), + 'utf8' + ) + + expect(pageSource).not.toContain(' { + const pageSource = readFileSync( + fileURLToPath(new URL('../RuntimeInitializationPage.vue', import.meta.url)), + 'utf8' + ) + const setupSource = readFileSync( + fileURLToPath(new URL('./RuntimeSetupPanel.vue', import.meta.url)), + 'utf8' + ) + const backendSource = readFileSync( + fileURLToPath(new URL('./RuntimeBackendStartPanel.vue', import.meta.url)), + 'utf8' + ) + + expect(pageSource).toContain('grid-template-columns: clamp(15rem, 24%, 19rem) minmax(0, 1fr)') + expect(pageSource).not.toContain('max-width: 980px') + expect(setupSource).toContain('inline-size: min(100%, 72ch)') + expect(setupSource).not.toContain('max-width: 560px') + expect(backendSource).toContain('inline-size: min(100%, 72ch)') + expect(backendSource).not.toContain('max-width: 560px') + }) + + it('初始化页只保留必要状态信息,并在主状态区提示首次耗时', () => { + const pageSource = readFileSync( + fileURLToPath(new URL('../RuntimeInitializationPage.vue', import.meta.url)), + 'utf8' + ) + const setupSource = readFileSync( + fileURLToPath(new URL('./RuntimeSetupPanel.vue', import.meta.url)), + 'utf8' + ) + const backendSource = readFileSync( + fileURLToPath(new URL('./RuntimeBackendStartPanel.vue', import.meta.url)), + 'utf8' + ) + + expect(pageSource).not.toContain('page-eyebrow') + expect(pageSource).not.toContain('stageDescriptions') + expect(pageSource).not.toContain('sidebar-note') + expect(setupSource).toContain("t('init.page.firstRunEstimate')") + expect(setupSource).not.toContain("t('init.page.longWaitHint')") + expect(setupSource).not.toContain('state-eyebrow') + expect(backendSource).not.toContain('state-eyebrow') + }) +}) diff --git a/frontend/src/views/Initialization/components/RuntimeSetupPanel.vue b/frontend/src/views/Initialization/components/RuntimeSetupPanel.vue new file mode 100644 index 000000000..787cf42bf --- /dev/null +++ b/frontend/src/views/Initialization/components/RuntimeSetupPanel.vue @@ -0,0 +1,554 @@ + + + + + diff --git a/frontend/src/views/Initialization/components/StepPanel.vue b/frontend/src/views/Initialization/components/StepPanel.vue index 4db2e2319..514848d89 100644 --- a/frontend/src/views/Initialization/components/StepPanel.vue +++ b/frontend/src/views/Initialization/components/StepPanel.vue @@ -1,677 +1,39 @@ - - diff --git a/frontend/src/views/Initialization/index.vue b/frontend/src/views/Initialization/index.vue index adf31f027..2e8fe7037 100644 --- a/frontend/src/views/Initialization/index.vue +++ b/frontend/src/views/Initialization/index.vue @@ -1,1155 +1,9 @@ - - diff --git a/frontend/src/views/Initialization/initializationPresentation.test.ts b/frontend/src/views/Initialization/initializationPresentation.test.ts new file mode 100644 index 000000000..ccab4b348 --- /dev/null +++ b/frontend/src/views/Initialization/initializationPresentation.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest' +import { + formatElapsedSeconds, + getInitializationStageKey, + getInitializationStageStatus, +} from './initializationPresentation' + +describe('初始化界面展示模型', () => { + it('把旧环境准备步骤合并成一个用户可理解的阶段', () => { + expect(getInitializationStageKey('python')).toBe('environment') + expect(getInitializationStageKey('pip')).toBe('environment') + expect(getInitializationStageKey('git')).toBe('environment') + expect(getInitializationStageKey('repository')).toBe('repository') + }) + + it('聚合阶段状态时优先展示失败和处理中', () => { + expect( + getInitializationStageStatus('environment', { + python: 'success', + pip: 'failed', + git: 'processing', + }) + ).toBe('failed') + + expect( + getInitializationStageStatus('environment', { + python: 'success', + pip: 'processing', + git: 'waiting', + }) + ).toBe('processing') + + expect( + getInitializationStageStatus('environment', { + python: 'success', + pip: 'success', + git: 'success', + }) + ).toBe('success') + }) + + it('把等待时间格式化为稳定的分秒显示', () => { + expect(formatElapsedSeconds(0)).toBe('00:00') + expect(formatElapsedSeconds(65)).toBe('01:05') + expect(formatElapsedSeconds(-1)).toBe('00:00') + }) +}) diff --git a/frontend/src/views/Initialization/initializationPresentation.ts b/frontend/src/views/Initialization/initializationPresentation.ts new file mode 100644 index 000000000..eda8e3b92 --- /dev/null +++ b/frontend/src/views/Initialization/initializationPresentation.ts @@ -0,0 +1,45 @@ +export type InitializationStepKey = + | 'python' + | 'pip' + | 'git' + | 'repository' + | 'dependency' + | 'backend' + +export type InitializationStepStatus = 'waiting' | 'processing' | 'success' | 'failed' + +export type InitializationStageKey = 'environment' | 'repository' | 'dependency' | 'backend' + +export const initializationStages: readonly { + key: InitializationStageKey + steps: readonly InitializationStepKey[] +}[] = [ + { key: 'environment', steps: ['python', 'pip', 'git'] }, + { key: 'repository', steps: ['repository'] }, + { key: 'dependency', steps: ['dependency'] }, + { key: 'backend', steps: ['backend'] }, +] + +export function getInitializationStageKey(stepKey: InitializationStepKey): InitializationStageKey { + return initializationStages.find(stage => stage.steps.includes(stepKey))?.key ?? 'environment' +} + +export function getInitializationStageStatus( + stageKey: InitializationStageKey, + stepStatuses: Readonly>> +): InitializationStepStatus { + const stage = initializationStages.find(item => item.key === stageKey) + const statuses = stage?.steps.map(step => stepStatuses[step] ?? 'waiting') ?? [] + + if (statuses.includes('failed')) return 'failed' + if (statuses.includes('processing')) return 'processing' + if (statuses.length > 0 && statuses.every(status => status === 'success')) return 'success' + return 'waiting' +} + +export function formatElapsedSeconds(totalSeconds: number): string { + const safeSeconds = Math.max(0, Math.floor(totalSeconds)) + const minutes = Math.floor(safeSeconds / 60) + const seconds = safeSeconds % 60 + return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}` +} diff --git a/frontend/src/views/Initialization/useInitializationFlow.ts b/frontend/src/views/Initialization/useInitializationFlow.ts new file mode 100644 index 000000000..726b6a971 --- /dev/null +++ b/frontend/src/views/Initialization/useInitializationFlow.ts @@ -0,0 +1,728 @@ +import { computed, onMounted, onUnmounted, ref } from 'vue' +import { useI18n } from 'vue-i18n' +import { message } from 'ant-design-vue' +import { enterApp, forceEnterApp } from '@/utils/appEntry.ts' +import { getBackendVersion } from '@/composables/useVersionService' +import { decideFailureActions, filterRuntimeMirrors } from '@/utils/initializationDecision' +import { + formatElapsedSeconds, + getInitializationStageKey, + getInitializationStageStatus, + initializationStages, +} from './initializationPresentation' +import type { + ElectronMirrorSource, + InstallStageResult, + RuntimeDoctorCheck, + RuntimeFailureFields, + RuntimeInitMode, +} from '@/types/electron' +import type { MirrorConfig } from '@/types/mirror' +import type { + FailureAction, + FailureActionKind, + FailureNoticeKind, +} from '@/utils/initializationDecision' +import type { InitializationStepKey, InitializationStepStatus } from './initializationPresentation' + +export function useInitializationFlow() { + const { t } = useI18n() + const logger = window.electronAPI.getLogger('初始化流程') + + interface StepDefinition { + key: InitializationStepKey + canSkip: boolean + } + + interface StepState { + status: InitializationStepStatus + message: string + progress: number + progressIndeterminate: boolean + showMirrorSelection: boolean + mirrors: MirrorConfig[] + selectedMirror: string + countdown: number + failureActions: FailureAction[] + failureNotice: FailureNoticeKind | null + failureLogs: string + failureLogPath: string + doctorChecks: RuntimeDoctorCheck[] | null + doctorRunning: boolean + } + + interface ProgressPayload { + stage?: string + progress?: number + message?: string + status?: 'started' | 'running' | 'completed' | 'failed' + indeterminate?: boolean + } + + const steps: readonly StepDefinition[] = [ + { key: 'python', canSkip: false }, + { key: 'pip', canSkip: false }, + { key: 'git', canSkip: false }, + { key: 'repository', canSkip: true }, + { key: 'dependency', canSkip: true }, + { key: 'backend', canSkip: true }, + ] + + function createStepState(): StepState { + return { + status: 'waiting', + message: '', + progress: 0, + progressIndeterminate: true, + showMirrorSelection: false, + mirrors: [], + selectedMirror: '', + countdown: 0, + failureActions: [], + failureNotice: null, + failureLogs: '', + failureLogPath: '', + doctorChecks: null, + doctorRunning: false, + } + } + + const stepStates = ref>({ + python: createStepState(), + pip: createStepState(), + git: createStepState(), + repository: createStepState(), + dependency: createStepState(), + backend: createStepState(), + }) + + const currentStepIndex = ref(0) + const runtimeMode = ref('off') + const runtimeMirrorKeys = ref>({}) + const runtimeFallbackLogPath = ref('') + const elapsedSeconds = ref(0) + const flowKind = ref<'first-run' | 'update' | 'startup'>('first-run') + + const isDev = import.meta.env.DEV + const version = import.meta.env.VITE_APP_VERSION + const targetBranch = ref(isDev ? 'dev' : `release/${version}`) + + const RUNTIME_TAKEOVER_STEPS = new Set(['pip', 'git']) + const RETRY_ACTION_KINDS = new Set([ + 'retry', + 'retry-other-mirror', + 'rebuild-environment', + ]) + + let countdownTimer: ReturnType | null = null + let elapsedTimer: ReturnType | null = null + let initializationTimer: ReturnType | null = null + + const currentStep = computed(() => steps[currentStepIndex.value]) + const activeStageKey = computed(() => getInitializationStageKey(currentStep.value.key)) + const elapsedText = computed(() => formatElapsedSeconds(elapsedSeconds.value)) + + const presentationStages = computed(() => { + const statuses = Object.fromEntries( + steps.map(step => [step.key, stepStates.value[step.key].status]) + ) as Record + + return initializationStages.map(stage => ({ + key: stage.key, + status: getInitializationStageStatus(stage.key, statuses), + })) + }) + + const pageTitle = computed(() => { + if (activeStageKey.value === 'backend') return t('init.page.startingTitle') + if (flowKind.value === 'update') return t('init.page.updatingTitle') + return t('init.page.preparingTitle') + }) + + const currentStepProps = computed(() => { + const step = currentStep.value + const state = stepStates.value[step.key] + + return { + title: t(`init.steps.${getInitializationStageKey(step.key)}`), + status: state.status, + message: state.message, + progress: state.progress, + progressIndeterminate: state.progressIndeterminate, + elapsedText: elapsedText.value, + showMirrorSelection: state.showMirrorSelection, + showSkipButton: step.canSkip && state.status === 'failed', + mirrors: filterRuntimeMirrors( + state.mirrors, + step.key, + runtimeMode.value, + runtimeMirrorKeys.value + ), + selectedMirror: state.selectedMirror, + countdown: state.countdown, + failureActions: state.failureActions, + failureNotice: state.failureNotice, + failureLogs: state.failureLogs, + doctorChecks: state.doctorChecks, + doctorRunning: state.doctorRunning, + } + }) + + logger.info(`当前环境: ${isDev ? '开发环境' : '生产环境'}, 目标分支: ${targetBranch.value}`) + + function stageStatusKey(status: InitializationStepStatus): string { + return `init.state.${status}` + } + + function readProgressPayload(value: unknown): ProgressPayload { + if (!value || typeof value !== 'object') return {} + const raw = value as Record + return { + stage: typeof raw.stage === 'string' ? raw.stage : undefined, + progress: typeof raw.progress === 'number' ? raw.progress : undefined, + message: typeof raw.message === 'string' ? raw.message : undefined, + indeterminate: typeof raw.indeterminate === 'boolean' ? raw.indeterminate : undefined, + status: + raw.status === 'started' || + raw.status === 'running' || + raw.status === 'completed' || + raw.status === 'failed' + ? raw.status + : undefined, + } + } + + function handleProgress(stepKey: InitializationStepKey, value: unknown) { + const state = stepStates.value[stepKey] + const progress = readProgressPayload(value) + const previousStatus = state.status + const previousMessage = state.message + + if (progress.status === 'completed' || (progress.progress ?? 0) >= 100) { + state.status = 'success' + state.message = progress.message || t('init.msg.stageDone') + state.progress = 100 + state.progressIndeterminate = false + } else if (progress.status === 'failed') { + state.status = 'failed' + state.message = progress.message || t('init.msg.execFailed') + state.progressIndeterminate = false + } else { + state.status = 'processing' + state.message = progress.message || t('init.msg.running') + if (progress.progress !== undefined) { + state.progress = Math.min(100, Math.max(0, Math.round(progress.progress))) + } + state.progressIndeterminate = progress.indeterminate ?? progress.progress === undefined + } + + if (previousStatus !== state.status || previousMessage !== state.message) { + logger.info(`[${stepKey}] ${state.message}`) + } + } + + function applyFailure( + state: StepState, + stepKey: InitializationStepKey, + failure: RuntimeFailureFields + ) { + const plan = decideFailureActions({ + code: failure.code, + retryable: failure.retryable, + remediation: failure.remediation, + stage: stepKey, + runtimeMode: runtimeMode.value, + }) + + state.failureActions = plan.actions + state.failureNotice = plan.notice + state.showMirrorSelection = plan.showMirrorSelection + state.failureLogs = failure.logs ?? '' + state.failureLogPath = failure.logPath ?? '' + state.doctorChecks = null + state.doctorRunning = false + + logger.info( + `[${stepKey}] 失败处置 - code: ${failure.code ?? '无'}, retryable: ${failure.retryable ?? '无'}, ` + + `动作: ${plan.actions.map(action => action.kind).join(', ') || '无'}` + ) + return plan + } + + function markStepTakenOver(state: StepState) { + state.status = 'success' + state.message = t('init.runtime.takenOver') + state.progress = 100 + state.progressIndeterminate = false + state.showMirrorSelection = false + state.countdown = 0 + state.failureActions = [] + state.failureNotice = null + } + + function handleRuntimeInitializationProgress(progress: { + stage: string + progress: number + message: string + status?: 'started' | 'running' | 'completed' | 'failed' + }) { + if (progress.stage === 'mirror' || progress.stage === 'complete') return + if (!steps.some(step => step.key === progress.stage)) return + + const stepKey = progress.stage as InitializationStepKey + if (stepKey === 'backend' || RUNTIME_TAKEOVER_STEPS.has(stepKey)) return + + currentStepIndex.value = steps.findIndex(step => step.key === stepKey) + handleProgress(stepKey, progress) + } + + function markStepFailed( + stepKey: InitializationStepKey, + errorMessage: string, + failure: RuntimeFailureFields + ) { + const state = stepStates.value[stepKey] + state.status = 'failed' + state.message = errorMessage + logger.error(`步骤 ${stepKey} 失败: ${errorMessage}`) + + const plan = applyFailure(state, stepKey, failure) + const autoAction = plan.actions.find(action => RETRY_ACTION_KINDS.has(action.kind)) + if (autoAction) startCountdown(stepKey, autoAction.kind === 'rebuild-environment') + } + + async function executeRuntimeInitialization(): Promise { + try { + const result = await window.electronAPI.initialize(targetBranch.value, false) + + if (!result.success) { + const failedStep = steps.find( + step => step.key === result.failedStage && !RUNTIME_TAKEOVER_STEPS.has(step.key) + ) + const failedStepKey = failedStep?.key ?? 'python' + currentStepIndex.value = steps.findIndex(step => step.key === failedStepKey) + markStepFailed(failedStepKey, result.error || t('init.msg.execFailed'), result) + return false + } + + for (const step of steps.slice(0, -1)) { + const state = stepStates.value[step.key] + state.status = 'success' + state.message ||= t('init.msg.stageDone') + state.progress = 100 + state.progressIndeterminate = false + } + + currentStepIndex.value = steps.length - 1 + logger.info('Runtime bootstrap 完成,准备启动后端') + return true + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error) + currentStepIndex.value = 0 + markStepFailed('python', errorMessage, {}) + return false + } + } + + function isRuntimeTakenOver(stepKey: InitializationStepKey): boolean { + return runtimeMode.value !== 'off' && RUNTIME_TAKEOVER_STEPS.has(stepKey) + } + + async function executeStep(stepKey: InitializationStepKey, rebuild = false): Promise { + const state = stepStates.value[stepKey] + + if (isRuntimeTakenOver(stepKey)) { + markStepTakenOver(state) + return true + } + + state.status = 'processing' + state.message = t('init.msg.running') + state.progress = 0 + state.progressIndeterminate = true + let failure: RuntimeFailureFields = {} + + try { + const api = window.electronAPI + let result: InstallStageResult + + switch (stepKey) { + case 'python': + result = await api.installPython(state.selectedMirror, rebuild) + break + case 'pip': + result = await api.installPip(state.selectedMirror, rebuild) + break + case 'git': + result = await api.installGit(state.selectedMirror, rebuild) + break + case 'repository': + result = await api.pullRepository(targetBranch.value, state.selectedMirror, rebuild) + break + case 'dependency': + result = await api.installDependencies(state.selectedMirror, rebuild) + break + case 'backend': + return true + } + + if (!result.success) { + failure = result + throw new Error(result.error || t('init.msg.execFailed')) + } + + state.status = 'success' + state.message = t('init.msg.stageDone') + logger.info(`步骤 ${stepKey} 完成`) + return true + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error) + markStepFailed(stepKey, errorMessage, failure) + return false + } + } + + async function startInitialization(startIndex = 0) { + logger.info('开始初始化流程') + + try { + if (runtimeMode.value !== 'off' && startIndex === 0) { + const success = await executeRuntimeInitialization() + if (!success) logger.warn('Runtime 初始化失败,等待用户处理') + return + } + + for (let index = startIndex; index < steps.length; index += 1) { + const step = steps[index] + currentStepIndex.value = index + if (!(await executeStep(step.key))) return + } + + logger.info('初始化准备完成,等待后端启动') + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error) + logger.error(`初始化失败: ${errorMessage}`) + message.error(t('init.msg.initFailed')) + } + } + + function handleMirrorSelect(mirrorKey: string) { + stepStates.value[currentStep.value.key].selectedMirror = mirrorKey + } + + function resetFailureState(state: StepState) { + state.showMirrorSelection = false + state.countdown = 0 + state.failureActions = [] + state.failureNotice = null + state.failureLogs = '' + state.doctorChecks = null + } + + async function continueAfterCurrentStep() { + for (let index = currentStepIndex.value + 1; index < steps.length; index += 1) { + currentStepIndex.value = index + if (!(await executeStep(steps[index].key))) return false + } + return true + } + + async function handleSkip() { + const step = currentStep.value + const state = stepStates.value[step.key] + clearCountdown() + + state.status = 'success' + state.message = t('init.msg.skipped') + resetFailureState(state) + message.warning(t('init.msg.skippedStep', { step: t(`init.steps.${activeStageKey.value}`) })) + + if (step.key === 'backend') { + await handleLocalEnterApp() + return + } + + if (await continueAfterCurrentStep()) logger.info('跳过当前阶段后,初始化流程继续完成') + } + + async function handleRetry(rebuild = false) { + const step = currentStep.value + const state = stepStates.value[step.key] + clearCountdown() + resetFailureState(state) + + logger.info(`重试 ${step.key}${rebuild ? '(重建环境)' : ''}`) + if (await executeStep(step.key)) await continueAfterCurrentStep() + } + + function handleBackendStatusChange( + status: 'waiting' | 'starting' | 'running' | 'success' | 'failed' + ) { + stepStates.value.backend.status = + status === 'starting' || status === 'running' ? 'processing' : status + } + + async function handleBackendComplete() { + const state = stepStates.value.backend + state.status = 'success' + state.message = t('init.msg.backendStarted') + clearElapsedClock() + + message.success(t('init.msg.initDone')) + await window.electronAPI.setInitializedVersion?.(version) + await getBackendVersion() + await handleLocalEnterApp() + } + + function handleBackendError(errorMessage: string) { + const state = stepStates.value.backend + state.status = 'failed' + state.message = errorMessage + } + + function clearCountdown() { + if (!countdownTimer) return + clearInterval(countdownTimer) + countdownTimer = null + } + + function startCountdown(stepKey: InitializationStepKey, rebuild = false) { + clearCountdown() + const state = stepStates.value[stepKey] + state.countdown = 60 + + countdownTimer = setInterval(() => { + state.countdown -= 1 + if (state.countdown > 0) return + clearCountdown() + void handleRetry(rebuild) + }, 1000) + } + + async function handleFailureAction(kind: FailureActionKind) { + const state = stepStates.value[currentStep.value.key] + + switch (kind) { + case 'open-log': + await openFailureLog(state) + return + case 'run-doctor': + await runRuntimeDoctor(state) + return + case 'retry': + case 'retry-other-mirror': + await handleRetry(false) + return + case 'rebuild-environment': + await handleRetry(true) + return + } + } + + async function openFailureLog(state: StepState) { + const target = state.failureLogPath || runtimeFallbackLogPath.value + if (!target) { + message.error(t('init.failure.openLogFailed', { error: t('init.msg.execFailed') })) + return + } + + try { + await window.electronAPI.openFile(target) + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error) + message.error(t('init.failure.openLogFailed', { error: errorMessage })) + } + } + + async function runRuntimeDoctor(state: StepState) { + state.doctorRunning = true + try { + const result = await window.electronAPI.checkCriticalFiles() + state.doctorChecks = result.runtimeChecks ?? [] + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error) + message.error(t('init.failure.doctorFailed', { error: errorMessage })) + state.doctorChecks = [] + } finally { + state.doctorRunning = false + } + } + + async function handleLocalEnterApp() { + try { + const success = await enterApp('初始化完成后进入', true) + if (!success) await forceEnterApp('初始化完成后强制进入') + } catch { + await forceEnterApp('初始化失败后强制进入') + } + } + + function convertMirror(mirror: ElectronMirrorSource): MirrorConfig { + return { + key: mirror.name, + name: mirror.name, + url: mirror.url, + type: mirror.type, + description: mirror.description, + recommended: mirror.type === 'mirror', + } + } + + async function loadMirrorConfigs() { + const api = window.electronAPI + try { + await api.initMirrors() + const [pythonMirrors, getPipMirrors, gitMirrors, repoMirrors, pipMirrors] = await Promise.all( + [ + api.getMirrors('python'), + api.getMirrors('get_pip'), + api.getMirrors('git'), + api.getMirrors('repo'), + api.getMirrors('pip_mirror'), + ] + ) + + stepStates.value.python.mirrors = pythonMirrors.map(convertMirror) + stepStates.value.pip.mirrors = getPipMirrors.map(convertMirror) + stepStates.value.git.mirrors = gitMirrors.map(convertMirror) + stepStates.value.repository.mirrors = repoMirrors.map(convertMirror) + stepStates.value.dependency.mirrors = pipMirrors.map(convertMirror) + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error) + logger.warn(`加载镜像源配置失败,将使用默认配置: ${errorMessage}`) + } + } + + function markStepsBefore(startIndex: number) { + for (let index = 0; index < startIndex; index += 1) { + markStepTakenOver(stepStates.value[steps[index].key]) + } + } + + function startElapsedClock() { + const startedAt = Date.now() + elapsedTimer = setInterval(() => { + elapsedSeconds.value = Math.floor((Date.now() - startedAt) / 1000) + }, 1000) + } + + function clearElapsedClock() { + if (!elapsedTimer) return + clearInterval(elapsedTimer) + elapsedTimer = null + } + + async function resolveStartIndex(): Promise { + const api = window.electronAPI + const forceBackendUpdate = sessionStorage.getItem('forceBackendUpdate') === 'true' + if (forceBackendUpdate) { + sessionStorage.removeItem('forceBackendUpdate') + flowKind.value = 'update' + markStepsBefore(3) + return 3 + } + + let autoUpdate = false + try { + const config = await api.loadConfig?.() + autoUpdate = config?.Update?.IfAutoUpdate === true + } catch { + logger.warn('读取自动更新配置失败,执行完整初始化') + } + + if (autoUpdate) { + flowKind.value = 'update' + return 0 + } + + const savedVersion = await api.getInitializedVersion?.() + if (savedVersion === version) { + flowKind.value = 'startup' + markStepsBefore(steps.length - 1) + return steps.length - 1 + } + + flowKind.value = savedVersion ? 'update' : 'first-run' + return 0 + } + + onMounted(async () => { + logger.info('新版初始化界面已加载') + + if (isDev) { + await handleLocalEnterApp() + return + } + + const api = window.electronAPI + try { + const context = await api.getRuntimeInitContext?.() + if (context) { + runtimeMode.value = context.mode + runtimeMirrorKeys.value = context.mirrorKeys ?? {} + runtimeFallbackLogPath.value = context.fallbackLogPath ?? '' + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error) + logger.warn(`读取 Runtime 上下文失败,回退兼容链路: ${errorMessage}`) + } + + if (runtimeMode.value !== 'off') { + for (const stepKey of RUNTIME_TAKEOVER_STEPS) markStepTakenOver(stepStates.value[stepKey]) + } + + const startIndex = await resolveStartIndex() + currentStepIndex.value = startIndex + + // 已完成过同版本初始化时只启动后端,不再为不会执行的安装步骤加载镜像配置。 + if (startIndex < steps.length - 1) await loadMirrorConfigs() + + api.onPythonProgress?.(progress => handleProgress('python', progress)) + api.onPipProgress?.(progress => handleProgress('pip', progress)) + api.onGitProgress?.(progress => handleProgress('git', progress)) + api.onRepositoryProgress?.(progress => handleProgress('repository', progress)) + api.onDependencyProgress?.(progress => handleProgress('dependency', progress)) + api.onInitializationProgress?.(handleRuntimeInitializationProgress) + api.onBackendStatus?.(backendStatus => { + if (backendStatus.isRunning) stepStates.value.backend.status = 'processing' + }) + + startElapsedClock() + if (startIndex < steps.length - 1) { + initializationTimer = setTimeout(() => { + void startInitialization(startIndex) + }, 400) + } + }) + + onUnmounted(() => { + clearCountdown() + clearElapsedClock() + if (initializationTimer) clearTimeout(initializationTimer) + + const api = window.electronAPI + api.removePythonProgressListener?.() + api.removePipProgressListener?.() + api.removeGitProgressListener?.() + api.removeRepositoryProgressListener?.() + api.removeDependencyProgressListener?.() + api.removeInitializationProgressListener?.() + api.removeBackendStatusListener?.() + }) + + return { + activeStageKey, + currentStep, + currentStepProps, + elapsedText, + handleBackendComplete, + handleBackendError, + handleBackendStatusChange, + handleFailureAction, + handleMirrorSelect, + handleSkip, + pageTitle, + presentationStages, + stageStatusKey, + } +} diff --git a/frontend/src/views/setting/TabOthers.vue b/frontend/src/views/setting/TabOthers.vue index 92cda1243..6d7e4e2f5 100644 --- a/frontend/src/views/setting/TabOthers.vue +++ b/frontend/src/views/setting/TabOthers.vue @@ -35,9 +35,6 @@ const { const buildCopyText = () => [ t('setting.others.copyVersion', { version }), - t('setting.others.copyBackendDate', { - date: backendUpdateInfo?.current_time || t('common.unknown'), - }), t('setting.others.copyBackendHash', { hash: backendUpdateInfo?.current_hash || t('common.unknown'), }), @@ -279,12 +276,6 @@ const copyAllInfo = async () => { {{ version }} -
- {{ t('setting.others.backendDate') }} - - {{ backendUpdateInfo?.current_time || t('common.unknown') }} - -
{{ t('setting.others.backendHash') }} diff --git a/res/version.json b/res/version.json index 611568fcf..b5a6a2e9d 100644 --- a/res/version.json +++ b/res/version.json @@ -13,6 +13,8 @@ "调度队列 完成后操作可单独设定延时,关机、休眠等操作会在队列结束后先静默等待设定的时长,再照常弹出 60 秒倒计时 by [@qiyinxi](https://github.com/qiyinxi)" ], "程序优化": [ + "后端更新自动在后台下载,准备好后提示下次启动生效,无需中断当前任务", + "Runtime 更新至 v0.1.3,并暂时隐藏设置页中无效的后端日期信息", "配置来源 MAA、SRC、MaaEnd 与 OK-NTE 用户页的「简洁/详细」配置模式更名为「脚本/用户」,含义不变,旧配置自动迁移 by [@1w1w11w1](https://github.com/1w1w11w1)", "代码清理 统一全仓 Python 导入排序,并清理 BetterGI 只写不读的配置项与已冻结的更新开关,行为保持不变 by [@1w1w11w1](https://github.com/1w1w11w1)", "代码清理 合并各脚本重复的用户标签生成与配置模型并精简报告统计逻辑,行为保持不变 by [@1w1w11w1](https://github.com/1w1w11w1)", diff --git a/scripts/README.md b/scripts/README.md new file mode 100644 index 000000000..6b52a0f9e --- /dev/null +++ b/scripts/README.md @@ -0,0 +1,28 @@ +# 本地打包 + +使用 PowerShell 7。后台更新需要桌面和 Runtime 同时使用新代码;当前工作流固定的旧 Runtime +版本尚未包含该能力,发布前须先发布新 Runtime,再更新 `.github/workflows/build-app.yml` +的 `RUNTIME_VERSION`。CI 会拒绝缺少后台更新协议的二进制,防止生成无法正常启动的安装包。 + +本地验证可直接使用本次源码构建的 Runtime,不必等待 Release: + +```powershell +Set-Location D:/Github/AUTO-MAS-Runtime +$env:GOCACHE = Join-Path $env:TEMP 'auto-mas-runtime-verify' +go build -buildvcs=false -o bin/auto-mas-runtime.exe ./cmd/auto-mas-runtime +if ($LASTEXITCODE -ne 0) { throw 'Runtime build failed' } + +Set-Location D:/Github/AUTO-MAS +pwsh -NoProfile -File scripts/build-local-package.ps1 ` + -LocalRuntimePath D:/Github/AUTO-MAS-Runtime/bin/auto-mas-runtime.exe ` + -VerifyRuntimeOnly -SkipInstall +if ($LASTEXITCODE -ne 0) { throw 'Runtime verification failed' } +``` + +去掉 `-VerifyRuntimeOnly` 后执行完整本地打包。脚本校验源码版本一致性、Runtime SHA-256、 +`workspace stage` 和 `bootstrap --if-needed`,然后生成携带指定 Runtime 的安装包和解压目录。 +`-SkipInstall` 仅适用于前端依赖已经安装的环境;脚本不会发布或上传安装包。 + +运行行为:managed 模式启动后首次检查当前 release 分支,之后每 10 分钟检查一次; +发现更新就后台下载,完成后标题栏提示下次启动生效。下载不改变当前后端和环境; +下次启动替换仓库并同步所需依赖。无更新时跳过依赖同步。依赖同步失败仍需通过已有修复入口重试。 diff --git a/scripts/build-local-package.ps1 b/scripts/build-local-package.ps1 new file mode 100644 index 000000000..bf443e5d9 --- /dev/null +++ b/scripts/build-local-package.ps1 @@ -0,0 +1,299 @@ +#Requires -Version 7.0 + +[CmdletBinding()] +param( + [switch]$SkipInstall, + [string]$LocalRuntimePath, + [switch]$VerifyRuntimeOnly +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +function Invoke-NativeCommand { + param( + [Parameter(Mandatory)] + [string]$Command, + + [Parameter()] + [string[]]$Arguments = @() + ) + + & $Command @Arguments + if ($LASTEXITCODE -ne 0) { + throw "命令执行失败(退出码 $LASTEXITCODE):$Command $($Arguments -join ' ')" + } +} + +$repoRoot = Split-Path -Parent $PSScriptRoot +$frontendRoot = Join-Path $repoRoot "frontend" +$versionFile = Join-Path $repoRoot "res\version.json" +$frontendPackageFile = Join-Path $frontendRoot "package.json" +$backendConfigFile = Join-Path $repoRoot "app\core\config.py" +$pyprojectFile = Join-Path $repoRoot "pyproject.toml" +$uvLockFile = Join-Path $repoRoot "uv.lock" +$buildWorkflowFile = Join-Path $repoRoot ".github\workflows\build-app.yml" + +foreach ($requiredFile in @( + $versionFile, + $frontendPackageFile, + $backendConfigFile, + $pyprojectFile, + $uvLockFile, + $buildWorkflowFile + )) { + if (-not (Test-Path -LiteralPath $requiredFile -PathType Leaf)) { + throw "缺少打包所需文件:$requiredFile" + } +} + +if (-not (Get-Command node -ErrorAction SilentlyContinue)) { + throw "未找到 Node.js,请先安装项目要求的 Node.js 环境。" +} +if (-not (Get-Command yarn -ErrorAction SilentlyContinue)) { + throw "未找到 Yarn,请先执行:corepack prepare yarn@4.9.1 --activate" +} + +# 第一步:确认所有版本来源一致,避免打出版本信息互相冲突的安装包。 +$versionConfig = Get-Content -LiteralPath $versionFile -Raw | ConvertFrom-Json +$frontendPackage = Get-Content -LiteralPath $frontendPackageFile -Raw | ConvertFrom-Json +$appVersion = [string]$versionConfig.version + +if ($appVersion -notmatch '^v\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$') { + throw "res/version.json 中的版本格式无效:$appVersion" +} +if ([string]$frontendPackage.version -ne $appVersion) { + throw "frontend/package.json 版本不一致:$($frontendPackage.version),预期 $appVersion" +} + +$backendConfigText = Get-Content -LiteralPath $backendConfigFile -Raw +$backendVersionMatch = [regex]::Match( + $backendConfigText, + '(?m)^\s*VERSION\s*=\s*"(?v[^"]+)"' +) +if (-not $backendVersionMatch.Success -or $backendVersionMatch.Groups['version'].Value -ne $appVersion) { + throw "app/core/config.py 版本与 $appVersion 不一致。" +} + +$pythonVersion = $appVersion.Substring(1) +$pyprojectText = Get-Content -LiteralPath $pyprojectFile -Raw +$pyprojectVersionMatch = [regex]::Match( + $pyprojectText, + '(?m)^version\s*=\s*"(?[^"]+)"' +) +if (-not $pyprojectVersionMatch.Success -or $pyprojectVersionMatch.Groups['version'].Value -ne $pythonVersion) { + throw "pyproject.toml 版本与 $pythonVersion 不一致。" +} + +$expectedLockVersion = $pythonVersion ` + -replace '-alpha\.', 'a' ` + -replace '-beta\.', 'b' ` + -replace '-rc\.', 'rc' +$uvLockText = Get-Content -LiteralPath $uvLockFile -Raw +$uvVersionMatch = [regex]::Match( + $uvLockText, + '(?ms)^\[\[package\]\]\r?\nname = "auto-mas"\r?\nversion = "(?[^"]+)"' +) +if (-not $uvVersionMatch.Success -or $uvVersionMatch.Groups['version'].Value -ne $expectedLockVersion) { + throw "uv.lock 中 auto-mas 的版本与 $expectedLockVersion 不一致,请先运行 uv lock。" +} + +$workflowText = Get-Content -LiteralPath $buildWorkflowFile -Raw +$runtimeVersionMatch = [regex]::Match( + $workflowText, + '(?m)^\s*RUNTIME_VERSION:\s*["'']?(?v[0-9A-Za-z.-]+)["'']?\s*$' +) +if (-not $runtimeVersionMatch.Success) { + throw "无法从 .github/workflows/build-app.yml 读取 RUNTIME_VERSION。" +} +$runtimeVersion = $runtimeVersionMatch.Groups['version'].Value + +Write-Host "应用版本:$appVersion" +Write-Host "Runtime 版本:$runtimeVersion" + +# 第二步:使用指定的本地 Runtime,或下载并校验官方 Release。 +$temporaryRoot = Join-Path ( + [System.IO.Path]::GetTempPath() +) "auto-mas-local-package-$([guid]::NewGuid().ToString('N'))" +$runtimeAssetName = "auto-mas-runtime-$runtimeVersion.exe" +$runtimePath = Join-Path $temporaryRoot $runtimeAssetName +$checksumPath = Join-Path $temporaryRoot "SHA256SUMS.txt" +$releaseBaseUrl = "https://github.com/AUTO-MAS-Project/AUTO-MAS-Runtime/releases/download/$runtimeVersion" + +New-Item -ItemType Directory -Path $temporaryRoot | Out-Null + +$savedEnvironment = @{} +foreach ($name in @( + "SENTRY_AUTH_TOKEN", + "SENTRY_ORG", + "SENTRY_PROJECT", + "CSC_IDENTITY_AUTO_DISCOVERY" + )) { + $savedEnvironment[$name] = [Environment]::GetEnvironmentVariable($name, "Process") +} + +try { + if ($LocalRuntimePath) { + $localRuntime = (Resolve-Path -LiteralPath $LocalRuntimePath -ErrorAction Stop).Path + $expectedRuntimeHash = (Get-FileHash -LiteralPath $localRuntime -Algorithm SHA256).Hash + Copy-Item -LiteralPath $localRuntime -Destination $runtimePath + } else { + Write-Host "正在下载 Runtime……" + Invoke-WebRequest -Uri "$releaseBaseUrl/$runtimeAssetName" -OutFile $runtimePath + Invoke-WebRequest -Uri "$releaseBaseUrl/SHA256SUMS.txt" -OutFile $checksumPath + + $checksumLine = Select-String ` + -LiteralPath $checksumPath ` + -Pattern ([regex]::Escape($runtimeAssetName)) | + Select-Object -First 1 + if (-not $checksumLine) { + throw "SHA256SUMS.txt 中找不到 $runtimeAssetName。" + } + + $expectedRuntimeHash = ($checksumLine.Line -split '\s+')[0].ToUpperInvariant() + } + $actualRuntimeHash = (Get-FileHash -LiteralPath $runtimePath -Algorithm SHA256).Hash.ToUpperInvariant() + if ($actualRuntimeHash -ne $expectedRuntimeHash) { + throw "Runtime SHA-256 校验失败,文件可能下载损坏。" + } + + $runtimeVersionOutput = @(& $runtimePath --output ndjson --protocol 1 version) + if ($LASTEXITCODE -ne 0) { + throw "Runtime 版本检查失败(退出码 $LASTEXITCODE)。" + } + $runtimeVersionOutput | ForEach-Object { Write-Host $_ } + + $runtimeHello = $runtimeVersionOutput | + ForEach-Object { $_ | ConvertFrom-Json } | + Where-Object { $_.type -eq "hello" } | + Select-Object -First 1 + if (-not $runtimeHello -or (-not $LocalRuntimePath -and $runtimeHello.runtimeVersion -ne $runtimeVersion)) { + throw "Runtime 实际版本与 $runtimeVersion 不一致。" + } + + # 用非法版本做只读协议探测,避免旧 Runtime 与新桌面包组合后无法启动。 + foreach ($probe in @( + @{ Arguments = @('workspace', 'stage', '--version', 'invalid') }, + @{ Arguments = @('bootstrap', '--version', 'invalid', '--if-needed') } + )) { + $probeArguments = $probe.Arguments + $probeOutput = @(& $runtimePath --app-root $temporaryRoot --output ndjson --protocol 1 @probeArguments) + $probeExit = $LASTEXITCODE + $probeResult = $probeOutput | ForEach-Object { $_ | ConvertFrom-Json } | + Where-Object { $_.type -eq 'result' } | Select-Object -Last 1 + if ($probeExit -ne 2 -or -not $probeResult -or $probeResult.code -ne 'INVALID_VERSION') { + throw 'Runtime 不支持后台更新启动协议,请用 -LocalRuntimePath 指定本次源码构建的 Runtime,或先更新发布版本。' + } + } + if ($VerifyRuntimeOnly) { + Write-Host "Runtime 后台更新协议与 SHA-256 已验证:$expectedRuntimeHash" + exit 0 + } + + # 第三步:正常构建 Electron,再把 Runtime 注入解压包并重建安装程序。 + Push-Location $frontendRoot + try { + if (-not $SkipInstall) { + Invoke-NativeCommand -Command "yarn" -Arguments @("install", "--immutable") + } + + [Environment]::SetEnvironmentVariable("SENTRY_AUTH_TOKEN", $null, "Process") + [Environment]::SetEnvironmentVariable("SENTRY_ORG", $null, "Process") + [Environment]::SetEnvironmentVariable("SENTRY_PROJECT", $null, "Process") + [Environment]::SetEnvironmentVariable("CSC_IDENTITY_AUTO_DISCOVERY", "false", "Process") + + Invoke-NativeCommand -Command "yarn" -Arguments @("build") + + $unpackedDirectory = Join-Path $frontendRoot "dist\win-unpacked" + $runtimeTarget = Join-Path $unpackedDirectory "resources\auto-mas-runtime.exe" + if (-not (Test-Path -LiteralPath $unpackedDirectory -PathType Container)) { + throw "Electron 未生成 win-unpacked:$unpackedDirectory" + } + + New-Item -ItemType Directory -Path (Split-Path -Parent $runtimeTarget) -Force | Out-Null + Copy-Item -LiteralPath $runtimePath -Destination $runtimeTarget -Force + + $packagedRuntimeHash = (Get-FileHash -LiteralPath $runtimeTarget -Algorithm SHA256).Hash + if ($packagedRuntimeHash -ne $expectedRuntimeHash) { + throw "注入后的 Runtime SHA-256 校验失败。" + } + + Invoke-NativeCommand ` + -Command "yarn" ` + -Arguments @( + "electron-builder", + "--prepackaged", + "dist/win-unpacked", + "--win", + "nsis", + "--publish", + "never" + ) + } finally { + Pop-Location + } + + # 第四步:复制到全新的时间戳目录,避免旧包运行数据或 ACL 影响下次打包。 + $installerName = "AUTO-MAS Setup $pythonVersion.exe" + $installerPath = Join-Path $frontendRoot "dist\$installerName" + $unpackedDirectory = Join-Path $frontendRoot "dist\win-unpacked" + $sevenZipPath = Join-Path $frontendRoot "node_modules\7zip-bin\win\x64\7za.exe" + + foreach ($artifact in @($installerPath, $unpackedDirectory, $sevenZipPath)) { + if (-not (Test-Path -LiteralPath $artifact)) { + throw "缺少打包产物:$artifact" + } + } + + $installerListing = @(& $sevenZipPath l -slt $installerPath) + if ($LASTEXITCODE -ne 0) { + throw "无法检查安装程序内容(退出码 $LASTEXITCODE)。" + } + $installerContainsRuntime = $installerListing | + Where-Object { $_ -eq 'Path = resources\auto-mas-runtime.exe' } | + Select-Object -First 1 + if (-not $installerContainsRuntime) { + throw "安装程序中未找到 resources\auto-mas-runtime.exe。" + } + + $timestamp = Get-Date -Format "yyyyMMdd-HHmmss" + $outputRoot = Join-Path $repoRoot "dist\AUTO-MAS-$appVersion-local-$timestamp" + $outputUnpacked = Join-Path $outputRoot "win-unpacked" + $outputInstaller = Join-Path $outputRoot $installerName + + New-Item -ItemType Directory -Path $outputRoot | Out-Null + Copy-Item -LiteralPath $unpackedDirectory -Destination $outputUnpacked -Recurse + Copy-Item -LiteralPath $installerPath -Destination $outputInstaller + + $blockmapPath = "$installerPath.blockmap" + if (Test-Path -LiteralPath $blockmapPath -PathType Leaf) { + Copy-Item -LiteralPath $blockmapPath -Destination $outputRoot + } + + $outputRuntime = Join-Path $outputUnpacked "resources\auto-mas-runtime.exe" + $outputApp = Get-Item -LiteralPath (Join-Path $outputUnpacked "AUTO-MAS.exe") + if ($outputApp.VersionInfo.FileVersion -ne $pythonVersion) { + throw "AUTO-MAS.exe 文件版本不正确:$($outputApp.VersionInfo.FileVersion)" + } + if ((Get-FileHash -LiteralPath $outputRuntime -Algorithm SHA256).Hash -ne $expectedRuntimeHash) { + throw "最终测试目录中的 Runtime SHA-256 校验失败。" + } + + $installerHash = (Get-FileHash -LiteralPath $outputInstaller -Algorithm SHA256).Hash + + Write-Host "" + Write-Host "本地测试包已生成:" -ForegroundColor Green + Write-Host "输出目录:$outputRoot" + Write-Host "安装程序:$outputInstaller" + Write-Host "解压运行:$(Join-Path $outputUnpacked 'AUTO-MAS.exe')" + Write-Host "安装包 SHA-256:$installerHash" + Write-Host "Runtime SHA-256:$expectedRuntimeHash" +} finally { + foreach ($name in $savedEnvironment.Keys) { + [Environment]::SetEnvironmentVariable($name, $savedEnvironment[$name], "Process") + } + + if (Test-Path -LiteralPath $temporaryRoot) { + Remove-Item -LiteralPath $temporaryRoot -Recurse -Force + } +}