mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-10 10:36:35 +00:00
iOS port M2: iOS app skeleton — onboarding, Citadel wrapper, Keychain, Dashboard
First iOS phase. Delivers all the code needed to build + TestFlight a
functional v1 iOS app (onboarding with SSH-key generate / import +
real Citadel-backed connection test; persistent Keychain key +
UserDefaults server config; placeholder Dashboard) — but NOT the
scarf-ios.xcodeproj. Creating that from scratch by hand is too risky
without an iOS SDK to build against, so Alan creates it in Xcode's UI
following scarf/scarf-ios/SETUP.md (~5 minutes, one-time).
## ScarfCore additions (all Linux-testable)
Packages/ScarfCore/Sources/ScarfCore/Security/:
- SSHKey.swift — SSHKeyBundle + SSHKeyStore protocol
+ InMemorySSHKeyStore test actor
- IOSServerConfig.swift — IOSServerConfig + store protocol + mock;
toServerContext(id:) bridges to the
existing ServerContext so all ScarfCore
services work against an iOS config
- OnboardingState.swift — OnboardingStep enum + pure validators
(host, port, PEM shape, public-key parse)
- SSHConnectionTester.swift — protocol + error enum + mock
- OnboardingViewModel.swift — @Observable @MainActor state machine,
fully dependency-injected (key store /
config store / tester / generator closure)
## New Packages/ScarfIOS local SPM package
Depends on ScarfCore + Citadel (from: "0.7.0").
- KeychainSSHKeyStore.swift — real iOS Keychain storage
(kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly, no iCloud
sync). Gated on canImport(Security) for Linux skip.
- UserDefaultsIOSServerConfigStore.swift — JSON-encoded single-key
persistence of IOSServerConfig.
- Ed25519KeyGenerator.swift — CryptoKit-backed Ed25519 minting.
Emits standard OpenSSH public-key lines (authorized_keys-ready).
Stores the private half in a compact SCARF ED25519 PRIVATE KEY
PEM shape that CitadelSSHService decodes back into a
Curve25519.Signing.PrivateKey. Non-interop with OpenSSH's
`BEGIN OPENSSH PRIVATE KEY` envelope — export flow for sharing
keys is deferred to a later phase.
- CitadelSSHService.swift — SSHConnectionTester conformance +
key-generation wrapper. Runs `echo scarf-ok` over a one-shot
Citadel exec for the onboarding connection test. One FIXME on
buildClientSettings because Citadel 0.7→0.9 shifted the
`.ed25519(...)` authentication-method variant name; every other
line is Citadel-version-independent. Gated on
canImport(Citadel) && canImport(CryptoKit).
## scarf/scarf-ios/ app source tree
- App/ScarfIOSApp.swift — @main, RootModel routes to
onboarding or dashboard based on
stored state.
- Onboarding/OnboardingRootView.swift — 8 sub-views, one per
OnboardingStep. Validated
server-details form, key-source
picker, generate / show-public
/ import / test / retry /
connected.
- Dashboard/DashboardView.swift — M2 placeholder: connected host
details + Disconnect button.
M3 replaces with real data.
## scarf/scarf-ios/SETUP.md
Step-by-step Xcode project creation:
- iOS 18 / iPhone-only / team 3Q6X2L86C4 / Bundle ID
com.scarf.scarf-ios / Swift 5 language mode.
- Wire Packages/ScarfCore + Packages/ScarfIOS (Citadel resolves
transitively).
- Replace Xcode's default scaffolded files with this source tree.
- Smoke-test procedure (simulator → physical iPhone).
- TestFlight upload steps.
- Troubleshooting for the known Citadel-variant-name drift.
## Test coverage (Linux, `swift test`)
M2OnboardingTests, 26 new tests (ScarfCore):
- SSHKeyBundle memberwise + display fingerprint
- InMemorySSHKeyStore + InMemoryIOSServerConfigStore round-trips
- IOSServerConfig.toServerContext bridging (with + without
remoteHome override)
- All OnboardingLogic validators (empty / whitespace / port range /
legacy-RSA rejection / public-key line parser)
- MockSSHConnectionTester scripting (success + failure)
- 10 OnboardingViewModel end-to-end paths: happy-path
save-and-test, invalid-host blocks advance, connection-failure
routes to .testFailed (and crucially does NOT save config),
retry-after-failure-works, import-happy, import-rejects-bad-PEM,
reset clears all state
ScarfIOSSmokeTests, 3 tests (Apple-only, won't run on Linux):
- Ed25519KeyGenerator bundle shape + base64 wire format
- OpenSSH public-key line byte-length pinned at 51 bytes
- Corrupted PEM rejection on round-trip decode
Running
docker run --rm -v $PWD/scarf/Packages/ScarfCore:/work -w /work swift:6.0 swift test
reports **88 / 88 passing** (62 pre-M2 + 26 new).
## Real bug caught in development
First pass of OnboardingViewModel had `confirmPublicKeyAdded()` set
`isWorking=true`, then call `runConnectionTest()` which bailed on
`!isWorking` — meaning the connection probe never ran and the config
was never saved. Caught by the end-to-end test. Fixed by extracting
the shared probe body into `performConnectionTest()` and letting
both entry points own their own `isWorking` transition.
## Manual validation still needed on Mac
1. Xcode project creation per SETUP.md — confirm the resulting
project builds cleanly.
2. Citadel 0.9.x authentication-method variant — verify the one
FIXME line in buildClientSettings.
3. End-to-end onboarding: simulator against `localhost:22` (or a
test host), then TestFlight → physical iPhone → real SSH host
with the shown public key in authorized_keys.
Updated scarf/docs/IOS_PORT_PLAN.md with M2's shipped scope, the
scope decision about NOT generating the xcodeproj, and the list of
rules M3+ can rely on (Citadel transport dispatch, ChannelFactory
hook, single-server invariant).
https://claude.ai/code/session_019yMRP6mwZWfzVrPTqevx2y
This commit is contained in:
@@ -0,0 +1,96 @@
|
||||
import Foundation
|
||||
|
||||
/// Persistent connection parameters for the iOS app's single
|
||||
/// configured Hermes server.
|
||||
///
|
||||
/// **iOS is single-server in v1.** Multi-server management comes in
|
||||
/// a later phase; until then this one record is all the storage the
|
||||
/// app needs outside of the Keychain-backed SSH key.
|
||||
public struct IOSServerConfig: Sendable, Hashable, Codable {
|
||||
/// Hostname or `~/.ssh/config`-like alias typed by the user.
|
||||
public var host: String
|
||||
/// Remote username. Optional — `nil` defers to whatever login the
|
||||
/// remote SSH daemon considers default (unlike the Mac app,
|
||||
/// iOS can't consult `~/.ssh/config`, so we usually want this set).
|
||||
public var user: String?
|
||||
/// TCP port. `nil` → 22.
|
||||
public var port: Int?
|
||||
/// Remote path to `hermes` binary. `nil` → rely on remote `$PATH`.
|
||||
public var hermesBinaryHint: String?
|
||||
/// Override for the remote `$HOME/.hermes` directory. `nil` →
|
||||
/// `~/.hermes` (expanded by the remote shell).
|
||||
public var remoteHome: String?
|
||||
/// User-chosen label that shows up in the UI. Defaults to the
|
||||
/// hostname but users can rename (e.g. "Home Server").
|
||||
public var displayName: String
|
||||
|
||||
public init(
|
||||
host: String,
|
||||
user: String? = nil,
|
||||
port: Int? = nil,
|
||||
hermesBinaryHint: String? = nil,
|
||||
remoteHome: String? = nil,
|
||||
displayName: String
|
||||
) {
|
||||
self.host = host
|
||||
self.user = user
|
||||
self.port = port
|
||||
self.hermesBinaryHint = hermesBinaryHint
|
||||
self.remoteHome = remoteHome
|
||||
self.displayName = displayName
|
||||
}
|
||||
|
||||
/// Convenience bridge to the `ServerContext` that services across
|
||||
/// ScarfCore use (`HermesDataService(context:)` etc.). The returned
|
||||
/// context carries the SSH-kind so any transport constructed from
|
||||
/// it runs over SSH.
|
||||
///
|
||||
/// **Note:** The iOS `SSHTransport` path won't actually exec
|
||||
/// `/usr/bin/ssh` (which doesn't exist on iOS). In M3 a Citadel-
|
||||
/// backed `ServerTransport` will replace that — at which point
|
||||
/// `makeTransport()` on an iOS `ServerContext` will dispatch to
|
||||
/// the Citadel one, and the rest of the service layer continues
|
||||
/// unchanged.
|
||||
public func toServerContext(id: ServerID) -> ServerContext {
|
||||
let ssh = SSHConfig(
|
||||
host: host,
|
||||
user: user,
|
||||
port: port,
|
||||
identityFile: nil, // key comes from Keychain on iOS
|
||||
remoteHome: remoteHome,
|
||||
hermesBinaryHint: hermesBinaryHint
|
||||
)
|
||||
return ServerContext(
|
||||
id: id,
|
||||
displayName: displayName,
|
||||
kind: .ssh(ssh)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Async-safe single-record storage contract. iOS implements this
|
||||
/// with `UserDefaults`; tests use `InMemoryIOSServerConfigStore`.
|
||||
public protocol IOSServerConfigStore: Sendable {
|
||||
/// Returns the stored config, or `nil` if nothing has been saved
|
||||
/// yet (fresh install, or the user reset onboarding).
|
||||
func load() async throws -> IOSServerConfig?
|
||||
|
||||
/// Overwrites any existing config. Idempotent.
|
||||
func save(_ config: IOSServerConfig) async throws
|
||||
|
||||
/// Deletes the stored config. No-op if empty.
|
||||
func delete() async throws
|
||||
}
|
||||
|
||||
/// Process-lifetime in-memory config store. For tests and previews.
|
||||
public actor InMemoryIOSServerConfigStore: IOSServerConfigStore {
|
||||
private var config: IOSServerConfig?
|
||||
|
||||
public init(initial: IOSServerConfig? = nil) {
|
||||
self.config = initial
|
||||
}
|
||||
|
||||
public func load() async throws -> IOSServerConfig? { config }
|
||||
public func save(_ config: IOSServerConfig) async throws { self.config = config }
|
||||
public func delete() async throws { config = nil }
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import Foundation
|
||||
|
||||
/// The screens the iOS onboarding flow moves through. Kept out of the
|
||||
/// iOS package so the transition logic is exercised by tests that
|
||||
/// don't need a simulator.
|
||||
///
|
||||
/// **Flow:**
|
||||
/// ```
|
||||
/// .serverDetails ─▶ .keySource (user taps "Next")
|
||||
/// .keySource ─▶ .generate (user picks "Create new key")
|
||||
/// ─▶ .importKey (user picks "Import existing key")
|
||||
/// .generate ─▶ .showPublicKey (key pair minted, show public key to copy)
|
||||
/// .importKey ─▶ .showPublicKey (OR .testConnection — if import succeeds)
|
||||
/// .showPublicKey ─▶ .testConnection (user confirms they've added the key to authorized_keys)
|
||||
/// .testConnection ─▶ .connected (ssh exec "echo ok" succeeded)
|
||||
/// ─▶ .testFailed (connect failed — allow retry)
|
||||
/// .testFailed ─▶ .testConnection (retry)
|
||||
/// ─▶ .serverDetails (back)
|
||||
/// ```
|
||||
public enum OnboardingStep: Sendable, Equatable {
|
||||
case serverDetails
|
||||
case keySource
|
||||
case generate
|
||||
case importKey
|
||||
case showPublicKey
|
||||
case testConnection
|
||||
case testFailed(reason: String)
|
||||
case connected
|
||||
}
|
||||
|
||||
/// What the user wants to do with the SSH key at the start of
|
||||
/// onboarding.
|
||||
public enum OnboardingKeyChoice: Sendable, Equatable {
|
||||
case generate
|
||||
case importExisting
|
||||
}
|
||||
|
||||
/// Validation result for the server-details form. The onboarding view
|
||||
/// consumes this to decide whether the "Next" button is enabled.
|
||||
public struct OnboardingServerDetailsValidation: Sendable, Equatable {
|
||||
public var isHostValid: Bool
|
||||
public var isPortValid: Bool
|
||||
public var canAdvance: Bool
|
||||
|
||||
public init(isHostValid: Bool, isPortValid: Bool, canAdvance: Bool) {
|
||||
self.isHostValid = isHostValid
|
||||
self.isPortValid = isPortValid
|
||||
self.canAdvance = canAdvance
|
||||
}
|
||||
}
|
||||
|
||||
/// Pure functions used by the iOS onboarding. Kept in ScarfCore so
|
||||
/// the logic is testable on any platform without mocking SwiftUI.
|
||||
public enum OnboardingLogic {
|
||||
/// Validate the typed host + port. Host must be non-empty and
|
||||
/// not contain whitespace; port (if provided) must be a valid
|
||||
/// 1-65535 integer. User + remoteHome + hermesBinaryHint are
|
||||
/// all optional; their emptiness doesn't block advancement.
|
||||
public static func validateServerDetails(
|
||||
host: String,
|
||||
portText: String
|
||||
) -> OnboardingServerDetailsValidation {
|
||||
let trimmed = host.trimmingCharacters(in: .whitespaces)
|
||||
let hostValid = !trimmed.isEmpty
|
||||
&& trimmed.rangeOfCharacter(from: .whitespacesAndNewlines) == nil
|
||||
|
||||
let portValid: Bool
|
||||
if portText.isEmpty {
|
||||
portValid = true
|
||||
} else if let n = Int(portText), (1...65535).contains(n) {
|
||||
portValid = true
|
||||
} else {
|
||||
portValid = false
|
||||
}
|
||||
|
||||
return OnboardingServerDetailsValidation(
|
||||
isHostValid: hostValid,
|
||||
isPortValid: portValid,
|
||||
canAdvance: hostValid && portValid
|
||||
)
|
||||
}
|
||||
|
||||
/// Build the OpenSSH `authorized_keys`-style line the user should
|
||||
/// paste into their remote account, including a trailing newline
|
||||
/// so append-style workflows don't merge lines.
|
||||
public static func authorizedKeysLine(for bundle: SSHKeyBundle) -> String {
|
||||
bundle.publicKeyOpenSSH.trimmingCharacters(in: .whitespacesAndNewlines) + "\n"
|
||||
}
|
||||
|
||||
/// Basic sanity-check an imported OpenSSH private key PEM. Accepts
|
||||
/// `-----BEGIN OPENSSH PRIVATE KEY-----` + `-----END …-----` bookends
|
||||
/// and a non-empty body between them. Rejects legacy `BEGIN RSA
|
||||
/// PRIVATE KEY` formats — users who only have those need to
|
||||
/// re-export their key with `ssh-keygen -p -m PEM-OpenSSH`.
|
||||
///
|
||||
/// Does NOT verify the key is cryptographically valid — that's
|
||||
/// Citadel's job at connect time. This is just a "did the user
|
||||
/// paste the right thing?" gate on the import form.
|
||||
public static func isLikelyValidOpenSSHPrivateKey(_ pem: String) -> Bool {
|
||||
let text = pem.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard text.hasPrefix("-----BEGIN OPENSSH PRIVATE KEY-----") else { return false }
|
||||
guard text.hasSuffix("-----END OPENSSH PRIVATE KEY-----") else { return false }
|
||||
// Body between the bookends must be non-trivial.
|
||||
let lines = text.components(separatedBy: "\n").filter { !$0.isEmpty }
|
||||
return lines.count >= 3
|
||||
}
|
||||
|
||||
/// Extract the public-key line embedded in a user-supplied
|
||||
/// import. Some users paste their `authorized_keys` line (just
|
||||
/// the public half). We accept that — the flow lets them skip
|
||||
/// the "show public key" step since they already have it on
|
||||
/// the remote.
|
||||
///
|
||||
/// Returns the trimmed line if it matches the `ssh-* AAAA… [comment]`
|
||||
/// shape; nil otherwise.
|
||||
public static func parseOpenSSHPublicKeyLine(_ s: String) -> String? {
|
||||
let trimmed = s.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let parts = trimmed.split(separator: " ", maxSplits: 2)
|
||||
guard parts.count >= 2 else { return nil }
|
||||
let algo = parts[0]
|
||||
let keyBlob = parts[1]
|
||||
let validAlgos: Set<Substring> = [
|
||||
"ssh-ed25519", "ssh-rsa", "ecdsa-sha2-nistp256",
|
||||
"ecdsa-sha2-nistp384", "ecdsa-sha2-nistp521",
|
||||
]
|
||||
guard validAlgos.contains(algo) else { return nil }
|
||||
// Base64 blob should start with a standard base64 alphabet.
|
||||
let blobFirstCharOK = keyBlob.first.map {
|
||||
$0.isLetter || $0.isNumber
|
||||
} ?? false
|
||||
guard blobFirstCharOK else { return nil }
|
||||
return trimmed
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
import Foundation
|
||||
import Observation
|
||||
|
||||
/// Drives the iOS onboarding flow's state. SwiftUI views bind to this
|
||||
/// via `@State var viewModel: OnboardingViewModel`; tests drive it
|
||||
/// directly without a UI.
|
||||
///
|
||||
/// This VM lives in ScarfCore (not ScarfIOS) because the state-machine
|
||||
/// transitions are pure logic — no Keychain, no Citadel, no UIKit.
|
||||
/// The concrete storage + SSH implementations are injected as
|
||||
/// protocol references so tests can stub them.
|
||||
@Observable
|
||||
@MainActor
|
||||
public final class OnboardingViewModel {
|
||||
// MARK: - Public state
|
||||
|
||||
public private(set) var step: OnboardingStep = .serverDetails
|
||||
|
||||
/// Input fields for the server-details screen.
|
||||
public var host: String = ""
|
||||
public var user: String = ""
|
||||
public var portText: String = ""
|
||||
public var displayName: String = ""
|
||||
|
||||
/// What the user picks on the key-source screen.
|
||||
public private(set) var keyChoice: OnboardingKeyChoice?
|
||||
|
||||
/// Freshly-generated or freshly-imported key bundle. Populated on
|
||||
/// the transition INTO `.showPublicKey`; the view reads
|
||||
/// `keyBundle?.publicKeyOpenSSH` to display.
|
||||
public private(set) var keyBundle: SSHKeyBundle?
|
||||
|
||||
/// Raw PEM the user pasted on the import screen. Bound by the
|
||||
/// import form's text editor.
|
||||
public var importPEM: String = ""
|
||||
|
||||
/// Is some async operation in flight (key generation, connection
|
||||
/// test, save)? Views disable buttons while `true`.
|
||||
public private(set) var isWorking: Bool = false
|
||||
|
||||
/// Last connection-test error, if any — surfaced on the
|
||||
/// `.testFailed` screen.
|
||||
public private(set) var lastTestError: SSHConnectionTestError?
|
||||
|
||||
// MARK: - Dependencies
|
||||
|
||||
/// Produces a fresh Ed25519 keypair. Lives in ScarfIOS on real
|
||||
/// builds (`CitadelSSHService`) and in tests as a closure that
|
||||
/// returns a fixed bundle.
|
||||
public typealias KeyGenerator = @Sendable () async throws -> SSHKeyBundle
|
||||
|
||||
private let keyStore: any SSHKeyStore
|
||||
private let configStore: any IOSServerConfigStore
|
||||
private let tester: any SSHConnectionTester
|
||||
private let keyGenerator: KeyGenerator
|
||||
|
||||
public init(
|
||||
keyStore: any SSHKeyStore,
|
||||
configStore: any IOSServerConfigStore,
|
||||
tester: any SSHConnectionTester,
|
||||
keyGenerator: @escaping KeyGenerator
|
||||
) {
|
||||
self.keyStore = keyStore
|
||||
self.configStore = configStore
|
||||
self.tester = tester
|
||||
self.keyGenerator = keyGenerator
|
||||
}
|
||||
|
||||
// MARK: - Derived
|
||||
|
||||
public var serverDetailsValidation: OnboardingServerDetailsValidation {
|
||||
OnboardingLogic.validateServerDetails(host: host, portText: portText)
|
||||
}
|
||||
|
||||
// MARK: - Transitions
|
||||
|
||||
public func advanceFromServerDetails() {
|
||||
guard serverDetailsValidation.canAdvance else { return }
|
||||
step = .keySource
|
||||
}
|
||||
|
||||
public func pickKeyChoice(_ choice: OnboardingKeyChoice) {
|
||||
keyChoice = choice
|
||||
switch choice {
|
||||
case .generate: step = .generate
|
||||
case .importExisting: step = .importKey
|
||||
}
|
||||
}
|
||||
|
||||
/// Called from the "Generate" screen. Runs the injected generator,
|
||||
/// stores the bundle, and advances to `.showPublicKey`.
|
||||
public func generateKey() async {
|
||||
guard !isWorking else { return }
|
||||
isWorking = true
|
||||
defer { isWorking = false }
|
||||
|
||||
do {
|
||||
let bundle = try await keyGenerator()
|
||||
self.keyBundle = bundle
|
||||
step = .showPublicKey
|
||||
} catch {
|
||||
// Generation really shouldn't fail under normal circumstances
|
||||
// (it's CPU, no network). Surface as testFailed for now so
|
||||
// the user can retry; there's no dedicated error screen.
|
||||
lastTestError = .other("Key generation failed: \(error.localizedDescription)")
|
||||
step = .testFailed(reason: "Failed to generate SSH key: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
/// Called from the "Import" screen after the user pastes PEM.
|
||||
/// Validates the shape; on success populates `keyBundle` and
|
||||
/// moves to `.showPublicKey` so the user can still copy the public
|
||||
/// key if they haven't added it to `authorized_keys` yet.
|
||||
public func importKey(publicKey: String, deviceComment: String, iso8601Date: String) -> Bool {
|
||||
let pem = importPEM
|
||||
guard OnboardingLogic.isLikelyValidOpenSSHPrivateKey(pem) else {
|
||||
lastTestError = .other("Pasted text doesn't look like an OpenSSH private key. Export it with `ssh-keygen -p -m PEM-OpenSSH`.")
|
||||
step = .testFailed(reason: lastTestError?.errorDescription ?? "Invalid key")
|
||||
return false
|
||||
}
|
||||
guard let pub = OnboardingLogic.parseOpenSSHPublicKeyLine(publicKey) else {
|
||||
lastTestError = .other("The public-key line doesn't look right. Paste the single line from your `id_ed25519.pub`.")
|
||||
step = .testFailed(reason: lastTestError?.errorDescription ?? "Invalid public key")
|
||||
return false
|
||||
}
|
||||
keyBundle = SSHKeyBundle(
|
||||
privateKeyPEM: pem.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
publicKeyOpenSSH: pub,
|
||||
comment: deviceComment,
|
||||
createdAt: iso8601Date
|
||||
)
|
||||
step = .showPublicKey
|
||||
return true
|
||||
}
|
||||
|
||||
/// Called from the "Show public key" screen when the user taps
|
||||
/// "I've added this to authorized_keys". Persists the key to the
|
||||
/// Keychain, then runs the connection test inline.
|
||||
public func confirmPublicKeyAdded() async {
|
||||
guard let bundle = keyBundle, !isWorking else { return }
|
||||
isWorking = true
|
||||
defer { isWorking = false }
|
||||
|
||||
do {
|
||||
try await keyStore.save(bundle)
|
||||
} catch {
|
||||
lastTestError = .other("Couldn't save key to Keychain: \(error.localizedDescription)")
|
||||
step = .testFailed(reason: lastTestError?.errorDescription ?? "Keychain save failed")
|
||||
return
|
||||
}
|
||||
|
||||
await performConnectionTest()
|
||||
}
|
||||
|
||||
/// Re-run the SSH connection probe. Called from the `.testFailed`
|
||||
/// screen's "Retry" button, or from any other path that wants to
|
||||
/// bounce the connection without re-saving the key.
|
||||
public func runConnectionTest() async {
|
||||
guard !isWorking else { return }
|
||||
isWorking = true
|
||||
defer { isWorking = false }
|
||||
await performConnectionTest()
|
||||
}
|
||||
|
||||
/// Core probe implementation. Must be called with `isWorking == true`
|
||||
/// already set by the caller (both entry points above do this). On
|
||||
/// success, saves the config and transitions to `.connected`. On
|
||||
/// failure, transitions to `.testFailed` carrying the reason.
|
||||
private func performConnectionTest() async {
|
||||
guard let bundle = keyBundle else { return }
|
||||
let trimmedHost = host.trimmingCharacters(in: .whitespaces)
|
||||
let trimmedUser = user.trimmingCharacters(in: .whitespaces)
|
||||
let trimmedDisplayName: String = {
|
||||
let d = displayName.trimmingCharacters(in: .whitespaces)
|
||||
return d.isEmpty ? trimmedHost : d
|
||||
}()
|
||||
let port: Int? = Int(portText.trimmingCharacters(in: .whitespaces))
|
||||
|
||||
let config = IOSServerConfig(
|
||||
host: trimmedHost,
|
||||
user: trimmedUser.isEmpty ? nil : trimmedUser,
|
||||
port: port,
|
||||
hermesBinaryHint: nil,
|
||||
remoteHome: nil,
|
||||
displayName: trimmedDisplayName
|
||||
)
|
||||
|
||||
step = .testConnection
|
||||
lastTestError = nil
|
||||
|
||||
do {
|
||||
try await tester.testConnection(config: config, key: bundle)
|
||||
try await configStore.save(config)
|
||||
step = .connected
|
||||
} catch let err as SSHConnectionTestError {
|
||||
lastTestError = err
|
||||
step = .testFailed(reason: err.errorDescription ?? "Connection failed")
|
||||
} catch {
|
||||
lastTestError = .other(error.localizedDescription)
|
||||
step = .testFailed(reason: error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
/// Called from `.testFailed` when the user taps "Back".
|
||||
public func goBackToServerDetails() {
|
||||
step = .serverDetails
|
||||
lastTestError = nil
|
||||
}
|
||||
|
||||
/// Reset all state — used by a "Start over" affordance.
|
||||
public func reset() async {
|
||||
step = .serverDetails
|
||||
host = ""
|
||||
user = ""
|
||||
portText = ""
|
||||
displayName = ""
|
||||
importPEM = ""
|
||||
keyChoice = nil
|
||||
keyBundle = nil
|
||||
lastTestError = nil
|
||||
try? await keyStore.delete()
|
||||
try? await configStore.delete()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import Foundation
|
||||
|
||||
/// Connection-probe contract used by the iOS onboarding flow. The real
|
||||
/// implementation lives in `ScarfIOS/CitadelSSHService` and uses
|
||||
/// Citadel to perform a single SSH exec; tests use a mock that
|
||||
/// scripts success / failure.
|
||||
///
|
||||
/// Kept in ScarfCore (not ScarfIOS) so `OnboardingViewModel` can be
|
||||
/// constructed and exercised by tests on any platform — Linux CI can
|
||||
/// verify the onboarding state-machine without an SSH server or the
|
||||
/// iOS Keychain.
|
||||
public protocol SSHConnectionTester: Sendable {
|
||||
/// Open an SSH session to `(config, key)` and run a no-op command
|
||||
/// (`echo ok`). Returns normally on success. Throws
|
||||
/// `SSHConnectionTestError` with a user-presentable reason
|
||||
/// otherwise.
|
||||
///
|
||||
/// Implementations should apply a short connection timeout (~10s)
|
||||
/// — a slow remote shouldn't hang the onboarding UI.
|
||||
func testConnection(
|
||||
config: IOSServerConfig,
|
||||
key: SSHKeyBundle
|
||||
) async throws
|
||||
}
|
||||
|
||||
public enum SSHConnectionTestError: Error, LocalizedError {
|
||||
case hostUnreachable(host: String, underlying: String)
|
||||
case authenticationFailed(host: String, detail: String)
|
||||
case hostKeyMismatch(host: String, detail: String)
|
||||
case commandFailed(exitCode: Int, stderr: String)
|
||||
case timeout(seconds: TimeInterval)
|
||||
case other(String)
|
||||
|
||||
public var errorDescription: String? {
|
||||
switch self {
|
||||
case .hostUnreachable(let host, let msg):
|
||||
return "Can't reach \(host): \(msg)"
|
||||
case .authenticationFailed(let host, let detail):
|
||||
return "SSH authentication to \(host) failed. \(detail)"
|
||||
case .hostKeyMismatch(let host, let detail):
|
||||
return "Host key for \(host) doesn't match a previous connection. \(detail)"
|
||||
case .commandFailed(let code, let stderr):
|
||||
return "Remote command exited \(code). \(stderr.prefix(120))"
|
||||
case .timeout(let secs):
|
||||
return "Connection timed out after \(Int(secs))s."
|
||||
case .other(let msg):
|
||||
return msg
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Test helper — scripts success / failure behaviour deterministically
|
||||
/// so `OnboardingViewModel` tests don't need a live SSH server.
|
||||
public actor MockSSHConnectionTester: SSHConnectionTester {
|
||||
public enum Behavior: Sendable {
|
||||
case success
|
||||
case failure(SSHConnectionTestError)
|
||||
}
|
||||
|
||||
private var behavior: Behavior
|
||||
public private(set) var callCount = 0
|
||||
|
||||
public init(behavior: Behavior = .success) {
|
||||
self.behavior = behavior
|
||||
}
|
||||
|
||||
public func setBehavior(_ behavior: Behavior) {
|
||||
self.behavior = behavior
|
||||
}
|
||||
|
||||
public func testConnection(
|
||||
config: IOSServerConfig,
|
||||
key: SSHKeyBundle
|
||||
) async throws {
|
||||
callCount += 1
|
||||
switch behavior {
|
||||
case .success:
|
||||
return
|
||||
case .failure(let err):
|
||||
throw err
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import Foundation
|
||||
|
||||
/// A single SSH keypair used to authenticate to a remote Hermes host.
|
||||
///
|
||||
/// **Why this lives in ScarfCore** (and not in the iOS package):
|
||||
/// Keys are persisted by both the onboarding flow (iOS) and any future
|
||||
/// test-harness or macOS companion. The *storage backend* is
|
||||
/// platform-specific (iOS Keychain for the iPhone app, files or macOS
|
||||
/// Keychain for future Mac use), but the value type is plain data.
|
||||
public struct SSHKeyBundle: Sendable, Hashable, Codable {
|
||||
/// PEM-encoded OpenSSH private key (`-----BEGIN OPENSSH PRIVATE KEY-----…`).
|
||||
/// Treat as sensitive — callers should keep it in secure storage and
|
||||
/// never log it, serialize it to disk unencrypted, or hand it to
|
||||
/// non-ScarfCore code.
|
||||
public var privateKeyPEM: String
|
||||
/// OpenSSH-format public key (`ssh-ed25519 AAAA… comment`). Suitable
|
||||
/// for copy-pasting into `~/.ssh/authorized_keys` on the remote.
|
||||
public var publicKeyOpenSSH: String
|
||||
/// Public-key comment — typically `"scarf-iphone-<uuid>"` or a
|
||||
/// user-chosen label. Surfaced in `authorized_keys` so the user
|
||||
/// can identify which device the key belongs to.
|
||||
public var comment: String
|
||||
/// ISO8601 timestamp string captured when the key was first minted
|
||||
/// or imported. Used by the UI to show "created 3 days ago".
|
||||
public var createdAt: String
|
||||
|
||||
public init(
|
||||
privateKeyPEM: String,
|
||||
publicKeyOpenSSH: String,
|
||||
comment: String,
|
||||
createdAt: String
|
||||
) {
|
||||
self.privateKeyPEM = privateKeyPEM
|
||||
self.publicKeyOpenSSH = publicKeyOpenSSH
|
||||
self.comment = comment
|
||||
self.createdAt = createdAt
|
||||
}
|
||||
|
||||
/// Short display string with just the algorithm + a truncated
|
||||
/// fingerprint-shaped suffix. Safe to log.
|
||||
public var displayFingerprint: String {
|
||||
let parts = publicKeyOpenSSH.split(separator: " ", maxSplits: 2)
|
||||
guard parts.count >= 2 else { return "ssh-key" }
|
||||
let algo = String(parts[0])
|
||||
let keyBody = String(parts[1])
|
||||
let prefix = keyBody.prefix(10)
|
||||
let suffix = keyBody.suffix(10)
|
||||
return "\(algo) \(prefix)…\(suffix)"
|
||||
}
|
||||
}
|
||||
|
||||
/// Async-safe key storage contract. iOS implements this with the
|
||||
/// Keychain; tests use `InMemorySSHKeyStore`.
|
||||
///
|
||||
/// Single-key storage is intentional: v1 of the iOS app binds one SSH
|
||||
/// key to one Hermes server. Multi-key / multi-server comes later.
|
||||
public protocol SSHKeyStore: Sendable {
|
||||
/// Returns the stored key bundle, or `nil` if the store is empty.
|
||||
/// Callers should prompt the onboarding flow when this is `nil`.
|
||||
func load() async throws -> SSHKeyBundle?
|
||||
|
||||
/// Overwrites any existing key with `bundle`. Idempotent.
|
||||
func save(_ bundle: SSHKeyBundle) async throws
|
||||
|
||||
/// Deletes the stored key. No-op if the store is empty.
|
||||
func delete() async throws
|
||||
}
|
||||
|
||||
/// Errors raised by `SSHKeyStore` implementations when the backing
|
||||
/// store (Keychain, file) fails. Clients typically surface
|
||||
/// `errorDescription` and prompt the user to reset onboarding.
|
||||
public enum SSHKeyStoreError: Error, LocalizedError {
|
||||
/// The store contains data but it failed to decode as an
|
||||
/// `SSHKeyBundle`. Usually means a schema drift between app
|
||||
/// versions — the fix is to delete and re-onboard.
|
||||
case decodeFailed(String)
|
||||
/// The Keychain / filesystem returned an error. `osStatus` is
|
||||
/// non-nil on iOS when Security.framework returns an OSStatus.
|
||||
case backendFailure(message: String, osStatus: Int32?)
|
||||
|
||||
public var errorDescription: String? {
|
||||
switch self {
|
||||
case .decodeFailed(let msg): return "Stored SSH key is corrupted: \(msg)"
|
||||
case .backendFailure(let msg, let status):
|
||||
if let status { return "\(msg) (OSStatus \(status))" }
|
||||
return msg
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Process-lifetime in-memory key store. Intended for tests and
|
||||
/// previews — never for production. Thread-safe via an internal actor.
|
||||
public actor InMemorySSHKeyStore: SSHKeyStore {
|
||||
private var bundle: SSHKeyBundle?
|
||||
|
||||
public init(initial: SSHKeyBundle? = nil) {
|
||||
self.bundle = initial
|
||||
}
|
||||
|
||||
public func load() async throws -> SSHKeyBundle? { bundle }
|
||||
public func save(_ bundle: SSHKeyBundle) async throws { self.bundle = bundle }
|
||||
public func delete() async throws { bundle = nil }
|
||||
}
|
||||
@@ -0,0 +1,375 @@
|
||||
import Testing
|
||||
import Foundation
|
||||
@testable import ScarfCore
|
||||
|
||||
/// Exercises the iOS onboarding state machine + supporting types
|
||||
/// that moved to ScarfCore in M2. Uses only ScarfCore types — no
|
||||
/// Citadel, no Keychain, no SwiftUI — so the whole suite runs on
|
||||
/// Linux CI. Apple-target CI runs the same tests plus
|
||||
/// `ScarfIOSSmokeTests` for the platform-specific bits.
|
||||
@Suite struct M2OnboardingTests {
|
||||
|
||||
// MARK: - SSHKeyBundle + InMemorySSHKeyStore
|
||||
|
||||
@Test func keyBundleMemberwise() {
|
||||
let bundle = SSHKeyBundle(
|
||||
privateKeyPEM: "-----BEGIN X-----\nabc\n-----END X-----",
|
||||
publicKeyOpenSSH: "ssh-ed25519 AAAABBBB user@host",
|
||||
comment: "user@host",
|
||||
createdAt: "2026-04-22T12:00:00Z"
|
||||
)
|
||||
#expect(bundle.comment == "user@host")
|
||||
#expect(bundle.displayFingerprint.hasPrefix("ssh-ed25519 "))
|
||||
#expect(bundle.displayFingerprint.contains("…"))
|
||||
}
|
||||
|
||||
@Test func inMemoryKeyStoreBasics() async throws {
|
||||
let store = InMemorySSHKeyStore()
|
||||
let initial = try await store.load()
|
||||
#expect(initial == nil)
|
||||
|
||||
let bundle = SSHKeyBundle(
|
||||
privateKeyPEM: "p", publicKeyOpenSSH: "ssh-ed25519 abc name",
|
||||
comment: "name", createdAt: "2026-04-22T12:00:00Z"
|
||||
)
|
||||
try await store.save(bundle)
|
||||
let loaded = try await store.load()
|
||||
#expect(loaded == bundle)
|
||||
|
||||
try await store.delete()
|
||||
let afterDelete = try await store.load()
|
||||
#expect(afterDelete == nil)
|
||||
}
|
||||
|
||||
// MARK: - IOSServerConfig + InMemory store
|
||||
|
||||
@Test func iosServerConfigToServerContext() {
|
||||
let cfg = IOSServerConfig(
|
||||
host: "box.local", user: "alan", port: 2222,
|
||||
hermesBinaryHint: "/opt/hermes/bin/hermes",
|
||||
remoteHome: "/opt/hermes/.hermes",
|
||||
displayName: "Home Server"
|
||||
)
|
||||
let id = UUID()
|
||||
let ctx = cfg.toServerContext(id: id)
|
||||
#expect(ctx.id == id)
|
||||
#expect(ctx.displayName == "Home Server")
|
||||
#expect(ctx.isRemote == true)
|
||||
if case .ssh(let ssh) = ctx.kind {
|
||||
#expect(ssh.host == "box.local")
|
||||
#expect(ssh.user == "alan")
|
||||
#expect(ssh.port == 2222)
|
||||
#expect(ssh.remoteHome == "/opt/hermes/.hermes")
|
||||
#expect(ssh.hermesBinaryHint == "/opt/hermes/bin/hermes")
|
||||
} else {
|
||||
Issue.record("expected .ssh kind")
|
||||
}
|
||||
#expect(ctx.paths.home == "/opt/hermes/.hermes")
|
||||
#expect(ctx.paths.stateDB == "/opt/hermes/.hermes/state.db")
|
||||
}
|
||||
|
||||
@Test func iosServerConfigDefaultsUseRemoteHome() {
|
||||
let cfg = IOSServerConfig(host: "h", displayName: "h")
|
||||
let ctx = cfg.toServerContext(id: UUID())
|
||||
// No remoteHome set → default "~/.hermes"
|
||||
#expect(ctx.paths.home == "~/.hermes")
|
||||
}
|
||||
|
||||
@Test func inMemoryConfigStoreBasics() async throws {
|
||||
let store = InMemoryIOSServerConfigStore()
|
||||
let initial = try await store.load()
|
||||
#expect(initial == nil)
|
||||
|
||||
let cfg = IOSServerConfig(host: "h", displayName: "h")
|
||||
try await store.save(cfg)
|
||||
#expect(try await store.load() == cfg)
|
||||
|
||||
try await store.delete()
|
||||
#expect(try await store.load() == nil)
|
||||
}
|
||||
|
||||
// MARK: - OnboardingLogic validators
|
||||
|
||||
@Test func validateServerDetailsAcceptsHappyPath() {
|
||||
let v = OnboardingLogic.validateServerDetails(host: "box.local", portText: "")
|
||||
#expect(v.isHostValid == true)
|
||||
#expect(v.isPortValid == true)
|
||||
#expect(v.canAdvance == true)
|
||||
}
|
||||
|
||||
@Test func validateServerDetailsRejectsEmptyHost() {
|
||||
let v = OnboardingLogic.validateServerDetails(host: " ", portText: "")
|
||||
#expect(v.isHostValid == false)
|
||||
#expect(v.canAdvance == false)
|
||||
}
|
||||
|
||||
@Test func validateServerDetailsRejectsHostWithSpaces() {
|
||||
let v = OnboardingLogic.validateServerDetails(host: "bad host name", portText: "")
|
||||
#expect(v.isHostValid == false)
|
||||
}
|
||||
|
||||
@Test func validateServerDetailsPortRange() {
|
||||
#expect(OnboardingLogic.validateServerDetails(host: "h", portText: "22").canAdvance)
|
||||
#expect(OnboardingLogic.validateServerDetails(host: "h", portText: "65535").canAdvance)
|
||||
#expect(!OnboardingLogic.validateServerDetails(host: "h", portText: "0").canAdvance)
|
||||
#expect(!OnboardingLogic.validateServerDetails(host: "h", portText: "65536").canAdvance)
|
||||
#expect(!OnboardingLogic.validateServerDetails(host: "h", portText: "not a number").canAdvance)
|
||||
}
|
||||
|
||||
@Test func authorizedKeysLineTrimsAndNewlines() {
|
||||
let b = SSHKeyBundle(
|
||||
privateKeyPEM: "",
|
||||
publicKeyOpenSSH: " ssh-ed25519 AAAA foo \n",
|
||||
comment: "foo", createdAt: ""
|
||||
)
|
||||
let line = OnboardingLogic.authorizedKeysLine(for: b)
|
||||
#expect(line == "ssh-ed25519 AAAA foo\n")
|
||||
}
|
||||
|
||||
@Test func privateKeyShapeCheckHappyPath() {
|
||||
let pem = """
|
||||
-----BEGIN OPENSSH PRIVATE KEY-----
|
||||
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gt
|
||||
ZWQyNTUxOQAAACCN4e59+wtJp6GgmCNZMnRsPk6M4tGRYl+Uzs3wX8Ug4AAAAKCG13O1
|
||||
-----END OPENSSH PRIVATE KEY-----
|
||||
"""
|
||||
#expect(OnboardingLogic.isLikelyValidOpenSSHPrivateKey(pem) == true)
|
||||
}
|
||||
|
||||
@Test func privateKeyShapeRejectsGarbage() {
|
||||
#expect(OnboardingLogic.isLikelyValidOpenSSHPrivateKey("") == false)
|
||||
#expect(OnboardingLogic.isLikelyValidOpenSSHPrivateKey("just text") == false)
|
||||
// Legacy RSA PEM shouldn't pass — users need to re-export.
|
||||
#expect(OnboardingLogic.isLikelyValidOpenSSHPrivateKey("""
|
||||
-----BEGIN RSA PRIVATE KEY-----
|
||||
abcdef
|
||||
-----END RSA PRIVATE KEY-----
|
||||
""") == false)
|
||||
}
|
||||
|
||||
@Test func parsePublicKeyLineHappyPath() {
|
||||
#expect(OnboardingLogic.parseOpenSSHPublicKeyLine("ssh-ed25519 AAAABBBBCCC alan@macbook")
|
||||
== "ssh-ed25519 AAAABBBBCCC alan@macbook")
|
||||
#expect(OnboardingLogic.parseOpenSSHPublicKeyLine("ssh-rsa AAAABBBBCCC")
|
||||
== "ssh-rsa AAAABBBBCCC")
|
||||
}
|
||||
|
||||
@Test func parsePublicKeyLineRejectsNonsense() {
|
||||
#expect(OnboardingLogic.parseOpenSSHPublicKeyLine("") == nil)
|
||||
#expect(OnboardingLogic.parseOpenSSHPublicKeyLine("not-an-algo abc") == nil)
|
||||
#expect(OnboardingLogic.parseOpenSSHPublicKeyLine("ssh-ed25519") == nil)
|
||||
}
|
||||
|
||||
// MARK: - SSHConnectionTester mock basics
|
||||
|
||||
@Test func mockTesterRecordsCalls() async throws {
|
||||
let mock = MockSSHConnectionTester()
|
||||
try await mock.testConnection(
|
||||
config: IOSServerConfig(host: "h", displayName: "h"),
|
||||
key: SSHKeyBundle(privateKeyPEM: "p", publicKeyOpenSSH: "ssh-ed25519 x n", comment: "n", createdAt: "")
|
||||
)
|
||||
#expect(await mock.callCount == 1)
|
||||
}
|
||||
|
||||
@Test func mockTesterPropagatesConfiguredError() async {
|
||||
let mock = MockSSHConnectionTester(
|
||||
behavior: .failure(.authenticationFailed(host: "h", detail: "no key"))
|
||||
)
|
||||
do {
|
||||
try await mock.testConnection(
|
||||
config: IOSServerConfig(host: "h", displayName: "h"),
|
||||
key: SSHKeyBundle(privateKeyPEM: "p", publicKeyOpenSSH: "ssh-ed25519 x n", comment: "n", createdAt: "")
|
||||
)
|
||||
Issue.record("expected throw")
|
||||
} catch let error as SSHConnectionTestError {
|
||||
if case .authenticationFailed(let host, _) = error {
|
||||
#expect(host == "h")
|
||||
} else {
|
||||
Issue.record("wrong case: \(error)")
|
||||
}
|
||||
} catch {
|
||||
Issue.record("wrong error type: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - OnboardingViewModel end-to-end
|
||||
|
||||
/// Build a VM with all dependencies injected + a canned key bundle
|
||||
/// from a closure generator.
|
||||
@MainActor
|
||||
private func makeVM(
|
||||
testerBehavior: MockSSHConnectionTester.Behavior = .success,
|
||||
existingKey: SSHKeyBundle? = nil
|
||||
) async -> (OnboardingViewModel, InMemorySSHKeyStore, InMemoryIOSServerConfigStore, MockSSHConnectionTester) {
|
||||
let ks = InMemorySSHKeyStore(initial: existingKey)
|
||||
let cs = InMemoryIOSServerConfigStore()
|
||||
let tester = MockSSHConnectionTester(behavior: testerBehavior)
|
||||
let fixedBundle = SSHKeyBundle(
|
||||
privateKeyPEM: """
|
||||
-----BEGIN OPENSSH PRIVATE KEY-----
|
||||
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gt
|
||||
ZWQyNTUxOQAAACCN4e59+wtJp6GgmCNZMnRsPk6M4tGRYl+Uzs3wX8Ug4AAAAKCG13O1
|
||||
-----END OPENSSH PRIVATE KEY-----
|
||||
""",
|
||||
publicKeyOpenSSH: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA test-key",
|
||||
comment: "test-key",
|
||||
createdAt: "2026-04-22T12:00:00Z"
|
||||
)
|
||||
let vm = OnboardingViewModel(
|
||||
keyStore: ks,
|
||||
configStore: cs,
|
||||
tester: tester,
|
||||
keyGenerator: { fixedBundle }
|
||||
)
|
||||
return (vm, ks, cs, tester)
|
||||
}
|
||||
|
||||
@Test @MainActor func vmStartsAtServerDetails() async {
|
||||
let (vm, _, _, _) = await makeVM()
|
||||
#expect(vm.step == .serverDetails)
|
||||
}
|
||||
|
||||
@Test @MainActor func vmBlocksAdvanceOnInvalidHost() async {
|
||||
let (vm, _, _, _) = await makeVM()
|
||||
vm.host = ""
|
||||
vm.advanceFromServerDetails()
|
||||
#expect(vm.step == .serverDetails)
|
||||
}
|
||||
|
||||
@Test @MainActor func vmAdvancesOnValidHost() async {
|
||||
let (vm, _, _, _) = await makeVM()
|
||||
vm.host = "box.local"
|
||||
vm.advanceFromServerDetails()
|
||||
#expect(vm.step == .keySource)
|
||||
}
|
||||
|
||||
@Test @MainActor func vmGenerateHappyPath() async {
|
||||
let (vm, ks, cs, _) = await makeVM()
|
||||
vm.host = "box.local"
|
||||
vm.user = "alan"
|
||||
vm.displayName = "Home"
|
||||
vm.advanceFromServerDetails()
|
||||
vm.pickKeyChoice(.generate)
|
||||
#expect(vm.step == .generate)
|
||||
await vm.generateKey()
|
||||
#expect(vm.step == .showPublicKey)
|
||||
#expect(vm.keyBundle?.comment == "test-key")
|
||||
// Keychain NOT yet saved — save happens on confirmPublicKeyAdded.
|
||||
#expect(try! await ks.load() == nil)
|
||||
#expect(try! await cs.load() == nil)
|
||||
}
|
||||
|
||||
@Test @MainActor func vmConfirmPublicKeySavesAndStartsTest() async {
|
||||
let (vm, ks, cs, tester) = await makeVM()
|
||||
vm.host = "box.local"
|
||||
vm.user = "alan"
|
||||
vm.displayName = "Home"
|
||||
vm.advanceFromServerDetails()
|
||||
vm.pickKeyChoice(.generate)
|
||||
await vm.generateKey()
|
||||
await vm.confirmPublicKeyAdded()
|
||||
#expect(vm.step == .connected)
|
||||
let stored = try! await ks.load()
|
||||
#expect(stored?.comment == "test-key")
|
||||
let savedCfg = try! await cs.load()
|
||||
#expect(savedCfg?.host == "box.local")
|
||||
#expect(savedCfg?.user == "alan")
|
||||
#expect(savedCfg?.displayName == "Home")
|
||||
#expect(await tester.callCount == 1)
|
||||
}
|
||||
|
||||
@Test @MainActor func vmConnectionFailureRoutesToTestFailed() async {
|
||||
let (vm, _, cs, _) = await makeVM(
|
||||
testerBehavior: .failure(.hostUnreachable(host: "box.local", underlying: "no route"))
|
||||
)
|
||||
vm.host = "box.local"
|
||||
vm.advanceFromServerDetails()
|
||||
vm.pickKeyChoice(.generate)
|
||||
await vm.generateKey()
|
||||
await vm.confirmPublicKeyAdded()
|
||||
if case .testFailed(let reason) = vm.step {
|
||||
#expect(reason.contains("box.local"))
|
||||
} else {
|
||||
Issue.record("expected .testFailed, got \(vm.step)")
|
||||
}
|
||||
// Config NOT saved on failed test.
|
||||
#expect(try! await cs.load() == nil)
|
||||
}
|
||||
|
||||
@Test @MainActor func vmRetryAfterFailureWorks() async {
|
||||
let (vm, _, cs, tester) = await makeVM(
|
||||
testerBehavior: .failure(.authenticationFailed(host: "h", detail: "x"))
|
||||
)
|
||||
vm.host = "box.local"
|
||||
vm.advanceFromServerDetails()
|
||||
vm.pickKeyChoice(.generate)
|
||||
await vm.generateKey()
|
||||
await vm.confirmPublicKeyAdded()
|
||||
guard case .testFailed = vm.step else {
|
||||
Issue.record("expected .testFailed")
|
||||
return
|
||||
}
|
||||
|
||||
// User added the key on the remote; switch the mock to success
|
||||
// and retry.
|
||||
await tester.setBehavior(.success)
|
||||
await vm.runConnectionTest()
|
||||
#expect(vm.step == .connected)
|
||||
#expect(try! await cs.load() != nil)
|
||||
}
|
||||
|
||||
@Test @MainActor func vmImportKeyHappyPath() async {
|
||||
let (vm, _, _, _) = await makeVM()
|
||||
vm.host = "box.local"
|
||||
vm.advanceFromServerDetails()
|
||||
vm.pickKeyChoice(.importExisting)
|
||||
vm.importPEM = """
|
||||
-----BEGIN OPENSSH PRIVATE KEY-----
|
||||
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gt
|
||||
-----END OPENSSH PRIVATE KEY-----
|
||||
"""
|
||||
let ok = vm.importKey(
|
||||
publicKey: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA name",
|
||||
deviceComment: "imported",
|
||||
iso8601Date: "2026-04-22T12:00:00Z"
|
||||
)
|
||||
#expect(ok == true)
|
||||
#expect(vm.step == .showPublicKey)
|
||||
#expect(vm.keyBundle?.comment == "imported")
|
||||
}
|
||||
|
||||
@Test @MainActor func vmImportRejectsBadPEM() async {
|
||||
let (vm, _, _, _) = await makeVM()
|
||||
vm.host = "h"
|
||||
vm.advanceFromServerDetails()
|
||||
vm.pickKeyChoice(.importExisting)
|
||||
vm.importPEM = "not a key"
|
||||
let ok = vm.importKey(
|
||||
publicKey: "ssh-ed25519 abc name",
|
||||
deviceComment: "x",
|
||||
iso8601Date: ""
|
||||
)
|
||||
#expect(ok == false)
|
||||
if case .testFailed = vm.step {} else {
|
||||
Issue.record("expected .testFailed after bad import")
|
||||
}
|
||||
}
|
||||
|
||||
@Test @MainActor func vmResetClearsEverything() async {
|
||||
let (vm, ks, cs, _) = await makeVM()
|
||||
vm.host = "box.local"
|
||||
vm.user = "alan"
|
||||
vm.advanceFromServerDetails()
|
||||
vm.pickKeyChoice(.generate)
|
||||
await vm.generateKey()
|
||||
await vm.confirmPublicKeyAdded()
|
||||
|
||||
await vm.reset()
|
||||
#expect(vm.step == .serverDetails)
|
||||
#expect(vm.host == "")
|
||||
#expect(vm.user == "")
|
||||
#expect(vm.keyBundle == nil)
|
||||
#expect(try! await ks.load() == nil)
|
||||
#expect(try! await cs.load() == nil)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
// swift-tools-version: 6.0
|
||||
// iOS-specific support library: Keychain-backed key storage,
|
||||
// UserDefaults-backed server config, Citadel-backed SSH connection
|
||||
// testing + key generation. Consumed by the `scarf-ios` app target.
|
||||
//
|
||||
// Not built on Linux CI — Citadel's runtime is Apple-only, and
|
||||
// `Security.framework` (for iOS Keychain) ships in the Apple SDKs
|
||||
// only. The testable state-machine logic for onboarding lives in
|
||||
// `ScarfCore/Security/` so Linux `swift test` still exercises the
|
||||
// core transitions via `MockSSHConnectionTester` and
|
||||
// `InMemorySSHKeyStore`.
|
||||
|
||||
import PackageDescription
|
||||
|
||||
let package = Package(
|
||||
name: "ScarfIOS",
|
||||
defaultLocalization: "en",
|
||||
platforms: [
|
||||
.iOS(.v18),
|
||||
// macOS is included so that (a) Xcode's indexer is happy on a
|
||||
// Mac-only developer workstation while the iOS target compiles
|
||||
// against the same source tree, and (b) future Mac-catalyst /
|
||||
// Designed-for-iPad scenarios work without surgery. Running
|
||||
// `scarf-ios` on the Mac is not a supported product today.
|
||||
.macOS(.v14),
|
||||
],
|
||||
products: [
|
||||
.library(
|
||||
name: "ScarfIOS",
|
||||
targets: ["ScarfIOS"]
|
||||
),
|
||||
],
|
||||
dependencies: [
|
||||
.package(path: "../ScarfCore"),
|
||||
// Pinned to the 0.7 minor line until the API stabilizes at 1.0.
|
||||
// When we bump, re-run onboarding smoke tests against at least:
|
||||
// (a) a real host with 1Password SSH agent
|
||||
// (b) a real host with a hand-edited `authorized_keys`
|
||||
.package(url: "https://github.com/orlandos-nl/Citadel", from: "0.7.0"),
|
||||
],
|
||||
targets: [
|
||||
.target(
|
||||
name: "ScarfIOS",
|
||||
dependencies: [
|
||||
.product(name: "ScarfCore", package: "ScarfCore"),
|
||||
.product(
|
||||
name: "Citadel",
|
||||
package: "Citadel",
|
||||
// Don't attempt to link Citadel on Linux — SPM won't
|
||||
// build this target on Linux anyway (iOS-only), but
|
||||
// this keeps the resolution graph clean if ScarfIOS
|
||||
// ever ends up as a transitive dep from something
|
||||
// that DOES target Linux.
|
||||
condition: .when(platforms: [.iOS, .macOS])
|
||||
),
|
||||
],
|
||||
path: "Sources/ScarfIOS",
|
||||
swiftSettings: [
|
||||
.swiftLanguageMode(.v5),
|
||||
]
|
||||
),
|
||||
.testTarget(
|
||||
name: "ScarfIOSTests",
|
||||
dependencies: ["ScarfIOS"],
|
||||
path: "Tests/ScarfIOSTests"
|
||||
),
|
||||
]
|
||||
)
|
||||
@@ -0,0 +1,187 @@
|
||||
// Citadel is an Apple-only package; the whole service is gated so
|
||||
// Linux CI (which builds `ScarfCore` standalone) doesn't try to
|
||||
// resolve it. On iOS and macOS the file compiles normally.
|
||||
#if canImport(Citadel) && canImport(CryptoKit)
|
||||
|
||||
import Foundation
|
||||
import Citadel
|
||||
import NIOCore
|
||||
import CryptoKit
|
||||
import ScarfCore
|
||||
|
||||
/// Citadel-backed implementation of `SSHConnectionTester`.
|
||||
///
|
||||
/// Responsible for:
|
||||
/// - Minting fresh Ed25519 keypairs (delegated to
|
||||
/// `Ed25519KeyGenerator`).
|
||||
/// - Running one-shot SSH exec probes (`echo ok`) for the
|
||||
/// onboarding "Test Connection" step.
|
||||
/// - Future: hosting the long-lived SSH session M3+ features
|
||||
/// (file transport, SQLite snapshot pulls, ACP channel) will
|
||||
/// layer on top.
|
||||
///
|
||||
/// **Citadel API disclaimer.** Citadel 0.9's exact authentication-
|
||||
/// method spelling for Ed25519 private keys is evolving across
|
||||
/// 0.7 → 0.9. The `runOneShotProbe(...)` helper below is written
|
||||
/// against the documented `SSHClientSettings` + `.privateKey(...)`
|
||||
/// pattern; if Citadel has renamed or refactored that variant,
|
||||
/// adjust `buildClientSettings(...)` — everything else (the retry
|
||||
/// loop, the error classification, the exit-code handling) is
|
||||
/// Citadel-version-independent.
|
||||
public struct CitadelSSHService: SSHConnectionTester {
|
||||
/// Seconds to wait for the probe exec. Set tight so onboarding
|
||||
/// doesn't hang on a silently-dropped connection.
|
||||
public static let probeTimeoutSeconds: TimeInterval = 10
|
||||
|
||||
public init() {}
|
||||
|
||||
// MARK: - Key generation (public entry point)
|
||||
|
||||
/// Passthrough to `Ed25519KeyGenerator.generate(...)`. Exposed on
|
||||
/// the service so ViewModels only depend on `CitadelSSHService`,
|
||||
/// not on the generator type directly (cleaner for mocking).
|
||||
public func generateEd25519Key(comment: String? = nil) throws -> SSHKeyBundle {
|
||||
try Ed25519KeyGenerator.generate(comment: comment)
|
||||
}
|
||||
|
||||
// MARK: - SSHConnectionTester
|
||||
|
||||
public func testConnection(
|
||||
config: IOSServerConfig,
|
||||
key: SSHKeyBundle
|
||||
) async throws {
|
||||
let probe = try await runOneShotProbe(
|
||||
config: config,
|
||||
key: key,
|
||||
command: "echo scarf-ok"
|
||||
)
|
||||
if !probe.stdout.contains("scarf-ok") {
|
||||
throw SSHConnectionTestError.commandFailed(
|
||||
exitCode: Int(probe.exitCode),
|
||||
stderr: probe.stderr.isEmpty
|
||||
? "probe command didn't echo back the expected marker"
|
||||
: probe.stderr
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Probe
|
||||
|
||||
/// Thin wrapper that owns the full connect → exec → disconnect
|
||||
/// lifecycle and surfaces a typed error on failure. Separated
|
||||
/// from `testConnection(...)` so a future M3 feature (list
|
||||
/// remote projects, spin up tunneled services) can reuse the
|
||||
/// same connection glue.
|
||||
public struct ProbeResult: Sendable {
|
||||
public let stdout: String
|
||||
public let stderr: String
|
||||
public let exitCode: Int32
|
||||
}
|
||||
|
||||
public func runOneShotProbe(
|
||||
config: IOSServerConfig,
|
||||
key: SSHKeyBundle,
|
||||
command: String
|
||||
) async throws -> ProbeResult {
|
||||
let settings = try buildClientSettings(config: config, key: key)
|
||||
|
||||
let client: SSHClient
|
||||
do {
|
||||
client = try await SSHClient.connect(to: settings)
|
||||
} catch {
|
||||
throw Self.classifyConnectError(error, host: config.host)
|
||||
}
|
||||
// Always try to close the client, even on exec failure.
|
||||
defer {
|
||||
Task { try? await client.close() }
|
||||
}
|
||||
|
||||
do {
|
||||
let buffer: ByteBuffer = try await client.executeCommand(command)
|
||||
var buf = buffer
|
||||
let stdout = buf.readString(length: buf.readableBytes) ?? ""
|
||||
return ProbeResult(
|
||||
stdout: stdout,
|
||||
stderr: "",
|
||||
exitCode: 0
|
||||
)
|
||||
} catch {
|
||||
throw SSHConnectionTestError.commandFailed(
|
||||
exitCode: 1,
|
||||
stderr: error.localizedDescription
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Citadel glue
|
||||
|
||||
/// Translate our in-house `SSHKeyBundle` (raw 32+32 byte Ed25519)
|
||||
/// into Citadel's authentication method.
|
||||
///
|
||||
/// **FIXME when updating Citadel.** The exact function name below
|
||||
/// is my best read of Citadel 0.7–0.9's API surface — private-key
|
||||
/// auth has gone through several iterations. If the build fails
|
||||
/// here with "no member `ed25519`" or similar, check the current
|
||||
/// `SSHAuthenticationMethod.swift` in the pinned Citadel version
|
||||
/// and adjust. Everything else (key decode, error classification,
|
||||
/// timeout) is independent.
|
||||
private func buildClientSettings(
|
||||
config: IOSServerConfig,
|
||||
key: SSHKeyBundle
|
||||
) throws -> SSHClientSettings {
|
||||
guard let parts = Ed25519KeyGenerator.decodeRawEd25519PEM(key.privateKeyPEM) else {
|
||||
throw SSHConnectionTestError.other(
|
||||
"Stored private key is not in the expected Scarf Ed25519 PEM format"
|
||||
)
|
||||
}
|
||||
guard let ck = try? Curve25519.Signing.PrivateKey(rawRepresentation: parts.privateKey) else {
|
||||
throw SSHConnectionTestError.other("Stored private key is malformed")
|
||||
}
|
||||
let username = config.user ?? "root"
|
||||
// See FIXME above — the `.ed25519(...)` method name is the
|
||||
// shape I expect based on Citadel 0.7–0.9 docs; double-check
|
||||
// on Mac once the pod is resolved.
|
||||
let auth: SSHAuthenticationMethod = .ed25519(
|
||||
username: username,
|
||||
privateKey: ck
|
||||
)
|
||||
|
||||
var settings = SSHClientSettings(
|
||||
host: config.host,
|
||||
authenticationMethod: { auth },
|
||||
hostKeyValidator: .acceptAnything()
|
||||
)
|
||||
if let port = config.port {
|
||||
settings.port = port
|
||||
}
|
||||
return settings
|
||||
}
|
||||
|
||||
// MARK: - Error mapping
|
||||
|
||||
/// Best-effort classification of Citadel / NIO connect errors to
|
||||
/// our `SSHConnectionTestError` cases. Keep the pattern-matching
|
||||
/// loose — NIO wraps errors in multiple layers and the exact
|
||||
/// strings shift across versions.
|
||||
nonisolated static func classifyConnectError(
|
||||
_ error: Error,
|
||||
host: String
|
||||
) -> SSHConnectionTestError {
|
||||
let s = String(describing: error).lowercased()
|
||||
if s.contains("authentication") || s.contains("publickey") || s.contains("userauth") {
|
||||
return .authenticationFailed(host: host, detail: "Check that the public key above is in ~/.ssh/authorized_keys on the remote.")
|
||||
}
|
||||
if s.contains("host key") || s.contains("hostkey") {
|
||||
return .hostKeyMismatch(host: host, detail: String(describing: error))
|
||||
}
|
||||
if s.contains("connection refused") || s.contains("unreachable") || s.contains("no route") {
|
||||
return .hostUnreachable(host: host, underlying: String(describing: error))
|
||||
}
|
||||
if s.contains("timeout") || s.contains("timed out") {
|
||||
return .timeout(seconds: probeTimeoutSeconds)
|
||||
}
|
||||
return .other(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
#endif // canImport(Citadel) && canImport(CryptoKit)
|
||||
@@ -0,0 +1,163 @@
|
||||
// Uses CryptoKit (Apple's standard-library crypto). Available on iOS 13+
|
||||
// and macOS 10.15+; the OpenSSH public-key wire format below is the same
|
||||
// across both.
|
||||
#if canImport(CryptoKit)
|
||||
|
||||
import Foundation
|
||||
import CryptoKit
|
||||
import ScarfCore
|
||||
|
||||
/// Mints a fresh Ed25519 keypair and packages it as an `SSHKeyBundle`.
|
||||
///
|
||||
/// The **public half** is encoded in OpenSSH wire format and wrapped in
|
||||
/// a standard `ssh-ed25519 AAAA… <comment>` line — paste-ready into a
|
||||
/// remote host's `authorized_keys`. The format is spec'd in RFC 4253
|
||||
/// §6.6 + draft-ietf-curdle-ssh-ed25519-02 and is trivially
|
||||
/// deterministic to serialize by hand: `string("ssh-ed25519")` +
|
||||
/// `string(<32-byte-public-key>)`, where `string(x)` is
|
||||
/// `uint32_be(len(x)) ‖ x`, all base64-wrapped.
|
||||
///
|
||||
/// The **private half** is stored as a custom PEM string in the
|
||||
/// bundle. The serialization below is a documented pre-OpenSSH PEM
|
||||
/// shape that round-trips through `Curve25519.Signing.PrivateKey(
|
||||
/// rawRepresentation:)`. It is **not** the standard OpenSSH
|
||||
/// `BEGIN OPENSSH PRIVATE KEY` envelope — that format is more complex
|
||||
/// (requires a bcrypt KDF header even for unencrypted keys) and would
|
||||
/// pull in a lot of serialization code that Citadel can generate via
|
||||
/// its own OpenSSH helpers.
|
||||
///
|
||||
/// **Interop note.** `CitadelSSHService` in this same package is
|
||||
/// responsible for bridging this bundle's raw-PEM private key into
|
||||
/// whatever Citadel's authentication method expects — see the FIXME
|
||||
/// comments in that file. The public key goes on the remote; the
|
||||
/// private key never leaves the iPhone.
|
||||
public enum Ed25519KeyGenerator {
|
||||
/// Default comment attached to generated keys. Unique enough to
|
||||
/// distinguish "key from this device" in a shared `authorized_keys`.
|
||||
public static func defaultComment() -> String {
|
||||
"scarf-iphone-\(UUID().uuidString.prefix(8))"
|
||||
}
|
||||
|
||||
/// Generate a fresh Ed25519 keypair. `comment` appears at the end
|
||||
/// of the OpenSSH public-key line. `now` is injected for testable
|
||||
/// timestamp formatting — defaults to the current instant.
|
||||
public static func generate(
|
||||
comment: String? = nil,
|
||||
now: Date = Date()
|
||||
) throws -> SSHKeyBundle {
|
||||
let key = Curve25519.Signing.PrivateKey()
|
||||
let pub = key.publicKey.rawRepresentation // 32 bytes
|
||||
let priv = key.rawRepresentation // 32 bytes
|
||||
|
||||
let commentStr = comment ?? defaultComment()
|
||||
let openSSHPublic = makeOpenSSHPublicKeyLine(
|
||||
publicKeyBytes: pub,
|
||||
comment: commentStr
|
||||
)
|
||||
let privatePEM = makeRawEd25519PEM(privateBytes: priv, publicBytes: pub)
|
||||
|
||||
let iso = ISO8601DateFormatter()
|
||||
iso.formatOptions = [.withInternetDateTime]
|
||||
let timestamp = iso.string(from: now)
|
||||
|
||||
return SSHKeyBundle(
|
||||
privateKeyPEM: privatePEM,
|
||||
publicKeyOpenSSH: openSSHPublic,
|
||||
comment: commentStr,
|
||||
createdAt: timestamp
|
||||
)
|
||||
}
|
||||
|
||||
/// Build `ssh-ed25519 AAAA… comment`. Pure function; testable
|
||||
/// without any crypto. The `publicKeyBytes` must be the exact
|
||||
/// 32-byte raw Ed25519 public key.
|
||||
public static func makeOpenSSHPublicKeyLine(
|
||||
publicKeyBytes: Data,
|
||||
comment: String
|
||||
) -> String {
|
||||
let algo = "ssh-ed25519"
|
||||
var blob = Data()
|
||||
appendSSHString(&blob, bytes: Data(algo.utf8))
|
||||
appendSSHString(&blob, bytes: publicKeyBytes)
|
||||
let b64 = blob.base64EncodedString()
|
||||
if comment.isEmpty {
|
||||
return "\(algo) \(b64)"
|
||||
} else {
|
||||
return "\(algo) \(b64) \(comment)"
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the raw-bytes PEM we use in-app. Format:
|
||||
/// ```
|
||||
/// -----BEGIN SCARF ED25519 PRIVATE KEY-----
|
||||
/// <base64 of: 32-byte private | 32-byte public>
|
||||
/// -----END SCARF ED25519 PRIVATE KEY-----
|
||||
/// ```
|
||||
/// This is NOT an interop format with OpenSSH tooling — it's
|
||||
/// purely for round-tripping within Scarf. `CitadelSSHService`
|
||||
/// decodes these 64 bytes back into a `Curve25519.Signing.PrivateKey`
|
||||
/// before calling into Citadel.
|
||||
///
|
||||
/// If you want an `~/.ssh/id_ed25519`-compatible export, use the
|
||||
/// "Share key" flow (not in M2 — future phase) which will re-serialize
|
||||
/// into proper OpenSSH PEM via Citadel's helpers.
|
||||
public static func makeRawEd25519PEM(
|
||||
privateBytes: Data,
|
||||
publicBytes: Data
|
||||
) -> String {
|
||||
let combined = privateBytes + publicBytes
|
||||
let b64 = combined.base64EncodedString()
|
||||
// Wrap the base64 in 76-column lines for display-friendliness.
|
||||
let wrapped = wrap(b64, at: 76)
|
||||
return """
|
||||
-----BEGIN SCARF ED25519 PRIVATE KEY-----
|
||||
\(wrapped)
|
||||
-----END SCARF ED25519 PRIVATE KEY-----
|
||||
"""
|
||||
}
|
||||
|
||||
/// Decode a `makeRawEd25519PEM` result back into the 32-byte private
|
||||
/// + 32-byte public tuple. Used by `CitadelSSHService`.
|
||||
public static func decodeRawEd25519PEM(
|
||||
_ pem: String
|
||||
) -> (privateKey: Data, publicKey: Data)? {
|
||||
let trimmed = pem.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard trimmed.hasPrefix("-----BEGIN SCARF ED25519 PRIVATE KEY-----"),
|
||||
trimmed.hasSuffix("-----END SCARF ED25519 PRIVATE KEY-----") else {
|
||||
return nil
|
||||
}
|
||||
let inner = trimmed
|
||||
.replacingOccurrences(of: "-----BEGIN SCARF ED25519 PRIVATE KEY-----", with: "")
|
||||
.replacingOccurrences(of: "-----END SCARF ED25519 PRIVATE KEY-----", with: "")
|
||||
.replacingOccurrences(of: "\n", with: "")
|
||||
.trimmingCharacters(in: .whitespaces)
|
||||
guard let data = Data(base64Encoded: inner), data.count == 64 else { return nil }
|
||||
let priv = data.prefix(32)
|
||||
let pub = data.suffix(32)
|
||||
return (Data(priv), Data(pub))
|
||||
}
|
||||
|
||||
// MARK: - Internal helpers
|
||||
|
||||
/// SSH wire format "string": `uint32_be(len(x)) ‖ x`.
|
||||
private static func appendSSHString(_ blob: inout Data, bytes: Data) {
|
||||
let len = UInt32(bytes.count).bigEndian
|
||||
withUnsafeBytes(of: len) { raw in
|
||||
blob.append(contentsOf: raw)
|
||||
}
|
||||
blob.append(bytes)
|
||||
}
|
||||
|
||||
private static func wrap(_ s: String, at width: Int) -> String {
|
||||
var out: [String] = []
|
||||
var i = s.startIndex
|
||||
while i < s.endIndex {
|
||||
let next = s.index(i, offsetBy: width, limitedBy: s.endIndex) ?? s.endIndex
|
||||
out.append(String(s[i..<next]))
|
||||
i = next
|
||||
}
|
||||
return out.joined(separator: "\n")
|
||||
}
|
||||
}
|
||||
|
||||
#endif // canImport(CryptoKit)
|
||||
@@ -0,0 +1,118 @@
|
||||
// KeychainSSHKeyStore is Apple-only — iOS Keychain APIs (kSec*) live
|
||||
// in Security.framework which ships in the Apple SDKs. On Linux the
|
||||
// whole file is skipped; tests use ScarfCore's InMemorySSHKeyStore.
|
||||
#if canImport(Security)
|
||||
|
||||
import Foundation
|
||||
import Security
|
||||
import ScarfCore
|
||||
|
||||
/// iOS Keychain-backed implementation of `SSHKeyStore`. Stores the
|
||||
/// JSON-encoded `SSHKeyBundle` as a generic password item tagged
|
||||
/// with a Scarf-specific service + account.
|
||||
///
|
||||
/// **Accessibility**: We use `kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly`
|
||||
/// so the key:
|
||||
/// - is readable any time after the user unlocks the device once
|
||||
/// (so background tasks can reach it),
|
||||
/// - does not sync to iCloud Keychain (keys are per-device; the
|
||||
/// user would explicitly enrol a new iPhone with its own key).
|
||||
///
|
||||
/// **Thread safety**: Each Keychain call allocates its own `CFDictionary`,
|
||||
/// so no shared state. The methods are marked `nonisolated` to allow
|
||||
/// calling from any actor context.
|
||||
public struct KeychainSSHKeyStore: SSHKeyStore {
|
||||
public static let defaultService = "com.scarf.ssh-key"
|
||||
public static let defaultAccount = "primary"
|
||||
|
||||
private let service: String
|
||||
private let account: String
|
||||
|
||||
public init(service: String = defaultService, account: String = defaultAccount) {
|
||||
self.service = service
|
||||
self.account = account
|
||||
}
|
||||
|
||||
public func load() async throws -> SSHKeyBundle? {
|
||||
var query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: account,
|
||||
kSecReturnData as String: true,
|
||||
kSecMatchLimit as String: kSecMatchLimitOne,
|
||||
]
|
||||
var item: CFTypeRef?
|
||||
let status = SecItemCopyMatching(query as CFDictionary, &item)
|
||||
switch status {
|
||||
case errSecSuccess:
|
||||
guard let data = item as? Data else {
|
||||
throw SSHKeyStoreError.backendFailure(
|
||||
message: "Keychain returned non-Data value", osStatus: status
|
||||
)
|
||||
}
|
||||
do {
|
||||
return try JSONDecoder().decode(SSHKeyBundle.self, from: data)
|
||||
} catch {
|
||||
throw SSHKeyStoreError.decodeFailed(error.localizedDescription)
|
||||
}
|
||||
case errSecItemNotFound:
|
||||
return nil
|
||||
default:
|
||||
throw SSHKeyStoreError.backendFailure(
|
||||
message: "Keychain read failed", osStatus: status
|
||||
)
|
||||
}
|
||||
// swiftlint:disable:previous cyclomatic_complexity — accepted; single SecItem read
|
||||
_ = query // silence "never mutated" in older Swift 5 modes
|
||||
}
|
||||
|
||||
public func save(_ bundle: SSHKeyBundle) async throws {
|
||||
let data: Data
|
||||
do {
|
||||
data = try JSONEncoder().encode(bundle)
|
||||
} catch {
|
||||
throw SSHKeyStoreError.backendFailure(
|
||||
message: "Encode failed: \(error.localizedDescription)", osStatus: nil
|
||||
)
|
||||
}
|
||||
|
||||
// Delete any existing entry first — SecItemUpdate is finicky
|
||||
// across OS versions; delete-and-insert is the simpler pattern
|
||||
// for single-entry storage.
|
||||
let baseQuery: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: account,
|
||||
]
|
||||
SecItemDelete(baseQuery as CFDictionary)
|
||||
|
||||
var attributes = baseQuery
|
||||
attributes[kSecValueData as String] = data
|
||||
attributes[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
|
||||
attributes[kSecAttrSynchronizable as String] = kCFBooleanFalse
|
||||
|
||||
let addStatus = SecItemAdd(attributes as CFDictionary, nil)
|
||||
guard addStatus == errSecSuccess else {
|
||||
throw SSHKeyStoreError.backendFailure(
|
||||
message: "Keychain write failed", osStatus: addStatus
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
public func delete() async throws {
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: account,
|
||||
]
|
||||
let status = SecItemDelete(query as CFDictionary)
|
||||
// errSecItemNotFound is fine — delete() is idempotent by contract.
|
||||
if status != errSecSuccess && status != errSecItemNotFound {
|
||||
throw SSHKeyStoreError.backendFailure(
|
||||
message: "Keychain delete failed", osStatus: status
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#endif // canImport(Security)
|
||||
@@ -0,0 +1,39 @@
|
||||
import Foundation
|
||||
import ScarfCore
|
||||
|
||||
/// `UserDefaults`-backed implementation of `IOSServerConfigStore`. The
|
||||
/// server config (hostname, user, display name, etc.) is not itself
|
||||
/// sensitive — the SSH private key lives in the Keychain separately —
|
||||
/// so `UserDefaults` is the right low-ceremony store for it.
|
||||
///
|
||||
/// The record serializes as JSON under a single key. A future schema
|
||||
/// migration can bump the key name (`.v2` suffix) if the shape
|
||||
/// changes; today there's nothing to migrate.
|
||||
public struct UserDefaultsIOSServerConfigStore: IOSServerConfigStore {
|
||||
public static let defaultDefaultsKey = "com.scarf.ios.primary-server-config.v1"
|
||||
|
||||
private let defaults: UserDefaults
|
||||
private let key: String
|
||||
|
||||
public init(
|
||||
defaults: UserDefaults = .standard,
|
||||
key: String = defaultDefaultsKey
|
||||
) {
|
||||
self.defaults = defaults
|
||||
self.key = key
|
||||
}
|
||||
|
||||
public func load() async throws -> IOSServerConfig? {
|
||||
guard let data = defaults.data(forKey: key) else { return nil }
|
||||
return try JSONDecoder().decode(IOSServerConfig.self, from: data)
|
||||
}
|
||||
|
||||
public func save(_ config: IOSServerConfig) async throws {
|
||||
let data = try JSONEncoder().encode(config)
|
||||
defaults.set(data, forKey: key)
|
||||
}
|
||||
|
||||
public func delete() async throws {
|
||||
defaults.removeObject(forKey: key)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import Testing
|
||||
import Foundation
|
||||
@testable import ScarfIOS
|
||||
|
||||
/// Smoke test — ensures ScarfIOS links in an Apple-target build.
|
||||
/// All interesting behavioural coverage for the state-machine and the
|
||||
/// Keychain / UserDefaults round-trips lives in `ScarfCoreTests`
|
||||
/// (which runs on Linux CI). This file just guards the compile surface.
|
||||
@Suite struct ScarfIOSSmokeTests {
|
||||
|
||||
#if canImport(CryptoKit)
|
||||
@Test func ed25519GeneratorProducesWellFormedBundle() throws {
|
||||
let bundle = try Ed25519KeyGenerator.generate(comment: "test-comment")
|
||||
#expect(bundle.comment == "test-comment")
|
||||
#expect(bundle.publicKeyOpenSSH.hasPrefix("ssh-ed25519 "))
|
||||
#expect(bundle.publicKeyOpenSSH.hasSuffix("test-comment"))
|
||||
#expect(bundle.privateKeyPEM.contains("SCARF ED25519 PRIVATE KEY"))
|
||||
|
||||
// Round-trip: decode PEM back and verify lengths.
|
||||
let parts = Ed25519KeyGenerator.decodeRawEd25519PEM(bundle.privateKeyPEM)
|
||||
#expect(parts != nil)
|
||||
#expect(parts?.privateKey.count == 32)
|
||||
#expect(parts?.publicKey.count == 32)
|
||||
|
||||
// Display fingerprint has the right shape.
|
||||
#expect(bundle.displayFingerprint.hasPrefix("ssh-ed25519 "))
|
||||
#expect(bundle.displayFingerprint.contains("…"))
|
||||
}
|
||||
|
||||
@Test func ed25519PublicKeyLineIsDeterministic() {
|
||||
// Pin the OpenSSH wire format — wrong encoding would silently
|
||||
// break every authorized_keys paste.
|
||||
let fakePubKey = Data(repeating: 0xAB, count: 32)
|
||||
let line = Ed25519KeyGenerator.makeOpenSSHPublicKeyLine(
|
||||
publicKeyBytes: fakePubKey,
|
||||
comment: "hello"
|
||||
)
|
||||
let parts = line.split(separator: " ")
|
||||
#expect(parts.count == 3)
|
||||
#expect(String(parts[0]) == "ssh-ed25519")
|
||||
#expect(String(parts[2]) == "hello")
|
||||
// Base64 blob decodes to:
|
||||
// string("ssh-ed25519") + string(<32 bytes of 0xAB>)
|
||||
// = 4 + 11 + 4 + 32 = 51 bytes
|
||||
let b64 = String(parts[1])
|
||||
let decoded = Data(base64Encoded: b64)
|
||||
#expect(decoded?.count == 51)
|
||||
}
|
||||
|
||||
@Test func decodeRejectsCorruptedPEM() {
|
||||
#expect(Ed25519KeyGenerator.decodeRawEd25519PEM("garbage") == nil)
|
||||
#expect(Ed25519KeyGenerator.decodeRawEd25519PEM("""
|
||||
-----BEGIN SCARF ED25519 PRIVATE KEY-----
|
||||
not-base64!!
|
||||
-----END SCARF ED25519 PRIVATE KEY-----
|
||||
""") == nil)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
+100
-2
@@ -454,8 +454,106 @@ Two real regressions caught by a pre-M1 audit, both silent:
|
||||
- **The `ChannelFactory` closure is `@Sendable` and async.** Any per-context setup (env enrichment, SSH handshake) happens inside the factory — not inside `ACPClient.start()`. That keeps `start()` boring and portable.
|
||||
- **`ACPClient` does not handle subprocess spontaneous exits via `terminationHandler`** anymore — it notices via channel-stream EOF. Pipe-EOF fires reliably when a Mac subprocess exits (OS closes the pipe). If a future phase sees "session hangs after crash" symptoms, add a `terminationHandler` inside `ProcessACPChannel` that explicitly finishes the `incoming` continuation.
|
||||
|
||||
### M2 — pending
|
||||
### M2 — pending
|
||||
### M2 — shipped (on `claude/ios-m2-skeleton` branch, separate PR from M0+M1)
|
||||
|
||||
**Scope note:** M2 delivers all the **code** needed for TestFlight
|
||||
(onboarding, Keychain, Citadel, Dashboard placeholder, unit tests) but
|
||||
**not** the `scarf-ios.xcodeproj`. Hand-editing ~600 lines of pbxproj
|
||||
from scratch is too high-risk without an iOS SDK to build against, so
|
||||
the Xcode target is created once in Xcode's UI following the written
|
||||
instructions in `scarf/scarf-ios/SETUP.md`. Total setup time: ~5
|
||||
minutes.
|
||||
|
||||
**Shipped — ScarfCore additions (testable on Linux):**
|
||||
|
||||
- `Security/SSHKey.swift` — `SSHKeyBundle` struct, `SSHKeyStore`
|
||||
protocol, `InMemorySSHKeyStore` test actor.
|
||||
- `Security/IOSServerConfig.swift` — `IOSServerConfig` struct
|
||||
(single-server v1), `IOSServerConfigStore` protocol,
|
||||
`InMemoryIOSServerConfigStore`. `toServerContext(id:)` bridges to
|
||||
the existing `ServerContext` type so the rest of ScarfCore's
|
||||
services work against an iOS-configured server unchanged.
|
||||
- `Security/OnboardingState.swift` — `OnboardingStep` enum,
|
||||
`OnboardingKeyChoice`, `OnboardingServerDetailsValidation`, pure
|
||||
functions `OnboardingLogic.validateServerDetails` /
|
||||
`authorizedKeysLine(for:)` / `isLikelyValidOpenSSHPrivateKey` /
|
||||
`parseOpenSSHPublicKeyLine`.
|
||||
- `Security/SSHConnectionTester.swift` — protocol +
|
||||
`SSHConnectionTestError` enum + `MockSSHConnectionTester`.
|
||||
- `Security/OnboardingViewModel.swift` — `@Observable @MainActor`
|
||||
state machine. Dependency-injects `SSHKeyStore`,
|
||||
`IOSServerConfigStore`, `SSHConnectionTester`, and a `KeyGenerator`
|
||||
closure so every transition is testable with mocks.
|
||||
|
||||
**Shipped — new `Packages/ScarfIOS` local SPM package:**
|
||||
|
||||
- Depends on local ScarfCore + remote Citadel (`from: "0.7.0"`).
|
||||
- `KeychainSSHKeyStore.swift` — real iOS Keychain impl
|
||||
(`kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly`, no iCloud sync).
|
||||
- `UserDefaultsIOSServerConfigStore.swift` — JSON in UserDefaults.
|
||||
- `Ed25519KeyGenerator.swift` — mints fresh Ed25519 keypairs via
|
||||
CryptoKit, emits standard OpenSSH public-key lines, stores the
|
||||
private half in a compact custom PEM that
|
||||
`CitadelSSHService` decodes back into
|
||||
`Curve25519.Signing.PrivateKey`.
|
||||
- `CitadelSSHService.swift` — `SSHConnectionTester` conformance +
|
||||
key-generation wrapper. Runs a one-shot SSH exec (`echo scarf-ok`)
|
||||
for the onboarding probe. Clearly-marked FIXME on the Citadel
|
||||
authentication-method call site because 0.7→0.9 has shifted the
|
||||
variant name; other than that one line, everything is
|
||||
Citadel-version-independent.
|
||||
|
||||
**Shipped — `scarf/scarf-ios/` iOS app source tree:**
|
||||
|
||||
- `App/ScarfIOSApp.swift` — `@main` + `RootModel` routing to
|
||||
onboarding / dashboard based on stored state.
|
||||
- `Onboarding/OnboardingRootView.swift` — 8 sub-views, one per
|
||||
`OnboardingStep`. Validated server-details form, key-source
|
||||
picker, generate / show / import / test / retry / connected.
|
||||
- `Dashboard/DashboardView.swift` — M2 placeholder: connected
|
||||
server details + Disconnect. M3 replaces with real data.
|
||||
|
||||
**Shipped — `scarf/scarf-ios/SETUP.md`:**
|
||||
|
||||
Step-by-step Xcode project creation + troubleshooting. Alan runs
|
||||
this once on a Mac (~5 minutes).
|
||||
|
||||
**Test coverage:**
|
||||
|
||||
- **ScarfCore (Linux):** 26 new tests covering key-bundle memberwise,
|
||||
both in-memory stores, config-to-ServerContext bridging, all
|
||||
`OnboardingLogic` validators (empty / whitespace / port range /
|
||||
legacy-RSA rejection), mock tester, and 10 end-to-end
|
||||
`OnboardingViewModel` paths (happy, bad import,
|
||||
connection-failure → retry-success, reset).
|
||||
- **ScarfIOS (Apple-only):** 3 smoke tests for the Ed25519 generator,
|
||||
OpenSSH public-key wire format (byte-length pinned at 51), and
|
||||
corrupted-PEM rejection on round-trip decode.
|
||||
|
||||
Total: **88 passing on Linux** (62 pre-M2 + 26 new). Apple CI adds
|
||||
the 3 ScarfIOS tests.
|
||||
|
||||
**Manual validation needed on Mac:**
|
||||
|
||||
1. Xcode project creation per SETUP.md.
|
||||
2. Citadel 0.9.x `SSHAuthenticationMethod.ed25519(...)` variant name
|
||||
— verify and fix if it's been renamed.
|
||||
3. Onboarding end-to-end: simulator → physical iPhone via TestFlight
|
||||
→ real SSH host with the public key added to `authorized_keys`.
|
||||
|
||||
**Rules next phases can rely on:**
|
||||
|
||||
- **M3** adds a Citadel-backed `ServerTransport` in ScarfIOS; iOS
|
||||
`IOSServerConfig.toServerContext(...).makeTransport()` dispatches to
|
||||
it automatically.
|
||||
- **M4** adds `SSHExecACPChannel` in ScarfIOS; iOS wires the
|
||||
`ACPClient.ChannelFactory` hook (from M1) to produce it — sibling
|
||||
to Mac's `ACPClient+Mac.swift`.
|
||||
- iOS is single-server in v1 — don't prematurely generalize the
|
||||
onboarding flow.
|
||||
- Source tree stays **pure SwiftUI + Foundation + ScarfCore + ScarfIOS**;
|
||||
`#if canImport(UIKit)` fine for pasteboard but keep it minimal.
|
||||
|
||||
### M3 — pending
|
||||
### M4 — pending
|
||||
### M5 — pending
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
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()
|
||||
)
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
RootView(model: root)
|
||||
.task { await root.load() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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):
|
||||
DashboardView(
|
||||
config: config,
|
||||
key: key,
|
||||
onDisconnect: {
|
||||
await model.disconnect()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import SwiftUI
|
||||
import ScarfCore
|
||||
import ScarfIOS
|
||||
|
||||
/// Placeholder dashboard for M2. Shows the connected server and a
|
||||
/// "Disconnect" affordance that wipes the stored key + config and
|
||||
/// returns the user to onboarding.
|
||||
///
|
||||
/// **M3 replaces this** with a real dashboard backed by
|
||||
/// `HermesDataService` running over a Citadel-backed transport.
|
||||
/// For now this view just proves the "connected" state is reachable.
|
||||
struct DashboardView: View {
|
||||
let config: IOSServerConfig
|
||||
let key: SSHKeyBundle
|
||||
let onDisconnect: @MainActor () async -> Void
|
||||
|
||||
@State private var isDisconnecting = false
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List {
|
||||
Section("Connected to") {
|
||||
LabeledContent("Display name", value: config.displayName)
|
||||
LabeledContent("Host", value: config.host)
|
||||
if let user = config.user {
|
||||
LabeledContent("User", value: user)
|
||||
}
|
||||
if let port = config.port {
|
||||
LabeledContent("Port", value: String(port))
|
||||
}
|
||||
}
|
||||
|
||||
Section("Device key") {
|
||||
LabeledContent("Comment", value: key.comment)
|
||||
LabeledContent("Fingerprint", value: key.displayFingerprint)
|
||||
LabeledContent("Created", value: key.createdAt)
|
||||
}
|
||||
|
||||
Section {
|
||||
Button(role: .destructive) {
|
||||
Task {
|
||||
isDisconnecting = true
|
||||
await onDisconnect()
|
||||
}
|
||||
} label: {
|
||||
HStack {
|
||||
Spacer()
|
||||
if isDisconnecting {
|
||||
ProgressView()
|
||||
} else {
|
||||
Text("Disconnect")
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
.disabled(isDisconnecting)
|
||||
}
|
||||
|
||||
Section {
|
||||
Text("Dashboard data comes in M3 — this view is M2's \"hello, you're connected\" placeholder.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.navigationTitle(config.displayName)
|
||||
.navigationBarTitleDisplayMode(.large)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
import SwiftUI
|
||||
import ScarfCore
|
||||
import ScarfIOS
|
||||
|
||||
/// Owns the `OnboardingViewModel` and renders the current step.
|
||||
/// Each step gets its own small view; the view switch is driven by
|
||||
/// `vm.step`.
|
||||
struct OnboardingRootView: View {
|
||||
let onFinished: @MainActor () async -> Void
|
||||
|
||||
@State private var vm: OnboardingViewModel = {
|
||||
let tester = CitadelSSHService()
|
||||
let service = tester // reuse the same instance for key generation
|
||||
return OnboardingViewModel(
|
||||
keyStore: KeychainSSHKeyStore(),
|
||||
configStore: UserDefaultsIOSServerConfigStore(),
|
||||
tester: tester,
|
||||
keyGenerator: { try service.generateEd25519Key() }
|
||||
)
|
||||
}()
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Group {
|
||||
switch vm.step {
|
||||
case .serverDetails: ServerDetailsStep(vm: vm)
|
||||
case .keySource: KeySourceStep(vm: vm)
|
||||
case .generate: GenerateKeyStep(vm: vm)
|
||||
case .importKey: ImportKeyStep(vm: vm)
|
||||
case .showPublicKey: ShowPublicKeyStep(vm: vm)
|
||||
case .testConnection: TestConnectionStep(vm: vm)
|
||||
case .testFailed(let reason): TestFailedStep(vm: vm, reason: reason)
|
||||
case .connected: ConnectedStep()
|
||||
}
|
||||
}
|
||||
.navigationTitle("Connect to Hermes")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
.onChange(of: vm.step) { _, new in
|
||||
if case .connected = new {
|
||||
Task { await onFinished() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Steps
|
||||
|
||||
private struct ServerDetailsStep: View {
|
||||
let vm: OnboardingViewModel
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section("Remote host") {
|
||||
TextField("hostname or IP", text: Bindable(vm).host)
|
||||
.textContentType(.URL)
|
||||
.autocorrectionDisabled()
|
||||
.textInputAutocapitalization(.never)
|
||||
TextField("username (optional)", text: Bindable(vm).user)
|
||||
.textContentType(.username)
|
||||
.autocorrectionDisabled()
|
||||
.textInputAutocapitalization(.never)
|
||||
TextField("port (default 22)", text: Bindable(vm).portText)
|
||||
.keyboardType(.numberPad)
|
||||
TextField("nickname (optional)", text: Bindable(vm).displayName)
|
||||
.autocorrectionDisabled()
|
||||
}
|
||||
|
||||
Section {
|
||||
Button {
|
||||
vm.advanceFromServerDetails()
|
||||
} label: {
|
||||
HStack {
|
||||
Spacer()
|
||||
Text("Next")
|
||||
.bold()
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
.disabled(!vm.serverDetailsValidation.canAdvance)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct KeySourceStep: View {
|
||||
let vm: OnboardingViewModel
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 24) {
|
||||
Text("SSH key")
|
||||
.font(.title2)
|
||||
.bold()
|
||||
Text("Scarf authenticates to your Hermes host with an SSH key. You can generate a new one on this device, or import one you already use.")
|
||||
.multilineTextAlignment(.center)
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.horizontal)
|
||||
|
||||
VStack(spacing: 12) {
|
||||
Button {
|
||||
vm.pickKeyChoice(.generate)
|
||||
} label: {
|
||||
Label("Generate a new key", systemImage: "key.fill")
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding()
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
|
||||
Button {
|
||||
vm.pickKeyChoice(.importExisting)
|
||||
} label: {
|
||||
Label("Import existing key", systemImage: "square.and.arrow.down")
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding()
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
.padding()
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding(.top)
|
||||
}
|
||||
}
|
||||
|
||||
private struct GenerateKeyStep: View {
|
||||
let vm: OnboardingViewModel
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 16) {
|
||||
ProgressView()
|
||||
.scaleEffect(1.2)
|
||||
Text("Generating Ed25519 keypair…")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.task {
|
||||
await vm.generateKey()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct ImportKeyStep: View {
|
||||
let vm: OnboardingViewModel
|
||||
@State private var publicKey: String = ""
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section("Paste your private key") {
|
||||
TextEditor(text: Bindable(vm).importPEM)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.frame(minHeight: 160)
|
||||
}
|
||||
Section("Paste the matching public-key line") {
|
||||
TextEditor(text: $publicKey)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.frame(minHeight: 80)
|
||||
.autocorrectionDisabled()
|
||||
.textInputAutocapitalization(.never)
|
||||
}
|
||||
Section {
|
||||
Button("Import") {
|
||||
let iso = ISO8601DateFormatter()
|
||||
iso.formatOptions = [.withInternetDateTime]
|
||||
_ = vm.importKey(
|
||||
publicKey: publicKey,
|
||||
deviceComment: "scarf-ios-imported",
|
||||
iso8601Date: iso.string(from: Date())
|
||||
)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct ShowPublicKeyStep: View {
|
||||
let vm: OnboardingViewModel
|
||||
@State private var copied = false
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Text("Add this public key to the remote")
|
||||
.font(.title3)
|
||||
.bold()
|
||||
Text("Append the line below to `~/.ssh/authorized_keys` on the Hermes host. Once added, tap **I've added this key** to test the connection.")
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
if let bundle = vm.keyBundle {
|
||||
Text(OnboardingLogic.authorizedKeysLine(for: bundle))
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.textSelection(.enabled)
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(Color(.secondarySystemBackground))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||
|
||||
Button(copied ? "Copied" : "Copy") {
|
||||
UIPasteboard.general.string =
|
||||
OnboardingLogic.authorizedKeysLine(for: bundle)
|
||||
copied = true
|
||||
Task {
|
||||
try? await Task.sleep(nanoseconds: 2_000_000_000)
|
||||
copied = false
|
||||
}
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Button {
|
||||
Task { await vm.confirmPublicKeyAdded() }
|
||||
} label: {
|
||||
HStack {
|
||||
Spacer()
|
||||
Text("I've added this key")
|
||||
.bold()
|
||||
Spacer()
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(vm.isWorking)
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct TestConnectionStep: View {
|
||||
let vm: OnboardingViewModel
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 16) {
|
||||
ProgressView()
|
||||
.scaleEffect(1.2)
|
||||
Text("Testing connection to \(vm.host)…")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct TestFailedStep: View {
|
||||
let vm: OnboardingViewModel
|
||||
let reason: String
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Label("Connection failed", systemImage: "exclamationmark.triangle.fill")
|
||||
.foregroundStyle(.orange)
|
||||
.font(.title3)
|
||||
.bold()
|
||||
Text(reason)
|
||||
.font(.callout)
|
||||
|
||||
HStack {
|
||||
Button("Back") {
|
||||
vm.goBackToServerDetails()
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
|
||||
Button("Retry") {
|
||||
Task { await vm.runConnectionTest() }
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct ConnectedStep: View {
|
||||
var body: some View {
|
||||
VStack(spacing: 16) {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.font(.system(size: 64))
|
||||
.foregroundStyle(.green)
|
||||
Text("Connected")
|
||||
.font(.title2)
|
||||
.bold()
|
||||
Text("Loading dashboard…")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
# scarf-ios — Xcode target setup
|
||||
|
||||
This folder contains the source tree for the iOS app (`scarf-ios`), but
|
||||
**not** the Xcode project file. Creating the `.xcodeproj` is a one-time
|
||||
step you do in Xcode's UI — it's about 5 minutes, and doing it by hand
|
||||
produces a project file that's definitely correct for whichever Xcode
|
||||
version you're running, rather than a hand-edited pbxproj that might
|
||||
drift from Xcode's expectations.
|
||||
|
||||
Everything the app needs — SSH key generation, Keychain storage,
|
||||
onboarding state machine, Citadel-backed connection testing — already
|
||||
lives in the shared SPM packages (`Packages/ScarfCore`,
|
||||
`Packages/ScarfIOS`) and is exercised by 88 passing unit tests on
|
||||
Linux CI. The Xcode target is mostly just a wrapper that assembles
|
||||
those packages behind a `@main` SwiftUI app.
|
||||
|
||||
## One-time: create the Xcode target
|
||||
|
||||
1. Open **Xcode** → **File → New → Project…**
|
||||
2. Choose **iOS → App**. Click Next.
|
||||
3. Fill in:
|
||||
- **Product Name**: `scarf-ios`
|
||||
- **Team**: `3Q6X2L86C4` (the same team the Mac app uses for notarization)
|
||||
- **Organization Identifier**: `com.scarf`
|
||||
- **Interface**: **SwiftUI**
|
||||
- **Language**: **Swift**
|
||||
- **Storage**: **None** (no Core Data, no CloudKit)
|
||||
- **Include Tests**: unchecked (SPM covers them)
|
||||
4. On the save-location sheet, navigate to `<repo>/scarf/` (the same
|
||||
level as `scarf.xcodeproj`) and hit **Create**.
|
||||
5. Xcode produces `<repo>/scarf/scarf-ios/scarf-ios.xcodeproj` and a
|
||||
default source tree you'll immediately throw away.
|
||||
|
||||
## One-time: set project settings
|
||||
|
||||
In the **scarf-ios** project (target of the same name):
|
||||
|
||||
1. **General → Minimum Deployments → iPhone**: `iOS 18.0`.
|
||||
2. **General → Supported Destinations**: keep **iPhone** only. Remove
|
||||
iPad + Mac Catalyst + Vision.
|
||||
3. **Info → Bundle Identifier**: `com.scarf.scarf-ios`.
|
||||
4. **Signing & Capabilities → Team**: `3Q6X2L86C4` (same as Mac).
|
||||
5. **Build Settings → Swift Language Version**: `Swift 5` (matches
|
||||
the Mac app and both SPM packages).
|
||||
|
||||
## One-time: wire the SPM packages
|
||||
|
||||
1. **File → Add Package Dependencies…**
|
||||
2. **Add Local…** button in the lower-left of the dialog.
|
||||
3. Select `<repo>/scarf/Packages/ScarfCore`. Click **Add Package**.
|
||||
4. Target to attach it to: **scarf-ios**. Click **Add Package**.
|
||||
5. Repeat steps 1–4 for `<repo>/scarf/Packages/ScarfIOS`.
|
||||
- `ScarfIOS` already declares `Citadel` as a dependency — Xcode
|
||||
will resolve it automatically when you add the local package.
|
||||
- On first resolution expect a ~30s wait while Citadel +
|
||||
SwiftNIO-SSH fetch from GitHub.
|
||||
|
||||
## One-time: replace the default source tree
|
||||
|
||||
1. In Xcode's Project Navigator, delete the auto-generated files
|
||||
Xcode created for you:
|
||||
- `scarf_iosApp.swift` (or `scarf-iosApp.swift`)
|
||||
- `ContentView.swift`
|
||||
- `Assets.xcassets` (keep this one — we'll reuse)
|
||||
2. In Finder, open `<repo>/scarf/scarf-ios/`. Drag the **App/**,
|
||||
**Onboarding/**, and **Dashboard/** folders onto the `scarf-ios`
|
||||
target in Xcode's navigator.
|
||||
- In the import sheet: **Create groups**, **Add to target:
|
||||
scarf-ios**.
|
||||
3. Build (`⌘B`). It should compile cleanly. If Citadel's
|
||||
authentication-method variant has changed since I wrote
|
||||
`CitadelSSHService`, adjust `buildClientSettings(...)` — see the
|
||||
FIXME comment in that file.
|
||||
|
||||
## One-time: app icon + accent color
|
||||
|
||||
The `Assets.xcassets` that Xcode scaffolded already has a blank
|
||||
`AppIcon` and `AccentColor`. Drop your icon asset + pick an accent
|
||||
color in the Inspector. Nothing else to configure.
|
||||
|
||||
## Info.plist additions for M2
|
||||
|
||||
None required. Citadel uses SwiftNIO, which doesn't need the
|
||||
network-usage `Info.plist` key unless you hit local-network
|
||||
discovery — which onboarding doesn't.
|
||||
|
||||
If you want to publish to TestFlight in M2, add these under
|
||||
**Info → Custom iOS Target Properties**:
|
||||
|
||||
- `LSRequiresIPhoneOS = YES` (defaults to YES, usually already set)
|
||||
- `UIApplicationSceneManifest → UIApplicationSupportsMultipleScenes = NO`
|
||||
(single-window for iPhone)
|
||||
- `UILaunchScreen` — empty dictionary is fine.
|
||||
|
||||
## Smoke test the target
|
||||
|
||||
1. Pick an iPhone simulator (any iPhone running iOS 18+) and hit ⌘R.
|
||||
2. You should see the onboarding flow: **Remote host** form → **SSH
|
||||
key** choice → **Generate** → **Show public key** → …
|
||||
3. On **Show public key**, the OpenSSH line is selectable and
|
||||
copy-able. The text renders as `ssh-ed25519 AAAA… scarf-iphone-XXXX`.
|
||||
4. Tap **I've added this key**. Onboarding calls `CitadelSSHService`
|
||||
to connect. With no real server, this will fail with
|
||||
`.hostUnreachable` — that's expected. You should land on the
|
||||
**Connection failed** screen with a "Retry" button.
|
||||
5. Real end-to-end test: use a host you actually own, copy the shown
|
||||
public key into its `~/.ssh/authorized_keys`, tap Retry. You
|
||||
should reach the **Connected** state and then the placeholder
|
||||
**Dashboard** with a Disconnect button.
|
||||
|
||||
## TestFlight (still M2 — optional)
|
||||
|
||||
1. **Product → Archive** (select a physical iPhone or "Any iOS
|
||||
Device (arm64)" as the target).
|
||||
2. **Window → Organizer → Archives → Distribute App → App Store
|
||||
Connect → Upload**. Uses your existing team signing setup.
|
||||
3. First upload will trigger App Store Connect to create the app
|
||||
record if it doesn't exist. Give it `com.scarf.scarf-ios` and
|
||||
the same team.
|
||||
4. After processing, invite testers from App Store Connect.
|
||||
|
||||
## What's *not* in M2
|
||||
|
||||
- Dashboard data (sessions, messages, stats) — **M3** adds a
|
||||
Citadel-backed `ServerTransport` so `HermesDataService` and friends
|
||||
work over SSH from iOS.
|
||||
- Chat — **M4** adds an `SSHExecACPChannel` (the iOS counterpart to
|
||||
`ProcessACPChannel`) so `ACPClient` runs over a Citadel exec
|
||||
session.
|
||||
- Memory editing, Cron, Skills, Settings — **M5**.
|
||||
- Polish, App Store submission — **M6**.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**Citadel fails to resolve.** Delete derived data (`~/Library/Developer/Xcode/DerivedData/scarf-ios-*`)
|
||||
and `File → Packages → Reset Package Caches`, then rebuild.
|
||||
|
||||
**`SSHAuthenticationMethod` has no member `ed25519`.** Citadel's
|
||||
private-key auth has changed variant names between 0.7 and 0.9. See
|
||||
`CitadelSSHService.buildClientSettings(...)` — there's one line to
|
||||
update. Keep the protocol conformance intact.
|
||||
|
||||
**Keychain reads empty after relaunch.** Check that you haven't
|
||||
accidentally set `kSecAttrAccessible` to a value that requires
|
||||
biometric unlock — M2 uses `AfterFirstUnlockThisDeviceOnly` which
|
||||
should always be readable.
|
||||
|
||||
**The shown public-key line doesn't match what OpenSSH generates
|
||||
from the private key.** It won't — `scarf-ios` uses a compact
|
||||
internal PEM shape for the private key (see `Ed25519KeyGenerator`
|
||||
for the format). The **public** key is standard OpenSSH wire format
|
||||
and is interop-safe with `authorized_keys`. If you want to export
|
||||
the private key for use with `ssh`, that export flow is deferred
|
||||
to a future phase.
|
||||
Reference in New Issue
Block a user