release: 1.4.4 — channel «Облако» (WebSocket via KeenDNS cloud to own Keenetic): wsgw gateway, Android 1.4.4, iOS, docs/cloud.md
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -11,6 +11,7 @@ struct ContentView: View {
|
||||
ScrollView {
|
||||
VStack(spacing: 18) {
|
||||
profileCard
|
||||
if vpn.servers.count > 1 { serversCard }
|
||||
powerButton
|
||||
statusLine
|
||||
optionsCard
|
||||
@@ -57,6 +58,30 @@ struct ContentView: View {
|
||||
.padding().background(.thinMaterial, in: RoundedRectangle(cornerRadius: 16))
|
||||
}
|
||||
|
||||
private var serversCard: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Text("Серверы").font(.subheadline.bold()).padding(.bottom, 6)
|
||||
ForEach(vpn.servers, id: \.self) { s in
|
||||
let n = s["name"] ?? ""
|
||||
HStack {
|
||||
Image(systemName: n == vpn.name ? "checkmark.circle.fill" : "circle")
|
||||
.foregroundStyle(n == vpn.name ? .green : .secondary)
|
||||
Text(n)
|
||||
Spacer()
|
||||
if n != vpn.name {
|
||||
Button(role: .destructive) { vpn.remove(n) } label: { Image(systemName: "trash") }
|
||||
.buttonStyle(.borderless)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture { vpn.select(n) }
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding().background(.thinMaterial, in: RoundedRectangle(cornerRadius: 16))
|
||||
}
|
||||
|
||||
private var powerButton: some View {
|
||||
Button(action: vpn.toggle) {
|
||||
Text(vpn.isOn ? "Отключить" : "Подключить")
|
||||
@@ -85,9 +110,16 @@ struct ContentView: View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("Канал до сервера").font(.subheadline.bold())
|
||||
Picker("Канал", selection: $vpn.transportMode) {
|
||||
Text("Авто").tag("auto"); Text("UDP").tag("udp"); Text("TCP").tag("tcp")
|
||||
Text("Авто").tag("auto"); Text("UDP").tag("udp"); Text("TCP").tag("tcp"); Text("Облако").tag("cloud")
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
if vpn.transportMode == "cloud" {
|
||||
TextField("wss://phobos.имя.keenetic.pro/", text: $vpn.wsURL)
|
||||
.textInputAutocapitalization(.never).autocorrectionDisabled().keyboardType(.URL)
|
||||
.textFieldStyle(.roundedBorder).onSubmit { vpn.wsURL = vpn.wsURL.trimmingCharacters(in: .whitespaces) }
|
||||
Text("Облако: телефон → облако KeenDNS → ваш Keenetic → сервер. Работает, когда до сервера напрямую не достучаться.")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
Text("Авто: если UDP режут, через 15 секунд переходит на TCP. Изменения применяются сразу.")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0</string>
|
||||
<string>$(MARKETING_VERSION)</string>
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
@@ -30,9 +30,13 @@
|
||||
</dict>
|
||||
</array>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1</string>
|
||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||
<key>ITSAppUsesNonExemptEncryption</key>
|
||||
<false/>
|
||||
<key>UILaunchScreen</key>
|
||||
<dict/>
|
||||
<key>UIRequiresFullScreen</key>
|
||||
<true/>
|
||||
<key>UISupportedInterfaceOrientations</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
|
||||
@@ -21,6 +21,10 @@ final class VPNController: ObservableObject {
|
||||
@Published var conf = UserDefaults.standard.string(forKey: "conf") ?? ""
|
||||
@Published var ruDirect = UserDefaults.standard.object(forKey: "ruDirect") as? Bool ?? true { didSet { saveOptions() } }
|
||||
@Published var transportMode = UserDefaults.standard.string(forKey: "transport") ?? "auto" { didSet { saveOptions() } }
|
||||
/// Channel «Облако»: wss:// of the user's Keenetic (empty -> "# pca-ws" from the link).
|
||||
@Published var wsURL = UserDefaults.standard.string(forKey: "wsURL") ?? "" { didSet { saveOptions() } }
|
||||
/// Imported servers: [["name": …, "conf": …]]; name/conf above mirror the selected one.
|
||||
@Published var servers = UserDefaults.standard.array(forKey: "servers") as? [[String: String]] ?? []
|
||||
@Published var status: NEVPNStatus = .invalid
|
||||
@Published var transport = ""
|
||||
@Published var message = ""
|
||||
@@ -29,6 +33,7 @@ final class VPNController: ObservableObject {
|
||||
private var timer: Timer?
|
||||
|
||||
init() {
|
||||
if servers.isEmpty && !conf.isEmpty { servers = [["name": name, "conf": conf]] } // pre-list single profile
|
||||
Task { await load() }
|
||||
NotificationCenter.default.addObserver(forName: .NEVPNStatusDidChange, object: nil, queue: .main) { [weak self] _ in
|
||||
Task { @MainActor in self?.status = self?.manager?.connection.status ?? .invalid }
|
||||
@@ -49,17 +54,40 @@ final class VPNController: ObservableObject {
|
||||
|
||||
func importLink(_ link: String) {
|
||||
guard let parsed = PhobosProfile.fromLink(link) else { message = "Это не ссылка phobos:// из панели"; return }
|
||||
conf = parsed.profile.raw
|
||||
name = parsed.name
|
||||
servers.removeAll { $0["name"] == parsed.name } // same name = updated link
|
||||
servers.append(["name": parsed.name, "conf": parsed.profile.raw])
|
||||
select(parsed.name)
|
||||
message = "Профиль «\(name)» импортирован"
|
||||
}
|
||||
|
||||
func select(_ server: String) {
|
||||
guard let s = servers.first(where: { $0["name"] == server }) else { return }
|
||||
name = server
|
||||
conf = s["conf"] ?? ""
|
||||
persist()
|
||||
Task { await save(reconnect: isOn) }
|
||||
}
|
||||
|
||||
func remove(_ server: String) {
|
||||
servers.removeAll { $0["name"] == server }
|
||||
if server == name {
|
||||
if let next = servers.first?["name"] { select(next); return }
|
||||
if isOn { manager?.connection.stopVPNTunnel() }
|
||||
name = ""; conf = ""
|
||||
}
|
||||
persist()
|
||||
}
|
||||
|
||||
private func persist() {
|
||||
UserDefaults.standard.set(servers, forKey: "servers")
|
||||
UserDefaults.standard.set(conf, forKey: "conf")
|
||||
UserDefaults.standard.set(name, forKey: "name")
|
||||
message = "Профиль «\(name)» импортирован"
|
||||
Task { await save(reconnect: isOn) }
|
||||
}
|
||||
|
||||
private func saveOptions() {
|
||||
UserDefaults.standard.set(ruDirect, forKey: "ruDirect")
|
||||
UserDefaults.standard.set(transportMode, forKey: "transport")
|
||||
UserDefaults.standard.set(wsURL, forKey: "wsURL")
|
||||
Task { await save(reconnect: isOn) }
|
||||
}
|
||||
|
||||
@@ -70,7 +98,7 @@ final class VPNController: ObservableObject {
|
||||
let proto = NETunnelProviderProtocol()
|
||||
proto.providerBundleIdentifier = "com.bobyrev.pcaphobos.tunnel"
|
||||
proto.serverAddress = profile.targetHost ?? "Phobos"
|
||||
proto.providerConfiguration = ["conf": conf, "ruDirect": ruDirect, "transport": transportMode]
|
||||
proto.providerConfiguration = ["conf": conf, "ruDirect": ruDirect, "transport": transportMode, "wsURL": wsURL]
|
||||
m.protocolConfiguration = proto
|
||||
m.localizedDescription = "PCA Phobos"
|
||||
m.isEnabled = true
|
||||
|
||||
Reference in New Issue
Block a user