Release 3.8.2.13: iOS Packet Tunnel client and Clash URL subscriptions.
Add SwiftUI + Network Extension (LibXray/hev), parse Clash Meta YAML from subscription URLs into vless/trojan/hy2 share links, keep the selected server on connect, and ship Navis-3.8.2.13.ipa with versioned changelog. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,32 @@
|
||||
import Foundation
|
||||
import HevSocks5Tunnel
|
||||
|
||||
/// Bridges NE utun fd ↔ local SOCKS (macOS sysproxy replacement).
|
||||
enum HevBridge {
|
||||
static func writeConfig(socksHost: String = "127.0.0.1", socksPort: Int = 1080, to url: URL) throws {
|
||||
let yaml = """
|
||||
tunnel:
|
||||
mtu: 1400
|
||||
socks5:
|
||||
address: \(socksHost)
|
||||
port: \(socksPort)
|
||||
udp: 'udp'
|
||||
misc:
|
||||
task-stack-size: 24576
|
||||
connect-timeout: 5000
|
||||
read-write-timeout: 60000
|
||||
log-level: warn
|
||||
"""
|
||||
try yaml.write(to: url, atomically: true, encoding: .utf8)
|
||||
}
|
||||
|
||||
/// Blocks until quit — call on a background thread.
|
||||
@discardableResult
|
||||
static func runBlocking(configPath: String, tunFd: Int32) -> Int32 {
|
||||
hev_socks5_tunnel_main(configPath, tunFd)
|
||||
}
|
||||
|
||||
static func quit() {
|
||||
hev_socks5_tunnel_quit()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import Foundation
|
||||
|
||||
/// Hysteria2 in-process runner.
|
||||
/// Uses NavisCore when linked; otherwise returns a clear error (cannot mix a second Go runtime with LibXray).
|
||||
enum Hy2CoreRunner {
|
||||
private static var running = false
|
||||
|
||||
static func start(configPath: String, workDir: String) throws {
|
||||
#if canImport(NavisCore)
|
||||
NavisCoreSetWorkDir(workDir)
|
||||
if let yaml = try? String(contentsOfFile: configPath, encoding: .utf8) {
|
||||
var error: NSError?
|
||||
// Generated gomobile name may vary; keep a stable Swift wrapper once NavisCore is produced.
|
||||
_ = yaml
|
||||
_ = error
|
||||
}
|
||||
throw TunnelError.message("Hysteria2: NavisCore.StartHy2YAML ещё не подключён в этой сборке")
|
||||
#else
|
||||
// LibXray already occupies the Go runtime in this appex.
|
||||
// Unified NavisCore (Xray+Hy2) is the path forward; until then ask users for Xray links.
|
||||
_ = configPath
|
||||
_ = workDir
|
||||
running = false
|
||||
throw TunnelError.message(
|
||||
"Hysteria2: в этой iOS-сборке активен LibXray (VLESS/VMess/Trojan). Hy2 требует единый NavisCore — пока используйте Xray-ссылку."
|
||||
)
|
||||
#endif
|
||||
}
|
||||
|
||||
static func stop() {
|
||||
#if canImport(NavisCore)
|
||||
_ = NavisCoreStop()
|
||||
#endif
|
||||
running = false
|
||||
LibXrayCore.stop() // no-op if not xray
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>NavisTunnel</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>XPC!</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>3.8.2</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>3.8.2.13</string>
|
||||
<key>NSExtension</key>
|
||||
<dict>
|
||||
<key>NSExtensionPointIdentifier</key>
|
||||
<string>com.apple.networkextension.packet-tunnel</string>
|
||||
<key>NSExtensionPrincipalClass</key>
|
||||
<string>$(PRODUCT_MODULE_NAME).PacketTunnelProvider</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,85 @@
|
||||
import Foundation
|
||||
import LibXray
|
||||
|
||||
/// Thin wrapper around LibXray.Invoke (macOS Manager engine.Start for Xray).
|
||||
enum LibXrayCore {
|
||||
struct InvokeError: LocalizedError {
|
||||
var message: String
|
||||
var errorDescription: String? { message }
|
||||
}
|
||||
|
||||
static func runJSON(_ configJSON: String) throws {
|
||||
let payload: [String: Any] = [
|
||||
"apiVersion": 1,
|
||||
"method": "runXrayFromJson",
|
||||
"payload": ["configJSON": configJSON],
|
||||
]
|
||||
try invoke(payload)
|
||||
}
|
||||
|
||||
static func runFile(_ configPath: String) throws {
|
||||
let payload: [String: Any] = [
|
||||
"apiVersion": 1,
|
||||
"method": "runXray",
|
||||
"payload": ["configPath": configPath],
|
||||
]
|
||||
try invoke(payload)
|
||||
}
|
||||
|
||||
static func stop() {
|
||||
let payload: [String: Any] = [
|
||||
"apiVersion": 1,
|
||||
"method": "stopXray",
|
||||
"payload": [:] as [String: Any],
|
||||
]
|
||||
_ = try? invoke(payload)
|
||||
}
|
||||
|
||||
static func isRunning() -> Bool {
|
||||
let payload: [String: Any] = [
|
||||
"apiVersion": 1,
|
||||
"method": "getXrayState",
|
||||
"payload": [:] as [String: Any],
|
||||
]
|
||||
guard let data = try? invokeData(payload),
|
||||
let running = data["running"] as? Bool
|
||||
else { return false }
|
||||
return running
|
||||
}
|
||||
|
||||
static func version() -> String {
|
||||
let payload: [String: Any] = [
|
||||
"apiVersion": 1,
|
||||
"method": "xrayVersion",
|
||||
"payload": [:] as [String: Any],
|
||||
]
|
||||
guard let data = try? invokeData(payload),
|
||||
let v = data["version"] as? String
|
||||
else { return "?" }
|
||||
return v
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
private static func invoke(_ payload: [String: Any]) throws -> [String: Any]? {
|
||||
let data = try invokeData(payload)
|
||||
return data
|
||||
}
|
||||
|
||||
private static func invokeData(_ payload: [String: Any]) throws -> [String: Any]? {
|
||||
let reqData = try JSONSerialization.data(withJSONObject: payload)
|
||||
guard let req = String(data: reqData, encoding: .utf8) else {
|
||||
throw InvokeError(message: "encode invoke request")
|
||||
}
|
||||
let respStr = LibXrayInvoke(req)
|
||||
guard let respData = respStr.data(using: .utf8),
|
||||
let obj = try JSONSerialization.jsonObject(with: respData) as? [String: Any]
|
||||
else {
|
||||
throw InvokeError(message: "decode invoke response")
|
||||
}
|
||||
let ok = obj["success"] as? Bool ?? false
|
||||
if !ok {
|
||||
throw InvokeError(message: (obj["error"] as? String) ?? "LibXray error")
|
||||
}
|
||||
return obj["data"] as? [String: Any]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.developer.networking.networkextension</key>
|
||||
<array>
|
||||
<string>packet-tunnel-provider</string>
|
||||
</array>
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>group.win.evilfox.navis</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,269 @@
|
||||
import Darwin
|
||||
import Foundation
|
||||
import NetworkExtension
|
||||
import os.log
|
||||
|
||||
class PacketTunnelProvider: NEPacketTunnelProvider {
|
||||
private let log = Logger(subsystem: "win.evilfox.navis.tunnel", category: "tunnel")
|
||||
private let store = ProfileStore()
|
||||
private var hevQueue: DispatchQueue?
|
||||
private var coreKind: String?
|
||||
|
||||
override func startTunnel(options: [String: NSObject]?, completionHandler: @escaping (Error?) -> Void) {
|
||||
log.info("startTunnel")
|
||||
store.setTunnelStatus("starting")
|
||||
|
||||
let resolved = resolveEndpoint(options: options)
|
||||
let name = resolved.name
|
||||
let uri = resolved.uri
|
||||
if uri.isEmpty {
|
||||
fail(TunnelError.message("нет URI — выберите сервер и Подключить снова"), completionHandler)
|
||||
return
|
||||
}
|
||||
|
||||
// Persist what we actually use (helps UI + next start).
|
||||
store.writeTunnelConfig(name: name, uri: uri)
|
||||
|
||||
let proto = Proto.detect(uri)
|
||||
switch proto {
|
||||
case .vless, .vmess, .trojan:
|
||||
break
|
||||
case .hy2:
|
||||
fail(TunnelError.message("Hysteria2 на iOS пока недоступен — выберите VLESS/VMess/Trojan"), completionHandler)
|
||||
return
|
||||
case .naive:
|
||||
fail(TunnelError.message("NaiveProxy на iOS недоступен (Chromium-ядро)"), completionHandler)
|
||||
return
|
||||
case .awg:
|
||||
fail(TunnelError.message("AmneziaWG на iOS — следующим этапом"), completionHandler)
|
||||
return
|
||||
case .unknown:
|
||||
fail(TunnelError.message("Неизвестный протокол"), completionHandler)
|
||||
return
|
||||
}
|
||||
|
||||
// Critical order: start local SOCKS core BEFORE installing full tunnel routes.
|
||||
// Otherwise traffic to the proxy server is sucked into utun and never reaches the internet.
|
||||
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
|
||||
guard let self else {
|
||||
completionHandler(TunnelError.message("tunnel deallocated"))
|
||||
return
|
||||
}
|
||||
do {
|
||||
try self.startCore(uri: uri, proto: proto)
|
||||
try self.waitLocalPort(1080, timeoutMs: 15_000)
|
||||
|
||||
let serverHost = LinkParser.hostOf(uri)
|
||||
let serverIP = Self.resolveIPv4(serverHost)
|
||||
let settings = Self.makeNetworkSettings(excludeHostOrIP: serverIP ?? serverHost)
|
||||
|
||||
let lock = DispatchSemaphore(value: 0)
|
||||
var settingsError: Error?
|
||||
self.setTunnelNetworkSettings(settings) { err in
|
||||
settingsError = err
|
||||
lock.signal()
|
||||
}
|
||||
lock.wait()
|
||||
if let settingsError {
|
||||
throw settingsError
|
||||
}
|
||||
|
||||
try self.startHev()
|
||||
self.store.setTunnelStatus("connected")
|
||||
self.log.info("tunnel up name=\(name, privacy: .public) proto=\(proto.label, privacy: .public) uriHost=\(serverHost, privacy: .public)")
|
||||
completionHandler(nil)
|
||||
} catch {
|
||||
self.log.error("start failed: \(error.localizedDescription, privacy: .public)")
|
||||
self.teardownCore()
|
||||
self.store.setTunnelStatus("error", error: error.localizedDescription)
|
||||
completionHandler(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override func stopTunnel(with reason: NEProviderStopReason, completionHandler: @escaping () -> Void) {
|
||||
log.info("stopTunnel reason=\(String(describing: reason), privacy: .public)")
|
||||
teardownCore()
|
||||
store.setTunnelStatus("stopped")
|
||||
store.clearTunnelConfig()
|
||||
completionHandler()
|
||||
}
|
||||
|
||||
/// Prefer: start options → providerConfiguration → App Group file/defaults.
|
||||
private func resolveEndpoint(options: [String: NSObject]?) -> (name: String, uri: String) {
|
||||
if let uri = options?["uri"] as? String, !uri.isEmpty {
|
||||
let name = (options?["name"] as? String).flatMap { $0.isEmpty ? nil : $0 } ?? "Navis"
|
||||
return (name, uri)
|
||||
}
|
||||
if let proto = protocolConfiguration as? NETunnelProviderProtocol,
|
||||
let cfg = proto.providerConfiguration
|
||||
{
|
||||
let uri = (cfg["uri"] as? String) ?? ""
|
||||
if !uri.isEmpty {
|
||||
let name = (cfg["name"] as? String).flatMap { $0.isEmpty ? nil : $0 } ?? "Navis"
|
||||
return (name, uri)
|
||||
}
|
||||
}
|
||||
if let file = store.readTunnelConfigFile() {
|
||||
return (file.name, file.uri)
|
||||
}
|
||||
if let cfg = store.readTunnelConfig() {
|
||||
return (cfg.name, cfg.uri)
|
||||
}
|
||||
return ("Navis", "")
|
||||
}
|
||||
|
||||
private func startCore(uri: String, proto: Proto) throws {
|
||||
let work = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: AppConstants.appGroupID)
|
||||
?? FileManager.default.temporaryDirectory
|
||||
let runtime = work.appendingPathComponent("runtime", isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: runtime, withIntermediateDirectories: true)
|
||||
|
||||
switch proto {
|
||||
case .vless, .vmess, .trojan:
|
||||
let json = try XrayConfigBuilder.buildJSON(uri: uri, socksPort: 1080, httpPort: 1081)
|
||||
let cfgURL = runtime.appendingPathComponent("xray.json")
|
||||
try json.write(to: cfgURL, atomically: true, encoding: .utf8)
|
||||
// Stop any previous instance before starting.
|
||||
LibXrayCore.stop()
|
||||
try LibXrayCore.runFile(cfgURL.path)
|
||||
coreKind = "xray"
|
||||
case .hy2:
|
||||
let yaml = try Hy2ConfigBuilder.buildYAML(uri: uri)
|
||||
let cfgURL = runtime.appendingPathComponent("hy2.yaml")
|
||||
try yaml.write(to: cfgURL, atomically: true, encoding: .utf8)
|
||||
try Hy2CoreRunner.start(configPath: cfgURL.path, workDir: runtime.path)
|
||||
coreKind = "hy2"
|
||||
default:
|
||||
throw TunnelError.message("протокол не поддержан в tunnel")
|
||||
}
|
||||
}
|
||||
|
||||
private func startHev() throws {
|
||||
let work = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: AppConstants.appGroupID)
|
||||
?? FileManager.default.temporaryDirectory
|
||||
let runtime = work.appendingPathComponent("runtime", isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: runtime, withIntermediateDirectories: true)
|
||||
|
||||
guard let tunFd = resolveTunnelFileDescriptor(), tunFd >= 0 else {
|
||||
throw TunnelError.message("не удалось получить utun fd для hev")
|
||||
}
|
||||
|
||||
let hevCfg = runtime.appendingPathComponent("hev.yml")
|
||||
try HevBridge.writeConfig(to: hevCfg)
|
||||
|
||||
let queue = DispatchQueue(label: "win.evilfox.navis.hev", qos: .userInitiated)
|
||||
hevQueue = queue
|
||||
queue.async {
|
||||
_ = HevBridge.runBlocking(configPath: hevCfg.path, tunFd: tunFd)
|
||||
}
|
||||
Thread.sleep(forTimeInterval: 0.35)
|
||||
}
|
||||
|
||||
private func teardownCore() {
|
||||
HevBridge.quit()
|
||||
switch coreKind {
|
||||
case "xray":
|
||||
LibXrayCore.stop()
|
||||
case "hy2":
|
||||
Hy2CoreRunner.stop()
|
||||
default:
|
||||
LibXrayCore.stop()
|
||||
Hy2CoreRunner.stop()
|
||||
}
|
||||
coreKind = nil
|
||||
}
|
||||
|
||||
private func fail(_ error: Error, _ completionHandler: @escaping (Error?) -> Void) {
|
||||
store.setTunnelStatus("error", error: error.localizedDescription)
|
||||
completionHandler(error)
|
||||
}
|
||||
|
||||
private func waitLocalPort(_ port: Int, timeoutMs: Int) throws {
|
||||
let deadline = Date().addingTimeInterval(Double(timeoutMs) / 1000.0)
|
||||
while Date() < deadline {
|
||||
if tcpConnectOK(port: port) { return }
|
||||
Thread.sleep(forTimeInterval: 0.15)
|
||||
}
|
||||
throw TunnelError.message("локальный SOCKS :\(port) не поднялся (проверьте ссылку VLESS/Trojan)")
|
||||
}
|
||||
|
||||
private func tcpConnectOK(port: Int) -> Bool {
|
||||
let fd = socket(AF_INET, SOCK_STREAM, 0)
|
||||
guard fd >= 0 else { return false }
|
||||
defer { close(fd) }
|
||||
var addr = sockaddr_in()
|
||||
addr.sin_len = UInt8(MemoryLayout<sockaddr_in>.size)
|
||||
addr.sin_family = sa_family_t(AF_INET)
|
||||
addr.sin_port = in_port_t(UInt16(port).bigEndian)
|
||||
addr.sin_addr = in_addr(s_addr: inet_addr("127.0.0.1"))
|
||||
let result = withUnsafePointer(to: &addr) {
|
||||
$0.withMemoryRebound(to: sockaddr.self, capacity: 1) {
|
||||
connect(fd, $0, socklen_t(MemoryLayout<sockaddr_in>.size))
|
||||
}
|
||||
}
|
||||
return result == 0
|
||||
}
|
||||
|
||||
private static func makeNetworkSettings(excludeHostOrIP: String) -> NEPacketTunnelNetworkSettings {
|
||||
let settings = NEPacketTunnelNetworkSettings(tunnelRemoteAddress: "127.0.0.1")
|
||||
let ipv4 = NEIPv4Settings(addresses: ["10.8.0.2"], subnetMasks: ["255.255.255.255"])
|
||||
ipv4.includedRoutes = [NEIPv4Route.default()]
|
||||
|
||||
var excluded: [NEIPv4Route] = [
|
||||
NEIPv4Route(destinationAddress: "127.0.0.0", subnetMask: "255.0.0.0"),
|
||||
// Private LAN — do not claim (and avoid eating the tunnel address space unnecessarily).
|
||||
NEIPv4Route(destinationAddress: "10.0.0.0", subnetMask: "255.0.0.0"),
|
||||
NEIPv4Route(destinationAddress: "172.16.0.0", subnetMask: "255.240.0.0"),
|
||||
NEIPv4Route(destinationAddress: "192.168.0.0", subnetMask: "255.255.0.0"),
|
||||
]
|
||||
// Keep path to proxy server outside the tunnel (prevents routing loop).
|
||||
if !excludeHostOrIP.isEmpty, looksLikeIPv4(excludeHostOrIP) {
|
||||
excluded.append(NEIPv4Route(destinationAddress: excludeHostOrIP, subnetMask: "255.255.255.255"))
|
||||
}
|
||||
ipv4.excludedRoutes = excluded
|
||||
settings.ipv4Settings = ipv4
|
||||
settings.dnsSettings = NEDNSSettings(servers: ["1.1.1.1", "8.8.8.8"])
|
||||
settings.mtu = 1400
|
||||
return settings
|
||||
}
|
||||
|
||||
private static func looksLikeIPv4(_ s: String) -> Bool {
|
||||
var addr = in_addr()
|
||||
return s.withCString { inet_pton(AF_INET, $0, &addr) == 1 }
|
||||
}
|
||||
|
||||
private static func resolveIPv4(_ host: String) -> String? {
|
||||
let trimmed = host.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if trimmed.isEmpty { return nil }
|
||||
if looksLikeIPv4(trimmed) { return trimmed }
|
||||
|
||||
var hints = addrinfo(
|
||||
ai_flags: AI_ADDRCONFIG,
|
||||
ai_family: AF_INET,
|
||||
ai_socktype: SOCK_STREAM,
|
||||
ai_protocol: 0,
|
||||
ai_addrlen: 0,
|
||||
ai_canonname: nil,
|
||||
ai_addr: nil,
|
||||
ai_next: nil
|
||||
)
|
||||
var res: UnsafeMutablePointer<addrinfo>?
|
||||
let rc = getaddrinfo(trimmed, nil, &hints, &res)
|
||||
guard rc == 0, let first = res else { return nil }
|
||||
defer { freeaddrinfo(res) }
|
||||
var addr = first.pointee.ai_addr.withMemoryRebound(to: sockaddr_in.self, capacity: 1) { $0.pointee }
|
||||
var buf = [CChar](repeating: 0, count: Int(INET_ADDRSTRLEN))
|
||||
guard inet_ntop(AF_INET, &addr.sin_addr, &buf, socklen_t(INET_ADDRSTRLEN)) != nil else { return nil }
|
||||
return String(cString: buf)
|
||||
}
|
||||
}
|
||||
|
||||
enum TunnelError: LocalizedError {
|
||||
case message(String)
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .message(let s): return s
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import Foundation
|
||||
import Darwin
|
||||
import NetworkExtension
|
||||
|
||||
enum TunFileDescriptor {
|
||||
/// Locate the utun socket used by NEPacketTunnelProvider (unsupported but widely used).
|
||||
static func find() -> Int32? {
|
||||
if let fd = packetFlowKVO() {
|
||||
return fd
|
||||
}
|
||||
return findUtunByGetsockopt()
|
||||
}
|
||||
|
||||
private static func packetFlowKVO() -> Int32? {
|
||||
// Caller may set via associated object; left for PacketTunnelProvider to try first.
|
||||
nil
|
||||
}
|
||||
|
||||
private static func findUtunByGetsockopt() -> Int32? {
|
||||
var buf = [CChar](repeating: 0, count: Int(IFNAMSIZ))
|
||||
let prefix = Array("utun".utf8CString.dropLast())
|
||||
for fd: Int32 in 0...1024 {
|
||||
var len = socklen_t(buf.count)
|
||||
// SOL_LOCAL=0 / SYSPROTO_CONTROL path varies; use getsockopt(fd, 2, 2) like WireGuard/others.
|
||||
if getsockopt(fd, 2, 2, &buf, &len) == 0 {
|
||||
if buf.starts(with: prefix) {
|
||||
return fd
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
extension NEPacketTunnelProvider {
|
||||
func resolveTunnelFileDescriptor() -> Int32? {
|
||||
if let fd = packetFlow.value(forKeyPath: "socket.fileDescriptor") as? Int32, fd >= 0 {
|
||||
return fd
|
||||
}
|
||||
return TunFileDescriptor.find()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user