From f23d786ab3bf1998f556f5fe05b6e47467a7ea48 Mon Sep 17 00:00:00 2001 From: Xinyu YANG Date: Sun, 19 Jul 2026 02:22:53 +0800 Subject: [PATCH 1/2] feat: unify Install detection and primary action --- src-tauri/src/commands/install.rs | 24 +- src-tauri/src/lib.rs | 3 +- src-tauri/src/problem.rs | 19 +- src-tauri/src/services/install/mod.rs | 190 +++++++-- src-tauri/src/services/install/operation.rs | 12 +- src-tauri/src/services/install/types.rs | 14 +- src/api/commandClient.dispatch.test.ts | 41 +- src/api/previewCommands.test.ts | 4 + src/api/previewDefaults.ts | 5 +- src/api/problems.ts | 6 +- src/features/history/runDetailProblems.ts | 2 +- src/features/install/InstallActionsPanel.tsx | 84 ++-- src/features/install/InstallProblemBanner.tsx | 32 ++ src/features/install/InstallStatusPanel.tsx | 29 +- src/features/install/installPageState.test.ts | 228 +++++++++++ src/features/install/installPageState.ts | 132 ++++++ src/features/install/installProblems.test.ts | 89 ++++ src/features/install/installProblems.ts | 107 +++++ src/features/install/useInstallPage.ts | 379 +++++++++--------- src/features/shared/errors.ts | 51 --- src/features/shared/useAsyncAction.test.ts | 23 -- src/i18n/messages.ts | 51 +++ src/pages/Install.tsx | 67 +++- src/types/generated/commands.ts | 8 +- 24 files changed, 1195 insertions(+), 405 deletions(-) create mode 100644 src/features/install/InstallProblemBanner.tsx create mode 100644 src/features/install/installPageState.test.ts create mode 100644 src/features/install/installPageState.ts create mode 100644 src/features/install/installProblems.test.ts create mode 100644 src/features/install/installProblems.ts diff --git a/src-tauri/src/commands/install.rs b/src-tauri/src/commands/install.rs index 6983db0f..5cb6154c 100644 --- a/src-tauri/src/commands/install.rs +++ b/src-tauri/src/commands/install.rs @@ -2,6 +2,7 @@ pub use crate::services::install::*; use tauri_plugin_dialog::DialogExt; +use crate::problem::SemanticProblem; use crate::services::{ install::{ build_install_state, install, launch_game_via_steam, run_reset_bepinex, run_reset_bpp_data, @@ -17,7 +18,7 @@ pub fn get_install_state( app: tauri::AppHandle, state: tauri::State<'_, InstallerContextState>, game_path: Option, -) -> Result { +) -> Result { build_install_state(app, state, game_path) } @@ -25,11 +26,14 @@ pub fn get_install_state( #[specta::specta] pub async fn choose_game_directory( app: tauri::AppHandle, -) -> Result { +) -> Result { let folder = tauri::async_runtime::spawn_blocking(move || app.dialog().file().blocking_pick_folder()) .await - .map_err(|err| format!("failed to open game directory picker: {err}"))?; + .map_err(|err| format!("failed to open game directory picker: {err}")) + .map_err(|diagnostic| { + crate::services::install::install_action_problem("choose_directory", diagnostic) + })?; let game_path = folder .and_then(|path| path.into_path().ok()) .map(|path| path.to_string_lossy().into_owned()); @@ -43,7 +47,7 @@ pub async fn install_mod( app: tauri::AppHandle, game_path: String, compat_opt_in: bool, -) -> Result { +) -> Result { install( app, InstallRequest { @@ -61,7 +65,7 @@ pub async fn reset_bpp_data( install_state: tauri::State<'_, InstallerContextState>, stream_runtime: tauri::State<'_, StreamRuntime>, game_path: String, -) -> Result { +) -> Result { run_reset_bpp_data(app, install_state, stream_runtime, game_path).await } @@ -71,7 +75,7 @@ pub async fn reset_bepinex( app: tauri::AppHandle, install_state: tauri::State<'_, InstallerContextState>, game_path: String, -) -> Result { +) -> Result { run_reset_bepinex(app, install_state, game_path).await } @@ -81,13 +85,15 @@ pub async fn uninstall_mod( app: tauri::AppHandle, state: tauri::State<'_, InstallerContextState>, game_path: String, -) -> Result { +) -> Result { run_uninstall(app, state, game_path).await } #[tauri::command(async)] #[specta::specta] -pub fn launch_game() -> Result { - launch_game_via_steam()?; +pub fn launch_game() -> Result { + launch_game_via_steam().map_err(|diagnostic| { + crate::services::install::install_action_problem("launch", diagnostic) + })?; Ok(FileActionResult { ok: true }) } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 5f73c7a6..3f21111e 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -6,7 +6,7 @@ mod services; mod stream; mod tray; -use tauri::{Emitter, Manager, WindowEvent}; +use tauri::{Manager, WindowEvent}; use services::startup::InstallerContextState; use tray::{build_tray, TrayMenuState}; @@ -49,7 +49,6 @@ pub fn run() { tauri::async_runtime::spawn_blocking(move || { let state = startup_handle.state::(); let _ = state.get_or_initialize(&startup_handle); - let _ = startup_handle.emit("startup-ready", ()); }); let app_handle = handle.clone(); tauri::async_runtime::spawn(async move { diff --git a/src-tauri/src/problem.rs b/src-tauri/src/problem.rs index 33414b5a..ed453daa 100644 --- a/src-tauri/src/problem.rs +++ b/src-tauri/src/problem.rs @@ -9,13 +9,14 @@ pub struct SemanticProblem { #[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize, specta::Type)] #[serde(rename_all = "snake_case")] -// The domain prefix is intentional: these names are the stable cross-language -// problem codes and must remain unambiguous as other feature domains are added. -#[allow(clippy::enum_variant_names)] pub enum SemanticProblemCode { HistoryUnavailable, HistoryReadFailed, HistoryActionFailed, + InstallDetectionFailed, + InstallActionFailed, + InstallGameRunning, + InstallPartialFailure, } impl SemanticProblem { @@ -68,5 +69,17 @@ mod tests { "diagnostic": null }) ); + + assert_eq!( + serde_json::to_value(SemanticProblem::new( + SemanticProblemCode::InstallDetectionFailed + )) + .unwrap(), + serde_json::json!({ + "code": "install_detection_failed", + "params": {}, + "diagnostic": null + }) + ); } } diff --git a/src-tauri/src/services/install/mod.rs b/src-tauri/src/services/install/mod.rs index b8d8de38..11964159 100644 --- a/src-tauri/src/services/install/mod.rs +++ b/src-tauri/src/services/install/mod.rs @@ -5,7 +5,8 @@ mod types; pub(crate) use operation::{install, InstallRequest}; pub use types::{ FileActionResult, GameDirectorySelection, InstallActions, InstallCompatState, InstallGameState, - InstallModState, InstallState, InstallWarning, ResetBepinexResult, ResetBppDataResult, + InstallModState, InstallState, InstallWarning, InstallWarningCode, ResetBepinexResult, + ResetBppDataResult, }; use std::process::Command; @@ -15,11 +16,18 @@ use tauri::Manager; use std::path::Path; use crate::services::{ - bepinex::{reset_bepinex_folder, reset_bpp_data, uninstall_bpp}, + bepinex::{ + reset_bepinex_folder, reset_bpp_data, uninstall_bpp, RESET_BEPINEX_ERR_GAME_RUNNING, + RESET_BEPINEX_ERR_PARTIAL_FAILURE, RESET_BPP_DATA_ERR_GAME_RUNNING, + RESET_BPP_DATA_ERR_PARTIAL_FAILURE, + }, detect::detect_for_install, startup::InstallerContextState, }; -use crate::stream::runtime::StreamRuntime; +use crate::{ + problem::{SemanticProblem, SemanticProblemCode}, + stream::runtime::StreamRuntime, +}; const STEAM_BAZAAR_URL: &str = "steam://rungameid/1617400"; @@ -27,6 +35,14 @@ pub fn build_install_state( app: tauri::AppHandle, state: tauri::State<'_, InstallerContextState>, game_path: Option, +) -> Result { + build_install_state_raw(app, state, game_path).map_err(install_detection_problem) +} + +pub(super) fn build_install_state_raw( + app: tauri::AppHandle, + state: tauri::State<'_, InstallerContextState>, + game_path: Option, ) -> Result { let snapshot = detect_for_install(app, state, game_path)?; Ok(install_state_from_snapshot(snapshot)) @@ -37,8 +53,10 @@ pub async fn run_reset_bpp_data( install_state: tauri::State<'_, InstallerContextState>, stream_runtime: tauri::State<'_, StreamRuntime>, game_path: String, -) -> Result { - let removed_data = reset_bpp_data(stream_runtime, game_path.clone()).await?; +) -> Result { + let removed_data = reset_bpp_data(stream_runtime, game_path.clone()) + .await + .map_err(|diagnostic| install_action_problem("reset_bpp_data", diagnostic))?; let state = build_install_state(app, install_state, Some(game_path))?; Ok(ResetBppDataResult { state, @@ -50,8 +68,10 @@ pub async fn run_reset_bepinex( app: tauri::AppHandle, install_state: tauri::State<'_, InstallerContextState>, game_path: String, -) -> Result { - let removed = reset_bepinex_folder(game_path.clone()).await?; +) -> Result { + let removed = reset_bepinex_folder(game_path.clone()) + .await + .map_err(|diagnostic| install_action_problem("reset_bepinex", diagnostic))?; let state = build_install_state(app, install_state, Some(game_path))?; Ok(ResetBepinexResult { state, removed }) } @@ -60,8 +80,9 @@ pub async fn run_uninstall( app: tauri::AppHandle, state: tauri::State<'_, InstallerContextState>, game_path: String, -) -> Result { - let before = detect_for_install(app.clone(), state, Some(game_path.clone()))?; +) -> Result { + let before = detect_for_install(app.clone(), state, Some(game_path.clone())) + .map_err(install_detection_problem)?; let app_for_task = app.clone(); let steam_path = before.steam_path.clone().unwrap_or_default(); let game_path_for_task = game_path.clone(); @@ -69,7 +90,10 @@ pub async fn run_uninstall( uninstall_bpp(app_for_task, steam_path, game_path_for_task) }) .await - .map_err(|err| format!("failed to run uninstall task: {err}"))??; + .map_err(|err| { + install_action_problem("uninstall", format!("failed to run uninstall task: {err}")) + })? + .map_err(|diagnostic| install_action_problem("uninstall", diagnostic))?; let app_for_state = app.clone(); let state = app_for_state.state::(); @@ -103,26 +127,12 @@ fn install_state_from_snapshot( let can_launch = game_found && env.game_path_valid; let has_resettable_data = has_resettable_bpp_data(env.game_path.as_deref()); let has_bepinex_files = has_bepinex_directory(env.game_path.as_deref()); - let mut warnings = Vec::new(); - if !game_found || !env.game_path_valid { - warnings.push(InstallWarning { - code: "game_missing".to_string(), - message: "未找到有效的 The Bazaar 安装目录。".to_string(), - }); - } - if !env.steam_launch_options_supported { - warnings.push(InstallWarning { - code: "launch_options_unsupported".to_string(), - message: "当前平台或 Steam 目录不支持自动写入启动项。".to_string(), - }); - } - if needs_trampoline_repair { - warnings.push(InstallWarning { - code: "trampoline_reverted".to_string(), - message: "检测到游戏文件已被还原,BazaarPlusPlus 的启动配置需要修复,请点击重新安装。" - .to_string(), - }); - } + let warnings = install_warnings( + game_found, + env.game_path_valid, + env.steam_launch_options_supported, + needs_trampoline_repair, + ); InstallState { selected_game_path, @@ -159,6 +169,71 @@ fn install_state_from_snapshot( } } +fn install_warnings( + game_found: bool, + game_path_valid: bool, + steam_launch_options_supported: bool, + needs_trampoline_repair: bool, +) -> Vec { + let mut warnings = Vec::new(); + if !game_found || !game_path_valid { + warnings.push(InstallWarning { + code: InstallWarningCode::GameMissing, + params: Default::default(), + }); + } + if !steam_launch_options_supported { + warnings.push(InstallWarning { + code: InstallWarningCode::LaunchOptionsUnsupported, + params: Default::default(), + }); + } + if needs_trampoline_repair { + warnings.push(InstallWarning { + code: InstallWarningCode::TrampolineReverted, + params: Default::default(), + }); + } + warnings +} + +fn install_detection_problem(diagnostic: String) -> SemanticProblem { + SemanticProblem::new(SemanticProblemCode::InstallDetectionFailed) + .with_param("operation", "detect_state") + .with_diagnostic(diagnostic) +} + +pub(crate) fn install_action_problem(operation: &str, diagnostic: String) -> SemanticProblem { + if diagnostic == RESET_BPP_DATA_ERR_GAME_RUNNING || diagnostic == RESET_BEPINEX_ERR_GAME_RUNNING + { + return SemanticProblem::new(SemanticProblemCode::InstallGameRunning) + .with_param("operation", operation); + } + + for prefix in [ + RESET_BPP_DATA_ERR_PARTIAL_FAILURE, + RESET_BEPINEX_ERR_PARTIAL_FAILURE, + ] { + if let Some(paths) = diagnostic + .strip_prefix(prefix) + .and_then(|remainder| remainder.strip_prefix(':')) + { + let count = paths + .split('\u{1f}') + .filter(|path| !path.trim().is_empty()) + .count(); + return SemanticProblem::new(SemanticProblemCode::InstallPartialFailure) + .with_param("operation", operation) + .with_param("count", count.to_string()) + .with_param("paths", paths); + } + } + + SemanticProblem::new(SemanticProblemCode::InstallActionFailed) + .with_param("operation", operation) + .with_diagnostic(diagnostic) +} + fn has_resettable_bpp_data(game_path: Option<&str>) -> bool { game_path .map(Path::new) @@ -204,7 +279,14 @@ fn open_url(url: &str) -> Result<(), String> { #[cfg(test)] mod tests { - use super::{has_bepinex_directory, has_resettable_bpp_data}; + use super::{ + has_bepinex_directory, has_resettable_bpp_data, install_action_problem, install_warnings, + InstallWarningCode, + }; + use crate::problem::SemanticProblemCode; + use crate::services::bepinex::{ + RESET_BEPINEX_ERR_GAME_RUNNING, RESET_BPP_DATA_ERR_PARTIAL_FAILURE, + }; #[test] fn test_has_resettable_bpp_data_detects_existing_data_directory() { @@ -235,4 +317,50 @@ mod tests { assert!(has_bepinex_directory(Some(path.as_str()))); assert!(!has_bepinex_directory(None)); } + + #[test] + fn install_warnings_are_semantic_codes_without_backend_copy() { + let warnings = install_warnings(false, false, false, true); + + assert_eq!( + warnings + .iter() + .map(|warning| warning.code) + .collect::>(), + vec![ + InstallWarningCode::GameMissing, + InstallWarningCode::LaunchOptionsUnsupported, + InstallWarningCode::TrampolineReverted, + ] + ); + assert!(warnings.iter().all(|warning| warning.params.is_empty())); + } + + #[test] + fn install_failures_classify_known_reset_conditions_and_generic_actions() { + let blocked = + install_action_problem("reset_bepinex", RESET_BEPINEX_ERR_GAME_RUNNING.to_string()); + assert_eq!(blocked.code, SemanticProblemCode::InstallGameRunning); + assert_eq!( + blocked.params.get("operation").map(String::as_str), + Some("reset_bepinex") + ); + assert_eq!(blocked.diagnostic, None); + + let partial = install_action_problem( + "reset_bpp_data", + format!("{RESET_BPP_DATA_ERR_PARTIAL_FAILURE}:/tmp/a\u{1f}/tmp/b"), + ); + assert_eq!(partial.code, SemanticProblemCode::InstallPartialFailure); + assert_eq!(partial.params.get("count").map(String::as_str), Some("2")); + assert_eq!( + partial.params.get("paths").map(String::as_str), + Some("/tmp/a\u{1f}/tmp/b") + ); + assert_eq!(partial.diagnostic, None); + + let generic = install_action_problem("install", "permission denied".to_string()); + assert_eq!(generic.code, SemanticProblemCode::InstallActionFailed); + assert_eq!(generic.diagnostic.as_deref(), Some("permission denied")); + } } diff --git a/src-tauri/src/services/install/operation.rs b/src-tauri/src/services/install/operation.rs index 916c0133..e3a609cc 100644 --- a/src-tauri/src/services/install/operation.rs +++ b/src-tauri/src/services/install/operation.rs @@ -3,7 +3,8 @@ use std::path::Path; use tauri::Manager; use super::plan::{plan_install, InstallEffect, InstallPlanInputs, PayloadState}; -use super::{build_install_state, InstallState}; +use super::{build_install_state_raw, install_action_problem, InstallState}; +use crate::problem::SemanticProblem; use crate::services::{ bepinex::{self, install_bepinex}, detect::detect_for_install, @@ -36,7 +37,7 @@ fn classify_payload( pub(crate) async fn install( app: tauri::AppHandle, request: InstallRequest, -) -> Result { +) -> Result { let task_app = app.clone(); tauri::async_runtime::spawn_blocking(move || { let installer_state = task_app.state::(); @@ -70,11 +71,14 @@ pub(crate) async fn install( execute_and_refresh(facts, &mut effects, || { let installer_state = task_app.state::(); - build_install_state(task_app.clone(), installer_state, Some(request.game_path)) + build_install_state_raw(task_app.clone(), installer_state, Some(request.game_path)) }) }) .await - .map_err(|error| format!("failed to run install task: {error}"))? + .map_err(|error| { + install_action_problem("install", format!("failed to run install task: {error}")) + })? + .map_err(|diagnostic| install_action_problem("install", diagnostic)) } trait InstallEffects { diff --git a/src-tauri/src/services/install/types.rs b/src-tauri/src/services/install/types.rs index 4da4f33d..c5d4e5db 100644 --- a/src-tauri/src/services/install/types.rs +++ b/src-tauri/src/services/install/types.rs @@ -1,3 +1,5 @@ +use std::collections::BTreeMap; + use serde::Serialize; #[derive(Clone, Debug, Serialize, specta::Type)] @@ -68,10 +70,18 @@ pub struct InstallActions { pub can_launch: bool, } +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, specta::Type)] +#[serde(rename_all = "snake_case")] +pub enum InstallWarningCode { + GameMissing, + LaunchOptionsUnsupported, + TrampolineReverted, +} + #[derive(Clone, Debug, Serialize, specta::Type)] pub struct InstallWarning { - pub code: String, - pub message: String, + pub code: InstallWarningCode, + pub params: BTreeMap, } #[derive(Clone, Debug, Serialize, specta::Type)] diff --git a/src/api/commandClient.dispatch.test.ts b/src/api/commandClient.dispatch.test.ts index 2cbf59a8..dbda6dd0 100644 --- a/src/api/commandClient.dispatch.test.ts +++ b/src/api/commandClient.dispatch.test.ts @@ -1,6 +1,5 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { invoke } from '@tauri-apps/api/core'; -import { parseResetBppDataError } from '../features/shared/errors'; vi.mock('@tauri-apps/api/core', () => ({ invoke: vi.fn() })); const invokeMock = vi.mocked(invoke); @@ -27,11 +26,13 @@ describe('native command adapter', () => { it('normalizes string rejections from generated commands', async () => { vi.stubGlobal('window', { __TAURI_INTERNALS__: {} }); - invokeMock.mockRejectedValueOnce('bpp_data_reset_blocked_by_game'); + invokeMock.mockRejectedValueOnce('raw backend failure'); const { commandClient } = await import('./commandClient'); - await expect(commandClient.resetBppData('/game')).rejects.toMatchObject({ - message: 'bpp_data_reset_blocked_by_game' + await expect( + commandClient.deleteRunVideos('run', null) + ).rejects.toMatchObject({ + message: 'raw backend failure' }); }); @@ -52,6 +53,27 @@ describe('native command adapter', () => { }); expect(invokeMock).toHaveBeenCalledWith('list_history_runs', { limit: 50 }); }); + + it('preserves semantic install recovery parameters', async () => { + vi.stubGlobal('window', { __TAURI_INTERNALS__: {} }); + const problem = { + code: 'install_partial_failure', + params: { + operation: 'reset_bpp_data', + count: '2', + paths: '/tmp/a\u001f/tmp/b' + }, + diagnostic: null + }; + invokeMock.mockRejectedValueOnce(problem); + const { commandClient } = await import('./commandClient'); + + await expect(commandClient.resetBppData('/game')).rejects.toMatchObject({ + name: 'SemanticProblemError', + message: 'install_partial_failure', + problem + }); + }); }); describe('normalizeBackendError sentinel contract', () => { @@ -78,15 +100,4 @@ describe('normalizeBackendError sentinel contract', () => { }).message ).toBe('Backend command failed.'); }); - - it('feeds reset partial-failure parsing end to end', async () => { - const { normalizeBackendError } = await import('./nativeCommands'); - const parsed = parseResetBppDataError( - normalizeBackendError('bpp_data_reset_partial_failure:a\u001fb') - ); - expect(parsed).toMatchObject({ - code: 'partial_failure', - paths: ['a', 'b'] - }); - }); }); diff --git a/src/api/previewCommands.test.ts b/src/api/previewCommands.test.ts index 49f352f0..47615fe6 100644 --- a/src/api/previewCommands.test.ts +++ b/src/api/previewCommands.test.ts @@ -108,6 +108,10 @@ describe('browser-preview command adapter', () => { expect(emptyInstallState.selected_game_path).toBeNull(); expect(emptyInstallState.has_resettable_data).toBe(false); expect(emptyInstallState.has_bepinex_files).toBe(false); + expect(emptyInstallState.warnings.map((warning) => warning.code)).toEqual([ + 'game_missing', + 'launch_options_unsupported' + ]); }); it('passes native-only preview commands through to the normalized client', async () => { diff --git a/src/api/previewDefaults.ts b/src/api/previewDefaults.ts index 6a8e9091..f23fe3bf 100644 --- a/src/api/previewDefaults.ts +++ b/src/api/previewDefaults.ts @@ -46,7 +46,10 @@ export const emptyInstallState: InstallState = { }, has_resettable_data: false, has_bepinex_files: false, - warnings: [] + warnings: [ + { code: 'game_missing', params: {} }, + { code: 'launch_options_unsupported', params: {} } + ] }; export const idleStreamStatus: StreamServiceStatus = { diff --git a/src/api/problems.ts b/src/api/problems.ts index 28b1cc59..35dd61f3 100644 --- a/src/api/problems.ts +++ b/src/api/problems.ts @@ -3,7 +3,11 @@ import type { SemanticProblem } from '../types/backend'; const semanticProblemCodes: Record = { history_unavailable: true, history_read_failed: true, - history_action_failed: true + history_action_failed: true, + install_detection_failed: true, + install_action_failed: true, + install_game_running: true, + install_partial_failure: true }; export class SemanticProblemError extends Error { diff --git a/src/features/history/runDetailProblems.ts b/src/features/history/runDetailProblems.ts index 95974936..30253c82 100644 --- a/src/features/history/runDetailProblems.ts +++ b/src/features/history/runDetailProblems.ts @@ -21,7 +21,7 @@ export function runDetailProblemFromError(error: unknown): RunDetailProblem { case 'history_read_failed': case 'history_action_failed': case 'run_detail_unexpected': - return problem; + return problem as RunDetailProblem; default: return createUiProblem('run_detail_unexpected', { params: problem.params, diff --git a/src/features/install/InstallActionsPanel.tsx b/src/features/install/InstallActionsPanel.tsx index 379e8c1e..10ed06f6 100644 --- a/src/features/install/InstallActionsPanel.tsx +++ b/src/features/install/InstallActionsPanel.tsx @@ -2,6 +2,7 @@ import { AlertCircle, AlertTriangle, DownloadCloud, + FolderOpen, FolderX, Loader2, Play, @@ -13,18 +14,24 @@ import { InstallActionButton } from './InstallActionButton'; import { InstallFactItem } from './InstallFactItem'; import { ResetDataFailureDetails } from './ResetDataFailureDetails'; import { useI18n } from '../../i18n/LocaleProvider'; +import type { InstallState } from '../../types/backend'; +import type { InstallPrimaryAction } from './installPageState'; +import { presentInstallWarning } from './installProblems'; +import { InstallProblemBanner } from './InstallProblemBanner'; type InstallPage = ReturnType; export function InstallActionsPanel({ page, - primaryMode, + state, + primaryAction, onOpenInstallModal, onOpenResetDataModal, onOpenResetBepinexModal }: { page: InstallPage; - primaryMode: 'install' | 'reinstall' | 'launch'; + state: InstallState; + primaryAction: InstallPrimaryAction; onOpenInstallModal: () => void; onOpenResetDataModal: () => void; onOpenResetBepinexModal: () => void; @@ -41,7 +48,7 @@ export function InstallActionsPanel({
@@ -52,38 +59,39 @@ export function InstallActionsPanel({ - {page.state.warnings.length > 0 && ( + {state.warnings.length > 0 && (
- {page.state.warnings.map((warning) => ( + {state.warnings.map((warning) => (

- {warning.message} + {presentInstallWarning(warning, t)}

))}
)} - {(page.error || page.message) && ( + {page.actionProblem && ( + + )} + {page.message && (

- {page.error ?? page.message} + {page.message}

)} {page.resetDataFailurePaths.length > 0 && ( @@ -94,19 +102,19 @@ export function InstallActionsPanel({
} label={ - page.state.game.path_valid && !page.state.has_resettable_data + state.game.path_valid && !state.has_resettable_data ? t('actionNoResettableData') : t('actionResetData') } danger /> } @@ -114,7 +122,7 @@ export function InstallActionsPanel({ danger /> } @@ -132,24 +140,24 @@ export function InstallActionsPanel({ function PrimaryActionButton({ page, - primaryMode, + primaryAction, onOpenInstallModal }: { page: InstallPage; - primaryMode: 'install' | 'reinstall' | 'launch'; + primaryAction: InstallPrimaryAction; onOpenInstallModal: () => void; }) { const { t } = useI18n(); - if (primaryMode === 'launch') { + if (primaryAction.mode === 'launch') { return (
+ ); + } + + if (primaryAction.mode === 'choose-directory') { + return ( + ); } @@ -181,11 +207,11 @@ function PrimaryActionButton({ return ( + ) : undefined + } + /> + ); +} diff --git a/src/features/install/InstallStatusPanel.tsx b/src/features/install/InstallStatusPanel.tsx index bbb50715..a1556b62 100644 --- a/src/features/install/InstallStatusPanel.tsx +++ b/src/features/install/InstallStatusPanel.tsx @@ -2,10 +2,19 @@ import { FolderOpen } from 'lucide-react'; import type { useInstallPage } from './useInstallPage'; import { InstallStatusCard } from './InstallStatusCard'; import { useI18n } from '../../i18n/LocaleProvider'; +import type { InstallState } from '../../types/backend'; type InstallPage = ReturnType; -export function InstallStatusPanel({ page }: { page: InstallPage }) { +export function InstallStatusPanel({ + page, + state, + status +}: { + page: InstallPage; + state: InstallState; + status: NonNullable; +}) { const { t } = useI18n(); return (
@@ -18,18 +27,16 @@ export function InstallStatusPanel({ page }: { page: InstallPage }) {
@@ -46,9 +53,9 @@ export function InstallStatusPanel({ page }: { page: InstallPage }) { /> - {page.state.selected_game_path ?? t('gamePathEmpty')} + {state.selected_game_path ?? t('gamePathEmpty')}
diff --git a/src/features/install/installPageState.test.ts b/src/features/install/installPageState.test.ts new file mode 100644 index 00000000..f1c62fda --- /dev/null +++ b/src/features/install/installPageState.test.ts @@ -0,0 +1,228 @@ +import { describe, expect, it } from 'vitest'; +import type { InstallState } from '../../types/backend'; +import { createUiProblem } from '../shared/problems'; +import { + deriveInstallPrimaryAction, + initialInstallPageState, + reduceInstallPageState +} from './installPageState'; + +function installState(overrides: Partial = {}): InstallState { + return { + selected_game_path: '/Applications/The Bazaar', + steam_path: '/Applications/Steam', + steam_launch_options_supported: true, + game: { found: true, path_valid: true, display_version: null }, + mod_state: { + installed: false, + installed_version: null, + bundled_version: '4.5.0', + version_matches: false + }, + compat: { + mode_available: true, + forced: false, + desired: false, + applied: false + }, + actions: { + can_install: true, + can_reinstall: false, + can_reset_data: false, + can_reset_bepinex: false, + can_uninstall: false, + can_launch: true + }, + has_resettable_data: false, + has_bepinex_files: false, + warnings: [], + ...overrides + }; +} + +describe('Install detection page state', () => { + it('stays in explicit initial detection until a native result completes', () => { + expect(initialInstallPageState).toEqual({ + phase: 'initial-loading', + requestId: 0 + }); + + expect( + reduceInstallPageState(initialInstallPageState, { + type: 'request-started', + requestId: 1 + }) + ).toEqual({ phase: 'initial-loading', requestId: 1 }); + + expect( + reduceInstallPageState( + { phase: 'initial-loading', requestId: 1 }, + { type: 'request-succeeded', requestId: 1, data: installState() } + ) + ).toMatchObject({ phase: 'ready', refresh: { phase: 'idle' } }); + }); + + it('retains the last valid detection through refresh failure and recovery', () => { + const data = installState(); + const problem = createUiProblem('install_detection_failed', { + params: { operation: 'detect_state' }, + diagnostic: 'probe failed' + }); + const ready = reduceInstallPageState( + { phase: 'initial-loading', requestId: 1 }, + { type: 'request-succeeded', requestId: 1, data } + ); + const refreshing = reduceInstallPageState(ready, { + type: 'request-started', + requestId: 2 + }); + const failed = reduceInstallPageState(refreshing, { + type: 'request-failed', + requestId: 2, + problem + }); + + expect(failed).toMatchObject({ + phase: 'ready', + data, + refresh: { phase: 'failed', problem } + }); + const retrying = reduceInstallPageState(failed, { + type: 'request-started', + requestId: 3 + }); + expect(retrying).toMatchObject({ + phase: 'ready', + data, + refresh: { phase: 'refreshing' } + }); + expect( + reduceInstallPageState(retrying, { + type: 'request-succeeded', + requestId: 3, + data + }) + ).toMatchObject({ phase: 'ready', refresh: { phase: 'idle' } }); + }); +}); + +describe('Install primary action', () => { + it.each([ + { + name: 'invalid or missing path', + state: installState({ + selected_game_path: null, + game: { found: false, path_valid: false, display_version: null }, + actions: { + can_install: false, + can_reinstall: false, + can_reset_data: false, + can_reset_bepinex: false, + can_uninstall: false, + can_launch: false + } + }), + mode: 'choose-directory', + operation: 'choose' + }, + { + name: 'valid path without the mod', + state: installState(), + mode: 'install', + operation: 'install' + }, + { + name: 'installed version mismatch', + state: installState({ + mod_state: { + installed: true, + installed_version: '4.4.0', + bundled_version: '4.5.0', + version_matches: false + }, + actions: { + can_install: false, + can_reinstall: true, + can_reset_data: false, + can_reset_bepinex: true, + can_uninstall: true, + can_launch: true + } + }), + mode: 'repair', + operation: 'install' + }, + { + name: 'compatibility mode drift', + state: installState({ + mod_state: { + installed: true, + installed_version: '4.5.0', + bundled_version: '4.5.0', + version_matches: true + }, + compat: { + mode_available: false, + forced: true, + desired: true, + applied: false + }, + actions: { + can_install: false, + can_reinstall: true, + can_reset_data: false, + can_reset_bepinex: true, + can_uninstall: true, + can_launch: true + } + }), + mode: 'repair', + operation: 'install' + }, + { + name: 'current installed state', + state: installState({ + mod_state: { + installed: true, + installed_version: '4.5.0', + bundled_version: '4.5.0', + version_matches: true + }, + actions: { + can_install: false, + can_reinstall: true, + can_reset_data: false, + can_reset_bepinex: true, + can_uninstall: true, + can_launch: true + } + }), + mode: 'launch', + operation: 'launch' + } + ])('selects exactly one action for $name', ({ state, mode, operation }) => { + expect(deriveInstallPrimaryAction(state, null, false)).toMatchObject({ + mode, + operation, + disabled: false, + running: false + }); + }); + + it('derives visibility, disabled state, and loading from one operation model', () => { + expect( + deriveInstallPrimaryAction(installState(), 'install', false) + ).toEqual({ + mode: 'install', + operation: 'install', + disabled: true, + running: true + }); + expect(deriveInstallPrimaryAction(installState(), null, true)).toEqual({ + mode: 'install', + operation: 'install', + disabled: true, + running: false + }); + }); +}); diff --git a/src/features/install/installPageState.ts b/src/features/install/installPageState.ts new file mode 100644 index 00000000..e2097a41 --- /dev/null +++ b/src/features/install/installPageState.ts @@ -0,0 +1,132 @@ +import type { InstallState } from '../../types/backend'; +import type { PageRefreshState } from '../shared/pageState'; +import type { InstallProblem } from './installProblems'; + +export type InstallPageState = + | { phase: 'initial-loading'; requestId: number } + | { + phase: 'blocking-failure'; + requestId: number; + problem: InstallProblem; + } + | { + phase: 'ready'; + requestId: number; + data: InstallState; + refresh: PageRefreshState; + }; + +export type InstallPageEvent = + | { type: 'request-started'; requestId: number } + | { type: 'request-succeeded'; requestId: number; data: InstallState } + | { + type: 'request-failed'; + requestId: number; + problem: InstallProblem; + } + | { type: 'data-replaced'; data: InstallState }; + +export const initialInstallPageState: InstallPageState = { + phase: 'initial-loading', + requestId: 0 +}; + +export function reduceInstallPageState( + state: InstallPageState, + event: InstallPageEvent +): InstallPageState { + switch (event.type) { + case 'request-started': + if (state.phase === 'ready') { + return { + ...state, + requestId: event.requestId, + refresh: { phase: 'refreshing' } + }; + } + return { phase: 'initial-loading', requestId: event.requestId }; + case 'request-succeeded': + if (state.requestId !== event.requestId) return state; + return { + phase: 'ready', + requestId: event.requestId, + data: event.data, + refresh: { phase: 'idle' } + }; + case 'request-failed': + if (state.requestId !== event.requestId) return state; + if (state.phase === 'ready') { + return { + ...state, + refresh: { phase: 'failed', problem: event.problem } + }; + } + return { + phase: 'blocking-failure', + requestId: event.requestId, + problem: event.problem + }; + case 'data-replaced': + if (state.phase !== 'ready') return state; + return { ...state, data: event.data, refresh: { phase: 'idle' } }; + } +} + +export type InstallOperation = + | 'choose' + | 'install' + | 'resetData' + | 'resetBepinex' + | 'uninstall' + | 'launch'; + +export type InstallPrimaryActionMode = + | 'choose-directory' + | 'install' + | 'repair' + | 'launch'; + +export type InstallPrimaryAction = { + mode: InstallPrimaryActionMode; + operation: Extract; + disabled: boolean; + running: boolean; +}; + +export function deriveInstallPrimaryAction( + state: InstallState, + current: InstallOperation | null, + refreshing: boolean +): InstallPrimaryAction { + let mode: InstallPrimaryActionMode; + let operation: InstallPrimaryAction['operation']; + let allowed: boolean; + + if (!state.selected_game_path || !state.game.path_valid) { + mode = 'choose-directory'; + operation = 'choose'; + allowed = true; + } else if (!state.mod_state.installed) { + mode = 'install'; + operation = 'install'; + allowed = state.actions.can_install; + } else if ( + !state.mod_state.version_matches || + state.compat.desired !== state.compat.applied + ) { + mode = 'repair'; + operation = 'install'; + allowed = state.actions.can_reinstall; + } else { + mode = 'launch'; + operation = 'launch'; + allowed = state.actions.can_launch; + } + + return { + mode, + operation, + disabled: refreshing || current !== null || !allowed, + running: current === operation + }; +} diff --git a/src/features/install/installProblems.test.ts b/src/features/install/installProblems.test.ts new file mode 100644 index 00000000..6421ec80 --- /dev/null +++ b/src/features/install/installProblems.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from 'vitest'; +import { formatMessage } from '../../i18n/messages'; +import { createUiProblem } from '../shared/problems'; +import { + installFailurePaths, + presentInstallProblem, + presentInstallWarning +} from './installProblems'; + +const warnings = [ + { code: 'game_missing', params: {} }, + { code: 'launch_options_unsupported', params: {} }, + { code: 'trampoline_reverted', params: {} } +] as const; + +const problems = [ + createUiProblem('install_detection_failed', { + params: { operation: 'detect_state' } + }), + createUiProblem('install_action_failed', { + params: { operation: 'choose_directory' } + }), + createUiProblem('install_action_failed', { + params: { operation: 'install' } + }), + createUiProblem('install_action_failed', { + params: { operation: 'uninstall' } + }), + createUiProblem('install_action_failed', { + params: { operation: 'launch' } + }), + createUiProblem('install_game_running', { + params: { operation: 'reset_bpp_data' } + }), + createUiProblem('install_game_running', { + params: { operation: 'reset_bepinex' } + }), + createUiProblem('install_partial_failure', { + params: { operation: 'reset_bpp_data', count: '2' } + }), + createUiProblem('install_partial_failure', { + params: { operation: 'reset_bepinex', count: '2' } + }), + createUiProblem('install_unexpected') +] as const; + +describe('Install semantic presentation', () => { + it.each(warnings)( + 'localizes warning $code in Chinese and English', + (warning) => { + const zh = presentInstallWarning(warning, (key, params) => + formatMessage('zh', key, params) + ); + const en = presentInstallWarning(warning, (key, params) => + formatMessage('en', key, params) + ); + + expect(zh).not.toBe(warning.code); + expect(en).not.toBe(warning.code); + expect(en).not.toBe(zh); + } + ); + + it.each(problems)( + 'localizes problem $code $params with recovery copy', + (problem) => { + const zh = presentInstallProblem(problem, (key, params) => + formatMessage('zh', key, params) + ); + const en = presentInstallProblem(problem, (key, params) => + formatMessage('en', key, params) + ); + + expect(zh).not.toBe(problem.code); + expect(en).not.toBe(problem.code); + expect(en).not.toBe(zh); + } + ); + + it('recovers partial-failure paths from semantic parameters', () => { + expect( + installFailurePaths( + createUiProblem('install_partial_failure', { + params: { paths: '/tmp/a\u001f/tmp/b', count: '2' } + }) + ) + ).toEqual(['/tmp/a', '/tmp/b']); + }); +}); diff --git a/src/features/install/installProblems.ts b/src/features/install/installProblems.ts new file mode 100644 index 00000000..1c232f28 --- /dev/null +++ b/src/features/install/installProblems.ts @@ -0,0 +1,107 @@ +import type { Translate } from '../../i18n/LocaleProvider'; +import type { MessageKey } from '../../i18n/messages'; +import { + createUiProblem, + problemFromError, + type UiProblem +} from '../shared/problems'; + +export type InstallProblemCode = + | 'install_detection_failed' + | 'install_action_failed' + | 'install_game_running' + | 'install_partial_failure' + | 'install_unexpected'; + +export type InstallProblem = UiProblem; + +type InstallWarning = { + code: string; + params: Record; +}; + +export function installProblemFromError(error: unknown): InstallProblem { + const problem: UiProblem = problemFromError(error, 'install_unexpected'); + switch (problem.code) { + case 'install_detection_failed': + case 'install_action_failed': + case 'install_game_running': + case 'install_partial_failure': + case 'install_unexpected': + return problem as InstallProblem; + default: + return createUiProblem('install_unexpected', { + params: problem.params, + diagnostic: problem.diagnostic + }); + } +} + +export function presentInstallWarning( + warning: InstallWarning, + t: Translate +): string { + return t(installWarningMessageKey(warning.code), warning.params); +} + +export function presentInstallProblem( + problem: InstallProblem, + t: Translate +): string { + return t(installProblemMessageKey(problem), problem.params); +} + +export function installFailurePaths(problem: InstallProblem): string[] { + if (problem.code !== 'install_partial_failure') return []; + return (problem.params.paths ?? '') + .split('\u001f') + .map((path) => path.trim()) + .filter(Boolean); +} + +function installWarningMessageKey(code: string): MessageKey { + switch (code) { + case 'game_missing': + return 'installWarningGameMissing'; + case 'launch_options_unsupported': + return 'installWarningLaunchOptionsUnsupported'; + case 'trampoline_reverted': + return 'installWarningTrampolineReverted'; + default: + return 'installWarningUnexpected'; + } +} + +function installProblemMessageKey(problem: InstallProblem): MessageKey { + switch (problem.code) { + case 'install_detection_failed': + return 'installProblemDetectionFailed'; + case 'install_game_running': + return problem.params.operation === 'reset_bepinex' + ? 'resetBepinexBlockedByGame' + : 'resetDataBlockedByGame'; + case 'install_partial_failure': + return problem.params.operation === 'reset_bepinex' + ? 'resetBepinexPartialFailure' + : 'resetDataPartialFailure'; + case 'install_action_failed': + switch (problem.params.operation) { + case 'choose_directory': + return 'installProblemChooseDirectoryFailed'; + case 'install': + return 'installProblemInstallFailed'; + case 'reset_bpp_data': + return 'installProblemResetDataFailed'; + case 'reset_bepinex': + return 'installProblemResetBepinexFailed'; + case 'uninstall': + return 'installProblemUninstallFailed'; + case 'launch': + return 'installProblemLaunchFailed'; + default: + return 'installProblemUnexpected'; + } + case 'install_unexpected': + return 'installProblemUnexpected'; + } +} diff --git a/src/features/install/useInstallPage.ts b/src/features/install/useInstallPage.ts index e45cc52d..97c2dd37 100644 --- a/src/features/install/useInstallPage.ts +++ b/src/features/install/useInstallPage.ts @@ -1,14 +1,13 @@ -import { useCallback, useEffect, useMemo, useState } from 'react'; -import { listen } from '@tauri-apps/api/event'; -import { hasTauriRuntime } from '../../api/runtime'; -import { emptyInstallState } from '../../api/previewDefaults'; -import type { InstallState } from '../../types/backend'; -import { useI18n, type Translate } from '../../i18n/LocaleProvider'; import { - parseResetBepinexError, - parseResetBppDataError, - toErrorMessage -} from '../shared/errors'; + useCallback, + useEffect, + useMemo, + useReducer, + useRef, + useState +} from 'react'; +import type { InstallState } from '../../types/backend'; +import { useI18n } from '../../i18n/LocaleProvider'; import { useAsyncAction } from '../shared/useAsyncAction'; import { useTransientMessage } from '../shared/useTransientMessage'; import { @@ -20,194 +19,213 @@ import { resetBppData, uninstallMod } from './installApi'; - -type InstallAction = - | 'load' - | 'choose' - | 'install' - | 'resetData' - | 'resetBepinex' - | 'uninstall' - | 'launch'; +import { + deriveInstallPrimaryAction, + initialInstallPageState, + reduceInstallPageState, + type InstallOperation +} from './installPageState'; +import { + installFailurePaths, + installProblemFromError, + presentInstallProblem, + type InstallProblem +} from './installProblems'; export function useInstallPage() { const { t } = useI18n(); - const [state, setState] = useState(emptyInstallState); - const [selectedPath, setSelectedPath] = useState( - undefined + const [pageState, dispatch] = useReducer( + reduceInstallPageState, + initialInstallPageState ); + const [selectedPath, setSelectedPath] = useState(); const [transient, setTransient] = useTransientMessage(4000); + const [actionProblem, setActionProblem] = useState( + null + ); const [resetDataFailurePaths, setResetDataFailurePaths] = useState( [] ); - const { action, error, run, busy } = useAsyncAction(); + const { action, run, busy: actionBusy } = useAsyncAction(); + const requestIdRef = useRef(0); + const requestInFlightRef = useRef(false); + const actionInFlightRef = useRef(false); - const refresh = useCallback( - async (gamePath = selectedPath) => { - await run( - 'load', - async () => { - const nextState = await loadInstallState(gamePath); - setState(nextState); - setSelectedPath(nextState.selected_game_path ?? gamePath); - }, - { onStart: () => setTransient(null) } - ); + const replaceData = useCallback((data: InstallState) => { + dispatch({ type: 'data-replaced', data }); + setSelectedPath(data.selected_game_path ?? undefined); + }, []); + + const detect = useCallback( + async (gamePath?: string) => { + if (requestInFlightRef.current || actionInFlightRef.current) return false; + const requestId = ++requestIdRef.current; + requestInFlightRef.current = true; + dispatch({ type: 'request-started', requestId }); + setTransient(null); + setActionProblem(null); + setResetDataFailurePaths([]); + try { + const data = await loadInstallState(gamePath); + dispatch({ type: 'request-succeeded', requestId, data }); + setSelectedPath(data.selected_game_path ?? gamePath); + return true; + } catch (caught) { + dispatch({ + type: 'request-failed', + requestId, + problem: installProblemFromError(caught) + }); + return false; + } finally { + if (requestIdRef.current === requestId) { + requestInFlightRef.current = false; + } + } }, - [run, selectedPath, setTransient] + [setTransient] ); useEffect(() => { - void run( - 'load', - async () => { - const nextState = await loadInstallState(undefined); - setState(nextState); - setSelectedPath(nextState.selected_game_path ?? undefined); - }, - { onStart: () => setTransient(null) } - ); - }, [run, setTransient]); + // `get_install_state` waits on the backend's OnceLock initialization, so its + // first successful response is already a completed detection snapshot. + void detect(undefined); + }, [detect]); - // The backend warms up installer context in the background and emits - // `startup-ready` when done. On slow first launches (Windows) the initial - // load above can race ahead of warm-up; refresh once the signal arrives so - // the first screen converges to fully-detected state without user action. - useEffect(() => { - if (!hasTauriRuntime()) return; - const unlisten = listen('startup-ready', () => { - void refresh(); - }); - return () => { - void unlisten.then((stop) => stop()); - }; - }, [refresh]); + const refresh = useCallback( + () => detect(selectedPath), + [detect, selectedPath] + ); + + const runInstallAction = useCallback( + async (name: InstallOperation, task: () => Promise) => { + if (requestInFlightRef.current || actionInFlightRef.current) return false; + actionInFlightRef.current = true; + try { + return await run(name, task, { + onStart: () => { + setTransient(null); + setActionProblem(null); + setResetDataFailurePaths([]); + }, + errorMessage: (caught) => { + const problem = installProblemFromError(caught); + setActionProblem(problem); + setResetDataFailurePaths(installFailurePaths(problem)); + return presentInstallProblem(problem, t); + } + }); + } finally { + actionInFlightRef.current = false; + } + }, + [run, setTransient, t] + ); const chooseDirectory = useCallback( () => - run('choose', async () => { + runInstallAction('choose', async () => { const selection = await chooseGameDirectory(); if (!selection.game_path) return; - setSelectedPath(selection.game_path); - setState(await loadInstallState(selection.game_path)); + const data = await loadInstallState(selection.game_path); + replaceData(data); }), - [run] + [replaceData, runInstallAction] ); + const installState = pageState.phase === 'ready' ? pageState.data : null; + const install = useCallback( (compatOptIn: boolean) => - run( - 'install', - async () => { - const path = requireGamePath(state, t); - setState(await installMod(path, compatOptIn)); - setTransient(t('installDone')); - }, - { onStart: () => setTransient(null) } - ), - [run, setTransient, state, t] + runInstallAction('install', async () => { + const path = requireGamePath(installState); + replaceData(await installMod(path, compatOptIn)); + setTransient(t('installDone')); + }), + [installState, replaceData, runInstallAction, setTransient, t] ); const resetData = useCallback( () => - run( - 'resetData', - async () => { - if (!state.has_resettable_data) { - setResetDataFailurePaths([]); - setTransient(t('resetDataNothingToDelete')); - return; - } - - const path = requireGamePath(state, t); - const result = await resetBppData(path); - setState(result.state); - setResetDataFailurePaths([]); - setTransient( - result.removed_data - ? t('resetDataDone') - : t('resetDataNothingToDelete') - ); - }, - { - onStart: () => { - setTransient(null); - setResetDataFailurePaths([]); - }, - errorMessage: (caught) => - formatResetBppDataError(caught, t, setResetDataFailurePaths) + runInstallAction('resetData', async () => { + if (!installState?.has_resettable_data) { + setTransient(t('resetDataNothingToDelete')); + return; } - ), - [run, setTransient, state, t] + + const path = requireGamePath(installState); + const result = await resetBppData(path); + replaceData(result.state); + setTransient( + result.removed_data + ? t('resetDataDone') + : t('resetDataNothingToDelete') + ); + }), + [installState, replaceData, runInstallAction, setTransient, t] ); const resetBepinexFolder = useCallback( () => - run( - 'resetBepinex', - async () => { - if (!state.has_bepinex_files) { - setResetDataFailurePaths([]); - setTransient(t('resetBepinexNothingToDelete')); - return; - } - - const path = requireGamePath(state, t); - const result = await resetBepinex(path); - setState(result.state); - setResetDataFailurePaths([]); - setTransient( - result.removed - ? t('resetBepinexDone') - : t('resetBepinexNothingToDelete') - ); - }, - { - onStart: () => { - setTransient(null); - setResetDataFailurePaths([]); - }, - errorMessage: (caught) => - formatResetBepinexError(caught, t, setResetDataFailurePaths) + runInstallAction('resetBepinex', async () => { + if (!installState?.has_bepinex_files) { + setTransient(t('resetBepinexNothingToDelete')); + return; } - ), - [run, setTransient, state, t] + + const path = requireGamePath(installState); + const result = await resetBepinex(path); + replaceData(result.state); + setTransient( + result.removed + ? t('resetBepinexDone') + : t('resetBepinexNothingToDelete') + ); + }), + [installState, replaceData, runInstallAction, setTransient, t] ); const uninstall = useCallback( () => - run( - 'uninstall', - async () => { - const path = requireGamePath(state, t); - setState(await uninstallMod(path)); - setTransient(t('uninstallDone')); - }, - { onStart: () => setTransient(null) } - ), - [run, setTransient, state, t] + runInstallAction('uninstall', async () => { + const path = requireGamePath(installState); + replaceData(await uninstallMod(path)); + setTransient(t('uninstallDone')); + }), + [installState, replaceData, runInstallAction, setTransient, t] ); const launch = useCallback( () => - run( - 'launch', - async () => { - await launchGame(); - }, - { onStart: () => setTransient(null) } - ), - [run, setTransient] + runInstallAction('launch', async () => { + await launchGame(); + }), + [runInstallAction] ); - const status = useMemo(() => createInstallStatus(state, t), [state, t]); + const refreshing = + pageState.phase === 'ready' && pageState.refresh.phase === 'refreshing'; + const status = useMemo( + () => (installState ? createInstallStatus(installState, t) : null), + [installState, t] + ); + const primaryAction = useMemo( + () => + installState + ? deriveInstallPrimaryAction(installState, action, refreshing) + : null, + [action, installState, refreshing] + ); return { - state, + pageState, + installState, status, + primaryAction, action, - busy, - error, + actionProblem, + busy: actionBusy || pageState.phase === 'initial-loading' || refreshing, + refreshing, message: transient?.text ?? null, resetDataFailurePaths, refresh, @@ -220,68 +238,31 @@ export function useInstallPage() { }; } -function requireGamePath(state: InstallState, t: Translate) { - if (!state.selected_game_path) { - throw new Error(t('selectGameDirFirst')); +function requireGamePath(state: InstallState | null) { + if (!state?.selected_game_path) { + throw new Error('Install action requires a selected game path.'); } return state.selected_game_path; } -function formatResetBppDataError( - error: unknown, - t: Translate, - setFailurePaths: (paths: string[]) => void +function createInstallStatus( + state: InstallState, + t: ReturnType['t'] ) { - const resetError = parseResetBppDataError(error); - if (resetError?.code === 'game_running') { - setFailurePaths([]); - return t('resetDataBlockedByGame'); - } - if (resetError?.code === 'partial_failure') { - setFailurePaths(resetError.paths); - return t('resetDataPartialFailure', { - count: Math.max(1, resetError.paths.length) - }); - } - setFailurePaths([]); - return toErrorMessage(error); -} - -function formatResetBepinexError( - error: unknown, - t: Translate, - setFailurePaths: (paths: string[]) => void -) { - const resetError = parseResetBepinexError(error); - if (resetError?.code === 'game_running') { - setFailurePaths([]); - return t('resetBepinexBlockedByGame'); - } - if (resetError?.code === 'partial_failure') { - setFailurePaths(resetError.paths); - return t('resetBepinexPartialFailure', { - count: Math.max(1, resetError.paths.length) - }); - } - setFailurePaths([]); - return toErrorMessage(error); -} - -function createInstallStatus(state: InstallState, t: Translate) { const installed = state.mod_state.installed; + const ready = + installed && + state.mod_state.version_matches && + state.compat.desired === state.compat.applied; return { gameLabel: state.game.path_valid ? t('gameFilesOk') : t('gameNotFound'), gameTone: state.game.path_valid ? ('ok' as const) : ('warn' as const), modLabel: installed - ? state.mod_state.version_matches + ? ready ? t('modReady') : t('modNeedsReinstall') : t('modNotInstalled'), - modTone: - installed && state.mod_state.version_matches - ? ('ok' as const) - : ('warn' as const), - primaryAction: installed ? t('actionReinstall') : t('actionInstall'), + modTone: ready ? ('ok' as const) : ('warn' as const), modVersion: state.mod_state.installed_version ?? state.mod_state.bundled_version ?? diff --git a/src/features/shared/errors.ts b/src/features/shared/errors.ts index 8ce84a81..56fddf6a 100644 --- a/src/features/shared/errors.ts +++ b/src/features/shared/errors.ts @@ -1,54 +1,3 @@ export function toErrorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); } - -const RESET_BPP_DATA_ERR_GAME_RUNNING = 'bpp_data_reset_blocked_by_game'; -const RESET_BPP_DATA_ERR_PARTIAL_FAILURE = 'bpp_data_reset_partial_failure:'; -const RESET_BEPINEX_ERR_GAME_RUNNING = 'bepinex_reset_blocked_by_game'; -const RESET_BEPINEX_ERR_PARTIAL_FAILURE = 'bepinex_reset_partial_failure:'; -const RESET_BPP_DATA_PATH_SEPARATOR = '\u001f'; - -export type ResetError = - | { code: 'game_running' } - | { code: 'partial_failure'; paths: string[] }; - -// Retained alias: the reset-data and reset-BepInEx errors share one shape. -export type ResetBppDataError = ResetError; - -function parseResetError( - error: unknown, - gameRunningCode: string, - partialFailurePrefix: string -): ResetError | null { - const message = toErrorMessage(error); - if (message === gameRunningCode) { - return { code: 'game_running' }; - } - - if (message.startsWith(partialFailurePrefix)) { - const payload = message.slice(partialFailurePrefix.length); - const paths = payload - .split(RESET_BPP_DATA_PATH_SEPARATOR) - .map((path) => path.trim()) - .filter(Boolean); - return { code: 'partial_failure', paths }; - } - - return null; -} - -export function parseResetBppDataError(error: unknown): ResetError | null { - return parseResetError( - error, - RESET_BPP_DATA_ERR_GAME_RUNNING, - RESET_BPP_DATA_ERR_PARTIAL_FAILURE - ); -} - -export function parseResetBepinexError(error: unknown): ResetError | null { - return parseResetError( - error, - RESET_BEPINEX_ERR_GAME_RUNNING, - RESET_BEPINEX_ERR_PARTIAL_FAILURE - ); -} diff --git a/src/features/shared/useAsyncAction.test.ts b/src/features/shared/useAsyncAction.test.ts index 71d6b866..ccddf0a8 100644 --- a/src/features/shared/useAsyncAction.test.ts +++ b/src/features/shared/useAsyncAction.test.ts @@ -1,6 +1,5 @@ import { describe, expect, it } from 'vitest'; import { createAsyncActionGate, runSingleFlightAction } from './useAsyncAction'; -import { parseResetBppDataError } from './errors'; describe('runSingleFlightAction', () => { it('rejects a second action while the first action is still running', async () => { @@ -71,25 +70,3 @@ describe('runSingleFlightAction', () => { expect(gate.current).toBeNull(); }); }); - -describe('parseResetBppDataError', () => { - it('parses reset data machine-code errors without exposing path payloads', () => { - expect(parseResetBppDataError('bpp_data_reset_blocked_by_game')).toEqual({ - code: 'game_running' - }); - - expect( - parseResetBppDataError( - 'bpp_data_reset_partial_failure:/tmp/a\u001f/tmp/b' - ) - ).toEqual({ - code: 'partial_failure', - paths: ['/tmp/a', '/tmp/b'] - }); - - expect(parseResetBppDataError('bpp_data_reset_partial_failure:')).toEqual({ - code: 'partial_failure', - paths: [] - }); - }); -}); diff --git a/src/i18n/messages.ts b/src/i18n/messages.ts index 0a4004c9..8a783ba9 100644 --- a/src/i18n/messages.ts +++ b/src/i18n/messages.ts @@ -72,8 +72,12 @@ const zh = { notSelected: '未选择', chooseAgain: '重新选择', recheck: '重新检测', + installDetecting: '正在检测 The Bazaar 与 BazaarPlusPlus', + installRefreshing: '正在重新检测安装状态', + actionChooseDirectory: '选择游戏目录', actionInstall: '安装', actionReinstall: '重新安装', + actionRepair: '修复安装', actionResetData: '重置本地数据', actionNoResettableData: '暂无本地数据', actionResetBepinex: '重置 BepInEx', @@ -124,6 +128,26 @@ const zh = { resetBepinexPartialFailure: '有 {count} 个项目未能删除。请关闭游戏后重试。', uninstallDone: '卸载完成', selectGameDirFirst: '请先选择 The Bazaar 安装目录。', + installWarningGameMissing: + '未找到有效的 The Bazaar 安装目录,请选择游戏目录。', + installWarningLaunchOptionsUnsupported: + '当前 Steam 安装不支持自动写入启动项,请检查 Steam 目录后重试。', + installWarningTrampolineReverted: + '游戏文件已还原,BazaarPlusPlus 的启动配置需要修复。', + installWarningUnexpected: '检测到未知的安装警告。', + installProblemDetectionFailed: '检测安装状态失败,请重试。', + installProblemChooseDirectoryFailed: '无法打开游戏目录选择器,请重试。', + installProblemInstallFailed: + '安装或修复失败。请退出 The Bazaar 与 Steam,检查目录权限后重试。', + installProblemResetDataFailed: + '重置本地数据失败,请关闭占用文件的程序后重试。', + installProblemResetBepinexFailed: + '重置 BepInEx 失败,请退出 The Bazaar 后重试。', + installProblemUninstallFailed: + '卸载失败,请退出 The Bazaar 与 Steam 后重试。', + installProblemLaunchFailed: + '无法通过 Steam 启动游戏,请确认 Steam 正在运行后重试。', + installProblemUnexpected: '处理安装状态时发生意外错误,请重试。', // Install confirmation modal installModalTitle: '安装 BazaarPlusPlus', @@ -345,8 +369,12 @@ const en: Record = { notSelected: 'Not selected', chooseAgain: 'Choose again', recheck: 'Re-detect', + installDetecting: 'Detecting The Bazaar and BazaarPlusPlus', + installRefreshing: 'Re-detecting installation state', + actionChooseDirectory: 'Choose Game Directory', actionInstall: 'Install', actionReinstall: 'Reinstall', + actionRepair: 'Repair Installation', actionResetData: 'Reset Local Data', actionNoResettableData: 'No Local Data', actionResetBepinex: 'Reset BepInEx', @@ -401,6 +429,29 @@ const en: Record = { '{count} item(s) could not be deleted. Close the game, then try again.', uninstallDone: 'Uninstall complete', selectGameDirFirst: 'Select The Bazaar install directory first.', + installWarningGameMissing: + 'No valid The Bazaar installation was found. Choose the game directory.', + installWarningLaunchOptionsUnsupported: + 'This Steam installation cannot update launch options automatically. Check the Steam directory and retry.', + installWarningTrampolineReverted: + 'Game files were restored and the BazaarPlusPlus launch configuration needs repair.', + installWarningUnexpected: 'An unknown installation warning was detected.', + installProblemDetectionFailed: + 'Installation state could not be detected. Please retry.', + installProblemChooseDirectoryFailed: + 'The game directory picker could not be opened. Please retry.', + installProblemInstallFailed: + 'Install or repair failed. Quit The Bazaar and Steam, check folder permissions, then retry.', + installProblemResetDataFailed: + 'Local data could not be reset. Close apps using those files, then retry.', + installProblemResetBepinexFailed: + 'BepInEx could not be reset. Quit The Bazaar, then retry.', + installProblemUninstallFailed: + 'Uninstall failed. Quit The Bazaar and Steam, then retry.', + installProblemLaunchFailed: + 'The game could not be launched through Steam. Make sure Steam is running, then retry.', + installProblemUnexpected: + 'Something unexpected happened while handling installation state. Please retry.', installModalTitle: 'Install BazaarPlusPlus', tutorialKicker: 'Tutorial', diff --git a/src/pages/Install.tsx b/src/pages/Install.tsx index f618f38a..56a150e7 100644 --- a/src/pages/Install.tsx +++ b/src/pages/Install.tsx @@ -1,5 +1,6 @@ import { useState } from 'react'; import { PageShell } from '../components/ui/PageShell'; +import { LoadingPanel } from '../components/ui/LoadingPanel'; import { InstallActionsPanel } from '../features/install/InstallActionsPanel'; import { InstallConfirmModal } from '../features/install/InstallConfirmModal'; import { InstallStatusPanel } from '../features/install/InstallStatusPanel'; @@ -7,6 +8,7 @@ import { ResetBepinexConfirmModal } from '../features/install/ResetBepinexConfir import { ResetDataConfirmModal } from '../features/install/ResetDataConfirmModal'; import { useInstallPage } from '../features/install/useInstallPage'; import { useI18n } from '../i18n/LocaleProvider'; +import { InstallProblemBanner } from '../features/install/InstallProblemBanner'; export default function Install() { const { t } = useI18n(); @@ -19,19 +21,13 @@ export default function Install() { const [resetBepinexAcknowledged, setResetBepinexAcknowledged] = useState(false); const [compatOptIn, setCompatOptIn] = useState(false); - const primaryMode: 'install' | 'reinstall' | 'launch' = !page.state.mod_state - .installed - ? 'install' - : page.state.mod_state.version_matches - ? 'launch' - : 'reinstall'; const openInstallModal = () => { setShowInstallModal(true); setInstallAcknowledged(false); // Seed the checkbox from the current desired mode: forced (checked + locked) on // macOS 27+, the persisted choice on <= 26, off elsewhere. - setCompatOptIn(page.state.compat.desired); + setCompatOptIn(page.installState?.compat.desired ?? false); }; const confirmInstall = async () => { @@ -66,23 +62,54 @@ export default function Install() { return ( -
- - + ) : page.pageState.phase === 'blocking-failure' ? ( + void page.refresh()} /> -
+ ) : page.installState && page.status && page.primaryAction ? ( + <> + {page.pageState.refresh.phase === 'failed' && ( + void page.refresh()} + /> + )} + {page.pageState.refresh.phase === 'refreshing' && ( +

+ {t('installRefreshing')} +

+ )} +
+ + +
+ + ) : null} - {showInstallModal && ( + {showInstallModal && page.installState && ( setShowInstallModal(false)} @@ -90,7 +117,7 @@ export default function Install() { /> )} - {showResetDataModal && ( + {showResetDataModal && page.installState && ( )} - {showResetBepinexModal && ( + {showResetBepinexModal && page.installState && ( Date: Sun, 19 Jul 2026 02:24:49 +0800 Subject: [PATCH 2/2] docs: refresh Install state truth --- CONTEXT.md | 8 ++++---- docs/INDEX.md | 14 ++++++++------ docs/truth/architecture.md | 9 +++++---- docs/truth/frontend.md | 10 ++++++---- docs/truth/install-reset.md | 23 +++++++++++++---------- docs/truth/launch-modes.md | 6 +++--- docs/truth/verification.md | 5 +++-- 7 files changed, 42 insertions(+), 33 deletions(-) diff --git a/CONTEXT.md b/CONTEXT.md index f77d4cc7..3c4e70b1 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -1,7 +1,7 @@ --- status: truth topic: context -last-verified: 68f2b1ef20e7c1c5c789bd5cde34821cf28efd57 +last-verified: f23d786ab3bf1998f556f5fe05b6e47467a7ea48 --- # BazaarPlusPlus Installer Context @@ -14,7 +14,7 @@ Current behavior truth lives under `docs/truth/` (topic-sliced, code-cited, hash - The app is a Tauri 2 desktop app with a React/Vite frontend and Rust backend. The package entry declares the app version and scripts in `package.json:2-21`; the Tauri app config sets the product name, frontend dev URL, build hooks, window size, and updater endpoint in `src-tauri/tauri.conf.json:3-35`. - The native runtime registers single-instance, window-state, updater, process, dialog, opener, tray, selected-installation, installer-context, and stream-runtime state in `src-tauri/src/lib.rs:20-44`. -- Startup warms installer context on a blocking task and emits `startup-ready`; setup asks the stream runtime to ensure the HTTP service in `src-tauri/src/lib.rs:45-60`. +- Startup warms installer context on a blocking task while setup asks the stream runtime to ensure the HTTP service in `src-tauri/src/lib.rs:45-59`. Install detection calls the same `OnceLock` initializer, so the first completed command response cannot observe a bootstrap seed in `src-tauri/src/services/startup.rs:23-34` and `src-tauri/src/services/detect/mod.rs:27-39`. - When the stream service is running, closing the main window hides it instead of quitting so OBS can keep using the local HTTP overlay in `src-tauri/src/lib.rs:61-75`. ## Glossary @@ -24,11 +24,11 @@ Current behavior truth lives under `docs/truth/` (topic-sliced, code-cited, hash - **Prefix mode** — the default launch mode: BepInEx loads via Steam launch options (doorstop). One of the two `LaunchMode` variants in `src-tauri/src/services/launch_mode.rs:20-46`. - **Trampoline mode** — macOS launch mode (forced on macOS 27+): the real Unity executable is renamed to `.orig` and a build-time stub is swapped in (`src-tauri/src/services/bepinex/trampoline.rs:362-445`). - **Launch-mode marker** — the `.bpp-launch-mode` file next to the game directory persisting the chosen mode (`src-tauri/src/services/bepinex/trampoline.rs:23-71`). -- **InstallState** — the frontend/backend contract for the install page: paths, game/mod state, compat state, action gates, warnings (`src-tauri/src/services/install/types.rs:3-19`). +- **InstallState** — the frontend/backend contract for the install page: paths, game/mod state, compat state, action gates, and semantic warning codes plus parameters (`src-tauri/src/services/install/types.rs:5-20`, `src-tauri/src/services/install/types.rs:73-85`). - **Selected game installation** — the one session-scoped The Bazaar installation shared by Install, History, and Stream. Valid explicit paths update it; resolution then uses explicit, selected, startup-detected, and fallback priority. It is held only in managed memory and is recreated empty on app restart (`src-tauri/src/services/selected_game_installation.rs:14-115`, `src-tauri/src/lib.rs:38-41`). - **Reset (local data)** — the only flow that deletes the mod's `BazaarPlusPlusV4/` data directory; explicit, confirmed, refused while the game runs, and performed under exclusive stream-runtime maintenance (`src-tauri/src/services/bepinex/mod.rs:20-62`, `src-tauri/src/stream/runtime.rs:100-108`). Uninstall never touches it. - **History** — the facade around the Selected game installation's mod-owned SQLite database, including reads, detail, reveal, video deletion, and storage cleanup (`src-tauri/src/services/history.rs:48-270`); the database is created and primarily written by the mod. -- **Semantic problem** — a command failure contract made of a stable code, string parameters, and an optional troubleshooting diagnostic (`src-tauri/src/problem.rs:3-38`). History list/detail loading publishes `history_unavailable` and `history_read_failed`, while screenshot/video actions publish `history_action_failed` with an operation parameter; the frontend adapter preserves the structured payload instead of turning it into display copy (`src-tauri/src/services/history.rs:74-188`, `src/api/problems.ts:3-42`, `src/api/nativeCommands.ts:5-15`). +- **Semantic problem** — a command failure contract made of a stable code, string parameters, and an optional troubleshooting diagnostic (`src-tauri/src/problem.rs:3-40`). History publishes unavailable/read/action codes; Install publishes detection/action/game-running/partial-failure codes and localizes them only in the frontend presenter (`src-tauri/src/services/history.rs:74-188`, `src-tauri/src/services/install/mod.rs:200-235`, `src/features/install/installProblems.ts:23-107`). The native adapter preserves the structured payload instead of turning it into display copy (`src/api/problems.ts:3-46`, `src/api/nativeCommands.ts:5-15`). - **Stream runtime / overlay** — the single serialized owner of the local Axum service lifecycle, window selection, and exclusive maintenance; the production service remains on `127.0.0.1:17654` and serves the OBS overlay and settings pages (`src-tauri/src/stream/runtime.rs:43-108`, `src-tauri/src/stream/server.rs:16-69`). - **Stream workflow** — the framework-neutral frontend owner of Stream page initialization, polling, intents, error priority, and its single derived snapshot. Browser/Tauri concerns enter through injected ports, and React only attaches lifecycle and subscription (`src/features/stream/streamWorkflow.ts:94-120`, `src/features/stream/useStreamPage.ts:21-61`). - **Storage cleanup** — preset-driven deletion of old screenshots and run data with upload-safety and referenced-file protections; its IPC is the two scope-tagged preview/execute operations (`src-tauri/src/commands/history.rs:61-79`, `src-tauri/src/services/history.rs:25-44`). diff --git a/docs/INDEX.md b/docs/INDEX.md index 24dc9a80..706db552 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -14,6 +14,8 @@ History page-state citation refresh: `2026-07-19` on `b07adb2e67f03480d039352837 Run Detail page-state citation refresh: `2026-07-19` on `68f2b1ef20e7c1c5c789bd5cde34821cf28efd57` — the context glossary plus architecture, frontend, History/Stream, and verification topics were checked against the nullable-detail, semantic-action, preserved-refresh, and single-flight implementation. +Install page-state citation refresh: `2026-07-19` on `f23d786ab3bf1998f556f5fe05b6e47467a7ea48` — the context glossary plus architecture, frontend, Install/Reset, Launch Modes, and verification topics were checked against completed native detection, semantic Install problems, preserved refresh state, and the single derived primary action. + ## Current Manifest | Path | Topic | Status | Last verified | @@ -23,14 +25,14 @@ Run Detail page-state citation refresh: `2026-07-19` on `68f2b1ef20e7c1c5c789bd5 | `README.md` | project entrypoint | current-entrypoint | 2026-07-11 | | `.trae/rules/git-commit-message.md` | ignored local rule | ignored-operational | n/a | | `docs/INDEX.md` | documentation manifest | manifest | 2026-07-19 | -| `CONTEXT.md` | entry map + glossary | truth | `68f2b1ef20e7c1c5c789bd5cde34821cf28efd57` | -| `docs/truth/architecture.md` | architecture | truth | `68f2b1ef20e7c1c5c789bd5cde34821cf28efd57` | -| `docs/truth/frontend.md` | frontend | truth | `68f2b1ef20e7c1c5c789bd5cde34821cf28efd57` | -| `docs/truth/install-reset.md` | install-reset | truth | `7500016b1c4adfc7b5d0206c7def0ceabae514d5` | -| `docs/truth/launch-modes.md` | launch-modes | truth | `4366cda394fe304066b55564c3c44d1f917a2273` | +| `CONTEXT.md` | entry map + glossary | truth | `f23d786ab3bf1998f556f5fe05b6e47467a7ea48` | +| `docs/truth/architecture.md` | architecture | truth | `f23d786ab3bf1998f556f5fe05b6e47467a7ea48` | +| `docs/truth/frontend.md` | frontend | truth | `f23d786ab3bf1998f556f5fe05b6e47467a7ea48` | +| `docs/truth/install-reset.md` | install-reset | truth | `f23d786ab3bf1998f556f5fe05b6e47467a7ea48` | +| `docs/truth/launch-modes.md` | launch-modes | truth | `f23d786ab3bf1998f556f5fe05b6e47467a7ea48` | | `docs/truth/history-stream.md` | history-stream | truth | `68f2b1ef20e7c1c5c789bd5cde34821cf28efd57` | | `docs/truth/updater-release.md` | updater-release | truth | `45764680a4476063a46a92f4606dd520f0ce29ef` | -| `docs/truth/verification.md` | verification | truth | `68f2b1ef20e7c1c5c789bd5cde34821cf28efd57` | +| `docs/truth/verification.md` | verification | truth | `f23d786ab3bf1998f556f5fe05b6e47467a7ea48` | | `docs/plans/manual-validation.md` | manual-validation | active-plan | `7500016b1c4adfc7b5d0206c7def0ceabae514d5` | | `docs/agents/issue-tracker.md` | agent skills: issue tracker | operational | 2026-07-11 | | `docs/agents/triage-labels.md` | agent skills: triage labels | operational | 2026-07-11 | diff --git a/docs/truth/architecture.md b/docs/truth/architecture.md index 574a7a64..2e783455 100644 --- a/docs/truth/architecture.md +++ b/docs/truth/architecture.md @@ -1,7 +1,7 @@ --- status: truth topic: architecture -last-verified: 68f2b1ef20e7c1c5c789bd5cde34821cf28efd57 +last-verified: f23d786ab3bf1998f556f5fe05b6e47467a7ea48 --- # Architecture @@ -15,14 +15,15 @@ last-verified: 68f2b1ef20e7c1c5c789bd5cde34821cf28efd57 ## Native Runtime -- Tauri startup builds the tray, warms `InstallerContextState`, emits `startup-ready`, and asks `StreamRuntime` to ensure the stream service in `src-tauri/src/lib.rs:45-60`. +- Tauri startup builds the tray, warms `InstallerContextState`, and asks `StreamRuntime` to ensure the stream service in `src-tauri/src/lib.rs:45-59`. Install detection waits on the same `OnceLock` initializer in `src-tauri/src/services/startup.rs:23-34` and `src-tauri/src/services/detect/mod.rs:27-39`, so no frontend event/refetch race is required. - Close behavior is service-aware: while the stream runtime reports `running`, the main window close request is prevented and the window is hidden in `src-tauri/src/lib.rs:61-75`. - The default capability grants updater check/download/install, process restart, `steam://*` opening, and dialog permissions in `src-tauri/capabilities/default.json:6-20`. ## Feature Boundaries -- Install state is produced by Rust detection and serialized through `InstallState`; the contract includes selected paths, game/mod state, macOS compatibility state, action gates, resettable-data and BepInEx-folder status, and warnings in `src-tauri/src/services/install/types.rs:3-19`. -- The complete install operation owns payload and launch-mode fact gathering, private planning, ordered production effects, first-error propagation, and a final state refresh in `src-tauri/src/services/install/operation.rs:16-125`; the Tauri command only constructs the request and invokes that operation in `src-tauri/src/commands/install.rs:40-55`. Reset, uninstall, and Steam-only launch remain in the install service facade. +- Install state is produced by Rust detection and serialized through `InstallState`; the contract includes selected paths, game/mod state, macOS compatibility state, action gates, resettable-data and BepInEx-folder status in `src-tauri/src/services/install/types.rs:5-20`, while warnings use typed codes plus parameters in `src-tauri/src/services/install/types.rs:73-85`. +- The complete install operation owns payload and launch-mode fact gathering, private planning, ordered production effects, first-error propagation, and a final state refresh in `src-tauri/src/services/install/operation.rs:17-130`; the Tauri command only constructs the request and invokes that operation in `src-tauri/src/commands/install.rs:44-59`. Reset, uninstall, and Steam-only launch remain in the install service facade. +- All Install command failures share `SemanticProblem`; detection and action boundaries classify stable codes and operation/recovery parameters in `src-tauri/src/commands/install.rs:15-99` and `src-tauri/src/services/install/mod.rs:200-235`. The frontend keeps Install page state and its sole primary-action derivation framework-independent in `src/features/install/installPageState.ts:5-132`. - The History facade resolves Selected game installation and privately owns storage derivation, reads, reveals, deletes, and cleanup dispatch in `src-tauri/src/services/history.rs:48-270`; commands expose ids plus domain cleanup scope/preset without raw paths or cutoffs in `src-tauri/src/commands/history.rs:7-79`. Reads use SQLite read-only connections by default, while mutation uses separate write connections in `src-tauri/src/history/queries.rs:29-54`. - History list/detail/reveal/delete commands use `SemanticProblem`; the shared Rust DTO fixes code/parameter/diagnostic shape, the detail command models not-found as a successful `Option`, and the facade classifies unavailable selection, failed reads, and failed actions before the command boundary in `src-tauri/src/problem.rs:3-38`, `src-tauri/src/services/history.rs:74-188`, and `src-tauri/src/commands/history.rs:7-49`. - History internals default to private modules; only cleanup algorithms and mapper/screenshots test seams are crate-visible, and the facade receives a narrowed repository surface in `src-tauri/src/history/mod.rs:1-13`. diff --git a/docs/truth/frontend.md b/docs/truth/frontend.md index aa96ddf6..bb3df22e 100644 --- a/docs/truth/frontend.md +++ b/docs/truth/frontend.md @@ -1,7 +1,7 @@ --- status: truth topic: frontend -last-verified: 68f2b1ef20e7c1c5c789bd5cde34821cf28efd57 +last-verified: f23d786ab3bf1998f556f5fe05b6e47467a7ea48 --- # Frontend @@ -32,9 +32,11 @@ last-verified: 68f2b1ef20e7c1c5c789bd5cde34821cf28efd57 ## Current Product Surfaces -- Install renders status and action panels plus install and reset confirmation modals in `src/pages/Install.tsx:68-111`. -- Install facts currently show only BazaarPlusPlus, not the broader fact list from the historical design spec, in `src/features/install/InstallActionsPanel.tsx:49-58`. -- The reset-local-data button is disabled unless backend action gates allow reset data, and its label switches to a no-data message when the game path is valid but no resettable data exists in `src/features/install/InstallActionsPanel.tsx:93-105`. +- Install renders mutually exclusive initial-detection, blocking-failure, and completed-state branches; a refresh failure keeps the completed status and actions visible behind a localized retry banner in `src/pages/Install.tsx:63-105` and `src/features/install/installPageState.ts:34-73`. +- Install facts currently show only BazaarPlusPlus, not the broader fact list from the historical design spec, in `src/features/install/InstallActionsPanel.tsx:58-65`. +- Install renders exactly one primary action. Its choose/install/repair/launch mode, gate, and loading state are derived from one view model based on path validity, install/version state, compatibility consistency, and the active operation in `src/features/install/installPageState.ts:75-132` and `src/features/install/InstallActionsPanel.tsx:141-222`. +- Install warnings and failures are presented from stable semantic codes in bilingual frontend copy; native diagnostics are kept in the diagnostic disclosure rather than used as the message in `src/features/install/installProblems.ts:23-107` and `src/features/install/InstallProblemBanner.tsx:1-32`. +- The reset-local-data button is disabled unless backend action gates allow reset data, and its label switches to a no-data message when the game path is valid but no resettable data exists in `src/features/install/InstallActionsPanel.tsx:103-115`. - History renders loading, blocking failure, and the two successful list states as mutually exclusive branches; refresh failures remain inside the ready branch and keep prior data in `src/pages/History.tsx:42-97` and `src/features/shared/pageState.ts:43-54`. - History summary cards are Runs, Videos, and Win Rate in `src/pages/History.tsx:50-64`. - History rows link to details, show lazy-decoded preview images with an error fallback, and display hero, locale-formatted date, result, progress, rank, and rating in `src/pages/History.tsx:125-230` and `src/features/history/format.ts:4-27`. diff --git a/docs/truth/install-reset.md b/docs/truth/install-reset.md index 9be2d560..d1d842db 100644 --- a/docs/truth/install-reset.md +++ b/docs/truth/install-reset.md @@ -1,23 +1,26 @@ --- status: truth topic: install-reset -last-verified: 7500016b1c4adfc7b5d0206c7def0ceabae514d5 +last-verified: f23d786ab3bf1998f556f5fe05b6e47467a7ea48 --- # Install And Reset ## Install State Contract -- `InstallState` is the frontend/backend contract for the install page. It includes selected paths, Steam launch-option support, game/mod state, compatibility state, action gates, resettable-data and BepInEx-folder status, and warnings in `src-tauri/src/services/install/types.rs:3-19`. -- Reset returns a typed `ResetBppDataResult` containing the refreshed state and a `removed_data` boolean in `src-tauri/src/services/install/types.rs:21-26`. -- Backend state derives `has_resettable_data`, action gates, and warnings from one detection snapshot in `src-tauri/src/services/install/mod.rs:83-159`. +- `InstallState` is the frontend/backend contract for the install page. It includes selected paths, Steam launch-option support, game/mod state, compatibility state, action gates, resettable-data and BepInEx-folder status, and warnings in `src-tauri/src/services/install/types.rs:5-20`. Warnings cross the boundary only as `InstallWarningCode` plus string parameters, never backend-authored display copy, in `src-tauri/src/services/install/types.rs:73-85` and `src-tauri/src/services/install/mod.rs:172-198`. +- Reset returns typed `ResetBppDataResult` and `ResetBepinexResult` values containing the refreshed state and removal outcome in `src-tauri/src/services/install/types.rs:22-32`. +- Backend state derives game/mod/compat facts, resettable-data status, action gates, and warnings from one completed detection snapshot in `src-tauri/src/services/install/mod.rs:107-170`. +- `get_install_state` enters the detection facade in `src-tauri/src/services/install/mod.rs:34-49`, which waits on the process-wide `InstallerContextState` initializer in `src-tauri/src/services/detect/mod.rs:27-39`. That `OnceLock` is shared with startup warm-up in `src-tauri/src/services/startup.rs:23-34` and `src-tauri/src/lib.rs:45-52`, so the first successful frontend response is a real native detection rather than an all-false bootstrap seed. +- The Install page reducer has explicit initial-loading, blocking-failure, and ready states; refresh failures preserve the last valid `InstallState`, and request ids ignore stale completions in `src/features/install/installPageState.ts:5-73`. Path validity, install/version state, compatibility consistency, and backend gates derive exactly one primary choose/install/repair/launch action plus its disabled/running state in `src/features/install/installPageState.ts:75-132`. ## Install And Uninstall -- `install` is one operation: it classifies the payload as missing, changed, or current; gathers launch-mode facts; executes private planned effects through the production adapter; stops on the first effect error; and returns only a freshly detected `InstallState` in `src-tauri/src/services/install/operation.rs:21-125`. +- `install` is one operation: it classifies the payload as missing, changed, or current; gathers launch-mode facts; executes private planned effects through the production adapter; stops on the first effect error; and returns only a freshly detected `InstallState` in `src-tauri/src/services/install/operation.rs:22-130`. - A current payload with an already-satisfied launch mode produces no effects but still runs the final refresh; missing or changed payloads install BepInEx, while launch-mode-only repair leaves a current payload intact in `src-tauri/src/services/install/plan.rs:70-152`. - Marker-before-Steam-clear, close-Steam-only-on-mode-switch, prefix-marker-last, and payload-before-bundle mutation remain private planner invariants when their corresponding effects are present in `src-tauri/src/services/install/plan.rs:97-152` and `src-tauri/src/services/install/plan.rs:176-255`. -- Prefix/trampoline ordering, fresh/missing/changed/current payload states, current-install no-op, launch-mode-only repair, first-error truncation, and refresh-after-success are tested through the operation's effect recorder in `src-tauri/src/services/install/operation.rs:165-289`; no command or service caller observes the planned effect vector. +- Prefix/trampoline ordering, fresh/missing/changed/current payload states, current-install no-op, launch-mode-only repair, first-error truncation, and refresh-after-success are tested through the operation's effect recorder in `src-tauri/src/services/install/operation.rs:169-293`; no command or service caller observes the planned effect vector. +- Every public Install command returns `SemanticProblem` in `src-tauri/src/commands/install.rs:15-99`. The service boundary classifies detection, generic actions, game-running resets, and partial reset failures into stable codes and parameters in `src-tauri/src/services/install/mod.rs:200-235`; the frontend maps those codes to bilingual recovery copy and keeps diagnostics separate in `src/features/install/installProblems.ts:23-107`. - Install pre-clean removes only BPP-owned files the incoming payload no longer ships in `prepare_install_target` and `remove_stale_bpp_files` in `src-tauri/src/services/bepinex/payload.rs:380-416`; extraction skips byte-identical existing files and overwrites the rest in `src-tauri/src/services/bepinex/zip_archive.rs:39-79`. Third-party files are never pre-deleted, but a colliding path with different content is still overwritten by extraction. - Ownership is defined by `BPP_PRIVATE_RELATIVE_PATHS` and `BPP_BUNDLED_DEPENDENCY_RELATIVE_PATHS` in `src-tauri/src/services/bepinex/payload.rs:10-35`; `test_ownership_lists_match_shipped_payload` pins them to `resources/SourceForBuild/{macos,windows}/BepInEx/plugins` in `src-tauri/src/services/bepinex/payload.rs:752-786`, and `scripts/prebuild-check.mjs` requires every `SourceForBuild` file to exist in the bundled zips and rejects stray OS artifacts (`.DS_Store` and the like) in both the tree and the zips in `scripts/prebuild-check.mjs:30-61` and `scripts/prebuild-check.mjs:219-250`. - Uninstall is gated on `has_third_party_plugins` and `has_third_party_patchers` in `src-tauri/src/services/bepinex/mod.rs:194-244` and `src-tauri/src/services/bepinex/payload.rs:326-352`: when another mod's plugin (in `BepInEx/plugins`) or patcher (any file under `BepInEx/patchers`) is present, only private BPP files are removed and shared dependencies, trampoline, launch options, and BepInEx bootstrap stay; when BPP is the last mod, the full payload, trampoline, launch-mode marker, Steam launch options, and BepInEx bootstrap are removed through `remove_bootstrap_files` in `src-tauri/src/services/bepinex/payload.rs:313-324`, which lets `is_bepinex_installed` return false in `src-tauri/src/services/detect/game.rs:3-21`. @@ -25,9 +28,9 @@ last-verified: 7500016b1c4adfc7b5d0206c7def0ceabae514d5 ## Reset Local Data -- The frontend opens a dedicated reset confirmation modal and requires an acknowledgement checkbox before confirming in `src/pages/Install.tsx:93-100` and `src/features/install/ResetDataConfirmModal.tsx:21-55`. -- The reset button is disabled when reset is not allowed, and the UI distinguishes "no resettable data" from the destructive action label in `src/features/install/InstallActionsPanel.tsx:97-109`. -- `useInstallPage` treats an already-empty state as a no-op, calls `resetBppData`, refreshes install state from the typed result, and chooses success versus no-op copy from `removed_data` in `src/features/install/useInstallPage.ts:111-141`. +- The frontend opens a dedicated reset confirmation modal and requires an acknowledgement checkbox before confirming in `src/pages/Install.tsx:120-128` and `src/features/install/ResetDataConfirmModal.tsx:21-55`. +- The reset button is disabled when reset is not allowed, and the UI distinguishes "no resettable data" from the destructive action label in `src/features/install/InstallActionsPanel.tsx:103-115`. +- `useInstallPage` treats an already-empty state as a no-op, calls `resetBppData`, replaces the completed state from the typed result, and chooses success versus no-op copy from `removed_data` in `src/features/install/useInstallPage.ts:148-166`. - The Rust reset path enters `StreamRuntime` exclusive maintenance, stops and awaits the stream task, and keeps lifecycle operations excluded throughout blocking deletion in `src-tauri/src/services/bepinex/mod.rs:29-42` and `src-tauri/src/stream/runtime.rs:100-108`. - Reset refuses to run while The Bazaar is detected as running, records whether the data directory existed before cleanup, and returns stable error-code prefixes for blocked or partial-failure cases in `src-tauri/src/services/bepinex/mod.rs:20-27` and `src-tauri/src/services/bepinex/mod.rs:44-70`. -- The frontend maps reset error prefixes to localized messages and captures partial-failure paths for display in `src/features/install/useInstallPage.ts:230-267`. +- Raw reset sentinels remain private to the Rust service boundary; the frontend consumes only semantic problem parameters, maps them to localized messages, and extracts partial-failure paths in `src/features/install/installProblems.ts:23-107` and `src/features/install/useInstallPage.ts:100-123`. diff --git a/docs/truth/launch-modes.md b/docs/truth/launch-modes.md index 4d55aa27..3a003eb7 100644 --- a/docs/truth/launch-modes.md +++ b/docs/truth/launch-modes.md @@ -1,7 +1,7 @@ --- status: truth topic: launch-modes -last-verified: 4366cda394fe304066b55564c3c44d1f917a2273 +last-verified: f23d786ab3bf1998f556f5fe05b6e47467a7ea48 --- # Launch Modes @@ -9,13 +9,13 @@ last-verified: 4366cda394fe304066b55564c3c44d1f917a2273 ## Steam Launch - The installer launches the game exclusively through the Steam client. There is no other launch path — Steam detection is the substrate for every install and launch. -- The launch command takes no arguments and calls `launch_game_via_steam()` directly in `src-tauri/src/commands/install.rs:76-81`, which opens `steam://rungameid/1617400` in `src-tauri/src/services/install/mod.rs:25` and `src-tauri/src/services/install/mod.rs:150-152`; the capability allows `steam://*` URLs in `src-tauri/capabilities/default.json:12-19`. +- The launch command takes no arguments, calls `launch_game_via_steam()`, and maps failures to the Install semantic problem contract in `src-tauri/src/commands/install.rs:92-99`. The service opens `steam://rungameid/1617400` in `src-tauri/src/services/install/mod.rs:32` and `src-tauri/src/services/install/mod.rs:103-105`; the capability allows `steam://*` URLs in `src-tauri/capabilities/default.json:12-19`. - Game detection resolves Steam copies only, via `steamapps/libraryfolders.vdf` and Steam-library candidates in `src-tauri/src/services/detect/steam.rs:149-316` and well-known platform paths in `src-tauri/src/services/game_path.rs:191-225`. ## macOS Prefix And Trampoline Modes - macOS trampoline mode is forced at macOS major version 27 or later, can be opted into below 27, and always resolves to prefix mode off macOS in `src-tauri/src/services/launch_mode.rs:17-102`; `src-tauri/src/services/macos_version.rs:12-40` is only the cached macOS version probe. -- `InstallCompatState` exposes whether compatibility mode is available, forced, desired, and applied in `src-tauri/src/services/install/types.rs:35-48`. +- `InstallCompatState` exposes whether compatibility mode is available, forced, desired, and applied in `src-tauri/src/services/install/types.rs:34-46`. - The `LaunchMode` enum and stable marker codec live in `src-tauri/src/services/launch_mode.rs:20-46`; marker IO persists the chosen mode in `.bpp-launch-mode` next to the game directory in `src-tauri/src/services/bepinex/trampoline.rs:23-71`. - Trampoline install renames the real Unity executable to `.orig`, swaps in a build-time stub, disables the prefix launcher, signs the real binary, seals the app bundle, verifies it, and rolls back on failure in `src-tauri/src/services/bepinex/trampoline.rs:362-445`. - Trampoline uninstall restores `.orig`, re-seals the bundle, treats already-vanilla bundles as no-op, and errors if the backup is missing while the stub remains in `src-tauri/src/services/bepinex/trampoline.rs:447-472`; callers skip this entirely when third-party plugins or patchers remain, so `uninstall_bpp` only restores the vanilla bundle when BPP is the last installed mod in `src-tauri/src/services/bepinex/mod.rs:210-236`. diff --git a/docs/truth/verification.md b/docs/truth/verification.md index 0e63bc29..c06de8a5 100644 --- a/docs/truth/verification.md +++ b/docs/truth/verification.md @@ -1,7 +1,7 @@ --- status: truth topic: verification -last-verified: 68f2b1ef20e7c1c5c789bd5cde34821cf28efd57 +last-verified: f23d786ab3bf1998f556f5fe05b6e47467a7ea48 --- # Verification @@ -39,9 +39,10 @@ Use the smallest command that verifies the changed behavior; use the authoritati - Stream runtime tests exercise concurrent ensure, lifecycle transitions, failed start, and exclusive maintenance blocking in `src-tauri/src/stream/runtime.rs:440-572`. - The History facade's tempfile test uses a real SQLite schema and managed files across queries, reveal/delete, and both cleanup scopes in `src-tauri/src/services/history.rs:619-725`. - The framework-neutral Stream workflow uses fake ports and a fake scheduler to cover initialization failures, polling threshold/recovery, stale-response and lifecycle epochs, action exclusion, window/crop updates, transient feedback, disposal/restart, and both command adapters in `src/features/stream/streamWorkflow.test.ts:133-450`. -- Semantic-problem serialization plus History list/detail/action classification are covered at the Rust boundary in `src-tauri/src/problem.rs:41-71` and `src-tauri/src/services/history.rs:526-602`; the native adapter preservation path is covered in `src/api/commandClient.dispatch.test.ts:38-54`. +- Semantic-problem serialization plus History list/detail/action classification are covered at the Rust boundary in `src-tauri/src/problem.rs:42-84` and `src-tauri/src/services/history.rs:526-602`; the native adapter preservation path is covered in `src/api/commandClient.dispatch.test.ts:39-55`. - Focused History tests cover exclusive empty/error/content transitions, refresh-data preservation, stale completions, stopped/failed preview capability, bilingual problem presentation, and locale-aware dates in `src/features/history/historyPageState.test.ts:46-108`, `src/features/history/historyPreview.test.ts:5-49`, `src/features/history/historyProblems.test.ts:17-30`, and `src/features/history/format.test.ts:4-19`. - Run Detail tests cover its four page states, preserved refresh failure, stale completion, global action gate, target-scoped retry, and bilingual semantic problem presentation in `src/features/history/runDetailPageState.test.ts:56-187` and `src/features/history/runDetailProblems.test.ts:6-39`. The shared confirmation test verifies all dismiss controls can be visibly disabled for an uncancellable action in `src/components/ui/ConfirmDialog.test.tsx:98-101`. +- Install tests cover explicit initial detection, preserved refresh failure and retry, each primary-action branch, shared disabled/loading derivation, bilingual semantic warnings/problems, partial-failure recovery parameters, and native-adapter preservation in `src/features/install/installPageState.test.ts:43-228`, `src/features/install/installProblems.test.ts:10-89`, and `src/api/commandClient.dispatch.test.ts:57-76`. Rust tests pin Install semantic serialization and service-boundary classification in `src-tauri/src/problem.rs:46-84` and `src-tauri/src/services/install/mod.rs:282-366`. ## Version And Platform Guards