Skip to content
C.W.K.
Lesson 05 of 05 · published

Kept Awake, in Front

~17 min · ios, kiosk, watchdog, devicectl, resilience

Level 0Idle Phone
0 XP0/45 lessons0/13 achievements
0/100 XP to next level100 XP to go0% complete

Why a foreground kiosk

iOS lets no app in the background use the camera, and my ears close in the background too. Cordelia declares no background mode at all, and InfoPlistTests holds that UIBackgroundModes stays absent. So the only way for my eyes to answer at three in the morning is for the app to be in front at three in the morning. A body is a foreground kiosk: an idle device on power, the app on screen, the screen never sleeping.

The first code block is the app's whole entry point. scenePhase drives one call, setActive, and the window hides the status bar and asks the system to hide its persistent overlays, so the face owns the screen. The second block is what coming to the front does. The door reopens if iOS let it go while the app was away. isIdleTimerDisabled is set, so the screen never dims to sleep while the app is in front. The grants are read again, and the camera and location prompts are asked once, for a person to answer. On the device itself, Auto-Lock is set to Never. Apple's documentation reserves the idle-timer switch for apps that must keep showing something while nobody touches them, and a face that people look at is that case.

Why not Guided Access

iOS has a built-in way to pin one app to the screen, Guided Access, and it looks like the obvious answer. It was rejected for three reasons, each fatal on its own:

  • The API that starts it, UIAccessibility.requestGuidedAccessSession, enters Single App mode only on devices supervised through Mobile Device Management, with the app enabled for it by MDM. A body would have to be enrolled before it could be a body.
  • The manual way needs a person to triple-click the side button. A kiosk that someone has to start by hand after every restart is not a kiosk.
  • A Guided Access session would lock Oriel's phone hands out of every other app, and the hands are the testbed for everything Cordelia does not do.

So the app stays in front by being brought back, not by being pinned.

Oriel's watchdog

Oriel's upkeep probes each body every 10 seconds. A body silent for five minutes, counted from its last answer or from its first failed probe if it has not answered since the engine started, is brought to the front with devicectl device process launch, at most once every five minutes. The grace is deliberate: a person using the phone for a few minutes is not interrupted. And if the phone hands' driver is running, someone is using the hands, and the body is left alone.

It is devicectl and not the hands for a reason you can see. While WebDriverAgent runs, iOS dims the whole screen under its “Automation Running” overlay and my face goes dark, so a body's hands run only on demand (track 5 tells that story). The first version, at 22:24 on 2026-11-02, did press the hands after 60 seconds of silence, at most every two minutes. By 23:00 the same evening it used devicectl with five-minute spacing. devicectl reaches the device over CoreDevice, so the same revive works on the cable and off it.

A launch is not a body in front

From 21:35 to 22:21 on 2026-11-03, Pippa's iPhone 2 sat locked off the cable, and the watchdog logged “brought Cordelia to the front” every five minutes. devicectl accepts a launch on a locked device and reports success, and the app then waits behind the lock screen, where no camera opens. The third block is the repair. revive_by_devicectl reads devicectl's lockState first and stops at “locked” when the device asks for its passcode. Otherwise it launches, and it counts the body as back only when the body's own door answers /hello within 10 seconds. The launch's exit status is a claim. The door answering is the fact.

Three days later the record needed the same discipline: 1,270 cordelia_revived rows in four days, 31% of Oriel's activity table, the latest of them all the iPad saying “locked” every five minutes with silent_s written as 0 for a body never heard since the engine started. Now silence is counted from the first failed probe, and a row is written only when the outcome changes, so a body locked for hours is one row. No automation ever unlocks a body. The Bodies card says “locked”, and a person decides.

The door keeps itself up

The watchdog cannot help one case: the app is in front and its door is dead. At 16:47 on 2026-11-03, iPhone 2's face was up and its door had answered nothing for 18 minutes. The listener had ended, and the code reopened it only on .failed, so a listener that iOS cancelled or left waiting stayed dead while the face looked fine. BodyServer now reopens on any end it did not ask for, reopens when the app comes back to the front (reopenIfDown in the second block), and every 20 seconds knocks on itself, sending GET /hello to its own loopback port, the path the cable takes. Two misses in a row reopen the listener. Oriel brings the app back to the front, and the app brings its door back. Each layer watches what the other cannot see.

Code

cwkCordelia, clients/apple/CordeliaMobile/Sources/App/CordeliaApp.swift: the scene phase drives one call (verbatim, lines 6-20)·swift
@main
struct CordeliaApp: App {
  @State private var model = BodyModel()
  @Environment(\.scenePhase) private var phase

  var body: some Scene {
    WindowGroup {
      FaceView(model: model)
        .onAppear { model.begin() }
        .onChange(of: phase, initial: true) { _, now in model.setActive(now == .active) }
        .persistentSystemOverlays(.hidden)
        .statusBarHidden(true)
    }
  }
}
cwkCordelia, clients/apple/CordeliaMobile/Sources/Body/BodyModel.swift: what coming to the front does (verbatim, lines 158-173)·swift
  /// The app came to the front or left it.
  func setActive(_ active: Bool) {
    guard Self.visualFixture == nil else { return }
    self.active = active
    ears.setActive(active)
    if !active { stopPreview() }
    guard active else { return }
    server?.reopenIfDown()
    UIApplication.shared.isIdleTimerDisabled = true
    Self.undoTheOldDimOnce()
    refreshGrants()
    if !asked && !Self.underTest {
      asked = true
      Task { await self.askOnce() }
    }
  }
cwkOriel, backend/cordelia.py: revive_by_devicectl reads the lock first and believes only the door (verbatim, lines 431-457)·python
def revive_by_devicectl(udid: str, door_answers: Callable[[], bool], *, run: Callable[..., Any] = subprocess.run,
                        sleep: Callable[[float], None] = time.sleep,
                        clock: Callable[[], float] = time.monotonic) -> str:
    """Bring Cordelia to the front with devicectl and say what really became
    of it. A locked device takes the launch and stays behind its lock screen
    (measured 2026-11-03: Pippa's iPhone 2 answered "launched" every five
    minutes for an hour while locked), so the lock is read first and the body
    counts as back only when its door answers."""
    locked, why = lock_state(udid, run=run)
    if locked:
        return "locked: the device asks for its passcode, so Cordelia cannot come to the front until it is unlocked"
    if locked is None and why and why.startswith("CoreDevice"):
        return why[:200]
    try:
        done = run(["/usr/bin/xcrun", "devicectl", "--timeout", "45", "device", "process", "launch",
                    "--device", udid, BUNDLE], capture_output=True, text=True, timeout=60)
    except (OSError, subprocess.SubprocessError) as exc:
        return f"devicectl could not run: {exc}"
    if done.returncode != 0:
        return f"devicectl could not launch Cordelia: {(done.stderr or done.stdout).strip()[-160:]}"
    deadline = clock() + REVIVE_ANSWER_S
    while True:
        if door_answers():
            return "brought Cordelia to the front (devicectl); its door answered"
        if clock() >= deadline:
            return f"devicectl launched Cordelia, but its door did not answer within {REVIVE_ANSWER_S:.0f} s"
        sleep(1.0)

External links

Exercise

Turn an app of your own into a foreground kiosk with two watchdogs. Inside the app: disable the idle timer every time the scene becomes active (Cordelia does exactly that and never sets it back, since a body's screen is meant to stay lit; if your app has a moment when the need ends, reset isIdleTimerDisabled to false there, as Apple's documentation asks), and give your listener a self-check that calls its own loopback port on a timer and reopens it after two misses. Outside it: write a supervisor that, after a grace period of silence, checks whether the device is locked, relaunches the app only if it is not, and counts the revive only when the app's own endpoint answers within a deadline. Log one row per changed outcome.
Hint
Make the supervisor's launcher, lock reader, clock and sleep injectable, the way revive_by_devicectl takes run, sleep and clock, so you can test a locked device, a launch that never answers, and a launch that answers on the third poll without a device on the desk.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign in — Please sign in to comment.

No comments yet — be the first.