eigent/electron/main/rendererAppCommandCoordinator.ts
Douglas Lai 8f4f575db9
feat: add native menus, shortcuts, and window chrome (#1858)
Co-authored-by: 4pmtong <web_chentong@163.com>
2026-08-21 20:39:55 +08:00

156 lines
4.9 KiB
TypeScript

// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
import crypto from 'node:crypto';
import {
APP_COMMAND,
type AppCommandHandled,
type AppCommandId,
type AppCommandRequest,
} from '../../src/shared/appCommands';
const NAVIGATION_COMMANDS = new Set<AppCommandId>([
APP_COMMAND.navigateHome,
APP_COMMAND.navigateWorkspace,
APP_COMMAND.navigateFiles,
APP_COMMAND.navigateScheduled,
APP_COMMAND.navigateDispatch,
APP_COMMAND.navigateConfiguration,
]);
interface QueuedCommand {
commandId: AppCommandId;
requestId: string;
}
export interface RendererAppCommandCoordinatorOptions {
send: (request: AppCommandRequest) => void;
createRequestId?: () => string;
maxQueuedCommands?: number;
diagnostic?: (message: string) => void;
}
/**
* Owns the main-to-renderer command lane for one app shell. Commands issued
* before the current renderer explicitly announces readiness are bounded and
* flushed in order. Renderer reloads invalidate the epoch, so receipts from an
* old document can never acknowledge a command sent to the new one.
*/
export class RendererAppCommandCoordinator {
private readonly send: (request: AppCommandRequest) => void;
private readonly createRequestId: () => string;
private readonly maxQueuedCommands: number;
private readonly diagnostic: (message: string) => void;
private epoch: string | null = null;
private queued: QueuedCommand[] = [];
private inFlight = new Map<string, AppCommandRequest>();
constructor({
send,
createRequestId = () => crypto.randomUUID(),
maxQueuedCommands = 100,
diagnostic = () => undefined,
}: RendererAppCommandCoordinatorOptions) {
this.send = send;
this.createRequestId = createRequestId;
this.maxQueuedCommands = Math.max(1, maxQueuedCommands);
this.diagnostic = diagnostic;
}
private enqueue(command: QueuedCommand): void {
if (NAVIGATION_COMMANDS.has(command.commandId)) {
this.queued = this.queued.filter(
(queued) => !NAVIGATION_COMMANDS.has(queued.commandId)
);
}
this.queued.push(command);
if (this.queued.length > this.maxQueuedCommands) {
const dropped = this.queued.shift();
this.diagnostic(
`[APP COMMAND] Dropped oldest queued command ${dropped?.commandId ?? 'unknown'} after reaching ${this.maxQueuedCommands}`
);
}
}
private sendCommand(command: QueuedCommand): void {
const epoch = this.epoch;
if (!epoch) {
this.enqueue(command);
return;
}
const request: AppCommandRequest = { ...command, epoch };
try {
this.send(request);
this.inFlight.set(request.requestId, request);
} catch (error) {
this.diagnostic(
`[APP COMMAND] Send failed for ${command.commandId}: ${String(error)}`
);
this.markNotReady('send-failed');
this.enqueue(command);
}
}
dispatch(commandId: AppCommandId): string {
const command = { commandId, requestId: this.createRequestId() };
this.sendCommand(command);
return command.requestId;
}
markReady(epoch: string): void {
if (!epoch) return;
if (this.epoch && this.epoch !== epoch && this.inFlight.size > 0) {
this.diagnostic(
`[APP COMMAND] Renderer epoch changed with ${this.inFlight.size} unacknowledged command(s)`
);
this.inFlight.clear();
}
this.epoch = epoch;
const queued = this.queued;
this.queued = [];
for (const command of queued) this.sendCommand(command);
}
markNotReady(reason: string, expectedEpoch?: string): boolean {
if (expectedEpoch && expectedEpoch !== this.epoch) return false;
if (this.inFlight.size > 0) {
this.diagnostic(
`[APP COMMAND] Renderer became unavailable (${reason}) with ${this.inFlight.size} unacknowledged command(s)`
);
}
this.epoch = null;
this.inFlight.clear();
return true;
}
handleReceipt(receipt: AppCommandHandled): boolean {
if (!this.epoch || receipt.epoch !== this.epoch) return false;
const request = this.inFlight.get(receipt.requestId);
if (!request || request.commandId !== receipt.commandId) return false;
this.inFlight.delete(receipt.requestId);
return true;
}
isReady(): boolean {
return this.epoch !== null;
}
getEpoch(): string | null {
return this.epoch;
}
getQueuedCount(): number {
return this.queued.length;
}
}