diff --git a/Cargo.lock b/Cargo.lock index e99d1e5e..999162d7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1183,6 +1183,16 @@ dependencies = [ "web-time", ] +[[package]] +name = "indicatif-log-bridge" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63703cf9069b85dbe6fe26e1c5230d013dee99d3559cd3d02ba39e099ef7ab02" +dependencies = [ + "indicatif", + "log", +] + [[package]] name = "indoc" version = "2.0.6" @@ -2793,6 +2803,7 @@ dependencies = [ "flexi_logger", "humantime", "indicatif", + "indicatif-log-bridge", "itertools", "log", "semver", diff --git a/openspec/changes/archive/2026-04-07-better-installation-progress-output/.openspec.yaml b/openspec/changes/archive/2026-04-07-better-installation-progress-output/.openspec.yaml new file mode 100644 index 00000000..fe53a538 --- /dev/null +++ b/openspec/changes/archive/2026-04-07-better-installation-progress-output/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-03-16 diff --git a/openspec/changes/archive/2026-04-07-better-installation-progress-output/design.md b/openspec/changes/archive/2026-04-07-better-installation-progress-output/design.md new file mode 100644 index 00000000..65e0659d --- /dev/null +++ b/openspec/changes/archive/2026-04-07-better-installation-progress-output/design.md @@ -0,0 +1,144 @@ +## Context + +The current installation flow in UVM has a `ProgressHandler` trait defined but is barely utilized. The `Loader` struct in `uvm_install/src/install/loader.rs` has optional progress handler support, but it's never actually wired up from the CLI layer. Users see only initial and final messages during what can be multi-gigabyte downloads and complex multi-component installations. + +The existing architecture is already structured for progress reporting: +- `ProgressHandler` trait exists with `finish()`, `inc()`, `set_length()`, `set_position()` methods +- `Loader` has optional progress handler support +- `DownloadProgress` wrapper implements incremental progress updates during HTTP transfers + +The `uvm` CLI binary already depends on `indicatif` 0.18.0, which provides cross-platform progress bars and multi-progress support for concurrent operations. + +## Goals / Non-Goals + +**Goals:** +- Provide real-time visual feedback during download operations with size/speed metrics +- Show clear phase transitions (fetching metadata → downloading → extracting → installing) +- Display component-level progress when installing multiple modules +- Summarize installation results (time taken, data downloaded, components installed) +- Maintain backward compatibility with existing error handling + +**Non-Goals:** +- Progress reporting for non-installation operations (launch, list, detect) +- Machine-readable progress output (JSON/structured logging) +- Progress persistence across process restarts +- Parallel download optimization (only progress reporting, not concurrency changes) + +## Decisions + +### Decision 1: Use `indicatif` for progress rendering +**Rationale:** Already a dependency in `uvm` crate, mature library with excellent cross-platform support, handles multi-progress scenarios elegantly. + +**Alternatives considered:** +- Custom progress implementation → Too much complexity for terminal handling, cursor control, etc. +- `pbr` crate → Less active maintenance, fewer features +- Simple print statements → Poor UX, no dynamic updates + +### Decision 2: Implement progress at CLI layer, not library layer +**Rationale:** Keep `uvm_install` library agnostic of terminal UI concerns. The CLI creates and passes progress handlers to the library, maintaining separation of concerns. + +**Alternatives considered:** +- Build `indicatif` into `uvm_install` → Creates unnecessary dependency for library users +- Create abstract progress types in library → Over-engineering for single use case + +**Implementation approach:** +- Create concrete `ProgressHandler` implementation in `uvm/src/commands/install.rs` +- Pass it to `InstallOptions` via new builder method +- Wire through to `Loader` and installer implementations + +### Decision 3: Multi-progress for component installations +**Rationale:** When installing editor + multiple modules, show overall progress plus individual component progress. Uses `indicatif::MultiProgress` to handle the hierarchy. + +**Structure:** +``` +Overall: [===========> ] 3/7 components +├─ Editor 2022.3.0f1: [================] 2.1 GB/2.1 GB +├─ Android Support: [=====> ] 512 MB/1.5 GB +└─ iOS Support: [ ] Waiting... +``` + +### Decision 4: Progress phases +**Phases to report:** +1. **Fetching metadata** - GraphQL API call (spinner, no progress bar) +2. **Resolving dependencies** - Graph construction (spinner) +3. **Downloading** - Per-component progress bars with size/speed +4. **Installing** - Per-component spinner/progress (platform-specific) +5. **Summary** - Final statistics + +**Rationale:** Matches natural user mental model of installation flow. + +### Decision 5: Extend `ProgressHandler` trait minimally +**Current trait is sufficient for downloads.** For installation phases (extraction), add optional method: +```rust +fn set_message(&self, msg: &str) {} // Default no-op +``` + +This allows spinners to show context ("Extracting pkg...", "Running installer...") without breaking existing interface. + +### Decision 6: Integrate logging with indicatif +**Rationale:** UVM uses `flexi_logger` with `log` crate. When progress bars are active, log messages must be routed through indicatif to prevent corruption of progress display. + +**Implementation approach:** +- Use `indicatif::MultiProgress::println()` or `ProgressBar::suspend()` for log output +- Create custom log writer that detects active progress bars and routes accordingly +- Check `std::io::IsTerminal` for stdout/stderr to detect piping +- In non-interactive mode, use normal logging without indicatif routing + +**TTY/Pipe detection:** +```rust +use std::io::IsTerminal; + +let is_interactive = std::io::stdout().is_terminal() + && std::io::stderr().is_terminal() + && std::env::var("CI").is_err(); +``` + +**Alternatives considered:** +- Ignore logging compatibility → Log messages would corrupt progress bars at verbose levels +- Disable logging when progress active → Loses important debug information +- Use separate terminal for logs → Not practical for CLI tool + +## Risks / Trade-offs + +**Risk:** Progress bars may flicker or display incorrectly on exotic terminals +→ **Mitigation:** `indicatif` handles this well; fallback to simple logging if progress rendering fails + +**Risk:** Installation phases without deterministic progress (platform installers) may show indefinite spinners +→ **Mitigation:** Acceptable UX - spinner indicates activity, better than silence + +**Risk:** Added overhead from progress updates in tight loops +→ **Mitigation:** `indicatif` is optimized for this; updates are rate-limited automatically + +**Trade-off:** Multi-progress output requires terminal height; may not fit on small terminals +→ **Mitigation:** `indicatif` handles overflow by scrolling; users on tiny terminals are rare edge case + +**Trade-off:** Dependency on `indicatif` already exists in CLI but not in `uvm_install` +→ **Decision:** Keep it this way - library stays lean, CLI handles presentation + +**Risk:** Logging integration with `flexi_logger` may be complex +→ **Mitigation:** Use MultiProgress as global state accessible to custom log writer; fallback to standard output if setup fails + +**Risk:** Piped output may still show progress artifacts +→ **Mitigation:** Strict TTY detection on both stdout and stderr before enabling progress bars + +## Migration Plan + +**Deployment:** +1. Add progress handler implementation to CLI (`uvm/src/commands/install.rs`) +2. Extend `InstallOptions` builder with `with_progress_handler()` method +3. Wire progress through `Loader` and installer create functions +4. Add optional progress calls to platform-specific installers + +**Rollback strategy:** +- Progress is additive only; no breaking changes +- If issues arise, can disable progress by not passing handler (defaults to `None`) +- Feature can be controlled via flag if needed: `--no-progress` + +**Testing:** +- Manual testing on macOS, Linux, Windows +- Test with single component and multi-component installations +- Verify graceful degradation in non-TTY environments (CI, redirected output) + +## Open Questions + +None - design is straightforward enhancement to existing architecture. diff --git a/openspec/changes/archive/2026-04-07-better-installation-progress-output/proposal.md b/openspec/changes/archive/2026-04-07-better-installation-progress-output/proposal.md new file mode 100644 index 00000000..c002146e --- /dev/null +++ b/openspec/changes/archive/2026-04-07-better-installation-progress-output/proposal.md @@ -0,0 +1,29 @@ +## Why + +The current Unity installation process provides minimal progress feedback, making it difficult for users to understand what's happening during downloads and installations. Users only see initial request messages and final success/failure messages, leaving them in the dark during the lengthy download and installation phases. This poor user experience makes the tool feel unresponsive and creates uncertainty about whether the installation is proceeding correctly. + +## What Changes + +- Add visual progress indicators for download operations showing current progress, total size, and download speed +- Display clear status messages for each installation phase (downloading, extracting, installing, verifying) +- Show component-level progress when installing modules with dependencies +- Provide summary statistics at completion (total time, data downloaded, components installed) +- Maintain current error handling while improving error context presentation + +## Capabilities + +### New Capabilities +- `installation-progress`: Visual progress reporting for downloads, extraction, and installation phases with component tracking and summary statistics + +### Modified Capabilities + + +## Impact + +- **CLI**: `uvm/src/commands/install.rs` - integrate progress reporting into install command +- **Installer Core**: `uvm_install/src/lib.rs` - wire up progress handlers during installation +- **Download Logic**: `uvm_install/src/install/loader.rs` - enhance ProgressHandler implementation with download metrics +- **Platform Installers**: `uvm_install/src/sys/*/` - add progress hooks to extraction/installation phases +- **Dependencies**: Add progress bar library (e.g., `indicatif`) for cross-platform terminal progress display diff --git a/openspec/changes/archive/2026-04-07-better-installation-progress-output/specs/installation-progress/spec.md b/openspec/changes/archive/2026-04-07-better-installation-progress-output/specs/installation-progress/spec.md new file mode 100644 index 00000000..e9b60a6e --- /dev/null +++ b/openspec/changes/archive/2026-04-07-better-installation-progress-output/specs/installation-progress/spec.md @@ -0,0 +1,122 @@ +## ADDED Requirements + +### Requirement: Display download progress with metrics +The system SHALL display real-time download progress for each installer component including current size, total size, download speed, and estimated time remaining. + +#### Scenario: Download progress updates during file transfer +- **WHEN** a Unity installer package is being downloaded +- **THEN** the system displays a progress bar showing bytes downloaded, total bytes, current speed in MB/s, and updates at least once per second + +#### Scenario: Multiple components show individual progress +- **WHEN** installing Unity editor with multiple modules +- **THEN** each component displays its own progress bar with individual download metrics + +#### Scenario: Download completes successfully +- **WHEN** a component download reaches 100% +- **THEN** the progress bar shows completion state and total time taken + +### Requirement: Display installation phase status +The system SHALL clearly indicate the current installation phase with descriptive status messages for metadata fetching, dependency resolution, downloading, extracting, and installing. + +#### Scenario: Metadata fetch phase +- **WHEN** installation begins +- **THEN** system displays a spinner with message "Fetching Unity version metadata..." + +#### Scenario: Dependency resolution phase +- **WHEN** metadata is fetched and dependency graph is being built +- **THEN** system displays a spinner with message "Resolving component dependencies..." + +#### Scenario: Download phase with component name +- **WHEN** downloading a specific component +- **THEN** system displays the component name and version being downloaded + +#### Scenario: Extraction phase +- **WHEN** extracting an installer package +- **THEN** system displays a spinner with message indicating extraction is in progress + +#### Scenario: Installation phase +- **WHEN** running platform-specific installer +- **THEN** system displays a spinner with message indicating installation is in progress + +### Requirement: Show multi-component installation hierarchy +The system SHALL display overall installation progress when multiple components are being installed, showing total component count and individual component status. + +#### Scenario: Overall progress with component count +- **WHEN** installing editor and 3 modules +- **THEN** system displays "Installing 4 components" with overall progress indicator + +#### Scenario: Component status hierarchy +- **WHEN** multiple components are being installed +- **THEN** system displays a hierarchical view with overall progress at top and individual component progress below + +#### Scenario: Pending components indication +- **WHEN** some components are waiting for dependencies +- **THEN** those components display "Waiting..." or "Pending..." status + +### Requirement: Provide installation summary statistics +The system SHALL display summary statistics upon completion including total time elapsed, total data downloaded, number of components installed, and final installation path. + +#### Scenario: Successful installation summary +- **WHEN** installation completes successfully +- **THEN** system displays total time, data downloaded in GB/MB, component count, and installation directory path + +#### Scenario: Summary includes all downloaded data +- **WHEN** multiple components are downloaded +- **THEN** summary aggregates total bytes downloaded across all components + +#### Scenario: Time formatting +- **WHEN** displaying elapsed time +- **THEN** system formats time appropriately (seconds for <60s, minutes:seconds for <1h, hours:minutes:seconds for >=1h) + +### Requirement: Handle non-TTY and piped output gracefully +The system SHALL detect non-interactive terminal environments (CI, piped stdout/stderr) and disable progress bars entirely, falling back to simple text milestone messages. + +#### Scenario: Non-TTY detection via stdout +- **WHEN** stdout is not a TTY (e.g., redirected to file) +- **THEN** system outputs simple text progress messages without progress bars or escape codes + +#### Scenario: Piped output detection +- **WHEN** stdout or stderr is piped to another process +- **THEN** system disables progress bars and outputs only milestone messages + +#### Scenario: Progress messages in non-interactive mode +- **WHEN** running in non-interactive mode +- **THEN** system outputs milestone messages like "Downloading component X...", "Downloaded X (Y MB)", "Installing X..." + +#### Scenario: CI environment detection +- **WHEN** running in CI environment (CI=true env var or non-TTY) +- **THEN** system disables all progress bars and spinners + +### Requirement: Integrate with logging system +The system SHALL route log messages through indicatif when progress bars are active to prevent log output from corrupting progress display, and SHALL respect log level settings. + +#### Scenario: Log messages with active progress bars +- **WHEN** progress bars are displayed and log messages are emitted +- **THEN** system routes log output through indicatif's println/suspend methods to appear above progress bars + +#### Scenario: Verbose logging compatibility +- **WHEN** user sets verbose log levels (debug, trace) +- **THEN** log messages appear correctly without interfering with progress bar rendering + +#### Scenario: Log output in non-interactive mode +- **WHEN** running in non-interactive mode (no progress bars) +- **THEN** log messages output normally via standard logging without indicatif routing + +#### Scenario: Logger initialization with progress support +- **WHEN** installing with progress bars enabled +- **THEN** system configures logging to use indicatif-compatible output methods + +### Requirement: Preserve error reporting +The system SHALL maintain existing error messages and context when errors occur during any installation phase, ensuring errors are visible regardless of progress display state. + +#### Scenario: Error during download +- **WHEN** a download fails with network error +- **THEN** system clears progress display and shows error message with context + +#### Scenario: Error during installation +- **WHEN** platform installer fails +- **THEN** system clears progress display and shows error message with exit code and component name + +#### Scenario: Error message visibility +- **WHEN** any error occurs during installation +- **THEN** error message is displayed prominently and not obscured by progress indicators diff --git a/openspec/changes/archive/2026-04-07-better-installation-progress-output/tasks.md b/openspec/changes/archive/2026-04-07-better-installation-progress-output/tasks.md new file mode 100644 index 00000000..97e9bc20 --- /dev/null +++ b/openspec/changes/archive/2026-04-07-better-installation-progress-output/tasks.md @@ -0,0 +1,83 @@ +## 1. Extend ProgressHandler trait + +- [x] 1.1 Add `set_message(&self, msg: &str)` default method to ProgressHandler trait in `uvm_install/src/install/loader.rs` +- [x] 1.2 Update ProgressHandler trait documentation with usage examples for different phases + +## 2. Create progress handler implementation + +- [x] 2.1 Create progress module in `uvm/src/commands/` with indicatif-based ProgressHandler implementation +- [x] 2.2 Implement wrapper struct around `indicatif::ProgressBar` that implements ProgressHandler trait +- [x] 2.3 Add TTY/pipe detection using `std::io::IsTerminal` on stdout and stderr, check CI env var +- [x] 2.4 Implement fallback SimpleProgressHandler for non-interactive environments using milestone messages +- [x] 2.5 Add helper functions for formatting bytes (KB/MB/GB) and time (seconds/minutes/hours) + +## 3. Add multi-progress coordinator + +- [x] 3.1 Create MultiProgressCoordinator struct using `indicatif::MultiProgress` for component hierarchy +- [x] 3.2 Implement overall progress bar showing total component count and completion +- [x] 3.3 Add method to create child progress bars for individual components +- [x] 3.4 Implement component status tracking (pending, downloading, installing, complete) +- [x] 3.5 Store MultiProgress as global/shared state for log integration access + +## 4. Wire progress through InstallOptions + +- [x] 4.1 Add `with_progress_handler` method to InstallOptions builder accepting trait object +- [x] 4.2 Store progress handler as `Option>` in InstallOptions +- [x] 4.3 Pass progress handler through to Loader::set_progress_handle in install flow + +## 5. Add progress to installation phases + +- [x] 5.1 Add spinner for "Fetching Unity version metadata..." before fetch_release call in InstallOptions::install +- [x] 5.2 Add spinner for "Resolving component dependencies..." before graph construction +- [x] 5.3 Update component download loop to create individual progress bars per component +- [x] 5.4 Add component name and type (Editor/Module) to progress bar titles + +## 6. Enhance Loader with download metrics + +- [x] 6.1 Track download start time in Loader::download for speed calculation +- [x] 6.2 Update DownloadProgress to calculate and report download speed via set_message +- [x] 6.3 Add download completion message with final size and time via finish callback +- [x] 6.4 Handle resume scenarios by adjusting initial progress position + +## 7. Add progress to platform installers + +- [x] 7.1 Pass progress handler to create_installer functions in `uvm_install/src/install/mod.rs` +- [x] 7.2 Update platform installer structs to accept optional ProgressHandler in constructor +- [x] 7.3 Add spinner with "Extracting..." message in pkg/xz/zip installer before_install hooks +- [x] 7.4 Add spinner with "Installing..." message in installer install_handler implementations +- [x] 7.5 Call finish on progress handler after installation completes + +## 8. Implement installation summary + +- [x] 8.1 Track installation start time at beginning of install command execute() +- [x] 8.2 Track total bytes downloaded by summing Loader responses via coordinator +- [x] 8.3 Count total components installed via coordinator.components_installed() +- [x] 8.4 Display formatted summary in install.rs after successful installation with time, data, count, path + +## 9. Integrate logging with indicatif + +- [x] 9.1 Use indicatif_log_bridge::LogWrapper as the custom log writer +- [x] 9.2 Route log messages through MultiProgress::println() via LogWrapper +- [x] 9.3 Fall back to standard output when no progress bars (LogWrapper handles this) +- [x] 9.4 Update flexi_logger initialization to use LogWrapper in main.rs +- [ ] 9.5 Test verbose logging (debug/trace) doesn't corrupt progress display + +## 10. Update CLI command integration + +- [x] 10.1 Detect interactive mode using IsTerminal on stdout/stderr and CI env var +- [x] 10.2 Create MultiProgressCoordinator in install.rs::execute only when interactive +- [x] 10.3 Pass coordinator to InstallOptions via with_progress_handler +- [x] 10.4 Update error handling to clear progress display before showing errors +- [x] 10.5 Ensure final success message uses console::style as before + +## 11. Testing and validation + +- [ ] 11.1 Test single component installation (editor only) shows progress bars +- [ ] 11.2 Test multi-component installation (editor + 2-3 modules) shows hierarchy +- [ ] 11.3 Test stdout redirect: `uvm install ... > output.txt` shows milestone messages only +- [ ] 11.4 Test stderr redirect: `uvm install ... 2> errors.txt` disables progress bars +- [ ] 11.5 Test full pipe: `uvm install ... 2>&1 | tee log.txt` disables progress bars +- [ ] 11.6 Test with verbose logging: `uvm install ... -vv` shows logs above progress bars +- [ ] 11.7 Test error scenarios (network failure, disk full) clear progress cleanly +- [ ] 11.8 Verify progress works correctly on Windows, Linux, macOS terminals +- [ ] 11.9 Test CI environment (CI=true) disables progress bars diff --git a/openspec/specs/installation-progress/spec.md b/openspec/specs/installation-progress/spec.md new file mode 100644 index 00000000..427203f2 --- /dev/null +++ b/openspec/specs/installation-progress/spec.md @@ -0,0 +1,128 @@ +# Specification: Installation Progress + +## Purpose + +Define how the Unity Version Manager reports installation progress to the user, covering real-time download metrics, phase status messages, multi-component hierarchy, summary statistics, non-interactive output, logging integration, and error visibility. + +## Requirements + +### Requirement: Display download progress with metrics +The system SHALL display real-time download progress for each installer component including current size, total size, download speed, and estimated time remaining. + +#### Scenario: Download progress updates during file transfer +- **WHEN** a Unity installer package is being downloaded +- **THEN** the system displays a progress bar showing bytes downloaded, total bytes, current speed in MB/s, and updates at least once per second + +#### Scenario: Multiple components show individual progress +- **WHEN** installing Unity editor with multiple modules +- **THEN** each component displays its own progress bar with individual download metrics + +#### Scenario: Download completes successfully +- **WHEN** a component download reaches 100% +- **THEN** the progress bar shows completion state and total time taken + +### Requirement: Display installation phase status +The system SHALL clearly indicate the current installation phase with descriptive status messages for metadata fetching, dependency resolution, downloading, extracting, and installing. + +#### Scenario: Metadata fetch phase +- **WHEN** installation begins +- **THEN** system displays a spinner with message "Fetching Unity version metadata..." + +#### Scenario: Dependency resolution phase +- **WHEN** metadata is fetched and dependency graph is being built +- **THEN** system displays a spinner with message "Resolving component dependencies..." + +#### Scenario: Download phase with component name +- **WHEN** downloading a specific component +- **THEN** system displays the component name and version being downloaded + +#### Scenario: Extraction phase +- **WHEN** extracting an installer package +- **THEN** system displays a spinner with message indicating extraction is in progress + +#### Scenario: Installation phase +- **WHEN** running platform-specific installer +- **THEN** system displays a spinner with message indicating installation is in progress + +### Requirement: Show multi-component installation hierarchy +The system SHALL display overall installation progress when multiple components are being installed, showing total component count and individual component status. + +#### Scenario: Overall progress with component count +- **WHEN** installing editor and 3 modules +- **THEN** system displays "Installing 4 components" with overall progress indicator + +#### Scenario: Component status hierarchy +- **WHEN** multiple components are being installed +- **THEN** system displays a hierarchical view with overall progress at top and individual component progress below + +#### Scenario: Pending components indication +- **WHEN** some components are waiting for dependencies +- **THEN** those components display "Waiting..." or "Pending..." status + +### Requirement: Provide installation summary statistics +The system SHALL display summary statistics upon completion including total time elapsed, total data downloaded, number of components installed, and final installation path. + +#### Scenario: Successful installation summary +- **WHEN** installation completes successfully +- **THEN** system displays total time, data downloaded in GB/MB, component count, and installation directory path + +#### Scenario: Summary includes all downloaded data +- **WHEN** multiple components are downloaded +- **THEN** summary aggregates total bytes downloaded across all components + +#### Scenario: Time formatting +- **WHEN** displaying elapsed time +- **THEN** system formats time appropriately (seconds for <60s, minutes:seconds for <1h, hours:minutes:seconds for >=1h) + +### Requirement: Handle non-TTY and piped output gracefully +The system SHALL detect non-interactive terminal environments (CI, piped stdout/stderr) and disable progress bars entirely, falling back to simple text milestone messages. + +#### Scenario: Non-TTY detection via stdout +- **WHEN** stdout is not a TTY (e.g., redirected to file) +- **THEN** system outputs simple text progress messages without progress bars or escape codes + +#### Scenario: Piped output detection +- **WHEN** stdout or stderr is piped to another process +- **THEN** system disables progress bars and outputs only milestone messages + +#### Scenario: Progress messages in non-interactive mode +- **WHEN** running in non-interactive mode +- **THEN** system outputs milestone messages like "Downloading component X...", "Downloaded X (Y MB)", "Installing X..." + +#### Scenario: CI environment detection +- **WHEN** running in CI environment (CI=true env var or non-TTY) +- **THEN** system disables all progress bars and spinners + +### Requirement: Integrate with logging system +The system SHALL route log messages through indicatif when progress bars are active to prevent log output from corrupting progress display, and SHALL respect log level settings. + +#### Scenario: Log messages with active progress bars +- **WHEN** progress bars are displayed and log messages are emitted +- **THEN** system routes log output through indicatif's println/suspend methods to appear above progress bars + +#### Scenario: Verbose logging compatibility +- **WHEN** user sets verbose log levels (debug, trace) +- **THEN** log messages appear correctly without interfering with progress bar rendering + +#### Scenario: Log output in non-interactive mode +- **WHEN** running in non-interactive mode (no progress bars) +- **THEN** log messages output normally via standard logging without indicatif routing + +#### Scenario: Logger initialization with progress support +- **WHEN** installing with progress bars enabled +- **THEN** system configures logging to use indicatif-compatible output methods + +### Requirement: Preserve error reporting +The system SHALL maintain existing error messages and context when errors occur during any installation phase, ensuring errors are visible regardless of progress display state. + +#### Scenario: Error during download +- **WHEN** a download fails with network error +- **THEN** system clears progress display and shows error message with context + +#### Scenario: Error during installation +- **WHEN** platform installer fails +- **THEN** system clears progress display and shows error message with exit code and component name + +#### Scenario: Error message visibility +- **WHEN** any error occurs during installation +- **THEN** error message is displayed prominently and not obscured by progress indicators diff --git a/probe_access.sh b/probe_access.sh new file mode 100644 index 00000000..f4a22671 --- /dev/null +++ b/probe_access.sh @@ -0,0 +1,43 @@ +#!/bin/bash +# Usage: ./probe_access.sh [path] [--read] +# Default: traverse $HOME, optionally attempt to read each file + +ROOT="${1:-$HOME}" +TRY_READ="${2:-}" + +echo "=== Traversal probe: $ROOT ===" +echo "" + +traverse() { + local dir="$1" + local indent="$2" + + local entries + entries=$(ls -A "$dir" 2>/dev/null) + if [[ $? -ne 0 ]]; then + echo "${indent}[DIR BLOCKED] $dir" + return + fi + + while IFS= read -r entry; do + [[ -z "$entry" ]] && continue + local path="$dir/$entry" + + if [[ -d "$path" ]]; then + echo "${indent}[DIR] $path" + traverse "$path" "${indent} " + elif [[ -f "$path" ]]; then + if [[ "$TRY_READ" == "--read" ]]; then + if head -c 1 "$path" > /dev/null 2>&1; then + echo "${indent}[FILE OK] $path" + else + echo "${indent}[FILE BLOCKED] $path" + fi + else + echo "${indent}[FILE] $path" + fi + fi + done <<< "$entries" +} + +traverse "$ROOT" "" diff --git a/tmux_workspace.yml b/tmux_workspace.yml new file mode 100644 index 00000000..c11e87de --- /dev/null +++ b/tmux_workspace.yml @@ -0,0 +1,18 @@ +windows: + - name: Workspace + layout: main-vertical + panes: + - name: Terminal + - split: vertical + name: Agent + size: 30% + cmd: claude + - split: horizontal + name: Logs + size: 30% + cmd: tail -f $HOME/Library/Caches/net.wooga.uvm/logs/uvm_jni.log + - name: Code + layout: main-vertical + panes: + - name: Neovim + cmd: nvim . diff --git a/uvm/Cargo.toml b/uvm/Cargo.toml index c2764113..40830bbe 100644 --- a/uvm/Cargo.toml +++ b/uvm/Cargo.toml @@ -20,6 +20,7 @@ unity-version = { version = "0.3.1", path="../unity-version", features = ["clap" uvm_detect = { version = "1.1.1", path = "../uvm_detect" } console = { workspace = true } indicatif = "0.18.0" +indicatif-log-bridge = "0.2" flexi_logger = "0.31.2" log = { workspace = true } semver = { workspace = true } diff --git a/uvm/src/commands/install.rs b/uvm/src/commands/install.rs index 020a24a1..1a44ae10 100644 --- a/uvm/src/commands/install.rs +++ b/uvm/src/commands/install.rs @@ -1,10 +1,15 @@ -use std::path::PathBuf; -use std::io; use clap::Args; use console::style; +use indicatif::{HumanBytes, HumanDuration}; +use std::io; +use std::path::PathBuf; +use std::time::Instant; use unity_version::Version; use uvm_install::{InstallArchitecture, InstallOptions}; +use crate::commands::progress::{ + is_interactive, ArcProgressCoordinator, SimpleProgressHandler, +}; use crate::commands::Command; #[derive(Args, Debug)] @@ -36,16 +41,12 @@ pub struct InstallArgs { impl Command for InstallArgs { fn execute(&self) -> io::Result { + let start_time = Instant::now(); let version = &self.editor_version; let modules = &self.modules; let install_sync = self.sync; let destination = &self.destination; - eprintln!( - "Request to install Unity Editor version {} with modules {:?} to destination: {:?}", - version, modules, destination - ); - let mut options = InstallOptions::new(version.to_owned()) .with_install_sync(install_sync) .with_architecture(self.architecture); @@ -58,19 +59,74 @@ impl Command for InstallArgs { options = options.with_destination(destination); } + // Detect interactive mode and create appropriate progress handler + let interactive = is_interactive(); + let progress_mode = crate::commands::progress::get_progress_mode(); + + let coordinator_opt = if interactive { + // Create a multi-progress coordinator for component installation hierarchy + // We start with 0 components - the library will update the count after building the graph + use crate::commands::progress::MultiProgressCoordinator; + use std::sync::Arc; + let coordinator = Arc::new(MultiProgressCoordinator::new(0)); + options = options.with_progress_handler(ArcProgressCoordinator(coordinator.clone())); + Some(coordinator) + } else if progress_mode != crate::commands::progress::ProgressMode::Disabled { + // Non-interactive mode - use simple milestone messages (unless --no-progress) + let simple_handler = SimpleProgressHandler::new("Unity".to_string()); + options = options.with_progress_handler(simple_handler); + None + } else { + // --no-progress: no handler at all + None + }; + match options.install() { Ok(installation) => { + let elapsed = start_time.elapsed(); + + // Clear progress bars before showing summary + if let Some(ref coordinator) = coordinator_opt { + coordinator.clear(); + } + + // Show installation summary + if let Some(ref coordinator) = coordinator_opt { + let components = coordinator.components_installed(); + let bytes = coordinator.bytes_downloaded(); + eprintln!( + "\n{} {} ({}) in {}", + style("Installed").green().bold(), + if components == 1 { "1 component".to_string() } else { format!("{} components", components) }, + HumanBytes(bytes), + HumanDuration(elapsed), + ); + } else { + eprintln!( + "\n{} in {}", + style("Installed").green().bold(), + HumanDuration(elapsed), + ); + } eprintln!( - "{}: Unity {} installed at {}", - style("Finish").green().bold(), + " Unity {} → {}", installation.version(), - installation.path().display() + installation.path().display(), ); + Ok(0) } Err(e) => { + // Clear progress display before showing error + if let Some(ref coordinator) = coordinator_opt { + coordinator.clear(); + } + eprintln!("{}: {}", style("Error").red().bold(), e); - Err(io::Error::new(io::ErrorKind::Other, format!("Installation failed: {}", e))) + Err(io::Error::new( + io::ErrorKind::Other, + format!("Installation failed: {}", e), + )) } } } diff --git a/uvm/src/commands/mod.rs b/uvm/src/commands/mod.rs index 6251440b..2c6b06eb 100644 --- a/uvm/src/commands/mod.rs +++ b/uvm/src/commands/mod.rs @@ -1,18 +1,19 @@ use std::io; pub mod detect; -pub mod list; -pub mod install; -pub mod uninstall; -pub mod version; +#[cfg(feature = "dev-commands")] +pub mod download_modules_json; pub mod external; -pub mod presentation; +pub mod gc; +pub mod install; pub mod launch; +pub mod list; pub mod modules; -pub mod gc; -#[cfg(feature = "dev-commands")] -pub mod download_modules_json; +pub mod presentation; +pub mod progress; +pub mod uninstall; +pub mod version; pub trait Command { fn execute(&self) -> io::Result; -} \ No newline at end of file +} diff --git a/uvm/src/commands/progress.rs b/uvm/src/commands/progress.rs new file mode 100644 index 00000000..37b267a4 --- /dev/null +++ b/uvm/src/commands/progress.rs @@ -0,0 +1,550 @@ +use indicatif::{HumanBytes, MultiProgress, ProgressBar, ProgressStyle}; +use std::sync::{Arc, Mutex, OnceLock}; +use std::time::Instant; +use uvm_install::ProgressHandler; + +static GLOBAL_MULTI_PROGRESS: OnceLock = OnceLock::new(); + +/// Returns the global MultiProgress instance, creating it on first call. +/// The same instance must be passed to LogWrapper so logging and progress bars +/// are coordinated (log messages suspend the bars instead of redrawing over them). +pub fn global_multi_progress() -> &'static MultiProgress { + GLOBAL_MULTI_PROGRESS.get_or_init(MultiProgress::new) +} + +/// Global progress configuration +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ProgressMode { + Auto, // Detect based on TTY + Enabled, // Force enabled via --progress + Disabled, // Force disabled via --no-progress +} + +static PROGRESS_MODE: Mutex = Mutex::new(ProgressMode::Auto); + +/// Configure progress mode based on --progress/--no-progress flags +pub fn set_progress_mode(enabled: bool, disabled: bool) { + let mut mode = PROGRESS_MODE.lock().unwrap(); + *mode = if enabled { + ProgressMode::Enabled + } else if disabled { + ProgressMode::Disabled + } else { + ProgressMode::Auto + }; +} + +/// Get the current progress mode +pub fn get_progress_mode() -> ProgressMode { + *PROGRESS_MODE.lock().unwrap() +} + +/// Detects if the current environment is interactive (has a TTY and not in CI). +pub fn is_interactive() -> bool { + use std::io::IsTerminal; + + let mode = *PROGRESS_MODE.lock().unwrap(); + + match mode { + ProgressMode::Enabled => true, + ProgressMode::Disabled => false, + ProgressMode::Auto => { + std::io::stdout().is_terminal() + && std::io::stderr().is_terminal() + && std::env::var("CI").is_err() + } + } +} + +/// Progress handler that wraps an indicatif ProgressBar. +pub struct IndicatifProgressHandler { + bar: ProgressBar, + start_time: Instant, + component_name: String, +} + +impl IndicatifProgressHandler { + pub fn new(bar: ProgressBar) -> Self { + Self { + bar, + start_time: Instant::now(), + component_name: String::new(), + } + } + + pub fn with_name(bar: ProgressBar, name: String) -> Self { + Self { + bar, + start_time: Instant::now(), + component_name: name, + } + } + + pub fn bar(&self) -> &ProgressBar { + &self.bar + } +} + +impl ProgressHandler for IndicatifProgressHandler { + fn finish(&self) { + self.bar.finish_with_message(self.bar.message()); + } + + fn inc(&self, delta: u64) { + self.bar.inc(delta); + } + + fn set_length(&self, len: u64) { + self.bar.set_length(len); + } + + fn set_position(&self, pos: u64) { + self.bar.set_position(pos); + } + + fn begin_extraction_progress(&self, total_bytes: u64) { + self.bar.reset(); + self.bar.set_style( + ProgressStyle::default_bar() + .template(&format!( + " {{spinner}} {}: {{msg}} [{{bar:40.cyan/blue}}] {{bytes}}/{{total_bytes}} ({{bytes_per_sec}}, {{eta}})", + self.component_name + )) + .unwrap() + .progress_chars("=>-"), + ); + self.bar.enable_steady_tick(std::time::Duration::from_millis(100)); + self.bar.set_length(total_bytes); + self.bar.set_message(format!( + "Extracting {} files", + HumanBytes(total_bytes) + )); + } + + fn set_message(&self, msg: &str) { + self.bar.set_message(msg.to_string()); + + // Switch template based on state + if msg.starts_with("Downloading") { + // Switch to download progress bar style + self.bar.set_style( + ProgressStyle::default_bar() + .template(&format!(" {{spinner}} {}: {{msg}} [{{bar:40.green/blue}}] {{bytes}}/{{total_bytes}} ({{bytes_per_sec}}, {{eta}})", self.component_name)) + .unwrap() + .progress_chars("=>-"), + ); + } else if msg.starts_with("Installing") || msg.starts_with("Extracting") || msg.starts_with("Unpacking") { + // Reset bar state to clear progress data, then switch to spinner-only style + self.bar.reset(); + self.bar.set_style( + ProgressStyle::default_spinner() + .template(&format!(" {{spinner}} {}: {{msg}}", self.component_name)) + .unwrap(), + ); + self.bar + .enable_steady_tick(std::time::Duration::from_millis(100)); + } else if msg.starts_with("✓") || msg.starts_with("Waiting") { + // Reset and switch to simple text-only style (no spinner animation for completed/waiting) + self.bar.reset(); + self.bar.set_style( + ProgressStyle::default_spinner() + .template(&format!(" {{spinner}} {}: {{msg}}", self.component_name)) + .unwrap(), + ); + // Disable spinning for static states + self.bar.disable_steady_tick(); + } + } +} + +/// Simple progress handler for non-interactive environments. +/// Outputs milestone messages instead of progress bars. +pub struct SimpleProgressHandler { + component_name: String, + total_size: Option, +} + +impl SimpleProgressHandler { + pub fn new(component_name: String) -> Self { + Self { + component_name, + total_size: None, + } + } +} + +impl ProgressHandler for SimpleProgressHandler { + fn finish(&self) { + if let Some(size) = self.total_size { + eprintln!( + "✓ Downloaded {} ({})", + self.component_name, + HumanBytes(size) + ); + } else { + eprintln!("✓ Completed {}", self.component_name); + } + } + + fn inc(&self, _delta: u64) { + // No-op for simple handler + } + + fn set_length(&self, len: u64) { + // Store for final message + let handler = self as *const Self as *mut Self; + unsafe { + (*handler).total_size = Some(len); + } + eprintln!( + "Downloading {} ({})...", + self.component_name, + HumanBytes(len) + ); + } + + fn set_position(&self, _pos: u64) { + // No-op for simple handler + } + + fn set_message(&self, msg: &str) { + eprintln!("{}: {}", self.component_name, msg); + } + + fn begin_extraction_progress(&self, total_bytes: u64) { + eprintln!( + "{}: Extracting {} ({})...", + self.component_name, + self.component_name, + HumanBytes(total_bytes) + ); + } + + fn initialize_components(&self, components: &[(String, String)]) { + eprintln!("Installing {} components...", components.len()); + } + + fn get_component_handler(&self, component_id: &str) -> Option> { + // Return a new simple handler for this component + Some(Box::new(SimpleProgressHandler::new( + component_id.to_string(), + ))) + } +} + +/// Coordinates multiple progress bars for component installation hierarchy. +pub struct MultiProgressCoordinator { + multi: Arc, + overall_bar: ProgressBar, + phase_spinner: Mutex>, + completed_components: Arc>, + component_handlers: + Arc>>>, + total_bytes_downloaded: Arc>, +} + +impl MultiProgressCoordinator { + /// Create a new multi-progress coordinator. + pub fn new(total_components: usize) -> Self { + let multi = Arc::new((*global_multi_progress()).clone()); + + let overall_bar = multi.add(ProgressBar::new(total_components as u64)); + overall_bar.set_style( + ProgressStyle::default_bar() + .template("[{elapsed_precise}] {bar:40.cyan/blue} {pos}/{len} components") + .unwrap() + .progress_chars("=>-"), + ); + overall_bar.enable_steady_tick(std::time::Duration::from_millis(100)); + + Self { + multi, + overall_bar, + phase_spinner: Mutex::new(None), + completed_components: Arc::new(Mutex::new(0)), + component_handlers: Arc::new(Mutex::new(std::collections::HashMap::new())), + total_bytes_downloaded: Arc::new(Mutex::new(0)), + } + } + + /// Show a phase-level status message (e.g. "Fetching metadata...", "Resolving dependencies..."). + /// Creates a spinner on first call; updates the message on subsequent calls. + /// The spinner is cleared when initialize_components is called. + fn set_phase_message(&self, msg: &str) { + let mut spinner_lock = self.phase_spinner.lock().unwrap(); + if let Some(ref spinner) = *spinner_lock { + spinner.set_message(msg.to_string()); + } else { + let spinner = self.multi.insert_before(&self.overall_bar, ProgressBar::new_spinner()); + spinner.set_style( + ProgressStyle::default_spinner() + .template("{spinner} {msg}") + .unwrap(), + ); + spinner.set_message(msg.to_string()); + spinner.enable_steady_tick(std::time::Duration::from_millis(100)); + *spinner_lock = Some(spinner); + } + } + + /// Pre-create progress bars for all components that will be installed. + pub fn init_component_progress(&self, components: &[(String, String)]) { + log::debug!( + "initialize_components called with {} components", + components.len() + ); + let mut handlers = self.component_handlers.lock().unwrap(); + + for (component_id, component_type) in components { + log::debug!( + "Creating progress bar for {} ({})", + component_id, + component_type + ); + let display_name = if component_type == "Editor" { + format!("Unity Editor") + } else { + component_id.clone() + }; + + let handler = self.create_download_progress(&display_name); + + // Set initial "Waiting..." state with spinner-only template + handler.bar.set_style( + ProgressStyle::default_spinner() + .template(&format!(" {{spinner}} {}: {{msg}}", display_name)) + .unwrap(), + ); + handler.bar.set_message("Waiting..."); + + let prev = handlers.insert(component_id.clone(), Arc::new(handler)); + if prev.is_some() { + log::warn!("Duplicate component: {}", component_id); + } + } + + log::debug!("Stored {} handlers in HashMap", handlers.len()); + + // Clear the phase spinner now that component bars are visible + let mut spinner_lock = self.phase_spinner.lock().unwrap(); + if let Some(spinner) = spinner_lock.take() { + spinner.finish_and_clear(); + } + + // Update total count + self.overall_bar.set_length(components.len() as u64); + } + + /// Get the progress handler for a specific component. + pub fn get_component_handler( + &self, + component_id: &str, + ) -> Option> { + let handlers = self.component_handlers.lock().unwrap(); + handlers.get(component_id).cloned() + } + + /// Get a reference to the MultiProgress instance for logging integration. + pub fn multi(&self) -> Arc { + self.multi.clone() + } + + /// Create a progress bar for a component download. + pub fn create_download_progress(&self, component_name: &str) -> IndicatifProgressHandler { + let bar = self.multi.add(ProgressBar::new(0)); + bar.set_style( + ProgressStyle::default_bar() + .template(&format!(" {{spinner}} {}: {{msg}} [{{bar:40.green/blue}}] {{bytes}}/{{total_bytes}} ({{bytes_per_sec}}, {{eta}})", component_name)) + .unwrap() + .progress_chars("=>-"), + ); + + IndicatifProgressHandler::with_name(bar, component_name.to_string()) + } + + /// Create a spinner for a component installation phase. + pub fn create_spinner(&self, message: &str) -> ProgressBar { + let spinner = self.multi.add(ProgressBar::new_spinner()); + spinner.set_style( + ProgressStyle::default_spinner() + .template(" {spinner} {msg}") + .unwrap(), + ); + spinner.set_message(message.to_string()); + spinner.enable_steady_tick(std::time::Duration::from_millis(100)); + spinner + } + + /// Finish the overall progress display. + pub fn finish(&self) { + let completed = *self.completed_components.lock().unwrap(); + self.overall_bar + .finish_with_message(format!("✓ {} components installed", completed)); + } + + /// Number of components that completed installation. + pub fn components_installed(&self) -> usize { + *self.completed_components.lock().unwrap() + } + + /// Total bytes downloaded across all components. + pub fn bytes_downloaded(&self) -> u64 { + *self.total_bytes_downloaded.lock().unwrap() + } + + /// Clear all progress bars (e.g., on error). + pub fn clear(&self) { + self.multi.clear().ok(); + } +} + +impl ProgressHandler for MultiProgressCoordinator { + fn finish(&self) { + self.finish(); + } + + fn inc(&self, _delta: u64) { + // Not used at coordinator level + } + + fn set_length(&self, _len: u64) { + // Not used at coordinator level + } + + fn set_position(&self, _pos: u64) { + // Not used at coordinator level + } + + fn set_message(&self, msg: &str) { + self.set_phase_message(msg); + } + + fn create_child_handler( + &self, + component_name: &str, + component_type: &str, + ) -> Option> { + let display_name = if component_type == "Editor" { + format!("Unity Editor ({})", component_name) + } else { + component_name.to_string() + }; + + Some(Box::new(self.create_download_progress(&display_name))) + } + + fn mark_component_complete(&self) { + let mut completed = self.completed_components.lock().unwrap(); + *completed += 1; + self.overall_bar.set_position(*completed as u64); + } + + fn set_total_components(&self, count: usize) { + self.overall_bar.set_length(count as u64); + } + + fn initialize_components(&self, components: &[(String, String)]) { + self.init_component_progress(components); + } + + fn get_component_handler(&self, component_id: &str) -> Option> { + let handlers = self.component_handlers.lock().unwrap(); + let handler = handlers.get(component_id)?; + Some(Box::new(ArcProgressHandler( + handler.clone(), + self.total_bytes_downloaded.clone(), + ))) + } +} + +/// Wrapper to allow Arc to be returned as Box. +/// Also accumulates bytes downloaded into the shared coordinator counter on finish(). +struct ArcProgressHandler(Arc, Arc>); + +/// Wrapper to allow Arc to be passed as a ProgressHandler +pub struct ArcProgressCoordinator(pub Arc); + +impl ProgressHandler for ArcProgressHandler { + fn finish(&self) { + // Capture bytes before the bar is reset by the subsequent "Installing..." set_message call. + let bytes = self.0.bar().position(); + *self.1.lock().unwrap() += bytes; + self.0.finish(); + } + + fn inc(&self, delta: u64) { + self.0.inc(delta); + } + + fn set_length(&self, len: u64) { + self.0.set_length(len); + } + + fn set_position(&self, pos: u64) { + self.0.set_position(pos); + } + + fn set_message(&self, msg: &str) { + self.0.set_message(msg); + } + + fn begin_extraction_progress(&self, total_bytes: u64) { + self.0.begin_extraction_progress(total_bytes); + } +} + +impl ProgressHandler for ArcProgressCoordinator { + fn finish(&self) { + self.0.finish(); + } + + fn inc(&self, delta: u64) { + self.0.inc(delta); + } + + fn set_length(&self, len: u64) { + self.0.set_length(len); + } + + fn set_position(&self, pos: u64) { + self.0.set_position(pos); + } + + fn set_message(&self, msg: &str) { + self.0.set_message(msg); + } + + fn begin_extraction_progress(&self, total_bytes: u64) { + self.0.begin_extraction_progress(total_bytes); + } + + fn create_child_handler( + &self, + component_name: &str, + component_type: &str, + ) -> Option> { + self.0.create_child_handler(component_name, component_type) + } + + fn mark_component_complete(&self) { + self.0.mark_component_complete(); + } + + fn set_total_components(&self, count: usize) { + self.0.set_total_components(count); + } + + fn initialize_components(&self, components: &[(String, String)]) { + self.0.initialize_components(components); + } + + fn get_component_handler(&self, component_id: &str) -> Option> { + let handlers = self.0.component_handlers.lock().unwrap(); + let handler = handlers.get(component_id)?; + Some(Box::new(ArcProgressHandler( + handler.clone(), + self.0.total_bytes_downloaded.clone(), + ))) + } +} + diff --git a/uvm/src/main.rs b/uvm/src/main.rs index a9ed4c45..84dbfe6c 100644 --- a/uvm/src/main.rs +++ b/uvm/src/main.rs @@ -1,10 +1,10 @@ mod commands; use crate::commands::detect::DetectCommand; -use crate::commands::external::{exec_command, sub_command_path}; -use crate::commands::gc::GcCommand; #[cfg(feature = "dev-commands")] use crate::commands::download_modules_json::DownloadModulesJsonCommand; +use crate::commands::external::{exec_command, sub_command_path}; +use crate::commands::gc::GcCommand; use crate::commands::install::InstallArgs; use crate::commands::launch::LaunchCommand; use crate::commands::list::ListCommand; @@ -15,6 +15,7 @@ use crate::commands::Command; use clap::{ArgAction, Args, ColorChoice, Parser, Subcommand}; use console::{style, Style}; use flexi_logger::{DeferredNow, Level, LevelFilter, LogSpecification, Logger, Record}; +use indicatif_log_bridge::LogWrapper; use log::{debug, info, Log}; use std::io; use std::path::PathBuf; @@ -37,6 +38,14 @@ pub struct GlobalOptions { #[arg(short, long, value_enum, env = "COLOR_OPTION", default_missing_value("always"), num_args(0..=1), default_value_t = ColorChoice::default() )] pub color: ColorChoice, + + /// Enable progress bars and spinners (default in interactive mode) + #[arg(long, conflicts_with = "no_progress", env = "UVM_PROGRESS")] + pub progress: bool, + + /// Disable progress bars and spinners + #[arg(long, conflicts_with = "progress", env = "UVM_NO_PROGRESS")] + pub no_progress: bool, } #[derive(Parser, Debug)] @@ -143,7 +152,15 @@ fn set_loglevel( _ => LevelFilter::max(), }; - log_spec_builder.default(level); + // Set level for uvm crates, but keep dependencies at warn to avoid noise + log_spec_builder.default(LevelFilter::Warn); + log_spec_builder.module("uvm", level); + log_spec_builder.module("uvm_install", level); + log_spec_builder.module("uvm_detect", level); + log_spec_builder.module("uvm_gc", level); + log_spec_builder.module("unity_hub", level); + log_spec_builder.module("unity_version", level); + let log_spec = log_spec_builder.build(); Logger::with(log_spec).format(format_logs).build() } @@ -161,9 +178,7 @@ fn format_logs( Level::Error => Style::new().red(), }; - write - .write(&format!("{}", style.apply_to(record.args())).into_bytes()) - .map(|_| ()) + write!(write, "{}", style.apply_to(record.args())) } fn main() -> Result<(), Box> { @@ -171,13 +186,29 @@ fn main() -> Result<(), Box> { debug!("CLI arguments: {:?}", cli); set_colors_enabled(&cli.global.color); - let (logger, _log_handle) = set_loglevel( - cli.global - .debug - .then(|| 2) - .unwrap_or(i32::from(cli.global.verbose)), - )?; - log::set_boxed_logger(logger)?; + + // Set global progress mode based on flags + commands::progress::set_progress_mode(cli.global.progress, cli.global.no_progress); + + let verbose_level = cli + .global + .debug + .then(|| 2) + .unwrap_or(i32::from(cli.global.verbose)); + + let max_level = match verbose_level { + 0 => log::LevelFilter::Warn, + 1 => log::LevelFilter::Info, + 2 => log::LevelFilter::Debug, + _ => log::LevelFilter::Trace, + }; + + let (logger, _log_handle) = set_loglevel(verbose_level)?; + let multi = commands::progress::global_multi_progress(); + LogWrapper::new((*multi).clone(), logger) + .try_init() + .map_err(|e| io::Error::new(io::ErrorKind::Other, e))?; + log::set_max_level(max_level); debug!("{:?}", cli); let code = cli.command.exec().unwrap_or_else(|err| { diff --git a/uvm_install/src/install/installer/mod.rs b/uvm_install/src/install/installer/mod.rs index 1a4e4e92..3790a624 100644 --- a/uvm_install/src/install/installer/mod.rs +++ b/uvm_install/src/install/installer/mod.rs @@ -2,6 +2,7 @@ use log::{debug, error}; use std::fs; use std::marker::PhantomData; use std::path::{Path, PathBuf}; +use super::ProgressHandler; pub type InstallerPath = PathBuf; pub type InstallDestination = PathBuf; pub type Rename = Option<(PathBuf, PathBuf)>; @@ -36,6 +37,7 @@ pub struct Installer { _variant: PhantomData, _installer_type: PhantomData, inner: I, + pub(crate) progress: Option>, } impl Installer { @@ -86,6 +88,7 @@ impl Installer { _variant: PhantomData, _installer_type: PhantomData, inner: (installer, destination, (), rename), + progress: None, } } } @@ -112,6 +115,7 @@ impl Installer { _variant: PhantomData, _installer_type: PhantomData, inner: (installer, destination, cmd, rename), + progress: None, } } } @@ -131,6 +135,7 @@ impl Installer { _variant: PhantomData, _installer_type: PhantomData, inner: (installer, (), cmd, rename), + progress: None, } } } @@ -150,6 +155,7 @@ impl Installer { _variant: PhantomData, _installer_type: PhantomData, inner: (installer, (), cmd, rename), + progress: None, } } } @@ -168,11 +174,17 @@ impl Installer { _variant: PhantomData, _installer_type: PhantomData, inner: (installer, (), (), rename), + progress: None, } } } impl Installer { + pub fn with_progress(mut self, handler: Box) -> Self { + self.progress = Some(handler); + self + } + pub fn cleanup_file_failable>(&self, file: P) { let file = file.as_ref(); if file.exists() && file.is_file() { diff --git a/uvm_install/src/install/installer/zip.rs b/uvm_install/src/install/installer/zip.rs index ee23a04c..bacc2f72 100644 --- a/uvm_install/src/install/installer/zip.rs +++ b/uvm_install/src/install/installer/zip.rs @@ -27,8 +27,24 @@ impl Installer { let file = File::open(installer).context("failed to open zip file")?; let mut archive = zip::ZipArchive::new(file)?; + // Calculate total uncompressed size from the central directory (no decompression needed) + let total_bytes: u64 = { + let mut total = 0u64; + for i in 0..archive.len() { + if let Ok(entry) = archive.by_index(i) { + total += entry.size(); + } + } + total + }; + + if let Some(ref p) = self.progress { + p.begin_extraction_progress(total_bytes); + } + for i in 0..archive.len() { let mut file = archive.by_index(i).expect("expect file entry at index 0"); + let file_size = file.size(); let output_path = rename_handler(&destination.join(file.mangled_name())); { let comment = file.comment(); @@ -55,7 +71,7 @@ impl Installer { "File {} extracted to \"{}\" ({} bytes)", i, output_path.as_path().display(), - file.size() + file_size ); if let Some(p) = output_path.parent() { if !p.exists() { @@ -89,6 +105,10 @@ impl Installer { )?; } } + + if let Some(ref p) = self.progress { + p.inc(file_size); + } } Ok(()) diff --git a/uvm_install/src/install/loader.rs b/uvm_install/src/install/loader.rs index 88a098b7..0ac7a222 100644 --- a/uvm_install/src/install/loader.rs +++ b/uvm_install/src/install/loader.rs @@ -1,3 +1,6 @@ +use crate::install::error::InstallerResult; +use crate::utils; +use crate::utils::lock_process; use crate::utils::UrlUtils; use log::*; use reqwest::header::{RANGE, USER_AGENT}; @@ -8,10 +11,8 @@ use std::io; use std::io::Read; use std::path::Path; use std::path::PathBuf; +use std::time::Instant; use unity_hub::unity::hub::paths; -use crate::install::error::InstallerResult; -use crate::utils::lock_process; -use crate::utils; #[derive(PartialEq, Eq, Hash, Debug, Clone, Copy)] enum CheckSumResult { @@ -22,12 +23,100 @@ enum CheckSumResult { Skipped, } +/// Progress handler for installation operations. +/// +/// This trait provides callbacks for tracking progress during downloads and installations. +/// +/// # Examples +/// +/// For download progress: +/// ```ignore +/// handler.set_length(total_bytes); +/// handler.inc(downloaded_bytes); +/// handler.finish(); +/// ``` +/// +/// For installation phases (extraction, installation): +/// ```ignore +/// handler.set_message("Extracting package..."); +/// // ... perform extraction ... +/// handler.set_message("Installing..."); +/// // ... perform installation ... +/// handler.finish(); +/// ``` pub trait ProgressHandler { + /// Mark the operation as finished. fn finish(&self); + + /// Increment the progress by the given delta. fn inc(&self, delta: u64); + + /// Set the total length/size of the operation. fn set_length(&self, len: u64); + + /// Set the current position. #[allow(dead_code)] fn set_position(&self, pos: u64); + + /// Set a status message for the current phase. + /// + /// This is useful for showing context during installation phases + /// that don't have deterministic progress (e.g., "Extracting...", "Installing..."). + fn set_message(&self, _msg: &str) { + // Default no-op implementation for backward compatibility + } + + /// Create a child progress handler for a component. + /// + /// This allows coordinators like MultiProgress to create individual progress bars + /// for each component being installed. Returns None if child handlers are not supported. + fn create_child_handler( + &self, + _component_name: &str, + _component_type: &str, + ) -> Option> { + None + } + + /// Mark a component as complete in the overall progress. + /// + /// For multi-component installations, this updates the overall progress counter. + fn mark_component_complete(&self) { + // Default no-op implementation + } + + /// Set the total number of components to install. + /// + /// This allows the progress handler to properly initialize the overall progress bar + /// once the component count is known. + fn set_total_components(&self, _count: usize) { + // Default no-op implementation + } + + /// Initialize progress tracking for all components upfront. + /// + /// This creates persistent progress lines for each component that will be updated + /// as they progress through their lifecycle (downloading, installing, complete/skipped). + fn initialize_components(&self, _components: &[(String, String)]) { + // Default no-op implementation + } + + /// Get a pre-created progress handler for a specific component. + /// + /// Returns the handler that was created during initialize_components. + fn get_component_handler(&self, _component_id: &str) -> Option> { + None + } + + /// Begin a determinate extraction progress display with a known total size. + /// + /// Unlike `set_length` (which is designed for download progress), this method + /// switches the display to extraction mode (e.g. progress bar with "Extracting..." + /// label) before setting the total byte count. Handlers that don't support + /// determinate extraction progress can use the default no-op. + fn begin_extraction_progress(&self, _total_bytes: u64) { + // Default no-op + } } pub trait InstallManifest { @@ -43,6 +132,9 @@ pub trait InstallManifest { struct DownloadProgress<'a, R, P> { pub inner: R, pub progress_handle: &'a P, + pub start_time: Instant, + pub bytes_downloaded: u64, + pub last_update: Instant, } // impl<'a, R: Read, P: 'a + ProgressHandler + ?Sized> Read for DownloadProgress<'a, R, &P> { @@ -58,6 +150,26 @@ impl<'a, R: Read, P: 'a + ProgressHandler + ?Sized> Read for DownloadProgress<'a fn read(&mut self, buf: &mut [u8]) -> io::Result { self.inner.read(buf).map(|n| { self.progress_handle.inc(n as u64); + self.bytes_downloaded += n as u64; + + // Update speed message periodically (every second) + let now = Instant::now(); + if now.duration_since(self.last_update).as_secs() >= 1 { + let elapsed = now.duration_since(self.start_time).as_secs_f64(); + if elapsed > 0.0 { + let speed = self.bytes_downloaded as f64 / elapsed; + let speed_msg = if speed >= 1_048_576.0 { + format!("{:.2} MB/s", speed / 1_048_576.0) + } else if speed >= 1024.0 { + format!("{:.2} KB/s", speed / 1024.0) + } else { + format!("{:.0} B/s", speed) + }; + self.progress_handle.set_message(&speed_msg); + } + self.last_update = now; + } + n }) } @@ -91,7 +203,7 @@ where } #[allow(dead_code)] - pub fn set_progress_handle(&mut self, progress_handle: &'a P) { + pub fn set_progress_handle(&mut self, progress_handle: &'a dyn ProgressHandler) { self.progress_handle = Some(Box::new(progress_handle)); } @@ -209,13 +321,28 @@ where .write(true) .open(&temp_file)?; + let download_start = Instant::now(); + if let Some(ref p) = self.progress_handle { let mut source = DownloadProgress { progress_handle: p, inner: response, + start_time: download_start, + bytes_downloaded: start_range, + last_update: download_start, }; let _ = io::copy(&mut source, &mut dest)?; + + // Set final completion message with time taken + let elapsed = download_start.elapsed(); + let elapsed_msg = if elapsed.as_secs() >= 60 { + format!("{}m {}s", elapsed.as_secs() / 60, elapsed.as_secs() % 60) + } else { + format!("{}s", elapsed.as_secs()) + }; + p.set_message(&format!("Downloaded in {}", elapsed_msg)); + p.finish(); } else { let mut source = response; let _ = io::copy(&mut source, &mut dest)?; diff --git a/uvm_install/src/install/mod.rs b/uvm_install/src/install/mod.rs index 502d01f0..2a79a860 100644 --- a/uvm_install/src/install/mod.rs +++ b/uvm_install/src/install/mod.rs @@ -3,7 +3,7 @@ pub mod installer; mod loader; pub mod utils; -pub use self::loader::{InstallManifest, Loader}; +pub use self::loader::{InstallManifest, Loader, ProgressHandler}; use error::InstallerError; use log::error; use std::path::Path; diff --git a/uvm_install/src/lib.rs b/uvm_install/src/lib.rs index 8fcd2c4d..a5cbc76f 100644 --- a/uvm_install/src/lib.rs +++ b/uvm_install/src/lib.rs @@ -4,6 +4,7 @@ mod sys; use crate::error::InstallError::{InstallFailed, InstallerCreatedFailed, LoadingInstallerFailed}; pub use error::*; use install::utils; +pub use install::ProgressHandler; use install::{InstallManifest, Loader}; use lazy_static::lazy_static; use log::{debug, info, trace}; @@ -37,6 +38,77 @@ impl AsRef for UNITY_BASE_PATTERN { } } +fn print_release_info(release: &uvm_live_platform::Release) { + use console::style; + + eprintln!( + "\n{} {}", + style("Unity Release:").cyan().bold(), + style(&release.version).white().bold() + ); + + if !release.product_name.is_empty() { + eprintln!(" {} {}", style("Product:").dim(), release.product_name); + } + + eprintln!( + " {} {}", + style("Stream:").dim(), + format!("{:?}", release.stream) + ); + + if let Some(download) = release.downloads.first() { + eprintln!( + " {} {:?} / {:?}", + style("Platform:").dim(), + download.platform, + download.architecture + ); + + if !download.modules.is_empty() { + eprintln!("\n{}", style("Available Modules:").cyan().bold()); + print_modules(&download.modules, 1); + } + } + + eprintln!(); +} + +fn print_modules(modules: &[uvm_live_platform::Module], depth: usize) { + print_modules_with_prefix(modules, depth, String::new()); +} + +fn print_modules_with_prefix(modules: &[uvm_live_platform::Module], depth: usize, prefix: String) { + for (i, module) in modules.iter().enumerate() { + let is_last = i == modules.len() - 1; + + let size_mb = module.download_size.to_bytes() / 1_048_576.0; + let size_str = if size_mb >= 1024.0 { + format!("{:.1} GB", size_mb / 1024.0) + } else { + format!("{:.0} MB", size_mb) + }; + + // Use └─ for last item, ├─ for others + let branch = if is_last { "└─" } else { "├─" }; + let module_line = format!("{}{} {} ({})", prefix, branch, module.id(), size_str); + + // All modules shown in dim style + eprintln!("{}", console::style(module_line).dim()); + + // Recursively print sub-modules with updated prefix + if !module.sub_modules().is_empty() { + // Add "│ " if not last, " " if last + let new_prefix = if is_last { + format!("{} ", prefix) + } else { + format!("{}│ ", prefix) + }; + print_modules_with_prefix(module.sub_modules(), depth + 1, new_prefix); + } + } +} + fn print_graph<'a>(graph: &'a InstallGraph<'a>) { use console::Style; @@ -130,6 +202,7 @@ pub struct InstallOptions { install_sync: bool, destination: Option, architecture: Option, + progress_handler: Option>, } impl InstallOptions { @@ -140,6 +213,7 @@ impl InstallOptions { install_sync: false, destination: None, architecture: None, + progress_handler: None, } } @@ -166,6 +240,14 @@ impl InstallOptions { self } + pub fn with_progress_handler( + mut self, + handler: P, + ) -> Self { + self.progress_handler = Some(Box::new(handler)); + self + } + fn modules_from_release(unity_release: &uvm_live_platform::Release) -> Vec { unity_release .downloads @@ -195,9 +277,16 @@ impl InstallOptions { fs::DirBuilder::new().recursive(true).create(&locks_dir)?; lock_process!(locks_dir.join(format!("{}.lock", version_string))); let architecture: UnityReleaseDownloadArchitecture = self - .architecture.clone() + .architecture + .clone() .unwrap_or(InstallArchitecture::X86_64) .into(); + + // Show spinner for metadata fetch if progress handler is available + if let Some(ref handler) = self.progress_handler { + handler.set_message("Fetching Unity version metadata..."); + } + let unity_release = FetchRelease::builder(version.to_owned()) .with_current_platform() .with_extended_lts() @@ -210,7 +299,13 @@ impl InstallOptions { })?; //let unity_release = fetch_release(version.to_owned())?; - eprintln!("{:#?}", unity_release); + print_release_info(&unity_release); + + // Show spinner for dependency resolution + if let Some(ref handler) = self.progress_handler { + handler.set_message("Resolving component dependencies..."); + } + let mut graph = InstallGraph::from(&unity_release); // @@ -350,19 +445,59 @@ impl InstallOptions { info!("\nInstall Graph"); print_graph(&graph); + // Collect all components with their status for progress tracking (deduplicate during collection) + let mut all_graph_components: Vec<(String, String, InstallStatus)> = Vec::new(); + let mut seen_ids = HashSet::new(); + + for node in graph.topo().iter(graph.context()) { + if let (Some(component), Some(status)) = + (graph.component(node), graph.install_status(node)) + { + let (id, component_type) = match component { + UnityComponent::Editor(_) => ("Unity".to_string(), "Editor".to_string()), + UnityComponent::Module(m) => (m.id().to_string(), "Module".to_string()), + }; + + if seen_ids.insert(id.clone()) { + debug!("Collected component: {} ({:?})", id, status); + all_graph_components.push((id, component_type, *status)); + } else { + debug!("Skipping duplicate component: {}", id); + } + } + } + + debug!("Total unique components: {}", all_graph_components.len()); + + // Initialize progress handler with all components (including already installed) + if let Some(ref handler) = self.progress_handler { + let component_list: Vec<(String, String)> = all_graph_components + .iter() + .map(|(id, comp_type, _)| (id.clone(), comp_type.clone())) + .collect(); + + debug!( + "Initializing progress for {} components", + component_list.len() + ); + + handler.initialize_components(&component_list); + } + // Ensure base directory exists before installation fs::DirBuilder::new().recursive(true).create(&base_dir)?; // Initialize modules list before installation loop let mut modules: Vec = match &installation { - Ok(inst) => inst.get_modules().unwrap_or_else(|_| { - Self::modules_from_release(&unity_release) - }), + Ok(inst) => inst + .get_modules() + .unwrap_or_else(|_| Self::modules_from_release(&unity_release)), Err(_) => Self::modules_from_release(&unity_release), }; // Install modules and update state incrementally - install_module_and_dependencies(&graph, &base_dir, &mut modules)?; + let progress_handler_ref = self.progress_handler.as_ref().map(|h| h.as_ref()); + install_module_and_dependencies(&graph, &base_dir, &mut modules, progress_handler_ref)?; // Get or create installation handle for final operations let installation = installation.or_else(|_| UnityInstallation::new(&base_dir))?; @@ -490,31 +625,75 @@ fn strip_unity_base_url, Q: AsRef>(path: P, base_dir: Q) -> /// Trait for installing individual modules, allowing for mocking in tests trait ModuleInstaller { fn install_module(&self, module_id: &str, base_dir: &Path) -> Result<()>; + fn progress_handler(&self) -> Option<&dyn install::ProgressHandler>; } /// Default implementation that uses the real download and install process struct RealModuleInstaller<'a> { graph: &'a InstallGraph<'a>, + progress_handler: Option<&'a dyn install::ProgressHandler>, } impl<'a> ModuleInstaller for RealModuleInstaller<'a> { fn install_module(&self, module_id: &str, base_dir: &Path) -> Result<()> { - let node = self.graph.get_node_id(module_id) - .ok_or_else(|| InstallError::UnsupportedModule(module_id.to_string(), "unknown".to_string()))?; + let node = self.graph.get_node_id(module_id).ok_or_else(|| { + InstallError::UnsupportedModule(module_id.to_string(), "unknown".to_string()) + })?; let component = self.graph.component(node).unwrap(); let unity_module = UnityComponent2(component); let version = &self.graph.release().version; let hash = &self.graph.release().short_revision; + // Determine component type for progress display + let component_type = if unity_module.is_editor() { + "Editor" + } else { + "Module" + }; + + // Get the pre-created progress handler for this component + let component_handler = self.progress_handler.and_then(|parent| { + let handler = parent.get_component_handler(module_id); + if handler.is_none() { + debug!( + "Component handler not found for '{}', creating fallback", + module_id + ); + // Fallback: create a new handler if not pre-created + parent.create_child_handler(module_id, component_type) + } else { + handler + } + }); + + // Set initial downloading state + if let Some(ref handler) = component_handler { + handler.set_message("Downloading..."); + } + info!("download installer for {}", module_id); - let loader = Loader::new(version, hash, &unity_module); + let mut loader = Loader::new(version, hash, &unity_module); + if let Some(ref handler) = component_handler { + loader.set_progress_handle(&**handler); + } let installer_path = loader .download() .map_err(|installer_err| LoadingInstallerFailed(installer_err))?; + // Update to installing state (fallback for installers without sub-phases) + if let Some(ref handler) = component_handler { + handler.set_message("Installing..."); + } + + // Get a second handler for installer sub-phase progress (Unpacking/Extracting/Installing). + // Both handlers share the same underlying progress bar via Arc. + let installer_progress = self.progress_handler.and_then(|parent| { + parent.get_component_handler(module_id) + }); + info!("create installer for {}", component); - let installer = create_installer(base_dir, installer_path, &unity_module) + let installer = create_installer(base_dir, installer_path, &unity_module, installer_progress) .map_err(|installer_err| InstallerCreatedFailed(installer_err))?; info!("install {}", component); @@ -522,16 +701,35 @@ impl<'a> ModuleInstaller for RealModuleInstaller<'a> { .install() .map_err(|installer_err| InstallFailed(module_id.to_string(), installer_err))?; + // Mark as complete + if let Some(ref handler) = component_handler { + handler.set_message("✓ Complete"); + handler.finish(); + } + + // Mark component as complete in overall progress + if let Some(handler) = self.progress_handler { + handler.mark_component_complete(); + } + Ok(()) } + + fn progress_handler(&self) -> Option<&dyn install::ProgressHandler> { + self.progress_handler + } } fn install_module_and_dependencies<'a, P: AsRef>( graph: &'a InstallGraph<'a>, base_dir: P, modules: &mut Vec, + progress_handler: Option<&'a dyn install::ProgressHandler>, ) -> Result<()> { - let installer = RealModuleInstaller { graph }; + let installer = RealModuleInstaller { + graph, + progress_handler, + }; install_modules_with_installer(graph, base_dir, modules, &installer) } @@ -545,46 +743,63 @@ fn install_modules_with_installer<'a, P: AsRef, I: ModuleInstaller>( let mut errors = Vec::new(); for node in graph.topo().iter(graph.context()) { - if let Some(InstallStatus::Missing) = graph.install_status(node) { - let component = graph.component(node).unwrap(); - let module_id = match component { - UnityComponent::Editor(_) => "Unity".to_string(), - UnityComponent::Module(m) => m.id().to_string(), - }; + let status = graph.install_status(node); + let component = graph.component(node).unwrap(); + let module_id = match component { + UnityComponent::Editor(_) => "Unity".to_string(), + UnityComponent::Module(m) => m.id().to_string(), + }; + + match status { + Some(InstallStatus::Installed) => { + // Mark already installed components + if let Some(handler) = installer.progress_handler() { + if let Some(comp_handler) = handler.get_component_handler(&module_id) { + comp_handler.set_message("✓ Already installed"); + comp_handler.finish(); + } + handler.mark_component_complete(); + } + continue; + } + Some(InstallStatus::Missing) => { + // Install missing components + } + _ => continue, + } - info!("install {}", module_id); + info!("install {}", module_id); - let install_result = installer.install_module(&module_id, base_dir); + let install_result = installer.install_module(&module_id, base_dir); - match install_result { - Err(err) if module_id == "Unity" => { - // Editor installation failed - cleanup and abort - log::error!("Editor installation failed, cleaning up"); - if base_dir.exists() { - if let Err(cleanup_err) = std::fs::remove_dir_all(base_dir) { - log::warn!("Failed to cleanup installation directory: {}", cleanup_err); - } + match install_result { + Err(err) if module_id == "Unity" => { + // Editor installation failed - cleanup and abort + log::error!("Editor installation failed, cleaning up"); + if base_dir.exists() { + if let Err(cleanup_err) = std::fs::remove_dir_all(base_dir) { + log::warn!("Failed to cleanup installation directory: {}", cleanup_err); } - return Err(InstallError::EditorInstallationFailed(Box::new(err))); - } - Err(err) => { - // Module failure - collect and continue - log::warn!("Failed to install module {}: {}", module_id, err); - errors.push(err); } - Ok(()) => { - // Mark module as installed in modules list - if let Some(m) = modules.iter_mut().find(|m| m.id() == module_id) { - m.is_installed = true; - trace!("module {} installed successfully", module_id); - } + return Err(InstallError::EditorInstallationFailed(Box::new(err))); + } + Err(err) => { + // Module failure - collect and continue + log::warn!("Failed to install module {}: {}", module_id, err); + errors.push(err); + } + Ok(()) => { + // Mark module as installed in modules list + if let Some(m) = modules.iter_mut().find(|m| m.id() == module_id) { + m.is_installed = true; + trace!("module {} installed successfully", module_id); } } - - // Write modules.json after each module (success or failure) - // Note: This won't run if we returned early from Editor failure above - write_modules_json(base_dir, modules); } + + // Write modules.json after each module (success or failure) + // Note: This won't run if we returned early from Editor failure above + write_modules_json(base_dir, modules); } // Return appropriate result based on collected errors @@ -831,8 +1046,8 @@ mod tests { mod install_integration_tests { use super::*; use std::sync::{Arc, Mutex}; - use uvm_live_platform::Release; use uvm_install_graph::InstallGraph; + use uvm_live_platform::Release; // ============================================================ // Test Fixtures - Create Release/Module from JSON @@ -948,16 +1163,24 @@ mod tests { } impl ModuleInstaller for MockModuleInstaller { + fn progress_handler(&self) -> Option<&dyn install::ProgressHandler> { + None + } + fn install_module(&self, module_id: &str, _base_dir: &Path) -> Result<()> { // Record this install attempt - self.install_order.lock().unwrap().push(module_id.to_string()); + self.install_order + .lock() + .unwrap() + .push(module_id.to_string()); if self.fail_modules.contains(module_id) { Err(InstallError::InstallFailed( module_id.to_string(), - crate::install::error::InstallerError::from( - io::Error::new(io::ErrorKind::Other, format!("Mock failure for {}", module_id)) - ), + crate::install::error::InstallerError::from(io::Error::new( + io::ErrorKind::Other, + format!("Mock failure for {}", module_id), + )), )) } else { Ok(()) @@ -1001,7 +1224,11 @@ mod tests { count += 1; } // Should have Editor + 2 modules = 3 nodes - assert!(count >= 2, "Graph should have at least 2 nodes, got {}", count); + assert!( + count >= 2, + "Graph should have at least 2 nodes, got {}", + count + ); } #[test] @@ -1090,7 +1317,10 @@ mod tests { } assert!(modules[0].is_installed, "android should be installed"); assert!(!modules[1].is_installed, "ios should NOT be installed"); - assert!(modules[2].is_installed, "webgl should be installed (continued past failure)"); + assert!( + modules[2].is_installed, + "webgl should be installed (continued past failure)" + ); } #[test] @@ -1153,7 +1383,8 @@ mod tests { // ios fails let installer = MockModuleInstaller::with_failures(["ios"]); - let _errors = install_modules_with_installer(&graph, base_dir, &mut modules, &installer); + let _errors = + install_modules_with_installer(&graph, base_dir, &mut modules, &installer); // Read modules.json and verify state let modules_json_path = base_dir.join("modules.json"); @@ -1166,9 +1397,18 @@ mod tests { let ios = parsed.iter().find(|m| m.id() == "ios"); let webgl = parsed.iter().find(|m| m.id() == "webgl"); - assert!(android.map(|m| m.is_installed).unwrap_or(false), "android should be installed in JSON"); - assert!(!ios.map(|m| m.is_installed).unwrap_or(true), "ios should NOT be installed in JSON"); - assert!(webgl.map(|m| m.is_installed).unwrap_or(false), "webgl should be installed in JSON"); + assert!( + android.map(|m| m.is_installed).unwrap_or(false), + "android should be installed in JSON" + ); + assert!( + !ios.map(|m| m.is_installed).unwrap_or(true), + "ios should NOT be installed in JSON" + ); + assert!( + webgl.map(|m| m.is_installed).unwrap_or(false), + "webgl should be installed in JSON" + ); } #[test] @@ -1194,13 +1434,23 @@ mod tests { // ios fails (middle module) let installer = MockModuleInstaller::with_failures(["ios"]); - let _errors = install_modules_with_installer(&graph, base_dir, &mut modules, &installer); + let _errors = + install_modules_with_installer(&graph, base_dir, &mut modules, &installer); // Verify all modules were attempted let install_order = installer.get_install_order(); - assert!(install_order.contains(&"android".to_string()), "android should have been attempted"); - assert!(install_order.contains(&"ios".to_string()), "ios should have been attempted"); - assert!(install_order.contains(&"webgl".to_string()), "webgl should have been attempted (after ios failure)"); + assert!( + install_order.contains(&"android".to_string()), + "android should have been attempted" + ); + assert!( + install_order.contains(&"ios".to_string()), + "ios should have been attempted" + ); + assert!( + install_order.contains(&"webgl".to_string()), + "webgl should have been attempted (after ios failure)" + ); } #[test] @@ -1238,11 +1488,18 @@ mod tests { // Verify no modules were attempted (Editor is first in topo order) let install_order = installer.get_install_order(); - assert_eq!(install_order.len(), 1, "Only Unity should have been attempted"); + assert_eq!( + install_order.len(), + 1, + "Only Unity should have been attempted" + ); assert_eq!(install_order[0], "Unity"); // Verify installation directory does not exist (cleanup worked) - assert!(!base_dir.exists(), "Installation directory should have been cleaned up"); + assert!( + !base_dir.exists(), + "Installation directory should have been cleaned up" + ); } #[test] @@ -1282,7 +1539,10 @@ mod tests { } // Verify installation directory still exists - assert!(base_dir.exists(), "Installation directory should still exist"); + assert!( + base_dir.exists(), + "Installation directory should still exist" + ); // Verify other modules were installed assert!(modules[0].is_installed, "android should be installed"); diff --git a/uvm_install/src/sys/linux/mod.rs b/uvm_install/src/sys/linux/mod.rs index 48a78699..3a7df421 100644 --- a/uvm_install/src/sys/linux/mod.rs +++ b/uvm_install/src/sys/linux/mod.rs @@ -7,6 +7,7 @@ use std::path::Path; use crate::install::error::{InstallerErrorInner, InstallerResult}; use crate::install::installer::{Installer, ModulePoInstaller, ModuleZipInstaller}; use crate::install::InstallHandler; +use crate::ProgressHandler; mod pkg; mod xz; @@ -16,6 +17,7 @@ pub fn create_installer( base_install_path: P, installer: I, module: &M, + progress: Option>, ) -> InstallerResult> where P: AsRef, @@ -26,10 +28,10 @@ where let rename = module.install_rename_from_to(base_install_path); if module.is_editor() { - parse_editor_installer(installer, &base_install_path, rename) + parse_editor_installer(installer, &base_install_path, rename, progress) } else { let destination = module.install_destination(&base_install_path); - parse_module_installer(installer, destination, rename) + parse_module_installer(installer, destination, rename, progress) } } @@ -37,6 +39,7 @@ fn parse_editor_installer( installer: P, destination: D, rename: Option<(R, R)>, + _progress: Option>, ) -> InstallerResult> where P: AsRef, @@ -67,6 +70,7 @@ fn parse_module_installer( installer: P, destination: Option, rename: Option<(R, R)>, + progress: Option>, ) -> InstallerResult> where P: AsRef, @@ -90,11 +94,15 @@ where Some(ext) if ext == "zip" => { if let Some(destination) = destination { - Ok(Box::new(ModuleZipInstaller::new( + let mut i = ModuleZipInstaller::new( installer.to_path_buf(), destination.as_ref().to_path_buf(), rename, - ))) + ); + if let Some(p) = progress { + i = i.with_progress(p); + } + Ok(Box::new(i)) } else { Err(InstallerErrorInner::MissingDestination("zip".to_string()).into()) } @@ -114,11 +122,15 @@ where Some(ext) if ext == "pkg" => { if let Some(destination) = destination { - Ok(Box::new(ModulePkgInstaller::new( + let mut i = ModulePkgInstaller::new( installer.to_path_buf(), destination.as_ref().to_path_buf(), rename, - ))) + ); + if let Some(p) = progress { + i = i.with_progress(p); + } + Ok(Box::new(i)) } else { Err(InstallerErrorInner::MissingDestination("po".to_string()).into()) } diff --git a/uvm_install/src/sys/linux/pkg.rs b/uvm_install/src/sys/linux/pkg.rs index 727d90c0..143c79b0 100644 --- a/uvm_install/src/sys/linux/pkg.rs +++ b/uvm_install/src/sys/linux/pkg.rs @@ -119,7 +119,15 @@ impl InstallHandler for ModulePkgInstaller { let tmp_destination = destination.join("tmp"); DirBuilder::new().recursive(true).create(&tmp_destination)?; + + if let Some(ref p) = self.progress { + p.set_message("Unpacking..."); + } self.xar(installer, &tmp_destination)?; + + if let Some(ref p) = self.progress { + p.set_message("Extracting..."); + } self.untar(&tmp_destination, destination)?; self.cleanup(&tmp_destination)?; Ok(()) diff --git a/uvm_install/src/sys/linux/xz.rs b/uvm_install/src/sys/linux/xz.rs index 352a35c1..fe9def21 100644 --- a/uvm_install/src/sys/linux/xz.rs +++ b/uvm_install/src/sys/linux/xz.rs @@ -76,6 +76,10 @@ impl InstallHandler for ModuleXzInstaller { self.destination().display() ); + if let Some(ref p) = self.progress { + p.set_message("Extracting..."); + } + let destination = self.destination(); let installer = self.installer(); let destination = if destination.ends_with("Editor/Data/PlaybackEngines/iOSSupport") { diff --git a/uvm_install/src/sys/mac/mod.rs b/uvm_install/src/sys/mac/mod.rs index 0a52d8bf..61a07b23 100644 --- a/uvm_install/src/sys/mac/mod.rs +++ b/uvm_install/src/sys/mac/mod.rs @@ -3,7 +3,7 @@ use self::pkg::{EditorPkgInstaller, ModulePkgInstaller, ModulePkgNativeInstaller use crate::install::error::{InstallerErrorInner, InstallerResult}; use crate::install::installer::{ModulePoInstaller, ModuleZipInstaller}; use crate::install::InstallHandler; -use crate::InstallManifest; +use crate::{InstallManifest, ProgressHandler}; use std::path::Path; mod dmg; @@ -15,6 +15,7 @@ pub fn create_installer( base_install_path: P, installer: I, module: &M, + progress: Option>, ) -> InstallerResult> where P: AsRef, @@ -25,10 +26,10 @@ where let rename = module.install_rename_from_to(&base_install_path); if module.is_editor() { - parse_editor_installer(installer, &base_install_path, rename) + parse_editor_installer(installer, &base_install_path, rename, progress) } else { let destination = module.install_destination(&base_install_path); - parse_module_installer(installer, destination, rename) + parse_module_installer(installer, destination, rename, progress) } } @@ -36,6 +37,7 @@ fn parse_editor_installer( installer: P, destination: D, rename: Option<(R, R)>, + progress: Option>, ) -> InstallerResult> where P: AsRef, @@ -44,11 +46,13 @@ where { let installer = installer.as_ref(); match installer.extension() { - Some(ext) if ext == "pkg" => Ok(Box::new(EditorPkgInstaller::new( - installer, - destination, - rename, - ))), + Some(ext) if ext == "pkg" => { + let mut i = EditorPkgInstaller::new(installer, destination, rename); + if let Some(p) = progress { + i = i.with_progress(p); + } + Ok(Box::new(i)) + } _ => Err(InstallerErrorInner::UnknownInstaller( installer.display().to_string(), ".pkg".to_string(), @@ -61,6 +65,7 @@ fn parse_module_installer( installer: P, destination: Option, rename: Option<(R, R)>, + progress: Option>, ) -> InstallerResult> where P: AsRef, @@ -72,11 +77,15 @@ where match installer.extension() { Some(ext) if ext == "pkg" => { if let Some(destination) = destination { - Ok(Box::new(ModulePkgInstaller::new( + let mut i = ModulePkgInstaller::new( installer.to_path_buf(), destination.as_ref().to_path_buf(), rename, - ))) + ); + if let Some(p) = progress { + i = i.with_progress(p); + } + Ok(Box::new(i)) } else { let i = ModulePkgNativeInstaller::new(installer, rename); Ok(Box::new(i)) @@ -85,11 +94,15 @@ where Some(ext) if ext == "zip" => { if let Some(destination) = destination { - Ok(Box::new(ModuleZipInstaller::new( + let mut i = ModuleZipInstaller::new( installer.to_path_buf(), destination.as_ref().to_path_buf(), rename, - ))) + ); + if let Some(p) = progress { + i = i.with_progress(p); + } + Ok(Box::new(i)) } else { Err(InstallerErrorInner::MissingDestination("zip".to_string()).into()) } diff --git a/uvm_install/src/sys/mac/pkg.rs b/uvm_install/src/sys/mac/pkg.rs index 5f82c092..730e3385 100644 --- a/uvm_install/src/sys/mac/pkg.rs +++ b/uvm_install/src/sys/mac/pkg.rs @@ -283,8 +283,20 @@ impl InstallHandler for EditorPkgInstaller { let tmp_destination = destination.join("tmp"); DirBuilder::new().recursive(true).create(&tmp_destination)?; + + if let Some(ref p) = self.progress { + p.set_message("Unpacking..."); + } self.xar(installer, &tmp_destination)?; + + if let Some(ref p) = self.progress { + p.set_message("Extracting..."); + } self.untar(&tmp_destination, destination)?; + + if let Some(ref p) = self.progress { + p.set_message("Installing..."); + } self.cleanup_editor(destination)?; self.cleanup(&tmp_destination)?; @@ -326,8 +338,20 @@ impl InstallHandler for ModulePkgInstaller { let tmp_destination = destination.join("tmp"); DirBuilder::new().recursive(true).create(&tmp_destination).context("failed to create temp install directory")?; + + if let Some(ref p) = self.progress { + p.set_message("Unpacking..."); + } self.xar(installer, &tmp_destination)?; + + if let Some(ref p) = self.progress { + p.set_message("Extracting..."); + } self.untar(&tmp_destination, destination).context("failed to unpack the payload")?; + + if let Some(ref p) = self.progress { + p.set_message("Installing..."); + } self.cleanup_ios_support(destination).context("failed to cleanup ios support destination")?; self.cleanup(&tmp_destination).context("failed to cleanup temp files")?; Ok(()) diff --git a/uvm_install/src/sys/win/mod.rs b/uvm_install/src/sys/win/mod.rs index ead7d366..26090158 100644 --- a/uvm_install/src/sys/win/mod.rs +++ b/uvm_install/src/sys/win/mod.rs @@ -4,6 +4,7 @@ use crate::install::error::{InstallerErrorInner, InstallerResult}; use crate::install::error::InstallerErrorInner::Other; use crate::install::installer::{Installer, ModulePoInstaller, ModuleZipInstaller}; use crate::install::InstallHandler; +use crate::ProgressHandler; use self::exe::*; use self::msi::ModuleMsiInstaller; mod exe; @@ -13,6 +14,7 @@ pub fn create_installer( base_install_path: P, installer: I, module: &M, + _progress: Option>, ) -> InstallerResult> where P: AsRef,