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:
Бобырев Андрей
2026-10-31 05:16:31 +03:00
parent d795bc6f70
commit dde6403cea
38 changed files with 855 additions and 58 deletions

View File

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

View File

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

View File

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