Skip to content
Source
Sign inSign up
Open History to see the last commit

PortalCaptureShortcut.ts

462 lines15.0 KB

1// @effect-diagnostics nodeBuiltinImport:off -- Stable shortcut IDs and unique portal request tokens.
2// @effect-diagnostics globalTimers:off -- Bounded D-Bus calls and user-consent requests at the native boundary.
3import * as NodeCrypto from "node:crypto";
4import {
5 DBusError,
6 Message,
7 MessageFlag,
8 MessageType,
9 Variant,
10 sessionBus,
11 type MessageBus,
12 type MessageLike,
13} from "dbus-next";
14import * as Schema from "effect/Schema";
15import type { SnapShotKeyChord } from "@t3tools/contracts";
16import { HYPRLAND_CAPTURE_ACTION, portalShortcutTrigger } from "./linuxCaptureSession.ts";
17export { portalShortcutTrigger } from "./linuxCaptureSession.ts";
18​
19const PORTAL = "org.freedesktop.portal.Desktop";
20const PATH = "/org/freedesktop/portal/desktop";
21const SHORTCUTS = "org.freedesktop.portal.GlobalShortcuts";
22const REQUEST = "org.freedesktop.portal.Request";
23const SESSION = "org.freedesktop.portal.Session";
24const DBUS = "org.freedesktop.DBus";
25const string = Schema.decodeUnknownSync(Schema.String);
26const StringVariant = Schema.Struct({ signature: Schema.Literal("s"), value: Schema.String });
27const Shortcuts = Schema.Array(
28 Schema.Tuple([
29 Schema.String,
30 Schema.Struct({
31 description: Schema.optional(StringVariant),
32 trigger_description: Schema.optional(StringVariant),
33 }),
34 ]),
35);
36const decodeShortcuts = Schema.decodeUnknownSync(Shortcuts);
37const decodeShortcutsResult = Schema.decodeUnknownSync(
38 Schema.Struct({
39 shortcuts: Schema.Struct({ signature: Schema.Literal("a(sa{sv})"), value: Shortcuts }),
40 }),
41);
42const decodeSession = Schema.decodeUnknownSync(Schema.Struct({ session_handle: StringVariant }));
43const decodeResponse = Schema.decodeUnknownSync(
44 Schema.Tuple([Schema.Int, Schema.Record(Schema.String, Schema.Unknown)]),
45);
46const decodeVersion = Schema.decodeUnknownSync(
47 Schema.Struct({
48 signature: Schema.Literal("u"),
49 value: Schema.Int,
50 }),
51);
52​
53export interface PortalShortcutState {
54 readonly shortcutActionRegistered?: boolean;
55 readonly shortcutRegistered: boolean;
56 readonly shortcutPending: boolean;
57 readonly shortcutCanRetry?: boolean;
58 readonly shortcutLabel?: string;
59 readonly shortcutMessage: string | null;
61​
62export class PortalCaptureShortcut {
63 state: PortalShortcutState = {
64 shortcutRegistered: false,
65 shortcutPending: true,
66 shortcutMessage: "Waiting for shortcut permission. Approve the desktop prompt if one appears.",
67 };
68 readonly ready: Promise<void>;
69 private closed = false;
70 private connection = 0;
71 private owner = "";
72 private namespace = "";
73 private session = "";
74 private shortcutId = "";
75 private version = 0;
76 private pending: { path: string; resolve: (body: unknown) => void } | undefined;
77 private responses = new Map<string, unknown>();
78 private stopped!: Promise<never>;
79 private rejectStopped!: (reason: Error) => void;
80 private readonly appId: string;
81 private readonly trigger: string;
82 private readonly onCapture: () => void;
83 private readonly onStateChanged: () => void;
84 private readonly bus: MessageBus;
85 private readonly managedByHyprland: boolean;
86​
87 constructor(
88 appId: string,
89 shortcut: SnapShotKeyChord,
90 onCapture: () => void,
91 onStateChanged: () => void,
92 bus: MessageBus = sessionBus(),
93 managedByHyprland = false,
94 ) {
95 this.appId = appId;
96 this.trigger = portalShortcutTrigger(shortcut);
97 this.onCapture = onCapture;
98 this.onStateChanged = onStateChanged;
99 this.bus = bus;
100 this.managedByHyprland = managedByHyprland;
101 if (managedByHyprland)
102 this.state = {
103 shortcutRegistered: false,
104 shortcutPending: true,
105 shortcutMessage: "Connecting to Hyprland shortcuts…",
106 };
107 this.resetStopped();
108 bus.on("error", this.failed);
109 bus.on("message", this.message);
110 this.ready = this.connect(true);
111 }
112​
113 private resetStopped() {
114 this.stopped = new Promise((_, reject) => {
115 this.rejectStopped = reject;
116 });
117 void this.stopped.catch(() => undefined);
118 }
119​
120 private connect(first = false) {
121 const connection = ++this.connection;
122 return this.initialize(first).catch((error: unknown) => {
123 if (connection === this.connection) this.failed(error);
124 });
125 }
126​
127 private portalGone() {
128 this.rejectStopped(new Error("The desktop shortcut service restarted."));
129 this.resetStopped();
130 this.owner = "";
131 this.session = "";
132 this.pending = undefined;
133 this.responses.clear();
134 this.update({
135 shortcutRegistered: false,
136 shortcutActionRegistered: false,
137 shortcutPending: true,
138 shortcutMessage: "The desktop shortcut service restarted. Reconnecting…",
139 });
140 // Register is sent to the well-known name, so it D-Bus-activates a portal that is not running.
141 void this.connect();
142 }
143​
144 close = () => {
145 if (this.closed) return;
146 this.closed = true;
147 this.rejectStopped(new Error("Capture shortcut registration closed."));
148 try {
149 if (this.pending) this.closeObject(this.pending.path, REQUEST);
150 if (this.session) this.closeObject(this.session, SESSION);
151 } catch {}
152 this.bus.removeListener("message", this.message);
153 this.responses.clear();
154 this.bus.disconnect();
155 };
156​
157 get hasSession() {
158 return !this.closed && Boolean(this.session);
159 }
160​
161 async configure() {
162 if (this.managedByHyprland)
163 throw new Error("Change the capture binding in your Hyprland config, then save it.");
164 if (!this.hasSession || this.version < 2)
165 throw new Error(
166 "Open your desktop's shortcut settings and allow T3 Code's capture shortcut.",
167 );
168 await this.call({
169 destination: this.owner,
170 path: PATH,
171 interface: SHORTCUTS,
172 member: "ConfigureShortcuts",
173 signature: "osa{sv}",
174 body: [this.session, "", {}],
175 });
176 }
177​
178 private update(state: PortalShortcutState) {
179 if (this.closed) return;
180 this.state = {
181 shortcutCanRetry: !this.managedByHyprland && (!this.hasSession || this.version >= 2),
182 ...state,
183 };
184 this.onStateChanged();
185 }
186​
187 private failed = (error: unknown) => {
188 if (this.closed) return;
189 this.update({
190 shortcutRegistered: false,
191 shortcutPending: false,
192 shortcutCanRetry: !this.managedByHyprland,
193 shortcutMessage: this.managedByHyprland
194 ? "Couldn't connect to Hyprland shortcuts. Make sure xdg-desktop-portal-hyprland is running, then restart T3 Code."
195 : error instanceof Error
196 ? error.message
197 : "Could not register the capture shortcut.",
198 });
199 this.close();
200 };
201​
202 private async wait<T>(promise: Promise<T>, timeoutMs = 5_000): Promise<T> {
203 let timer: ReturnType<typeof setTimeout> | undefined;
204 try {
205 return await Promise.race([
206 promise,
207 this.stopped,
208 new Promise<never>((_, reject) => {
209 timer = setTimeout(
210 () => reject(new Error("Shortcut permission request timed out. Try again.")),
211 timeoutMs,
212 );
213 }),
214 ]);
215 } finally {
216 clearTimeout(timer);
217 }
218 }
219​
220 private async call(message: MessageLike) {
221 if (this.closed) throw new Error("Capture shortcut registration closed.");
222 const reply = await this.wait(this.bus.call(new Message(message)));
223 if (!reply) throw new Error("Missing shortcut portal reply.");
224 return reply;
225 }
226​
227 private closeObject(path: string, iface: string) {
228 this.bus.send(
229 new Message({
230 destination: this.owner,
231 path,
232 interface: iface,
233 member: "Close",
234 flags: MessageFlag.NO_REPLY_EXPECTED,
235 }),
236 );
237 }
238​
239 private message = (message: Message) => {
240 if (this.closed || message.type !== MessageType.SIGNAL) return;
241 if (
242 message.sender === "org.freedesktop.DBus" &&
243 message.interface === DBUS &&
244 message.member === "NameOwnerChanged" &&
245 message.body[0] === PORTAL
246 ) {
247 if (this.owner && message.body[1] === this.owner) this.portalGone();
248 return;
249 }
250 if (!this.owner || message.sender !== this.owner) return;
251 if (
252 message.interface === REQUEST &&
253 message.member === "Response" &&
254 message.path.startsWith(this.namespace)
255 ) {
256 if (message.path === this.pending?.path) this.pending.resolve(message.body);
257 else if (this.responses.size < 8) this.responses.set(message.path, message.body);
258 return;
259 }
260 if (
261 this.session &&
262 message.path === this.session &&
263 message.interface === SESSION &&
264 message.member === "Closed"
265 ) {
266 this.failed(
267 new Error("Your desktop closed the capture shortcut. Retry the shortcut request."),
268 );
269 return;
270 }
271 if (
272 message.path !== PATH ||
273 message.interface !== SHORTCUTS ||
274 message.body[0] !== this.session
275 )
276 return;
277 if (
278 message.member === "Activated" &&
279 message.signature === "osta{sv}" &&
280 message.body[1] === this.shortcutId &&
281 (this.state.shortcutRegistered || this.state.shortcutActionRegistered)
282 )
283 this.onCapture();
284 if (message.member === "ShortcutsChanged") {
285 try {
286 this.bound(decodeShortcuts(message.body[1]));
287 } catch (error) {
288 this.failed(error);
289 }
290 }
291 };
292​
293 private async request(
294 member: string,
295 signature: string,
296 body: unknown[],
297 options: Record<string, Variant<unknown>> = {},
298 ) {
299 const token = `t3_${NodeCrypto.randomUUID().replaceAll("-", "")}`;
300 const expectedPath = this.namespace + token;
301 let resolve!: (body: unknown) => void;
302 const response = new Promise<unknown>((done) => {
303 resolve = done;
304 });
305 const pending = { path: expectedPath, resolve };
306 this.pending = pending;
307 this.responses.clear();
308 let completed = false;
309 try {
310 const reply = await this.call({
311 destination: this.owner,
312 path: PATH,
313 interface: SHORTCUTS,
314 member,
315 signature: signature + "a{sv}",
316 body: [...body, { ...options, handle_token: new Variant("s", token) }],
317 });
318 const handle = string(reply.body[0]);
319 if (!handle.startsWith(this.namespace)) throw new Error("Invalid shortcut request handle.");
320 pending.path = handle;
321 if (this.responses.has(handle)) resolve(this.responses.get(handle));
322 const [status, results] = decodeResponse(
323 await this.wait(response, member === "BindShortcuts" ? 120_000 : 5_000),
324 );
325 completed = true;
326 if (status !== 0) {
327 if (member === "BindShortcuts") {
328 this.update({
329 shortcutRegistered: false,
330 shortcutPending: false,
331 shortcutMessage:
332 this.version >= 2
333 ? "Shortcut permission wasn't granted. Open shortcut permissions to allow it."
334 : "Shortcut permission wasn't granted. Allow T3 Code in your desktop's shortcut settings.",
335 });
336 return undefined;
337 }
338 throw new Error("Your desktop could not create a capture shortcut session.");
339 }
340 return results;
341 } finally {
342 if (this.pending === pending) {
343 if (!completed && !this.closed) this.closeObject(pending.path, REQUEST);
344 this.pending = undefined;
345 this.responses.clear();
346 }
347 }
348 }
349​
350 private bound(shortcuts: typeof Shortcuts.Type) {
351 const shortcut = shortcuts.find(([id]) => id === this.shortcutId);
352 if (this.managedByHyprland) {
353 this.update({
354 shortcutRegistered: false,
355 shortcutActionRegistered: Boolean(shortcut),
356 shortcutPending: false,
357 shortcutMessage: shortcut
358 ? "Managed by Hyprland. Add the binding to your config and save it."
359 : "Hyprland did not register the capture action. Check that xdg-desktop-portal-hyprland is running, then restart T3 Code.",
360 });
361 return;
362 }
363 const label = shortcut?.[1].trigger_description?.value.trim();
364 this.update({
365 shortcutRegistered: Boolean(shortcut && label),
366 shortcutPending: false,
367 ...(label ? { shortcutLabel: label } : {}),
368 shortcutMessage: label
369 ? `Desktop shortcut: ${label}`
370 : this.version >= 2
371 ? "No shortcut is assigned. Open shortcut permissions to choose one."
372 : "No shortcut is assigned. Choose one in your desktop's shortcut settings.",
373 });
374 }
375​
376 private async initialize(first: boolean) {
377 const trigger = this.trigger;
378 if (!process.env.FLATPAK_ID && !process.env.SNAP) {
379 await this.call({
380 destination: PORTAL,
381 path: PATH,
382 interface: "org.freedesktop.host.portal.Registry",
383 member: "Register",
384 signature: "sa{sv}",
385 body: [this.appId, {}],
386 }).catch((error: unknown) => {
387 if (
388 !(
389 error instanceof DBusError &&
390 ["UnknownMethod", "UnknownInterface"].some(
391 (name) => error.type === `${DBUS}.Error.${name}`,
392 )
393 )
394 )
395 throw error;
396 });
397 }
398 const owner = await this.call({
399 destination: DBUS,
400 path: "/org/freedesktop/DBus",
401 interface: DBUS,
402 member: "GetNameOwner",
403 signature: "s",
404 body: [PORTAL],
405 });
406 this.owner = string(owner.body[0]);
407 this.namespace = `${PATH}/request/${owner.destination.slice(1).replaceAll(".", "_")}/`;
408 const version = await this.call({
409 destination: this.owner,
410 path: PATH,
411 interface: "org.freedesktop.DBus.Properties",
412 member: "Get",
413 signature: "ss",
414 body: [SHORTCUTS, "version"],
415 });
416 this.version = decodeVersion(version.body[0]).value;
417 for (const rule of [
418 `type='signal',sender='${this.owner}',path_namespace='${PATH}'`,
419 ...(first
420 ? [
421 `type='signal',sender='org.freedesktop.DBus',interface='${DBUS}',member='NameOwnerChanged',arg0='${PORTAL}'`,
422 ]
423 : []),
424 ])
425 await this.call({
426 destination: DBUS,
427 path: "/org/freedesktop/DBus",
428 interface: DBUS,
429 member: "AddMatch",
430 signature: "s",
431 body: [rule],
432 });
433 const created = await this.request("CreateSession", "", [], {
434 session_handle_token: new Variant(
435 "s",
436 `t3_capture_${NodeCrypto.randomUUID().replaceAll("-", "")}`,
437 ),
438 });
439 const session = decodeSession(created).session_handle.value;
440 const sessionNamespace = this.namespace.replace("/request/", "/session/");
441 if (!session.startsWith(sessionNamespace)) throw new Error("Invalid shortcut session handle.");
442 this.session = session;
443 this.shortcutId = this.managedByHyprland
444 ? HYPRLAND_CAPTURE_ACTION
445 : `t3-snap-shot-${NodeCrypto.createHash("sha256").update(trigger).digest("hex").slice(0, 16)}`;
446 // Every session must bind, even when the desktop remembers this shortcut's approval.
447 const bound = await this.request("BindShortcuts", "oa(sa{sv})s", [
448 this.session,
449 [
450 [
451 this.shortcutId,
452 {
453 description: new Variant("s", "Capture a window"),
454 ...(!this.managedByHyprland ? { preferred_trigger: new Variant("s", trigger) } : {}),
455 },
456 ],
457 ],
458 "",
459 ]);
460 if (bound) this.bound(decodeShortcutsResult(bound).shortcuts.value);
461 }