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] diff --git a/lib/core/models/lantern_status.dart b/lib/core/models/lantern_status.dart index 955d353ba7..7d6e236780 100644 --- a/lib/core/models/lantern_status.dart +++ b/lib/core/models/lantern_status.dart @@ -9,9 +9,35 @@ import '../common/common.dart'; +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; final String? error; + final VPNStatusOrigin origin; factory LanternStatus.fromJson(Map json) { appLogger.info('LanternStatus.fromJson $json'); @@ -33,14 +59,17 @@ class LanternStatus { appLogger.error('Unknown status: $statusStr'); status = VPNStatus.disconnected; } - return LanternStatus( - status: status, - error: json['error'], - ); + final origin = VPNStatusOrigin.fromWire(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)'; } 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 ce84bddcdb..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,6 +20,15 @@ part 'app_setting_notifier.g.dart'; @Riverpod(keepAlive: true) class AppSettingNotifier extends _$AppSettingNotifier { LocalStorageService get _storage => sl(); + late final LatestAsyncQueue> + _routingModeQueue = LatestAsyncQueue( + worker: _applyRoutingMode, + defaultResult: right(unit), + ); + late final LatestAsyncQueue _blockAdsQueue = LatestAsyncQueue( + worker: _applyBlockAds, + defaultResult: unit, + ); @override AppSetting build() { @@ -36,13 +46,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 +74,42 @@ class AppSettingNotifier extends _$AppSettingNotifier { update(state.copyWith(newIsSpiltTunnelingOn: value)); Future> setRoutingMode(RoutingMode mode) async { - final prev = state.routingModeRaw; + 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); + } + + 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 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()); + } } void setUserLoggedIn(bool value) => @@ -90,16 +125,42 @@ class AppSettingNotifier extends _$AppSettingNotifier { update(state.copyWith(successfulConnection: value)); void setBlockAds(bool value) { - final prev = state.blockAds; - update(state.copyWith(blockAds: value)); + if (_blockAdsQueue.isRunning) { + appLogger.info( + 'Block ads update in progress. Queued latest request: $value', + ); + } + 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; + } final svc = ref.read(lanternServiceProvider); - svc.setBlockAdsEnabled(value).then((res) { - res.match((err) { + final prev = state.blockAds; + await update(state.copyWith(blockAds: value)); + + try { + final res = await 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 {}); + } catch (e, st) { + appLogger.error('Unexpected setBlockAdsEnabled failure', e, st); + await update(state.copyWith(blockAds: prev)); + } + return unit; } void updateAnonymousDataConsent(bool value) { @@ -192,8 +253,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 +270,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..8c11a122e1 100644 --- a/lib/features/vpn/provider/vpn_notifier.dart +++ b/lib/features/vpn/provider/vpn_notifier.dart @@ -2,6 +2,7 @@ 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'; @@ -21,17 +22,28 @@ 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 = + nextOrigin == VPNStatusOrigin.settingsMutation && + (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 (origin=$nextOrigin)', + ); + } } else if (nextStatus == VPNStatus.connected) { if (PlatformUtils.isMobile) { HapticFeedback.mediumImpact(); @@ -45,11 +57,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 (origin=$nextOrigin)', + ); + } } } state = nextStatus; diff --git a/lib/lantern/lantern_ffi_service.dart b/lib/lantern/lantern_ffi_service.dart index a75c46ce7d..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,10 +1551,13 @@ class LanternFFIService implements LanternCoreService { @override Future> setBlockAdsEnabled(bool enabled) async { try { - final result = _ffiService - .setBlockAdsEnabled(enabled ? 1 : 0) - .cast() - .toDartString(); + await _markWindowsStatusOrigin(VPNStatusOrigin.settingsMutation); + final result = await runInBackground(() async { + return _ffiService + .setBlockAdsEnabled(enabled ? 1 : 0) + .cast() + .toDartString(); + }); checkAPIError(result); return right(unit); } catch (e, st) { diff --git a/lib/lantern/lantern_windows_service.dart b/lib/lantern/lantern_windows_service.dart index af19867b6c..bcc1e672bd 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,67 @@ 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 && + _shouldClearPendingOrigin(status.status)) { + _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 _pendingStatusOrigin.wireValue; + } + + void _clearPendingStatusOrigin() { + _pendingStatusOrigin = VPNStatusOrigin.unknown; + _pendingStatusOriginExpiresAt = DateTime.fromMillisecondsSinceEpoch(0); + } + + bool _shouldClearPendingOrigin(VPNStatus status) { + if (_pendingStatusOrigin == VPNStatusOrigin.settingsMutation && + status == VPNStatus.disconnected) { + // Settings changes can trigger a reconnect sequence where disconnected + // is transient before connected. Keep origin until the sequence settles. + return false; + } + return true; } Stream> watchLogs() { 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); + }); +}