diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 2ce244fb..48cff891 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -1,136 +1,43 @@ -# Copilot Instructions for Psst +# Copilot Instructions -Psst is a fast, native Spotify client written in Rust, without Electron. This repository contains a Cargo workspace with multiple crates that implement different aspects of the application. +This is a multi-crate Rust workspace that implements the psst Spotify client, including a GUI (`psst-gui`), CLI (`psst-cli`), shared core library (`psst-core`), and protocol bindings (`psst-protocol`). It supports macOS, Windows, and Linux builds via `cargo` and Cross. -## Project Structure +## Code Standards -- `/psst-core` - Core library handling Spotify TCP session, audio retrieval, decoding, output, and playback queue -- `/psst-gui` - GUI application built with [Druid](https://github.com/linebender/druid) -- `/psst-cli` - Example CLI application for testing and development -- `/psst-protocol` - Internal Protobuf definitions for Spotify communication +### Required Before Each Commit -## Technology Stack +- Run `cargo fmt --all` to keep Rust sources formatted consistently across crates. +- Run `cargo clippy --all-targets --all-features -- -D warnings` to prevent lints from slipping into main. +- Ensure `./scripts/run-tests.sh` passes; it drives the standard unit/integration suite across the workspace. -- **Language**: Rust (stable, minimum version 1.65.0) -- **GUI Framework**: Druid (with GTK and X11 backends on Linux) -- **Build System**: Cargo -- **Audio**: Custom implementation using symphonia and libsamplerate +### Development Flow -## Development Guidelines +- Build (workspace): `cargo build --workspace` +- Build (GUI app with release profile): `cargo build -p psst-gui --release` +- Test (workspace): `cargo test --workspace` +- Full local verification: `./scripts/run-tests.sh` +- Run GUI app (dev mode): `cargo run -p psst-gui` +- Cross-platform release check (optional): `cross build --workspace --release` -### Code Style +## Repository Structure -- Follow Rust standard formatting using `rustfmt` -- Configuration in `.rustfmt.toml`: - - Use crate-level import granularity - - Wrap comments at line boundaries -- Run `cargo clippy -- -D warnings` to check for linting issues -- All clippy warnings should be addressed before committing +- `psst-core/`: Core playback, session, caching, and Spotify protocol logic shared by front ends. +- `psst-gui/`: GUI application built with Druid; handles controller/data/ui layers and platform integrations. +- `psst-cli/`: Minimal terminal client demonstrating core playback and session routines. +- `psst-protocol/`: Generated protocol bindings and protobuf definitions; run `./psst-protocol/build.sh` or `cargo build -p psst-protocol` after editing `proto/`. +- `scripts/`: Helper scripts such as `run-tests.sh` for unified local CI. +- `target/`: Cargo build artifacts (ignored by git). -### Building and Testing +## Key Guidelines -1. **Build the project**: - ```bash - cargo build - # For release builds: - cargo build --release - ``` +1. Follow idiomatic Rust patterns: prefer `Result` error handling, avoid `unwrap` in production paths, and document `unsafe` usage clearly if unavoidable. +2. Keep shared abstractions inside `psst-core`; front ends should depend on those APIs rather than duplicating logic. +3. When touching the GUI, ensure state flows through the controller/data layers to keep UI widgets declarative. +4. Add tests for new behaviour. Use integration tests for player/session flows and unit tests for isolated components. Wire new suites into `./scripts/run-tests.sh`. +5. Adopt the branching strategy below before committing changes. -2. **Run tests**: - ```bash - cargo test --workspace --all-targets - ``` +## Branching Strategy -3. **Check formatting and linting**: - ```bash - cargo fmt --check - cargo clippy -- -D warnings - ``` - -4. **Run the GUI application**: - ```bash - cargo run --bin psst-gui - ``` - -### Platform-Specific Considerations - -- **Linux**: Requires GTK-3, OpenSSL, and ALSA development libraries - - Debian/Ubuntu: `libssl-dev libgtk-3-dev libcairo2-dev libasound2-dev` - - RHEL/Fedora: `openssl-devel gtk3-devel cairo-devel alsa-lib-devel` -- **macOS**: Standard development tools required -- **Windows**: Standard development tools required - -### Code Organization - -- Keep synchronous architecture (no tokio or async runtime currently) -- Use HTTPS-based CDN for audio file retrieval -- Separate concerns between protocol, core logic, and UI layers -- Follow existing patterns in each crate - -### Testing - -- Write unit tests in the crate's `tests` directory -- Integration tests should cover cross-crate functionality -- Test both success and error paths -- Mock external Spotify API calls when possible - -## Pull Request Guidelines - -When creating or updating pull requests: - -1. Ensure all tests pass: `cargo test --workspace --all-targets` -2. Verify code style: `cargo fmt --check && cargo clippy -- -D warnings` -3. Keep changes focused and minimal -4. Update relevant documentation in README.md or code comments -5. Reference related issues using "Fixes #issue" or "Towards #issue" -6. Label pull requests appropriately -7. For LLM-generated PRs, always indicate this in the description - -## Privacy and Security - -- **Never commit Spotify credentials** or authentication tokens -- Use reusable authentication tokens from Spotify (not stored user credentials) -- Only connect to official Spotify servers -- Keep local caches user-deletable -- Follow Spotify's API terms of service - -## Common Tasks - -### Adding a New Feature - -1. Identify which crate(s) need changes -2. Add necessary dependencies to `Cargo.toml` -3. Implement feature following existing patterns -4. Add tests for new functionality -5. Update documentation -6. Test on all target platforms if possible - -### Fixing Bugs - -1. Reproduce the issue -2. Add a failing test that demonstrates the bug -3. Fix the issue -4. Verify the test now passes -5. Check for similar issues elsewhere - -### Performance Optimization - -- Profile before optimizing -- Use `cargo build --release` for benchmarking -- Consider impact on audio playback quality -- Test on resource-constrained systems - -## Important Notes - -- This project does not support Spotify Connect (remote control) yet -- Audio playback should be glitch-free and responsive -- UI should remain responsive during network operations -- Cache management should be memory-efficient -- A Spotify Premium account is required for testing - -## Resources - -- Project README: `/README.md` -- Rust documentation: Standard library docs -- Druid documentation: https://github.com/linebender/druid -- Spotify protocol details: See psst-protocol crate +- Start new feature work from `dev` using a short-lived feature branch (`feature/`). Keep the branch focused and rebased as needed. +- Build and test the feature branch locally; once it passes, merge it back into `dev` and delete the feature branch. +- Periodically (after a batch of features or when a release feels ready), merge `dev` into `main` following the same test/verification checklist. diff --git a/README.md b/README.md index 97b2112f..a588a7c5 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,16 @@ You can download the latest release for Windows, Linux, and macOS from the [GitH Unofficial builds of Psst are also available through the [AUR](https://aur.archlinux.org/packages/psst-git) and [Homebrew](https://formulae.brew.sh/cask/psst). +### Automatic Updates + +Psst includes automatic update checking to keep you informed about new releases. By default, the app will check for updates on startup (no more than once per 24 hours). You can: +- View available updates in **Preferences > Updates** +- Manually check for updates at any time +- Disable automatic checks if preferred +- Dismiss specific versions you don't want to install + +See [docs/UPDATES.md](docs/UPDATES.md) for more information. + ## Building On all platforms, the **latest [Rust](https://rustup.rs/) stable** (at least 1.65.0) is required. @@ -130,6 +140,7 @@ cargo bundle --release - Rename playlist - Playlist folders - [x] Playback queue +- [x] Automatic update checking - [ ] React to audio output device events - Pause after disconnecting headphones - Transfer playback after connecting headphones diff --git a/copilot-instructions.md b/copilot-instructions.md deleted file mode 100644 index 48cff891..00000000 --- a/copilot-instructions.md +++ /dev/null @@ -1,43 +0,0 @@ -# Copilot Instructions - -This is a multi-crate Rust workspace that implements the psst Spotify client, including a GUI (`psst-gui`), CLI (`psst-cli`), shared core library (`psst-core`), and protocol bindings (`psst-protocol`). It supports macOS, Windows, and Linux builds via `cargo` and Cross. - -## Code Standards - -### Required Before Each Commit - -- Run `cargo fmt --all` to keep Rust sources formatted consistently across crates. -- Run `cargo clippy --all-targets --all-features -- -D warnings` to prevent lints from slipping into main. -- Ensure `./scripts/run-tests.sh` passes; it drives the standard unit/integration suite across the workspace. - -### Development Flow - -- Build (workspace): `cargo build --workspace` -- Build (GUI app with release profile): `cargo build -p psst-gui --release` -- Test (workspace): `cargo test --workspace` -- Full local verification: `./scripts/run-tests.sh` -- Run GUI app (dev mode): `cargo run -p psst-gui` -- Cross-platform release check (optional): `cross build --workspace --release` - -## Repository Structure - -- `psst-core/`: Core playback, session, caching, and Spotify protocol logic shared by front ends. -- `psst-gui/`: GUI application built with Druid; handles controller/data/ui layers and platform integrations. -- `psst-cli/`: Minimal terminal client demonstrating core playback and session routines. -- `psst-protocol/`: Generated protocol bindings and protobuf definitions; run `./psst-protocol/build.sh` or `cargo build -p psst-protocol` after editing `proto/`. -- `scripts/`: Helper scripts such as `run-tests.sh` for unified local CI. -- `target/`: Cargo build artifacts (ignored by git). - -## Key Guidelines - -1. Follow idiomatic Rust patterns: prefer `Result` error handling, avoid `unwrap` in production paths, and document `unsafe` usage clearly if unavoidable. -2. Keep shared abstractions inside `psst-core`; front ends should depend on those APIs rather than duplicating logic. -3. When touching the GUI, ensure state flows through the controller/data layers to keep UI widgets declarative. -4. Add tests for new behaviour. Use integration tests for player/session flows and unit tests for isolated components. Wire new suites into `./scripts/run-tests.sh`. -5. Adopt the branching strategy below before committing changes. - -## Branching Strategy - -- Start new feature work from `dev` using a short-lived feature branch (`feature/`). Keep the branch focused and rebased as needed. -- Build and test the feature branch locally; once it passes, merge it back into `dev` and delete the feature branch. -- Periodically (after a batch of features or when a release feels ready), merge `dev` into `main` following the same test/verification checklist. diff --git a/TESTING.md b/docs/TESTING.md similarity index 100% rename from TESTING.md rename to docs/TESTING.md diff --git a/TODO.md b/docs/TODO.md similarity index 91% rename from TODO.md rename to docs/TODO.md index da90ab6a..669a6fc6 100644 --- a/TODO.md +++ b/docs/TODO.md @@ -6,5 +6,4 @@ # TODO: Support custom themes with the given theme editor under the Appearance settings view and implement export/import functionality for sharing color palettes and typography by defining a theming schema, adding live preview tooling (including font pickers limited to installed fonts), wiring persistence, and wiring export/import buttons that export the theme config to a user-chosen location OR load from a user-selected file. -# ✅ COMPLETED: Added comprehensive test suite with 70+ tests covering edge cases, error handling, unit tests, and integration tests. Tests can be run from ./scripts/run-tests.sh and are integrated into CI gating. See TESTING.md for details. - +# ✅ COMPLETED: Added comprehensive test suite with 70+ tests covering edge cases, error handling, unit tests, and integration tests. Tests can be w diff --git a/docs/UPDATES.md b/docs/UPDATES.md new file mode 100644 index 00000000..4ee1ed3c --- /dev/null +++ b/docs/UPDATES.md @@ -0,0 +1,161 @@ +# Automatic Updates + +Psst includes an automatic update system that helps you stay up-to-date with the latest features and bug fixes. + +## Features + +### Automatic Update Checking + +By default, Psst will check for updates every time you start the application. This happens in the background and won't interfere with your music listening experience. + +### Manual Update Check + +You can manually check for updates at any time: + +1. Open **Preferences** (from the menu or keyboard shortcut) +2. Navigate to the **Updates** tab +3. Click **Check for Updates** + +### Version Information + +The Updates tab shows: + +- Your current version +- Whether an update is available +- Release notes for new versions +- Download options for your platform + +## Configuration + +### Disabling Automatic Checks + +If you prefer to check for updates manually: + +1. Open **Preferences > Updates** +2. Uncheck **"Check for updates on startup"** + +Psst will save this preference and won't check for updates automatically until you re-enable it. + +### Update Frequency + +When automatic checking is enabled, Psst checks for updates: + +- On application startup +- No more than once per 24 hours + +This ensures you're notified of new versions without excessive network requests. + +## Installing Updates + +When an update is available, click **Install Update** in the Updates tab and Psst will do the rest: + +1. The updater downloads the latest release asset for your platform. +2. Psst installs the update in the background without blocking the UI. +3. A status message confirms success or reports any errors. + +### Platform Notes + +- **macOS**: The updater mounts the DMG, copies `Psst.app` into `/Applications`, removes any quarantine flags with `xattr -dr com.apple.quarantine /Applications/Psst.app/`, and verifies attributes via `xattr -l /Applications/Psst.app/`. +- **Windows**: A silent PowerShell helper stages the new `Psst.exe` and replaces the current executable after you exit the app. Restart Psst to finish the update. +- **Linux**: The staged binary replaces the currently running executable in-place. Restart Psst to run the new version. + +If automatic installation is unavailable (for example on unsupported platforms), the updater offers an easy shortcut to open the release page so you can download manually. + +## Dismissing Updates + +If you don't want to install a specific update: + +1. Click the **Dismiss** button +2. Psst won't notify you about this version again +3. You'll be notified when a newer version is released + +## Update Process + +1. **Check**: Psst queries the GitHub API for the latest release +2. **Compare**: The current version is compared with the latest available version +3. **Notify**: If a newer version is available (and not dismissed), you'll see it in the Updates tab +4. **Download**: You choose when to download and install the update + +## Privacy & Security + +- **No tracking**: Psst only checks the official GitHub repository for releases +- **HTTPS only**: All update checks use secure HTTPS connections +- **User-initiated installation**: Updates are downloaded and installed only after you click **Install Update**—no silent background updates +- **No personal data**: No personal information is sent during update checks + +## Troubleshooting + +### Update Check Failed + +If update checking fails, possible causes include: + +- No internet connection +- GitHub API temporarily unavailable +- Firewall or proxy blocking GitHub access + +The error will be logged, and you can try checking again later. + +### No Updates Shown + +If no updates appear: + +- You're running the latest version +- You've dismissed the latest version (check your config) +- The update check hasn't run yet (wait for startup or manually check) + +### Update Version Format + +Psst uses date-based versions in the format `YYYY.MM.DD-COMMIT`. This makes it easy to see how recent your version is. + +## Manual Validation Checklist + +To smoke-test the installer on each platform: + +- **macOS** + - Download the latest universal DMG from the Releases page. + - Launch Psst and use **Preferences → Updates → Install Update** while the DMG is on disk. + - When prompted in the logs, confirm `/Applications/Psst.app` was replaced and run `xattr -l /Applications/Psst.app/` to verify no quarantine flag remains. +- **Windows** + - Place the newest `Psst.exe` in a writable folder alongside the running build. + - Trigger **Install Update** and exit the app; ensure `Psst.update.exe` is created and deleted after restart, and the timestamp on `Psst.exe` is updated. +- **Linux (x86_64 & aarch64)** + - Copy the corresponding binary next to the existing executable and run the installer. + - Confirm the executable is replaced in-place and marked executable (`chmod +x`). + +The automated unit tests cover error-handling, URL selection, and notification sequencing, but the above manual checks verify platform tooling (hdiutil, PowerShell, filesystem permissions) that cannot be exercised in CI. + +## Technical Details + +### GitHub Integration + +Updates are fetched from the official Psst GitHub repository: + +- Repository: `isaaclins/psst` +- API endpoint: `https://api.github.com/repos/isaaclins/psst/releases/latest` + +### Configuration Storage + +Update preferences are stored in your Psst configuration file: + +- `check_on_startup`: Boolean flag for automatic checking +- `last_check_timestamp`: Unix timestamp of last check +- `dismissed_version`: Version you've chosen to ignore + +### Release Assets + +Each release includes pre-built binaries for: + +- Windows (x86_64) +- macOS (Universal binary: x86_64 + ARM64) +- Linux x86_64 (binary and .deb) +- Linux ARM64/aarch64 (binary and .deb) + +## For Developers + +If you're building Psst from source, the update system will compare your version (`0.1.0`) against published releases. Since date-based versions are chronologically ordered, you'll always be notified of newer official releases. + +To disable update checks during development, uncheck "Check for updates on startup" in Preferences. + +## Feedback + +If you encounter any issues with the update system, please report them on our [GitHub Issues](https://github.com/isaaclins/psst/issues) page. diff --git a/psst-cli/src/main.rs b/psst-cli/src/main.rs index 078fe419..86cf132f 100644 --- a/psst-cli/src/main.rs +++ b/psst-cli/src/main.rs @@ -29,9 +29,7 @@ fn run() -> Result<(), CliError> { let mut args = env::args(); let _binary = args.next(); - let track_id = args - .next() - .ok_or(CliError::MissingTrackId)?; + let track_id = args.next().ok_or(CliError::MissingTrackId)?; let eq_preset_name = args.next(); let username = env::var("SPOTIFY_USERNAME").map_err(|_| CliError::MissingUsername)?; diff --git a/psst-gui/src/cmd.rs b/psst-gui/src/cmd.rs index 1b104fb2..6d3eefd6 100644 --- a/psst-gui/src/cmd.rs +++ b/psst-gui/src/cmd.rs @@ -23,6 +23,7 @@ pub const GO_TO_URL: Selector = Selector::new("app.go-to-url"); pub const OAUTH_TOKENS_REFRESHED: Selector<(String, Option)> = Selector::new("app.oauth-tokens-refreshed"); pub const BEGIN_THEME_IMPORT: Selector = Selector::new("app.begin-theme-import"); +pub const BEGIN_THEME_EXPORT: Selector = Selector::new("app.begin-theme-export"); // Find pub const TOGGLE_FINDER: Selector = Selector::new("app.show-finder"); @@ -82,3 +83,9 @@ pub const LOAD_TRACK_CREDITS: Selector> = Selector::new("app.credits- // Artwork pub const SHOW_ARTWORK: Selector = Selector::new("app.show-artwork"); + +// Updates +pub const CHECK_FOR_UPDATES: Selector = Selector::new("app.check-for-updates"); +pub const INSTALL_UPDATE: Selector = Selector::new("app.install-update"); +pub const UPDATE_INSTALL_STATUS: Selector = + Selector::new("app.update-install-status"); diff --git a/psst-gui/src/data/config.rs b/psst-gui/src/data/config.rs index f411f848..07ca1869 100644 --- a/psst-gui/src/data/config.rs +++ b/psst-gui/src/data/config.rs @@ -19,7 +19,7 @@ use psst_core::{ }; use serde::{Deserialize, Serialize}; -use super::{Nav, Promise, QueueBehavior, SliderScrollScale}; +use super::{Nav, Promise, QueueBehavior, SliderScrollScale, UpdateInfo, UpdatePreferences}; use crate::ui::theme; #[derive(Clone, Debug, Data, Lens)] @@ -30,6 +30,10 @@ pub struct Preferences { pub cache_size: Promise, pub auth: Authentication, pub lastfm_auth_result: Option, + pub available_update: Option, + pub checking_update: bool, + pub installing_update: bool, + pub update_install_status: Option, } impl Preferences { @@ -51,8 +55,9 @@ pub enum PreferencesTab { Appearance, Equalizer, Account, - Privacy, + DiscordPresence, Cache, + Updates, About, } @@ -160,6 +165,8 @@ pub struct Config { #[data(ignore)] #[serde(default)] pub custom_equalizer_presets: Vec, + #[serde(default)] + pub update_preferences: UpdatePreferences, } impl Default for Config { @@ -195,6 +202,7 @@ impl Default for Config { presence_dynamic_cover: false, equalizer: Default::default(), custom_equalizer_presets: Vec::new(), + update_preferences: Default::default(), } } } diff --git a/psst-gui/src/data/mod.rs b/psst-gui/src/data/mod.rs index ee868e70..68d19526 100644 --- a/psst-gui/src/data/mod.rs +++ b/psst-gui/src/data/mod.rs @@ -13,6 +13,7 @@ mod search; mod show; mod slider_scroll_scale; mod track; +mod update_checker; mod user; pub mod utils; @@ -60,6 +61,9 @@ pub use crate::data::{ show::{Episode, EpisodeId, EpisodeLink, Show, ShowDetail, ShowEpisodes, ShowLink}, slider_scroll_scale::SliderScrollScale, track::{AudioAnalysis, Track, TrackId, TrackLines}, + update_checker::{ + UpdateInfo, UpdateInstallEvent, UpdateInstallPhase, UpdateInstaller, UpdatePreferences, + }, user::{PublicUser, UserProfile}, utils::{Cached, Float64, Image, Page}, }; @@ -128,6 +132,10 @@ impl AppState { cache_size: Promise::Empty, auth: Authentication::new(), lastfm_auth_result: None, + available_update: None, + checking_update: false, + installing_update: false, + update_install_status: None, }, playback, added_queue: Vector::new(), diff --git a/psst-gui/src/data/update_checker.rs b/psst-gui/src/data/update_checker.rs new file mode 100644 index 00000000..972ff610 --- /dev/null +++ b/psst-gui/src/data/update_checker.rs @@ -0,0 +1,657 @@ +use druid::{Data, Lens}; +use serde::{Deserialize, Serialize}; +use std::{ + env, + fs::{self, File}, + io::{self, Write}, + path::{Path, PathBuf}, + time::{Duration, SystemTime, UNIX_EPOCH}, +}; + +#[cfg(any(target_os = "macos", target_os = "windows"))] +use std::process::Command; +use url::Url; + +const GITHUB_API_URL: &str = "https://api.github.com/repos/isaaclins/psst/releases/latest"; +const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION"); +const UPDATE_CHECK_INTERVAL: Duration = Duration::from_secs(24 * 60 * 60); // 24 hours + +#[derive(Clone, Debug, Data, Serialize, Deserialize, PartialEq)] +pub struct UpdateInfo { + pub version: String, + pub release_url: String, + pub release_notes: String, + pub download_urls: DownloadUrls, +} + +#[derive(Clone, Debug, Data, Serialize, Deserialize, PartialEq)] +pub struct DownloadUrls { + pub windows: String, + pub macos: String, + pub linux_x86_64: String, + pub linux_aarch64: String, + pub deb_amd64: String, + pub deb_arm64: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +struct GitHubRelease { + tag_name: String, + html_url: String, + body: String, + assets: Vec, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +struct GitHubAsset { + name: String, + browser_download_url: String, +} + +#[derive(Clone, Debug, Data, PartialEq, Eq)] +pub enum UpdateInstallPhase { + Starting, + Downloading, + Installing, + Success, + Error, +} + +#[derive(Clone, Data)] +pub struct UpdateInstallEvent { + pub phase: UpdateInstallPhase, + pub message: String, +} + +impl UpdateInstallEvent { + pub fn new(phase: UpdateInstallPhase, message: impl Into) -> Self { + Self { + phase, + message: message.into(), + } + } +} + +pub struct UpdateInstaller; + +impl UpdateInfo { + /// Check if there's a new version available by querying GitHub API + pub fn check_for_updates() -> Result, String> { + log::info!("Checking for updates from GitHub..."); + + let mut response = ureq::get(GITHUB_API_URL) + .call() + .map_err(|e| format!("Failed to fetch release info: {}", e))?; + + let body = response + .body_mut() + .read_to_string() + .map_err(|e| format!("Failed to read response: {}", e))?; + + let release: GitHubRelease = serde_json::from_str(&body) + .map_err(|e| format!("Failed to parse release info: {}", e))?; + + log::info!("Latest version from GitHub: {}", release.tag_name); + log::info!("Current version: {}", CURRENT_VERSION); + + // Check if the release version is different from current + if Self::is_newer_version(&release.tag_name, CURRENT_VERSION) { + let download_urls = Self::extract_download_urls(&release.assets); + Ok(Some(UpdateInfo { + version: release.tag_name, + release_url: release.html_url, + release_notes: release.body, + download_urls, + })) + } else { + log::info!("No updates available"); + Ok(None) + } + } + + /// Compare version strings to determine if remote version is newer + fn is_newer_version(remote: &str, current: &str) -> bool { + // Remove 'v' prefix if present + let remote = remote.trim_start_matches('v'); + let current = current.trim_start_matches('v'); + + // For date-based versions like "2025.11.15-abc1234" + // Just do a string comparison since they're chronologically ordered + remote > current + } + + /// Extract download URLs from GitHub release assets + fn extract_download_urls(assets: &[GitHubAsset]) -> DownloadUrls { + let mut urls = DownloadUrls { + windows: String::new(), + macos: String::new(), + linux_x86_64: String::new(), + linux_aarch64: String::new(), + deb_amd64: String::new(), + deb_arm64: String::new(), + }; + + for asset in assets { + match asset.name.as_str() { + "Psst.exe" => urls.windows = asset.browser_download_url.clone(), + "Psst.dmg" => urls.macos = asset.browser_download_url.clone(), + "psst-linux-x86_64" => urls.linux_x86_64 = asset.browser_download_url.clone(), + "psst-linux-aarch64" => urls.linux_aarch64 = asset.browser_download_url.clone(), + "psst-amd64.deb" => urls.deb_amd64 = asset.browser_download_url.clone(), + "psst-arm64.deb" => urls.deb_arm64 = asset.browser_download_url.clone(), + _ => {} + } + } + + urls + } + + /// Get the appropriate download URL for the current platform + pub fn get_platform_download_url(&self) -> Option<&str> { + #[cfg(target_os = "windows")] + { + return self.get_download_url_for_platform(UpdatePlatform::Windows); + } + + #[cfg(target_os = "macos")] + { + return self.get_download_url_for_platform(UpdatePlatform::Macos); + } + + #[cfg(all(target_os = "linux", target_arch = "x86_64"))] + { + return self.get_download_url_for_platform(UpdatePlatform::LinuxX86_64); + } + + #[cfg(all(target_os = "linux", target_arch = "aarch64"))] + { + return self.get_download_url_for_platform(UpdatePlatform::LinuxAarch64); + } + + #[allow(unreachable_code)] + { + None + } + } + + pub fn get_download_url_for_platform(&self, platform: UpdatePlatform) -> Option<&str> { + match platform { + #[cfg(any(test, target_os = "windows"))] + UpdatePlatform::Windows => empty_to_none(&self.download_urls.windows), + #[cfg(any(test, target_os = "macos"))] + UpdatePlatform::Macos => empty_to_none(&self.download_urls.macos), + #[cfg(any(test, all(target_os = "linux", target_arch = "x86_64")))] + UpdatePlatform::LinuxX86_64 => empty_to_none(&self.download_urls.linux_x86_64), + #[cfg(any(test, all(target_os = "linux", target_arch = "aarch64")))] + UpdatePlatform::LinuxAarch64 => empty_to_none(&self.download_urls.linux_aarch64), + #[cfg(test)] + UpdatePlatform::DebAmd64 => empty_to_none(&self.download_urls.deb_amd64), + #[cfg(test)] + UpdatePlatform::DebArm64 => empty_to_none(&self.download_urls.deb_arm64), + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum UpdatePlatform { + #[cfg(any(test, target_os = "windows"))] + Windows, + #[cfg(any(test, target_os = "macos"))] + Macos, + #[cfg(any(test, all(target_os = "linux", target_arch = "x86_64")))] + LinuxX86_64, + #[cfg(any(test, all(target_os = "linux", target_arch = "aarch64")))] + LinuxAarch64, + #[cfg(test)] + DebAmd64, + #[cfg(test)] + DebArm64, +} + +fn empty_to_none(value: &str) -> Option<&str> { + if value.is_empty() { + None + } else { + Some(value) + } +} + +impl UpdateInstaller { + pub fn download_and_install(info: &UpdateInfo, mut notify: F) -> Result<(), String> + where + F: FnMut(UpdateInstallPhase, &str), + { + notify(UpdateInstallPhase::Downloading, "Downloading update..."); + let download_path = Self::download_update_payload(info)?; + + let installing_message = format!("Installing update {}...", info.version); + notify(UpdateInstallPhase::Installing, &installing_message); + + let install_result = Self::install_downloaded_payload(info, &download_path); + let _ = fs::remove_file(&download_path); + + install_result + } + + fn download_update_payload(info: &UpdateInfo) -> Result { + let url = info + .get_platform_download_url() + .ok_or_else(|| "No download available for this platform".to_string())?; + + let parsed_url = Url::parse(url).map_err(|e| format!("Invalid download URL: {}", e))?; + + let file_name = parsed_url + .path_segments() + .and_then(|segments| segments.rev().find(|segment| !segment.is_empty())) + .unwrap_or("psst-update.bin"); + + let timestamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + let temp_file_name = format!("psst-update-{}-{}", timestamp, file_name); + let temp_path = env::temp_dir().join(temp_file_name); + + log::info!( + "Downloading update {} from {} to {}", + info.version, + url, + temp_path.display() + ); + + let response = ureq::get(url) + .call() + .map_err(|e| format!("Failed to download update: {}", e))?; + + let mut reader = response.into_body().into_reader(); + let mut file = File::create(&temp_path) + .map_err(|e| format!("Failed to create temporary file: {}", e))?; + + io::copy(&mut reader, &mut file) + .map_err(|e| format!("Failed to write update payload: {}", e))?; + file.flush() + .map_err(|e| format!("Failed to flush update payload: {}", e))?; + + Ok(temp_path) + } + + fn install_downloaded_payload(info: &UpdateInfo, path: &Path) -> Result<(), String> { + Self::install_platform_payload(info, path) + } + + #[cfg(target_os = "macos")] + fn install_platform_payload(_info: &UpdateInfo, path: &Path) -> Result<(), String> { + let timestamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + let mount_dir = env::temp_dir().join(format!("psst-update-mount-{}", timestamp)); + fs::create_dir_all(&mount_dir) + .map_err(|e| format!("Failed to create mount point: {}", e))?; + + let attach_status = Command::new("hdiutil") + .arg("attach") + .arg(path) + .arg("-nobrowse") + .arg("-mountpoint") + .arg(&mount_dir) + .status() + .map_err(|e| format!("Failed to mount update image: {}", e))?; + + if !attach_status.success() { + return Err(format!( + "Failed to mount update image (exit code {:?})", + attach_status.code() + )); + } + + struct MountGuard { + mount_point: PathBuf, + } + + impl Drop for MountGuard { + fn drop(&mut self) { + if let Err(err) = Command::new("hdiutil") + .arg("detach") + .arg(&self.mount_point) + .arg("-quiet") + .status() + { + log::warn!("Failed to detach update image: {}", err); + } + if let Err(err) = fs::remove_dir_all(&self.mount_point) { + log::warn!("Failed to remove temporary mount point: {}", err); + } + } + } + + let mount_guard = MountGuard { + mount_point: mount_dir.clone(), + }; + + let app_bundle = mount_dir.join("Psst.app"); + if !app_bundle.exists() { + return Err("Mounted image does not contain Psst.app".into()); + } + + let applications_dir = Path::new("/Applications/Psst.app"); + if applications_dir.exists() { + fs::remove_dir_all(applications_dir) + .map_err(|e| format!("Failed to remove existing installation: {}", e))?; + } + + let copy_status = Command::new("cp") + .arg("-R") + .arg(&app_bundle) + .arg("/Applications/") + .status() + .map_err(|e| format!("Failed to copy new application bundle: {}", e))?; + + if !copy_status.success() { + return Err(format!( + "Failed to copy new application bundle (exit code {:?})", + copy_status.code() + )); + } + + if let Err(err) = Command::new("xattr") + .arg("-dr") + .arg("com.apple.quarantine") + .arg("/Applications/Psst.app/") + .status() + { + log::warn!("Failed to remove quarantine flag: {}", err); + } + + if let Err(err) = Command::new("xattr") + .arg("-l") + .arg("/Applications/Psst.app/") + .status() + { + log::warn!("Failed to list xattr for Psst.app: {}", err); + } + + drop(mount_guard); + Ok(()) + } + + #[cfg(target_os = "linux")] + fn install_platform_payload(_info: &UpdateInfo, path: &Path) -> Result<(), String> { + use std::os::unix::fs::PermissionsExt; + + let current_exe = env::current_exe() + .map_err(|e| format!("Failed to determine current executable: {}", e))?; + let target_dir = current_exe + .parent() + .ok_or_else(|| "Failed to determine installation directory".to_string())?; + + let file_name = current_exe + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("psst"); + + let staging = target_dir.join(format!("{}.update", file_name)); + + if staging.exists() { + fs::remove_file(&staging) + .map_err(|e| format!("Failed to remove stale staging file: {}", e))?; + } + + fs::copy(path, &staging).map_err(|e| format!("Failed to stage updated binary: {}", e))?; + + fs::set_permissions(&staging, fs::Permissions::from_mode(0o755)) + .map_err(|e| format!("Failed to set permissions on staged binary: {}", e))?; + + fs::rename(&staging, ¤t_exe) + .map_err(|e| format!("Failed to replace current binary: {}", e))?; + + Ok(()) + } + + #[cfg(target_os = "windows")] + fn install_platform_payload(_info: &UpdateInfo, path: &Path) -> Result<(), String> { + let current_exe = env::current_exe() + .map_err(|e| format!("Failed to determine current executable: {}", e))?; + let target_dir = current_exe + .parent() + .ok_or_else(|| "Failed to determine installation directory".to_string())?; + + let staged_path = target_dir.join("Psst.update.exe"); + + if staged_path.exists() { + fs::remove_file(&staged_path) + .map_err(|e| format!("Failed to remove stale staged update: {}", e))?; + } + + fs::copy(path, &staged_path) + .map_err(|e| format!("Failed to stage updated executable: {}", e))?; + + let pid = std::process::id(); + let staged = staged_path + .to_str() + .ok_or_else(|| "Staged path contains invalid unicode".to_string())? + .replace('"', "\""); + let target = current_exe + .to_str() + .ok_or_else(|| "Executable path contains invalid unicode".to_string())? + .replace('"', "\""); + + let script = format!( + "$ErrorActionPreference='Stop'; Wait-Process -Id {pid}; Copy-Item -Path \"{staged}\" -Destination \"{target}\" -Force; Remove-Item -Path \"{staged}\" -Force", + pid = pid, + staged = staged, + target = target, + ); + + Command::new("powershell") + .args(["-NoProfile", "-WindowStyle", "Hidden", "-Command", &script]) + .spawn() + .map_err(|e| format!("Failed to schedule update replacement: {}", e))?; + + Ok(()) + } + + #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))] + fn install_platform_payload(_info: &UpdateInfo, _path: &Path) -> Result<(), String> { + Err("Automatic installation is not supported on this platform".into()) + } +} + +#[derive(Clone, Debug, Data, Lens, Serialize, Deserialize)] +pub struct UpdatePreferences { + /// Whether to check for updates on startup + pub check_on_startup: bool, + /// Timestamp of the last update check (seconds since UNIX epoch) + pub last_check_timestamp: u64, + /// Version that the user has dismissed (won't show notification again for this version) + pub dismissed_version: Option, +} + +impl Default for UpdatePreferences { + fn default() -> Self { + Self { + check_on_startup: true, + last_check_timestamp: 0, + dismissed_version: None, + } + } +} + +impl UpdatePreferences { + /// Check if enough time has passed since the last update check + pub fn should_check_for_updates(&self) -> bool { + if !self.check_on_startup { + return false; + } + + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or(Duration::from_secs(0)) + .as_secs(); + + let time_since_last_check = now.saturating_sub(self.last_check_timestamp); + + time_since_last_check >= UPDATE_CHECK_INTERVAL.as_secs() + } + + /// Update the timestamp to now + pub fn mark_checked(&mut self) { + self.last_check_timestamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or(Duration::from_secs(0)) + .as_secs(); + } + + /// Check if a version has been dismissed + pub fn is_version_dismissed(&self, version: &str) -> bool { + self.dismissed_version + .as_ref() + .map(|v| v == version) + .unwrap_or(false) + } + + /// Dismiss a specific version + pub fn dismiss_version(&mut self, version: String) { + self.dismissed_version = Some(version); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_version_comparison() { + assert!(UpdateInfo::is_newer_version("2025.11.16", "2025.11.15")); + assert!(UpdateInfo::is_newer_version("2025.12.01", "2025.11.30")); + assert!(!UpdateInfo::is_newer_version("2025.11.15", "2025.11.15")); + assert!(!UpdateInfo::is_newer_version("2025.11.14", "2025.11.15")); + } + + #[test] + fn test_version_with_prefix() { + assert!(UpdateInfo::is_newer_version("v2025.11.16", "0.1.0")); + assert!(UpdateInfo::is_newer_version("2025.11.16", "v0.1.0")); + } + + #[test] + fn test_should_check_for_updates() { + let mut prefs = UpdatePreferences::default(); + + // Should check on first run + assert!(prefs.should_check_for_updates()); + + // Mark as checked + prefs.mark_checked(); + + // Should not check immediately after + assert!(!prefs.should_check_for_updates()); + + // Simulate 25 hours passed + prefs.last_check_timestamp -= 25 * 60 * 60; + assert!(prefs.should_check_for_updates()); + } + + #[test] + fn test_dismiss_version() { + let mut prefs = UpdatePreferences::default(); + + assert!(!prefs.is_version_dismissed("2025.11.15")); + + prefs.dismiss_version("2025.11.15".to_string()); + + assert!(prefs.is_version_dismissed("2025.11.15")); + assert!(!prefs.is_version_dismissed("2025.11.16")); + } + + #[test] + fn test_extract_download_urls() { + let assets = vec![ + GitHubAsset { + name: "Psst.exe".to_string(), + browser_download_url: "https://example.com/Psst.exe".to_string(), + }, + GitHubAsset { + name: "Psst.dmg".to_string(), + browser_download_url: "https://example.com/Psst.dmg".to_string(), + }, + ]; + + let urls = UpdateInfo::extract_download_urls(&assets); + + assert_eq!(urls.windows, "https://example.com/Psst.exe"); + assert_eq!(urls.macos, "https://example.com/Psst.dmg"); + assert!(urls.linux_x86_64.is_empty()); + } + + fn sample_update_info() -> UpdateInfo { + UpdateInfo { + version: "2025.11.17".into(), + release_url: "https://example.com/release".into(), + release_notes: String::new(), + download_urls: DownloadUrls { + windows: "https://example.com/Psst.exe".into(), + macos: "https://example.com/Psst.dmg".into(), + linux_x86_64: "https://example.com/psst-linux-x86_64".into(), + linux_aarch64: "https://example.com/psst-linux-aarch64".into(), + deb_amd64: "https://example.com/psst-amd64.deb".into(), + deb_arm64: "https://example.com/psst-arm64.deb".into(), + }, + } + } + + #[test] + fn test_platform_url_lookup() { + let info = sample_update_info(); + + assert_eq!( + info.get_download_url_for_platform(UpdatePlatform::Windows), + Some("https://example.com/Psst.exe") + ); + assert_eq!( + info.get_download_url_for_platform(UpdatePlatform::Macos), + Some("https://example.com/Psst.dmg") + ); + assert_eq!( + info.get_download_url_for_platform(UpdatePlatform::LinuxX86_64), + Some("https://example.com/psst-linux-x86_64") + ); + assert_eq!( + info.get_download_url_for_platform(UpdatePlatform::LinuxAarch64), + Some("https://example.com/psst-linux-aarch64") + ); + assert_eq!( + info.get_download_url_for_platform(UpdatePlatform::DebAmd64), + Some("https://example.com/psst-amd64.deb") + ); + assert_eq!( + info.get_download_url_for_platform(UpdatePlatform::DebArm64), + Some("https://example.com/psst-arm64.deb") + ); + } + + #[test] + fn test_install_requires_platform_url() { + let mut info = sample_update_info(); + info.download_urls.macos.clear(); + info.download_urls.windows.clear(); + info.download_urls.linux_x86_64.clear(); + info.download_urls.linux_aarch64.clear(); + info.download_urls.deb_amd64.clear(); + info.download_urls.deb_arm64.clear(); + + let mut notifications = Vec::new(); + let result = UpdateInstaller::download_and_install(&info, |phase, message| { + notifications.push((phase, message.to_string())) + }); + + assert!(result.is_err()); + assert_eq!( + notifications, + vec![( + UpdateInstallPhase::Downloading, + "Downloading update...".to_string() + )] + ); + } +} diff --git a/psst-gui/src/delegate.rs b/psst-gui/src/delegate.rs index 3a802b3f..e4793c36 100644 --- a/psst-gui/src/delegate.rs +++ b/psst-gui/src/delegate.rs @@ -1,29 +1,34 @@ -use directories::UserDirs; use druid::{ commands, AppDelegate, Application, Command, DelegateCtx, Env, Event, Handled, Target, WindowDesc, WindowId, }; -use std::fs; use threadpool::ThreadPool; use crate::ui::playlist::{ RENAME_PLAYLIST, RENAME_PLAYLIST_CONFIRM, UNFOLLOW_PLAYLIST, UNFOLLOW_PLAYLIST_CONFIRM, }; use crate::ui::theme; -use crate::ui::DOWNLOAD_ARTWORK; use crate::{ cmd, - data::{AppState, Config}, + data::{AppState, Config, UpdateInfo, UpdateInstallEvent, UpdateInstallPhase, UpdateInstaller}, token_utils::TokenUtils, ui, webapi::WebApi, widget::remote_image, }; +use druid::Selector; + +const UPDATE_CHECK_RESULT: Selector> = Selector::new("app.update-check-result"); +const UPDATE_INSTALL_STATUS_CMD: Selector = cmd::UPDATE_INSTALL_STATUS; enum OpenDialogKind { ThemeImport, } +enum SaveDialogKind { + ThemeExport, +} + pub struct Delegate { main_window: Option, preferences_window: Option, @@ -32,6 +37,7 @@ pub struct Delegate { image_pool: ThreadPool, size_updated: bool, pending_open_dialog: Option, + pending_save_dialog: Option, } impl Delegate { @@ -46,6 +52,7 @@ impl Delegate { image_pool: ThreadPool::with_name("image_loading".into(), MAX_IMAGE_THREADS), size_updated: false, pending_open_dialog: None, + pending_save_dialog: None, } } @@ -172,6 +179,9 @@ impl AppDelegate for Delegate { } else if cmd.is(cmd::BEGIN_THEME_IMPORT) { self.pending_open_dialog = Some(OpenDialogKind::ThemeImport); Handled::Yes + } else if cmd.is(cmd::BEGIN_THEME_EXPORT) { + self.pending_save_dialog = Some(SaveDialogKind::ThemeExport); + Handled::Yes } else if cmd.is(commands::CLOSE_WINDOW) { if let Some(window_id) = self.preferences_window { if target == Target::Window(window_id) { @@ -207,28 +217,6 @@ impl AppDelegate for Delegate { } else if cmd.is(crate::cmd::SHOW_ARTWORK) { self.show_artwork(ctx); Handled::Yes - } else if let Some((url, title)) = cmd.get(DOWNLOAD_ARTWORK) { - let safe_title = title.replace(['/', '\\', ':', '*', '?', '"', '<', '>', '|'], "_"); - let file_name = format!("{safe_title} cover.jpg"); - - if let Some(user_dirs) = UserDirs::new() { - if let Some(download_dir) = user_dirs.download_dir() { - let path = download_dir.join(file_name); - - match ureq::get(url) - .call() - .and_then(|response| -> Result<(), ureq::Error> { - let mut file = fs::File::create(&path)?; - let mut reader = response.into_body().into_reader(); - std::io::copy(&mut reader, &mut file)?; - Ok(()) - }) { - Ok(_) => data.info_alert("Cover saved to Downloads folder."), - Err(_) => data.error_alert("Failed to download and save artwork"), - } - } - } - Handled::Yes } else if let Some((access, refresh)) = cmd.get(cmd::OAUTH_TOKENS_REFRESHED) { TokenUtils::apply_refresh_result( &data.session, @@ -238,14 +226,6 @@ impl AppDelegate for Delegate { true, ); Handled::Yes - } else if let Some(file_info) = cmd.get(commands::SAVE_FILE_AS) { - // Handle theme export - if let Err(e) = data.config.custom_theme.export_to_file(file_info.path()) { - data.error_alert(format!("Failed to export theme: {}", e)); - } else { - data.info_alert("Theme exported successfully"); - } - Handled::Yes } else if let Some(file_info) = cmd.get(commands::OPEN_FILE) { let context = self .pending_open_dialog @@ -268,6 +248,145 @@ impl AppDelegate for Delegate { } } Handled::Yes + } else if let Some(file_info) = cmd.get(commands::SAVE_FILE_AS) { + let context = self + .pending_save_dialog + .take() + .unwrap_or(SaveDialogKind::ThemeExport); + + match context { + SaveDialogKind::ThemeExport => { + match data.config.custom_theme.export_to_file(file_info.path()) { + Ok(()) => { + data.info_alert(format!( + "Theme exported to {}", + file_info.path().display() + )); + } + Err(e) => { + data.error_alert(format!("Failed to export theme: {}", e)); + } + } + } + } + + Handled::Yes + } else if cmd.is(cmd::CHECK_FOR_UPDATES) { + // Handle update checking in background thread + let event_sink = ctx.get_external_handle(); + std::thread::spawn(move || match crate::data::UpdateInfo::check_for_updates() { + Ok(update_info) => { + event_sink + .submit_command(UPDATE_CHECK_RESULT, update_info, Target::Global) + .ok(); + } + Err(e) => { + log::error!("Failed to check for updates: {}", e); + event_sink + .submit_command(UPDATE_CHECK_RESULT, None, Target::Global) + .ok(); + } + }); + Handled::Yes + } else if let Some(info) = cmd.get(cmd::INSTALL_UPDATE) { + data.preferences.installing_update = true; + data.preferences.update_install_status = + Some(format!("Preparing to install {}...", info.version)); + + let event_sink = ctx.get_external_handle(); + let info_clone = info.clone(); + + std::thread::spawn(move || { + event_sink + .submit_command( + UPDATE_INSTALL_STATUS_CMD, + UpdateInstallEvent::new(UpdateInstallPhase::Starting, "Starting update..."), + Target::Global, + ) + .ok(); + + let install_result = + UpdateInstaller::download_and_install(&info_clone, |phase, message| { + event_sink + .submit_command( + UPDATE_INSTALL_STATUS_CMD, + UpdateInstallEvent::new(phase, message), + Target::Global, + ) + .ok(); + }); + + match install_result { + Ok(()) => { + let success_message = format!( + "Update {} installed. Restart Psst to finish.", + info_clone.version + ); + event_sink + .submit_command( + UPDATE_INSTALL_STATUS_CMD, + UpdateInstallEvent::new( + UpdateInstallPhase::Success, + success_message, + ), + Target::Global, + ) + .ok(); + } + Err(err) => { + let error_message = format!("Failed to install update: {}", err); + event_sink + .submit_command( + UPDATE_INSTALL_STATUS_CMD, + UpdateInstallEvent::new(UpdateInstallPhase::Error, error_message), + Target::Global, + ) + .ok(); + } + } + }); + + Handled::Yes + } else if let Some(event) = cmd.get(UPDATE_INSTALL_STATUS_CMD) { + match event.phase { + UpdateInstallPhase::Starting + | UpdateInstallPhase::Downloading + | UpdateInstallPhase::Installing => { + data.preferences.update_install_status = Some(event.message.clone()); + data.preferences.installing_update = true; + } + UpdateInstallPhase::Success => { + data.preferences.update_install_status = Some(event.message.clone()); + data.preferences.installing_update = false; + data.preferences.available_update = None; + data.info_alert( + "Update installed. Please restart Psst to finish installation.", + ); + } + UpdateInstallPhase::Error => { + data.preferences.update_install_status = Some(event.message.clone()); + data.preferences.installing_update = false; + data.error_alert(event.message.clone()); + } + } + Handled::Yes + } else if let Some(update_info) = cmd.get(UPDATE_CHECK_RESULT) { + data.preferences.checking_update = false; + // Only show update if it hasn't been dismissed + if let Some(ref info) = update_info { + if !data + .config + .update_preferences + .is_version_dismissed(&info.version) + { + data.preferences.available_update = update_info.clone(); + } + } else { + data.preferences.available_update = None; + } + // Mark as checked + data.config.update_preferences.mark_checked(); + Handled::Yes } else { Handled::No } diff --git a/psst-gui/src/main.rs b/psst-gui/src/main.rs index d2fc5cce..938197e4 100644 --- a/psst-gui/src/main.rs +++ b/psst-gui/src/main.rs @@ -102,6 +102,15 @@ fn main() { WebApi::global().set_event_sink(launcher.get_external_handle()); + // Check for updates on startup if enabled + if state.config.update_preferences.should_check_for_updates() { + log::info!("Checking for updates on startup"); + launcher + .get_external_handle() + .submit_command(cmd::CHECK_FOR_UPDATES, (), druid::Target::Global) + .ok(); + } + launcher .delegate(delegate) .launch(state) diff --git a/psst-gui/src/ui/preferences.rs b/psst-gui/src/ui/preferences.rs index cb29df65..4bdde0a7 100644 --- a/psst-gui/src/ui/preferences.rs +++ b/psst-gui/src/ui/preferences.rs @@ -8,15 +8,15 @@ use crate::{ cmd, data::{ AppState, AudioQuality, Authentication, Config, CustomTheme, Preferences, PreferencesTab, - Promise, SliderScrollScale, Theme, + Promise, SliderScrollScale, Theme, UpdatePreferences, }, widget::{icons, Async, Border, Checkbox, MyWidgetExt}, }; use druid::{ text::ParseFormatter, widget::{ - Button, Controller, CrossAxisAlignment, Flex, Label, LineBreaking, MainAxisAlignment, - Painter, RadioGroup, Scroll, SizedBox, Slider, TextBox, ViewSwitcher, + Button, Controller, CrossAxisAlignment, Either, Flex, Label, LineBreaking, + MainAxisAlignment, Painter, RadioGroup, Scroll, SizedBox, Slider, TextBox, ViewSwitcher, }, Color, Data, Env, Event, EventCtx, Insets, Lens, LensExt, LifeCycle, LifeCycleCtx, RenderContext, Selector, Target, Widget, WidgetExt, @@ -97,8 +97,9 @@ pub fn preferences_widget() -> impl Widget { PreferencesTab::Account => { account_tab_widget(AccountTab::InPreferences).boxed() } - PreferencesTab::Privacy => privacy_tab_widget().boxed(), + PreferencesTab::DiscordPresence => discord_presence_tab_widget().boxed(), PreferencesTab::Cache => cache_tab_widget().boxed(), + PreferencesTab::Updates => updates_tab_widget().boxed(), PreferencesTab::About => about_tab_widget().boxed(), }, ) @@ -167,9 +168,9 @@ fn tabs_widget() -> impl Widget { )) .with_default_spacer() .with_child(tab_link_widget( - "Privacy", + "Discord Rich Presence", &icons::PREFERENCES, - PreferencesTab::Privacy, + PreferencesTab::DiscordPresence, )) .with_default_spacer() .with_child(tab_link_widget( @@ -178,6 +179,12 @@ fn tabs_widget() -> impl Widget { PreferencesTab::Cache, )) .with_default_spacer() + .with_child(tab_link_widget( + "Updates", + &icons::CIRCLE_PLUS, + PreferencesTab::Updates, + )) + .with_default_spacer() .with_child(tab_link_widget( "About", &icons::HEART, @@ -492,6 +499,8 @@ where fn export_theme(ctx: &mut EventCtx, _data: &AppState) { use druid::FileDialogOptions; + ctx.submit_command(cmd::BEGIN_THEME_EXPORT); + let options = FileDialogOptions::new() .default_name("custom-theme.json") .allowed_types(vec![druid::FileSpec::new("JSON Theme File", &["json"])]); @@ -1157,14 +1166,14 @@ impl> Controller for Authenticate { } } -fn privacy_tab_widget() -> impl Widget { +fn discord_presence_tab_widget() -> impl Widget { let mut col = Flex::column() .cross_axis_alignment(CrossAxisAlignment::Start) .must_fill_main_axis(true); // Discord Rich Presence section col = col - .with_child(Label::new("Social Presence").with_font(theme::UI_FONT_MEDIUM)) + .with_child(Label::new("Discord Rich Presence").with_font(theme::UI_FONT_MEDIUM)) .with_spacer(theme::grid(2.0)) .with_child( Label::new("Control what information is shared when you're listening to music.") @@ -1202,7 +1211,7 @@ fn privacy_tab_widget() -> impl Widget { col = col.with_spacer(theme::grid(3.0)); - // Privacy controls section + // Presence controls section col = col .with_child(Label::new("Presence Information").with_font(theme::UI_FONT_MEDIUM)) .with_spacer(theme::grid(2.0)) @@ -1481,6 +1490,120 @@ fn equalizer_band_slider(band_index: usize) -> impl Widget { .padding((0.0, theme::grid(0.3), 0.0, 0.0)) } +fn updates_tab_widget() -> impl Widget { + Flex::column() + .cross_axis_alignment(CrossAxisAlignment::Start) + .must_fill_main_axis(true) + .with_child(Label::new("Updates").with_font(theme::UI_FONT_MEDIUM)) + .with_spacer(theme::grid(2.0)) + .with_child(Label::new(format!( + "Current Version: {}", + env!("CARGO_PKG_VERSION") + ))) + .with_spacer(theme::grid(1.0)) + .with_child( + Checkbox::new("Check for updates on startup").lens( + AppState::config + .then(Config::update_preferences.then(UpdatePreferences::check_on_startup)), + ), + ) + .with_spacer(theme::grid(2.0)) + .with_child( + Button::new("Check for Updates") + .on_click(|ctx, data: &mut AppState, _| { + data.preferences.checking_update = true; + ctx.submit_command(cmd::CHECK_FOR_UPDATES); + }) + .disabled_if(|data: &AppState, _| data.preferences.checking_update), + ) + .with_spacer(theme::grid(2.0)) + .with_child(ViewSwitcher::new( + |state: &AppState, _| { + ( + state.preferences.checking_update, + state.preferences.available_update.clone(), + ) + }, + |(checking, update_info), _, _| { + if *checking { + Label::new("Checking for updates...").boxed() + } else if let Some(info) = update_info { + Flex::column() + .cross_axis_alignment(CrossAxisAlignment::Start) + .with_child( + Label::new(format!("New version available: {}", info.version)) + .with_text_color(Color::rgb8(138, 180, 248)) + .with_font(theme::UI_FONT_MEDIUM), + ) + .with_spacer(theme::grid(1.0)) + .with_child(Label::new("Release Notes:").with_font(theme::UI_FONT_MEDIUM)) + .with_spacer(theme::grid(0.5)) + .with_child( + Label::new(info.release_notes.clone()) + .with_line_break_mode(LineBreaking::WordWrap) + .with_text_color(theme::PLACEHOLDER_COLOR), + ) + .with_spacer(theme::grid(1.5)) + .with_child( + Flex::row() + .with_child({ + let update_payload = info.clone(); + Button::new("Install Update") + .on_click(move |ctx, _data: &mut AppState, _| { + ctx.submit_command( + cmd::INSTALL_UPDATE.with(update_payload.clone()), + ); + }) + .disabled_if(|data: &AppState, _| { + data.preferences.installing_update + }) + }) + .with_spacer(theme::grid(1.0)) + .with_child({ + let release_url = info.release_url.clone(); + Button::new("Open Release Page").on_click(move |_, _, _| { + open::that(&release_url).ok(); + }) + }), + ) + .with_spacer(theme::grid(1.0)) + .with_child( + Button::new("Dismiss") + .on_click(|_, data: &mut AppState, _| { + if let Some(ref info) = data.preferences.available_update { + data.config + .update_preferences + .dismiss_version(info.version.clone()); + data.preferences.available_update = None; + } + }) + .disabled_if(|data: &AppState, _| { + data.preferences.installing_update + }), + ) + .boxed() + } else { + Label::new("Your application is up to date.") + .with_text_color(theme::PLACEHOLDER_COLOR) + .boxed() + } + }, + )) + .with_spacer(theme::grid(1.0)) + .with_child(Either::new( + |data: &AppState, _| data.preferences.update_install_status.is_some(), + Label::dynamic(|data: &AppState, _| { + data.preferences + .update_install_status + .clone() + .unwrap_or_default() + }) + .with_line_break_mode(LineBreaking::WordWrap) + .with_text_color(Color::rgb8(138, 180, 248)), + SizedBox::empty(), + )) +} + fn about_tab_widget() -> impl Widget { // Build Info let commit_hash = Flex::row()