import Citadel import Foundation import SwiftUI import UIKit /// The app's one piece of state: which headset, and how far along connecting to it is. @MainActor final class AppModel: ObservableObject { enum Phase: Equatable { case setup case connecting(String) case ready(URL) case failed(String) } @Published private(set) var phase: Phase @Published private(set) var settings: FrameSettings? /// Install links that arrived before the page was ready for them. @Published var pendingInstallLinks: [InstallLink] = [] private var link: FrameLink? private var server: HeadsetServer? private var forwarder: PortForwarder? private var attempt = 0 private static let settingsKey = "frame.settings" private static let hostKeyKey = "frame.hostKey" init() { let saved = UserDefaults.standard.data(forKey: Self.settingsKey).flatMap { try? JSONDecoder().decode(FrameSettings.self, from: $0) } settings = saved phase = saved == nil ? .setup : .connecting("Connecting") } var deviceName: String { UIDevice.current.userInterfaceIdiom == .pad ? "iPad" : "iPhone" } private var hostKey: String? { UserDefaults.standard.string(forKey: Self.hostKeyKey) } // MARK: pairing /// First time: log in with the Developer Mode password, add this phone's key to /// ~/.ssh/authorized_keys, record the Frame's host key, then connect with the key. func pair(host: String, user: String, password: String) async { guard let target = Self.parse(host: host, user: user) else { phase = .failed("Enter the headset's address and user name.") return } await teardown() attempt += 1 let mine = attempt phase = .connecting("Signing in to \(target.host)") let pin = PinnedHostKey(expected: nil) do { let link = try await FrameLink.connect(target, auth: .passwordBased(username: target.user, password: password), hostKey: pin) defer { Task { await link.close() } } guard mine == attempt else { return } phase = .connecting("Adding this \(deviceName)'s key") let line = authorizedKeysLine try await link.check("umask 077; mkdir -p ~/.ssh && touch ~/.ssh/authorized_keys && " + "(grep -qxF \(shellQuote(line)) ~/.ssh/authorized_keys || echo \(shellQuote(line)) >> ~/.ssh/authorized_keys)", "Couldn't add the key on the Frame") guard let seen = pin.seen else { throw FrameFailure("The Frame didn't show a host key") } UserDefaults.standard.set(seen, forKey: Self.hostKeyKey) UserDefaults.standard.set(try JSONEncoder().encode(target), forKey: Self.settingsKey) settings = target } catch { guard mine == attempt else { return } phase = .failed((error as? FrameFailure)?.message ?? FrameLink.describe(error, host: target.host)) return } await connect() } /// For someone who added this phone's key to the Frame themselves: no password. /// The Frame's host key is recorded on this first connection. func useKey(host: String, user: String) async { guard let target = Self.parse(host: host, user: user) else { phase = .failed("Enter the headset's address and user name.") return } UserDefaults.standard.removeObject(forKey: Self.hostKeyKey) UserDefaults.standard.set(try? JSONEncoder().encode(target), forKey: Self.settingsKey) settings = target await connect() } /// "host", "host:port" or "[v6]:port", plus a user name. static func parse(host: String, user: String) -> FrameSettings? { var target = FrameSettings(host: host.trimmingCharacters(in: .whitespaces), user: user.trimmingCharacters(in: .whitespaces)) if target.host.hasPrefix("["), let close = target.host.firstIndex(of: "]") { let rest = target.host[target.host.index(after: close)...] if rest.hasPrefix(":"), let port = Int(rest.dropFirst()) { target.port = port } target.host = String(target.host[target.host.index(after: target.host.startIndex).. String { var bytes = [UInt8](repeating: 0, count: 24) _ = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes) return bytes.map { String(format: "%02x", $0) }.joined() } }