diff --git a/.gitignore b/.gitignore index ee1e87d..5c92d8c 100644 --- a/.gitignore +++ b/.gitignore @@ -50,6 +50,20 @@ linked_*.ds unlinked.ds unlinked_spec.ds +# fastforge / distributor output +dist/ +**/dist/ + +# Local pub cache (when PUB_CACHE is redirected into the repo) +**/Pub/ +**/Pub/Cache/ +**/.pub-cache/ + +# Flutter tool state +**/.flutter_tool_state +**/.flutter +**/.flutter/ + # Android related **/android/**/gradle-wrapper.jar .gradle/ diff --git a/apps/linkunbound/lib/bootstrap.dart b/apps/linkunbound/lib/bootstrap.dart index 6f07f3c..2ce02c2 100644 --- a/apps/linkunbound/lib/bootstrap.dart +++ b/apps/linkunbound/lib/bootstrap.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:io'; import 'package:flutter/material.dart'; @@ -12,6 +13,7 @@ import 'platform/local_file_url.dart'; import 'platform/macos/mac_window_channel.dart'; import 'platform/platform_bindings.dart'; import 'platform/tray_controller.dart'; +import 'platform/windows/win_package_context.dart'; import 'providers.dart'; import 'ui/picker/picker_layout.dart'; @@ -20,15 +22,24 @@ final _log = Logger('Bootstrap'); Future bootstrap(PlatformBindings bindings, List args) async { initLogging(bindings.logFile); - _log.info('Started with args: $args'); + _log.info('LinkUnbound starting (msix=${isRunningInMsix()})'); - if (await bindings.tryDelegate(bindings.initialEvent)) { - exit(0); + try { + if (await bindings.tryDelegate(bindings.initialEvent)) { + exit(0); + } + } on Object catch (e, st) { + _log.warning('Delegation check failed', e, st); } - if (!await bindings.claim()) { - exit(0); + bool claimed; + try { + claimed = await bindings.claim(); + } on Object catch (e, st) { + _log.severe('claim() crashed', e, st); + claimed = false; } + if (!claimed) exit(0); final browserService = BrowserService( configFile: bindings.browsersFile, @@ -37,36 +48,68 @@ Future bootstrap(PlatformBindings bindings, List args) async { final ruleService = RuleService(rulesFile: bindings.rulesFile); final isFirstBoot = !bindings.browsersFile.existsSync(); - await browserService.load(); + + try { + await browserService.load(); + } on Object catch (e, st) { + _log.severe('Browser config corrupted, resetting', e, st); + try { + await browserService.reset(); + } on Object catch (e, st) { + _log.warning('Browser reset failed', e, st); + } + } if (isFirstBoot) { - await _firstBoot( - browserService: browserService, - iconExtractor: bindings.iconExtractor, - iconsDir: bindings.iconsDir, - registrationService: bindings.registrationService, - executablePath: bindings.executablePath, - ); + try { + await _firstBoot( + browserService: browserService, + iconExtractor: bindings.iconExtractor, + iconsDir: bindings.iconsDir, + registrationService: bindings.registrationService, + executablePath: bindings.executablePath, + skipRegistration: isRunningInMsix(), + ); + } on Object catch (e, st) { + _log.severe('First boot failed (non-fatal)', e, st); + } } - await ruleService.load(); + try { + await ruleService.load(); + } on Object catch (e, st) { + _log.severe('Rules config corrupted, ignoring', e, st); + } - await windowManager.ensureInitialized(); - await windowManager.setPreventClose(true); - await windowManager.waitUntilReadyToShow( - const WindowOptions( - titleBarStyle: TitleBarStyle.hidden, - size: Size(640, 700), - center: false, - ), - () async { - await windowManager.setSkipTaskbar(true); - if (!Platform.isMacOS) { - await windowManager.setPosition(const Offset(-9999, -9999)); - await windowManager.hide(); - } - }, - ); + try { + await windowManager.ensureInitialized(); + await windowManager.setPreventClose(true); + await windowManager.waitUntilReadyToShow( + const WindowOptions( + titleBarStyle: TitleBarStyle.hidden, + size: Size(640, 700), + center: false, + // Force a fully opaque background so compositors that lack Mica / + // DWM acrylic (Windows 10 integrated GPUs, Remote Desktop) don't try + // to render a transparent frame and crash the Flutter engine. + backgroundColor: Color(0xFF1E1E1E), + ), + () async { + await windowManager.setSkipTaskbar(true); + if (!Platform.isMacOS) { + try { + await windowManager.setHasShadow(false); + } on Object catch (e) { + _log.fine('setHasShadow not supported: $e'); + } + await windowManager.setPosition(const Offset(-9999, -9999)); + await windowManager.hide(); + } + }, + ); + } on Object catch (e, st) { + _log.severe('Window manager init failed', e, st); + } final container = ProviderContainer( overrides: [ @@ -83,7 +126,11 @@ Future bootstrap(PlatformBindings bindings, List args) async { edgeWarningFileProvider.overrideWithValue(bindings.edgeWarningFile), appDataDirProvider.overrideWithValue(bindings.appDataDir), exitAppProvider.overrideWithValue(() async { - await bindings.release(); + try { + await bindings.release(); + } on Object catch (e, st) { + _log.warning('Release failed during exit', e, st); + } exit(0); }), ], @@ -94,64 +141,36 @@ Future bootstrap(PlatformBindings bindings, List args) async { final macWindow = Platform.isMacOS ? MacWindowChannel() : null; container.listen(appStateProvider, (prev, next) async { - if (prev?.mode == next.mode) { - if (next.mode == AppMode.settings) { - await windowManager.show(); - await windowManager.focus(); - await macWindow?.activate(); - } - return; - } - switch (next.mode) { - case AppMode.hidden: - await windowManager.hide(); - case AppMode.settings: - await macWindow?.setSettingsMode(); - await windowManager.setSize(const Size(640, 700)); - await windowManager.center(); - await windowManager.setSkipTaskbar(false); - await windowManager.setAlwaysOnTop(false); - await windowManager.show(); - await windowManager.focus(); - await macWindow?.activate(); - case AppMode.picker: - await macWindow?.setPickerMode(); - final browsers = container.read(browsersProvider); - final winSize = PickerLayout.windowSize(browsers.length); - final (cursorX, cursorY) = await bindings.cursorLocator - .cursorPosition(); - final (screenW, screenH) = await bindings.cursorLocator.screenSize(); - final x = (cursorX - winSize.width / 2).clamp( - 8.0, - screenW - winSize.width - 8, - ); - final y = (cursorY + 16).clamp(8.0, screenH - winSize.height - 8); - _log.info( - 'Picker: ${browsers.length} browsers, ' - 'window=${winSize.width.toInt()}x${winSize.height.toInt()}, ' - 'pos=(${x.toInt()}, ${y.toInt()})', - ); - await windowManager.setSize(winSize); - await windowManager.setPosition(Offset(x, y)); - await windowManager.setSkipTaskbar(true); - await windowManager.setAlwaysOnTop(true); - await windowManager.show(); - await macWindow?.activate(); + try { + await _applyAppMode(prev, next, container, bindings, macWindow); + } on Object catch (e, st) { + _log.warning('App mode transition failed', e, st); } }); - bindings.inboundEvents.listen((event) { - switch (event) { - case OpenUrlEvent(:final url): - _log.info('Inbound: open_url ${_redactForLog(url)}'); - _handleUrl(url, container); - case ShowSettingsEvent(): - _log.info('Inbound: show_settings'); - container.read(appStateProvider.notifier).showSettings(); - } - }); + bindings.inboundEvents.listen( + (event) { + try { + switch (event) { + case OpenUrlEvent(:final url): + _handleUrl(url, container); + case ShowSettingsEvent(): + container.read(appStateProvider.notifier).showSettings(); + } + } on Object catch (e, st) { + _log.warning('Inbound event handler failed', e, st); + } + }, + onError: (Object e, StackTrace st) { + _log.warning('Inbound event stream error', e, st); + }, + ); - await _initTray(bindings, container); + try { + await _initTray(bindings, container); + } on Object catch (e, st) { + _log.severe('Tray init failed (non-fatal)', e, st); + } runApp( UncontrolledProviderScope(container: container, child: const NavigateApp()), @@ -164,28 +183,86 @@ Future bootstrap(PlatformBindings bindings, List args) async { }); } +Future _applyAppMode( + AppState? prev, + AppState next, + ProviderContainer container, + PlatformBindings bindings, + MacWindowChannel? macWindow, +) async { + if (prev?.mode == next.mode) { + if (next.mode == AppMode.settings) { + await windowManager.show(); + await windowManager.focus(); + await macWindow?.activate(); + } + return; + } + switch (next.mode) { + case AppMode.hidden: + await windowManager.hide(); + case AppMode.settings: + await macWindow?.setSettingsMode(); + await windowManager.setSize(const Size(640, 700)); + await windowManager.center(); + await windowManager.setSkipTaskbar(false); + await windowManager.setAlwaysOnTop(false); + await windowManager.show(); + await windowManager.focus(); + await macWindow?.activate(); + case AppMode.picker: + await macWindow?.setPickerMode(); + final browsers = container.read(browsersProvider); + final winSize = PickerLayout.windowSize(browsers.length); + final (cursorX, cursorY) = await bindings.cursorLocator.cursorPosition(); + final (screenW, screenH) = await bindings.cursorLocator.screenSize(); + final x = (cursorX - winSize.width / 2).clamp( + 8.0, + screenW - winSize.width - 8, + ); + final y = (cursorY + 16).clamp(8.0, screenH - winSize.height - 8); + await windowManager.setSize(winSize); + await windowManager.setPosition(Offset(x, y)); + await windowManager.setSkipTaskbar(true); + await windowManager.setAlwaysOnTop(true); + await windowManager.show(); + await macWindow?.activate(); + } +} + Future _firstBoot({ required BrowserService browserService, required IconExtractor iconExtractor, required Directory iconsDir, required RegistrationService registrationService, required String executablePath, + bool skipRegistration = false, }) async { await browserService.scanAndMerge(); - await iconsDir.create(recursive: true); + try { + await iconsDir.create(recursive: true); + } on Object catch (e, st) { + _log.warning('Could not create icons directory', e, st); + } for (final browser in browserService.browsers) { try { final outputPath = '${iconsDir.path}${Platform.pathSeparator}${browser.id}.png'; await iconExtractor.extractIcon(browser.executablePath, outputPath); - } on Exception catch (e) { + } on Object catch (e) { _log.warning('Icon extraction failed for ${browser.name}: $e'); } } - await registrationService.register(executablePath); - _log.info( - 'First boot: scanned ${browserService.browsers.length} browsers, registered', - ); + if (skipRegistration) { + _log.info('Skipping browser registration in MSIX context'); + } else { + try { + await registrationService.register(executablePath); + } on Object catch (e, st) { + _log.warning('Browser registration failed (non-fatal)', e, st); + } + } + _log.info('First boot complete: ${browserService.browsers.length} browsers'); } void _handleUrl(String url, ProviderContainer container) { @@ -195,7 +272,6 @@ void _handleUrl(String url, ProviderContainer container) { _log.warning('Rejected local file: ${_redactForLog(url)}'); return; } - _log.info('Local file accepted: ${redactPath(resolved)}'); final fileUri = Uri.file(resolved).toString(); container.read(appStateProvider.notifier).showPicker(fileUri); return; @@ -209,10 +285,15 @@ void _handleUrl(String url, ProviderContainer container) { final browsers = container.read(browserServiceProvider).browsers; final browser = browsers.where((b) => b.id == matchedBrowserId).firstOrNull; if (browser != null) { - _log.info('Rule match: ${_redactForLog(resolved)} → ${browser.name}'); - container + final launch = container .read(launchServiceProvider) .launch(browser.executablePath, resolved, browser.extraArgs); + unawaited( + launch.catchError((Object e, StackTrace st) { + _log.severe('Launch failed for ${browser.name}', e, st); + container.read(appStateProvider.notifier).showPicker(resolved); + }), + ); return; } } @@ -248,9 +329,8 @@ Future _initTray( () => container.read(appStateProvider.notifier).showSettings(), ); - // Resolve the active locale once so the tray menu matches the user's - // configured language (the tray runs outside the MaterialApp tree, so - // `AppLocalizations.of(context)` isn't available here). + // The tray runs outside the MaterialApp tree, so AppLocalizations.of(context) + // is not available here; load the configured locale's strings directly. final locale = container.read(localeProvider); final l10n = await AppLocalizations.delegate.load( locale ?? const Locale('en'), diff --git a/apps/linkunbound/lib/l10n/app_en.arb b/apps/linkunbound/lib/l10n/app_en.arb index 5290c70..44d047c 100644 --- a/apps/linkunbound/lib/l10n/app_en.arb +++ b/apps/linkunbound/lib/l10n/app_en.arb @@ -18,6 +18,8 @@ "sectionStartup": "STARTUP", "launchAtStartup": "Launch at system startup", + "startupManagedByWindows": "Managed by Windows Settings > Startup Apps", + "@startupManagedByWindows": {"description": "Tooltip shown when the startup toggle is disabled because the app runs as MSIX and Windows owns that preference."}, "sectionLanguage": "LANGUAGE", "languageAuto": "Automatic (system)", @@ -91,6 +93,12 @@ } }, "updateDownload": "Download", + "updateAvailableStore": "Version {version} available — check Microsoft Store for the new version", + "@updateAvailableStore": { + "placeholders": { + "version": { "type": "String" } + } + }, "updateTooltip": "New version available — check for updates in Settings", "sectionSupport": "SUPPORT", @@ -107,5 +115,10 @@ "sectionMaintenance": "MAINTENANCE", "exportDiagnosticsLabel": "Export diagnostics", - "exportDiagnosticsDescription": "Generate a ZIP with system info, registry data, and logs for troubleshooting" + "exportDiagnosticsDescription": "Generate a ZIP with system info, registry data, and logs for troubleshooting", + + "errorStartupToggle": "Could not change startup setting", + "errorUnregister": "Could not unregister LinkUnbound", + "errorExportDiagnostics": "Could not export diagnostics", + "errorResetConfig": "Could not reset configuration" } diff --git a/apps/linkunbound/lib/l10n/app_es.arb b/apps/linkunbound/lib/l10n/app_es.arb index 9fdf248..c08d762 100644 --- a/apps/linkunbound/lib/l10n/app_es.arb +++ b/apps/linkunbound/lib/l10n/app_es.arb @@ -18,6 +18,7 @@ "sectionStartup": "INICIO", "launchAtStartup": "Iniciar con el sistema", + "startupManagedByWindows": "Gestionado desde Configuración de Windows > Aplicaciones de inicio", "sectionLanguage": "IDIOMA", "languageAuto": "Automático (sistema)", @@ -70,6 +71,12 @@ "updateAvailable": "Versión {version} disponible", "updateDownload": "Descargar", + "updateAvailableStore": "Versión {version} disponible — verifica en Microsoft Store la nueva versión", + "@updateAvailableStore": { + "placeholders": { + "version": { "type": "String" } + } + }, "updateTooltip": "Nueva versión disponible — revisa las actualizaciones en Ajustes", "sectionSupport": "APÓYANOS", @@ -86,5 +93,10 @@ "sectionMaintenance": "MANTENIMIENTO", "exportDiagnosticsLabel": "Exportar diagnóstico", - "exportDiagnosticsDescription": "Genera un ZIP con info del sistema, datos del registro y logs para diagnóstico" + "exportDiagnosticsDescription": "Genera un ZIP con info del sistema, datos del registro y logs para diagnóstico", + + "errorStartupToggle": "No se pudo cambiar la configuración de inicio", + "errorUnregister": "No se pudo desregistrar LinkUnbound", + "errorExportDiagnostics": "No se pudo exportar el diagnóstico", + "errorResetConfig": "No se pudo restablecer la configuración" } diff --git a/apps/linkunbound/lib/l10n/app_localizations.dart b/apps/linkunbound/lib/l10n/app_localizations.dart index e094129..1957036 100644 --- a/apps/linkunbound/lib/l10n/app_localizations.dart +++ b/apps/linkunbound/lib/l10n/app_localizations.dart @@ -182,6 +182,12 @@ abstract class AppLocalizations { /// **'Launch at system startup'** String get launchAtStartup; + /// Tooltip shown when the startup toggle is disabled because the app runs as MSIX and Windows owns that preference. + /// + /// In en, this message translates to: + /// **'Managed by Windows Settings > Startup Apps'** + String get startupManagedByWindows; + /// No description provided for @sectionLanguage. /// /// In en, this message translates to: @@ -464,6 +470,12 @@ abstract class AppLocalizations { /// **'Download'** String get updateDownload; + /// No description provided for @updateAvailableStore. + /// + /// In en, this message translates to: + /// **'Version {version} available — check Microsoft Store for the new version'** + String updateAvailableStore(String version); + /// No description provided for @updateTooltip. /// /// In en, this message translates to: @@ -547,6 +559,30 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Generate a ZIP with system info, registry data, and logs for troubleshooting'** String get exportDiagnosticsDescription; + + /// No description provided for @errorStartupToggle. + /// + /// In en, this message translates to: + /// **'Could not change startup setting'** + String get errorStartupToggle; + + /// No description provided for @errorUnregister. + /// + /// In en, this message translates to: + /// **'Could not unregister LinkUnbound'** + String get errorUnregister; + + /// No description provided for @errorExportDiagnostics. + /// + /// In en, this message translates to: + /// **'Could not export diagnostics'** + String get errorExportDiagnostics; + + /// No description provided for @errorResetConfig. + /// + /// In en, this message translates to: + /// **'Could not reset configuration'** + String get errorResetConfig; } class _AppLocalizationsDelegate diff --git a/apps/linkunbound/lib/l10n/app_localizations_en.dart b/apps/linkunbound/lib/l10n/app_localizations_en.dart index 8882308..375fd37 100644 --- a/apps/linkunbound/lib/l10n/app_localizations_en.dart +++ b/apps/linkunbound/lib/l10n/app_localizations_en.dart @@ -50,6 +50,10 @@ class AppLocalizationsEn extends AppLocalizations { @override String get launchAtStartup => 'Launch at system startup'; + @override + String get startupManagedByWindows => + 'Managed by Windows Settings > Startup Apps'; + @override String get sectionLanguage => 'LANGUAGE'; @@ -203,6 +207,11 @@ class AppLocalizationsEn extends AppLocalizations { @override String get updateDownload => 'Download'; + @override + String updateAvailableStore(String version) { + return 'Version $version available — check Microsoft Store for the new version'; + } + @override String get updateTooltip => 'New version available — check for updates in Settings'; @@ -250,4 +259,16 @@ class AppLocalizationsEn extends AppLocalizations { @override String get exportDiagnosticsDescription => 'Generate a ZIP with system info, registry data, and logs for troubleshooting'; + + @override + String get errorStartupToggle => 'Could not change startup setting'; + + @override + String get errorUnregister => 'Could not unregister LinkUnbound'; + + @override + String get errorExportDiagnostics => 'Could not export diagnostics'; + + @override + String get errorResetConfig => 'Could not reset configuration'; } diff --git a/apps/linkunbound/lib/l10n/app_localizations_es.dart b/apps/linkunbound/lib/l10n/app_localizations_es.dart index ed71065..6c1c03b 100644 --- a/apps/linkunbound/lib/l10n/app_localizations_es.dart +++ b/apps/linkunbound/lib/l10n/app_localizations_es.dart @@ -52,6 +52,10 @@ class AppLocalizationsEs extends AppLocalizations { @override String get launchAtStartup => 'Iniciar con el sistema'; + @override + String get startupManagedByWindows => + 'Gestionado desde Configuración de Windows > Aplicaciones de inicio'; + @override String get sectionLanguage => 'IDIOMA'; @@ -208,6 +212,11 @@ class AppLocalizationsEs extends AppLocalizations { @override String get updateDownload => 'Descargar'; + @override + String updateAvailableStore(String version) { + return 'Versión $version disponible — verifica en Microsoft Store la nueva versión'; + } + @override String get updateTooltip => 'Nueva versión disponible — revisa las actualizaciones en Ajustes'; @@ -255,4 +264,17 @@ class AppLocalizationsEs extends AppLocalizations { @override String get exportDiagnosticsDescription => 'Genera un ZIP con info del sistema, datos del registro y logs para diagnóstico'; + + @override + String get errorStartupToggle => + 'No se pudo cambiar la configuración de inicio'; + + @override + String get errorUnregister => 'No se pudo desregistrar LinkUnbound'; + + @override + String get errorExportDiagnostics => 'No se pudo exportar el diagnóstico'; + + @override + String get errorResetConfig => 'No se pudo restablecer la configuración'; } diff --git a/apps/linkunbound/lib/main.dart b/apps/linkunbound/lib/main.dart index 9088bc4..7b2fee6 100644 --- a/apps/linkunbound/lib/main.dart +++ b/apps/linkunbound/lib/main.dart @@ -1,5 +1,7 @@ +import 'dart:async'; import 'dart:io'; +import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; import 'bootstrap.dart'; @@ -8,14 +10,54 @@ import 'platform/platform_bindings.dart'; import 'platform/windows/windows_bindings.dart'; Future main(List args) async { - WidgetsFlutterBinding.ensureInitialized(); + runZonedGuarded>( + () async { + WidgetsFlutterBinding.ensureInitialized(); - final PlatformBindings bindings; - if (Platform.isMacOS) { - bindings = await MacOsBindings.create(); - } else { - bindings = await WindowsBindings.create(args); - } + FlutterError.onError = (details) { + _writeStartupCrashLog('FlutterError', details.exception, details.stack); + }; + PlatformDispatcher.instance.onError = (error, stack) { + _writeStartupCrashLog('PlatformDispatcher', error, stack); + return true; + }; + + final PlatformBindings bindings; + if (Platform.isMacOS) { + bindings = await MacOsBindings.create(); + } else { + bindings = await WindowsBindings.create(args); + } - await bootstrap(bindings, args); + await bootstrap(bindings, args); + }, + (error, stack) { + _writeStartupCrashLog('runZonedGuarded', error, stack); + }, + ); +} + +void _writeStartupCrashLog(String source, Object error, StackTrace? stack) { + try { + final String base; + if (Platform.isWindows) { + base = + Platform.environment['APPDATA'] ?? + Platform.environment['LOCALAPPDATA'] ?? + '${Platform.environment['USERPROFILE'] ?? Directory.systemTemp.path}\\AppData\\Roaming'; + } else { + base = + '${Platform.environment['HOME'] ?? Directory.systemTemp.path}/Library/Application Support'; + } + final dir = Platform.isWindows ? '$base\\LinkUnbound' : '$base/LinkUnbound'; + Directory(dir).createSync(recursive: true); + final file = File('$dir${Platform.pathSeparator}startup_crash.log'); + final now = DateTime.now().toIso8601String(); + file.writeAsStringSync( + '[$now] $source: $error\n$stack\n\n', + mode: FileMode.append, + ); + } on Object { + // Best-effort crash log; ignore secondary failures. + } } diff --git a/apps/linkunbound/lib/platform/macos/mac_browser_detector.dart b/apps/linkunbound/lib/platform/macos/mac_browser_detector.dart index 30261d3..4f9eca4 100644 --- a/apps/linkunbound/lib/platform/macos/mac_browser_detector.dart +++ b/apps/linkunbound/lib/platform/macos/mac_browser_detector.dart @@ -1,25 +1,33 @@ import 'package:flutter/services.dart'; import 'package:linkunbound_core/linkunbound_core.dart'; +import 'package:logging/logging.dart'; + +final _log = Logger('MacBrowserDetector'); class MacBrowserDetector implements BrowserDetector { static const _channel = MethodChannel('linkunbound/browser_detector'); @override Future> detect() async { - final raw = await _channel.invokeListMethod>( - 'detect', - ); - if (raw == null) return const []; - return raw - .map((entry) { - final m = entry.cast(); - return Browser( - id: m['id'] as String, - name: m['name'] as String, - executablePath: m['executablePath'] as String, - iconPath: '', - ); - }) - .toList(growable: false); + try { + final raw = await _channel.invokeListMethod>( + 'detect', + ); + if (raw == null) return const []; + return raw + .map((entry) { + final m = entry.cast(); + return Browser( + id: m['id'] as String, + name: m['name'] as String, + executablePath: m['executablePath'] as String, + iconPath: '', + ); + }) + .toList(growable: false); + } on PlatformException catch (e, st) { + _log.warning('Browser detection failed', e, st); + return const []; + } } } diff --git a/apps/linkunbound/lib/platform/macos/mac_icon_extractor.dart b/apps/linkunbound/lib/platform/macos/mac_icon_extractor.dart index b6bac4b..e1171e2 100644 --- a/apps/linkunbound/lib/platform/macos/mac_icon_extractor.dart +++ b/apps/linkunbound/lib/platform/macos/mac_icon_extractor.dart @@ -2,6 +2,9 @@ import 'dart:io'; import 'package:flutter/services.dart'; import 'package:linkunbound_core/linkunbound_core.dart'; +import 'package:logging/logging.dart'; + +final _log = Logger('MacIconExtractor'); class MacIconExtractor implements IconExtractor { static const _channel = MethodChannel('linkunbound/icon_extractor'); @@ -9,10 +12,15 @@ class MacIconExtractor implements IconExtractor { @override Future extractIcon(String executablePath, String outputPath) async { if (await File(outputPath).exists()) return outputPath; - final result = await _channel.invokeMethod('extract', { - 'appPath': executablePath, - 'outputPath': outputPath, - }); - return result ?? outputPath; + try { + final result = await _channel.invokeMethod('extract', { + 'appPath': executablePath, + 'outputPath': outputPath, + }); + return result ?? outputPath; + } on PlatformException catch (e, st) { + _log.warning('Icon extraction failed for $executablePath', e, st); + return outputPath; + } } } diff --git a/apps/linkunbound/lib/platform/macos/mac_registration_service.dart b/apps/linkunbound/lib/platform/macos/mac_registration_service.dart index 192b63c..1bfa173 100644 --- a/apps/linkunbound/lib/platform/macos/mac_registration_service.dart +++ b/apps/linkunbound/lib/platform/macos/mac_registration_service.dart @@ -1,5 +1,8 @@ import 'package:flutter/services.dart'; import 'package:linkunbound_core/linkunbound_core.dart'; +import 'package:logging/logging.dart'; + +final _log = Logger('MacRegistrationService'); class MacRegistrationService implements RegistrationService { static const _channel = MethodChannel('linkunbound/registration'); @@ -16,13 +19,25 @@ class MacRegistrationService implements RegistrationService { @override Future get isDefault async { - final result = await _channel.invokeMethod('isDefault'); - return result ?? false; + try { + final result = await _channel.invokeMethod('isDefault'); + return result ?? false; + } on PlatformException catch (e, st) { + _log.warning('isDefault check failed', e, st); + return false; + } } @override Future> get defaultAssociations async { - final list = await _channel.invokeListMethod('defaultAssociations'); - return (list ?? const []).toSet(); + try { + final list = await _channel.invokeListMethod( + 'defaultAssociations', + ); + return (list ?? const []).toSet(); + } on PlatformException catch (e, st) { + _log.warning('defaultAssociations failed', e, st); + return const {}; + } } } diff --git a/apps/linkunbound/lib/platform/macos/mac_startup_service.dart b/apps/linkunbound/lib/platform/macos/mac_startup_service.dart index acbcc94..370643f 100644 --- a/apps/linkunbound/lib/platform/macos/mac_startup_service.dart +++ b/apps/linkunbound/lib/platform/macos/mac_startup_service.dart @@ -1,5 +1,8 @@ import 'package:flutter/services.dart'; import 'package:linkunbound_core/linkunbound_core.dart'; +import 'package:logging/logging.dart'; + +final _log = Logger('MacStartupService'); class MacStartupService implements StartupService { static const _channel = MethodChannel('linkunbound/startup'); @@ -16,7 +19,12 @@ class MacStartupService implements StartupService { @override Future get isEnabled async { - final result = await _channel.invokeMethod('isEnabled'); - return result ?? false; + try { + final result = await _channel.invokeMethod('isEnabled'); + return result ?? false; + } on PlatformException catch (e, st) { + _log.warning('isEnabled check failed', e, st); + return false; + } } } diff --git a/apps/linkunbound/lib/platform/macos/mac_window_channel.dart b/apps/linkunbound/lib/platform/macos/mac_window_channel.dart index fcad516..8a38011 100644 --- a/apps/linkunbound/lib/platform/macos/mac_window_channel.dart +++ b/apps/linkunbound/lib/platform/macos/mac_window_channel.dart @@ -1,12 +1,22 @@ import 'package:flutter/services.dart'; +import 'package:logging/logging.dart'; + +final _log = Logger('MacWindowChannel'); class MacWindowChannel { static const _channel = MethodChannel('linkunbound/window'); - Future setPickerMode() => _channel.invokeMethod('setPickerMode'); + Future setPickerMode() => _invoke('setPickerMode'); + + Future setSettingsMode() => _invoke('setSettingsMode'); - Future setSettingsMode() => - _channel.invokeMethod('setSettingsMode'); + Future activate() => _invoke('activate'); - Future activate() => _channel.invokeMethod('activate'); + Future _invoke(String method) async { + try { + await _channel.invokeMethod(method); + } on PlatformException catch (e, st) { + _log.warning('Window channel "$method" failed', e, st); + } + } } diff --git a/apps/linkunbound/lib/platform/macos/macos_bindings.dart b/apps/linkunbound/lib/platform/macos/macos_bindings.dart index 827b9b5..a35c882 100644 --- a/apps/linkunbound/lib/platform/macos/macos_bindings.dart +++ b/apps/linkunbound/lib/platform/macos/macos_bindings.dart @@ -2,7 +2,6 @@ import 'dart:async'; import 'dart:io'; import 'package:linkunbound_core/linkunbound_core.dart'; -import 'package:logging/logging.dart'; import 'package:path_provider/path_provider.dart'; import '../cursor_locator.dart'; @@ -16,8 +15,6 @@ import 'mac_registration_service.dart'; import 'mac_startup_service.dart'; import 'macos_tray_controller.dart'; -final _log = Logger('MacOsBindings'); - final class MacOsBindings implements PlatformBindings { MacOsBindings._({ required this.browserDetector, @@ -38,9 +35,19 @@ final class MacOsBindings implements PlatformBindings { }) : _inboundServer = inboundServer; static Future create() async { - final supportDir = await getApplicationSupportDirectory(); + Directory supportDir; + try { + supportDir = await getApplicationSupportDirectory(); + } on Object { + final home = Platform.environment['HOME'] ?? Directory.systemTemp.path; + supportDir = Directory('$home/Library/Application Support'); + } final appDataDir = Directory('${supportDir.path}/LinkUnbound'); - await appDataDir.create(recursive: true); + try { + await appDataDir.create(recursive: true); + } on FileSystemException { + // Best-effort; downstream services will surface specific failures. + } return MacOsBindings._( browserDetector: MacBrowserDetector(), @@ -113,7 +120,6 @@ final class MacOsBindings implements PlatformBindings { @override Future claim() async { await _inboundServer.start(); - _log.info('macOS bindings ready'); return true; } diff --git a/apps/linkunbound/lib/platform/windows/win_browser_detector.dart b/apps/linkunbound/lib/platform/windows/win_browser_detector.dart index 5585099..fb1ffbd 100644 --- a/apps/linkunbound/lib/platform/windows/win_browser_detector.dart +++ b/apps/linkunbound/lib/platform/windows/win_browser_detector.dart @@ -13,7 +13,6 @@ final class WinBrowserDetector implements BrowserDetector { _scanHive(hive, browsers); } - _log.info('Detected ${browsers.length} browsers'); return browsers.values.toList(); } diff --git a/apps/linkunbound/lib/platform/windows/win_diagnostics_service.dart b/apps/linkunbound/lib/platform/windows/win_diagnostics_service.dart index 731d869..61465e8 100644 --- a/apps/linkunbound/lib/platform/windows/win_diagnostics_service.dart +++ b/apps/linkunbound/lib/platform/windows/win_diagnostics_service.dart @@ -1,5 +1,6 @@ import 'dart:io'; +import 'package:archive/archive_io.dart'; import 'package:logging/logging.dart'; import 'package:win32_registry/win32_registry.dart'; @@ -16,6 +17,7 @@ const _registryPaths = [ Future exportDiagnostics({ required Directory appDataDir, required String appVersion, + void Function(Directory staging)? registryDumper, }) async { final timestamp = DateTime.now() .toIso8601String() @@ -26,26 +28,35 @@ Future exportDiagnostics({ try { _writeSystemInfo(staging, appDataDir, appVersion); - _writeRegistryDump(staging); + (registryDumper ?? _writeRegistryDump)(staging); _copyLogTail(appDataDir, staging); - final zipPath = '${appDataDir.path}\\linkunbound-diag-$timestamp.zip'; + final zipPath = + '${appDataDir.path}${Platform.pathSeparator}linkunbound-diag-$timestamp.zip'; - final result = await Process.run('powershell', [ - '-NoProfile', - '-Command', - 'Compress-Archive' - ' -Path "${staging.path}\\*"' - ' -DestinationPath "$zipPath"' - ' -Force', - ]); - - if (result.exitCode != 0) { - _log.warning('Compress-Archive failed: ${result.stderr}'); - throw Exception('Compress-Archive failed: ${result.stderr}'); + try { + final encoder = ZipFileEncoder()..create(zipPath); + for (final entity in staging.listSync(recursive: true)) { + if (entity is File) { + await encoder.addFile( + entity, + entity.path + .substring(staging.path.length + 1) + .replaceAll('\\', '/'), + ); + } + } + await encoder.close(); + } on Object catch (e) { + _log.warning('Diagnostics zip creation failed: $e'); + throw Exception('Diagnostics zip creation failed: $e'); } - await Process.run('explorer.exe', ['/select,$zipPath']); + try { + await Process.run('explorer.exe', ['/select,$zipPath']); + } on ProcessException catch (e) { + _log.fine('Could not reveal zip in Explorer: ${e.message}'); + } return zipPath; } on Exception catch (e) { @@ -83,7 +94,7 @@ void _writeSystemInfo( try { if (appDataDir.existsSync()) { for (final entity in appDataDir.listSync()) { - final name = entity.path.split('\\').last; + final name = entity.uri.pathSegments.where((s) => s.isNotEmpty).last; if (entity is File) { buf.writeln(' $name (${entity.lengthSync()} bytes)'); } else if (entity is Directory) { @@ -95,7 +106,9 @@ void _writeSystemInfo( buf.writeln(' '); } - File('${staging.path}\\system_info.txt').writeAsStringSync(buf.toString()); + File( + '${staging.path}${Platform.pathSeparator}system_info.txt', + ).writeAsStringSync(buf.toString()); } String _osVersion() => parseWindowsVersion(Platform.operatingSystemVersion); @@ -119,7 +132,9 @@ void _writeRegistryDump(Directory staging) { buf.writeln(); } - File('${staging.path}\\registry.txt').writeAsStringSync(buf.toString()); + File( + '${staging.path}${Platform.pathSeparator}registry.txt', + ).writeAsStringSync(buf.toString()); } void _dumpKey(String path, StringBuffer buf, {int depth = 0}) { @@ -149,7 +164,9 @@ void _dumpKey(String path, StringBuffer buf, {int depth = 0}) { } void _copyLogTail(Directory appDataDir, Directory staging) { - final logFile = File('${appDataDir.path}\\navigate.log'); + final logFile = File( + '${appDataDir.path}${Platform.pathSeparator}navigate.log', + ); if (!logFile.existsSync()) return; try { @@ -158,10 +175,12 @@ void _copyLogTail(Directory appDataDir, Directory staging) { ? lines.sublist(lines.length - _maxLogLines) : lines; - File('${staging.path}\\navigate.log').writeAsStringSync(tail.join('\n')); + File( + '${staging.path}${Platform.pathSeparator}navigate.log', + ).writeAsStringSync(tail.join('\n')); } on Exception catch (e) { File( - '${staging.path}\\navigate.log', + '${staging.path}${Platform.pathSeparator}navigate.log', ).writeAsStringSync(''); } } diff --git a/apps/linkunbound/lib/platform/windows/win_icon_extractor.dart b/apps/linkunbound/lib/platform/windows/win_icon_extractor.dart index 9cd1be4..e6f8b57 100644 --- a/apps/linkunbound/lib/platform/windows/win_icon_extractor.dart +++ b/apps/linkunbound/lib/platform/windows/win_icon_extractor.dart @@ -1,49 +1,191 @@ +import 'dart:ffi'; import 'dart:io'; +import 'dart:typed_data'; -import 'package:logging/logging.dart'; +import 'package:ffi/ffi.dart'; +import 'package:image/image.dart' as img; import 'package:linkunbound_core/linkunbound_core.dart'; +import 'package:logging/logging.dart'; +import 'package:win32/win32.dart'; final _log = Logger('WinIconExtractor'); +const _iconSize = 32; + final class WinIconExtractor implements IconExtractor { @override Future extractIcon(String executablePath, String outputPath) async { final outFile = File(outputPath); if (outFile.existsSync()) return outputPath; - await outFile.parent.create(recursive: true); + try { + await outFile.parent.create(recursive: true); + } on Object catch (e) { + _log.warning('Could not create icon output dir: $e'); + } - final escapedExe = executablePath.replaceAll("'", "''"); - final escapedOut = outputPath.replaceAll("'", "''"); + final png = _extractViaWin32(executablePath); + if (png == null) { + throw IconExtractionException(executablePath, 'Win32 extraction failed'); + } - final script = - ''' -Add-Type -AssemblyName System.Drawing -\$icon = [System.Drawing.Icon]::ExtractAssociatedIcon('$escapedExe') -if (\$icon) { - \$bmp = \$icon.ToBitmap() - \$bmp.Save('$escapedOut', [System.Drawing.Imaging.ImageFormat]::Png) - \$bmp.Dispose() - \$icon.Dispose() + try { + outFile.writeAsBytesSync(png); + } on Object catch (e) { + throw IconExtractionException(executablePath, 'write failed: $e'); + } + + _log.fine('Extracted icon: $executablePath -> $outputPath'); + return outputPath; + } } -'''; - final result = await Process.run('powershell', [ - '-NoProfile', - '-NonInteractive', - '-Command', - script, - ]); +Uint8List? _extractViaWin32(String executablePath) { + final pathPtr = executablePath.toNativeUtf16(); + final hIconPtr = calloc(); + final iconIdPtr = calloc(); - if (result.exitCode != 0 || !outFile.existsSync()) { - _log.warning( - 'Icon extraction failed for $executablePath: ${result.stderr}', - ); - throw IconExtractionException(executablePath, '${result.stderr}'); + try { + final extracted = PrivateExtractIcons( + pathPtr, + 0, + _iconSize, + _iconSize, + hIconPtr, + iconIdPtr, + 1, + 0, + ); + if (extracted == 0 || hIconPtr.value == 0) { + _log.fine('PrivateExtractIcons returned $extracted for $executablePath'); + return null; } - _log.fine('Extracted icon: $executablePath → $outputPath'); - return outputPath; + final hIcon = hIconPtr.value; + try { + return _iconToPng(hIcon); + } finally { + DestroyIcon(hIcon); + } + } on Object catch (e, st) { + _log.warning('Win32 icon extraction threw for $executablePath: $e', e, st); + return null; + } finally { + calloc.free(pathPtr); + calloc.free(hIconPtr); + calloc.free(iconIdPtr); + } +} + +Uint8List? _iconToPng(int hIcon) { + final iconInfo = calloc(); + try { + if (GetIconInfo(hIcon, iconInfo) == 0) { + _log.fine('GetIconInfo failed'); + return null; + } + + final hbmColor = iconInfo.ref.hbmColor; + final hbmMask = iconInfo.ref.hbmMask; + + try { + if (hbmColor == 0) { + _log.fine('Monochrome icon (no color bitmap); skipping'); + return null; + } + + final bmp = calloc(); + try { + if (GetObject(hbmColor, sizeOf(), bmp.cast()) == 0) { + _log.fine('GetObject on hbmColor failed'); + return null; + } + + final width = bmp.ref.bmWidth; + final height = bmp.ref.bmHeight; + if (width <= 0 || height <= 0) return null; + + return _readBitmapPixels(hbmColor, width, height); + } finally { + calloc.free(bmp); + } + } finally { + if (hbmColor != 0) DeleteObject(hbmColor); + if (hbmMask != 0) DeleteObject(hbmMask); + } + } finally { + calloc.free(iconInfo); + } +} + +Uint8List? _readBitmapPixels(int hBitmap, int width, int height) { + final hdc = GetDC(NULL); + if (hdc == 0) return null; + + final bmi = calloc(); + final pixelBytes = width * height * 4; + final pixels = calloc(pixelBytes); + + try { + bmi.ref.bmiHeader.biSize = sizeOf(); + bmi.ref.bmiHeader.biWidth = width; + bmi.ref.bmiHeader.biHeight = -height; + bmi.ref.bmiHeader.biPlanes = 1; + bmi.ref.bmiHeader.biBitCount = 32; + bmi.ref.bmiHeader.biCompression = BI_RGB; + bmi.ref.bmiHeader.biSizeImage = pixelBytes; + + final rows = GetDIBits( + hdc, + hBitmap, + 0, + height, + pixels.cast(), + bmi, + DIB_RGB_COLORS, + ); + if (rows == 0) { + _log.fine('GetDIBits returned 0 rows'); + return null; + } + + final bgra = Uint8List.fromList(pixels.asTypedList(pixelBytes)); + + var anyAlpha = false; + for (var i = 0; i < bgra.length; i += 4) { + final b = bgra[i]; + final g = bgra[i + 1]; + final r = bgra[i + 2]; + final a = bgra[i + 3]; + bgra[i] = r; + bgra[i + 1] = g; + bgra[i + 2] = b; + bgra[i + 3] = a; + if (a != 0) anyAlpha = true; + } + if (!anyAlpha) { + for (var i = 3; i < bgra.length; i += 4) { + bgra[i] = 0xFF; + } + } + + final image = img.Image.fromBytes( + width: width, + height: height, + bytes: bgra.buffer, + numChannels: 4, + order: img.ChannelOrder.rgba, + ); + + final target = image.width > _iconSize || image.height > _iconSize + ? img.copyResize(image, width: _iconSize, height: _iconSize) + : image; + + return Uint8List.fromList(img.encodePng(target)); + } finally { + calloc.free(pixels); + calloc.free(bmi); + ReleaseDC(NULL, hdc); } } diff --git a/apps/linkunbound/lib/platform/windows/win_instance.dart b/apps/linkunbound/lib/platform/windows/win_instance.dart index 76835b5..f4f91b7 100644 --- a/apps/linkunbound/lib/platform/windows/win_instance.dart +++ b/apps/linkunbound/lib/platform/windows/win_instance.dart @@ -65,14 +65,12 @@ final class WinInstance { final result = waitForSingleObject(handle, 0); if (result == _waitObject0 || result == _waitAbandoned) { _mutexHandle = handle; - _log.info('Single instance acquired'); return true; } final closeHandle = _kernel32 .lookupFunction<_CloseHandleNative, _CloseHandleDart>('CloseHandle'); closeHandle(handle); - _log.info('Another instance already running'); return false; } finally { calloc.free(name); @@ -90,7 +88,6 @@ final class WinInstance { releaseMutex(_mutexHandle); closeHandle(_mutexHandle); _mutexHandle = 0; - _log.info('Single instance released'); } static void allowForeground() { diff --git a/apps/linkunbound/lib/platform/windows/win_launch_service.dart b/apps/linkunbound/lib/platform/windows/win_launch_service.dart index 5c34c4c..fc2a2f4 100644 --- a/apps/linkunbound/lib/platform/windows/win_launch_service.dart +++ b/apps/linkunbound/lib/platform/windows/win_launch_service.dart @@ -1,12 +1,7 @@ import 'dart:io'; -import 'package:logging/logging.dart'; import 'package:linkunbound_core/linkunbound_core.dart'; -import '../local_file_url.dart'; - -final _log = Logger('WinLaunchService'); - final class WinLaunchService implements LaunchService { @override Future launch( @@ -15,24 +10,6 @@ final class WinLaunchService implements LaunchService { List extraArgs, ) async { final args = [...extraArgs, url]; - _log.info( - 'Launching ${redactPath(executablePath)} with ' - '${extraArgs.length} extra arg(s), target=${_redactTarget(url)}', - ); await Process.start(executablePath, args, mode: ProcessStartMode.detached); } - - String _redactTarget(String url) { - if (looksLikeLocalFile(url)) { - if (url.startsWith('file://')) { - try { - return 'file://${redactPath(Uri.parse(url).toFilePath())}'; - } on Exception { - return 'file://'; - } - } - return redactPath(url); - } - return url; - } } diff --git a/apps/linkunbound/lib/platform/windows/win_package_context.dart b/apps/linkunbound/lib/platform/windows/win_package_context.dart new file mode 100644 index 0000000..33f23a8 --- /dev/null +++ b/apps/linkunbound/lib/platform/windows/win_package_context.dart @@ -0,0 +1,11 @@ +import 'dart:io'; + +/// Detects whether the running process is packaged in an MSIX container. +/// MSIX apps run from `...\WindowsApps\...` and expose the `APPX_PACKAGE_FULL_NAME` +/// environment variable via the package identity. +bool isRunningInMsix() { + if (!Platform.isWindows) return false; + if (Platform.environment.containsKey('APPX_PACKAGE_FULL_NAME')) return true; + final exe = Platform.resolvedExecutable.toLowerCase(); + return exe.contains(r'\windowsapps\'); +} diff --git a/apps/linkunbound/lib/platform/windows/win_pipe_server.dart b/apps/linkunbound/lib/platform/windows/win_pipe_server.dart index 7ab02f8..c0cadac 100644 --- a/apps/linkunbound/lib/platform/windows/win_pipe_server.dart +++ b/apps/linkunbound/lib/platform/windows/win_pipe_server.dart @@ -172,7 +172,6 @@ final class WinPipeServer implements InboundEventServer { }); _isolate = await Isolate.spawn(_serverLoop, _receivePort!.sendPort); - _log.info('Pipe server started'); } @override @@ -189,7 +188,6 @@ final class WinPipeServer implements InboundEventServer { _receivePort?.close(); _receivePort = null; await _controller.close(); - _log.info('Pipe server stopped'); } static void _serverLoop(SendPort sendPort) { diff --git a/apps/linkunbound/lib/platform/windows/win_registration_service.dart b/apps/linkunbound/lib/platform/windows/win_registration_service.dart index 1b51c13..1c7d942 100644 --- a/apps/linkunbound/lib/platform/windows/win_registration_service.dart +++ b/apps/linkunbound/lib/platform/windows/win_registration_service.dart @@ -4,6 +4,8 @@ import 'package:logging/logging.dart'; import 'package:linkunbound_core/linkunbound_core.dart'; import 'package:win32_registry/win32_registry.dart'; +import 'win_package_context.dart'; + final _log = Logger('WinRegistrationService'); typedef _SHChangeNotifyNative = @@ -27,6 +29,11 @@ const _shcnfIdList = 0x0000; final class WinRegistrationService implements RegistrationService { @override Future register(String executablePath) async { + if (isRunningInMsix()) { + // Protocol association is declared via the MSIX manifest; HKCU writes + // are sandboxed to the package and invisible to the Shell. + return; + } final exe = executablePath.replaceAll('/', '\\'); final quotedExe = '"$exe"'; @@ -35,19 +42,18 @@ final class WinRegistrationService implements RegistrationService { _writeCapabilities(exe, quotedExe); _writeRegisteredApplications(); _notifyShell(); - - _log.info('Registered LinkUnbound as browser handler'); } @override Future unregister() async { + if (isRunningInMsix()) { + return; + } _deleteKeyTree(r'Software\Classes\LinkUnboundURL'); _deleteKeyTree(r'Software\Clients\StartMenuInternet\LinkUnbound'); _deleteKeyTree(r'Software\LinkUnbound'); _removeRegisteredApplication(); _notifyShell(); - - _log.info('Unregistered LinkUnbound from browser handlers'); } @override @@ -60,7 +66,11 @@ final class WinRegistrationService implements RegistrationService { ); final progId = key.getValueAsString('ProgId'); key.close(); - return progId == 'LinkUnboundURL'; + if (progId == null) return false; + // Desktop install writes "LinkUnboundURL"; MSIX writes a package-scoped + // AppX... ProgId that embeds the identity name. + return progId == 'LinkUnboundURL' || + progId.toLowerCase().contains('linkunbound'); } on Exception { return false; } @@ -77,7 +87,11 @@ final class WinRegistrationService implements RegistrationService { ); final progId = key.getValueAsString('ProgId'); key.close(); - if (progId == 'LinkUnboundURL') result.add(entry.key); + if (progId == null) continue; + if (progId == 'LinkUnboundURL' || + progId.toLowerCase().contains('linkunbound')) { + result.add(entry.key); + } } on Exception { // Not set as default for this association. } diff --git a/apps/linkunbound/lib/platform/windows/win_startup_service.dart b/apps/linkunbound/lib/platform/windows/win_startup_service.dart index fe77db4..4365fdb 100644 --- a/apps/linkunbound/lib/platform/windows/win_startup_service.dart +++ b/apps/linkunbound/lib/platform/windows/win_startup_service.dart @@ -2,6 +2,8 @@ import 'package:logging/logging.dart'; import 'package:linkunbound_core/linkunbound_core.dart'; import 'package:win32_registry/win32_registry.dart'; +import 'win_package_context.dart'; + final _log = Logger('WinStartupService'); const _runKeyPath = r'Software\Microsoft\Windows\CurrentVersion\Run'; @@ -10,6 +12,11 @@ const _valueName = 'LinkUnbound'; final class WinStartupService implements StartupService { @override Future enable(String executablePath) async { + if (isRunningInMsix()) { + // Startup in MSIX is declared via the manifest StartupTask; HKCU\Run is + // virtualized and the user toggles it from Windows Settings > Startup. + return; + } final key = Registry.openPath( RegistryHive.currentUser, path: _runKeyPath, @@ -23,11 +30,13 @@ final class WinStartupService implements StartupService { ), ); key.close(); - _log.info('Startup enabled'); } @override Future disable() async { + if (isRunningInMsix()) { + return; + } try { final key = Registry.openPath( RegistryHive.currentUser, @@ -36,7 +45,6 @@ final class WinStartupService implements StartupService { ); key.deleteValue(_valueName); key.close(); - _log.info('Startup disabled'); } on Exception { _log.fine('Run key not found during disable'); } @@ -44,6 +52,12 @@ final class WinStartupService implements StartupService { @override Future get isEnabled async { + if (isRunningInMsix()) { + // Manifest declares enabled=true by default; the user can disable it + // from Windows Settings but we cannot read that state from Dart, so we + // surface the declared default. Toggling in-app is a no-op (see above). + return true; + } try { final key = Registry.openPath( RegistryHive.currentUser, diff --git a/apps/linkunbound/lib/platform/windows/windows_bindings.dart b/apps/linkunbound/lib/platform/windows/windows_bindings.dart index 2ed0ce7..e472098 100644 --- a/apps/linkunbound/lib/platform/windows/windows_bindings.dart +++ b/apps/linkunbound/lib/platform/windows/windows_bindings.dart @@ -43,10 +43,16 @@ final class WindowsBindings implements PlatformBindings { _pipeServer = pipeServer; static Future create(List args) async { - final appDataDir = Directory( - '${Platform.environment['APPDATA']}\\LinkUnbound', - ); - await appDataDir.create(recursive: true); + final baseDir = + Platform.environment['APPDATA'] ?? + Platform.environment['LOCALAPPDATA'] ?? + '${Platform.environment['USERPROFILE'] ?? Directory.systemTemp.path}\\AppData\\Roaming'; + final appDataDir = Directory('$baseDir\\LinkUnbound'); + try { + await appDataDir.create(recursive: true); + } on FileSystemException catch (e) { + _log.severe('Could not create app data dir at ${appDataDir.path}', e); + } return WindowsBindings._( browserDetector: WinBrowserDetector(), @@ -132,11 +138,7 @@ final class WindowsBindings implements PlatformBindings { final client = WinPipeClient(); final payload = event ?? const ShowSettingsEvent(); WinInstance.allowForeground(); - if (await client.send(payload)) { - _log.info('Delegated to existing instance'); - return true; - } - return false; + return client.send(payload); } @override diff --git a/apps/linkunbound/lib/ui/settings/general_page.dart b/apps/linkunbound/lib/ui/settings/general_page.dart index 6fd8393..40b5c2c 100644 --- a/apps/linkunbound/lib/ui/settings/general_page.dart +++ b/apps/linkunbound/lib/ui/settings/general_page.dart @@ -6,6 +6,7 @@ import 'package:linkunbound_core/linkunbound_core.dart'; import 'package:url_launcher/url_launcher.dart'; import '../../l10n/app_localizations.dart'; +import '../../platform/windows/win_package_context.dart'; import '../../providers.dart'; import '../shared/widgets/browser_tile.dart'; import '../shared/widgets/group_card.dart'; @@ -206,6 +207,7 @@ class GeneralPage extends ConsumerWidget { AsyncValue isStartupAsync, ) { final l10n = AppLocalizations.of(context)!; + final managedByOs = isRunningInMsix(); return [ SectionHeader(label: l10n.sectionStartup), @@ -213,22 +215,36 @@ class GeneralPage extends ConsumerWidget { child: Row( children: [ Expanded( - child: Text( - l10n.launchAtStartup, - style: Theme.of(context).textTheme.bodyMedium, + child: Tooltip( + message: managedByOs ? l10n.startupManagedByWindows : '', + child: Text( + l10n.launchAtStartup, + style: Theme.of(context).textTheme.bodyMedium, + ), ), ), Switch( value: isStartupAsync.valueOrNull ?? false, - onChanged: (enabled) async { - final service = ref.read(startupServiceProvider); - if (enabled) { - await service.enable(Platform.resolvedExecutable); - } else { - await service.disable(); - } - ref.invalidate(isStartupEnabledProvider); - }, + onChanged: managedByOs + ? null + : (enabled) async { + final messenger = ScaffoldMessenger.maybeOf(context); + final errorMsg = l10n.errorStartupToggle; + final service = ref.read(startupServiceProvider); + try { + if (enabled) { + await service.enable(Platform.resolvedExecutable); + } else { + await service.disable(); + } + } on Object { + messenger?.showSnackBar( + SnackBar(content: Text(errorMsg)), + ); + } finally { + ref.invalidate(isStartupEnabledProvider); + } + }, ), ], ), diff --git a/apps/linkunbound/lib/ui/settings/maintenance_page.dart b/apps/linkunbound/lib/ui/settings/maintenance_page.dart index 12c5324..926a265 100644 --- a/apps/linkunbound/lib/ui/settings/maintenance_page.dart +++ b/apps/linkunbound/lib/ui/settings/maintenance_page.dart @@ -6,6 +6,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../l10n/app_localizations.dart'; import '../../platform/macos/mac_diagnostics_service.dart'; import '../../platform/windows/win_diagnostics_service.dart'; +import '../../platform/windows/win_package_context.dart'; import '../../providers.dart'; import '../shared/widgets/base_dialog.dart'; import '../shared/widgets/group_card.dart'; @@ -18,6 +19,7 @@ class MaintenancePage extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final colors = Theme.of(context).colorScheme; final l10n = AppLocalizations.of(context)!; + final showUnregister = !isRunningInMsix(); return ListView( padding: const EdgeInsets.fromLTRB(24, 20, 24, 24), @@ -41,14 +43,16 @@ class MaintenancePage extends ConsumerWidget { color: colors.error, onTap: () => _confirmReset(context, ref), ), - Divider(height: 1, color: colors.outline.withAlpha(40)), - _ActionRow( - icon: Icons.delete_outline, - label: l10n.unregisterLabel, - description: l10n.unregisterDescription, - color: colors.error, - onTap: () => _confirmUnregister(context, ref), - ), + if (showUnregister) ...[ + Divider(height: 1, color: colors.outline.withAlpha(40)), + _ActionRow( + icon: Icons.delete_outline, + label: l10n.unregisterLabel, + description: l10n.unregisterDescription, + color: colors.error, + onTap: () => _confirmUnregister(context, ref), + ), + ], ], ), ), @@ -57,12 +61,15 @@ class MaintenancePage extends ConsumerWidget { } Future _exportDiagnostics(BuildContext context, WidgetRef ref) async { + final messenger = ScaffoldMessenger.maybeOf(context); + final errorMsg = AppLocalizations.of(context)!.errorExportDiagnostics; showDialog( context: context, barrierDismissible: false, builder: (_) => const Center(child: CircularProgressIndicator()), ); + var failed = false; try { final appDataDir = ref.read(appDataDirProvider); final version = @@ -73,15 +80,19 @@ class MaintenancePage extends ConsumerWidget { } else { await exportDiagnostics(appDataDir: appDataDir, appVersion: version); } - } on Exception { - // Best-effort + } on Object { + failed = true; } finally { if (context.mounted) Navigator.of(context).pop(); + if (failed) { + messenger?.showSnackBar(SnackBar(content: Text(errorMsg))); + } } } void _confirmReset(BuildContext context, WidgetRef ref) { final l10n = AppLocalizations.of(context)!; + final messenger = ScaffoldMessenger.maybeOf(context); showDialog( context: context, builder: (ctx) => BaseDialog( @@ -91,23 +102,29 @@ class MaintenancePage extends ConsumerWidget { confirmColor: Theme.of(ctx).colorScheme.error, onConfirm: () async { Navigator.of(ctx).pop(); - final browserService = ref.read(browserServiceProvider); - await browserService.reset(); - await browserService.scanAndMerge(); - final iconsDir = ref.read(iconsDirProvider); - final iconExtractor = ref.read(iconExtractorProvider); - for (final browser in browserService.browsers) { - try { - await iconExtractor.extractIcon( - browser.executablePath, - '${iconsDir.path}/${browser.id}.png', - ); - } on Exception { - // Best-effort + try { + final browserService = ref.read(browserServiceProvider); + await browserService.reset(); + await browserService.scanAndMerge(); + final iconsDir = ref.read(iconsDirProvider); + final iconExtractor = ref.read(iconExtractorProvider); + for (final browser in browserService.browsers) { + try { + await iconExtractor.extractIcon( + browser.executablePath, + '${iconsDir.path}/${browser.id}.png', + ); + } on Exception { + // Best-effort + } } + ref.invalidate(browsersProvider); + ref.invalidate(rulesProvider); + } on Object { + messenger?.showSnackBar( + SnackBar(content: Text(l10n.errorResetConfig)), + ); } - ref.invalidate(browsersProvider); - ref.invalidate(rulesProvider); }, ), ); @@ -115,6 +132,7 @@ class MaintenancePage extends ConsumerWidget { void _confirmUnregister(BuildContext context, WidgetRef ref) { final l10n = AppLocalizations.of(context)!; + final messenger = ScaffoldMessenger.maybeOf(context); showDialog( context: context, builder: (ctx) => BaseDialog( @@ -124,8 +142,15 @@ class MaintenancePage extends ConsumerWidget { confirmColor: Theme.of(ctx).colorScheme.error, onConfirm: () async { Navigator.of(ctx).pop(); - await ref.read(registrationServiceProvider).unregister(); - ref.invalidate(isDefaultBrowserProvider); + try { + await ref.read(registrationServiceProvider).unregister(); + } on Object { + messenger?.showSnackBar( + SnackBar(content: Text(l10n.errorUnregister)), + ); + } finally { + ref.invalidate(isDefaultBrowserProvider); + } }, ), ); diff --git a/apps/linkunbound/lib/ui/settings/settings_view.dart b/apps/linkunbound/lib/ui/settings/settings_view.dart index f0bf837..c554f2f 100644 --- a/apps/linkunbound/lib/ui/settings/settings_view.dart +++ b/apps/linkunbound/lib/ui/settings/settings_view.dart @@ -7,6 +7,7 @@ import 'package:url_launcher/url_launcher.dart'; import 'package:window_manager/window_manager.dart'; import '../../l10n/app_localizations.dart'; +import '../../platform/windows/win_package_context.dart'; import '../../providers.dart'; import '../shared/widgets/title_bar.dart'; import 'about_page.dart'; @@ -103,6 +104,10 @@ class _UpdateBanner extends StatelessWidget { @override Widget build(BuildContext context) { final colors = Theme.of(context).colorScheme; + final inStore = isRunningInMsix(); + final message = inStore + ? l10n.updateAvailableStore(update.latestVersion) + : l10n.updateAvailable(update.latestVersion); return Container( width: double.infinity, @@ -117,24 +122,25 @@ class _UpdateBanner extends StatelessWidget { const SizedBox(width: 8), Expanded( child: Text( - l10n.updateAvailable(update.latestVersion), + message, style: Theme.of( context, ).textTheme.bodySmall?.copyWith(color: colors.primary), ), ), - TextButton( - style: TextButton.styleFrom( - padding: const EdgeInsets.symmetric(horizontal: 12), - minimumSize: const Size(0, 28), - tapTargetSize: MaterialTapTargetSize.shrinkWrap, + if (!inStore) + TextButton( + style: TextButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 12), + minimumSize: const Size(0, 28), + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + onPressed: () => launchUrl(Uri.parse(update.releaseUrl)), + child: Text( + l10n.updateDownload, + style: TextStyle(fontSize: 12, color: colors.primary), + ), ), - onPressed: () => launchUrl(Uri.parse(update.releaseUrl)), - child: Text( - l10n.updateDownload, - style: TextStyle(fontSize: 12, color: colors.primary), - ), - ), ], ), ); diff --git a/apps/linkunbound/pubspec.yaml b/apps/linkunbound/pubspec.yaml index 58a911d..753aa52 100644 --- a/apps/linkunbound/pubspec.yaml +++ b/apps/linkunbound/pubspec.yaml @@ -29,6 +29,9 @@ dependencies: url_launcher: ^6.3.1 package_info_plus: ^8.3.0 logging: ^1.3.0 + archive: ^3.6.1 + image: ^4.3.0 + win32: ^5.15.0 dev_dependencies: flutter_test: @@ -45,3 +48,9 @@ flutter: - assets/copypaste_icon.png - assets/LinkUnbound_tray_32.png - assets/LinkUnbound_tray_64.png + +msix_config: + startup_task: + task_id: LinkUnboundStartup + enabled: true + parameters: --background diff --git a/apps/linkunbound/test/bootstrap_test.dart b/apps/linkunbound/test/bootstrap_test.dart index 95d1df9..e188f98 100644 --- a/apps/linkunbound/test/bootstrap_test.dart +++ b/apps/linkunbound/test/bootstrap_test.dart @@ -340,6 +340,16 @@ final class _FakeBindings implements PlatformBindings { } } +final class _ThrowingDelegateBindings extends _FakeBindings { + _ThrowingDelegateBindings({required super.rootDir}); + + @override + Future tryDelegate(InboundEvent? event) async { + tryDelegateCalls++; + throw const SocketException('simulated delegation failure'); + } +} + void main() { if (!(Platform.isMacOS || Platform.isWindows)) { test('bootstrap suite skipped on this platform', () {}, skip: true); @@ -446,8 +456,10 @@ void main() { await tester.pump(); expect(find.byType(SettingsWindow), findsOneWidget); - expect(macWindowSpy.methods, contains('setSettingsMode')); - expect(macWindowSpy.methods, contains('activate')); + if (Platform.isMacOS) { + expect(macWindowSpy.methods, contains('setSettingsMode')); + expect(macWindowSpy.methods, contains('activate')); + } }); testWidgets('matching rule launches browser instead of opening picker', ( @@ -526,7 +538,9 @@ void main() { await tester.pump(); expect(find.byType(PickerWindow), findsOneWidget); - expect(macWindowSpy.methods, contains('setPickerMode')); + if (Platform.isMacOS) { + expect(macWindowSpy.methods, contains('setPickerMode')); + } }); testWidgets('unsupported local file is ignored', (tester) async { @@ -558,6 +572,62 @@ void main() { await tester.pump(); expect(find.byType(SettingsWindow), findsOneWidget); - expect(macWindowSpy.methods, contains('setSettingsMode')); + if (Platform.isMacOS) { + expect(macWindowSpy.methods, contains('setSettingsMode')); + } }); + + testWidgets('tryDelegate exception is non-fatal and boot continues', ( + tester, + ) async { + final bindings = _ThrowingDelegateBindings(rootDir: tempDir); + addTearDown(bindings.close); + + await boot(tester, bindings, const []); + + expect(bindings.tryDelegateCalls, 1); + expect(find.byType(SettingsWindow), findsOneWidget); + }); + + testWidgets('corrupt browsers.json is reset and boot continues', ( + tester, + ) async { + final bindings = _FakeBindings( + rootDir: tempDir, + detectedBrowsers: const [_chrome], + ); + addTearDown(bindings.close); + + bindings.browsersFile + ..createSync(recursive: true) + ..writeAsStringSync('{{not valid json'); + + await boot(tester, bindings, const []); + + expect(find.byType(SettingsWindow), findsOneWidget); + }); + + testWidgets( + 'ShowSettingsEvent when settings already showing re-focuses window', + (tester) async { + final bindings = _FakeBindings(rootDir: tempDir)..startsHidden = false; + addTearDown(bindings.close); + + await boot(tester, bindings, const []); + expect(find.byType(SettingsWindow), findsOneWidget); + + windowSpy.clear(); + + await tester.runAsync(() async { + bindings.emit(const ShowSettingsEvent()); + await Future.delayed(const Duration(milliseconds: 150)); + }); + await tester.pump(); + await tester.pump(); + + expect(find.byType(SettingsWindow), findsOneWidget); + expect(windowSpy.methods, contains('show')); + expect(windowSpy.methods, contains('focus')); + }, + ); } diff --git a/apps/linkunbound/test/helpers.dart b/apps/linkunbound/test/helpers.dart index bc34ff6..a5da5d9 100644 --- a/apps/linkunbound/test/helpers.dart +++ b/apps/linkunbound/test/helpers.dart @@ -88,9 +88,11 @@ Widget buildTestApp(Widget child, {required List overrides}) { makeFixtures({ Directory? dir, List browsers = const [], + List detectedBrowsers = const [], List rules = const [], bool isDefault = false, bool isStartupEnabled = false, + StartupService? startupService, UpdateInfo? updateInfo, }) { final tempDir = dir ?? Directory.systemTemp.createTempSync('lu_test_'); @@ -102,7 +104,7 @@ makeFixtures({ final browserService = BrowserService( configFile: configFile, - browserDetector: FakeBrowserDetector([]), + browserDetector: FakeBrowserDetector(detectedBrowsers), ); for (final b in browsers) { browserService.addBrowser(b); @@ -122,7 +124,7 @@ makeFixtures({ FakeRegistrationService(isDefaultValue: isDefault), ), startupServiceProvider.overrideWithValue( - FakeStartupService(isEnabledValue: isStartupEnabled), + startupService ?? FakeStartupService(isEnabledValue: isStartupEnabled), ), launchServiceProvider.overrideWithValue(launchService), iconExtractorProvider.overrideWithValue(FakeIconExtractor()), diff --git a/apps/linkunbound/test/platform/win_diagnostics_service_test.dart b/apps/linkunbound/test/platform/win_diagnostics_service_test.dart index 6c95cc8..d748ae3 100644 --- a/apps/linkunbound/test/platform/win_diagnostics_service_test.dart +++ b/apps/linkunbound/test/platform/win_diagnostics_service_test.dart @@ -1,7 +1,17 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:archive/archive.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:linkunbound/platform/windows/win_diagnostics_service.dart'; +void _stubRegistry(Directory staging) { + File( + '${staging.path}${Platform.pathSeparator}registry.txt', + ).writeAsStringSync(''); +} + void main() { group('parseWindowsVersion', () { test('returns Windows 11 for build 22000', () { @@ -30,4 +40,125 @@ void main() { expect(parseWindowsVersion(raw), equals(raw)); }); }); + + group('exportDiagnostics', () { + late Directory appDataDir; + + Future export({String appVersion = '1.0.0'}) => exportDiagnostics( + appDataDir: appDataDir, + appVersion: appVersion, + registryDumper: _stubRegistry, + ); + + setUp(() { + appDataDir = Directory.systemTemp.createTempSync('lu_diag_test_'); + }); + + tearDown(() { + if (appDataDir.existsSync()) appDataDir.deleteSync(recursive: true); + }); + + List zipFiles(String zipPath) { + final bytes = File(zipPath).readAsBytesSync(); + return ZipDecoder().decodeBytes(bytes).files; + } + + test('creates a zip file and returns its path', () async { + final zipPath = await export(appVersion: '1.0.0-test'); + expect(File(zipPath).existsSync(), isTrue); + }); + + test('returned path is inside appDataDir', () async { + final zipPath = await export(); + expect(zipPath, startsWith(appDataDir.path)); + }); + + test('zip contains system_info.txt', () async { + final zipPath = await export(); + final names = zipFiles(zipPath).map((f) => f.name).toList(); + expect(names, contains('system_info.txt')); + }); + + test('zip contains registry.txt', () async { + final zipPath = await export(); + final names = zipFiles(zipPath).map((f) => f.name).toList(); + expect(names, contains('registry.txt')); + }); + + test('system_info.txt contains the app version', () async { + final zipPath = await export(appVersion: '9.8.7-diag-test'); + final files = zipFiles(zipPath); + final sysInfo = files.firstWhere((f) => f.name == 'system_info.txt'); + final content = utf8.decode(sysInfo.content as List); + expect(content, contains('9.8.7-diag-test')); + }); + + test('system_info.txt includes appDataDir path', () async { + final zipPath = await export(); + final files = zipFiles(zipPath); + final sysInfo = files.firstWhere((f) => f.name == 'system_info.txt'); + final content = utf8.decode(sysInfo.content as List); + expect(content, contains(appDataDir.path)); + }); + + test('includes navigate.log when file exists', () async { + File( + '${appDataDir.path}${Platform.pathSeparator}navigate.log', + ).writeAsStringSync('line1\nline2\nline3'); + + final zipPath = await export(); + final names = zipFiles(zipPath).map((f) => f.name).toList(); + expect(names, contains('navigate.log')); + }); + + test('navigate.log content is preserved when small', () async { + File( + '${appDataDir.path}${Platform.pathSeparator}navigate.log', + ).writeAsStringSync('alpha\nbeta\ngamma'); + + final zipPath = await export(); + final files = zipFiles(zipPath); + final logFile = files.firstWhere((f) => f.name == 'navigate.log'); + final content = utf8.decode(logFile.content as List); + expect(content, contains('alpha')); + expect(content, contains('gamma')); + }); + + test('navigate.log is truncated to last 200 lines when large', () async { + final lines = List.generate(350, (i) => 'entry $i'); + File( + '${appDataDir.path}${Platform.pathSeparator}navigate.log', + ).writeAsStringSync(lines.join('\n')); + + final zipPath = await export(); + final files = zipFiles(zipPath); + final logFile = files.firstWhere((f) => f.name == 'navigate.log'); + final content = utf8.decode(logFile.content as List); + final resultLines = content + .split('\n') + .where((l) => l.isNotEmpty) + .toList(); + expect(resultLines.length, 200); + expect(resultLines.first, 'entry 150'); + expect(resultLines.last, 'entry 349'); + }); + + test('navigate.log missing is silently skipped', () async { + final zipPath = await export(); + final names = zipFiles(zipPath).map((f) => f.name).toList(); + expect(names, isNot(contains('navigate.log'))); + }); + + test('data files listed in system_info.txt', () async { + File( + '${appDataDir.path}${Platform.pathSeparator}browsers.json', + ).writeAsStringSync('[]'); + + final zipPath = await export(); + final files = zipFiles(zipPath); + final sysInfo = files.firstWhere((f) => f.name == 'system_info.txt'); + final content = utf8.decode(sysInfo.content as List); + expect(content, contains('browsers.json')); + }); + }); } diff --git a/apps/linkunbound/test/ui/general_page_test.dart b/apps/linkunbound/test/ui/general_page_test.dart index 76cd4b0..9f5a75c 100644 --- a/apps/linkunbound/test/ui/general_page_test.dart +++ b/apps/linkunbound/test/ui/general_page_test.dart @@ -473,4 +473,63 @@ void main() { expect(find.text('Microsoft Edge detected'), findsNothing); }); }); + + group('GeneralPage — refresh with changes', () { + testWidgets('refresh shows result snackbar when new browser detected', ( + tester, + ) async { + // Detector returns _firefox but initial list is empty → added: 1 + final f = makeFixtures(dir: tempDir, detectedBrowsers: [_firefox]); + await tester.pumpWidget( + buildTestApp(const GeneralPage(), overrides: f.overrides), + ); + await tester.pumpAndSettle(); + await tester.tap(find.byTooltip('Refresh browsers')); + await tester.pumpAndSettle(); + expect(find.text('1 added, 0 removed'), findsOneWidget); + }); + + testWidgets('refresh result snackbar contains added count', (tester) async { + final f = makeFixtures( + dir: tempDir, + detectedBrowsers: [_chrome, _firefox], + ); + await tester.pumpWidget( + buildTestApp(const GeneralPage(), overrides: f.overrides), + ); + await tester.pumpAndSettle(); + await tester.tap(find.byTooltip('Refresh browsers')); + await tester.pumpAndSettle(); + expect(find.text('2 added, 0 removed'), findsOneWidget); + }); + }); + + group('GeneralPage — startup toggle error', () { + testWidgets('startup toggle error shows error snackbar', (tester) async { + final f = makeFixtures( + dir: tempDir, + startupService: _ThrowingStartupService(), + isStartupEnabled: false, + ); + await tester.pumpWidget( + buildTestApp(const GeneralPage(), overrides: f.overrides), + ); + await tester.pumpAndSettle(); + await tester.tap(find.byType(Switch)); + await tester.pumpAndSettle(); + expect(find.text('Could not change startup setting'), findsOneWidget); + }); + }); +} + +final class _ThrowingStartupService implements StartupService { + @override + Future enable(String executablePath) => + Future.error(Exception('startup failed')); + + @override + Future disable() => Future.error(Exception('startup failed')); + + @override + Future get isEnabled async => false; } diff --git a/apps/linkunbound/windows/packaging/msix/make_config.yaml b/apps/linkunbound/windows/packaging/msix/make_config.yaml index 81165d2..506ae7d 100644 --- a/apps/linkunbound/windows/packaging/msix/make_config.yaml +++ b/apps/linkunbound/windows/packaging/msix/make_config.yaml @@ -5,5 +5,7 @@ identity_name: rgdevment.LinkUnbound-BrowserPicker logo_path: assets/app_icon.png store: "true" build_windows: "false" +architecture: x64 languages: en-us, es-es capabilities: internetClient +protocol_activation: http, https diff --git a/apps/linkunbound/windows/runner/main.cpp b/apps/linkunbound/windows/runner/main.cpp index 53ea18c..c9183aa 100644 --- a/apps/linkunbound/windows/runner/main.cpp +++ b/apps/linkunbound/windows/runner/main.cpp @@ -22,6 +22,16 @@ int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev, std::vector command_line_arguments = GetCommandLineArguments(); + // Escape hatch for older Windows 10 integrated GPUs (no Mica, limited + // DirectX) that crash the Impeller backend. Users can set this in their + // environment and we fall back to ANGLE/OpenGL. + wchar_t legacy_render[8] = {0}; + DWORD legacy_len = ::GetEnvironmentVariableW( + L"LINKUNBOUND_LEGACY_RENDER", legacy_render, 8); + if (legacy_len > 0 && legacy_render[0] == L'1') { + command_line_arguments.push_back("--no-enable-impeller"); + } + project.set_dart_entrypoint_arguments(std::move(command_line_arguments)); FlutterWindow window(project); @@ -41,3 +51,4 @@ int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev, ::CoUninitialize(); return EXIT_SUCCESS; } + diff --git a/packages/core/lib/src/services/browser_service.dart b/packages/core/lib/src/services/browser_service.dart index 86fcfa3..995bc6b 100644 --- a/packages/core/lib/src/services/browser_service.dart +++ b/packages/core/lib/src/services/browser_service.dart @@ -23,7 +23,6 @@ final class BrowserService { _browsers = []; return; } - _log.info('Loading browsers from ${configFile.path}'); final content = await configFile.readAsString(); final config = BrowserConfig.fromJson( jsonDecode(content) as Map, @@ -32,7 +31,6 @@ final class BrowserService { } Future save() async { - _log.info('Saving ${_browsers.length} browsers to ${configFile.path}'); await configFile.parent.create(recursive: true); final config = BrowserConfig(browsers: _browsers); const encoder = JsonEncoder.withIndent(' '); @@ -40,7 +38,6 @@ final class BrowserService { } Future<({int added, int removed})> scanAndMerge() async { - _log.info('Scanning for browsers'); final detected = await browserDetector.detect(); final detectedById = {for (final d in detected) d.id: d}; @@ -63,10 +60,12 @@ final class BrowserService { .toList(); _browsers = [...kept, ...newBrowsers]; - _log.info( - 'Scan complete: ${newBrowsers.length} added, $removedCount removed, ' - '${kept.length} kept', - ); + if (newBrowsers.isNotEmpty || removedCount > 0) { + _log.info( + 'Browsers updated: ${newBrowsers.length} added, ' + '$removedCount removed, ${kept.length} kept', + ); + } return (added: newBrowsers.length, removed: removedCount); } @@ -97,6 +96,6 @@ final class BrowserService { if (configFile.existsSync()) { await configFile.delete(); } - _log.info('Browser config reset'); + _log.warning('Browser config reset'); } } diff --git a/packages/core/lib/src/services/log_service.dart b/packages/core/lib/src/services/log_service.dart index e8c94f9..dc89529 100644 --- a/packages/core/lib/src/services/log_service.dart +++ b/packages/core/lib/src/services/log_service.dart @@ -28,15 +28,25 @@ String redactUrls(String text) { return result; } -void initLogging(File logFile) { +void initLogging(File logFile, {Level fileLevel = Level.INFO}) { _logSubscription?.cancel(); _logSubscription = null; - logFile.parent.createSync(recursive: true); - _rotateIfNeeded(logFile); + try { + logFile.parent.createSync(recursive: true); + _rotateIfNeeded(logFile); + } on FileSystemException { + // Best-effort: file logging will be disabled below if writes fail. + } Logger.root.level = Level.ALL; + // Windows GUI subsystem binaries (flutter build windows --release) have no + // valid stdio handles, and even `stderr.hasTerminal` can lie; a single + // write then crashes the main zone asynchronously. On Windows we therefore + // skip console output entirely and rely on the file sink. + var useStderr = !Platform.isWindows; _logSubscription = Logger.root.onRecord.listen((record) { + if (record.level < fileLevel) return; final message = redactUrls(record.message); final line = '${record.time.toIso8601String()} ' @@ -45,7 +55,13 @@ void initLogging(File logFile) { '$message' '${record.error != null ? '\n ${record.error}' : ''}' '${record.stackTrace != null ? '\n ${record.stackTrace}' : ''}'; - stderr.writeln(line); + if (useStderr) { + try { + stderr.writeln(line); + } on Object { + useStderr = false; + } + } try { logFile.writeAsStringSync('$line\n', mode: FileMode.append); } on FileSystemException { diff --git a/packages/core/lib/src/services/rule_service.dart b/packages/core/lib/src/services/rule_service.dart index 7476e23..bb8a834 100644 --- a/packages/core/lib/src/services/rule_service.dart +++ b/packages/core/lib/src/services/rule_service.dart @@ -1,15 +1,12 @@ import 'dart:convert'; import 'dart:io'; -import 'package:logging/logging.dart'; - import '../models/rule.dart'; final class RuleService { RuleService({required this.rulesFile}); final File rulesFile; - final _log = Logger('RuleService'); List _rules = []; @@ -20,7 +17,6 @@ final class RuleService { _rules = []; return; } - _log.info('Loading rules from ${rulesFile.path}'); final content = await rulesFile.readAsString(); final decoded = jsonDecode(content) as List; _rules = decoded @@ -29,7 +25,6 @@ final class RuleService { } Future save() async { - _log.info('Saving ${_rules.length} rules to ${rulesFile.path}'); await rulesFile.parent.create(recursive: true); const encoder = JsonEncoder.withIndent(' '); await rulesFile.writeAsString( diff --git a/packages/core/test/update_service_test.dart b/packages/core/test/update_service_test.dart index db14c5e..ae0f89a 100644 --- a/packages/core/test/update_service_test.dart +++ b/packages/core/test/update_service_test.dart @@ -1,6 +1,91 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + import 'package:linkunbound_core/linkunbound_core.dart'; import 'package:test/test.dart'; +// --- Minimal HTTP stub chain for intercepting UpdateService's HttpClient --- + +final class _StubHeaders implements HttpHeaders { + @override + void set(String name, Object value, {bool preserveHeaderCase = false}) {} + + @override + dynamic noSuchMethod(Invocation invocation) => null; +} + +final class _StubResponse extends Stream> + implements HttpClientResponse { + _StubResponse(this._status, this._body); + final int _status; + final String _body; + + @override + int get statusCode => _status; + + @override + StreamSubscription> listen( + void Function(List event)? onData, { + Function? onError, + void Function()? onDone, + bool? cancelOnError, + }) => Stream.value(utf8.encode(_body)).listen( + onData, + onError: onError, + onDone: onDone, + cancelOnError: cancelOnError, + ); + + @override + dynamic noSuchMethod(Invocation invocation) => null; +} + +final class _StubRequest implements HttpClientRequest { + _StubRequest(this._status, this._body); + final int _status; + final String _body; + + @override + HttpHeaders get headers => _StubHeaders(); + + @override + Future close() async => _StubResponse(_status, _body); + + @override + dynamic noSuchMethod(Invocation invocation) => null; +} + +final class _StubHttpClient implements HttpClient { + _StubHttpClient(this._status, this._body); + final int _status; + final String _body; + + @override + Duration? connectionTimeout; + + @override + Future getUrl(Uri url) async => + _StubRequest(_status, _body); + + @override + void close({bool force = false}) {} + + @override + dynamic noSuchMethod(Invocation invocation) => null; +} + +Future _check({ + required int status, + required Map body, + required String current, +}) => HttpOverrides.runZoned( + () => const UpdateService(owner: 'o', repo: 'r').checkForUpdate(current), + createHttpClient: (_) => _StubHttpClient(status, jsonEncode(body)), +); + +// --------------------------------------------------------------------------- + void main() { group('UpdateInfo', () { test('stores latestVersion and releaseUrl', () { @@ -46,4 +131,115 @@ void main() { timeout: const Timeout(Duration(seconds: 10)), ); }); + + group('UpdateService HTTP paths', () { + test('returns UpdateInfo when newer major version available', () async { + final result = await _check( + status: 200, + body: { + 'tag_name': 'v2.0.0', + 'html_url': 'https://github.com/o/r/releases/tag/v2.0.0', + }, + current: '1.0.0', + ); + expect(result, isNotNull); + expect(result!.latestVersion, '2.0.0'); + expect(result.releaseUrl, 'https://github.com/o/r/releases/tag/v2.0.0'); + }); + + test('strips v prefix from tag_name', () async { + final result = await _check( + status: 200, + body: {'tag_name': 'v1.5.0', 'html_url': 'https://example.com'}, + current: '1.0.0', + ); + expect(result!.latestVersion, '1.5.0'); + }); + + test('accepts tag_name without v prefix', () async { + final result = await _check( + status: 200, + body: {'tag_name': '1.5.0', 'html_url': 'https://example.com'}, + current: '1.0.0', + ); + expect(result!.latestVersion, '1.5.0'); + }); + + test('returns null when version is equal to current', () async { + final result = await _check( + status: 200, + body: {'tag_name': 'v1.0.0', 'html_url': 'https://example.com'}, + current: '1.0.0', + ); + expect(result, isNull); + }); + + test('returns null when latest is older than current', () async { + final result = await _check( + status: 200, + body: {'tag_name': 'v0.9.9', 'html_url': 'https://example.com'}, + current: '1.0.0', + ); + expect(result, isNull); + }); + + test('returns null on non-200 status', () async { + final result = await _check(status: 404, body: {}, current: '1.0.0'); + expect(result, isNull); + }); + + test('returns null when tag_name is null', () async { + final result = await _check( + status: 200, + body: {'tag_name': null, 'html_url': 'https://example.com'}, + current: '1.0.0', + ); + expect(result, isNull); + }); + + test('returns null when html_url is null', () async { + final result = await _check( + status: 200, + body: {'tag_name': 'v2.0.0', 'html_url': null}, + current: '1.0.0', + ); + expect(result, isNull); + }); + + test('minor version bump triggers update', () async { + final result = await _check( + status: 200, + body: {'tag_name': 'v1.1.0', 'html_url': 'https://example.com'}, + current: '1.0.9', + ); + expect(result, isNotNull); + }); + + test('patch version bump triggers update', () async { + final result = await _check( + status: 200, + body: {'tag_name': 'v1.0.1', 'html_url': 'https://example.com'}, + current: '1.0.0', + ); + expect(result, isNotNull); + }); + + test('major rollback returns null', () async { + final result = await _check( + status: 200, + body: {'tag_name': 'v2.0.0', 'html_url': 'https://example.com'}, + current: '3.0.0', + ); + expect(result, isNull); + }); + + test('minor rollback returns null', () async { + final result = await _check( + status: 200, + body: {'tag_name': 'v1.0.0', 'html_url': 'https://example.com'}, + current: '1.1.0', + ); + expect(result, isNull); + }); + }); }