Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
105 changes: 105 additions & 0 deletions ios/PPGMobile/PPGMobile/Models/ServerConnection.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import Foundation

/// Represents a saved connection to a ppg serve instance.
struct ServerConnection: Codable, Identifiable, Hashable {
let id: UUID
var name: String
var host: String
var port: Int
var token: String
var ca: String?
var isDefault: Bool

init(name: String = "My Mac", host: String, port: Int = 7700, token: String, ca: String? = nil, isDefault: Bool = false) {
self.id = UUID()
self.name = name
self.host = host
self.port = port
self.token = token
self.ca = ca
self.isDefault = isDefault
}

private var usesTLS: Bool {
ca != nil
}

var baseURL: URL? {
makeURL(scheme: usesTLS ? "https" : "http")
}

var wsURL: URL? {
makeURL(
scheme: usesTLS ? "wss" : "ws",
path: "/ws",
queryItems: [URLQueryItem(name: "token", value: token)]
)
}

var apiURL: URL? {
baseURL?.appendingPathComponent("api")
}

/// Parse a ppg serve QR code payload.
/// Format: ppg://connect?host=<host>&port=<port>&token=<token>[&ca=<base64>]
static func fromQRCode(_ payload: String) -> ServerConnection? {
guard let components = URLComponents(string: payload),
components.scheme?.lowercased() == "ppg",
components.host?.lowercased() == "connect"
else {
return nil
}

let params = Dictionary(
(components.queryItems ?? []).compactMap { item in
item.value.map { (item.name, $0) }
},
uniquingKeysWith: { _, last in last }
)

guard let host = params["host"], isValidHost(host),
let token = params["token"], !token.isEmpty
else {
return nil
}

let port = params["port"].flatMap(Int.init) ?? 7700
guard (1...65_535).contains(port) else { return nil }
let ca = params["ca"].flatMap { Data(base64Encoded: $0) != nil ? $0 : nil }

return ServerConnection(
name: host == "0.0.0.0" ? "Local Mac" : host,
host: host,
port: port,
token: token,
ca: ca
)
}

private func makeURL(
scheme: String,
path: String = "",
queryItems: [URLQueryItem] = []
) -> URL? {
var components = URLComponents()
components.scheme = scheme
components.host = host
components.port = port
components.path = path
components.queryItems = queryItems.isEmpty ? nil : queryItems
return components.url
}

private static func isValidHost(_ host: String) -> Bool {
guard !host.isEmpty,
host.rangeOfCharacter(from: .whitespacesAndNewlines) == nil
else {
return false
}

var components = URLComponents()
components.scheme = "http"
components.host = host
return components.url != nil
}
}
151 changes: 151 additions & 0 deletions ios/PPGMobile/PPGMobile/Models/ServerConnectionTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
import XCTest
@testable import PPGMobile

final class ServerConnectionTests: XCTestCase {

// MARK: - fromQRCode

func testValidQRCodeParsesCorrectly() {
let qr = "ppg://connect?host=192.168.1.10&port=7700&token=abc123"
let conn = ServerConnection.fromQRCode(qr)

XCTAssertNotNil(conn)
XCTAssertEqual(conn?.host, "192.168.1.10")
XCTAssertEqual(conn?.port, 7700)
XCTAssertEqual(conn?.token, "abc123")
XCTAssertNil(conn?.ca)
}

func testValidQRCodeWithCAParsesCorrectly() {
// "dGVzdA==" is base64 for "test"
let qr = "ppg://connect?host=myhost&port=8080&token=secret&ca=dGVzdA=="
let conn = ServerConnection.fromQRCode(qr)

XCTAssertNotNil(conn)
XCTAssertEqual(conn?.host, "myhost")
XCTAssertEqual(conn?.port, 8080)
XCTAssertEqual(conn?.token, "secret")
XCTAssertEqual(conn?.ca, "dGVzdA==")
}

func testMissingHostReturnsNil() {
let qr = "ppg://connect?port=7700&token=abc123"
XCTAssertNil(ServerConnection.fromQRCode(qr))
}

func testEmptyHostReturnsNil() {
let qr = "ppg://connect?host=&port=7700&token=abc123"
XCTAssertNil(ServerConnection.fromQRCode(qr))
}

func testMissingTokenReturnsNil() {
let qr = "ppg://connect?host=myhost&port=7700"
XCTAssertNil(ServerConnection.fromQRCode(qr))
}

func testEmptyTokenReturnsNil() {
let qr = "ppg://connect?host=myhost&port=7700&token="
XCTAssertNil(ServerConnection.fromQRCode(qr))
}

func testMissingPortDefaultsTo7700() {
let qr = "ppg://connect?host=myhost&token=abc123"
let conn = ServerConnection.fromQRCode(qr)

XCTAssertNotNil(conn)
XCTAssertEqual(conn?.port, 7700)
}

func testInvalidPortReturnsNil() {
XCTAssertNil(ServerConnection.fromQRCode("ppg://connect?host=myhost&port=0&token=abc123"))
XCTAssertNil(ServerConnection.fromQRCode("ppg://connect?host=myhost&port=70000&token=abc123"))
}

func testInvalidHostReturnsNil() {
let qr = "ppg://connect?host=my%20host&port=7700&token=abc123"
XCTAssertNil(ServerConnection.fromQRCode(qr))
}

func testWrongSchemeReturnsNil() {
let qr = "http://connect?host=myhost&port=7700&token=abc123"
XCTAssertNil(ServerConnection.fromQRCode(qr))
}

func testWrongHostReturnsNil() {
let qr = "ppg://pair?host=myhost&port=7700&token=abc123"
XCTAssertNil(ServerConnection.fromQRCode(qr))
}

func testNonPPGStringReturnsNil() {
XCTAssertNil(ServerConnection.fromQRCode("https://example.com"))
XCTAssertNil(ServerConnection.fromQRCode("just some text"))
XCTAssertNil(ServerConnection.fromQRCode(""))
}

func testDuplicateQueryParamsDoNotCrash() {
let qr = "ppg://connect?host=myhost&token=first&token=second&port=7700"
let conn = ServerConnection.fromQRCode(qr)

XCTAssertNotNil(conn)
// Last value wins per uniquingKeysWith
XCTAssertEqual(conn?.token, "second")
}

func testInvalidBase64CAIsDiscarded() {
let qr = "ppg://connect?host=myhost&port=7700&token=abc&ca=not-valid-base64!!!"
let conn = ServerConnection.fromQRCode(qr)

XCTAssertNotNil(conn)
XCTAssertNil(conn?.ca)
}

func testLocalhostNameMapping() {
let qr = "ppg://connect?host=0.0.0.0&port=7700&token=abc123"
let conn = ServerConnection.fromQRCode(qr)

XCTAssertEqual(conn?.name, "Local Mac")
}

func testNonLocalhostUsesHostAsName() {
let qr = "ppg://connect?host=workstation.local&port=7700&token=abc123"
let conn = ServerConnection.fromQRCode(qr)

XCTAssertEqual(conn?.name, "workstation.local")
}

// MARK: - URL construction

func testBaseURLUsesHTTPWithoutCA() {
let conn = ServerConnection(host: "myhost", port: 7700, token: "abc")
XCTAssertEqual(conn.baseURL?.absoluteString, "http://myhost:7700")
}

func testBaseURLUsesHTTPSWithCA() {
let conn = ServerConnection(host: "myhost", port: 7700, token: "abc", ca: "dGVzdA==")
XCTAssertEqual(conn.baseURL?.absoluteString, "https://myhost:7700")
}

func testWsURLUsesWSSWithCA() {
let conn = ServerConnection(host: "myhost", port: 7700, token: "abc", ca: "dGVzdA==")
XCTAssertEqual(conn.wsURL?.scheme, "wss")
}

func testWsURLPercentEncodesToken() {
let conn = ServerConnection(host: "myhost", port: 7700, token: "abc+def&ghi=jkl")
guard let url = conn.wsURL else {
XCTFail("Expected wsURL to be generated")
return
}

let components = URLComponents(url: url, resolvingAgainstBaseURL: false)
let tokenValue = components?.queryItems?.first(where: { $0.name == "token" })?.value
XCTAssertEqual(tokenValue, "abc+def&ghi=jkl")
XCTAssertEqual(components?.queryItems?.count, 1)
}

func testInvalidHostDoesNotCrashURLBuilding() {
let conn = ServerConnection(host: "bad host", port: 7700, token: "abc")
XCTAssertNil(conn.baseURL)
XCTAssertNil(conn.wsURL)
}
}
Loading