@narumitw/pi-caffeinate 0.49.3 → 0.49.5

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/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
- export default function caffeinate(pi: ExtensionAPI) {
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 += 1;
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(state.process && previousMode !== mode && !state.command?.custom);
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
- startInhibitor(ctx, { notify: false });
274
- if (!state.process) {
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
- const rollbackError = state.process
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
- startInhibitor(ctx, { notify: false });
301
- if (!state.process) rollbackError = state.lastError ?? "the prior inhibitor was not restored";
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(ctx: ExtensionContext, options: { notify?: boolean } = {}) {
367
- if (state.disabled || state.process) {
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
- if (!command) {
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
- try {
382
- const child = startInhibitorProcess(
383
- command,
384
- (error) => {
385
- if (state.process === child) {
386
- state.process = undefined;
387
- state.startedAt = undefined;
388
- }
389
- state.available = false;
390
- state.lastError = `${command.description} failed: ${error.message}`;
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 = error instanceof Error ? error.message : String(error);
418
- ctx.ui.notify(`Unable to start pi-caffeinate: ${state.lastError}`, "warning");
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(ctx: ExtensionContext, reason: string, options: { notify?: boolean } = {}) {
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
- if (!child) return;
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.startedAt = undefined;
428
- stopInhibitorProcess(child, state.command);
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 (state.process) {
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 (state.process) {
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 ${state.command?.description ?? "an inhibitor"} for ${seconds}s.`,
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 { formatMode, splitCommand, windowsInhibitorScript } from "./inhibitors.js";
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
+ }