From 91e2d78b4fcb84983304bea0f2e79aeb3343cfeb Mon Sep 17 00:00:00 2001 From: Harald Date: Tue, 6 Oct 2026 12:23:14 +0200 Subject: [PATCH] =?UTF-8?q?fix(updater):=20Sichere=20In-App=20Updates=20mi?= =?UTF-8?q?t=20ureq,=20SHA256-Integrit=C3=A4t=20und=20atomarem=20AppImage-?= =?UTF-8?q?Tausch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Cargo.lock | 212 ++++++++++- Cargo.toml | 4 + src/autosave.rs | 9 +- src/background.rs | 22 +- src/canvas.rs | 58 +-- src/document.rs | 96 +++-- src/export.rs | 48 ++- src/filter.rs | 35 +- src/geometry.rs | 289 +++++++++++---- src/history.rs | 12 +- src/main.rs | 31 +- src/selection.rs | 45 ++- src/spline.rs | 49 ++- src/storage.rs | 49 ++- src/ui.rs | 268 +++++++++----- src/updater.rs | 901 +++++++++++++++++++++++++++++++++++++--------- src/viewport.rs | 14 +- 17 files changed, 1664 insertions(+), 478 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 843cc25..0f61684 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -118,6 +118,12 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + [[package]] name = "bit-set" version = "0.5.3" @@ -151,6 +157,15 @@ version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a" +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + [[package]] name = "block-sys" version = "0.2.1" @@ -375,6 +390,15 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48" +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + [[package]] name = "crc32fast" version = "1.5.2" @@ -390,6 +414,16 @@ version = "0.8.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + [[package]] name = "cursor-icon" version = "1.2.0" @@ -403,7 +437,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3e3d747f100290a1ca24b752186f61f6637e1deffe3bf6320de6fcb29510a307" dependencies = [ "bitflags 2.13.2", - "libloading 0.7.4", + "libloading 0.8.9", "winapi", ] @@ -413,6 +447,16 @@ version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + [[package]] name = "dispatch" version = "0.2.0" @@ -644,6 +688,16 @@ dependencies = [ "slab", ] +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + [[package]] name = "gethostname" version = "1.1.0" @@ -654,6 +708,17 @@ dependencies = [ "windows-link", ] +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + [[package]] name = "getrandom" version = "0.3.4" @@ -958,9 +1023,13 @@ dependencies = [ "glam", "pollster", "printpdf", + "semver", "serde", "serde_json", + "sha2", "tempfile", + "ureq", + "url", "webbrowser", "wgpu", "winit", @@ -1609,6 +1678,20 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + [[package]] name = "rustc-hash" version = "1.1.0" @@ -1647,6 +1730,41 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rustls" +version = "0.23.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustversion" version = "1.0.23" @@ -1687,6 +1805,12 @@ dependencies = [ "tiny-skia", ] +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + [[package]] name = "serde" version = "1.0.229" @@ -1730,6 +1854,17 @@ dependencies = [ "zmij", ] +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "shlex" version = "2.0.1" @@ -1830,6 +1965,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "syn" version = "1.0.109" @@ -2041,6 +2182,12 @@ dependencies = [ "rustc-hash 2.1.3", ] +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + [[package]] name = "unicode-ident" version = "1.0.26" @@ -2065,6 +2212,30 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "ureq" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d" +dependencies = [ + "base64", + "flate2", + "log", + "once_cell", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "url", + "webpki-roots 0.26.11", +] + [[package]] name = "url" version = "2.5.8" @@ -2099,6 +2270,12 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + [[package]] name = "wasip2" version = "1.0.4+wasi-0.2.12" @@ -2310,6 +2487,24 @@ dependencies = [ "web-sys", ] +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.9", +] + +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "weezl" version = "0.1.12" @@ -2503,6 +2698,15 @@ dependencies = [ "windows-targets 0.48.5", ] +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-sys" version = "0.59.0" @@ -2895,6 +3099,12 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + [[package]] name = "zerotrie" version = "0.2.5" diff --git a/Cargo.toml b/Cargo.toml index ea44276..9166f7c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -29,6 +29,10 @@ egui = "0.27" egui-wgpu = "0.27" egui-winit = { version = "0.27", default-features = false, features = ["links"] } webbrowser = "0.8" +semver = "1.0" +sha2 = "0.10" +ureq = { version = "2.10", default-features = false, features = ["tls", "gzip", "json"] } +url = "2.5" [profile.dev] opt-level = 1 # Erhöht Rendering-Performance auch im Debug-Modus diff --git a/src/autosave.rs b/src/autosave.rs index 7a5bb52..6e4a9f9 100644 --- a/src/autosave.rs +++ b/src/autosave.rs @@ -165,13 +165,18 @@ impl AutosaveController { } AutosaveResponse::ManifestSaved { result } => { if let Err(e) = result { - eprintln!("[Autosave-Fehler] Manifest konnte nicht gespeichert werden: {e}"); + eprintln!( + "[Autosave-Fehler] Manifest konnte nicht gespeichert werden: {e}" + ); self.status = AutosaveStatus::Error; } } } - if self.in_flight_count == 0 && self.pending_pages.is_empty() && self.status != AutosaveStatus::Error { + if self.in_flight_count == 0 + && self.pending_pages.is_empty() + && self.status != AutosaveStatus::Error + { self.status = AutosaveStatus::Saved; } } diff --git a/src/background.rs b/src/background.rs index a17f9a5..ca215b7 100644 --- a/src/background.rs +++ b/src/background.rs @@ -39,13 +39,7 @@ pub fn generate_paper_quad() -> Vec { /// Fügt ein Quad (2 Dreiecke) zur Vertexliste hinzu. #[inline] -fn push_quad( - vertices: &mut Vec, - x_min: f32, - y_min: f32, - x_max: f32, - y_max: f32, -) { +fn push_quad(vertices: &mut Vec, x_min: f32, y_min: f32, x_max: f32, y_max: f32) { vertices.push(InkVertex::new(x_min, y_min, BACKGROUND_PATTERN_COLOR)); vertices.push(InkVertex::new(x_max, y_min, BACKGROUND_PATTERN_COLOR)); vertices.push(InkVertex::new(x_min, y_max, BACKGROUND_PATTERN_COLOR)); @@ -102,7 +96,13 @@ pub fn generate_background_mesh(background: PageBackground) -> Vec { while y < PAGE_HEIGHT { let mut x = GRID_5MM_SPACING; while x < PAGE_WIDTH { - push_quad(&mut vertices, x - half_d, y - half_d, x + half_d, y + half_d); + push_quad( + &mut vertices, + x - half_d, + y - half_d, + x + half_d, + y + half_d, + ); x += GRID_5MM_SPACING; } y += GRID_5MM_SPACING; @@ -132,7 +132,11 @@ mod tests { let grid = generate_background_mesh(PageBackground::Grid5mm); assert!(!grid.is_empty()); - assert_eq!(grid.len() % 6, 0, "Muss ein Vielfaches von 6 Vertizes sein (Dreiecke)"); + assert_eq!( + grid.len() % 6, + 0, + "Muss ein Vielfaches von 6 Vertizes sein (Dreiecke)" + ); let lined = generate_background_mesh(PageBackground::Lined); assert!(!lined.is_empty()); diff --git a/src/canvas.rs b/src/canvas.rs index 9a93b8e..bfaeaa2 100644 --- a/src/canvas.rs +++ b/src/canvas.rs @@ -11,9 +11,7 @@ use std::sync::Arc; use winit::dpi::PhysicalSize; use winit::window::Window; -use crate::background::{ - generate_background_mesh, generate_paper_quad, DESK_COLOR, -}; +use crate::background::{generate_background_mesh, generate_paper_quad, DESK_COLOR}; use crate::document::{Page, PageBackground, PageViewport, Stroke, StylusSample}; use crate::filter::OneEuroFilter; use crate::spline::{self, ControlPoint, SplineConfig}; @@ -143,7 +141,8 @@ impl CanvasState { /// Initialisiert den CanvasState für das übergebene Winit-Fenster. pub async fn new(window: Arc) -> Result> { let size = window.inner_size(); - let viewport = PageViewport::calculate(size.width as f32, size.height as f32, Self::VIEWPORT_MARGIN); + let viewport = + PageViewport::calculate(size.width as f32, size.height as f32, Self::VIEWPORT_MARGIN); // 1. WGPU Instance & Surface let instance = wgpu::Instance::new(wgpu::InstanceDescriptor { @@ -187,7 +186,10 @@ impl CanvasState { let present_mode = if std::env::var("INK_FORCE_FIFO").is_ok() { eprintln!("Erzwinge PresentMode::Fifo (via INK_FORCE_FIFO)"); wgpu::PresentMode::Fifo - } else if surface_caps.present_modes.contains(&wgpu::PresentMode::Mailbox) { + } else if surface_caps + .present_modes + .contains(&wgpu::PresentMode::Mailbox) + { eprintln!("Verwende Zero-Latency PresentMode::Mailbox"); wgpu::PresentMode::Mailbox } else { @@ -358,12 +360,7 @@ impl CanvasState { }); // 8. egui Renderer für das schwebende UI initialisieren - let egui_renderer = egui_wgpu::Renderer::new( - &device, - config.format, - None, - 1, - ); + let egui_renderer = egui_wgpu::Renderer::new(&device, config.format, None, 1); Ok(Self { surface, @@ -410,7 +407,11 @@ impl CanvasState { transform_scale: scale, transform_offset: offset, }; - self.queue.write_buffer(&self.camera_buffer, 0, bytemuck::cast_slice(&[camera_uniforms])); + self.queue.write_buffer( + &self.camera_buffer, + 0, + bytemuck::cast_slice(&[camera_uniforms]), + ); } /// Passt die Surface und Projektionsmatrizen an eine neue Fenstergröße an. @@ -433,13 +434,15 @@ impl CanvasState { /// Verschiebt den Bildausschnitt um Bildschirm-Pixel delta. pub fn pan(&mut self, delta_x: f32, delta_y: f32) { - self.camera.pan_by_screen_delta(delta_x, delta_y, &self.viewport); + self.camera + .pan_by_screen_delta(delta_x, delta_y, &self.viewport); self.update_uniforms(); } /// Zoomt zentriert auf eine Cursor-Bildschirmposition. pub fn zoom_at(&mut self, cursor_x: f32, cursor_y: f32, factor: f32) { - self.camera.zoom_at_cursor([cursor_x, cursor_y], factor, &self.viewport); + self.camera + .zoom_at_cursor([cursor_x, cursor_y], factor, &self.viewport); self.update_uniforms(); } @@ -451,12 +454,14 @@ impl CanvasState { /// Transformiert Fenster-Pixel in Seitenkoordinaten. pub fn screen_to_page(&self, screen_x: f32, screen_y: f32) -> Option { - self.camera.screen_to_page(screen_x, screen_y, &self.viewport) + self.camera + .screen_to_page(screen_x, screen_y, &self.viewport) } /// Transformiert Fenster-Pixel in Seitenkoordinaten und klemmt auf die Seite. pub fn screen_to_page_clamped(&self, screen_x: f32, screen_y: f32) -> glam::Vec2 { - self.camera.screen_to_page_clamped(screen_x, screen_y, &self.viewport) + self.camera + .screen_to_page_clamped(screen_x, screen_y, &self.viewport) } /// Transformiert einen Punkt aus DIN-A4-Seitenkoordinaten in Fenster-Bildschirmkoordinaten. @@ -471,7 +476,8 @@ impl CanvasState { self.live_vertex_count = self.live_vertices.len() as u32; if self.live_vertices.len() > self.live_vertex_capacity { - self.live_vertex_capacity = (self.live_vertices.len() * 2).max(Self::INITIAL_LIVE_CAPACITY); + self.live_vertex_capacity = + (self.live_vertices.len() * 2).max(Self::INITIAL_LIVE_CAPACITY); self.live_vertex_buffer = self.device.create_buffer(&wgpu::BufferDescriptor { label: Some("Live Stroke Vertex Buffer (Reallocated)"), size: (self.live_vertex_capacity * std::mem::size_of::()) as u64, @@ -522,7 +528,8 @@ impl CanvasState { } if !mesh.is_empty() { - self.queue.write_buffer(&self.background_buffer, 0, bytemuck::cast_slice(&mesh)); + self.queue + .write_buffer(&self.background_buffer, 0, bytemuck::cast_slice(&mesh)); } } @@ -539,7 +546,8 @@ 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); + self.committed_vertex_capacity = + (page.vertex_cache.len() * 2).max(Self::INITIAL_COMMITTED_CAPACITY); 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, @@ -655,7 +663,8 @@ impl CanvasState { } let color = self.current_ink_color; - let raw_strip = spline::generate_triangle_strip(&self.live_control_points, &self.spline_config); + let raw_strip = + spline::generate_triangle_strip(&self.live_control_points, &self.spline_config); self.live_vertices = raw_strip .into_iter() .map(|v| InkVertex::new(v.position[0], v.position[1], color)) @@ -667,7 +676,8 @@ impl CanvasState { } if self.live_vertices.len() > self.live_vertex_capacity { - self.live_vertex_capacity = (self.live_vertices.len() * 2).max(Self::INITIAL_LIVE_CAPACITY); + self.live_vertex_capacity = + (self.live_vertices.len() * 2).max(Self::INITIAL_LIVE_CAPACITY); self.live_vertex_buffer = self.device.create_buffer(&wgpu::BufferDescriptor { label: Some("Live Stroke Vertex Buffer (Reallocated)"), size: (self.live_vertex_capacity * std::mem::size_of::()) as u64, @@ -694,7 +704,8 @@ impl CanvasState { ) -> Result<(), wgpu::SurfaceError> { // Vor dem Renderpass: egui-Texturen aktualisieren for (id, image_delta) in &textures_delta.set { - self.egui_renderer.update_texture(&self.device, &self.queue, *id, image_delta); + self.egui_renderer + .update_texture(&self.device, &self.queue, *id, image_delta); } let output = self.surface.get_current_texture()?; @@ -784,7 +795,8 @@ impl CanvasState { occlusion_query_set: None, }); - self.egui_renderer.render(&mut egui_pass, clipped_primitives, screen_descriptor); + self.egui_renderer + .render(&mut egui_pass, clipped_primitives, screen_descriptor); } self.queue.submit(std::iter::once(encoder.finish())); diff --git a/src/document.rs b/src/document.rs index 8766509..996285a 100644 --- a/src/document.rs +++ b/src/document.rs @@ -115,12 +115,20 @@ pub struct Stroke { } impl Stroke { - pub fn new(id: u64, points: Vec, vertices: Vec, color: [f32; 4]) -> Self { + pub fn new( + id: u64, + points: Vec, + vertices: Vec, + color: [f32; 4], + ) -> Self { let aabb = if !points.is_empty() { let pts: Vec = points.iter().map(|s| s.position).collect(); Aabb::from_points(&pts) } else if !vertices.is_empty() { - let pts: Vec = vertices.iter().map(|v| glam::Vec2::new(v.position[0], v.position[1])).collect(); + let pts: Vec = vertices + .iter() + .map(|v| glam::Vec2::new(v.position[0], v.position[1])) + .collect(); Aabb::from_points(&pts) } else { Aabb::new(glam::Vec2::ZERO, glam::Vec2::ZERO) @@ -171,7 +179,12 @@ impl Stroke { } /// Prüft, ob dieser Strich von einem Radierer an Position `eraser_pos` mit Radius `eraser_radius` getroffen wird. - pub fn intersects_eraser(&self, eraser_pos: glam::Vec2, eraser_radius: f32, max_stroke_radius: f32) -> bool { + pub fn intersects_eraser( + &self, + eraser_pos: glam::Vec2, + eraser_radius: f32, + max_stroke_radius: f32, + ) -> bool { // 1. Vorabtest: Schneidet der Radierer die vergrößerte AABB? let total_margin = eraser_radius + max_stroke_radius; if !self.aabb.expanded(total_margin).contains_point(eraser_pos) { @@ -254,9 +267,7 @@ impl Stroke { /// Skaliert den Strich bezüglich `origin` um die Faktoren `factors`. pub fn scale(&mut self, origin: glam::Vec2, factors: glam::Vec2) { - let sc = |pos: glam::Vec2| -> glam::Vec2 { - origin + (pos - origin) * factors - }; + let sc = |pos: glam::Vec2| -> glam::Vec2 { origin + (pos - origin) * factors }; for pt in &mut self.points { pt.position = sc(pt.position); @@ -402,7 +413,12 @@ impl Page { /// Radiert alle Striche an der Position `eraser_pos` mit Radius `eraser_radius`. /// /// Gibt die gelöschten Striche zurück (falls welche getroffen wurden). - pub fn erase_at(&mut self, eraser_pos: glam::Vec2, eraser_radius: f32, max_stroke_radius: f32) -> Vec { + pub fn erase_at( + &mut self, + eraser_pos: glam::Vec2, + eraser_radius: f32, + max_stroke_radius: f32, + ) -> Vec { let mut hit_indices = Vec::new(); for (i, stroke) in self.strokes.iter().enumerate() { @@ -422,7 +438,8 @@ impl Page { } removed.reverse(); - self.history.push(HistoryAction::RemoveStrokes(removed.clone())); + self.history + .push(HistoryAction::RemoveStrokes(removed.clone())); self.is_dirty = true; removed @@ -473,7 +490,11 @@ impl Page { /// Liefert Klone der Striche mit den angegebenen IDs. pub fn get_strokes(&self, ids: &[u64]) -> Vec { - self.strokes.iter().filter(|s| ids.contains(&s.id)).cloned().collect() + self.strokes + .iter() + .filter(|s| ids.contains(&s.id)) + .cloned() + .collect() } /// Aktualisiert eine Menge geänderter Striche transaktional im History-Stack. @@ -482,7 +503,12 @@ impl Page { return; } let ids: Vec = modified.iter().map(|s| s.id).collect(); - let before: Vec = self.strokes.iter().filter(|s| ids.contains(&s.id)).cloned().collect(); + let before: Vec = self + .strokes + .iter() + .filter(|s| ids.contains(&s.id)) + .cloned() + .collect(); for m in modified.iter() { if let Some(pos) = self.strokes.iter().position(|s| s.id == m.id) { @@ -517,7 +543,8 @@ impl Page { self.strokes[pos] = m.clone(); } } - self.history.push(HistoryAction::ModifyStrokes { before, after }); + self.history + .push(HistoryAction::ModifyStrokes { before, after }); self.is_dirty = true; } @@ -534,7 +561,8 @@ impl Page { }); if !removed.is_empty() { - self.history.push(HistoryAction::RemoveStrokes(removed.clone())); + self.history + .push(HistoryAction::RemoveStrokes(removed.clone())); self.is_dirty = true; } removed @@ -545,7 +573,8 @@ impl Page { if new_strokes.is_empty() { return; } - self.history.push(HistoryAction::AddStrokes(new_strokes.clone())); + self.history + .push(HistoryAction::AddStrokes(new_strokes.clone())); self.strokes.extend(new_strokes); self.is_dirty = true; } @@ -653,7 +682,11 @@ impl Notebook { /// Text für das On-Screen-Display (OSD), z. B. "Seite 3 / 12". pub fn osd_text(&self) -> String { - format!("Seite {} / {}", self.current_page_index + 1, self.pages.len()) + format!( + "Seite {} / {}", + self.current_page_index + 1, + self.pages.len() + ) } } @@ -787,13 +820,25 @@ mod tests { let stroke_radius = 2.0; // Treffer: Radierer bei (150, 105) (Abstand 5 <= 12) - assert!(stroke.intersects_eraser(glam::Vec2::new(150.0, 105.0), eraser_radius, stroke_radius)); + assert!(stroke.intersects_eraser( + glam::Vec2::new(150.0, 105.0), + eraser_radius, + stroke_radius + )); // Kein Treffer: Radierer bei (150, 150) (Abstand 50 > 12) - assert!(!stroke.intersects_eraser(glam::Vec2::new(150.0, 150.0), eraser_radius, stroke_radius)); + assert!(!stroke.intersects_eraser( + glam::Vec2::new(150.0, 150.0), + eraser_radius, + stroke_radius + )); // Kein Treffer: Weit außerhalb der AABB - assert!(!stroke.intersects_eraser(glam::Vec2::new(500.0, 500.0), eraser_radius, stroke_radius)); + assert!(!stroke.intersects_eraser( + glam::Vec2::new(500.0, 500.0), + eraser_radius, + stroke_radius + )); } #[test] @@ -966,9 +1011,12 @@ mod tests { let mut moved = s.clone(); moved.translate(glam::Vec2::new(20.0, 30.0)); page.update_strokes_ephemeral(&[moved.clone()]); - assert_eq!(page.strokes[0].points[0].position, glam::Vec2::new(30.0, 40.0)); + assert_eq!( + page.strokes[0].points[0].position, + glam::Vec2::new(30.0, 40.0) + ); // History should not have been updated yet - assert_eq!(page.history.can_redo(), false); + assert!(!page.history.can_redo()); // Transactional commit (mouse release) let before = vec![s.clone()]; @@ -977,10 +1025,16 @@ mod tests { // Verify Undo restores original position assert!(page.undo()); - assert_eq!(page.strokes[0].points[0].position, glam::Vec2::new(10.0, 10.0)); + assert_eq!( + page.strokes[0].points[0].position, + glam::Vec2::new(10.0, 10.0) + ); // Verify Redo applies modified position assert!(page.redo()); - assert_eq!(page.strokes[0].points[0].position, glam::Vec2::new(30.0, 40.0)); + assert_eq!( + page.strokes[0].points[0].position, + glam::Vec2::new(30.0, 40.0) + ); } } diff --git a/src/export.rs b/src/export.rs index 9289a40..5a65657 100644 --- a/src/export.rs +++ b/src/export.rs @@ -78,12 +78,8 @@ pub fn generate_pdf_document( // 1. Erstes Dokument mit Seite 1 erstellen let title = "ink-notes Notebook"; - let (doc, page1_idx, layer1_bg) = PdfDocument::new( - title, - Mm(A4_WIDTH_MM), - Mm(A4_HEIGHT_MM), - "Background", - ); + let (doc, page1_idx, layer1_bg) = + PdfDocument::new(title, Mm(A4_WIDTH_MM), Mm(A4_HEIGHT_MM), "Background"); // Seite 1 befüllen let page1_ref = doc.get_page(page1_idx); @@ -95,11 +91,7 @@ pub fn generate_pdf_document( // 2. Alle nachfolgenden Seiten hinzufügen for page in ¬ebook.pages[1..] { - let (page_idx, layer_bg) = doc.add_page( - Mm(A4_WIDTH_MM), - Mm(A4_HEIGHT_MM), - "Background", - ); + let (page_idx, layer_bg) = doc.add_page(Mm(A4_WIDTH_MM), Mm(A4_HEIGHT_MM), "Background"); let page_ref = doc.get_page(page_idx); let layer_bg_ref = page_ref.get_layer(layer_bg); render_background_layer(&layer_bg_ref, page.background); @@ -112,10 +104,7 @@ pub fn generate_pdf_document( } /// Rendert das Vektorraster auf Layer 1 ("Background"). -fn render_background_layer( - layer: &printpdf::PdfLayerReference, - background: PageBackground, -) { +fn render_background_layer(layer: &printpdf::PdfLayerReference, background: PageBackground) { let grid_color = Color::Rgb(Rgb::new(0.88, 0.88, 0.88, None)); layer.set_outline_color(grid_color.clone()); layer.set_outline_thickness(0.4); // Feine 0.15 mm Haarlinie (ca. 0.4 pt) @@ -203,11 +192,7 @@ fn render_background_layer( } /// Rendert alle Striche einer Seite auf Layer 2 ("Ink"). -fn render_ink_layer( - layer: &printpdf::PdfLayerReference, - page: &Page, - strategy: ExportStrategy, -) { +fn render_ink_layer(layer: &printpdf::PdfLayerReference, page: &Page, strategy: ExportStrategy) { for stroke in &page.strokes { if let Some(ref shape) = stroke.shape { render_recognized_shape(layer, shape, stroke.color); @@ -326,7 +311,12 @@ fn render_recognized_shape( layer.add_polygon(poly); } - RecognizedShape::Octahedron { center, radius_x, radius_y, depth } => { + RecognizedShape::Octahedron { + center, + radius_x, + radius_y, + depth, + } => { let edges = crate::geometry::octahedron_edges(*center, *radius_x, *radius_y, *depth); for (p1, p2) in edges { let (x1, y1) = page_to_pdf_mm(p1.x, p1.y); @@ -514,7 +504,8 @@ fn render_stroke_bezier(layer: &printpdf::PdfLayerReference, stroke: &Stroke) { layer.set_line_join_style(printpdf::LineJoinStyle::Round); // Durchschnittliche Linienstärke aus dem Druck berechnen - let avg_pressure = stroke.points.iter().map(|p| p.pressure).sum::() / stroke.points.len() as f32; + let avg_pressure = + stroke.points.iter().map(|p| p.pressure).sum::() / stroke.points.len() as f32; let radius_mm = (avg_pressure * 0.75 + 0.25).max(0.25); let thickness_pt = radius_mm * 2.0 * (72.0 / 25.4); // mm in DTP-Punkte layer.set_outline_thickness(thickness_pt); @@ -693,11 +684,18 @@ mod tests { let temp_dir = std::env::temp_dir(); let pdf_path = temp_dir.join("ink_notes_test_export.pdf"); - let export_res = export_notebook_to_pdf(¬ebook, &pdf_path, ExportStrategy::FilledPolygon); - assert!(export_res.is_ok(), "PDF-Export zu Datei muss erfolgreich sein"); + let export_res = + export_notebook_to_pdf(¬ebook, &pdf_path, ExportStrategy::FilledPolygon); + assert!( + export_res.is_ok(), + "PDF-Export zu Datei muss erfolgreich sein" + ); // Datei-Existenz und Größe prüfen - assert!(pdf_path.exists(), "Die generierte PDF-Datei muss existieren"); + assert!( + pdf_path.exists(), + "Die generierte PDF-Datei muss existieren" + ); let metadata = std::fs::metadata(&pdf_path).unwrap(); assert!( metadata.len() > 1024, diff --git a/src/filter.rs b/src/filter.rs index a03d55f..97fa1f6 100644 --- a/src/filter.rs +++ b/src/filter.rs @@ -265,9 +265,18 @@ mod tests { // 4. dt > 1.0 s (großer Zeitsprung z.B. auf t = 5.0 s): Muss reset() auslösen let (x4, y4, p4) = filter.filter(50.0, 100.0, 0.9, 5.0); // Nach dem Reset muss der Filter sofort exakt den neuen Punkt als Startpunkt annehmen - assert_eq!(x4, 50.0, "Nach Reset muss der neue X-Wert übernommen werden"); - assert_eq!(y4, 100.0, "Nach Reset muss der neue Y-Wert übernommen werden"); - assert_eq!(p4, 0.9, "Nach Reset muss der neue Druckwert übernommen werden"); + assert_eq!( + x4, 50.0, + "Nach Reset muss der neue X-Wert übernommen werden" + ); + assert_eq!( + y4, 100.0, + "Nach Reset muss der neue Y-Wert übernommen werden" + ); + assert_eq!( + p4, 0.9, + "Nach Reset muss der neue Druckwert übernommen werden" + ); } #[test] @@ -344,9 +353,18 @@ mod tests { // 2. Vergiftungsversuch mit NaN und Infinity let (x_nan, y_nan, p_nan) = filter.filter(f32::NAN, f32::INFINITY, f32::NEG_INFINITY, 0.02); - assert!(x_nan.is_finite(), "Filter darf kein NaN bei Vergiftung ausgeben"); - assert!(y_nan.is_finite(), "Filter darf kein Inf bei Vergiftung ausgeben"); - assert!(p_nan.is_finite(), "Filter darf kein -Inf bei Vergiftung ausgeben"); + assert!( + x_nan.is_finite(), + "Filter darf kein NaN bei Vergiftung ausgeben" + ); + assert!( + y_nan.is_finite(), + "Filter darf kein Inf bei Vergiftung ausgeben" + ); + assert!( + p_nan.is_finite(), + "Filter darf kein -Inf bei Vergiftung ausgeben" + ); let (x_nan2, y_nan2, p_nan2) = filter.filter(12.0, 22.0, 0.6, f64::NAN); assert!(x_nan2.is_finite()); @@ -364,6 +382,9 @@ mod tests { assert!(x3.is_finite()); assert!(y3.is_finite()); assert!(p3.is_finite()); - assert!(x3 > x2, "Filter muss sich nach Vergiftung weiter normal anpassen"); + assert!( + x3 > x2, + "Filter muss sich nach Vergiftung weiter normal anpassen" + ); } } diff --git a/src/geometry.rs b/src/geometry.rs index e4bf8b6..b81c724 100644 --- a/src/geometry.rs +++ b/src/geometry.rs @@ -3,9 +3,9 @@ //! Erkennt Geraden, Rechtecke, Kreise und Koordinatensysteme (Achsen) //! und erzeugt saubere Vektor-Primitiven und GPU-Triangle-Meshes. -use std::f32::consts::PI; use glam::Vec2; use serde::{Deserialize, Serialize}; +use std::f32::consts::PI; use crate::canvas::InkVertex; use crate::document::{Aabb, Stroke, StylusSample}; @@ -117,7 +117,12 @@ impl RecognizedShape { *c += delta; } } - Self::CoordinateSystem { origin, x_axis_end, y_axis_end, .. } => { + Self::CoordinateSystem { + origin, + x_axis_end, + y_axis_end, + .. + } => { *origin += delta; *x_axis_end += delta; *y_axis_end += delta; @@ -159,7 +164,12 @@ impl RecognizedShape { *c = rot(*c); } } - Self::CoordinateSystem { origin: o, x_axis_end, y_axis_end, .. } => { + Self::CoordinateSystem { + origin: o, + x_axis_end, + y_axis_end, + .. + } => { *o = rot(*o); *x_axis_end = rot(*x_axis_end); *y_axis_end = rot(*y_axis_end); @@ -177,9 +187,7 @@ impl RecognizedShape { /// Skaliert die geometrische Form bezüglich `origin` um den Faktor `factors`. pub fn scale(&mut self, origin: Vec2, factors: Vec2) { - let sc = |pt: Vec2| -> Vec2 { - origin + (pt - origin) * factors - }; + let sc = |pt: Vec2| -> Vec2 { origin + (pt - origin) * factors }; match self { Self::Line { start, end } => { @@ -200,7 +208,12 @@ impl RecognizedShape { *c = sc(*c); } } - Self::CoordinateSystem { origin: o, x_axis_end, y_axis_end, .. } => { + Self::CoordinateSystem { + origin: o, + x_axis_end, + y_axis_end, + .. + } => { *o = sc(*o); *x_axis_end = sc(*x_axis_end); *y_axis_end = sc(*y_axis_end); @@ -210,7 +223,12 @@ impl RecognizedShape { *v = sc(*v); } } - Self::Octahedron { center, radius_x, radius_y, depth } => { + Self::Octahedron { + center, + radius_x, + radius_y, + depth, + } => { *center = sc(*center); *radius_x *= factors.x.abs(); *radius_y *= factors.y.abs(); @@ -415,18 +433,17 @@ pub fn detect_rectangle(points: &[Vec2]) -> Option { let epsilon = (total_len * 0.05).clamp(8.0, 45.0); let simplified = ramer_douglas_peucker(points, epsilon); - let corners: Vec = if simplified.len() == 5 - && (simplified[0] - simplified[4]).length() < epsilon * 2.5 - { - simplified[0..4].to_vec() - } else if simplified.len() == 4 { - simplified - } else if simplified.len() > 4 { - // Suche die 4 markantesten Ecken (stärkste Richtungsänderung) - extract_k_corners(&simplified, 4)? - } else { - return None; - }; + let corners: Vec = + if simplified.len() == 5 && (simplified[0] - simplified[4]).length() < epsilon * 2.5 { + simplified[0..4].to_vec() + } else if simplified.len() == 4 { + simplified + } else if simplified.len() > 4 { + // Suche die 4 markantesten Ecken (stärkste Richtungsänderung) + extract_k_corners(&simplified, 4)? + } else { + return None; + }; if corners.len() != 4 { return None; @@ -535,7 +552,12 @@ pub fn extract_k_corners(points: &[Vec2], k: usize) -> Option> { let mut selected_indices: Vec = scored.iter().take(k).map(|&(idx, _)| idx).collect(); selected_indices.sort_unstable(); - Some(selected_indices.into_iter().map(|idx| points[idx]).collect()) + Some( + selected_indices + .into_iter() + .map(|idx| points[idx]) + .collect(), + ) } /// 4. Dreieck-Erkennung: Geschlossener Pfad mit 3 dominanten Ecken. @@ -561,7 +583,8 @@ pub fn detect_triangle(points: &[Vec2]) -> Option { let epsilon = (total_len * 0.07).clamp(8.0, 50.0); let simplified = ramer_douglas_peucker(points, epsilon); - let corners: Vec = if (simplified.len() == 4 && (simplified[0] - simplified[3]).length() < epsilon * 2.5) + let corners: Vec = if (simplified.len() == 4 + && (simplified[0] - simplified[3]).length() < epsilon * 2.5) || simplified.len() == 3 { simplified[0..3].to_vec() @@ -617,9 +640,19 @@ pub fn detect_triangle(points: &[Vec2]) -> Option { let snapped_angle = snap_angle(base_angle, 15.0); let c0 = center + Vec2::new(snapped_angle.cos(), snapped_angle.sin()) * r; - let c1 = center + Vec2::new((snapped_angle + 2.0 * PI / 3.0).cos(), (snapped_angle + 2.0 * PI / 3.0).sin()) * r; - let c2 = center + Vec2::new((snapped_angle + 4.0 * PI / 3.0).cos(), (snapped_angle + 4.0 * PI / 3.0).sin()) * r; - return Some(RecognizedShape::Triangle { corners: [c0, c1, c2] }); + let c1 = center + + Vec2::new( + (snapped_angle + 2.0 * PI / 3.0).cos(), + (snapped_angle + 2.0 * PI / 3.0).sin(), + ) * r; + let c2 = center + + Vec2::new( + (snapped_angle + 4.0 * PI / 3.0).cos(), + (snapped_angle + 4.0 * PI / 3.0).sin(), + ) * r; + return Some(RecognizedShape::Triangle { + corners: [c0, c1, c2], + }); } // 2. Rechtwinklig? (Ein Winkel nahe 90°) @@ -639,7 +672,11 @@ pub fn detect_triangle(points: &[Vec2]) -> Option { let len2 = v2.length(); v1 = Vec2::new(a1_snapped.cos(), a1_snapped.sin()) * len1; - let sign = if (v1.x * v2.y - v1.y * v2.x) >= 0.0 { 1.0 } else { -1.0 }; + let sign = if (v1.x * v2.y - v1.y * v2.x) >= 0.0 { + 1.0 + } else { + -1.0 + }; v2 = Vec2::new(-v1.y * sign, v1.x * sign).normalize_or_zero() * len2; let new_p1 = corner_v + v1; @@ -667,7 +704,9 @@ pub fn detect_triangle(points: &[Vec2]) -> Option { let half_base = ((a - mid_ac).length() + (c - mid_ac).length()) * 0.5; let new_a = mid_ac + perp * half_base; c = mid_ac - perp * half_base; - return Some(RecognizedShape::Triangle { corners: [new_a, b, c] }); + return Some(RecognizedShape::Triangle { + corners: [new_a, b, c], + }); } else if (l_bc - l_ca).abs() / l_bc.max(l_ca) < 0.12 { let mid_ab = (a + b) * 0.5; let axis = (c - mid_ab).normalize_or_zero(); @@ -675,7 +714,9 @@ pub fn detect_triangle(points: &[Vec2]) -> Option { let half_base = ((a - mid_ab).length() + (b - mid_ab).length()) * 0.5; let new_a = mid_ab + perp * half_base; b = mid_ab - perp * half_base; - return Some(RecognizedShape::Triangle { corners: [new_a, b, c] }); + return Some(RecognizedShape::Triangle { + corners: [new_a, b, c], + }); } Some(RecognizedShape::Triangle { corners: [a, b, c] }) @@ -701,15 +742,16 @@ pub fn detect_rhombus(points: &[Vec2]) -> Option { let epsilon = (total_len * 0.05).clamp(8.0, 45.0); let simplified = ramer_douglas_peucker(points, epsilon); - let corners: Vec = if simplified.len() == 5 && (simplified[0] - simplified[4]).length() < epsilon * 2.5 { - simplified[0..4].to_vec() - } else if simplified.len() == 4 { - simplified - } else if simplified.len() > 4 { - extract_k_corners(&simplified, 4)? - } else { - return None; - }; + let corners: Vec = + if simplified.len() == 5 && (simplified[0] - simplified[4]).length() < epsilon * 2.5 { + simplified[0..4].to_vec() + } else if simplified.len() == 4 { + simplified + } else if simplified.len() > 4 { + extract_k_corners(&simplified, 4)? + } else { + return None; + }; if corners.len() != 4 { return None; @@ -756,7 +798,11 @@ pub fn detect_rhombus(points: &[Vec2]) -> Option { let snapped_a1 = snap_angle(angle1, 15.0); dir1 = Vec2::new(snapped_a1.cos(), snapped_a1.sin()); - let sign = if (dir1.x * d2.y - dir1.y * d2.x) >= 0.0 { 1.0 } else { -1.0 }; + let sign = if (dir1.x * d2.y - dir1.y * d2.x) >= 0.0 { + 1.0 + } else { + -1.0 + }; let dir2 = Vec2::new(-dir1.y * sign, dir1.x * sign); let h1 = len1 * 0.5; @@ -806,7 +852,10 @@ pub fn detect_polygon(points: &[Vec2]) -> Option { return None; } - let max_r_diff = radii.iter().map(|r| (r - mean_r).abs()).fold(0.0f32, f32::max); + let max_r_diff = radii + .iter() + .map(|r| (r - mean_r).abs()) + .fold(0.0f32, f32::max); let snapped_vertices = if max_r_diff / mean_r < 0.25 { let base_dir = (corners[0] - center).normalize_or_zero(); @@ -830,7 +879,9 @@ pub fn detect_polygon(points: &[Vec2]) -> Option { fn ramer_douucker_closed(points: &[Vec2], epsilon: f32) -> Vec { let simplified = ramer_douglas_peucker(points, epsilon); - if simplified.len() >= 2 && (simplified[0] - *simplified.last().unwrap()).length() < epsilon * 2.0 { + if simplified.len() >= 2 + && (simplified[0] - *simplified.last().unwrap()).length() < epsilon * 2.0 + { simplified[0..simplified.len() - 1].to_vec() } else { simplified @@ -1043,8 +1094,16 @@ pub fn closed_polygon_to_mesh( let n_next = Vec2::new(-d_next.y, d_next.x); let corner_norm = (n_prev + n_next).normalize_or_zero() * half_w; - vertices.push(InkVertex::new(curr.x + corner_norm.x, curr.y + corner_norm.y, color)); - vertices.push(InkVertex::new(curr.x - corner_norm.x, curr.y - corner_norm.y, color)); + vertices.push(InkVertex::new( + curr.x + corner_norm.x, + curr.y + corner_norm.y, + color, + )); + vertices.push(InkVertex::new( + curr.x - corner_norm.x, + curr.y - corner_norm.y, + color, + )); let edge_len = (next - curr).length(); let steps = ((edge_len / 10.0).ceil() as usize).max(2); @@ -1069,7 +1128,12 @@ pub fn closed_polygon_to_mesh( } /// Berechnet die 12 Kanten der 3D-Schrägbild-Projektion eines Oktaeders (Doppelpyramide). -pub fn octahedron_edges(center: Vec2, radius_x: f32, radius_y: f32, depth: f32) -> Vec<(Vec2, Vec2)> { +pub fn octahedron_edges( + center: Vec2, + radius_x: f32, + radius_y: f32, + depth: f32, +) -> Vec<(Vec2, Vec2)> { let top = center + Vec2::new(0.0, -radius_y); let bottom = center + Vec2::new(0.0, radius_y); let left = center + Vec2::new(-radius_x, 0.0); @@ -1143,8 +1207,16 @@ pub fn shape_to_mesh( let angle = (i as f32 / segments as f32) * 2.0 * PI; let (sin, cos) = angle.sin_cos(); - vertices.push(InkVertex::new(center.x + cos * r_out, center.y + sin * r_out, color)); - vertices.push(InkVertex::new(center.x + cos * r_in, center.y + sin * r_in, color)); + vertices.push(InkVertex::new( + center.x + cos * r_out, + center.y + sin * r_out, + color, + )); + vertices.push(InkVertex::new( + center.x + cos * r_in, + center.y + sin * r_in, + color, + )); if i < segments { samples.push(StylusSample { @@ -1158,23 +1230,20 @@ pub fn shape_to_mesh( (samples, vertices) } - RecognizedShape::Rectangle { corners } => { - closed_polygon_to_mesh(corners, half_w, color) - } + RecognizedShape::Rectangle { corners } => closed_polygon_to_mesh(corners, half_w, color), - RecognizedShape::Triangle { corners } => { - closed_polygon_to_mesh(corners, half_w, color) - } + RecognizedShape::Triangle { corners } => closed_polygon_to_mesh(corners, half_w, color), - RecognizedShape::Rhombus { corners } => { - closed_polygon_to_mesh(corners, half_w, color) - } + RecognizedShape::Rhombus { corners } => closed_polygon_to_mesh(corners, half_w, color), - RecognizedShape::Polygon { vertices } => { - closed_polygon_to_mesh(vertices, half_w, color) - } + RecognizedShape::Polygon { vertices } => closed_polygon_to_mesh(vertices, half_w, color), - RecognizedShape::Octahedron { center, radius_x, radius_y, depth } => { + RecognizedShape::Octahedron { + center, + radius_x, + radius_y, + depth, + } => { let edges = octahedron_edges(*center, *radius_x, *radius_y, *depth); let mut vertices = Vec::new(); let mut samples = Vec::new(); @@ -1228,10 +1297,26 @@ pub fn shape_to_mesh( let y_norm = Vec2::new(-y_dir.y, y_dir.x) * half_w; // Y-Linienband - vertices.push(InkVertex::new(origin.x + y_norm.x, origin.y + y_norm.y, color)); - vertices.push(InkVertex::new(origin.x - y_norm.x, origin.y - y_norm.y, color)); - vertices.push(InkVertex::new(y_axis_end.x + y_norm.x, y_axis_end.y + y_norm.y, color)); - vertices.push(InkVertex::new(y_axis_end.x - y_norm.x, y_axis_end.y - y_norm.y, color)); + vertices.push(InkVertex::new( + origin.x + y_norm.x, + origin.y + y_norm.y, + color, + )); + vertices.push(InkVertex::new( + origin.x - y_norm.x, + origin.y - y_norm.y, + color, + )); + vertices.push(InkVertex::new( + y_axis_end.x + y_norm.x, + y_axis_end.y + y_norm.y, + color, + )); + vertices.push(InkVertex::new( + y_axis_end.x - y_norm.x, + y_axis_end.y - y_norm.y, + color, + )); // Degenerate Dreiecke für Übergang zur X-Achse let x_dir = (*x_axis_end - *origin).normalize_or_zero(); @@ -1244,9 +1329,21 @@ pub fn shape_to_mesh( // 2. X-Linienband vertices.push(next_v); - vertices.push(InkVertex::new(origin.x - x_norm.x, origin.y - x_norm.y, color)); - vertices.push(InkVertex::new(x_axis_end.x + x_norm.x, x_axis_end.y + x_norm.y, color)); - vertices.push(InkVertex::new(x_axis_end.x - x_norm.x, x_axis_end.y - x_norm.y, color)); + vertices.push(InkVertex::new( + origin.x - x_norm.x, + origin.y - x_norm.y, + color, + )); + vertices.push(InkVertex::new( + x_axis_end.x + x_norm.x, + x_axis_end.y + x_norm.y, + color, + )); + vertices.push(InkVertex::new( + x_axis_end.x - x_norm.x, + x_axis_end.y - x_norm.y, + color, + )); // 3. Optionale Pfeilspitzen if *with_arrows { @@ -1328,7 +1425,10 @@ mod tests { for i in 0..=50 { let t = i as f32 / 50.0; let jitter = (i as f32 * 0.7).sin() * 1.5; - points.push(Vec2::new(100.0 + t * 400.0 + jitter, 100.0 + t * 300.0 - jitter)); + points.push(Vec2::new( + 100.0 + t * 400.0 + jitter, + 100.0 + t * 300.0 - jitter, + )); } let detected = detect_line(&points); @@ -1380,7 +1480,12 @@ mod tests { let v1 = (prev - curr).normalize(); let v2 = (next - curr).normalize(); let dot = v1.dot(v2).abs(); - assert!(dot < 1e-4, "Winkel an Ecke {} ist nicht rechtwinklig: dot={}", i, dot); + assert!( + dot < 1e-4, + "Winkel an Ecke {} ist nicht rechtwinklig: dot={}", + i, + dot + ); } } else { panic!("Falscher Formtyp erkannt"); @@ -1402,7 +1507,11 @@ mod tests { let detected = detect_circle(&points); assert!(detected.is_some(), "Kreis sollte erkannt werden"); - if let Some(RecognizedShape::Circle { center: c, radius: r }) = detected { + if let Some(RecognizedShape::Circle { + center: c, + radius: r, + }) = detected + { assert!((c - center).length() < 5.0, "Zentrum weicht ab: {:?}", c); assert!((r - 54.0).abs() < 5.0, "Radius weicht ab: {}", r); } else { @@ -1431,14 +1540,27 @@ mod tests { } let detected = detect_coordinate_system(&points); - assert!(detected.is_some(), "Koordinatensystem sollte erkannt werden"); + assert!( + detected.is_some(), + "Koordinatensystem sollte erkannt werden" + ); - if let Some(RecognizedShape::CoordinateSystem { origin: o, x_axis_end, y_axis_end, .. }) = detected { + if let Some(RecognizedShape::CoordinateSystem { + origin: o, + x_axis_end, + y_axis_end, + .. + }) = detected + { assert!((o - origin).length() < 2.0, "Ursprung weicht ab"); let v_x = (x_axis_end - o).normalize(); let v_y = (y_axis_end - o).normalize(); let dot = v_x.dot(v_y).abs(); - assert!(dot < 1e-4, "Achsen sind nach Orthogonalitäts-Snap nicht exakt 90°: dot={}", dot); + assert!( + dot < 1e-4, + "Achsen sind nach Orthogonalitäts-Snap nicht exakt 90°: dot={}", + dot + ); } else { panic!("Falscher Formtyp erkannt"); } @@ -1476,7 +1598,10 @@ mod tests { let ab = (corners[1] - corners[0]).length(); let bc = (corners[2] - corners[1]).length(); let ca = (corners[0] - corners[2]).length(); - assert!((ab - bc).abs() < 5.0, "Gleichseitiges Dreieck sollte gesnappt sein"); + assert!( + (ab - bc).abs() < 5.0, + "Gleichseitiges Dreieck sollte gesnappt sein" + ); assert!((bc - ca).abs() < 5.0); } else { panic!("Falscher Formtyp erkannt"); @@ -1490,7 +1615,7 @@ mod tests { let c0 = Vec2::new(200.0, 100.0); // Oben let c1 = Vec2::new(320.0, 250.0); // Rechts let c2 = Vec2::new(200.0, 400.0); // Unten - let c3 = Vec2::new(80.0, 250.0); // Links + let c3 = Vec2::new(80.0, 250.0); // Links for i in 0..20 { let t = i as f32 / 20.0; @@ -1520,7 +1645,10 @@ mod tests { let l1 = (corners[2] - corners[1]).length(); let l2 = (corners[3] - corners[2]).length(); let l3 = (corners[0] - corners[3]).length(); - assert!((l0 - l1).abs() < 1e-3, "Alle 4 Seiten einer Raute müssen exakt gleich lang sein"); + assert!( + (l0 - l1).abs() < 1e-3, + "Alle 4 Seiten einer Raute müssen exakt gleich lang sein" + ); assert!((l1 - l2).abs() < 1e-3); assert!((l2 - l3).abs() < 1e-3); } else { @@ -1549,7 +1677,10 @@ mod tests { points.push(points[0]); let detected = detect_polygon(&points); - assert!(detected.is_some(), "Oktagon / Achteck sollte erkannt werden"); + assert!( + detected.is_some(), + "Oktagon / Achteck sollte erkannt werden" + ); if let Some(RecognizedShape::Polygon { vertices }) = detected { assert_eq!(vertices.len(), 8, "Achteck muss 8 Vertizes haben"); @@ -1587,7 +1718,13 @@ mod tests { let detected = detect_octahedron(&points); assert!(detected.is_some(), "Oktaeder sollte erkannt werden"); - if let Some(RecognizedShape::Octahedron { center, radius_x, radius_y, depth }) = detected { + if let Some(RecognizedShape::Octahedron { + center, + radius_x, + radius_y, + depth, + }) = detected + { assert!((center.x - 200.0).abs() < 5.0); assert!((center.y - 200.0).abs() < 5.0); assert!(radius_x > 100.0); diff --git a/src/history.rs b/src/history.rs index 27efc30..1db5e73 100644 --- a/src/history.rs +++ b/src/history.rs @@ -83,7 +83,8 @@ impl HistoryStack { if let Some(pos) = strokes.iter().position(|s| s.id == new.id) { strokes[pos] = old.clone(); } - self.redo_stack.push(HistoryAction::ReplaceStroke { old, new }); + self.redo_stack + .push(HistoryAction::ReplaceStroke { old, new }); } HistoryAction::ModifyStrokes { before, after } => { for b in &before { @@ -91,7 +92,8 @@ impl HistoryStack { strokes[pos] = b.clone(); } } - self.redo_stack.push(HistoryAction::ModifyStrokes { before, after }); + self.redo_stack + .push(HistoryAction::ModifyStrokes { before, after }); } } @@ -126,7 +128,8 @@ impl HistoryStack { if let Some(pos) = strokes.iter().position(|s| s.id == old.id) { strokes[pos] = new.clone(); } - self.undo_stack.push(HistoryAction::ReplaceStroke { old, new }); + self.undo_stack + .push(HistoryAction::ReplaceStroke { old, new }); } HistoryAction::ModifyStrokes { before, after } => { for a in &after { @@ -134,7 +137,8 @@ impl HistoryStack { strokes[pos] = a.clone(); } } - self.undo_stack.push(HistoryAction::ModifyStrokes { before, after }); + self.undo_stack + .push(HistoryAction::ModifyStrokes { before, after }); } } diff --git a/src/main.rs b/src/main.rs index 7cc7696..358024a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,7 +4,9 @@ use std::sync::Arc; use std::time::{Duration, Instant}; use winit::{ - event::{ElementState, Event, KeyEvent, MouseButton, MouseScrollDelta, TouchPhase, WindowEvent}, + event::{ + ElementState, Event, KeyEvent, MouseButton, MouseScrollDelta, TouchPhase, WindowEvent, + }, event_loop::{ControlFlow, EventLoop}, keyboard::{Key, ModifiersState, NamedKey}, window::{Window, WindowBuilder}, @@ -16,7 +18,9 @@ use ink_notes::canvas::CanvasState; use ink_notes::document::{Notebook, PageBackground}; use ink_notes::export::{export_notebook_to_pdf, ExportStrategy}; use ink_notes::geometry::{self, RecognizedShape}; -use ink_notes::selection::{render_selection_overlay, DragMode, SelectionHandleHit, SelectionState}; +use ink_notes::selection::{ + render_selection_overlay, DragMode, SelectionHandleHit, SelectionState, +}; use ink_notes::storage::{self, NotebookManifest}; use ink_notes::ui::{self, ActiveTool, UiAction, UiState}; use ink_notes::updater::UpdaterController; @@ -76,12 +80,21 @@ fn main() -> Result<(), Box> { Ok((nb, m)) => (nb, m), Err(err) => { eprintln!("[Storage-Fehler] Konnte Notizbuch nicht laden ({err}). Erstelle neues Notizbuch."); - (Notebook::new(), NotebookManifest::new(&initial_id, &initial_title)) + ( + Notebook::new(), + NotebookManifest::new(&initial_id, &initial_title), + ) } } } else { - println!("[Storage] Erstelle neues Notizbuch in: {}", storage_dir.display()); - (Notebook::new(), NotebookManifest::new(&initial_id, &initial_title)) + println!( + "[Storage] Erstelle neues Notizbuch in: {}", + storage_dir.display() + ); + ( + Notebook::new(), + NotebookManifest::new(&initial_id, &initial_title), + ) }; // 2. Asynchronen Autosave-Controller mit 1.5s Debounce starten @@ -139,7 +152,13 @@ fn main() -> Result<(), Box> { let eraser_radius = 30.0_f32; // 3 mm in DIN-A4-Einheiten let stroke_max_radius = 12.0_f32; - update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status()); + update_window_title( + &window, + ¬ebook, + &manifest, + ui_state.active_tool, + autosave.status(), + ); println!("=== ink-notes (Multi-Color & Objektauswahl/Transformation) bereit ==="); println!("Toolbar:"); diff --git a/src/selection.rs b/src/selection.rs index 8583ddd..49aa0cd 100644 --- a/src/selection.rs +++ b/src/selection.rs @@ -2,8 +2,8 @@ //! //! Stellt transaktionale Manipulationen für einzelne oder mehrere Striche und geometrische Formen bereit. -use std::f32::consts::FRAC_PI_2; use glam::Vec2; +use std::f32::consts::FRAC_PI_2; use crate::canvas::CanvasState; use crate::document::{Aabb, Page, Stroke}; @@ -144,16 +144,16 @@ impl SelectionState { // 2. 4 Eck-Skalierungsgriffe testen let corners_page = [ - aabb.min, // 0: oben-links - Vec2::new(aabb.max.x, aabb.min.y), // 1: oben-rechts - aabb.max, // 2: unten-rechts - Vec2::new(aabb.min.x, aabb.max.y), // 3: unten-links + aabb.min, // 0: oben-links + Vec2::new(aabb.max.x, aabb.min.y), // 1: oben-rechts + aabb.max, // 2: unten-rechts + Vec2::new(aabb.min.x, aabb.max.y), // 3: unten-links ]; let fixed_corners_page = [ - aabb.max, // gegenüber 0 -> unten-rechts - Vec2::new(aabb.min.x, aabb.max.y), // gegenüber 1 -> unten-links - aabb.min, // gegenüber 2 -> oben-links - Vec2::new(aabb.max.x, aabb.min.y), // gegenüber 3 -> oben-rechts + aabb.max, // gegenüber 0 -> unten-rechts + Vec2::new(aabb.min.x, aabb.max.y), // gegenüber 1 -> unten-links + aabb.min, // gegenüber 2 -> oben-links + Vec2::new(aabb.max.x, aabb.min.y), // gegenüber 3 -> oben-rechts ]; let corners_screen = [ @@ -285,7 +285,11 @@ pub fn render_selection_overlay( )); // 1. Wenn aktuell ein Auswahlrahmen gezogen wird (Marquee Box) - if let DragMode::BoxSelect { start_page, current_page } = selection.drag_mode { + if let DragMode::BoxSelect { + start_page, + current_page, + } = selection.drag_mode + { let p1 = canvas.page_to_screen(start_page); let p2 = canvas.page_to_screen(current_page); let min_x = p1[0].min(p2[0]); @@ -304,7 +308,10 @@ pub fn render_selection_overlay( painter.rect_stroke( rect, egui::Rounding::same(4.0_f32), - egui::Stroke::new(1.5_f32, egui::Color32::from_rgba_premultiplied(80, 160, 255, 220)), + egui::Stroke::new( + 1.5_f32, + egui::Color32::from_rgba_premultiplied(80, 160, 255, 220), + ), ); return; } @@ -357,7 +364,11 @@ pub fn render_selection_overlay( for c in corners { let handle_rect = egui::Rect::from_center_size(c, egui::vec2(handle_size, handle_size)); - painter.rect_filled(handle_rect, egui::Rounding::same(1.5_f32), egui::Color32::WHITE); + painter.rect_filled( + handle_rect, + egui::Rounding::same(1.5_f32), + egui::Color32::WHITE, + ); painter.rect_stroke( handle_rect, egui::Rounding::same(1.5_f32), @@ -369,7 +380,11 @@ pub fn render_selection_overlay( let badge_text = format!( "{} {} | [R] Drehen [Entf] Löschen", selection.count(), - if selection.count() == 1 { "Objekt" } else { "Objekte" } + if selection.count() == 1 { + "Objekt" + } else { + "Objekte" + } ); let badge_pos = egui::pos2(rect.center().x, rect.max.y + 16.0); painter.text( @@ -459,7 +474,9 @@ mod tests { let orig_pos = page.strokes[0].points[0].position; assert!(sel.rotate_selected_90_deg(&mut page)); let rotated_pos = page.strokes[0].points[0].position; - assert!((orig_pos.x - rotated_pos.x).abs() > 0.1 || (orig_pos.y - rotated_pos.y).abs() > 0.1); + assert!( + (orig_pos.x - rotated_pos.x).abs() > 0.1 || (orig_pos.y - rotated_pos.y).abs() > 0.1 + ); // Undo rotate assert!(page.undo()); diff --git a/src/spline.rs b/src/spline.rs index 44fc9b0..8f7c109 100644 --- a/src/spline.rs +++ b/src/spline.rs @@ -355,11 +355,11 @@ fn rdp_mark(positions: &[glam::Vec2], start: usize, end: usize, epsilon: f32, ke let mut max_dist = 0.0_f32; let mut max_idx = start; - for i in (start + 1)..end { - let dist = point_to_segment_distance(positions[i], a, b); + for (offset, &pos) in positions[(start + 1)..end].iter().enumerate() { + let dist = point_to_segment_distance(pos, a, b); if dist > max_dist { max_dist = dist; - max_idx = i; + max_idx = start + 1 + offset; } } @@ -418,10 +418,7 @@ pub fn detect_corners(samples: &[StylusSample], threshold_deg: f32) -> Vec Vec { +fn sample_smooth_run(control_points: &[ControlPoint], config: &SplineConfig) -> Vec { match control_points.len() { 0 => Vec::new(), 1 => { @@ -621,7 +618,12 @@ pub fn generate_finalized_triangle_strip( /// 3. Ramer-Douglas-Peucker (RDP) Dezimierung (Toleranz ca. 0.05 bis 0.1 mm) /// 4. Generierung der finalen Triangle-Strips mit runden Kappen an Start und Ende. pub fn finalize_stroke(raw_samples: &[StylusSample]) -> Stroke { - finalize_stroke_with_config(0, raw_samples, [0.0, 0.0, 0.0, 1.0], &SplineConfig::default()) + finalize_stroke_with_config( + 0, + raw_samples, + [0.0, 0.0, 0.0, 1.0], + &SplineConfig::default(), + ) } /// Vollständige Finalisierung mit konfigurierbarer Stroke-ID, Farbe und SplineConfig. @@ -808,9 +810,10 @@ mod tests { assert!(!stroke.vertices.is_empty()); // 2. Der Eckpunkt (100.0, 0.0) muss in den Strichpunkten erhalten bleiben - let corner_found = stroke.points.iter().any(|s| { - (s.position.x - 100.0).abs() < 1e-3 && (s.position.y - 0.0).abs() < 1e-3 - }); + let corner_found = stroke + .points + .iter() + .any(|s| (s.position.x - 100.0).abs() < 1e-3 && (s.position.y - 0.0).abs() < 1e-3); assert!( corner_found, "Eckpunkt (100.0, 0.0) muss in den finalen Strichpunkten erhalten bleiben!" @@ -837,7 +840,11 @@ mod tests { // 4. Ecken-Erkennung prüft den 90°-Winkel (> 65 Grad) let corners = detect_corners(&stroke.points, CORNER_ANGLE_THRESHOLD_DEG); - assert_eq!(corners.len(), 1, "Exakt eine 90-Grad-Ecke muss erkannt werden"); + assert_eq!( + corners.len(), + 1, + "Exakt eine 90-Grad-Ecke muss erkannt werden" + ); let corner_idx = corners[0]; let corner_pt = stroke.points[corner_idx]; assert!((corner_pt.position.x - 100.0).abs() < 1e-3); @@ -847,9 +854,21 @@ mod tests { #[test] fn test_pressure_smoothing() { let samples = vec![ - StylusSample { position: glam::Vec2::new(0.0, 0.0), pressure: 0.2, timestamp: 0.0 }, - StylusSample { position: glam::Vec2::new(1.0, 0.0), pressure: 0.8, timestamp: 0.1 }, - StylusSample { position: glam::Vec2::new(2.0, 0.0), pressure: 0.2, timestamp: 0.2 }, + StylusSample { + position: glam::Vec2::new(0.0, 0.0), + pressure: 0.2, + timestamp: 0.0, + }, + StylusSample { + position: glam::Vec2::new(1.0, 0.0), + pressure: 0.8, + timestamp: 0.1, + }, + StylusSample { + position: glam::Vec2::new(2.0, 0.0), + pressure: 0.2, + timestamp: 0.2, + }, ]; let smoothed = smooth_pressure(&samples); diff --git a/src/storage.rs b/src/storage.rs index 7ecd6dc..4271ec8 100644 --- a/src/storage.rs +++ b/src/storage.rs @@ -69,7 +69,12 @@ impl StrokeData { /// Wandelt die semantischen Daten deterministisch zurück in einen `Stroke` um, /// inklusive Wiederherstellung des Triangle-Strip-Vertexpuffers und der AABB. pub fn to_stroke(&self) -> Stroke { - let stylus_samples: Vec = self.samples.iter().copied().map(StylusSample::from).collect(); + let stylus_samples: Vec = self + .samples + .iter() + .copied() + .map(StylusSample::from) + .collect(); let vertices = if let Some(ref shape) = self.shape { let (_, shape_verts) = crate::geometry::shape_to_mesh(shape, 20.0, self.color); shape_verts @@ -207,7 +212,11 @@ pub fn reconstruct_stroke_vertices(samples: &[StylusSample], color: [f32; 4]) -> let angle = (i as f32 / segments as f32) * std::f32::consts::TAU; let offset = glam::Vec2::new(angle.cos() * radius, angle.sin() * radius); vertices.push(InkVertex::new(pt.position.x, pt.position.y, color)); - vertices.push(InkVertex::new(pt.position.x + offset.x, pt.position.y + offset.y, color)); + vertices.push(InkVertex::new( + pt.position.x + offset.x, + pt.position.y + offset.y, + color, + )); } return vertices; } @@ -241,7 +250,10 @@ pub fn reconstruct_stroke_vertices(samples: &[StylusSample], color: [f32; 4]) -> /// Schreibt Inhalt atomar auf die Festplatte (über temporäre Datei + `std::fs::rename`). pub fn atomic_write_file(path: &Path, content: &[u8]) -> Result<(), io::Error> { let dir = path.parent().ok_or_else(|| { - io::Error::new(io::ErrorKind::NotFound, "Übergeordnetes Verzeichnis nicht gefunden") + io::Error::new( + io::ErrorKind::NotFound, + "Übergeordnetes Verzeichnis nicht gefunden", + ) })?; fs::create_dir_all(dir)?; @@ -475,7 +487,7 @@ pub fn list_notebooks(base_dir: &Path) -> Vec { } // Nach letztem Änderungsdatum absteigend sortieren (neueste zuerst) - list.sort_by(|a, b| b.modified_at.cmp(&a.modified_at)); + list.sort_by_key(|a| std::cmp::Reverse(a.modified_at)); list } @@ -613,7 +625,11 @@ mod tests { assert_eq!(loaded_stroke.color, [0.1, 0.2, 0.8, 1.0]); assert_eq!(loaded_stroke.points.len(), 3); - for (orig, loaded) in page.strokes[0].points.iter().zip(loaded_stroke.points.iter()) { + for (orig, loaded) in page.strokes[0] + .points + .iter() + .zip(loaded_stroke.points.iter()) + { assert!((orig.position.x - loaded.position.x).abs() < 1e-4); assert!((orig.position.y - loaded.position.y).abs() < 1e-4); assert!((orig.pressure - loaded.pressure).abs() < 1e-4); @@ -633,12 +649,16 @@ mod tests { // Beschädigtes, unvollständiges JSON manuell schreiben let corrupted_file = path.join("page_99.json"); - let truncated_json = br#"{"id": 99, "background": "Grid5mm", "strokes": [{"id": 1, "color": [0.1, 0.2"#; + let truncated_json = + br#"{"id": 99, "background": "Grid5mm", "strokes": [{"id": 1, "color": [0.1, 0.2"#; fs::write(&corrupted_file, truncated_json).expect("Konnte Datei nicht schreiben"); // Laden muss einen abgefangenen CorruptedPage-Fehler liefern und darf nicht panicken let load_res = load_page(path, 99); - assert!(load_res.is_err(), "Laden beschädigter Daten muss fehlschlagen"); + assert!( + load_res.is_err(), + "Laden beschädigter Daten muss fehlschlagen" + ); match load_res.unwrap_err() { StorageError::CorruptedPage(id, msg) => { @@ -729,16 +749,21 @@ mod tests { // 3. Auflisten let summaries = list_notebooks(base_dir); assert_eq!(summaries.len(), 2); - assert!(summaries.iter().any(|s| s.id == m1.id && s.title == "Mathe Vorlesung")); - assert!(summaries.iter().any(|s| s.id == m2.id && s.title == "Skizzen & Entwürfe")); + assert!(summaries + .iter() + .any(|s| s.id == m1.id && s.title == "Mathe Vorlesung")); + assert!(summaries + .iter() + .any(|s| s.id == m2.id && s.title == "Skizzen & Entwürfe")); // 4. Umbenennen - rename_notebook(&dir1, &mut m1, "Höhere Mathematik") - .expect("Umbenennen muss klappen"); + rename_notebook(&dir1, &mut m1, "Höhere Mathematik").expect("Umbenennen muss klappen"); assert_eq!(m1.title, "Höhere Mathematik"); let updated_summaries = list_notebooks(base_dir); - assert!(updated_summaries.iter().any(|s| s.id == m1.id && s.title == "Höhere Mathematik")); + assert!(updated_summaries + .iter() + .any(|s| s.id == m1.id && s.title == "Höhere Mathematik")); // 5. Löschen delete_notebook(&dir2).expect("Löschen muss klappen"); diff --git a/src/ui.rs b/src/ui.rs index ad48302..3d50835 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -81,11 +81,8 @@ pub const PEN_WIDTH_PRESETS: [(f32, &str); 4] = [ ]; /// Dicken-Presets für den Textmarker: -pub const HIGHLIGHTER_WIDTH_PRESETS: [(f32, &str); 3] = [ - (4.0, "4.0 mm"), - (6.0, "6.0 mm"), - (8.0, "8.0 mm"), -]; +pub const HIGHLIGHTER_WIDTH_PRESETS: [(f32, &str); 3] = + [(4.0, "4.0 mm"), (6.0, "6.0 mm"), (8.0, "8.0 mm")]; /// Aktionen, die von der UI an die Anwendungslogik signalisiert werden. #[derive(Debug, Clone, PartialEq)] @@ -111,7 +108,10 @@ pub enum UiAction { // Dokumenten- & Notizbuch-Verwaltung OpenNotebook(String), CreateNotebook(String), - RenameNotebook { id: String, new_title: String }, + RenameNotebook { + id: String, + new_title: String, + }, DeleteNotebook(String), RefreshNotebooks, // Gitea Release & Updater @@ -119,7 +119,10 @@ pub enum UiAction { DownloadUpdateAsset(crate::updater::GiteaAsset), InstallOrOpenUpdate(std::path::PathBuf), OpenUrlInBrowser(String), - SaveUpdaterConfig { server_url: String, auth_token: Option }, + SaveUpdaterConfig { + server_url: String, + auth_token: Option, + }, } /// Zustand der grafischen Benutzeroberfläche. @@ -312,25 +315,36 @@ fn tool_pill_button_with_indicator( ( egui::Color32::from_rgba_premultiplied(45, 80, 145, 230), egui::Color32::WHITE, - egui::Stroke::new(1.0_f32, egui::Color32::from_rgba_premultiplied(110, 165, 255, 190)), + egui::Stroke::new( + 1.0_f32, + egui::Color32::from_rgba_premultiplied(110, 165, 255, 190), + ), ) } else if response.hovered() { ( egui::Color32::from_rgba_premultiplied(255, 255, 255, 16), egui::Color32::WHITE, - egui::Stroke::new(1.0_f32, egui::Color32::from_rgba_premultiplied(255, 255, 255, 25)), + egui::Stroke::new( + 1.0_f32, + egui::Color32::from_rgba_premultiplied(255, 255, 255, 25), + ), ) } else { ( egui::Color32::from_rgba_premultiplied(255, 255, 255, 8), egui::Color32::from_gray(215), - egui::Stroke::new(1.0_f32, egui::Color32::from_rgba_premultiplied(255, 255, 255, 15)), + egui::Stroke::new( + 1.0_f32, + egui::Color32::from_rgba_premultiplied(255, 255, 255, 15), + ), ) }; // Button-Hintergrund - ui.painter().rect_filled(rect, egui::Rounding::same(8.0), bg); - ui.painter().rect_stroke(rect, egui::Rounding::same(8.0), stroke); + ui.painter() + .rect_filled(rect, egui::Rounding::same(8.0), bg); + ui.painter() + .rect_stroke(rect, egui::Rounding::same(8.0), stroke); // Text & Caret let label = if is_menu_open { @@ -359,7 +373,8 @@ fn tool_pill_button_with_indicator( (indicator_color[2] * 255.0) as u8, ((indicator_color[3] * 255.0) as u8).max(180), ); - ui.painter().rect_filled(bar_rect, egui::Rounding::same(1.5), bar_color); + ui.painter() + .rect_filled(bar_rect, egui::Rounding::same(1.5), bar_color); } response.on_hover_text(tooltip) @@ -376,13 +391,19 @@ fn tool_pill_button( ( egui::Color32::from_rgba_premultiplied(45, 80, 145, 230), egui::Color32::WHITE, - egui::Stroke::new(1.0_f32, egui::Color32::from_rgba_premultiplied(110, 165, 255, 190)), + egui::Stroke::new( + 1.0_f32, + egui::Color32::from_rgba_premultiplied(110, 165, 255, 190), + ), ) } else { ( egui::Color32::from_rgba_premultiplied(255, 255, 255, 8), egui::Color32::from_gray(215), - egui::Stroke::new(1.0_f32, egui::Color32::from_rgba_premultiplied(255, 255, 255, 15)), + egui::Stroke::new( + 1.0_f32, + egui::Color32::from_rgba_premultiplied(255, 255, 255, 15), + ), ) }; @@ -415,22 +436,33 @@ fn stroke_preview_button( let (bg, border) = if is_active { ( egui::Color32::from_rgba_premultiplied(50, 85, 150, 220), - egui::Stroke::new(1.0_f32, egui::Color32::from_rgba_premultiplied(120, 175, 255, 200)), + egui::Stroke::new( + 1.0_f32, + egui::Color32::from_rgba_premultiplied(120, 175, 255, 200), + ), ) } else if response.hovered() { ( egui::Color32::from_rgba_premultiplied(255, 255, 255, 16), - egui::Stroke::new(1.0_f32, egui::Color32::from_rgba_premultiplied(255, 255, 255, 25)), + egui::Stroke::new( + 1.0_f32, + egui::Color32::from_rgba_premultiplied(255, 255, 255, 25), + ), ) } else { ( egui::Color32::from_rgba_premultiplied(255, 255, 255, 8), - egui::Stroke::new(1.0_f32, egui::Color32::from_rgba_premultiplied(255, 255, 255, 14)), + egui::Stroke::new( + 1.0_f32, + egui::Color32::from_rgba_premultiplied(255, 255, 255, 14), + ), ) }; - ui.painter().rect_filled(rect, egui::Rounding::same(6.0), bg); - ui.painter().rect_stroke(rect, egui::Rounding::same(6.0), border); + ui.painter() + .rect_filled(rect, egui::Rounding::same(6.0), bg); + ui.painter() + .rect_stroke(rect, egui::Rounding::same(6.0), border); // Horizontale Vorschau-Linie zeichnen let line_thickness = (stroke_width_mm * 2.5).clamp(1.0, 9.0); @@ -456,7 +488,11 @@ fn stroke_preview_button( egui::Align2::CENTER_CENTER, label_text, egui::FontId::proportional(10.0), - if is_active { egui::Color32::WHITE } else { egui::Color32::from_gray(190) }, + if is_active { + egui::Color32::WHITE + } else { + egui::Color32::from_gray(190) + }, ); } @@ -464,34 +500,32 @@ fn stroke_preview_button( } /// Kompakter Strichbreiten-Preset-Button für die Haupt-Toolbar. -fn preset_width_button( - ui: &mut egui::Ui, - is_active: bool, - text: &str, -) -> egui::Response { +fn preset_width_button(ui: &mut egui::Ui, is_active: bool, text: &str) -> egui::Response { let (bg, text_color, stroke) = if is_active { ( egui::Color32::from_rgba_premultiplied(50, 85, 150, 230), egui::Color32::WHITE, - egui::Stroke::new(1.0_f32, egui::Color32::from_rgba_premultiplied(120, 175, 255, 190)), + egui::Stroke::new( + 1.0_f32, + egui::Color32::from_rgba_premultiplied(120, 175, 255, 190), + ), ) } else { ( egui::Color32::from_rgba_premultiplied(255, 255, 255, 6), egui::Color32::from_gray(190), - egui::Stroke::new(1.0_f32, egui::Color32::from_rgba_premultiplied(255, 255, 255, 12)), + egui::Stroke::new( + 1.0_f32, + egui::Color32::from_rgba_premultiplied(255, 255, 255, 12), + ), ) }; - let button = egui::Button::new( - egui::RichText::new(text) - .color(text_color) - .size(11.5), - ) - .fill(bg) - .stroke(stroke) - .rounding(egui::Rounding::same(6.0)) - .min_size(egui::vec2(0.0, 24.0)); + let button = egui::Button::new(egui::RichText::new(text).color(text_color).size(11.5)) + .fill(bg) + .stroke(stroke) + .rounding(egui::Rounding::same(6.0)) + .min_size(egui::vec2(0.0, 24.0)); ui.add(button) } @@ -512,24 +546,26 @@ fn action_pill_button( let (bg, stroke) = if enabled { ( egui::Color32::from_rgba_premultiplied(255, 255, 255, 10), - egui::Stroke::new(1.0_f32, egui::Color32::from_rgba_premultiplied(255, 255, 255, 22)), + egui::Stroke::new( + 1.0_f32, + egui::Color32::from_rgba_premultiplied(255, 255, 255, 22), + ), ) } else { ( egui::Color32::from_rgba_premultiplied(255, 255, 255, 3), - egui::Stroke::new(1.0_f32, egui::Color32::from_rgba_premultiplied(255, 255, 255, 8)), + egui::Stroke::new( + 1.0_f32, + egui::Color32::from_rgba_premultiplied(255, 255, 255, 8), + ), ) }; - let button = egui::Button::new( - egui::RichText::new(text) - .color(text_color) - .size(12.5), - ) - .fill(bg) - .stroke(stroke) - .rounding(egui::Rounding::same(7.0)) - .min_size(egui::vec2(0.0, 26.0)); + let button = egui::Button::new(egui::RichText::new(text).color(text_color).size(12.5)) + .fill(bg) + .stroke(stroke) + .rounding(egui::Rounding::same(7.0)) + .min_size(egui::vec2(0.0, 26.0)); ui.add_enabled(enabled, button).on_hover_text(tooltip) } @@ -541,7 +577,10 @@ fn toolbar_divider(ui: &mut egui::Ui) { ui.painter().vline( rect.center().x, rect.y_range(), - egui::Stroke::new(1.0_f32, egui::Color32::from_rgba_premultiplied(255, 255, 255, 25)), + egui::Stroke::new( + 1.0_f32, + egui::Color32::from_rgba_premultiplied(255, 255, 255, 25), + ), ); ui.add_space(2.0); } @@ -1014,14 +1053,22 @@ pub fn render_ui( .color(egui::Color32::WHITE) .size(12.5), ); - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - if ui - .add(egui::Button::new(egui::RichText::new("✕").size(11.0)).frame(false)) - .clicked() - { - state.is_pen_menu_open = false; - } - }); + ui.with_layout( + egui::Layout::right_to_left(egui::Align::Center), + |ui| { + if ui + .add( + egui::Button::new( + egui::RichText::new("✕").size(11.0), + ) + .frame(false), + ) + .clicked() + { + state.is_pen_menu_open = false; + } + }, + ); }); ui.add_space(8.0); @@ -1040,7 +1087,8 @@ pub fn render_ui( let is_sel = (state.pen_color[0] - color[0]).abs() < 0.05 && (state.pen_color[1] - color[1]).abs() < 0.05 && (state.pen_color[2] - color[2]).abs() < 0.05; - if color_circle_swatch(ui, color, is_sel, label, 22.0).clicked() { + if color_circle_swatch(ui, color, is_sel, label, 22.0).clicked() + { state.pen_color = color; actions.push(UiAction::SetColor(color)); } @@ -1079,7 +1127,14 @@ pub fn render_ui( ui.spacing_mut().item_spacing = egui::vec2(6.0, 0.0); for (preset, label) in PEN_WIDTH_PRESETS { let is_sel = (state.pen_stroke_width_mm - preset).abs() < 0.05; - if stroke_preview_button(ui, is_sel, preset, label, state.pen_color).clicked() + if stroke_preview_button( + ui, + is_sel, + preset, + label, + state.pen_color, + ) + .clicked() { state.pen_stroke_width_mm = preset; actions.push(UiAction::SetStrokeWidthMm(preset)); @@ -1145,14 +1200,22 @@ pub fn render_ui( .color(egui::Color32::WHITE) .size(12.5), ); - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - if ui - .add(egui::Button::new(egui::RichText::new("✕").size(11.0)).frame(false)) - .clicked() - { - state.is_highlighter_menu_open = false; - } - }); + ui.with_layout( + egui::Layout::right_to_left(egui::Align::Center), + |ui| { + if ui + .add( + egui::Button::new( + egui::RichText::new("✕").size(11.0), + ) + .frame(false), + ) + .clicked() + { + state.is_highlighter_menu_open = false; + } + }, + ); }); ui.add_space(8.0); @@ -1168,10 +1231,12 @@ pub fn render_ui( ui.horizontal(|ui| { ui.spacing_mut().item_spacing = egui::vec2(8.0, 0.0); for (color, label) in HIGHLIGHTER_COLOR_PRESETS { - let is_sel = (state.highlighter_color[0] - color[0]).abs() < 0.05 + let is_sel = (state.highlighter_color[0] - color[0]).abs() + < 0.05 && (state.highlighter_color[1] - color[1]).abs() < 0.05 && (state.highlighter_color[2] - color[2]).abs() < 0.05; - if color_circle_swatch(ui, color, is_sel, label, 22.0).clicked() { + if color_circle_swatch(ui, color, is_sel, label, 22.0).clicked() + { state.highlighter_color = color; actions.push(UiAction::SetColor(color)); } @@ -1208,7 +1273,8 @@ pub fn render_ui( ui.horizontal(|ui| { ui.spacing_mut().item_spacing = egui::vec2(6.0, 0.0); for (preset, label) in HIGHLIGHTER_WIDTH_PRESETS { - let is_sel = (state.highlighter_stroke_width_mm - preset).abs() < 0.1; + let is_sel = + (state.highlighter_stroke_width_mm - preset).abs() < 0.1; if stroke_preview_button( ui, is_sel, @@ -1236,13 +1302,18 @@ pub fn render_ui( ui.add_space(2.0); if ui .add( - egui::Slider::new(&mut state.highlighter_stroke_width_mm, 2.0..=16.0) - .suffix(" mm") - .step_by(0.5), + egui::Slider::new( + &mut state.highlighter_stroke_width_mm, + 2.0..=16.0, + ) + .suffix(" mm") + .step_by(0.5), ) .changed() { - actions.push(UiAction::SetStrokeWidthMm(state.highlighter_stroke_width_mm)); + actions.push(UiAction::SetStrokeWidthMm( + state.highlighter_stroke_width_mm, + )); } }); @@ -1299,16 +1370,18 @@ pub fn render_ui( ); ui.separator(); - egui::ScrollArea::vertical().max_height(220.0).show(ui, |ui| { - for idx in 0..notebook.pages.len() { - let is_current = idx == notebook.current_page_index; - let text = format!("Seite {}", idx + 1); - if tool_pill_button(ui, is_current, &text, "").clicked() { - actions.push(UiAction::GoToPage(idx)); - state.show_page_jump_popup = false; + egui::ScrollArea::vertical() + .max_height(220.0) + .show(ui, |ui| { + for idx in 0..notebook.pages.len() { + let is_current = idx == notebook.current_page_index; + let text = format!("Seite {}", idx + 1); + if tool_pill_button(ui, is_current, &text, "").clicked() { + actions.push(UiAction::GoToPage(idx)); + state.show_page_jump_popup = false; + } } - } - }); + }); ui.separator(); if action_pill_button(ui, true, "Schließen", "Fenster schließen").clicked() { @@ -1471,13 +1544,13 @@ pub fn render_ui( }); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - if state.available_notebooks.len() > 1 { - if ui.button(egui::RichText::new("🗑").size(11.0).color(egui::Color32::from_rgb(255, 120, 120))) + if state.available_notebooks.len() > 1 + && ui + .button(egui::RichText::new("🗑").size(11.0).color(egui::Color32::from_rgb(255, 120, 120))) .on_hover_text("Notizbuch unwiderruflich löschen") .clicked() - { - actions.push(UiAction::DeleteNotebook(summary.id.clone())); - } + { + actions.push(UiAction::DeleteNotebook(summary.id.clone())); } if ui.button(egui::RichText::new("✏").size(11.0)) @@ -1701,10 +1774,10 @@ pub fn render_ui( } ui.add_space(8.0); - if !release.html_url.is_empty() { - if ui.button(egui::RichText::new("🌐 Release auf Gitea öffnen").size(12.0)).clicked() { - actions.push(UiAction::OpenUrlInBrowser(release.html_url.clone())); - } + if !release.html_url.is_empty() + && ui.button(egui::RichText::new("🌐 Release auf Gitea öffnen").size(12.0)).clicked() + { + actions.push(UiAction::OpenUrlInBrowser(release.html_url.clone())); } }); } @@ -1810,7 +1883,12 @@ pub fn render_ui( .order(egui::Order::Tooltip) .show(ctx, |ui| { egui::Frame::none() - .fill(egui::Color32::from_rgba_unmultiplied(20, 25, 30, alpha.min(235))) + .fill(egui::Color32::from_rgba_unmultiplied( + 20, + 25, + 30, + alpha.min(235), + )) .rounding(egui::Rounding::same(10.0)) .stroke(egui::Stroke::new( 1.0_f32, @@ -1826,7 +1904,9 @@ pub fn render_ui( .show(ui, |ui| { ui.label( egui::RichText::new(format!("✓ {}", msg)) - .color(egui::Color32::from_rgba_unmultiplied(220, 255, 230, alpha)) + .color(egui::Color32::from_rgba_unmultiplied( + 220, 255, 230, alpha, + )) .size(13.0) .strong(), ); diff --git a/src/updater.rs b/src/updater.rs index e1e189e..670ab53 100644 --- a/src/updater.rs +++ b/src/updater.rs @@ -1,18 +1,27 @@ //! In-App Updater für ink-notes mit Gitea-Releases-Integration. //! -//! Unterstützt: -//! - Versionsabfrage gegen Gitea API (`/api/v1/repos/{owner}/{repo}/releases/latest`) -//! - SemVer-Vergleich (`remote > current`) -//! - Erkennung der Laufzeitumgebung (AppImage, .deb-Paket, Entwicklungs-Binary) -//! - Asynchroner Download ohne Blockierung der Rendering-Schleife -//! - Direkte AppImage-Aktualisierung bzw. Debian-Paket-Installation via xdg-open +//! Sicherheits- und Zuverlässigkeitsmerkmale: +//! - Rust-nativer HTTP-Client (`ureq` mit rustls) ohne externe CLI-Abhängigkeit +//! - Erzwingen von HTTPS (unsicheres HTTP standardmäßig blockiert) +//! - Kein Leak von Authorization-Tokens bei HTTP-Redirects auf fremde Origins +//! - Sichere Asset-Namensbereinigung (Path Traversal Schutz) +//! - Isolierter Benutzer-Cache-Pfad (`$XDG_CACHE_HOME/ink-notes/updates` mit 0700 Rechten) +//! - Integritätsprüfung via SHA-256 (`SHA256SUMS.txt`) mit automatischem Löschen ungültiger Dateien +//! - Erweiterbare Schnittstelle für optionale Signaturprüfung (.sig / .minisig) +//! - Atomares AppImage-Self-Update via `.new`-Zwischendatei, Backup-Pflicht und atomarem `fs::rename` +//! - Echtes Fortschritts-Streaming (~10 Updates/s) +//! - Korrekte SemVer-Prüfung via `semver` Crate (Prerelease-Schutz standardmäßig aktiv) +use std::collections::HashMap; +use std::io::{Read, Write}; use std::path::{Path, PathBuf}; use std::process::Command; use std::sync::mpsc::{channel, Receiver, Sender}; use std::thread; +use std::time::{Duration, Instant}; use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; /// Konfiguration für den Gitea-Update-Server. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] @@ -25,21 +34,44 @@ pub struct UpdaterConfig { pub repo_name: String, /// Optionales Personal Access Token für private Repositories pub auth_token: Option, + /// Ob unsicheres HTTP ausnahmsweise erlaubt ist (Standard: false) + #[serde(default)] + pub allow_insecure_http: bool, + /// Ob Prerelease-Versionen als Update angeboten werden sollen (Standard: false) + #[serde(default)] + pub include_prereleases: bool, } impl Default for UpdaterConfig { fn default() -> Self { - // Falls Umgebungsvariable gesetzt ist, wird das Token automatisch übernommen let auth_token = std::env::var("INK_NOTES_GITEA_TOKEN").ok(); Self { server_url: "https://gitea.pansi.eu".to_string(), repo_owner: "harald".to_string(), repo_name: "ink-notes".to_string(), auth_token, + allow_insecure_http: false, + include_prereleases: false, } } } +impl UpdaterConfig { + /// Validiert die Server-URL auf Protokollsicherheit (erzwingt HTTPS). + pub fn validate_url(&self) -> Result { + let parsed = url::Url::parse(&self.server_url) + .map_err(|e| format!("Ungültige Server-URL '{}': {e}", self.server_url))?; + + if parsed.scheme() != "https" && !self.allow_insecure_http { + return Err(format!( + "Unsicheres Protokoll '{}' abgewiesen. Es wird zwingend HTTPS verlangt (z. B. https://gitea.pansi.eu).", + parsed.scheme() + )); + } + Ok(parsed) + } +} + /// Ein Release-Asset aus der Gitea API. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct GiteaAsset { @@ -82,26 +114,32 @@ pub enum RuntimeEnvironment { } impl RuntimeEnvironment { - /// Erkennt die aktuelle Laufzeitumgebung automatisch. + /// Erkennt die aktuelle Laufzeitumgebung anhand der Umgebung und des Binärpfads. pub fn detect() -> Self { - if let Ok(appimage_path) = std::env::var("APPIMAGE") { - if !appimage_path.is_empty() { + let appimage_var = std::env::var("APPIMAGE").ok(); + let current_exe = std::env::current_exe().unwrap_or_else(|_| PathBuf::from("ink-notes")); + Self::detect_from(appimage_var.as_deref(), ¤t_exe) + } + + /// Testbare Kernlogik zur Erkennung der Laufzeitumgebung. + pub fn detect_from(appimage_var: Option<&str>, current_exe: &Path) -> Self { + if let Some(appimage_path) = appimage_var { + let trimmed = appimage_path.trim(); + if !trimmed.is_empty() { return RuntimeEnvironment::AppImage { - appimage_path: PathBuf::from(appimage_path), + appimage_path: PathBuf::from(trimmed), }; } } - let current_exe = std::env::current_exe().unwrap_or_else(|_| PathBuf::from("ink-notes")); let exe_str = current_exe.to_string_lossy(); - if exe_str.starts_with("/usr/bin/") || exe_str.starts_with("/usr/local/bin/") { RuntimeEnvironment::DebPackage { - binary_path: current_exe, + binary_path: current_exe.to_path_buf(), } } else { RuntimeEnvironment::PortableOrDev { - binary_path: current_exe, + binary_path: current_exe.to_path_buf(), } } } @@ -115,37 +153,198 @@ impl RuntimeEnvironment { } } -/// Zerlegt einen Versionstag wie "v0.1.2" oder "0.1.2-beta" in numerische Komponenten. -pub fn parse_semver(tag: &str) -> Option<(u64, u64, u64)> { - let clean = tag.trim().trim_start_matches('v').trim_start_matches('V'); - let version_part = clean.split('-').next()?; // Prerelease-Suffix abtrennen - let mut parts = version_part.split('.'); +/// Bereinigt und validiert einen Asset-Dateinamen gegen Path Traversal Angriffe. +pub fn sanitize_asset_name(raw_name: &str) -> Result { + let trimmed = raw_name.trim(); + if trimmed.is_empty() { + return Err("Asset-Name darf nicht leer sein.".to_string()); + } + if trimmed.contains('/') || trimmed.contains('\\') || trimmed.contains("..") { + return Err(format!( + "Ungültiger Asset-Name (enthält unzulässige Pfadkomponenten): '{raw_name}'" + )); + } + let path = Path::new(trimmed); + let file_name = path + .file_name() + .and_then(|n| n.to_str()) + .ok_or_else(|| format!("Ungültiger Dateiname: '{raw_name}'"))?; - let major: u64 = parts.next()?.parse().ok()?; - let minor: u64 = parts.next()?.parse().ok()?; - let patch: u64 = parts.next()?.parse().ok()?; + if file_name != trimmed || file_name.starts_with('.') { + return Err(format!( + "Ungültiger oder versteckter Asset-Name: '{raw_name}'" + )); + } + Ok(file_name.to_string()) +} - Some((major, minor, patch)) +/// Liefert das isolierte Cache-Verzeichnis für Downloads pro Nutzer mit Rechten 0700. +pub fn get_updates_cache_dir() -> Result { + let base = if let Ok(xdg) = std::env::var("XDG_CACHE_HOME") { + let trimmed = xdg.trim(); + if !trimmed.is_empty() { + PathBuf::from(trimmed) + } else { + default_user_cache_dir()? + } + } else { + default_user_cache_dir()? + }; + + let dir = base.join("ink-notes").join("updates"); + std::fs::create_dir_all(&dir).map_err(|e| { + format!( + "Konnte Update-Cache-Verzeichnis nicht anlegen ({}): {e}", + dir.display() + ) + })?; + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let _ = std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700)); + } + + Ok(dir) +} + +fn default_user_cache_dir() -> Result { + let home = std::env::var("HOME") + .map_err(|_| "HOME-Umgebungsvariable ist nicht gesetzt.".to_string())?; + Ok(PathBuf::from(home).join(".cache")) +} + +/// Löscht alte temporäre Downloads aus dem Cache-Verzeichnis. +pub fn cleanup_old_updates() { + if let Ok(dir) = get_updates_cache_dir() { + if let Ok(entries) = std::fs::read_dir(dir) { + for entry in entries.flatten() { + let path = entry.path(); + if path.is_file() { + let _ = std::fs::remove_file(path); + } + } + } + } +} + +/// Zerlegt einen Versionstag wie "v0.1.2" oder "0.1.2" in ein `semver::Version`. +pub fn parse_semver(tag: &str) -> Option { + let clean = tag.trim().trim_start_matches(['v', 'V']); + semver::Version::parse(clean).ok() } /// Prüft, ob `remote_tag` numerisch neuer ist als `current_tag`. -pub fn is_newer_version(remote_tag: &str, current_tag: &str) -> bool { +/// Prerelease-Versionen (z. B. `-rc1`) werden standardmäßig ausgeschlossen. +pub fn is_newer_version(remote_tag: &str, current_tag: &str, include_prereleases: bool) -> bool { match (parse_semver(remote_tag), parse_semver(current_tag)) { - (Some((r_maj, r_min, r_pat)), Some((c_maj, c_min, c_pat))) => { - (r_maj, r_min, r_pat) > (c_maj, c_min, c_pat) + (Some(remote), Some(current)) => { + if !include_prereleases && !remote.pre.is_empty() { + return false; + } + remote > current } _ => false, } } +/// 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| { + format!( + "Konnte Datei '{}' zur Prüfsummenberechnung nicht öffnen: {e}", + path.display() + ) + })?; + let mut hasher = Sha256::new(); + let mut buffer = [0u8; 16384]; + loop { + let n = file + .read(&mut buffer) + .map_err(|e| format!("Fehler beim Lesen der Prüfsummendatei: {e}"))?; + if n == 0 { + break; + } + hasher.update(&buffer[..n]); + } + Ok(format!("{:x}", hasher.finalize())) +} + +/// Parst eine `SHA256SUMS.txt` Datei in ein Mapping von Dateiname -> Hash. +pub fn parse_checksums(content: &str) -> HashMap { + let mut map = HashMap::new(); + for line in content.lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + let parts: Vec<&str> = line.split_whitespace().collect(); + if parts.len() >= 2 { + let hash = parts[0].to_lowercase(); + let raw_file = parts[1].trim_start_matches('*'); + if let Ok(safe_name) = sanitize_asset_name(raw_file) { + map.insert(safe_name, hash); + } + } + } + map +} + +/// Verifiziert die Integrität einer Datei anhand von `SHA256SUMS.txt`. +/// Bei fehlender oder nicht übereinstimmender Prüfsumme wird die Datei gelöscht! +pub fn verify_checksum( + downloaded_path: &Path, + asset_name: &str, + checksums_content: &str, +) -> Result<(), String> { + let checksums = parse_checksums(checksums_content); + let expected_hash = checksums.get(asset_name).ok_or_else(|| { + let _ = std::fs::remove_file(downloaded_path); + format!("Kein Eintrag für '{asset_name}' in SHA256SUMS.txt gefunden. Heruntergeladene Datei wurde gelöscht.") + })?; + + let actual_hash = compute_sha256(downloaded_path)?; + if !actual_hash.eq_ignore_ascii_case(expected_hash) { + let _ = std::fs::remove_file(downloaded_path); + return Err(format!( + "Integritätsprüfung fehlgeschlagen für '{asset_name}': Erwartet {expected_hash}, berechnet {actual_hash}. Datei wurde gelöscht." + )); + } + Ok(()) +} + +/// Status einer Signaturverifikation (.sig / .minisig). +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SignatureVerificationStatus { + NotPresent, + Verified { signer: String }, + Failed(String), +} + +/// Erweiterbare Schnittstelle für kryptografische Signaturprüfung. +pub fn verify_signature_if_present( + _asset_file: &Path, + _sig_bytes: Option<&[u8]>, +) -> Result { + // Erweiterungspunkt: Sobald ein Signaturschlüssel / Minisign-Public-Key + // im Projekt konfiguriert ist, kann hier die kryptografische Verifikation erfolgen. + Ok(SignatureVerificationStatus::NotPresent) +} + /// Event-Typen für den asynchronen Worker. #[derive(Debug)] pub enum UpdaterEvent { CheckSuccess(GiteaRelease), CheckUpToDate(String), CheckError(String), - DownloadProgress { percent: f32, message: String }, - DownloadSuccess { file_path: PathBuf, asset_name: String }, + DownloadProgress { + percent: f32, + message: String, + }, + DownloadSuccess { + file_path: PathBuf, + asset_name: String, + }, DownloadError(String), } @@ -155,28 +354,99 @@ pub enum UpdaterStatus { Idle, Checking, UpdateAvailable(GiteaRelease), - UpToDate { current_version: String }, - Downloading { asset_name: String, percent: f32 }, - DownloadFinished { file_path: PathBuf, asset_name: String }, + UpToDate { + current_version: String, + }, + Downloading { + asset_name: String, + percent: f32, + }, + DownloadFinished { + file_path: PathBuf, + asset_name: String, + }, Error(String), } +/// Erstellt einen ureq Agent mit sauberen Timeouts. +fn create_http_agent() -> ureq::Agent { + ureq::AgentBuilder::new() + .timeout_connect(Duration::from_secs(10)) + .timeout_read(Duration::from_secs(180)) + .redirects(0) // Manuelle Redirect-Behandlung zum Schutz des Authorization-Tokens + .build() +} + +/// Führt einen GET-Request mit Redirect-Schutz aus: +/// Bei Weiterleitungen auf andere Hosts (z. B. S3, Fremd-CDNs) wird der Authorization-Header verworfen. +fn http_get_with_auth_guard( + agent: &ureq::Agent, + initial_url_str: &str, + token: Option<&str>, + max_redirects: usize, +) -> Result { + let mut current_url = url::Url::parse(initial_url_str) + .map_err(|e| format!("Ungültige URL '{initial_url_str}': {e}"))?; + let initial_origin = current_url.origin().ascii_serialization(); + + for _ in 0..=max_redirects { + let mut req = agent.get(current_url.as_str()); + + // Token NUR anhängen, wenn Origin (Scheme, Host, Port) identisch ist + if let Some(tok) = token { + let tok_trimmed = tok.trim(); + if !tok_trimmed.is_empty() + && current_url.origin().ascii_serialization() == initial_origin + { + req = req.set("Authorization", &format!("token {tok_trimmed}")); + } + } + + match req.call() { + Ok(resp) => return Ok(resp), + Err(ureq::Error::Status(code, resp)) if (301..=308).contains(&code) => { + let loc = resp.header("Location").ok_or_else(|| { + format!("HTTP {code} Weiterleitung ohne Location-Header erhalten") + })?; + current_url = current_url + .join(loc) + .map_err(|e| format!("Ungültiges Weiterleitungsziel '{loc}': {e}"))?; + continue; + } + Err(ureq::Error::Status(code, resp)) => { + let body = resp.into_string().unwrap_or_default(); + return Err(format!("Gitea-Server antwortete mit HTTP {code}: {body}")); + } + Err(ureq::Error::Transport(t)) => { + return Err(format!("Netzwerk-Transportfehler: {t}")); + } + } + } + + Err("Zu viele Weiterleitungen (Redirect-Schleife abgebrochen).".to_string()) +} + /// Asynchroner Controller für Update-Prüfung und Asset-Download. pub struct UpdaterController { config: UpdaterConfig, runtime: RuntimeEnvironment, status: UpdaterStatus, + active_release: Option, receiver: Receiver, sender: Sender, } impl UpdaterController { pub fn new() -> Self { + // Alten Download-Cache beim Start aufräumen + cleanup_old_updates(); + let (sender, receiver) = channel(); Self { config: UpdaterConfig::default(), runtime: RuntimeEnvironment::detect(), status: UpdaterStatus::Idle, + active_release: None, receiver, sender, } @@ -204,7 +474,10 @@ impl UpdaterController { /// Fragt im Hintergrund die Gitea-API ab. pub fn check_for_updates(&mut self) { - if matches!(self.status, UpdaterStatus::Checking | UpdaterStatus::Downloading { .. }) { + if matches!( + self.status, + UpdaterStatus::Checking | UpdaterStatus::Downloading { .. } + ) { return; } @@ -214,6 +487,11 @@ impl UpdaterController { let current_version = env!("CARGO_PKG_VERSION").to_string(); thread::spawn(move || { + if let Err(e) = config.validate_url() { + let _ = tx.send(UpdaterEvent::CheckError(e)); + return; + } + let api_url = format!( "{}/api/v1/repos/{}/{}/releases/latest", config.server_url.trim_end_matches('/'), @@ -221,135 +499,220 @@ impl UpdaterController { config.repo_name ); - let mut cmd = Command::new("curl"); - cmd.arg("-fsSL") - .arg("-m") - .arg("10") - .arg("-H") - .arg("Accept: application/json"); - - if let Some(token) = &config.auth_token { - if !token.is_empty() { - cmd.arg("-H").arg(format!("Authorization: token {token}")); - } - } - - cmd.arg(&api_url); - - match cmd.output() { - Ok(output) => { - if output.status.success() { - match serde_json::from_slice::(&output.stdout) { - Ok(release) => { - if is_newer_version(&release.tag_name, ¤t_version) { - let _ = tx.send(UpdaterEvent::CheckSuccess(release)); - } else { - let _ = tx.send(UpdaterEvent::CheckUpToDate(current_version)); - } - } - Err(e) => { - let _ = tx.send(UpdaterEvent::CheckError(format!( - "JSON-Parsefehler der Gitea API: {e}" - ))); - } + let agent = create_http_agent(); + match http_get_with_auth_guard(&agent, &api_url, config.auth_token.as_deref(), 5) { + Ok(resp) => match resp.into_json::() { + Ok(release) => { + if release.prerelease && !config.include_prereleases { + let _ = tx.send(UpdaterEvent::CheckUpToDate(current_version)); + } else if is_newer_version( + &release.tag_name, + ¤t_version, + config.include_prereleases, + ) { + let _ = tx.send(UpdaterEvent::CheckSuccess(release)); + } else { + let _ = tx.send(UpdaterEvent::CheckUpToDate(current_version)); } - } else { - let stderr = String::from_utf8_lossy(&output.stderr); + } + Err(e) => { let _ = tx.send(UpdaterEvent::CheckError(format!( - "Gitea API antwortete mit Fehler (HTTP {}): {}", - output.status.code().unwrap_or(-1), - stderr.trim() + "Gitea Release-JSON konnte nicht verarbeitet werden: {e}" ))); } - } + }, Err(e) => { - let _ = tx.send(UpdaterEvent::CheckError(format!( - "curl konnte nicht ausgeführt werden: {e}" - ))); + let _ = tx.send(UpdaterEvent::CheckError(e)); } } }); } - /// Startet den asynchronen Download eines Release-Assets. + /// Startet den asynchronen Download eines Release-Assets inklusive Integritätsprüfung. pub fn download_asset(&mut self, asset: GiteaAsset) { if matches!(self.status, UpdaterStatus::Downloading { .. }) { return; } + let safe_asset_name = match sanitize_asset_name(&asset.name) { + Ok(n) => n, + Err(e) => { + self.status = UpdaterStatus::Error(e); + return; + } + }; + self.status = UpdaterStatus::Downloading { - asset_name: asset.name.clone(), + asset_name: safe_asset_name.clone(), percent: 0.0, }; let config = self.config.clone(); let tx = self.sender.clone(); + let active_release = self.active_release.clone(); thread::spawn(move || { - // Download in temporäres Verzeichnis - let cache_dir = std::env::temp_dir().join("ink-notes-updates"); - if let Err(e) = std::fs::create_dir_all(&cache_dir) { - let _ = tx.send(UpdaterEvent::DownloadError(format!( - "Konnte Download-Verzeichnis nicht anlegen: {e}" - ))); + let cache_dir = match get_updates_cache_dir() { + Ok(d) => d, + Err(e) => { + let _ = tx.send(UpdaterEvent::DownloadError(e)); + return; + } + }; + + let target_file = cache_dir.join(&safe_asset_name); + let agent = create_http_agent(); + + // 1. Download des Release-Assets mit Fortschritts-Streaming + let resp = match http_get_with_auth_guard( + &agent, + &asset.browser_download_url, + config.auth_token.as_deref(), + 5, + ) { + Ok(r) => r, + Err(e) => { + let _ = tx.send(UpdaterEvent::DownloadError(format!( + "Download-Verbindungsfehler: {e}" + ))); + return; + } + }; + + let content_length: Option = + resp.header("Content-Length").and_then(|s| s.parse().ok()); + let mut reader = resp.into_reader(); + let mut file = match std::fs::File::create(&target_file) { + Ok(f) => f, + Err(e) => { + let _ = tx.send(UpdaterEvent::DownloadError(format!( + "Konnte Zieldatei '{}' nicht erstellen: {e}", + target_file.display() + ))); + return; + } + }; + + let mut downloaded: u64 = 0; + let mut buffer = [0u8; 16384]; + let mut last_progress_time = Instant::now(); + let throttle = Duration::from_millis(100); // Max. 10 Progress-Events pro Sekunde + + loop { + let n = match reader.read(&mut buffer) { + Ok(0) => break, + Ok(count) => count, + Err(e) => { + let _ = std::fs::remove_file(&target_file); + let _ = tx.send(UpdaterEvent::DownloadError(format!( + "Fehler beim Lesen des Download-Streams: {e}" + ))); + return; + } + }; + + if let Err(e) = file.write_all(&buffer[..n]) { + let _ = std::fs::remove_file(&target_file); + let _ = tx.send(UpdaterEvent::DownloadError(format!( + "Fehler beim Schreiben auf die Festplatte: {e}" + ))); + return; + } + + downloaded += n as u64; + + if last_progress_time.elapsed() >= throttle { + let percent = match content_length { + Some(total) if total > 0 => { + (downloaded as f32 / total as f32).clamp(0.0, 1.0) + } + _ => 0.0, + }; + let msg = match content_length { + Some(total) => format!( + "{:.1} / {:.1} MB", + downloaded as f64 / 1_048_576.0, + total as f64 / 1_048_576.0 + ), + None => format!("{:.1} MB", downloaded as f64 / 1_048_576.0), + }; + let _ = tx.send(UpdaterEvent::DownloadProgress { + percent, + message: msg, + }); + last_progress_time = Instant::now(); + } + } + + drop(file); + + // 2. Integritätsprüfung gegen SHA256SUMS.txt + let checksum_asset = active_release + .as_ref() + .and_then(|r| r.assets.iter().find(|a| a.name == "SHA256SUMS.txt")); + + let checksums_content = match checksum_asset { + Some(ca) => { + match http_get_with_auth_guard( + &agent, + &ca.browser_download_url, + config.auth_token.as_deref(), + 5, + ) { + Ok(c_resp) => match c_resp.into_string() { + Ok(text) => text, + Err(e) => { + let _ = std::fs::remove_file(&target_file); + let _ = tx.send(UpdaterEvent::DownloadError(format!( + "Konnte SHA256SUMS.txt nicht dekodieren: {e}. Datei gelöscht." + ))); + return; + } + }, + Err(e) => { + let _ = std::fs::remove_file(&target_file); + let _ = tx.send(UpdaterEvent::DownloadError(format!( + "Konnte SHA256SUMS.txt nicht laden: {e}. Datei aus Sicherheitsgründen gelöscht." + ))); + return; + } + } + } + None => { + let _ = std::fs::remove_file(&target_file); + let _ = tx.send(UpdaterEvent::DownloadError( + "Release enthält keine SHA256SUMS.txt-Prüfsummen. Download abgelehnt und gelöscht.".to_string(), + )); + return; + } + }; + + if let Err(err) = verify_checksum(&target_file, &safe_asset_name, &checksums_content) { + let _ = tx.send(UpdaterEvent::DownloadError(err)); return; } - let target_file = cache_dir.join(&asset.name); - - let mut cmd = Command::new("curl"); - cmd.arg("-fsSL") - .arg("-m") - .arg("180") // Bis zu 3 Minuten Timeout für größere Pakete - .arg("-o") - .arg(&target_file); - - if let Some(token) = &config.auth_token { - if !token.is_empty() { - cmd.arg("-H").arg(format!("Authorization: token {token}")); - } - } - - cmd.arg(&asset.browser_download_url); - - match cmd.output() { - Ok(output) => { - if output.status.success() && target_file.exists() { - // Wenn es ein AppImage ist, Executable-Bit setzen - if asset.name.ends_with(".AppImage") { - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - if let Ok(metadata) = std::fs::metadata(&target_file) { - let mut perms = metadata.permissions(); - perms.set_mode(0o755); - let _ = std::fs::set_permissions(&target_file, perms); - } - } - } - - let _ = tx.send(UpdaterEvent::DownloadSuccess { - file_path: target_file, - asset_name: asset.name, - }); - } else { - let stderr = String::from_utf8_lossy(&output.stderr); - let _ = tx.send(UpdaterEvent::DownloadError(format!( - "Download fehlgeschlagen: {}", - stderr.trim() - ))); + // 3. Wenn es ein AppImage ist, Executable-Bit auf 0755 setzen + if safe_asset_name.ends_with(".AppImage") { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + if let Ok(meta) = std::fs::metadata(&target_file) { + let mut perms = meta.permissions(); + perms.set_mode(0o755); + let _ = std::fs::set_permissions(&target_file, perms); } } - Err(e) => { - let _ = tx.send(UpdaterEvent::DownloadError(format!( - "curl-Fehler beim Download: {e}" - ))); - } } + + let _ = tx.send(UpdaterEvent::DownloadSuccess { + file_path: target_file, + asset_name: safe_asset_name, + }); }); } - /// Startet die Installation des heruntergeladenen Pakets oder öffnet es im System. + /// Startet die sichere Installation des heruntergeladenen Pakets oder öffnet es im System. pub fn install_or_open(&self, file_path: &Path) -> Result<(), String> { if !file_path.exists() { return Err("Die heruntergeladene Datei existiert nicht mehr.".to_string()); @@ -357,32 +720,66 @@ impl UpdaterController { match &self.runtime { RuntimeEnvironment::AppImage { appimage_path } => { - // Bei AppImage: Die aktuelle Datei ersetzen oder neue Datei ausführen - // Sicherer Modus: Zielpfad überschreiben mit Backup - if let Some(parent) = appimage_path.parent() { - let backup_path = parent.join(format!( - "{}.bak", - appimage_path.file_name().unwrap().to_string_lossy() + let parent_dir = appimage_path.parent().ok_or_else(|| { + "Konnte übergeordnetes Verzeichnis des AppImages nicht ermitteln.".to_string() + })?; + + let file_name = appimage_path + .file_name() + .ok_or_else(|| "Ungültiger AppImage-Dateiname.".to_string())? + .to_string_lossy(); + + let temp_new_file = + parent_dir.join(format!("{}.new.{}", file_name, std::process::id())); + let backup_file = parent_dir.join(format!("{}.bak", file_name)); + + // 1. Neue Datei im SELBEN Verzeichnis anlegen (garantiert atomares rename) + if let Err(e) = std::fs::copy(file_path, &temp_new_file) { + let _ = std::fs::remove_file(&temp_new_file); + return Err(format!( + "Konnte temporäre Update-Datei nicht schreiben: {e}" )); - let _ = std::fs::copy(appimage_path, &backup_path); } - match std::fs::copy(file_path, appimage_path) { - Ok(_) => Ok(()), - Err(e) => Err(format!( - "Konnte laufendes AppImage nicht direkt überschreiben: {e}. Die neue Datei liegt unter: {}", - file_path.display() - )), + + // 2. Executable-Bit setzen + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + if let Ok(meta) = std::fs::metadata(&temp_new_file) { + let mut perms = meta.permissions(); + perms.set_mode(0o755); + let _ = std::fs::set_permissions(&temp_new_file, perms); + } } + + // 3. Backup des alten AppImages anlegen – Fehler NIEMALS ignorieren! + if let Err(e) = std::fs::copy(appimage_path, &backup_file) { + let _ = std::fs::remove_file(&temp_new_file); + return Err(format!( + "Konnte Backup ({}) nicht anlegen: {e}. Update abgebrochen, altes AppImage unverändert.", + backup_file.display() + )); + } + + // 4. Atomar ersetzen via rename + if let Err(e) = std::fs::rename(&temp_new_file, appimage_path) { + let _ = std::fs::remove_file(&temp_new_file); + return Err(format!( + "Konnte AppImage nicht atomar ersetzen: {e}. Altes AppImage bleibt unverändert." + )); + } + + // Heruntergeladene Cache-Datei aufräumen + let _ = std::fs::remove_file(file_path); + Ok(()) } RuntimeEnvironment::DebPackage { .. } => { - // Öffne mit Standardanwendung (auf Zorin OS: Zorin Software Store oder gdebi) match Command::new("xdg-open").arg(file_path).spawn() { Ok(_) => Ok(()), Err(e) => Err(format!("Konnte xdg-open nicht ausführen: {e}")), } } RuntimeEnvironment::PortableOrDev { .. } => { - // Im Dateimanager anzeigen if let Some(dir) = file_path.parent() { let _ = Command::new("xdg-open").arg(dir).spawn(); } @@ -396,6 +793,7 @@ impl UpdaterController { while let Ok(event) = self.receiver.try_recv() { match event { UpdaterEvent::CheckSuccess(release) => { + self.active_release = Some(release.clone()); self.status = UpdaterStatus::UpdateAvailable(release); } UpdaterEvent::CheckUpToDate(ver) => { @@ -443,20 +841,202 @@ mod tests { #[test] fn test_semver_parsing() { - assert_eq!(parse_semver("v0.1.0"), Some((0, 1, 0))); - assert_eq!(parse_semver("0.1.0"), Some((0, 1, 0))); - assert_eq!(parse_semver("v1.2.3-rc1"), Some((1, 2, 3))); + assert_eq!(parse_semver("v0.1.0"), Some(semver::Version::new(0, 1, 0))); + assert_eq!(parse_semver("0.1.0"), Some(semver::Version::new(0, 1, 0))); + assert_eq!( + parse_semver("v1.2.3-rc1"), + Some(semver::Version::parse("1.2.3-rc1").unwrap()) + ); assert_eq!(parse_semver("invalid"), None); } #[test] - fn test_is_newer_version() { - assert!(is_newer_version("v0.2.0", "0.1.0")); - assert!(is_newer_version("0.1.1", "0.1.0")); - assert!(is_newer_version("1.0.0", "0.9.9")); - assert!(!is_newer_version("0.1.0", "0.1.0")); - assert!(!is_newer_version("v0.1.0", "0.1.0")); - assert!(!is_newer_version("0.0.9", "0.1.0")); + fn test_is_newer_version_semver_rules() { + // Standardmäßig keine Prereleases anbieten + assert!(is_newer_version("v0.2.0", "0.1.0", false)); + assert!(is_newer_version("0.1.1", "0.1.0", false)); + assert!(is_newer_version("1.0.0", "0.9.9", false)); + assert!(!is_newer_version("0.1.0", "0.1.0", false)); + assert!(!is_newer_version("v0.1.0", "0.1.0", false)); + assert!(!is_newer_version("0.0.9", "0.1.0", false)); + + // SemVer Prerelease-Reihenfolge: 1.0.0-rc1 ist kleiner als 1.0.0 + assert!(is_newer_version("1.0.0", "1.0.0-rc1", false)); + assert!(!is_newer_version("1.0.0-rc1", "1.0.0", false)); + assert!(!is_newer_version("1.0.0-rc1", "1.0.0", true)); + + // Prerelease-Filterung + assert!(!is_newer_version("1.0.0-rc1", "0.9.0", false)); + assert!(is_newer_version("1.0.0-rc1", "0.9.0", true)); + } + + #[test] + fn test_sanitize_asset_name() { + assert_eq!( + sanitize_asset_name("ink-notes_0.1.0_amd64.deb").unwrap(), + "ink-notes_0.1.0_amd64.deb" + ); + assert_eq!( + sanitize_asset_name("ink-notes_0.1.0_x86_64.AppImage").unwrap(), + "ink-notes_0.1.0_x86_64.AppImage" + ); + + // Bösartige Pfade abfangen + assert!(sanitize_asset_name("../../etc/passwd").is_err()); + assert!(sanitize_asset_name("/etc/shadow").is_err()); + assert!(sanitize_asset_name("..").is_err()); + assert!(sanitize_asset_name("").is_err()); + assert!(sanitize_asset_name(" ").is_err()); + assert!(sanitize_asset_name("subdir/file.deb").is_err()); + assert!(sanitize_asset_name(".hidden_file").is_err()); + } + + #[test] + fn test_checksum_parsing_and_verification() { + let checksums_txt = "\ +# SHA256 Prüfsummen +dcdeb7ac87d9e6cb8022e5a71bc7f2c56d779b4fce4efdd5bae5be3b0214884a ink-notes_0.1.0_amd64.deb +53383dcdb49651dcd15358b9d03c7d0482dcbe4dae9f8f9b28e24c5900192229 ink-notes_0.1.0_x86_64.AppImage +"; + + let parsed = parse_checksums(checksums_txt); + assert_eq!( + parsed.get("ink-notes_0.1.0_amd64.deb").unwrap(), + "dcdeb7ac87d9e6cb8022e5a71bc7f2c56d779b4fce4efdd5bae5be3b0214884a" + ); + + // Testdatei anlegen + let temp_dir = tempfile::tempdir().unwrap(); + let test_file = temp_dir.path().join("test_asset.bin"); + std::fs::write(&test_file, b"ink-notes test payload").unwrap(); + + let real_hash = compute_sha256(&test_file).unwrap(); + let valid_sums = format!("{real_hash} test_asset.bin\n"); + + // 1. Gültige Prüfsumme + assert!(verify_checksum(&test_file, "test_asset.bin", &valid_sums).is_ok()); + assert!(test_file.exists()); + + // 2. Ungültige Prüfsumme -> Muss fehlschlagen UND Datei löschen + let invalid_sums = + "0000000000000000000000000000000000000000000000000000000000000000 test_asset.bin\n"; + assert!(verify_checksum(&test_file, "test_asset.bin", invalid_sums).is_err()); + assert!( + !test_file.exists(), + "Manipulierte Datei muss gelöscht werden" + ); + + // 3. Fehlender Eintrag -> Muss fehlschlagen + let test_file2 = temp_dir.path().join("missing.bin"); + std::fs::write(&test_file2, b"other data").unwrap(); + assert!(verify_checksum(&test_file2, "missing.bin", &valid_sums).is_err()); + assert!( + !test_file2.exists(), + "Datei ohne Prüfsumme muss gelöscht werden" + ); + } + + #[test] + fn test_runtime_environment_detection_logic() { + // AppImage gesetzt + let r1 = RuntimeEnvironment::detect_from( + Some("/home/user/Applications/ink-notes.AppImage"), + Path::new("/tmp/.mount_123/usr/bin/ink-notes"), + ); + assert_eq!( + r1, + RuntimeEnvironment::AppImage { + appimage_path: PathBuf::from("/home/user/Applications/ink-notes.AppImage") + } + ); + + // Systemweites Debian-Paket (/usr/bin) + let r2 = RuntimeEnvironment::detect_from(None, Path::new("/usr/bin/ink-notes")); + assert_eq!( + r2, + RuntimeEnvironment::DebPackage { + binary_path: PathBuf::from("/usr/bin/ink-notes") + } + ); + + // Lokales Entwicklungs-Binary + let r3 = RuntimeEnvironment::detect_from( + None, + Path::new("/home/user/ink-notes/target/release/ink-notes"), + ); + assert_eq!( + r3, + RuntimeEnvironment::PortableOrDev { + binary_path: PathBuf::from("/home/user/ink-notes/target/release/ink-notes") + } + ); + } + + #[test] + fn test_https_enforcement() { + let config = UpdaterConfig { + server_url: "https://gitea.pansi.eu".to_string(), + allow_insecure_http: false, + ..Default::default() + }; + assert!(config.validate_url().is_ok()); + + let insecure_config = UpdaterConfig { + server_url: "http://gitea.pansi.eu".to_string(), + allow_insecure_http: false, + ..Default::default() + }; + assert!(insecure_config.validate_url().is_err()); + + let allowed_insecure = UpdaterConfig { + server_url: "http://gitea.pansi.eu".to_string(), + allow_insecure_http: true, + ..Default::default() + }; + assert!(allowed_insecure.validate_url().is_ok()); + } + + #[test] + fn test_appimage_atomic_self_update() { + let temp_dir = tempfile::tempdir().unwrap(); + let appimage_path = temp_dir.path().join("ink-notes.AppImage"); + std::fs::write(&appimage_path, b"original-appimage-v1").unwrap(); + + let new_download = temp_dir.path().join("downloaded.AppImage"); + std::fs::write(&new_download, b"updated-appimage-v2").unwrap(); + + let controller = UpdaterController { + config: UpdaterConfig::default(), + runtime: RuntimeEnvironment::AppImage { + appimage_path: appimage_path.clone(), + }, + status: UpdaterStatus::Idle, + active_release: None, + receiver: channel().1, + sender: channel().0, + }; + + // Update durchführen + let res = controller.install_or_open(&new_download); + assert!(res.is_ok(), "Update sollte erfolgreich sein: {res:?}"); + + // Verifizieren: AppImage hat neuen Inhalt + let updated_content = std::fs::read(&appimage_path).unwrap(); + assert_eq!(updated_content, b"updated-appimage-v2"); + + // Backup existiert mit altem Inhalt + let backup_path = temp_dir.path().join("ink-notes.AppImage.bak"); + assert!(backup_path.exists(), "Backup-Datei muss existieren"); + assert_eq!( + std::fs::read(&backup_path).unwrap(), + b"original-appimage-v1" + ); + + // Download-Cache-Datei wurde aufgeräumt + assert!( + !new_download.exists(), + "Download-Datei muss nach Erfolg aufgeräumt werden" + ); } #[test] @@ -487,17 +1067,12 @@ mod tests { ] }"#; - let release: GiteaRelease = serde_json::from_str(json_data).expect("Sollte Release deserialisieren"); + let release: GiteaRelease = + serde_json::from_str(json_data).expect("Sollte Release deserialisieren"); assert_eq!(release.tag_name, "v0.2.0"); assert_eq!(release.assets.len(), 2); assert_eq!(release.assets[0].name, "ink-notes_0.2.0_amd64.deb"); assert_eq!(release.assets[1].name, "ink-notes_0.2.0_x86_64.AppImage"); - assert!(is_newer_version(&release.tag_name, "0.1.0")); - } - - #[test] - fn test_runtime_environment_detection() { - let runtime = RuntimeEnvironment::detect(); - assert!(!runtime.display_name().is_empty()); + assert!(is_newer_version(&release.tag_name, "0.1.0", false)); } } diff --git a/src/viewport.rs b/src/viewport.rs index 7a4eefb..668c7f4 100644 --- a/src/viewport.rs +++ b/src/viewport.rs @@ -3,8 +3,8 @@ //! Berechnet die affine Transformation zwischen Bildschirmkoordinaten (Window-Pixel), //! virtuellen DIN-A4-Seitenkoordinaten ([0, 2100] x [0, 2970]) und dem GPU-NDC-Raum ([-1, 1]). -use glam::{Mat4, Vec2, Vec4}; use crate::document::{PageViewport, PAGE_HEIGHT, PAGE_WIDTH}; +use glam::{Mat4, Vec2, Vec4}; /// Minimaler und maximaler Zoomfaktor. pub const MIN_ZOOM: f32 = 0.25; @@ -40,7 +40,12 @@ impl ViewportState { } /// Passt den Pan-Offset um eine Bildschirmverschiebung (in Fensterpixeln) an. - pub fn pan_by_screen_delta(&mut self, delta_x: f32, delta_y: f32, page_viewport: &PageViewport) { + pub fn pan_by_screen_delta( + &mut self, + delta_x: f32, + delta_y: f32, + page_viewport: &PageViewport, + ) { let base_scale = page_viewport.page_width / PAGE_WIDTH; if base_scale * self.zoom > 1e-6 { self.pan_offset[0] += delta_x / (base_scale * self.zoom); @@ -122,10 +127,7 @@ impl ViewportState { page_viewport: &PageViewport, ) -> Vec2 { let pt = self.screen_to_page_unclamped(screen_x, screen_y, page_viewport); - Vec2::new( - pt.x.clamp(0.0, PAGE_WIDTH), - pt.y.clamp(0.0, PAGE_HEIGHT), - ) + Vec2::new(pt.x.clamp(0.0, PAGE_WIDTH), pt.y.clamp(0.0, PAGE_HEIGHT)) } /// Transformiert einen DIN-A4-Seitenpunkt in Fenster-Pixelkoordinaten.