diff --git a/src/canvas.rs b/src/canvas.rs index 1a743a2..79b692a 100644 --- a/src/canvas.rs +++ b/src/canvas.rs @@ -1021,8 +1021,13 @@ impl CanvasState { } if page.vertex_cache.len() > self.committed_vertex_capacity { - self.committed_vertex_capacity = - (page.vertex_cache.len() * 2).max(Self::INITIAL_COMMITTED_CAPACITY); + let vertex_size = std::mem::size_of::() as u64; + let max_vertices = (self.device.limits().max_buffer_size / vertex_size) as usize; + self.committed_vertex_capacity = grown_vertex_capacity( + page.vertex_cache.len(), + Self::INITIAL_COMMITTED_CAPACITY, + max_vertices, + ); self.committed_vertex_buffer = self.device.create_buffer(&wgpu::BufferDescriptor { label: Some("Committed Strokes Vertex Buffer (Reallocated)"), size: (self.committed_vertex_capacity * std::mem::size_of::()) as u64, @@ -1735,10 +1740,32 @@ fn text_resolution(screen_px_per_unit: f32) -> f32 { 2.0_f32.powf(steps / 4.0).clamp(0.125, 8.0) } +/// Capacity for a vertex buffer that has to hold `needed` vertices: twice as much, so that +/// it is not reallocated with every stroke, but no more than the device allows. Doubling +/// alone asked for a buffer beyond the limit as soon as a very full page used half of it, +/// which ends in a validation panic. +fn grown_vertex_capacity(needed: usize, initial: usize, max_vertices: usize) -> usize { + needed + .saturating_mul(2) + .max(initial) + .min(max_vertices) + .max(needed) +} + #[cfg(test)] mod tests { use super::*; + #[test] + fn test_vertex_capacity_doubles_up_to_the_device_limit() { + assert_eq!(grown_vertex_capacity(10, 1000, 1_000_000), 1000); + assert_eq!(grown_vertex_capacity(5000, 1000, 1_000_000), 10_000); + // More than half the limit: the limit, not twice the need + assert_eq!(grown_vertex_capacity(600_000, 1000, 1_000_000), 1_000_000); + // Never less than what is needed + assert_eq!(grown_vertex_capacity(2_000_000, 1000, 1_000_000), 2_000_000); + } + #[test] fn test_text_resolution_is_quantized_and_never_below_screen() { for px in [0.2_f32, 0.27, 0.5, 1.3, 3.9] { diff --git a/src/cloud_sync.rs b/src/cloud_sync.rs index bcb7ed8..3754e42 100644 --- a/src/cloud_sync.rs +++ b/src/cloud_sync.rs @@ -953,6 +953,16 @@ struct Shared { /// Finished sign-in, to be stored by the app signed_in: Option, stop: bool, + /// Counts the started workers. A worker whose pass was still running when the next one + /// was started must end afterwards, although `stop` is `false` again by then. + generation: u64, +} + +impl Shared { + /// Is the worker of `generation` to end? + fn retired(&self, generation: u64) -> bool { + self.stop || self.generation != generation + } } type SharedState = Arc<(Mutex, Condvar)>; @@ -986,6 +996,7 @@ impl CloudSync { changed: false, signed_in: None, stop: false, + generation: 0, }), Condvar::new(), )), @@ -1009,15 +1020,19 @@ impl CloudSync { fn start(&mut self, account: Account) { self.stop_worker(); - { + let generation = { let mut shared = lock(&self.shared); shared.stop = false; + shared.generation += 1; shared.status = Status::Idle { last: None }; shared.next_pass = Instant::now(); - } + shared.generation + }; let shared = self.shared.clone(); let worker_account = account.clone(); - self.worker = Some(std::thread::spawn(move || worker(shared, worker_account))); + self.worker = Some(std::thread::spawn(move || { + worker(shared, worker_account, generation) + })); self.account = Some(account); } @@ -1211,7 +1226,7 @@ fn sign_in( } } -fn worker(shared: SharedState, account: Account) { +fn worker(shared: SharedState, account: Account, generation: u64) { let Some(state_path) = SyncState::file_path() else { set_status(&shared, Status::Failed(t!("Kein Datenordner").to_string())); return; @@ -1228,7 +1243,7 @@ fn worker(shared: SharedState, account: Account) { let (dir, ctx) = { let mut guard = lock(&shared); loop { - if guard.stop { + if guard.retired(generation) { return; } let now = Instant::now(); @@ -1252,7 +1267,7 @@ fn worker(shared: SharedState, account: Account) { eprintln!("[Nextcloud] Sync-Stand nicht gespeichert: {err}"); } let mut guard = lock(&shared); - if guard.stop { + if guard.retired(generation) { return; } guard.status = match result { @@ -1282,6 +1297,27 @@ mod tests { use super::*; use std::collections::HashMap; + #[test] + fn worker_of_an_earlier_start_is_retired() { + let mut shared = Shared { + status: Status::Off, + local_dir: PathBuf::new(), + ctx: Context::default(), + next_pass: Instant::now(), + changed: false, + signed_in: None, + stop: false, + generation: 1, + }; + assert!(!shared.retired(1)); + // Stopped and started again while the pass of worker 1 was still running + shared.generation = 2; + assert!(shared.retired(1)); + assert!(!shared.retired(2)); + shared.stop = true; + assert!(shared.retired(2)); + } + /// Server folder in memory; folder ETags change with anything inside, like Nextcloud's. #[derive(Default)] struct FakeRemote { diff --git a/src/i18n/en.rs b/src/i18n/en.rs index f7bd556..4861359 100644 --- a/src/i18n/en.rs +++ b/src/i18n/en.rs @@ -179,6 +179,10 @@ pub const EN: &[(&str, &str)] = &[ ("Kariert", "Grid"), ("KAROS, LINIEN UND PUNKTE", "GRID, LINES AND DOTS"), ("kein Asset-Ordner", "no asset folder"), + ( + "'{asset_name}' ist keine neuere Version und wird nicht installiert.", + "'{asset_name}' is not a newer version and will not be installed.", + ), ("Kein Eintrag für '{asset_name}' in SHA256SUMS.txt gefunden. Heruntergeladene Datei wurde gelöscht.", "No entry for '{asset_name}' found in SHA256SUMS.txt. The downloaded file was deleted."), ("Kein Release ausgewählt. Download abgelehnt.", "No release selected. Download refused."), ("Keine manifest.json vorhanden", "No manifest.json present"), diff --git a/src/settings.rs b/src/settings.rs index 10de737..6d915bd 100644 --- a/src/settings.rs +++ b/src/settings.rs @@ -302,15 +302,35 @@ impl UiSettings { crate::paths::config_dir().map(|d| d.join("ui.json")) } - /// Lädt die Einstellungen; bei fehlender oder beschädigter Datei gelten Standardwerte. + /// Keeps every setting of a partly invalid file that can still be read; only the + /// unreadable ones get their default. One bad value must not reset everything, least of + /// all the storage location. + fn salvage(content: &str) -> Self { + let Ok(serde_json::Value::Object(file)) = serde_json::from_str(content) else { + return Self::default(); + }; + let mut good = serde_json::Map::new(); + for (key, value) in file { + good.insert(key.clone(), value); + let candidate = serde_json::Value::Object(good.clone()); + if serde_json::from_value::(candidate).is_err() { + eprintln!("[Einstellungen] '{key}' ist ungültig, verwende Standardwert."); + good.remove(&key); + } + } + serde_json::from_value(serde_json::Value::Object(good)).unwrap_or_default() + } + + /// Lädt die Einstellungen; bei fehlender Datei gelten Standardwerte, bei einer teilweise + /// ungültigen Datei nur für die unlesbaren Werte. pub fn load_from(path: &Path) -> Self { let mut settings: Self = match std::fs::read_to_string(path) { Ok(content) => serde_json::from_str(&content).unwrap_or_else(|e| { eprintln!( - "[Einstellungen] '{}' ist ungültig ({e}), verwende Standardwerte.", + "[Einstellungen] '{}' ist ungültig ({e}), unlesbare Werte werden ersetzt.", path.display() ); - Self::default() + Self::salvage(&content) }), Err(_) => Self::default(), }; @@ -437,6 +457,27 @@ mod tests { assert_eq!(UiSettings::load_from(&path), UiSettings::default()); } + #[test] + fn test_one_invalid_value_does_not_reset_the_other_settings() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("ui.json"); + std::fs::write( + &path, + br#"{"storage_dir": "/home/pia/Nextcloud/ink-notes", "eraser_radius_mm": "big", "math_assist": true}"#, + ) + .unwrap(); + let loaded = UiSettings::load_from(&path); + assert_eq!( + loaded.storage_dir, + Some(PathBuf::from("/home/pia/Nextcloud/ink-notes")) + ); + assert!(loaded.math_assist); + assert_eq!( + loaded.eraser_radius_mm, + UiSettings::default().eraser_radius_mm + ); + } + #[test] fn test_seven_pens_stay_readable_for_older_versions() { // Versions before 0.44 read `pen_presets` as exactly four pens (Issue #56) diff --git a/src/updater.rs b/src/updater.rs index 9e7b6dc..8a7aad0 100644 --- a/src/updater.rs +++ b/src/updater.rs @@ -272,6 +272,22 @@ pub fn is_newer_version(remote_tag: &str, current_tag: &str, include_prereleases } } +/// Version in a release file name (`ink-notes__.`). +pub fn asset_version(asset_name: &str) -> Option<&str> { + let rest = asset_name.strip_prefix("ink-notes_")?; + let (version, _) = rest.split_once('_')?; + parse_semver(version).map(|_| version) +} + +/// Is the package named `asset_name` newer than the running version `current`? +/// +/// The signature only covers file names and hashes, not the release they are offered in. A +/// server could therefore present an old, correctly signed package under a new version +/// number. The version in the signed file name closes that gap. +pub fn asset_is_upgrade(asset_name: &str, current: &str) -> bool { + asset_version(asset_name).is_some_and(|version| is_newer_version(version, current, true)) +} + /// Berechnet den hex-kodierten SHA-256 Hash einer Datei. pub fn compute_sha256(path: &Path) -> Result { let mut file = std::fs::File::open(path).map_err(|e| { @@ -712,6 +728,14 @@ impl UpdaterController { } }; + if !asset_is_upgrade(&safe_asset_name, env!("CARGO_PKG_VERSION")) { + self.status = UpdaterStatus::Error(t!( + "'{asset_name}' ist keine neuere Version und wird nicht installiert.", + asset_name = safe_asset_name + )); + return; + } + self.status = UpdaterStatus::Downloading { asset_name: safe_asset_name.clone(), percent: 0.0, @@ -1036,6 +1060,23 @@ mod tests { assert!(is_newer_version("1.0.0-rc1", "0.9.0", true)); } + #[test] + fn test_only_packages_named_as_newer_are_upgrades() { + assert_eq!(asset_version("ink-notes_0.46.0_amd64.deb"), Some("0.46.0")); + assert_eq!( + asset_version("ink-notes_0.46.0_x86_64.AppImage"), + Some("0.46.0") + ); + assert_eq!(asset_version("SHA256SUMS.txt"), None); + assert_eq!(asset_version("ink-notes_latest_amd64.deb"), None); + + assert!(asset_is_upgrade("ink-notes_0.46.0_arm64.apk", "0.45.0")); + // An old release offered again under a new tag + assert!(!asset_is_upgrade("ink-notes_0.30.0_amd64.deb", "0.45.0")); + assert!(!asset_is_upgrade("ink-notes_0.45.0_x86_64.exe", "0.45.0")); + assert!(!asset_is_upgrade("something_else.deb", "0.45.0")); + } + #[test] fn test_sanitize_asset_name() { assert_eq!(