Files
saphid--frame-control/ios/FrameControl/SSH/HeadsetServer.swift
T
saphidandClaude Opus 5.5 a910f83ac1 Fix the iPhone review's findings
- Cancelling (Change headset, Forget) invalidates the attempt in flight; a
  connection only becomes the app's once every step finished for it.
- A server that stops while the tunnel opens is noticed (exit callbacks are
  synchronised and replayed), and the app isn't left showing a dead page.
- The port is read only once the digits are complete, and range-checked.
- Other versions in ~/.cache/frame-control stay unless untouched for 14 days,
  so a second phone or iPad isn't cut off.
- The key is appended on its own line even if authorized_keys lacks a final
  newline.
- The app checks every 20 s, and on returning to the foreground, that the SSH
  session still answers, and reconnects if not.
- The ssh stand-in runs the command as its own process group and passes a
  TERM on to all of it, so a live-video ffmpeg stops with its stream.
- Touch screens show the library's Play buttons (the rule now follows the
  base one); the failure screen only says it's retrying when it is; the page
  says the app reconnects rather than naming a desktop menu.

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

176 lines
7.6 KiB
Swift

import Citadel
import Foundation
import NIOCore
/// Frame Control's server, running on the Frame itself. The app copies the bundle
/// (ios/scripts/make_frame_bundle.py) to ~/.cache/frame-control/<version> once per
/// version, then starts ui/server.py there over SSH. It listens only on the Frame's
/// 127.0.0.1, and it exits when this SSH session ends (--exit-on-eof).
final class HeadsetServer: @unchecked Sendable {
let port: Int
private let lock = NSLock()
private var _exited: String?
private var onExit: (@Sendable (String) -> Void)?
/// Set once the server stops, with its last output.
var exited: String? { lock.withLock { _exited } }
private init(port: Int) { self.port = port }
/// Calls back once when the server stops, at once if it already has.
func whenExited(_ callback: @escaping @Sendable (String) -> Void) {
let already: String? = lock.withLock {
if _exited == nil { onExit = callback }
return _exited
}
if let already { callback(already) }
}
fileprivate func markExited(_ tail: String) {
let callback: (@Sendable (String) -> Void)? = lock.withLock {
guard _exited == nil else { return nil }
_exited = tail
defer { onExit = nil }
return onExit
}
callback?(tail)
}
static let cacheDir = ".cache/frame-control"
struct Bundle {
let data: Data
let version: String
static func fromApp() throws -> Bundle {
guard let url = Foundation.Bundle.main.url(forResource: "frame-bundle", withExtension: "tar.gz"),
let data = try? Data(contentsOf: url),
let vurl = Foundation.Bundle.main.url(forResource: "frame-bundle", withExtension: "version"),
let version = try? String(contentsOf: vurl, encoding: .utf8).trimmingCharacters(in: .whitespacesAndNewlines),
version.range(of: "^[0-9a-f]{16}$", options: .regularExpression) != nil else {
throw FrameFailure("This build of the app is missing its Frame bundle")
}
return Bundle(data: data, version: version)
}
}
/// Copies the bundle over unless this version is already there; removes older versions.
static func deploy(_ bundle: Bundle, over link: FrameLink, progress: @escaping @Sendable (String) -> Void) async throws -> String {
let dir = "\(cacheDir)/\(bundle.version)"
let py = try await link.run("command -v python3 >/dev/null && python3 -c 'import sys; print(sys.version_info >= (3, 8))'")
guard py.status == 0, py.output.hasSuffix("True") else {
throw FrameFailure("The Frame has no Python 3.8 or later, which Frame Control needs there.")
}
if try await link.run("test -f \(dir)/ui/server.py").status != 0 {
progress("Copying Frame Control to the headset")
try await link.check("mkdir -p \(cacheDir)", "Couldn't make \(cacheDir)")
let archive = "\(dir).tar.gz"
try await link.upload(bundle.data, to: archive)
progress("Unpacking")
try await link.check("rm -rf \(dir).tmp && mkdir \(dir).tmp && tar xzf \(archive) -C \(dir).tmp && rm -f \(archive) "
+ "&& rm -rf \(dir) && mv \(dir).tmp \(dir)", "Couldn't unpack Frame Control on the headset")
}
// Another phone or iPad may be running a different version right now, so only
// versions (and interrupted unpacks) untouched for two weeks go. This one is
// marked as used.
_ = try? await link.run("touch \(dir) && find \(cacheDir) -mindepth 1 -maxdepth 1 -type d ! -name \(bundle.version) "
+ "-mtime +14 -exec rm -rf {} +")
return dir
}
/// Starts the server in dir and waits for it to say which port it took.
static func start(in dir: String, over link: FrameLink, key: String, device: String) async throws -> HeadsetServer {
let command = "cd \(dir) && FRAME_LOCAL=1 FRAME_UI_KEY=\(key) FRAME_DEVICE=\(shellQuote(device)) "
+ "exec python3 -I -u -B ui/server.py --port 0 --exit-on-eof 2>&1"
let stream = try await link.client.executeCommandStream(command)
let box = PortWaiter()
let reader = Task { () -> Void in
var text = ""
do {
for try await chunk in stream {
switch chunk {
case .stdout(let b), .stderr(let b): text += String(buffer: b)
}
if text.count > 20_000 { text = String(text.suffix(10_000)) }
if let port = Self.port(in: text) { box.found(port) }
}
} catch {
text += "\n\(error)"
}
box.ended(text)
}
let server: HeadsetServer
do {
server = HeadsetServer(port: try await box.wait(seconds: 30))
} catch {
reader.cancel()
throw error
}
box.whenEnded { [weak server] tail in server?.markExited(tail) }
return server
}
/// The port from the server's first line. Output arrives in chunks, so the digits
/// only count once something follows them (the line goes on after the port).
static func port(in text: String) -> Int? {
guard let r = text.range(of: #"Frame Control on http://127\.0\.0\.1:[0-9]+\s"#, options: .regularExpression),
let port = Int(text[r].dropLast().split(separator: ":").last ?? ""), (1...65535).contains(port) else { return nil }
return port
}
}
/// Hands the port from the output reader to start(), or the output if the server died first.
private final class PortWaiter: @unchecked Sendable {
private let lock = NSLock()
private var continuation: CheckedContinuation<Int, Error>?
private var result: Result<Int, Error>?
private var endedTail: String?
private var onEnd: (@Sendable (String) -> Void)?
/// Calls back when the output ends, at once if it already has.
func whenEnded(_ callback: @escaping @Sendable (String) -> Void) {
let already: String? = lock.withLock {
if endedTail == nil { onEnd = callback }
return endedTail
}
if let already { callback(already) }
}
func found(_ port: Int) { finish(.success(port)) }
func ended(_ text: String) {
let tail = String(text.suffix(600)).trimmingCharacters(in: .whitespacesAndNewlines)
let callback: (@Sendable (String) -> Void)? = lock.withLock {
endedTail = tail
defer { onEnd = nil }
return onEnd
}
finish(.failure(FrameFailure("Frame Control's server on the headset stopped: \(tail.isEmpty ? "no output" : tail)")))
callback?(tail)
}
private func finish(_ r: Result<Int, Error>) {
let c: CheckedContinuation<Int, Error>? = lock.withLock {
guard result == nil else { return nil }
result = r
defer { continuation = nil }
return continuation
}
c?.resume(with: r)
}
func wait(seconds: Double) async throws -> Int {
Task { [weak self] in
try? await Task.sleep(nanoseconds: UInt64(seconds * 1e9))
self?.finish(.failure(FrameFailure("Frame Control's server on the headset didn't start within \(Int(seconds)) s")))
}
return try await withCheckedThrowingContinuation { c in
let done: Result<Int, Error>? = lock.withLock {
if let result { return result }
continuation = c
return nil
}
if let done { c.resume(with: done) }
}
}
}