From 9b6c5833dcf23e17804c11df624bfa04140b5a7b Mon Sep 17 00:00:00 2001 From: Adam Fisk Date: Tue, 5 May 2026 06:06:10 -0600 Subject: [PATCH 1/3] Wire Share My Connection toggle in Dart UI (PR 4/4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Final PR in the four-PR stack. Stacks on lantern #8729 (FFI exports); combined with radiance #458 / #460 / lantern-cloud #2678-#2681 this ships a feature-complete Phase 1 of "Share My Connection" for desktop (macOS + Linux + Windows). * lantern_generated_bindings.dart: add setPeerProxyEnabled + isPeerProxyEnabled. Manually inserted to match the existing pattern rather than regenerating the whole file (a local ffigen run from the macOS header would drop ~5K lines of Windows-only declarations the upstream generator emits). * LanternCoreService / LanternFFIService / LanternPlatformService / LanternService: add setPeerProxyEnabled / isPeerProxyEnabled across all four service layers, mirroring the setBlockAdsEnabled pattern. FFI path on isFFISupported platforms (Windows + Linux), MethodChannel fallback on macOS / mobile. * RadianceSettingsState: new peerProxy bool field with copyWith and equality. * RadianceSettings notifier: new setPeerProxy method (pessimistic — call FFI, log on failure, update state on success — matching setBlockAds). _refresh now reads peerProxy alongside the others. * vpn_setting.dart: SwitchButton tile gated to PlatformUtils.isDesktop with i18n strings share_my_connection / share_my_connection_subtitle in en.po. Other locales will pick up via the standard translation flow. Lifecycle end-to-end: Dart toggle → RadianceSettings.setPeerProxy(bool) → LanternService.setPeerProxyEnabled → FFI: setPeerProxyEnabled(int) -> *char → Core.SetPeerShareEnabled(bool) → ipc.Client.PatchSettings({PeerShareEnabledKey: ...}) → radiance LocalBackend.PatchSettings dispatch → peer.Client.Start / Stop → UPnP MapPort + register + sing-box samizdat inbound + heartbeat flutter analyze: clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- assets/locales/en.po | 6 ++++ lib/core/models/radiance_settings_state.dart | 9 +++-- .../provider/radiance_settings_providers.dart | 14 ++++++++ lib/features/setting/vpn_setting.dart | 33 +++++++++++++++++++ lib/lantern/lantern_core_service.dart | 4 +++ lib/lantern/lantern_ffi_service.dart | 28 ++++++++++++++++ lib/lantern/lantern_generated_bindings.dart | 20 +++++++++++ lib/lantern/lantern_platform_service.dart | 24 ++++++++++++++ lib/lantern/lantern_service.dart | 16 +++++++++ 9 files changed, 152 insertions(+), 2 deletions(-) diff --git a/assets/locales/en.po b/assets/locales/en.po index f705fc01c6..303e466d36 100644 --- a/assets/locales/en.po +++ b/assets/locales/en.po @@ -551,6 +551,12 @@ msgstr "Block Ads" msgid "only_active" msgstr "Only active when VPN is connected" +msgid "share_my_connection" +msgstr "Share My Connection" + +msgid "share_my_connection_subtitle" +msgstr "Let other Lantern users route through your connection to bypass censorship." + msgid "vpn_connected" msgstr "Lantern is now connected." diff --git a/lib/core/models/radiance_settings_state.dart b/lib/core/models/radiance_settings_state.dart index 70323e0a13..28026ff95a 100644 --- a/lib/core/models/radiance_settings_state.dart +++ b/lib/core/models/radiance_settings_state.dart @@ -12,12 +12,14 @@ class RadianceSettingsState { final RoutingMode routingMode; final bool splitTunneling; final bool telemetry; + final bool peerProxy; const RadianceSettingsState({ this.blockAds = false, this.routingMode = RoutingMode.full, this.splitTunneling = false, this.telemetry = false, + this.peerProxy = false, }); RadianceSettingsState copyWith({ @@ -25,12 +27,14 @@ class RadianceSettingsState { RoutingMode? routingMode, bool? splitTunneling, bool? telemetry, + bool? peerProxy, }) { return RadianceSettingsState( blockAds: blockAds ?? this.blockAds, routingMode: routingMode ?? this.routingMode, splitTunneling: splitTunneling ?? this.splitTunneling, telemetry: telemetry ?? this.telemetry, + peerProxy: peerProxy ?? this.peerProxy, ); } @@ -41,9 +45,10 @@ class RadianceSettingsState { blockAds == other.blockAds && routingMode == other.routingMode && splitTunneling == other.splitTunneling && - telemetry == other.telemetry; + telemetry == other.telemetry && + peerProxy == other.peerProxy; @override int get hashCode => - Object.hash(blockAds, routingMode, splitTunneling, telemetry); + Object.hash(blockAds, routingMode, splitTunneling, telemetry, peerProxy); } diff --git a/lib/features/home/provider/radiance_settings_providers.dart b/lib/features/home/provider/radiance_settings_providers.dart index ae9cee0f6d..89ececbb66 100644 --- a/lib/features/home/provider/radiance_settings_providers.dart +++ b/lib/features/home/provider/radiance_settings_providers.dart @@ -29,16 +29,19 @@ class RadianceSettings extends _$RadianceSettings { final routingF = svc.isSmartRoutingEnabled(); final telemetryF = svc.isTelemetryEnabled(); final splitF = PlatformUtils.isIOS ? null : svc.isSplitTunnelingEnabled(); + final peerProxyF = svc.isPeerProxyEnabled(); final results = await Future.wait([ blockAdsF, routingF, telemetryF, ?splitF, + peerProxyF, ]); if (!ref.mounted) return; const defaults = RadianceSettingsState(); + final peerIdx = splitF == null ? 3 : 4; state = RadianceSettingsState( blockAds: results[0].fold((_) => defaults.blockAds, (v) => v), routingMode: results[1].fold( @@ -49,6 +52,7 @@ class RadianceSettings extends _$RadianceSettings { splitTunneling: splitF == null ? defaults.splitTunneling : results[3].fold((_) => defaults.splitTunneling, (v) => v), + peerProxy: results[peerIdx].fold((_) => defaults.peerProxy, (v) => v), ); } @@ -97,6 +101,16 @@ class RadianceSettings extends _$RadianceSettings { (_) => state = state.copyWith(telemetry: consent), ); } + + Future setPeerProxy(bool value) async { + final svc = ref.read(lanternServiceProvider); + final result = await svc.setPeerProxyEnabled(value); + if (!ref.mounted) return; + result.fold( + (err) => appLogger.error('setPeerProxyEnabled failed: ${err.error}'), + (_) => state = state.copyWith(peerProxy: value), + ); + } } /// Fetches whether user logged in via OAuth from radiance. diff --git a/lib/features/setting/vpn_setting.dart b/lib/features/setting/vpn_setting.dart index 8c108473a9..ec4d3920a5 100644 --- a/lib/features/setting/vpn_setting.dart +++ b/lib/features/setting/vpn_setting.dart @@ -35,6 +35,9 @@ class VPNSetting extends HookConsumerWidget { final telemetryConsent = ref.watch( radianceSettingsProvider.select((s) => s.telemetry), ); + final peerProxy = ref.watch( + radianceSettingsProvider.select((s) => s.peerProxy), + ); return ListView( padding: const EdgeInsets.all(0), @@ -117,6 +120,36 @@ class VPNSetting extends HookConsumerWidget { }, ), ), + if (PlatformUtils.isDesktop) ...{ + SizedBox(height: 16), + AppCard( + padding: EdgeInsets.zero, + child: AppTile( + label: 'share_my_connection'.i18n, + subtitle: Text( + 'share_my_connection_subtitle'.i18n, + style: textTheme.labelMedium!.copyWith( + color: context.textTertiary, + letterSpacing: 0.0, + ), + ), + icon: AppImagePaths.share, + trailing: SwitchButton( + value: peerProxy, + onChanged: (bool? value) { + ref + .read(radianceSettingsProvider.notifier) + .setPeerProxy(value ?? false); + }, + ), + onPressed: () { + ref + .read(radianceSettingsProvider.notifier) + .setPeerProxy(!peerProxy); + }, + ), + ), + }, SizedBox(height: 16), AppCard( padding: EdgeInsets.zero, diff --git a/lib/lantern/lantern_core_service.dart b/lib/lantern/lantern_core_service.dart index 7dde6b538b..124ad9b1d4 100644 --- a/lib/lantern/lantern_core_service.dart +++ b/lib/lantern/lantern_core_service.dart @@ -79,6 +79,10 @@ abstract class LanternCoreService { Future> isBlockAdsEnabled(); + Future> setPeerProxyEnabled(bool enabled); + + Future> isPeerProxyEnabled(); + Future> isSmartRoutingEnabled(); Future> isTelemetryEnabled(); diff --git a/lib/lantern/lantern_ffi_service.dart b/lib/lantern/lantern_ffi_service.dart index dec4dec5ca..6c206cf5ef 100644 --- a/lib/lantern/lantern_ffi_service.dart +++ b/lib/lantern/lantern_ffi_service.dart @@ -1550,6 +1550,34 @@ class LanternFFIService implements LanternCoreService { } } + @override + Future> setPeerProxyEnabled(bool enabled) async { + try { + final result = await runInBackground(() async { + return _ffiService + .setPeerProxyEnabled(enabled ? 1 : 0) + .cast() + .toDartString(); + }); + checkAPIError(result); + return right(unit); + } catch (e, st) { + appLogger.error('setPeerProxyEnabled error: $e', e, st); + return Left(e.toFailure()); + } + } + + @override + Future> isPeerProxyEnabled() async { + try { + final res = _ffiService.isPeerProxyEnabled(); + return right(res != 0); + } catch (e, st) { + appLogger.error('isPeerProxyEnabled error: $e', e, st); + return Left(e.toFailure()); + } + } + @override Future> isSmartRoutingEnabled() async { try { diff --git a/lib/lantern/lantern_generated_bindings.dart b/lib/lantern/lantern_generated_bindings.dart index 14cc3944cb..315ad4a52c 100644 --- a/lib/lantern/lantern_generated_bindings.dart +++ b/lib/lantern/lantern_generated_bindings.dart @@ -6275,6 +6275,26 @@ class LanternBindings { late final _isBlockAdsEnabled = _isBlockAdsEnabledPtr .asFunction(); + ffi.Pointer setPeerProxyEnabled(int enabled) { + return _setPeerProxyEnabled(enabled); + } + + late final _setPeerProxyEnabledPtr = + _lookup Function(ffi.Int)>>( + 'setPeerProxyEnabled', + ); + late final _setPeerProxyEnabled = _setPeerProxyEnabledPtr + .asFunction Function(int)>(); + + int isPeerProxyEnabled() { + return _isPeerProxyEnabled(); + } + + late final _isPeerProxyEnabledPtr = + _lookup>('isPeerProxyEnabled'); + late final _isPeerProxyEnabled = _isPeerProxyEnabledPtr + .asFunction(); + ffi.Pointer setSmartRoutingEnabled(int enabled) { return _setSmartRoutingEnabled(enabled); } diff --git a/lib/lantern/lantern_platform_service.dart b/lib/lantern/lantern_platform_service.dart index 734c827544..9774c1b7f4 100644 --- a/lib/lantern/lantern_platform_service.dart +++ b/lib/lantern/lantern_platform_service.dart @@ -290,6 +290,30 @@ class LanternPlatformService implements LanternCoreService { } } + @override + Future> setPeerProxyEnabled(bool enabled) async { + try { + await _methodChannel.invokeMethod('setPeerProxyEnabled', { + 'enabled': enabled, + }); + return right(unit); + } catch (e, st) { + appLogger.error('setPeerProxyEnabled failed', e, st); + return Left(e.toFailure()); + } + } + + @override + Future> isPeerProxyEnabled() async { + try { + final res = await _methodChannel.invokeMethod('isPeerProxyEnabled'); + return right(res ?? false); + } catch (e, st) { + appLogger.error('isPeerProxyEnabled failed', e, st); + return Left(e.toFailure()); + } + } + @override Future> isSmartRoutingEnabled() async { try { diff --git a/lib/lantern/lantern_service.dart b/lib/lantern/lantern_service.dart index 2f3ae8fa48..9936cd4164 100644 --- a/lib/lantern/lantern_service.dart +++ b/lib/lantern/lantern_service.dart @@ -794,6 +794,22 @@ class LanternService implements LanternCoreService { return _platformService.setBlockAdsEnabled(enabled); } + @override + Future> isPeerProxyEnabled() { + if (PlatformUtils.isFFISupported) { + return _ffiService.isPeerProxyEnabled(); + } + return _platformService.isPeerProxyEnabled(); + } + + @override + Future> setPeerProxyEnabled(bool enabled) { + if (PlatformUtils.isFFISupported) { + return _ffiService.setPeerProxyEnabled(enabled); + } + return _platformService.setPeerProxyEnabled(enabled); + } + @override Future> isSmartRoutingEnabled() { if (PlatformUtils.isFFISupported) { From 67ba9ea5fae4ddeba68b9dfd98530aaec877cc70 Mon Sep 17 00:00:00 2001 From: Adam Fisk Date: Tue, 5 May 2026 06:14:10 -0600 Subject: [PATCH 2/3] review: gate peer-proxy toggle to FFI-supported platforms Three review comments converged on the same root cause: the toggle was gated to PlatformUtils.isDesktop and the platform-service shims invoked MethodChannel methods that have no native handlers anywhere (Android/iOS/macOS), so on any non-FFI platform the toggle would render but the call would fail with MissingPluginException. * vpn_setting.dart: gate to PlatformUtils.isFFISupported (Windows + Linux), where the FFI path actually drives the toggle. * radiance_settings_providers.dart: skip the isPeerProxyEnabled probe in _refresh on non-FFI platforms so we don't log a failure on every settings init. * lantern_platform_service.dart: replace the MethodChannel passthroughs with explicit "not supported on this platform" stubs. They exist only for LanternCoreService interface conformance; the UI gate prevents them from ever being called. macOS / iOS / Android support requires a native handler (Swift / Kotlin) calling into the Go core; that's a follow-up. flutter analyze: clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../provider/radiance_settings_providers.dart | 13 +++++++--- lib/features/setting/vpn_setting.dart | 2 +- lib/lantern/lantern_platform_service.dart | 25 +++++++------------ 3 files changed, 19 insertions(+), 21 deletions(-) diff --git a/lib/features/home/provider/radiance_settings_providers.dart b/lib/features/home/provider/radiance_settings_providers.dart index 89ececbb66..b867592c68 100644 --- a/lib/features/home/provider/radiance_settings_providers.dart +++ b/lib/features/home/provider/radiance_settings_providers.dart @@ -29,19 +29,22 @@ class RadianceSettings extends _$RadianceSettings { final routingF = svc.isSmartRoutingEnabled(); final telemetryF = svc.isTelemetryEnabled(); final splitF = PlatformUtils.isIOS ? null : svc.isSplitTunnelingEnabled(); - final peerProxyF = svc.isPeerProxyEnabled(); + // Peer-proxy probe runs only on platforms with native handlers + // (FFI-supported = Windows + Linux). On other platforms the call would + // fail with MissingPluginException on every settings init. + final peerF = PlatformUtils.isFFISupported ? svc.isPeerProxyEnabled() : null; final results = await Future.wait([ blockAdsF, routingF, telemetryF, ?splitF, - peerProxyF, + ?peerF, ]); if (!ref.mounted) return; const defaults = RadianceSettingsState(); - final peerIdx = splitF == null ? 3 : 4; + final peerIdx = 3 + (splitF == null ? 0 : 1); state = RadianceSettingsState( blockAds: results[0].fold((_) => defaults.blockAds, (v) => v), routingMode: results[1].fold( @@ -52,7 +55,9 @@ class RadianceSettings extends _$RadianceSettings { splitTunneling: splitF == null ? defaults.splitTunneling : results[3].fold((_) => defaults.splitTunneling, (v) => v), - peerProxy: results[peerIdx].fold((_) => defaults.peerProxy, (v) => v), + peerProxy: peerF == null + ? defaults.peerProxy + : results[peerIdx].fold((_) => defaults.peerProxy, (v) => v), ); } diff --git a/lib/features/setting/vpn_setting.dart b/lib/features/setting/vpn_setting.dart index ec4d3920a5..a50279c94a 100644 --- a/lib/features/setting/vpn_setting.dart +++ b/lib/features/setting/vpn_setting.dart @@ -120,7 +120,7 @@ class VPNSetting extends HookConsumerWidget { }, ), ), - if (PlatformUtils.isDesktop) ...{ + if (PlatformUtils.isFFISupported) ...{ SizedBox(height: 16), AppCard( padding: EdgeInsets.zero, diff --git a/lib/lantern/lantern_platform_service.dart b/lib/lantern/lantern_platform_service.dart index 9774c1b7f4..038e6e8b39 100644 --- a/lib/lantern/lantern_platform_service.dart +++ b/lib/lantern/lantern_platform_service.dart @@ -290,28 +290,21 @@ class LanternPlatformService implements LanternCoreService { } } + // Peer-proxy has no native MethodChannel handler outside the FFI path. + // The toggle is gated to PlatformUtils.isFFISupported in the UI, so these + // implementations exist for interface conformance only and return a clear + // error rather than a MissingPluginException if ever invoked. @override Future> setPeerProxyEnabled(bool enabled) async { - try { - await _methodChannel.invokeMethod('setPeerProxyEnabled', { - 'enabled': enabled, - }); - return right(unit); - } catch (e, st) { - appLogger.error('setPeerProxyEnabled failed', e, st); - return Left(e.toFailure()); - } + return Left(Failure( + error: 'peer-proxy not supported on this platform', + localizedErrorMessage: 'peer-proxy not supported on this platform', + )); } @override Future> isPeerProxyEnabled() async { - try { - final res = await _methodChannel.invokeMethod('isPeerProxyEnabled'); - return right(res ?? false); - } catch (e, st) { - appLogger.error('isPeerProxyEnabled failed', e, st); - return Left(e.toFailure()); - } + return right(false); } @override From 714eb7185592142156cb335c83c9ac9bd1adbde0 Mon Sep 17 00:00:00 2001 From: Adam Fisk Date: Tue, 5 May 2026 10:47:18 -0600 Subject: [PATCH 3/3] peer-proxy: add macOS native handler MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit macOS routes through MethodChannel → Swift → MobileSetPeerShareEnabled (gomobile-bind) rather than the FFI path that Windows + Linux use. The previous review fix gated the toggle to PlatformUtils.isFFISupported to avoid a MissingPluginException on macOS, but per Phase 1 plan macOS should be supported. * macos/Runner/Handlers/MethodHandler.swift: new setPeerProxyEnabled case + setPeerProxyEnabled function calling MobileSetPeerShareEnabled, plus an isPeerProxyEnabled case calling MobileIsPeerShareEnabled. Mirrors the existing setBlockAdsEnabled handler exactly. (The MobileSet/IsPeerShareEnabled gomobile bindings come from the SetPeerShareEnabled / IsPeerShareEnabled methods added to lantern-core/mobile/mobile.go in PR 8729; the Liblantern xcframework needs a rebuild via `make macos-framework` to pick them up.) * lantern_platform_service.dart: restore the MethodChannel passthrough for setPeerProxyEnabled / isPeerProxyEnabled. The "not supported on this platform" stubs from the prior review fix are no longer appropriate now that there's a native handler. * vpn_setting.dart: widen the toggle gate from isFFISupported (Windows + Linux) to isDesktop (Windows + Linux + macOS). * radiance_settings_providers.dart: same widening for the isPeerProxyEnabled probe in _refresh. Verified locally: `make macos-framework` rebuilds successfully and exports MobileSetPeerShareEnabled / MobileIsPeerShareEnabled. flutter analyze clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../provider/radiance_settings_providers.dart | 7 +++--- lib/features/setting/vpn_setting.dart | 2 +- lib/lantern/lantern_platform_service.dart | 25 ++++++++++++------- macos/Runner/Handlers/MethodHandler.swift | 24 ++++++++++++++++++ 4 files changed, 45 insertions(+), 13 deletions(-) diff --git a/lib/features/home/provider/radiance_settings_providers.dart b/lib/features/home/provider/radiance_settings_providers.dart index b867592c68..be8900aede 100644 --- a/lib/features/home/provider/radiance_settings_providers.dart +++ b/lib/features/home/provider/radiance_settings_providers.dart @@ -30,9 +30,10 @@ class RadianceSettings extends _$RadianceSettings { final telemetryF = svc.isTelemetryEnabled(); final splitF = PlatformUtils.isIOS ? null : svc.isSplitTunnelingEnabled(); // Peer-proxy probe runs only on platforms with native handlers - // (FFI-supported = Windows + Linux). On other platforms the call would - // fail with MissingPluginException on every settings init. - final peerF = PlatformUtils.isFFISupported ? svc.isPeerProxyEnabled() : null; + // (Windows + Linux via FFI, macOS via MethodChannel — i.e. all desktop). + // On iOS / Android the call would fail with MissingPluginException on + // every settings init. + final peerF = PlatformUtils.isDesktop ? svc.isPeerProxyEnabled() : null; final results = await Future.wait([ blockAdsF, diff --git a/lib/features/setting/vpn_setting.dart b/lib/features/setting/vpn_setting.dart index a50279c94a..ec4d3920a5 100644 --- a/lib/features/setting/vpn_setting.dart +++ b/lib/features/setting/vpn_setting.dart @@ -120,7 +120,7 @@ class VPNSetting extends HookConsumerWidget { }, ), ), - if (PlatformUtils.isFFISupported) ...{ + if (PlatformUtils.isDesktop) ...{ SizedBox(height: 16), AppCard( padding: EdgeInsets.zero, diff --git a/lib/lantern/lantern_platform_service.dart b/lib/lantern/lantern_platform_service.dart index 038e6e8b39..9774c1b7f4 100644 --- a/lib/lantern/lantern_platform_service.dart +++ b/lib/lantern/lantern_platform_service.dart @@ -290,21 +290,28 @@ class LanternPlatformService implements LanternCoreService { } } - // Peer-proxy has no native MethodChannel handler outside the FFI path. - // The toggle is gated to PlatformUtils.isFFISupported in the UI, so these - // implementations exist for interface conformance only and return a clear - // error rather than a MissingPluginException if ever invoked. @override Future> setPeerProxyEnabled(bool enabled) async { - return Left(Failure( - error: 'peer-proxy not supported on this platform', - localizedErrorMessage: 'peer-proxy not supported on this platform', - )); + try { + await _methodChannel.invokeMethod('setPeerProxyEnabled', { + 'enabled': enabled, + }); + return right(unit); + } catch (e, st) { + appLogger.error('setPeerProxyEnabled failed', e, st); + return Left(e.toFailure()); + } } @override Future> isPeerProxyEnabled() async { - return right(false); + try { + final res = await _methodChannel.invokeMethod('isPeerProxyEnabled'); + return right(res ?? false); + } catch (e, st) { + appLogger.error('isPeerProxyEnabled failed', e, st); + return Left(e.toFailure()); + } } @override diff --git a/macos/Runner/Handlers/MethodHandler.swift b/macos/Runner/Handlers/MethodHandler.swift index d41e93e30d..6eaa33d4b2 100644 --- a/macos/Runner/Handlers/MethodHandler.swift +++ b/macos/Runner/Handlers/MethodHandler.swift @@ -245,6 +245,16 @@ class MethodHandler { let enabled = data?["enabled"] as? Bool ?? false self.setBlockAdsEnabled(result: result, enabled: enabled) + case "isPeerProxyEnabled": + Task { + await MainActor.run { result(MobileIsPeerShareEnabled()) } + } + + case "setPeerProxyEnabled": + let data = call.arguments as? [String: Any] + let enabled = data?["enabled"] as? Bool ?? false + self.setPeerProxyEnabled(result: result, enabled: enabled) + case "updateTelemetryEvents": guard let consent: Bool = self.decodeValue(from: call.arguments, result: result) else { return @@ -1152,6 +1162,20 @@ class MethodHandler { } } + func setPeerProxyEnabled(result: @escaping FlutterResult, enabled: Bool) { + Task { + var error: NSError? + MobileSetPeerShareEnabled(enabled, &error) + if let error { + await self.handleFlutterError(error, result: result, code: "SET_PEER_PROXY_ERROR") + return + } + await MainActor.run { + result("ok") + } + } + } + func updateTelemetryEvents(consent: Bool, result: @escaping FlutterResult) { Task { var error: NSError?