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:
@@ -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).
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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, ¬es));
|
||||
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).
|
||||
|
||||
@@ -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| {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user