mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-10 10:36:35 +00:00
5cac3836cf
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>
140 lines
4.7 KiB
Swift
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()
|
|
}
|
|
)
|
|
}
|
|
}
|
|
}
|