@narumitw/pi-caffeinate 0.49.2 → 0.49.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +22 -4
- package/package.json +9 -5
- package/src/caffeinate.ts +315 -67
- package/src/dbus-inhibit.ts +152 -0
- package/src/inhibitors.ts +7 -1
package/README.md
CHANGED
|
@@ -17,6 +17,7 @@ It is designed for long-running coding, refactoring, debugging, web research, an
|
|
|
17
17
|
- Persists the selected keep-awake mode and optional quiet mode in a small JSON settings file.
|
|
18
18
|
- Allows a custom inhibitor command through environment configuration.
|
|
19
19
|
- Emits plain status text; `@narumitw/pi-statusline` can add or suppress the status icon from JSON config.
|
|
20
|
+
- Uses the standard `org.freedesktop.ScreenSaver` D-Bus idle-inhibition service for Linux `display` mode.
|
|
20
21
|
- Fails safely when no supported inhibitor is available.
|
|
21
22
|
|
|
22
23
|
## 📦 Install
|
|
@@ -34,7 +35,7 @@ pi -e npm:@narumitw/pi-caffeinate
|
|
|
34
35
|
Try this package locally from the repository root:
|
|
35
36
|
|
|
36
37
|
```bash
|
|
37
|
-
pi -e ./
|
|
38
|
+
pi -e ./packages/pi-caffeinate
|
|
38
39
|
```
|
|
39
40
|
|
|
40
41
|
## 🖥️ Supported platforms
|
|
@@ -48,8 +49,20 @@ Use `/caffeinate sleep` if you want to prevent system sleep while allowing norma
|
|
|
48
49
|
| macOS | `caffeinate -ims` | `caffeinate -dimsu` |
|
|
49
50
|
| Windows | PowerShell `SetThreadExecutionState(0x80000001)` | PowerShell `SetThreadExecutionState(0x80000003)` |
|
|
50
51
|
| WSL | Windows `powershell.exe` with `SetThreadExecutionState(0x80000001)` | Windows `powershell.exe` with `SetThreadExecutionState(0x80000003)` |
|
|
51
|
-
| Linux with systemd | `systemd-inhibit --what=sleep ... sleep infinity` | `systemd-inhibit --what=idle:sleep ... sleep infinity` |
|
|
52
|
-
| Linux
|
|
52
|
+
| Linux with systemd | `systemd-inhibit --what=sleep ... sleep infinity` | D-Bus `org.freedesktop.ScreenSaver.Inhibit` + `systemd-inhibit --what=idle:sleep ... sleep infinity` |
|
|
53
|
+
| Linux without systemd | `caffeinate -ims` when available | D-Bus `org.freedesktop.ScreenSaver.Inhibit` + `caffeinate -dimsu` when available; D-Bus only otherwise |
|
|
54
|
+
|
|
55
|
+
On Linux, `display` mode requests idle inhibition through the standard `org.freedesktop.ScreenSaver`
|
|
56
|
+
D-Bus service, trying both `/org/freedesktop/ScreenSaver` and `/ScreenSaver` for desktop compatibility.
|
|
57
|
+
The session-bus connection stays open for the whole agent turn.
|
|
58
|
+
The inhibition ends when `UnInhibit` is called or the connection closes.
|
|
59
|
+
`systemd-inhibit --what=idle:sleep` runs alongside it to preserve logind idle and sleep inhibition.
|
|
60
|
+
If no ScreenSaver service is available, pi-caffeinate keeps the systemd blocker or `caffeinate`
|
|
61
|
+
fallback and reports a partial-activation warning.
|
|
62
|
+
If only D-Bus is available, pi-caffeinate reports partial activation because desktop idle is
|
|
63
|
+
inhibited but direct system suspend may remain possible.
|
|
64
|
+
D-Bus method calls use short deadlines, and stop or shutdown aborts an in-flight acquisition before
|
|
65
|
+
closing its session-bus connection.
|
|
53
66
|
|
|
54
67
|
If no supported inhibitor is available, the extension stays loaded and reports that caffeinate is unavailable.
|
|
55
68
|
|
|
@@ -166,10 +179,15 @@ AI coding agents often run tool-heavy tasks that take several minutes. `pi-caffe
|
|
|
166
179
|
|
|
167
180
|
The default display-awake mode prioritizes uninterrupted long-running Pi work across platforms, including Linux desktops that require idle inhibition to prevent automatic suspend. Use `/caffeinate sleep` (shown as `system-awake` in status output) when you prefer normal screen power saving and your system does not need idle inhibition to keep Pi running.
|
|
168
181
|
|
|
182
|
+
## 📦 Dependencies
|
|
183
|
+
|
|
184
|
+
On Linux, `display` mode uses the `dbus-native` package (pure JavaScript, no native build step) to
|
|
185
|
+
call `org.freedesktop.ScreenSaver` on the session bus.
|
|
186
|
+
|
|
169
187
|
## 🗂️ Package layout
|
|
170
188
|
|
|
171
189
|
```txt
|
|
172
|
-
|
|
190
|
+
packages/pi-caffeinate/
|
|
173
191
|
├── src/
|
|
174
192
|
│ ├── index.ts # Pi package entrypoint
|
|
175
193
|
│ ├── caffeinate.ts # Extension registration and lifecycle orchestration
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@narumitw/pi-caffeinate",
|
|
3
|
-
"version": "0.49.
|
|
3
|
+
"version": "0.49.4",
|
|
4
4
|
"description": "Pi extension that keeps the computer awake while the agent is running.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -23,6 +23,9 @@
|
|
|
23
23
|
"./src/index.ts"
|
|
24
24
|
]
|
|
25
25
|
},
|
|
26
|
+
"piExtension": {
|
|
27
|
+
"lifecycle": "stable"
|
|
28
|
+
},
|
|
26
29
|
"scripts": {
|
|
27
30
|
"check": "biome check . && npm run typecheck",
|
|
28
31
|
"format": "biome check --write .",
|
|
@@ -32,17 +35,18 @@
|
|
|
32
35
|
"@earendil-works/pi-coding-agent": "*"
|
|
33
36
|
},
|
|
34
37
|
"devDependencies": {
|
|
35
|
-
"@biomejs/biome": "2.5.
|
|
36
|
-
"@earendil-works/pi-coding-agent": "0.
|
|
38
|
+
"@biomejs/biome": "2.5.7",
|
|
39
|
+
"@earendil-works/pi-coding-agent": "0.84.1",
|
|
37
40
|
"@types/node": "26.1.2",
|
|
38
41
|
"typescript": "7.0.2"
|
|
39
42
|
},
|
|
40
43
|
"repository": {
|
|
41
44
|
"type": "git",
|
|
42
45
|
"url": "https://github.com/narumiruna/pi-extensions",
|
|
43
|
-
"directory": "
|
|
46
|
+
"directory": "packages/pi-caffeinate"
|
|
44
47
|
},
|
|
45
48
|
"dependencies": {
|
|
46
|
-
"@narumitw/pi-tui-kit": "^0.49.1"
|
|
49
|
+
"@narumitw/pi-tui-kit": "^0.49.1",
|
|
50
|
+
"dbus-native": "^0.15.1"
|
|
47
51
|
}
|
|
48
52
|
}
|
package/src/caffeinate.ts
CHANGED
|
@@ -5,6 +5,13 @@ import type {
|
|
|
5
5
|
ExtensionCommandContext,
|
|
6
6
|
ExtensionContext,
|
|
7
7
|
} from "@earendil-works/pi-coding-agent";
|
|
8
|
+
import {
|
|
9
|
+
type DbusScreenSaverClient,
|
|
10
|
+
type DbusScreenSaverFactory,
|
|
11
|
+
defaultDbusScreenSaverFactory,
|
|
12
|
+
INHIBIT_REASON,
|
|
13
|
+
SCREENSAVER_BUS_NAME,
|
|
14
|
+
} from "./dbus-inhibit.js";
|
|
8
15
|
import { startInhibitorProcess, stopInhibitorProcess } from "./inhibitor-process.js";
|
|
9
16
|
import { formatMode, getInhibitorCommand, type InhibitorCommand } from "./inhibitors.js";
|
|
10
17
|
import {
|
|
@@ -41,11 +48,27 @@ const MODE_OPTIONS = {
|
|
|
41
48
|
type CommandAction = "menu" | "help" | "status" | "mode" | "sleep" | "display" | "stop";
|
|
42
49
|
type CommandContext = ExtensionCommandContext;
|
|
43
50
|
|
|
51
|
+
interface CaffeinateOptions {
|
|
52
|
+
dbusFactory?: DbusScreenSaverFactory;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
interface PendingDbusStart {
|
|
56
|
+
token: number;
|
|
57
|
+
controller: AbortController;
|
|
58
|
+
client?: DbusScreenSaverClient;
|
|
59
|
+
}
|
|
60
|
+
|
|
44
61
|
interface CaffeinateState {
|
|
45
62
|
process?: ChildProcess;
|
|
63
|
+
dbus?: DbusScreenSaverClient;
|
|
64
|
+
currentSession?: { generation: number; ctx: ExtensionContext };
|
|
65
|
+
dbusCleanup?: Promise<void>;
|
|
66
|
+
pendingDbus?: PendingDbusStart;
|
|
46
67
|
startedAt?: number;
|
|
47
68
|
command?: InhibitorCommand;
|
|
48
69
|
lastError?: string;
|
|
70
|
+
inhibitWarning?: string;
|
|
71
|
+
inhibitorStarting: boolean;
|
|
49
72
|
activeTurns: number;
|
|
50
73
|
available: boolean;
|
|
51
74
|
disabled: boolean;
|
|
@@ -62,6 +85,7 @@ interface CaffeinateState {
|
|
|
62
85
|
const state: CaffeinateState = {
|
|
63
86
|
activeTurns: 0,
|
|
64
87
|
available: true,
|
|
88
|
+
inhibitorStarting: false,
|
|
65
89
|
disabled: isDisabled(),
|
|
66
90
|
mode: DEFAULT_MODE,
|
|
67
91
|
quiet: false,
|
|
@@ -71,9 +95,14 @@ const state: CaffeinateState = {
|
|
|
71
95
|
menuController: new AbortController(),
|
|
72
96
|
};
|
|
73
97
|
|
|
74
|
-
|
|
98
|
+
let dbusFactory = defaultDbusScreenSaverFactory;
|
|
99
|
+
let inhibitSequence = 0;
|
|
100
|
+
|
|
101
|
+
export default function caffeinate(pi: ExtensionAPI, options: CaffeinateOptions = {}) {
|
|
102
|
+
dbusFactory = options.dbusFactory ?? defaultDbusScreenSaverFactory;
|
|
75
103
|
pi.on("session_start", async (_event, ctx) => {
|
|
76
104
|
const generation = ++state.sessionGeneration;
|
|
105
|
+
state.currentSession = { generation, ctx };
|
|
77
106
|
replaceMenuController("Caffeinate session replaced");
|
|
78
107
|
state.iconWarningShown = false;
|
|
79
108
|
state.settingsNotice = undefined;
|
|
@@ -88,24 +117,28 @@ export default function caffeinate(pi: ExtensionAPI) {
|
|
|
88
117
|
await ensureSettingsLoaded(ctx, generation);
|
|
89
118
|
if (generation !== state.sessionGeneration) return;
|
|
90
119
|
state.activeTurns += 1;
|
|
91
|
-
startInhibitor(ctx, { notify: !state.quiet });
|
|
120
|
+
await startInhibitor(ctx, generation, { notify: !state.quiet });
|
|
92
121
|
});
|
|
93
122
|
|
|
94
|
-
pi.on("agent_end", (_event, ctx) => {
|
|
123
|
+
pi.on("agent_end", async (_event, ctx) => {
|
|
124
|
+
const generation = state.sessionGeneration;
|
|
95
125
|
state.activeTurns = Math.max(0, state.activeTurns - 1);
|
|
96
126
|
if (state.activeTurns === 0) {
|
|
97
|
-
stopInhibitor(ctx, "agent finished", { notify: !state.quiet });
|
|
127
|
+
await stopInhibitor(ctx, "agent finished", { notify: !state.quiet });
|
|
98
128
|
}
|
|
129
|
+
if (generation !== state.sessionGeneration) return;
|
|
99
130
|
updateStatus(ctx);
|
|
100
131
|
});
|
|
101
132
|
|
|
102
133
|
pi.on("session_shutdown", async (_event, ctx) => {
|
|
103
|
-
state.sessionGeneration
|
|
134
|
+
const generation = ++state.sessionGeneration;
|
|
135
|
+
state.currentSession = undefined;
|
|
104
136
|
state.menuController.abort(new DOMException("Caffeinate session shut down", "AbortError"));
|
|
105
137
|
state.activeTurns = 0;
|
|
106
|
-
stopInhibitor(ctx, "session shutdown", { notify: false });
|
|
107
|
-
ctx.ui.setStatus(STATUS_KEY, undefined);
|
|
138
|
+
await stopInhibitor(ctx, "session shutdown", { notify: false });
|
|
108
139
|
await modeOperationQueue;
|
|
140
|
+
if (generation !== state.sessionGeneration) return;
|
|
141
|
+
ctx.ui.setStatus(STATUS_KEY, undefined);
|
|
109
142
|
});
|
|
110
143
|
|
|
111
144
|
pi.registerCommand("caffeinate", {
|
|
@@ -147,7 +180,7 @@ async function handleCaffeinateCommand(args: string, ctx: CommandContext, genera
|
|
|
147
180
|
await setMode(ctx, "display", generation);
|
|
148
181
|
return;
|
|
149
182
|
case "stop":
|
|
150
|
-
stopCaffeinate(ctx, "manual stop");
|
|
183
|
+
await stopCaffeinate(ctx, "manual stop", generation);
|
|
151
184
|
return;
|
|
152
185
|
}
|
|
153
186
|
|
|
@@ -215,7 +248,7 @@ async function runCaffeinateMenu(ctx: CommandContext, generation: number, start:
|
|
|
215
248
|
return { kind: "close" };
|
|
216
249
|
},
|
|
217
250
|
stop: async () => {
|
|
218
|
-
stopCaffeinate(ctx, "manual stop");
|
|
251
|
+
await stopCaffeinate(ctx, "manual stop", generation);
|
|
219
252
|
return { kind: "close" };
|
|
220
253
|
},
|
|
221
254
|
help: async () => {
|
|
@@ -264,18 +297,23 @@ async function setModeNow(ctx: ExtensionContext, mode: CaffeinateMode, generatio
|
|
|
264
297
|
if (generation !== state.sessionGeneration) return;
|
|
265
298
|
const previousMode = state.mode;
|
|
266
299
|
const previousQuiet = state.quiet;
|
|
267
|
-
const restartRequired = Boolean(
|
|
300
|
+
const restartRequired = Boolean(
|
|
301
|
+
hasActiveInhibitor() && previousMode !== mode && !state.command?.custom,
|
|
302
|
+
);
|
|
268
303
|
state.settingsError = undefined;
|
|
269
304
|
|
|
270
305
|
if (restartRequired) {
|
|
271
306
|
state.mode = mode;
|
|
272
|
-
stopInhibitor(ctx, "mode changed", { notify: false });
|
|
273
|
-
|
|
274
|
-
|
|
307
|
+
await stopInhibitor(ctx, "mode changed", { notify: false });
|
|
308
|
+
if (generation !== state.sessionGeneration) return;
|
|
309
|
+
await startInhibitor(ctx, generation, { notify: false });
|
|
310
|
+
if (generation !== state.sessionGeneration) return;
|
|
311
|
+
if (!hasActiveInhibitor()) {
|
|
275
312
|
const applicationError = state.lastError ?? "the inhibitor could not be restarted";
|
|
276
313
|
state.mode = previousMode;
|
|
277
|
-
startInhibitor(ctx, { notify: false });
|
|
278
|
-
|
|
314
|
+
await startInhibitor(ctx, generation, { notify: false });
|
|
315
|
+
if (generation !== state.sessionGeneration) return;
|
|
316
|
+
const rollbackError = hasActiveInhibitor()
|
|
279
317
|
? undefined
|
|
280
318
|
: (state.lastError ?? "the previous inhibitor could not be restored");
|
|
281
319
|
state.settingsError = rollbackError
|
|
@@ -296,9 +334,13 @@ async function setModeNow(ctx: ExtensionContext, mode: CaffeinateMode, generatio
|
|
|
296
334
|
if (restartRequired) {
|
|
297
335
|
state.mode = previousMode;
|
|
298
336
|
state.quiet = previousQuiet;
|
|
299
|
-
stopInhibitor(ctx, "settings save failed", { notify: false });
|
|
300
|
-
|
|
301
|
-
|
|
337
|
+
await stopInhibitor(ctx, "settings save failed", { notify: false });
|
|
338
|
+
if (generation !== state.sessionGeneration) return;
|
|
339
|
+
await startInhibitor(ctx, generation, { notify: false });
|
|
340
|
+
if (generation !== state.sessionGeneration) return;
|
|
341
|
+
if (!hasActiveInhibitor()) {
|
|
342
|
+
rollbackError = state.lastError ?? "the prior inhibitor was not restored";
|
|
343
|
+
}
|
|
302
344
|
}
|
|
303
345
|
state.settingsError = rollbackError
|
|
304
346
|
? `settings save failed (${formatError(error)}); runtime rollback failed: ${rollbackError}`
|
|
@@ -323,9 +365,10 @@ function showStatus(ctx: ExtensionContext) {
|
|
|
323
365
|
updateStatus(ctx);
|
|
324
366
|
}
|
|
325
367
|
|
|
326
|
-
function stopCaffeinate(ctx: ExtensionContext, reason: string) {
|
|
368
|
+
async function stopCaffeinate(ctx: ExtensionContext, reason: string, sessionGeneration: number) {
|
|
327
369
|
state.activeTurns = 0;
|
|
328
|
-
stopInhibitor(ctx, reason);
|
|
370
|
+
await stopInhibitor(ctx, reason);
|
|
371
|
+
if (sessionGeneration !== state.sessionGeneration) return;
|
|
329
372
|
updateStatus(ctx);
|
|
330
373
|
}
|
|
331
374
|
|
|
@@ -363,14 +406,24 @@ function buildCommandGuide() {
|
|
|
363
406
|
].join("\n");
|
|
364
407
|
}
|
|
365
408
|
|
|
366
|
-
function startInhibitor(
|
|
367
|
-
|
|
409
|
+
async function startInhibitor(
|
|
410
|
+
ctx: ExtensionContext,
|
|
411
|
+
sessionGeneration: number,
|
|
412
|
+
options: { notify?: boolean } = {},
|
|
413
|
+
) {
|
|
414
|
+
if (state.disabled || hasActiveInhibitor() || state.inhibitorStarting) {
|
|
368
415
|
updateStatus(ctx);
|
|
369
416
|
return;
|
|
370
417
|
}
|
|
371
418
|
|
|
419
|
+
const token = ++inhibitSequence;
|
|
372
420
|
const command = getInhibitorCommand(state.mode);
|
|
373
|
-
|
|
421
|
+
const wantsDbus =
|
|
422
|
+
!command?.custom &&
|
|
423
|
+
state.mode === "display" &&
|
|
424
|
+
process.platform === "linux" &&
|
|
425
|
+
(command === undefined || command.addDbusIdleInhibit === true);
|
|
426
|
+
if (!command && !wantsDbus) {
|
|
374
427
|
state.available = false;
|
|
375
428
|
state.lastError = `No supported sleep inhibitor found for ${process.platform}.`;
|
|
376
429
|
ctx.ui.notify(state.lastError, "warning");
|
|
@@ -378,56 +431,242 @@ function startInhibitor(ctx: ExtensionContext, options: { notify?: boolean } = {
|
|
|
378
431
|
return;
|
|
379
432
|
}
|
|
380
433
|
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
ctx.ui.notify(state.lastError, "warning");
|
|
392
|
-
updateStatus(ctx);
|
|
393
|
-
},
|
|
394
|
-
(exit) => {
|
|
395
|
-
if (state.process !== child) return;
|
|
396
|
-
state.process = undefined;
|
|
397
|
-
state.startedAt = undefined;
|
|
398
|
-
state.available = false;
|
|
399
|
-
state.lastError = `${command.description} exited unexpectedly (${exit}).`;
|
|
400
|
-
ctx.ui.notify(state.lastError, "warning");
|
|
401
|
-
updateStatus(ctx);
|
|
402
|
-
},
|
|
403
|
-
);
|
|
404
|
-
state.process = child;
|
|
405
|
-
state.startedAt = Date.now();
|
|
406
|
-
state.command = command;
|
|
407
|
-
state.available = true;
|
|
408
|
-
state.lastError = undefined;
|
|
409
|
-
if (options.notify !== false) {
|
|
410
|
-
ctx.ui.notify(`Keeping computer awake (${statusModeLabel()}).`, "info");
|
|
411
|
-
}
|
|
412
|
-
updateStatus(ctx);
|
|
413
|
-
} catch (error) {
|
|
434
|
+
state.inhibitorStarting = true;
|
|
435
|
+
state.inhibitWarning = undefined;
|
|
436
|
+
let child: ChildProcess | undefined;
|
|
437
|
+
let childError =
|
|
438
|
+
!command && wantsDbus
|
|
439
|
+
? "No supported system sleep inhibitor is available; direct system suspend may remain possible."
|
|
440
|
+
: undefined;
|
|
441
|
+
let assembling = true;
|
|
442
|
+
const handleChildFailure = (message: string) => {
|
|
443
|
+
if (!child || token !== inhibitSequence || state.process !== child) return;
|
|
414
444
|
state.process = undefined;
|
|
445
|
+
state.command = undefined;
|
|
446
|
+
if (assembling) {
|
|
447
|
+
childError = message;
|
|
448
|
+
return;
|
|
449
|
+
}
|
|
450
|
+
applyChildFailure(message);
|
|
451
|
+
};
|
|
452
|
+
|
|
453
|
+
if (command) {
|
|
454
|
+
try {
|
|
455
|
+
child = startInhibitorProcess(
|
|
456
|
+
command,
|
|
457
|
+
(error) => handleChildFailure(`${command.description} failed: ${error.message}`),
|
|
458
|
+
(exit) => handleChildFailure(`${command.description} exited unexpectedly (${exit}).`),
|
|
459
|
+
);
|
|
460
|
+
state.process = child;
|
|
461
|
+
state.command = command;
|
|
462
|
+
state.startedAt = Date.now();
|
|
463
|
+
} catch (error) {
|
|
464
|
+
childError = `${command.description}: ${formatError(error)}`;
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
let dbus: DbusScreenSaverClient | undefined;
|
|
469
|
+
let dbusError: string | undefined;
|
|
470
|
+
if (wantsDbus) {
|
|
471
|
+
const pending: PendingDbusStart = { token, controller: new AbortController() };
|
|
472
|
+
state.pendingDbus = pending;
|
|
473
|
+
try {
|
|
474
|
+
const client = await dbusFactory();
|
|
475
|
+
if (!pendingStartIsCurrent(pending, sessionGeneration)) {
|
|
476
|
+
await client.close().catch(() => undefined);
|
|
477
|
+
await cancelPendingStart(token, child, command);
|
|
478
|
+
return;
|
|
479
|
+
}
|
|
480
|
+
pending.client = client;
|
|
481
|
+
await client.inhibit(INHIBIT_REASON, pending.controller.signal);
|
|
482
|
+
if (!pendingStartIsCurrent(pending, sessionGeneration)) {
|
|
483
|
+
await cancelPendingStart(token, child, command);
|
|
484
|
+
return;
|
|
485
|
+
}
|
|
486
|
+
pending.client = undefined;
|
|
487
|
+
state.pendingDbus = undefined;
|
|
488
|
+
dbus = client;
|
|
489
|
+
} catch (error) {
|
|
490
|
+
const stale =
|
|
491
|
+
!pendingStartIsCurrent(pending, sessionGeneration) || pending.controller.signal.aborted;
|
|
492
|
+
const pendingClient = takePendingDbusClient(pending);
|
|
493
|
+
await pendingClient?.close().catch(() => undefined);
|
|
494
|
+
if (stale) {
|
|
495
|
+
await cancelPendingStart(token, child, command);
|
|
496
|
+
return;
|
|
497
|
+
}
|
|
498
|
+
dbusError = `D-Bus idle inhibit (${SCREENSAVER_BUS_NAME}): ${formatError(error)}`;
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
assembling = false;
|
|
503
|
+
if (startIsStale(token, sessionGeneration)) {
|
|
504
|
+
await cancelPendingStart(token, child, command);
|
|
505
|
+
await dbus?.close().catch(() => undefined);
|
|
506
|
+
return;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
state.inhibitorStarting = false;
|
|
510
|
+
state.dbus = dbus;
|
|
511
|
+
if (dbus) {
|
|
512
|
+
dbus.setFailureHandler((error) => applyDbusFailure(token, dbus, error));
|
|
513
|
+
if (state.dbus !== dbus) return;
|
|
514
|
+
}
|
|
515
|
+
const failures = [childError, dbusError].filter((failure): failure is string => Boolean(failure));
|
|
516
|
+
if (!hasActiveInhibitor()) {
|
|
415
517
|
state.startedAt = undefined;
|
|
518
|
+
state.command = undefined;
|
|
416
519
|
state.available = false;
|
|
417
|
-
state.lastError =
|
|
418
|
-
|
|
520
|
+
state.lastError =
|
|
521
|
+
failures.join("; ") || `No supported sleep inhibitor found for ${process.platform}.`;
|
|
522
|
+
ctx.ui.notify(state.lastError, "warning");
|
|
419
523
|
updateStatus(ctx);
|
|
524
|
+
return;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
state.startedAt ??= Date.now();
|
|
528
|
+
state.available = true;
|
|
529
|
+
state.lastError = undefined;
|
|
530
|
+
state.inhibitWarning = failures.length > 0 ? failures.join("; ") : undefined;
|
|
531
|
+
if (state.inhibitWarning) {
|
|
532
|
+
ctx.ui.notify(`pi-caffeinate is partially active: ${state.inhibitWarning}`, "warning");
|
|
533
|
+
} else if (options.notify !== false) {
|
|
534
|
+
ctx.ui.notify(`Keeping computer awake (${statusModeLabel()}).`, "info");
|
|
420
535
|
}
|
|
536
|
+
updateStatus(ctx);
|
|
421
537
|
}
|
|
422
538
|
|
|
423
|
-
function stopInhibitor(
|
|
539
|
+
async function stopInhibitor(
|
|
540
|
+
ctx: ExtensionContext,
|
|
541
|
+
reason: string,
|
|
542
|
+
options: { notify?: boolean } = {},
|
|
543
|
+
) {
|
|
544
|
+
inhibitSequence += 1;
|
|
424
545
|
const child = state.process;
|
|
425
|
-
|
|
546
|
+
const dbus = state.dbus;
|
|
547
|
+
const dbusCleanup = state.dbusCleanup;
|
|
548
|
+
const pending = state.pendingDbus;
|
|
549
|
+
const command = state.command;
|
|
550
|
+
const wasStarting = state.inhibitorStarting;
|
|
426
551
|
state.process = undefined;
|
|
427
|
-
state.
|
|
428
|
-
|
|
552
|
+
state.dbus = undefined;
|
|
553
|
+
state.dbusCleanup = undefined;
|
|
554
|
+
state.pendingDbus = undefined;
|
|
429
555
|
state.command = undefined;
|
|
556
|
+
state.startedAt = undefined;
|
|
557
|
+
state.inhibitWarning = undefined;
|
|
558
|
+
state.inhibitorStarting = false;
|
|
559
|
+
dbus?.setFailureHandler(undefined);
|
|
560
|
+
pending?.controller.abort(new DOMException(`Caffeinate stopped: ${reason}`, "AbortError"));
|
|
561
|
+
const pendingClient = pending ? takePendingDbusClient(pending) : undefined;
|
|
562
|
+
if (child) stopInhibitorProcess(child, command);
|
|
563
|
+
if (!child && !dbus && !pendingClient && !wasStarting) {
|
|
564
|
+
await dbusCleanup;
|
|
565
|
+
return;
|
|
566
|
+
}
|
|
430
567
|
if (options.notify !== false) ctx.ui.notify(`Released pi-caffeinate (${reason}).`, "info");
|
|
568
|
+
await Promise.all([dbusCleanup, pendingClient?.close().catch(() => undefined)]);
|
|
569
|
+
if (!dbus) return;
|
|
570
|
+
try {
|
|
571
|
+
await dbus.uninhibit();
|
|
572
|
+
} catch {
|
|
573
|
+
// Closing the session-bus connection also releases its inhibition cookie.
|
|
574
|
+
}
|
|
575
|
+
await dbus.close().catch(() => undefined);
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
function applyChildFailure(message: string) {
|
|
579
|
+
if (state.dbus) {
|
|
580
|
+
state.available = true;
|
|
581
|
+
state.lastError = undefined;
|
|
582
|
+
state.inhibitWarning = message;
|
|
583
|
+
} else {
|
|
584
|
+
state.startedAt = undefined;
|
|
585
|
+
state.available = false;
|
|
586
|
+
state.lastError = message;
|
|
587
|
+
state.inhibitWarning = undefined;
|
|
588
|
+
}
|
|
589
|
+
const ctx = currentSessionContext();
|
|
590
|
+
if (!ctx) return;
|
|
591
|
+
ctx.ui.notify(message, "warning");
|
|
592
|
+
updateStatus(ctx);
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
function applyDbusFailure(token: number, client: DbusScreenSaverClient, error: Error) {
|
|
596
|
+
if (token !== inhibitSequence || state.dbus !== client) return;
|
|
597
|
+
client.setFailureHandler(undefined);
|
|
598
|
+
state.dbus = undefined;
|
|
599
|
+
const message = `D-Bus idle inhibit (${SCREENSAVER_BUS_NAME}) failed: ${formatError(error)}`;
|
|
600
|
+
const notification = state.process ? `pi-caffeinate is partially active: ${message}` : message;
|
|
601
|
+
if (state.process) {
|
|
602
|
+
state.available = true;
|
|
603
|
+
state.lastError = undefined;
|
|
604
|
+
state.inhibitWarning = message;
|
|
605
|
+
} else {
|
|
606
|
+
state.startedAt = undefined;
|
|
607
|
+
state.command = undefined;
|
|
608
|
+
state.available = false;
|
|
609
|
+
state.lastError = [state.inhibitWarning, message].filter(Boolean).join("; ");
|
|
610
|
+
state.inhibitWarning = undefined;
|
|
611
|
+
}
|
|
612
|
+
const cleanup = client.close().catch(() => undefined);
|
|
613
|
+
state.dbusCleanup = cleanup;
|
|
614
|
+
void cleanup.then(() => {
|
|
615
|
+
if (state.dbusCleanup === cleanup) state.dbusCleanup = undefined;
|
|
616
|
+
});
|
|
617
|
+
const ctx = currentSessionContext();
|
|
618
|
+
if (!ctx) return;
|
|
619
|
+
ctx.ui.notify(notification, "warning");
|
|
620
|
+
updateStatus(ctx);
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
function currentSessionContext() {
|
|
624
|
+
const session = state.currentSession;
|
|
625
|
+
return session?.generation === state.sessionGeneration ? session.ctx : undefined;
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
function hasActiveInhibitor() {
|
|
629
|
+
return Boolean(state.process || state.dbus);
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
function startIsStale(token: number, sessionGeneration: number) {
|
|
633
|
+
return token !== inhibitSequence || sessionGeneration !== state.sessionGeneration;
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
function pendingStartIsCurrent(pending: PendingDbusStart, sessionGeneration: number) {
|
|
637
|
+
return (
|
|
638
|
+
state.pendingDbus === pending &&
|
|
639
|
+
!pending.controller.signal.aborted &&
|
|
640
|
+
!startIsStale(pending.token, sessionGeneration)
|
|
641
|
+
);
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
function takePendingDbusClient(pending: PendingDbusStart) {
|
|
645
|
+
if (state.pendingDbus === pending) state.pendingDbus = undefined;
|
|
646
|
+
const client = pending.client;
|
|
647
|
+
pending.client = undefined;
|
|
648
|
+
return client;
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
async function cancelPendingStart(
|
|
652
|
+
token: number,
|
|
653
|
+
child: ChildProcess | undefined,
|
|
654
|
+
command: InhibitorCommand | undefined,
|
|
655
|
+
) {
|
|
656
|
+
if (token !== inhibitSequence) return;
|
|
657
|
+
inhibitSequence += 1;
|
|
658
|
+
state.inhibitorStarting = false;
|
|
659
|
+
const pending = state.pendingDbus?.token === token ? state.pendingDbus : undefined;
|
|
660
|
+
state.pendingDbus = undefined;
|
|
661
|
+
pending?.controller.abort(new DOMException("Caffeinate start cancelled", "AbortError"));
|
|
662
|
+
const pendingClient = pending ? takePendingDbusClient(pending) : undefined;
|
|
663
|
+
if (child && state.process === child) {
|
|
664
|
+
state.process = undefined;
|
|
665
|
+
state.command = undefined;
|
|
666
|
+
state.startedAt = undefined;
|
|
667
|
+
stopInhibitorProcess(child, command);
|
|
668
|
+
}
|
|
669
|
+
await pendingClient?.close().catch(() => undefined);
|
|
431
670
|
}
|
|
432
671
|
|
|
433
672
|
function updateStatus(ctx: ExtensionContext) {
|
|
@@ -436,7 +675,7 @@ function updateStatus(ctx: ExtensionContext) {
|
|
|
436
675
|
return;
|
|
437
676
|
}
|
|
438
677
|
|
|
439
|
-
if (
|
|
678
|
+
if (hasActiveInhibitor()) {
|
|
440
679
|
ctx.ui.setStatus(STATUS_KEY, withDeprecatedIcon(statusModeLabel()));
|
|
441
680
|
return;
|
|
442
681
|
}
|
|
@@ -465,11 +704,15 @@ function describeState() {
|
|
|
465
704
|
return lines.join("\n");
|
|
466
705
|
}
|
|
467
706
|
|
|
468
|
-
if (
|
|
707
|
+
if (hasActiveInhibitor()) {
|
|
469
708
|
const seconds = state.startedAt ? Math.round((Date.now() - state.startedAt) / 1000) : 0;
|
|
709
|
+
const activeParts: string[] = [];
|
|
710
|
+
if (state.dbus) activeParts.push(`D-Bus idle inhibit (${SCREENSAVER_BUS_NAME})`);
|
|
711
|
+
if (state.process && state.command) activeParts.push(state.command.description);
|
|
470
712
|
lines.unshift(
|
|
471
|
-
`pi-caffeinate is active using ${
|
|
713
|
+
`pi-caffeinate is active using ${activeParts.join(" + ") || "an inhibitor"} for ${seconds}s.`,
|
|
472
714
|
);
|
|
715
|
+
if (state.inhibitWarning) lines.push(`Inhibitor warning: ${state.inhibitWarning}`);
|
|
473
716
|
return lines.join("\n");
|
|
474
717
|
}
|
|
475
718
|
|
|
@@ -485,7 +728,7 @@ function describeState() {
|
|
|
485
728
|
}
|
|
486
729
|
|
|
487
730
|
function statusLevel() {
|
|
488
|
-
return state.available && !state.settingsError ? "info" : "warning";
|
|
731
|
+
return state.available && !state.settingsError && !state.inhibitWarning ? "info" : "warning";
|
|
489
732
|
}
|
|
490
733
|
|
|
491
734
|
function statusModeLabel() {
|
|
@@ -559,5 +802,10 @@ function warnDeprecatedIcon(ctx: ExtensionContext) {
|
|
|
559
802
|
);
|
|
560
803
|
}
|
|
561
804
|
|
|
562
|
-
export {
|
|
805
|
+
export {
|
|
806
|
+
formatMode,
|
|
807
|
+
getInhibitorCommand,
|
|
808
|
+
splitCommand,
|
|
809
|
+
windowsInhibitorScript,
|
|
810
|
+
} from "./inhibitors.js";
|
|
563
811
|
export { normalizeCaffeinateSettings } from "./settings.js";
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { type Message, type MessageBus, sessionBus } from "dbus-native";
|
|
2
|
+
|
|
3
|
+
export const SCREENSAVER_BUS_NAME = "org.freedesktop.ScreenSaver";
|
|
4
|
+
export const SCREENSAVER_INTERFACE = "org.freedesktop.ScreenSaver";
|
|
5
|
+
export const INHIBIT_REASON = "Pi agent is running";
|
|
6
|
+
|
|
7
|
+
const INHIBIT_APPLICATION_NAME = "pi-caffeinate";
|
|
8
|
+
const SCREENSAVER_OBJECT_PATHS = ["/org/freedesktop/ScreenSaver", "/ScreenSaver"];
|
|
9
|
+
const DBUS_CALL_TIMEOUT_MS = 2_000;
|
|
10
|
+
|
|
11
|
+
type DbusFailureHandler = (error: Error) => void;
|
|
12
|
+
|
|
13
|
+
export interface DbusScreenSaverClient {
|
|
14
|
+
setFailureHandler(handler: DbusFailureHandler | undefined): void;
|
|
15
|
+
inhibit(reason: string, signal?: AbortSignal): Promise<void>;
|
|
16
|
+
uninhibit(): Promise<void>;
|
|
17
|
+
close(): Promise<void>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export type DbusScreenSaverFactory = () => Promise<DbusScreenSaverClient>;
|
|
21
|
+
|
|
22
|
+
export async function defaultDbusScreenSaverFactory(): Promise<DbusScreenSaverClient> {
|
|
23
|
+
return new NativeScreenSaverClient(sessionBus());
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
class NativeScreenSaverClient implements DbusScreenSaverClient {
|
|
27
|
+
private cookie?: number;
|
|
28
|
+
private objectPath?: string;
|
|
29
|
+
private failureHandler?: DbusFailureHandler;
|
|
30
|
+
private pendingFailure?: Error;
|
|
31
|
+
private lastConnectionError?: Error;
|
|
32
|
+
private closing = false;
|
|
33
|
+
|
|
34
|
+
private readonly handleConnectionError = (error: unknown) => {
|
|
35
|
+
this.lastConnectionError = toError(error, "D-Bus session connection failed");
|
|
36
|
+
if (this.cookie !== undefined) this.reportFailure(this.lastConnectionError);
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
private readonly handleConnectionClose = (error?: unknown) => {
|
|
40
|
+
if (this.closing || this.cookie === undefined) return;
|
|
41
|
+
this.reportFailure(
|
|
42
|
+
toError(error, this.lastConnectionError?.message ?? "D-Bus session connection closed"),
|
|
43
|
+
);
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
constructor(private readonly bus: MessageBus) {
|
|
47
|
+
this.bus.connection.on("error", this.handleConnectionError);
|
|
48
|
+
this.bus.connection.on("close", this.handleConnectionClose);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
setFailureHandler(handler: DbusFailureHandler | undefined): void {
|
|
52
|
+
this.failureHandler = handler;
|
|
53
|
+
if (!handler || !this.pendingFailure) return;
|
|
54
|
+
const failure = this.pendingFailure;
|
|
55
|
+
this.pendingFailure = undefined;
|
|
56
|
+
handler(failure);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async inhibit(reason: string, signal?: AbortSignal): Promise<void> {
|
|
60
|
+
let lastError: unknown;
|
|
61
|
+
for (const objectPath of SCREENSAVER_OBJECT_PATHS) {
|
|
62
|
+
if (signal?.aborted) throw abortReason(signal);
|
|
63
|
+
try {
|
|
64
|
+
this.cookie = await invoke<number>(
|
|
65
|
+
this.bus,
|
|
66
|
+
{
|
|
67
|
+
destination: SCREENSAVER_BUS_NAME,
|
|
68
|
+
path: objectPath,
|
|
69
|
+
interface: SCREENSAVER_INTERFACE,
|
|
70
|
+
member: "Inhibit",
|
|
71
|
+
signature: "ss",
|
|
72
|
+
body: [INHIBIT_APPLICATION_NAME, reason],
|
|
73
|
+
},
|
|
74
|
+
signal,
|
|
75
|
+
);
|
|
76
|
+
this.objectPath = objectPath;
|
|
77
|
+
return;
|
|
78
|
+
} catch (error) {
|
|
79
|
+
if (signal?.aborted) throw abortReason(signal);
|
|
80
|
+
lastError = error;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
throw new Error(`D-Bus idle inhibit failed: ${formatError(lastError)}`, {
|
|
84
|
+
cause: lastError,
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async uninhibit(): Promise<void> {
|
|
89
|
+
if (this.cookie === undefined || !this.objectPath) return;
|
|
90
|
+
const cookie = this.cookie;
|
|
91
|
+
this.cookie = undefined;
|
|
92
|
+
const objectPath = this.objectPath;
|
|
93
|
+
this.objectPath = undefined;
|
|
94
|
+
await invoke<void>(this.bus, {
|
|
95
|
+
destination: SCREENSAVER_BUS_NAME,
|
|
96
|
+
path: objectPath,
|
|
97
|
+
interface: SCREENSAVER_INTERFACE,
|
|
98
|
+
member: "UnInhibit",
|
|
99
|
+
signature: "u",
|
|
100
|
+
body: [cookie],
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async close(): Promise<void> {
|
|
105
|
+
this.closing = true;
|
|
106
|
+
this.cookie = undefined;
|
|
107
|
+
this.objectPath = undefined;
|
|
108
|
+
this.failureHandler = undefined;
|
|
109
|
+
try {
|
|
110
|
+
await this.bus.close();
|
|
111
|
+
} finally {
|
|
112
|
+
this.bus.connection.off("error", this.handleConnectionError);
|
|
113
|
+
this.bus.connection.off("close", this.handleConnectionClose);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
private reportFailure(error: Error): void {
|
|
118
|
+
if (this.cookie === undefined) return;
|
|
119
|
+
this.cookie = undefined;
|
|
120
|
+
this.objectPath = undefined;
|
|
121
|
+
if (this.failureHandler) this.failureHandler(error);
|
|
122
|
+
else this.pendingFailure = error;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function invoke<TResult>(
|
|
127
|
+
bus: MessageBus,
|
|
128
|
+
message: Message,
|
|
129
|
+
signal?: AbortSignal,
|
|
130
|
+
): Promise<TResult> {
|
|
131
|
+
return new Promise<TResult>((resolve, reject) => {
|
|
132
|
+
bus.invoke(message, { signal, timeout: DBUS_CALL_TIMEOUT_MS }, (error, ...values) => {
|
|
133
|
+
if (error) reject(error);
|
|
134
|
+
else resolve(values[0] as TResult);
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function abortReason(signal: AbortSignal): unknown {
|
|
140
|
+
return signal.reason ?? new DOMException("D-Bus idle inhibit cancelled", "AbortError");
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function toError(error: unknown, fallback: string): Error {
|
|
144
|
+
return error instanceof Error ? error : new Error(fallback);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function formatError(error: unknown) {
|
|
148
|
+
if (error instanceof Error) {
|
|
149
|
+
return error.name === "Error" ? error.message : `${error.name}: ${error.message}`;
|
|
150
|
+
}
|
|
151
|
+
return String(error);
|
|
152
|
+
}
|
package/src/inhibitors.ts
CHANGED
|
@@ -9,6 +9,8 @@ export interface InhibitorCommand {
|
|
|
9
9
|
description: string;
|
|
10
10
|
releaseOnStdinClose?: boolean;
|
|
11
11
|
custom?: boolean;
|
|
12
|
+
/** Display mode also needs D-Bus because logind idle inhibition misses some compositors. */
|
|
13
|
+
addDbusIdleInhibit?: boolean;
|
|
12
14
|
}
|
|
13
15
|
|
|
14
16
|
export function getInhibitorCommand(mode: CaffeinateMode): InhibitorCommand | undefined {
|
|
@@ -29,7 +31,7 @@ export function getInhibitorCommand(mode: CaffeinateMode): InhibitorCommand | un
|
|
|
29
31
|
return windowsPowerInhibitorCommand("powershell.exe", mode);
|
|
30
32
|
}
|
|
31
33
|
if (commandExists("systemd-inhibit")) {
|
|
32
|
-
const what = mode === "
|
|
34
|
+
const what = mode === "display" ? "idle:sleep" : "sleep";
|
|
33
35
|
return parentBoundUnixCommand(
|
|
34
36
|
"systemd-inhibit",
|
|
35
37
|
[
|
|
@@ -41,6 +43,7 @@ export function getInhibitorCommand(mode: CaffeinateMode): InhibitorCommand | un
|
|
|
41
43
|
"infinity",
|
|
42
44
|
],
|
|
43
45
|
`systemd-inhibit (${formatMode(mode)})`,
|
|
46
|
+
mode === "display",
|
|
44
47
|
);
|
|
45
48
|
}
|
|
46
49
|
if (commandExists("caffeinate")) {
|
|
@@ -48,6 +51,7 @@ export function getInhibitorCommand(mode: CaffeinateMode): InhibitorCommand | un
|
|
|
48
51
|
"caffeinate",
|
|
49
52
|
macCaffeinateArgs(mode),
|
|
50
53
|
caffeinateDescription(mode),
|
|
54
|
+
mode === "display",
|
|
51
55
|
);
|
|
52
56
|
}
|
|
53
57
|
}
|
|
@@ -67,6 +71,7 @@ function parentBoundUnixCommand(
|
|
|
67
71
|
command: string,
|
|
68
72
|
args: string[],
|
|
69
73
|
description: string,
|
|
74
|
+
addDbusIdleInhibit = false,
|
|
70
75
|
): InhibitorCommand {
|
|
71
76
|
return {
|
|
72
77
|
command: "sh",
|
|
@@ -79,6 +84,7 @@ function parentBoundUnixCommand(
|
|
|
79
84
|
...args,
|
|
80
85
|
],
|
|
81
86
|
description,
|
|
87
|
+
...(addDbusIdleInhibit ? { addDbusIdleInhibit: true } : {}),
|
|
82
88
|
};
|
|
83
89
|
}
|
|
84
90
|
|