diff --git a/apps/desktop/src/snapShot/PortalCaptureShortcut.dbus.test.ts b/apps/desktop/src/snapShot/PortalCaptureShortcut.dbus.test.ts index acd9a29b32..c80aad57de 100644 --- a/apps/desktop/src/snapShot/PortalCaptureShortcut.dbus.test.ts +++ b/apps/desktop/src/snapShot/PortalCaptureShortcut.dbus.test.ts @@ -5,29 +5,36 @@ import * as NodeFSP from "node:fs/promises"; import * as NodeOS from "node:os"; import * as NodePath from "node:path"; import * as NodeReadline from "node:readline"; -import { Message, NameFlag, Variant, sessionBus, type MessageBus } from "dbus-next"; +import { Message, MessageType, NameFlag, Variant, sessionBus, type MessageBus } from "dbus-next"; import { expect, it, vi } from "vite-plus/test"; import { PortalCaptureShortcut } from "./PortalCaptureShortcut.ts"; it.runIf(NodeChildProcess.spawnSync("dbus-daemon", ["--version"]).status === 0)( - "registers, rebinds saved keys, receives activations, and replaces sessions over real D-Bus", + "registers, rebinds saved keys, recovers returning portal owners, and releases sessions over real D-Bus", async () => { const dir = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-shortcut-dbus-")); let daemon: NodeChildProcess.ChildProcess | undefined; - let server: MessageBus | undefined; + const servers: MessageBus[] = []; const clients: PortalCaptureShortcut[] = []; try { vi.stubEnv("FLATPAK_ID", ""); vi.stubEnv("SNAP", ""); + const config = NodePath.join(dir, "bus.conf"); + await NodeFSP.writeFile( + config, + ` + session + unix:path=${NodePath.join(dir, "bus")} + + + + + +`, + ); daemon = NodeChildProcess.spawn( "dbus-daemon", - [ - "--session", - "--nofork", - "--nopidfile", - "--print-address", - `--address=unix:path=${NodePath.join(dir, "bus")}`, - ], + [`--config-file=${config}`, "--nofork", "--nopidfile", "--print-address"], { stdio: ["ignore", "pipe", "pipe"] }, ); const lines = NodeReadline.createInterface({ input: daemon.stdout! }); @@ -39,70 +46,84 @@ it.runIf(NodeChildProcess.spawnSync("dbus-daemon", ["--version"]).status === 0)( ]); lines.close(); const connect = () => sessionBus({ busAddress: String(address) }); - server = connect(); + let server = connect(); + servers.push(server); server.on("error", () => undefined); - await server.requestName("org.freedesktop.portal.Desktop", NameFlag.DO_NOT_QUEUE); + await server.requestName("org.freedesktop.portal.Desktop", NameFlag.ALLOW_REPLACEMENT); const root = "/org/freedesktop/portal/desktop"; const iface = "org.freedesktop.portal.GlobalShortcuts"; const sessions = new Map(); const bindings = new Map(); - const identities = new Set(); const closed = new Set(); const closeWaiters = new Map void>(); let bindCount = 0; - server.addMethodHandler((message: Message) => { - if (message.member === "Register") { - expect(message.body[0]).toBe("com.t3tools.T3Code"); - identities.add(message.sender); - server!.send(Message.newMethodReturn(message)); - } else if (message.member === "Get") { - server!.send(Message.newMethodReturn(message, "v", [new Variant("u", 2)])); - } else if (message.member === "Close") { - closed.add(message.path); - closeWaiters.get(message.path)?.(); - } else if (["CreateSession", "BindShortcuts"].includes(message.member)) { - expect(identities.has(message.sender)).toBe(true); - const options = message.body.at(-1) as Record>; - const sender = message.sender.slice(1).replaceAll(".", "_"); - const handle = `${root}/request/${sender}/${options.handle_token!.value}`; - let results: Record>; - if (message.member === "CreateSession") { - const session = `${root}/session/${sender}/${options.session_handle_token!.value}`; - sessions.set(message.sender, session); - results = { session_handle: new Variant("s", session) }; - } else { - bindCount++; - const shortcuts = message.body[1] as Array< - [string, { preferred_trigger: Variant }] - >; - expect(shortcuts).toHaveLength(1); - const [id, properties] = shortcuts[0]!; - expect(properties.preferred_trigger.signature).toBe("s"); - bindings.set(message.sender, id); - const bound = [ - [ - id, - { - description: new Variant("s", "Capture a window"), - trigger_description: new Variant("s", properties.preferred_trigger.value), - }, - ], - ]; - results = { shortcuts: new Variant("a(sa{sv})", bound) }; - } - const response = Message.newSignal( - handle, - "org.freedesktop.portal.Request", - "Response", - "ua{sv}", - [0, results], - ); - response.destination = message.sender; - server!.send(response); - server!.send(Message.newMethodReturn(message, "o", [handle])); - } else return false; - return true; - }); + const serve = (bus: MessageBus) => { + const identities = new Set(); + const ownedSessions = new Set(); + bus.addMethodHandler((message: Message) => { + if (message.member === "Register") { + expect(message.body[0]).toBe("com.t3tools.T3Code"); + if (identities.has(message.sender)) { + const reply = Message.newMethodReturn(message, "s", ["Already registered"]); + reply.type = MessageType.ERROR; + reply.errorName = "org.freedesktop.portal.Error.Failed"; + bus.send(reply); + return true; + } + identities.add(message.sender); + bus.send(Message.newMethodReturn(message)); + } else if (message.member === "Get") { + bus.send(Message.newMethodReturn(message, "v", [new Variant("u", 2)])); + } else if (message.member === "Close") { + expect(ownedSessions.delete(message.path)).toBe(true); + closed.add(message.path); + closeWaiters.get(message.path)?.(); + } else if (["CreateSession", "BindShortcuts"].includes(message.member)) { + expect(identities.has(message.sender)).toBe(true); + const options = message.body.at(-1) as Record>; + const sender = message.sender.slice(1).replaceAll(".", "_"); + const handle = `${root}/request/${sender}/${options.handle_token!.value}`; + let results: Record>; + if (message.member === "CreateSession") { + const session = `${root}/session/${sender}/${options.session_handle_token!.value}`; + ownedSessions.add(session); + sessions.set(message.sender, session); + results = { session_handle: new Variant("s", session) }; + } else { + bindCount++; + const shortcuts = message.body[1] as Array< + [string, { preferred_trigger: Variant }] + >; + expect(shortcuts).toHaveLength(1); + const [id, properties] = shortcuts[0]!; + expect(properties.preferred_trigger.signature).toBe("s"); + bindings.set(message.sender, id); + const bound = [ + [ + id, + { + description: new Variant("s", "Capture a window"), + trigger_description: new Variant("s", properties.preferred_trigger.value), + }, + ], + ]; + results = { shortcuts: new Variant("a(sa{sv})", bound) }; + } + const response = Message.newSignal( + handle, + "org.freedesktop.portal.Request", + "Response", + "ua{sv}", + [0, results], + ); + response.destination = message.sender; + bus.send(response); + bus.send(Message.newMethodReturn(message, "o", [handle])); + } else return false; + return true; + }); + }; + serve(server); const shortcut = { key: "2", @@ -113,17 +134,19 @@ it.runIf(NodeChildProcess.spawnSync("dbus-daemon", ["--version"]).status === 0)( metaKey: false, }; const start = (key = "2") => { - const received = Promise.withResolvers(); - const capture = vi.fn(() => received.resolve()); + const events = new NodeEvents.EventEmitter(); + const capture = vi.fn(() => events.emit("capture")); const client = new PortalCaptureShortcut( "com.t3tools.T3Code", { ...shortcut, key }, capture, - () => {}, + () => { + if (!client.state.shortcutPending) events.emit("settled"); + }, connect(), ); clients.push(client); - return { client, capture, received: received.promise }; + return { client, capture, events }; }; const activate = (sender: string) => { const signal = Message.newSignal(root, iface, "Activated", "osta{sv}", [ @@ -139,8 +162,9 @@ it.runIf(NodeChildProcess.spawnSync("dbus-daemon", ["--version"]).status === 0)( await first.client.ready; expect(first.client.state.shortcutRegistered).toBe(true); const firstSender = [...bindings.keys()][0]!; + const firstCapture = NodeEvents.EventEmitter.once(first.events, "capture"); activate(firstSender); - await first.received; + await firstCapture; expect(first.capture).toHaveBeenCalledOnce(); const sessionClosed = new Promise((resolve) => closeWaiters.set(sessions.get(firstSender)!, resolve), @@ -160,12 +184,52 @@ it.runIf(NodeChildProcess.spawnSync("dbus-daemon", ["--version"]).status === 0)( expect(bindCount).toBe(3); const lastSender = [...bindings.keys()].at(-1)!; expect(bindings.get(lastSender)).not.toBe(bindings.get(firstSender)); + const nextCapture = NodeEvents.EventEmitter.once(next.events, "capture"); activate(lastSender); - await next.received; + await nextCapture; expect(next.capture).toHaveBeenCalledOnce(); + restoredClient.client.close(); + + const original = server; + const replacement = connect(); + servers.push(replacement); + replacement.on("error", () => undefined); + serve(replacement); + for (const owner of [replacement, original]) { + const previous = server; + const oldSession = sessions.get(lastSender)!; + const released = new Promise((resolve) => closeWaiters.set(oldSession, resolve)); + const settled = NodeEvents.EventEmitter.once(next.events, "settled"); + server = owner; + await owner.requestName( + "org.freedesktop.portal.Desktop", + NameFlag.ALLOW_REPLACEMENT | NameFlag.REPLACE_EXISTING, + ); + await settled; + expect(next.client.state.shortcutRegistered, next.client.state.shortcutMessage ?? "").toBe( + true, + ); + await released; + expect(sessions.get(lastSender)).not.toBe(oldSession); + expect(next.client.state.shortcutLabel).toBe("CTRL+SHIFT+8"); + + const stale = Message.newSignal(root, iface, "Activated", "osta{sv}", [ + oldSession, + bindings.get(lastSender), + 123, + {}, + ]); + stale.destination = lastSender; + previous.send(stale); + const captured = NodeEvents.EventEmitter.once(next.events, "capture"); + activate(lastSender); + await captured; + } + expect(next.capture).toHaveBeenCalledTimes(3); + expect(bindCount).toBe(5); } finally { clients.forEach((client) => client.close()); - server?.disconnect(); + servers.forEach((bus) => bus.disconnect()); if (daemon && daemon.exitCode === null) { const exited = NodeEvents.EventEmitter.once(daemon, "exit"); daemon.kill(); diff --git a/apps/desktop/src/snapShot/PortalCaptureShortcut.test.ts b/apps/desktop/src/snapShot/PortalCaptureShortcut.test.ts index eb20a4a041..f586e2dfb2 100644 --- a/apps/desktop/src/snapShot/PortalCaptureShortcut.test.ts +++ b/apps/desktop/src/snapShot/PortalCaptureShortcut.test.ts @@ -22,6 +22,11 @@ class FakeBus extends NodeEvents.EventEmitter { sends: Message[] = []; disconnect = vi.fn(); registryError: Error | undefined; + registryResponse: Promise | undefined; + registeredOwners = new Set(); + activationError: Error | undefined; + ownerReply: Promise | undefined; + bindReply: Promise | undefined; version = 2; autoBind = true; actualLabel = "Ctrl+Shift+2"; @@ -30,6 +35,20 @@ class FakeBus extends NodeEvents.EventEmitter { requestPath = ""; boundId = ""; foreignHandle = false; + owner = ":1.2"; + + changeOwner(owner: string) { + const previous = this.owner; + this.owner = owner; + this.signal( + "org.freedesktop.DBus", + "NameOwnerChanged", + "/org/freedesktop/DBus", + ["org.freedesktop.portal.Desktop", previous, owner], + "org.freedesktop.DBus", + "sss", + ); + } send(message: Message) { this.sends.push(message); @@ -39,7 +58,7 @@ class FakeBus extends NodeEvents.EventEmitter { member: string, path: string, body: unknown[], - sender = ":1.2", + sender = this.owner, signature = "", ) { this.emit( @@ -71,7 +90,7 @@ class FakeBus extends NodeEvents.EventEmitter { ]), }); } - activate(id = this.boundId, session = this.session, sender = ":1.2") { + activate(id = this.boundId, session = this.session, sender = this.owner) { this.signal(portal, "Activated", root, [session, id, 1, {}], sender, "osta{sv}"); } async call(message: Message) { @@ -85,9 +104,28 @@ class FakeBus extends NodeEvents.EventEmitter { }); if (message.member === "Register") { if (this.registryError) throw this.registryError; + const owner = + message.destination === "org.freedesktop.portal.Desktop" ? this.owner : message.destination; + if (this.registeredOwners.has(owner)) + throw new DBusError("org.freedesktop.portal.Error.Failed", "Already registered"); + this.registeredOwners.add(owner); + this.emit("register"); + await this.registryResponse; return reply(); } - if (message.member === "GetNameOwner") return reply([":1.2"]); + if (message.member === "GetNameOwner") { + if (!this.owner) + throw new DBusError("org.freedesktop.DBus.Error.NameHasNoOwner", "No portal owner"); + const owner = this.owner; + this.emit("owner"); + await this.ownerReply; + return reply([owner]); + } + if (message.member === "StartServiceByName") { + if (this.activationError) throw this.activationError; + this.changeOwner(":1.3"); + return reply([1]); + } if (message.member === "Get") return reply([new Variant("u", this.version)]); if (message.member === "AddMatch" || message.member === "ConfigureShortcuts") return reply(); const options = message.body.at(-1) as Record>; @@ -100,11 +138,13 @@ class FakeBus extends NodeEvents.EventEmitter { this.foreignHandle ? `${root}/session/9_9/other` : this.session, ), }); + this.emit("create"); } else if (message.member === "BindShortcuts") { this.boundId = (message.body[1] as Shortcut[])[0]![0]; this.requestPath = path; this.emit("bind"); if (this.autoBind) this.respondBind(); + await this.bindReply; } else throw new Error(`Unexpected call: ${message.member}`); return reply([path]); } @@ -134,7 +174,13 @@ afterEach(() => { it("accepts Hyprland's action-only binding without claiming the keys are reserved", async () => { const bus = new FakeBus(); bus.actualLabel = ""; + bus.owner = ""; + bus.autoBind = false; + const binding = NodeEvents.EventEmitter.once(bus, "bind"); const { client, capture } = start(bus, chord, true); + await binding; + expect(client.state.shortcutMessage).toBe("Connecting to Hyprland shortcuts…"); + bus.respondBind(); await client.ready; expect(client.state).toMatchObject({ shortcutRegistered: false, @@ -231,7 +277,9 @@ it("binds only the requested shortcut with a stable ID across sessions", async ( ]); bus.activate(); expect(capture).toHaveBeenCalledOnce(); - expect(bus.calls[0]?.member).toBe("Register"); + expect(bus.calls.find((call) => call.destination !== "org.freedesktop.DBus")?.member).toBe( + "Register", + ); }); it("ignores other shortcuts, sessions, and forged activations", async () => { @@ -376,19 +424,195 @@ it("bounds unanswered consent and cleans up the pending request", async () => { expect(vi.getTimerCount()).toBe(0); }); -it("handles a disappearing portal without keeping a false registered state", async () => { - const { bus, client } = start(); +it("uses the replacement owner when an earlier owner lookup is still pending", async () => { + const bus = new FakeBus(); + const lookup = Promise.withResolvers(); + bus.ownerReply = lookup.promise; + const resolving = NodeEvents.EventEmitter.once(bus, "owner"); + const { client, capture } = start(bus); + await resolving; + bus.ownerReply = undefined; + const registering = NodeEvents.EventEmitter.once(bus, "register"); + bus.changeOwner(":1.3"); + lookup.resolve(); + await registering; + expect(bus.calls.find((call) => call.member === "Register")?.destination).toBe(":1.3"); + await client.ready; + bus.activate(); + expect(client.state.shortcutRegistered).toBe(true); + expect(capture).toHaveBeenCalledOnce(); +}); + +it.each([false, true])( + "restores a lost portal shortcut and keeps listening after activation fails (Hyprland: %s)", + async (hyprland) => { + const { bus, client, capture } = start(new FakeBus(), chord, hyprland); + await client.ready; + const originalSession = bus.session; + const bound = NodeEvents.EventEmitter.once(bus, "bind"); + bus.changeOwner(""); + expect(client.state).toMatchObject({ shortcutRegistered: false, shortcutPending: true }); + expect(client.state.shortcutActionRegistered).not.toBe(true); + expect(client.hasSession).toBe(false); + bus.activate(bus.boundId, originalSession, ":1.2"); + expect(capture).not.toHaveBeenCalled(); + await bound; + await client.ready; + bus.activate(); + expect(capture).toHaveBeenCalledOnce(); + + bus.activationError = new DBusError("org.freedesktop.DBus.Error.ServiceUnknown", "No portal"); + bus.changeOwner(""); + await client.ready; + expect(client.state).toMatchObject({ + shortcutRegistered: false, + shortcutPending: false, + shortcutCanRetry: !hyprland, + }); + expect(client.state.shortcutMessage).toContain("No portal"); + expect(bus.disconnect).not.toHaveBeenCalled(); + bus.changeOwner(":1.4"); + await client.ready; + bus.activate(); + expect(capture).toHaveBeenCalledTimes(2); + expect(client.state.shortcutPending).toBe(false); + }, +); + +it("releases a created session when its response races an owner replacement", async () => { + const { bus, client, capture } = start(); await client.ready; - bus.signal( - "org.freedesktop.DBus", - "NameOwnerChanged", - "/org/freedesktop/DBus", - ["org.freedesktop.portal.Desktop", ":1.2", ""], - "org.freedesktop.DBus", + const creating = NodeEvents.EventEmitter.once(bus, "create"); + bus.changeOwner(":1.3"); + await creating; + const oldSession = bus.session; + bus.changeOwner(":1.4"); + await client.ready; + expect(bus.sends).toContainEqual( + expect.objectContaining({ destination: ":1.3", path: oldSession, member: "Close" }), ); - expect(client.state.shortcutRegistered).toBe(false); - expect(client.state.shortcutMessage).toContain("restarted"); + bus.activate(); + expect(capture).toHaveBeenCalledOnce(); +}); + +it("recovers approved consent when its response races an owner replacement", async () => { + const bus = new FakeBus(); + bus.autoBind = false; + const binding = NodeEvents.EventEmitter.once(bus, "bind"); + const { client, capture } = start(bus); + await binding; + bus.respondBind(); + bus.autoBind = true; + bus.changeOwner(":1.3"); + await client.ready; + bus.activate(); + expect(client.state.shortcutRegistered).toBe(true); + expect(capture).toHaveBeenCalledOnce(); +}); + +it("discards interrupted replies, closes old portal objects, and stays disabled after close", async () => { + const { bus, client, capture } = start(); + await client.ready; + bus.autoBind = false; + const reply = Promise.withResolvers(); + bus.bindReply = reply.promise; + const binding = NodeEvents.EventEmitter.once(bus, "bind"); + bus.changeOwner(":1.3"); + await binding; + const oldSession = bus.session; + const oldRequest = bus.requestPath; + const interrupted = client.ready; + + bus.bindReply = undefined; + const replacement = NodeEvents.EventEmitter.once(bus, "bind"); + bus.changeOwner(":1.4"); + await replacement; + await interrupted; + expect(bus.sends).toEqual( + expect.arrayContaining([ + expect.objectContaining({ destination: ":1.3", path: oldRequest, member: "Close" }), + expect.objectContaining({ destination: ":1.3", path: oldSession, member: "Close" }), + ]), + ); + reply.resolve(); + bus.signal("org.freedesktop.portal.Request", "Response", oldRequest, [2, {}], ":1.3"); + bus.activate(bus.boundId, oldSession, ":1.3"); + expect(capture).not.toHaveBeenCalled(); + expect(client.state.shortcutPending).toBe(true); + bus.respondBind(); + await client.ready; + expect(bus.sends.some((message) => message.path === bus.requestPath)).toBe(false); + bus.activate(); + expect(capture).toHaveBeenCalledOnce(); + + const disabling = NodeEvents.EventEmitter.once(bus, "bind"); + bus.changeOwner(":1.5"); + await disabling; + const disabledRequest = bus.requestPath; + const disabledSession = bus.session; + client.close(); + bus.respondBind(); + bus.changeOwner(":1.6"); + await client.ready; + bus.activate(); + expect(capture).toHaveBeenCalledOnce(); + expect(client.hasSession).toBe(false); expect(bus.disconnect).toHaveBeenCalledOnce(); + expect(bus.calls.filter((call) => call.member === "BindShortcuts")).toHaveLength(4); + expect(bus.sends).toEqual( + expect.arrayContaining([ + expect.objectContaining({ destination: ":1.5", path: disabledRequest, member: "Close" }), + expect.objectContaining({ destination: ":1.5", path: disabledSession, member: "Close" }), + ]), + ); +}); + +it.each([true, false])( + "honors an interrupted portal identity result when its owner returns (allowed: %s)", + async (allowed) => { + const { bus, client, capture } = start(); + await client.ready; + const identity = Promise.withResolvers(); + bus.registryResponse = identity.promise; + const registering = NodeEvents.EventEmitter.once(bus, "register"); + bus.changeOwner(":1.3"); + await registering; + bus.registryResponse = undefined; + bus.changeOwner(":1.2"); + await client.ready; + bus.activate(); + expect(capture).toHaveBeenCalledOnce(); + + if (allowed) identity.resolve(); + else + identity.reject( + new DBusError("org.freedesktop.portal.Error.NotAllowed", "App is not allowed"), + ); + bus.changeOwner(":1.3"); + await client.ready; + bus.activate(); + expect(client.state.shortcutRegistered).toBe(allowed); + expect(capture).toHaveBeenCalledTimes(allowed ? 2 : 1); + if (!allowed) expect(client.state.shortcutMessage).toContain("App is not allowed"); + expect(bus.calls.filter((call) => call.member === "Register")).toHaveLength(2); + }, +); + +it("does not reopen consent after a refused recovery and immediate owner replacement", async () => { + const { bus, client, capture } = start(); + await client.ready; + bus.autoBind = false; + const binding = NodeEvents.EventEmitter.once(bus, "bind"); + bus.changeOwner(":1.3"); + await binding; + bus.respondBind(1); + bus.changeOwner(":1.4"); + await client.ready; + bus.activate(); + expect(client.state.shortcutRegistered).toBe(false); + expect(client.state.shortcutPending).toBe(false); + expect(capture).not.toHaveBeenCalled(); + expect(bus.calls.filter((call) => call.member === "BindShortcuts")).toHaveLength(2); }); it("accepts old portals without Registry but does not ignore permission denial", async () => { diff --git a/apps/desktop/src/snapShot/PortalCaptureShortcut.ts b/apps/desktop/src/snapShot/PortalCaptureShortcut.ts index ec42af46a3..f90a59071c 100644 --- a/apps/desktop/src/snapShot/PortalCaptureShortcut.ts +++ b/apps/desktop/src/snapShot/PortalCaptureShortcut.ts @@ -65,8 +65,9 @@ export class PortalCaptureShortcut { shortcutPending: true, shortcutMessage: "Waiting for shortcut permission. Approve the desktop prompt if one appears.", }; - readonly ready: Promise; + ready: Promise; private closed = false; + private reconnecting = false; private owner = ""; private namespace = ""; private session = ""; @@ -74,12 +75,14 @@ export class PortalCaptureShortcut { private version = 0; private pending: { path: string; resolve: (body: unknown) => void } | undefined; private responses = new Map(); - private readonly stopped: Promise; - private rejectStopped!: (reason: Error) => void; + private registrations = new Map>(); + private stopped = Promise.withResolvers(); private readonly onCapture: () => void; private readonly onStateChanged: () => void; private readonly bus: MessageBus; private readonly managedByHyprland: boolean; + private readonly appId: string; + private readonly shortcut: SnapShotKeyChord; constructor( appId: string, @@ -93,33 +96,83 @@ export class PortalCaptureShortcut { this.onStateChanged = onStateChanged; this.bus = bus; this.managedByHyprland = managedByHyprland; + this.appId = appId; + this.shortcut = shortcut; if (managedByHyprland) this.state = { shortcutRegistered: false, shortcutPending: true, shortcutMessage: "Connecting to Hyprland shortcuts…", }; - this.stopped = new Promise((_, reject) => { - this.rejectStopped = reject; - }); - void this.stopped.catch(() => undefined); + void this.stopped.promise.catch(() => undefined); bus.on("error", this.failed); bus.on("message", this.message); - this.ready = this.initialize(appId, shortcut).catch(this.failed); + this.ready = this.subscribe(); + this.register(); + } + + private async subscribe() { + for (const rule of [ + `type='signal',sender='${PORTAL}',path_namespace='${PATH}'`, + `type='signal',sender='org.freedesktop.DBus',interface='${DBUS}',member='NameOwnerChanged',arg0='${PORTAL}'`, + ]) + await this.call({ + destination: DBUS, + path: "/org/freedesktop/DBus", + interface: DBUS, + member: "AddMatch", + signature: "s", + body: [rule], + }); + } + + private register() { + const stopped = this.stopped; + // Drain the interrupted registration before starting another on this connection. + this.ready = this.ready + .then(async () => { + if (this.closed || stopped !== this.stopped) return; + await this.initialize(stopped); + this.assertCurrent(stopped); + this.reconnecting = false; + }) + .catch((error) => { + if (this.closed || stopped !== this.stopped) return; + if (this.reconnecting && !this.owner) { + this.update({ + shortcutRegistered: false, + shortcutPending: false, + shortcutCanRetry: !this.managedByHyprland, + shortcutMessage: + (error instanceof Error ? `${error.message} ` : "") + + "Waiting for the desktop shortcut service to return.", + }); + return; + } + this.failed(error); + }); } close = () => { if (this.closed) return; this.closed = true; - this.rejectStopped(new Error("Capture shortcut registration closed.")); + this.stopped.reject(new Error("Capture shortcut registration closed.")); + this.releaseSession(); + this.bus.removeListener("message", this.message); + this.bus.disconnect(); + }; + + private releaseSession() { try { if (this.pending) this.closeObject(this.pending.path, REQUEST); if (this.session) this.closeObject(this.session, SESSION); - } catch {} - this.bus.removeListener("message", this.message); + } catch { + // A broken bus is already closing its sessions; local cleanup must still run. + } + this.session = ""; + this.pending = undefined; this.responses.clear(); - this.bus.disconnect(); - }; + } get hasSession() { return !this.closed && Boolean(this.session); @@ -166,12 +219,21 @@ export class PortalCaptureShortcut { this.close(); }; - private async wait(promise: Promise, timeoutMs = 5_000): Promise { + private assertCurrent(stopped: PromiseWithResolvers) { + if (this.closed) throw new Error("Capture shortcut registration closed."); + if (stopped !== this.stopped) throw new Error("The desktop shortcut service restarted."); + } + + private async wait( + promise: Promise, + timeoutMs = 5_000, + stopped = this.stopped, + ): Promise { let timer: ReturnType | undefined; try { - return await Promise.race([ + const result = await Promise.race([ promise, - this.stopped, + stopped.promise, new Promise((_, reject) => { timer = setTimeout( () => reject(new Error("Shortcut permission request timed out. Try again.")), @@ -179,22 +241,25 @@ export class PortalCaptureShortcut { ); }), ]); + this.assertCurrent(stopped); + return result; } finally { clearTimeout(timer); } } - private async call(message: MessageLike) { - if (this.closed) throw new Error("Capture shortcut registration closed."); - const reply = await this.wait(this.bus.call(new Message(message))); + private async call(message: MessageLike, stopped = this.stopped) { + this.assertCurrent(stopped); + const reply = await this.wait(this.bus.call(new Message(message)), 5_000, stopped); + this.assertCurrent(stopped); if (!reply) throw new Error("Missing shortcut portal reply."); return reply; } - private closeObject(path: string, iface: string) { + private closeObject(path: string, iface: string, owner = this.owner) { this.bus.send( new Message({ - destination: this.owner, + destination: owner, path, interface: iface, member: "Close", @@ -209,10 +274,35 @@ export class PortalCaptureShortcut { message.sender === "org.freedesktop.DBus" && message.interface === DBUS && message.member === "NameOwnerChanged" && + message.signature === "sss" && message.body[0] === PORTAL && - message.body[1] === this.owner + (!this.owner || message.body[1] === this.owner) ) { - this.failed(new Error("The desktop shortcut service restarted. Retry the shortcut request.")); + const recovering = + this.reconnecting || + this.state.shortcutRegistered || + this.state.shortcutActionRegistered === true; + if (!recovering && this.namespace) { + this.failed( + new Error("The desktop shortcut service restarted. Retry the shortcut request."), + ); + return; + } + this.reconnecting = recovering; + this.stopped.reject(new Error("The desktop shortcut service restarted.")); + this.stopped = Promise.withResolvers(); + void this.stopped.promise.catch(() => undefined); + // A replaced owner can still be alive. Release its objects before switching owners. + this.releaseSession(); + this.owner = string(message.body[2]); + if (recovering) + this.update({ + shortcutRegistered: false, + shortcutPending: true, + shortcutCanRetry: false, + shortcutMessage: "Reconnecting to the desktop shortcut service…", + }); + this.register(); return; } if (!this.owner || message.sender !== this.owner) return; @@ -262,53 +352,83 @@ export class PortalCaptureShortcut { member: string, signature: string, body: unknown[], - options: Record> = {}, + options: Record>, + stopped: PromiseWithResolvers, ) { + this.assertCurrent(stopped); + const owner = this.owner; + const namespace = this.namespace; const token = `t3_${NodeCrypto.randomUUID().replaceAll("-", "")}`; - const expectedPath = this.namespace + token; - let resolve!: (body: unknown) => void; - const response = new Promise((done) => { - resolve = done; - }); - this.pending = { path: expectedPath, resolve }; + const expectedPath = namespace + token; + const response = Promise.withResolvers>(); + void response.promise.catch(() => undefined); + const pending = { + path: expectedPath, + resolve: (body: unknown) => { + try { + const result = decodeResponse(body); + // Owner changes can arrive before the request continuation consumes this response. + if (member === "CreateSession" && result[0] === 0) { + const session = decodeSession(result[1]).session_handle.value; + if (!session.startsWith(namespace.replace("/request/", "/session/"))) + throw new Error("Invalid shortcut session handle."); + this.session = session; + } + if (member === "BindShortcuts") { + if (result[0] === 0) this.bound(decodeShortcutsResult(result[1]).shortcuts.value); + else { + this.reconnecting = false; + this.update({ + shortcutRegistered: false, + shortcutPending: false, + shortcutMessage: + this.version >= 2 + ? "Shortcut permission wasn't granted. Open shortcut permissions to allow it." + : "Shortcut permission wasn't granted. Allow T3 Code in your desktop's shortcut settings.", + }); + } + } + response.resolve(result); + } catch (error) { + response.reject(error); + } + }, + }; + this.pending = pending; this.responses.clear(); let completed = false; try { - const reply = await this.call({ - destination: this.owner, - path: PATH, - interface: SHORTCUTS, - member, - signature: signature + "a{sv}", - body: [...body, { ...options, handle_token: new Variant("s", token) }], - }); + const reply = await this.call( + { + destination: owner, + path: PATH, + interface: SHORTCUTS, + member, + signature: signature + "a{sv}", + body: [...body, { ...options, handle_token: new Variant("s", token) }], + }, + stopped, + ); + this.assertCurrent(stopped); const handle = string(reply.body[0]); - if (!handle.startsWith(this.namespace)) throw new Error("Invalid shortcut request handle."); - this.pending.path = handle; - if (this.responses.has(handle)) resolve(this.responses.get(handle)); - const [status, results] = decodeResponse( - await this.wait(response, member === "BindShortcuts" ? 120_000 : 5_000), + if (!handle.startsWith(namespace)) throw new Error("Invalid shortcut request handle."); + pending.path = handle; + if (this.responses.has(handle)) pending.resolve(this.responses.get(handle)); + const [status] = await this.wait( + response.promise, + member === "BindShortcuts" ? 120_000 : 5_000, + stopped, ); + this.assertCurrent(stopped); completed = true; - if (status !== 0) { - if (member === "BindShortcuts") { - this.update({ - shortcutRegistered: false, - shortcutPending: false, - shortcutMessage: - this.version >= 2 - ? "Shortcut permission wasn't granted. Open shortcut permissions to allow it." - : "Shortcut permission wasn't granted. Allow T3 Code in your desktop's shortcut settings.", - }); - return undefined; - } + if (status !== 0 && member !== "BindShortcuts") throw new Error("Your desktop could not create a capture shortcut session."); - } - return results; } finally { - if (!completed && !this.closed && this.pending) this.closeObject(this.pending.path, REQUEST); - this.pending = undefined; - this.responses.clear(); + if (this.pending === pending) { + if (!completed && !this.closed) this.closeObject(pending.path, REQUEST, owner); + this.pending = undefined; + this.responses.clear(); + } } } @@ -338,86 +458,119 @@ export class PortalCaptureShortcut { }); } - private async initialize(appId: string, shortcut: SnapShotKeyChord) { - const trigger = portalShortcutTrigger(shortcut); - if (!process.env.FLATPAK_ID && !process.env.SNAP) { - await this.call({ - destination: PORTAL, - path: PATH, - interface: "org.freedesktop.host.portal.Registry", - member: "Register", - signature: "sa{sv}", - body: [appId, {}], - }).catch((error: unknown) => { - if ( - !( - error instanceof DBusError && - ["UnknownMethod", "UnknownInterface"].some( - (name) => error.type === `${DBUS}.Error.${name}`, - ) - ) - ) - throw error; - }); - } - const owner = await this.call({ + private async initialize(stopped: PromiseWithResolvers) { + const trigger = portalShortcutTrigger(this.shortcut); + const ownerRequest = { destination: DBUS, path: "/org/freedesktop/DBus", interface: DBUS, member: "GetNameOwner", signature: "s", body: [PORTAL], + }; + const owner = await this.call(ownerRequest, stopped).catch(async (error: unknown) => { + this.assertCurrent(stopped); + if (!(error instanceof DBusError && error.type === `${DBUS}.Error.NameHasNoOwner`)) + throw error; + await this.call( + { + destination: DBUS, + path: "/org/freedesktop/DBus", + interface: DBUS, + member: "StartServiceByName", + signature: "su", + body: [PORTAL, 0], + }, + stopped, + ); + this.assertCurrent(stopped); + return this.call(ownerRequest, stopped); }); + this.assertCurrent(stopped); this.owner = string(owner.body[0]); this.namespace = `${PATH}/request/${owner.destination.slice(1).replaceAll(".", "_")}/`; - const version = await this.call({ - destination: this.owner, - path: PATH, - interface: "org.freedesktop.DBus.Properties", - member: "Get", - signature: "ss", - body: [SHORTCUTS, "version"], - }); + if (!process.env.FLATPAK_ID && !process.env.SNAP) { + let registration = this.registrations.get(this.owner); + if (!registration) { + // Registry permits one registration per connection and portal, even across owner changes. + registration = this.bus + .call( + new Message({ + destination: this.owner, + path: PATH, + interface: "org.freedesktop.host.portal.Registry", + member: "Register", + signature: "sa{sv}", + body: [this.appId, {}], + }), + ) + .then((reply) => { + if (!reply) throw new Error("Missing shortcut portal reply."); + }) + .catch((error: unknown) => { + if ( + !( + error instanceof DBusError && + ["UnknownMethod", "UnknownInterface"].some( + (name) => error.type === `${DBUS}.Error.${name}`, + ) + ) + ) + throw error; + }); + this.registrations.set(this.owner, registration); + } + await this.wait(registration, 5_000, stopped); + this.assertCurrent(stopped); + } + const version = await this.call( + { + destination: this.owner, + path: PATH, + interface: "org.freedesktop.DBus.Properties", + member: "Get", + signature: "ss", + body: [SHORTCUTS, "version"], + }, + stopped, + ); + this.assertCurrent(stopped); this.version = decodeVersion(version.body[0]).value; - for (const rule of [ - `type='signal',sender='${this.owner}',path_namespace='${PATH}'`, - `type='signal',sender='org.freedesktop.DBus',interface='${DBUS}',member='NameOwnerChanged',arg0='${PORTAL}'`, - ]) - await this.call({ - destination: DBUS, - path: "/org/freedesktop/DBus", - interface: DBUS, - member: "AddMatch", - signature: "s", - body: [rule], - }); - const created = await this.request("CreateSession", "", [], { - session_handle_token: new Variant( - "s", - `t3_capture_${NodeCrypto.randomUUID().replaceAll("-", "")}`, - ), - }); - const session = decodeSession(created).session_handle.value; - const sessionNamespace = this.namespace.replace("/request/", "/session/"); - if (!session.startsWith(sessionNamespace)) throw new Error("Invalid shortcut session handle."); - this.session = session; + await this.request( + "CreateSession", + "", + [], + { + session_handle_token: new Variant( + "s", + `t3_capture_${NodeCrypto.randomUUID().replaceAll("-", "")}`, + ), + }, + stopped, + ); + this.assertCurrent(stopped); this.shortcutId = this.managedByHyprland ? HYPRLAND_CAPTURE_ACTION : `t3-snap-shot-${NodeCrypto.createHash("sha256").update(trigger).digest("hex").slice(0, 16)}`; // Every session must bind, even when the desktop remembers this shortcut's approval. - const bound = await this.request("BindShortcuts", "oa(sa{sv})s", [ - this.session, + await this.request( + "BindShortcuts", + "oa(sa{sv})s", [ + this.session, [ - this.shortcutId, - { - description: new Variant("s", "Capture a window"), - ...(!this.managedByHyprland ? { preferred_trigger: new Variant("s", trigger) } : {}), - }, + [ + this.shortcutId, + { + description: new Variant("s", "Capture a window"), + ...(!this.managedByHyprland ? { preferred_trigger: new Variant("s", trigger) } : {}), + }, + ], ], + "", ], - "", - ]); - if (bound) this.bound(decodeShortcutsResult(bound).shortcuts.value); + {}, + stopped, + ); } }