Skip to content

Commit 0d3f5db

Browse files
committed
feat: add powerSaveBlocker via macOS IOPMAssertion and Linux ScreenSaver inhibition
1 parent cdb3b5b commit 0d3f5db

12 files changed

Lines changed: 651 additions & 4 deletions

File tree

src/main/api/power-save-blocker.ts

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
import { currentPlatform } from '../../common/platform';
2+
import { linuxPowerSaveBlockerBackend } from '../platform/linux/linux-power-save-blocker';
3+
import { cocoaPowerSaveBlockerBackend } from '../platform/macos/cocoa-power-save-blocker';
4+
5+
/**
6+
* Block system/display sleep — a drop-in subset of Electron's `powerSaveBlocker`.
7+
*
8+
* A start/stop REGISTRY (not an emitter): {@link PowerSaveBlockerImpl.start} takes a
9+
* blocker `type`, asks the platform backend to {@link PowerSaveBlockerBackend.acquire} a
10+
* native blocker, stores it under a fresh incrementing id, and returns that id;
11+
* {@link PowerSaveBlockerImpl.stop} releases the native blocker and forgets the id.
12+
*
13+
* Backends: macOS holds an IOKit `IOPMAssertion` (synchronous, no run loop); Linux holds
14+
* an `org.freedesktop.ScreenSaver` inhibition cookie over the deadlock-safe bounded GDBus
15+
* method-call primitive (gated behind `SAMBAR_ENABLE_LINUX_POWER_BLOCKER`; a clean no-op
16+
* when there is no session bus).
17+
*
18+
* NO-MECHANISM SEMANTICS (matches Electron, which "always returns an integer identifying
19+
* the power save blocker"): when {@link PowerSaveBlockerBackend.acquire} returns null (no
20+
* native mechanism — headless CI, or the gate is off), `start()` STILL returns a real id;
21+
* the block is simply a documented no-op (`isStarted` true, `stop` true, nothing native to
22+
* release). Callers never get -1.
23+
*/
24+
25+
/** Electron's two power-save-blocker types. */
26+
export type PowerSaveBlockerType = 'prevent-app-suspension' | 'prevent-display-sleep';
27+
28+
/** An opaque, platform-owned native blocker handle (a CFTypeRef id, a D-Bus cookie, …). */
29+
export type NativeBlocker = unknown;
30+
31+
/**
32+
* The native operations the registry drives — injectable so the id-bookkeeping is
33+
* unit-tested with a fake (no FFI). `acquire` returns null when no mechanism is available
34+
* (the block becomes a no-op); `release` is best-effort and never throws.
35+
*/
36+
export type PowerSaveBlockerBackend = {
37+
acquire: (type: PowerSaveBlockerType) => NativeBlocker | null;
38+
release: (handle: NativeBlocker) => void;
39+
};
40+
41+
/** A no-op backend (no native power management on this platform). */
42+
const noopBackend: PowerSaveBlockerBackend = {
43+
acquire: () => null,
44+
release: () => undefined,
45+
};
46+
47+
const platformBackend = (): PowerSaveBlockerBackend => {
48+
const platform = currentPlatform();
49+
if (platform === 'macos') {
50+
return cocoaPowerSaveBlockerBackend;
51+
}
52+
if (platform === 'linux') {
53+
return linuxPowerSaveBlockerBackend;
54+
}
55+
return noopBackend;
56+
};
57+
58+
type Entry = { readonly type: PowerSaveBlockerType; readonly nativeHandle: NativeBlocker | null };
59+
60+
export class PowerSaveBlockerImpl {
61+
readonly #backend: PowerSaveBlockerBackend;
62+
readonly #blockers = new Map<number, Entry>();
63+
#nextId = 1;
64+
65+
/** `backend` is injectable so the registry is unit-testable with a fake (no FFI). */
66+
constructor(backend: PowerSaveBlockerBackend = platformBackend()) {
67+
this.#backend = backend;
68+
}
69+
70+
/**
71+
* Start a power-save blocker of `type`. Returns the blocker id (ALWAYS a real id, even
72+
* when no native mechanism is available — the block is then a no-op). Ids are unique for
73+
* the process lifetime and never reused.
74+
*/
75+
start(type: PowerSaveBlockerType): number {
76+
const id = this.#nextId++;
77+
let nativeHandle: NativeBlocker | null = null;
78+
try {
79+
nativeHandle = this.#backend.acquire(type);
80+
} catch {
81+
nativeHandle = null; // acquire must never take down the caller; treat as a no-op.
82+
}
83+
this.#blockers.set(id, { type, nativeHandle });
84+
return id;
85+
}
86+
87+
/**
88+
* Stop the blocker with `id`, releasing the native handle. Returns true if `id` referred
89+
* to a live blocker (now stopped), false for an unknown/already-stopped id.
90+
*/
91+
stop(id: number): boolean {
92+
const entry = this.#blockers.get(id);
93+
if (entry === undefined) {
94+
return false;
95+
}
96+
this.#blockers.delete(id);
97+
if (entry.nativeHandle !== null) {
98+
try {
99+
this.#backend.release(entry.nativeHandle);
100+
} catch {
101+
// Best-effort release; the id is already forgotten.
102+
}
103+
}
104+
return true;
105+
}
106+
107+
/** Whether `id` refers to a currently-started blocker. */
108+
isStarted(id: number): boolean {
109+
return this.#blockers.has(id);
110+
}
111+
112+
/** Clear every blocker without releasing natively. Test-only. */
113+
resetForTesting(): void {
114+
this.#blockers.clear();
115+
this.#nextId = 1;
116+
}
117+
}
118+
119+
/** The power-save-blocker singleton. Drop-in equivalent of Electron's `powerSaveBlocker`. */
120+
export const powerSaveBlocker = new PowerSaveBlockerImpl();
121+
export type PowerSaveBlocker = PowerSaveBlockerImpl;

src/main/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ export {
2929
export { nativeTheme, type NativeTheme } from './api/native-theme';
3030
export { Notification, type NotificationOptions } from './api/notification';
3131
export { type PowerMonitor, powerMonitor } from './api/power-monitor';
32+
export { type PowerSaveBlocker, powerSaveBlocker } from './api/power-save-blocker';
3233
export {
3334
type ProtocolHandler,
3435
type ProtocolRequest,

src/main/module-list.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ export const IMPLEMENTED_MODULES = [
6969
'nativeTheme',
7070
'Notification',
7171
'powerMonitor',
72+
'powerSaveBlocker',
7273
'protocol',
7374
'safeStorage',
7475
'screen',

src/main/platform/linux/gdbus-ffi.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,13 @@ const LIBGIO_PATH = 'libgio-2.0.so.0';
3434

3535
/** `GBusType` enum (gio/gioenums.h): STARTER=-1, NONE=0, SYSTEM=1, SESSION=2. */
3636
export const G_BUS_TYPE_SYSTEM = 1;
37+
export const G_BUS_TYPE_SESSION = 2;
3738
/** `GDBusSignalFlags` — no special matching. */
3839
export const G_DBUS_SIGNAL_FLAGS_NONE = 0;
40+
/** `GDBusCallFlags` — default. */
41+
export const G_DBUS_CALL_FLAGS_NONE = 0;
42+
/** Bounded reply timeout (ms). NEVER `G_MAXINT` — a finite backstop against a peer that never answers. */
43+
export const DBUS_CALL_TIMEOUT_MS = 5000;
3944

4045
/**
4146
* ABI shape of `GDBusSignalCallback`:
@@ -77,6 +82,31 @@ export const GDBUS_FFI_SYMBOLS = {
7782
args: [FFIType.pointer, FFIType.u32],
7883
returns: FFIType.void,
7984
},
85+
// Bounded REMOTE method call. SAFE on the pumped thread: the reply is read by the
86+
// connection's PRIVATE GDBusWorker thread and call_sync awaits it on its OWN private
87+
// GMainContext (gdbusconnection.c), so it blocks only THIS thread for a bounded round
88+
// trip and never needs Sambar's pump to turn (unlike the clipboard local-pipe read). A
89+
// FINITE timeout_msec (NEVER G_MAXINT) is mandatory. The floating `parameters` GVariant
90+
// is consumed by the call; the reply tuple is transfer-full (caller g_variant_unref).
91+
// (connection, bus_name, object_path, interface_name, method_name, parameters:GVariant*,
92+
// reply_type:GVariantType*|null, flags:GDBusCallFlags, timeout_msec:gint,
93+
// cancellable|null, error:GError**|null) -> GVariant*
94+
g_dbus_connection_call_sync: {
95+
args: [
96+
FFIType.pointer,
97+
FFIType.cstring,
98+
FFIType.cstring,
99+
FFIType.cstring,
100+
FFIType.cstring,
101+
FFIType.pointer,
102+
FFIType.pointer,
103+
FFIType.u32,
104+
FFIType.i32,
105+
FFIType.pointer,
106+
FFIType.pointer,
107+
],
108+
returns: FFIType.pointer,
109+
},
80110
} as const;
81111

82112
const cache: { ffi: ReturnType<typeof dlopen<typeof GDBUS_FFI_SYMBOLS>> | undefined } = {

src/main/platform/linux/glib-ffi.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,27 @@ export const GLIB_FFI_SYMBOLS = {
107107
args: [FFIType.pointer],
108108
returns: FFIType.void,
109109
},
110+
// (value /*GVariant* 'u'*/) -> guint32. ABORTS on a non-u32 — guard with the type string.
111+
g_variant_get_uint32: {
112+
args: [FFIType.pointer],
113+
returns: FFIType.u32,
114+
},
115+
// (string) -> GVariant* 's' (FLOATING). Builds a D-Bus method arg.
116+
g_variant_new_string: {
117+
args: [FFIType.cstring],
118+
returns: FFIType.pointer,
119+
},
120+
// (value) -> GVariant* 'u' (FLOATING).
121+
g_variant_new_uint32: {
122+
args: [FFIType.u32],
123+
returns: FFIType.pointer,
124+
},
125+
// (children /*GVariant**/, n_children /*gsize*/) -> GVariant* tuple (FLOATING; SINKS each
126+
// child's floating ref). Explicit builder avoids the fragile varargs g_variant_new.
127+
g_variant_new_tuple: {
128+
args: [FFIType.pointer, FFIType.u64],
129+
returns: FFIType.pointer,
130+
},
110131
} as const;
111132

112133
const cache: { ffi: ReturnType<typeof dlopen<typeof GLIB_FFI_SYMBOLS>> | undefined } = {

src/main/platform/linux/linux-dbus.ts

Lines changed: 84 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
import { CString, JSCallback, type Pointer } from 'bun:ffi';
22
import { cstr } from '../cstr';
33
import {
4+
DBUS_CALL_TIMEOUT_MS,
45
DBUS_SIGNAL_CB_DEF,
6+
G_BUS_TYPE_SESSION,
57
G_BUS_TYPE_SYSTEM,
8+
G_DBUS_CALL_FLAGS_NONE,
69
G_DBUS_SIGNAL_FLAGS_NONE,
710
loadGDBusFFI,
811
} from './gdbus-ffi';
@@ -12,10 +15,14 @@ import {
1215
*
1316
* THE RULE (hard-won — a synchronous GIO read once hung CI for hours, see
1417
* gtk-clipboard.ts): never block Sambar's single pumped thread on a D-Bus reply that
15-
* only the GMainContext dispatch can deliver. So this module exposes ONLY subscription,
16-
* never a `*_call_sync`. A subscribed `GDBusSignalCallback` fires on the default
17-
* GMainContext during ordinary cooperative-pump iterations (gtk-run-loop.ts) — no thread
18-
* ever blocks for it.
18+
* only the GMainContext dispatch can deliver. Signal SUBSCRIPTION never blocks (the
19+
* `GDBusSignalCallback` fires on the default GMainContext during ordinary cooperative-pump
20+
* iterations, gtk-run-loop.ts). The one method-call helper, {@link callMethodSync}, uses a
21+
* BOUNDED `g_dbus_connection_call_sync` whose reply is read by the connection's PRIVATE
22+
* GDBusWorker thread and awaited on `call_sync`'s OWN private GMainContext — it stalls the
23+
* caller for at most {@link DBUS_CALL_TIMEOUT_MS} and NEVER needs our pump to turn, so it
24+
* is categorically unlike the clipboard's local-pipe read (whose reply could only come
25+
* from our pump). It is still gated off in CI.
1926
*
2027
* `getSystemBus()` is gated behind `SAMBAR_ENABLE_LINUX_POWER` (mirroring the libsecret
2128
* keyring gate): CI never sets it, so the bus is NEVER touched on the headless runner and
@@ -152,3 +159,76 @@ export const subscribeSignal = (
152159
export const resetSystemBusCacheForTesting = (): void => {
153160
cache.systemBus = undefined;
154161
};
162+
163+
// --- Session bus + bounded method call (powerSaveBlocker) -------------------------------
164+
165+
/**
166+
* Whether the live SESSION-bus method-call path is enabled. A SEPARATE flag from the
167+
* system-bus power flag, so a developer can enable read-only power-monitor signals without
168+
* enabling outbound blocker method calls (different bus, different risk surface). CI never
169+
* sets it → the session bus is never touched and blocker `acquire` is a no-op.
170+
*/
171+
const liveBlockerEnabled = (): boolean => process.env['SAMBAR_ENABLE_LINUX_POWER_BLOCKER'] === '1';
172+
173+
const sessionCache: { sessionBus: Pointer | null | undefined } = { sessionBus: undefined };
174+
175+
/** Call `g_bus_get_sync(SESSION)` directly, bypassing the gate. Never throws (see the system probe). */
176+
export const probeSessionBusUnchecked = (): Pointer | null => {
177+
const gdbus = loadGDBusFFI();
178+
try {
179+
return gdbus.symbols.g_bus_get_sync(G_BUS_TYPE_SESSION, null, null);
180+
} catch {
181+
return null;
182+
}
183+
};
184+
185+
/** The session `GDBusConnection*`, or null when the gate is off OR there is no bus. Cached. */
186+
export const getSessionBus = (): Pointer | null => {
187+
if (sessionCache.sessionBus !== undefined) {
188+
return sessionCache.sessionBus;
189+
}
190+
const conn = liveBlockerEnabled() ? probeSessionBusUnchecked() : null;
191+
sessionCache.sessionBus = conn;
192+
return conn;
193+
};
194+
195+
/**
196+
* A BOUNDED, deadlock-safe synchronous D-Bus method call. The reply is read by the
197+
* connection's private GDBusWorker thread and awaited on `call_sync`'s own private
198+
* GMainContext, so this blocks only the calling thread for the round-trip (finite
199+
* {@link DBUS_CALL_TIMEOUT_MS}), never the pump. Returns the transfer-FULL reply GVariant
200+
* tuple (caller `g_variant_unref`) or null on any failure (NULL GError**). `parameters`
201+
* (a floating GVariant, or null for no args) is CONSUMED by the call.
202+
*/
203+
export const callMethodSync = (
204+
conn: Pointer,
205+
busName: string,
206+
objectPath: string,
207+
iface: string,
208+
method: string,
209+
parameters: Pointer | null,
210+
): Pointer | null => {
211+
const gdbus = loadGDBusFFI();
212+
try {
213+
return gdbus.symbols.g_dbus_connection_call_sync(
214+
conn,
215+
cstr(busName),
216+
cstr(objectPath),
217+
cstr(iface),
218+
cstr(method),
219+
parameters,
220+
null, // reply_type
221+
G_DBUS_CALL_FLAGS_NONE,
222+
DBUS_CALL_TIMEOUT_MS,
223+
null, // cancellable
224+
null, // error (NULL return already means "failed")
225+
);
226+
} catch {
227+
return null;
228+
}
229+
};
230+
231+
/** Reset the session-bus probe cache. Test-only. */
232+
export const resetSessionBusCacheForTesting = (): void => {
233+
sessionCache.sessionBus = undefined;
234+
};

0 commit comments

Comments
 (0)