Skip to content

Commit 110ef34

Browse files
committed
Add Settings Gateway pane for native VocaGateway control
Ship an MVP Settings → Gateway tab that starts and stops a local vocagateway binary, probes health, and shows a Pair phone QR once the pairing URL is non-loopback. Docker stays a fallback CTA only.
1 parent 0629ef1 commit 110ef34

7 files changed

Lines changed: 1139 additions & 0 deletions

File tree

Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
// GatewayPairing.swift
2+
// VocaMac
3+
//
4+
// Pure pairing helpers for VocaGateway: payload decode and loopback rejection.
5+
// Foundation-only so unit tests cover the contract without process APIs.
6+
7+
import Foundation
8+
9+
/// Decoded phone-pairing document `{v,url,token}` from Gateway admin `payload`.
10+
struct GatewayPairingPayload: Codable, Equatable {
11+
let v: Int
12+
let url: String
13+
let token: String
14+
15+
enum CodingKeys: String, CodingKey {
16+
case v
17+
case url
18+
case token
19+
}
20+
21+
var gatewayURL: URL? { URL(string: url) }
22+
23+
/// Compact JSON string suitable for QR encoding (phones expect this shape).
24+
var qrPayloadString: String {
25+
let dict: [String: Any] = ["v": v, "url": url, "token": token]
26+
guard let data = try? JSONSerialization.data(withJSONObject: dict, options: [.sortedKeys]),
27+
let text = String(data: data, encoding: .utf8) else {
28+
return #"{"token":"\#(token)","url":"\#(url)","v":\#(v)}"#
29+
}
30+
return text
31+
}
32+
}
33+
34+
enum GatewayPairingDecodeError: Error, Equatable, LocalizedError {
35+
case empty
36+
case invalidJSON
37+
case missingPayload
38+
case unsupportedVersion(Int?)
39+
case missingURL
40+
case missingToken
41+
case invalidURL(String)
42+
case loopbackURL(String)
43+
44+
var errorDescription: String? {
45+
switch self {
46+
case .empty:
47+
return "Pairing payload is empty."
48+
case .invalidJSON:
49+
return "Pairing payload is not valid JSON."
50+
case .missingPayload:
51+
return "Admin pairing response is missing payload."
52+
case .unsupportedVersion(let version):
53+
return "Unsupported pairing version: \(version.map(String.init) ?? "missing")."
54+
case .missingURL:
55+
return "Pairing payload is missing a gateway URL."
56+
case .missingToken:
57+
return "Pairing payload is missing a bearer token."
58+
case .invalidURL(let raw):
59+
return "Pairing payload has an invalid gateway URL: \(raw)."
60+
case .loopbackURL(let raw):
61+
return "Pairing URL must not be localhost or 127.0.0.1 (got \(raw)). Set a LAN or Tailscale address."
62+
}
63+
}
64+
}
65+
66+
/// Decodes `/v1/admin/pairing` admin JSON whose `payload` may be an object or a JSON string.
67+
enum GatewayPairingDecoder {
68+
static let supportedVersion = 1
69+
70+
/// Decode from an admin JSON object that exposes `payload` (object or string).
71+
static func decodeAdminJSON(
72+
_ json: [String: Any],
73+
rejectLoopback: Bool = true
74+
) -> Result<GatewayPairingPayload, GatewayPairingDecodeError> {
75+
guard let rawPayload = json["payload"] else {
76+
return .failure(.missingPayload)
77+
}
78+
79+
let object: [String: Any]
80+
if let asObject = rawPayload as? [String: Any] {
81+
object = asObject
82+
} else if let asString = rawPayload as? String {
83+
let trimmed = asString.trimmingCharacters(in: .whitespacesAndNewlines)
84+
guard !trimmed.isEmpty else { return .failure(.empty) }
85+
guard let data = trimmed.data(using: .utf8),
86+
let parsed = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
87+
return .failure(.invalidJSON)
88+
}
89+
object = parsed
90+
} else if let data = try? JSONSerialization.data(withJSONObject: rawPayload),
91+
let parsed = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
92+
object = parsed
93+
} else {
94+
return .failure(.invalidJSON)
95+
}
96+
97+
return decodePayloadObject(object, rejectLoopback: rejectLoopback)
98+
}
99+
100+
/// Decode a raw payload JSON string `{v,url,token}`.
101+
static func decodePayloadString(
102+
_ raw: String,
103+
rejectLoopback: Bool = true
104+
) -> Result<GatewayPairingPayload, GatewayPairingDecodeError> {
105+
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
106+
guard !trimmed.isEmpty else { return .failure(.empty) }
107+
guard let data = trimmed.data(using: .utf8),
108+
let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
109+
return .failure(.invalidJSON)
110+
}
111+
return decodePayloadObject(object, rejectLoopback: rejectLoopback)
112+
}
113+
114+
static func decodePayloadObject(
115+
_ object: [String: Any],
116+
rejectLoopback: Bool = true
117+
) -> Result<GatewayPairingPayload, GatewayPairingDecodeError> {
118+
let versionValue = object["v"] ?? object["version"]
119+
let version: Int?
120+
if let intValue = versionValue as? Int {
121+
version = intValue
122+
} else if let number = versionValue as? NSNumber {
123+
version = number.intValue
124+
} else {
125+
version = nil
126+
}
127+
guard version == supportedVersion else {
128+
return .failure(.unsupportedVersion(version))
129+
}
130+
131+
guard let urlString = (object["url"] as? String)?
132+
.trimmingCharacters(in: .whitespacesAndNewlines),
133+
!urlString.isEmpty else {
134+
return .failure(.missingURL)
135+
}
136+
guard let token = (object["token"] as? String)?
137+
.trimmingCharacters(in: .whitespacesAndNewlines),
138+
!token.isEmpty else {
139+
return .failure(.missingToken)
140+
}
141+
guard let url = URL(string: urlString), url.scheme != nil, url.host != nil else {
142+
return .failure(.invalidURL(urlString))
143+
}
144+
145+
if rejectLoopback, GatewayPairingURL.isLoopback(url) {
146+
return .failure(.loopbackURL(urlString))
147+
}
148+
149+
return .success(GatewayPairingPayload(v: supportedVersion, url: urlString, token: token))
150+
}
151+
}
152+
153+
/// Loopback / pairability checks for Gateway URLs used in phone QR codes.
154+
enum GatewayPairingURL {
155+
/// True when the URL host is loopback and must not appear in a phone QR.
156+
static func isLoopback(_ url: URL) -> Bool {
157+
guard let host = url.host?.lowercased() else { return false }
158+
if host == "localhost" || host == "127.0.0.1" || host == "::1" || host == "[::1]" {
159+
return true
160+
}
161+
if host.hasPrefix("127.") { return true }
162+
return false
163+
}
164+
165+
static func isLoopback(_ raw: String) -> Bool {
166+
guard let url = URL(string: raw) else { return false }
167+
return isLoopback(url)
168+
}
169+
170+
/// True when a URL is usable for pairing QR (has scheme+host and is not loopback).
171+
static func isPairableURL(_ url: URL) -> Bool {
172+
guard url.scheme != nil, url.host != nil else { return false }
173+
return !isLoopback(url)
174+
}
175+
176+
static func isPairableURL(_ raw: String) -> Bool {
177+
guard let url = URL(string: raw), url.scheme != nil, url.host != nil else { return false }
178+
return isPairableURL(url)
179+
}
180+
181+
/// Normalize a user-supplied public URL override for pairing.
182+
static func validatedPublicURL(_ raw: String) -> Result<URL, GatewayPairingDecodeError> {
183+
var trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
184+
guard !trimmed.isEmpty else { return .failure(.missingURL) }
185+
if !trimmed.contains("://") {
186+
trimmed = "http://\(trimmed)"
187+
}
188+
guard let url = URL(string: trimmed), url.scheme != nil, url.host != nil else {
189+
return .failure(.invalidURL(trimmed))
190+
}
191+
if isLoopback(url) {
192+
return .failure(.loopbackURL(trimmed))
193+
}
194+
return .success(url)
195+
}
196+
}

Sources/VocaMac/Models/SettingsPage.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ enum SettingsPage: String, CaseIterable, Identifiable, Hashable {
1616
case application
1717
case stats
1818
case advanced
19+
case gateway
1920
case about
2021

2122
var id: String { rawValue }
@@ -30,6 +31,7 @@ enum SettingsPage: String, CaseIterable, Identifiable, Hashable {
3031
case .application: return "Application"
3132
case .stats: return "Stats"
3233
case .advanced: return "Advanced"
34+
case .gateway: return "Gateway"
3335
case .about: return "About"
3436
}
3537
}
@@ -44,6 +46,7 @@ enum SettingsPage: String, CaseIterable, Identifiable, Hashable {
4446
case .application: return "gearshape"
4547
case .stats: return "chart.xyaxis.line"
4648
case .advanced: return "ladybug"
49+
case .gateway: return "server.rack"
4750
case .about: return "info.circle"
4851
}
4952
}

Sources/VocaMac/Models/SettingsSearchIndex.swift

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,29 @@ enum SettingsSearchIndex {
183183
keywords: ["overlay", "cursor", "indicator", "mic", "position", "style"]
184184
),
185185

186+
// Gateway
187+
SettingsSearchEntry(
188+
id: "gateway",
189+
page: .gateway,
190+
title: "Gateway",
191+
subtitle: "Optional self-hosted VocaGateway",
192+
keywords: ["gateway", "vocagateway", "pair", "phone", "docker", "self-hosted", "qr"]
193+
),
194+
SettingsSearchEntry(
195+
id: "gateway-pair",
196+
page: .gateway,
197+
title: "Pair phone",
198+
subtitle: "QR code for VocaPhone",
199+
keywords: ["pair", "phone", "qr", "pairing"]
200+
),
201+
SettingsSearchEntry(
202+
id: "gateway-docker",
203+
page: .gateway,
204+
title: "Docker fallback",
205+
subtitle: "Install Docker Desktop when native binary is missing",
206+
keywords: ["docker", "desktop", "container", "compose"]
207+
),
208+
186209
// Stats / Advanced / About
187210
SettingsSearchEntry(
188211
id: "stats",

0 commit comments

Comments
 (0)