diff --git a/lib/database/app_database.dart b/lib/database/app_database.dart index 274b9043..b25629b9 100644 --- a/lib/database/app_database.dart +++ b/lib/database/app_database.dart @@ -570,11 +570,7 @@ LazyDatabase _openConnection() { // Migrate from old location on desktop (was in Documents subfolder) if (!Platform.isAndroid && !Platform.isIOS && !await file.exists()) { - final oldFolder = await getApplicationDocumentsDirectory(); - final oldFile = File(p.join(oldFolder.path, 'plezy_downloads.db')); - if (await oldFile.exists()) { - await oldFile.rename(file.path); - } + await migrateLegacyDesktopDatabase(target: file); } return NativeDatabase.createInBackground( @@ -589,3 +585,56 @@ LazyDatabase _openConnection() { ); }); } + +/// Move the legacy desktop DB from `Documents/` to `ApplicationSupport/`. +/// `File.rename` only works within a single volume — Windows users with +/// OneDrive-redirected Documents (or any cross-drive setup) hit +/// `ERROR_NOT_SAME_DEVICE` (errno 17), and the uncaught throw used to +/// strand the splash on "Loading servers..." forever (#1022). Falls back +/// to copy + delete on any [FileSystemException] and swallows all errors +/// so a failed migration never propagates fatally. +/// +/// [sourceOverride] and [renameOverride] are test seams — production +/// callers leave them null. +Future migrateLegacyDesktopDatabase({ + required File target, + File? sourceOverride, + Future Function(File source, String targetPath)? renameOverride, +}) async { + final File oldFile; + if (sourceOverride != null) { + oldFile = sourceOverride; + } else { + final oldFolder = await getApplicationDocumentsDirectory(); + oldFile = File(p.join(oldFolder.path, 'plezy_downloads.db')); + } + if (!await oldFile.exists()) return; + + try { + if (renameOverride != null) { + await renameOverride(oldFile, target.path); + } else { + await oldFile.rename(target.path); + } + appLogger.i('Moved legacy DB from ${oldFile.path} → ${target.path}'); + return; + } on FileSystemException catch (e) { + appLogger.w('Legacy DB rename failed (osError=${e.osError?.errorCode}); falling back to copy', error: e); + } + + try { + await oldFile.copy(target.path); + try { + await oldFile.delete(); + } catch (e) { + // Leaving the source behind is non-fatal — the new file is canonical. + appLogger.w('Legacy DB copied but old file delete failed: $e'); + } + appLogger.i('Copied legacy DB from ${oldFile.path} → ${target.path}'); + } catch (e, st) { + // Copy itself failed (disk full, source locked by OneDrive sync, + // permissions). Leave both files alone — drift will create a fresh + // empty DB at the new location, and a future relaunch can retry. + appLogger.e('Legacy DB migration failed entirely', error: e, stackTrace: st); + } +} diff --git a/lib/main.dart b/lib/main.dart index 315ca6d1..7834e685 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1083,7 +1083,21 @@ class _SetupScreenState extends State with MountedSetStateMixin { // through `context` after async gaps trip the use_build_context_synchronously // lint, and reading early is safe because the registry is a singleton. final connectionRegistry = context.read(); - final allConnections = await connectionRegistry.list(); + final List allConnections; + try { + allConnections = await connectionRegistry.list(); + } catch (e, st) { + // Defence-in-depth: a DB-open failure here used to propagate + // uncaught and strand the splash forever (#1022). Route to auth so + // the user is never trapped, and surface to Sentry so an unknown + // regression doesn't go silent. + appLogger.e('Setup: failed to load connections; returning to auth', error: e, stackTrace: st); + unawaited(Sentry.captureException(e, stackTrace: st)); + if (mounted) { + unawaited(Navigator.pushReplacement(context, fadeRoute(const AuthScreen()))); + } + return; + } if (allConnections.isEmpty) { if (mounted) { diff --git a/test/database/app_database_test.dart b/test/database/app_database_test.dart index 716007bb..b9eddb04 100644 --- a/test/database/app_database_test.dart +++ b/test/database/app_database_test.dart @@ -137,6 +137,103 @@ class _AppDatabaseTestSuite { } }); }); + + _registerLegacyDesktopMigrationTests(); + } + + void _registerLegacyDesktopMigrationTests() { + // ============================================================ + // Legacy desktop DB-file relocation (Documents → AppSupport). + // Regression coverage for #1022: cross-drive rename (e.g. OneDrive + // Documents on X:, AppData on C:) used to throw an uncaught + // FileSystemException out of _openConnection and strand the splash. + // ============================================================ + + group('legacy desktop DB migration', () { + late Directory tempDir; + + setUp(() async { + tempDir = await Directory.systemTemp.createTemp('plezy_legacy_migration_test_'); + }); + + tearDown(() async { + if (await tempDir.exists()) { + await tempDir.delete(recursive: true); + } + }); + + test('no-op when source does not exist', () async { + final source = File('${tempDir.path}/Documents/plezy_downloads.db'); + final target = File('${tempDir.path}/AppData/plezy_downloads.db'); + await target.parent.create(recursive: true); + + await migrateLegacyDesktopDatabase(sourceOverride: source, target: target); + + expect(await source.exists(), isFalse); + expect(await target.exists(), isFalse); + }); + + test('rename happy path moves the file and preserves content', () async { + final source = File('${tempDir.path}/Documents/plezy_downloads.db'); + final target = File('${tempDir.path}/AppData/plezy_downloads.db'); + await source.parent.create(recursive: true); + await target.parent.create(recursive: true); + await source.writeAsBytes([1, 2, 3, 4, 5]); + + await migrateLegacyDesktopDatabase(sourceOverride: source, target: target); + + expect(await source.exists(), isFalse); + expect(await target.exists(), isTrue); + expect(await target.readAsBytes(), [1, 2, 3, 4, 5]); + }); + + test('cross-drive rename failure falls back to copy + delete', () async { + // Simulate Windows ERROR_NOT_SAME_DEVICE by throwing the same + // exception shape `File.rename` would emit when source and target + // live on different volumes. + final source = File('${tempDir.path}/Documents/plezy_downloads.db'); + final target = File('${tempDir.path}/AppData/plezy_downloads.db'); + await source.parent.create(recursive: true); + await target.parent.create(recursive: true); + await source.writeAsBytes([9, 8, 7]); + + await migrateLegacyDesktopDatabase( + sourceOverride: source, + target: target, + renameOverride: (_, _) => throw const FileSystemException( + 'Cannot rename file across drives', + '', + OSError('The system cannot move the file to a different disk drive', 17), + ), + ); + + expect(await source.exists(), isFalse, reason: 'source should be deleted after successful copy'); + expect(await target.exists(), isTrue); + expect(await target.readAsBytes(), [9, 8, 7]); + }); + + test('copy failure leaves source intact and never throws', () async { + final source = File('${tempDir.path}/Documents/plezy_downloads.db'); + // Point target at a non-existent directory so copy fails. The + // helper must swallow the error — splash boot must never see it. + final target = File('${tempDir.path}/does-not-exist/AppData/plezy_downloads.db'); + await source.parent.create(recursive: true); + await source.writeAsBytes([0xAA, 0xBB]); + + await expectLater( + migrateLegacyDesktopDatabase( + sourceOverride: source, + target: target, + renameOverride: (_, _) => throw const FileSystemException('cross-drive', ''), + ), + completes, + ); + + expect(await source.exists(), isTrue, reason: 'source should be preserved when copy fails'); + expect(await source.readAsBytes(), [0xAA, 0xBB]); + expect(await target.exists(), isFalse); + }); + }); } void _registerApiCacheTests() {