Today Platform Web — Dev Docs
Architecture

macOS screen recording helper

Why GhostOS runs in a separately identified Today Helper app installed outside the bundle, how screen recording and accessibility grants are attributed, and what is measured versus assumed.

Screen Recording and Accessibility are granted to Today Helper.app, not to Today itself. The helper is a real app bundle with its own bundle identifier (ai.today.desktop.mac-helper), launched through LaunchServices, and installed outside the Today app bundle.

The CN build is signed by a different team and can be installed side by side with the global build, so its helper gets its own identity: ai.today.desktop.mac-helper.cn, installed as Today Helper CN.app. Sharing one bundle identifier would make both builds compete for a single TCC record, and System Settings would show one indistinguishable "Today Helper" row. The packaging hook (after-pack.mjs) writes the region identity into the helper's Info.plist before signing. At runtime the host reads the identity from that plist and derives the install directory and the installed copy's file name from it. System Settings shows that file name.

Why a separate process

macOS binds Screen Recording and Input Monitoring to the process that uses them: WindowServer caches the capture verdict when a process first connects, and an event tap is validated when it is created. Changing either permission therefore makes macOS offer to quit and reopen the app that holds it — which, when that app is Today, tears down the Electron main window.

GhostOS (screenshots, AX inspection, event injection) used to run inside today-mac-platform-host. That host is a bare executable spawned by Electron, so its TCC identity is the enclosing Today.app. Moving GhostOS into a process with its own identity makes the prompt target that process instead.

The helper is also disposable: it starts on first use, is reused across calls, and exits after an idle timeout. Permission probes use a separate one-shot process. In practice no process holds the permission when the user flips the switch, so macOS has nothing to prompt about at all.

Why it must live outside the app bundle

Measured on macOS 26, with one binary, one bundle identifier and one signature:

LocationScreen RecordingAccessibility
inside the app bundleunauthorizedauthorized
anywhere outside itauthorizedauthorized

When the helper runs from inside an app bundle, macOS redirects Screen Recording attribution to the enclosing app. The helper's own row in System Settings is then ignored — it reads the outer app's grant instead, even while that row shows as enabled. Accessibility is unaffected and always uses the helper's own identity.

So the copy shipped in Contents/Resources/tools/macos/Today Helper.app is only an install source. On startup the platform host copies it to ~/Library/Application Support/<enclosing app bundle id>/Today Helper.app and launches it from there, refreshing the copy whenever the bundled executable changes. Scoping the directory by the host bundle id keeps canary and release installs from overwriting each other.

How status is read

CGPreflightScreenCaptureAccess() in the platform host reports the outer app's grant, which is unrelated to the process that actually captures. Status for these two grants therefore comes from a freshly launched helper.

Reading is never on the query path. Permission queries return a cached snapshot; only a cold start, with no value at all, waits for a probe. Everything else is refreshed in the background. Two reasons:

  • Every probe launches a helper process. Probing per query, with the host's 15s steady poll and the guide overlay's 0.15s tracking timer, measured 65 launches in 40 minutes. A helper was then almost always alive, so toggling the switch in System Settings made macOS offer to quit and reopen the helper — a prompt the native app never shows, because there its helper has already exited.
  • The permissions module is an actor. A helper that cannot start would block every permission RPC for the accept timeout.

Refresh is event-driven rather than polled, mirroring the native app: leaving System Settings, the com.apple.accessibility.api notification, returning to the foreground while System Settings runs, and — while the guide is up — a write to the TCC database. A 60s staleness bound only covers the case where none of those fire. When a probe shows the snapshot changed, the long-lived helper session is invalidated: it connected under the old verdict and must be replaced.

A probe that fails leaves the previous value in place. Reporting "unreadable" as "unauthorized" would flip connectors to disconnected on a transient failure.

How the user grants it

Only the drag-to-allow guide. SystemGrant.strategy marks both grants settingsOnly, and PermissionActionRunner.request deliberately does nothing for them: an in-process request would register and prompt for the outer app, so the user would grant the wrong identity and the status — read from the helper — would never turn green. The guide targets the installed helper and labels it with the bundle file name, which is what System Settings shows in its list.

It never falls back to the outer app for these two panels. Telling the user to drag Today would have them grant the wrong identity, and the status — read from the helper — would never turn green. If the helper is not installed yet, the guide is skipped and the plain settings page opens instead.

TCC change detection

PermissionSettingsAssistant watches TCC database metadata to notice that the user just changed something. Both databases must be watched: Screen Recording and Accessibility grants land in the system-level /Library/Application Support/com.apple.TCC/, and the user-level database is never touched by them. Watching only the user-level copy, as this code did originally, means the check never fires.

Neither directory can be listed and neither file can be opened without Full Disk Access, but stat on a known path is permitted, so only mtime and size are read. A write there is treated as a signal to re-probe, never as proof of authorization: the database is shared system-wide, so any app's permission change writes to it, and adding an app to a list is not the same as enabling its switch. Treating it as proof dismissed the guide while the switch was still off.

Migration

Users who previously granted Screen Recording or Accessibility to Today must grant them to Today Helper instead. The connector cards report the helper's real state and route to the drag guide; there is no separate upgrade prompt.

Not covered

Input Monitoring is not modelled anywhere in this client. screen.* host methods remain deliberately unwired, so the Screen Context AX reader still runs in the platform host under the outer app's Accessibility grant.

On this page