| 1 | # Linux window capture |
| 2 | |
| 3 | Wayland only. Each capture picks one backend in `LinuxSnapShot.ts` (Hyprland helper, Niri IPC, KDE |
| 4 | helper, Screenshot portal with active-window target, GNOME extension, then the Electron picker) and |
| 5 | never falls back to another on failure, cancellation, or denial. A stale `NIRI_SOCKET` or a reachable |
| 6 | GNOME extension must not select another desktop's backend; session detection comes from |
| 7 | `XDG_CURRENT_DESKTOP`, in `linuxCaptureSession.ts`. |
| 8 | |
| 9 | ## Module boundaries |
| 10 | |
| 11 | `dbus-next` must only be reached through dynamic imports. `linuxCaptureSession.ts` holds the pure |
| 12 | helpers (session detection, Niri binding text, portal key mapping, PNG reading) so the main process |
| 13 | can answer "which desktop is this" without loading a D-Bus client on macOS or Windows. |
| 14 | |
| 15 | `patches/dbus-next@0.10.2.patch` removes `usocket` (its optional native Unix-FD transport) and |
| 16 | connects to both `unix:path=` and `unix:abstract=` buses through Node's `net`. We never pass Unix |
| 17 | descriptors, and upstream had no `net` fallback for abstract sockets, which `dbus-launch` produces |
| 18 | outside systemd. Keep the patch when bumping `dbus-next`. |
| 19 | |
| 20 | ## Accessibility identity |
| 21 | |
| 22 | The Screenshot portal returns a PNG URI and no window identity, so portal and picker captures carry |
| 23 | no accessibility data. Do not infer it from the subsequently focused window or a guessed title. |
| 24 | |
| 25 | Native backends supply PID, title, and frame. AT-SPI matching is by PID plus a unique title and size |
| 26 | match. AT-SPI reports `(0, 0)` for a window's screen position on Wayland even when the compositor |
| 27 | knows the real one, so only width and height are compared, and descendant coordinates are trusted |
| 28 | only when the AT-SPI root agrees with the compositor frame and at least one descendant reports a |
| 29 | distinct position. Otherwise bounds are `null` rather than zero-origin rectangles. |
| 30 | |
| 31 | Title matching ignores a single leading Braille CLI spinner frame, since terminals change it between |
| 32 | capture and lookup. KDE supplies both `frameGeometry` and `clientGeometry`; accept either verified |
| 33 | size, never a decoration-height tolerance. |
| 34 | |
| 35 | On GNOME, browser accessibility bridges need `org.gnome.desktop.interface toolkit-accessibility` |
| 36 | before the browser starts. Do not toggle it during capture. |
| 37 | |
| 38 | ## KDE |
| 39 | |
| 40 | KWin authorizes `org.kde.KWin.ScreenShot2` by resolving the calling PID's executable against its |
| 41 | application registry, so the helper is installed at a stable path under the XDG data home with a |
| 42 | hidden desktop entry, not run from the AppImage mount. Two traps: |
| 43 | |
| 44 | - `X-KDE-DBUS-Restricted-Interfaces` uses KConfig's comma-separated list syntax. A trailing |
| 45 | semicolon becomes part of the interface name and KWin rejects it. |
| 46 | - KService's cache key includes search paths and locale. Refresh with `kbuildsycoca6` both directly |
| 47 | and through `systemd-run --user`; refreshing only inside the AppImage environment can leave KWin |
| 48 | reading stale permissions. |
| 49 | |
| 50 | Readiness is checked by calling `CaptureWindow` with an invalid ID and expecting `InvalidWindow`, |
| 51 | which runs after KWin's authorization check. Files on disk alone never mean ready. |
| 52 | |
| 53 | Plasma consumes Shift for shifted digits and produces a punctuation keysym, so a successfully bound |
| 54 | `Ctrl+Shift+2` may never fire on some layouts. Letter chords work around it. A general fix needs |
| 55 | layout-aware encoding. |
| 56 | |
| 57 | ## Hyprland |
| 58 | |
| 59 | The helper maps a foreign-toplevel handle to the full 64-bit window address with |
| 60 | `hyprland-toplevel-mapping-v1` before exporting through `hyprland-toplevel-export-v1`. Never |
| 61 | truncate the address or pick a window by title. |
| 62 | |
| 63 | Hyprland 0.56.2 renders an access-denied texture instead of failing the export frame when consent |
| 64 | is rejected or a `no_screen_share` rule applies. `ready` is not a permission grant, and pixels must |
| 65 | not be inspected to guess one. See |
| 66 | [ScreenshareFrame](https://github.com/hyprwm/Hyprland/blob/v0.56.2/src/managers/screenshare/ScreenshareFrame.cpp). |
| 67 | |
| 68 | Hyprland's GlobalShortcuts portal registers actions, not key chords. `shortcutActionRegistered` is |
| 69 | distinct from `shortcutRegistered`; nothing in the UI may claim the keys are reserved. |
| 70 | |
| 71 | The bundled protocol XML ships with the helper because its BSD license requires the notice. |
| 72 | |
| 73 | ## Niri |
| 74 | |
| 75 | Niri does not implement the global-shortcut portal. While capture is enabled the app owns |
| 76 | `<app-id>.SnapShot` on the session bus and exports `com.t3tools.SnapShot.Capture`; the config |
| 77 | binding spawns `gdbus` to call it. Development and packaged app IDs use separate names so a dev |
| 78 | build does not steal the user's binding. |
| 79 | |
| 80 | Niri supplies no global screenshot origin, so AT-SPI matching uses logical size and title only. |
| 81 | |
| 82 | ## Config edits |
| 83 | |
| 84 | `CaptureShortcutConfig` edits user dotfiles. The invariants: opening setup never reads the file; |
| 85 | **Review changes** is the read consent; the renderer only ever sees before/after text over trusted |
| 86 | desktop IPC; **Save** sends a proposal ID and the desktop re-verifies bytes, inode, mode, and |
| 87 | includes against the snapshot it previewed before writing. Writes go through a staged temp file and |
| 88 | atomic rename with a backup beside the original. Niri validates the staged file first. |
| 89 | |
| 90 | Modifier serialization writes Linux `Ctrl` explicitly, never the cross-platform `mod` alias. |
| 91 | |
| 92 | ## GNOME extension |
| 93 | |
| 94 | Source in `apps/desktop/gnome-extension`, UUID `snap-shot@t3.codes`. GNOME only discovers a newly |
| 95 | installed extension at login, so setup distinguishes "installed, needs logout" from "discovered but |
| 96 | disabled" and compares loaded and installed versions. |
| 97 | |
| 98 | The extension trusts callers that own `com.t3tools.T3Code.SnapShot` (or the `.Development` |
| 99 | variant) on the same connection. This is GNOME's trusted-session-client pattern, not authentication |
| 100 | against a hostile process on the user's bus. |
| 101 | |
| 102 | Electron does not position overlay windows on Wayland, so the flash and flight run as Shell actors |
| 103 | inside the extension with coordinates relative to T3's content area. Electron 44's restored-session |
| 104 | path can skip rebinding and leave callbacks behind on unregister, which is why |
| 105 | `PortalCaptureShortcut` owns its own portal session instead of using Electron's global-shortcut API. |
| 106 | |
| 107 | GNOME 50 removed `Meta.is_wayland_compositor`. Shell internals change across majors; verify each |
| 108 | version before adding it to `metadata.json`. |