1// @effect-diagnostics globalTimers:off -- This protocol broker owns cancellable request deadlines outside the Effect runtime.
2import {
3 DESKTOP_APP_ACTIVATION_PROTOCOL_VERSION,
4 type DesktopAppActivationFailure,
5 type DesktopAppActivationRequest,
6 type DesktopAppActivationResponse,
7} from "@t3tools/contracts";
8
9interface PendingActivation {
10 readonly request: DesktopAppActivationRequest;
11 readonly resolve: (response: DesktopAppActivationResponse) => void;
12 readonly timeout: ReturnType<typeof setTimeout>;
13 dispatched: boolean;
14}
15
16type RendererSender = (request: DesktopAppActivationRequest) => void;
17
18function failure(
19 requestId: string,
20 code: DesktopAppActivationFailure["code"],
21 message: string,
22): DesktopAppActivationFailure {
23 return {
24 version: DESKTOP_APP_ACTIVATION_PROTOCOL_VERSION,
25 requestId,
26 ok: false,
27 code,
28 message,
29 };
30}
31
32/** Holds CLI requests until the real desktop renderer is ready to handle them. */
33export class DesktopAppActivationBroker {
34 readonly #pending = new Map<string, PendingActivation>();
35 readonly #requestTimeoutMs: number;
36 readonly #activate: () => void;
37 #renderer: RendererSender | null = null;
38 #closed = false;
39
40 constructor(input: { readonly requestTimeoutMs: number; readonly activate: () => void }) {
41 this.#requestTimeoutMs = input.requestTimeoutMs;
42 this.#activate = input.activate;
43 }
44
45 request(request: DesktopAppActivationRequest): Promise<DesktopAppActivationResponse> {
46 if (this.#closed) {
47 return Promise.resolve(
48 failure(request.requestId, "renderer-unavailable", "The Knife is shutting down."),
49 );
50 }
51 if (this.#pending.has(request.requestId)) {
52 return Promise.resolve(
53 failure(request.requestId, "invalid-request", "The request id is already in use."),
54 );
55 }
56
57 const response = new Promise<DesktopAppActivationResponse>((resolve) => {
58 const timeout = setTimeout(() => {
59 this.#settle(
60 failure(
61 request.requestId,
62 "request-timeout",
63 "The desktop app did not finish opening the project in time.",
64 ),
65 );
66 }, this.#requestTimeoutMs);
67 this.#pending.set(request.requestId, {
68 request,
69 resolve,
70 timeout,
71 dispatched: false,
72 });
73 });
74
75 this.#activate();
76 this.#flush();
77 return response;
78 }
79
80 registerRenderer(send: RendererSender): void {
81 this.#renderer = send;
82 this.#flush();
83 }
84
85 clearRenderer(): void {
86 this.#renderer = null;
87 for (const pending of this.#pending.values()) {
88 if (pending.dispatched) {
89 this.#settle(
90 failure(
91 pending.request.requestId,
92 "renderer-unavailable",
93 "The Knife closed before opening the project.",
94 ),
95 );
96 }
97 }
98 }
99
100 complete(response: DesktopAppActivationResponse): void {
101 this.#settle(response);
102 }
103
104 cancel(requestId: string): void {
105 this.#settle(
106 failure(requestId, "renderer-unavailable", "The command closed before The Knife was ready."),
107 );
108 }
109
110 close(): void {
111 this.#closed = true;
112 this.#renderer = null;
113 for (const pending of this.#pending.values()) {
114 this.#settle(
115 failure(pending.request.requestId, "renderer-unavailable", "The Knife is shutting down."),
116 );
117 }
118 }
119
120 #flush(): void {
121 const renderer = this.#renderer;
122 if (renderer === null) return;
123 if ([...this.#pending.values()].some((pending) => pending.dispatched)) return;
124
125 for (const pending of this.#pending.values()) {
126 if (pending.dispatched) continue;
127 try {
128 pending.dispatched = true;
129 renderer(pending.request);
130 } catch {
131 pending.dispatched = false;
132 this.#renderer = null;
133 }
134 return;
135 }
136 }
137
138 #settle(response: DesktopAppActivationResponse): void {
139 const pending = this.#pending.get(response.requestId);
140 if (!pending) return;
141 clearTimeout(pending.timeout);
142 this.#pending.delete(response.requestId);
143 pending.resolve(response);
144 this.#flush();
145 }
146}