Skip to content

Unified Share My Connection screen with globe, disclosure, Advanced section, and Unbounded mode#8740

Draft
myleshorton wants to merge 42 commits into
fisk/peer-dartfrom
fisk/share-my-connection-ux
Draft

Unified Share My Connection screen with globe, disclosure, Advanced section, and Unbounded mode#8740
myleshorton wants to merge 42 commits into
fisk/peer-dartfrom
fisk/share-my-connection-ux

Conversation

@myleshorton
Copy link
Copy Markdown
Contributor

Summary

End-to-end UX for "Share My Connection" — one unified screen, one toggle, one globe — backed by both donor protocols:

  • SmC (samizdat over UPnP / manual port): real radiance peer module wired through; full mode with persistent residential exit
  • Unbounded (broflake / WebRTC): real radiance/unbounded subscription wired through; basic mode with ephemeral WebRTC sessions

The user picks between modes via a one-time disclosure dialog when UPnP is available; fallback to Unbounded when UPnP isn't workable.

End-to-end event flow

```mermaid
sequenceDiagram
autonumber
participant UI as SmC screen 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) or 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 renders

```

What's in this PR

Screen (`lib/features/share_my_connection/share_my_connection.dart`):

  • Replaces the old inline SmC toggle in `vpn_setting.dart` with a navigation tile to the new dedicated screen
  • Globe (Jigar's `flutter_earth_globe` integration from Unbounded changes #8493 — reused verbatim, themed, MediaQuery-overridden so it centres in-widget rather than full-screen)
  • Status card with mode badge + active/total counters
  • One-time SmC disclosure dialog with "Basic mode (Unbounded)" / "Full mode (SmC)" choice
  • Advanced section (collapsed by default): manual port forward field for users on UPnP-less networks; persists via `setPeerManualPort` FFI, takes effect on next toggle-on

Backend (Go):

  • `Core.SetPeerManualPort / GetPeerManualPort` — `PatchSettings(PeerManualPortKey: int)`
  • `Core.SetUnboundedEnabled / IsUnboundedEnabled` — `PatchSettings(UnboundedKey: bool)`
  • `listenPeerConnectionEvents` subscribes to BOTH `peer.ConnectionEvent` (samizdat) and `unbounded.ConnectionEvent` (broflake), forwards as one `EventTypePeerConnection` FlutterEvent — globe is protocol-agnostic
  • 4 new `//export` FFI functions: `setPeerManualPort`, `getPeerManualPort`, `setUnboundedEnabled`, `isUnboundedEnabled`

Frontend (Dart):

  • Hand-rolled FFI bindings for the 4 new exports (skipping ffigen for the prototype)
  • `LanternCoreService` interface, `FFI` impl, `Service` router, `Platform` stub all gain the new methods. Platform stub returns "not implemented" — iOS / Android MethodChannel handlers aren't plumbed yet, so the Advanced UI degrades gracefully

Test plan

  • `flutter analyze` clean
  • `go build ./lantern-core/...` clean
  • macOS end-to-end (requires `make macos-release` after merging the two dependent branches):
    • Open Settings → VPN → Share my connection
    • Toggle on, take "Full mode (SmC)" → real `peer.Client.Start` runs (UPnP map, lantern-cloud register, samizdat inbound)
    • Toggle off, on, take "Basic mode (Unbounded)" → broflake widget proxy runs (assuming server has `Features[unbounded]` flagged on)
    • Globe arcs animate from origin to connecting client countries
    • Advanced → enter port → Save → toggle off/on → `peer.Client` uses manual port instead of UPnP

Caveats

  • Local `replace github.com/getlantern/radiance => ../radiance` and `replace github.com/getlantern/lantern-box => ../lantern-box` in go.mod — must come out before merge once the dependent branches land
  • Unbounded actually runs only when server-side `Features[unbounded]` flag is on AND `UnboundedConfig` is provided. Without those, the local opt-in persists but the proxy stays inactive

Related

🤖 Generated with Claude Code

jigar-f and others added 30 commits May 6, 2026 18:36
* Check in-flight in app purchase

* code review updates

* Safeguard checks.
Pulls in radiance b8f04e3 (latest origin/main as of this commit) and
the transitive bumps that come with it:

  - radiance v0.0.0-20260504203153-371d9879d4cd → v0.0.0-20260506165909-b8f04e3a710e
    * #463 fix: preserve caller-supplied data dir; restore Pro on upgrade
    * #461 fix: disconnect VPN on backend close
    * #459 bump lantern-box to v0.0.78 for QUIC err_class instrumentation
  - lantern-box v0.0.77 → v0.0.78 (transitive via radiance)
  - broflake (transitive) bumped to its latest

No client-side code changes — go.mod / go.sum only. The local
`replace github.com/getlantern/radiance => ../radiance` directive in
go.mod stays commented out so this PR doesn't accidentally point at
anyone's local checkout.

Co-authored-by: Adam Fisk <afisk@mini.local>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pulls in radiance #464 (pre-9.x flashlight/lantern-client YAML migration)
which lets fresh v9.x installs recover user_id / device_id / token / pro
state from old desktop ~/Library/Application Support/Lantern/settings.yaml,
%APPDATA%\Lantern\settings.yaml, ~/.config/lantern/settings.yaml, or iOS
<sandbox>/userconfig.yaml.

go.mod / go.sum only — no transitive bumps this round.

Co-authored-by: Adam Fisk <afisk@mini.local>
…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>
* initial commit

* code review updates

* update go.mod

* further UI updates

* Fix report issue screen issues

* Update Radiance report issue dependency

* Bump radiance for report issue attachments

* code review updates

* bump radiance for screenshot attachments
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>
* initial commit

* payment: clean redirect idempotency plumbing

* code review updates
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>
Picks up radiance fisk/peer-connection-events → lantern-box
fisk/peer-connection-listener → samizdat#10, which plumbs real
TLS peer addresses through the H2 stream. peer.ConnectionEvent
now carries a unique source per peer, so the Share My Connection
globe arcs persist per real peer instead of collapsing onto a
single "client:0" key.

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>
…ndles (#8745)

* macos: cache installed system-extension hashes + skip uninstalling bundles

When many old system extensions accumulate on a user's Mac (e.g. from
repeated install/uninstall cycles), every status query was paying a
full SHA-256 of every installed bundle: ~10 lingering extensions ⇒ ~10
full bundle hashes per query ⇒ several seconds of stall before any UI
update on startup, every Flutter checkInstallationStatus, every
reconcile retry, and every post-completion follow-up.

Two fixes:

1. Cache installed-descriptor content hashes by URL. Installed bundles
   live at immutable per-UUID paths under /Library/SystemExtensions/,
   so once a URL has been hashed it never needs to be hashed again.
   Stores just the hash (not the whole descriptor) because mutable
   property flags — isEnabled, isAwaitingUserApproval, isUninstalling —
   change between queries while bundle bytes don't.

2. Skip hashing entirely for uninstalling extensions. The reconciler
   already short-circuits on installed.contains(where: \.isUninstalling),
   so a draining extension's hash never participates in a useful
   comparison. These dominate the cost of large backfills.

Adds two RunnerTests cases covering the URL cache and the
uninstalling-skip path via a thin internal seam.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* review: uninstalling descriptors never match, regardless of hash

Skipping the bundle hash for isUninstalling extensions (previous commit)
left matchesContent() free to gracefully return true when either side's
hash was nil — so an uninstalling descriptor with the same version as
bundled would slip through matches() as "matched" even when the actual
bytes differ. That can mask a legitimate same-version replacement: the
reconciler's `enabled.matches(bundled)` would short-circuit to
.activated/.none, and actionForReplacingExtension would return .cancel.

Treat isUninstalling on either side as a hard non-match. Uninstalling
extensions are going away — they should never participate in a "matched"
outcome, regardless of version or hash.

Two new tests cover the regression:
- testMatchesReturnsFalseWhenUninstalling — direct symmetry check on
  SystemExtensionDescriptor.matches.
- testReconcileReplacesEnabledUninstallingExtensionWithSkippedHash —
  integration-level: reconciler given an enabled+uninstalling installed
  extension with nil hash must produce a replacement, not .none.

---------

Co-authored-by: Adam Fisk <afisk@mini.local>
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>
Pulls in #8736 (idempotency keys), #8738 #8744 (radiance/lantern-box
bumps), #8739 (VPN performance), screenshot attachments to issue
reports, and #8745 (sysext hash caching).

Conflict resolution:
- go.mod / go.sum: took main's pins (radiance, kindling,
  lantern-box bumped to current main), then restored our local
  replace directives for ../radiance and ../lantern-box so the
  in-flight SmC work on fisk/peer-connection-events and
  fisk/peer-connection-listener stays in effect during dev. Replaces
  carry comments noting when to drop them.
- backend/radiance.go (in ../radiance fisk/peer-connection-events):
  combined main's sessionHistory field + DisconnectVPN-on-close with
  our peer-share teardown. Peer teardown runs BEFORE DisconnectVPN
  because /v1/peer/deregister needs a live outbound.
- ../radiance go.mod re-pinned to our lantern-box pseudo-version
  (3201d6a) via go get so radiance still gets the samizdat
  real-RemoteAddr fix instead of main's stale lantern-box v0.0.82.

lantern-core builds clean; radiance peer + ipc tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
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>
Adam Fisk and others added 12 commits May 12, 2026 12:14
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>
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>
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.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>
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>
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>
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>
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>
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>
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>
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>
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants