mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-06 01:00:18 +02:00
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>
55 lines
2.0 KiB
Swift
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
|
|
}
|
|
}
|