@@ -20,14 +20,123 @@ import 'util.dart';
2020abstract 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