113 lines
3.2 KiB
TypeScript
113 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();
|
|
if (!this.shouldSleep() || this.leases.size > 0) return;
|
|
await this.options.onStopRuntime();
|
|
this.emit('sleep');
|
|
} finally {
|
|
this.stopping = false;
|
|
if (this.shouldSleep() && this.leases.size === 0) this.scheduleIdleStop();
|
|
}
|
|
}
|
|
}
|