diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 24db537..293faf0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,9 +26,11 @@ jobs: uses: actions/checkout@v5 - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable + uses: dtolnay/rust-toolchain@1.99 with: - components: clippy + # A pinned toolchain installs only what is listed here; @stable + # used to borrow rustfmt from the runner's preinstalled Rust. + components: clippy, rustfmt - name: Cache Rust dependencies uses: Swatinem/rust-cache@v2 @@ -94,7 +96,7 @@ jobs: uses: actions/checkout@v5 - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable + uses: dtolnay/rust-toolchain@1.99 - name: Cache Rust dependencies uses: Swatinem/rust-cache@v2 diff --git a/.github/workflows/macos-signing-smoke.yml b/.github/workflows/macos-signing-smoke.yml index f48a806..87b8dc4 100644 --- a/.github/workflows/macos-signing-smoke.yml +++ b/.github/workflows/macos-signing-smoke.yml @@ -41,7 +41,7 @@ jobs: fi - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable + uses: dtolnay/rust-toolchain@1.99 - name: Cache Rust dependencies uses: Swatinem/rust-cache@v2 diff --git a/.github/workflows/macos-smoke.yml b/.github/workflows/macos-smoke.yml index d9c28ab..04db16a 100644 --- a/.github/workflows/macos-smoke.yml +++ b/.github/workflows/macos-smoke.yml @@ -24,7 +24,7 @@ jobs: uses: actions/checkout@v5 - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable + uses: dtolnay/rust-toolchain@1.99 - name: Cache Rust dependencies uses: Swatinem/rust-cache@v2 diff --git a/.github/workflows/prepare-release.yml b/.github/workflows/prepare-release.yml index 30ef6ef..41cbb86 100644 --- a/.github/workflows/prepare-release.yml +++ b/.github/workflows/prepare-release.yml @@ -49,7 +49,7 @@ jobs: fi - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable + uses: dtolnay/rust-toolchain@1.99 - name: Install cargo-edit uses: taiki-e/install-action@v2 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6b8f45b..4451590 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -41,7 +41,10 @@ jobs: uses: actions/checkout@v5 - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable + uses: dtolnay/rust-toolchain@1.99 + with: + # Release validation runs cargo fmt --check. + components: rustfmt # Intentionally NOT caching Rust dependencies on the release workflow: # release builds run once per tag and never benefit from a hot cache, diff --git a/Cargo.toml b/Cargo.toml index f23cc14..3e90a75 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,6 +10,7 @@ resolver = "3" [workspace.package] edition = "2024" +rust-version = "1.99" license = "MIT OR Apache-2.0" version = "0.4.2" diff --git a/README.md b/README.md index c3f8df3..3077098 100644 --- a/README.md +++ b/README.md @@ -440,7 +440,7 @@ into the project. ## Development See [DESIGN.md](./DESIGN.md) for the full architecture and design rules. To -build from source you need a recent stable Rust toolchain. The wxDragon GUI +build from source you need Rust 1.99 or newer. The wxDragon GUI feature on Windows additionally needs the Visual Studio C++ build tools, an LLVM `libclang.dll` discoverable through `LIBCLANG_PATH`, and Ninja on `PATH`. diff --git a/crates/rabbit-cli/Cargo.toml b/crates/rabbit-cli/Cargo.toml index 562c4e5..4ff80a9 100644 --- a/crates/rabbit-cli/Cargo.toml +++ b/crates/rabbit-cli/Cargo.toml @@ -2,6 +2,7 @@ name = "rabbit-cli" version.workspace = true edition.workspace = true +rust-version.workspace = true license.workspace = true [dependencies] diff --git a/crates/rabbit-core/Cargo.toml b/crates/rabbit-core/Cargo.toml index ae69224..f09e2fb 100644 --- a/crates/rabbit-core/Cargo.toml +++ b/crates/rabbit-core/Cargo.toml @@ -2,6 +2,7 @@ name = "rabbit-core" version.workspace = true edition.workspace = true +rust-version.workspace = true license.workspace = true [dependencies] diff --git a/crates/rabbit-core/src/artifact.rs b/crates/rabbit-core/src/artifact.rs index 108a487..ba1f102 100644 --- a/crates/rabbit-core/src/artifact.rs +++ b/crates/rabbit-core/src/artifact.rs @@ -1604,7 +1604,6 @@ fn invalid_file_url(input: &str) -> RabbitError { #[cfg(test)] mod tests { use std::fs; - use std::io::{Read as _, Write as _}; use std::net::TcpListener; use std::thread; use std::time::Duration; diff --git a/crates/rabbit-core/src/reapack.rs b/crates/rabbit-core/src/reapack.rs index 6bd0b7d..11b7907 100644 --- a/crates/rabbit-core/src/reapack.rs +++ b/crates/rabbit-core/src/reapack.rs @@ -534,8 +534,10 @@ mod tests { let written = fs::read(&ini_path).unwrap(); assert_eq!(&written[..2], &[0xFF, 0xFE], "UTF-16 LE BOM preserved"); let units: Vec = written[2..] - .chunks_exact(2) - .map(|pair| u16::from_le_bytes([pair[0], pair[1]])) + .as_chunks::<2>() + .0 + .iter() + .map(|pair| u16::from_le_bytes(*pair)) .collect(); let decoded = String::from_utf16(&units).unwrap(); assert!(decoded.contains(&format!("remote1={}|{}|1|2", TEST_REPO_NAME, TEST_REPO_URL))); diff --git a/crates/rabbit-core/src/text_file.rs b/crates/rabbit-core/src/text_file.rs index a67a146..aef4db0 100644 --- a/crates/rabbit-core/src/text_file.rs +++ b/crates/rabbit-core/src/text_file.rs @@ -85,13 +85,11 @@ fn decode_bytes(bytes: Vec) -> DecodedTextFile { } fn decode_utf16(bytes: &[u8], unit_from_bytes: fn([u8; 2]) -> u16) -> String { - let mut units: Vec = bytes - .chunks_exact(2) - .map(|pair| unit_from_bytes([pair[0], pair[1]])) - .collect(); + let (pairs, rest) = bytes.as_chunks::<2>(); + let mut units: Vec = pairs.iter().map(|pair| unit_from_bytes(*pair)).collect(); // A trailing odd byte means the file is malformed UTF-16; keep the byte // as its own unit rather than silently dropping it. - if let Some(&last) = bytes.chunks_exact(2).remainder().first() { + if let Some(&last) = rest.first() { units.push(u16::from(last)); } String::from_utf16_lossy(&units) diff --git a/crates/rabbit-platform/Cargo.toml b/crates/rabbit-platform/Cargo.toml index b968e58..0992fd0 100644 --- a/crates/rabbit-platform/Cargo.toml +++ b/crates/rabbit-platform/Cargo.toml @@ -2,6 +2,7 @@ name = "rabbit-platform" version.workspace = true edition.workspace = true +rust-version.workspace = true license.workspace = true [dependencies] diff --git a/crates/rabbit-ui-wxdragon/Cargo.toml b/crates/rabbit-ui-wxdragon/Cargo.toml index caaf18c..56140c1 100644 --- a/crates/rabbit-ui-wxdragon/Cargo.toml +++ b/crates/rabbit-ui-wxdragon/Cargo.toml @@ -2,6 +2,7 @@ name = "rabbit-ui-wxdragon" version.workspace = true edition.workspace = true +rust-version.workspace = true license.workspace = true [features] diff --git a/crates/rabbit-ui-wxdragon/src/lib.rs b/crates/rabbit-ui-wxdragon/src/lib.rs index 262eef8..e37bc9e 100644 --- a/crates/rabbit-ui-wxdragon/src/lib.rs +++ b/crates/rabbit-ui-wxdragon/src/lib.rs @@ -1,6 +1,13 @@ +//! The RABBIT wizard: `wizard` holds the pages' data and decisions, `wx_app` +//! renders them with wxWidgets. + #[cfg(feature = "gui")] mod wx_app; +mod wizard; + +pub use wizard::*; + /// Run the wxDragon wizard. Wraps the internal `wx_app::run` so the merged /// `rabbit` binary can spawn the GUI without needing to know the module's /// internals. @@ -8,6510 +15,3 @@ mod wx_app; pub fn run_gui() { wx_app::run(); } - -use std::collections::BTreeMap; -use std::fs; -use std::path::Path; -use std::path::PathBuf; - -use rabbit_core::arch_probe::probe_executable_architecture; -use rabbit_core::artifact::{default_cache_dir, expected_artifact_kind}; -use rabbit_core::cancel::CancelToken; -use rabbit_core::detection::{ - DiscoveryOptions, colocated_portable_root, default_standard_installation, detect_components, - discover_installations, -}; -use rabbit_core::latest::fetch_latest_versions; -use rabbit_core::localization::{DEFAULT_LOCALE, Localizer, embedded_locales}; -use rabbit_core::metadata::file_version; -use rabbit_core::model::{Architecture, Confidence, Installation, InstallationKind, Platform}; -use rabbit_core::operation::{ - PackageAutomationSupport, PackageOperationStatus, PlannedExecutionKind, PlannedExecutionPlan, - package_automation_support, preview_manual_instruction, -}; -use rabbit_core::package::{ - HostCapabilities, PACKAGE_OSARA, PackageSpec, builtin_package_specs, detect_host_capabilities, - host_supports_package, -}; -use rabbit_core::plan::{ - AvailablePackage, InstallPlan, PlanAction, PlanActionKind, build_install_plan, -}; -use rabbit_core::report::{default_report_path, save_json_and_text_reports}; -use rabbit_core::resource::{ - ResourceInitActionKind, ResourceInitOptions, initialize_resource_path, -}; -use rabbit_core::self_update::{ - ApplySelfUpdateOptions, DEFAULT_SELF_UPDATE_MANIFEST_URL, SelfUpdateApplyReport, - SelfUpdateCheckReport, apply_self_update, check_self_update, default_self_update_staging_dir, - resolve_self_update_release_notes, stage_self_update_with_progress, -}; -use rabbit_core::setup::{SetupOptions, SetupReport, setup_requires_extension_support}; -use rabbit_core::version::Version; -use rabbit_core::{RabbitError, Result}; -use serde::Serialize; - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct UiBootstrapOptions { - pub locale: String, - pub locales_dir: Option, - pub portable_roots: Vec, - pub online_versions: bool, -} - -impl Default for UiBootstrapOptions { - fn default() -> Self { - Self { - locale: DEFAULT_LOCALE.to_string(), - locales_dir: None, - portable_roots: Vec::new(), - online_versions: false, - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct WizardModel { - pub window_title: String, - pub platform: Platform, - pub architecture: Architecture, - pub text: WizardText, - pub bootstrap_options: UiBootstrapOptions, - pub current_step: WizardStep, - pub steps: Vec, - pub target_rows: Vec, - pub selected_target_index: Option, - pub package_rows: Vec, - pub configuration_rows: Vec, - pub available_packages: Vec, - pub review_lines: Vec, - pub notes: Vec, - pub controls: WizardControls, - pub language_options: Vec, - pub current_language: String, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct LanguageOption { - pub locale: String, - pub display_name: String, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct WizardText { - pub common_yes: String, - pub common_no: String, - pub target_heading: String, - pub target_language_label: String, - pub target_language_restart_note: String, - pub target_choice_label: String, - pub target_details_label: String, - pub target_empty: String, - pub target_portable_choice: String, - pub target_portable_folder_label: String, - pub target_portable_folder_message: String, - pub target_portable_folder_browse_label: String, - pub target_portable_pending_details: String, - pub target_custom_portable_label: String, - pub target_custom_portable_app_path_label: String, - pub target_custom_portable_path_label: String, - pub target_custom_portable_version_label: String, - pub target_custom_portable_writable_label: String, - pub target_custom_portable_note: String, - pub packages_heading: String, - pub packages_list_label: String, - pub packages_tree_group_label: String, - pub additional_software_tree_group_label: String, - pub language_tree_group_label: String, - pub configuration_tree_group_label: String, - pub reapack_ack_heading: String, - pub reapack_ack_body: String, - pub reapack_ack_link_label: String, - pub reapack_ack_confirm_label: String, - pub version_check_heading: String, - pub version_check_status_pending: String, - pub version_check_progress_label: String, - pub version_check_error_heading: String, - pub package_details_label: String, - pub packages_osara_keymap_heading: String, - pub packages_osara_keymap_replace_label: String, - pub packages_reaper_language_label: String, - pub packages_spanish_variant_label: String, - /// The selectable Spanish OSARA translations, in the same order as - /// [`VARIANT_CHOICE_IDS`]. - pub packages_spanish_variant_options: Vec, - pub packages_osara_keymap_unavailable_note: String, - pub packages_osara_keymap_preserve_note: String, - pub packages_osara_keymap_replace_note: String, - pub package_details_handling_prefix: String, - pub package_handling_automatic: String, - pub package_handling_unattended: String, - pub package_handling_planned: String, - pub package_handling_manual: String, - pub package_handling_unavailable: String, - pub review_heading: String, - pub review_target_prefix: String, - pub review_package_heading: String, - pub review_osara_keymap_heading: String, - pub review_osara_keymap_preserve: String, - pub review_osara_keymap_replace: String, - pub review_notes_heading: String, - pub review_preflight_prefix: String, - pub review_no_target: String, - pub review_no_package: String, - pub progress_heading: String, - pub progress_status: String, - pub progress_status_running: String, - /// Shown from the moment the user confirms the stop until the pipeline - /// finishes the step it is on. Deliberately not "cancelled": nothing has - /// stopped yet when it first appears. - pub progress_status_cancelling: String, - pub progress_details_label: String, - pub progress_details_idle: String, - pub progress_details_starting: String, - pub progress_details_cache_prefix: String, - pub done_heading: String, - pub done_status: String, - pub done_status_success: String, - pub done_status_completed_with_errors: String, - pub done_status_error: String, - pub done_status_cancelled: String, - pub done_status_no_packages: String, - pub done_show_details_label: String, - pub done_launch_reaper_label: String, - pub done_open_resource_label: String, - pub done_no_reaper_app: String, - pub done_launch_reaper_error_prefix: String, - pub done_open_resource_error_prefix: String, - pub done_self_update_apply_running: String, - pub done_self_update_error_prefix: String, - pub done_self_update_relaunch_prefix: String, - pub self_update_status_checking: String, - pub close_during_install_title: String, - pub close_during_install_body: String, - pub close_during_self_update_title: String, - pub close_during_self_update_body: String, - pub expert_enable_title: String, - pub expert_enable_body: String, - pub expert_disabled_title: String, - pub expert_disabled_body: String, - pub expert_first_page_title: String, - pub expert_first_page_body: String, - pub expert_busy_body: String, - pub expert_reaper_builds_label: String, - pub expert_reaper_builds_stable: String, - pub expert_reaper_builds_dev: String, - pub expert_osara_builds_label: String, - pub expert_osara_builds_snapshot: String, - pub expert_osara_builds_loading: String, - pub expert_osara_builds_unavailable: String, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum WizardStep { - Target, - VersionCheck, - Packages, - ReapackAcknowledgement, - Review, - Progress, - Done, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct WizardStepLabel { - pub step: WizardStep, - pub label: String, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct TargetRow { - pub label: String, - pub details: String, - pub app_path: Option, - pub planned_app_path: PathBuf, - pub path: PathBuf, - pub version: Option, - pub portable: bool, - pub selected: bool, - pub writable: bool, - /// Architecture of the REAPER binary at this target. Populated by the - /// detection layer's binary-header probe rather than the host arch, so - /// e.g. an Intel REAPER on an Apple Silicon Mac, or an x86_64 REAPER on - /// Windows-on-ARM, gets the arch-correct extension binaries (ReaPack, - /// SWS, OSARA) instead of host-matching ones REAPER would refuse to - /// load. Falls back to `Architecture::current()` when the binary can't - /// be probed (synthetic / portable targets without a binary on disk yet). - pub architecture: Architecture, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct PackageRow { - pub package_id: String, - pub display_name: String, - pub description: String, - pub selected: bool, - pub summary: String, - pub details: String, - pub installed_version: String, - pub available_version: String, - pub action: PlanActionKind, - pub action_label: String, - /// Plan-time action, captured before any user toggle of the package - /// checkbox. Used by the wizard's checklist handler to decide whether - /// re-checking a row means "Install" (originally not installed) or - /// "Update" (already installed) — the displayed `action` mutates as - /// the user clicks, but `original_action` is the authoritative anchor. - pub original_action: PlanActionKind, - pub reason: String, - pub handling_summary: String, - pub manual_attention_expected: bool, - /// `false` when this package can't be installed against the currently - /// selected target — the row is shown but its checkbox is disabled and - /// the row label carries a localized indicator. Today only true → false - /// flip is "JAWS-for-REAPER scripts on a portable REAPER target", since - /// the NSIS installer hard-codes `%APPDATA%\REAPER\UserPlugins\` and - /// can't honor the portable destination. - pub available_for_target: bool, - /// Localized reason matching `available_for_target == false`. `None` - /// when the row is available. - pub unavailability_reason: Option, - /// Which wizard UI group this row belongs to ("Packages" vs "Additional - /// software"). Mirrors the package spec's category. - pub category: rabbit_core::package::PackageCategory, - /// Mirrors the spec's `requires_standard_install`: when `true`, the row is - /// disabled on a portable REAPER target (the package installs to a fixed - /// location outside any portable folder). - pub requires_standard_install: bool, - /// The channel the available build is on; `None` is the regular release. - pub available_channel: Option, - /// The channel the installed build came from, as its receipt recorded it. - pub installed_channel: Option, -} - -/// Wizard-side row for a single [`crate::configuration::ConfigurationStep`] -/// (re-exported from `rabbit-core` as `rabbit_core::configuration::*`). -/// Mirrors the `PackageRow` shape just enough that the tree UI can render -/// it as a sibling leaf under the "Configuration" group, but configuration -/// steps don't have versions / actions / artifacts, so most package -/// fields don't apply. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ConfigurationRow { - /// Stable id of the underlying `ConfigurationStep`. - pub step_id: String, - /// Localized step name (the row's primary label). - pub display_name: String, - /// Localized free-form description shown in the package-details - /// pane when the row is selected. - pub description: String, - /// Whether the row's checkbox is currently ticked. Initialised from - /// the step's `recommended` flag intersected with the row's - /// `available_for_target`. - pub selected: bool, - /// Row label as rendered in the tree. For configuration rows the - /// summary is just `display_name` today; kept as a separate field - /// so the wizard's tree-refresh helpers can stay symmetric with - /// PackageRow. - pub summary: String, - /// Free-form details shown alongside `description` (status hints, - /// dependency reasons). Today carries the localized - /// "(unavailable: …)" sentence when the dependency package isn't - /// queued for install. - pub details: String, - /// `true` iff the step's dependency package (if any) is either - /// already installed on the selected target or queued for install - /// in the current package plan. The wizard greys out the row's - /// checkbox when this is `false`. - pub available_for_target: bool, - /// `true` iff the step's effect is already in place on disk under - /// the selected target (e.g. the ReaPack remote URL is already - /// listed in `reapack.ini`). The wizard treats this like - /// `available_for_target == false` for interactivity (the checkbox - /// is disabled) but uses a different reason string so the user - /// understands the row isn't unsupported, just done. - pub already_applied: bool, - /// Localized reason matching `available_for_target == false` OR - /// `already_applied == true`. `None` when the row is interactive. - pub unavailability_reason: Option, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct WizardControls { - pub back_label: String, - pub next_label: String, - pub install_label: String, - pub close_label: String, - pub can_go_back: bool, - pub can_go_next: bool, - pub can_install: bool, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] -#[serde(rename_all = "kebab-case")] -pub enum OsaraKeymapChoice { - PreserveCurrent, - ReplaceCurrent, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct WizardInstallOptions { - pub dry_run: bool, - pub allow_reaper_running: bool, - pub stage_unsupported: bool, - pub osara_keymap_choice: OsaraKeymapChoice, - /// Chosen package flavour per package id; see - /// [`rabbit_core::package::PackageVariant`]. - pub package_variants: std::collections::BTreeMap, - /// Language pack to make active after installing; see - /// [`WizardInstallRequest::reaper_language_package`]. - pub reaper_language_package: Option, - pub cache_dir: Option, - /// Channel per package for this run; see - /// [`WizardInstallRequest::package_channels`]. - pub package_channels: rabbit_core::package::PackageChannels, -} - -impl Default for WizardInstallOptions { - fn default() -> Self { - Self { - dry_run: false, - allow_reaper_running: false, - stage_unsupported: true, - osara_keymap_choice: OsaraKeymapChoice::ReplaceCurrent, - package_variants: std::collections::BTreeMap::new(), - reaper_language_package: None, - cache_dir: None, - package_channels: Default::default(), - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct WizardInstallRequest { - pub resource_path: PathBuf, - pub package_ids: Vec, - pub platform: Platform, - pub architecture: Architecture, - pub portable: bool, - pub target_app_path: Option, - pub dry_run: bool, - pub allow_reaper_running: bool, - pub stage_unsupported: bool, - pub osara_keymap_choice: OsaraKeymapChoice, - /// Chosen package flavour per package id, forwarded to - /// [`rabbit_core::setup::SetupOptions::package_variants`]. - pub package_variants: std::collections::BTreeMap, - /// Language pack to make active after installing. Several can be - /// installed at once — REAPER keeps them all — but only one is active. - pub reaper_language_package: Option, - pub cache_dir: PathBuf, - /// Packages whose plan-time decision was `Keep` (already current) but - /// the user explicitly checked the box anyway, opting in to a - /// re-install. The setup pipeline promotes these from Keep to Update - /// so the install step actually runs instead of being silently - /// skipped. - pub force_reinstall_packages: Vec, - /// Configuration step ids the user opted in to. Forwarded straight - /// through to [`SetupOptions::configuration_step_ids`]. - pub configuration_step_ids: Vec, - /// Opt-out-remembering packages the user actively turned down: the row - /// had something to offer (an install or an update) and they left it - /// unticked. Recorded so the suggestion does not come back next launch. - /// - /// A `Keep` row is deliberately absent from this and from - /// `accepted_packages`. An installed, up-to-date package sits unticked - /// because there is nothing to do, which is silence rather than a - /// verdict; reading it as a refusal would eventually turn RABBIT's own - /// successful install into a "no". - pub declined_packages: Vec, - /// Opt-out-remembering packages the user ticked, clearing any refusal - /// recorded earlier so a change of mind sticks. - pub accepted_packages: Vec, - /// The channel each package is installed from. Empty outside expert - /// mode, which is exactly what sends a package that came from a - /// development build back to stable. - pub package_channels: rabbit_core::package::PackageChannels, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct WizardInstallSummary { - pub status_line: String, - pub detail_lines: Vec, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct WizardPackagePlan { - pub package_rows: Vec, - pub notes: Vec, - pub can_install: bool, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct WizardReviewPreview { - pub lines: Vec, - pub can_install: bool, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] -#[serde(rename_all = "kebab-case")] -pub enum WizardOutcomeStatus { - Success, - /// The operation finished, but one or more packages failed or were - /// skipped because a dependency failed. Distinct from `Error` (the whole - /// operation aborted) so the saved report doesn't claim `success` when - /// the done page shows "completed with errors". - CompletedWithErrors, - Error, - /// The user stopped the run. Some packages may have installed before - /// the stop; the ones after it were never touched. Distinct from both - /// `CompletedWithErrors` (nothing broke) and `Error` (the run did what - /// it was asked to do, right up to being asked to stop). - Cancelled, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] -pub struct WizardOutcomeReport { - pub status: WizardOutcomeStatus, - pub resource_path: PathBuf, - pub target_app_path: Option, - pub package_ids: Vec, - pub platform: Platform, - pub architecture: Architecture, - pub portable: bool, - pub dry_run: bool, - pub allow_reaper_running: bool, - pub stage_unsupported: bool, - pub cache_dir: PathBuf, - pub osara_keymap_choice: OsaraKeymapChoice, - pub status_line: String, - pub detail_lines: Vec, - pub error_message: Option, - pub setup_report: Option, -} - -pub fn load_wizard_model(options: UiBootstrapOptions) -> Result { - let platform = Platform::current().ok_or(RabbitError::UnsupportedPlatform)?; - let localizer = localizer_from_options(&options)?; - // If RABBIT was dropped next to a portable REAPER, fold that folder into - // the portable roots so it's discovered as a target the user can update - // without reaching for the Browse button. - let colocated_portable = colocated_portable_root(); - let mut portable_roots = options.portable_roots.clone(); - if let Some(root) = &colocated_portable - && !portable_roots.contains(root) - { - portable_roots.push(root.clone()); - } - let discovered_installations = discover_installations(&DiscoveryOptions { - include_standard: true, - portable_roots, - })?; - let installations = selectable_installations(platform, discovered_installations); - // Prefer the co-located portable install as the default selection so the - // "drop RABBIT next to a portable REAPER" flow lands on it automatically; - // otherwise fall back to the first writable target. - let selected_target_index = colocated_portable - .as_ref() - .and_then(|root| { - installations.iter().position(|installation| { - installation.kind == InstallationKind::Portable - && &installation.resource_path == root - }) - }) - .or_else(|| { - installations - .iter() - .position(|installation| installation.writable) - }); - let target = selected_target_index.and_then(|index| installations.get(index).cloned()); - // Default the model's architecture to the initially-selected target's - // probed binary arch — that's what the artifact resolver actually - // wants. Falls back to the host arch when no writable target was - // discovered (the user will pick one manually before any artifact - // work runs anyway). - let architecture = target - .as_ref() - .and_then(|installation| installation.architecture) - .unwrap_or_else(Architecture::current); - let detections = match target.as_ref() { - Some(target) => detect_components(&target.resource_path, platform)?, - None => Vec::new(), - }; - // Per-provider failures don't abort the model load: the packages whose - // upstream answered keep their update flow, and the failed ones get - // their rows disabled below with the failure recorded as a note. - let (available, version_check_failures) = if options.online_versions { - let report = fetch_latest_versions()?; - (report.packages, report.failures) - } else { - (Vec::new(), Vec::new()) - }; - let desired = wizard_desired_package_ids(platform); - let plan = build_install_plan(target, &detections, &desired, &available); - - let mut model = model_from_plan_with_options( - &localizer, - options, - PlanModelInputs { - platform, - architecture, - installations, - selected_target_index, - available_packages: available, - plan, - }, - ); - if !version_check_failures.is_empty() { - let failures: Vec<(String, String)> = version_check_failures - .into_iter() - .map(|failure| (failure.package_id, failure.message)) - .collect(); - model.controls.can_install = apply_version_check_failures_to_rows( - &localizer, - &mut model.package_rows, - &mut model.notes, - &failures, - ); - } - Ok(model) -} - -fn selectable_installations( - platform: Platform, - mut installations: Vec, -) -> Vec { - if !installations - .iter() - .any(|installation| installation.kind == InstallationKind::Standard) - && let Some(standard) = default_standard_installation(platform) - { - installations.push(standard); - } - installations -} - -pub fn localizer_from_options(options: &UiBootstrapOptions) -> Result { - match &options.locales_dir { - Some(locales_dir) => Localizer::from_locale_dir(locales_dir, &options.locale), - None => Localizer::embedded(&options.locale), - } -} - -pub fn model_from_plan( - localizer: &Localizer, - platform: Platform, - architecture: Architecture, - installations: Vec, - selected_target_index: Option, - plan: InstallPlan, -) -> WizardModel { - model_from_plan_with_options( - localizer, - UiBootstrapOptions::default(), - PlanModelInputs { - platform, - architecture, - installations, - selected_target_index, - available_packages: Vec::new(), - plan, - }, - ) -} - -/// The per-run inputs to [`model_from_plan_with_options`], grouped so the -/// call doesn't carry a long positional argument list. -struct PlanModelInputs { - platform: Platform, - architecture: Architecture, - installations: Vec, - selected_target_index: Option, - available_packages: Vec, - plan: InstallPlan, -} - -fn model_from_plan_with_options( - localizer: &Localizer, - bootstrap_options: UiBootstrapOptions, - inputs: PlanModelInputs, -) -> WizardModel { - let PlanModelInputs { - platform, - architecture, - installations, - selected_target_index, - available_packages, - plan, - } = inputs; - let package_specs = builtin_package_specs(platform); - let text = wizard_text(localizer); - let target_rows = target_rows(localizer, &installations, selected_target_index); - let host = detect_host_capabilities(); - let target_resource_path = selected_target_index - .and_then(|idx| target_rows.get(idx)) - .map(|row| row.path.clone()); - let declined = target_resource_path - .as_deref() - .map(rabbit_core::receipt::declined_packages) - .unwrap_or_default(); - let installed_channels = target_resource_path - .as_deref() - .map(rabbit_core::receipt::installed_channels_at) - .unwrap_or_default(); - let package_rows = package_rows( - localizer, - &text, - platform, - architecture, - &package_specs, - &plan.actions, - &available_packages, - &host, - &declined, - &installed_channels, - ); - let configuration_rows = - configuration_rows(localizer, &package_rows, target_resource_path.as_deref()); - let review_lines = review_lines(localizer, &target_rows, &package_rows, &plan.notes); - let can_install = package_rows - .iter() - .any(|row| matches!(row.action, PlanActionKind::Install | PlanActionKind::Update)); - - WizardModel { - window_title: format!( - "{} v{}", - localizer.text("app-title").value, - env!("CARGO_PKG_VERSION") - ), - platform, - architecture, - bootstrap_options, - current_step: WizardStep::Target, - steps: wizard_steps(localizer), - selected_target_index, - target_rows, - package_rows, - configuration_rows, - available_packages, - review_lines, - notes: plan.notes, - text, - controls: WizardControls { - back_label: localized_wx_mnemonic_label( - localizer, - "wizard-button-back", - "wizard-button-back-mnemonic", - ), - next_label: localized_wx_mnemonic_label( - localizer, - "wizard-button-next", - "wizard-button-next-mnemonic", - ), - install_label: localized_wx_mnemonic_label( - localizer, - "wizard-button-install", - "wizard-button-install-mnemonic", - ), - close_label: localized_wx_mnemonic_label( - localizer, - "wizard-button-close", - "wizard-button-close-mnemonic", - ), - can_go_back: false, - can_go_next: selected_target_index.is_some(), - can_install, - }, - language_options: language_options(localizer), - current_language: localizer.active_locale().to_string(), - } -} - -fn language_options(localizer: &Localizer) -> Vec { - let mut options: Vec = embedded_locales() - .iter() - .map(|locale| { - let key = format!("wizard-locale-name-{locale}"); - let text = localizer.text(&key); - let display_name = if text.missing { - (*locale).to_string() - } else { - text.value - }; - LanguageOption { - locale: (*locale).to_string(), - display_name, - } - }) - .collect(); - options.sort_by(|a, b| a.display_name.cmp(&b.display_name)); - options -} - -fn wizard_text(localizer: &Localizer) -> WizardText { - WizardText { - common_yes: localizer.text("common-yes").value, - common_no: localizer.text("common-no").value, - target_heading: localizer.text("wizard-target-heading").value, - target_language_label: localizer.text("wizard-target-language-label").value, - target_language_restart_note: localizer.text("wizard-target-language-restart-note").value, - target_choice_label: localizer.text("wizard-target-choice-label").value, - target_details_label: localizer.text("wizard-target-details-label").value, - target_empty: localizer.text("wizard-target-empty").value, - target_portable_choice: localizer.text("wizard-target-portable-choice").value, - target_portable_folder_label: localizer.text("wizard-target-portable-folder-label").value, - target_portable_folder_message: localizer - .text("wizard-target-portable-folder-message") - .value, - target_portable_folder_browse_label: localizer - .text("wizard-target-portable-folder-browse-label") - .value, - target_portable_pending_details: localizer - .text("wizard-target-portable-pending-details") - .value, - target_custom_portable_label: localizer.text("wizard-target-custom-portable-label").value, - target_custom_portable_app_path_label: localizer - .text("wizard-target-custom-portable-app-path-label") - .value, - target_custom_portable_path_label: localizer - .text("wizard-target-custom-portable-path-label") - .value, - target_custom_portable_version_label: localizer - .text("wizard-target-custom-portable-version-label") - .value, - target_custom_portable_writable_label: localizer - .text("wizard-target-custom-portable-writable-label") - .value, - target_custom_portable_note: localizer.text("wizard-target-custom-portable-note").value, - packages_heading: localizer.text("wizard-packages-heading").value, - packages_list_label: localizer.text("wizard-packages-list-label").value, - packages_tree_group_label: localizer.text("wizard-packages-tree-group-label").value, - additional_software_tree_group_label: localizer - .text("wizard-additional-software-tree-group-label") - .value, - language_tree_group_label: localizer.text("wizard-language-tree-group-label").value, - configuration_tree_group_label: localizer - .text("wizard-configuration-tree-group-label") - .value, - reapack_ack_heading: localizer.text("wizard-reapack-ack-heading").value, - reapack_ack_body: localizer.text("wizard-reapack-ack-body").value, - reapack_ack_link_label: localizer.text("wizard-reapack-ack-link-label").value, - reapack_ack_confirm_label: localizer.text("wizard-reapack-ack-confirm-label").value, - version_check_heading: localizer.text("wizard-version-check-heading").value, - version_check_status_pending: localizer.text("wizard-version-check-status-pending").value, - version_check_progress_label: localizer.text("wizard-version-check-progress-label").value, - version_check_error_heading: localizer.text("wizard-version-check-error-heading").value, - package_details_label: localizer.text("wizard-package-details-label").value, - packages_osara_keymap_heading: localizer.text("wizard-packages-osara-keymap-heading").value, - packages_osara_keymap_replace_label: localizer - .text("wizard-packages-osara-keymap-replace-label") - .value, - packages_reaper_language_label: localizer.text("packages-reaper-language-label").value, - packages_spanish_variant_label: localizer.text("packages-spanish-variant-label").value, - packages_spanish_variant_options: VARIANT_CHOICE_IDS - .iter() - .map(|id| { - localizer - .text(&format!("package-langpack-es-variant-{id}")) - .value - }) - .collect(), - packages_osara_keymap_unavailable_note: localizer - .text("wizard-packages-osara-keymap-unavailable-note") - .value, - packages_osara_keymap_preserve_note: localizer - .text("wizard-packages-osara-keymap-preserve-note") - .value, - packages_osara_keymap_replace_note: localizer - .text("wizard-packages-osara-keymap-replace-note") - .value, - package_details_handling_prefix: localizer - .text("wizard-package-details-handling-prefix") - .value, - package_handling_automatic: localizer.text("wizard-package-handling-automatic").value, - package_handling_unattended: localizer.text("wizard-package-handling-unattended").value, - package_handling_planned: localizer.text("wizard-package-handling-planned").value, - package_handling_manual: localizer.text("wizard-package-handling-manual").value, - package_handling_unavailable: localizer.text("wizard-package-handling-unavailable").value, - review_heading: localizer.text("wizard-review-heading").value, - review_target_prefix: localizer.text("wizard-review-target-prefix").value, - review_package_heading: localizer.text("wizard-review-package-heading").value, - review_osara_keymap_heading: localizer.text("wizard-review-osara-keymap-heading").value, - review_osara_keymap_preserve: localizer.text("wizard-review-osara-keymap-preserve").value, - review_osara_keymap_replace: localizer.text("wizard-review-osara-keymap-replace").value, - review_notes_heading: localizer.text("wizard-review-notes-heading").value, - review_preflight_prefix: localizer.text("wizard-review-preflight-prefix").value, - review_no_target: localizer.text("wizard-review-no-target").value, - review_no_package: localizer.text("wizard-review-no-package").value, - progress_heading: localizer.text("wizard-progress-heading").value, - progress_status: localizer.text("wizard-progress-status-idle").value, - done_heading: localizer.text("wizard-done-heading").value, - done_status: localizer.text("wizard-done-status-idle").value, - progress_status_running: localizer.text("wizard-progress-status-running").value, - progress_status_cancelling: localizer.text("wizard-progress-status-cancelling").value, - progress_details_label: localizer.text("wizard-progress-details-label").value, - progress_details_idle: localizer.text("wizard-progress-details-idle").value, - progress_details_starting: localizer.text("wizard-progress-details-starting").value, - progress_details_cache_prefix: localizer.text("wizard-progress-details-cache-prefix").value, - done_status_success: localizer.text("wizard-done-status-success").value, - done_status_completed_with_errors: localizer - .text("wizard-done-status-completed-with-errors") - .value, - done_status_error: localizer.text("wizard-done-status-error").value, - done_status_cancelled: localizer.text("wizard-done-status-cancelled").value, - done_status_no_packages: localizer.text("wizard-done-status-no-packages").value, - done_show_details_label: localizer.text("wizard-done-show-details").value, - done_launch_reaper_label: localized_wx_mnemonic_label( - localizer, - "wizard-done-launch-reaper", - "wizard-done-launch-reaper-mnemonic", - ), - done_open_resource_label: localized_wx_mnemonic_label( - localizer, - "wizard-done-open-resource", - "wizard-done-open-resource-mnemonic", - ), - done_no_reaper_app: localizer.text("wizard-done-no-reaper-app").value, - done_launch_reaper_error_prefix: localizer - .text("wizard-done-launch-reaper-error-prefix") - .value, - done_open_resource_error_prefix: localizer - .text("wizard-done-open-resource-error-prefix") - .value, - done_self_update_apply_running: localizer - .text("wizard-done-self-update-apply-running") - .value, - done_self_update_error_prefix: localizer.text("wizard-done-self-update-error-prefix").value, - done_self_update_relaunch_prefix: localizer - .text("wizard-done-self-update-relaunch-prefix") - .value, - self_update_status_checking: localizer.text("wizard-self-update-status-checking").value, - close_during_install_title: localizer.text("wizard-close-during-install-title").value, - close_during_install_body: localizer.text("wizard-close-during-install-body").value, - close_during_self_update_title: localizer - .text("wizard-close-during-self-update-title") - .value, - close_during_self_update_body: localizer.text("wizard-close-during-self-update-body").value, - expert_enable_title: localizer.text("wizard-expert-enable-title").value, - expert_enable_body: localizer.text("wizard-expert-enable-body").value, - expert_disabled_title: localizer.text("wizard-expert-disabled-title").value, - expert_disabled_body: localizer.text("wizard-expert-disabled-body").value, - expert_first_page_title: localizer.text("wizard-expert-first-page-title").value, - expert_first_page_body: localizer.text("wizard-expert-first-page-body").value, - expert_busy_body: localizer.text("wizard-expert-busy-body").value, - expert_reaper_builds_label: localizer.text("wizard-expert-reaper-builds-label").value, - expert_reaper_builds_stable: localizer.text("wizard-expert-reaper-builds-stable").value, - expert_reaper_builds_dev: localizer.text("package-channel-reaper-dev").value, - expert_osara_builds_label: localizer.text("wizard-expert-osara-builds-label").value, - expert_osara_builds_snapshot: localizer.text("wizard-expert-osara-builds-snapshot").value, - expert_osara_builds_loading: localizer.text("wizard-expert-osara-builds-loading").value, - expert_osara_builds_unavailable: localizer - .text("wizard-expert-osara-builds-unavailable") - .value, - } -} - -fn localized_wx_mnemonic_label(localizer: &Localizer, label_id: &str, mnemonic_id: &str) -> String { - let label = localizer.text(label_id).value; - // wxWidgets' OSX backend binds button-label `&` mnemonics as Cmd+letter - // accelerators, which collides with macOS system shortcuts (Cmd+C - // copy, Cmd+S save, Cmd+I info, …) and would, e.g., trigger the - // Close button when the user just wanted to copy. Apple's HIG also - // doesn't use mnemonics on buttons, so dropping them on macOS is the - // platform-appropriate behavior in addition to fixing the collision. - // Other platforms keep the underlined mnemonic + Alt/Option key. - if cfg!(target_os = "macos") { - return escape_wx_label(&label); - } - wx_mnemonic_label(&label, &localizer.text(mnemonic_id).value) -} - -fn wx_mnemonic_label(label: &str, mnemonic: &str) -> String { - let Some(key) = mnemonic.trim().chars().next() else { - return escape_wx_label(label); - }; - - let mut output = String::new(); - let mut inserted = false; - for label_char in label.chars() { - if !inserted && mnemonic_matches(label_char, key) { - output.push('&'); - inserted = true; - } - push_escaped_wx_label_char(&mut output, label_char); - } - - if !inserted { - if !output.is_empty() { - output.push(' '); - } - output.push('('); - output.push('&'); - push_escaped_wx_label_char(&mut output, key); - output.push(')'); - } - - output -} - -fn escape_wx_label(label: &str) -> String { - let mut output = String::new(); - for label_char in label.chars() { - push_escaped_wx_label_char(&mut output, label_char); - } - output -} - -fn push_escaped_wx_label_char(output: &mut String, label_char: char) { - if label_char == '&' { - output.push_str("&&"); - } else { - output.push(label_char); - } -} - -fn mnemonic_matches(label_char: char, mnemonic: char) -> bool { - label_char == mnemonic - || label_char.eq_ignore_ascii_case(&mnemonic) - || label_char.to_lowercase().to_string() == mnemonic.to_lowercase().to_string() -} - -fn wizard_steps(localizer: &Localizer) -> Vec { - [ - (WizardStep::Target, "wizard-step-target"), - (WizardStep::VersionCheck, "wizard-step-version-check"), - (WizardStep::Packages, "wizard-step-packages"), - ( - WizardStep::ReapackAcknowledgement, - "wizard-step-reapack-acknowledgement", - ), - (WizardStep::Review, "wizard-step-review"), - (WizardStep::Progress, "wizard-step-progress"), - (WizardStep::Done, "wizard-step-done"), - ] - .into_iter() - .map(|(step, key)| WizardStepLabel { - step, - label: localizer.text(key).value, - }) - .collect() -} - -fn target_rows( - localizer: &Localizer, - installations: &[Installation], - selected_target_index: Option, -) -> Vec { - installations - .iter() - .enumerate() - .map(|(index, installation)| { - target_row( - localizer, - installation, - Some(index) == selected_target_index, - ) - }) - .collect() -} - -fn target_row(localizer: &Localizer, installation: &Installation, selected: bool) -> TargetRow { - let version = installation - .version - .as_ref() - .map(ToString::to_string) - .unwrap_or_else(|| localizer.text("detect-version-unknown").value); - // Dropdown label shows the *install directory* (where reaper.exe - // lives), not the resource folder. For a standard install that's - // typically `C:\Program Files\REAPER (x64)`; for portable it's the - // portable folder itself. The resource folder still appears in the - // expanded "Target details" pane via `wizard-target-details`. - let install_dir = installation - .app_path - .parent() - .map(|parent| parent.display().to_string()) - .unwrap_or_else(|| installation.app_path.display().to_string()); - TargetRow { - label: localizer - .format( - "wizard-target-row", - &[ - ("version", version.as_str()), - ("path", install_dir.as_str()), - ], - ) - .value, - details: localizer - .format( - "wizard-target-details", - &[ - ("app_path", &installation.app_path.display().to_string()), - ("version", version.as_str()), - ("path", &installation.resource_path.display().to_string()), - ( - "writable", - yes_no(localizer, installation.writable).as_str(), - ), - ], - ) - .value, - app_path: installation - .app_path - .exists() - .then(|| installation.app_path.clone()), - planned_app_path: installation.app_path.clone(), - path: installation.resource_path.clone(), - version: installation.version.clone(), - portable: installation.kind == InstallationKind::Portable, - selected, - writable: installation.writable, - architecture: installation - .architecture - .unwrap_or_else(Architecture::current), - } -} - -pub fn install_request_from_model( - model: &WizardModel, - selected_target_index: Option, - selected_package_indices: &[usize], - options: WizardInstallOptions, -) -> Result { - let target = selected_target_index - .and_then(|index| model.target_rows.get(index)) - .ok_or_else(|| RabbitError::PreflightFailed { - message: "No REAPER installation target was selected.".to_string(), - })?; - - install_request_from_target(model, target, selected_package_indices, options) -} - -pub fn install_request_from_target( - model: &WizardModel, - target: &TargetRow, - selected_package_indices: &[usize], - options: WizardInstallOptions, -) -> Result { - let configuration_step_ids = selected_configuration_step_ids(&model.configuration_rows); - install_request_from_target_and_rows( - model, - target, - &model.package_rows, - selected_package_indices, - configuration_step_ids, - options, - ) -} - -pub fn install_request_from_target_and_rows( - model: &WizardModel, - target: &TargetRow, - package_rows: &[PackageRow], - selected_package_indices: &[usize], - configuration_step_ids: Vec, - options: WizardInstallOptions, -) -> Result { - if !target.writable { - return Err(RabbitError::PreflightFailed { - message: format!( - "Target resource path is not writable: {}", - target.path.display() - ), - }); - } - - let package_ids = package_ids_for_rows(package_rows, selected_package_indices); - if package_ids.is_empty() && configuration_step_ids.is_empty() { - return Err(RabbitError::PreflightFailed { - message: "No package or configuration step was selected for installation or update." - .to_string(), - }); - } - let osara_selected = package_ids.iter().any(|id| id == PACKAGE_OSARA); - // A row is "force reinstall" when the user has the box checked but - // the original detection said the package was already current — i.e. - // the plan would normally Keep it, but the user explicitly opted in - // to a re-run. Detected via `original_action == Keep` on a checked - // row (the toggle helper promotes the displayed `action` to Update - // in that case, but `original_action` keeps the plan-time decision - // for exactly this disambiguation). - let force_reinstall_packages = selected_package_indices - .iter() - .filter_map(|index| { - let row = package_rows.get(*index)?; - if row.original_action == PlanActionKind::Keep { - Some(row.package_id.clone()) - } else { - None - } - }) - .collect(); - - // Only rows that actually offered something carry a verdict: ticked is - // a yes, unticked is a no. A `Keep` row offered nothing, so it says - // nothing either way. - let remembers_opt_out: std::collections::BTreeSet = - builtin_package_specs(model.platform) - .into_iter() - .filter(|spec| spec.remember_opt_out) - .map(|spec| spec.id) - .collect(); - let mut declined_packages = Vec::new(); - let mut accepted_packages = Vec::new(); - for (index, row) in package_rows.iter().enumerate() { - if !remembers_opt_out.contains(&row.package_id) { - continue; - } - if selected_package_indices.contains(&index) { - accepted_packages.push(row.package_id.clone()); - } else if row.original_action != PlanActionKind::Keep { - declined_packages.push(row.package_id.clone()); - } - } - - Ok(WizardInstallRequest { - resource_path: target.path.clone(), - package_ids, - platform: model.platform, - architecture: target.architecture, - portable: target.portable, - target_app_path: Some(target.planned_app_path.clone()), - dry_run: options.dry_run, - allow_reaper_running: options.allow_reaper_running, - stage_unsupported: options.stage_unsupported, - osara_keymap_choice: if osara_selected { - options.osara_keymap_choice - } else { - OsaraKeymapChoice::PreserveCurrent - }, - package_variants: options.package_variants.clone(), - reaper_language_package: options.reaper_language_package.clone(), - cache_dir: options.cache_dir.unwrap_or_else(default_cache_dir), - force_reinstall_packages, - configuration_step_ids, - declined_packages, - accepted_packages, - package_channels: options.package_channels.clone(), - }) -} - -pub fn package_ids_for_indices(model: &WizardModel, indices: &[usize]) -> Vec { - package_ids_for_rows(&model.package_rows, indices) -} - -pub fn package_ids_for_rows(package_rows: &[PackageRow], indices: &[usize]) -> Vec { - let mut package_ids = Vec::new(); - for index in indices { - let Some(row) = package_rows.get(*index) else { - continue; - }; - if !package_ids.contains(&row.package_id) { - package_ids.push(row.package_id.clone()); - } - } - package_ids -} - -pub fn osara_selected_for_rows(package_rows: &[PackageRow], indices: &[usize]) -> bool { - indices - .iter() - .filter_map(|index| package_rows.get(*index)) - .any(|row| row.package_id == PACKAGE_OSARA) -} - -/// Whether a package offering a variant choice is currently selected for -/// install, so the wizard can enable its choice control. Today only the -/// Spanish language pack qualifies: its two variants pick which OSARA -/// translation loads. -pub fn variant_choice_package_selected(package_rows: &[PackageRow], indices: &[usize]) -> bool { - indices - .iter() - .filter_map(|index| package_rows.get(*index)) - .any(|row| row.package_id == VARIANT_CHOICE_PACKAGE_ID) -} - -/// Ticked language packs, in row order, as (package id, display name). The -/// wizard's "REAPER language after installation" dropdown lists these: any -/// number can be installed side by side, but exactly one is made active. -pub fn selected_language_packs( - package_rows: &[PackageRow], - indices: &[usize], -) -> Vec<(String, String)> { - indices - .iter() - .filter_map(|index| package_rows.get(*index)) - .filter(|row| row.category == rabbit_core::package::PackageCategory::Language) - .map(|row| (row.package_id.clone(), row.display_name.clone())) - .collect() -} - -/// The one package that currently offers a variant choice in the wizard. -pub const VARIANT_CHOICE_PACKAGE_ID: &str = "langpack-es"; -/// The selectable Spanish OSARA translations, in the order the wizard's -/// dropdown lists them. Index 0 is the default. Kept as an explicit list so -/// the dropdown's position maps to a manifest variant id without the UI -/// hard-coding which is which. -pub const VARIANT_CHOICE_IDS: &[&str] = &["rae", "pma"]; - -/// Build the `package_variants` map from the wizard's dropdown selection. -/// -/// Always records an explicit choice. Picking the first entry has to mean -/// "use REAPER Accesible español", not "no opinion" — the install path -/// falls back to the previously-installed variant when no choice is given, -/// so an absent entry would make it impossible to switch back from Team PMA -/// once chosen. An out-of-range index falls back to the first entry. -pub fn package_variants_from_choice( - selection: Option, -) -> std::collections::BTreeMap { - let index = selection.unwrap_or(0) as usize; - let id = VARIANT_CHOICE_IDS - .get(index) - .copied() - .unwrap_or(VARIANT_CHOICE_IDS[0]); - let mut variants = std::collections::BTreeMap::new(); - variants.insert(VARIANT_CHOICE_PACKAGE_ID.to_string(), id.to_string()); - variants -} - -/// Dropdown index matching the Spanish variant currently installed at -/// `resource_path`, so the wizard shows the user's existing choice rather -/// than a default that would silently switch them. Falls back to the first -/// entry when nothing is installed. -pub fn spanish_variant_selection(resource_path: &std::path::Path) -> u32 { - let installed = rabbit_core::package::effective_variant_id( - resource_path, - VARIANT_CHOICE_PACKAGE_ID, - &std::collections::BTreeMap::new(), - ); - installed - .and_then(|id| { - VARIANT_CHOICE_IDS - .iter() - .position(|candidate| *candidate == id) - }) - .unwrap_or(0) as u32 -} - -/// Returns true when ReaPack is selected and its planned action would -/// actually stage the package (Install or Update), i.e. the run will need -/// the donation acknowledgement before it proceeds. -pub fn reapack_selected_for_install_or_update( - package_rows: &[PackageRow], - indices: &[usize], -) -> bool { - indices - .iter() - .filter_map(|index| package_rows.get(*index)) - .any(|row| { - row.package_id == rabbit_core::package::PACKAGE_REAPACK - && matches!(row.action, PlanActionKind::Install | PlanActionKind::Update) - }) -} - -pub fn osara_keymap_note( - model: &WizardModel, - osara_selected: bool, - choice: OsaraKeymapChoice, -) -> String { - if !osara_selected { - return model.text.packages_osara_keymap_unavailable_note.clone(); - } - - match choice { - OsaraKeymapChoice::PreserveCurrent => { - model.text.packages_osara_keymap_preserve_note.clone() - } - OsaraKeymapChoice::ReplaceCurrent => model.text.packages_osara_keymap_replace_note.clone(), - } -} - -pub fn review_lines_for_indices( - model: &WizardModel, - selected_target_index: Option, - selected_package_indices: &[usize], -) -> Vec { - let target = selected_target_index.and_then(|index| model.target_rows.get(index)); - review_lines_for_target(model, target, selected_package_indices) -} - -pub fn review_lines_for_target( - model: &WizardModel, - target: Option<&TargetRow>, - selected_package_indices: &[usize], -) -> Vec { - review_lines_for_package_rows( - model, - target, - selected_package_indices, - &model.package_rows, - &model.notes, - ) -} - -pub fn review_lines_for_package_rows( - model: &WizardModel, - target: Option<&TargetRow>, - selected_package_indices: &[usize], - package_rows: &[PackageRow], - notes: &[String], -) -> Vec { - let mut lines = Vec::new(); - if let Some(target) = target { - lines.push(format!( - "{}: {}", - model.text.review_target_prefix, - target.path.display() - )); - } else { - lines.push(model.text.review_no_target.clone()); - } - - let package_ids = package_ids_for_rows(package_rows, selected_package_indices); - if package_ids.is_empty() { - lines.push(model.text.review_no_package.clone()); - } else { - for package_id in package_ids { - if let Some(package) = package_rows - .iter() - .find(|package| package.package_id == package_id) - { - lines.push(format!( - "{}: {}", - package.display_name, package.action_label - )); - } - } - } - - lines.extend(notes.iter().cloned()); - lines -} - -pub fn build_review_preview_for_package_rows( - model: &WizardModel, - target: Option<&TargetRow>, - selected_package_indices: &[usize], - package_rows: &[PackageRow], - notes: &[String], - osara_keymap_choice: OsaraKeymapChoice, -) -> WizardReviewPreview { - let Some(target) = target else { - return WizardReviewPreview { - lines: vec![model.text.review_no_target.clone()], - can_install: false, - }; - }; - - let mut lines = vec![format!( - "{}: {}", - model.text.review_target_prefix, - target.path.display() - )]; - - let mut can_install = !selected_package_indices.is_empty(); - - // Run the resource-path preflight to surface fatal blockers (read-only - // target, REAPER currently running, etc.) — those still need to land in - // the GUI summary so the user knows install is blocked. Successful - // resource-init details (the long "Create directory…" / "Create file…" - // list) are now report-only; the GUI just notes that backups will be - // taken if needed. - match initialize_resource_path( - &target.path, - &ResourceInitOptions { - dry_run: true, - portable: target.portable, - include_extension_support_dirs: target.portable - || setup_requires_extension_support( - &selected_package_indices - .iter() - .filter_map(|index| package_rows.get(*index)) - .map(|package| package.package_id.clone()) - .collect::>(), - ), - allow_reaper_running: false, - target_app_path: Some(target.planned_app_path.clone()), - }, - ) { - Ok(_) => {} - Err(error) => { - can_install = false; - lines.push(format!("{}: {}", model.text.review_preflight_prefix, error)); - } - } - - lines.push(String::new()); - lines.push(model.text.review_package_heading.clone()); - if selected_package_indices.is_empty() { - lines.push(model.text.review_no_package.clone()); - } else { - for index in selected_package_indices { - if let Some(package) = package_rows.get(*index) { - lines.push(package.summary.clone()); - } - } - } - - if osara_selected_for_rows(package_rows, selected_package_indices) { - lines.push(String::new()); - lines.push(model.text.review_osara_keymap_heading.clone()); - lines.push(match osara_keymap_choice { - OsaraKeymapChoice::PreserveCurrent => model.text.review_osara_keymap_preserve.clone(), - OsaraKeymapChoice::ReplaceCurrent => model.text.review_osara_keymap_replace.clone(), - }); - } - - if !notes.is_empty() { - lines.push(String::new()); - lines.push(model.text.review_notes_heading.clone()); - lines.extend(notes.iter().cloned()); - } - - WizardReviewPreview { lines, can_install } -} - -pub fn package_requires_manual_attention( - _model: &WizardModel, - package: &PackageRow, - _osara_keymap_choice: OsaraKeymapChoice, -) -> bool { - matches!( - package.action, - PlanActionKind::Install | PlanActionKind::Update - ) && package.manual_attention_expected -} - -pub fn manual_attention_handling_summary( - _model: &WizardModel, - package: &PackageRow, - _osara_keymap_choice: OsaraKeymapChoice, -) -> String { - package.handling_summary.clone() -} - -pub fn preview_manual_instruction_lines( - model: &WizardModel, - target: &TargetRow, - package: &PackageRow, - osara_keymap_choice: OsaraKeymapChoice, -) -> Vec { - let Ok(kind) = expected_artifact_kind(&package.package_id, model.platform, model.architecture) - else { - return Vec::new(); - }; - let instruction = preview_manual_instruction( - &package.package_id, - kind, - &target.path, - Some(&target.planned_app_path), - matches!(osara_keymap_choice, OsaraKeymapChoice::ReplaceCurrent), - ); - let mut lines = instruction - .steps - .into_iter() - .map(|step| format!(" {step}")) - .collect::>(); - lines.extend( - instruction - .notes - .into_iter() - .map(|note| format!(" Note: {note}")), - ); - lines -} - -pub fn wizard_package_plan_for_target( - model: &WizardModel, - target: Option<&TargetRow>, -) -> Result { - wizard_package_plan_for_target_with_available(model, target, &model.available_packages) -} - -/// Like `wizard_package_plan_for_target`, but uses an explicit -/// `available_packages` list instead of the one stored in the model. This is -/// what the wizard calls after the GUI's background latest-version fetch -/// completes so the package list can be re-rendered with fresh upstream data -/// without rebuilding the whole model. -pub fn wizard_package_plan_for_target_with_available( - model: &WizardModel, - target: Option<&TargetRow>, - available_packages: &[AvailablePackage], -) -> Result { - let localizer = localizer_from_options(&model.bootstrap_options)?; - let detections = match target { - Some(target) => detect_components(&target.path, model.platform)?, - None => Vec::new(), - }; - let desired = wizard_desired_package_ids(model.platform); - let plan = build_install_plan( - target.map(|target| installation_from_target_row(model, target)), - &detections, - &desired, - available_packages, - ); - let package_specs = builtin_package_specs(model.platform); - let host = detect_host_capabilities(); - let declined = target - .map(|target| rabbit_core::receipt::declined_packages(&target.path)) - .unwrap_or_default(); - let installed_channels = target - .map(|target| rabbit_core::receipt::installed_channels_at(&target.path)) - .unwrap_or_default(); - let mut package_rows = package_rows( - &localizer, - &model.text, - model.platform, - model.architecture, - &package_specs, - &plan.actions, - available_packages, - &host, - &declined, - &installed_channels, - ); - - // Some packages can't honor a portable target: they install to a fixed - // location outside any portable REAPER folder. JAWS-for-REAPER scripts' - // NSIS package hard-codes `%APPDATA%\REAPER\UserPlugins\`; Surge XT's - // vendor installer writes to the system VST3 root + ProgramData / - // /Library/Application Support; app2clap installs into the per-user CLAP - // folder. Each declares `requires_standard_install` in the manifest, so - // the gate is data-driven rather than a hardcoded package-id list. Mark - // those rows unavailable on a portable target so the checklist disables - // their checkboxes and each row label carries a localized "(requires - // standard installation)" indicator. - if target.is_some_and(|target| target.portable) { - for row in &mut package_rows { - if row.requires_standard_install { - mark_row_unavailable(&localizer, row, "wizard-package-row-unavailable-portable"); - } - } - } - - let can_install = package_rows.iter().any(|row| { - row.available_for_target - && matches!(row.action, PlanActionKind::Install | PlanActionKind::Update) - }); - - Ok(WizardPackagePlan { - package_rows, - notes: plan.notes, - can_install, - }) -} - -/// Mark `row` as unavailable: force-uncheck it, record the localized reason, -/// and append a localized "(not available: )" indicator to the row -/// summary so the indicator shows up in the package CheckListBox label. -/// -/// Also flips the row's displayed action to `Keep` (mirroring what the -/// auto-untick path in [`package_rows`] does for non-recommended Install/ -/// Update rows). Without this, an Install/Update row that becomes unavailable -/// — e.g. JAWS-for-REAPER scripts on a portable target — would still read -/// "Will install" / "Will update" while sitting unticked and disabled. -/// `original_action` is preserved so the row can revert to its plan-time -/// intent if the unavailability is later lifted. -fn mark_row_unavailable(localizer: &Localizer, row: &mut PackageRow, reason_key: &str) { - let reason = localizer.text(reason_key).value; - row.available_for_target = false; - row.selected = false; - row.action = PlanActionKind::Keep; - row.action_label = action_label(localizer, PlanActionKind::Keep); - let summary = package_row_summary(localizer, row); - let indicator = localizer - .format( - "wizard-package-row-unavailable-suffix", - &[("reason", reason.as_str())], - ) - .value; - row.summary = format!("{summary} {indicator}"); - row.unavailability_reason = Some(reason); -} - -/// Apply per-package latest-version-check failures to a built package-row -/// set. Each failed package's row is force-unchecked and disabled (same -/// mechanism as the portable-target restriction) with a localized "version -/// check failed" reason in its label, and a note carrying the package name -/// plus the full error message is appended for the Review page. One -/// unreachable upstream (e.g. the SWS homepage being down) therefore -/// disables just that package instead of blocking the whole update flow. -/// -/// Returns the recomputed "at least one actionable Install/Update row is -/// left" flag, which the caller should store as its can-install state. -pub fn apply_version_check_failures_to_rows( - localizer: &Localizer, - package_rows: &mut [PackageRow], - notes: &mut Vec, - failures: &[(String, String)], -) -> bool { - for (package_id, message) in failures { - if let Some(row) = package_rows - .iter_mut() - .find(|row| row.package_id == *package_id) - { - mark_row_unavailable( - localizer, - row, - "wizard-package-row-unavailable-version-check", - ); - } - let display = localized_package_display_name(localizer, package_id); - notes.push( - localizer - .format( - "wizard-version-check-failed-note", - &[("package", display.as_str()), ("message", message.as_str())], - ) - .value, - ); - } - package_rows.iter().any(|row| { - row.available_for_target - && matches!(row.action, PlanActionKind::Install | PlanActionKind::Update) - }) -} - -/// Recompute a `PackageRow`'s `action`, `action_label`, `summary`, and -/// `selected` fields to match a new checkbox state. Used by the wizard's -/// CheckListBox toggle handler so the visible "Install/Update/Keep" label -/// follows what the user just clicked. Returns the freshly-formatted -/// summary so the caller can also push it into the CheckListBox label. -pub fn apply_checkbox_state_to_package_row( - model: &WizardModel, - row: &mut PackageRow, - checked: bool, -) -> Result { - let localizer = localizer_from_options(&model.bootstrap_options)?; - let new_action = if checked { - // Originally-not-installed packages stay "Install" when re-checked; - // anything else means the package is on disk, so re-checking it - // means "Update" (re-stage the latest known upstream version). - match row.original_action { - PlanActionKind::Install => PlanActionKind::Install, - _ => PlanActionKind::Update, - } - } else { - PlanActionKind::Keep - }; - row.action = new_action; - row.action_label = action_label(&localizer, new_action); - row.summary = package_row_summary(&localizer, row); - row.selected = checked; - Ok(row.summary.clone()) -} - -/// Localized package display name for `package_id`, falling back to the raw id -/// when no Fluent key is available. Used by the version-check progress log. -pub fn localized_package_display_name(localizer: &Localizer, package_id: &str) -> String { - let key = format!("package-{package_id}"); - let text = localizer.text(&key); - if text.missing { - package_id.to_string() - } else { - text.value - } -} - -/// List of package ids the wizard cares about for a given platform — exposed -/// so the GUI can iterate them without duplicating builtin_package_specs. -/// Host-conditional packages (e.g. JAWS-for-REAPER scripts) are filtered out -/// when the corresponding host facility isn't available. -pub fn wizard_desired_package_ids(platform: Platform) -> Vec { - wizard_desired_package_ids_for_host(platform, &detect_host_capabilities()) -} - -/// Same as [`wizard_desired_package_ids`] but with an explicit host snapshot, -/// so tests can pin "JAWS detected"/"JAWS missing" without touching the real -/// filesystem. -pub fn wizard_desired_package_ids_for_host( - platform: Platform, - host: &HostCapabilities, -) -> Vec { - builtin_package_specs(platform) - .into_iter() - .filter(|spec| host_supports_package(spec, host)) - .map(|spec| spec.id) - .collect() -} - -pub fn custom_portable_target_row(model: &WizardModel, path: PathBuf, selected: bool) -> TargetRow { - let writable = is_probably_writable(&path); - let writable_text = if writable { - model.text.common_yes.clone() - } else { - model.text.common_no.clone() - }; - let app_path = portable_reaper_app_path(model.platform, &path); - let version = app_path - .as_ref() - .and_then(|path| file_version(path).ok().flatten()); - let version_text = version - .as_ref() - .map(ToString::to_string) - .unwrap_or_else(|| unknown_version_text(model)); - TargetRow { - label: format!( - "{}: {}", - model.text.target_custom_portable_label, - path.display() - ), - details: format!( - "{}: {}\n{}: {}\n{}: {}\n{}: {}\n{}", - model.text.target_custom_portable_app_path_label, - app_path - .as_ref() - .unwrap_or(&default_portable_reaper_app_path(model.platform, &path)) - .display(), - model.text.target_custom_portable_path_label, - path.display(), - model.text.target_custom_portable_version_label, - version_text, - model.text.target_custom_portable_writable_label, - writable_text, - model.text.target_custom_portable_note - ), - app_path: app_path.clone(), - planned_app_path: app_path - .clone() - .unwrap_or_else(|| default_portable_reaper_app_path(model.platform, &path)), - path, - version, - portable: true, - selected, - writable, - // Probe the portable target's REAPER binary if it exists on disk; - // otherwise inherit the host arch (the user is staging a fresh - // portable, so the upcoming install will land a host-arch REAPER). - architecture: app_path - .as_deref() - .map(probe_executable_architecture) - .unwrap_or_else(Architecture::current), - } -} - -pub fn refreshed_target_row(model: &WizardModel, target: &TargetRow) -> TargetRow { - if target.portable { - return custom_portable_target_row(model, target.path.clone(), target.selected); - } - - // Re-probe the target binary's architecture instead of inheriting the - // host arch — `target.architecture` may be stale if the user swapped - // REAPER builds (Intel ↔ Apple Silicon, x64 ↔ ARM) under the same - // install path between wizard launches. - let probed_architecture = probe_executable_architecture(&target.planned_app_path); - let installation = Installation { - kind: InstallationKind::Standard, - platform: model.platform, - app_path: target.planned_app_path.clone(), - resource_path: target.path.clone(), - version: file_version(&target.planned_app_path).ok().flatten(), - architecture: Some(probed_architecture), - writable: is_probably_writable(&target.path), - confidence: Confidence::Medium, - evidence: Vec::new(), - }; - - match localizer_from_options(&model.bootstrap_options) { - Ok(localizer) => target_row(&localizer, &installation, target.selected), - Err(_) => TargetRow { - label: target.label.clone(), - details: target.details.clone(), - app_path: installation - .app_path - .exists() - .then(|| installation.app_path.clone()), - planned_app_path: installation.app_path.clone(), - path: installation.resource_path.clone(), - version: installation.version.clone(), - portable: false, - selected: target.selected, - writable: installation.writable, - architecture: probed_architecture, - }, - } -} - -fn installation_from_target_row(model: &WizardModel, target: &TargetRow) -> Installation { - Installation { - kind: if target.portable { - InstallationKind::Portable - } else { - InstallationKind::Standard - }, - platform: model.platform, - app_path: target.planned_app_path.clone(), - resource_path: target.path.clone(), - version: target.version.clone(), - architecture: Some(target.architecture), - writable: target.writable, - confidence: Confidence::Medium, - evidence: Vec::new(), - } -} - -fn portable_reaper_app_path(platform: Platform, resource_path: &Path) -> Option { - match platform { - Platform::Windows => { - let app_path = resource_path.join("reaper.exe"); - app_path.is_file().then_some(app_path) - } - Platform::MacOs => fs::read_dir(resource_path) - .ok()? - .filter_map(std::result::Result::ok) - .map(|entry| entry.path()) - .find(|path| { - path.extension() - .and_then(|extension| extension.to_str()) - .is_some_and(|extension| extension.eq_ignore_ascii_case("app")) - && path - .file_name() - .and_then(|name| name.to_str()) - .is_some_and(|name| name.to_ascii_lowercase().contains("reaper")) - }), - } -} - -fn default_portable_reaper_app_path(platform: Platform, resource_path: &Path) -> PathBuf { - match platform { - Platform::Windows => resource_path.join("reaper.exe"), - Platform::MacOs => resource_path.join("REAPER.app"), - } -} - -pub fn execute_wizard_install(request: WizardInstallRequest) -> Result { - execute_wizard_install_with_progress( - request, - &rabbit_core::progress::ProgressReporter::noop(), - &CancelToken::new(), - ) -} - -/// Like [`execute_wizard_install`] but threads a [`ProgressReporter`] -/// through to the core setup pipeline so the wizard's progress page can -/// render a live status bar. The plain [`execute_wizard_install`] -/// delegates here with a [`ProgressReporter::noop`]. -/// -/// `cancel` travels the other way: the wizard flips it when the user asks -/// to stop, and the pipeline reads it at each package boundary. -/// -/// [`ProgressReporter`]: rabbit_core::progress::ProgressReporter -/// [`ProgressReporter::noop`]: rabbit_core::progress::ProgressReporter::noop -pub fn execute_wizard_install_with_progress( - request: WizardInstallRequest, - progress: &rabbit_core::progress::ProgressReporter, - cancel: &CancelToken, -) -> Result { - // Before any download, make Windows Defender ignore RABBIT's own cache - // folder so a freshly built, low-prevalence (but signed) installer — - // OSARA's snapshots especially — isn't quarantined as a false positive - // mid-download. Interactive GUI path only (the CLI stays non-interactive - // for unattended/CI use and never touches Defender); skipped on dry runs - // and off Windows. Best-effort: whatever the outcome, the install - // proceeds — a real block still surfaces the actionable guidance. - if !request.dry_run { - let outcome = - rabbit_core::antivirus::ensure_cache_excluded_from_antivirus(&request.cache_dir); - if !matches!( - outcome, - rabbit_core::antivirus::DefenderExclusionOutcome::Unsupported - ) { - eprintln!("antivirus cache exclusion: {outcome:?}"); - } - } - let report = rabbit_core::setup::execute_setup_operation_with_progress( - &request.resource_path, - &request.package_ids, - request.platform, - request.architecture, - &request.cache_dir, - &SetupOptions { - dry_run: request.dry_run, - portable: request.portable, - allow_reaper_running: request.allow_reaper_running, - stage_unsupported: request.stage_unsupported, - replace_osara_keymap: matches!( - request.osara_keymap_choice, - OsaraKeymapChoice::ReplaceCurrent - ), - target_app_path: request.target_app_path.clone(), - lock_path: None, - force_reinstall_packages: request.force_reinstall_packages.clone(), - package_variants: request.package_variants.clone(), - package_channels: request.package_channels.clone(), - reaper_language_package: request.reaper_language_package.clone(), - configuration_step_ids: request.configuration_step_ids.clone(), - }, - progress, - cancel, - )?; - - // Remember what the user decided about the packages that remember it, - // so a default RABBIT picked for them — a language pack matching - // RABBIT's own language — does not come back ticked on the next launch. - // The verdict is worked out where the rows are (see - // `install_request_from_target_and_rows`), because only there can a - // deliberate "no" be told apart from a row that had nothing to offer. - // The CLI records nothing: `--package` is a scope for one run, not a - // standing preference. - let has_verdict = - !(request.declined_packages.is_empty() && request.accepted_packages.is_empty()); - if !request.dry_run && has_verdict { - let recorded = rabbit_core::receipt::record_package_opt_outs( - &request.resource_path, - &request.declined_packages, - &request.accepted_packages, - ); - // A preference is never worth failing a finished install over. - if let Err(error) = recorded { - eprintln!("could not record package opt-outs: {error}"); - } - } - - Ok(report) -} - -pub fn run_wizard_self_update_check() -> Result { - let platform = Platform::current().ok_or(RabbitError::UnsupportedPlatform)?; - check_self_update(platform, DEFAULT_SELF_UPDATE_MANIFEST_URL) -} - -/// What's-New notes for a pending RABBIT update, covering every release -/// between the running version and the latest one. -/// -/// Called from the same startup worker thread as -/// [`run_wizard_self_update_check`], never from the UI thread: it performs an -/// HTTP round-trip, and the wizard must stay responsive while it runs. The -/// result is best-effort — `None` simply means the prompt falls back to its -/// plain version-to-version wording. -pub fn run_wizard_self_update_release_notes(report: &SelfUpdateCheckReport) -> Option { - resolve_self_update_release_notes(report) -} - -/// Download-then-install for RABBIT itself, reporting both phases through -/// `progress`. -/// -/// Runs on a worker thread, so `progress` is invoked off the UI thread and -/// the caller is responsible for forwarding events onto it — the same -/// contract the package install pipeline has. Pass -/// `ProgressReporter::noop()` to run it silently. -/// -/// [`ProgressReporter`]: rabbit_core::progress::ProgressReporter -pub fn run_wizard_self_update_apply( - progress: &rabbit_core::progress::ProgressReporter, -) -> Result { - let platform = Platform::current().ok_or(RabbitError::UnsupportedPlatform)?; - let staging_dir = default_self_update_staging_dir(); - let stage = stage_self_update_with_progress( - platform, - DEFAULT_SELF_UPDATE_MANIFEST_URL, - &staging_dir, - progress, - )?; - apply_self_update( - &stage, - &ApplySelfUpdateOptions { - install_root: None, - install_target_basename: None, - progress: Some(progress.clone()), - }, - ) -} - -/// Relaunch RABBIT after a self-update, keeping expert mode as it was: -/// nothing saves it, so the new process only has it if we pass it on. -pub fn relaunch_rabbit_after_apply(expert_mode: bool) -> Result { - rabbit_core::self_update::relaunch_current_executable_with(&[( - EXPERT_MODE_ENV, - expert_mode.then_some("1"), - )]) -} - -pub fn format_self_update_check_summary( - localizer: &Localizer, - report: &SelfUpdateCheckReport, -) -> String { - let current = report.current_version.to_string(); - let latest = report.latest_version.to_string(); - if report.update_available { - localizer - .format( - "self-update-status-update-available", - &[ - ("current", current.as_str()), - ("latest", latest.as_str()), - ("channel", report.channel.as_str()), - ], - ) - .value - } else { - localizer - .format( - "self-update-status-up-to-date", - &[ - ("current", current.as_str()), - ("channel", report.channel.as_str()), - ], - ) - .value - } -} - -pub fn format_self_update_apply_summary( - localizer: &Localizer, - report: &SelfUpdateApplyReport, -) -> String { - let version = report.stage.check.latest_version.to_string(); - if report.replaced_files.is_empty() { - return localizer - .format( - "self-update-apply-no-files-replaced", - &[("version", version.as_str())], - ) - .value; - } - - let count = report.replaced_files.len().to_string(); - let install_root = report.install_root.display().to_string(); - let mut summary = localizer - .format( - "self-update-apply-replaced-summary", - &[ - ("count", count.as_str()), - ("root", install_root.as_str()), - ("version", version.as_str()), - ], - ) - .value; - if let Some(signature_summary) = format_signature_verdict_summary(localizer, report) { - summary.push(' '); - summary.push_str(&signature_summary); - } - summary -} - -fn format_signature_verdict_summary( - localizer: &Localizer, - report: &SelfUpdateApplyReport, -) -> Option { - use rabbit_core::signature::SignatureVerdict; - - if report.signature_verdicts.is_empty() { - return None; - } - let mut signed = 0usize; - let mut unsigned = 0usize; - for record in &report.signature_verdicts { - match record.verdict { - SignatureVerdict::Signed { .. } => signed += 1, - SignatureVerdict::Unsigned { .. } => unsigned += 1, - SignatureVerdict::Invalid { .. } => {} - } - } - let signed_str = signed.to_string(); - let unsigned_str = unsigned.to_string(); - let value = match (signed, unsigned) { - (0, 0) => return None, - (_, 0) => { - localizer - .format( - "self-update-apply-signature-summary-signed-only", - &[("signed", signed_str.as_str())], - ) - .value - } - (0, _) => { - localizer - .format( - "self-update-apply-signature-summary-unsigned-only", - &[("unsigned", unsigned_str.as_str())], - ) - .value - } - _ => { - localizer - .format( - "self-update-apply-signature-summary-mixed", - &[ - ("signed", signed_str.as_str()), - ("unsigned", unsigned_str.as_str()), - ], - ) - .value - } - }; - Some(value) -} - -pub fn wizard_outcome_report_from_success( - model: &WizardModel, - request: &WizardInstallRequest, - report: &SetupReport, -) -> WizardOutcomeReport { - let summary = summarize_setup_report(model, report); - WizardOutcomeReport { - // Cancellation outranks a package failure in the heading: "you - // stopped it" explains the half-finished run, where "finished with - // errors" would send the user looking for a fault. - status: if report.was_cancelled() { - WizardOutcomeStatus::Cancelled - } else if report.package_operation.has_failures() { - WizardOutcomeStatus::CompletedWithErrors - } else { - WizardOutcomeStatus::Success - }, - resource_path: report.resource_path.clone(), - target_app_path: request.target_app_path.clone(), - package_ids: request.package_ids.clone(), - platform: request.platform, - architecture: request.architecture, - portable: request.portable, - dry_run: request.dry_run, - allow_reaper_running: request.allow_reaper_running, - stage_unsupported: request.stage_unsupported, - cache_dir: request.cache_dir.clone(), - osara_keymap_choice: request.osara_keymap_choice, - status_line: summary.status_line, - detail_lines: summary.detail_lines, - error_message: None, - setup_report: Some(report.clone()), - } -} - -pub fn summarize_wizard_error( - model: &WizardModel, - request: &WizardInstallRequest, - error: &RabbitError, -) -> WizardInstallSummary { - let localizer = localizer_from_options(&model.bootstrap_options).ok(); - let selected_packages = if request.package_ids.is_empty() { - model.text.review_no_package.clone() - } else { - request - .package_ids - .iter() - .map(|package_id| package_display_name(model, package_id)) - .collect::>() - .join(", ") - }; - let mut detail_lines = vec![ - format_localized_message( - localizer.as_ref(), - "wizard-summary-target", - &[("path", request.resource_path.display().to_string())], - format!("Target: {}", request.resource_path.display()), - ), - format_localized_message( - localizer.as_ref(), - "wizard-summary-portable", - &[( - "value", - if request.portable { - model.text.common_yes.clone() - } else { - model.text.common_no.clone() - }, - )], - format!( - "Portable target: {}", - if request.portable { - &model.text.common_yes - } else { - &model.text.common_no - } - ), - ), - format_localized_message( - localizer.as_ref(), - "wizard-summary-dry-run", - &[( - "value", - if request.dry_run { - model.text.common_yes.clone() - } else { - model.text.common_no.clone() - }, - )], - format!( - "Dry run: {}", - if request.dry_run { - &model.text.common_yes - } else { - &model.text.common_no - } - ), - ), - format_localized_message( - localizer.as_ref(), - "wizard-summary-packages-selected", - &[("packages", selected_packages.clone())], - format!("Packages selected: {selected_packages}"), - ), - format_localized_message( - localizer.as_ref(), - "wizard-summary-cache", - &[("path", request.cache_dir.display().to_string())], - format!("Cache: {}", request.cache_dir.display()), - ), - ]; - - if let Some(target_app_path) = &request.target_app_path { - detail_lines.push(format_localized_message( - localizer.as_ref(), - "wizard-summary-planned-app", - &[("path", target_app_path.display().to_string())], - format!("Planned app path: {}", target_app_path.display()), - )); - } - - if request - .package_ids - .iter() - .any(|package_id| package_id == PACKAGE_OSARA) - { - detail_lines.push(model.text.review_osara_keymap_heading.clone()); - detail_lines.push(match request.osara_keymap_choice { - OsaraKeymapChoice::PreserveCurrent => model.text.review_osara_keymap_preserve.clone(), - OsaraKeymapChoice::ReplaceCurrent => model.text.review_osara_keymap_replace.clone(), - }); - } - - detail_lines.push(format_localized_message( - localizer.as_ref(), - "wizard-summary-error", - &[("message", error.to_string())], - format!("Error: {error}"), - )); - - // The error text itself is English (it goes into bug reports); an - // antivirus block is the one failure a user can usually clear - // themselves, so follow it with the remediation steps in their own - // language. - if matches!(error, RabbitError::WindowsFileBlockedByAntivirus { .. }) { - detail_lines.push(format_localized_message( - localizer.as_ref(), - "wizard-summary-error-antivirus", - &[], - "Windows security software blocked this download. Open Windows Security > \ - Virus & threat protection > Protection history, allow the blocked item, and run \ - RABBIT again." - .to_string(), - )); - } - - WizardInstallSummary { - status_line: model.text.done_status_error.clone(), - detail_lines, - } -} - -pub fn wizard_outcome_report_from_error( - model: &WizardModel, - request: &WizardInstallRequest, - error: &RabbitError, -) -> WizardOutcomeReport { - let summary = summarize_wizard_error(model, request, error); - WizardOutcomeReport { - status: WizardOutcomeStatus::Error, - resource_path: request.resource_path.clone(), - target_app_path: request.target_app_path.clone(), - package_ids: request.package_ids.clone(), - platform: request.platform, - architecture: request.architecture, - portable: request.portable, - dry_run: request.dry_run, - allow_reaper_running: request.allow_reaper_running, - stage_unsupported: request.stage_unsupported, - cache_dir: request.cache_dir.clone(), - osara_keymap_choice: request.osara_keymap_choice, - status_line: summary.status_line, - detail_lines: summary.detail_lines, - error_message: Some(error.to_string()), - setup_report: None, - } -} - -pub fn save_wizard_outcome_report(report: &WizardOutcomeReport) -> Result { - let json_path = default_report_path(&report.resource_path, "setup"); - let saved = save_json_and_text_reports(&json_path, report)?; - Ok(saved.text_path) -} - -pub fn save_wizard_setup_report(report: &SetupReport) -> Result { - let json_path = default_report_path(&report.resource_path, "setup"); - let saved = save_json_and_text_reports(&json_path, report)?; - Ok(saved.text_path) -} - -pub fn summarize_setup_report(model: &WizardModel, report: &SetupReport) -> WizardInstallSummary { - let localizer = localizer_from_options(&model.bootstrap_options).ok(); - let created_resources = report - .resource_init - .actions - .iter() - .filter(|action| action.action == ResourceInitActionKind::Created) - .count(); - let installed_or_checked = report - .package_operation - .items - .iter() - .filter(|item| item.status == PackageOperationStatus::InstalledOrChecked) - .count(); - let skipped_current = report - .package_operation - .items - .iter() - .filter(|item| item.status == PackageOperationStatus::SkippedCurrent) - .count(); - let manual_items = report - .package_operation - .items - .iter() - .filter(|item| matches!(item.status, PackageOperationStatus::DeferredUnattended)) - .count(); - let failed_items = report - .package_operation - .items - .iter() - .filter(|item| { - matches!( - item.status, - PackageOperationStatus::Failed | PackageOperationStatus::SkippedDependencyFailed - ) - }) - .count(); - let any_antivirus_block = report.package_operation.items.iter().any(|item| { - matches!( - item.message_code, - rabbit_core::operation::PackageOperationMessage::InstallFailed { - antivirus_block: true, - .. - } - ) - }); - - let architecture_label = architecture_label_for_summary(model.architecture); - let mut detail_lines = vec![ - format_localized_message( - localizer.as_ref(), - "wizard-summary-target", - &[("path", report.resource_path.display().to_string())], - format!("Target: {}", report.resource_path.display()), - ), - format_localized_message( - localizer.as_ref(), - "wizard-summary-architecture", - &[("architecture", architecture_label.clone())], - format!("Architecture: {architecture_label}"), - ), - format_localized_message( - localizer.as_ref(), - "wizard-summary-dry-run", - &[( - "value", - if report.dry_run { - model.text.common_yes.clone() - } else { - model.text.common_no.clone() - }, - )], - format!( - "Dry run: {}", - if report.dry_run { - &model.text.common_yes - } else { - &model.text.common_no - } - ), - ), - format_localized_message( - localizer.as_ref(), - "wizard-summary-resource-items-created", - &[("count", created_resources.to_string())], - format!("Resource items created: {created_resources}"), - ), - format_localized_message( - localizer.as_ref(), - "wizard-summary-packages-installed-or-checked", - &[("count", installed_or_checked.to_string())], - format!("Packages installed or checked: {installed_or_checked}"), - ), - format_localized_message( - localizer.as_ref(), - "wizard-summary-packages-current", - &[("count", skipped_current.to_string())], - format!("Packages already current: {skipped_current}"), - ), - format_localized_message( - localizer.as_ref(), - "wizard-summary-packages-manual", - &[("count", manual_items.to_string())], - format!("Packages requiring manual attention: {manual_items}"), - ), - ]; - - if let Some(install_report) = &report.package_operation.install_report { - let backup_paths = install_report - .actions - .iter() - .filter_map(|action| action.backup_path.as_ref()) - .collect::>(); - if !backup_paths.is_empty() - || install_report.receipt_backup_path.is_some() - || install_report.backup_manifest_path.is_some() - { - detail_lines.push(format_localized_message( - localizer.as_ref(), - "wizard-summary-backup-files-created", - &[("count", backup_paths.len().to_string())], - format!("Backup files created: {}", backup_paths.len()), - )); - for path in backup_paths { - detail_lines.push(format_localized_message( - localizer.as_ref(), - "wizard-summary-backup-file", - &[("path", path.display().to_string())], - format!("Backup file: {}", path.display()), - )); - } - if let Some(path) = &install_report.receipt_backup_path { - detail_lines.push(format_localized_message( - localizer.as_ref(), - "wizard-summary-receipt-backup", - &[("path", path.display().to_string())], - format!("Receipt backup: {}", path.display()), - )); - } - if let Some(path) = &install_report.backup_manifest_path { - detail_lines.push(format_localized_message( - localizer.as_ref(), - "wizard-summary-backup-manifest", - &[("path", path.display().to_string())], - format!("Backup manifest: {}", path.display()), - )); - } - } - } - - let item_backup_paths = report - .package_operation - .items - .iter() - .flat_map(|item| item.backup_paths.iter()) - .collect::>(); - let item_backup_manifest_paths = report - .package_operation - .items - .iter() - .filter_map(|item| item.backup_manifest_path.as_ref()) - .collect::>(); - let package_receipt_backup_path = report.package_operation.receipt_backup_path.as_ref(); - let package_receipt_backup_manifest_path = report - .package_operation - .receipt_backup_manifest_path - .as_ref(); - if report.package_operation.install_report.is_none() - && (!item_backup_paths.is_empty() - || !item_backup_manifest_paths.is_empty() - || package_receipt_backup_path.is_some() - || package_receipt_backup_manifest_path.is_some()) - { - detail_lines.push(format_localized_message( - localizer.as_ref(), - "wizard-summary-backup-files-created", - &[( - "count", - (item_backup_paths.len() + usize::from(package_receipt_backup_path.is_some())) - .to_string(), - )], - format!( - "Backup files created: {}", - item_backup_paths.len() + usize::from(package_receipt_backup_path.is_some()) - ), - )); - } - for path in item_backup_paths { - detail_lines.push(format_localized_message( - localizer.as_ref(), - "wizard-summary-backup-file", - &[("path", path.display().to_string())], - format!("Backup file: {}", path.display()), - )); - } - for path in item_backup_manifest_paths { - detail_lines.push(format_localized_message( - localizer.as_ref(), - "wizard-summary-backup-manifest", - &[("path", path.display().to_string())], - format!("Backup manifest: {}", path.display()), - )); - } - if let Some(path) = package_receipt_backup_path { - detail_lines.push(format_localized_message( - localizer.as_ref(), - "wizard-summary-receipt-backup", - &[("path", path.display().to_string())], - format!("Receipt backup: {}", path.display()), - )); - } - if let Some(path) = package_receipt_backup_manifest_path { - detail_lines.push(format_localized_message( - localizer.as_ref(), - "wizard-summary-backup-manifest", - &[("path", path.display().to_string())], - format!("Backup manifest: {}", path.display()), - )); - } - - for item in &report.package_operation.items { - let package_name = package_display_name(model, &item.package_id); - let localized_message = localizer - .as_ref() - .map(|localizer| { - localized_package_operation_message(localizer, &item.message_code, &item.message) - }) - .unwrap_or_else(|| item.message.clone()); - detail_lines.push(format_localized_message( - localizer.as_ref(), - "wizard-summary-package-message", - &[ - ("package", package_name.clone()), - ("message", localized_message.clone()), - ], - format!("{package_name}: {localized_message}"), - )); - let plan_action_label = action_label_for_summary(localizer.as_ref(), item.plan_action); - detail_lines.push(format_localized_message( - localizer.as_ref(), - "wizard-summary-package-plan-action", - &[("action", plan_action_label.clone())], - format!(" Plan action: {plan_action_label}"), - )); - let status_label = status_label_for_summary(localizer.as_ref(), item.status); - detail_lines.push(format_localized_message( - localizer.as_ref(), - "wizard-summary-package-status", - &[("status", status_label.clone())], - format!(" Status: {status_label}"), - )); - // Surface the installed version so users can confirm the install - // landed without having to scroll through the install report. The - // artifact descriptor's version is what RABBIT chose to install (and, - // for an InstalledOrChecked status, what now lives on disk per the - // receipt the operation pipeline just wrote). - if matches!( - item.status, - PackageOperationStatus::InstalledOrChecked | PackageOperationStatus::SkippedCurrent - ) { - let installed_version = item.artifact.version.to_string(); - detail_lines.push(format_localized_message( - localizer.as_ref(), - "wizard-summary-package-installed-version", - &[("version", installed_version.clone())], - format!(" Installed version: {installed_version}"), - )); - } - if let Some(plan) = &item.planned_execution { - detail_lines.push(format_localized_message( - localizer.as_ref(), - "wizard-summary-planned-execution-title", - &[], - "Planned unattended execution:".to_string(), - )); - let runner = planned_execution_runner_label(localizer.as_ref(), plan.kind); - detail_lines.push(format_localized_message( - localizer.as_ref(), - "wizard-summary-planned-execution-runner", - &[("runner", runner.clone())], - format!(" Runner: {runner}"), - )); - detail_lines.push(format_localized_message( - localizer.as_ref(), - "wizard-summary-planned-execution-artifact", - &[("artifact", plan.artifact_location.clone())], - format!(" Artifact: {}", plan.artifact_location), - )); - detail_lines.extend(planned_execution_program_line(localizer.as_ref(), plan)); - if !plan.arguments.is_empty() { - let arguments = plan.arguments.join(" "); - detail_lines.push(format_localized_message( - localizer.as_ref(), - "wizard-summary-planned-execution-arguments", - &[("arguments", arguments.clone())], - format!(" Arguments: {arguments}"), - )); - } - if let Some(path) = &plan.working_directory { - detail_lines.push(format_localized_message( - localizer.as_ref(), - "wizard-summary-planned-execution-working-directory", - &[("path", path.display().to_string())], - format!(" Working directory: {}", path.display()), - )); - } - detail_lines.extend(plan.verification_paths.iter().map(|path| { - format_localized_message( - localizer.as_ref(), - "wizard-summary-planned-execution-verify", - &[("path", path.display().to_string())], - format!(" Verify: {}", path.display()), - ) - })); - } - if let Some(manual) = &item.manual_instruction { - detail_lines.push(format_localized_message( - localizer.as_ref(), - "wizard-summary-manual-title", - &[("title", manual.title.clone())], - format!("{}:", manual.title), - )); - detail_lines.extend(manual.steps.iter().map(|step| { - format_localized_message( - localizer.as_ref(), - "wizard-summary-manual-step", - &[("step", step.clone())], - format!(" {step}"), - ) - })); - detail_lines.extend(manual.notes.iter().map(|note| { - format_localized_message( - localizer.as_ref(), - "wizard-summary-manual-note", - &[("note", note.clone())], - format!(" Note: {note}"), - ) - })); - } - } - - for step_report in &report.configuration_steps { - let step_name = localized_configuration_step_name(localizer.as_ref(), &step_report.step_id); - let localized_message = localizer - .as_ref() - .map(|localizer| { - localized_configuration_message( - localizer, - &step_report.message_code, - &step_report.message, - ) - }) - .unwrap_or_else(|| step_report.message.clone()); - detail_lines.push(format_localized_message( - localizer.as_ref(), - "wizard-summary-configuration-message", - &[ - ("step", step_name.clone()), - ("message", localized_message.clone()), - ], - format!("{step_name}: {localized_message}"), - )); - let status_label = - configuration_status_label_for_summary(localizer.as_ref(), step_report.status); - detail_lines.push(format_localized_message( - localizer.as_ref(), - "wizard-summary-configuration-status", - &[("status", status_label.clone())], - format!(" Status: {status_label}"), - )); - } - - // When some packages failed, lead with a "completed with errors" line - // (and, for the antivirus false-positive case, the localized how-to- - // allow-it guidance) instead of the plain finished line, so the result - // page names the problem up front. A skipped-because-REAPER-failed - // package counts toward the failure total. - if failed_items > 0 { - if any_antivirus_block { - detail_lines.push(format_localized_message( - localizer.as_ref(), - "wizard-summary-error-antivirus", - &[], - "Windows security software blocked at least one download. Open Windows \ - Security > Virus & threat protection > Protection history, allow the blocked \ - item, and run RABBIT again." - .to_string(), - )); - } - return WizardInstallSummary { - status_line: format_localized_message( - localizer.as_ref(), - "wizard-summary-status-finished-with-errors", - &[ - ("installed", installed_or_checked.to_string()), - ("failed", failed_items.to_string()), - ], - format!( - "Finished with errors. {installed_or_checked} package item(s) installed or checked; {failed_items} failed." - ), - ), - detail_lines, - }; - } - - WizardInstallSummary { - status_line: format_localized_message( - localizer.as_ref(), - "wizard-summary-status-finished", - &[ - ("installed", installed_or_checked.to_string()), - ("manual", manual_items.to_string()), - ], - format!( - "Finished. {installed_or_checked} package item(s) installed or checked; {manual_items} require manual attention." - ), - ), - detail_lines, - } -} - -/// The summary's "Program" line for a planned execution. An installer that -/// arrives zipped (an OSARA pull-request build) has no program to name until -/// it is downloaded and unpacked, so the line says what will run instead of -/// leaving the reader to wonder why it is missing. Other plans without a -/// program (archives, disk images) get no line, as before. -fn planned_execution_program_line( - localizer: Option<&Localizer>, - plan: &PlannedExecutionPlan, -) -> Option { - match &plan.program { - Some(program) => Some(format_localized_message( - localizer, - "wizard-summary-planned-execution-program", - &[("program", program.clone())], - format!(" Program: {program}"), - )), - None if plan.kind == PlannedExecutionKind::LaunchInstallerExecutable => { - Some(format_localized_message( - localizer, - "wizard-summary-planned-execution-program-zipped", - &[], - " Program: the installer inside the .zip, once it is downloaded".to_string(), - )) - } - None => None, - } -} - -fn format_localized_message( - localizer: Option<&Localizer>, - id: &str, - args: &[(&str, String)], - fallback: String, -) -> String { - let Some(localizer) = localizer else { - return fallback; - }; - let borrowed_args = args - .iter() - .map(|(name, value)| (*name, value.as_str())) - .collect::>(); - localizer.format(id, &borrowed_args).value -} - -fn planned_execution_runner_label( - localizer: Option<&Localizer>, - kind: PlannedExecutionKind, -) -> String { - let (id, fallback) = match kind { - PlannedExecutionKind::LaunchInstallerExecutable => ( - "wizard-planned-runner-launch-installer", - "Launch installer executable", - ), - PlannedExecutionKind::ExtractArchiveAndRunInstaller => ( - "wizard-planned-runner-extract-archive", - "Extract archive and run contained installer", - ), - PlannedExecutionKind::ExtractArchiveAndCopyOsaraAssets => ( - "wizard-planned-runner-extract-archive-copy-osara", - "Extract archive and copy OSARA installer assets", - ), - PlannedExecutionKind::MountDiskImageAndRunInstaller => ( - "wizard-planned-runner-mount-disk-image", - "Mount disk image and run contained installer", - ), - PlannedExecutionKind::MountDiskImageAndCopyAppBundle => ( - "wizard-planned-runner-mount-disk-image-copy-app", - "Mount disk image and copy contained app bundle", - ), - PlannedExecutionKind::MountDiskImageAndRunPkgInstaller => ( - "wizard-planned-runner-mount-disk-image-run-pkg", - "Mount disk image and run contained pkg installer", - ), - }; - localizer - .map(|localizer| localizer.text(id).value) - .unwrap_or_else(|| fallback.to_string()) -} - -/// Localized "Install / Update / Keep" label resolver scoped to the saved -/// summary report. Mirrors the wizard's `action_label` but works against an -/// `Option<&Localizer>` so the summarizer can degrade gracefully when no -/// localizer is available. -fn action_label_for_summary(localizer: Option<&Localizer>, action: PlanActionKind) -> String { - let (id, fallback) = match action { - PlanActionKind::Install => ("action-install", "Install"), - PlanActionKind::Update => ("action-update", "Update"), - PlanActionKind::Keep => ("action-keep", "Keep"), - }; - localizer - .map(|localizer| localizer.text(id).value) - .unwrap_or_else(|| fallback.to_string()) -} - -/// Localized status-label resolver for `PackageOperationStatus` values -/// surfaced by the saved summary report. -fn status_label_for_summary( - localizer: Option<&Localizer>, - status: PackageOperationStatus, -) -> String { - let (id, fallback) = match status { - PackageOperationStatus::InstalledOrChecked => { - ("status-installed-or-checked", "Installed or checked") - } - PackageOperationStatus::PlannedUnattended => { - ("status-planned-unattended", "Planned unattended") - } - PackageOperationStatus::DeferredUnattended => { - ("status-deferred-unattended", "Deferred unattended") - } - PackageOperationStatus::SkippedCurrent => { - ("status-skipped-current", "Skipped (already current)") - } - PackageOperationStatus::Failed => ("status-failed", "Failed"), - PackageOperationStatus::SkippedDependencyFailed => ( - "status-skipped-dependency-failed", - "Skipped (dependency failed)", - ), - PackageOperationStatus::Cancelled => ("status-cancelled", "Not installed (you stopped)"), - }; - localizer - .map(|localizer| localizer.text(id).value) - .unwrap_or_else(|| fallback.to_string()) -} - -/// Translate a [`rabbit_core::operation::PackageOperationMessage`] into a -/// localized sentence using Fluent. Falls back to the message's English -/// form (`fallback_english`) when the locale doesn't have the key the -/// variant maps to — that's the same English string rabbit-core stamps -/// into the JSON report, so the saved report remains stable while the -/// wizard renders the user's locale. -fn localized_package_operation_message( - localizer: &Localizer, - code: &rabbit_core::operation::PackageOperationMessage, - fallback_english: &str, -) -> String { - use rabbit_core::operation::PackageOperationMessage as Msg; - let message = match code { - Msg::ExtensionBinaryInstalled => { - localizer.text("package-status-extension-binary-installed") - } - Msg::SkippedCurrent { - installed_version, - available_version, - } => localizer.format( - "package-status-skipped-current", - &[ - ("installed", installed_version.as_str()), - ("available", available_version.as_str()), - ], - ), - Msg::SkippedContentUnchanged => localizer.text("package-status-skipped-content-unchanged"), - Msg::DryRunWouldRunUnattended { artifact_kind } => localizer.format( - "package-status-dry-run-would-run-unattended", - &[( - "automation", - localized_automation_description(localizer, *artifact_kind).as_str(), - )], - ), - Msg::DeferredUnattendedStaged { artifact_kind } => localizer.format( - "package-status-deferred-unattended-staged", - &[( - "automation", - localized_automation_description(localizer, *artifact_kind).as_str(), - )], - ), - Msg::DeferredUnattendedNotStaged { artifact_kind } => localizer.format( - "package-status-deferred-unattended-not-staged", - &[( - "automation", - localized_automation_description(localizer, *artifact_kind).as_str(), - )], - ), - Msg::UnattendedInstalled => localizer.text("package-status-unattended-installed"), - Msg::OsaraUnattendedInstalledKeymapBackedUp => { - localizer.text("package-status-osara-unattended-keymap-backed-up") - } - Msg::OsaraUnattendedInstalledKeymapReplaced => { - localizer.text("package-status-osara-unattended-keymap-replaced") - } - Msg::InstallFailed { error, .. } => localizer.format( - "package-status-install-failed", - &[("error", error.as_str())], - ), - Msg::SkippedDependencyFailed { dependency } => localizer.format( - "package-status-skipped-dependency-failed", - &[("dependency", dependency.as_str())], - ), - Msg::Cancelled => localizer.text("package-status-cancelled"), - }; - if message.missing { - fallback_english.to_string() - } else { - message.value - } -} - -/// Localize the short "vendor installer" / "archive extraction" / -/// "disk image install" / "direct file install" automation-kind label -/// used inside the dry-run / deferred-unattended status messages. -fn localized_automation_description( - localizer: &Localizer, - kind: rabbit_core::artifact::ArtifactKind, -) -> String { - use rabbit_core::artifact::ArtifactKind; - let key = match kind { - ArtifactKind::Installer => "package-automation-installer", - // `.zip` and `.7z` end up extracted into UserPlugins by the same - // user-facing operation; the extractor differs but the - // user-facing description doesn't. - ArtifactKind::Archive | ArtifactKind::SevenZipArchive => "package-automation-archive", - ArtifactKind::DiskImage => "package-automation-disk-image", - ArtifactKind::ExtensionBinary => "package-automation-extension-binary", - }; - let text = localizer.text(key); - if text.missing { - match kind { - ArtifactKind::Installer => "vendor installer".to_string(), - ArtifactKind::Archive | ArtifactKind::SevenZipArchive => { - "archive extraction".to_string() - } - ArtifactKind::DiskImage => "disk image install".to_string(), - ArtifactKind::ExtensionBinary => "direct file install".to_string(), - } - } else { - text.value - } -} - -/// Translate a [`rabbit_core::configuration::ConfigurationMessage`] into a -/// localized sentence using Fluent, with the same English-fallback shape -/// as [`localized_package_operation_message`]. -fn localized_configuration_message( - localizer: &Localizer, - code: &rabbit_core::configuration::ConfigurationMessage, - fallback_english: &str, -) -> String { - use rabbit_core::configuration::ConfigurationMessage as Msg; - let message = match code { - Msg::ReapackRemoteAlreadyPresent { name, url } => localizer.format( - "config-message-reapack-remote-already-present", - &[("name", name.as_str()), ("url", url.as_str())], - ), - Msg::ReapackRemoteAdded { name, url } => localizer.format( - "config-message-reapack-remote-added", - &[("name", name.as_str()), ("url", url.as_str())], - ), - Msg::ReapackRemoteCreatedFile { name, url } => localizer.format( - "config-message-reapack-remote-created-file", - &[("name", name.as_str()), ("url", url.as_str())], - ), - Msg::ReapackRemoteDryRun { name, url } => localizer.format( - "config-message-reapack-remote-dry-run", - &[("name", name.as_str()), ("url", url.as_str())], - ), - Msg::ReaperLanguageAlreadySelected { file_name } => localizer.format( - "config-message-reaper-language-already-selected", - &[("file", file_name.as_str())], - ), - Msg::ReaperLanguageSelected { file_name } => localizer.format( - "config-message-reaper-language-selected", - &[("file", file_name.as_str())], - ), - Msg::ReaperLanguageDryRun { file_name } => localizer.format( - "config-message-reaper-language-dry-run", - &[("file", file_name.as_str())], - ), - Msg::Skipped { step_id } => { - localizer.format("config-message-skipped", &[("step", step_id.as_str())]) - } - Msg::SkippedDependencyMissing { step_id, dep_id } => localizer.format( - "config-message-skipped-dependency-missing", - &[("step", step_id.as_str()), ("dependency", dep_id.as_str())], - ), - Msg::AppliedNoOp => localizer.text("config-message-applied-no-op"), - Msg::Cancelled { .. } => localizer.text("config-message-cancelled"), - }; - if message.missing { - fallback_english.to_string() - } else { - message.value - } -} - -/// Look up a configuration step's localized display name from the -/// builtin manifest. Falls back to the raw step id when the step -/// isn't in the manifest (forward-compat for unknown ids loaded from -/// an older receipt). -fn localized_configuration_step_name(localizer: Option<&Localizer>, step_id: &str) -> String { - let steps = rabbit_core::configuration::builtin_configuration_steps(); - let display_key = steps - .iter() - .find(|step| step.id == step_id) - .map(|step| step.display_name_key.clone()); - match (localizer, display_key) { - (Some(localizer), Some(key)) => { - let text = localizer.text(&key); - if text.missing { - step_id.to_string() - } else { - text.value - } - } - _ => step_id.to_string(), - } -} - -/// Localize a [`rabbit_core::configuration::ConfigurationStatus`] for -/// the summary's " Status: …" sub-line. Mirrors -/// [`status_label_for_summary`] for `PackageOperationStatus`. -fn configuration_status_label_for_summary( - localizer: Option<&Localizer>, - status: rabbit_core::configuration::ConfigurationStatus, -) -> String { - use rabbit_core::configuration::ConfigurationStatus; - let (id, fallback) = match status { - ConfigurationStatus::Applied => ("config-status-applied", "Applied"), - ConfigurationStatus::Skipped => ("config-status-skipped", "Skipped"), - ConfigurationStatus::SkippedDependencyMissing => ( - "config-status-skipped-dependency-missing", - "Skipped (dependency missing)", - ), - ConfigurationStatus::DryRun => ("config-status-dry-run", "Dry run"), - ConfigurationStatus::Cancelled => ("config-status-cancelled", "Did not run (you stopped)"), - }; - localizer - .map(|localizer| localizer.text(id).value) - .unwrap_or_else(|| fallback.to_string()) -} - -/// Format the wizard's detected architecture as a stable short token used in -/// the summary report. Not localized — these are the same identifiers the -/// CLI's `rabbit detect` output uses, so external tooling can grep for them. -fn architecture_label_for_summary(architecture: Architecture) -> String { - match architecture { - Architecture::X86 => "x86".to_string(), - Architecture::X64 => "x64".to_string(), - Architecture::Arm64 => "arm64".to_string(), - Architecture::Arm64Ec => "arm64ec".to_string(), - Architecture::Universal => "universal".to_string(), - Architecture::Unknown => "unknown".to_string(), - } -} - -/// The row tag and details sentence for a package whose build is not the -/// regular release, or which this run takes back to it. `None` for the -/// ordinary case. The sentence says what the install will do, so a row -/// that installs nothing (`acting` false) gets the tag without it. -fn package_channel_note( - localizer: &Localizer, - package: &str, - available_channel: Option<&str>, - installed_channel: Option<&str>, - acting: bool, -) -> Option<(String, Option)> { - let text = |key: &str, args: &[(&str, &str)]| localizer.format(key, args).value; - match available_channel.map(rabbit_core::package::split_channel) { - Some(("pr", Some(number))) => Some(( - text("wizard-package-channel-pr", &[("number", number)]), - acting.then(|| { - text( - "wizard-package-channel-pr-details", - &[("package", package), ("number", number)], - ) - }), - )), - Some(_) => Some(( - text("wizard-package-channel-dev", &[]), - acting.then(|| { - text( - "wizard-package-channel-dev-details", - &[("package", package)], - ) - }), - )), - // Installed from a pre-release, and this run puts the regular - // release back - which is exactly what leaving expert mode does. - None if installed_channel.is_some() && acting => Some(( - text("wizard-package-channel-back", &[]), - Some(text( - "wizard-package-channel-back-details", - &[("package", package)], - )), - )), - None => None, - } -} - -fn row_installs(action: PlanActionKind) -> bool { - matches!(action, PlanActionKind::Install | PlanActionKind::Update) -} - -/// A package row's one-line label: package, action and versions, then a tag -/// when the build is not the regular release, or this run takes it back to -/// one. Everything that changes a row's action rebuilds the label through -/// here, so ticking or unticking a row never drops the tag. -fn package_row_summary(localizer: &Localizer, row: &PackageRow) -> String { - let summary = localizer - .format( - "wizard-package-row", - &[ - ("package", row.display_name.as_str()), - ("action", row.action_label.as_str()), - ("installed", row.installed_version.as_str()), - ("available", row.available_version.as_str()), - ], - ) - .value; - match package_channel_note( - localizer, - &row.display_name, - row.available_channel.as_deref(), - row.installed_channel.as_deref(), - row_installs(row.action), - ) { - Some((tag, _)) => { - localizer - .format( - "wizard-package-row-channel-suffix", - &[("row", summary.as_str()), ("channel", tag.as_str())], - ) - .value - } - None => summary, - } -} - -/// Environment variable that turns the wizard's expert mode on at launch. -/// Expert mode is never saved - RABBIT keeps no settings file - so this is -/// how someone who always wants it gets it. -pub const EXPERT_MODE_ENV: &str = "RABBIT_EXPERT"; - -/// Whether `RABBIT_EXPERT` asks for expert mode (`1`, `true`, `yes`, `on`). -pub fn expert_mode_requested_by_env() -> bool { - std::env::var(EXPERT_MODE_ENV).is_ok_and(|value| expert_mode_value_enabled(&value)) -} - -fn expert_mode_value_enabled(value: &str) -> bool { - matches!( - value.trim().to_ascii_lowercase().as_str(), - "1" | "true" | "yes" | "on" - ) -} - -/// The channels a wizard run installs from. Outside expert mode that is no -/// channel at all, which is what takes any package that came from a -/// pre-release back to its regular release. In expert mode it is whatever -/// the build choices say. -pub fn wizard_channels( - expert_mode: bool, - reaper: Option, - osara: Option, -) -> rabbit_core::package::PackageChannels { - let mut channels = rabbit_core::package::PackageChannels::new(); - if expert_mode { - if let Some(channel) = reaper { - channels.insert(rabbit_core::package::PACKAGE_REAPER.to_string(), channel); - } - if let Some(channel) = osara { - channels.insert(PACKAGE_OSARA.to_string(), channel); - } - } - channels -} - -#[allow(clippy::too_many_arguments)] // Row building needs the full wizard context. -fn package_rows( - localizer: &Localizer, - text: &WizardText, - platform: Platform, - architecture: Architecture, - package_specs: &[PackageSpec], - actions: &[PlanAction], - available_packages: &[AvailablePackage], - host: &HostCapabilities, - declined: &std::collections::BTreeSet, - installed_channels: &rabbit_core::package::PackageChannels, -) -> Vec { - let channel_by_id: BTreeMap<_, _> = available_packages - .iter() - .filter_map(|available| { - available - .channel - .as_deref() - .map(|channel| (available.package_id.as_str(), channel)) - }) - .collect(); - let specs_by_id: BTreeMap<_, _> = package_specs - .iter() - .map(|spec| (spec.id.as_str(), spec)) - .collect(); - let whats_new_by_id: BTreeMap<_, _> = available_packages - .iter() - .filter_map(|available| { - available - .whats_new - .as_deref() - .map(|notes| (available.package_id.as_str(), notes)) - }) - .collect(); - actions - .iter() - .map(|action| { - let spec = specs_by_id.get(action.package_id.as_str()).copied(); - let display_name = spec - .map(|spec| localizer.text(&spec.display_name_key).value) - .unwrap_or_else(|| action.package_id.clone()); - let description = spec - .map(|spec| localizer.text(&spec.display_description_key)) - .filter(|text| !text.missing) - .map(|text| text.value) - .unwrap_or_default(); - let installed_version = version_text(localizer, action.installed_version.as_ref()); - let available_version = version_text(localizer, action.available_version.as_ref()); - // Auto-tick rule: - // - Update → always ticked (the package is already on disk; the - // user opted into having it, so keep it current by default). - // - Install → only ticked when the spec is *effectively* - // recommended. That's the manifest baseline OR a host-conditional - // escalation (e.g. ReaKontrol's `recommended_when: - // komplete_kontrol_installed`), so non-recommended packages - // (FFmpeg, plain ReaKontrol on a non-KK host) stay unchecked. - // - Keep → never ticked (nothing to do). - // A language pack for the language RABBIT is running in counts - // as recommended, so a Spanish user gets the Spanish pack ticked - // without hunting for it. Packs for other languages stay listed - // but unticked; nothing is offered for English, since REAPER is - // already English. - // Has the user turned this package down on this install? It - // stays listed and tickable either way — a refusal suppresses - // the automatic tick, never the package itself. - let refused = spec - .is_some_and(|spec| spec.remember_opt_out && declined.contains(&action.package_id)); - let recommended = spec - .map(|spec| { - rabbit_core::package::effective_recommended(spec, host) - || rabbit_core::package::matches_ui_language( - spec, - localizer.active_locale(), - ) - }) - .unwrap_or(false); - let initially_selected = match action.action { - // Updates are ticked by default because a package you have - // should stay current — but not once you have said no to it. - // Someone who stopped using a language pack and turned its - // update down would otherwise be re-offered it, ticked, - // every time the translators publish a fix. - PlanActionKind::Update => !refused, - PlanActionKind::Install => recommended && !refused, - PlanActionKind::Keep => false, - }; - // When the auto-tick rule leaves an Install/Update row unticked, - // mirror what `apply_checkbox_state_to_package_row(checked=false)` - // does on a manual untick: flip the *displayed* action to Keep so - // the row label / summary match the checkbox state. `original_action` - // still records the plan's decision so re-ticking restores Install/ - // Update without losing the underlying intent. - let initial_action = if initially_selected { - action.action - } else { - PlanActionKind::Keep - }; - let action_label = action_label(localizer, initial_action); - // Say in the row itself when a build is not the regular release, - // or when this run takes a pre-release back to the regular one. - // The plan's own reason text stays out of the wizard, and the - // unlock dialog is long gone by the time a later run switches a - // package back - so the row is the one place it is always heard. - let available_channel = channel_by_id - .get(action.package_id.as_str()) - .map(|channel| channel.to_string()); - let installed_channel = installed_channels.get(&action.package_id).cloned(); - let channel_sentence = package_channel_note( - localizer, - &display_name, - available_channel.as_deref(), - installed_channel.as_deref(), - row_installs(initial_action), - ) - .and_then(|(_, sentence)| sentence); - let (handling_summary, manual_attention_expected) = - package_handling_summary(text, &action.package_id, platform, architecture); - // Compose the details text shown in the wizard's package - // details pane. The localized description follows the summary - // line so users can see what a package is before deciding - // what to do with it. The plan-reason string ("Installed - // version is current or newer…") and the handling-summary / - // automation-kind detail are not localized for end users — - // both stay on PackageRow as structured fields for the saved - // report and stay out of the wizard pane. - let mut row = PackageRow { - package_id: action.package_id.clone(), - summary: String::new(), - details: String::new(), - display_name: display_name.clone(), - description: description.clone(), - selected: initially_selected, - installed_version, - available_version, - action: initial_action, - action_label, - original_action: action.action, - reason: action.reason.clone(), - handling_summary, - manual_attention_expected, - available_for_target: true, - unavailability_reason: None, - category: spec.map(|spec| spec.category).unwrap_or_default(), - requires_standard_install: spec - .map(|spec| spec.requires_standard_install) - .unwrap_or(false), - available_channel, - installed_channel, - }; - row.summary = package_row_summary(localizer, &row); - let summary = &row.summary; - let details = match &channel_sentence { - Some(sentence) => format!("{summary}\n\n{sentence}"), - None => summary.clone(), - }; - let details = if description.is_empty() { - details - } else { - format!("{details}\n\n{description}") - }; - // The available version's What's-New notes (resolved by the - // deferred version check for packages that declare a source) - // follow under a localized heading, so the pane answers "what - // changed upstream?" without a trip to the package's website. - let details = match whats_new_by_id.get(action.package_id.as_str()) { - Some(notes) => { - let heading = localizer - .format( - "wizard-package-whats-new-heading", - &[("package", display_name.as_str())], - ) - .value; - format!("{details}\n\n{heading}\n{notes}") - } - None => details, - }; - row.details = details; - row - }) - .collect() -} - -/// Will this package be on disk after the current wizard run completes? -/// -/// Two ways the answer is yes: -/// -/// 1. The package was already on disk before the wizard opened. We read -/// that from `original_action` — the plan only emits `Install` for -/// packages that aren't installed, so anything else (`Update`, `Keep`) -/// means the package was on disk when the wizard built its rows. -/// 2. The user has the row ticked and its current action stages it to -/// disk (`Install` or `Update`). `Keep` doesn't move bytes. -/// -/// Driving the configuration-row dependency check off this predicate -/// keeps gating coherent when a row's `selected` and `action` disagree — -/// e.g. a non-recommended `Install` row arrives unticked-by-default -/// (so `selected=false` but `action=Install`), and a user-unticked row -/// is flipped to `Keep` (so `selected=false` but `action=Keep`). Both -/// must read as "won't be on disk", which the simpler action-only check -/// got wrong for the latter and our new default broke for the former. -/// Is this package being written to disk *by this run* — as opposed to -/// merely being there already? Steps that change existing configuration -/// rather than adding to it auto-tick off this, not off -/// [`package_row_will_land_on_disk`]. -fn package_row_installs_now(row: &PackageRow) -> bool { - row.available_for_target - && row.selected - && matches!(row.action, PlanActionKind::Install | PlanActionKind::Update) -} - -fn package_row_will_land_on_disk(row: &PackageRow) -> bool { - if !row.available_for_target { - return false; - } - let was_installed = !matches!(row.original_action, PlanActionKind::Install); - let installing_now = - matches!(row.action, PlanActionKind::Install | PlanActionKind::Update) && row.selected; - was_installed || installing_now -} - -/// Build [`ConfigurationRow`]s from `rabbit-core`'s builtin step -/// catalogue, gating each row on whether its dependency package is -/// either already installed (action `Keep`) or queued for install / -/// update in the current package plan. Re-checked whenever the -/// package plan is rebuilt (target switch, post-install rescan, etc.). -pub fn configuration_rows( - localizer: &Localizer, - package_rows: &[PackageRow], - target_resource_path: Option<&Path>, -) -> Vec { - let installed_or_pending: BTreeMap<&str, bool> = package_rows - .iter() - .map(|row| (row.package_id.as_str(), package_row_will_land_on_disk(row))) - .collect(); - let installing_now: BTreeMap<&str, bool> = package_rows - .iter() - .map(|row| (row.package_id.as_str(), package_row_installs_now(row))) - .collect(); - - rabbit_core::configuration::builtin_configuration_steps() - .into_iter() - .map(|step| { - let display_name = localizer.text(&step.display_name_key).value; - let description = { - let text = localizer.text(&step.display_description_key); - if text.missing { - String::new() - } else { - text.value - } - }; - let dependency_satisfied = - configuration_dependency_met(&step, &installed_or_pending, &installing_now); - let already_applied = target_resource_path - .and_then(|path| { - rabbit_core::configuration::is_configuration_step_applied( - path, - &step, - &Default::default(), - ) - .ok() - }) - .unwrap_or(false); - - let unavailability_reason = build_configuration_unavailability_reason( - localizer, - &step, - dependency_satisfied, - already_applied, - ); - - let summary = configuration_row_summary( - localizer, - &step, - &display_name, - dependency_satisfied, - already_applied, - ); - let details = if description.is_empty() { - summary.clone() - } else { - format!("{summary}\n\n{description}") - }; - - ConfigurationRow { - step_id: step.id.clone(), - display_name, - description, - selected: dependency_satisfied && !already_applied && step.recommended, - summary, - details, - available_for_target: dependency_satisfied, - already_applied, - unavailability_reason, - } - }) - .collect() -} - -/// Build the tree-row label for a configuration step: the localized -/// display name on its own when the row is actionable, otherwise the -/// display name plus a short parenthesised status tag (`(requires -/// ReaPack)`, `(already applied)`) so the indicator is visible without -/// the user having to focus the row to read its details. -/// How to refer to a configuration step's dependency in a message. -/// -/// A step satisfied by any one of several packages names the group ("a -/// language pack") rather than whichever package sorts first, which is how -/// "Set REAPER's language" came to advertise itself as requiring Spanish. -/// Steps with a single dependency keep naming it directly. -fn configuration_dependency_label( - localizer: &Localizer, - step: &rabbit_core::configuration::ConfigurationStep, -) -> String { - if let Some(key) = step.dependency_name_key.as_deref() { - let text = localizer.text(key); - if !text.missing { - return text.value; - } - } - let dep_id = step.requires_packages.first().cloned().unwrap_or_default(); - let dep_name = localizer.text(&format!("package-{dep_id}")); - if dep_name.missing { - dep_id - } else { - dep_name.value - } -} - -fn configuration_row_summary( - localizer: &Localizer, - step: &rabbit_core::configuration::ConfigurationStep, - display_name: &str, - dependency_satisfied: bool, - already_applied: bool, -) -> String { - let status = if !dependency_satisfied { - let dep_label = configuration_dependency_label(localizer, step); - Some( - localizer - .format( - "wizard-configuration-row-status-requires", - &[("package", dep_label.as_str())], - ) - .value, - ) - } else if already_applied { - Some( - localizer - .text("wizard-configuration-row-status-already-applied") - .value, - ) - } else { - None - }; - match status { - Some(reason) => { - let suffix = localizer - .format( - "wizard-configuration-row-summary-suffix", - &[("reason", reason.as_str())], - ) - .value; - format!("{display_name} {suffix}") - } - None => display_name.to_string(), - } -} - -/// Build the localized "(unavailable: …)" / "(already configured)" -/// sentence shown on a configuration row that isn't actionable. -/// `dependency_satisfied == false` takes precedence over -/// `already_applied`: if the dep is missing and the row is also already -/// applied, we surface the dep error so the user knows the row is -/// gated rather than complete. -fn build_configuration_unavailability_reason( - localizer: &Localizer, - step: &rabbit_core::configuration::ConfigurationStep, - dependency_satisfied: bool, - already_applied: bool, -) -> Option { - if !dependency_satisfied { - let dep_label = configuration_dependency_label(localizer, step); - return Some( - localizer - .format( - "wizard-configuration-row-unavailable", - &[("package", dep_label.as_str())], - ) - .value, - ); - } - if already_applied { - return Some( - localizer - .text("wizard-configuration-row-already-applied") - .value, - ); - } - None -} - -/// Re-evaluate each [`ConfigurationRow`]'s `available_for_target` / -/// `selected` / `unavailability_reason` against the current package -/// rows. Called by the wizard whenever the user toggles a package row -/// — e.g. unticking ReaPack should immediately disable the -/// "configure REAPER Accessibility ReaPack remote" row, and re-ticking -/// it should re-enable + re-recommend the row. -/// -/// Selection is preserved across the recompute *unless* the row goes -/// from available → unavailable, in which case it's force-unticked -/// (we never want to ship the install with a configuration step -/// queued whose dependency isn't available). -/// ANY-of dependency check for a configuration step: satisfied when at least -/// one required package will be on disk, or when the step requires none. -/// "Set REAPER's language" lists every language pack, so it lights up -/// whichever one the user picked. -fn configuration_dependency_satisfied( - step: &rabbit_core::configuration::ConfigurationStep, - mut is_present: impl FnMut(&str) -> bool, -) -> bool { - step.requires_packages.is_empty() - || step - .requires_packages - .iter() - .any(|pkg| is_present(pkg.as_str())) -} - -/// Is this step's dependency satisfied? -/// -/// Steps flagged `requires_fresh_dependency` count only packages being -/// installed or updated in this run; every other step also counts packages -/// that are already on disk. -fn configuration_dependency_met( - step: &rabbit_core::configuration::ConfigurationStep, - landing: &BTreeMap<&str, bool>, - installing: &BTreeMap<&str, bool>, -) -> bool { - let source = if step.requires_fresh_dependency { - installing - } else { - landing - }; - configuration_dependency_satisfied(step, |pkg| source.get(pkg).copied().unwrap_or(false)) -} - -pub fn recompute_configuration_row_availability( - localizer: &Localizer, - package_rows: &[PackageRow], - target_resource_path: Option<&Path>, - configuration_rows: &mut [ConfigurationRow], -) { - let installed_or_pending: BTreeMap<&str, bool> = package_rows - .iter() - .map(|row| (row.package_id.as_str(), package_row_will_land_on_disk(row))) - .collect(); - let installing_now: BTreeMap<&str, bool> = package_rows - .iter() - .map(|row| (row.package_id.as_str(), package_row_installs_now(row))) - .collect(); - let steps = rabbit_core::configuration::builtin_configuration_steps(); - for row in configuration_rows.iter_mut() { - let Some(step) = steps.iter().find(|step| step.id == row.step_id) else { - continue; - }; - let dependency_satisfied = - configuration_dependency_met(step, &installed_or_pending, &installing_now); - let already_applied = target_resource_path - .and_then(|path| { - rabbit_core::configuration::is_configuration_step_applied( - path, - step, - &Default::default(), - ) - .ok() - }) - .unwrap_or(row.already_applied); - let was_actionable = row.available_for_target && !row.already_applied; - row.available_for_target = dependency_satisfied; - row.already_applied = already_applied; - row.unavailability_reason = build_configuration_unavailability_reason( - localizer, - step, - dependency_satisfied, - already_applied, - ); - // Refresh the row's tree-label so the inline status tag matches - // the new state (e.g. unticking ReaPack while the row is - // visible adds "(requires ReaPack)"; re-ticking it removes the - // tag). - row.summary = configuration_row_summary( - localizer, - step, - &row.display_name, - dependency_satisfied, - already_applied, - ); - row.details = if row.description.is_empty() { - row.summary.clone() - } else { - format!("{}\n\n{}", row.summary, row.description) - }; - let actionable_now = dependency_satisfied && !already_applied; - if !actionable_now { - // Nothing to act on, so nothing ticked. Unticking the last - // language pack clears "Set REAPER's language" through this - // branch, rather than leaving a ticked step pointing at no pack. - row.selected = false; - } else if !was_actionable { - // Just became actionable — e.g. a language pack was ticked. - // Restore the recommended default so the user doesn't have to - // hunt for the step; unticking it afterwards survives, because - // by then the row was already actionable. - row.selected = step.recommended; - } - } -} - -/// Return the step ids of configuration rows that are both actionable -/// (available + not already applied) and currently selected. Mirrors -/// `package_ids_for_rows` but for configuration rows. -pub fn selected_configuration_step_ids(configuration_rows: &[ConfigurationRow]) -> Vec { - configuration_rows - .iter() - .filter(|row| row.available_for_target && !row.already_applied && row.selected) - .map(|row| row.step_id.clone()) - .collect() -} - -fn package_handling_summary( - text: &WizardText, - package_id: &str, - platform: Platform, - architecture: Architecture, -) -> (String, bool) { - match package_automation_support(package_id, platform, architecture) { - PackageAutomationSupport::Direct => (text.package_handling_automatic.clone(), false), - PackageAutomationSupport::AvailableUnattended(_) => { - (text.package_handling_unattended.clone(), false) - } - PackageAutomationSupport::PlannedUnattended(_) => { - (text.package_handling_planned.clone(), true) - } - PackageAutomationSupport::Unavailable => (text.package_handling_unavailable.clone(), true), - } -} - -fn package_display_name(model: &WizardModel, package_id: &str) -> String { - if let Ok(localizer) = localizer_from_options(&model.bootstrap_options) - && let Some(spec) = builtin_package_specs(model.platform) - .into_iter() - .find(|spec| spec.id == package_id) - { - return localizer.text(&spec.display_name_key).value; - } - - builtin_package_specs(model.platform) - .into_iter() - .find(|spec| spec.id == package_id) - .map(|spec| spec.display_name) - .unwrap_or_else(|| package_id.to_string()) -} - -fn review_lines( - localizer: &Localizer, - target_rows: &[TargetRow], - package_rows: &[PackageRow], - notes: &[String], -) -> Vec { - let mut lines = Vec::new(); - if let Some(target) = target_rows.iter().find(|target| target.selected) { - lines.push( - localizer - .format( - "wizard-review-target", - &[("path", &target.path.display().to_string())], - ) - .value, - ); - } else { - lines.push(localizer.text("wizard-review-no-target").value); - } - - for package in package_rows { - lines.push( - localizer - .format( - "wizard-review-package", - &[ - ("package", package.display_name.as_str()), - ("action", package.action_label.as_str()), - ], - ) - .value, - ); - } - - lines.extend(notes.iter().cloned()); - lines -} - -fn version_text(localizer: &Localizer, version: Option<&rabbit_core::version::Version>) -> String { - version - .map(ToString::to_string) - .unwrap_or_else(|| localizer.text("detect-version-unknown").value) -} - -fn unknown_version_text(model: &WizardModel) -> String { - localizer_from_options(&model.bootstrap_options) - .map(|localizer| localizer.text("detect-version-unknown").value) - .unwrap_or_else(|_| "Version unknown".to_string()) -} - -fn action_label(localizer: &Localizer, action: PlanActionKind) -> String { - let key = match action { - PlanActionKind::Install => "action-install", - PlanActionKind::Update => "action-update", - PlanActionKind::Keep => "action-keep", - }; - localizer.text(key).value -} - -fn yes_no(localizer: &Localizer, value: bool) -> String { - if value { - localizer.text("common-yes").value - } else { - localizer.text("common-no").value - } -} - -fn is_probably_writable(path: &Path) -> bool { - let existing_path = if path.exists() { - path - } else { - path.parent().unwrap_or(path) - }; - - fs::metadata(existing_path) - .map(|metadata| !metadata.permissions().readonly()) - .unwrap_or(false) -} - -#[cfg(test)] -mod tests { - use std::path::PathBuf; - - use rabbit_core::artifact::{ArtifactDescriptor, ArtifactKind}; - use rabbit_core::install::{InstallFileAction, InstallFileReport, InstallReport}; - use rabbit_core::localization::{DEFAULT_LOCALE, Localizer}; - use rabbit_core::model::{Architecture, Confidence, Installation, InstallationKind, Platform}; - use rabbit_core::operation::{ - ManualInstallInstruction, PackageOperationItem, PackageOperationReport, - PackageOperationStatus, PlannedExecutionKind, PlannedExecutionPlan, - }; - use rabbit_core::package::{ - PACKAGE_FFMPEG, PACKAGE_OSARA, PACKAGE_REAKONTROL, PACKAGE_REAPACK, PACKAGE_REAPER, - PACKAGE_SWS, builtin_package_specs, - }; - use rabbit_core::plan::{AvailablePackage, InstallPlan, PlanAction, PlanActionKind}; - use rabbit_core::preflight::PreflightReport; - use rabbit_core::resource::ResourceInitReport; - use rabbit_core::setup::SetupReport; - use rabbit_core::version::Version; - use tempfile::tempdir; - - use super::{ - HostCapabilities, OsaraKeymapChoice, UiBootstrapOptions, WizardInstallRequest, - custom_portable_target_row, format_self_update_apply_summary, localizer_from_options, - model_from_plan, refreshed_target_row, wizard_desired_package_ids_for_host, - }; - - #[test] - fn jaws_scripts_only_appear_when_jaws_is_detected() { - let with_jaws = wizard_desired_package_ids_for_host( - Platform::Windows, - &HostCapabilities { - jaws_installed: true, - ..HostCapabilities::default() - }, - ); - assert!(with_jaws.iter().any(|id| id == "jaws-scripts")); - - let without_jaws = wizard_desired_package_ids_for_host( - Platform::Windows, - &HostCapabilities { - jaws_installed: false, - ..HostCapabilities::default() - }, - ); - assert!(!without_jaws.iter().any(|id| id == "jaws-scripts")); - - // macOS never sees the JAWS row regardless of the host flag — the - // package itself is platform-gated to Windows. - let macos_with_jaws = wizard_desired_package_ids_for_host( - Platform::MacOs, - &HostCapabilities { - jaws_installed: true, - ..HostCapabilities::default() - }, - ); - assert!(!macos_with_jaws.iter().any(|id| id == "jaws-scripts")); - } - - #[test] - fn default_options_use_embedded_localization() { - let options = UiBootstrapOptions::default(); - let localizer = localizer_from_options(&options).unwrap(); - - assert_eq!(localizer.active_locale(), DEFAULT_LOCALE); - assert!(localizer.source_path().is_none()); - assert_eq!( - localizer.text("app-title").value, - "REAPER Accessibility Bootstrap & Bundle Installation Tool" - ); - } - - #[test] - fn package_operation_messages_localize_into_german() { - use rabbit_core::operation::PackageOperationMessage as Msg; - let de = Localizer::embedded("de-DE").unwrap(); - let extension = super::localized_package_operation_message( - &de, - &Msg::ExtensionBinaryInstalled, - "Single extension binary handled by RABBIT installer.", - ); - assert!( - extension.starts_with("Einzelne"), - "expected German extension-binary status, got: {extension:?}" - ); - let skipped = super::localized_package_operation_message( - &de, - &Msg::SkippedCurrent { - installed_version: "8.1.1".to_string(), - available_version: "8.0".to_string(), - }, - "Installed version 8.1.1 is current or newer than available version 8.0.", - ); - assert!( - skipped.contains("Installierte Version") && skipped.contains("8.1.1"), - "expected German skipped-current with version interpolation, got: {skipped:?}" - ); - let dry_run = super::localized_package_operation_message( - &de, - &Msg::DryRunWouldRunUnattended { - artifact_kind: rabbit_core::artifact::ArtifactKind::Installer, - }, - "Dry run: RABBIT would download and run this vendor installer unattended.", - ); - assert!( - dry_run.starts_with("Probelauf") && dry_run.contains("Vendor-Installationsprogramm"), - "expected German dry-run with translated automation kind, got: {dry_run:?}" - ); - } - - #[test] - fn configuration_step_messages_localize_into_german() { - use rabbit_core::configuration::{ConfigurationMessage as Msg, ConfigurationStatus}; - let de = Localizer::embedded("de-DE").unwrap(); - let added = super::localized_configuration_message( - &de, - &Msg::ReapackRemoteAdded { - name: "REAPER Accessibility".to_string(), - url: "https://example.test/index.xml".to_string(), - }, - "Added ReaPack remote ...", - ); - assert!( - added.contains("REAPER Accessibility") - && added.contains("hinzugefügt") - && added.contains("https://example.test/index.xml"), - "expected German added-remote message with name + URL interpolation, got: {added:?}" - ); - let dep_missing = super::localized_configuration_message( - &de, - &Msg::SkippedDependencyMissing { - step_id: "reapack-add-reaper-accessibility-remote".to_string(), - dep_id: "reapack".to_string(), - }, - "Configuration step skipped because dependency missing.", - ); - assert!( - dep_missing.contains("übersprungen") && dep_missing.contains("reapack"), - "expected German dependency-missing message, got: {dep_missing:?}" - ); - let status = super::configuration_status_label_for_summary( - Some(&de), - ConfigurationStatus::SkippedDependencyMissing, - ); - assert!( - status.starts_with("Übersprungen") && status.contains("Abhängigkeit"), - "expected German skipped-dep-missing status, got: {status:?}" - ); - // The step name lookup should resolve `reapack-add-reaper-accessibility-remote` - // to its localized display name. - let step_name = super::localized_configuration_step_name( - Some(&de), - "reapack-add-reaper-accessibility-remote", - ); - assert!( - step_name.contains("ReaPack") - && (step_name.contains("Repository") || step_name.contains("Repositorys")), - "expected German display name for the REAPER Accessibility ReaPack repo step, got: {step_name:?}" - ); - } - - #[test] - fn wizard_command_labels_include_native_mnemonics() { - let localizer = Localizer::embedded(DEFAULT_LOCALE).unwrap(); - let model = model_from_plan( - &localizer, - Platform::Windows, - Architecture::X64, - Vec::new(), - None, - InstallPlan { - target: None, - actions: Vec::new(), - notes: Vec::new(), - }, - ); - - // `localized_wx_mnemonic_label` strips `&` mnemonics on macOS to - // avoid colliding with Cmd+letter system shortcuts (Cmd+C copying - // would close the wizard via the `&Close` mnemonic). Other - // platforms keep the underlined Alt-key access. Test both shapes - // explicitly so a regression in either direction is caught. - if cfg!(target_os = "macos") { - assert_eq!(model.controls.back_label, "Back"); - assert_eq!(model.controls.next_label, "Next"); - assert_eq!(model.controls.install_label, "Install"); - assert_eq!(model.controls.close_label, "Close"); - assert_eq!( - model.text.done_launch_reaper_label, - "Open REAPER and close RABBIT" - ); - assert_eq!( - model.text.done_open_resource_label, - "Open resource folder (only for advanced manual maintenance)" - ); - } else { - assert_eq!(model.controls.back_label, "&Back"); - assert_eq!(model.controls.next_label, "&Next"); - assert_eq!(model.controls.install_label, "&Install"); - assert_eq!(model.controls.close_label, "&Close"); - assert_eq!( - model.text.done_launch_reaper_label, - "&Open REAPER and close RABBIT" - ); - assert_eq!( - model.text.done_open_resource_label, - "Open &resource folder (only for advanced manual maintenance)" - ); - } - assert_eq!( - model.text.package_handling_unattended, - "RABBIT can install this package unattended, including launching its installer when required." - ); - assert_eq!( - model.text.package_handling_planned, - "RABBIT is designed to run this package's installer or setup routine itself and finish the installation unattended, but this build still reports the steps instead of executing them." - ); - assert_eq!( - model.text.packages_osara_keymap_replace_label, - "Replace your current key map with latest OSARA key map" - ); - } - - #[test] - fn wx_mnemonic_labels_support_translated_access_keys() { - assert_eq!(super::wx_mnemonic_label("Weiter", "W"), "&Weiter"); - assert_eq!(super::wx_mnemonic_label("Schliessen", "S"), "&Schliessen"); - assert_eq!( - super::wx_mnemonic_label("Bericht speichern", "S"), - "Bericht &speichern" - ); - assert_eq!(super::wx_mnemonic_label("Weiter", "X"), "Weiter (&X)"); - assert_eq!( - super::wx_mnemonic_label("Save & report", "S"), - "&Save && report" - ); - } - - #[test] - fn locale_directory_override_remains_available_for_development() { - let dir = tempdir().unwrap(); - let locale_dir = dir.path().join("de-DE"); - std::fs::create_dir_all(&locale_dir).unwrap(); - std::fs::write(locale_dir.join("rabbit.ftl"), "app-title = RABBIT Test\n").unwrap(); - let options = UiBootstrapOptions { - locale: "de-DE".to_string(), - locales_dir: Some(dir.path().to_path_buf()), - portable_roots: Vec::new(), - online_versions: false, - }; - - let localizer = localizer_from_options(&options).unwrap(); - - assert_eq!(localizer.active_locale(), "de-DE"); - assert!(localizer.source_path().is_some()); - assert_eq!(localizer.text("app-title").value, "RABBIT Test"); - } - - #[test] - fn builds_initial_wizard_model_from_plan() { - let localizer = Localizer::embedded(DEFAULT_LOCALE).unwrap(); - let installation = fake_installation(); - let plan = InstallPlan { - target: Some(installation.clone()), - actions: vec![ - PlanAction { - package_id: PACKAGE_OSARA.to_string(), - action: PlanActionKind::Install, - installed_version: None, - available_version: Some(Version::parse("2026.1").unwrap()), - reason: "Missing".to_string(), - }, - PlanAction { - package_id: PACKAGE_REAPACK.to_string(), - action: PlanActionKind::Keep, - installed_version: Some(Version::parse("1.2.6").unwrap()), - available_version: Some(Version::parse("1.2.6").unwrap()), - reason: "Current".to_string(), - }, - ], - notes: vec!["Review note".to_string()], - }; - - let model = model_from_plan( - &localizer, - Platform::Windows, - Architecture::X64, - vec![installation], - Some(0), - plan, - ); - - assert_eq!( - model.window_title, - format!( - "REAPER Accessibility Bootstrap & Bundle Installation Tool v{}", - env!("CARGO_PKG_VERSION") - ) - ); - assert_eq!(model.steps.len(), 7); - assert_eq!(model.target_rows.len(), 1); - assert!(model.target_rows[0].selected); - assert!(model.target_rows[0].portable); - assert!( - model.target_rows[0] - .details - .contains("REAPER installation path") - ); - assert!(model.target_rows[0].details.contains("Version:")); - assert!(!model.target_rows[0].details.contains("Architecture")); - assert!( - !model.target_rows[0] - .details - .contains("Detection confidence") - ); - assert!(model.target_rows[0].details.contains("Writable")); - assert_eq!(model.package_rows.len(), 2); - assert_eq!(model.package_rows[0].display_name, "OSARA"); - assert!(model.package_rows[0].summary.contains("OSARA")); - // The wizard package details pane no longer surfaces the internal - // handling-kind line; it stays in the saved report instead. - assert!(!model.package_rows[0].details.contains("Handling:")); - // The localized package description from the embedded en-US locale - // should land in `description` and inside `details` so the wizard's - // package details pane explains what the package is for. - assert!( - model.package_rows[0].description.contains("screen readers"), - "expected OSARA description in row, got {:?}", - model.package_rows[0].description - ); - assert!( - model.package_rows[0] - .details - .contains(&model.package_rows[0].description), - "expected OSARA description embedded in details" - ); - assert_eq!(model.package_rows[0].action_label, "Will install"); - assert!(!model.package_rows[0].manual_attention_expected); - assert_eq!( - model.package_rows[0].handling_summary, - model.text.package_handling_unattended - ); - assert!(model.package_rows[0].selected); - assert_eq!(model.package_rows[1].action_label, "Won't touch"); - assert!(!model.package_rows[1].manual_attention_expected); - assert!(!model.package_rows[1].selected); - assert!(model.controls.can_go_next); - assert!(model.controls.can_install); - assert!(model.review_lines.iter().any(|line| line.contains("OSARA"))); - } - - #[test] - fn toggling_a_package_row_updates_its_action_label_and_summary() { - // The package list row label must follow the user's checkbox state: - // unchecking should switch the visible action to "Keep", and - // re-checking it should restore the install/update action that the - // plan originally chose for this package. - let localizer = Localizer::embedded(DEFAULT_LOCALE).unwrap(); - let installation = fake_installation(); - let model = model_from_plan( - &localizer, - Platform::Windows, - Architecture::X64, - vec![installation.clone()], - Some(0), - InstallPlan { - target: Some(installation), - actions: vec![PlanAction { - package_id: PACKAGE_OSARA.to_string(), - action: PlanActionKind::Install, - installed_version: None, - available_version: Some(Version::parse("2026.1").unwrap()), - reason: "Missing".to_string(), - }], - notes: Vec::new(), - }, - ); - let mut row = model.package_rows[0].clone(); - assert_eq!(row.action, PlanActionKind::Install); - assert_eq!(row.action_label, "Will install"); - assert!(row.selected); - - let summary = super::apply_checkbox_state_to_package_row(&model, &mut row, false).unwrap(); - assert_eq!(row.action, PlanActionKind::Keep); - assert_eq!(row.action_label, "Won't touch"); - assert!(!row.selected); - assert!(summary.contains("Won't touch")); - assert!(row.summary.contains("Won't touch")); - - // Re-checking restores the original install action because the - // package was originally not installed. - let summary = super::apply_checkbox_state_to_package_row(&model, &mut row, true).unwrap(); - assert_eq!(row.action, PlanActionKind::Install); - assert_eq!(row.action_label, "Will install"); - assert!(row.selected); - assert!(summary.contains("Will install")); - } - - #[test] - fn toggling_a_keep_row_to_checked_promotes_it_to_update() { - // For a package that was already installed and current, the plan's - // original action is Keep. If the user explicitly checks the row, - // they're asking RABBIT to re-stage the package — that translates to - // Update so the install pipeline runs. - let localizer = Localizer::embedded(DEFAULT_LOCALE).unwrap(); - let installation = fake_installation(); - let model = model_from_plan( - &localizer, - Platform::Windows, - Architecture::X64, - vec![installation.clone()], - Some(0), - InstallPlan { - target: Some(installation), - actions: vec![PlanAction { - package_id: PACKAGE_REAPACK.to_string(), - action: PlanActionKind::Keep, - installed_version: Some(Version::parse("1.2.6").unwrap()), - available_version: Some(Version::parse("1.2.6").unwrap()), - reason: "Current".to_string(), - }], - notes: Vec::new(), - }, - ); - let mut row = model.package_rows[0].clone(); - assert_eq!(row.action, PlanActionKind::Keep); - assert!(!row.selected); - - let _ = super::apply_checkbox_state_to_package_row(&model, &mut row, true).unwrap(); - assert_eq!(row.action, PlanActionKind::Update); - assert_eq!(row.action_label, "Will update"); - assert!(row.selected); - assert!(row.summary.contains("Will update")); - } - - #[test] - fn non_recommended_package_install_row_starts_unticked() { - // FFmpeg is `recommended: false` in builtin-packages.json. Even when - // the plan's action for it is Install, the wizard must NOT auto-tick - // the row — non-recommended packages should be opt-in. - let localizer = Localizer::embedded(DEFAULT_LOCALE).unwrap(); - let installation = fake_installation(); - let model = model_from_plan( - &localizer, - Platform::Windows, - Architecture::X64, - vec![installation.clone()], - Some(0), - InstallPlan { - target: Some(installation), - actions: vec![PlanAction { - package_id: PACKAGE_FFMPEG.to_string(), - action: PlanActionKind::Install, - installed_version: None, - available_version: Some(Version::parse("8.1.1").unwrap()), - reason: "Missing".to_string(), - }], - notes: Vec::new(), - }, - ); - let row = &model.package_rows[0]; - // The plan's action stays available on `original_action`; the row's - // current `action` mirrors the auto-untick (Keep) so the row label - // reads "Won't touch" instead of "Will install" while unselected. - assert_eq!(row.original_action, PlanActionKind::Install); - assert_eq!(row.action, PlanActionKind::Keep); - assert!(!row.selected); - } - - /// Build a one-package model whose single row is the German language - /// pack in the given plan state. - fn langpack_model(action: PlanActionKind) -> (Localizer, super::WizardModel) { - let localizer = Localizer::embedded(DEFAULT_LOCALE).unwrap(); - let installation = fake_installation(); - let model = model_from_plan( - &localizer, - Platform::Windows, - Architecture::X64, - vec![installation.clone()], - Some(0), - InstallPlan { - target: Some(installation), - actions: vec![PlanAction { - package_id: "langpack-de".to_string(), - action, - installed_version: None, - available_version: None, - reason: "test".to_string(), - }], - notes: Vec::new(), - }, - ); - (localizer, model) - } - - fn language_step(rows: &[super::ConfigurationRow]) -> &super::ConfigurationRow { - rows.iter() - .find(|row| row.step_id == rabbit_core::configuration::CONFIG_SET_REAPER_LANGUAGE) - .expect("set-reaper-language row should exist") - } - - #[test] - fn a_declined_package_does_not_auto_tick_its_updates_either() { - // Someone who stopped using a language pack and turned it down has - // said no to the package, not merely to one version of it. Ticking - // its next update for them would re-offer it every time the - // translators publish a fix, which is the exact loop the refusal is - // meant to end. The row is still there to tick by hand. - let localizer = Localizer::embedded("de-DE").unwrap(); - let text = super::wizard_text(&localizer); - let specs = builtin_package_specs(Platform::Windows); - let declined: std::collections::BTreeSet = - ["langpack-de".to_string()].into_iter().collect(); - - let rows = super::package_rows( - &localizer, - &text, - Platform::Windows, - Architecture::X64, - &specs, - &[PlanAction { - package_id: "langpack-de".to_string(), - action: PlanActionKind::Update, - installed_version: None, - available_version: None, - reason: "upstream file changed".to_string(), - }], - &[], - &HostCapabilities::default(), - &declined, - &Default::default(), - ); - assert!( - !rows[0].selected, - "a refused package must not have its update ticked for the user" - ); - assert!( - rows[0].available_for_target, - "the update must still be listed so it can be taken deliberately" - ); - } - - #[test] - fn only_rows_that_offered_something_record_a_verdict() { - // Three language-pack rows in three states, and only two of them - // mean anything: - // - an Update left unticked is a refusal (the user was offered a - // newer pack and said no), - // - a Keep is silence (there was nothing to accept or refuse), - // - anything ticked is a yes. - let localizer = Localizer::embedded("de-DE").unwrap(); - let installation = fake_installation(); - let action = |id: &str, kind| PlanAction { - package_id: id.to_string(), - action: kind, - installed_version: None, - available_version: None, - reason: "test".to_string(), - }; - let model = model_from_plan( - &localizer, - Platform::Windows, - Architecture::X64, - vec![installation.clone()], - Some(0), - InstallPlan { - target: Some(installation), - actions: vec![ - action("langpack-de", PlanActionKind::Update), - action("langpack-es", PlanActionKind::Keep), - action("langpack-fr", PlanActionKind::Install), - action(PACKAGE_OSARA, PlanActionKind::Install), - ], - notes: Vec::new(), - }, - ); - let index_of = |id: &str| { - model - .package_rows - .iter() - .position(|row| row.package_id == id) - .unwrap_or_else(|| panic!("row for {id} should exist")) - }; - - // Tick only the French pack (and OSARA, so the request is valid). - let request = super::install_request_from_target( - &model, - &model.target_rows[0], - &[index_of("langpack-fr"), index_of(PACKAGE_OSARA)], - super::WizardInstallOptions::default(), - ) - .unwrap(); - - assert_eq!( - request.accepted_packages, - vec!["langpack-fr".to_string()], - "a ticked pack is a yes" - ); - assert_eq!( - request.declined_packages, - vec!["langpack-de".to_string()], - "an unticked Update is a no; the Keep row must say nothing" - ); - assert!( - !request - .declined_packages - .iter() - .any(|id| id == "langpack-es"), - "an installed, up-to-date pack sits unticked because there is \ - nothing to do — that is not a refusal" - ); - } - - /// One REAPER row planned against `available` on `channel`, for a target - /// whose REAPER was last installed from `installed_channel`. - fn reaper_row( - action: PlanActionKind, - channel: Option<&str>, - installed_channel: Option<&str>, - ) -> super::PackageRow { - let localizer = Localizer::embedded(DEFAULT_LOCALE).unwrap(); - let text = super::wizard_text(&localizer); - let specs = builtin_package_specs(Platform::Windows); - let installed_channels: rabbit_core::package::PackageChannels = installed_channel - .map(|channel| { - [(PACKAGE_REAPER.to_string(), channel.to_string())] - .into_iter() - .collect() - }) - .unwrap_or_default(); - super::package_rows( - &localizer, - &text, - Platform::Windows, - Architecture::X64, - &specs, - &[PlanAction { - package_id: PACKAGE_REAPER.to_string(), - action, - installed_version: Some(Version::parse("7.80").unwrap()), - available_version: Some(Version::parse("7.80+dev0918").unwrap()), - reason: "test".to_string(), - }], - &[AvailablePackage { - package_id: PACKAGE_REAPER.to_string(), - version: Some(Version::parse("7.80+dev0918").unwrap()), - whats_new: None, - channel: channel.map(str::to_string), - }], - &HostCapabilities::default(), - &Default::default(), - &installed_channels, - ) - .remove(0) - } - - #[test] - fn a_development_build_says_so_in_the_row_itself() { - let row = reaper_row(PlanActionKind::Update, Some("dev"), None); - assert!( - row.summary.contains("(development build)"), - "{}", - row.summary - ); - assert!(row.details.contains("unsupported"), "{}", row.details); - } - - #[test] - fn ticking_a_row_keeps_its_channel_tag() { - let localizer = Localizer::embedded(DEFAULT_LOCALE).unwrap(); - let installation = fake_installation(); - let model = model_from_plan( - &localizer, - Platform::Windows, - Architecture::X64, - vec![installation.clone()], - Some(0), - InstallPlan { - target: Some(installation), - actions: Vec::new(), - notes: Vec::new(), - }, - ); - let mut row = reaper_row(PlanActionKind::Update, Some("dev"), None); - super::apply_checkbox_state_to_package_row(&model, &mut row, false).unwrap(); - super::apply_checkbox_state_to_package_row(&model, &mut row, true).unwrap(); - assert!( - row.summary.contains("(development build)"), - "{}", - row.summary - ); - - // The way back is only announced while the row actually takes it. - let mut back = reaper_row(PlanActionKind::Update, None, Some("dev")); - super::apply_checkbox_state_to_package_row(&model, &mut back, false).unwrap(); - assert!(!back.summary.contains("back to"), "{}", back.summary); - super::apply_checkbox_state_to_package_row(&model, &mut back, true).unwrap(); - assert!(back.summary.contains("back to"), "{}", back.summary); - } - - #[test] - fn a_row_that_installs_nothing_does_not_promise_an_install() { - // After installing a development build, the re-plan keeps REAPER. - // The tag still says which build it is, but "will be installed" - // would be false. - let row = reaper_row(PlanActionKind::Keep, Some("dev"), Some("dev")); - assert!( - row.summary.contains("(development build)"), - "{}", - row.summary - ); - assert!( - !row.details.contains("will be installed"), - "{}", - row.details - ); - } - - #[test] - fn a_pull_request_build_names_its_pull_request() { - let row = reaper_row(PlanActionKind::Update, Some("pr:1454"), None); - assert!(row.summary.contains("pull request 1454"), "{}", row.summary); - assert!(row.details.contains("90 days"), "{}", row.details); - } - - #[test] - fn going_back_to_the_regular_release_is_announced() { - // Leaving expert mode quietly takes a pre-release back to the regular - // release. The unlock dialog is long gone by then, so the row is the - // only place that can say why REAPER is being "updated" to an older - // version. - let row = reaper_row(PlanActionKind::Update, None, Some("dev")); - assert!( - row.summary.contains("back to the regular release"), - "{}", - row.summary - ); - // ...but only when this run actually does something with it. - let kept = reaper_row(PlanActionKind::Keep, None, Some("dev")); - assert!(!kept.summary.contains("back to"), "{}", kept.summary); - // and an ordinary stable row carries no note at all. - let plain = reaper_row(PlanActionKind::Update, None, None); - assert!(!plain.summary.contains('('), "{}", plain.summary); - } - - #[test] - fn expert_mode_env_values_and_channels_outside_expert_mode() { - for on in ["1", "true", "YES", " on "] { - assert!(super::expert_mode_value_enabled(on), "{on:?}"); - } - for off in ["", "0", "false", "no", "maybe"] { - assert!(!super::expert_mode_value_enabled(off), "{off:?}"); - } - // Outside expert mode no channel is passed, whatever the choices - // say - which is what sends pre-release packages back to stable. - assert!(super::wizard_channels(false, Some("dev".into()), Some("pr:1".into())).is_empty()); - let on = super::wizard_channels(true, Some("dev".into()), None); - assert_eq!(on.get(PACKAGE_REAPER).map(String::as_str), Some("dev")); - assert!(!on.contains_key(PACKAGE_OSARA)); - } - - #[test] - fn a_declined_language_pack_stops_being_ticked_by_default() { - // RABBIT running in German suggests the German pack — that is the - // whole point of the UI-language match. But someone who does not - // want it had to untick it on every single launch, because nothing - // remembered the refusal. - let localizer = Localizer::embedded("de-DE").unwrap(); - let text = super::wizard_text(&localizer); - let specs = builtin_package_specs(Platform::Windows); - let host = HostCapabilities::default(); - let actions = vec![PlanAction { - package_id: "langpack-de".to_string(), - action: PlanActionKind::Install, - installed_version: None, - available_version: None, - reason: "Missing".to_string(), - }]; - - let suggested = super::package_rows( - &localizer, - &text, - Platform::Windows, - Architecture::X64, - &specs, - &actions, - &[], - &host, - &Default::default(), - &Default::default(), - ); - assert!( - suggested[0].selected, - "a German RABBIT should suggest the German pack" - ); - - let declined: std::collections::BTreeSet = - ["langpack-de".to_string()].into_iter().collect(); - let remembered = super::package_rows( - &localizer, - &text, - Platform::Windows, - Architecture::X64, - &specs, - &actions, - &[], - &host, - &declined, - &Default::default(), - ); - assert!( - !remembered[0].selected, - "a pack the user turned down must not come back ticked" - ); - assert!( - remembered[0].available_for_target, - "it must stay listed and tickable — this suppresses the tick, not the package" - ); - } - - #[test] - fn the_language_step_names_a_language_pack_not_a_particular_one() { - // Reported from Q&A: the row read "Set REAPER's language (requires - // REAPER en espanol)". The step lists every language pack as an - // any-of dependency and langpack-es sorts first, so the message - // picked it out and made a general requirement look Spanish-specific. - let localizer = Localizer::embedded(DEFAULT_LOCALE).unwrap(); - let step = rabbit_core::configuration::builtin_configuration_steps() - .into_iter() - .find(|step| step.id == rabbit_core::configuration::CONFIG_SET_REAPER_LANGUAGE) - .expect("the language step exists"); - assert!( - step.requires_packages.len() > 1, - "this test only means anything while the step is an any-of" - ); - - let summary = super::configuration_row_summary( - &localizer, - &step, - "Set REAPER's language", - false, - false, - ); - let reason = - super::build_configuration_unavailability_reason(&localizer, &step, false, false) - .expect("an unsatisfied dependency has a reason"); - - for text in [&summary, &reason] { - assert!( - text.contains("language pack"), - "the message should name the group: {text}" - ); - for package_id in &step.requires_packages { - let name = localizer.text(&format!("package-{package_id}")); - if name.missing || name.value.is_empty() { - continue; - } - assert!( - !text.contains(&name.value), - "must not single out {:?}: {text}", - name.value - ); - } - } - } - - #[test] - fn a_single_dependency_step_still_names_its_package() { - // The fix above must not generalise away a genuinely specific - // requirement: the ReaPack steps depend on exactly one package and - // should keep saying so. - let localizer = Localizer::embedded(DEFAULT_LOCALE).unwrap(); - let step = rabbit_core::configuration::builtin_configuration_steps() - .into_iter() - .find(|step| step.id == "reapack-add-reaper-accessibility-remote") - .expect("the ReaPack remote step exists"); - - let reason = - super::build_configuration_unavailability_reason(&localizer, &step, false, false) - .expect("an unsatisfied dependency has a reason"); - let reapack = localizer.text("package-reapack"); - assert!( - reason.contains(&reapack.value), - "a one-package step should name it: {reason}" - ); - } - - #[test] - fn language_step_is_unavailable_while_no_language_pack_is_ticked() { - // The step activates one of the packs you ticked — the "REAPER - // language after installation" dropdown lists exactly those — so - // with none ticked it has nothing to point at. It must be greyed - // out rather than tickable-but-meaningless, even when a pack is - // already sitting on disk from an earlier run. - let (_localizer, model) = langpack_model(PlanActionKind::Keep); - let pack = &model.package_rows[0]; - assert!(!pack.selected, "an already-installed pack starts unticked"); - - let step = language_step(&model.configuration_rows); - assert!( - !step.available_for_target, - "no language pack is ticked, so the step must not be tickable" - ); - assert!(!step.selected, "and it must not be ticked"); - } - - #[test] - fn language_step_follows_the_pack_being_ticked_and_unticked() { - let (localizer, model) = langpack_model(PlanActionKind::Keep); - let mut package_rows = model.package_rows.clone(); - let mut configuration_rows = model.configuration_rows.clone(); - - // Tick the pack: the user explicitly wants it re-staged, so the - // step's default flips on. - super::apply_checkbox_state_to_package_row(&model, &mut package_rows[0], true).unwrap(); - super::recompute_configuration_row_availability( - &localizer, - &package_rows, - None, - &mut configuration_rows, - ); - let step = language_step(&configuration_rows); - assert!( - step.available_for_target, - "ticking a language pack must make the step usable" - ); - assert!( - step.selected, - "ticking a language pack must tick the step that activates it" - ); - - // Untick it again: nothing language-related is happening any more. - super::apply_checkbox_state_to_package_row(&model, &mut package_rows[0], false).unwrap(); - super::recompute_configuration_row_availability( - &localizer, - &package_rows, - None, - &mut configuration_rows, - ); - let step = language_step(&configuration_rows); - assert!( - !step.selected, - "unticking the last pack must untick the step again" - ); - assert!( - !step.available_for_target, - "and must grey it out, since there is no longer a pack to activate" - ); - } - - #[test] - fn language_step_keeps_an_explicit_user_override() { - let (localizer, model) = langpack_model(PlanActionKind::Keep); - let mut package_rows = model.package_rows.clone(); - let mut configuration_rows = model.configuration_rows.clone(); - - super::apply_checkbox_state_to_package_row(&model, &mut package_rows[0], true).unwrap(); - super::recompute_configuration_row_availability( - &localizer, - &package_rows, - None, - &mut configuration_rows, - ); - assert!(language_step(&configuration_rows).selected); - - // The user unticks the step by hand: install the pack, but leave - // REAPER's language alone. A later recompute must not re-tick it. - let index = configuration_rows - .iter() - .position(|row| row.step_id == rabbit_core::configuration::CONFIG_SET_REAPER_LANGUAGE) - .unwrap(); - configuration_rows[index].selected = false; - super::recompute_configuration_row_availability( - &localizer, - &package_rows, - None, - &mut configuration_rows, - ); - assert!( - !language_step(&configuration_rows).selected, - "an explicit untick must survive a recompute" - ); - } - - #[test] - fn additive_steps_still_auto_tick_for_an_already_installed_dependency() { - // The guard is deliberately narrow: only steps that CHANGE existing - // configuration wait for a fresh install. Adding the ReaPack remote - // is additive, so an already-installed ReaPack still auto-ticks it. - let localizer = Localizer::embedded(DEFAULT_LOCALE).unwrap(); - let installation = fake_installation(); - let model = model_from_plan( - &localizer, - Platform::Windows, - Architecture::X64, - vec![installation.clone()], - Some(0), - InstallPlan { - target: Some(installation), - actions: vec![PlanAction { - package_id: PACKAGE_REAPACK.to_string(), - action: PlanActionKind::Keep, - installed_version: Some(Version::parse("1.2.6").unwrap()), - available_version: Some(Version::parse("1.2.6").unwrap()), - reason: "installed".to_string(), - }], - notes: Vec::new(), - }, - ); - let remote = model - .configuration_rows - .iter() - .find(|row| row.step_id == "reapack-add-reaper-accessibility-remote") - .expect("reapack remote row should exist"); - assert!(remote.available_for_target); - assert!( - remote.selected, - "additive steps must keep auto-ticking for a dependency that is already installed" - ); - } - - #[test] - fn configuration_row_unticks_when_dep_package_is_unticked_install() { - // ReaPack is `recommended: false`. With a fresh target it lands as an - // Install action that arrives unticked-by-default, so the - // "add ReaPack remote" configuration step's dependency is NOT - // satisfied — the configuration row must start unticked too, - // otherwise the wizard would queue a config step that points at a - // package the user hasn't asked for. - let localizer = Localizer::embedded(DEFAULT_LOCALE).unwrap(); - let installation = fake_installation(); - let model = model_from_plan( - &localizer, - Platform::Windows, - Architecture::X64, - vec![installation.clone()], - Some(0), - InstallPlan { - target: Some(installation), - actions: vec![PlanAction { - package_id: PACKAGE_REAPACK.to_string(), - action: PlanActionKind::Install, - installed_version: None, - available_version: Some(Version::parse("1.2.6").unwrap()), - reason: "Missing".to_string(), - }], - notes: Vec::new(), - }, - ); - - let reapack = &model.package_rows[0]; - // Plan's action lives on `original_action`; the unticked row's - // current `action` is Keep so the visible row label says "Won't touch". - assert_eq!(reapack.original_action, PlanActionKind::Install); - assert_eq!(reapack.action, PlanActionKind::Keep); - assert!(!reapack.selected, "ReaPack row should start unticked"); - - let reapack_remote = model - .configuration_rows - .iter() - .find(|row| row.step_id == "reapack-add-reaper-accessibility-remote") - .expect("reapack-add-reaper-accessibility-remote row should exist"); - assert!( - !reapack_remote.available_for_target, - "config row's dependency package isn't going to land on disk; \ - row must be marked unavailable" - ); - assert!( - !reapack_remote.selected, - "config row must not auto-tick when its dep package is an unticked Install" - ); - } - - #[test] - fn reakontrol_install_row_starts_ticked_when_komplete_kontrol_is_detected() { - // ReaKontrol's manifest baseline is `recommended: false`, but it - // declares `recommended_when: komplete_kontrol_installed` so the - // wizard escalates it to recommended-by-default for users who have - // Komplete Kontrol on their host. This test pins the host capability - // explicitly so the result doesn't depend on whether dev/CI has KK. - let localizer = Localizer::embedded(DEFAULT_LOCALE).unwrap(); - let text = super::wizard_text(&localizer); - let specs = builtin_package_specs(Platform::Windows); - let host = HostCapabilities { - komplete_kontrol_installed: true, - ..HostCapabilities::default() - }; - - let rows = super::package_rows( - &localizer, - &text, - Platform::Windows, - Architecture::X64, - &specs, - &[PlanAction { - package_id: PACKAGE_REAKONTROL.to_string(), - action: PlanActionKind::Install, - installed_version: None, - available_version: Some(Version::parse("2026.2").unwrap()), - reason: "Missing".to_string(), - }], - &[], - &host, - &Default::default(), - &Default::default(), - ); - assert!( - rows[0].selected, - "ReaKontrol Install row must auto-tick on a host where Komplete \ - Kontrol is detected" - ); - - // Sanity: same plan, KK absent → row stays unticked (the manifest - // baseline of recommended:false wins). - let rows = super::package_rows( - &localizer, - &text, - Platform::Windows, - Architecture::X64, - &specs, - &[PlanAction { - package_id: PACKAGE_REAKONTROL.to_string(), - action: PlanActionKind::Install, - installed_version: None, - available_version: Some(Version::parse("2026.2").unwrap()), - reason: "Missing".to_string(), - }], - &[], - &HostCapabilities::default(), - &Default::default(), - &Default::default(), - ); - assert!( - !rows[0].selected, - "ReaKontrol Install row must stay unticked on a host without \ - Komplete Kontrol" - ); - } - - #[test] - fn non_recommended_package_update_row_starts_ticked() { - // Update means the package is already on disk — the user opted into - // having it. RABBIT should keep it current by default, so the Update - // row stays auto-ticked even for a non-recommended package. - let localizer = Localizer::embedded(DEFAULT_LOCALE).unwrap(); - let installation = fake_installation(); - let model = model_from_plan( - &localizer, - Platform::Windows, - Architecture::X64, - vec![installation.clone()], - Some(0), - InstallPlan { - target: Some(installation), - actions: vec![PlanAction { - package_id: PACKAGE_FFMPEG.to_string(), - action: PlanActionKind::Update, - installed_version: Some(Version::parse("8.0.0").unwrap()), - available_version: Some(Version::parse("8.1.1").unwrap()), - reason: "Older version on disk".to_string(), - }], - notes: Vec::new(), - }, - ); - let row = &model.package_rows[0]; - assert_eq!(row.action, PlanActionKind::Update); - assert!(row.selected); - } - - #[test] - fn disables_next_when_no_target_is_selected() { - let localizer = Localizer::embedded(DEFAULT_LOCALE).unwrap(); - let model = model_from_plan( - &localizer, - Platform::Windows, - Architecture::X64, - Vec::new(), - None, - InstallPlan { - target: None, - actions: Vec::new(), - notes: Vec::new(), - }, - ); - - assert!(!model.controls.can_go_next); - assert!(!model.controls.can_install); - assert_eq!(model.review_lines[0], "No target selected."); - } - - #[test] - fn builds_install_request_from_selected_rows() { - let localizer = Localizer::embedded(DEFAULT_LOCALE).unwrap(); - let installation = fake_installation(); - let model = model_from_plan( - &localizer, - Platform::Windows, - Architecture::X64, - vec![installation], - Some(0), - InstallPlan { - target: None, - actions: vec![ - PlanAction { - package_id: PACKAGE_OSARA.to_string(), - action: PlanActionKind::Install, - installed_version: None, - available_version: None, - reason: "Missing".to_string(), - }, - PlanAction { - package_id: PACKAGE_REAPACK.to_string(), - action: PlanActionKind::Keep, - installed_version: None, - available_version: None, - reason: "Current".to_string(), - }, - ], - notes: Vec::new(), - }, - ); - - let request = super::install_request_from_model( - &model, - Some(0), - &[0], - super::WizardInstallOptions { - dry_run: true, - allow_reaper_running: true, - stage_unsupported: false, - osara_keymap_choice: OsaraKeymapChoice::ReplaceCurrent, - package_variants: Default::default(), - reaper_language_package: None, - cache_dir: Some(PathBuf::from("C:/cache")), - package_channels: Default::default(), - }, - ) - .unwrap(); - - assert_eq!(request.resource_path, PathBuf::from("C:/REAPER")); - assert_eq!(request.package_ids, vec![PACKAGE_OSARA.to_string()]); - assert!(request.portable); - assert_eq!( - request.target_app_path, - Some(PathBuf::from("C:/REAPER/reaper.exe")) - ); - assert!(request.dry_run); - assert_eq!( - request.osara_keymap_choice, - OsaraKeymapChoice::ReplaceCurrent - ); - assert_eq!(request.cache_dir, PathBuf::from("C:/cache")); - } - - #[test] - fn install_request_requires_selected_package() { - let localizer = Localizer::embedded(DEFAULT_LOCALE).unwrap(); - let installation = fake_installation(); - let model = model_from_plan( - &localizer, - Platform::Windows, - Architecture::X64, - vec![installation], - Some(0), - InstallPlan { - target: None, - actions: Vec::new(), - notes: Vec::new(), - }, - ); - - let error = super::install_request_from_model( - &model, - Some(0), - &[], - super::WizardInstallOptions::default(), - ) - .unwrap_err(); - - assert!(error.to_string().contains("No package")); - } - - #[test] - fn builds_custom_portable_target_row() { - let dir = tempdir().unwrap(); - let localizer = Localizer::embedded(DEFAULT_LOCALE).unwrap(); - let model = model_from_plan( - &localizer, - Platform::Windows, - Architecture::X64, - Vec::new(), - None, - InstallPlan { - target: None, - actions: Vec::new(), - notes: Vec::new(), - }, - ); - - let row = custom_portable_target_row(&model, dir.path().join("PortableREAPER"), true); - - assert!(row.selected); - assert!(row.portable); - assert!(row.writable); - assert!(row.app_path.is_none()); - assert_eq!( - row.planned_app_path, - dir.path().join("PortableREAPER").join("reaper.exe") - ); - assert!(row.label.contains("Portable REAPER folder")); - assert!(row.details.contains("REAPER application path")); - assert!(row.details.contains("REAPER version: Unknown version")); - assert!(!row.details.contains("Architecture")); - assert!(row.details.contains("Portable resource path")); - } - - #[test] - fn custom_portable_target_uses_reaper_exe_when_present() { - let dir = tempdir().unwrap(); - let resource_path = dir.path().join("PortableREAPER"); - std::fs::create_dir_all(&resource_path).unwrap(); - std::fs::write(resource_path.join("reaper.exe"), b"").unwrap(); - let localizer = Localizer::embedded(DEFAULT_LOCALE).unwrap(); - let model = model_from_plan( - &localizer, - Platform::Windows, - Architecture::X64, - Vec::new(), - None, - InstallPlan { - target: None, - actions: Vec::new(), - notes: Vec::new(), - }, - ); - - let row = custom_portable_target_row(&model, resource_path.clone(), true); - - assert_eq!(row.app_path, Some(resource_path.join("reaper.exe"))); - assert_eq!(row.planned_app_path, resource_path.join("reaper.exe")); - } - - #[test] - fn refreshed_standard_target_row_detects_app_that_appeared_after_startup() { - let dir = tempdir().unwrap(); - let resource_path = dir.path().join("REAPER"); - let app_path = dir - .path() - .join("Program Files") - .join("REAPER") - .join("reaper.exe"); - std::fs::create_dir_all(&resource_path).unwrap(); - std::fs::create_dir_all(app_path.parent().unwrap()).unwrap(); - - let localizer = Localizer::embedded(DEFAULT_LOCALE).unwrap(); - let installation = Installation { - kind: InstallationKind::Standard, - platform: Platform::Windows, - app_path: app_path.clone(), - resource_path: resource_path.clone(), - version: None, - architecture: Some(Architecture::X64), - writable: true, - confidence: Confidence::Low, - evidence: Vec::new(), - }; - let model = model_from_plan( - &localizer, - Platform::Windows, - Architecture::X64, - vec![installation], - Some(0), - InstallPlan { - target: None, - actions: Vec::new(), - notes: Vec::new(), - }, - ); - - assert!(model.target_rows[0].app_path.is_none()); - - std::fs::write(&app_path, b"").unwrap(); - - let refreshed = refreshed_target_row(&model, &model.target_rows[0]); - - assert_eq!(refreshed.app_path, Some(app_path.clone())); - assert_eq!(refreshed.planned_app_path, app_path); - assert!( - refreshed - .details - .contains(&resource_path.display().to_string()) - ); - } - - #[test] - fn builds_package_plan_for_custom_target_path() { - let dir = tempdir().unwrap(); - let plugins = dir.path().join("PortableREAPER").join("UserPlugins"); - std::fs::create_dir_all(&plugins).unwrap(); - std::fs::write(plugins.join("reaper_reapack-x64.dll"), b"installed").unwrap(); - let localizer = Localizer::embedded(DEFAULT_LOCALE).unwrap(); - let model = model_from_plan( - &localizer, - Platform::Windows, - Architecture::X64, - Vec::new(), - None, - InstallPlan { - target: None, - actions: Vec::new(), - notes: Vec::new(), - }, - ); - let target = custom_portable_target_row(&model, dir.path().join("PortableREAPER"), true); - - let plan = super::wizard_package_plan_for_target(&model, Some(&target)).unwrap(); - let reapack = plan - .package_rows - .iter() - .find(|row| row.package_id == PACKAGE_REAPACK) - .unwrap(); - - assert_eq!(reapack.action, PlanActionKind::Keep); - assert!(!reapack.selected); - assert!(plan.package_rows.iter().any(|row| row.selected)); - } - - #[test] - fn package_plan_includes_reaper_for_empty_custom_target() { - let dir = tempdir().unwrap(); - let localizer = Localizer::embedded(DEFAULT_LOCALE).unwrap(); - let model = model_from_plan( - &localizer, - Platform::Windows, - Architecture::X64, - Vec::new(), - None, - InstallPlan { - target: None, - actions: Vec::new(), - notes: Vec::new(), - }, - ); - let target = custom_portable_target_row(&model, dir.path().join("PortableREAPER"), true); - - let plan = super::wizard_package_plan_for_target(&model, Some(&target)).unwrap(); - let reaper = plan - .package_rows - .iter() - .find(|row| row.package_id == PACKAGE_REAPER) - .unwrap(); - - assert_eq!(reaper.display_name, "REAPER"); - assert_eq!(reaper.action, PlanActionKind::Install); - assert!(!reaper.manual_attention_expected); - // Handling-kind no longer surfaces in the wizard details pane; it - // remains as a structured field on PackageRow for the saved report. - assert_eq!( - reaper.handling_summary, - model.text.package_handling_unattended - ); - assert!(reaper.selected); - } - - #[test] - fn portable_target_marks_surge_xt_as_unavailable() { - let dir = tempdir().unwrap(); - let localizer = Localizer::embedded(DEFAULT_LOCALE).unwrap(); - let model = model_from_plan( - &localizer, - Platform::Windows, - Architecture::X64, - Vec::new(), - None, - InstallPlan { - target: None, - actions: Vec::new(), - notes: Vec::new(), - }, - ); - let target = custom_portable_target_row(&model, dir.path().join("PortableREAPER"), true); - - let plan = super::wizard_package_plan_for_target(&model, Some(&target)).unwrap(); - let surge = plan - .package_rows - .iter() - .find(|row| row.package_id == rabbit_core::package::PACKAGE_SURGE_XT) - .expect("Surge XT row should appear in the package list"); - - assert!( - !surge.available_for_target, - "Surge XT must be marked unavailable on a portable REAPER target" - ); - assert!(!surge.selected); - assert_eq!(surge.action, PlanActionKind::Keep); - assert!( - surge.unavailability_reason.is_some(), - "the gate should attach a localized reason so screen readers announce why" - ); - } - - #[test] - fn portable_target_marks_app2clap_as_unavailable() { - // app2clap installs into the per-user CLAP folder, outside any - // portable REAPER folder, so it must be gated on portable targets - // exactly like Surge XT. - let dir = tempdir().unwrap(); - let localizer = Localizer::embedded(DEFAULT_LOCALE).unwrap(); - let model = model_from_plan( - &localizer, - Platform::Windows, - Architecture::X64, - Vec::new(), - None, - InstallPlan { - target: None, - actions: Vec::new(), - notes: Vec::new(), - }, - ); - let target = custom_portable_target_row(&model, dir.path().join("PortableREAPER"), true); - - let plan = super::wizard_package_plan_for_target(&model, Some(&target)).unwrap(); - let app2clap = plan - .package_rows - .iter() - .find(|row| row.package_id == rabbit_core::package::PACKAGE_APP2CLAP) - .expect("app2clap row should appear in the package list"); - - assert!( - !app2clap.available_for_target, - "app2clap must be marked unavailable on a portable REAPER target" - ); - assert!(!app2clap.selected); - assert_eq!(app2clap.action, PlanActionKind::Keep); - assert!( - app2clap.unavailability_reason.is_some(), - "the gate should attach a localized reason so screen readers announce why" - ); - } - - #[test] - fn version_check_failure_disables_row_and_records_note() { - // SWS-website-down scenario: the deferred latest-version check fails - // for one package. Its row must be disabled (unchecked, Keep, with a - // localized reason) while every other row keeps its update flow, and - // the full error message must land in the notes for the Review page. - let dir = tempdir().unwrap(); - let localizer = Localizer::embedded(DEFAULT_LOCALE).unwrap(); - let model = model_from_plan( - &localizer, - Platform::Windows, - Architecture::X64, - Vec::new(), - None, - InstallPlan { - target: None, - actions: Vec::new(), - notes: Vec::new(), - }, - ); - let target = custom_portable_target_row(&model, dir.path().join("PortableREAPER"), true); - let mut plan = super::wizard_package_plan_for_target(&model, Some(&target)).unwrap(); - - let failures = vec![( - PACKAGE_SWS.to_string(), - "HTTP status server error (503) for https://sws-extension.org/".to_string(), - )]; - let can_install = super::apply_version_check_failures_to_rows( - &localizer, - &mut plan.package_rows, - &mut plan.notes, - &failures, - ); - - let sws = plan - .package_rows - .iter() - .find(|row| row.package_id == PACKAGE_SWS) - .expect("SWS row should be in the package list"); - assert!(!sws.available_for_target); - assert!(!sws.selected); - assert_eq!(sws.action, PlanActionKind::Keep); - assert!( - sws.unavailability_reason.is_some(), - "a localized reason should be attached so screen readers announce why" - ); - - // Other packages keep their flow — the plan as a whole stays - // actionable (REAPER etc. are still installable on the empty target). - assert!(can_install); - assert!( - plan.package_rows - .iter() - .any(|row| row.package_id != PACKAGE_SWS && row.available_for_target) - ); - - // The note carries the package name and the full error text. - assert!( - plan.notes - .iter() - .any(|note| note.contains("SWS") && note.contains("503")), - "expected a note with the failure detail, got {:?}", - plan.notes - ); - } - - #[test] - fn whats_new_notes_render_in_the_package_details_pane() { - // A package whose deferred version check resolved What's-New notes - // gets them appended to its details pane under a localized heading; - // packages without notes keep the plain summary + description. - let dir = tempdir().unwrap(); - let localizer = Localizer::embedded(DEFAULT_LOCALE).unwrap(); - let model = model_from_plan( - &localizer, - Platform::Windows, - Architecture::X64, - Vec::new(), - None, - InstallPlan { - target: None, - actions: Vec::new(), - notes: Vec::new(), - }, - ); - let target = custom_portable_target_row(&model, dir.path().join("PortableREAPER"), true); - let available = vec![AvailablePackage { - package_id: PACKAGE_OSARA.to_string(), - version: Some(Version::parse("2026.8.1.2278,857265da").unwrap()), - whats_new: Some("• Fix the slider.\n• Logging improvements.".to_string()), - channel: None, - }]; - let plan = - super::wizard_package_plan_for_target_with_available(&model, Some(&target), &available) - .unwrap(); - - let osara = plan - .package_rows - .iter() - .find(|row| row.package_id == PACKAGE_OSARA) - .expect("OSARA row should be in the package list"); - assert!( - osara.details.contains("What's new in OSARA:"), - "expected a localized What's-New heading, got {:?}", - osara.details - ); - assert!(osara.details.contains("• Fix the slider.")); - // The notes follow the description, they don't replace it. - assert!(osara.details.contains(&osara.description)); - - let reaper = plan - .package_rows - .iter() - .find(|row| row.package_id == PACKAGE_REAPER) - .expect("REAPER row should be in the package list"); - assert!( - !reaper.details.contains("What's new"), - "a package without resolved notes must keep its plain details" - ); - } - - #[cfg(target_os = "windows")] - #[test] - fn selectable_installations_appends_standard_target_when_missing() { - let installations = - super::selectable_installations(Platform::Windows, vec![fake_installation()]); - - assert_eq!(installations[0].kind, InstallationKind::Portable); - assert_eq!( - installations - .iter() - .filter(|installation| installation.kind == InstallationKind::Standard) - .count(), - 1 - ); - } - - #[test] - fn selectable_installations_does_not_duplicate_detected_standard_target() { - let installations = super::selectable_installations( - Platform::Windows, - vec![fake_standard_installation(), fake_installation()], - ); - - assert_eq!( - installations - .iter() - .filter(|installation| installation.kind == InstallationKind::Standard) - .count(), - 1 - ); - } - - #[test] - fn reaper_windows_row_uses_unattended_handling() { - let dir = tempdir().unwrap(); - let localizer = Localizer::embedded(DEFAULT_LOCALE).unwrap(); - let model = model_from_plan( - &localizer, - Platform::Windows, - Architecture::X64, - Vec::new(), - None, - InstallPlan { - target: None, - actions: Vec::new(), - notes: Vec::new(), - }, - ); - let target = custom_portable_target_row(&model, dir.path().join("PortableREAPER"), true); - let plan = super::wizard_package_plan_for_target(&model, Some(&target)).unwrap(); - let reaper_row = plan - .package_rows - .iter() - .find(|row| row.package_id == PACKAGE_REAPER) - .unwrap(); - - assert!(!reaper_row.manual_attention_expected); - assert_eq!( - reaper_row.handling_summary, - model.text.package_handling_unattended - ); - } - - #[test] - fn sws_windows_uses_unattended_handling_summary() { - let localizer = Localizer::embedded(DEFAULT_LOCALE).unwrap(); - let model = model_from_plan( - &localizer, - Platform::Windows, - Architecture::X64, - Vec::new(), - None, - InstallPlan { - target: None, - actions: Vec::new(), - notes: Vec::new(), - }, - ); - - let (handling_summary, manual_attention_expected) = super::package_handling_summary( - &model.text, - PACKAGE_SWS, - Platform::Windows, - Architecture::X64, - ); - - assert_eq!(handling_summary, model.text.package_handling_unattended); - assert!(!manual_attention_expected); - } - - #[test] - fn review_preview_includes_osara_keymap_choice() { - let localizer = Localizer::embedded(DEFAULT_LOCALE).unwrap(); - let installation = fake_installation(); - let model = model_from_plan( - &localizer, - Platform::Windows, - Architecture::X64, - vec![installation.clone()], - Some(0), - InstallPlan { - target: Some(installation), - actions: vec![PlanAction { - package_id: PACKAGE_OSARA.to_string(), - action: PlanActionKind::Install, - installed_version: None, - available_version: Some(Version::parse("2026.1").unwrap()), - reason: "Missing".to_string(), - }], - notes: Vec::new(), - }, - ); - - let preview = super::build_review_preview_for_package_rows( - &model, - model.target_rows.first(), - &[0], - &model.package_rows, - &model.notes, - OsaraKeymapChoice::ReplaceCurrent, - ); - - assert!(preview.lines.iter().any(|line| line == "OSARA key map")); - assert!(preview.lines.iter().any(|line| { - line.contains("Backup your current key map") && line.contains("OSARA") - })); - assert!( - !preview - .lines - .iter() - .any(|line| line == "Manual attention expected") - ); - } - - #[test] - fn osara_keymap_note_defaults_to_unavailable_when_osara_is_not_selected() { - let localizer = Localizer::embedded(DEFAULT_LOCALE).unwrap(); - let model = model_from_plan( - &localizer, - Platform::Windows, - Architecture::X64, - Vec::new(), - None, - InstallPlan { - target: None, - actions: Vec::new(), - notes: Vec::new(), - }, - ); - - let note = super::osara_keymap_note(&model, false, OsaraKeymapChoice::PreserveCurrent); - - assert!(note.contains("Select OSARA")); - } - - #[test] - fn default_install_options_replace_osara_keymap() { - assert_eq!( - super::WizardInstallOptions::default().osara_keymap_choice, - OsaraKeymapChoice::ReplaceCurrent - ); - } - - #[test] - fn the_summary_says_what_runs_for_an_installer_still_in_its_zip() { - let plan = |kind, program: Option<&str>| PlannedExecutionPlan { - kind, - artifact_location: "https://nightly.link/jcsteh/osara/actions/artifacts/1.zip" - .to_string(), - program: program.map(str::to_string), - arguments: Vec::new(), - working_directory: None, - verification_paths: Vec::new(), - requires_elevation: false, - freshness_paths: Vec::new(), - }; - let english = Localizer::embedded(DEFAULT_LOCALE).unwrap(); - let german = Localizer::embedded("de-DE").unwrap(); - - let zipped = plan(PlannedExecutionKind::LaunchInstallerExecutable, None); - let line = super::planned_execution_program_line(Some(&english), &zipped).unwrap(); - assert!(line.contains("the installer inside the .zip"), "{line}"); - let line = super::planned_execution_program_line(Some(&german), &zipped).unwrap(); - assert!(line.contains("ZIP-Datei"), "{line}"); - - let downloaded = plan( - PlannedExecutionKind::LaunchInstallerExecutable, - Some(r"C:\cache\osara.unzipped\osara.exe"), - ); - let line = super::planned_execution_program_line(Some(&english), &downloaded).unwrap(); - assert!( - line.contains(r"C:\cache\osara.unzipped\osara.exe"), - "{line}" - ); - - // A plan that never names a program still gets no line. - let archive = plan(PlannedExecutionKind::ExtractArchiveAndRunInstaller, None); - assert_eq!( - super::planned_execution_program_line(Some(&english), &archive), - None - ); - } - - #[test] - fn setup_summary_includes_manual_instruction_notes() { - let localizer = Localizer::embedded(DEFAULT_LOCALE).unwrap(); - let model = model_from_plan( - &localizer, - Platform::Windows, - Architecture::X64, - Vec::new(), - None, - InstallPlan { - target: None, - actions: Vec::new(), - notes: Vec::new(), - }, - ); - let report = SetupReport { - resource_path: PathBuf::from("C:/PortableREAPER"), - dry_run: true, - resource_init: ResourceInitReport { - resource_path: PathBuf::from("C:/PortableREAPER"), - dry_run: true, - portable: true, - preflight: PreflightReport { - passed: true, - checks: Vec::new(), - }, - actions: Vec::new(), - }, - package_operation: PackageOperationReport { - resource_path: PathBuf::from("C:/PortableREAPER"), - dry_run: true, - install_report: None, - receipt_backup_path: None, - receipt_backup_manifest_path: None, - items: vec![PackageOperationItem { - package_id: PACKAGE_OSARA.to_string(), - plan_action: PlanActionKind::Install, - status: PackageOperationStatus::DeferredUnattended, - artifact: ArtifactDescriptor { - package_id: PACKAGE_OSARA.to_string(), - version: Version::parse("2026.1").unwrap(), - platform: Platform::Windows, - architecture: Architecture::X64, - kind: ArtifactKind::Installer, - url: "https://example.test/osara.exe".to_string(), - file_name: "osara.exe".to_string(), - channel: None, - }, - cached_artifact: None, - install_action: None, - backup_paths: Vec::new(), - backup_manifest_path: None, - planned_execution: Some(PlannedExecutionPlan { - kind: PlannedExecutionKind::LaunchInstallerExecutable, - artifact_location: "https://example.test/osara.exe".to_string(), - program: Some("https://example.test/osara.exe".to_string()), - arguments: Vec::new(), - working_directory: None, - verification_paths: vec![ - PathBuf::from("C:/PortableREAPER/UserPlugins"), - PathBuf::from("C:/PortableREAPER/osara"), - ], - requires_elevation: false, - freshness_paths: Vec::new(), - }), - manual_instruction: Some(ManualInstallInstruction { - title: "Manual install required for osara".to_string(), - steps: vec!["Use this artifact: https://example.test/osara.exe".to_string()], - notes: vec![ - "The selected workflow preserves the current key map. Leave reaper-kb.ini unchanged.".to_string(), - ], - }), - message: "This build has not implemented the planned unattended vendor installer execution path yet. RABBIT did not download or run the artifact.".to_string(), - message_code: rabbit_core::operation::PackageOperationMessage::DeferredUnattendedNotStaged { - artifact_kind: ArtifactKind::Installer, - }, - }], - }, - configuration_steps: Vec::new(), - }; - - let summary = super::summarize_setup_report(&model, &report); - - assert!( - summary - .detail_lines - .iter() - .any(|line| line.contains("Planned unattended execution")) - ); - assert!( - summary.detail_lines.iter().any( - |line| line.contains("Runner:") && line.contains("Launch installer executable") - ) - ); - assert!(summary.detail_lines.iter().any(|line| { - line.contains("Note:") && line.contains("Leave reaper-kb.ini unchanged") - })); - // Architecture line + per-package plan action / status are now part - // of the saved report so power users have everything the wizard hides. - assert!( - summary - .detail_lines - .iter() - .any(|line| line.contains("Architecture:") && line.contains("x64")) - ); - assert!( - summary - .detail_lines - .iter() - .any(|line| line.contains("Plan action:") && line.contains("Will install")) - ); - assert!( - summary - .detail_lines - .iter() - .any(|line| line.contains("Status:") && line.contains("Deferred unattended")) - ); - } - - #[test] - fn setup_summary_includes_backup_paths_when_present() { - let localizer = Localizer::embedded(DEFAULT_LOCALE).unwrap(); - let model = model_from_plan( - &localizer, - Platform::Windows, - Architecture::X64, - Vec::new(), - None, - InstallPlan { - target: None, - actions: Vec::new(), - notes: Vec::new(), - }, - ); - let report = SetupReport { - resource_path: PathBuf::from("C:/PortableREAPER"), - dry_run: false, - resource_init: ResourceInitReport { - resource_path: PathBuf::from("C:/PortableREAPER"), - dry_run: false, - portable: true, - preflight: PreflightReport { - passed: true, - checks: Vec::new(), - }, - actions: Vec::new(), - }, - package_operation: PackageOperationReport { - resource_path: PathBuf::from("C:/PortableREAPER"), - dry_run: false, - install_report: Some(InstallReport { - resource_path: PathBuf::from("C:/PortableREAPER"), - dry_run: false, - preflight: PreflightReport { - passed: true, - checks: Vec::new(), - }, - receipt_written: true, - receipt_backup_path: Some(PathBuf::from( - "C:/PortableREAPER/RABBIT/backups/unix-1/RABBIT/install-state.json", - )), - backup_manifest_path: Some(PathBuf::from( - "C:/PortableREAPER/RABBIT/backups/unix-1/backup-manifest.json", - )), - actions: vec![InstallFileReport { - package_id: PACKAGE_REAPACK.to_string(), - source_path: PathBuf::from("C:/cache/reaper_reapack-x64.dll"), - target_path: PathBuf::from( - "C:/PortableREAPER/UserPlugins/reaper_reapack-x64.dll", - ), - backup_path: Some(PathBuf::from( - "C:/PortableREAPER/RABBIT/backups/unix-1/UserPlugins/reaper_reapack-x64.dll", - )), - action: InstallFileAction::Replaced, - size: 7, - sha256: "hash".to_string(), - }], - }), - receipt_backup_path: None, - receipt_backup_manifest_path: None, - items: vec![PackageOperationItem { - package_id: PACKAGE_REAPACK.to_string(), - plan_action: PlanActionKind::Update, - status: PackageOperationStatus::InstalledOrChecked, - artifact: ArtifactDescriptor { - package_id: PACKAGE_REAPACK.to_string(), - version: Version::parse("1.2.6").unwrap(), - platform: Platform::Windows, - architecture: Architecture::X64, - kind: ArtifactKind::ExtensionBinary, - url: "https://example.test/reaper_reapack-x64.dll".to_string(), - file_name: "reaper_reapack-x64.dll".to_string(), - channel: None, - }, - cached_artifact: None, - install_action: None, - backup_paths: Vec::new(), - backup_manifest_path: None, - planned_execution: None, - manual_instruction: None, - message: "Single extension binary handled by RABBIT installer.".to_string(), - message_code: - rabbit_core::operation::PackageOperationMessage::ExtensionBinaryInstalled, - }], - }, - configuration_steps: Vec::new(), - }; - - let summary = super::summarize_setup_report(&model, &report); - - assert!( - summary - .detail_lines - .iter() - .any(|line| line.contains("Backup file:")) - ); - assert!( - summary - .detail_lines - .iter() - .any(|line| line.contains("Receipt backup:")) - ); - assert!( - summary - .detail_lines - .iter() - .any(|line| line.contains("Backup manifest:")) - ); - } - - #[test] - fn setup_summary_includes_unattended_receipt_backup_paths() { - let localizer = Localizer::embedded(DEFAULT_LOCALE).unwrap(); - let model = model_from_plan( - &localizer, - Platform::Windows, - Architecture::X64, - Vec::new(), - None, - InstallPlan { - target: None, - actions: Vec::new(), - notes: Vec::new(), - }, - ); - let report = SetupReport { - resource_path: PathBuf::from("C:/PortableREAPER"), - dry_run: false, - resource_init: ResourceInitReport { - resource_path: PathBuf::from("C:/PortableREAPER"), - dry_run: false, - portable: true, - preflight: PreflightReport { - passed: true, - checks: Vec::new(), - }, - actions: Vec::new(), - }, - package_operation: PackageOperationReport { - resource_path: PathBuf::from("C:/PortableREAPER"), - dry_run: false, - install_report: None, - receipt_backup_path: Some(PathBuf::from( - "C:/PortableREAPER/RABBIT/backups/unattended-1/RABBIT/install-state.json", - )), - receipt_backup_manifest_path: Some(PathBuf::from( - "C:/PortableREAPER/RABBIT/backups/unattended-1/backup-manifest.json", - )), - items: vec![PackageOperationItem { - package_id: PACKAGE_OSARA.to_string(), - plan_action: PlanActionKind::Install, - status: PackageOperationStatus::InstalledOrChecked, - artifact: ArtifactDescriptor { - package_id: PACKAGE_OSARA.to_string(), - version: Version::parse("2026.1").unwrap(), - platform: Platform::Windows, - architecture: Architecture::X64, - kind: ArtifactKind::Installer, - url: "https://example.test/osara.exe".to_string(), - file_name: "osara.exe".to_string(), - channel: None, - }, - cached_artifact: None, - install_action: None, - backup_paths: Vec::new(), - backup_manifest_path: None, - planned_execution: None, - manual_instruction: None, - message: "RABBIT ran the upstream installer unattended, verified the expected target paths, and updated the RABBIT receipt.".to_string(), - message_code: rabbit_core::operation::PackageOperationMessage::UnattendedInstalled, - }], - }, - configuration_steps: Vec::new(), - }; - - let summary = super::summarize_setup_report(&model, &report); - - assert!( - summary - .detail_lines - .iter() - .any(|line| line.contains("Receipt backup:")) - ); - assert!( - summary - .detail_lines - .iter() - .any(|line| line.contains("Backup manifest:")) - ); - } - - #[test] - fn wizard_error_summary_includes_selected_request_context() { - let localizer = Localizer::embedded(DEFAULT_LOCALE).unwrap(); - let model = model_from_plan( - &localizer, - Platform::Windows, - Architecture::X64, - vec![fake_installation()], - Some(0), - InstallPlan { - target: None, - actions: Vec::new(), - notes: Vec::new(), - }, - ); - let request = sample_install_request(PathBuf::from("C:/PortableREAPER")); - let error = rabbit_core::RabbitError::PreflightFailed { - message: "REAPER is running.".to_string(), - }; - - let summary = super::summarize_wizard_error(&model, &request, &error); - - assert_eq!( - summary.status_line, - "Installation failed. Review the error below." - ); - assert!( - summary - .detail_lines - .iter() - .any(|line| line.contains("Packages selected: OSARA, ReaPack")) - ); - assert!( - summary - .detail_lines - .iter() - .any(|line| line == "OSARA key map") - ); - assert!( - summary - .detail_lines - .iter() - .any(|line| line.contains("Backup your current key map")) - ); - assert!( - summary - .detail_lines - .iter() - .any(|line| line.contains("Error: preflight failed: REAPER is running.")) - ); - } - - /// An antivirus block is the one install failure a user can usually - /// clear themselves, so the summary follows the (English, report-bound) - /// error text with localized remediation steps. Everything else keeps - /// exactly one error line. - /// The wizard's Spanish OSARA-translation dropdown maps each position to - /// a manifest variant id. Every position must record an EXPLICIT choice: - /// the install path falls back to the previously-installed variant when - /// none is given, so picking the first entry has to actively mean - /// "REAPER Accesible español" or a Team PMA user could never switch back. - #[test] - fn spanish_variant_dropdown_maps_each_position_to_a_manifest_variant() { - let specs = rabbit_core::package::package_specs_by_id(Platform::Windows); - let spanish = specs - .get(super::VARIANT_CHOICE_PACKAGE_ID) - .expect("Spanish language pack"); - - // The dropdown offers exactly the variants the manifest declares. - assert_eq!(super::VARIANT_CHOICE_IDS.len(), spanish.variants.len()); - for id in super::VARIANT_CHOICE_IDS { - assert!( - spanish.variants.iter().any(|v| v.id == *id), - "dropdown id {id:?} must exist in the manifest" - ); - } - // Position 0 is the manifest's default, so an untouched dropdown - // agrees with what a CLI run with no flag would install. - assert!( - spanish - .variants - .iter() - .any(|v| v.default && v.id == super::VARIANT_CHOICE_IDS[0]), - "the first entry must be the manifest default" - ); - - for (index, expected) in super::VARIANT_CHOICE_IDS.iter().enumerate() { - let chosen = super::package_variants_from_choice(Some(index as u32)); - assert_eq!( - chosen - .get(super::VARIANT_CHOICE_PACKAGE_ID) - .map(String::as_str), - Some(*expected), - "position {index} should select {expected:?}" - ); - } - // An out-of-range selection falls back to the default rather than - // silently recording no choice. - let fallback = super::package_variants_from_choice(Some(99)); - assert_eq!( - fallback - .get(super::VARIANT_CHOICE_PACKAGE_ID) - .map(String::as_str), - Some(super::VARIANT_CHOICE_IDS[0]) - ); - } - - #[test] - fn wizard_error_summary_adds_localized_hint_for_antivirus_blocks() { - let localizer = Localizer::embedded(DEFAULT_LOCALE).unwrap(); - let model = model_from_plan( - &localizer, - Platform::Windows, - Architecture::X64, - vec![fake_installation()], - Some(0), - InstallPlan { - target: None, - actions: Vec::new(), - notes: Vec::new(), - }, - ); - let request = sample_install_request(PathBuf::from("C:/PortableREAPER")); - - let blocked = rabbit_core::RabbitError::WindowsFileBlockedByAntivirus { - path: PathBuf::from("C:/Temp/rabbit-cache/osara/osara.exe"), - source: std::io::Error::from_raw_os_error(225), - }; - let summary = super::summarize_wizard_error(&model, &request, &blocked); - assert!( - summary - .detail_lines - .iter() - .any(|line| line.contains("Protection history")), - "antivirus blocks must carry the remediation steps: {:?}", - summary.detail_lines - ); - - // Unrelated failures must not gain the antivirus advice. - let other = rabbit_core::RabbitError::PreflightFailed { - message: "REAPER is running.".to_string(), - }; - let summary = super::summarize_wizard_error(&model, &request, &other); - assert!( - !summary - .detail_lines - .iter() - .any(|line| line.contains("Protection history")), - "only antivirus blocks get the antivirus advice: {:?}", - summary.detail_lines - ); - } - - #[test] - fn saves_wizard_outcome_error_report_under_resource_logs() { - let dir = tempdir().unwrap(); - let localizer = Localizer::embedded(DEFAULT_LOCALE).unwrap(); - let model = model_from_plan( - &localizer, - Platform::Windows, - Architecture::X64, - vec![fake_installation()], - Some(0), - InstallPlan { - target: None, - actions: Vec::new(), - notes: Vec::new(), - }, - ); - let request = sample_install_request(dir.path().join("PortableREAPER")); - let error = rabbit_core::RabbitError::PreflightFailed { - message: "Target path blocked".to_string(), - }; - let report = super::wizard_outcome_report_from_error(&model, &request, &error); - - let path = super::save_wizard_outcome_report(&report).unwrap(); - let json_path = path.with_extension("json"); - - assert!(path.starts_with(dir.path().join("PortableREAPER/RABBIT/logs"))); - assert!(path.is_file()); - assert!(json_path.is_file()); - let content = std::fs::read_to_string(path).unwrap(); - assert!(content.contains("status: error")); - assert!(content.contains("error_message: preflight failed: Target path blocked")); - } - - #[test] - fn saves_wizard_setup_report_under_resource_logs() { - let dir = tempdir().unwrap(); - let report = empty_setup_report(dir.path().join("PortableREAPER")); - - let path = super::save_wizard_setup_report(&report).unwrap(); - let json_path = path.with_extension("json"); - - assert!(path.starts_with(dir.path().join("PortableREAPER/RABBIT/logs"))); - assert!(path.is_file()); - assert!(json_path.is_file()); - let content = std::fs::read_to_string(path).unwrap(); - assert!(content.contains("RABBIT Report")); - assert!(content.contains("resource_path:")); - } - - #[test] - fn apply_summary_appends_signed_count_when_signatures_were_verified() { - use rabbit_core::self_update::{ReplacedFile, SignatureVerdictRecord}; - use rabbit_core::signature::SignatureVerdict; - - let report = sample_apply_report( - vec![ReplacedFile { - install_path: PathBuf::from("/install/RABBIT"), - backup_path: PathBuf::from("/install/RABBIT.rabbit-old"), - }], - vec![SignatureVerdictRecord { - source_path: PathBuf::from("/staging/RABBIT"), - verdict: SignatureVerdict::Signed { - details: "valid on disk".to_string(), - }, - }], - ); - - let localizer = Localizer::embedded(DEFAULT_LOCALE).unwrap(); - let summary = format_self_update_apply_summary(&localizer, &report); - assert!(summary.contains("Replaced 1 file(s)")); - assert!(summary.contains("Signature verification: 1 signed.")); - } - - #[test] - fn apply_summary_omits_signature_clause_when_no_verdicts_recorded() { - use rabbit_core::self_update::ReplacedFile; - - let report = sample_apply_report( - vec![ReplacedFile { - install_path: PathBuf::from("/install/RABBIT"), - backup_path: PathBuf::from("/install/RABBIT.rabbit-old"), - }], - Vec::new(), - ); - - let localizer = Localizer::embedded(DEFAULT_LOCALE).unwrap(); - let summary = format_self_update_apply_summary(&localizer, &report); - assert!(summary.contains("Replaced 1 file(s)")); - assert!(!summary.contains("Signature verification")); - } - - #[test] - fn apply_summary_reports_signed_and_unsigned_split() { - use rabbit_core::self_update::{ReplacedFile, SignatureVerdictRecord}; - use rabbit_core::signature::SignatureVerdict; - - let report = sample_apply_report( - vec![ - ReplacedFile { - install_path: PathBuf::from("/install/RABBIT"), - backup_path: PathBuf::from("/install/RABBIT.rabbit-old"), - }, - ReplacedFile { - install_path: PathBuf::from("/install/rabbit-cli"), - backup_path: PathBuf::from("/install/rabbit-cli.rabbit-old"), - }, - ], - vec![ - SignatureVerdictRecord { - source_path: PathBuf::from("/staging/RABBIT"), - verdict: SignatureVerdict::Signed { - details: "ok".to_string(), - }, - }, - SignatureVerdictRecord { - source_path: PathBuf::from("/staging/rabbit-cli"), - verdict: SignatureVerdict::Unsigned { - reason: "no signtool".to_string(), - }, - }, - ], - ); - - let localizer = Localizer::embedded(DEFAULT_LOCALE).unwrap(); - let summary = format_self_update_apply_summary(&localizer, &report); - assert!(summary.contains("Signature verification: 1 signed, 1 unsigned.")); - } - - fn sample_apply_report( - replaced_files: Vec, - signature_verdicts: Vec, - ) -> rabbit_core::self_update::SelfUpdateApplyReport { - use rabbit_core::model::Platform; - use rabbit_core::self_update::{ - SelfUpdateApplyReport, SelfUpdateAssetSelection, SelfUpdateCheckReport, - SelfUpdateStageReport, - }; - - let check = SelfUpdateCheckReport { - manifest_url: "https://example.test/rabbit-update-stable.json".to_string(), - current_version: Version::parse("0.1.0").unwrap(), - latest_version: Version::parse("0.2.0").unwrap(), - channel: "stable".to_string(), - published_at: "2026-04-25T00:00:00Z".to_string(), - release_notes_url: None, - minimum_supported_previous_version: None, - update_available: true, - requires_manual_transition: false, - asset: SelfUpdateAssetSelection { - platform: Platform::Windows, - url: "https://example.test/RABBIT-windows.zip".to_string(), - sha256: "0".repeat(64), - kind: rabbit_core::self_update::SelfUpdateAssetKind::Binary, - }, - }; - let stage = SelfUpdateStageReport { - check, - staging_dir: PathBuf::from("/staging"), - staged_asset_path: Some(PathBuf::from("/staging/0.2.0/RABBIT-windows.zip")), - downloaded: true, - reused_existing_file: false, - verified_sha256: Some("0".repeat(64)), - ready_to_apply: true, - status_message: "ready".to_string(), - }; - SelfUpdateApplyReport { - stage, - install_root: PathBuf::from("/install"), - replaced_files, - skipped_files: Vec::new(), - signature_verdicts, - status_message: "applied".to_string(), - } - } - - fn fake_installation() -> Installation { - Installation { - kind: InstallationKind::Portable, - platform: Platform::Windows, - app_path: PathBuf::from("C:/REAPER/reaper.exe"), - resource_path: PathBuf::from("C:/REAPER"), - version: Some(Version::parse("7.69").unwrap()), - architecture: Some(Architecture::X64), - writable: true, - confidence: Confidence::High, - evidence: Vec::new(), - } - } - - fn fake_standard_installation() -> Installation { - Installation { - kind: InstallationKind::Standard, - platform: Platform::Windows, - app_path: PathBuf::from("C:/Program Files/REAPER/reaper.exe"), - resource_path: PathBuf::from("C:/Users/Test/AppData/Roaming/REAPER"), - version: Some(Version::parse("7.69").unwrap()), - architecture: Some(Architecture::X64), - writable: true, - confidence: Confidence::High, - evidence: Vec::new(), - } - } - - /// A run with a failed package renders the "finished with errors" - /// status line, and an antivirus block adds the localized how-to-allow - /// guidance once for the whole run. - #[test] - fn setup_summary_reports_failures_and_antivirus_hint() { - let localizer = Localizer::embedded(DEFAULT_LOCALE).unwrap(); - let model = model_from_plan( - &localizer, - Platform::Windows, - Architecture::X64, - vec![fake_installation()], - Some(0), - InstallPlan { - target: None, - actions: Vec::new(), - notes: Vec::new(), - }, - ); - let mut report = empty_setup_report(PathBuf::from("C:/PortableREAPER")); - report.package_operation.items.push(PackageOperationItem { - package_id: PACKAGE_OSARA.to_string(), - plan_action: PlanActionKind::Install, - status: PackageOperationStatus::Failed, - artifact: ArtifactDescriptor { - package_id: PACKAGE_OSARA.to_string(), - version: Version::parse("2026.1").unwrap(), - platform: Platform::Windows, - architecture: Architecture::X64, - kind: ArtifactKind::Installer, - url: "https://example.test/osara.exe".to_string(), - file_name: "osara.exe".to_string(), - channel: None, - }, - cached_artifact: None, - install_action: None, - backup_paths: Vec::new(), - backup_manifest_path: None, - planned_execution: None, - manual_instruction: None, - message: "Installation failed: Windows security software blocked …".to_string(), - message_code: rabbit_core::operation::PackageOperationMessage::InstallFailed { - error: "Windows security software blocked …".to_string(), - antivirus_block: true, - }, - }); - - let summary = super::summarize_setup_report(&model, &report); - - assert!( - summary.status_line.contains("Finished with errors"), - "status line: {}", - summary.status_line - ); - assert!( - summary - .detail_lines - .iter() - .any(|line| line.contains("Protection history")), - "antivirus hint missing: {:?}", - summary.detail_lines - ); - } - - fn empty_setup_report(resource_path: PathBuf) -> SetupReport { - SetupReport { - resource_path: resource_path.clone(), - dry_run: true, - resource_init: ResourceInitReport { - resource_path: resource_path.clone(), - dry_run: true, - portable: true, - preflight: PreflightReport { - passed: true, - checks: Vec::new(), - }, - actions: Vec::new(), - }, - package_operation: PackageOperationReport { - resource_path, - dry_run: true, - install_report: None, - receipt_backup_path: None, - receipt_backup_manifest_path: None, - items: Vec::new(), - }, - configuration_steps: Vec::new(), - } - } - - fn sample_install_request(resource_path: PathBuf) -> WizardInstallRequest { - WizardInstallRequest { - resource_path: resource_path.clone(), - package_ids: vec![PACKAGE_OSARA.to_string(), PACKAGE_REAPACK.to_string()], - platform: Platform::Windows, - architecture: Architecture::X64, - portable: true, - target_app_path: Some(resource_path.join("reaper.exe")), - dry_run: false, - allow_reaper_running: false, - stage_unsupported: true, - osara_keymap_choice: OsaraKeymapChoice::ReplaceCurrent, - cache_dir: PathBuf::from("C:/cache"), - force_reinstall_packages: Vec::new(), - package_variants: Default::default(), - reaper_language_package: None, - configuration_step_ids: Vec::new(), - declined_packages: Vec::new(), - accepted_packages: Vec::new(), - package_channels: Default::default(), - } - } -} diff --git a/crates/rabbit-ui-wxdragon/src/wizard.rs b/crates/rabbit-ui-wxdragon/src/wizard.rs new file mode 100644 index 0000000..77f15a7 --- /dev/null +++ b/crates/rabbit-ui-wxdragon/src/wizard.rs @@ -0,0 +1,34 @@ +//! The wizard's data layer: everything the pages show, decide, and report, +//! with no wxWidgets dependency. `wx_app` renders these types; the tests +//! drive them directly. + +mod bootstrap; +mod configuration; +mod expert_mode; +mod install; +mod labels; +mod model; +mod outcome; +mod packages; +mod request; +mod review; +mod self_update; +mod summary; +mod target; +mod text; + +#[cfg(test)] +mod tests; + +pub use bootstrap::*; +pub use configuration::*; +pub use expert_mode::*; +pub use install::*; +pub use model::*; +pub use outcome::*; +pub use packages::*; +pub use request::*; +pub use review::*; +pub use self_update::*; +pub use summary::*; +pub use target::*; diff --git a/crates/rabbit-ui-wxdragon/src/wizard/bootstrap.rs b/crates/rabbit-ui-wxdragon/src/wizard/bootstrap.rs new file mode 100644 index 0000000..37dc31b --- /dev/null +++ b/crates/rabbit-ui-wxdragon/src/wizard/bootstrap.rs @@ -0,0 +1,280 @@ +//! Startup path: detect installations, build the install plan, and turn it +//! into the first [`WizardModel`] the pages render. + +use rabbit_core::detection::{ + DiscoveryOptions, colocated_portable_root, default_standard_installation, detect_components, + discover_installations, +}; +use rabbit_core::latest::fetch_latest_versions; +use rabbit_core::localization::{Localizer, embedded_locales}; +use rabbit_core::model::{Architecture, Installation, InstallationKind, Platform}; +use rabbit_core::package::{builtin_package_specs, detect_host_capabilities}; +use rabbit_core::plan::{AvailablePackage, InstallPlan, PlanActionKind, build_install_plan}; +use rabbit_core::{RabbitError, Result}; + +use super::configuration::configuration_rows; +use super::model::{LanguageOption, UiBootstrapOptions, WizardControls, WizardModel, WizardStep}; +use super::packages::{ + apply_version_check_failures_to_rows, package_rows, wizard_desired_package_ids, +}; +use super::review::review_lines; +use super::target::target_rows; +use super::text::{localized_wx_mnemonic_label, wizard_steps, wizard_text}; + +pub fn load_wizard_model(options: UiBootstrapOptions) -> Result { + let platform = Platform::current().ok_or(RabbitError::UnsupportedPlatform)?; + let localizer = localizer_from_options(&options)?; + // If RABBIT was dropped next to a portable REAPER, fold that folder into + // the portable roots so it's discovered as a target the user can update + // without reaching for the Browse button. + let colocated_portable = colocated_portable_root(); + let mut portable_roots = options.portable_roots.clone(); + if let Some(root) = &colocated_portable + && !portable_roots.contains(root) + { + portable_roots.push(root.clone()); + } + let discovered_installations = discover_installations(&DiscoveryOptions { + include_standard: true, + portable_roots, + })?; + let installations = selectable_installations(platform, discovered_installations); + // Prefer the co-located portable install as the default selection so the + // "drop RABBIT next to a portable REAPER" flow lands on it automatically; + // otherwise fall back to the first writable target. + let selected_target_index = colocated_portable + .as_ref() + .and_then(|root| { + installations.iter().position(|installation| { + installation.kind == InstallationKind::Portable + && &installation.resource_path == root + }) + }) + .or_else(|| { + installations + .iter() + .position(|installation| installation.writable) + }); + let target = selected_target_index.and_then(|index| installations.get(index).cloned()); + // Default the model's architecture to the initially-selected target's + // probed binary arch — that's what the artifact resolver actually + // wants. Falls back to the host arch when no writable target was + // discovered (the user will pick one manually before any artifact + // work runs anyway). + let architecture = target + .as_ref() + .and_then(|installation| installation.architecture) + .unwrap_or_else(Architecture::current); + let detections = match target.as_ref() { + Some(target) => detect_components(&target.resource_path, platform)?, + None => Vec::new(), + }; + // Per-provider failures don't abort the model load: the packages whose + // upstream answered keep their update flow, and the failed ones get + // their rows disabled below with the failure recorded as a note. + let (available, version_check_failures) = if options.online_versions { + let report = fetch_latest_versions()?; + (report.packages, report.failures) + } else { + (Vec::new(), Vec::new()) + }; + let desired = wizard_desired_package_ids(platform); + let plan = build_install_plan(target, &detections, &desired, &available); + + let mut model = model_from_plan_with_options( + &localizer, + options, + PlanModelInputs { + platform, + architecture, + installations, + selected_target_index, + available_packages: available, + plan, + }, + ); + if !version_check_failures.is_empty() { + let failures: Vec<(String, String)> = version_check_failures + .into_iter() + .map(|failure| (failure.package_id, failure.message)) + .collect(); + model.controls.can_install = apply_version_check_failures_to_rows( + &localizer, + &mut model.package_rows, + &mut model.notes, + &failures, + ); + } + Ok(model) +} + +pub(crate) fn selectable_installations( + platform: Platform, + mut installations: Vec, +) -> Vec { + if !installations + .iter() + .any(|installation| installation.kind == InstallationKind::Standard) + && let Some(standard) = default_standard_installation(platform) + { + installations.push(standard); + } + installations +} + +pub fn localizer_from_options(options: &UiBootstrapOptions) -> Result { + match &options.locales_dir { + Some(locales_dir) => Localizer::from_locale_dir(locales_dir, &options.locale), + None => Localizer::embedded(&options.locale), + } +} + +pub fn model_from_plan( + localizer: &Localizer, + platform: Platform, + architecture: Architecture, + installations: Vec, + selected_target_index: Option, + plan: InstallPlan, +) -> WizardModel { + model_from_plan_with_options( + localizer, + UiBootstrapOptions::default(), + PlanModelInputs { + platform, + architecture, + installations, + selected_target_index, + available_packages: Vec::new(), + plan, + }, + ) +} + +/// The per-run inputs to [`model_from_plan_with_options`], grouped so the +/// call doesn't carry a long positional argument list. +struct PlanModelInputs { + platform: Platform, + architecture: Architecture, + installations: Vec, + selected_target_index: Option, + available_packages: Vec, + plan: InstallPlan, +} + +fn model_from_plan_with_options( + localizer: &Localizer, + bootstrap_options: UiBootstrapOptions, + inputs: PlanModelInputs, +) -> WizardModel { + let PlanModelInputs { + platform, + architecture, + installations, + selected_target_index, + available_packages, + plan, + } = inputs; + let package_specs = builtin_package_specs(platform); + let text = wizard_text(localizer); + let target_rows = target_rows(localizer, &installations, selected_target_index); + let host = detect_host_capabilities(); + let target_resource_path = selected_target_index + .and_then(|idx| target_rows.get(idx)) + .map(|row| row.path.clone()); + let declined = target_resource_path + .as_deref() + .map(rabbit_core::receipt::declined_packages) + .unwrap_or_default(); + let installed_channels = target_resource_path + .as_deref() + .map(rabbit_core::receipt::installed_channels_at) + .unwrap_or_default(); + let package_rows = package_rows( + localizer, + &text, + platform, + architecture, + &package_specs, + &plan.actions, + &available_packages, + &host, + &declined, + &installed_channels, + ); + let configuration_rows = + configuration_rows(localizer, &package_rows, target_resource_path.as_deref()); + let review_lines = review_lines(localizer, &target_rows, &package_rows, &plan.notes); + let can_install = package_rows + .iter() + .any(|row| matches!(row.action, PlanActionKind::Install | PlanActionKind::Update)); + + WizardModel { + window_title: format!( + "{} v{}", + localizer.text("app-title").value, + env!("CARGO_PKG_VERSION") + ), + platform, + architecture, + bootstrap_options, + current_step: WizardStep::Target, + steps: wizard_steps(localizer), + selected_target_index, + target_rows, + package_rows, + configuration_rows, + available_packages, + review_lines, + notes: plan.notes, + text, + controls: WizardControls { + back_label: localized_wx_mnemonic_label( + localizer, + "wizard-button-back", + "wizard-button-back-mnemonic", + ), + next_label: localized_wx_mnemonic_label( + localizer, + "wizard-button-next", + "wizard-button-next-mnemonic", + ), + install_label: localized_wx_mnemonic_label( + localizer, + "wizard-button-install", + "wizard-button-install-mnemonic", + ), + close_label: localized_wx_mnemonic_label( + localizer, + "wizard-button-close", + "wizard-button-close-mnemonic", + ), + can_go_back: false, + can_go_next: selected_target_index.is_some(), + can_install, + }, + language_options: language_options(localizer), + current_language: localizer.active_locale().to_string(), + } +} + +pub(crate) fn language_options(localizer: &Localizer) -> Vec { + let mut options: Vec = embedded_locales() + .iter() + .map(|locale| { + let key = format!("wizard-locale-name-{locale}"); + let text = localizer.text(&key); + let display_name = if text.missing { + (*locale).to_string() + } else { + text.value + }; + LanguageOption { + locale: (*locale).to_string(), + display_name, + } + }) + .collect(); + options.sort_by(|a, b| a.display_name.cmp(&b.display_name)); + options +} diff --git a/crates/rabbit-ui-wxdragon/src/wizard/configuration.rs b/crates/rabbit-ui-wxdragon/src/wizard/configuration.rs new file mode 100644 index 0000000..f5a2938 --- /dev/null +++ b/crates/rabbit-ui-wxdragon/src/wizard/configuration.rs @@ -0,0 +1,319 @@ +//! Configuration page rows: the optional post-install steps and the +//! package dependencies that decide whether each one is available. + +use std::collections::BTreeMap; +use std::path::Path; + +use rabbit_core::localization::Localizer; + +use super::model::{ConfigurationRow, PackageRow}; +use super::packages::{package_row_installs_now, package_row_will_land_on_disk}; + +/// Build [`ConfigurationRow`]s from `rabbit-core`'s builtin step +/// catalogue, gating each row on whether its dependency package is +/// either already installed (action `Keep`) or queued for install / +/// update in the current package plan. Re-checked whenever the +/// package plan is rebuilt (target switch, post-install rescan, etc.). +pub fn configuration_rows( + localizer: &Localizer, + package_rows: &[PackageRow], + target_resource_path: Option<&Path>, +) -> Vec { + let installed_or_pending: BTreeMap<&str, bool> = package_rows + .iter() + .map(|row| (row.package_id.as_str(), package_row_will_land_on_disk(row))) + .collect(); + let installing_now: BTreeMap<&str, bool> = package_rows + .iter() + .map(|row| (row.package_id.as_str(), package_row_installs_now(row))) + .collect(); + + rabbit_core::configuration::builtin_configuration_steps() + .into_iter() + .map(|step| { + let display_name = localizer.text(&step.display_name_key).value; + let description = { + let text = localizer.text(&step.display_description_key); + if text.missing { + String::new() + } else { + text.value + } + }; + let dependency_satisfied = + configuration_dependency_met(&step, &installed_or_pending, &installing_now); + let already_applied = target_resource_path + .and_then(|path| { + rabbit_core::configuration::is_configuration_step_applied( + path, + &step, + &Default::default(), + ) + .ok() + }) + .unwrap_or(false); + + let unavailability_reason = build_configuration_unavailability_reason( + localizer, + &step, + dependency_satisfied, + already_applied, + ); + + let summary = configuration_row_summary( + localizer, + &step, + &display_name, + dependency_satisfied, + already_applied, + ); + let details = if description.is_empty() { + summary.clone() + } else { + format!("{summary}\n\n{description}") + }; + + ConfigurationRow { + step_id: step.id.clone(), + display_name, + description, + selected: dependency_satisfied && !already_applied && step.recommended, + summary, + details, + available_for_target: dependency_satisfied, + already_applied, + unavailability_reason, + } + }) + .collect() +} + +/// Build the tree-row label for a configuration step: the localized +/// display name on its own when the row is actionable, otherwise the +/// display name plus a short parenthesised status tag (`(requires +/// ReaPack)`, `(already applied)`) so the indicator is visible without +/// the user having to focus the row to read its details. +/// How to refer to a configuration step's dependency in a message. +/// +/// A step satisfied by any one of several packages names the group ("a +/// language pack") rather than whichever package sorts first, which is how +/// "Set REAPER's language" came to advertise itself as requiring Spanish. +/// Steps with a single dependency keep naming it directly. +fn configuration_dependency_label( + localizer: &Localizer, + step: &rabbit_core::configuration::ConfigurationStep, +) -> String { + if let Some(key) = step.dependency_name_key.as_deref() { + let text = localizer.text(key); + if !text.missing { + return text.value; + } + } + let dep_id = step.requires_packages.first().cloned().unwrap_or_default(); + let dep_name = localizer.text(&format!("package-{dep_id}")); + if dep_name.missing { + dep_id + } else { + dep_name.value + } +} + +pub(crate) fn configuration_row_summary( + localizer: &Localizer, + step: &rabbit_core::configuration::ConfigurationStep, + display_name: &str, + dependency_satisfied: bool, + already_applied: bool, +) -> String { + let status = if !dependency_satisfied { + let dep_label = configuration_dependency_label(localizer, step); + Some( + localizer + .format( + "wizard-configuration-row-status-requires", + &[("package", dep_label.as_str())], + ) + .value, + ) + } else if already_applied { + Some( + localizer + .text("wizard-configuration-row-status-already-applied") + .value, + ) + } else { + None + }; + match status { + Some(reason) => { + let suffix = localizer + .format( + "wizard-configuration-row-summary-suffix", + &[("reason", reason.as_str())], + ) + .value; + format!("{display_name} {suffix}") + } + None => display_name.to_string(), + } +} + +/// Build the localized "(unavailable: …)" / "(already configured)" +/// sentence shown on a configuration row that isn't actionable. +/// `dependency_satisfied == false` takes precedence over +/// `already_applied`: if the dep is missing and the row is also already +/// applied, we surface the dep error so the user knows the row is +/// gated rather than complete. +pub(crate) fn build_configuration_unavailability_reason( + localizer: &Localizer, + step: &rabbit_core::configuration::ConfigurationStep, + dependency_satisfied: bool, + already_applied: bool, +) -> Option { + if !dependency_satisfied { + let dep_label = configuration_dependency_label(localizer, step); + return Some( + localizer + .format( + "wizard-configuration-row-unavailable", + &[("package", dep_label.as_str())], + ) + .value, + ); + } + if already_applied { + return Some( + localizer + .text("wizard-configuration-row-already-applied") + .value, + ); + } + None +} + +/// Re-evaluate each [`ConfigurationRow`]'s `available_for_target` / +/// `selected` / `unavailability_reason` against the current package +/// rows. Called by the wizard whenever the user toggles a package row +/// — e.g. unticking ReaPack should immediately disable the +/// "configure REAPER Accessibility ReaPack remote" row, and re-ticking +/// it should re-enable + re-recommend the row. +/// +/// Selection is preserved across the recompute *unless* the row goes +/// from available → unavailable, in which case it's force-unticked +/// (we never want to ship the install with a configuration step +/// queued whose dependency isn't available). +/// ANY-of dependency check for a configuration step: satisfied when at least +/// one required package will be on disk, or when the step requires none. +/// "Set REAPER's language" lists every language pack, so it lights up +/// whichever one the user picked. +fn configuration_dependency_satisfied( + step: &rabbit_core::configuration::ConfigurationStep, + mut is_present: impl FnMut(&str) -> bool, +) -> bool { + step.requires_packages.is_empty() + || step + .requires_packages + .iter() + .any(|pkg| is_present(pkg.as_str())) +} + +/// Is this step's dependency satisfied? +/// +/// Steps flagged `requires_fresh_dependency` count only packages being +/// installed or updated in this run; every other step also counts packages +/// that are already on disk. +fn configuration_dependency_met( + step: &rabbit_core::configuration::ConfigurationStep, + landing: &BTreeMap<&str, bool>, + installing: &BTreeMap<&str, bool>, +) -> bool { + let source = if step.requires_fresh_dependency { + installing + } else { + landing + }; + configuration_dependency_satisfied(step, |pkg| source.get(pkg).copied().unwrap_or(false)) +} + +pub fn recompute_configuration_row_availability( + localizer: &Localizer, + package_rows: &[PackageRow], + target_resource_path: Option<&Path>, + configuration_rows: &mut [ConfigurationRow], +) { + let installed_or_pending: BTreeMap<&str, bool> = package_rows + .iter() + .map(|row| (row.package_id.as_str(), package_row_will_land_on_disk(row))) + .collect(); + let installing_now: BTreeMap<&str, bool> = package_rows + .iter() + .map(|row| (row.package_id.as_str(), package_row_installs_now(row))) + .collect(); + let steps = rabbit_core::configuration::builtin_configuration_steps(); + for row in configuration_rows.iter_mut() { + let Some(step) = steps.iter().find(|step| step.id == row.step_id) else { + continue; + }; + let dependency_satisfied = + configuration_dependency_met(step, &installed_or_pending, &installing_now); + let already_applied = target_resource_path + .and_then(|path| { + rabbit_core::configuration::is_configuration_step_applied( + path, + step, + &Default::default(), + ) + .ok() + }) + .unwrap_or(row.already_applied); + let was_actionable = row.available_for_target && !row.already_applied; + row.available_for_target = dependency_satisfied; + row.already_applied = already_applied; + row.unavailability_reason = build_configuration_unavailability_reason( + localizer, + step, + dependency_satisfied, + already_applied, + ); + // Refresh the row's tree-label so the inline status tag matches + // the new state (e.g. unticking ReaPack while the row is + // visible adds "(requires ReaPack)"; re-ticking it removes the + // tag). + row.summary = configuration_row_summary( + localizer, + step, + &row.display_name, + dependency_satisfied, + already_applied, + ); + row.details = if row.description.is_empty() { + row.summary.clone() + } else { + format!("{}\n\n{}", row.summary, row.description) + }; + let actionable_now = dependency_satisfied && !already_applied; + if !actionable_now { + // Nothing to act on, so nothing ticked. Unticking the last + // language pack clears "Set REAPER's language" through this + // branch, rather than leaving a ticked step pointing at no pack. + row.selected = false; + } else if !was_actionable { + // Just became actionable — e.g. a language pack was ticked. + // Restore the recommended default so the user doesn't have to + // hunt for the step; unticking it afterwards survives, because + // by then the row was already actionable. + row.selected = step.recommended; + } + } +} + +/// Return the step ids of configuration rows that are both actionable +/// (available + not already applied) and currently selected. Mirrors +/// `package_ids_for_rows` but for configuration rows. +pub fn selected_configuration_step_ids(configuration_rows: &[ConfigurationRow]) -> Vec { + configuration_rows + .iter() + .filter(|row| row.available_for_target && !row.already_applied && row.selected) + .map(|row| row.step_id.clone()) + .collect() +} diff --git a/crates/rabbit-ui-wxdragon/src/wizard/expert_mode.rs b/crates/rabbit-ui-wxdragon/src/wizard/expert_mode.rs new file mode 100644 index 0000000..18abbf6 --- /dev/null +++ b/crates/rabbit-ui-wxdragon/src/wizard/expert_mode.rs @@ -0,0 +1,42 @@ +//! Expert mode's wizard side: the environment switch that turns it on at +//! launch, and the channels a run installs from. + +use rabbit_core::package::PACKAGE_OSARA; + +/// Environment variable that turns the wizard's expert mode on at launch. +/// Expert mode is never saved - RABBIT keeps no settings file - so this is +/// how someone who always wants it gets it. +pub const EXPERT_MODE_ENV: &str = "RABBIT_EXPERT"; + +/// Whether `RABBIT_EXPERT` asks for expert mode (`1`, `true`, `yes`, `on`). +pub fn expert_mode_requested_by_env() -> bool { + std::env::var(EXPERT_MODE_ENV).is_ok_and(|value| expert_mode_value_enabled(&value)) +} + +pub(crate) fn expert_mode_value_enabled(value: &str) -> bool { + matches!( + value.trim().to_ascii_lowercase().as_str(), + "1" | "true" | "yes" | "on" + ) +} + +/// The channels a wizard run installs from. Outside expert mode that is no +/// channel at all, which is what takes any package that came from a +/// pre-release back to its regular release. In expert mode it is whatever +/// the build choices say. +pub fn wizard_channels( + expert_mode: bool, + reaper: Option, + osara: Option, +) -> rabbit_core::package::PackageChannels { + let mut channels = rabbit_core::package::PackageChannels::new(); + if expert_mode { + if let Some(channel) = reaper { + channels.insert(rabbit_core::package::PACKAGE_REAPER.to_string(), channel); + } + if let Some(channel) = osara { + channels.insert(PACKAGE_OSARA.to_string(), channel); + } + } + channels +} diff --git a/crates/rabbit-ui-wxdragon/src/wizard/install.rs b/crates/rabbit-ui-wxdragon/src/wizard/install.rs new file mode 100644 index 0000000..7b3043f --- /dev/null +++ b/crates/rabbit-ui-wxdragon/src/wizard/install.rs @@ -0,0 +1,99 @@ +//! Runs the install the wizard assembled and reports progress back. + +use rabbit_core::Result; +use rabbit_core::cancel::CancelToken; +use rabbit_core::setup::{SetupOptions, SetupReport}; + +use super::model::{OsaraKeymapChoice, WizardInstallRequest}; + +pub fn execute_wizard_install(request: WizardInstallRequest) -> Result { + execute_wizard_install_with_progress( + request, + &rabbit_core::progress::ProgressReporter::noop(), + &CancelToken::new(), + ) +} + +/// Like [`execute_wizard_install`] but threads a [`ProgressReporter`] +/// through to the core setup pipeline so the wizard's progress page can +/// render a live status bar. The plain [`execute_wizard_install`] +/// delegates here with a [`ProgressReporter::noop`]. +/// +/// `cancel` travels the other way: the wizard flips it when the user asks +/// to stop, and the pipeline reads it at each package boundary. +/// +/// [`ProgressReporter`]: rabbit_core::progress::ProgressReporter +/// [`ProgressReporter::noop`]: rabbit_core::progress::ProgressReporter::noop +pub fn execute_wizard_install_with_progress( + request: WizardInstallRequest, + progress: &rabbit_core::progress::ProgressReporter, + cancel: &CancelToken, +) -> Result { + // Before any download, make Windows Defender ignore RABBIT's own cache + // folder so a freshly built, low-prevalence (but signed) installer — + // OSARA's snapshots especially — isn't quarantined as a false positive + // mid-download. Interactive GUI path only (the CLI stays non-interactive + // for unattended/CI use and never touches Defender); skipped on dry runs + // and off Windows. Best-effort: whatever the outcome, the install + // proceeds — a real block still surfaces the actionable guidance. + if !request.dry_run { + let outcome = + rabbit_core::antivirus::ensure_cache_excluded_from_antivirus(&request.cache_dir); + if !matches!( + outcome, + rabbit_core::antivirus::DefenderExclusionOutcome::Unsupported + ) { + eprintln!("antivirus cache exclusion: {outcome:?}"); + } + } + let report = rabbit_core::setup::execute_setup_operation_with_progress( + &request.resource_path, + &request.package_ids, + request.platform, + request.architecture, + &request.cache_dir, + &SetupOptions { + dry_run: request.dry_run, + portable: request.portable, + allow_reaper_running: request.allow_reaper_running, + stage_unsupported: request.stage_unsupported, + replace_osara_keymap: matches!( + request.osara_keymap_choice, + OsaraKeymapChoice::ReplaceCurrent + ), + target_app_path: request.target_app_path.clone(), + lock_path: None, + force_reinstall_packages: request.force_reinstall_packages.clone(), + package_variants: request.package_variants.clone(), + package_channels: request.package_channels.clone(), + reaper_language_package: request.reaper_language_package.clone(), + configuration_step_ids: request.configuration_step_ids.clone(), + }, + progress, + cancel, + )?; + + // Remember what the user decided about the packages that remember it, + // so a default RABBIT picked for them — a language pack matching + // RABBIT's own language — does not come back ticked on the next launch. + // The verdict is worked out where the rows are (see + // `install_request_from_target_and_rows`), because only there can a + // deliberate "no" be told apart from a row that had nothing to offer. + // The CLI records nothing: `--package` is a scope for one run, not a + // standing preference. + let has_verdict = + !(request.declined_packages.is_empty() && request.accepted_packages.is_empty()); + if !request.dry_run && has_verdict { + let recorded = rabbit_core::receipt::record_package_opt_outs( + &request.resource_path, + &request.declined_packages, + &request.accepted_packages, + ); + // A preference is never worth failing a finished install over. + if let Err(error) = recorded { + eprintln!("could not record package opt-outs: {error}"); + } + } + + Ok(report) +} diff --git a/crates/rabbit-ui-wxdragon/src/wizard/labels.rs b/crates/rabbit-ui-wxdragon/src/wizard/labels.rs new file mode 100644 index 0000000..e0753f8 --- /dev/null +++ b/crates/rabbit-ui-wxdragon/src/wizard/labels.rs @@ -0,0 +1,363 @@ +//! Localized labels shared by the row builders and the run summary. + +use rabbit_core::localization::Localizer; +use rabbit_core::model::Architecture; +use rabbit_core::operation::{PackageOperationStatus, PlannedExecutionKind}; +use rabbit_core::plan::PlanActionKind; + +use super::bootstrap::localizer_from_options; +use super::model::WizardModel; + +pub(crate) fn format_localized_message( + localizer: Option<&Localizer>, + id: &str, + args: &[(&str, String)], + fallback: String, +) -> String { + let Some(localizer) = localizer else { + return fallback; + }; + let borrowed_args = args + .iter() + .map(|(name, value)| (*name, value.as_str())) + .collect::>(); + localizer.format(id, &borrowed_args).value +} + +pub(crate) fn planned_execution_runner_label( + localizer: Option<&Localizer>, + kind: PlannedExecutionKind, +) -> String { + let (id, fallback) = match kind { + PlannedExecutionKind::LaunchInstallerExecutable => ( + "wizard-planned-runner-launch-installer", + "Launch installer executable", + ), + PlannedExecutionKind::ExtractArchiveAndRunInstaller => ( + "wizard-planned-runner-extract-archive", + "Extract archive and run contained installer", + ), + PlannedExecutionKind::ExtractArchiveAndCopyOsaraAssets => ( + "wizard-planned-runner-extract-archive-copy-osara", + "Extract archive and copy OSARA installer assets", + ), + PlannedExecutionKind::MountDiskImageAndRunInstaller => ( + "wizard-planned-runner-mount-disk-image", + "Mount disk image and run contained installer", + ), + PlannedExecutionKind::MountDiskImageAndCopyAppBundle => ( + "wizard-planned-runner-mount-disk-image-copy-app", + "Mount disk image and copy contained app bundle", + ), + PlannedExecutionKind::MountDiskImageAndRunPkgInstaller => ( + "wizard-planned-runner-mount-disk-image-run-pkg", + "Mount disk image and run contained pkg installer", + ), + }; + localizer + .map(|localizer| localizer.text(id).value) + .unwrap_or_else(|| fallback.to_string()) +} + +/// Localized "Install / Update / Keep" label resolver scoped to the saved +/// summary report. Mirrors the wizard's `action_label` but works against an +/// `Option<&Localizer>` so the summarizer can degrade gracefully when no +/// localizer is available. +pub(crate) fn action_label_for_summary( + localizer: Option<&Localizer>, + action: PlanActionKind, +) -> String { + let (id, fallback) = match action { + PlanActionKind::Install => ("action-install", "Install"), + PlanActionKind::Update => ("action-update", "Update"), + PlanActionKind::Keep => ("action-keep", "Keep"), + }; + localizer + .map(|localizer| localizer.text(id).value) + .unwrap_or_else(|| fallback.to_string()) +} + +/// Localized status-label resolver for `PackageOperationStatus` values +/// surfaced by the saved summary report. +pub(crate) fn status_label_for_summary( + localizer: Option<&Localizer>, + status: PackageOperationStatus, +) -> String { + let (id, fallback) = match status { + PackageOperationStatus::InstalledOrChecked => { + ("status-installed-or-checked", "Installed or checked") + } + PackageOperationStatus::PlannedUnattended => { + ("status-planned-unattended", "Planned unattended") + } + PackageOperationStatus::DeferredUnattended => { + ("status-deferred-unattended", "Deferred unattended") + } + PackageOperationStatus::SkippedCurrent => { + ("status-skipped-current", "Skipped (already current)") + } + PackageOperationStatus::Failed => ("status-failed", "Failed"), + PackageOperationStatus::SkippedDependencyFailed => ( + "status-skipped-dependency-failed", + "Skipped (dependency failed)", + ), + PackageOperationStatus::Cancelled => ("status-cancelled", "Not installed (you stopped)"), + }; + localizer + .map(|localizer| localizer.text(id).value) + .unwrap_or_else(|| fallback.to_string()) +} + +/// Translate a [`rabbit_core::operation::PackageOperationMessage`] into a +/// localized sentence using Fluent. Falls back to the message's English +/// form (`fallback_english`) when the locale doesn't have the key the +/// variant maps to — that's the same English string rabbit-core stamps +/// into the JSON report, so the saved report remains stable while the +/// wizard renders the user's locale. +pub(crate) fn localized_package_operation_message( + localizer: &Localizer, + code: &rabbit_core::operation::PackageOperationMessage, + fallback_english: &str, +) -> String { + use rabbit_core::operation::PackageOperationMessage as Msg; + let message = match code { + Msg::ExtensionBinaryInstalled => { + localizer.text("package-status-extension-binary-installed") + } + Msg::SkippedCurrent { + installed_version, + available_version, + } => localizer.format( + "package-status-skipped-current", + &[ + ("installed", installed_version.as_str()), + ("available", available_version.as_str()), + ], + ), + Msg::SkippedContentUnchanged => localizer.text("package-status-skipped-content-unchanged"), + Msg::DryRunWouldRunUnattended { artifact_kind } => localizer.format( + "package-status-dry-run-would-run-unattended", + &[( + "automation", + localized_automation_description(localizer, *artifact_kind).as_str(), + )], + ), + Msg::DeferredUnattendedStaged { artifact_kind } => localizer.format( + "package-status-deferred-unattended-staged", + &[( + "automation", + localized_automation_description(localizer, *artifact_kind).as_str(), + )], + ), + Msg::DeferredUnattendedNotStaged { artifact_kind } => localizer.format( + "package-status-deferred-unattended-not-staged", + &[( + "automation", + localized_automation_description(localizer, *artifact_kind).as_str(), + )], + ), + Msg::UnattendedInstalled => localizer.text("package-status-unattended-installed"), + Msg::OsaraUnattendedInstalledKeymapBackedUp => { + localizer.text("package-status-osara-unattended-keymap-backed-up") + } + Msg::OsaraUnattendedInstalledKeymapReplaced => { + localizer.text("package-status-osara-unattended-keymap-replaced") + } + Msg::InstallFailed { error, .. } => localizer.format( + "package-status-install-failed", + &[("error", error.as_str())], + ), + Msg::SkippedDependencyFailed { dependency } => localizer.format( + "package-status-skipped-dependency-failed", + &[("dependency", dependency.as_str())], + ), + Msg::Cancelled => localizer.text("package-status-cancelled"), + }; + if message.missing { + fallback_english.to_string() + } else { + message.value + } +} + +/// Localize the short "vendor installer" / "archive extraction" / +/// "disk image install" / "direct file install" automation-kind label +/// used inside the dry-run / deferred-unattended status messages. +fn localized_automation_description( + localizer: &Localizer, + kind: rabbit_core::artifact::ArtifactKind, +) -> String { + use rabbit_core::artifact::ArtifactKind; + let key = match kind { + ArtifactKind::Installer => "package-automation-installer", + // `.zip` and `.7z` end up extracted into UserPlugins by the same + // user-facing operation; the extractor differs but the + // user-facing description doesn't. + ArtifactKind::Archive | ArtifactKind::SevenZipArchive => "package-automation-archive", + ArtifactKind::DiskImage => "package-automation-disk-image", + ArtifactKind::ExtensionBinary => "package-automation-extension-binary", + }; + let text = localizer.text(key); + if text.missing { + match kind { + ArtifactKind::Installer => "vendor installer".to_string(), + ArtifactKind::Archive | ArtifactKind::SevenZipArchive => { + "archive extraction".to_string() + } + ArtifactKind::DiskImage => "disk image install".to_string(), + ArtifactKind::ExtensionBinary => "direct file install".to_string(), + } + } else { + text.value + } +} + +/// Translate a [`rabbit_core::configuration::ConfigurationMessage`] into a +/// localized sentence using Fluent, with the same English-fallback shape +/// as [`localized_package_operation_message`]. +pub(crate) fn localized_configuration_message( + localizer: &Localizer, + code: &rabbit_core::configuration::ConfigurationMessage, + fallback_english: &str, +) -> String { + use rabbit_core::configuration::ConfigurationMessage as Msg; + let message = match code { + Msg::ReapackRemoteAlreadyPresent { name, url } => localizer.format( + "config-message-reapack-remote-already-present", + &[("name", name.as_str()), ("url", url.as_str())], + ), + Msg::ReapackRemoteAdded { name, url } => localizer.format( + "config-message-reapack-remote-added", + &[("name", name.as_str()), ("url", url.as_str())], + ), + Msg::ReapackRemoteCreatedFile { name, url } => localizer.format( + "config-message-reapack-remote-created-file", + &[("name", name.as_str()), ("url", url.as_str())], + ), + Msg::ReapackRemoteDryRun { name, url } => localizer.format( + "config-message-reapack-remote-dry-run", + &[("name", name.as_str()), ("url", url.as_str())], + ), + Msg::ReaperLanguageAlreadySelected { file_name } => localizer.format( + "config-message-reaper-language-already-selected", + &[("file", file_name.as_str())], + ), + Msg::ReaperLanguageSelected { file_name } => localizer.format( + "config-message-reaper-language-selected", + &[("file", file_name.as_str())], + ), + Msg::ReaperLanguageDryRun { file_name } => localizer.format( + "config-message-reaper-language-dry-run", + &[("file", file_name.as_str())], + ), + Msg::Skipped { step_id } => { + localizer.format("config-message-skipped", &[("step", step_id.as_str())]) + } + Msg::SkippedDependencyMissing { step_id, dep_id } => localizer.format( + "config-message-skipped-dependency-missing", + &[("step", step_id.as_str()), ("dependency", dep_id.as_str())], + ), + Msg::AppliedNoOp => localizer.text("config-message-applied-no-op"), + Msg::Cancelled { .. } => localizer.text("config-message-cancelled"), + }; + if message.missing { + fallback_english.to_string() + } else { + message.value + } +} + +/// Look up a configuration step's localized display name from the +/// builtin manifest. Falls back to the raw step id when the step +/// isn't in the manifest (forward-compat for unknown ids loaded from +/// an older receipt). +pub(crate) fn localized_configuration_step_name( + localizer: Option<&Localizer>, + step_id: &str, +) -> String { + let steps = rabbit_core::configuration::builtin_configuration_steps(); + let display_key = steps + .iter() + .find(|step| step.id == step_id) + .map(|step| step.display_name_key.clone()); + match (localizer, display_key) { + (Some(localizer), Some(key)) => { + let text = localizer.text(&key); + if text.missing { + step_id.to_string() + } else { + text.value + } + } + _ => step_id.to_string(), + } +} + +/// Localize a [`rabbit_core::configuration::ConfigurationStatus`] for +/// the summary's " Status: …" sub-line. Mirrors +/// [`status_label_for_summary`] for `PackageOperationStatus`. +pub(crate) fn configuration_status_label_for_summary( + localizer: Option<&Localizer>, + status: rabbit_core::configuration::ConfigurationStatus, +) -> String { + use rabbit_core::configuration::ConfigurationStatus; + let (id, fallback) = match status { + ConfigurationStatus::Applied => ("config-status-applied", "Applied"), + ConfigurationStatus::Skipped => ("config-status-skipped", "Skipped"), + ConfigurationStatus::SkippedDependencyMissing => ( + "config-status-skipped-dependency-missing", + "Skipped (dependency missing)", + ), + ConfigurationStatus::DryRun => ("config-status-dry-run", "Dry run"), + ConfigurationStatus::Cancelled => ("config-status-cancelled", "Did not run (you stopped)"), + }; + localizer + .map(|localizer| localizer.text(id).value) + .unwrap_or_else(|| fallback.to_string()) +} + +/// Format the wizard's detected architecture as a stable short token used in +/// the summary report. Not localized — these are the same identifiers the +/// CLI's `rabbit detect` output uses, so external tooling can grep for them. +pub(crate) fn architecture_label_for_summary(architecture: Architecture) -> String { + match architecture { + Architecture::X86 => "x86".to_string(), + Architecture::X64 => "x64".to_string(), + Architecture::Arm64 => "arm64".to_string(), + Architecture::Arm64Ec => "arm64ec".to_string(), + Architecture::Universal => "universal".to_string(), + Architecture::Unknown => "unknown".to_string(), + } +} + +pub(crate) fn version_text( + localizer: &Localizer, + version: Option<&rabbit_core::version::Version>, +) -> String { + version + .map(ToString::to_string) + .unwrap_or_else(|| localizer.text("detect-version-unknown").value) +} + +pub(crate) fn unknown_version_text(model: &WizardModel) -> String { + localizer_from_options(&model.bootstrap_options) + .map(|localizer| localizer.text("detect-version-unknown").value) + .unwrap_or_else(|_| "Version unknown".to_string()) +} + +pub(crate) fn action_label(localizer: &Localizer, action: PlanActionKind) -> String { + let key = match action { + PlanActionKind::Install => "action-install", + PlanActionKind::Update => "action-update", + PlanActionKind::Keep => "action-keep", + }; + localizer.text(key).value +} + +pub(crate) fn yes_no(localizer: &Localizer, value: bool) -> String { + if value { + localizer.text("common-yes").value + } else { + localizer.text("common-no").value + } +} diff --git a/crates/rabbit-ui-wxdragon/src/wizard/model.rs b/crates/rabbit-ui-wxdragon/src/wizard/model.rs new file mode 100644 index 0000000..7c4b930 --- /dev/null +++ b/crates/rabbit-ui-wxdragon/src/wizard/model.rs @@ -0,0 +1,449 @@ +//! Data the wizard renders: one struct per screen plus the install +//! request and outcome types the pages exchange with `rabbit-core`. + +use std::path::PathBuf; + +use rabbit_core::localization::DEFAULT_LOCALE; +use rabbit_core::model::{Architecture, Platform}; +use rabbit_core::plan::{AvailablePackage, PlanActionKind}; +use rabbit_core::setup::SetupReport; +use rabbit_core::version::Version; +use serde::Serialize; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct UiBootstrapOptions { + pub locale: String, + pub locales_dir: Option, + pub portable_roots: Vec, + pub online_versions: bool, +} + +impl Default for UiBootstrapOptions { + fn default() -> Self { + Self { + locale: DEFAULT_LOCALE.to_string(), + locales_dir: None, + portable_roots: Vec::new(), + online_versions: false, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WizardModel { + pub window_title: String, + pub platform: Platform, + pub architecture: Architecture, + pub text: WizardText, + pub bootstrap_options: UiBootstrapOptions, + pub current_step: WizardStep, + pub steps: Vec, + pub target_rows: Vec, + pub selected_target_index: Option, + pub package_rows: Vec, + pub configuration_rows: Vec, + pub available_packages: Vec, + pub review_lines: Vec, + pub notes: Vec, + pub controls: WizardControls, + pub language_options: Vec, + pub current_language: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LanguageOption { + pub locale: String, + pub display_name: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WizardText { + pub common_yes: String, + pub common_no: String, + pub target_heading: String, + pub target_language_label: String, + pub target_language_restart_note: String, + pub target_choice_label: String, + pub target_details_label: String, + pub target_empty: String, + pub target_portable_choice: String, + pub target_portable_folder_label: String, + pub target_portable_folder_message: String, + pub target_portable_folder_browse_label: String, + pub target_portable_pending_details: String, + pub target_custom_portable_label: String, + pub target_custom_portable_app_path_label: String, + pub target_custom_portable_path_label: String, + pub target_custom_portable_version_label: String, + pub target_custom_portable_writable_label: String, + pub target_custom_portable_note: String, + pub packages_heading: String, + pub packages_list_label: String, + pub packages_tree_group_label: String, + pub additional_software_tree_group_label: String, + pub language_tree_group_label: String, + pub configuration_tree_group_label: String, + pub reapack_ack_heading: String, + pub reapack_ack_body: String, + pub reapack_ack_link_label: String, + pub reapack_ack_confirm_label: String, + pub version_check_heading: String, + pub version_check_status_pending: String, + pub version_check_progress_label: String, + pub version_check_error_heading: String, + pub package_details_label: String, + pub packages_osara_keymap_heading: String, + pub packages_osara_keymap_replace_label: String, + pub packages_reaper_language_label: String, + pub packages_spanish_variant_label: String, + /// The selectable Spanish OSARA translations, in the same order as + /// [`VARIANT_CHOICE_IDS`]. + pub packages_spanish_variant_options: Vec, + pub packages_osara_keymap_unavailable_note: String, + pub packages_osara_keymap_preserve_note: String, + pub packages_osara_keymap_replace_note: String, + pub package_details_handling_prefix: String, + pub package_handling_automatic: String, + pub package_handling_unattended: String, + pub package_handling_planned: String, + pub package_handling_manual: String, + pub package_handling_unavailable: String, + pub review_heading: String, + pub review_target_prefix: String, + pub review_package_heading: String, + pub review_osara_keymap_heading: String, + pub review_osara_keymap_preserve: String, + pub review_osara_keymap_replace: String, + pub review_notes_heading: String, + pub review_preflight_prefix: String, + pub review_no_target: String, + pub review_no_package: String, + pub progress_heading: String, + pub progress_status: String, + pub progress_status_running: String, + /// Shown from the moment the user confirms the stop until the pipeline + /// finishes the step it is on. Deliberately not "cancelled": nothing has + /// stopped yet when it first appears. + pub progress_status_cancelling: String, + pub progress_details_label: String, + pub progress_details_idle: String, + pub progress_details_starting: String, + pub progress_details_cache_prefix: String, + pub done_heading: String, + pub done_status: String, + pub done_status_success: String, + pub done_status_completed_with_errors: String, + pub done_status_error: String, + pub done_status_cancelled: String, + pub done_status_no_packages: String, + pub done_show_details_label: String, + pub done_launch_reaper_label: String, + pub done_open_resource_label: String, + pub done_no_reaper_app: String, + pub done_launch_reaper_error_prefix: String, + pub done_open_resource_error_prefix: String, + pub done_self_update_apply_running: String, + pub done_self_update_error_prefix: String, + pub done_self_update_relaunch_prefix: String, + pub self_update_status_checking: String, + pub close_during_install_title: String, + pub close_during_install_body: String, + pub close_during_self_update_title: String, + pub close_during_self_update_body: String, + pub expert_enable_title: String, + pub expert_enable_body: String, + pub expert_disabled_title: String, + pub expert_disabled_body: String, + pub expert_first_page_title: String, + pub expert_first_page_body: String, + pub expert_busy_body: String, + pub expert_reaper_builds_label: String, + pub expert_reaper_builds_stable: String, + pub expert_reaper_builds_dev: String, + pub expert_osara_builds_label: String, + pub expert_osara_builds_snapshot: String, + pub expert_osara_builds_loading: String, + pub expert_osara_builds_unavailable: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WizardStep { + Target, + VersionCheck, + Packages, + ReapackAcknowledgement, + Review, + Progress, + Done, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WizardStepLabel { + pub step: WizardStep, + pub label: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TargetRow { + pub label: String, + pub details: String, + pub app_path: Option, + pub planned_app_path: PathBuf, + pub path: PathBuf, + pub version: Option, + pub portable: bool, + pub selected: bool, + pub writable: bool, + /// Architecture of the REAPER binary at this target. Populated by the + /// detection layer's binary-header probe rather than the host arch, so + /// e.g. an Intel REAPER on an Apple Silicon Mac, or an x86_64 REAPER on + /// Windows-on-ARM, gets the arch-correct extension binaries (ReaPack, + /// SWS, OSARA) instead of host-matching ones REAPER would refuse to + /// load. Falls back to `Architecture::current()` when the binary can't + /// be probed (synthetic / portable targets without a binary on disk yet). + pub architecture: Architecture, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PackageRow { + pub package_id: String, + pub display_name: String, + pub description: String, + pub selected: bool, + pub summary: String, + pub details: String, + pub installed_version: String, + pub available_version: String, + pub action: PlanActionKind, + pub action_label: String, + /// Plan-time action, captured before any user toggle of the package + /// checkbox. Used by the wizard's checklist handler to decide whether + /// re-checking a row means "Install" (originally not installed) or + /// "Update" (already installed) — the displayed `action` mutates as + /// the user clicks, but `original_action` is the authoritative anchor. + pub original_action: PlanActionKind, + pub reason: String, + pub handling_summary: String, + pub manual_attention_expected: bool, + /// `false` when this package can't be installed against the currently + /// selected target — the row is shown but its checkbox is disabled and + /// the row label carries a localized indicator. Today only true → false + /// flip is "JAWS-for-REAPER scripts on a portable REAPER target", since + /// the NSIS installer hard-codes `%APPDATA%\REAPER\UserPlugins\` and + /// can't honor the portable destination. + pub available_for_target: bool, + /// Localized reason matching `available_for_target == false`. `None` + /// when the row is available. + pub unavailability_reason: Option, + /// Which wizard UI group this row belongs to ("Packages" vs "Additional + /// software"). Mirrors the package spec's category. + pub category: rabbit_core::package::PackageCategory, + /// Mirrors the spec's `requires_standard_install`: when `true`, the row is + /// disabled on a portable REAPER target (the package installs to a fixed + /// location outside any portable folder). + pub requires_standard_install: bool, + /// The channel the available build is on; `None` is the regular release. + pub available_channel: Option, + /// The channel the installed build came from, as its receipt recorded it. + pub installed_channel: Option, +} + +/// Wizard-side row for a single [`crate::configuration::ConfigurationStep`] +/// (re-exported from `rabbit-core` as `rabbit_core::configuration::*`). +/// Mirrors the `PackageRow` shape just enough that the tree UI can render +/// it as a sibling leaf under the "Configuration" group, but configuration +/// steps don't have versions / actions / artifacts, so most package +/// fields don't apply. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ConfigurationRow { + /// Stable id of the underlying `ConfigurationStep`. + pub step_id: String, + /// Localized step name (the row's primary label). + pub display_name: String, + /// Localized free-form description shown in the package-details + /// pane when the row is selected. + pub description: String, + /// Whether the row's checkbox is currently ticked. Initialised from + /// the step's `recommended` flag intersected with the row's + /// `available_for_target`. + pub selected: bool, + /// Row label as rendered in the tree. For configuration rows the + /// summary is just `display_name` today; kept as a separate field + /// so the wizard's tree-refresh helpers can stay symmetric with + /// PackageRow. + pub summary: String, + /// Free-form details shown alongside `description` (status hints, + /// dependency reasons). Today carries the localized + /// "(unavailable: …)" sentence when the dependency package isn't + /// queued for install. + pub details: String, + /// `true` iff the step's dependency package (if any) is either + /// already installed on the selected target or queued for install + /// in the current package plan. The wizard greys out the row's + /// checkbox when this is `false`. + pub available_for_target: bool, + /// `true` iff the step's effect is already in place on disk under + /// the selected target (e.g. the ReaPack remote URL is already + /// listed in `reapack.ini`). The wizard treats this like + /// `available_for_target == false` for interactivity (the checkbox + /// is disabled) but uses a different reason string so the user + /// understands the row isn't unsupported, just done. + pub already_applied: bool, + /// Localized reason matching `available_for_target == false` OR + /// `already_applied == true`. `None` when the row is interactive. + pub unavailability_reason: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WizardControls { + pub back_label: String, + pub next_label: String, + pub install_label: String, + pub close_label: String, + pub can_go_back: bool, + pub can_go_next: bool, + pub can_install: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum OsaraKeymapChoice { + PreserveCurrent, + ReplaceCurrent, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WizardInstallOptions { + pub dry_run: bool, + pub allow_reaper_running: bool, + pub stage_unsupported: bool, + pub osara_keymap_choice: OsaraKeymapChoice, + /// Chosen package flavour per package id; see + /// [`rabbit_core::package::PackageVariant`]. + pub package_variants: std::collections::BTreeMap, + /// Language pack to make active after installing; see + /// [`WizardInstallRequest::reaper_language_package`]. + pub reaper_language_package: Option, + pub cache_dir: Option, + /// Channel per package for this run; see + /// [`WizardInstallRequest::package_channels`]. + pub package_channels: rabbit_core::package::PackageChannels, +} + +impl Default for WizardInstallOptions { + fn default() -> Self { + Self { + dry_run: false, + allow_reaper_running: false, + stage_unsupported: true, + osara_keymap_choice: OsaraKeymapChoice::ReplaceCurrent, + package_variants: std::collections::BTreeMap::new(), + reaper_language_package: None, + cache_dir: None, + package_channels: Default::default(), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WizardInstallRequest { + pub resource_path: PathBuf, + pub package_ids: Vec, + pub platform: Platform, + pub architecture: Architecture, + pub portable: bool, + pub target_app_path: Option, + pub dry_run: bool, + pub allow_reaper_running: bool, + pub stage_unsupported: bool, + pub osara_keymap_choice: OsaraKeymapChoice, + /// Chosen package flavour per package id, forwarded to + /// [`rabbit_core::setup::SetupOptions::package_variants`]. + pub package_variants: std::collections::BTreeMap, + /// Language pack to make active after installing. Several can be + /// installed at once — REAPER keeps them all — but only one is active. + pub reaper_language_package: Option, + pub cache_dir: PathBuf, + /// Packages whose plan-time decision was `Keep` (already current) but + /// the user explicitly checked the box anyway, opting in to a + /// re-install. The setup pipeline promotes these from Keep to Update + /// so the install step actually runs instead of being silently + /// skipped. + pub force_reinstall_packages: Vec, + /// Configuration step ids the user opted in to. Forwarded straight + /// through to [`SetupOptions::configuration_step_ids`]. + pub configuration_step_ids: Vec, + /// Opt-out-remembering packages the user actively turned down: the row + /// had something to offer (an install or an update) and they left it + /// unticked. Recorded so the suggestion does not come back next launch. + /// + /// A `Keep` row is deliberately absent from this and from + /// `accepted_packages`. An installed, up-to-date package sits unticked + /// because there is nothing to do, which is silence rather than a + /// verdict; reading it as a refusal would eventually turn RABBIT's own + /// successful install into a "no". + pub declined_packages: Vec, + /// Opt-out-remembering packages the user ticked, clearing any refusal + /// recorded earlier so a change of mind sticks. + pub accepted_packages: Vec, + /// The channel each package is installed from. Empty outside expert + /// mode, which is exactly what sends a package that came from a + /// development build back to stable. + pub package_channels: rabbit_core::package::PackageChannels, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WizardInstallSummary { + pub status_line: String, + pub detail_lines: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WizardPackagePlan { + pub package_rows: Vec, + pub notes: Vec, + pub can_install: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WizardReviewPreview { + pub lines: Vec, + pub can_install: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum WizardOutcomeStatus { + Success, + /// The operation finished, but one or more packages failed or were + /// skipped because a dependency failed. Distinct from `Error` (the whole + /// operation aborted) so the saved report doesn't claim `success` when + /// the done page shows "completed with errors". + CompletedWithErrors, + Error, + /// The user stopped the run. Some packages may have installed before + /// the stop; the ones after it were never touched. Distinct from both + /// `CompletedWithErrors` (nothing broke) and `Error` (the run did what + /// it was asked to do, right up to being asked to stop). + Cancelled, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct WizardOutcomeReport { + pub status: WizardOutcomeStatus, + pub resource_path: PathBuf, + pub target_app_path: Option, + pub package_ids: Vec, + pub platform: Platform, + pub architecture: Architecture, + pub portable: bool, + pub dry_run: bool, + pub allow_reaper_running: bool, + pub stage_unsupported: bool, + pub cache_dir: PathBuf, + pub osara_keymap_choice: OsaraKeymapChoice, + pub status_line: String, + pub detail_lines: Vec, + pub error_message: Option, + pub setup_report: Option, +} diff --git a/crates/rabbit-ui-wxdragon/src/wizard/outcome.rs b/crates/rabbit-ui-wxdragon/src/wizard/outcome.rs new file mode 100644 index 0000000..0be2a3c --- /dev/null +++ b/crates/rabbit-ui-wxdragon/src/wizard/outcome.rs @@ -0,0 +1,218 @@ +//! Outcome reports: what the wizard writes to disk after a run and the +//! error summaries it shows when one fails. + +use std::path::PathBuf; + +use rabbit_core::package::PACKAGE_OSARA; +use rabbit_core::report::{default_report_path, save_json_and_text_reports}; +use rabbit_core::setup::SetupReport; +use rabbit_core::{RabbitError, Result}; + +use super::bootstrap::localizer_from_options; +use super::labels::format_localized_message; +use super::model::{ + OsaraKeymapChoice, WizardInstallRequest, WizardInstallSummary, WizardModel, + WizardOutcomeReport, WizardOutcomeStatus, +}; +use super::packages::package_display_name; +use super::summary::summarize_setup_report; + +pub fn wizard_outcome_report_from_success( + model: &WizardModel, + request: &WizardInstallRequest, + report: &SetupReport, +) -> WizardOutcomeReport { + let summary = summarize_setup_report(model, report); + WizardOutcomeReport { + // Cancellation outranks a package failure in the heading: "you + // stopped it" explains the half-finished run, where "finished with + // errors" would send the user looking for a fault. + status: if report.was_cancelled() { + WizardOutcomeStatus::Cancelled + } else if report.package_operation.has_failures() { + WizardOutcomeStatus::CompletedWithErrors + } else { + WizardOutcomeStatus::Success + }, + resource_path: report.resource_path.clone(), + target_app_path: request.target_app_path.clone(), + package_ids: request.package_ids.clone(), + platform: request.platform, + architecture: request.architecture, + portable: request.portable, + dry_run: request.dry_run, + allow_reaper_running: request.allow_reaper_running, + stage_unsupported: request.stage_unsupported, + cache_dir: request.cache_dir.clone(), + osara_keymap_choice: request.osara_keymap_choice, + status_line: summary.status_line, + detail_lines: summary.detail_lines, + error_message: None, + setup_report: Some(report.clone()), + } +} + +pub fn summarize_wizard_error( + model: &WizardModel, + request: &WizardInstallRequest, + error: &RabbitError, +) -> WizardInstallSummary { + let localizer = localizer_from_options(&model.bootstrap_options).ok(); + let selected_packages = if request.package_ids.is_empty() { + model.text.review_no_package.clone() + } else { + request + .package_ids + .iter() + .map(|package_id| package_display_name(model, package_id)) + .collect::>() + .join(", ") + }; + let mut detail_lines = vec![ + format_localized_message( + localizer.as_ref(), + "wizard-summary-target", + &[("path", request.resource_path.display().to_string())], + format!("Target: {}", request.resource_path.display()), + ), + format_localized_message( + localizer.as_ref(), + "wizard-summary-portable", + &[( + "value", + if request.portable { + model.text.common_yes.clone() + } else { + model.text.common_no.clone() + }, + )], + format!( + "Portable target: {}", + if request.portable { + &model.text.common_yes + } else { + &model.text.common_no + } + ), + ), + format_localized_message( + localizer.as_ref(), + "wizard-summary-dry-run", + &[( + "value", + if request.dry_run { + model.text.common_yes.clone() + } else { + model.text.common_no.clone() + }, + )], + format!( + "Dry run: {}", + if request.dry_run { + &model.text.common_yes + } else { + &model.text.common_no + } + ), + ), + format_localized_message( + localizer.as_ref(), + "wizard-summary-packages-selected", + &[("packages", selected_packages.clone())], + format!("Packages selected: {selected_packages}"), + ), + format_localized_message( + localizer.as_ref(), + "wizard-summary-cache", + &[("path", request.cache_dir.display().to_string())], + format!("Cache: {}", request.cache_dir.display()), + ), + ]; + + if let Some(target_app_path) = &request.target_app_path { + detail_lines.push(format_localized_message( + localizer.as_ref(), + "wizard-summary-planned-app", + &[("path", target_app_path.display().to_string())], + format!("Planned app path: {}", target_app_path.display()), + )); + } + + if request + .package_ids + .iter() + .any(|package_id| package_id == PACKAGE_OSARA) + { + detail_lines.push(model.text.review_osara_keymap_heading.clone()); + detail_lines.push(match request.osara_keymap_choice { + OsaraKeymapChoice::PreserveCurrent => model.text.review_osara_keymap_preserve.clone(), + OsaraKeymapChoice::ReplaceCurrent => model.text.review_osara_keymap_replace.clone(), + }); + } + + detail_lines.push(format_localized_message( + localizer.as_ref(), + "wizard-summary-error", + &[("message", error.to_string())], + format!("Error: {error}"), + )); + + // The error text itself is English (it goes into bug reports); an + // antivirus block is the one failure a user can usually clear + // themselves, so follow it with the remediation steps in their own + // language. + if matches!(error, RabbitError::WindowsFileBlockedByAntivirus { .. }) { + detail_lines.push(format_localized_message( + localizer.as_ref(), + "wizard-summary-error-antivirus", + &[], + "Windows security software blocked this download. Open Windows Security > \ + Virus & threat protection > Protection history, allow the blocked item, and run \ + RABBIT again." + .to_string(), + )); + } + + WizardInstallSummary { + status_line: model.text.done_status_error.clone(), + detail_lines, + } +} + +pub fn wizard_outcome_report_from_error( + model: &WizardModel, + request: &WizardInstallRequest, + error: &RabbitError, +) -> WizardOutcomeReport { + let summary = summarize_wizard_error(model, request, error); + WizardOutcomeReport { + status: WizardOutcomeStatus::Error, + resource_path: request.resource_path.clone(), + target_app_path: request.target_app_path.clone(), + package_ids: request.package_ids.clone(), + platform: request.platform, + architecture: request.architecture, + portable: request.portable, + dry_run: request.dry_run, + allow_reaper_running: request.allow_reaper_running, + stage_unsupported: request.stage_unsupported, + cache_dir: request.cache_dir.clone(), + osara_keymap_choice: request.osara_keymap_choice, + status_line: summary.status_line, + detail_lines: summary.detail_lines, + error_message: Some(error.to_string()), + setup_report: None, + } +} + +pub fn save_wizard_outcome_report(report: &WizardOutcomeReport) -> Result { + let json_path = default_report_path(&report.resource_path, "setup"); + let saved = save_json_and_text_reports(&json_path, report)?; + Ok(saved.text_path) +} + +pub fn save_wizard_setup_report(report: &SetupReport) -> Result { + let json_path = default_report_path(&report.resource_path, "setup"); + let saved = save_json_and_text_reports(&json_path, report)?; + Ok(saved.text_path) +} diff --git a/crates/rabbit-ui-wxdragon/src/wizard/packages.rs b/crates/rabbit-ui-wxdragon/src/wizard/packages.rs new file mode 100644 index 0000000..458bccf --- /dev/null +++ b/crates/rabbit-ui-wxdragon/src/wizard/packages.rs @@ -0,0 +1,704 @@ +//! Package page rows: what is offered for a target, what stays ticked, and +//! how a checkbox toggle changes a row. + +use std::collections::BTreeMap; + +use rabbit_core::Result; +use rabbit_core::detection::detect_components; +use rabbit_core::localization::Localizer; +use rabbit_core::model::{Architecture, Platform}; +use rabbit_core::operation::{PackageAutomationSupport, package_automation_support}; +use rabbit_core::package::{ + HostCapabilities, PACKAGE_OSARA, PackageSpec, builtin_package_specs, detect_host_capabilities, + host_supports_package, +}; +use rabbit_core::plan::{AvailablePackage, PlanAction, PlanActionKind, build_install_plan}; + +use super::bootstrap::localizer_from_options; +use super::labels::{action_label, version_text}; +use super::model::{ + OsaraKeymapChoice, PackageRow, TargetRow, WizardModel, WizardPackagePlan, WizardText, +}; +use super::target::installation_from_target_row; + +pub fn package_ids_for_indices(model: &WizardModel, indices: &[usize]) -> Vec { + package_ids_for_rows(&model.package_rows, indices) +} + +pub fn package_ids_for_rows(package_rows: &[PackageRow], indices: &[usize]) -> Vec { + let mut package_ids = Vec::new(); + for index in indices { + let Some(row) = package_rows.get(*index) else { + continue; + }; + if !package_ids.contains(&row.package_id) { + package_ids.push(row.package_id.clone()); + } + } + package_ids +} + +pub fn osara_selected_for_rows(package_rows: &[PackageRow], indices: &[usize]) -> bool { + indices + .iter() + .filter_map(|index| package_rows.get(*index)) + .any(|row| row.package_id == PACKAGE_OSARA) +} + +/// Whether a package offering a variant choice is currently selected for +/// install, so the wizard can enable its choice control. Today only the +/// Spanish language pack qualifies: its two variants pick which OSARA +/// translation loads. +pub fn variant_choice_package_selected(package_rows: &[PackageRow], indices: &[usize]) -> bool { + indices + .iter() + .filter_map(|index| package_rows.get(*index)) + .any(|row| row.package_id == VARIANT_CHOICE_PACKAGE_ID) +} + +/// Ticked language packs, in row order, as (package id, display name). The +/// wizard's "REAPER language after installation" dropdown lists these: any +/// number can be installed side by side, but exactly one is made active. +pub fn selected_language_packs( + package_rows: &[PackageRow], + indices: &[usize], +) -> Vec<(String, String)> { + indices + .iter() + .filter_map(|index| package_rows.get(*index)) + .filter(|row| row.category == rabbit_core::package::PackageCategory::Language) + .map(|row| (row.package_id.clone(), row.display_name.clone())) + .collect() +} + +/// The one package that currently offers a variant choice in the wizard. +pub const VARIANT_CHOICE_PACKAGE_ID: &str = "langpack-es"; +/// The selectable Spanish OSARA translations, in the order the wizard's +/// dropdown lists them. Index 0 is the default. Kept as an explicit list so +/// the dropdown's position maps to a manifest variant id without the UI +/// hard-coding which is which. +pub const VARIANT_CHOICE_IDS: &[&str] = &["rae", "pma"]; + +/// Build the `package_variants` map from the wizard's dropdown selection. +/// +/// Always records an explicit choice. Picking the first entry has to mean +/// "use REAPER Accesible español", not "no opinion" — the install path +/// falls back to the previously-installed variant when no choice is given, +/// so an absent entry would make it impossible to switch back from Team PMA +/// once chosen. An out-of-range index falls back to the first entry. +pub fn package_variants_from_choice( + selection: Option, +) -> std::collections::BTreeMap { + let index = selection.unwrap_or(0) as usize; + let id = VARIANT_CHOICE_IDS + .get(index) + .copied() + .unwrap_or(VARIANT_CHOICE_IDS[0]); + let mut variants = std::collections::BTreeMap::new(); + variants.insert(VARIANT_CHOICE_PACKAGE_ID.to_string(), id.to_string()); + variants +} + +/// Dropdown index matching the Spanish variant currently installed at +/// `resource_path`, so the wizard shows the user's existing choice rather +/// than a default that would silently switch them. Falls back to the first +/// entry when nothing is installed. +pub fn spanish_variant_selection(resource_path: &std::path::Path) -> u32 { + let installed = rabbit_core::package::effective_variant_id( + resource_path, + VARIANT_CHOICE_PACKAGE_ID, + &std::collections::BTreeMap::new(), + ); + installed + .and_then(|id| { + VARIANT_CHOICE_IDS + .iter() + .position(|candidate| *candidate == id) + }) + .unwrap_or(0) as u32 +} + +/// Returns true when ReaPack is selected and its planned action would +/// actually stage the package (Install or Update), i.e. the run will need +/// the donation acknowledgement before it proceeds. +pub fn reapack_selected_for_install_or_update( + package_rows: &[PackageRow], + indices: &[usize], +) -> bool { + indices + .iter() + .filter_map(|index| package_rows.get(*index)) + .any(|row| { + row.package_id == rabbit_core::package::PACKAGE_REAPACK + && matches!(row.action, PlanActionKind::Install | PlanActionKind::Update) + }) +} + +pub fn osara_keymap_note( + model: &WizardModel, + osara_selected: bool, + choice: OsaraKeymapChoice, +) -> String { + if !osara_selected { + return model.text.packages_osara_keymap_unavailable_note.clone(); + } + + match choice { + OsaraKeymapChoice::PreserveCurrent => { + model.text.packages_osara_keymap_preserve_note.clone() + } + OsaraKeymapChoice::ReplaceCurrent => model.text.packages_osara_keymap_replace_note.clone(), + } +} + +pub fn wizard_package_plan_for_target( + model: &WizardModel, + target: Option<&TargetRow>, +) -> Result { + wizard_package_plan_for_target_with_available(model, target, &model.available_packages) +} + +/// Like `wizard_package_plan_for_target`, but uses an explicit +/// `available_packages` list instead of the one stored in the model. This is +/// what the wizard calls after the GUI's background latest-version fetch +/// completes so the package list can be re-rendered with fresh upstream data +/// without rebuilding the whole model. +pub fn wizard_package_plan_for_target_with_available( + model: &WizardModel, + target: Option<&TargetRow>, + available_packages: &[AvailablePackage], +) -> Result { + let localizer = localizer_from_options(&model.bootstrap_options)?; + let detections = match target { + Some(target) => detect_components(&target.path, model.platform)?, + None => Vec::new(), + }; + let desired = wizard_desired_package_ids(model.platform); + let plan = build_install_plan( + target.map(|target| installation_from_target_row(model, target)), + &detections, + &desired, + available_packages, + ); + let package_specs = builtin_package_specs(model.platform); + let host = detect_host_capabilities(); + let declined = target + .map(|target| rabbit_core::receipt::declined_packages(&target.path)) + .unwrap_or_default(); + let installed_channels = target + .map(|target| rabbit_core::receipt::installed_channels_at(&target.path)) + .unwrap_or_default(); + let mut package_rows = package_rows( + &localizer, + &model.text, + model.platform, + model.architecture, + &package_specs, + &plan.actions, + available_packages, + &host, + &declined, + &installed_channels, + ); + + // Some packages can't honor a portable target: they install to a fixed + // location outside any portable REAPER folder. JAWS-for-REAPER scripts' + // NSIS package hard-codes `%APPDATA%\REAPER\UserPlugins\`; Surge XT's + // vendor installer writes to the system VST3 root + ProgramData / + // /Library/Application Support; app2clap installs into the per-user CLAP + // folder. Each declares `requires_standard_install` in the manifest, so + // the gate is data-driven rather than a hardcoded package-id list. Mark + // those rows unavailable on a portable target so the checklist disables + // their checkboxes and each row label carries a localized "(requires + // standard installation)" indicator. + if target.is_some_and(|target| target.portable) { + for row in &mut package_rows { + if row.requires_standard_install { + mark_row_unavailable(&localizer, row, "wizard-package-row-unavailable-portable"); + } + } + } + + let can_install = package_rows.iter().any(|row| { + row.available_for_target + && matches!(row.action, PlanActionKind::Install | PlanActionKind::Update) + }); + + Ok(WizardPackagePlan { + package_rows, + notes: plan.notes, + can_install, + }) +} + +/// Mark `row` as unavailable: force-uncheck it, record the localized reason, +/// and append a localized "(not available: )" indicator to the row +/// summary so the indicator shows up in the package CheckListBox label. +/// +/// Also flips the row's displayed action to `Keep` (mirroring what the +/// auto-untick path in [`package_rows`] does for non-recommended Install/ +/// Update rows). Without this, an Install/Update row that becomes unavailable +/// — e.g. JAWS-for-REAPER scripts on a portable target — would still read +/// "Will install" / "Will update" while sitting unticked and disabled. +/// `original_action` is preserved so the row can revert to its plan-time +/// intent if the unavailability is later lifted. +fn mark_row_unavailable(localizer: &Localizer, row: &mut PackageRow, reason_key: &str) { + let reason = localizer.text(reason_key).value; + row.available_for_target = false; + row.selected = false; + row.action = PlanActionKind::Keep; + row.action_label = action_label(localizer, PlanActionKind::Keep); + let summary = package_row_summary(localizer, row); + let indicator = localizer + .format( + "wizard-package-row-unavailable-suffix", + &[("reason", reason.as_str())], + ) + .value; + row.summary = format!("{summary} {indicator}"); + row.unavailability_reason = Some(reason); +} + +/// Apply per-package latest-version-check failures to a built package-row +/// set. Each failed package's row is force-unchecked and disabled (same +/// mechanism as the portable-target restriction) with a localized "version +/// check failed" reason in its label, and a note carrying the package name +/// plus the full error message is appended for the Review page. One +/// unreachable upstream (e.g. the SWS homepage being down) therefore +/// disables just that package instead of blocking the whole update flow. +/// +/// Returns the recomputed "at least one actionable Install/Update row is +/// left" flag, which the caller should store as its can-install state. +pub fn apply_version_check_failures_to_rows( + localizer: &Localizer, + package_rows: &mut [PackageRow], + notes: &mut Vec, + failures: &[(String, String)], +) -> bool { + for (package_id, message) in failures { + if let Some(row) = package_rows + .iter_mut() + .find(|row| row.package_id == *package_id) + { + mark_row_unavailable( + localizer, + row, + "wizard-package-row-unavailable-version-check", + ); + } + let display = localized_package_display_name(localizer, package_id); + notes.push( + localizer + .format( + "wizard-version-check-failed-note", + &[("package", display.as_str()), ("message", message.as_str())], + ) + .value, + ); + } + package_rows.iter().any(|row| { + row.available_for_target + && matches!(row.action, PlanActionKind::Install | PlanActionKind::Update) + }) +} + +/// Recompute a `PackageRow`'s `action`, `action_label`, `summary`, and +/// `selected` fields to match a new checkbox state. Used by the wizard's +/// CheckListBox toggle handler so the visible "Install/Update/Keep" label +/// follows what the user just clicked. Returns the freshly-formatted +/// summary so the caller can also push it into the CheckListBox label. +pub fn apply_checkbox_state_to_package_row( + model: &WizardModel, + row: &mut PackageRow, + checked: bool, +) -> Result { + let localizer = localizer_from_options(&model.bootstrap_options)?; + let new_action = if checked { + // Originally-not-installed packages stay "Install" when re-checked; + // anything else means the package is on disk, so re-checking it + // means "Update" (re-stage the latest known upstream version). + match row.original_action { + PlanActionKind::Install => PlanActionKind::Install, + _ => PlanActionKind::Update, + } + } else { + PlanActionKind::Keep + }; + row.action = new_action; + row.action_label = action_label(&localizer, new_action); + row.summary = package_row_summary(&localizer, row); + row.selected = checked; + Ok(row.summary.clone()) +} + +/// Localized package display name for `package_id`, falling back to the raw id +/// when no Fluent key is available. Used by the version-check progress log. +pub fn localized_package_display_name(localizer: &Localizer, package_id: &str) -> String { + let key = format!("package-{package_id}"); + let text = localizer.text(&key); + if text.missing { + package_id.to_string() + } else { + text.value + } +} + +/// List of package ids the wizard cares about for a given platform — exposed +/// so the GUI can iterate them without duplicating builtin_package_specs. +/// Host-conditional packages (e.g. JAWS-for-REAPER scripts) are filtered out +/// when the corresponding host facility isn't available. +pub fn wizard_desired_package_ids(platform: Platform) -> Vec { + wizard_desired_package_ids_for_host(platform, &detect_host_capabilities()) +} + +/// Same as [`wizard_desired_package_ids`] but with an explicit host snapshot, +/// so tests can pin "JAWS detected"/"JAWS missing" without touching the real +/// filesystem. +pub fn wizard_desired_package_ids_for_host( + platform: Platform, + host: &HostCapabilities, +) -> Vec { + builtin_package_specs(platform) + .into_iter() + .filter(|spec| host_supports_package(spec, host)) + .map(|spec| spec.id) + .collect() +} + +/// The row tag and details sentence for a package whose build is not the +/// regular release, or which this run takes back to it. `None` for the +/// ordinary case. The sentence says what the install will do, so a row +/// that installs nothing (`acting` false) gets the tag without it. +fn package_channel_note( + localizer: &Localizer, + package: &str, + available_channel: Option<&str>, + installed_channel: Option<&str>, + acting: bool, +) -> Option<(String, Option)> { + let text = |key: &str, args: &[(&str, &str)]| localizer.format(key, args).value; + match available_channel.map(rabbit_core::package::split_channel) { + Some(("pr", Some(number))) => Some(( + text("wizard-package-channel-pr", &[("number", number)]), + acting.then(|| { + text( + "wizard-package-channel-pr-details", + &[("package", package), ("number", number)], + ) + }), + )), + Some(_) => Some(( + text("wizard-package-channel-dev", &[]), + acting.then(|| { + text( + "wizard-package-channel-dev-details", + &[("package", package)], + ) + }), + )), + // Installed from a pre-release, and this run puts the regular + // release back - which is exactly what leaving expert mode does. + None if installed_channel.is_some() && acting => Some(( + text("wizard-package-channel-back", &[]), + Some(text( + "wizard-package-channel-back-details", + &[("package", package)], + )), + )), + None => None, + } +} + +fn row_installs(action: PlanActionKind) -> bool { + matches!(action, PlanActionKind::Install | PlanActionKind::Update) +} + +/// A package row's one-line label: package, action and versions, then a tag +/// when the build is not the regular release, or this run takes it back to +/// one. Everything that changes a row's action rebuilds the label through +/// here, so ticking or unticking a row never drops the tag. +fn package_row_summary(localizer: &Localizer, row: &PackageRow) -> String { + let summary = localizer + .format( + "wizard-package-row", + &[ + ("package", row.display_name.as_str()), + ("action", row.action_label.as_str()), + ("installed", row.installed_version.as_str()), + ("available", row.available_version.as_str()), + ], + ) + .value; + match package_channel_note( + localizer, + &row.display_name, + row.available_channel.as_deref(), + row.installed_channel.as_deref(), + row_installs(row.action), + ) { + Some((tag, _)) => { + localizer + .format( + "wizard-package-row-channel-suffix", + &[("row", summary.as_str()), ("channel", tag.as_str())], + ) + .value + } + None => summary, + } +} + +#[allow(clippy::too_many_arguments)] // Row building needs the full wizard context. +pub(crate) fn package_rows( + localizer: &Localizer, + text: &WizardText, + platform: Platform, + architecture: Architecture, + package_specs: &[PackageSpec], + actions: &[PlanAction], + available_packages: &[AvailablePackage], + host: &HostCapabilities, + declined: &std::collections::BTreeSet, + installed_channels: &rabbit_core::package::PackageChannels, +) -> Vec { + let channel_by_id: BTreeMap<_, _> = available_packages + .iter() + .filter_map(|available| { + available + .channel + .as_deref() + .map(|channel| (available.package_id.as_str(), channel)) + }) + .collect(); + let specs_by_id: BTreeMap<_, _> = package_specs + .iter() + .map(|spec| (spec.id.as_str(), spec)) + .collect(); + let whats_new_by_id: BTreeMap<_, _> = available_packages + .iter() + .filter_map(|available| { + available + .whats_new + .as_deref() + .map(|notes| (available.package_id.as_str(), notes)) + }) + .collect(); + actions + .iter() + .map(|action| { + let spec = specs_by_id.get(action.package_id.as_str()).copied(); + let display_name = spec + .map(|spec| localizer.text(&spec.display_name_key).value) + .unwrap_or_else(|| action.package_id.clone()); + let description = spec + .map(|spec| localizer.text(&spec.display_description_key)) + .filter(|text| !text.missing) + .map(|text| text.value) + .unwrap_or_default(); + let installed_version = version_text(localizer, action.installed_version.as_ref()); + let available_version = version_text(localizer, action.available_version.as_ref()); + // Auto-tick rule: + // - Update → always ticked (the package is already on disk; the + // user opted into having it, so keep it current by default). + // - Install → only ticked when the spec is *effectively* + // recommended. That's the manifest baseline OR a host-conditional + // escalation (e.g. ReaKontrol's `recommended_when: + // komplete_kontrol_installed`), so non-recommended packages + // (FFmpeg, plain ReaKontrol on a non-KK host) stay unchecked. + // - Keep → never ticked (nothing to do). + // A language pack for the language RABBIT is running in counts + // as recommended, so a Spanish user gets the Spanish pack ticked + // without hunting for it. Packs for other languages stay listed + // but unticked; nothing is offered for English, since REAPER is + // already English. + // Has the user turned this package down on this install? It + // stays listed and tickable either way — a refusal suppresses + // the automatic tick, never the package itself. + let refused = spec + .is_some_and(|spec| spec.remember_opt_out && declined.contains(&action.package_id)); + let recommended = spec + .map(|spec| { + rabbit_core::package::effective_recommended(spec, host) + || rabbit_core::package::matches_ui_language( + spec, + localizer.active_locale(), + ) + }) + .unwrap_or(false); + let initially_selected = match action.action { + // Updates are ticked by default because a package you have + // should stay current — but not once you have said no to it. + // Someone who stopped using a language pack and turned its + // update down would otherwise be re-offered it, ticked, + // every time the translators publish a fix. + PlanActionKind::Update => !refused, + PlanActionKind::Install => recommended && !refused, + PlanActionKind::Keep => false, + }; + // When the auto-tick rule leaves an Install/Update row unticked, + // mirror what `apply_checkbox_state_to_package_row(checked=false)` + // does on a manual untick: flip the *displayed* action to Keep so + // the row label / summary match the checkbox state. `original_action` + // still records the plan's decision so re-ticking restores Install/ + // Update without losing the underlying intent. + let initial_action = if initially_selected { + action.action + } else { + PlanActionKind::Keep + }; + let action_label = action_label(localizer, initial_action); + // Say in the row itself when a build is not the regular release, + // or when this run takes a pre-release back to the regular one. + // The plan's own reason text stays out of the wizard, and the + // unlock dialog is long gone by the time a later run switches a + // package back - so the row is the one place it is always heard. + let available_channel = channel_by_id + .get(action.package_id.as_str()) + .map(|channel| channel.to_string()); + let installed_channel = installed_channels.get(&action.package_id).cloned(); + let channel_sentence = package_channel_note( + localizer, + &display_name, + available_channel.as_deref(), + installed_channel.as_deref(), + row_installs(initial_action), + ) + .and_then(|(_, sentence)| sentence); + let (handling_summary, manual_attention_expected) = + package_handling_summary(text, &action.package_id, platform, architecture); + // Compose the details text shown in the wizard's package + // details pane. The localized description follows the summary + // line so users can see what a package is before deciding + // what to do with it. The plan-reason string ("Installed + // version is current or newer…") and the handling-summary / + // automation-kind detail are not localized for end users — + // both stay on PackageRow as structured fields for the saved + // report and stay out of the wizard pane. + let mut row = PackageRow { + package_id: action.package_id.clone(), + summary: String::new(), + details: String::new(), + display_name: display_name.clone(), + description: description.clone(), + selected: initially_selected, + installed_version, + available_version, + action: initial_action, + action_label, + original_action: action.action, + reason: action.reason.clone(), + handling_summary, + manual_attention_expected, + available_for_target: true, + unavailability_reason: None, + category: spec.map(|spec| spec.category).unwrap_or_default(), + requires_standard_install: spec + .map(|spec| spec.requires_standard_install) + .unwrap_or(false), + available_channel, + installed_channel, + }; + row.summary = package_row_summary(localizer, &row); + let summary = &row.summary; + let details = match &channel_sentence { + Some(sentence) => format!("{summary}\n\n{sentence}"), + None => summary.clone(), + }; + let details = if description.is_empty() { + details + } else { + format!("{details}\n\n{description}") + }; + // The available version's What's-New notes (resolved by the + // deferred version check for packages that declare a source) + // follow under a localized heading, so the pane answers "what + // changed upstream?" without a trip to the package's website. + let details = match whats_new_by_id.get(action.package_id.as_str()) { + Some(notes) => { + let heading = localizer + .format( + "wizard-package-whats-new-heading", + &[("package", display_name.as_str())], + ) + .value; + format!("{details}\n\n{heading}\n{notes}") + } + None => details, + }; + row.details = details; + row + }) + .collect() +} + +/// Will this package be on disk after the current wizard run completes? +/// +/// Two ways the answer is yes: +/// +/// 1. The package was already on disk before the wizard opened. We read +/// that from `original_action` — the plan only emits `Install` for +/// packages that aren't installed, so anything else (`Update`, `Keep`) +/// means the package was on disk when the wizard built its rows. +/// 2. The user has the row ticked and its current action stages it to +/// disk (`Install` or `Update`). `Keep` doesn't move bytes. +/// +/// Driving the configuration-row dependency check off this predicate +/// keeps gating coherent when a row's `selected` and `action` disagree — +/// e.g. a non-recommended `Install` row arrives unticked-by-default +/// (so `selected=false` but `action=Install`), and a user-unticked row +/// is flipped to `Keep` (so `selected=false` but `action=Keep`). Both +/// must read as "won't be on disk", which the simpler action-only check +/// got wrong for the latter and our new default broke for the former. +/// Is this package being written to disk *by this run* — as opposed to +/// merely being there already? Steps that change existing configuration +/// rather than adding to it auto-tick off this, not off +/// [`package_row_will_land_on_disk`]. +pub(crate) fn package_row_installs_now(row: &PackageRow) -> bool { + row.available_for_target + && row.selected + && matches!(row.action, PlanActionKind::Install | PlanActionKind::Update) +} + +pub(crate) fn package_row_will_land_on_disk(row: &PackageRow) -> bool { + if !row.available_for_target { + return false; + } + let was_installed = !matches!(row.original_action, PlanActionKind::Install); + let installing_now = + matches!(row.action, PlanActionKind::Install | PlanActionKind::Update) && row.selected; + was_installed || installing_now +} + +pub(crate) fn package_handling_summary( + text: &WizardText, + package_id: &str, + platform: Platform, + architecture: Architecture, +) -> (String, bool) { + match package_automation_support(package_id, platform, architecture) { + PackageAutomationSupport::Direct => (text.package_handling_automatic.clone(), false), + PackageAutomationSupport::AvailableUnattended(_) => { + (text.package_handling_unattended.clone(), false) + } + PackageAutomationSupport::PlannedUnattended(_) => { + (text.package_handling_planned.clone(), true) + } + PackageAutomationSupport::Unavailable => (text.package_handling_unavailable.clone(), true), + } +} + +pub(crate) fn package_display_name(model: &WizardModel, package_id: &str) -> String { + if let Ok(localizer) = localizer_from_options(&model.bootstrap_options) + && let Some(spec) = builtin_package_specs(model.platform) + .into_iter() + .find(|spec| spec.id == package_id) + { + return localizer.text(&spec.display_name_key).value; + } + + builtin_package_specs(model.platform) + .into_iter() + .find(|spec| spec.id == package_id) + .map(|spec| spec.display_name) + .unwrap_or_else(|| package_id.to_string()) +} diff --git a/crates/rabbit-ui-wxdragon/src/wizard/request.rs b/crates/rabbit-ui-wxdragon/src/wizard/request.rs new file mode 100644 index 0000000..7e07113 --- /dev/null +++ b/crates/rabbit-ui-wxdragon/src/wizard/request.rs @@ -0,0 +1,137 @@ +//! Turns the user's page selections into a [`WizardInstallRequest`]. + +use rabbit_core::artifact::default_cache_dir; +use rabbit_core::package::{PACKAGE_OSARA, builtin_package_specs}; +use rabbit_core::plan::PlanActionKind; +use rabbit_core::{RabbitError, Result}; + +use super::configuration::selected_configuration_step_ids; +use super::model::{ + OsaraKeymapChoice, PackageRow, TargetRow, WizardInstallOptions, WizardInstallRequest, + WizardModel, +}; +use super::packages::package_ids_for_rows; + +pub fn install_request_from_model( + model: &WizardModel, + selected_target_index: Option, + selected_package_indices: &[usize], + options: WizardInstallOptions, +) -> Result { + let target = selected_target_index + .and_then(|index| model.target_rows.get(index)) + .ok_or_else(|| RabbitError::PreflightFailed { + message: "No REAPER installation target was selected.".to_string(), + })?; + + install_request_from_target(model, target, selected_package_indices, options) +} + +pub fn install_request_from_target( + model: &WizardModel, + target: &TargetRow, + selected_package_indices: &[usize], + options: WizardInstallOptions, +) -> Result { + let configuration_step_ids = selected_configuration_step_ids(&model.configuration_rows); + install_request_from_target_and_rows( + model, + target, + &model.package_rows, + selected_package_indices, + configuration_step_ids, + options, + ) +} + +pub fn install_request_from_target_and_rows( + model: &WizardModel, + target: &TargetRow, + package_rows: &[PackageRow], + selected_package_indices: &[usize], + configuration_step_ids: Vec, + options: WizardInstallOptions, +) -> Result { + if !target.writable { + return Err(RabbitError::PreflightFailed { + message: format!( + "Target resource path is not writable: {}", + target.path.display() + ), + }); + } + + let package_ids = package_ids_for_rows(package_rows, selected_package_indices); + if package_ids.is_empty() && configuration_step_ids.is_empty() { + return Err(RabbitError::PreflightFailed { + message: "No package or configuration step was selected for installation or update." + .to_string(), + }); + } + let osara_selected = package_ids.iter().any(|id| id == PACKAGE_OSARA); + // A row is "force reinstall" when the user has the box checked but + // the original detection said the package was already current — i.e. + // the plan would normally Keep it, but the user explicitly opted in + // to a re-run. Detected via `original_action == Keep` on a checked + // row (the toggle helper promotes the displayed `action` to Update + // in that case, but `original_action` keeps the plan-time decision + // for exactly this disambiguation). + let force_reinstall_packages = selected_package_indices + .iter() + .filter_map(|index| { + let row = package_rows.get(*index)?; + if row.original_action == PlanActionKind::Keep { + Some(row.package_id.clone()) + } else { + None + } + }) + .collect(); + + // Only rows that actually offered something carry a verdict: ticked is + // a yes, unticked is a no. A `Keep` row offered nothing, so it says + // nothing either way. + let remembers_opt_out: std::collections::BTreeSet = + builtin_package_specs(model.platform) + .into_iter() + .filter(|spec| spec.remember_opt_out) + .map(|spec| spec.id) + .collect(); + let mut declined_packages = Vec::new(); + let mut accepted_packages = Vec::new(); + for (index, row) in package_rows.iter().enumerate() { + if !remembers_opt_out.contains(&row.package_id) { + continue; + } + if selected_package_indices.contains(&index) { + accepted_packages.push(row.package_id.clone()); + } else if row.original_action != PlanActionKind::Keep { + declined_packages.push(row.package_id.clone()); + } + } + + Ok(WizardInstallRequest { + resource_path: target.path.clone(), + package_ids, + platform: model.platform, + architecture: target.architecture, + portable: target.portable, + target_app_path: Some(target.planned_app_path.clone()), + dry_run: options.dry_run, + allow_reaper_running: options.allow_reaper_running, + stage_unsupported: options.stage_unsupported, + osara_keymap_choice: if osara_selected { + options.osara_keymap_choice + } else { + OsaraKeymapChoice::PreserveCurrent + }, + package_variants: options.package_variants.clone(), + reaper_language_package: options.reaper_language_package.clone(), + cache_dir: options.cache_dir.unwrap_or_else(default_cache_dir), + force_reinstall_packages, + configuration_step_ids, + declined_packages, + accepted_packages, + package_channels: options.package_channels.clone(), + }) +} diff --git a/crates/rabbit-ui-wxdragon/src/wizard/review.rs b/crates/rabbit-ui-wxdragon/src/wizard/review.rs new file mode 100644 index 0000000..40000df --- /dev/null +++ b/crates/rabbit-ui-wxdragon/src/wizard/review.rs @@ -0,0 +1,245 @@ +//! Review page text: the summary lines and manual-step notes shown before +//! the install starts. + +use rabbit_core::artifact::expected_artifact_kind; +use rabbit_core::localization::Localizer; +use rabbit_core::operation::preview_manual_instruction; +use rabbit_core::plan::PlanActionKind; +use rabbit_core::resource::{ResourceInitOptions, initialize_resource_path}; +use rabbit_core::setup::setup_requires_extension_support; + +use super::model::{OsaraKeymapChoice, PackageRow, TargetRow, WizardModel, WizardReviewPreview}; +use super::packages::{osara_selected_for_rows, package_ids_for_rows}; + +pub fn review_lines_for_indices( + model: &WizardModel, + selected_target_index: Option, + selected_package_indices: &[usize], +) -> Vec { + let target = selected_target_index.and_then(|index| model.target_rows.get(index)); + review_lines_for_target(model, target, selected_package_indices) +} + +pub fn review_lines_for_target( + model: &WizardModel, + target: Option<&TargetRow>, + selected_package_indices: &[usize], +) -> Vec { + review_lines_for_package_rows( + model, + target, + selected_package_indices, + &model.package_rows, + &model.notes, + ) +} + +pub fn review_lines_for_package_rows( + model: &WizardModel, + target: Option<&TargetRow>, + selected_package_indices: &[usize], + package_rows: &[PackageRow], + notes: &[String], +) -> Vec { + let mut lines = Vec::new(); + if let Some(target) = target { + lines.push(format!( + "{}: {}", + model.text.review_target_prefix, + target.path.display() + )); + } else { + lines.push(model.text.review_no_target.clone()); + } + + let package_ids = package_ids_for_rows(package_rows, selected_package_indices); + if package_ids.is_empty() { + lines.push(model.text.review_no_package.clone()); + } else { + for package_id in package_ids { + if let Some(package) = package_rows + .iter() + .find(|package| package.package_id == package_id) + { + lines.push(format!( + "{}: {}", + package.display_name, package.action_label + )); + } + } + } + + lines.extend(notes.iter().cloned()); + lines +} + +pub fn build_review_preview_for_package_rows( + model: &WizardModel, + target: Option<&TargetRow>, + selected_package_indices: &[usize], + package_rows: &[PackageRow], + notes: &[String], + osara_keymap_choice: OsaraKeymapChoice, +) -> WizardReviewPreview { + let Some(target) = target else { + return WizardReviewPreview { + lines: vec![model.text.review_no_target.clone()], + can_install: false, + }; + }; + + let mut lines = vec![format!( + "{}: {}", + model.text.review_target_prefix, + target.path.display() + )]; + + let mut can_install = !selected_package_indices.is_empty(); + + // Run the resource-path preflight to surface fatal blockers (read-only + // target, REAPER currently running, etc.) — those still need to land in + // the GUI summary so the user knows install is blocked. Successful + // resource-init details (the long "Create directory…" / "Create file…" + // list) are now report-only; the GUI just notes that backups will be + // taken if needed. + match initialize_resource_path( + &target.path, + &ResourceInitOptions { + dry_run: true, + portable: target.portable, + include_extension_support_dirs: target.portable + || setup_requires_extension_support( + &selected_package_indices + .iter() + .filter_map(|index| package_rows.get(*index)) + .map(|package| package.package_id.clone()) + .collect::>(), + ), + allow_reaper_running: false, + target_app_path: Some(target.planned_app_path.clone()), + }, + ) { + Ok(_) => {} + Err(error) => { + can_install = false; + lines.push(format!("{}: {}", model.text.review_preflight_prefix, error)); + } + } + + lines.push(String::new()); + lines.push(model.text.review_package_heading.clone()); + if selected_package_indices.is_empty() { + lines.push(model.text.review_no_package.clone()); + } else { + for index in selected_package_indices { + if let Some(package) = package_rows.get(*index) { + lines.push(package.summary.clone()); + } + } + } + + if osara_selected_for_rows(package_rows, selected_package_indices) { + lines.push(String::new()); + lines.push(model.text.review_osara_keymap_heading.clone()); + lines.push(match osara_keymap_choice { + OsaraKeymapChoice::PreserveCurrent => model.text.review_osara_keymap_preserve.clone(), + OsaraKeymapChoice::ReplaceCurrent => model.text.review_osara_keymap_replace.clone(), + }); + } + + if !notes.is_empty() { + lines.push(String::new()); + lines.push(model.text.review_notes_heading.clone()); + lines.extend(notes.iter().cloned()); + } + + WizardReviewPreview { lines, can_install } +} + +pub fn package_requires_manual_attention( + _model: &WizardModel, + package: &PackageRow, + _osara_keymap_choice: OsaraKeymapChoice, +) -> bool { + matches!( + package.action, + PlanActionKind::Install | PlanActionKind::Update + ) && package.manual_attention_expected +} + +pub fn manual_attention_handling_summary( + _model: &WizardModel, + package: &PackageRow, + _osara_keymap_choice: OsaraKeymapChoice, +) -> String { + package.handling_summary.clone() +} + +pub fn preview_manual_instruction_lines( + model: &WizardModel, + target: &TargetRow, + package: &PackageRow, + osara_keymap_choice: OsaraKeymapChoice, +) -> Vec { + let Ok(kind) = expected_artifact_kind(&package.package_id, model.platform, model.architecture) + else { + return Vec::new(); + }; + let instruction = preview_manual_instruction( + &package.package_id, + kind, + &target.path, + Some(&target.planned_app_path), + matches!(osara_keymap_choice, OsaraKeymapChoice::ReplaceCurrent), + ); + let mut lines = instruction + .steps + .into_iter() + .map(|step| format!(" {step}")) + .collect::>(); + lines.extend( + instruction + .notes + .into_iter() + .map(|note| format!(" Note: {note}")), + ); + lines +} + +pub(crate) fn review_lines( + localizer: &Localizer, + target_rows: &[TargetRow], + package_rows: &[PackageRow], + notes: &[String], +) -> Vec { + let mut lines = Vec::new(); + if let Some(target) = target_rows.iter().find(|target| target.selected) { + lines.push( + localizer + .format( + "wizard-review-target", + &[("path", &target.path.display().to_string())], + ) + .value, + ); + } else { + lines.push(localizer.text("wizard-review-no-target").value); + } + + for package in package_rows { + lines.push( + localizer + .format( + "wizard-review-package", + &[ + ("package", package.display_name.as_str()), + ("action", package.action_label.as_str()), + ], + ) + .value, + ); + } + + lines.extend(notes.iter().cloned()); + lines +} diff --git a/crates/rabbit-ui-wxdragon/src/wizard/self_update.rs b/crates/rabbit-ui-wxdragon/src/wizard/self_update.rs new file mode 100644 index 0000000..f69fd5a --- /dev/null +++ b/crates/rabbit-ui-wxdragon/src/wizard/self_update.rs @@ -0,0 +1,185 @@ +//! Self-update check and apply, plus the summaries the wizard shows for +//! both. + +use rabbit_core::localization::Localizer; +use rabbit_core::model::Platform; +use rabbit_core::self_update::{ + ApplySelfUpdateOptions, DEFAULT_SELF_UPDATE_MANIFEST_URL, SelfUpdateApplyReport, + SelfUpdateCheckReport, apply_self_update, check_self_update, default_self_update_staging_dir, + resolve_self_update_release_notes, stage_self_update_with_progress, +}; +use rabbit_core::{RabbitError, Result}; + +use super::expert_mode::EXPERT_MODE_ENV; + +pub fn run_wizard_self_update_check() -> Result { + let platform = Platform::current().ok_or(RabbitError::UnsupportedPlatform)?; + check_self_update(platform, DEFAULT_SELF_UPDATE_MANIFEST_URL) +} + +/// What's-New notes for a pending RABBIT update, covering every release +/// between the running version and the latest one. +/// +/// Called from the same startup worker thread as +/// [`run_wizard_self_update_check`], never from the UI thread: it performs an +/// HTTP round-trip, and the wizard must stay responsive while it runs. The +/// result is best-effort — `None` simply means the prompt falls back to its +/// plain version-to-version wording. +pub fn run_wizard_self_update_release_notes(report: &SelfUpdateCheckReport) -> Option { + resolve_self_update_release_notes(report) +} + +/// Download-then-install for RABBIT itself, reporting both phases through +/// `progress`. +/// +/// Runs on a worker thread, so `progress` is invoked off the UI thread and +/// the caller is responsible for forwarding events onto it — the same +/// contract the package install pipeline has. Pass +/// `ProgressReporter::noop()` to run it silently. +/// +/// [`ProgressReporter`]: rabbit_core::progress::ProgressReporter +pub fn run_wizard_self_update_apply( + progress: &rabbit_core::progress::ProgressReporter, +) -> Result { + let platform = Platform::current().ok_or(RabbitError::UnsupportedPlatform)?; + let staging_dir = default_self_update_staging_dir(); + let stage = stage_self_update_with_progress( + platform, + DEFAULT_SELF_UPDATE_MANIFEST_URL, + &staging_dir, + progress, + )?; + apply_self_update( + &stage, + &ApplySelfUpdateOptions { + install_root: None, + install_target_basename: None, + progress: Some(progress.clone()), + }, + ) +} + +/// Relaunch RABBIT after a self-update, keeping expert mode as it was: +/// nothing saves it, so the new process only has it if we pass it on. +pub fn relaunch_rabbit_after_apply(expert_mode: bool) -> Result { + rabbit_core::self_update::relaunch_current_executable_with(&[( + EXPERT_MODE_ENV, + expert_mode.then_some("1"), + )]) +} + +pub fn format_self_update_check_summary( + localizer: &Localizer, + report: &SelfUpdateCheckReport, +) -> String { + let current = report.current_version.to_string(); + let latest = report.latest_version.to_string(); + if report.update_available { + localizer + .format( + "self-update-status-update-available", + &[ + ("current", current.as_str()), + ("latest", latest.as_str()), + ("channel", report.channel.as_str()), + ], + ) + .value + } else { + localizer + .format( + "self-update-status-up-to-date", + &[ + ("current", current.as_str()), + ("channel", report.channel.as_str()), + ], + ) + .value + } +} + +pub fn format_self_update_apply_summary( + localizer: &Localizer, + report: &SelfUpdateApplyReport, +) -> String { + let version = report.stage.check.latest_version.to_string(); + if report.replaced_files.is_empty() { + return localizer + .format( + "self-update-apply-no-files-replaced", + &[("version", version.as_str())], + ) + .value; + } + + let count = report.replaced_files.len().to_string(); + let install_root = report.install_root.display().to_string(); + let mut summary = localizer + .format( + "self-update-apply-replaced-summary", + &[ + ("count", count.as_str()), + ("root", install_root.as_str()), + ("version", version.as_str()), + ], + ) + .value; + if let Some(signature_summary) = format_signature_verdict_summary(localizer, report) { + summary.push(' '); + summary.push_str(&signature_summary); + } + summary +} + +fn format_signature_verdict_summary( + localizer: &Localizer, + report: &SelfUpdateApplyReport, +) -> Option { + use rabbit_core::signature::SignatureVerdict; + + if report.signature_verdicts.is_empty() { + return None; + } + let mut signed = 0usize; + let mut unsigned = 0usize; + for record in &report.signature_verdicts { + match record.verdict { + SignatureVerdict::Signed { .. } => signed += 1, + SignatureVerdict::Unsigned { .. } => unsigned += 1, + SignatureVerdict::Invalid { .. } => {} + } + } + let signed_str = signed.to_string(); + let unsigned_str = unsigned.to_string(); + let value = match (signed, unsigned) { + (0, 0) => return None, + (_, 0) => { + localizer + .format( + "self-update-apply-signature-summary-signed-only", + &[("signed", signed_str.as_str())], + ) + .value + } + (0, _) => { + localizer + .format( + "self-update-apply-signature-summary-unsigned-only", + &[("unsigned", unsigned_str.as_str())], + ) + .value + } + _ => { + localizer + .format( + "self-update-apply-signature-summary-mixed", + &[ + ("signed", signed_str.as_str()), + ("unsigned", unsigned_str.as_str()), + ], + ) + .value + } + }; + Some(value) +} diff --git a/crates/rabbit-ui-wxdragon/src/wizard/summary.rs b/crates/rabbit-ui-wxdragon/src/wizard/summary.rs new file mode 100644 index 0000000..d703ab1 --- /dev/null +++ b/crates/rabbit-ui-wxdragon/src/wizard/summary.rs @@ -0,0 +1,465 @@ +//! Renders a finished [`SetupReport`] into the done page's summary text. + +use rabbit_core::localization::Localizer; +use rabbit_core::operation::{PackageOperationStatus, PlannedExecutionKind, PlannedExecutionPlan}; +use rabbit_core::resource::ResourceInitActionKind; +use rabbit_core::setup::SetupReport; + +use super::bootstrap::localizer_from_options; +use super::labels::{ + action_label_for_summary, architecture_label_for_summary, + configuration_status_label_for_summary, format_localized_message, + localized_configuration_message, localized_configuration_step_name, + localized_package_operation_message, planned_execution_runner_label, status_label_for_summary, +}; +use super::model::{WizardInstallSummary, WizardModel}; +use super::packages::package_display_name; + +pub fn summarize_setup_report(model: &WizardModel, report: &SetupReport) -> WizardInstallSummary { + let localizer = localizer_from_options(&model.bootstrap_options).ok(); + let created_resources = report + .resource_init + .actions + .iter() + .filter(|action| action.action == ResourceInitActionKind::Created) + .count(); + let installed_or_checked = report + .package_operation + .items + .iter() + .filter(|item| item.status == PackageOperationStatus::InstalledOrChecked) + .count(); + let skipped_current = report + .package_operation + .items + .iter() + .filter(|item| item.status == PackageOperationStatus::SkippedCurrent) + .count(); + let manual_items = report + .package_operation + .items + .iter() + .filter(|item| matches!(item.status, PackageOperationStatus::DeferredUnattended)) + .count(); + let failed_items = report + .package_operation + .items + .iter() + .filter(|item| { + matches!( + item.status, + PackageOperationStatus::Failed | PackageOperationStatus::SkippedDependencyFailed + ) + }) + .count(); + let any_antivirus_block = report.package_operation.items.iter().any(|item| { + matches!( + item.message_code, + rabbit_core::operation::PackageOperationMessage::InstallFailed { + antivirus_block: true, + .. + } + ) + }); + + let architecture_label = architecture_label_for_summary(model.architecture); + let mut detail_lines = vec![ + format_localized_message( + localizer.as_ref(), + "wizard-summary-target", + &[("path", report.resource_path.display().to_string())], + format!("Target: {}", report.resource_path.display()), + ), + format_localized_message( + localizer.as_ref(), + "wizard-summary-architecture", + &[("architecture", architecture_label.clone())], + format!("Architecture: {architecture_label}"), + ), + format_localized_message( + localizer.as_ref(), + "wizard-summary-dry-run", + &[( + "value", + if report.dry_run { + model.text.common_yes.clone() + } else { + model.text.common_no.clone() + }, + )], + format!( + "Dry run: {}", + if report.dry_run { + &model.text.common_yes + } else { + &model.text.common_no + } + ), + ), + format_localized_message( + localizer.as_ref(), + "wizard-summary-resource-items-created", + &[("count", created_resources.to_string())], + format!("Resource items created: {created_resources}"), + ), + format_localized_message( + localizer.as_ref(), + "wizard-summary-packages-installed-or-checked", + &[("count", installed_or_checked.to_string())], + format!("Packages installed or checked: {installed_or_checked}"), + ), + format_localized_message( + localizer.as_ref(), + "wizard-summary-packages-current", + &[("count", skipped_current.to_string())], + format!("Packages already current: {skipped_current}"), + ), + format_localized_message( + localizer.as_ref(), + "wizard-summary-packages-manual", + &[("count", manual_items.to_string())], + format!("Packages requiring manual attention: {manual_items}"), + ), + ]; + + if let Some(install_report) = &report.package_operation.install_report { + let backup_paths = install_report + .actions + .iter() + .filter_map(|action| action.backup_path.as_ref()) + .collect::>(); + if !backup_paths.is_empty() + || install_report.receipt_backup_path.is_some() + || install_report.backup_manifest_path.is_some() + { + detail_lines.push(format_localized_message( + localizer.as_ref(), + "wizard-summary-backup-files-created", + &[("count", backup_paths.len().to_string())], + format!("Backup files created: {}", backup_paths.len()), + )); + for path in backup_paths { + detail_lines.push(format_localized_message( + localizer.as_ref(), + "wizard-summary-backup-file", + &[("path", path.display().to_string())], + format!("Backup file: {}", path.display()), + )); + } + if let Some(path) = &install_report.receipt_backup_path { + detail_lines.push(format_localized_message( + localizer.as_ref(), + "wizard-summary-receipt-backup", + &[("path", path.display().to_string())], + format!("Receipt backup: {}", path.display()), + )); + } + if let Some(path) = &install_report.backup_manifest_path { + detail_lines.push(format_localized_message( + localizer.as_ref(), + "wizard-summary-backup-manifest", + &[("path", path.display().to_string())], + format!("Backup manifest: {}", path.display()), + )); + } + } + } + + let item_backup_paths = report + .package_operation + .items + .iter() + .flat_map(|item| item.backup_paths.iter()) + .collect::>(); + let item_backup_manifest_paths = report + .package_operation + .items + .iter() + .filter_map(|item| item.backup_manifest_path.as_ref()) + .collect::>(); + let package_receipt_backup_path = report.package_operation.receipt_backup_path.as_ref(); + let package_receipt_backup_manifest_path = report + .package_operation + .receipt_backup_manifest_path + .as_ref(); + if report.package_operation.install_report.is_none() + && (!item_backup_paths.is_empty() + || !item_backup_manifest_paths.is_empty() + || package_receipt_backup_path.is_some() + || package_receipt_backup_manifest_path.is_some()) + { + detail_lines.push(format_localized_message( + localizer.as_ref(), + "wizard-summary-backup-files-created", + &[( + "count", + (item_backup_paths.len() + usize::from(package_receipt_backup_path.is_some())) + .to_string(), + )], + format!( + "Backup files created: {}", + item_backup_paths.len() + usize::from(package_receipt_backup_path.is_some()) + ), + )); + } + for path in item_backup_paths { + detail_lines.push(format_localized_message( + localizer.as_ref(), + "wizard-summary-backup-file", + &[("path", path.display().to_string())], + format!("Backup file: {}", path.display()), + )); + } + for path in item_backup_manifest_paths { + detail_lines.push(format_localized_message( + localizer.as_ref(), + "wizard-summary-backup-manifest", + &[("path", path.display().to_string())], + format!("Backup manifest: {}", path.display()), + )); + } + if let Some(path) = package_receipt_backup_path { + detail_lines.push(format_localized_message( + localizer.as_ref(), + "wizard-summary-receipt-backup", + &[("path", path.display().to_string())], + format!("Receipt backup: {}", path.display()), + )); + } + if let Some(path) = package_receipt_backup_manifest_path { + detail_lines.push(format_localized_message( + localizer.as_ref(), + "wizard-summary-backup-manifest", + &[("path", path.display().to_string())], + format!("Backup manifest: {}", path.display()), + )); + } + + for item in &report.package_operation.items { + let package_name = package_display_name(model, &item.package_id); + let localized_message = localizer + .as_ref() + .map(|localizer| { + localized_package_operation_message(localizer, &item.message_code, &item.message) + }) + .unwrap_or_else(|| item.message.clone()); + detail_lines.push(format_localized_message( + localizer.as_ref(), + "wizard-summary-package-message", + &[ + ("package", package_name.clone()), + ("message", localized_message.clone()), + ], + format!("{package_name}: {localized_message}"), + )); + let plan_action_label = action_label_for_summary(localizer.as_ref(), item.plan_action); + detail_lines.push(format_localized_message( + localizer.as_ref(), + "wizard-summary-package-plan-action", + &[("action", plan_action_label.clone())], + format!(" Plan action: {plan_action_label}"), + )); + let status_label = status_label_for_summary(localizer.as_ref(), item.status); + detail_lines.push(format_localized_message( + localizer.as_ref(), + "wizard-summary-package-status", + &[("status", status_label.clone())], + format!(" Status: {status_label}"), + )); + // Surface the installed version so users can confirm the install + // landed without having to scroll through the install report. The + // artifact descriptor's version is what RABBIT chose to install (and, + // for an InstalledOrChecked status, what now lives on disk per the + // receipt the operation pipeline just wrote). + if matches!( + item.status, + PackageOperationStatus::InstalledOrChecked | PackageOperationStatus::SkippedCurrent + ) { + let installed_version = item.artifact.version.to_string(); + detail_lines.push(format_localized_message( + localizer.as_ref(), + "wizard-summary-package-installed-version", + &[("version", installed_version.clone())], + format!(" Installed version: {installed_version}"), + )); + } + if let Some(plan) = &item.planned_execution { + detail_lines.push(format_localized_message( + localizer.as_ref(), + "wizard-summary-planned-execution-title", + &[], + "Planned unattended execution:".to_string(), + )); + let runner = planned_execution_runner_label(localizer.as_ref(), plan.kind); + detail_lines.push(format_localized_message( + localizer.as_ref(), + "wizard-summary-planned-execution-runner", + &[("runner", runner.clone())], + format!(" Runner: {runner}"), + )); + detail_lines.push(format_localized_message( + localizer.as_ref(), + "wizard-summary-planned-execution-artifact", + &[("artifact", plan.artifact_location.clone())], + format!(" Artifact: {}", plan.artifact_location), + )); + detail_lines.extend(planned_execution_program_line(localizer.as_ref(), plan)); + if !plan.arguments.is_empty() { + let arguments = plan.arguments.join(" "); + detail_lines.push(format_localized_message( + localizer.as_ref(), + "wizard-summary-planned-execution-arguments", + &[("arguments", arguments.clone())], + format!(" Arguments: {arguments}"), + )); + } + if let Some(path) = &plan.working_directory { + detail_lines.push(format_localized_message( + localizer.as_ref(), + "wizard-summary-planned-execution-working-directory", + &[("path", path.display().to_string())], + format!(" Working directory: {}", path.display()), + )); + } + detail_lines.extend(plan.verification_paths.iter().map(|path| { + format_localized_message( + localizer.as_ref(), + "wizard-summary-planned-execution-verify", + &[("path", path.display().to_string())], + format!(" Verify: {}", path.display()), + ) + })); + } + if let Some(manual) = &item.manual_instruction { + detail_lines.push(format_localized_message( + localizer.as_ref(), + "wizard-summary-manual-title", + &[("title", manual.title.clone())], + format!("{}:", manual.title), + )); + detail_lines.extend(manual.steps.iter().map(|step| { + format_localized_message( + localizer.as_ref(), + "wizard-summary-manual-step", + &[("step", step.clone())], + format!(" {step}"), + ) + })); + detail_lines.extend(manual.notes.iter().map(|note| { + format_localized_message( + localizer.as_ref(), + "wizard-summary-manual-note", + &[("note", note.clone())], + format!(" Note: {note}"), + ) + })); + } + } + + for step_report in &report.configuration_steps { + let step_name = localized_configuration_step_name(localizer.as_ref(), &step_report.step_id); + let localized_message = localizer + .as_ref() + .map(|localizer| { + localized_configuration_message( + localizer, + &step_report.message_code, + &step_report.message, + ) + }) + .unwrap_or_else(|| step_report.message.clone()); + detail_lines.push(format_localized_message( + localizer.as_ref(), + "wizard-summary-configuration-message", + &[ + ("step", step_name.clone()), + ("message", localized_message.clone()), + ], + format!("{step_name}: {localized_message}"), + )); + let status_label = + configuration_status_label_for_summary(localizer.as_ref(), step_report.status); + detail_lines.push(format_localized_message( + localizer.as_ref(), + "wizard-summary-configuration-status", + &[("status", status_label.clone())], + format!(" Status: {status_label}"), + )); + } + + // When some packages failed, lead with a "completed with errors" line + // (and, for the antivirus false-positive case, the localized how-to- + // allow-it guidance) instead of the plain finished line, so the result + // page names the problem up front. A skipped-because-REAPER-failed + // package counts toward the failure total. + if failed_items > 0 { + if any_antivirus_block { + detail_lines.push(format_localized_message( + localizer.as_ref(), + "wizard-summary-error-antivirus", + &[], + "Windows security software blocked at least one download. Open Windows \ + Security > Virus & threat protection > Protection history, allow the blocked \ + item, and run RABBIT again." + .to_string(), + )); + } + return WizardInstallSummary { + status_line: format_localized_message( + localizer.as_ref(), + "wizard-summary-status-finished-with-errors", + &[ + ("installed", installed_or_checked.to_string()), + ("failed", failed_items.to_string()), + ], + format!( + "Finished with errors. {installed_or_checked} package item(s) installed or checked; {failed_items} failed." + ), + ), + detail_lines, + }; + } + + WizardInstallSummary { + status_line: format_localized_message( + localizer.as_ref(), + "wizard-summary-status-finished", + &[ + ("installed", installed_or_checked.to_string()), + ("manual", manual_items.to_string()), + ], + format!( + "Finished. {installed_or_checked} package item(s) installed or checked; {manual_items} require manual attention." + ), + ), + detail_lines, + } +} + +/// The summary's "Program" line for a planned execution. An installer that +/// arrives zipped (an OSARA pull-request build) has no program to name until +/// it is downloaded and unpacked, so the line says what will run instead of +/// leaving the reader to wonder why it is missing. Other plans without a +/// program (archives, disk images) get no line, as before. +pub(crate) fn planned_execution_program_line( + localizer: Option<&Localizer>, + plan: &PlannedExecutionPlan, +) -> Option { + match &plan.program { + Some(program) => Some(format_localized_message( + localizer, + "wizard-summary-planned-execution-program", + &[("program", program.clone())], + format!(" Program: {program}"), + )), + None if plan.kind == PlannedExecutionKind::LaunchInstallerExecutable => { + Some(format_localized_message( + localizer, + "wizard-summary-planned-execution-program-zipped", + &[], + " Program: the installer inside the .zip, once it is downloaded".to_string(), + )) + } + None => None, + } +} diff --git a/crates/rabbit-ui-wxdragon/src/wizard/target.rs b/crates/rabbit-ui-wxdragon/src/wizard/target.rs new file mode 100644 index 0000000..1c9cae1 --- /dev/null +++ b/crates/rabbit-ui-wxdragon/src/wizard/target.rs @@ -0,0 +1,247 @@ +//! Target page rows: the detected installations plus the custom portable +//! folder the user can point at. + +use std::fs; +use std::path::{Path, PathBuf}; + +use rabbit_core::arch_probe::probe_executable_architecture; +use rabbit_core::localization::Localizer; +use rabbit_core::metadata::file_version; +use rabbit_core::model::{Architecture, Confidence, Installation, InstallationKind, Platform}; + +use super::bootstrap::localizer_from_options; +use super::labels::{unknown_version_text, yes_no}; +use super::model::{TargetRow, WizardModel}; + +pub(crate) fn target_rows( + localizer: &Localizer, + installations: &[Installation], + selected_target_index: Option, +) -> Vec { + installations + .iter() + .enumerate() + .map(|(index, installation)| { + target_row( + localizer, + installation, + Some(index) == selected_target_index, + ) + }) + .collect() +} + +fn target_row(localizer: &Localizer, installation: &Installation, selected: bool) -> TargetRow { + let version = installation + .version + .as_ref() + .map(ToString::to_string) + .unwrap_or_else(|| localizer.text("detect-version-unknown").value); + // Dropdown label shows the *install directory* (where reaper.exe + // lives), not the resource folder. For a standard install that's + // typically `C:\Program Files\REAPER (x64)`; for portable it's the + // portable folder itself. The resource folder still appears in the + // expanded "Target details" pane via `wizard-target-details`. + let install_dir = installation + .app_path + .parent() + .map(|parent| parent.display().to_string()) + .unwrap_or_else(|| installation.app_path.display().to_string()); + TargetRow { + label: localizer + .format( + "wizard-target-row", + &[ + ("version", version.as_str()), + ("path", install_dir.as_str()), + ], + ) + .value, + details: localizer + .format( + "wizard-target-details", + &[ + ("app_path", &installation.app_path.display().to_string()), + ("version", version.as_str()), + ("path", &installation.resource_path.display().to_string()), + ( + "writable", + yes_no(localizer, installation.writable).as_str(), + ), + ], + ) + .value, + app_path: installation + .app_path + .exists() + .then(|| installation.app_path.clone()), + planned_app_path: installation.app_path.clone(), + path: installation.resource_path.clone(), + version: installation.version.clone(), + portable: installation.kind == InstallationKind::Portable, + selected, + writable: installation.writable, + architecture: installation + .architecture + .unwrap_or_else(Architecture::current), + } +} + +pub fn custom_portable_target_row(model: &WizardModel, path: PathBuf, selected: bool) -> TargetRow { + let writable = is_probably_writable(&path); + let writable_text = if writable { + model.text.common_yes.clone() + } else { + model.text.common_no.clone() + }; + let app_path = portable_reaper_app_path(model.platform, &path); + let version = app_path + .as_ref() + .and_then(|path| file_version(path).ok().flatten()); + let version_text = version + .as_ref() + .map(ToString::to_string) + .unwrap_or_else(|| unknown_version_text(model)); + TargetRow { + label: format!( + "{}: {}", + model.text.target_custom_portable_label, + path.display() + ), + details: format!( + "{}: {}\n{}: {}\n{}: {}\n{}: {}\n{}", + model.text.target_custom_portable_app_path_label, + app_path + .as_ref() + .unwrap_or(&default_portable_reaper_app_path(model.platform, &path)) + .display(), + model.text.target_custom_portable_path_label, + path.display(), + model.text.target_custom_portable_version_label, + version_text, + model.text.target_custom_portable_writable_label, + writable_text, + model.text.target_custom_portable_note + ), + app_path: app_path.clone(), + planned_app_path: app_path + .clone() + .unwrap_or_else(|| default_portable_reaper_app_path(model.platform, &path)), + path, + version, + portable: true, + selected, + writable, + // Probe the portable target's REAPER binary if it exists on disk; + // otherwise inherit the host arch (the user is staging a fresh + // portable, so the upcoming install will land a host-arch REAPER). + architecture: app_path + .as_deref() + .map(probe_executable_architecture) + .unwrap_or_else(Architecture::current), + } +} + +pub fn refreshed_target_row(model: &WizardModel, target: &TargetRow) -> TargetRow { + if target.portable { + return custom_portable_target_row(model, target.path.clone(), target.selected); + } + + // Re-probe the target binary's architecture instead of inheriting the + // host arch — `target.architecture` may be stale if the user swapped + // REAPER builds (Intel ↔ Apple Silicon, x64 ↔ ARM) under the same + // install path between wizard launches. + let probed_architecture = probe_executable_architecture(&target.planned_app_path); + let installation = Installation { + kind: InstallationKind::Standard, + platform: model.platform, + app_path: target.planned_app_path.clone(), + resource_path: target.path.clone(), + version: file_version(&target.planned_app_path).ok().flatten(), + architecture: Some(probed_architecture), + writable: is_probably_writable(&target.path), + confidence: Confidence::Medium, + evidence: Vec::new(), + }; + + match localizer_from_options(&model.bootstrap_options) { + Ok(localizer) => target_row(&localizer, &installation, target.selected), + Err(_) => TargetRow { + label: target.label.clone(), + details: target.details.clone(), + app_path: installation + .app_path + .exists() + .then(|| installation.app_path.clone()), + planned_app_path: installation.app_path.clone(), + path: installation.resource_path.clone(), + version: installation.version.clone(), + portable: false, + selected: target.selected, + writable: installation.writable, + architecture: probed_architecture, + }, + } +} + +pub(crate) fn installation_from_target_row( + model: &WizardModel, + target: &TargetRow, +) -> Installation { + Installation { + kind: if target.portable { + InstallationKind::Portable + } else { + InstallationKind::Standard + }, + platform: model.platform, + app_path: target.planned_app_path.clone(), + resource_path: target.path.clone(), + version: target.version.clone(), + architecture: Some(target.architecture), + writable: target.writable, + confidence: Confidence::Medium, + evidence: Vec::new(), + } +} + +fn portable_reaper_app_path(platform: Platform, resource_path: &Path) -> Option { + match platform { + Platform::Windows => { + let app_path = resource_path.join("reaper.exe"); + app_path.is_file().then_some(app_path) + } + Platform::MacOs => fs::read_dir(resource_path) + .ok()? + .filter_map(std::result::Result::ok) + .map(|entry| entry.path()) + .find(|path| { + path.extension() + .and_then(|extension| extension.to_str()) + .is_some_and(|extension| extension.eq_ignore_ascii_case("app")) + && path + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.to_ascii_lowercase().contains("reaper")) + }), + } +} + +fn default_portable_reaper_app_path(platform: Platform, resource_path: &Path) -> PathBuf { + match platform { + Platform::Windows => resource_path.join("reaper.exe"), + Platform::MacOs => resource_path.join("REAPER.app"), + } +} + +fn is_probably_writable(path: &Path) -> bool { + let existing_path = if path.exists() { + path + } else { + path.parent().unwrap_or(path) + }; + + fs::metadata(existing_path) + .map(|metadata| !metadata.permissions().readonly()) + .unwrap_or(false) +} diff --git a/crates/rabbit-ui-wxdragon/src/wizard/tests.rs b/crates/rabbit-ui-wxdragon/src/wizard/tests.rs new file mode 100644 index 0000000..b8ea125 --- /dev/null +++ b/crates/rabbit-ui-wxdragon/src/wizard/tests.rs @@ -0,0 +1,12 @@ +//! Wizard tests, grouped the same way the modules under test are. + +mod bootstrap; +mod configuration; +mod localization; +mod outcome; +mod packages; +mod request; +mod self_update; +mod summary; +mod support; +mod target; diff --git a/crates/rabbit-ui-wxdragon/src/wizard/tests/bootstrap.rs b/crates/rabbit-ui-wxdragon/src/wizard/tests/bootstrap.rs new file mode 100644 index 0000000..823b775 --- /dev/null +++ b/crates/rabbit-ui-wxdragon/src/wizard/tests/bootstrap.rs @@ -0,0 +1,153 @@ +use rabbit_core::localization::{DEFAULT_LOCALE, Localizer}; +use rabbit_core::model::{Architecture, InstallationKind, Platform}; +use rabbit_core::package::{PACKAGE_OSARA, PACKAGE_REAPACK}; +use rabbit_core::plan::{InstallPlan, PlanAction, PlanActionKind}; +use rabbit_core::version::Version; + +use super::support::*; +use crate::wizard::*; + +#[test] +fn builds_initial_wizard_model_from_plan() { + let localizer = Localizer::embedded(DEFAULT_LOCALE).unwrap(); + let installation = fake_installation(); + let plan = InstallPlan { + target: Some(installation.clone()), + actions: vec![ + PlanAction { + package_id: PACKAGE_OSARA.to_string(), + action: PlanActionKind::Install, + installed_version: None, + available_version: Some(Version::parse("2026.1").unwrap()), + reason: "Missing".to_string(), + }, + PlanAction { + package_id: PACKAGE_REAPACK.to_string(), + action: PlanActionKind::Keep, + installed_version: Some(Version::parse("1.2.6").unwrap()), + available_version: Some(Version::parse("1.2.6").unwrap()), + reason: "Current".to_string(), + }, + ], + notes: vec!["Review note".to_string()], + }; + + let model = model_from_plan( + &localizer, + Platform::Windows, + Architecture::X64, + vec![installation], + Some(0), + plan, + ); + + assert_eq!( + model.window_title, + format!( + "REAPER Accessibility Bootstrap & Bundle Installation Tool v{}", + env!("CARGO_PKG_VERSION") + ) + ); + assert_eq!(model.steps.len(), 7); + assert_eq!(model.target_rows.len(), 1); + assert!(model.target_rows[0].selected); + assert!(model.target_rows[0].portable); + assert!( + model.target_rows[0] + .details + .contains("REAPER installation path") + ); + assert!(model.target_rows[0].details.contains("Version:")); + assert!(!model.target_rows[0].details.contains("Architecture")); + assert!( + !model.target_rows[0] + .details + .contains("Detection confidence") + ); + assert!(model.target_rows[0].details.contains("Writable")); + assert_eq!(model.package_rows.len(), 2); + assert_eq!(model.package_rows[0].display_name, "OSARA"); + assert!(model.package_rows[0].summary.contains("OSARA")); + // The wizard package details pane no longer surfaces the internal + // handling-kind line; it stays in the saved report instead. + assert!(!model.package_rows[0].details.contains("Handling:")); + // The localized package description from the embedded en-US locale + // should land in `description` and inside `details` so the wizard's + // package details pane explains what the package is for. + assert!( + model.package_rows[0].description.contains("screen readers"), + "expected OSARA description in row, got {:?}", + model.package_rows[0].description + ); + assert!( + model.package_rows[0] + .details + .contains(&model.package_rows[0].description), + "expected OSARA description embedded in details" + ); + assert_eq!(model.package_rows[0].action_label, "Will install"); + assert!(!model.package_rows[0].manual_attention_expected); + assert_eq!( + model.package_rows[0].handling_summary, + model.text.package_handling_unattended + ); + assert!(model.package_rows[0].selected); + assert_eq!(model.package_rows[1].action_label, "Won't touch"); + assert!(!model.package_rows[1].manual_attention_expected); + assert!(!model.package_rows[1].selected); + assert!(model.controls.can_go_next); + assert!(model.controls.can_install); + assert!(model.review_lines.iter().any(|line| line.contains("OSARA"))); +} + +#[test] +fn disables_next_when_no_target_is_selected() { + let localizer = Localizer::embedded(DEFAULT_LOCALE).unwrap(); + let model = model_from_plan( + &localizer, + Platform::Windows, + Architecture::X64, + Vec::new(), + None, + InstallPlan { + target: None, + actions: Vec::new(), + notes: Vec::new(), + }, + ); + + assert!(!model.controls.can_go_next); + assert!(!model.controls.can_install); + assert_eq!(model.review_lines[0], "No target selected."); +} + +#[cfg(target_os = "windows")] +#[test] +fn selectable_installations_appends_standard_target_when_missing() { + let installations = selectable_installations(Platform::Windows, vec![fake_installation()]); + + assert_eq!(installations[0].kind, InstallationKind::Portable); + assert_eq!( + installations + .iter() + .filter(|installation| installation.kind == InstallationKind::Standard) + .count(), + 1 + ); +} + +#[test] +fn selectable_installations_does_not_duplicate_detected_standard_target() { + let installations = selectable_installations( + Platform::Windows, + vec![fake_standard_installation(), fake_installation()], + ); + + assert_eq!( + installations + .iter() + .filter(|installation| installation.kind == InstallationKind::Standard) + .count(), + 1 + ); +} diff --git a/crates/rabbit-ui-wxdragon/src/wizard/tests/configuration.rs b/crates/rabbit-ui-wxdragon/src/wizard/tests/configuration.rs new file mode 100644 index 0000000..0980e04 --- /dev/null +++ b/crates/rabbit-ui-wxdragon/src/wizard/tests/configuration.rs @@ -0,0 +1,387 @@ +use rabbit_core::localization::{DEFAULT_LOCALE, Localizer}; +use rabbit_core::model::{Architecture, Platform}; +use rabbit_core::package::{ + HostCapabilities, PACKAGE_OSARA, PACKAGE_REAPACK, builtin_package_specs, +}; +use rabbit_core::plan::{InstallPlan, PlanAction, PlanActionKind}; +use rabbit_core::version::Version; + +use super::support::*; +use crate::wizard::text::*; +use crate::wizard::*; + +#[test] +fn only_rows_that_offered_something_record_a_verdict() { + // Three language-pack rows in three states, and only two of them + // mean anything: + // - an Update left unticked is a refusal (the user was offered a + // newer pack and said no), + // - a Keep is silence (there was nothing to accept or refuse), + // - anything ticked is a yes. + let localizer = Localizer::embedded("de-DE").unwrap(); + let installation = fake_installation(); + let action = |id: &str, kind| PlanAction { + package_id: id.to_string(), + action: kind, + installed_version: None, + available_version: None, + reason: "test".to_string(), + }; + let model = model_from_plan( + &localizer, + Platform::Windows, + Architecture::X64, + vec![installation.clone()], + Some(0), + InstallPlan { + target: Some(installation), + actions: vec![ + action("langpack-de", PlanActionKind::Update), + action("langpack-es", PlanActionKind::Keep), + action("langpack-fr", PlanActionKind::Install), + action(PACKAGE_OSARA, PlanActionKind::Install), + ], + notes: Vec::new(), + }, + ); + let index_of = |id: &str| { + model + .package_rows + .iter() + .position(|row| row.package_id == id) + .unwrap_or_else(|| panic!("row for {id} should exist")) + }; + + // Tick only the French pack (and OSARA, so the request is valid). + let request = install_request_from_target( + &model, + &model.target_rows[0], + &[index_of("langpack-fr"), index_of(PACKAGE_OSARA)], + WizardInstallOptions::default(), + ) + .unwrap(); + + assert_eq!( + request.accepted_packages, + vec!["langpack-fr".to_string()], + "a ticked pack is a yes" + ); + assert_eq!( + request.declined_packages, + vec!["langpack-de".to_string()], + "an unticked Update is a no; the Keep row must say nothing" + ); + assert!( + !request + .declined_packages + .iter() + .any(|id| id == "langpack-es"), + "an installed, up-to-date pack sits unticked because there is \ + nothing to do — that is not a refusal" + ); +} + +#[test] +fn a_declined_language_pack_stops_being_ticked_by_default() { + // RABBIT running in German suggests the German pack — that is the + // whole point of the UI-language match. But someone who does not + // want it had to untick it on every single launch, because nothing + // remembered the refusal. + let localizer = Localizer::embedded("de-DE").unwrap(); + let text = wizard_text(&localizer); + let specs = builtin_package_specs(Platform::Windows); + let host = HostCapabilities::default(); + let actions = vec![PlanAction { + package_id: "langpack-de".to_string(), + action: PlanActionKind::Install, + installed_version: None, + available_version: None, + reason: "Missing".to_string(), + }]; + + let suggested = package_rows( + &localizer, + &text, + Platform::Windows, + Architecture::X64, + &specs, + &actions, + &[], + &host, + &Default::default(), + &Default::default(), + ); + assert!( + suggested[0].selected, + "a German RABBIT should suggest the German pack" + ); + + let declined: std::collections::BTreeSet = + ["langpack-de".to_string()].into_iter().collect(); + let remembered = package_rows( + &localizer, + &text, + Platform::Windows, + Architecture::X64, + &specs, + &actions, + &[], + &host, + &declined, + &Default::default(), + ); + assert!( + !remembered[0].selected, + "a pack the user turned down must not come back ticked" + ); + assert!( + remembered[0].available_for_target, + "it must stay listed and tickable — this suppresses the tick, not the package" + ); +} + +#[test] +fn the_language_step_names_a_language_pack_not_a_particular_one() { + // Reported from Q&A: the row read "Set REAPER's language (requires + // REAPER en espanol)". The step lists every language pack as an + // any-of dependency and langpack-es sorts first, so the message + // picked it out and made a general requirement look Spanish-specific. + let localizer = Localizer::embedded(DEFAULT_LOCALE).unwrap(); + let step = rabbit_core::configuration::builtin_configuration_steps() + .into_iter() + .find(|step| step.id == rabbit_core::configuration::CONFIG_SET_REAPER_LANGUAGE) + .expect("the language step exists"); + assert!( + step.requires_packages.len() > 1, + "this test only means anything while the step is an any-of" + ); + + let summary = + configuration_row_summary(&localizer, &step, "Set REAPER's language", false, false); + let reason = build_configuration_unavailability_reason(&localizer, &step, false, false) + .expect("an unsatisfied dependency has a reason"); + + for text in [&summary, &reason] { + assert!( + text.contains("language pack"), + "the message should name the group: {text}" + ); + for package_id in &step.requires_packages { + let name = localizer.text(&format!("package-{package_id}")); + if name.missing || name.value.is_empty() { + continue; + } + assert!( + !text.contains(&name.value), + "must not single out {:?}: {text}", + name.value + ); + } + } +} + +#[test] +fn a_single_dependency_step_still_names_its_package() { + // The fix above must not generalise away a genuinely specific + // requirement: the ReaPack steps depend on exactly one package and + // should keep saying so. + let localizer = Localizer::embedded(DEFAULT_LOCALE).unwrap(); + let step = rabbit_core::configuration::builtin_configuration_steps() + .into_iter() + .find(|step| step.id == "reapack-add-reaper-accessibility-remote") + .expect("the ReaPack remote step exists"); + + let reason = build_configuration_unavailability_reason(&localizer, &step, false, false) + .expect("an unsatisfied dependency has a reason"); + let reapack = localizer.text("package-reapack"); + assert!( + reason.contains(&reapack.value), + "a one-package step should name it: {reason}" + ); +} + +#[test] +fn language_step_is_unavailable_while_no_language_pack_is_ticked() { + // The step activates one of the packs you ticked — the "REAPER + // language after installation" dropdown lists exactly those — so + // with none ticked it has nothing to point at. It must be greyed + // out rather than tickable-but-meaningless, even when a pack is + // already sitting on disk from an earlier run. + let (_localizer, model) = langpack_model(PlanActionKind::Keep); + let pack = &model.package_rows[0]; + assert!(!pack.selected, "an already-installed pack starts unticked"); + + let step = language_step(&model.configuration_rows); + assert!( + !step.available_for_target, + "no language pack is ticked, so the step must not be tickable" + ); + assert!(!step.selected, "and it must not be ticked"); +} + +#[test] +fn language_step_follows_the_pack_being_ticked_and_unticked() { + let (localizer, model) = langpack_model(PlanActionKind::Keep); + let mut package_rows = model.package_rows.clone(); + let mut configuration_rows = model.configuration_rows.clone(); + + // Tick the pack: the user explicitly wants it re-staged, so the + // step's default flips on. + apply_checkbox_state_to_package_row(&model, &mut package_rows[0], true).unwrap(); + recompute_configuration_row_availability( + &localizer, + &package_rows, + None, + &mut configuration_rows, + ); + let step = language_step(&configuration_rows); + assert!( + step.available_for_target, + "ticking a language pack must make the step usable" + ); + assert!( + step.selected, + "ticking a language pack must tick the step that activates it" + ); + + // Untick it again: nothing language-related is happening any more. + apply_checkbox_state_to_package_row(&model, &mut package_rows[0], false).unwrap(); + recompute_configuration_row_availability( + &localizer, + &package_rows, + None, + &mut configuration_rows, + ); + let step = language_step(&configuration_rows); + assert!( + !step.selected, + "unticking the last pack must untick the step again" + ); + assert!( + !step.available_for_target, + "and must grey it out, since there is no longer a pack to activate" + ); +} + +#[test] +fn language_step_keeps_an_explicit_user_override() { + let (localizer, model) = langpack_model(PlanActionKind::Keep); + let mut package_rows = model.package_rows.clone(); + let mut configuration_rows = model.configuration_rows.clone(); + + apply_checkbox_state_to_package_row(&model, &mut package_rows[0], true).unwrap(); + recompute_configuration_row_availability( + &localizer, + &package_rows, + None, + &mut configuration_rows, + ); + assert!(language_step(&configuration_rows).selected); + + // The user unticks the step by hand: install the pack, but leave + // REAPER's language alone. A later recompute must not re-tick it. + let index = configuration_rows + .iter() + .position(|row| row.step_id == rabbit_core::configuration::CONFIG_SET_REAPER_LANGUAGE) + .unwrap(); + configuration_rows[index].selected = false; + recompute_configuration_row_availability( + &localizer, + &package_rows, + None, + &mut configuration_rows, + ); + assert!( + !language_step(&configuration_rows).selected, + "an explicit untick must survive a recompute" + ); +} + +#[test] +fn additive_steps_still_auto_tick_for_an_already_installed_dependency() { + // The guard is deliberately narrow: only steps that CHANGE existing + // configuration wait for a fresh install. Adding the ReaPack remote + // is additive, so an already-installed ReaPack still auto-ticks it. + let localizer = Localizer::embedded(DEFAULT_LOCALE).unwrap(); + let installation = fake_installation(); + let model = model_from_plan( + &localizer, + Platform::Windows, + Architecture::X64, + vec![installation.clone()], + Some(0), + InstallPlan { + target: Some(installation), + actions: vec![PlanAction { + package_id: PACKAGE_REAPACK.to_string(), + action: PlanActionKind::Keep, + installed_version: Some(Version::parse("1.2.6").unwrap()), + available_version: Some(Version::parse("1.2.6").unwrap()), + reason: "installed".to_string(), + }], + notes: Vec::new(), + }, + ); + let remote = model + .configuration_rows + .iter() + .find(|row| row.step_id == "reapack-add-reaper-accessibility-remote") + .expect("reapack remote row should exist"); + assert!(remote.available_for_target); + assert!( + remote.selected, + "additive steps must keep auto-ticking for a dependency that is already installed" + ); +} + +#[test] +fn configuration_row_unticks_when_dep_package_is_unticked_install() { + // ReaPack is `recommended: false`. With a fresh target it lands as an + // Install action that arrives unticked-by-default, so the + // "add ReaPack remote" configuration step's dependency is NOT + // satisfied — the configuration row must start unticked too, + // otherwise the wizard would queue a config step that points at a + // package the user hasn't asked for. + let localizer = Localizer::embedded(DEFAULT_LOCALE).unwrap(); + let installation = fake_installation(); + let model = model_from_plan( + &localizer, + Platform::Windows, + Architecture::X64, + vec![installation.clone()], + Some(0), + InstallPlan { + target: Some(installation), + actions: vec![PlanAction { + package_id: PACKAGE_REAPACK.to_string(), + action: PlanActionKind::Install, + installed_version: None, + available_version: Some(Version::parse("1.2.6").unwrap()), + reason: "Missing".to_string(), + }], + notes: Vec::new(), + }, + ); + + let reapack = &model.package_rows[0]; + // Plan's action lives on `original_action`; the unticked row's + // current `action` is Keep so the visible row label says "Won't touch". + assert_eq!(reapack.original_action, PlanActionKind::Install); + assert_eq!(reapack.action, PlanActionKind::Keep); + assert!(!reapack.selected, "ReaPack row should start unticked"); + + let reapack_remote = model + .configuration_rows + .iter() + .find(|row| row.step_id == "reapack-add-reaper-accessibility-remote") + .expect("reapack-add-reaper-accessibility-remote row should exist"); + assert!( + !reapack_remote.available_for_target, + "config row's dependency package isn't going to land on disk; \ + row must be marked unavailable" + ); + assert!( + !reapack_remote.selected, + "config row must not auto-tick when its dep package is an unticked Install" + ); +} diff --git a/crates/rabbit-ui-wxdragon/src/wizard/tests/localization.rs b/crates/rabbit-ui-wxdragon/src/wizard/tests/localization.rs new file mode 100644 index 0000000..69c33ca --- /dev/null +++ b/crates/rabbit-ui-wxdragon/src/wizard/tests/localization.rs @@ -0,0 +1,202 @@ +use rabbit_core::localization::{DEFAULT_LOCALE, Localizer}; +use rabbit_core::model::{Architecture, Platform}; +use rabbit_core::plan::InstallPlan; +use tempfile::tempdir; + +use crate::wizard::labels::*; +use crate::wizard::text::*; +use crate::wizard::*; + +#[test] +fn default_options_use_embedded_localization() { + let options = UiBootstrapOptions::default(); + let localizer = localizer_from_options(&options).unwrap(); + + assert_eq!(localizer.active_locale(), DEFAULT_LOCALE); + assert!(localizer.source_path().is_none()); + assert_eq!( + localizer.text("app-title").value, + "REAPER Accessibility Bootstrap & Bundle Installation Tool" + ); +} + +#[test] +fn package_operation_messages_localize_into_german() { + use rabbit_core::operation::PackageOperationMessage as Msg; + let de = Localizer::embedded("de-DE").unwrap(); + let extension = localized_package_operation_message( + &de, + &Msg::ExtensionBinaryInstalled, + "Single extension binary handled by RABBIT installer.", + ); + assert!( + extension.starts_with("Einzelne"), + "expected German extension-binary status, got: {extension:?}" + ); + let skipped = localized_package_operation_message( + &de, + &Msg::SkippedCurrent { + installed_version: "8.1.1".to_string(), + available_version: "8.0".to_string(), + }, + "Installed version 8.1.1 is current or newer than available version 8.0.", + ); + assert!( + skipped.contains("Installierte Version") && skipped.contains("8.1.1"), + "expected German skipped-current with version interpolation, got: {skipped:?}" + ); + let dry_run = localized_package_operation_message( + &de, + &Msg::DryRunWouldRunUnattended { + artifact_kind: rabbit_core::artifact::ArtifactKind::Installer, + }, + "Dry run: RABBIT would download and run this vendor installer unattended.", + ); + assert!( + dry_run.starts_with("Probelauf") && dry_run.contains("Vendor-Installationsprogramm"), + "expected German dry-run with translated automation kind, got: {dry_run:?}" + ); +} + +#[test] +fn configuration_step_messages_localize_into_german() { + use rabbit_core::configuration::{ConfigurationMessage as Msg, ConfigurationStatus}; + let de = Localizer::embedded("de-DE").unwrap(); + let added = localized_configuration_message( + &de, + &Msg::ReapackRemoteAdded { + name: "REAPER Accessibility".to_string(), + url: "https://example.test/index.xml".to_string(), + }, + "Added ReaPack remote ...", + ); + assert!( + added.contains("REAPER Accessibility") + && added.contains("hinzugefügt") + && added.contains("https://example.test/index.xml"), + "expected German added-remote message with name + URL interpolation, got: {added:?}" + ); + let dep_missing = localized_configuration_message( + &de, + &Msg::SkippedDependencyMissing { + step_id: "reapack-add-reaper-accessibility-remote".to_string(), + dep_id: "reapack".to_string(), + }, + "Configuration step skipped because dependency missing.", + ); + assert!( + dep_missing.contains("übersprungen") && dep_missing.contains("reapack"), + "expected German dependency-missing message, got: {dep_missing:?}" + ); + let status = configuration_status_label_for_summary( + Some(&de), + ConfigurationStatus::SkippedDependencyMissing, + ); + assert!( + status.starts_with("Übersprungen") && status.contains("Abhängigkeit"), + "expected German skipped-dep-missing status, got: {status:?}" + ); + // The step name lookup should resolve `reapack-add-reaper-accessibility-remote` + // to its localized display name. + let step_name = + localized_configuration_step_name(Some(&de), "reapack-add-reaper-accessibility-remote"); + assert!( + step_name.contains("ReaPack") + && (step_name.contains("Repository") || step_name.contains("Repositorys")), + "expected German display name for the REAPER Accessibility ReaPack repo step, got: {step_name:?}" + ); +} + +#[test] +fn wizard_command_labels_include_native_mnemonics() { + let localizer = Localizer::embedded(DEFAULT_LOCALE).unwrap(); + let model = model_from_plan( + &localizer, + Platform::Windows, + Architecture::X64, + Vec::new(), + None, + InstallPlan { + target: None, + actions: Vec::new(), + notes: Vec::new(), + }, + ); + + // `localized_wx_mnemonic_label` strips `&` mnemonics on macOS to + // avoid colliding with Cmd+letter system shortcuts (Cmd+C copying + // would close the wizard via the `&Close` mnemonic). Other + // platforms keep the underlined Alt-key access. Test both shapes + // explicitly so a regression in either direction is caught. + if cfg!(target_os = "macos") { + assert_eq!(model.controls.back_label, "Back"); + assert_eq!(model.controls.next_label, "Next"); + assert_eq!(model.controls.install_label, "Install"); + assert_eq!(model.controls.close_label, "Close"); + assert_eq!( + model.text.done_launch_reaper_label, + "Open REAPER and close RABBIT" + ); + assert_eq!( + model.text.done_open_resource_label, + "Open resource folder (only for advanced manual maintenance)" + ); + } else { + assert_eq!(model.controls.back_label, "&Back"); + assert_eq!(model.controls.next_label, "&Next"); + assert_eq!(model.controls.install_label, "&Install"); + assert_eq!(model.controls.close_label, "&Close"); + assert_eq!( + model.text.done_launch_reaper_label, + "&Open REAPER and close RABBIT" + ); + assert_eq!( + model.text.done_open_resource_label, + "Open &resource folder (only for advanced manual maintenance)" + ); + } + assert_eq!( + model.text.package_handling_unattended, + "RABBIT can install this package unattended, including launching its installer when required." + ); + assert_eq!( + model.text.package_handling_planned, + "RABBIT is designed to run this package's installer or setup routine itself and finish the installation unattended, but this build still reports the steps instead of executing them." + ); + assert_eq!( + model.text.packages_osara_keymap_replace_label, + "Replace your current key map with latest OSARA key map" + ); +} + +#[test] +fn wx_mnemonic_labels_support_translated_access_keys() { + assert_eq!(wx_mnemonic_label("Weiter", "W"), "&Weiter"); + assert_eq!(wx_mnemonic_label("Schliessen", "S"), "&Schliessen"); + assert_eq!( + wx_mnemonic_label("Bericht speichern", "S"), + "Bericht &speichern" + ); + assert_eq!(wx_mnemonic_label("Weiter", "X"), "Weiter (&X)"); + assert_eq!(wx_mnemonic_label("Save & report", "S"), "&Save && report"); +} + +#[test] +fn locale_directory_override_remains_available_for_development() { + let dir = tempdir().unwrap(); + let locale_dir = dir.path().join("de-DE"); + std::fs::create_dir_all(&locale_dir).unwrap(); + std::fs::write(locale_dir.join("rabbit.ftl"), "app-title = RABBIT Test\n").unwrap(); + let options = UiBootstrapOptions { + locale: "de-DE".to_string(), + locales_dir: Some(dir.path().to_path_buf()), + portable_roots: Vec::new(), + online_versions: false, + }; + + let localizer = localizer_from_options(&options).unwrap(); + + assert_eq!(localizer.active_locale(), "de-DE"); + assert!(localizer.source_path().is_some()); + assert_eq!(localizer.text("app-title").value, "RABBIT Test"); +} diff --git a/crates/rabbit-ui-wxdragon/src/wizard/tests/outcome.rs b/crates/rabbit-ui-wxdragon/src/wizard/tests/outcome.rs new file mode 100644 index 0000000..2e23cc7 --- /dev/null +++ b/crates/rabbit-ui-wxdragon/src/wizard/tests/outcome.rs @@ -0,0 +1,156 @@ +use std::path::PathBuf; + +use rabbit_core::localization::{DEFAULT_LOCALE, Localizer}; +use rabbit_core::model::{Architecture, Platform}; +use rabbit_core::plan::InstallPlan; +use tempfile::tempdir; + +use super::support::*; +use crate::wizard::*; + +#[test] +fn wizard_error_summary_includes_selected_request_context() { + let localizer = Localizer::embedded(DEFAULT_LOCALE).unwrap(); + let model = model_from_plan( + &localizer, + Platform::Windows, + Architecture::X64, + vec![fake_installation()], + Some(0), + InstallPlan { + target: None, + actions: Vec::new(), + notes: Vec::new(), + }, + ); + let request = sample_install_request(PathBuf::from("C:/PortableREAPER")); + let error = rabbit_core::RabbitError::PreflightFailed { + message: "REAPER is running.".to_string(), + }; + + let summary = summarize_wizard_error(&model, &request, &error); + + assert_eq!( + summary.status_line, + "Installation failed. Review the error below." + ); + assert!( + summary + .detail_lines + .iter() + .any(|line| line.contains("Packages selected: OSARA, ReaPack")) + ); + assert!( + summary + .detail_lines + .iter() + .any(|line| line == "OSARA key map") + ); + assert!( + summary + .detail_lines + .iter() + .any(|line| line.contains("Backup your current key map")) + ); + assert!( + summary + .detail_lines + .iter() + .any(|line| line.contains("Error: preflight failed: REAPER is running.")) + ); +} + +#[test] +fn wizard_error_summary_adds_localized_hint_for_antivirus_blocks() { + let localizer = Localizer::embedded(DEFAULT_LOCALE).unwrap(); + let model = model_from_plan( + &localizer, + Platform::Windows, + Architecture::X64, + vec![fake_installation()], + Some(0), + InstallPlan { + target: None, + actions: Vec::new(), + notes: Vec::new(), + }, + ); + let request = sample_install_request(PathBuf::from("C:/PortableREAPER")); + + let blocked = rabbit_core::RabbitError::WindowsFileBlockedByAntivirus { + path: PathBuf::from("C:/Temp/rabbit-cache/osara/osara.exe"), + source: std::io::Error::from_raw_os_error(225), + }; + let summary = summarize_wizard_error(&model, &request, &blocked); + assert!( + summary + .detail_lines + .iter() + .any(|line| line.contains("Protection history")), + "antivirus blocks must carry the remediation steps: {:?}", + summary.detail_lines + ); + + // Unrelated failures must not gain the antivirus advice. + let other = rabbit_core::RabbitError::PreflightFailed { + message: "REAPER is running.".to_string(), + }; + let summary = summarize_wizard_error(&model, &request, &other); + assert!( + !summary + .detail_lines + .iter() + .any(|line| line.contains("Protection history")), + "only antivirus blocks get the antivirus advice: {:?}", + summary.detail_lines + ); +} + +#[test] +fn saves_wizard_outcome_error_report_under_resource_logs() { + let dir = tempdir().unwrap(); + let localizer = Localizer::embedded(DEFAULT_LOCALE).unwrap(); + let model = model_from_plan( + &localizer, + Platform::Windows, + Architecture::X64, + vec![fake_installation()], + Some(0), + InstallPlan { + target: None, + actions: Vec::new(), + notes: Vec::new(), + }, + ); + let request = sample_install_request(dir.path().join("PortableREAPER")); + let error = rabbit_core::RabbitError::PreflightFailed { + message: "Target path blocked".to_string(), + }; + let report = wizard_outcome_report_from_error(&model, &request, &error); + + let path = save_wizard_outcome_report(&report).unwrap(); + let json_path = path.with_extension("json"); + + assert!(path.starts_with(dir.path().join("PortableREAPER/RABBIT/logs"))); + assert!(path.is_file()); + assert!(json_path.is_file()); + let content = std::fs::read_to_string(path).unwrap(); + assert!(content.contains("status: error")); + assert!(content.contains("error_message: preflight failed: Target path blocked")); +} + +#[test] +fn saves_wizard_setup_report_under_resource_logs() { + let dir = tempdir().unwrap(); + let report = empty_setup_report(dir.path().join("PortableREAPER")); + + let path = save_wizard_setup_report(&report).unwrap(); + let json_path = path.with_extension("json"); + + assert!(path.starts_with(dir.path().join("PortableREAPER/RABBIT/logs"))); + assert!(path.is_file()); + assert!(json_path.is_file()); + let content = std::fs::read_to_string(path).unwrap(); + assert!(content.contains("RABBIT Report")); + assert!(content.contains("resource_path:")); +} diff --git a/crates/rabbit-ui-wxdragon/src/wizard/tests/packages.rs b/crates/rabbit-ui-wxdragon/src/wizard/tests/packages.rs new file mode 100644 index 0000000..8a148b0 --- /dev/null +++ b/crates/rabbit-ui-wxdragon/src/wizard/tests/packages.rs @@ -0,0 +1,845 @@ +use rabbit_core::localization::{DEFAULT_LOCALE, Localizer}; +use rabbit_core::model::{Architecture, Platform}; +use rabbit_core::package::{ + HostCapabilities, PACKAGE_FFMPEG, PACKAGE_OSARA, PACKAGE_REAKONTROL, PACKAGE_REAPACK, + PACKAGE_REAPER, PACKAGE_SWS, builtin_package_specs, +}; +use rabbit_core::plan::{AvailablePackage, InstallPlan, PlanAction, PlanActionKind}; +use rabbit_core::version::Version; +use tempfile::tempdir; + +use super::support::*; +use crate::wizard::text::*; +use crate::wizard::*; + +#[test] +fn jaws_scripts_only_appear_when_jaws_is_detected() { + let with_jaws = wizard_desired_package_ids_for_host( + Platform::Windows, + &HostCapabilities { + jaws_installed: true, + ..HostCapabilities::default() + }, + ); + assert!(with_jaws.iter().any(|id| id == "jaws-scripts")); + + let without_jaws = wizard_desired_package_ids_for_host( + Platform::Windows, + &HostCapabilities { + jaws_installed: false, + ..HostCapabilities::default() + }, + ); + assert!(!without_jaws.iter().any(|id| id == "jaws-scripts")); + + // macOS never sees the JAWS row regardless of the host flag — the + // package itself is platform-gated to Windows. + let macos_with_jaws = wizard_desired_package_ids_for_host( + Platform::MacOs, + &HostCapabilities { + jaws_installed: true, + ..HostCapabilities::default() + }, + ); + assert!(!macos_with_jaws.iter().any(|id| id == "jaws-scripts")); +} + +#[test] +fn toggling_a_package_row_updates_its_action_label_and_summary() { + // The package list row label must follow the user's checkbox state: + // unchecking should switch the visible action to "Keep", and + // re-checking it should restore the install/update action that the + // plan originally chose for this package. + let localizer = Localizer::embedded(DEFAULT_LOCALE).unwrap(); + let installation = fake_installation(); + let model = model_from_plan( + &localizer, + Platform::Windows, + Architecture::X64, + vec![installation.clone()], + Some(0), + InstallPlan { + target: Some(installation), + actions: vec![PlanAction { + package_id: PACKAGE_OSARA.to_string(), + action: PlanActionKind::Install, + installed_version: None, + available_version: Some(Version::parse("2026.1").unwrap()), + reason: "Missing".to_string(), + }], + notes: Vec::new(), + }, + ); + let mut row = model.package_rows[0].clone(); + assert_eq!(row.action, PlanActionKind::Install); + assert_eq!(row.action_label, "Will install"); + assert!(row.selected); + + let summary = apply_checkbox_state_to_package_row(&model, &mut row, false).unwrap(); + assert_eq!(row.action, PlanActionKind::Keep); + assert_eq!(row.action_label, "Won't touch"); + assert!(!row.selected); + assert!(summary.contains("Won't touch")); + assert!(row.summary.contains("Won't touch")); + + // Re-checking restores the original install action because the + // package was originally not installed. + let summary = apply_checkbox_state_to_package_row(&model, &mut row, true).unwrap(); + assert_eq!(row.action, PlanActionKind::Install); + assert_eq!(row.action_label, "Will install"); + assert!(row.selected); + assert!(summary.contains("Will install")); +} + +#[test] +fn toggling_a_keep_row_to_checked_promotes_it_to_update() { + // For a package that was already installed and current, the plan's + // original action is Keep. If the user explicitly checks the row, + // they're asking RABBIT to re-stage the package — that translates to + // Update so the install pipeline runs. + let localizer = Localizer::embedded(DEFAULT_LOCALE).unwrap(); + let installation = fake_installation(); + let model = model_from_plan( + &localizer, + Platform::Windows, + Architecture::X64, + vec![installation.clone()], + Some(0), + InstallPlan { + target: Some(installation), + actions: vec![PlanAction { + package_id: PACKAGE_REAPACK.to_string(), + action: PlanActionKind::Keep, + installed_version: Some(Version::parse("1.2.6").unwrap()), + available_version: Some(Version::parse("1.2.6").unwrap()), + reason: "Current".to_string(), + }], + notes: Vec::new(), + }, + ); + let mut row = model.package_rows[0].clone(); + assert_eq!(row.action, PlanActionKind::Keep); + assert!(!row.selected); + + let _ = apply_checkbox_state_to_package_row(&model, &mut row, true).unwrap(); + assert_eq!(row.action, PlanActionKind::Update); + assert_eq!(row.action_label, "Will update"); + assert!(row.selected); + assert!(row.summary.contains("Will update")); +} + +#[test] +fn non_recommended_package_install_row_starts_unticked() { + // FFmpeg is `recommended: false` in builtin-packages.json. Even when + // the plan's action for it is Install, the wizard must NOT auto-tick + // the row — non-recommended packages should be opt-in. + let localizer = Localizer::embedded(DEFAULT_LOCALE).unwrap(); + let installation = fake_installation(); + let model = model_from_plan( + &localizer, + Platform::Windows, + Architecture::X64, + vec![installation.clone()], + Some(0), + InstallPlan { + target: Some(installation), + actions: vec![PlanAction { + package_id: PACKAGE_FFMPEG.to_string(), + action: PlanActionKind::Install, + installed_version: None, + available_version: Some(Version::parse("8.1.1").unwrap()), + reason: "Missing".to_string(), + }], + notes: Vec::new(), + }, + ); + let row = &model.package_rows[0]; + // The plan's action stays available on `original_action`; the row's + // current `action` mirrors the auto-untick (Keep) so the row label + // reads "Won't touch" instead of "Will install" while unselected. + assert_eq!(row.original_action, PlanActionKind::Install); + assert_eq!(row.action, PlanActionKind::Keep); + assert!(!row.selected); +} + +#[test] +fn a_declined_package_does_not_auto_tick_its_updates_either() { + // Someone who stopped using a language pack and turned it down has + // said no to the package, not merely to one version of it. Ticking + // its next update for them would re-offer it every time the + // translators publish a fix, which is the exact loop the refusal is + // meant to end. The row is still there to tick by hand. + let localizer = Localizer::embedded("de-DE").unwrap(); + let text = wizard_text(&localizer); + let specs = builtin_package_specs(Platform::Windows); + let declined: std::collections::BTreeSet = + ["langpack-de".to_string()].into_iter().collect(); + + let rows = package_rows( + &localizer, + &text, + Platform::Windows, + Architecture::X64, + &specs, + &[PlanAction { + package_id: "langpack-de".to_string(), + action: PlanActionKind::Update, + installed_version: None, + available_version: None, + reason: "upstream file changed".to_string(), + }], + &[], + &HostCapabilities::default(), + &declined, + &Default::default(), + ); + assert!( + !rows[0].selected, + "a refused package must not have its update ticked for the user" + ); + assert!( + rows[0].available_for_target, + "the update must still be listed so it can be taken deliberately" + ); +} + +#[test] +fn reakontrol_install_row_starts_ticked_when_komplete_kontrol_is_detected() { + // ReaKontrol's manifest baseline is `recommended: false`, but it + // declares `recommended_when: komplete_kontrol_installed` so the + // wizard escalates it to recommended-by-default for users who have + // Komplete Kontrol on their host. This test pins the host capability + // explicitly so the result doesn't depend on whether dev/CI has KK. + let localizer = Localizer::embedded(DEFAULT_LOCALE).unwrap(); + let text = wizard_text(&localizer); + let specs = builtin_package_specs(Platform::Windows); + let host = HostCapabilities { + komplete_kontrol_installed: true, + ..HostCapabilities::default() + }; + + let rows = package_rows( + &localizer, + &text, + Platform::Windows, + Architecture::X64, + &specs, + &[PlanAction { + package_id: PACKAGE_REAKONTROL.to_string(), + action: PlanActionKind::Install, + installed_version: None, + available_version: Some(Version::parse("2026.2").unwrap()), + reason: "Missing".to_string(), + }], + &[], + &host, + &Default::default(), + &Default::default(), + ); + assert!( + rows[0].selected, + "ReaKontrol Install row must auto-tick on a host where Komplete \ + Kontrol is detected" + ); + + // Sanity: same plan, KK absent → row stays unticked (the manifest + // baseline of recommended:false wins). + let rows = package_rows( + &localizer, + &text, + Platform::Windows, + Architecture::X64, + &specs, + &[PlanAction { + package_id: PACKAGE_REAKONTROL.to_string(), + action: PlanActionKind::Install, + installed_version: None, + available_version: Some(Version::parse("2026.2").unwrap()), + reason: "Missing".to_string(), + }], + &[], + &HostCapabilities::default(), + &Default::default(), + &Default::default(), + ); + assert!( + !rows[0].selected, + "ReaKontrol Install row must stay unticked on a host without \ + Komplete Kontrol" + ); +} + +#[test] +fn non_recommended_package_update_row_starts_ticked() { + // Update means the package is already on disk — the user opted into + // having it. RABBIT should keep it current by default, so the Update + // row stays auto-ticked even for a non-recommended package. + let localizer = Localizer::embedded(DEFAULT_LOCALE).unwrap(); + let installation = fake_installation(); + let model = model_from_plan( + &localizer, + Platform::Windows, + Architecture::X64, + vec![installation.clone()], + Some(0), + InstallPlan { + target: Some(installation), + actions: vec![PlanAction { + package_id: PACKAGE_FFMPEG.to_string(), + action: PlanActionKind::Update, + installed_version: Some(Version::parse("8.0.0").unwrap()), + available_version: Some(Version::parse("8.1.1").unwrap()), + reason: "Older version on disk".to_string(), + }], + notes: Vec::new(), + }, + ); + let row = &model.package_rows[0]; + assert_eq!(row.action, PlanActionKind::Update); + assert!(row.selected); +} + +#[test] +fn builds_package_plan_for_custom_target_path() { + let dir = tempdir().unwrap(); + let plugins = dir.path().join("PortableREAPER").join("UserPlugins"); + std::fs::create_dir_all(&plugins).unwrap(); + std::fs::write(plugins.join("reaper_reapack-x64.dll"), b"installed").unwrap(); + let localizer = Localizer::embedded(DEFAULT_LOCALE).unwrap(); + let model = model_from_plan( + &localizer, + Platform::Windows, + Architecture::X64, + Vec::new(), + None, + InstallPlan { + target: None, + actions: Vec::new(), + notes: Vec::new(), + }, + ); + let target = custom_portable_target_row(&model, dir.path().join("PortableREAPER"), true); + + let plan = wizard_package_plan_for_target(&model, Some(&target)).unwrap(); + let reapack = plan + .package_rows + .iter() + .find(|row| row.package_id == PACKAGE_REAPACK) + .unwrap(); + + assert_eq!(reapack.action, PlanActionKind::Keep); + assert!(!reapack.selected); + assert!(plan.package_rows.iter().any(|row| row.selected)); +} + +#[test] +fn package_plan_includes_reaper_for_empty_custom_target() { + let dir = tempdir().unwrap(); + let localizer = Localizer::embedded(DEFAULT_LOCALE).unwrap(); + let model = model_from_plan( + &localizer, + Platform::Windows, + Architecture::X64, + Vec::new(), + None, + InstallPlan { + target: None, + actions: Vec::new(), + notes: Vec::new(), + }, + ); + let target = custom_portable_target_row(&model, dir.path().join("PortableREAPER"), true); + + let plan = wizard_package_plan_for_target(&model, Some(&target)).unwrap(); + let reaper = plan + .package_rows + .iter() + .find(|row| row.package_id == PACKAGE_REAPER) + .unwrap(); + + assert_eq!(reaper.display_name, "REAPER"); + assert_eq!(reaper.action, PlanActionKind::Install); + assert!(!reaper.manual_attention_expected); + // Handling-kind no longer surfaces in the wizard details pane; it + // remains as a structured field on PackageRow for the saved report. + assert_eq!( + reaper.handling_summary, + model.text.package_handling_unattended + ); + assert!(reaper.selected); +} + +#[test] +fn portable_target_marks_surge_xt_as_unavailable() { + let dir = tempdir().unwrap(); + let localizer = Localizer::embedded(DEFAULT_LOCALE).unwrap(); + let model = model_from_plan( + &localizer, + Platform::Windows, + Architecture::X64, + Vec::new(), + None, + InstallPlan { + target: None, + actions: Vec::new(), + notes: Vec::new(), + }, + ); + let target = custom_portable_target_row(&model, dir.path().join("PortableREAPER"), true); + + let plan = wizard_package_plan_for_target(&model, Some(&target)).unwrap(); + let surge = plan + .package_rows + .iter() + .find(|row| row.package_id == rabbit_core::package::PACKAGE_SURGE_XT) + .expect("Surge XT row should appear in the package list"); + + assert!( + !surge.available_for_target, + "Surge XT must be marked unavailable on a portable REAPER target" + ); + assert!(!surge.selected); + assert_eq!(surge.action, PlanActionKind::Keep); + assert!( + surge.unavailability_reason.is_some(), + "the gate should attach a localized reason so screen readers announce why" + ); +} + +#[test] +fn portable_target_marks_app2clap_as_unavailable() { + // app2clap installs into the per-user CLAP folder, outside any + // portable REAPER folder, so it must be gated on portable targets + // exactly like Surge XT. + let dir = tempdir().unwrap(); + let localizer = Localizer::embedded(DEFAULT_LOCALE).unwrap(); + let model = model_from_plan( + &localizer, + Platform::Windows, + Architecture::X64, + Vec::new(), + None, + InstallPlan { + target: None, + actions: Vec::new(), + notes: Vec::new(), + }, + ); + let target = custom_portable_target_row(&model, dir.path().join("PortableREAPER"), true); + + let plan = wizard_package_plan_for_target(&model, Some(&target)).unwrap(); + let app2clap = plan + .package_rows + .iter() + .find(|row| row.package_id == rabbit_core::package::PACKAGE_APP2CLAP) + .expect("app2clap row should appear in the package list"); + + assert!( + !app2clap.available_for_target, + "app2clap must be marked unavailable on a portable REAPER target" + ); + assert!(!app2clap.selected); + assert_eq!(app2clap.action, PlanActionKind::Keep); + assert!( + app2clap.unavailability_reason.is_some(), + "the gate should attach a localized reason so screen readers announce why" + ); +} + +#[test] +fn version_check_failure_disables_row_and_records_note() { + // SWS-website-down scenario: the deferred latest-version check fails + // for one package. Its row must be disabled (unchecked, Keep, with a + // localized reason) while every other row keeps its update flow, and + // the full error message must land in the notes for the Review page. + let dir = tempdir().unwrap(); + let localizer = Localizer::embedded(DEFAULT_LOCALE).unwrap(); + let model = model_from_plan( + &localizer, + Platform::Windows, + Architecture::X64, + Vec::new(), + None, + InstallPlan { + target: None, + actions: Vec::new(), + notes: Vec::new(), + }, + ); + let target = custom_portable_target_row(&model, dir.path().join("PortableREAPER"), true); + let mut plan = wizard_package_plan_for_target(&model, Some(&target)).unwrap(); + + let failures = vec![( + PACKAGE_SWS.to_string(), + "HTTP status server error (503) for https://sws-extension.org/".to_string(), + )]; + let can_install = apply_version_check_failures_to_rows( + &localizer, + &mut plan.package_rows, + &mut plan.notes, + &failures, + ); + + let sws = plan + .package_rows + .iter() + .find(|row| row.package_id == PACKAGE_SWS) + .expect("SWS row should be in the package list"); + assert!(!sws.available_for_target); + assert!(!sws.selected); + assert_eq!(sws.action, PlanActionKind::Keep); + assert!( + sws.unavailability_reason.is_some(), + "a localized reason should be attached so screen readers announce why" + ); + + // Other packages keep their flow — the plan as a whole stays + // actionable (REAPER etc. are still installable on the empty target). + assert!(can_install); + assert!( + plan.package_rows + .iter() + .any(|row| row.package_id != PACKAGE_SWS && row.available_for_target) + ); + + // The note carries the package name and the full error text. + assert!( + plan.notes + .iter() + .any(|note| note.contains("SWS") && note.contains("503")), + "expected a note with the failure detail, got {:?}", + plan.notes + ); +} + +#[test] +fn whats_new_notes_render_in_the_package_details_pane() { + // A package whose deferred version check resolved What's-New notes + // gets them appended to its details pane under a localized heading; + // packages without notes keep the plain summary + description. + let dir = tempdir().unwrap(); + let localizer = Localizer::embedded(DEFAULT_LOCALE).unwrap(); + let model = model_from_plan( + &localizer, + Platform::Windows, + Architecture::X64, + Vec::new(), + None, + InstallPlan { + target: None, + actions: Vec::new(), + notes: Vec::new(), + }, + ); + let target = custom_portable_target_row(&model, dir.path().join("PortableREAPER"), true); + let available = vec![AvailablePackage { + package_id: PACKAGE_OSARA.to_string(), + version: Some(Version::parse("2026.8.1.2278,857265da").unwrap()), + whats_new: Some("• Fix the slider.\n• Logging improvements.".to_string()), + channel: None, + }]; + let plan = + wizard_package_plan_for_target_with_available(&model, Some(&target), &available).unwrap(); + + let osara = plan + .package_rows + .iter() + .find(|row| row.package_id == PACKAGE_OSARA) + .expect("OSARA row should be in the package list"); + assert!( + osara.details.contains("What's new in OSARA:"), + "expected a localized What's-New heading, got {:?}", + osara.details + ); + assert!(osara.details.contains("• Fix the slider.")); + // The notes follow the description, they don't replace it. + assert!(osara.details.contains(&osara.description)); + + let reaper = plan + .package_rows + .iter() + .find(|row| row.package_id == PACKAGE_REAPER) + .expect("REAPER row should be in the package list"); + assert!( + !reaper.details.contains("What's new"), + "a package without resolved notes must keep its plain details" + ); +} + +#[test] +fn reaper_windows_row_uses_unattended_handling() { + let dir = tempdir().unwrap(); + let localizer = Localizer::embedded(DEFAULT_LOCALE).unwrap(); + let model = model_from_plan( + &localizer, + Platform::Windows, + Architecture::X64, + Vec::new(), + None, + InstallPlan { + target: None, + actions: Vec::new(), + notes: Vec::new(), + }, + ); + let target = custom_portable_target_row(&model, dir.path().join("PortableREAPER"), true); + let plan = wizard_package_plan_for_target(&model, Some(&target)).unwrap(); + let reaper_row = plan + .package_rows + .iter() + .find(|row| row.package_id == PACKAGE_REAPER) + .unwrap(); + + assert!(!reaper_row.manual_attention_expected); + assert_eq!( + reaper_row.handling_summary, + model.text.package_handling_unattended + ); +} + +#[test] +fn sws_windows_uses_unattended_handling_summary() { + let localizer = Localizer::embedded(DEFAULT_LOCALE).unwrap(); + let model = model_from_plan( + &localizer, + Platform::Windows, + Architecture::X64, + Vec::new(), + None, + InstallPlan { + target: None, + actions: Vec::new(), + notes: Vec::new(), + }, + ); + + let (handling_summary, manual_attention_expected) = package_handling_summary( + &model.text, + PACKAGE_SWS, + Platform::Windows, + Architecture::X64, + ); + + assert_eq!(handling_summary, model.text.package_handling_unattended); + assert!(!manual_attention_expected); +} + +#[test] +fn osara_keymap_note_defaults_to_unavailable_when_osara_is_not_selected() { + let localizer = Localizer::embedded(DEFAULT_LOCALE).unwrap(); + let model = model_from_plan( + &localizer, + Platform::Windows, + Architecture::X64, + Vec::new(), + None, + InstallPlan { + target: None, + actions: Vec::new(), + notes: Vec::new(), + }, + ); + + let note = osara_keymap_note(&model, false, OsaraKeymapChoice::PreserveCurrent); + + assert!(note.contains("Select OSARA")); +} + +/// An antivirus block is the one install failure a user can usually +/// clear themselves, so the summary follows the (English, report-bound) +/// error text with localized remediation steps. Everything else keeps +/// exactly one error line. +/// The wizard's Spanish OSARA-translation dropdown maps each position to +/// a manifest variant id. Every position must record an EXPLICIT choice: +/// the install path falls back to the previously-installed variant when +/// none is given, so picking the first entry has to actively mean +/// "REAPER Accesible español" or a Team PMA user could never switch back. +#[test] +fn spanish_variant_dropdown_maps_each_position_to_a_manifest_variant() { + let specs = rabbit_core::package::package_specs_by_id(Platform::Windows); + let spanish = specs + .get(VARIANT_CHOICE_PACKAGE_ID) + .expect("Spanish language pack"); + + // The dropdown offers exactly the variants the manifest declares. + assert_eq!(VARIANT_CHOICE_IDS.len(), spanish.variants.len()); + for id in VARIANT_CHOICE_IDS { + assert!( + spanish.variants.iter().any(|v| v.id == *id), + "dropdown id {id:?} must exist in the manifest" + ); + } + // Position 0 is the manifest's default, so an untouched dropdown + // agrees with what a CLI run with no flag would install. + assert!( + spanish + .variants + .iter() + .any(|v| v.default && v.id == VARIANT_CHOICE_IDS[0]), + "the first entry must be the manifest default" + ); + + for (index, expected) in VARIANT_CHOICE_IDS.iter().enumerate() { + let chosen = package_variants_from_choice(Some(index as u32)); + assert_eq!( + chosen.get(VARIANT_CHOICE_PACKAGE_ID).map(String::as_str), + Some(*expected), + "position {index} should select {expected:?}" + ); + } + // An out-of-range selection falls back to the default rather than + // silently recording no choice. + let fallback = package_variants_from_choice(Some(99)); + assert_eq!( + fallback.get(VARIANT_CHOICE_PACKAGE_ID).map(String::as_str), + Some(VARIANT_CHOICE_IDS[0]) + ); +} + +/// One REAPER row planned against `available` on `channel`, for a target +/// whose REAPER was last installed from `installed_channel`. +fn reaper_row( + action: PlanActionKind, + channel: Option<&str>, + installed_channel: Option<&str>, +) -> PackageRow { + let localizer = Localizer::embedded(DEFAULT_LOCALE).unwrap(); + let text = wizard_text(&localizer); + let specs = builtin_package_specs(Platform::Windows); + let installed_channels: rabbit_core::package::PackageChannels = installed_channel + .map(|channel| { + [(PACKAGE_REAPER.to_string(), channel.to_string())] + .into_iter() + .collect() + }) + .unwrap_or_default(); + package_rows( + &localizer, + &text, + Platform::Windows, + Architecture::X64, + &specs, + &[PlanAction { + package_id: PACKAGE_REAPER.to_string(), + action, + installed_version: Some(Version::parse("7.80").unwrap()), + available_version: Some(Version::parse("7.80+dev0918").unwrap()), + reason: "test".to_string(), + }], + &[AvailablePackage { + package_id: PACKAGE_REAPER.to_string(), + version: Some(Version::parse("7.80+dev0918").unwrap()), + whats_new: None, + channel: channel.map(str::to_string), + }], + &HostCapabilities::default(), + &Default::default(), + &installed_channels, + ) + .remove(0) +} + +#[test] +fn a_development_build_says_so_in_the_row_itself() { + let row = reaper_row(PlanActionKind::Update, Some("dev"), None); + assert!( + row.summary.contains("(development build)"), + "{}", + row.summary + ); + assert!(row.details.contains("unsupported"), "{}", row.details); +} + +#[test] +fn ticking_a_row_keeps_its_channel_tag() { + let localizer = Localizer::embedded(DEFAULT_LOCALE).unwrap(); + let installation = fake_installation(); + let model = model_from_plan( + &localizer, + Platform::Windows, + Architecture::X64, + vec![installation.clone()], + Some(0), + InstallPlan { + target: Some(installation), + actions: Vec::new(), + notes: Vec::new(), + }, + ); + let mut row = reaper_row(PlanActionKind::Update, Some("dev"), None); + apply_checkbox_state_to_package_row(&model, &mut row, false).unwrap(); + apply_checkbox_state_to_package_row(&model, &mut row, true).unwrap(); + assert!( + row.summary.contains("(development build)"), + "{}", + row.summary + ); + + // The way back is only announced while the row actually takes it. + let mut back = reaper_row(PlanActionKind::Update, None, Some("dev")); + apply_checkbox_state_to_package_row(&model, &mut back, false).unwrap(); + assert!(!back.summary.contains("back to"), "{}", back.summary); + apply_checkbox_state_to_package_row(&model, &mut back, true).unwrap(); + assert!(back.summary.contains("back to"), "{}", back.summary); +} + +#[test] +fn a_row_that_installs_nothing_does_not_promise_an_install() { + // After installing a development build, the re-plan keeps REAPER. + // The tag still says which build it is, but "will be installed" + // would be false. + let row = reaper_row(PlanActionKind::Keep, Some("dev"), Some("dev")); + assert!( + row.summary.contains("(development build)"), + "{}", + row.summary + ); + assert!( + !row.details.contains("will be installed"), + "{}", + row.details + ); +} + +#[test] +fn a_pull_request_build_names_its_pull_request() { + let row = reaper_row(PlanActionKind::Update, Some("pr:1454"), None); + assert!(row.summary.contains("pull request 1454"), "{}", row.summary); + assert!(row.details.contains("90 days"), "{}", row.details); +} + +#[test] +fn going_back_to_the_regular_release_is_announced() { + // Leaving expert mode quietly takes a pre-release back to the regular + // release. The unlock dialog is long gone by then, so the row is the + // only place that can say why REAPER is being "updated" to an older + // version. + let row = reaper_row(PlanActionKind::Update, None, Some("dev")); + assert!( + row.summary.contains("back to the regular release"), + "{}", + row.summary + ); + // ...but only when this run actually does something with it. + let kept = reaper_row(PlanActionKind::Keep, None, Some("dev")); + assert!(!kept.summary.contains("back to"), "{}", kept.summary); + // and an ordinary stable row carries no note at all. + let plain = reaper_row(PlanActionKind::Update, None, None); + assert!(!plain.summary.contains('('), "{}", plain.summary); +} + +#[test] +fn expert_mode_env_values_and_channels_outside_expert_mode() { + for on in ["1", "true", "YES", " on "] { + assert!(expert_mode_value_enabled(on), "{on:?}"); + } + for off in ["", "0", "false", "no", "maybe"] { + assert!(!expert_mode_value_enabled(off), "{off:?}"); + } + // Outside expert mode no channel is passed, whatever the choices + // say - which is what sends pre-release packages back to stable. + assert!(wizard_channels(false, Some("dev".into()), Some("pr:1".into())).is_empty()); + let on = wizard_channels(true, Some("dev".into()), None); + assert_eq!(on.get(PACKAGE_REAPER).map(String::as_str), Some("dev")); + assert!(!on.contains_key(PACKAGE_OSARA)); +} diff --git a/crates/rabbit-ui-wxdragon/src/wizard/tests/request.rs b/crates/rabbit-ui-wxdragon/src/wizard/tests/request.rs new file mode 100644 index 0000000..803666d --- /dev/null +++ b/crates/rabbit-ui-wxdragon/src/wizard/tests/request.rs @@ -0,0 +1,152 @@ +use std::path::PathBuf; + +use rabbit_core::localization::{DEFAULT_LOCALE, Localizer}; +use rabbit_core::model::{Architecture, Platform}; +use rabbit_core::package::{PACKAGE_OSARA, PACKAGE_REAPACK}; +use rabbit_core::plan::{InstallPlan, PlanAction, PlanActionKind}; +use rabbit_core::version::Version; + +use super::support::*; +use crate::wizard::*; + +#[test] +fn builds_install_request_from_selected_rows() { + let localizer = Localizer::embedded(DEFAULT_LOCALE).unwrap(); + let installation = fake_installation(); + let model = model_from_plan( + &localizer, + Platform::Windows, + Architecture::X64, + vec![installation], + Some(0), + InstallPlan { + target: None, + actions: vec![ + PlanAction { + package_id: PACKAGE_OSARA.to_string(), + action: PlanActionKind::Install, + installed_version: None, + available_version: None, + reason: "Missing".to_string(), + }, + PlanAction { + package_id: PACKAGE_REAPACK.to_string(), + action: PlanActionKind::Keep, + installed_version: None, + available_version: None, + reason: "Current".to_string(), + }, + ], + notes: Vec::new(), + }, + ); + + let request = install_request_from_model( + &model, + Some(0), + &[0], + WizardInstallOptions { + dry_run: true, + allow_reaper_running: true, + stage_unsupported: false, + osara_keymap_choice: OsaraKeymapChoice::ReplaceCurrent, + package_variants: Default::default(), + reaper_language_package: None, + cache_dir: Some(PathBuf::from("C:/cache")), + package_channels: Default::default(), + }, + ) + .unwrap(); + + assert_eq!(request.resource_path, PathBuf::from("C:/REAPER")); + assert_eq!(request.package_ids, vec![PACKAGE_OSARA.to_string()]); + assert!(request.portable); + assert_eq!( + request.target_app_path, + Some(PathBuf::from("C:/REAPER/reaper.exe")) + ); + assert!(request.dry_run); + assert_eq!( + request.osara_keymap_choice, + OsaraKeymapChoice::ReplaceCurrent + ); + assert_eq!(request.cache_dir, PathBuf::from("C:/cache")); +} + +#[test] +fn install_request_requires_selected_package() { + let localizer = Localizer::embedded(DEFAULT_LOCALE).unwrap(); + let installation = fake_installation(); + let model = model_from_plan( + &localizer, + Platform::Windows, + Architecture::X64, + vec![installation], + Some(0), + InstallPlan { + target: None, + actions: Vec::new(), + notes: Vec::new(), + }, + ); + + let error = install_request_from_model(&model, Some(0), &[], WizardInstallOptions::default()) + .unwrap_err(); + + assert!(error.to_string().contains("No package")); +} + +#[test] +fn review_preview_includes_osara_keymap_choice() { + let localizer = Localizer::embedded(DEFAULT_LOCALE).unwrap(); + let installation = fake_installation(); + let model = model_from_plan( + &localizer, + Platform::Windows, + Architecture::X64, + vec![installation.clone()], + Some(0), + InstallPlan { + target: Some(installation), + actions: vec![PlanAction { + package_id: PACKAGE_OSARA.to_string(), + action: PlanActionKind::Install, + installed_version: None, + available_version: Some(Version::parse("2026.1").unwrap()), + reason: "Missing".to_string(), + }], + notes: Vec::new(), + }, + ); + + let preview = build_review_preview_for_package_rows( + &model, + model.target_rows.first(), + &[0], + &model.package_rows, + &model.notes, + OsaraKeymapChoice::ReplaceCurrent, + ); + + assert!(preview.lines.iter().any(|line| line == "OSARA key map")); + assert!( + preview + .lines + .iter() + .any(|line| { line.contains("Backup your current key map") && line.contains("OSARA") }) + ); + assert!( + !preview + .lines + .iter() + .any(|line| line == "Manual attention expected") + ); +} + +#[test] +fn default_install_options_replace_osara_keymap() { + assert_eq!( + WizardInstallOptions::default().osara_keymap_choice, + OsaraKeymapChoice::ReplaceCurrent + ); +} diff --git a/crates/rabbit-ui-wxdragon/src/wizard/tests/self_update.rs b/crates/rabbit-ui-wxdragon/src/wizard/tests/self_update.rs new file mode 100644 index 0000000..858f137 --- /dev/null +++ b/crates/rabbit-ui-wxdragon/src/wizard/tests/self_update.rs @@ -0,0 +1,85 @@ +use std::path::PathBuf; + +use rabbit_core::localization::{DEFAULT_LOCALE, Localizer}; + +use super::support::*; +use crate::wizard::*; + +#[test] +fn apply_summary_appends_signed_count_when_signatures_were_verified() { + use rabbit_core::self_update::{ReplacedFile, SignatureVerdictRecord}; + use rabbit_core::signature::SignatureVerdict; + + let report = sample_apply_report( + vec![ReplacedFile { + install_path: PathBuf::from("/install/RABBIT"), + backup_path: PathBuf::from("/install/RABBIT.rabbit-old"), + }], + vec![SignatureVerdictRecord { + source_path: PathBuf::from("/staging/RABBIT"), + verdict: SignatureVerdict::Signed { + details: "valid on disk".to_string(), + }, + }], + ); + + let localizer = Localizer::embedded(DEFAULT_LOCALE).unwrap(); + let summary = format_self_update_apply_summary(&localizer, &report); + assert!(summary.contains("Replaced 1 file(s)")); + assert!(summary.contains("Signature verification: 1 signed.")); +} + +#[test] +fn apply_summary_omits_signature_clause_when_no_verdicts_recorded() { + use rabbit_core::self_update::ReplacedFile; + + let report = sample_apply_report( + vec![ReplacedFile { + install_path: PathBuf::from("/install/RABBIT"), + backup_path: PathBuf::from("/install/RABBIT.rabbit-old"), + }], + Vec::new(), + ); + + let localizer = Localizer::embedded(DEFAULT_LOCALE).unwrap(); + let summary = format_self_update_apply_summary(&localizer, &report); + assert!(summary.contains("Replaced 1 file(s)")); + assert!(!summary.contains("Signature verification")); +} + +#[test] +fn apply_summary_reports_signed_and_unsigned_split() { + use rabbit_core::self_update::{ReplacedFile, SignatureVerdictRecord}; + use rabbit_core::signature::SignatureVerdict; + + let report = sample_apply_report( + vec![ + ReplacedFile { + install_path: PathBuf::from("/install/RABBIT"), + backup_path: PathBuf::from("/install/RABBIT.rabbit-old"), + }, + ReplacedFile { + install_path: PathBuf::from("/install/rabbit-cli"), + backup_path: PathBuf::from("/install/rabbit-cli.rabbit-old"), + }, + ], + vec![ + SignatureVerdictRecord { + source_path: PathBuf::from("/staging/RABBIT"), + verdict: SignatureVerdict::Signed { + details: "ok".to_string(), + }, + }, + SignatureVerdictRecord { + source_path: PathBuf::from("/staging/rabbit-cli"), + verdict: SignatureVerdict::Unsigned { + reason: "no signtool".to_string(), + }, + }, + ], + ); + + let localizer = Localizer::embedded(DEFAULT_LOCALE).unwrap(); + let summary = format_self_update_apply_summary(&localizer, &report); + assert!(summary.contains("Signature verification: 1 signed, 1 unsigned.")); +} diff --git a/crates/rabbit-ui-wxdragon/src/wizard/tests/summary.rs b/crates/rabbit-ui-wxdragon/src/wizard/tests/summary.rs new file mode 100644 index 0000000..3e21e42 --- /dev/null +++ b/crates/rabbit-ui-wxdragon/src/wizard/tests/summary.rs @@ -0,0 +1,435 @@ +use std::path::PathBuf; + +use rabbit_core::artifact::{ArtifactDescriptor, ArtifactKind}; +use rabbit_core::install::{InstallFileAction, InstallFileReport, InstallReport}; +use rabbit_core::localization::{DEFAULT_LOCALE, Localizer}; +use rabbit_core::model::{Architecture, Platform}; +use rabbit_core::operation::{ + ManualInstallInstruction, PackageOperationItem, PackageOperationReport, PackageOperationStatus, + PlannedExecutionKind, PlannedExecutionPlan, +}; +use rabbit_core::package::{PACKAGE_OSARA, PACKAGE_REAPACK}; +use rabbit_core::plan::{InstallPlan, PlanActionKind}; +use rabbit_core::preflight::PreflightReport; +use rabbit_core::resource::ResourceInitReport; +use rabbit_core::setup::SetupReport; +use rabbit_core::version::Version; + +use super::support::*; +use crate::wizard::*; + +#[test] +fn the_summary_says_what_runs_for_an_installer_still_in_its_zip() { + let plan = |kind, program: Option<&str>| PlannedExecutionPlan { + kind, + artifact_location: "https://nightly.link/jcsteh/osara/actions/artifacts/1.zip".to_string(), + program: program.map(str::to_string), + arguments: Vec::new(), + working_directory: None, + verification_paths: Vec::new(), + requires_elevation: false, + freshness_paths: Vec::new(), + }; + let english = Localizer::embedded(DEFAULT_LOCALE).unwrap(); + let german = Localizer::embedded("de-DE").unwrap(); + + let zipped = plan(PlannedExecutionKind::LaunchInstallerExecutable, None); + let line = planned_execution_program_line(Some(&english), &zipped).unwrap(); + assert!(line.contains("the installer inside the .zip"), "{line}"); + let line = planned_execution_program_line(Some(&german), &zipped).unwrap(); + assert!(line.contains("ZIP-Datei"), "{line}"); + + let downloaded = plan( + PlannedExecutionKind::LaunchInstallerExecutable, + Some(r"C:\cache\osara.unzipped\osara.exe"), + ); + let line = planned_execution_program_line(Some(&english), &downloaded).unwrap(); + assert!( + line.contains(r"C:\cache\osara.unzipped\osara.exe"), + "{line}" + ); + + // A plan that never names a program still gets no line. + let archive = plan(PlannedExecutionKind::ExtractArchiveAndRunInstaller, None); + assert_eq!( + planned_execution_program_line(Some(&english), &archive), + None + ); +} + +#[test] +fn setup_summary_includes_manual_instruction_notes() { + let localizer = Localizer::embedded(DEFAULT_LOCALE).unwrap(); + let model = model_from_plan( + &localizer, + Platform::Windows, + Architecture::X64, + Vec::new(), + None, + InstallPlan { + target: None, + actions: Vec::new(), + notes: Vec::new(), + }, + ); + let report = SetupReport { + resource_path: PathBuf::from("C:/PortableREAPER"), + dry_run: true, + resource_init: ResourceInitReport { + resource_path: PathBuf::from("C:/PortableREAPER"), + dry_run: true, + portable: true, + preflight: PreflightReport { + passed: true, + checks: Vec::new(), + }, + actions: Vec::new(), + }, + package_operation: PackageOperationReport { + resource_path: PathBuf::from("C:/PortableREAPER"), + dry_run: true, + install_report: None, + receipt_backup_path: None, + receipt_backup_manifest_path: None, + items: vec![PackageOperationItem { + package_id: PACKAGE_OSARA.to_string(), + plan_action: PlanActionKind::Install, + status: PackageOperationStatus::DeferredUnattended, + artifact: ArtifactDescriptor { + package_id: PACKAGE_OSARA.to_string(), + version: Version::parse("2026.1").unwrap(), + platform: Platform::Windows, + architecture: Architecture::X64, + kind: ArtifactKind::Installer, + url: "https://example.test/osara.exe".to_string(), + file_name: "osara.exe".to_string(), + channel: None, + }, + cached_artifact: None, + install_action: None, + backup_paths: Vec::new(), + backup_manifest_path: None, + planned_execution: Some(PlannedExecutionPlan { + kind: PlannedExecutionKind::LaunchInstallerExecutable, + artifact_location: "https://example.test/osara.exe".to_string(), + program: Some("https://example.test/osara.exe".to_string()), + arguments: Vec::new(), + working_directory: None, + verification_paths: vec![ + PathBuf::from("C:/PortableREAPER/UserPlugins"), + PathBuf::from("C:/PortableREAPER/osara"), + ], + requires_elevation: false, + freshness_paths: Vec::new(), + }), + manual_instruction: Some(ManualInstallInstruction { + title: "Manual install required for osara".to_string(), + steps: vec!["Use this artifact: https://example.test/osara.exe".to_string()], + notes: vec![ + "The selected workflow preserves the current key map. Leave reaper-kb.ini unchanged.".to_string(), + ], + }), + message: "This build has not implemented the planned unattended vendor installer execution path yet. RABBIT did not download or run the artifact.".to_string(), + message_code: rabbit_core::operation::PackageOperationMessage::DeferredUnattendedNotStaged { + artifact_kind: ArtifactKind::Installer, + }, + }], + }, + configuration_steps: Vec::new(), + }; + + let summary = summarize_setup_report(&model, &report); + + assert!( + summary + .detail_lines + .iter() + .any(|line| line.contains("Planned unattended execution")) + ); + assert!( + summary + .detail_lines + .iter() + .any(|line| line.contains("Runner:") && line.contains("Launch installer executable")) + ); + assert!( + summary.detail_lines.iter().any(|line| { + line.contains("Note:") && line.contains("Leave reaper-kb.ini unchanged") + }) + ); + // Architecture line + per-package plan action / status are now part + // of the saved report so power users have everything the wizard hides. + assert!( + summary + .detail_lines + .iter() + .any(|line| line.contains("Architecture:") && line.contains("x64")) + ); + assert!( + summary + .detail_lines + .iter() + .any(|line| line.contains("Plan action:") && line.contains("Will install")) + ); + assert!( + summary + .detail_lines + .iter() + .any(|line| line.contains("Status:") && line.contains("Deferred unattended")) + ); +} + +#[test] +fn setup_summary_includes_backup_paths_when_present() { + let localizer = Localizer::embedded(DEFAULT_LOCALE).unwrap(); + let model = model_from_plan( + &localizer, + Platform::Windows, + Architecture::X64, + Vec::new(), + None, + InstallPlan { + target: None, + actions: Vec::new(), + notes: Vec::new(), + }, + ); + let report = SetupReport { + resource_path: PathBuf::from("C:/PortableREAPER"), + dry_run: false, + resource_init: ResourceInitReport { + resource_path: PathBuf::from("C:/PortableREAPER"), + dry_run: false, + portable: true, + preflight: PreflightReport { + passed: true, + checks: Vec::new(), + }, + actions: Vec::new(), + }, + package_operation: PackageOperationReport { + resource_path: PathBuf::from("C:/PortableREAPER"), + dry_run: false, + install_report: Some(InstallReport { + resource_path: PathBuf::from("C:/PortableREAPER"), + dry_run: false, + preflight: PreflightReport { + passed: true, + checks: Vec::new(), + }, + receipt_written: true, + receipt_backup_path: Some(PathBuf::from( + "C:/PortableREAPER/RABBIT/backups/unix-1/RABBIT/install-state.json", + )), + backup_manifest_path: Some(PathBuf::from( + "C:/PortableREAPER/RABBIT/backups/unix-1/backup-manifest.json", + )), + actions: vec![InstallFileReport { + package_id: PACKAGE_REAPACK.to_string(), + source_path: PathBuf::from("C:/cache/reaper_reapack-x64.dll"), + target_path: PathBuf::from( + "C:/PortableREAPER/UserPlugins/reaper_reapack-x64.dll", + ), + backup_path: Some(PathBuf::from( + "C:/PortableREAPER/RABBIT/backups/unix-1/UserPlugins/reaper_reapack-x64.dll", + )), + action: InstallFileAction::Replaced, + size: 7, + sha256: "hash".to_string(), + }], + }), + receipt_backup_path: None, + receipt_backup_manifest_path: None, + items: vec![PackageOperationItem { + package_id: PACKAGE_REAPACK.to_string(), + plan_action: PlanActionKind::Update, + status: PackageOperationStatus::InstalledOrChecked, + artifact: ArtifactDescriptor { + package_id: PACKAGE_REAPACK.to_string(), + version: Version::parse("1.2.6").unwrap(), + platform: Platform::Windows, + architecture: Architecture::X64, + kind: ArtifactKind::ExtensionBinary, + url: "https://example.test/reaper_reapack-x64.dll".to_string(), + file_name: "reaper_reapack-x64.dll".to_string(), + channel: None, + }, + cached_artifact: None, + install_action: None, + backup_paths: Vec::new(), + backup_manifest_path: None, + planned_execution: None, + manual_instruction: None, + message: "Single extension binary handled by RABBIT installer.".to_string(), + message_code: + rabbit_core::operation::PackageOperationMessage::ExtensionBinaryInstalled, + }], + }, + configuration_steps: Vec::new(), + }; + + let summary = summarize_setup_report(&model, &report); + + assert!( + summary + .detail_lines + .iter() + .any(|line| line.contains("Backup file:")) + ); + assert!( + summary + .detail_lines + .iter() + .any(|line| line.contains("Receipt backup:")) + ); + assert!( + summary + .detail_lines + .iter() + .any(|line| line.contains("Backup manifest:")) + ); +} + +#[test] +fn setup_summary_includes_unattended_receipt_backup_paths() { + let localizer = Localizer::embedded(DEFAULT_LOCALE).unwrap(); + let model = model_from_plan( + &localizer, + Platform::Windows, + Architecture::X64, + Vec::new(), + None, + InstallPlan { + target: None, + actions: Vec::new(), + notes: Vec::new(), + }, + ); + let report = SetupReport { + resource_path: PathBuf::from("C:/PortableREAPER"), + dry_run: false, + resource_init: ResourceInitReport { + resource_path: PathBuf::from("C:/PortableREAPER"), + dry_run: false, + portable: true, + preflight: PreflightReport { + passed: true, + checks: Vec::new(), + }, + actions: Vec::new(), + }, + package_operation: PackageOperationReport { + resource_path: PathBuf::from("C:/PortableREAPER"), + dry_run: false, + install_report: None, + receipt_backup_path: Some(PathBuf::from( + "C:/PortableREAPER/RABBIT/backups/unattended-1/RABBIT/install-state.json", + )), + receipt_backup_manifest_path: Some(PathBuf::from( + "C:/PortableREAPER/RABBIT/backups/unattended-1/backup-manifest.json", + )), + items: vec![PackageOperationItem { + package_id: PACKAGE_OSARA.to_string(), + plan_action: PlanActionKind::Install, + status: PackageOperationStatus::InstalledOrChecked, + artifact: ArtifactDescriptor { + package_id: PACKAGE_OSARA.to_string(), + version: Version::parse("2026.1").unwrap(), + platform: Platform::Windows, + architecture: Architecture::X64, + kind: ArtifactKind::Installer, + url: "https://example.test/osara.exe".to_string(), + file_name: "osara.exe".to_string(), + channel: None, + }, + cached_artifact: None, + install_action: None, + backup_paths: Vec::new(), + backup_manifest_path: None, + planned_execution: None, + manual_instruction: None, + message: "RABBIT ran the upstream installer unattended, verified the expected target paths, and updated the RABBIT receipt.".to_string(), + message_code: rabbit_core::operation::PackageOperationMessage::UnattendedInstalled, + }], + }, + configuration_steps: Vec::new(), + }; + + let summary = summarize_setup_report(&model, &report); + + assert!( + summary + .detail_lines + .iter() + .any(|line| line.contains("Receipt backup:")) + ); + assert!( + summary + .detail_lines + .iter() + .any(|line| line.contains("Backup manifest:")) + ); +} + +/// A run with a failed package renders the "finished with errors" +/// status line, and an antivirus block adds the localized how-to-allow +/// guidance once for the whole run. +#[test] +fn setup_summary_reports_failures_and_antivirus_hint() { + let localizer = Localizer::embedded(DEFAULT_LOCALE).unwrap(); + let model = model_from_plan( + &localizer, + Platform::Windows, + Architecture::X64, + vec![fake_installation()], + Some(0), + InstallPlan { + target: None, + actions: Vec::new(), + notes: Vec::new(), + }, + ); + let mut report = empty_setup_report(PathBuf::from("C:/PortableREAPER")); + report.package_operation.items.push(PackageOperationItem { + package_id: PACKAGE_OSARA.to_string(), + plan_action: PlanActionKind::Install, + status: PackageOperationStatus::Failed, + artifact: ArtifactDescriptor { + package_id: PACKAGE_OSARA.to_string(), + version: Version::parse("2026.1").unwrap(), + platform: Platform::Windows, + architecture: Architecture::X64, + kind: ArtifactKind::Installer, + url: "https://example.test/osara.exe".to_string(), + file_name: "osara.exe".to_string(), + channel: None, + }, + cached_artifact: None, + install_action: None, + backup_paths: Vec::new(), + backup_manifest_path: None, + planned_execution: None, + manual_instruction: None, + message: "Installation failed: Windows security software blocked …".to_string(), + message_code: rabbit_core::operation::PackageOperationMessage::InstallFailed { + error: "Windows security software blocked …".to_string(), + antivirus_block: true, + }, + }); + + let summary = summarize_setup_report(&model, &report); + + assert!( + summary.status_line.contains("Finished with errors"), + "status line: {}", + summary.status_line + ); + assert!( + summary + .detail_lines + .iter() + .any(|line| line.contains("Protection history")), + "antivirus hint missing: {:?}", + summary.detail_lines + ); +} diff --git a/crates/rabbit-ui-wxdragon/src/wizard/tests/support.rs b/crates/rabbit-ui-wxdragon/src/wizard/tests/support.rs new file mode 100644 index 0000000..53bc223 --- /dev/null +++ b/crates/rabbit-ui-wxdragon/src/wizard/tests/support.rs @@ -0,0 +1,171 @@ +//! Fixtures shared by the wizard tests. + +use std::path::PathBuf; + +use rabbit_core::localization::{DEFAULT_LOCALE, Localizer}; +use rabbit_core::model::{Architecture, Confidence, Installation, InstallationKind, Platform}; +use rabbit_core::operation::PackageOperationReport; +use rabbit_core::package::{PACKAGE_OSARA, PACKAGE_REAPACK}; +use rabbit_core::plan::{InstallPlan, PlanAction, PlanActionKind}; +use rabbit_core::preflight::PreflightReport; +use rabbit_core::resource::ResourceInitReport; +use rabbit_core::setup::SetupReport; +use rabbit_core::version::Version; + +use crate::wizard::*; + +/// Build a one-package model whose single row is the German language +/// pack in the given plan state. +pub(super) fn langpack_model(action: PlanActionKind) -> (Localizer, WizardModel) { + let localizer = Localizer::embedded(DEFAULT_LOCALE).unwrap(); + let installation = fake_installation(); + let model = model_from_plan( + &localizer, + Platform::Windows, + Architecture::X64, + vec![installation.clone()], + Some(0), + InstallPlan { + target: Some(installation), + actions: vec![PlanAction { + package_id: "langpack-de".to_string(), + action, + installed_version: None, + available_version: None, + reason: "test".to_string(), + }], + notes: Vec::new(), + }, + ); + (localizer, model) +} + +pub(super) fn language_step(rows: &[ConfigurationRow]) -> &ConfigurationRow { + rows.iter() + .find(|row| row.step_id == rabbit_core::configuration::CONFIG_SET_REAPER_LANGUAGE) + .expect("set-reaper-language row should exist") +} + +pub(super) fn sample_apply_report( + replaced_files: Vec, + signature_verdicts: Vec, +) -> rabbit_core::self_update::SelfUpdateApplyReport { + use rabbit_core::model::Platform; + use rabbit_core::self_update::{ + SelfUpdateApplyReport, SelfUpdateAssetSelection, SelfUpdateCheckReport, + SelfUpdateStageReport, + }; + + let check = SelfUpdateCheckReport { + manifest_url: "https://example.test/rabbit-update-stable.json".to_string(), + current_version: Version::parse("0.1.0").unwrap(), + latest_version: Version::parse("0.2.0").unwrap(), + channel: "stable".to_string(), + published_at: "2026-04-25T00:00:00Z".to_string(), + release_notes_url: None, + minimum_supported_previous_version: None, + update_available: true, + requires_manual_transition: false, + asset: SelfUpdateAssetSelection { + platform: Platform::Windows, + url: "https://example.test/RABBIT-windows.zip".to_string(), + sha256: "0".repeat(64), + kind: rabbit_core::self_update::SelfUpdateAssetKind::Binary, + }, + }; + let stage = SelfUpdateStageReport { + check, + staging_dir: PathBuf::from("/staging"), + staged_asset_path: Some(PathBuf::from("/staging/0.2.0/RABBIT-windows.zip")), + downloaded: true, + reused_existing_file: false, + verified_sha256: Some("0".repeat(64)), + ready_to_apply: true, + status_message: "ready".to_string(), + }; + SelfUpdateApplyReport { + stage, + install_root: PathBuf::from("/install"), + replaced_files, + skipped_files: Vec::new(), + signature_verdicts, + status_message: "applied".to_string(), + } +} + +pub(super) fn fake_installation() -> Installation { + Installation { + kind: InstallationKind::Portable, + platform: Platform::Windows, + app_path: PathBuf::from("C:/REAPER/reaper.exe"), + resource_path: PathBuf::from("C:/REAPER"), + version: Some(Version::parse("7.69").unwrap()), + architecture: Some(Architecture::X64), + writable: true, + confidence: Confidence::High, + evidence: Vec::new(), + } +} + +pub(super) fn fake_standard_installation() -> Installation { + Installation { + kind: InstallationKind::Standard, + platform: Platform::Windows, + app_path: PathBuf::from("C:/Program Files/REAPER/reaper.exe"), + resource_path: PathBuf::from("C:/Users/Test/AppData/Roaming/REAPER"), + version: Some(Version::parse("7.69").unwrap()), + architecture: Some(Architecture::X64), + writable: true, + confidence: Confidence::High, + evidence: Vec::new(), + } +} + +pub(super) fn empty_setup_report(resource_path: PathBuf) -> SetupReport { + SetupReport { + resource_path: resource_path.clone(), + dry_run: true, + resource_init: ResourceInitReport { + resource_path: resource_path.clone(), + dry_run: true, + portable: true, + preflight: PreflightReport { + passed: true, + checks: Vec::new(), + }, + actions: Vec::new(), + }, + package_operation: PackageOperationReport { + resource_path, + dry_run: true, + install_report: None, + receipt_backup_path: None, + receipt_backup_manifest_path: None, + items: Vec::new(), + }, + configuration_steps: Vec::new(), + } +} + +pub(super) fn sample_install_request(resource_path: PathBuf) -> WizardInstallRequest { + WizardInstallRequest { + resource_path: resource_path.clone(), + package_ids: vec![PACKAGE_OSARA.to_string(), PACKAGE_REAPACK.to_string()], + platform: Platform::Windows, + architecture: Architecture::X64, + portable: true, + target_app_path: Some(resource_path.join("reaper.exe")), + dry_run: false, + allow_reaper_running: false, + stage_unsupported: true, + osara_keymap_choice: OsaraKeymapChoice::ReplaceCurrent, + cache_dir: PathBuf::from("C:/cache"), + force_reinstall_packages: Vec::new(), + package_variants: Default::default(), + reaper_language_package: None, + configuration_step_ids: Vec::new(), + declined_packages: Vec::new(), + accepted_packages: Vec::new(), + package_channels: Default::default(), + } +} diff --git a/crates/rabbit-ui-wxdragon/src/wizard/tests/target.rs b/crates/rabbit-ui-wxdragon/src/wizard/tests/target.rs new file mode 100644 index 0000000..5eb4381 --- /dev/null +++ b/crates/rabbit-ui-wxdragon/src/wizard/tests/target.rs @@ -0,0 +1,118 @@ +use rabbit_core::localization::{DEFAULT_LOCALE, Localizer}; +use rabbit_core::model::{Architecture, Confidence, Installation, InstallationKind, Platform}; +use rabbit_core::plan::InstallPlan; +use tempfile::tempdir; + +use crate::wizard::*; + +#[test] +fn builds_custom_portable_target_row() { + let dir = tempdir().unwrap(); + let localizer = Localizer::embedded(DEFAULT_LOCALE).unwrap(); + let model = model_from_plan( + &localizer, + Platform::Windows, + Architecture::X64, + Vec::new(), + None, + InstallPlan { + target: None, + actions: Vec::new(), + notes: Vec::new(), + }, + ); + + let row = custom_portable_target_row(&model, dir.path().join("PortableREAPER"), true); + + assert!(row.selected); + assert!(row.portable); + assert!(row.writable); + assert!(row.app_path.is_none()); + assert_eq!( + row.planned_app_path, + dir.path().join("PortableREAPER").join("reaper.exe") + ); + assert!(row.label.contains("Portable REAPER folder")); + assert!(row.details.contains("REAPER application path")); + assert!(row.details.contains("REAPER version: Unknown version")); + assert!(!row.details.contains("Architecture")); + assert!(row.details.contains("Portable resource path")); +} + +#[test] +fn custom_portable_target_uses_reaper_exe_when_present() { + let dir = tempdir().unwrap(); + let resource_path = dir.path().join("PortableREAPER"); + std::fs::create_dir_all(&resource_path).unwrap(); + std::fs::write(resource_path.join("reaper.exe"), b"").unwrap(); + let localizer = Localizer::embedded(DEFAULT_LOCALE).unwrap(); + let model = model_from_plan( + &localizer, + Platform::Windows, + Architecture::X64, + Vec::new(), + None, + InstallPlan { + target: None, + actions: Vec::new(), + notes: Vec::new(), + }, + ); + + let row = custom_portable_target_row(&model, resource_path.clone(), true); + + assert_eq!(row.app_path, Some(resource_path.join("reaper.exe"))); + assert_eq!(row.planned_app_path, resource_path.join("reaper.exe")); +} + +#[test] +fn refreshed_standard_target_row_detects_app_that_appeared_after_startup() { + let dir = tempdir().unwrap(); + let resource_path = dir.path().join("REAPER"); + let app_path = dir + .path() + .join("Program Files") + .join("REAPER") + .join("reaper.exe"); + std::fs::create_dir_all(&resource_path).unwrap(); + std::fs::create_dir_all(app_path.parent().unwrap()).unwrap(); + + let localizer = Localizer::embedded(DEFAULT_LOCALE).unwrap(); + let installation = Installation { + kind: InstallationKind::Standard, + platform: Platform::Windows, + app_path: app_path.clone(), + resource_path: resource_path.clone(), + version: None, + architecture: Some(Architecture::X64), + writable: true, + confidence: Confidence::Low, + evidence: Vec::new(), + }; + let model = model_from_plan( + &localizer, + Platform::Windows, + Architecture::X64, + vec![installation], + Some(0), + InstallPlan { + target: None, + actions: Vec::new(), + notes: Vec::new(), + }, + ); + + assert!(model.target_rows[0].app_path.is_none()); + + std::fs::write(&app_path, b"").unwrap(); + + let refreshed = refreshed_target_row(&model, &model.target_rows[0]); + + assert_eq!(refreshed.app_path, Some(app_path.clone())); + assert_eq!(refreshed.planned_app_path, app_path); + assert!( + refreshed + .details + .contains(&resource_path.display().to_string()) + ); +} diff --git a/crates/rabbit-ui-wxdragon/src/wizard/text.rs b/crates/rabbit-ui-wxdragon/src/wizard/text.rs new file mode 100644 index 0000000..82a67d2 --- /dev/null +++ b/crates/rabbit-ui-wxdragon/src/wizard/text.rs @@ -0,0 +1,260 @@ +//! Localized labels for every wizard page, including the wx mnemonic +//! (`&`-prefixed access key) encoding native buttons expect. + +use rabbit_core::localization::Localizer; + +use super::model::{WizardStep, WizardStepLabel, WizardText}; +use super::packages::VARIANT_CHOICE_IDS; + +pub(crate) fn wizard_text(localizer: &Localizer) -> WizardText { + WizardText { + common_yes: localizer.text("common-yes").value, + common_no: localizer.text("common-no").value, + target_heading: localizer.text("wizard-target-heading").value, + target_language_label: localizer.text("wizard-target-language-label").value, + target_language_restart_note: localizer.text("wizard-target-language-restart-note").value, + target_choice_label: localizer.text("wizard-target-choice-label").value, + target_details_label: localizer.text("wizard-target-details-label").value, + target_empty: localizer.text("wizard-target-empty").value, + target_portable_choice: localizer.text("wizard-target-portable-choice").value, + target_portable_folder_label: localizer.text("wizard-target-portable-folder-label").value, + target_portable_folder_message: localizer + .text("wizard-target-portable-folder-message") + .value, + target_portable_folder_browse_label: localizer + .text("wizard-target-portable-folder-browse-label") + .value, + target_portable_pending_details: localizer + .text("wizard-target-portable-pending-details") + .value, + target_custom_portable_label: localizer.text("wizard-target-custom-portable-label").value, + target_custom_portable_app_path_label: localizer + .text("wizard-target-custom-portable-app-path-label") + .value, + target_custom_portable_path_label: localizer + .text("wizard-target-custom-portable-path-label") + .value, + target_custom_portable_version_label: localizer + .text("wizard-target-custom-portable-version-label") + .value, + target_custom_portable_writable_label: localizer + .text("wizard-target-custom-portable-writable-label") + .value, + target_custom_portable_note: localizer.text("wizard-target-custom-portable-note").value, + packages_heading: localizer.text("wizard-packages-heading").value, + packages_list_label: localizer.text("wizard-packages-list-label").value, + packages_tree_group_label: localizer.text("wizard-packages-tree-group-label").value, + additional_software_tree_group_label: localizer + .text("wizard-additional-software-tree-group-label") + .value, + language_tree_group_label: localizer.text("wizard-language-tree-group-label").value, + configuration_tree_group_label: localizer + .text("wizard-configuration-tree-group-label") + .value, + reapack_ack_heading: localizer.text("wizard-reapack-ack-heading").value, + reapack_ack_body: localizer.text("wizard-reapack-ack-body").value, + reapack_ack_link_label: localizer.text("wizard-reapack-ack-link-label").value, + reapack_ack_confirm_label: localizer.text("wizard-reapack-ack-confirm-label").value, + version_check_heading: localizer.text("wizard-version-check-heading").value, + version_check_status_pending: localizer.text("wizard-version-check-status-pending").value, + version_check_progress_label: localizer.text("wizard-version-check-progress-label").value, + version_check_error_heading: localizer.text("wizard-version-check-error-heading").value, + package_details_label: localizer.text("wizard-package-details-label").value, + packages_osara_keymap_heading: localizer.text("wizard-packages-osara-keymap-heading").value, + packages_osara_keymap_replace_label: localizer + .text("wizard-packages-osara-keymap-replace-label") + .value, + packages_reaper_language_label: localizer.text("packages-reaper-language-label").value, + packages_spanish_variant_label: localizer.text("packages-spanish-variant-label").value, + packages_spanish_variant_options: VARIANT_CHOICE_IDS + .iter() + .map(|id| { + localizer + .text(&format!("package-langpack-es-variant-{id}")) + .value + }) + .collect(), + packages_osara_keymap_unavailable_note: localizer + .text("wizard-packages-osara-keymap-unavailable-note") + .value, + packages_osara_keymap_preserve_note: localizer + .text("wizard-packages-osara-keymap-preserve-note") + .value, + packages_osara_keymap_replace_note: localizer + .text("wizard-packages-osara-keymap-replace-note") + .value, + package_details_handling_prefix: localizer + .text("wizard-package-details-handling-prefix") + .value, + package_handling_automatic: localizer.text("wizard-package-handling-automatic").value, + package_handling_unattended: localizer.text("wizard-package-handling-unattended").value, + package_handling_planned: localizer.text("wizard-package-handling-planned").value, + package_handling_manual: localizer.text("wizard-package-handling-manual").value, + package_handling_unavailable: localizer.text("wizard-package-handling-unavailable").value, + review_heading: localizer.text("wizard-review-heading").value, + review_target_prefix: localizer.text("wizard-review-target-prefix").value, + review_package_heading: localizer.text("wizard-review-package-heading").value, + review_osara_keymap_heading: localizer.text("wizard-review-osara-keymap-heading").value, + review_osara_keymap_preserve: localizer.text("wizard-review-osara-keymap-preserve").value, + review_osara_keymap_replace: localizer.text("wizard-review-osara-keymap-replace").value, + review_notes_heading: localizer.text("wizard-review-notes-heading").value, + review_preflight_prefix: localizer.text("wizard-review-preflight-prefix").value, + review_no_target: localizer.text("wizard-review-no-target").value, + review_no_package: localizer.text("wizard-review-no-package").value, + progress_heading: localizer.text("wizard-progress-heading").value, + progress_status: localizer.text("wizard-progress-status-idle").value, + done_heading: localizer.text("wizard-done-heading").value, + done_status: localizer.text("wizard-done-status-idle").value, + progress_status_running: localizer.text("wizard-progress-status-running").value, + progress_status_cancelling: localizer.text("wizard-progress-status-cancelling").value, + progress_details_label: localizer.text("wizard-progress-details-label").value, + progress_details_idle: localizer.text("wizard-progress-details-idle").value, + progress_details_starting: localizer.text("wizard-progress-details-starting").value, + progress_details_cache_prefix: localizer.text("wizard-progress-details-cache-prefix").value, + done_status_success: localizer.text("wizard-done-status-success").value, + done_status_completed_with_errors: localizer + .text("wizard-done-status-completed-with-errors") + .value, + done_status_error: localizer.text("wizard-done-status-error").value, + done_status_cancelled: localizer.text("wizard-done-status-cancelled").value, + done_status_no_packages: localizer.text("wizard-done-status-no-packages").value, + done_show_details_label: localizer.text("wizard-done-show-details").value, + done_launch_reaper_label: localized_wx_mnemonic_label( + localizer, + "wizard-done-launch-reaper", + "wizard-done-launch-reaper-mnemonic", + ), + done_open_resource_label: localized_wx_mnemonic_label( + localizer, + "wizard-done-open-resource", + "wizard-done-open-resource-mnemonic", + ), + done_no_reaper_app: localizer.text("wizard-done-no-reaper-app").value, + done_launch_reaper_error_prefix: localizer + .text("wizard-done-launch-reaper-error-prefix") + .value, + done_open_resource_error_prefix: localizer + .text("wizard-done-open-resource-error-prefix") + .value, + done_self_update_apply_running: localizer + .text("wizard-done-self-update-apply-running") + .value, + done_self_update_error_prefix: localizer.text("wizard-done-self-update-error-prefix").value, + done_self_update_relaunch_prefix: localizer + .text("wizard-done-self-update-relaunch-prefix") + .value, + self_update_status_checking: localizer.text("wizard-self-update-status-checking").value, + close_during_install_title: localizer.text("wizard-close-during-install-title").value, + close_during_install_body: localizer.text("wizard-close-during-install-body").value, + close_during_self_update_title: localizer + .text("wizard-close-during-self-update-title") + .value, + close_during_self_update_body: localizer.text("wizard-close-during-self-update-body").value, + expert_enable_title: localizer.text("wizard-expert-enable-title").value, + expert_enable_body: localizer.text("wizard-expert-enable-body").value, + expert_disabled_title: localizer.text("wizard-expert-disabled-title").value, + expert_disabled_body: localizer.text("wizard-expert-disabled-body").value, + expert_first_page_title: localizer.text("wizard-expert-first-page-title").value, + expert_first_page_body: localizer.text("wizard-expert-first-page-body").value, + expert_busy_body: localizer.text("wizard-expert-busy-body").value, + expert_reaper_builds_label: localizer.text("wizard-expert-reaper-builds-label").value, + expert_reaper_builds_stable: localizer.text("wizard-expert-reaper-builds-stable").value, + expert_reaper_builds_dev: localizer.text("package-channel-reaper-dev").value, + expert_osara_builds_label: localizer.text("wizard-expert-osara-builds-label").value, + expert_osara_builds_snapshot: localizer.text("wizard-expert-osara-builds-snapshot").value, + expert_osara_builds_loading: localizer.text("wizard-expert-osara-builds-loading").value, + expert_osara_builds_unavailable: localizer + .text("wizard-expert-osara-builds-unavailable") + .value, + } +} + +pub(crate) fn localized_wx_mnemonic_label( + localizer: &Localizer, + label_id: &str, + mnemonic_id: &str, +) -> String { + let label = localizer.text(label_id).value; + // wxWidgets' OSX backend binds button-label `&` mnemonics as Cmd+letter + // accelerators, which collides with macOS system shortcuts (Cmd+C + // copy, Cmd+S save, Cmd+I info, …) and would, e.g., trigger the + // Close button when the user just wanted to copy. Apple's HIG also + // doesn't use mnemonics on buttons, so dropping them on macOS is the + // platform-appropriate behavior in addition to fixing the collision. + // Other platforms keep the underlined mnemonic + Alt/Option key. + if cfg!(target_os = "macos") { + return escape_wx_label(&label); + } + wx_mnemonic_label(&label, &localizer.text(mnemonic_id).value) +} + +pub(crate) fn wx_mnemonic_label(label: &str, mnemonic: &str) -> String { + let Some(key) = mnemonic.trim().chars().next() else { + return escape_wx_label(label); + }; + + let mut output = String::new(); + let mut inserted = false; + for label_char in label.chars() { + if !inserted && mnemonic_matches(label_char, key) { + output.push('&'); + inserted = true; + } + push_escaped_wx_label_char(&mut output, label_char); + } + + if !inserted { + if !output.is_empty() { + output.push(' '); + } + output.push('('); + output.push('&'); + push_escaped_wx_label_char(&mut output, key); + output.push(')'); + } + + output +} + +fn escape_wx_label(label: &str) -> String { + let mut output = String::new(); + for label_char in label.chars() { + push_escaped_wx_label_char(&mut output, label_char); + } + output +} + +fn push_escaped_wx_label_char(output: &mut String, label_char: char) { + if label_char == '&' { + output.push_str("&&"); + } else { + output.push(label_char); + } +} + +fn mnemonic_matches(label_char: char, mnemonic: char) -> bool { + label_char == mnemonic + || label_char.eq_ignore_ascii_case(&mnemonic) + || label_char.to_lowercase().to_string() == mnemonic.to_lowercase().to_string() +} + +pub(crate) fn wizard_steps(localizer: &Localizer) -> Vec { + [ + (WizardStep::Target, "wizard-step-target"), + (WizardStep::VersionCheck, "wizard-step-version-check"), + (WizardStep::Packages, "wizard-step-packages"), + ( + WizardStep::ReapackAcknowledgement, + "wizard-step-reapack-acknowledgement", + ), + (WizardStep::Review, "wizard-step-review"), + (WizardStep::Progress, "wizard-step-progress"), + (WizardStep::Done, "wizard-step-done"), + ] + .into_iter() + .map(|(step, key)| WizardStepLabel { + step, + label: localizer.text(key).value, + }) + .collect() +} diff --git a/crates/rabbit-ui-wxdragon/src/wx_app.rs b/crates/rabbit-ui-wxdragon/src/wx_app.rs index 1d13ee7..88c6397 100644 --- a/crates/rabbit-ui-wxdragon/src/wx_app.rs +++ b/crates/rabbit-ui-wxdragon/src/wx_app.rs @@ -1,7432 +1,1115 @@ +//! The wxWidgets wizard shell: `run` builds the frame, wires every page's +//! events, and hands the work to the modules beside it. +mod close_guard; +mod expert_mode; +mod globals; +mod packages_page; +mod pages; +mod progress_ui; +mod self_update_ui; +mod shell; +mod version_check; +mod widgets; + +use expert_mode::{ + EXPERT_MODE, RUN_AVAILABLE, RUN_CHANNELS, apply_expert_mode, bind_expert_mode_chord, + expert_mode, osara_build_channel, reaper_build_channel, +}; +use globals::abandon_version_check; + use std::cell::{Cell, RefCell}; -use std::path::Path; +use std::collections::HashMap; use std::path::PathBuf; -use std::process::Command; use std::rc::Rc; -use std::sync::{ - Arc, Mutex, - atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering}, -}; - -use rabbit_core::cancel::CancelToken; -use rabbit_core::localization::{Localizer, resolve_runtime_locale}; -use rabbit_core::self_update::SelfUpdateCheckReport; - -// FluentBundle is !Send, so we keep one Localizer instance per UI thread and -// have call_after bodies read it from this thread-local rather than capturing -// it through worker threads. The wxdragon event loop runs every call_after -// body on the same thread that initialised UI_LOCALIZER (the main thread). -thread_local! { - static UI_LOCALIZER: RefCell>> = const { RefCell::new(None) }; - /// Top-level wizard frame, stashed here so transient modal dialogs - /// (e.g., the once-per-session "RABBIT update available" prompt) can - /// parent themselves on the wizard window without `Frame` having to - /// ride inside `Send`-requiring `call_after` closures or the - /// `WizardWidgets` struct (which is captured by those closures). - /// `Frame` doesn't impl `Send` because its underlying pointer is - /// `*mut`; in practice we only ever access it on the UI thread, but - /// the type system won't accept that as a static promise. - static UI_FRAME: RefCell> = const { RefCell::new(None) }; - /// Post-install rescan hook. The install click handler arms this with a - /// closure that captures the UI-thread `Rc` shared state for - /// `package_rows`/`package_notes`/`can_install`. The wizard install - /// runs on a worker thread; its `call_after` success branch fires the - /// hook so the cached package state reflects what the just-completed - /// install left on disk. Lives in a thread-local because the - /// `Rc` it captures is `!Send` and can't ride inside the - /// `call_after` `Box`. - static POST_INSTALL_HOOK: RefCell>> = const { RefCell::new(None) }; -} - -fn install_ui_localizer(localizer: Localizer) { - UI_LOCALIZER.with(|cell| { - *cell.borrow_mut() = Some(Rc::new(localizer)); - }); -} - -fn with_ui_localizer(f: F) { - UI_LOCALIZER.with(|cell| { - if let Some(localizer) = cell.borrow().as_ref() { - f(localizer); - } - }); -} - -fn install_ui_frame(frame: Frame) { - UI_FRAME.with(|cell| { - *cell.borrow_mut() = Some(frame); - }); -} - -fn with_ui_frame(f: F) { - UI_FRAME.with(|cell| { - if let Some(frame) = cell.borrow().as_ref() { - f(frame); - } - }); -} - -fn arm_post_install_hook(callback: impl FnOnce() + 'static) { - POST_INSTALL_HOOK.with(|cell| { - *cell.borrow_mut() = Some(Box::new(callback)); - }); -} - -fn fire_post_install_hook() { - let callback = POST_INSTALL_HOOK.with(|cell| cell.borrow_mut().take()); - if let Some(callback) = callback { - callback(); - } -} - -/// Stages of the deferred latest-version fetch the wizard runs once the user -/// transitions Target → Packages. -enum VersionCheckEvent { - /// Per-package outcome: a fetched version plus the package's optional - /// What's-New notes, or an error message. - Result { - package_id: String, - /// (version, What's-New notes, the channel the version came from). - outcome: std::result::Result<(String, Option, Option), String>, - }, - /// Worker has finished iterating all packages — the UI should rebuild the - /// package list with the fetched data and re-enable interaction. - Finished, -} - -/// Dispatcher set up by the Target → Packages click handler so the -/// version-check worker's `call_after` posts can mutate UI-thread-only state -/// (Rc-based package_rows, package_notes, can_install) without violating Send. -type VersionCheckDispatcher = Box; - -thread_local! { - static VERSION_CHECK_DISPATCHER: RefCell> = - const { RefCell::new(None) }; -} - -fn install_version_check_dispatcher(dispatcher: VersionCheckDispatcher) { - VERSION_CHECK_DISPATCHER.with(|cell| { - *cell.borrow_mut() = Some(dispatcher); - }); -} - -/// Which version check is current. Each check tags its events with the -/// number it started under, and Back from the check's page moves the number -/// on, so a check that was left behind can neither advance the wizard nor mix -/// its results into the next one. It would carry the builds chosen before -/// Back, and those may since have changed (or expert mode been turned off). -static VERSION_CHECK_GENERATION: AtomicU64 = AtomicU64::new(0); - -fn abandon_version_check() { - VERSION_CHECK_GENERATION.fetch_add(1, Ordering::SeqCst); -} +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex}; -fn dispatch_version_check_event(generation: u64, event: VersionCheckEvent) { - if generation != VERSION_CHECK_GENERATION.load(Ordering::SeqCst) { - return; - } - VERSION_CHECK_DISPATCHER.with(|cell| { - if let Some(dispatcher) = cell.borrow_mut().as_mut() { - dispatcher(event); - } - }); -} -// `ConfigurationRow` and `recompute_configuration_row_availability` are -// brought in for the upcoming Configuration tree-group wiring; the model -// currently feeds the install pipeline its initial recommended-selection -// directly via `selected_configuration_step_ids` so the import-warnings -// suppression is a stop-gap until the tree UI lands. -#[allow(unused_imports)] -use crate::{ConfigurationRow, recompute_configuration_row_availability}; use crate::{ - OsaraKeymapChoice, PackageRow, TargetRow, UiBootstrapOptions, WizardInstallOptions, - WizardModel, WizardOutcomeReport, apply_checkbox_state_to_package_row, - apply_version_check_failures_to_rows, build_review_preview_for_package_rows, - custom_portable_target_row, execute_wizard_install_with_progress, - format_self_update_apply_summary, format_self_update_check_summary, - install_request_from_target_and_rows, load_wizard_model, localized_package_display_name, - localizer_from_options, osara_keymap_note, osara_selected_for_rows, - reapack_selected_for_install_or_update, refreshed_target_row, relaunch_rabbit_after_apply, - run_wizard_self_update_apply, run_wizard_self_update_check, - run_wizard_self_update_release_notes, save_wizard_outcome_report, - selected_configuration_step_ids, wizard_desired_package_ids, wizard_outcome_report_from_error, + UiBootstrapOptions, WizardInstallOptions, WizardOutcomeReport, + build_review_preview_for_package_rows, execute_wizard_install_with_progress, + install_request_from_target_and_rows, load_wizard_model, localizer_from_options, + reapack_selected_for_install_or_update, recompute_configuration_row_availability, + refreshed_target_row, run_wizard_self_update_check, run_wizard_self_update_release_notes, + save_wizard_outcome_report, selected_configuration_step_ids, wizard_outcome_report_from_error, wizard_outcome_report_from_success, wizard_package_plan_for_target, wizard_package_plan_for_target_with_available, }; -use rabbit_core::detection::detect_components; -use rabbit_core::latest::fetch_latest_details_for_package_on; -use rabbit_core::model::Platform; -use rabbit_core::package::PACKAGE_REAPER; -use rabbit_core::plan::{AvailablePackage, PlanActionKind}; -use rabbit_core::progress::{ProgressEvent, ProgressReporter}; -use rabbit_core::version::Version; -use std::collections::HashMap; -#[cfg(target_os = "windows")] -use wxdragon::event::tree_events::TreeEventData; +use rabbit_core::localization::resolve_runtime_locale; +use rabbit_core::progress::ProgressReporter; + use wxdragon::prelude::*; use wxdragon::widgets::SimpleBook; -#[cfg(target_os = "windows")] -use wxdragon::widgets::treectrl::{TreeCtrl, TreeCtrlStyle, TreeItemId}; -// Non-Windows uses wxDataViewCtrl with a custom tree model + a toggle -// renderer because there's no equivalent of TVS_CHECKBOXES on macOS's -// NSOutlineView or GTK's GtkTreeView. wxDataView's DataViewToggleRenderer -// is rendered by the platform's native cell-rendering path and (on macOS -// in particular) is exposed through NSAccessibility as a real checkbox -// cell — not as good as Windows' TVS_CHECKBOXES on UIA, but the closest -// portable option without forking wxdragon. -#[cfg(not(target_os = "windows"))] -use wxdragon::widgets::dataview::{ - CustomDataViewTreeModel, DataViewAlign, DataViewCellMode, DataViewColumn, DataViewColumnFlags, - DataViewCtrl, DataViewEventHandler, DataViewStyle, DataViewTextRenderer, - DataViewToggleRenderer, Variant, VariantType, +use crate::wx_app::close_guard::{ + CloseVerdict, InstallRunState, announce_stopping, close_verdict, confirm_stop_install, + show_close_blocked_by_self_update, +}; +use crate::wx_app::globals::{ + arm_post_install_hook, fire_post_install_hook, install_ui_frame, install_ui_localizer, + with_ui_frame, with_ui_localizer, +}; +use crate::wx_app::packages_page::{ + PackagesStateCell, new_packages_state, refresh_package_checklist, +}; +use crate::wx_app::pages::{add_pages, build_language_footer}; +use crate::wx_app::progress_ui::{ProgressUiState, apply_progress_event_to_ui}; +use crate::wx_app::self_update_ui::{SelfUpdateUiState, render_self_update_status}; +use crate::wx_app::shell::{launch_reaper, open_resource_folder, seat_macos_apple_languages}; +use crate::wx_app::version_check::{VersionCheckUi, start_version_check}; +use crate::wx_app::widgets::{ + append_done_status, bind_done_page_enter_closes, bind_reapack_ack_navigation_updates, + bind_target_navigation_updates, can_launch_last_reaper_path, checked_package_indices, + clone_last_path, clone_last_resource_path, effective_can_install, osara_keymap_choice, + planned_reaper_launch_path_for_target, progress_details_for_start, reapack_ack_confirmed, + refresh_target_choice, refreshed_target_index, selected_target_row, set_last_path, + set_last_report, set_last_resource_path, step_status, target_is_valid, update_navigation, }; -const TARGET_STEP: usize = 0; -const VERSION_CHECK_STEP: usize = 1; -const PACKAGES_STEP: usize = 2; -const REAPACK_ACK_STEP: usize = 3; -const REVIEW_STEP: usize = 4; -const PROGRESS_STEP: usize = 5; -const DONE_STEP: usize = 6; - -/// Set while the self-update worker is swapping RABBIT's own files. That -/// swap has no unwind path — the whole point of the progress window having -/// no Cancel button — so the close handler refuses to quit under it rather -/// than leaving a half-replaced executable behind. A plain static because -/// `start_self_update_apply` is reachable from the startup prompt and from -/// the Done page, neither of which shares the wizard's state. -static SELF_UPDATE_APPLYING: AtomicBool = AtomicBool::new(false); - -/// The install worker's half of the close handshake. -/// -/// Closing the window used to end the process wherever the worker happened -/// to be: mid-download, mid-extract, or between a vendor installer finishing -/// and RABBIT writing the receipt that records it. Nothing was cleaned up, -/// because nothing unwound — no `Drop` ran, so the install lock file, the -/// extraction temp dirs and any `.part` downloads stayed behind, and the -/// user got no report of what had actually landed. -/// -/// Now the close handler talks to the worker instead. It flips `cancel`, -/// leaves `close_when_done` set, and vetoes the close; the pipeline stops at -/// its next package boundary, unwinds normally, writes its report, and the -/// completion closure closes the window for real. -#[derive(Default)] -struct InstallRunState { - /// The running install's token. `None` before the first install and - /// after each one finishes. - cancel: Mutex>, - /// True between the Install click and the worker's completion closure. - running: AtomicBool, - /// Set when the user asked to close during an install: the completion - /// closure closes the window once the worker is done. - close_when_done: AtomicBool, -} - -impl InstallRunState { - fn begin(&self) -> CancelToken { - let token = CancelToken::new(); - if let Ok(mut slot) = self.cancel.lock() { - *slot = Some(token.clone()); - } - self.running.store(true, Ordering::SeqCst); - token - } - - fn finish(&self) { - self.running.store(false, Ordering::SeqCst); - self.close_when_done.store(false, Ordering::SeqCst); - if let Ok(mut slot) = self.cancel.lock() { - *slot = None; - } - } +pub(crate) const TARGET_STEP: usize = 0; +pub(crate) const VERSION_CHECK_STEP: usize = 1; +pub(crate) const PACKAGES_STEP: usize = 2; +pub(crate) const REAPACK_ACK_STEP: usize = 3; +pub(crate) const REVIEW_STEP: usize = 4; +pub(crate) const PROGRESS_STEP: usize = 5; +pub(crate) const DONE_STEP: usize = 6; - fn is_running(&self) -> bool { - self.running.load(Ordering::SeqCst) - } +pub fn run() { + // Pre-seat Cocoa's per-process language so VoiceOver picks a voice that + // matches the in-app Fluent locale. Has to happen before `wxdragon::main` + // because that brings NSApplication / NSBundle up, and `AppleLanguages` + // is only consulted on first read of `[NSBundle preferredLocalizations]`. + // No-op off macOS. + seat_macos_apple_languages(&resolve_runtime_locale()); - /// Ask the pipeline to stop, and remember that the window should close - /// once it has. Returns `false` when there was nothing left to stop — - /// the run finished while the confirmation dialog was on screen, whose - /// nested event loop is what lets the worker's completion closure run - /// mid-question. Both halves happen under the one lock `finish` takes, - /// so the answer can't go stale between them. - fn request_stop_and_close(&self) -> bool { - let Ok(slot) = self.cancel.lock() else { - return false; + let _ = wxdragon::main(|_| { + let bootstrap = UiBootstrapOptions { + locale: resolve_runtime_locale(), + online_versions: false, + ..UiBootstrapOptions::default() }; - match slot.as_ref() { - Some(token) => { - self.close_when_done.store(true, Ordering::SeqCst); - token.cancel(); - true + match localizer_from_options(&bootstrap) { + Ok(localizer) => install_ui_localizer(localizer), + Err(error) => { + eprintln!("{error}"); + return; } - None => false, } - } - - fn stop_requested(&self) -> bool { - self.close_when_done.load(Ordering::SeqCst) - } -} - -#[derive(Default)] -struct SelfUpdateUiState { - /// Result of the one-shot manifest check at startup. `None` while the - /// startup probe is still running; `Some(Ok)` on success; `Some(Err)` - /// carries the formatted error message (RabbitError isn't Clone). - check: Option>, - /// What's-New notes for the pending update, resolved by the same startup - /// worker as `check`. `None` when there is no update, or when the notes - /// couldn't be fetched — the prompt then falls back to its plain form. - release_notes: Option, - /// Last status string written to the status bar — used to suppress - /// screen-reader re-announcements when nothing has changed. - last_status: String, - /// `true` once the once-per-session "RABBIT update available" prompt - /// dialog has been shown. Re-renders that follow the same check - /// result (e.g., a step change that re-invokes render) skip the - /// modal so the user isn't re-prompted after dismissing it. - prompted: bool, -} - -/// What closing the window should do right now. -enum CloseVerdict { - /// Nothing is running: destroy the window. - Allow, - /// An install is running and the user has not been asked yet. - Ask, - /// An install is running and the user already said "stop": the worker - /// is unwinding and will close the window when it is done. - AlreadyStopping, - /// RABBIT is replacing its own files. There is no safe moment to quit. - SelfUpdateInProgress, -} - -/// Say that RABBIT is stopping, in both places the progress page speaks -/// from: the status heading and the running log. -/// -/// The log is a `TextCtrl` and takes focus; the status heading is a -/// `StaticText` and cannot, and a label change on its own is not something -/// a screen reader announces. Moving focus onto the log is what makes the -/// answer to "did it hear me?" audible. -fn announce_stopping(widgets: &WizardWidgets, model: &WizardModel) { - widgets - .progress_status - .set_label(&model.text.progress_status_cancelling); - widgets - .progress_details - .append_text(&format!("\n{}", model.text.progress_status_cancelling)); - widgets.progress_details.set_focus(); -} - -fn close_verdict(install_run: &InstallRunState) -> CloseVerdict { - if SELF_UPDATE_APPLYING.load(Ordering::SeqCst) { - return CloseVerdict::SelfUpdateInProgress; - } - if !install_run.is_running() { - return CloseVerdict::Allow; - } - if install_run.stop_requested() { - return CloseVerdict::AlreadyStopping; - } - CloseVerdict::Ask -} - -/// Expert mode for this session: the hidden unlock for pre-release builds. -/// Never saved - RABBIT keeps no settings file - so it starts from -/// `RABBIT_EXPERT` and is otherwise toggled with Ctrl+Shift+E (Cmd+Shift+E). -/// A process-wide flag rather than a thread-local because the self-update -/// relaunch runs on a worker thread and has to pass it on. -static EXPERT_MODE: AtomicBool = AtomicBool::new(false); + let model = match load_wizard_model(bootstrap) { + Ok(model) => model, + Err(error) => { + eprintln!("{error}"); + return; + } + }; -fn expert_mode() -> bool { - EXPERT_MODE.load(Ordering::SeqCst) -} + EXPERT_MODE.store(crate::expert_mode_requested_by_env(), Ordering::SeqCst); + let frame = Frame::builder() + .with_title(&model.window_title) + .with_size(Size::new(820, 680)) + .build(); + frame.set_name("rabbit-main-window"); + // Keep a floor on the window size: the wizard pages stack fixed-height + // controls (lists, detail panes, notes) that wrap to more lines in + // longer translations, so shrinking below this clips bottom content. + frame.set_min_size(Size::new(760, 600)); + install_ui_frame(frame); -thread_local! { - /// The pull request behind each OSARA build choice; entry 0 is the - /// regular snapshot. Rebuilt whenever the list is fetched. - static OSARA_PULL_REQUESTS: RefCell>> = RefCell::new(vec![None]); - /// The pull request the selected target's OSARA was last installed from, - /// to select once the list of builds arrives. - static OSARA_REMEMBERED_PULL_REQUEST: Cell> = const { Cell::new(None) }; - /// The OSARA build choice, so the background listing can fill it in on - /// the UI thread. - static OSARA_BUILD_CHOICE: Cell> = const { Cell::new(None) }; - /// The channels chosen when leaving the target page. The version check - /// and the install both read this, so they always agree on the builds. - static RUN_CHANNELS: RefCell = - const { RefCell::new(rabbit_core::package::PackageChannels::new()) }; - /// The versions the last version check found, on this run's channels. - /// Re-planning after an install uses these rather than the versions - /// fetched at launch, which knew nothing of the build choices. - static RUN_AVAILABLE: RefCell>> = const { RefCell::new(None) }; -} + let root_panel = Panel::builder(&frame).build(); + root_panel.set_name("rabbit-root-panel"); -const REAPER_BUILDS_LABEL_NAME: &str = "rabbit-reaper-builds-label"; -const OSARA_BUILDS_LABEL_NAME: &str = "rabbit-osara-builds-label"; -/// REAPER's development channel, as its manifest entry names it. -const REAPER_DEV_CHANNEL: &str = "dev"; + let root = BoxSizer::builder(Orientation::Vertical).build(); + let step_label = StaticText::builder(&root_panel) + .with_label(&step_status(&model, TARGET_STEP)) + .build(); + step_label.set_name("rabbit-step-status"); + root.add(&step_label, 0, SizerFlag::All | SizerFlag::Expand, 12); -/// The window title, with the expert-mode suffix while it is on. Reachable -/// at any time with the screen reader's "read title" command, which is how -/// the state stays discoverable after the confirmation dialog is gone. -fn window_title(model: &WizardModel) -> String { - let mut title = model.window_title.clone(); - if expert_mode() { - with_ui_localizer(|localizer| { - title = localizer - .format( - "wizard-window-title-expert", - &[("title", model.window_title.as_str())], - ) - .value; - }); - } - title -} + // Use the frame's wxStatusBar for self-update status. NVDA's "Report + // status bar" command (NVDA+End) reads exactly this control, JAWS + // exposes it via its status-bar review keys, and Narrator/UIA expose + // the StatusBar role natively. Updating via SetStatusText fires the + // platform notifications that screen readers auto-announce. + let self_update_status = frame.create_status_bar(1, 0, 0, "rabbit-self-update-status"); + self_update_status.set_status_text(&model.text.self_update_status_checking, 0); -fn apply_window_title(model: &WizardModel) { - let title = window_title(model); - with_ui_frame(|frame| frame.set_title(&title)); -} + let book = SimpleBook::builder(&root_panel).build(); + book.set_name("rabbit-wizard-pages"); + let package_rows = Rc::new(RefCell::new(model.package_rows.clone())); + let package_notes = Rc::new(RefCell::new(model.notes.clone())); + let configuration_rows = Rc::new(RefCell::new(model.configuration_rows.clone())); + // Per-platform shared state for the package list — see + // `PackagesStateCell`. Populated by `build_packages_page` on the + // first run and refreshed by `populate_packages_tree` / + // `rebuild_packages_tree_model` on subsequent rebuilds (deferred + // version-check finish, post-install rescan). + let package_items: PackagesStateCell = new_packages_state(); + let can_install = Rc::new(Cell::new(model.controls.can_install)); + let review_can_install = Rc::new(Cell::new(false)); + let last_report = Arc::new(Mutex::new(None::)); + let last_reaper_app_path = Arc::new(Mutex::new(None::)); + let last_resource_path = Arc::new(Mutex::new(None::)); + let install_run = Arc::new(InstallRunState::default()); + // Build the wizard pages first, the buttons row, then the language + // footer. Footer is constructed after the buttons so its widgets + // come *after* the buttons in tab order, but it needs to exist + // *before* `add_pages` so the WizardWidgets struct can capture + // its Panel handle. + root.add(&book, 1, SizerFlag::All | SizerFlag::Expand, 12); -/// Ask before turning expert mode on. No is the default, so a stray Enter -/// never unlocks pre-release builds. -fn confirm_expert_mode(model: &WizardModel) -> bool { - let no_default = MessageDialogStyle::from_bits_retain(wxdragon::ffi::WXD_NO_DEFAULT); - let mut confirmed = false; - with_ui_frame(|frame| { - let dialog = MessageDialog::builder( - frame, - &model.text.expert_enable_body, - &model.text.expert_enable_title, - ) - .with_style( - MessageDialogStyle::YesNo - | MessageDialogStyle::IconWarning - | MessageDialogStyle::Centre - | no_default, - ) - .build(); - confirmed = dialog.show_modal() == ID_YES; - }); - confirmed -} + let buttons = BoxSizer::builder(Orientation::Horizontal).build(); + buttons.add_stretch_spacer(1); -/// A plain OK message. Every screen reader reads a native message box when -/// it opens, which a status-bar line would not guarantee. -fn show_expert_mode_message(title: &str, body: &str) { - with_ui_frame(|frame| { - let dialog = MessageDialog::builder(frame, body, title) - .with_style( - MessageDialogStyle::OK - | MessageDialogStyle::IconInformation - | MessageDialogStyle::Centre, - ) + let back = Button::builder(&root_panel) + .with_label(&model.controls.back_label) .build(); - dialog.show_modal(); - }); -} - -/// The channel REAPER's build choice asks for (`None` = regular releases). -fn reaper_build_channel(widgets: &WizardWidgets) -> Option { - (widgets.reaper_build_choice.get_selection() == Some(1)).then(|| REAPER_DEV_CHANNEL.to_string()) -} - -/// The channel OSARA's build choice asks for (`None` = regular snapshots). -fn osara_build_channel(widgets: &WizardWidgets) -> Option { - let index = widgets.osara_build_choice.get_selection()? as usize; - OSARA_PULL_REQUESTS - .with(|list| list.borrow().get(index).copied().flatten()) - .map(|number| format!("pr:{number}")) -} + back.set_name("rabbit-back-button"); + back.add_style(WindowStyle::TabStop); + back.set_can_focus(true); + buttons.add(&back, 0, SizerFlag::All, 6); -/// Start the build choices from what `target_path` was last installed from, -/// so someone on development builds sees them selected rather than being -/// quietly switched back. -fn seed_build_choices(target_path: Option<&Path>, reaper_choice: &Choice) { - let remembered = |package: &str| { - target_path.and_then(|path| rabbit_core::package::remembered_channel(path, package)) - }; - let on_dev = remembered(PACKAGE_REAPER).is_some_and(|channel| channel == REAPER_DEV_CHANNEL); - reaper_choice.set_selection(u32::from(on_dev)); - let pull_request = remembered(rabbit_core::package::PACKAGE_OSARA).and_then(|channel| { - match rabbit_core::package::split_channel(&channel) { - ("pr", Some(number)) => number.parse().ok(), - _ => None, - } - }); - OSARA_REMEMBERED_PULL_REQUEST.set(pull_request); - select_remembered_osara_build(); -} + let next = Button::builder(&root_panel) + .with_label(&model.controls.next_label) + .build(); + next.set_name("rabbit-next-button"); + next.add_style(WindowStyle::TabStop); + next.set_can_focus(true); + buttons.add(&next, 0, SizerFlag::All, 6); -/// Select the pull request the target's OSARA was last installed from. -fn select_remembered_osara_build() { - select_osara_build(OSARA_REMEMBERED_PULL_REQUEST.get()); -} + let install = Button::builder(&root_panel) + .with_label(&model.controls.install_label) + .build(); + install.set_name("rabbit-install-button"); + install.add_style(WindowStyle::TabStop); + install.set_can_focus(true); + buttons.add(&install, 0, SizerFlag::All, 6); -/// The pull request OSARA's build choice has selected (`None` = snapshots). -fn selected_osara_build() -> Option { - let choice = OSARA_BUILD_CHOICE.get()?; - let index = choice.get_selection()? as usize; - OSARA_PULL_REQUESTS.with(|list| list.borrow().get(index).copied().flatten()) -} + let close = Button::builder(&root_panel) + .with_label(&model.controls.close_label) + .build(); + close.set_name("rabbit-close-button"); + close.add_style(WindowStyle::TabStop); + close.set_can_focus(true); + buttons.add(&close, 0, SizerFlag::All, 6); -/// Select pull request `wanted` (`None` = the regular snapshot) in OSARA's -/// build choice. A pull request missing from the list is added under its -/// number. The list only reaches back so far, and it can fail to load or not -/// have arrived yet. None of those may quietly take a tester off the build -/// they are on. If that build really is gone, resolution falls back to the -/// snapshot and the package row says so. -fn select_osara_build(wanted: Option) { - let Some(choice) = OSARA_BUILD_CHOICE.get() else { - return; - }; - let listed = - OSARA_PULL_REQUESTS.with(|list| list.borrow().iter().position(|number| *number == wanted)); - let index = match (listed, wanted) { - (Some(index), _) => index, - (None, None) => 0, - (None, Some(number)) => { - let number_text = number.to_string(); - let mut label = number_text.clone(); - with_ui_localizer(|localizer| { - label = localizer - .format( - "wizard-expert-osara-builds-pr-untitled", - &[("number", number_text.as_str())], - ) - .value; - }); - OSARA_PULL_REQUESTS.with(|list| { - let mut list = list.borrow_mut(); - let index = osara_build_insert_position(&list, number); - choice.insert(&label, index); - list.insert(index, Some(number)); - index - }) - } - }; - choice.set_selection(index as u32); -} + root.add_sizer(&buttons, 0, SizerFlag::All | SizerFlag::Expand, 6); -/// Where pull request `number` belongs in OSARA's build list: after the -/// regular snapshot (always first) and every higher pull request number, so -/// one added under its number keeps the list's highest-first order. -fn osara_build_insert_position(list: &[Option], number: u32) -> usize { - list.iter() - .position(|listed| listed.is_some_and(|listed| listed < number)) - .unwrap_or(list.len()) -} + let language_footer = build_language_footer(&root_panel, &root, &model); + let wizard_widgets = add_pages( + &book, + &model, + Rc::clone(&package_rows), + Rc::clone(&configuration_rows), + Rc::clone(&package_items), + Rc::clone(&can_install), + self_update_status, + language_footer, + ); -/// List OSARA's pull requests that have a test build, in the background. -/// Until the list arrives, the choice says it is looking, which still means -/// "regular snapshots", and it already offers the pull request the target is -/// on, so moving on early keeps that build. -fn start_osara_build_listing(model: &WizardModel, widgets: &WizardWidgets) { - let choice = widgets.osara_build_choice; - OSARA_BUILD_CHOICE.set(Some(choice)); - OSARA_PULL_REQUESTS.with(|list| *list.borrow_mut() = vec![None]); - choice.clear(); - choice.append(&model.text.expert_osara_builds_loading); - choice.set_selection(0); - select_remembered_osara_build(); - let platform = model.platform; - std::thread::spawn(move || { - let result = rabbit_core::actions_artifact::pull_request_choices( - rabbit_core::package::PACKAGE_OSARA, - platform, - ) - .map_err(|error| error.to_string()); - wxdragon::call_after(Box::new(move || fill_osara_build_choice(result))); - }); -} + root_panel.set_sizer(root, true); -fn fill_osara_build_choice( - result: std::result::Result, String>, -) { - let Some(choice) = OSARA_BUILD_CHOICE.get() else { - return; - }; - // Whatever is selected now stays selected. That is the remembered pull - // request, or whatever the user has picked since. A second listing (expert - // mode turned off and on again) must not undo their pick. - let keep = selected_osara_build(); - with_ui_localizer(|localizer| { - choice.clear(); - let mut numbers = vec![None]; - match &result { - Ok(builds) => { - choice.append(&localizer.text("wizard-expert-osara-builds-snapshot").value); - for build in builds { - let number = build.number.to_string(); - let label = match &build.title { - Some(title) => localizer.format( - "wizard-expert-osara-builds-pr", - &[("number", number.as_str()), ("title", title.as_str())], - ), - None => localizer.format( - "wizard-expert-osara-builds-pr-untitled", - &[("number", number.as_str())], - ), - }; - choice.append(&label.value); - numbers.push(Some(build.number)); - } - } - Err(error) => { - eprintln!("could not list OSARA pull request builds: {error}"); - choice.append( - &localizer - .text("wizard-expert-osara-builds-unavailable") - .value, - ); - } - } - OSARA_PULL_REQUESTS.with(|list| *list.borrow_mut() = numbers); - }); - select_osara_build(keep); -} + let frame_sizer = BoxSizer::builder(Orientation::Vertical).build(); + frame_sizer.add(&root_panel, 1, SizerFlag::Expand, 0); + frame.set_sizer(frame_sizer, true); -/// Show or hide everything expert mode adds, and bring it up to date. -fn apply_expert_mode(model: &WizardModel, widgets: &WizardWidgets) { - let on = expert_mode(); - apply_window_title(model); - set_optional_choice_shown(&widgets.reaper_build_choice, REAPER_BUILDS_LABEL_NAME, on); - set_optional_choice_shown(&widgets.osara_build_choice, OSARA_BUILDS_LABEL_NAME, on); - if on { - let target = selected_target_row(model, widgets); - seed_build_choices( - target.as_ref().map(|target| target.path.as_path()), - &widgets.reaper_build_choice, + let current_step = Arc::new(AtomicUsize::new(TARGET_STEP)); + let labels = Arc::new( + (TARGET_STEP..=DONE_STEP) + .map(|step| step_status(&model, step)) + .collect::>(), ); - start_osara_build_listing(model, widgets); - } -} - -/// Handle the expert-mode chord. It only switches on the target page, where -/// the build choices live: changing it later would leave a version check -/// that no longer matches the chosen builds. Anywhere else it explains -/// rather than doing nothing, because a blind user gets no other signal. -fn toggle_expert_mode(model: &WizardModel, widgets: &WizardWidgets, step: usize) { - let text = &model.text; - match step { - TARGET_STEP => {} - PROGRESS_STEP | DONE_STEP => { - show_expert_mode_message(&text.expert_first_page_title, &text.expert_busy_body); - return; - } - _ => { - show_expert_mode_message(&text.expert_first_page_title, &text.expert_first_page_body); - return; - } - } - // Focus stays where the user pressed the keys: the dialog hands it back - // when it closes. Only a build choice that is about to be hidden gives it - // up, to the REAPER installation choice above it, before it disappears. - if expert_mode() { - if widgets.reaper_build_choice.has_focus() || widgets.osara_build_choice.has_focus() { - widgets.target_choice.set_focus(); - } - EXPERT_MODE.store(false, Ordering::SeqCst); - apply_expert_mode(model, widgets); - show_expert_mode_message(&text.expert_disabled_title, &text.expert_disabled_body); - } else { - if !confirm_expert_mode(model) { - return; - } - EXPERT_MODE.store(true, Ordering::SeqCst); - apply_expert_mode(model, widgets); - } -} - -/// Ctrl+Shift+E, or Cmd+Shift+E on macOS (`cmd_down` is Ctrl elsewhere). -/// Alt/Option must NOT be down: Ctrl+Alt is AltGr on German and many other -/// layouts, and Ctrl+Option is the VoiceOver modifier. -fn is_expert_mode_chord(event: &Event) -> bool { - matches!(event.get_key_code(), Some(code) if code == 'E' as i32 || code == 'e' as i32) - && event.cmd_down() - && event.shift_down() - && !event.alt_down() -} + let model = Arc::new(model); -/// Listen for the expert-mode chord on the whole window. wxEVT_CHAR_HOOK -/// reaches the top-level window before the focused control sees the key, -/// which is what lets one binding work wherever focus is. -fn bind_expert_mode_chord( - frame: &Frame, - model: &Arc, - widgets: WizardWidgets, - current_step: &Arc, -) { - let model = Arc::clone(model); - let current_step = Arc::clone(current_step); - frame.bind_internal(EventType::CHAR_HOOK, move |event| { - if is_expert_mode_chord(&event) { - event.skip(false); - toggle_expert_mode(&model, &widgets, current_step.load(Ordering::SeqCst)); - } else { - // Every other key carries on to the focused control. A hook that - // swallowed keys would break typing and arrowing everywhere. - event.skip(true); + update_navigation( + TARGET_STEP, + &book, + &step_label, + labels.as_slice(), + &back, + &next, + &install, + &close, + &language_footer, + effective_can_install(&can_install, &review_can_install), + target_is_valid(&model, &wizard_widgets), + reapack_ack_confirmed(&wizard_widgets), + ); + bind_target_navigation_updates(&model, wizard_widgets, ¤t_step, &next); + bind_reapack_ack_navigation_updates(wizard_widgets, ¤t_step, &next); + // The Done page parks focus on its read-only summary TextCtrl, which + // eats Enter before the default Close button can see it — bind both + // of that page's text boxes so Enter dismisses the wizard from + // wherever the user happens to be standing. + bind_done_page_enter_closes(&wizard_widgets.done_status, &frame, ¤t_step); + bind_done_page_enter_closes(&wizard_widgets.done_details, &frame, ¤t_step); + bind_expert_mode_chord(&frame, &model, wizard_widgets, ¤t_step); + if expert_mode() { + // RABBIT_EXPERT=1: no confirmation, setting the variable was it. + apply_expert_mode(&model, &wizard_widgets); } - }); -} -/// Ask before throwing away a running install. Returns `true` when the user -/// confirmed. -/// -/// `wxNO_DEFAULT` puts the focused button on **No**: this dialog appears -/// over work the user asked for, so a stray Enter or Space — from a screen -/// reader user tabbing around the progress page, say — must not be what -/// stops their REAPER install halfway. wxdragon's `MessageDialogStyle` -/// doesn't name the flag, so it comes from the raw wx constant. -fn confirm_stop_install(model: &WizardModel) -> bool { - let no_default = MessageDialogStyle::from_bits_retain(wxdragon::ffi::WXD_NO_DEFAULT); - let mut confirmed = false; - with_ui_frame(|frame| { - let dialog = MessageDialog::builder( - frame, - &model.text.close_during_install_body, - &model.text.close_during_install_title, - ) - .with_style( - MessageDialogStyle::YesNo - | MessageDialogStyle::IconWarning - | MessageDialogStyle::Centre - | no_default, - ) - .build(); - confirmed = dialog.show_modal() == ID_YES; - }); - confirmed -} - -/// Tell the user why the window will not close during a self-update. There -/// is no Yes/No here because there is no "yes": the file swap has no unwind -/// path, so the only honest answer is "wait". -fn show_close_blocked_by_self_update(model: &WizardModel) { - with_ui_frame(|frame| { - let dialog = MessageDialog::builder( - frame, - &model.text.close_during_self_update_body, - &model.text.close_during_self_update_title, - ) - .with_style( - MessageDialogStyle::OK - | MessageDialogStyle::IconInformation - | MessageDialogStyle::Centre, - ) - .build(); - dialog.show_modal(); - }); -} - -fn render_self_update_status( - widgets: WizardWidgets, - model: &Arc, - localizer: &Localizer, - state: &Arc>, -) { - let mut state_guard = state.lock().unwrap(); - // Clone the check result up-front so the rest of the function can - // freely mutate `state_guard` without fighting the borrow checker - // over an `as_ref()` view of `state_guard.check`. The clone is - // cheap (a single `Result`) and the - // function only runs on completion of a one-shot manifest probe. - let Some(check) = state_guard.check.clone() else { - // Startup probe hasn't completed yet; leave the initial - // "Checking for RABBIT updates…" placeholder in place. - return; - }; - - // (The package-install lock used to be a single LocalAppData path so - // RABBIT could warn that another install was in progress before - // applying a self-update. With locks now scoped per-target we don't - // have a single global lock to consult here, so the cross-target - // status line is gone. Concurrent self-update + install on the same - // target still races at the file rename and surfaces a normal IO - // error.) - let status = match &check { - Ok(report) => format_self_update_check_summary(localizer, report), - Err(error) => format!("{}: {}", model.text.done_self_update_error_prefix, error), - }; - let apply_enabled = matches!(&check, Ok(report) if report.update_available); - - let status_changed = status != state_guard.last_status; - if status_changed { - widgets.self_update_status.set_status_text(&status, 0); - state_guard.last_status = status; - } - - // Once-per-session prompt: if an update is available, ask up front - // instead of forcing the user to navigate to the Done page to find - // the apply button. The Done-page button stays around as a fallback - // for users who pick "No" here and change their mind later. - if !apply_enabled || state_guard.prompted { - return; - } - state_guard.prompted = true; - let Ok(report) = check else { return }; - let release_notes = state_guard.release_notes.clone(); - // Drop the lock before showing the modal — `MessageDialog::show_modal` - // runs a nested wxWidgets event loop, and any UI-thread callback that - // re-enters `render_self_update_status` while the modal is open would - // deadlock on a still-held mutex. - drop(state_guard); - - let title = localizer.text("wizard-self-update-prompt-title").value; - let current = report.current_version.to_string(); - let latest = report.latest_version.to_string(); - let body = localizer - .format( - "wizard-self-update-prompt-body", - &[("current", current.as_str()), ("latest", latest.as_str())], - ) - .value; - - // Pull the frame from the UI-thread-local rather than from the - // captured `widgets` so we don't have to send a non-`Send` `Frame` - // through the `call_after` closure that wraps this function. The - // closure runs on the UI thread, so the thread-local was populated - // by `run()` before any worker fired. - with_ui_frame(|frame| { - let accepted = match release_notes.as_deref() { - Some(notes) => { - show_self_update_prompt_with_notes(frame, localizer, &title, &body, ¤t, notes) - } - // No notes to show (fetch failed, or the release carries no - // body): the plain native message box, exactly as before. - None => { - let dialog = MessageDialog::builder(frame, &body, &title) - .with_style( - MessageDialogStyle::YesNo - | MessageDialogStyle::IconQuestion - | MessageDialogStyle::Centre, - ) - .build(); - dialog.show_modal() == ID_YES - } - }; - - if accepted { - start_self_update_apply( - widgets.done_status, - widgets.self_update_status, - Arc::clone(model), - ); - } - }); -} - -/// The update prompt in its rich form: the same question the plain message -/// box asks, plus the release notes for everything the update brings, so the -/// answer to "should I say yes?" is on screen when the question is asked. -/// Returns `true` when the user accepted. -/// -/// A `wxMessageDialog` can't host this — its message is a single unscrollable -/// static label, and RABBIT's release notes routinely run past a screenful. -/// Hence a plain `Dialog` carrying a read-only multiline `TextCtrl`, which is -/// the same control (and therefore the same screen-reader behaviour) the -/// packages page already uses for a package's What's-New notes. -/// -/// Accessibility notes, in the spirit of the Done page: -/// - Focus parks on the notes control, so the screen reader reads what the -/// update contains instead of announcing a bare button. -/// - The notes control's accessible name carries the heading, since a -/// `StaticText` label sits outside the tab order and would otherwise never -/// be announced. -/// - Update is the default button, so Enter accepts from anywhere in the -/// dialog, and Escape maps to Later. -fn show_self_update_prompt_with_notes( - frame: &Frame, - localizer: &Localizer, - title: &str, - body: &str, - current: &str, - notes: &str, -) -> bool { - let notes_heading = localizer - .format( - "wizard-self-update-prompt-notes-heading", - &[("current", current)], - ) - .value; - let update_label = localizer - .text("wizard-self-update-prompt-update-button") - .value; - let later_label = localizer - .text("wizard-self-update-prompt-later-button") - .value; - - let dialog = Dialog::builder(frame, title) - // Resizable so a long set of notes can be opened up, rather than - // forcing everything through one fixed-height scroll region. - .with_style(DialogStyle::DefaultDialogStyle | DialogStyle::ResizeBorder) - .with_size(560, 420) - .build(); - // Controls go on a child Panel rather than straight onto the dialog: - // that's what gives MSW its dialog-navigation behaviour (Tab/arrow - // traversal between the notes and the buttons) for free. - let panel = Panel::builder(&dialog).build(); - let sizer = BoxSizer::builder(Orientation::Vertical).build(); - - let question = StaticText::builder(&panel).with_label(body).build(); - question.set_name("rabbit-self-update-prompt-question"); - sizer.add(&question, 0, SizerFlag::All | SizerFlag::Expand, 6); - - let heading = StaticText::builder(&panel) - .with_label(¬es_heading) - .build(); - heading.set_name("rabbit-self-update-prompt-notes-heading"); - sizer.add(&heading, 0, SizerFlag::All | SizerFlag::Expand, 6); - - let notes_text = TextCtrl::builder(&panel) - .with_value(notes) - .with_style(TextCtrlStyle::MultiLine | TextCtrlStyle::ReadOnly | TextCtrlStyle::WordWrap) - .build(); - notes_text.set_name(¬es_heading); - sizer.add(¬es_text, 1, SizerFlag::All | SizerFlag::Expand, 6); - - // The buttons carry the standard yes/no ids rather than generated ones - // so `set_escape_id` below has something to act on: wxWidgets answers - // Escape by emulating a click on the button holding the escape id, and - // does nothing at all when no button has it. - // - // `TabStop` + `set_can_focus` for the same reason the wizard's own - // navigation buttons carry them: without it macOS leaves buttons out of - // the Tab ring unless Full Keyboard Access is on. - let buttons = BoxSizer::builder(Orientation::Horizontal).build(); - let update = Button::builder(&panel) - .with_id(ID_YES) - .with_label(&update_label) - .build(); - update.set_name("rabbit-self-update-prompt-update"); - update.add_style(WindowStyle::TabStop); - update.set_can_focus(true); - buttons.add(&update, 0, SizerFlag::All, 6); - let later = Button::builder(&panel) - .with_id(ID_NO) - .with_label(&later_label) - .build(); - later.set_name("rabbit-self-update-prompt-later"); - later.add_style(WindowStyle::TabStop); - later.set_can_focus(true); - buttons.add(&later, 0, SizerFlag::All, 6); - sizer.add_sizer(&buttons, 0, SizerFlag::AlignRight, 0); - - panel.set_sizer(sizer, true); - // Same frame → panel → content nesting `run()` builds for the wizard - // window: the dialog's own sizer is what makes the panel track the - // dialog when it is resized. - let dialog_sizer = BoxSizer::builder(Orientation::Vertical).build(); - dialog_sizer.add(&panel, 1, SizerFlag::Expand, 0); - dialog.set_sizer(dialog_sizer, true); - - // `Dialog` is `Copy`, so each `move` handler closes over its own copy - // and the original stays usable below. Neither handler skips the event, - // so it stops at the button and wxDialog's own button handling never - // gets a second go at ending the modal. - update.on_click(move |_| dialog.end_modal(ID_YES)); - later.on_click(move |_| dialog.end_modal(ID_NO)); - dialog.set_affirmative_id(ID_YES); - dialog.set_escape_id(ID_NO); - update.set_default(); - - // A multiline TextCtrl claims Enter for itself (DLGC_WANTALLKEYS on - // MSW, the NSTextView swallows it on macOS), so the default button - // never sees the key while focus is parked on the notes — the same - // trap `bind_done_page_enter_closes` works around on the Done page. - notes_text.on_key_down(move |event| { - let key_code = if let WindowEventData::Keyboard(kbd) = &event { - kbd.get_key_code() - } else { - None - }; - if !matches!(key_code, Some(WXK_RETURN) | Some(WXK_NUMPAD_ENTER)) { - return; - } - // Consume the key so the read-only control neither beeps nor - // tries to insert a newline before the dialog closes. - event.skip(false); - dialog.end_modal(ID_YES); - }); - - notes_text.set_focus(); - let accepted = dialog.show_modal() == ID_YES; - dialog.destroy(); - accepted -} - -/// `wx/defs.h`: `WXK_SPACE = 32` (just the ASCII value). Kept around as a -/// fallback intercept on platforms without TVS_CHECKBOXES; on Windows the -/// native tree handles Space toggles internally. -#[allow(dead_code)] -const WXK_SPACE: i32 = 32; - -/// `wx/defs.h`: `WXK_RETURN = 13` (the ASCII value) and, from the keycode -/// enum that starts at `WXK_START = 300`, `WXK_NUMPAD_ENTER = 370`. -/// Redeclared here for the same reason as `WXK_SPACE` above. -const WXK_RETURN: i32 = 13; -const WXK_NUMPAD_ENTER: i32 = 370; - -/// Per-platform state handle that the orchestrator (run, button click -/// handlers, post-install hook, version-check dispatcher) holds onto and -/// passes through to `build_packages_page` / `refresh_package_checklist` / -/// `rebuild_package_list_widgets` without caring which widget is on the -/// page. On Windows it carries the live `TreeItemId`s for the native -/// TreeCtrl rows; elsewhere it carries the `CustomDataViewTreeModel` -/// handle so the refresh helpers can re-emit notifications and rebuild -/// the model's userdata in place. -#[cfg(target_os = "windows")] -type PackagesStateCell = Rc>; -#[cfg(not(target_os = "windows"))] -type PackagesStateCell = Rc>>; - -/// Type alias used by `WizardWidgets` for the package list widget itself. -/// Windows: native `wxTreeCtrl` (`SysTreeView32` underneath, with -/// `TVS_CHECKBOXES` enabled by `native_tree_checkboxes::enable_checkboxes`). -/// Non-Windows: `wxDataViewCtrl` driven by a `CustomDataViewTreeModel` -/// with a `DataViewToggleRenderer` for the checkbox column. -#[cfg(target_os = "windows")] -type PackagesView = TreeCtrl; -#[cfg(not(target_os = "windows"))] -type PackagesView = DataViewCtrl; - -/// Build the empty per-platform state container that lives for the -/// lifetime of the wizard. On Windows it starts with no leaf TreeItemIds -/// (populated during `build_packages_page`); on non-Windows it starts -/// with `None` for the model handle (populated immediately after the -/// model is constructed in `build_packages_page`). -fn new_packages_state() -> PackagesStateCell { - #[cfg(target_os = "windows")] - { - Rc::new(RefCell::new(PackageItems::empty())) - } - #[cfg(not(target_os = "windows"))] - { - Rc::new(RefCell::new(None)) - } -} - -/// Live wxTreeItemId handles for both top-level groups in the Packages -/// tree — "Packages" with its package leaves, and "Configuration" with -/// its configuration-step leaves. Index `i` in each leaves vec -/// corresponds to index `i` in the matching `Vec` / -/// `Vec`. Kept in an `Rc` so the closures -/// that handle the state-image-click event, the LEFT_UP fallback, the -/// keyboard fallbacks, and the post-install / version-check rebuild -/// helpers can all reach the same TreeItemIds the populate routine -/// handed out. `TreeItemId` is not `Copy` (it owns a pointer with -/// custom Drop), so we can't store it on the `Copy`-derived -/// `WizardWidgets` directly. -#[cfg(target_os = "windows")] -struct PackageItems { - /// The "Packages" group node under the (hidden) virtual root. - /// Becomes `None` between `populate_packages_tree` calls; populated - /// immediately after each rebuild. - packages_group: Option, - /// The "Additional software" group node (Surge XT, app2clap, …), a - /// sibling of the Packages group. `None` when no package has the - /// `Additional` category — the group node isn't created then. - additional_software_group: Option, - /// The "Language packs" group node (REAPER translations), a sibling of - /// the Packages group. `None` when no package has the `Language` - /// category — the group node isn't created then. - language_group: Option, - /// One TreeItemId per package row, in the same order as `package_rows` - /// (covering BOTH the Packages and Additional-software groups). The leaf - /// for `package_rows[i]` is at `packages_leaves[i]` regardless of which - /// group node it visually hangs under, so leaf↔row index stays 1:1. - packages_leaves: Vec, - /// The "Configuration" group node sitting alongside the Packages - /// group under the virtual root. - configuration_group: Option, - /// One TreeItemId per configuration row, in the same order as - /// `configuration_rows`. - configuration_leaves: Vec, -} - -#[cfg(target_os = "windows")] -impl PackageItems { - fn empty() -> Self { - Self { - packages_group: None, - additional_software_group: None, - language_group: None, - packages_leaves: Vec::new(), - configuration_group: None, - configuration_leaves: Vec::new(), - } - } -} - -/// Identifies a row in the non-Windows `CustomDataViewTreeModel`. `Package` -/// carries the index into `package_rows`; `Group` is the synthetic -/// "Packages" parent under the invisible root. The `Box` storage -/// owned by `PackageTreeData` is heap-stable, so `*mut Node` pointers -/// passed across the FFI boundary as opaque item ids stay valid for the -/// model's lifetime. -#[cfg(not(target_os = "windows"))] -#[derive(Clone, Copy, Debug)] -enum NodeKind { - /// The synthetic "Packages" parent under the invisible root. - PackagesGroup, - /// The synthetic "Additional software" parent, sibling of PackagesGroup. - /// Holds the `Additional`-category package leaves (Surge XT, app2clap, …). - AdditionalSoftwareGroup, - /// The synthetic "Language packs" parent, sibling of PackagesGroup. - /// Holds the `Language`-category package leaves (REAPER translations). - LanguageGroup, - /// A package leaf — index into `package_rows`. Parented under either - /// PackagesGroup or AdditionalSoftwareGroup depending on the row's - /// category; the index is into the full `package_rows` either way. - Package(usize), - /// The synthetic "Configuration" parent, sibling of PackagesGroup. - ConfigurationGroup, - /// A configuration-step leaf — index into `configuration_rows`. - Configuration(usize), -} - -#[cfg(not(target_os = "windows"))] -#[derive(Debug)] -struct Node { - kind: NodeKind, -} - -/// Userdata stored inside the non-Windows `CustomDataViewTreeModel`. Owns -/// the heap-stable node objects we hand to wxDataView as item ids, and -/// holds clones of the shared `package_rows` and `configuration_rows` -/// Rcs so model callbacks can read row state without going through any -/// external lookup. -#[cfg(not(target_os = "windows"))] -struct PackageTreeData { - rows: Rc>>, - configuration_rows: Rc>>, - packages_group_label: String, - additional_software_group_label: String, - language_group_label: String, - configuration_group_label: String, - packages_group_node: Box, - additional_software_group_node: Box, - language_group_node: Box, - package_nodes: Vec>, - configuration_group_node: Box, - configuration_nodes: Vec>, -} - -#[cfg(not(target_os = "windows"))] -impl PackageTreeData { - fn new( - rows: Rc>>, - configuration_rows: Rc>>, - packages_group_label: String, - additional_software_group_label: String, - language_group_label: String, - configuration_group_label: String, - ) -> Self { - let package_len = rows.borrow().len(); - let package_nodes: Vec> = (0..package_len) - .map(|i| { - Box::new(Node { - kind: NodeKind::Package(i), - }) - }) - .collect(); - let configuration_len = configuration_rows.borrow().len(); - let configuration_nodes: Vec> = (0..configuration_len) - .map(|i| { - Box::new(Node { - kind: NodeKind::Configuration(i), - }) - }) - .collect(); - Self { - rows, - configuration_rows, - packages_group_label, - additional_software_group_label, - language_group_label, - configuration_group_label, - packages_group_node: Box::new(Node { - kind: NodeKind::PackagesGroup, - }), - additional_software_group_node: Box::new(Node { - kind: NodeKind::AdditionalSoftwareGroup, - }), - language_group_node: Box::new(Node { - kind: NodeKind::LanguageGroup, - }), - package_nodes, - configuration_group_node: Box::new(Node { - kind: NodeKind::ConfigurationGroup, - }), - configuration_nodes, - } - } - - fn packages_group_ptr(&self) -> *const Node { - self.packages_group_node.as_ref() - } - - fn additional_software_group_ptr(&self) -> *const Node { - self.additional_software_group_node.as_ref() - } - - fn language_group_ptr(&self) -> *const Node { - self.language_group_node.as_ref() - } - - /// Whether any package row is in the `Language` category — drives - /// whether the "Language packs" group node is exposed as a root child - /// (so it never renders empty). - fn has_language_packs(&self) -> bool { - self.rows - .borrow() - .iter() - .any(|r| r.category == rabbit_core::package::PackageCategory::Language) - } - - /// Whether any package row is in the `Additional` category — drives - /// whether the "Additional software" group node is exposed as a root - /// child (so it never renders empty). - fn has_additional_software(&self) -> bool { - self.rows - .borrow() - .iter() - .any(|r| r.category == rabbit_core::package::PackageCategory::Additional) - } - - /// The package leaf pointers whose row matches `category`, in row order. - /// Used to populate each group's children and to refresh just that - /// group's leaves after a toggle. - fn package_ptrs_in_category( - &self, - category: rabbit_core::package::PackageCategory, - ) -> Vec<*const Node> { - let rows = self.rows.borrow(); - self.package_nodes - .iter() - .enumerate() - .filter(|(i, _)| { - rows.get(*i) - .map(|r| r.category == category) - .unwrap_or(false) - }) - .map(|(_, node)| node.as_ref() as *const Node) - .collect() - } - - fn configuration_group_ptr(&self) -> *const Node { - self.configuration_group_node.as_ref() - } - - fn package_ptr(&self, idx: usize) -> *const Node { - self.package_nodes[idx].as_ref() - } - - fn configuration_ptr(&self, idx: usize) -> *const Node { - self.configuration_nodes[idx].as_ref() - } - - fn all_configuration_ptrs(&self) -> Vec<*const Node> { - self.configuration_nodes - .iter() - .map(|b| b.as_ref() as *const Node) - .collect() - } -} - -/// Model column indices for the non-Windows DataView path. -#[cfg(not(target_os = "windows"))] -const PACKAGE_COL_TOGGLE: u32 = 0; -#[cfg(not(target_os = "windows"))] -const PACKAGE_COL_LABEL: u32 = 1; - -/// Windows-only helpers that turn the wx-created `wxTreeCtrl` into a -/// `SysTreeView32` with `TVS_CHECKBOXES` set. wxdragon doesn't expose any of -/// the native APIs we need (no `EnableCheckBoxes`, no `SetItemState`, no -/// `SetStateImageList`), so we reach down to the underlying `HWND` via -/// `Window::get_handle()` and drive the control directly through user32 + -/// raw `SendMessageW` traffic. `wxTreeItemId` on wxMSW is a single-member -/// struct (`void* m_pItem`, no vtable / no padding), so reading the first -/// pointer-sized word of the wxd_TreeItemId_t* gives us the native -/// `HTREEITEM`. This is implementation-dependent but stable on wxMSW today -/// — we live with that fragility because the alternative is forking -/// wxdragon-sys, and the prize is screen-reader-correct native checkboxes -/// (UIA Toggle pattern on each tree row). -#[cfg(target_os = "windows")] -mod native_tree_checkboxes { - use super::TreeItemId; - use std::ffi::c_void; - - pub const GWL_STYLE: i32 = -16; - pub const TVS_CHECKBOXES: u32 = 0x0100; - const TV_FIRST: u32 = 0x1100; - const TVM_SETITEMW: u32 = TV_FIRST + 63; - const TVM_GETITEMW: u32 = TV_FIRST + 62; - const TVM_SETIMAGELIST: u32 = TV_FIRST + 9; - const TVM_HITTEST: u32 = TV_FIRST + 17; - const TVSIL_STATE: usize = 2; - const TVIF_HANDLE: u32 = 0x0010; - const TVIF_STATE: u32 = 0x0008; - const TVIS_STATEIMAGEMASK: u32 = 0xF000; - pub const TVHT_ONITEMSTATEICON: u32 = 0x0040; - - /// Themed checkbox state ids. See `BP_CHECKBOX` (= 3) of `BUTTON` - /// theme class in ``. We use the "normal" variants because - /// the tree control overlays its own selection/hover effects on top. - const BP_CHECKBOX: i32 = 3; - const CBS_UNCHECKEDNORMAL: i32 = 1; - const CBS_CHECKEDNORMAL: i32 = 5; - const CBS_MIXEDNORMAL: i32 = 9; - const TS_TRUE: i32 = 1; - - /// `DrawFrameControl` flags used as the unthemed fallback when - /// `OpenThemeData("BUTTON")` returns null (classic theme / no themes). - const DFC_BUTTON: u32 = 4; - const DFCS_BUTTONCHECK: u32 = 0x0000; - const DFCS_CHECKED: u32 = 0x0400; - const DFCS_BUTTON3STATE: u32 = 0x0008; - - /// `ImageList_Create` flags: 32-bit color + mask channel. - const ILC_COLOR32: u32 = 0x0020; - const ILC_MASK: u32 = 0x0001; - - /// State-image indices we use. `TVS_CHECKBOXES` indices are 1-based - /// (index 0 means "no state image"). `Mixed` is what the parent - /// "Packages" group shows when only some children are checked. - #[derive(Clone, Copy, Debug, PartialEq, Eq)] - pub enum TriState { - Unchecked, - Checked, - Mixed, - } - - impl TriState { - fn state_image_index(self) -> u32 { - match self { - TriState::Unchecked => 1, - TriState::Checked => 2, - TriState::Mixed => 3, - } - } - } - - /// Layout-compatible mirror of `TVITEMW` from ``. - #[repr(C)] - struct Tvitemw { - mask: u32, - h_item: *mut c_void, - state: u32, - state_mask: u32, - text: *mut u16, - text_max: i32, - image: i32, - selected_image: i32, - children: i32, - l_param: isize, - } - - #[repr(C)] - struct RectStruct { - left: i32, - top: i32, - right: i32, - bottom: i32, - } - - #[repr(C)] - struct SizeStruct { - cx: i32, - cy: i32, - } - - unsafe extern "system" { - fn GetWindowLongPtrW(h_wnd: *mut c_void, n_index: i32) -> isize; - fn SetWindowLongPtrW(h_wnd: *mut c_void, n_index: i32, dw_new_long: isize) -> isize; - fn SendMessageW(h_wnd: *mut c_void, msg: u32, w_param: usize, l_param: isize) -> isize; - fn GetDC(h_wnd: *mut c_void) -> *mut c_void; - fn ReleaseDC(h_wnd: *mut c_void, hdc: *mut c_void) -> i32; - fn DrawFrameControl( - hdc: *mut c_void, - lprc: *const RectStruct, - type_: u32, - state: u32, - ) -> i32; - fn FillRect(hdc: *mut c_void, lprc: *const RectStruct, hbr: *mut c_void) -> i32; - } - - #[link(name = "gdi32")] - unsafe extern "system" { - fn CreateCompatibleDC(hdc: *mut c_void) -> *mut c_void; - fn DeleteDC(hdc: *mut c_void) -> i32; - fn CreateCompatibleBitmap(hdc: *mut c_void, cx: i32, cy: i32) -> *mut c_void; - fn DeleteObject(obj: *mut c_void) -> i32; - fn SelectObject(hdc: *mut c_void, obj: *mut c_void) -> *mut c_void; - fn CreateSolidBrush(color: u32) -> *mut c_void; - } - - #[link(name = "uxtheme")] - unsafe extern "system" { - fn OpenThemeData(h_wnd: *mut c_void, classlist: *const u16) -> *mut c_void; - fn CloseThemeData(htheme: *mut c_void) -> i32; - fn DrawThemeBackground( - htheme: *mut c_void, - hdc: *mut c_void, - partid: i32, - stateid: i32, - prect: *const RectStruct, - pcliprect: *const RectStruct, - ) -> i32; - fn GetThemePartSize( - htheme: *mut c_void, - hdc: *mut c_void, - partid: i32, - stateid: i32, - prc: *const RectStruct, - esize: i32, - psz: *mut SizeStruct, - ) -> i32; - } - - #[link(name = "comctl32")] - unsafe extern "system" { - fn ImageList_Create(cx: i32, cy: i32, flags: u32, initial: i32, grow: i32) -> *mut c_void; - fn ImageList_AddMasked(himl: *mut c_void, hbm_image: *mut c_void, cr_mask: u32) -> i32; - fn ImageList_Destroy(himl: *mut c_void) -> i32; - } - - /// OR-in the `TVS_CHECKBOXES` style on an existing tree's `HWND` AND - /// install our own 3-image state list so the synthetic Packages group - /// can show an indeterminate ("half-checked") state when only some of - /// its children are checked. The native control by default creates a - /// 2-image list (unchecked, checked); we replace it with a 3-image - /// list (unchecked, checked, mixed). The first two come from the - /// `BUTTON` theme via `DrawThemeBackground` so they match the rest of - /// the OS's checkboxes; the third uses `CBS_MIXEDNORMAL` to draw the - /// system's standard mixed-state checkbox glyph. - pub fn enable_checkboxes(hwnd: *mut c_void) { - if hwnd.is_null() { - return; - } - unsafe { - let style = GetWindowLongPtrW(hwnd, GWL_STYLE); - if (style as u32 & TVS_CHECKBOXES) == 0 { - SetWindowLongPtrW(hwnd, GWL_STYLE, style | TVS_CHECKBOXES as isize); - } - } - install_tristate_state_image_list(hwnd); - } - - /// Build a 3-image `HIMAGELIST` containing themed unchecked + checked + - /// mixed checkbox glyphs and install it as the tree's state image - /// list. Replaces (and frees) whatever list `TVS_CHECKBOXES` may have - /// auto-created. - fn install_tristate_state_image_list(hwnd: *mut c_void) { - // BGR magenta serves as the transparency key for the image list: - // anywhere we leave magenta is treated as transparent on draw. - const MAGENTA_BGR: u32 = 0x00FF00FF; - - let class: Vec = "BUTTON".encode_utf16().chain(std::iter::once(0)).collect(); - let htheme = unsafe { OpenThemeData(hwnd, class.as_ptr()) }; - - // Determine checkbox glyph size from the theme (DPI-aware) when - // available; fall back to a sensible default otherwise. - let mut size = SizeStruct { cx: 13, cy: 13 }; - if !htheme.is_null() { - unsafe { - let _ = GetThemePartSize( - htheme, - std::ptr::null_mut(), - BP_CHECKBOX, - CBS_UNCHECKEDNORMAL, - std::ptr::null(), - TS_TRUE, - &mut size, - ); - } - } - let cx = size.cx.max(13); - let cy = size.cy.max(13); - - let himl = unsafe { ImageList_Create(cx, cy, ILC_COLOR32 | ILC_MASK, 3, 0) }; - if himl.is_null() { - if !htheme.is_null() { - unsafe { - CloseThemeData(htheme); - } - } - return; - } - - let states = [CBS_UNCHECKEDNORMAL, CBS_CHECKEDNORMAL, CBS_MIXEDNORMAL]; - - unsafe { - let hdc_screen = GetDC(std::ptr::null_mut()); - let hdc_mem = CreateCompatibleDC(hdc_screen); - let key_brush = CreateSolidBrush(MAGENTA_BGR); - - for state in &states { - let hbm = CreateCompatibleBitmap(hdc_screen, cx, cy); - let prev_bm = SelectObject(hdc_mem, hbm); - - // Fill with magenta — anything DrawThemeBackground (or the - // unthemed fallback) leaves untouched stays magenta and - // becomes transparent in the image list. - let rc = RectStruct { - left: 0, - top: 0, - right: cx, - bottom: cy, - }; - let _ = FillRect(hdc_mem, &rc, key_brush); - - if !htheme.is_null() { - let _ = DrawThemeBackground( - htheme, - hdc_mem, - BP_CHECKBOX, - *state, - &rc, - std::ptr::null(), - ); - } else { - let dfcs = match *state { - CBS_UNCHECKEDNORMAL => DFCS_BUTTONCHECK, - CBS_CHECKEDNORMAL => DFCS_BUTTONCHECK | DFCS_CHECKED, - CBS_MIXEDNORMAL => DFCS_BUTTON3STATE | DFCS_CHECKED, - _ => DFCS_BUTTONCHECK, - }; - let _ = DrawFrameControl(hdc_mem, &rc, DFC_BUTTON, dfcs); - } - - SelectObject(hdc_mem, prev_bm); - let _ = ImageList_AddMasked(himl, hbm, MAGENTA_BGR); - DeleteObject(hbm); - } - - DeleteObject(key_brush); - DeleteDC(hdc_mem); - ReleaseDC(std::ptr::null_mut(), hdc_screen); - - if !htheme.is_null() { - CloseThemeData(htheme); - } - - // Hand the new image list to the tree control. The control - // takes ownership of the new list and returns the old one for - // us to destroy. - let old_himl = SendMessageW(hwnd, TVM_SETIMAGELIST, TVSIL_STATE, himl as isize); - if old_himl != 0 { - ImageList_Destroy(old_himl as *mut c_void); - } - } - } - - #[repr(C)] - struct TvHitTestPoint { - x: i32, - y: i32, - } - - /// Layout-compatible mirror of `TVHITTESTINFO` from ``. - #[repr(C)] - struct TvHitTestInfo { - pt: TvHitTestPoint, - flags: u32, - h_item: *mut c_void, - } - - /// Send `TVM_HITTEST` to the native tree control and return the hit - /// item handle plus the result flags. `(x, y)` is in the tree's - /// client-area coordinates (which is what `wxEVT_LEFT_*` mouse-event - /// positions report on the bound window). - pub fn hit_test(hwnd: *mut c_void, x: i32, y: i32) -> (u32, *mut c_void) { - if hwnd.is_null() { - return (0, std::ptr::null_mut()); - } - let mut info = TvHitTestInfo { - pt: TvHitTestPoint { x, y }, - flags: 0, - h_item: std::ptr::null_mut(), - }; - unsafe { - SendMessageW(hwnd, TVM_HITTEST, 0, &mut info as *mut _ as isize); - } - (info.flags, info.h_item) - } - - /// Read the native `HTREEITEM` out of a wxdragon `TreeItemId`. Relies - /// on: - /// 1. `TreeItemId { ptr: *mut wxd_TreeItemId_t }` being a single-field - /// `repr(Rust)` struct — its layout matches the inner pointer. - /// 2. `wxd_TreeItemId_t*` being a `reinterpret_cast` of `wxTreeItemId*` - /// (confirmed in wxdragon-sys/cpp/src/treectrl.cpp). - /// 3. `wxTreeItemId` having `void* m_pItem` as its only non-static - /// member with no vtable. - fn htreeitem_from(item: &TreeItemId) -> *mut c_void { - // SAFETY: see the contract above. Reading the first pointer-sized - // word of the `TreeItemId` wrapper yields its private `ptr` field; - // reading the first word of that yields `wxTreeItemId::m_pItem`. - let inner: *mut c_void = unsafe { std::mem::transmute_copy(item) }; - if inner.is_null() { - return std::ptr::null_mut(); - } - unsafe { *(inner as *const *mut c_void) } - } - - pub fn set_check_state(hwnd: *mut c_void, item: &TreeItemId, checked: bool) { - let state = if checked { - TriState::Checked - } else { - TriState::Unchecked - }; - set_check_state_tri(hwnd, item, state); - } - - /// Set the state image index (1, 2, or 3 — see `TriState`) for the - /// given tree item. Used both for leaf rows (only `Checked` / - /// `Unchecked`) and the synthetic Packages group (any of the three). - pub fn set_check_state_tri(hwnd: *mut c_void, item: &TreeItemId, state: TriState) { - if hwnd.is_null() { - return; - } - let h_item = htreeitem_from(item); - if h_item.is_null() { - return; - } - let state_value = state.state_image_index() << 12; - let mut tvi = Tvitemw { - mask: TVIF_STATE | TVIF_HANDLE, - h_item, - state: state_value, - state_mask: TVIS_STATEIMAGEMASK, - text: std::ptr::null_mut(), - text_max: 0, - image: 0, - selected_image: 0, - children: 0, - l_param: 0, - }; - unsafe { - SendMessageW(hwnd, TVM_SETITEMW, 0, &mut tvi as *mut _ as isize); - } - } - - pub fn get_check_state(hwnd: *mut c_void, item: &TreeItemId) -> bool { - if hwnd.is_null() { - return false; - } - let h_item = htreeitem_from(item); - if h_item.is_null() { - return false; - } - let mut tvi = Tvitemw { - mask: TVIF_STATE | TVIF_HANDLE, - h_item, - state: 0, - state_mask: TVIS_STATEIMAGEMASK, - text: std::ptr::null_mut(), - text_max: 0, - image: 0, - selected_image: 0, - children: 0, - l_param: 0, - }; - unsafe { - SendMessageW(hwnd, TVM_GETITEMW, 0, &mut tvi as *mut _ as isize); - } - // State image index 2 = checked. - ((tvi.state & TVIS_STATEIMAGEMASK) >> 12) == 2 - } -} - -/// State carried across [`ProgressEvent`] notifications during a wizard -/// install. Holds the totals the install handler pre-computed up front -/// (so the gauge percentage is a fraction of completed work, not a -/// guess) plus per-package byte counters for every download currently -/// streaming — the core pipelines downloads on a small concurrent pool, -/// so several packages can be mid-download at once (FFmpeg streaming -/// while REAPER installs). Mutated only on the UI thread inside each -/// `call_after` closure; the `Arc>` wrapper is purely so the -/// closures satisfy `Send`. -#[derive(Debug, Clone)] -struct ProgressUiState { - /// Total packages selected for install. Each contributes two phases - /// (download + install) to the overall progress denominator. - total_packages: usize, - /// Total opted-in configuration steps. Each contributes one phase. - total_configuration_steps: usize, - /// Phases finished so far across all packages and configuration - /// steps. Bounded above by `total_packages * 2 + - /// total_configuration_steps`. - completed_phases: usize, - /// Byte counters of every in-flight download, keyed by package id: - /// `(bytes_downloaded, content_length_if_known)`. Inserted on - /// `DownloadStarted`, updated on `DownloadProgress`, removed on - /// `DownloadCompleted` (which also bumps `completed_phases`). - active_downloads: std::collections::HashMap)>, - /// Package currently running its install step, if any. While set, the - /// status label belongs to the install ("Installing REAPER…") and - /// background download ticks must not overwrite it — the install is - /// the foreground activity the user is waiting on. - current_install: Option, -} - -impl ProgressUiState { - fn new(total_packages: usize, total_configuration_steps: usize) -> Self { - Self { - total_packages, - total_configuration_steps, - completed_phases: 0, - active_downloads: std::collections::HashMap::new(), - current_install: None, - } - } - - /// Total phases the install will go through: every package emits a - /// download phase *and* an install phase, every opted-in - /// configuration step emits one phase. Always at least 1 so the - /// percentage math doesn't divide by zero on a no-op run. - fn total_phases(&self) -> usize { - (self.total_packages * 2 + self.total_configuration_steps).max(1) - } - - /// Gauge value in 0..=100. Combines completed phases with the byte - /// fraction of every in-flight download (when its `Content-Length` - /// is known) so the bar moves smoothly during a long download pull - /// rather than jumping in step-shaped chunks. Each download's - /// fraction is capped at its own single phase, so the sum stays - /// monotonic no matter how many downloads overlap. - fn percentage(&self) -> i32 { - let total = self.total_phases() as f64; - let mut fraction = self.completed_phases as f64; - for (bytes_downloaded, bytes_total) in self.active_downloads.values() { - if let Some(total_bytes) = bytes_total - && *total_bytes > 0 - { - fraction += (*bytes_downloaded as f64 / *total_bytes as f64).clamp(0.0, 1.0); - } - } - ((fraction / total) * 100.0).round().clamp(0.0, 100.0) as i32 - } - - /// Summed `(bytes_downloaded, bytes_total)` across all in-flight - /// downloads, for the aggregate status line. `bytes_total` is `None` - /// when any active download has an unknown length. - fn download_totals(&self) -> (u64, Option) { - let mut downloaded: u64 = 0; - let mut total: Option = Some(0); - for (bytes_downloaded, bytes_total) in self.active_downloads.values() { - downloaded += bytes_downloaded; - total = match (total, bytes_total) { - (Some(sum), Some(bytes)) => Some(sum + bytes), - _ => None, - }; - } - (downloaded, total) - } -} - -/// Aggregate status line for several concurrent downloads: -/// "Downloading N packages… X / Y". Used whenever more than one download -/// is in flight so the single status label doesn't flip-flop between -/// packages several times a second. -fn aggregate_download_status_line(state: &ProgressUiState, localizer: &Localizer) -> String { - let (downloaded, total) = state.download_totals(); - let count = state.active_downloads.len().to_string(); - let downloaded = format_bytes_human(downloaded); - let total = total.map_or_else(|| "?".to_string(), format_bytes_human); - localizer - .format( - "wizard-progress-status-downloading-many", - &[ - ("count", count.as_str()), - ("downloaded", downloaded.as_str()), - ("total", total.as_str()), - ], - ) - .value -} - -/// Render a byte count in the locale-neutral form `12.4 MB`. The wizard -/// has space for at most a single inline byte counter in the status -/// label, so we always pick whichever IEC unit gives a value below 1024 -/// and format it with one decimal place. Bytes (`< 1 KiB`) skip the -/// decimal entirely to avoid "0.4 B"-style nonsense. -fn format_bytes_human(bytes: u64) -> String { - const UNITS: &[&str] = &["B", "KB", "MB", "GB", "TB"]; - let mut value = bytes as f64; - let mut unit_idx = 0; - while value >= 1024.0 && unit_idx + 1 < UNITS.len() { - value /= 1024.0; - unit_idx += 1; - } - if unit_idx == 0 { - format!("{bytes} {}", UNITS[0]) - } else { - format!("{value:.1} {}", UNITS[unit_idx]) - } -} - -/// Apply a single [`ProgressEvent`] to the wizard's progress page. -/// Mutates the gauge value, replaces the status label, and appends a -/// new log line to the details TextCtrl (so a screen reader can read -/// each line as it lands). The package / configuration display-name -/// lookups go through the pre-built maps so this function never has to -/// touch the package spec list — the install handler builds the maps -/// once, before spawning the worker thread, and they're shared via -/// `Arc>`. -/// -/// `status_frozen` holds the status label at whatever the close handler -/// wrote there. Without it the next download or install event would paint -/// "Downloading OSARA…" over "Stopping…" and the wizard would look like it -/// had ignored the user. The log lines below keep flowing either way, so the -/// step that is still finishing stays visible. -fn apply_progress_event_to_ui( - state: &Arc>, - widgets: &WizardWidgets, - package_display_names: &Arc>, - configuration_display_names: &Arc>, - event: ProgressEvent, - status_frozen: bool, -) { - let mut state = state.lock().unwrap(); - let mut status_line: Option = None; - let mut log_line: Option = None; - - with_ui_localizer(|localizer| match &event { - ProgressEvent::DownloadStarted { - package_id, - bytes_total, - } => { - state - .active_downloads - .insert(package_id.clone(), (0, *bytes_total)); - let package = package_display_name(package_display_names, package_id); - // Downloads only own the status label while no install is - // running — an in-flight "Installing REAPER…" must not be - // stomped by a background download starting. And once several - // downloads are active, the label stays in aggregate form - // instead of flipping to whichever package started last. - if state.current_install.is_none() { - status_line = Some(if state.active_downloads.len() > 1 { - aggregate_download_status_line(&state, localizer) - } else { - localizer - .format( - "wizard-progress-status-downloading", - &[("package", package.as_str())], - ) - .value - }); - } - log_line = Some( - localizer - .format( - "wizard-progress-log-download-started", - &[("package", package.as_str())], - ) - .value, - ); - } - ProgressEvent::DownloadProgress { - package_id, - bytes_downloaded, - bytes_total, - } => { - let entry = state - .active_downloads - .entry(package_id.clone()) - .or_insert((0, None)); - entry.0 = *bytes_downloaded; - if bytes_total.is_some() { - entry.1 = *bytes_total; - } - // Downloads own the status label only while no install runs. - // One active download keeps today's per-package byte line; - // several concurrent downloads aggregate into one summary so - // the label (and a screen reader following it) doesn't - // flip-flop between unrelated packages several times a second. - if state.current_install.is_none() { - status_line = Some(if state.active_downloads.len() > 1 { - aggregate_download_status_line(&state, localizer) - } else { - let package = package_display_name(package_display_names, package_id); - let downloaded = format_bytes_human(*bytes_downloaded); - let total = bytes_total.map_or_else(|| "?".to_string(), format_bytes_human); - localizer - .format( - "wizard-progress-status-downloading-with-bytes", - &[ - ("package", package.as_str()), - ("downloaded", downloaded.as_str()), - ("total", total.as_str()), - ], - ) - .value - }); - } - // No log line: the running log shows discrete transitions, - // not intra-download tick-by-tick noise. - } - ProgressEvent::DownloadCompleted { package_id } => { - state.active_downloads.remove(package_id); - state.completed_phases += 1; - let package = package_display_name(package_display_names, package_id); - log_line = Some( - localizer - .format( - "wizard-progress-log-download-completed", - &[("package", package.as_str())], - ) - .value, - ); - } - ProgressEvent::InstallStarted { package_id } => { - state.current_install = Some(package_id.clone()); - let package = package_display_name(package_display_names, package_id); - status_line = Some( - localizer - .format( - "wizard-progress-status-installing", - &[("package", package.as_str())], - ) - .value, - ); - log_line = Some( - localizer - .format( - "wizard-progress-log-install-started", - &[("package", package.as_str())], - ) - .value, - ); - } - ProgressEvent::InstallCompleted { package_id } => { - state.current_install = None; - state.completed_phases += 1; - let package = package_display_name(package_display_names, package_id); - log_line = Some( - localizer - .format( - "wizard-progress-log-install-completed", - &[("package", package.as_str())], - ) - .value, - ); - } - ProgressEvent::ConfigurationStarted { step_id } => { - let step = configuration_display_name(configuration_display_names, step_id); - status_line = Some( - localizer - .format( - "wizard-progress-status-configuring", - &[("step", step.as_str())], - ) - .value, - ); - log_line = Some( - localizer - .format( - "wizard-progress-log-configuration-started", - &[("step", step.as_str())], - ) - .value, - ); - } - ProgressEvent::ConfigurationCompleted { step_id } => { - state.completed_phases += 1; - let step = configuration_display_name(configuration_display_names, step_id); - log_line = Some( - localizer - .format( - "wizard-progress-log-configuration-completed", - &[("step", step.as_str())], - ) - .value, - ); - } - }); - - widgets.progress_gauge.set_value(state.percentage()); - if let Some(line) = status_line - && !status_frozen - { - widgets.progress_status.set_label(&line); - } - // Hold the lock no longer than necessary — the TextCtrl call below - // re-enters the wxWidgets event pump, which can run other queued - // call_after closures. - drop(state); - if let Some(line) = log_line { - widgets.progress_details.append_text(&format!("\n{line}")); - } -} - -/// Resolve a `package_id` to its localized display name from the wizard -/// plan's pre-built map. Falls back to the raw id when the map doesn't -/// know the package — this only happens for synthetic test packages -/// that aren't in the wizard's PackageRow list, but the wizard should -/// still render *something* readable rather than panicking. -fn package_display_name(map: &Arc>, package_id: &str) -> String { - map.get(package_id) - .cloned() - .unwrap_or_else(|| package_id.to_string()) -} - -/// As [`package_display_name`] but for configuration-step ids. -fn configuration_display_name(map: &Arc>, step_id: &str) -> String { - map.get(step_id) - .cloned() - .unwrap_or_else(|| step_id.to_string()) -} - -#[derive(Clone, Copy)] -struct WizardWidgets { - target_choice: Choice, - portable_folder: TextCtrl, - target_details: TextCtrl, - /// Expert mode's build choices on the target page; hidden otherwise. - reaper_build_choice: Choice, - osara_build_choice: Choice, - version_check_status: StaticText, - version_check_gauge: Gauge, - version_check_error_heading: StaticText, - version_check_error_log: TextCtrl, - package_checklist: PackagesView, - package_details: TextCtrl, - osara_keymap_replace: CheckBox, - osara_keymap_note: TextCtrl, - /// Which installed language pack REAPER should use. Enabled only while - /// at least one language pack is selected; several can be installed - /// side by side, but only one is active. - reaper_language_choice: Choice, - /// Choice between the two Spanish OSARA translations. Enabled only - /// while the Spanish language pack is selected. - spanish_variant_choice: Choice, - reapack_ack_confirm: CheckBox, - review_text: TextCtrl, - progress_status: StaticText, - progress_gauge: Gauge, - progress_details: TextCtrl, - done_status: TextCtrl, - done_details: TextCtrl, - done_launch_reaper: Button, - done_open_resource: Button, - self_update_status: StatusBar, - /// Child Panel hosting the language picker + restart-note label, - /// rendered below the wizard buttons. Hidden on every step except - /// `TARGET_STEP` because switching languages relaunches RABBIT, so the - /// dropdown is only useful before the user has invested any wizard - /// progress. - language_footer: Panel, -} - -pub fn run() { - // Pre-seat Cocoa's per-process language so VoiceOver picks a voice that - // matches the in-app Fluent locale. Has to happen before `wxdragon::main` - // because that brings NSApplication / NSBundle up, and `AppleLanguages` - // is only consulted on first read of `[NSBundle preferredLocalizations]`. - // No-op off macOS. - seat_macos_apple_languages(&resolve_runtime_locale()); - - let _ = wxdragon::main(|_| { - let bootstrap = UiBootstrapOptions { - locale: resolve_runtime_locale(), - online_versions: false, - ..UiBootstrapOptions::default() - }; - match localizer_from_options(&bootstrap) { - Ok(localizer) => install_ui_localizer(localizer), - Err(error) => { - eprintln!("{error}"); - return; - } - } - let model = match load_wizard_model(bootstrap) { - Ok(model) => model, - Err(error) => { - eprintln!("{error}"); - return; - } - }; - - EXPERT_MODE.store(crate::expert_mode_requested_by_env(), Ordering::SeqCst); - let frame = Frame::builder() - .with_title(&model.window_title) - .with_size(Size::new(820, 680)) - .build(); - frame.set_name("rabbit-main-window"); - // Keep a floor on the window size: the wizard pages stack fixed-height - // controls (lists, detail panes, notes) that wrap to more lines in - // longer translations, so shrinking below this clips bottom content. - frame.set_min_size(Size::new(760, 600)); - install_ui_frame(frame); - - let root_panel = Panel::builder(&frame).build(); - root_panel.set_name("rabbit-root-panel"); - - let root = BoxSizer::builder(Orientation::Vertical).build(); - let step_label = StaticText::builder(&root_panel) - .with_label(&step_status(&model, TARGET_STEP)) - .build(); - step_label.set_name("rabbit-step-status"); - root.add(&step_label, 0, SizerFlag::All | SizerFlag::Expand, 12); - - // Use the frame's wxStatusBar for self-update status. NVDA's "Report - // status bar" command (NVDA+End) reads exactly this control, JAWS - // exposes it via its status-bar review keys, and Narrator/UIA expose - // the StatusBar role natively. Updating via SetStatusText fires the - // platform notifications that screen readers auto-announce. - let self_update_status = frame.create_status_bar(1, 0, 0, "rabbit-self-update-status"); - self_update_status.set_status_text(&model.text.self_update_status_checking, 0); - - let book = SimpleBook::builder(&root_panel).build(); - book.set_name("rabbit-wizard-pages"); - let package_rows = Rc::new(RefCell::new(model.package_rows.clone())); - let package_notes = Rc::new(RefCell::new(model.notes.clone())); - let configuration_rows = Rc::new(RefCell::new(model.configuration_rows.clone())); - // Per-platform shared state for the package list — see - // `PackagesStateCell`. Populated by `build_packages_page` on the - // first run and refreshed by `populate_packages_tree` / - // `rebuild_packages_tree_model` on subsequent rebuilds (deferred - // version-check finish, post-install rescan). - let package_items: PackagesStateCell = new_packages_state(); - let can_install = Rc::new(Cell::new(model.controls.can_install)); - let review_can_install = Rc::new(Cell::new(false)); - let last_report = Arc::new(Mutex::new(None::)); - let last_reaper_app_path = Arc::new(Mutex::new(None::)); - let last_resource_path = Arc::new(Mutex::new(None::)); - let install_run = Arc::new(InstallRunState::default()); - // Build the wizard pages first, the buttons row, then the language - // footer. Footer is constructed after the buttons so its widgets - // come *after* the buttons in tab order, but it needs to exist - // *before* `add_pages` so the WizardWidgets struct can capture - // its Panel handle. - root.add(&book, 1, SizerFlag::All | SizerFlag::Expand, 12); - - let buttons = BoxSizer::builder(Orientation::Horizontal).build(); - buttons.add_stretch_spacer(1); - - let back = Button::builder(&root_panel) - .with_label(&model.controls.back_label) - .build(); - back.set_name("rabbit-back-button"); - back.add_style(WindowStyle::TabStop); - back.set_can_focus(true); - buttons.add(&back, 0, SizerFlag::All, 6); - - let next = Button::builder(&root_panel) - .with_label(&model.controls.next_label) - .build(); - next.set_name("rabbit-next-button"); - next.add_style(WindowStyle::TabStop); - next.set_can_focus(true); - buttons.add(&next, 0, SizerFlag::All, 6); - - let install = Button::builder(&root_panel) - .with_label(&model.controls.install_label) - .build(); - install.set_name("rabbit-install-button"); - install.add_style(WindowStyle::TabStop); - install.set_can_focus(true); - buttons.add(&install, 0, SizerFlag::All, 6); - - let close = Button::builder(&root_panel) - .with_label(&model.controls.close_label) - .build(); - close.set_name("rabbit-close-button"); - close.add_style(WindowStyle::TabStop); - close.set_can_focus(true); - buttons.add(&close, 0, SizerFlag::All, 6); - - root.add_sizer(&buttons, 0, SizerFlag::All | SizerFlag::Expand, 6); - - let language_footer = build_language_footer(&root_panel, &root, &model); - let wizard_widgets = add_pages( - &book, - &model, - Rc::clone(&package_rows), - Rc::clone(&configuration_rows), - Rc::clone(&package_items), - Rc::clone(&can_install), - self_update_status, - language_footer, - ); - - root_panel.set_sizer(root, true); - - let frame_sizer = BoxSizer::builder(Orientation::Vertical).build(); - frame_sizer.add(&root_panel, 1, SizerFlag::Expand, 0); - frame.set_sizer(frame_sizer, true); - - let current_step = Arc::new(AtomicUsize::new(TARGET_STEP)); - let labels = Arc::new( - (TARGET_STEP..=DONE_STEP) - .map(|step| step_status(&model, step)) - .collect::>(), - ); - let model = Arc::new(model); - - update_navigation( - TARGET_STEP, - &book, - &step_label, - labels.as_slice(), - &back, - &next, - &install, - &close, - &language_footer, - effective_can_install(&can_install, &review_can_install), - target_is_valid(&model, &wizard_widgets), - reapack_ack_confirmed(&wizard_widgets), - ); - bind_target_navigation_updates(&model, wizard_widgets, ¤t_step, &next); - bind_reapack_ack_navigation_updates(wizard_widgets, ¤t_step, &next); - // The Done page parks focus on its read-only summary TextCtrl, which - // eats Enter before the default Close button can see it — bind both - // of that page's text boxes so Enter dismisses the wizard from - // wherever the user happens to be standing. - bind_done_page_enter_closes(&wizard_widgets.done_status, &frame, ¤t_step); - bind_done_page_enter_closes(&wizard_widgets.done_details, &frame, ¤t_step); - bind_expert_mode_chord(&frame, &model, wizard_widgets, ¤t_step); - if expert_mode() { - // RABBIT_EXPERT=1: no confirmation, setting the variable was it. - apply_expert_mode(&model, &wizard_widgets); - } - - { - let current_step = Arc::clone(¤t_step); - let labels = Arc::clone(&labels); - let model = Arc::clone(&model); - let widgets = wizard_widgets; - let can_install = Rc::clone(&can_install); - let review_can_install = Rc::clone(&review_can_install); - let back_package_rows = Rc::clone(&package_rows); - back.on_click(move |_| { - // Custom Back routing: - // - PACKAGES_STEP → TARGET_STEP (skip version check; re-running - // the fetch from a Back press isn't what the user asked for). - // - REAPACK_ACK_STEP → PACKAGES_STEP and clear the - // acknowledgement (going back resets the explicit consent). - // - REVIEW_STEP → REAPACK_ACK_STEP if ReaPack is in the - // currently-selected plan; otherwise PACKAGES_STEP, again to - // skip the now-irrelevant ack page. - let current = current_step.load(Ordering::SeqCst); - if current == VERSION_CHECK_STEP { - abandon_version_check(); - } - let step = match current { - PACKAGES_STEP => TARGET_STEP, - REAPACK_ACK_STEP => { - widgets.reapack_ack_confirm.set_value(false); - PACKAGES_STEP - } - REVIEW_STEP => { - let rows = back_package_rows.borrow(); - let checked = checked_package_indices(&rows); - if reapack_selected_for_install_or_update(&rows, &checked) { - REAPACK_ACK_STEP - } else { - PACKAGES_STEP - } - } - other => other.saturating_sub(1), - }; - current_step.store(step, Ordering::SeqCst); - update_navigation( - step, - &book, - &step_label, - labels.as_slice(), - &back, - &next, - &install, - &close, - &widgets.language_footer, - effective_can_install(&can_install, &review_can_install), - target_is_valid(&model, &widgets), - reapack_ack_confirmed(&widgets), - ); - }); - } - - { - let current_step = Arc::clone(¤t_step); - let labels = Arc::clone(&labels); - let model = Arc::clone(&model); - let widgets = wizard_widgets; - let package_rows = Rc::clone(&package_rows); - let package_notes = Rc::clone(&package_notes); - let package_items = Rc::clone(&package_items); - let configuration_rows = Rc::clone(&configuration_rows); - let can_install = Rc::clone(&can_install); - let review_can_install = Rc::clone(&review_can_install); - next.on_click(move |_| { - let step = match current_step.load(Ordering::SeqCst) { - TARGET_STEP => { - let Some(selected_target) = selected_target_row(&model, &widgets) else { - return; - }; - // No offline plan computation here: it would call - // `detect_components` (file-system + registry - // probes for every builtin package), blocking - // the UI thread for ~1–2s before the page - // transition fires — long enough that the - // screen reader and the page-flip both lag - // visibly. The version-check Finished handler - // already calls `wizard_package_plan_for_target_with_available` - // once latest versions are fetched, which does - // the same `detect_components` + `build_install_plan` - // work; doing it twice (offline first, then - // online) was redundant. Reset - // `review_can_install` so the Install button - // can't fire from a stale Review state, and let - // the worker thread do the heavy lifting. - review_can_install.set(false); - // Fix the builds for this run now, so the version - // check and the install can't disagree about them. - let channels = crate::wizard_channels( - expert_mode(), - reaper_build_channel(&widgets), - osara_build_channel(&widgets), - ); - RUN_CHANNELS.with(|cell| *cell.borrow_mut() = channels.clone()); - start_version_check(VersionCheckUi { - channels, - widgets, - model: Arc::clone(&model), - package_rows: Rc::clone(&package_rows), - package_notes: Rc::clone(&package_notes), - configuration_rows: Rc::clone(&configuration_rows), - package_items: Rc::clone(&package_items), - can_install: Rc::clone(&can_install), - review_can_install: Rc::clone(&review_can_install), - target: selected_target, - book, - step_label, - labels: Arc::clone(&labels), - back, - next, - install, - close, - current_step: Arc::clone(¤t_step), - }); - VERSION_CHECK_STEP - } - PACKAGES_STEP => { - let selected_target = selected_target_row(&model, &widgets); - let rows = package_rows.borrow(); - let notes = package_notes.borrow(); - let checked = checked_package_indices(&rows); - let review_preview = build_review_preview_for_package_rows( - &model, - selected_target.as_ref(), - &checked, - &rows, - ¬es, - osara_keymap_choice(&widgets.osara_keymap_replace), - ); - review_can_install.set(review_preview.can_install); - widgets - .review_text - .set_value(&review_preview.lines.join("\n")); - // Route through the ReaPack donation acknowledgement - // page when the user has ReaPack in the install/update - // plan; everyone else goes straight to Review. - if reapack_selected_for_install_or_update(&rows, &checked) { - REAPACK_ACK_STEP - } else { - REVIEW_STEP - } - } - REAPACK_ACK_STEP => REVIEW_STEP, - PROGRESS_STEP => DONE_STEP, - other => other, - }; - current_step.store(step, Ordering::SeqCst); - update_navigation( - step, - &book, - &step_label, - labels.as_slice(), - &back, - &next, - &install, - &close, - &widgets.language_footer, - effective_can_install(&can_install, &review_can_install), - target_is_valid(&model, &widgets), - reapack_ack_confirmed(&widgets), - ); - if step == VERSION_CHECK_STEP { - // Pull the screen reader onto the progress bar so the - // user hears that a check is running. Without this, - // focus would stay on the Next button from the Target - // page and the version-check progress wouldn't be - // announced until the auto-advance to Packages fires. - widgets.version_check_gauge.set_focus(); - } - }); - } - - { - let current_step = Arc::clone(¤t_step); - let labels = Arc::clone(&labels); - let model = Arc::clone(&model); - let widgets = wizard_widgets; - let package_rows = Rc::clone(&package_rows); - let package_notes = Rc::clone(&package_notes); - let package_items = Rc::clone(&package_items); - let configuration_rows = Rc::clone(&configuration_rows); - let can_install = Rc::clone(&can_install); - let review_can_install = Rc::clone(&review_can_install); - let last_report = Arc::clone(&last_report); - let last_reaper_app_path = Arc::clone(&last_reaper_app_path); - let last_resource_path = Arc::clone(&last_resource_path); - let install_run = Arc::clone(&install_run); - install.on_click(move |_| { - current_step.store(PROGRESS_STEP, Ordering::SeqCst); - update_navigation( - PROGRESS_STEP, - &book, - &step_label, - labels.as_slice(), - &back, - &next, - &install, - &close, - &widgets.language_footer, - effective_can_install(&can_install, &review_can_install), - target_is_valid(&model, &widgets), - reapack_ack_confirmed(&widgets), - ); - back.enable(false); - next.enable(false); - install.enable(false); - widgets.done_launch_reaper.enable(false); - widgets.done_open_resource.enable(false); - widgets - .progress_status - .set_label(&model.text.progress_status_running); - widgets.progress_gauge.set_value(10); - set_last_report(&last_report, None); - - let selected_target = selected_target_row(&model, &widgets); - set_last_path( - &last_reaper_app_path, - selected_target - .as_ref() - .map(planned_reaper_launch_path_for_target), - ); - set_last_resource_path( - &last_resource_path, - selected_target.as_ref().map(|target| target.path.clone()), - ); - let rows = package_rows.borrow(); - let selected_packages = checked_package_indices(&rows); - widgets - .progress_details - .set_value(&progress_details_for_start( - &model, - selected_target.as_ref(), - &selected_packages, - &rows, - osara_keymap_choice(&widgets.osara_keymap_replace), - None, - )); - let request = match selected_target - .as_ref() - .ok_or_else(|| rabbit_core::RabbitError::PreflightFailed { - message: model.text.review_no_target.clone(), - }) - .and_then(|target| { - let configuration_step_ids = - selected_configuration_step_ids(&configuration_rows.borrow()); - install_request_from_target_and_rows( - &model, - target, - &rows, - &selected_packages, - configuration_step_ids, - WizardInstallOptions { - osara_keymap_choice: osara_keymap_choice( - &widgets.osara_keymap_replace, - ), - // Ticked = Team PMA's Spanish OSARA - // translation (installs as es_MX); unticked - // = the default REAPER Accesible español - // (es_ES). - package_variants: crate::package_variants_from_choice( - widgets.spanish_variant_choice.get_selection(), - ), - // Which of the installed language packs REAPER - // starts in. The dropdown lists the ticked - // packs in row order, so the selection index - // maps straight back to a package id. - reaper_language_package: crate::selected_language_packs( - &rows, - &selected_packages, - ) - .get(widgets.reaper_language_choice.get_selection().unwrap_or(0) - as usize) - .map(|(id, _)| id.clone()), - package_channels: RUN_CHANNELS.with(|cell| cell.borrow().clone()), - ..WizardInstallOptions::default() - }, - ) - }) { - Ok(request) => request, - Err(error) => { - widgets.progress_gauge.set_value(100); - widgets - .progress_status - .set_label(&model.text.done_status_error); - // Done page: short reason on the always-visible - // status TextCtrl; full error text in the - // collapsible details below. - widgets.done_status.set_value(&model.text.done_status_error); - widgets.done_details.set_value(&error.to_string()); - widgets - .progress_details - .set_value(&format!("{}\n\n{}", model.text.done_status_error, error)); - widgets - .done_open_resource - .enable(clone_last_resource_path(&last_resource_path).is_some()); - widgets - .done_launch_reaper - .enable(can_launch_last_reaper_path(&last_reaper_app_path)); - current_step.store(DONE_STEP, Ordering::SeqCst); - update_navigation( - DONE_STEP, - &book, - &step_label, - labels.as_slice(), - &back, - &next, - &install, - &close, - &widgets.language_footer, - effective_can_install(&can_install, &review_can_install), - target_is_valid(&model, &widgets), - reapack_ack_confirmed(&widgets), - ); - // Focus the always-visible status TextCtrl so the - // screen reader reads the success/failure summary - // immediately, and so Tab from there moves on to - // the Show-details CheckBox / action buttons - // instead of cycling back through earlier widgets. - widgets.done_status.set_focus(); - return; - } - }; - widgets - .progress_details - .set_value(&progress_details_for_start( - &model, - selected_target.as_ref(), - &selected_packages, - &rows, - osara_keymap_choice(&widgets.osara_keymap_replace), - Some(&request.cache_dir), - )); - drop(rows); - - // Arm the post-install rescan hook. The hook captures the - // UI-thread `Rc` shared state so the call_after - // success arm can refresh it without smuggling non-Send - // references across threads. The hook closure runs on the - // UI thread; it re-detects the selected target, runs the - // offline package plan against the now-fresh receipts, and - // updates both the cached state and the on-screen package - // list — so navigating Back from the Done page (or - // re-opening the Packages step via Rescan) shows the - // post-install version without the user having to click - // anything. - { - let model = Arc::clone(&model); - let package_rows = Rc::clone(&package_rows); - let package_notes = Rc::clone(&package_notes); - let package_items = Rc::clone(&package_items); - let configuration_rows = Rc::clone(&configuration_rows); - let can_install = Rc::clone(&can_install); - let review_can_install = Rc::clone(&review_can_install); - let last_reaper_app_path = Arc::clone(&last_reaper_app_path); - let last_resource_path = Arc::clone(&last_resource_path); - arm_post_install_hook(move || { - let Some(target) = selected_target_row(&model, &widgets) else { - return; - }; - let refreshed_target = refreshed_target_row(&model, &target); - // Plan against the versions this run installed from, - // so a development build just installed reads as - // current instead of as something to replace. - let plan = match RUN_AVAILABLE.with(|cell| cell.borrow().clone()) { - Some(available) => wizard_package_plan_for_target_with_available( - &model, - Some(&refreshed_target), - &available, - ), - None => wizard_package_plan_for_target(&model, Some(&refreshed_target)), - }; - let Ok(plan) = plan else { - return; - }; - *package_rows.borrow_mut() = plan.package_rows; - *package_notes.borrow_mut() = plan.notes; - // Recompute configuration row availability against - // the freshly-rebuilt package plan (e.g. ReaPack - // just got installed → REAPER Accessibility step - // becomes available). - if let Ok(localizer) = localizer_from_options(&model.bootstrap_options) { - recompute_configuration_row_availability( - &localizer, - &package_rows.borrow(), - Some(&refreshed_target.path), - &mut configuration_rows.borrow_mut(), - ); - } - can_install.set(plan.can_install); - review_can_install.set(false); - refresh_package_checklist( - &widgets.package_checklist, - &package_items, - &widgets.package_details, - &widgets.osara_keymap_replace, - &widgets.osara_keymap_note, - &widgets.spanish_variant_choice, - &widgets.reaper_language_choice, - &model, - &package_rows.borrow(), - &configuration_rows.borrow(), - ); - refresh_target_choice( - &model, - &widgets.target_choice, - refreshed_target_index(&model, &widgets), - &refreshed_target, - ); - widgets.target_details.set_value(&refreshed_target.details); - set_last_path( - &last_reaper_app_path, - Some(planned_reaper_launch_path_for_target(&refreshed_target)), - ); - set_last_resource_path( - &last_resource_path, - Some(refreshed_target.path.clone()), - ); - }); - } - - let ui_model = Arc::clone(&model); - let ui_current_step = Arc::clone(¤t_step); - let ui_labels = Arc::clone(&labels); - let ui_last_report = Arc::clone(&last_report); - let ui_last_reaper_app_path = Arc::clone(&last_reaper_app_path); - let ui_last_resource_path = Arc::clone(&last_resource_path); - let can_install = effective_can_install(&can_install, &review_can_install); - let request_for_report = request.clone(); - - // Build progress lookup maps + the per-install UI state now, - // on the UI thread, where the Rc-based package_rows / - // configuration_rows are still in scope. The maps are - // Send+Sync (Arc>) so they ride along - // with the worker thread's progress callback into each - // call_after closure that runs back on the UI thread. - let configuration_rows_for_progress = configuration_rows.borrow(); - let rows_for_progress = package_rows.borrow(); - let package_display_names: Arc> = Arc::new( - request - .package_ids - .iter() - .filter_map(|package_id| { - rows_for_progress - .iter() - .find(|row| &row.package_id == package_id) - .map(|row| (row.package_id.clone(), row.display_name.clone())) - }) - .collect(), - ); - let configuration_display_names: Arc> = Arc::new( - request - .configuration_step_ids - .iter() - .filter_map(|step_id| { - configuration_rows_for_progress - .iter() - .find(|row| &row.step_id == step_id) - .map(|row| (row.step_id.clone(), row.display_name.clone())) - }) - .collect(), - ); - drop(configuration_rows_for_progress); - drop(rows_for_progress); - - let progress_state = Arc::new(Mutex::new(ProgressUiState::new( - request.package_ids.len(), - request.configuration_step_ids.len(), - ))); - let progress_widgets = widgets; - let progress_state_for_reporter = Arc::clone(&progress_state); - let package_display_names_for_reporter = Arc::clone(&package_display_names); - let configuration_display_names_for_reporter = - Arc::clone(&configuration_display_names); - // Once the operation returned, background download workers - // may still emit a few final events before they notice the - // cancel flag — gate them out so nothing repaints the gauge - // or status after the completion closure wrote the outcome. - let operation_finished = Arc::new(std::sync::atomic::AtomicBool::new(false)); - let operation_finished_for_reporter = Arc::clone(&operation_finished); - let install_run_for_reporter = Arc::clone(&install_run); - let progress = ProgressReporter::new(move |event| { - // The reporter fires on the worker thread; forward each - // event to the UI thread so the gauge / status / log can - // be touched safely. call_after serialises closures on - // the UI thread, so ProgressUiState mutations happen one - // event at a time despite the Arc> wrapper. - if operation_finished_for_reporter.load(std::sync::atomic::Ordering::Relaxed) { - return; - } - let state = Arc::clone(&progress_state_for_reporter); - let package_display_names = Arc::clone(&package_display_names_for_reporter); - let configuration_display_names = - Arc::clone(&configuration_display_names_for_reporter); - let widgets = progress_widgets; - let status_frozen = install_run_for_reporter.stop_requested(); - wxdragon::call_after(Box::new(move || { - apply_progress_event_to_ui( - &state, - &widgets, - &package_display_names, - &configuration_display_names, - event, - status_frozen, - ); - })); - }); - - let cancel = install_run.begin(); - let install_run_for_worker = Arc::clone(&install_run); - std::thread::spawn(move || { - let result = execute_wizard_install_with_progress(request, &progress, &cancel); - operation_finished.store(true, std::sync::atomic::Ordering::Relaxed); - wxdragon::call_after(Box::new(move || { - widgets.progress_gauge.set_value(100); - match result { - Ok(report) => { - let outcome_report = wizard_outcome_report_from_success( - &ui_model, - &request_for_report, - &report, - ); - widgets.progress_details.set_value(&format!( - "{}\n\n{}", - outcome_report.status_line, - outcome_report.detail_lines.join("\n") - )); - set_last_resource_path( - &ui_last_resource_path, - Some(report.resource_path.clone()), - ); - set_last_report(&ui_last_report, Some(outcome_report.clone())); - // Auto-save the outcome report under - // /RABBIT/logs/ so users always have - // a JSON+text trail without having to - // remember to click "Save report". Best - // effort: log to stderr and continue if the - // save itself fails. - if let Err(error) = save_wizard_outcome_report(&outcome_report) { - eprintln!("could not auto-save wizard outcome report: {error}"); - } - // The operation returns Ok even when some - // packages failed (it installs everything it - // can), and when the user stopped it partway. - // Pick an honest heading: a plain "success" - // line would contradict the "finished with - // errors" summary otherwise, and a stopped run - // is neither of those. - let heading = if report.was_cancelled() { - &ui_model.text.done_status_cancelled - } else if report.package_operation.has_failures() { - &ui_model.text.done_status_completed_with_errors - } else { - &ui_model.text.done_status_success - }; - widgets.progress_status.set_label(heading); - // Done page: show the outcome heading on the - // status TextCtrl and the full setup-report - // detail block in the collapsible TextCtrl. - widgets.done_status.set_value(&format!( - "{}\n\n{}", - heading, outcome_report.status_line, - )); - widgets - .done_details - .set_value(&outcome_report.detail_lines.join("\n")); - set_last_path( - &ui_last_reaper_app_path, - request_for_report - .target_app_path - .as_ref() - .filter(|path| path.exists()) - .cloned(), - ); - widgets - .done_launch_reaper - .enable(can_launch_last_reaper_path(&ui_last_reaper_app_path)); - widgets.done_open_resource.enable(true); - // Auto-rescan: the install pipeline just - // wrote a fresh receipt for whatever - // landed, and the cached package_rows - // still reflect pre-install state. Fire - // the post-install hook the click handler - // armed earlier so navigating back from - // the Done page (or via Rescan) reflects - // the new on-disk state without the user - // having to click anything. - fire_post_install_hook(); - } - Err(error) => { - let outcome_report = wizard_outcome_report_from_error( - &ui_model, - &request_for_report, - &error, - ); - set_last_report(&ui_last_report, Some(outcome_report.clone())); - // Same auto-save policy as the success path: - // failure runs are exactly when a saved log - // helps users diagnose what went wrong. - if let Err(save_error) = save_wizard_outcome_report(&outcome_report) - { - eprintln!( - "could not auto-save wizard outcome report: {save_error}" - ); - } - widgets.progress_details.set_value(&format!( - "{}\n\n{}", - outcome_report.status_line, - outcome_report.detail_lines.join("\n") - )); - widgets - .progress_status - .set_label(&ui_model.text.done_status_error); - widgets.done_status.set_value(&outcome_report.status_line); - widgets - .done_details - .set_value(&outcome_report.detail_lines.join("\n")); - widgets - .done_launch_reaper - .enable(can_launch_last_reaper_path(&ui_last_reaper_app_path)); - widgets.done_open_resource.enable( - clone_last_resource_path(&ui_last_resource_path).is_some(), - ); - } - } - ui_current_step.store(DONE_STEP, Ordering::SeqCst); - update_navigation( - DONE_STEP, - &book, - &step_label, - ui_labels.as_slice(), - &back, - &next, - &install, - &close, - &widgets.language_footer, - can_install, - target_is_valid(&ui_model, &widgets), - reapack_ack_confirmed(&widgets), - ); - // Focus the always-visible status TextCtrl so the - // screen reader announces the install result and - // Tab moves forward to the Show-details CheckBox - // and action buttons instead of cycling back to - // an earlier widget. - widgets.done_status.set_focus(); - // The worker is done and everything it held has been - // dropped, so the window can go now. Force the close: - // there is nothing left to ask the user about, and the - // close handler would otherwise re-open the same - // question it already answered. - let close_now = install_run_for_worker.stop_requested(); - install_run_for_worker.finish(); - if close_now { - with_ui_frame(|frame| frame.close(true)); - } - })); - }); - }); - } - - let frame_for_close = frame; - close.on_click(move |_| { - // Not forced: the frame's close handler has to be able to stop - // this while an install is running. - frame_for_close.close(false); - }); - - // Every route out of the wizard — the Close button, the window's - // close box, Alt+F4, Cmd+Q — arrives here, which is the one place - // that knows whether it is safe to go. - { - let model_for_close = Arc::clone(&model); - let install_run = Arc::clone(&install_run); - let widgets = wizard_widgets; - let close_button = close; - frame.on_close(move |event| { - match close_verdict(&install_run) { - CloseVerdict::Allow => { - // Let wxWidgets' own frame handler run and destroy - // the window. Nothing is in flight, so there is - // nothing to unwind. - event.skip(true); - } - CloseVerdict::SelfUpdateInProgress => { - // Not a question: there is no answer that makes - // quitting safe here, so say what is happening - // instead of offering a choice RABBIT can't honour. - show_close_blocked_by_self_update(&model_for_close); - } - CloseVerdict::AlreadyStopping => { - // A second press while the first stop is still - // unwinding. Re-announce rather than re-ask. - announce_stopping(&widgets, &model_for_close); - } - CloseVerdict::Ask => { - if !confirm_stop_install(&model_for_close) { - return; - } - if !install_run.request_stop_and_close() { - // The install finished while the question was on - // screen, so there is nothing left to stop and - // nobody left to close the window for us. Queue - // the close for the next turn of the event loop - // rather than re-entering this handler from - // inside itself. - wxdragon::call_after(Box::new(|| { - with_ui_frame(|frame| frame.close(true)); - })); - return; - } - // The button that raised the question is now the - // wrong thing to press again. - close_button.enable(false); - announce_stopping(&widgets, &model_for_close); - } - } - // Every branch except `Allow` falls through without - // skipping, which is what keeps the window alive: the - // default handler that would destroy it never runs. - }); - } - - // Handle the application-menu Quit item: the macOS menu bar - // installed below carries a stock wxID_EXIT item, which wxOSX - // relocates into the application menu as "Quit rabbit" (Cmd+Q). - // Selecting it emits a plain menu command with that id at the - // frame — route it to the same teardown as the wizard's Close - // button and the window close box. Bound on every platform, but - // only macOS installs a menu that can emit it. - { - let frame_for_quit = frame; - frame.on_menu_selected(move |event| { - if event.get_id() == wxdragon::id::ID_EXIT { - frame_for_quit.close(false); - } - }); - } - - // Without any menu bar, wxOSX installs no functional main menu at - // all: the wizard's application menu had a dead Quit item and Cmd+Q - // did nothing. Installing a minimal menu bar makes wxWidgets build - // a real application menu and wire its Quit item to the stock - // wxID_EXIT command handled above. `Ctrl+Q` in a wx accelerator - // string means Cmd+Q on macOS. macOS-only: on Windows this would - // add a visible File menu to a wizard that doesn't want one, and - // Alt+F4 already closes the window natively there. - #[cfg(target_os = "macos")] - { - let file_menu = Menu::builder() - .append_item(wxdragon::id::ID_EXIT, "E&xit\tCtrl+Q", "") - .build(); - let menu_bar = MenuBar::builder().append(file_menu, "&File").build(); - frame.set_menu_bar(menu_bar); - } - - { - let model = Arc::clone(&model); - let widgets = wizard_widgets; - let last_reaper_app_path = Arc::clone(&last_reaper_app_path); - let frame_for_launch = frame; - widgets.done_launch_reaper.on_click(move |_| { - let Some(app_path) = clone_last_path(&last_reaper_app_path) else { - append_done_status(&widgets.done_status, &model.text.done_no_reaper_app); - return; - }; - if let Err(error) = launch_reaper(&app_path) { - append_done_status( - &widgets.done_status, - &format!("{}: {}", model.text.done_launch_reaper_error_prefix, error), - ); - return; - } - frame_for_launch.close(true); - }); - } - - { - let model = Arc::clone(&model); - let widgets = wizard_widgets; - let last_resource_path = Arc::clone(&last_resource_path); - widgets.done_open_resource.on_click(move |_| { - let Some(path) = clone_last_resource_path(&last_resource_path) else { - append_done_status(&widgets.done_status, &model.text.review_no_target); - return; - }; - if let Err(error) = open_resource_folder(&path) { - append_done_status( - &widgets.done_status, - &format!("{}: {}", model.text.done_open_resource_error_prefix, error), - ); - } - }); - } - - // (The "Save report" button used to live on the Done page so the - // user could re-save the outcome JSON+text manually. RABBIT already - // auto-saves under `/RABBIT/logs/` on every run — both - // success and failure paths — so the manual button was redundant - // and added clutter on a page meant to read like a destination, - // not a dashboard.) - - let self_update_state = Arc::new(Mutex::new(SelfUpdateUiState::default())); - - // One-shot startup probe: runs the self-update manifest check - // and stores the result into the shared state, then renders. - // (Used to also poll a global package-install lock — that lock - // is now per-target, so the cross-target probe is gone.) - { - let model = Arc::clone(&model); - let widgets = wizard_widgets; - let state = Arc::clone(&self_update_state); - std::thread::spawn(move || { - let check = run_wizard_self_update_check(); - // Resolve the What's-New notes on this same worker, while - // the status line still reads "Checking for RABBIT - // updates…": the prompt is only raised once the whole probe - // lands on the UI thread, so fetching notes here costs the - // user nothing and spares the UI thread an HTTP round-trip. - let release_notes = check - .as_ref() - .ok() - .and_then(run_wizard_self_update_release_notes); - { - let mut state = state.lock().unwrap(); - state.release_notes = release_notes; - state.check = Some(match check { - Ok(report) => Ok(report), - Err(error) => Err(error.to_string()), - }); - } - let render_state = Arc::clone(&state); - let render_model = Arc::clone(&model); - wxdragon::call_after(Box::new(move || { - with_ui_localizer(|localizer| { - render_self_update_status(widgets, &render_model, localizer, &render_state); - }); - })); - }); - } - - // (Used to also spawn a polling thread that re-checked a global - // install lock and re-rendered when another RABBIT process started - // an install. With per-target locks there's no global lock to - // poll; if a same-target race happens, the install path surfaces - // it as a `PackageInstallInProgress` error at acquire time.) - - // (The Done page used to host an "Apply RABBIT update" button as - // an always-reachable fallback to the once-per-session prompt. It - // was removed because users couldn't find it before completing an - // install — the modal at startup is now the only entry point, and - // a user who picks "No" gets re-prompted by relaunching RABBIT.) - - // (The "Rescan target" button used to live here so the user could - // re-detect installed components on the Done page and jump back - // to the Packages step. With the post-install auto-rescan hook, - // package_rows is already up to date by the time the user lands - // on Done — manual rescan is a debugging affordance. Users who - // want to re-detect can just relaunch RABBIT.) - - frame.centre(); - frame.show(true); - }); -} - -/// A wizard page: a scrolling panel rather than a plain one. -/// -/// Every page stacks fixed-height controls — lists, detail panes, notes — -/// and their total height grows with the UI language, since longer -/// translations wrap the labels between them onto extra lines. A plain -/// `wxPanel` cannot scroll, so whatever no longer fits is silently clipped, -/// and a `BoxSizer` short of room takes the whole deficit out of its only -/// proportion-1 child. On the Packages page that child is the package list -/// itself: it collapsed to nothing on a window that wasn't maximized, and a -/// control of zero height is not in the platform's accessibility tree at -/// all, so screen readers stopped seeing the list. Scrolling keeps every -/// control laid out at its real size and reachable whatever the window size. -type WizardPage = ScrolledWindow; - -/// Vertical scroll step for a wizard page, in pixels. The horizontal step is -/// 0, which turns horizontal scrolling off: page content expands to the page -/// width instead of extending past it. -const WIZARD_PAGE_SCROLL_STEP: i32 = 10; - -/// Build one empty wizard page inside the book. -fn new_wizard_page(book: &SimpleBook) -> WizardPage { - let page = ScrolledWindow::builder(book).build(); - page.set_scroll_rate(0, WIZARD_PAGE_SCROLL_STEP); - // wxScrolledWindow is created without wxTAB_TRAVERSAL, which wxPanel - // gets by default — without it Tab stops moving between the controls of - // a page. Put the flag back before any child is created. - page.set_style_raw(page.get_style_raw() | PanelStyle::TabTraversal.bits()); - page -} - -#[allow(clippy::too_many_arguments)] // UI plumbing: one parameter per widget handle. -fn add_pages( - book: &SimpleBook, - model: &WizardModel, - package_rows: Rc>>, - configuration_rows: Rc>>, - package_items: PackagesStateCell, - can_install: Rc>, - self_update_status: StatusBar, - language_footer: Panel, -) -> WizardWidgets { - let target_page = new_wizard_page(book); - let (target_choice, portable_folder, target_details, reaper_build_choice, osara_build_choice) = - build_target_page(&target_page, model); - book.add_page(&target_page, &model.steps[TARGET_STEP].label, true, None); - - let version_check_page = new_wizard_page(book); - let ( - version_check_status, - version_check_gauge, - version_check_error_heading, - version_check_error_log, - ) = build_version_check_page( - &version_check_page, - model, - wizard_desired_package_ids(model.platform).len() as i32, - ); - book.add_page( - &version_check_page, - &model.steps[VERSION_CHECK_STEP].label, - false, - None, - ); - - let packages_page = new_wizard_page(book); - let ( - package_checklist, - package_details, - osara_keymap_replace, - osara_keymap_note, - spanish_variant_choice, - reaper_language_choice, - ) = build_packages_page( - &packages_page, - model, - package_rows, - configuration_rows, - package_items, - can_install, - ); - book.add_page( - &packages_page, - &model.steps[PACKAGES_STEP].label, - false, - None, - ); - - let reapack_ack_page = new_wizard_page(book); - let (_reapack_donate_link, reapack_ack_confirm) = - build_reapack_ack_page(&reapack_ack_page, model); - book.add_page( - &reapack_ack_page, - &model.steps[REAPACK_ACK_STEP].label, - false, - None, - ); - - let review_page = new_wizard_page(book); - let review_text = build_review_page(&review_page, model); - book.add_page(&review_page, &model.steps[REVIEW_STEP].label, false, None); - - let progress_page = new_wizard_page(book); - let (progress_status, progress_gauge, progress_details) = - build_progress_page(&progress_page, model); - book.add_page( - &progress_page, - &model.steps[PROGRESS_STEP].label, - false, - None, - ); - - let done_page = new_wizard_page(book); - let (done_status, done_details, done_launch_reaper, done_open_resource) = - build_done_page(&done_page, model); - book.add_page(&done_page, &model.steps[DONE_STEP].label, false, None); - - WizardWidgets { - target_choice, - portable_folder, - target_details, - reaper_build_choice, - osara_build_choice, - version_check_status, - version_check_gauge, - version_check_error_heading, - version_check_error_log, - package_checklist, - package_details, - osara_keymap_replace, - osara_keymap_note, - spanish_variant_choice, - reaper_language_choice, - reapack_ack_confirm, - review_text, - progress_status, - progress_gauge, - progress_details, - done_status, - done_details, - done_launch_reaper, - done_open_resource, - self_update_status, - language_footer, - } -} - -fn build_target_page( - page: &WizardPage, - model: &WizardModel, -) -> (Choice, TextCtrl, TextCtrl, Choice, Choice) { - let sizer = BoxSizer::builder(Orientation::Vertical).build(); - add_heading( - page, - &sizer, - &model.text.target_heading, - "rabbit-target-heading", - ); - - add_label( - page, - &sizer, - &model.text.target_choice_label, - "rabbit-target-choice-label", - ); - - let choice = Choice::builder(page).build(); - choice.set_name("rabbit-target-choice"); - for row in &model.target_rows { - choice.append(&row.label); - } - let portable_index = portable_choice_index(model); - choice.append(&model.text.target_portable_choice); - choice.set_selection(model.selected_target_index.unwrap_or(portable_index) as u32); - sizer.add(&choice, 0, SizerFlag::All | SizerFlag::Expand, 6); - - add_label( - page, - &sizer, - &model.text.target_portable_folder_label, - "rabbit-target-portable-folder-label", - ); - - // We build the path input as a TextCtrl + Browse button instead of - // wxDirPickerCtrl: wxdragon doesn't expose the picker's inner wxTextCtrl, - // so the screen reader has no way to read a label off it. Mirroring the - // picker's composition by hand lets us name the editable field directly, - // and the user gets a real text input they can paste/type into. - let portable_row = BoxSizer::builder(Orientation::Horizontal).build(); - let portable_folder = TextCtrl::builder(page).build(); - // Same wxdragon quirk as the ReaPack-ack checkbox below: the screen - // reader reads the wxWindow *name*, not the preceding StaticText, so - // set the name to the localized label instead of an internal id. - portable_folder.set_name(&model.text.target_portable_folder_label); - portable_folder.add_style(WindowStyle::TabStop); - portable_row.add(&portable_folder, 1, SizerFlag::Expand | SizerFlag::Right, 6); - - let portable_folder_browse = Button::builder(page) - .with_label(&model.text.target_portable_folder_browse_label) - .build(); - portable_folder_browse.set_name(&model.text.target_portable_folder_browse_label); - portable_folder_browse.add_style(WindowStyle::TabStop); - portable_row.add( - &portable_folder_browse, - 0, - SizerFlag::AlignCenterVertical, - 0, - ); - - sizer.add_sizer(&portable_row, 0, SizerFlag::All | SizerFlag::Expand, 6); - - configure_portable_folder( - &portable_folder, - &portable_folder_browse, - choice - .get_selection() - .map(|index| index as usize == portable_index) - .unwrap_or(false), - ); - - add_label( - page, - &sizer, - &model.text.target_details_label, - "rabbit-target-details-label", - ); - let initial_details = selected_target_details(model, &choice, &portable_folder); - let details = TextCtrl::builder(page) - .with_value(&initial_details) - .with_style(TextCtrlStyle::MultiLine | TextCtrlStyle::ReadOnly | TextCtrlStyle::WordWrap) - .with_size(Size::new(-1, 120)) - .build(); - details.set_name("rabbit-target-details"); - sizer.add(&details, 1, SizerFlag::All | SizerFlag::Expand, 6); - - // Expert mode's build choices. Created after the target details, so they - // come last in the tab order, and hidden until expert mode is on. - add_label( - page, - &sizer, - &model.text.expert_reaper_builds_label, - REAPER_BUILDS_LABEL_NAME, - ); - let reaper_build_choice = Choice::builder(page).build(); - reaper_build_choice.set_name(&model.text.expert_reaper_builds_label); - reaper_build_choice.append(&model.text.expert_reaper_builds_stable); - reaper_build_choice.append(&model.text.expert_reaper_builds_dev); - reaper_build_choice.set_selection(0); - sizer.add( - &reaper_build_choice, - 0, - SizerFlag::All | SizerFlag::Expand, - 6, - ); - - add_label( - page, - &sizer, - &model.text.expert_osara_builds_label, - OSARA_BUILDS_LABEL_NAME, - ); - let osara_build_choice = Choice::builder(page).build(); - osara_build_choice.set_name(&model.text.expert_osara_builds_label); - osara_build_choice.append(&model.text.expert_osara_builds_snapshot); - osara_build_choice.set_selection(0); - sizer.add( - &osara_build_choice, - 0, - SizerFlag::All | SizerFlag::Expand, - 6, - ); - OSARA_BUILD_CHOICE.set(Some(osara_build_choice)); - - { - let choice_model = model.clone(); - let choice_portable_folder = portable_folder; - let choice_portable_browse = portable_folder_browse; - let choice_details = details; - let choice_reaper_builds = reaper_build_choice; - choice.on_selection_changed(move |event| { - if let Some(index) = event.get_selection() { - let index = index as usize; - let portable_selected = index == portable_choice_index(&choice_model); - if expert_mode() { - // Another REAPER may have been installed from other - // builds: start the choices from its receipt. - let target_path = if portable_selected { - portable_folder_path(&choice_portable_folder) - } else { - choice_model - .target_rows - .get(index) - .map(|row| row.path.clone()) - }; - seed_build_choices(target_path.as_deref(), &choice_reaper_builds); - } - configure_portable_folder( - &choice_portable_folder, - &choice_portable_browse, - portable_selected, - ); - let value = if portable_selected { - portable_target_details(&choice_model, &choice_portable_folder) - } else { - target_details_for_index(&choice_model, index) - }; - choice_details.set_value(&value); - } - }); - } - - { - let model = model.clone(); - let dir_choice = choice; - let dir_details = details; - let dir_portable_folder = portable_folder; - let dir_portable_browse = portable_folder_browse; - let dir_reaper_builds = reaper_build_choice; - // Fires both for keyboard input AND for `set_value` from the Browse - // button below — wxTextCtrl::SetValue generates wxEVT_TEXT — so this - // single handler handles typing and the picker dialog uniformly. - portable_folder.on_text_changed(move |_| { - let portable_index = portable_choice_index(&model); - if dir_choice - .get_selection() - .map(|index| index as usize != portable_index) - .unwrap_or(true) - { - dir_choice.set_selection(portable_index as u32); - configure_portable_folder(&dir_portable_folder, &dir_portable_browse, true); - } - dir_details.set_value(&portable_target_details(&model, &dir_portable_folder)); - // A different folder is a different REAPER, possibly installed - // from other builds: start the choices from its receipt, as a - // change of target does. - if expert_mode() { - seed_build_choices( - portable_folder_path(&dir_portable_folder).as_deref(), - &dir_reaper_builds, - ); - } - }); - } - - { - let dialog_parent = *page; - let model_for_browse = model.clone(); - let browse_target = portable_folder; - portable_folder_browse.on_click(move |_| { - let current = browse_target.get_value(); - let dialog = DirDialog::builder( - &dialog_parent, - &model_for_browse.text.target_portable_folder_message, - ¤t, - ) - .build(); - if dialog.show_modal() == ID_OK - && let Some(path) = dialog.get_path() - { - // Fires on_text_changed, which runs the same flip-to-portable - // + update-details logic typing does. - browse_target.set_value(&path); - } - }); - } - - page.set_sizer(sizer, true); - set_optional_choice_shown(&reaper_build_choice, REAPER_BUILDS_LABEL_NAME, false); - set_optional_choice_shown(&osara_build_choice, OSARA_BUILDS_LABEL_NAME, false); - choice.set_focus(); - ( - choice, - portable_folder, - details, - reaper_build_choice, - osara_build_choice, - ) -} - -/// Base id for the language popup menu's radio items. Item id at index `i` -/// in `WizardModel::language_options` is `LANGUAGE_MENU_ID_BASE + i`. -const LANGUAGE_MENU_ID_BASE: i32 = 13700; - -/// Build the language-picker footer inside a child Panel that lives below -/// the wizard buttons. The footer is only meaningful on the Target page — -/// switching languages relaunches RABBIT, so a switch from a later step -/// would discard the user's wizard progress anyway. Returning the child -/// Panel here lets the caller hide/show it via `update_navigation` based -/// on the current step. Adding it as a sibling of the button row means -/// tab order naturally reaches it after the last button (rather than -/// partway through the page), then wraps back to the page's first -/// focusable widget. -fn build_language_footer(root_panel: &Panel, root: &BoxSizer, model: &WizardModel) -> Panel { - let footer = Panel::builder(root_panel).build(); - footer.set_name("rabbit-language-footer"); - let footer_sizer = BoxSizer::builder(Orientation::Vertical).build(); - - add_label( - &footer, - &footer_sizer, - &model.text.target_language_label, - "rabbit-target-language-label", - ); - - let current_display_name = model - .language_options - .iter() - .find(|option| option.locale == model.current_language) - .map(|option| option.display_name.clone()) - .unwrap_or_else(|| model.current_language.clone()); - - let language_button = Button::builder(&footer) - .with_label(¤t_display_name) - .build(); - language_button.set_name("rabbit-target-language"); - language_button.add_style(WindowStyle::TabStop); - language_button.set_can_focus(true); - footer_sizer.add(&language_button, 0, SizerFlag::All | SizerFlag::Expand, 6); - - add_label( - &footer, - &footer_sizer, - &model.text.target_language_restart_note, - "rabbit-target-language-restart-note", - ); - - footer.set_sizer(footer_sizer, true); - root.add(&footer, 0, SizerFlag::All | SizerFlag::Expand, 6); - - let language_options = model.language_options.clone(); - let current_locale = model.current_language.clone(); - - // The popup menu dispatches its EVT_MENU to the popup's owner window - // (the footer Panel here), not to the button that opened it. - { - let language_options = language_options.clone(); - let current_locale = current_locale.clone(); - footer.on_menu_selected(move |event| { - let id = event.get_id(); - let raw_index = id - LANGUAGE_MENU_ID_BASE; - if raw_index < 0 || (raw_index as usize) >= language_options.len() { - return; - } - let Some(option) = language_options.get(raw_index as usize) else { - return; - }; - if option.locale == current_locale { - return; - } - relaunch_with_locale(&option.locale); - }); - } - - let menu_owner = footer; - language_button.on_click(move |_| { - let mut builder = Menu::builder(); - for (index, option) in language_options.iter().enumerate() { - let id = LANGUAGE_MENU_ID_BASE + index as i32; - builder = builder.append_radio_item(id, &option.display_name, ""); - } - let menu = builder.build(); - for (index, option) in language_options.iter().enumerate() { - if option.locale == current_locale { - let id = LANGUAGE_MENU_ID_BASE + index as i32; - menu.check_item(id, true); - } - } - let mut menu = menu; - menu_owner.popup_menu(&mut menu, None); - }); - - footer -} - -/// Captures everything the version-check dispatcher needs to drive the -/// dedicated version-check page: widgets, model, package-row state for the -/// auto-rebuild on success, and the navigation handles needed to advance to -/// the Packages step. -struct VersionCheckUi { - widgets: WizardWidgets, - model: Arc, - package_rows: Rc>>, - package_notes: Rc>>, - configuration_rows: Rc>>, - package_items: PackagesStateCell, - can_install: Rc>, - review_can_install: Rc>, - target: TargetRow, - /// Which builds to check each package on (empty outside expert mode). - channels: rabbit_core::package::PackageChannels, - book: SimpleBook, - step_label: StaticText, - labels: Arc>, - back: Button, - next: Button, - install: Button, - close: Button, - current_step: Arc, -} - -/// Reset the version-check page to its starting state, install the dispatcher -/// that handles per-package events on the UI thread, and spawn the worker -/// thread. The dispatcher auto-advances to the Packages step on full success; -/// on any failure it stays on the version-check page with the error log -/// populated and the Back button enabled. -fn start_version_check(ui: VersionCheckUi) { - let package_ids = wizard_desired_package_ids(ui.model.platform); - let package_count = package_ids.len() as i32; - // Snapshot what the worker's since-installed What's-New trim needs - // before `ui` moves into the dispatcher closure below. - let target_resource_path = ui.target.path.clone(); - let target_platform = ui.model.platform; - let target_reaper_version = ui.target.version.clone(); - let channels = ui.channels.clone(); - ui.widgets - .version_check_status - .set_label(&ui.model.text.version_check_status_pending); - ui.widgets.version_check_gauge.set_value(0); - ui.widgets - .version_check_gauge - .set_range(package_count.max(1)); - ui.widgets.version_check_error_log.set_value(""); - // The error region stays out of the tab order and the a11y tree until a - // check actually fails — see render_version_check_errors for the show. - ui.widgets.version_check_error_heading.hide(); - ui.widgets.version_check_error_log.hide(); - - let mut accumulated: Vec = Vec::new(); - let mut errors: Vec<(String, String)> = Vec::new(); - let mut completed: i32 = 0; - - let dispatcher = move |event: VersionCheckEvent| match event { - VersionCheckEvent::Result { - package_id, - outcome, - } => { - completed += 1; - ui.widgets.version_check_gauge.set_value(completed); - with_ui_localizer(|localizer| { - let line = localizer - .format( - "wizard-version-check-status-progress", - &[ - ("done", completed.to_string().as_str()), - ("total", package_count.to_string().as_str()), - ], - ) - .value; - ui.widgets.version_check_status.set_label(&line); - }); - match outcome { - Ok((version_str, whats_new, channel)) => { - match rabbit_core::version::Version::parse(&version_str) { - Ok(version) => { - accumulated.push(AvailablePackage { - package_id, - version: Some(version), - whats_new, - channel, - }); - } - Err(error) => { - errors.push((package_id, error.to_string())); - } - } - } - Err(message) => { - errors.push((package_id, message)); - } - } - } - VersionCheckEvent::Finished => { - // Per-package failures (an upstream being down, a parse error) - // no longer block the wizard: the plan is built from whatever - // versions did resolve, and each failed package's row is - // disabled with a localized reason + a Review-page note carrying - // the full error. Only a failure to build the plan itself keeps - // the wizard on this page with the error log shown. - { - RUN_AVAILABLE.with(|cell| *cell.borrow_mut() = Some(accumulated.clone())); - match wizard_package_plan_for_target_with_available( - &ui.model, - Some(&ui.target), - &accumulated, - ) { - Ok(mut plan) => { - if !errors.is_empty() - && let Ok(localizer) = - localizer_from_options(&ui.model.bootstrap_options) - { - plan.can_install = apply_version_check_failures_to_rows( - &localizer, - &mut plan.package_rows, - &mut plan.notes, - &errors, - ); - } - *ui.package_rows.borrow_mut() = plan.package_rows; - *ui.package_notes.borrow_mut() = plan.notes; - // The deferred fetch may have promoted ReaPack to - // Update (or vice versa); refresh configuration - // row availability against the fresh plan. - if let Ok(localizer) = localizer_from_options(&ui.model.bootstrap_options) { - recompute_configuration_row_availability( - &localizer, - &ui.package_rows.borrow(), - Some(&ui.target.path), - &mut ui.configuration_rows.borrow_mut(), - ); - } - ui.can_install.set(plan.can_install); - ui.review_can_install.set(false); - // Show the Spanish variant the target already has - // installed, so a Team PMA user isn't presented with - // an unticked box that would switch them back. - ui.widgets - .spanish_variant_choice - .set_selection(crate::spanish_variant_selection(&ui.target.path)); - rebuild_package_list_widgets( - &ui.widgets, - &ui.package_items, - &ui.model, - &ui.package_rows.borrow(), - &ui.configuration_rows.borrow(), - ); - ui.current_step.store(PACKAGES_STEP, Ordering::SeqCst); - update_navigation( - PACKAGES_STEP, - &ui.book, - &ui.step_label, - ui.labels.as_slice(), - &ui.back, - &ui.next, - &ui.install, - &ui.close, - &ui.widgets.language_footer, - effective_can_install(&ui.can_install, &ui.review_can_install), - true, - reapack_ack_confirmed(&ui.widgets), - ); - // The wizard just auto-advanced onto the packages - // page: land the screen reader on the top of the - // list (the "Packages" group header), not wherever - // the native control left its caret after the - // repopulation above. Mirrors the explicit gauge - // focus on entering the version-check page. - focus_packages_list_top(&ui.widgets, &ui.package_items); - } - Err(error) => { - errors.push((String::new(), error.to_string())); - render_version_check_errors(&ui, &errors); - } - } - } - } - }; - - install_version_check_dispatcher(Box::new(dispatcher)); - let generation = VERSION_CHECK_GENERATION.fetch_add(1, Ordering::SeqCst) + 1; - spawn_version_check_worker( - package_ids, - target_resource_path, - target_platform, - target_reaper_version, - channels, - generation, - ); -} - -/// Render error lines to the version-check page's error TextCtrl and update -/// the status text to point the user at Back/Close. -fn render_version_check_errors(ui: &VersionCheckUi, errors: &[(String, String)]) { - with_ui_localizer(|localizer| { - let mut lines = Vec::with_capacity(errors.len()); - for (package_id, message) in errors { - let display = if package_id.is_empty() { - String::new() - } else { - localized_package_display_name(localizer, package_id) - }; - let line = localizer - .format( - "wizard-version-check-error-line", - &[("package", display.as_str()), ("message", message.as_str())], - ) - .value; - lines.push(line); - } - ui.widgets - .version_check_error_log - .set_value(&lines.join("\n")); - // Surface the error region now that there is content for screen - // readers + the tab order to expose. - ui.widgets.version_check_error_heading.show(true); - ui.widgets.version_check_error_log.show(true); - let status = localizer - .format( - "wizard-version-check-status-error", - &[("error_count", errors.len().to_string().as_str())], - ) - .value; - ui.widgets.version_check_status.set_label(&status); - }); -} - -/// Re-render the package list after the deferred fetch repopulates -/// `package_rows`. Invoked on successful version check, just before the -/// auto-advance to the Packages step. Two implementations: Windows rebuilds -/// the native TreeCtrl from scratch; non-Windows mutates the DataView -/// model's userdata in place and emits a `cleared()` notification. -#[cfg(target_os = "windows")] -fn rebuild_package_list_widgets( - widgets: &WizardWidgets, - package_items: &PackagesStateCell, - model: &WizardModel, - package_rows: &[PackageRow], - configuration_rows: &[ConfigurationRow], -) { - populate_packages_tree( - &widgets.package_checklist, - package_items, - model, - package_rows, - configuration_rows, - ); - let initial = package_rows - .first() - .map(package_details) - .unwrap_or_default(); - widgets.package_details.set_value(&initial); -} - -/// Put keyboard focus on the packages list with the screen-reader caret on -/// its TOP-MOST entry — the "Packages" group header. After the list is -/// repopulated (delete-all + re-append + per-item check-state writes) the -/// native `wxTreeCtrl`'s caret ends up on an arbitrary row a few items -/// down, so a screen-reader user entering the page starts mid-list instead -/// of at the top. Called when the wizard auto-advances onto the packages -/// page; deliberate Back navigation is left alone so it keeps the user's -/// previous position. -#[cfg(target_os = "windows")] -fn focus_packages_list_top(widgets: &WizardWidgets, package_items: &PackagesStateCell) { - // Clone the item id and DROP the borrow before touching the tree: - // `select_item` dispatches the selection-changed handler synchronously - // on MSW, and that handler borrows `package_items` too. - let group = package_items.borrow().packages_group.clone(); - if let Some(group) = group { - let tree = &widgets.package_checklist; - tree.ensure_visible(&group); - tree.select_item(&group); - tree.set_focused_item(&group); - } - widgets.package_checklist.set_focus(); -} - -/// Windows-only: tear down the existing native tree and rebuild both -/// top-level groups ("Packages" and "Configuration") from -/// `package_rows` + `configuration_rows`. Each leaf gets its native -/// `TVS_CHECKBOXES` state set to match its row's `selected`; each -/// group gets a tristate reflecting its children's aggregate. -#[cfg(target_os = "windows")] -fn populate_packages_tree( - tree: &TreeCtrl, - package_items: &PackagesStateCell, - model: &WizardModel, - package_rows: &[PackageRow], - configuration_rows: &[ConfigurationRow], -) { - tree.delete_all_items(); - { - let mut items = package_items.borrow_mut(); - items.packages_group = None; - items.additional_software_group = None; - items.language_group = None; - items.packages_leaves.clear(); - items.configuration_group = None; - items.configuration_leaves.clear(); - } - - let Some(root) = tree.add_root("", None, None) else { - return; - }; - - // Package rows split into two sibling groups by category: "Packages" - // (Core — the REAPER accessibility stack) and "Additional software" - // (Surge XT, app2clap, … — extras not tied to REAPER itself). The - // Additional-software group node is only created when at least one - // Additional-category row exists, so it never renders empty. - let Some(packages_group) = - tree.append_item(&root, &model.text.packages_tree_group_label, None, None) - else { - return; - }; - let has_additional = package_rows - .iter() - .any(|row| row.category == rabbit_core::package::PackageCategory::Additional); - let additional_software_group = if has_additional { - tree.append_item( - &root, - &model.text.additional_software_tree_group_label, - None, - None, - ) - } else { - None - }; - let has_language = package_rows - .iter() - .any(|row| row.category == rabbit_core::package::PackageCategory::Language); - let language_group = if has_language { - tree.append_item(&root, &model.text.language_tree_group_label, None, None) - } else { - None - }; - // One leaf per row, in row order, parented under the group that matches - // the row's category. `packages_leaves[i]` stays the leaf for - // `package_rows[i]` regardless of which group it hangs under, so every - // leaf↔row index lookup elsewhere remains 1:1. - let mut packages_leaves = Vec::with_capacity(package_rows.len()); - for row in package_rows.iter() { - let parent = match row.category { - rabbit_core::package::PackageCategory::Additional => additional_software_group - .as_ref() - .unwrap_or(&packages_group), - rabbit_core::package::PackageCategory::Language => { - language_group.as_ref().unwrap_or(&packages_group) - } - rabbit_core::package::PackageCategory::Core => &packages_group, - }; - let label = format_row_label(&row.summary, row.selected); - if let Some(item) = tree.append_item(parent, &label, None, None) { - native_tree_checkboxes::set_check_state(tree.get_handle(), &item, row.selected); - packages_leaves.push(item); - } - } - // Tristate reflecting each group's available children's aggregate. - let packages_state = compute_packages_group_tristate(package_rows); - native_tree_checkboxes::set_check_state_tri(tree.get_handle(), &packages_group, packages_state); - if let Some(group) = additional_software_group.as_ref() { - let additional_state = compute_additional_software_group_tristate(package_rows); - native_tree_checkboxes::set_check_state_tri(tree.get_handle(), group, additional_state); - } - if let Some(group) = language_group.as_ref() { - let language_state = compute_language_group_tristate(package_rows); - native_tree_checkboxes::set_check_state_tri(tree.get_handle(), group, language_state); - } - - // Configuration group + leaves. Always created, even if no - // configuration rows are recommended for this run — keeps the tree - // shape stable so the user can find the section if/when it - // populates after a target switch or post-install rescan. - let Some(configuration_group) = tree.append_item( - &root, - &model.text.configuration_tree_group_label, - None, - None, - ) else { - return; - }; - let mut configuration_leaves = Vec::with_capacity(configuration_rows.len()); - for row in configuration_rows.iter() { - let label = format_row_label(&row.summary, row.selected); - if let Some(item) = tree.append_item(&configuration_group, &label, None, None) { - native_tree_checkboxes::set_check_state(tree.get_handle(), &item, row.selected); - configuration_leaves.push(item); - } - } - let configuration_state = compute_configuration_group_tristate(configuration_rows); - native_tree_checkboxes::set_check_state_tri( - tree.get_handle(), - &configuration_group, - configuration_state, - ); - - { - let mut items = package_items.borrow_mut(); - items.packages_group = Some(packages_group.clone()); - items.additional_software_group = additional_software_group.clone(); - items.language_group = language_group.clone(); - items.packages_leaves = packages_leaves; - items.configuration_group = Some(configuration_group.clone()); - items.configuration_leaves = configuration_leaves; - } - - tree.expand(&packages_group); - if let Some(group) = additional_software_group.as_ref() { - tree.expand(group); - } - if let Some(group) = language_group.as_ref() { - tree.expand(group); - } - tree.expand(&configuration_group); -} - -/// Windows-only: format a tree-row label. The native `TVS_CHECKBOXES` -/// style draws the checkbox for us, so this is currently just the summary -/// — kept as a single point so we can later add a status glyph or icon -/// without auditing every call site. -#[cfg(target_os = "windows")] -fn format_row_label(summary: &str, _selected: bool) -> String { - summary.to_string() -} - -/// Windows-only: aggregate the per-row `selected` flags into a tristate for -/// the synthetic "Packages" (Core) group node. Unavailable rows don't count -/// for either side because they can't enter the install plan and toggling -/// them is a no-op — we only look at the rows the user can actually flip. -#[cfg(target_os = "windows")] -fn compute_packages_group_tristate(rows: &[crate::PackageRow]) -> native_tree_checkboxes::TriState { - compute_package_category_tristate(rows, rabbit_core::package::PackageCategory::Core) -} - -/// Windows-only: the same aggregate for the "Additional software" group node. -#[cfg(target_os = "windows")] -fn compute_additional_software_group_tristate( - rows: &[crate::PackageRow], -) -> native_tree_checkboxes::TriState { - compute_package_category_tristate(rows, rabbit_core::package::PackageCategory::Additional) -} - -/// Windows-only: the same aggregate for the "Language packs" group node. -#[cfg(target_os = "windows")] -fn compute_language_group_tristate(rows: &[crate::PackageRow]) -> native_tree_checkboxes::TriState { - compute_package_category_tristate(rows, rabbit_core::package::PackageCategory::Language) -} - -/// Windows-only: tristate over the available rows of a single package -/// category, so each top-level group's checkbox reflects only its own -/// children. -#[cfg(target_os = "windows")] -fn compute_package_category_tristate( - rows: &[crate::PackageRow], - category: rabbit_core::package::PackageCategory, -) -> native_tree_checkboxes::TriState { - let mut any = false; - let mut all = true; - let mut any_checked = false; - for row in rows - .iter() - .filter(|r| r.category == category && r.available_for_target) - { - any = true; - if row.selected { - any_checked = true; - } else { - all = false; - } - } - if !any { - // No selectable rows in this category (e.g. everything's unavailable - // for this target). Render the group as unchecked rather than mixed — - // there's nothing to toggle. - return native_tree_checkboxes::TriState::Unchecked; - } - if all { - native_tree_checkboxes::TriState::Checked - } else if any_checked { - native_tree_checkboxes::TriState::Mixed - } else { - native_tree_checkboxes::TriState::Unchecked - } -} - -/// Windows-only: aggregate the per-row `selected` flags of every -/// actionable [`ConfigurationRow`] into a tristate for the synthetic -/// "Configuration" group node. Same convention as -/// `compute_packages_group_tristate` — unavailable rows AND -/// already-applied rows are excluded (the user can't toggle either), -/// and an empty actionable-set renders as Unchecked. -#[cfg(target_os = "windows")] -fn compute_configuration_group_tristate( - rows: &[crate::ConfigurationRow], -) -> native_tree_checkboxes::TriState { - let mut any = false; - let mut all = true; - let mut any_checked = false; - for row in rows - .iter() - .filter(|r| r.available_for_target && !r.already_applied) - { - any = true; - if row.selected { - any_checked = true; - } else { - all = false; - } - } - if !any { - return native_tree_checkboxes::TriState::Unchecked; - } - if all { - native_tree_checkboxes::TriState::Checked - } else if any_checked { - native_tree_checkboxes::TriState::Mixed - } else { - native_tree_checkboxes::TriState::Unchecked - } -} - -/// Spawn the deferred latest-version fetch on a background thread. Each -/// per-package outcome is forwarded to the UI thread via `call_after`, which -/// invokes the dispatcher installed by the click handler. -/// -/// Before fetching, the worker detects what's already installed at the -/// target so each package's What's-New notes can be trimmed to the changes -/// since the installed version. Detection here is best-effort: a failure -/// just means untrimmed notes, never a failed check. -/// How many latest-version checks run at once. Matches the download pool's -/// modest fan-out: enough to hide one slow check behind the others without -/// hammering hosts that serve several packages. -const VERSION_CHECK_CONCURRENCY: usize = 4; - -fn spawn_version_check_worker( - package_ids: Vec, - resource_path: PathBuf, - platform: Platform, - target_reaper_version: Option, - channels: rabbit_core::package::PackageChannels, - generation: u64, -) { - std::thread::spawn(move || { - let mut installed_versions: HashMap = - detect_components(&resource_path, platform) - .map(|detections| { - detections - .into_iter() - .filter(|detection| detection.installed) - .filter_map(|detection| Some((detection.package_id, detection.version?))) - .collect() - }) - .unwrap_or_default(); - // REAPER itself is versioned by the selected target row, not by the - // component detections that cover the extensions. - if let Some(version) = target_reaper_version { - installed_versions.insert(PACKAGE_REAPER.to_string(), version); - } - // Check packages CONCURRENTLY. The checks are independent network - // fetches against different hosts, and their costs are wildly - // uneven — most resolve in about a second, but a language pack is - // identified by the hash of its contents, so checking one downloads - // the whole file (the German pack is ~1.7 MB and takes several - // seconds). Run sequentially that one check stalled the whole - // progress bar; overlapped, the run costs roughly the slowest single - // check instead of the sum of all of them. - // - // Bounded pool rather than one thread per package: a handful of - // parallel requests is polite to the upstream hosts (several - // packages share one) and is where the wall-clock win already - // saturates. - let queue = std::sync::Arc::new(std::sync::Mutex::new( - package_ids - .into_iter() - .collect::>(), - )); - let installed_versions = std::sync::Arc::new(installed_versions); - let channels = std::sync::Arc::new(channels); - let worker_count = - VERSION_CHECK_CONCURRENCY.min(queue.lock().map(|q| q.len()).unwrap_or(1).max(1)); - let mut workers = Vec::with_capacity(worker_count); - for _ in 0..worker_count { - let queue = std::sync::Arc::clone(&queue); - let installed_versions = std::sync::Arc::clone(&installed_versions); - let channels = std::sync::Arc::clone(&channels); - workers.push(std::thread::spawn(move || { - loop { - // Scope the lock so it is never held across a fetch. - let next = queue.lock().ok().and_then(|mut q| q.pop_front()); - let Some(package_id) = next else { - break; - }; - let installed = installed_versions.get(&package_id); - let channel = channels.get(&package_id).map(String::as_str); - let outcome = match fetch_latest_details_for_package_on( - &package_id, - installed, - channel, - ) { - Ok(details) => Ok(( - details.version.to_string(), - details.whats_new, - details.channel, - )), - Err(error) => Err(error.to_string()), - }; - - let id_for_result = package_id.clone(); - wxdragon::call_after(Box::new(move || { - dispatch_version_check_event( - generation, - VersionCheckEvent::Result { - package_id: id_for_result, - outcome, - }, - ); - })); + { + let current_step = Arc::clone(¤t_step); + let labels = Arc::clone(&labels); + let model = Arc::clone(&model); + let widgets = wizard_widgets; + let can_install = Rc::clone(&can_install); + let review_can_install = Rc::clone(&review_can_install); + let back_package_rows = Rc::clone(&package_rows); + back.on_click(move |_| { + // Custom Back routing: + // - PACKAGES_STEP → TARGET_STEP (skip version check; re-running + // the fetch from a Back press isn't what the user asked for). + // - REAPACK_ACK_STEP → PACKAGES_STEP and clear the + // acknowledgement (going back resets the explicit consent). + // - REVIEW_STEP → REAPACK_ACK_STEP if ReaPack is in the + // currently-selected plan; otherwise PACKAGES_STEP, again to + // skip the now-irrelevant ack page. + let current = current_step.load(Ordering::SeqCst); + if current == VERSION_CHECK_STEP { + abandon_version_check(); } - })); - } - for worker in workers { - let _ = worker.join(); - } - wxdragon::call_after(Box::new(move || { - dispatch_version_check_event(generation, VersionCheckEvent::Finished); - })); - }); -} - -/// Trigger the self-update apply pipeline on a worker thread, routing -/// progress (start, summary, relaunch / error) to both the Done page's -/// `done_status` text control and the always-visible `self_update_status` -/// status bar. Two surfaces because the apply can be invoked from two -/// places: the Done page button (where `done_status` is the natural -/// detail surface and `self_update_status` is a redundant short-form), -/// and the once-per-session "RABBIT update available" prompt at startup -/// (where the user is on the Target step and only `self_update_status` -/// is visible). The duplication keeps both call sites simple — neither -/// has to know which surface their user can see. -/// -/// Takes individual widget handles rather than the full `WizardWidgets` -/// because that struct now holds a `Frame` (for parenting modal -/// dialogs) and `Frame` isn't `Send` — capturing the whole struct -/// into the spawned worker would break the closure's `Send` bound. -fn start_self_update_apply( - done_status: TextCtrl, - self_update_status: StatusBar, - model: Arc, -) { - append_done_status(&done_status, &model.text.done_self_update_apply_running); - self_update_status.set_status_text(&model.text.done_self_update_apply_running, 0); - // Put the progress window up before the worker starts, so the very - // first event has somewhere to land. It is modeless: the worker's - // completion path runs through `call_after` on this same thread, which - // a modal dialog's nested event loop would let run but would also trap - // the exit-after-relaunch behind its own dismissal. - open_self_update_progress_window(&model); - // Held for the whole swap so the close handler refuses to quit under a - // half-replaced RABBIT. Cleared on every exit from the worker, including - // the failure paths — the relaunch path exits the process instead. - SELF_UPDATE_APPLYING.store(true, Ordering::SeqCst); - let model_for_thread = Arc::clone(&model); - std::thread::spawn(move || { - // Every event crosses back to the UI thread — widgets must not be - // touched from here — and `ProgressEvent` is `Send`, so it rides - // inside the `call_after` closure. - let progress = ProgressReporter::new(|event| { - wxdragon::call_after(Box::new(move || { - update_self_update_progress_window(&event); - })); - }); - let result = run_wizard_self_update_apply(&progress); - SELF_UPDATE_APPLYING.store(false, Ordering::SeqCst); - wxdragon::call_after(Box::new(close_self_update_progress_window)); - wxdragon::call_after(Box::new(move || match result { - Ok(report) => { - with_ui_localizer(|localizer| { - let summary = format_self_update_apply_summary(localizer, &report); - append_done_status(&done_status, &summary); - self_update_status.set_status_text(&summary, 0); - }); - if !report.replaced_files.is_empty() { - match relaunch_rabbit_after_apply(expert_mode()) { - Ok(pid) => { - let msg = format!( - "{}: PID {}", - model_for_thread.text.done_self_update_relaunch_prefix, pid - ); - append_done_status(&done_status, &msg); - self_update_status.set_status_text(&msg, 0); - // Mirror relaunch_with_locale: hand off to the new - // process and exit, otherwise the pre-update GUI - // sticks around next to the freshly-launched copy. - std::process::exit(0); - } - Err(error) => { - let msg = format!( - "{}: {}", - model_for_thread.text.done_self_update_error_prefix, error - ); - append_done_status(&done_status, &msg); - self_update_status.set_status_text(&msg, 0); + let step = match current { + PACKAGES_STEP => TARGET_STEP, + REAPACK_ACK_STEP => { + widgets.reapack_ack_confirm.set_value(false); + PACKAGES_STEP + } + REVIEW_STEP => { + let rows = back_package_rows.borrow(); + let checked = checked_package_indices(&rows); + if reapack_selected_for_install_or_update(&rows, &checked) { + REAPACK_ACK_STEP + } else { + PACKAGES_STEP } } - } - } - Err(error) => { - let msg = format!( - "{}: {}", - model_for_thread.text.done_self_update_error_prefix, error + other => other.saturating_sub(1), + }; + current_step.store(step, Ordering::SeqCst); + update_navigation( + step, + &book, + &step_label, + labels.as_slice(), + &back, + &next, + &install, + &close, + &widgets.language_footer, + effective_can_install(&can_install, &review_can_install), + target_is_valid(&model, &widgets), + reapack_ack_confirmed(&widgets), ); - append_done_status(&done_status, &msg); - self_update_status.set_status_text(&msg, 0); - } - })); - }); -} - -/// Widgets of the live self-update progress window. Held in a thread-local -/// because none of them are `Send`: the worker thread never touches them, -/// it only posts `ProgressEvent`s through `call_after`, and the closure -/// that runs on the UI thread picks the widgets up from here. -struct SelfUpdateProgressWindow { - dialog: Dialog, - status: StaticText, - gauge: Gauge, - log: TextCtrl, - /// Localized product name, resolved once at construction so each event - /// doesn't have to go back through the localizer for it. - app_name: String, -} - -thread_local! { - static SELF_UPDATE_PROGRESS: RefCell> = - const { RefCell::new(None) }; -} - -/// Share of the bar given to the download. The remainder covers the -/// install, which is a single opaque step: the bar jumps to this mark when -/// the bytes are in and finishes when the swap is done. -const SELF_UPDATE_DOWNLOAD_SHARE: i32 = 90; - -/// Put up the update progress window: a status line, a bar, and a running -/// log of the phases as they complete. -/// -/// The same three-part shape as the wizard's own progress page, for the -/// same reason — the bar is what a sighted user reads, while the log is -/// what a screen reader can be walked through afterwards, line by line, -/// which a bar alone never affords. Modeless, and it takes no buttons: the -/// update cannot be cancelled once started (nothing in the core can unwind -/// a half-applied swap), so a Cancel button would be a lie. -fn open_self_update_progress_window(model: &Arc) { - close_self_update_progress_window(); - with_ui_frame(|frame| { - with_ui_localizer(|localizer| { - let title = localizer.text("wizard-self-update-progress-title").value; - let app_name = localizer.text("app-short-name").value; - let dialog = Dialog::builder(frame, &title) - .with_style(DialogStyle::Caption | DialogStyle::ResizeBorder) - .with_size(460, 260) - .build(); - let panel = Panel::builder(&dialog).build(); - let sizer = BoxSizer::builder(Orientation::Vertical).build(); - - let status = StaticText::builder(&panel) - .with_label(&model.text.done_self_update_apply_running) - .build(); - status.set_name("rabbit-self-update-progress-status"); - sizer.add(&status, 0, SizerFlag::All | SizerFlag::Expand, 6); - - let gauge = Gauge::builder(&panel).with_range(100).build(); - gauge.set_name("rabbit-self-update-progress-gauge"); - sizer.add(&gauge, 0, SizerFlag::All | SizerFlag::Expand, 6); - - let log = TextCtrl::builder(&panel) - .with_value("") - .with_style( - TextCtrlStyle::MultiLine | TextCtrlStyle::ReadOnly | TextCtrlStyle::WordWrap, - ) - .build(); - log.set_name(&title); - sizer.add(&log, 1, SizerFlag::All | SizerFlag::Expand, 6); - - panel.set_sizer(sizer, true); - let dialog_sizer = BoxSizer::builder(Orientation::Vertical).build(); - dialog_sizer.add(&panel, 1, SizerFlag::Expand, 0); - dialog.set_sizer(dialog_sizer, true); - - // No Cancel button also means Escape does nothing: wxWidgets - // answers it by emulating a click on the escape-id button, and - // there is none here. That is the behaviour we want — dismissing - // the window would leave the update running unreported. - dialog.show(true); - log.set_focus(); - - SELF_UPDATE_PROGRESS.with(|cell| { - *cell.borrow_mut() = Some(SelfUpdateProgressWindow { - dialog, - status, - gauge, - log, - app_name, - }); }); - }); - }); -} - -/// Fold one progress event into the window. Silently does nothing when no -/// window is up — events can still be in flight when it has been closed. -fn update_self_update_progress_window(event: &ProgressEvent) { - SELF_UPDATE_PROGRESS.with(|cell| { - let borrowed = cell.borrow(); - let Some(window) = borrowed.as_ref() else { - return; - }; - with_ui_localizer(|localizer| { - let package = window.app_name.as_str(); - match event { - ProgressEvent::DownloadStarted { .. } => { - window.gauge.set_value(0); - window.status.set_label( - &localizer - .format( - "wizard-progress-status-downloading", - &[("package", package)], - ) - .value, - ); - // Only the phase change is logged; the byte ticks below - // would flood a screen reader reading the log. - append_self_update_progress_log( - window, - &localizer - .format( - "wizard-progress-log-download-started", - &[("package", package)], - ) - .value, - ); - } - ProgressEvent::DownloadProgress { - bytes_downloaded, - bytes_total, - .. - } => { - // Without a Content-Length there is no fraction to show, - // so the bar stays where it is and the byte counter in - // the status line carries the news instead. - if let Some(total) = bytes_total.filter(|total| *total > 0) { - let ratio = (*bytes_downloaded as f64 / total as f64).clamp(0.0, 1.0); - window - .gauge - .set_value((ratio * SELF_UPDATE_DOWNLOAD_SHARE as f64) as i32); - } - let downloaded = format_bytes_human(*bytes_downloaded); - let total = bytes_total.map_or_else(|| "?".to_string(), format_bytes_human); - window.status.set_label( - &localizer - .format( - "wizard-progress-status-downloading-with-bytes", - &[ - ("package", package), - ("downloaded", downloaded.as_str()), - ("total", total.as_str()), - ], - ) - .value, - ); - } - ProgressEvent::DownloadCompleted { .. } => { - window.gauge.set_value(SELF_UPDATE_DOWNLOAD_SHARE); - append_self_update_progress_log( - window, - &localizer - .format( - "wizard-progress-log-download-completed", - &[("package", package)], - ) - .value, - ); - } - ProgressEvent::InstallStarted { .. } => { - window.status.set_label( - &localizer - .format("wizard-progress-status-installing", &[("package", package)]) - .value, - ); - append_self_update_progress_log( - window, - &localizer - .format( - "wizard-progress-log-install-started", - &[("package", package)], - ) - .value, - ); - } - ProgressEvent::InstallCompleted { .. } => { - window.gauge.set_value(100); - append_self_update_progress_log( - window, - &localizer - .format( - "wizard-progress-log-install-completed", - &[("package", package)], - ) - .value, - ); - } - // Configuration steps belong to package installs; RABBIT's - // own update never emits them. - ProgressEvent::ConfigurationStarted { .. } - | ProgressEvent::ConfigurationCompleted { .. } => {} - } - }); - }); -} - -/// Append one line to the progress window's log, keeping the newest line in -/// view. -fn append_self_update_progress_log(window: &SelfUpdateProgressWindow, line: &str) { - let existing = window.log.get_value(); - let separator = if existing.is_empty() { "" } else { "\n" }; - window - .log - .set_value(&format!("{existing}{separator}{line}")); -} - -/// Tear the progress window down. Safe to call when none is up, and safe to -/// call twice — the second call finds the slot empty. -fn close_self_update_progress_window() { - let window = SELF_UPDATE_PROGRESS.with(|cell| cell.borrow_mut().take()); - if let Some(window) = window { - window.dialog.destroy(); - } -} - -/// macOS: tell Cocoa what language this process is running in by setting the -/// `AppleLanguages` env var, which `[NSBundle preferredLocalizations]` honors -/// before falling back to the user's system-wide language preferences. The -/// bundle's `CFBundleLocalizations` (set in `packaging/macos/Info.plist`) -/// must list the same language codes for this to take effect — without that, -/// Cocoa refuses the override and falls through to its English default. The -/// payoff is VoiceOver picking a voice that matches the in-app UI language; -/// without it, the German UI gets read with the English voice on a system -/// configured for English. -/// -/// Uses the BCP-47 language subtag only (`de-DE` → `de`) because that's what -/// matches the `.lproj` directory names and avoids needing region-specific -/// voices to exist on the host. Caller is `run`, before any AppKit init has -/// happened — `AppleLanguages` is read on first access and cached. -#[cfg(target_os = "macos")] -fn seat_macos_apple_languages(locale: &str) { - let language = locale.split('-').next().unwrap_or(locale).trim(); - if language.is_empty() { - return; - } - // Property-list array literal — Cocoa's preferred encoding for - // `AppleLanguages` env var values. Single-language form is enough; we - // don't ship a fallback chain. - let value = format!("({language})"); - // SAFETY: `run` is called from `main` before any threads are spawned; - // edition-2024 `set_var` only requires unsafe to flag the cross-thread - // hazard, which doesn't apply at this point in startup. - unsafe { - std::env::set_var("AppleLanguages", value); - } -} - -#[cfg(not(target_os = "macos"))] -fn seat_macos_apple_languages(_locale: &str) {} - -/// Relaunch the running RABBIT executable with `RABBIT_LOCALE=` set so the -/// new locale takes effect immediately, then exit. Errors during relaunch are -/// printed to stderr and the current process keeps running so the user is not -/// left without a UI. -fn relaunch_with_locale(locale: &str) { - let exe = match std::env::current_exe() { - Ok(exe) => exe, - Err(error) => { - eprintln!("could not resolve current executable for relaunch: {error}"); - return; - } - }; - let mut command = Command::new(&exe); - command.env("RABBIT_LOCALE", locale); - // Expert mode is not saved anywhere, so a relaunch has to carry it - - // and must drop an inherited RABBIT_EXPERT if it was switched off. - if expert_mode() { - command.env(crate::EXPERT_MODE_ENV, "1"); - } else { - command.env_remove(crate::EXPERT_MODE_ENV); - } - match command.spawn() { - Ok(_) => std::process::exit(0), - Err(error) => { - eprintln!("could not relaunch RABBIT with locale {locale}: {error}"); } - } -} - -/// Windows: native `wxTreeCtrl` driving `SysTreeView32` with -/// `TVS_CHECKBOXES`. Each row exposes UIA Toggle pattern, screen readers -/// announce checked state, Space toggles natively. See -/// `native_tree_checkboxes` for the raw Win32 plumbing that flips the -/// style after wx has created the control. -#[cfg(target_os = "windows")] -fn build_packages_page( - page: &WizardPage, - model: &WizardModel, - package_rows: Rc>>, - configuration_rows: Rc>>, - package_items: PackagesStateCell, - can_install: Rc>, -) -> (PackagesView, TextCtrl, CheckBox, TextCtrl, Choice, Choice) { - let sizer = BoxSizer::builder(Orientation::Vertical).build(); - add_heading( - page, - &sizer, - &model.text.packages_heading, - "rabbit-packages-heading", - ); - add_label( - page, - &sizer, - &model.text.packages_list_label, - "rabbit-packages-list-label", - ); - - // wxTreeCtrl is a thin wrapper around the platform's native tree: - // SysTreeView32 on Windows, NSOutlineView on macOS, GtkTreeView on GTK. - // HasButtons + LinesAtRoot give the standard expand/collapse affordance; - // HideRoot keeps the synthetic root invisible so the "Packages" group - // appears as the top-level branch the user navigates first. - let tree = TreeCtrl::builder(page) - .with_style( - TreeCtrlStyle::HasButtons - | TreeCtrlStyle::LinesAtRoot - | TreeCtrlStyle::Single - | TreeCtrlStyle::HideRoot, - ) - .with_size(Size::new(-1, 220)) - .build(); - tree.set_name("rabbit-package-list"); - // Floor the list height so longer translations (the labels, checkbox, and - // notes below grow vertically in German/French) can never squeeze the - // proportion-1 list down to nothing. It still expands to fill free space. - tree.set_min_size(Size::new(-1, 160)); - - // Switch the underlying SysTreeView32 to TVS_CHECKBOXES so each tree - // row gets a real native checkbox — UIA exposes a Toggle pattern on - // each TreeItem, screen readers announce the checked state, Space - // toggles natively, and the visual is indistinguishable from File - // Explorer's "items to copy" tree. - native_tree_checkboxes::enable_checkboxes(tree.get_handle()); - - populate_packages_tree( - &tree, - &package_items, - model, - &package_rows.borrow(), - &configuration_rows.borrow(), - ); - sizer.add(&tree, 1, SizerFlag::All | SizerFlag::Expand, 6); - - add_label( - page, - &sizer, - &model.text.package_details_label, - "rabbit-package-details-label", - ); - let initial_details = package_rows - .borrow() - .first() - .map(package_details) - .unwrap_or_default(); - let details = TextCtrl::builder(page) - .with_value(&initial_details) - .with_style(TextCtrlStyle::MultiLine | TextCtrlStyle::ReadOnly | TextCtrlStyle::WordWrap) - .with_size(Size::new(-1, 120)) - .build(); - details.set_name("rabbit-package-details"); - sizer.add(&details, 0, SizerFlag::All | SizerFlag::Expand, 6); - - add_label( - page, - &sizer, - &model.text.packages_osara_keymap_heading, - "rabbit-osara-keymap-heading", - ); - let osara_keymap_replace = CheckBox::builder(page) - .with_label(&model.text.packages_osara_keymap_replace_label) - .build(); - osara_keymap_replace.set_name(&model.text.packages_osara_keymap_replace_label); - osara_keymap_replace.set_label(&model.text.packages_osara_keymap_replace_label); - osara_keymap_replace.add_style(WindowStyle::TabStop); - osara_keymap_replace.set_value(matches!( - WizardInstallOptions::default().osara_keymap_choice, - OsaraKeymapChoice::ReplaceCurrent - )); - osara_keymap_replace.set_can_focus(false); - sizer.add( - &osara_keymap_replace, - 0, - SizerFlag::All | SizerFlag::Expand, - 6, - ); - - let osara_keymap_note = TextCtrl::builder(page) - .with_value(&model.text.packages_osara_keymap_unavailable_note) - .with_style(TextCtrlStyle::MultiLine | TextCtrlStyle::ReadOnly | TextCtrlStyle::WordWrap) - .with_size(Size::new(-1, 68)) - .build(); - osara_keymap_note.set_name("rabbit-osara-keymap-note"); - osara_keymap_note.enable(false); - osara_keymap_note.set_can_focus(false); - sizer.add(&osara_keymap_note, 0, SizerFlag::All | SizerFlag::Expand, 6); - - // Which installed language pack REAPER starts in. Several packs can be - // installed at once (REAPER keeps them all in LangPack/ and you can - // switch inside REAPER later), so this only picks the one active - // straight after installing. - add_label( - page, - &sizer, - &model.text.packages_reaper_language_label, - REAPER_LANGUAGE_LABEL_NAME, - ); - let reaper_language_choice = Choice::builder(page).build(); - reaper_language_choice.set_name(&model.text.packages_reaper_language_label); - sizer.add( - &reaper_language_choice, - 0, - SizerFlag::All | SizerFlag::Expand, - 6, - ); - - // Which of the two Spanish OSARA translations to install. Only the - // installed FILE NAME differs (es_ES vs es_MX) — that is what OSARA - // reads to pick its translation — so this is a plain either/or rather - // than a separate package. - add_label( - page, - &sizer, - &model.text.packages_spanish_variant_label, - SPANISH_VARIANT_LABEL_NAME, - ); - let spanish_variant_choice = Choice::builder(page).build(); - spanish_variant_choice.set_name(&model.text.packages_spanish_variant_label); - for label in &model.text.packages_spanish_variant_options { - spanish_variant_choice.append(label); - } - spanish_variant_choice.set_selection(0); - sizer.add( - &spanish_variant_choice, - 0, - SizerFlag::All | SizerFlag::Expand, - 6, - ); - - sync_osara_keymap_widgets( - model, - &package_rows.borrow(), - &osara_keymap_replace, - &osara_keymap_note, - ); - sync_spanish_variant_widget(&package_rows.borrow(), &spanish_variant_choice); - sync_reaper_language_widget(&package_rows.borrow(), &reaper_language_choice); - - // Selection-change updates the package details text. The event fires - // when the focused row changes via mouse or arrow keys; we use the - // wxTreeItemId from the event to find the matching index in - // `package_items.leaves`. - { - let package_rows = Rc::clone(&package_rows); - let configuration_rows = Rc::clone(&configuration_rows); - let package_items = Rc::clone(&package_items); - let model_text = model.clone(); - let osara_checkbox = osara_keymap_replace; - let osara_note = osara_keymap_note; - let spanish_checkbox = spanish_variant_choice; - let language_choice = reaper_language_choice; - tree.on_selection_changed(move |event| { - if let Some(item) = event.get_item() { - match classify_leaf(&package_items.borrow(), &item) { - Some(WhichLeaf::Packages(idx)) => { - if let Some(value) = package_rows.borrow().get(idx).map(package_details) { - details.set_value(&value); - } - } - Some(WhichLeaf::Configuration(idx)) => { - if let Some(row) = configuration_rows.borrow().get(idx) { - details.set_value(&row.details); - } - } - None => {} - } - } - sync_osara_keymap_widgets( - &model_text, - &package_rows.borrow(), - &osara_checkbox, - &osara_note, - ); - sync_spanish_variant_widget(&package_rows.borrow(), &spanish_checkbox); - sync_reaper_language_widget(&package_rows.borrow(), &language_choice); - }); - } - - // Native checkbox toggle handling for LEAVES: SysTreeView32 fires - // `wxEVT_TREE_STATE_IMAGE_CLICK` whenever the user activates the - // checkbox area of a leaf item — both mouse click and Space go through - // the same notification. The typed `TreeEvents` trait doesn't expose - // this variant, so we bind the raw `EventType::TREE_STATE_IMAGE_CLICK` - // ourselves. - { - let tree_widget = tree; - let package_rows = Rc::clone(&package_rows); - let configuration_rows = Rc::clone(&configuration_rows); - let package_items = Rc::clone(&package_items); - let can_install = Rc::clone(&can_install); - let wizard_model = model.clone(); - let osara_checkbox = osara_keymap_replace; - let osara_note = osara_keymap_note; - let spanish_checkbox = spanish_variant_choice; - let language_choice = reaper_language_choice; - tree.bind_internal(EventType::TREE_STATE_IMAGE_CLICK, move |event| { - handle_native_checkbox_toggle( - &tree_widget, - &package_items, - &package_rows, - &configuration_rows, - &can_install, - &wizard_model, - &details, - &osara_checkbox, - &osara_note, - &spanish_checkbox, - &language_choice, - TreeEventData::new(event).get_item(), - ); - }); - } - - // Pre-empt mouse clicks on not-actionable leaves' state icons — - // unavailable packages, already-applied / unavailable configuration - // steps. SysTreeView32 cycles TVS_CHECKBOXES on WM_LBUTTONDOWN - // (which gives the click immediate visual feedback); without this - // pre-empt, the click flips the state image and the accessibility - // layer announces "checked", and we then have to revert in the - // TREE_STATE_IMAGE_CLICK path — the user sees a flicker plus a - // doubled screen-reader announcement. Eating the event before - // native sees it keeps the row's state stable. We deliberately - // do NOT pre-empt clicks on the parent group's state icon here - // (the LEFT_UP handler below handles those — we still want the - // native side effects of focus/selection to run on a parent - // click). - { - let tree_widget = tree; - let package_rows = Rc::clone(&package_rows); - let configuration_rows = Rc::clone(&configuration_rows); - let package_items = Rc::clone(&package_items); - tree.on_mouse_left_down(move |event| { - let WindowEventData::MouseButton(mb) = &event else { - return; - }; - let Some(pos) = mb.get_position() else { return }; - let hwnd = tree_widget.get_handle(); - let (flags, h_item) = native_tree_checkboxes::hit_test(hwnd, pos.x, pos.y); - if (flags & native_tree_checkboxes::TVHT_ONITEMSTATEICON) == 0 || h_item.is_null() { - return; - } - let items = package_items.borrow(); - let blocked = items - .packages_leaves - .iter() - .position(|leaf| native_tree_handle(leaf) == h_item) - .and_then(|idx| package_rows.borrow().get(idx).cloned()) - .map(|row| !row.available_for_target) - .or_else(|| { - items - .configuration_leaves - .iter() - .position(|leaf| native_tree_handle(leaf) == h_item) - .and_then(|idx| configuration_rows.borrow().get(idx).cloned()) - .map(|row| !row.available_for_target || row.already_applied) - }) - .unwrap_or(false); - drop(items); - if blocked { - event.skip(false); - } - }); - } - - // Parent-group toggle fallback: wxEVT_TREE_STATE_IMAGE_CLICK doesn't - // fire reliably for the parent group (the native control's auto-cycle - // on state image 3 doesn't propagate to wx in our setup), so we hit- - // test on every left-button release and propagate manually if the - // click landed on the group's state icon. Leaves are still handled - // by the TREE_STATE_IMAGE_CLICK binding above; this handler ignores - // them (see the early-return on `is_group` inside). - { - let tree_widget = tree; - let package_rows = Rc::clone(&package_rows); - let configuration_rows = Rc::clone(&configuration_rows); - let package_items = Rc::clone(&package_items); - let can_install = Rc::clone(&can_install); - let wizard_model = model.clone(); - let osara_checkbox = osara_keymap_replace; - let osara_note = osara_keymap_note; - let spanish_checkbox = spanish_variant_choice; - let language_choice = reaper_language_choice; - tree.on_mouse_left_up(move |event| { - if let WindowEventData::MouseButton(mb) = &event - && let Some(pos) = mb.get_position() - { - handle_packages_left_up( - &tree_widget, - &package_items, - &package_rows, - &configuration_rows, - &can_install, - &wizard_model, - &details, - &osara_checkbox, - &osara_note, - &spanish_checkbox, - &language_choice, - pos, - ); - } - event.skip(true); - }); - } - - // Keyboard parent-toggle: Space on the parent group needs to - // propagate just like a mouse click on its checkbox. Native - // TVS_CHECKBOXES auto-cycles state on Space too, but its NM_CLICK - // → wxEVT_TREE_STATE_IMAGE_CLICK dispatch has the same parent-skip - // problem we hit with the mouse, so we intercept Space at key-down - // time, propagate, and consume the event so the native cycle - // doesn't get a chance to leave the parent in some weird half-state. - { - let tree_widget = tree; - let package_rows = Rc::clone(&package_rows); - let configuration_rows = Rc::clone(&configuration_rows); - let package_items = Rc::clone(&package_items); - let can_install = Rc::clone(&can_install); - let wizard_model = model.clone(); - let osara_checkbox = osara_keymap_replace; - let osara_note = osara_keymap_note; - let spanish_checkbox = spanish_variant_choice; - let language_choice = reaper_language_choice; - tree.on_key_down(move |event| { - let key_code = if let WindowEventData::Keyboard(kbd) = &event { - kbd.get_key_code() - } else { - None - }; - if key_code != Some(WXK_SPACE) { - return; - } - let Some(focused) = tree_widget.get_selection() else { - return; - }; - // Pre-empt Space on a leaf the user can't toggle — - // unavailable packages, or already-applied / unavailable - // configuration steps. If we let native TVS_CHECKBOXES - // see the keystroke, it cycles the state image (and the - // accessibility layer announces "checked"), and our key-up - // handler then has to revert. The user experiences that - // as a flicker plus a doubled screen-reader announcement. - // Consuming the event here keeps the row's state stable. - if let Some(leaf) = classify_leaf(&package_items.borrow(), &focused) { - let blocked = match leaf { - WhichLeaf::Packages(idx) => package_rows - .borrow() - .get(idx) - .is_some_and(|row| !row.available_for_target), - WhichLeaf::Configuration(idx) => configuration_rows - .borrow() - .get(idx) - .is_some_and(|row| !row.available_for_target || row.already_applied), - }; - if blocked { - event.skip(false); - return; - } - // Actionable leaf: let native cycle run; on_key_up - // reconciles row.selected with the post-cycle state. - return; - } - let group = classify_group(&package_items.borrow(), &focused); - let Some(group) = group else { - return; - }; - propagate_group_toggle_to_leaves( - &tree_widget, - &package_items, - group, - &package_rows, - &configuration_rows, - &wizard_model, - ); - refresh_after_packages_toggle( - &tree_widget, - &package_items, - &package_rows, - &configuration_rows, - &can_install, - &wizard_model, - &details, - &osara_checkbox, - &osara_note, - &spanish_checkbox, - &language_choice, - ); - // Consume the event so the native control doesn't *also* - // toggle the parent's state image after us. - event.skip(false); - }); - } - // Keyboard leaf-toggle: wxEVT_TREE_STATE_IMAGE_CLICK fires only off - // NM_CLICK (mouse), not for keyboard Space — the native control's - // TVS_CHECKBOXES auto-cycle on Space flips the visual but doesn't - // route through any wx event we can hook. Without this handler, a - // Space toggle on a leaf updates the visual but never updates - // `package_rows`, leaving the row out of sync with the checkbox - // and (as a knock-on) the parent's tristate stuck on whatever it - // was before. We bind KEY_UP because by then the native auto-cycle - // has already run, so reading `get_check_state` gives us the new - // post-cycle state. - { - let tree_widget = tree; - let package_rows = Rc::clone(&package_rows); - let configuration_rows = Rc::clone(&configuration_rows); - let package_items = Rc::clone(&package_items); - let can_install = Rc::clone(&can_install); - let wizard_model = model.clone(); - let osara_checkbox = osara_keymap_replace; - let osara_note = osara_keymap_note; - let spanish_checkbox = spanish_variant_choice; - let language_choice = reaper_language_choice; - tree.on_key_up(move |event| { - let key_code = if let WindowEventData::Keyboard(kbd) = &event { - kbd.get_key_code() - } else { - None - }; - if key_code != Some(WXK_SPACE) { - return; - } - let Some(focused) = tree_widget.get_selection() else { - return; - }; - // Parent Space is already handled in on_key_down (which - // consumed the event before native processing); only - // leaves need post-cycle reconciliation here. - if classify_group(&package_items.borrow(), &focused).is_some() { - return; - } - let leaf = classify_leaf(&package_items.borrow(), &focused); - let new_state = - native_tree_checkboxes::get_check_state(tree_widget.get_handle(), &focused); - match leaf { - Some(WhichLeaf::Packages(idx)) => { - let unavailable = package_rows - .borrow() - .get(idx) - .is_some_and(|row| !row.available_for_target); - if unavailable { - native_tree_checkboxes::set_check_state( - tree_widget.get_handle(), - &focused, - false, + { + let current_step = Arc::clone(¤t_step); + let labels = Arc::clone(&labels); + let model = Arc::clone(&model); + let widgets = wizard_widgets; + let package_rows = Rc::clone(&package_rows); + let package_notes = Rc::clone(&package_notes); + let package_items = Rc::clone(&package_items); + let configuration_rows = Rc::clone(&configuration_rows); + let can_install = Rc::clone(&can_install); + let review_can_install = Rc::clone(&review_can_install); + next.on_click(move |_| { + let step = match current_step.load(Ordering::SeqCst) { + TARGET_STEP => { + let Some(selected_target) = selected_target_row(&model, &widgets) else { + return; + }; + // No offline plan computation here: it would call + // `detect_components` (file-system + registry + // probes for every builtin package), blocking + // the UI thread for ~1–2s before the page + // transition fires — long enough that the + // screen reader and the page-flip both lag + // visibly. The version-check Finished handler + // already calls `wizard_package_plan_for_target_with_available` + // once latest versions are fetched, which does + // the same `detect_components` + `build_install_plan` + // work; doing it twice (offline first, then + // online) was redundant. Reset + // `review_can_install` so the Install button + // can't fire from a stale Review state, and let + // the worker thread do the heavy lifting. + review_can_install.set(false); + // Fix the builds for this run now, so the version + // check and the install can't disagree about them. + let channels = crate::wizard_channels( + expert_mode(), + reaper_build_channel(&widgets), + osara_build_channel(&widgets), ); - return; - } - if let Some(row) = package_rows.borrow_mut().get_mut(idx) { - let _ = apply_checkbox_state_to_package_row(&wizard_model, row, new_state); - } - if let Some(row) = package_rows.borrow().get(idx) { - let label = format_row_label(&row.summary, row.selected); - tree_widget.set_item_text(&focused, &label); + RUN_CHANNELS.with(|cell| *cell.borrow_mut() = channels.clone()); + start_version_check(VersionCheckUi { + channels, + widgets, + model: Arc::clone(&model), + package_rows: Rc::clone(&package_rows), + package_notes: Rc::clone(&package_notes), + configuration_rows: Rc::clone(&configuration_rows), + package_items: Rc::clone(&package_items), + can_install: Rc::clone(&can_install), + review_can_install: Rc::clone(&review_can_install), + target: selected_target, + book, + step_label, + labels: Arc::clone(&labels), + back, + next, + install, + close, + current_step: Arc::clone(¤t_step), + }); + VERSION_CHECK_STEP } - } - Some(WhichLeaf::Configuration(idx)) => { - let not_actionable = configuration_rows - .borrow() - .get(idx) - .is_some_and(|row| !row.available_for_target || row.already_applied); - if not_actionable { - native_tree_checkboxes::set_check_state( - tree_widget.get_handle(), - &focused, - false, + PACKAGES_STEP => { + let selected_target = selected_target_row(&model, &widgets); + let rows = package_rows.borrow(); + let notes = package_notes.borrow(); + let checked = checked_package_indices(&rows); + let review_preview = build_review_preview_for_package_rows( + &model, + selected_target.as_ref(), + &checked, + &rows, + ¬es, + osara_keymap_choice(&widgets.osara_keymap_replace), ); - return; - } - if let Some(row) = configuration_rows.borrow_mut().get_mut(idx) { - row.selected = new_state; + review_can_install.set(review_preview.can_install); + widgets + .review_text + .set_value(&review_preview.lines.join("\n")); + // Route through the ReaPack donation acknowledgement + // page when the user has ReaPack in the install/update + // plan; everyone else goes straight to Review. + if reapack_selected_for_install_or_update(&rows, &checked) { + REAPACK_ACK_STEP + } else { + REVIEW_STEP + } } + REAPACK_ACK_STEP => REVIEW_STEP, + PROGRESS_STEP => DONE_STEP, + other => other, + }; + current_step.store(step, Ordering::SeqCst); + update_navigation( + step, + &book, + &step_label, + labels.as_slice(), + &back, + &next, + &install, + &close, + &widgets.language_footer, + effective_can_install(&can_install, &review_can_install), + target_is_valid(&model, &widgets), + reapack_ack_confirmed(&widgets), + ); + if step == VERSION_CHECK_STEP { + // Pull the screen reader onto the progress bar so the + // user hears that a check is running. Without this, + // focus would stay on the Next button from the Target + // page and the version-check progress wouldn't be + // announced until the auto-advance to Packages fires. + widgets.version_check_gauge.set_focus(); } - None => return, - } - refresh_after_packages_toggle( - &tree_widget, - &package_items, - &package_rows, - &configuration_rows, - &can_install, - &wizard_model, - &details, - &osara_checkbox, - &osara_note, - &spanish_checkbox, - &language_choice, - ); - }); - } - - // Enter / double-click handler — Enter on the parent group needs to - // propagate the toggle to all leaves (the native control fires - // wxEVT_TREE_ITEM_ACTIVATED for Enter, regardless of TVS_CHECKBOXES). - // - // We deliberately ignore the leaf case here: wxMSW also dispatches - // ITEM_ACTIVATED for Space on a focused leaf, and toggling the leaf - // here would race with the TREE_STATE_IMAGE_CLICK leaf path (the - // native auto-cycle has already flipped the state image, our - // STATE_IMAGE_CLICK handler reads + applies the new state, and a - // second flip from this handler would then leave the row out of - // sync with the visual). Space + click on leaves continue to work - // through the existing TREE_STATE_IMAGE_CLICK binding; Enter on a - // leaf is intentionally a no-op. - { - let tree_widget = tree; - let package_rows = Rc::clone(&package_rows); - let configuration_rows = Rc::clone(&configuration_rows); - let package_items = Rc::clone(&package_items); - let can_install = Rc::clone(&can_install); - let wizard_model = model.clone(); - let osara_checkbox = osara_keymap_replace; - let osara_note = osara_keymap_note; - let spanish_checkbox = spanish_variant_choice; - let language_choice = reaper_language_choice; - tree.on_item_activated(move |event| { - let Some(item) = event.get_item() else { - return; - }; - let Some(group) = classify_group(&package_items.borrow(), &item) else { - return; - }; - propagate_group_toggle_to_leaves( - &tree_widget, - &package_items, - group, - &package_rows, - &configuration_rows, - &wizard_model, - ); - refresh_after_packages_toggle( - &tree_widget, - &package_items, - &package_rows, - &configuration_rows, - &can_install, - &wizard_model, - &details, - &osara_checkbox, - &osara_note, - &spanish_checkbox, - &language_choice, - ); - }); - } - - { - let model_text = model.clone(); - let rows = Rc::clone(&package_rows); - let osara_checkbox = osara_keymap_replace; - let osara_note = osara_keymap_note; - let spanish_checkbox = spanish_variant_choice; - let language_choice = reaper_language_choice; - osara_keymap_replace.on_toggled(move |_| { - sync_osara_keymap_widgets(&model_text, &rows.borrow(), &osara_checkbox, &osara_note); - sync_spanish_variant_widget(&rows.borrow(), &spanish_checkbox); - sync_reaper_language_widget(&rows.borrow(), &language_choice); - }); - } - - page.set_sizer(sizer, true); - ( - tree, - details, - osara_keymap_replace, - osara_keymap_note, - spanish_variant_choice, - reaper_language_choice, - ) -} - -/// Windows-only: which top-level group a tree item belongs to, if any. -/// Used by the toggle handlers to dispatch on Packages-vs-Configuration -/// without rebuilding the HTREEITEM-comparison plumbing at each call. -#[cfg(target_os = "windows")] -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum WhichGroup { - Packages, - AdditionalSoftware, - Language, - Configuration, -} - -/// Windows-only: a leaf's owning group + its row index. Mirrors the -/// shape of the row vec the index applies to, so callers can reach into -/// the right `Vec` / `Vec` without an -/// extra branch. -#[cfg(target_os = "windows")] -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum WhichLeaf { - Packages(usize), - Configuration(usize), -} - -/// Windows-only: identify which top-level group node a tree item is. -/// Returns `None` for leaves and for the (hidden) virtual root. Compares -/// via the native `HTREEITEM` (`m_pItem`) because wxdragon's -/// `TreeItemId` wraps a fresh allocation per event call — pointer -/// equality on the Rust wrappers wouldn't match our stored handles. -#[cfg(target_os = "windows")] -fn classify_group(items: &PackageItems, candidate: &TreeItemId) -> Option { - let candidate_handle = native_tree_handle(candidate); - if candidate_handle.is_null() { - return None; - } - if items - .packages_group - .as_ref() - .is_some_and(|group| native_tree_handle(group) == candidate_handle) - { - return Some(WhichGroup::Packages); - } - if items - .additional_software_group - .as_ref() - .is_some_and(|group| native_tree_handle(group) == candidate_handle) - { - return Some(WhichGroup::AdditionalSoftware); - } - if items - .language_group - .as_ref() - .is_some_and(|group| native_tree_handle(group) == candidate_handle) - { - return Some(WhichGroup::Language); - } - if items - .configuration_group - .as_ref() - .is_some_and(|group| native_tree_handle(group) == candidate_handle) - { - return Some(WhichGroup::Configuration); - } - None -} - -/// Windows-only: identify which group's leaves a tree item belongs to, -/// and at which index within that vec. Returns `None` for the group -/// nodes themselves, the hidden root, and items that aren't part of -/// our current row sets. -#[cfg(target_os = "windows")] -fn classify_leaf(items: &PackageItems, candidate: &TreeItemId) -> Option { - let candidate_handle = native_tree_handle(candidate); - if candidate_handle.is_null() { - return None; - } - if let Some(idx) = items - .packages_leaves - .iter() - .position(|stored| native_tree_handle(stored) == candidate_handle) - { - return Some(WhichLeaf::Packages(idx)); - } - if let Some(idx) = items - .configuration_leaves - .iter() - .position(|stored| native_tree_handle(stored) == candidate_handle) - { - return Some(WhichLeaf::Configuration(idx)); - } - None -} - -/// Windows-only: read the native `HTREEITEM` behind a wxdragon -/// `TreeItemId`. SAFETY contract is the same as `native_tree_checkboxes`: -/// `TreeItemId` is a single-field `repr(Rust)` wrapper around -/// `*mut wxd_TreeItemId_t`, and that pointer is a `reinterpret_cast` of -/// `wxTreeItemId*` which holds a single `void* m_pItem` member. -#[cfg(target_os = "windows")] -fn native_tree_handle(item: &TreeItemId) -> *mut std::ffi::c_void { - if !item.is_ok() { - return std::ptr::null_mut(); - } - // Read the wrapper's private `ptr` field by transmuting `&TreeItemId` - // into a borrow of its inner pointer. - let inner: *mut std::ffi::c_void = unsafe { std::mem::transmute_copy(item) }; - if inner.is_null() { - return std::ptr::null_mut(); - } - unsafe { *(inner as *const *mut std::ffi::c_void) } -} - -/// Windows-only: handle a `wxEVT_TREE_STATE_IMAGE_CLICK` for a leaf row -/// in either the Packages or the Configuration group. -/// -/// Parent-group state-icon clicks are intentionally NOT handled here — -/// they're routed through the dedicated `LEFT_UP` + hit-test fallback -/// (`handle_packages_left_up`) because wxMSW's NM_CLICK → -/// wxEVT_TREE_STATE_IMAGE_CLICK dispatch isn't reliable for parent items -/// in our setup (the native auto-cycle sees state image index 3 and may -/// not propagate the event). -/// -/// For leaves: `TVS_CHECKBOXES` has already flipped the state image by -/// the time this event fires, so we read the post-click state from the -/// native control rather than computing it ourselves. Package leaves -/// route through `apply_checkbox_state_to_package_row` (so action labels -/// flip Install/Update/Keep); configuration leaves just toggle -/// `row.selected`. -#[cfg(target_os = "windows")] -#[allow(clippy::too_many_arguments)] -fn handle_native_checkbox_toggle( - tree: &TreeCtrl, - package_items: &PackagesStateCell, - package_rows: &Rc>>, - configuration_rows: &Rc>>, - can_install: &Rc>, - wizard_model: &WizardModel, - details: &TextCtrl, - osara_checkbox: &CheckBox, - osara_note: &TextCtrl, - spanish_checkbox: &Choice, - language_choice: &Choice, - item: Option, -) { - let Some(item) = item else { - return; - }; - - // Parent-group clicks defer to the LEFT_UP handler. - if classify_group(&package_items.borrow(), &item).is_some() { - return; - } - - let leaf = classify_leaf(&package_items.borrow(), &item); - let new_state = native_tree_checkboxes::get_check_state(tree.get_handle(), &item); - match leaf { - Some(WhichLeaf::Packages(idx)) => { - let unavailable = package_rows - .borrow() - .get(idx) - .is_some_and(|row| !row.available_for_target); - if unavailable { - native_tree_checkboxes::set_check_state(tree.get_handle(), &item, false); - return; - } - if let Some(row) = package_rows.borrow_mut().get_mut(idx) { - let _ = apply_checkbox_state_to_package_row(wizard_model, row, new_state); - } - if let Some(row) = package_rows.borrow().get(idx) { - let label = format_row_label(&row.summary, row.selected); - tree.set_item_text(&item, &label); - } - } - Some(WhichLeaf::Configuration(idx)) => { - let not_actionable = configuration_rows - .borrow() - .get(idx) - .is_some_and(|row| !row.available_for_target || row.already_applied); - if not_actionable { - native_tree_checkboxes::set_check_state(tree.get_handle(), &item, false); - return; - } - if let Some(row) = configuration_rows.borrow_mut().get_mut(idx) { - row.selected = new_state; - } - // Configuration row labels don't include action text, so no - // re-format is needed; the row's `summary` already matches - // `display_name`. + }); } - None => return, - } - - refresh_after_packages_toggle( - tree, - package_items, - package_rows, - configuration_rows, - can_install, - wizard_model, - details, - osara_checkbox, - osara_note, - spanish_checkbox, - language_choice, - ); -} -/// Windows-only: hit-test a `wxEVT_LEFT_UP` mouse-up against the tree. -/// If the click landed on either group's state icon, propagate the -/// toggle to that group's available leaves. The native control may -/// have auto-cycled the parent's image to a state that disagrees with -/// the row aggregate, but we always rewrite the parent state via -/// `set_check_state_tri` at the end so the visual matches the data. -/// -/// We deliberately ignore leaf state-icon clicks here — they go through -/// `wxEVT_TREE_STATE_IMAGE_CLICK` which is reliable for leaf items and -/// has the post-cycle state already populated, so duplicating the work -/// here would either double-toggle or fight the leaf path. -#[cfg(target_os = "windows")] -#[allow(clippy::too_many_arguments)] -fn handle_packages_left_up( - tree: &TreeCtrl, - package_items: &PackagesStateCell, - package_rows: &Rc>>, - configuration_rows: &Rc>>, - can_install: &Rc>, - wizard_model: &WizardModel, - details: &TextCtrl, - osara_checkbox: &CheckBox, - osara_note: &TextCtrl, - spanish_checkbox: &Choice, - language_choice: &Choice, - pos: Point, -) { - let hwnd = tree.get_handle(); - let (flags, h_item) = native_tree_checkboxes::hit_test(hwnd, pos.x, pos.y); - if (flags & native_tree_checkboxes::TVHT_ONITEMSTATEICON) == 0 || h_item.is_null() { - return; - } - // Identify which group's state icon was hit by comparing the raw - // HTREEITEM directly against each stored group's native handle. - // We don't go through `classify_group` here because we don't have - // a `TreeItemId` wrapper yet — the hit-test message returns only - // the native handle. - let group = { - let items = package_items.borrow(); - if items - .packages_group - .as_ref() - .is_some_and(|g| native_tree_handle(g) == h_item) - { - Some(WhichGroup::Packages) - } else if items - .additional_software_group - .as_ref() - .is_some_and(|g| native_tree_handle(g) == h_item) - { - Some(WhichGroup::AdditionalSoftware) - } else if items - .language_group - .as_ref() - .is_some_and(|g| native_tree_handle(g) == h_item) - { - Some(WhichGroup::Language) - } else if items - .configuration_group - .as_ref() - .is_some_and(|g| native_tree_handle(g) == h_item) { - Some(WhichGroup::Configuration) - } else { - None - } - }; - let Some(group) = group else { - return; - }; - - propagate_group_toggle_to_leaves( - tree, - package_items, - group, - package_rows, - configuration_rows, - wizard_model, - ); - refresh_after_packages_toggle( - tree, - package_items, - package_rows, - configuration_rows, - can_install, - wizard_model, - details, - osara_checkbox, - osara_note, - spanish_checkbox, - language_choice, - ); -} - -/// Windows-only: refresh both groups' tristate visuals + plan-level -/// `can_install` flag + OSARA widgets + details pane after any toggle -/// (leaf or group) that mutated `package_rows` or -/// `configuration_rows`. Also re-evaluates configuration-row -/// availability against the latest package plan so that, e.g., -/// unchecking ReaPack greys out the REAPER Accessibility row in real -/// time. -#[cfg(target_os = "windows")] -#[allow(clippy::too_many_arguments)] -fn refresh_after_packages_toggle( - tree: &TreeCtrl, - package_items: &PackagesStateCell, - package_rows: &Rc>>, - configuration_rows: &Rc>>, - can_install: &Rc>, - wizard_model: &WizardModel, - details: &TextCtrl, - osara_checkbox: &CheckBox, - osara_note: &TextCtrl, - spanish_checkbox: &Choice, - language_choice: &Choice, -) { - // Configuration rows depend on the package plan (e.g. ReaPack must - // be installed/queued for the REAPER Accessibility step). Re-evaluate - // before refreshing the tree so the leaves' selected/state-image - // values match what's in `configuration_rows`. - if let Ok(localizer) = localizer_from_options(&wizard_model.bootstrap_options) { - // None for the resource-path argument: a package toggle can't - // change `reapack.ini`, so preserve each row's existing - // `already_applied` flag rather than re-reading from disk on - // every click. - recompute_configuration_row_availability( - &localizer, - &package_rows.borrow(), - None, - &mut configuration_rows.borrow_mut(), - ); - // Push the recomputed leaf states into the tree visual so the - // user sees the live re-evaluation. - let items = package_items.borrow(); - let configuration_rows_borrowed = configuration_rows.borrow(); - for (idx, leaf) in items.configuration_leaves.iter().enumerate() { - let Some(row) = configuration_rows_borrowed.get(idx) else { - continue; - }; - native_tree_checkboxes::set_check_state( - tree.get_handle(), - leaf, - row.selected && row.available_for_target && !row.already_applied, - ); - let label = format_row_label(&row.summary, row.selected); - tree.set_item_text(leaf, &label); - } - } - - { - let items = package_items.borrow(); - if let Some(group) = items.packages_group.as_ref() { - let group_state = compute_packages_group_tristate(&package_rows.borrow()); - native_tree_checkboxes::set_check_state_tri(tree.get_handle(), group, group_state); - } - if let Some(group) = items.additional_software_group.as_ref() { - let group_state = compute_additional_software_group_tristate(&package_rows.borrow()); - native_tree_checkboxes::set_check_state_tri(tree.get_handle(), group, group_state); - } - if let Some(group) = items.configuration_group.as_ref() { - let group_state = compute_configuration_group_tristate(&configuration_rows.borrow()); - native_tree_checkboxes::set_check_state_tri(tree.get_handle(), group, group_state); - } - } - - let any_install_or_update = package_rows.borrow().iter().any(|row| { - row.available_for_target - && matches!(row.action, PlanActionKind::Install | PlanActionKind::Update) - }); - can_install.set(any_install_or_update); - - if let Some(selected) = tree.get_selection() { - match classify_leaf(&package_items.borrow(), &selected) { - Some(WhichLeaf::Packages(idx)) => { - if let Some(row) = package_rows.borrow().get(idx) { - details.set_value(&package_details(row)); - } - } - Some(WhichLeaf::Configuration(idx)) => { - if let Some(row) = configuration_rows.borrow().get(idx) { - details.set_value(&row.details); - } - } - None => {} - } - } - - sync_osara_keymap_widgets( - wizard_model, - &package_rows.borrow(), - osara_checkbox, - osara_note, - ); - sync_spanish_variant_widget(&package_rows.borrow(), spanish_checkbox); - sync_reaper_language_widget(&package_rows.borrow(), language_choice); -} - -/// Windows-only: implement the parent-checkbox propagation for the -/// requested group. -/// -/// Convention (matches Windows Explorer / Visual Studio Installer): -/// - clicking a fully-checked parent → uncheck all available children; -/// - clicking an unchecked or mixed parent → check all available children. -/// -/// Package leaves route mutations through `apply_checkbox_state_to_package_row` -/// so action labels flip Install / Update / Keep; configuration leaves -/// just flip `row.selected`. Unavailable rows in either group are left -/// untouched. -#[cfg(target_os = "windows")] -fn propagate_group_toggle_to_leaves( - tree: &TreeCtrl, - package_items: &PackagesStateCell, - target_group: WhichGroup, - package_rows: &Rc>>, - configuration_rows: &Rc>>, - wizard_model: &WizardModel, -) { - match target_group { - WhichGroup::Packages | WhichGroup::AdditionalSoftware | WhichGroup::Language => { - // All three package groups share the same `packages_leaves` vec - // (row-indexed); the category decides which rows this toggle - // owns so the click only flips its own group's children. - let category = match target_group { - WhichGroup::AdditionalSoftware => rabbit_core::package::PackageCategory::Additional, - WhichGroup::Language => rabbit_core::package::PackageCategory::Language, - _ => rabbit_core::package::PackageCategory::Core, - }; - let pre_state = compute_package_category_tristate(&package_rows.borrow(), category); - let target = !matches!(pre_state, native_tree_checkboxes::TriState::Checked); - let leaves: Vec = package_items.borrow().packages_leaves.to_vec(); - { - let mut rows = package_rows.borrow_mut(); - for row in rows - .iter_mut() - .filter(|r| r.category == category && r.available_for_target) - { - let _ = apply_checkbox_state_to_package_row(wizard_model, row, target); - } - } - let rows = package_rows.borrow(); - let hwnd = tree.get_handle(); - for (idx, leaf) in leaves.iter().enumerate() { - let Some(row) = rows.get(idx) else { continue }; - if row.category == category && row.available_for_target { - native_tree_checkboxes::set_check_state(hwnd, leaf, row.selected); - let label = format_row_label(&row.summary, row.selected); - tree.set_item_text(leaf, &label); - } - } - } - WhichGroup::Configuration => { - let pre_state = compute_configuration_group_tristate(&configuration_rows.borrow()); - let target = !matches!(pre_state, native_tree_checkboxes::TriState::Checked); - let leaves: Vec = package_items.borrow().configuration_leaves.to_vec(); - { - let mut rows = configuration_rows.borrow_mut(); - for row in rows - .iter_mut() - .filter(|r| r.available_for_target && !r.already_applied) - { - row.selected = target; - } - } - let rows = configuration_rows.borrow(); - let hwnd = tree.get_handle(); - for (idx, leaf) in leaves.iter().enumerate() { - let Some(row) = rows.get(idx) else { continue }; - if row.available_for_target && !row.already_applied { - native_tree_checkboxes::set_check_state(hwnd, leaf, row.selected); - let label = format_row_label(&row.summary, row.selected); - tree.set_item_text(leaf, &label); - } - } - } - } -} - -// =========================================================================== -// Non-Windows: wxDataViewCtrl + CustomDataViewTreeModel. -// -// Windows is special-cased via `TVS_CHECKBOXES`; on macOS and GTK the -// equivalent native pattern is "outline view with a check column" — i.e. -// wxDataView with `DataViewToggleRenderer` over `VariantType::Bool`. The -// model carries one synthetic Group node + one leaf per `PackageRow`, the -// toggle column gets `Activatable` mode so Space + click both route through -// `set_value`, and `is_enabled` returns false for unavailable rows so the -// platform draws (and exposes) them as disabled. -// =========================================================================== - -/// Non-Windows: build the Packages page using a wxDataViewCtrl driven by a -/// `CustomDataViewTreeModel`. The model exposes a synthetic Packages group -/// + one leaf per `PackageRow`; column 0 is a Bool toggle, column 1 is the -/// row label (the column with the expander triangle). The model's -/// `set_value` callback owns all the toggle side effects. -#[cfg(not(target_os = "windows"))] -fn build_packages_page( - page: &WizardPage, - model: &WizardModel, - package_rows: Rc>>, - configuration_rows: Rc>>, - package_items: PackagesStateCell, - can_install: Rc>, -) -> (PackagesView, TextCtrl, CheckBox, TextCtrl, Choice, Choice) { - let sizer = BoxSizer::builder(Orientation::Vertical).build(); - add_heading( - page, - &sizer, - &model.text.packages_heading, - "rabbit-packages-heading", - ); - add_label( - page, - &sizer, - &model.text.packages_list_label, - "rabbit-packages-list-label", - ); - - let tree = DataViewCtrl::builder(page) - .with_style(DataViewStyle::Single | DataViewStyle::RowLines | DataViewStyle::NoHeader) - .with_size(Size::new(-1, 220)) - .build(); - tree.set_name("rabbit-package-list"); - // Floor the list height so longer translations (the labels, checkbox, and - // notes below grow vertically in German/French) can never squeeze the - // proportion-1 list down to nothing. It still expands to fill free space. - tree.set_min_size(Size::new(-1, 160)); - - // The model is constructed BEFORE associate_model so wx's internal - // refcount stays sane. `package_items` (the model handle cell) gets - // populated immediately afterwards so set_value's notification path - // can find the model the next time the user toggles a row. - let tree_data = PackageTreeData::new( - Rc::clone(&package_rows), - Rc::clone(&configuration_rows), - model.text.packages_tree_group_label.clone(), - model.text.additional_software_tree_group_label.clone(), - model.text.language_tree_group_label.clone(), - model.text.configuration_tree_group_label.clone(), - ); - let side_widgets: PackagesSideWidgetsCell = Rc::new(RefCell::new(None)); - let dv_model = build_packages_tree_model( - tree_data, - Rc::clone(&package_rows), - Rc::clone(&configuration_rows), - Rc::clone(&package_items), - Rc::clone(&side_widgets), - Rc::clone(&can_install), - model.clone(), - ); - *package_items.borrow_mut() = Some(dv_model.clone()); - - let toggle_renderer = DataViewToggleRenderer::new( - VariantType::Bool, - DataViewCellMode::Activatable, - DataViewAlign::Center, - ); - let toggle_column = DataViewColumn::new( - "", - &toggle_renderer, - PACKAGE_COL_TOGGLE as usize, - 28, - DataViewAlign::Center, - DataViewColumnFlags::DefaultNone, - ); - tree.append_column(&toggle_column); - - let text_renderer = DataViewTextRenderer::new( - VariantType::String, - DataViewCellMode::Inert, - DataViewAlign::Left, - ); - let text_column = DataViewColumn::new( - "", - &text_renderer, - PACKAGE_COL_LABEL as usize, - -1, - DataViewAlign::Left, - DataViewColumnFlags::Resizable, - ); - tree.append_column(&text_column); - - tree.associate_model(&dv_model); - - expand_packages_group(&tree, &dv_model); - sizer.add(&tree, 1, SizerFlag::All | SizerFlag::Expand, 6); - - add_label( - page, - &sizer, - &model.text.package_details_label, - "rabbit-package-details-label", - ); - let initial_details = package_rows - .borrow() - .first() - .map(package_details) - .unwrap_or_default(); - let details = TextCtrl::builder(page) - .with_value(&initial_details) - .with_style(TextCtrlStyle::MultiLine | TextCtrlStyle::ReadOnly | TextCtrlStyle::WordWrap) - .with_size(Size::new(-1, 120)) - .build(); - details.set_name("rabbit-package-details"); - sizer.add(&details, 0, SizerFlag::All | SizerFlag::Expand, 6); - - add_label( - page, - &sizer, - &model.text.packages_osara_keymap_heading, - "rabbit-osara-keymap-heading", - ); - let osara_keymap_replace = CheckBox::builder(page) - .with_label(&model.text.packages_osara_keymap_replace_label) - .build(); - osara_keymap_replace.set_name(&model.text.packages_osara_keymap_replace_label); - osara_keymap_replace.set_label(&model.text.packages_osara_keymap_replace_label); - osara_keymap_replace.add_style(WindowStyle::TabStop); - osara_keymap_replace.set_value(matches!( - WizardInstallOptions::default().osara_keymap_choice, - OsaraKeymapChoice::ReplaceCurrent - )); - osara_keymap_replace.set_can_focus(false); - sizer.add( - &osara_keymap_replace, - 0, - SizerFlag::All | SizerFlag::Expand, - 6, - ); - - let osara_keymap_note = TextCtrl::builder(page) - .with_value(&model.text.packages_osara_keymap_unavailable_note) - .with_style(TextCtrlStyle::MultiLine | TextCtrlStyle::ReadOnly | TextCtrlStyle::WordWrap) - .with_size(Size::new(-1, 68)) - .build(); - osara_keymap_note.set_name("rabbit-osara-keymap-note"); - osara_keymap_note.enable(false); - osara_keymap_note.set_can_focus(false); - sizer.add(&osara_keymap_note, 0, SizerFlag::All | SizerFlag::Expand, 6); - - // Which installed language pack REAPER starts in. Several packs can be - // installed at once (REAPER keeps them all in LangPack/ and you can - // switch inside REAPER later), so this only picks the one active - // straight after installing. - add_label( - page, - &sizer, - &model.text.packages_reaper_language_label, - REAPER_LANGUAGE_LABEL_NAME, - ); - let reaper_language_choice = Choice::builder(page).build(); - reaper_language_choice.set_name(&model.text.packages_reaper_language_label); - sizer.add( - &reaper_language_choice, - 0, - SizerFlag::All | SizerFlag::Expand, - 6, - ); - - // Which of the two Spanish OSARA translations to install. Only the - // installed FILE NAME differs (es_ES vs es_MX) — that is what OSARA - // reads to pick its translation — so this is a plain either/or rather - // than a separate package. - add_label( - page, - &sizer, - &model.text.packages_spanish_variant_label, - SPANISH_VARIANT_LABEL_NAME, - ); - let spanish_variant_choice = Choice::builder(page).build(); - spanish_variant_choice.set_name(&model.text.packages_spanish_variant_label); - for label in &model.text.packages_spanish_variant_options { - spanish_variant_choice.append(label); - } - spanish_variant_choice.set_selection(0); - sizer.add( - &spanish_variant_choice, - 0, - SizerFlag::All | SizerFlag::Expand, - 6, - ); - - sync_osara_keymap_widgets( - model, - &package_rows.borrow(), - &osara_keymap_replace, - &osara_keymap_note, - ); - sync_spanish_variant_widget(&package_rows.borrow(), &spanish_variant_choice); - sync_reaper_language_widget(&package_rows.borrow(), &reaper_language_choice); - - // Hand the tree model its handles now that the widgets exist. All four - // types are `Copy`, so the moves into the event closures below still - // work. This runs before `page.set_sizer`, i.e. before the user can - // interact with anything. - *side_widgets.borrow_mut() = Some(PackagesSideWidgets { - osara_checkbox: osara_keymap_replace, - osara_note: osara_keymap_note, - spanish_choice: spanish_variant_choice, - language_choice: reaper_language_choice, - }); - - { - let package_rows = Rc::clone(&package_rows); - let model_text = model.clone(); - let osara_checkbox = osara_keymap_replace; - let osara_note = osara_keymap_note; - let spanish_checkbox = spanish_variant_choice; - let language_choice = reaper_language_choice; - tree.on_selection_changed(move |event| { - if let Some(item) = event.get_item() { - if let Some(node_ptr) = item.get_id::() { - if !node_ptr.is_null() { - // SAFETY: node_ptr originated from a Box - // owned by the model's userdata; the model lives - // for as long as this closure can fire. - let node = unsafe { &*node_ptr }; - if let NodeKind::Package(idx) = node.kind { - if let Some(value) = package_rows.borrow().get(idx).map(package_details) - { - details.set_value(&value); - } - } - } - } - } - sync_osara_keymap_widgets( - &model_text, - &package_rows.borrow(), - &osara_checkbox, - &osara_note, - ); - sync_spanish_variant_widget(&package_rows.borrow(), &spanish_checkbox); - sync_reaper_language_widget(&package_rows.borrow(), &language_choice); - }); - } - - { - let model_text = model.clone(); - let rows = Rc::clone(&package_rows); - let osara_checkbox = osara_keymap_replace; - let osara_note = osara_keymap_note; - let spanish_checkbox = spanish_variant_choice; - let language_choice = reaper_language_choice; - osara_keymap_replace.on_toggled(move |_| { - sync_osara_keymap_widgets(&model_text, &rows.borrow(), &osara_checkbox, &osara_note); - sync_spanish_variant_widget(&rows.borrow(), &spanish_checkbox); - sync_reaper_language_widget(&rows.borrow(), &language_choice); - }); - } - - page.set_sizer(sizer, true); - ( - tree, - details, - osara_keymap_replace, - osara_keymap_note, - spanish_variant_choice, - reaper_language_choice, - ) -} - -/// Non-Windows: the widgets that sit *below* the packages tree and whose -/// state depends on which packages are ticked. -/// -/// They cannot be captured by the model's `set_value` closure, because -/// creation order is tab order: the tree has to be created before them, and -/// the model is built with the tree. So they arrive afterwards through a -/// cell, exactly like the self-referential model handle in -/// `PackagesStateCell`. -#[cfg(not(target_os = "windows"))] -#[derive(Clone, Copy)] -struct PackagesSideWidgets { - osara_checkbox: CheckBox, - osara_note: TextCtrl, - spanish_choice: Choice, - language_choice: Choice, -} - -#[cfg(not(target_os = "windows"))] -type PackagesSideWidgetsCell = Rc>>; - -/// Non-Windows: build the `CustomDataViewTreeModel` that backs the packages -/// tree. The closures capture clones of `package_rows`, `package_items` -/// (the self-referential model handle cell), `can_install`, and the wizard -/// model, so `set_value` can mutate row state, fire item-changed -/// notifications and recompute downstream UI flags without going through -/// any external lookup. -#[cfg(not(target_os = "windows"))] -fn build_packages_tree_model( - data: PackageTreeData, - rows: Rc>>, - configuration_rows: Rc>>, - model_cell: PackagesStateCell, - side_widgets: PackagesSideWidgetsCell, - can_install: Rc>, - wizard_model: WizardModel, -) -> CustomDataViewTreeModel { - type CompareFn = fn(&PackageTreeData, &Node, &Node, u32, bool) -> i32; - - let rows_for_get_value = Rc::clone(&rows); - let rows_for_set_value = Rc::clone(&rows); - let rows_for_is_enabled = Rc::clone(&rows); - let configuration_rows_for_get_value = Rc::clone(&configuration_rows); - let configuration_rows_for_set_value = Rc::clone(&configuration_rows); - let configuration_rows_for_is_enabled = Rc::clone(&configuration_rows); - let configuration_rows_for_recompute = Rc::clone(&configuration_rows); - let wizard_model_for_recompute = wizard_model.clone(); - let model_cell_for_set_value = Rc::clone(&model_cell); - let side_widgets_for_set_value = Rc::clone(&side_widgets); + let current_step = Arc::clone(¤t_step); + let labels = Arc::clone(&labels); + let model = Arc::clone(&model); + let widgets = wizard_widgets; + let package_rows = Rc::clone(&package_rows); + let package_notes = Rc::clone(&package_notes); + let package_items = Rc::clone(&package_items); + let configuration_rows = Rc::clone(&configuration_rows); + let can_install = Rc::clone(&can_install); + let review_can_install = Rc::clone(&review_can_install); + let last_report = Arc::clone(&last_report); + let last_reaper_app_path = Arc::clone(&last_reaper_app_path); + let last_resource_path = Arc::clone(&last_resource_path); + let install_run = Arc::clone(&install_run); + install.on_click(move |_| { + current_step.store(PROGRESS_STEP, Ordering::SeqCst); + update_navigation( + PROGRESS_STEP, + &book, + &step_label, + labels.as_slice(), + &back, + &next, + &install, + &close, + &widgets.language_footer, + effective_can_install(&can_install, &review_can_install), + target_is_valid(&model, &widgets), + reapack_ack_confirmed(&widgets), + ); + back.enable(false); + next.enable(false); + install.enable(false); + widgets.done_launch_reaper.enable(false); + widgets.done_open_resource.enable(false); + widgets + .progress_status + .set_label(&model.text.progress_status_running); + widgets.progress_gauge.set_value(10); + set_last_report(&last_report, None); - CustomDataViewTreeModel::new( - data, - // get_parent - |data: &PackageTreeData, item: Option<&Node>| -> Option<*mut Node> { - match item { - None => None, - Some(node) => match node.kind { - NodeKind::PackagesGroup - | NodeKind::AdditionalSoftwareGroup - | NodeKind::LanguageGroup - | NodeKind::ConfigurationGroup => None, - NodeKind::Package(idx) => { - let category = data - .rows - .borrow() - .get(idx) - .map(|r| r.category) - .unwrap_or_default(); - match category { - rabbit_core::package::PackageCategory::Additional => { - Some(data.additional_software_group_ptr() as *mut Node) - } - rabbit_core::package::PackageCategory::Language => { - Some(data.language_group_ptr() as *mut Node) - } - rabbit_core::package::PackageCategory::Core => { - Some(data.packages_group_ptr() as *mut Node) - } - } - } - NodeKind::Configuration(_) => Some(data.configuration_group_ptr() as *mut Node), - }, - } - }, - // is_container - |_data: &PackageTreeData, item: Option<&Node>| -> bool { - match item { - None => true, - Some(node) => matches!( - node.kind, - NodeKind::PackagesGroup - | NodeKind::AdditionalSoftwareGroup - | NodeKind::LanguageGroup - | NodeKind::ConfigurationGroup - ), - } - }, - // get_children - |data: &PackageTreeData, item: Option<&Node>| -> Vec<*mut Node> { - match item { - None => { - // Root children: Packages, then Additional software (only - // when populated, so it never shows empty), then - // Configuration. - let mut roots = vec![data.packages_group_ptr() as *mut Node]; - if data.has_additional_software() { - roots.push(data.additional_software_group_ptr() as *mut Node); - } - if data.has_language_packs() { - roots.push(data.language_group_ptr() as *mut Node); - } - roots.push(data.configuration_group_ptr() as *mut Node); - roots - } - Some(node) => match node.kind { - NodeKind::PackagesGroup => data - .package_ptrs_in_category(rabbit_core::package::PackageCategory::Core) - .into_iter() - .map(|p| p as *mut Node) - .collect(), - NodeKind::AdditionalSoftwareGroup => data - .package_ptrs_in_category(rabbit_core::package::PackageCategory::Additional) - .into_iter() - .map(|p| p as *mut Node) - .collect(), - NodeKind::LanguageGroup => data - .package_ptrs_in_category(rabbit_core::package::PackageCategory::Language) - .into_iter() - .map(|p| p as *mut Node) - .collect(), - NodeKind::ConfigurationGroup => data - .all_configuration_ptrs() - .into_iter() - .map(|p| p as *mut Node) - .collect(), - NodeKind::Package(_) | NodeKind::Configuration(_) => Vec::new(), - }, - } - }, - // get_value - move |data: &PackageTreeData, item: Option<&Node>, col: u32| -> Variant { - let Some(node) = item else { - return Variant::from_string(""); - }; - match node.kind { - NodeKind::PackagesGroup => { - if col == PACKAGE_COL_TOGGLE { - // Aggregate state: true only if every available row - // in this group's category is selected. The standard - // toggle renderer can't show a tristate, so a - // partially-selected group reads as unchecked. - let rows = rows_for_get_value.borrow(); - let mut any_available = false; - let all_checked = rows - .iter() - .filter(|r| { - r.category == rabbit_core::package::PackageCategory::Core - && r.available_for_target - }) - .inspect(|_| any_available = true) - .all(|r| r.selected); - Variant::from_bool(any_available && all_checked) - } else { - Variant::from_string(&data.packages_group_label) - } - } - NodeKind::AdditionalSoftwareGroup => { - if col == PACKAGE_COL_TOGGLE { - let rows = rows_for_get_value.borrow(); - let mut any_available = false; - let all_checked = rows - .iter() - .filter(|r| { - r.category == rabbit_core::package::PackageCategory::Additional - && r.available_for_target - }) - .inspect(|_| any_available = true) - .all(|r| r.selected); - Variant::from_bool(any_available && all_checked) - } else { - Variant::from_string(&data.additional_software_group_label) - } - } - NodeKind::LanguageGroup => { - if col == PACKAGE_COL_TOGGLE { - let rows = rows_for_get_value.borrow(); - let mut any_available = false; - let all_checked = rows - .iter() - .filter(|r| { - r.category == rabbit_core::package::PackageCategory::Language - && r.available_for_target - }) - .inspect(|_| any_available = true) - .all(|r| r.selected); - Variant::from_bool(any_available && all_checked) - } else { - Variant::from_string(&data.language_group_label) - } - } - NodeKind::Package(idx) => { - let rows = rows_for_get_value.borrow(); - let Some(row) = rows.get(idx) else { - return Variant::from_string(""); - }; - if col == PACKAGE_COL_TOGGLE { - Variant::from_bool(row.selected) - } else { - Variant::from_string(&row.summary) - } - } - NodeKind::ConfigurationGroup => { - if col == PACKAGE_COL_TOGGLE { - let cfg_rows = configuration_rows_for_get_value.borrow(); - let mut any_available = false; - let all_checked = cfg_rows - .iter() - // already-applied rows are excluded, exactly as - // compute_configuration_group_tristate does on - // Windows: they are forced unselected and would - // otherwise pin the group to "unchecked" forever. - .filter(|r| r.available_for_target && !r.already_applied) - .inspect(|_| any_available = true) - .all(|r| r.selected); - Variant::from_bool(any_available && all_checked) - } else { - Variant::from_string(&data.configuration_group_label) - } - } - NodeKind::Configuration(idx) => { - let cfg_rows = configuration_rows_for_get_value.borrow(); - let Some(row) = cfg_rows.get(idx) else { - return Variant::from_string(""); - }; - if col == PACKAGE_COL_TOGGLE { - Variant::from_bool(row.selected) - } else { - Variant::from_string(&row.summary) + let selected_target = selected_target_row(&model, &widgets); + set_last_path( + &last_reaper_app_path, + selected_target + .as_ref() + .map(planned_reaper_launch_path_for_target), + ); + set_last_resource_path( + &last_resource_path, + selected_target.as_ref().map(|target| target.path.clone()), + ); + let rows = package_rows.borrow(); + let selected_packages = checked_package_indices(&rows); + widgets + .progress_details + .set_value(&progress_details_for_start( + &model, + selected_target.as_ref(), + &selected_packages, + &rows, + osara_keymap_choice(&widgets.osara_keymap_replace), + None, + )); + let request = match selected_target + .as_ref() + .ok_or_else(|| rabbit_core::RabbitError::PreflightFailed { + message: model.text.review_no_target.clone(), + }) + .and_then(|target| { + let configuration_step_ids = + selected_configuration_step_ids(&configuration_rows.borrow()); + install_request_from_target_and_rows( + &model, + target, + &rows, + &selected_packages, + configuration_step_ids, + WizardInstallOptions { + osara_keymap_choice: osara_keymap_choice( + &widgets.osara_keymap_replace, + ), + // Ticked = Team PMA's Spanish OSARA + // translation (installs as es_MX); unticked + // = the default REAPER Accesible español + // (es_ES). + package_variants: crate::package_variants_from_choice( + widgets.spanish_variant_choice.get_selection(), + ), + // Which of the installed language packs REAPER + // starts in. The dropdown lists the ticked + // packs in row order, so the selection index + // maps straight back to a package id. + reaper_language_package: crate::selected_language_packs( + &rows, + &selected_packages, + ) + .get(widgets.reaper_language_choice.get_selection().unwrap_or(0) + as usize) + .map(|(id, _)| id.clone()), + package_channels: RUN_CHANNELS.with(|cell| cell.borrow().clone()), + ..WizardInstallOptions::default() + }, + ) + }) { + Ok(request) => request, + Err(error) => { + widgets.progress_gauge.set_value(100); + widgets + .progress_status + .set_label(&model.text.done_status_error); + // Done page: short reason on the always-visible + // status TextCtrl; full error text in the + // collapsible details below. + widgets.done_status.set_value(&model.text.done_status_error); + widgets.done_details.set_value(&error.to_string()); + widgets + .progress_details + .set_value(&format!("{}\n\n{}", model.text.done_status_error, error)); + widgets + .done_open_resource + .enable(clone_last_resource_path(&last_resource_path).is_some()); + widgets + .done_launch_reaper + .enable(can_launch_last_reaper_path(&last_reaper_app_path)); + current_step.store(DONE_STEP, Ordering::SeqCst); + update_navigation( + DONE_STEP, + &book, + &step_label, + labels.as_slice(), + &back, + &next, + &install, + &close, + &widgets.language_footer, + effective_can_install(&can_install, &review_can_install), + target_is_valid(&model, &widgets), + reapack_ack_confirmed(&widgets), + ); + // Focus the always-visible status TextCtrl so the + // screen reader reads the success/failure summary + // immediately, and so Tab from there moves on to + // the Show-details CheckBox / action buttons + // instead of cycling back through earlier widgets. + widgets.done_status.set_focus(); + return; } - } - } - }, - // set_value - Some( - move |data: &PackageTreeData, item: Option<&Node>, col: u32, var: &Variant| -> bool { - if col != PACKAGE_COL_TOGGLE { - return false; - } - let Some(node) = item else { - return false; }; - let new_state = var.get_bool().unwrap_or(false); + widgets + .progress_details + .set_value(&progress_details_for_start( + &model, + selected_target.as_ref(), + &selected_packages, + &rows, + osara_keymap_choice(&widgets.osara_keymap_replace), + Some(&request.cache_dir), + )); + drop(rows); - match node.kind { - NodeKind::PackagesGroup - | NodeKind::AdditionalSoftwareGroup - | NodeKind::LanguageGroup => { - // Group toggle propagates to every available leaf in - // this group's category; unavailable rows stay - // untouched so the install plan never carries - // something we can't honor. - let category = match node.kind { - NodeKind::AdditionalSoftwareGroup => { - rabbit_core::package::PackageCategory::Additional - } - NodeKind::LanguageGroup => { - rabbit_core::package::PackageCategory::Language - } - _ => rabbit_core::package::PackageCategory::Core, + // Arm the post-install rescan hook. The hook captures the + // UI-thread `Rc` shared state so the call_after + // success arm can refresh it without smuggling non-Send + // references across threads. The hook closure runs on the + // UI thread; it re-detects the selected target, runs the + // offline package plan against the now-fresh receipts, and + // updates both the cached state and the on-screen package + // list — so navigating Back from the Done page (or + // re-opening the Packages step via Rescan) shows the + // post-install version without the user having to click + // anything. + { + let model = Arc::clone(&model); + let package_rows = Rc::clone(&package_rows); + let package_notes = Rc::clone(&package_notes); + let package_items = Rc::clone(&package_items); + let configuration_rows = Rc::clone(&configuration_rows); + let can_install = Rc::clone(&can_install); + let review_can_install = Rc::clone(&review_can_install); + let last_reaper_app_path = Arc::clone(&last_reaper_app_path); + let last_resource_path = Arc::clone(&last_resource_path); + arm_post_install_hook(move || { + let Some(target) = selected_target_row(&model, &widgets) else { + return; }; - let mut rows = rows_for_set_value.borrow_mut(); - for row in rows.iter_mut() { - if row.category == category && row.available_for_target { - let _ = apply_checkbox_state_to_package_row( - &wizard_model, - row, - new_state, - ); - } - } - } - NodeKind::Package(idx) => { - let mut rows = rows_for_set_value.borrow_mut(); - let Some(row) = rows.get_mut(idx) else { - return false; + let refreshed_target = refreshed_target_row(&model, &target); + // Plan against the versions this run installed from, + // so a development build just installed reads as + // current instead of as something to replace. + let plan = match RUN_AVAILABLE.with(|cell| cell.borrow().clone()) { + Some(available) => wizard_package_plan_for_target_with_available( + &model, + Some(&refreshed_target), + &available, + ), + None => wizard_package_plan_for_target(&model, Some(&refreshed_target)), }; - if !row.available_for_target { - return false; - } - let _ = apply_checkbox_state_to_package_row(&wizard_model, row, new_state); - } - NodeKind::ConfigurationGroup => { - let mut cfg_rows = configuration_rows_for_set_value.borrow_mut(); - for row in cfg_rows.iter_mut() { - if row.available_for_target && !row.already_applied { - row.selected = new_state; - } - } - } - NodeKind::Configuration(idx) => { - let mut cfg_rows = configuration_rows_for_set_value.borrow_mut(); - let Some(row) = cfg_rows.get_mut(idx) else { - return false; + let Ok(plan) = plan else { + return; }; - if !row.available_for_target || row.already_applied { - return false; + *package_rows.borrow_mut() = plan.package_rows; + *package_notes.borrow_mut() = plan.notes; + // Recompute configuration row availability against + // the freshly-rebuilt package plan (e.g. ReaPack + // just got installed → REAPER Accessibility step + // becomes available). + if let Ok(localizer) = localizer_from_options(&model.bootstrap_options) { + recompute_configuration_row_availability( + &localizer, + &package_rows.borrow(), + Some(&refreshed_target.path), + &mut configuration_rows.borrow_mut(), + ); } - row.selected = new_state; - } + can_install.set(plan.can_install); + review_can_install.set(false); + refresh_package_checklist( + &widgets.package_checklist, + &package_items, + &widgets.package_details, + &widgets.osara_keymap_replace, + &widgets.osara_keymap_note, + &widgets.spanish_variant_choice, + &widgets.reaper_language_choice, + &model, + &package_rows.borrow(), + &configuration_rows.borrow(), + ); + refresh_target_choice( + &model, + &widgets.target_choice, + refreshed_target_index(&model, &widgets), + &refreshed_target, + ); + widgets.target_details.set_value(&refreshed_target.details); + set_last_path( + &last_reaper_app_path, + Some(planned_reaper_launch_path_for_target(&refreshed_target)), + ); + set_last_resource_path( + &last_resource_path, + Some(refreshed_target.path.clone()), + ); + }); } - let any_install_or_update = rows_for_set_value.borrow().iter().any(|row| { - row.available_for_target - && matches!(row.action, PlanActionKind::Install | PlanActionKind::Update) - }); - can_install.set(any_install_or_update); + let ui_model = Arc::clone(&model); + let ui_current_step = Arc::clone(¤t_step); + let ui_labels = Arc::clone(&labels); + let ui_last_report = Arc::clone(&last_report); + let ui_last_reaper_app_path = Arc::clone(&last_reaper_app_path); + let ui_last_resource_path = Arc::clone(&last_resource_path); + let can_install = effective_can_install(&can_install, &review_can_install); + let request_for_report = request.clone(); - // Recompute configuration row availability whenever a - // package toggle could have flipped a dependency state. - let recomputed_configuration = matches!( - node.kind, - NodeKind::PackagesGroup - | NodeKind::AdditionalSoftwareGroup - | NodeKind::LanguageGroup - | NodeKind::Package(_) + // Build progress lookup maps + the per-install UI state now, + // on the UI thread, where the Rc-based package_rows / + // configuration_rows are still in scope. The maps are + // Send+Sync (Arc>) so they ride along + // with the worker thread's progress callback into each + // call_after closure that runs back on the UI thread. + let configuration_rows_for_progress = configuration_rows.borrow(); + let rows_for_progress = package_rows.borrow(); + let package_display_names: Arc> = Arc::new( + request + .package_ids + .iter() + .filter_map(|package_id| { + rows_for_progress + .iter() + .find(|row| &row.package_id == package_id) + .map(|row| (row.package_id.clone(), row.display_name.clone())) + }) + .collect(), ); - if recomputed_configuration { - if let Ok(localizer) = - crate::localizer_from_options(&wizard_model_for_recompute.bootstrap_options) - { - let package_rows_snapshot = rows_for_set_value.borrow(); - let mut cfg_rows = configuration_rows_for_recompute.borrow_mut(); - // None for the resource-path argument: a package - // toggle can't change `reapack.ini`, so preserve - // each row's existing `already_applied` flag. - crate::recompute_configuration_row_availability( - &localizer, - &package_rows_snapshot, - None, - &mut cfg_rows, - ); - } + let configuration_display_names: Arc> = Arc::new( + request + .configuration_step_ids + .iter() + .filter_map(|step_id| { + configuration_rows_for_progress + .iter() + .find(|row| &row.step_id == step_id) + .map(|row| (row.step_id.clone(), row.display_name.clone())) + }) + .collect(), + ); + drop(configuration_rows_for_progress); + drop(rows_for_progress); - // Same tail as the Windows `refresh_after_packages_toggle`: - // ticking a package can change which OSARA keymap note - // applies, whether the Spanish variant picker is usable, - // and — because it owns the dropdown's *contents*, not - // just its enabled state — what the REAPER-language - // dropdown offers. Without this, toggling a row that is - // already selected (Space, or clicking its checkbox) - // leaves all three stale, because `on_selection_changed` - // never fires. - if let Some(widgets) = *side_widgets_for_set_value.borrow() { - let rows = rows_for_set_value.borrow(); - sync_osara_keymap_widgets( - &wizard_model_for_recompute, - &rows, - &widgets.osara_checkbox, - &widgets.osara_note, + let progress_state = Arc::new(Mutex::new(ProgressUiState::new( + request.package_ids.len(), + request.configuration_step_ids.len(), + ))); + let progress_widgets = widgets; + let progress_state_for_reporter = Arc::clone(&progress_state); + let package_display_names_for_reporter = Arc::clone(&package_display_names); + let configuration_display_names_for_reporter = + Arc::clone(&configuration_display_names); + // Once the operation returned, background download workers + // may still emit a few final events before they notice the + // cancel flag — gate them out so nothing repaints the gauge + // or status after the completion closure wrote the outcome. + let operation_finished = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let operation_finished_for_reporter = Arc::clone(&operation_finished); + let install_run_for_reporter = Arc::clone(&install_run); + let progress = ProgressReporter::new(move |event| { + // The reporter fires on the worker thread; forward each + // event to the UI thread so the gauge / status / log can + // be touched safely. call_after serialises closures on + // the UI thread, so ProgressUiState mutations happen one + // event at a time despite the Arc> wrapper. + if operation_finished_for_reporter.load(std::sync::atomic::Ordering::Relaxed) { + return; + } + let state = Arc::clone(&progress_state_for_reporter); + let package_display_names = Arc::clone(&package_display_names_for_reporter); + let configuration_display_names = + Arc::clone(&configuration_display_names_for_reporter); + let widgets = progress_widgets; + let status_frozen = install_run_for_reporter.stop_requested(); + wxdragon::call_after(Box::new(move || { + apply_progress_event_to_ui( + &state, + &widgets, + &package_display_names, + &configuration_display_names, + event, + status_frozen, ); - sync_spanish_variant_widget(&rows, &widgets.spanish_choice); - sync_reaper_language_widget(&rows, &widgets.language_choice); - } - } + })); + }); - // Push the cell changes back into the view. SetValue's - // true return only auto-refreshes the (item, col) we set; - // we also need to refresh the row's label cell (the action - // text flips Install/Update/Keep) and the parent group's - // aggregate cell. - if let Some(model) = model_cell_for_set_value.borrow().as_ref() { - match node.kind { - NodeKind::PackagesGroup => { - let parent_ptr = data.packages_group_ptr(); - let leaf_ptrs = data.package_ptrs_in_category( - rabbit_core::package::PackageCategory::Core, - ); - model.items_changed(&leaf_ptrs); - model.item_value_changed(parent_ptr, PACKAGE_COL_TOGGLE); - } - NodeKind::AdditionalSoftwareGroup => { - let parent_ptr = data.additional_software_group_ptr(); - let leaf_ptrs = data.package_ptrs_in_category( - rabbit_core::package::PackageCategory::Additional, - ); - model.items_changed(&leaf_ptrs); - model.item_value_changed(parent_ptr, PACKAGE_COL_TOGGLE); - } - NodeKind::LanguageGroup => { - let parent_ptr = data.language_group_ptr(); - let leaf_ptrs = data.package_ptrs_in_category( - rabbit_core::package::PackageCategory::Language, - ); - model.items_changed(&leaf_ptrs); - model.item_value_changed(parent_ptr, PACKAGE_COL_TOGGLE); - } - NodeKind::Package(idx) => { - let leaf_ptr = data.package_ptr(idx); - model.item_value_changed(leaf_ptr, PACKAGE_COL_LABEL); - // Refresh the aggregate cell of whichever group - // this package hangs under. - let category = data - .rows - .borrow() - .get(idx) - .map(|r| r.category) - .unwrap_or_default(); - let parent_ptr = match category { - rabbit_core::package::PackageCategory::Additional => { - data.additional_software_group_ptr() - } - rabbit_core::package::PackageCategory::Language => { - data.language_group_ptr() + let cancel = install_run.begin(); + let install_run_for_worker = Arc::clone(&install_run); + std::thread::spawn(move || { + let result = execute_wizard_install_with_progress(request, &progress, &cancel); + operation_finished.store(true, std::sync::atomic::Ordering::Relaxed); + wxdragon::call_after(Box::new(move || { + widgets.progress_gauge.set_value(100); + match result { + Ok(report) => { + let outcome_report = wizard_outcome_report_from_success( + &ui_model, + &request_for_report, + &report, + ); + widgets.progress_details.set_value(&format!( + "{}\n\n{}", + outcome_report.status_line, + outcome_report.detail_lines.join("\n") + )); + set_last_resource_path( + &ui_last_resource_path, + Some(report.resource_path.clone()), + ); + set_last_report(&ui_last_report, Some(outcome_report.clone())); + // Auto-save the outcome report under + // /RABBIT/logs/ so users always have + // a JSON+text trail without having to + // remember to click "Save report". Best + // effort: log to stderr and continue if the + // save itself fails. + if let Err(error) = save_wizard_outcome_report(&outcome_report) { + eprintln!("could not auto-save wizard outcome report: {error}"); } - rabbit_core::package::PackageCategory::Core => { - data.packages_group_ptr() + // The operation returns Ok even when some + // packages failed (it installs everything it + // can), and when the user stopped it partway. + // Pick an honest heading: a plain "success" + // line would contradict the "finished with + // errors" summary otherwise, and a stopped run + // is neither of those. + let heading = if report.was_cancelled() { + &ui_model.text.done_status_cancelled + } else if report.package_operation.has_failures() { + &ui_model.text.done_status_completed_with_errors + } else { + &ui_model.text.done_status_success + }; + widgets.progress_status.set_label(heading); + // Done page: show the outcome heading on the + // status TextCtrl and the full setup-report + // detail block in the collapsible TextCtrl. + widgets.done_status.set_value(&format!( + "{}\n\n{}", + heading, outcome_report.status_line, + )); + widgets + .done_details + .set_value(&outcome_report.detail_lines.join("\n")); + set_last_path( + &ui_last_reaper_app_path, + request_for_report + .target_app_path + .as_ref() + .filter(|path| path.exists()) + .cloned(), + ); + widgets + .done_launch_reaper + .enable(can_launch_last_reaper_path(&ui_last_reaper_app_path)); + widgets.done_open_resource.enable(true); + // Auto-rescan: the install pipeline just + // wrote a fresh receipt for whatever + // landed, and the cached package_rows + // still reflect pre-install state. Fire + // the post-install hook the click handler + // armed earlier so navigating back from + // the Done page (or via Rescan) reflects + // the new on-disk state without the user + // having to click anything. + fire_post_install_hook(); + } + Err(error) => { + let outcome_report = wizard_outcome_report_from_error( + &ui_model, + &request_for_report, + &error, + ); + set_last_report(&ui_last_report, Some(outcome_report.clone())); + // Same auto-save policy as the success path: + // failure runs are exactly when a saved log + // helps users diagnose what went wrong. + if let Err(save_error) = save_wizard_outcome_report(&outcome_report) + { + eprintln!( + "could not auto-save wizard outcome report: {save_error}" + ); } - }; - model.item_value_changed(parent_ptr, PACKAGE_COL_TOGGLE); - } - NodeKind::ConfigurationGroup => { - let parent_ptr = data.configuration_group_ptr(); - let leaf_ptrs = data.all_configuration_ptrs(); - model.items_changed(&leaf_ptrs); - model.item_value_changed(parent_ptr, PACKAGE_COL_TOGGLE); + widgets.progress_details.set_value(&format!( + "{}\n\n{}", + outcome_report.status_line, + outcome_report.detail_lines.join("\n") + )); + widgets + .progress_status + .set_label(&ui_model.text.done_status_error); + widgets.done_status.set_value(&outcome_report.status_line); + widgets + .done_details + .set_value(&outcome_report.detail_lines.join("\n")); + widgets + .done_launch_reaper + .enable(can_launch_last_reaper_path(&ui_last_reaper_app_path)); + widgets.done_open_resource.enable( + clone_last_resource_path(&ui_last_resource_path).is_some(), + ); + } } - NodeKind::Configuration(idx) => { - let leaf_ptr = data.configuration_ptr(idx); - model.item_value_changed(leaf_ptr, PACKAGE_COL_LABEL); - model.item_value_changed( - data.configuration_group_ptr(), - PACKAGE_COL_TOGGLE, - ); + ui_current_step.store(DONE_STEP, Ordering::SeqCst); + update_navigation( + DONE_STEP, + &book, + &step_label, + ui_labels.as_slice(), + &back, + &next, + &install, + &close, + &widgets.language_footer, + can_install, + target_is_valid(&ui_model, &widgets), + reapack_ack_confirmed(&widgets), + ); + // Focus the always-visible status TextCtrl so the + // screen reader announces the install result and + // Tab moves forward to the Show-details CheckBox + // and action buttons instead of cycling back to + // an earlier widget. + widgets.done_status.set_focus(); + // The worker is done and everything it held has been + // dropped, so the window can go now. Force the close: + // there is nothing left to ask the user about, and the + // close handler would otherwise re-open the same + // question it already answered. + let close_now = install_run_for_worker.stop_requested(); + install_run_for_worker.finish(); + if close_now { + with_ui_frame(|frame| frame.close(true)); } - } - - if recomputed_configuration { - let cfg_leaf_ptrs = data.all_configuration_ptrs(); - model.items_changed(&cfg_leaf_ptrs); - model - .item_value_changed(data.configuration_group_ptr(), PACKAGE_COL_TOGGLE); - } - } - - true - }, - ), - // is_enabled — gray out the checkbox + label of unavailable rows. - Some( - move |_data: &PackageTreeData, item: Option<&Node>, _col: u32| -> bool { - let Some(node) = item else { - return true; - }; - match node.kind { - NodeKind::PackagesGroup - | NodeKind::AdditionalSoftwareGroup - | NodeKind::LanguageGroup - | NodeKind::ConfigurationGroup => true, - NodeKind::Package(idx) => rows_for_is_enabled - .borrow() - .get(idx) - .map(|row| row.available_for_target) - .unwrap_or(true), - NodeKind::Configuration(idx) => configuration_rows_for_is_enabled - .borrow() - .get(idx) - .map(|row| row.available_for_target && !row.already_applied) - .unwrap_or(true), - } - }, - ), - // compare — left at None semantically; the explicit type is needed - // because the closure-based `Option` pattern doesn't infer - // without it. - None::, - ) -} - -/// Non-Windows: expand both synthetic group nodes ("Packages" and -/// "Configuration") so all leaves are visible without an extra click. -/// Reads the group pointers from the model's userdata so the model -/// owns the canonical Node addresses. -#[cfg(not(target_os = "windows"))] -fn expand_packages_group(tree: &PackagesView, model: &CustomDataViewTreeModel) { - let mut packages_group_ptr: *const Node = std::ptr::null(); - let mut additional_software_group_ptr: *const Node = std::ptr::null(); - let mut language_group_ptr: *const Node = std::ptr::null(); - let mut configuration_group_ptr: *const Node = std::ptr::null(); - model.with_userdata_mut::(|data| { - packages_group_ptr = data.packages_group_ptr(); - // Only expand the additional-software group when it's actually - // exposed as a root child; otherwise its pointer addresses a node - // the view never asked about. - if data.has_additional_software() { - additional_software_group_ptr = data.additional_software_group_ptr(); - } - // Same guard for the language-packs group. - if data.has_language_packs() { - language_group_ptr = data.language_group_ptr(); - } - configuration_group_ptr = data.configuration_group_ptr(); - }); - for ptr in [ - packages_group_ptr, - additional_software_group_ptr, - language_group_ptr, - configuration_group_ptr, - ] { - if ptr.is_null() { - continue; - } - let item = wxdragon::widgets::dataview::DataViewItem::from_id_ptr(ptr); - if item.is_ok() { - tree.expand(&item); - } - } -} - -/// Non-Windows: replace the row set inside the live -/// `CustomDataViewTreeModel`. Reuses the existing model + control -/// association so nothing has to be rewired; the model just gets new -/// userdata, then we tell the view that everything has changed via -/// `cleared()`. After cleared() the control re-queries the model for -/// visible items and the previously-selected row drops away (caller -/// resets `package_details` to the first row). -#[cfg(not(target_os = "windows"))] -fn rebuild_packages_tree_model( - tree: &PackagesView, - package_items: &PackagesStateCell, - model: &WizardModel, - package_rows: &[PackageRow], - configuration_rows: &[ConfigurationRow], -) { - let Some(dv_model) = package_items.borrow().as_ref().cloned() else { - return; - }; - let packages_group_label = model.text.packages_tree_group_label.clone(); - let additional_software_group_label = model.text.additional_software_tree_group_label.clone(); - let language_group_label = model.text.language_tree_group_label.clone(); - let configuration_group_label = model.text.configuration_tree_group_label.clone(); - dv_model.with_userdata_mut::(|data| { - // Sync the shared Rc>>s in case the caller - // hasn't pre-replaced them (the post-install hook does, the - // version-check finish handler also does — be defensive in - // case a future caller forgets). - let pkg_len = package_rows.len(); - if data.rows.borrow().len() != pkg_len { - *data.rows.borrow_mut() = package_rows.to_vec(); - } - let cfg_len = configuration_rows.len(); - if data.configuration_rows.borrow().len() != cfg_len { - *data.configuration_rows.borrow_mut() = configuration_rows.to_vec(); - } - data.packages_group_label = packages_group_label; - data.additional_software_group_label = additional_software_group_label; - data.language_group_label = language_group_label; - data.configuration_group_label = configuration_group_label; - data.package_nodes = (0..pkg_len) - .map(|i| { - Box::new(Node { - kind: NodeKind::Package(i), - }) - }) - .collect(); - data.configuration_nodes = (0..cfg_len) - .map(|i| { - Box::new(Node { - kind: NodeKind::Configuration(i), - }) - }) - .collect(); - }); - dv_model.cleared(); - // wxDataViewCtrl auto-collapses the groups on Cleared; re-expand so - // the user sees the leaves immediately. - expand_packages_group(tree, &dv_model); -} - -#[cfg(not(target_os = "windows"))] -#[allow(clippy::too_many_arguments)] // UI plumbing: one parameter per widget handle. -fn refresh_package_checklist( - tree: &PackagesView, - package_items: &PackagesStateCell, - details: &TextCtrl, - osara_keymap_replace: &CheckBox, - osara_keymap_note: &TextCtrl, - spanish_variant_choice: &Choice, - reaper_language_choice: &Choice, - model: &WizardModel, - rows: &[crate::PackageRow], - configuration_rows: &[ConfigurationRow], -) { - rebuild_packages_tree_model(tree, package_items, model, rows, configuration_rows); - details.set_value(&rows.first().map(package_details).unwrap_or_default()); - sync_osara_keymap_widgets(model, rows, osara_keymap_replace, osara_keymap_note); - sync_spanish_variant_widget(rows, spanish_variant_choice); - sync_reaper_language_widget(rows, reaper_language_choice); -} - -#[cfg(not(target_os = "windows"))] -fn rebuild_package_list_widgets( - widgets: &WizardWidgets, - package_items: &PackagesStateCell, - model: &WizardModel, - package_rows: &[PackageRow], - configuration_rows: &[ConfigurationRow], -) { - rebuild_packages_tree_model( - &widgets.package_checklist, - package_items, - model, - package_rows, - configuration_rows, - ); - let initial = package_rows - .first() - .map(package_details) - .unwrap_or_default(); - widgets.package_details.set_value(&initial); -} - -/// Non-Windows twin of the tree-based helper: give the packages DataView -/// keyboard focus so a screen reader starts reading it from the top. -/// -/// Deliberately focus-only, NO selection. wxdragon's -/// `DataViewCtrl::select_row` fabricates a `wxDataViewItem` whose internal -/// pointer IS the integer `row + 1` — the encoding only virtual LIST models -/// use. This view is driven by a custom TREE model whose item ids are real -/// node pointers, so `select_row(0)` hands the native macOS port a garbage -/// `0x1` pointer to dereference and crashes the app (reported by macOS -/// users as a crash right after the first Next, when the version check -/// auto-advances onto this page). A freshly rebuilt DataView has no -/// selection anyway, so reading naturally starts at the top — the -/// caret-drift problem this helper fixes is native-Windows-tree-specific. -#[cfg(not(target_os = "windows"))] -fn focus_packages_list_top(widgets: &WizardWidgets, _package_items: &PackagesStateCell) { - widgets.package_checklist.set_focus(); -} - -fn build_version_check_page( - page: &WizardPage, - model: &WizardModel, - package_count: i32, -) -> (StaticText, Gauge, StaticText, TextCtrl) { - let sizer = BoxSizer::builder(Orientation::Vertical).build(); - add_heading( - page, - &sizer, - &model.text.version_check_heading, - "rabbit-version-check-heading", - ); - let status = StaticText::builder(page) - .with_label(&model.text.version_check_status_pending) - .build(); - status.set_name("rabbit-version-check-status"); - sizer.add(&status, 0, SizerFlag::All | SizerFlag::Expand, 6); - - add_label( - page, - &sizer, - &model.text.version_check_progress_label, - "rabbit-version-check-progress-label", - ); - let gauge = Gauge::builder(page) - .with_range(package_count.max(1)) - .build(); - gauge.set_name("rabbit-version-check-progress"); - sizer.add(&gauge, 0, SizerFlag::All | SizerFlag::Expand, 6); - - let error_heading = StaticText::builder(page) - .with_label(&model.text.version_check_error_heading) - .build(); - error_heading.set_name("rabbit-version-check-error-heading"); - sizer.add(&error_heading, 0, SizerFlag::All | SizerFlag::Expand, 6); - let error_log = TextCtrl::builder(page) - .with_value("") - .with_style(TextCtrlStyle::MultiLine | TextCtrlStyle::ReadOnly | TextCtrlStyle::WordWrap) - .with_size(Size::new(-1, 120)) - .build(); - error_log.set_name("rabbit-version-check-error-log"); - sizer.add(&error_log, 1, SizerFlag::All | SizerFlag::Expand, 6); - - // Hide the error region until something fails so screen readers do not - // see an empty Failed-checks/error-log pair while a check is in progress. - // Show()/Hide() removes the controls from the tab order and the - // accessibility tree; we re-Show() them in render_version_check_errors. - error_heading.hide(); - error_log.hide(); - - page.set_sizer(sizer, true); - (status, gauge, error_heading, error_log) -} - -/// Build the ReaPack donation-acknowledgement page. The page is only ever -/// shown when ReaPack is in the install/update plan — the Packages → Review -/// transition routes through it conditionally. The Continue button stays -/// disabled until the user checks the acknowledgement; that gating happens -/// in `update_navigation` based on `reapack_ack_confirm.get_value()`. -fn build_reapack_ack_page(page: &WizardPage, model: &WizardModel) -> (Button, CheckBox) { - let sizer = BoxSizer::builder(Orientation::Vertical).build(); - add_heading( - page, - &sizer, - &model.text.reapack_ack_heading, - "rabbit-reapack-ack-heading", - ); - let body = TextCtrl::builder(page) - .with_value(&model.text.reapack_ack_body) - .with_style(TextCtrlStyle::MultiLine | TextCtrlStyle::ReadOnly | TextCtrlStyle::WordWrap) - .with_size(Size::new(-1, 120)) - .build(); - body.set_name("rabbit-reapack-ack-body"); - sizer.add(&body, 0, SizerFlag::All | SizerFlag::Expand, 6); - - let donate_link = Button::builder(page) - .with_label(&model.text.reapack_ack_link_label) - .build(); - donate_link.set_name("rabbit-reapack-ack-donate-link"); - donate_link.add_style(WindowStyle::TabStop); - donate_link.set_can_focus(true); - sizer.add(&donate_link, 0, SizerFlag::All, 6); - donate_link.on_click(move |_| { - // Best-effort: open the donation page in the user's default browser - // so the donation hint surfaces on a real, current upstream page - // rather than a stale cached blurb in the wizard. - let _ = open_external_url("https://reapack.com/donate"); - }); - - let confirm = CheckBox::builder(page) - .with_label(&model.text.reapack_ack_confirm_label) - .build(); - // Mirror the OSARA-keymap / done-page CheckBox pattern: on this - // wxdragon version the accessible name is driven by the wxWindow - // *name* on Windows, not the visible `with_label` argument, so set - // both `name` and `label` to the localized string. Without this the - // screen reader announces the literal Fluent key - // (`rabbit-reapack-ack-confirm`) instead of the translated label. - confirm.set_name(&model.text.reapack_ack_confirm_label); - confirm.set_label(&model.text.reapack_ack_confirm_label); - confirm.add_style(WindowStyle::TabStop); - confirm.set_value(false); - sizer.add(&confirm, 0, SizerFlag::All, 6); - - page.set_sizer(sizer, true); - (donate_link, confirm) -} - -fn open_external_url(url: &str) -> std::io::Result<()> { - #[cfg(target_os = "windows")] - { - // `cmd` is a console program, and RABBIT's release build owns no - // console to lend it, so without this a black window flashes up - // every time the user follows a link. The browser `start` hands the - // URL to is a separate process and still opens normally. - let mut command = Command::new("cmd"); - command.args(["/C", "start", "", url]); - rabbit_platform::process::without_console_window(&mut command).spawn()?; - Ok(()) - } - - #[cfg(target_os = "macos")] - { - Command::new("open").arg(url).spawn()?; - Ok(()) - } - - #[cfg(not(any(target_os = "windows", target_os = "macos")))] - { - let _ = url; - Err(std::io::Error::new( - std::io::ErrorKind::Unsupported, - "opening URLs is only implemented on Windows and macOS", - )) - } -} - -fn build_review_page(page: &WizardPage, model: &WizardModel) -> TextCtrl { - let sizer = BoxSizer::builder(Orientation::Vertical).build(); - add_heading( - page, - &sizer, - &model.text.review_heading, - "rabbit-review-heading", - ); - let review = TextCtrl::builder(page) - .with_value(&model.review_lines.join("\n")) - .with_style(TextCtrlStyle::MultiLine | TextCtrlStyle::ReadOnly | TextCtrlStyle::WordWrap) - .build(); - review.set_name("rabbit-review-text"); - sizer.add(&review, 1, SizerFlag::All | SizerFlag::Expand, 6); - page.set_sizer(sizer, true); - review -} - -fn build_progress_page(page: &WizardPage, model: &WizardModel) -> (StaticText, Gauge, TextCtrl) { - let sizer = BoxSizer::builder(Orientation::Vertical).build(); - add_heading( - page, - &sizer, - &model.text.progress_heading, - "rabbit-progress-heading", - ); - let status = StaticText::builder(page) - .with_label(&model.text.progress_status) - .build(); - status.set_name("rabbit-progress-status"); - sizer.add(&status, 0, SizerFlag::All | SizerFlag::Expand, 6); - let gauge = Gauge::builder(page).with_range(100).build(); - gauge.set_name("rabbit-progress-gauge"); - sizer.add(&gauge, 0, SizerFlag::All | SizerFlag::Expand, 6); - - add_label( - page, - &sizer, - &model.text.progress_details_label, - "rabbit-progress-details-label", - ); - let details = TextCtrl::builder(page) - .with_value(&model.text.progress_details_idle) - .with_style(TextCtrlStyle::MultiLine | TextCtrlStyle::ReadOnly | TextCtrlStyle::WordWrap) - .build(); - details.set_name("rabbit-progress-details"); - sizer.add(&details, 1, SizerFlag::All | SizerFlag::Expand, 6); - - page.set_sizer(sizer, true); - (status, gauge, details) -} - -fn build_done_page(page: &WizardPage, model: &WizardModel) -> (TextCtrl, TextCtrl, Button, Button) { - let sizer = BoxSizer::builder(Orientation::Vertical).build(); - add_heading( - page, - &sizer, - &model.text.done_heading, - "rabbit-done-heading", - ); - // One short status TextCtrl (always visible) carries the success / - // failure sentence + any follow-up status updates ("Report saved at …", - // "REAPER could not be launched: …"). Power-user details live in the - // collapsible TextCtrl below — kept hidden by default per the - // streamlined wizard design. - let status = TextCtrl::builder(page) - .with_value(&model.text.done_status) - .with_style(TextCtrlStyle::MultiLine | TextCtrlStyle::ReadOnly | TextCtrlStyle::WordWrap) - .with_size(Size::new(-1, 80)) - .build(); - status.set_name("rabbit-done-status"); - sizer.add(&status, 0, SizerFlag::All | SizerFlag::Expand, 6); - - let show_details = CheckBox::builder(page) - .with_label(&model.text.done_show_details_label) - .build(); - // Mirror the OSARA-keymap checkbox pattern: on this wxdragon version - // the visible label appears to be driven by the wxWindow *name* on - // Windows (the `with_label` builder argument doesn't reliably stick), - // so set both name and label to the same localized string and the - // checkbox renders correctly in every locale. - show_details.set_name(&model.text.done_show_details_label); - show_details.set_label(&model.text.done_show_details_label); - show_details.add_style(WindowStyle::TabStop); - show_details.set_value(false); - sizer.add(&show_details, 0, SizerFlag::All, 6); - - let details = TextCtrl::builder(page) - .with_value("") - .with_style(TextCtrlStyle::MultiLine | TextCtrlStyle::ReadOnly | TextCtrlStyle::WordWrap) - .build(); - details.set_name("rabbit-done-details"); - details.hide(); - sizer.add(&details, 1, SizerFlag::All | SizerFlag::Expand, 6); - - let toggle_details = details; - let toggle_page = *page; - show_details.on_toggled(move |event| { - let visible = event.is_checked(); - toggle_details.show(visible); - toggle_page.layout(); - // Move keyboard focus into the details TextCtrl as soon as the - // user reveals it. Screen readers (NVDA, JAWS) announce the - // newly-focused control, which both confirms the checkbox click - // and reads out the install report without the user having to - // hunt for it via Tab. - if visible { - toggle_details.set_focus(); - } - }); - - let actions = BoxSizer::builder(Orientation::Horizontal).build(); - actions.add_stretch_spacer(1); - - let launch_reaper = Button::builder(page) - .with_label(&model.text.done_launch_reaper_label) - .build(); - launch_reaper.set_name("rabbit-done-launch-reaper"); - launch_reaper.add_style(WindowStyle::TabStop); - launch_reaper.set_can_focus(true); - launch_reaper.enable(false); - actions.add(&launch_reaper, 0, SizerFlag::All, 6); - - let open_resource = Button::builder(page) - .with_label(&model.text.done_open_resource_label) - .build(); - open_resource.set_name("rabbit-done-open-resource"); - open_resource.add_style(WindowStyle::TabStop); - open_resource.set_can_focus(true); - open_resource.enable(false); - actions.add(&open_resource, 0, SizerFlag::All, 6); - - sizer.add_sizer(&actions, 0, SizerFlag::All | SizerFlag::Expand, 0); - page.set_sizer(sizer, true); - (status, details, launch_reaper, open_resource) -} - -fn add_heading(page: &W, sizer: &BoxSizer, label: &str, name: &str) { - let heading = StaticText::builder(page).with_label(label).build(); - heading.set_name(name); - sizer.add(&heading, 0, SizerFlag::All | SizerFlag::Expand, 6); -} - -fn add_label(page: &W, sizer: &BoxSizer, label: &str, name: &str) { - let widget = StaticText::builder(page).with_label(label).build(); - widget.set_name(name); - sizer.add( - &widget, - 0, - SizerFlag::Left | SizerFlag::Right | SizerFlag::Top, - 6, - ); -} - -fn selected_target_details( - model: &WizardModel, - choice: &Choice, - portable_folder: &TextCtrl, -) -> String { - match choice.get_selection().map(|index| index as usize) { - Some(index) if index == portable_choice_index(model) => { - portable_target_details(model, portable_folder) - } - Some(index) => target_details_for_index(model, index), - None => model.text.target_empty.clone(), - } -} - -fn target_details_for_index(model: &WizardModel, index: usize) -> String { - model - .target_rows - .get(index) - .map(|row| refreshed_target_row(model, row).details) - .unwrap_or_else(|| model.text.target_empty.clone()) -} - -fn package_details(row: &crate::PackageRow) -> String { - row.details.clone() -} - -fn progress_details_for_start( - model: &WizardModel, - target: Option<&TargetRow>, - selected_package_indices: &[usize], - package_rows: &[crate::PackageRow], - osara_keymap_choice: OsaraKeymapChoice, - cache_dir: Option<&Path>, -) -> String { - let mut lines = vec![model.text.progress_details_starting.clone()]; - if let Some(target) = target { - lines.push(format!( - "{}: {}", - model.text.review_target_prefix, - target.path.display() - )); - } else { - lines.push(model.text.review_no_target.clone()); - } - - if selected_package_indices.is_empty() { - lines.push(model.text.review_no_package.clone()); - } else { - for index in selected_package_indices { - if let Some(row) = package_rows.get(*index) { - lines.push(format!("{}: {}", row.display_name, row.action_label)); - } + })); + }); + }); } - } - - if osara_selected_for_rows(package_rows, selected_package_indices) { - lines.push(model.text.review_osara_keymap_heading.clone()); - lines.push(match osara_keymap_choice { - OsaraKeymapChoice::PreserveCurrent => model.text.review_osara_keymap_preserve.clone(), - OsaraKeymapChoice::ReplaceCurrent => model.text.review_osara_keymap_replace.clone(), - }); - } - - if let Some(cache_dir) = cache_dir { - lines.push(format!( - "{}: {}", - model.text.progress_details_cache_prefix, - cache_dir.display() - )); - } - - lines.join("\n") -} - -fn step_status(model: &WizardModel, step: usize) -> String { - model - .steps - .get(step) - .map(|step| step.label.clone()) - .unwrap_or_else(|| model.window_title.clone()) -} -fn selected_target_row(model: &WizardModel, widgets: &WizardWidgets) -> Option { - let index = widgets.target_choice.get_selection()? as usize; - if index == portable_choice_index(model) { - return portable_folder_path(&widgets.portable_folder) - .map(|path| custom_portable_target_row(model, path, true)); - } - model - .target_rows - .get(index) - .map(|row| refreshed_target_row(model, row)) -} + let frame_for_close = frame; + close.on_click(move |_| { + // Not forced: the frame's close handler has to be able to stop + // this while an install is running. + frame_for_close.close(false); + }); -fn refreshed_target_index(model: &WizardModel, widgets: &WizardWidgets) -> Option { - widgets.target_choice.get_selection().map(|index| { - let index = index as usize; - if index == portable_choice_index(model) { - portable_choice_index(model) - } else { - index + // Every route out of the wizard — the Close button, the window's + // close box, Alt+F4, Cmd+Q — arrives here, which is the one place + // that knows whether it is safe to go. + { + let model_for_close = Arc::clone(&model); + let install_run = Arc::clone(&install_run); + let widgets = wizard_widgets; + let close_button = close; + frame.on_close(move |event| { + match close_verdict(&install_run) { + CloseVerdict::Allow => { + // Let wxWidgets' own frame handler run and destroy + // the window. Nothing is in flight, so there is + // nothing to unwind. + event.skip(true); + } + CloseVerdict::SelfUpdateInProgress => { + // Not a question: there is no answer that makes + // quitting safe here, so say what is happening + // instead of offering a choice RABBIT can't honour. + show_close_blocked_by_self_update(&model_for_close); + } + CloseVerdict::AlreadyStopping => { + // A second press while the first stop is still + // unwinding. Re-announce rather than re-ask. + announce_stopping(&widgets, &model_for_close); + } + CloseVerdict::Ask => { + if !confirm_stop_install(&model_for_close) { + return; + } + if !install_run.request_stop_and_close() { + // The install finished while the question was on + // screen, so there is nothing left to stop and + // nobody left to close the window for us. Queue + // the close for the next turn of the event loop + // rather than re-entering this handler from + // inside itself. + wxdragon::call_after(Box::new(|| { + with_ui_frame(|frame| frame.close(true)); + })); + return; + } + // The button that raised the question is now the + // wrong thing to press again. + close_button.enable(false); + announce_stopping(&widgets, &model_for_close); + } + } + // Every branch except `Allow` falls through without + // skipping, which is what keeps the window alive: the + // default handler that would destroy it never runs. + }); } - }) -} -fn refresh_target_choice( - model: &WizardModel, - choice: &Choice, - selected_index: Option, - refreshed_target: &TargetRow, -) { - let selected_index = selected_index.unwrap_or_else(|| portable_choice_index(model)); - choice.clear(); - for (index, row) in model.target_rows.iter().enumerate() { - if index == selected_index { - choice.append(&refreshed_target.label); - } else { - choice.append(&row.label); + // Handle the application-menu Quit item: the macOS menu bar + // installed below carries a stock wxID_EXIT item, which wxOSX + // relocates into the application menu as "Quit rabbit" (Cmd+Q). + // Selecting it emits a plain menu command with that id at the + // frame — route it to the same teardown as the wizard's Close + // button and the window close box. Bound on every platform, but + // only macOS installs a menu that can emit it. + { + let frame_for_quit = frame; + frame.on_menu_selected(move |event| { + if event.get_id() == wxdragon::id::ID_EXIT { + frame_for_quit.close(false); + } + }); } - } - choice.append(&model.text.target_portable_choice); - choice.set_selection(selected_index as u32); -} - -fn checked_package_indices(rows: &[PackageRow]) -> Vec { - rows.iter() - .enumerate() - .filter(|(_, row)| row.selected) - .map(|(index, _)| index) - .collect() -} - -fn osara_keymap_choice(checkbox: &CheckBox) -> OsaraKeymapChoice { - if checkbox.get_value() { - OsaraKeymapChoice::ReplaceCurrent - } else { - OsaraKeymapChoice::PreserveCurrent - } -} - -fn effective_can_install(plan_can_install: &Cell, review_can_install: &Cell) -> bool { - plan_can_install.get() && review_can_install.get() -} - -/// Windows: re-render the native TreeCtrl after a row replacement. -#[cfg(target_os = "windows")] -#[allow(clippy::too_many_arguments)] // UI plumbing: one parameter per widget handle. -fn refresh_package_checklist( - tree: &PackagesView, - package_items: &PackagesStateCell, - details: &TextCtrl, - osara_keymap_replace: &CheckBox, - osara_keymap_note: &TextCtrl, - spanish_variant_choice: &Choice, - reaper_language_choice: &Choice, - model: &WizardModel, - rows: &[crate::PackageRow], - configuration_rows: &[ConfigurationRow], -) { - populate_packages_tree(tree, package_items, model, rows, configuration_rows); - details.set_value(&rows.first().map(package_details).unwrap_or_default()); - sync_osara_keymap_widgets(model, rows, osara_keymap_replace, osara_keymap_note); - sync_spanish_variant_widget(rows, spanish_variant_choice); - sync_reaper_language_widget(rows, reaper_language_choice); -} - -/// Enable the Spanish OSARA-translation choice only while the Spanish -/// language pack is actually selected for install — the setting has no -/// effect otherwise, and a live-but-inert control is a trap for a screen -/// reader. Mirrors how the OSARA key-map choice is gated on OSARA. -/// Refill the "REAPER language after installation" dropdown from the -/// language packs currently ticked, preserving the user's pick when it is -/// still on the list. Disabled when nothing is ticked, since there would be -/// nothing to choose. Several packs can be installed at once, so this is a -/// list rather than a consequence of which pack was chosen. -fn sync_reaper_language_widget(rows: &[crate::PackageRow], choice: &Choice) { - let selected_indices = checked_package_indices(rows); - let packs = crate::selected_language_packs(rows, &selected_indices); - let previous = choice - .get_selection() - .and_then(|index| choice.get_string(index)); - choice.clear(); - for (_, display_name) in &packs { - choice.append(display_name); - } - let restored = previous - .and_then(|name| packs.iter().position(|(_, display)| *display == name)) - .unwrap_or(0); - if !packs.is_empty() { - choice.set_selection(restored as u32); - } - choice.enable(!packs.is_empty()); - choice.set_can_focus(!packs.is_empty()); - set_optional_choice_shown(choice, REAPER_LANGUAGE_LABEL_NAME, !packs.is_empty()); -} - -fn sync_spanish_variant_widget(rows: &[crate::PackageRow], choice: &Choice) { - let selected_indices = checked_package_indices(rows); - let selected = crate::variant_choice_package_selected(rows, &selected_indices); - choice.enable(selected); - choice.set_can_focus(selected); - set_optional_choice_shown(choice, SPANISH_VARIANT_LABEL_NAME, selected); -} - -/// wxWindow names of the labels sitting above the two Packages-page -/// dropdowns that only apply to some selections. `set_optional_choice_shown` -/// looks a label up by name from the dropdown's parent page, which keeps the -/// dozen call sites that already carry a dropdown handle unchanged. -const REAPER_LANGUAGE_LABEL_NAME: &str = "rabbit-reaper-language-label"; -const SPANISH_VARIANT_LABEL_NAME: &str = "rabbit-spanish-variant-label"; - -/// Show or hide a dropdown together with its label, then re-lay out the page -/// so the row's space is reclaimed (or given back). -/// -/// A dropdown that doesn't apply to the current selection used to stay on -/// the page, greyed out and — for the REAPER language — empty. Tab already -/// skipped it, but a screen reader walking the page still stopped on a dead -/// control and read out an empty combo box; hiding it removes it from the -/// accessibility tree entirely, and it comes straight back when ticking a -/// package makes the choice mean something again. -fn set_optional_choice_shown(choice: &Choice, label_name: &str, shown: bool) { - if choice.is_shown() == shown { - return; - } - choice.show(shown); - let Some(page) = choice.get_parent() else { - return; - }; - if let Some(label) = page.find_window_by_name(label_name) { - label.show(shown); - } - // The sizer reads each child's visibility when it lays the page out, so - // the hidden row only stops taking vertical space once we ask for it. - page.layout(); -} - -fn sync_osara_keymap_widgets( - model: &WizardModel, - rows: &[crate::PackageRow], - checkbox: &CheckBox, - note: &TextCtrl, -) { - let selected_indices = checked_package_indices(rows); - let osara_selected = osara_selected_for_rows(rows, &selected_indices); - checkbox.enable(osara_selected); - checkbox.set_can_focus(osara_selected); - note.set_value(&osara_keymap_note( - model, - osara_selected, - osara_keymap_choice(checkbox), - )); - note.enable(osara_selected); - note.set_can_focus(osara_selected); -} - -fn portable_choice_index(model: &WizardModel) -> usize { - model.target_rows.len() -} - -fn portable_folder_path(portable_folder: &TextCtrl) -> Option { - let path = portable_folder.get_value(); - let path = path.trim(); - if path.is_empty() { - None - } else { - Some(PathBuf::from(path)) - } -} - -fn portable_target_details(model: &WizardModel, portable_folder: &TextCtrl) -> String { - portable_folder_path(portable_folder) - .map(|path| custom_portable_target_row(model, path, true).details) - .unwrap_or_else(|| model.text.target_portable_pending_details.clone()) -} - -fn target_is_valid(model: &WizardModel, widgets: &WizardWidgets) -> bool { - selected_target_row(model, widgets) - .map(|target| target.writable) - .unwrap_or(false) -} -/// Whether the user has checked the ReaPack-donation acknowledgement on -/// the dedicated wizard page. Used by `update_navigation` to gate the -/// Next button on REAPACK_ACK_STEP — the page never shows up in the run -/// at all when ReaPack isn't being installed/updated, so on every other -/// step this value is irrelevant. -fn reapack_ack_confirmed(widgets: &WizardWidgets) -> bool { - widgets.reapack_ack_confirm.get_value() -} - -fn bind_reapack_ack_navigation_updates( - widgets: WizardWidgets, - current_step: &Arc, - next: &Button, -) { - let current_step = Arc::clone(current_step); - let next = *next; - widgets.reapack_ack_confirm.on_toggled(move |event| { - if current_step.load(Ordering::SeqCst) == REAPACK_ACK_STEP { - next.enable(event.is_checked()); + // Without any menu bar, wxOSX installs no functional main menu at + // all: the wizard's application menu had a dead Quit item and Cmd+Q + // did nothing. Installing a minimal menu bar makes wxWidgets build + // a real application menu and wire its Quit item to the stock + // wxID_EXIT command handled above. `Ctrl+Q` in a wx accelerator + // string means Cmd+Q on macOS. macOS-only: on Windows this would + // add a visible File menu to a wizard that doesn't want one, and + // Alt+F4 already closes the window natively there. + #[cfg(target_os = "macos")] + { + let file_menu = Menu::builder() + .append_item(wxdragon::id::ID_EXIT, "E&xit\tCtrl+Q", "") + .build(); + let menu_bar = MenuBar::builder().append(file_menu, "&File").build(); + frame.set_menu_bar(menu_bar); } - }); -} -/// A multiline `wxTextCtrl` claims Enter for itself (it reports -/// `DLGC_WANTALLKEYS` on MSW and the NSTextView swallows the key on macOS), -/// so the window's default button never sees it. The Done page deliberately -/// parks focus on the read-only summary TextCtrl so the screen reader reads -/// the outcome out loud — which would otherwise leave Enter dead on the very -/// last page even with Close as the default button. Re-route Enter from the -/// Done page's two read-only TextCtrls to the Close action. -/// -/// Guarded on `DONE_STEP`: these controls only live on the Done page, but the -/// guard is what guarantees Enter can never tear the window down while the -/// install worker is still running — `DONE_STEP` is stored only after the -/// worker finished, or before it is even spawned on a preparation error. -fn bind_done_page_enter_closes(text: &TextCtrl, frame: &Frame, current_step: &Arc) { - let frame = *frame; - let current_step = Arc::clone(current_step); - text.on_key_down(move |event| { - let key_code = if let WindowEventData::Keyboard(kbd) = &event { - kbd.get_key_code() - } else { - None - }; - if !matches!(key_code, Some(WXK_RETURN) | Some(WXK_NUMPAD_ENTER)) { - return; - } - if current_step.load(Ordering::SeqCst) != DONE_STEP { - return; + { + let model = Arc::clone(&model); + let widgets = wizard_widgets; + let last_reaper_app_path = Arc::clone(&last_reaper_app_path); + let frame_for_launch = frame; + widgets.done_launch_reaper.on_click(move |_| { + let Some(app_path) = clone_last_path(&last_reaper_app_path) else { + append_done_status(&widgets.done_status, &model.text.done_no_reaper_app); + return; + }; + if let Err(error) = launch_reaper(&app_path) { + append_done_status( + &widgets.done_status, + &format!("{}: {}", model.text.done_launch_reaper_error_prefix, error), + ); + return; + } + frame_for_launch.close(true); + }); } - // Consume the key before closing so the native control never beeps - // or inserts a newline. `Frame::close(true)` goes through - // wxEVT_CLOSE_WINDOW → Destroy(), which defers via wxPendingDelete, - // so tearing the window down from inside a key handler is safe. - event.skip(false); - frame.close(true); - }); -} - -fn bind_target_navigation_updates( - model: &Arc, - widgets: WizardWidgets, - current_step: &Arc, - next: &Button, -) { - { - let model = Arc::clone(model); - let current_step = Arc::clone(current_step); - let next = *next; - widgets.target_choice.on_selection_changed(move |_| { - if current_step.load(Ordering::SeqCst) == TARGET_STEP { - next.enable(target_is_valid(&model, &widgets)); - } - }); - } - { - let model = Arc::clone(model); - let current_step = Arc::clone(current_step); - let next = *next; - widgets.portable_folder.on_text_changed(move |_| { - if current_step.load(Ordering::SeqCst) == TARGET_STEP { - next.enable(target_is_valid(&model, &widgets)); - } - }); - } -} - -fn configure_portable_folder( - portable_folder: &TextCtrl, - portable_folder_browse: &Button, - enabled: bool, -) { - portable_folder.enable(enabled); - portable_folder.set_can_focus(enabled); - portable_folder_browse.enable(enabled); - portable_folder_browse.set_can_focus(enabled); -} - -fn set_last_report( - state: &Arc>>, - report: Option, -) { - if let Ok(mut slot) = state.lock() { - *slot = report; - } -} - -fn set_last_resource_path(state: &Arc>>, path: Option) { - set_last_path(state, path); -} - -fn clone_last_resource_path(state: &Arc>>) -> Option { - clone_last_path(state) -} - -fn set_last_path(state: &Arc>>, path: Option) { - if let Ok(mut slot) = state.lock() { - *slot = path; - } -} - -fn clone_last_path(state: &Arc>>) -> Option { - state.lock().ok().and_then(|slot| slot.clone()) -} - -fn planned_reaper_launch_path_for_target(target: &TargetRow) -> PathBuf { - target.planned_app_path.clone() -} - -fn can_launch_reaper_path(path: Option<&Path>) -> bool { - path.is_some_and(Path::exists) -} -fn can_launch_last_reaper_path(state: &Arc>>) -> bool { - can_launch_reaper_path(clone_last_path(state).as_deref()) -} - -fn append_done_status(status: &TextCtrl, message: &str) { - let current = status.get_value(); - if current.trim().is_empty() { - status.set_value(message); - } else { - status.set_value(&format!("{current}\n\n{message}")); - } -} - -fn open_resource_folder(path: &Path) -> std::io::Result<()> { - #[cfg(target_os = "windows")] - { - Command::new("explorer.exe").arg(path).spawn()?; - Ok(()) - } - - #[cfg(target_os = "macos")] - { - Command::new("open").arg(path).spawn()?; - Ok(()) - } - - #[cfg(not(any(target_os = "windows", target_os = "macos")))] - { - let _ = path; - Err(std::io::Error::new( - std::io::ErrorKind::Unsupported, - "opening folders is only implemented on Windows and macOS", - )) - } -} - -/// Start REAPER. It never gets RABBIT's GitHub token: it may be running an -/// OSARA pull-request build. An app bundle started through `open` gets its -/// environment from launchd, not from RABBIT, so it needs nothing removed. -fn launch_reaper(path: &Path) -> std::io::Result<()> { - #[cfg(target_os = "windows")] - { - rabbit_platform::process::without_rabbit_secrets(&mut Command::new(path)).spawn()?; - Ok(()) - } - - #[cfg(target_os = "macos")] - { - if path - .extension() - .and_then(|extension| extension.to_str()) - .is_some_and(|extension| extension.eq_ignore_ascii_case("app")) { - Command::new("open").arg(path).spawn()?; - } else { - rabbit_platform::process::without_rabbit_secrets(&mut Command::new(path)).spawn()?; + let model = Arc::clone(&model); + let widgets = wizard_widgets; + let last_resource_path = Arc::clone(&last_resource_path); + widgets.done_open_resource.on_click(move |_| { + let Some(path) = clone_last_resource_path(&last_resource_path) else { + append_done_status(&widgets.done_status, &model.text.review_no_target); + return; + }; + if let Err(error) = open_resource_folder(&path) { + append_done_status( + &widgets.done_status, + &format!("{}: {}", model.text.done_open_resource_error_prefix, error), + ); + } + }); } - Ok(()) - } - - #[cfg(not(any(target_os = "windows", target_os = "macos")))] - { - let _ = path; - Err(std::io::Error::new( - std::io::ErrorKind::Unsupported, - "launching REAPER is only implemented on Windows and macOS", - )) - } -} - -#[allow(clippy::too_many_arguments)] // UI plumbing: one parameter per widget handle. -fn update_navigation( - step: usize, - book: &SimpleBook, - step_label: &StaticText, - labels: &[String], - back: &Button, - next: &Button, - install: &Button, - close: &Button, - language_footer: &Panel, - can_install: bool, - target_valid: bool, - reapack_ack_confirmed: bool, -) { - book.set_selection(step); - if let Some(label) = labels.get(step) { - step_label.set_label(label); - } - back.enable(step > TARGET_STEP && step < DONE_STEP); - next.enable(match step { - TARGET_STEP => target_valid, - // VERSION_CHECK_STEP auto-advances on success; never user-driven. - PACKAGES_STEP | PROGRESS_STEP => true, - REAPACK_ACK_STEP => reapack_ack_confirmed, - _ => false, - }); - install.enable(step == REVIEW_STEP && can_install); - // Make the step's primary action the window's default button so Enter - // activates it regardless of which control holds focus (the standard - // dialog convention on both macOS and Windows). A disabled default - // button is a no-op on Enter, so an invalid Target step or a Review - // step that can't install yet won't advance — exactly what we want. - // Install is primary on Review, Close on Done (the wizard is over there - // and Next is disabled, which used to leave Enter a dead key). Progress - // deliberately keeps the *disabled* Next as its default: Enter must - // never tear the window down while the install worker is running. - match step { - REVIEW_STEP => install.set_default(), - DONE_STEP => close.set_default(), - _ => next.set_default(), - } - // Language picker only matters on the Target step — switching languages - // relaunches RABBIT and discards wizard progress, so a footer on later - // pages would just be a tripwire. - language_footer.show(step == TARGET_STEP); -} - -#[cfg(test)] -mod tests { - use std::fs; - use std::path::PathBuf; - - #[test] - fn a_pull_request_added_later_keeps_the_list_highest_number_first() { - let list = [None, Some(1454), Some(1448), Some(1404)]; - assert_eq!(super::osara_build_insert_position(&list, 1460), 1); - assert_eq!(super::osara_build_insert_position(&list, 1450), 2); - assert_eq!(super::osara_build_insert_position(&list, 1300), 4); - // Only the snapshot so far (the list is still loading or failed). - assert_eq!(super::osara_build_insert_position(&[None], 1454), 1); - } - - use tempfile::tempdir; - use super::{can_launch_reaper_path, planned_reaper_launch_path_for_target}; - use crate::TargetRow; + // (The "Save report" button used to live on the Done page so the + // user could re-save the outcome JSON+text manually. RABBIT already + // auto-saves under `/RABBIT/logs/` on every run — both + // success and failure paths — so the manual button was redundant + // and added clutter on a page meant to read like a destination, + // not a dashboard.) - #[test] - fn launchability_requires_existing_path() { - let dir = tempdir().unwrap(); - let path = dir.path().join("reaper.exe"); + let self_update_state = Arc::new(Mutex::new(SelfUpdateUiState::default())); - assert!(!can_launch_reaper_path(Some(&path))); + // One-shot startup probe: runs the self-update manifest check + // and stores the result into the shared state, then renders. + // (Used to also poll a global package-install lock — that lock + // is now per-target, so the cross-target probe is gone.) + { + let model = Arc::clone(&model); + let widgets = wizard_widgets; + let state = Arc::clone(&self_update_state); + std::thread::spawn(move || { + let check = run_wizard_self_update_check(); + // Resolve the What's-New notes on this same worker, while + // the status line still reads "Checking for RABBIT + // updates…": the prompt is only raised once the whole probe + // lands on the UI thread, so fetching notes here costs the + // user nothing and spares the UI thread an HTTP round-trip. + let release_notes = check + .as_ref() + .ok() + .and_then(run_wizard_self_update_release_notes); + { + let mut state = state.lock().unwrap(); + state.release_notes = release_notes; + state.check = Some(match check { + Ok(report) => Ok(report), + Err(error) => Err(error.to_string()), + }); + } + let render_state = Arc::clone(&state); + let render_model = Arc::clone(&model); + wxdragon::call_after(Box::new(move || { + with_ui_localizer(|localizer| { + render_self_update_status(widgets, &render_model, localizer, &render_state); + }); + })); + }); + } - fs::write(&path, b"stub").unwrap(); + // (Used to also spawn a polling thread that re-checked a global + // install lock and re-rendered when another RABBIT process started + // an install. With per-target locks there's no global lock to + // poll; if a same-target race happens, the install path surfaces + // it as a `PackageInstallInProgress` error at acquire time.) - assert!(can_launch_reaper_path(Some(&path))); - assert!(!can_launch_reaper_path(None)); - } + // (The Done page used to host an "Apply RABBIT update" button as + // an always-reachable fallback to the once-per-session prompt. It + // was removed because users couldn't find it before completing an + // install — the modal at startup is now the only entry point, and + // a user who picks "No" gets re-prompted by relaunching RABBIT.) - #[test] - fn planned_launch_path_uses_target_planned_app_path() { - let target = TargetRow { - label: "Portable REAPER".to_string(), - details: String::new(), - app_path: None, - planned_app_path: PathBuf::from("C:/PortableREAPER/reaper.exe"), - path: PathBuf::from("C:/PortableREAPER"), - version: None, - portable: true, - selected: true, - writable: true, - architecture: rabbit_core::model::Architecture::current(), - }; + // (The "Rescan target" button used to live here so the user could + // re-detect installed components on the Done page and jump back + // to the Packages step. With the post-install auto-rescan hook, + // package_rows is already up to date by the time the user lands + // on Done — manual rescan is a debugging affordance. Users who + // want to re-detect can just relaunch RABBIT.) - assert_eq!( - planned_reaper_launch_path_for_target(&target), - PathBuf::from("C:/PortableREAPER/reaper.exe") - ); - } + frame.centre(); + frame.show(true); + }); } diff --git a/crates/rabbit-ui-wxdragon/src/wx_app/close_guard.rs b/crates/rabbit-ui-wxdragon/src/wx_app/close_guard.rs new file mode 100644 index 0000000..cedd4db --- /dev/null +++ b/crates/rabbit-ui-wxdragon/src/wx_app/close_guard.rs @@ -0,0 +1,178 @@ +//! Decides what closing the window means while an install is running, and +//! asks the user when the answer is "stop the install". + +use std::sync::Mutex; +use std::sync::atomic::{AtomicBool, Ordering}; + +use crate::WizardModel; +use rabbit_core::cancel::CancelToken; + +use wxdragon::prelude::*; + +use crate::wx_app::globals::with_ui_frame; +use crate::wx_app::self_update_ui::SELF_UPDATE_APPLYING; +use crate::wx_app::widgets::WizardWidgets; + +/// The install worker's half of the close handshake. +/// +/// Closing the window used to end the process wherever the worker happened +/// to be: mid-download, mid-extract, or between a vendor installer finishing +/// and RABBIT writing the receipt that records it. Nothing was cleaned up, +/// because nothing unwound — no `Drop` ran, so the install lock file, the +/// extraction temp dirs and any `.part` downloads stayed behind, and the +/// user got no report of what had actually landed. +/// +/// Now the close handler talks to the worker instead. It flips `cancel`, +/// leaves `close_when_done` set, and vetoes the close; the pipeline stops at +/// its next package boundary, unwinds normally, writes its report, and the +/// completion closure closes the window for real. +#[derive(Default)] +pub(crate) struct InstallRunState { + /// The running install's token. `None` before the first install and + /// after each one finishes. + pub(crate) cancel: Mutex>, + /// True between the Install click and the worker's completion closure. + pub(crate) running: AtomicBool, + /// Set when the user asked to close during an install: the completion + /// closure closes the window once the worker is done. + pub(crate) close_when_done: AtomicBool, +} + +impl InstallRunState { + pub(crate) fn begin(&self) -> CancelToken { + let token = CancelToken::new(); + if let Ok(mut slot) = self.cancel.lock() { + *slot = Some(token.clone()); + } + self.running.store(true, Ordering::SeqCst); + token + } + + pub(crate) fn finish(&self) { + self.running.store(false, Ordering::SeqCst); + self.close_when_done.store(false, Ordering::SeqCst); + if let Ok(mut slot) = self.cancel.lock() { + *slot = None; + } + } + + pub(crate) fn is_running(&self) -> bool { + self.running.load(Ordering::SeqCst) + } + + /// Ask the pipeline to stop, and remember that the window should close + /// once it has. Returns `false` when there was nothing left to stop — + /// the run finished while the confirmation dialog was on screen, whose + /// nested event loop is what lets the worker's completion closure run + /// mid-question. Both halves happen under the one lock `finish` takes, + /// so the answer can't go stale between them. + pub(crate) fn request_stop_and_close(&self) -> bool { + let Ok(slot) = self.cancel.lock() else { + return false; + }; + match slot.as_ref() { + Some(token) => { + self.close_when_done.store(true, Ordering::SeqCst); + token.cancel(); + true + } + None => false, + } + } + + pub(crate) fn stop_requested(&self) -> bool { + self.close_when_done.load(Ordering::SeqCst) + } +} + +/// What closing the window should do right now. +pub(crate) enum CloseVerdict { + /// Nothing is running: destroy the window. + Allow, + /// An install is running and the user has not been asked yet. + Ask, + /// An install is running and the user already said "stop": the worker + /// is unwinding and will close the window when it is done. + AlreadyStopping, + /// RABBIT is replacing its own files. There is no safe moment to quit. + SelfUpdateInProgress, +} + +/// Say that RABBIT is stopping, in both places the progress page speaks +/// from: the status heading and the running log. +/// +/// The log is a `TextCtrl` and takes focus; the status heading is a +/// `StaticText` and cannot, and a label change on its own is not something +/// a screen reader announces. Moving focus onto the log is what makes the +/// answer to "did it hear me?" audible. +pub(crate) fn announce_stopping(widgets: &WizardWidgets, model: &WizardModel) { + widgets + .progress_status + .set_label(&model.text.progress_status_cancelling); + widgets + .progress_details + .append_text(&format!("\n{}", model.text.progress_status_cancelling)); + widgets.progress_details.set_focus(); +} + +pub(crate) fn close_verdict(install_run: &InstallRunState) -> CloseVerdict { + if SELF_UPDATE_APPLYING.load(Ordering::SeqCst) { + return CloseVerdict::SelfUpdateInProgress; + } + if !install_run.is_running() { + return CloseVerdict::Allow; + } + if install_run.stop_requested() { + return CloseVerdict::AlreadyStopping; + } + CloseVerdict::Ask +} + +/// Ask before throwing away a running install. Returns `true` when the user +/// confirmed. +/// +/// `wxNO_DEFAULT` puts the focused button on **No**: this dialog appears +/// over work the user asked for, so a stray Enter or Space — from a screen +/// reader user tabbing around the progress page, say — must not be what +/// stops their REAPER install halfway. wxdragon's `MessageDialogStyle` +/// doesn't name the flag, so it comes from the raw wx constant. +pub(crate) fn confirm_stop_install(model: &WizardModel) -> bool { + let no_default = MessageDialogStyle::from_bits_retain(wxdragon::ffi::WXD_NO_DEFAULT); + let mut confirmed = false; + with_ui_frame(|frame| { + let dialog = MessageDialog::builder( + frame, + &model.text.close_during_install_body, + &model.text.close_during_install_title, + ) + .with_style( + MessageDialogStyle::YesNo + | MessageDialogStyle::IconWarning + | MessageDialogStyle::Centre + | no_default, + ) + .build(); + confirmed = dialog.show_modal() == ID_YES; + }); + confirmed +} + +/// Tell the user why the window will not close during a self-update. There +/// is no Yes/No here because there is no "yes": the file swap has no unwind +/// path, so the only honest answer is "wait". +pub(crate) fn show_close_blocked_by_self_update(model: &WizardModel) { + with_ui_frame(|frame| { + let dialog = MessageDialog::builder( + frame, + &model.text.close_during_self_update_body, + &model.text.close_during_self_update_title, + ) + .with_style( + MessageDialogStyle::OK + | MessageDialogStyle::IconInformation + | MessageDialogStyle::Centre, + ) + .build(); + dialog.show_modal(); + }); +} diff --git a/crates/rabbit-ui-wxdragon/src/wx_app/expert_mode.rs b/crates/rabbit-ui-wxdragon/src/wx_app/expert_mode.rs new file mode 100644 index 0000000..0dfe177 --- /dev/null +++ b/crates/rabbit-ui-wxdragon/src/wx_app/expert_mode.rs @@ -0,0 +1,372 @@ +//! Expert mode's wxWidgets side: the hidden unlock (Ctrl+Shift+E, or +//! RABBIT_EXPERT at launch), the build choices it adds to the target page, +//! and the run's channels and versions the version check and install share. + +use std::cell::{Cell, RefCell}; +use std::path::Path; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; + +use rabbit_core::package::PACKAGE_REAPER; +use rabbit_core::plan::AvailablePackage; +use wxdragon::prelude::*; + +use crate::WizardModel; +use crate::wx_app::globals::{with_ui_frame, with_ui_localizer}; +use crate::wx_app::widgets::{WizardWidgets, selected_target_row, set_optional_choice_shown}; +use crate::wx_app::{DONE_STEP, PROGRESS_STEP, TARGET_STEP}; + +/// Expert mode for this session: the hidden unlock for pre-release builds. +/// Never saved - RABBIT keeps no settings file - so it starts from +/// `RABBIT_EXPERT` and is otherwise toggled with Ctrl+Shift+E (Cmd+Shift+E). +/// A process-wide flag rather than a thread-local because the self-update +/// relaunch runs on a worker thread and has to pass it on. +pub(crate) static EXPERT_MODE: AtomicBool = AtomicBool::new(false); + +pub(crate) fn expert_mode() -> bool { + EXPERT_MODE.load(Ordering::SeqCst) +} + +thread_local! { + /// The pull request behind each OSARA build choice; entry 0 is the + /// regular snapshot. Rebuilt whenever the list is fetched. + pub(crate) static OSARA_PULL_REQUESTS: RefCell>> = RefCell::new(vec![None]); + /// The pull request the selected target's OSARA was last installed from, + /// to select once the list of builds arrives. + pub(crate) static OSARA_REMEMBERED_PULL_REQUEST: Cell> = const { Cell::new(None) }; + /// The OSARA build choice, so the background listing can fill it in on + /// the UI thread. + pub(crate) static OSARA_BUILD_CHOICE: Cell> = const { Cell::new(None) }; + /// The channels chosen when leaving the target page. The version check + /// and the install both read this, so they always agree on the builds. + pub(crate) static RUN_CHANNELS: RefCell = + const { RefCell::new(rabbit_core::package::PackageChannels::new()) }; + /// The versions the last version check found, on this run's channels. + /// Re-planning after an install uses these rather than the versions + /// fetched at launch, which knew nothing of the build choices. + pub(crate) static RUN_AVAILABLE: RefCell>> = const { RefCell::new(None) }; +} + +pub(crate) const REAPER_BUILDS_LABEL_NAME: &str = "rabbit-reaper-builds-label"; +pub(crate) const OSARA_BUILDS_LABEL_NAME: &str = "rabbit-osara-builds-label"; +/// REAPER's development channel, as its manifest entry names it. +pub(crate) const REAPER_DEV_CHANNEL: &str = "dev"; + +/// The window title, with the expert-mode suffix while it is on. Reachable +/// at any time with the screen reader's "read title" command, which is how +/// the state stays discoverable after the confirmation dialog is gone. +pub(crate) fn window_title(model: &WizardModel) -> String { + let mut title = model.window_title.clone(); + if expert_mode() { + with_ui_localizer(|localizer| { + title = localizer + .format( + "wizard-window-title-expert", + &[("title", model.window_title.as_str())], + ) + .value; + }); + } + title +} + +pub(crate) fn apply_window_title(model: &WizardModel) { + let title = window_title(model); + with_ui_frame(|frame| frame.set_title(&title)); +} + +/// Ask before turning expert mode on. No is the default, so a stray Enter +/// never unlocks pre-release builds. +pub(crate) fn confirm_expert_mode(model: &WizardModel) -> bool { + let no_default = MessageDialogStyle::from_bits_retain(wxdragon::ffi::WXD_NO_DEFAULT); + let mut confirmed = false; + with_ui_frame(|frame| { + let dialog = MessageDialog::builder( + frame, + &model.text.expert_enable_body, + &model.text.expert_enable_title, + ) + .with_style( + MessageDialogStyle::YesNo + | MessageDialogStyle::IconWarning + | MessageDialogStyle::Centre + | no_default, + ) + .build(); + confirmed = dialog.show_modal() == ID_YES; + }); + confirmed +} + +/// A plain OK message. Every screen reader reads a native message box when +/// it opens, which a status-bar line would not guarantee. +pub(crate) fn show_expert_mode_message(title: &str, body: &str) { + with_ui_frame(|frame| { + let dialog = MessageDialog::builder(frame, body, title) + .with_style( + MessageDialogStyle::OK + | MessageDialogStyle::IconInformation + | MessageDialogStyle::Centre, + ) + .build(); + dialog.show_modal(); + }); +} + +/// The channel REAPER's build choice asks for (`None` = regular releases). +pub(crate) fn reaper_build_channel(widgets: &WizardWidgets) -> Option { + (widgets.reaper_build_choice.get_selection() == Some(1)).then(|| REAPER_DEV_CHANNEL.to_string()) +} + +/// The channel OSARA's build choice asks for (`None` = regular snapshots). +pub(crate) fn osara_build_channel(widgets: &WizardWidgets) -> Option { + let index = widgets.osara_build_choice.get_selection()? as usize; + OSARA_PULL_REQUESTS + .with(|list| list.borrow().get(index).copied().flatten()) + .map(|number| format!("pr:{number}")) +} + +/// Start the build choices from what `target_path` was last installed from, +/// so someone on development builds sees them selected rather than being +/// quietly switched back. +pub(crate) fn seed_build_choices(target_path: Option<&Path>, reaper_choice: &Choice) { + let remembered = |package: &str| { + target_path.and_then(|path| rabbit_core::package::remembered_channel(path, package)) + }; + let on_dev = remembered(PACKAGE_REAPER).is_some_and(|channel| channel == REAPER_DEV_CHANNEL); + reaper_choice.set_selection(u32::from(on_dev)); + let pull_request = remembered(rabbit_core::package::PACKAGE_OSARA).and_then(|channel| { + match rabbit_core::package::split_channel(&channel) { + ("pr", Some(number)) => number.parse().ok(), + _ => None, + } + }); + OSARA_REMEMBERED_PULL_REQUEST.set(pull_request); + select_remembered_osara_build(); +} + +/// Select the pull request the target's OSARA was last installed from. +pub(crate) fn select_remembered_osara_build() { + select_osara_build(OSARA_REMEMBERED_PULL_REQUEST.get()); +} + +/// The pull request OSARA's build choice has selected (`None` = snapshots). +pub(crate) fn selected_osara_build() -> Option { + let choice = OSARA_BUILD_CHOICE.get()?; + let index = choice.get_selection()? as usize; + OSARA_PULL_REQUESTS.with(|list| list.borrow().get(index).copied().flatten()) +} + +/// Select pull request `wanted` (`None` = the regular snapshot) in OSARA's +/// build choice. A pull request missing from the list is added under its +/// number. The list only reaches back so far, and it can fail to load or not +/// have arrived yet. None of those may quietly take a tester off the build +/// they are on. If that build really is gone, resolution falls back to the +/// snapshot and the package row says so. +pub(crate) fn select_osara_build(wanted: Option) { + let Some(choice) = OSARA_BUILD_CHOICE.get() else { + return; + }; + let listed = + OSARA_PULL_REQUESTS.with(|list| list.borrow().iter().position(|number| *number == wanted)); + let index = match (listed, wanted) { + (Some(index), _) => index, + (None, None) => 0, + (None, Some(number)) => { + let number_text = number.to_string(); + let mut label = number_text.clone(); + with_ui_localizer(|localizer| { + label = localizer + .format( + "wizard-expert-osara-builds-pr-untitled", + &[("number", number_text.as_str())], + ) + .value; + }); + OSARA_PULL_REQUESTS.with(|list| { + let mut list = list.borrow_mut(); + let index = osara_build_insert_position(&list, number); + choice.insert(&label, index); + list.insert(index, Some(number)); + index + }) + } + }; + choice.set_selection(index as u32); +} + +/// Where pull request `number` belongs in OSARA's build list: after the +/// regular snapshot (always first) and every higher pull request number, so +/// one added under its number keeps the list's highest-first order. +pub(crate) fn osara_build_insert_position(list: &[Option], number: u32) -> usize { + list.iter() + .position(|listed| listed.is_some_and(|listed| listed < number)) + .unwrap_or(list.len()) +} + +/// List OSARA's pull requests that have a test build, in the background. +/// Until the list arrives, the choice says it is looking, which still means +/// "regular snapshots", and it already offers the pull request the target is +/// on, so moving on early keeps that build. +pub(crate) fn start_osara_build_listing(model: &WizardModel, widgets: &WizardWidgets) { + let choice = widgets.osara_build_choice; + OSARA_BUILD_CHOICE.set(Some(choice)); + OSARA_PULL_REQUESTS.with(|list| *list.borrow_mut() = vec![None]); + choice.clear(); + choice.append(&model.text.expert_osara_builds_loading); + choice.set_selection(0); + select_remembered_osara_build(); + let platform = model.platform; + std::thread::spawn(move || { + let result = rabbit_core::actions_artifact::pull_request_choices( + rabbit_core::package::PACKAGE_OSARA, + platform, + ) + .map_err(|error| error.to_string()); + wxdragon::call_after(Box::new(move || fill_osara_build_choice(result))); + }); +} + +pub(crate) fn fill_osara_build_choice( + result: std::result::Result, String>, +) { + let Some(choice) = OSARA_BUILD_CHOICE.get() else { + return; + }; + // Whatever is selected now stays selected. That is the remembered pull + // request, or whatever the user has picked since. A second listing (expert + // mode turned off and on again) must not undo their pick. + let keep = selected_osara_build(); + with_ui_localizer(|localizer| { + choice.clear(); + let mut numbers = vec![None]; + match &result { + Ok(builds) => { + choice.append(&localizer.text("wizard-expert-osara-builds-snapshot").value); + for build in builds { + let number = build.number.to_string(); + let label = match &build.title { + Some(title) => localizer.format( + "wizard-expert-osara-builds-pr", + &[("number", number.as_str()), ("title", title.as_str())], + ), + None => localizer.format( + "wizard-expert-osara-builds-pr-untitled", + &[("number", number.as_str())], + ), + }; + choice.append(&label.value); + numbers.push(Some(build.number)); + } + } + Err(error) => { + eprintln!("could not list OSARA pull request builds: {error}"); + choice.append( + &localizer + .text("wizard-expert-osara-builds-unavailable") + .value, + ); + } + } + OSARA_PULL_REQUESTS.with(|list| *list.borrow_mut() = numbers); + }); + select_osara_build(keep); +} + +/// Show or hide everything expert mode adds, and bring it up to date. +pub(crate) fn apply_expert_mode(model: &WizardModel, widgets: &WizardWidgets) { + let on = expert_mode(); + apply_window_title(model); + set_optional_choice_shown(&widgets.reaper_build_choice, REAPER_BUILDS_LABEL_NAME, on); + set_optional_choice_shown(&widgets.osara_build_choice, OSARA_BUILDS_LABEL_NAME, on); + if on { + let target = selected_target_row(model, widgets); + seed_build_choices( + target.as_ref().map(|target| target.path.as_path()), + &widgets.reaper_build_choice, + ); + start_osara_build_listing(model, widgets); + } +} + +/// Handle the expert-mode chord. It only switches on the target page, where +/// the build choices live: changing it later would leave a version check +/// that no longer matches the chosen builds. Anywhere else it explains +/// rather than doing nothing, because a blind user gets no other signal. +pub(crate) fn toggle_expert_mode(model: &WizardModel, widgets: &WizardWidgets, step: usize) { + let text = &model.text; + match step { + TARGET_STEP => {} + PROGRESS_STEP | DONE_STEP => { + show_expert_mode_message(&text.expert_first_page_title, &text.expert_busy_body); + return; + } + _ => { + show_expert_mode_message(&text.expert_first_page_title, &text.expert_first_page_body); + return; + } + } + // Focus stays where the user pressed the keys: the dialog hands it back + // when it closes. Only a build choice that is about to be hidden gives it + // up, to the REAPER installation choice above it, before it disappears. + if expert_mode() { + if widgets.reaper_build_choice.has_focus() || widgets.osara_build_choice.has_focus() { + widgets.target_choice.set_focus(); + } + EXPERT_MODE.store(false, Ordering::SeqCst); + apply_expert_mode(model, widgets); + show_expert_mode_message(&text.expert_disabled_title, &text.expert_disabled_body); + } else { + if !confirm_expert_mode(model) { + return; + } + EXPERT_MODE.store(true, Ordering::SeqCst); + apply_expert_mode(model, widgets); + } +} + +/// Ctrl+Shift+E, or Cmd+Shift+E on macOS (`cmd_down` is Ctrl elsewhere). +/// Alt/Option must NOT be down: Ctrl+Alt is AltGr on German and many other +/// layouts, and Ctrl+Option is the VoiceOver modifier. +pub(crate) fn is_expert_mode_chord(event: &Event) -> bool { + matches!(event.get_key_code(), Some(code) if code == 'E' as i32 || code == 'e' as i32) + && event.cmd_down() + && event.shift_down() + && !event.alt_down() +} + +/// Listen for the expert-mode chord on the whole window. wxEVT_CHAR_HOOK +/// reaches the top-level window before the focused control sees the key, +/// which is what lets one binding work wherever focus is. +pub(crate) fn bind_expert_mode_chord( + frame: &Frame, + model: &Arc, + widgets: WizardWidgets, + current_step: &Arc, +) { + let model = Arc::clone(model); + let current_step = Arc::clone(current_step); + frame.bind_internal(EventType::CHAR_HOOK, move |event| { + if is_expert_mode_chord(&event) { + event.skip(false); + toggle_expert_mode(&model, &widgets, current_step.load(Ordering::SeqCst)); + } else { + // Every other key carries on to the focused control. A hook that + // swallowed keys would break typing and arrowing everywhere. + event.skip(true); + } + }); +} + +#[cfg(test)] +mod tests { + #[test] + fn a_pull_request_added_later_keeps_the_list_highest_number_first() { + let list = [None, Some(1454), Some(1448), Some(1404)]; + assert_eq!(super::osara_build_insert_position(&list, 1460), 1); + assert_eq!(super::osara_build_insert_position(&list, 1450), 2); + assert_eq!(super::osara_build_insert_position(&list, 1300), 4); + // Only the snapshot so far (the list is still loading or failed). + assert_eq!(super::osara_build_insert_position(&[None], 1454), 1); + } +} diff --git a/crates/rabbit-ui-wxdragon/src/wx_app/globals.rs b/crates/rabbit-ui-wxdragon/src/wx_app/globals.rs new file mode 100644 index 0000000..ef2286f --- /dev/null +++ b/crates/rabbit-ui-wxdragon/src/wx_app/globals.rs @@ -0,0 +1,130 @@ +//! Process-wide handles the event callbacks need: the localizer, the top +//! frame, the post-install hook, and the version-check dispatcher. + +use std::cell::RefCell; +use std::rc::Rc; +use std::sync::atomic::{AtomicU64, Ordering}; + +use rabbit_core::localization::Localizer; + +use wxdragon::prelude::*; + +// FluentBundle is !Send, so we keep one Localizer instance per UI thread and +// have call_after bodies read it from this thread-local rather than capturing +// it through worker threads. The wxdragon event loop runs every call_after +// body on the same thread that initialised UI_LOCALIZER (the main thread). +thread_local! { + static UI_LOCALIZER: RefCell>> = const { RefCell::new(None) }; + /// Top-level wizard frame, stashed here so transient modal dialogs + /// (e.g., the once-per-session "RABBIT update available" prompt) can + /// parent themselves on the wizard window without `Frame` having to + /// ride inside `Send`-requiring `call_after` closures or the + /// `WizardWidgets` struct (which is captured by those closures). + /// `Frame` doesn't impl `Send` because its underlying pointer is + /// `*mut`; in practice we only ever access it on the UI thread, but + /// the type system won't accept that as a static promise. + static UI_FRAME: RefCell> = const { RefCell::new(None) }; + /// Post-install rescan hook. The install click handler arms this with a + /// closure that captures the UI-thread `Rc` shared state for + /// `package_rows`/`package_notes`/`can_install`. The wizard install + /// runs on a worker thread; its `call_after` success branch fires the + /// hook so the cached package state reflects what the just-completed + /// install left on disk. Lives in a thread-local because the + /// `Rc` it captures is `!Send` and can't ride inside the + /// `call_after` `Box`. + static POST_INSTALL_HOOK: RefCell>> = const { RefCell::new(None) }; +} + +pub(crate) fn install_ui_localizer(localizer: Localizer) { + UI_LOCALIZER.with(|cell| { + *cell.borrow_mut() = Some(Rc::new(localizer)); + }); +} + +pub(crate) fn with_ui_localizer(f: F) { + UI_LOCALIZER.with(|cell| { + if let Some(localizer) = cell.borrow().as_ref() { + f(localizer); + } + }); +} + +pub(crate) fn install_ui_frame(frame: Frame) { + UI_FRAME.with(|cell| { + *cell.borrow_mut() = Some(frame); + }); +} + +pub(crate) fn with_ui_frame(f: F) { + UI_FRAME.with(|cell| { + if let Some(frame) = cell.borrow().as_ref() { + f(frame); + } + }); +} + +pub(crate) fn arm_post_install_hook(callback: impl FnOnce() + 'static) { + POST_INSTALL_HOOK.with(|cell| { + *cell.borrow_mut() = Some(Box::new(callback)); + }); +} + +pub(crate) fn fire_post_install_hook() { + let callback = POST_INSTALL_HOOK.with(|cell| cell.borrow_mut().take()); + if let Some(callback) = callback { + callback(); + } +} + +/// Stages of the deferred latest-version fetch the wizard runs once the user +/// transitions Target → Packages. +pub(crate) enum VersionCheckEvent { + /// Per-package outcome: a fetched version plus the package's optional + /// What's-New notes, or an error message. + Result { + package_id: String, + /// (version, What's-New notes, the channel the version came from). + outcome: std::result::Result<(String, Option, Option), String>, + }, + /// Worker has finished iterating all packages — the UI should rebuild the + /// package list with the fetched data and re-enable interaction. + Finished, +} + +/// Dispatcher set up by the Target → Packages click handler so the +/// version-check worker's `call_after` posts can mutate UI-thread-only state +/// (Rc-based package_rows, package_notes, can_install) without violating Send. +pub(crate) type VersionCheckDispatcher = Box; + +thread_local! { + static VERSION_CHECK_DISPATCHER: RefCell> = + const { RefCell::new(None) }; +} + +pub(crate) fn install_version_check_dispatcher(dispatcher: VersionCheckDispatcher) { + VERSION_CHECK_DISPATCHER.with(|cell| { + *cell.borrow_mut() = Some(dispatcher); + }); +} + +/// Which version check is current. Each check tags its events with the +/// number it started under, and Back from the check's page moves the number +/// on, so a check that was left behind can neither advance the wizard nor mix +/// its results into the next one. It would carry the builds chosen before +/// Back, and those may since have changed (or expert mode been turned off). +pub(crate) static VERSION_CHECK_GENERATION: AtomicU64 = AtomicU64::new(0); + +pub(crate) fn abandon_version_check() { + VERSION_CHECK_GENERATION.fetch_add(1, Ordering::SeqCst); +} + +pub(crate) fn dispatch_version_check_event(generation: u64, event: VersionCheckEvent) { + if generation != VERSION_CHECK_GENERATION.load(Ordering::SeqCst) { + return; + } + VERSION_CHECK_DISPATCHER.with(|cell| { + if let Some(dispatcher) = cell.borrow_mut().as_mut() { + dispatcher(event); + } + }); +} diff --git a/crates/rabbit-ui-wxdragon/src/wx_app/packages_page.rs b/crates/rabbit-ui-wxdragon/src/wx_app/packages_page.rs new file mode 100644 index 0000000..351e713 --- /dev/null +++ b/crates/rabbit-ui-wxdragon/src/wx_app/packages_page.rs @@ -0,0 +1,78 @@ +//! The packages page. Windows drives a native `TreeCtrl` with real +//! checkboxes; everywhere else a `DataViewCtrl` with a toggle column. + +use std::cell::RefCell; +use std::rc::Rc; + +#[cfg(target_os = "windows")] +use wxdragon::widgets::treectrl::TreeCtrl; + +#[cfg(not(target_os = "windows"))] +use wxdragon::widgets::dataview::{CustomDataViewTreeModel, DataViewCtrl}; + +#[cfg(target_os = "windows")] +mod native; +#[cfg(target_os = "windows")] +pub(crate) mod native_tree_checkboxes; + +#[cfg(not(target_os = "windows"))] +mod dataview; + +#[cfg(target_os = "windows")] +use native::PackageItems; +#[cfg(target_os = "windows")] +pub(crate) use native::*; + +#[cfg(not(target_os = "windows"))] +pub(crate) use dataview::*; + +/// `wx/defs.h`: `WXK_SPACE = 32` (just the ASCII value). Kept around as a +/// fallback intercept on platforms without TVS_CHECKBOXES; on Windows the +/// native tree handles Space toggles internally. +#[allow(dead_code)] +pub(crate) const WXK_SPACE: i32 = 32; + +/// `wx/defs.h`: `WXK_RETURN = 13` (the ASCII value) and, from the keycode +/// enum that starts at `WXK_START = 300`, `WXK_NUMPAD_ENTER = 370`. +/// Redeclared here for the same reason as `WXK_SPACE` above. +pub(crate) const WXK_RETURN: i32 = 13; +pub(crate) const WXK_NUMPAD_ENTER: i32 = 370; + +/// Per-platform state handle that the orchestrator (run, button click +/// handlers, post-install hook, version-check dispatcher) holds onto and +/// passes through to `build_packages_page` / `refresh_package_checklist` / +/// `rebuild_package_list_widgets` without caring which widget is on the +/// page. On Windows it carries the live `TreeItemId`s for the native +/// TreeCtrl rows; elsewhere it carries the `CustomDataViewTreeModel` +/// handle so the refresh helpers can re-emit notifications and rebuild +/// the model's userdata in place. +#[cfg(target_os = "windows")] +pub(crate) type PackagesStateCell = Rc>; +#[cfg(not(target_os = "windows"))] +pub(crate) type PackagesStateCell = Rc>>; + +/// Type alias used by `WizardWidgets` for the package list widget itself. +/// Windows: native `wxTreeCtrl` (`SysTreeView32` underneath, with +/// `TVS_CHECKBOXES` enabled by `native_tree_checkboxes::enable_checkboxes`). +/// Non-Windows: `wxDataViewCtrl` driven by a `CustomDataViewTreeModel` +/// with a `DataViewToggleRenderer` for the checkbox column. +#[cfg(target_os = "windows")] +pub(crate) type PackagesView = TreeCtrl; +#[cfg(not(target_os = "windows"))] +pub(crate) type PackagesView = DataViewCtrl; + +/// Build the empty per-platform state container that lives for the +/// lifetime of the wizard. On Windows it starts with no leaf TreeItemIds +/// (populated during `build_packages_page`); on non-Windows it starts +/// with `None` for the model handle (populated immediately after the +/// model is constructed in `build_packages_page`). +pub(crate) fn new_packages_state() -> PackagesStateCell { + #[cfg(target_os = "windows")] + { + Rc::new(RefCell::new(PackageItems::empty())) + } + #[cfg(not(target_os = "windows"))] + { + Rc::new(RefCell::new(None)) + } +} diff --git a/crates/rabbit-ui-wxdragon/src/wx_app/packages_page/dataview.rs b/crates/rabbit-ui-wxdragon/src/wx_app/packages_page/dataview.rs new file mode 100644 index 0000000..9df6c8d --- /dev/null +++ b/crates/rabbit-ui-wxdragon/src/wx_app/packages_page/dataview.rs @@ -0,0 +1,1117 @@ +//! Non-Windows packages page: a `DataViewCtrl` tree with a toggle column, +//! backed by a hand-rolled model. + +use std::cell::{Cell, RefCell}; +use std::rc::Rc; + +use crate::{ + ConfigurationRow, OsaraKeymapChoice, PackageRow, WizardInstallOptions, WizardModel, + apply_checkbox_state_to_package_row, localizer_from_options, osara_keymap_note, + recompute_configuration_row_availability, +}; +use rabbit_core::plan::PlanActionKind; +use wxdragon::widgets::dataview::{ + CustomDataViewTreeModel, DataViewAlign, DataViewCellMode, DataViewColumn, DataViewColumnFlags, + DataViewCtrl, DataViewEventHandler, DataViewStyle, DataViewTextRenderer, + DataViewToggleRenderer, Variant, VariantType, +}; + +use wxdragon::prelude::*; + +use super::{PackagesStateCell, PackagesView, WXK_NUMPAD_ENTER, WXK_RETURN, WXK_SPACE}; + +use crate::wx_app::pages::{WizardPage, add_heading, add_label}; +use crate::wx_app::widgets::{ + REAPER_LANGUAGE_LABEL_NAME, SPANISH_VARIANT_LABEL_NAME, WizardWidgets, osara_keymap_choice, + package_details, sync_osara_keymap_widgets, sync_reaper_language_widget, + sync_spanish_variant_widget, +}; + +/// Identifies a row in the non-Windows `CustomDataViewTreeModel`. `Package` +/// carries the index into `package_rows`; `Group` is the synthetic +/// "Packages" parent under the invisible root. The `Box` storage +/// owned by `PackageTreeData` is heap-stable, so `*mut Node` pointers +/// passed across the FFI boundary as opaque item ids stay valid for the +/// model's lifetime. +#[derive(Clone, Copy, Debug)] +pub(crate) enum NodeKind { + /// The synthetic "Packages" parent under the invisible root. + PackagesGroup, + /// The synthetic "Additional software" parent, sibling of PackagesGroup. + /// Holds the `Additional`-category package leaves (Surge XT, app2clap, …). + AdditionalSoftwareGroup, + /// The synthetic "Language packs" parent, sibling of PackagesGroup. + /// Holds the `Language`-category package leaves (REAPER translations). + LanguageGroup, + /// A package leaf — index into `package_rows`. Parented under either + /// PackagesGroup or AdditionalSoftwareGroup depending on the row's + /// category; the index is into the full `package_rows` either way. + Package(usize), + /// The synthetic "Configuration" parent, sibling of PackagesGroup. + ConfigurationGroup, + /// A configuration-step leaf — index into `configuration_rows`. + Configuration(usize), +} + +#[derive(Debug)] +pub(crate) struct Node { + pub(crate) kind: NodeKind, +} + +/// Userdata stored inside the non-Windows `CustomDataViewTreeModel`. Owns +/// the heap-stable node objects we hand to wxDataView as item ids, and +/// holds clones of the shared `package_rows` and `configuration_rows` +/// Rcs so model callbacks can read row state without going through any +/// external lookup. +pub(crate) struct PackageTreeData { + pub(crate) rows: Rc>>, + pub(crate) configuration_rows: Rc>>, + pub(crate) packages_group_label: String, + pub(crate) additional_software_group_label: String, + pub(crate) language_group_label: String, + pub(crate) configuration_group_label: String, + pub(crate) packages_group_node: Box, + pub(crate) additional_software_group_node: Box, + pub(crate) language_group_node: Box, + pub(crate) package_nodes: Vec>, + pub(crate) configuration_group_node: Box, + pub(crate) configuration_nodes: Vec>, +} + +impl PackageTreeData { + pub(crate) fn new( + rows: Rc>>, + configuration_rows: Rc>>, + packages_group_label: String, + additional_software_group_label: String, + language_group_label: String, + configuration_group_label: String, + ) -> Self { + let package_len = rows.borrow().len(); + let package_nodes: Vec> = (0..package_len) + .map(|i| { + Box::new(Node { + kind: NodeKind::Package(i), + }) + }) + .collect(); + let configuration_len = configuration_rows.borrow().len(); + let configuration_nodes: Vec> = (0..configuration_len) + .map(|i| { + Box::new(Node { + kind: NodeKind::Configuration(i), + }) + }) + .collect(); + Self { + rows, + configuration_rows, + packages_group_label, + additional_software_group_label, + language_group_label, + configuration_group_label, + packages_group_node: Box::new(Node { + kind: NodeKind::PackagesGroup, + }), + additional_software_group_node: Box::new(Node { + kind: NodeKind::AdditionalSoftwareGroup, + }), + language_group_node: Box::new(Node { + kind: NodeKind::LanguageGroup, + }), + package_nodes, + configuration_group_node: Box::new(Node { + kind: NodeKind::ConfigurationGroup, + }), + configuration_nodes, + } + } + + pub(crate) fn packages_group_ptr(&self) -> *const Node { + self.packages_group_node.as_ref() + } + + pub(crate) fn additional_software_group_ptr(&self) -> *const Node { + self.additional_software_group_node.as_ref() + } + + pub(crate) fn language_group_ptr(&self) -> *const Node { + self.language_group_node.as_ref() + } + + /// Whether any package row is in the `Language` category — drives + /// whether the "Language packs" group node is exposed as a root child + /// (so it never renders empty). + pub(crate) fn has_language_packs(&self) -> bool { + self.rows + .borrow() + .iter() + .any(|r| r.category == rabbit_core::package::PackageCategory::Language) + } + + /// Whether any package row is in the `Additional` category — drives + /// whether the "Additional software" group node is exposed as a root + /// child (so it never renders empty). + pub(crate) fn has_additional_software(&self) -> bool { + self.rows + .borrow() + .iter() + .any(|r| r.category == rabbit_core::package::PackageCategory::Additional) + } + + /// The package leaf pointers whose row matches `category`, in row order. + /// Used to populate each group's children and to refresh just that + /// group's leaves after a toggle. + pub(crate) fn package_ptrs_in_category( + &self, + category: rabbit_core::package::PackageCategory, + ) -> Vec<*const Node> { + let rows = self.rows.borrow(); + self.package_nodes + .iter() + .enumerate() + .filter(|(i, _)| { + rows.get(*i) + .map(|r| r.category == category) + .unwrap_or(false) + }) + .map(|(_, node)| node.as_ref() as *const Node) + .collect() + } + + pub(crate) fn configuration_group_ptr(&self) -> *const Node { + self.configuration_group_node.as_ref() + } + + pub(crate) fn package_ptr(&self, idx: usize) -> *const Node { + self.package_nodes[idx].as_ref() + } + + pub(crate) fn configuration_ptr(&self, idx: usize) -> *const Node { + self.configuration_nodes[idx].as_ref() + } + + pub(crate) fn all_configuration_ptrs(&self) -> Vec<*const Node> { + self.configuration_nodes + .iter() + .map(|b| b.as_ref() as *const Node) + .collect() + } +} + +/// Model column indices for the non-Windows DataView path. +pub(crate) const PACKAGE_COL_TOGGLE: u32 = 0; +pub(crate) const PACKAGE_COL_LABEL: u32 = 1; + +// =========================================================================== +// Non-Windows: wxDataViewCtrl + CustomDataViewTreeModel. +// +// Windows is special-cased via `TVS_CHECKBOXES`; on macOS and GTK the +// equivalent native pattern is "outline view with a check column" — i.e. +// wxDataView with `DataViewToggleRenderer` over `VariantType::Bool`. The +// model carries one synthetic Group node + one leaf per `PackageRow`, the +// toggle column gets `Activatable` mode so Space + click both route through +// `set_value`, and `is_enabled` returns false for unavailable rows so the +// platform draws (and exposes) them as disabled. +// =========================================================================== + +/// Non-Windows: build the Packages page using a wxDataViewCtrl driven by a +/// `CustomDataViewTreeModel`. The model exposes a synthetic Packages group +/// + one leaf per `PackageRow`; column 0 is a Bool toggle, column 1 is the +/// row label (the column with the expander triangle). The model's +/// `set_value` callback owns all the toggle side effects. +pub(crate) fn build_packages_page( + page: &WizardPage, + model: &WizardModel, + package_rows: Rc>>, + configuration_rows: Rc>>, + package_items: PackagesStateCell, + can_install: Rc>, +) -> (PackagesView, TextCtrl, CheckBox, TextCtrl, Choice, Choice) { + let sizer = BoxSizer::builder(Orientation::Vertical).build(); + add_heading( + page, + &sizer, + &model.text.packages_heading, + "rabbit-packages-heading", + ); + add_label( + page, + &sizer, + &model.text.packages_list_label, + "rabbit-packages-list-label", + ); + + let tree = DataViewCtrl::builder(page) + .with_style(DataViewStyle::Single | DataViewStyle::RowLines | DataViewStyle::NoHeader) + .with_size(Size::new(-1, 220)) + .build(); + tree.set_name("rabbit-package-list"); + // Floor the list height so longer translations (the labels, checkbox, and + // notes below grow vertically in German/French) can never squeeze the + // proportion-1 list down to nothing. It still expands to fill free space. + tree.set_min_size(Size::new(-1, 160)); + + // The model is constructed BEFORE associate_model so wx's internal + // refcount stays sane. `package_items` (the model handle cell) gets + // populated immediately afterwards so set_value's notification path + // can find the model the next time the user toggles a row. + let tree_data = PackageTreeData::new( + Rc::clone(&package_rows), + Rc::clone(&configuration_rows), + model.text.packages_tree_group_label.clone(), + model.text.additional_software_tree_group_label.clone(), + model.text.language_tree_group_label.clone(), + model.text.configuration_tree_group_label.clone(), + ); + let side_widgets: PackagesSideWidgetsCell = Rc::new(RefCell::new(None)); + let dv_model = build_packages_tree_model( + tree_data, + Rc::clone(&package_rows), + Rc::clone(&configuration_rows), + Rc::clone(&package_items), + Rc::clone(&side_widgets), + Rc::clone(&can_install), + model.clone(), + ); + *package_items.borrow_mut() = Some(dv_model.clone()); + + let toggle_renderer = DataViewToggleRenderer::new( + VariantType::Bool, + DataViewCellMode::Activatable, + DataViewAlign::Center, + ); + let toggle_column = DataViewColumn::new( + "", + &toggle_renderer, + PACKAGE_COL_TOGGLE as usize, + 28, + DataViewAlign::Center, + DataViewColumnFlags::DefaultNone, + ); + tree.append_column(&toggle_column); + + let text_renderer = DataViewTextRenderer::new( + VariantType::String, + DataViewCellMode::Inert, + DataViewAlign::Left, + ); + let text_column = DataViewColumn::new( + "", + &text_renderer, + PACKAGE_COL_LABEL as usize, + -1, + DataViewAlign::Left, + DataViewColumnFlags::Resizable, + ); + tree.append_column(&text_column); + + tree.associate_model(&dv_model); + + expand_packages_group(&tree, &dv_model); + sizer.add(&tree, 1, SizerFlag::All | SizerFlag::Expand, 6); + + add_label( + page, + &sizer, + &model.text.package_details_label, + "rabbit-package-details-label", + ); + let initial_details = package_rows + .borrow() + .first() + .map(package_details) + .unwrap_or_default(); + let details = TextCtrl::builder(page) + .with_value(&initial_details) + .with_style(TextCtrlStyle::MultiLine | TextCtrlStyle::ReadOnly | TextCtrlStyle::WordWrap) + .with_size(Size::new(-1, 120)) + .build(); + details.set_name("rabbit-package-details"); + sizer.add(&details, 0, SizerFlag::All | SizerFlag::Expand, 6); + + add_label( + page, + &sizer, + &model.text.packages_osara_keymap_heading, + "rabbit-osara-keymap-heading", + ); + let osara_keymap_replace = CheckBox::builder(page) + .with_label(&model.text.packages_osara_keymap_replace_label) + .build(); + osara_keymap_replace.set_name(&model.text.packages_osara_keymap_replace_label); + osara_keymap_replace.set_label(&model.text.packages_osara_keymap_replace_label); + osara_keymap_replace.add_style(WindowStyle::TabStop); + osara_keymap_replace.set_value(matches!( + WizardInstallOptions::default().osara_keymap_choice, + OsaraKeymapChoice::ReplaceCurrent + )); + osara_keymap_replace.set_can_focus(false); + sizer.add( + &osara_keymap_replace, + 0, + SizerFlag::All | SizerFlag::Expand, + 6, + ); + + let osara_keymap_note = TextCtrl::builder(page) + .with_value(&model.text.packages_osara_keymap_unavailable_note) + .with_style(TextCtrlStyle::MultiLine | TextCtrlStyle::ReadOnly | TextCtrlStyle::WordWrap) + .with_size(Size::new(-1, 68)) + .build(); + osara_keymap_note.set_name("rabbit-osara-keymap-note"); + osara_keymap_note.enable(false); + osara_keymap_note.set_can_focus(false); + sizer.add(&osara_keymap_note, 0, SizerFlag::All | SizerFlag::Expand, 6); + + // Which installed language pack REAPER starts in. Several packs can be + // installed at once (REAPER keeps them all in LangPack/ and you can + // switch inside REAPER later), so this only picks the one active + // straight after installing. + add_label( + page, + &sizer, + &model.text.packages_reaper_language_label, + REAPER_LANGUAGE_LABEL_NAME, + ); + let reaper_language_choice = Choice::builder(page).build(); + reaper_language_choice.set_name(&model.text.packages_reaper_language_label); + sizer.add( + &reaper_language_choice, + 0, + SizerFlag::All | SizerFlag::Expand, + 6, + ); + + // Which of the two Spanish OSARA translations to install. Only the + // installed FILE NAME differs (es_ES vs es_MX) — that is what OSARA + // reads to pick its translation — so this is a plain either/or rather + // than a separate package. + add_label( + page, + &sizer, + &model.text.packages_spanish_variant_label, + SPANISH_VARIANT_LABEL_NAME, + ); + let spanish_variant_choice = Choice::builder(page).build(); + spanish_variant_choice.set_name(&model.text.packages_spanish_variant_label); + for label in &model.text.packages_spanish_variant_options { + spanish_variant_choice.append(label); + } + spanish_variant_choice.set_selection(0); + sizer.add( + &spanish_variant_choice, + 0, + SizerFlag::All | SizerFlag::Expand, + 6, + ); + + sync_osara_keymap_widgets( + model, + &package_rows.borrow(), + &osara_keymap_replace, + &osara_keymap_note, + ); + sync_spanish_variant_widget(&package_rows.borrow(), &spanish_variant_choice); + sync_reaper_language_widget(&package_rows.borrow(), &reaper_language_choice); + + // Hand the tree model its handles now that the widgets exist. All four + // types are `Copy`, so the moves into the event closures below still + // work. This runs before `page.set_sizer`, i.e. before the user can + // interact with anything. + *side_widgets.borrow_mut() = Some(PackagesSideWidgets { + osara_checkbox: osara_keymap_replace, + osara_note: osara_keymap_note, + spanish_choice: spanish_variant_choice, + language_choice: reaper_language_choice, + }); + + { + let package_rows = Rc::clone(&package_rows); + let model_text = model.clone(); + let osara_checkbox = osara_keymap_replace; + let osara_note = osara_keymap_note; + let spanish_checkbox = spanish_variant_choice; + let language_choice = reaper_language_choice; + tree.on_selection_changed(move |event| { + if let Some(item) = event.get_item() { + if let Some(node_ptr) = item.get_id::() { + if !node_ptr.is_null() { + // SAFETY: node_ptr originated from a Box + // owned by the model's userdata; the model lives + // for as long as this closure can fire. + let node = unsafe { &*node_ptr }; + if let NodeKind::Package(idx) = node.kind { + if let Some(value) = package_rows.borrow().get(idx).map(package_details) + { + details.set_value(&value); + } + } + } + } + } + sync_osara_keymap_widgets( + &model_text, + &package_rows.borrow(), + &osara_checkbox, + &osara_note, + ); + sync_spanish_variant_widget(&package_rows.borrow(), &spanish_checkbox); + sync_reaper_language_widget(&package_rows.borrow(), &language_choice); + }); + } + + { + let model_text = model.clone(); + let rows = Rc::clone(&package_rows); + let osara_checkbox = osara_keymap_replace; + let osara_note = osara_keymap_note; + let spanish_checkbox = spanish_variant_choice; + let language_choice = reaper_language_choice; + osara_keymap_replace.on_toggled(move |_| { + sync_osara_keymap_widgets(&model_text, &rows.borrow(), &osara_checkbox, &osara_note); + sync_spanish_variant_widget(&rows.borrow(), &spanish_checkbox); + sync_reaper_language_widget(&rows.borrow(), &language_choice); + }); + } + + page.set_sizer(sizer, true); + ( + tree, + details, + osara_keymap_replace, + osara_keymap_note, + spanish_variant_choice, + reaper_language_choice, + ) +} + +/// Non-Windows: the widgets that sit *below* the packages tree and whose +/// state depends on which packages are ticked. +/// +/// They cannot be captured by the model's `set_value` closure, because +/// creation order is tab order: the tree has to be created before them, and +/// the model is built with the tree. So they arrive afterwards through a +/// cell, exactly like the self-referential model handle in +/// `PackagesStateCell`. +#[derive(Clone, Copy)] +pub(crate) struct PackagesSideWidgets { + pub(crate) osara_checkbox: CheckBox, + pub(crate) osara_note: TextCtrl, + pub(crate) spanish_choice: Choice, + pub(crate) language_choice: Choice, +} + +pub(crate) type PackagesSideWidgetsCell = Rc>>; + +/// Non-Windows: build the `CustomDataViewTreeModel` that backs the packages +/// tree. The closures capture clones of `package_rows`, `package_items` +/// (the self-referential model handle cell), `can_install`, and the wizard +/// model, so `set_value` can mutate row state, fire item-changed +/// notifications and recompute downstream UI flags without going through +/// any external lookup. +pub(crate) fn build_packages_tree_model( + data: PackageTreeData, + rows: Rc>>, + configuration_rows: Rc>>, + model_cell: PackagesStateCell, + side_widgets: PackagesSideWidgetsCell, + can_install: Rc>, + wizard_model: WizardModel, +) -> CustomDataViewTreeModel { + type CompareFn = fn(&PackageTreeData, &Node, &Node, u32, bool) -> i32; + + let rows_for_get_value = Rc::clone(&rows); + let rows_for_set_value = Rc::clone(&rows); + let rows_for_is_enabled = Rc::clone(&rows); + let configuration_rows_for_get_value = Rc::clone(&configuration_rows); + let configuration_rows_for_set_value = Rc::clone(&configuration_rows); + let configuration_rows_for_is_enabled = Rc::clone(&configuration_rows); + let configuration_rows_for_recompute = Rc::clone(&configuration_rows); + let wizard_model_for_recompute = wizard_model.clone(); + let model_cell_for_set_value = Rc::clone(&model_cell); + let side_widgets_for_set_value = Rc::clone(&side_widgets); + + CustomDataViewTreeModel::new( + data, + // get_parent + |data: &PackageTreeData, item: Option<&Node>| -> Option<*mut Node> { + match item { + None => None, + Some(node) => match node.kind { + NodeKind::PackagesGroup + | NodeKind::AdditionalSoftwareGroup + | NodeKind::LanguageGroup + | NodeKind::ConfigurationGroup => None, + NodeKind::Package(idx) => { + let category = data + .rows + .borrow() + .get(idx) + .map(|r| r.category) + .unwrap_or_default(); + match category { + rabbit_core::package::PackageCategory::Additional => { + Some(data.additional_software_group_ptr() as *mut Node) + } + rabbit_core::package::PackageCategory::Language => { + Some(data.language_group_ptr() as *mut Node) + } + rabbit_core::package::PackageCategory::Core => { + Some(data.packages_group_ptr() as *mut Node) + } + } + } + NodeKind::Configuration(_) => Some(data.configuration_group_ptr() as *mut Node), + }, + } + }, + // is_container + |_data: &PackageTreeData, item: Option<&Node>| -> bool { + match item { + None => true, + Some(node) => matches!( + node.kind, + NodeKind::PackagesGroup + | NodeKind::AdditionalSoftwareGroup + | NodeKind::LanguageGroup + | NodeKind::ConfigurationGroup + ), + } + }, + // get_children + |data: &PackageTreeData, item: Option<&Node>| -> Vec<*mut Node> { + match item { + None => { + // Root children: Packages, then Additional software (only + // when populated, so it never shows empty), then + // Configuration. + let mut roots = vec![data.packages_group_ptr() as *mut Node]; + if data.has_additional_software() { + roots.push(data.additional_software_group_ptr() as *mut Node); + } + if data.has_language_packs() { + roots.push(data.language_group_ptr() as *mut Node); + } + roots.push(data.configuration_group_ptr() as *mut Node); + roots + } + Some(node) => match node.kind { + NodeKind::PackagesGroup => data + .package_ptrs_in_category(rabbit_core::package::PackageCategory::Core) + .into_iter() + .map(|p| p as *mut Node) + .collect(), + NodeKind::AdditionalSoftwareGroup => data + .package_ptrs_in_category(rabbit_core::package::PackageCategory::Additional) + .into_iter() + .map(|p| p as *mut Node) + .collect(), + NodeKind::LanguageGroup => data + .package_ptrs_in_category(rabbit_core::package::PackageCategory::Language) + .into_iter() + .map(|p| p as *mut Node) + .collect(), + NodeKind::ConfigurationGroup => data + .all_configuration_ptrs() + .into_iter() + .map(|p| p as *mut Node) + .collect(), + NodeKind::Package(_) | NodeKind::Configuration(_) => Vec::new(), + }, + } + }, + // get_value + move |data: &PackageTreeData, item: Option<&Node>, col: u32| -> Variant { + let Some(node) = item else { + return Variant::from_string(""); + }; + match node.kind { + NodeKind::PackagesGroup => { + if col == PACKAGE_COL_TOGGLE { + // Aggregate state: true only if every available row + // in this group's category is selected. The standard + // toggle renderer can't show a tristate, so a + // partially-selected group reads as unchecked. + let rows = rows_for_get_value.borrow(); + let mut any_available = false; + let all_checked = rows + .iter() + .filter(|r| { + r.category == rabbit_core::package::PackageCategory::Core + && r.available_for_target + }) + .inspect(|_| any_available = true) + .all(|r| r.selected); + Variant::from_bool(any_available && all_checked) + } else { + Variant::from_string(&data.packages_group_label) + } + } + NodeKind::AdditionalSoftwareGroup => { + if col == PACKAGE_COL_TOGGLE { + let rows = rows_for_get_value.borrow(); + let mut any_available = false; + let all_checked = rows + .iter() + .filter(|r| { + r.category == rabbit_core::package::PackageCategory::Additional + && r.available_for_target + }) + .inspect(|_| any_available = true) + .all(|r| r.selected); + Variant::from_bool(any_available && all_checked) + } else { + Variant::from_string(&data.additional_software_group_label) + } + } + NodeKind::LanguageGroup => { + if col == PACKAGE_COL_TOGGLE { + let rows = rows_for_get_value.borrow(); + let mut any_available = false; + let all_checked = rows + .iter() + .filter(|r| { + r.category == rabbit_core::package::PackageCategory::Language + && r.available_for_target + }) + .inspect(|_| any_available = true) + .all(|r| r.selected); + Variant::from_bool(any_available && all_checked) + } else { + Variant::from_string(&data.language_group_label) + } + } + NodeKind::Package(idx) => { + let rows = rows_for_get_value.borrow(); + let Some(row) = rows.get(idx) else { + return Variant::from_string(""); + }; + if col == PACKAGE_COL_TOGGLE { + Variant::from_bool(row.selected) + } else { + Variant::from_string(&row.summary) + } + } + NodeKind::ConfigurationGroup => { + if col == PACKAGE_COL_TOGGLE { + let cfg_rows = configuration_rows_for_get_value.borrow(); + let mut any_available = false; + let all_checked = cfg_rows + .iter() + // already-applied rows are excluded, exactly as + // compute_configuration_group_tristate does on + // Windows: they are forced unselected and would + // otherwise pin the group to "unchecked" forever. + .filter(|r| r.available_for_target && !r.already_applied) + .inspect(|_| any_available = true) + .all(|r| r.selected); + Variant::from_bool(any_available && all_checked) + } else { + Variant::from_string(&data.configuration_group_label) + } + } + NodeKind::Configuration(idx) => { + let cfg_rows = configuration_rows_for_get_value.borrow(); + let Some(row) = cfg_rows.get(idx) else { + return Variant::from_string(""); + }; + if col == PACKAGE_COL_TOGGLE { + Variant::from_bool(row.selected) + } else { + Variant::from_string(&row.summary) + } + } + } + }, + // set_value + Some( + move |data: &PackageTreeData, item: Option<&Node>, col: u32, var: &Variant| -> bool { + if col != PACKAGE_COL_TOGGLE { + return false; + } + let Some(node) = item else { + return false; + }; + let new_state = var.get_bool().unwrap_or(false); + + match node.kind { + NodeKind::PackagesGroup + | NodeKind::AdditionalSoftwareGroup + | NodeKind::LanguageGroup => { + // Group toggle propagates to every available leaf in + // this group's category; unavailable rows stay + // untouched so the install plan never carries + // something we can't honor. + let category = match node.kind { + NodeKind::AdditionalSoftwareGroup => { + rabbit_core::package::PackageCategory::Additional + } + NodeKind::LanguageGroup => { + rabbit_core::package::PackageCategory::Language + } + _ => rabbit_core::package::PackageCategory::Core, + }; + let mut rows = rows_for_set_value.borrow_mut(); + for row in rows.iter_mut() { + if row.category == category && row.available_for_target { + let _ = apply_checkbox_state_to_package_row( + &wizard_model, + row, + new_state, + ); + } + } + } + NodeKind::Package(idx) => { + let mut rows = rows_for_set_value.borrow_mut(); + let Some(row) = rows.get_mut(idx) else { + return false; + }; + if !row.available_for_target { + return false; + } + let _ = apply_checkbox_state_to_package_row(&wizard_model, row, new_state); + } + NodeKind::ConfigurationGroup => { + let mut cfg_rows = configuration_rows_for_set_value.borrow_mut(); + for row in cfg_rows.iter_mut() { + if row.available_for_target && !row.already_applied { + row.selected = new_state; + } + } + } + NodeKind::Configuration(idx) => { + let mut cfg_rows = configuration_rows_for_set_value.borrow_mut(); + let Some(row) = cfg_rows.get_mut(idx) else { + return false; + }; + if !row.available_for_target || row.already_applied { + return false; + } + row.selected = new_state; + } + } + + let any_install_or_update = rows_for_set_value.borrow().iter().any(|row| { + row.available_for_target + && matches!(row.action, PlanActionKind::Install | PlanActionKind::Update) + }); + can_install.set(any_install_or_update); + + // Recompute configuration row availability whenever a + // package toggle could have flipped a dependency state. + let recomputed_configuration = matches!( + node.kind, + NodeKind::PackagesGroup + | NodeKind::AdditionalSoftwareGroup + | NodeKind::LanguageGroup + | NodeKind::Package(_) + ); + if recomputed_configuration { + if let Ok(localizer) = + crate::localizer_from_options(&wizard_model_for_recompute.bootstrap_options) + { + let package_rows_snapshot = rows_for_set_value.borrow(); + let mut cfg_rows = configuration_rows_for_recompute.borrow_mut(); + // None for the resource-path argument: a package + // toggle can't change `reapack.ini`, so preserve + // each row's existing `already_applied` flag. + crate::recompute_configuration_row_availability( + &localizer, + &package_rows_snapshot, + None, + &mut cfg_rows, + ); + } + + // Same tail as the Windows `refresh_after_packages_toggle`: + // ticking a package can change which OSARA keymap note + // applies, whether the Spanish variant picker is usable, + // and — because it owns the dropdown's *contents*, not + // just its enabled state — what the REAPER-language + // dropdown offers. Without this, toggling a row that is + // already selected (Space, or clicking its checkbox) + // leaves all three stale, because `on_selection_changed` + // never fires. + if let Some(widgets) = *side_widgets_for_set_value.borrow() { + let rows = rows_for_set_value.borrow(); + sync_osara_keymap_widgets( + &wizard_model_for_recompute, + &rows, + &widgets.osara_checkbox, + &widgets.osara_note, + ); + sync_spanish_variant_widget(&rows, &widgets.spanish_choice); + sync_reaper_language_widget(&rows, &widgets.language_choice); + } + } + + // Push the cell changes back into the view. SetValue's + // true return only auto-refreshes the (item, col) we set; + // we also need to refresh the row's label cell (the action + // text flips Install/Update/Keep) and the parent group's + // aggregate cell. + if let Some(model) = model_cell_for_set_value.borrow().as_ref() { + match node.kind { + NodeKind::PackagesGroup => { + let parent_ptr = data.packages_group_ptr(); + let leaf_ptrs = data.package_ptrs_in_category( + rabbit_core::package::PackageCategory::Core, + ); + model.items_changed(&leaf_ptrs); + model.item_value_changed(parent_ptr, PACKAGE_COL_TOGGLE); + } + NodeKind::AdditionalSoftwareGroup => { + let parent_ptr = data.additional_software_group_ptr(); + let leaf_ptrs = data.package_ptrs_in_category( + rabbit_core::package::PackageCategory::Additional, + ); + model.items_changed(&leaf_ptrs); + model.item_value_changed(parent_ptr, PACKAGE_COL_TOGGLE); + } + NodeKind::LanguageGroup => { + let parent_ptr = data.language_group_ptr(); + let leaf_ptrs = data.package_ptrs_in_category( + rabbit_core::package::PackageCategory::Language, + ); + model.items_changed(&leaf_ptrs); + model.item_value_changed(parent_ptr, PACKAGE_COL_TOGGLE); + } + NodeKind::Package(idx) => { + let leaf_ptr = data.package_ptr(idx); + model.item_value_changed(leaf_ptr, PACKAGE_COL_LABEL); + // Refresh the aggregate cell of whichever group + // this package hangs under. + let category = data + .rows + .borrow() + .get(idx) + .map(|r| r.category) + .unwrap_or_default(); + let parent_ptr = match category { + rabbit_core::package::PackageCategory::Additional => { + data.additional_software_group_ptr() + } + rabbit_core::package::PackageCategory::Language => { + data.language_group_ptr() + } + rabbit_core::package::PackageCategory::Core => { + data.packages_group_ptr() + } + }; + model.item_value_changed(parent_ptr, PACKAGE_COL_TOGGLE); + } + NodeKind::ConfigurationGroup => { + let parent_ptr = data.configuration_group_ptr(); + let leaf_ptrs = data.all_configuration_ptrs(); + model.items_changed(&leaf_ptrs); + model.item_value_changed(parent_ptr, PACKAGE_COL_TOGGLE); + } + NodeKind::Configuration(idx) => { + let leaf_ptr = data.configuration_ptr(idx); + model.item_value_changed(leaf_ptr, PACKAGE_COL_LABEL); + model.item_value_changed( + data.configuration_group_ptr(), + PACKAGE_COL_TOGGLE, + ); + } + } + + if recomputed_configuration { + let cfg_leaf_ptrs = data.all_configuration_ptrs(); + model.items_changed(&cfg_leaf_ptrs); + model + .item_value_changed(data.configuration_group_ptr(), PACKAGE_COL_TOGGLE); + } + } + + true + }, + ), + // is_enabled — gray out the checkbox + label of unavailable rows. + Some( + move |_data: &PackageTreeData, item: Option<&Node>, _col: u32| -> bool { + let Some(node) = item else { + return true; + }; + match node.kind { + NodeKind::PackagesGroup + | NodeKind::AdditionalSoftwareGroup + | NodeKind::LanguageGroup + | NodeKind::ConfigurationGroup => true, + NodeKind::Package(idx) => rows_for_is_enabled + .borrow() + .get(idx) + .map(|row| row.available_for_target) + .unwrap_or(true), + NodeKind::Configuration(idx) => configuration_rows_for_is_enabled + .borrow() + .get(idx) + .map(|row| row.available_for_target && !row.already_applied) + .unwrap_or(true), + } + }, + ), + // compare — left at None semantically; the explicit type is needed + // because the closure-based `Option` pattern doesn't infer + // without it. + None::, + ) +} + +/// Non-Windows: expand both synthetic group nodes ("Packages" and +/// "Configuration") so all leaves are visible without an extra click. +/// Reads the group pointers from the model's userdata so the model +/// owns the canonical Node addresses. +pub(crate) fn expand_packages_group(tree: &PackagesView, model: &CustomDataViewTreeModel) { + let mut packages_group_ptr: *const Node = std::ptr::null(); + let mut additional_software_group_ptr: *const Node = std::ptr::null(); + let mut language_group_ptr: *const Node = std::ptr::null(); + let mut configuration_group_ptr: *const Node = std::ptr::null(); + model.with_userdata_mut::(|data| { + packages_group_ptr = data.packages_group_ptr(); + // Only expand the additional-software group when it's actually + // exposed as a root child; otherwise its pointer addresses a node + // the view never asked about. + if data.has_additional_software() { + additional_software_group_ptr = data.additional_software_group_ptr(); + } + // Same guard for the language-packs group. + if data.has_language_packs() { + language_group_ptr = data.language_group_ptr(); + } + configuration_group_ptr = data.configuration_group_ptr(); + }); + for ptr in [ + packages_group_ptr, + additional_software_group_ptr, + language_group_ptr, + configuration_group_ptr, + ] { + if ptr.is_null() { + continue; + } + let item = wxdragon::widgets::dataview::DataViewItem::from_id_ptr(ptr); + if item.is_ok() { + tree.expand(&item); + } + } +} + +/// Non-Windows: replace the row set inside the live +/// `CustomDataViewTreeModel`. Reuses the existing model + control +/// association so nothing has to be rewired; the model just gets new +/// userdata, then we tell the view that everything has changed via +/// `cleared()`. After cleared() the control re-queries the model for +/// visible items and the previously-selected row drops away (caller +/// resets `package_details` to the first row). +pub(crate) fn rebuild_packages_tree_model( + tree: &PackagesView, + package_items: &PackagesStateCell, + model: &WizardModel, + package_rows: &[PackageRow], + configuration_rows: &[ConfigurationRow], +) { + let Some(dv_model) = package_items.borrow().as_ref().cloned() else { + return; + }; + let packages_group_label = model.text.packages_tree_group_label.clone(); + let additional_software_group_label = model.text.additional_software_tree_group_label.clone(); + let language_group_label = model.text.language_tree_group_label.clone(); + let configuration_group_label = model.text.configuration_tree_group_label.clone(); + dv_model.with_userdata_mut::(|data| { + // Sync the shared Rc>>s in case the caller + // hasn't pre-replaced them (the post-install hook does, the + // version-check finish handler also does — be defensive in + // case a future caller forgets). + let pkg_len = package_rows.len(); + if data.rows.borrow().len() != pkg_len { + *data.rows.borrow_mut() = package_rows.to_vec(); + } + let cfg_len = configuration_rows.len(); + if data.configuration_rows.borrow().len() != cfg_len { + *data.configuration_rows.borrow_mut() = configuration_rows.to_vec(); + } + data.packages_group_label = packages_group_label; + data.additional_software_group_label = additional_software_group_label; + data.language_group_label = language_group_label; + data.configuration_group_label = configuration_group_label; + data.package_nodes = (0..pkg_len) + .map(|i| { + Box::new(Node { + kind: NodeKind::Package(i), + }) + }) + .collect(); + data.configuration_nodes = (0..cfg_len) + .map(|i| { + Box::new(Node { + kind: NodeKind::Configuration(i), + }) + }) + .collect(); + }); + dv_model.cleared(); + // wxDataViewCtrl auto-collapses the groups on Cleared; re-expand so + // the user sees the leaves immediately. + expand_packages_group(tree, &dv_model); +} + +#[allow(clippy::too_many_arguments)] // UI plumbing: one parameter per widget handle. +pub(crate) fn refresh_package_checklist( + tree: &PackagesView, + package_items: &PackagesStateCell, + details: &TextCtrl, + osara_keymap_replace: &CheckBox, + osara_keymap_note: &TextCtrl, + spanish_variant_choice: &Choice, + reaper_language_choice: &Choice, + model: &WizardModel, + rows: &[crate::PackageRow], + configuration_rows: &[ConfigurationRow], +) { + rebuild_packages_tree_model(tree, package_items, model, rows, configuration_rows); + details.set_value(&rows.first().map(package_details).unwrap_or_default()); + sync_osara_keymap_widgets(model, rows, osara_keymap_replace, osara_keymap_note); + sync_spanish_variant_widget(rows, spanish_variant_choice); + sync_reaper_language_widget(rows, reaper_language_choice); +} + +pub(crate) fn rebuild_package_list_widgets( + widgets: &WizardWidgets, + package_items: &PackagesStateCell, + model: &WizardModel, + package_rows: &[PackageRow], + configuration_rows: &[ConfigurationRow], +) { + rebuild_packages_tree_model( + &widgets.package_checklist, + package_items, + model, + package_rows, + configuration_rows, + ); + let initial = package_rows + .first() + .map(package_details) + .unwrap_or_default(); + widgets.package_details.set_value(&initial); +} + +/// Non-Windows twin of the tree-based helper: give the packages DataView +/// keyboard focus so a screen reader starts reading it from the top. +/// +/// Deliberately focus-only, NO selection. wxdragon's +/// `DataViewCtrl::select_row` fabricates a `wxDataViewItem` whose internal +/// pointer IS the integer `row + 1` — the encoding only virtual LIST models +/// use. This view is driven by a custom TREE model whose item ids are real +/// node pointers, so `select_row(0)` hands the native macOS port a garbage +/// `0x1` pointer to dereference and crashes the app (reported by macOS +/// users as a crash right after the first Next, when the version check +/// auto-advances onto this page). A freshly rebuilt DataView has no +/// selection anyway, so reading naturally starts at the top — the +/// caret-drift problem this helper fixes is native-Windows-tree-specific. +pub(crate) fn focus_packages_list_top(widgets: &WizardWidgets, _package_items: &PackagesStateCell) { + widgets.package_checklist.set_focus(); +} diff --git a/crates/rabbit-ui-wxdragon/src/wx_app/packages_page/native.rs b/crates/rabbit-ui-wxdragon/src/wx_app/packages_page/native.rs new file mode 100644 index 0000000..8fe344b --- /dev/null +++ b/crates/rabbit-ui-wxdragon/src/wx_app/packages_page/native.rs @@ -0,0 +1,1460 @@ +//! Windows packages page: a native `TreeCtrl` whose checkboxes come from +//! the Win32 control itself, so screen readers announce them. + +use std::cell::{Cell, RefCell}; +use std::rc::Rc; + +use crate::{ + ConfigurationRow, OsaraKeymapChoice, PackageRow, WizardInstallOptions, WizardModel, + apply_checkbox_state_to_package_row, localizer_from_options, + recompute_configuration_row_availability, +}; +use rabbit_core::plan::PlanActionKind; +use wxdragon::event::tree_events::TreeEventData; +use wxdragon::widgets::treectrl::{TreeCtrl, TreeCtrlStyle, TreeItemId}; + +use super::native_tree_checkboxes; +use wxdragon::prelude::*; + +use super::{PackagesStateCell, PackagesView, WXK_SPACE}; + +use crate::wx_app::pages::{WizardPage, add_heading, add_label}; +use crate::wx_app::widgets::{ + REAPER_LANGUAGE_LABEL_NAME, SPANISH_VARIANT_LABEL_NAME, WizardWidgets, package_details, + sync_osara_keymap_widgets, sync_reaper_language_widget, sync_spanish_variant_widget, +}; + +/// Live wxTreeItemId handles for both top-level groups in the Packages +/// tree — "Packages" with its package leaves, and "Configuration" with +/// its configuration-step leaves. Index `i` in each leaves vec +/// corresponds to index `i` in the matching `Vec` / +/// `Vec`. Kept in an `Rc` so the closures +/// that handle the state-image-click event, the LEFT_UP fallback, the +/// keyboard fallbacks, and the post-install / version-check rebuild +/// helpers can all reach the same TreeItemIds the populate routine +/// handed out. `TreeItemId` is not `Copy` (it owns a pointer with +/// custom Drop), so we can't store it on the `Copy`-derived +/// `WizardWidgets` directly. +pub(crate) struct PackageItems { + /// The "Packages" group node under the (hidden) virtual root. + /// Becomes `None` between `populate_packages_tree` calls; populated + /// immediately after each rebuild. + pub(crate) packages_group: Option, + /// The "Additional software" group node (Surge XT, app2clap, …), a + /// sibling of the Packages group. `None` when no package has the + /// `Additional` category — the group node isn't created then. + pub(crate) additional_software_group: Option, + /// The "Language packs" group node (REAPER translations), a sibling of + /// the Packages group. `None` when no package has the `Language` + /// category — the group node isn't created then. + pub(crate) language_group: Option, + /// One TreeItemId per package row, in the same order as `package_rows` + /// (covering BOTH the Packages and Additional-software groups). The leaf + /// for `package_rows[i]` is at `packages_leaves[i]` regardless of which + /// group node it visually hangs under, so leaf↔row index stays 1:1. + pub(crate) packages_leaves: Vec, + /// The "Configuration" group node sitting alongside the Packages + /// group under the virtual root. + pub(crate) configuration_group: Option, + /// One TreeItemId per configuration row, in the same order as + /// `configuration_rows`. + pub(crate) configuration_leaves: Vec, +} + +impl PackageItems { + pub(crate) fn empty() -> Self { + Self { + packages_group: None, + additional_software_group: None, + language_group: None, + packages_leaves: Vec::new(), + configuration_group: None, + configuration_leaves: Vec::new(), + } + } +} + +/// Re-render the package list after the deferred fetch repopulates +/// `package_rows`. Invoked on successful version check, just before the +/// auto-advance to the Packages step. Two implementations: Windows rebuilds +/// the native TreeCtrl from scratch; non-Windows mutates the DataView +/// model's userdata in place and emits a `cleared()` notification. +pub(crate) fn rebuild_package_list_widgets( + widgets: &WizardWidgets, + package_items: &PackagesStateCell, + model: &WizardModel, + package_rows: &[PackageRow], + configuration_rows: &[ConfigurationRow], +) { + populate_packages_tree( + &widgets.package_checklist, + package_items, + model, + package_rows, + configuration_rows, + ); + let initial = package_rows + .first() + .map(package_details) + .unwrap_or_default(); + widgets.package_details.set_value(&initial); +} + +/// Put keyboard focus on the packages list with the screen-reader caret on +/// its TOP-MOST entry — the "Packages" group header. After the list is +/// repopulated (delete-all + re-append + per-item check-state writes) the +/// native `wxTreeCtrl`'s caret ends up on an arbitrary row a few items +/// down, so a screen-reader user entering the page starts mid-list instead +/// of at the top. Called when the wizard auto-advances onto the packages +/// page; deliberate Back navigation is left alone so it keeps the user's +/// previous position. +pub(crate) fn focus_packages_list_top(widgets: &WizardWidgets, package_items: &PackagesStateCell) { + // Clone the item id and DROP the borrow before touching the tree: + // `select_item` dispatches the selection-changed handler synchronously + // on MSW, and that handler borrows `package_items` too. + let group = package_items.borrow().packages_group.clone(); + if let Some(group) = group { + let tree = &widgets.package_checklist; + tree.ensure_visible(&group); + tree.select_item(&group); + tree.set_focused_item(&group); + } + widgets.package_checklist.set_focus(); +} + +/// Windows-only: tear down the existing native tree and rebuild both +/// top-level groups ("Packages" and "Configuration") from +/// `package_rows` + `configuration_rows`. Each leaf gets its native +/// `TVS_CHECKBOXES` state set to match its row's `selected`; each +/// group gets a tristate reflecting its children's aggregate. +pub(crate) fn populate_packages_tree( + tree: &TreeCtrl, + package_items: &PackagesStateCell, + model: &WizardModel, + package_rows: &[PackageRow], + configuration_rows: &[ConfigurationRow], +) { + tree.delete_all_items(); + { + let mut items = package_items.borrow_mut(); + items.packages_group = None; + items.additional_software_group = None; + items.language_group = None; + items.packages_leaves.clear(); + items.configuration_group = None; + items.configuration_leaves.clear(); + } + + let Some(root) = tree.add_root("", None, None) else { + return; + }; + + // Package rows split into two sibling groups by category: "Packages" + // (Core — the REAPER accessibility stack) and "Additional software" + // (Surge XT, app2clap, … — extras not tied to REAPER itself). The + // Additional-software group node is only created when at least one + // Additional-category row exists, so it never renders empty. + let Some(packages_group) = + tree.append_item(&root, &model.text.packages_tree_group_label, None, None) + else { + return; + }; + let has_additional = package_rows + .iter() + .any(|row| row.category == rabbit_core::package::PackageCategory::Additional); + let additional_software_group = if has_additional { + tree.append_item( + &root, + &model.text.additional_software_tree_group_label, + None, + None, + ) + } else { + None + }; + let has_language = package_rows + .iter() + .any(|row| row.category == rabbit_core::package::PackageCategory::Language); + let language_group = if has_language { + tree.append_item(&root, &model.text.language_tree_group_label, None, None) + } else { + None + }; + // One leaf per row, in row order, parented under the group that matches + // the row's category. `packages_leaves[i]` stays the leaf for + // `package_rows[i]` regardless of which group it hangs under, so every + // leaf↔row index lookup elsewhere remains 1:1. + let mut packages_leaves = Vec::with_capacity(package_rows.len()); + for row in package_rows.iter() { + let parent = match row.category { + rabbit_core::package::PackageCategory::Additional => additional_software_group + .as_ref() + .unwrap_or(&packages_group), + rabbit_core::package::PackageCategory::Language => { + language_group.as_ref().unwrap_or(&packages_group) + } + rabbit_core::package::PackageCategory::Core => &packages_group, + }; + let label = format_row_label(&row.summary, row.selected); + if let Some(item) = tree.append_item(parent, &label, None, None) { + native_tree_checkboxes::set_check_state(tree.get_handle(), &item, row.selected); + packages_leaves.push(item); + } + } + // Tristate reflecting each group's available children's aggregate. + let packages_state = compute_packages_group_tristate(package_rows); + native_tree_checkboxes::set_check_state_tri(tree.get_handle(), &packages_group, packages_state); + if let Some(group) = additional_software_group.as_ref() { + let additional_state = compute_additional_software_group_tristate(package_rows); + native_tree_checkboxes::set_check_state_tri(tree.get_handle(), group, additional_state); + } + if let Some(group) = language_group.as_ref() { + let language_state = compute_language_group_tristate(package_rows); + native_tree_checkboxes::set_check_state_tri(tree.get_handle(), group, language_state); + } + + // Configuration group + leaves. Always created, even if no + // configuration rows are recommended for this run — keeps the tree + // shape stable so the user can find the section if/when it + // populates after a target switch or post-install rescan. + let Some(configuration_group) = tree.append_item( + &root, + &model.text.configuration_tree_group_label, + None, + None, + ) else { + return; + }; + let mut configuration_leaves = Vec::with_capacity(configuration_rows.len()); + for row in configuration_rows.iter() { + let label = format_row_label(&row.summary, row.selected); + if let Some(item) = tree.append_item(&configuration_group, &label, None, None) { + native_tree_checkboxes::set_check_state(tree.get_handle(), &item, row.selected); + configuration_leaves.push(item); + } + } + let configuration_state = compute_configuration_group_tristate(configuration_rows); + native_tree_checkboxes::set_check_state_tri( + tree.get_handle(), + &configuration_group, + configuration_state, + ); + + { + let mut items = package_items.borrow_mut(); + items.packages_group = Some(packages_group.clone()); + items.additional_software_group = additional_software_group.clone(); + items.language_group = language_group.clone(); + items.packages_leaves = packages_leaves; + items.configuration_group = Some(configuration_group.clone()); + items.configuration_leaves = configuration_leaves; + } + + tree.expand(&packages_group); + if let Some(group) = additional_software_group.as_ref() { + tree.expand(group); + } + if let Some(group) = language_group.as_ref() { + tree.expand(group); + } + tree.expand(&configuration_group); +} + +/// Windows-only: format a tree-row label. The native `TVS_CHECKBOXES` +/// style draws the checkbox for us, so this is currently just the summary +/// — kept as a single point so we can later add a status glyph or icon +/// without auditing every call site. +pub(crate) fn format_row_label(summary: &str, _selected: bool) -> String { + summary.to_string() +} + +/// Windows-only: aggregate the per-row `selected` flags into a tristate for +/// the synthetic "Packages" (Core) group node. Unavailable rows don't count +/// for either side because they can't enter the install plan and toggling +/// them is a no-op — we only look at the rows the user can actually flip. +pub(crate) fn compute_packages_group_tristate( + rows: &[crate::PackageRow], +) -> native_tree_checkboxes::TriState { + compute_package_category_tristate(rows, rabbit_core::package::PackageCategory::Core) +} + +/// Windows-only: the same aggregate for the "Additional software" group node. +pub(crate) fn compute_additional_software_group_tristate( + rows: &[crate::PackageRow], +) -> native_tree_checkboxes::TriState { + compute_package_category_tristate(rows, rabbit_core::package::PackageCategory::Additional) +} + +/// Windows-only: the same aggregate for the "Language packs" group node. +pub(crate) fn compute_language_group_tristate( + rows: &[crate::PackageRow], +) -> native_tree_checkboxes::TriState { + compute_package_category_tristate(rows, rabbit_core::package::PackageCategory::Language) +} + +/// Windows-only: tristate over the available rows of a single package +/// category, so each top-level group's checkbox reflects only its own +/// children. +pub(crate) fn compute_package_category_tristate( + rows: &[crate::PackageRow], + category: rabbit_core::package::PackageCategory, +) -> native_tree_checkboxes::TriState { + let mut any = false; + let mut all = true; + let mut any_checked = false; + for row in rows + .iter() + .filter(|r| r.category == category && r.available_for_target) + { + any = true; + if row.selected { + any_checked = true; + } else { + all = false; + } + } + if !any { + // No selectable rows in this category (e.g. everything's unavailable + // for this target). Render the group as unchecked rather than mixed — + // there's nothing to toggle. + return native_tree_checkboxes::TriState::Unchecked; + } + if all { + native_tree_checkboxes::TriState::Checked + } else if any_checked { + native_tree_checkboxes::TriState::Mixed + } else { + native_tree_checkboxes::TriState::Unchecked + } +} + +/// Windows-only: aggregate the per-row `selected` flags of every +/// actionable [`ConfigurationRow`] into a tristate for the synthetic +/// "Configuration" group node. Same convention as +/// `compute_packages_group_tristate` — unavailable rows AND +/// already-applied rows are excluded (the user can't toggle either), +/// and an empty actionable-set renders as Unchecked. +pub(crate) fn compute_configuration_group_tristate( + rows: &[crate::ConfigurationRow], +) -> native_tree_checkboxes::TriState { + let mut any = false; + let mut all = true; + let mut any_checked = false; + for row in rows + .iter() + .filter(|r| r.available_for_target && !r.already_applied) + { + any = true; + if row.selected { + any_checked = true; + } else { + all = false; + } + } + if !any { + return native_tree_checkboxes::TriState::Unchecked; + } + if all { + native_tree_checkboxes::TriState::Checked + } else if any_checked { + native_tree_checkboxes::TriState::Mixed + } else { + native_tree_checkboxes::TriState::Unchecked + } +} + +/// Windows: native `wxTreeCtrl` driving `SysTreeView32` with +/// `TVS_CHECKBOXES`. Each row exposes UIA Toggle pattern, screen readers +/// announce checked state, Space toggles natively. See +/// `native_tree_checkboxes` for the raw Win32 plumbing that flips the +/// style after wx has created the control. +pub(crate) fn build_packages_page( + page: &WizardPage, + model: &WizardModel, + package_rows: Rc>>, + configuration_rows: Rc>>, + package_items: PackagesStateCell, + can_install: Rc>, +) -> (PackagesView, TextCtrl, CheckBox, TextCtrl, Choice, Choice) { + let sizer = BoxSizer::builder(Orientation::Vertical).build(); + add_heading( + page, + &sizer, + &model.text.packages_heading, + "rabbit-packages-heading", + ); + add_label( + page, + &sizer, + &model.text.packages_list_label, + "rabbit-packages-list-label", + ); + + // wxTreeCtrl is a thin wrapper around the platform's native tree: + // SysTreeView32 on Windows, NSOutlineView on macOS, GtkTreeView on GTK. + // HasButtons + LinesAtRoot give the standard expand/collapse affordance; + // HideRoot keeps the synthetic root invisible so the "Packages" group + // appears as the top-level branch the user navigates first. + let tree = TreeCtrl::builder(page) + .with_style( + TreeCtrlStyle::HasButtons + | TreeCtrlStyle::LinesAtRoot + | TreeCtrlStyle::Single + | TreeCtrlStyle::HideRoot, + ) + .with_size(Size::new(-1, 220)) + .build(); + tree.set_name("rabbit-package-list"); + // Floor the list height so longer translations (the labels, checkbox, and + // notes below grow vertically in German/French) can never squeeze the + // proportion-1 list down to nothing. It still expands to fill free space. + tree.set_min_size(Size::new(-1, 160)); + + // Switch the underlying SysTreeView32 to TVS_CHECKBOXES so each tree + // row gets a real native checkbox — UIA exposes a Toggle pattern on + // each TreeItem, screen readers announce the checked state, Space + // toggles natively, and the visual is indistinguishable from File + // Explorer's "items to copy" tree. + native_tree_checkboxes::enable_checkboxes(tree.get_handle()); + + populate_packages_tree( + &tree, + &package_items, + model, + &package_rows.borrow(), + &configuration_rows.borrow(), + ); + sizer.add(&tree, 1, SizerFlag::All | SizerFlag::Expand, 6); + + add_label( + page, + &sizer, + &model.text.package_details_label, + "rabbit-package-details-label", + ); + let initial_details = package_rows + .borrow() + .first() + .map(package_details) + .unwrap_or_default(); + let details = TextCtrl::builder(page) + .with_value(&initial_details) + .with_style(TextCtrlStyle::MultiLine | TextCtrlStyle::ReadOnly | TextCtrlStyle::WordWrap) + .with_size(Size::new(-1, 120)) + .build(); + details.set_name("rabbit-package-details"); + sizer.add(&details, 0, SizerFlag::All | SizerFlag::Expand, 6); + + add_label( + page, + &sizer, + &model.text.packages_osara_keymap_heading, + "rabbit-osara-keymap-heading", + ); + let osara_keymap_replace = CheckBox::builder(page) + .with_label(&model.text.packages_osara_keymap_replace_label) + .build(); + osara_keymap_replace.set_name(&model.text.packages_osara_keymap_replace_label); + osara_keymap_replace.set_label(&model.text.packages_osara_keymap_replace_label); + osara_keymap_replace.add_style(WindowStyle::TabStop); + osara_keymap_replace.set_value(matches!( + WizardInstallOptions::default().osara_keymap_choice, + OsaraKeymapChoice::ReplaceCurrent + )); + osara_keymap_replace.set_can_focus(false); + sizer.add( + &osara_keymap_replace, + 0, + SizerFlag::All | SizerFlag::Expand, + 6, + ); + + let osara_keymap_note = TextCtrl::builder(page) + .with_value(&model.text.packages_osara_keymap_unavailable_note) + .with_style(TextCtrlStyle::MultiLine | TextCtrlStyle::ReadOnly | TextCtrlStyle::WordWrap) + .with_size(Size::new(-1, 68)) + .build(); + osara_keymap_note.set_name("rabbit-osara-keymap-note"); + osara_keymap_note.enable(false); + osara_keymap_note.set_can_focus(false); + sizer.add(&osara_keymap_note, 0, SizerFlag::All | SizerFlag::Expand, 6); + + // Which installed language pack REAPER starts in. Several packs can be + // installed at once (REAPER keeps them all in LangPack/ and you can + // switch inside REAPER later), so this only picks the one active + // straight after installing. + add_label( + page, + &sizer, + &model.text.packages_reaper_language_label, + REAPER_LANGUAGE_LABEL_NAME, + ); + let reaper_language_choice = Choice::builder(page).build(); + reaper_language_choice.set_name(&model.text.packages_reaper_language_label); + sizer.add( + &reaper_language_choice, + 0, + SizerFlag::All | SizerFlag::Expand, + 6, + ); + + // Which of the two Spanish OSARA translations to install. Only the + // installed FILE NAME differs (es_ES vs es_MX) — that is what OSARA + // reads to pick its translation — so this is a plain either/or rather + // than a separate package. + add_label( + page, + &sizer, + &model.text.packages_spanish_variant_label, + SPANISH_VARIANT_LABEL_NAME, + ); + let spanish_variant_choice = Choice::builder(page).build(); + spanish_variant_choice.set_name(&model.text.packages_spanish_variant_label); + for label in &model.text.packages_spanish_variant_options { + spanish_variant_choice.append(label); + } + spanish_variant_choice.set_selection(0); + sizer.add( + &spanish_variant_choice, + 0, + SizerFlag::All | SizerFlag::Expand, + 6, + ); + + sync_osara_keymap_widgets( + model, + &package_rows.borrow(), + &osara_keymap_replace, + &osara_keymap_note, + ); + sync_spanish_variant_widget(&package_rows.borrow(), &spanish_variant_choice); + sync_reaper_language_widget(&package_rows.borrow(), &reaper_language_choice); + + // Selection-change updates the package details text. The event fires + // when the focused row changes via mouse or arrow keys; we use the + // wxTreeItemId from the event to find the matching index in + // `package_items.leaves`. + { + let package_rows = Rc::clone(&package_rows); + let configuration_rows = Rc::clone(&configuration_rows); + let package_items = Rc::clone(&package_items); + let model_text = model.clone(); + let osara_checkbox = osara_keymap_replace; + let osara_note = osara_keymap_note; + let spanish_checkbox = spanish_variant_choice; + let language_choice = reaper_language_choice; + tree.on_selection_changed(move |event| { + if let Some(item) = event.get_item() { + match classify_leaf(&package_items.borrow(), &item) { + Some(WhichLeaf::Packages(idx)) => { + if let Some(value) = package_rows.borrow().get(idx).map(package_details) { + details.set_value(&value); + } + } + Some(WhichLeaf::Configuration(idx)) => { + if let Some(row) = configuration_rows.borrow().get(idx) { + details.set_value(&row.details); + } + } + None => {} + } + } + sync_osara_keymap_widgets( + &model_text, + &package_rows.borrow(), + &osara_checkbox, + &osara_note, + ); + sync_spanish_variant_widget(&package_rows.borrow(), &spanish_checkbox); + sync_reaper_language_widget(&package_rows.borrow(), &language_choice); + }); + } + + // Native checkbox toggle handling for LEAVES: SysTreeView32 fires + // `wxEVT_TREE_STATE_IMAGE_CLICK` whenever the user activates the + // checkbox area of a leaf item — both mouse click and Space go through + // the same notification. The typed `TreeEvents` trait doesn't expose + // this variant, so we bind the raw `EventType::TREE_STATE_IMAGE_CLICK` + // ourselves. + { + let tree_widget = tree; + let package_rows = Rc::clone(&package_rows); + let configuration_rows = Rc::clone(&configuration_rows); + let package_items = Rc::clone(&package_items); + let can_install = Rc::clone(&can_install); + let wizard_model = model.clone(); + let osara_checkbox = osara_keymap_replace; + let osara_note = osara_keymap_note; + let spanish_checkbox = spanish_variant_choice; + let language_choice = reaper_language_choice; + tree.bind_internal(EventType::TREE_STATE_IMAGE_CLICK, move |event| { + handle_native_checkbox_toggle( + &tree_widget, + &package_items, + &package_rows, + &configuration_rows, + &can_install, + &wizard_model, + &details, + &osara_checkbox, + &osara_note, + &spanish_checkbox, + &language_choice, + TreeEventData::new(event).get_item(), + ); + }); + } + + // Pre-empt mouse clicks on not-actionable leaves' state icons — + // unavailable packages, already-applied / unavailable configuration + // steps. SysTreeView32 cycles TVS_CHECKBOXES on WM_LBUTTONDOWN + // (which gives the click immediate visual feedback); without this + // pre-empt, the click flips the state image and the accessibility + // layer announces "checked", and we then have to revert in the + // TREE_STATE_IMAGE_CLICK path — the user sees a flicker plus a + // doubled screen-reader announcement. Eating the event before + // native sees it keeps the row's state stable. We deliberately + // do NOT pre-empt clicks on the parent group's state icon here + // (the LEFT_UP handler below handles those — we still want the + // native side effects of focus/selection to run on a parent + // click). + { + let tree_widget = tree; + let package_rows = Rc::clone(&package_rows); + let configuration_rows = Rc::clone(&configuration_rows); + let package_items = Rc::clone(&package_items); + tree.on_mouse_left_down(move |event| { + let WindowEventData::MouseButton(mb) = &event else { + return; + }; + let Some(pos) = mb.get_position() else { return }; + let hwnd = tree_widget.get_handle(); + let (flags, h_item) = native_tree_checkboxes::hit_test(hwnd, pos.x, pos.y); + if (flags & native_tree_checkboxes::TVHT_ONITEMSTATEICON) == 0 || h_item.is_null() { + return; + } + let items = package_items.borrow(); + let blocked = items + .packages_leaves + .iter() + .position(|leaf| native_tree_handle(leaf) == h_item) + .and_then(|idx| package_rows.borrow().get(idx).cloned()) + .map(|row| !row.available_for_target) + .or_else(|| { + items + .configuration_leaves + .iter() + .position(|leaf| native_tree_handle(leaf) == h_item) + .and_then(|idx| configuration_rows.borrow().get(idx).cloned()) + .map(|row| !row.available_for_target || row.already_applied) + }) + .unwrap_or(false); + drop(items); + if blocked { + event.skip(false); + } + }); + } + + // Parent-group toggle fallback: wxEVT_TREE_STATE_IMAGE_CLICK doesn't + // fire reliably for the parent group (the native control's auto-cycle + // on state image 3 doesn't propagate to wx in our setup), so we hit- + // test on every left-button release and propagate manually if the + // click landed on the group's state icon. Leaves are still handled + // by the TREE_STATE_IMAGE_CLICK binding above; this handler ignores + // them (see the early-return on `is_group` inside). + { + let tree_widget = tree; + let package_rows = Rc::clone(&package_rows); + let configuration_rows = Rc::clone(&configuration_rows); + let package_items = Rc::clone(&package_items); + let can_install = Rc::clone(&can_install); + let wizard_model = model.clone(); + let osara_checkbox = osara_keymap_replace; + let osara_note = osara_keymap_note; + let spanish_checkbox = spanish_variant_choice; + let language_choice = reaper_language_choice; + tree.on_mouse_left_up(move |event| { + if let WindowEventData::MouseButton(mb) = &event + && let Some(pos) = mb.get_position() + { + handle_packages_left_up( + &tree_widget, + &package_items, + &package_rows, + &configuration_rows, + &can_install, + &wizard_model, + &details, + &osara_checkbox, + &osara_note, + &spanish_checkbox, + &language_choice, + pos, + ); + } + event.skip(true); + }); + } + + // Keyboard parent-toggle: Space on the parent group needs to + // propagate just like a mouse click on its checkbox. Native + // TVS_CHECKBOXES auto-cycles state on Space too, but its NM_CLICK + // → wxEVT_TREE_STATE_IMAGE_CLICK dispatch has the same parent-skip + // problem we hit with the mouse, so we intercept Space at key-down + // time, propagate, and consume the event so the native cycle + // doesn't get a chance to leave the parent in some weird half-state. + { + let tree_widget = tree; + let package_rows = Rc::clone(&package_rows); + let configuration_rows = Rc::clone(&configuration_rows); + let package_items = Rc::clone(&package_items); + let can_install = Rc::clone(&can_install); + let wizard_model = model.clone(); + let osara_checkbox = osara_keymap_replace; + let osara_note = osara_keymap_note; + let spanish_checkbox = spanish_variant_choice; + let language_choice = reaper_language_choice; + tree.on_key_down(move |event| { + let key_code = if let WindowEventData::Keyboard(kbd) = &event { + kbd.get_key_code() + } else { + None + }; + if key_code != Some(WXK_SPACE) { + return; + } + let Some(focused) = tree_widget.get_selection() else { + return; + }; + // Pre-empt Space on a leaf the user can't toggle — + // unavailable packages, or already-applied / unavailable + // configuration steps. If we let native TVS_CHECKBOXES + // see the keystroke, it cycles the state image (and the + // accessibility layer announces "checked"), and our key-up + // handler then has to revert. The user experiences that + // as a flicker plus a doubled screen-reader announcement. + // Consuming the event here keeps the row's state stable. + if let Some(leaf) = classify_leaf(&package_items.borrow(), &focused) { + let blocked = match leaf { + WhichLeaf::Packages(idx) => package_rows + .borrow() + .get(idx) + .is_some_and(|row| !row.available_for_target), + WhichLeaf::Configuration(idx) => configuration_rows + .borrow() + .get(idx) + .is_some_and(|row| !row.available_for_target || row.already_applied), + }; + if blocked { + event.skip(false); + return; + } + // Actionable leaf: let native cycle run; on_key_up + // reconciles row.selected with the post-cycle state. + return; + } + let group = classify_group(&package_items.borrow(), &focused); + let Some(group) = group else { + return; + }; + propagate_group_toggle_to_leaves( + &tree_widget, + &package_items, + group, + &package_rows, + &configuration_rows, + &wizard_model, + ); + refresh_after_packages_toggle( + &tree_widget, + &package_items, + &package_rows, + &configuration_rows, + &can_install, + &wizard_model, + &details, + &osara_checkbox, + &osara_note, + &spanish_checkbox, + &language_choice, + ); + // Consume the event so the native control doesn't *also* + // toggle the parent's state image after us. + event.skip(false); + }); + } + + // Keyboard leaf-toggle: wxEVT_TREE_STATE_IMAGE_CLICK fires only off + // NM_CLICK (mouse), not for keyboard Space — the native control's + // TVS_CHECKBOXES auto-cycle on Space flips the visual but doesn't + // route through any wx event we can hook. Without this handler, a + // Space toggle on a leaf updates the visual but never updates + // `package_rows`, leaving the row out of sync with the checkbox + // and (as a knock-on) the parent's tristate stuck on whatever it + // was before. We bind KEY_UP because by then the native auto-cycle + // has already run, so reading `get_check_state` gives us the new + // post-cycle state. + { + let tree_widget = tree; + let package_rows = Rc::clone(&package_rows); + let configuration_rows = Rc::clone(&configuration_rows); + let package_items = Rc::clone(&package_items); + let can_install = Rc::clone(&can_install); + let wizard_model = model.clone(); + let osara_checkbox = osara_keymap_replace; + let osara_note = osara_keymap_note; + let spanish_checkbox = spanish_variant_choice; + let language_choice = reaper_language_choice; + tree.on_key_up(move |event| { + let key_code = if let WindowEventData::Keyboard(kbd) = &event { + kbd.get_key_code() + } else { + None + }; + if key_code != Some(WXK_SPACE) { + return; + } + let Some(focused) = tree_widget.get_selection() else { + return; + }; + // Parent Space is already handled in on_key_down (which + // consumed the event before native processing); only + // leaves need post-cycle reconciliation here. + if classify_group(&package_items.borrow(), &focused).is_some() { + return; + } + let leaf = classify_leaf(&package_items.borrow(), &focused); + let new_state = + native_tree_checkboxes::get_check_state(tree_widget.get_handle(), &focused); + match leaf { + Some(WhichLeaf::Packages(idx)) => { + let unavailable = package_rows + .borrow() + .get(idx) + .is_some_and(|row| !row.available_for_target); + if unavailable { + native_tree_checkboxes::set_check_state( + tree_widget.get_handle(), + &focused, + false, + ); + return; + } + if let Some(row) = package_rows.borrow_mut().get_mut(idx) { + let _ = apply_checkbox_state_to_package_row(&wizard_model, row, new_state); + } + if let Some(row) = package_rows.borrow().get(idx) { + let label = format_row_label(&row.summary, row.selected); + tree_widget.set_item_text(&focused, &label); + } + } + Some(WhichLeaf::Configuration(idx)) => { + let not_actionable = configuration_rows + .borrow() + .get(idx) + .is_some_and(|row| !row.available_for_target || row.already_applied); + if not_actionable { + native_tree_checkboxes::set_check_state( + tree_widget.get_handle(), + &focused, + false, + ); + return; + } + if let Some(row) = configuration_rows.borrow_mut().get_mut(idx) { + row.selected = new_state; + } + } + None => return, + } + refresh_after_packages_toggle( + &tree_widget, + &package_items, + &package_rows, + &configuration_rows, + &can_install, + &wizard_model, + &details, + &osara_checkbox, + &osara_note, + &spanish_checkbox, + &language_choice, + ); + }); + } + + // Enter / double-click handler — Enter on the parent group needs to + // propagate the toggle to all leaves (the native control fires + // wxEVT_TREE_ITEM_ACTIVATED for Enter, regardless of TVS_CHECKBOXES). + // + // We deliberately ignore the leaf case here: wxMSW also dispatches + // ITEM_ACTIVATED for Space on a focused leaf, and toggling the leaf + // here would race with the TREE_STATE_IMAGE_CLICK leaf path (the + // native auto-cycle has already flipped the state image, our + // STATE_IMAGE_CLICK handler reads + applies the new state, and a + // second flip from this handler would then leave the row out of + // sync with the visual). Space + click on leaves continue to work + // through the existing TREE_STATE_IMAGE_CLICK binding; Enter on a + // leaf is intentionally a no-op. + { + let tree_widget = tree; + let package_rows = Rc::clone(&package_rows); + let configuration_rows = Rc::clone(&configuration_rows); + let package_items = Rc::clone(&package_items); + let can_install = Rc::clone(&can_install); + let wizard_model = model.clone(); + let osara_checkbox = osara_keymap_replace; + let osara_note = osara_keymap_note; + let spanish_checkbox = spanish_variant_choice; + let language_choice = reaper_language_choice; + tree.on_item_activated(move |event| { + let Some(item) = event.get_item() else { + return; + }; + let Some(group) = classify_group(&package_items.borrow(), &item) else { + return; + }; + propagate_group_toggle_to_leaves( + &tree_widget, + &package_items, + group, + &package_rows, + &configuration_rows, + &wizard_model, + ); + refresh_after_packages_toggle( + &tree_widget, + &package_items, + &package_rows, + &configuration_rows, + &can_install, + &wizard_model, + &details, + &osara_checkbox, + &osara_note, + &spanish_checkbox, + &language_choice, + ); + }); + } + + { + let model_text = model.clone(); + let rows = Rc::clone(&package_rows); + let osara_checkbox = osara_keymap_replace; + let osara_note = osara_keymap_note; + let spanish_checkbox = spanish_variant_choice; + let language_choice = reaper_language_choice; + osara_keymap_replace.on_toggled(move |_| { + sync_osara_keymap_widgets(&model_text, &rows.borrow(), &osara_checkbox, &osara_note); + sync_spanish_variant_widget(&rows.borrow(), &spanish_checkbox); + sync_reaper_language_widget(&rows.borrow(), &language_choice); + }); + } + + page.set_sizer(sizer, true); + ( + tree, + details, + osara_keymap_replace, + osara_keymap_note, + spanish_variant_choice, + reaper_language_choice, + ) +} + +/// Windows-only: which top-level group a tree item belongs to, if any. +/// Used by the toggle handlers to dispatch on Packages-vs-Configuration +/// without rebuilding the HTREEITEM-comparison plumbing at each call. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum WhichGroup { + Packages, + AdditionalSoftware, + Language, + Configuration, +} + +/// Windows-only: a leaf's owning group + its row index. Mirrors the +/// shape of the row vec the index applies to, so callers can reach into +/// the right `Vec` / `Vec` without an +/// extra branch. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum WhichLeaf { + Packages(usize), + Configuration(usize), +} + +/// Windows-only: identify which top-level group node a tree item is. +/// Returns `None` for leaves and for the (hidden) virtual root. Compares +/// via the native `HTREEITEM` (`m_pItem`) because wxdragon's +/// `TreeItemId` wraps a fresh allocation per event call — pointer +/// equality on the Rust wrappers wouldn't match our stored handles. +pub(crate) fn classify_group(items: &PackageItems, candidate: &TreeItemId) -> Option { + let candidate_handle = native_tree_handle(candidate); + if candidate_handle.is_null() { + return None; + } + if items + .packages_group + .as_ref() + .is_some_and(|group| native_tree_handle(group) == candidate_handle) + { + return Some(WhichGroup::Packages); + } + if items + .additional_software_group + .as_ref() + .is_some_and(|group| native_tree_handle(group) == candidate_handle) + { + return Some(WhichGroup::AdditionalSoftware); + } + if items + .language_group + .as_ref() + .is_some_and(|group| native_tree_handle(group) == candidate_handle) + { + return Some(WhichGroup::Language); + } + if items + .configuration_group + .as_ref() + .is_some_and(|group| native_tree_handle(group) == candidate_handle) + { + return Some(WhichGroup::Configuration); + } + None +} + +/// Windows-only: identify which group's leaves a tree item belongs to, +/// and at which index within that vec. Returns `None` for the group +/// nodes themselves, the hidden root, and items that aren't part of +/// our current row sets. +pub(crate) fn classify_leaf(items: &PackageItems, candidate: &TreeItemId) -> Option { + let candidate_handle = native_tree_handle(candidate); + if candidate_handle.is_null() { + return None; + } + if let Some(idx) = items + .packages_leaves + .iter() + .position(|stored| native_tree_handle(stored) == candidate_handle) + { + return Some(WhichLeaf::Packages(idx)); + } + if let Some(idx) = items + .configuration_leaves + .iter() + .position(|stored| native_tree_handle(stored) == candidate_handle) + { + return Some(WhichLeaf::Configuration(idx)); + } + None +} + +/// Windows-only: read the native `HTREEITEM` behind a wxdragon +/// `TreeItemId`. SAFETY contract is the same as `native_tree_checkboxes`: +/// `TreeItemId` is a single-field `repr(Rust)` wrapper around +/// `*mut wxd_TreeItemId_t`, and that pointer is a `reinterpret_cast` of +/// `wxTreeItemId*` which holds a single `void* m_pItem` member. +pub(crate) fn native_tree_handle(item: &TreeItemId) -> *mut std::ffi::c_void { + if !item.is_ok() { + return std::ptr::null_mut(); + } + // Read the wrapper's private `ptr` field by transmuting `&TreeItemId` + // into a borrow of its inner pointer. + let inner: *mut std::ffi::c_void = unsafe { std::mem::transmute_copy(item) }; + if inner.is_null() { + return std::ptr::null_mut(); + } + unsafe { *(inner as *const *mut std::ffi::c_void) } +} + +/// Windows-only: handle a `wxEVT_TREE_STATE_IMAGE_CLICK` for a leaf row +/// in either the Packages or the Configuration group. +/// +/// Parent-group state-icon clicks are intentionally NOT handled here — +/// they're routed through the dedicated `LEFT_UP` + hit-test fallback +/// (`handle_packages_left_up`) because wxMSW's NM_CLICK → +/// wxEVT_TREE_STATE_IMAGE_CLICK dispatch isn't reliable for parent items +/// in our setup (the native auto-cycle sees state image index 3 and may +/// not propagate the event). +/// +/// For leaves: `TVS_CHECKBOXES` has already flipped the state image by +/// the time this event fires, so we read the post-click state from the +/// native control rather than computing it ourselves. Package leaves +/// route through `apply_checkbox_state_to_package_row` (so action labels +/// flip Install/Update/Keep); configuration leaves just toggle +/// `row.selected`. +#[allow(clippy::too_many_arguments)] +pub(crate) fn handle_native_checkbox_toggle( + tree: &TreeCtrl, + package_items: &PackagesStateCell, + package_rows: &Rc>>, + configuration_rows: &Rc>>, + can_install: &Rc>, + wizard_model: &WizardModel, + details: &TextCtrl, + osara_checkbox: &CheckBox, + osara_note: &TextCtrl, + spanish_checkbox: &Choice, + language_choice: &Choice, + item: Option, +) { + let Some(item) = item else { + return; + }; + + // Parent-group clicks defer to the LEFT_UP handler. + if classify_group(&package_items.borrow(), &item).is_some() { + return; + } + + let leaf = classify_leaf(&package_items.borrow(), &item); + let new_state = native_tree_checkboxes::get_check_state(tree.get_handle(), &item); + match leaf { + Some(WhichLeaf::Packages(idx)) => { + let unavailable = package_rows + .borrow() + .get(idx) + .is_some_and(|row| !row.available_for_target); + if unavailable { + native_tree_checkboxes::set_check_state(tree.get_handle(), &item, false); + return; + } + if let Some(row) = package_rows.borrow_mut().get_mut(idx) { + let _ = apply_checkbox_state_to_package_row(wizard_model, row, new_state); + } + if let Some(row) = package_rows.borrow().get(idx) { + let label = format_row_label(&row.summary, row.selected); + tree.set_item_text(&item, &label); + } + } + Some(WhichLeaf::Configuration(idx)) => { + let not_actionable = configuration_rows + .borrow() + .get(idx) + .is_some_and(|row| !row.available_for_target || row.already_applied); + if not_actionable { + native_tree_checkboxes::set_check_state(tree.get_handle(), &item, false); + return; + } + if let Some(row) = configuration_rows.borrow_mut().get_mut(idx) { + row.selected = new_state; + } + // Configuration row labels don't include action text, so no + // re-format is needed; the row's `summary` already matches + // `display_name`. + } + None => return, + } + + refresh_after_packages_toggle( + tree, + package_items, + package_rows, + configuration_rows, + can_install, + wizard_model, + details, + osara_checkbox, + osara_note, + spanish_checkbox, + language_choice, + ); +} + +/// Windows-only: hit-test a `wxEVT_LEFT_UP` mouse-up against the tree. +/// If the click landed on either group's state icon, propagate the +/// toggle to that group's available leaves. The native control may +/// have auto-cycled the parent's image to a state that disagrees with +/// the row aggregate, but we always rewrite the parent state via +/// `set_check_state_tri` at the end so the visual matches the data. +/// +/// We deliberately ignore leaf state-icon clicks here — they go through +/// `wxEVT_TREE_STATE_IMAGE_CLICK` which is reliable for leaf items and +/// has the post-cycle state already populated, so duplicating the work +/// here would either double-toggle or fight the leaf path. +#[allow(clippy::too_many_arguments)] +pub(crate) fn handle_packages_left_up( + tree: &TreeCtrl, + package_items: &PackagesStateCell, + package_rows: &Rc>>, + configuration_rows: &Rc>>, + can_install: &Rc>, + wizard_model: &WizardModel, + details: &TextCtrl, + osara_checkbox: &CheckBox, + osara_note: &TextCtrl, + spanish_checkbox: &Choice, + language_choice: &Choice, + pos: Point, +) { + let hwnd = tree.get_handle(); + let (flags, h_item) = native_tree_checkboxes::hit_test(hwnd, pos.x, pos.y); + if (flags & native_tree_checkboxes::TVHT_ONITEMSTATEICON) == 0 || h_item.is_null() { + return; + } + // Identify which group's state icon was hit by comparing the raw + // HTREEITEM directly against each stored group's native handle. + // We don't go through `classify_group` here because we don't have + // a `TreeItemId` wrapper yet — the hit-test message returns only + // the native handle. + let group = { + let items = package_items.borrow(); + if items + .packages_group + .as_ref() + .is_some_and(|g| native_tree_handle(g) == h_item) + { + Some(WhichGroup::Packages) + } else if items + .additional_software_group + .as_ref() + .is_some_and(|g| native_tree_handle(g) == h_item) + { + Some(WhichGroup::AdditionalSoftware) + } else if items + .language_group + .as_ref() + .is_some_and(|g| native_tree_handle(g) == h_item) + { + Some(WhichGroup::Language) + } else if items + .configuration_group + .as_ref() + .is_some_and(|g| native_tree_handle(g) == h_item) + { + Some(WhichGroup::Configuration) + } else { + None + } + }; + let Some(group) = group else { + return; + }; + + propagate_group_toggle_to_leaves( + tree, + package_items, + group, + package_rows, + configuration_rows, + wizard_model, + ); + refresh_after_packages_toggle( + tree, + package_items, + package_rows, + configuration_rows, + can_install, + wizard_model, + details, + osara_checkbox, + osara_note, + spanish_checkbox, + language_choice, + ); +} + +/// Windows-only: refresh both groups' tristate visuals + plan-level +/// `can_install` flag + OSARA widgets + details pane after any toggle +/// (leaf or group) that mutated `package_rows` or +/// `configuration_rows`. Also re-evaluates configuration-row +/// availability against the latest package plan so that, e.g., +/// unchecking ReaPack greys out the REAPER Accessibility row in real +/// time. +#[allow(clippy::too_many_arguments)] +pub(crate) fn refresh_after_packages_toggle( + tree: &TreeCtrl, + package_items: &PackagesStateCell, + package_rows: &Rc>>, + configuration_rows: &Rc>>, + can_install: &Rc>, + wizard_model: &WizardModel, + details: &TextCtrl, + osara_checkbox: &CheckBox, + osara_note: &TextCtrl, + spanish_checkbox: &Choice, + language_choice: &Choice, +) { + // Configuration rows depend on the package plan (e.g. ReaPack must + // be installed/queued for the REAPER Accessibility step). Re-evaluate + // before refreshing the tree so the leaves' selected/state-image + // values match what's in `configuration_rows`. + if let Ok(localizer) = localizer_from_options(&wizard_model.bootstrap_options) { + // None for the resource-path argument: a package toggle can't + // change `reapack.ini`, so preserve each row's existing + // `already_applied` flag rather than re-reading from disk on + // every click. + recompute_configuration_row_availability( + &localizer, + &package_rows.borrow(), + None, + &mut configuration_rows.borrow_mut(), + ); + // Push the recomputed leaf states into the tree visual so the + // user sees the live re-evaluation. + let items = package_items.borrow(); + let configuration_rows_borrowed = configuration_rows.borrow(); + for (idx, leaf) in items.configuration_leaves.iter().enumerate() { + let Some(row) = configuration_rows_borrowed.get(idx) else { + continue; + }; + native_tree_checkboxes::set_check_state( + tree.get_handle(), + leaf, + row.selected && row.available_for_target && !row.already_applied, + ); + let label = format_row_label(&row.summary, row.selected); + tree.set_item_text(leaf, &label); + } + } + + { + let items = package_items.borrow(); + if let Some(group) = items.packages_group.as_ref() { + let group_state = compute_packages_group_tristate(&package_rows.borrow()); + native_tree_checkboxes::set_check_state_tri(tree.get_handle(), group, group_state); + } + if let Some(group) = items.additional_software_group.as_ref() { + let group_state = compute_additional_software_group_tristate(&package_rows.borrow()); + native_tree_checkboxes::set_check_state_tri(tree.get_handle(), group, group_state); + } + if let Some(group) = items.configuration_group.as_ref() { + let group_state = compute_configuration_group_tristate(&configuration_rows.borrow()); + native_tree_checkboxes::set_check_state_tri(tree.get_handle(), group, group_state); + } + } + + let any_install_or_update = package_rows.borrow().iter().any(|row| { + row.available_for_target + && matches!(row.action, PlanActionKind::Install | PlanActionKind::Update) + }); + can_install.set(any_install_or_update); + + if let Some(selected) = tree.get_selection() { + match classify_leaf(&package_items.borrow(), &selected) { + Some(WhichLeaf::Packages(idx)) => { + if let Some(row) = package_rows.borrow().get(idx) { + details.set_value(&package_details(row)); + } + } + Some(WhichLeaf::Configuration(idx)) => { + if let Some(row) = configuration_rows.borrow().get(idx) { + details.set_value(&row.details); + } + } + None => {} + } + } + + sync_osara_keymap_widgets( + wizard_model, + &package_rows.borrow(), + osara_checkbox, + osara_note, + ); + sync_spanish_variant_widget(&package_rows.borrow(), spanish_checkbox); + sync_reaper_language_widget(&package_rows.borrow(), language_choice); +} + +/// Windows-only: implement the parent-checkbox propagation for the +/// requested group. +/// +/// Convention (matches Windows Explorer / Visual Studio Installer): +/// - clicking a fully-checked parent → uncheck all available children; +/// - clicking an unchecked or mixed parent → check all available children. +/// +/// Package leaves route mutations through `apply_checkbox_state_to_package_row` +/// so action labels flip Install / Update / Keep; configuration leaves +/// just flip `row.selected`. Unavailable rows in either group are left +/// untouched. +pub(crate) fn propagate_group_toggle_to_leaves( + tree: &TreeCtrl, + package_items: &PackagesStateCell, + target_group: WhichGroup, + package_rows: &Rc>>, + configuration_rows: &Rc>>, + wizard_model: &WizardModel, +) { + match target_group { + WhichGroup::Packages | WhichGroup::AdditionalSoftware | WhichGroup::Language => { + // All three package groups share the same `packages_leaves` vec + // (row-indexed); the category decides which rows this toggle + // owns so the click only flips its own group's children. + let category = match target_group { + WhichGroup::AdditionalSoftware => rabbit_core::package::PackageCategory::Additional, + WhichGroup::Language => rabbit_core::package::PackageCategory::Language, + _ => rabbit_core::package::PackageCategory::Core, + }; + let pre_state = compute_package_category_tristate(&package_rows.borrow(), category); + let target = !matches!(pre_state, native_tree_checkboxes::TriState::Checked); + let leaves: Vec = package_items.borrow().packages_leaves.to_vec(); + { + let mut rows = package_rows.borrow_mut(); + for row in rows + .iter_mut() + .filter(|r| r.category == category && r.available_for_target) + { + let _ = apply_checkbox_state_to_package_row(wizard_model, row, target); + } + } + let rows = package_rows.borrow(); + let hwnd = tree.get_handle(); + for (idx, leaf) in leaves.iter().enumerate() { + let Some(row) = rows.get(idx) else { continue }; + if row.category == category && row.available_for_target { + native_tree_checkboxes::set_check_state(hwnd, leaf, row.selected); + let label = format_row_label(&row.summary, row.selected); + tree.set_item_text(leaf, &label); + } + } + } + WhichGroup::Configuration => { + let pre_state = compute_configuration_group_tristate(&configuration_rows.borrow()); + let target = !matches!(pre_state, native_tree_checkboxes::TriState::Checked); + let leaves: Vec = package_items.borrow().configuration_leaves.to_vec(); + { + let mut rows = configuration_rows.borrow_mut(); + for row in rows + .iter_mut() + .filter(|r| r.available_for_target && !r.already_applied) + { + row.selected = target; + } + } + let rows = configuration_rows.borrow(); + let hwnd = tree.get_handle(); + for (idx, leaf) in leaves.iter().enumerate() { + let Some(row) = rows.get(idx) else { continue }; + if row.available_for_target && !row.already_applied { + native_tree_checkboxes::set_check_state(hwnd, leaf, row.selected); + let label = format_row_label(&row.summary, row.selected); + tree.set_item_text(leaf, &label); + } + } + } + } +} + +/// Windows: re-render the native TreeCtrl after a row replacement. +#[allow(clippy::too_many_arguments)] // UI plumbing: one parameter per widget handle. +pub(crate) fn refresh_package_checklist( + tree: &PackagesView, + package_items: &PackagesStateCell, + details: &TextCtrl, + osara_keymap_replace: &CheckBox, + osara_keymap_note: &TextCtrl, + spanish_variant_choice: &Choice, + reaper_language_choice: &Choice, + model: &WizardModel, + rows: &[crate::PackageRow], + configuration_rows: &[ConfigurationRow], +) { + populate_packages_tree(tree, package_items, model, rows, configuration_rows); + details.set_value(&rows.first().map(package_details).unwrap_or_default()); + sync_osara_keymap_widgets(model, rows, osara_keymap_replace, osara_keymap_note); + sync_spanish_variant_widget(rows, spanish_variant_choice); + sync_reaper_language_widget(rows, reaper_language_choice); +} diff --git a/crates/rabbit-ui-wxdragon/src/wx_app/packages_page/native_tree_checkboxes.rs b/crates/rabbit-ui-wxdragon/src/wx_app/packages_page/native_tree_checkboxes.rs new file mode 100644 index 0000000..d01e817 --- /dev/null +++ b/crates/rabbit-ui-wxdragon/src/wx_app/packages_page/native_tree_checkboxes.rs @@ -0,0 +1,391 @@ +//! Windows-only helpers that turn the wx-created `wxTreeCtrl` into a +//! `SysTreeView32` with `TVS_CHECKBOXES` set. wxdragon doesn't expose any of +//! the native APIs we need (no `EnableCheckBoxes`, no `SetItemState`, no +//! `SetStateImageList`), so we reach down to the underlying `HWND` via +//! `Window::get_handle()` and drive the control directly through user32 + +//! raw `SendMessageW` traffic. `wxTreeItemId` on wxMSW is a single-member +//! struct (`void* m_pItem`, no vtable / no padding), so reading the first +//! pointer-sized word of the wxd_TreeItemId_t* gives us the native +//! `HTREEITEM`. This is implementation-dependent but stable on wxMSW today +//! — we live with that fragility because the alternative is forking +//! wxdragon-sys, and the prize is screen-reader-correct native checkboxes +//! (UIA Toggle pattern on each tree row). + +use std::ffi::c_void; +use wxdragon::widgets::treectrl::TreeItemId; + +pub const GWL_STYLE: i32 = -16; +pub const TVS_CHECKBOXES: u32 = 0x0100; +const TV_FIRST: u32 = 0x1100; +const TVM_SETITEMW: u32 = TV_FIRST + 63; +const TVM_GETITEMW: u32 = TV_FIRST + 62; +const TVM_SETIMAGELIST: u32 = TV_FIRST + 9; +const TVM_HITTEST: u32 = TV_FIRST + 17; +const TVSIL_STATE: usize = 2; +const TVIF_HANDLE: u32 = 0x0010; +const TVIF_STATE: u32 = 0x0008; +const TVIS_STATEIMAGEMASK: u32 = 0xF000; +pub const TVHT_ONITEMSTATEICON: u32 = 0x0040; + +/// Themed checkbox state ids. See `BP_CHECKBOX` (= 3) of `BUTTON` +/// theme class in ``. We use the "normal" variants because +/// the tree control overlays its own selection/hover effects on top. +const BP_CHECKBOX: i32 = 3; +const CBS_UNCHECKEDNORMAL: i32 = 1; +const CBS_CHECKEDNORMAL: i32 = 5; +const CBS_MIXEDNORMAL: i32 = 9; +const TS_TRUE: i32 = 1; + +/// `DrawFrameControl` flags used as the unthemed fallback when +/// `OpenThemeData("BUTTON")` returns null (classic theme / no themes). +const DFC_BUTTON: u32 = 4; +const DFCS_BUTTONCHECK: u32 = 0x0000; +const DFCS_CHECKED: u32 = 0x0400; +const DFCS_BUTTON3STATE: u32 = 0x0008; + +/// `ImageList_Create` flags: 32-bit color + mask channel. +const ILC_COLOR32: u32 = 0x0020; +const ILC_MASK: u32 = 0x0001; + +/// State-image indices we use. `TVS_CHECKBOXES` indices are 1-based +/// (index 0 means "no state image"). `Mixed` is what the parent +/// "Packages" group shows when only some children are checked. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum TriState { + Unchecked, + Checked, + Mixed, +} + +impl TriState { + fn state_image_index(self) -> u32 { + match self { + TriState::Unchecked => 1, + TriState::Checked => 2, + TriState::Mixed => 3, + } + } +} + +/// Layout-compatible mirror of `TVITEMW` from ``. +#[repr(C)] +struct Tvitemw { + mask: u32, + h_item: *mut c_void, + state: u32, + state_mask: u32, + text: *mut u16, + text_max: i32, + image: i32, + selected_image: i32, + children: i32, + l_param: isize, +} + +#[repr(C)] +struct RectStruct { + left: i32, + top: i32, + right: i32, + bottom: i32, +} + +#[repr(C)] +struct SizeStruct { + cx: i32, + cy: i32, +} + +unsafe extern "system" { + fn GetWindowLongPtrW(h_wnd: *mut c_void, n_index: i32) -> isize; + fn SetWindowLongPtrW(h_wnd: *mut c_void, n_index: i32, dw_new_long: isize) -> isize; + fn SendMessageW(h_wnd: *mut c_void, msg: u32, w_param: usize, l_param: isize) -> isize; + fn GetDC(h_wnd: *mut c_void) -> *mut c_void; + fn ReleaseDC(h_wnd: *mut c_void, hdc: *mut c_void) -> i32; + fn DrawFrameControl(hdc: *mut c_void, lprc: *const RectStruct, type_: u32, state: u32) -> i32; + fn FillRect(hdc: *mut c_void, lprc: *const RectStruct, hbr: *mut c_void) -> i32; +} + +#[link(name = "gdi32")] +unsafe extern "system" { + fn CreateCompatibleDC(hdc: *mut c_void) -> *mut c_void; + fn DeleteDC(hdc: *mut c_void) -> i32; + fn CreateCompatibleBitmap(hdc: *mut c_void, cx: i32, cy: i32) -> *mut c_void; + fn DeleteObject(obj: *mut c_void) -> i32; + fn SelectObject(hdc: *mut c_void, obj: *mut c_void) -> *mut c_void; + fn CreateSolidBrush(color: u32) -> *mut c_void; +} + +#[link(name = "uxtheme")] +unsafe extern "system" { + fn OpenThemeData(h_wnd: *mut c_void, classlist: *const u16) -> *mut c_void; + fn CloseThemeData(htheme: *mut c_void) -> i32; + fn DrawThemeBackground( + htheme: *mut c_void, + hdc: *mut c_void, + partid: i32, + stateid: i32, + prect: *const RectStruct, + pcliprect: *const RectStruct, + ) -> i32; + fn GetThemePartSize( + htheme: *mut c_void, + hdc: *mut c_void, + partid: i32, + stateid: i32, + prc: *const RectStruct, + esize: i32, + psz: *mut SizeStruct, + ) -> i32; +} + +#[link(name = "comctl32")] +unsafe extern "system" { + fn ImageList_Create(cx: i32, cy: i32, flags: u32, initial: i32, grow: i32) -> *mut c_void; + fn ImageList_AddMasked(himl: *mut c_void, hbm_image: *mut c_void, cr_mask: u32) -> i32; + fn ImageList_Destroy(himl: *mut c_void) -> i32; +} + +/// OR-in the `TVS_CHECKBOXES` style on an existing tree's `HWND` AND +/// install our own 3-image state list so the synthetic Packages group +/// can show an indeterminate ("half-checked") state when only some of +/// its children are checked. The native control by default creates a +/// 2-image list (unchecked, checked); we replace it with a 3-image +/// list (unchecked, checked, mixed). The first two come from the +/// `BUTTON` theme via `DrawThemeBackground` so they match the rest of +/// the OS's checkboxes; the third uses `CBS_MIXEDNORMAL` to draw the +/// system's standard mixed-state checkbox glyph. +pub fn enable_checkboxes(hwnd: *mut c_void) { + if hwnd.is_null() { + return; + } + unsafe { + let style = GetWindowLongPtrW(hwnd, GWL_STYLE); + if (style as u32 & TVS_CHECKBOXES) == 0 { + SetWindowLongPtrW(hwnd, GWL_STYLE, style | TVS_CHECKBOXES as isize); + } + } + install_tristate_state_image_list(hwnd); +} + +/// Build a 3-image `HIMAGELIST` containing themed unchecked + checked + +/// mixed checkbox glyphs and install it as the tree's state image +/// list. Replaces (and frees) whatever list `TVS_CHECKBOXES` may have +/// auto-created. +fn install_tristate_state_image_list(hwnd: *mut c_void) { + // BGR magenta serves as the transparency key for the image list: + // anywhere we leave magenta is treated as transparent on draw. + const MAGENTA_BGR: u32 = 0x00FF00FF; + + let class: Vec = "BUTTON".encode_utf16().chain(std::iter::once(0)).collect(); + let htheme = unsafe { OpenThemeData(hwnd, class.as_ptr()) }; + + // Determine checkbox glyph size from the theme (DPI-aware) when + // available; fall back to a sensible default otherwise. + let mut size = SizeStruct { cx: 13, cy: 13 }; + if !htheme.is_null() { + unsafe { + let _ = GetThemePartSize( + htheme, + std::ptr::null_mut(), + BP_CHECKBOX, + CBS_UNCHECKEDNORMAL, + std::ptr::null(), + TS_TRUE, + &mut size, + ); + } + } + let cx = size.cx.max(13); + let cy = size.cy.max(13); + + let himl = unsafe { ImageList_Create(cx, cy, ILC_COLOR32 | ILC_MASK, 3, 0) }; + if himl.is_null() { + if !htheme.is_null() { + unsafe { + CloseThemeData(htheme); + } + } + return; + } + + let states = [CBS_UNCHECKEDNORMAL, CBS_CHECKEDNORMAL, CBS_MIXEDNORMAL]; + + unsafe { + let hdc_screen = GetDC(std::ptr::null_mut()); + let hdc_mem = CreateCompatibleDC(hdc_screen); + let key_brush = CreateSolidBrush(MAGENTA_BGR); + + for state in &states { + let hbm = CreateCompatibleBitmap(hdc_screen, cx, cy); + let prev_bm = SelectObject(hdc_mem, hbm); + + // Fill with magenta — anything DrawThemeBackground (or the + // unthemed fallback) leaves untouched stays magenta and + // becomes transparent in the image list. + let rc = RectStruct { + left: 0, + top: 0, + right: cx, + bottom: cy, + }; + let _ = FillRect(hdc_mem, &rc, key_brush); + + if !htheme.is_null() { + let _ = DrawThemeBackground( + htheme, + hdc_mem, + BP_CHECKBOX, + *state, + &rc, + std::ptr::null(), + ); + } else { + let dfcs = match *state { + CBS_UNCHECKEDNORMAL => DFCS_BUTTONCHECK, + CBS_CHECKEDNORMAL => DFCS_BUTTONCHECK | DFCS_CHECKED, + CBS_MIXEDNORMAL => DFCS_BUTTON3STATE | DFCS_CHECKED, + _ => DFCS_BUTTONCHECK, + }; + let _ = DrawFrameControl(hdc_mem, &rc, DFC_BUTTON, dfcs); + } + + SelectObject(hdc_mem, prev_bm); + let _ = ImageList_AddMasked(himl, hbm, MAGENTA_BGR); + DeleteObject(hbm); + } + + DeleteObject(key_brush); + DeleteDC(hdc_mem); + ReleaseDC(std::ptr::null_mut(), hdc_screen); + + if !htheme.is_null() { + CloseThemeData(htheme); + } + + // Hand the new image list to the tree control. The control + // takes ownership of the new list and returns the old one for + // us to destroy. + let old_himl = SendMessageW(hwnd, TVM_SETIMAGELIST, TVSIL_STATE, himl as isize); + if old_himl != 0 { + ImageList_Destroy(old_himl as *mut c_void); + } + } +} + +#[repr(C)] +struct TvHitTestPoint { + x: i32, + y: i32, +} + +/// Layout-compatible mirror of `TVHITTESTINFO` from ``. +#[repr(C)] +struct TvHitTestInfo { + pt: TvHitTestPoint, + flags: u32, + h_item: *mut c_void, +} + +/// Send `TVM_HITTEST` to the native tree control and return the hit +/// item handle plus the result flags. `(x, y)` is in the tree's +/// client-area coordinates (which is what `wxEVT_LEFT_*` mouse-event +/// positions report on the bound window). +pub fn hit_test(hwnd: *mut c_void, x: i32, y: i32) -> (u32, *mut c_void) { + if hwnd.is_null() { + return (0, std::ptr::null_mut()); + } + let mut info = TvHitTestInfo { + pt: TvHitTestPoint { x, y }, + flags: 0, + h_item: std::ptr::null_mut(), + }; + unsafe { + SendMessageW(hwnd, TVM_HITTEST, 0, &mut info as *mut _ as isize); + } + (info.flags, info.h_item) +} + +/// Read the native `HTREEITEM` out of a wxdragon `TreeItemId`. Relies +/// on: +/// 1. `TreeItemId { ptr: *mut wxd_TreeItemId_t }` being a single-field +/// `repr(Rust)` struct — its layout matches the inner pointer. +/// 2. `wxd_TreeItemId_t*` being a `reinterpret_cast` of `wxTreeItemId*` +/// (confirmed in wxdragon-sys/cpp/src/treectrl.cpp). +/// 3. `wxTreeItemId` having `void* m_pItem` as its only non-static +/// member with no vtable. +fn htreeitem_from(item: &TreeItemId) -> *mut c_void { + // SAFETY: see the contract above. Reading the first pointer-sized + // word of the `TreeItemId` wrapper yields its private `ptr` field; + // reading the first word of that yields `wxTreeItemId::m_pItem`. + let inner: *mut c_void = unsafe { std::mem::transmute_copy(item) }; + if inner.is_null() { + return std::ptr::null_mut(); + } + unsafe { *(inner as *const *mut c_void) } +} + +pub fn set_check_state(hwnd: *mut c_void, item: &TreeItemId, checked: bool) { + let state = if checked { + TriState::Checked + } else { + TriState::Unchecked + }; + set_check_state_tri(hwnd, item, state); +} + +/// Set the state image index (1, 2, or 3 — see `TriState`) for the +/// given tree item. Used both for leaf rows (only `Checked` / +/// `Unchecked`) and the synthetic Packages group (any of the three). +pub fn set_check_state_tri(hwnd: *mut c_void, item: &TreeItemId, state: TriState) { + if hwnd.is_null() { + return; + } + let h_item = htreeitem_from(item); + if h_item.is_null() { + return; + } + let state_value = state.state_image_index() << 12; + let mut tvi = Tvitemw { + mask: TVIF_STATE | TVIF_HANDLE, + h_item, + state: state_value, + state_mask: TVIS_STATEIMAGEMASK, + text: std::ptr::null_mut(), + text_max: 0, + image: 0, + selected_image: 0, + children: 0, + l_param: 0, + }; + unsafe { + SendMessageW(hwnd, TVM_SETITEMW, 0, &mut tvi as *mut _ as isize); + } +} + +pub fn get_check_state(hwnd: *mut c_void, item: &TreeItemId) -> bool { + if hwnd.is_null() { + return false; + } + let h_item = htreeitem_from(item); + if h_item.is_null() { + return false; + } + let mut tvi = Tvitemw { + mask: TVIF_STATE | TVIF_HANDLE, + h_item, + state: 0, + state_mask: TVIS_STATEIMAGEMASK, + text: std::ptr::null_mut(), + text_max: 0, + image: 0, + selected_image: 0, + children: 0, + l_param: 0, + }; + unsafe { + SendMessageW(hwnd, TVM_GETITEMW, 0, &mut tvi as *mut _ as isize); + } + // State image index 2 = checked. + ((tvi.state & TVIS_STATEIMAGEMASK) >> 12) == 2 +} diff --git a/crates/rabbit-ui-wxdragon/src/wx_app/pages.rs b/crates/rabbit-ui-wxdragon/src/wx_app/pages.rs new file mode 100644 index 0000000..4e67726 --- /dev/null +++ b/crates/rabbit-ui-wxdragon/src/wx_app/pages.rs @@ -0,0 +1,770 @@ +//! Builds each wizard page's widgets and adds them to the book. + +use std::cell::{Cell, RefCell}; +use std::rc::Rc; + +use crate::{WizardModel, wizard_desired_package_ids}; +use wxdragon::widgets::SimpleBook; + +use wxdragon::prelude::*; + +use crate::wx_app::expert_mode::{ + OSARA_BUILD_CHOICE, OSARA_BUILDS_LABEL_NAME, REAPER_BUILDS_LABEL_NAME, expert_mode, + seed_build_choices, +}; +use crate::wx_app::packages_page::{PackagesStateCell, build_packages_page}; +use crate::wx_app::shell::{open_external_url, relaunch_with_locale}; +use crate::wx_app::widgets::{ + WizardWidgets, configure_portable_folder, portable_choice_index, portable_folder_path, + portable_target_details, selected_target_details, set_optional_choice_shown, + target_details_for_index, +}; +use crate::wx_app::{ + DONE_STEP, PACKAGES_STEP, PROGRESS_STEP, REAPACK_ACK_STEP, REVIEW_STEP, TARGET_STEP, + VERSION_CHECK_STEP, +}; + +/// A wizard page: a scrolling panel rather than a plain one. +/// +/// Every page stacks fixed-height controls — lists, detail panes, notes — +/// and their total height grows with the UI language, since longer +/// translations wrap the labels between them onto extra lines. A plain +/// `wxPanel` cannot scroll, so whatever no longer fits is silently clipped, +/// and a `BoxSizer` short of room takes the whole deficit out of its only +/// proportion-1 child. On the Packages page that child is the package list +/// itself: it collapsed to nothing on a window that wasn't maximized, and a +/// control of zero height is not in the platform's accessibility tree at +/// all, so screen readers stopped seeing the list. Scrolling keeps every +/// control laid out at its real size and reachable whatever the window size. +pub(crate) type WizardPage = ScrolledWindow; + +/// Vertical scroll step for a wizard page, in pixels. The horizontal step is +/// 0, which turns horizontal scrolling off: page content expands to the page +/// width instead of extending past it. +pub(crate) const WIZARD_PAGE_SCROLL_STEP: i32 = 10; + +/// Build one empty wizard page inside the book. +pub(crate) fn new_wizard_page(book: &SimpleBook) -> WizardPage { + let page = ScrolledWindow::builder(book).build(); + page.set_scroll_rate(0, WIZARD_PAGE_SCROLL_STEP); + // wxScrolledWindow is created without wxTAB_TRAVERSAL, which wxPanel + // gets by default — without it Tab stops moving between the controls of + // a page. Put the flag back before any child is created. + page.set_style_raw(page.get_style_raw() | PanelStyle::TabTraversal.bits()); + page +} + +#[allow(clippy::too_many_arguments)] // UI plumbing: one parameter per widget handle. +pub(crate) fn add_pages( + book: &SimpleBook, + model: &WizardModel, + package_rows: Rc>>, + configuration_rows: Rc>>, + package_items: PackagesStateCell, + can_install: Rc>, + self_update_status: StatusBar, + language_footer: Panel, +) -> WizardWidgets { + let target_page = new_wizard_page(book); + let (target_choice, portable_folder, target_details, reaper_build_choice, osara_build_choice) = + build_target_page(&target_page, model); + book.add_page(&target_page, &model.steps[TARGET_STEP].label, true, None); + + let version_check_page = new_wizard_page(book); + let ( + version_check_status, + version_check_gauge, + version_check_error_heading, + version_check_error_log, + ) = build_version_check_page( + &version_check_page, + model, + wizard_desired_package_ids(model.platform).len() as i32, + ); + book.add_page( + &version_check_page, + &model.steps[VERSION_CHECK_STEP].label, + false, + None, + ); + + let packages_page = new_wizard_page(book); + let ( + package_checklist, + package_details, + osara_keymap_replace, + osara_keymap_note, + spanish_variant_choice, + reaper_language_choice, + ) = build_packages_page( + &packages_page, + model, + package_rows, + configuration_rows, + package_items, + can_install, + ); + book.add_page( + &packages_page, + &model.steps[PACKAGES_STEP].label, + false, + None, + ); + + let reapack_ack_page = new_wizard_page(book); + let (_reapack_donate_link, reapack_ack_confirm) = + build_reapack_ack_page(&reapack_ack_page, model); + book.add_page( + &reapack_ack_page, + &model.steps[REAPACK_ACK_STEP].label, + false, + None, + ); + + let review_page = new_wizard_page(book); + let review_text = build_review_page(&review_page, model); + book.add_page(&review_page, &model.steps[REVIEW_STEP].label, false, None); + + let progress_page = new_wizard_page(book); + let (progress_status, progress_gauge, progress_details) = + build_progress_page(&progress_page, model); + book.add_page( + &progress_page, + &model.steps[PROGRESS_STEP].label, + false, + None, + ); + + let done_page = new_wizard_page(book); + let (done_status, done_details, done_launch_reaper, done_open_resource) = + build_done_page(&done_page, model); + book.add_page(&done_page, &model.steps[DONE_STEP].label, false, None); + + WizardWidgets { + target_choice, + portable_folder, + target_details, + reaper_build_choice, + osara_build_choice, + version_check_status, + version_check_gauge, + version_check_error_heading, + version_check_error_log, + package_checklist, + package_details, + osara_keymap_replace, + osara_keymap_note, + spanish_variant_choice, + reaper_language_choice, + reapack_ack_confirm, + review_text, + progress_status, + progress_gauge, + progress_details, + done_status, + done_details, + done_launch_reaper, + done_open_resource, + self_update_status, + language_footer, + } +} + +pub(crate) fn build_target_page( + page: &WizardPage, + model: &WizardModel, +) -> (Choice, TextCtrl, TextCtrl, Choice, Choice) { + let sizer = BoxSizer::builder(Orientation::Vertical).build(); + add_heading( + page, + &sizer, + &model.text.target_heading, + "rabbit-target-heading", + ); + + add_label( + page, + &sizer, + &model.text.target_choice_label, + "rabbit-target-choice-label", + ); + + let choice = Choice::builder(page).build(); + choice.set_name("rabbit-target-choice"); + for row in &model.target_rows { + choice.append(&row.label); + } + let portable_index = portable_choice_index(model); + choice.append(&model.text.target_portable_choice); + choice.set_selection(model.selected_target_index.unwrap_or(portable_index) as u32); + sizer.add(&choice, 0, SizerFlag::All | SizerFlag::Expand, 6); + + add_label( + page, + &sizer, + &model.text.target_portable_folder_label, + "rabbit-target-portable-folder-label", + ); + + // We build the path input as a TextCtrl + Browse button instead of + // wxDirPickerCtrl: wxdragon doesn't expose the picker's inner wxTextCtrl, + // so the screen reader has no way to read a label off it. Mirroring the + // picker's composition by hand lets us name the editable field directly, + // and the user gets a real text input they can paste/type into. + let portable_row = BoxSizer::builder(Orientation::Horizontal).build(); + let portable_folder = TextCtrl::builder(page).build(); + // Same wxdragon quirk as the ReaPack-ack checkbox below: the screen + // reader reads the wxWindow *name*, not the preceding StaticText, so + // set the name to the localized label instead of an internal id. + portable_folder.set_name(&model.text.target_portable_folder_label); + portable_folder.add_style(WindowStyle::TabStop); + portable_row.add(&portable_folder, 1, SizerFlag::Expand | SizerFlag::Right, 6); + + let portable_folder_browse = Button::builder(page) + .with_label(&model.text.target_portable_folder_browse_label) + .build(); + portable_folder_browse.set_name(&model.text.target_portable_folder_browse_label); + portable_folder_browse.add_style(WindowStyle::TabStop); + portable_row.add( + &portable_folder_browse, + 0, + SizerFlag::AlignCenterVertical, + 0, + ); + + sizer.add_sizer(&portable_row, 0, SizerFlag::All | SizerFlag::Expand, 6); + + configure_portable_folder( + &portable_folder, + &portable_folder_browse, + choice + .get_selection() + .map(|index| index as usize == portable_index) + .unwrap_or(false), + ); + + add_label( + page, + &sizer, + &model.text.target_details_label, + "rabbit-target-details-label", + ); + let initial_details = selected_target_details(model, &choice, &portable_folder); + let details = TextCtrl::builder(page) + .with_value(&initial_details) + .with_style(TextCtrlStyle::MultiLine | TextCtrlStyle::ReadOnly | TextCtrlStyle::WordWrap) + .with_size(Size::new(-1, 120)) + .build(); + details.set_name("rabbit-target-details"); + sizer.add(&details, 1, SizerFlag::All | SizerFlag::Expand, 6); + + // Expert mode's build choices. Created after the target details, so they + // come last in the tab order, and hidden until expert mode is on. + add_label( + page, + &sizer, + &model.text.expert_reaper_builds_label, + REAPER_BUILDS_LABEL_NAME, + ); + let reaper_build_choice = Choice::builder(page).build(); + reaper_build_choice.set_name(&model.text.expert_reaper_builds_label); + reaper_build_choice.append(&model.text.expert_reaper_builds_stable); + reaper_build_choice.append(&model.text.expert_reaper_builds_dev); + reaper_build_choice.set_selection(0); + sizer.add( + &reaper_build_choice, + 0, + SizerFlag::All | SizerFlag::Expand, + 6, + ); + + add_label( + page, + &sizer, + &model.text.expert_osara_builds_label, + OSARA_BUILDS_LABEL_NAME, + ); + let osara_build_choice = Choice::builder(page).build(); + osara_build_choice.set_name(&model.text.expert_osara_builds_label); + osara_build_choice.append(&model.text.expert_osara_builds_snapshot); + osara_build_choice.set_selection(0); + sizer.add( + &osara_build_choice, + 0, + SizerFlag::All | SizerFlag::Expand, + 6, + ); + OSARA_BUILD_CHOICE.set(Some(osara_build_choice)); + + { + let choice_model = model.clone(); + let choice_portable_folder = portable_folder; + let choice_portable_browse = portable_folder_browse; + let choice_details = details; + let choice_reaper_builds = reaper_build_choice; + choice.on_selection_changed(move |event| { + if let Some(index) = event.get_selection() { + let index = index as usize; + let portable_selected = index == portable_choice_index(&choice_model); + if expert_mode() { + // Another REAPER may have been installed from other + // builds: start the choices from its receipt. + let target_path = if portable_selected { + portable_folder_path(&choice_portable_folder) + } else { + choice_model + .target_rows + .get(index) + .map(|row| row.path.clone()) + }; + seed_build_choices(target_path.as_deref(), &choice_reaper_builds); + } + configure_portable_folder( + &choice_portable_folder, + &choice_portable_browse, + portable_selected, + ); + let value = if portable_selected { + portable_target_details(&choice_model, &choice_portable_folder) + } else { + target_details_for_index(&choice_model, index) + }; + choice_details.set_value(&value); + } + }); + } + + { + let model = model.clone(); + let dir_choice = choice; + let dir_details = details; + let dir_portable_folder = portable_folder; + let dir_portable_browse = portable_folder_browse; + let dir_reaper_builds = reaper_build_choice; + // Fires both for keyboard input AND for `set_value` from the Browse + // button below — wxTextCtrl::SetValue generates wxEVT_TEXT — so this + // single handler handles typing and the picker dialog uniformly. + portable_folder.on_text_changed(move |_| { + let portable_index = portable_choice_index(&model); + if dir_choice + .get_selection() + .map(|index| index as usize != portable_index) + .unwrap_or(true) + { + dir_choice.set_selection(portable_index as u32); + configure_portable_folder(&dir_portable_folder, &dir_portable_browse, true); + } + dir_details.set_value(&portable_target_details(&model, &dir_portable_folder)); + // A different folder is a different REAPER, possibly installed + // from other builds: start the choices from its receipt, as a + // change of target does. + if expert_mode() { + seed_build_choices( + portable_folder_path(&dir_portable_folder).as_deref(), + &dir_reaper_builds, + ); + } + }); + } + + { + let dialog_parent = *page; + let model_for_browse = model.clone(); + let browse_target = portable_folder; + portable_folder_browse.on_click(move |_| { + let current = browse_target.get_value(); + let dialog = DirDialog::builder( + &dialog_parent, + &model_for_browse.text.target_portable_folder_message, + ¤t, + ) + .build(); + if dialog.show_modal() == ID_OK + && let Some(path) = dialog.get_path() + { + // Fires on_text_changed, which runs the same flip-to-portable + // + update-details logic typing does. + browse_target.set_value(&path); + } + }); + } + + page.set_sizer(sizer, true); + set_optional_choice_shown(&reaper_build_choice, REAPER_BUILDS_LABEL_NAME, false); + set_optional_choice_shown(&osara_build_choice, OSARA_BUILDS_LABEL_NAME, false); + choice.set_focus(); + ( + choice, + portable_folder, + details, + reaper_build_choice, + osara_build_choice, + ) +} + +/// Base id for the language popup menu's radio items. Item id at index `i` +/// in `WizardModel::language_options` is `LANGUAGE_MENU_ID_BASE + i`. +pub(crate) const LANGUAGE_MENU_ID_BASE: i32 = 13700; + +/// Build the language-picker footer inside a child Panel that lives below +/// the wizard buttons. The footer is only meaningful on the Target page — +/// switching languages relaunches RABBIT, so a switch from a later step +/// would discard the user's wizard progress anyway. Returning the child +/// Panel here lets the caller hide/show it via `update_navigation` based +/// on the current step. Adding it as a sibling of the button row means +/// tab order naturally reaches it after the last button (rather than +/// partway through the page), then wraps back to the page's first +/// focusable widget. +pub(crate) fn build_language_footer( + root_panel: &Panel, + root: &BoxSizer, + model: &WizardModel, +) -> Panel { + let footer = Panel::builder(root_panel).build(); + footer.set_name("rabbit-language-footer"); + let footer_sizer = BoxSizer::builder(Orientation::Vertical).build(); + + add_label( + &footer, + &footer_sizer, + &model.text.target_language_label, + "rabbit-target-language-label", + ); + + let current_display_name = model + .language_options + .iter() + .find(|option| option.locale == model.current_language) + .map(|option| option.display_name.clone()) + .unwrap_or_else(|| model.current_language.clone()); + + let language_button = Button::builder(&footer) + .with_label(¤t_display_name) + .build(); + language_button.set_name("rabbit-target-language"); + language_button.add_style(WindowStyle::TabStop); + language_button.set_can_focus(true); + footer_sizer.add(&language_button, 0, SizerFlag::All | SizerFlag::Expand, 6); + + add_label( + &footer, + &footer_sizer, + &model.text.target_language_restart_note, + "rabbit-target-language-restart-note", + ); + + footer.set_sizer(footer_sizer, true); + root.add(&footer, 0, SizerFlag::All | SizerFlag::Expand, 6); + + let language_options = model.language_options.clone(); + let current_locale = model.current_language.clone(); + + // The popup menu dispatches its EVT_MENU to the popup's owner window + // (the footer Panel here), not to the button that opened it. + { + let language_options = language_options.clone(); + let current_locale = current_locale.clone(); + footer.on_menu_selected(move |event| { + let id = event.get_id(); + let raw_index = id - LANGUAGE_MENU_ID_BASE; + if raw_index < 0 || (raw_index as usize) >= language_options.len() { + return; + } + let Some(option) = language_options.get(raw_index as usize) else { + return; + }; + if option.locale == current_locale { + return; + } + relaunch_with_locale(&option.locale); + }); + } + + let menu_owner = footer; + language_button.on_click(move |_| { + let mut builder = Menu::builder(); + for (index, option) in language_options.iter().enumerate() { + let id = LANGUAGE_MENU_ID_BASE + index as i32; + builder = builder.append_radio_item(id, &option.display_name, ""); + } + let menu = builder.build(); + for (index, option) in language_options.iter().enumerate() { + if option.locale == current_locale { + let id = LANGUAGE_MENU_ID_BASE + index as i32; + menu.check_item(id, true); + } + } + let mut menu = menu; + menu_owner.popup_menu(&mut menu, None); + }); + + footer +} + +pub(crate) fn build_version_check_page( + page: &WizardPage, + model: &WizardModel, + package_count: i32, +) -> (StaticText, Gauge, StaticText, TextCtrl) { + let sizer = BoxSizer::builder(Orientation::Vertical).build(); + add_heading( + page, + &sizer, + &model.text.version_check_heading, + "rabbit-version-check-heading", + ); + let status = StaticText::builder(page) + .with_label(&model.text.version_check_status_pending) + .build(); + status.set_name("rabbit-version-check-status"); + sizer.add(&status, 0, SizerFlag::All | SizerFlag::Expand, 6); + + add_label( + page, + &sizer, + &model.text.version_check_progress_label, + "rabbit-version-check-progress-label", + ); + let gauge = Gauge::builder(page) + .with_range(package_count.max(1)) + .build(); + gauge.set_name("rabbit-version-check-progress"); + sizer.add(&gauge, 0, SizerFlag::All | SizerFlag::Expand, 6); + + let error_heading = StaticText::builder(page) + .with_label(&model.text.version_check_error_heading) + .build(); + error_heading.set_name("rabbit-version-check-error-heading"); + sizer.add(&error_heading, 0, SizerFlag::All | SizerFlag::Expand, 6); + let error_log = TextCtrl::builder(page) + .with_value("") + .with_style(TextCtrlStyle::MultiLine | TextCtrlStyle::ReadOnly | TextCtrlStyle::WordWrap) + .with_size(Size::new(-1, 120)) + .build(); + error_log.set_name("rabbit-version-check-error-log"); + sizer.add(&error_log, 1, SizerFlag::All | SizerFlag::Expand, 6); + + // Hide the error region until something fails so screen readers do not + // see an empty Failed-checks/error-log pair while a check is in progress. + // Show()/Hide() removes the controls from the tab order and the + // accessibility tree; we re-Show() them in render_version_check_errors. + error_heading.hide(); + error_log.hide(); + + page.set_sizer(sizer, true); + (status, gauge, error_heading, error_log) +} + +/// Build the ReaPack donation-acknowledgement page. The page is only ever +/// shown when ReaPack is in the install/update plan — the Packages → Review +/// transition routes through it conditionally. The Continue button stays +/// disabled until the user checks the acknowledgement; that gating happens +/// in `update_navigation` based on `reapack_ack_confirm.get_value()`. +pub(crate) fn build_reapack_ack_page(page: &WizardPage, model: &WizardModel) -> (Button, CheckBox) { + let sizer = BoxSizer::builder(Orientation::Vertical).build(); + add_heading( + page, + &sizer, + &model.text.reapack_ack_heading, + "rabbit-reapack-ack-heading", + ); + let body = TextCtrl::builder(page) + .with_value(&model.text.reapack_ack_body) + .with_style(TextCtrlStyle::MultiLine | TextCtrlStyle::ReadOnly | TextCtrlStyle::WordWrap) + .with_size(Size::new(-1, 120)) + .build(); + body.set_name("rabbit-reapack-ack-body"); + sizer.add(&body, 0, SizerFlag::All | SizerFlag::Expand, 6); + + let donate_link = Button::builder(page) + .with_label(&model.text.reapack_ack_link_label) + .build(); + donate_link.set_name("rabbit-reapack-ack-donate-link"); + donate_link.add_style(WindowStyle::TabStop); + donate_link.set_can_focus(true); + sizer.add(&donate_link, 0, SizerFlag::All, 6); + donate_link.on_click(move |_| { + // Best-effort: open the donation page in the user's default browser + // so the donation hint surfaces on a real, current upstream page + // rather than a stale cached blurb in the wizard. + let _ = open_external_url("https://reapack.com/donate"); + }); + + let confirm = CheckBox::builder(page) + .with_label(&model.text.reapack_ack_confirm_label) + .build(); + // Mirror the OSARA-keymap / done-page CheckBox pattern: on this + // wxdragon version the accessible name is driven by the wxWindow + // *name* on Windows, not the visible `with_label` argument, so set + // both `name` and `label` to the localized string. Without this the + // screen reader announces the literal Fluent key + // (`rabbit-reapack-ack-confirm`) instead of the translated label. + confirm.set_name(&model.text.reapack_ack_confirm_label); + confirm.set_label(&model.text.reapack_ack_confirm_label); + confirm.add_style(WindowStyle::TabStop); + confirm.set_value(false); + sizer.add(&confirm, 0, SizerFlag::All, 6); + + page.set_sizer(sizer, true); + (donate_link, confirm) +} + +pub(crate) fn build_review_page(page: &WizardPage, model: &WizardModel) -> TextCtrl { + let sizer = BoxSizer::builder(Orientation::Vertical).build(); + add_heading( + page, + &sizer, + &model.text.review_heading, + "rabbit-review-heading", + ); + let review = TextCtrl::builder(page) + .with_value(&model.review_lines.join("\n")) + .with_style(TextCtrlStyle::MultiLine | TextCtrlStyle::ReadOnly | TextCtrlStyle::WordWrap) + .build(); + review.set_name("rabbit-review-text"); + sizer.add(&review, 1, SizerFlag::All | SizerFlag::Expand, 6); + page.set_sizer(sizer, true); + review +} + +pub(crate) fn build_progress_page( + page: &WizardPage, + model: &WizardModel, +) -> (StaticText, Gauge, TextCtrl) { + let sizer = BoxSizer::builder(Orientation::Vertical).build(); + add_heading( + page, + &sizer, + &model.text.progress_heading, + "rabbit-progress-heading", + ); + let status = StaticText::builder(page) + .with_label(&model.text.progress_status) + .build(); + status.set_name("rabbit-progress-status"); + sizer.add(&status, 0, SizerFlag::All | SizerFlag::Expand, 6); + let gauge = Gauge::builder(page).with_range(100).build(); + gauge.set_name("rabbit-progress-gauge"); + sizer.add(&gauge, 0, SizerFlag::All | SizerFlag::Expand, 6); + + add_label( + page, + &sizer, + &model.text.progress_details_label, + "rabbit-progress-details-label", + ); + let details = TextCtrl::builder(page) + .with_value(&model.text.progress_details_idle) + .with_style(TextCtrlStyle::MultiLine | TextCtrlStyle::ReadOnly | TextCtrlStyle::WordWrap) + .build(); + details.set_name("rabbit-progress-details"); + sizer.add(&details, 1, SizerFlag::All | SizerFlag::Expand, 6); + + page.set_sizer(sizer, true); + (status, gauge, details) +} + +pub(crate) fn build_done_page( + page: &WizardPage, + model: &WizardModel, +) -> (TextCtrl, TextCtrl, Button, Button) { + let sizer = BoxSizer::builder(Orientation::Vertical).build(); + add_heading( + page, + &sizer, + &model.text.done_heading, + "rabbit-done-heading", + ); + // One short status TextCtrl (always visible) carries the success / + // failure sentence + any follow-up status updates ("Report saved at …", + // "REAPER could not be launched: …"). Power-user details live in the + // collapsible TextCtrl below — kept hidden by default per the + // streamlined wizard design. + let status = TextCtrl::builder(page) + .with_value(&model.text.done_status) + .with_style(TextCtrlStyle::MultiLine | TextCtrlStyle::ReadOnly | TextCtrlStyle::WordWrap) + .with_size(Size::new(-1, 80)) + .build(); + status.set_name("rabbit-done-status"); + sizer.add(&status, 0, SizerFlag::All | SizerFlag::Expand, 6); + + let show_details = CheckBox::builder(page) + .with_label(&model.text.done_show_details_label) + .build(); + // Mirror the OSARA-keymap checkbox pattern: on this wxdragon version + // the visible label appears to be driven by the wxWindow *name* on + // Windows (the `with_label` builder argument doesn't reliably stick), + // so set both name and label to the same localized string and the + // checkbox renders correctly in every locale. + show_details.set_name(&model.text.done_show_details_label); + show_details.set_label(&model.text.done_show_details_label); + show_details.add_style(WindowStyle::TabStop); + show_details.set_value(false); + sizer.add(&show_details, 0, SizerFlag::All, 6); + + let details = TextCtrl::builder(page) + .with_value("") + .with_style(TextCtrlStyle::MultiLine | TextCtrlStyle::ReadOnly | TextCtrlStyle::WordWrap) + .build(); + details.set_name("rabbit-done-details"); + details.hide(); + sizer.add(&details, 1, SizerFlag::All | SizerFlag::Expand, 6); + + let toggle_details = details; + let toggle_page = *page; + show_details.on_toggled(move |event| { + let visible = event.is_checked(); + toggle_details.show(visible); + toggle_page.layout(); + // Move keyboard focus into the details TextCtrl as soon as the + // user reveals it. Screen readers (NVDA, JAWS) announce the + // newly-focused control, which both confirms the checkbox click + // and reads out the install report without the user having to + // hunt for it via Tab. + if visible { + toggle_details.set_focus(); + } + }); + + let actions = BoxSizer::builder(Orientation::Horizontal).build(); + actions.add_stretch_spacer(1); + + let launch_reaper = Button::builder(page) + .with_label(&model.text.done_launch_reaper_label) + .build(); + launch_reaper.set_name("rabbit-done-launch-reaper"); + launch_reaper.add_style(WindowStyle::TabStop); + launch_reaper.set_can_focus(true); + launch_reaper.enable(false); + actions.add(&launch_reaper, 0, SizerFlag::All, 6); + + let open_resource = Button::builder(page) + .with_label(&model.text.done_open_resource_label) + .build(); + open_resource.set_name("rabbit-done-open-resource"); + open_resource.add_style(WindowStyle::TabStop); + open_resource.set_can_focus(true); + open_resource.enable(false); + actions.add(&open_resource, 0, SizerFlag::All, 6); + + sizer.add_sizer(&actions, 0, SizerFlag::All | SizerFlag::Expand, 0); + page.set_sizer(sizer, true); + (status, details, launch_reaper, open_resource) +} + +pub(crate) fn add_heading(page: &W, sizer: &BoxSizer, label: &str, name: &str) { + let heading = StaticText::builder(page).with_label(label).build(); + heading.set_name(name); + sizer.add(&heading, 0, SizerFlag::All | SizerFlag::Expand, 6); +} + +pub(crate) fn add_label(page: &W, sizer: &BoxSizer, label: &str, name: &str) { + let widget = StaticText::builder(page).with_label(label).build(); + widget.set_name(name); + sizer.add( + &widget, + 0, + SizerFlag::Left | SizerFlag::Right | SizerFlag::Top, + 6, + ); +} diff --git a/crates/rabbit-ui-wxdragon/src/wx_app/progress_ui.rs b/crates/rabbit-ui-wxdragon/src/wx_app/progress_ui.rs new file mode 100644 index 0000000..7793082 --- /dev/null +++ b/crates/rabbit-ui-wxdragon/src/wx_app/progress_ui.rs @@ -0,0 +1,358 @@ +//! Turns `ProgressEvent`s into the progress page's gauge, status line, and +//! log. + +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; + +use rabbit_core::localization::Localizer; +use rabbit_core::progress::ProgressEvent; + +use crate::wx_app::globals::with_ui_localizer; +use crate::wx_app::widgets::WizardWidgets; + +/// State carried across [`ProgressEvent`] notifications during a wizard +/// install. Holds the totals the install handler pre-computed up front +/// (so the gauge percentage is a fraction of completed work, not a +/// guess) plus per-package byte counters for every download currently +/// streaming — the core pipelines downloads on a small concurrent pool, +/// so several packages can be mid-download at once (FFmpeg streaming +/// while REAPER installs). Mutated only on the UI thread inside each +/// `call_after` closure; the `Arc>` wrapper is purely so the +/// closures satisfy `Send`. +#[derive(Debug, Clone)] +pub(crate) struct ProgressUiState { + /// Total packages selected for install. Each contributes two phases + /// (download + install) to the overall progress denominator. + pub(crate) total_packages: usize, + /// Total opted-in configuration steps. Each contributes one phase. + pub(crate) total_configuration_steps: usize, + /// Phases finished so far across all packages and configuration + /// steps. Bounded above by `total_packages * 2 + + /// total_configuration_steps`. + pub(crate) completed_phases: usize, + /// Byte counters of every in-flight download, keyed by package id: + /// `(bytes_downloaded, content_length_if_known)`. Inserted on + /// `DownloadStarted`, updated on `DownloadProgress`, removed on + /// `DownloadCompleted` (which also bumps `completed_phases`). + pub(crate) active_downloads: std::collections::HashMap)>, + /// Package currently running its install step, if any. While set, the + /// status label belongs to the install ("Installing REAPER…") and + /// background download ticks must not overwrite it — the install is + /// the foreground activity the user is waiting on. + pub(crate) current_install: Option, +} + +impl ProgressUiState { + pub(crate) fn new(total_packages: usize, total_configuration_steps: usize) -> Self { + Self { + total_packages, + total_configuration_steps, + completed_phases: 0, + active_downloads: std::collections::HashMap::new(), + current_install: None, + } + } + + /// Total phases the install will go through: every package emits a + /// download phase *and* an install phase, every opted-in + /// configuration step emits one phase. Always at least 1 so the + /// percentage math doesn't divide by zero on a no-op run. + pub(crate) fn total_phases(&self) -> usize { + (self.total_packages * 2 + self.total_configuration_steps).max(1) + } + + /// Gauge value in 0..=100. Combines completed phases with the byte + /// fraction of every in-flight download (when its `Content-Length` + /// is known) so the bar moves smoothly during a long download pull + /// rather than jumping in step-shaped chunks. Each download's + /// fraction is capped at its own single phase, so the sum stays + /// monotonic no matter how many downloads overlap. + pub(crate) fn percentage(&self) -> i32 { + let total = self.total_phases() as f64; + let mut fraction = self.completed_phases as f64; + for (bytes_downloaded, bytes_total) in self.active_downloads.values() { + if let Some(total_bytes) = bytes_total + && *total_bytes > 0 + { + fraction += (*bytes_downloaded as f64 / *total_bytes as f64).clamp(0.0, 1.0); + } + } + ((fraction / total) * 100.0).round().clamp(0.0, 100.0) as i32 + } + + /// Summed `(bytes_downloaded, bytes_total)` across all in-flight + /// downloads, for the aggregate status line. `bytes_total` is `None` + /// when any active download has an unknown length. + pub(crate) fn download_totals(&self) -> (u64, Option) { + let mut downloaded: u64 = 0; + let mut total: Option = Some(0); + for (bytes_downloaded, bytes_total) in self.active_downloads.values() { + downloaded += bytes_downloaded; + total = match (total, bytes_total) { + (Some(sum), Some(bytes)) => Some(sum + bytes), + _ => None, + }; + } + (downloaded, total) + } +} + +/// Aggregate status line for several concurrent downloads: +/// "Downloading N packages… X / Y". Used whenever more than one download +/// is in flight so the single status label doesn't flip-flop between +/// packages several times a second. +pub(crate) fn aggregate_download_status_line( + state: &ProgressUiState, + localizer: &Localizer, +) -> String { + let (downloaded, total) = state.download_totals(); + let count = state.active_downloads.len().to_string(); + let downloaded = format_bytes_human(downloaded); + let total = total.map_or_else(|| "?".to_string(), format_bytes_human); + localizer + .format( + "wizard-progress-status-downloading-many", + &[ + ("count", count.as_str()), + ("downloaded", downloaded.as_str()), + ("total", total.as_str()), + ], + ) + .value +} + +/// Render a byte count in the locale-neutral form `12.4 MB`. The wizard +/// has space for at most a single inline byte counter in the status +/// label, so we always pick whichever IEC unit gives a value below 1024 +/// and format it with one decimal place. Bytes (`< 1 KiB`) skip the +/// decimal entirely to avoid "0.4 B"-style nonsense. +pub(crate) fn format_bytes_human(bytes: u64) -> String { + const UNITS: &[&str] = &["B", "KB", "MB", "GB", "TB"]; + let mut value = bytes as f64; + let mut unit_idx = 0; + while value >= 1024.0 && unit_idx + 1 < UNITS.len() { + value /= 1024.0; + unit_idx += 1; + } + if unit_idx == 0 { + format!("{bytes} {}", UNITS[0]) + } else { + format!("{value:.1} {}", UNITS[unit_idx]) + } +} + +/// Apply a single [`ProgressEvent`] to the wizard's progress page. +/// Mutates the gauge value, replaces the status label, and appends a +/// new log line to the details TextCtrl (so a screen reader can read +/// each line as it lands). The package / configuration display-name +/// lookups go through the pre-built maps so this function never has to +/// touch the package spec list — the install handler builds the maps +/// once, before spawning the worker thread, and they're shared via +/// `Arc>`. +/// +/// `status_frozen` holds the status label at whatever the close handler +/// wrote there. Without it the next download or install event would paint +/// "Downloading OSARA…" over "Stopping…" and the wizard would look like it +/// had ignored the user. The log lines below keep flowing either way, so the +/// step that is still finishing stays visible. +pub(crate) fn apply_progress_event_to_ui( + state: &Arc>, + widgets: &WizardWidgets, + package_display_names: &Arc>, + configuration_display_names: &Arc>, + event: ProgressEvent, + status_frozen: bool, +) { + let mut state = state.lock().unwrap(); + let mut status_line: Option = None; + let mut log_line: Option = None; + + with_ui_localizer(|localizer| match &event { + ProgressEvent::DownloadStarted { + package_id, + bytes_total, + } => { + state + .active_downloads + .insert(package_id.clone(), (0, *bytes_total)); + let package = package_display_name(package_display_names, package_id); + // Downloads only own the status label while no install is + // running — an in-flight "Installing REAPER…" must not be + // stomped by a background download starting. And once several + // downloads are active, the label stays in aggregate form + // instead of flipping to whichever package started last. + if state.current_install.is_none() { + status_line = Some(if state.active_downloads.len() > 1 { + aggregate_download_status_line(&state, localizer) + } else { + localizer + .format( + "wizard-progress-status-downloading", + &[("package", package.as_str())], + ) + .value + }); + } + log_line = Some( + localizer + .format( + "wizard-progress-log-download-started", + &[("package", package.as_str())], + ) + .value, + ); + } + ProgressEvent::DownloadProgress { + package_id, + bytes_downloaded, + bytes_total, + } => { + let entry = state + .active_downloads + .entry(package_id.clone()) + .or_insert((0, None)); + entry.0 = *bytes_downloaded; + if bytes_total.is_some() { + entry.1 = *bytes_total; + } + // Downloads own the status label only while no install runs. + // One active download keeps today's per-package byte line; + // several concurrent downloads aggregate into one summary so + // the label (and a screen reader following it) doesn't + // flip-flop between unrelated packages several times a second. + if state.current_install.is_none() { + status_line = Some(if state.active_downloads.len() > 1 { + aggregate_download_status_line(&state, localizer) + } else { + let package = package_display_name(package_display_names, package_id); + let downloaded = format_bytes_human(*bytes_downloaded); + let total = bytes_total.map_or_else(|| "?".to_string(), format_bytes_human); + localizer + .format( + "wizard-progress-status-downloading-with-bytes", + &[ + ("package", package.as_str()), + ("downloaded", downloaded.as_str()), + ("total", total.as_str()), + ], + ) + .value + }); + } + // No log line: the running log shows discrete transitions, + // not intra-download tick-by-tick noise. + } + ProgressEvent::DownloadCompleted { package_id } => { + state.active_downloads.remove(package_id); + state.completed_phases += 1; + let package = package_display_name(package_display_names, package_id); + log_line = Some( + localizer + .format( + "wizard-progress-log-download-completed", + &[("package", package.as_str())], + ) + .value, + ); + } + ProgressEvent::InstallStarted { package_id } => { + state.current_install = Some(package_id.clone()); + let package = package_display_name(package_display_names, package_id); + status_line = Some( + localizer + .format( + "wizard-progress-status-installing", + &[("package", package.as_str())], + ) + .value, + ); + log_line = Some( + localizer + .format( + "wizard-progress-log-install-started", + &[("package", package.as_str())], + ) + .value, + ); + } + ProgressEvent::InstallCompleted { package_id } => { + state.current_install = None; + state.completed_phases += 1; + let package = package_display_name(package_display_names, package_id); + log_line = Some( + localizer + .format( + "wizard-progress-log-install-completed", + &[("package", package.as_str())], + ) + .value, + ); + } + ProgressEvent::ConfigurationStarted { step_id } => { + let step = configuration_display_name(configuration_display_names, step_id); + status_line = Some( + localizer + .format( + "wizard-progress-status-configuring", + &[("step", step.as_str())], + ) + .value, + ); + log_line = Some( + localizer + .format( + "wizard-progress-log-configuration-started", + &[("step", step.as_str())], + ) + .value, + ); + } + ProgressEvent::ConfigurationCompleted { step_id } => { + state.completed_phases += 1; + let step = configuration_display_name(configuration_display_names, step_id); + log_line = Some( + localizer + .format( + "wizard-progress-log-configuration-completed", + &[("step", step.as_str())], + ) + .value, + ); + } + }); + + widgets.progress_gauge.set_value(state.percentage()); + if let Some(line) = status_line + && !status_frozen + { + widgets.progress_status.set_label(&line); + } + // Hold the lock no longer than necessary — the TextCtrl call below + // re-enters the wxWidgets event pump, which can run other queued + // call_after closures. + drop(state); + if let Some(line) = log_line { + widgets.progress_details.append_text(&format!("\n{line}")); + } +} + +/// Resolve a `package_id` to its localized display name from the wizard +/// plan's pre-built map. Falls back to the raw id when the map doesn't +/// know the package — this only happens for synthetic test packages +/// that aren't in the wizard's PackageRow list, but the wizard should +/// still render *something* readable rather than panicking. +pub(crate) fn package_display_name(map: &Arc>, package_id: &str) -> String { + map.get(package_id) + .cloned() + .unwrap_or_else(|| package_id.to_string()) +} + +/// As [`package_display_name`] but for configuration-step ids. +pub(crate) fn configuration_display_name( + map: &Arc>, + step_id: &str, +) -> String { + map.get(step_id) + .cloned() + .unwrap_or_else(|| step_id.to_string()) +} diff --git a/crates/rabbit-ui-wxdragon/src/wx_app/self_update_ui.rs b/crates/rabbit-ui-wxdragon/src/wx_app/self_update_ui.rs new file mode 100644 index 0000000..17d8a20 --- /dev/null +++ b/crates/rabbit-ui-wxdragon/src/wx_app/self_update_ui.rs @@ -0,0 +1,596 @@ +//! Self-update in the wizard: the status line, the prompt with release +//! notes, and the progress window the apply step drives. + +use std::cell::RefCell; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; + +use crate::{ + WizardModel, format_self_update_apply_summary, format_self_update_check_summary, + relaunch_rabbit_after_apply, run_wizard_self_update_apply, +}; +use rabbit_core::localization::Localizer; +use rabbit_core::progress::{ProgressEvent, ProgressReporter}; +use rabbit_core::self_update::SelfUpdateCheckReport; + +use wxdragon::prelude::*; + +use crate::wx_app::expert_mode::expert_mode; + +use crate::wx_app::globals::{with_ui_frame, with_ui_localizer}; +use crate::wx_app::packages_page::{WXK_NUMPAD_ENTER, WXK_RETURN}; +use crate::wx_app::progress_ui::format_bytes_human; +use crate::wx_app::widgets::{WizardWidgets, append_done_status}; + +/// Set while the self-update worker is swapping RABBIT's own files. That +/// swap has no unwind path — the whole point of the progress window having +/// no Cancel button — so the close handler refuses to quit under it rather +/// than leaving a half-replaced executable behind. A plain static because +/// `start_self_update_apply` is reachable from the startup prompt and from +/// the Done page, neither of which shares the wizard's state. +pub(crate) static SELF_UPDATE_APPLYING: AtomicBool = AtomicBool::new(false); + +#[derive(Default)] +pub(crate) struct SelfUpdateUiState { + /// Result of the one-shot manifest check at startup. `None` while the + /// startup probe is still running; `Some(Ok)` on success; `Some(Err)` + /// carries the formatted error message (RabbitError isn't Clone). + pub(crate) check: Option>, + /// What's-New notes for the pending update, resolved by the same startup + /// worker as `check`. `None` when there is no update, or when the notes + /// couldn't be fetched — the prompt then falls back to its plain form. + pub(crate) release_notes: Option, + /// Last status string written to the status bar — used to suppress + /// screen-reader re-announcements when nothing has changed. + pub(crate) last_status: String, + /// `true` once the once-per-session "RABBIT update available" prompt + /// dialog has been shown. Re-renders that follow the same check + /// result (e.g., a step change that re-invokes render) skip the + /// modal so the user isn't re-prompted after dismissing it. + pub(crate) prompted: bool, +} + +pub(crate) fn render_self_update_status( + widgets: WizardWidgets, + model: &Arc, + localizer: &Localizer, + state: &Arc>, +) { + let mut state_guard = state.lock().unwrap(); + // Clone the check result up-front so the rest of the function can + // freely mutate `state_guard` without fighting the borrow checker + // over an `as_ref()` view of `state_guard.check`. The clone is + // cheap (a single `Result`) and the + // function only runs on completion of a one-shot manifest probe. + let Some(check) = state_guard.check.clone() else { + // Startup probe hasn't completed yet; leave the initial + // "Checking for RABBIT updates…" placeholder in place. + return; + }; + + // (The package-install lock used to be a single LocalAppData path so + // RABBIT could warn that another install was in progress before + // applying a self-update. With locks now scoped per-target we don't + // have a single global lock to consult here, so the cross-target + // status line is gone. Concurrent self-update + install on the same + // target still races at the file rename and surfaces a normal IO + // error.) + let status = match &check { + Ok(report) => format_self_update_check_summary(localizer, report), + Err(error) => format!("{}: {}", model.text.done_self_update_error_prefix, error), + }; + let apply_enabled = matches!(&check, Ok(report) if report.update_available); + + let status_changed = status != state_guard.last_status; + if status_changed { + widgets.self_update_status.set_status_text(&status, 0); + state_guard.last_status = status; + } + + // Once-per-session prompt: if an update is available, ask up front + // instead of forcing the user to navigate to the Done page to find + // the apply button. The Done-page button stays around as a fallback + // for users who pick "No" here and change their mind later. + if !apply_enabled || state_guard.prompted { + return; + } + state_guard.prompted = true; + let Ok(report) = check else { return }; + let release_notes = state_guard.release_notes.clone(); + // Drop the lock before showing the modal — `MessageDialog::show_modal` + // runs a nested wxWidgets event loop, and any UI-thread callback that + // re-enters `render_self_update_status` while the modal is open would + // deadlock on a still-held mutex. + drop(state_guard); + + let title = localizer.text("wizard-self-update-prompt-title").value; + let current = report.current_version.to_string(); + let latest = report.latest_version.to_string(); + let body = localizer + .format( + "wizard-self-update-prompt-body", + &[("current", current.as_str()), ("latest", latest.as_str())], + ) + .value; + + // Pull the frame from the UI-thread-local rather than from the + // captured `widgets` so we don't have to send a non-`Send` `Frame` + // through the `call_after` closure that wraps this function. The + // closure runs on the UI thread, so the thread-local was populated + // by `run()` before any worker fired. + with_ui_frame(|frame| { + let accepted = match release_notes.as_deref() { + Some(notes) => { + show_self_update_prompt_with_notes(frame, localizer, &title, &body, ¤t, notes) + } + // No notes to show (fetch failed, or the release carries no + // body): the plain native message box, exactly as before. + None => { + let dialog = MessageDialog::builder(frame, &body, &title) + .with_style( + MessageDialogStyle::YesNo + | MessageDialogStyle::IconQuestion + | MessageDialogStyle::Centre, + ) + .build(); + dialog.show_modal() == ID_YES + } + }; + + if accepted { + start_self_update_apply( + widgets.done_status, + widgets.self_update_status, + Arc::clone(model), + ); + } + }); +} + +/// The update prompt in its rich form: the same question the plain message +/// box asks, plus the release notes for everything the update brings, so the +/// answer to "should I say yes?" is on screen when the question is asked. +/// Returns `true` when the user accepted. +/// +/// A `wxMessageDialog` can't host this — its message is a single unscrollable +/// static label, and RABBIT's release notes routinely run past a screenful. +/// Hence a plain `Dialog` carrying a read-only multiline `TextCtrl`, which is +/// the same control (and therefore the same screen-reader behaviour) the +/// packages page already uses for a package's What's-New notes. +/// +/// Accessibility notes, in the spirit of the Done page: +/// - Focus parks on the notes control, so the screen reader reads what the +/// update contains instead of announcing a bare button. +/// - The notes control's accessible name carries the heading, since a +/// `StaticText` label sits outside the tab order and would otherwise never +/// be announced. +/// - Update is the default button, so Enter accepts from anywhere in the +/// dialog, and Escape maps to Later. +pub(crate) fn show_self_update_prompt_with_notes( + frame: &Frame, + localizer: &Localizer, + title: &str, + body: &str, + current: &str, + notes: &str, +) -> bool { + let notes_heading = localizer + .format( + "wizard-self-update-prompt-notes-heading", + &[("current", current)], + ) + .value; + let update_label = localizer + .text("wizard-self-update-prompt-update-button") + .value; + let later_label = localizer + .text("wizard-self-update-prompt-later-button") + .value; + + let dialog = Dialog::builder(frame, title) + // Resizable so a long set of notes can be opened up, rather than + // forcing everything through one fixed-height scroll region. + .with_style(DialogStyle::DefaultDialogStyle | DialogStyle::ResizeBorder) + .with_size(560, 420) + .build(); + // Controls go on a child Panel rather than straight onto the dialog: + // that's what gives MSW its dialog-navigation behaviour (Tab/arrow + // traversal between the notes and the buttons) for free. + let panel = Panel::builder(&dialog).build(); + let sizer = BoxSizer::builder(Orientation::Vertical).build(); + + let question = StaticText::builder(&panel).with_label(body).build(); + question.set_name("rabbit-self-update-prompt-question"); + sizer.add(&question, 0, SizerFlag::All | SizerFlag::Expand, 6); + + let heading = StaticText::builder(&panel) + .with_label(¬es_heading) + .build(); + heading.set_name("rabbit-self-update-prompt-notes-heading"); + sizer.add(&heading, 0, SizerFlag::All | SizerFlag::Expand, 6); + + let notes_text = TextCtrl::builder(&panel) + .with_value(notes) + .with_style(TextCtrlStyle::MultiLine | TextCtrlStyle::ReadOnly | TextCtrlStyle::WordWrap) + .build(); + notes_text.set_name(¬es_heading); + sizer.add(¬es_text, 1, SizerFlag::All | SizerFlag::Expand, 6); + + // The buttons carry the standard yes/no ids rather than generated ones + // so `set_escape_id` below has something to act on: wxWidgets answers + // Escape by emulating a click on the button holding the escape id, and + // does nothing at all when no button has it. + // + // `TabStop` + `set_can_focus` for the same reason the wizard's own + // navigation buttons carry them: without it macOS leaves buttons out of + // the Tab ring unless Full Keyboard Access is on. + let buttons = BoxSizer::builder(Orientation::Horizontal).build(); + let update = Button::builder(&panel) + .with_id(ID_YES) + .with_label(&update_label) + .build(); + update.set_name("rabbit-self-update-prompt-update"); + update.add_style(WindowStyle::TabStop); + update.set_can_focus(true); + buttons.add(&update, 0, SizerFlag::All, 6); + let later = Button::builder(&panel) + .with_id(ID_NO) + .with_label(&later_label) + .build(); + later.set_name("rabbit-self-update-prompt-later"); + later.add_style(WindowStyle::TabStop); + later.set_can_focus(true); + buttons.add(&later, 0, SizerFlag::All, 6); + sizer.add_sizer(&buttons, 0, SizerFlag::AlignRight, 0); + + panel.set_sizer(sizer, true); + // Same frame → panel → content nesting `run()` builds for the wizard + // window: the dialog's own sizer is what makes the panel track the + // dialog when it is resized. + let dialog_sizer = BoxSizer::builder(Orientation::Vertical).build(); + dialog_sizer.add(&panel, 1, SizerFlag::Expand, 0); + dialog.set_sizer(dialog_sizer, true); + + // `Dialog` is `Copy`, so each `move` handler closes over its own copy + // and the original stays usable below. Neither handler skips the event, + // so it stops at the button and wxDialog's own button handling never + // gets a second go at ending the modal. + update.on_click(move |_| dialog.end_modal(ID_YES)); + later.on_click(move |_| dialog.end_modal(ID_NO)); + dialog.set_affirmative_id(ID_YES); + dialog.set_escape_id(ID_NO); + update.set_default(); + + // A multiline TextCtrl claims Enter for itself (DLGC_WANTALLKEYS on + // MSW, the NSTextView swallows it on macOS), so the default button + // never sees the key while focus is parked on the notes — the same + // trap `bind_done_page_enter_closes` works around on the Done page. + notes_text.on_key_down(move |event| { + let key_code = if let WindowEventData::Keyboard(kbd) = &event { + kbd.get_key_code() + } else { + None + }; + if !matches!(key_code, Some(WXK_RETURN) | Some(WXK_NUMPAD_ENTER)) { + return; + } + // Consume the key so the read-only control neither beeps nor + // tries to insert a newline before the dialog closes. + event.skip(false); + dialog.end_modal(ID_YES); + }); + + notes_text.set_focus(); + let accepted = dialog.show_modal() == ID_YES; + dialog.destroy(); + accepted +} + +/// Trigger the self-update apply pipeline on a worker thread, routing +/// progress (start, summary, relaunch / error) to both the Done page's +/// `done_status` text control and the always-visible `self_update_status` +/// status bar. Two surfaces because the apply can be invoked from two +/// places: the Done page button (where `done_status` is the natural +/// detail surface and `self_update_status` is a redundant short-form), +/// and the once-per-session "RABBIT update available" prompt at startup +/// (where the user is on the Target step and only `self_update_status` +/// is visible). The duplication keeps both call sites simple — neither +/// has to know which surface their user can see. +/// +/// Takes individual widget handles rather than the full `WizardWidgets` +/// because that struct now holds a `Frame` (for parenting modal +/// dialogs) and `Frame` isn't `Send` — capturing the whole struct +/// into the spawned worker would break the closure's `Send` bound. +pub(crate) fn start_self_update_apply( + done_status: TextCtrl, + self_update_status: StatusBar, + model: Arc, +) { + append_done_status(&done_status, &model.text.done_self_update_apply_running); + self_update_status.set_status_text(&model.text.done_self_update_apply_running, 0); + // Put the progress window up before the worker starts, so the very + // first event has somewhere to land. It is modeless: the worker's + // completion path runs through `call_after` on this same thread, which + // a modal dialog's nested event loop would let run but would also trap + // the exit-after-relaunch behind its own dismissal. + open_self_update_progress_window(&model); + // Held for the whole swap so the close handler refuses to quit under a + // half-replaced RABBIT. Cleared on every exit from the worker, including + // the failure paths — the relaunch path exits the process instead. + SELF_UPDATE_APPLYING.store(true, Ordering::SeqCst); + let model_for_thread = Arc::clone(&model); + std::thread::spawn(move || { + // Every event crosses back to the UI thread — widgets must not be + // touched from here — and `ProgressEvent` is `Send`, so it rides + // inside the `call_after` closure. + let progress = ProgressReporter::new(|event| { + wxdragon::call_after(Box::new(move || { + update_self_update_progress_window(&event); + })); + }); + let result = run_wizard_self_update_apply(&progress); + SELF_UPDATE_APPLYING.store(false, Ordering::SeqCst); + wxdragon::call_after(Box::new(close_self_update_progress_window)); + wxdragon::call_after(Box::new(move || match result { + Ok(report) => { + with_ui_localizer(|localizer| { + let summary = format_self_update_apply_summary(localizer, &report); + append_done_status(&done_status, &summary); + self_update_status.set_status_text(&summary, 0); + }); + if !report.replaced_files.is_empty() { + match relaunch_rabbit_after_apply(expert_mode()) { + Ok(pid) => { + let msg = format!( + "{}: PID {}", + model_for_thread.text.done_self_update_relaunch_prefix, pid + ); + append_done_status(&done_status, &msg); + self_update_status.set_status_text(&msg, 0); + // Mirror relaunch_with_locale: hand off to the new + // process and exit, otherwise the pre-update GUI + // sticks around next to the freshly-launched copy. + std::process::exit(0); + } + Err(error) => { + let msg = format!( + "{}: {}", + model_for_thread.text.done_self_update_error_prefix, error + ); + append_done_status(&done_status, &msg); + self_update_status.set_status_text(&msg, 0); + } + } + } + } + Err(error) => { + let msg = format!( + "{}: {}", + model_for_thread.text.done_self_update_error_prefix, error + ); + append_done_status(&done_status, &msg); + self_update_status.set_status_text(&msg, 0); + } + })); + }); +} + +/// Widgets of the live self-update progress window. Held in a thread-local +/// because none of them are `Send`: the worker thread never touches them, +/// it only posts `ProgressEvent`s through `call_after`, and the closure +/// that runs on the UI thread picks the widgets up from here. +pub(crate) struct SelfUpdateProgressWindow { + pub(crate) dialog: Dialog, + pub(crate) status: StaticText, + pub(crate) gauge: Gauge, + pub(crate) log: TextCtrl, + /// Localized product name, resolved once at construction so each event + /// doesn't have to go back through the localizer for it. + pub(crate) app_name: String, +} + +thread_local! { + static SELF_UPDATE_PROGRESS: RefCell> = + const { RefCell::new(None) }; +} + +/// Share of the bar given to the download. The remainder covers the +/// install, which is a single opaque step: the bar jumps to this mark when +/// the bytes are in and finishes when the swap is done. +pub(crate) const SELF_UPDATE_DOWNLOAD_SHARE: i32 = 90; + +/// Put up the update progress window: a status line, a bar, and a running +/// log of the phases as they complete. +/// +/// The same three-part shape as the wizard's own progress page, for the +/// same reason — the bar is what a sighted user reads, while the log is +/// what a screen reader can be walked through afterwards, line by line, +/// which a bar alone never affords. Modeless, and it takes no buttons: the +/// update cannot be cancelled once started (nothing in the core can unwind +/// a half-applied swap), so a Cancel button would be a lie. +pub(crate) fn open_self_update_progress_window(model: &Arc) { + close_self_update_progress_window(); + with_ui_frame(|frame| { + with_ui_localizer(|localizer| { + let title = localizer.text("wizard-self-update-progress-title").value; + let app_name = localizer.text("app-short-name").value; + let dialog = Dialog::builder(frame, &title) + .with_style(DialogStyle::Caption | DialogStyle::ResizeBorder) + .with_size(460, 260) + .build(); + let panel = Panel::builder(&dialog).build(); + let sizer = BoxSizer::builder(Orientation::Vertical).build(); + + let status = StaticText::builder(&panel) + .with_label(&model.text.done_self_update_apply_running) + .build(); + status.set_name("rabbit-self-update-progress-status"); + sizer.add(&status, 0, SizerFlag::All | SizerFlag::Expand, 6); + + let gauge = Gauge::builder(&panel).with_range(100).build(); + gauge.set_name("rabbit-self-update-progress-gauge"); + sizer.add(&gauge, 0, SizerFlag::All | SizerFlag::Expand, 6); + + let log = TextCtrl::builder(&panel) + .with_value("") + .with_style( + TextCtrlStyle::MultiLine | TextCtrlStyle::ReadOnly | TextCtrlStyle::WordWrap, + ) + .build(); + log.set_name(&title); + sizer.add(&log, 1, SizerFlag::All | SizerFlag::Expand, 6); + + panel.set_sizer(sizer, true); + let dialog_sizer = BoxSizer::builder(Orientation::Vertical).build(); + dialog_sizer.add(&panel, 1, SizerFlag::Expand, 0); + dialog.set_sizer(dialog_sizer, true); + + // No Cancel button also means Escape does nothing: wxWidgets + // answers it by emulating a click on the escape-id button, and + // there is none here. That is the behaviour we want — dismissing + // the window would leave the update running unreported. + dialog.show(true); + log.set_focus(); + + SELF_UPDATE_PROGRESS.with(|cell| { + *cell.borrow_mut() = Some(SelfUpdateProgressWindow { + dialog, + status, + gauge, + log, + app_name, + }); + }); + }); + }); +} + +/// Fold one progress event into the window. Silently does nothing when no +/// window is up — events can still be in flight when it has been closed. +pub(crate) fn update_self_update_progress_window(event: &ProgressEvent) { + SELF_UPDATE_PROGRESS.with(|cell| { + let borrowed = cell.borrow(); + let Some(window) = borrowed.as_ref() else { + return; + }; + with_ui_localizer(|localizer| { + let package = window.app_name.as_str(); + match event { + ProgressEvent::DownloadStarted { .. } => { + window.gauge.set_value(0); + window.status.set_label( + &localizer + .format( + "wizard-progress-status-downloading", + &[("package", package)], + ) + .value, + ); + // Only the phase change is logged; the byte ticks below + // would flood a screen reader reading the log. + append_self_update_progress_log( + window, + &localizer + .format( + "wizard-progress-log-download-started", + &[("package", package)], + ) + .value, + ); + } + ProgressEvent::DownloadProgress { + bytes_downloaded, + bytes_total, + .. + } => { + // Without a Content-Length there is no fraction to show, + // so the bar stays where it is and the byte counter in + // the status line carries the news instead. + if let Some(total) = bytes_total.filter(|total| *total > 0) { + let ratio = (*bytes_downloaded as f64 / total as f64).clamp(0.0, 1.0); + window + .gauge + .set_value((ratio * SELF_UPDATE_DOWNLOAD_SHARE as f64) as i32); + } + let downloaded = format_bytes_human(*bytes_downloaded); + let total = bytes_total.map_or_else(|| "?".to_string(), format_bytes_human); + window.status.set_label( + &localizer + .format( + "wizard-progress-status-downloading-with-bytes", + &[ + ("package", package), + ("downloaded", downloaded.as_str()), + ("total", total.as_str()), + ], + ) + .value, + ); + } + ProgressEvent::DownloadCompleted { .. } => { + window.gauge.set_value(SELF_UPDATE_DOWNLOAD_SHARE); + append_self_update_progress_log( + window, + &localizer + .format( + "wizard-progress-log-download-completed", + &[("package", package)], + ) + .value, + ); + } + ProgressEvent::InstallStarted { .. } => { + window.status.set_label( + &localizer + .format("wizard-progress-status-installing", &[("package", package)]) + .value, + ); + append_self_update_progress_log( + window, + &localizer + .format( + "wizard-progress-log-install-started", + &[("package", package)], + ) + .value, + ); + } + ProgressEvent::InstallCompleted { .. } => { + window.gauge.set_value(100); + append_self_update_progress_log( + window, + &localizer + .format( + "wizard-progress-log-install-completed", + &[("package", package)], + ) + .value, + ); + } + // Configuration steps belong to package installs; RABBIT's + // own update never emits them. + ProgressEvent::ConfigurationStarted { .. } + | ProgressEvent::ConfigurationCompleted { .. } => {} + } + }); + }); +} + +/// Append one line to the progress window's log, keeping the newest line in +/// view. +pub(crate) fn append_self_update_progress_log(window: &SelfUpdateProgressWindow, line: &str) { + let existing = window.log.get_value(); + let separator = if existing.is_empty() { "" } else { "\n" }; + window + .log + .set_value(&format!("{existing}{separator}{line}")); +} + +/// Tear the progress window down. Safe to call when none is up, and safe to +/// call twice — the second call finds the slot empty. +pub(crate) fn close_self_update_progress_window() { + let window = SELF_UPDATE_PROGRESS.with(|cell| cell.borrow_mut().take()); + if let Some(window) = window { + window.dialog.destroy(); + } +} diff --git a/crates/rabbit-ui-wxdragon/src/wx_app/shell.rs b/crates/rabbit-ui-wxdragon/src/wx_app/shell.rs new file mode 100644 index 0000000..62e10d3 --- /dev/null +++ b/crates/rabbit-ui-wxdragon/src/wx_app/shell.rs @@ -0,0 +1,157 @@ +//! Hands work to the OS: open a URL or folder, launch REAPER, relaunch +//! RABBIT in another language. + +use std::path::Path; + +use crate::wx_app::expert_mode::expert_mode; +use std::process::Command; + +/// macOS: tell Cocoa what language this process is running in by setting the +/// `AppleLanguages` env var, which `[NSBundle preferredLocalizations]` honors +/// before falling back to the user's system-wide language preferences. The +/// bundle's `CFBundleLocalizations` (set in `packaging/macos/Info.plist`) +/// must list the same language codes for this to take effect — without that, +/// Cocoa refuses the override and falls through to its English default. The +/// payoff is VoiceOver picking a voice that matches the in-app UI language; +/// without it, the German UI gets read with the English voice on a system +/// configured for English. +/// +/// Uses the BCP-47 language subtag only (`de-DE` → `de`) because that's what +/// matches the `.lproj` directory names and avoids needing region-specific +/// voices to exist on the host. Caller is `run`, before any AppKit init has +/// happened — `AppleLanguages` is read on first access and cached. +#[cfg(target_os = "macos")] +pub(crate) fn seat_macos_apple_languages(locale: &str) { + let language = locale.split('-').next().unwrap_or(locale).trim(); + if language.is_empty() { + return; + } + // Property-list array literal — Cocoa's preferred encoding for + // `AppleLanguages` env var values. Single-language form is enough; we + // don't ship a fallback chain. + let value = format!("({language})"); + // SAFETY: `run` is called from `main` before any threads are spawned; + // edition-2024 `set_var` only requires unsafe to flag the cross-thread + // hazard, which doesn't apply at this point in startup. + unsafe { + std::env::set_var("AppleLanguages", value); + } +} + +#[cfg(not(target_os = "macos"))] +pub(crate) fn seat_macos_apple_languages(_locale: &str) {} + +/// Relaunch the running RABBIT executable with `RABBIT_LOCALE=` set so the +/// new locale takes effect immediately, then exit. Errors during relaunch are +/// printed to stderr and the current process keeps running so the user is not +/// left without a UI. +pub(crate) fn relaunch_with_locale(locale: &str) { + let exe = match std::env::current_exe() { + Ok(exe) => exe, + Err(error) => { + eprintln!("could not resolve current executable for relaunch: {error}"); + return; + } + }; + let mut command = Command::new(&exe); + command.env("RABBIT_LOCALE", locale); + // Expert mode is not saved anywhere, so a relaunch has to carry it - + // and must drop an inherited RABBIT_EXPERT if it was switched off. + if expert_mode() { + command.env(crate::EXPERT_MODE_ENV, "1"); + } else { + command.env_remove(crate::EXPERT_MODE_ENV); + } + match command.spawn() { + Ok(_) => std::process::exit(0), + Err(error) => { + eprintln!("could not relaunch RABBIT with locale {locale}: {error}"); + } + } +} + +pub(crate) fn open_external_url(url: &str) -> std::io::Result<()> { + #[cfg(target_os = "windows")] + { + // `cmd` is a console program, and RABBIT's release build owns no + // console to lend it, so without this a black window flashes up + // every time the user follows a link. The browser `start` hands the + // URL to is a separate process and still opens normally. + let mut command = Command::new("cmd"); + command.args(["/C", "start", "", url]); + rabbit_platform::process::without_console_window(&mut command).spawn()?; + Ok(()) + } + + #[cfg(target_os = "macos")] + { + Command::new("open").arg(url).spawn()?; + Ok(()) + } + + #[cfg(not(any(target_os = "windows", target_os = "macos")))] + { + let _ = url; + Err(std::io::Error::new( + std::io::ErrorKind::Unsupported, + "opening URLs is only implemented on Windows and macOS", + )) + } +} + +pub(crate) fn open_resource_folder(path: &Path) -> std::io::Result<()> { + #[cfg(target_os = "windows")] + { + Command::new("explorer.exe").arg(path).spawn()?; + Ok(()) + } + + #[cfg(target_os = "macos")] + { + Command::new("open").arg(path).spawn()?; + Ok(()) + } + + #[cfg(not(any(target_os = "windows", target_os = "macos")))] + { + let _ = path; + Err(std::io::Error::new( + std::io::ErrorKind::Unsupported, + "opening folders is only implemented on Windows and macOS", + )) + } +} + +/// Start REAPER. It never gets RABBIT's GitHub token: it may be running an +/// OSARA pull-request build. An app bundle started through `open` gets its +/// environment from launchd, not from RABBIT, so it needs nothing removed. +pub(crate) fn launch_reaper(path: &Path) -> std::io::Result<()> { + #[cfg(target_os = "windows")] + { + rabbit_platform::process::without_rabbit_secrets(&mut Command::new(path)).spawn()?; + Ok(()) + } + + #[cfg(target_os = "macos")] + { + if path + .extension() + .and_then(|extension| extension.to_str()) + .is_some_and(|extension| extension.eq_ignore_ascii_case("app")) + { + Command::new("open").arg(path).spawn()?; + } else { + rabbit_platform::process::without_rabbit_secrets(&mut Command::new(path)).spawn()?; + } + Ok(()) + } + + #[cfg(not(any(target_os = "windows", target_os = "macos")))] + { + let _ = path; + Err(std::io::Error::new( + std::io::ErrorKind::Unsupported, + "launching REAPER is only implemented on Windows and macOS", + )) + } +} diff --git a/crates/rabbit-ui-wxdragon/src/wx_app/version_check.rs b/crates/rabbit-ui-wxdragon/src/wx_app/version_check.rs new file mode 100644 index 0000000..1cc83bc --- /dev/null +++ b/crates/rabbit-ui-wxdragon/src/wx_app/version_check.rs @@ -0,0 +1,379 @@ +//! The version-check page: fans out upstream version lookups on worker +//! threads and folds the answers back into the package rows. + +use std::cell::{Cell, RefCell}; +use std::collections::HashMap; +use std::path::PathBuf; +use std::rc::Rc; +use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; + +use crate::{ + ConfigurationRow, PackageRow, TargetRow, WizardModel, apply_version_check_failures_to_rows, + localized_package_display_name, localizer_from_options, + recompute_configuration_row_availability, wizard_desired_package_ids, + wizard_package_plan_for_target_with_available, +}; +use rabbit_core::detection::detect_components; +use rabbit_core::latest::fetch_latest_details_for_package_on; +use rabbit_core::model::Platform; +use rabbit_core::package::PACKAGE_REAPER; +use rabbit_core::plan::AvailablePackage; +use rabbit_core::version::Version; +use wxdragon::widgets::SimpleBook; + +use wxdragon::prelude::*; + +use crate::wx_app::expert_mode::RUN_AVAILABLE; +use crate::wx_app::globals::VERSION_CHECK_GENERATION; + +use crate::wx_app::PACKAGES_STEP; +use crate::wx_app::globals::{ + VersionCheckEvent, dispatch_version_check_event, install_version_check_dispatcher, + with_ui_localizer, +}; +use crate::wx_app::packages_page::{ + PackagesStateCell, focus_packages_list_top, rebuild_package_list_widgets, +}; +use crate::wx_app::widgets::{ + WizardWidgets, effective_can_install, reapack_ack_confirmed, update_navigation, +}; + +/// Captures everything the version-check dispatcher needs to drive the +/// dedicated version-check page: widgets, model, package-row state for the +/// auto-rebuild on success, and the navigation handles needed to advance to +/// the Packages step. +pub(crate) struct VersionCheckUi { + pub(crate) widgets: WizardWidgets, + pub(crate) model: Arc, + pub(crate) package_rows: Rc>>, + pub(crate) package_notes: Rc>>, + pub(crate) configuration_rows: Rc>>, + pub(crate) package_items: PackagesStateCell, + pub(crate) can_install: Rc>, + pub(crate) review_can_install: Rc>, + pub(crate) target: TargetRow, + /// Which builds to check each package on (empty outside expert mode). + pub(crate) channels: rabbit_core::package::PackageChannels, + pub(crate) book: SimpleBook, + pub(crate) step_label: StaticText, + pub(crate) labels: Arc>, + pub(crate) back: Button, + pub(crate) next: Button, + pub(crate) install: Button, + pub(crate) close: Button, + pub(crate) current_step: Arc, +} + +/// Reset the version-check page to its starting state, install the dispatcher +/// that handles per-package events on the UI thread, and spawn the worker +/// thread. The dispatcher auto-advances to the Packages step on full success; +/// on any failure it stays on the version-check page with the error log +/// populated and the Back button enabled. +pub(crate) fn start_version_check(ui: VersionCheckUi) { + let package_ids = wizard_desired_package_ids(ui.model.platform); + let package_count = package_ids.len() as i32; + // Snapshot what the worker's since-installed What's-New trim needs + // before `ui` moves into the dispatcher closure below. + let target_resource_path = ui.target.path.clone(); + let target_platform = ui.model.platform; + let target_reaper_version = ui.target.version.clone(); + let channels = ui.channels.clone(); + ui.widgets + .version_check_status + .set_label(&ui.model.text.version_check_status_pending); + ui.widgets.version_check_gauge.set_value(0); + ui.widgets + .version_check_gauge + .set_range(package_count.max(1)); + ui.widgets.version_check_error_log.set_value(""); + // The error region stays out of the tab order and the a11y tree until a + // check actually fails — see render_version_check_errors for the show. + ui.widgets.version_check_error_heading.hide(); + ui.widgets.version_check_error_log.hide(); + + let mut accumulated: Vec = Vec::new(); + let mut errors: Vec<(String, String)> = Vec::new(); + let mut completed: i32 = 0; + + let dispatcher = move |event: VersionCheckEvent| match event { + VersionCheckEvent::Result { + package_id, + outcome, + } => { + completed += 1; + ui.widgets.version_check_gauge.set_value(completed); + with_ui_localizer(|localizer| { + let line = localizer + .format( + "wizard-version-check-status-progress", + &[ + ("done", completed.to_string().as_str()), + ("total", package_count.to_string().as_str()), + ], + ) + .value; + ui.widgets.version_check_status.set_label(&line); + }); + match outcome { + Ok((version_str, whats_new, channel)) => { + match rabbit_core::version::Version::parse(&version_str) { + Ok(version) => { + accumulated.push(AvailablePackage { + package_id, + version: Some(version), + whats_new, + channel, + }); + } + Err(error) => { + errors.push((package_id, error.to_string())); + } + } + } + Err(message) => { + errors.push((package_id, message)); + } + } + } + VersionCheckEvent::Finished => { + // Per-package failures (an upstream being down, a parse error) + // no longer block the wizard: the plan is built from whatever + // versions did resolve, and each failed package's row is + // disabled with a localized reason + a Review-page note carrying + // the full error. Only a failure to build the plan itself keeps + // the wizard on this page with the error log shown. + { + RUN_AVAILABLE.with(|cell| *cell.borrow_mut() = Some(accumulated.clone())); + match wizard_package_plan_for_target_with_available( + &ui.model, + Some(&ui.target), + &accumulated, + ) { + Ok(mut plan) => { + if !errors.is_empty() + && let Ok(localizer) = + localizer_from_options(&ui.model.bootstrap_options) + { + plan.can_install = apply_version_check_failures_to_rows( + &localizer, + &mut plan.package_rows, + &mut plan.notes, + &errors, + ); + } + *ui.package_rows.borrow_mut() = plan.package_rows; + *ui.package_notes.borrow_mut() = plan.notes; + // The deferred fetch may have promoted ReaPack to + // Update (or vice versa); refresh configuration + // row availability against the fresh plan. + if let Ok(localizer) = localizer_from_options(&ui.model.bootstrap_options) { + recompute_configuration_row_availability( + &localizer, + &ui.package_rows.borrow(), + Some(&ui.target.path), + &mut ui.configuration_rows.borrow_mut(), + ); + } + ui.can_install.set(plan.can_install); + ui.review_can_install.set(false); + // Show the Spanish variant the target already has + // installed, so a Team PMA user isn't presented with + // an unticked box that would switch them back. + ui.widgets + .spanish_variant_choice + .set_selection(crate::spanish_variant_selection(&ui.target.path)); + rebuild_package_list_widgets( + &ui.widgets, + &ui.package_items, + &ui.model, + &ui.package_rows.borrow(), + &ui.configuration_rows.borrow(), + ); + ui.current_step.store(PACKAGES_STEP, Ordering::SeqCst); + update_navigation( + PACKAGES_STEP, + &ui.book, + &ui.step_label, + ui.labels.as_slice(), + &ui.back, + &ui.next, + &ui.install, + &ui.close, + &ui.widgets.language_footer, + effective_can_install(&ui.can_install, &ui.review_can_install), + true, + reapack_ack_confirmed(&ui.widgets), + ); + // The wizard just auto-advanced onto the packages + // page: land the screen reader on the top of the + // list (the "Packages" group header), not wherever + // the native control left its caret after the + // repopulation above. Mirrors the explicit gauge + // focus on entering the version-check page. + focus_packages_list_top(&ui.widgets, &ui.package_items); + } + Err(error) => { + errors.push((String::new(), error.to_string())); + render_version_check_errors(&ui, &errors); + } + } + } + } + }; + + install_version_check_dispatcher(Box::new(dispatcher)); + let generation = VERSION_CHECK_GENERATION.fetch_add(1, Ordering::SeqCst) + 1; + spawn_version_check_worker( + package_ids, + target_resource_path, + target_platform, + target_reaper_version, + channels, + generation, + ); +} + +/// Render error lines to the version-check page's error TextCtrl and update +/// the status text to point the user at Back/Close. +pub(crate) fn render_version_check_errors(ui: &VersionCheckUi, errors: &[(String, String)]) { + with_ui_localizer(|localizer| { + let mut lines = Vec::with_capacity(errors.len()); + for (package_id, message) in errors { + let display = if package_id.is_empty() { + String::new() + } else { + localized_package_display_name(localizer, package_id) + }; + let line = localizer + .format( + "wizard-version-check-error-line", + &[("package", display.as_str()), ("message", message.as_str())], + ) + .value; + lines.push(line); + } + ui.widgets + .version_check_error_log + .set_value(&lines.join("\n")); + // Surface the error region now that there is content for screen + // readers + the tab order to expose. + ui.widgets.version_check_error_heading.show(true); + ui.widgets.version_check_error_log.show(true); + let status = localizer + .format( + "wizard-version-check-status-error", + &[("error_count", errors.len().to_string().as_str())], + ) + .value; + ui.widgets.version_check_status.set_label(&status); + }); +} + +/// Spawn the deferred latest-version fetch on a background thread. Each +/// per-package outcome is forwarded to the UI thread via `call_after`, which +/// invokes the dispatcher installed by the click handler. +/// +/// Before fetching, the worker detects what's already installed at the +/// target so each package's What's-New notes can be trimmed to the changes +/// since the installed version. Detection here is best-effort: a failure +/// just means untrimmed notes, never a failed check. +/// How many latest-version checks run at once. Matches the download pool's +/// modest fan-out: enough to hide one slow check behind the others without +/// hammering hosts that serve several packages. +pub(crate) const VERSION_CHECK_CONCURRENCY: usize = 4; + +pub(crate) fn spawn_version_check_worker( + package_ids: Vec, + resource_path: PathBuf, + platform: Platform, + target_reaper_version: Option, + channels: rabbit_core::package::PackageChannels, + generation: u64, +) { + std::thread::spawn(move || { + let mut installed_versions: HashMap = + detect_components(&resource_path, platform) + .map(|detections| { + detections + .into_iter() + .filter(|detection| detection.installed) + .filter_map(|detection| Some((detection.package_id, detection.version?))) + .collect() + }) + .unwrap_or_default(); + // REAPER itself is versioned by the selected target row, not by the + // component detections that cover the extensions. + if let Some(version) = target_reaper_version { + installed_versions.insert(PACKAGE_REAPER.to_string(), version); + } + // Check packages CONCURRENTLY. The checks are independent network + // fetches against different hosts, and their costs are wildly + // uneven — most resolve in about a second, but a language pack is + // identified by the hash of its contents, so checking one downloads + // the whole file (the German pack is ~1.7 MB and takes several + // seconds). Run sequentially that one check stalled the whole + // progress bar; overlapped, the run costs roughly the slowest single + // check instead of the sum of all of them. + // + // Bounded pool rather than one thread per package: a handful of + // parallel requests is polite to the upstream hosts (several + // packages share one) and is where the wall-clock win already + // saturates. + let queue = std::sync::Arc::new(std::sync::Mutex::new( + package_ids + .into_iter() + .collect::>(), + )); + let installed_versions = std::sync::Arc::new(installed_versions); + let channels = std::sync::Arc::new(channels); + let worker_count = + VERSION_CHECK_CONCURRENCY.min(queue.lock().map(|q| q.len()).unwrap_or(1).max(1)); + let mut workers = Vec::with_capacity(worker_count); + for _ in 0..worker_count { + let queue = std::sync::Arc::clone(&queue); + let installed_versions = std::sync::Arc::clone(&installed_versions); + let channels = std::sync::Arc::clone(&channels); + workers.push(std::thread::spawn(move || { + loop { + // Scope the lock so it is never held across a fetch. + let next = queue.lock().ok().and_then(|mut q| q.pop_front()); + let Some(package_id) = next else { + break; + }; + let installed = installed_versions.get(&package_id); + let channel = channels.get(&package_id).map(String::as_str); + let outcome = match fetch_latest_details_for_package_on( + &package_id, + installed, + channel, + ) { + Ok(details) => Ok(( + details.version.to_string(), + details.whats_new, + details.channel, + )), + Err(error) => Err(error.to_string()), + }; + + let id_for_result = package_id.clone(); + wxdragon::call_after(Box::new(move || { + dispatch_version_check_event( + generation, + VersionCheckEvent::Result { + package_id: id_for_result, + outcome, + }, + ); + })); + } + })); + } + for worker in workers { + let _ = worker.join(); + } + wxdragon::call_after(Box::new(move || { + dispatch_version_check_event(generation, VersionCheckEvent::Finished); + })); + }); +} diff --git a/crates/rabbit-ui-wxdragon/src/wx_app/widgets.rs b/crates/rabbit-ui-wxdragon/src/wx_app/widgets.rs new file mode 100644 index 0000000..9260619 --- /dev/null +++ b/crates/rabbit-ui-wxdragon/src/wx_app/widgets.rs @@ -0,0 +1,572 @@ +//! The widget handles `run` passes around, plus the small readers and +//! updaters the event callbacks use to keep them in sync. + +use std::cell::Cell; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex}; + +use crate::{ + OsaraKeymapChoice, PackageRow, TargetRow, WizardModel, WizardOutcomeReport, + custom_portable_target_row, osara_keymap_note, osara_selected_for_rows, refreshed_target_row, +}; +use wxdragon::widgets::SimpleBook; + +use wxdragon::prelude::*; + +use crate::wx_app::packages_page::{PackagesView, WXK_NUMPAD_ENTER, WXK_RETURN}; +use crate::wx_app::{ + DONE_STEP, PACKAGES_STEP, PROGRESS_STEP, REAPACK_ACK_STEP, REVIEW_STEP, TARGET_STEP, +}; + +#[derive(Clone, Copy)] +pub(crate) struct WizardWidgets { + pub(crate) target_choice: Choice, + pub(crate) portable_folder: TextCtrl, + pub(crate) target_details: TextCtrl, + /// Expert mode's build choices on the target page; hidden otherwise. + pub(crate) reaper_build_choice: Choice, + pub(crate) osara_build_choice: Choice, + pub(crate) version_check_status: StaticText, + pub(crate) version_check_gauge: Gauge, + pub(crate) version_check_error_heading: StaticText, + pub(crate) version_check_error_log: TextCtrl, + pub(crate) package_checklist: PackagesView, + pub(crate) package_details: TextCtrl, + pub(crate) osara_keymap_replace: CheckBox, + pub(crate) osara_keymap_note: TextCtrl, + /// Which installed language pack REAPER should use. Enabled only while + /// at least one language pack is selected; several can be installed + /// side by side, but only one is active. + pub(crate) reaper_language_choice: Choice, + /// Choice between the two Spanish OSARA translations. Enabled only + /// while the Spanish language pack is selected. + pub(crate) spanish_variant_choice: Choice, + pub(crate) reapack_ack_confirm: CheckBox, + pub(crate) review_text: TextCtrl, + pub(crate) progress_status: StaticText, + pub(crate) progress_gauge: Gauge, + pub(crate) progress_details: TextCtrl, + pub(crate) done_status: TextCtrl, + pub(crate) done_details: TextCtrl, + pub(crate) done_launch_reaper: Button, + pub(crate) done_open_resource: Button, + pub(crate) self_update_status: StatusBar, + /// Child Panel hosting the language picker + restart-note label, + /// rendered below the wizard buttons. Hidden on every step except + /// `TARGET_STEP` because switching languages relaunches RABBIT, so the + /// dropdown is only useful before the user has invested any wizard + /// progress. + pub(crate) language_footer: Panel, +} + +pub(crate) fn selected_target_details( + model: &WizardModel, + choice: &Choice, + portable_folder: &TextCtrl, +) -> String { + match choice.get_selection().map(|index| index as usize) { + Some(index) if index == portable_choice_index(model) => { + portable_target_details(model, portable_folder) + } + Some(index) => target_details_for_index(model, index), + None => model.text.target_empty.clone(), + } +} + +pub(crate) fn target_details_for_index(model: &WizardModel, index: usize) -> String { + model + .target_rows + .get(index) + .map(|row| refreshed_target_row(model, row).details) + .unwrap_or_else(|| model.text.target_empty.clone()) +} + +pub(crate) fn package_details(row: &crate::PackageRow) -> String { + row.details.clone() +} + +pub(crate) fn progress_details_for_start( + model: &WizardModel, + target: Option<&TargetRow>, + selected_package_indices: &[usize], + package_rows: &[crate::PackageRow], + osara_keymap_choice: OsaraKeymapChoice, + cache_dir: Option<&Path>, +) -> String { + let mut lines = vec![model.text.progress_details_starting.clone()]; + if let Some(target) = target { + lines.push(format!( + "{}: {}", + model.text.review_target_prefix, + target.path.display() + )); + } else { + lines.push(model.text.review_no_target.clone()); + } + + if selected_package_indices.is_empty() { + lines.push(model.text.review_no_package.clone()); + } else { + for index in selected_package_indices { + if let Some(row) = package_rows.get(*index) { + lines.push(format!("{}: {}", row.display_name, row.action_label)); + } + } + } + + if osara_selected_for_rows(package_rows, selected_package_indices) { + lines.push(model.text.review_osara_keymap_heading.clone()); + lines.push(match osara_keymap_choice { + OsaraKeymapChoice::PreserveCurrent => model.text.review_osara_keymap_preserve.clone(), + OsaraKeymapChoice::ReplaceCurrent => model.text.review_osara_keymap_replace.clone(), + }); + } + + if let Some(cache_dir) = cache_dir { + lines.push(format!( + "{}: {}", + model.text.progress_details_cache_prefix, + cache_dir.display() + )); + } + + lines.join("\n") +} + +pub(crate) fn step_status(model: &WizardModel, step: usize) -> String { + model + .steps + .get(step) + .map(|step| step.label.clone()) + .unwrap_or_else(|| model.window_title.clone()) +} + +pub(crate) fn selected_target_row( + model: &WizardModel, + widgets: &WizardWidgets, +) -> Option { + let index = widgets.target_choice.get_selection()? as usize; + if index == portable_choice_index(model) { + return portable_folder_path(&widgets.portable_folder) + .map(|path| custom_portable_target_row(model, path, true)); + } + model + .target_rows + .get(index) + .map(|row| refreshed_target_row(model, row)) +} + +pub(crate) fn refreshed_target_index( + model: &WizardModel, + widgets: &WizardWidgets, +) -> Option { + widgets.target_choice.get_selection().map(|index| { + let index = index as usize; + if index == portable_choice_index(model) { + portable_choice_index(model) + } else { + index + } + }) +} + +pub(crate) fn refresh_target_choice( + model: &WizardModel, + choice: &Choice, + selected_index: Option, + refreshed_target: &TargetRow, +) { + let selected_index = selected_index.unwrap_or_else(|| portable_choice_index(model)); + choice.clear(); + for (index, row) in model.target_rows.iter().enumerate() { + if index == selected_index { + choice.append(&refreshed_target.label); + } else { + choice.append(&row.label); + } + } + choice.append(&model.text.target_portable_choice); + choice.set_selection(selected_index as u32); +} + +pub(crate) fn checked_package_indices(rows: &[PackageRow]) -> Vec { + rows.iter() + .enumerate() + .filter(|(_, row)| row.selected) + .map(|(index, _)| index) + .collect() +} + +pub(crate) fn osara_keymap_choice(checkbox: &CheckBox) -> OsaraKeymapChoice { + if checkbox.get_value() { + OsaraKeymapChoice::ReplaceCurrent + } else { + OsaraKeymapChoice::PreserveCurrent + } +} + +pub(crate) fn effective_can_install( + plan_can_install: &Cell, + review_can_install: &Cell, +) -> bool { + plan_can_install.get() && review_can_install.get() +} + +/// Enable the Spanish OSARA-translation choice only while the Spanish +/// language pack is actually selected for install — the setting has no +/// effect otherwise, and a live-but-inert control is a trap for a screen +/// reader. Mirrors how the OSARA key-map choice is gated on OSARA. +/// Refill the "REAPER language after installation" dropdown from the +/// language packs currently ticked, preserving the user's pick when it is +/// still on the list. Disabled when nothing is ticked, since there would be +/// nothing to choose. Several packs can be installed at once, so this is a +/// list rather than a consequence of which pack was chosen. +pub(crate) fn sync_reaper_language_widget(rows: &[crate::PackageRow], choice: &Choice) { + let selected_indices = checked_package_indices(rows); + let packs = crate::selected_language_packs(rows, &selected_indices); + let previous = choice + .get_selection() + .and_then(|index| choice.get_string(index)); + choice.clear(); + for (_, display_name) in &packs { + choice.append(display_name); + } + let restored = previous + .and_then(|name| packs.iter().position(|(_, display)| *display == name)) + .unwrap_or(0); + if !packs.is_empty() { + choice.set_selection(restored as u32); + } + choice.enable(!packs.is_empty()); + choice.set_can_focus(!packs.is_empty()); + set_optional_choice_shown(choice, REAPER_LANGUAGE_LABEL_NAME, !packs.is_empty()); +} + +pub(crate) fn sync_spanish_variant_widget(rows: &[crate::PackageRow], choice: &Choice) { + let selected_indices = checked_package_indices(rows); + let selected = crate::variant_choice_package_selected(rows, &selected_indices); + choice.enable(selected); + choice.set_can_focus(selected); + set_optional_choice_shown(choice, SPANISH_VARIANT_LABEL_NAME, selected); +} + +/// wxWindow names of the labels sitting above the two Packages-page +/// dropdowns that only apply to some selections. `set_optional_choice_shown` +/// looks a label up by name from the dropdown's parent page, which keeps the +/// dozen call sites that already carry a dropdown handle unchanged. +pub(crate) const REAPER_LANGUAGE_LABEL_NAME: &str = "rabbit-reaper-language-label"; +pub(crate) const SPANISH_VARIANT_LABEL_NAME: &str = "rabbit-spanish-variant-label"; + +/// Show or hide a dropdown together with its label, then re-lay out the page +/// so the row's space is reclaimed (or given back). +/// +/// A dropdown that doesn't apply to the current selection used to stay on +/// the page, greyed out and — for the REAPER language — empty. Tab already +/// skipped it, but a screen reader walking the page still stopped on a dead +/// control and read out an empty combo box; hiding it removes it from the +/// accessibility tree entirely, and it comes straight back when ticking a +/// package makes the choice mean something again. +pub(crate) fn set_optional_choice_shown(choice: &Choice, label_name: &str, shown: bool) { + if choice.is_shown() == shown { + return; + } + choice.show(shown); + let Some(page) = choice.get_parent() else { + return; + }; + if let Some(label) = page.find_window_by_name(label_name) { + label.show(shown); + } + // The sizer reads each child's visibility when it lays the page out, so + // the hidden row only stops taking vertical space once we ask for it. + page.layout(); +} + +pub(crate) fn sync_osara_keymap_widgets( + model: &WizardModel, + rows: &[crate::PackageRow], + checkbox: &CheckBox, + note: &TextCtrl, +) { + let selected_indices = checked_package_indices(rows); + let osara_selected = osara_selected_for_rows(rows, &selected_indices); + checkbox.enable(osara_selected); + checkbox.set_can_focus(osara_selected); + note.set_value(&osara_keymap_note( + model, + osara_selected, + osara_keymap_choice(checkbox), + )); + note.enable(osara_selected); + note.set_can_focus(osara_selected); +} + +pub(crate) fn portable_choice_index(model: &WizardModel) -> usize { + model.target_rows.len() +} + +pub(crate) fn portable_folder_path(portable_folder: &TextCtrl) -> Option { + let path = portable_folder.get_value(); + let path = path.trim(); + if path.is_empty() { + None + } else { + Some(PathBuf::from(path)) + } +} + +pub(crate) fn portable_target_details(model: &WizardModel, portable_folder: &TextCtrl) -> String { + portable_folder_path(portable_folder) + .map(|path| custom_portable_target_row(model, path, true).details) + .unwrap_or_else(|| model.text.target_portable_pending_details.clone()) +} + +pub(crate) fn target_is_valid(model: &WizardModel, widgets: &WizardWidgets) -> bool { + selected_target_row(model, widgets) + .map(|target| target.writable) + .unwrap_or(false) +} + +/// Whether the user has checked the ReaPack-donation acknowledgement on +/// the dedicated wizard page. Used by `update_navigation` to gate the +/// Next button on REAPACK_ACK_STEP — the page never shows up in the run +/// at all when ReaPack isn't being installed/updated, so on every other +/// step this value is irrelevant. +pub(crate) fn reapack_ack_confirmed(widgets: &WizardWidgets) -> bool { + widgets.reapack_ack_confirm.get_value() +} + +pub(crate) fn bind_reapack_ack_navigation_updates( + widgets: WizardWidgets, + current_step: &Arc, + next: &Button, +) { + let current_step = Arc::clone(current_step); + let next = *next; + widgets.reapack_ack_confirm.on_toggled(move |event| { + if current_step.load(Ordering::SeqCst) == REAPACK_ACK_STEP { + next.enable(event.is_checked()); + } + }); +} + +/// A multiline `wxTextCtrl` claims Enter for itself (it reports +/// `DLGC_WANTALLKEYS` on MSW and the NSTextView swallows the key on macOS), +/// so the window's default button never sees it. The Done page deliberately +/// parks focus on the read-only summary TextCtrl so the screen reader reads +/// the outcome out loud — which would otherwise leave Enter dead on the very +/// last page even with Close as the default button. Re-route Enter from the +/// Done page's two read-only TextCtrls to the Close action. +/// +/// Guarded on `DONE_STEP`: these controls only live on the Done page, but the +/// guard is what guarantees Enter can never tear the window down while the +/// install worker is still running — `DONE_STEP` is stored only after the +/// worker finished, or before it is even spawned on a preparation error. +pub(crate) fn bind_done_page_enter_closes( + text: &TextCtrl, + frame: &Frame, + current_step: &Arc, +) { + let frame = *frame; + let current_step = Arc::clone(current_step); + text.on_key_down(move |event| { + let key_code = if let WindowEventData::Keyboard(kbd) = &event { + kbd.get_key_code() + } else { + None + }; + if !matches!(key_code, Some(WXK_RETURN) | Some(WXK_NUMPAD_ENTER)) { + return; + } + if current_step.load(Ordering::SeqCst) != DONE_STEP { + return; + } + // Consume the key before closing so the native control never beeps + // or inserts a newline. `Frame::close(true)` goes through + // wxEVT_CLOSE_WINDOW → Destroy(), which defers via wxPendingDelete, + // so tearing the window down from inside a key handler is safe. + event.skip(false); + frame.close(true); + }); +} + +pub(crate) fn bind_target_navigation_updates( + model: &Arc, + widgets: WizardWidgets, + current_step: &Arc, + next: &Button, +) { + { + let model = Arc::clone(model); + let current_step = Arc::clone(current_step); + let next = *next; + widgets.target_choice.on_selection_changed(move |_| { + if current_step.load(Ordering::SeqCst) == TARGET_STEP { + next.enable(target_is_valid(&model, &widgets)); + } + }); + } + { + let model = Arc::clone(model); + let current_step = Arc::clone(current_step); + let next = *next; + widgets.portable_folder.on_text_changed(move |_| { + if current_step.load(Ordering::SeqCst) == TARGET_STEP { + next.enable(target_is_valid(&model, &widgets)); + } + }); + } +} + +pub(crate) fn configure_portable_folder( + portable_folder: &TextCtrl, + portable_folder_browse: &Button, + enabled: bool, +) { + portable_folder.enable(enabled); + portable_folder.set_can_focus(enabled); + portable_folder_browse.enable(enabled); + portable_folder_browse.set_can_focus(enabled); +} + +pub(crate) fn set_last_report( + state: &Arc>>, + report: Option, +) { + if let Ok(mut slot) = state.lock() { + *slot = report; + } +} + +pub(crate) fn set_last_resource_path(state: &Arc>>, path: Option) { + set_last_path(state, path); +} + +pub(crate) fn clone_last_resource_path(state: &Arc>>) -> Option { + clone_last_path(state) +} + +pub(crate) fn set_last_path(state: &Arc>>, path: Option) { + if let Ok(mut slot) = state.lock() { + *slot = path; + } +} + +pub(crate) fn clone_last_path(state: &Arc>>) -> Option { + state.lock().ok().and_then(|slot| slot.clone()) +} + +pub(crate) fn planned_reaper_launch_path_for_target(target: &TargetRow) -> PathBuf { + target.planned_app_path.clone() +} + +pub(crate) fn can_launch_reaper_path(path: Option<&Path>) -> bool { + path.is_some_and(Path::exists) +} + +pub(crate) fn can_launch_last_reaper_path(state: &Arc>>) -> bool { + can_launch_reaper_path(clone_last_path(state).as_deref()) +} + +pub(crate) fn append_done_status(status: &TextCtrl, message: &str) { + let current = status.get_value(); + if current.trim().is_empty() { + status.set_value(message); + } else { + status.set_value(&format!("{current}\n\n{message}")); + } +} + +#[allow(clippy::too_many_arguments)] // UI plumbing: one parameter per widget handle. +pub(crate) fn update_navigation( + step: usize, + book: &SimpleBook, + step_label: &StaticText, + labels: &[String], + back: &Button, + next: &Button, + install: &Button, + close: &Button, + language_footer: &Panel, + can_install: bool, + target_valid: bool, + reapack_ack_confirmed: bool, +) { + book.set_selection(step); + if let Some(label) = labels.get(step) { + step_label.set_label(label); + } + back.enable(step > TARGET_STEP && step < DONE_STEP); + next.enable(match step { + TARGET_STEP => target_valid, + // VERSION_CHECK_STEP auto-advances on success; never user-driven. + PACKAGES_STEP | PROGRESS_STEP => true, + REAPACK_ACK_STEP => reapack_ack_confirmed, + _ => false, + }); + install.enable(step == REVIEW_STEP && can_install); + // Make the step's primary action the window's default button so Enter + // activates it regardless of which control holds focus (the standard + // dialog convention on both macOS and Windows). A disabled default + // button is a no-op on Enter, so an invalid Target step or a Review + // step that can't install yet won't advance — exactly what we want. + // Install is primary on Review, Close on Done (the wizard is over there + // and Next is disabled, which used to leave Enter a dead key). Progress + // deliberately keeps the *disabled* Next as its default: Enter must + // never tear the window down while the install worker is running. + match step { + REVIEW_STEP => install.set_default(), + DONE_STEP => close.set_default(), + _ => next.set_default(), + } + // Language picker only matters on the Target step — switching languages + // relaunches RABBIT and discards wizard progress, so a footer on later + // pages would just be a tripwire. + language_footer.show(step == TARGET_STEP); +} + +#[cfg(test)] +mod tests { + use std::fs; + use std::path::PathBuf; + + use tempfile::tempdir; + + use super::{can_launch_reaper_path, planned_reaper_launch_path_for_target}; + use crate::TargetRow; + + #[test] + fn launchability_requires_existing_path() { + let dir = tempdir().unwrap(); + let path = dir.path().join("reaper.exe"); + + assert!(!can_launch_reaper_path(Some(&path))); + + fs::write(&path, b"stub").unwrap(); + + assert!(can_launch_reaper_path(Some(&path))); + assert!(!can_launch_reaper_path(None)); + } + + #[test] + fn planned_launch_path_uses_target_planned_app_path() { + let target = TargetRow { + label: "Portable REAPER".to_string(), + details: String::new(), + app_path: None, + planned_app_path: PathBuf::from("C:/PortableREAPER/reaper.exe"), + path: PathBuf::from("C:/PortableREAPER"), + version: None, + portable: true, + selected: true, + writable: true, + architecture: rabbit_core::model::Architecture::current(), + }; + + assert_eq!( + planned_reaper_launch_path_for_target(&target), + PathBuf::from("C:/PortableREAPER/reaper.exe") + ); + } +} diff --git a/crates/rabbit/Cargo.toml b/crates/rabbit/Cargo.toml index 0efd30f..cbd51b2 100644 --- a/crates/rabbit/Cargo.toml +++ b/crates/rabbit/Cargo.toml @@ -2,6 +2,7 @@ name = "rabbit" version.workspace = true edition.workspace = true +rust-version.workspace = true license.workspace = true [features]