feat(update): show release notes + version-card layout in update notice
The update notice dialog now mirrors what GitHub publishes: side-by-side
Installed → Latest version cards (the new one accent-tinted to draw the
eye) and a scrollable release-notes panel under them. Light markdown
rendering covers `##` headings, bullet lists, and inline bold/code
markers — enough for the GitHub release body format we ship.
- update_check::check_for_update() returns UpdateInfo { version, body };
body is parsed from the releases API JSON with a tiny custom
string-field extractor (\n / \" / \u handling) so we keep the
serde_json-free dependency footprint.
- Window grows from 480x240 to 560x460 and becomes resizable to make
room for the notes scroller.
- Layout tightens: header bottom padding drops 14 → 6, notes heading
10, and the notes block is wrapped in a Fill-height container so it
claims every leftover pixel — no more dead space between heading and
footer.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
c98e9ebd76
commit
2357b39a1d
5 changed files with 255 additions and 53 deletions
|
|
@ -3,7 +3,7 @@
|
|||
// `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
|
||||
// returns `Some(UpdateInfo)` when a newer release is available, or
|
||||
// `None` when up to date / on network failure / on parse error.
|
||||
|
||||
const RELEASES_API: &str =
|
||||
|
|
@ -11,14 +11,24 @@ const RELEASES_API: &str =
|
|||
pub const RELEASES_PAGE: &str =
|
||||
"https://github.com/HakanSeven12/H7CAD/releases/latest";
|
||||
|
||||
pub async fn check_for_update() -> Option<String> {
|
||||
/// What `check_for_update` reports when a newer release exists.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct UpdateInfo {
|
||||
/// `tag_name` with the leading `v` stripped (e.g. `0.3.7`).
|
||||
pub version: String,
|
||||
/// Release notes / markdown body from the GitHub release. May be empty
|
||||
/// when the release was published without notes.
|
||||
pub body: String,
|
||||
}
|
||||
|
||||
pub async fn check_for_update() -> Option<UpdateInfo> {
|
||||
std::thread::spawn(fetch_latest_if_outdated)
|
||||
.join()
|
||||
.ok()
|
||||
.flatten()
|
||||
}
|
||||
|
||||
fn fetch_latest_if_outdated() -> Option<String> {
|
||||
fn fetch_latest_if_outdated() -> Option<UpdateInfo> {
|
||||
let body = ureq::AgentBuilder::new()
|
||||
.timeout(std::time::Duration::from_secs(5))
|
||||
.build()
|
||||
|
|
@ -29,22 +39,71 @@ fn fetch_latest_if_outdated() -> Option<String> {
|
|||
.ok()?
|
||||
.into_string()
|
||||
.ok()?;
|
||||
let latest = extract_tag(&body)?
|
||||
let latest = extract_string_field(&body, "tag_name")?
|
||||
.trim_start_matches('v')
|
||||
.to_string();
|
||||
if latest != env!("CARGO_PKG_VERSION") {
|
||||
Some(latest)
|
||||
} else {
|
||||
None
|
||||
if latest == env!("CARGO_PKG_VERSION") {
|
||||
return None;
|
||||
}
|
||||
// Release notes are optional; treat missing as empty.
|
||||
let notes = extract_string_field(&body, "body").unwrap_or_default();
|
||||
Some(UpdateInfo { version: latest, body: notes })
|
||||
}
|
||||
|
||||
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())
|
||||
/// Minimal extractor for a top-level string field in the releases JSON.
|
||||
/// Avoids pulling in `serde_json` for two fields. Handles standard JSON
|
||||
/// string escapes (`\"`, `\\`, `\n`, `\r`, `\t`, `\/`) which are all the
|
||||
/// GitHub release body uses in practice.
|
||||
fn extract_string_field(body: &str, field: &str) -> Option<String> {
|
||||
let key = format!("\"{}\":\"", field);
|
||||
let start = body.find(&key)? + key.len();
|
||||
// Walk to the closing unescaped `"`, JSON-unescaping as we go.
|
||||
let mut out = String::new();
|
||||
let bytes = body.as_bytes();
|
||||
let mut i = start;
|
||||
while i < bytes.len() {
|
||||
let b = bytes[i];
|
||||
if b == b'"' {
|
||||
return Some(out);
|
||||
}
|
||||
if b == b'\\' && i + 1 < bytes.len() {
|
||||
match bytes[i + 1] {
|
||||
b'"' => out.push('"'),
|
||||
b'\\' => out.push('\\'),
|
||||
b'/' => out.push('/'),
|
||||
b'n' => out.push('\n'),
|
||||
b'r' => out.push('\r'),
|
||||
b't' => out.push('\t'),
|
||||
b'u' => {
|
||||
// \uXXXX — decode 4 hex digits. Surrogate pairs (BMP only
|
||||
// for now) are uncommon in release notes; skip on parse
|
||||
// failure.
|
||||
if i + 5 < bytes.len() {
|
||||
let hex = std::str::from_utf8(&bytes[i + 2..i + 6]).ok()?;
|
||||
if let Ok(code) = u32::from_str_radix(hex, 16) {
|
||||
if let Some(c) = char::from_u32(code) {
|
||||
out.push(c);
|
||||
}
|
||||
}
|
||||
i += 6;
|
||||
continue;
|
||||
}
|
||||
return None;
|
||||
}
|
||||
other => {
|
||||
// Unknown escape — keep the literal pair, GitHub doesn't emit these.
|
||||
out.push('\\');
|
||||
out.push(other as char);
|
||||
}
|
||||
}
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
// UTF-8 multi-byte chars: push the whole code-point so we don't
|
||||
// bisect a sequence.
|
||||
let ch = body[i..].chars().next()?;
|
||||
out.push(ch);
|
||||
i += ch.len_utf8();
|
||||
}
|
||||
None
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue