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
6 changes: 6 additions & 0 deletions lib/core/storage/app_data_root.dart
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,12 @@ abstract final class AppDataRoot {
@visibleForTesting
static List<Directory>? mockLegacySupportCandidates;

/// Points the portable data root at [path] for the rest of this session
/// (used by Safe Mode to run against a clean temporary profile).
static void setSafeModeRootPath(String path) {
mockPortableRootPath = path;
}

@visibleForTesting
static void resetMocks() {
mockPortableRootPath = null;
Expand Down
76 changes: 76 additions & 0 deletions lib/core/storage/local_db.dart
Original file line number Diff line number Diff line change
Expand Up @@ -878,6 +878,82 @@ class LocalDb {
await db.delete('connections', where: 'id = ?', whereArgs: [id]);
}

/// Resolves the absolute path to `querya.db`.
Future<String> databasePath() async {
if (_cachedDbPath != null) return _cachedDbPath!;
final dir = await AppDataRoot.applicationSupportDirectory();
final sub = Directory(p.join(dir.path, 'querya_desktop'));
if (!await sub.exists()) await sub.create(recursive: true);
_cachedDbPath = p.join(sub.path, _dbName);
return _cachedDbPath!;
}

/// Explicitly opens the database or throws if opening/migrating fails.
Future<Database> open() => _open();

/// Removes stale WAL, SHM, and lock sidecars (e.g. after a process crash or freeze).
Future<void> removeWalAndLockFiles() async {
await close();
final dbPath = await databasePath();
final walFile = File('$dbPath-wal');
final shmFile = File('$dbPath-shm');
final lockFile = File('$dbPath-lock');
if (await walFile.exists()) {
try {
await walFile.delete();
} catch (_) {}
}
if (await shmFile.exists()) {
try {
await shmFile.delete();
} catch (_) {}
}
if (await lockFile.exists()) {
try {
await lockFile.delete();
} catch (_) {}
}
}

/// 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 {
await close();
final dbPath = await databasePath();
final dbFile = File(dbPath);
if (!await dbFile.exists()) {
throw FileSystemException('Database file does not exist', dbPath);
}
final timestamp = DateTime.now().toIso8601String().replaceAll(':', '-');
final backupPath = '$dbPath.corrupted.$timestamp.bak';
await dbFile.copy(backupPath);

final walFile = File('$dbPath-wal');
if (await walFile.exists()) {
try {
await walFile.copy('$backupPath-wal');
} catch (_) {}
}
return backupPath;
}

/// Resets the database file by creating a backup and removing the corrupted db
/// so a fresh schema can be created on the next launch.
Future<void> resetDatabaseFile() async {
await close();
final dbPath = await databasePath();
final dbFile = File(dbPath);
if (await dbFile.exists()) {
try {
await backupDatabaseFile();
} catch (_) {}
try {
await dbFile.delete();
} catch (_) {}
}
await removeWalAndLockFiles();
}

Future<void> close() async {
_openFuture = null;
await _db?.close();
Expand Down
246 changes: 246 additions & 0 deletions lib/features/recovery/querya_startup_recovery_app.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,246 @@
import 'dart:io';

import 'package:flutter/material.dart' as material;
import 'package:querya_desktop/app/app.dart';
import 'package:querya_desktop/core/storage/app_data_root.dart';
import 'package:querya_desktop/core/storage/local_db.dart';
import 'package:querya_desktop/core/theme/theme_controller.dart';
import 'package:querya_desktop/core/ui/querya_icons.dart';
import 'package:querya_desktop/shared/widgets/widgets.dart';

/// Disaster recovery UI launched when LocalDb fails to open or migrate on startup.
/// Allows the user to inspect the error, retry, remove stale locks, backup the database,
/// or launch in Safe Mode.
class QueryaStartupRecoveryApp extends material.StatefulWidget {
const QueryaStartupRecoveryApp({
super.key,
required this.error,
this.stackTrace,
this.onRetrySuccess,
});

final Object error;
final StackTrace? stackTrace;
final material.VoidCallback? onRetrySuccess;

@override
material.State<QueryaStartupRecoveryApp> createState() =>
_QueryaStartupRecoveryAppState();
}

class _QueryaStartupRecoveryAppState
extends material.State<QueryaStartupRecoveryApp> {
bool _isBusy = false;
String? _statusMessage;
bool _isErrorStatus = false;

void _setStatus(String message, {bool isError = false}) {
setState(() {
_statusMessage = message;
_isErrorStatus = isError;
});
}

Future<void> _handleRetry() async {
setState(() => _isBusy = true);
_setStatus('Attempting to open database...');
try {
await LocalDb.instance.open();
_setStatus('Database opened successfully!');
if (mounted) {
if (widget.onRetrySuccess != null) {
widget.onRetrySuccess!();
} else {
material.runApp(const QueryaApp());
}
}
} catch (e) {
_setStatus('Retry failed: $e', isError: true);
} finally {
if (mounted) setState(() => _isBusy = false);
}
}

Future<void> _handleUnlockWal() async {
setState(() => _isBusy = true);
_setStatus('Removing WAL and lock files...');
try {
await LocalDb.instance.removeWalAndLockFiles();
_setStatus('Stale lock and WAL files removed. Testing database connection...');
await LocalDb.instance.open();
_setStatus('Database opened successfully!');
if (mounted) {
if (widget.onRetrySuccess != null) {
widget.onRetrySuccess!();
} else {
material.runApp(const QueryaApp());
}
}
} catch (e) {
_setStatus('Unlock attempted, but database failed to open: $e',
isError: true);
} finally {
if (mounted) setState(() => _isBusy = false);
}
}

Future<void> _handleBackup() async {
setState(() => _isBusy = true);
_setStatus('Creating timestamped backup...');
try {
final backupPath = await LocalDb.instance.backupDatabaseFile();
_setStatus('Backup saved to: $backupPath');
} catch (e) {
_setStatus('Backup failed: $e', isError: true);
} finally {
if (mounted) setState(() => _isBusy = false);
}
}

Future<void> _handleSafeMode() async {
setState(() => _isBusy = true);
_setStatus('Switching to clean safe profile...');
try {
await LocalDb.instance.close();
final tempDir = await Directory.systemTemp.createTemp('querya_safe_mode_');
AppDataRoot.setSafeModeRootPath(tempDir.path);
await LocalDb.instance.open();
_setStatus('Safe mode ready. Launching Querya...');
if (mounted) {
if (widget.onRetrySuccess != null) {
widget.onRetrySuccess!();
} else {
material.runApp(const QueryaApp());
}
}
} catch (e) {
_setStatus('Failed to enter safe mode: $e', isError: true);
} finally {
if (mounted) setState(() => _isBusy = false);
}
}

@override
material.Widget build(material.BuildContext context) {
final themeData = ThemeController.instance.darkShadcnTheme;
final colorScheme = themeData.colorScheme;

return ShadcnApp(
title: 'Querya - Startup Recovery',
theme: ThemeController.instance.darkShadcnTheme,
darkTheme: ThemeController.instance.darkShadcnTheme,
themeMode: ThemeMode.dark,
debugShowCheckedModeBanner: false,
home: material.Scaffold(
backgroundColor: colorScheme.background,
body: material.Center(
child: material.SingleChildScrollView(
padding: const material.EdgeInsets.all(32),
child: material.ConstrainedBox(
constraints: const material.BoxConstraints(maxWidth: 640),
child: QueryaDialogCard(
borderColor: colorScheme.destructive,
child: material.Padding(
padding: const material.EdgeInsets.all(28),
child: material.Column(
mainAxisSize: material.MainAxisSize.min,
crossAxisAlignment: material.CrossAxisAlignment.start,
children: [
material.Row(
children: [
material.Icon(
QueryaIcons.treeError,
size: 32,
color: colorScheme.destructive,
),
const material.SizedBox(width: 12),
const material.Expanded(
child: Text(
'Database Recovery',
),
),
],
),
const material.SizedBox(height: 16),
const Text(
'Querya could not open the profile database (querya.db) on startup. '
'This can happen after a crash, concurrent lock, file permission conflict, '
'or SQLite database corruption.',
).muted(),
const material.SizedBox(height: 16),
material.Container(
width: double.infinity,
padding: const material.EdgeInsets.all(12),
decoration: material.BoxDecoration(
color: colorScheme.muted.withOpacity(0.3),
borderRadius: material.BorderRadius.circular(6),
border: material.Border.all(
color: colorScheme.border,
),
),
child: material.SelectableText(
widget.error.toString(),
style: const material.TextStyle(
fontFamily: 'monospace',
fontSize: 12,
),
),
),
if (_statusMessage != null) ...[
const material.SizedBox(height: 16),
material.Container(
width: double.infinity,
padding: const material.EdgeInsets.all(10),
decoration: material.BoxDecoration(
color: (_isErrorStatus
? colorScheme.destructive
: colorScheme.primary)
.withOpacity(0.15),
borderRadius: material.BorderRadius.circular(6),
),
child: Text(
_statusMessage!,
style: material.TextStyle(
color: _isErrorStatus
? colorScheme.destructive
: colorScheme.primary,
fontSize: 13,
fontWeight: material.FontWeight.w500,
),
),
),
],
const material.SizedBox(height: 24),
material.Wrap(
spacing: 12,
runSpacing: 12,
children: [
PrimaryButton(
onPressed: _isBusy ? null : _handleRetry,
child: const Text('Retry Opening'),
),
OutlineButton(
onPressed: _isBusy ? null : _handleUnlockWal,
child: const Text('Unlock Database (Clear WAL)'),
),
OutlineButton(
onPressed: _isBusy ? null : _handleBackup,
child: const Text('Export / Backup querya.db'),
),
GhostButton(
onPressed: _isBusy ? null : _handleSafeMode,
child: const Text('Start in Safe Mode'),
),
],
),
],
),
),
),
),
),
),
),
);
}
}
30 changes: 22 additions & 8 deletions lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import 'core/platform/file_launch_service.dart';
import 'core/security/ssl_certificate_support.dart';
import 'core/storage/local_db.dart';
import 'core/theme/theme_controller.dart';
import 'features/recovery/querya_startup_recovery_app.dart';
import 'features/updater/update_controller.dart';

void main([List<String> args = const []]) async {
Expand All @@ -30,14 +31,27 @@ void main([List<String> args = const []]) async {

DisplayRefreshService.initialize();
await LocalDb.initFfi();
await SyntaxHighlightService.ensureInitialized();
await ThemeController.instance.load();
await UiScaleController.instance.load();
await QueryaMotionController.instance.load();
unawaited(UpdateController.instance.initialize());
unawaited(cleanupStaleMongoTlsTempFiles());
unawaited(McpServerController.instance.startIfEnabled());
runApp(const QueryaApp());

try {
await LocalDb.instance.open();
await SyntaxHighlightService.ensureInitialized();
await ThemeController.instance.load();
await UiScaleController.instance.load();
await QueryaMotionController.instance.load();
unawaited(UpdateController.instance.initialize());
unawaited(cleanupStaleMongoTlsTempFiles());
unawaited(McpServerController.instance.startIfEnabled());
runApp(const QueryaApp());
} catch (error, stack) {
debugPrint('Startup database initialization failed: $error\n$stack');
runApp(
QueryaStartupRecoveryApp(
error: error,
stackTrace: stack,
),
);
}

doWhenWindowReady(() {
final win = appWindow;
win.minSize = const Size(900, 600);
Expand Down
Loading
Loading