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