From b1d027b0202acde039281b4607bcca693a59da71 Mon Sep 17 00:00:00 2001 From: Fabian Freund Date: Sun, 22 Feb 2026 15:01:47 +0100 Subject: [PATCH] heal profile after migration --- app/lib/core/filesystem.dart | 222 +++++++++++++++++- .../user/domain/services/user_backup.dart | 3 + 2 files changed, 214 insertions(+), 11 deletions(-) diff --git a/app/lib/core/filesystem.dart b/app/lib/core/filesystem.dart index be5ac31b..1ce4ffbf 100644 --- a/app/lib/core/filesystem.dart +++ b/app/lib/core/filesystem.dart @@ -18,6 +18,7 @@ * along with this program. If not, see . */ import 'dart:async'; +import 'dart:convert'; import 'dart:io'; import 'package:path/path.dart' as p; @@ -73,13 +74,14 @@ class _Filesystem { return fs.getMozillaProfileIds(getProfileDir(uuid)); } - /// If the old canonical location `{profile}/mozilla/` exists as a real - /// directory and the new location `{profile}/files/mozilla/` does not, + /// If the old canonical location `{profileDir}/mozilla/` exists as a real + /// directory and the new location `{profileDir}/files/mozilla/` does not, /// rename the former to the latter. - Future _migrateMozillaDirToFiles() async { - final oldDir = Directory(p.join(selectedProfileDir.path, 'mozilla')); + /// Returns `true` if a migration was performed. + static Future _migrateMozillaDirToFiles(Directory profileDir) async { + final oldDir = Directory(p.join(profileDir.path, 'mozilla')); final newDir = Directory( - p.join(selectedProfileDir.path, 'files', 'mozilla'), + p.join(profileDir.path, 'files', 'mozilla'), ); final oldType = await FileSystemEntity.type( @@ -89,20 +91,22 @@ class _Filesystem { if (oldType == FileSystemEntityType.directory && !await newDir.exists()) { await Directory( - p.join(selectedProfileDir.path, 'files'), + p.join(profileDir.path, 'files'), ).create(recursive: true); await oldDir.rename(newDir.path); + return true; } + return false; } - Future _migrateGeckoCache() async { - final profileIds = fs.getMozillaProfileIds(selectedProfileDir); + Future _migrateGeckoCache(Directory profileDir) async { + final profileIds = fs.getMozillaProfileIds(profileDir); final globalCacheDir = Directory(p.join(dataDir.path, 'cache')); for (final profileId in profileIds) { final oldCache = Directory(p.join(globalCacheDir.path, profileId)); final newCache = Directory( - p.join(selectedProfileDir.path, 'cache', profileId), + p.join(profileDir.path, 'cache', profileId), ); if (await oldCache.exists() && !await newCache.exists()) { @@ -119,6 +123,203 @@ class _Filesystem { } } + /// Ensure the top-level `{filesDir}/mozilla` symlink points to the given + /// profile's `files/mozilla/` directory. Old versions created this symlink + /// targeting `{profile}/mozilla` which no longer exists after the migration + /// moved it to `{profile}/files/mozilla`. GeckoView's `extensions.json` + /// stores absolute paths through this symlink, so it must stay valid. + static Future _linkMozillaDir( + Directory filesDir, + Directory profileDir, + ) async { + final mozillaDir = Directory( + p.join(profileDir.path, 'files', 'mozilla'), + ); + await mozillaDir.create(recursive: true); + + final mozillaPath = p.join(filesDir.path, 'mozilla'); + + final currentType = await FileSystemEntity.type( + mozillaPath, + followLinks: false, + ); + + switch (currentType) { + case FileSystemEntityType.notFound: + break; + case FileSystemEntityType.link: + final link = Link(mozillaPath); + try { + if (await link.target() == mozillaDir.path) { + return; + } + } on FileSystemException { + // Replace unreadable or broken links. + } + await link.delete(); + default: + // Move aside any non-link entity (directory, file, etc.) + final backupPath = p.join( + filesDir.path, + 'mozilla.backup.${DateTime.now().millisecondsSinceEpoch}', + ); + if (currentType == FileSystemEntityType.directory) { + await Directory(mozillaPath).rename(backupPath); + } else { + await File(mozillaPath).rename(backupPath); + } + } + + await Link(mozillaPath).create(mozillaDir.path); + } + + /// Remove path-sensitive Gecko caches that may contain stale absolute paths. + /// Gecko regenerates these on next startup. + static Future _healGeckoStartupCaches(Directory profileDir) async { + final profileIds = fs.getMozillaProfileIds(profileDir); + + for (final profileId in profileIds) { + final mozProfileDir = Directory( + p.join(profileDir.path, 'files', 'mozilla', profileId), + ); + if (!await mozProfileDir.exists()) continue; + + // addonStartup.json.lz4 caches absolute addon paths + final addonStartup = File( + p.join(mozProfileDir.path, 'addonStartup.json.lz4'), + ); + if (await addonStartup.exists()) { + try { + await addonStartup.delete(); + logger.i('Cleared addonStartup.json.lz4 for $profileId'); + } catch (e, s) { + logger.w( + 'Failed to clear addonStartup.json.lz4 for $profileId', + error: e, + stackTrace: s, + ); + } + } + + // startupCache may contain stale path references + final startupCache = Directory( + p.join(mozProfileDir.path, 'startupCache'), + ); + if (await startupCache.exists()) { + try { + await startupCache.delete(recursive: true); + logger.i('Cleared startupCache for $profileId'); + } catch (e, s) { + logger.w( + 'Failed to clear startupCache for $profileId', + error: e, + stackTrace: s, + ); + } + } + } + } + + /// Rewrite absolute paths in `extensions.json` that still reference the + /// old pre-migration layout (`{filesDir}/mozilla/…`). After migration the + /// real location is `{filesDir}/weblibre_profiles/profile-…/files/mozilla/…` + /// and the top-level symlink bridges the two, but fixing the stored paths + /// removes the permanent dependency on the symlink. + /// Returns `true` if any paths were rewritten. + static Future _migrateExtensionPaths( + Directory filesDir, + Directory profileDir, + ) async { + final profileIds = fs.getMozillaProfileIds(profileDir); + // Pattern: /data/user/0/eu.weblibre.gecko/files/mozilla/ + // Should become: /data/user/0/eu.weblibre.gecko/files/weblibre_profiles/profile-…/files/mozilla/ + final oldPrefix = '${filesDir.path}/mozilla/'; + final newPrefix = '${profileDir.path}/files/mozilla/'; + + if (oldPrefix == newPrefix) return false; + + var migrated = false; + + for (final profileId in profileIds) { + final extensionsFile = File( + p.join( + profileDir.path, + 'files', + 'mozilla', + profileId, + 'extensions.json', + ), + ); + + if (!await extensionsFile.exists()) continue; + + try { + final content = await extensionsFile.readAsString(); + if (!content.contains(oldPrefix)) continue; + + final json = jsonDecode(content); + var changed = false; + + if (json is Map && json['addons'] is List) { + for (final addon in json['addons'] as List) { + if (addon is! Map) continue; + + final path = addon['path']; + if (path is String && path.startsWith(oldPrefix)) { + addon['path'] = path.replaceFirst(oldPrefix, newPrefix); + changed = true; + } + + final rootURI = addon['rootURI']; + if (rootURI is String && rootURI.contains(oldPrefix)) { + addon['rootURI'] = rootURI.replaceFirst(oldPrefix, newPrefix); + changed = true; + } + } + } + + if (changed) { + await extensionsFile.writeAsString( + jsonEncode(json), + flush: true, + ); + logger.i('Migrated extension paths in $profileId/extensions.json'); + migrated = true; + } + } catch (e, s) { + logger.w( + 'Failed to migrate extension paths for $profileId', + error: e, + stackTrace: s, + ); + } + } + + return migrated; + } + + /// Run all post-migration healing for a profile directory. + /// Called during init for the active profile and after backup restore. + Future healProfile(Directory profileDir) async { + final filesDir = profilesDir.parent; + final dirMigrated = await _migrateMozillaDirToFiles(profileDir); + await _migrateGeckoCache(profileDir); + final pathsMigrated = await _migrateExtensionPaths(filesDir, profileDir); + + // Only update the global symlink for the currently active profile — + // restoring a non-active profile must not repoint it. + final isActiveProfile = profileDir.path == selectedProfileDir.path; + if (isActiveProfile) { + await _linkMozillaDir(filesDir, profileDir); + } + + // Only clear Gecko startup caches when a path migration actually happened, + // to avoid a recurring startup performance penalty. + if (dirMigrated || pathsMigrated) { + await _healGeckoStartupCaches(profileDir); + } + } + Future _setupSqliteCache() async { // Make sqlite3 pick a more suitable location for temporary files - the // one from the system may be inaccessible due to sandboxing. @@ -194,8 +395,7 @@ class _Filesystem { ); await profileDatabasesDir.create(); - await _migrateMozillaDirToFiles(); - await _migrateGeckoCache(); + await healProfile(selectedProfileDir); await _setupSqliteCache(); } diff --git a/app/lib/features/user/domain/services/user_backup.dart b/app/lib/features/user/domain/services/user_backup.dart index f18b1a57..3e8f3eb3 100644 --- a/app/lib/features/user/domain/services/user_backup.dart +++ b/app/lib/features/user/domain/services/user_backup.dart @@ -108,6 +108,7 @@ class UserBackupService extends _$UserBackupService { await outputDirectory.rename(newPath.path); await filesystem.updateProfileMetadata(newProfile); + await filesystem.healProfile(newPath); }); ref.invalidate(profileRepositoryProvider); @@ -168,10 +169,12 @@ class UserBackupService extends _$UserBackupService { if (result == true) { await profileDir.delete(recursive: true); await outputDirectory.rename(profileDir.path); + await filesystem.healProfile(profileDir); } } else { // Profile doesn't exist yet, just move the restored data into place await outputDirectory.rename(profileDir.path); + await filesystem.healProfile(profileDir); } });