Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions lib/core/updater/app_updater_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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, {
Expand Down
8 changes: 8 additions & 0 deletions lib/core/updater/installers/linux_appimage_installer.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
109 changes: 106 additions & 3 deletions lib/core/updater/installers/update_install_context.dart
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,22 @@ class UpdateInstallContext {
const UpdateInstallContext({
required this.environment,
required this.resolvedExecutable,
this.fileExists,
this.listDirectory,
this.isDirectoryWritable,
});

final Map<String, String> environment;
final String resolvedExecutable;

/// File-system probes, replaceable in tests; default to the real disk.
final bool Function(String path)? fileExists;
final List<String> 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),
Expand All @@ -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<String> _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 {
Expand Down
27 changes: 20 additions & 7 deletions lib/core/updater/installers/update_install_utils.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
10 changes: 6 additions & 4 deletions lib/core/updater/update_platform_installer.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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,
);
});
}
Expand Down
22 changes: 18 additions & 4 deletions lib/features/updater/update_dialog.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -437,7 +438,8 @@ class UpdateDialogContentState extends material.State<UpdateDialogContent> {
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.',
Expand Down Expand Up @@ -480,8 +482,10 @@ class UpdateDialogContentState extends material.State<UpdateDialogContent> {
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),
],
Expand Down Expand Up @@ -549,12 +553,22 @@ class UpdateDialogContentState extends material.State<UpdateDialogContent> {
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',
Expand Down
77 changes: 77 additions & 0 deletions test/core/updater/update_platform_installer_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<PackageManagerUpdateRequiredException>()
.having((e) => e.manager, 'manager', 'pacman')),
);
}, skip: !Platform.isLinux);

test('finds macOS .app bundle from executable path', () {
expect(
UpdateInstallContext.macAppBundlePathFromExecutable(
Expand All @@ -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 {
Expand Down
Loading