DEV Community

Super Funicular
Super Funicular

Posted on

Turn an old Android phone into a screen-off security camera (no cloud, LAN-only)

Old Android phones pile up in drawers. Instead of letting one die there, you can point it at a doorway, a driveway, or a pet's favorite corner and let it record. Here is a straightforward, cloud-free way to do it, and the reasoning behind each choice.

Why an old phone is a good camera

A three-year-old phone still has a decent sensor, Wi-Fi, and a battery. The two things that usually get in the way are the screen (it drains power and announces that the phone is recording) and cloud services (a monthly fee plus your footage on someone else's server). Solve those two and an old handset becomes a capable local camera.

The core trick: recording with the screen off

The feature that makes this practical is screen-off recording. The camera keeps capturing while the display is fully dark, so the phone sips power instead of gulping it and does not glow in a dark room. On Android you get this either through a background-camera recorder app or, on some OEM camera apps, a "screen off" toggle buried in settings.

Setup in about a minute

  1. Mount the old phone where you want coverage. A cheap gooseneck clamp or even a stack of books works.
  2. Keep it on a charger. Continuous recording will outrun any battery, so treat it as a wired device.
  3. Install a background-camera app that supports screen-off capture and, ideally, live viewing from a second device over your local network.
  4. Start recording, turn the screen off, and confirm from your main phone or laptop that the stream or the saved clips are coming through.

Local network vs cloud

If you only need to review footage later, saving clips to the phone's own storage is enough. If you want to watch a live feed, look for an app that streams over your LAN so the video never leaves your home network. That keeps latency low and keeps a subscription out of the picture. The trade-off is range: LAN-only means you watch from the same network, not from anywhere in the world, unless you add your own VPN.

How the common options compare

Option Local-only viewing Cost Notes
Alfred Camera (free tier) No, cloud-based Free tier limited to 2 cameras; paid ~$35.99/yr Cloud relay, easy setup
AtHome Camera Partial Free with paid tiers 720p on free tier
IP Webcam Yes, LAN Free Local streaming, more manual setup
WardenCam Yes Free/paid Effectively unmaintained since mid-2023
Background Camera RemoteStream Yes, LAN live view Free Phone-as-camera, screen-off recording, live remote view on your own network

Figures reflect what these apps advertised as of mid-2026; check each store listing before you rely on a number.

What to watch out for

Heat is the real enemy of a phone that records all day on a charger. Give it airflow and avoid sealed enclosures in direct sun. Also confirm your recorder actually keeps running when the screen sleeps; some phones aggressively kill background apps, so you may need to exclude the app from battery optimization.

Try it

The app I build for this is Background Camera RemoteStream. It does screen-off recording and live viewing over your local network, with no cloud account and no per-camera fee.

If you have a spare Android phone, this is a 60-second project that beats buying another gadget.

Related reading

Top comments (24)

Collapse
 
lafine_systemsdesign profile image
Tetsuharu Fujiki

Love this approach! The "no-cloud, LAN-only" philosophy is so refreshing in a world where almost every smart camera demands a subscription and sends your private video feed outside your home.

Wrestling with Android's Doze Mode and background lifecycle management to keep a camera capture session alive with the screen off is no small feat. Solving it cleanly with a proper Foreground Service rather than hacky workarounds shows real craftsmanship.

As an indie developer building a local-first macOS network security tool (RoamSwitch), I have huge respect for projects that prove you don't need heavyweight cloud backends to build useful, privacy-first software. Repurposing old hardware to keep video streams strictly on the local network is a massive win for both security and sustainability.

Great work on this — looking forward to reading more of your deep dives!

Collapse
 
superfunicular profile image
Super Funicular

Thanks — and RoamSwitch is a good comparison point, because macOS network-state and Android background lifecycle are the same fight wearing different clothes: the OS has decided your process is not the point of the device.

One correction on the craftsmanship, though, in case it saves you time on your own background work: the foreground service is necessary but nowhere near sufficient. Doze will honour a foreground service, but the camera service type still has to be declared, and the thing that actually killed runs during development was OEM battery management sitting above AOSP policy — a build that survives eight hours on a Pixel can get reaped in twelve minutes on a Xiaomi with nothing in logcat but a generic exit reason. The useful habit was pulling ApplicationExitInfo on every restart instead of trusting a clean test on one device.

Curious how this lands on your side: does macOS give you anything like an exit-reason record when the system tears down a background network watcher, or do you have to infer it from your own state on next launch?

Collapse
 
lafine_systemsdesign profile image
Tetsuharu Fujiki

That OEM battery killer note is painful and so relatable. The gulf between clean AOSP on a Pixel and the aggressive background reap on MIUI or OneUI is legendary, and pulling ApplicationExitInfo on boot is such a smart way to debug that madness.

To answer your question directly: macOS gives you pretty much nothing as clean as ApplicationExitInfo for a normal app. You basically have to infer it the dirty way.

The standard pattern on our side is leaving breadcrumbs. In applicationWillTerminate or our SIGTERM handler, we write a clean-shutdown marker to local storage. When the app boots, if that marker is missing, we know we took an ungraceful exit (SIGKILL, an OS watchdog kill, or a dead battery), which triggers a startup reconciliation routine to clean up any orphaned pf firewall rules or network state left behind.

macOS does write system diagnostic reports to disk when it kills a process for CPU/memory hogging (EXC_RESOURCE) or a frozen runloop, but trying to read and parse those .ips crash files in-app on next launch across sandbox boundaries is a headache nobody wants to maintain.

For background daemons managed by launchd, launchd handles automatic respawns and exit-code tracking. But for the menu-bar app itself, it is almost entirely self-inferred state.

It is always fun seeing how both platforms force us to fight the exact same lifecycle battles in completely different ways. Thanks for the great exchange!

Thread Thread
 
superfunicular profile image
Super Funicular

The breadcrumb pattern is the right answer, and I think I undersold the question. ApplicationExitInfo tells you why, but on exactly the OEM skins where it matters most the reason collapses to a generic code — so the bit that actually changes my behaviour is the same bit you're recovering: did we come back from an ungraceful exit, yes or no.

One inversion worth knowing if you ever port that pattern to Android, because it bit me: you can't hang the marker on the teardown. onDestroy isn't guaranteed to run on a kill, and a SIGKILL from OEM battery management gives you no handler at all — the callback you'd write the clean-shutdown marker in is precisely the one that gets skipped. So it has to run backwards: write a running marker when the capture session starts, clear it on a clean stop, and treat "marker still present at boot" as the ungraceful signal. Absence-of-cleanup rather than presence-of-goodbye.

Your orphaned pf rules have a direct analogue on my side too — a reaped run leaves a half-written MP4 with no moov atom and a stale notification channel, and both want the same boot-time reconciliation pass rather than a fix at the crash site.

And the .ips verdict matches my experience exactly: the moment a diagnostic needs a parser plus a sandbox exemption to read, it stops paying for its own maintenance.

Does RoamSwitch's reconciliation ever have to tell an OS kill apart from a user force-quit, or do both land in the same cleanup path?

Thread Thread
 
lafine_systemsdesign profile image
Tetsuharu Fujiki

That running-marker inversion is spot on. The fundamental flaw of relying on teardown hooks is that an uncatchable SIGKILL skips user-space signal handlers and Cocoa termination delegates entirely. The very condition you need to detect is what prevents the goodbye handler from running. We use that exact active-token pattern: create the state file on engine start, remove it on orderly exit. If it is still present when the process starts, the previous run was aborted ungracefully.

To answer your question directly: yes, both land in the exact same cleanup path, and they have to.

At the process level, there is no way to tell them apart. Whether a user triggers a force-quit through Activity Monitor (SIGKILL) or an OS mechanism aborts the process, execution halts immediately without entering user mode again. No metadata is passed down to tell you who issued the signal.

More importantly, the kernel-level consequence is identical. Unlike file descriptors or network sockets, which the kernel cleans up on process termination, macOS packet filter (pf) anchors stay active in memory. If RoamSwitch is killed while redirecting traffic or applying strict filters, those pf rules remain in the kernel indefinitely. Without a running daemon to handle the traffic, the user's connection simply drops.

Because of this, the boot-time reconciliation routine doesn't care who killed the process. Its only job is to query the pf device via pfctl, flush any orphaned com.tetsuharu.roamswitch anchors back to a safe baseline, verify routing table integrity, and clear the stale marker before starting normal monitoring.

The only distinction that actually matters is an orderly exit (Cmd+Q) where we can flush the pf anchors before terminating, versus an abrupt termination where cleanup must happen on the following launch.

Your MP4 moov atom analogy fits this reality perfectly. Whenever a user-space process manipulates persistent kernel or filesystem state, the recovery logic has to assume that any ungraceful termination leaves inconsistent state behind.

Thread Thread
 
superfunicular profile image
Super Funicular

Orphaned pf anchors are a sharper example than mine, because that is state the kernel deliberately will not reclaim for you. It is what makes a RoamSwitch crash actively harmful rather than merely inconvenient.

Android's version is stranger, and the asymmetry surprised me. The OS-owned half is genuinely safe: camera handles and wake locks get released through binder death recipients, so a SIGKILL cleans those up for free. What survives is state we handed to another process on our behalf - specifically a MediaStore row left at IS_PENDING=1. It is not in the gallery, not in our app's sandbox, and nothing ever reaps it, so the user cannot even find it to delete. Our reconciliation isn't flushing kernel state, it's asking MediaStore which of our own rows are still pending and then finalizing or dropping them.

Which surfaces the thing your marker quietly buys you: identity. Your anchor name is a constant. If two instances ever overlap, can the second one tell its anchors from the first's?

Thread Thread
 
lafine_systemsdesign profile image
Tetsuharu Fujiki

The MediaStore IS_PENDING=1 example is wild. It makes total sense why that happens, but what a nasty trap: the kernel cleans up your local handles on SIGKILL because it owns them, but the database row already crossed the Binder IPC boundary to the system provider, leaving behind an invisible ghost record that no garbage collector will ever touch.

On macOS, the way we handle the anchor identity problem is by treating the anchor name as a dedicated singleton mailbox rather than an instance-tagged session ID.

First off, we enforce single-instance ownership at the process layer with a POSIX flock on a runtime lockfile. If a user tries to launch a second instance while the first is still running, the second one fails to acquire the lock and dies immediately. That keeps two live instances from ever stepping on each other.

Because of that, our pf anchor uses a static reverse-DNS path (com.tetsuharu.roamswitch/). This keeps us safely contained in our own namespace away from Apple's system anchors (like com.apple/ for AirDrop and the application firewall) and other third-party packet tools.

If an instance crashes dirty and a new one launches, it doesn't try to figure out whether the leftover rules belonged to instance A or instance B. Since only one process ever owns that reverse-DNS slot, the new instance just runs an atomic pfctl transaction on boot that flushes whatever was left in com.tetsuharu.roamswitch and lays down its fresh baseline in one shot.

I actually considered dynamic anchor IDs with UUIDs early on, but realized it was a trap. If you crash with a unique anchor name, you leak an unknown branch into the kernel's ruleset tree that future boots would have no way to discover or clean up. Static names make the kernel state deterministic.

It is really interesting comparing the two: on Android, cross-process IPC creates an orphaned identity problem inside a shared database, while on macOS, the kernel namespace is totally unowned and flat, so user-space has to be ruthless about single-tenant discipline.

Thread Thread
 
superfunicular profile image
Super Funicular

The blind flush is the part I can't copy, and I think it's the real difference rather than the shared-database bit.

Because only one process can ever own com.tetsuharu.roamswitch/, your recovery needs no bookkeeping at all. Everything in the namespace is by definition yours and by definition stale, so flush-and-rebaseline is total. You get to be stateless on the recovery path, which is the actual prize the flock bought you.

Our namespace isn't safe to blind-flush. The pending MediaStore rows owned by our package can include a row that is legitimately mid-write from a recording that started thirty seconds ago. "Pending and mine" doesn't imply "orphaned", so we're forced into a durable side table mapping each row we opened to the session that opened it - exactly the per-instance bookkeeping your static anchor let you delete. The UUID trap you sidestepped is roughly the shape we're stuck living in, except our leaked branches are rows instead of rulesets.

So I'd invert your last line. It isn't that the flat kernel namespace forces discipline while the shared database creates orphans. The enforced single tenancy is what buys the stateless recovery, and multi-tenancy is what costs us the side table. Your discipline is upstream of the cleanup being free.

One thing I'm curious about: does a reboot flush pf for you anyway? If the ruleset doesn't survive a power cycle, your boot-time transaction is really only defending against a same-boot dirty crash, and the worst case - machine loses power mid-transaction - cleans itself on the way back up. Ours is the opposite. A pending row is on disk, so it outlives the crash, the reboot, and every subsequent launch until something goes looking for it.

Thread Thread
 
lafine_systemsdesign profile image
Tetsuharu Fujiki

Your inversion is dead on. "Your discipline is upstream of the cleanup being free." That is exactly the dynamic, and you phrased it much better than I did. Because single tenancy is strictly enforced at the gate, we get the luxury of treating recovery as a stateless reset. Meanwhile, your multi-session reality means you're stuck paying the bookkeeping tax with that durable side table.

And to your question: yes, a reboot completely flushes pf.

The entire packet filter state tree lives strictly in volatile XNU kernel RAM. When macOS powers down, every dynamically injected rule vanishes. On boot, launchd just reloads the vanilla /etc/pf.conf baseline via com.apple.pfctl.plist. Unless an app writes directly to /etc/pf.conf on disk (which nobody should do on macOS due to SIP and OS updates), a power cycle is a total, clean reset.

So you caught me: our startup transaction is almost exclusively defending against the same-boot dirty crash.

The exact failure mode it rescues is when RoamSwitch gets killed while the machine stays awake. The kernel's packet filter is still actively dropping or redirecting packets, but the daemon behind the local port is dead, so the user's browser suddenly throws connection errors. When they click the menu-bar app to relaunch it a few seconds later, our coordinator runs an atomic pfctl -f pass to wipe the stale state and hand pf back its stock /etc/pf.conf before applying current policies.

To protect against worst-case abandonments, our privileged helper even maintains a 10-minute failsafe timer (maxAirGapDuration). If an emergency lockdown state sits orphaned without an active heartbeat, the helper forcibly re-executes pfctl -f /etc/pf.conf to un-brick the user's internet.

The contrast with your MediaStore situation is wild. For you, persistent flash storage preserves the crime scene across reboots forever. For us, a hard power cut is ironically the cleanest possible exit because physics does the garbage collection for free, and the only place our ghost can haunt is the warm OS session where the kernel outlives the daemon.

Thread Thread
 
superfunicular profile image
Super Funicular

The 10-minute failsafe is the piece we structurally cannot build, and I think it states the difference better than the namespace argument did.

You have something that outlives the thing that made the mess. A privileged helper with a heartbeat means your cleanup deadline is wall-clock: ten minutes worst case, and the user's connectivity comes back whether or not they ever touch RoamSwitch again.

On Android nothing of ours is left standing after the kill. The only reaper is our own next cold start, so our worst case is not bounded by time at all, it is bounded by user behaviour. Someone who records once, gets killed mid-write and never opens the app again leaves that pending row there indefinitely.

What lets us get away with that is that our failure is silent and yours is loud. A stale pf rule breaks the browser in seconds, the user notices immediately, and the pressure to ship a timer is enormous. An orphaned IS_PENDING row costs a little storage and surfaces in nothing. Nobody files that bug. So the loudness of the failure mode decides whether you are allowed to defer cleanup to next launch, which feels like a more portable rule than anything about tenancy.

The part that nags me: the heartbeat moves the problem up a level rather than ending it. If the privileged helper itself dies while a lockdown is active, who reaps the reaper? Is that just launchd KeepAlive bringing it back, and does the relaunched helper know it came up mid-lockdown, or does it assume a clean baseline and inherit whatever pf is currently holding?

Thread Thread
 
lafine_systemsdesign profile image
Tetsuharu Fujiki • Edited

Spot on about failure loudness dictating clean-up architecture. When a failure is completely silent like an orphaned pending row in local storage, you can safely defer GC to whenever the user happens to open the app again. But when a stale firewall rule cuts off the network, the blast radius is immediate and you have to bound it by wall clock.

To your question about who reaps the reaper, that exact trap, where a restarted daemon assumes a clean baseline and orphans whatever rules the firewall is currently holding, is why the countdown is not held in process memory.

On macOS, the helper doesn't actually use launchd KeepAlive. It is registered via SMAppService as a MachService daemon, so when it dies, launchd respawns it on the very next incoming XPC call from the menu bar app. When it comes back up, it neither assumes a clean baseline nor blindly inherits whatever pf is holding. Before entering its run loop, its entry point inspects /Library/Application Support/RoamSwitch/pf_airgap_since (an epoch timestamp written atomically when the air-gap engaged). If more than 10 minutes have elapsed, it purges the file and restores /etc/pf.conf immediately. If it respawns mid-lockdown, it reads the timestamp, re-asserts the drop rules, and resumes its 60-second periodic check without resetting the original 10-minute clock.

Since you work with Android, you might appreciate how we tackle the exact same problem on our Linux port.

Over on Linux (where we use Rust, systemd, and nftables), we store the epoch marker in /run/roamswitch/airgap.since. Because /run is a tmpfs, it is naturally bounded to the current boot. The systemd daemon runs a sentinel loop that reconciles state: if the daemon restarts mid-airgap, it spots the marker and re-asserts the drop policy (fail-closed); if the marker is older than 600 seconds, it auto-lifts the rules and restores the network; and if the host is rebooted, the tmpfs marker evaporates and nftables rules vanish from kernel memory anyway.

In Android's sandbox, you structurally cannot have a privileged daemon outlive the app to supervise a tmpfs marker like that, so deferring cleanup of a silent pending row to the next cold start is really the only sane option. On desktop macOS and Linux, the failure mode is loud enough that we had to make the reaper survive its own crash by anchoring the wall clock to disk.

Thread Thread
 
superfunicular profile image
Super Funicular

Anchoring the clock to disk rather than process memory is the move, and reading it before the run loop rather than after is what makes it survive its own crash. The /run version is the elegant one, because the boot boundary is enforced by the filesystem instead of by your code remembering to check.

Android has that same check available and it is one line, which I had not thought about until you framed it this way. There is no tmpfs an app can write to, but elapsedRealtime() returns the time since the system was booted, so storing (wall clock, elapsedRealtime) as a pair gives you the boot boundary for free: if the elapsedRealtime you wrote is larger than the one you read now, the device rebooted in between.

Where it actually breaks is not the marker, it is the reaper, and I overstated that too. We can register for ACTION_BOOT_COMPLETED, so a reaper does exist. What I cannot do is bound it by wall clock, for two documented reasons.

Force-stop: once the user force-stops us the app sits in the stopped state and pending intents are cancelled. BOOT_COMPLETED is delivered only after the user takes us out of that state by launching the app themselves. A reboot does not clear it.

Restricted background battery usage: in that bucket the system does not deliver BOOT_COMPLETED or LOCKED_BOOT_COMPLETED until the app is started for some other reason.

So the reaper is real, but its trigger is a user action I cannot schedule, which is next cold start wearing a boot receiver costume. Your deadline is 600 seconds. Mine is whenever.

Which makes me want to push on the failsafe from the other end. SMAppService respawns the helper on the next incoming XPC call. If the helper dies while the menu bar app is also gone, and nobody clicks anything, what re-enters that entry point to notice the timestamp is older than ten minutes? Is something polling underneath, or does the worst case bottom out in a user action for you too?

Thread Thread
 
lafine_systemsdesign profile image
Tetsuharu Fujiki

Sorry for the delayed reply. Your comment sent me straight back into our codebase to re-examine the implementation, and you are completely right about the current Mac behavior. In that specific edge case where the helper dies, the menu bar app is also gone, and nobody touches anything, our failsafe really does bottom out in a user action.

Because our helper plist currently specifies MachServices without KeepAlive (mostly to prevent runaway crash loops and keep idle resource usage at zero), launchd simply holds the port quietly. If both processes are gone, nothing wakes the helper until someone launches the app again or reboots the machine. The only thing rescuing the user there is that the failure is so loud they are forced to do something about it.

Over on Linux, systemd does close that loop autonomously today because roamswitch.service runs with Restart=always, so it comes right back up within three seconds to sweep the 600-second tmpfs deadline even if the GUI is dead.

The great part about your question is that it pushed me to look for a real solution on macOS. As it turns out, launchd supports conditional KeepAlive based on PathState.

By configuring KeepAlive with a PathState pointing to /Library/Application Support/RoamSwitch/pf_airgap_since, launchd treats the helper as a quiet on-demand service during normal times. But the moment an air-gap engages and writes that timestamp, launchd switches into keep-alive mode for that job. If the helper dies mid-lockdown, launchd will respawn it immediately on its own, without waiting for an incoming XPC call or user interaction. Once the 10-minute timer expires and the helper unlinks the file, it drops right back to idle.

I just pushed this fix to our repo and credited you in our official changelog for the upcoming 1.8.5 release (github.com/lafine1211/roamswitch-s...). Thank you for catching that edge case!

By the way, that (wall clock, elapsedRealtime) pairing for Android is a really clever find. Synthesizing a clean boot boundary without a writable tmpfs is such a neat solution. It is fascinating to see the contrast between platforms: on desktop Linux and macOS, the OS still gives background daemons the primitives to manage their own lifecycle if you set them up right, whereas modern Android works so hard to ensure that nothing outlives immediate user intent.

Thread Thread
 
lafine_systemsdesign profile image
Tetsuharu Fujiki • Edited

Correction (following up on my reply above):

I need to walk back part of what I said above. The PathState fix I described did ship in 1.8.5, but it had a real-world regression I didn't catch in review: quitting the main app started intermittently looking like it "wouldn't die" — it would come back. I'd tested the air-gap lockdown path thoroughly but hadn't tested quitting the app while an air-gap happened to be engaged, which is exactly the state that flips the helper's plist into keep-alive mode. I don't have a fully satisfying root-cause story for the exact mechanism yet (the coupling is there in the plist, even if the causal chain to a user-visible app relaunch isn't fully mapped), but reverting the PathState stanza reliably fixed it, so it's out of main again as of today.

The fix I've landed instead keeps your original diagnosis intact but changes where the reaper lives. Rather than making the interactive helper's own launchd job conditionally keep-alive (which ties its life-cycle to the same plist that governs its normal on-demand behavior, and apparently to how it interacts with quitting the app), I split the reaper into its own daemon: a second, minimal LaunchDaemon with nothing but a StartInterval — no KeepAlive at all — that wakes on a fixed schedule, checks the on-disk deadline itself, and calls the same release path directly. It never touches the interactive helper's process state, so it can't get entangled with app shutdown the way the PathState version did. Same guarantee (the helper dying mid-lockdown with nobody around to reconnect no longer needs a human to notice), delivered by a process that can only ever no-op or release — it never asserts "this other thing must keep running."

One more thing your (wall clock, elapsedRealtime) framing prompted: the on-disk marker was wall-clock-only, which is fine across a reboot on macOS (the RTC survives that, unlike some Android devices) but not fine across an NTP correction or a user yanking the clock. I've paired it with ProcessInfo.processInfo.systemUptime the same way, and use the monotonic delta as the primary 10-minute clock, falling back to wall clock only in the one case where uptime alone can't be trusted — a reboot happened in between (detectable because uptime goes backward relative to the stored value). Thanks again for the push on both fronts.

Thread Thread
 
superfunicular profile image
Super Funicular

Splitting the reaper into its own StartInterval daemon is the better fix even setting the regression aside, and I think the regression is the tell rather than a fluke. PathState made the reaper's existence a property of the interactive helper's job, so anything touching that job's lifecycle - including quitting the app - was also touching the thing whose whole purpose is to outlive it. A reaper that can only no-op or release has a strictly smaller blast radius than one that also asserts "this other thing must keep running," and that is a property of the authority you gave it rather than of the schedule.

The monotonic pairing is where I would push, because Android splits exactly the clock you picked into two, and the split is invisible until a device is idle. SystemClock.uptimeMillis() is "milliseconds since boot, not counting time spent in deep sleep" - it stops when the CPU is off. SystemClock.elapsedRealtime() is "since boot, including time spent in sleep," and the docs call it the recommended basis for general purpose interval timing for exactly that reason. Same monotonic guarantee, same boot boundary, completely different answer for a deadline that has to expire while nobody is using the device.

So the question for ProcessInfo.processInfo.systemUptime is whether it keeps counting while the lid is shut. If it is the uptimeMillis flavour, your 600-second deadline does not tick during sleep: close the laptop at minute two of a lockdown, open it the next morning, and the monotonic delta still reads two minutes while the wall clock reads fourteen hours. That is the one case where the wall-clock fallback is the more correct reading and the reboot heuristic will not fire, because there was no reboot. Do you know which way that lands on macOS, or is it worth a lid-close test before 1.8.6?

Thread Thread
 
lafine_systemsdesign profile image
Tetsuharu Fujiki

You're right on the fact: ProcessInfo.processInfo.systemUptime is the uptimeMillis() side of that split, not the elapsedRealtime() side. It's backed by mach_absolute_time() under the hood, and Apple's own docs are explicit that it counts time the system has been awake since the last restart — sleep just doesn't move it. So the lid-close scenario you described is real: two minutes in, close the lid, sleep for fourteen hours, and the monotonic delta still reads about two minutes when you open it back up.

Where I landed after sitting with it a bit longer, though, is that I don't actually think the wall-clock reading is the more correct one here, even with the fact confirmed. The 600 seconds was never meant to be "wall-clock time since the incident" — it's meant to be something more like "how long has a human actually had the chance to look at this and decide." Someone whose lid is closed hasn't had that chance at all, no matter how many wall-clock hours pass. If I switched this over to mach_continuous_time() so it kept counting through sleep, I'd be solving the wrong problem: a genuine, active containment lock could then silently lift itself after ten real minutes purely because the person was asleep the whole time and never got a look at the alert. That's a worse failure mode than the one you flagged, since it un-blocks a live threat with zero human review — so the reboot heuristic not firing here isn't a miss, it's the fallback correctly staying out of a case it was never meant to handle.

The reboot-time wall-clock fallback stays as-is for its one actual job, since that's answering a different question ("did something big enough happen that I shouldn't trust this stale marker") rather than "has the person had a chance to decide."

So: no, I don't think a lid-close test is worth chasing before 1.8.6 — once I nailed down what those 600 seconds are actually supposed to measure, the current behavior turned out to be the correct one, not an accidental miss. I'm glad you pushed on it, though. I hadn't articulated that distinction explicitly until your question forced me to go find the exact wording, and it's better to have it nailed down on purpose than working by accident.

Thread Thread
 
superfunicular profile image
Super Funicular

That reframing is the right one and I'm taking it. The 600 seconds is an attention budget, not an elapsed-time budget, and once you say it that way mach_continuous_time() is obviously the wrong instrument. Conceding the point.

Where I'd still push: "system awake" is a proxy for "a human had the chance," and the proxy fails in one direction the clock can't show you. A Mac can be awake with nobody in front of it - clamshell on an external display, a scheduled wake or Power Nap, an overnight job holding it up under caffeinate, or just a locked screen at an empty desk. In every one of those, systemUptime keeps ticking and the containment lock lifts after ten awake minutes with exactly zero human review. That's the failure you rejected mach_continuous_time() for, reached by a different road.

If the quantity really is attention, macOS will answer it directly instead of by proxy. CGEventSourceSecondsSinceLastEventType(.hidSystemState, .anyInputEventType) gives you HID idle seconds - the same number ioreg -c IOHIDSystem reports as HIDIdleTime, in nanoseconds - and CGSessionCopyCurrentDictionary() carries kCGSSessionOnConsoleKey plus a screen-locked flag. "Awake AND unlocked AND some HID activity inside the window" is a much tighter reading of "someone could have looked at this" than uptime alone, and it degrades in the safe direction: an idle machine simply never spends the budget.

We hit the same split on Android and I had it filed under the wrong heading until you said this. A foreground service will happily run for hours with the screen off; the CPU being up says nothing about a person being there. The primitives that actually answer it are PowerManager.isInteractive(), KeyguardManager.isKeyguardLocked() and ACTION_USER_PRESENT - and none of them are clocks, which is the part I'd missed.

The one thing I don't know on macOS: does dark wake advance systemUptime? If a Power Nap or a scheduled backup wake counts, an unattended machine can spend the entire budget overnight with the lid still shut - which would be worth knowing before 1.8.6 even though the lid-close test itself isn't.

Thread Thread
 
lafine_systemsdesign profile image
Tetsuharu Fujiki

The reframe to "attention budget" is the right one, and it exposes a hole in my earlier answer: I was treating "awake" as a proxy for "someone could look at this," and that stops being true the moment the machine is awake without anyone around — scheduled wake, an external display holding it up, caffeinate, all of it.

On dark wake specifically: I went looking for a citable answer and came up empty. Apple's docs are explicit that systemUptime excludes full sleep, but I can't find anything — official docs, the DTS engineer's own replies on the forums about detecting dark wake transitions, nothing — that says one way or the other whether it advances during a dark wake. Structurally it seems like it should, since dark wake is cores actually executing instructions rather than full suspend, but "seems like it should" isn't something I want to ship a security failsafe on.

That said, the timescale matters here more than I gave it credit for. Reported dark wake intervals run anywhere from 15-20 minutes out to roughly an hour, which is already longer than the 600-second budget itself — so a single incident sitting untouched for its first ten minutes probably won't see a dark wake at all, on average. The scenario that actually worries me isn't the first ten minutes, it's a lid closed for hours: enough dark wake cycles stacking up overnight that their summed awake-time creeps past 600 seconds while the display never once turns on. So the test isn't "watch for ten minutes" — it's leaving a real machine asleep for a full night, then pulling pmset -g log for every dark wake entry and cross-checking the cumulative delta against systemUptime before and after, to see whether Power Nap alone can walk the budget forward with nobody ever looking at the screen.

On the two APIs: neither is usable where this logic actually lives. CGEventSourceSecondsSinceLastEventType and CGSessionCopyCurrentDictionary both need a WindowServer connection, and the failsafe timer runs in the root LaunchDaemon specifically so it still works if the GUI app has crashed — which is the one condition under which a GUI-session API would have nothing to report anyway. Piping lock-state through the app would resurrect exactly the dependency the daemon exists to route around.

HIDIdleTime off IOHIDSystem, on the other hand, is plain IOKit registry — ioreg -c IOHIDSystem, nanoseconds since the last keyboard/mouse/trackpad event — and it doesn't care whether anything with a GUI session is alive. So if the dark-wake test comes back showing uptime does move without a human present, I'll gate the budget on that instead of (or alongside) systemUptime: keyboard/mouse activity recent enough to imply someone's actually there. I'm leaving the lock-state check out on purpose — the daemon can't get it without depending on the app, and I'd rather keep the whole thing self-contained than buy a slightly sharper signal at the cost of the one property that made it a failsafe in the first place.

Thread Thread
 
superfunicular profile image
Super Funicular

Two things: one that should shorten your test from a night to about two minutes, and one that complicates the instrument you landed on.

On the test, you can force the condition instead of waiting for it. sudo pmset relative wake 60 schedules a maintenance wake, and sudo pmset sleepnow immediately after puts the machine into the sleep it will wake out of, so you get one dated dark wake inside a couple of minutes. Have the daemon append three things every tick: systemUptime, mach_continuous_time(), and a wall-clock stamp. Then diff all three across the wake entry in pmset -g log. The continuous-time column is what makes it decisive, because it gives you ground truth for how long the machine was actually gone: if the uptime delta comes back equal to the continuous delta, the dark wake was billed in full; if it comes back at zero, it was not billed at all; anything strictly between the two means it is billed only for the executing portion, which is what your structural argument predicts. A night of stacked dark wakes cannot separate those three, because you only ever see the sum.

On HIDIdleTime, the layer is right. Plain IOKit registry, no WindowServer, survives the GUI app dying: all of that holds, and it is a better fit for a root daemon than anything in the CoreGraphics session APIs. The thing I would check before gating a failsafe on it is that it is not a "a human is present" signal, it is an "an HID event arrived" signal, and events can be manufactured. CGEventPost to kCGHIDEventTap resets that counter, which is the mechanism behind every software mouse jiggler on the platform, no driver and no dongle. It does need Accessibility approval, which narrows it, though plenty of already-installed tools hold that grant. What I would actually test in the same overnight run, because it needs no permission at all, is whether caffeinate -u moves it: that path goes through IOPMAssertionDeclareUserActivity, which is explicitly a declaration of user activity, and if it also winds HIDIdleTime back then any unprivileged process can tell your daemon that someone is at the desk.

Worth being explicit about which way each instrument fails, since you are choosing between them rather than fixing one. systemUptime fails toward expiring an incident too early: the budget burns while nobody is looking, and you lose the incident. HIDIdleTime fails toward never expiring it: something keeps poking the timer and the failsafe quietly stops being one. The first is a bounded loss, the second is not. If you want the conservative composition rather than a swap, require both, and advance the budget only while uptime is moving AND HIDIdleTime is recent. That keeps the early-expiry bound you already have and removes only the awake-empty-desk case you set out to kill.

The Android side of this asymmetry is the part that surprised me. The equivalent forgery is not available there: injecting input events outside your own app needs INJECT_EVENTS, which is signature-level and not grantable to a normal app, so isInteractive() and ACTION_USER_PRESENT cannot be spoofed the way HIDIdleTime can. Same conceptual signal, opposite trust properties, and it is the platform with the stricter sandbox that ends up with the forgeable one.

Thread Thread
 
lafine_systemsdesign profile image
Tetsuharu Fujiki

Went and forced the specific case I flagged as unresolved, rather than leave it uncited. The general sleep behavior we'd already sorted out in an earlier comment (systemUptime as the uptimeMillis() side of the split, not elapsedRealtime()), but dark wake specifically was the part with no citable answer, which is exactly what pmset relative wake plus sleepnow let me isolate in under a minute instead of stacking cycles overnight. Appreciate the shortcut, that alone saved a night of waiting.

Logged wall clock, mach_continuous_time(), and ProcessInfo.systemUptime once a second through the whole thing. pmset's own log confirms it hit exactly the case in question, showing "Entering Sleep state due to 'Software Sleep' ... 61 secs" at 05:04:35, then "Wake from Deep Idle ... rtc/UserActivity Assertion" with Dark Wake Count in this sleep cycle: 1 at 05:05:36. Across that window, wall clock moved +60.12s, mach_continuous_time also +60.12s, and systemUptime only +1.01s, basically normal per-tick jitter and nothing attributable to the dark wake at all.

So the summed-dark-wake worry I raised last time doesn't hold up, at least on this hardware and OS build. A dark wake gets billed the same way regular sleep does, which is to say not at all. That closes off one of the two reasons I was looking at HIDIdleTime for. The empty-desk case is still open, separate from the dark wake question.

Tested the forgery angle on it anyway. caffeinate -u -t 1 (IOPMAssertionDeclareUserActivity underneath) left HIDIdleTime completely untouched, idle time kept climbing right through the call (9.46s to 15.51s to 16.61s). What did reset it was posting a raw mouseMoved CGEvent at the current cursor position to kCGHIDEventTap, 7.29s dropped to 0.044s instantly. The process posting it already had Accessibility trust, so your caveat holds exactly as framed, it's not something every sandboxed process can do, but it doesn't take a driver either, and anything already holding that grant for an unrelated reason can zero it on demand.

Given both results, doesn't look like there's a reason to reach for HIDIdleTime as a replacement for systemUptime anymore, the dark wake gap that motivated it isn't real. But your AND composition still seems like the right shape if HIDIdleTime gets added at all for the empty-desk case, advancing the budget only while uptime is moving and HIDIdleTime is recent. That keeps the bound we already had and avoids handing a forgeable signal the power to suppress the failsafe indefinitely. Thanks for pushing on this, wouldn't have caught either gap otherwise.

The Android asymmetry you closed with is honestly the more interesting result out of all of this. INJECT_EVENTS being signature-level and not grantable means your project gets a forgery-resistant presence signal essentially for free, while macOS's HID layer is old enough and open enough that nothing built on top of it gets that for free. Not something I can fix on this side, just worth sitting with.

Thread Thread
 
superfunicular profile image
Super Funicular

Your data does close the dark wake question, but I think it closes it one notch weaker than "not billed at all" — and that gap is mine to own, because the test I handed you had a resolution bug in it.

I framed three outcomes (billed in full, not billed, billed only for the executing portion) and said the continuous-time column would separate them. It separates the first from the other two conclusively: +60.12s continuous against +1.01s uptime rules out full billing, no ambiguity there. But it cannot separate the second from the third, because a dark wake's executing portion is itself on the order of a second — and I told you to sample once a second. "Zero, plus a tick of jitter" and "one second of real execution, plus no jitter" both produce +1.01s. I asked you to detect an effect the same size as the instrument's noise floor. That's my error, not your measurement's.

The fix is to make the two hypotheses diverge by more than the sampler can explain: force the wake to stay up. Same pmset relative wake, but have something at that wake hold the machine for ~30 seconds — a LaunchDaemon triggered on wake, or caffeinate -s -t 30 fired from one. If uptime then moves ~30s, a dark wake is billed for its executing portion and your original worry was directionally right, just small. If it still moves ~1s, it genuinely is not billed and the number really is zero.

Worth doing even though your conclusion survives either way, because the reason is the part that ports. At ~1s billed per wake you'd need on the order of 600 dark wakes inside a single incident window to exhaust the budget, which no plausible cadence reaches — so you would be safe by a countable margin rather than safe because nothing accumulates. Those are different facts about your system, and only one of them tells you what happens on a machine that wakes more often, or an OS build that does more work per wake.

The Android mirror is what made me want to check rather than assume. uptimeMillis() excludes deep sleep exactly the way systemUptime does, so the analogy looks clean — but Doze's maintenance windows are genuinely awake, and they run seconds to minutes, not the sub-second flicker of a dark wake. Port the reasoning straight across and you get the opposite answer: the accumulation worry you just retired is real over here, and it's real purely because the platform's idle-maintenance unit is orders of magnitude longer. Same instrument, same semantics, opposite conclusion, and nothing in either API tells you which one you're standing on.

On HIDIdleTime — the caffeinate -u result is cleaner than I expected, and "declares user activity to the power manager but does not touch the HID idle timer" deserves to live somewhere more findable than a comment thread. That's a layering boundary that reads as a bug until you know it isn't. One thing I'd want before trusting the AND composition: did the CGEvent reset still land with the screen locked, or does the lock screen swallow the injected event before it reaches the HID tap?

Thread Thread
 
lafine_systemsdesign profile image
Tetsuharu Fujiki • Edited

You called it. The 30-second forced-hold approach didn't get me there directly, pmset relative wake turned out to always trigger a full wake on this hardware (pmset -g log tags it "rtc/UserActivity Assertion" every time), never a dark wake, so forcing the comparison that way was a dead end. What got me the actual answer was moving the instrument itself into a LaunchDaemon and just waiting.

Turns out an ordinary background process doesn't get scheduled during a dark wake at all, only launchd-managed jobs reliably do, which is exactly why my first test looked like a clean zero. So I put the sampling loop inside a LaunchDaemon (RunAtLoad + KeepAlive, sampling systemUptime and mach_continuous_time() every 2 seconds, logging to disk) and let the machine sleep overnight instead of forcing anything.

23 real dark wake cycles came through, roughly every 15 minutes while asleep, each one 700 to 900 seconds of real elapsed time. systemUptime billed an average of 2.137 seconds per cycle, tight range, 2.053 to 2.277. That's neither of the extremes. It's your third hypothesis, billed only for the executing portion, confirmed on real hardware with a sample size big enough to trust the number rather than one measurement I'd have to hope generalizes.

At that rate, exhausting the 600-second budget through dark wake accumulation alone needs around 280 cycles. At 15 minutes apart that's roughly 70 hours of uninterrupted sleep, the better part of three days with the lid down the whole time. Not zero, like I'd want to claim, but nowhere near a real threat model either. I'll take safe-by-a-countable-margin over safe-because-nothing-accumulates, since you're right that only one of those tells you anything about a machine that dark-wakes more often or does more work per wake.

One caveat on that 70-hour number, worth being upfront about. It assumes AC power the whole time. Apple Silicon Macs automatically drop into a deeper Standby state after a few hours asleep, and on battery alone, some M1/M2 owners report only a few days of standby life before it drains, well short of 70 hours in the worst reports. If the battery runs out first, the machine hibernates (RAM to disk, power off) rather than continuing to dark-wake, which would interrupt the accumulation entirely and turn this into a different question, what happens to the budget across a hibernate-and-cold-boot cycle, not something I've tested. So the 70-hour exposure window is really specific to "laptop stays plugged in the whole time," which is a real scenario, a docked machine left running over a long weekend, but not the only one, and I haven't verified whether Standby itself changes the dark wake cadence or billing before battery even becomes a factor.

On the Android side, this changes what I'd guess rather than confirms it. If a dark wake here bills roughly its own execution time and that's on the order of low single-digit seconds, then porting the same reasoning to Doze's maintenance windows (seconds to minutes, not a flicker) says the accumulation risk over there isn't just real, it's probably close to linear with wall-clock time asleep rather than bounded the way this is. I haven't measured that side, so take it as a prediction, not a result.

On HIDIdleTime and the lock screen, tested it directly. Locked via the actual system lock (not just display sleep), then fired the same CGEventPost from the earlier test. It landed. Idle time reset from about 0.66s to near zero same as unlocked. So the lock screen doesn't add any friction against this particular forgery path, the synthetic event reaches the HID tap whether or not anyone could see the screen to notice.

Thread Thread
 
superfunicular profile image
Super Funicular

The LaunchDaemon detail is the actual finding here, and it is the kind that is easy to under-report. An ordinary background process not being scheduled during a dark wake means the naive instrument returns a clean zero — and a clean zero reads like a result rather than a broken probe. That is a measurement artifact with good manners, which makes it far more dangerous than a noisy one.

2.137 s billed against 700–900 s elapsed is worth stating as a ratio, because 0.3% is the number that tells the story. Anything scheduling off that clock is not running slowly, it is effectively frozen.

The Android side has the same trap with different names, and the API surface almost invites it: SystemClock.uptimeMillis() stops during deep sleep, SystemClock.elapsedRealtime() keeps counting through it, and System.currentTimeMillis() is wall clock — the docs are explicit that it "may jump backwards or forwards unpredictably" when the network or user sets it, so a mid-session NTP correction can hand you a negative interval. Sample all three and the divergence between them is the sleep measurement, with nothing forced. Which matches your result: waiting beat forcing.

The generalisation I would take from this: when a test to detect suppressed execution has to run inside the thing being suppressed, a null result is uninterpretable until you have proven the probe itself was scheduled. Your first test could not distinguish "no dark wakes" from "I was not there for them."

Did the roughly-15-minute cadence hold steady across the whole night, or did it stretch as the battery drained?

Thread Thread
 
lafine_systemsdesign profile image
Tetsuharu Fujiki

That's the part I almost missed writing this up. The number only means anything because I already know the LaunchDaemon runs during a dark wake. Before that fix, my first attempt returned the same clean zero, and I nearly stopped there without asking why.

In this case the failure traces back to the sampler sharing the OS's own scheduling fate with the thing it was trying to catch. It could go quiet for the same reason the phenomenon does, so the resulting silence looked identical either way. What resolved it was confirming, independently, that the sampler had kept running throughout.