Skip to content

Commit 02a8ba2

Browse files
committed
feat(mobile): pick a city for each multi-hop end; hide split tunneling where nothing can be bought
Multi-hop entry/exit pickers were country-only — tapping a country selected it and closed, with no way into its cities. They now drill in like the single-hop picker: a hop pick is a location id (`DE` or `DE:Nuremberg`, so previously-stored bare country codes keep working), Country rows carry the gateway IPs they cover, and those are passed to selectHops as entryIps/exitIps. The hop card names the pinned city — without it the card read "Germany" whether you chose the country or one city, making the pick invisible. An impossible pair (e.g. the same one-node city at both ends) gets a message naming the knob to loosen instead of a raw selectHops error. Split tunneling is now hidden entirely for a non-premium user who cannot buy anything — iOS store builds, where PURCHASE_UI_PLATFORMS excludes the platform structurally. Showing a locked paid feature with no purchase path is the guideline 3.1.1 upsell surface that got a previous build rejected; the section ignored that flag and rendered "upgrade to route only the traffic you choose". Premium users still see it on iOS, since that follows tier, not platform. Also: requestPermission now rejects on the iOS Simulator with "VPN tunnels are not supported on the iOS Simulator" (compile-time guard, so the device build is untouched). The Simulator has no NetworkExtension VPN subsystem, so the generic "VPN permission is required" left a tester hunting for a consent dialog that can never appear.
1 parent 4192af0 commit 02a8ba2

7 files changed

Lines changed: 143 additions & 24 deletions

File tree

clients/mobile/App.tsx

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -112,8 +112,9 @@ function App(): React.JSX.Element {
112112
) : route === 'entry' ? (
113113
<CountryPickerScreen
114114
countries={vpn.countries}
115-
selectedCode={vpn.entry?.code ?? null}
116-
onSelect={(code) => void vpn.selectEntryCountry(code)}
115+
locations={vpn.locations}
116+
selectedCode={vpn.entry?.id ?? null}
117+
onSelect={(id) => void vpn.selectEntryCountry(id)}
117118
onSelectAuto={() => void vpn.selectEntryCountry(null)}
118119
onClose={() => setRoute('connect')}
119120
onRefresh={() => vpn.refresh()}
@@ -124,8 +125,9 @@ function App(): React.JSX.Element {
124125
) : route === 'exit' ? (
125126
<CountryPickerScreen
126127
countries={vpn.countries}
127-
selectedCode={vpn.exit?.code ?? null}
128-
onSelect={(code) => void vpn.selectExitCountry(code)}
128+
locations={vpn.locations}
129+
selectedCode={vpn.exit?.id ?? null}
130+
onSelect={(id) => void vpn.selectExitCountry(id)}
129131
onSelectAuto={() => void vpn.selectExitCountry(null)}
130132
onClose={() => setRoute('connect')}
131133
onRefresh={() => vpn.refresh()}
@@ -148,6 +150,7 @@ function App(): React.JSX.Element {
148150
onClose={() => setRoute('connect')}
149151
onOpenUpgrade={flags.inAppUpgrade ? () => setRoute('upgrade') : undefined}
150152
onOpenPrivacy={() => setRoute('privacy')}
153+
canUpgrade={flags.inAppUpgrade}
151154
/>
152155
) : (
153156
<ConnectScreen

clients/mobile/ios/CumulusTunnel/CumulusTunnelModule.swift

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -241,6 +241,20 @@ final class CumulusTunnelModule: RCTEventEmitter {
241241
// "VPN permission is required to connect." Give it a minimal valid
242242
// provider protocol so the save is accepted and the prompt appears; the
243243
// real connect overwrites it with the full tunnel config.
244+
#if targetEnvironment(simulator)
245+
// The Simulator has no NetworkExtension VPN subsystem: saving a manager
246+
// always fails and iOS never shows the consent prompt, so the generic
247+
// "VPN permission is required" left a tester hunting for a dialog that
248+
// cannot appear. Say what is actually true. Compile-time guard, so the
249+
// shipped device build is untouched (docs/16 Stage F: packet-tunnel
250+
// extensions do not run on the Simulator).
251+
reject(
252+
"E_SIMULATOR",
253+
"VPN tunnels are not supported on the iOS Simulator — run on a real device to connect.",
254+
nil
255+
)
256+
return
257+
#else
244258
loadOrCreateManager { [weak self] mgr, _ in
245259
guard let self, let mgr else { resolve(false); return }
246260
if mgr.protocolConfiguration == nil {
@@ -253,6 +267,7 @@ final class CumulusTunnelModule: RCTEventEmitter {
253267
mgr.isEnabled = true
254268
mgr.saveToPreferences { err in resolve(err == nil) }
255269
}
270+
#endif
256271
}
257272

258273
// solvePow(publicKeyB64, bits): Promise<String>

clients/mobile/src/components/SplitTunnelingSection.tsx

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,9 +38,25 @@ interface Props {
3838
readonly killSwitch: boolean;
3939
/** True while a session exists — changes then apply on the next connect. */
4040
readonly locked: boolean;
41+
/**
42+
* Whether this build may show purchase/upsell surfaces at all
43+
* (`flags.inAppUpgrade`; structurally false on iOS — see lib/flags).
44+
*
45+
* A non-premium user who CANNOT buy anything must not be shown a locked
46+
* paid feature: on the App Store that is a 3.1.1 upsell surface with no IAP
47+
* behind it, which is what got a previous build rejected. Settings hides the
48+
* whole section in that case; this guard makes the component itself safe if
49+
* it is ever rendered directly.
50+
*/
51+
readonly canUpgrade: boolean;
4152
}
4253

43-
export function SplitTunnelingSection({ tier, killSwitch, locked }: Props): React.JSX.Element {
54+
export function SplitTunnelingSection({
55+
tier,
56+
killSwitch,
57+
locked,
58+
canUpgrade,
59+
}: Props): React.JSX.Element | null {
4460
const [policy, setPolicy] = useState<SplitPolicy>(EMPTY_POLICY);
4561
const [draft, setDraft] = useState('');
4662
const [inputError, setInputError] = useState<string | null>(null);
@@ -110,6 +126,13 @@ export function SplitTunnelingSection({ tier, killSwitch, locked }: Props): Reac
110126
// (docs/17 §4.5) — with both on, the kill switch wins at connect time.
111127
const iosConflict = Platform.OS === 'ios' && killSwitch && active;
112128

129+
// Nothing to offer and nothing to sell: render nothing rather than advertise
130+
// a locked paid feature (see `canUpgrade`). Placed after the hooks so hook
131+
// order stays stable across renders.
132+
if (!premium && !canUpgrade) {
133+
return null;
134+
}
135+
113136
return (
114137
<View style={styles.card}>
115138
<View style={styles.modes} accessibilityRole="radiogroup">

clients/mobile/src/lib/gateways.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,15 @@ export interface Country {
3333
readonly city: string;
3434
/** Number of reachable gateways in this country. */
3535
readonly nodeCount: number;
36-
/** Best (lowest-load) gateway — the one we enroll at. */
36+
/** Best gateway in this row (see `bestOf`) — the one we enroll at. */
3737
readonly best: GatewayInfo;
38+
/**
39+
* Every gateway IP this row covers. Multi-hop passes these to core's
40+
* `selectHops` as `entryIps`/`exitIps`, so picking a CITY pins that hop to
41+
* the city's nodes instead of letting load ordering choose anywhere in the
42+
* country (docs/11).
43+
*/
44+
readonly ips: readonly string[];
3845
/** Round-trip latency in ms to `best`, or null if not yet measured. */
3946
readonly latencyMs: number | null;
4047
}
@@ -229,6 +236,7 @@ export function groupByCountry(
229236
city: localityOf(best.city, code),
230237
nodeCount: list.length,
231238
best,
239+
ips: list.map((g) => g.ip),
232240
latencyMs: latency ?? null,
233241
});
234242
}
@@ -274,6 +282,7 @@ export function groupByLocation(
274282
city: localityOf(best.city, code),
275283
nodeCount: list.length,
276284
best,
285+
ips: list.map((g) => g.ip),
277286
latencyMs: latencyByIp[best.ip] ?? null,
278287
});
279288
}

clients/mobile/src/screens/ConnectScreen.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -399,6 +399,14 @@ function HopButton({
399399
{country?.name ?? 'Auto'}
400400
</Text>
401401
</View>
402+
{/* Name the city when this hop is pinned to one (a location id carries
403+
`cc:city`). Without it a city pick is invisible: the card would read
404+
"Germany" whether you chose the whole country or Nuremberg. */}
405+
{country && country.id.includes(':') && country.city ? (
406+
<Text style={styles.hopCity} numberOfLines={1}>
407+
{country.city}
408+
</Text>
409+
) : null}
402410
</Pressable>
403411
);
404412
}
@@ -645,6 +653,7 @@ const styles = StyleSheet.create({
645653
},
646654
hopMain: { flexDirection: 'row', alignItems: 'center', gap: 6, marginTop: 4 },
647655
hopFlag: { fontSize: 18 },
656+
hopCity: { color: color.inkDim, fontSize: 11.5, marginTop: 2 },
648657
hopName: { flex: 1, color: color.ink, fontSize: 14, fontWeight: '600' },
649658
tradeoff: { color: color.inkMuted, fontSize: 12, lineHeight: 17, marginTop: space.xs },
650659
locBtn: {

clients/mobile/src/screens/SettingsScreen.tsx

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,13 +33,16 @@ interface Props {
3333
readonly onOpenUpgrade?: (() => void) | undefined;
3434
/** Re-open the 5.4 data disclosure (also shown as a first-run gate). */
3535
readonly onOpenPrivacy: () => void;
36+
/** Whether this build may show purchase/upsell surfaces (`flags.inAppUpgrade`). */
37+
readonly canUpgrade: boolean;
3638
}
3739

3840
export function SettingsScreen({
3941
vpn,
4042
onClose,
4143
onOpenUpgrade,
4244
onOpenPrivacy,
45+
canUpgrade,
4346
}: Props): React.JSX.Element {
4447
const premium = vpn.tier === 'premium';
4548
const expiry = formatExpiry(vpn.paidUntil);
@@ -136,9 +139,21 @@ export function SettingsScreen({
136139
/>
137140

138141
{/* Split tunneling (docs/17) — premium, applied when a tunnel is built,
139-
like the transport/routing toggles above. */}
140-
<Text style={styles.section}>Split tunneling</Text>
141-
<SplitTunnelingSection tier={vpn.tier} killSwitch={vpn.killSwitch} locked={locked} />
142+
like the transport/routing toggles above. Hidden entirely for a
143+
non-premium user who has no way to buy (iOS store builds): showing a
144+
locked paid feature with no purchase path is the 3.1.1 upsell
145+
surface that got a previous build rejected. */}
146+
{(premium || canUpgrade) && (
147+
<>
148+
<Text style={styles.section}>Split tunneling</Text>
149+
<SplitTunnelingSection
150+
tier={vpn.tier}
151+
killSwitch={vpn.killSwitch}
152+
locked={locked}
153+
canUpgrade={canUpgrade}
154+
/>
155+
</>
156+
)}
142157

143158
<Text style={styles.section}>Privacy &amp; support</Text>
144159
<Pressable

clients/mobile/src/state/useVpn.ts

Lines changed: 60 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -229,9 +229,11 @@ export interface VpnActions {
229229
/** Switch transport mode (Auto vs Stealth); persisted. Applies on next connect. */
230230
setTransportMode(mode: TransportMode): Promise<void>;
231231
/** Pick the multi-hop entry country (`null` = auto-pick nearest); persisted. */
232-
selectEntryCountry(code: string | null): Promise<void>;
232+
/** Pick the multi-hop ENTRY: a location id (`DE` or `DE:Frankfurt`), null = auto. */
233+
selectEntryCountry(id: string | null): Promise<void>;
233234
/** Pick the multi-hop exit country (`null` = auto-pick); persisted. */
234-
selectExitCountry(code: string | null): Promise<void>;
235+
/** Pick the multi-hop EXIT: a location id (`DE` or `DE:Frankfurt`), null = auto. */
236+
selectExitCountry(id: string | null): Promise<void>;
235237
/** Toggle the kill switch (persisted). Applies on the next connect. */
236238
setKillSwitch(enabled: boolean): Promise<void>;
237239
/** Toggle multi-hop node diversity (persisted). Applies on the next connect. */
@@ -371,13 +373,22 @@ export function useVpn(): VpnModel & VpnActions {
371373
[locations, selectedCode],
372374
);
373375

376+
// A hop pick is a LOCATION id: `DE` (whole country) or `DE:Frankfurt` (one
377+
// city). Look in the city rows first, then fall back to the country row —
378+
// which is also what makes previously-stored bare country codes keep working.
374379
const entry = useMemo<Country | null>(
375-
() => countries.find((c) => c.code === entryCode) ?? null,
376-
[countries, entryCode],
380+
() =>
381+
locations.find((l) => l.id === entryCode) ??
382+
countries.find((c) => c.code === entryCode) ??
383+
null,
384+
[countries, locations, entryCode],
377385
);
378386
const exit = useMemo<Country | null>(
379-
() => countries.find((c) => c.code === exitCode) ?? null,
380-
[countries, exitCode],
387+
() =>
388+
locations.find((l) => l.id === exitCode) ??
389+
countries.find((c) => c.code === exitCode) ??
390+
null,
391+
[countries, locations, exitCode],
381392
);
382393
const multihop = isMultihop(routeStyle);
383394

@@ -814,7 +825,19 @@ export function useVpn(): VpnModel & VpnActions {
814825
// without it the native backend throws (Android: GoBackend BackendException).
815826
// Ask once — no-op if already granted (VpnService.prepare()==null).
816827
if (!(await CumulusTunnel.isPrepared())) {
817-
const granted = await CumulusTunnel.requestPermission();
828+
// requestPermission REJECTS with a specific reason on platforms that
829+
// can never grant it (the iOS Simulator has no VPN subsystem, so no
830+
// consent dialog can appear); surface that instead of the generic line,
831+
// which otherwise sends a tester looking for a prompt that will never
832+
// come.
833+
let granted = false;
834+
try {
835+
granted = await CumulusTunnel.requestPermission();
836+
} catch (e) {
837+
setState('error');
838+
setError(e instanceof Error ? e.message : 'VPN permission is required to connect.');
839+
return;
840+
}
818841
if (!granted) {
819842
setState('error');
820843
setError('VPN permission is required to connect.');
@@ -837,8 +860,11 @@ export function useVpn(): VpnModel & VpnActions {
837860
transportMode,
838861
tier: tierRef.current,
839862
gateways: availableGateways(),
840-
entryCountry: entryCode ?? autoEntry ?? null,
841-
exitCountry: exitCode,
863+
entryCountry: entry?.code ?? autoEntry ?? null,
864+
exitCountry: exit?.code ?? null,
865+
// City-level pins: only when the user picked a specific city row.
866+
entryIps: entry && entry.id.includes(':') ? entry.ips : null,
867+
exitIps: exit && exit.id.includes(':') ? exit.ips : null,
842868
gatewayIpRef,
843869
setEnrollment,
844870
killSwitch,
@@ -1122,14 +1148,14 @@ export function useVpn(): VpnModel & VpnActions {
11221148
await saveTransportMode(mode);
11231149
}, []);
11241150

1125-
const selectEntryCountry = useCallback(async (code: string | null): Promise<void> => {
1126-
setEntryCode(code);
1127-
await saveEntryCountry(code);
1151+
const selectEntryCountry = useCallback(async (id: string | null): Promise<void> => {
1152+
setEntryCode(id);
1153+
await saveEntryCountry(id);
11281154
}, []);
11291155

1130-
const selectExitCountry = useCallback(async (code: string | null): Promise<void> => {
1131-
setExitCode(code);
1132-
await saveExitCountry(code);
1156+
const selectExitCountry = useCallback(async (id: string | null): Promise<void> => {
1157+
setExitCode(id);
1158+
await saveExitCountry(id);
11331159
}, []);
11341160

11351161
const setKillSwitch = useCallback(async (enabled: boolean): Promise<void> => {
@@ -1337,6 +1363,10 @@ async function connectMultihop(args: {
13371363
gateways: readonly GatewayInfo[];
13381364
entryCountry: string | null;
13391365
exitCountry: string | null;
1366+
/** Gateway IPs of the chosen ENTRY city, or null for "anywhere in country". */
1367+
entryIps: readonly string[] | null;
1368+
/** Gateway IPs of the chosen EXIT city, or null. */
1369+
exitIps: readonly string[] | null;
13401370
gatewayIpRef: { current: string | null };
13411371
setEnrollment: (r: EnrollResponse) => void;
13421372
killSwitch: boolean;
@@ -1350,6 +1380,8 @@ async function connectMultihop(args: {
13501380
gateways,
13511381
entryCountry,
13521382
exitCountry,
1383+
entryIps,
1384+
exitIps,
13531385
gatewayIpRef,
13541386
setEnrollment,
13551387
killSwitch,
@@ -1365,9 +1397,22 @@ async function connectMultihop(args: {
13651397
hops = selectHops(gateways, routeStyle, {
13661398
...(entryCountry ? { entryCountry } : {}),
13671399
...(exitCountry ? { exitCountry } : {}),
1400+
...(entryIps ? { entryIps } : {}),
1401+
...(exitIps ? { exitIps } : {}),
13681402
...(requireDistinctSubnet ? { requireDistinctSubnet: true } : {}),
13691403
});
13701404
} catch (e) {
1405+
// Pinning cities narrows each hop to a handful of nodes, so an impossible
1406+
// pair is a normal outcome (one city, one node, used for both ends) rather
1407+
// than a fault. Say which knob to loosen instead of surfacing a raw
1408+
// selectHops error.
1409+
if (entryIps || exitIps) {
1410+
throw new Error(
1411+
requireDistinctSubnet
1412+
? 'No route between those cities. Pick a different city, choose the whole country instead, or turn off Node diversity in Settings.'
1413+
: 'No route between those cities — they may share the only available node. Pick a different city, or choose the whole country instead.',
1414+
);
1415+
}
13711416
if (requireDistinctSubnet) {
13721417
// Name where the setting actually lives — it is no longer on this screen,
13731418
// so "turn off Node diversity" alone would send the user hunting.

0 commit comments

Comments
 (0)