fix(updater): restore Qt release checks and signed installation

cx
This commit is contained in:
vicrodh committed 2026-09-12 15:20:23 -06:00
1 parent 6a02dcdd67
commit 64ec3e2bc8
37 files changed
+2462 -7

No files matched your search

+1 -1
View File
@@ -259,7 +259,7 @@ jobs:
# verify artifacts with the Tauri minisign pubkey baked into the old
# binaries. Signing with the SAME private key lets them auto-update
# straight into this AppImage. release-updater-manifest.yml collects
# the .sig. The app itself has no auto-updater.
# the .sig. The Qt updater also consumes this signed manifest.
- name: Sign AppImage (Tauri-updater bridge)
if: startsWith(github.ref, 'refs/tags/')
env:
+1 -1
View File
@@ -263,7 +263,7 @@ jobs:
# verify artifacts with the Tauri minisign pubkey baked into the old
# binaries. Signing with the SAME private key lets them auto-update
# straight into this AppImage. release-updater-manifest.yml collects
# the .sig. The app itself has no auto-updater.
# the .sig. The Qt updater also consumes this signed manifest.
- name: Sign AppImage (Tauri-updater bridge)
if: startsWith(github.ref, 'refs/tags/')
env:
+4
View File
@@ -34,6 +34,8 @@
# pre-release
# integration so regressions land before a release cut.
# Shared cargo-test.sh also gates signed updater verification, version selection
# and preference preservation; qt-runtime-gate.sh covers the update UI.
# Shared cargo-test.sh also gates Orbit HTTP host isolation, library profiles
# and the SSE public-identity projection. No separate test command to drift.
name: test-crates
@@ -57,6 +59,7 @@ on:
- "scripts/qml-tests/**"
- "scripts/test-search-local-qml.sh"
- "scripts/test-orbit-qml.sh"
- "scripts/test-updates-qml.sh"
- "scripts/test-orbit-daemon.py"
- ".github/workflows/test-crates.yml"
push:
@@ -76,6 +79,7 @@ on:
- "scripts/qml-tests/**"
- "scripts/test-search-local-qml.sh"
- "scripts/test-orbit-qml.sh"
- "scripts/test-updates-qml.sh"
- "scripts/test-orbit-daemon.py"
- ".github/workflows/test-crates.yml"
workflow_dispatch:
+14
View File
@@ -333,6 +333,20 @@ subscription, Gatekeeper blocks its first run. On recent macOS versions
it's a one-time unlock for this copy of the app; updates installed through
QBZ's own updater don't need it again.
### Updates
Use **Check for updates now** in the app menu or **Settings → Updates**.
Launch checks are optional, preserve existing preferences, and notify only
after a stable release is at least 12 hours old. Manual checks find new stable
releases immediately and report network errors separately from “up to date”.
AppImage and upstream macOS `.app` installations can download and install
signed updates from QBZ. Close and reopen the app after installation. macOS
builds signed by another distributor, including the notarized community
builds, keep their original update channel. For Flatpak, Snap, APT/RPM, AUR,
Gentoo, Nix, Homebrew, Windows and manual installs, update through the original
installation source; its package may arrive after the GitHub release.
## Features
### Audio and playback
+47
View File
@@ -579,6 +579,7 @@ dependencies = [
"js-sys",
"num-traits",
"pure-rust-locales",
"serde",
"wasm-bindgen",
"windows-link 0.2.1",
]
@@ -2945,6 +2946,12 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
[[package]]
name = "minisign-verify"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22f9645cb765ea72b8111f36c522475d2daa0d22c957a9826437e97534bc4e9e"
[[package]]
name = "miniz_oxide"
version = "0.8.9"
@@ -4465,6 +4472,7 @@ dependencies = [
"qbz-subsonic",
"qbz-text-utils",
"qbz-theme",
"qbz-updater",
"qconnect-app",
"qconnect-lan",
"qconnect-protocol",
@@ -4591,6 +4599,24 @@ dependencies = [
"serde_json",
]
[[package]]
name = "qbz-updater"
version = "2.1.1"
dependencies = [
"base64 0.23.1",
"chrono",
"flate2",
"minisign-verify",
"reqwest",
"rusqlite",
"semver",
"serde",
"serde_json",
"tar",
"tempfile",
"tokio",
]
[[package]]
name = "qbzd"
version = "2.1.1"
@@ -5992,6 +6018,17 @@ dependencies = [
"syn 2.0.119",
]
[[package]]
name = "tar"
version = "0.4.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840"
dependencies = [
"filetime",
"libc",
"xattr",
]
[[package]]
name = "task-local"
version = "0.1.1"
@@ -7475,6 +7512,16 @@ version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc"
[[package]]
name = "xattr"
version = "1.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156"
dependencies = [
"libc",
"rustix 1.1.4",
]
[[package]]
name = "xdg-home"
version = "1.3.0"
+1
View File
@@ -2,6 +2,7 @@
resolver = "2"
members = [
"qbz-updater",
"qbz-app",
"qbz-control",
"qbz-theme",
@@ -7140,3 +7140,108 @@ msgstr "Dieser Host unterstützt keine Bibliotheksabfragen."
#. Experimental Orbit host inspection and persistent remote context.
msgid "Verify host"
msgstr "Host überprüfen"
#: qbz-qt updates
msgid "A new version of QBZ is available."
msgstr "Eine neue Version von QBZ ist verfügbar."
#: qbz-qt updates
msgid "Cancel download"
msgstr "Download abbrechen"
#: qbz-qt updates
msgid "Check for new releases on launch"
msgstr "Beim Start nach neuen Versionen suchen"
#: qbz-qt updates
msgid "Check for updates now"
msgstr "Jetzt nach Updates suchen"
#: qbz-qt updates
msgid "Checking for updates..."
msgstr "Suche nach Updates..."
#: qbz-qt updates
msgid "Current version"
msgstr "Aktuelle Version"
#: qbz-qt updates
msgid "Download and install"
msgstr "Herunterladen und installieren"
#: qbz-qt updates
msgid "Downloading update..."
msgstr "Update wird heruntergeladen..."
#: qbz-qt updates
msgid "Installation"
msgstr "Installation"
#: qbz-qt updates
msgid "Installing update..."
msgstr "Update wird installiert..."
#: qbz-qt updates
msgid "Show changelog for current version"
msgstr "Änderungen der aktuellen Version anzeigen"
#: qbz-qt updates
msgid "Skip this version"
msgstr "Diese Version überspringen"
#: qbz-qt updates
msgid "The update could not be completed. Please try again."
msgstr "Das Update konnte nicht abgeschlossen werden. Bitte erneut versuchen."
#: qbz-qt updates
msgid "Update QBZ through your installation source. Packages may become available after the GitHub release."
msgstr "Aktualisiere QBZ über die ursprüngliche Installationsquelle. Pakete können erst nach der GitHub-Veröffentlichung verfügbar sein."
#: qbz-qt updates
msgid "Update cancelled."
msgstr "Update abgebrochen."
#: qbz-qt updates
msgid "Update installed. Close and reopen QBZ to use the new version."
msgstr "Update installiert. Schließe QBZ und öffne es erneut, um die neue Version zu verwenden."
#: qbz-qt updates
msgid "Updates"
msgstr "Updates"
#: qbz-qt updates
msgid "Verifying update signature..."
msgstr "Update-Signatur wird überprüft..."
#: qbz-qt updates
msgid "Visit release page"
msgstr "Veröffentlichungsseite öffnen"
#: qbz-qt updates
msgid "You are using the latest version of QBZ."
msgstr "Du verwendest die neueste Version von QBZ."
#: qbz-qt updates
msgid "System / manual"
msgstr "System / manuell"
@@ -2740,3 +2740,108 @@ msgstr "This host does not support library inspection."
#. Experimental Orbit host inspection and persistent remote context.
msgid "Verify host"
msgstr "Verify host"
#: qbz-qt updates
msgid "A new version of QBZ is available."
msgstr "A new version of QBZ is available."
#: qbz-qt updates
msgid "Cancel download"
msgstr "Cancel download"
#: qbz-qt updates
msgid "Check for new releases on launch"
msgstr "Check for new releases on launch"
#: qbz-qt updates
msgid "Check for updates now"
msgstr "Check for updates now"
#: qbz-qt updates
msgid "Checking for updates..."
msgstr "Checking for updates..."
#: qbz-qt updates
msgid "Current version"
msgstr "Current version"
#: qbz-qt updates
msgid "Download and install"
msgstr "Download and install"
#: qbz-qt updates
msgid "Downloading update..."
msgstr "Downloading update..."
#: qbz-qt updates
msgid "Installation"
msgstr "Installation"
#: qbz-qt updates
msgid "Installing update..."
msgstr "Installing update..."
#: qbz-qt updates
msgid "Show changelog for current version"
msgstr "Show changelog for current version"
#: qbz-qt updates
msgid "Skip this version"
msgstr "Skip this version"
#: qbz-qt updates
msgid "The update could not be completed. Please try again."
msgstr "The update could not be completed. Please try again."
#: qbz-qt updates
msgid "Update QBZ through your installation source. Packages may become available after the GitHub release."
msgstr "Update QBZ through your installation source. Packages may become available after the GitHub release."
#: qbz-qt updates
msgid "Update cancelled."
msgstr "Update cancelled."
#: qbz-qt updates
msgid "Update installed. Close and reopen QBZ to use the new version."
msgstr "Update installed. Close and reopen QBZ to use the new version."
#: qbz-qt updates
msgid "Updates"
msgstr "Updates"
#: qbz-qt updates
msgid "Verifying update signature..."
msgstr "Verifying update signature..."
#: qbz-qt updates
msgid "Visit release page"
msgstr "Visit release page"
#: qbz-qt updates
msgid "You are using the latest version of QBZ."
msgstr "You are using the latest version of QBZ."
#: qbz-qt updates
msgid "System / manual"
msgstr "System / manual"
@@ -7146,3 +7146,108 @@ msgstr "Este equipo no permite consultar su biblioteca."
#. Experimental Orbit host inspection and persistent remote context.
msgid "Verify host"
msgstr "Verificar equipo"
#: qbz-qt updates
msgid "A new version of QBZ is available."
msgstr "Hay una nueva versión de QBZ disponible."
#: qbz-qt updates
msgid "Cancel download"
msgstr "Cancelar descarga"
#: qbz-qt updates
msgid "Check for new releases on launch"
msgstr "Buscar nuevas versiones al iniciar"
#: qbz-qt updates
msgid "Check for updates now"
msgstr "Buscar actualizaciones ahora"
#: qbz-qt updates
msgid "Checking for updates..."
msgstr "Buscando actualizaciones..."
#: qbz-qt updates
msgid "Current version"
msgstr "Versión actual"
#: qbz-qt updates
msgid "Download and install"
msgstr "Descargar e instalar"
#: qbz-qt updates
msgid "Downloading update..."
msgstr "Descargando actualización..."
#: qbz-qt updates
msgid "Installation"
msgstr "Instalación"
#: qbz-qt updates
msgid "Installing update..."
msgstr "Instalando actualización..."
#: qbz-qt updates
msgid "Show changelog for current version"
msgstr "Mostrar cambios de la versión actual"
#: qbz-qt updates
msgid "Skip this version"
msgstr "Omitir esta versión"
#: qbz-qt updates
msgid "The update could not be completed. Please try again."
msgstr "No se pudo completar la actualización. Inténtalo de nuevo."
#: qbz-qt updates
msgid "Update QBZ through your installation source. Packages may become available after the GitHub release."
msgstr "Actualiza QBZ desde el medio que usaste para instalarlo. Los paquetes pueden estar disponibles después del lanzamiento en GitHub."
#: qbz-qt updates
msgid "Update cancelled."
msgstr "Actualización cancelada."
#: qbz-qt updates
msgid "Update installed. Close and reopen QBZ to use the new version."
msgstr "Actualización instalada. Cierra y vuelve a abrir QBZ para usar la nueva versión."
#: qbz-qt updates
msgid "Updates"
msgstr "Actualizaciones"
#: qbz-qt updates
msgid "Verifying update signature..."
msgstr "Verificando la firma de la actualización..."
#: qbz-qt updates
msgid "Visit release page"
msgstr "Ver página del lanzamiento"
#: qbz-qt updates
msgid "You are using the latest version of QBZ."
msgstr "Estás usando la versión más reciente de QBZ."
#: qbz-qt updates
msgid "System / manual"
msgstr "Sistema / manual"
@@ -7138,3 +7138,108 @@ msgstr "Cet hôte ne permet pas de consulter sa bibliothèque."
#. Experimental Orbit host inspection and persistent remote context.
msgid "Verify host"
msgstr "Vérifier l’hôte"
#: qbz-qt updates
msgid "A new version of QBZ is available."
msgstr "Une nouvelle version de QBZ est disponible."
#: qbz-qt updates
msgid "Cancel download"
msgstr "Annuler le téléchargement"
#: qbz-qt updates
msgid "Check for new releases on launch"
msgstr "Rechercher de nouvelles versions au démarrage"
#: qbz-qt updates
msgid "Check for updates now"
msgstr "Rechercher des mises à jour"
#: qbz-qt updates
msgid "Checking for updates..."
msgstr "Recherche de mises à jour..."
#: qbz-qt updates
msgid "Current version"
msgstr "Version actuelle"
#: qbz-qt updates
msgid "Download and install"
msgstr "Télécharger et installer"
#: qbz-qt updates
msgid "Downloading update..."
msgstr "Téléchargement de la mise à jour..."
#: qbz-qt updates
msgid "Installation"
msgstr "Installation"
#: qbz-qt updates
msgid "Installing update..."
msgstr "Installation de la mise à jour..."
#: qbz-qt updates
msgid "Show changelog for current version"
msgstr "Afficher les changements de la version actuelle"
#: qbz-qt updates
msgid "Skip this version"
msgstr "Ignorer cette version"
#: qbz-qt updates
msgid "The update could not be completed. Please try again."
msgstr "La mise à jour a échoué. Veuillez réessayer."
#: qbz-qt updates
msgid "Update QBZ through your installation source. Packages may become available after the GitHub release."
msgstr "Mettez QBZ à jour depuis sa source d’installation. Les paquets peuvent être disponibles après la publication sur GitHub."
#: qbz-qt updates
msgid "Update cancelled."
msgstr "Mise à jour annulée."
#: qbz-qt updates
msgid "Update installed. Close and reopen QBZ to use the new version."
msgstr "Mise à jour installée. Fermez puis rouvrez QBZ pour utiliser la nouvelle version."
#: qbz-qt updates
msgid "Updates"
msgstr "Mises à jour"
#: qbz-qt updates
msgid "Verifying update signature..."
msgstr "Vérification de la signature de la mise à jour..."
#: qbz-qt updates
msgid "Visit release page"
msgstr "Voir la page de la version"
#: qbz-qt updates
msgid "You are using the latest version of QBZ."
msgstr "Vous utilisez la dernière version de QBZ."
#: qbz-qt updates
msgid "System / manual"
msgstr "Système / manuelle"
@@ -7121,3 +7121,108 @@ msgstr "このホストはライブラリの参照に対応していません。
#. Experimental Orbit host inspection and persistent remote context.
msgid "Verify host"
msgstr "ホストを確認"
#: qbz-qt updates
msgid "A new version of QBZ is available."
msgstr "QBZの新しいバージョンが利用可能です。"
#: qbz-qt updates
msgid "Cancel download"
msgstr "ダウンロードをキャンセル"
#: qbz-qt updates
msgid "Check for new releases on launch"
msgstr "起動時に新しいバージョンを確認"
#: qbz-qt updates
msgid "Check for updates now"
msgstr "今すぐ更新を確認"
#: qbz-qt updates
msgid "Checking for updates..."
msgstr "更新を確認中..."
#: qbz-qt updates
msgid "Current version"
msgstr "現在のバージョン"
#: qbz-qt updates
msgid "Download and install"
msgstr "ダウンロードしてインストール"
#: qbz-qt updates
msgid "Downloading update..."
msgstr "更新をダウンロード中..."
#: qbz-qt updates
msgid "Installation"
msgstr "インストール方法"
#: qbz-qt updates
msgid "Installing update..."
msgstr "更新をインストール中..."
#: qbz-qt updates
msgid "Show changelog for current version"
msgstr "現在のバージョンの変更履歴を表示"
#: qbz-qt updates
msgid "Skip this version"
msgstr "このバージョンをスキップ"
#: qbz-qt updates
msgid "The update could not be completed. Please try again."
msgstr "更新を完了できませんでした。もう一度お試しください。"
#: qbz-qt updates
msgid "Update QBZ through your installation source. Packages may become available after the GitHub release."
msgstr "インストール元からQBZを更新してください。パッケージはGitHubでのリリース後に利用可能になる場合があります。"
#: qbz-qt updates
msgid "Update cancelled."
msgstr "更新をキャンセルしました。"
#: qbz-qt updates
msgid "Update installed. Close and reopen QBZ to use the new version."
msgstr "更新をインストールしました。新しいバージョンを使うにはQBZを閉じて再度開いてください。"
#: qbz-qt updates
msgid "Updates"
msgstr "更新"
#: qbz-qt updates
msgid "Verifying update signature..."
msgstr "更新の署名を検証中..."
#: qbz-qt updates
msgid "Visit release page"
msgstr "リリースページを開く"
#: qbz-qt updates
msgid "You are using the latest version of QBZ."
msgstr "最新バージョンのQBZを使用しています。"
#: qbz-qt updates
msgid "System / manual"
msgstr "システム / 手動"
@@ -7128,3 +7128,108 @@ msgstr "Deze host ondersteunt geen bibliotheekinzage."
#. Experimental Orbit host inspection and persistent remote context.
msgid "Verify host"
msgstr "Host controleren"
#: qbz-qt updates
msgid "A new version of QBZ is available."
msgstr "Er is een nieuwe versie van QBZ beschikbaar."
#: qbz-qt updates
msgid "Cancel download"
msgstr "Download annuleren"
#: qbz-qt updates
msgid "Check for new releases on launch"
msgstr "Bij het opstarten naar nieuwe versies zoeken"
#: qbz-qt updates
msgid "Check for updates now"
msgstr "Nu op updates controleren"
#: qbz-qt updates
msgid "Checking for updates..."
msgstr "Controleren op updates..."
#: qbz-qt updates
msgid "Current version"
msgstr "Huidige versie"
#: qbz-qt updates
msgid "Download and install"
msgstr "Downloaden en installeren"
#: qbz-qt updates
msgid "Downloading update..."
msgstr "Update downloaden..."
#: qbz-qt updates
msgid "Installation"
msgstr "Installatie"
#: qbz-qt updates
msgid "Installing update..."
msgstr "Update installeren..."
#: qbz-qt updates
msgid "Show changelog for current version"
msgstr "Wijzigingen van de huidige versie tonen"
#: qbz-qt updates
msgid "Skip this version"
msgstr "Deze versie overslaan"
#: qbz-qt updates
msgid "The update could not be completed. Please try again."
msgstr "De update kon niet worden voltooid. Probeer het opnieuw."
#: qbz-qt updates
msgid "Update QBZ through your installation source. Packages may become available after the GitHub release."
msgstr "Werk QBZ bij via de oorspronkelijke installatiebron. Pakketten kunnen na de GitHub-release beschikbaar komen."
#: qbz-qt updates
msgid "Update cancelled."
msgstr "Update geannuleerd."
#: qbz-qt updates
msgid "Update installed. Close and reopen QBZ to use the new version."
msgstr "Update geïnstalleerd. Sluit en heropen QBZ om de nieuwe versie te gebruiken."
#: qbz-qt updates
msgid "Updates"
msgstr "Updates"
#: qbz-qt updates
msgid "Verifying update signature..."
msgstr "Handtekening van update controleren..."
#: qbz-qt updates
msgid "Visit release page"
msgstr "Releasepagina openen"
#: qbz-qt updates
msgid "You are using the latest version of QBZ."
msgstr "Je gebruikt de nieuwste versie van QBZ."
#: qbz-qt updates
msgid "System / manual"
msgstr "Systeem / handmatig"
@@ -7138,3 +7138,108 @@ msgstr "Este host não permite consultar sua biblioteca."
#. Experimental Orbit host inspection and persistent remote context.
msgid "Verify host"
msgstr "Verificar host"
#: qbz-qt updates
msgid "A new version of QBZ is available."
msgstr "Uma nova versão do QBZ está disponível."
#: qbz-qt updates
msgid "Cancel download"
msgstr "Cancelar download"
#: qbz-qt updates
msgid "Check for new releases on launch"
msgstr "Buscar novas versões ao iniciar"
#: qbz-qt updates
msgid "Check for updates now"
msgstr "Buscar atualizações agora"
#: qbz-qt updates
msgid "Checking for updates..."
msgstr "Buscando atualizações..."
#: qbz-qt updates
msgid "Current version"
msgstr "Versão atual"
#: qbz-qt updates
msgid "Download and install"
msgstr "Baixar e instalar"
#: qbz-qt updates
msgid "Downloading update..."
msgstr "Baixando atualização..."
#: qbz-qt updates
msgid "Installation"
msgstr "Instalação"
#: qbz-qt updates
msgid "Installing update..."
msgstr "Instalando atualização..."
#: qbz-qt updates
msgid "Show changelog for current version"
msgstr "Mostrar alterações da versão atual"
#: qbz-qt updates
msgid "Skip this version"
msgstr "Ignorar esta versão"
#: qbz-qt updates
msgid "The update could not be completed. Please try again."
msgstr "Não foi possível concluir a atualização. Tente novamente."
#: qbz-qt updates
msgid "Update QBZ through your installation source. Packages may become available after the GitHub release."
msgstr "Atualize o QBZ pela fonte usada na instalação. Os pacotes podem ficar disponíveis após o lançamento no GitHub."
#: qbz-qt updates
msgid "Update cancelled."
msgstr "Atualização cancelada."
#: qbz-qt updates
msgid "Update installed. Close and reopen QBZ to use the new version."
msgstr "Atualização instalada. Feche e reabra o QBZ para usar a nova versão."
#: qbz-qt updates
msgid "Updates"
msgstr "Atualizações"
#: qbz-qt updates
msgid "Verifying update signature..."
msgstr "Verificando a assinatura da atualização..."
#: qbz-qt updates
msgid "Visit release page"
msgstr "Ver página do lançamento"
#: qbz-qt updates
msgid "You are using the latest version of QBZ."
msgstr "Você está usando a versão mais recente do QBZ."
#: qbz-qt updates
msgid "System / manual"
msgstr "Sistema / manual"
@@ -7159,3 +7159,108 @@ msgstr "Этот хост не поддерживает просмотр биб
#. Experimental Orbit host inspection and persistent remote context.
msgid "Verify host"
msgstr "Проверить хост"
#: qbz-qt updates
msgid "A new version of QBZ is available."
msgstr "Доступна новая версия QBZ."
#: qbz-qt updates
msgid "Cancel download"
msgstr "Отменить загрузку"
#: qbz-qt updates
msgid "Check for new releases on launch"
msgstr "Проверять новые версии при запуске"
#: qbz-qt updates
msgid "Check for updates now"
msgstr "Проверить обновления сейчас"
#: qbz-qt updates
msgid "Checking for updates..."
msgstr "Проверка обновлений..."
#: qbz-qt updates
msgid "Current version"
msgstr "Текущая версия"
#: qbz-qt updates
msgid "Download and install"
msgstr "Скачать и установить"
#: qbz-qt updates
msgid "Downloading update..."
msgstr "Загрузка обновления..."
#: qbz-qt updates
msgid "Installation"
msgstr "Установка"
#: qbz-qt updates
msgid "Installing update..."
msgstr "Установка обновления..."
#: qbz-qt updates
msgid "Show changelog for current version"
msgstr "Показать изменения текущей версии"
#: qbz-qt updates
msgid "Skip this version"
msgstr "Пропустить эту версию"
#: qbz-qt updates
msgid "The update could not be completed. Please try again."
msgstr "Не удалось завершить обновление. Повторите попытку."
#: qbz-qt updates
msgid "Update QBZ through your installation source. Packages may become available after the GitHub release."
msgstr "Обновите QBZ через источник установки. Пакеты могут появиться позже выпуска на GitHub."
#: qbz-qt updates
msgid "Update cancelled."
msgstr "Обновление отменено."
#: qbz-qt updates
msgid "Update installed. Close and reopen QBZ to use the new version."
msgstr "Обновление установлено. Закройте и снова откройте QBZ, чтобы использовать новую версию."
#: qbz-qt updates
msgid "Updates"
msgstr "Обновления"
#: qbz-qt updates
msgid "Verifying update signature..."
msgstr "Проверка подписи обновления..."
#: qbz-qt updates
msgid "Visit release page"
msgstr "Открыть страницу выпуска"
#: qbz-qt updates
msgid "You are using the latest version of QBZ."
msgstr "Вы используете последнюю версию QBZ."
#: qbz-qt updates
msgid "System / manual"
msgstr "Системная / ручная"
+1
View File
@@ -12,6 +12,7 @@ name = "qbz"
path = "src/main.rs"
[dependencies]
qbz-updater = { path = "../qbz-updater" }
qbz-control = { path = "../qbz-control" }
cxx = "1"
cxx-qt = "0.7"
+2
View File
@@ -642,6 +642,7 @@ fn main() {
"qml/rows/TrackRow.qml",
"qml/settings/AppearanceSettings.qml",
"qml/settings/NavigationSettings.qml",
"qml/settings/UpdatesSettings.qml",
"qml/settings/CustomThemeEditor.qml",
"qml/settings/IntegrationsSettings.qml",
"qml/settings/SettingsView.qml",
@@ -721,6 +722,7 @@ fn main() {
// QbzAbout's two documents.
"qml/shell/AboutModal.qml",
"qml/shell/WhatsNewModal.qml",
"qml/shell/UpdatesModal.qml",
"qml/shell/WindowsDisclaimerModal.qml",
// Immersive mode (2026-08-02 immersive-port contract §2) —
// its own module directory like views/local/ and
+10
View File
@@ -940,6 +940,10 @@ ApplicationWindow {
property bool quitAccepted: false
function requestQuit() {
if (JSON.parse(QbzAbout.updatesJson).phase === "installing") {
QbzAbout.updatesCheck()
return
}
if (quitAccepted || quitConfirmation.opened)
return
if (!QbzTray.confirmQuitEnabled()) {
@@ -955,6 +959,12 @@ ApplicationWindow {
}
function finishQuit() {
// The download may have reached replacement while a quit confirmation
// was open. Check again before arming the hard-exit watchdog.
if (JSON.parse(QbzAbout.updatesJson).phase === "installing") {
QbzAbout.updatesCheck()
return
}
if (quitAccepted)
return
quitAccepted = true
@@ -76,6 +76,7 @@ Item {
{ label: QbzSession.tr("Blacklist", QbzSession.trRev), section: 5 },
{ label: QbzSession.tr("Integrations", QbzSession.trRev), section: 6 },
{ label: QbzSession.tr("Import / Export", QbzSession.trRev), section: 9 },
{ label: QbzSession.tr("Updates", QbzSession.trRev), section: 12 },
{ label: QbzSession.tr("Developer", QbzSession.trRev), section: 7 }
].concat(QbzOrbit.enabled ? [{ label: "Orbit", section: 11 }] : []).concat(root.sandboxed ? [{ label: (doc.dev || ({})).installMethod === "snap"
? QbzSession.tr("Snap", QbzSession.trRev) : QbzSession.tr("Flatpak", QbzSession.trRev), section: 8 }] : [])
@@ -281,6 +282,12 @@ Item {
active: root.section === 9
onClicked: QbzBridge.settingsSetSection(9)
}
SubNavItem {
name: "refresh-cw"
label: QbzSession.tr("Updates", QbzSession.trRev)
active: root.section === 12
onClicked: QbzBridge.settingsSetSection(12)
}
SubNavItem {
name: "bug"
label: QbzSession.tr("Developer", QbzSession.trRev)
@@ -413,6 +420,12 @@ Item {
width: parent ? parent.width : 0
}
Panel {
panelIndex: 12
sourceComponent: Component {
UpdatesSettings { width: parent.width; kioskHost: root.kioskHost }
}
}
OrbitSettingsPanel {
width: parent.width
selected: root.section === 11
@@ -0,0 +1,57 @@
import QtQuick
import com.blitzfc.qbz
import "../controls"
import "../theme"
Column {
id: root
property bool kioskHost: false
readonly property var doc: JSON.parse(QbzAbout.updatesJson)
spacing: 4
QbzTheme { id: theme }
SettingRow {
kioskHost: root.kioskHost
label: QbzSession.tr("Check for new releases on launch", QbzSession.trRev)
QbzToggle {
kioskHost: root.kioskHost
checked: root.doc.checkOnLaunch !== false
onToggled: function (enabled) { QbzAbout.updatesSetLaunch(enabled) }
}
}
SettingRow {
kioskHost: root.kioskHost
label: QbzSession.tr("Check for updates now", QbzSession.trRev)
SettingsButton {
objectName: "updateCheckButton"
kioskHost: root.kioskHost
width: 160
text: root.doc.busy ? QbzSession.tr("Checking...", QbzSession.trRev) : QbzSession.tr("Check", QbzSession.trRev)
enabled: root.doc.busy !== true
onClicked: QbzAbout.updatesCheck()
}
}
Text {
width: parent.width
text: QbzSession.tr("Current version", QbzSession.trRev) + ": " + (root.doc.currentVersion || "")
color: theme.textMuted
font.pixelSize: theme.fontBody
}
Text {
width: parent.width
text: QbzSession.tr("Installation", QbzSession.trRev) + ": " + (root.doc.installMethod === "System / manual" ? QbzSession.tr("System / manual", QbzSession.trRev) : (root.doc.installMethod || ""))
color: theme.textMuted
font.pixelSize: theme.fontBody
}
SettingsSpacer { }
SettingRow {
kioskHost: root.kioskHost
label: QbzSession.tr("Show changelog for current version", QbzSession.trRev)
SettingsButton {
kioskHost: root.kioskHost
width: 160
text: QbzSession.tr("Show", QbzSession.trRev)
onClicked: QbzAbout.whatsNewOpen()
}
}
}
+5 -1
View File
@@ -141,7 +141,10 @@ Rectangle {
// until the first click (measured 2026-08-03, RFB H0 first pass). Grab
// once at mount; every other lifecycle arm (immersive close, modal
// closes) hands focus BACK here.
Component.onCompleted: root.forceActiveFocus()
Component.onCompleted: {
root.forceActiveFocus()
Qt.callLater(function () { QbzAbout.updatesLaunch() })
}
// THE ONE key entry (§1.1 route (b), divergence K1): NOTHING is handled
// locally. The ordered pipeline (capture steal, search-dropdown Up/Down
@@ -1051,6 +1054,7 @@ Rectangle {
// while closed it is an invisible, non-interactive Item that parses one
// small JSON string — and each carries an explicit `z: 3000` (ADR-009 as
// this port spells it) rather than relying on declaration order alone.
UpdatesModal { }
AboutModal {
anchors.fill: parent
}
+8
View File
@@ -1497,6 +1497,14 @@ Rectangle {
root.reportIssueRequested()
}
}
AppMenuItem {
name: "refresh-cw"
label: QbzSession.tr("Check for updates now", QbzSession.trRev)
onClicked: {
appMenu.close()
QbzAbout.updatesCheck()
}
}
AppMenuItem {
name: "wand-sparkles"
label: QbzSession.tr("What's New", QbzSession.trRev)
+6 -1
View File
@@ -48,9 +48,14 @@ Rectangle {
// in a WM-less session.
readonly property bool isQbzShellRoot: true
focus: true
Component.onCompleted: root.forceActiveFocus()
Component.onCompleted: {
root.forceActiveFocus()
Qt.callLater(function () { QbzAbout.updatesLaunch() })
}
QbzTheme { id: theme }
UpdatesModal { kioskHost: true }
WhatsNewModal { }
/// The back bar's one button form: 44x36, radius-sm hover fill, a 20px
/// glyph. `available` dims the glyph and disarms the area (Back/Forward
+159
View File
@@ -0,0 +1,159 @@
import QtQuick
import com.blitzfc.qbz
import "../controls"
import "../theme"
FocusScope {
id: root
property bool kioskHost: false
anchors.fill: parent
z: 3000
readonly property var doc: JSON.parse(QbzAbout.updatesJson)
readonly property bool installing: doc.phase === "installing"
visible: doc.open === true
enabled: visible
QbzTheme { id: theme }
onVisibleChanged: if (visible) root.forceActiveFocus()
Keys.onEscapePressed: root.dismiss()
function dismiss() { if (!installing) QbzAbout.updatesClose() }
function statusText() {
switch (doc.phase) {
case "checking": return QbzSession.tr("Checking for updates...", QbzSession.trRev)
case "current": return QbzSession.tr("You are using the latest version of QBZ.", QbzSession.trRev)
case "available": return QbzSession.tr("A new version of QBZ is available.", QbzSession.trRev)
case "downloading": return QbzSession.tr("Downloading update...", QbzSession.trRev)
case "verifying": return QbzSession.tr("Verifying update signature...", QbzSession.trRev)
case "installing": return QbzSession.tr("Installing update...", QbzSession.trRev)
case "installed": return QbzSession.tr("Update installed. Close and reopen QBZ to use the new version.", QbzSession.trRev)
case "cancelled": return QbzSession.tr("Update cancelled.", QbzSession.trRev)
case "error": return QbzSession.tr("The update could not be completed. Please try again.", QbzSession.trRev)
default: return ""
}
}
Rectangle {
anchors.fill: parent
color: Qt.rgba(0, 0, 0, 0.75)
MouseArea {
anchors.fill: parent
onClicked: root.dismiss()
onWheel: function (wheel) { wheel.accepted = true }
}
}
Rectangle {
id: panel
anchors.centerIn: parent
width: Math.min(root.width - 40, 640)
height: Math.min(root.height - 40, content.implicitHeight + 40)
radius: theme.radiusMd
color: theme.surfaceCard
border.width: 1
border.color: theme.borderSubtle
MouseArea { anchors.fill: parent; onWheel: function (wheel) { wheel.accepted = true } }
Flickable {
anchors.fill: parent
anchors.margins: 20
contentHeight: content.implicitHeight
clip: true
Column {
id: content
width: parent.width
spacing: 16
Text {
width: parent.width
text: QbzSession.tr("Updates", QbzSession.trRev)
font.pixelSize: theme.fontTitle
font.weight: theme.weightBold
color: theme.textPrimary
}
Text {
width: parent.width
text: root.statusText()
wrapMode: Text.WordWrap
font.pixelSize: theme.fontBody
color: theme.textPrimary
}
Text {
width: parent.width
visible: (root.doc.version || "") !== ""
text: (root.doc.currentVersion || "") + " → " + (root.doc.version || "")
font.pixelSize: theme.fontBody
color: theme.textSecondary
}
Text {
width: parent.width
visible: root.doc.phase === "available" && !root.doc.canInstall
text: QbzSession.tr("Update QBZ through your installation source. Packages may become available after the GitHub release.", QbzSession.trRev)
wrapMode: Text.WordWrap
font.pixelSize: theme.fontBody
color: theme.textMuted
}
Text {
width: parent.width
visible: root.doc.phase === "downloading"
text: Math.floor((root.doc.downloaded || 0) / 1048576) + " MB"
+ (root.doc.total ? " / " + Math.ceil(root.doc.total / 1048576) + " MB" : "")
font.pixelSize: theme.fontBody
color: theme.textSecondary
}
Text {
width: parent.width
visible: root.doc.phase === "error"
text: root.doc.error || ""
textFormat: Text.PlainText
wrapMode: Text.WrapAnywhere
font.pixelSize: theme.fontBody
color: theme.textMuted
}
Flow {
width: parent.width
layoutDirection: Qt.RightToLeft
spacing: 10
SettingsButton {
kioskHost: root.kioskHost
minWidth: 0
btnHeight: 36
objectName: "updateInstallButton"
visible: root.doc.canInstall === true && !root.doc.busy
text: QbzSession.tr("Download and install", QbzSession.trRev)
onClicked: QbzAbout.updatesInstall()
}
SettingsButton {
kioskHost: root.kioskHost
minWidth: 0
btnHeight: 36
objectName: "updateReleaseButton"
visible: !!root.doc.version && !root.doc.busy && root.doc.phase !== "installed"
text: QbzSession.tr("Visit release page", QbzSession.trRev)
onClicked: QbzShell.openExternalUrl(root.doc.releaseUrl || "")
}
SettingsButton {
kioskHost: root.kioskHost
minWidth: 0
btnHeight: 36
visible: root.doc.phase === "downloading" || root.doc.phase === "verifying"
text: QbzSession.tr("Cancel download", QbzSession.trRev)
onClicked: QbzAbout.updatesCancel()
}
SettingsButton {
kioskHost: root.kioskHost
minWidth: 0
btnHeight: 36
objectName: "updateCloseButton"
enabled: !root.installing
text: QbzSession.tr("Close", QbzSession.trRev)
onClicked: root.dismiss()
}
SettingsButton {
kioskHost: root.kioskHost
minWidth: 0
btnHeight: 36
visible: root.doc.phase === "available"
text: QbzSession.tr("Skip this version", QbzSession.trRev)
onClicked: QbzAbout.updatesIgnore()
}
}
}
}
}
}
+52 -3
View File
@@ -1,7 +1,7 @@
//! QbzAbout — the About QBZ + What's New bridge.
//! QbzAbout — About QBZ, release notes and local application updates.
//!
//! Two documents and four invokables. The controllers are `about_qt.rs` and
//! `whats_new_qt.rs`; this file is glue and nothing else, exactly like
//! Controllers: `about_qt.rs`, `whats_new_qt.rs`, and `updates_qt.rs`.
//! This file is glue and nothing else, exactly like
//! `musician_bridge.rs`.
//!
//! # Why a NEW bridge instead of more members on QbzShell
@@ -65,6 +65,7 @@ pub mod qbz_about_bridge {
/// `hasBody`, the TOC and the flat block model. Produced by
/// `whats_new_qt.rs`. Same `"{}"` default, same reason.
#[qproperty(QString, whats_new_json)]
#[qproperty(QString, updates_json)]
type QbzAbout = super::QbzAboutRust;
/// Registers this object's Qt-thread hop. Harmless to call twice, and
@@ -73,6 +74,21 @@ pub mod qbz_about_bridge {
#[qinvokable]
fn boot(self: Pin<&mut QbzAbout>);
#[qinvokable]
fn updates_launch(self: Pin<&mut QbzAbout>);
#[qinvokable]
fn updates_check(self: Pin<&mut QbzAbout>);
#[qinvokable]
fn updates_close(self: Pin<&mut QbzAbout>);
#[qinvokable]
fn updates_ignore(self: Pin<&mut QbzAbout>);
#[qinvokable]
fn updates_install(self: Pin<&mut QbzAbout>);
#[qinvokable]
fn updates_cancel(self: Pin<&mut QbzAbout>);
#[qinvokable]
fn updates_set_launch(self: Pin<&mut QbzAbout>, enabled: bool);
/// The header menu's "About QBZ" row. Publishes the document open and
/// kicks the one-shot GitHub avatar fetch.
#[qinvokable]
@@ -103,6 +119,7 @@ use qbz_about_bridge::QbzAbout;
pub struct QbzAboutRust {
about_json: QString,
whats_new_json: QString,
updates_json: QString,
}
impl Default for QbzAboutRust {
@@ -112,6 +129,7 @@ impl Default for QbzAboutRust {
// empty string throws on the very first frame.
about_json: QString::from("{}"),
whats_new_json: QString::from("{}"),
updates_json: QString::from("{}"),
}
}
}
@@ -163,3 +181,34 @@ impl qbz_about_bridge::QbzAbout {
crate::whats_new_qt::close();
}
}
impl qbz_about_bridge::QbzAbout {
pub fn updates_launch(self: Pin<&mut Self>) {
self.register_thread();
crate::updates_qt::launch();
}
pub fn updates_check(self: Pin<&mut Self>) {
self.register_thread();
crate::updates_qt::check();
}
pub fn updates_close(self: Pin<&mut Self>) {
self.register_thread();
crate::updates_qt::close();
}
pub fn updates_ignore(self: Pin<&mut Self>) {
self.register_thread();
crate::updates_qt::ignore();
}
pub fn updates_install(self: Pin<&mut Self>) {
self.register_thread();
crate::updates_qt::install();
}
pub fn updates_cancel(self: Pin<&mut Self>) {
self.register_thread();
crate::updates_qt::cancel();
}
pub fn updates_set_launch(self: Pin<&mut Self>, enabled: bool) {
self.register_thread();
crate::updates_qt::set_launch(enabled);
}
}
+1
View File
@@ -196,6 +196,7 @@ mod log_viewer_qt;
// other two are plain controller modules and must NOT be.
mod about_bridge;
mod about_qt;
mod updates_qt;
mod library_bulk;
mod library_db_qt;
mod library_prefs;
+262
View File
@@ -0,0 +1,262 @@
//! Local application updates. Orbit's selected host never owns this state.
use cxx_qt_lib::QString;
use qbz_updater::{install::Installation, store::Store, Asset, Release};
use serde::Serialize;
use std::sync::{
atomic::{AtomicBool, Ordering},
LazyLock, Mutex,
};
static STARTED: AtomicBool = AtomicBool::new(false);
static CANCEL: AtomicBool = AtomicBool::new(false);
static STATE: LazyLock<Mutex<State>> = LazyLock::new(|| Mutex::new(State::default()));
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct State {
open: bool,
phase: String,
busy: bool,
check_on_launch: bool,
current_version: &'static str,
version: String,
release_url: String,
install_method: &'static str,
can_install: bool,
downloaded: u64,
total: Option<u64>,
error: String,
#[serde(skip)]
manual: bool,
#[serde(skip)]
release: Option<Release>,
#[serde(skip)]
asset: Option<Asset>,
}
impl Default for State {
fn default() -> Self {
Self {
open: false,
phase: "idle".into(),
busy: false,
check_on_launch: true,
current_version: crate::about_qt::app_version(),
version: String::new(),
release_url: qbz_updater::RELEASE_PAGE.into(),
install_method: Installation::detect().label(),
can_install: false,
downloaded: 0,
total: None,
error: String::new(),
manual: false,
release: None,
asset: None,
}
}
}
fn update(f: impl FnOnce(&mut State)) {
let mut state = STATE.lock().unwrap_or_else(|e| e.into_inner());
f(&mut state);
let json = serde_json::to_string(&*state).unwrap_or_else(|_| "{}".into());
// Enqueue while holding the state lock, so publishes preserve mutation order.
crate::about_bridge::ui(move |mut b| b.as_mut().set_updates_json(QString::from(&json)));
}
fn store() -> Result<Store, String> {
let path = dirs::data_dir()
.ok_or("No application data directory")?
.join("qbz/updates.db");
Store::open(&path)
}
fn failed(error: String) {
log::warn!("[updates] {error}");
update(|s| {
s.busy = false;
s.phase = "error".into();
s.error = error;
});
}
fn preference_failed(error: String) {
log::warn!("[updates] {error}");
update(|s| {
// A settings write must never unlock an active installer/check.
if !s.busy {
s.phase = "error".into();
s.error = error;
s.open = true;
}
});
}
/// Called after the shell is mounted, once per process. No polling timer.
pub fn launch() {
if STARTED.swap(true, Ordering::SeqCst) {
return;
}
crate::spawn(async {
let pref = tokio::task::spawn_blocking(|| store()?.check_on_launch()).await;
match pref {
Ok(Ok(enabled)) => {
update(|s| s.check_on_launch = enabled);
if enabled {
begin_check(false);
}
}
result => log::warn!("[updates] startup preference unavailable: {result:?}"),
}
});
}
pub fn check() {
begin_check(true);
}
fn begin_check(manual: bool) {
let mut start = false;
update(|s| {
if manual {
s.open = true;
s.manual = true;
}
if !s.busy && s.phase != "installed" {
s.busy = true;
s.phase = "checking".into();
s.error.clear();
s.manual = manual;
s.asset = None;
s.release = None;
s.can_install = false;
s.version.clear();
start = true;
}
});
if !start {
return;
}
crate::spawn(async {
// Fetch without suppression first: a manual request can join a launch
// request while it is in flight and must still see the actual latest.
match qbz_updater::check(crate::about_qt::app_version(), false).await {
Err(error) => failed(error),
Ok(None) => update(|s| {
s.busy = false;
s.phase = "current".into();
}),
Ok(Some(release)) => {
let version = match release.version() {
Ok(v) => v.to_string(),
Err(e) => {
failed(e);
return;
}
};
let suppression_version = version.clone();
let suppressed =
tokio::task::spawn_blocking(move || store()?.suppressed(&suppression_version))
.await;
let suppressed = !matches!(suppressed, Ok(Ok(false)));
let old_enough = release.notification_ready();
let asset = if let Some(platform) = Installation::detect().platform() {
match qbz_updater::asset(&release, &platform).await {
Ok(asset) => Some(asset),
Err(e) => {
log::info!("[updates] signed installer not available yet: {e}");
None
}
}
} else {
None
};
update(|s| {
s.busy = false;
s.phase = "available".into();
s.version = version;
s.release_url = release.page();
s.release = Some(release);
s.can_install = asset.is_some();
s.asset = asset;
if !s.manual && s.check_on_launch && old_enough && !suppressed {
s.open = true;
}
});
}
}
});
}
pub fn close() {
update(|s| s.open = false);
}
pub fn set_launch(enabled: bool) {
crate::spawn(async move {
match tokio::task::spawn_blocking(move || store()?.set_check_on_launch(enabled)).await {
Ok(Ok(())) => update(|s| s.check_on_launch = enabled),
error => preference_failed(format!("Could not save update preferences: {error:?}")),
}
});
}
pub fn ignore() {
let version = STATE
.lock()
.unwrap_or_else(|e| e.into_inner())
.version
.clone();
if version.is_empty() {
return;
}
crate::spawn(async move {
match tokio::task::spawn_blocking(move || store()?.ignore(&version)).await {
Ok(Ok(())) => close(),
error => preference_failed(format!("Could not save release reminder: {error:?}")),
}
});
}
pub fn cancel() {
CANCEL.store(true, Ordering::Relaxed);
}
pub fn install() {
let mut asset = None;
update(|s| {
if !s.busy && s.can_install && s.phase != "installed" {
asset = s.asset.clone();
if asset.is_some() {
s.busy = true;
s.open = true;
s.phase = "downloading".into();
s.downloaded = 0;
s.total = None;
s.error.clear();
CANCEL.store(false, Ordering::Relaxed);
}
}
});
let Some(asset) = asset else { return };
crate::spawn(async move {
let result = qbz_updater::install::install(
Installation::detect(),
asset,
&CANCEL,
|phase, downloaded, total| {
update(|s| {
s.phase = phase.into();
s.downloaded = downloaded;
s.total = total;
})
},
)
.await;
match result {
Ok(backup) => {
log::info!("[updates] installed; rollback copy: {}", backup.display());
update(|s| {
s.phase = "installed".into();
s.busy = false;
s.can_install = false;
});
}
Err(_) if CANCEL.load(Ordering::Relaxed) => update(|s| {
s.phase = "cancelled".into();
s.busy = false;
}),
Err(error) => failed(error),
}
});
}
+20
View File
@@ -0,0 +1,20 @@
[package]
name = "qbz-updater"
version.workspace = true
edition.workspace = true
license.workspace = true
repository.workspace = true
[dependencies]
base64.workspace = true
chrono = { workspace = true, features = ["serde"] }
reqwest.workspace = true
serde.workspace = true
serde_json.workspace = true
tokio.workspace = true
semver = "1"
minisign-verify = "0.2.5"
rusqlite = { version = "0.40", features = ["bundled"] }
tempfile = "3"
flate2 = "1"
tar = "0.4"
+18
View File
@@ -0,0 +1,18 @@
//! Verify a downloaded release without installing or launching it.
fn main() {
let args: Vec<_> = std::env::args_os().skip(1).collect();
if args.len() != 2 {
eprintln!("usage: verify ASSET SIGNATURE_FILE");
std::process::exit(2);
}
let result = std::fs::read_to_string(&args[1])
.map_err(|e| e.to_string())
.and_then(|sig| qbz_updater::install::verify(std::path::Path::new(&args[0]), &sig));
match result {
Ok(()) => println!("QBZ release signature verified"),
Err(error) => {
eprintln!("{error}");
std::process::exit(1);
}
}
}
+523
View File
@@ -0,0 +1,523 @@
use crate::{Asset, Result};
use base64::Engine;
use std::{
io::Read,
path::{Path, PathBuf},
sync::atomic::{AtomicBool, Ordering},
time::{Duration, Instant},
};
// Public verification key from the shipping Tauri updater. Not an API secret.
const PUBLIC_KEY: &str = "RWSGlbvrbc5/P3/4zoXZfBd+pee5Kw7h5/gUIE7B9GiL47dvUSNpNGLm";
const MAX_DOWNLOAD: u64 = 2 * 1024 * 1024 * 1024;
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Installation {
AppImage(PathBuf),
MacBundle(PathBuf),
Flatpak,
Snap,
Nix,
Managed,
}
impl Installation {
pub fn detect() -> Self {
if std::env::var_os("FLATPAK_ID").is_some() || Path::new("/.flatpak-info").exists() {
return Self::Flatpak;
}
if std::env::var_os("SNAP").is_some() {
return Self::Snap;
}
let exe = std::env::current_exe().unwrap_or_default();
if exe.starts_with("/nix/store") {
return Self::Nix;
}
if cfg!(target_os = "linux") {
if let Some(path) = std::env::var_os("APPIMAGE").map(PathBuf::from) {
// The runtime supplies the original image, outside its mount.
if path.is_absolute() && path.is_file() {
return Self::AppImage(path);
}
}
}
if cfg!(target_os = "macos") {
if let Some(bundle) = exe
.ancestors()
.find(|p| p.extension().is_some_and(|x| x == "app"))
{
// Homebrew owns bundles in its cellar; do not replace them.
if !bundle.starts_with("/Volumes")
&& !bundle.to_string_lossy().contains("/Caskroom/")
&& mac_bundle_uses_upstream_signature(bundle)
{
return Self::MacBundle(bundle.into());
}
}
}
Self::Managed
}
pub fn label(&self) -> &'static str {
match self {
Self::AppImage(_) => "AppImage",
Self::MacBundle(_) => "macOS",
Self::Flatpak => "Flatpak",
Self::Snap => "Snap",
Self::Nix => "Nix",
Self::Managed => "System / manual",
}
}
pub fn platform(&self) -> Option<String> {
if !matches!(std::env::consts::ARCH, "x86_64" | "aarch64") {
return None;
}
let os = match self {
Self::AppImage(_) => "linux",
Self::MacBundle(_) => "darwin",
_ => return None,
};
Some(format!("{os}-{}", std::env::consts::ARCH))
}
fn destination(&self) -> Result<&Path> {
match self {
Self::AppImage(p) | Self::MacBundle(p) => Ok(p),
_ => Err("This installation is managed outside QBZ".into()),
}
}
}
fn mac_bundle_uses_upstream_signature(bundle: &Path) -> bool {
#[cfg(target_os = "macos")]
{
// Community DMGs/Homebrew use a Developer ID and notarization. The
// upstream manifest contains an ad-hoc bundle: never downgrade that
// trust channel merely because both bundles are named QBZ.app.
let Ok(output) = std::process::Command::new("/usr/bin/codesign")
.args(["--display", "--verbose=2"])
.arg(bundle)
.output()
else {
return false;
};
output.status.success()
&& upstream_signature_description(&String::from_utf8_lossy(&output.stderr))
}
#[cfg(not(target_os = "macos"))]
{
let _ = bundle;
false
}
}
#[cfg(any(target_os = "macos", test))]
fn upstream_signature_description(description: &str) -> bool {
description.lines().any(|l| l.trim() == "Signature=adhoc")
&& !description
.lines()
.any(|l| l.starts_with("TeamIdentifier=") && l != "TeamIdentifier=not set")
}
fn cancelled(cancel: &AtomicBool) -> Result<()> {
if cancel.load(Ordering::Relaxed) {
Err("Update cancelled".into())
} else {
Ok(())
}
}
fn signature(encoded: &str) -> Result<minisign_verify::Signature> {
let bytes = base64::engine::general_purpose::STANDARD
.decode(encoded.trim())
.map_err(|e| e.to_string())?;
let text = std::str::from_utf8(&bytes).map_err(|e| e.to_string())?;
minisign_verify::Signature::decode(text).map_err(|e| e.to_string())
}
pub fn verify(file: &Path, encoded: &str) -> Result<()> {
verify_with_key(file, encoded, PUBLIC_KEY)
}
fn verify_with_key(file: &Path, encoded: &str, public_key: &str) -> Result<()> {
let key = minisign_verify::PublicKey::from_base64(public_key).map_err(|e| e.to_string())?;
let sig = signature(encoded)?;
let mut verifier = key.verify_stream(&sig).map_err(|e| e.to_string())?;
let mut input = std::fs::File::open(file).map_err(|e| e.to_string())?;
let mut buffer = [0u8; 65536];
loop {
let n = input.read(&mut buffer).map_err(|e| e.to_string())?;
if n == 0 {
break;
}
verifier.update(&buffer[..n]);
}
verifier
.finalize()
.map_err(|e| format!("Update signature verification failed: {e}"))
}
/// Downloads onto the installation filesystem, verifies before touching the
/// live application, and keeps the old application as a rollback copy.
/// The caller serializes installs and disables exit during final replacement.
pub async fn install(
installation: Installation,
asset: Asset,
cancel: &AtomicBool,
progress: impl Fn(&str, u64, Option<u64>) + Send + Sync,
) -> Result<PathBuf> {
let destination = installation.destination()?.to_path_buf();
let parent = destination
.parent()
.ok_or("Installation has no parent directory")?;
// Advisory OS lock survives an async hop and is released after a crash.
// Keep the lock file: removing it would let another process lock a new inode.
let install_lock = std::fs::OpenOptions::new()
.create(true)
.truncate(false)
.write(true)
.open(parent.join(".qbz-updater.lock"))
.map_err(|e| e.to_string())?;
install_lock
.try_lock()
.map_err(|e| format!("Another update is already running: {e}"))?;
// Do not follow a launcher symlink and unexpectedly replace its target.
let original = std::fs::symlink_metadata(&destination).map_err(|e| e.to_string())?;
if original.file_type().is_symlink() {
return Err(
"This installation is a symlink; update it using its installation manager".into(),
);
}
let stage = tempfile::Builder::new()
.prefix(".qbz-update-")
.tempdir_in(parent)
.map_err(|e| format!("Cannot write to the installation directory: {e}"))?;
let payload = stage.path().join("download");
let mut output = tokio::fs::File::create(&payload)
.await
.map_err(|e| e.to_string())?;
cancelled(cancel)?;
let mut response = crate::client(Duration::from_secs(30 * 60))?
.get(&asset.url)
.send()
.await
.and_then(reqwest::Response::error_for_status)
.map_err(|e| e.to_string())?;
let total = response.content_length();
if total.is_some_and(|v| v > MAX_DOWNLOAD) {
return Err("Update exceeds size limit".into());
}
let mut downloaded = 0;
let mut last = Instant::now();
progress("downloading", 0, total);
loop {
cancelled(cancel)?;
// Bounded inactivity, separate from the full download deadline.
let chunk = tokio::time::timeout(Duration::from_secs(30), response.chunk())
.await
.map_err(|_| "Update download timed out")?
.map_err(|e| e.to_string())?;
let Some(chunk) = chunk else { break };
downloaded += chunk.len() as u64;
if downloaded > MAX_DOWNLOAD {
return Err("Update exceeds size limit".into());
}
tokio::io::AsyncWriteExt::write_all(&mut output, &chunk)
.await
.map_err(|e| e.to_string())?;
if last.elapsed() >= Duration::from_millis(200) {
progress("downloading", downloaded, total);
last = Instant::now();
}
}
output.sync_all().await.map_err(|e| e.to_string())?;
drop(output);
cancelled(cancel)?;
progress("verifying", downloaded, total);
let signature = asset.signature;
let verify_path = payload.clone();
tokio::task::spawn_blocking(move || verify(&verify_path, &signature))
.await
.map_err(|e| e.to_string())??;
cancelled(cancel)?;
progress("installing", downloaded, total);
tokio::task::spawn_blocking(move || {
let _lock = install_lock;
let current = std::fs::symlink_metadata(&destination).map_err(|e| e.to_string())?;
if current.file_type().is_symlink()
|| current.len() != original.len()
|| current.modified().ok() != original.modified().ok()
{
return Err(
"The installation changed while downloading; check for updates again".into(),
);
}
apply(&installation, stage, &payload)
})
.await
.map_err(|e| e.to_string())?
}
fn apply(installation: &Installation, stage: tempfile::TempDir, payload: &Path) -> Result<PathBuf> {
let destination = installation.destination()?;
let backup = stage.path().join("previous");
match installation {
Installation::AppImage(_) => {
// Signatures authenticate bytes; format validation prevents a
// mismatched official asset from replacing a working installation.
let mut header = [0u8; 11];
std::fs::File::open(payload)
.and_then(|mut f| f.read_exact(&mut header))
.map_err(|e| e.to_string())?;
if &header[..4] != b"\x7fELF" || &header[8..11] != b"AI\x02" {
return Err("The signed file is not an AppImage".into());
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(payload, std::fs::Permissions::from_mode(0o755))
.map_err(|e| e.to_string())?;
}
// Hard-link backup keeps the live path present until atomic rename.
std::fs::hard_link(destination, &backup).map_err(|e| e.to_string())?;
std::fs::rename(payload, destination).map_err(|e| e.to_string())?;
}
Installation::MacBundle(_) => {
#[cfg(target_os = "macos")]
if !mac_bundle_uses_upstream_signature(destination) {
return Err(
"This macOS app must be updated through its original signing channel".into(),
);
}
let unpacked = stage.path().join("unpacked");
unpack_bundle(payload, &unpacked)?;
let bundle = unpacked.join("QBZ.app");
#[cfg(target_os = "macos")]
{
let status = std::process::Command::new("/usr/bin/codesign")
.args(["--verify", "--deep", "--strict"])
.arg(&bundle)
.status()
.map_err(|e| e.to_string())?;
if !status.success() {
return Err("The updated application failed code-signature validation".into());
}
}
std::fs::rename(destination, &backup).map_err(|e| e.to_string())?;
if let Err(error) = std::fs::rename(&bundle, destination) {
if let Err(rollback) = std::fs::rename(&backup, destination) {
let retained = stage.keep();
return Err(format!("Install failed: {error}; restore failed: {rollback}. Original retained at {}", retained.join("previous").display()));
}
return Err(error.to_string());
}
}
_ => return Err("This installation is managed outside QBZ".into()),
}
// Retain ONE old application per explicit update; never delete files a
// still-running process may need. No cleanup of arbitrary sibling folders.
let retained = stage.keep();
Ok(retained.join("previous"))
}
fn unpack_bundle(payload: &Path, destination: &Path) -> Result<()> {
std::fs::create_dir_all(destination).map_err(|e| e.to_string())?;
let file = std::fs::File::open(payload).map_err(|e| e.to_string())?;
let mut archive = tar::Archive::new(flate2::read::GzDecoder::new(file));
let mut size = 0u64;
for entry in archive.entries().map_err(|e| e.to_string())? {
let mut entry = entry.map_err(|e| e.to_string())?;
let path = entry.path().map_err(|e| e.to_string())?.into_owned();
if !path.starts_with("QBZ.app")
|| path.components().any(|c| {
matches!(
c,
std::path::Component::ParentDir
| std::path::Component::RootDir
| std::path::Component::Prefix(_)
)
})
{
return Err("Update archive contains an invalid path".into());
}
if let Some(target) = entry.link_name().map_err(|e| e.to_string())? {
// Framework bundles contain relative symlinks. Permit those only
// while their lexical target remains inside the staged QBZ.app.
let base = if entry.header().entry_type().is_hard_link() {
Path::new("")
} else {
path.parent().unwrap_or(Path::new(""))
};
let mut resolved = PathBuf::new();
for component in base.join(target.as_ref()).components() {
match component {
std::path::Component::Normal(name) => resolved.push(name),
std::path::Component::CurDir => {}
std::path::Component::ParentDir => {
if !resolved.pop() {
return Err("Update archive link escapes its bundle".into());
}
}
_ => return Err("Update archive contains an absolute link".into()),
}
}
if !resolved.starts_with("QBZ.app") {
return Err("Update archive link escapes its bundle".into());
}
}
size = size
.checked_add(entry.size())
.ok_or("Update archive size overflow")?;
if size > 4 * MAX_DOWNLOAD {
return Err("Update archive exceeds size limit".into());
}
if !entry.unpack_in(destination).map_err(|e| e.to_string())? {
return Err("Update archive escaped its staging directory".into());
}
}
if !destination.join("QBZ.app/Contents/MacOS/qbz").is_file()
|| !destination.join("QBZ.app/Contents/Info.plist").is_file()
{
return Err("Update archive does not contain a QBZ application".into());
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn notarized_macos_channel_is_never_replaced_with_an_ad_hoc_bundle() {
assert!(upstream_signature_description(
"Signature=adhoc\nTeamIdentifier=not set\n"
));
assert!(!upstream_signature_description(
"Authority=Developer ID Application: Publisher\nTeamIdentifier=ABC123\n"
));
assert!(!upstream_signature_description(
"Signature=adhoc\nTeamIdentifier=ABC123\n"
));
assert!(!upstream_signature_description(""));
}
#[test]
fn public_key_and_bad_signature_fail_closed() {
assert!(minisign_verify::PublicKey::from_base64(PUBLIC_KEY).is_ok());
let f = tempfile::NamedTempFile::new().unwrap();
assert!(verify(f.path(), "not a signature").is_err());
}
#[test]
fn tauri_envelope_verifies_bytes_and_rejects_tampering_or_wrong_key() {
// Public test vector from minisign-verify (MIT); same prehashed
// signature + outer base64 envelope used by the QBZ release signer.
let key = "RWQf6LRCGA9i53mlYecO4IzT51TGPpvWucNSCh1CBM0QTaLn73Y7GFO3";
let text = "untrusted comment: signature from minisign secret key\nRUQf6LRCGA9i559r3g7V1qNyJDApGip8MfqcadIgT9CuhV3EMhHoN1mGTkUidF/z7SrlQgXdy8ofjb7bNJJylDOocrCo8KLzZwo=\ntrusted comment: timestamp:1556193335\tfile:test\ny/rUw2y8/hOUYjZU71eHp/Wo1KZ40fGy2VJEDl34XMJM+TX48Ss/17u3IvIfbVR1FkZZSNCisQbuQY+bHwhEBg==";
let signature = base64::engine::general_purpose::STANDARD.encode(text);
let f = tempfile::NamedTempFile::new().unwrap();
std::fs::write(f.path(), b"test").unwrap();
assert!(verify_with_key(f.path(), &signature, key).is_ok());
assert!(verify(f.path(), &signature).is_err());
std::fs::write(f.path(), b"tampered").unwrap();
assert!(verify_with_key(f.path(), &signature, key).is_err());
}
#[test]
fn mac_archive_rejects_symlink_outside_bundle() {
let dir = tempfile::tempdir().unwrap();
let payload = dir.path().join("update.tar.gz");
let gz = flate2::write::GzEncoder::new(
std::fs::File::create(&payload).unwrap(),
flate2::Compression::fast(),
);
let mut tar = tar::Builder::new(gz);
let mut h = tar::Header::new_gnu();
h.set_entry_type(tar::EntryType::Symlink);
h.set_size(0);
h.set_mode(0o777);
tar.append_link(&mut h, "QBZ.app/escape", "../../outside")
.unwrap();
tar.into_inner().unwrap().finish().unwrap();
assert!(unpack_bundle(&payload, &dir.path().join("unpacked")).is_err());
assert!(!dir.path().join("unpacked/QBZ.app/escape").exists());
}
#[cfg(not(target_os = "macos"))]
#[test]
fn mac_bundle_layout_replaces_whole_bundle_and_retains_previous_files() {
// Exercise archive/filesystem behavior independently of macOS's native
// codesign gate; that gate still requires a real macOS smoke test.
let dir = tempfile::tempdir().unwrap();
let destination = dir.path().join("QBZ.app");
std::fs::create_dir_all(destination.join("Contents/MacOS")).unwrap();
std::fs::write(destination.join("Contents/MacOS/qbz"), b"old executable").unwrap();
std::fs::write(destination.join("old-only"), b"old resource").unwrap();
let stage = tempfile::tempdir_in(dir.path()).unwrap();
let payload = stage.path().join("download");
let gz = flate2::write::GzEncoder::new(
std::fs::File::create(&payload).unwrap(),
flate2::Compression::fast(),
);
let mut tar = tar::Builder::new(gz);
for (path, bytes) in [
("QBZ.app/Contents/MacOS/qbz", b"new executable".as_slice()),
("QBZ.app/Contents/Info.plist", b"bundle metadata".as_slice()),
] {
let mut header = tar::Header::new_gnu();
header.set_size(bytes.len() as u64);
header.set_mode(0o755);
header.set_cksum();
tar.append_data(&mut header, path, bytes).unwrap();
}
tar.into_inner().unwrap().finish().unwrap();
let backup = apply(
&Installation::MacBundle(destination.clone()),
stage,
&payload,
)
.unwrap();
assert_eq!(
std::fs::read(destination.join("Contents/MacOS/qbz")).unwrap(),
b"new executable"
);
assert!(!destination.join("old-only").exists());
assert_eq!(
std::fs::read(backup.join("Contents/MacOS/qbz")).unwrap(),
b"old executable"
);
assert_eq!(
std::fs::read(backup.join("old-only")).unwrap(),
b"old resource"
);
}
#[test]
fn package_managers_never_offer_binary_replacement() {
for install in [
Installation::Flatpak,
Installation::Snap,
Installation::Nix,
Installation::Managed,
] {
assert!(install.platform().is_none());
assert!(install.destination().is_err());
}
}
#[test]
fn invalid_payload_leaves_installation_untouched() {
let d = tempfile::tempdir().unwrap();
let dest = d.path().join("QBZ.AppImage");
std::fs::write(&dest, b"old application").unwrap();
let stage = tempfile::tempdir_in(d.path()).unwrap();
let payload = stage.path().join("download");
std::fs::write(&payload, b"wrong payload bytes").unwrap();
assert!(apply(&Installation::AppImage(dest.clone()), stage, &payload).is_err());
assert_eq!(std::fs::read(dest).unwrap(), b"old application");
}
#[test]
fn replacement_retains_previous_application() {
let d = tempfile::tempdir().unwrap();
let dest = d.path().join("QBZ.AppImage");
std::fs::write(&dest, b"old application").unwrap();
let stage = tempfile::tempdir_in(d.path()).unwrap();
let payload = stage.path().join("download");
let image = b"\x7fELF\0\0\0\0AI\x02new application";
std::fs::write(&payload, image).unwrap();
let backup = apply(&Installation::AppImage(dest.clone()), stage, &payload).unwrap();
assert_eq!(std::fs::read(dest).unwrap(), image);
assert_eq!(std::fs::read(backup).unwrap(), b"old application");
}
}
+240
View File
@@ -0,0 +1,240 @@
//! Release checks and signed updates, independent of the desktop and audio.
//! Package-manager installations only check: their manager owns installation.
pub mod install;
pub mod store;
use chrono::{DateTime, Utc};
use semver::Version;
use serde::Deserialize;
use std::{collections::HashMap, time::Duration};
pub type Result<T> = std::result::Result<T, String>;
const RELEASES: &str = "https://api.github.com/repos/vicrodh/qbz/releases?per_page=30";
pub const RELEASE_PAGE: &str = "https://github.com/vicrodh/qbz/releases";
const MAX_METADATA: usize = 2 * 1024 * 1024;
#[derive(Clone, Debug, Deserialize)]
pub struct Release {
pub tag_name: String,
pub published_at: Option<DateTime<Utc>>,
pub draft: bool,
pub prerelease: bool,
}
impl Release {
pub fn version(&self) -> Result<Version> {
Version::parse(self.tag_name.strip_prefix('v').unwrap_or(&self.tag_name))
.map_err(|e| e.to_string())
}
pub fn page(&self) -> String {
format!("{RELEASE_PAGE}/tag/{}", self.tag_name)
}
/// Release-age policy stays in the shared updater, not in its UI adapter.
pub fn notification_ready(&self) -> bool {
self.published_at
.is_some_and(|date| Utc::now().signed_duration_since(date).num_hours() >= 12)
}
}
/// Manual checks bypass notification suppression and the automatic 12-hour delay.
pub fn select_release(
releases: Vec<Release>,
current: &str,
automatic: bool,
now: DateTime<Utc>,
) -> Result<Option<Release>> {
let current = Version::parse(current).map_err(|e| e.to_string())?;
Ok(releases
.into_iter()
.filter(|r| {
let Ok(v) = r.version() else { return false };
!r.draft
&& !r.prerelease
&& v.pre.is_empty()
&& v.cmp_precedence(&current).is_gt()
&& r.published_at.is_some_and(|date| {
let age = now.signed_duration_since(date).num_seconds();
age >= if automatic { 12 * 3600 } else { 0 }
})
})
.max_by(|a, b| a.version().unwrap().cmp_precedence(&b.version().unwrap())))
}
pub fn client(timeout: Duration) -> Result<reqwest::Client> {
reqwest::Client::builder()
.user_agent(concat!("QBZ/", env!("CARGO_PKG_VERSION")))
.https_only(true)
.connect_timeout(Duration::from_secs(4))
.timeout(timeout)
.build()
.map_err(|e| e.to_string())
}
pub async fn metadata(url: &str) -> Result<Vec<u8>> {
let mut response = client(Duration::from_secs(4))?
.get(url)
.send()
.await
.and_then(reqwest::Response::error_for_status)
.map_err(|e| e.to_string())?;
let mut bytes = Vec::new();
while let Some(chunk) = response.chunk().await.map_err(|e| e.to_string())? {
if bytes.len() + chunk.len() > MAX_METADATA {
return Err("Update metadata exceeds size limit".into());
}
bytes.extend_from_slice(&chunk);
}
Ok(bytes)
}
pub async fn check(current: &str, automatic: bool) -> Result<Option<Release>> {
let releases = serde_json::from_slice(&metadata(RELEASES).await?).map_err(|e| e.to_string())?;
select_release(releases, current, automatic, Utc::now())
}
#[derive(Clone, Debug, Deserialize)]
pub struct Asset {
pub url: String,
pub signature: String,
}
#[derive(Deserialize)]
struct Manifest {
version: String,
platforms: HashMap<String, Asset>,
}
pub fn select_asset(bytes: &[u8], release: &Release, platform: &str) -> Result<Asset> {
let manifest: Manifest = serde_json::from_slice(bytes).map_err(|e| e.to_string())?;
if Version::parse(&manifest.version).map_err(|e| e.to_string())? != release.version()? {
return Err("Update manifest does not match the selected release".into());
}
let asset = manifest
.platforms
.get(platform)
.ok_or("No signed update for this platform")?;
let prefix = format!(
"https://github.com/vicrodh/qbz/releases/download/{}/",
release.tag_name
);
let name = asset
.url
.strip_prefix(&prefix)
.ok_or("Update asset is outside the selected QBZ release")?;
let expected = match platform {
"linux-x86_64" => format!("QBZ_{}_amd64.AppImage", release.version()?),
"linux-aarch64" => format!("QBZ_{}_aarch64.AppImage", release.version()?),
"darwin-x86_64" => "QBZ_x64.app.tar.gz".into(),
"darwin-aarch64" => "QBZ_aarch64.app.tar.gz".into(),
_ => return Err("No signed update for this platform".into()),
};
if name != expected || asset.signature.is_empty() {
return Err("Invalid signed update asset".into());
}
Ok(asset.clone())
}
pub async fn asset(release: &Release, platform: &str) -> Result<Asset> {
// Bind to the selected tag, never the mutable /latest alias.
let url = format!(
"https://github.com/vicrodh/qbz/releases/download/{}/latest.json",
release.tag_name
);
select_asset(&metadata(&url).await?, release, platform)
}
#[cfg(test)]
mod tests {
use super::*;
fn release(v: &str, age: i64) -> Release {
Release {
tag_name: format!("v{v}"),
published_at: Some(Utc::now() - chrono::Duration::hours(age)),
draft: false,
prerelease: false,
}
}
#[test]
fn versions_order_semantically_and_never_downgrade() {
let r = select_release(
vec![release("2.9.0", 24), release("2.10.0", 24)],
"2.1.1",
true,
Utc::now(),
)
.unwrap()
.unwrap();
assert_eq!(r.tag_name, "v2.10.0");
assert!(select_release(
vec![release("2.1.1+other", 24), release("2.0.0", 24)],
"2.1.1",
false,
Utc::now()
)
.unwrap()
.is_none());
}
#[test]
fn automatic_waits_but_manual_finds_new_release() {
assert!(
select_release(vec![release("2.1.2", 1)], "2.1.1", true, Utc::now())
.unwrap()
.is_none()
);
assert!(
select_release(vec![release("2.1.2", 1)], "2.1.1", false, Utc::now())
.unwrap()
.is_some()
);
}
#[test]
fn rejects_drafts_prereleases_invalid_and_future_versions() {
let mut draft = release("3.0.0", 24);
draft.draft = true;
let mut pre = release("3.0.0", 24);
pre.prerelease = true;
assert!(select_release(
vec![
draft,
pre,
release("3.0.0-rc.1", 24),
release("bad", 24),
release("3.0.0", -1)
],
"2.1.1",
false,
Utc::now()
)
.unwrap()
.is_none());
}
#[test]
fn manifest_cannot_switch_version_platform_or_download_origin() {
let r = release("2.1.2", 24);
let valid = serde_json::json!({"version":"2.1.2","platforms":{"linux-x86_64":{"signature":"sig","url":"https://github.com/vicrodh/qbz/releases/download/v2.1.2/QBZ_2.1.2_amd64.AppImage"}}});
assert!(select_asset(&serde_json::to_vec(&valid).unwrap(), &r, "linux-x86_64").is_ok());
assert!(select_asset(&serde_json::to_vec(&valid).unwrap(), &r, "darwin-aarch64").is_err());
for (field, value) in [
("version", "2.1.3"),
("url", "https://evil.example/update.AppImage"),
(
"url",
"https://github.com/vicrodh/qbz/releases/download/v2.1.2/../bad.AppImage",
),
(
"url",
"https://github.com/vicrodh/qbz/releases/download/v2.1.2/QBZ_2.1.2_aarch64.AppImage",
),
] {
let mut invalid = valid.clone();
if field == "version" {
invalid[field] = value.into();
} else {
invalid["platforms"]["linux-x86_64"][field] = value.into();
}
assert!(
select_asset(&serde_json::to_vec(&invalid).unwrap(), &r, "linux-x86_64").is_err()
);
}
}
}
+72
View File
@@ -0,0 +1,72 @@
//! Reuses the Tauri-era updates.db and preserves existing opt-outs.
use crate::Result;
use rusqlite::Connection;
use std::path::Path;
pub struct Store(Connection);
impl Store {
pub fn open(path: &Path) -> Result<Self> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
}
let conn = Connection::open(path).map_err(|e| e.to_string())?;
conn.busy_timeout(std::time::Duration::from_secs(2))
.map_err(|e| e.to_string())?;
conn.execute_batch("PRAGMA journal_mode=WAL;
CREATE TABLE IF NOT EXISTS update_preferences (id INTEGER PRIMARY KEY CHECK(id=1), check_on_launch INTEGER NOT NULL DEFAULT 1, show_whats_new_on_launch INTEGER NOT NULL DEFAULT 1);
INSERT OR IGNORE INTO update_preferences (id,check_on_launch,show_whats_new_on_launch) VALUES (1,1,1);
CREATE TABLE IF NOT EXISTS ignored_releases (version TEXT PRIMARY KEY, ignored_at INTEGER NOT NULL);
CREATE TABLE IF NOT EXISTS acknowledged_releases (version TEXT PRIMARY KEY, acknowledged_at INTEGER NOT NULL);")
.map_err(|e| e.to_string())?;
Ok(Self(conn))
}
pub fn check_on_launch(&self) -> Result<bool> {
self.0
.query_row(
"SELECT check_on_launch FROM update_preferences WHERE id=1",
[],
|r| r.get(0),
)
.map_err(|e| e.to_string())
}
pub fn set_check_on_launch(&self, enabled: bool) -> Result<()> {
self.0
.execute(
"UPDATE update_preferences SET check_on_launch=? WHERE id=1",
[enabled],
)
.map_err(|e| e.to_string())?;
Ok(())
}
pub fn suppressed(&self, version: &str) -> Result<bool> {
self.0.query_row("SELECT EXISTS(SELECT 1 FROM ignored_releases WHERE version=?1 UNION ALL SELECT 1 FROM acknowledged_releases WHERE version=?1)", [version], |r| r.get(0)).map_err(|e| e.to_string())
}
pub fn ignore(&self, version: &str) -> Result<()> {
self.0
.execute(
"INSERT OR REPLACE INTO ignored_releases VALUES (?1,unixepoch())",
[version],
)
.map_err(|e| e.to_string())?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn preserves_existing_opt_out_and_ignores_across_reopen() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("updates.db");
let s = Store::open(&path).unwrap();
assert!(s.check_on_launch().unwrap());
s.set_check_on_launch(false).unwrap();
s.ignore("2.1.2").unwrap();
drop(s);
let s = Store::open(&path).unwrap();
assert!(!s.check_on_launch().unwrap());
assert!(s.suppressed("2.1.2").unwrap());
assert!(!s.suppressed("2.1.3").unwrap());
}
}
+4
View File
@@ -75,6 +75,10 @@ cargo test \
--no-fail-fast \
"$@"
say "gate: signed updater regression suite present"
n=$(cargo test --manifest-path crates/Cargo.toml -p qbz-updater --lib -- --list 2>/dev/null | grep -c ': test$' || true)
(( n >= 11 )) || { echo "Updater suite has $n tests (expected >= 11)"; exit 1; }
say "gate: Orbit HTTP host isolation and library profile regressions present"
# The workspace run executes these. Keep actual socket/gate/shutdown tests and
# profile isolation from disappearing during the next extraction phases.
@@ -0,0 +1,20 @@
pragma Singleton
import QtQuick
QtObject {
property string updatesJson: "{}"
property int checkCalls: 0
property int installCalls: 0
property int cancelCalls: 0
property int ignoreCalls: 0
function updatesCheck() { checkCalls++ }
function updatesInstall() { installCalls++ }
function updatesCancel() { cancelCalls++ }
function updatesIgnore() { ignoreCalls++ }
function updatesClose() {
var doc = JSON.parse(updatesJson)
doc.open = false
updatesJson = JSON.stringify(doc)
}
function updatesSetLaunch(enabled) {}
function whatsNewOpen() {}
}
@@ -7,3 +7,5 @@ singleton QbzBridge 1.0 QbzBridge.qml
singleton QbzLibrary 1.0 QbzLibrary.qml
singleton QbzPurchases 1.0 QbzPurchases.qml
singleton QbzOrbit 1.0 QbzOrbit.qml
singleton QbzAbout 1.0 QbzAbout.qml
+61
View File
@@ -0,0 +1,61 @@
import QtQuick
import QtTest
import com.blitzfc.qbz
import "../../crates/qbz-qt/qml/settings" as Settings
import "../../crates/qbz-qt/qml/shell" as Shell
Item {
width: 1000; height: 800
Settings.UpdatesSettings { id: settings; width: 900 }
Shell.UpdatesModal { id: modal }
TestCase {
name: "Updates"; when: windowShown
function init() {
modal.kioskHost = false
QbzAbout.checkCalls = 0
QbzAbout.installCalls = 0
QbzAbout.updatesJson = JSON.stringify({open:false, phase:"idle", currentVersion:"2.1.1"})
}
function test_manual_check_action() {
var button = findChild(settings, "updateCheckButton")
verify(button.enabled)
mouseClick(button)
compare(QbzAbout.checkCalls, 1)
QbzAbout.updatesJson = JSON.stringify({busy:true, phase:"checking"})
verify(!button.enabled)
}
function test_managed_install_has_release_link_without_installer() {
QbzAbout.updatesJson = JSON.stringify({open:true, phase:"available", version:"2.1.2", canInstall:false})
verify(modal.visible)
verify(!findChild(modal, "updateInstallButton").visible)
verify(findChild(modal, "updateReleaseButton").visible)
modal.dismiss()
verify(!modal.visible)
}
function test_signed_installer_action_and_commit_cannot_be_dismissed() {
QbzAbout.updatesJson = JSON.stringify({open:true, phase:"available", version:"2.1.2", canInstall:true})
var button = findChild(modal, "updateInstallButton")
verify(button.visible)
verify(waitForRendering(modal))
mouseClick(button)
compare(QbzAbout.installCalls, 1)
QbzAbout.updatesJson = JSON.stringify({open:true, phase:"installing", busy:true})
modal.dismiss()
verify(modal.visible)
verify(!findChild(modal, "updateCloseButton").enabled)
verify(!button.visible)
}
function test_kiosk_update_buttons_have_touch_height() {
modal.kioskHost = true
QbzAbout.updatesJson = JSON.stringify({open:true, phase:"available", version:"2.1.2", canInstall:true})
verify(findChild(modal, "updateInstallButton").height >= 44)
verify(findChild(modal, "updateCloseButton").height >= 44)
}
function test_network_failure_is_not_reported_as_up_to_date() {
QbzAbout.updatesJson = JSON.stringify({open:true, phase:"error", error:"HTTP 403"})
verify(modal.statusText().indexOf("could not be completed") >= 0)
QbzAbout.updatesJson = JSON.stringify({open:true, phase:"current"})
verify(modal.statusText().indexOf("latest version") >= 0)
}
}
}
+1
View File
@@ -4,6 +4,7 @@ set -euo pipefail
cd "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/.."
export QBZ_PREBUILT_SHADERS=1
bash scripts/test-search-local-qml.sh
bash scripts/test-updates-qml.sh
bash scripts/test-orbit-qml.sh
node scripts/test_qt_local_views.mjs
node scripts/test_qt_kiosk_art.mjs
+17
View File
@@ -0,0 +1,17 @@
#!/usr/bin/env bash
# The same bounded view/activation regression runs in CI and locally.
set -euo pipefail
cd "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/.."
if [[ -n "${QT_ROOT_DIR:-}" ]]; then
qt_updates_bins="$QT_ROOT_DIR/bin"
else
qt_updates_qmake="${QMAKE:-}"
if [[ -z "$qt_updates_qmake" ]]; then
qt_updates_qmake="$(command -v qmake6 || command -v qmake)"
fi
qt_updates_bins="$("$qt_updates_qmake" -query QT_INSTALL_BINS)"
fi
QT_QPA_PLATFORM=offscreen QT_QUICK_BACKEND=software \
"$qt_updates_bins/qmltestrunner" \
-input scripts/qml-tests/tst_updates.qml \
-import scripts/qml-tests/imports