Files
hapi/ios/Packages/HapiKit/Sources/HapiProtocol/Pairing/BindLink.swift
T
weishu 3363d1c75b feat(ios): pairing flow, deep link, app session wiring (A-M1d)
- HapiProtocol/Pairing/BindLink: parses the companion deeplink
  (hapicompanion://bind?hub=&code=) and the web direct-access QR
  (?hub=&token=) with URLSearchParams form-decoding semantics, in
  lockstep with the Android port (tests mirror BindLinkTest.kt).
- HapiClient/Auth/HubPairingService: normalize -> GET /health
  (reachability + protocolVersion) -> POST /api/auth -> persist
  Keychain + registry + active hub; unpair with fallback. Covered by
  PairingLogicTests through the HTTPPerforming seam.
- App layer: AppModel (@Observable @MainActor pairing state machine:
  restore, pair, switch, sign out, deep-link routing, scenePhase,
  terminal-auth-failure banner) + HubSession (per-active-hub APIClient/
  AuthManager/global SSEClient with suspend-resume and a connection
  state for the UI; store routing is TODO(M2)).
- Pairing UI: welcome flow, VisionKit QR scanner (with Simulator/
  permission fallbacks), manual entry (paste-friendly), shared confirm
  sheet with per-PairingFailure error states.
- HapiApp routes hapicompanion:// through AppModel (paired hubs switch
  with a notice, never log the token); RootView switches unpaired/
  paired and hosts the deep-link confirm sheet; HomePlaceholderView
  shows hub, connection dot, hub switcher (M2a replaces it with the
  session list).
- Info.plist: NSCameraUsageDescription; README: pairing guide + manual
  test pass.
2026-08-17 15:58:17 +08:00

157 lines
6.6 KiB
Swift

import Foundation
/// Parsed companion pairing link.
///
/// The hub prints (and renders as QR codes) two pairing links — see
/// `hub/src/startHub.ts` and `docs/api/client-contract/auth.md`:
///
/// 1. The canonical companion deeplink
/// `hapicompanion://bind?hub=<url>&code=<accessToken>`, and
/// 2. the web direct-access URL `https://<web>/?hub=<url>&token=<accessToken>`
/// (same values, different scheme and param name). A robust scanner accepts
/// both, so pointing the camera at the "wrong" QR still pairs.
///
/// Both queries are built with `URLSearchParams`, i.e.
/// `application/x-www-form-urlencoded`: `%xx` escapes plus `+` for space.
/// `code`/`token` is the raw CLI access token, possibly suffixed with
/// `:namespace`, so it round-trips percent-encoded (`%3A`).
///
/// Semantics are kept in lockstep with the Android port
/// (`android/core/protocol/.../pairing/BindLink.kt`): scheme and host match
/// case-insensitively, the first occurrence wins for duplicate params, blank
/// values are rejected, the `hub` value must itself be an http(s) URL, and any
/// malformed input (including invalid percent escapes) parses to `nil`.
///
/// Lives in `HapiProtocol` (pure Foundation) so it is unit-testable without
/// the app target; the app's `onOpenURL` handler and QR scanner both feed
/// their strings through ``parse(_:)``.
public struct BindLink: Equatable, Sendable {
/// The hub base URL exactly as carried by the link (decoded, not yet
/// normalized — hub-URL normalization is the client layer's job).
public let hubUrl: String
/// The pairing access token, passed verbatim to `POST /api/auth`.
public let accessToken: String
public init(hubUrl: String, accessToken: String) {
self.hubUrl = hubUrl
self.accessToken = accessToken
}
/// Scheme of the canonical companion deeplink.
public static let scheme = "hapicompanion"
/// Host of the canonical companion deeplink.
public static let host = "bind"
/// Parses `raw` into a ``BindLink``.
///
/// Accepts the companion deeplink form (`hapicompanion://bind?hub=&code=`)
/// and the web direct-access form (`http(s)://<web>/...?hub=&token=`).
/// Returns `nil` on any malformed input: unparseable URL, wrong scheme or
/// host, missing/blank `hub` and `code`/`token` parameters, invalid
/// percent escapes, or a `hub` value that is not an http(s) URL.
public static func parse(_ raw: String) -> BindLink? {
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty,
let components = URLComponents(string: trimmed),
let scheme = components.scheme?.lowercased() else {
return nil
}
switch scheme {
case Self.scheme:
return parseCompanionDeeplink(components)
case "http", "https":
return parseWebDirectAccessURL(components)
default:
return nil
}
}
// MARK: - Forms
/// `hapicompanion://bind?hub=<url>&code=<accessToken>` — the canonical
/// deeplink; the only form that arrives via the registered URL scheme.
private static func parseCompanionDeeplink(_ components: URLComponents) -> BindLink? {
guard let host = components.host?.lowercased(), host == Self.host else {
return nil
}
guard let params = formParameters(of: components),
let hub = params["hub"], !isBlank(hub),
let code = params["code"], !isBlank(code),
isHTTPURL(hub) else {
return nil
}
return BindLink(hubUrl: hub, accessToken: code)
}
/// `http(s)://<web host>/...?hub=<url>&token=<accessToken>` — the web
/// direct-access QR (`${officialWebUrl}/?hub=…&token=…`). The web host and
/// path are not pinned: self-hosted deployments serve the app anywhere.
private static func parseWebDirectAccessURL(_ components: URLComponents) -> BindLink? {
guard let host = components.host, !host.isEmpty else {
return nil
}
guard let params = formParameters(of: components),
let hub = params["hub"], !isBlank(hub),
let token = params["token"], !isBlank(token),
isHTTPURL(hub) else {
return nil
}
return BindLink(hubUrl: hub, accessToken: token)
}
// MARK: - Form decoding (URLSearchParams parity)
/// Decodes the raw query as `application/x-www-form-urlencoded`.
/// First occurrence wins for duplicate keys. Returns `nil` when the query
/// is absent or any component carries invalid percent escapes.
private static func formParameters(of components: URLComponents) -> [String: String]? {
guard let rawQuery = components.percentEncodedQuery else { return nil }
var result: [String: String] = [:]
for pair in rawQuery.split(separator: "&", omittingEmptySubsequences: false) {
if pair.isEmpty { continue }
let rawKey: Substring
let rawValue: Substring
if let separator = pair.firstIndex(of: "=") {
rawKey = pair[..<separator]
rawValue = pair[pair.index(after: separator)...]
} else {
rawKey = pair
rawValue = ""
}
guard let key = decodeFormComponent(String(rawKey)),
let value = decodeFormComponent(String(rawValue)) else {
return nil
}
if result[key] == nil {
result[key] = value
}
}
return result
}
/// One form-encoded component: `+` means space, then percent-decoding.
/// (`%2B` still decodes to a literal `+` — the replacement happens on the
/// still-encoded text.) `removingPercentEncoding` answers `nil` for
/// invalid escape sequences, matching the Android port's null-on-invalid.
private static func decodeFormComponent(_ component: String) -> String? {
component
.replacingOccurrences(of: "+", with: " ")
.removingPercentEncoding
}
private static func isBlank(_ value: String) -> Bool {
value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
/// The `hub` value must be an absolute http(s) URL with a host.
private static func isHTTPURL(_ value: String) -> Bool {
guard let components = URLComponents(string: value),
let scheme = components.scheme?.lowercased(),
scheme == "http" || scheme == "https",
let host = components.host, !host.isEmpty else {
return false
}
return true
}
}