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,126 @@
|
||||
import Foundation
|
||||
|
||||
/// Mirrors internal/protocols/hysteria2 config_write.go + endpoint.go (SOCKS 1080 / HTTP 1081).
|
||||
enum Hy2ConfigBuilder {
|
||||
struct Endpoint {
|
||||
var server: String
|
||||
var auth: String = ""
|
||||
var sni: String = ""
|
||||
var insecure: Bool = false
|
||||
var pinSHA256: String = ""
|
||||
var obfs: String = ""
|
||||
var obfsPassword: String = ""
|
||||
}
|
||||
|
||||
static func buildYAML(uri: String, socks: String = "127.0.0.1:1080", http: String = "127.0.0.1:1081") throws -> String {
|
||||
let ep = try parseEndpoint(uri)
|
||||
var b = "# generated by Navis — do not edit\n"
|
||||
b += "server: \(yamlQuote(ep.server))\n"
|
||||
if !ep.auth.isEmpty {
|
||||
b += "auth: \(yamlQuote(ep.auth))\n"
|
||||
}
|
||||
if !ep.sni.isEmpty || ep.insecure || !ep.pinSHA256.isEmpty {
|
||||
b += "tls:\n"
|
||||
if !ep.sni.isEmpty {
|
||||
b += " sni: \(yamlQuote(ep.sni))\n"
|
||||
}
|
||||
if ep.insecure {
|
||||
b += " insecure: true\n"
|
||||
}
|
||||
if !ep.pinSHA256.isEmpty {
|
||||
b += " pinSHA256: \(yamlQuote(ep.pinSHA256))\n"
|
||||
}
|
||||
}
|
||||
let obfs = ep.obfs.lowercased()
|
||||
switch obfs {
|
||||
case "salamander":
|
||||
if ep.obfsPassword.isEmpty {
|
||||
throw BuilderError.message("hysteria2: salamander требует пароль (obfs-password)")
|
||||
}
|
||||
b += "obfs:\n type: salamander\n salamander:\n password: \(yamlQuote(ep.obfsPassword))\n"
|
||||
case "gecko":
|
||||
if ep.obfsPassword.isEmpty {
|
||||
throw BuilderError.message("hysteria2: gecko требует пароль (obfs-password)")
|
||||
}
|
||||
b += "obfs:\n type: gecko\n gecko:\n password: \(yamlQuote(ep.obfsPassword))\n"
|
||||
default:
|
||||
break
|
||||
}
|
||||
// Default BBR like desktop when no bandwidth overrides.
|
||||
b += "congestion:\n type: bbr\n bbrProfile: standard\n"
|
||||
b += "socks5:\n listen: \(yamlQuote(socks))\n"
|
||||
b += "http:\n listen: \(yamlQuote(http))\n"
|
||||
return b
|
||||
}
|
||||
|
||||
static func parseEndpoint(_ raw: String) throws -> Endpoint {
|
||||
var s = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if let hash = s.firstIndex(of: "#") {
|
||||
s = String(s[..<hash])
|
||||
}
|
||||
let lower = s.lowercased()
|
||||
if lower.hasPrefix("hy2://") {
|
||||
s = "hysteria2://" + String(s.dropFirst("hy2://".count))
|
||||
}
|
||||
guard lower.hasPrefix("hysteria2://") || s.lowercased().hasPrefix("hysteria2://") else {
|
||||
throw BuilderError.message("ожидалась ссылка hysteria2:// или hy2://")
|
||||
}
|
||||
guard let u = URLComponents(string: s) else {
|
||||
throw BuilderError.message("parse hysteria2 URI")
|
||||
}
|
||||
guard let host = u.host, !host.isEmpty else {
|
||||
throw BuilderError.message("hysteria2 URI missing host")
|
||||
}
|
||||
var server = host
|
||||
if let port = u.port {
|
||||
server = "\(host):\(port)"
|
||||
} else if let h = u.percentEncodedHost ?? u.host {
|
||||
// URLComponents may put multi-port in host; prefer raw host field from string.
|
||||
if let at = s.range(of: "://") {
|
||||
var rest = String(s[at.upperBound...])
|
||||
if let slash = rest.firstIndex(of: "/") { rest = String(rest[..<slash]) }
|
||||
if let q = rest.firstIndex(of: "?") { rest = String(rest[..<q]) }
|
||||
if let atSign = rest.firstIndex(of: "@") {
|
||||
rest = String(rest[rest.index(after: atSign)...])
|
||||
}
|
||||
if !rest.isEmpty { server = rest }
|
||||
}
|
||||
}
|
||||
|
||||
var auth = ""
|
||||
if let user = u.user {
|
||||
if let pass = u.password {
|
||||
auth = "\(user):\(pass)"
|
||||
} else {
|
||||
auth = user.removingPercentEncoding ?? user
|
||||
}
|
||||
}
|
||||
let q = Dictionary(uniqueKeysWithValues: (u.queryItems ?? []).map { ($0.name, $0.value ?? "") })
|
||||
var ep = Endpoint(server: server, auth: auth)
|
||||
ep.sni = first(q, "sni", "peer")
|
||||
ep.pinSHA256 = first(q, "pinSHA256", "pinsha256")
|
||||
ep.obfs = first(q, "obfs")
|
||||
ep.obfsPassword = first(q, "obfs-password", "obfs_password")
|
||||
let insc = first(q, "insecure")
|
||||
ep.insecure = insc == "1" || insc.lowercased() == "true"
|
||||
return ep
|
||||
}
|
||||
|
||||
private static func first(_ q: [String: String], _ keys: String...) -> String {
|
||||
for k in keys {
|
||||
if let v = q[k], !v.trimmingCharacters(in: .whitespaces).isEmpty {
|
||||
return v.trimmingCharacters(in: .whitespaces)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
private static func yamlQuote(_ s: String) -> String {
|
||||
if s.isEmpty { return "\"\"" }
|
||||
if s.contains(where: { ":#{}[],&*?|-<>=!%@`'\"".contains($0) || $0.isWhitespace }) {
|
||||
let escaped = s.replacingOccurrences(of: "\\", with: "\\\\").replacingOccurrences(of: "\"", with: "\\\"")
|
||||
return "\"\(escaped)\""
|
||||
}
|
||||
return s
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user