From aaf1c2a8b7d54ec7ec84416507085ce327d47b4a Mon Sep 17 00:00:00 2001 From: meetsu <96637888+klNuno@users.noreply.github.com> Date: Thu, 3 Sep 2026 21:49:39 +0200 Subject: [PATCH 01/15] fix(window): save logical size, restore position boot.rs saved win.inner_size() in physical px and restored it through WebviewWindowBuilder::inner_size, which reads logical px, so the window grew by the scale factor at every launch above 100%. config.rs stores logical px, clamps on load and keeps window_x/window_y; boot.rs recenters when the saved rectangle meets no monitor. --- crates/accshift-core/src/config.rs | 234 +++++++++++++++++++-- src-tauri/src/boot.rs | 316 ++++++++++++++++++++++++++--- src-tauri/src/main.rs | 2 +- 3 files changed, 504 insertions(+), 48 deletions(-) diff --git a/crates/accshift-core/src/config.rs b/crates/accshift-core/src/config.rs index d8fd3b4..65cdf36 100644 --- a/crates/accshift-core/src/config.rs +++ b/crates/accshift-core/src/config.rs @@ -3,10 +3,25 @@ use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; use std::fs; +// Every window measurement here is in LOGICAL pixels, which is what +// `WebviewWindowBuilder::inner_size` and `::position` consume. Callers that +// read a live window get physical pixels and must divide by the scale factor +// first (see `logical_from_physical`), or the window grows by that factor at +// every launch on a scaled display. pub const DEFAULT_WINDOW_WIDTH: f64 = 1000.0; pub const DEFAULT_WINDOW_HEIGHT: f64 = 520.0; pub const MIN_WINDOW_WIDTH: f64 = 400.0; pub const MIN_WINDOW_HEIGHT: f64 = 300.0; +/// Upper bound on a restored window, in logical pixels. Windows itself refuses +/// to create a window wider or taller than this, so a config claiming more is +/// corrupt whatever produced it. +pub const MAX_WINDOW_WIDTH: f64 = 16_384.0; +pub const MAX_WINDOW_HEIGHT: f64 = 16_384.0; +/// Upper bound on a restored window origin, in logical pixels. A multi-monitor +/// desktop can put a window at a negative coordinate, so this bounds the +/// magnitude and not the sign. Whether the saved spot is still on a monitor is +/// a question only the GUI can answer. +const MAX_WINDOW_ORIGIN: f64 = 32_768.0; const WINDOW_SIZE_EPSILON: f64 = 1.0; #[derive(Debug, Serialize, Deserialize, Default, Clone)] @@ -282,6 +297,13 @@ pub struct AppConfig { pub window_width: Option, #[serde(default)] pub window_height: Option, + /// Window origin in logical pixels. Absent means "no saved placement", and + /// the GUI centers the window, which is also what every config written + /// before this field existed says. + #[serde(default)] + pub window_x: Option, + #[serde(default)] + pub window_y: Option, } #[derive(Debug, Serialize, Deserialize, Default)] @@ -317,6 +339,10 @@ struct RawAppConfig { window_width: Option, #[serde(default)] window_height: Option, + #[serde(default)] + window_x: Option, + #[serde(default)] + window_y: Option, } #[derive(Debug, Serialize, Deserialize, Default, Clone)] @@ -526,6 +552,8 @@ fn normalize_config(raw: RawAppConfig) -> AppConfig { telemetry, window_width: raw.window_width, window_height: raw.window_height, + window_x: raw.window_x, + window_y: raw.window_y, } } @@ -752,6 +780,7 @@ fn save_config_unlocked(app_handle: &dyn AppContext, config: &AppConfig) -> Resu "jagexAccounts": config.jagex.accounts.len(), "discordAccounts": config.discord.accounts.len(), "hasWindowSize": config.window_width.is_some() && config.window_height.is_some(), + "hasWindowPosition": config.window_x.is_some() && config.window_y.is_some(), }) .to_string(); let _ = crate::logging::append_app_log( @@ -849,20 +878,54 @@ pub fn migrate_legacy_config(app_handle: &dyn AppContext) -> Option f64 { + if !scale_factor.is_finite() || scale_factor <= 0.0 { + return physical; + } + physical / scale_factor +} + +/// The saved size as the window builder should get it, or `None` when there is +/// nothing usable to restore. +/// +/// A size at the minimum is treated as a bug rather than a preference (a window +/// collapsed by a runtime glitch), and anything past the maximum is a corrupt +/// file: clamping it keeps the window reachable instead of opening it off +/// screen or failing to open at all. +pub fn clamp_window_size(width: f64, height: f64) -> Option<(f64, f64)> { + if !width.is_finite() || !height.is_finite() || width <= 0.0 || height <= 0.0 { + return None; + } + if is_suspicious_min_window_size(width, height) { + return None; + } + Some(( + width.clamp(MIN_WINDOW_WIDTH, MAX_WINDOW_WIDTH), + height.clamp(MIN_WINDOW_HEIGHT, MAX_WINDOW_HEIGHT), + )) +} + +/// The saved origin, or `None` when it is missing or nonsense. The caller still +/// has to check it against the monitors actually attached today. +pub fn clamp_window_position(x: f64, y: f64) -> Option<(f64, f64)> { + let sane = x.is_finite() + && y.is_finite() + && x.abs() <= MAX_WINDOW_ORIGIN + && y.abs() <= MAX_WINDOW_ORIGIN; + sane.then_some((x, y)) +} + pub fn load_window_size(app_handle: &dyn AppContext) -> Option<(f64, f64)> { let cfg = load_config(app_handle); - let width = cfg.window_width?; - let height = cfg.window_height?; - if width.is_finite() - && height.is_finite() - && width > 0.0 - && height > 0.0 - && !is_suspicious_min_window_size(width, height) - { - Some((width, height)) - } else { - None - } + clamp_window_size(cfg.window_width?, cfg.window_height?) +} + +pub fn load_window_position(app_handle: &dyn AppContext) -> Option<(f64, f64)> { + let cfg = load_config(app_handle); + clamp_window_position(cfg.window_x?, cfg.window_y?) } pub fn save_window_size( @@ -870,17 +933,36 @@ pub fn save_window_size( width: f64, height: f64, ) -> Result<(), String> { - if !width.is_finite() || !height.is_finite() || width <= 0.0 || height <= 0.0 { - return Ok(()); - } + save_window_geometry(app_handle, width, height, None) +} - if is_suspicious_min_window_size(width, height) { +/// Persist the window geometry, in logical pixels. +/// +/// A `None` position leaves the stored placement alone, so a caller that only +/// knows the size never erases where the user put the window. A value that +/// fails validation is dropped rather than written, and a call where nothing +/// survives validation touches no file at all. +pub fn save_window_geometry( + app_handle: &dyn AppContext, + width: f64, + height: f64, + position: Option<(f64, f64)>, +) -> Result<(), String> { + let size = clamp_window_size(width, height); + let position = position.and_then(|(x, y)| clamp_window_position(x, y)); + if size.is_none() && position.is_none() { return Ok(()); } update_config(app_handle, |cfg| { - cfg.window_width = Some(width); - cfg.window_height = Some(height); + if let Some((width, height)) = size { + cfg.window_width = Some(width); + cfg.window_height = Some(height); + } + if let Some((x, y)) = position { + cfg.window_x = Some(x); + cfg.window_y = Some(y); + } }) } @@ -938,6 +1020,8 @@ fn portable_config(config: &AppConfig) -> AppConfig { portable.telemetry.anonymous_id.clear(); portable.window_width = None; portable.window_height = None; + portable.window_x = None; + portable.window_y = None; for account in &mut portable.roblox.accounts { account.cookie_encrypted.clear(); } @@ -1051,6 +1135,8 @@ fn local_config(config: &AppConfig) -> AppConfig { local.telemetry.onboarding_completed = false; local.window_width = config.window_width; local.window_height = config.window_height; + local.window_x = config.window_x; + local.window_y = config.window_y; local.roblox.accounts = config .roblox .accounts @@ -1102,6 +1188,8 @@ fn merge_split_configs(portable: AppConfig, mut local: AppConfig) -> AppConfig { ); overwrite_if_set(&mut merged.window_width, local.window_width); overwrite_if_set(&mut merged.window_height, local.window_height); + overwrite_if_set(&mut merged.window_x, local.window_x); + overwrite_if_set(&mut merged.window_y, local.window_y); for local_account in local.roblox.accounts { if local_account.user_id.trim().is_empty() { @@ -1288,6 +1376,108 @@ mod tests { let _ = std::fs::remove_dir_all(&ctx.root); } + // Regression for the launch-over-launch growth: the window reports a + // physical size, the builder consumes logical pixels, so a config that + // stored the physical number grew the window by the scale factor every + // time. The saver converts once, and the round trip is an identity at any + // scale. + #[test] + fn window_size_round_trips_in_logical_pixels_at_scale_1_5() { + let _test_guard = config_io_test_mutex() + .lock() + .unwrap_or_else(|e| e.into_inner()); + let ctx = tmp_ctx("window-size-dpi"); + save_config(&*ctx, &AppConfig::default()).unwrap(); + + let scale = 1.5_f64; + let (logical_width, logical_height) = (DEFAULT_WINDOW_WIDTH, DEFAULT_WINDOW_HEIGHT); + // What the window would report on a 150% display. + let physical_width = logical_width * scale; + let physical_height = logical_height * scale; + + save_window_geometry( + &*ctx, + logical_from_physical(physical_width, scale), + logical_from_physical(physical_height, scale), + None, + ) + .unwrap(); + + assert_eq!( + load_window_size(&*ctx), + Some((logical_width, logical_height)), + "a saved size must come back unchanged, not scaled" + ); + + // Second launch: the restored size is what the window is built with, so + // feeding it back through the same path must not move either. + let (restored_width, restored_height) = load_window_size(&*ctx).unwrap(); + save_window_geometry( + &*ctx, + logical_from_physical(restored_width * scale, scale), + logical_from_physical(restored_height * scale, scale), + None, + ) + .unwrap(); + assert_eq!( + load_window_size(&*ctx), + Some((logical_width, logical_height)) + ); + + let _ = std::fs::remove_dir_all(&ctx.root); + } + + #[test] + fn window_size_is_clamped_to_something_openable() { + assert_eq!(clamp_window_size(f64::NAN, 600.0), None); + assert_eq!(clamp_window_size(0.0, 600.0), None); + // A window collapsed to the minimum is a glitch, not a preference. + assert_eq!(clamp_window_size(MIN_WINDOW_WIDTH, MIN_WINDOW_HEIGHT), None); + assert_eq!( + clamp_window_size(1.0e9, 1.0e9), + Some((MAX_WINDOW_WIDTH, MAX_WINDOW_HEIGHT)) + ); + assert_eq!( + clamp_window_size(200.0, 4000.0), + Some((MIN_WINDOW_WIDTH, 4000.0)) + ); + assert_eq!(clamp_window_size(1280.0, 720.0), Some((1280.0, 720.0))); + } + + #[test] + fn window_position_round_trips_and_survives_a_missing_field() { + let _test_guard = config_io_test_mutex() + .lock() + .unwrap_or_else(|e| e.into_inner()); + let ctx = tmp_ctx("window-position"); + save_config(&*ctx, &AppConfig::default()).unwrap(); + + // A config written before the field existed means "center me". + assert_eq!(load_window_position(&*ctx), None); + + // A second monitor to the left gives a negative origin, which is valid. + save_window_geometry(&*ctx, 1280.0, 720.0, Some((-1920.0, 240.0))).unwrap(); + assert_eq!(load_window_position(&*ctx), Some((-1920.0, 240.0))); + + // A size-only save must not erase the placement. + save_window_size(&*ctx, 1000.0, 600.0).unwrap(); + assert_eq!(load_window_position(&*ctx), Some((-1920.0, 240.0))); + assert_eq!(load_window_size(&*ctx), Some((1000.0, 600.0))); + + let _ = std::fs::remove_dir_all(&ctx.root); + } + + #[test] + fn a_nonsense_window_position_is_refused() { + assert_eq!(clamp_window_position(f64::NAN, 0.0), None); + assert_eq!(clamp_window_position(0.0, f64::INFINITY), None); + assert_eq!(clamp_window_position(1.0e9, 0.0), None); + assert_eq!( + clamp_window_position(-1920.0, -80.0), + Some((-1920.0, -80.0)) + ); + } + #[test] fn normalize_config_migrates_legacy_steam_fields() { let raw = RawAppConfig { @@ -1408,6 +1598,8 @@ mod tests { telemetry: TelemetryConfig::default(), window_width: Some(1200.0), window_height: Some(800.0), + window_x: Some(120.0), + window_y: Some(64.0), }; let p = portable_config(&config); @@ -1427,6 +1619,8 @@ mod tests { assert!(p.jagex.path_override.is_empty()); assert!(p.window_width.is_none()); assert!(p.window_height.is_none()); + assert!(p.window_x.is_none()); + assert!(p.window_y.is_none()); // Roblox cookies stripped assert!(p.roblox.accounts[0].cookie_encrypted.is_empty()); @@ -1495,6 +1689,8 @@ mod tests { telemetry: TelemetryConfig::default(), window_width: Some(1024.0), window_height: Some(768.0), + window_x: Some(-1920.0), + window_y: Some(40.0), }; let l = local_config(&config); @@ -1511,6 +1707,8 @@ mod tests { assert_eq!(l.jagex.path_override, "C:\\Jagex"); assert_eq!(l.window_width, Some(1024.0)); assert_eq!(l.window_height, Some(768.0)); + assert_eq!(l.window_x, Some(-1920.0)); + assert_eq!(l.window_y, Some(40.0)); // Roblox local keeps user_id + cookie, but not username/display_name assert_eq!(l.roblox.accounts.len(), 1); diff --git a/src-tauri/src/boot.rs b/src-tauri/src/boot.rs index 14bc98e..15c2079 100644 --- a/src-tauri/src/boot.rs +++ b/src-tauri/src/boot.rs @@ -8,9 +8,11 @@ use crate::{app_runtime, config, ctx, logging, telemetry, telemetry_runtime}; use accshift_core::AppCtx; +use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::mpsc::Receiver; +use std::sync::Mutex; use tauri::webview::PageLoadEvent; -use tauri::{AppHandle, Manager, WebviewWindow}; +use tauri::{AppHandle, Manager, Monitor, WebviewWindow}; /// Shared HTTP client. A process that cannot build one cannot reach Steam, so /// there is nothing useful left to boot into. @@ -39,8 +41,10 @@ fn navigation_allowed(url: &tauri::Url) -> bool { || (cfg!(debug_assertions) && is_http && matches!(host, Some("localhost" | "127.0.0.1"))) } -/// Build the main window: last saved size, frameless and transparent, with the -/// navigation guard and the page-load log wired in. +/// Build the main window: last saved size and placement, frameless and +/// transparent, with the navigation guard and the page-load log wired in. +/// +/// Both saved values are logical pixels, which is the unit the builder takes. /// /// It is built hidden. Boot completion (or the failsafe below) shows it. pub(crate) fn build_main_window( @@ -49,6 +53,7 @@ pub(crate) fn build_main_window( ) -> Result> { let (start_width, start_height) = config::load_window_size(setup_ctx) .unwrap_or((config::DEFAULT_WINDOW_WIDTH, config::DEFAULT_WINDOW_HEIGHT)); + let saved_position = config::load_window_position(setup_ctx); let navigation_log_ctx = setup_ctx.clone(); let page_load_log_ctx = setup_ctx.clone(); @@ -61,7 +66,6 @@ pub(crate) fn build_main_window( .visible(false) .transparent(true) .background_color(tauri::webview::Color(0, 0, 0, 0)) - .center() .resizable(true) .on_navigation(move |url| { let allowed = navigation_allowed(url); @@ -93,6 +97,12 @@ pub(crate) fn build_main_window( ); }); + // First launch, or a config with no placement in it, still opens centered. + window_builder = match saved_position { + Some((x, y)) => window_builder.position(x, y), + None => window_builder.center(), + }; + #[cfg(target_os = "macos")] { // Native traffic lights float over our custom titlebar. WKWebView @@ -112,6 +122,21 @@ pub(crate) fn build_main_window( } let win = window_builder.build()?; + + // The monitor the window was saved on may be unplugged, or the desktop + // rearranged. The window is still hidden here, so recentering it costs no + // visible jump. + if saved_position.is_some() && !window_sits_on_a_monitor(&win) { + let _ = win.center(); + let _ = logging::append_app_log( + setup_ctx, + "info", + "backend.window", + "Saved window position is off every monitor; centered instead", + None, + ); + } + let _ = logging::append_app_log( setup_ctx, "info", @@ -122,6 +147,74 @@ pub(crate) fn build_main_window( Ok(win) } +/// True when the window overlaps the work area of at least one attached +/// monitor. Everything here is physical pixels, which is what both the window +/// and the monitor report, so no scale factor is involved. +/// +/// A window whose monitor list cannot be read is left where it is: with no +/// monitors to compare against there is no evidence it sits anywhere wrong. +fn window_sits_on_a_monitor(win: &WebviewWindow) -> bool { + let (Ok(position), Ok(size), Ok(monitors)) = ( + win.outer_position(), + win.outer_size(), + win.available_monitors(), + ) else { + return true; + }; + if monitors.is_empty() { + return true; + } + let window = Rect::at( + f64::from(position.x), + f64::from(position.y), + f64::from(size.width), + f64::from(size.height), + ); + monitors + .iter() + .any(|monitor| window.overlaps(&work_area_rect(monitor))) +} + +fn work_area_rect(monitor: &Monitor) -> Rect { + let area = monitor.work_area(); + Rect::at( + f64::from(area.position.x), + f64::from(area.position.y), + f64::from(area.size.width), + f64::from(area.size.height), + ) +} + +/// Screen rectangle in physical pixels, origin top left. +#[derive(Clone, Copy, Debug)] +struct Rect { + left: f64, + top: f64, + right: f64, + bottom: f64, +} + +impl Rect { + fn at(left: f64, top: f64, width: f64, height: f64) -> Self { + Self { + left, + top, + right: left + width, + bottom: top + height, + } + } + + /// True when the two rectangles share any area. Touching edges do not + /// count: a window whose right edge is exactly a monitor's left edge shows + /// nothing on it. + fn overlaps(&self, other: &Self) -> bool { + self.left < other.right + && self.right > other.left + && self.top < other.bottom + && self.bottom > other.top + } +} + /// Turn off Edge's form autofill. /// /// It pops "saved information" suggestions over plain text inputs (Steam launch @@ -147,56 +240,164 @@ pub(crate) fn disable_webview_autofill(win: &WebviewWindow) { #[cfg(not(windows))] pub(crate) fn disable_webview_autofill(_win: &WebviewWindow) {} -/// Persist the window size on its own thread, and hand back the channel that -/// says when the write landed. `None` means nothing was queued. +/// Size and placement of the main window, in the logical pixels the config +/// stores and the window builder consumes. +#[derive(Clone, Copy, Debug, PartialEq)] +struct WindowGeometry { + width: f64, + height: f64, + x: f64, + y: f64, +} + +/// Last geometry a move event reported, waiting to be written. +static PENDING_GEOMETRY: Mutex> = Mutex::new(None); +/// Whether a thread is already draining `PENDING_GEOMETRY`. +static GEOMETRY_SAVER_RUNNING: AtomicBool = AtomicBool::new(false); +/// Quiet time after the last move event before the write goes out. A window +/// drag emits dozens of events per second and each save takes the +/// cross-process config lock, so only the last one is worth writing. +const GEOMETRY_SAVE_DEBOUNCE: std::time::Duration = std::time::Duration::from_millis(700); + +/// Read the window's live geometry, converted to logical pixels once. +/// +/// This is the bug fix for the launch-over-launch growth: `inner_size` and +/// `outer_position` are physical, the builder is logical, so on a 125% display +/// storing the raw numbers multiplied the window by 1.25 every launch. +/// +/// `None` when nothing worth saving is on screen: a maximized window would +/// store the screen size as the restored size, a minimized one reports a +/// parking position far off every monitor (-32000 on Windows), and a window +/// whose size or position cannot be read has nothing to offer. +fn current_geometry(win: &WebviewWindow) -> Option { + if matches!(win.is_maximized(), Ok(true)) || matches!(win.is_minimized(), Ok(true)) { + return None; + } + let scale = win.scale_factor().ok()?; + let size = win.inner_size().ok()?; + let position = win.outer_position().ok()?; + let size = size.to_logical::(scale); + let position = position.to_logical::(scale); + Some(WindowGeometry { + width: size.width, + height: size.height, + x: position.x, + y: position.y, + }) +} + +/// Whether saving is allowed at all. A window closed or moved before boot +/// completed never got its saved geometry applied, so saving now would +/// overwrite the real one with the default. +fn geometry_save_allowed(app_handle: &AppHandle) -> bool { + app_handle.state::().is_completed() +} + +/// Persist the window geometry on its own thread, and hand back the channel +/// that says when the write landed. `None` means nothing was queued. /// /// The save is a read-modify-write that takes the cross-process config lock, /// which can wait up to 5s while the CLI holds it. Running it inline would /// freeze the UI thread for that whole stretch. -/// -/// Three reasons to skip it, and the first is not cosmetic: a window closed -/// before boot completed never got its saved size applied, so saving now would -/// overwrite the real one with the default. -fn spawn_window_size_save(app_handle: &AppHandle, win: &WebviewWindow) -> Option> { - if !app_handle.state::().is_completed() { +fn spawn_window_geometry_save(app_handle: &AppHandle, win: &WebviewWindow) -> Option> { + if !geometry_save_allowed(app_handle) { let _ = logging::append_app_log( &ctx(app_handle), "info", "backend.window", - "Skipped window size save because boot was not completed", + "Skipped window geometry save because boot was not completed", None, ); return None; } - if matches!(win.is_maximized(), Ok(true)) { - return None; - } - let size = win.inner_size().ok()?; + let geometry = current_geometry(win)?; + // This write is newer than anything the debounce still holds, and it must + // not be undone by a thread waking up after it. + take_pending_geometry(); let save_handle = app_handle.clone(); - let width = f64::from(size.width); - let height = f64::from(size.height); let (tx, rx) = std::sync::mpsc::channel(); std::thread::spawn(move || { - let _ = config::save_window_size(&ctx(&save_handle), width, height); + save_geometry(&save_handle, geometry); let _ = tx.send(()); }); Some(rx) } -/// What runs when the user closes the window: queue the size save, hide, end -/// the telemetry session, then wait out the size save. +fn save_geometry(app_handle: &AppHandle, geometry: WindowGeometry) { + let _ = config::save_window_geometry( + &ctx(app_handle), + geometry.width, + geometry.height, + Some((geometry.x, geometry.y)), + ); +} + +fn take_pending_geometry() -> Option { + PENDING_GEOMETRY + .lock() + .unwrap_or_else(|e| e.into_inner()) + .take() +} + +/// Queue a debounced geometry save. Called from the move handler, so it does +/// no IO of its own: it stamps the value and lets a background thread write the +/// last one once the drag stops. +fn queue_window_geometry_save(app_handle: &AppHandle, win: &WebviewWindow) { + if !geometry_save_allowed(app_handle) { + return; + } + let Some(geometry) = current_geometry(win) else { + return; + }; + *PENDING_GEOMETRY.lock().unwrap_or_else(|e| e.into_inner()) = Some(geometry); + if GEOMETRY_SAVER_RUNNING.swap(true, Ordering::SeqCst) { + // A thread is already waiting; it will pick this value up. + return; + } + + let save_handle = app_handle.clone(); + std::thread::spawn(move || { + loop { + std::thread::sleep(GEOMETRY_SAVE_DEBOUNCE); + match take_pending_geometry() { + Some(geometry) => save_geometry(&save_handle, geometry), + None => { + GEOMETRY_SAVER_RUNNING.store(false, Ordering::SeqCst); + // A move that landed between the take above and this + // release would otherwise sit unwritten until the next one. + let missed = PENDING_GEOMETRY + .lock() + .unwrap_or_else(|e| e.into_inner()) + .is_some(); + if missed && !GEOMETRY_SAVER_RUNNING.swap(true, Ordering::SeqCst) { + continue; + } + break; + } + } + } + }); +} + +/// Window events that outlive a single frame: the close sequence, and the +/// debounced geometry save behind every move. /// -/// This handler runs on the UI thread, so the hide comes before the telemetry -/// flush: anything slow ahead of it shows up as a frozen window rather than a -/// closed app. -pub(crate) fn install_close_handler(app_handle: AppHandle, win: &WebviewWindow) { +/// On close: queue the geometry save, hide, end the telemetry session, then +/// wait out the save. This handler runs on the UI thread, so the hide comes +/// before the telemetry flush: anything slow ahead of it shows up as a frozen +/// window rather than a closed app. +pub(crate) fn install_window_event_handlers(app_handle: AppHandle, win: &WebviewWindow) { let win_for_events = win.clone(); win.on_window_event(move |event| { + if matches!(event, tauri::WindowEvent::Moved(_)) { + queue_window_geometry_save(&app_handle, &win_for_events); + return; + } if !matches!(event, tauri::WindowEvent::CloseRequested { .. }) { return; } - let size_save_wait = spawn_window_size_save(&app_handle, &win_for_events); + let geometry_save_wait = spawn_window_geometry_save(&app_handle, &win_for_events); let _ = win_for_events.hide(); @@ -211,10 +412,10 @@ pub(crate) fn install_close_handler(app_handle: AppHandle, win: &WebviewWindow) .track(telemetry::Event::SessionEnded { duration_ms }); tstate.shutdown(); - // Give the size save the same bound save_config's own cross-process + // Give the geometry save the same bound save_config's own cross-process // lock uses, so it either lands before we exit or is abandoned // deliberately rather than silently. - if let Some(rx) = size_save_wait { + if let Some(rx) = geometry_save_wait { let _ = rx.recv_timeout(std::time::Duration::from_secs(5)); } }); @@ -327,3 +528,60 @@ pub(crate) fn spawn_boot_failsafe(fallback_handle: AppHandle) { let _ = app_runtime::show_main_window(&fallback_handle); }); } + +#[cfg(test)] +mod tests { + use super::*; + use tauri::{PhysicalPosition, PhysicalSize}; + + // The unit bug in one assertion: a 1000x520 logical window on a 125% + // display reports 1250x650 physical. Storing that raw is what made the + // window grow by 25% at every launch, because the builder reads the stored + // number as logical. + #[test] + fn a_physical_window_size_converts_back_to_the_logical_one() { + let scale = 1.25; + let physical = PhysicalSize::new(1250_u32, 650_u32); + let logical = physical.to_logical::(scale); + + assert_eq!((logical.width, logical.height), (1000.0, 520.0)); + assert_eq!( + ( + accshift_core::config::logical_from_physical(1250.0, scale), + accshift_core::config::logical_from_physical(650.0, scale), + ), + (logical.width, logical.height), + "the config helper and the tauri conversion must agree" + ); + } + + #[test] + fn a_physical_window_position_converts_back_to_the_logical_one() { + let physical = PhysicalPosition::new(-2400_i32, 150_i32); + let logical = physical.to_logical::(1.5); + assert_eq!((logical.x, logical.y), (-1600.0, 100.0)); + } + + #[test] + fn a_window_overlapping_a_monitor_is_kept() { + let monitor = Rect::at(0.0, 0.0, 1920.0, 1040.0); + // Fully inside. + assert!(Rect::at(100.0, 100.0, 1000.0, 520.0).overlaps(&monitor)); + // Half off the right edge, still reachable. + assert!(Rect::at(1900.0, 100.0, 1000.0, 520.0).overlaps(&monitor)); + // A second monitor to the left of the primary one. + assert!(Rect::at(-1800.0, 40.0, 1000.0, 520.0) + .overlaps(&Rect::at(-1920.0, 0.0, 1920.0, 1040.0))); + } + + #[test] + fn a_window_off_every_monitor_is_rejected() { + let monitor = Rect::at(0.0, 0.0, 1920.0, 1040.0); + // The unplugged second monitor case. + assert!(!Rect::at(-1800.0, 40.0, 1000.0, 520.0).overlaps(&monitor)); + // Below the taskbar, off the work area. + assert!(!Rect::at(100.0, 1040.0, 1000.0, 520.0).overlaps(&monitor)); + // Touching edges share no pixel. + assert!(!Rect::at(1920.0, 0.0, 1000.0, 520.0).overlaps(&monitor)); + } +} diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index cbee5d5..e8e341c 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -104,7 +104,7 @@ fn main() { &setup_ctx, app_start, )); - boot::install_close_handler(app.handle().clone(), &win); + boot::install_window_event_handlers(app.handle().clone(), &win); boot::wire_deep_links(app, &setup_ctx); boot::spawn_snapshot_upgrade(setup_ctx.clone()); boot::spawn_boot_failsafe(app.handle().clone()); From 5ef40875fb060ff8cc7fc7d58d48a78c9192966c Mon Sep 17 00:00:00 2001 From: meetsu <96637888+klNuno@users.noreply.github.com> Date: Thu, 3 Sep 2026 21:49:49 +0200 Subject: [PATCH 02/15] fix(accounts): last login unit and absolute date genericAdapter.ts, battle-net/adapter.ts and ubisoft/adapter.ts passed the backend ms stamp to formatRelativeTimeFromUnixSeconds, which rendered just now for ever on six platforms. lastLoginAt becomes lastLoginAtSec, adapters convert through unixMsToSeconds, values outside unix seconds render as unknown, time.ts shows an absolute date past 30 days. --- src/lib/app/AppWorkspace.svelte | 2 +- src/lib/app/platformAddFlow.svelte.ts | 2 +- src/lib/app/useOnboardingTour.svelte.ts | 2 +- src/lib/platforms/battle-net/adapter.test.ts | 26 ++++ src/lib/platforms/battle-net/adapter.ts | 17 ++- src/lib/platforms/genericAdapter.test.ts | 27 ++++ src/lib/platforms/genericAdapter.ts | 7 +- src/lib/platforms/riot/adapter.test.ts | 29 ++++ src/lib/platforms/riot/adapter.ts | 9 +- src/lib/platforms/riot/types.ts | 2 + src/lib/platforms/roblox/adapter.test.ts | 19 +++ src/lib/platforms/roblox/adapter.ts | 9 +- src/lib/platforms/roblox/types.ts | 4 + src/lib/platforms/steam/adapter.test.ts | 25 ++++ src/lib/platforms/steam/adapter.ts | 10 +- src/lib/platforms/steam/types.ts | 2 + src/lib/platforms/ubisoft/adapter.test.ts | 32 ++++ src/lib/platforms/ubisoft/adapter.ts | 15 +- src/lib/shared/components/AccountCard.svelte | 17 ++- src/lib/shared/components/ListRow.svelte | 16 +- src/lib/shared/components/ListView.svelte | 4 +- src/lib/shared/components/PreviewPanel.svelte | 15 +- src/lib/shared/platform.ts | 5 +- src/lib/shared/time.test.ts | 141 ++++++++++++++++-- src/lib/shared/time.ts | 106 +++++++++++-- 25 files changed, 475 insertions(+), 68 deletions(-) create mode 100644 src/lib/platforms/battle-net/adapter.test.ts create mode 100644 src/lib/platforms/genericAdapter.test.ts create mode 100644 src/lib/platforms/riot/adapter.test.ts create mode 100644 src/lib/platforms/roblox/adapter.test.ts create mode 100644 src/lib/platforms/steam/adapter.test.ts create mode 100644 src/lib/platforms/ubisoft/adapter.test.ts diff --git a/src/lib/app/AppWorkspace.svelte b/src/lib/app/AppWorkspace.svelte index 910ccc9..90e91fb 100644 --- a/src/lib/app/AppWorkspace.svelte +++ b/src/lib/app/AppWorkspace.svelte @@ -254,7 +254,7 @@ showNoteInline={bulkEditMode ? false : showCardNotesInline} showUsername={isPendingSetupAccount(account.id) ? false : showUsernames} showLastLogin={isPendingSetupAccount(account.id) ? false : showLastLogin} - lastLoginAt={account.lastLoginAt} + lastLoginAtSec={account.lastLoginAtSec} {lastLoginUnknownKey} {locale} isActive={!bulkEditMode && account.id === currentAccountId} diff --git a/src/lib/app/platformAddFlow.svelte.ts b/src/lib/app/platformAddFlow.svelte.ts index d26143a..ec113a9 100644 --- a/src/lib/app/platformAddFlow.svelte.ts +++ b/src/lib/app/platformAddFlow.svelte.ts @@ -98,7 +98,7 @@ export function createPlatformAddFlowController({ username: detectedName ? t(getSetupKey(flow.platformId, "connected")) : t(getSetupKey(flow.platformId, "waitingForLogin")), - lastLoginAt: null, + lastLoginAtSec: null, } satisfies PlatformAccount; }); diff --git a/src/lib/app/useOnboardingTour.svelte.ts b/src/lib/app/useOnboardingTour.svelte.ts index 047afb4..b7725a8 100644 --- a/src/lib/app/useOnboardingTour.svelte.ts +++ b/src/lib/app/useOnboardingTour.svelte.ts @@ -39,7 +39,7 @@ export function createOnboardingTour({ t, getActiveTab, setActiveTab }: Onboardi id, displayName: t("onboarding.features.mockAccount", { number: index + 1 }), username: `account_${index + 1}`, - lastLoginAt: null, + lastLoginAtSec: null, })), ); const mockItems = $derived( diff --git a/src/lib/platforms/battle-net/adapter.test.ts b/src/lib/platforms/battle-net/adapter.test.ts new file mode 100644 index 0000000..f14fa32 --- /dev/null +++ b/src/lib/platforms/battle-net/adapter.test.ts @@ -0,0 +1,26 @@ +import { describe, it, expect } from "vitest"; +import { toBattleNetAccount } from "./adapter"; + +describe("battle.net account mapping", () => { + it("converts the backend millisecond stamp to seconds", () => { + const account = toBattleNetAccount({ + email: "player@example.com", + battleTag: "Player#1234", + lastLoginAt: 1_760_000_000_999, + }); + expect(account.lastLoginAtSec).toBe(1_760_000_000); + }); + + it("keeps a missing stamp as null", () => { + expect(toBattleNetAccount({ email: "player@example.com" }).lastLoginAtSec).toBeNull(); + expect( + toBattleNetAccount({ email: "player@example.com", lastLoginAt: null }).lastLoginAtSec, + ).toBeNull(); + }); + + it("prefers the battle tag name over the email local part", () => { + const account = toBattleNetAccount({ email: "player@example.com", battleTag: "Nova#1234" }); + expect(account.displayName).toBe("Nova"); + expect(account.id).toBe("player@example.com"); + }); +}); diff --git a/src/lib/platforms/battle-net/adapter.ts b/src/lib/platforms/battle-net/adapter.ts index 153e711..4f8878a 100644 --- a/src/lib/platforms/battle-net/adapter.ts +++ b/src/lib/platforms/battle-net/adapter.ts @@ -1,9 +1,14 @@ import type { PlatformAccount } from "$lib/shared/platform"; import { createGenericAdapter } from "$lib/platforms/genericAdapter"; +import { unixMsToSeconds } from "$lib/shared/time"; -interface BattleNetAccount { +/** Backend payload of `platform_get_accounts` for Battle.net. Field names are + * the serde camelCase of `BattleNetAccount` in battle_net.rs. */ +export interface BattleNetRawAccount { email: string; battleTag?: string; + /** Unix MILLISECONDS (`now_unix_ms`), despite the wire name. Renaming it + * would break the mapping, so `toBattleNetAccount` converts instead. */ lastLoginAt?: number | null; } @@ -13,7 +18,7 @@ function getBattleNetDisplayName(email: string): string { return candidate || trimmed; } -function getBattleNetLabel(account: BattleNetAccount): string { +function getBattleNetLabel(account: BattleNetRawAccount): string { const battleTag = (account.battleTag ?? "").trim(); if (battleTag) { return battleTag.split("#")[0]?.trim() || battleTag; @@ -21,12 +26,12 @@ function getBattleNetLabel(account: BattleNetAccount): string { return getBattleNetDisplayName(account.email); } -function toAccount(account: BattleNetAccount): PlatformAccount { +export function toBattleNetAccount(account: BattleNetRawAccount): PlatformAccount { return { id: account.email, displayName: getBattleNetLabel(account), username: "", - lastLoginAt: account.lastLoginAt ?? null, + lastLoginAtSec: unixMsToSeconds(account.lastLoginAt), }; } @@ -36,11 +41,11 @@ function maskEmail(email: string): string { return `${local.slice(0, 3)}…`; } -export const battleNetAdapter = createGenericAdapter({ +export const battleNetAdapter = createGenericAdapter({ id: "battle-net", i18nPrefix: "battlenet", noAccountsToastKey: "toast.noBattleNetAccountsFound", - toAccount, + toAccount: toBattleNetAccount, supportsAccountLabels: false, maskSwitchLogId: maskEmail, copyItems: (account) => { diff --git a/src/lib/platforms/genericAdapter.test.ts b/src/lib/platforms/genericAdapter.test.ts new file mode 100644 index 0000000..e145d03 --- /dev/null +++ b/src/lib/platforms/genericAdapter.test.ts @@ -0,0 +1,27 @@ +import { describe, it, expect } from "vitest"; +import { defaultToAccount } from "./genericAdapter"; + +// The descriptor platforms (GOG, Jagex, Epic, Discord, Ubisoft) all stamp +// `platforms::now_unix_ms` on the Rust side, so the mapping owes the UI seconds. +describe("generic adapter account mapping", () => { + it("converts the backend millisecond stamp to seconds", () => { + const account = defaultToAccount({ + accountId: "acct-1", + label: "main", + lastUsedAt: 1_760_000_000_999, + }); + expect(account.lastLoginAtSec).toBe(1_760_000_000); + }); + + it("keeps a missing stamp as null", () => { + expect(defaultToAccount({ accountId: "acct-1", label: "main" }).lastLoginAtSec).toBeNull(); + expect( + defaultToAccount({ accountId: "acct-1", label: "main", lastUsedAt: null }).lastLoginAtSec, + ).toBeNull(); + }); + + it("falls back to the account id when the label is empty", () => { + const account = defaultToAccount({ accountId: "acct-1", label: "" }); + expect(account.displayName).toBe("acct-1"); + }); +}); diff --git a/src/lib/platforms/genericAdapter.ts b/src/lib/platforms/genericAdapter.ts index cd39a46..def5c1c 100644 --- a/src/lib/platforms/genericAdapter.ts +++ b/src/lib/platforms/genericAdapter.ts @@ -11,12 +11,15 @@ import { } from "$lib/shared/contextMenu/platformMenuBuilder"; import { createPlatformAddFlowHandlers } from "$lib/platforms/addFlow"; import { createPlatformApi } from "$lib/platforms/platformApi"; +import { unixMsToSeconds } from "$lib/shared/time"; /** Raw account shape shared by the simple snapshot-based platforms * (GOG, Jagex, Discord, Epic). Custom shapes supply their own `toAccount`. */ export interface GenericRawAccount { accountId: string; label: string; + /** Unix MILLISECONDS: the backend stamps these with `platforms::now_unix_ms`. + * `toAccount` divides before the value reaches the UI. */ lastUsedAt?: number | null; snapshotSaved?: boolean; } @@ -51,12 +54,12 @@ export interface GenericAdapterConfig { maskSwitchLogId?: (accountId: string) => string; } -function defaultToAccount(raw: GenericRawAccount): PlatformAccount { +export function defaultToAccount(raw: GenericRawAccount): PlatformAccount { return { id: raw.accountId, displayName: raw.label || raw.accountId, username: raw.accountId, - lastLoginAt: raw.lastUsedAt ?? null, + lastLoginAtSec: unixMsToSeconds(raw.lastUsedAt), }; } diff --git a/src/lib/platforms/riot/adapter.test.ts b/src/lib/platforms/riot/adapter.test.ts new file mode 100644 index 0000000..1a7bfc1 --- /dev/null +++ b/src/lib/platforms/riot/adapter.test.ts @@ -0,0 +1,29 @@ +import { describe, it, expect } from "vitest"; +import { toRiotAccount } from "./adapter"; + +describe("riot account mapping", () => { + it("converts the backend millisecond stamp to seconds", () => { + const account = toRiotAccount({ + id: "riot-1", + label: "main", + snapshot_state: "ready", + last_used_at: 1_760_000_000_999, + }); + expect(account.lastLoginAtSec).toBe(1_760_000_000); + }); + + it("falls back to the capture stamp, also in milliseconds", () => { + const account = toRiotAccount({ + id: "riot-1", + label: "main", + snapshot_state: "ready", + last_captured_at: 1_759_000_000_500, + }); + expect(account.lastLoginAtSec).toBe(1_759_000_000); + }); + + it("keeps a missing stamp as null", () => { + const account = toRiotAccount({ id: "riot-1", label: "main", snapshot_state: "ready" }); + expect(account.lastLoginAtSec).toBeNull(); + }); +}); diff --git a/src/lib/platforms/riot/adapter.ts b/src/lib/platforms/riot/adapter.ts index 15b5685..70a8fb8 100644 --- a/src/lib/platforms/riot/adapter.ts +++ b/src/lib/platforms/riot/adapter.ts @@ -11,6 +11,7 @@ import { rememberRiotProfiles } from "./accountCache"; import { getRiotContextMenuItems } from "./contextMenu"; import { getRiotProfile } from "./profile"; import type { RiotProfile } from "./types"; +import { unixMsToSeconds } from "$lib/shared/time"; function getRiotAlias(profile: RiotProfile): string { const name = (profile.account_name ?? "").trim(); @@ -45,13 +46,13 @@ function profileSecondaryLabel(profile: RiotProfile): string { return `${label} · ${status}`; } -function toAccount(profile: RiotProfile): PlatformAccount { +export function toRiotAccount(profile: RiotProfile): PlatformAccount { const lastLoginUnixMs = profile.last_used_at ?? profile.last_captured_at ?? null; return { id: profile.id, displayName: getRiotAlias(profile) || profile.label, username: profileSecondaryLabel(profile), - lastLoginAt: lastLoginUnixMs ? Math.floor(lastLoginUnixMs / 1000) : null, + lastLoginAtSec: unixMsToSeconds(lastLoginUnixMs), }; } @@ -66,7 +67,7 @@ export const riotAdapter: PlatformAdapter = { async loadAccounts(): Promise { const profiles = await service.getProfiles(); rememberRiotProfiles(profiles); - return profiles.map(toAccount); + return profiles.map(toRiotAccount); }, async getCurrentAccount(): Promise { @@ -77,7 +78,7 @@ export const riotAdapter: PlatformAdapter = { const snapshot = await service.getStartupSnapshot(); rememberRiotProfiles(snapshot.profiles); return { - accounts: snapshot.profiles.map(toAccount), + accounts: snapshot.profiles.map(toRiotAccount), currentAccount: snapshot.currentProfile, }; }, diff --git a/src/lib/platforms/riot/types.ts b/src/lib/platforms/riot/types.ts index 9e5c5f9..d7f1f7e 100644 --- a/src/lib/platforms/riot/types.ts +++ b/src/lib/platforms/riot/types.ts @@ -6,7 +6,9 @@ export interface RiotProfile { account_name?: string; account_tag_line?: string; snapshot_state: RiotSnapshotState | string; + /** Unix MILLISECONDS (`platforms::now_unix_ms`). */ last_captured_at?: number | null; + /** Unix MILLISECONDS (`platforms::now_unix_ms`). */ last_used_at?: number | null; } diff --git a/src/lib/platforms/roblox/adapter.test.ts b/src/lib/platforms/roblox/adapter.test.ts new file mode 100644 index 0000000..3dd9adb --- /dev/null +++ b/src/lib/platforms/roblox/adapter.test.ts @@ -0,0 +1,19 @@ +import { describe, it, expect } from "vitest"; +import { toRobloxAccount } from "./adapter"; + +describe("roblox account mapping", () => { + it("converts the backend millisecond stamp to seconds", () => { + const account = toRobloxAccount({ + userId: "1234", + username: "player", + displayName: "Player", + lastLoginAt: 1_760_000_000_999, + }); + expect(account.lastLoginAtSec).toBe(1_760_000_000); + }); + + it("keeps a missing stamp as null", () => { + const account = toRobloxAccount({ userId: "1234", username: "player", displayName: "Player" }); + expect(account.lastLoginAtSec).toBeNull(); + }); +}); diff --git a/src/lib/platforms/roblox/adapter.ts b/src/lib/platforms/roblox/adapter.ts index e599563..016842c 100644 --- a/src/lib/platforms/roblox/adapter.ts +++ b/src/lib/platforms/roblox/adapter.ts @@ -17,17 +17,18 @@ import { } from "./warnings"; import type { RobloxAccount } from "./types"; import { isSafeHttpUrl } from "$lib/shared/url"; +import { unixMsToSeconds } from "$lib/shared/time"; // Stable backend wording from request_auth_ticket (roblox.rs) when the stored // .ROBLOSECURITY cookie is rejected server-side. const SESSION_EXPIRED_PATTERN = /auth ticket request failed \(http 401/i; -function toAccount(account: RobloxAccount): PlatformAccount { +export function toRobloxAccount(account: RobloxAccount): PlatformAccount { return { id: account.userId, displayName: account.displayName || account.username, username: account.username, - lastLoginAt: account.lastLoginAt ? Math.floor(account.lastLoginAt / 1000) : null, + lastLoginAtSec: unixMsToSeconds(account.lastLoginAt), }; } @@ -52,7 +53,7 @@ export const robloxAdapter: PlatformAdapter = { async loadAccounts(): Promise { const accounts = await service.getAccounts(); - return accounts.map(toAccount); + return accounts.map(toRobloxAccount); }, async getCurrentAccount(): Promise { @@ -62,7 +63,7 @@ export const robloxAdapter: PlatformAdapter = { async getStartupSnapshot() { const snapshot = await service.getStartupSnapshot(); return { - accounts: snapshot.accounts.map(toAccount), + accounts: snapshot.accounts.map(toRobloxAccount), currentAccount: snapshot.currentAccount, }; }, diff --git a/src/lib/platforms/roblox/types.ts b/src/lib/platforms/roblox/types.ts index 70d84ee..a546173 100644 --- a/src/lib/platforms/roblox/types.ts +++ b/src/lib/platforms/roblox/types.ts @@ -1,7 +1,11 @@ +/** Backend payload of `roblox_get_accounts`. Field names are the serde + * camelCase of `RobloxAccount` in roblox.rs. */ export interface RobloxAccount { userId: string; username: string; displayName: string; + /** Unix MILLISECONDS (`now_unix_ms`), despite the wire name. Renaming it + * would break the mapping, so `toRobloxAccount` converts instead. */ lastLoginAt?: number | null; } diff --git a/src/lib/platforms/steam/adapter.test.ts b/src/lib/platforms/steam/adapter.test.ts new file mode 100644 index 0000000..941a6a4 --- /dev/null +++ b/src/lib/platforms/steam/adapter.test.ts @@ -0,0 +1,25 @@ +import { describe, it, expect } from "vitest"; +import { toSteamAccount } from "./adapter"; + +// Steam is the exception: loginusers.vdf already holds Unix seconds, so the +// mapping passes the value through untouched. +describe("steam account mapping", () => { + it("passes the seconds stamp through unchanged", () => { + const account = toSteamAccount({ + steam_id: "76561198000000001", + account_name: "player", + persona_name: "Player", + last_login_at: 1_760_000_000, + }); + expect(account.lastLoginAtSec).toBe(1_760_000_000); + }); + + it("keeps a missing stamp as null", () => { + const account = toSteamAccount({ + steam_id: "76561198000000001", + account_name: "player", + persona_name: "Player", + }); + expect(account.lastLoginAtSec).toBeNull(); + }); +}); diff --git a/src/lib/platforms/steam/adapter.ts b/src/lib/platforms/steam/adapter.ts index c23e1e3..92f74dd 100644 --- a/src/lib/platforms/steam/adapter.ts +++ b/src/lib/platforms/steam/adapter.ts @@ -13,12 +13,14 @@ import { getCachedSteamWarningStates, loadSteamWarningStates } from "./warnings" import type { ProfileInfo, SteamAccount } from "./types"; import { isSafeHttpUrl } from "$lib/shared/url"; -function toAccount(s: SteamAccount): PlatformAccount { +/** Steam is the one platform whose stored stamp is already Unix SECONDS: it + * comes straight out of loginusers.vdf, not from `now_unix_ms`. */ +export function toSteamAccount(s: SteamAccount): PlatformAccount { return { id: s.steam_id, displayName: s.persona_name, username: s.account_name, - lastLoginAt: s.last_login_at ?? null, + lastLoginAtSec: s.last_login_at ?? null, }; } @@ -46,7 +48,7 @@ export const steamAdapter: PlatformAdapter = { async loadAccounts(): Promise { const accounts = await service.getAccounts(); - return accounts.map(toAccount); + return accounts.map(toSteamAccount); }, async getCurrentAccount(): Promise { @@ -56,7 +58,7 @@ export const steamAdapter: PlatformAdapter = { async getStartupSnapshot() { const snapshot = await service.getStartupSnapshot(); return { - accounts: snapshot.accounts.map(toAccount), + accounts: snapshot.accounts.map(toSteamAccount), currentAccount: snapshot.currentAccount, }; }, diff --git a/src/lib/platforms/steam/types.ts b/src/lib/platforms/steam/types.ts index 0d1b045..8444ddb 100644 --- a/src/lib/platforms/steam/types.ts +++ b/src/lib/platforms/steam/types.ts @@ -17,6 +17,8 @@ export interface SteamAccount { steam_id: string; account_name: string; persona_name: string; + /** Unix SECONDS: read verbatim out of Steam's loginusers.vdf, the one + * platform whose stamp is not `now_unix_ms`. */ last_login_at?: number | null; } diff --git a/src/lib/platforms/ubisoft/adapter.test.ts b/src/lib/platforms/ubisoft/adapter.test.ts new file mode 100644 index 0000000..e5eef58 --- /dev/null +++ b/src/lib/platforms/ubisoft/adapter.test.ts @@ -0,0 +1,32 @@ +import { describe, it, expect } from "vitest"; +import { toUbisoftAccount } from "./adapter"; + +describe("ubisoft account mapping", () => { + it("converts the backend millisecond stamp to seconds", () => { + const account = toUbisoftAccount({ + uuid: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + label: "main", + lastUsedAt: 1_760_000_000_999, + snapshotSaved: true, + }); + expect(account.lastLoginAtSec).toBe(1_760_000_000); + }); + + it("keeps a missing stamp as null", () => { + const account = toUbisoftAccount({ + uuid: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + label: "main", + snapshotSaved: false, + }); + expect(account.lastLoginAtSec).toBeNull(); + }); + + it("shortens the uuid when there is no label", () => { + const account = toUbisoftAccount({ + uuid: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + label: "", + snapshotSaved: false, + }); + expect(account.displayName).toBe("aaaaaaaa"); + }); +}); diff --git a/src/lib/platforms/ubisoft/adapter.ts b/src/lib/platforms/ubisoft/adapter.ts index f23559c..0a608de 100644 --- a/src/lib/platforms/ubisoft/adapter.ts +++ b/src/lib/platforms/ubisoft/adapter.ts @@ -1,33 +1,36 @@ import type { PlatformAccount } from "$lib/shared/platform"; import { createGenericAdapter } from "$lib/platforms/genericAdapter"; +import { unixMsToSeconds } from "$lib/shared/time"; -interface UbisoftAccount { +/** Backend payload of `platform_get_accounts` for Ubisoft Connect. */ +export interface UbisoftRawAccount { uuid: string; label: string; + /** Unix MILLISECONDS (`platforms::now_unix_ms`). */ lastUsedAt?: number | null; snapshotSaved: boolean; } -function getDisplayName(account: UbisoftAccount): string { +function getDisplayName(account: UbisoftRawAccount): string { const label = (account.label ?? "").trim(); if (label) return label; // Shorten UUID for display: first 8 chars return account.uuid.split("-")[0] ?? account.uuid; } -function toAccount(account: UbisoftAccount): PlatformAccount { +export function toUbisoftAccount(account: UbisoftRawAccount): PlatformAccount { return { id: account.uuid, displayName: getDisplayName(account), username: "", - lastLoginAt: account.lastUsedAt ?? null, + lastLoginAtSec: unixMsToSeconds(account.lastUsedAt), }; } -export const ubisoftAdapter = createGenericAdapter({ +export const ubisoftAdapter = createGenericAdapter({ id: "ubisoft", noAccountsToastKey: "toast.noUbisoftAccountsFound", - toAccount, + toAccount: toUbisoftAccount, copyItems: (account) => [ { field: "uuid", diff --git a/src/lib/shared/components/AccountCard.svelte b/src/lib/shared/components/AccountCard.svelte index ec129c4..05a9b28 100644 --- a/src/lib/shared/components/AccountCard.svelte +++ b/src/lib/shared/components/AccountCard.svelte @@ -5,7 +5,10 @@ import type { AccountUsernameBadge, CardExtensionContent } from "$lib/shared/cardExtension"; import { hasCardExtensionContent } from "$lib/shared/cardExtension"; import CardExtensionPanel from "./CardExtensionPanel.svelte"; - import { formatRelativeTimeCompact } from "$lib/shared/time"; + import { + formatAbsoluteDateTimeFromUnixSeconds, + formatRelativeTimeCompact, + } from "$lib/shared/time"; import { getAvatarGradientStyle, getAvatarInitials, getAvatarSeed } from "$lib/shared/avatarFallback"; import { fadeInOnLoad } from "$lib/shared/avatarFadeIn"; import { DEFAULT_LOCALE, translate, type Locale, type MessageKey } from "$lib/i18n"; @@ -32,7 +35,7 @@ showNoteInline = false, showLastLogin = false, lastLoginUnknownKey = "time.unknown", - lastLoginAt = null, + lastLoginAtSec = null, note = "", usernameBadge = null, singleClickSwitch = false, @@ -63,7 +66,7 @@ showNoteInline?: boolean; showLastLogin?: boolean; lastLoginUnknownKey?: MessageKey; - lastLoginAt?: number | null; + lastLoginAtSec?: number | null; note?: string; usernameBadge?: AccountUsernameBadge | null; isSwitching?: boolean; @@ -91,6 +94,12 @@ const EXTENSION_DETAIL_WIDTH_PX = 130; const EXTENSION_VIEWPORT_GAP_PX = 12; const noteText = $derived(note.trim()); + const lastLoginLabel = $derived( + formatRelativeTimeCompact(lastLoginAtSec, locale, lastLoginUnknownKey) + ); + // Empty when the timestamp is unusable, which drops the attribute rather + // than showing an empty tooltip. + const lastLoginTitle = $derived(formatAbsoluteDateTimeFromUnixSeconds(lastLoginAtSec, locale)); const hasUsername = $derived(Boolean(showUsername && account.username.trim())); const hasRedWarning = $derived(warningInfo?.cardOutlineTone === "red"); const hasOrangeWarning = $derived(warningInfo?.cardOutlineTone === "orange"); @@ -413,7 +422,7 @@
{noteText}
{/if} {#if showLastLogin} - + {/if} {/if} diff --git a/src/lib/shared/components/ListRow.svelte b/src/lib/shared/components/ListRow.svelte index c1e4b42..9499aed 100644 --- a/src/lib/shared/components/ListRow.svelte +++ b/src/lib/shared/components/ListRow.svelte @@ -2,7 +2,10 @@ import type { PlatformAccount } from "../platform"; import type { AccountWarningPresentation } from "../accountWarnings"; import type { FolderInfo } from "../../features/folders/types"; - import { formatRelativeTimeCompact } from "$lib/shared/time"; + import { + formatAbsoluteDateTimeFromUnixSeconds, + formatRelativeTimeCompact, + } from "$lib/shared/time"; import { getAvatarGradientStyle, getAvatarInitials, getAvatarSeed } from "$lib/shared/avatarFallback"; import { fadeInOnLoad } from "$lib/shared/avatarFadeIn"; import { DEFAULT_LOCALE, translate, type Locale, type MessageKey } from "$lib/i18n"; @@ -25,7 +28,7 @@ showUsername = true, showLastLogin = false, lastLoginUnknownKey = "time.unknown", - lastLoginAt = null, + lastLoginAtSec = null, accentColor = "#3b82f6", locale = DEFAULT_LOCALE, onClick, @@ -51,7 +54,7 @@ showUsername?: boolean; showLastLogin?: boolean; lastLoginUnknownKey?: MessageKey; - lastLoginAt?: number | null; + lastLoginAtSec?: number | null; accentColor?: string; locale?: Locale; onClick: () => void; @@ -72,6 +75,11 @@ onClick(); } + let lastLoginLabel = $derived(formatRelativeTimeCompact(lastLoginAtSec, locale, lastLoginUnknownKey)); + // Empty when the timestamp is unusable, which drops the attribute rather + // than showing an empty tooltip. + let lastLoginTitle = $derived(formatAbsoluteDateTimeFromUnixSeconds(lastLoginAtSec, locale)); + let hasRedWarning = $derived(Boolean(warningInfo?.listHasRed)); let hasOrangeWarning = $derived(Boolean(warningInfo?.listHasOrange)); @@ -164,7 +172,7 @@ {account.username} {/if} {#if showLastLogin} - {formatRelativeTimeCompact(lastLoginAt, locale, lastLoginUnknownKey)} + {lastLoginLabel} {/if} {/if} diff --git a/src/lib/shared/components/ListView.svelte b/src/lib/shared/components/ListView.svelte index 6d779ac..bc88938 100644 --- a/src/lib/shared/components/ListView.svelte +++ b/src/lib/shared/components/ListView.svelte @@ -145,7 +145,7 @@ showUsername={showUsernames} {showLastLogin} {lastLoginUnknownKey} - lastLoginAt={account.lastLoginAt} + lastLoginAtSec={account.lastLoginAtSec} isActive={account.id === currentAccountId} isSelected={selectedAccountId === account.id} avatarUrl={avatarState?.url} @@ -258,7 +258,7 @@ showUsername={showUsernames} {showLastLogin} {lastLoginUnknownKey} - lastLoginAt={selectedAccount.lastLoginAt} + lastLoginAtSec={selectedAccount.lastLoginAtSec} isActive={selectedAccount.id === currentAccountId} avatarUrl={selectedAvatarState?.url} isLoadingAvatar={selectedIsPendingSetup || (selectedAvatarState?.loading ?? false)} diff --git a/src/lib/shared/components/PreviewPanel.svelte b/src/lib/shared/components/PreviewPanel.svelte index bc755b0..440db81 100644 --- a/src/lib/shared/components/PreviewPanel.svelte +++ b/src/lib/shared/components/PreviewPanel.svelte @@ -1,7 +1,10 @@