diff --git a/lib/main.dart b/lib/main.dart index 89d7decb85..6ae722d21d 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -24,6 +24,7 @@ import 'package:google_fonts/google_fonts.dart'; import 'package:keyboard_dismisser/keyboard_dismisser.dart'; import 'package:logger/logger.dart'; import 'package:mobile_app_privacy/mobile_app_privacy.dart'; +import 'package:path/path.dart' as path; import 'package:path_provider/path_provider.dart'; import 'package:window_size/window_size.dart'; @@ -96,8 +97,43 @@ void main(List args) async { } WidgetsFlutterBinding.ensureInitialized(); - if (Util.isDesktop && args.length == 2 && args.first == "-d") { - StackFileSystem.setDesktopOverrideDir(args.last); + final appImageEnv = Platform.isLinux + ? Platform.environment["APPIMAGE"] + : null; + // A blank APPIMAGE means "not an AppImage" just as an unset one does. + final appImagePath = appImageEnv != null && appImageEnv.trim().isNotEmpty + ? path.absolute(appImageEnv) + : null; + final portableMarkerExists = + appImagePath != null && File("$appImagePath.portable").existsSync(); + + if (args.contains("--portable") && appImagePath == null) { + stderr.writeln("--portable is only supported when running an AppImage."); + // Returning from main() would leave the runner's already mapped window + // open and empty; only exit() ends the process. + exit(64); + } + + final ({String path, bool portable})? dataDirectoryOverride; + try { + dataDirectoryOverride = StackFileSystem.desktopDataDirectoryOverride( + arguments: args, + appImagePath: appImagePath, + appDataDirectoryName: AppConfig.appDefaultDataDirName, + portableMarkerExists: portableMarkerExists, + ); + } on ArgumentError catch (e) { + stderr.writeln(e.message); + exit(64); + } + if (dataDirectoryOverride != null && Util.isDesktop) { + if (dataDirectoryOverride.portable) { + StackFileSystem.setPortableDesktopDataDirectory( + dataDirectoryOverride.path, + ); + } else { + StackFileSystem.setDesktopOverrideDir(dataDirectoryOverride.path); + } } final loadCoinlibFuture = loadCoinlib(); diff --git a/lib/utilities/stack_file_system.dart b/lib/utilities/stack_file_system.dart index 292dda1368..9f65af7794 100644 --- a/lib/utilities/stack_file_system.dart +++ b/lib/utilities/stack_file_system.dart @@ -20,7 +20,22 @@ import 'util.dart'; abstract class StackFileSystem { static String? _overrideDesktopDirPath; static bool _overrideDirSet = false; + static bool _isPortable = false; + + static bool get isPortableMode => _isPortable; + static void setDesktopOverrideDir(String dirPath) { + _setDesktopDataDirectory(dirPath, portable: false); + } + + static void setPortableDesktopDataDirectory(String dirPath) { + _setDesktopDataDirectory(dirPath, portable: true); + } + + static void _setDesktopDataDirectory( + String dirPath, { + required bool portable, + }) { if (_overrideDirSet) { throw Exception( "Attempted to change StackFileSystem._overrideDir unexpectedly", @@ -28,6 +43,51 @@ abstract class StackFileSystem { } _overrideDesktopDirPath = dirPath; _overrideDirSet = true; + _isPortable = portable; + } + + /// Resolves an explicit AppImage portable-data request. + /// + /// `-d ` is authoritative wherever it appears in [arguments]. Portable + /// mode is otherwise enabled by `--portable` or an `.portable` + /// marker. An existing data directory alone is deliberately not treated as + /// consent. + /// + /// Throws an [ArgumentError] when `-d` is given without a directory. + static ({String path, bool portable})? desktopDataDirectoryOverride({ + required List arguments, + required String? appImagePath, + required String appDataDirectoryName, + required bool portableMarkerExists, + }) { + final dataDirFlagIndex = arguments.indexOf("-d"); + if (dataDirFlagIndex != -1) { + final dataDirPath = dataDirFlagIndex + 1 < arguments.length + ? arguments[dataDirFlagIndex + 1] + : null; + if (dataDirPath == null || + dataDirPath.isEmpty || + dataDirPath.startsWith("-")) { + throw ArgumentError("-d requires a directory path"); + } + return (path: dataDirPath, portable: false); + } + + if (!arguments.contains("--portable") && !portableMarkerExists) { + return null; + } + + if (appImagePath == null || appImagePath.trim().isEmpty) { + return null; + } + + return ( + path: path.join( + path.dirname(path.absolute(appImagePath)), + ".$appDataDirectoryName", + ), + portable: true, + ); } static bool get _createSubDirs => @@ -213,23 +273,24 @@ abstract class StackFileSystem { } } - final appDocsDir = await getApplicationDocumentsDirectory(); - const logsDirName = "${AppConfig.prefix}_Logs"; final Directory logsDir; - if (Platform.isIOS) { - logsDir = Directory(path.join(appDocsDir.path, "logs")); - } else if (Platform.isMacOS || Platform.isLinux || Platform.isWindows) { - // TODO check this is correct for macos - logsDir = Directory(path.join(appDocsDir.path, logsDirName)); - } else if (Platform.isAndroid) { - // final dir = await wtfAndroidDocumentsPath(); - // final logsDirPath = path.join(dir.path, logsDirName); - // logsDir = Directory(logsDirPath); - - logsDir = Directory(path.join(appDocsDir.path, "logs")); + if (_isPortable && _overrideDesktopDirPath != null) { + logsDir = Directory(path.join(_overrideDesktopDirPath!, "logs")); } else { - throw Exception("Unsupported Platform"); + final appDocsDir = await getApplicationDocumentsDirectory(); + const logsDirName = "${AppConfig.prefix}_Logs"; + + if (Platform.isIOS) { + logsDir = Directory(path.join(appDocsDir.path, "logs")); + } else if (Platform.isMacOS || Platform.isLinux || Platform.isWindows) { + // TODO check this is correct for macos + logsDir = Directory(path.join(appDocsDir.path, logsDirName)); + } else if (Platform.isAndroid) { + logsDir = Directory(path.join(appDocsDir.path, "logs")); + } else { + throw Exception("Unsupported Platform"); + } } if (!logsDir.existsSync()) { diff --git a/test/utilities/stack_file_system_test.dart b/test/utilities/stack_file_system_test.dart new file mode 100644 index 0000000000..8966d29b63 --- /dev/null +++ b/test/utilities/stack_file_system_test.dart @@ -0,0 +1,143 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:path/path.dart' as path; +import 'package:stackwallet/utilities/prefs.dart'; +import 'package:stackwallet/utilities/stack_file_system.dart'; + +void main() { + const appImagePath = "/media/secure/StackWallet.AppImage"; + + group("AppImage portable data directory", () { + test("is opt in", () { + final result = StackFileSystem.desktopDataDirectoryOverride( + arguments: const [], + appImagePath: appImagePath, + appDataDirectoryName: "stackwallet", + portableMarkerExists: false, + ); + + expect(result, isNull); + }); + + test("supports the portable flag", () { + final result = StackFileSystem.desktopDataDirectoryOverride( + arguments: const ["--portable"], + appImagePath: appImagePath, + appDataDirectoryName: "stackwallet", + portableMarkerExists: false, + ); + + expect(result, (path: "/media/secure/.stackwallet", portable: true)); + }); + + test("supports a marker beside the AppImage", () { + final result = StackFileSystem.desktopDataDirectoryOverride( + arguments: const [], + appImagePath: appImagePath, + appDataDirectoryName: "stackwallet", + portableMarkerExists: true, + ); + + expect(result, (path: "/media/secure/.stackwallet", portable: true)); + }); + + test("keeps the explicit data directory authoritative", () { + final result = StackFileSystem.desktopDataDirectoryOverride( + arguments: const ["-d", "/custom/data"], + appImagePath: appImagePath, + appDataDirectoryName: "stackwallet", + portableMarkerExists: true, + ); + + expect(result, (path: "/custom/data", portable: false)); + }); + + test("keeps the explicit data directory authoritative after other " + "arguments", () { + final result = StackFileSystem.desktopDataDirectoryOverride( + arguments: const ["--portable", "-d", "/custom/data"], + appImagePath: appImagePath, + appDataDirectoryName: "stackwallet", + portableMarkerExists: true, + ); + + expect(result, (path: "/custom/data", portable: false)); + }); + + test("keeps the explicit data directory authoritative before other " + "arguments", () { + final result = StackFileSystem.desktopDataDirectoryOverride( + arguments: const ["-d", "/custom/data", "--portable"], + appImagePath: appImagePath, + appDataDirectoryName: "stackwallet", + portableMarkerExists: true, + ); + + expect(result, (path: "/custom/data", portable: false)); + }); + + test("keeps the explicit data directory authoritative alongside an " + "unrelated argument", () { + final result = StackFileSystem.desktopDataDirectoryOverride( + arguments: const ["-d", "/custom/data", "--verbose"], + appImagePath: appImagePath, + appDataDirectoryName: "stackwallet", + portableMarkerExists: true, + ); + + expect(result, (path: "/custom/data", portable: false)); + }); + + test("rejects an explicit data directory without a path", () { + for (final arguments in const [ + ["-d"], + ["-d", "--portable"], + ["-d", ""], + ]) { + expect( + () => StackFileSystem.desktopDataDirectoryOverride( + arguments: arguments, + appImagePath: appImagePath, + appDataDirectoryName: "stackwallet", + portableMarkerExists: false, + ), + throwsArgumentError, + reason: "$arguments", + ); + } + }); + + test("does not enable portable mode outside an AppImage", () { + final result = StackFileSystem.desktopDataDirectoryOverride( + arguments: const ["--portable"], + appImagePath: null, + appDataDirectoryName: "stackwallet", + portableMarkerExists: false, + ); + + expect(result, isNull); + }); + }); + + test("creates data and log directories inside the portable root", () async { + final temporaryDirectory = Directory.systemTemp.createTempSync( + "stack-wallet-portable-test-", + ); + addTearDown(() => temporaryDirectory.deleteSync(recursive: true)); + final portableRoot = path.join(temporaryDirectory.path, ".stackwallet"); + + StackFileSystem.setPortableDesktopDataDirectory(portableRoot); + + final dataDirectory = await StackFileSystem.applicationRootDirectory(); + final logsDirectory = await StackFileSystem.applicationLogsDirectory( + Prefs.instance, + ); + + expect(StackFileSystem.isPortableMode, isTrue); + expect(dataDirectory.path, portableRoot); + expect(dataDirectory.existsSync(), isTrue); + expect(logsDirectory.path, path.join(portableRoot, "logs")); + expect(logsDirectory.existsSync(), isTrue); + }); +}