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>
114 lines
4.5 KiB
Swift
114 lines
4.5 KiB
Swift
import Citadel
|
|
import Foundation
|
|
import NIOCore
|
|
import NIOPosix
|
|
import NIOSSH
|
|
|
|
/// Listens on this phone's 127.0.0.1 and carries each connection to a port on the
|
|
/// Frame's 127.0.0.1 through the SSH session (ssh -L). The web view loads the
|
|
/// server from here; every API request still needs the session's key.
|
|
final class PortForwarder: @unchecked Sendable {
|
|
private let channel: Channel
|
|
let localPort: Int
|
|
|
|
private init(channel: Channel, localPort: Int) {
|
|
self.channel = channel
|
|
self.localPort = localPort
|
|
}
|
|
|
|
static func start(over link: FrameLink, to remotePort: Int) async throws -> PortForwarder {
|
|
let client = link.client
|
|
// The listener shares the SSH connection's event loop, so the glue between
|
|
// each pair of channels never crosses threads.
|
|
let bootstrap = ServerBootstrap(group: client.eventLoop)
|
|
.serverChannelOption(ChannelOptions.socketOption(.so_reuseaddr), value: 1)
|
|
.childChannelOption(ChannelOptions.allowRemoteHalfClosure, value: true)
|
|
// Nothing is read from the web view until the SSH side is ready for it.
|
|
.childChannelOption(ChannelOptions.autoRead, value: false)
|
|
.childChannelInitializer { inbound in
|
|
inbound.eventLoop.makeFutureWithTask {
|
|
let (local, remote) = GlueHandler.matchedPair()
|
|
try await inbound.pipeline.addHandler(local).get()
|
|
let origin = try inbound.remoteAddress ?? SocketAddress(ipAddress: "127.0.0.1", port: 0)
|
|
_ = try await client.createDirectTCPIPChannel(
|
|
using: SSHChannelType.DirectTCPIP(targetHost: "127.0.0.1", targetPort: remotePort, originatorAddress: origin)
|
|
) { channel in channel.pipeline.addHandler(remote) }
|
|
try await inbound.setOption(ChannelOptions.autoRead, value: true).get()
|
|
}
|
|
}
|
|
let channel = try await bootstrap.bind(host: "127.0.0.1", port: 0).get()
|
|
guard let port = channel.localAddress?.port else { throw FrameFailure("Couldn't open a local port") }
|
|
return PortForwarder(channel: channel, localPort: port)
|
|
}
|
|
|
|
func stop() {
|
|
channel.close(promise: nil)
|
|
}
|
|
}
|
|
|
|
/// Joins two channels: what one reads, the other writes, with backpressure and
|
|
/// half-close passed across (the pattern from SwiftNIO's examples).
|
|
final class GlueHandler: ChannelDuplexHandler, @unchecked Sendable {
|
|
typealias InboundIn = NIOAny
|
|
typealias OutboundIn = NIOAny
|
|
typealias OutboundOut = NIOAny
|
|
|
|
private var partner: GlueHandler?
|
|
private var context: ChannelHandlerContext?
|
|
private var pendingRead = false
|
|
|
|
static func matchedPair() -> (GlueHandler, GlueHandler) {
|
|
let a = GlueHandler(), b = GlueHandler()
|
|
a.partner = b
|
|
b.partner = a
|
|
return (a, b)
|
|
}
|
|
|
|
private func partnerWrite(_ data: NIOAny) { context?.write(data, promise: nil) }
|
|
private func partnerFlush() { context?.flush() }
|
|
private func partnerWriteEOF() { context?.close(mode: .output, promise: nil) }
|
|
private func partnerClose() { context?.close(promise: nil) }
|
|
private var partnerWritable: Bool { context?.channel.isWritable ?? false }
|
|
|
|
private func partnerBecameWritable() {
|
|
if pendingRead {
|
|
pendingRead = false
|
|
context?.read()
|
|
}
|
|
}
|
|
|
|
func handlerAdded(context: ChannelHandlerContext) { self.context = context }
|
|
|
|
func handlerRemoved(context: ChannelHandlerContext) {
|
|
self.context = nil
|
|
partner = nil
|
|
}
|
|
|
|
func channelRead(context: ChannelHandlerContext, data: NIOAny) { partner?.partnerWrite(data) }
|
|
func channelReadComplete(context: ChannelHandlerContext) { partner?.partnerFlush() }
|
|
func channelInactive(context: ChannelHandlerContext) { partner?.partnerClose() }
|
|
|
|
func userInboundEventTriggered(context: ChannelHandlerContext, event: Any) {
|
|
if let e = event as? ChannelEvent, case .inputClosed = e {
|
|
partner?.partnerWriteEOF()
|
|
}
|
|
context.fireUserInboundEventTriggered(event)
|
|
}
|
|
|
|
func errorCaught(context: ChannelHandlerContext, error: Error) {
|
|
partner?.partnerClose()
|
|
}
|
|
|
|
func channelWritabilityChanged(context: ChannelHandlerContext) {
|
|
if context.channel.isWritable { partner?.partnerBecameWritable() }
|
|
}
|
|
|
|
func read(context: ChannelHandlerContext) {
|
|
if let partner, partner.partnerWritable {
|
|
context.read()
|
|
} else {
|
|
pendingRead = true
|
|
}
|
|
}
|
|
}
|