Files
scarf/scarf/Scarf iOS/App/ScarfIOSApp.swift
T
Alan Wizemann 5cac3836cf M8: TabView root navigation (Chat / Dashboard / Memory / More)
Pass-1 loudest UX complaint — "I don't see any navigation" — was
rooted in the Dashboard-as-hub pattern. Chat/Memory/Cron/Skills/
Settings lived as a NavigationLink section halfway down a scrolling
List, below the stats + recent sessions. Users had to scroll to
find any feature. That was the right shape for a very-early MVP
but the wrong shape for a companion app whose primary tab should
be Chat.

New `ScarfGoTabRoot` renders a 4-tab TabView at the scene root:

- **Chat** — primary tab. Tapping the app opens straight into it.
- **Dashboard** — stats + recent sessions (stripped of Surfaces /
  Connected-to / Disconnect, which now live in More).
- **Memory** — MEMORY.md + USER.md + SOUL.md, unchanged.
- **More** — bucket for Cron / Skills / Settings plus the
  destructive Forget-this-server action. Also shows the host /
  user / port info as a read-only section.

Uses iOS 18's `.tabViewStyle(.sidebarAdaptable)` so the same tree
degrades to a bottom tab bar on iPhone and renders as a native
sidebar on iPadOS / macCatalyst if we add those targets later — no
UI code change required. Matches the M8 density research's sidebar
recommendation.

Each tab owns its own NavigationStack so push navigation (Cron
editor, Memory detail, chat session list) stays scoped to that tab
and doesn't bleed across.

DashboardView is now simpler: just stats + recent sessions. The
Forget confirmation + Disconnect button moved wholesale to
MoreTab inside ScarfGoTabRoot.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 13:38:03 +02:00

140 lines
4.7 KiB
Swift

import SwiftUI
import ScarfCore
import ScarfIOS
/// App entry point. Renders a single `WindowGroup` whose root decides
/// between onboarding and the connected-app surface based on whether
/// a `IOSServerConfig` + `SSHKeyBundle` pair is already stored.
@main
struct ScarfIOSApp: App {
@State private var root = RootModel(
keyStore: KeychainSSHKeyStore(),
configStore: UserDefaultsIOSServerConfigStore()
)
init() {
// Wire ScarfCore's transport factory to produce Citadel-backed
// `ServerTransport`s for every `.ssh` context. Without this,
// `ServerContext.makeTransport()` would fall back to the
// Mac-only `SSHTransport` which shells out to `/usr/bin/ssh`
// not present on iOS.
//
// Each call builds a fresh `CitadelServerTransport`. The
// transport itself lazily opens + caches a single long-lived
// SSH connection internally, so the per-call overhead is
// just the factory invocation, not a new SSH handshake.
ServerContext.sshTransportFactory = { id, config, displayName in
CitadelServerTransport(
contextID: id,
config: config,
displayName: displayName,
keyProvider: {
// The transport needs the SSH key every time it
// (re)opens an SSH session. We re-read from the
// Keychain each time rather than caching in memory
// so Keychain-level access controls (After First
// Unlock) are honoured.
let store = KeychainSSHKeyStore()
guard let key = try await store.load() else {
throw SSHKeyStoreError.backendFailure(
message: "No SSH key in Keychain — re-run onboarding.",
osStatus: nil
)
}
return key
}
)
}
}
var body: some Scene {
WindowGroup {
RootView(model: root)
.task { await root.load() }
// Clamp Dynamic Type at the scene root. ScarfGo is a
// developer tool that needs more density than Apple's
// .xxxLarge default, but we still scale from .xSmall
// to .accessibility2 so users who need larger text can
// get it without breaking the layout. Going past
// .accessibility2 (~XL accessibility) collapses
// multi-column rows and forces text truncation not
// a win for anyone. Cross-checked against
// Use-Your-Loaf's "Restricting Dynamic Type Sizes"
// guidance (M8 density research).
.dynamicTypeSize(.xSmall ... .accessibility2)
}
}
}
/// Decides whether the user needs to onboard or can see the Dashboard.
@Observable
@MainActor
final class RootModel {
enum State {
case loading
case onboarding
case connected(IOSServerConfig, SSHKeyBundle)
}
private(set) var state: State = .loading
private let keyStore: any SSHKeyStore
private let configStore: any IOSServerConfigStore
init(keyStore: any SSHKeyStore, configStore: any IOSServerConfigStore) {
self.keyStore = keyStore
self.configStore = configStore
}
func load() async {
do {
let key = try await keyStore.load()
let cfg = try await configStore.load()
if let key, let cfg {
state = .connected(cfg, key)
} else {
state = .onboarding
}
} catch {
// Corrupted state re-onboard. Logging would go here.
state = .onboarding
}
}
/// Called from OnboardingView when the flow reaches `.connected`.
/// Re-reads the stores and flips the root state.
func onboardingFinished() async {
await load()
}
/// Called from Dashboard "Disconnect" to wipe state and restart onboarding.
func disconnect() async {
try? await keyStore.delete()
try? await configStore.delete()
state = .onboarding
}
}
struct RootView: View {
let model: RootModel
var body: some View {
switch model.state {
case .loading:
ProgressView("Loading…")
case .onboarding:
OnboardingRootView(onFinished: {
await model.onboardingFinished()
})
case .connected(let config, let key):
ScarfGoTabRoot(
config: config,
key: key,
onDisconnect: {
await model.disconnect()
}
)
}
}
}