Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
52 changes: 52 additions & 0 deletions .github/workflows/ios.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
name: iOS

on:
push:
branches: [ios, main]
pull_request:
branches: [ios, main]

jobs:
build:
name: Build iOS app
runs-on: macos-26
steps:
- uses: actions/checkout@v4

- name: Select latest Xcode
run: sudo xcode-select -s /Applications/Xcode.app

- uses: actions/setup-go@v5
with:
go-version: '1.26'
cache-dependency-path: ios/XrayBridge/go.sum

# Xray-core compiled for iOS with gomobile. This is the app's only
# native dependency — no tun2socks, no sing-box.
- name: Cache XrayCore.xcframework
id: framework-cache
uses: actions/cache@v4
with:
path: ios/Frameworks
key: xraycore-${{ runner.os }}-${{ hashFiles('ios/XrayBridge/go.sum', 'ios/XrayBridge/xray.go') }}

- name: Build XrayCore.xcframework
if: steps.framework-cache.outputs.cache-hit != 'true'
run: Scripts/ios/build-xraycore.sh

# The iOS targets compile the same Models/ and Core/ sources as the
# desktop app, so keep the desktop build honest too.
- name: Fetch desktop core binaries
run: |
Scripts/fetch-xray.sh
Scripts/fetch-singbox.sh
Scripts/fetch-tun2socks.sh

- name: Test shared code
run: swift test

- name: Build for simulator
run: Scripts/ios/build-app.sh simulator Release

- name: Build for device (unsigned)
run: Scripts/ios/build-app.sh device Release
13 changes: 11 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,16 @@ Sources/XrayClient/Resources/tun2socks
*.dmg
dist/

# iOS: the Xray-core xcframework is built locally
# (Scripts/ios/build-xraycore.sh), never committed.
ios/Frameworks/
ios/build/

# Xcode
DerivedData/
*.xcuserdatad/
xcuserdata/
*.xcworkspace

# Editor
.vscode/
*.xcodeproj
*.xcworkspace
53 changes: 53 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,59 @@ A native macOS VPN client for the [Xray-core](https://github.com/XTLS/Xray-core)
- **Localized** in 12 languages: English, Русский, 中文, Español, हिन्दी, العربية, Français, Português, Deutsch, 日本語, Bahasa Indonesia, Türkçe.
- **Safe shutdown** — restores routes/DNS on quit and recovers from a crashed previous session so you're never left without internet.

## iOS

This branch also contains the iPhone/iPad app (`ios/`), built on Apple's
**NetworkExtension** so all traffic on the device is tunnelled. It ships
**Xray-core only** — no tun2socks and no second core: Xray's own layer-3 `tun`
inbound takes the utun descriptor straight from `NEPacketTunnelProvider`.

It shares its entire model and core layer with the Mac app (`Sources/XrayClient/`),
so parsers, config builder, subscriptions, routing and localization behave
identically on both platforms.

```bash
Scripts/ios/build-xraycore.sh # Xray-core -> XrayCore.xcframework
Scripts/ios/build-app.sh simulator Release
```

See **[docs/ios.md](docs/ios.md)** for the packet path, signing requirements and
the app↔extension protocol.

### Why there is no iOS build to download yet

The tunnel needs the `packet-tunnel-provider` Network Extension entitlement.
Apple only issues that entitlement through a **paid Apple Developer Program
membership** ($99/year) — a free personal team cannot provision it.

**Please don't try to sign the app with an ordinary certificate.** It will not
work, and the failure is confusing rather than obvious:

- Sign it with a free personal team and signing fails outright — the
entitlement is rejected, because entitlements have to be authorised by a
provisioning profile Apple issued.
- Strip the entitlement to make it build and the app installs and launches
fine, but the VPN never starts: `NETunnelProviderManager` refuses the
configuration, and you get an error with no obvious cause.

Re-signing tools that rely on free accounts (AltStore, Sideloadly and friends)
hit the same wall, for the same reason — none of them can grant an entitlement
Apple has not issued.

So the app is buildable from source by anyone who already has a paid account,
but there is no signed build to hand out until the membership is funded.

**Raising the money for the Apple Developer Program membership.** If you'd like
to help, TON:

```
UQDsbwQEaspICRDSW4oSNmL0PXxDlnfkiMuqoUbK7ufiCVXj
```

Nothing in the app is paywalled and none of this changes the licence — it is
MIT either way. The membership only buys the ability to ship a build that iOS
will actually run.

## Requirements

- macOS 14 (Sonoma) or later
Expand Down
62 changes: 62 additions & 0 deletions Scripts/ios/build-app.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
#!/usr/bin/env bash
#
# Builds the iOS app. Defaults to the simulator, which needs no signing;
# pass `device` to build for a real iPhone (requires a Team ID).
#
# Usage:
# Scripts/ios/build-app.sh # simulator, Debug
# Scripts/ios/build-app.sh simulator Release
# Scripts/ios/build-app.sh device Release TEAMID
#
set -euo pipefail

ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
PROJECT="$ROOT/ios/Veil.xcodeproj"
FRAMEWORK="$ROOT/ios/Frameworks/XrayCore.xcframework"

TARGET_KIND="${1:-simulator}"
CONFIGURATION="${2:-Debug}"
TEAM_ID="${3:-}"

if [[ ! -d "$FRAMEWORK" ]]; then
echo "error: $FRAMEWORK is missing."
echo " Run Scripts/ios/build-xraycore.sh first — the app cannot link without it."
exit 1
fi

case "$TARGET_KIND" in
simulator)
DESTINATION='generic/platform=iOS Simulator'
SDK=iphonesimulator
EXTRA=()
;;
device)
DESTINATION='generic/platform=iOS'
SDK=iphoneos
if [[ -n "$TEAM_ID" ]]; then
EXTRA=("DEVELOPMENT_TEAM=$TEAM_ID")
else
echo "note: no Team ID given — building without code signing."
echo " The result will not install on a device; pass your Team ID as arg 3."
EXTRA=(CODE_SIGNING_ALLOWED=NO)
fi
;;
*)
echo "usage: $0 [simulator|device] [Debug|Release] [TEAM_ID]"
exit 1
;;
esac

echo "==> building Veil ($TARGET_KIND, $CONFIGURATION)"
xcodebuild \
-project "$PROJECT" \
-scheme Veil \
-configuration "$CONFIGURATION" \
-sdk "$SDK" \
-destination "$DESTINATION" \
-derivedDataPath "$ROOT/ios/build" \
${EXTRA[@]+"${EXTRA[@]}"} \
build

echo
echo "==> done: $ROOT/ios/build/Build/Products/$CONFIGURATION-$SDK/Veil.app"
48 changes: 48 additions & 0 deletions Scripts/ios/build-xraycore.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
#!/usr/bin/env bash
#
# Builds XrayCore.xcframework — Xray-core compiled for iOS device + simulator
# via gomobile. This is the ONLY native dependency of the iOS app: no
# tun2socks, no sing-box. Xray's own `tun` inbound terminates the packets it
# gets from NetworkExtension.
#
# Usage: Scripts/ios/build-xraycore.sh
#
set -euo pipefail

ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
BRIDGE="$ROOT/ios/XrayBridge"
OUT_DIR="$ROOT/ios/Frameworks"
OUT="$OUT_DIR/XrayCore.xcframework"

command -v go >/dev/null || { echo "error: Go toolchain not found (brew install go)"; exit 1; }
command -v xcodebuild >/dev/null || { echo "error: Xcode not found"; exit 1; }

export PATH="$PATH:$(go env GOPATH)/bin"

if ! command -v gomobile >/dev/null; then
echo "==> installing gomobile"
go install golang.org/x/mobile/cmd/gomobile@latest
go install golang.org/x/mobile/cmd/gobind@latest
fi

echo "==> resolving Go modules"
cd "$BRIDGE"
go mod download

echo "==> gomobile init"
gomobile init

echo "==> building XrayCore.xcframework (device + simulator, arm64)"
mkdir -p "$OUT_DIR"
rm -rf "$OUT"
gomobile bind \
-target=ios,iossimulator \
-iosversion=16.0 \
-o "$OUT" \
-ldflags="-s -w" \
-trimpath \
.

echo
echo "==> done: $OUT"
du -sh "$OUT"
56 changes: 56 additions & 0 deletions Sources/XrayClient/Core/AddInput.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import Foundation

/// What the user just pasted, scanned or picked.
enum AddInput: Equatable {
/// One or more share links, or a wg-quick profile — nothing to fetch.
case servers([ProxyConfig])
/// A subscription URL that still has to be downloaded.
case subscription(String)
case unrecognized
}

/// Works out what a blob of pasted text actually is, so the UI can offer a
/// single "add" action instead of making the user classify it first.
///
/// Order matters: share links and subscription URLs live in disjoint schemes
/// (`vless://` … vs `http(s)://`), so links are tried first and a URL is only
/// considered once nothing parsed as a server.
enum AddInputClassifier {

static func classify(_ raw: String) -> AddInput {
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return .unrecognized }

// A wg-quick profile pasted whole.
if trimmed.lowercased().contains("[interface]"),
let wireguard = LinkParser.parseWireGuardConf(trimmed) {
return .servers([wireguard])
}

// Share links, one per line — or a whole base64-wrapped subscription
// body, which some users paste instead of the URL.
let servers = BalancerGrouper.group(SubscriptionFetcher.decode(trimmed))
if !servers.isEmpty { return .servers(servers) }

// A subscription URL, plain…
if let url = subscriptionURL(trimmed) { return .subscription(url) }

// …or base64-wrapped, the way some panels hand them out.
if let data = LinkParser.decodeBase64(trimmed),
let decoded = String(data: data, encoding: .utf8),
let url = subscriptionURL(decoded.trimmingCharacters(in: .whitespacesAndNewlines)) {
return .subscription(url)
}

return .unrecognized
}

private static func subscriptionURL(_ text: String) -> String? {
guard !text.contains(where: \.isNewline),
let url = URL(string: text),
let scheme = url.scheme?.lowercased(),
scheme == "http" || scheme == "https",
let host = url.host, !host.isEmpty else { return nil }
return text
}
}
16 changes: 8 additions & 8 deletions Sources/XrayClient/Core/BalancerGrouper.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,24 +9,24 @@ enum BalancerGrouper {
/// Groups servers by normalized name, protocol and auth key. Servers that do
/// not share a bucket are returned unchanged.
static func group(_ servers: [ProxyConfig]) -> [ProxyConfig] {
// Bucket order follows first appearance, not the dictionary's hash
// order — otherwise the server list reshuffles on every refresh.
var order: [String] = []
var buckets: [String: [ProxyConfig]] = [:]
for server in servers {
let key = groupKey(for: server)
if buckets[key] == nil { order.append(key) }
buckets[key, default: []].append(server)
}

var result: [ProxyConfig] = []
for group in buckets.values {
guard group.count > 1 else {
result.append(group[0])
continue
}
return order.map { key in
let group = buckets[key]!
guard group.count > 1 else { return group[0] }
var main = group[0]
main.name = baseName(main.name)
main.alternates = Array(group.dropFirst())
result.append(main)
return main
}
return result
}

private static func groupKey(for server: ProxyConfig) -> String {
Expand Down
9 changes: 8 additions & 1 deletion Sources/XrayClient/Core/ConnectionManager.swift
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import Foundation
import Observation
import Network
import AppKit

// The macOS coordinator drives an out-of-process core plus the system proxy or
// tun2socks. iOS has neither: there the tunnel lives in a NetworkExtension and
// is driven by `TunnelController` (ios/App), which speaks to the provider over
// NETunnelProviderSession. `ConnectionState` is shared by both.
enum ConnectionState: Equatable {
case disconnected
case connecting
Expand All @@ -19,6 +22,9 @@ enum ConnectionState: Equatable {
}
}

#if os(macOS)
import AppKit

/// Top-level coordinator: owns the xray process, the active transport mode
/// (system proxy or TUN), uptime tracking, and logs.
@MainActor
Expand Down Expand Up @@ -500,3 +506,4 @@ final class ConnectionManager {
}
}
}
#endif
Loading
Loading