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
Generated
+127 -4
View File
@@ -392,7 +392,7 @@ dependencies = [
"arrow-schema",
"arrow-select",
"atoi",
"base64",
"base64 0.22.1",
"chrono",
"half",
"lexical-core",
@@ -846,6 +846,12 @@ version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "base64"
version = "0.23.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
[[package]]
name = "bigdecimal"
version = "0.4.10"
@@ -3444,6 +3450,12 @@ dependencies = [
"itoa",
]
[[package]]
name = "httparse"
version = "1.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
[[package]]
name = "hybrid-array"
version = "0.4.14"
@@ -4638,8 +4650,11 @@ dependencies = [
"oak-task",
"oak-timeline",
"oak-undo",
"serde",
"serde_json",
"serde_yaml",
"smallvec",
"ureq",
"wgpu",
]
@@ -6150,6 +6165,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 = "rkyv"
version = "0.7.46"
@@ -6291,6 +6320,41 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "rustls"
version = "0.23.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634"
dependencies = [
"log",
"once_cell",
"ring",
"rustls-pki-types",
"rustls-webpki",
"subtle",
"zeroize",
]
[[package]]
name = "rustls-pki-types"
version = "1.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"
dependencies = [
"zeroize",
]
[[package]]
name = "rustls-webpki"
version = "0.103.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
dependencies = [
"ring",
"rustls-pki-types",
"untrusted",
]
[[package]]
name = "rustversion"
version = "1.0.23"
@@ -6938,7 +7002,7 @@ version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "05b44e85bf579a8eeb4ceaa77a3a523baf2bf0e9bac7e40f405d537b5d2d5ccb"
dependencies = [
"base64",
"base64 0.22.1",
"bytes",
"cfg-if",
"chrono",
@@ -7045,7 +7109,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "87a2bdd6e83f6b3ea525ca9fee568030508b58355a43d0b2c1674d5f79dcd65e"
dependencies = [
"atoi",
"base64",
"base64 0.22.1",
"bitflags 2.13.1",
"byteorder",
"chrono",
@@ -7900,6 +7964,41 @@ version = "0.2.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861"
[[package]]
name = "untrusted"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]]
name = "ureq"
version = "3.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a7ac20be9b7726e0bbdbf974c059676d9acb1cd414961f570a4e8231cacd7fc"
dependencies = [
"base64 0.23.1",
"flate2",
"log",
"percent-encoding",
"rustls",
"rustls-pki-types",
"ureq-proto",
"utf8-zero",
"webpki-roots",
]
[[package]]
name = "ureq-proto"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f86fd172ccca569e458f61b6bdd6220965a9ef36e672a6852953b51a0e1583be"
dependencies = [
"base64 0.23.1",
"http",
"httparse",
"log",
]
[[package]]
name = "url"
version = "2.5.8"
@@ -7918,7 +8017,7 @@ version = "0.45.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "80be9b06fbae3b8b303400ab20778c80bbaf338f563afe567cf3c9eea17b47ef"
dependencies = [
"base64",
"base64 0.22.1",
"data-url",
"flate2",
"fontdb",
@@ -7945,6 +8044,12 @@ version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
[[package]]
name = "utf8-zero"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e"
[[package]]
name = "utf8_iter"
version = "1.0.4"
@@ -8269,6 +8374,15 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "webpki-roots"
version = "1.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a"
dependencies = [
"rustls-pki-types",
]
[[package]]
name = "weezl"
version = "0.1.12"
@@ -8759,6 +8873,15 @@ dependencies = [
"windows-targets 0.48.5",
]
[[package]]
name = "windows-sys"
version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
dependencies = [
"windows-targets 0.52.6",
]
[[package]]
name = "windows-sys"
version = "0.59.0"
+8
View File
@@ -133,6 +133,7 @@
"menu.tools.use_proxy": "Proxy-Medien verwenden"
"menu.tools.proxy_settings": "Proxy-Einstellungen…"
"menu.help.action_search": "Aktionen durchsuchen…"
"menu.help.bug_report": "Fehler melden…"
"menu.help.feedback": "Feedback senden…"
"menu.help.about": "Über Oak…"
"shortcut.space": "Leertaste"
@@ -682,6 +683,12 @@
"about.description": "Oak Video Editor ist ein freier, quelloffener nichtlinearer Videoeditor. Diese Software ist unter der GNU GPL Version 3 lizenziert."
"about.thanks": "Besonderer Dank gilt Enzo GD, unserem Community Promoter, für seine großzügige Unterstützung bei der Verbreitung dieses Projekts in dessen Anfangszeit."
"about.fork_notice": "Dieses Projekt ist ein Fork des Olive Video Editors."
"update.title": "Update verfügbar"
"update.available": "Version {version} ist verfügbar; installiert ist {current}."
"update.notes": "Neuerungen"
"update.no_notes": "Es wurden keine Versionshinweise angegeben."
"update.download": "Zu den Downloads"
"update.later": "Später"
"file.open.title": "Projekt öffnen (.ove / .otio / .fcpxml)"
"file.import_footage.title": "Material importieren"
"preferences.title": "Einstellungen"
@@ -706,6 +713,7 @@
"preferences.language": "Sprache"
"preferences.language.placeholder": "Sprache auswählen…"
"preferences.theme": "Design"
"preferences.check_updates": "Beim Start nach Updates suchen"
"preferences.theme.dark": "Olive Dark"
"preferences.theme.light": "Olive Light"
"preferences.cache.dir": "Verzeichnis für den Platten-Cache"
+8
View File
@@ -133,6 +133,7 @@
"menu.tools.use_proxy": "Use Proxy Media"
"menu.tools.proxy_settings": "Proxy Settings…"
"menu.help.action_search": "Search Actions…"
"menu.help.bug_report": "Report a Bug…"
"menu.help.feedback": "Send Feedback…"
"menu.help.about": "About Oak…"
"shortcut.space": "Space"
@@ -680,6 +681,12 @@
"about.description": "Oak Video Editor is a free open source non-linear video editor. This software is licensed under the GNU GPL Version 3."
"about.thanks": "Special thanks to Enzo GD, our Community Promoter, for his generous support in spreading the word about this project in its early days."
"about.fork_notice": "This project is a fork of Olive Video Editor."
"update.title": "Update Available"
"update.available": "Version {version} is available; you are running {current}."
"update.notes": "What's new"
"update.no_notes": "No release notes were provided."
"update.download": "Go to Downloads"
"update.later": "Later"
"file.open.title": "Open Project (.ove / .otio / .fcpxml)"
"file.import_footage.title": "Import Footage"
"preferences.title": "Preferences"
@@ -704,6 +711,7 @@
"preferences.language": "Language"
"preferences.language.placeholder": "Select a language…"
"preferences.theme": "Theme"
"preferences.check_updates": "Check for updates on startup"
"preferences.theme.dark": "Olive Dark"
"preferences.theme.light": "Olive Light"
"preferences.cache.dir": "Disk cache directory"
+8
View File
@@ -133,6 +133,7 @@
"menu.tools.use_proxy": "Usar medios proxy"
"menu.tools.proxy_settings": "Configuración de proxy…"
"menu.help.action_search": "Buscar acciones…"
"menu.help.bug_report": "Informar de un error…"
"menu.help.feedback": "Enviar comentarios…"
"menu.help.about": "Acerca de Oak…"
"shortcut.space": "Barra espaciadora"
@@ -682,6 +683,12 @@
"about.description": "Oak Video Editor es un editor de vídeo no lineal libre y de código abierto. Este software está licenciado bajo la GNU GPL versión 3."
"about.thanks": "Un agradecimiento especial a Enzo GD, nuestro Community Promoter, por su generoso apoyo al difundir este proyecto en sus primeros días."
"about.fork_notice": "Este proyecto es un fork de Olive Video Editor."
"update.title": "Actualización disponible"
"update.available": "La versión {version} está disponible; estás usando la {current}."
"update.notes": "Novedades"
"update.no_notes": "No se proporcionaron notas de la versión."
"update.download": "Ir a descargas"
"update.later": "Más tarde"
"file.open.title": "Abrir proyecto (.ove / .otio / .fcpxml)"
"file.import_footage.title": "Importar material"
"preferences.title": "Preferencias"
@@ -706,6 +713,7 @@
"preferences.language": "Idioma"
"preferences.language.placeholder": "Seleccionar un idioma…"
"preferences.theme": "Tema"
"preferences.check_updates": "Buscar actualizaciones al iniciar"
"preferences.theme.dark": "Olive Dark"
"preferences.theme.light": "Olive Light"
"preferences.cache.dir": "Directorio de caché en disco"
+8
View File
@@ -133,6 +133,7 @@
"menu.tools.use_proxy": "Utiliser les médias proxy"
"menu.tools.proxy_settings": "Paramètres de proxy…"
"menu.help.action_search": "Rechercher des actions…"
"menu.help.bug_report": "Signaler un bug…"
"menu.help.feedback": "Envoyer un retour…"
"menu.help.about": "À propos d'Oak…"
"shortcut.space": "Espace"
@@ -682,6 +683,12 @@
"about.description": "Oak Video Editor est un éditeur vidéo non linéaire libre et open source. Ce logiciel est sous licence GNU GPL version 3."
"about.thanks": "Remerciements particuliers à Enzo GD, notre Community Promoter, pour son généreux soutien à la diffusion de ce projet à ses débuts."
"about.fork_notice": "Ce projet est un fork d'Olive Video Editor."
"update.title": "Mise à jour disponible"
"update.available": "La version {version} est disponible ; vous utilisez la {current}."
"update.notes": "Nouveautés"
"update.no_notes": "Aucune note de version fournie."
"update.download": "Aller aux téléchargements"
"update.later": "Plus tard"
"file.open.title": "Ouvrir le projet (.ove / .otio / .fcpxml)"
"file.import_footage.title": "Importer des médias"
"preferences.title": "Préférences"
@@ -706,6 +713,7 @@
"preferences.language": "Langue"
"preferences.language.placeholder": "Sélectionner une langue…"
"preferences.theme": "Thème"
"preferences.check_updates": "Rechercher des mises à jour au démarrage"
"preferences.theme.dark": "Olive Dark"
"preferences.theme.light": "Olive Light"
"preferences.cache.dir": "Dossier du cache disque"
+8
View File
@@ -133,6 +133,7 @@
"menu.tools.use_proxy": "プロキシメディアを使用"
"menu.tools.proxy_settings": "プロキシ設定…"
"menu.help.action_search": "アクションを検索…"
"menu.help.bug_report": "バグを報告…"
"menu.help.feedback": "フィードバックを送信…"
"menu.help.about": "Oak について…"
"shortcut.space": "スペース"
@@ -682,6 +683,12 @@
"about.description": "Oak Video Editor はフリーでオープンソースのノンリニア動画エディタです。本ソフトウェアは GNU GPL バージョン 3 でライセンスされています。"
"about.thanks": "プロジェクト初期に広く紹介してくれたコミュニティプロモーター Enzo GD に心より感謝します。"
"about.fork_notice": "本プロジェクトは Olive Video Editor のフォークです。"
"update.title": "更新があります"
"update.available": "バージョン {version} が公開されています(現在は {current})。"
"update.notes": "更新内容"
"update.no_notes": "リリースノートはありません。"
"update.download": "ダウンロードへ"
"update.later": "後で"
"file.open.title": "プロジェクトを開く(.ove / .otio / .fcpxml)"
"file.import_footage.title": "素材を読み込み"
"preferences.title": "環境設定"
@@ -706,6 +713,7 @@
"preferences.language": "言語"
"preferences.language.placeholder": "言語を選択…"
"preferences.theme": "テーマ"
"preferences.check_updates": "起動時に更新を確認"
"preferences.theme.dark": "Olive Dark"
"preferences.theme.light": "Olive Light"
"preferences.cache.dir": "ディスクキャッシュの保存先"
+8
View File
@@ -133,6 +133,7 @@
"menu.tools.use_proxy": "Usar mídia proxy"
"menu.tools.proxy_settings": "Configurações de proxy…"
"menu.help.action_search": "Pesquisar ações…"
"menu.help.bug_report": "Relatar um bug…"
"menu.help.feedback": "Enviar comentários…"
"menu.help.about": "Sobre o Oak…"
"shortcut.space": "Espaço"
@@ -682,6 +683,12 @@
"about.description": "O Oak Video Editor é um editor de vídeo não linear livre e de código aberto. Este software é licenciado sob a GNU GPL versão 3."
"about.thanks": "Um agradecimento especial a Enzo GD, nosso Community Promoter, por seu generoso apoio na divulgação deste projeto em seus primórdios."
"about.fork_notice": "Este projeto é um fork do Olive Video Editor."
"update.title": "Atualização disponível"
"update.available": "A versão {version} está disponível; você está usando a {current}."
"update.notes": "Novidades"
"update.no_notes": "Nenhuma nota de versão fornecida."
"update.download": "Ir para downloads"
"update.later": "Mais tarde"
"file.open.title": "Abrir projeto (.ove / .otio / .fcpxml)"
"file.import_footage.title": "Importar mídia"
"preferences.title": "Preferências"
@@ -706,6 +713,7 @@
"preferences.language": "Idioma"
"preferences.language.placeholder": "Selecionar um idioma…"
"preferences.theme": "Tema"
"preferences.check_updates": "Verificar atualizações ao iniciar"
"preferences.theme.dark": "Olive Dark"
"preferences.theme.light": "Olive Light"
"preferences.cache.dir": "Diretório do cache em disco"
+8
View File
@@ -133,6 +133,7 @@
"menu.tools.use_proxy": "Использовать прокси-медиа"
"menu.tools.proxy_settings": "Параметры прокси…"
"menu.help.action_search": "Поиск действий…"
"menu.help.bug_report": "Сообщить об ошибке…"
"menu.help.feedback": "Отправить отзыв…"
"menu.help.about": "О программе Oak…"
"shortcut.space": "Пробел"
@@ -682,6 +683,12 @@
"about.description": "Oak Video Editor — свободный нелинейный видеоредактор с открытым исходным кодом. Программа распространяется по лицензии GNU GPL версии 3."
"about.thanks": "Особая благодарность Enzo GD, нашему Community Promoter, за щедрую поддержку в распространении этого проекта в первые дни."
"about.fork_notice": "Этот проект является форком Olive Video Editor."
"update.title": "Доступно обновление"
"update.available": "Доступна версия {version}; у вас установлена {current}."
"update.notes": "Что нового"
"update.no_notes": "Примечания к выпуску не указаны."
"update.download": "Перейти к загрузкам"
"update.later": "Позже"
"file.open.title": "Открыть проект (.ove / .otio / .fcpxml)"
"file.import_footage.title": "Импорт медиафайлов"
"preferences.title": "Настройки"
@@ -706,6 +713,7 @@
"preferences.language": "Язык"
"preferences.language.placeholder": "Выберите язык…"
"preferences.theme": "Тема"
"preferences.check_updates": "Проверять обновления при запуске"
"preferences.theme.dark": "Olive Dark"
"preferences.theme.light": "Olive Light"
"preferences.cache.dir": "Каталог дискового кэша"
+8
View File
@@ -133,6 +133,7 @@
"menu.tools.use_proxy": "使用代理媒体"
"menu.tools.proxy_settings": "代理设置…"
"menu.help.action_search": "搜索动作…"
"menu.help.bug_report": "报告问题…"
"menu.help.feedback": "发送反馈…"
"menu.help.about": "关于 Oak…"
"shortcut.space": "空格"
@@ -680,6 +681,12 @@
"about.description": "Oak 视频编辑器是一款自由开源的非线性视频编辑器。本软件以 GNU GPL 第三版许可证发布。"
"about.thanks": "特别感谢我们的社区推广者 Enzo GD,感谢他在项目早期为传播本项目所给予的慷慨支持。"
"about.fork_notice": "本项目是 Olive 视频编辑器的 fork。"
"update.title": "有新版本可用"
"update.available": "新版本 {version} 已发布,当前版本为 {current}。"
"update.notes": "更新说明"
"update.no_notes": "未提供更新说明。"
"update.download": "前往下载"
"update.later": "稍后"
"file.open.title": "打开项目(.ove / .otio / .fcpxml)"
"file.import_footage.title": "导入素材"
"preferences.title": "偏好设置"
@@ -704,6 +711,7 @@
"preferences.language": "语言"
"preferences.language.placeholder": "选择语言…"
"preferences.theme": "主题"
"preferences.check_updates": "启动时检查更新"
"preferences.theme.dark": "Olive Dark"
"preferences.theme.light": "Olive Light"
"preferences.cache.dir": "磁盘缓存目录"
+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());
}
}