Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
102a26e
Add peer-share toggle to lantern-core (Share My Connection PR 3/4)
May 5, 2026
118fa8a
Wire Share My Connection toggle in Dart UI (PR 4/4)
May 5, 2026
9122938
review: gate peer-proxy toggle to FFI-supported platforms
May 5, 2026
c156d63
peer-proxy: add macOS native handler
May 5, 2026
4965455
Prototype: unified Share My Connection screen with globe + SmC disclo…
May 7, 2026
cf474aa
prototype: globe wasn't visible — restore MediaQuery override + ClipRect
May 7, 2026
8eb80cb
prototype: use SwitchButton to match the rest of the app's toggles
May 7, 2026
02e43f4
prototype: nudge the globe up — alignment(0, 0.1) → (0, -0.1)
May 7, 2026
b93e873
prototype: replace mock event timer with poll of radiance peer stats …
May 7, 2026
3bb2788
Stream peer-connection events from radiance to Flutter via the existing
May 7, 2026
db0249f
Advanced section in Share My Connection: manual port forward setting
May 7, 2026
ec0a947
Unbounded fully wired through to the SmC UI's "Basic mode"
May 8, 2026
c2201a6
macOS: wire setPeerManualPort + setUnboundedEnabled through MethodCha…
May 8, 2026
d7caeda
share-my-connection: toggle honors Advanced manual port before UPnP p…
May 8, 2026
bfaeea2
mobile: sanitize errors before returning to gomobile bridge
May 8, 2026
c0508d2
share-my-connection: surface radiance peer phase events to the UI
May 11, 2026
0ee4853
core: instrument peer-connection subscriber to pair with radiance bre…
May 11, 2026
3230651
core: log listenPeerConnectionEvents goroutine entry
May 11, 2026
9209aef
core: consume peer events over IPC SSE instead of in-process events.S…
May 11, 2026
76d738a
SmC: real per-peer geo, on-globe heart burst, arc reversal
May 12, 2026
db98526
SmC: lift heart-burst off the globe into a floating toast
May 12, 2026
3dda051
unbounded: phase 1 — tab shell + Unbounded as a top-level tab
May 12, 2026
33886b4
unbounded: phase 2 — Unbounded Settings sheet + hide-tab toggle
May 12, 2026
ea55b91
unbounded: phase 3 — auto-enable on VPN connect
May 12, 2026
956a7ae
unbounded: phase 4 — first-visit welcome popup + info bubble
May 12, 2026
e22392c
unbounded: also auto-enable on app launch (not just VPN connect)
May 12, 2026
5dc5f27
unbounded: lift the heart spray out of the pill, onto the globe
May 12, 2026
dec39fa
unbounded: put the Lottie inside the pill, overflowing — matches CSS
May 12, 2026
e2c89cf
unbounded: match the pill exactly — heart+text, bottom-left anchor
May 12, 2026
0b9965e
unbounded: revert the pill back to centered
May 12, 2026
c6a025e
unbounded: persist "Total people helped to date" across restarts
May 12, 2026
adeb9e5
share: auto-fall-back from SmC to Unbounded on any Start failure
May 13, 2026
407f7fa
unbounded: gate entire UI surface on server Features[unbounded] flag
May 13, 2026
dca50d2
share: render Lottie arrival heart-burst at native canvas size
May 14, 2026
a2438f2
share: nudge heart-to-text gap from 10 → 14 px
May 14, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions assets/locales/en.po
Original file line number Diff line number Diff line change
Expand Up @@ -551,6 +551,12 @@ msgstr "Block Ads"
msgid "only_active"
msgstr "Only active when VPN is connected"

msgid "share_my_connection"
msgstr "Share My Connection"

msgid "share_my_connection_subtitle"
msgstr "Let other Lantern users route through your connection to bypass censorship."

msgid "vpn_connected"
msgstr "Lantern is now connected."

Expand Down
1 change: 1 addition & 0 deletions assets/unbounded/explosion.json

Large diffs are not rendered by default.

Binary file added assets/unbounded/uv-map-dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added assets/unbounded/uv-map.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
6 changes: 5 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,11 @@ module github.com/getlantern/lantern

go 1.26.2

// replace github.com/getlantern/radiance => ../radiance
// Local while peer connection-stats endpoint is in flight; remove once
// radiance tags a release that includes peer/connstats.go.
replace github.com/getlantern/radiance => ../radiance

replace github.com/getlantern/lantern-box => ../lantern-box

// replace github.com/getlantern/lantern-server-provisioner => ../lantern-server-provisioner

Expand Down
163 changes: 162 additions & 1 deletion lantern-core/core.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,12 @@ import (
"github.com/getlantern/radiance/common"
"github.com/getlantern/radiance/common/env"
"github.com/getlantern/radiance/common/settings"
"github.com/getlantern/radiance/events"
"github.com/getlantern/radiance/ipc"
"github.com/getlantern/radiance/issue"
"github.com/getlantern/radiance/peer"
"github.com/getlantern/radiance/servers"
"github.com/getlantern/radiance/unbounded"
"github.com/getlantern/radiance/vpn"

"github.com/getlantern/lantern/lantern-core/apps"
Expand All @@ -36,7 +39,19 @@ const (
EventTypeServerLocation EventType = "server-location"
EventTypeConfig EventType = "config"
EventTypeCountryCode EventType = "country-code"
DefaultLogLevel = "trace"
// EventTypePeerConnection signals a samizdat peer accept/close on the
// local Share My Connection inbound. Message is JSON
// {"state": +1|-1, "source": "ip:port"}; consumers extract the IP for
// geo-lookup or rate-limit attribution.
EventTypePeerConnection EventType = "peer-connection"
// EventTypePeerStatus signals a peer.Client lifecycle phase change
// (mapping_port → registering → verifying → serving on the way up,
// stopping → idle on the way down, error on failure). Message is the
// JSON-marshalled peer.Status struct. The Dart side switches on
// .phase to render progress text and on .error to surface
// diagnostics on the failure path.
EventTypePeerStatus EventType = "peer-status"
DefaultLogLevel = "trace"
)

// LanternCore wraps an IPC client and provides the interface expected by the FFI and mobile layers.
Expand Down Expand Up @@ -153,6 +168,23 @@ type SmartRouting interface {
IsSmartRoutingEnabled() bool
}

type PeerShare interface {
SetPeerShareEnabled(bool) error
IsPeerShareEnabled() bool
// SetPeerManualPort persists the user's manually-configured router
// port forward (Advanced setting in the Share My Connection UI).
// 0 clears the override, restoring UPnP-discovered port behavior.
SetPeerManualPort(port int) error
GetPeerManualPort() int
// SetUnboundedEnabled is the local opt-in for the broflake /
// Unbounded widget proxy ("Basic mode" in the SmC UI). The
// proxy actually runs only when this is true AND the server-side
// Features[unbounded] flag is on AND the server provides
// UnboundedConfig — see radiance/unbounded.shouldRunUnbounded.
SetUnboundedEnabled(bool) error
IsUnboundedEnabled() bool
}

type VPN interface {
ConnectVPN(tag string) error
SelectServer(tag string) error
Expand All @@ -169,6 +201,7 @@ type Core interface {
SplitTunnel
Ads
SmartRouting
PeerShare
VPN
Client() *ipc.Client
}
Expand Down Expand Up @@ -240,6 +273,8 @@ func (lc *LanternCore) initialize(opts *utils.Opts, eventEmitter utils.FlutterEv
go lc.listenAutoSelectedEvents()
go lc.listenConfigEvents()
go lc.listenDataCapEvents()
go lc.listenPeerConnectionEvents()
go lc.listenPeerStatusEvents()
go lc.fetchUserDataIfNeeded()

slog.Debug("LanternCore initialized successfully")
Expand Down Expand Up @@ -354,6 +389,91 @@ func (lc *LanternCore) listenDataCapEvents() {
}
}

// listenPeerConnectionEvents forwards inbound accept/close events from
// either of the two donor protocols (samizdat-over-UPnP "Share My
// Connection" and broflake "Unbounded") to the Flutter side via the
// existing FlutterEvent emitter, so the same globe widget can render
// arcs without caring which protocol produced them. Subscription is
// process-lifetime; events.Subscribe silently delivers nothing while
// no peer / widget is active.
//
// The wire format unifies both protocols on a single event type
// (EventTypePeerConnection) with a {state, source} payload. Unbounded
// has a workerIdx in addition to source IP — surfaced as part of the
// JSON in case the Dart side eventually wants to disambiguate same-IP
// reconnects (broflake's WebRTC sessions are short and same-IP churn
// is more common than for SmC's long-lived TCP).
func (lc *LanternCore) listenPeerConnectionEvents() {
// peer.ConnectionEvent: subscribe via the IPC client's SSE stream.
// The events package's globals are process-scoped — events.Emit in
// lanternd (where radiance/peer runs) doesn't reach events.Subscribe
// in Liblantern. The /peer/connection/events SSE endpoint in
// radiance/ipc/server.go bridges the two processes.
go func() {
err := lc.client.PeerConnectionEvents(lc.ctx, func(evt peer.ConnectionEvent) {
jsonBytes, err := json.Marshal(map[string]any{
"state": evt.State,
"source": evt.Source,
})
if err != nil {
slog.Error("marshal peer connection event", "error", err)
return
}
lc.notifyFlutter(EventTypePeerConnection, string(jsonBytes))
})
if err != nil && lc.ctx.Err() == nil {
slog.Error("peer-connection event stream exited unexpectedly", "error", err)
}
}()
// unbounded.ConnectionEvent stays on in-process events.Subscribe for
// now. Unbounded runs in the same process as the consumer in mobile
// builds (broflake-as-library); the desktop path doesn't yet have a
// gomobile-bridged Unbounded peer, so the cross-process gap doesn't
// hit here today. Worth revisiting if Unbounded ever moves out of
// process.
events.Subscribe(func(evt unbounded.ConnectionEvent) {
jsonBytes, err := json.Marshal(map[string]any{
"state": evt.State,
"source": evt.Addr,
"workerIdx": evt.WorkerIdx,
})
if err != nil {
slog.Error("marshal unbounded connection event", "error", err)
return
}
lc.notifyFlutter(EventTypePeerConnection, string(jsonBytes))
})
}

// listenPeerStatusEvents forwards peer.Client lifecycle phase changes to
// the Flutter side. radiance's peer module emits one StatusEvent per
// stage during Start (mapping_port → detecting_ip → registering →
// starting_proxy → verifying → serving) and during Stop (stopping →
// idle), plus an "error" terminal event with Status.Error populated on
// failure. The Dart side renders each phase as user-facing progress
// text instead of a single active/inactive flip.
//
// Message body is the JSON-marshalled peer.Status — the struct already
// carries phase, error, active, sharing_since, external_ip,
// external_port, route_id with stable JSON tags.
func (lc *LanternCore) listenPeerStatusEvents() {
// Same cross-process bridging story as listenPeerConnectionEvents: the
// peer.StatusEvent emits live in lanternd, so subscribing in this
// process via events.Subscribe gets us nothing. /peer/status/events
// SSE in radiance/ipc/server.go is the canonical source.
err := lc.client.PeerStatusEvents(lc.ctx, func(evt peer.StatusEvent) {
jsonBytes, err := json.Marshal(evt.Status)
if err != nil {
slog.Error("marshal peer status event", "error", err)
return
}
lc.notifyFlutter(EventTypePeerStatus, string(jsonBytes))
})
if err != nil && lc.ctx.Err() == nil {
slog.Error("peer-status event stream exited unexpectedly", "error", err)
}
}

/////////////////
// VPN //
/////////////////
Expand Down Expand Up @@ -454,6 +574,47 @@ func (lc *LanternCore) IsSmartRoutingEnabled() bool {
return b
}

func (lc *LanternCore) SetPeerShareEnabled(enabled bool) error {
_, err := lc.client.PatchSettings(lc.ctx, settings.Settings{settings.PeerShareEnabledKey: enabled})
return err
}

func (lc *LanternCore) IsPeerShareEnabled() bool {
b, _ := lc.settings()[settings.PeerShareEnabledKey].(bool)
return b
}

func (lc *LanternCore) SetPeerManualPort(port int) error {
if port < 0 || port > 65535 {
return fmt.Errorf("port %d out of range (0-65535)", port)
}
_, err := lc.client.PatchSettings(lc.ctx, settings.Settings{settings.PeerManualPortKey: port})
return err
}

func (lc *LanternCore) GetPeerManualPort() int {
// koanf typically stores numeric settings as float64 after JSON
// round-trip; handle both float64 and int paths so loads from disk
// and freshly-set values both work.
switch v := lc.settings()[settings.PeerManualPortKey].(type) {
case int:
return v
case float64:
return int(v)
}
return 0
}

func (lc *LanternCore) SetUnboundedEnabled(enabled bool) error {
_, err := lc.client.PatchSettings(lc.ctx, settings.Settings{settings.UnboundedKey: enabled})
return err
}

func (lc *LanternCore) IsUnboundedEnabled() bool {
b, _ := lc.settings()[settings.UnboundedKey].(bool)
return b
}

func (lc *LanternCore) IsTelemetryEnabled() bool {
b, _ := lc.settings()[settings.TelemetryKey].(bool)
return b
Expand Down
80 changes: 80 additions & 0 deletions lantern-core/ffi/ffi.go
Original file line number Diff line number Diff line change
Expand Up @@ -1352,6 +1352,86 @@ func isSmartRoutingEnabled() C.int {
return 0
}

//export setPeerProxyEnabled
func setPeerProxyEnabled(enabled C.int) *C.char {
return runOnGoStack(func() *C.char {
c, errStr := requireCore()
if errStr != nil {
return errStr
}
if err := c.SetPeerShareEnabled(enabled != 0); err != nil {
return SendError(err)
}
return C.CString("ok")
})
}

//export isPeerProxyEnabled
func isPeerProxyEnabled() C.int {
c, _ := requireCore()
if c != nil && c.IsPeerShareEnabled() {
return 1
}
return 0
}

// setPeerManualPort persists the manually-configured router port-forward
// for the Share My Connection peer-share feature. 0 clears the override,
// reverting to UPnP discovery on the next peer.Client.Start.
//
//export setPeerManualPort
func setPeerManualPort(port C.int) *C.char {
return runOnGoStack(func() *C.char {
c, errStr := requireCore()
if errStr != nil {
return errStr
}
if err := c.SetPeerManualPort(int(port)); err != nil {
return SendError(err)
}
return C.CString("ok")
})
}

//export getPeerManualPort
func getPeerManualPort() C.int {
c, _ := requireCore()
if c == nil {
return 0
}
return C.int(c.GetPeerManualPort())
}

// setUnboundedEnabled is the local opt-in for the broflake / Unbounded
// widget proxy ("Basic mode" in the SmC UI). The widget actually runs
// only when this is true AND the server-side Features[unbounded] flag
// is on AND the server provides UnboundedConfig — flipping this to
// true on a network where the server hasn't enabled the feature is a
// no-op until the next /config response opts the user in.
//
//export setUnboundedEnabled
func setUnboundedEnabled(enabled C.int) *C.char {
return runOnGoStack(func() *C.char {
c, errStr := requireCore()
if errStr != nil {
return errStr
}
if err := c.SetUnboundedEnabled(enabled != 0); err != nil {
return SendError(err)
}
return C.CString("ok")
})
}

//export isUnboundedEnabled
func isUnboundedEnabled() C.int {
c, _ := requireCore()
if c != nil && c.IsUnboundedEnabled() {
return 1
}
return 0
}

//export getSplitTunnelState
func getSplitTunnelState() *C.char {
return runOnGoStack(func() *C.char {
Expand Down
Loading