@@ -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<void> migrateLegacyDesktopDatabase({
|
||||
required File target,
|
||||
File? sourceOverride,
|
||||
Future<void> 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);
|
||||
}
|
||||
}
|
||||
|
||||
+15
-1
@@ -1083,7 +1083,21 @@ class _SetupScreenState extends State<SetupScreen> 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<ConnectionRegistry>();
|
||||
final allConnections = await connectionRegistry.list();
|
||||
final List<Connection> 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) {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user