From f1489e544e3e703e2d57d39a5b86e99cc85ce7e4 Mon Sep 17 00:00:00 2001 From: Dara Adedeji <76637177+SunkenInTime@users.noreply.github.com> Date: Tue, 25 Aug 2026 01:17:30 -0400 Subject: [PATCH] Make the cloud web client buildable --- .github/workflows/ci.yml | 3 + lib/const/settings.dart | 2 + lib/main.dart | 29 ++------- lib/providers/auth_provider.dart | 19 ++++-- lib/services/deep_link_registrar.dart | 66 +------------------- lib/services/deep_link_registrar_native.dart | 64 +++++++++++++++++++ lib/services/deep_link_registrar_stub.dart | 1 + lib/services/desktop_runtime.dart | 2 + lib/services/desktop_runtime_native.dart | 28 +++++++++ lib/services/desktop_runtime_stub.dart | 5 ++ lib/widgets/demo_dialog.dart | 10 +-- lib/widgets/demo_tag.dart | 5 +- lib/widgets/folder_navigator.dart | 14 ----- test/providers/auth_provider_test.dart | 21 ++++--- test/widgets/demo_dialog_test.dart | 25 ++++++++ web/index.html | 7 ++- web/manifest.json | 11 ++-- 17 files changed, 181 insertions(+), 131 deletions(-) create mode 100644 lib/services/deep_link_registrar_native.dart create mode 100644 lib/services/deep_link_registrar_stub.dart create mode 100644 lib/services/desktop_runtime.dart create mode 100644 lib/services/desktop_runtime_native.dart create mode 100644 lib/services/desktop_runtime_stub.dart create mode 100644 test/widgets/demo_dialog_test.dart diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4038fa88..944d8d75 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -54,6 +54,9 @@ jobs: - name: Analyze shell: pwsh run: fvm flutter analyze --no-fatal-infos + - name: Build Web Client + shell: pwsh + run: fvm flutter build web --no-wasm-dry-run --no-tree-shake-icons - name: Run Tests shell: pwsh run: fvm flutter test diff --git a/lib/const/settings.dart b/lib/const/settings.dart index 61939274..6d3a4f11 100644 --- a/lib/const/settings.dart +++ b/lib/const/settings.dart @@ -109,6 +109,8 @@ class Settings { static final Uri windowsStoreLink = Uri.parse( "https://apps.microsoft.com/detail/9PBWHHZRQFW6?hl=en-us&gl=US&ocid=pdpshare"); + static final Uri stableWindowsInstallerLink = Uri.parse( + "https://sunkenintime.github.io/icarus/downloads/windows/stable/icarus-setup-latest.exe"); static ThemeData appTheme = ThemeData( colorScheme: const ColorScheme.dark( // primary: Color.fromARGB(255, 129, 75, 223), diff --git a/lib/main.dart b/lib/main.dart index bdeae433..ceff0bc0 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,6 +1,5 @@ import 'dart:async'; import 'dart:developer' as developer; -import 'dart:io'; import 'dart:ui' show PlatformDispatcher; import 'package:app_links/app_links.dart'; @@ -12,9 +11,9 @@ import 'package:flutter_inappwebview/flutter_inappwebview.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:hive_ce_flutter/adapters.dart'; import 'package:icarus/services/deep_link_registrar.dart'; +import 'package:icarus/services/desktop_runtime.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; -import 'package:windows_single_instance/windows_single_instance.dart'; import 'package:icarus/const/custom_icons.dart'; import 'package:icarus/const/hive_boxes.dart'; import 'package:icarus/const/app_navigator.dart'; @@ -42,7 +41,6 @@ import 'package:path/path.dart' as path; import 'package:path_provider/path_provider.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; import 'package:toastification/toastification.dart'; -import 'package:window_manager/window_manager.dart'; late CustomMouseCursor staticDrawingCursor; WebViewEnvironment? webViewEnvironment; @@ -114,15 +112,7 @@ Future main(List args) async { _publishDeepLink(Uri.base, source: 'web_location'); } - if (!kIsWeb && Platform.isWindows) { - await WindowsSingleInstance.ensureSingleInstance( - args, - 'icarus_single_instance', - onSecondWindow: (args) { - publishSecondInstanceArgs(args); - }, - ); - } + await ensureIcarusSingleInstance(args); if (kIsWeb) { // On web, Hive uses IndexedDB; no path needed. @@ -172,15 +162,9 @@ Future main(List args) async { // await Hive.box(HiveBoxNames.strategiesBox).clear(); if (!kIsWeb) { - await windowManager.ensureInitialized(); - WindowOptions windowOptions = const WindowOptions( - title: - "Icarus: Valorant Strategies & Line ups ${Settings.versionName}", + await initializeIcarusDesktopWindow( + "Icarus: Valorant Strategies & Line ups ${Settings.versionName}", ); - windowManager.waitUntilReadyToShow(windowOptions, () async { - await windowManager.show(); - await windowManager.focus(); - }); } runApp( UncontrolledProviderScope( @@ -201,7 +185,7 @@ Future main(List args) async { } Future warmUpWebViewEnvironment() { - if (kIsWeb || !Platform.isWindows) { + if (!isWindowsRuntime) { isWebViewWarmupComplete = true; return Future.value(); } @@ -281,8 +265,7 @@ Future _initializePersistedDebugLog() async { } Future _initWebViewEnvironment() async { - if (kIsWeb) return; - if (Platform.isWindows) { + if (isWindowsRuntime) { if (isWebViewInitialized && webViewEnvironment != null) { return; } diff --git a/lib/providers/auth_provider.dart b/lib/providers/auth_provider.dart index b44bec7c..66ee601c 100644 --- a/lib/providers/auth_provider.dart +++ b/lib/providers/auth_provider.dart @@ -707,10 +707,6 @@ class AuthProvider extends Notifier { isConvexUserReady: false, convexAuthStatus: ConvexAuthStatus.signedOut, ); - Settings.showToast( - message: 'Signed out. Your local strategies stay on this device.', - backgroundColor: Settings.tacticalVioletTheme.primary, - ); } catch (error, stackTrace) { log( 'Sign out failed: $error', @@ -724,6 +720,21 @@ class AuthProvider extends Notifier { convexAuthStatus: ConvexAuthStatus.incident, errorMessage: 'Sign out failed. Please try again.', ); + return; + } + + try { + Settings.showToast( + message: 'Signed out. Your local strategies stay on this device.', + backgroundColor: Settings.tacticalVioletTheme.primary, + ); + } catch (error, stackTrace) { + log( + 'Signed out, but the confirmation toast could not be shown: $error', + name: 'auth', + error: error, + stackTrace: stackTrace, + ); } } diff --git a/lib/services/deep_link_registrar.dart b/lib/services/deep_link_registrar.dart index 33687c41..c43aa042 100644 --- a/lib/services/deep_link_registrar.dart +++ b/lib/services/deep_link_registrar.dart @@ -1,64 +1,2 @@ -import 'dart:developer'; -import 'dart:io'; - -import 'package:win32_registry/win32_registry.dart'; - -Future registerDeepLinkProtocol(String scheme) async { - if (!Platform.isWindows) { - return; - } - - final appPath = Platform.resolvedExecutable; - final expectedCommand = '"$appPath" "%1"'; - - final protocolRegKey = 'Software\\Classes\\$scheme'; - const protocolRegValue = RegistryValue.string('URL Protocol', ''); - const protocolCmdRegKey = 'shell\\open\\command'; - - final currentCommand = _readCurrentProtocolCommand(scheme); - final looksLikeDevBuild = _looksLikeDevBuildPath(appPath); - final canOverwriteDevRegistration = !looksLikeDevBuild || - const bool.fromEnvironment('ICARUS_FORCE_PROTOCOL_REGISTER'); - - if (!canOverwriteDevRegistration && - currentCommand != null && - currentCommand.isNotEmpty && - currentCommand != expectedCommand) { - log( - 'Deep link registration skipped for dev executable. ' - 'existing="$currentCommand" ' - 'resolvedExecutable="$appPath"', - name: 'deep_link_registrar', - ); - return; - } - - final regKey = Registry.currentUser.createKey(protocolRegKey); - regKey.createValue(protocolRegValue); - regKey - .createKey(protocolCmdRegKey) - .createValue(RegistryValue.string('', expectedCommand)); - - log( - 'Deep link registration updated. ' - 'scheme="$scheme" command="$expectedCommand"', - name: 'deep_link_registrar', - ); -} - -String? _readCurrentProtocolCommand(String scheme) { - final path = 'Software\\Classes\\$scheme\\shell\\open\\command'; - try { - final key = Registry.openPath(RegistryHive.currentUser, path: path); - final value = key.getStringValue(''); - key.close(); - return value; - } catch (_) { - return null; - } -} - -bool _looksLikeDevBuildPath(String appPath) { - final normalized = appPath.toLowerCase().replaceAll('/', '\\'); - return normalized.contains('\\build\\windows\\x64\\runner\\debug\\'); -} +export 'deep_link_registrar_stub.dart' + if (dart.library.io) 'deep_link_registrar_native.dart'; diff --git a/lib/services/deep_link_registrar_native.dart b/lib/services/deep_link_registrar_native.dart new file mode 100644 index 00000000..33687c41 --- /dev/null +++ b/lib/services/deep_link_registrar_native.dart @@ -0,0 +1,64 @@ +import 'dart:developer'; +import 'dart:io'; + +import 'package:win32_registry/win32_registry.dart'; + +Future registerDeepLinkProtocol(String scheme) async { + if (!Platform.isWindows) { + return; + } + + final appPath = Platform.resolvedExecutable; + final expectedCommand = '"$appPath" "%1"'; + + final protocolRegKey = 'Software\\Classes\\$scheme'; + const protocolRegValue = RegistryValue.string('URL Protocol', ''); + const protocolCmdRegKey = 'shell\\open\\command'; + + final currentCommand = _readCurrentProtocolCommand(scheme); + final looksLikeDevBuild = _looksLikeDevBuildPath(appPath); + final canOverwriteDevRegistration = !looksLikeDevBuild || + const bool.fromEnvironment('ICARUS_FORCE_PROTOCOL_REGISTER'); + + if (!canOverwriteDevRegistration && + currentCommand != null && + currentCommand.isNotEmpty && + currentCommand != expectedCommand) { + log( + 'Deep link registration skipped for dev executable. ' + 'existing="$currentCommand" ' + 'resolvedExecutable="$appPath"', + name: 'deep_link_registrar', + ); + return; + } + + final regKey = Registry.currentUser.createKey(protocolRegKey); + regKey.createValue(protocolRegValue); + regKey + .createKey(protocolCmdRegKey) + .createValue(RegistryValue.string('', expectedCommand)); + + log( + 'Deep link registration updated. ' + 'scheme="$scheme" command="$expectedCommand"', + name: 'deep_link_registrar', + ); +} + +String? _readCurrentProtocolCommand(String scheme) { + final path = 'Software\\Classes\\$scheme\\shell\\open\\command'; + try { + final key = Registry.openPath(RegistryHive.currentUser, path: path); + final value = key.getStringValue(''); + key.close(); + return value; + } catch (_) { + return null; + } +} + +bool _looksLikeDevBuildPath(String appPath) { + final normalized = appPath.toLowerCase().replaceAll('/', '\\'); + return normalized.contains('\\build\\windows\\x64\\runner\\debug\\'); +} diff --git a/lib/services/deep_link_registrar_stub.dart b/lib/services/deep_link_registrar_stub.dart new file mode 100644 index 00000000..98629f0f --- /dev/null +++ b/lib/services/deep_link_registrar_stub.dart @@ -0,0 +1 @@ +Future registerDeepLinkProtocol(String scheme) async {} diff --git a/lib/services/desktop_runtime.dart b/lib/services/desktop_runtime.dart new file mode 100644 index 00000000..122bef7c --- /dev/null +++ b/lib/services/desktop_runtime.dart @@ -0,0 +1,2 @@ +export 'desktop_runtime_stub.dart' + if (dart.library.io) 'desktop_runtime_native.dart'; diff --git a/lib/services/desktop_runtime_native.dart b/lib/services/desktop_runtime_native.dart new file mode 100644 index 00000000..98dffe4c --- /dev/null +++ b/lib/services/desktop_runtime_native.dart @@ -0,0 +1,28 @@ +import 'dart:io'; + +import 'package:icarus/const/second_instance_args.dart'; +import 'package:window_manager/window_manager.dart'; +import 'package:windows_single_instance/windows_single_instance.dart'; + +bool get isWindowsRuntime => Platform.isWindows; + +Future ensureIcarusSingleInstance(List args) async { + if (!Platform.isWindows) { + return; + } + + await WindowsSingleInstance.ensureSingleInstance( + args, + 'icarus_single_instance', + onSecondWindow: publishSecondInstanceArgs, + ); +} + +Future initializeIcarusDesktopWindow(String title) async { + await windowManager.ensureInitialized(); + final windowOptions = WindowOptions(title: title); + await windowManager.waitUntilReadyToShow(windowOptions, () async { + await windowManager.show(); + await windowManager.focus(); + }); +} diff --git a/lib/services/desktop_runtime_stub.dart b/lib/services/desktop_runtime_stub.dart new file mode 100644 index 00000000..09c4e3e8 --- /dev/null +++ b/lib/services/desktop_runtime_stub.dart @@ -0,0 +1,5 @@ +bool get isWindowsRuntime => false; + +Future ensureIcarusSingleInstance(List args) async {} + +Future initializeIcarusDesktopWindow(String title) async {} diff --git a/lib/widgets/demo_dialog.dart b/lib/widgets/demo_dialog.dart index 03afda3e..5034a8f4 100644 --- a/lib/widgets/demo_dialog.dart +++ b/lib/widgets/demo_dialog.dart @@ -10,12 +10,12 @@ class DemoDialog extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { return ShadDialog.alert( - title: const Text('Demo Version'), + title: const Text('Browser beta'), description: const SizedBox( width: 400, child: Text( - 'You are running the web version of this application, which has limited functionality. ' - 'For the best experience, please install the Windows version from the Microsoft Store — it is free.', + 'The browser client supports the cloud library and shared strategies. ' + 'Some editing and file tools still require the Windows app.', ), ), actions: [ @@ -31,9 +31,9 @@ class DemoDialog extends ConsumerWidget { ShadButton( leading: const Icon(Icons.download), onPressed: () async { - await launchUrl(Settings.windowsStoreLink); + await launchUrl(Settings.stableWindowsInstallerLink); }, - child: const Text('Download'), + child: const Text('Download app'), ) ], ); diff --git a/lib/widgets/demo_tag.dart b/lib/widgets/demo_tag.dart index 2aed2d6f..95881eef 100644 --- a/lib/widgets/demo_tag.dart +++ b/lib/widgets/demo_tag.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:icarus/const/settings.dart'; import 'package:icarus/widgets/demo_dialog.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; @@ -11,7 +12,7 @@ class DemoTag extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final theme = Theme.of(context); - const accent = Colors.red; + final accent = Settings.tacticalVioletTheme.primary; return InkWell( mouseCursor: SystemMouseCursors.click, @@ -48,7 +49,7 @@ class DemoTag extends ConsumerWidget { ), const SizedBox(width: 6), Text( - 'DEMO', + 'WEB BETA', style: theme.textTheme.labelMedium?.copyWith( color: Colors.white, fontWeight: FontWeight.w700, diff --git a/lib/widgets/folder_navigator.dart b/lib/widgets/folder_navigator.dart index e7299871..c5a04b7f 100644 --- a/lib/widgets/folder_navigator.dart +++ b/lib/widgets/folder_navigator.dart @@ -24,7 +24,6 @@ import 'package:icarus/services/windows_desktop_update_controller.dart'; import 'package:icarus/strategy_view.dart'; import 'package:icarus/widgets/current_path_bar.dart'; import 'package:icarus/widgets/desktop_update_dialog.dart'; -import 'package:icarus/widgets/demo_dialog.dart'; import 'package:icarus/widgets/demo_tag.dart'; import 'package:icarus/widgets/dialogs/auth/auth_dialog.dart'; import 'package:icarus/widgets/dialogs/confirm_alert_dialog.dart'; @@ -66,14 +65,11 @@ class _FolderNavigatorState extends ConsumerState { void initState() { super.initState(); - // Show the demo warning only once after the first frame on web. WidgetsBinding.instance.addPostFrameCallback((_) { if (!_warnedOnce) { _warnedOnce = true; _warnWebView(); - - _warnDemo(); } }); } @@ -92,16 +88,6 @@ class _FolderNavigatorState extends ConsumerState { ); } - void _warnDemo() async { - if (!kIsWeb) return; - await showShadDialog( - context: context, - builder: (context) { - return const DemoDialog(); - }, - ); - } - void _showDesktopOnlyToast() { Settings.showToast( message: 'This feature is only supported in the Windows version.', diff --git a/test/providers/auth_provider_test.dart b/test/providers/auth_provider_test.dart index 408fce0e..e8d9f650 100644 --- a/test/providers/auth_provider_test.dart +++ b/test/providers/auth_provider_test.dart @@ -185,10 +185,11 @@ void main() { await pumpMicrotasks(); expect(convexApi.setAuthCalls, 1); - final error = await container.read(authProvider.notifier).signInWithEmailPassword( - email: 'test@example.com', - password: 'password', - ); + final error = + await container.read(authProvider.notifier).signInWithEmailPassword( + email: 'test@example.com', + password: 'password', + ); await pumpMicrotasks(); expect(error, isNull); @@ -220,7 +221,8 @@ void main() { expect(convexApi.clearAuthCalls, 0); await container.read(authProvider.notifier).signOut(); - expect(container.read(authProvider).convexAuthStatus, ConvexAuthStatus.signedOut); + expect(container.read(authProvider).convexAuthStatus, + ConvexAuthStatus.signedOut); expect(convexApi.clearAuthCalls, 1); convexApi.setAuthCompleter!.complete(FakeAuthHandle()); @@ -265,7 +267,7 @@ void main() { expect(state.activeAuthIncidentId, isNotNull); expect( state.errorMessage, - 'Cloud authentication expired. Retry Convex auth or sign out.', + 'Your cloud session expired. Reconnect to resume syncing, or sign out.', ); }); @@ -285,7 +287,7 @@ void main() { expect(state.activeAuthIncidentId, isNull); expect( state.errorMessage, - contains('Convex auth did not become ready within'), + "Couldn't connect to cloud sync. Please retry.", ); expect(convexApi.mutationCalls, 0); }); @@ -304,7 +306,7 @@ void main() { final state = container.read(authProvider); expect(state.convexAuthStatus, ConvexAuthStatus.incident); expect(state.activeAuthIncidentId, isNull); - expect(state.errorMessage, contains('Failed to configure Convex auth')); + expect(state.errorMessage, "Couldn't connect to cloud sync. Please retry."); }); test( @@ -326,9 +328,8 @@ void main() { expect(state.activeAuthIncidentId, isNull); expect( state.errorMessage, - contains('Convex auth did not become ready within'), + "Couldn't connect to cloud sync. Please retry.", ); - expect(state.errorMessage, contains('reconnectResult: unknown')); }); } diff --git a/test/widgets/demo_dialog_test.dart b/test/widgets/demo_dialog_test.dart new file mode 100644 index 00000000..8e136a66 --- /dev/null +++ b/test/widgets/demo_dialog_test.dart @@ -0,0 +1,25 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:icarus/widgets/demo_dialog.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; + +void main() { + testWidgets('browser beta copy describes the supported cloud path', + (tester) async { + await tester.pumpWidget( + const ProviderScope( + child: ShadApp( + home: DemoDialog(), + ), + ), + ); + + expect(find.text('Browser beta'), findsOneWidget); + expect( + find.textContaining('cloud library and shared strategies'), + findsOneWidget, + ); + expect(find.text('Download app'), findsOneWidget); + expect(find.textContaining('Microsoft Store'), findsNothing); + }); +} diff --git a/web/index.html b/web/index.html index 19f9dafb..31f272a1 100644 --- a/web/index.html +++ b/web/index.html @@ -18,18 +18,19 @@ - + + - + - icarus + Icarus diff --git a/web/manifest.json b/web/manifest.json index 5455636c..1eb164fa 100644 --- a/web/manifest.json +++ b/web/manifest.json @@ -1,12 +1,11 @@ { - "name": "icarus", - "short_name": "icarus", + "name": "Icarus", + "short_name": "Icarus", "start_url": ".", "display": "standalone", - "background_color": "#0175C2", - "theme_color": "#0175C2", - "description": "A new Flutter project.", - "orientation": "portrait-primary", + "background_color": "#09090b", + "theme_color": "#09090b", + "description": "Create, sync, and share Valorant strategies.", "prefer_related_applications": false, "icons": [ {