mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-06 06:00:33 +02:00
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>
This commit is contained in:
1 parent
0f770dc88a
commit
13187e8f6a
33 files changed
+2459
-34
No files matched your search
@@ -0,0 +1,212 @@
|
||||
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)..<close])
|
||||
} else if target.host.filter({ $0 == ":" }).count == 1, let colon = target.host.lastIndex(of: ":"),
|
||||
let port = Int(target.host[target.host.index(after: colon)...]) {
|
||||
target.port = port
|
||||
target.host = String(target.host[..<colon])
|
||||
}
|
||||
guard !target.host.isEmpty, !target.user.isEmpty, (1...65535).contains(target.port) else { return nil }
|
||||
return target
|
||||
}
|
||||
|
||||
/// This phone's line for ~/.ssh/authorized_keys on the Frame.
|
||||
var authorizedKeysLine: String {
|
||||
DeviceKey.authorizedKeysLine(DeviceKey.loadOrCreate(), comment: "frame-control@\(deviceName)")
|
||||
}
|
||||
|
||||
/// Forget the headset: back to the pairing screen. The Frame keeps the key line;
|
||||
/// remove it from ~/.ssh/authorized_keys there to revoke this phone.
|
||||
func forget() async {
|
||||
await teardown()
|
||||
UserDefaults.standard.removeObject(forKey: Self.settingsKey)
|
||||
UserDefaults.standard.removeObject(forKey: Self.hostKeyKey)
|
||||
settings = nil
|
||||
phase = .setup
|
||||
}
|
||||
|
||||
func showSetup() {
|
||||
Task { await teardown() }
|
||||
phase = .setup
|
||||
}
|
||||
|
||||
// MARK: connecting
|
||||
|
||||
/// quiet: a background retry, which leaves the failure screen up until it works.
|
||||
func connect(quiet: Bool = false) async {
|
||||
guard let settings else {
|
||||
phase = .setup
|
||||
return
|
||||
}
|
||||
await teardown()
|
||||
attempt += 1
|
||||
let mine = attempt
|
||||
func step(_ s: String) { if mine == attempt && !quiet { phase = .connecting(s) } }
|
||||
step("Connecting to \(settings.host)")
|
||||
do {
|
||||
let bundle = try HeadsetServer.Bundle.fromApp()
|
||||
let auth = SSHAuthenticationMethod.ed25519(username: settings.user, privateKey: DeviceKey.loadOrCreate())
|
||||
let pin = PinnedHostKey(expected: hostKey)
|
||||
let link = try await FrameLink.connect(settings, auth: auth, hostKey: pin)
|
||||
guard mine == attempt else { await link.close(); return }
|
||||
self.link = link
|
||||
if hostKey == nil, let seen = pin.seen { UserDefaults.standard.set(seen, forKey: Self.hostKeyKey) }
|
||||
let dir = try await HeadsetServer.deploy(bundle, over: link) { s in Task { @MainActor in step(s) } }
|
||||
step("Starting Frame Control on the headset")
|
||||
let key = Self.randomKey()
|
||||
let server = try await HeadsetServer.start(in: dir, over: link, key: key, device: deviceName)
|
||||
self.server = server
|
||||
let forwarder = try await PortForwarder.start(over: link, to: server.port)
|
||||
self.forwarder = forwarder
|
||||
guard mine == attempt else { return }
|
||||
server.onExit = { [weak self] tail in
|
||||
Task { @MainActor in self?.lost(mine, "Frame Control on the headset stopped. \(tail.suffix(200))") }
|
||||
}
|
||||
readySince = Date()
|
||||
phase = .ready(URL(string: "http://127.0.0.1:\(forwarder.localPort)/?key=\(key)")!)
|
||||
} catch {
|
||||
guard mine == attempt else { return }
|
||||
await teardown()
|
||||
phase = .failed((error as? FrameFailure)?.message ?? FrameLink.describe(error, host: settings.host))
|
||||
// Keep trying quietly while the app is open: the Frame may just be asleep.
|
||||
// A new task each time, so retrying for hours doesn't nest awaits.
|
||||
Task { [weak self] in
|
||||
try? await Task.sleep(nanoseconds: 10_000_000_000)
|
||||
guard let self, mine == self.attempt, case .failed = self.phase,
|
||||
UIApplication.shared.applicationState == .active else { return }
|
||||
await self.connect(quiet: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Called when the app comes back to the foreground: iOS may have dropped the
|
||||
/// connection while it was in the background.
|
||||
func resume() {
|
||||
switch phase {
|
||||
case .ready:
|
||||
if link?.isConnected != true || server?.exited != nil { Task { await connect() } }
|
||||
case .failed:
|
||||
if settings != nil { Task { await connect() } }
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
private var readySince = Date.distantPast
|
||||
|
||||
private func lost(_ which: Int, _ why: String) {
|
||||
guard which == attempt, case .ready = phase else { return }
|
||||
// Restart it once; if it dies again straight away, say so instead of looping.
|
||||
if Date().timeIntervalSince(readySince) < 20 {
|
||||
Task { await teardown() }
|
||||
phase = .failed(why)
|
||||
} else {
|
||||
Task { await connect() }
|
||||
}
|
||||
}
|
||||
|
||||
private func teardown() async {
|
||||
forwarder?.stop()
|
||||
forwarder = nil
|
||||
server = nil
|
||||
if let link {
|
||||
self.link = nil
|
||||
await link.close() // ends the server too: its stdin closes
|
||||
}
|
||||
}
|
||||
|
||||
private static func randomKey() -> String {
|
||||
var bytes = [UInt8](repeating: 0, count: 24)
|
||||
_ = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes)
|
||||
return bytes.map { String(format: "%02x", $0) }.joined()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct FrameControlApp: App {
|
||||
@StateObject private var model = AppModel()
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
RootView(model: model)
|
||||
.task {
|
||||
#if DEBUG
|
||||
// Simulator testing without the pairing screen: print this device's key,
|
||||
// and connect to FRAME_TEST_HOST with it (`simctl launch` passes
|
||||
// SIMCTL_CHILD_FRAME_TEST_HOST through as FRAME_TEST_HOST).
|
||||
print("FRAME_CONTROL_KEY: \(model.authorizedKeysLine)")
|
||||
if model.settings == nil, let host = ProcessInfo.processInfo.environment["FRAME_TEST_HOST"] {
|
||||
await model.useKey(host: host, user: "steamos")
|
||||
return
|
||||
}
|
||||
#endif
|
||||
if model.settings != nil { await model.connect() }
|
||||
}
|
||||
.onOpenURL { url in
|
||||
// frame-control://install?… from a website (docs/web-install.md).
|
||||
guard let link = InstallLink(url.absoluteString), model.pendingInstallLinks.count < 5 else { return }
|
||||
model.pendingInstallLinks.append(link)
|
||||
}
|
||||
.onChange(of: scenePhase) { _, phase in
|
||||
if phase == .active { model.resume() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import Foundation
|
||||
|
||||
/// frame-control://install?manifest=URL or ?url=URL (docs/web-install.md), the same
|
||||
/// first filter as app/install-link.js. The server on the Frame applies the full
|
||||
/// rules (HTTPS, no private addresses, redirects) before fetching anything.
|
||||
struct InstallLink: Equatable {
|
||||
enum Kind: String { case manifest, url }
|
||||
let kind: Kind
|
||||
let target: String
|
||||
|
||||
static let scheme = "frame-control"
|
||||
private static let maxLink = 4096
|
||||
private static let maxURL = 2048
|
||||
|
||||
init?(_ raw: String) {
|
||||
guard raw.count <= Self.maxLink, raw.lowercased().hasPrefix("\(Self.scheme):"),
|
||||
let link = URLComponents(string: raw), link.scheme?.lowercased() == Self.scheme,
|
||||
link.host?.lowercased() == "install", ["", "/"].contains(link.path) else { return nil }
|
||||
let items = link.queryItems ?? []
|
||||
guard items.count == 1, let item = items.first, let kind = Kind(rawValue: item.name),
|
||||
let target = item.value, !target.isEmpty, target.count <= Self.maxURL,
|
||||
let url = URLComponents(string: target), ["https", "http"].contains(url.scheme?.lowercased() ?? ""),
|
||||
url.host?.isEmpty == false, url.user == nil, url.password == nil else { return nil }
|
||||
self.kind = kind
|
||||
self.target = target
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"colors" : [ { "color" : { "color-space" : "srgb", "components" : { "alpha" : "1.000", "blue" : "0xFF", "green" : "0x9F", "red" : "0x1A" } }, "idiom" : "universal" } ],
|
||||
"info" : { "author" : "xcode", "version" : 1 }
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"images" : [ { "filename" : "icon-1024.png", "idiom" : "universal", "platform" : "ios", "size" : "1024x1024" } ],
|
||||
"info" : { "author" : "xcode", "version" : 1 }
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 190 KiB |
@@ -0,0 +1 @@
|
||||
{ "images" : [ { "filename" : "icon.png", "idiom" : "universal" } ], "info" : { "author" : "xcode", "version" : 1 } }
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 190 KiB |
@@ -0,0 +1 @@
|
||||
{ "info" : { "author" : "xcode", "version" : 1 } }
|
||||
@@ -0,0 +1,69 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>Frame Control</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0</string>
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleURLName</key>
|
||||
<string>com.saphid.framecontrol.install</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>frame-control</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1</string>
|
||||
<key>LSApplicationQueriesSchemes</key>
|
||||
<array>
|
||||
<string>ssh</string>
|
||||
<string>sftp</string>
|
||||
<string>steamlink</string>
|
||||
<string>rdp</string>
|
||||
</array>
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsLocalNetworking</key>
|
||||
<true/>
|
||||
</dict>
|
||||
<key>NSLocalNetworkUsageDescription</key>
|
||||
<string>Frame Control connects to your Steam Frame over your local network with SSH.</string>
|
||||
<key>UILaunchScreen</key>
|
||||
<dict>
|
||||
<key>UIColorName</key>
|
||||
<string></string>
|
||||
</dict>
|
||||
<key>UISupportedInterfaceOrientations</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>UISupportedInterfaceOrientations~ipad</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>UIUserInterfaceStyle</key>
|
||||
<string>Dark</string>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,126 @@
|
||||
import Citadel
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
import NIOCore
|
||||
import NIOSSH
|
||||
|
||||
/// Where the Frame is and who to log in as.
|
||||
struct FrameSettings: Codable, Equatable {
|
||||
var host: String
|
||||
var port: Int = 22
|
||||
var user: String = "steamos"
|
||||
}
|
||||
|
||||
struct FrameFailure: LocalizedError {
|
||||
let message: String
|
||||
init(_ message: String) { self.message = message }
|
||||
var errorDescription: String? { message }
|
||||
}
|
||||
|
||||
/// Trust on first use: pairing records the Frame's host key; later connections
|
||||
/// accept that key and nothing else, as ssh's known_hosts does.
|
||||
final class PinnedHostKey: NIOSSHClientServerAuthenticationDelegate, @unchecked Sendable {
|
||||
struct Changed: Error {}
|
||||
let expected: String?
|
||||
private let lock = NSLock()
|
||||
private var _seen: String?
|
||||
var seen: String? { lock.withLock { _seen } }
|
||||
|
||||
init(expected: String?) { self.expected = expected }
|
||||
|
||||
func validateHostKey(hostKey: NIOSSHPublicKey, validationCompletePromise: EventLoopPromise<Void>) {
|
||||
let key = String(openSSHPublicKey: hostKey)
|
||||
lock.withLock { _seen = key }
|
||||
if expected == nil || expected == key {
|
||||
validationCompletePromise.succeed(())
|
||||
} else {
|
||||
validationCompletePromise.fail(Changed())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One SSH connection to the Frame, and the few things the app does over it.
|
||||
final class FrameLink: @unchecked Sendable {
|
||||
let client: SSHClient
|
||||
|
||||
private init(client: SSHClient) { self.client = client }
|
||||
|
||||
static func connect(_ settings: FrameSettings, auth: SSHAuthenticationMethod, hostKey: PinnedHostKey) async throws -> FrameLink {
|
||||
do {
|
||||
let client = try await SSHClient.connect(
|
||||
host: settings.host, port: settings.port, authenticationMethod: auth,
|
||||
hostKeyValidator: .custom(hostKey), reconnect: .never, connectTimeout: .seconds(8))
|
||||
return FrameLink(client: client)
|
||||
} catch {
|
||||
throw FrameFailure(describe(error, host: settings.host))
|
||||
}
|
||||
}
|
||||
|
||||
/// The plain-language reason a connection failed, like the desktop server's messages.
|
||||
static func describe(_ error: Error, host: String) -> String {
|
||||
if error is PinnedHostKey.Changed {
|
||||
return "The Frame's SSH identity changed (after a reinstall, or a different device at \(host)). Pair again."
|
||||
}
|
||||
let text = String(describing: error)
|
||||
if text.contains("allAuthenticationOptionsFailed") || text.contains("authentication") {
|
||||
return "The Frame didn't accept the login. Pair again, and check the Developer Mode password."
|
||||
}
|
||||
if ["timeout", "Timeout", "timed out", "Host is down", "No route to host", "Network is unreachable",
|
||||
"errno: 64", "errno: 65", "errno: 51", "errno: 60"].contains(where: text.contains) {
|
||||
return "The Frame isn't answering at \(host). It may be asleep, switched off, or on another network."
|
||||
}
|
||||
if text.contains("refused") || text.contains("ECONNREFUSED") {
|
||||
return "The Frame refused the connection at \(host). Check Developer Mode is still on."
|
||||
}
|
||||
if text.contains("NXDOMAIN") || text.contains("resolve") || text.contains("unknownHost") || text.contains("NoAddress") {
|
||||
return "Can't find \(host) on the network. Check the address, and that the Frame is on the same network."
|
||||
}
|
||||
return "Couldn't connect to \(host): \(text)"
|
||||
}
|
||||
|
||||
var isConnected: Bool { client.isConnected }
|
||||
|
||||
func close() async {
|
||||
try? await client.close()
|
||||
}
|
||||
|
||||
/// Runs a shell command; returns its combined output and exit status.
|
||||
func run(_ command: String) async throws -> (output: String, status: Int) {
|
||||
// stderr joins stdout (Citadel treats any stderr as a failure), and the
|
||||
// status comes back as the last line so a non-zero exit isn't an exception.
|
||||
let buffer = try await client.executeCommand("{ \(command)\n} 2>&1; echo \"@@rc=$?\"")
|
||||
var text = String(buffer: buffer)
|
||||
var status = 0
|
||||
if let range = text.range(of: "@@rc=", options: .backwards) {
|
||||
status = Int(text[range.upperBound...].trimmingCharacters(in: .whitespacesAndNewlines)) ?? -1
|
||||
text = String(text[..<range.lowerBound])
|
||||
}
|
||||
return (text.trimmingCharacters(in: .whitespacesAndNewlines), status)
|
||||
}
|
||||
|
||||
/// Runs a command that must succeed; its output, or a FrameFailure with it.
|
||||
@discardableResult
|
||||
func check(_ command: String, _ what: String) async throws -> String {
|
||||
let r = try await run(command)
|
||||
guard r.status == 0 else { throw FrameFailure("\(what): \(r.output.isEmpty ? "exit \(r.status)" : r.output)") }
|
||||
return r.output
|
||||
}
|
||||
|
||||
/// Writes data to a path relative to the home directory.
|
||||
func upload(_ data: Data, to path: String) async throws {
|
||||
let sftp = try await client.openSFTP()
|
||||
do {
|
||||
try await sftp.withFile(filePath: path, flags: [.write, .create, .truncate]) { file in
|
||||
try await file.write(ByteBuffer(bytes: data))
|
||||
}
|
||||
try? await sftp.close()
|
||||
} catch {
|
||||
try? await sftp.close()
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func shellQuote(_ s: String) -> String {
|
||||
"'" + s.replacingOccurrences(of: "'", with: "'\\''") + "'"
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
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?
|
||||
/// Set once the server stops, with its last output.
|
||||
var exited: String? { lock.withLock { _exited } }
|
||||
var onExit: (@Sendable (String) -> Void)?
|
||||
|
||||
private init(port: Int) { self.port = port }
|
||||
|
||||
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")
|
||||
}
|
||||
// Older versions: only this one is used from now on.
|
||||
_ = try? await link.run("cd \(cacheDir) && for d in */; do [ \"${d%/}\" = \(bundle.version) ] || rm -rf -- \"$d\"; done")
|
||||
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.onEnd = { [weak server] tail in
|
||||
guard let server else { return }
|
||||
server.lock.withLock { server._exited = tail }
|
||||
server.onExit?(tail)
|
||||
}
|
||||
return server
|
||||
}
|
||||
|
||||
static func port(in text: String) -> Int? {
|
||||
guard let r = text.range(of: #"Frame Control on http://127\.0\.0\.1:(\d+)"#, options: .regularExpression) else { return nil }
|
||||
return Int(text[r].split(separator: ":").last ?? "")
|
||||
}
|
||||
}
|
||||
|
||||
/// 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?
|
||||
var onEnd: (@Sendable (String) -> Void)? {
|
||||
didSet { if let tail = lock.withLock({ endedTail }) { onEnd?(tail) } }
|
||||
}
|
||||
|
||||
func found(_ port: Int) { finish(.success(port)) }
|
||||
|
||||
func ended(_ text: String) {
|
||||
let tail = String(text.suffix(600)).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
lock.withLock { endedTail = tail }
|
||||
finish(.failure(FrameFailure("Frame Control's server on the headset stopped: \(tail.isEmpty ? "no output" : tail)")))
|
||||
onEnd?(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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import SwiftUI
|
||||
|
||||
struct RootView: View {
|
||||
@ObservedObject var model: AppModel
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Color.frameBackground.ignoresSafeArea()
|
||||
switch model.phase {
|
||||
case .setup:
|
||||
SetupView(model: model)
|
||||
case .connecting(let step):
|
||||
ConnectingView(step: step, host: model.settings?.host) { model.showSetup() }
|
||||
case .failed(let message):
|
||||
FailedView(message: message, canRetry: model.settings != nil,
|
||||
retry: { Task { await model.connect() } }, change: { model.showSetup() })
|
||||
case .ready(let url):
|
||||
WebShell(url: url, model: model).ignoresSafeArea()
|
||||
}
|
||||
}
|
||||
.preferredColorScheme(.dark)
|
||||
.tint(.frameBlue)
|
||||
}
|
||||
}
|
||||
|
||||
extension Color {
|
||||
static let frameBackground = Color(red: 0.055, green: 0.078, blue: 0.106)
|
||||
static let framePanel = Color(red: 0.118, green: 0.137, blue: 0.161)
|
||||
static let frameBlue = Color(red: 0.102, green: 0.624, blue: 1.0)
|
||||
static let frameMuted = Color(red: 0.561, green: 0.596, blue: 0.627)
|
||||
}
|
||||
|
||||
struct ConnectingView: View {
|
||||
let step: String
|
||||
let host: String?
|
||||
let cancel: () -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 18) {
|
||||
Image("AppIconImage").resizable().frame(width: 76, height: 76).clipShape(RoundedRectangle(cornerRadius: 17))
|
||||
ProgressView().controlSize(.large)
|
||||
Text(step).font(.headline).multilineTextAlignment(.center)
|
||||
if let host { Text(host).font(.subheadline).foregroundStyle(Color.frameMuted) }
|
||||
Button("Change headset", action: cancel).padding(.top, 8)
|
||||
}
|
||||
.padding(32)
|
||||
}
|
||||
}
|
||||
|
||||
struct FailedView: View {
|
||||
let message: String
|
||||
let canRetry: Bool
|
||||
let retry: () -> Void
|
||||
let change: () -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 16) {
|
||||
Image(systemName: "wifi.exclamationmark").font(.system(size: 44)).foregroundStyle(.orange)
|
||||
Text("Can't reach the Frame").font(.title3.bold())
|
||||
Text(message).multilineTextAlignment(.center).foregroundStyle(Color.frameMuted)
|
||||
if canRetry { Text("Trying again every few seconds.").font(.footnote).foregroundStyle(Color.frameMuted) }
|
||||
if canRetry {
|
||||
Button("Try again", action: retry).buttonStyle(.borderedProminent).controlSize(.large)
|
||||
}
|
||||
Button(canRetry ? "Change headset" : "Back", action: change)
|
||||
}
|
||||
.padding(32)
|
||||
.frame(maxWidth: 480)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
/// Pairing: the Developer Mode password is used once, to add this phone's own
|
||||
/// key to the Frame. It isn't stored.
|
||||
struct SetupView: View {
|
||||
@ObservedObject var model: AppModel
|
||||
@State private var host = ""
|
||||
@State private var user = "steamos"
|
||||
@State private var password = ""
|
||||
@FocusState private var focus: Field?
|
||||
private enum Field { case host, user, password }
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Image("AppIconImage").resizable().frame(width: 64, height: 64).clipShape(RoundedRectangle(cornerRadius: 14))
|
||||
Text("Connect to your Steam Frame").font(.title2.bold())
|
||||
Text("See what the headset sees, install games and Android apps, and send files and text, from this \(model.deviceName).")
|
||||
.foregroundStyle(Color.frameMuted)
|
||||
}
|
||||
.padding(.vertical, 6)
|
||||
.listRowBackground(Color.clear)
|
||||
}
|
||||
Section {
|
||||
Label("On the Frame, open Steam Settings → System and turn on Developer Mode.", systemImage: "1.circle")
|
||||
Label("Then Developer → Set User Password.", systemImage: "2.circle")
|
||||
Label("Enter the headset's address and that password here, once.", systemImage: "3.circle")
|
||||
} header: { Text("Before you start") }
|
||||
Section {
|
||||
TextField("frame.local or 192.168.1.20", text: $host)
|
||||
.textContentType(.URL).keyboardType(.URL).autocorrectionDisabled().textInputAutocapitalization(.never)
|
||||
.focused($focus, equals: .host).submitLabel(.next).onSubmit { focus = .password }
|
||||
TextField("User", text: $user)
|
||||
.autocorrectionDisabled().textInputAutocapitalization(.never).focused($focus, equals: .user)
|
||||
SecureField("Developer Mode password", text: $password)
|
||||
.textContentType(.password).focused($focus, equals: .password).submitLabel(.go).onSubmit(pair)
|
||||
} header: { Text("Headset") } footer: {
|
||||
Text("The password is only used to add this \(model.deviceName)'s own SSH key to the Frame; it isn't saved. The Frame and this \(model.deviceName) need to be on the same network, or both on Tailscale.")
|
||||
}
|
||||
Section {
|
||||
Button(action: pair) {
|
||||
Text("Pair").frame(maxWidth: .infinity).fontWeight(.semibold)
|
||||
}
|
||||
.disabled(host.trimmingCharacters(in: .whitespaces).isEmpty || password.isEmpty)
|
||||
if model.settings != nil {
|
||||
Button("Use \(model.settings!.host) again") { Task { await model.connect() } }
|
||||
Button("Forget this headset", role: .destructive) { Task { await model.forget() } }
|
||||
}
|
||||
}
|
||||
Section {
|
||||
Text(model.authorizedKeysLine)
|
||||
.font(.system(.caption2, design: .monospaced)).lineLimit(3).textSelection(.enabled)
|
||||
Button("Copy this \(model.deviceName)'s key") { UIPasteboard.general.string = model.authorizedKeysLine }
|
||||
Button("Connect with the key") {
|
||||
let (h, u) = (host, user)
|
||||
Task { await model.useKey(host: h, user: u) }
|
||||
}
|
||||
.disabled(host.trimmingCharacters(in: .whitespaces).isEmpty)
|
||||
} header: { Text("Or add the key yourself") } footer: {
|
||||
Text("If you already reach the Frame over SSH, add this line to ~/.ssh/authorized_keys there, then connect without a password.")
|
||||
}
|
||||
}
|
||||
.scrollContentBackground(.hidden)
|
||||
.background(Color.frameBackground)
|
||||
.navigationTitle("Frame Control")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
.onAppear {
|
||||
host = model.settings?.host ?? "frame.local"
|
||||
user = model.settings?.user ?? "steamos"
|
||||
}
|
||||
}
|
||||
|
||||
private func pair() {
|
||||
let (h, u, p) = (host, user, password)
|
||||
password = ""
|
||||
Task { await model.pair(host: h, user: u, password: p) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
import WebKit
|
||||
|
||||
/// The Frame Control page, served by the server on the headset, in a web view.
|
||||
/// window.frameApp (the same bridge the desktop app's preload.js provides) lets
|
||||
/// the page use the phone: clipboard, saving images, other apps, install links.
|
||||
struct WebShell: UIViewRepresentable {
|
||||
let url: URL
|
||||
@ObservedObject var model: AppModel
|
||||
|
||||
func makeCoordinator() -> Coordinator { Coordinator(model: model) }
|
||||
|
||||
func makeUIView(context: Context) -> WKWebView {
|
||||
let config = WKWebViewConfiguration()
|
||||
let content = WKUserContentController()
|
||||
content.addUserScript(WKUserScript(source: Self.bridge, injectionTime: .atDocumentStart, forMainFrameOnly: true))
|
||||
content.addScriptMessageHandler(context.coordinator, contentWorld: .page, name: "frameApp")
|
||||
config.userContentController = content
|
||||
config.allowsInlineMediaPlayback = true
|
||||
let web = WKWebView(frame: .zero, configuration: config)
|
||||
web.navigationDelegate = context.coordinator
|
||||
web.uiDelegate = context.coordinator
|
||||
web.isOpaque = false
|
||||
web.backgroundColor = UIColor(red: 0.055, green: 0.078, blue: 0.106, alpha: 1)
|
||||
web.scrollView.backgroundColor = web.backgroundColor
|
||||
web.scrollView.contentInsetAdjustmentBehavior = .never // the page pads for the safe area itself
|
||||
web.allowsBackForwardNavigationGestures = false
|
||||
#if DEBUG
|
||||
web.isInspectable = true
|
||||
#endif
|
||||
context.coordinator.web = web
|
||||
web.load(URLRequest(url: url))
|
||||
return web
|
||||
}
|
||||
|
||||
func updateUIView(_ web: WKWebView, context: Context) {
|
||||
if context.coordinator.loaded != url {
|
||||
context.coordinator.loaded = url
|
||||
web.load(URLRequest(url: url))
|
||||
}
|
||||
context.coordinator.deliverInstallLinks()
|
||||
}
|
||||
|
||||
static let bridge = """
|
||||
(() => {
|
||||
const call = (name, arg) => window.webkit.messageHandlers.frameApp.postMessage({ name, arg: arg ?? null });
|
||||
let installCb = null;
|
||||
window.frameApp = {
|
||||
platform: "ios",
|
||||
readClipboard: () => call("readClipboard"),
|
||||
setUpConnection: () => call("setUpConnection"),
|
||||
open: (what) => call("open", what),
|
||||
saveImages: (images) => call("saveImages", images),
|
||||
onInstallLink: (cb) => { installCb = cb; return call("installLinkReady"); },
|
||||
};
|
||||
window.__frameInstallLink = (req) => { if (installCb) installCb(req); };
|
||||
})();
|
||||
"""
|
||||
|
||||
final class Coordinator: NSObject, WKScriptMessageHandlerWithReply, WKNavigationDelegate, WKUIDelegate {
|
||||
let model: AppModel
|
||||
weak var web: WKWebView?
|
||||
var loaded: URL?
|
||||
private var installReady = false
|
||||
|
||||
init(model: AppModel) { self.model = model }
|
||||
|
||||
// MARK: bridge
|
||||
|
||||
@MainActor
|
||||
func userContentController(_ controller: WKUserContentController, didReceive message: WKScriptMessage,
|
||||
replyHandler: @escaping (Any?, String?) -> Void) {
|
||||
guard let body = message.body as? [String: Any], let name = body["name"] as? String else {
|
||||
return replyHandler(nil, "bad message")
|
||||
}
|
||||
let arg = body["arg"]
|
||||
switch name {
|
||||
case "readClipboard":
|
||||
replyHandler(UIPasteboard.general.string ?? "", nil)
|
||||
case "setUpConnection":
|
||||
model.showSetup()
|
||||
replyHandler(nil, nil)
|
||||
case "open":
|
||||
let result = open(arg as? String ?? "")
|
||||
replyHandler(result.message.map { ["message": $0] }, result.error)
|
||||
case "saveImages":
|
||||
let images = (arg as? [[String: Any]] ?? []).compactMap { item -> UIImage? in
|
||||
guard let b64 = item["data"] as? String, let data = Data(base64Encoded: b64) else { return nil }
|
||||
return UIImage(data: data)
|
||||
}
|
||||
guard !images.isEmpty else { return replyHandler(nil, "No images to save") }
|
||||
share(images)
|
||||
replyHandler(["message": "Choose Save Image to keep \(images.count == 1 ? "it" : "them") in Photos"], nil)
|
||||
case "installLinkReady":
|
||||
installReady = true
|
||||
deliverInstallLinks()
|
||||
replyHandler(nil, nil)
|
||||
default:
|
||||
replyHandler(nil, "unknown request \(name)")
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func deliverInstallLinks() {
|
||||
guard installReady, let web, !model.pendingInstallLinks.isEmpty else { return }
|
||||
let links = model.pendingInstallLinks
|
||||
model.pendingInstallLinks = []
|
||||
for link in links {
|
||||
let req = ["kind": link.kind.rawValue, "target": link.target]
|
||||
guard let json = try? JSONSerialization.data(withJSONObject: req), let text = String(data: json, encoding: .utf8) else { continue }
|
||||
web.evaluateJavaScript("window.__frameInstallLink(\(text))")
|
||||
}
|
||||
}
|
||||
|
||||
/// SSH, SFTP, Steam Link and remote desktop open in the apps that handle them.
|
||||
@MainActor
|
||||
private func open(_ what: String) -> (message: String?, error: String?) {
|
||||
guard let s = model.settings else { return (nil, "Not paired with a Frame") }
|
||||
let host = s.host.contains(":") ? "[\(s.host)]" : s.host
|
||||
let target: (url: String, app: String, store: String)
|
||||
switch what {
|
||||
case "terminal": target = ("ssh://\(s.user)@\(host):\(s.port)", "an SSH app such as Blink Shell or Termius", "https://apps.apple.com/search?term=ssh")
|
||||
case "sftp": target = ("sftp://\(s.user)@\(host):\(s.port)", "an SFTP app such as Termius or Secure ShellFish", "https://apps.apple.com/search?term=sftp")
|
||||
case "steamlink": target = ("steamlink://", "Steam Link", "https://apps.apple.com/app/steam-link/id1246969117")
|
||||
case "rdp": target = ("rdp://full%20address=s:\(s.host):3389", "Windows App (Microsoft Remote Desktop)", "https://apps.apple.com/app/windows-app/id714464092")
|
||||
default: return (nil, "Can't open \(what) on this \(model.deviceName)")
|
||||
}
|
||||
guard let url = URL(string: target.url) else { return (nil, "Bad address") }
|
||||
if UIApplication.shared.canOpenURL(url) {
|
||||
UIApplication.shared.open(url)
|
||||
return ("Opening \(target.app)", nil)
|
||||
}
|
||||
if let store = URL(string: target.store) { UIApplication.shared.open(store) }
|
||||
return (nil, "Install \(target.app) to open this; opening the App Store")
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func share(_ images: [UIImage]) {
|
||||
guard let web, let root = web.window?.rootViewController else { return }
|
||||
let sheet = UIActivityViewController(activityItems: images, applicationActivities: nil)
|
||||
sheet.popoverPresentationController?.sourceView = web
|
||||
sheet.popoverPresentationController?.sourceRect = CGRect(x: web.bounds.midX, y: web.bounds.midY, width: 1, height: 1)
|
||||
(root.presentedViewController ?? root).present(sheet, animated: true)
|
||||
}
|
||||
|
||||
// MARK: navigation: the app's page stays here; other sites open in Safari
|
||||
|
||||
func webView(_ webView: WKWebView, decidePolicyFor action: WKNavigationAction,
|
||||
decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
|
||||
guard let url = action.request.url else { return decisionHandler(.cancel) }
|
||||
if url.host == "127.0.0.1" || url.scheme == "about" || url.scheme == "blob" || url.scheme == "data" {
|
||||
return decisionHandler(.allow)
|
||||
}
|
||||
UIApplication.shared.open(url)
|
||||
decisionHandler(.cancel)
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, createWebViewWith configuration: WKWebViewConfiguration,
|
||||
for action: WKNavigationAction, windowFeatures: WKWindowFeatures) -> WKWebView? {
|
||||
if let url = action.request.url { UIApplication.shared.open(url) } // target="_blank" links
|
||||
return nil
|
||||
}
|
||||
|
||||
// MARK: alert() and confirm(), which the page uses before removing things
|
||||
|
||||
func webView(_ webView: WKWebView, runJavaScriptAlertPanelWithMessage message: String,
|
||||
initiatedByFrame frame: WKFrameInfo, completionHandler: @escaping () -> Void) {
|
||||
present(message, actions: [UIAlertAction(title: "OK", style: .default) { _ in completionHandler() }], fallback: completionHandler)
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, runJavaScriptConfirmPanelWithMessage message: String,
|
||||
initiatedByFrame frame: WKFrameInfo, completionHandler: @escaping (Bool) -> Void) {
|
||||
present(message, actions: [
|
||||
UIAlertAction(title: "Cancel", style: .cancel) { _ in completionHandler(false) },
|
||||
UIAlertAction(title: "OK", style: .default) { _ in completionHandler(true) },
|
||||
], fallback: { completionHandler(false) })
|
||||
}
|
||||
|
||||
private func present(_ message: String, actions: [UIAlertAction], fallback: @escaping () -> Void) {
|
||||
guard let root = web?.window?.rootViewController else { return fallback() }
|
||||
let alert = UIAlertController(title: nil, message: message, preferredStyle: .alert)
|
||||
actions.forEach(alert.addAction)
|
||||
(root.presentedViewController ?? root).present(alert, animated: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user