fix(updater): restore Qt release checks and signed installation
cx
This commit is contained in:
37 files changed
+2462
-7
No files matched your search
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
Generated
+47
@@ -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"
|
||||
|
||||
@@ -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 "Системная / ручная"
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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(¤t).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()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
Reference in new issue
Block a user