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
40 changes: 38 additions & 2 deletions lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -96,8 +97,43 @@ void main(List<String> 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();
Expand Down
89 changes: 75 additions & 14 deletions lib/utilities/stack_file_system.dart
Original file line number Diff line number Diff line change
Expand Up @@ -20,14 +20,74 @@ 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",
);
}
_overrideDesktopDirPath = dirPath;
_overrideDirSet = true;
_isPortable = portable;
}

/// Resolves an explicit AppImage portable-data request.
///
/// `-d <dir>` is authoritative wherever it appears in [arguments]. Portable
/// mode is otherwise enabled by `--portable` or an `<AppImage>.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<String> 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 =>
Expand Down Expand Up @@ -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()) {
Expand Down
143 changes: 143 additions & 0 deletions test/utilities/stack_file_system_test.dart
Original file line number Diff line number Diff line change
@@ -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);
});
}
Loading