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:
26 files changed
+1279
-19
No files matched your search
@@ -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
|
||||
|
||||
@@ -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' }}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Generated
+4
@@ -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 для завершения установки. Приложение откроется автоматически."
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"] }
|
||||
@@ -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;
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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())?
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
|
||||
Executable
+249
@@ -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 "$@"
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Executable
+150
@@ -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()
|
||||
Reference in new issue
Block a user