Files
navi/ios/Shared/Hy2ConfigBuilder.swift
T
M4andCursor 7ceb3a5148
ci / test (macos-latest) (push) Waiting to run
ci / test (ubuntu-latest) (push) Waiting to run
ci / test (windows-latest) (push) Waiting to run
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>
2026-08-01 22:46:32 +03:00

127 lines
5.0 KiB
Swift

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
}
}