Conversation
Adds an iPhone/iPad client under ios/, built on NEPacketTunnelProvider so
every app on the device is tunnelled — not just proxy-aware ones.
The tunnel is Xray and nothing else. Xray-core v26 has a native layer-3
`tun` inbound with an iOS mode: when the `xray.tun.fd` environment flag is
set it adopts that descriptor instead of opening an interface itself. The
provider applies its network settings, finds the utun descriptor iOS just
created, and hands it straight to the core. No tun2socks, no local SOCKS
hop, no second core — packets go utun -> Xray -> server inside one process.
Shared code, not a fork. The iOS targets compile the very same
Sources/XrayClient/{Models,Core} files as the Mac app; platform-specific
pieces are behind `#if os(macOS)`. So parsers, config builder, routing
model, subscriptions, store, ping and localization cannot drift between
platforms, and `swift test` covers both.
Core changes:
- XrayConfigBuilder gains an inbound mode (local proxy vs. TUN), an
optional log file, a DNS section with a port-53 rule, traffic stats, and
a native WireGuard outbound.
- QRCode and DeviceID are now cross-platform; ServerStore and
GeoAssetManager use the shared app group container on iOS.
- ProxyConfig.xraySupported marks the protocols an Xray-only build can run.
Hysteria2/TUIC/AnyTLS need sing-box, so they are shown but not connectable.
iOS specifics:
- Switching servers does not reconnect the VPN: the app writes a new config
and sends a `reload` message, the provider restarts only Xray on the same
descriptor. Sub-second, and iOS never sees the tunnel drop.
- IPv6 is claimed by default so IPv6 traffic cannot route around the tunnel.
- NWPathMonitor rebuilds the core after Wi-Fi/cellular handovers and wake.
- Go heap capped at 48 MB — NetworkExtension processes are killed outright
when they exceed their budget.
ios/XrayBridge is the gomobile binding; Scripts/ios/build-xraycore.sh
produces the (gitignored, ~110 MB static) XrayCore.xcframework, and
Scripts/ios/build-app.sh builds the app. CI added in .github/workflows/ios.yml.
Verified: simulator and device builds are clean, 48 shared tests pass, and
the exact configs the app generates boot a real Xray server through a passed
in tun fd (`XRAY_TUN_FD=1 xray run -test`) for Reality, XHTTP, WireGuard and
Shadowsocks+ws.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The main screen offered "Add Subscription" and "Paste Link" side by side, which
made the user classify their own input before the app would take it. Now there
is a single "+" and a single sheet.
`AddInputClassifier` (shared Core, covered by tests) recognises share links one
per line, a wg-quick [Interface] profile, a subscription URL — plain or
base64-wrapped as some panels hand them out — and a base64 subscription body
pasted instead of its address. The sheet says what it found before you commit
("Subscription · panel.example.com", "2 servers detected") and keeps the confirm
button disabled for anything it can't place. The optional name field only
appears when the input is actually a subscription.
Also from checking the UI in the simulator:
- BalancerGrouper iterated a dictionary, so the server list reshuffled on every
refresh. Bucket order now follows first appearance. Affected macOS equally.
- NEVPNError.localizedDescription is developer-speak; "IPC failed" is what the
user saw when a tunnel refused to start. The actionable codes are now
translated, including the Simulator's (which has no VPN stack at all).
- The status card ate half the screen before a single server was visible;
tightened it.
- Labelled the DNS field in Settings — it was a bare, unexplained text row.
- The row badge for sing-box-only protocols said "Xray only", which reads as if
the server *is* supported. It now says "needs sing-box".
Verified in the iOS 27 simulator: list, empty state, add sheet in all three
detection states, settings, routing, QR, light and dark. 58 shared tests pass;
simulator and device builds are clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every iOS screen was a mix of Russian and English. Three separate causes:
1. 55 of the strings the iOS app passes through `loc(…)` were never added to
LocalizationTable. `Loc` falls back to the English key when a translation is
missing, so the gap is invisible in development and only shows up to a user
running the app in their own language.
2. The enum titles behind every picker — LogLevel, RoutingPreset (and its
subtitles), AppAppearance, RuleOutbound, GeoAssetSource — returned hardcoded
English and were never passed through `loc` at all, on either platform.
"Bypass LAN", "Warning", "Proxy/Direct/Block" were English for everyone.
3. Strings the system draws itself (Cancel, Edit, the share sheet, the photo
picker) stayed English regardless. Without .lproj bundles iOS treats the app
as English-only, so it never offers its own localizations. Declaring
CFBundleLocalizations fixes that; `EditButton` still follows the *device*
language rather than this app's setting, so routing uses its own button.
83 keys added across all 11 languages, plus the 2 macOS settings strings that
were also missing. LocalizationTests now scans Sources/XrayClient/Views and
ios/App for `loc("…")` calls and fails if any key is absent, if a row is
missing a language, or if a row has an unknown language code — this class of
bug should not be found by a user again.
Also:
- The empty-state Add button showed a bare word: the button style drops a
Label's icon unless the label style is stated explicitly.
- "Paste from clipboard" read UIPasteboard directly, which pops the system
"Allow Paste?" alert on every tap and looks broken when declined. Replaced
with a PasteButton, which needs no permission.
- Server counts are no longer interpolated into a translated noun; plural forms
differ per language, so the detection footer reads "Servers found: N".
Verified in the simulator with -AppleLanguages (ru): main list, add sheet,
settings and routing are fully Russian, including system-drawn controls. 61
tests pass; simulator and device builds are clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- The Connect/Disconnect button is gone; the shield itself is the control and fills a ring while held. Dropping the tunnel mid-session is disruptive, so a deliberate press beats a button that is easy to hit by accident. A caption under it says which way it goes, and it dims when the selected server is one the Xray-only build cannot run. - Subscription note, traffic and expiry moved from the section footer to the header. Underneath the last row they read as if they described that server. - The Test Ping button laid its icon out on a fixed column, leaving a gap before the title; it uses a plain HStack now. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The subscription name, note, traffic and expiry floated on the list background while everything around them sat on cards. They now share the same secondarySystemGroupedBackground card as the rows, aligned to the same insets, so a group reads as one object. Collapsing is animated: the state change runs inside withAnimation, and the chevron is a single glyph that rotates rather than two symbols swapping — the latter cannot tween. Tapping anywhere across the name toggles it now, with the overflow menu keeping its own hit area. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The separate backdrop read as a slab parked next to the server list rather than part of it. The heading is now the section's first row, so the list draws one rounded card around heading and servers together, with a separator between them — one object instead of two. The description is also no longer a wall of grey text: providers pad the Announce header with blank lines, which ate the whole preview (the first two lines were the title and an empty one), so blank lines are dropped and the note shows two lines with tap-to-expand. Traffic and expiry share one compact line with a 4pt bar drawn directly, since ProgressView's is too tall and too loud for a line of metadata. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two lines with tap-to-expand hid most of what the provider wrote. The note is now rendered whole and is selectable. Runs of blank lines still collapse to a single break — providers pad the Announce header with them, and the paragraphs read better without the dead space. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The tunnel needs the packet-tunnel-provider entitlement, which Apple only issues with a paid Developer Program membership. Worth spelling out, because both ways of trying to work around it fail confusingly: signing with a free personal team is rejected outright, and stripping the entitlement produces an app that installs and launches but whose VPN silently never starts. Re-signing tools built on free accounts hit the same wall. Adds a TON address for anyone who wants to help fund the membership, and notes that nothing is paywalled and the licence stays MIT either way. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
No description provided.