mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-17 16:37:43 +00:00
* fix(electron): retry wayland idle helper startup * build(electron): enforce wayland helper in CI * fix(electron): avoid blocking wayland idle promotion * test(e2e): make reminder option selector exact * test(e2e): resolve reminder option selector conflict * fix(electron): gate wayland helper redetection * fix(electron): avoid stale Wayland helper packaging --------- Co-authored-by: Johannes Millan <johannes.millan@gmail.com>
590 lines
17 KiB
TypeScript
590 lines
17 KiB
TypeScript
import { ChildProcessWithoutNullStreams, exec, spawn } from 'child_process';
|
|
import { existsSync } from 'fs';
|
|
import path from 'path';
|
|
import { createInterface } from 'readline';
|
|
import { promisify } from 'util';
|
|
import { app, powerMonitor } from 'electron';
|
|
import electronLog from 'electron-log/main';
|
|
import { CONFIG } from './CONFIG';
|
|
|
|
const execAsync = promisify(exec);
|
|
const log = electronLog.scope('IdleTimeHandler');
|
|
const WAYLAND_IDLE_HELPER_READY_TIMEOUT_MS = 3000;
|
|
const WAYLAND_IDLE_HELPER_KILL_TIMEOUT_MS = 500;
|
|
const WAYLAND_IDLE_HELPER_FAILURE_THRESHOLD = 3;
|
|
const WAYLAND_IDLE_NOTIFY_RETRY_INTERVAL_MS = 60_000;
|
|
const WAYLAND_IDLE_NOTIFY_DEMOTION_COOLDOWN_MS = 5 * 60_000;
|
|
|
|
type IdleDetectionMethod =
|
|
| 'powerMonitor'
|
|
| 'gnomeDBus'
|
|
| 'waylandIdleNotify'
|
|
| 'xprintidle'
|
|
| 'loginctl'
|
|
| 'none';
|
|
|
|
interface EnvironmentInfo {
|
|
readonly isWayland: boolean;
|
|
readonly isGnomeWayland: boolean;
|
|
readonly isSnap: boolean;
|
|
readonly sessionType?: string;
|
|
readonly currentDesktop?: string;
|
|
readonly waylandDisplay?: string;
|
|
readonly gnomeShellSession?: string;
|
|
}
|
|
|
|
interface IdleMethodCandidate {
|
|
readonly name: IdleDetectionMethod;
|
|
readonly test: () => Promise<boolean>;
|
|
}
|
|
|
|
export class IdleTimeHandler {
|
|
private readonly _environment: EnvironmentInfo;
|
|
private _methodDetectionPromise: Promise<IdleDetectionMethod> | null = null;
|
|
private _workingMethod: IdleDetectionMethod = 'none';
|
|
private _waylandIdleHelperProcess: ChildProcessWithoutNullStreams | null = null;
|
|
private _waylandIdleHelperReady: boolean = false;
|
|
private _waylandIdleHelperReadyPromise: Promise<boolean> | null = null;
|
|
private _waylandIdleSinceMs: number | null = null;
|
|
private _waylandIdleHelperFailureCount: number = 0;
|
|
private _waylandIdleNotifyPromotionPromise: Promise<void> | null = null;
|
|
private _lastWaylandIdleNotifyRetryMs: number = 0;
|
|
private _lastWaylandIdleNotifyDemotionMs: number = 0;
|
|
private readonly _waylandHelperTimeoutMs = CONFIG.MIN_IDLE_TIME;
|
|
private readonly _resetWaylandIdleAfterResume = (): void => {
|
|
this._waylandIdleSinceMs = null;
|
|
};
|
|
|
|
constructor() {
|
|
this._environment = this._detectEnvironment();
|
|
this._methodDetectionPromise = this._initializeWorkingMethod();
|
|
powerMonitor.on('resume', this._resetWaylandIdleAfterResume);
|
|
}
|
|
|
|
private _detectEnvironment(): EnvironmentInfo {
|
|
const sessionType = process.env.XDG_SESSION_TYPE;
|
|
const currentDesktop = process.env.XDG_CURRENT_DESKTOP;
|
|
const waylandDisplay = process.env.WAYLAND_DISPLAY;
|
|
const gnomeShellSession = process.env.GNOME_SHELL_SESSION_MODE;
|
|
|
|
const isWayland = sessionType === 'wayland' || !!waylandDisplay;
|
|
const normalizedDesktop = currentDesktop?.toLowerCase() ?? '';
|
|
const isGnomeDesktop =
|
|
normalizedDesktop.includes('gnome') ||
|
|
normalizedDesktop.includes('ubuntu') ||
|
|
!!gnomeShellSession;
|
|
const isGnomeWayland = isWayland && isGnomeDesktop;
|
|
const isSnap = !!process.env.SNAP || !!process.env.SNAP_NAME;
|
|
|
|
const environment: EnvironmentInfo = {
|
|
isWayland,
|
|
isGnomeWayland,
|
|
isSnap,
|
|
sessionType,
|
|
currentDesktop,
|
|
waylandDisplay,
|
|
gnomeShellSession,
|
|
};
|
|
|
|
log.debug('Environment detection:', environment);
|
|
return environment;
|
|
}
|
|
|
|
get currentMethod(): IdleDetectionMethod {
|
|
return this._workingMethod;
|
|
}
|
|
|
|
dispose(): void {
|
|
powerMonitor.off('resume', this._resetWaylandIdleAfterResume);
|
|
|
|
const child = this._waylandIdleHelperProcess;
|
|
if (child) {
|
|
this._stopWaylandIdleHelperProcess(child);
|
|
}
|
|
this._resetWaylandIdleHelperState();
|
|
}
|
|
|
|
async getIdleTime(): Promise<number> {
|
|
const methodUsed = await this._ensureWorkingMethod();
|
|
this._maybePromoteToWaylandIdleNotify(methodUsed);
|
|
|
|
switch (methodUsed) {
|
|
case 'powerMonitor':
|
|
try {
|
|
return powerMonitor.getSystemIdleTime() * 1000;
|
|
} catch (error) {
|
|
this._logError('powerMonitor failed', error);
|
|
return 0;
|
|
}
|
|
|
|
case 'gnomeDBus':
|
|
try {
|
|
const result = await this._getGnomeIdleTime();
|
|
return result ?? 0;
|
|
} catch (error) {
|
|
this._logError('GNOME DBus error', error);
|
|
return 0;
|
|
}
|
|
|
|
case 'waylandIdleNotify':
|
|
try {
|
|
const isReady = await this._ensureWaylandIdleHelperStarted();
|
|
if (!isReady || this._waylandIdleSinceMs === null) {
|
|
if (!isReady) {
|
|
this._handleWaylandIdleHelperFailure();
|
|
}
|
|
return 0;
|
|
}
|
|
this._waylandIdleHelperFailureCount = 0;
|
|
return Math.max(0, Date.now() - this._waylandIdleSinceMs);
|
|
} catch (error) {
|
|
this._logError('Wayland idle-notify helper error', error);
|
|
this._handleWaylandIdleHelperFailure();
|
|
return 0;
|
|
}
|
|
|
|
case 'xprintidle':
|
|
try {
|
|
const result = await this._getXprintidleTime();
|
|
return result ?? 0;
|
|
} catch (error) {
|
|
this._logError('xprintidle error', error);
|
|
return 0;
|
|
}
|
|
|
|
case 'loginctl':
|
|
try {
|
|
const result = await this._getLoginctlIdleTime();
|
|
return result ?? 0;
|
|
} catch (error) {
|
|
this._logError('loginctl error', error);
|
|
return 0;
|
|
}
|
|
|
|
case 'none':
|
|
default:
|
|
this._logError('No working idle detection method available', undefined);
|
|
return 0;
|
|
}
|
|
}
|
|
|
|
private async _initializeWorkingMethod(): Promise<IdleDetectionMethod> {
|
|
try {
|
|
const method = await this._determineWorkingMethod();
|
|
this._workingMethod = method;
|
|
log.info(`Idle detection method ready: ${method}`);
|
|
return method;
|
|
} catch (error) {
|
|
log.warn('Idle detection initialization failed', error);
|
|
this._workingMethod = 'none';
|
|
return 'none';
|
|
}
|
|
}
|
|
|
|
private async _ensureWorkingMethod(): Promise<IdleDetectionMethod> {
|
|
if (!this._methodDetectionPromise) {
|
|
this._methodDetectionPromise = this._initializeWorkingMethod();
|
|
}
|
|
return this._methodDetectionPromise;
|
|
}
|
|
|
|
private _maybePromoteToWaylandIdleNotify(currentMethod: IdleDetectionMethod): void {
|
|
if (
|
|
!this._environment.isWayland ||
|
|
currentMethod === 'gnomeDBus' ||
|
|
currentMethod === 'waylandIdleNotify' ||
|
|
this._waylandIdleNotifyPromotionPromise
|
|
) {
|
|
return;
|
|
}
|
|
|
|
const now = Date.now();
|
|
if (this._isWaylandIdleNotifyDemotionCoolingDown(now)) {
|
|
return;
|
|
}
|
|
|
|
if (
|
|
this._lastWaylandIdleNotifyRetryMs &&
|
|
now - this._lastWaylandIdleNotifyRetryMs < WAYLAND_IDLE_NOTIFY_RETRY_INTERVAL_MS
|
|
) {
|
|
return;
|
|
}
|
|
|
|
// Stamp before the async helper-ready attempt so repeated idle polls do not
|
|
// start duplicate helper processes while the up-to-3s readiness check runs.
|
|
this._lastWaylandIdleNotifyRetryMs = now;
|
|
void this._tryPromoteToWaylandIdleNotify();
|
|
}
|
|
|
|
private _isWaylandIdleNotifyDemotionCoolingDown(now = Date.now()): boolean {
|
|
return (
|
|
!!this._lastWaylandIdleNotifyDemotionMs &&
|
|
now - this._lastWaylandIdleNotifyDemotionMs <
|
|
WAYLAND_IDLE_NOTIFY_DEMOTION_COOLDOWN_MS
|
|
);
|
|
}
|
|
|
|
private async _tryPromoteToWaylandIdleNotify(): Promise<void> {
|
|
this._waylandIdleNotifyPromotionPromise = this._ensureWaylandIdleHelperStarted()
|
|
.then((works) => {
|
|
if (!works) {
|
|
return;
|
|
}
|
|
|
|
log.info('Promoted idle detection method to waylandIdleNotify');
|
|
this._workingMethod = 'waylandIdleNotify';
|
|
this._methodDetectionPromise = Promise.resolve('waylandIdleNotify');
|
|
this._lastWaylandIdleNotifyDemotionMs = 0;
|
|
})
|
|
.catch((error: unknown) => {
|
|
log.debug('Wayland idle-notify promotion failed', error);
|
|
})
|
|
.finally(() => {
|
|
this._waylandIdleNotifyPromotionPromise = null;
|
|
});
|
|
|
|
await this._waylandIdleNotifyPromotionPromise;
|
|
}
|
|
|
|
private async _determineWorkingMethod(): Promise<IdleDetectionMethod> {
|
|
log.debug('Determining idle detection method...');
|
|
|
|
if (!this._environment.isWayland) {
|
|
log.debug('Using powerMonitor for non-Wayland session');
|
|
return 'powerMonitor';
|
|
}
|
|
|
|
for (const candidate of this._buildWaylandCandidates()) {
|
|
log.debug(`Testing ${candidate.name}...`);
|
|
try {
|
|
const works = await candidate.test();
|
|
if (works) {
|
|
log.info(`Selected ${candidate.name} for idle detection`);
|
|
return candidate.name;
|
|
}
|
|
log.debug(`${candidate.name} test failed`);
|
|
} catch (error) {
|
|
log.warn(`${candidate.name} test error`, error);
|
|
}
|
|
}
|
|
|
|
log.warn(
|
|
'No working idle detection method found for Wayland. Idle detection will be disabled.',
|
|
);
|
|
return 'none';
|
|
}
|
|
|
|
private _buildWaylandCandidates(): IdleMethodCandidate[] {
|
|
const candidates: IdleMethodCandidate[] = [];
|
|
|
|
if (this._environment.isGnomeWayland) {
|
|
candidates.push({
|
|
name: 'gnomeDBus',
|
|
test: async () => {
|
|
try {
|
|
const idleTime = await this._getGnomeIdleTime();
|
|
return idleTime !== null;
|
|
} catch (error) {
|
|
if (this._environment.isSnap) {
|
|
log.debug('GNOME DBus test failed in snap environment', error);
|
|
}
|
|
return false;
|
|
}
|
|
},
|
|
});
|
|
}
|
|
|
|
if (this._isWaylandIdleNotifyDemotionCoolingDown()) {
|
|
log.debug('Skipping waylandIdleNotify during demotion cooldown');
|
|
} else {
|
|
candidates.push({
|
|
name: 'waylandIdleNotify',
|
|
test: async () => this._ensureWaylandIdleHelperStarted(),
|
|
});
|
|
}
|
|
|
|
candidates.push({
|
|
name: 'xprintidle',
|
|
test: async () => {
|
|
try {
|
|
const idleTime = await this._getXprintidleTime();
|
|
return idleTime !== null;
|
|
} catch {
|
|
return false;
|
|
}
|
|
},
|
|
});
|
|
|
|
if (this._environment.isSnap) {
|
|
log.debug('Skipping loginctl in snap environment');
|
|
} else {
|
|
candidates.push({
|
|
name: 'loginctl',
|
|
test: async () => {
|
|
try {
|
|
const idleTime = await this._getLoginctlIdleTime();
|
|
return idleTime !== null;
|
|
} catch {
|
|
return false;
|
|
}
|
|
},
|
|
});
|
|
}
|
|
|
|
return candidates;
|
|
}
|
|
|
|
private async _getGnomeIdleTime(): Promise<number | null> {
|
|
const isSnap = this._environment.isSnap;
|
|
if (isSnap) {
|
|
log.debug('Attempting GNOME idle detection inside snap environment');
|
|
}
|
|
|
|
let command =
|
|
'gdbus call --session --dest org.gnome.Mutter.IdleMonitor --object-path /org/gnome/Mutter/IdleMonitor/Core --method org.gnome.Mutter.IdleMonitor.GetIdletime';
|
|
|
|
try {
|
|
await execAsync('which gdbus', { timeout: 1000 });
|
|
} catch {
|
|
if (isSnap) {
|
|
log.warn(
|
|
'gdbus unavailable in snap environment, skipping dbus-send fallback to avoid libdbus mismatch',
|
|
);
|
|
return null;
|
|
}
|
|
command =
|
|
'dbus-send --print-reply --dest=org.gnome.Mutter.IdleMonitor /org/gnome/Mutter/IdleMonitor/Core org.gnome.Mutter.IdleMonitor.GetIdletime';
|
|
}
|
|
|
|
let stdout: string;
|
|
try {
|
|
const result = await execAsync(command, { timeout: 5000 });
|
|
stdout = result.stdout;
|
|
} catch (error) {
|
|
if (isSnap) {
|
|
log.warn('gdbus idle monitor failed in snap environment', error);
|
|
return null;
|
|
}
|
|
throw error;
|
|
}
|
|
|
|
const match = stdout.match(/uint64\s+(\d+)|(?:\(uint64\s+)?(\d+)(?:,\))?/);
|
|
if (match) {
|
|
const idleMs = parseInt(match[1] || match[2], 10);
|
|
if (idleMs >= 0 && idleMs < Number.MAX_SAFE_INTEGER) {
|
|
return idleMs;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private async _ensureWaylandIdleHelperStarted(): Promise<boolean> {
|
|
if (this._waylandIdleHelperReady && this._waylandIdleHelperProcess) {
|
|
return true;
|
|
}
|
|
|
|
if (this._waylandIdleHelperReadyPromise) {
|
|
return this._waylandIdleHelperReadyPromise;
|
|
}
|
|
|
|
const helperPath = this._resolveWaylandIdleHelperPath();
|
|
if (!helperPath) {
|
|
return false;
|
|
}
|
|
|
|
this._waylandIdleHelperReadyPromise = new Promise<boolean>((resolve) => {
|
|
let isSettled = false;
|
|
const settle = (value: boolean): void => {
|
|
if (isSettled) {
|
|
return;
|
|
}
|
|
isSettled = true;
|
|
resolve(value);
|
|
};
|
|
|
|
const child = spawn(
|
|
helperPath,
|
|
['--timeout-ms', String(this._waylandHelperTimeoutMs)],
|
|
{
|
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
},
|
|
);
|
|
child.unref();
|
|
|
|
this._waylandIdleHelperProcess = child;
|
|
this._waylandIdleHelperReady = false;
|
|
this._waylandIdleSinceMs = null;
|
|
|
|
const readyTimeout = setTimeout(() => {
|
|
log.warn('Wayland idle helper did not become ready in time');
|
|
this._stopWaylandIdleHelperProcess(child);
|
|
if (this._waylandIdleHelperProcess === child) {
|
|
this._resetWaylandIdleHelperState();
|
|
}
|
|
settle(false);
|
|
}, WAYLAND_IDLE_HELPER_READY_TIMEOUT_MS);
|
|
|
|
const stdout = createInterface({ input: child.stdout });
|
|
const stderr = createInterface({ input: child.stderr });
|
|
let isOutputClosed = false;
|
|
const closeOutput = (): void => {
|
|
if (isOutputClosed) {
|
|
return;
|
|
}
|
|
isOutputClosed = true;
|
|
stdout.close();
|
|
stderr.close();
|
|
};
|
|
|
|
stdout.on('line', (line) => {
|
|
const message = line.trim();
|
|
if (!message) {
|
|
return;
|
|
}
|
|
|
|
if (message === 'ready') {
|
|
clearTimeout(readyTimeout);
|
|
this._waylandIdleHelperReady = true;
|
|
this._waylandIdleHelperFailureCount = 0;
|
|
settle(true);
|
|
return;
|
|
}
|
|
|
|
if (message === 'idle') {
|
|
this._waylandIdleSinceMs = Date.now() - this._waylandHelperTimeoutMs;
|
|
return;
|
|
}
|
|
|
|
if (message === 'resumed') {
|
|
this._waylandIdleSinceMs = null;
|
|
return;
|
|
}
|
|
|
|
log.debug(`Wayland idle helper sent unexpected message: ${message}`);
|
|
});
|
|
|
|
stderr.on('line', (line) => {
|
|
log.debug(`Wayland idle helper stderr: ${line}`);
|
|
});
|
|
|
|
child.on('error', (error) => {
|
|
clearTimeout(readyTimeout);
|
|
closeOutput();
|
|
log.warn('Failed to start Wayland idle helper', error);
|
|
if (this._waylandIdleHelperProcess === child) {
|
|
this._resetWaylandIdleHelperState();
|
|
}
|
|
settle(false);
|
|
});
|
|
|
|
child.on('exit', (code, signal) => {
|
|
clearTimeout(readyTimeout);
|
|
closeOutput();
|
|
if (this._waylandIdleHelperProcess !== child) {
|
|
settle(false);
|
|
return;
|
|
}
|
|
if (this._waylandIdleHelperReady) {
|
|
log.warn(
|
|
`Wayland idle helper exited (code=${code ?? 'null'}, signal=${signal ?? 'null'})`,
|
|
);
|
|
}
|
|
this._resetWaylandIdleHelperState();
|
|
settle(false);
|
|
});
|
|
}).finally(() => {
|
|
this._waylandIdleHelperReadyPromise = null;
|
|
});
|
|
|
|
return this._waylandIdleHelperReadyPromise;
|
|
}
|
|
|
|
private _resetWaylandIdleHelperState(): void {
|
|
this._waylandIdleHelperProcess = null;
|
|
this._waylandIdleHelperReady = false;
|
|
this._waylandIdleSinceMs = null;
|
|
}
|
|
|
|
private _stopWaylandIdleHelperProcess(child: ChildProcessWithoutNullStreams): void {
|
|
if (child.exitCode !== null || child.signalCode !== null) {
|
|
return;
|
|
}
|
|
|
|
let didExit = false;
|
|
child.once('exit', () => {
|
|
didExit = true;
|
|
});
|
|
child.kill('SIGTERM');
|
|
|
|
const killTimer = setTimeout(() => {
|
|
if (!didExit && child.exitCode === null && child.signalCode === null) {
|
|
child.kill('SIGKILL');
|
|
}
|
|
}, WAYLAND_IDLE_HELPER_KILL_TIMEOUT_MS);
|
|
killTimer.unref();
|
|
}
|
|
|
|
private _handleWaylandIdleHelperFailure(): void {
|
|
this._waylandIdleHelperFailureCount += 1;
|
|
if (this._waylandIdleHelperFailureCount < WAYLAND_IDLE_HELPER_FAILURE_THRESHOLD) {
|
|
return;
|
|
}
|
|
|
|
log.warn('Wayland idle helper failed repeatedly; re-detecting idle method');
|
|
this._waylandIdleHelperFailureCount = 0;
|
|
this._lastWaylandIdleNotifyDemotionMs = Date.now();
|
|
this._workingMethod = 'none';
|
|
this._methodDetectionPromise = null;
|
|
}
|
|
|
|
private _resolveWaylandIdleHelperPath(): string | null {
|
|
const helperPath = app.isPackaged
|
|
? path.join(path.dirname(process.execPath), 'wayland-idle-helper')
|
|
: path.join(__dirname, 'bin', 'wayland-idle-helper');
|
|
|
|
if (!existsSync(helperPath)) {
|
|
log.debug(`Wayland idle helper binary not found at ${helperPath}`);
|
|
return null;
|
|
}
|
|
|
|
return helperPath;
|
|
}
|
|
|
|
private async _getLoginctlIdleTime(): Promise<number | null> {
|
|
const { stdout } = await execAsync('loginctl show-session -p IdleSinceHint', {
|
|
timeout: 3000,
|
|
});
|
|
|
|
const match = stdout.match(/IdleSinceHint=(\d+)/);
|
|
if (match && match[1]) {
|
|
const idleSince = parseInt(match[1], 10);
|
|
if (idleSince === 0) {
|
|
return 0;
|
|
}
|
|
if (idleSince > 0) {
|
|
const nowMs = Date.now() * 1000;
|
|
const idleMs = nowMs - idleSince;
|
|
if (idleMs >= 0 && idleMs < Number.MAX_SAFE_INTEGER) {
|
|
return Math.floor(idleMs / 1000);
|
|
}
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private async _getXprintidleTime(): Promise<number | null> {
|
|
const { stdout } = await execAsync('xprintidle', { timeout: 3000 });
|
|
const idleMs = parseInt(stdout.trim(), 10);
|
|
if (!isNaN(idleMs) && idleMs >= 0) {
|
|
return idleMs;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private _logError(context: string, error: unknown): void {
|
|
log.debug(`${context} (falling back to 0):`, error);
|
|
}
|
|
}
|