Add peer-share toggle to lantern-core (Share My Connection PR 3/4) - #8729
Add peer-share toggle to lantern-core (Share My Connection PR 3/4)#8729myleshorton wants to merge 2 commits into
Conversation
There was a problem hiding this comment.
Pull request overview
Adds Lantern-side wiring for the Share My Connection feature by introducing a new peer-share toggle in lantern-core, exposed through both gomobile bindings and the existing C FFI surface, and backed by a radiance settings patch.
Changes:
- Add a
PeerShareinterface tolantern-coreand implementSetPeerShareEnabled/IsPeerShareEnabledvia radiance settings (PatchSettings+ snapshot read). - Expose the toggle through FFI exports (
setPeerProxyEnabled/isPeerProxyEnabled) and gomobile (SetPeerShareEnabled/IsPeerShareEnabled). - Temporarily bump
github.com/getlantern/radianceto a newer pseudo-version containingsettings.PeerShareEnabledKey.
Reviewed changes
Copilot reviewed 4 out of 5 changed files in this pull request and generated no comments.
Show a summary per file
| File | Description |
|---|---|
| lantern-core/mobile/mobile.go | Adds gomobile-facing setters/getters to toggle peer-share and query its current state. |
| lantern-core/ffi/ffi.go | Adds C-exported functions to toggle/query peer-share from the Dart/FFI layer. |
| lantern-core/core.go | Introduces PeerShare interface and core implementation backed by radiance settings. |
| go.mod | Updates the radiance dependency to a newer pseudo-version for the new settings key. |
| go.sum | Updates sums to match the radiance version bump. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
b40f25b to
6ae10c5
Compare
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) <noreply@anthropic.com>
PR 3 of 4 implementing the lantern-side wiring for "Share My Connection". Bumps radiance to fisk/peer-localbackend tip so we can reference the new PeerShareEnabledKey setting; that bump is provisional and should be re-pinned to a release tag once radiance #460 merges. * lantern-core/core.go: new PeerShare interface (mirrors Ads / SmartRouting), embedded in Core. SetPeerShareEnabled patches PeerShareEnabledKey via the radiance ipc client; IsPeerShareEnabled reads the snapshot. * lantern-core/ffi/ffi.go: new //export setPeerProxyEnabled and //export isPeerProxyEnabled, mirroring setBlockAdsEnabled exactly. The Dart FFI binding name uses "PeerProxy" to match the existing user-facing naming in the lantern repo (vpn_setting.dart toggle was drafted as "Peer Proxy"). * lantern-core/mobile/mobile.go: SetPeerShareEnabled / IsPeerShareEnabled for the gomobile-bind surface so Android can toggle once Dart wires it up in PR 4. The lifecycle path: Dart toggle → setPeerProxyEnabled(enabled) → LanternCore.SetPeerShareEnabled → ipc.Client.PatchSettings({PeerShareEnabledKey: ...}) → radiance LocalBackend.PatchSettings dispatch → peer.Client.Start / Stop ffigen regen for the Dart bindings happens in PR 4 alongside the Dart wire-through and rollback logic. go test ./lantern-core/... and golangci-lint --new-from-rev=origin/main both clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
6ae10c5 to
ced78e1
Compare
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
|
This PR has been inactive for 30 days. Waiting on: none It will be closed automatically in 7 days if no action is taken. |
|
This PR has been inactive for 30 days. Waiting on: none It will be closed automatically in 7 days if no action is taken. |
📝 WalkthroughWalkthroughThe core API now supports enabling and reading peer-sharing state. FFI and mobile bindings expose the feature. The dependency versions for ChangesPeer sharing controls
Estimated code review effort: 3 (Moderate) | ~20 minutes Sequence Diagram(s)sequenceDiagram
participant MobileOrFFI
participant LanternCore
participant IPCSettings
MobileOrFFI->>LanternCore: SetPeerShareEnabled(enabled)
LanternCore->>IPCSettings: Patch peer-sharing setting
MobileOrFFI->>LanternCore: IsPeerShareEnabled()
LanternCore->>IPCSettings: Read peer-sharing setting
IPCSettings-->>LanternCore: Return setting value
LanternCore-->>MobileOrFFI: Return enabled state
Possibly related PRs
Suggested reviewers: 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Picks up main (88 commits) and the radiance peer pin via #8729. No conflicts in the Dart layer.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 4 out of 5 changed files in this pull request and generated no new comments.
Suppressed comments (2)
lantern-core/mobile/mobile.go:222
- Function grouping/order looks inconsistent here:
SetSmartRoutingEnabledis separated fromIsSmartRoutingEnabledby the new PeerShare functions, whereas the surrounding toggles keep Set/Is pairs adjacent (e.g., BlockAds). Consider regrouping so each feature’s Set/Is functions remain together for easier scanning and future edits.
func SetSmartRoutingEnabled(enabled bool) error {
slog.Info("smart-routing: SetSmartRoutingEnabled", "enabled", enabled)
return withCore(func(c lanterncore.Core) error {
return c.SetSmartRoutingEnabled(enabled)
})
}
func SetPeerShareEnabled(enabled bool) error {
slog.Info("peer-share: SetPeerShareEnabled", "enabled", enabled)
return withCore(func(c lanterncore.Core) error {
return c.SetPeerShareEnabled(enabled)
})
}
func IsPeerShareEnabled() bool {
ok, err := withCoreR(func(c lanterncore.Core) (bool, error) {
return c.IsPeerShareEnabled(), nil
})
if err != nil {
return false
}
return ok
}
go.mod:31
- The PR description says the temporary radiance bump is to commit
6a238ce, but go.mod is pinned to pseudo-versionv0.0.0-20260805211630-2d796b8547f6(commit2d796b8547f6). Please reconcile this so reviewers and future pinning work are based on the intended radiance revision (either update the description or pin to the intended commit/tag).
require (
github.com/alecthomas/assert/v2 v2.3.0
github.com/getlantern/lantern-server-provisioner v0.0.0-20251031121934-8ea031fccfa9
github.com/getlantern/radiance v0.0.0-20260805211630-2d796b8547f6
github.com/sagernet/sing-box v1.12.22
golang.org/x/mobile v0.0.0-20250711185624-d5bb5ecc55c0
golang.org/x/sys v0.45.0
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@lantern-core/ffi/ffi.go`:
- Around line 1425-1426: Update the isPeerProxyEnabled getter around requireCore
so the unavailable-core path does not leak the allocated C error string: either
call lanternCore.Load() directly or capture and free the error pointer before
returning 0, while preserving the existing IsPeerShareEnabled check for an
available core.
🪄 Autofix
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: 9e8cec18-a90e-43c4-b7a9-5911a739612f
⛔ Files ignored due to path filters (1)
go.sumis excluded by!**/*.sum
📒 Files selected for processing (4)
go.modlantern-core/core.golantern-core/ffi/ffi.golantern-core/mobile/mobile.go
| c, _ := requireCore() | ||
| if c != nil && c.IsPeerShareEnabled() { |
There was a problem hiding this comment.
🩺 Stability & Availability | 🟠 Major | ⚡ Quick win
Release the error string allocated on the unavailable-core path.
When the core is unavailable, requireCore allocates a C.CString at Line 61. Line 1425 discards the returned pointer. Repeated calls to isPeerProxyEnabled before initialization can therefore grow the native heap without bound.
Read lanternCore.Load() directly for this getter, or free the error string before returning 0.
🐛 Proposed fix
func isPeerProxyEnabled() C.int {
- c, _ := requireCore()
- if c != nil && c.IsPeerShareEnabled() {
+ c := lanternCore.Load()
+ if c != nil && (*c).IsPeerShareEnabled() {
return 1
}
return 0
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| c, _ := requireCore() | |
| if c != nil && c.IsPeerShareEnabled() { | |
| c := lanternCore.Load() | |
| if c != nil && (*c).IsPeerShareEnabled() { | |
| return 1 | |
| } | |
| return 0 | |
| } |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@lantern-core/ffi/ffi.go` around lines 1425 - 1426, Update the
isPeerProxyEnabled getter around requireCore so the unavailable-core path does
not leak the allocated C error string: either call lanternCore.Load() directly
or capture and free the error pointer before returning 0, while preserving the
existing IsPeerShareEnabled check for an available core.
…Unbounded as Basic mode (Part 1/2) (#8819) * Add peer-share toggle to lantern-core (Share My Connection PR 3/4) PR 3 of 4 implementing the lantern-side wiring for "Share My Connection". Bumps radiance to fisk/peer-localbackend tip so we can reference the new PeerShareEnabledKey setting; that bump is provisional and should be re-pinned to a release tag once radiance #460 merges. * lantern-core/core.go: new PeerShare interface (mirrors Ads / SmartRouting), embedded in Core. SetPeerShareEnabled patches PeerShareEnabledKey via the radiance ipc client; IsPeerShareEnabled reads the snapshot. * lantern-core/ffi/ffi.go: new //export setPeerProxyEnabled and //export isPeerProxyEnabled, mirroring setBlockAdsEnabled exactly. The Dart FFI binding name uses "PeerProxy" to match the existing user-facing naming in the lantern repo (vpn_setting.dart toggle was drafted as "Peer Proxy"). * lantern-core/mobile/mobile.go: SetPeerShareEnabled / IsPeerShareEnabled for the gomobile-bind surface so Android can toggle once Dart wires it up in PR 4. The lifecycle path: Dart toggle → setPeerProxyEnabled(enabled) → LanternCore.SetPeerShareEnabled → ipc.Client.PatchSettings({PeerShareEnabledKey: ...}) → radiance LocalBackend.PatchSettings dispatch → peer.Client.Start / Stop ffigen regen for the Dart bindings happens in PR 4 alongside the Dart wire-through and rollback logic. go test ./lantern-core/... and golangci-lint --new-from-rev=origin/main both clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Wire Share My Connection toggle in Dart UI (PR 4/4) 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * peer-proxy: add macOS native handler 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) <noreply@anthropic.com> * Prototype: unified Share My Connection screen with globe + SmC disclosure UX prototype combining the Unbounded globe work (from Jigar's #8493 + Adam's #8492) with the Share My Connection FFI plumbing already on this branch. One unified screen, one toggle, one globe — auto-picks SmC when UPnP works and the user accepts the one-time disclosure, otherwise falls back to Unbounded. Backend wiring is mocked for the prototype: - UPnP probe is a 1.5s delay returning a coin-flip (so the demo exercises both the SmC and Unbounded paths across runs) - Connection events come from a 3s timer cycling through canned residential IPs in IR/CN/RU/TR/VN/PK/EG/MM, so the globe arcs animate while the screen is visible Real wiring (radiance peer module event emit, broflake OnConnectionChange plumb-through, persisted SmC acknowledgment, real UPnP probe via FFI) follows once we land the security review CRITICALs (C1/C2/C3). Reuses Jigar's flutter_earth_globe approach verbatim — uv-map textures, GeoLookupService, _GlobeView pattern with addPointConnection arcs. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * prototype: globe wasn't visible — restore MediaQuery override + ClipRect flutter_earth_globe positions the sphere relative to MediaQuery.size (full screen) by default, so embedding it in a non-fullscreen layout slot puts the sphere off-screen. The original unbounded.dart wrapped it in MediaQuery + Positioned.fill + ClipRect to keep the sphere centred inside the parent widget's bounds — I'd dropped those when porting. Restored. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * prototype: use SwitchButton to match the rest of the app's toggles Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * prototype: nudge the globe up — alignment(0, 0.1) → (0, -0.1) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * prototype: replace mock event timer with poll of radiance peer stats endpoint The Dart side now reads live connection state from the radiance peer client's localhost stats endpoint (127.0.0.1:17099/peer/connections) every 3s and diffs against the last snapshot to fire +1 / -1 events for the globe arcs. Globe origin is unchanged; arc destinations are real connected client IPs from Iran / China / Russia / etc. as the bandit assigns them. If the endpoint isn't up yet (peer.Client.Start in flight, or no real radiance peer process attached), the poll silently retries; the globe stays empty until the first successful snapshot. The IP→country geo lookup still runs through GeoLookupService.peerLookup (geo.getiantem.org), so each arc lands on the connecting client's country centroid. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Stream peer-connection events from radiance to Flutter via the existing FlutterEvent bridge; wire SmC toggle to the real radiance peer module. The localhost stats HTTP endpoint approach was reverted in radiance (detectability + extra attack surface). This swaps it for the existing Dart api_dl FlutterEvent channel — same bridge already carrying config / server-location / data-cap events, no new ports, no new process boundaries. lantern-core/core.go: - New EventTypePeerConnection event type, message JSON {state: +1|-1, source: "ip:port"}. - listenPeerConnectionEvents goroutine subscribes to radiance events.Subscribe[peer.ConnectionEvent] and forwards via notifyFlutter, which lights up the same appEventPort that AppEventNotifier already listens on. lib/features/share_my_connection/share_my_connection.dart: - Replaced the HTTP poll loop with a subscription to lanternServiceProvider.watchAppEvents(), filtered for type=='peer-connection'. Same UnboundedConnectionEvent shape goes into the existing globe stream — globe widget unchanged. - Wired the toggle to actually flip the real radiance peer module on for SmC mode via radianceSettingsProvider.setPeerProxy(true); the OFF path calls setPeerProxy(false) when the active mode was SmC (no-op otherwise so Unbounded mode doesn't accidentally tear down a peer that was never started). - Unbounded mode remains UI-only on this branch; broflake plumbing follows when radiance#336 lands. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Advanced section in Share My Connection: manual port forward setting For users on networks where UPnP doesn't work (most consumer routers ship with UPnP off by default, ISP gateways without IGD, double-NAT networks), this adds a UI-driven way to configure a router-side port forward without needing to set RADIANCE_PEER_EXTERNAL_PORT in the environment. Backend (Go side): - Core gains SetPeerManualPort(int) and GetPeerManualPort() — PatchSettings(PeerManualPortKey: <port>) and a typed read with koanf's float64-after-JSON-roundtrip behavior handled. - Two new //export FFI functions: setPeerManualPort(C.int) and getPeerManualPort() returning C.int. Frontend (Dart side): - lantern_generated_bindings.dart: hand-rolled bindings for the new exports (skipping ffigen for the prototype). - LanternCoreService interface, LanternFFIService impl, LanternService router, LanternPlatformService stub all gain setPeerManualPort / getPeerManualPort. Platform stub returns "not implemented" since the iOS/Android MethodChannel handlers aren't plumbed yet — degrades gracefully on those platforms. - New _AdvancedCard widget on the Share My Connection screen with an ExpansionTile (collapsed by default), containing _ManualPortField: loads the persisted port via getPeerManualPort, validates 1-65535, saves via setPeerManualPort, surfaces a SnackBar on success/failure. When set, displays a hint that toggling the share off-and-on is needed for the change to take effect (peer.Client.Start reads the setting once at start, doesn't watch it). Note on Unbounded: the disclosure dialog still references "Basic mode (Unbounded)" but Unbounded is not actually wired up on this branch — selecting it just sets local Dart state with no backend running. Real broflake/Unbounded integration follows when radiance#336 lands. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Unbounded fully wired through to the SmC UI's "Basic mode" End-to-end Unbounded integration on top of the radiance side: - Core gains SetUnboundedEnabled(bool) / IsUnboundedEnabled() — PatchSettings(UnboundedKey: ...) into the radiance settings store, picked up by radiance/unbounded's config-event subscription. - listenPeerConnectionEvents now subscribes to BOTH peer.ConnectionEvent (samizdat over UPnP / manual port — SmC mode) and unbounded.ConnectionEvent (broflake WebRTC — Unbounded mode), each forwarded as the same EventTypePeerConnection FlutterEvent. The globe sees a single unified stream and renders arcs identically regardless of which donor protocol produced the connection. - Two new //export FFI functions: setUnboundedEnabled, isUnboundedEnabled, with hand-rolled Dart bindings (skipping ffigen for the prototype). - LanternCoreService interface + FFI / Service / Platform impls all gain setUnboundedEnabled / isUnboundedEnabled. Platform stub returns "not implemented" for non-FFI platforms (iOS / Android) since their MethodChannel handlers aren't plumbed yet. - share_my_connection.dart's _start / _stop now actually call setUnboundedEnabled when the user picks Unbounded mode — so flipping the toggle and choosing "Basic mode (Unbounded)" in the disclosure dialog now starts the real broflake widget proxy, not just sets local Dart state. The broflake widget only actually runs when all three conditions hold: local opt-in (this toggle), server Features[UNBOUNDED] flag, and server-supplied UnboundedConfig. If the server hasn't rolled out the feature yet, the toggle persists the opt-in but the proxy stays inactive until the next /config response opts the user in. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * macOS: wire setPeerManualPort + setUnboundedEnabled through MethodChannel PlatformUtils.isFFISupported is Windows-or-Linux only — macOS routes through MethodChannel because the radiance backend runs inside the network extension, not the main app process. Without these handlers, the Advanced "Manual port forward" save and the Unbounded mode selection both hit the platform-service stub and surface "not yet available on this platform" SnackBars even though the underlying Core methods exist. Brings macOS to feature parity with Windows/Linux for the SmC stack: Already wired (existed): setPeerProxyEnabled / isPeerProxyEnabled → MobileSetPeerShareEnabled / MobileIsPeerShareEnabled Wired in this commit: setPeerManualPort / getPeerManualPort → MobileSetPeerManualPort / MobileGetPeerManualPort setUnboundedEnabled / isUnboundedEnabled → MobileSetUnboundedEnabled / MobileIsUnboundedEnabled After the next `make macos-release` (gomobile-bind regenerates Liblantern.xcframework with the four new symbols), the Share My Connection UI works end-to-end on macOS: - Toggle on, choose Full mode → peer.Client.Start, samizdat inbound - Choose Basic mode → unbounded.SetEnabled, broflake widget runs when the server's Features[unbounded] flag + config arrive - Advanced section save → port persisted, used as the manual forward override on next peer.Client.Start iOS / Android still don't have these handlers; SmC is also gated behind PlatformUtils.isDesktop in vpn_setting.dart so the tile isn't visible there. Mobile support is a separate UX pass — the "share my connection" mental model is different on cellular (sharing data plan, not residential bandwidth) and UPnP isn't applicable. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * share-my-connection: toggle honors Advanced manual port before UPnP probe The Dart-side toggle was running its mocked UPnP probe (a coin flip) without first checking whether the user had configured a manual port in Advanced settings. When the coin landed "no UPnP" the user got silently dropped into Unbounded mode despite having explicitly set up a port forward — defeating the whole point of the Advanced setting. Resolution order on enable is now: 1. settings.PeerManualPortKey is set (via Advanced UI): → straight to SmC mode, no UPnP probe, no disclosure dialog. Configuring a manual port forward is an explicit user-driven SmC opt-in; they wouldn't set it up if they weren't sure they wanted to share via the residential-IP path. 2. UPnP probe (mocked for now): → SmC if available + disclosure accepted, Unbounded if declined or unavailable. The radiance side already had the right precedence in peer.Client.Start's NewForwarder factory (settings > env var > UPnP); this just stops the Dart toggle from short-circuiting to Unbounded before the radiance side ever gets called. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * mobile: sanitize errors before returning to gomobile bridge RunOffCgoStack normalizes any non-nil error to a plain errorString with a guaranteed non-empty, valid-UTF-8 Error() message before handing it back to the gomobile-exported caller. Without this, a SIGABRT crashes the Lantern process when any mobile-exported function returns an error whose string contains non-UTF-8 bytes. Reproduced when toggling Share My Connection on while the prod /v1/peer/register endpoint returned 404 with a body whose bytes weren't valid UTF-8 (likely a gzipped or otherwise binary error page from the upstream LB). The chain that triggers the crash: *Error{Message: <404 body bytes>} → Error.Error() = "ipc: status 500: ... body=<bytes>" → withCore returns this through gomobile → -[Universeerror initWithRef:] auto-generated wrapper: self = [super initWithDomain:@"go" code:1 userInfo:@{NSLocalizedDescriptionKey: [self error]}]; → [self error] calls go_seq_to_objc_string(<bytes>) → [[NSString alloc] initWithBytesNoCopy:bytes length:N encoding:NSUTF8StringEncoding freeWhenDone:YES] → returns nil for non-UTF-8 input → @{...: nil} expands to +[NSDictionary dictionaryWithObjects:forKeys:count:] with objects[0] == nil → NSInvalidArgumentException → SIGABRT Crash signature on macOS: *** -[__NSPlaceholderDictionary initWithObjects:forKeys:count:]: attempt to insert nil object from objects[0] ... -[Universeerror initWithRef:] + 192 MobileSetPeerShareEnabled + 160 Centralizing the sanitization in RunOffCgoStack covers every Mobile* function that funnels its body through withCore (essentially all of mobile.go), so we don't have to thread fixes through individual exports. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * share-my-connection: surface radiance peer phase events to the UI The toggle today flips active/inactive with a multi-second gap between "on" and "Active — sharing" while radiance walks the Start lifecycle (port map → IP detect → register → libbox start → verify). To the user this looks hung. Adds granular status text driven by the new peer StatusEvent stream from radiance/peer (companion PR github.com/getlantern/radiance/pull/<TBD>). lantern-core/core.go: + EventTypePeerStatus = "peer-status" + listenPeerStatusEvents() forwards peer.StatusEvent (whose .Status field already has JSON tags for phase, error, active, etc.) as a FlutterEvent so the Dart side gets per-stage notifications. share_my_connection.dart: + SharePhase enum mirrors radiance Phase strings; .fromWire() maps backward-compatibly so unknown future phases default to idle. + ShareState carries phase + errorMessage; _handlePeerStatus folds incoming events into state. + _StatusCard renders phase-specific labels (Opening port… → Registering… → Verifying… → Sharing) and the error message on the failure terminal state. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * core: instrument peer-connection subscriber to pair with radiance breadcrumb If radiance's peer listener logs "forwarding" but this subscriber doesn't log "forwarding to Flutter", events.Emit is reaching no subscriber — the events bus is broken between Emit and Subscribe (process boundary in gomobile builds, etc.). If both log but Flutter sees nothing, the FlutterEvent bridge is the culprit. Spam-friendly: ~1 line per accept/close, bounded by peer inbound throughput. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * core: log listenPeerConnectionEvents goroutine entry One-shot diagnostic: if we see radiance peer listener firing but never this line, the goroutine that calls events.Subscribe was never started. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * core: consume peer events over IPC SSE instead of in-process events.Subscribe The events.Subscribe path was broken — radiance/peer emits in the lanternd process, but lantern-core's subscriber lives in Liblantern. Process boundary means two separate events package instances; subscribers=0 at every emit. Replace both listenPeerStatusEvents and listenPeerConnectionEvents (peer half) with the IPC client's PeerStatusEvents / PeerConnectionEvents SSE stream methods. The unbounded.ConnectionEvent half stays on events.Subscribe — broflake-as-library runs in the consumer process today and doesn't hit the cross-process gap. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * SmC: real per-peer geo, on-globe heart burst, arc reversal - Geo: peerLookup switched from geo.getiantem.org/<ip> (returns 404 for arbitrary IPs — every peer collapsed to the IR-fallback center) to ipwho.is (HTTPS, no auth, city-level lat/lon + country name + flag emoji). PeerLookup now returns PeerGeo with a real, unique location per peer. - Event model: UnboundedConnectionEvent carries country name, flag emoji, coords, and an isReplay flag. - Notifier: ref-counts streams per TCP peer so the arc persists until the peer's last H2 stream closes (samizdat multiplexes many streams over one conn); resolves geo async then emits enriched events; replayCurrentPeers() seeds the globe with existing peers when the user navigates to SmC mid-stream; emits synthetic -1's on toggle-off so arcs don't orphan when peer.Client.Stop suppresses the box.Close cascade. - Globe: arcs linger 5s past last -1 so brief URL-test probes still register; coords jittered ±2° per workerIdx hash so multiple peers in the same city fan out instead of overlapping; arc direction reversed (censored user → uncensored peer) so the dash animation reads as traffic arriving at us. - Heart burst: on-globe animation anchored at peer coords via Point.labelBuilder (lib projects 3D→2D for us). Uses the actual assets from getlantern/unbounded — explosion.json Lottie + the inline FF5A79 heart SVG path via CustomPainter. 4.6s burst + 4.2s fading country label below. - StatusCard: small info_outline tooltip explaining that most events are short URL-test liveness probes (601 of ~700 CONNECTs in a measured session were to api.iantem.io — clients probing peer reachability before sending real traffic). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * SmC: lift heart-burst off the globe into a floating toast Anchoring the burst to projected globe coords (via Point.labelBuilder) forced the widget to repaint every rotation frame, which made the globe rotation jittery. The burst is now a separate floating pill overlaid at the bottom of the globe area: - _ArrivalToast subscribes to ShareNotifier.connectionEvents, ignores replays, surfaces the current arrival in a slide-up + fade-in card. ValueKey on workerIdx forces AnimatedSwitcher to swap the widget when overlapping arrivals land so the Lottie restarts cleanly. - _HeartBurst is now just heart + Lottie, no country label, no globe anchor. The label moved into _ArrivalCard alongside the burst. - Removed _announceArrival (Point/labelBuilder pattern) and the burst anchor lifecycle. Globe rotation is smooth again. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * deps: bump radiance to #501 tip + lantern-box to #255 tip; drop local replaces After both stacks were rebased today, repin to fresh pseudo-versions: - github.com/getlantern/radiance @ 3684cef (radiance #501 tip; has peer/, settings.PeerShareEnabledKey, unbounded/) - github.com/getlantern/lantern-box @ 0b63c0f (lantern-box #255 tip; has tracker/peerconn + newer samizdat) Removed the dev-only `replace ../radiance` and `replace ../lantern-box` directives so this PR builds standalone for CI / reviewers. Once both feature stacks land on their respective mains, this commit can be amended away in favor of the released versions. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * smc: address Copilot review (5 of 7) share_my_connection.dart: - ShareState.copyWith now uses a sentinel default for errorMessage (Object? = _unsetErrorMessage) so callers can distinguish 'leave alone' from 'clear it'. The naive '?? this.errorMessage' pattern conflated the two and left stale error text wedged in state — the next phase transition into error would re-render the wrong message. - _smcAck (the SmC disclosure ack) now persists via LocalStorageService using a containsKey-based 'smc_disclosure_acked' key, so the disclosure modal doesn't re-fire on every app restart. - All new user-facing strings (~30) moved from hardcoded English into assets/locales/en.po and consumed via .i18n / .fill. Covers hero copy, status phase labels, status card stats, tooltip, arrival toast, Advanced section, manual port forward field, snackbar messages, and the disclosure dialog. Matches the established convention in vpn_setting.dart. lib/core/services/geo_lookup_service.dart: - Added a privacy note on peerLookup documenting the ipwho.is data flow: each call ships a peer's IP (typically a censored user's address) to a third-party geo-IP service. Documents the current rationale + the fix to do before any production-scale rollout (Lantern-controlled endpoint or local DB). The lookup itself stays; see PR reply for the design discussion. lib/features/home/provider/radiance_settings_providers.dart: - _refresh's fragile positional 'peerIdx' index into Future.wait results replaced with named-future await-per-variable. Adding another optional fetch later can't silently desync read indices. Performance unchanged: the futures are still started before any await, so they run concurrently; the awaits just collect them in order. lantern-core/core.go: - listenPeerConnectionEvents: unbounded.ConnectionEvent subscription was leaked (Subscribe but never Unsubscribe). Now captures the Subscription handle and unsubscribes on ctx.Done in a small companion goroutine. - Dropped the redundant inner 'go func()' that wrapped the SSE call. The caller already spawned the outer goroutine via 'go lc.listenPeerConnectionEvents()', so the inner go just exited the outer immediately and lost structured cancellation. The SSE call now blocks the outer goroutine directly. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * smc: wire real UPnP / IGD probe via FFI + MethodChannel Replaces the Random().nextBool() mock that was gating Full SmC vs Unbounded mode on toggle. Half of opted-in users were getting routed to a mode that didn't match their network capabilities. End-to-end: - radiance/portforward.ProbeUPnP(ctx) (added in radiance commit 79400ef): wraps NewForwarder to do the M-SEARCH discovery, returns bool, no port actually mapped. - lantern-core/core.go: LanternCore.ProbeUPnP() bool wrapping portforward.ProbeUPnP with a 6s internal timeout. Added to the PeerShare interface so callers and stubs stay in sync. - lantern-core/ffi/ffi.go: //export probeUPnP returning C.int (0/1). Documents the 6s upper bound and the requirement that Dart callers invoke from a background isolate. - lantern-core/mobile/mobile.go: ProbeUPnP() bool for the iOS / Android MethodChannel handler — platforms whose Flutter side can't reach the FFI directly. - lantern_generated_bindings.dart: regenerated via make ffigen. All existing SmC stack exports (setPeerProxy / setPeerManualPort / setUnboundedEnabled / etc.) still present; probeUPnP added. - Dart service layer (core_service / service / ffi_service / platform_service): added probeUPnP() Future<Either<Failure, bool>>. FFI implementation runs the synchronous C call inside runInBackground so the 6s wait doesn't pin the UI isolate. Platform implementation hops to the MethodChannel platform thread. - share_my_connection.dart: replaced the Random / 1.5s-sleep mock with svc.probeUPnP(). Any probe error degrades to 'UPnP unavailable' → Unbounded fallback, matching the user-visible contract from the mock. Dropped the now-unused dart:math import (showed up as max/min usage elsewhere, switched to a 'show' filter). Updated the file-level docstring to reflect the wired probe. - go.mod: bumped radiance to the commit with ProbeUPnP. - lantern-core/core.go: drive-by fix for ConnectionEvent shape drift (the radiance unbounded.ConnectionEvent JSON contract finalized to {state, source, timestamp}; the lantern side was still writing the old {addr, workerIdx} field names). dart analyze clean on touched files; CGO_ENABLED=1 go build ./lantern-core/... clean. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * smc: i18n the 'On — tap to view' subtitle in VPN settings Added share_my_connection_on_tap_to_view key to assets/locales/en.po and consumed it via .i18n. Matches the existing share_my_connection_subtitle pattern in the same conditional. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * macos: wire probeUPnP MethodChannel handler Round-2's UPnP probe FFI wiring added the Dart-side platform service that calls _methodChannel.invokeMethod('probeUPnP'), but forgot to add the corresponding case in MethodHandler.swift. Without this, the MethodChannel call throws MissingPluginException on macOS, the platform service returns Left(...), and ShareNotifier.toggle interprets that as 'UPnP unavailable' → falls straight through to Unbounded mode. macOS users would never reach the Full SmC path unless they set a manual port — directly contradicting the macOS test plan in the PR description. Mirrors the existing pattern for the peer-share / unbounded methods. Uses Task.detached so the up-to-6s M-SEARCH wait runs off the MainActor; delivers the bool back via MainActor.run for the Flutter result callback. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * mobile: wire SmC + Unbounded MethodChannel handlers for Android & iOS macOS already had all seven SmC stack methods wired in its Runner's MethodHandler.swift, but Android (handler/MethodHandler.kt) and iOS (ios/Runner/Handlers/MethodHandler.swift) had zero coverage. With the unbounded tab gated on FeatureFlag.unbounded (server-side region check) rather than a platform check, mobile users in unbounded-enabled regions would tap the toggle and hit MissingPluginException for setUnboundedEnabled / probeUPnP / setPeerShareEnabled / setPeerManualPort — silent failures, no actual effect. The whole stack is exposed on mobile rather than desktop-only because manual port forwarding works on any home WiFi where the user owns the router (UPnP discovery works there too). The probe correctly returns false on cellular networks, falling back to Unbounded. Android handler/MethodHandler.kt: - 7 new enum entries (setPeerProxyEnabled, isPeerProxyEnabled, setPeerManualPort, getPeerManualPort, setUnboundedEnabled, isUnboundedEnabled, probeUPnP). - 7 new dispatch cases delegating to Mobile.setPeerShareEnabled, Mobile.isPeerShareEnabled, Mobile.setPeerManualPort, Mobile.getPeerManualPort, Mobile.setUnboundedEnabled, Mobile.isUnboundedEnabled, Mobile.probeUPnP. Wraps go's int return as toInt() (the gomobile binding maps Go int → Java long), and the input port as toLong(). scope.handleValue runs on Dispatchers.IO so probeUPnP's up-to-6s M-SEARCH wait doesn't pin the main thread. ios/Runner/Handlers/MethodHandler.swift: - 7 new case branches mirroring the existing macOS handler: - Direct one-liner cases for the simple bool getters (isPeerProxyEnabled, isUnboundedEnabled). - Helper-function dispatches for the setters (setPeerProxyEnabled, setPeerManualPort, setUnboundedEnabled) so the gomobile error-out path stays consistent with handleFlutterError. - probeUPnP uses Task.detached so the multicast wait runs off the main actor. - 3 new helper functions (setPeerProxyEnabled, setPeerManualPort, setUnboundedEnabled) following the existing setBlockAdsEnabled pattern. lib/features/home/provider/radiance_settings_providers.dart: - Dropped the PlatformUtils.isDesktop gate on the isPeerProxyEnabled fetch. The handler now exists on every platform, so the fetch succeeds across the board. Updated the comment to reflect the Windows+Linux+macOS+Android+iOS coverage and the rationale (manual port forwarding works on home WiFi). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * smc: address Copilot review on #8819 round-N Six findings: 1-3. lantern_ffi_service.dart: the SmC stack's three setter wrappers (setPeerProxyEnabled, setPeerManualPort, setUnboundedEnabled) each invoked the FFI `*C.char`-returning function, converted the pointer to a Dart string, and dropped the pointer. The Go side allocates each return via C.CString — every call leaked a small heap allocation. Wrapped each in the resultPtr-finally-freeCString pattern matching the existing startVPN / stripeBillingPortalUrl call sites. 4. share_my_connection.dart: the peer-event handler used `source.split(':').first` to strip the port off the source address. Mis-parses IPv6: '[2001:db8::1]:443'.split(':').first → '[2001' '2001:db8::1'.split(':').first → '2001' Extracted a small _extractIP helper handling the three emit forms — bracketed-IPv6 host:port, bare-IPv6, IPv4 host:port — via Uri.tryParse with a synthesized scheme (which gets the bracket-stripping right) and a bare-IP fallback for cases with no port. 5-6. lantern_platform_service.dart: the docstrings on the setPeerManualPort and setUnboundedEnabled MethodChannel wrappers still said the handlers exist only on macOS and 'iOS / Android don't implement these handlers yet' — that's stale after the previous commit added them. Rewrote both to describe the current state (macOS / iOS Swift + Android Kotlin all delegate to Mobile.* via the gomobile binding). dart analyze clean on the touched files. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * smc: tighten _extractIP for bare IPv6 + check Unbounded enable Either Two Copilot findings: 1. _extractIP mis-parsed bare IPv6 (e.g. `2001:db8::1`). The previous condition routed multi-colon strings into Uri.tryParse, which can't parse an un-bracketed IPv6 host and returned empty, then fell through to substring(0, lastColon) which truncated the address to '2001:db8:'. Reworked the parser around the four shapes the Go side emits: - bracketed IPv6 host:port → Uri parse (strips brackets) - bare IPv6 (multi-colon, no brackets) → return as-is - IPv4 host:port (single colon) → substring up to colon - bare IPv4 (no colon) → return as-is 2. _start's ShareMode.unbounded branch threw away the Either returned by setUnboundedEnabled — a failure (core not initialized, MethodChannel failure, etc.) left the UI stuck at 'Active' while nothing actually started. Now folds the result, logs on Left, and reverts state to SharePhase.error with the error message so the user sees an actionable failure. The ShareMode.smc branch doesn't need an equivalent check because peer.Client emits phase=error StatusEvent on real failures, which _handlePeerStatus already routes through _fallbackToUnbounded. dart analyze clean on the touched file. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * smc: render off-with-error + check SmC enable Either + cache peer geo Four Copilot findings: 1. _StatusCard's switch matched (ShareMode.off, _) before (ShareMode.off, SharePhase.error), so the round-N 'revert to off+error on Unbounded failure' state rendered as plain 'Off' instead of an actionable error. Added a specific (off, error) arm before the catch-all that renders the same smc_status_error_with_message / generic strings the SmC error path uses. 2. The ShareMode.smc enable path threw away setPeerProxy's result, so failures BEFORE peer.Client.Start (IPC error, MissingPluginException, core not initialized) didn't surface anywhere — the screen stuck at 'active: true' with an event subscription running while sharing never started. Changed radianceSettingsProvider.notifier.setPeerProxy to return Future<Either<Failure, Unit>> instead of Future<void>. The SmC branch in _start now folds the result and, on Left, tears down the event subscription and reverts to mode=off / phase=error (matching the Unbounded branch's pattern). The stop-path caller doesn't read the return value, which is fine — Dart allows the discard, and toggle-off is fire-and- forget anyway. 3-4. GeoLookupService.peerLookup ran a fresh HTTP request to ipwho.is for every probe connection. The tooltip explicitly notes most connections are short liveness probes from the same handful of client IPs — without caching this would chew through the 10k/month free quota in minutes and leak more data to the third party than necessary. Added a process-lifetime per-IP cache (Map<String, PeerGeo>) that also caches the PeerGeo.unknown sentinel from failed lookups (so a previously-failed lookup doesn't retry on every subsequent probe). No TTL — IP→country bindings don't change on human timescales, and the TTL bookkeeping adds complexity without changing the privacy or quota math. Added a resetCacheForTest() helper for unit tests. dart analyze clean on the touched files. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * smc: list-spread + dispose guards + terminal-phase reset + docs Seven Copilot findings (with 5 duplicate threads, so 7 unique): 1. vpn_setting.dart used '...{' (Set literal spread) inside a List children: literal in three spots. Switched all three to '...[', and closed with '],' to match. Set literals would dedup widgets silently and the type mismatch is easy to miss; list spread is the conventional shape. 2. _ManualPortField's useEffect did a fire-and-forget Future.microtask that wrote to the TextEditingController and ValueNotifiers after disposal if the user navigated away quickly. Added a 'disposed' flag flipped from the useEffect cleanup, checked after the await. 3. _HeartBurst's Lottie.asset onLoaded callback could fire after the State was disposed (rapid arrival burst replaces the ArrivalCard before composition load). The setState + AnimationController(vsync: this) inside the callback would then throw. Added 'if (!mounted) return;' guard and disposed any prior controller so a stale ticker subscription from an earlier onLoaded doesn't leak. 4. _handlePeerStatus only updated phase/errorMessage, leaving state.active/mode stuck on SmC when the backend reported a terminal phase. The toggle could show ON while radiance was idle (clean stop) or error (start failed). Added a terminal- phase branch: both idle and error tear down the event subscription; error preserves the message so the new (off, error) StatusCard arm renders it. 5. lantern-core/core.go's listenPeerConnectionEvents doc comment said the Unbounded payload included workerIdx, but the actual marshal block emits {state, source, timestamp}. Updated the comment to match the actual payload + describe the source format on both protocols. dart analyze clean on the touched files; Go build clean on lantern-core/... Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * smc: MediaQuery copyWith + symmetric wire format + drop stale fromJson Three Copilot findings: 1. _GlobeView wrapped its body in MediaQuery(data: MediaQueryData(size: widgetSize), ...) — constructing MediaQueryData from scratch drops inherited fields (devicePixelRatio, textScaleFactor, padding, viewInsets, etc.). On high-DPI displays the pixel ratio fell to 1.0, breaking globe rendering crispness; accessibility scaling for any descendants would also break. Switched to MediaQuery.of(context).copyWith(size: widgetSize) which keeps the inherited fields and only overrides what we need. 2. lantern-core/core.go's doc comment said the peer-connection wire payload was always {state, source, timestamp}, but the peer.ConnectionEvent marshal block emitted only {state, source}. Added timestamp to the peer marshal (both peer.ConnectionEvent and unbounded.ConnectionEvent carry Timestamp on the radiance side, so the consumer-facing shape is now symmetric) and updated the comment to spell out the source format difference between protocols and call out Unix-millis for timestamp. 3. UnboundedConnectionEvent.fromJson was stale: it expected {workerIdx, addr} keys, but the actual wire format is {state, source, timestamp}. The factory is never called from anywhere in lib/ — wire-format parsing happens inline in share_my_connection.dart, and the class is only constructed directly by the notifier as an internal Dart-side event model. Dropped the dead factory and clarified the class docstring to distinguish 'internal Dart-side model' from 'wire format', including a note that workerIdx is a Dart-side identity counter (_workerSeq), not the broflake worker index. dart analyze clean; Go build clean. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * smc: gate arc draws on origin coords + accurate event type doc Four Copilot findings: 1. replayCurrentPeers fired before _initOrigin completed — replayed arcs drew to GlobeCoordinates(0,0) and never got corrected. Moved the replay call into _initOrigin's continuation so it runs only AFTER origin coords are known. 2. _addPeer fell back to GlobeCoordinates(0,0) when _originCoords hadn't loaded yet, so real-time +1 events arriving during the origin-lookup window could still draw to (0,0). Added a null guard: if origin isn't resolved, skip the draw — the peer is still tracked in the notifier's _peerArcs map (source of truth), and replayCurrentPeers in _initOrigin's continuation picks it up. Reordered initState: subscribe FIRST so real-time events accumulate in _peerArcs while origin is loading, then call _initOrigin which finishes by calling replayCurrentPeers. With both changes there's no window where a peer is drawn without correct origin coords. 3. debugPrint comment claimed it 'avoids bringing in the appLogger' and that 'real impl can switch to slog' — but the file already uses appLogger, and slog isn't a Dart logger. Rewrote to describe the actual rationale: avoid escalating a single malformed wire event to a user-visible error toast in debug builds; keep the listener subscribed so subsequent well-formed events still arrive. 4. lantern-core's EventTypePeerConnection doc described it as samizdat-only with a {state, source} payload. Both donor protocols now emit on this event type with the unified {state, source, timestamp} payload (peer-share's marshal was bumped in the previous round to include timestamp). Updated the doc accordingly with source-format details for both protocols. dart analyze clean; Go build clean. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * smc: defer subscription cleanup + strict arg validation across handlers Six Copilot findings: 1. lantern-core/core.go's listenPeerConnectionEvents subscribed to unbounded.ConnectionEvent and started a goroutine waiting on ctx.Done to unsubscribe. If client.PeerConnectionEvents returned an error while ctx was still live, the function returned but the ctx-watcher goroutine + subscription leaked for the rest of the process. Replaced with 'defer unbSub.Unsubscribe()' so cleanup runs on both exit paths (normal ctx cancel + unexpected stream exit). 2. iOS handler comment said the SmC setter helpers use a 'detached Task' but the implementation uses 'Task {}'. Updated the comment to describe the actual choice — plain Task is fine for the millisecond-range PatchSettings calls because inheriting the current actor's executor is cheap; the probeUPnP case is the one exception that uses Task.detached because its M-SEARCH wait is multi-second. 3-5. macOS / iOS handlers had unsafe defaults on the SmC setters: - setPeerProxyEnabled: defaulted enabled=false on missing arg (would silently disable sharing on caller bugs) - setPeerManualPort: defaulted port=0 on missing arg (would silently clear the user's manual port override, since 0 has the real semantic of 'no manual port') - setUnboundedEnabled: same Bool-defaults-to-false issue (Copilot didn't flag this one but it has the identical problem) All four now route through requireArg, which surfaces a FlutterError on missing/invalid argument shape instead of defaulting silently. 6. Android setPeerManualPort defaulted port=0 the same way. Switched the elvis '?: 0' to '?: error("Missing port")' to match the SetPeerProxyEnabled pattern on Android. Go build clean. The Swift / Kotlin changes are mechanical and follow patterns already established in the same files (requireArg / error()-on-missing). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * smc: _resolveAndEmit isClosed guard + honest 'session' stat label Two Copilot fixes (+ a third pushback in the reply): 1. _resolveAndEmit awaits peerLookup. If the notifier is disposed during the await, _eventController.close() has already run; the subsequent _eventController.add would throw 'Bad state: Cannot add event after closing'. Added an isClosed check after the await before the identity check. 2. smc_stat_total_today msgstr read 'Total today' but the ShareState.totalCount is session-scoped (reset on every toggle-on, no day bucket, no persistence). Renamed to 'Total this session' so the label matches the implemented semantics. #8820's rebase later replaces this key with smc_stat_total_helped + 'Total people helped to date' alongside persistence via unboundedTotalHelped — until then the more honest 'session' wording matches reality. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * smc: keep-alive ShareNotifier + reflect Unbounded in VPN tile Two new Copilot fixes (+ a third escalation acknowledged in reply): 1. shareProvider was the default (non-annotated) NotifierProvider, which is autoDispose in Riverpod 3.x — so navigating away from the screen disposed the notifier, re-entry reset state to mode=off / active=false even when SmC or Unbounded was still running, and the next toggle tried to re-enable an already- enabled setting. Added `ref.keepAlive()` at the top of build() so the notifier sticks for the process lifetime. onDispose stays registered for the explicit teardown paths (provider container reset, hot reload) so the event subscription + stream controller still get cleaned up. 2. VPN settings tile rendered "Off" when the user had picked "Basic mode (Unbounded)" in the disclosure dialog because the subtitle was driven solely by peerProxy. Added unboundedEnabled to RadianceSettingsState (with a copyWith field + equality / hashCode update), wired the fetch + setter through radianceSettingsProvider, and the tile now reads OR of both to decide whether to show share_my_connection_on_tap_to_view. dart analyze clean on the touched files. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * smc: revise defer Unsubscribe comment to match actual lifecycle Per Copilot — the previous comment claimed parity with the SSE-stream-failure path, but in practice PeerConnectionEvents blocks until ctx cancellation and there's no retry loop wrapping listenPeerConnectionEvents, so the defer effectively runs at process shutdown. Rewrote to spell that out while still calling out why defer is the right shape (future-proofing against early returns / retry wrappers). No code change. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * smc: re-apply the globe theme whenever the inherited theme changes The globe loaded uv-map-dark.png in a light-themed app. _applyTheme ran once, from the controller's onLoaded, and Theme.of() read outside build/didChangeDependencies registers no dependency — so whatever brightness the first frame reported was latched for the widget's whole life. macOS can report a platformBrightness for that first frame which then changes once the platform settles; every other widget self-corrects on the resulting rebuild, but a one-shot read cannot. Drive it from didChangeDependencies instead, guarded on the last applied brightness so an unrelated inherited-widget change doesn't re-decode and re-project the 2048x1024 texture. This also makes a live light/dark switch update the globe, which previously kept the stale surface. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * smc: defer the globe theme apply out of the build phase FlutterEarthGlobeController's setters call notifyListeners synchronously, and didChangeDependencies runs inside the build phase, so applying the surface and atmosphere directly from it marks the globe dirty while it is already building. The previous onLoaded path never hit this because it ran from a Duration.zero future. Read the brightness in didChangeDependencies, which is where the dependency has to be registered, but apply it in a post-frame callback. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * deps: bump radiance to f59e3fe This branch pinned a radiance feature-branch commit predating the one #8820 stacks on top of, so go.mod conflicted between the two. radiance#589 has since merged to radiance main, so both can pin the same main commit and the conflict goes away. Carries lantern-box v0.0.111 -> v0.0.112 along with it, matching main. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * smc: never choose the peer-proxy mode on iOS radiance refuses to serve as a peer on iOS, where the backend runs inside the network extension and its memory budget cannot carry the peer proxy's second sing-box instance. This branch carries the SmC screen and targets main, so without the matching choice here it can land the UI that routes users into that mode on its own, and the backend's refusal would surface as a failure. The branch already ships this gate one level up on the unbounded-tab branch, which is the wrong place for it: that one is stacked on this, so merging this alone would have shipped the screen ungated. toggle() is the only function that selects SmC — all three of its paths (manual port, a stored disclosure ack, and accepting the dialog) sit below the short-circuit, so one branch covers them. It goes ahead of the manual-port read because none of those inputs can change the outcome: the constraint is the extension's budget rather than reachability or consent, and the probe blocks about six seconds before answering a question that no longer matters. Sharing still works on iOS through Unbounded, which is where the probe already falls back when no gateway is reachable. Deliberately not running dart format on this file: it predates the tall-style formatter, so formatting it would bury eleven lines under a five-hundred-line reflow. CI enforces no format check. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * utils: sanitize the gomobile error message without discarding the error sanitizeForGomobile rebuilt every error as a plain errors.New, which strips the type and the wrap chain. Callers keep the identical text, so nothing looks wrong in a log, but errors.Is and errors.As stop matching across any gomobile-exported call that funnels through RunOffCgoStack. main grew a test for exactly that on 2026-08-12 and this branch forked before it, so the two only meet in the PR merge — which is why CI went red on a Dart-only push. The test is right and this branch was wrong. Keep the crash-safety guarantee, which is real: the objc bridge turns invalid UTF-8 into a nil NSString and then aborts on inserting nil into a dictionary literal. But the bridge only ever reads Error(), so it costs nothing to return the original error untouched when its message is already usable, and to wrap rather than replace when it is not. sanitizedError reports the cleaned message and unwraps to the cause. Adds the tests this file never had, covering both halves: a sentinel survives a round trip bare and wrapped, and a sanitized error still unwraps to its original while presenting a bridge-safe message. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Adam Fisk <afisk@mini.local> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Add peer-share toggle to lantern-core (Share My Connection PR 3/4)
PR 3 of 4 implementing the lantern-side wiring for "Share My
Connection". Bumps radiance to fisk/peer-localbackend tip so we can
reference the new PeerShareEnabledKey setting; that bump is provisional
and should be re-pinned to a release tag once radiance #460 merges.
* lantern-core/core.go: new PeerShare interface (mirrors Ads /
SmartRouting), embedded in Core. SetPeerShareEnabled patches
PeerShareEnabledKey via the radiance ipc client; IsPeerShareEnabled
reads the snapshot.
* lantern-core/ffi/ffi.go: new //export setPeerProxyEnabled and
//export isPeerProxyEnabled, mirroring setBlockAdsEnabled exactly.
The Dart FFI binding name uses "PeerProxy" to match the existing
user-facing naming in the lantern repo (vpn_setting.dart toggle was
drafted as "Peer Proxy").
* lantern-core/mobile/mobile.go: SetPeerShareEnabled / IsPeerShareEnabled
for the gomobile-bind surface so Android can toggle once Dart wires
it up in PR 4.
The lifecycle path:
Dart toggle → setPeerProxyEnabled(enabled)
→ LanternCore.SetPeerShareEnabled
→ ipc.Client.PatchSettings({PeerShareEnabledKey: ...})
→ radiance LocalBackend.PatchSettings dispatch
→ peer.Client.Start / Stop
ffigen regen for the Dart bindings happens in PR 4 alongside the Dart
wire-through and rollback logic.
go test ./lantern-core/... and golangci-lint --new-from-rev=origin/main
both clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Wire Share My Connection toggle in Dart UI (PR 4/4)
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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* peer-proxy: add macOS native handler
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) <noreply@anthropic.com>
* Prototype: unified Share My Connection screen with globe + SmC disclosure
UX prototype combining the Unbounded globe work (from Jigar's #8493 + Adam's
#8492) with the Share My Connection FFI plumbing already on this branch. One
unified screen, one toggle, one globe — auto-picks SmC when UPnP works and
the user accepts the one-time disclosure, otherwise falls back to Unbounded.
Backend wiring is mocked for the prototype:
- UPnP probe is a 1.5s delay returning a coin-flip (so the demo exercises
both the SmC and Unbounded paths across runs)
- Connection events come from a 3s timer cycling through canned residential
IPs in IR/CN/RU/TR/VN/PK/EG/MM, so the globe arcs animate while the
screen is visible
Real wiring (radiance peer module event emit, broflake OnConnectionChange
plumb-through, persisted SmC acknowledgment, real UPnP probe via FFI)
follows once we land the security review CRITICALs (C1/C2/C3).
Reuses Jigar's flutter_earth_globe approach verbatim — uv-map textures,
GeoLookupService, _GlobeView pattern with addPointConnection arcs.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* prototype: globe wasn't visible — restore MediaQuery override + ClipRect
flutter_earth_globe positions the sphere relative to MediaQuery.size (full
screen) by default, so embedding it in a non-fullscreen layout slot puts
the sphere off-screen. The original unbounded.dart wrapped it in
MediaQuery + Positioned.fill + ClipRect to keep the sphere centred inside
the parent widget's bounds — I'd dropped those when porting. Restored.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* prototype: use SwitchButton to match the rest of the app's toggles
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* prototype: nudge the globe up — alignment(0, 0.1) → (0, -0.1)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* prototype: replace mock event timer with poll of radiance peer stats endpoint
The Dart side now reads live connection state from the radiance peer
client's localhost stats endpoint (127.0.0.1:17099/peer/connections)
every 3s and diffs against the last snapshot to fire +1 / -1 events
for the globe arcs. Globe origin is unchanged; arc destinations are
real connected client IPs from Iran / China / Russia / etc. as the
bandit assigns them.
If the endpoint isn't up yet (peer.Client.Start in flight, or no
real radiance peer process attached), the poll silently retries; the
globe stays empty until the first successful snapshot.
The IP→country geo lookup still runs through GeoLookupService.peerLookup
(geo.getiantem.org), so each arc lands on the connecting client's
country centroid.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Stream peer-connection events from radiance to Flutter via the existing
FlutterEvent bridge; wire SmC toggle to the real radiance peer module.
The localhost stats HTTP endpoint approach was reverted in radiance
(detectability + extra attack surface). This swaps it for the existing
Dart api_dl FlutterEvent channel — same bridge already carrying
config / server-location / data-cap events, no new ports, no new
process boundaries.
lantern-core/core.go:
- New EventTypePeerConnection event type, message JSON
{state: +1|-1, source: "ip:port"}.
- listenPeerConnectionEvents goroutine subscribes to radiance
events.Subscribe[peer.ConnectionEvent] and forwards via
notifyFlutter, which lights up the same appEventPort that
AppEventNotifier already listens on.
lib/features/share_my_connection/share_my_connection.dart:
- Replaced the HTTP poll loop with a subscription to
lanternServiceProvider.watchAppEvents(), filtered for
type=='peer-connection'. Same UnboundedConnectionEvent shape
goes into the existing globe stream — globe widget unchanged.
- Wired the toggle to actually flip the real radiance peer
module on for SmC mode via radianceSettingsProvider.setPeerProxy(true);
the OFF path calls setPeerProxy(false) when the active mode was SmC
(no-op otherwise so Unbounded mode doesn't accidentally tear down a
peer that was never started).
- Unbounded mode remains UI-only on this branch; broflake plumbing
follows when radiance#336 lands.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Advanced section in Share My Connection: manual port forward setting
For users on networks where UPnP doesn't work (most consumer routers
ship with UPnP off by default, ISP gateways without IGD, double-NAT
networks), this adds a UI-driven way to configure a router-side port
forward without needing to set RADIANCE_PEER_EXTERNAL_PORT in the
environment.
Backend (Go side):
- Core gains SetPeerManualPort(int) and GetPeerManualPort() —
PatchSettings(PeerManualPortKey: <port>) and a typed read with
koanf's float64-after-JSON-roundtrip behavior handled.
- Two new //export FFI functions: setPeerManualPort(C.int) and
getPeerManualPort() returning C.int.
Frontend (Dart side):
- lantern_generated_bindings.dart: hand-rolled bindings for the new
exports (skipping ffigen for the prototype).
- LanternCoreService interface, LanternFFIService impl, LanternService
router, LanternPlatformService stub all gain setPeerManualPort /
getPeerManualPort. Platform stub returns "not implemented" since the
iOS/Android MethodChannel handlers aren't plumbed yet — degrades
gracefully on those platforms.
- New _AdvancedCard widget on the Share My Connection screen with an
ExpansionTile (collapsed by default), containing _ManualPortField:
loads the persisted port via getPeerManualPort, validates 1-65535,
saves via setPeerManualPort, surfaces a SnackBar on success/failure.
When set, displays a hint that toggling the share off-and-on is
needed for the change to take effect (peer.Client.Start reads the
setting once at start, doesn't watch it).
Note on Unbounded: the disclosure dialog still references "Basic mode
(Unbounded)" but Unbounded is not actually wired up on this branch —
selecting it just sets local Dart state with no backend running. Real
broflake/Unbounded integration follows when radiance#336 lands.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Unbounded fully wired through to the SmC UI's "Basic mode"
End-to-end Unbounded integration on top of the radiance side:
- Core gains SetUnboundedEnabled(bool) / IsUnboundedEnabled() —
PatchSettings(UnboundedKey: ...) into the radiance settings store,
picked up by radiance/unbounded's config-event subscription.
- listenPeerConnectionEvents now subscribes to BOTH peer.ConnectionEvent
(samizdat over UPnP / manual port — SmC mode) and
unbounded.ConnectionEvent (broflake WebRTC — Unbounded mode), each
forwarded as the same EventTypePeerConnection FlutterEvent. The
globe sees a single unified stream and renders arcs identically
regardless of which donor protocol produced the connection.
- Two new //export FFI functions: setUnboundedEnabled, isUnboundedEnabled,
with hand-rolled Dart bindings (skipping ffigen for the prototype).
- LanternCoreService interface + FFI / Service / Platform impls all
gain setUnboundedEnabled / isUnboundedEnabled. Platform stub returns
"not implemented" for non-FFI platforms (iOS / Android) since their
MethodChannel handlers aren't plumbed yet.
- share_my_connection.dart's _start / _stop now actually call
setUnboundedEnabled when the user picks Unbounded mode — so flipping
the toggle and choosing "Basic mode (Unbounded)" in the disclosure
dialog now starts the real broflake widget proxy, not just sets
local Dart state.
The broflake widget only actually runs when all three conditions hold:
local opt-in (this toggle), server Features[UNBOUNDED] flag, and
server-supplied UnboundedConfig. If the server hasn't rolled out the
feature yet, the toggle persists the opt-in but the proxy stays
inactive until the next /config response opts the user in.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* macOS: wire setPeerManualPort + setUnboundedEnabled through MethodChannel
PlatformUtils.isFFISupported is Windows-or-Linux only — macOS routes
through MethodChannel because the radiance backend runs inside the
network extension, not the main app process. Without these handlers,
the Advanced "Manual port forward" save and the Unbounded mode
selection both hit the platform-service stub and surface "not yet
available on this platform" SnackBars even though the underlying
Core methods exist.
Brings macOS to feature parity with Windows/Linux for the SmC stack:
Already wired (existed):
setPeerProxyEnabled / isPeerProxyEnabled
→ MobileSetPeerShareEnabled / MobileIsPeerShareEnabled
Wired in this commit:
setPeerManualPort / getPeerManualPort
→ MobileSetPeerManualPort / MobileGetPeerManualPort
setUnboundedEnabled / isUnboundedEnabled
→ MobileSetUnboundedEnabled / MobileIsUnboundedEnabled
After the next `make macos-release` (gomobile-bind regenerates
Liblantern.xcframework with the four new symbols), the Share My
Connection UI works end-to-end on macOS:
- Toggle on, choose Full mode → peer.Client.Start, samizdat inbound
- Choose Basic mode → unbounded.SetEnabled, broflake widget runs
when the server's Features[unbounded] flag + config arrive
- Advanced section save → port persisted, used as the manual
forward override on next peer.Client.Start
iOS / Android still don't have these handlers; SmC is also gated
behind PlatformUtils.isDesktop in vpn_setting.dart so the tile isn't
visible there. Mobile support is a separate UX pass — the "share my
connection" mental model is different on cellular (sharing data
plan, not residential bandwidth) and UPnP isn't applicable.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* share-my-connection: toggle honors Advanced manual port before UPnP probe
The Dart-side toggle was running its mocked UPnP probe (a coin flip)
without first checking whether the user had configured a manual port
in Advanced settings. When the coin landed "no UPnP" the user got
silently dropped into Unbounded mode despite having explicitly set up
a port forward — defeating the whole point of the Advanced setting.
Resolution order on enable is now:
1. settings.PeerManualPortKey is set (via Advanced UI):
→ straight to SmC mode, no UPnP probe, no disclosure dialog.
Configuring a manual port forward is an explicit user-driven
SmC opt-in; they wouldn't set it up if they weren't sure they
wanted to share via the residential-IP path.
2. UPnP probe (mocked for now):
→ SmC if available + disclosure accepted, Unbounded if declined
or unavailable.
The radiance side already had the right precedence in
peer.Client.Start's NewForwarder factory (settings > env var > UPnP);
this just stops the Dart toggle from short-circuiting to Unbounded
before the radiance side ever gets called.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* mobile: sanitize errors before returning to gomobile bridge
RunOffCgoStack normalizes any non-nil error to a plain errorString with
a guaranteed non-empty, valid-UTF-8 Error() message before handing it
back to the gomobile-exported caller.
Without this, a SIGABRT crashes the Lantern process when any
mobile-exported function returns an error whose string contains
non-UTF-8 bytes. Reproduced when toggling Share My Connection on while
the prod /v1/peer/register endpoint returned 404 with a body whose
bytes weren't valid UTF-8 (likely a gzipped or otherwise binary error
page from the upstream LB). The chain that triggers the crash:
*Error{Message: <404 body bytes>}
→ Error.Error() = "ipc: status 500: ... body=<bytes>"
→ withCore returns this through gomobile
→ -[Universeerror initWithRef:] auto-generated wrapper:
self = [super initWithDomain:@"go" code:1
userInfo:@{NSLocalizedDescriptionKey:
[self error]}];
→ [self error] calls go_seq_to_objc_string(<bytes>)
→ [[NSString alloc] initWithBytesNoCopy:bytes length:N
encoding:NSUTF8StringEncoding
freeWhenDone:YES]
→ returns nil for non-UTF-8 input
→ @{...: nil} expands to
+[NSDictionary dictionaryWithObjects:forKeys:count:] with
objects[0] == nil → NSInvalidArgumentException → SIGABRT
Crash signature on macOS:
*** -[__NSPlaceholderDictionary initWithObjects:forKeys:count:]:
attempt to insert nil object from objects[0]
...
-[Universeerror initWithRef:] + 192
MobileSetPeerShareEnabled + 160
Centralizing the sanitization in RunOffCgoStack covers every Mobile*
function that funnels its body through withCore (essentially all of
mobile.go), so we don't have to thread fixes through individual
exports.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* share-my-connection: surface radiance peer phase events to the UI
The toggle today flips active/inactive with a multi-second gap between
"on" and "Active — sharing" while radiance walks the Start lifecycle
(port map → IP detect → register → libbox start → verify). To the user
this looks hung. Adds granular status text driven by the new peer
StatusEvent stream from radiance/peer (companion PR
github.com/getlantern/radiance/pull/<TBD>).
lantern-core/core.go:
+ EventTypePeerStatus = "peer-status"
+ listenPeerStatusEvents() forwards peer.StatusEvent (whose .Status
field already has JSON tags for phase, error, active, etc.) as a
FlutterEvent so the Dart side gets per-stage notifications.
share_my_connection.dart:
+ SharePhase enum mirrors radiance Phase strings; .fromWire() maps
backward-compatibly so unknown future phases default to idle.
+ ShareState carries phase + errorMessage; _handlePeerStatus folds
incoming events into state.
+ _StatusCard renders phase-specific labels (Opening port… →
Registering… → Verifying… → Sharing) and the error message on the
failure terminal state.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* core: instrument peer-connection subscriber to pair with radiance breadcrumb
If radiance's peer listener logs "forwarding" but this subscriber
doesn't log "forwarding to Flutter", events.Emit is reaching no
subscriber — the events bus is broken between Emit and Subscribe
(process boundary in gomobile builds, etc.). If both log but Flutter
sees nothing, the FlutterEvent bridge is the culprit. Spam-friendly:
~1 line per accept/close, bounded by peer inbound throughput.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* core: log listenPeerConnectionEvents goroutine entry
One-shot diagnostic: if we see radiance peer listener firing but never
this line, the goroutine that calls events.Subscribe was never started.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* core: consume peer events over IPC SSE instead of in-process events.Subscribe
The events.Subscribe path was broken — radiance/peer emits in the
lanternd process, but lantern-core's subscriber lives in Liblantern.
Process boundary means two separate events package instances; subscribers=0
at every emit.
Replace both listenPeerStatusEvents and listenPeerConnectionEvents
(peer half) with the IPC client's PeerStatusEvents / PeerConnectionEvents
SSE stream methods. The unbounded.ConnectionEvent half stays on
events.Subscribe — broflake-as-library runs in the consumer process today
and doesn't hit the cross-process gap.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* SmC: real per-peer geo, on-globe heart burst, arc reversal
- Geo: peerLookup switched from geo.getiantem.org/<ip> (returns 404 for
arbitrary IPs — every peer collapsed to the IR-fallback center) to
ipwho.is (HTTPS, no auth, city-level lat/lon + country name + flag
emoji). PeerLookup now returns PeerGeo with a real, unique location
per peer.
- Event model: UnboundedConnectionEvent carries country name, flag
emoji, coords, and an isReplay flag.
- Notifier: ref-counts streams per TCP peer so the arc persists until
the peer's last H2 stream closes (samizdat multiplexes many streams
over one conn); resolves geo async then emits enriched events;
replayCurrentPeers() seeds the globe with existing peers when the
user navigates to SmC mid-stream; emits synthetic -1's on toggle-off
so arcs don't orphan when peer.Client.Stop suppresses the box.Close
cascade.
- Globe: arcs linger 5s past last -1 so brief URL-test probes still
register; coords jittered ±2° per workerIdx hash so multiple peers
in the same city fan out instead of overlapping; arc direction
reversed (censored user → uncensored peer) so the dash animation
reads as traffic arriving at us.
- Heart burst: on-globe animation anchored at peer coords via
Point.labelBuilder (lib projects 3D→2D for us). Uses the actual
assets from getlantern/unbounded — explosion.json Lottie + the
inline FF5A79 heart SVG path via CustomPainter. 4.6s burst + 4.2s
fading country label below.
- StatusCard: small info_outline tooltip explaining that most events
are short URL-test liveness probes (601 of ~700 CONNECTs in a
measured session were to api.iantem.io — clients probing peer
reachability before sending real traffic).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* SmC: lift heart-burst off the globe into a floating toast
Anchoring the burst to projected globe coords (via Point.labelBuilder)
forced the widget to repaint every rotation frame, which made the
globe rotation jittery. The burst is now a separate floating pill
overlaid at the bottom of the globe area:
- _ArrivalToast subscribes to ShareNotifier.connectionEvents, ignores
replays, surfaces the current arrival in a slide-up + fade-in card.
ValueKey on workerIdx forces AnimatedSwitcher to swap the widget
when overlapping arrivals land so the Lottie restarts cleanly.
- _HeartBurst is now just heart + Lottie, no country label, no globe
anchor. The label moved into _ArrivalCard alongside the burst.
- Removed _announceArrival (Point/labelBuilder pattern) and the burst
anchor lifecycle.
Globe rotation is smooth again.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* deps: bump radiance to #501 tip + lantern-box to #255 tip; drop local replaces
After both stacks were rebased today, repin to fresh pseudo-versions:
- github.com/getlantern/radiance @ 3684cef (radiance #501 tip; has peer/,
settings.PeerShareEnabledKey, unbounded/)
- github.com/getlantern/lantern-box @ 0b63c0f (lantern-box #255 tip;
has tracker/peerconn + newer samizdat)
Removed the dev-only `replace ../radiance` and `replace ../lantern-box`
directives so this PR builds standalone for CI / reviewers. Once both
feature stacks land on their respective mains, this commit can be
amended away in favor of the released versions.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* smc: address Copilot review (5 of 7)
share_my_connection.dart:
- ShareState.copyWith now uses a sentinel default for errorMessage
(Object? = _unsetErrorMessage) so callers can distinguish 'leave
alone' from 'clear it'. The naive '?? this.errorMessage' pattern
conflated the two and left stale error text wedged in state — the
next phase transition into error would re-render the wrong message.
- _smcAck (the SmC disclosure ack) now persists via LocalStorageService
using a containsKey-based 'smc_disclosure_acked' key, so the
disclosure modal doesn't re-fire on every app restart.
- All new user-facing strings (~30) moved from hardcoded English
into assets/locales/en.po and consumed via .i18n / .fill. Covers
hero copy, status phase labels, status card stats, tooltip,
arrival toast, Advanced section, manual port forward field,
snackbar messages, and the disclosure dialog. Matches the
established convention in vpn_setting.dart.
lib/core/services/geo_lookup_service.dart:
- Added a privacy note on peerLookup documenting the ipwho.is data
flow: each call ships a peer's IP (typically a censored user's
address) to a third-party geo-IP service. Documents the current
rationale + the fix to do before any production-scale rollout
(Lantern-controlled endpoint or local DB). The lookup itself
stays; see PR reply for the design discussion.
lib/features/home/provider/radiance_settings_providers.dart:
- _refresh's fragile positional 'peerIdx' index into Future.wait
results replaced with named-future await-per-variable. Adding
another optional fetch later can't silently desync read indices.
Performance unchanged: the futures are still started before any
await, so they run concurrently; the awaits just collect them
in order.
lantern-core/core.go:
- listenPeerConnectionEvents: unbounded.ConnectionEvent
subscription was leaked (Subscribe but never Unsubscribe).
Now captures the Subscription handle and unsubscribes on
ctx.Done in a small companion goroutine.
- Dropped the redundant inner 'go func()' that wrapped the SSE
call. The caller already spawned the outer goroutine via
'go lc.listenPeerConnectionEvents()', so the inner go just
exited the outer immediately and lost structured cancellation.
The SSE call now blocks the outer goroutine directly.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* smc: wire real UPnP / IGD probe via FFI + MethodChannel
Replaces the Random().nextBool() mock that was gating Full SmC
vs Unbounded mode on toggle. Half of opted-in users were getting
routed to a mode that didn't match their network capabilities.
End-to-end:
- radiance/portforward.ProbeUPnP(ctx) (added in radiance commit
79400ef): wraps NewForwarder to do the M-SEARCH discovery,
returns bool, no port actually mapped.
- lantern-core/core.go: LanternCore.ProbeUPnP() bool wrapping
portforward.ProbeUPnP with a 6s internal timeout. Added to the
PeerShare interface so callers and stubs stay in sync.
- lantern-core/ffi/ffi.go: //export probeUPnP returning C.int
(0/1). Documents the 6s upper bound and the requirement that
Dart callers invoke from a background isolate.
- lantern-core/mobile/mobile.go: ProbeUPnP() bool for the iOS /
Android MethodChannel handler — platforms whose Flutter side
can't reach the FFI directly.
- lantern_generated_bindings.dart: regenerated via make ffigen.
All existing SmC stack exports (setPeerProxy / setPeerManualPort
/ setUnboundedEnabled / etc.) still present; probeUPnP added.
- Dart service layer (core_service / service / ffi_service /
platform_service): added probeUPnP() Future<Either<Failure,
bool>>. FFI implementation runs the synchronous C call inside
runInBackground so the 6s wait doesn't pin the UI isolate.
Platform implementation hops to the MethodChannel platform
thread.
- share_my_connection.dart: replaced the Random / 1.5s-sleep
mock with svc.probeUPnP(). Any probe error degrades to 'UPnP
unavailable' → Unbounded fallback, matching the user-visible
contract from the mock. Dropped the now-unused dart:math import
(showed up as max/min usage elsewhere, switched to a 'show'
filter). Updated the file-level docstring to reflect the wired
probe.
- go.mod: bumped radiance to the commit with ProbeUPnP.
- lantern-core/core.go: drive-by fix for ConnectionEvent shape
drift (the radiance unbounded.ConnectionEvent JSON contract
finalized to {state, source, timestamp}; the lantern side was
still writing the old {addr, workerIdx} field names).
dart analyze clean on touched files; CGO_ENABLED=1 go build
./lantern-core/... clean.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* smc: i18n the 'On — tap to view' subtitle in VPN settings
Added share_my_connection_on_tap_to_view key to assets/locales/en.po
and consumed it via .i18n. Matches the existing
share_my_connection_subtitle pattern in the same conditional.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* macos: wire probeUPnP MethodChannel handler
Round-2's UPnP probe FFI wiring added the Dart-side platform
service that calls _methodChannel.invokeMethod('probeUPnP'), but
forgot to add the corresponding case in MethodHandler.swift.
Without this, the MethodChannel call throws MissingPluginException
on macOS, the platform service returns Left(...), and
ShareNotifier.toggle interprets that as 'UPnP unavailable' →
falls straight through to Unbounded mode. macOS users would never
reach the Full SmC path unless they set a manual port — directly
contradicting the macOS test plan in the PR description.
Mirrors the existing pattern for the peer-share / unbounded
methods. Uses Task.detached so the up-to-6s M-SEARCH wait runs
off the MainActor; delivers the bool back via MainActor.run for
the Flutter result callback.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* mobile: wire SmC + Unbounded MethodChannel handlers for Android & iOS
macOS already had all seven SmC stack methods wired in its Runner's
MethodHandler.swift, but Android (handler/MethodHandler.kt) and iOS
(ios/Runner/Handlers/MethodHandler.swift) had zero coverage. With
the unbounded tab gated on FeatureFlag.unbounded (server-side
region check) rather than a platform check, mobile users in
unbounded-enabled regions would tap the toggle and hit
MissingPluginException for setUnboundedEnabled / probeUPnP /
setPeerShareEnabled / setPeerManualPort — silent failures, no
actual effect.
The whole stack is exposed on mobile rather than desktop-only
because manual port forwarding works on any home WiFi where the
user owns the router (UPnP discovery works there too). The probe
correctly returns false on cellular networks, falling back to
Unbounded.
Android handler/MethodHandler.kt:
- 7 new enum entries (setPeerProxyEnabled, isPeerProxyEnabled,
setPeerManualPort, getPeerManualPort, setUnboundedEnabled,
isUnboundedEnabled, probeUPnP).
- 7 new dispatch cases delegating to Mobile.setPeerShareEnabled,
Mobile.isPeerShareEnabled, Mobile.setPeerManualPort,
Mobile.getPeerManualPort, Mobile.setUnboundedEnabled,
Mobile.isUnboundedEnabled, Mobile.probeUPnP. Wraps go's int
return as toInt() (the gomobile binding maps Go int → Java
long), and the input port as toLong(). scope.handleValue runs
on Dispatchers.IO so probeUPnP's up-to-6s M-SEARCH wait
doesn't pin the main thread.
ios/Runner/Handlers/MethodHandler.swift:
- 7 new case branches mirroring the existing macOS handler:
- Direct one-liner cases for the simple bool getters
(isPeerProxyEnabled, isUnboundedEnabled).
- Helper-function dispatches for the setters (setPeerProxyEnabled,
setPeerManualPort, setUnboundedEnabled) so the gomobile
error-out path stays consistent with handleFlutterError.
- probeUPnP uses Task.detached so the multicast wait runs off
the main actor.
- 3 new helper functions (setPeerProxyEnabled, setPeerManualPort,
setUnboundedEnabled) following the existing setBlockAdsEnabled
pattern.
lib/features/home/provider/radiance_settings_providers.dart:
- Dropped the PlatformUtils.isDesktop gate on the isPeerProxyEnabled
fetch. The handler now exists on every platform, so the fetch
succeeds across the board. Updated the comment to reflect the
Windows+Linux+macOS+Android+iOS coverage and the rationale
(manual port forwarding works on home WiFi).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* smc: address Copilot review on #8819 round-N
Six findings:
1-3. lantern_ffi_service.dart: the SmC stack's three setter wrappers
(setPeerProxyEnabled, setPeerManualPort, setUnboundedEnabled)
each invoked the FFI `*C.char`-returning function, converted
the pointer to a Dart string, and dropped the pointer. The Go
side allocates each return via C.CString — every call leaked
a small heap allocation. Wrapped each in the
resultPtr-finally-freeCString pattern matching the existing
startVPN / stripeBillingPortalUrl call sites.
4. share_my_connection.dart: the peer-event handler used
`source.split(':').first` to strip the port off the source
address. Mis-parses IPv6:
'[2001:db8::1]:443'.split(':').first → '[2001'
'2001:db8::1'.split(':').first → '2001'
Extracted a small _extractIP helper handling the three emit
forms — bracketed-IPv6 host:port, bare-IPv6, IPv4 host:port —
via Uri.tryParse with a synthesized scheme (which gets the
bracket-stripping right) and a bare-IP fallback for cases with
no port.
5-6. lantern_platform_service.dart: the docstrings on the
setPeerManualPort and setUnboundedEnabled MethodChannel
wrappers still said the handlers exist only on macOS and
'iOS / Android don't implement these handlers yet' — that's
stale after the previous commit added them. Rewrote both to
describe the current state (macOS / iOS Swift + Android
Kotlin all delegate to Mobile.* via the gomobile binding).
dart analyze clean on the touched files.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* smc: tighten _extractIP for bare IPv6 + check Unbounded enable Either
Two Copilot findings:
1. _extractIP mis-parsed bare IPv6 (e.g. `2001:db8::1`). The
previous condition routed multi-colon strings into Uri.tryParse,
which can't parse an un-bracketed IPv6 host and returned empty,
then fell through to substring(0, lastColon) which truncated
the address to '2001:db8:'. Reworked the parser around the four
shapes the Go side emits:
- bracketed IPv6 host:port → Uri parse (strips brackets)
- bare IPv6 (multi-colon, no brackets) → return as-is
- IPv4 host:port (single colon) → substring up to colon
- bare IPv4 (no colon) → return as-is
2. _start's ShareMode.unbounded branch threw away the Either
returned by setUnboundedEnabled — a failure (core not
initialized, MethodChannel failure, etc.) left the UI stuck at
'Active' while nothing actually started. Now folds the result,
logs on Left, and reverts state to SharePhase.error with the
error message so the user sees an actionable failure.
The ShareMode.smc branch doesn't need an equivalent check
because peer.Client emits phase=error StatusEvent on real
failures, which _handlePeerStatus already routes through
_fallbackToUnbounded.
dart analyze clean on the touched file.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* smc: render off-with-error + check SmC enable Either + cache peer geo
Four Copilot findings:
1. _StatusCard's switch matched (ShareMode.off, _) before
(ShareMode.off, SharePhase.error), so the round-N
'revert to off+error on Unbounded failure' state rendered
as plain 'Off' instead of an actionable error. Added a
specific (off, error) arm before the catch-all that renders
the same smc_status_error_with_message / generic strings the
SmC error path uses.
2. The ShareMode.smc enable path threw away setPeerProxy's
result, so failures BEFORE peer.Client.Start (IPC error,
MissingPluginException, core not initialized) didn't surface
anywhere — the screen stuck at 'active: true' with an event
subscription running while sharing never started.
Changed radianceSettingsProvider.notifier.setPeerProxy to
return Future<Either<Failure, Unit>> instead of Future<void>.
The SmC branch in _start now folds the result and, on Left,
tears down the event subscription and reverts to mode=off /
phase=error (matching the Unbounded branch's pattern). The
stop-path caller doesn't read the return value, which is
fine — Dart allows the discard, and toggle-off is fire-and-
forget anyway.
3-4. GeoLookupService.peerLookup ran a fresh HTTP request to
ipwho.is for every probe connection. The tooltip explicitly
notes most connections are short liveness probes from the
same handful of client IPs — without caching this would
chew through the 10k/month free quota in minutes and leak
more data to the third party than necessary.
Added a process-lifetime per-IP cache (Map<String, PeerGeo>)
that also caches the PeerGeo.unknown sentinel from failed
lookups (so a previously-failed lookup doesn't retry on
every subsequent probe). No TTL — IP→country bindings
don't change on human timescales, and the TTL bookkeeping
adds complexity without changing the privacy or quota math.
Added a resetCacheForTest() helper for unit tests.
dart analyze clean on the touched files.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* smc: list-spread + dispose guards + terminal-phase reset + docs
Seven Copilot findings (with 5 duplicate threads, so 7 unique):
1. vpn_setting.dart used '...{' (Set literal spread) inside a List
children: literal in three spots. Switched all three to '...[',
and closed with '],' to match. Set literals would dedup widgets
silently and the type mismatch is easy to miss; list spread is
the conventional shape.
2. _ManualPortField's useEffect did a fire-and-forget
Future.microtask that wrote to the TextEditingController and
ValueNotifiers after disposal if the user navigated away
quickly. Added a 'disposed' flag flipped from the useEffect
cleanup, checked after the await.
3. _HeartBurst's Lottie.asset onLoaded callback could fire after
the State was disposed (rapid arrival burst replaces the
ArrivalCard before composition load). The setState +
AnimationController(vsync: this) inside the callback would
then throw. Added 'if (!mounted) return;' guard and disposed
any prior controller so a stale ticker subscription from an
earlier onLoaded doesn't leak.
4. _handlePeerStatus only updated phase/errorMessage, leaving
state.active/mode stuck on SmC when the backend reported a
terminal phase. The toggle could show ON while radiance was
idle (clean stop) or error (start failed). Added a terminal-
phase branch: both idle and error tear down the event
subscription; error preserves the message so the new
(off, error) StatusCard arm renders it.
5. lantern-core/core.go's listenPeerConnectionEvents doc comment
said the Unbounded payload included workerIdx, but the actual
marshal block emits {state, source, timestamp}. Updated the
comment to match the actual payload + describe the source
format on both protocols.
dart analyze clean on the touched files; Go build clean on
lantern-core/...
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* smc: MediaQuery copyWith + symmetric wire format + drop stale fromJson
Three Copilot findings:
1. _GlobeView wrapped its body in MediaQuery(data: MediaQueryData(size:
widgetSize), ...) — constructing MediaQueryData from scratch drops
inherited fields (devicePixelRatio, textScaleFactor, padding,
viewInsets, etc.). On high-DPI displays the pixel ratio fell to
1.0, breaking globe rendering crispness; accessibility scaling
for any descendants would also break. Switched to
MediaQuery.of(context).copyWith(size: widgetSize) which keeps
the inherited fields and only overrides what we need.
2. lantern-core/core.go's doc comment said the peer-connection wire
payload was always {state, source, timestamp}, but the
peer.ConnectionEvent marshal block emitted only {state, source}.
Added timestamp to the peer marshal (both peer.ConnectionEvent
and unbounded.ConnectionEvent carry Timestamp on the radiance
side, so the consumer-facing shape is now symmetric) and
updated the comment to spell out the source format difference
between protocols and call out Unix-millis for timestamp.
3. UnboundedConnectionEvent.fromJson was stale: it expected
{workerIdx, addr} keys, but the actual wire format is
{state, source, timestamp}. The factory is never called from
anywhere in lib/ — wire-format parsing happens inline in
share_my_connection.dart, and the class is only constructed
directly by the notifier as an internal Dart-side event model.
Dropped the dead factory and clarified the class docstring to
distinguish 'internal Dart-side model' from 'wire format',
including a note that workerIdx is a Dart-side identity
counter (_workerSeq), not the broflake worker index.
dart analyze clean; Go build clean.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* smc: gate arc draws on origin coords + accurate event type doc
Four Copilot findings:
1. replayCurrentPeers fired before _initOrigin completed — replayed
arcs drew to GlobeCoordinates(0,0) and never got corrected.
Moved the replay call into _initOrigin's continuation so it
runs only AFTER origin coords are known.
2. _addPeer fell back to GlobeCoordinates(0,0) when _originCoords
hadn't loaded yet, so real-time +1 events arriving during the
origin-lookup window could still draw to (0,0). Added a null
guard: if origin isn't resolved, skip the draw — the peer is
still tracked in the notifier's _peerArcs map (source of
truth), and replayCurrentPeers in _initOrigin's continuation
picks it up.
Reordered initState: subscribe FIRST so real-time events
accumulate in _peerArcs while origin is loading, then call
_initOrigin which finishes by calling replayCurrentPeers.
With both changes there's no window where a peer is drawn
without correct origin coords.
3. debugPrint comment claimed it 'avoids bringing in the
appLogger' and that 'real impl can switch to slog' — but the
file already uses appLogger, and slog isn't a Dart logger.
Rewrote to describe the actual rationale: avoid escalating a
single malformed wire event to a user-visible error toast in
debug builds; keep the listener subscribed so subsequent
well-formed events still arrive.
4. lantern-core's EventTypePeerConnection doc described it as
samizdat-only with a {state, source} payload. Both donor
protocols now emit on this event type with the unified
{state, source, timestamp} payload (peer-share's marshal was
bumped in the previous round to include timestamp). Updated
the doc accordingly with source-format details for both
protocols.
dart analyze clean; Go build clean.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* smc: defer subscription cleanup + strict arg validation across handlers
Six Copilot findings:
1. lantern-core/core.go's listenPeerConnectionEvents subscribed
to unbounded.ConnectionEvent and started a goroutine waiting
on ctx.Done to unsubscribe. If client.PeerConnectionEvents
returned an error while ctx was still live, the function
returned but the ctx-watcher goroutine + subscription leaked
for the rest of the process. Replaced with 'defer
unbSub.Unsubscribe()' so cleanup runs on both exit paths
(normal ctx cancel + unexpected stream exit).
2. iOS handler comment said the SmC setter helpers use a
'detached Task' but the implementation uses 'Task {}'. Updated
the comment to describe the actual choice — plain Task is
fine for the millisecond-range PatchSettings calls because
inheriting the current actor's executor is cheap; the
probeUPnP case is the one exception that uses Task.detached
because its M-SEARCH wait is multi-second.
3-5. macOS / iOS handlers had unsafe defaults on the SmC setters:
- setPeerProxyEnabled: defaulted enabled=false on missing arg
(would silently disable sharing on caller bugs)
- setPeerManualPort: defaulted port=0 on missing arg (would
silently clear the user's manual port override, since 0
has the real semantic of 'no manual port')
- setUnboundedEnabled: same Bool-defaults-to-false issue
(Copilot didn't flag this one but it has the identical
problem)
All four now route through requireArg, which surfaces a
FlutterError on missing/invalid argument shape instead of
defaulting silently.
6. Android setPeerManualPort defaulted port=0 the same way.
Switched the elvis '?: 0' to '?: error("Missing port")' to
match the SetPeerProxyEnabled pattern on Android.
Go build clean. The Swift / Kotlin changes are mechanical and
follow patterns already established in the same files
(requireArg / error()-on-missing).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* smc: _resolveAndEmit isClosed guard + honest 'session' stat label
Two Copilot fixes (+ a third pushback in the reply):
1. _resolveAndEmit awaits peerLookup. If the notifier is disposed
during the await, _eventController.close() has already run; the
subsequent _eventController.add would throw 'Bad state: Cannot
add event after closing'. Added an isClosed check after the
await before the identity check.
2. smc_stat_total_today msgstr read 'Total today' but the
ShareState.totalCount is session-scoped (reset on every
toggle-on, no day bucket, no persistence). Renamed to 'Total
this session' so the label matches the implemented semantics.
#8820's rebase later replaces this key with smc_stat_total_helped
+ 'Total people helped to date' alongside persistence via
unboundedTotalHelped — until then the more honest 'session'
wording matches reality.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* smc: keep-alive ShareNotifier + reflect Unbounded in VPN tile
Two new Copilot fixes (+ a third escalation acknowledged in reply):
1. shareProvider was the default (non-annotated) NotifierProvider,
which is autoDispose in Riverpod 3.x — so navigating away from
the screen disposed the notifier, re-entry reset state to
mode=off / active=false even when SmC or Unbounded was still
running, and the next toggle tried to re-enable an already-
enabled setting. Added `ref.keepAlive()` at the top of
build() so the notifier sticks for the process lifetime.
onDispose stays registered for the explicit teardown paths
(provider container reset, hot reload) so the event
subscription + stream controller still get cleaned up.
2. VPN settings tile rendered "Off" when the user had picked
"Basic mode (Unbounded)" in the disclosure dialog because
the subtitle was driven solely by peerProxy. Added
unboundedEnabled to RadianceSettingsState (with a copyWith
field + equality / hashCode update), wired the fetch +
setter through radianceSettingsProvider, and the tile now
reads OR of both to decide whether to show
share_my_connection_on_tap_to_view.
dart analyze clean on the touched files.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* smc: revise defer Unsubscribe comment to match actual lifecycle
Per Copilot — the previous comment claimed parity with the
SSE-stream-failure path, but in practice PeerConnectionEvents
blocks until ctx cancellation and there's no retry loop wrapping
listenPeerConnectionEvents, so the defer effectively runs at
process shutdown. Rewrote to spell that out while still calling
out why defer is the right shape (future-proofing against early
returns / retry wrappers).
No code change.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* unbounded: phase 1 — tab shell + Unbounded as a top-level tab
Restructures Home into a two-tab shell (VPN + Unbounded) per the
Figma spec at figma.com/design/hNlyYToB5TnX9SDBFDYJTq?node-id=2403-19287
and tracking ticket getlantern/engineering#3455. Previously the
peer-share UI sat behind a "Share My Connection" entry on the VPN
settings screen that opened it as a modal; the spec elevates it to a
peer of the VPN view.
- New lib/features/home/vpn_tab.dart: VpnTab body lifted from the
old Home (toggle, data usage, location, routing, split tunneling).
Scaffold/AppBar moved up to the shell.
- home.dart: Home becomes the tab shell. AppBar hosts the Lantern
logo, settings menu, account/sign-in actions, plus a TabBar with
green/grey-dot tab labels (green when feature enabled per spec).
Onboarding, macOS sysext, and telemetry-consent init preserved
inside the shell so launch behaviour is unchanged.
- share_my_connection.dart: ShareMyConnectionScreen renamed to
UnboundedTab, BaseScreen wrapper dropped (shell provides chrome).
Description text updated to the spec's
"Help others bypass censorship by securely sharing your
connection."
- Arrival toast copy updated to match the spec:
"Helping a new person in <country>" while a peer is arriving,
"Waiting for connections..." in the idle state (new _WaitingCard).
- vpn_setting.dart: SmC modal entry removed — there is no longer a
Share-My-Connection tile here. Unused peerProxy watch dropped.
Followups (separate phases): Unbounded Settings sheet (Auto-enable
+ Hide Unbounded toggles), auto-enable on VPN connect, first-visit
Welcome popup. Files/class names still say "share_my_connection"
and "ShareNotifier" to keep this diff focused; rename to
"unbounded" is a polish step at the end.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* unbounded: phase 2 — Unbounded Settings sheet + hide-tab toggle
Adds the Unbounded Settings sheet from the Figma spec
(figma.com/design/hNlyYToB5TnX9SDBFDYJTq?node-id=2403-19287),
reached from the main Settings menu (between VPN Settings and
Language). Two toggles:
- Auto-enable Unbounded — defaults on, subtitle "Turn on
automatically when Lantern is open". The actual auto-enable
wiring (listening to vpnProvider and toggling peer-proxy) lands
in phase 3.
- Hide Unbounded — defaults off, subtitle "Removes Unbounded from
the top of this screen". When on, the Home shell hides the
Unbounded tab AND collapses the tab strip entirely (single-tab
case), falling back to rendering VpnTab directly.
State persistence via AppSetting:
- unboundedAutoEnable (default true)
- unboundedHidden (default false)
- unboundedWelcomeSeen (default false) — added now, used in phase 4
All three round-trip via toJson/fromJson and the new
setUnboundedAutoEnable / setUnboundedHidden / setUnboundedWelcomeSeen
notifier methods.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* unbounded: phase 3 — auto-enable on VPN connect
When the "Auto-enable Unbounded" toggle in Unbounded Settings is on
(default per phase 2), Unbounded turns on automatically the moment
the VPN reaches the connected state — per the Figma spec and ticket
getlantern/engineering#3455 ("turns on automatically when Lantern
connects").
- New ShareNotifier.autoStart(): public, programmatic entry point
that mirrors the toggle() probe-then-start path but skips the
disclosure dialog because the user has already opted in via
settings. No-ops if already active or probing.
- Home shell uses ref.listen<VPNStatus>(vpnProvider, ...) to detect
the disconnected → connected transition. On match, reads the
auto-enable flag and current share state, then calls autoStart in
a microtask so we don't mutate provider state from inside the
listen callback.
Disconnect path is left alone — turning Unbounded off when the VPN
drops would be surprising; the user can toggle it off manually.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* unbounded: phase 4 — first-visit welcome popup + info bubble
Adds the "Welcome to Unbounded" first-visit explainer dialog per
Figma (figma.com/design/hNlyYToB5TnX9SDBFDYJTq?node-id=2403-19287).
Fires automatically the first time the user opens the Unbounded
tab, then never again — gated on unboundedWelcomeSeen (added to
AppSetting in phase 2). The info-bubble icon in the tab header
re-opens the same dialog so users can revisit the explanation.
- New showUnboundedWelcomeDialog(context, ref): wraps a Dialog with
the spec's heart-Lantern logo (re-using _HeartPainter), title,
three-paragraph explainer body, and Learn more + Got it buttons.
Dismissal (either button or scrim tap) flips welcomeSeen true via
whenComplete so a single completion path handles both.
- UnboundedTab.useEffect runs once on mount, schedules the dialog
in a post-frame callback when welcomeSeen is false.
- Description text row now also hosts an Icons.info_outline button
to the right that calls showUnboundedWelcomeDialog directly.
"Learn more" link is a no-op stub for now — wiring it to the public
Unbounded explainer URL is a tiny followup once the URL is decided.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* unbounded: also auto-enable on app launch (not just VPN connect)
The Unbounded Settings subtitle reads "Turn on automatically when
Lantern is open" — which is app-launch, not VPN-connect. Phase 3
only handled the VPN-connect transition, so a user who launches the
app and never connects the VPN would never see Unbounded auto-start
despite the toggle being on.
Adds a second entry point: a post-frame useEffect on Home mount
that reads autoEnable + onboardingCompleted, and calls
ShareNotifier.autoStart if conditions hold. The existing
ref.listen<VPNStatus> path stays in place for the case where the
toggle flipped on after launch or the user connects the VPN later.
Both paths gate on (active || probing) to avoid re-triggering
mid-flight and skip the disclosure dialog since settings opt-in is
the consent gate.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* unbounded: lift the heart spray out of the pill, onto the globe
unbounded.lantern.io shows dozens of pink hearts spraying outward
across the whole globe area on each arrival — not a single burst
cramped inside the toast pill. Watching unbounded-russia.mp4 made
it clear my previous implementation had the wrong scale: the Lottie
was confined to a 40×40 slot inside the pill, so all the particle
spray got clipped.
Restructure:
- New _LottieBurstLayer: a Positioned.fill overlay on top of the
globe (sibling to _GlobeView, parent Stack now clipBehavior:
Clip.none). Subscribes to ShareNotifier.connectionEvents and
bumps a burstId counter on each non-replay state=1. The inner
_BurstAnimation widget gets a fresh ValueKey per burst so the
Lottie restarts from frame 0; the previous Lottie's
AnimationController is disposed when the State unmounts.
- _ArrivalCard simplified: replaces the embedded _HeartBurst with
a static _HeartPainter heart, matching unbounded's pill chrome
(small heart icon + text, no animation inside the pill).
- _HeartBurst class removed.
Result: the hearts now spread across the entire globe Stack area
instead of being trapped inside a 40×40 box.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* unbounded: put the Lottie inside the pill, overflowing — matches CSS
Previous approach made the Lottie a globe-wide Positioned.fill layer.
unbounded.lantern.io actually anchors the Lottie INSIDE the toast
pill's heart slot, with absolute-positioned negative offsets so it
overflows up and to the right into the globe area:
LottieContainer { position: relative; width: 32px; height: 27px; }
LottieWrapper { position: absolute; bottom: -55px; left: -105px;
width: 420px; }
Translating one-to-one in Flutter: the pill's heart slot is a Stack
with clipBehavior: Clip.none, containing the static _HeartPainter
centered + a Positioned _ArrivalLottie at bottom: -55, left: -105,
width: 420, height: 420. The pill Container itself also uses
clipBehavior: Clip.none so the Lottie can spill past the rounded
borders.
Side benefits:
- The burst now follows the pill — when AnimatedSwitcher swaps to a
new arrival card, the Lottie restarts naturally because each card
has its own _ArrivalLottie state (no need for the burstId counter
+ the standalone _LottieBurstLayer, both deleted).
- The burst origin is anchored at the pill's heart, so hearts spray
from a single, semantically-meaningful point instead of
centre-of-globe.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* unbounded: match the pill exactly — heart+text, bottom-left anchor
Compared the current implementation against frame-020.png of
unbounded-russia.mp4:
- The pill in unbounded is just [heart icon] + text, no flag emoji.
Removed the flag prefix so the pill width stays manageable and
the layout reads identically. flagEmoji is still on the event for
future use (label above the arc, etc).
- Anchor the pill at the bottom-LEFT of the globe area, not
centered. Position changes from (left: 0, right: 0, child:
Center(...)) to (left: 12, bottom: 8, child: ...).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* unbounded: revert the pill back to centered
Previous commit moved the pill to bottom-left, overshooting the fix
for the cut-off text — the actual cause was the extra flag-emoji
width, which is already removed. Restoring (left: 0, right: 0,
child: Center(...)) so the pill sits under the globe's centre per
frame-020 of unbounded-russia.mp4. Static heart in the pill stays
visible (also matches unbounded) and continues to anchor the Lottie
burst origin.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* unbounded: persist "Total people helped to date" across restarts
The stat was an in-memory counter that reset on every app launch and
on every off→on toggle. Spec wording ("Total people helped to date")
implies lifetime — survives both.
- AppSetting gains unboundedTotalHelped (int, default 0) + the
matching setUnboundedTotalHelped notifier method. Round-trips via
toJson/fromJson.
- ShareNotifier.build() seeds totalCount from the persisted value
instead of starting at 0.
- _start and _stop now preserve state.totalCount across toggle
cycles (were overwriting with ShareState() defaults).
- On each new-peer arrival, after incrementing totalCount, write the
new value via setUnboundedTotalHelped so the persisted value stays
in sync. SharedPreferences I/O is fine — peer arrivals are bursty,
not continuous.
- Stat labels updated to the Figma copy: "People helping right now"
(was "Active now") and "Total people helped to date" (was "Total
today" — which was inaccurate even before persistence).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* share: auto-fall-back from SmC to Unbounded on any Start failure
peer.Client.Start failures (UPnP miss, /v1/peer/register 404/4xx/5xx,
samizdat verify timeout) arrive in Dart as a peer-status FlutterEvent
with phase=error. Until now those rendered raw inside the SmC status
card ("Couldn't share: register with lantern-cloud: register: peer api:
status=404 body=404 page not found"), which is both ugly and inactionable.
Now `_handlePeerStatus` detects phase==error with mode==SmC and
transparently switches to Unbounded via setUnboundedEnabled(true).
The user's intent — "I want to share" — is honoured via broflake
regardless of SmC's outcome. UPnP failure is the common case; treating
it as a routine fallback rather than an error matches the design
expectation that UPnP works only some of the time.
State is rebuilt with ShareState() directly (rather than copyWith) so
errorMessage clears — copyWith's `?? this.errorMessage` would otherwise
keep the stale SmC failure string visible after the fallback.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* unbounded: gate entire UI surface on server Features[unbounded] flag
Censored users should not see a "share your connection" UI on their
device — it can be a red flag on-device evidence even when broflake
itself is server-gated off. Mirror the radiance shouldRunUnbounded
gate up into Flutter so the Unbounded tab, settings sub-page, project
promo tile, first-visit welcome dialog, and auto-enable hooks all
disappear when Features[unbounded] is false.
Adds FeatureFlag.unbounded backed by the same "unbounded" key the
server already emits (common/types.go UNBOUNDED). Default getBool(...)
is false, so any user whose /v1/config-new response omits the flag
(no connectivity, parse failure, censored region) sees the safe state:
no Unbounded UI at all.
The user's "Hide Unbounded tab" toggle (appSettingProvider
unboundedHidden) still wins on top of this for non-censored users who
want it hidden. The new effective predicate is
unboundedAvailable && !unboundedHidden.
The welcome dialog at share_my_connection.dart:572 and the info-bubble
re-opener at :607 are both inside UnboundedTab.build, which never
mounts when the tab is hidden, so no defensive code is needed there.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* share: render Lottie arrival heart-burst at native canvas size
Lottie's explosion.json is 420×502; we were forcing it into a 420×420
Positioned with BoxFit.contain, which uniform-scaled the animation
down by ~83% and lopped 82 px off the upward spread. End result: the
hearts clustered tightly just above the pill instead of fanning out
across the globe the way unbounded.lantern.io's CSS renders them
(width:420 with height:auto preserves the native aspect ratio).
Set height to 502 to match the native canvas exactly. Width and the
bottom/left negative offsets stay the same — the bottom of the
Lottie still anchors 55 px below the pill heart's bottom and 105 px
left of its left edge.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* share: nudge heart-to-text gap from 10 → 14 px
The pill's static heart was sitting a touch close to the "H" in
"Helping a new person in <country>". 4 px is the smallest visibly
noticeable nudge — large enough to ease the crowding without making
the pill feel padded.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* smc: address Copilot review on #8820
share_my_connection.dart:
- autoStart unconditionally starts ShareMode.unbounded, dropping
the manual-port and UPnP-probe branches. The auto path is the
low-friction Unbounded-only surface; SmC requires the explicit
disclosure dialog enforced by toggle(). Without this fix, a
user who'd configured a manual port (or got lucky on the mock
probe) would silently land in SmC mode without ever seeing the
disclosure, turning their device into a residential exit they
never agreed to.
- _fallbackToUnbounded: added a comment documenting the
intentional reuse of the prior _start's event subscription. The
invariant works because the error path stays inside the same
subscription; flipping state.mode keeps the same forwarder
pushing events for Unbounded. _stop is the only teardown path.
- Welcome dialog 'restrictors' → 'restrictions' typo; normalized
the surrounding paragraph to single-quote-delimited strings
(the inner curly quote in 'digital bridges' is retained — it's
intentional inside the prose).
- About-bubble tooltip moved through .i18n.
unbounded_setting.dart + setting.dart:
- New i18n keys added to en.po:
unbounded_settings_title
auto_enable_unbounded / auto_enable_unbounded_subtitle
hide_unbounded / hide_unbounded_subtitle
about_unbounded
All hardcoded English strings in the new screens replaced with
.i18n lookups, matching the existing pattern in vpn_setting.dart.
dart analyze clean on the touched files.…
PR 3 of 4 implementing the lantern-side wiring for Share My Connection (peer-proxy). Builds on radiance #458 (peer module) and #460 (LocalBackend wiring).
Lifecycle
```
Dart toggle → setPeerProxyEnabled(C.int)
→ LanternCore.SetPeerShareEnabled(bool)
→ ipc.Client.PatchSettings({PeerShareEnabledKey: ...})
→ radiance LocalBackend.PatchSettings dispatch
→ peer.Client.Start / Stop
```
What's in here
lantern-core/core.go— newPeerShareinterface (mirrorsAds/SmartRouting), embedded inCore.SetPeerShareEnabledpatches via the radiance IPC client;IsPeerShareEnabledreads the snapshot.lantern-core/ffi/ffi.go—//export setPeerProxyEnabledand//export isPeerProxyEnabled, mirroringsetBlockAdsEnabled/isBlockAdsEnabledexactly.lantern-core/mobile/mobile.go— gomobile-bind surface (SetPeerShareEnabled/IsPeerShareEnabled) so Android can toggle once Dart wires it up.go.mod/go.sum— provisional radiance bump to6a238ce(fisk/peer-localbackend tip).Naming
C export name uses
PeerProxyto match the existing user-facing terminology in this repo (the toggle stub inlib/features/setting/vpn_setting.dartwas drafted as "Peer Proxy"). Internal Go names usePeerShareto match the radiance setting key (PeerShareEnabledKey). The Dart side will routesetPeerProxyEnabledin PR 4.What's NOT in here (PR 4)
ffigenregen for the auto-generated Dart bindings.LanternFFIService.setPeerProxyEnabledDart method.app_setting_notifier.setPeerProxy()body rewrite (mirrorsetBlockAdsrollback pattern)./peer/status/eventsfrom radiance Write a complete spec of Lantern in preparation for a security audit #460).Test plan
go build ./lantern-core/...go test ./lantern-core/...golangci-lint run --new-from-rev=origin/main ./lantern-core/...🤖 Generated with Claude Code
Summary by CodeRabbit
New Features
Chores