Files
makelore/electron/main/background-lifecycle.ts

112 lines
3.2 KiB
TypeScript

import { EventEmitter } from 'node:events';
export type DesktopModule = 'programming' | 'painting' | 'learning' | 'robot' | 'other';
export type DesktopActivity = {
visible: boolean;
module: DesktopModule | null;
};
/** Public contract names used by Main-owned lifecycle integrations. */
export type DesktopActivityState = DesktopActivity;
export type BackgroundLease = {
id: string;
kind: string;
};
export type ModuleLease = BackgroundLease;
export type BackgroundTaskLease = BackgroundLease;
export type BackgroundLifecycleOptions = {
idleStopMs?: number;
onSleep: () => void | Promise<void>;
onStopRuntime: () => void | Promise<void>;
};
/**
* Main-owned activity/lease state machine. A hidden or inactive window gets a
* grace period, while a lease held by an active generation, prompt or build
* prevents the runtime from being stopped underneath the work.
*/
export class BackgroundLifecycleController extends EventEmitter {
private readonly idleStopMs: number;
private readonly leases = new Map<string, BackgroundLease>();
private activity: DesktopActivity = { visible: true, module: null };
private idleTimer: ReturnType<typeof setTimeout> | null = null;
private stopping = false;
constructor(private readonly options: BackgroundLifecycleOptions) {
super();
this.idleStopMs = options.idleStopMs ?? 60_000;
}
getActivity(): DesktopActivity {
return { ...this.activity };
}
getLeaseCount(): number {
return this.leases.size;
}
setActivity(activity: DesktopActivity): void {
this.activity = {
visible: Boolean(activity.visible),
module: activity.module ?? null,
};
this.emit('activity', this.getActivity());
if (this.shouldSleep()) this.scheduleIdleStop();
else this.cancelIdleStop();
}
acquireLease(lease: BackgroundLease): () => void {
if (!lease.id.trim()) throw new Error('Background lease id is required');
this.leases.set(lease.id, { ...lease });
this.cancelIdleStop();
this.emit('lease-count', this.leases.size);
return () => this.releaseLease(lease.id);
}
releaseLease(id: string): void {
if (!this.leases.delete(id)) return;
this.emit('lease-count', this.leases.size);
if (this.shouldSleep()) this.scheduleIdleStop();
}
dispose(): void {
this.cancelIdleStop();
this.leases.clear();
this.removeAllListeners();
}
private shouldSleep(): boolean {
return !this.activity.visible || this.activity.module !== 'programming';
}
private cancelIdleStop(): void {
if (this.idleTimer !== null) clearTimeout(this.idleTimer);
this.idleTimer = null;
}
private scheduleIdleStop(): void {
if (this.idleTimer !== null || this.stopping) return;
this.idleTimer = setTimeout(() => {
this.idleTimer = null;
void this.sleepIfIdle();
}, this.idleStopMs);
}
private async sleepIfIdle(): Promise<void> {
if (!this.shouldSleep() || this.leases.size > 0 || this.stopping) return;
this.stopping = true;
try {
await this.options.onSleep();
await this.options.onStopRuntime();
this.emit('sleep');
} finally {
this.stopping = false;
if (this.shouldSleep() && this.leases.size === 0) this.scheduleIdleStop();
}
}
}