Skip to content

Commit 7fa2145

Browse files
committed
feat: portable AppImage mode detection
closes #1177
1 parent 51db6c7 commit 7fa2145

4 files changed

Lines changed: 245 additions & 16 deletions

File tree

lib/main.dart

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import 'package:google_fonts/google_fonts.dart';
2424
import 'package:keyboard_dismisser/keyboard_dismisser.dart';
2525
import 'package:logger/logger.dart';
2626
import 'package:mobile_app_privacy/mobile_app_privacy.dart';
27+
import 'package:path/path.dart' as path;
2728
import 'package:path_provider/path_provider.dart';
2829
import 'package:window_size/window_size.dart';
2930

@@ -96,7 +97,38 @@ void main(List<String> args) async {
9697
}
9798
WidgetsFlutterBinding.ensureInitialized();
9899

99-
if (Util.isDesktop && args.length == 2 && args.first == "-d") {
100+
if (Platform.isLinux) {
101+
final appImagePath = Platform.environment['APPIMAGE'];
102+
if (appImagePath != null) {
103+
final appImageDir = path.dirname(appImagePath);
104+
final portableMarker = File(path.join(appImageDir, '.portable'));
105+
final portableDataDir = Directory(
106+
path.join(appImageDir, '.${AppConfig.appDefaultDataDirName}'),
107+
);
108+
// Portable mode is enabled when any of the following are true:
109+
// - the user created a `.portable` marker beside the AppImage,
110+
// - a portable data dir already exists beside the AppImage,
111+
// - the toggle in Advanced settings created the marker on a previous
112+
// run (handled by the two checks above), or
113+
// - the app is running on an amnesic / privacy focused distro such as
114+
// Whonix or Tails, where the home directory is not persistent.
115+
// The pref toggle works by creating/removing the marker file, so it is
116+
// covered by the marker check here and takes effect on the next launch.
117+
if (portableMarker.existsSync() ||
118+
portableDataDir.existsSync() ||
119+
StackFileSystem.isAmnesicOrPortableDistro()) {
120+
StackFileSystem.setDesktopOverrideDir(
121+
portableDataDir.path,
122+
portable: true,
123+
);
124+
}
125+
}
126+
}
127+
128+
if (!StackFileSystem.isPortableMode &&
129+
Util.isDesktop &&
130+
args.length == 2 &&
131+
args.first == "-d") {
100132
StackFileSystem.setDesktopOverrideDir(args.last);
101133
}
102134

lib/pages_desktop_specific/settings/settings_menu/advanced_settings/advanced_settings.dart

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import '../../../../providers/global/prefs_provider.dart';
1818
import '../../../../themes/stack_colors.dart';
1919
import '../../../../utilities/assets.dart';
2020
import '../../../../utilities/constants.dart';
21+
import '../../../../utilities/stack_file_system.dart';
2122
import '../../../../utilities/text_styles.dart';
2223
import '../../../../widgets/custom_buttons/draggable_switch_button.dart';
2324
import '../../../../widgets/desktop/primary_button.dart';
@@ -390,6 +391,67 @@ class _AdvancedSettings extends ConsumerState<AdvancedSettings> {
390391
],
391392
),
392393
),
394+
if (StackFileSystem.canTogglePortableMode)
395+
const Padding(
396+
padding: EdgeInsets.all(10.0),
397+
child: Divider(
398+
thickness: 0.5,
399+
),
400+
),
401+
if (StackFileSystem.canTogglePortableMode)
402+
Padding(
403+
padding: const EdgeInsets.all(10),
404+
child: Row(
405+
mainAxisAlignment: MainAxisAlignment.spaceBetween,
406+
children: [
407+
Expanded(
408+
child: Column(
409+
crossAxisAlignment: CrossAxisAlignment.start,
410+
children: [
411+
Text(
412+
"Portable mode",
413+
style:
414+
STextStyles.desktopTextExtraSmall(context)
415+
.copyWith(
416+
color: Theme.of(context)
417+
.extension<StackColors>()!
418+
.textDark,
419+
),
420+
textAlign: TextAlign.left,
421+
),
422+
Text(
423+
"Store all data beside the AppImage. "
424+
"Restart the app for changes to take effect.",
425+
style: STextStyles.desktopTextExtraExtraSmall(
426+
context,
427+
),
428+
),
429+
],
430+
),
431+
),
432+
const SizedBox(
433+
width: 10,
434+
),
435+
SizedBox(
436+
height: 20,
437+
width: 40,
438+
child: DraggableSwitchButton(
439+
isOn: ref.watch(
440+
prefsChangeNotifierProvider.select(
441+
(value) => value.enablePortableMode,
442+
),
443+
),
444+
onValueChanged: (newValue) {
445+
StackFileSystem.setPortableMarker(newValue);
446+
ref
447+
.read(prefsChangeNotifierProvider)
448+
.enablePortableMode = newValue;
449+
},
450+
),
451+
),
452+
],
453+
),
454+
),
393455
const SizedBox(
394456
height: 10,
395457
),

lib/utilities/prefs.dart

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ class Prefs extends ChangeNotifier {
8383
_autoLockInfo = await _getAutoLockInfo();
8484
_privacyScreen = await _getPrivacyScreen();
8585
_disableScreenShots = await _getDisableScreenShots();
86+
_enablePortableMode = await _getEnablePortableMode();
8687

8788
_initialized = true;
8889
}
@@ -1433,4 +1434,28 @@ class Prefs extends ChangeNotifier {
14331434
as bool? ??
14341435
false;
14351436
}
1437+
1438+
// store all app data beside the AppImage instead of in the home directory
1439+
bool _enablePortableMode = false;
1440+
bool get enablePortableMode => _enablePortableMode;
1441+
set enablePortableMode(bool enablePortableMode) {
1442+
if (_enablePortableMode != enablePortableMode) {
1443+
DB.instance.put<dynamic>(
1444+
boxName: DB.boxNamePrefs,
1445+
key: "enablePortableMode",
1446+
value: enablePortableMode,
1447+
);
1448+
_enablePortableMode = enablePortableMode;
1449+
notifyListeners();
1450+
}
1451+
}
1452+
1453+
Future<bool> _getEnablePortableMode() async {
1454+
return await DB.instance.get<dynamic>(
1455+
boxName: DB.boxNamePrefs,
1456+
key: "enablePortableMode",
1457+
)
1458+
as bool? ??
1459+
false;
1460+
}
14361461
}

lib/utilities/stack_file_system.dart

Lines changed: 125 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -20,14 +20,123 @@ import 'util.dart';
2020
abstract class StackFileSystem {
2121
static String? _overrideDesktopDirPath;
2222
static bool _overrideDirSet = false;
23-
static void setDesktopOverrideDir(String dirPath) {
23+
static bool _isPortable = false;
24+
25+
static bool get isPortableMode => _isPortable;
26+
27+
static void setDesktopOverrideDir(String dirPath, {bool portable = false}) {
2428
if (_overrideDirSet) {
2529
throw Exception(
2630
"Attempted to change StackFileSystem._overrideDir unexpectedly",
2731
);
2832
}
2933
_overrideDesktopDirPath = dirPath;
3034
_overrideDirSet = true;
35+
_isPortable = portable;
36+
}
37+
38+
/// Detects whether the app is running on a privacy focused, amnesic, or
39+
/// otherwise portable oriented Linux distribution such as Whonix or Tails.
40+
///
41+
/// On such systems the user's home directory is typically not persistent, so
42+
/// data should be stored beside the AppImage instead. All file reads are
43+
/// wrapped in try/catch so that a missing file or permission error never
44+
/// crashes startup; the worst case simply returns false.
45+
static bool isAmnesicOrPortableDistro() {
46+
if (!Platform.isLinux) {
47+
return false;
48+
}
49+
50+
// Whonix markers.
51+
try {
52+
if (File("/etc/whonix_version").existsSync() ||
53+
Directory("/usr/share/whonix").existsSync()) {
54+
return true;
55+
}
56+
} catch (_) {
57+
// Ignore and continue checking other markers.
58+
}
59+
60+
// Whonix uses well known hostnames such as 'host' or 'anon-...'.
61+
try {
62+
final hostname = Platform.localHostname.toLowerCase();
63+
if (hostname == "host" || hostname.startsWith("anon-")) {
64+
return true;
65+
}
66+
} catch (_) {
67+
// Ignore and continue checking other markers.
68+
}
69+
70+
// Tails markers.
71+
try {
72+
if (File("/etc/amnesia").existsSync() ||
73+
Directory("/live").existsSync()) {
74+
return true;
75+
}
76+
} catch (_) {
77+
// Ignore and continue checking other markers.
78+
}
79+
80+
try {
81+
final osRelease = File("/etc/os-release");
82+
if (osRelease.existsSync() &&
83+
osRelease.readAsStringSync().toLowerCase().contains("tails")) {
84+
return true;
85+
}
86+
} catch (_) {
87+
// Ignore; absence or read failure simply means no match.
88+
}
89+
90+
return false;
91+
}
92+
93+
/// The path to the directory containing the running AppImage, or null if the
94+
/// app is not running as an AppImage.
95+
static String? get appImageDirectoryPath {
96+
if (!Platform.isLinux) {
97+
return null;
98+
}
99+
final appImagePath = Platform.environment['APPIMAGE'];
100+
if (appImagePath == null) {
101+
return null;
102+
}
103+
return path.dirname(appImagePath);
104+
}
105+
106+
/// Whether the app is running as an AppImage and so can support the manual
107+
/// portable mode toggle (which works by writing a marker beside the binary).
108+
static bool get canTogglePortableMode => appImageDirectoryPath != null;
109+
110+
static File? _portableMarkerFile() {
111+
final dir = appImageDirectoryPath;
112+
if (dir == null) {
113+
return null;
114+
}
115+
return File(path.join(dir, ".portable"));
116+
}
117+
118+
/// Creates or removes the `.portable` marker beside the AppImage. The change
119+
/// only takes effect on the next launch, since the data directory is chosen
120+
/// during startup before this can be called. Returns true on success.
121+
static bool setPortableMarker(bool enabled) {
122+
final marker = _portableMarkerFile();
123+
if (marker == null) {
124+
return false;
125+
}
126+
try {
127+
if (enabled) {
128+
if (!marker.existsSync()) {
129+
marker.createSync(recursive: true);
130+
}
131+
} else {
132+
if (marker.existsSync()) {
133+
marker.deleteSync();
134+
}
135+
}
136+
return true;
137+
} catch (_) {
138+
return false;
139+
}
31140
}
32141

33142
static bool get _createSubDirs =>
@@ -213,23 +322,24 @@ abstract class StackFileSystem {
213322
}
214323
}
215324

216-
final appDocsDir = await getApplicationDocumentsDirectory();
217-
const logsDirName = "${AppConfig.prefix}_Logs";
218325
final Directory logsDir;
219326

220-
if (Platform.isIOS) {
221-
logsDir = Directory(path.join(appDocsDir.path, "logs"));
222-
} else if (Platform.isMacOS || Platform.isLinux || Platform.isWindows) {
223-
// TODO check this is correct for macos
224-
logsDir = Directory(path.join(appDocsDir.path, logsDirName));
225-
} else if (Platform.isAndroid) {
226-
// final dir = await wtfAndroidDocumentsPath();
227-
// final logsDirPath = path.join(dir.path, logsDirName);
228-
// logsDir = Directory(logsDirPath);
229-
230-
logsDir = Directory(path.join(appDocsDir.path, "logs"));
327+
if (_overrideDesktopDirPath != null) {
328+
logsDir = Directory(path.join(_overrideDesktopDirPath!, "logs"));
231329
} else {
232-
throw Exception("Unsupported Platform");
330+
final appDocsDir = await getApplicationDocumentsDirectory();
331+
const logsDirName = "${AppConfig.prefix}_Logs";
332+
333+
if (Platform.isIOS) {
334+
logsDir = Directory(path.join(appDocsDir.path, "logs"));
335+
} else if (Platform.isMacOS || Platform.isLinux || Platform.isWindows) {
336+
// TODO check this is correct for macos
337+
logsDir = Directory(path.join(appDocsDir.path, logsDirName));
338+
} else if (Platform.isAndroid) {
339+
logsDir = Directory(path.join(appDocsDir.path, "logs"));
340+
} else {
341+
throw Exception("Unsupported Platform");
342+
}
233343
}
234344

235345
if (!logsDir.existsSync()) {

0 commit comments

Comments
 (0)