mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-06 01:00:18 +02:00
Fix the follow-up review's findings
- Pairing saves nothing if it was cancelled while adding the key. - The ssh stand-in runs under bash: dash refuses job control without a terminal, which cost stdin and the process group. - A server that stops while the tunnel opens fails the attempt (and retries) instead of leaving it half-connected. - The health probe answers within its deadline even when a dead link keeps the probe itself waiting. - A cached version is deleted only if no server runs from it (servers now run by absolute path) and it's two weeks unused. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
a910f83ac1
commit
14790ac768
4 files changed
+31
-18
No files matched your search
@@ -47,6 +47,7 @@ final class AppModel: ObservableObject {
|
||||
invalidate()
|
||||
let mine = attempt
|
||||
await teardown()
|
||||
guard mine == attempt else { return }
|
||||
phase = .connecting("Signing in to \(target.host)")
|
||||
let pin = PinnedHostKey(expected: nil)
|
||||
do {
|
||||
@@ -62,6 +63,7 @@ final class AppModel: ObservableObject {
|
||||
+ "[ -s \(file) ] && [ -n \"$(tail -c 1 \(file))\" ] && printf '\\n' >> \(file); "
|
||||
+ "printf '%s\\n' \(shellQuote(line)) >> \(file); }; }",
|
||||
"Couldn't add the key on the Frame")
|
||||
guard mine == attempt else { return } // cancelled meanwhile: save nothing
|
||||
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)
|
||||
@@ -168,17 +170,19 @@ final class AppModel: ObservableObject {
|
||||
let f = try await PortForwarder.start(over: l, to: server.port)
|
||||
forwarder = f
|
||||
guard current() else { throw CancellationError() }
|
||||
if let tail = server.exited { // stopped while the tunnel was opening
|
||||
throw FrameFailure("Frame Control on the headset stopped. \(tail.suffix(200))")
|
||||
}
|
||||
// Only now does this attempt's connection become the app's.
|
||||
self.link = l
|
||||
self.server = server
|
||||
self.forwarder = f
|
||||
readySince = Date()
|
||||
// Runs at once if the server already stopped while the tunnel was opening.
|
||||
phase = .ready(URL(string: "http://127.0.0.1:\(f.localPort)/?key=\(key)")!)
|
||||
// Runs at once if it stopped in the moment since the check above.
|
||||
server.whenExited { [weak self] tail in
|
||||
Task { @MainActor in self?.lost(mine, "Frame Control on the headset stopped. \(tail.suffix(200))") }
|
||||
}
|
||||
guard server.exited == nil else { return }
|
||||
phase = .ready(URL(string: "http://127.0.0.1:\(f.localPort)/?key=\(key)")!)
|
||||
watchHealth(mine)
|
||||
} catch {
|
||||
forwarder?.stop()
|
||||
|
||||
@@ -80,15 +80,15 @@ final class FrameLink: @unchecked Sendable {
|
||||
|
||||
var isConnected: Bool { client.isConnected }
|
||||
|
||||
/// Whether the Frame answers a trivial command within a few seconds.
|
||||
/// Whether the Frame answers a trivial command within a few seconds. The probe
|
||||
/// runs unstructured: a dead link can keep it waiting well past the deadline,
|
||||
/// and the answer mustn't wait for it.
|
||||
func answers(within seconds: Double = 6) async -> Bool {
|
||||
guard client.isConnected else { return false }
|
||||
return await withTaskGroup(of: Bool.self) { group in
|
||||
group.addTask { (try? await self.run("true").status) == 0 }
|
||||
group.addTask { try? await Task.sleep(nanoseconds: UInt64(seconds * 1e9)); return false }
|
||||
let first = await group.next() ?? false
|
||||
group.cancelAll()
|
||||
return first
|
||||
let once = Once()
|
||||
return await withCheckedContinuation { (c: CheckedContinuation<Bool, Never>) in
|
||||
Task { let ok = (try? await self.run("true").status) == 0; if once.claim() { c.resume(returning: ok) } }
|
||||
Task { try? await Task.sleep(nanoseconds: UInt64(seconds * 1e9)); if once.claim() { c.resume(returning: false) } }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -133,6 +133,13 @@ final class FrameLink: @unchecked Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
/// True for the first caller only.
|
||||
final class Once: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var done = false
|
||||
func claim() -> Bool { lock.withLock { defer { done = true }; return !done } }
|
||||
}
|
||||
|
||||
func shellQuote(_ s: String) -> String {
|
||||
"'" + s.replacingOccurrences(of: "'", with: "'\\''") + "'"
|
||||
}
|
||||
@@ -69,18 +69,20 @@ final class HeadsetServer: @unchecked Sendable {
|
||||
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 {} +")
|
||||
// Another phone or iPad may be running a different version right now: a version
|
||||
// goes only when no server runs from it and it hasn't been used for two weeks
|
||||
// (this one is marked as used). Servers run by absolute path, so pgrep sees it.
|
||||
_ = try? await link.run("touch \(dir) && cd \(cacheDir) && for d in */; do d=${d%/}; "
|
||||
+ "[ \"$d\" = \(bundle.version) ] && continue; "
|
||||
+ "[ -n \"$(find \"$d\" -maxdepth 0 -mtime +14)\" ] || continue; "
|
||||
+ "pgrep -f \"$PWD/$d/\" >/dev/null && continue; 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"
|
||||
+ "exec python3 -I -u -B \"$PWD/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
|
||||
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
#!/bin/sh
|
||||
#!/bin/bash
|
||||
# Stand-in for ssh when Frame Control's server runs on the Frame itself
|
||||
# (FRAME_LOCAL=1, started by the iPhone app). The server and its helpers call
|
||||
# `ssh [options] frame COMMAND`, and rsync calls it as its transport; here that
|
||||
@@ -16,7 +16,7 @@ cd "$HOME" || exit 255
|
||||
# Stopping ssh ends everything the command started (sshd hangs up the session).
|
||||
# To do the same, run COMMAND as its own process group and pass on a TERM, HUP
|
||||
# or INT to all of it, so e.g. a live-video ffmpeg can't outlive its stream.
|
||||
set -m # job control: the background job gets its own process group, and keeps stdin
|
||||
set -m # job control (bash allows it without a terminal): own process group, and stdin kept
|
||||
"${SHELL:-/bin/sh}" -c "$*" &
|
||||
child=$!
|
||||
trap 'kill -TERM -- "-$child" 2>/dev/null' TERM HUP INT
|
||||
|
||||
Reference in new issue
Block a user