feat: in-app update notice — checks GitHub releases on startup
Adds a one-shot update check that runs on app boot and pops up a small
"Update Available" window when the GitHub releases API reports a newer
tag than this build's `CARGO_PKG_VERSION`.
- `src/update_check.rs` — async wrapper around a blocking `ureq` GET to
`api.github.com/repos/HakanSeven12/H7CAD/releases/latest`, returns
`Some(version)` when a newer release exists, `None` otherwise (silent
on network / parse failure so a missing connection never blocks
launch).
- `H7CAD::boot` chains a `Task::perform` next to the main-window open
so the check runs in parallel with first-paint.
- New window plumbed like the other floating panels (page setup, about,
etc.): `update_notice_window`/`update_notice_version` state, title
entry, `OsWindowClosed` cleanup, and a `ui::update_notice::view_window`
modal that shows installed vs. latest version plus "Later" /
"Open Release Page" buttons. Open routes through `open::that` to the
releases URL and dismisses the modal.
Dependency: adds `ureq = { version = "2", default-features = false,
features = ["tls"] }` — the smallest blocking HTTP client + TLS combo
that doesn't drag in an async runtime.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
de37ee04bf
commit
86f5ba7b62
10 changed files with 388 additions and 2 deletions
101
Cargo.lock
generated
101
Cargo.lock
generated
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"] }
|
||||
|
|
|
|||
|
|
@ -98,6 +98,12 @@ pub(super) struct H7CAD {
|
|||
dimstyle_window: Option<window::Id>,
|
||||
shortcuts_window: Option<window::Id>,
|
||||
about_window: Option<window::Id>,
|
||||
/// New-release notification window — opened on startup when the
|
||||
/// GitHub releases API reports a newer version than this build.
|
||||
update_notice_window: Option<window::Id>,
|
||||
/// 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<String>,
|
||||
/// In-memory clipboard: cloned entities waiting to be pasted.
|
||||
clipboard: Vec<acadrust::EntityType>,
|
||||
/// 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<String>),
|
||||
/// 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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -10,3 +10,4 @@ pub mod patterns;
|
|||
pub mod scene;
|
||||
pub mod snap;
|
||||
pub mod ui;
|
||||
pub mod update_check;
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ mod patterns;
|
|||
mod scene;
|
||||
mod snap;
|
||||
mod ui;
|
||||
mod update_check;
|
||||
|
||||
fn main() -> iced::Result {
|
||||
app::run()
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
167
src/ui/update_notice.rs
Normal file
167
src/ui/update_notice.rs
Normal file
|
|
@ -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()
|
||||
}
|
||||
50
src/update_check.rs
Normal file
50
src/update_check.rs
Normal file
|
|
@ -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<String> {
|
||||
std::thread::spawn(fetch_latest_if_outdated)
|
||||
.join()
|
||||
.ok()
|
||||
.flatten()
|
||||
}
|
||||
|
||||
fn fetch_latest_if_outdated() -> Option<String> {
|
||||
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<String> {
|
||||
// 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())
|
||||
}
|
||||
Loading…
Reference in a new issue