feat(app): startup update check and Help > Report a Bug

On every startup (unless Preferences > General turns the new "Check for
updates" toggle off) the app GETs
https://www.oakvideoeditor.org/api/v1/update/latest, parses the
documented latest-release JSON and, when the remote version is newer
than the running build, prompts with a dialog whose primary button opens
https://www.oakvideoeditor.org/downloads. The blocking fetch runs on the
gpui background executor with a 5 s bound; transport and parse failures
are silent, and a release found while another modal is up (the project
manager on a fresh start) is deferred until the modal layer frees.

The transport sits behind an `UpdateTransport` seam so tests script the
response without touching the network; version comparison strips the
`v` prefix and pre-release suffixes and orders the components
numerically (an unparsable remote falls back to string inequality).

Help > Report a Bug... opens
https://www.oakvideoeditor.org/bug-report.

The eight shipped i18n packs carry the new menu/preferences/update keys.
This commit is contained in:
2026-09-24 12:55:42 +08:00
parent c86bcae68e
commit 9cfcb0af92
15 changed files with 865 additions and 4 deletions
+6
View File
@@ -61,6 +61,12 @@ gpui_elements = { path = "../../gpui/crates/gpui_elements" }
# runtime (bundled with the app, user-extensible) with the compiled-in
# English table as the fallback.
serde_yaml = "0.9"
# The startup update check and the external oakvideoeditor.org links: a
# blocking, bounded HTTPS GET (rustls) plus JSON response parsing. The
# fetch runs on the gpui background executor, never on the UI thread.
ureq = "3"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
# The gpui/wgpu stack logs through the `log` facade; the app installs the
# stderr backend (see oakapp::logging) so validation errors and warnings
# are actually visible (RUST_LOG selects the verbosity).
+1
View File
@@ -275,6 +275,7 @@ define_actions! {
// --- Help ---------------------------------------------------------------
ActionSearch { cpp: "actionsearch", i18n: "menu.help.action_search", keys: ["/"], route: Global, menu_id: 1120 };
Feedback { cpp: "feedback", i18n: "menu.help.feedback", keys: [], route: Global, menu_id: 1121 };
BugReport { cpp: "bugreport", i18n: "menu.help.bug_report", keys: [], route: Global, menu_id: 1122 };
About { cpp: "about", i18n: "menu.help.about", keys: [], route: Global, menu_id: 801 };
}
+279
View File
@@ -150,6 +150,8 @@ mod modal_ids {
pub const NEW_PROJECT: usize = 20;
/// The save-as dialog (文件 → 另存为).
pub const SAVE_AS: usize = 21;
/// The update-available prompt (the startup update check).
pub const UPDATE_AVAILABLE: usize = 22;
}
/// What a picked platform-dialog path should do.
@@ -222,6 +224,11 @@ enum ModalState<E: AppEngine> {
About {
modal: Entity<Modal>,
},
/// The update-available prompt (the startup check; the dialog itself
/// carries the version and notes).
UpdateAvailable {
modal: Entity<Modal>,
},
/// The new-sequence dialog (File > New > Sequence…).
NewSequence {
modal: Entity<Modal>,
@@ -291,6 +298,7 @@ impl<E: AppEngine> ModalState<E> {
| ModalState::ActionSearch { modal, .. }
| ModalState::ProjectProperties { modal, .. }
| ModalState::About { modal }
| ModalState::UpdateAvailable { modal }
| ModalState::NewSequence { modal, .. }
| ModalState::SequenceProperties { modal, .. }
| ModalState::DropSequenceChoice { modal, .. }
@@ -454,6 +462,13 @@ pub struct OakApp<E: AppEngine> {
show_all: bool,
/// 视图 → Full Screen is on (placeholder state for the checkmark).
full_screen: bool,
/// A checked-but-unshown newer release: the startup check can finish
/// while another modal (the project manager) is up, so the prompt is
/// stashed and opens when the modal layer frees up.
pending_update: Option<crate::update::LatestRelease>,
/// The transport the startup update check fetches through (tests inject
/// a scripted one; production uses the real HTTPS client).
update_transport: Arc<dyn crate::update::UpdateTransport>,
}
/// The dock panels the shell builds up front, kept for focused-panel command
@@ -895,6 +910,8 @@ impl<E: AppEngine> OakApp<E> {
loop_playback: false,
show_all: false,
full_screen: false,
pending_update: None,
update_transport: Arc::new(crate::update::HttpTransport),
};
// Open the CLI-provided project once the shell is up.
@@ -1305,6 +1322,7 @@ impl<E: AppEngine> OakApp<E> {
| A::MulticamSwitchNoSplit9 => {}
// --- Help --------------------------------------------------------
A::ActionSearch => self.open_action_search(cx),
A::BugReport => cx.open_url(crate::update::BUG_REPORT_URL),
A::About => self.open_about(cx),
// --- everything else is a placeholder --------------------------
other => println!(
@@ -1977,6 +1995,11 @@ impl<E: AppEngine> OakApp<E> {
pub fn close_modal(&mut self, cx: &mut Context<Self>) {
self.modal = ModalState::None;
cx.notify();
// A checked update prompt deferred while another modal was up (the
// project manager at startup) opens now that the layer is free.
if let Some(release) = self.pending_update.take() {
self.open_update_available(release, cx);
}
}
/// Builds a modal on the main window, subscribes it to
@@ -2378,6 +2401,87 @@ impl<E: AppEngine> OakApp<E> {
});
}
/// Starts the startup update check unless the preferences disable it
/// (`CheckForUpdates`): the blocking HTTPS fetch runs on the background
/// executor and the prompt opens from its completion, so the first frame
/// never waits on the network. Failures are logged and dropped.
fn start_update_check(&mut self, cx: &mut Context<Self>) {
if !crate::update::check_enabled() {
return;
}
let transport = self.update_transport.clone();
let executor = cx.background_executor().clone();
cx.spawn(async move |this, cx| {
let result = executor
.spawn(async move { crate::update::fetch_latest(transport.as_ref()) })
.await;
let _ = this.update(cx, |this, cx| this.on_update_checked(result, cx));
})
.detach();
}
/// Applies one finished check: a newer release opens (or defers) the
/// prompt; an equal/older release and transport failures are silent.
fn on_update_checked(
&mut self,
result: Result<crate::update::LatestRelease, String>,
cx: &mut Context<Self>,
) {
match result {
Ok(release) if crate::update::should_notify(&release) => {
println!(
"[update] {} is available (running {})",
release.version,
crate::update::current_version()
);
self.open_update_available(release, cx);
}
Ok(_) => {}
Err(error) => println!("[update] check failed: {error}"),
}
}
/// Opens the update-available prompt; when another modal is up (the
/// project manager on a fresh start) the release is stashed and shown by
/// [`Self::close_modal`] once the modal layer frees up.
fn open_update_available(
&mut self,
release: crate::update::LatestRelease,
cx: &mut Context<Self>,
) {
if !matches!(self.modal, ModalState::None) {
self.pending_update = Some(release);
return;
}
let version = release.version.clone();
let notes = release.notes.clone();
self.spawn_modal(cx, move |window, app| {
let content = app.new(|_cx| crate::dialogs::UpdateDialogContent::new(&version, &notes));
let modal = app.new(|cx| {
Modal::new(
modal_ids::UPDATE_AVAILABLE,
ModalOptions::new(crate::i18n::tr("update.title"), px(520.0))
.with_button(DialogButton::primary(crate::i18n::tr("update.download")))
.with_button(DialogButton::new(
crate::i18n::tr("update.later"),
gpui_widgets::dialog::DialogButtonRole::Secondary,
)),
window,
cx,
)
.with_content(content)
});
ModalState::UpdateAvailable { modal }
});
}
/// Test seam: replaces the update transport so tests can script the
/// endpoint response without touching the network.
#[cfg(test)]
fn set_update_transport(&mut self, transport: Arc<dyn crate::update::UpdateTransport>) {
self.update_transport = transport;
}
/// Opens the proxy settings dialog (Tools > Proxy Settings; the C++
/// `ProxyDialog`): the global generation settings plus the footage
/// proxy list with Generate / Delete buttons.
@@ -3107,6 +3211,14 @@ impl<E: AppEngine> OakApp<E> {
}
}
modal_ids::ABOUT => self.close_modal(cx),
modal_ids::UPDATE_AVAILABLE => {
// Primary opens the downloads page in the user's
// browser; the secondary (and the mask) just dismisses.
if *button == 0 {
cx.open_url(crate::update::DOWNLOAD_URL);
}
self.close_modal(cx);
}
modal_ids::PROJECT_PROPERTIES => {
if let ModalState::ProjectProperties { content, .. } = &self.modal {
let content = content.clone();
@@ -3688,6 +3800,7 @@ fn make_menus(state: MenuState) -> Vec<MenuBarEntry> {
tr("menu.help"),
Menu::new(vec![
menu_item(A::ActionSearch),
menu_item(A::BugReport),
menu_item(A::Feedback).separated(),
menu_item(A::About),
]),
@@ -3921,6 +4034,13 @@ fn run_with<E: AppEngine>(args: AppArgs) {
}
}
// Startup update check (unless disabled in the preferences): the
// fetch is async and the prompt defers behind the project manager
// when that is up.
if let Some(root) = &root_slot {
root.update(cx, |app, cx| app.start_update_check(cx));
}
cx.activate(true);
cx.on_window_closed(|cx, _| {
if cx.windows().is_empty() {
@@ -8744,6 +8864,165 @@ mod tests {
cx.run_until_parked();
}
/// The startup update check end to end: a scripted transport returning a
/// newer release opens the prompt and the primary button opens the
/// downloads page; an equal release and a transport failure stay silent;
/// the preferences toggle skips the fetch entirely; a release found while
/// another modal is up waits for the layer; and the Bug Report action
/// opens the report site.
#[gpui::test]
async fn update_check_prompts_and_respects_the_preferences(cx: &mut TestAppContext) {
use std::sync::atomic::{AtomicU32, Ordering};
let _guard = crate::oakui::graphops::test_lock();
let _lang = crate::i18n::lang_test_lock()
.lock()
.unwrap_or_else(|e| e.into_inner());
crate::i18n::set_language_code("en-US");
let (_window, root) = mock_shell(cx);
/// A scripted transport: every call records itself and returns the
/// canned body/error.
struct Scripted {
body: Result<String, String>,
calls: AtomicU32,
}
impl crate::update::UpdateTransport for Scripted {
fn get(&self, url: &str) -> Result<String, String> {
assert_eq!(url, crate::update::UPDATE_ENDPOINT);
self.calls.fetch_add(1, Ordering::SeqCst);
self.body.clone()
}
}
let set_transport = |cx: &mut TestAppContext, transport: Arc<Scripted>| {
cx.update(|app| {
root.update(app, |app, _cx| app.set_update_transport(transport));
});
};
// A newer release prompts; the primary button opens the downloads
// page and dismisses the dialog.
let newer = Arc::new(Scripted {
body: Ok(r#"{"version":"v9.9.9","notes":"New things"}"#.to_string()),
calls: AtomicU32::new(0),
});
set_transport(cx, newer.clone());
cx.update(|app| root.update(app, |app, cx| app.start_update_check(cx)));
cx.run_until_parked();
assert_eq!(newer.calls.load(Ordering::SeqCst), 1, "one fetch");
assert!(
cx.read(|app| matches!(root.read(app).modal, ModalState::UpdateAvailable { .. })),
"a newer release opens the prompt"
);
cx.update(|app| {
root.update(app, |app, cx| {
app.on_modal(
&ModalEvent::ButtonClicked {
control: modal_ids::UPDATE_AVAILABLE,
button: 0,
},
cx,
)
})
});
cx.run_until_parked();
assert_eq!(
cx.opened_url().as_deref(),
Some(crate::update::DOWNLOAD_URL),
"the primary button sends the user to the downloads page"
);
assert!(cx.read(|app| matches!(root.read(app).modal, ModalState::None)));
// The running version (and a failing transport) stay silent.
let current = Arc::new(Scripted {
body: Ok(format!(
r#"{{"version":"{}"}}"#,
crate::update::current_version()
)),
calls: AtomicU32::new(0),
});
set_transport(cx, current.clone());
cx.update(|app| root.update(app, |app, cx| app.start_update_check(cx)));
cx.run_until_parked();
assert_eq!(current.calls.load(Ordering::SeqCst), 1);
assert!(
cx.read(|app| matches!(root.read(app).modal, ModalState::None)),
"the current release does not prompt"
);
let failing = Arc::new(Scripted {
body: Err("offline".to_string()),
calls: AtomicU32::new(0),
});
set_transport(cx, failing.clone());
cx.update(|app| root.update(app, |app, cx| app.start_update_check(cx)));
cx.run_until_parked();
assert_eq!(failing.calls.load(Ordering::SeqCst), 1);
assert!(cx.read(|app| matches!(root.read(app).modal, ModalState::None)));
// Preferences off: the startup path does not even fetch.
let previous =
crate::oakui::real::config_get_string(crate::update::CONFIG_KEY_CHECK_UPDATES);
crate::oakui::real::config_set_string(crate::update::CONFIG_KEY_CHECK_UPDATES, "false");
let disabled = Arc::new(Scripted {
body: Ok(r#"{"version":"v9.9.9"}"#.to_string()),
calls: AtomicU32::new(0),
});
set_transport(cx, disabled.clone());
cx.update(|app| root.update(app, |app, cx| app.start_update_check(cx)));
cx.run_until_parked();
assert_eq!(
disabled.calls.load(Ordering::SeqCst),
0,
"the toggle off skips the request"
);
assert!(cx.read(|app| matches!(root.read(app).modal, ModalState::None)));
crate::oakui::real::config_set_string(
crate::update::CONFIG_KEY_CHECK_UPDATES,
&previous,
);
// A release checked while another modal is up is deferred until the
// modal layer frees.
cx.update(|app| root.update(app, |app, cx| app.open_about(cx)));
cx.run_until_parked();
let release = crate::update::LatestRelease {
version: "v9.9.9".to_string(),
tag_name: "v9.9.9".to_string(),
notes: "New things".to_string(),
is_prerelease: false,
published_at: "2026-09-20T08:00:00+00:00".to_string(),
download_url: "/api/v1/releases/7/download?asset_id=12".to_string(),
};
cx.update(|app| {
root.update(app, |app, cx| app.on_update_checked(Ok(release), cx))
});
cx.run_until_parked();
assert!(
cx.read(|app| matches!(root.read(app).modal, ModalState::About { .. })),
"the open modal stays on top"
);
cx.update(|app| root.update(app, |app, cx| app.close_modal(cx)));
cx.run_until_parked();
assert!(
cx.read(|app| matches!(root.read(app).modal, ModalState::UpdateAvailable { .. })),
"the deferred prompt opens once the layer is free"
);
cx.update(|app| root.update(app, |app, cx| app.close_modal(cx)));
cx.run_until_parked();
// Help → Report a Bug opens the report form.
cx.update(|app| {
root.update(app, |app, cx| {
app.on_action_dispatched(ActionId::BugReport, cx)
})
});
assert_eq!(
cx.opened_url().as_deref(),
Some(crate::update::BUG_REPORT_URL)
);
}
/// The new-sequence dialog's OK path (the mock has no create command,
/// so the error keeps the dialog open) and its Cancel path (which also
/// abandons a paused drop).
+115
View File
@@ -113,6 +113,9 @@ pub struct PreferencesContent {
display_bit_depth: Entity<ComboBox>,
language: Entity<ComboBox>,
theme: Entity<ComboBox>,
/// 常规 General: check for a newer release on startup (config
/// `CheckForUpdates`; the startup path reads the key live).
check_updates: Entity<CheckBox>,
cache_dir: Entity<PathField>,
cache_ahead: Entity<SpinBox>,
use_proxy: Entity<CheckBox>,
@@ -255,6 +258,32 @@ impl PreferencesContent {
combo.set_selected(Some(if theme_is_dark() { 0 } else { 1 }), cx)
});
// --- 常规 General: the startup update check -------------------------
// On by default; off skips the startup request entirely (the config
// key is read when the check would start, no restart needed).
let check_updates = cx.new(|cx| {
CheckBox::new(
45,
if crate::update::check_enabled() {
CheckState::Checked
} else {
CheckState::Unchecked
},
window,
cx,
)
.with_label(i18n::tr("preferences.check_updates"))
});
cx.subscribe(
&check_updates,
|_this, check, event: &CheckBoxEvent, cx| {
let CheckBoxEvent::Toggled { state, .. } = event;
crate::update::set_check_enabled(*state == CheckState::Checked);
check.update(cx, |check, cx| check.set_state(*state, cx));
},
)
.detach();
// --- 缓存 Cache: the disk cache directory --------------------------
let cache_dir = cx.new(|cx| {
let editor = cx.new(|cx| EditableTextState::new(StringStorage::default(), cx));
@@ -533,6 +562,7 @@ impl PreferencesContent {
display_bit_depth,
language,
theme,
check_updates,
cache_dir,
cache_ahead,
use_proxy,
@@ -740,6 +770,7 @@ impl Render for PreferencesContent {
i18n::tr("preferences.theme").into(),
self.theme.clone(),
))
.child(self.check_updates.clone())
// 渲染 Rendering
.child(section_header(
&colors,
@@ -3224,6 +3255,59 @@ impl AboutContent {
}
}
/// The update-available prompt: the remote version, the running build and
/// the release notes (rendered as plain text — the API sends Markdown).
pub struct UpdateDialogContent {
version: SharedString,
notes: SharedString,
}
impl UpdateDialogContent {
/// Builds the content from one latest-release response.
pub fn new(version: &str, notes: &str) -> Self {
let notes = notes.trim();
let notes = if notes.is_empty() {
i18n::tr("update.no_notes").to_string()
} else {
notes.to_string()
};
Self {
version: version.to_string().into(),
notes: notes.into(),
}
}
}
impl Render for UpdateDialogContent {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let colors = cx.default_colors().clone();
let available = i18n::tr("update.available")
.replace("{version}", self.version.as_ref())
.replace("{current}", crate::update::current_version());
div()
.id("update-dialog-content")
.flex()
.flex_col()
.gap_2()
.w_full()
.child(div().text_color(colors.text).child(available))
.child(
div()
.text_color(colors.disabled)
.text_xs()
.child(i18n::tr("update.notes")),
)
.child(
div()
.id("update-dialog-notes")
.max_h(px(220.0))
.overflow_y_scroll()
.text_color(colors.text)
.child(self.notes.clone()),
)
}
}
impl Render for AboutContent {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let colors = cx.default_colors().clone();
@@ -4612,8 +4696,10 @@ mod tests {
let _snapshot = ConfigRestore::of(CONFIG_KEY_SNAPSHOT_INTERVAL_SEC);
let _transition = ConfigRestore::of(CONFIG_KEY_DEFAULT_TRANSITION_SEC);
let _storage = ConfigRestore::of(CONFIG_KEY_STORAGE_BACKEND);
let _updates = ConfigRestore::of(crate::update::CONFIG_KEY_CHECK_UPDATES);
// Pin known starting values so the seeded rows are deterministic.
config_set_string(crate::update::CONFIG_KEY_CHECK_UPDATES, "true");
config_set_string(CONFIG_KEY_RENDERER_BACKEND, "opengl");
config_set_string(CONFIG_KEY_DISPLAY_BIT_DEPTH, "10");
config_set_string(crate::oakui::real::CONFIG_KEY_THEME, "dark");
@@ -4791,6 +4877,35 @@ mod tests {
});
assert_eq!(config_get_string("HardwareDecoding"), "false");
// The startup update check: the toggle writes the config key the
// startup path reads.
cx.update(|cx| {
content.update(cx, |content, cx| {
content.check_updates.update(cx, |_check, cx| {
cx.emit(CheckBoxEvent::Toggled {
control: 45,
state: CheckState::Unchecked,
})
})
})
});
assert_eq!(
config_get_string(crate::update::CONFIG_KEY_CHECK_UPDATES),
"false"
);
assert!(!crate::update::check_enabled());
cx.update(|cx| {
content.update(cx, |content, cx| {
content.check_updates.update(cx, |_check, cx| {
cx.emit(CheckBoxEvent::Toggled {
control: 45,
state: CheckState::Checked,
})
})
})
});
assert!(crate::update::check_enabled());
// Proxy divider: the selected option's divider value is persisted.
let dividers = cx.read(|cx| content.read(cx).dividers.clone());
cx.update(|cx| {
+1
View File
@@ -64,6 +64,7 @@ pub mod logging;
pub mod manager;
pub mod oakui;
pub mod panels;
pub mod update;
/// The application entry point (called from `main.rs`).
pub fn run() {
+272
View File
@@ -0,0 +1,272 @@
// Oak Video Editor - Non-Linear Video Editor
// Copyright (C) 2026 Oak Team
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
//! The startup update check and the external oakvideoeditor.org links.
//!
//! On every launch (unless the user turned it off in the preferences, the
//! `CheckForUpdates` config key) the app GETs [`UPDATE_ENDPOINT`], parses
//! the `LatestRelease` JSON and — when the remote version is newer than
//! [`current_version`] — prompts with a dialog pointing at
//! [`DOWNLOAD_URL`]. Network and parse failures are silent (a missing
//! update server must never disturb startup); the fetch is blocking and
//! runs on the gpui background executor.
//!
//! The transport sits behind [`UpdateTransport`] so tests can script
//! responses without touching the network.
use std::time::Duration;
use serde::Deserialize;
/// The latest-release endpoint (see the API contract in the module docs
/// of the website; the response is the struct below).
pub const UPDATE_ENDPOINT: &str = "https://www.oakvideoeditor.org/api/v1/update/latest";
/// Where the update dialog sends the user to download the new build.
pub const DOWNLOAD_URL: &str = "https://www.oakvideoeditor.org/downloads";
/// The bug-report form opened from the Help menu.
pub const BUG_REPORT_URL: &str = "https://www.oakvideoeditor.org/bug-report";
/// Config key: run the startup update check (preferences toggle, default on).
pub const CONFIG_KEY_CHECK_UPDATES: &str = "CheckForUpdates";
/// One `GET /api/v1/update/latest` response.
///
/// ```json
/// {
/// "version": "v1.0.0",
/// "tag_name": "v1.0.0",
/// "notes": "更新说明(Markdown)",
/// "is_prerelease": false,
/// "published_at": "2026-09-20T08:00:00+00:00",
/// "download_url": "/api/v1/releases/<id>/download?asset_id=<asset_id>"
/// }
/// ```
#[derive(Clone, Debug, PartialEq, Eq, Deserialize)]
pub struct LatestRelease {
/// The release version (e.g. `v1.0.0`).
pub version: String,
/// The release tag (usually the same as `version`).
#[serde(default)]
pub tag_name: String,
/// The release notes (Markdown; displayed as plain text).
#[serde(default)]
pub notes: String,
/// Whether the release is flagged as a pre-release.
#[serde(default)]
pub is_prerelease: bool,
/// The publication timestamp (ISO-8601).
#[serde(default)]
pub published_at: String,
/// The download path relative to the API host.
#[serde(default)]
pub download_url: String,
}
/// The running app's version (`Cargo.toml` `version`, e.g. `0.5.0`).
pub fn current_version() -> &'static str {
env!("CARGO_PKG_VERSION")
}
/// Parses one endpoint body. A missing `version` is rejected (an answer
/// without a version cannot drive the comparison).
pub fn parse_latest(body: &str) -> Result<LatestRelease, String> {
let release: LatestRelease =
serde_json::from_str(body).map_err(|e| format!("invalid update response: {e}"))?;
if release.version.trim().is_empty() {
return Err("the update response carries no version".to_string());
}
Ok(release)
}
/// Parses `v1.2.3` / `1.2.3-rc.1` / `1.2` into a comparable triple; `None`
/// when the string is not a dotted numeric version.
pub fn parse_version(version: &str) -> Option<(u64, u64, u64)> {
let trimmed = version.trim();
let trimmed = trimmed.strip_prefix(['v', 'V']).unwrap_or(trimmed);
// Pre-release/build metadata does not participate in the ordering the
// prompt needs (`1.2.3-rc.1` compares as `1.2.3`).
let core = trimmed.split(['-', '+']).next().unwrap_or(trimmed);
let mut parts = core.split('.');
let major = parts.next()?.trim().parse().ok()?;
let minor = parts.next().unwrap_or("0").trim().parse().ok()?;
let patch = parts.next().unwrap_or("0").trim().parse().ok()?;
if parts.next().is_some() {
return None;
}
Some((major, minor, patch))
}
/// Whether `remote` names a release newer than `current`.
///
/// Both sides accept the leading `v`; unparsable versions fall back to a
/// plain string inequality (a differently-named release is worth telling
/// the user about, per the "different version" contract).
pub fn is_newer(remote: &str, current: &str) -> bool {
match (parse_version(remote), parse_version(current)) {
(Some(remote), Some(current)) => remote > current,
_ => {
remote.trim().trim_start_matches(['v', 'V']) != current.trim().trim_start_matches(['v', 'V'])
}
}
}
/// Whether the preferences allow the startup check (default on).
pub fn check_enabled() -> bool {
crate::oakui::real::config_get_bool(CONFIG_KEY_CHECK_UPDATES, true)
}
/// Persists the preferences toggle.
pub fn set_check_enabled(enabled: bool) {
crate::oakui::real::config_set_bool(CONFIG_KEY_CHECK_UPDATES, enabled);
}
/// Whether a fetched release is worth prompting about (newer than the
/// running build).
pub fn should_notify(release: &LatestRelease) -> bool {
is_newer(&release.version, current_version())
}
/// The transport seam: one blocking GET. Implementations must be cheap to
/// clone-free share (`Send + Sync`); the checked-in implementation is
/// [`HttpTransport`].
pub trait UpdateTransport: Send + Sync {
/// GETs `url` and returns the body as text.
fn get(&self, url: &str) -> Result<String, String>;
}
/// The real HTTPS transport (ureq + rustls, bounded at 5 s); the call is
/// blocking, so callers run it on the background executor.
pub struct HttpTransport;
impl UpdateTransport for HttpTransport {
fn get(&self, url: &str) -> Result<String, String> {
let config = ureq::Agent::config_builder()
.timeout_global(Some(Duration::from_secs(5)))
.build();
let agent: ureq::Agent = config.into();
let mut response = agent.get(url).call().map_err(|e| e.to_string())?;
response
.body_mut()
.read_to_string()
.map_err(|e| e.to_string())
}
}
/// Fetches and parses the latest release through `transport`.
pub fn fetch_latest(transport: &dyn UpdateTransport) -> Result<LatestRelease, String> {
let body = transport.get(UPDATE_ENDPOINT)?;
parse_latest(&body)
}
#[cfg(test)]
mod tests {
use super::*;
/// The documented response shape parses field by field.
#[test]
fn parse_latest_reads_the_documented_shape() {
let body = r#"{
"version": "v1.0.0",
"tag_name": "v1.0.0",
"notes": "更新说明(Markdown)",
"is_prerelease": false,
"published_at": "2026-09-20T08:00:00+00:00",
"download_url": "/api/v1/releases/7/download?asset_id=12"
}"#;
let release = parse_latest(body).expect("valid response");
assert_eq!(release.version, "v1.0.0");
assert_eq!(release.tag_name, "v1.0.0");
assert_eq!(release.notes, "更新说明(Markdown)");
assert!(!release.is_prerelease);
assert_eq!(release.published_at, "2026-09-20T08:00:00+00:00");
assert_eq!(release.download_url, "/api/v1/releases/7/download?asset_id=12");
}
/// Optional fields default; a response without a version is rejected.
#[test]
fn parse_latest_rejects_missing_versions() {
let minimal = parse_latest(r#"{"version":"v2.1.0"}"#).expect("minimal response");
assert!(minimal.notes.is_empty());
assert!(!minimal.is_prerelease);
assert!(parse_latest("{}").is_err(), "no version");
assert!(parse_latest(r#"{"version":" "}"#).is_err(), "blank version");
assert!(parse_latest("not json").is_err());
}
/// The version parser strips the tag prefix and ignores pre-release
/// metadata; the comparison orders numerically.
#[test]
fn version_comparison_orders_numerically() {
assert_eq!(parse_version("v1.2.3"), Some((1, 2, 3)));
assert_eq!(parse_version("1.2.3-rc.1"), Some((1, 2, 3)));
assert_eq!(parse_version("1.2"), Some((1, 2, 0)));
assert_eq!(parse_version("v10.0.1"), Some((10, 0, 1)));
assert_eq!(parse_version("nightly"), None);
assert!(is_newer("v1.0.0", "0.5.0"));
assert!(is_newer("v0.5.1", "0.5.0"));
assert!(!is_newer("v0.5.0", "0.5.0"));
assert!(!is_newer("v0.4.9", "0.5.0"));
assert!(is_newer("2026.1", "0.5.0"), "unparsable remote falls back to inequality");
assert!(!is_newer("v0.5.0", "0.5.0-rc.1"), "pre-release current compares by core");
}
/// The check is on by default and follows the preferences toggle.
#[test]
fn check_enabled_follows_the_config() {
let _guard = crate::oakui::graphops::test_lock();
// Restore the key on drop (the config store is process-global).
struct Restore(&'static str, String);
impl Drop for Restore {
fn drop(&mut self) {
crate::oakui::real::config_set_string(self.0, &self.1);
}
}
let _save = Restore(
CONFIG_KEY_CHECK_UPDATES,
crate::oakui::real::config_get_string(CONFIG_KEY_CHECK_UPDATES),
);
crate::oakui::real::config_set_string(CONFIG_KEY_CHECK_UPDATES, "");
assert!(check_enabled(), "default-on");
set_check_enabled(false);
assert!(!check_enabled());
set_check_enabled(true);
assert!(check_enabled());
}
/// A scripted transport drives `fetch_latest` end to end.
#[test]
fn fetch_latest_reads_through_the_transport() {
struct Scripted;
impl UpdateTransport for Scripted {
fn get(&self, url: &str) -> Result<String, String> {
assert_eq!(url, UPDATE_ENDPOINT);
Ok(r#"{"version":"v9.9.9"}"#.to_string())
}
}
let release = fetch_latest(&Scripted).expect("scripted response");
assert_eq!(release.version, "v9.9.9");
assert!(should_notify(&release));
struct Failing;
impl UpdateTransport for Failing {
fn get(&self, _url: &str) -> Result<String, String> {
Err("offline".to_string())
}
}
assert!(fetch_latest(&Failing).is_err());
}
}