diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4c5ceb4..2ed575c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -290,6 +290,8 @@ jobs: run: node test/live-resize.js - name: The activation policy before launch — APPKIT_ACTIVATION_POLICY and runMain({ activationPolicy }) run: node test/activation-policy.js + - name: The session's idle time — lastInputMs, before initApp and from a worker + run: node test/last-input.js - name: A worker that ends with answers in flight, from its own thread and others — dropped, not a fatal error or an abort run: node test/ending-worker.js diff --git a/README.md b/README.md index f122136..c62dfca 100644 --- a/README.md +++ b/README.md @@ -125,7 +125,7 @@ waits on the UI thread (`test/threaded-verbs.js` covers each row): | from a worker | verbs | | --- | --- | -| unchanged, on the calling thread | surfaces and video surfaces, every `ctx*` (`ctxDrawSymbol` included), layouts and fonts, `symbolSize`, `pasteboardTypeForMIME`, `pasteboardTypeInfo`, `contentTypeFor`, `colorSpace` | +| unchanged, on the calling thread | surfaces and video surfaces, every `ctx*` (`ctxDrawSymbol` included), layouts and fonts, `symbolSize`, `pasteboardTypeForMIME`, `pasteboardTypeInfo`, `contentTypeFor`, `colorSpace`, `lastInputMs` | | a command, answering nothing | `initApp`, `setActivationPolicy`, `setAppName`, `activateApp`, `showWindow`, `hideWindow`, `setWindowFrame`, `setWindowTitle`, `setWindowMinMax`, `setWindowIgnoresMouseEvents`, `invalidateWindowShadow`, `destroyWindow2`, `setCursor`, `setMainMenu`, `setDockMenu`, `setDockBadge`, `cancelUserAttention`, `setStatusItem`, `setStatusItemMenu`, `removeStatusItem`, `registerDropTypes`, `setDropResponse`, `pasteboardWriteText`, `pasteboardWrite`, `pasteboardClear`, `cancelPanel`, `cancelPopUpMenu`; the test posts `postMouseEvent`, `postKeyEvent`, `postAppleEvent`, `postAccessibilityDisplayChange` | | a handle at the call | `createWindow2`, followed by `window-created { handle, windowNumber }`; every event about the window, input included, carries `handle`, so `ev.handle === win` | | | `windowRootLayer`, allocated with the window | @@ -1599,6 +1599,23 @@ Mechanism only: what to do with `reduceMotion` is the renderer's (react-x11: loo so the observer path can be exercised without touching the user's settings — test-only, like `postAppleEvent`; `test/accessibility-display.js` is the check. +## Idle time (an empty desk) + +How long the user has been away from the keyboard and pointer, across the whole session and +every app — the clock a screen saver keys off (react-x11's `useIdle`, +[react-x11#951](https://github.com/sidorares/react-x11/pull/951)): + +```js +native.lastInputMs(); // -> 41250.3: ms since the last keyboard, mouse, trackpad or tablet event +``` + +It is `CGEventSourceSecondsSinceLastEventType(kCGEventSourceStateCombinedSessionState, +kCGAnyInputEventType)`, in milliseconds. There is no event: poll it. It asks nothing of AppKit and +needs no permission, so it answers synchronously on whichever thread calls it — before +`initApp`, in pump mode, and from a worker with the main thread parked in `runMain`. It is not +`ioreg`'s `HIDIdleTime`: the two are kept apart, and while they agree at a desk, on CI's VMs this +one ran 3–5 s longer. `test/last-input.js` is the check. + ## Mapping to a React reconciler The shape of a host config on top of this: diff --git a/package.json b/package.json index fa708cb..d5a3ee7 100644 --- a/package.json +++ b/package.json @@ -43,7 +43,7 @@ "install": "node scripts/install.js", "build": "node-gyp rebuild", "demo": "node demo/demo.js", - "test": "node test/app-lifecycle.js && node test/animation.js && node test/layer-transform.js && node test/contents-gravity.js && node test/contents-center.js && node test/accessibility-display.js && node test/ignores-mouse-events.js && node test/popup-menu.js && node test/pasteboard-write.js && node test/surface-blit.js && node test/surface-faded.js && node test/image-smoothing.js && node test/surface-colorspace.js && node test/video-surface.js && node test/player.js && node test/round-rect-xy.js && node test/clip-rule.js && node test/radial-gradient.js && node test/control-bezels.js && node test/text-features.js && node test/font-from-data.js && node test/text-coverage.js && node test/text-typesetter.js && node test/text-line-height.js && node test/text-line-width.js && node test/text-justify.js && node test/text-scale.js && node test/symbols.js && node test/eventkit-permissions.js && node test/calendars.js && node test/calendar-writes.js && node test/screencolor.js && node test/notifications.js && node test/threaded.js && node test/threaded-verbs.js && node test/threaded-frames.js && node test/threaded-resize.js && node test/ending-worker.js && node test/live-resize.js && node test/activation-policy.js" + "test": "node test/app-lifecycle.js && node test/animation.js && node test/layer-transform.js && node test/contents-gravity.js && node test/contents-center.js && node test/accessibility-display.js && node test/ignores-mouse-events.js && node test/popup-menu.js && node test/pasteboard-write.js && node test/surface-blit.js && node test/surface-faded.js && node test/image-smoothing.js && node test/surface-colorspace.js && node test/video-surface.js && node test/player.js && node test/round-rect-xy.js && node test/clip-rule.js && node test/radial-gradient.js && node test/control-bezels.js && node test/text-features.js && node test/font-from-data.js && node test/text-coverage.js && node test/text-typesetter.js && node test/text-line-height.js && node test/text-line-width.js && node test/text-justify.js && node test/text-scale.js && node test/symbols.js && node test/eventkit-permissions.js && node test/calendars.js && node test/calendar-writes.js && node test/screencolor.js && node test/notifications.js && node test/threaded.js && node test/threaded-verbs.js && node test/threaded-frames.js && node test/threaded-resize.js && node test/ending-worker.js && node test/live-resize.js && node test/activation-policy.js && node test/last-input.js" }, "dependencies": { "node-addon-api": "^8.3.0" diff --git a/src/backend.mm b/src/backend.mm index 75798a9..da6fa89 100644 --- a/src/backend.mm +++ b/src/backend.mm @@ -6929,6 +6929,22 @@ static void Apply(void* info, const CGPathElement* e) { (double)NSPasteboard.generalPasteboard.changeCount); } +// --------------------------------------------------------------------------- +// idle time +// --------------------------------------------------------------------------- + +// lastInputMs() -> number: milliseconds since the last input event of any +// kind in the login session — keyboard, mouse, trackpad, tablet — from any +// app, the clock a screen saver keys off. CGEventSource keeps it per session, +// so it asks nothing of AppKit or the window server's event stream and needs +// no permission; it answers on whichever thread calls it, before initApp +// and from a worker alike. +static Napi::Value LastInputMsFn(const Napi::CallbackInfo& info) { + CFTimeInterval s = CGEventSourceSecondsSinceLastEventType( + kCGEventSourceStateCombinedSessionState, kCGAnyInputEventType); + return Napi::Number::New(info.Env(), s * 1000.0); +} + // --------------------------------------------------------------------------- // screens + cursors + appearance // --------------------------------------------------------------------------- @@ -8060,6 +8076,7 @@ void InitBackend(Napi::Env env, Napi::Object exports) { BFN("pasteboardReadText", PbReadTextFn); BFN("pasteboardClear", PbClearFn); BFN("pasteboardChangeCount", PbChangeCountFn); + BFN("lastInputMs", LastInputMsFn); BFN("setMainMenu", SetMainMenuFn); BFN("mainMenuInfo", MainMenuInfoFn); BFN("activateMenuItem", ActivateMenuItemFn); diff --git a/test/last-input.js b/test/last-input.js new file mode 100644 index 0000000..e3c2e42 --- /dev/null +++ b/test/last-input.js @@ -0,0 +1,76 @@ +'use strict'; +// lastInputMs (react-x11#951's bridge follow-up): the session's idle time, +// read the way a screen saver reads it. A test cannot make or withhold the +// user's input, so what is checked is what holds whatever they do. Exits 0 +// when every expectation held. +// +// Checked: before initApp it answers a finite, non-negative number; between +// two reads it either grew by the time between them or was reset by input +// since; and from a worker, with the main thread parked in runMain, it +// answers synchronously on the worker's thread. IOHIDSystem's HIDIdleTime is +// printed beside it, not compared: the two are kept apart, and on CI's VMs +// this one ran 3-5 s longer than HIDIdleTime read just before it. + +const { execFileSync } = require('child_process'); +const fs = require('fs'); +const { Worker, isMainThread } = require('worker_threads'); +const { native } = require('..'); + +const say = (s) => fs.writeSync(2, `last-input: ${s}\n`); +const fail = (msg) => { + say(`FAIL ${msg}`); + process.exit(1); +}; +const TOL = 50; + +const isIdleTime = (v) => typeof v === 'number' && Number.isFinite(v) && v >= 0; + +const blockFor = (ms) => { + const until = Date.now() + ms; + while (Date.now() < until); +}; + +function hidIdleMs() { + const out = execFileSync('/usr/sbin/ioreg', ['-c', 'IOHIDSystem', '-r', '-d', '1', '-k', 'HIDIdleTime'], { + encoding: 'utf8', + }); + const m = /"HIDIdleTime"\s*=\s*(\d+)/.exec(out); + return m ? Number(m[1]) / 1e6 : null; +} + +if (isMainThread) { + // 1. before initApp, synchronously + const first = native.lastInputMs(); + if (!isIdleTime(first)) fail(`lastInputMs() before initApp: ${first}`); + + // 2. a clock: it grew by the time between the reads, or input reset it + const t0 = Date.now(); + const a = native.lastInputMs(); + blockFor(300); + const b = native.lastInputMs(); + const elapsed = Date.now() - t0; + const grew = Math.abs(b - a - elapsed) <= TOL; + const reset = b <= elapsed + TOL; + if (!grew && !reset) fail(`two reads ${elapsed} ms apart: ${a} then ${b}`); + + // 3. beside the HID idle time, for the log + const hid = hidIdleMs(); + say(`lastInputMs ${native.lastInputMs().toFixed(0)} ms, HIDIdleTime ${hid == null ? 'none' : hid.toFixed(0) + ' ms'}`); + + // 4. from a worker, the main thread parked + native.initApp(); + new Worker(__filename).on('error', (e) => fail(e && e.stack ? e.stack : e)); + const code = native.runMain(); + if (code !== 0) fail(`runMain returned ${code}`); + say('ok'); + process.exit(0); +} else { + native.connect(() => {}); + const v = native.lastInputMs(); + if (!isIdleTime(v)) { + say(`FAIL lastInputMs() from a worker: ${v}`); + native.requestExit(1); + } else { + native.requestExit(0); + } +}