diff --git a/ios/FrameControl/App/AppModel.swift b/ios/FrameControl/App/AppModel.swift index b019b5e..018d333 100644 --- a/ios/FrameControl/App/AppModel.swift +++ b/ios/FrameControl/App/AppModel.swift @@ -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() diff --git a/ios/FrameControl/SSH/FrameLink.swift b/ios/FrameControl/SSH/FrameLink.swift index 12f4e84..9c57ebc 100644 --- a/ios/FrameControl/SSH/FrameLink.swift +++ b/ios/FrameControl/SSH/FrameLink.swift @@ -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) 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: "'\\''") + "'" } diff --git a/ios/FrameControl/SSH/HeadsetServer.swift b/ios/FrameControl/SSH/HeadsetServer.swift index d273ee2..443e99d 100644 --- a/ios/FrameControl/SSH/HeadsetServer.swift +++ b/ios/FrameControl/SSH/HeadsetServer.swift @@ -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 diff --git a/ui/local-bin/ssh b/ui/local-bin/ssh index f73b5bf..733b1b8 100755 --- a/ui/local-bin/ssh +++ b/ui/local-bin/ssh @@ -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