Skip to content
Draft
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
1 change: 1 addition & 0 deletions flatpak/com.cypherstack.campfire.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ finish-args:
- --socket=fallback-x11
- --socket=wayland
- --device=dri
# Retained for legacy-data migration.
- --filesystem=~/.campfire
- --talk-name=org.freedesktop.secrets
- --talk-name=org.freedesktop.Notifications
Expand Down
1 change: 1 addition & 0 deletions flatpak/com.cypherstack.stackduo.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ finish-args:
- --socket=fallback-x11
- --socket=wayland
- --device=dri
# Retained for legacy-data migration.
- --filesystem=~/.stackduo
- --talk-name=org.freedesktop.secrets
- --talk-name=org.freedesktop.Notifications
Expand Down
1 change: 1 addition & 0 deletions flatpak/com.cypherstack.stackwallet.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ finish-args:
- --socket=fallback-x11
- --socket=wayland
- --device=dri
# Retained for legacy-data migration.
- --filesystem=~/.stackwallet
- --talk-name=org.freedesktop.secrets
- --talk-name=org.freedesktop.Notifications
Expand Down
217 changes: 217 additions & 0 deletions lib/utilities/flatpak_data_directory.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
import 'dart:io';

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

typedef DirectoryCopy =
Future<void> Function(Directory source, Directory destination);

abstract class FlatpakDataDirectory {
static const migrationMarker = ".flatpak_data_migration_v1";

/// Copies legacy data before selecting Flatpak's app-private XDG root.
static Future<Directory?> resolve({
required Map<String, String> environment,
required String appDirectoryName,
DirectoryCopy copyDirectory = _copyDirectory,
void Function(Object error)? onError,
}) async {
if ((environment["FLATPAK_ID"] ?? "").isEmpty) {
return null;
}

final xdgDataHome = environment["XDG_DATA_HOME"];
if (xdgDataHome == null || xdgDataHome.isEmpty) {
return null;
}

final destination = Directory(path.join(xdgDataHome, appDirectoryName));
final home = environment["HOME"];
if (home == null || home.isEmpty) {
await _initialize(destination);
return destination;
}

final legacy = Directory(path.join(home, ".$appDirectoryName"));
final legacyType = await FileSystemEntity.type(
legacy.path,
followLinks: false,
);
if (legacyType == FileSystemEntityType.notFound) {
await _initialize(destination);
return destination;
}
if (legacyType != FileSystemEntityType.directory) {
onError?.call(
FileSystemException(
"Legacy Flatpak data is not a directory",
legacy.path,
),
);
return legacy;
}

final marker = File(path.join(destination.path, migrationMarker));
if (await marker.exists()) {
return destination;
}

if (await destination.exists()) {
onError?.call(
StateError(
"Flatpak data migration found both legacy and unverified data",
),
);
return legacy;
}

final temporary = Directory("${destination.path}.migrating");
RandomAccessFile? legacyLock;
try {
legacyLock = await _lockLegacyDatabase(legacy);
// Another instance may have completed a verified migration between the
// marker check above and taking the lock.
if (await marker.exists()) {
return destination;
}
final temporaryType = await FileSystemEntity.type(
temporary.path,
followLinks: false,
);
if (temporaryType == FileSystemEntityType.directory) {
await temporary.delete(recursive: true);
} else if (temporaryType != FileSystemEntityType.notFound) {
throw FileSystemException(
"Flatpak migration path is not a directory",
temporary.path,
);
}

await Directory(path.dirname(destination.path)).create(recursive: true);
await copyDirectory(legacy, temporary);
await _verifyDirectoryCopy(legacy, temporary);
await File(
path.join(temporary.path, migrationMarker),
).writeAsString("complete", flush: true);
await temporary.rename(destination.path);
return destination;
} catch (error) {
try {
if (await FileSystemEntity.type(temporary.path, followLinks: false) ==
FileSystemEntityType.directory) {
await temporary.delete(recursive: true);
}
} catch (cleanupError) {
onError?.call(cleanupError);
}
// The rename loses to another instance that finished a verified
// migration first; use that data rather than diverging onto legacy.
if (await marker.exists()) {
return destination;
}
onError?.call(error);
return legacy;
} finally {
if (legacyLock != null) {
try {
await legacyLock.unlock();
await legacyLock.close();
} catch (error) {
onError?.call(error);
}
}
}
}

static Future<RandomAccessFile?> _lockLegacyDatabase(Directory legacy) async {
final lockFile = File(path.join(legacy.path, "hive", "dbinfo.lock"));
if (!await lockFile.exists()) {
return null;
}

final handle = await lockFile.open(mode: FileMode.append);
try {
await handle.lock(FileLock.exclusive);
return handle;
} catch (_) {
await handle.close();
rethrow;
}
}

static Future<void> _initialize(Directory destination) async {
await destination.create(recursive: true);
final marker = File(path.join(destination.path, migrationMarker));
if (!await marker.exists()) {
await marker.writeAsString("complete", flush: true);
}
}

static Future<void> _copyDirectory(
Directory source,
Directory destination,
) async {
await destination.create(recursive: true);
await for (final entity in source.list(followLinks: false)) {
final targetPath = path.join(
destination.path,
path.basename(entity.path),
);
final type = await FileSystemEntity.type(entity.path, followLinks: false);
switch (type) {
case FileSystemEntityType.directory:
await _copyDirectory(entity as Directory, Directory(targetPath));
case FileSystemEntityType.file:
await (entity as File).copy(targetPath);
final copied = await File(targetPath).open(mode: FileMode.append);
await copied.flush();
await copied.close();
default:
throw FileSystemException(
"Unsupported entry in Flatpak data directory",
entity.path,
);
}
}
}

static Future<void> _verifyDirectoryCopy(
Directory source,
Directory destination,
) async {
final sourceEntries = await _fileDigests(source);
final destinationEntries = await _fileDigests(destination);
if (sourceEntries.length != destinationEntries.length) {
throw const FileSystemException("Flatpak data copy is incomplete");
}

for (final entry in sourceEntries.entries) {
if (destinationEntries[entry.key] != entry.value) {
throw FileSystemException(
"Flatpak data copy verification failed",
entry.key,
);
}
}
}

static Future<Map<String, String>> _fileDigests(Directory root) async {
final entries = <String, String>{};
await for (final entity in root.list(recursive: true, followLinks: false)) {
final relativePath = path.relative(entity.path, from: root.path);
final type = await FileSystemEntity.type(entity.path, followLinks: false);
if (type == FileSystemEntityType.directory) {
entries[relativePath] = "directory";
} else if (type == FileSystemEntityType.file) {
entries[relativePath] =
(await sha256.bind((entity as File).openRead()).first).toString();
} else {
throw FileSystemException(
"Unsupported entry in Flatpak data directory",
entity.path,
);
}
}
return entries;
}
}
38 changes: 32 additions & 6 deletions lib/utilities/stack_file_system.dart
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import 'package:path/path.dart' as path;
import 'package:path_provider/path_provider.dart';

import '../app_config.dart';
import 'flatpak_data_directory.dart';
import 'prefs.dart';
import 'util.dart';

Expand All @@ -33,6 +34,30 @@ abstract class StackFileSystem {
static bool get _createSubDirs =>
Util.isDesktop || AppConfig.appName == "Campfire";

static Future<Directory>? _linuxRootResolution;

static Future<Directory> _resolveLinuxRoot() async {
try {
return await FlatpakDataDirectory.resolve(
environment: Platform.environment,
appDirectoryName: AppConfig.appDefaultDataDirName,
onError: (error) => stderr.writeln(
"Flatpak data migration failed; using legacy data: $error",
),
) ??
Directory(
path.join(
Platform.environment['HOME']!,
".${AppConfig.appDefaultDataDirName}",
),
);
} catch (_) {
// Only a successful resolution is sticky.
_linuxRootResolution = null;
rethrow;
}
}

static Future<Directory> applicationRootDirectory() async {
Directory appDirectory;

Expand All @@ -46,12 +71,13 @@ abstract class StackFileSystem {
if (_overrideDesktopDirPath != null) {
appDirectory = Directory(_overrideDesktopDirPath!);
} else {
appDirectory = Directory(
path.join(
Platform.environment['HOME']!,
".${AppConfig.appDefaultDataDirName}",
),
);
// Resolved once per process: the Flatpak migration must run before
// Hive opens and never again. A second resolution could switch the
// root out from under an already open Hive/Isar, redo the whole copy
// after a failure, and its unlock() would drop this process's own
// fcntl lock on hive/dbinfo.lock (those locks are per process),
// defeating the already-running guard in main().
appDirectory = await (_linuxRootResolution ??= _resolveLinuxRoot());
}
} else if (Platform.isWindows) {
if (_overrideDesktopDirPath != null) {
Expand Down
Loading
Loading