From 955676e7f05be68a690c862d4fa81e625a78bb7f Mon Sep 17 00:00:00 2001 From: atavism Date: Thu, 26 Mar 2026 08:25:41 -0700 Subject: [PATCH 1/6] Fix remaining Windows issues --- .../home/provider/app_setting_notifier.dart | 124 ++++++++++++------ lib/features/vpn/provider/vpn_notifier.dart | 39 ++++-- .../vpn_transition_origin_tracker.dart | 60 +++++++++ lib/lantern/lantern_ffi_service.dart | 10 +- 4 files changed, 181 insertions(+), 52 deletions(-) create mode 100644 lib/features/vpn/provider/vpn_transition_origin_tracker.dart diff --git a/lib/features/home/provider/app_setting_notifier.dart b/lib/features/home/provider/app_setting_notifier.dart index ce84bddcdb..f46336433f 100644 --- a/lib/features/home/provider/app_setting_notifier.dart +++ b/lib/features/home/provider/app_setting_notifier.dart @@ -11,6 +11,7 @@ import 'package:lantern/core/services/local_storage_service.dart'; import 'package:lantern/core/utils/storage_utils.dart'; import 'package:lantern/lantern/lantern_service.dart'; import 'package:lantern/lantern/lantern_service_notifier.dart'; +import 'package:lantern/features/vpn/provider/vpn_transition_origin_tracker.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:window_manager/window_manager.dart'; @@ -19,6 +20,8 @@ part 'app_setting_notifier.g.dart'; @Riverpod(keepAlive: true) class AppSettingNotifier extends _$AppSettingNotifier { LocalStorageService get _storage => sl(); + bool _isRoutingModeUpdateInFlight = false; + bool _isBlockAdsUpdateInFlight = false; @override AppSetting build() { @@ -36,13 +39,15 @@ class AppSettingNotifier extends _$AppSettingNotifier { if (settings == null) { appLogger.info( - 'No stored settings found, saving defaults: ${_settingsLogFields(fallback)}'); + 'No stored settings found, saving defaults: ${_settingsLogFields(fallback)}', + ); unawaited(_storage.saveAppSettings(fallback)); return fallback; } - appLogger - .info('Loaded stored app settings: ${_settingsLogFields(settings)}'); + appLogger.info( + 'Loaded stored app settings: ${_settingsLogFields(settings)}', + ); return settings; } @@ -62,19 +67,38 @@ class AppSettingNotifier extends _$AppSettingNotifier { update(state.copyWith(newIsSpiltTunnelingOn: value)); Future> setRoutingMode(RoutingMode mode) async { + if (state.routingMode == mode) { + return right(unit); + } + + if (_isRoutingModeUpdateInFlight) { + appLogger.info( + 'Routing mode update already in progress. Ignoring duplicate request.', + ); + return right(unit); + } + + _isRoutingModeUpdateInFlight = true; final prev = state.routingModeRaw; appLogger.info('Setting routing mode to: ${mode.key}'); - update(state.copyWith(routingModeRaw: mode.key)); + await update(state.copyWith(routingModeRaw: mode.key)); - final lantern = ref.read(lanternServiceProvider); - final res = await lantern.setRoutingMode(mode == RoutingMode.smart); - - res.fold((f) { - appLogger.error('Failed to set routing mode', f); - update(state.copyWith(routingModeRaw: prev)); - }, (_) {}); - return res; + try { + final lantern = ref.read(lanternServiceProvider); + final tracker = ref.read(vpnTransitionOriginTrackerProvider); + final res = await tracker.runAsSettingsMutation( + () => lantern.setRoutingMode(mode == RoutingMode.smart), + ); + + await res.match((f) async { + appLogger.error('Failed to set routing mode', f); + await update(state.copyWith(routingModeRaw: prev)); + }, (_) async {}); + return res; + } finally { + _isRoutingModeUpdateInFlight = false; + } } void setUserLoggedIn(bool value) => @@ -90,16 +114,39 @@ class AppSettingNotifier extends _$AppSettingNotifier { update(state.copyWith(successfulConnection: value)); void setBlockAds(bool value) { + unawaited(_setBlockAds(value)); + } + + Future _setBlockAds(bool value) async { + if (state.blockAds == value) { + return; + } + + if (_isBlockAdsUpdateInFlight) { + appLogger.info( + 'Block ads update already in progress. Ignoring duplicate request.', + ); + return; + } + + _isBlockAdsUpdateInFlight = true; final prev = state.blockAds; - update(state.copyWith(blockAds: value)); + await update(state.copyWith(blockAds: value)); - final svc = ref.read(lanternServiceProvider); - svc.setBlockAdsEnabled(value).then((res) { - res.match((err) { + try { + final svc = ref.read(lanternServiceProvider); + final tracker = ref.read(vpnTransitionOriginTrackerProvider); + final res = await tracker.runAsSettingsMutation( + () => svc.setBlockAdsEnabled(value), + ); + + await res.match((err) async { appLogger.error('setBlockAdsEnabled failed: ${err.error}'); - update(state.copyWith(blockAds: prev)); - }, (_) {}); - }); + await update(state.copyWith(blockAds: prev)); + }, (_) async {}); + } finally { + _isBlockAdsUpdateInFlight = false; + } } void updateAnonymousDataConsent(bool value) { @@ -192,8 +239,9 @@ class AppSettingNotifier extends _$AppSettingNotifier { } Future updateTelemetryConsent(bool consent) async { - final result = - await ref.read(lanternServiceProvider).updateTelemetryEvents(consent); + final result = await ref + .read(lanternServiceProvider) + .updateTelemetryEvents(consent); result.fold( (err) { @@ -208,21 +256,21 @@ class AppSettingNotifier extends _$AppSettingNotifier { } Map _settingsLogFields(AppSetting setting) => { - 'isPro': setting.isPro, - 'isSplitTunnelingOn': setting.isSplitTunnelingOn, - 'themeMode': setting.themeMode, - 'environment': setting.environment, - 'locale': setting.locale, - 'userLoggedIn': setting.userLoggedIn, - 'blockAds': setting.blockAds, - 'showSplashScreen': setting.showSplashScreen, - 'telemetryDialogDismissed': setting.telemetryDialogDismissed, - 'telemetryConsent': setting.telemetryConsent, - 'successfulConnection': setting.successfulConnection, - 'routingModeRaw': setting.routingModeRaw, - 'dataCapThreshold': setting.dataCapThreshold, - 'onboardingCompleted': setting.onboardingCompleted, - 'hasOAuthToken': setting.oAuthToken.isNotEmpty, - 'hasEmail': setting.email.isNotEmpty, - }; + 'isPro': setting.isPro, + 'isSplitTunnelingOn': setting.isSplitTunnelingOn, + 'themeMode': setting.themeMode, + 'environment': setting.environment, + 'locale': setting.locale, + 'userLoggedIn': setting.userLoggedIn, + 'blockAds': setting.blockAds, + 'showSplashScreen': setting.showSplashScreen, + 'telemetryDialogDismissed': setting.telemetryDialogDismissed, + 'telemetryConsent': setting.telemetryConsent, + 'successfulConnection': setting.successfulConnection, + 'routingModeRaw': setting.routingModeRaw, + 'dataCapThreshold': setting.dataCapThreshold, + 'onboardingCompleted': setting.onboardingCompleted, + 'hasOAuthToken': setting.oAuthToken.isNotEmpty, + 'hasEmail': setting.email.isNotEmpty, + }; } diff --git a/lib/features/vpn/provider/vpn_notifier.dart b/lib/features/vpn/provider/vpn_notifier.dart index d19c77162f..9178c865d1 100644 --- a/lib/features/vpn/provider/vpn_notifier.dart +++ b/lib/features/vpn/provider/vpn_notifier.dart @@ -7,6 +7,7 @@ import 'package:lantern/core/services/injection_container.dart'; import 'package:lantern/core/services/notification_service.dart'; import 'package:lantern/features/home/provider/app_setting_notifier.dart'; import 'package:lantern/features/vpn/provider/server_location_notifier.dart'; +import 'package:lantern/features/vpn/provider/vpn_transition_origin_tracker.dart'; import 'package:lantern/features/vpn/provider/vpn_status_notifier.dart'; import 'package:lantern/lantern/lantern_service_notifier.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; @@ -21,17 +22,29 @@ class VpnNotifier extends _$VpnNotifier { ref.listen(vPNStatusProvider, (previous, next) { final previousStatus = previous?.value?.status; final nextStatus = next.value!.status; + final suppressConnectionNotifications = + ref + .read(vpnTransitionOriginTrackerProvider) + .isInSettingsMutationWindow && + (nextStatus == VPNStatus.connected || + nextStatus == VPNStatus.disconnected); if (previous != null && previous.value != null && previousStatus != nextStatus) { if (previousStatus != VPNStatus.connecting && nextStatus == VPNStatus.disconnected) { - sl().showNotification( - id: NotificationEvent.vpnDisconnected.id, - title: 'app_name'.i18n, - body: 'vpn_disconnected'.i18n, - ); + if (!suppressConnectionNotifications) { + sl().showNotification( + id: NotificationEvent.vpnDisconnected.id, + title: 'app_name'.i18n, + body: 'vpn_disconnected'.i18n, + ); + } else { + appLogger.debug( + 'Suppressed vpn_disconnected notification (settings-driven reconnect)', + ); + } } else if (nextStatus == VPNStatus.connected) { if (PlatformUtils.isMobile) { HapticFeedback.mediumImpact(); @@ -45,11 +58,17 @@ class VpnNotifier extends _$VpnNotifier { // getAutoServerLocation here. This avoids a race where the NE // reports "connected" before the Go tunnel is fully ready. - sl().showNotification( - id: NotificationEvent.vpnConnected.id, - title: 'app_name'.i18n, - body: 'vpn_connected'.i18n, - ); + if (!suppressConnectionNotifications) { + sl().showNotification( + id: NotificationEvent.vpnConnected.id, + title: 'app_name'.i18n, + body: 'vpn_connected'.i18n, + ); + } else { + appLogger.debug( + 'Suppressed vpn_connected notification (settings-driven reconnect)', + ); + } } } state = nextStatus; diff --git a/lib/features/vpn/provider/vpn_transition_origin_tracker.dart b/lib/features/vpn/provider/vpn_transition_origin_tracker.dart new file mode 100644 index 0000000000..b32c8c31b0 --- /dev/null +++ b/lib/features/vpn/provider/vpn_transition_origin_tracker.dart @@ -0,0 +1,60 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +/// Tracks whether current VPN status transitions are likely settings-driven. +/// +/// The VPN status stream does not include the origin/cause of transitions, so +/// we track expected settings-triggered reconnect windows here and let +/// [VpnNotifier] decide whether connection notifications should be shown. +final vpnTransitionOriginTrackerProvider = Provider( + (ref) { + final tracker = VpnTransitionOriginTracker(); + ref.onDispose(tracker.dispose); + return tracker; + }, +); + +class VpnTransitionOriginTracker { + int _activeScopes = 0; + DateTime _settingsMutationUntil = DateTime.fromMillisecondsSinceEpoch(0); + + bool get isInSettingsMutationWindow { + if (_activeScopes > 0) { + return true; + } + return DateTime.now().isBefore(_settingsMutationUntil); + } + + void beginSettingsMutation() { + _activeScopes++; + } + + void endSettingsMutation({ + Duration settleWindow = const Duration(seconds: 8), + }) { + if (_activeScopes > 0) { + _activeScopes--; + } + + final candidate = DateTime.now().add(settleWindow); + if (candidate.isAfter(_settingsMutationUntil)) { + _settingsMutationUntil = candidate; + } + } + + Future runAsSettingsMutation( + Future Function() operation, { + Duration settleWindow = const Duration(seconds: 8), + }) async { + beginSettingsMutation(); + try { + return await operation(); + } finally { + endSettingsMutation(settleWindow: settleWindow); + } + } + + void dispose() { + _activeScopes = 0; + _settingsMutationUntil = DateTime.fromMillisecondsSinceEpoch(0); + } +} diff --git a/lib/lantern/lantern_ffi_service.dart b/lib/lantern/lantern_ffi_service.dart index a75c46ce7d..f203debbd3 100644 --- a/lib/lantern/lantern_ffi_service.dart +++ b/lib/lantern/lantern_ffi_service.dart @@ -1539,10 +1539,12 @@ class LanternFFIService implements LanternCoreService { @override Future> setBlockAdsEnabled(bool enabled) async { try { - final result = _ffiService - .setBlockAdsEnabled(enabled ? 1 : 0) - .cast() - .toDartString(); + final result = await runInBackground(() async { + return _ffiService + .setBlockAdsEnabled(enabled ? 1 : 0) + .cast() + .toDartString(); + }); checkAPIError(result); return right(unit); } catch (e, st) { From c9f230756ff555caaaf8e8a9a4616fc081940413 Mon Sep 17 00:00:00 2001 From: atavism Date: Thu, 26 Mar 2026 08:41:23 -0700 Subject: [PATCH 2/6] Fix remaining Windows issues --- lib/core/models/lantern_status.dart | 35 ++++++-- .../home/provider/app_setting_notifier.dart | 11 +-- lib/features/vpn/provider/vpn_notifier.dart | 11 ++- .../vpn_transition_origin_tracker.dart | 60 -------------- lib/lantern/lantern_ffi_service.dart | 13 +++ lib/lantern/lantern_windows_service.dart | 83 ++++++++++++++++--- 6 files changed, 121 insertions(+), 92 deletions(-) delete mode 100644 lib/features/vpn/provider/vpn_transition_origin_tracker.dart diff --git a/lib/core/models/lantern_status.dart b/lib/core/models/lantern_status.dart index 955d353ba7..ece58d6901 100644 --- a/lib/core/models/lantern_status.dart +++ b/lib/core/models/lantern_status.dart @@ -9,9 +9,12 @@ import '../common/common.dart'; +enum VPNStatusOrigin { userAction, settingsMutation, system, unknown } + class LanternStatus { final VPNStatus status; final String? error; + final VPNStatusOrigin origin; factory LanternStatus.fromJson(Map json) { appLogger.info('LanternStatus.fromJson $json'); @@ -33,14 +36,34 @@ class LanternStatus { appLogger.error('Unknown status: $statusStr'); status = VPNStatus.disconnected; } - return LanternStatus( - status: status, - error: json['error'], - ); + final origin = _originFromJson(json['origin']); + return LanternStatus(status: status, error: json['error'], origin: origin); } - LanternStatus({required this.status, this.error}); + LanternStatus({ + required this.status, + this.error, + this.origin = VPNStatusOrigin.unknown, + }); @override - String toString() => 'LanternStatus(status: $status, error: $error)'; + String toString() => + 'LanternStatus(status: $status, error: $error, origin: $origin)'; + + static VPNStatusOrigin _originFromJson(dynamic rawOrigin) { + if (rawOrigin is! String) { + return VPNStatusOrigin.unknown; + } + + switch (rawOrigin.toLowerCase()) { + case 'user_action': + return VPNStatusOrigin.userAction; + case 'settings_mutation': + return VPNStatusOrigin.settingsMutation; + case 'system': + return VPNStatusOrigin.system; + default: + return VPNStatusOrigin.unknown; + } + } } diff --git a/lib/features/home/provider/app_setting_notifier.dart b/lib/features/home/provider/app_setting_notifier.dart index f46336433f..9853660fe1 100644 --- a/lib/features/home/provider/app_setting_notifier.dart +++ b/lib/features/home/provider/app_setting_notifier.dart @@ -11,7 +11,6 @@ import 'package:lantern/core/services/local_storage_service.dart'; import 'package:lantern/core/utils/storage_utils.dart'; import 'package:lantern/lantern/lantern_service.dart'; import 'package:lantern/lantern/lantern_service_notifier.dart'; -import 'package:lantern/features/vpn/provider/vpn_transition_origin_tracker.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:window_manager/window_manager.dart'; @@ -86,10 +85,7 @@ class AppSettingNotifier extends _$AppSettingNotifier { try { final lantern = ref.read(lanternServiceProvider); - final tracker = ref.read(vpnTransitionOriginTrackerProvider); - final res = await tracker.runAsSettingsMutation( - () => lantern.setRoutingMode(mode == RoutingMode.smart), - ); + final res = await lantern.setRoutingMode(mode == RoutingMode.smart); await res.match((f) async { appLogger.error('Failed to set routing mode', f); @@ -135,10 +131,7 @@ class AppSettingNotifier extends _$AppSettingNotifier { try { final svc = ref.read(lanternServiceProvider); - final tracker = ref.read(vpnTransitionOriginTrackerProvider); - final res = await tracker.runAsSettingsMutation( - () => svc.setBlockAdsEnabled(value), - ); + final res = await svc.setBlockAdsEnabled(value); await res.match((err) async { appLogger.error('setBlockAdsEnabled failed: ${err.error}'); diff --git a/lib/features/vpn/provider/vpn_notifier.dart b/lib/features/vpn/provider/vpn_notifier.dart index 9178c865d1..8c11a122e1 100644 --- a/lib/features/vpn/provider/vpn_notifier.dart +++ b/lib/features/vpn/provider/vpn_notifier.dart @@ -2,12 +2,12 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:fpdart/fpdart.dart'; import 'package:lantern/core/common/common.dart'; +import 'package:lantern/core/models/lantern_status.dart'; import 'package:lantern/core/models/notification_event.dart'; import 'package:lantern/core/services/injection_container.dart'; import 'package:lantern/core/services/notification_service.dart'; import 'package:lantern/features/home/provider/app_setting_notifier.dart'; import 'package:lantern/features/vpn/provider/server_location_notifier.dart'; -import 'package:lantern/features/vpn/provider/vpn_transition_origin_tracker.dart'; import 'package:lantern/features/vpn/provider/vpn_status_notifier.dart'; import 'package:lantern/lantern/lantern_service_notifier.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; @@ -22,10 +22,9 @@ class VpnNotifier extends _$VpnNotifier { ref.listen(vPNStatusProvider, (previous, next) { final previousStatus = previous?.value?.status; final nextStatus = next.value!.status; + final nextOrigin = next.value!.origin; final suppressConnectionNotifications = - ref - .read(vpnTransitionOriginTrackerProvider) - .isInSettingsMutationWindow && + nextOrigin == VPNStatusOrigin.settingsMutation && (nextStatus == VPNStatus.connected || nextStatus == VPNStatus.disconnected); @@ -42,7 +41,7 @@ class VpnNotifier extends _$VpnNotifier { ); } else { appLogger.debug( - 'Suppressed vpn_disconnected notification (settings-driven reconnect)', + 'Suppressed vpn_disconnected notification (origin=$nextOrigin)', ); } } else if (nextStatus == VPNStatus.connected) { @@ -66,7 +65,7 @@ class VpnNotifier extends _$VpnNotifier { ); } else { appLogger.debug( - 'Suppressed vpn_connected notification (settings-driven reconnect)', + 'Suppressed vpn_connected notification (origin=$nextOrigin)', ); } } diff --git a/lib/features/vpn/provider/vpn_transition_origin_tracker.dart b/lib/features/vpn/provider/vpn_transition_origin_tracker.dart deleted file mode 100644 index b32c8c31b0..0000000000 --- a/lib/features/vpn/provider/vpn_transition_origin_tracker.dart +++ /dev/null @@ -1,60 +0,0 @@ -import 'package:hooks_riverpod/hooks_riverpod.dart'; - -/// Tracks whether current VPN status transitions are likely settings-driven. -/// -/// The VPN status stream does not include the origin/cause of transitions, so -/// we track expected settings-triggered reconnect windows here and let -/// [VpnNotifier] decide whether connection notifications should be shown. -final vpnTransitionOriginTrackerProvider = Provider( - (ref) { - final tracker = VpnTransitionOriginTracker(); - ref.onDispose(tracker.dispose); - return tracker; - }, -); - -class VpnTransitionOriginTracker { - int _activeScopes = 0; - DateTime _settingsMutationUntil = DateTime.fromMillisecondsSinceEpoch(0); - - bool get isInSettingsMutationWindow { - if (_activeScopes > 0) { - return true; - } - return DateTime.now().isBefore(_settingsMutationUntil); - } - - void beginSettingsMutation() { - _activeScopes++; - } - - void endSettingsMutation({ - Duration settleWindow = const Duration(seconds: 8), - }) { - if (_activeScopes > 0) { - _activeScopes--; - } - - final candidate = DateTime.now().add(settleWindow); - if (candidate.isAfter(_settingsMutationUntil)) { - _settingsMutationUntil = candidate; - } - } - - Future runAsSettingsMutation( - Future Function() operation, { - Duration settleWindow = const Duration(seconds: 8), - }) async { - beginSettingsMutation(); - try { - return await operation(); - } finally { - endSettingsMutation(settleWindow: settleWindow); - } - } - - void dispose() { - _activeScopes = 0; - _settingsMutationUntil = DateTime.fromMillisecondsSinceEpoch(0); - } -} diff --git a/lib/lantern/lantern_ffi_service.dart b/lib/lantern/lantern_ffi_service.dart index f203debbd3..74d2e05b23 100644 --- a/lib/lantern/lantern_ffi_service.dart +++ b/lib/lantern/lantern_ffi_service.dart @@ -275,6 +275,14 @@ class LanternFFIService implements LanternCoreService { return initFuture; } + Future _markWindowsStatusOrigin(VPNStatusOrigin origin) async { + if (!Platform.isWindows) { + return; + } + final ws = await _getOrInitWindowsService(); + ws?.setNextStatusOrigin(origin); + } + @override Stream watchAppEvents() { return _appEvents; @@ -299,6 +307,7 @@ class LanternFFIService implements LanternCoreService { @override Future> setRoutingMode(bool mode) async { try { + await _markWindowsStatusOrigin(VPNStatusOrigin.settingsMutation); final result = await runInBackground(() async { return _ffiService.setSmartRoutingEnabled(mode ? 1 : 0).toDartString(); }); @@ -592,6 +601,7 @@ class LanternFFIService implements LanternCoreService { ); } + ws.setNextStatusOrigin(VPNStatusOrigin.userAction); return ws.connect(); } @@ -681,6 +691,7 @@ class LanternFFIService implements LanternCoreService { ); } + ws.setNextStatusOrigin(VPNStatusOrigin.userAction); return ws.connectToServer(location, tag); } @@ -734,6 +745,7 @@ class LanternFFIService implements LanternCoreService { return right('ok'); } + ws.setNextStatusOrigin(VPNStatusOrigin.userAction); return ws.disconnect(); } @@ -1539,6 +1551,7 @@ class LanternFFIService implements LanternCoreService { @override Future> setBlockAdsEnabled(bool enabled) async { try { + await _markWindowsStatusOrigin(VPNStatusOrigin.settingsMutation); final result = await runInBackground(() async { return _ffiService .setBlockAdsEnabled(enabled ? 1 : 0) diff --git a/lib/lantern/lantern_windows_service.dart b/lib/lantern/lantern_windows_service.dart index af19867b6c..a18cfddc0c 100644 --- a/lib/lantern/lantern_windows_service.dart +++ b/lib/lantern/lantern_windows_service.dart @@ -9,11 +9,17 @@ import 'package:lantern/core/windows/pipe_commands.dart'; class LanternServiceWindows { LanternServiceWindows(this._rpcPipe); + static const Duration _statusOriginTtl = Duration(seconds: 15); + final PipeClient _rpcPipe; // dedicated streaming pipes PipeClient? _statusPipe; PipeClient? _logsPipe; + VPNStatusOrigin _pendingStatusOrigin = VPNStatusOrigin.unknown; + DateTime _pendingStatusOriginExpiresAt = DateTime.fromMillisecondsSinceEpoch( + 0, + ); Future init() async { try { @@ -58,7 +64,9 @@ class LanternServiceWindows { } Future> connectToServer( - String location, String tag) async { + String location, + String tag, + ) async { try { await _rpcPipe.call(ServiceCommand.connectToServer.wire, { 'location': location, @@ -67,7 +75,9 @@ class LanternServiceWindows { return right('ok'); } catch (e) { appLogger.error( - '[WS] connectToServer() failed for location=$location, tag=$tag', e); + '[WS] connectToServer() failed for location=$location, tag=$tag', + e, + ); return Left(e.toFailure()); } } @@ -82,17 +92,68 @@ class LanternServiceWindows { } } + void setNextStatusOrigin(VPNStatusOrigin origin) { + _pendingStatusOrigin = origin; + _pendingStatusOriginExpiresAt = DateTime.now().add(_statusOriginTtl); + } + Stream watchVPNStatus() { _statusPipe ??= PipeClient(token: _rpcPipe.token); - return _statusPipe!.watchStatus().map((evt) { - final data = evt; - final raw = data['state'] as String? ?? 'Disconnected'; - final error = data['error']; - return LanternStatus.fromJson( - {'status': raw.toLowerCase(), 'error': error}); - }).handleError((error, st) { - appLogger.error('[WS] watchStatus() stream error', error, st); - }); + return _statusPipe! + .watchStatus() + .map((evt) { + final data = evt; + final raw = data['state'] as String? ?? 'Disconnected'; + final error = data['error']; + final origin = _resolveStatusOrigin(data['origin']); + final status = LanternStatus.fromJson({ + 'status': raw.toLowerCase(), + 'error': error, + 'origin': origin, + }); + + final terminal = + status.status == VPNStatus.connected || + status.status == VPNStatus.disconnected || + status.status == VPNStatus.error; + if (_pendingStatusOrigin != VPNStatusOrigin.unknown && terminal) { + _clearPendingStatusOrigin(); + } + + return status; + }) + .handleError((error, st) { + appLogger.error('[WS] watchStatus() stream error', error, st); + }); + } + + String _resolveStatusOrigin(dynamic originFromEvent) { + if (originFromEvent is String && originFromEvent.isNotEmpty) { + return originFromEvent; + } + + if (DateTime.now().isAfter(_pendingStatusOriginExpiresAt)) { + _clearPendingStatusOrigin(); + } + return _originToWireValue(_pendingStatusOrigin); + } + + void _clearPendingStatusOrigin() { + _pendingStatusOrigin = VPNStatusOrigin.unknown; + _pendingStatusOriginExpiresAt = DateTime.fromMillisecondsSinceEpoch(0); + } + + static String _originToWireValue(VPNStatusOrigin origin) { + switch (origin) { + case VPNStatusOrigin.userAction: + return 'user_action'; + case VPNStatusOrigin.settingsMutation: + return 'settings_mutation'; + case VPNStatusOrigin.system: + return 'system'; + case VPNStatusOrigin.unknown: + return 'unknown'; + } } Stream> watchLogs() { From 1be9e46deb233225e3f3f3f511f3698fd5934c44 Mon Sep 17 00:00:00 2001 From: atavism Date: Thu, 26 Mar 2026 11:32:12 -0700 Subject: [PATCH 3/6] Bump radiance for smart-routing outbound fix --- go.mod | 2 +- go.sum | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/go.mod b/go.mod index c9e39806fc..b7b190b563 100644 --- a/go.mod +++ b/go.mod @@ -25,7 +25,7 @@ require ( github.com/alecthomas/assert/v2 v2.3.0 github.com/getlantern/common v1.2.1-0.20260325181816-33f69c725899 github.com/getlantern/lantern-server-provisioner v0.0.0-20251031121934-8ea031fccfa9 - github.com/getlantern/radiance v0.0.0-20260326151852-020898b9b028 + github.com/getlantern/radiance v0.0.0-20260326182827-e35d6c1be462 github.com/sagernet/sing-box v1.12.22 golang.org/x/mobile v0.0.0-20250711185624-d5bb5ecc55c0 golang.org/x/sys v0.41.0 diff --git a/go.sum b/go.sum index 1ae47145e6..551c0d581c 100644 --- a/go.sum +++ b/go.sum @@ -261,6 +261,8 @@ github.com/getlantern/pluriconfig v0.0.0-20251126214241-8cc8bc561535 h1:rtDmW8YL github.com/getlantern/pluriconfig v0.0.0-20251126214241-8cc8bc561535/go.mod h1:WKJEdjMOD4IuTRYwjQHjT4bmqDl5J82RShMLxPAvi0Q= github.com/getlantern/radiance v0.0.0-20260326151852-020898b9b028 h1:LXR1YBRvbrhhc7/G8yBSzB4oNJ59vbf8ZIUHbiednEk= github.com/getlantern/radiance v0.0.0-20260326151852-020898b9b028/go.mod h1:jn8+Y7vnyULW+8DX1iDp3qKVBa81EUWFVDvMvkaZrQE= +github.com/getlantern/radiance v0.0.0-20260326182827-e35d6c1be462 h1:80p2Lm7Q7opsCKkQZBc4jbciVBCeYbqBUNgquVokVUc= +github.com/getlantern/radiance v0.0.0-20260326182827-e35d6c1be462/go.mod h1:jn8+Y7vnyULW+8DX1iDp3qKVBa81EUWFVDvMvkaZrQE= github.com/getlantern/samizdat v0.0.3-0.20260310125445-325cf1bd1b60 h1:m9eXjDK9vllbVH467+QXbrxUFFM9Yp7YJ90wZLw4dwU= github.com/getlantern/samizdat v0.0.3-0.20260310125445-325cf1bd1b60/go.mod h1:uEeykQSW2/6rTjfPlj3MTTo59poSHXfAHTGgzYDkbr0= github.com/getlantern/sing v0.7.18-lantern h1:QKGgIUA3LwmKYP/7JlQTRkxj9jnP4cX2Q/B+nd8XEjo= From 2b85290ef1d44ceb63434bfaf6748fc752a04644 Mon Sep 17 00:00:00 2001 From: atavism Date: Thu, 26 Mar 2026 14:02:21 -0700 Subject: [PATCH 4/6] Skip Windows signing for nightly test runs --- .github/workflows/build-windows.yml | 13 +++++++++++++ .github/workflows/release.yml | 1 + 2 files changed, 14 insertions(+) diff --git a/.github/workflows/build-windows.yml b/.github/workflows/build-windows.yml index d252e57f7f..31e49ebce6 100644 --- a/.github/workflows/build-windows.yml +++ b/.github/workflows/build-windows.yml @@ -22,6 +22,11 @@ on: required: false type: boolean default: false + skip_signing: + description: "Skip code signing for this run" + required: false + type: boolean + default: false jobs: build-windows: @@ -126,6 +131,7 @@ jobs: } - name: Sign embedded binaries + if: ${{ !inputs.skip_signing }} shell: pwsh env: SIGNPATH_API_TOKEN: ${{ secrets.SIGNPATH_API_TOKEN }} @@ -168,6 +174,12 @@ jobs: Write-Host "All binaries signed successfully" + - name: Signing disabled for this run + if: ${{ inputs.skip_signing }} + shell: pwsh + run: | + Write-Host "Skipping embedded and installer signing for this run." + - name: Package installer shell: pwsh env: @@ -202,6 +214,7 @@ jobs: } - name: Sign installer + if: ${{ !inputs.skip_signing }} shell: pwsh env: SIGNPATH_API_TOKEN: ${{ secrets.SIGNPATH_API_TOKEN }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0487b2c1e4..7e472b97f4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -254,6 +254,7 @@ jobs: build_type: ${{ needs.set-metadata.outputs.build_type }} installer_base_name: ${{ needs.set-metadata.outputs.installer_base_name }} enable_ip_check: ${{ needs.set-metadata.outputs.build_type == 'nightly' }} + skip_signing: ${{ needs.set-metadata.outputs.build_type == 'nightly' && needs.set-metadata.outputs.is_test_run == 'true' }} build-linux: needs: [set-metadata, release-create, release-approval] From 014ab6626b17c7dd43cf98daba57be2696fe4110 Mon Sep 17 00:00:00 2001 From: atavism Date: Thu, 26 Mar 2026 18:16:27 -0700 Subject: [PATCH 5/6] code review updates --- lib/core/models/lantern_status.dart | 44 +++++----- .../home/provider/app_setting_notifier.dart | 83 +++++++++++-------- lib/lantern/lantern_windows_service.dart | 15 +--- 3 files changed, 75 insertions(+), 67 deletions(-) diff --git a/lib/core/models/lantern_status.dart b/lib/core/models/lantern_status.dart index ece58d6901..7d6e236780 100644 --- a/lib/core/models/lantern_status.dart +++ b/lib/core/models/lantern_status.dart @@ -9,7 +9,30 @@ import '../common/common.dart'; -enum VPNStatusOrigin { userAction, settingsMutation, system, unknown } +enum VPNStatusOrigin { + userAction('user_action'), + settingsMutation('settings_mutation'), + system('system'), + unknown('unknown'); + + const VPNStatusOrigin(this.wireValue); + + final String wireValue; + + static VPNStatusOrigin fromWire(dynamic rawOrigin) { + if (rawOrigin is! String) { + return VPNStatusOrigin.unknown; + } + + final normalized = rawOrigin.toLowerCase(); + for (final origin in VPNStatusOrigin.values) { + if (origin.wireValue == normalized) { + return origin; + } + } + return VPNStatusOrigin.unknown; + } +} class LanternStatus { final VPNStatus status; @@ -36,7 +59,7 @@ class LanternStatus { appLogger.error('Unknown status: $statusStr'); status = VPNStatus.disconnected; } - final origin = _originFromJson(json['origin']); + final origin = VPNStatusOrigin.fromWire(json['origin']); return LanternStatus(status: status, error: json['error'], origin: origin); } @@ -49,21 +72,4 @@ class LanternStatus { @override String toString() => 'LanternStatus(status: $status, error: $error, origin: $origin)'; - - static VPNStatusOrigin _originFromJson(dynamic rawOrigin) { - if (rawOrigin is! String) { - return VPNStatusOrigin.unknown; - } - - switch (rawOrigin.toLowerCase()) { - case 'user_action': - return VPNStatusOrigin.userAction; - case 'settings_mutation': - return VPNStatusOrigin.settingsMutation; - case 'system': - return VPNStatusOrigin.system; - default: - return VPNStatusOrigin.unknown; - } - } } diff --git a/lib/features/home/provider/app_setting_notifier.dart b/lib/features/home/provider/app_setting_notifier.dart index 9853660fe1..9b6a10769d 100644 --- a/lib/features/home/provider/app_setting_notifier.dart +++ b/lib/features/home/provider/app_setting_notifier.dart @@ -21,6 +21,8 @@ class AppSettingNotifier extends _$AppSettingNotifier { LocalStorageService get _storage => sl(); bool _isRoutingModeUpdateInFlight = false; bool _isBlockAdsUpdateInFlight = false; + RoutingMode? _pendingRoutingMode; + bool? _pendingBlockAds; @override AppSetting build() { @@ -66,35 +68,43 @@ class AppSettingNotifier extends _$AppSettingNotifier { update(state.copyWith(newIsSpiltTunnelingOn: value)); Future> setRoutingMode(RoutingMode mode) async { - if (state.routingMode == mode) { - return right(unit); - } - + _pendingRoutingMode = mode; if (_isRoutingModeUpdateInFlight) { appLogger.info( - 'Routing mode update already in progress. Ignoring duplicate request.', + 'Routing mode update in progress. Queued latest request: ${mode.key}', ); return right(unit); } _isRoutingModeUpdateInFlight = true; - final prev = state.routingModeRaw; - - appLogger.info('Setting routing mode to: ${mode.key}'); - await update(state.copyWith(routingModeRaw: mode.key)); + Failure? lastFailure; + final lantern = ref.read(lanternServiceProvider); try { - final lantern = ref.read(lanternServiceProvider); - final res = await lantern.setRoutingMode(mode == RoutingMode.smart); - - await res.match((f) async { - appLogger.error('Failed to set routing mode', f); - await update(state.copyWith(routingModeRaw: prev)); - }, (_) async {}); - return res; + while (_pendingRoutingMode != null) { + final nextMode = _pendingRoutingMode!; + _pendingRoutingMode = null; + + if (state.routingMode == nextMode) { + continue; + } + + final prev = state.routingModeRaw; + appLogger.info('Setting routing mode to: ${nextMode.key}'); + await update(state.copyWith(routingModeRaw: nextMode.key)); + + final res = await lantern.setRoutingMode(nextMode == RoutingMode.smart); + await res.match((f) async { + lastFailure = f; + appLogger.error('Failed to set routing mode', f); + await update(state.copyWith(routingModeRaw: prev)); + }, (_) async {}); + } } finally { _isRoutingModeUpdateInFlight = false; } + + return lastFailure != null ? left(lastFailure!) : right(unit); } void setUserLoggedIn(bool value) => @@ -110,33 +120,38 @@ class AppSettingNotifier extends _$AppSettingNotifier { update(state.copyWith(successfulConnection: value)); void setBlockAds(bool value) { - unawaited(_setBlockAds(value)); + _pendingBlockAds = value; + unawaited(_setBlockAds()); } - Future _setBlockAds(bool value) async { - if (state.blockAds == value) { - return; - } - + Future _setBlockAds() async { if (_isBlockAdsUpdateInFlight) { appLogger.info( - 'Block ads update already in progress. Ignoring duplicate request.', + 'Block ads update in progress. Queued latest request: $_pendingBlockAds', ); return; } _isBlockAdsUpdateInFlight = true; - final prev = state.blockAds; - await update(state.copyWith(blockAds: value)); - + final svc = ref.read(lanternServiceProvider); try { - final svc = ref.read(lanternServiceProvider); - final res = await svc.setBlockAdsEnabled(value); - - await res.match((err) async { - appLogger.error('setBlockAdsEnabled failed: ${err.error}'); - await update(state.copyWith(blockAds: prev)); - }, (_) async {}); + while (_pendingBlockAds != null) { + final nextValue = _pendingBlockAds!; + _pendingBlockAds = null; + + if (state.blockAds == nextValue) { + continue; + } + + final prev = state.blockAds; + await update(state.copyWith(blockAds: nextValue)); + + final res = await svc.setBlockAdsEnabled(nextValue); + await res.match((err) async { + appLogger.error('setBlockAdsEnabled failed: ${err.error}'); + await update(state.copyWith(blockAds: prev)); + }, (_) async {}); + } } finally { _isBlockAdsUpdateInFlight = false; } diff --git a/lib/lantern/lantern_windows_service.dart b/lib/lantern/lantern_windows_service.dart index a18cfddc0c..8acf1414f8 100644 --- a/lib/lantern/lantern_windows_service.dart +++ b/lib/lantern/lantern_windows_service.dart @@ -135,7 +135,7 @@ class LanternServiceWindows { if (DateTime.now().isAfter(_pendingStatusOriginExpiresAt)) { _clearPendingStatusOrigin(); } - return _originToWireValue(_pendingStatusOrigin); + return _pendingStatusOrigin.wireValue; } void _clearPendingStatusOrigin() { @@ -143,19 +143,6 @@ class LanternServiceWindows { _pendingStatusOriginExpiresAt = DateTime.fromMillisecondsSinceEpoch(0); } - static String _originToWireValue(VPNStatusOrigin origin) { - switch (origin) { - case VPNStatusOrigin.userAction: - return 'user_action'; - case VPNStatusOrigin.settingsMutation: - return 'settings_mutation'; - case VPNStatusOrigin.system: - return 'system'; - case VPNStatusOrigin.unknown: - return 'unknown'; - } - } - Stream> watchLogs() { _logsPipe ??= PipeClient(token: _rpcPipe.token); return _logsPipe!.watchLogs(); From 6872d49cb8a281abdfe8e61cb449ce3a06c9dace Mon Sep 17 00:00:00 2001 From: atavism Date: Thu, 26 Mar 2026 19:17:04 -0700 Subject: [PATCH 6/6] code review updates --- lib/core/utils/latest_async_queue.dart | 63 +++++++++ .../home/provider/app_setting_notifier.dart | 126 +++++++++--------- test/core/utils/latest_async_queue_test.dart | 58 ++++++++ 3 files changed, 187 insertions(+), 60 deletions(-) create mode 100644 lib/core/utils/latest_async_queue.dart create mode 100644 test/core/utils/latest_async_queue_test.dart diff --git a/lib/core/utils/latest_async_queue.dart b/lib/core/utils/latest_async_queue.dart new file mode 100644 index 0000000000..97c8fa57b6 --- /dev/null +++ b/lib/core/utils/latest_async_queue.dart @@ -0,0 +1,63 @@ +import 'dart:async'; + +typedef LatestAsyncQueueWorker = Future Function(T value); + +/// Serializes async updates and coalesces queued values so only the latest +/// pending value is processed after each await point. +class LatestAsyncQueue { + LatestAsyncQueue({ + required LatestAsyncQueueWorker worker, + required this.defaultResult, + }) : _worker = worker; + + final LatestAsyncQueueWorker _worker; + final R defaultResult; + + bool _running = false; + bool _hasPending = false; + late T _pendingValue; + Completer? _cycleCompleter; + + bool get isRunning => _running; + + Future enqueue(T value) { + _pendingValue = value; + _hasPending = true; + _cycleCompleter ??= Completer(); + + if (_running) { + return _cycleCompleter!.future; + } + + return _drainCurrentCycle(); + } + + Future _drainCurrentCycle() async { + _running = true; + final completer = _cycleCompleter!; + var cycleResult = defaultResult; + + try { + while (_hasPending) { + final value = _pendingValue; + _hasPending = false; + cycleResult = await _worker(value); + } + + if (!completer.isCompleted) { + completer.complete(cycleResult); + } + return cycleResult; + } catch (e, st) { + if (!completer.isCompleted) { + completer.completeError(e, st); + } + rethrow; + } finally { + _running = false; + if (identical(_cycleCompleter, completer)) { + _cycleCompleter = null; + } + } + } +} diff --git a/lib/features/home/provider/app_setting_notifier.dart b/lib/features/home/provider/app_setting_notifier.dart index 9b6a10769d..b50840fde7 100644 --- a/lib/features/home/provider/app_setting_notifier.dart +++ b/lib/features/home/provider/app_setting_notifier.dart @@ -8,6 +8,7 @@ import 'package:lantern/core/common/common.dart'; import 'package:lantern/core/models/app_setting.dart'; import 'package:lantern/core/services/injection_container.dart' show sl; import 'package:lantern/core/services/local_storage_service.dart'; +import 'package:lantern/core/utils/latest_async_queue.dart'; import 'package:lantern/core/utils/storage_utils.dart'; import 'package:lantern/lantern/lantern_service.dart'; import 'package:lantern/lantern/lantern_service_notifier.dart'; @@ -19,10 +20,15 @@ part 'app_setting_notifier.g.dart'; @Riverpod(keepAlive: true) class AppSettingNotifier extends _$AppSettingNotifier { LocalStorageService get _storage => sl(); - bool _isRoutingModeUpdateInFlight = false; - bool _isBlockAdsUpdateInFlight = false; - RoutingMode? _pendingRoutingMode; - bool? _pendingBlockAds; + late final LatestAsyncQueue> + _routingModeQueue = LatestAsyncQueue( + worker: _applyRoutingMode, + defaultResult: right(unit), + ); + late final LatestAsyncQueue _blockAdsQueue = LatestAsyncQueue( + worker: _applyBlockAds, + defaultResult: unit, + ); @override AppSetting build() { @@ -68,43 +74,42 @@ class AppSettingNotifier extends _$AppSettingNotifier { update(state.copyWith(newIsSpiltTunnelingOn: value)); Future> setRoutingMode(RoutingMode mode) async { - _pendingRoutingMode = mode; - if (_isRoutingModeUpdateInFlight) { + if (_routingModeQueue.isRunning) { appLogger.info( 'Routing mode update in progress. Queued latest request: ${mode.key}', ); + } + + try { + return await _routingModeQueue.enqueue(mode); + } catch (e, st) { + appLogger.error('Unexpected routing mode update failure', e, st); + return left(e.toFailure()); + } + } + + Future> _applyRoutingMode(RoutingMode mode) async { + if (state.routingMode == mode) { return right(unit); } - _isRoutingModeUpdateInFlight = true; - Failure? lastFailure; - final lantern = ref.read(lanternServiceProvider); + final prev = state.routingModeRaw; + appLogger.info('Setting routing mode to: ${mode.key}'); + await update(state.copyWith(routingModeRaw: mode.key)); + final lantern = ref.read(lanternServiceProvider); try { - while (_pendingRoutingMode != null) { - final nextMode = _pendingRoutingMode!; - _pendingRoutingMode = null; - - if (state.routingMode == nextMode) { - continue; - } - - final prev = state.routingModeRaw; - appLogger.info('Setting routing mode to: ${nextMode.key}'); - await update(state.copyWith(routingModeRaw: nextMode.key)); - - final res = await lantern.setRoutingMode(nextMode == RoutingMode.smart); - await res.match((f) async { - lastFailure = f; - appLogger.error('Failed to set routing mode', f); - await update(state.copyWith(routingModeRaw: prev)); - }, (_) async {}); - } - } finally { - _isRoutingModeUpdateInFlight = false; + final res = await lantern.setRoutingMode(mode == RoutingMode.smart); + return await res.match((f) async { + appLogger.error('Failed to set routing mode', f); + await update(state.copyWith(routingModeRaw: prev)); + return left(f); + }, (_) async => right(unit)); + } catch (e, st) { + appLogger.error('Unexpected setRoutingMode error', e, st); + await update(state.copyWith(routingModeRaw: prev)); + return left(e.toFailure()); } - - return lastFailure != null ? left(lastFailure!) : right(unit); } void setUserLoggedIn(bool value) => @@ -120,41 +125,42 @@ class AppSettingNotifier extends _$AppSettingNotifier { update(state.copyWith(successfulConnection: value)); void setBlockAds(bool value) { - _pendingBlockAds = value; - unawaited(_setBlockAds()); - } - - Future _setBlockAds() async { - if (_isBlockAdsUpdateInFlight) { + if (_blockAdsQueue.isRunning) { appLogger.info( - 'Block ads update in progress. Queued latest request: $_pendingBlockAds', + 'Block ads update in progress. Queued latest request: $value', ); - return; + } + unawaited(_enqueueBlockAds(value)); + } + + Future _enqueueBlockAds(bool value) async { + try { + await _blockAdsQueue.enqueue(value); + } catch (e, st) { + appLogger.error('Unexpected setBlockAdsEnabled error', e, st); + } + } + + Future _applyBlockAds(bool value) async { + if (state.blockAds == value) { + return unit; } - _isBlockAdsUpdateInFlight = true; final svc = ref.read(lanternServiceProvider); + final prev = state.blockAds; + await update(state.copyWith(blockAds: value)); + try { - while (_pendingBlockAds != null) { - final nextValue = _pendingBlockAds!; - _pendingBlockAds = null; - - if (state.blockAds == nextValue) { - continue; - } - - final prev = state.blockAds; - await update(state.copyWith(blockAds: nextValue)); - - final res = await svc.setBlockAdsEnabled(nextValue); - await res.match((err) async { - appLogger.error('setBlockAdsEnabled failed: ${err.error}'); - await update(state.copyWith(blockAds: prev)); - }, (_) async {}); - } - } finally { - _isBlockAdsUpdateInFlight = false; + final res = await svc.setBlockAdsEnabled(value); + await res.match((err) async { + appLogger.error('setBlockAdsEnabled failed: ${err.error}'); + await update(state.copyWith(blockAds: prev)); + }, (_) async {}); + } catch (e, st) { + appLogger.error('Unexpected setBlockAdsEnabled failure', e, st); + await update(state.copyWith(blockAds: prev)); } + return unit; } void updateAnonymousDataConsent(bool value) { diff --git a/test/core/utils/latest_async_queue_test.dart b/test/core/utils/latest_async_queue_test.dart new file mode 100644 index 0000000000..2c4827702b --- /dev/null +++ b/test/core/utils/latest_async_queue_test.dart @@ -0,0 +1,58 @@ +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:lantern/core/utils/latest_async_queue.dart'; + +void main() { + test( + 'coalesces queued values and applies only latest pending value', + () async { + final startedFirstApply = Completer(); + final allowFirstApplyToFinish = Completer(); + final applied = []; + + final queue = LatestAsyncQueue( + defaultResult: -1, + worker: (value) async { + applied.add(value); + if (value == 1) { + startedFirstApply.complete(); + await allowFirstApplyToFinish.future; + } + return value; + }, + ); + + final first = queue.enqueue(1); + await startedFirstApply.future; + final second = queue.enqueue(2); + final third = queue.enqueue(3); + + allowFirstApplyToFinish.complete(); + + expect(await first, 3); + expect(await second, 3); + expect(await third, 3); + expect(applied, [1, 3]); + }, + ); + + test('starts a new cycle after queue drains', () async { + final applied = []; + final queue = LatestAsyncQueue( + defaultResult: -1, + worker: (value) async { + applied.add(value); + return value; + }, + ); + + final firstCycle = await queue.enqueue(10); + final secondCycle = await queue.enqueue(20); + + expect(firstCycle, 10); + expect(secondCycle, 20); + expect(applied, [10, 20]); + expect(queue.isRunning, isFalse); + }); +}