diff --git a/scarf/Scarf iOS/App/ScarfIOSApp.swift b/scarf/Scarf iOS/App/ScarfIOSApp.swift index 9b3a974..bbb371e 100644 --- a/scarf/Scarf iOS/App/ScarfIOSApp.swift +++ b/scarf/Scarf iOS/App/ScarfIOSApp.swift @@ -1,6 +1,7 @@ import SwiftUI import ScarfCore import ScarfIOS +import os /// App entry point. Renders a single `WindowGroup` whose root decides /// between onboarding and the connected-app surface based on whether @@ -116,19 +117,37 @@ final class RootModel { /// having to re-query stores on every re-render. private(set) var servers: [ServerID: IOSServerConfig] = [:] + /// Most recent non-fatal failure surfaced from RootModel operations + /// (load, connect, forget). The ServerListView renders a banner above + /// the list when this is non-nil with a Retry/Dismiss affordance. + /// `nil` after a successful op so stale errors don't linger. + var lastError: String? + private let keyStore: any SSHKeyStore private let configStore: any IOSServerConfigStore + private static let logger = Logger( + subsystem: "com.scarf.ios", + category: "RootModel" + ) + init(keyStore: any SSHKeyStore, configStore: any IOSServerConfigStore) { self.keyStore = keyStore self.configStore = configStore } + /// Clear the surfaced error. Called by the ServerListView banner's + /// Dismiss button. + func clearLastError() { + lastError = nil + } + /// Load configured servers from disk and pick an initial state. func load() async { do { let all = try await configStore.listAll() servers = all + lastError = nil if all.isEmpty { // Fresh install or user forgot every server → go // straight to onboarding with a new ID reserved so @@ -138,7 +157,14 @@ final class RootModel { state = .serverList } } catch { + // configStore is UserDefaults-backed; failures here are + // exceptional (corrupted v2 blob, JSONDecoder error). Surface + // the error to the user but recover into onboarding so they + // aren't permanently locked out of the app — the state is + // unsalvageable, the user needs to re-onboard anyway. + Self.logger.error("RootModel.load failed: \(error.localizedDescription, privacy: .public)") servers = [:] + lastError = "Couldn't load saved servers (\(error.localizedDescription)). Starting fresh." state = .onboarding(forNewServer: ServerID()) } } @@ -179,18 +205,33 @@ final class RootModel { /// Tap a server row → connect. Loads fresh from disk to catch any /// edits made through the Mac app (or future multi-device scenarios). func connect(to id: ServerID) async { - var diskConfig: IOSServerConfig? = servers[id] - if diskConfig == nil { - diskConfig = try? await configStore.load(id: id) + do { + var diskConfig: IOSServerConfig? = servers[id] + if diskConfig == nil { + diskConfig = try await configStore.load(id: id) + } + let diskKey: SSHKeyBundle? = try await keyStore.load(for: id) + guard let config = diskConfig, let key = diskKey else { + // Genuine "no row" / "no key" — preserve the pre-A.3 + // behaviour: re-onboard under this ID so the user keeps + // host/user/port and just regenerates the key. + state = .onboarding(forNewServer: id) + return + } + lastError = nil + state = .connected(id, config, key) + } catch { + // Transient Keychain errors (biometric cancel, device + // locked, OS-level Keychain corruption) used to drop the + // user into fresh onboarding — destroying useful state. + // Now we keep them on the server list with a banner so + // they can retry once the Keychain is reachable again. + Self.logger.error( + "RootModel.connect failed for \(id, privacy: .public): \(error.localizedDescription, privacy: .public)" + ) + lastError = "Couldn't unlock server credentials: \(error.localizedDescription)" + state = .serverList } - let diskKey: SSHKeyBundle? = try? await keyStore.load(for: id) - guard let config = diskConfig, let key = diskKey else { - // Missing key → force re-onboarding under this ID so the - // user can regenerate without losing host/user/port. - state = .onboarding(forNewServer: id) - return - } - state = .connected(id, config, key) } /// Soft disconnect: return to the server list without wiping @@ -211,20 +252,60 @@ final class RootModel { /// Hard forget: wipe the specified server's key + config, refresh /// the list, transition to serverList (or onboarding if empty). + /// Per-store failures are captured in `lastError` so a partial + /// forget surfaces a banner instead of silently leaving orphans. func forget(id: ServerID) async { - try? await keyStore.delete(for: id) - try? await configStore.delete(id: id) + var failures: [String] = [] + do { + try await keyStore.delete(for: id) + } catch { + Self.logger.error( + "RootModel.forget keyStore.delete failed for \(id, privacy: .public): \(error.localizedDescription, privacy: .public)" + ) + failures.append("Keychain: \(error.localizedDescription)") + } + do { + try await configStore.delete(id: id) + } catch { + Self.logger.error( + "RootModel.forget configStore.delete failed for \(id, privacy: .public): \(error.localizedDescription, privacy: .public)" + ) + failures.append("Config: \(error.localizedDescription)") + } + // Reload from disk so in-memory state reflects what's actually + // persisted — covers the partial-failure case where Keychain + // succeeded but config didn't (or vice versa). servers = (try? await configStore.listAll()) ?? [:] + if failures.isEmpty { + lastError = nil + } else { + lastError = "Couldn't fully forget server: " + failures.joined(separator: "; ") + } state = servers.isEmpty ? .onboarding(forNewServer: ServerID()) : .serverList } /// Legacy v1 "Disconnect" that wipes EVERYTHING. Kept for back-compat /// with any caller that still hits the no-arg path (there shouldn't /// be any after 3.5 lands, but the protocol still supports it). + /// Same partial-failure semantics as `forget(id:)`. func disconnect() async { - try? await keyStore.delete() - try? await configStore.delete() - servers = [:] + var failures: [String] = [] + do { + try await keyStore.delete() + } catch { + Self.logger.error("RootModel.disconnect keyStore.delete failed: \(error.localizedDescription, privacy: .public)") + failures.append("Keychain: \(error.localizedDescription)") + } + do { + try await configStore.delete() + } catch { + Self.logger.error("RootModel.disconnect configStore.delete failed: \(error.localizedDescription, privacy: .public)") + failures.append("Config: \(error.localizedDescription)") + } + servers = (try? await configStore.listAll()) ?? [:] + if !failures.isEmpty { + lastError = "Couldn't fully sign out: " + failures.joined(separator: "; ") + } state = .onboarding(forNewServer: ServerID()) } } diff --git a/scarf/Scarf iOS/Servers/ServerListView.swift b/scarf/Scarf iOS/Servers/ServerListView.swift index 851ccaa..115356e 100644 --- a/scarf/Scarf iOS/Servers/ServerListView.swift +++ b/scarf/Scarf iOS/Servers/ServerListView.swift @@ -19,6 +19,26 @@ struct ServerListView: View { var body: some View { NavigationStack { List { + if let err = model.lastError { + Section { + VStack(alignment: .leading, spacing: 8) { + Label("Something went wrong", systemImage: "exclamationmark.triangle.fill") + .foregroundStyle(.orange) + .font(.headline) + Text(err) + .font(.callout) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + HStack(spacing: 12) { + Button("Dismiss") { model.clearLastError() } + .buttonStyle(.bordered) + .controlSize(.small) + } + } + .padding(.vertical, 4) + } + } + Section { ForEach(sortedServers, id: \.id) { row in ServerListRow(row: row) {