Unified Share My Connection screen with globe, Advanced section, and Unbounded as Basic mode (Part 1/2)#8819
Unified Share My Connection screen with globe, Advanced section, and Unbounded as Basic mode (Part 1/2)#8819myleshorton wants to merge 37 commits into
Conversation
There was a problem hiding this comment.
Pull request overview
Part 1/2 of the unified Share My Connection UX. Adds a dedicated screen with a rotating globe, a one-time disclosure dialog gating Full mode (samizdat-over-UPnP / SmC) vs Basic mode (Unbounded / broflake), and an Advanced section for a manual port-forward override. Wires four new FFI/MethodChannel surfaces (setPeerProxyEnabled, setPeerManualPort, setUnboundedEnabled, plus is* getters) through the Go FFI/mobile layers, the Dart service layers, macOS Swift handler, and a new radianceSettingsProvider.peerProxy field. Introduces a unified EventTypePeerConnection/EventTypePeerStatus FlutterEvent stream so the globe is protocol-agnostic, and sanitizes Go errors crossing gomobile to prevent invalid-UTF-8 crashes.
Changes:
- New
ShareMyConnectionScreenwith globe, status card, Advanced manual-port field, SmC disclosure dialog, and Lottie heart-burst arrival toast. - New Go
Core.{SetPeerShareEnabled, SetPeerManualPort, SetUnboundedEnabled, …}+ FFI exports + mobile bindings + macOS MethodChannel handlers;listenPeerConnectionEvents/listenPeerStatusEventsbridge radiance events to Flutter. RadianceSettingsState.peerProxy,RadianceSettings.setPeerProxy, gomobilesanitizeForGomobilefor UTF-8/empty error normalization, newGeoLookupService,UnboundedConnectionEventmodel,flutter_earth_globe/lottie/httpdeps, Lottie explosion + locale strings.
Reviewed changes
Copilot reviewed 20 out of 24 changed files in this pull request and generated 7 comments.
Show a summary per file
| File | Description |
|---|---|
| lib/features/share_my_connection/share_my_connection.dart | New unified SmC screen with globe, disclosure, Advanced port field, arrival toast. |
| lib/features/setting/vpn_setting.dart | Replaces inline SmC toggle with navigation tile to new screen (desktop only). |
| lib/features/home/provider/radiance_settings_providers.dart | Adds peerProxy field/setter to settings provider; conditional load by platform. |
| lib/core/models/radiance_settings_state.dart | Adds peerProxy to state, copyWith, equality, hashCode. |
| lib/core/models/unbounded_connection_event.dart | New model representing peer connection events with geo fields. |
| lib/core/services/geo_lookup_service.dart | New geo lookup service (self via geo.getiantem.org, peers via ipwho.is). |
| lib/lantern/lantern_core_service.dart | Adds peer-proxy / manual-port / unbounded methods to interface. |
| lib/lantern/lantern_ffi_service.dart | FFI implementations for the six new methods. |
| lib/lantern/lantern_platform_service.dart | MethodChannel implementations for the six new methods. |
| lib/lantern/lantern_service.dart | Routes new methods to FFI or platform service per platform. |
| lib/lantern/lantern_generated_bindings.dart | Hand-added native bindings for four new C exports. |
| macos/Runner/Handlers/MethodHandler.swift | macOS routing of new MethodChannel calls into Mobile* Go binds. |
| lantern-core/core.go | New PeerShare interface, settings get/set, event bridges for peer/unbounded. |
| lantern-core/ffi/ffi.go | New //export C functions for peer / unbounded / manual-port settings. |
| lantern-core/mobile/mobile.go | gomobile-bound wrappers around the new Core methods. |
| lantern-core/utils/gostack.go | Sanitizes errors for gomobile to ensure non-empty valid UTF-8 messages. |
| pubspec.yaml / pubspec.lock | Adds flutter_earth_globe, lottie, http deps and assets/unbounded/. |
| go.mod / go.sum | Bumps radiance and related deps; drops local replace directives. |
| assets/locales/en.po | Adds share_my_connection / share_my_connection_subtitle strings. |
| assets/unbounded/explosion.json | Lottie animation asset for arrival toast burst. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
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>
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>
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>
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>
…sure 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>
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>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…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>
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>
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>
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>
…nnel
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>
…robe
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>
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>
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>
…adcrumb 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>
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>
…ubscribe 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>
- 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>
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>
f7e65ef to
a190859
Compare
… 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>
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>
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>
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>
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>
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>
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>
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>
| Future<void> toggle(BuildContext context, WidgetRef widgetRef) async { | ||
| if (state.active || state.probing) { | ||
| await _stop(widgetRef); | ||
| return; | ||
| } | ||
|
|
||
| state = state.copyWith(probing: true); | ||
|
|
||
| // Manual port forward bypasses both the UPnP probe and the SmC | ||
| // disclosure dialog. Configuring a port in Advanced is an explicit | ||
| // user-driven SmC opt-in — they wouldn't have set it up if they | ||
| // weren't sure they wanted to share via the residential-IP path. | ||
| final manualPortRes = | ||
| await widgetRef.read(lanternServiceProvider).getPeerManualPort(); | ||
| final manualPort = manualPortRes.fold((_) => 0, (p) => p); | ||
| if (manualPort > 0) { | ||
| await _start(widgetRef, ShareMode.smc); | ||
| return; | ||
| } | ||
|
|
||
| // Real UPnP probe via FFI / MethodChannel. probeUPnP runs IGD | ||
| // discovery on the local network and returns true when a usable | ||
| // gateway is reachable. Blocks up to ~6 seconds on the M-SEARCH | ||
| // multicast wait — long enough that the "Probing your network…" | ||
| // status from copyWith(probing: true) above is visible to the | ||
| // user. Any failure (no IGD, timeout, FFI / channel error) is | ||
| // treated as "UPnP unavailable" → fall back to Unbounded. | ||
| final probeRes = | ||
| await widgetRef.read(lanternServiceProvider).probeUPnP(); | ||
| final upnpAvailable = probeRes.fold((_) => false, (v) => v); |
There was a problem hiding this comment.
Pushing back: tests would be valuable here, but I'd rather add them after #8820 (Part 2/2) lands than now. #8820 substantially rewrites ShareNotifier.toggle's mode-selection (auto-enable path becomes Unbounded-only, the screen becomes a tab embed rather than a navigated screen, the welcome dialog and Unbounded settings get introduced, _fallbackToUnbounded lands). Tests written against the #8819-only surface would need to be largely rewritten 24 hours later.
Tracking this as a follow-up task to land alongside #8820 — _extractIP is the cleanest immediate test target since its surface is fixed; toggle and _handlePeerStatus should wait until the Part 2 lifecycle is in place.
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>
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>
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>
Summary
End-to-end UX for Share My Connection — one unified screen, one toggle, one globe — with both donor modes wired through:
The user picks between modes via a one-time disclosure dialog. Both protocols emit identically-shaped
EventTypePeerConnectionFlutterEvents that the globe consumes — protocol-agnostic rendering.This is Part 1 of 2 of the original #8740 split. Part 2 ships Unbounded as a top-level home tab in a follow-up.
What's in this PR
Screen (
lib/features/share_my_connection/share_my_connection.dart):vpn_setting.dartwith a navigation tile to a dedicated screen.flutter_earth_globeintegration from Unbounded changes #8493, reused verbatim, MediaQuery-overridden to centre in-widget).setPeerManualPortFFI, takes effect on next toggle-on.Backend (Go,
lantern-core/):Core.SetPeerManualPort / GetPeerManualPort→PatchSettings(PeerManualPortKey: int).Core.SetUnboundedEnabled / IsUnboundedEnabled→PatchSettings(UnboundedKey: bool).listenPeerConnectionEventssubscribes to bothpeer.ConnectionEvent(samizdat) andunbounded.ConnectionEvent(broflake), forwards as oneEventTypePeerConnectionFlutterEvent — globe is protocol-agnostic.//exportFFI functions:setPeerManualPort,getPeerManualPort,setUnboundedEnabled,isUnboundedEnabled.events.Subscribe— same wire format as the rest of the FlutterEvent bridge.Frontend (Dart):
LanternCoreServiceinterface,FFIimpl,Servicerouter,Platformstub all gain the new methods. Platform stub returns "not implemented" — iOS / Android MethodChannel handlers aren't plumbed yet (degrades gracefully).End-to-end event flow
sequenceDiagram autonumber participant UI as SmC toggle participant FFI as lantern-core FFI participant Core as LanternCore participant Backend as radiance Backend participant Mode as peer/unbounded participant Bus as radiance events participant Globe as Flutter globe UI->>FFI: setPeerProxyEnabled(1) | setUnboundedEnabled(1) FFI->>Core: SetPeerShareEnabled | SetUnboundedEnabled Core->>Backend: PatchSettings(...) Note over Backend: applyPeerShare → peer.Client.Start<br/>or unbounded.shouldRun re-evaluates Backend->>Mode: Start loop each remote client Mode->>Bus: events.Emit(ConnectionEvent{...}) end Bus->>Core: listenPeerConnectionEvents (subscribes to BOTH) Core->>FFI: notifyFlutter(EventTypePeerConnection, json) FFI->>Globe: dart_api_dl.SendToPort(appEventPort, ...) Globe->>Globe: appEvents stream → arc + heart-burst rendersHow this was sliced
Cherry-picked from the original
fisk/share-my-connection-ux(#8740) — the chronological first 21 commits. The second half (Unbounded as a top-level home tab + auto-enable + final polish) lands as the stacked follow-up.Test plan
flutter analyzeclean.go build ./lantern-core/...clean.make macos-releaseafter merging the dependent radiance PRs):peer.Client.Startruns (UPnP map → lantern-cloud register → verify → samizdat inbound).Dependencies
🤖 Generated with Claude Code