feat(updater): add native update channels and one-line installation

Support Flatpak portal updates and signed Windows MSI upgrades. Add the Linux/macOS installer for 2.1.2, release signing, translations and regression coverage.

cx
This commit is contained in:
vicrodh committed 2026-09-12 16:25:08 -06:00
1 parent 64ec3e2bc8
commit 7a22fd6867
26 files changed
+1279 -19

No files matched your search

+11 -2
View File
@@ -5,6 +5,7 @@ on:
workflows:
- release-macos
- release-linux
- release-windows
- "Linux ARM64 (aarch64) Build"
types:
- completed
@@ -76,6 +77,7 @@ jobs:
"x64.app.tar.gz.sig"
"amd64.AppImage.sig"
"aarch64.AppImage.sig"
"x64.msi.sig"
)
MAX_ATTEMPTS=40
SLEEP_SECONDS=30
@@ -186,9 +188,16 @@ jobs:
'. + {"linux-aarch64": {"signature": $sig, "url": $url}}')
fi
WINDOWS_MSI=$(find_asset "_x64.msi$")
WINDOWS_SIG=$(read_sig "*_x64.msi.sig")
if [ -n "$WINDOWS_MSI" ] && [ -n "$WINDOWS_SIG" ]; then
PLATFORMS=$(echo "$PLATFORMS" | jq --arg url "${RELEASE_URL}/${WINDOWS_MSI}" --arg sig "$WINDOWS_SIG" \
'. + {"windows-x86_64": {"signature": $sig, "url": $url}}')
fi
PLATFORM_COUNT=$(echo "$PLATFORMS" | jq 'length')
if [ "$PLATFORM_COUNT" -ne 4 ]; then
echo "::error::Expected four updater platforms, generated $PLATFORM_COUNT."
if [ "$PLATFORM_COUNT" -ne 5 ]; then
echo "::error::Expected five updater platforms, generated $PLATFORM_COUNT."
echo "$PLATFORMS" | jq .
exit 1
fi
+25
View File
@@ -349,6 +349,15 @@ jobs:
# installer on the last compatible pre-OSMF toolchain instead of making
# a CI job accept legal terms on a maintainer's behalf. The committed
# authoring deliberately stays within WiX 5 syntax.
- name: Validate updater helper syntax
shell: pwsh
run: |
$tokens = $null
$errors = $null
[System.Management.Automation.Language.Parser]::ParseFile(
"$PWD/crates/qbz-updater/src/install-msi.ps1", [ref]$tokens, [ref]$errors) | Out-Null
if ($errors.Count -gt 0) { $errors | Format-List; throw 'Invalid updater helper' }
- name: WiX
run: dotnet tool install --global wix --version 5.0.2 && wix --version
@@ -420,6 +429,20 @@ jobs:
run: |
& "$(vswhere -latest -find '**\signtool.exe' | Select-Object -First 1)" sign /f cert.pfx /p $env:WINDOWS_CERT_PASSWORD /fd SHA256 /tr http://timestamp.digicert.com /td SHA256 "dist\QBZ_$env:VERSION`_x64.msi"
# Minisign is the updater signature, separate from optional Authenticode.
# Sign the final MSI bytes after Authenticode, with the existing release key.
- name: Sign MSI for the QBZ updater
if: startsWith(github.ref, 'refs/tags/')
env:
VERSION: ${{ steps.release.outputs.version }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
run: |
npx --yes @tauri-apps/cli@^2 signer sign \
--private-key "$TAURI_SIGNING_PRIVATE_KEY" \
--password "$TAURI_SIGNING_PRIVATE_KEY_PASSWORD" \
"dist/QBZ_${VERSION}_x64.msi"
# Portable zip of the same directory (testers without install rights),
# plus the PDB (MSVC emits it regardless of `strip = "symbols"`).
- name: Portable zip + symbols
@@ -462,6 +485,7 @@ jobs:
retention-days: ${{ startsWith(github.ref, 'refs/tags/v') && 90 || 7 }}
path: |
dist/*.msi
dist/*.msi.sig
dist/*.zip
dist/*.pdb
@@ -472,6 +496,7 @@ jobs:
with:
files: |
dist/*.msi
dist/*.msi.sig
dist/*.zip
prerelease: ${{ steps.release.outputs.prerelease }}
make_latest: ${{ steps.release.outputs.prerelease == 'true' && 'false' || 'true' }}
+5 -1
View File
@@ -48,6 +48,8 @@ on:
paths:
- "crates/**"
- "scripts/cargo-test.sh"
- "scripts/install.sh"
- "scripts/test-installer.py"
- "scripts/qt-*.py"
- "scripts/qt-*.sh"
- "scripts/test_qt_build_gates.py"
@@ -68,6 +70,8 @@ on:
paths:
- "crates/**"
- "scripts/cargo-test.sh"
- "scripts/install.sh"
- "scripts/test-installer.py"
- "scripts/qt-*.py"
- "scripts/qt-*.sh"
- "scripts/test_qt_build_gates.py"
@@ -113,7 +117,7 @@ jobs:
# OpenSSL (reqwest).
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
build-essential pkg-config gettext \
build-essential pkg-config gettext dbus-daemon desktop-file-utils \
libasound2-dev libjack-jackd2-dev \
libdbus-1-dev libssl-dev
+35 -4
View File
@@ -126,6 +126,32 @@ another module, and it didn't go well.
## Installation
### One-line installer (Linux and macOS, from 2.1.2)
```bash
curl -fsSL https://qbz.lol/install.sh | bash
```
Installs the official AppImage on Linux or the upstream `.app` in
`~/Applications` on macOS, for the current user. It detects Intel/AMD or ARM64,
verifies the package SHA-256 against GitHub's release metadata, and preserves
existing installations from other sources. Future updates use **Settings →
Updates** in QBZ. Linux needs curl and Python 3 or jq; the launcher works without
FUSE. The existing glibc requirements still apply. The macOS package uses the
upstream ad-hoc signature; the notarized community channel remains available below.
To inspect the script before running it, or remove this installation while
keeping personal data:
```bash
curl -fsSL https://qbz.lol/install.sh -o qbz-install.sh
less qbz-install.sh
bash qbz-install.sh --check
bash qbz-install.sh
# Close QBZ before uninstalling:
bash qbz-install.sh --uninstall
```
### Arch Linux (AUR)
Install the prebuilt packages (recommended):
@@ -336,15 +362,20 @@ subscription, Gatekeeper blocks its first run. On recent macOS versions
### 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
Launch checks are optional and preserve existing preferences. GitHub release
notifications wait until 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
builds, keep their original update channel. The one-line installer uses these
same update paths. Flatpak checks and updates its own installed ref through the
Flatpak portal, including progress, cancellation and already-deployed updates
that only need a restart. Permission changes require the system software manager.
Windows MSI installations stage a signed MSI and run Windows Installer after
QBZ closes, then reopen the app. The installer never requests a machine restart.
For Snap, APT/RPM, AUR, Gentoo, Nix, Homebrew and other manual installs, update through the original
installation source; its package may arrive after the GitHub release.
## Features
+4
View File
@@ -4606,15 +4606,19 @@ dependencies = [
"base64 0.23.1",
"chrono",
"flate2",
"futures-util",
"minisign-verify",
"reqwest",
"rusqlite",
"semver",
"serde",
"serde_json",
"sha2 0.10.9",
"tar",
"tempfile",
"tokio",
"windows-sys 0.61.2",
"zbus 5.19.0",
]
[[package]]
@@ -7245,3 +7245,7 @@ msgstr "Du verwendest die neueste Version von QBZ."
#: qbz-qt updates
msgid "System / manual"
msgstr "System / manuell"
#: qbz-qt/qml/shell/UpdatesModal.qml
msgid "Update ready. Close QBZ to finish installation. It will reopen automatically."
msgstr "Update bereit. Schließe QBZ, um die Installation abzuschließen. QBZ wird automatisch wieder geöffnet."
@@ -2845,3 +2845,7 @@ msgstr "You are using the latest version of QBZ."
#: qbz-qt updates
msgid "System / manual"
msgstr "System / manual"
#: qbz-qt/qml/shell/UpdatesModal.qml
msgid "Update ready. Close QBZ to finish installation. It will reopen automatically."
msgstr "Update ready. Close QBZ to finish installation. It will reopen automatically."
@@ -7251,3 +7251,7 @@ msgstr "Estás usando la versión más reciente de QBZ."
#: qbz-qt updates
msgid "System / manual"
msgstr "Sistema / manual"
#: qbz-qt/qml/shell/UpdatesModal.qml
msgid "Update ready. Close QBZ to finish installation. It will reopen automatically."
msgstr "Actualización lista. Cierra QBZ para terminar la instalación. Se abrirá de nuevo automáticamente."
@@ -7243,3 +7243,7 @@ msgstr "Vous utilisez la dernière version de QBZ."
#: qbz-qt updates
msgid "System / manual"
msgstr "Système / manuelle"
#: qbz-qt/qml/shell/UpdatesModal.qml
msgid "Update ready. Close QBZ to finish installation. It will reopen automatically."
msgstr "Mise à jour prête. Fermez QBZ pour terminer l’installation. Il se rouvrira automatiquement."
@@ -7226,3 +7226,7 @@ msgstr "最新バージョンのQBZを使用しています。"
#: qbz-qt updates
msgid "System / manual"
msgstr "システム / 手動"
#: qbz-qt/qml/shell/UpdatesModal.qml
msgid "Update ready. Close QBZ to finish installation. It will reopen automatically."
msgstr "更新の準備ができました。QBZ を終了するとインストールが完了し、自動的に再起動します。"
@@ -7233,3 +7233,7 @@ msgstr "Je gebruikt de nieuwste versie van QBZ."
#: qbz-qt updates
msgid "System / manual"
msgstr "Systeem / handmatig"
#: qbz-qt/qml/shell/UpdatesModal.qml
msgid "Update ready. Close QBZ to finish installation. It will reopen automatically."
msgstr "Update gereed. Sluit QBZ om de installatie te voltooien. Het wordt automatisch opnieuw geopend."
@@ -7243,3 +7243,7 @@ msgstr "Você está usando a versão mais recente do QBZ."
#: qbz-qt updates
msgid "System / manual"
msgstr "Sistema / manual"
#: qbz-qt/qml/shell/UpdatesModal.qml
msgid "Update ready. Close QBZ to finish installation. It will reopen automatically."
msgstr "Atualização pronta. Feche o QBZ para concluir a instalação. Ele será reaberto automaticamente."
@@ -7264,3 +7264,7 @@ msgstr "Вы используете последнюю версию QBZ."
#: qbz-qt updates
msgid "System / manual"
msgstr "Системная / ручная"
#: qbz-qt/qml/shell/UpdatesModal.qml
msgid "Update ready. Close QBZ to finish installation. It will reopen automatically."
msgstr "Обновление готово. Закройте QBZ для завершения установки. Приложение откроется автоматически."
+12 -2
View File
@@ -24,7 +24,9 @@ FocusScope {
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 "updating": 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 "prepared": return QbzSession.tr("Update ready. Close QBZ to finish installation. It will reopen automatically.", 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 ""
@@ -96,6 +98,13 @@ FocusScope {
font.pixelSize: theme.fontBody
color: theme.textSecondary
}
Text {
width: parent.width
visible: root.doc.phase === "updating"
text: (root.doc.percent || 0) + "%"
font.pixelSize: theme.fontBody
color: theme.textSecondary
}
Text {
width: parent.width
visible: root.doc.phase === "error"
@@ -131,8 +140,9 @@ FocusScope {
kioskHost: root.kioskHost
minWidth: 0
btnHeight: 36
visible: root.doc.phase === "downloading" || root.doc.phase === "verifying"
text: QbzSession.tr("Cancel download", QbzSession.trRev)
visible: root.doc.phase === "downloading" || root.doc.phase === "verifying" || root.doc.phase === "updating"
objectName: "updateCancelButton"
text: root.doc.phase === "updating" ? QbzSession.tr("Cancel", QbzSession.trRev) : QbzSession.tr("Cancel download", QbzSession.trRev)
onClicked: QbzAbout.updatesCancel()
}
SettingsButton {
+104 -7
View File
@@ -1,10 +1,10 @@
//! 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 qbz_updater::{Asset, Release, install::Installation, store::Store};
use serde::Serialize;
use std::sync::{
atomic::{AtomicBool, Ordering},
LazyLock, Mutex,
atomic::{AtomicBool, Ordering},
};
static STARTED: AtomicBool = AtomicBool::new(false);
@@ -26,6 +26,12 @@ struct State {
downloaded: u64,
total: Option<u64>,
error: String,
percent: Option<u32>,
#[serde(skip)]
reminder_key: String,
#[cfg(target_os = "linux")]
#[serde(skip)]
flatpak: Option<std::sync::Arc<qbz_updater::flatpak::Monitor>>,
#[serde(skip)]
manual: bool,
#[serde(skip)]
@@ -48,6 +54,10 @@ impl Default for State {
downloaded: 0,
total: None,
error: String::new(),
percent: None,
reminder_key: String::new(),
#[cfg(target_os = "linux")]
flatpak: None,
manual: false,
release: None,
asset: None,
@@ -117,7 +127,7 @@ fn begin_check(manual: bool) {
s.open = true;
s.manual = true;
}
if !s.busy && s.phase != "installed" {
if !s.busy && s.phase != "installed" && s.phase != "prepared" {
s.busy = true;
s.phase = "checking".into();
s.error.clear();
@@ -126,6 +136,12 @@ fn begin_check(manual: bool) {
s.release = None;
s.can_install = false;
s.version.clear();
s.reminder_key.clear();
s.percent = None;
#[cfg(target_os = "linux")]
{
s.flatpak = None;
}
start = true;
}
});
@@ -133,6 +149,11 @@ fn begin_check(manual: bool) {
return;
}
crate::spawn(async {
#[cfg(target_os = "linux")]
if Installation::detect() == Installation::Flatpak {
check_flatpak().await;
return;
}
// 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 {
@@ -170,6 +191,7 @@ fn begin_check(manual: bool) {
s.busy = false;
s.phase = "available".into();
s.version = version;
s.reminder_key = s.version.clone();
s.release_url = release.page();
s.release = Some(release);
s.can_install = asset.is_some();
@@ -197,7 +219,7 @@ pub fn ignore() {
let version = STATE
.lock()
.unwrap_or_else(|e| e.into_inner())
.version
.reminder_key
.clone();
if version.is_empty() {
return;
@@ -214,8 +236,21 @@ pub fn cancel() {
}
pub fn install() {
let mut asset = None;
#[cfg(target_os = "linux")]
let mut flatpak = None;
update(|s| {
if !s.busy && s.can_install && s.phase != "installed" {
#[cfg(target_os = "linux")]
if let Some(monitor) = &s.flatpak {
flatpak = Some(monitor.clone());
s.busy = true;
s.open = true;
s.phase = "updating".into();
s.error.clear();
s.percent = Some(0);
CANCEL.store(false, Ordering::Relaxed);
return;
}
asset = s.asset.clone();
if asset.is_some() {
s.busy = true;
@@ -228,6 +263,30 @@ pub fn install() {
}
}
});
#[cfg(target_os = "linux")]
if let Some(monitor) = flatpak {
crate::spawn(async move {
let result = monitor
.install(&CANCEL, |percent| update(|s| s.percent = Some(percent)))
.await;
update(|s| {
s.flatpak = None;
s.can_install = false;
});
match result {
Ok(()) => update(|s| {
s.phase = "installed".into();
s.busy = false;
}),
Err(_) if CANCEL.load(Ordering::Relaxed) => update(|s| {
s.phase = "cancelled".into();
s.busy = false;
}),
Err(error) => failed(error),
}
});
return;
}
let Some(asset) = asset else { return };
crate::spawn(async move {
let result = qbz_updater::install::install(
@@ -244,10 +303,17 @@ pub fn install() {
)
.await;
match result {
Ok(backup) => {
log::info!("[updates] installed; rollback copy: {}", backup.display());
Ok(outcome) => {
let (staged, backup) = match outcome {
qbz_updater::install::InstallOutcome::Installed(path) => (false, path),
qbz_updater::install::InstallOutcome::Prepared(path) => (true, path),
};
log::info!(
"[updates] completed staging/install; recovery or installer files: {}",
backup.display()
);
update(|s| {
s.phase = "installed".into();
s.phase = if staged { "prepared" } else { "installed" }.into();
s.busy = false;
s.can_install = false;
});
@@ -260,3 +326,34 @@ pub fn install() {
}
});
}
#[cfg(target_os = "linux")]
async fn check_flatpak() {
use qbz_updater::flatpak::{Availability, Monitor};
match Monitor::check().await {
Err(error) => failed(error),
Ok(monitor) => {
let key = monitor.info.reminder_key();
let suppression_key = key.clone();
let suppressed =
tokio::task::spawn_blocking(move || store()?.suppressed(&suppression_key)).await;
let suppressed = !matches!(suppressed, Ok(Ok(false)));
update(|s| {
s.busy = false;
s.reminder_key = key;
match monitor.info.availability() {
Availability::Current => s.phase = "current".into(),
Availability::Restart => s.phase = "installed".into(),
Availability::Available => {
s.phase = "available".into();
s.can_install = true;
s.flatpak = Some(std::sync::Arc::new(monitor));
if !s.manual && s.check_on_launch && !suppressed {
s.open = true;
}
}
}
});
}
}
}
+9
View File
@@ -18,3 +18,12 @@ rusqlite = { version = "0.40", features = ["bundled"] }
tempfile = "3"
flate2 = "1"
tar = "0.4"
[target.'cfg(target_os = "linux")'.dependencies]
# Match Qt/ksni: never enable zbus/tokio through feature unification.
zbus = { version = "5", default-features = false, features = ["async-io"] }
futures-util = "0.3"
[target.'cfg(windows)'.dependencies]
sha2 = "0.10"
windows-sys = { version = "0.61", features = ["Win32_System_Registry"] }
+225
View File
@@ -0,0 +1,225 @@
//! Update only the caller's Flatpak ref through its native portal. GitHub
//! release versions do not establish availability in this installation's remote.
use crate::Result;
use futures_util::StreamExt;
use std::{
collections::HashMap,
sync::atomic::{AtomicBool, AtomicU64, Ordering},
time::Duration,
};
use zbus::{
Connection, Proxy,
zvariant::{OwnedObjectPath, OwnedValue, Value},
};
const SERVICE: &str = "org.freedesktop.portal.Flatpak";
const ROOT: &str = "/org/freedesktop/portal/Flatpak";
const INTERFACE: &str = "org.freedesktop.portal.Flatpak.UpdateMonitor";
static TOKEN: AtomicU64 = AtomicU64::new(0);
type Dict = HashMap<String, OwnedValue>;
#[derive(Debug, PartialEq, Eq)]
pub enum Availability {
Current,
Available,
Restart,
}
#[derive(Debug)]
pub struct Info {
pub running: String,
pub local: String,
pub remote: String,
}
impl Info {
pub fn availability(&self) -> Availability {
if self.remote != self.local {
Availability::Available
} else if self.local != self.running {
Availability::Restart
} else {
Availability::Current
}
}
pub fn reminder_key(&self) -> String {
format!("flatpak:{}", self.remote)
}
fn parse(dict: &Dict) -> Result<Self> {
fn commit(dict: &Dict, key: &str) -> Result<String> {
let value = dict
.get(key)
.and_then(|v| <&str>::try_from(v).ok())
.ok_or_else(|| format!("Flatpak omitted {key}; update availability is unknown"))?;
if value.len() != 64 || !value.bytes().all(|b| b.is_ascii_hexdigit()) {
return Err(format!("Flatpak returned an invalid {key}"));
}
Ok(value.into())
}
Ok(Self {
running: commit(dict, "running-commit")?,
local: commit(dict, "local-commit")?,
remote: commit(dict, "remote-commit")?,
})
}
}
/// Owns a dedicated bus connection. Dropping the monitor disconnects its unique
/// sender, which also releases the portal object, including failed checks.
pub struct Monitor {
proxy: Proxy<'static>,
pub info: Info,
}
impl Monitor {
pub async fn check() -> Result<Self> {
tokio::time::timeout(Duration::from_secs(45), async {
let connection = Connection::session().await.map_err(|e| e.to_string())?;
Self::with_connection(connection).await
})
.await
.map_err(|_| "Flatpak did not report update availability in time".to_string())?
}
async fn with_connection(connection: Connection) -> Result<Self> {
let token = format!(
"qbz{}_{}",
std::process::id(),
TOKEN.fetch_add(1, Ordering::Relaxed)
);
let sender = connection
.unique_name()
.ok_or("Flatpak connection has no unique name")?
.as_str()
.trim_start_matches(':')
.replace('.', "_");
let path = format!("{ROOT}/update_monitor/{sender}/{token}");
let proxy = Proxy::new_owned(connection.clone(), SERVICE, path.clone(), INTERFACE)
.await
.map_err(|e| e.to_string())?;
// Subscribe BEFORE creation: a locally cached update can arrive before
// CreateUpdateMonitor returns. The caller-provided token fixes the path.
let mut signals = proxy
.receive_signal("UpdateAvailable")
.await
.map_err(|e| e.to_string())?;
let portal = Proxy::new(&connection, SERVICE, ROOT, SERVICE)
.await
.map_err(|e| e.to_string())?;
let options = HashMap::from([("handle_token", Value::from(token.as_str()))]);
let handle: OwnedObjectPath = portal
.call("CreateUpdateMonitor", &(options,))
.await
.map_err(|e| e.to_string())?;
if handle.as_str() != path {
return Err("Flatpak returned an unexpected update monitor".into());
}
let message = signals
.next()
.await
.ok_or("Flatpak update monitor disconnected")?;
let (dict,): (Dict,) = message.body().deserialize().map_err(|e| e.to_string())?;
let info = Info::parse(&dict)?;
drop(signals);
Ok(Self { proxy, info })
}
pub async fn install(&self, cancel: &AtomicBool, progress: impl Fn(u32)) -> Result<()> {
let operation = async {
let mut signals = self
.proxy
.receive_signal("Progress")
.await
.map_err(|e| e.to_string())?;
let options: HashMap<&str, Value<'_>> = HashMap::new();
// An empty parent is permitted; Flatpak presents its own consent UI.
self.proxy
.call::<_, _, ()>("Update", &("", options))
.await
.map_err(|e| e.to_string())?;
loop {
let message = signals
.next()
.await
.ok_or("Flatpak update monitor disconnected")?;
let (dict,): (Dict,) = message.body().deserialize().map_err(|e| e.to_string())?;
match progress_state(&dict)? {
Progress::Running(percent) => progress(percent),
Progress::Done => return Ok(()),
}
}
};
let cancellation = async {
while !cancel.load(Ordering::Relaxed) {
tokio::time::sleep(Duration::from_millis(100)).await;
}
};
let result = tokio::select! {
r = tokio::time::timeout(Duration::from_secs(1800), operation) =>
r.map_err(|_| "Flatpak update timed out".to_string()).and_then(|r| r),
_ = cancellation => Err("Update cancelled".into()),
};
// Close cancels the native transaction on failure/cancellation too.
let _ = tokio::time::timeout(
Duration::from_secs(5),
self.proxy.call::<_, _, ()>("Close", &()),
)
.await;
result
}
}
enum Progress {
Running(u32),
Done,
}
fn progress_state(dict: &Dict) -> Result<Progress> {
let uint = |key| dict.get(key).and_then(|v| u32::try_from(v).ok());
match uint("status").unwrap_or(0) {
0 => {
let count = uint("n_ops").unwrap_or(1).max(1) as u64;
let operation = uint("op").unwrap_or(0) as u64;
let percent = uint("progress").unwrap_or(0).min(100) as u64;
Ok(Progress::Running(
((operation * 100 + percent) / count).min(100) as u32,
))
}
1 => Err("Flatpak found no update to install. Check for updates again.".into()),
2 => Ok(Progress::Done),
3 => Err(dict
.get("error_message")
.and_then(|v| <&str>::try_from(v).ok())
.unwrap_or("Flatpak could not install the update; use your software manager")
.into()),
_ => Err("Flatpak returned an unknown update status".into()),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn distinguishes_channel_update_from_already_deployed_restart() {
let mut i = Info {
running: "a".repeat(64),
local: "a".repeat(64),
remote: "a".repeat(64),
};
assert_eq!(i.availability(), Availability::Current);
i.remote = "b".repeat(64);
assert_eq!(i.availability(), Availability::Available);
i.local = i.remote.clone();
assert_eq!(i.availability(), Availability::Restart);
assert!(i.reminder_key().starts_with("flatpak:"));
assert!(Info::parse(&Dict::new()).is_err());
}
#[test]
fn progress_failure_is_not_install_success() {
let d = HashMap::from([("status".into(), OwnedValue::from(3u32))]);
assert!(progress_state(&d).is_err());
let d = HashMap::from([("status".into(), OwnedValue::from(2u32))]);
assert!(matches!(progress_state(&d), Ok(Progress::Done)));
}
}
#[cfg(test)]
#[path = "flatpak_tests.rs"]
mod wire_tests;
+192
View File
@@ -0,0 +1,192 @@
use super::*;
use std::{
io::{BufRead, BufReader},
process::{Child, Command, Stdio},
sync::Arc,
};
struct Bus(Child, String);
impl Bus {
fn new() -> Self {
let mut child = Command::new("dbus-daemon")
.args(["--session", "--nofork", "--print-address=1"])
.stdout(Stdio::piped())
.spawn()
.expect("dbus-daemon is required for the updater wire tests");
let mut address = String::new();
BufReader::new(child.stdout.take().unwrap())
.read_line(&mut address)
.unwrap();
Self(child, address.trim().into())
}
async fn connect(&self) -> Connection {
zbus::connection::Builder::address(self.1.as_str())
.unwrap()
.build()
.await
.unwrap()
}
}
impl Drop for Bus {
fn drop(&mut self) {
let _ = self.0.kill();
let _ = self.0.wait();
}
}
struct Portal {
closed: Arc<AtomicBool>,
status: u32,
}
struct UpdateObject {
closed: Arc<AtomicBool>,
status: u32,
}
#[zbus::interface(name = "org.freedesktop.portal.Flatpak")]
impl Portal {
async fn create_update_monitor(
&self,
options: Dict,
#[zbus(connection)] connection: &Connection,
#[zbus(header)] header: zbus::message::Header<'_>,
) -> zbus::fdo::Result<OwnedObjectPath> {
let token = <&str>::try_from(options.get("handle_token").unwrap()).unwrap();
let sender = header
.sender()
.unwrap()
.as_str()
.trim_start_matches(':')
.replace('.', "_");
let path = format!("{ROOT}/update_monitor/{sender}/{token}");
connection
.object_server()
.at(
path.clone(),
UpdateObject {
closed: self.closed.clone(),
status: self.status,
},
)
.await
.unwrap();
let info = HashMap::from([
("running-commit", Value::from("a".repeat(64))),
("local-commit", Value::from("a".repeat(64))),
("remote-commit", Value::from("b".repeat(64))),
]);
// Intentionally send before the method reply: subscriptions installed
// afterwards lose this cached initial notification.
connection
.emit_signal(
header.sender().cloned(),
path.as_str(),
INTERFACE,
"UpdateAvailable",
&(info,),
)
.await
.unwrap();
Ok(OwnedObjectPath::try_from(path).unwrap())
}
}
#[zbus::interface(name = "org.freedesktop.portal.Flatpak.UpdateMonitor")]
impl UpdateObject {
async fn update(
&self,
parent_window: &str,
options: Dict,
#[zbus(connection)] connection: &Connection,
#[zbus(header)] header: zbus::message::Header<'_>,
) -> zbus::fdo::Result<()> {
assert!(parent_window.is_empty());
assert!(options.is_empty());
if self.status == 0 {
return Ok(());
}
let progress = HashMap::from([("status", Value::from(self.status))]);
connection
.emit_signal(
header.sender().cloned(),
header.path().unwrap(),
INTERFACE,
"Progress",
&(progress,),
)
.await
.unwrap();
Ok(())
}
fn close(&self) {
self.closed.store(true, Ordering::Relaxed);
}
}
async fn fixture(status: u32) -> (Bus, Connection, Arc<AtomicBool>, Monitor) {
let bus = Bus::new();
let closed = Arc::new(AtomicBool::new(false));
let server = zbus::connection::Builder::address(bus.1.as_str())
.unwrap()
.name(SERVICE)
.unwrap()
.serve_at(
ROOT,
Portal {
closed: closed.clone(),
status,
},
)
.unwrap()
.build()
.await
.unwrap();
let monitor = tokio::time::timeout(
Duration::from_secs(5),
Monitor::with_connection(bus.connect().await),
)
.await
.unwrap()
.unwrap();
(bus, server, closed, monitor)
}
#[tokio::test]
async fn early_portal_signals_survive_check_and_install() {
let (_bus, _server, closed, monitor) = fixture(2).await;
assert_eq!(monitor.info.availability(), Availability::Available);
let cancel = AtomicBool::new(false);
tokio::time::timeout(Duration::from_secs(5), monitor.install(&cancel, |_| {}))
.await
.unwrap()
.unwrap();
assert!(closed.load(Ordering::Relaxed));
}
#[tokio::test]
async fn portal_failure_is_reported_and_monitor_closed() {
let (_bus, _server, closed, monitor) = fixture(3).await;
let cancel = AtomicBool::new(false);
let result = tokio::time::timeout(Duration::from_secs(5), monitor.install(&cancel, |_| {}))
.await
.unwrap();
assert!(result.is_err());
assert!(closed.load(Ordering::Relaxed));
}
#[tokio::test]
async fn cancelling_portal_update_closes_the_native_transaction() {
let (_bus, _server, closed, monitor) = fixture(0).await;
let cancel = AtomicBool::new(true);
let result = tokio::time::timeout(Duration::from_secs(5), monitor.install(&cancel, |_| {}))
.await
.unwrap();
assert!(result.unwrap_err().contains("cancelled"));
assert!(closed.load(Ordering::Relaxed));
}
#[tokio::test]
async fn an_empty_transaction_never_reports_a_successful_installation() {
let (_bus, _server, closed, monitor) = fixture(1).await;
let cancel = AtomicBool::new(false);
let result = tokio::time::timeout(Duration::from_secs(5), monitor.install(&cancel, |_| {}))
.await.unwrap();
assert!(result.unwrap_err().contains("no update to install"));
assert!(closed.load(Ordering::Relaxed));
}
+35
View File
@@ -0,0 +1,35 @@
param(
[Parameter(Mandatory=$true)][int]$ParentPid,
[Parameter(Mandatory=$true)][string]$AppPath,
[Parameter(Mandatory=$true)][string]$Package,
[Parameter(Mandatory=$true)][ValidatePattern('^[a-f0-9]{64}$')][string]$Sha256
)
$ErrorActionPreference = 'Stop'
$stage = Split-Path -Parent $Package
$result = Join-Path $stage 'result.txt'
try {
$parent = Get-Process -Id $ParentPid
if ($parent.Path -ine $AppPath) { throw 'The parent application path changed.' }
# Open the actual process handle BEFORE the ready handshake. PID reuse
# cannot make us wait for or terminate an unrelated process afterwards.
$null = $parent.Handle
[IO.File]::WriteAllText((Join-Path $stage 'ready'), 'ready')
$parent.WaitForExit()
if ((Get-FileHash -LiteralPath $Package -Algorithm SHA256).Hash -ine $Sha256) {
throw 'The verified MSI changed before installation.'
}
$log = Join-Path $stage 'msiexec.log'
# No elevation or machine restart. The MSI retains its per-user scope and
# MajorUpgrade/rollback behavior. Keep the log and package on failure.
$arguments = '/i "{0}" /passive /norestart REBOOT=ReallySuppress /l*v "{1}"' -f $Package,$log
$installer = Start-Process -FilePath "$env:SystemRoot\System32\msiexec.exe" -ArgumentList $arguments -Wait -PassThru
if ($installer.ExitCode -notin @(0,3010)) { throw "Windows Installer returned $($installer.ExitCode). See $log" }
[IO.File]::WriteAllText($result, "Installed; Windows Installer exit $($installer.ExitCode)")
Start-Process -FilePath $AppPath
} catch {
[IO.File]::WriteAllText($result, $_.Exception.Message)
# The parent has exited; show the failure instead of silently losing it.
Add-Type -AssemblyName System.Windows.Forms
[System.Windows.Forms.MessageBox]::Show($_.Exception.Message, 'QBZ update', 'OK', 'Error') | Out-Null
exit 1
}
+19 -3
View File
@@ -11,10 +11,16 @@ use std::{
const PUBLIC_KEY: &str = "RWSGlbvrbc5/P3/4zoXZfBd+pee5Kw7h5/gUIE7B9GiL47dvUSNpNGLm";
const MAX_DOWNLOAD: u64 = 2 * 1024 * 1024 * 1024;
pub enum InstallOutcome {
Installed(PathBuf),
Prepared(PathBuf),
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Installation {
AppImage(PathBuf),
MacBundle(PathBuf),
WindowsMsi(PathBuf),
Flatpak,
Snap,
Nix,
@@ -29,6 +35,10 @@ impl Installation {
return Self::Snap;
}
let exe = std::env::current_exe().unwrap_or_default();
#[cfg(windows)]
if crate::windows::is_msi_install(&exe) {
return Self::WindowsMsi(exe);
}
if exe.starts_with("/nix/store") {
return Self::Nix;
}
@@ -60,6 +70,7 @@ impl Installation {
match self {
Self::AppImage(_) => "AppImage",
Self::MacBundle(_) => "macOS",
Self::WindowsMsi(_) => "Windows MSI",
Self::Flatpak => "Flatpak",
Self::Snap => "Snap",
Self::Nix => "Nix",
@@ -73,13 +84,14 @@ impl Installation {
let os = match self {
Self::AppImage(_) => "linux",
Self::MacBundle(_) => "darwin",
Self::WindowsMsi(_) if std::env::consts::ARCH == "x86_64" => "windows",
_ => return None,
};
Some(format!("{os}-{}", std::env::consts::ARCH))
}
fn destination(&self) -> Result<&Path> {
match self {
Self::AppImage(p) | Self::MacBundle(p) => Ok(p),
Self::AppImage(p) | Self::MacBundle(p) | Self::WindowsMsi(p) => Ok(p),
_ => Err("This installation is managed outside QBZ".into()),
}
}
@@ -162,7 +174,7 @@ pub async fn install(
asset: Asset,
cancel: &AtomicBool,
progress: impl Fn(&str, u64, Option<u64>) + Send + Sync,
) -> Result<PathBuf> {
) -> Result<InstallOutcome> {
let destination = installation.destination()?.to_path_buf();
let parent = destination
.parent()
@@ -249,7 +261,11 @@ pub async fn install(
"The installation changed while downloading; check for updates again".into(),
);
}
apply(&installation, stage, &payload)
#[cfg(windows)]
if let Installation::WindowsMsi(exe) = &installation {
return crate::windows::stage_msi(exe, stage, &payload).map(InstallOutcome::Prepared);
}
apply(&installation, stage, &payload).map(InstallOutcome::Installed)
})
.await
.map_err(|e| e.to_string())?
+13
View File
@@ -1,7 +1,11 @@
//! Release checks and signed updates, independent of the desktop and audio.
//! Package-manager installations only check: their manager owns installation.
#[cfg(target_os = "linux")]
pub mod flatpak;
pub mod install;
pub mod store;
#[cfg(windows)]
mod windows;
use chrono::{DateTime, Utc};
use semver::Version;
@@ -126,6 +130,7 @@ pub fn select_asset(bytes: &[u8], release: &Release, platform: &str) -> Result<A
"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(),
"windows-x86_64" => format!("QBZ_{}_x64.msi", release.version()?),
_ => return Err("No signed update for this platform".into()),
};
if name != expected || asset.signature.is_empty() {
@@ -237,4 +242,12 @@ mod tests {
);
}
}
#[test]
fn windows_msi_manifest_cannot_select_a_portable_archive() {
let r = release("2.1.2", 24);
let mut manifest = serde_json::json!({"version":"2.1.2","platforms":{"windows-x86_64":{"signature":"sig","url":"https://github.com/vicrodh/qbz/releases/download/v2.1.2/QBZ_2.1.2_x64.msi"}}});
assert!(select_asset(&serde_json::to_vec(&manifest).unwrap(), &r, "windows-x86_64").is_ok());
manifest["platforms"]["windows-x86_64"]["url"] = "https://github.com/vicrodh/qbz/releases/download/v2.1.2/qbz_2.1.2_windows_x64.zip".into();
assert!(select_asset(&serde_json::to_vec(&manifest).unwrap(), &r, "windows-x86_64").is_err());
}
}
+132
View File
@@ -0,0 +1,132 @@
//! The MSI owns its files. Stage verified bytes and let Windows Installer do
//! the upgrade after the current application exits; never overwrite DLLs here.
use crate::Result;
use sha2::{Digest, Sha256};
use std::{
io::Read,
os::windows::process::CommandExt,
path::{Path, PathBuf},
process::{Command, Stdio},
time::{Duration, Instant},
};
use windows_sys::Win32::System::Registry::{HKEY_CURRENT_USER, RRF_RT_REG_SZ, RegGetValueW};
fn wide(value: &str) -> Vec<u16> {
value.encode_utf16().chain(Some(0)).collect()
}
pub fn is_msi_install(exe: &Path) -> bool {
let key = wide("Software\\blitzfc\\QBZ");
let name = wide("InstallDir");
let mut size = 0u32;
// This per-user key is authored by the fixed MainExe WiX component.
unsafe {
if RegGetValueW(
HKEY_CURRENT_USER,
key.as_ptr(),
name.as_ptr(),
RRF_RT_REG_SZ,
std::ptr::null_mut(),
std::ptr::null_mut(),
&mut size,
) != 0
|| size > 65536
{
return false;
}
let mut bytes = vec![0u16; size as usize / 2];
if RegGetValueW(
HKEY_CURRENT_USER,
key.as_ptr(),
name.as_ptr(),
RRF_RT_REG_SZ,
std::ptr::null_mut(),
bytes.as_mut_ptr().cast(),
&mut size,
) != 0
{
return false;
}
let end = bytes.iter().position(|c| *c == 0).unwrap_or(bytes.len());
let Ok(dir) = String::from_utf16(&bytes[..end]) else {
return false;
};
let registered = PathBuf::from(dir).join("qbz.exe").canonicalize();
matches!((registered,exe.canonicalize()),(Ok(a),Ok(b)) if a==b)
}
}
pub fn stage_msi(exe: &Path, stage: tempfile::TempDir, payload: &Path) -> Result<PathBuf> {
if !is_msi_install(exe) {
return Err("The installation is no longer owned by the QBZ MSI".into());
}
let mut input = std::fs::File::open(payload).map_err(|e| e.to_string())?;
let mut magic = [0u8; 8];
input.read_exact(&mut magic).map_err(|e| e.to_string())?;
if magic != [0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1] {
return Err("The signed package is not a Windows Installer database".into());
}
drop(input);
let mut input = std::fs::File::open(payload).map_err(|e| e.to_string())?;
let mut hash = Sha256::new();
let mut buffer = [0u8; 65536];
loop {
let n = input.read(&mut buffer).map_err(|e| e.to_string())?;
if n == 0 {
break;
}
hash.update(&buffer[..n]);
}
drop(input);
let digest = format!("{:x}", hash.finalize());
let package = stage.path().join("update.msi");
std::fs::rename(payload, &package).map_err(|e| e.to_string())?;
let script = stage.path().join("install.ps1");
let ready = stage.path().join("ready");
std::fs::write(&script, include_str!("install-msi.ps1")).map_err(|e| e.to_string())?;
let powershell =
PathBuf::from(std::env::var_os("SystemRoot").ok_or("SystemRoot is unavailable")?)
.join("System32/WindowsPowerShell/v1.0/powershell.exe");
let mut child = Command::new(powershell)
.args([
"-NoProfile",
"-NonInteractive",
"-WindowStyle",
"Hidden",
"-ExecutionPolicy",
"Bypass",
"-File",
])
.arg(&script)
.arg("-ParentPid")
.arg(std::process::id().to_string())
.arg("-AppPath")
.arg(exe)
.arg("-Package")
.arg(&package)
.arg("-Sha256")
.arg(digest)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.creation_flags(0x08000000)
.spawn()
.map_err(|e| format!("Could not start Windows Installer helper: {e}"))?;
let start = Instant::now();
loop {
if ready.is_file() {
return Ok(stage.keep());
}
if let Some(status) = child.try_wait().map_err(|e| e.to_string())? {
return Err(format!(
"Windows Installer helper exited before becoming ready: {status}"
));
}
if start.elapsed() > Duration::from_secs(15) {
let _ = child.kill();
let _ = child.wait();
return Err("Windows Installer helper did not become ready".into());
}
std::thread::sleep(Duration::from_millis(50));
}
}
+3
View File
@@ -75,6 +75,9 @@ cargo test \
--no-fail-fast \
"$@"
say "gate: one-line installer (isolated fixture homes)"
python3 scripts/test-installer.py
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; }
+249
View File
@@ -0,0 +1,249 @@
#!/usr/bin/env bash
# QBZ per-user installer. Canonical source: vicrodh/qbz scripts/install.sh.
# Published verbatim at https://qbz.lol/install.sh with the 2.1.2 website.
# Keep execution after the complete function definition: a truncated download
# must not start installing half a script. Compatible with macOS Bash 3.2.
qbz_install_main() (
set -euo pipefail
umask 077
local requested='' check_only=0 action=install arg
for arg in "$@"; do
case "$arg" in
--help|-h)
printf '%s\n' 'QBZ installer — Linux and macOS, x86_64 and ARM64' \
'Usage: bash install.sh [--check] [--version=X.Y.Z] [--uninstall]' \
'Default: latest stable release (2.1.2 or newer), installed for this user.' \
'--check: resolve and validate release metadata without installing.' \
'--uninstall: remove only this installer’s application and launchers; keep your data.'
return 0 ;;
--check) check_only=1 ;;
--uninstall) action=uninstall ;;
--version=*) requested=${arg#--version=} ;;
*) printf 'Unknown argument: %s\n' "$arg" >&2; return 1 ;;
esac
done
fail() { printf 'QBZ: %s\n' "$*" >&2; exit 1; }
if [ "$action" = uninstall ] && { [ "$check_only" = 1 ] || [ -n "$requested" ]; }; then
fail '--uninstall cannot be combined with --check or --version.'
fi
[ "${EUID:-$(id -u)}" -ne 0 ] || fail 'Run as your normal user, without sudo.'
[ -n "${HOME:-}" ] && [ "${HOME#/}" != "$HOME" ] && [ "$HOME" != / ] || fail 'HOME must be an absolute user directory.'
# Desktop Exec and shell launchers cannot safely represent control characters.
case "$HOME" in *$'\n'*|*$'\r'*|*$'\t'*) fail 'HOME contains an unsupported control character.' ;; esac
local system arch platform install_dir destination receipt launcher desktop icon
system=$(uname -s)
arch=$(uname -m)
case "$arch" in x86_64|amd64) arch=x86_64 ;; aarch64|arm64) arch=aarch64 ;; *) fail "Unsupported architecture: $arch" ;; esac
case "$system" in
Linux)
local libc libc_major libc_minor floor=35
[ "$arch" != aarch64 ] || floor=39
libc=$(getconf GNU_LIBC_VERSION 2>/dev/null) || fail 'The official Linux package requires glibc (musl systems are not supported).'
libc=${libc#glibc }
IFS=. read -r libc_major libc_minor <<< "$libc"
[[ "$libc_major" =~ ^[0-9]+$ ]] && [[ "$libc_minor" =~ ^[0-9]+$ ]] || fail 'Could not determine the glibc version.'
(( libc_major > 2 || (libc_major == 2 && libc_minor >= floor) )) || fail "This architecture requires glibc 2.$floor or newer. Use your distribution's package instead."
platform="linux-$arch"
install_dir="$HOME/.local/opt/qbz"
destination="$install_dir/QBZ.AppImage"
receipt="$install_dir/.qbz-installer"
launcher="$HOME/.local/bin/qbz"
desktop="$HOME/.local/share/applications/com.blitzfc.qbz.desktop"
icon="$install_dir/qbz.png" ;;
Darwin)
# Prefer a native bundle even when invoked from a Rosetta terminal.
if [ "$(/usr/sbin/sysctl -n hw.optional.arm64 2>/dev/null || :)" = 1 ]; then arch=aarch64; fi
platform="darwin-$arch"
install_dir="$HOME/Applications"
destination="$install_dir/QBZ.app"
receipt="$install_dir/.qbz-installer"
launcher=''; desktop=''; icon='' ;;
*) fail "Unsupported operating system: $system" ;;
esac
[ ! -L "$install_dir" ] && [ ! -L "$destination" ] && [ ! -L "$receipt" ] || fail 'Installation path is a symlink; manage it through its original installer.'
if [ "$action" = uninstall ]; then
if command -v pgrep >/dev/null 2>&1 && pgrep -x qbz >/dev/null 2>&1; then fail 'Close QBZ before uninstalling it.'; fi
[ -f "$receipt" ] && [ "$(cat "$receipt")" = 'qbz-one-line-v1' ] || fail 'This installation is not owned by this script.'
if [ "$system" = Linux ]; then
for arg in "$launcher" "$desktop"; do
if [ -f "$arg" ] && [ ! -L "$arg" ] && grep -q '^# QBZ one-line installer$' "$arg"; then rm -- "$arg"; fi
done
rm -f -- "$destination" "$icon" "$receipt"
rmdir "$install_dir" 2>/dev/null || :
else
[ -d "$destination" ] || fail 'QBZ.app is missing.'
/usr/bin/codesign -d --verbose=2 "$destination" 2>&1 | grep -q '^Signature=adhoc$' || fail 'The macOS signing channel changed; remove the app through its current installer.'
rm -rf -- "$destination"
rm -- "$receipt"
fi
printf '%s\n' 'QBZ removed. Your settings, library and downloads were kept.'
return 0
fi
if [ "$check_only" = 0 ]; then
if [ -e "$destination" ]; then
if [ -f "$receipt" ] && [ "$(cat "$receipt")" = 'qbz-one-line-v1' ]; then
printf '%s\n' 'QBZ is already installed. Use Settings → Updates in QBZ.'
return 0
fi
fail "An existing installation occupies $destination. Use its original updater."
fi
if [ "$system" = Linux ]; then
for arg in "$launcher" "$desktop"; do
[ ! -e "$arg" ] && [ ! -L "$arg" ] || fail "Existing launcher preserved: $arg"
done
fi
# A second install would shadow a distribution package or signed Mac app.
if command -v qbz >/dev/null 2>&1; then fail 'QBZ is already on PATH. Update it through its original installer.'; fi
if [ "$system" = Darwin ] && [ -e /Applications/QBZ.app ]; then fail 'QBZ already exists in /Applications. Keep its original update channel.'; fi
if [ "$system" = Linux ]; then
if command -v flatpak >/dev/null 2>&1 && flatpak info com.blitzfc.qbz >/dev/null 2>&1; then fail 'QBZ is installed with Flatpak. Update it through Flatpak.'; fi
if command -v snap >/dev/null 2>&1 && snap list qbz-player >/dev/null 2>&1; then fail 'QBZ is installed with Snap. Snap manages its updates.'; fi
fi
fi
command -v curl >/dev/null 2>&1 || fail 'curl is required.'
if ! command -v python3 >/dev/null 2>&1 && ! command -v jq >/dev/null 2>&1 && [ "$system" != Darwin ]; then
fail 'Install python3 or jq with your package manager, then run this command again.'
fi
local scratch
scratch=$(mktemp -d "${TMPDIR:-/tmp}/qbz-install.XXXXXXXX")
# A private scratch directory is the only recursively cleaned temporary path.
trap 'rm -rf -- "$scratch"' EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
fetch() { curl --proto '=https' --proto-redir '=https' --tlsv1.2 --fail --location --silent --show-error --connect-timeout 10 --max-time 600 --max-filesize 2147483648 --retry 2 --output "$2" "$1"; }
local api='https://api.github.com/repos/vicrodh/qbz/releases/latest'
if [ -n "$requested" ]; then
[[ "$requested" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || fail '--version must be a stable X.Y.Z version.'
api="https://api.github.com/repos/vicrodh/qbz/releases/tags/v$requested"
fi
printf '%s\n' 'Finding the latest stable QBZ package...'
fetch "$api" "$scratch/release.json" || fail 'Cannot read the release. Check your connection or GitHub rate limit.'
# Parse JSON as data. No eval, shell interpolation or regex JSON parsing.
json_get() {
# macOS ships JXA. Prefer it to Apple's python3 stub, which can trigger
# a Command Line Tools installation on an otherwise stock machine.
if [ "$system" = Darwin ] && [ -x /usr/bin/osascript ]; then
/usr/bin/osascript -l JavaScript - "$scratch/release.json" "$1" <<'JS'
ObjC.import('Foundation');
function run(args) {
const data = $.NSData.dataWithContentsOfFile(args[0]);
let value = JSON.parse(ObjC.unwrap($.NSString.alloc.initWithDataEncoding(data, $.NSUTF8StringEncoding)));
args[1].split('.').forEach(function (key) { value = value[key]; });
if (Array.isArray(value)) return String(value.length);
if (['string', 'boolean', 'number'].indexOf(typeof value) < 0) throw Error('Unsupported release field');
return String(value);
}
JS
elif command -v python3 >/dev/null 2>&1; then
python3 - "$scratch/release.json" "$1" <<'PY'
import json,sys
v=json.load(open(sys.argv[1]))
for key in sys.argv[2].split('.'): v=v[int(key)] if isinstance(v,list) else v[key]
if isinstance(v,list): print(len(v))
elif isinstance(v,bool): print(str(v).lower())
elif isinstance(v,(str,int)): print(v)
else: raise SystemExit('Unsupported release field')
PY
else
jq -er --arg path "$1" 'getpath($path | split(".") | map(tonumber? // .)) | if type == "array" then length elif type == "boolean" then tostring else . end' "$scratch/release.json"
fi
}
local tag version major minor patch filename url='' digest='' size='' count i name
tag=$(json_get tag_name)
[[ "$tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] || fail 'The release is not a stable version.'
[ "$(json_get draft)" = false ] && [ "$(json_get prerelease)" = false ] || fail 'Pre-release installation is not supported.'
version=${tag#v}
IFS=. read -r major minor patch <<< "$version"
if (( 10#$major < 2 || (10#$major == 2 && 10#$minor < 1) || (10#$major == 2 && 10#$minor == 1 && 10#$patch < 2) )); then
fail 'The one-line installer requires QBZ 2.1.2 or newer. It will become available with that release.'
fi
[ -z "$requested" ] || [ "$version" = "$requested" ] || fail 'The response does not match the requested version.'
case "$platform" in
linux-x86_64) filename="QBZ_${version}_amd64.AppImage" ;;
linux-aarch64) filename="QBZ_${version}_aarch64.AppImage" ;;
darwin-x86_64) filename='QBZ_x64.app.tar.gz' ;;
darwin-aarch64) filename='QBZ_aarch64.app.tar.gz' ;;
esac
count=$(json_get assets)
[[ "$count" =~ ^[0-9]+$ ]] && [ "$count" -le 1000 ] || fail 'Invalid asset count.'
for ((i=0; i<count; i++)); do
name=$(json_get "assets.$i.name")
if [ "$name" = "$filename" ]; then
[ -z "$url" ] || fail 'The release contains duplicate packages.'
url=$(json_get "assets.$i.browser_download_url")
digest=$(json_get "assets.$i.digest")
size=$(json_get "assets.$i.size")
fi
done
[ "$url" = "https://github.com/vicrodh/qbz/releases/download/$tag/$filename" ] || fail "The official $platform package is not available yet. Try again after its release build finishes."
[[ "$digest" =~ ^sha256:[0-9a-f]{64}$ ]] || fail 'The release has no SHA-256 digest. Refusing an unchecked download.'
[[ "$size" =~ ^[0-9]+$ ]] && [ "$size" -gt 0 ] && [ "$size" -le 2147483648 ] || fail 'Invalid package size.'
printf 'QBZ %s · %s\nDestination: %s\n' "$version" "$platform" "$destination"
if [ "$check_only" = 1 ]; then printf 'Package: %s\nSHA-256: %s\n' "$url" "${digest#sha256:}"; return 0; fi
fetch "$url" "$scratch/package" || fail 'Download failed. No installation was changed.'
local actual
if command -v sha256sum >/dev/null 2>&1; then actual=$(sha256sum "$scratch/package"); else actual=$(shasum -a 256 "$scratch/package"); fi
[ "${actual%% *}" = "${digest#sha256:}" ] || fail 'Package checksum mismatch. No installation was changed.'
[ "$(wc -c < "$scratch/package" | tr -d ' ')" = "$size" ] || fail 'Package size mismatch.'
mkdir -p "$install_dir"
mkdir "$install_dir/.qbz-setup.lock" 2>/dev/null || fail 'Another installer is active (or left .qbz-setup.lock after interruption).'
trap 'rm -rf -- "$scratch"; rmdir "$install_dir/.qbz-setup.lock"' EXIT
[ ! -e "$destination" ] && [ ! -L "$destination" ] || fail 'An installation appeared while downloading. It was preserved.'
local stage
stage=$(mktemp -d "$install_dir/.qbz-setup.XXXXXXXX")
local committed=0 published=("$stage")
trap 'if [ "$committed" = 0 ]; then for arg in "${published[@]}"; do rm -rf -- "$arg"; done; fi; rm -rf -- "$scratch" "$stage"; rmdir "$install_dir/.qbz-setup.lock"' EXIT
if [ "$system" = Linux ]; then
for arg in "$launcher" "$desktop"; do
[ ! -e "$arg" ] && [ ! -L "$arg" ] || fail "A launcher appeared while downloading: $arg"
done
[ "$(od -An -tx1 -N4 "$scratch/package" | tr -d ' \n')" = 7f454c46 ] || fail 'The package is not an ELF executable.'
[ "$(od -An -tx1 -j8 -N3 "$scratch/package" | tr -d ' \n')" = 414902 ] || fail 'The package is not a type-2 AppImage.'
cp "$scratch/package" "$stage/QBZ.AppImage"
chmod 755 "$stage/QBZ.AppImage"
# The logo is data from the same website that hosts this script.
fetch 'https://qbz.lol/assets/brand/64x64.png' "$stage/qbz.png" || fail 'Could not download the application icon.'
mkdir -p "$HOME/.local/bin" "$HOME/.local/share/applications"
{
printf '%s\n' '#!/usr/bin/env bash' '# QBZ one-line installer'
# Extract-and-run works without FUSE and preserves APPIMAGE for the updater.
printf 'export APPIMAGE_EXTRACT_AND_RUN=1\nexec %q "$@"\n' "$destination"
} > "$stage/launcher"
chmod 755 "$stage/launcher"
local desktop_exec desktop_icon
desktop_exec=${launcher//\\/\\\\}; desktop_exec=${desktop_exec//\"/\\\"}; desktop_exec=${desktop_exec//\$/\\$}; desktop_exec=${desktop_exec//\`/\\\`}; desktop_exec=${desktop_exec//%/%%}
# Desktop Entry string escaping happens before Exec argument unquoting.
desktop_exec=${desktop_exec//\\/\\\\}
desktop_icon=${icon//\\/\\\\}
printf '# QBZ one-line installer\n[Desktop Entry]\nType=Application\nName=QBZ\nComment=Qobuz hi-res player\nExec="%s" %%U\nIcon=%s\nTerminal=false\nCategories=AudioVideo;Audio;Player;\nStartupWMClass=qbz\n' "$desktop_exec" "$desktop_icon" > "$stage/desktop"
chmod 644 "$stage/desktop" "$stage/qbz.png"
# Publish the executable last. All downloads and verification precede this.
mv "$stage/qbz.png" "$icon"
published+=("$icon")
mv "$stage/launcher" "$launcher"
published+=("$launcher")
mv "$stage/desktop" "$desktop"
published+=("$desktop")
mv "$stage/QBZ.AppImage" "$destination"
published+=("$destination")
else
mkdir "$stage/unpacked"
tar -tzf "$scratch/package" > "$stage/contents"
while IFS= read -r name; do
case "$name" in QBZ.app|QBZ.app/*) ;; *) fail 'The archive contains files outside QBZ.app.' ;; esac
case "/$name/" in */../*) fail 'The archive contains a parent path.' ;; esac
done < "$stage/contents"
tar -xzf "$scratch/package" -C "$stage/unpacked"
[ -f "$stage/unpacked/QBZ.app/Contents/MacOS/qbz" ] && [ -f "$stage/unpacked/QBZ.app/Contents/Info.plist" ] || fail 'The archive is not a QBZ app bundle.'
/usr/bin/codesign --verify --deep --strict "$stage/unpacked/QBZ.app" || fail 'The app bundle failed code-signature validation.'
mv "$stage/unpacked/QBZ.app" "$destination"
published+=("$destination")
fi
printf '%s\n' 'qbz-one-line-v1' > "$receipt"
committed=1
printf '%s\n' 'Installed. Open QBZ from your applications menu.' 'Future updates: Settings → Updates in QBZ.'
if [ "$system" = Linux ]; then
case ":$PATH:" in *":$HOME/.local/bin:"*) ;; *) printf 'For terminal launches, add %s to PATH. The menu launcher already works.\n' "$HOME/.local/bin" ;; esac
fi
)
qbz_install_main "$@"
+24
View File
@@ -14,6 +14,7 @@ Item {
modal.kioskHost = false
QbzAbout.checkCalls = 0
QbzAbout.installCalls = 0
QbzAbout.cancelCalls = 0
QbzAbout.updatesJson = JSON.stringify({open:false, phase:"idle", currentVersion:"2.1.1"})
}
function test_manual_check_action() {
@@ -57,5 +58,28 @@ Item {
QbzAbout.updatesJson = JSON.stringify({open:true, phase:"current"})
verify(modal.statusText().indexOf("latest version") >= 0)
}
function test_flatpak_channel_update_without_github_version() {
QbzAbout.updatesJson = JSON.stringify({open:true, phase:"available", version:"", canInstall:true, installMethod:"Flatpak"})
var install = findChild(modal, "updateInstallButton")
verify(install.visible)
verify(!findChild(modal, "updateReleaseButton").visible)
verify(waitForRendering(modal))
mouseClick(install)
compare(QbzAbout.installCalls, 1)
QbzAbout.updatesJson = JSON.stringify({open:true, phase:"updating", busy:true, percent:42})
var cancel = findChild(modal, "updateCancelButton")
verify(cancel.visible)
verify(waitForRendering(modal))
mouseClick(cancel)
compare(QbzAbout.cancelCalls, 1)
}
function test_msi_staged_is_not_reported_as_installed() {
QbzAbout.updatesJson = JSON.stringify({open:true, phase:"prepared", busy:false, canInstall:false})
verify(modal.statusText().indexOf("finish installation") >= 0)
verify(findChild(modal, "updateCloseButton").enabled)
verify(!findChild(modal, "updateInstallButton").visible)
modal.dismiss()
verify(!modal.visible)
}
}
}
+150
View File
@@ -0,0 +1,150 @@
#!/usr/bin/env python3
"""Exercise the shipped shell installer in disposable homes, with no live installs."""
import hashlib
import json
import os
from pathlib import Path
import shutil
import subprocess
import sys
import tempfile
import unittest
SCRIPT = Path(__file__).resolve().with_name('install.sh')
class InstallerTest(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory(prefix='qbz-installer-test-')
self.root = Path(self.tmp.name)
self.home = self.root / 'home space $cash 100% "quoted" `ticks`'
self.home.mkdir()
self.bin = self.root / 'bin'
self.bin.mkdir()
for cmd in ('bash','id','python3','mktemp','rm','rmdir','grep','cat','tr',
'mkdir','cp','chmod','mv','od','wc','sha256sum','getconf'):
(self.bin / cmd).symlink_to(shutil.which(cmd))
(self.bin/'uname').write_text('#!/usr/bin/env bash\nif [ "$1" = -s ]; then printf "%s\\n" "${FIXTURE_OS:-Linux}"; else printf "%s\\n" "${FIXTURE_ARCH:-x86_64}"; fi\n')
(self.bin/'uname').chmod(0o755)
self.payload = b'\x7fELF' + b'\x00'*4 + b'AI\x02' + b'fixture only, never executed'
(self.root/'package').write_bytes(self.payload)
self.asset = {
'name':'QBZ_2.1.2_amd64.AppImage',
'browser_download_url':'https://github.com/vicrodh/qbz/releases/download/v2.1.2/QBZ_2.1.2_amd64.AppImage',
'digest':'sha256:'+hashlib.sha256(self.payload).hexdigest(),
'size':len(self.payload),
}
self.release = {'tag_name':'v2.1.2','draft':False,'prerelease':False,'assets':[self.asset]}
# Only curl is replaced. Real bash, JSON parser, hashes and filesystem
# operations exercise the production script, including spaces/$/% in HOME.
(self.bin/'curl').write_text('#!'+sys.executable+'''
import os,sys,pathlib,shutil
args=sys.argv[1:]; root=pathlib.Path(os.environ['INSTALLER_FIXTURE'])
out=pathlib.Path(args[args.index('--output')+1]); url=args[-1]
with (root/'requests').open('a') as f: f.write(url+'\\n')
if url.startswith('https://api.github.com/repos/vicrodh/qbz/releases/'):
shutil.copyfile(root/'release.json',out)
elif url=='https://github.com/vicrodh/qbz/releases/download/v2.1.2/QBZ_2.1.2_amd64.AppImage':
shutil.copyfile(root/'package',out)
elif url=='https://qbz.lol/assets/brand/64x64.png': out.write_bytes(b'icon fixture')
else: raise SystemExit('Unexpected URL: '+url)
''')
(self.bin/'curl').chmod(0o755)
self.env = dict(os.environ, HOME=str(self.home), PATH=str(self.bin),
TMPDIR=str(self.root), INSTALLER_FIXTURE=str(self.root))
self.env.pop('BASH_ENV', None)
self.dest = self.home/'.local/opt/qbz/QBZ.AppImage'
def tearDown(self):
self.tmp.cleanup()
def run_installer(self,*args):
(self.root/'release.json').write_text(json.dumps(self.release))
return subprocess.run([shutil.which('bash'),str(SCRIPT),*args],env=self.env,text=True,capture_output=True,timeout=20)
def test_install_verify_launchers_repeat_and_uninstall_keep_data(self):
data=self.home/'.local/share/qbz/personal.db'
data.parent.mkdir(parents=True); data.write_text('keep')
r=self.run_installer(); self.assertEqual(r.returncode,0,r.stderr)
self.assertEqual(self.dest.read_bytes(),self.payload)
launcher=self.home/'.local/bin/qbz'
self.assertIn('APPIMAGE_EXTRACT_AND_RUN=1',launcher.read_text())
subprocess.run(['bash','-n',str(launcher)],check=True)
self.assertIn('100%%',(self.home/'.local/share/applications/com.blitzfc.qbz.desktop').read_text())
if shutil.which('desktop-file-validate'):
subprocess.run(['desktop-file-validate',str(self.home/'.local/share/applications/com.blitzfc.qbz.desktop')],check=True)
self.assertFalse(list(self.root.glob('qbz-install.*')))
requests=(self.root/'requests').read_text()
r=self.run_installer(); self.assertEqual(r.returncode,0,r.stderr)
self.assertEqual((self.root/'requests').read_text(),requests)
r=self.run_installer('--uninstall'); self.assertEqual(r.returncode,0,r.stderr)
self.assertFalse(self.dest.exists()); self.assertFalse(launcher.exists())
self.assertEqual(data.read_text(),'keep')
def test_hash_failure_never_creates_installation(self):
self.asset['digest']='sha256:'+'0'*64
r=self.run_installer(); self.assertNotEqual(r.returncode,0)
self.assertIn('checksum mismatch',r.stderr)
self.assertFalse(self.dest.exists())
def test_existing_app_and_launcher_are_preserved(self):
self.dest.parent.mkdir(parents=True); self.dest.write_text('existing')
self.assertNotEqual(self.run_installer().returncode,0)
self.assertEqual(self.dest.read_text(),'existing')
self.dest.unlink()
launcher=self.home/'.local/bin/qbz'; launcher.parent.mkdir(parents=True)
launcher.write_text('existing launcher')
self.assertNotEqual(self.run_installer().returncode,0)
self.assertEqual(launcher.read_text(),'existing launcher')
def test_symlink_installation_is_not_followed(self):
outside=self.root/'outside'; outside.mkdir()
self.dest.parent.parent.mkdir(parents=True)
self.dest.parent.symlink_to(outside,target_is_directory=True)
self.assertNotEqual(self.run_installer().returncode,0)
self.assertEqual(list(outside.iterdir()),[])
def test_check_is_read_only_and_rejects_missing_digest_or_foreign_url(self):
r=self.run_installer('--check'); self.assertEqual(r.returncode,0,r.stderr)
self.assertFalse(self.dest.parent.exists())
self.asset['digest']=''
self.assertNotEqual(self.run_installer('--check').returncode,0)
self.asset['digest']='sha256:'+'0'*64
self.asset['browser_download_url']='https://example.org/payload'
self.assertNotEqual(self.run_installer('--check').returncode,0)
def test_older_release_and_prerelease_never_install(self):
self.release['tag_name']='v2.1.1'
r=self.run_installer(); self.assertNotEqual(r.returncode,0)
self.assertIn('2.1.2 or newer',r.stderr)
self.release['tag_name']='v2.1.2'; self.release['prerelease']=True
self.assertNotEqual(self.run_installer().returncode,0)
def test_all_four_platform_assets_are_selected(self):
for system,arch,name in [('Linux','x86_64','QBZ_2.1.2_amd64.AppImage'),
('Linux','aarch64','QBZ_2.1.2_aarch64.AppImage'),
('Darwin','x86_64','QBZ_x64.app.tar.gz'),
('Darwin','arm64','QBZ_aarch64.app.tar.gz')]:
with self.subTest(system=system,arch=arch):
self.env.update(FIXTURE_OS=system,FIXTURE_ARCH=arch)
self.asset['name']=name
self.asset['browser_download_url']='https://github.com/vicrodh/qbz/releases/download/v2.1.2/'+name
r=self.run_installer('--check')
self.assertEqual(r.returncode,0,r.stderr)
self.assertIn(name,r.stdout)
def test_check_cannot_accidentally_uninstall(self):
r=self.run_installer('--check','--uninstall')
self.assertNotEqual(r.returncode,0)
self.assertIn('cannot be combined',r.stderr)
@unittest.skipUnless(shutil.which('jq'), 'jq is required for its parser fallback test')
def test_jq_parser_works_without_python_on_path(self):
(self.bin/'python3').unlink()
(self.bin/'jq').symlink_to(shutil.which('jq'))
r=self.run_installer()
self.assertEqual(r.returncode,0,r.stderr)
self.assertEqual(self.dest.read_bytes(),self.payload)
if __name__=='__main__': unittest.main()