feat(updates): add update checks and what's new modal
cx
This commit is contained in:
1 parent
f84c27b5ee
commit
54740ee225
11 files changed
+1790
-2
No files matched your search
@@ -30,6 +30,7 @@ pub mod radio_engine;
|
|||||||
pub mod session_store;
|
pub mod session_store;
|
||||||
pub mod share;
|
pub mod share;
|
||||||
pub mod tray;
|
pub mod tray;
|
||||||
|
pub mod updates;
|
||||||
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tauri::{Emitter, Manager};
|
use tauri::{Emitter, Manager};
|
||||||
@@ -191,6 +192,9 @@ pub fn run() {
|
|||||||
// Initialize tray settings state
|
// Initialize tray settings state
|
||||||
let tray_settings_state = config::tray_settings::TraySettingsState::new()
|
let tray_settings_state = config::tray_settings::TraySettingsState::new()
|
||||||
.expect("Failed to initialize tray settings");
|
.expect("Failed to initialize tray settings");
|
||||||
|
// Initialize updates state
|
||||||
|
let updates_state = updates::UpdatesState::new()
|
||||||
|
.expect("Failed to initialize updates state");
|
||||||
// Initialize subscription validity tracking (for offline download compliance)
|
// Initialize subscription validity tracking (for offline download compliance)
|
||||||
let subscription_state = config::create_subscription_state()
|
let subscription_state = config::create_subscription_state()
|
||||||
.expect("Failed to initialize subscription state");
|
.expect("Failed to initialize subscription state");
|
||||||
@@ -430,6 +434,7 @@ pub fn run() {
|
|||||||
.manage(playback_prefs_state)
|
.manage(playback_prefs_state)
|
||||||
.manage(favorites_prefs_state)
|
.manage(favorites_prefs_state)
|
||||||
.manage(tray_settings_state)
|
.manage(tray_settings_state)
|
||||||
|
.manage(updates_state)
|
||||||
.manage(musicbrainz_state)
|
.manage(musicbrainz_state)
|
||||||
.manage(listenbrainz_state)
|
.manage(listenbrainz_state)
|
||||||
.manage(remote_metadata_state)
|
.manage(remote_metadata_state)
|
||||||
@@ -756,6 +761,19 @@ pub fn run() {
|
|||||||
config::playback_preferences::set_show_context_icon,
|
config::playback_preferences::set_show_context_icon,
|
||||||
config::favorites_preferences::get_favorites_preferences,
|
config::favorites_preferences::get_favorites_preferences,
|
||||||
config::favorites_preferences::save_favorites_preferences,
|
config::favorites_preferences::save_favorites_preferences,
|
||||||
|
// Updates commands
|
||||||
|
updates::get_update_preferences,
|
||||||
|
updates::set_update_check_on_launch,
|
||||||
|
updates::set_show_whats_new_on_launch,
|
||||||
|
updates::acknowledge_release,
|
||||||
|
updates::ignore_release,
|
||||||
|
updates::is_release_acknowledged,
|
||||||
|
updates::is_release_ignored,
|
||||||
|
updates::has_whats_new_been_shown,
|
||||||
|
updates::mark_whats_new_shown,
|
||||||
|
updates::get_current_version,
|
||||||
|
updates::check_for_updates,
|
||||||
|
updates::fetch_release_for_version,
|
||||||
offline::commands::set_allow_immediate_scrobbling,
|
offline::commands::set_allow_immediate_scrobbling,
|
||||||
offline::commands::set_allow_accumulated_scrobbling,
|
offline::commands::set_allow_accumulated_scrobbling,
|
||||||
offline::commands::set_show_network_folders_in_manual_offline,
|
offline::commands::set_show_network_folders_in_manual_offline,
|
||||||
|
|||||||
@@ -0,0 +1,519 @@
|
|||||||
|
//! Update checks and "What's New" persistence
|
||||||
|
//!
|
||||||
|
//! Requirements:
|
||||||
|
//! - Durable persistence via SQLite (no JSON files)
|
||||||
|
//! - Network failures must fail silently
|
||||||
|
//! - Update checks must never block startup
|
||||||
|
|
||||||
|
use chrono::{DateTime, Duration as ChronoDuration, Utc};
|
||||||
|
use log::{info, warn};
|
||||||
|
use reqwest::Client;
|
||||||
|
use rusqlite::{params, Connection};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
const GITHUB_RELEASES_URL: &str = "https://api.github.com/repos/vicrodh/qbz/releases";
|
||||||
|
const UPDATE_MIN_AGE_HOURS: i64 = 12;
|
||||||
|
const NETWORK_TIMEOUT_SECONDS: u64 = 4;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct UpdatePreferences {
|
||||||
|
pub check_on_launch: bool,
|
||||||
|
pub show_whats_new_on_launch: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for UpdatePreferences {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
check_on_launch: true,
|
||||||
|
show_whats_new_on_launch: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
struct GithubRelease {
|
||||||
|
tag_name: String,
|
||||||
|
name: String,
|
||||||
|
published_at: String,
|
||||||
|
body: Option<String>,
|
||||||
|
html_url: String,
|
||||||
|
draft: bool,
|
||||||
|
prerelease: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ReleaseInfo {
|
||||||
|
pub version: String,
|
||||||
|
pub tag_name: String,
|
||||||
|
pub name: String,
|
||||||
|
pub published_at: String,
|
||||||
|
pub published_at_epoch: i64,
|
||||||
|
pub body: Option<String>,
|
||||||
|
pub html_url: String,
|
||||||
|
pub is_old_enough: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum UpdateCheckStatus {
|
||||||
|
NoUpdates,
|
||||||
|
UpdateAvailable,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct UpdateCheckResult {
|
||||||
|
pub status: UpdateCheckStatus,
|
||||||
|
pub current_version: String,
|
||||||
|
pub release: Option<ReleaseInfo>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UpdateCheckResult {
|
||||||
|
fn no_updates(current_version: String) -> Self {
|
||||||
|
Self {
|
||||||
|
status: UpdateCheckStatus::NoUpdates,
|
||||||
|
current_version,
|
||||||
|
release: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// SQLite-backed persistence for updates
|
||||||
|
pub struct UpdatesStore {
|
||||||
|
conn: Connection,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UpdatesStore {
|
||||||
|
pub fn new() -> Result<Self, String> {
|
||||||
|
let data_dir = dirs::data_dir()
|
||||||
|
.ok_or("Could not determine data directory")?
|
||||||
|
.join("qbz");
|
||||||
|
|
||||||
|
std::fs::create_dir_all(&data_dir)
|
||||||
|
.map_err(|e| format!("Failed to create data directory: {}", e))?;
|
||||||
|
|
||||||
|
let db_path = data_dir.join("updates.db");
|
||||||
|
let conn =
|
||||||
|
Connection::open(&db_path).map_err(|e| format!("Failed to open updates database: {}", e))?;
|
||||||
|
|
||||||
|
conn.execute_batch(
|
||||||
|
"CREATE TABLE IF NOT EXISTS update_preferences (
|
||||||
|
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||||
|
check_on_launch INTEGER NOT NULL DEFAULT 1,
|
||||||
|
show_whats_new_on_launch INTEGER NOT NULL DEFAULT 1
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT OR IGNORE INTO update_preferences (id, check_on_launch, show_whats_new_on_launch)
|
||||||
|
VALUES (1, 1, 1);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS acknowledged_releases (
|
||||||
|
version TEXT PRIMARY KEY,
|
||||||
|
acknowledged_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS ignored_releases (
|
||||||
|
version TEXT PRIMARY KEY,
|
||||||
|
ignored_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS whats_new_shown (
|
||||||
|
version TEXT PRIMARY KEY,
|
||||||
|
shown_at INTEGER NOT NULL
|
||||||
|
);",
|
||||||
|
)
|
||||||
|
.map_err(|e| format!("Failed to create updates tables: {}", e))?;
|
||||||
|
|
||||||
|
// Migration guard: ensure both preference columns exist even on partial schemas
|
||||||
|
let pref_migrations = [
|
||||||
|
"ALTER TABLE update_preferences ADD COLUMN check_on_launch INTEGER NOT NULL DEFAULT 1",
|
||||||
|
"ALTER TABLE update_preferences ADD COLUMN show_whats_new_on_launch INTEGER NOT NULL DEFAULT 1",
|
||||||
|
];
|
||||||
|
for migration in pref_migrations {
|
||||||
|
let _ = conn.execute(migration, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Self { conn })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_preferences(&self) -> Result<UpdatePreferences, String> {
|
||||||
|
self.conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT check_on_launch, show_whats_new_on_launch FROM update_preferences WHERE id = 1",
|
||||||
|
[],
|
||||||
|
|row| {
|
||||||
|
Ok(UpdatePreferences {
|
||||||
|
check_on_launch: row.get::<_, i64>(0)? != 0,
|
||||||
|
show_whats_new_on_launch: row.get::<_, i64>(1)? != 0,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.map_err(|e| format!("Failed to get update preferences: {}", e))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_check_on_launch(&self, enabled: bool) -> Result<(), String> {
|
||||||
|
self.conn
|
||||||
|
.execute(
|
||||||
|
"UPDATE update_preferences SET check_on_launch = ?1 WHERE id = 1",
|
||||||
|
params![enabled as i64],
|
||||||
|
)
|
||||||
|
.map_err(|e| format!("Failed to set check_on_launch: {}", e))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_show_whats_new_on_launch(&self, enabled: bool) -> Result<(), String> {
|
||||||
|
self.conn
|
||||||
|
.execute(
|
||||||
|
"UPDATE update_preferences SET show_whats_new_on_launch = ?1 WHERE id = 1",
|
||||||
|
params![enabled as i64],
|
||||||
|
)
|
||||||
|
.map_err(|e| format!("Failed to set show_whats_new_on_launch: {}", e))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn acknowledge_release(&self, version: &str) -> Result<(), String> {
|
||||||
|
self.conn
|
||||||
|
.execute(
|
||||||
|
"INSERT OR REPLACE INTO acknowledged_releases (version, acknowledged_at) VALUES (?1, ?2)",
|
||||||
|
params![version, now_epoch_seconds()],
|
||||||
|
)
|
||||||
|
.map_err(|e| format!("Failed to acknowledge release: {}", e))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn ignore_release(&self, version: &str) -> Result<(), String> {
|
||||||
|
self.conn
|
||||||
|
.execute(
|
||||||
|
"INSERT OR REPLACE INTO ignored_releases (version, ignored_at) VALUES (?1, ?2)",
|
||||||
|
params![version, now_epoch_seconds()],
|
||||||
|
)
|
||||||
|
.map_err(|e| format!("Failed to ignore release: {}", e))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_release_acknowledged(&self, version: &str) -> bool {
|
||||||
|
self.conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT 1 FROM acknowledged_releases WHERE version = ?1 LIMIT 1",
|
||||||
|
params![version],
|
||||||
|
|_row| Ok(()),
|
||||||
|
)
|
||||||
|
.is_ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_release_ignored(&self, version: &str) -> bool {
|
||||||
|
self.conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT 1 FROM ignored_releases WHERE version = ?1 LIMIT 1",
|
||||||
|
params![version],
|
||||||
|
|_row| Ok(()),
|
||||||
|
)
|
||||||
|
.is_ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn has_whats_new_been_shown(&self, version: &str) -> bool {
|
||||||
|
self.conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT 1 FROM whats_new_shown WHERE version = ?1 LIMIT 1",
|
||||||
|
params![version],
|
||||||
|
|_row| Ok(()),
|
||||||
|
)
|
||||||
|
.is_ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn mark_whats_new_shown(&self, version: &str) -> Result<(), String> {
|
||||||
|
self.conn
|
||||||
|
.execute(
|
||||||
|
"INSERT OR REPLACE INTO whats_new_shown (version, shown_at) VALUES (?1, ?2)",
|
||||||
|
params![version, now_epoch_seconds()],
|
||||||
|
)
|
||||||
|
.map_err(|e| format!("Failed to mark whats_new_shown: {}", e))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Thread-safe state wrapper
|
||||||
|
pub struct UpdatesState {
|
||||||
|
store: Arc<Mutex<UpdatesStore>>,
|
||||||
|
client: Client,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UpdatesState {
|
||||||
|
pub fn new() -> Result<Self, String> {
|
||||||
|
let store = UpdatesStore::new()?;
|
||||||
|
let client = Client::builder()
|
||||||
|
.timeout(Duration::from_secs(NETWORK_TIMEOUT_SECONDS))
|
||||||
|
.user_agent("qbz-nix-updates")
|
||||||
|
.build()
|
||||||
|
.map_err(|e| format!("Failed to create updates HTTP client: {}", e))?;
|
||||||
|
Ok(Self {
|
||||||
|
store: Arc::new(Mutex::new(store)),
|
||||||
|
client,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn with_store<T>(&self, f: impl FnOnce(&UpdatesStore) -> T) -> T {
|
||||||
|
let guard = self.store.lock().expect("UpdatesStore mutex poisoned");
|
||||||
|
f(&guard)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn current_version(&self) -> String {
|
||||||
|
env!("CARGO_PKG_VERSION").to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn now_epoch_seconds() -> i64 {
|
||||||
|
Utc::now().timestamp()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_version_tag(tag: &str) -> String {
|
||||||
|
tag.trim().trim_start_matches('v').to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_version_parts(version: &str) -> Vec<u64> {
|
||||||
|
normalize_version_tag(version)
|
||||||
|
.split('.')
|
||||||
|
.map(|segment| {
|
||||||
|
let numeric: String = segment.chars().take_while(|c| c.is_ascii_digit()).collect();
|
||||||
|
numeric.parse::<u64>().unwrap_or(0)
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_newer_version(current: &str, candidate: &str) -> bool {
|
||||||
|
let current_parts = parse_version_parts(current);
|
||||||
|
let candidate_parts = parse_version_parts(candidate);
|
||||||
|
let max_len = current_parts.len().max(candidate_parts.len());
|
||||||
|
for idx in 0..max_len {
|
||||||
|
let cur = *current_parts.get(idx).unwrap_or(&0);
|
||||||
|
let cand = *candidate_parts.get(idx).unwrap_or(&0);
|
||||||
|
if cand > cur {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if cand < cur {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_published_at_epoch(published_at: &str) -> Option<i64> {
|
||||||
|
let parsed: DateTime<Utc> = DateTime::parse_from_rfc3339(published_at)
|
||||||
|
.ok()?
|
||||||
|
.with_timezone(&Utc);
|
||||||
|
Some(parsed.timestamp())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_old_enough(epoch_seconds: i64) -> bool {
|
||||||
|
let published = DateTime::<Utc>::from_timestamp(epoch_seconds, 0);
|
||||||
|
let Some(published) = published else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let age = Utc::now() - published;
|
||||||
|
age >= ChronoDuration::hours(UPDATE_MIN_AGE_HOURS)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn to_release_info(release: GithubRelease) -> Option<ReleaseInfo> {
|
||||||
|
let epoch = parse_published_at_epoch(&release.published_at)?;
|
||||||
|
let version = normalize_version_tag(&release.tag_name);
|
||||||
|
let old_enough = is_old_enough(epoch);
|
||||||
|
Some(ReleaseInfo {
|
||||||
|
version,
|
||||||
|
tag_name: release.tag_name,
|
||||||
|
name: release.name,
|
||||||
|
published_at: release.published_at,
|
||||||
|
published_at_epoch: epoch,
|
||||||
|
body: release.body,
|
||||||
|
html_url: release.html_url,
|
||||||
|
is_old_enough: old_enough,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn fetch_releases(client: &Client) -> Result<Vec<GithubRelease>, String> {
|
||||||
|
let response = client
|
||||||
|
.get(GITHUB_RELEASES_URL)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to fetch releases: {}", e))?;
|
||||||
|
if !response.status().is_success() {
|
||||||
|
return Err(format!("Release fetch returned HTTP {}", response.status()));
|
||||||
|
}
|
||||||
|
response
|
||||||
|
.json::<Vec<GithubRelease>>()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to parse releases JSON: {}", e))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn fetch_release_by_tag(client: &Client, tag: &str) -> Result<GithubRelease, String> {
|
||||||
|
let url = format!("{}/tags/{}", GITHUB_RELEASES_URL, tag);
|
||||||
|
let response = client
|
||||||
|
.get(url)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to fetch release by tag: {}", e))?;
|
||||||
|
if !response.status().is_success() {
|
||||||
|
return Err(format!(
|
||||||
|
"Release-by-tag fetch returned HTTP {}",
|
||||||
|
response.status()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
response
|
||||||
|
.json::<GithubRelease>()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to parse release-by-tag JSON: {}", e))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn select_latest_valid_update(
|
||||||
|
current_version: &str,
|
||||||
|
releases: Vec<GithubRelease>,
|
||||||
|
state: &UpdatesState,
|
||||||
|
) -> Option<ReleaseInfo> {
|
||||||
|
for release in releases {
|
||||||
|
if release.draft || release.prerelease {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let Some(info) = to_release_info(release) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if !info.is_old_enough {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if !is_newer_version(current_version, &info.version) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let is_blocked = state.with_store(|store| {
|
||||||
|
store.is_release_acknowledged(&info.version) || store.is_release_ignored(&info.version)
|
||||||
|
});
|
||||||
|
if is_blocked {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
return Some(info);
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Tauri commands ====================
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn get_update_preferences(state: tauri::State<UpdatesState>) -> Result<UpdatePreferences, String> {
|
||||||
|
state.with_store(|store| store.get_preferences())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn set_update_check_on_launch(
|
||||||
|
enabled: bool,
|
||||||
|
state: tauri::State<UpdatesState>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
state.with_store(|store| store.set_check_on_launch(enabled))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn set_show_whats_new_on_launch(
|
||||||
|
enabled: bool,
|
||||||
|
state: tauri::State<UpdatesState>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
state.with_store(|store| store.set_show_whats_new_on_launch(enabled))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn acknowledge_release(version: String, state: tauri::State<UpdatesState>) -> Result<(), String> {
|
||||||
|
state.with_store(|store| store.acknowledge_release(&version))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn ignore_release(version: String, state: tauri::State<UpdatesState>) -> Result<(), String> {
|
||||||
|
state.with_store(|store| store.ignore_release(&version))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn is_release_acknowledged(version: String, state: tauri::State<UpdatesState>) -> bool {
|
||||||
|
state.with_store(|store| store.is_release_acknowledged(&version))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn is_release_ignored(version: String, state: tauri::State<UpdatesState>) -> bool {
|
||||||
|
state.with_store(|store| store.is_release_ignored(&version))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn has_whats_new_been_shown(version: String, state: tauri::State<UpdatesState>) -> bool {
|
||||||
|
state.with_store(|store| store.has_whats_new_been_shown(&version))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn mark_whats_new_shown(version: String, state: tauri::State<UpdatesState>) -> Result<(), String> {
|
||||||
|
state.with_store(|store| store.mark_whats_new_shown(&version))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn get_current_version(state: tauri::State<UpdatesState>) -> String {
|
||||||
|
state.current_version()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn check_for_updates(
|
||||||
|
mode: String,
|
||||||
|
state: tauri::State<'_, UpdatesState>,
|
||||||
|
) -> Result<UpdateCheckResult, String> {
|
||||||
|
let current_version = state.current_version();
|
||||||
|
|
||||||
|
if mode == "launch" {
|
||||||
|
let prefs = match state.with_store(|store| store.get_preferences()) {
|
||||||
|
Ok(p) => p,
|
||||||
|
Err(e) => {
|
||||||
|
warn!("[Updates] Failed to read preferences: {}", e);
|
||||||
|
return Ok(UpdateCheckResult::no_updates(current_version));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if !prefs.check_on_launch {
|
||||||
|
info!("[Updates] Skipping launch check (disabled in preferences)");
|
||||||
|
return Ok(UpdateCheckResult::no_updates(current_version));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let releases = match fetch_releases(&state.client).await {
|
||||||
|
Ok(r) => r,
|
||||||
|
Err(e) => {
|
||||||
|
// Silent failure: log and return "no updates"
|
||||||
|
warn!("[Updates] Release fetch failed: {}", e);
|
||||||
|
return Ok(UpdateCheckResult::no_updates(current_version));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let maybe_update = select_latest_valid_update(¤t_version, releases, &state);
|
||||||
|
match maybe_update {
|
||||||
|
Some(release) => Ok(UpdateCheckResult {
|
||||||
|
status: UpdateCheckStatus::UpdateAvailable,
|
||||||
|
current_version,
|
||||||
|
release: Some(release),
|
||||||
|
}),
|
||||||
|
None => Ok(UpdateCheckResult::no_updates(current_version)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn fetch_release_for_version(
|
||||||
|
version: String,
|
||||||
|
state: tauri::State<'_, UpdatesState>,
|
||||||
|
) -> Result<Option<ReleaseInfo>, String> {
|
||||||
|
// Prefer exact tag lookup to avoid scanning all releases for the current version.
|
||||||
|
let tag = if version.starts_with('v') {
|
||||||
|
version.clone()
|
||||||
|
} else {
|
||||||
|
format!("v{}", version)
|
||||||
|
};
|
||||||
|
let release = match fetch_release_by_tag(&state.client, &tag).await {
|
||||||
|
Ok(r) => r,
|
||||||
|
Err(e) => {
|
||||||
|
warn!("[Updates] Release-by-tag fetch failed for {}: {}", tag, e);
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if release.draft || release.prerelease {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
Ok(to_release_info(release))
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Modal from '../Modal.svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
isOpen: boolean;
|
||||||
|
currentVersion: string;
|
||||||
|
newVersion: string;
|
||||||
|
onClose: () => void;
|
||||||
|
onVisitReleasePage: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { isOpen, currentVersion, newVersion, onClose, onVisitReleasePage }: Props = $props();
|
||||||
|
|
||||||
|
function handleVisit(): void {
|
||||||
|
onVisitReleasePage();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Modal {isOpen} onClose={onClose} title="New release available" maxWidth="560px">
|
||||||
|
<div class="update-modal">
|
||||||
|
<p class="lead">A new version of QBZ has been released</p>
|
||||||
|
|
||||||
|
<div class="version-row" aria-label="Version change">
|
||||||
|
<span class="version-chip">v{currentVersion}</span>
|
||||||
|
<span class="arrow">→</span>
|
||||||
|
<span class="version-chip new">v{newVersion}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="download-btn" onclick={handleVisit} type="button">
|
||||||
|
Download on GitHub
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#snippet footer()}
|
||||||
|
<div class="footer-actions">
|
||||||
|
<button class="btn btn-ghost" type="button" onclick={onClose}>Close</button>
|
||||||
|
<button class="btn btn-primary" type="button" onclick={handleVisit}>Visit release page</button>
|
||||||
|
</div>
|
||||||
|
{/snippet}
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.update-modal {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
text-align: center;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lead {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border: 1px solid var(--border-color, var(--bg-tertiary));
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-chip.new {
|
||||||
|
background: color-mix(in srgb, var(--accent-primary) 16%, var(--bg-tertiary));
|
||||||
|
border-color: color-mix(in srgb, var(--accent-primary) 40%, var(--bg-tertiary));
|
||||||
|
}
|
||||||
|
|
||||||
|
.arrow {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.download-btn {
|
||||||
|
margin-top: 8px;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--accent-primary);
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.download-btn:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
width: 100%;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Modal from '../Modal.svelte';
|
||||||
|
import type { UpdateCheckStatus } from '$lib/stores/updatesStore';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
isOpen: boolean;
|
||||||
|
status: UpdateCheckStatus;
|
||||||
|
newVersion: string;
|
||||||
|
onClose: () => void;
|
||||||
|
onVisitReleasePage: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { isOpen, status, newVersion, onClose, onVisitReleasePage }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Modal {isOpen} onClose={onClose} title="Check for updates" maxWidth="460px">
|
||||||
|
<div class="result-body">
|
||||||
|
{#if status === 'update_available'}
|
||||||
|
<p class="message">New version available: v{newVersion}</p>
|
||||||
|
{:else}
|
||||||
|
<p class="message">No updates found.</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#snippet footer()}
|
||||||
|
<div class="footer-actions">
|
||||||
|
<button class="btn btn-ghost" type="button" onclick={onClose}>Close</button>
|
||||||
|
{#if status === 'update_available'}
|
||||||
|
<button class="btn btn-primary" type="button" onclick={onVisitReleasePage}>Visit release page</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/snippet}
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.result-body {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
text-align: center;
|
||||||
|
padding: 12px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-actions {
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Modal from '../Modal.svelte';
|
||||||
|
|
||||||
|
type ReminderChoice = 'later' | 'ignore_release' | 'disable_all';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onRemindLater: () => void;
|
||||||
|
onIgnoreRelease: () => void;
|
||||||
|
onDisableAllUpdates: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { isOpen, onClose, onRemindLater, onIgnoreRelease, onDisableAllUpdates }: Props = $props();
|
||||||
|
|
||||||
|
let choice = $state<ReminderChoice>('later');
|
||||||
|
|
||||||
|
function handleSubmit(): void {
|
||||||
|
if (choice === 'ignore_release') {
|
||||||
|
onIgnoreRelease();
|
||||||
|
} else if (choice === 'disable_all') {
|
||||||
|
onDisableAllUpdates();
|
||||||
|
} else {
|
||||||
|
onRemindLater();
|
||||||
|
}
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Modal {isOpen} onClose={onClose} title="New version reminder" maxWidth="600px">
|
||||||
|
<div class="reminder-modal">
|
||||||
|
<label class="option">
|
||||||
|
<input type="radio" name="reminder" bind:group={choice} value="later" />
|
||||||
|
<span>Remind me later</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="option">
|
||||||
|
<input type="radio" name="reminder" bind:group={choice} value="ignore_release" />
|
||||||
|
<span>Do not notify me again about this release</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="option">
|
||||||
|
<input type="radio" name="reminder" bind:group={choice} value="disable_all" />
|
||||||
|
<span>Do not notify me about new releases</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<p class="hint">Can be re-enabled from Settings</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#snippet footer()}
|
||||||
|
<div class="footer-actions">
|
||||||
|
<button class="btn btn-primary" type="button" onclick={handleSubmit}>OK, close</button>
|
||||||
|
</div>
|
||||||
|
{/snippet}
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.reminder-modal {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.option {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.option input[type='radio'] {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
accent-color: var(--accent-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
margin: 4px 0 0;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-actions {
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onDestroy, onMount } from 'svelte';
|
||||||
|
import Modal from '../Modal.svelte';
|
||||||
|
import type { ReleaseInfo } from '$lib/stores/updatesStore';
|
||||||
|
import {
|
||||||
|
formatReleaseDate,
|
||||||
|
renderMarkdownWithToc,
|
||||||
|
} from '$lib/utils/markdown';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
isOpen: boolean;
|
||||||
|
release: ReleaseInfo | null;
|
||||||
|
showTitleBar: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { isOpen, release, showTitleBar, onClose }: Props = $props();
|
||||||
|
|
||||||
|
const PLAYER_HEIGHT = 104;
|
||||||
|
const TITLEBAR_HEIGHT = $derived(showTitleBar ? 32 : 0);
|
||||||
|
const INNER_MARGIN = 20;
|
||||||
|
const MAX_MODAL_OFFSET_PX = 145;
|
||||||
|
|
||||||
|
let maxHeightPx = $state(720);
|
||||||
|
|
||||||
|
function computeMaxHeight(): void {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
// Per requirement: modal max height = window height - 145px.
|
||||||
|
const available = window.innerHeight - MAX_MODAL_OFFSET_PX;
|
||||||
|
maxHeightPx = Math.max(360, available);
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
computeMaxHeight();
|
||||||
|
const handler = () => computeMaxHeight();
|
||||||
|
window.addEventListener('resize', handler);
|
||||||
|
return () => window.removeEventListener('resize', handler);
|
||||||
|
});
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
// No-op, but keeps lifecycle explicit in this component.
|
||||||
|
});
|
||||||
|
|
||||||
|
const releaseDate = $derived(release ? formatReleaseDate(release.publishedAt) : '');
|
||||||
|
const markdownResult = $derived.by(() => renderMarkdownWithToc(release?.body));
|
||||||
|
const releaseHtml = $derived(markdownResult.html);
|
||||||
|
const modalTitle = $derived(
|
||||||
|
release
|
||||||
|
? `What's new in v${release.version}, ${releaseDate}`
|
||||||
|
: "What's new"
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="whats-new-modal"
|
||||||
|
style={`
|
||||||
|
--updates-modal-max-height: ${maxHeightPx}px;
|
||||||
|
--updates-overlay-pad-top: ${TITLEBAR_HEIGHT + INNER_MARGIN}px;
|
||||||
|
--updates-overlay-pad-bottom: ${PLAYER_HEIGHT + INNER_MARGIN}px;
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<Modal
|
||||||
|
{isOpen}
|
||||||
|
onClose={onClose}
|
||||||
|
title={modalTitle}
|
||||||
|
maxWidth="860px"
|
||||||
|
>
|
||||||
|
{#if release}
|
||||||
|
{#if releaseHtml}
|
||||||
|
<div class="release-body prose">
|
||||||
|
{@html releaseHtml}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<p class="empty">Release notes are not available for this version.</p>
|
||||||
|
{/if}
|
||||||
|
{:else}
|
||||||
|
<p class="empty">Release notes are not available.</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#snippet footer()}
|
||||||
|
<div class="footer-actions">
|
||||||
|
<button class="btn btn-primary" type="button" onclick={onClose}>Close</button>
|
||||||
|
</div>
|
||||||
|
{/snippet}
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
:global(.whats-new-modal .modal) {
|
||||||
|
max-height: var(--updates-modal-max-height);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.whats-new-modal .modal-overlay) {
|
||||||
|
padding-top: var(--updates-overlay-pad-top);
|
||||||
|
padding-bottom: var(--updates-overlay-pad-bottom);
|
||||||
|
}
|
||||||
|
|
||||||
|
.release-body {
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Minimal prose styling for safe markdown output */
|
||||||
|
:global(.whats-new-modal .prose p) {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.whats-new-modal .prose ul) {
|
||||||
|
margin: 6px 0 14px 10px;
|
||||||
|
padding: 0 0 0 44px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
list-style-position: outside;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.whats-new-modal .prose li) {
|
||||||
|
line-height: 1.6;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.whats-new-modal .prose li)::marker {
|
||||||
|
color: color-mix(in srgb, var(--text-muted) 85%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.whats-new-modal .prose h3),
|
||||||
|
:global(.whats-new-modal .prose .wn-section) {
|
||||||
|
margin: 16px 0 8px;
|
||||||
|
font-weight: 800;
|
||||||
|
font-size: 18px;
|
||||||
|
letter-spacing: 0.2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.whats-new-modal .prose code) {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border: 1px solid var(--bg-hover);
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-actions {
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -7,6 +7,8 @@
|
|||||||
import Toggle from '../Toggle.svelte';
|
import Toggle from '../Toggle.svelte';
|
||||||
import Dropdown from '../Dropdown.svelte';
|
import Dropdown from '../Dropdown.svelte';
|
||||||
import VolumeSlider from '../VolumeSlider.svelte';
|
import VolumeSlider from '../VolumeSlider.svelte';
|
||||||
|
import UpdateCheckResultModal from '../updates/UpdateCheckResultModal.svelte';
|
||||||
|
import WhatsNewModal from '../updates/WhatsNewModal.svelte';
|
||||||
import {
|
import {
|
||||||
getOfflineCacheStats,
|
getOfflineCacheStats,
|
||||||
clearOfflineCache,
|
clearOfflineCache,
|
||||||
@@ -56,6 +58,20 @@
|
|||||||
setShowContextIcon,
|
setShowContextIcon,
|
||||||
type AutoplayMode
|
type AutoplayMode
|
||||||
} from '$lib/stores/playbackPreferencesStore';
|
} from '$lib/stores/playbackPreferencesStore';
|
||||||
|
import {
|
||||||
|
subscribe as subscribeUpdates,
|
||||||
|
checkForUpdates,
|
||||||
|
fetchReleaseForVersion,
|
||||||
|
getCurrentVersion as getUpdatesCurrentVersion,
|
||||||
|
getPreferences as getUpdatePreferences,
|
||||||
|
initUpdatesStore,
|
||||||
|
setCheckOnLaunch,
|
||||||
|
setShowWhatsNewOnLaunch,
|
||||||
|
type ReleaseInfo,
|
||||||
|
type UpdateCheckStatus,
|
||||||
|
type UpdatePreferences
|
||||||
|
} from '$lib/stores/updatesStore';
|
||||||
|
import { openReleasePageAndAcknowledge } from '$lib/services/updatesService';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onBack?: () => void;
|
onBack?: () => void;
|
||||||
@@ -64,6 +80,7 @@
|
|||||||
userEmail?: string;
|
userEmail?: string;
|
||||||
subscription?: string;
|
subscription?: string;
|
||||||
subscriptionValidUntil?: string | null;
|
subscriptionValidUntil?: string | null;
|
||||||
|
showTitleBar?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CacheStats {
|
interface CacheStats {
|
||||||
@@ -92,7 +109,8 @@
|
|||||||
userName = 'User',
|
userName = 'User',
|
||||||
userEmail = '',
|
userEmail = '',
|
||||||
subscription = 'Qobuz™',
|
subscription = 'Qobuz™',
|
||||||
subscriptionValidUntil = null
|
subscriptionValidUntil = null,
|
||||||
|
showTitleBar = true
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
// Cache state (memory cache)
|
// Cache state (memory cache)
|
||||||
@@ -123,6 +141,16 @@
|
|||||||
let flatpakHelpText = $state('');
|
let flatpakHelpText = $state('');
|
||||||
let isCheckingNetwork = $state(false);
|
let isCheckingNetwork = $state(false);
|
||||||
|
|
||||||
|
// Updates state
|
||||||
|
let updatePreferences = $state<UpdatePreferences>(getUpdatePreferences());
|
||||||
|
let updatesCurrentVersion = $state(getUpdatesCurrentVersion());
|
||||||
|
let isCheckingUpdates = $state(false);
|
||||||
|
let isUpdateResultOpen = $state(false);
|
||||||
|
let updateResultStatus = $state<UpdateCheckStatus>('no_updates');
|
||||||
|
let updateResultRelease = $state<ReleaseInfo | null>(null);
|
||||||
|
let isSettingsWhatsNewOpen = $state(false);
|
||||||
|
let settingsWhatsNewRelease = $state<ReleaseInfo | null>(null);
|
||||||
|
|
||||||
// Section navigation
|
// Section navigation
|
||||||
let settingsViewEl: HTMLDivElement;
|
let settingsViewEl: HTMLDivElement;
|
||||||
let audioSection: HTMLElement;
|
let audioSection: HTMLElement;
|
||||||
@@ -132,6 +160,7 @@
|
|||||||
let downloadsSection: HTMLElement;
|
let downloadsSection: HTMLElement;
|
||||||
let librarySection: HTMLElement;
|
let librarySection: HTMLElement;
|
||||||
let integrationsSection: HTMLElement;
|
let integrationsSection: HTMLElement;
|
||||||
|
let updatesSection: HTMLElement;
|
||||||
let storageSection: HTMLElement;
|
let storageSection: HTMLElement;
|
||||||
let activeSection = $state('audio');
|
let activeSection = $state('audio');
|
||||||
|
|
||||||
@@ -144,6 +173,7 @@
|
|||||||
{ id: 'downloads', label: 'Offline Library' },
|
{ id: 'downloads', label: 'Offline Library' },
|
||||||
{ id: 'library', label: 'Library' },
|
{ id: 'library', label: 'Library' },
|
||||||
{ id: 'integrations', label: 'Integrations' },
|
{ id: 'integrations', label: 'Integrations' },
|
||||||
|
{ id: 'updates', label: 'Updates' },
|
||||||
{ id: 'storage', label: 'Storage' },
|
{ id: 'storage', label: 'Storage' },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -157,6 +187,7 @@
|
|||||||
case 'downloads': return downloadsSection;
|
case 'downloads': return downloadsSection;
|
||||||
case 'library': return librarySection;
|
case 'library': return librarySection;
|
||||||
case 'integrations': return integrationsSection;
|
case 'integrations': return integrationsSection;
|
||||||
|
case 'updates': return updatesSection;
|
||||||
case 'storage': return storageSection;
|
case 'storage': return storageSection;
|
||||||
default: return undefined;
|
default: return undefined;
|
||||||
}
|
}
|
||||||
@@ -169,6 +200,51 @@
|
|||||||
el.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
el.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleUpdateCheckOnLaunchToggle(enabled: boolean): Promise<void> {
|
||||||
|
await setCheckOnLaunch(enabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleShowWhatsNewToggle(enabled: boolean): Promise<void> {
|
||||||
|
await setShowWhatsNewOnLaunch(enabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleManualUpdateCheck(): Promise<void> {
|
||||||
|
if (isCheckingUpdates) return;
|
||||||
|
isCheckingUpdates = true;
|
||||||
|
try {
|
||||||
|
const result = await checkForUpdates('manual');
|
||||||
|
updateResultStatus = result.status;
|
||||||
|
updateResultRelease = result.release;
|
||||||
|
isUpdateResultOpen = true;
|
||||||
|
} finally {
|
||||||
|
isCheckingUpdates = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCloseUpdateResult(): void {
|
||||||
|
isUpdateResultOpen = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleVisitReleaseFromResult(): void {
|
||||||
|
if (!updateResultRelease) return;
|
||||||
|
void openReleasePageAndAcknowledge(updateResultRelease);
|
||||||
|
isUpdateResultOpen = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleShowCurrentChangelog(): Promise<void> {
|
||||||
|
const version = updatesCurrentVersion || getUpdatesCurrentVersion();
|
||||||
|
if (!version) return;
|
||||||
|
const release = await fetchReleaseForVersion(version);
|
||||||
|
if (!release) return;
|
||||||
|
settingsWhatsNewRelease = release;
|
||||||
|
isSettingsWhatsNewOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCloseSettingsWhatsNew(): void {
|
||||||
|
isSettingsWhatsNewOpen = false;
|
||||||
|
settingsWhatsNewRelease = null;
|
||||||
|
}
|
||||||
|
|
||||||
// Audio device state - use PipeWire sinks directly for friendly names
|
// Audio device state - use PipeWire sinks directly for friendly names
|
||||||
let pipewireSinks = $state<PipewireSink[]>([]);
|
let pipewireSinks = $state<PipewireSink[]>([]);
|
||||||
let hardwareStatus = $state<HardwareAudioStatus | null>(null);
|
let hardwareStatus = $state<HardwareAudioStatus | null>(null);
|
||||||
@@ -499,6 +575,13 @@
|
|||||||
// Load tray settings
|
// Load tray settings
|
||||||
loadTraySettings();
|
loadTraySettings();
|
||||||
|
|
||||||
|
// Initialize updates preferences/version state
|
||||||
|
initUpdatesStore();
|
||||||
|
const unsubscribeUpdates = subscribeUpdates(() => {
|
||||||
|
updatePreferences = getUpdatePreferences();
|
||||||
|
updatesCurrentVersion = getUpdatesCurrentVersion();
|
||||||
|
});
|
||||||
|
|
||||||
// Detect if running in Flatpak
|
// Detect if running in Flatpak
|
||||||
loadFlatpakStatus();
|
loadFlatpakStatus();
|
||||||
|
|
||||||
@@ -544,6 +627,7 @@
|
|||||||
unsubscribeOffline();
|
unsubscribeOffline();
|
||||||
unsubscribeZoom();
|
unsubscribeZoom();
|
||||||
unsubscribeTitleBar();
|
unsubscribeTitleBar();
|
||||||
|
unsubscribeUpdates();
|
||||||
settingsViewEl?.removeEventListener('scroll', handleScroll);
|
settingsViewEl?.removeEventListener('scroll', handleScroll);
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -2263,6 +2347,85 @@ flatpak override --user --filesystem=/home/USUARIO/Música com.blitzfc.qbz</pre>
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- Updates Section -->
|
||||||
|
<section class="section" bind:this={updatesSection}>
|
||||||
|
<h3 class="section-title">Updates</h3>
|
||||||
|
|
||||||
|
<div class="setting-row">
|
||||||
|
<div class="setting-info">
|
||||||
|
<span class="setting-label">Check for new releases on launch</span>
|
||||||
|
</div>
|
||||||
|
<Toggle
|
||||||
|
enabled={updatePreferences.checkOnLaunch}
|
||||||
|
onchange={handleUpdateCheckOnLaunchToggle}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="setting-row">
|
||||||
|
<div class="setting-info">
|
||||||
|
<span class="setting-label">Check for updates now</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="connect-btn updates-check-btn"
|
||||||
|
onclick={handleManualUpdateCheck}
|
||||||
|
disabled={isCheckingUpdates}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{#if isCheckingUpdates}
|
||||||
|
<Loader2 size={14} class="spin" />
|
||||||
|
<span>Checking...</span>
|
||||||
|
{:else}
|
||||||
|
<span>Check</span>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="setting-row">
|
||||||
|
<div class="setting-info">
|
||||||
|
<span class="setting-label">Show what's new on new version launch</span>
|
||||||
|
</div>
|
||||||
|
<Toggle
|
||||||
|
enabled={updatePreferences.showWhatsNewOnLaunch}
|
||||||
|
onchange={handleShowWhatsNewToggle}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="setting-row last">
|
||||||
|
<div class="setting-info">
|
||||||
|
<span class="setting-label">Show changelog for current version</span>
|
||||||
|
{#if updatesCurrentVersion}
|
||||||
|
<small class="setting-note">Current version: v{updatesCurrentVersion}</small>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="connect-btn"
|
||||||
|
onclick={handleShowCurrentChangelog}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Show
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{#if isUpdateResultOpen}
|
||||||
|
<UpdateCheckResultModal
|
||||||
|
isOpen={isUpdateResultOpen}
|
||||||
|
status={updateResultStatus}
|
||||||
|
newVersion={updateResultRelease?.version ?? ''}
|
||||||
|
onClose={handleCloseUpdateResult}
|
||||||
|
onVisitReleasePage={handleVisitReleaseFromResult}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if settingsWhatsNewRelease}
|
||||||
|
<WhatsNewModal
|
||||||
|
isOpen={isSettingsWhatsNewOpen}
|
||||||
|
release={settingsWhatsNewRelease}
|
||||||
|
showTitleBar={showTitleBar}
|
||||||
|
onClose={handleCloseSettingsWhatsNew}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!-- Storage Section (Memory Cache) -->
|
<!-- Storage Section (Memory Cache) -->
|
||||||
<section class="section" bind:this={storageSection}>
|
<section class="section" bind:this={storageSection}>
|
||||||
<h3 class="section-title">{$t('settings.storage.title')}</h3>
|
<h3 class="section-title">{$t('settings.storage.title')}</h3>
|
||||||
@@ -2762,6 +2925,23 @@ flatpak override --user --filesystem=$HOME/music-nas com.blitzfc.qbz</pre>
|
|||||||
background-color: var(--accent-hover);
|
background-color: var(--accent-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.updates-check-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: 112px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.updates-check-btn :global(.spin) {
|
||||||
|
animation: updates-spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes updates-spin {
|
||||||
|
from { transform: rotate(0deg); }
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
.connect-btn.connected {
|
.connect-btn.connected {
|
||||||
background: none;
|
background: none;
|
||||||
border: 1px solid var(--text-muted);
|
border: 1px solid var(--text-muted);
|
||||||
|
|||||||
@@ -0,0 +1,237 @@
|
|||||||
|
import { openUrl } from '@tauri-apps/plugin-opener';
|
||||||
|
import {
|
||||||
|
acknowledgeRelease,
|
||||||
|
checkForUpdates,
|
||||||
|
fetchReleaseForVersion,
|
||||||
|
getCurrentVersion,
|
||||||
|
getPreferences,
|
||||||
|
hasWhatsNewBeenShown,
|
||||||
|
ignoreRelease,
|
||||||
|
initUpdatesStore,
|
||||||
|
markWhatsNewShown,
|
||||||
|
setCheckOnLaunch,
|
||||||
|
} from '$lib/stores/updatesStore';
|
||||||
|
import type { ReleaseInfo } from '$lib/stores/updatesStore';
|
||||||
|
|
||||||
|
const SESSION_ID_KEY = 'qbz-updates-session-id';
|
||||||
|
const SESSION_CHECK_DONE_KEY = 'qbz-updates-session-check-done';
|
||||||
|
const SESSION_UPDATE_SHOWN_KEY = 'qbz-updates-session-update-shown';
|
||||||
|
const SESSION_WHATS_NEW_SHOWN_KEY = 'qbz-updates-session-whats-new-shown';
|
||||||
|
|
||||||
|
const DEV_FAKE_ENABLED_KEY = 'qbz-updates-dev-fake-enabled';
|
||||||
|
const DEV_FAKE_VERSION_KEY = 'qbz-updates-dev-fake-version';
|
||||||
|
const DEV_FAKE_URL_KEY = 'qbz-updates-dev-fake-url';
|
||||||
|
const DEV_FAKE_BODY_KEY = 'qbz-updates-dev-fake-body';
|
||||||
|
|
||||||
|
let sessionId = '';
|
||||||
|
let launchFlowStarted = false;
|
||||||
|
|
||||||
|
function ensureSessionId(): string {
|
||||||
|
if (sessionId) return sessionId;
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
sessionId = 'server';
|
||||||
|
return sessionId;
|
||||||
|
}
|
||||||
|
sessionId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||||
|
try {
|
||||||
|
localStorage.setItem(SESSION_ID_KEY, sessionId);
|
||||||
|
} catch {
|
||||||
|
// Ignore storage errors; we will still gate in memory.
|
||||||
|
}
|
||||||
|
return sessionId;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sessionScopedKey(baseKey: string): string {
|
||||||
|
return `${baseKey}:${ensureSessionId()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSessionFlag(baseKey: string): boolean {
|
||||||
|
if (typeof window === 'undefined') return false;
|
||||||
|
try {
|
||||||
|
return localStorage.getItem(sessionScopedKey(baseKey)) === '1';
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setSessionFlag(baseKey: string): void {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
try {
|
||||||
|
localStorage.setItem(sessionScopedKey(baseKey), '1');
|
||||||
|
} catch {
|
||||||
|
// Ignore storage errors.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseVersionParts(version: string): number[] {
|
||||||
|
return version
|
||||||
|
.trim()
|
||||||
|
.replace(/^v/i, '')
|
||||||
|
.split('.')
|
||||||
|
.map((segment) => {
|
||||||
|
const numeric = segment.match(/\d+/)?.[0] ?? '0';
|
||||||
|
return Number.parseInt(numeric, 10) || 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function isNewerVersion(current: string, candidate: string): boolean {
|
||||||
|
const a = parseVersionParts(current);
|
||||||
|
const b = parseVersionParts(candidate);
|
||||||
|
const len = Math.max(a.length, b.length);
|
||||||
|
for (let i = 0; i < len; i += 1) {
|
||||||
|
const av = a[i] ?? 0;
|
||||||
|
const bv = b[i] ?? 0;
|
||||||
|
if (bv > av) return true;
|
||||||
|
if (bv < av) return false;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDevFakeRelease(currentVersion: string): ReleaseInfo | null {
|
||||||
|
if (typeof window === 'undefined') return null;
|
||||||
|
try {
|
||||||
|
const enabled = localStorage.getItem(DEV_FAKE_ENABLED_KEY) === '1';
|
||||||
|
if (!enabled) return null;
|
||||||
|
|
||||||
|
const version = (localStorage.getItem(DEV_FAKE_VERSION_KEY) || '').trim();
|
||||||
|
if (!version) return null;
|
||||||
|
if (!isNewerVersion(currentVersion, version)) return null;
|
||||||
|
|
||||||
|
const htmlUrl =
|
||||||
|
localStorage.getItem(DEV_FAKE_URL_KEY) ||
|
||||||
|
`https://github.com/vicrodh/qbz/releases/tag/v${version}`;
|
||||||
|
const body = localStorage.getItem(DEV_FAKE_BODY_KEY);
|
||||||
|
|
||||||
|
const publishedAtDate = new Date(Date.now() - 13 * 60 * 60 * 1000);
|
||||||
|
const publishedAt = publishedAtDate.toISOString();
|
||||||
|
|
||||||
|
return {
|
||||||
|
version,
|
||||||
|
tagName: version.startsWith('v') ? version : `v${version}`,
|
||||||
|
name: `v${version}`,
|
||||||
|
publishedAt,
|
||||||
|
publishedAtEpoch: Math.floor(publishedAtDate.getTime() / 1000),
|
||||||
|
body,
|
||||||
|
htmlUrl,
|
||||||
|
isOldEnough: true,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LaunchUpdateDecision {
|
||||||
|
currentVersion: string;
|
||||||
|
updateRelease: ReleaseInfo | null;
|
||||||
|
whatsNewRelease: ReleaseInfo | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decide which modal (if any) should be shown on launch.
|
||||||
|
*
|
||||||
|
* Priority:
|
||||||
|
* 1) Update available modal
|
||||||
|
* 2) What's New modal
|
||||||
|
*/
|
||||||
|
export async function decideLaunchModals(): Promise<LaunchUpdateDecision> {
|
||||||
|
if (launchFlowStarted) {
|
||||||
|
return {
|
||||||
|
currentVersion: getCurrentVersion(),
|
||||||
|
updateRelease: null,
|
||||||
|
whatsNewRelease: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
launchFlowStarted = true;
|
||||||
|
|
||||||
|
ensureSessionId();
|
||||||
|
|
||||||
|
await initUpdatesStore();
|
||||||
|
const prefs = getPreferences();
|
||||||
|
const currentVersion = getCurrentVersion();
|
||||||
|
|
||||||
|
if (getSessionFlag(SESSION_CHECK_DONE_KEY)) {
|
||||||
|
return { currentVersion, updateRelease: null, whatsNewRelease: null };
|
||||||
|
}
|
||||||
|
setSessionFlag(SESSION_CHECK_DONE_KEY);
|
||||||
|
|
||||||
|
// Phase 1: update available
|
||||||
|
if (prefs.checkOnLaunch) {
|
||||||
|
const devFake = getDevFakeRelease(currentVersion);
|
||||||
|
if (devFake) {
|
||||||
|
const devScopedKey = `${SESSION_UPDATE_SHOWN_KEY}:${devFake.version}`;
|
||||||
|
if (!getSessionFlag(devScopedKey)) {
|
||||||
|
setSessionFlag(devScopedKey);
|
||||||
|
return {
|
||||||
|
currentVersion,
|
||||||
|
updateRelease: devFake,
|
||||||
|
whatsNewRelease: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await checkForUpdates('launch');
|
||||||
|
if (result.status === 'update_available' && result.release) {
|
||||||
|
const scopedUpdateKey = `${SESSION_UPDATE_SHOWN_KEY}:${result.release.version}`;
|
||||||
|
if (!getSessionFlag(scopedUpdateKey)) {
|
||||||
|
setSessionFlag(scopedUpdateKey);
|
||||||
|
return {
|
||||||
|
currentVersion,
|
||||||
|
updateRelease: result.release,
|
||||||
|
whatsNewRelease: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 2: What's New for current version
|
||||||
|
if (prefs.showWhatsNewOnLaunch && currentVersion) {
|
||||||
|
const alreadyShown = await hasWhatsNewBeenShown(currentVersion);
|
||||||
|
if (!alreadyShown) {
|
||||||
|
const scopedWhatsNewKey = `${SESSION_WHATS_NEW_SHOWN_KEY}:${currentVersion}`;
|
||||||
|
if (!getSessionFlag(scopedWhatsNewKey)) {
|
||||||
|
const release = await fetchReleaseForVersion(currentVersion);
|
||||||
|
if (release) {
|
||||||
|
setSessionFlag(scopedWhatsNewKey);
|
||||||
|
// Persist "shown once per version" at the time we decide to show it.
|
||||||
|
await markWhatsNewShown(currentVersion);
|
||||||
|
return {
|
||||||
|
currentVersion,
|
||||||
|
updateRelease: null,
|
||||||
|
whatsNewRelease: release,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { currentVersion, updateRelease: null, whatsNewRelease: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function openReleasePageAndAcknowledge(release: ReleaseInfo): Promise<void> {
|
||||||
|
try {
|
||||||
|
await openUrl(release.htmlUrl);
|
||||||
|
} catch (error) {
|
||||||
|
console.debug('[Updates] Failed to open release URL:', error);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await acknowledgeRelease(release.version);
|
||||||
|
} catch (error) {
|
||||||
|
console.debug('[Updates] Failed to acknowledge release:', release.version, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ignoreReleaseVersion(version: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
await ignoreRelease(version);
|
||||||
|
} catch (error) {
|
||||||
|
console.debug('[Updates] Failed to ignore release:', version, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function disableUpdateChecks(): Promise<void> {
|
||||||
|
try {
|
||||||
|
await setCheckOnLaunch(false);
|
||||||
|
} catch (error) {
|
||||||
|
console.debug('[Updates] Failed to disable update checks:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
|
|
||||||
|
export interface UpdatePreferences {
|
||||||
|
checkOnLaunch: boolean;
|
||||||
|
showWhatsNewOnLaunch: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReleaseInfo {
|
||||||
|
version: string;
|
||||||
|
tagName: string;
|
||||||
|
name: string;
|
||||||
|
publishedAt: string;
|
||||||
|
publishedAtEpoch: number;
|
||||||
|
body: string | null;
|
||||||
|
htmlUrl: string;
|
||||||
|
isOldEnough: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UpdateCheckStatus = 'no_updates' | 'update_available';
|
||||||
|
|
||||||
|
export interface UpdateCheckResult {
|
||||||
|
status: UpdateCheckStatus;
|
||||||
|
currentVersion: string;
|
||||||
|
release: ReleaseInfo | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let preferences: UpdatePreferences = {
|
||||||
|
checkOnLaunch: true,
|
||||||
|
showWhatsNewOnLaunch: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
let currentVersion = '';
|
||||||
|
let initialized = false;
|
||||||
|
|
||||||
|
const listeners = new Set<() => void>();
|
||||||
|
|
||||||
|
function notify(): void {
|
||||||
|
for (const listener of listeners) {
|
||||||
|
listener();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function subscribe(listener: () => void): () => void {
|
||||||
|
listeners.add(listener);
|
||||||
|
listener();
|
||||||
|
return () => listeners.delete(listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPreferences(): UpdatePreferences {
|
||||||
|
return preferences;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCurrentVersion(): string {
|
||||||
|
return currentVersion;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function initUpdatesStore(): Promise<void> {
|
||||||
|
if (initialized) return;
|
||||||
|
initialized = true;
|
||||||
|
try {
|
||||||
|
const [prefs, version] = await Promise.all([
|
||||||
|
invoke<UpdatePreferences>('get_update_preferences'),
|
||||||
|
invoke<string>('get_current_version'),
|
||||||
|
]);
|
||||||
|
preferences = prefs;
|
||||||
|
currentVersion = version;
|
||||||
|
notify();
|
||||||
|
} catch (error) {
|
||||||
|
console.debug('[Updates] Failed to initialize updates store:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function refreshUpdatePreferences(): Promise<void> {
|
||||||
|
try {
|
||||||
|
preferences = await invoke<UpdatePreferences>('get_update_preferences');
|
||||||
|
notify();
|
||||||
|
} catch (error) {
|
||||||
|
console.debug('[Updates] Failed to refresh update preferences:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setCheckOnLaunch(enabled: boolean): Promise<void> {
|
||||||
|
try {
|
||||||
|
await invoke('set_update_check_on_launch', { enabled });
|
||||||
|
preferences.checkOnLaunch = enabled;
|
||||||
|
notify();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Updates] Failed to set checkOnLaunch:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setShowWhatsNewOnLaunch(enabled: boolean): Promise<void> {
|
||||||
|
try {
|
||||||
|
await invoke('set_show_whats_new_on_launch', { enabled });
|
||||||
|
preferences.showWhatsNewOnLaunch = enabled;
|
||||||
|
notify();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Updates] Failed to set showWhatsNewOnLaunch:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function checkForUpdates(mode: 'launch' | 'manual'): Promise<UpdateCheckResult> {
|
||||||
|
const result = await invoke<UpdateCheckResult>('check_for_updates', { mode });
|
||||||
|
return {
|
||||||
|
...result,
|
||||||
|
release: result.release ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchReleaseForVersion(version: string): Promise<ReleaseInfo | null> {
|
||||||
|
try {
|
||||||
|
const release = await invoke<ReleaseInfo | null>('fetch_release_for_version', { version });
|
||||||
|
return release ?? null;
|
||||||
|
} catch (error) {
|
||||||
|
console.debug('[Updates] Failed to fetch release for version:', version, error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function acknowledgeRelease(version: string): Promise<void> {
|
||||||
|
await invoke('acknowledge_release', { version });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ignoreRelease(version: string): Promise<void> {
|
||||||
|
await invoke('ignore_release', { version });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function hasWhatsNewBeenShown(version: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
return await invoke<boolean>('has_whats_new_been_shown', { version });
|
||||||
|
} catch (error) {
|
||||||
|
console.debug('[Updates] Failed to check whats_new_shown:', version, error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function markWhatsNewShown(version: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
await invoke('mark_whats_new_shown', { version });
|
||||||
|
} catch (error) {
|
||||||
|
console.debug('[Updates] Failed to mark whats_new_shown:', version, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
function escapeHtml(input: string): string {
|
||||||
|
return input
|
||||||
|
.replaceAll('&', '&')
|
||||||
|
.replaceAll('<', '<')
|
||||||
|
.replaceAll('>', '>')
|
||||||
|
.replaceAll('"', '"')
|
||||||
|
.replaceAll("'", ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderInline(text: string): string {
|
||||||
|
const escaped = escapeHtml(text);
|
||||||
|
// Minimal inline formatting: **bold** and `code`
|
||||||
|
const withBold = escaped.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
|
||||||
|
return withBold.replace(/`([^`]+)`/g, '<code>$1</code>');
|
||||||
|
}
|
||||||
|
|
||||||
|
function slugify(input: string): string {
|
||||||
|
return input
|
||||||
|
.toLowerCase()
|
||||||
|
.trim()
|
||||||
|
.replace(/[`*_~]/g, '')
|
||||||
|
.replace(/[^a-z0-9\s-]/g, '')
|
||||||
|
.replace(/\s+/g, '-')
|
||||||
|
.replace(/-+/g, '-');
|
||||||
|
}
|
||||||
|
|
||||||
|
function countLeadingSpaces(line: string): number {
|
||||||
|
let count = 0;
|
||||||
|
for (const ch of line) {
|
||||||
|
if (ch === ' ') count += 1;
|
||||||
|
else if (ch === '\t') count += 2;
|
||||||
|
else break;
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TocEntry {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
level: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MarkdownRenderResult {
|
||||||
|
html: string;
|
||||||
|
toc: TocEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Safe markdown renderer for release notes.
|
||||||
|
*
|
||||||
|
* This intentionally supports a small subset and escapes all HTML first,
|
||||||
|
* which makes it safe to inject with {@html ...}.
|
||||||
|
*/
|
||||||
|
export function renderMarkdownWithToc(markdown: string | null | undefined): MarkdownRenderResult {
|
||||||
|
if (!markdown) {
|
||||||
|
return { html: '', toc: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines = markdown.split('\n');
|
||||||
|
const html: string[] = [];
|
||||||
|
const toc: TocEntry[] = [];
|
||||||
|
|
||||||
|
// Stack of list levels currently open (each entry is the level number)
|
||||||
|
const openLists: number[] = [];
|
||||||
|
|
||||||
|
const closeListsToLevel = (targetLevel: number) => {
|
||||||
|
while (openLists.length > targetLevel) {
|
||||||
|
html.push('</ul>');
|
||||||
|
openLists.pop();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openListToLevel = (targetLevel: number) => {
|
||||||
|
while (openLists.length < targetLevel) {
|
||||||
|
html.push('<ul>');
|
||||||
|
openLists.push(openLists.length + 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const pushHeading = (label: string, level: number) => {
|
||||||
|
const cleanLabel = label.trim();
|
||||||
|
if (!cleanLabel) return;
|
||||||
|
const id = slugify(cleanLabel);
|
||||||
|
toc.push({ id, label: cleanLabel, level });
|
||||||
|
// Use h3 for section headings to keep hierarchy subtle but visible.
|
||||||
|
html.push(`<h3 id="${id}" class="wn-section">${renderInline(cleanLabel)}</h3>`);
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
|
||||||
|
if (!trimmed) {
|
||||||
|
closeListsToLevel(0);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Headings (#, ##, ###)
|
||||||
|
if (trimmed.startsWith('# ')) {
|
||||||
|
closeListsToLevel(0);
|
||||||
|
const label = trimmed.slice(2);
|
||||||
|
pushHeading(label, 0);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (trimmed.startsWith('## ')) {
|
||||||
|
closeListsToLevel(0);
|
||||||
|
const label = trimmed.slice(3);
|
||||||
|
pushHeading(label, 0);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (trimmed.startsWith('### ')) {
|
||||||
|
closeListsToLevel(0);
|
||||||
|
const label = trimmed.slice(4);
|
||||||
|
pushHeading(label, 1);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// List items with indentation-based nesting
|
||||||
|
const isList = trimmed.startsWith('- ') || trimmed.startsWith('* ');
|
||||||
|
if (isList) {
|
||||||
|
const indentSpaces = countLeadingSpaces(line);
|
||||||
|
const level = Math.floor(indentSpaces / 2);
|
||||||
|
const content = trimmed.slice(2).trim();
|
||||||
|
|
||||||
|
if (level === 0) {
|
||||||
|
// Top-level bullets become section headings without bullets.
|
||||||
|
closeListsToLevel(0);
|
||||||
|
pushHeading(content, 0);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
openListToLevel(level);
|
||||||
|
html.push(`<li>${renderInline(content)}</li>`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
closeListsToLevel(0);
|
||||||
|
html.push(`<p>${renderInline(trimmed)}</p>`);
|
||||||
|
}
|
||||||
|
|
||||||
|
closeListsToLevel(0);
|
||||||
|
return { html: html.join('\n'), toc };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderMarkdownSafe(markdown: string | null | undefined): string {
|
||||||
|
return renderMarkdownWithToc(markdown).html;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatReleaseDate(isoDate: string): string {
|
||||||
|
const date = new Date(isoDate);
|
||||||
|
if (Number.isNaN(date.getTime())) return isoDate;
|
||||||
|
return new Intl.DateTimeFormat('en-US', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
}).format(date);
|
||||||
|
}
|
||||||
+121
-1
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount, onDestroy } from 'svelte';
|
import { onMount, onDestroy, tick } from 'svelte';
|
||||||
import { invoke, convertFileSrc } from '@tauri-apps/api/core';
|
import { invoke, convertFileSrc } from '@tauri-apps/api/core';
|
||||||
import { listen, emitTo, type UnlistenFn } from '@tauri-apps/api/event';
|
import { listen, emitTo, type UnlistenFn } from '@tauri-apps/api/event';
|
||||||
import { writeText } from '@tauri-apps/plugin-clipboard-manager';
|
import { writeText } from '@tauri-apps/plugin-clipboard-manager';
|
||||||
@@ -298,6 +298,16 @@
|
|||||||
import CastPicker from '$lib/components/CastPicker.svelte';
|
import CastPicker from '$lib/components/CastPicker.svelte';
|
||||||
import LyricsSidebar from '$lib/components/lyrics/LyricsSidebar.svelte';
|
import LyricsSidebar from '$lib/components/lyrics/LyricsSidebar.svelte';
|
||||||
import OfflinePlaceholder from '$lib/components/OfflinePlaceholder.svelte';
|
import OfflinePlaceholder from '$lib/components/OfflinePlaceholder.svelte';
|
||||||
|
import UpdateAvailableModal from '$lib/components/updates/UpdateAvailableModal.svelte';
|
||||||
|
import UpdateReminderModal from '$lib/components/updates/UpdateReminderModal.svelte';
|
||||||
|
import WhatsNewModal from '$lib/components/updates/WhatsNewModal.svelte';
|
||||||
|
import type { ReleaseInfo } from '$lib/stores/updatesStore';
|
||||||
|
import {
|
||||||
|
decideLaunchModals,
|
||||||
|
disableUpdateChecks,
|
||||||
|
ignoreReleaseVersion,
|
||||||
|
openReleasePageAndAcknowledge,
|
||||||
|
} from '$lib/services/updatesService';
|
||||||
|
|
||||||
// Offline state
|
// Offline state
|
||||||
import {
|
import {
|
||||||
@@ -326,6 +336,14 @@
|
|||||||
// View State (from navigationStore subscription)
|
// View State (from navigationStore subscription)
|
||||||
let activeView = $state<ViewType>('home');
|
let activeView = $state<ViewType>('home');
|
||||||
let selectedPlaylistId = $state<number | null>(null);
|
let selectedPlaylistId = $state<number | null>(null);
|
||||||
|
let updatesCurrentVersion = $state('');
|
||||||
|
let updateRelease = $state<ReleaseInfo | null>(null);
|
||||||
|
let whatsNewRelease = $state<ReleaseInfo | null>(null);
|
||||||
|
let isUpdateModalOpen = $state(false);
|
||||||
|
let isReminderModalOpen = $state(false);
|
||||||
|
let isWhatsNewModalOpen = $state(false);
|
||||||
|
let updatesLaunchTriggered = $state(false);
|
||||||
|
|
||||||
// Album, Artist and Label data are fetched, so kept local
|
// Album, Artist and Label data are fetched, so kept local
|
||||||
let selectedAlbum = $state<AlbumDetail | null>(null);
|
let selectedAlbum = $state<AlbumDetail | null>(null);
|
||||||
let selectedArtist = $state<ArtistDetail | null>(null);
|
let selectedArtist = $state<ArtistDetail | null>(null);
|
||||||
@@ -334,6 +352,80 @@
|
|||||||
let musicianModalData = $state<ResolvedMusician | null>(null);
|
let musicianModalData = $state<ResolvedMusician | null>(null);
|
||||||
let isArtistAlbumsLoading = $state(false);
|
let isArtistAlbumsLoading = $state(false);
|
||||||
|
|
||||||
|
function waitForHomePaint(): Promise<void> {
|
||||||
|
if (typeof window === 'undefined') return Promise.resolve();
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
requestAnimationFrame(() => resolve());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runLaunchUpdateFlow(): Promise<void> {
|
||||||
|
// Ensure the UI has rendered and Home is visible before showing any modal.
|
||||||
|
await tick();
|
||||||
|
await waitForHomePaint();
|
||||||
|
if (activeView !== 'home') return;
|
||||||
|
|
||||||
|
const decision = await decideLaunchModals();
|
||||||
|
updatesCurrentVersion = decision.currentVersion;
|
||||||
|
|
||||||
|
if (decision.updateRelease) {
|
||||||
|
updateRelease = decision.updateRelease;
|
||||||
|
isUpdateModalOpen = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (decision.whatsNewRelease) {
|
||||||
|
whatsNewRelease = decision.whatsNewRelease;
|
||||||
|
isWhatsNewModalOpen = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleUpdateVisit(): void {
|
||||||
|
if (!updateRelease) return;
|
||||||
|
void openReleasePageAndAcknowledge(updateRelease);
|
||||||
|
isUpdateModalOpen = false;
|
||||||
|
updateRelease = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleUpdateClose(): void {
|
||||||
|
isUpdateModalOpen = false;
|
||||||
|
if (updateRelease) {
|
||||||
|
isReminderModalOpen = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleReminderClose(): void {
|
||||||
|
isReminderModalOpen = false;
|
||||||
|
updateRelease = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleReminderLater(): void {
|
||||||
|
// No persistence by design.
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleReminderIgnoreRelease(): void {
|
||||||
|
if (!updateRelease) return;
|
||||||
|
void ignoreReleaseVersion(updateRelease.version);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleReminderDisableUpdates(): void {
|
||||||
|
void disableUpdateChecks();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleWhatsNewClose(): void {
|
||||||
|
isWhatsNewModalOpen = false;
|
||||||
|
whatsNewRelease = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (updatesLaunchTriggered) return;
|
||||||
|
if (activeView !== 'home') return;
|
||||||
|
updatesLaunchTriggered = true;
|
||||||
|
void runLaunchUpdateFlow();
|
||||||
|
});
|
||||||
|
|
||||||
// Artist albums for "By the same artist" section in album view
|
// Artist albums for "By the same artist" section in album view
|
||||||
let albumArtistAlbums = $state<{ id: string; title: string; artwork: string; quality: string; genre: string; releaseDate?: string }[]>([]);
|
let albumArtistAlbums = $state<{ id: string; title: string; artwork: string; quality: string; genre: string; releaseDate?: string }[]>([]);
|
||||||
|
|
||||||
@@ -2583,6 +2675,7 @@
|
|||||||
userName={userInfo?.userName}
|
userName={userInfo?.userName}
|
||||||
subscription={userInfo?.subscription}
|
subscription={userInfo?.subscription}
|
||||||
subscriptionValidUntil={userInfo?.subscriptionValidUntil}
|
subscriptionValidUntil={userInfo?.subscriptionValidUntil}
|
||||||
|
showTitleBar={showTitleBar}
|
||||||
/>
|
/>
|
||||||
{:else if activeView === 'album' && selectedAlbum}
|
{:else if activeView === 'album' && selectedAlbum}
|
||||||
<AlbumDetailView
|
<AlbumDetailView
|
||||||
@@ -2999,6 +3092,33 @@
|
|||||||
onClose={() => isAboutModalOpen = false}
|
onClose={() => isAboutModalOpen = false}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{#if updateRelease}
|
||||||
|
<UpdateAvailableModal
|
||||||
|
isOpen={isUpdateModalOpen}
|
||||||
|
currentVersion={updatesCurrentVersion}
|
||||||
|
newVersion={updateRelease.version}
|
||||||
|
onClose={handleUpdateClose}
|
||||||
|
onVisitReleasePage={handleUpdateVisit}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<UpdateReminderModal
|
||||||
|
isOpen={isReminderModalOpen}
|
||||||
|
onClose={handleReminderClose}
|
||||||
|
onRemindLater={handleReminderLater}
|
||||||
|
onIgnoreRelease={handleReminderIgnoreRelease}
|
||||||
|
onDisableAllUpdates={handleReminderDisableUpdates}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if whatsNewRelease}
|
||||||
|
<WhatsNewModal
|
||||||
|
isOpen={isWhatsNewModalOpen}
|
||||||
|
release={whatsNewRelease}
|
||||||
|
{showTitleBar}
|
||||||
|
onClose={handleWhatsNewClose}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!-- Track Info Modal -->
|
<!-- Track Info Modal -->
|
||||||
<TrackInfoModal
|
<TrackInfoModal
|
||||||
isOpen={isTrackInfoOpen}
|
isOpen={isTrackInfoOpen}
|
||||||
|
|||||||
Reference in new issue
Block a user