diff --git a/Cargo.lock b/Cargo.lock index b7805e0ca..e68e7a248 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -19,6 +19,7 @@ dependencies = [ "truck-meshalgo", "truck-modeling", "truck-polymesh", + "ureq", "windows-sys 0.59.0", ] @@ -4527,6 +4528,20 @@ dependencies = [ "bytemuck", ] +[[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 = "robust" version = "1.2.0" @@ -4607,6 +4622,41 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rustls" +version = "0.23.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustversion" version = "1.0.22" @@ -5126,6 +5176,12 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "svg_fmt" version = "0.4.5" @@ -5724,6 +5780,27 @@ 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", + "log", + "once_cell", + "rustls", + "rustls-pki-types", + "url", + "webpki-roots 0.26.11", +] + [[package]] name = "url" version = "2.5.8" @@ -6108,6 +6185,24 @@ dependencies = [ "wasm-bindgen", ] +[[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.7", +] + +[[package]] +name = "webpki-roots" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "weezl" version = "0.1.12" @@ -7033,6 +7128,12 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + [[package]] name = "zerotrie" version = "0.2.4" diff --git a/Cargo.toml b/Cargo.toml index 79e744622..0120e1266 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,6 +18,7 @@ printpdf = "0.9.1" flate2 = "1" image = { version = "0.25", default-features = false, features = ["png", "jpeg", "bmp", "tiff"] } rayon = "1" +ureq = { version = "2", default-features = false, features = ["tls"] } [target.'cfg(target_os = "windows")'.dependencies] windows-sys = { version = "0.59", features = ["Win32_UI_Shell", "Win32_UI_WindowsAndMessaging"] } diff --git a/src/app/mod.rs b/src/app/mod.rs index 695fd670e..bbe4b5c93 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -98,6 +98,12 @@ pub(super) struct H7CAD { dimstyle_window: Option, shortcuts_window: Option, about_window: Option, + /// New-release notification window — opened on startup when the + /// GitHub releases API reports a newer version than this build. + update_notice_window: Option, + /// Tag of the latest available release (without the leading "v"), + /// e.g. `"0.3.0"`. `None` when up-to-date or check hasn't returned. + update_notice_version: Option, /// In-memory clipboard: cloned entities waiting to be pasted. clipboard: Vec, /// Centroid of the clipboard entities (XZ plane, Y-up). @@ -529,6 +535,14 @@ pub enum Message { OsWindowClosed(window::Id), /// No-op — used as a fallback when a TabEvent has no host mapping. Noop, + /// GitHub releases API returned a result. `Some(version)` means a + /// newer release exists; we open the update-notice window. + UpdateCheckResult(Option), + /// User dismissed the update-notice window. + UpdateNoticeClose, + /// User clicked the "Open release page" button — opens the GitHub + /// release URL in the OS default browser and closes the notice. + UpdateNoticeOpenRelease, // ── Page Setup ──────────────────────────────────────────────────────── /// Open the Page Setup panel for the current layout. PageSetupOpen, @@ -713,6 +727,8 @@ impl H7CAD { dimstyle_window: None, shortcuts_window: None, about_window: None, + update_notice_window: None, + update_notice_version: None, clipboard: Vec::new(), clipboard_centroid: glam::Vec3::ZERO, layout_context_menu: None, @@ -810,8 +826,12 @@ impl H7CAD { }); let mut s = state; s.main_window = Some(id); - let task = open_task.map(|_| Message::Noop); - (s, task) + let open_main = open_task.map(|_| Message::Noop); + let check_update = Task::perform( + crate::update_check::check_for_update(), + Message::UpdateCheckResult, + ); + (s, Task::batch([open_main, check_update])) } } @@ -851,6 +871,9 @@ pub fn run() -> iced::Result { if Some(window_id) == state.about_window { return "About H7CAD".into(); } + if Some(window_id) == state.update_notice_window { + return "Update Available".into(); + } if Some(window_id) == state.unsaved_dialog_window { return "Unsaved Changes".into(); } diff --git a/src/app/update.rs b/src/app/update.rs index 81a6e6236..b842fd9d2 100644 --- a/src/app/update.rs +++ b/src/app/update.rs @@ -974,6 +974,9 @@ impl H7CAD { if self.about_window == Some(id) { self.about_window = None; } + if self.update_notice_window == Some(id) { + self.update_notice_window = None; + } Task::none() } @@ -3536,6 +3539,37 @@ impl H7CAD { Task::none() } } + Message::UpdateCheckResult(latest) => { + let Some(version) = latest else { + return Task::none(); + }; + self.update_notice_version = Some(version); + if let Some(id) = self.update_notice_window { + return window::gain_focus(id); + } + let (id, task) = window::open(window::Settings { + size: iced::Size::new(480.0, 240.0), + resizable: false, + ..Default::default() + }); + self.update_notice_window = Some(id); + task.map(|_| Message::Noop) + } + Message::UpdateNoticeClose => { + if let Some(id) = self.update_notice_window.take() { + window::close(id) + } else { + Task::none() + } + } + Message::UpdateNoticeOpenRelease => { + let _ = open::that(crate::update_check::RELEASES_PAGE); + if let Some(id) = self.update_notice_window.take() { + window::close(id) + } else { + Task::none() + } + } Message::PageSetupWidthEdit(s) => { self.page_setup_w = s; Task::none() diff --git a/src/app/view.rs b/src/app/view.rs index c51564150..03ec41471 100644 --- a/src/app/view.rs +++ b/src/app/view.rs @@ -190,6 +190,13 @@ impl H7CAD { if Some(window_id) == self.about_window { return crate::ui::about::view_window(); } + if Some(window_id) == self.update_notice_window { + let latest = self + .update_notice_version + .as_deref() + .unwrap_or("?"); + return crate::ui::update_notice::view_window(latest); + } if Some(window_id) == self.unsaved_dialog_window { let tab_name = match &self.pending_close { Some(super::PendingClose::Tab(idx)) => self diff --git a/src/lib.rs b/src/lib.rs index 3bef8e46c..f9164b31f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -10,3 +10,4 @@ pub mod patterns; pub mod scene; pub mod snap; pub mod ui; +pub mod update_check; diff --git a/src/main.rs b/src/main.rs index 16707cba9..8913fb10a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -10,6 +10,7 @@ mod patterns; mod scene; mod snap; mod ui; +mod update_check; fn main() -> iced::Result { app::run() diff --git a/src/ui/mod.rs b/src/ui/mod.rs index ec8156d7a..93df7944d 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -4,6 +4,7 @@ pub const ROW_H: f32 = 26.0; pub mod about; pub mod app_menu; +pub mod update_notice; pub mod command_line; pub mod dimstyle; pub mod layers; diff --git a/src/ui/update_notice.rs b/src/ui/update_notice.rs new file mode 100644 index 000000000..68a5a6f0e --- /dev/null +++ b/src/ui/update_notice.rs @@ -0,0 +1,167 @@ +use crate::app::Message; +use iced::widget::{button, column, container, row, text, Space}; +use iced::{Background, Border, Color, Element, Fill, Theme}; + +const BG: Color = Color { + r: 0.15, + g: 0.15, + b: 0.15, + a: 1.0, +}; +const PANEL: Color = Color { + r: 0.12, + g: 0.12, + b: 0.12, + a: 1.0, +}; +const BORDER: Color = Color { + r: 0.30, + g: 0.30, + b: 0.30, + a: 1.0, +}; +const DIM: Color = Color { + r: 0.55, + g: 0.55, + b: 0.55, + a: 1.0, +}; +const ACCENT: Color = Color { + r: 0.25, + g: 0.50, + b: 0.85, + a: 1.0, +}; +const WHITE: Color = Color { + r: 0.92, + g: 0.92, + b: 0.92, + a: 1.0, +}; + +fn info_row<'a>(label: &'static str, value: String) -> Element<'a, Message> { + row![ + text(label).size(11).color(DIM).width(100), + text(value).size(11).color(WHITE), + ] + .spacing(8) + .align_y(iced::Center) + .padding([3, 0]) + .into() +} + +pub fn view_window<'a>(latest: &'a str) -> Element<'a, Message> { + let header = container( + column![ + text("New Release Available").size(20).color(ACCENT), + text("A newer H7CAD version is published on GitHub.") + .size(11) + .color(DIM), + ] + .spacing(4) + .align_x(iced::Center), + ) + .width(Fill) + .padding(iced::Padding { + top: 16.0, + right: 0.0, + bottom: 14.0, + left: 0.0, + }) + .align_x(iced::Center); + + let info_block = container( + column![ + info_row("Installed", format!("v{}", env!("CARGO_PKG_VERSION"))), + info_row("Latest", format!("v{}", latest)), + ] + .spacing(2) + .padding([12, 16]), + ) + .width(Fill) + .style(|_: &Theme| container::Style { + background: Some(Background::Color(PANEL)), + border: Border { + color: BORDER, + width: 1.0, + radius: 4.0.into(), + }, + ..Default::default() + }); + + let later_btn = button(text("Later").size(11)) + .on_press(Message::UpdateNoticeClose) + .style(|_: &Theme, st| button::Style { + background: Some(Background::Color(match st { + button::Status::Hovered | button::Status::Pressed => Color { + r: 0.22, + g: 0.22, + b: 0.22, + a: 1.0, + }, + _ => Color { + r: 0.18, + g: 0.18, + b: 0.18, + a: 1.0, + }, + })), + text_color: WHITE, + border: Border { + color: BORDER, + width: 1.0, + radius: 4.0.into(), + }, + ..Default::default() + }) + .padding([6, 16]); + + let open_btn = button(text("Open Release Page").size(11)) + .on_press(Message::UpdateNoticeOpenRelease) + .style(|_: &Theme, st| button::Style { + background: Some(Background::Color(match st { + button::Status::Hovered | button::Status::Pressed => Color { + r: 0.20, + g: 0.42, + b: 0.72, + a: 1.0, + }, + _ => ACCENT, + })), + text_color: WHITE, + border: Border { + radius: 4.0.into(), + ..Default::default() + }, + ..Default::default() + }) + .padding([6, 16]); + + let footer = row![Space::new().width(Fill), later_btn, open_btn] + .spacing(8) + .align_y(iced::Center) + .padding(iced::Padding { + top: 14.0, + right: 0.0, + bottom: 0.0, + left: 0.0, + }); + + container( + column![header, info_block, footer] + .spacing(0) + .padding(iced::Padding { + top: 0.0, + right: 20.0, + bottom: 20.0, + left: 20.0, + }), + ) + .style(|_: &Theme| container::Style { + background: Some(Background::Color(BG)), + ..Default::default() + }) + .width(Fill) + .height(Fill) + .into() +} diff --git a/src/update_check.rs b/src/update_check.rs new file mode 100644 index 000000000..6301e4d03 --- /dev/null +++ b/src/update_check.rs @@ -0,0 +1,50 @@ +// One-shot update check. +// +// `check_for_update()` runs on a background thread (joined inside an +// async wrapper, mirroring how the rest of `crate::io` wraps blocking +// work for iced's `Task::perform`). It hits the GitHub releases API and +// returns `Some(latest_version)` when a newer release is available, or +// `None` when up to date / on network failure / on parse error. + +const RELEASES_API: &str = + "https://api.github.com/repos/HakanSeven12/H7CAD/releases/latest"; +pub const RELEASES_PAGE: &str = + "https://github.com/HakanSeven12/H7CAD/releases/latest"; + +pub async fn check_for_update() -> Option { + std::thread::spawn(fetch_latest_if_outdated) + .join() + .ok() + .flatten() +} + +fn fetch_latest_if_outdated() -> Option { + let body = ureq::AgentBuilder::new() + .timeout(std::time::Duration::from_secs(5)) + .build() + .get(RELEASES_API) + .set("User-Agent", concat!("h7cad/", env!("CARGO_PKG_VERSION"))) + .set("Accept", "application/vnd.github+json") + .call() + .ok()? + .into_string() + .ok()?; + let latest = extract_tag(&body)? + .trim_start_matches('v') + .to_string(); + if latest != env!("CARGO_PKG_VERSION") { + Some(latest) + } else { + None + } +} + +fn extract_tag(body: &str) -> Option { + // Minimal substring extraction so we don't pull serde_json just for + // one field. The GitHub releases endpoint always returns + // `"tag_name":"v…"` near the top of the JSON object. + const KEY: &str = "\"tag_name\":\""; + let start = body.find(KEY)? + KEY.len(); + let end = body[start..].find('"')? + start; + Some(body[start..end].to_string()) +}