From 9cfcb0af92074082acbbb4dfb4977b84a04806f1 Mon Sep 17 00:00:00 2001 From: Mike Solar Date: Thu, 24 Sep 2026 12:55:42 +0800 Subject: [PATCH] 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. --- Cargo.lock | 131 +++++++++++++++- assets/i18n/de.yaml | 8 + assets/i18n/en-US.yaml | 8 + assets/i18n/es.yaml | 8 + assets/i18n/fr.yaml | 8 + assets/i18n/ja.yaml | 8 + assets/i18n/pt.yaml | 8 + assets/i18n/ru.yaml | 8 + assets/i18n/zh-CN.yaml | 8 + crates/oak-app/Cargo.toml | 6 + crates/oak-app/src/actions.rs | 1 + crates/oak-app/src/app.rs | 279 ++++++++++++++++++++++++++++++++++ crates/oak-app/src/dialogs.rs | 115 ++++++++++++++ crates/oak-app/src/lib.rs | 1 + crates/oak-app/src/update.rs | 272 +++++++++++++++++++++++++++++++++ 15 files changed, 865 insertions(+), 4 deletions(-) create mode 100644 crates/oak-app/src/update.rs diff --git a/Cargo.lock b/Cargo.lock index 3420d0859..4abb543b8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/assets/i18n/de.yaml b/assets/i18n/de.yaml index fb2d431d8..29c4deeda 100644 --- a/assets/i18n/de.yaml +++ b/assets/i18n/de.yaml @@ -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" diff --git a/assets/i18n/en-US.yaml b/assets/i18n/en-US.yaml index 7494d4e8f..6928eff4e 100644 --- a/assets/i18n/en-US.yaml +++ b/assets/i18n/en-US.yaml @@ -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" diff --git a/assets/i18n/es.yaml b/assets/i18n/es.yaml index 0fdd167b8..849bcdd3f 100644 --- a/assets/i18n/es.yaml +++ b/assets/i18n/es.yaml @@ -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" diff --git a/assets/i18n/fr.yaml b/assets/i18n/fr.yaml index 215870a9d..1069d89c2 100644 --- a/assets/i18n/fr.yaml +++ b/assets/i18n/fr.yaml @@ -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" diff --git a/assets/i18n/ja.yaml b/assets/i18n/ja.yaml index b2392fb3a..f4d15c330 100644 --- a/assets/i18n/ja.yaml +++ b/assets/i18n/ja.yaml @@ -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": "ディスクキャッシュの保存先" diff --git a/assets/i18n/pt.yaml b/assets/i18n/pt.yaml index 929523924..1af123efa 100644 --- a/assets/i18n/pt.yaml +++ b/assets/i18n/pt.yaml @@ -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" diff --git a/assets/i18n/ru.yaml b/assets/i18n/ru.yaml index 4b69a7270..b32bedaf4 100644 --- a/assets/i18n/ru.yaml +++ b/assets/i18n/ru.yaml @@ -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": "Каталог дискового кэша" diff --git a/assets/i18n/zh-CN.yaml b/assets/i18n/zh-CN.yaml index eab0e1f02..e5237e288 100644 --- a/assets/i18n/zh-CN.yaml +++ b/assets/i18n/zh-CN.yaml @@ -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": "磁盘缓存目录" diff --git a/crates/oak-app/Cargo.toml b/crates/oak-app/Cargo.toml index 27f66722c..03b16e937 100644 --- a/crates/oak-app/Cargo.toml +++ b/crates/oak-app/Cargo.toml @@ -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). diff --git a/crates/oak-app/src/actions.rs b/crates/oak-app/src/actions.rs index 732cd2330..9c62f59b1 100644 --- a/crates/oak-app/src/actions.rs +++ b/crates/oak-app/src/actions.rs @@ -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 }; } diff --git a/crates/oak-app/src/app.rs b/crates/oak-app/src/app.rs index d57a74348..beb25b7a5 100644 --- a/crates/oak-app/src/app.rs +++ b/crates/oak-app/src/app.rs @@ -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 { About { modal: Entity, }, + /// The update-available prompt (the startup check; the dialog itself + /// carries the version and notes). + UpdateAvailable { + modal: Entity, + }, /// The new-sequence dialog (File > New > Sequence…). NewSequence { modal: Entity, @@ -291,6 +298,7 @@ impl ModalState { | 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 { 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, + /// The transport the startup update check fetches through (tests inject + /// a scripted one; production uses the real HTTPS client). + update_transport: Arc, } /// The dock panels the shell builds up front, kept for focused-panel command @@ -895,6 +910,8 @@ impl OakApp { 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 OakApp { | 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 OakApp { pub fn close_modal(&mut self, cx: &mut Context) { 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 OakApp { }); } + /// 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) { + 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, + cx: &mut Context, + ) { + 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, + ) { + 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) { + 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 OakApp { } } 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 { 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(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, + calls: AtomicU32, + } + impl crate::update::UpdateTransport for Scripted { + fn get(&self, url: &str) -> Result { + 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| { + 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). diff --git a/crates/oak-app/src/dialogs.rs b/crates/oak-app/src/dialogs.rs index 34f90d400..d5d31c21d 100644 --- a/crates/oak-app/src/dialogs.rs +++ b/crates/oak-app/src/dialogs.rs @@ -113,6 +113,9 @@ pub struct PreferencesContent { display_bit_depth: Entity, language: Entity, theme: Entity, + /// 常规 General: check for a newer release on startup (config + /// `CheckForUpdates`; the startup path reads the key live). + check_updates: Entity, cache_dir: Entity, cache_ahead: Entity, use_proxy: Entity, @@ -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) -> 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) -> 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| { diff --git a/crates/oak-app/src/lib.rs b/crates/oak-app/src/lib.rs index e356ce921..8fb3ea504 100644 --- a/crates/oak-app/src/lib.rs +++ b/crates/oak-app/src/lib.rs @@ -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() { diff --git a/crates/oak-app/src/update.rs b/crates/oak-app/src/update.rs new file mode 100644 index 000000000..d49fa6fbd --- /dev/null +++ b/crates/oak-app/src/update.rs @@ -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 . + +//! 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//download?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 { + 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; +} + +/// 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 { + 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 { + 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 { + 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 { + Err("offline".to_string()) + } + } + assert!(fetch_latest(&Failing).is_err()); + } +}