From 464942a47c7e72c44e7a654259df1cd4813611a4 Mon Sep 17 00:00:00 2001 From: Dara Adedeji <76637177+SunkenInTime@users.noreply.github.com> Date: Fri, 4 Sep 2026 13:15:02 -0400 Subject: [PATCH 1/2] Add isolated Hive store launch flag --- README.md | 25 ++++ lib/main.dart | 31 +++- lib/startup/hive_store_launch.dart | 182 ++++++++++++++++++++++ test/hive_store_launch_test.dart | 233 +++++++++++++++++++++++++++++ 4 files changed, 465 insertions(+), 6 deletions(-) create mode 100644 lib/startup/hive_store_launch.dart create mode 100644 test/hive_store_launch_test.dart diff --git a/README.md b/README.md index 0c4c40a1..87d4f07f 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,31 @@ flutter pub get flutter run ``` +### Isolated Hive store + +Desktop builds accept an absolute `--hive-store-dir` path. It moves every +Hive box, including the library, preferences, and anonymous analytics, without +touching the normal Hive files. + +Pass the option through Flutter with one Dart entrypoint argument: + +```bash +fvm flutter run -d macos -a "--hive-store-dir=/Users/your-name/Library/Containers/xyz.icarus-strats/Data/Library/Application Support/xyz.icarus-strats-demo" +``` + +The macOS app sandbox limits this path to Icarus's container unless the user +selects another directory through a native picker. The container lives at +`~/Library/Containers/xyz.icarus-strats/Data`. + +Or pass it directly to a built executable: + +```bash +./icarus --hive-store-dir "/absolute/path/to/icarus-demo-hive" +``` + +Strategy media, debug logs, and WebView data still use the normal application +support directory. Use a different absolute Hive directory for each instance. + ## Build ```bash diff --git a/lib/main.dart b/lib/main.dart index 1a88759b..e662a635 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -30,6 +30,7 @@ import 'package:icarus/providers/strategy_provider.dart'; import 'package:icarus/services/app_error_reporter.dart'; import 'package:icarus/services/analytics_service.dart'; import 'package:icarus/services/discord_presence_service.dart'; +import 'package:icarus/startup/hive_store_launch.dart'; import 'package:icarus/strategy_view.dart'; import 'package:icarus/widgets/folder_navigator.dart'; import 'package:icarus/widgets/global_shortcuts.dart'; @@ -52,10 +53,22 @@ Future main(List args) async { await _initializePersistedDebugLog(); _installGlobalErrorHandlers(); + final launch = HiveStoreLaunch.parse(args); + final PreparedHiveStore? alternateHiveStore; + if (kIsWeb) { + launch.validateForWeb(); + alternateHiveStore = null; + } else { + alternateHiveStore = await launch.prepareAlternateStore( + getDefaultHiveDirectory: getApplicationSupportDirectory, + ); + } + if (!kIsWeb && Platform.isWindows) { await WindowsSingleInstance.ensureSingleInstance( - args, - 'icarus_single_instance', + launch.fileOpenArgs, + alternateHiveStore?.windowsSingleInstanceId ?? + HiveStoreLaunch.defaultWindowsSingleInstanceId, onSecondWindow: (args) { publishSecondInstanceArgs(args); }, @@ -66,10 +79,16 @@ Future main(List args) async { // On web, Hive uses IndexedDB; no path needed. await Hive.initFlutter(); } else { - // On mobile/desktop, you can still choose an explicit directory. - final dir = await getApplicationSupportDirectory(); + final hiveDirectoryPath = alternateHiveStore?.hiveDirectoryPath ?? + (await getApplicationSupportDirectory()).path; await getTemporaryDirectory(); - await Hive.initFlutter(dir.path); + await Hive.initFlutter(hiveDirectoryPath); + if (alternateHiveStore != null) { + AppErrorReporter.reportInfo( + 'Using alternate Hive store: $hiveDirectoryPath', + source: 'main.hiveStore', + ); + } } staticDrawingCursor = await CustomMouseCursor.icon( @@ -126,7 +145,7 @@ Future main(List args) async { runApp( UncontrolledProviderScope( container: appProviderContainer, - child: MyApp(data: args), + child: MyApp(data: launch.fileOpenArgs), ), ); }, diff --git a/lib/startup/hive_store_launch.dart b/lib/startup/hive_store_launch.dart new file mode 100644 index 00000000..81b55c0e --- /dev/null +++ b/lib/startup/hive_store_launch.dart @@ -0,0 +1,182 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:cryptography_plus/cryptography_plus.dart'; +import 'package:path/path.dart' as path; + +final class HiveStoreLaunchException implements Exception { + const HiveStoreLaunchException(this.message, [this.cause]); + + final String message; + final Object? cause; + + @override + String toString() => message; +} + +final class HiveStoreLaunch { + const HiveStoreLaunch._({ + required this.fileOpenArgs, + required String? alternateHiveDirectory, + }) : _alternateHiveDirectory = alternateHiveDirectory; + + static const optionName = '--hive-store-dir'; + static const defaultWindowsSingleInstanceId = 'icarus_single_instance'; + + final List fileOpenArgs; + final String? _alternateHiveDirectory; + + static HiveStoreLaunch parse(List rawArgs) { + final fileOpenArgs = []; + String? alternateHiveDirectory; + var sawOption = false; + + for (var index = 0; index < rawArgs.length; index += 1) { + final argument = rawArgs[index]; + if (argument == optionName) { + if (sawOption) { + throw const HiveStoreLaunchException( + '$optionName may only be supplied once.', + ); + } + if (index + 1 >= rawArgs.length || + rawArgs[index + 1] == optionName || + rawArgs[index + 1].startsWith('$optionName=')) { + throw const HiveStoreLaunchException( + '$optionName requires an absolute directory path.', + ); + } + sawOption = true; + alternateHiveDirectory = rawArgs[++index]; + _validateOptionValue(alternateHiveDirectory); + continue; + } + + if (argument.startsWith('$optionName=')) { + if (sawOption) { + throw const HiveStoreLaunchException( + '$optionName may only be supplied once.', + ); + } + sawOption = true; + alternateHiveDirectory = argument.substring(optionName.length + 1); + _validateOptionValue(alternateHiveDirectory); + continue; + } + + fileOpenArgs.add(argument); + } + + return HiveStoreLaunch._( + fileOpenArgs: List.unmodifiable(fileOpenArgs), + alternateHiveDirectory: alternateHiveDirectory, + ); + } + + void validateForWeb() { + if (_alternateHiveDirectory == null) return; + throw const HiveStoreLaunchException( + '$optionName is only available in desktop builds.', + ); + } + + Future prepareAlternateStore({ + required Future Function() getDefaultHiveDirectory, + Future Function(Directory directory)? probeDirectory, + }) async { + final requestedPath = _alternateHiveDirectory; + if (requestedPath == null) return null; + if (!path.isAbsolute(requestedPath)) { + throw HiveStoreLaunchException( + '$optionName requires an absolute path: $requestedPath', + ); + } + + try { + final requestedDirectory = Directory(path.normalize(requestedPath)); + await requestedDirectory.create(recursive: true); + final canonicalPath = path.normalize( + await requestedDirectory.resolveSymbolicLinks(), + ); + final canonicalDirectory = Directory(canonicalPath); + await (probeDirectory ?? _probeDirectory)(canonicalDirectory); + + final defaultDirectory = await getDefaultHiveDirectory(); + final defaultPath = await _resolvedDirectoryPath(defaultDirectory); + final isDefaultDirectory = + _pathIdentity(canonicalPath) == _pathIdentity(defaultPath); + + return PreparedHiveStore( + hiveDirectoryPath: canonicalPath, + windowsSingleInstanceId: isDefaultDirectory + ? defaultWindowsSingleInstanceId + : await _windowsSingleInstanceId(canonicalPath), + ); + } on HiveStoreLaunchException { + rethrow; + } catch (error) { + throw HiveStoreLaunchException( + 'Could not prepare Hive store directory: $requestedPath', + error, + ); + } + } + + static void _validateOptionValue(String value) { + if (value.isEmpty) { + throw const HiveStoreLaunchException( + '$optionName requires a non-empty directory path.', + ); + } + if (value.contains('\u0000')) { + throw const HiveStoreLaunchException( + '$optionName cannot contain a NUL character.', + ); + } + } + + static Future _probeDirectory(Directory directory) async { + final probeDirectory = await directory.createTemp('.icarus-hive-probe-'); + try { + final probeFile = File(path.join(probeDirectory.path, 'write-probe')); + await probeFile.writeAsBytes(const [0], flush: true); + } finally { + if (await probeDirectory.exists()) { + await probeDirectory.delete(recursive: true); + } + } + } + + static Future _resolvedDirectoryPath(Directory directory) async { + final absoluteDirectory = + Directory(path.normalize(directory.absolute.path)); + if (!await absoluteDirectory.exists()) return absoluteDirectory.path; + return path.normalize(await absoluteDirectory.resolveSymbolicLinks()); + } + + static String _pathIdentity(String directoryPath) { + final normalized = path.normalize(directoryPath); + return Platform.isWindows ? normalized.toLowerCase() : normalized; + } + + static Future _windowsSingleInstanceId( + String canonicalPath, + ) async { + final digest = + await Sha256().hash(utf8.encode(_pathIdentity(canonicalPath))); + final hex = digest.bytes + .map((byte) => byte.toRadixString(16).padLeft(2, '0')) + .join(); + return '${defaultWindowsSingleInstanceId}_$hex'; + } +} + +final class PreparedHiveStore { + const PreparedHiveStore({ + required this.hiveDirectoryPath, + required this.windowsSingleInstanceId, + }); + + final String hiveDirectoryPath; + final String windowsSingleInstanceId; +} diff --git a/test/hive_store_launch_test.dart b/test/hive_store_launch_test.dart new file mode 100644 index 00000000..0e0b4217 --- /dev/null +++ b/test/hive_store_launch_test.dart @@ -0,0 +1,233 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:icarus/startup/hive_store_launch.dart'; +import 'package:path/path.dart' as path; + +void main() { + group('HiveStoreLaunch.parse', () { + test('keeps file arguments unchanged when no option is present', () { + final launch = HiveStoreLaunch.parse(['first.ica', 'second.ica']); + + expect(launch.fileOpenArgs, ['first.ica', 'second.ica']); + }); + + test('consumes the separate option and value', () { + final launch = HiveStoreLaunch.parse([ + 'first.ica', + HiveStoreLaunch.optionName, + '/tmp/icarus-demo', + 'second.ica', + ]); + + expect(launch.fileOpenArgs, ['first.ica', 'second.ica']); + }); + + test('consumes the equals form', () { + final launch = HiveStoreLaunch.parse([ + '${HiveStoreLaunch.optionName}=/tmp/icarus-demo', + 'strategy.ica', + ]); + + expect(launch.fileOpenArgs, ['strategy.ica']); + }); + + test('rejects duplicate options', () { + expect( + () => HiveStoreLaunch.parse([ + '${HiveStoreLaunch.optionName}=/tmp/one', + HiveStoreLaunch.optionName, + '/tmp/two', + ]), + throwsA(isA()), + ); + }); + + test('rejects missing, empty, and NUL values', () { + for (final arguments in [ + [HiveStoreLaunch.optionName], + ['${HiveStoreLaunch.optionName}='], + ['${HiveStoreLaunch.optionName}=/tmp/bad\u0000path'], + ]) { + expect( + () => HiveStoreLaunch.parse(arguments), + throwsA(isA()), + ); + } + }); + }); + + group('HiveStoreLaunch.prepareAlternateStore', () { + late Directory testRoot; + + setUp(() async { + testRoot = await Directory.systemTemp.createTemp('icarus-hive-launch-'); + }); + + tearDown(() async { + if (await testRoot.exists()) { + await testRoot.delete(recursive: true); + } + }); + + test('does no filesystem work without an alternate option', () async { + var requestedDefault = false; + var probedDirectory = false; + final launch = HiveStoreLaunch.parse(const []); + + final prepared = await launch.prepareAlternateStore( + getDefaultHiveDirectory: () async { + requestedDefault = true; + return testRoot; + }, + probeDirectory: (_) async { + probedDirectory = true; + }, + ); + + expect(prepared, isNull); + expect(requestedDefault, isFalse); + expect(probedDirectory, isFalse); + }); + + test('rejects a relative path before filesystem work', () async { + var requestedDefault = false; + var probedDirectory = false; + final launch = HiveStoreLaunch.parse([ + HiveStoreLaunch.optionName, + 'relative/demo-hive', + ]); + + await expectLater( + launch.prepareAlternateStore( + getDefaultHiveDirectory: () async { + requestedDefault = true; + return testRoot; + }, + probeDirectory: (_) async { + probedDirectory = true; + }, + ), + throwsA(isA()), + ); + expect(requestedDefault, isFalse); + expect(probedDirectory, isFalse); + }); + + test('creates, resolves, and probes an alternate directory', () async { + final requestedDirectory = Directory( + path.join(testRoot.path, 'nested', '..', 'demo-hive'), + ); + Directory? probedDirectory; + final launch = HiveStoreLaunch.parse([ + '${HiveStoreLaunch.optionName}=${requestedDirectory.path}', + ]); + + final prepared = await launch.prepareAlternateStore( + getDefaultHiveDirectory: () async => + Directory(path.join(testRoot.path, 'default-hive')), + probeDirectory: (directory) async { + probedDirectory = directory; + }, + ); + + final resolvedRequestedPath = path.normalize( + await Directory( + path.normalize(requestedDirectory.path), + ).resolveSymbolicLinks(), + ); + expect(prepared, isNotNull); + expect(prepared!.hiveDirectoryPath, resolvedRequestedPath); + expect(probedDirectory?.path, resolvedRequestedPath); + expect(await Directory(resolvedRequestedPath).exists(), isTrue); + expect( + prepared.windowsSingleInstanceId, + matches(RegExp(r'^icarus_single_instance_[0-9a-f]{64}$')), + ); + }); + + test('uses the legacy instance id for the default directory', () async { + final defaultDirectory = Directory(path.join(testRoot.path, 'default')); + await defaultDirectory.create(); + final launch = HiveStoreLaunch.parse([ + HiveStoreLaunch.optionName, + path.join(defaultDirectory.path, '.'), + ]); + + final prepared = await launch.prepareAlternateStore( + getDefaultHiveDirectory: () async => defaultDirectory, + probeDirectory: (_) async {}, + ); + + expect( + prepared?.windowsSingleInstanceId, + HiveStoreLaunch.defaultWindowsSingleInstanceId, + ); + }); + + test('maps a symlink alias of the default directory to the legacy id', + () async { + if (Platform.isWindows) return; + + final defaultDirectory = Directory(path.join(testRoot.path, 'default')); + await defaultDirectory.create(); + final alias = Link(path.join(testRoot.path, 'default-alias')); + await alias.create(defaultDirectory.path); + final launch = HiveStoreLaunch.parse([ + HiveStoreLaunch.optionName, + alias.path, + ]); + + final prepared = await launch.prepareAlternateStore( + getDefaultHiveDirectory: () async => defaultDirectory, + probeDirectory: (_) async {}, + ); + + expect( + prepared?.windowsSingleInstanceId, + HiveStoreLaunch.defaultWindowsSingleInstanceId, + ); + }); + + test('wraps an unusable-directory failure without falling back', () async { + final requestedDirectory = Directory(path.join(testRoot.path, 'blocked')); + final launch = HiveStoreLaunch.parse([ + HiveStoreLaunch.optionName, + requestedDirectory.path, + ]); + + await expectLater( + launch.prepareAlternateStore( + getDefaultHiveDirectory: () async => testRoot, + probeDirectory: (_) async { + throw const FileSystemException('blocked'); + }, + ), + throwsA( + isA().having( + (error) => error.cause, + 'cause', + isA(), + ), + ), + ); + }); + }); + + group('HiveStoreLaunch.validateForWeb', () { + test('allows the default IndexedDB store', () { + expect(HiveStoreLaunch.parse(const []).validateForWeb, returnsNormally); + }); + + test('rejects a filesystem override', () { + final launch = HiveStoreLaunch.parse([ + '${HiveStoreLaunch.optionName}=/tmp/icarus-demo', + ]); + + expect( + launch.validateForWeb, + throwsA(isA()), + ); + }); + }); +} From 10afcba9873c7a561cd67445cb6b626a5838febf Mon Sep 17 00:00:00 2001 From: Dara Adedeji Date: Fri, 4 Sep 2026 15:01:45 -0400 Subject: [PATCH 2/2] Fix duplicate Windows instance shutdown --- lib/main.dart | 2 ++ lib/startup/windows_process_termination.dart | 2 ++ .../windows_process_termination_io.dart | 22 +++++++++++++++++++ .../windows_process_termination_stub.dart | 3 +++ pubspec.lock | 2 +- pubspec.yaml | 1 + 6 files changed, 31 insertions(+), 1 deletion(-) create mode 100644 lib/startup/windows_process_termination.dart create mode 100644 lib/startup/windows_process_termination_io.dart create mode 100644 lib/startup/windows_process_termination_stub.dart diff --git a/lib/main.dart b/lib/main.dart index e662a635..fb96e9a8 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -31,6 +31,7 @@ import 'package:icarus/services/app_error_reporter.dart'; import 'package:icarus/services/analytics_service.dart'; import 'package:icarus/services/discord_presence_service.dart'; import 'package:icarus/startup/hive_store_launch.dart'; +import 'package:icarus/startup/windows_process_termination.dart'; import 'package:icarus/strategy_view.dart'; import 'package:icarus/widgets/folder_navigator.dart'; import 'package:icarus/widgets/global_shortcuts.dart'; @@ -72,6 +73,7 @@ Future main(List args) async { onSecondWindow: (args) { publishSecondInstanceArgs(args); }, + exitFunction: terminateDuplicateWindowsProcess, ); } diff --git a/lib/startup/windows_process_termination.dart b/lib/startup/windows_process_termination.dart new file mode 100644 index 00000000..22b6e999 --- /dev/null +++ b/lib/startup/windows_process_termination.dart @@ -0,0 +1,2 @@ +export 'windows_process_termination_stub.dart' + if (dart.library.io) 'windows_process_termination_io.dart'; diff --git a/lib/startup/windows_process_termination_io.dart b/lib/startup/windows_process_termination_io.dart new file mode 100644 index 00000000..53c2f120 --- /dev/null +++ b/lib/startup/windows_process_termination_io.dart @@ -0,0 +1,22 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:win32/win32.dart'; + +Future terminateDuplicateWindowsProcess() { + if (!Platform.isWindows) { + throw UnsupportedError('Windows process termination requires Windows.'); + } + + // flutter_inappwebview_windows can crash in DLL_PROCESS_DETACH while + // releasing static WinRT state. This runs after argument forwarding and + // before Hive opens, so skipping DLL cleanup cannot interrupt library writes. + // See https://github.com/pichillilorenzo/flutter_inappwebview/issues/2733. + final result = TerminateProcess(GetCurrentProcess(), 0); + if (result == 0) { + throw WindowsException(HRESULT_FROM_WIN32(GetLastError())); + } + + // Keep startup suspended until Windows finishes terminating the process. + return Completer().future; +} diff --git a/lib/startup/windows_process_termination_stub.dart b/lib/startup/windows_process_termination_stub.dart new file mode 100644 index 00000000..ea3a436b --- /dev/null +++ b/lib/startup/windows_process_termination_stub.dart @@ -0,0 +1,3 @@ +Future terminateDuplicateWindowsProcess() async { + throw UnsupportedError('Windows process termination requires Windows.'); +} diff --git a/pubspec.lock b/pubspec.lock index 9b859144..3deb7872 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1337,7 +1337,7 @@ packages: source: hosted version: "3.1.0" win32: - dependency: transitive + dependency: "direct main" description: name: win32 sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e diff --git a/pubspec.yaml b/pubspec.yaml index fcf2f0fa..df0e8d79 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -38,6 +38,7 @@ dependencies: custom_mouse_cursor: ^1.1.3 flutter_inappwebview: ^6.1.5 windows_single_instance: ^1.0.0 + win32: ^5.15.0 pasteboard: ^0.4.0 desktop_updater: ^1.4.0 cryptography_plus: ^2.7.1