Files
saphid--frame-control/ios/FrameControl/SSH/Keys.swift
T
saphidandClaude Opus 5.5 13187e8f6a iPhone and iPad app: the same features, served from the Frame
An iPhone can't run Python or ssh, but the Frame can. The app (ios/, SwiftUI)
connects with its own SSH key (Citadel), copies the server and helpers to
~/.cache/frame-control/<version> on the Frame once per version, starts
ui/server.py there with FRAME_LOCAL=1 on the Frame's 127.0.0.1, and shows the
page through an SSH tunnel. The server exits when the phone disconnects.

Server: FRAME_LOCAL=1 puts ui/local-bin on PATH, whose ssh stand-in runs each
`ssh frame COMMAND` locally (and serves as rsync's transport), so desktop and
phone share one code path. Android display goes through podman exec there, as
the Frame has no adb. FRAME_UI_KEY replaces the fixed X-Frame-UI value with a
per-session key. Power actions take the Developer Mode password via sudo -S.
--port 0 now prints the port it took.

Page: a bottom tab bar and safe areas on phones, Play buttons visible on touch
screens, saving through the share sheet, SSH/SFTP/Steam Link/remote desktop
opening in their iOS apps, and a password dialog for power.

App: pairing with the Developer Mode password once (never stored) or with a
key the user adds; host key pinned on first use; plain-language connection
errors with quiet retries; frame-control://install links; alerts and confirms.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-27 11:18:15 +10:00

55 lines
2.0 KiB
Swift

import CryptoKit
import Foundation
import NIOSSH
import Security
/// Small wrapper over the Keychain for this app's secrets.
enum Keychain {
private static let service = "com.saphid.framecontrol"
private static func query(_ account: String) -> [String: Any] {
[kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service,
kSecAttrAccount as String: account]
}
static func data(_ account: String) -> Data? {
var q = query(account)
q[kSecReturnData as String] = true
q[kSecMatchLimit as String] = kSecMatchLimitOne
var out: AnyObject?
return SecItemCopyMatching(q as CFDictionary, &out) == errSecSuccess ? out as? Data : nil
}
static func set(_ data: Data, _ account: String) {
SecItemDelete(query(account) as CFDictionary)
var q = query(account)
q[kSecValueData as String] = data
// Only on this device and not in backups: the key is this phone's identity.
q[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
SecItemAdd(q as CFDictionary, nil)
}
static func delete(_ account: String) {
SecItemDelete(query(account) as CFDictionary)
}
}
/// This phone's SSH key: ed25519, made once, kept in the Keychain.
enum DeviceKey {
private static let account = "ssh-ed25519"
static func loadOrCreate() -> Curve25519.Signing.PrivateKey {
if let raw = Keychain.data(account), let key = try? Curve25519.Signing.PrivateKey(rawRepresentation: raw) {
return key
}
let key = Curve25519.Signing.PrivateKey()
Keychain.set(key.rawRepresentation, account)
return key
}
/// The line for ~/.ssh/authorized_keys, e.g. "ssh-ed25519 AAAA… frame-control@iPhone".
static func authorizedKeysLine(_ key: Curve25519.Signing.PrivateKey, comment: String) -> String {
String(openSSHPublicKey: NIOSSHPrivateKey(ed25519Key: key).publicKey) + " " + comment
}
}