System Events
8.26 SystemEventsService — Observe OS state changes
Runs in: both worker and view. Subscriptions must register from the worker so events fire even while the view is Dormant.
Permission required: systemEvents:read for every subscription.
Subscribe to OS-level system events — sleep, wake, lid open/close, battery
level changes, and AC-vs-battery power-source transitions. Paired
conceptually with PowerService as its opposite
direction: PowerService instructs the OS (prevent sleep);
SystemEventsService observes it (the OS just slept / woke / changed
power source).
type SystemEvent =
| { type: 'sleep' }
| { type: 'wake' }
| { type: 'lid-open' }
| { type: 'lid-close' }
| { type: 'battery-level-changed'; percent: number }
| { type: 'power-source-changed'; onBattery: boolean };
type Disposer = () => void;
interface ISystemEventsService {
onSystemSleep(cb: () => void): Disposer;
onSystemWake(cb: () => void): Disposer;
onLidOpen(cb: () => void): Disposer;
onLidClose(cb: () => void): Disposer;
onBatteryLevelChange(cb: (percent: number) => void): Disposer;
onPowerSourceChange(cb: (onBattery: boolean) => void): Disposer;
}
Manifest declaration:
{ "permissions": ["systemEvents:read"] }
Usage — pause background sync when running on battery:
const systemEvents = context.getService<ISystemEventsService>('systemEvents');
const dispose = systemEvents.onPowerSourceChange((onBattery) => {
if (onBattery) pauseBackgroundSync();
else resumeBackgroundSync();
});
// Later (e.g. on deactivate):
dispose();
Usage — flush state before sleep, rehydrate after wake:
const d1 = systemEvents.onSystemSleep(() => flushToDisk());
const d2 = systemEvents.onSystemWake(() => reconnectWebsocket());
Usage — react to low battery:
systemEvents.onBatteryLevelChange((percent) => {
if (percent <= 10) showLowBatteryWarning();
});
Subscription semantics
Each on* returns a Disposer. Invoke it to unsubscribe. The proxy
ref-counts listeners per event kind — calling onSystemWake(cb1) and
onSystemWake(cb2) issues only one systemEvents:subscribe RPC to the
host. When the last listener for that kind is disposed, one
systemEvents:unsubscribe RPC fires. This means you can sprinkle
subscriptions across a feature's lifecycle without worrying about stacking
up host-side subscribe calls.
Per-kind subscriptions are independent: onSystemWake and onSystemSleep
each get their own host subscription.
Platform coverage
| Event | macOS | Linux | Windows |
|---|---|---|---|
sleep / wake |
IORegisterForSystemPower on a CFRunLoop thread |
logind org.freedesktop.login1.Manager.PrepareForSleep(bool) signal |
WM_POWERBROADCAST — PBT_APMSUSPEND / PBT_APMRESUME* |
lid-open / lid-close |
Poll AppleClamshellState from IORegistry every 2s |
UPower LidIsClosed property via DBus PropertiesChanged |
GUID_LIDSWITCH_STATE_CHANGE via RegisterPowerSettingNotification |
battery-level-changed |
Poll IOPSCopyPowerSourcesInfo every 30s, dispatch on change |
UPower Percentage property on the Battery device via PropertiesChanged (deduped at integer granularity) |
GUID_BATTERY_PERCENTAGE_REMAINING via RegisterPowerSettingNotification (deduped at integer granularity) |
power-source-changed |
Poll IOPSCopyPowerSourcesInfo every 30s, dispatch on change |
UPower OnBattery property via PropertiesChanged |
GUID_ACDC_POWER_SOURCE via RegisterPowerSettingNotification |
All three platforms dispatch into the same SystemEventsHub from a dedicated
watcher thread so the Tauri runtime stays free of blocking system calls.
Per-source degradation
On Linux each DBus source (logind, UPower root, UPower display battery)
runs on its own blocking thread with its own zbus::blocking::Connection.
If any single source fails to start — the machine has no UPower (headless
container, minimal distro), no battery device (desktop PC), or the lid
switch isn't exposed — the watcher logs a single warning for that source
and keeps the others running. Sleep/wake via logind still fires even when
UPower is absent; lid events still fire even when no battery device is
present.
On Windows the four RegisterPowerSettingNotification registrations are
independent: any single registration failure logs a warning and leaves the
other sources wired up. WM_POWERBROADCAST sleep/wake events continue to
fire regardless — they are delivered by the OS to every message-pump
window and don't require the power-setting registrations.
Despite the per-platform source coverage, treat the service as best-effort
at the call site. A machine without a battery will never emit
battery-level-changed; a desktop without a lid switch will never emit
lid-*. Design each on* subscription so the feature degrades gracefully
when no events arrive.
Edge cases
- Desktops without a battery (most iMacs, Mac Studios, desktop PCs):
none of the three platforms will ever fire
battery-level-changedorpower-source-changed. On macOSIOPSCopyPowerSourcesListreturns an empty list; on Linux UPower exposes noType == 2device; on WindowsRegisterPowerSettingNotificationregisters successfully but the OS never deliversGUID_BATTERY_PERCENTAGE_REMAINING/GUID_ACDC_POWER_SOURCE. Treat the absence of events as "power state unknown." - Debouncing battery ticks: All three platforms dedupe at integer percent granularity. macOS polls every 30s and only dispatches on change; Linux rounds fractional UPower updates to an integer before comparing; Windows stores the last-seen percent per window and suppresses repeats. Expect a burst only during discharge/recharge; a fully charged machine can go hours without a single event.
lid-open/lid-closeon desktops never fires. The clamshell key is absent on macOS IORegistry; UPower has noLidIsClosedon desktops; Windows'GUID_LIDSWITCH_STATE_CHANGEsimply never fires.sleepcallback is not a lifecycle hook. On macOS you get only a few seconds afterkIOMessageSystemWillSleepbefore the system acknowledges and powers down. logind'sPrepareForSleep(true)and Windows'PBT_APMSUSPENDarrive with similarly tight deadlines. Keep the callback fast — flush critical state, don't start new work.- Permission scope.
systemEvents:readis a single permission that unlocks all six event kinds. There's no per-kind granularity.
Lifecycle
| Event | What happens to your subscriptions |
|---|---|
| Extension uninstall | All subscriptions for this extension are removed automatically. |
| Extension disable | All subscriptions for this extension are removed via the uninstall cleanup path. |
| Iframe reload | In-flight subscriptions are dropped; the SDK proxy re-subscribes on the next on* call after reload. |
| Launcher process exit | OS reclaims all watcher resources. |
Wire contract
- Subscribe RPC:
asyar:api:systemEvents:subscribewith payload{ eventTypes: string[] }→ returns an opaque UUID subscription id. - Unsubscribe RPC:
asyar:api:systemEvents:unsubscribewith payload{ subscriptionId: string }. - Push messages:
asyar:event:system-event:pushwith payload matching theSystemEventunion (typediscriminator in kebab-case, camelCase fields). Dispatched from Rust'sSystemEventsHubviaapp_handle.emit("asyar:system-event", {extensionId, event})and forwarded to the target iframe by the host'ssystemEventsBridge.
Pro Tip: To react to "connected" / "disconnected" network state, combine
onPowerSourceChange(battery might mean offline soon) with your own connectivity probe —SystemEventsServicedoes not emit network events.