From 0e6125fa08dcd4d6e3cffe5a95c8c982aac4214e Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sun, 11 Oct 2026 20:04:40 +0300 Subject: [PATCH] fix(updater): package installs (AUR, deb, rpm) are updated outside the app The AUR, .deb and .rpm packages put the bundle in /opt/querya-desktop, owned by root. The in-app updater treated it as a portable bundle: it downloaded the zip, quit the app and ran a replace script that could not write there, so the update failed and nothing started again (seen going from 0.4.18 to 0.4.19 with the AUR package). - UpdateInstallContext detects the owning package manager (pacman, dpkg, rpm) for /opt/querya-desktop and any bundle folder the user cannot write; both count as managed, so the dialog offers the update command (or the release page) instead of Download. - The zip installer refuses such a target before the app quits. - The replace script checks that the target is writable and, on any failure, logs the reason and starts the app again. --- CHANGELOG.md | 4 + lib/core/updater/app_updater_service.dart | 4 + .../installers/linux_appimage_installer.dart | 8 ++ .../installers/update_install_context.dart | 109 +++++++++++++++++- .../installers/update_install_utils.dart | 27 +++-- .../updater/update_platform_installer.dart | 10 +- lib/features/updater/update_dialog.dart | 22 +++- .../update_platform_installer_test.dart | 77 +++++++++++++ 8 files changed, 243 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e309798..8b50708a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- **In-app update of a package install (AUR, `.deb`, `.rpm`)** — the updater no longer downloads a zip and quits when the app lives in `/opt/querya-desktop` (owned by root): the update dialog says how to update it (`yay -Syu querya-desktop`, `apt`, `dnf`) with a *Copy update command* button, or offers the release page for any other folder the user cannot write. If an in-place replace fails anyway, the app is started again and the reason is logged to `~/.local/state/querya-update.log`, instead of staying closed. + Everything merged to `dev` since 0.4.18: the NoSQL workbench, SSH tunnelling and Production safeguards, the ER diagram, charts and plans, the MCP server, a rebuilt SQL workspace toolbar, a UI kit, and a round of session and connection correctness fixes. ### Added diff --git a/lib/core/updater/app_updater_service.dart b/lib/core/updater/app_updater_service.dart index b5fe0c42..fc462096 100644 --- a/lib/core/updater/app_updater_service.dart +++ b/lib/core/updater/app_updater_service.dart @@ -202,6 +202,10 @@ class AppUpdaterService { return UpdateInstallContext.current().packageManagerUpdateCommand; } + /// Why in-app install is blocked and what to run instead. + String get packageManagerUpdateHint => + UpdateInstallContext.current().managedUpdateHint; + /// Picks the best Release asset for the current packaging context. UpdateAsset? platformAssetFor( UpdateManifest manifest, { diff --git a/lib/core/updater/installers/linux_appimage_installer.dart b/lib/core/updater/installers/linux_appimage_installer.dart index 2f6bc2c1..6fb9ef1a 100644 --- a/lib/core/updater/installers/linux_appimage_installer.dart +++ b/lib/core/updater/installers/linux_appimage_installer.dart @@ -55,6 +55,14 @@ class LinuxAppImageInstaller { 'Could not determine the Linux install directory for in-place update', ); } + // Checked before the app quits: a replace that cannot write leaves the + // user with no running app. + if (context.isManagedPackage) { + throw PackageManagerUpdateRequiredException( + manager: context.linuxSystemPackageManager ?? 'system', + hint: context.managedUpdateHint, + ); + } final executable = context.resolvedExecutable; final sourceDir = resolveLinuxFlutterBundleRoot( diff --git a/lib/core/updater/installers/update_install_context.dart b/lib/core/updater/installers/update_install_context.dart index 54d51a64..dd29552f 100644 --- a/lib/core/updater/installers/update_install_context.dart +++ b/lib/core/updater/installers/update_install_context.dart @@ -9,11 +9,22 @@ class UpdateInstallContext { const UpdateInstallContext({ required this.environment, required this.resolvedExecutable, + this.fileExists, + this.listDirectory, + this.isDirectoryWritable, }); final Map environment; final String resolvedExecutable; + /// File-system probes, replaceable in tests; default to the real disk. + final bool Function(String path)? fileExists; + final List Function(String dir)? listDirectory; + final bool Function(String dir)? isDirectoryWritable; + + /// Where the `.deb`, `.rpm` and AUR packages put the bundle. + static const linuxSystemBundleRoot = '/opt/querya-desktop'; + factory UpdateInstallContext.current() { return UpdateInstallContext( environment: Map.unmodifiable(Platform.environment), @@ -35,13 +46,105 @@ class UpdateInstallContext { bool get isFlatpak => Platform.isLinux && environment.containsKey('FLATPAK_ID'); - bool get isManagedPackage => isSnap || isFlatpak; + /// The system package manager that owns the running bundle: `pacman` (AUR), + /// `dpkg` (`.deb`) or `rpm` (`.rpm`); null for a portable bundle, an + /// AppImage, Snap or Flatpak. + /// + /// Those packages install the bundle into [linuxSystemBundleRoot], owned by + /// root: an in-place update cannot write there, quits the app and leaves it + /// not running (seen with the AUR package, 0.4.18 -> 0.4.19). + String? get linuxSystemPackageManager { + if (!Platform.isLinux || isSnap || isFlatpak || isLinuxAppImage) { + return null; + } + if (linuxBundleRoot != linuxSystemBundleRoot) return null; + final exists = fileExists ?? (path) => File(path).existsSync(); + final list = listDirectory ?? _listDirectory; + if (list('/var/lib/pacman/local') + .any((name) => name.startsWith('querya-desktop-'))) { + return 'pacman'; + } + if (exists('/var/lib/dpkg/info/querya-desktop.list')) return 'dpkg'; + if (exists('/usr/bin/rpm') || exists('/bin/rpm')) return 'rpm'; + return null; + } - /// Command the user should run when [isManagedPackage] is true. + /// True when the Linux bundle directory exists but this user cannot write + /// it, whoever installed it: an in-place update would fail half-way. + bool get isLinuxBundleReadOnly { + if (!Platform.isLinux || isSnap || isFlatpak || isLinuxAppImage) { + return false; + } + final root = linuxBundleRoot; + if (root == null) return false; + return !(isDirectoryWritable ?? _isDirectoryWritable)(root); + } + + bool get isManagedPackage => + isSnap || + isFlatpak || + linuxSystemPackageManager != null || + isLinuxBundleReadOnly; + + /// Command the user should run when [isManagedPackage] is true; null when + /// there is no single command (a read-only bundle of unknown origin). String? get packageManagerUpdateCommand { if (isSnap) return 'snap refresh'; if (isFlatpak) return 'flatpak update'; - return null; + return switch (linuxSystemPackageManager) { + 'pacman' => 'yay -Syu querya-desktop', + 'dpkg' => 'sudo apt update && sudo apt install --only-upgrade querya-desktop', + 'rpm' => 'sudo dnf upgrade querya-desktop', + _ => null, + }; + } + + /// Why in-app install is blocked and what to do, for messages. + String get managedUpdateHint { + if (isSnap) { + return 'Updates for the Snap build must be installed with: snap refresh'; + } + if (isFlatpak) { + return 'Updates for the Flatpak build must be installed with: flatpak update'; + } + final command = packageManagerUpdateCommand; + final manager = switch (linuxSystemPackageManager) { + 'pacman' => 'the AUR package', + 'dpkg' => 'the .deb package', + 'rpm' => 'the .rpm package', + _ => null, + }; + if (manager != null && command != null) { + return 'Querya Desktop was installed from $manager; update it with: $command'; + } + return 'Querya Desktop is installed in ${linuxBundleRoot ?? 'a folder'}, ' + 'which this user cannot write. Update it the way it was installed, ' + 'or download the new version from the release page.'; + } + + static List _listDirectory(String dir) { + try { + return [ + for (final e in Directory(dir).listSync(followLinks: false)) + p.basename(e.path), + ]; + } on FileSystemException { + return const []; + } + } + + /// A missing directory counts as writable: nothing to protect, and tests + /// use made-up paths. + static bool _isDirectoryWritable(String dir) { + if (!Directory(dir).existsSync()) return true; + final probe = File(p.join(dir, '.querya-write-probe-$pid')); + try { + probe.writeAsStringSync(''); + probe.deleteSync(); + return true; + } on FileSystemException { + return false; + } } String? get linuxBundleRoot { diff --git a/lib/core/updater/installers/update_install_utils.dart b/lib/core/updater/installers/update_install_utils.dart index 70264f64..ff0489c1 100644 --- a/lib/core/updater/installers/update_install_utils.dart +++ b/lib/core/updater/installers/update_install_utils.dart @@ -153,20 +153,33 @@ SRC='${_shellQuote(sourceDir)}' DST='${_shellQuote(targetDir)}' EXE='${_shellQuote(executable)}' EXE_NAME=\$(basename "\$EXE") +LOG="\${XDG_STATE_HOME:-\$HOME/.local/state}/querya-update.log" +# Any failure is logged and the app is started again (the old bundle when the +# replace did not happen), instead of leaving it closed. +fail() { + mkdir -p "\$(dirname "\$LOG")" 2>/dev/null || true + echo "\$(date) querya-update: \$1" >> "\$LOG" 2>/dev/null || true + echo "querya-update: \$1" >&2 + if [ -x "\$EXE" ]; then exec "\$EXE"; fi + exit 1 +} while kill -0 "\$PID" 2>/dev/null; do sleep 0.2; done if [ ! -f "\$SRC/\$EXE_NAME" ]; then - echo "querya-update: refusing replace, \$SRC is not a Flutter bundle" >&2 - exit 1 + fail "refusing replace, \$SRC is not a Flutter bundle" fi if [ ! -d "\$SRC/lib" ] && [ ! -d "\$SRC/data" ]; then - echo "querya-update: refusing replace, missing lib/ or data/" >&2 - exit 1 + fail "refusing replace, missing lib/ or data/" +fi +if [ ! -w "\$DST" ]; then + fail "refusing replace, \$DST is not writable (installed by a package manager?)" fi if command -v rsync >/dev/null 2>&1; then - rsync -a --delete --exclude='QueryaData' --exclude='.portable' "\$SRC"/ "\$DST"/ + rsync -a --delete --exclude='QueryaData' --exclude='.portable' "\$SRC"/ "\$DST"/ \\ + || fail "rsync into \$DST failed" else - find "\$DST" -mindepth 1 -maxdepth 1 ! -name 'QueryaData' ! -name '.portable' -exec rm -rf {} + - cp -a "\$SRC"/. "\$DST"/ + find "\$DST" -mindepth 1 -maxdepth 1 ! -name 'QueryaData' ! -name '.portable' -exec rm -rf {} + \\ + || fail "could not remove the old bundle in \$DST" + cp -a "\$SRC"/. "\$DST"/ || fail "could not copy the new bundle into \$DST" fi chmod +x "\$EXE" 2>/dev/null || true rm -f "\$0" diff --git a/lib/core/updater/update_platform_installer.dart b/lib/core/updater/update_platform_installer.dart index d9acfcaf..5a783a93 100644 --- a/lib/core/updater/update_platform_installer.dart +++ b/lib/core/updater/update_platform_installer.dart @@ -24,10 +24,12 @@ class UpdatePlatformInstaller { if (ctx.isManagedPackage) { return UpdatePlatformInstaller._((file) async { throw PackageManagerUpdateRequiredException( - manager: ctx.isSnap ? 'snap' : 'flatpak', - hint: ctx.isSnap - ? 'Updates for the Snap build must be installed with: snap refresh' - : 'Updates for the Flatpak build must be installed with: flatpak update', + manager: ctx.isSnap + ? 'snap' + : ctx.isFlatpak + ? 'flatpak' + : ctx.linuxSystemPackageManager ?? 'system', + hint: ctx.managedUpdateHint, ); }); } diff --git a/lib/features/updater/update_dialog.dart b/lib/features/updater/update_dialog.dart index 7d912159..cde282ac 100644 --- a/lib/features/updater/update_dialog.dart +++ b/lib/features/updater/update_dialog.dart @@ -2,6 +2,7 @@ import 'dart:async' show unawaited; import 'dart:io'; import 'package:flutter/material.dart' as material; +import 'package:url_launcher/url_launcher_string.dart'; import 'package:flutter/services.dart'; import 'package:querya_desktop/core/layout/window_layout.dart'; import 'package:querya_desktop/core/unsaved_work_registry.dart'; @@ -437,7 +438,8 @@ class UpdateDialogContentState extends material.State { UpdateDialogPhase.upToDate => 'You are running the latest version of Querya Desktop (v$_currentVersion).', UpdateDialogPhase.available => _installBlocked - ? 'This build is managed by ${_packageCommand ?? 'the package manager'}.' + ? 'Querya Desktop v${_manifest?.version ?? ''} is available; ' + 'this install is updated outside the app.' : 'Querya Desktop v${_manifest?.version ?? ''} is available!', UpdateDialogPhase.downloading => 'Downloading update…', UpdateDialogPhase.readyToInstall => 'Update ready to install.', @@ -480,8 +482,10 @@ class UpdateDialogContentState extends material.State { children: [ if (_installBlocked && _phase == UpdateDialogPhase.available) ...[ Text( - 'Install updates with ${_packageCommand ?? 'the system package manager'} ' - 'instead of downloading a GitHub package.', + _packageCommand != null + ? 'Install updates with $_packageCommand ' + 'instead of downloading a GitHub package.' + : _updater.packageManagerUpdateHint, ).muted().small(), const material.SizedBox(height: 12), ], @@ -549,12 +553,22 @@ class UpdateDialogContentState extends material.State { size: QueryaControlSize.lg, ), const material.SizedBox(width: 8), - if (_installBlocked) + if (_installBlocked && _packageCommand != null) QueryaButton.primary( label: 'Copy update command', onPressed: () => unawaited(_copyPackageCommand()), size: QueryaControlSize.lg, ) + else if (_installBlocked) + QueryaButton.primary( + label: 'Open release page', + onPressed: () => unawaited(launchUrlString( + 'https://github.com/QueryaHub/Querya-Desktop/releases/tag/' + '${_manifest?.version ?? ''}', + mode: LaunchMode.externalApplication, + )), + size: QueryaControlSize.lg, + ) else QueryaButton.primary( label: 'Download', diff --git a/test/core/updater/update_platform_installer_test.dart b/test/core/updater/update_platform_installer_test.dart index 686000f6..2e1253e4 100644 --- a/test/core/updater/update_platform_installer_test.dart +++ b/test/core/updater/update_platform_installer_test.dart @@ -47,6 +47,80 @@ void main() { expect(docker.packageManagerUpdateCommand, isNull); }); + test('an AUR / pacman install under /opt is updated outside the app', + () { + final ctx = UpdateInstallContext( + environment: const {}, + resolvedExecutable: '/opt/querya-desktop/querya_desktop', + listDirectory: (dir) => dir == '/var/lib/pacman/local' + ? const ['glibc-2.40-1', 'querya-desktop-0.4.19-1'] + : const [], + fileExists: (_) => false, + isDirectoryWritable: (_) => false, + ); + expect(ctx.linuxSystemPackageManager, 'pacman'); + expect(ctx.isManagedPackage, isTrue); + expect(ctx.packageManagerUpdateCommand, 'yay -Syu querya-desktop'); + expect(ctx.managedUpdateHint, contains('AUR')); + }, skip: !Platform.isLinux); + + test('a .deb install is detected from the dpkg database', () { + final ctx = UpdateInstallContext( + environment: const {}, + resolvedExecutable: '/opt/querya-desktop/querya_desktop', + listDirectory: (_) => const [], + fileExists: (path) => path == '/var/lib/dpkg/info/querya-desktop.list', + isDirectoryWritable: (_) => false, + ); + expect(ctx.linuxSystemPackageManager, 'dpkg'); + expect(ctx.packageManagerUpdateCommand, contains('apt')); + }, skip: !Platform.isLinux); + + test('a portable bundle elsewhere is not blocked by an installed package', + () { + final ctx = UpdateInstallContext( + environment: const {}, + resolvedExecutable: '/home/me/Querya/querya_desktop', + listDirectory: (_) => const ['querya-desktop-0.4.19-1'], + fileExists: (_) => true, + isDirectoryWritable: (_) => true, + ); + expect(ctx.linuxSystemPackageManager, isNull); + expect(ctx.isManagedPackage, isFalse); + }, skip: !Platform.isLinux); + + test('a bundle the user cannot write is blocked, with a hint and no command', + () { + final ctx = UpdateInstallContext( + environment: const {}, + resolvedExecutable: '/srv/apps/querya/querya_desktop', + listDirectory: (_) => const [], + fileExists: (_) => false, + isDirectoryWritable: (dir) => dir != '/srv/apps/querya', + ); + expect(ctx.isLinuxBundleReadOnly, isTrue); + expect(ctx.isManagedPackage, isTrue); + expect(ctx.packageManagerUpdateCommand, isNull); + expect(ctx.managedUpdateHint, contains('/srv/apps/querya')); + }, skip: !Platform.isLinux); + + test('the installer refuses a system install before the app quits', + () async { + final ctx = UpdateInstallContext( + environment: const {}, + resolvedExecutable: '/opt/querya-desktop/querya_desktop', + listDirectory: (_) => const ['querya-desktop-0.4.19-1'], + fileExists: (_) => false, + isDirectoryWritable: (_) => false, + ); + final installer = UpdatePlatformInstaller.forCurrentPlatform(context: ctx); + await expectLater( + installer.install(File('/tmp/Querya-Desktop-0.4.20-linux.zip')), + throwsA(isA() + .having((e) => e.manager, 'manager', 'pacman')), + ); + }, skip: !Platform.isLinux); + test('finds macOS .app bundle from executable path', () { expect( UpdateInstallContext.macAppBundlePathFromExecutable( @@ -73,6 +147,9 @@ void main() { expect(script, contains("! -name 'QueryaData'")); expect(script, contains("! -name '.portable'")); expect(script, contains('exec "\$EXE"')); + // A replace that cannot write relaunches the old app and logs why. + expect(script, contains('[ ! -w "\$DST" ]')); + expect(script, contains('querya-update.log')); }); test('resolves nested Flutter zip layout and refuses garbage', () async {