mirror of
https://github.com/wu736139669/hapi.git
synced 2026-11-11 02:11:37 +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.
143 lines
4.7 KiB
Swift
143 lines
4.7 KiB
Swift
import HapiClient
|
|
import SwiftUI
|
|
|
|
/// Post-pairing placeholder: shows the active hub with its live global-SSE
|
|
/// connection state and the hub switcher (switch / add / sign out). The
|
|
/// session list replaces the body content in M2.
|
|
struct HomePlaceholderView: View {
|
|
let session: HubSession
|
|
|
|
@Environment(AppModel.self) private var model
|
|
@State private var confirmSignOut = false
|
|
|
|
var body: some View {
|
|
@Bindable var model = model
|
|
NavigationStack {
|
|
VStack(spacing: 0) {
|
|
if let failedHub = model.authFailureNotice {
|
|
authFailureBanner(failedHub: failedHub)
|
|
}
|
|
// TODO(M2a): replace HomePlaceholderView with SessionListView.
|
|
ContentUnavailableView {
|
|
Label("Paired with \(HubDisplay.host(session.hubUrl))", systemImage: "checkmark.seal")
|
|
} description: {
|
|
Text("Sessions arrive with M2.")
|
|
}
|
|
}
|
|
.navigationTitle(HubDisplay.host(session.hubUrl))
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.toolbar {
|
|
ToolbarItem(placement: .topBarLeading) {
|
|
connectionIndicator
|
|
}
|
|
ToolbarItem(placement: .topBarTrailing) {
|
|
hubMenu
|
|
}
|
|
}
|
|
.confirmationDialog(
|
|
"Sign out of \(HubDisplay.host(session.hubUrl))?",
|
|
isPresented: $confirmSignOut,
|
|
titleVisibility: .visible
|
|
) {
|
|
Button("Sign Out", role: .destructive) {
|
|
model.signOut(hub: session.hubUrl)
|
|
}
|
|
} message: {
|
|
Text("Removes the stored access token for this hub. "
|
|
+ "Pair again to reconnect.")
|
|
}
|
|
}
|
|
.sheet(isPresented: $model.showAddHub) {
|
|
PairingFlowView(context: .addHub)
|
|
}
|
|
}
|
|
|
|
// MARK: - Connection state
|
|
|
|
private var connectionIndicator: some View {
|
|
HStack(spacing: 6) {
|
|
Circle()
|
|
.fill(connectionColor)
|
|
.frame(width: 8, height: 8)
|
|
Text(connectionLabel)
|
|
.font(.footnote)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
.accessibilityElement(children: .combine)
|
|
.accessibilityLabel("Connection: \(connectionLabel)")
|
|
}
|
|
|
|
private var connectionColor: Color {
|
|
switch session.connectionState {
|
|
case .connected: .green
|
|
case .connecting, .backoff: .orange
|
|
case .idle, .suspended: .gray
|
|
}
|
|
}
|
|
|
|
private var connectionLabel: String {
|
|
switch session.connectionState {
|
|
case .connected: "Live"
|
|
case .connecting: "Connecting…"
|
|
case .backoff: "Reconnecting…"
|
|
case .suspended: "Paused"
|
|
case .idle: "Offline"
|
|
}
|
|
}
|
|
|
|
// MARK: - Hub switcher
|
|
|
|
private var hubMenu: some View {
|
|
Menu {
|
|
Section("Hubs") {
|
|
ForEach(model.hubs, id: \.self) { hub in
|
|
Button {
|
|
model.switchHub(to: hub)
|
|
} label: {
|
|
if hub == session.hubUrl {
|
|
Label(HubDisplay.host(hub), systemImage: "checkmark")
|
|
} else {
|
|
Text(HubDisplay.host(hub))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
Button {
|
|
model.showAddHub = true
|
|
} label: {
|
|
Label("Add Hub…", systemImage: "plus")
|
|
}
|
|
Button(role: .destructive) {
|
|
confirmSignOut = true
|
|
} label: {
|
|
Label("Sign Out…", systemImage: "rectangle.portrait.and.arrow.right")
|
|
}
|
|
} label: {
|
|
Label("Hubs", systemImage: "server.rack")
|
|
}
|
|
}
|
|
|
|
private func authFailureBanner(failedHub: String) -> some View {
|
|
HStack(alignment: .top, spacing: 12) {
|
|
Image(systemName: "exclamationmark.triangle.fill")
|
|
.foregroundStyle(.orange)
|
|
Text("\(HubDisplay.host(failedHub)) rejected its stored "
|
|
+ "credentials and was signed out. Pair it again from the hub "
|
|
+ "menu.")
|
|
.font(.footnote)
|
|
Spacer(minLength: 0)
|
|
Button {
|
|
model.authFailureNotice = nil
|
|
} label: {
|
|
Image(systemName: "xmark")
|
|
.font(.footnote.bold())
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
.padding(12)
|
|
.background(.orange.opacity(0.12), in: RoundedRectangle(cornerRadius: 12))
|
|
.padding(.horizontal, 16)
|
|
.padding(.top, 8)
|
|
}
|
|
}
|