mirror of
https://github.com/wu736139669/hapi.git
synced 2026-10-12 20:07:31 +00:00
- 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.
150 lines
5.7 KiB
Swift
150 lines
5.7 KiB
Swift
import Foundation
|
|
import HapiProtocol
|
|
import Testing
|
|
|
|
/// Mirrors the Android suite (`android/.../pairing/BindLinkTest.kt`) case for
|
|
/// case so the two ports cannot drift, plus the web direct-access form the
|
|
/// iOS parser additionally accepts.
|
|
@Suite("BindLink parsing")
|
|
struct BindLinkTests {
|
|
// MARK: - Companion deeplink form
|
|
|
|
@Test func parsesAPlainValidLink() {
|
|
let link = BindLink.parse("hapicompanion://bind?hub=http://192.168.1.20:3006&code=abc123")
|
|
|
|
#expect(link == BindLink(hubUrl: "http://192.168.1.20:3006", accessToken: "abc123"))
|
|
}
|
|
|
|
@Test func parsesURLEncodedValuesAsTheHubEmitsThem() {
|
|
// Exactly what `new URLSearchParams({hub, code}).toString()` produces
|
|
// for hub=https://demo.hapi.run and code=tok_9f8:default.
|
|
let link = BindLink.parse(
|
|
"hapicompanion://bind?hub=https%3A%2F%2Fdemo.hapi.run&code=tok_9f8%3Adefault"
|
|
)
|
|
|
|
#expect(link?.hubUrl == "https://demo.hapi.run")
|
|
#expect(link?.accessToken == "tok_9f8:default")
|
|
}
|
|
|
|
@Test func decodesPlusAsSpaceInFormEncodedValues() {
|
|
let link = BindLink.parse("hapicompanion://bind?hub=https%3A%2F%2Fh.example.com&code=a+b")
|
|
|
|
#expect(link?.accessToken == "a b")
|
|
}
|
|
|
|
@Test func keepsEncodedPlusLiteral() {
|
|
let link = BindLink.parse("hapicompanion://bind?hub=https%3A%2F%2Fh.example.com&code=a%2Bb")
|
|
|
|
#expect(link?.accessToken == "a+b")
|
|
}
|
|
|
|
@Test func acceptsSchemeAndHostCaseInsensitively() {
|
|
let link = BindLink.parse("HAPICOMPANION://BIND?hub=https%3A%2F%2Fh.example.com&code=x")
|
|
|
|
#expect(link?.hubUrl == "https://h.example.com")
|
|
}
|
|
|
|
@Test func ignoresExtraParamsAndKeepsFirstOccurrenceOfDuplicates() {
|
|
let link = BindLink.parse(
|
|
"hapicompanion://bind?v=1&hub=https%3A%2F%2Fa.example.com&code=first&code=second"
|
|
)
|
|
|
|
#expect(link?.hubUrl == "https://a.example.com")
|
|
#expect(link?.accessToken == "first")
|
|
}
|
|
|
|
@Test func returnsNilWhenHubParamIsMissing() {
|
|
#expect(BindLink.parse("hapicompanion://bind?code=abc123") == nil)
|
|
}
|
|
|
|
@Test func returnsNilWhenCodeParamIsMissing() {
|
|
#expect(BindLink.parse("hapicompanion://bind?hub=https%3A%2F%2Fh.example.com") == nil)
|
|
}
|
|
|
|
@Test func returnsNilWhenQueryIsAbsent() {
|
|
#expect(BindLink.parse("hapicompanion://bind") == nil)
|
|
}
|
|
|
|
@Test func returnsNilWhenParamsAreBlank() {
|
|
#expect(BindLink.parse("hapicompanion://bind?hub=&code=x") == nil)
|
|
#expect(BindLink.parse("hapicompanion://bind?hub=https%3A%2F%2Fh.example.com&code=") == nil)
|
|
}
|
|
|
|
@Test func returnsNilForAWrongScheme() {
|
|
// https with `code=` is neither a valid deeplink nor a valid web QR
|
|
// (the web form carries `token=`).
|
|
#expect(BindLink.parse("https://bind?hub=https%3A%2F%2Fh.example.com&code=x") == nil)
|
|
#expect(BindLink.parse("hapicompanionx://bind?hub=https%3A%2F%2Fh.example.com&code=x") == nil)
|
|
#expect(BindLink.parse("ftp://bind?hub=https%3A%2F%2Fh.example.com&code=x") == nil)
|
|
}
|
|
|
|
@Test func returnsNilForAWrongHost() {
|
|
#expect(BindLink.parse("hapicompanion://pair?hub=https%3A%2F%2Fh.example.com&code=x") == nil)
|
|
}
|
|
|
|
@Test func returnsNilWhenHubIsNotAnHTTPURL() {
|
|
#expect(BindLink.parse("hapicompanion://bind?hub=ftp%3A%2F%2Fh.example.com&code=x") == nil)
|
|
#expect(BindLink.parse("hapicompanion://bind?hub=not-a-url&code=x") == nil)
|
|
}
|
|
|
|
@Test func returnsNilForMalformedInput() {
|
|
#expect(BindLink.parse("") == nil)
|
|
#expect(BindLink.parse(" ") == nil)
|
|
#expect(BindLink.parse("not a uri at all") == nil)
|
|
// Scheme-relative (no authority): `bind` is a path, not a host.
|
|
#expect(BindLink.parse("hapicompanion:bind?hub=https%3A%2F%2Fh.example.com&code=x") == nil)
|
|
}
|
|
|
|
@Test func returnsNilOnInvalidPercentEscapes() {
|
|
#expect(BindLink.parse("hapicompanion://bind?hub=https%3A%2F%2Fh.example.com&code=%zz") == nil)
|
|
}
|
|
|
|
@Test func trimsSurroundingWhitespace() {
|
|
let link = BindLink.parse(" hapicompanion://bind?hub=https%3A%2F%2Fh.example.com&code=x\n")
|
|
|
|
#expect(link?.hubUrl == "https://h.example.com")
|
|
#expect(link?.accessToken == "x")
|
|
}
|
|
|
|
// MARK: - Web direct-access form
|
|
|
|
@Test func parsesTheWebDirectAccessQR() {
|
|
// Exactly what `${officialWebUrl}/?${new URLSearchParams({hub, token})}`
|
|
// produces in hub/src/startHub.ts.
|
|
let link = BindLink.parse(
|
|
"https://app.hapi.run/?hub=https%3A%2F%2Fdemo.hapi.run&token=tok_9f8%3Adefault"
|
|
)
|
|
|
|
#expect(link?.hubUrl == "https://demo.hapi.run")
|
|
#expect(link?.accessToken == "tok_9f8:default")
|
|
}
|
|
|
|
@Test func acceptsAnyWebHostAndPath() {
|
|
// Self-hosted deployments serve the web app anywhere.
|
|
let link = BindLink.parse(
|
|
"http://my-server.local:8080/hapi/?hub=http%3A%2F%2F192.168.1.20%3A3006&token=abc"
|
|
)
|
|
|
|
#expect(link?.hubUrl == "http://192.168.1.20:3006")
|
|
#expect(link?.accessToken == "abc")
|
|
}
|
|
|
|
@Test func webFormRequiresTokenParamName() {
|
|
// `code=` belongs to the deeplink form only.
|
|
#expect(BindLink.parse("https://app.hapi.run/?hub=https%3A%2F%2Fdemo.hapi.run&code=abc") == nil)
|
|
}
|
|
|
|
@Test func webFormRequiresHubParam() {
|
|
#expect(BindLink.parse("https://app.hapi.run/?token=abc") == nil)
|
|
}
|
|
|
|
@Test func webFormRejectsBlankValues() {
|
|
#expect(BindLink.parse("https://app.hapi.run/?hub=&token=abc") == nil)
|
|
#expect(BindLink.parse("https://app.hapi.run/?hub=https%3A%2F%2Fdemo.hapi.run&token=") == nil)
|
|
}
|
|
|
|
@Test func webFormRequiresAnHTTPHubValue() {
|
|
#expect(BindLink.parse("https://app.hapi.run/?hub=ftp%3A%2F%2Fdemo.hapi.run&token=abc") == nil)
|
|
}
|
|
}
|