Skip to content
Merged
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 docs/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,10 @@ Credentials never reach the model: tools return only connection id, name, type,

**Audit:** every call is logged (Preferences → MCP Server → Recent calls).

## Local database, updates and downgrades

`querya.db` carries a schema version. Before an in-app update the file (and its `-wal`) is copied to `querya.db.pre-<from>-<to>.bak`, the newest three copies are kept, and a failed copy asks before the update goes on (#1497). On open, a version **newer than the build knows** raises `LocalDbNewerSchemaException`: nothing is migrated, nothing is written, and the file keeps its version number (without this, sqflite would stamp the older number on it and an older build would carry on with tables it does not understand). The startup recovery screen shows the message. Restoring the pre-update copy is the supported way back; see the [user guide](user-guide.md#updates-betas-and-going-back).

## Tests

Automated tests use an **in-memory** secrets backend (see `test/flutter_test_config.dart`) so CI does not require a desktop keyring.
Expand Down
6 changes: 6 additions & 0 deletions docs/user-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,12 @@ Command Palette (`Ctrl+P`) → **Export Team Profile…** writes a `team-profile
- **Connection list and settings**: local SQLite (`querya.db` under the app support directory).
- **Passwords / connection strings**: OS secure store (see [security.md](security.md)).

### Updates, betas and going back

- **Release channel** (Preferences → General): *Stable* gets the releases marked **Latest** on GitHub; *Beta / Early Access* also gets pre-releases (`0.5.0-beta.2`) and always the highest version. A beta is replaced by the final release of the same version (`0.5.0-beta.2` → `0.5.0`). The channel only applies to the portable zip / AppImage, the Windows setup and the macOS app; a Snap, Flatpak, AUR, `.deb` or `.rpm` install is updated by its package manager, stable only, and the row is shown as such.
- **Before an in-app update** Querya copies `querya.db` next to itself as `querya.db.pre-<from>-<to>.bak` (the newest 3 are kept). If the copy fails you are asked whether to install anyway.
- **Going back to an older version is not supported once a newer one has run.** A beta may change the format of the local data. An older build that finds data written by a newer one **refuses to open it and does not change it**: the startup screen names the newer format. To go back, close Querya, replace `querya.db` with the `.bak` made before the update (and delete any `querya.db-wal` / `-shm` next to it), then start the older version. Changes made since that copy are lost.

## Preferences

Open **Edit → Preferences…** to change options that apply across the app:
Expand Down
53 changes: 53 additions & 0 deletions lib/core/storage/local_db.dart
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import 'package:querya_desktop/core/security/connection_environment.dart';
import 'package:querya_desktop/core/security/ssh_tunnel_config.dart';
import 'package:querya_desktop/core/storage/app_data_root.dart';
import 'package:querya_desktop/core/storage/connection_secrets_store.dart';
import 'package:querya_desktop/core/storage/update_backup.dart';
import 'package:sqflite_common_ffi/sqflite_ffi.dart';

const _dbName = 'querya.db';
Expand Down Expand Up @@ -40,6 +41,36 @@ String? _normalizeHistoryDatabaseName(String? databaseName) {
return t;
}

/// The local database was written by a newer Querya Desktop: its schema
/// version is above the one this build knows (#1497).
///
/// Opening it would have let sqflite write the older version number into it
/// and carry on with tables it does not understand. The open is refused
/// instead, inside the open transaction, so nothing in the file changes.
class LocalDbNewerSchemaException implements Exception {
const LocalDbNewerSchemaException({
required this.found,
required this.supported,
required this.path,
});

/// Schema version stored in the file.
final int found;

/// Highest schema version this build reads.
final int supported;

final String path;

@override
String toString() =>
'The local data in $path was written by a newer version of Querya '
'Desktop (data format $found; this version reads up to $supported). '
'It has not been changed. Install the newer version again, or restore '
'a copy made before the update (querya.db.pre-<from>-<to>.bak in the '
'same folder).';
}

/// Local SQLite database for folders and connections.
/// File: [applicationSupport]/querya_desktop/querya.db
class LocalDb {
Expand Down Expand Up @@ -84,6 +115,13 @@ class LocalDb {
version: _dbVersion,
onCreate: _onCreate,
onUpgrade: _onUpgrade,
// A newer schema: refuse, so sqflite does not stamp the old version
// on it (the open transaction rolls back).
onDowngrade: (db, found, supported) => throw LocalDbNewerSchemaException(
found: found,
supported: supported,
path: _cachedDbPath!,
),
onOpen: (db) async {
await db.execute('PRAGMA foreign_keys = ON');
await db.execute('PRAGMA journal_mode = WAL');
Expand Down Expand Up @@ -916,6 +954,21 @@ class LocalDb {
}
}

/// Copies `querya.db` to `querya.db.pre-<from>-<to>.bak` before an in-app
/// update and keeps the newest [UpdateBackup.keep] copies (#1497). Closes
/// the database first; the next query opens it again.
Future<String> backupBeforeUpdate({
required String fromVersion,
required String toVersion,
}) async {
await close();
return UpdateBackup.create(
dbPath: await databasePath(),
fromVersion: fromVersion,
toVersion: toVersion,
);
}

/// Creates a timestamped backup copy of `querya.db` (and any sidecar files)
/// in the same directory and returns the backup file path.
Future<String> backupDatabaseFile() async {
Expand Down
72 changes: 72 additions & 0 deletions lib/core/storage/update_backup.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import 'dart:io';

import 'package:path/path.dart' as p;

/// Copies of the local database made before an in-app update (#1497).
///
/// An update can bring a newer database schema, and a later downgrade (a
/// beta promoted to an older stable, or an older build installed by hand)
/// cannot read it. The copy next to the database is the way back.
abstract final class UpdateBackup {
/// How many pre-update copies are kept; older ones are deleted.
static const keep = 3;

/// `querya.db.pre-<from>-<to>.bak` next to [dbPath].
static String pathFor(String dbPath, String fromVersion, String toVersion) {
String safe(String v) => v.replaceAll(RegExp(r'[^0-9A-Za-z.+-]'), '_');
return '$dbPath.pre-${safe(fromVersion)}-${safe(toVersion)}.bak';
}

/// Copies [dbPath] (and its `-wal`, if any) to [pathFor] and deletes all but
/// the newest [keep] copies. The database must be closed. Returns the copy.
static Future<String> create({
required String dbPath,
required String fromVersion,
required String toVersion,
int keep = UpdateBackup.keep,
}) async {
final db = File(dbPath);
if (!await db.exists()) {
throw FileSystemException('Database file does not exist', dbPath);
}
final backup = pathFor(dbPath, fromVersion, toVersion);
await db.copy(backup);
final wal = File('$dbPath-wal');
final backupWal = File('$backup-wal');
if (await wal.exists()) {
await wal.copy(backupWal.path);
} else if (await backupWal.exists()) {
await backupWal.delete(); // from an earlier copy with the same name
}
// A copy made just now is the newest even when the clock is coarse.
await File(backup).setLastModified(DateTime.now());
await prune(dbPath, keep: keep);
return backup;
}

/// The pre-update copies of [dbPath], newest first.
static Future<List<File>> list(String dbPath) async {
final dir = Directory(p.dirname(dbPath));
final prefix = '${p.basename(dbPath)}.pre-';
final copies = <File>[];
await for (final e in dir.list(followLinks: false)) {
final name = p.basename(e.path);
if (e is File && name.startsWith(prefix) && name.endsWith('.bak')) {
copies.add(e);
}
}
final stamps = {for (final f in copies) f.path: await f.lastModified()};
copies.sort((a, b) => stamps[b.path]!.compareTo(stamps[a.path]!));
return copies;
}

/// Deletes all but the newest [keep] copies, with their `-wal` files.
static Future<void> prune(String dbPath, {int keep = UpdateBackup.keep}) async {
final copies = await list(dbPath);
for (final old in copies.skip(keep)) {
await old.delete();
final wal = File('${old.path}-wal');
if (await wal.exists()) await wal.delete();
}
}
}
6 changes: 5 additions & 1 deletion lib/core/updater/app_updater_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,11 @@ class AppUpdaterService {
throw AppUpdaterException('Invalid current app version: $currentRaw');
}

final channel = await _settings.getUpdateChannel();
// A package install only ever gets stable releases from its package
// manager, so it is not told about betas it cannot install (#1497).
final channel = UpdateInstallContext.currentIsPackageManagerInstall
? UpdateChannel.stable
: await _settings.getUpdateChannel();
final manifest = await _releasesClient.fetchLatest(channel: channel);
final candidateVersion = UpdateVersion.tryParse(manifest.version);
if (candidateVersion == null) {
Expand Down
16 changes: 16 additions & 0 deletions lib/core/updater/installers/update_install_context.dart
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import 'dart:io';

import 'package:flutter/foundation.dart' show visibleForTesting;
import 'package:path/path.dart' as p;
import 'package:querya_desktop/core/platform/macos_sandbox.dart';

Expand Down Expand Up @@ -114,6 +115,21 @@ class UpdateInstallContext {
return null;
}

/// Installed by a package manager (Snap, Flatpak, AUR, `.deb`, `.rpm`):
/// it brings the updates, and they are always stable releases, so the
/// Release channel has no effect there (#1497).
bool get isPackageManagerInstall =>
isSnap || isFlatpak || linuxSystemPackageManager != null;

/// Forces [currentIsPackageManagerInstall] in tests; null detects.
@visibleForTesting
static bool? debugPackageManagerInstall;

/// [isPackageManagerInstall] for this process.
static bool get currentIsPackageManagerInstall =>
debugPackageManagerInstall ??
UpdateInstallContext.current().isPackageManagerInstall;

bool get isManagedPackage =>
isSnap ||
isFlatpak ||
Expand Down
15 changes: 12 additions & 3 deletions lib/features/settings/preferences_dialog.dart
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import 'package:flutter/services.dart' show LogicalKeyboardKey;
import 'package:querya_desktop/core/layout/ui_scale.dart';
import 'package:querya_desktop/core/layout/window_layout.dart';
import 'package:querya_desktop/core/storage/app_settings.dart';
import 'package:querya_desktop/core/updater/installers/update_install_context.dart';
import 'package:querya_desktop/core/updater/update_manifest.dart';
import 'package:querya_desktop/features/settings/preferences_about_section.dart';
import 'package:querya_desktop/features/settings/preferences_appearance_section.dart';
Expand Down Expand Up @@ -576,11 +577,19 @@ class PreferencesDialogContentState
),
const material.SizedBox(height: 16),
PreferencesFieldRow(
key: const material.ValueKey('release_channel_row'),
label: 'Release channel',
hint:
'Stable receives thoroughly tested monthly updates. Beta receives early releases with experimental features.',
// On a package install the channel does nothing: the package
// manager brings the updates, stable only (#1497).
hint: UpdateInstallContext.currentIsPackageManagerInstall
? 'Updates come from your package manager (stable releases '
'only), so the release channel does not apply to this install.'
: 'Stable receives thoroughly tested monthly updates. Beta receives early releases with experimental features.',
control: PreferencesDropdownMenu<UpdateChannel>(
value: _updateChannel,
enabled: !UpdateInstallContext.currentIsPackageManagerInstall,
value: UpdateInstallContext.currentIsPackageManagerInstall
? UpdateChannel.stable
: _updateChannel,
onSelected: (v) {
if (v != null) unawaited(_setUpdateChannel(v));
},
Expand Down
78 changes: 78 additions & 0 deletions lib/features/updater/update_dialog.dart
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ 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/storage/local_db.dart';
import 'package:querya_desktop/core/unsaved_work_registry.dart';
import 'package:querya_desktop/core/updater/app_updater_service.dart';
import 'package:querya_desktop/core/updater/sha256_checksums.dart';
Expand Down Expand Up @@ -87,6 +88,59 @@ Future<bool?> showUnsavedUpdateRestartDialog(material.BuildContext context) {
);
}

/// The local database could not be copied before the update (#1497): install
/// anyway, or cancel.
Future<bool?> showUpdateBackupFailedDialog(
material.BuildContext context,
String error,
) {
return showAppDialog<bool>(
context: context,
builder: (ctx) {
return QueryaDialogCard(
constraints: const material.BoxConstraints(maxWidth: 440),
child: material.Padding(
padding: const material.EdgeInsets.all(20),
child: material.Column(
mainAxisSize: material.MainAxisSize.min,
crossAxisAlignment: material.CrossAxisAlignment.start,
children: [
const Text('Could not back up local data').semiBold().large(),
const Gap(8),
Text(
'Querya Desktop copies its local data (connections, history, '
'settings) before an update, so you can go back if the new '
'version changes it. The copy failed: $error',
).muted().small(),
const Gap(20),
material.Align(
alignment: material.Alignment.centerRight,
child: material.Wrap(
alignment: material.WrapAlignment.end,
spacing: 8,
runSpacing: 8,
children: [
QueryaButton.ghost(
label: 'Cancel',
onPressed: () => material.Navigator.of(ctx).pop(false),
size: QueryaControlSize.lg,
),
QueryaButton.destructive(
label: 'Install without a copy',
onPressed: () => material.Navigator.of(ctx).pop(true),
size: QueryaControlSize.lg,
),
],
),
),
],
),
),
);
},
);
}

/// Update check / download / install pane. Test hooks are optional overrides.
class UpdateDialogContent extends material.StatefulWidget {
const UpdateDialogContent({
Expand All @@ -100,6 +154,7 @@ class UpdateDialogContent extends material.StatefulWidget {
this.selectAsset,
this.download,
this.install,
this.backupLocalData,
this.confirmRestartIfUnsaved,
this.showCopyToast = true,
});
Expand All @@ -114,6 +169,11 @@ class UpdateDialogContent extends material.StatefulWidget {
final Future<File> Function(UpdateAsset asset, UpdateManifest manifest)?
download;
final Future<void> Function(File file)? install;

/// Copies the local database before installing; defaults to
/// [LocalDb.backupBeforeUpdate].
final Future<void> Function(String fromVersion, String toVersion)?
backupLocalData;
final Future<bool?> Function(material.BuildContext context)?
confirmRestartIfUnsaved;
final bool showCopyToast;
Expand Down Expand Up @@ -271,6 +331,24 @@ class UpdateDialogContentState extends material.State<UpdateDialogContent> {
if (!mounted || confirmed != true) return;
}

// A copy of the local database first: the new version may change its
// schema, and an older one cannot read it back (#1497).
final from = _currentVersion.isEmpty ? 'unknown' : _currentVersion;
final to = _manifest?.version ?? 'unknown';
// A test that replaces [install] and not this hook skips the copy.
final backup = widget.backupLocalData ??
(widget.install == null
? (String f, String t) =>
LocalDb.instance.backupBeforeUpdate(fromVersion: f, toVersion: t)
: null);
try {
if (backup != null) await backup(from, to);
} catch (e) {
if (!mounted) return;
final proceed = await showUpdateBackupFailedDialog(context, '$e');
if (!mounted || proceed != true) return;
}

try {
if (widget.install != null) {
await widget.install!(file);
Expand Down
Loading
Loading