feat(ios): reconnect to last bridge
parent
e6d522493b
commit
f7076c38ea
|
|
@ -0,0 +1,94 @@
|
||||||
|
import ClawdisKit
|
||||||
|
import Combine
|
||||||
|
import Foundation
|
||||||
|
import Network
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class BridgeConnectionController: ObservableObject {
|
||||||
|
@Published private(set) var bridges: [BridgeDiscoveryModel.DiscoveredBridge] = []
|
||||||
|
@Published private(set) var discoveryStatusText: String = "Idle"
|
||||||
|
|
||||||
|
private let discovery = BridgeDiscoveryModel()
|
||||||
|
private weak var appModel: NodeAppModel?
|
||||||
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
private var didAutoConnect = false
|
||||||
|
|
||||||
|
init(appModel: NodeAppModel) {
|
||||||
|
self.appModel = appModel
|
||||||
|
|
||||||
|
BridgeSettingsStore.bootstrapPersistence()
|
||||||
|
|
||||||
|
self.discovery.$bridges
|
||||||
|
.sink { [weak self] newValue in
|
||||||
|
guard let self else { return }
|
||||||
|
self.bridges = newValue
|
||||||
|
self.maybeAutoConnect()
|
||||||
|
}
|
||||||
|
.store(in: &self.cancellables)
|
||||||
|
|
||||||
|
self.discovery.$statusText
|
||||||
|
.assign(to: &self.$discoveryStatusText)
|
||||||
|
|
||||||
|
self.discovery.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
func setScenePhase(_ phase: ScenePhase) {
|
||||||
|
switch phase {
|
||||||
|
case .background:
|
||||||
|
self.discovery.stop()
|
||||||
|
case .active, .inactive:
|
||||||
|
self.discovery.start()
|
||||||
|
@unknown default:
|
||||||
|
self.discovery.start()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func maybeAutoConnect() {
|
||||||
|
guard !self.didAutoConnect else { return }
|
||||||
|
guard let appModel = self.appModel else { return }
|
||||||
|
guard appModel.bridgeServerName == nil else { return }
|
||||||
|
|
||||||
|
let defaults = UserDefaults.standard
|
||||||
|
let preferredStableID = defaults.string(forKey: "bridge.preferredStableID")?
|
||||||
|
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
guard !preferredStableID.isEmpty else { return }
|
||||||
|
|
||||||
|
let instanceId = defaults.string(forKey: "node.instanceId")?
|
||||||
|
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
guard !instanceId.isEmpty else { return }
|
||||||
|
|
||||||
|
let token = KeychainStore.loadString(
|
||||||
|
service: "com.steipete.clawdis.bridge",
|
||||||
|
account: "bridge-token.\(instanceId)")?
|
||||||
|
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
guard !token.isEmpty else { return }
|
||||||
|
|
||||||
|
guard let target = self.bridges.first(where: { $0.stableID == preferredStableID }) else { return }
|
||||||
|
|
||||||
|
self.didAutoConnect = true
|
||||||
|
appModel.connectToBridge(endpoint: target.endpoint, hello: self.makeHello(token: token))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeHello(token: String) -> BridgeHello {
|
||||||
|
let defaults = UserDefaults.standard
|
||||||
|
let nodeId = defaults.string(forKey: "node.instanceId") ?? "ios-node"
|
||||||
|
let displayName = defaults.string(forKey: "node.displayName") ?? "iOS Node"
|
||||||
|
|
||||||
|
return BridgeHello(
|
||||||
|
nodeId: nodeId,
|
||||||
|
displayName: displayName,
|
||||||
|
token: token,
|
||||||
|
platform: self.platformString(),
|
||||||
|
version: self.appVersion())
|
||||||
|
}
|
||||||
|
|
||||||
|
private func platformString() -> String {
|
||||||
|
let v = ProcessInfo.processInfo.operatingSystemVersion
|
||||||
|
return "iOS \(v.majorVersion).\(v.minorVersion).\(v.patchVersion)"
|
||||||
|
}
|
||||||
|
|
||||||
|
private func appVersion() -> String {
|
||||||
|
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "dev"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,79 @@
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum BridgeSettingsStore {
|
||||||
|
private static let bridgeService = "com.steipete.clawdis.bridge"
|
||||||
|
private static let nodeService = "com.steipete.clawdis.node"
|
||||||
|
|
||||||
|
private static let instanceIdDefaultsKey = "node.instanceId"
|
||||||
|
private static let preferredBridgeStableIDDefaultsKey = "bridge.preferredStableID"
|
||||||
|
|
||||||
|
private static let instanceIdAccount = "instanceId"
|
||||||
|
private static let preferredBridgeStableIDAccount = "preferredStableID"
|
||||||
|
|
||||||
|
static func bootstrapPersistence() {
|
||||||
|
self.ensureStableInstanceID()
|
||||||
|
self.ensurePreferredBridgeStableID()
|
||||||
|
}
|
||||||
|
|
||||||
|
static func loadStableInstanceID() -> String? {
|
||||||
|
KeychainStore.loadString(service: self.nodeService, account: self.instanceIdAccount)?
|
||||||
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func saveStableInstanceID(_ instanceId: String) {
|
||||||
|
_ = KeychainStore.saveString(instanceId, service: self.nodeService, account: self.instanceIdAccount)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func loadPreferredBridgeStableID() -> String? {
|
||||||
|
KeychainStore.loadString(service: self.bridgeService, account: self.preferredBridgeStableIDAccount)?
|
||||||
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func savePreferredBridgeStableID(_ stableID: String) {
|
||||||
|
_ = KeychainStore.saveString(
|
||||||
|
stableID,
|
||||||
|
service: self.bridgeService,
|
||||||
|
account: self.preferredBridgeStableIDAccount)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func ensureStableInstanceID() {
|
||||||
|
let defaults = UserDefaults.standard
|
||||||
|
|
||||||
|
if let existing = defaults.string(forKey: self.instanceIdDefaultsKey)?
|
||||||
|
.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||||
|
!existing.isEmpty
|
||||||
|
{
|
||||||
|
if self.loadStableInstanceID() == nil {
|
||||||
|
self.saveStableInstanceID(existing)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if let stored = self.loadStableInstanceID(), !stored.isEmpty {
|
||||||
|
defaults.set(stored, forKey: self.instanceIdDefaultsKey)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let fresh = UUID().uuidString
|
||||||
|
self.saveStableInstanceID(fresh)
|
||||||
|
defaults.set(fresh, forKey: self.instanceIdDefaultsKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func ensurePreferredBridgeStableID() {
|
||||||
|
let defaults = UserDefaults.standard
|
||||||
|
|
||||||
|
if let existing = defaults.string(forKey: self.preferredBridgeStableIDDefaultsKey)?
|
||||||
|
.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||||
|
!existing.isEmpty
|
||||||
|
{
|
||||||
|
if self.loadPreferredBridgeStableID() == nil {
|
||||||
|
self.savePreferredBridgeStableID(existing)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if let stored = self.loadPreferredBridgeStableID(), !stored.isEmpty {
|
||||||
|
defaults.set(stored, forKey: self.preferredBridgeStableIDDefaultsKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -3,14 +3,13 @@ import Security
|
||||||
|
|
||||||
enum KeychainStore {
|
enum KeychainStore {
|
||||||
static func loadString(service: String, account: String) -> String? {
|
static func loadString(service: String, account: String) -> String? {
|
||||||
var query: [String: Any] = [
|
let query: [String: Any] = [
|
||||||
kSecClass as String: kSecClassGenericPassword,
|
kSecClass as String: kSecClassGenericPassword,
|
||||||
kSecAttrService as String: service,
|
kSecAttrService as String: service,
|
||||||
kSecAttrAccount as String: account,
|
kSecAttrAccount as String: account,
|
||||||
kSecReturnData as String: true,
|
kSecReturnData as String: true,
|
||||||
kSecMatchLimit as String: kSecMatchLimitOne,
|
kSecMatchLimit as String: kSecMatchLimitOne,
|
||||||
]
|
]
|
||||||
query[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
|
|
||||||
|
|
||||||
var item: CFTypeRef?
|
var item: CFTypeRef?
|
||||||
let status = SecItemCopyMatching(query as CFDictionary, &item)
|
let status = SecItemCopyMatching(query as CFDictionary, &item)
|
||||||
|
|
@ -20,20 +19,20 @@ enum KeychainStore {
|
||||||
|
|
||||||
static func saveString(_ value: String, service: String, account: String) -> Bool {
|
static func saveString(_ value: String, service: String, account: String) -> Bool {
|
||||||
let data = Data(value.utf8)
|
let data = Data(value.utf8)
|
||||||
let base: [String: Any] = [
|
let query: [String: Any] = [
|
||||||
kSecClass as String: kSecClassGenericPassword,
|
kSecClass as String: kSecClassGenericPassword,
|
||||||
kSecAttrService as String: service,
|
kSecAttrService as String: service,
|
||||||
kSecAttrAccount as String: account,
|
kSecAttrAccount as String: account,
|
||||||
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly,
|
|
||||||
]
|
]
|
||||||
|
|
||||||
let update: [String: Any] = [kSecValueData as String: data]
|
let update: [String: Any] = [kSecValueData as String: data]
|
||||||
let status = SecItemUpdate(base as CFDictionary, update as CFDictionary)
|
let status = SecItemUpdate(query as CFDictionary, update as CFDictionary)
|
||||||
if status == errSecSuccess { return true }
|
if status == errSecSuccess { return true }
|
||||||
if status != errSecItemNotFound { return false }
|
if status != errSecItemNotFound { return false }
|
||||||
|
|
||||||
var insert = base
|
var insert = query
|
||||||
insert[kSecValueData as String] = data
|
insert[kSecValueData as String] = data
|
||||||
|
insert[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
|
||||||
return SecItemAdd(insert as CFDictionary, nil) == errSecSuccess
|
return SecItemAdd(insert as CFDictionary, nil) == errSecSuccess
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,19 +2,29 @@ import SwiftUI
|
||||||
|
|
||||||
@main
|
@main
|
||||||
struct ClawdisApp: App {
|
struct ClawdisApp: App {
|
||||||
@StateObject private var appModel = NodeAppModel()
|
@StateObject private var appModel: NodeAppModel
|
||||||
|
@StateObject private var bridgeController: BridgeConnectionController
|
||||||
@Environment(\.scenePhase) private var scenePhase
|
@Environment(\.scenePhase) private var scenePhase
|
||||||
|
|
||||||
|
init() {
|
||||||
|
BridgeSettingsStore.bootstrapPersistence()
|
||||||
|
let appModel = NodeAppModel()
|
||||||
|
_appModel = StateObject(wrappedValue: appModel)
|
||||||
|
_bridgeController = StateObject(wrappedValue: BridgeConnectionController(appModel: appModel))
|
||||||
|
}
|
||||||
|
|
||||||
var body: some Scene {
|
var body: some Scene {
|
||||||
WindowGroup {
|
WindowGroup {
|
||||||
RootCanvas()
|
RootCanvas()
|
||||||
.environmentObject(self.appModel)
|
.environmentObject(self.appModel)
|
||||||
.environmentObject(self.appModel.voiceWake)
|
.environmentObject(self.appModel.voiceWake)
|
||||||
|
.environmentObject(self.bridgeController)
|
||||||
.onOpenURL { url in
|
.onOpenURL { url in
|
||||||
Task { await self.appModel.handleDeepLink(url: url) }
|
Task { await self.appModel.handleDeepLink(url: url) }
|
||||||
}
|
}
|
||||||
.onChange(of: self.scenePhase) { _, newValue in
|
.onChange(of: self.scenePhase) { _, newValue in
|
||||||
self.appModel.setScenePhase(newValue)
|
self.appModel.setScenePhase(newValue)
|
||||||
|
self.bridgeController.setScenePhase(newValue)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct RootTabs: View {
|
struct RootTabs: View {
|
||||||
|
@EnvironmentObject private var appModel: NodeAppModel
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
TabView {
|
TabView {
|
||||||
ScreenTab()
|
ScreenTab()
|
||||||
|
|
@ -10,7 +12,71 @@ struct RootTabs: View {
|
||||||
.tabItem { Label("Voice", systemImage: "mic") }
|
.tabItem { Label("Voice", systemImage: "mic") }
|
||||||
|
|
||||||
SettingsTab()
|
SettingsTab()
|
||||||
.tabItem { Label("Settings", systemImage: "gearshape") }
|
.tabItem {
|
||||||
|
VStack {
|
||||||
|
ZStack(alignment: .topTrailing) {
|
||||||
|
Image(systemName: "gearshape")
|
||||||
|
Circle()
|
||||||
|
.fill(self.settingsIndicatorColor)
|
||||||
|
.frame(width: 9, height: 9)
|
||||||
|
.overlay(
|
||||||
|
Circle()
|
||||||
|
.stroke(.black.opacity(0.2), lineWidth: 0.5))
|
||||||
|
.shadow(
|
||||||
|
color: self.settingsIndicatorGlowColor,
|
||||||
|
radius: self.settingsIndicatorGlowRadius,
|
||||||
|
x: 0,
|
||||||
|
y: 0)
|
||||||
|
.offset(x: 7, y: -2)
|
||||||
|
}
|
||||||
|
Text("Settings")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum BridgeIndicatorState {
|
||||||
|
case connected
|
||||||
|
case connecting
|
||||||
|
case disconnected
|
||||||
|
}
|
||||||
|
|
||||||
|
private var bridgeIndicatorState: BridgeIndicatorState {
|
||||||
|
if self.appModel.bridgeServerName != nil { return .connected }
|
||||||
|
if self.appModel.bridgeStatusText.localizedCaseInsensitiveContains("connecting") { return .connecting }
|
||||||
|
return .disconnected
|
||||||
|
}
|
||||||
|
|
||||||
|
private var settingsIndicatorColor: Color {
|
||||||
|
switch self.bridgeIndicatorState {
|
||||||
|
case .connected:
|
||||||
|
Color.green
|
||||||
|
case .connecting:
|
||||||
|
Color.yellow
|
||||||
|
case .disconnected:
|
||||||
|
Color.red
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var settingsIndicatorGlowColor: Color {
|
||||||
|
switch self.bridgeIndicatorState {
|
||||||
|
case .connected:
|
||||||
|
Color.green.opacity(0.75)
|
||||||
|
case .connecting:
|
||||||
|
Color.yellow.opacity(0.6)
|
||||||
|
case .disconnected:
|
||||||
|
Color.clear
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var settingsIndicatorGlowRadius: CGFloat {
|
||||||
|
switch self.bridgeIndicatorState {
|
||||||
|
case .connected:
|
||||||
|
6
|
||||||
|
case .connecting:
|
||||||
|
4
|
||||||
|
case .disconnected:
|
||||||
|
0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,15 +12,15 @@ extension ConnectStatusStore: @unchecked Sendable {}
|
||||||
struct SettingsTab: View {
|
struct SettingsTab: View {
|
||||||
@EnvironmentObject private var appModel: NodeAppModel
|
@EnvironmentObject private var appModel: NodeAppModel
|
||||||
@EnvironmentObject private var voiceWake: VoiceWakeManager
|
@EnvironmentObject private var voiceWake: VoiceWakeManager
|
||||||
|
@EnvironmentObject private var bridgeController: BridgeConnectionController
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
@AppStorage("node.displayName") private var displayName: String = "iOS Node"
|
@AppStorage("node.displayName") private var displayName: String = "iOS Node"
|
||||||
@AppStorage("node.instanceId") private var instanceId: String = UUID().uuidString
|
@AppStorage("node.instanceId") private var instanceId: String = UUID().uuidString
|
||||||
@AppStorage("voiceWake.enabled") private var voiceWakeEnabled: Bool = false
|
@AppStorage("voiceWake.enabled") private var voiceWakeEnabled: Bool = false
|
||||||
|
@AppStorage("camera.enabled") private var cameraEnabled: Bool = true
|
||||||
@AppStorage("bridge.preferredStableID") private var preferredBridgeStableID: String = ""
|
@AppStorage("bridge.preferredStableID") private var preferredBridgeStableID: String = ""
|
||||||
@StateObject private var discovery = BridgeDiscoveryModel()
|
|
||||||
@StateObject private var connectStatus = ConnectStatusStore()
|
@StateObject private var connectStatus = ConnectStatusStore()
|
||||||
@State private var connectingBridgeID: String?
|
@State private var connectingBridgeID: String?
|
||||||
@State private var didAutoConnect = false
|
|
||||||
@State private var localIPAddress: String?
|
@State private var localIPAddress: String?
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
|
|
@ -58,8 +58,15 @@ struct SettingsTab: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Section("Camera") {
|
||||||
|
Toggle("Allow Camera", isOn: self.$cameraEnabled)
|
||||||
|
Text("Allows the bridge to request photos or short video clips (foreground only).")
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
Section("Bridge") {
|
Section("Bridge") {
|
||||||
LabeledContent("Discovery", value: self.discovery.statusText)
|
LabeledContent("Discovery", value: self.bridgeController.discoveryStatusText)
|
||||||
LabeledContent("Status", value: self.appModel.bridgeStatusText)
|
LabeledContent("Status", value: self.appModel.bridgeStatusText)
|
||||||
if let serverName = self.appModel.bridgeServerName {
|
if let serverName = self.appModel.bridgeServerName {
|
||||||
LabeledContent("Server", value: serverName)
|
LabeledContent("Server", value: serverName)
|
||||||
|
|
@ -120,31 +127,12 @@ struct SettingsTab: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
self.discovery.start()
|
|
||||||
self.localIPAddress = Self.primaryIPv4Address()
|
self.localIPAddress = Self.primaryIPv4Address()
|
||||||
}
|
}
|
||||||
.onDisappear { self.discovery.stop() }
|
.onChange(of: self.preferredBridgeStableID) { _, newValue in
|
||||||
.onChange(of: self.discovery.bridges) { _, newValue in
|
let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
if self.didAutoConnect { return }
|
guard !trimmed.isEmpty else { return }
|
||||||
if self.appModel.bridgeServerName != nil { return }
|
BridgeSettingsStore.savePreferredBridgeStableID(trimmed)
|
||||||
|
|
||||||
let existing = KeychainStore.loadString(
|
|
||||||
service: "com.steipete.clawdis.bridge",
|
|
||||||
account: self.keychainAccount())
|
|
||||||
guard let existing, !existing.isEmpty else { return }
|
|
||||||
guard let target = self.pickAutoConnectBridge(from: newValue) else { return }
|
|
||||||
|
|
||||||
self.didAutoConnect = true
|
|
||||||
self.preferredBridgeStableID = target.stableID
|
|
||||||
self.appModel.connectToBridge(
|
|
||||||
endpoint: target.endpoint,
|
|
||||||
hello: BridgeHello(
|
|
||||||
nodeId: self.instanceId,
|
|
||||||
displayName: self.displayName,
|
|
||||||
token: existing,
|
|
||||||
platform: self.platformString(),
|
|
||||||
version: self.appVersion()))
|
|
||||||
self.connectStatus.text = nil
|
|
||||||
}
|
}
|
||||||
.onChange(of: self.appModel.bridgeServerName) { _, _ in
|
.onChange(of: self.appModel.bridgeServerName) { _, _ in
|
||||||
self.connectStatus.text = nil
|
self.connectStatus.text = nil
|
||||||
|
|
@ -154,12 +142,12 @@ struct SettingsTab: View {
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private func bridgeList(showing: BridgeListMode) -> some View {
|
private func bridgeList(showing: BridgeListMode) -> some View {
|
||||||
if self.discovery.bridges.isEmpty {
|
if self.bridgeController.bridges.isEmpty {
|
||||||
Text("No bridges found yet.")
|
Text("No bridges found yet.")
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
} else {
|
} else {
|
||||||
let connectedID = self.appModel.connectedBridgeID
|
let connectedID = self.appModel.connectedBridgeID
|
||||||
let rows = self.discovery.bridges.filter { bridge in
|
let rows = self.bridgeController.bridges.filter { bridge in
|
||||||
let isConnected = bridge.stableID == connectedID
|
let isConnected = bridge.stableID == connectedID
|
||||||
switch showing {
|
switch showing {
|
||||||
case .all:
|
case .all:
|
||||||
|
|
@ -218,6 +206,7 @@ struct SettingsTab: View {
|
||||||
private func connect(_ bridge: BridgeDiscoveryModel.DiscoveredBridge) async {
|
private func connect(_ bridge: BridgeDiscoveryModel.DiscoveredBridge) async {
|
||||||
self.connectingBridgeID = bridge.id
|
self.connectingBridgeID = bridge.id
|
||||||
self.preferredBridgeStableID = bridge.stableID
|
self.preferredBridgeStableID = bridge.stableID
|
||||||
|
BridgeSettingsStore.savePreferredBridgeStableID(bridge.stableID)
|
||||||
defer { self.connectingBridgeID = nil }
|
defer { self.connectingBridgeID = nil }
|
||||||
|
|
||||||
do {
|
do {
|
||||||
|
|
@ -265,16 +254,6 @@ struct SettingsTab: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func pickAutoConnectBridge(from bridges: [BridgeDiscoveryModel.DiscoveredBridge]) -> BridgeDiscoveryModel
|
|
||||||
.DiscoveredBridge? {
|
|
||||||
if !self.preferredBridgeStableID.isEmpty,
|
|
||||||
let match = bridges.first(where: { $0.stableID == self.preferredBridgeStableID })
|
|
||||||
{
|
|
||||||
return match
|
|
||||||
}
|
|
||||||
return bridges.first
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func primaryIPv4Address() -> String? {
|
private static func primaryIPv4Address() -> String? {
|
||||||
var addrList: UnsafeMutablePointer<ifaddrs>?
|
var addrList: UnsafeMutablePointer<ifaddrs>?
|
||||||
guard getifaddrs(&addrList) == 0, let first = addrList else { return nil }
|
guard getifaddrs(&addrList) == 0, let first = addrList else { return nil }
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,14 @@ More debugging notes: `docs/bonjour.md`.
|
||||||
In Iris:
|
In Iris:
|
||||||
- Pick the discovered bridge (or hit refresh).
|
- Pick the discovered bridge (or hit refresh).
|
||||||
- If not paired yet, Iris will initiate pairing automatically.
|
- If not paired yet, Iris will initiate pairing automatically.
|
||||||
|
- After the first successful pairing, Iris will auto-reconnect to the **last bridge** on launch (including after reinstall), as long as the iOS Keychain entry is still present.
|
||||||
|
|
||||||
|
### Connection indicator (always visible)
|
||||||
|
|
||||||
|
The Settings tab icon shows a small status dot:
|
||||||
|
- **Green**: connected to the bridge
|
||||||
|
- **Yellow**: connecting
|
||||||
|
- **Red**: not connected / error
|
||||||
|
|
||||||
## 4) Approve pairing (CLI)
|
## 4) Approve pairing (CLI)
|
||||||
|
|
||||||
|
|
@ -119,7 +127,8 @@ The response includes `base64` PNG data (for debugging/verification).
|
||||||
- **iOS in background:** all `screen.*` commands fail fast with `NODE_BACKGROUND_UNAVAILABLE` (bring Iris to foreground).
|
- **iOS in background:** all `screen.*` commands fail fast with `NODE_BACKGROUND_UNAVAILABLE` (bring Iris to foreground).
|
||||||
- **mDNS blocked:** some networks block multicast; use a different LAN or plan a tailnet-capable bridge (see `docs/discovery.md`).
|
- **mDNS blocked:** some networks block multicast; use a different LAN or plan a tailnet-capable bridge (see `docs/discovery.md`).
|
||||||
- **Wrong node selector:** `--node` can be the node id (UUID), display name (e.g. `iOS Node`), IP, or an unambiguous prefix. If it’s ambiguous, the CLI will tell you.
|
- **Wrong node selector:** `--node` can be the node id (UUID), display name (e.g. `iOS Node`), IP, or an unambiguous prefix. If it’s ambiguous, the CLI will tell you.
|
||||||
- **Stale pairing:** if the token is lost, Iris must pair again; approve a new pending request.
|
- **Stale pairing / Keychain cleared:** if the pairing token is missing (or iOS Keychain was wiped), Iris must pair again; approve a new pending request.
|
||||||
|
- **App reinstall but no reconnect:** Iris restores `instanceId` + last bridge preference from Keychain; if it still comes up “unpaired”, verify Keychain persistence on your device/simulator and re-pair once.
|
||||||
|
|
||||||
## Related docs
|
## Related docs
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue