Skip to content

Commit 2b553c7

Browse files
myleshortonclaude
andcommitted
smc: keep-alive ShareNotifier + reflect Unbounded in VPN tile
Two new Copilot fixes (+ a third escalation acknowledged in reply): 1. shareProvider was the default (non-annotated) NotifierProvider, which is autoDispose in Riverpod 3.x — so navigating away from the screen disposed the notifier, re-entry reset state to mode=off / active=false even when SmC or Unbounded was still running, and the next toggle tried to re-enable an already- enabled setting. Added `ref.keepAlive()` at the top of build() so the notifier sticks for the process lifetime. onDispose stays registered for the explicit teardown paths (provider container reset, hot reload) so the event subscription + stream controller still get cleaned up. 2. VPN settings tile rendered "Off" when the user had picked "Basic mode (Unbounded)" in the disclosure dialog because the subtitle was driven solely by peerProxy. Added unboundedEnabled to RadianceSettingsState (with a copyWith field + equality / hashCode update), wired the fetch + setter through radianceSettingsProvider, and the tile now reads OR of both to decide whether to show share_my_connection_on_tap_to_view. dart analyze clean on the touched files. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 95c1b5a commit 2b553c7

4 files changed

Lines changed: 66 additions & 4 deletions

File tree

lib/core/models/radiance_settings_state.dart

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,21 @@ class RadianceSettingsState {
1313
final bool splitTunneling;
1414
final bool telemetry;
1515
final bool peerProxy;
16+
// Local opt-in for the broflake / Unbounded widget proxy. Separate
17+
// from peerProxy because the two are independent toggles — the SmC
18+
// disclosure dialog flips just one of them based on the user's
19+
// choice ("Basic mode" → unboundedEnabled, "Full mode" → peerProxy).
20+
// The VPN settings tile uses BOTH to decide whether to show the
21+
// "On — tap to view" subtitle.
22+
final bool unboundedEnabled;
1623

1724
const RadianceSettingsState({
1825
this.blockAds = false,
1926
this.routingMode = RoutingMode.full,
2027
this.splitTunneling = false,
2128
this.telemetry = false,
2229
this.peerProxy = false,
30+
this.unboundedEnabled = false,
2331
});
2432

2533
RadianceSettingsState copyWith({
@@ -28,13 +36,15 @@ class RadianceSettingsState {
2836
bool? splitTunneling,
2937
bool? telemetry,
3038
bool? peerProxy,
39+
bool? unboundedEnabled,
3140
}) {
3241
return RadianceSettingsState(
3342
blockAds: blockAds ?? this.blockAds,
3443
routingMode: routingMode ?? this.routingMode,
3544
splitTunneling: splitTunneling ?? this.splitTunneling,
3645
telemetry: telemetry ?? this.telemetry,
3746
peerProxy: peerProxy ?? this.peerProxy,
47+
unboundedEnabled: unboundedEnabled ?? this.unboundedEnabled,
3848
);
3949
}
4050

@@ -46,9 +56,16 @@ class RadianceSettingsState {
4656
routingMode == other.routingMode &&
4757
splitTunneling == other.splitTunneling &&
4858
telemetry == other.telemetry &&
49-
peerProxy == other.peerProxy;
59+
peerProxy == other.peerProxy &&
60+
unboundedEnabled == other.unboundedEnabled;
5061

5162
@override
52-
int get hashCode =>
53-
Object.hash(blockAds, routingMode, splitTunneling, telemetry, peerProxy);
63+
int get hashCode => Object.hash(
64+
blockAds,
65+
routingMode,
66+
splitTunneling,
67+
telemetry,
68+
peerProxy,
69+
unboundedEnabled,
70+
);
5471
}

lib/features/home/provider/radiance_settings_providers.dart

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,12 +36,14 @@ class RadianceSettings extends _$RadianceSettings {
3636
// Manual port forwarding works on any home WiFi where the user owns
3737
// the router, so mobile is included rather than desktop-only.
3838
final peerF = svc.isPeerProxyEnabled();
39+
final unboundedF = svc.isUnboundedEnabled();
3940

4041
final blockAds = await blockAdsF;
4142
final routing = await routingF;
4243
final telemetry = await telemetryF;
4344
final split = splitF == null ? null : await splitF;
4445
final peer = await peerF;
46+
final unbounded = await unboundedF;
4547
if (!ref.mounted) return;
4648

4749
const defaults = RadianceSettingsState();
@@ -56,6 +58,8 @@ class RadianceSettings extends _$RadianceSettings {
5658
? defaults.splitTunneling
5759
: split.fold((_) => defaults.splitTunneling, (v) => v),
5860
peerProxy: peer.fold((_) => defaults.peerProxy, (v) => v),
61+
unboundedEnabled:
62+
unbounded.fold((_) => defaults.unboundedEnabled, (v) => v),
5963
);
6064
}
6165

@@ -126,6 +130,25 @@ class RadianceSettings extends _$RadianceSettings {
126130
},
127131
);
128132
}
133+
134+
/// Mirror of setPeerProxy for the Unbounded toggle. Returns the
135+
/// Either so callers can react to failure (the Unbounded enable
136+
/// path in share_my_connection.dart uses this for UI rollback).
137+
Future<Either<Failure, Unit>> setUnboundedEnabled(bool value) async {
138+
final svc = ref.read(lanternServiceProvider);
139+
final result = await svc.setUnboundedEnabled(value);
140+
if (!ref.mounted) return result;
141+
return result.fold(
142+
(err) {
143+
appLogger.error('setUnboundedEnabled failed: ${err.error}');
144+
return left(err);
145+
},
146+
(_) {
147+
state = state.copyWith(unboundedEnabled: value);
148+
return right(unit);
149+
},
150+
);
151+
}
129152
}
130153

131154
/// Fetches whether user logged in via OAuth from radiance.

lib/features/setting/vpn_setting.dart

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,15 @@ class VPNSetting extends HookConsumerWidget {
3939
final peerProxy = ref.watch(
4040
radianceSettingsProvider.select((s) => s.peerProxy),
4141
);
42+
final unboundedEnabled = ref.watch(
43+
radianceSettingsProvider.select((s) => s.unboundedEnabled),
44+
);
45+
// The tile reads "On" when EITHER donor protocol is active —
46+
// the disclosure dialog flips peerProxy for "Full mode" and
47+
// unboundedEnabled for "Basic mode", and the user shouldn't
48+
// see a stale "Off" subtitle just because they picked the
49+
// lower-friction Unbounded path.
50+
final shareActive = peerProxy || unboundedEnabled;
4251

4352
return ListView(
4453
padding: const EdgeInsets.all(0),
@@ -128,7 +137,7 @@ class VPNSetting extends HookConsumerWidget {
128137
child: AppTile(
129138
label: 'share_my_connection'.i18n,
130139
subtitle: Text(
131-
peerProxy
140+
shareActive
132141
? 'share_my_connection_on_tap_to_view'.i18n
133142
: 'share_my_connection_subtitle'.i18n,
134143
style: textTheme.labelMedium!.copyWith(

lib/features/share_my_connection/share_my_connection.dart

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,19 @@ class ShareNotifier extends Notifier<ShareState> {
201201

202202
@override
203203
ShareState build() {
204+
// Keep the notifier alive for the process lifetime. Without this,
205+
// navigating away from the screen disposes the notifier; re-entry
206+
// calls build() again and resets state to mode=off / active=false
207+
// even when SmC or Unbounded is still actually running on the
208+
// backend. The next toggle would then try to re-enable an
209+
// already-enabled setting, and the user loses visibility into
210+
// the active counter and the granular peer-status phase.
211+
//
212+
// ref.onDispose stays registered for explicit Stop/Disable paths
213+
// (provider container reset, hot reload, etc.) so the event
214+
// subscription and stream controller still get cleaned up when
215+
// it does actually happen.
216+
ref.keepAlive();
204217
ref.onDispose(() {
205218
_stopEventSubscription();
206219
_eventController.close();

0 commit comments

Comments
 (0)