Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
19 changes: 18 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
17 changes: 17 additions & 0 deletions src/backend.mm
Original file line number Diff line number Diff line change
Expand Up @@ -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
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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);
Expand Down
76 changes: 76 additions & 0 deletions test/last-input.js
Original file line number Diff line number Diff line change
@@ -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);
}
}
Loading