Files
Panama/config/dot/quickshell/services/Capture.qml
T
Gabriel Brown 8b59b78d9f Settle process-signal races across the services layer
A Process's exited and streamFinished signals aren't guaranteed to
fire in order, and several services decided an outcome on whichever
fired first: KdeConnect could report a successful file transfer as
failed if exited landed before the real stdout payload; Clipboard
could present a failed history query as an empty-but-healthy one;
Brightness could strand the last queued write of a drag; SoundFeedback
and SystemLocale could drop or misapply a rapid second toggle/click
because re-arming an already-running Process is a no-op. All five now
wait for both signals and let the authoritative one decide, matching
the pattern HomeAssistantConfig.qml already used correctly.

Health's "copy report" never enabled stdin, so it copied nothing
while claiming success. Capture announced every recording as saved
regardless of the recorder's actual exit code. Connectivity never
restarted Bluetooth discovery when the adapter was enabled from an
already-open page. CalendarAgenda left the UI in "loading" forever if
its helper died at startup, and the helper itself could crash
unguarded instead of reporting unavailable. Geocoding silently
dropped a query typed while the previous one was still in flight.
Notifs leaked tracked-but-undisplayed notifications under Do Not
Disturb, and dismissAll() skipped them.

Claude-Session: https://claude.ai/code/session_01E6TJUAh41HaP25MVHWkhRZ
2026-08-18 21:23:07 -04:00

462 lines
19 KiB
QML

pragma Singleton
// ─────────────────────────────────────────────────────────────────────────────
// Screenshot / screen-recording engine.
//
// This is the whole non-visual half of the GNOME 42 screenshot experience:
// freeze the screen, hand the frozen frame to the picker UI, then run grim or
// wf-recorder against whatever region the user chose.
//
// The UI (modules/capture) only ever reads state from here and calls the
// shoot*/record* functions -- it never spawns a process itself.
//
// Verified against the installed tools on 2026-08-17:
// grim 1.x -g "<X>,<Y> <W>x<H>" <- SPACE before WxH, not a comma.
// "0,0,100x100" is rejected with
// "invalid geometry".
// -s <factor> output image scale
// -c include the cursor
// wf-recorder -g "<X>,<Y> <W>x<H>" same format as grim
// no cursor toggle in this build; the pointer is always
// recorded, so "Show Pointer" only affects screenshots.
// notify-send -A NAME=Label implies --wait and prints NAME on stdout,
// which is how the notification
// actions below are dispatched.
// satty 0.22 --filename / --output-filename / --early-exit
// ─────────────────────────────────────────────────────────────────────────────
import Quickshell
import Quickshell.Io
import Quickshell.Hyprland
import QtQuick
import qs.config
import qs.services
Singleton {
id: root
// ── Picker state (bound by modules/capture) ─────────────────────────────
// "screen" | "window" | "selection"
property string mode: "screen"
// Capture action. The booleans stay explicit because the existing picker
// reads them directly, while the setters guarantee only one special mode
// can be active at a time.
property bool recordMode: false
property bool intelligenceMode: false
// GNOME's "Show Pointer" checkbox. Screenshots only -- see the note above.
property bool showPointer: false
// file:// URL of the frozen frame the overlay paints as its backdrop, or
// "" while the grab is still in flight / after it failed.
readonly property string freezeUrl: root._freezePath === "" ? "" : "file://" + root._freezePath
// Top-left of the captured layout in Hyprland's global coordinate space.
// Everything the picker reports back is layout-global; the overlay
// subtracts these to get window-local pixels.
property real originX: 0
property real originY: 0
// Windows on the focused workspace, newest-focused first, as
// { address, class, title, x, y, w, h } in layout-global logical pixels.
property var windows: []
// ── Recording state (read by the bar / recording indicator) ─────────────
readonly property bool recording: recProc.running
property int recordingSeconds: 0
property string recordingPath: ""
// ── Coordinate space ────────────────────────────────────────────────────
// `hyprctl -j clients` reports `at`/`size` in Hyprland's *logical* layout
// coordinates (the 3000x2000 space on this 1.5x display), and grim's and
// wf-recorder's -g flags consume the same logical space -- so no scaling is
// needed anywhere and this stays 1.0. It exists because that is the one
// thing here that could not be verified without a live Hyprland session: if
// window outlines land at 2/3 size, set it to 1.5 and the whole pipeline
// (outlines and grim geometry alike) corrects together.
property real coordinateScale: 1.0
// ── Paths ───────────────────────────────────────────────────────────────
readonly property string _home: Quickshell.env("HOME") || "/home/gib"
readonly property string shotDir: root._home + "/" + Settings.screenshotDir
readonly property string recDir: root._home + "/" + Settings.recordingDir
property string _freezePath: ""
property string _prevFreezePath: ""
function _stamp(): string {
return Qt.formatDateTime(new Date(), "yyyy-MM-dd HH-mm-ss");
}
// grim/wf-recorder geometry string, or "" for "the whole thing".
function _geom(x: real, y: real, w: real, h: real): string {
const s = root.coordinateScale;
return Math.round(x * s) + "," + Math.round(y * s) + " " + Math.round(w * s) + "x" + Math.round(h * s);
}
// ── Public API (driven by shell.qml's IpcHandler) ───────────────────────
// Open the GNOME-style picker. The frozen frame is grabbed *before* the
// overlay is shown, otherwise the overlay would appear in its own backdrop.
function open(): void {
if (root.recording) {
// Print while recording = stop, matching the way GNOME's indicator
// behaves. The lead binds this to the same key.
root.stopRecording();
return;
}
if (ShellState.captureOpen) {
// Print again while the picker is up dismisses it. Re-grabbing here
// would freeze a screen with the picker already in it.
root.close();
return;
}
if (ScreenIntelligence.visible)
ScreenIntelligence.close();
root.refreshWindows();
root._grabFreeze();
}
function openIntelligence(): void {
root.mode = "selection";
root.selectIntelligence();
root.open();
}
function selectScreenshot(): void {
root.recordMode = false;
root.intelligenceMode = false;
}
function selectRecording(): void {
root.recordMode = true;
root.intelligenceMode = false;
}
function selectIntelligence(): void {
root.recordMode = false;
root.intelligenceMode = true;
}
function close(): void {
ShellState.close();
root._dropFreeze();
}
// Immediate whole-screen screenshot, no UI. (Shift+Print)
function screenNow(): void {
root.shootRegion("");
}
// Immediate active-window screenshot, no UI. (Alt+Print)
function windowNow(): void {
activeWindowProc.running = false;
activeWindowProc.running = true;
}
function stopRecording(): void {
if (!recProc.running)
return;
// SIGINT, never SIGKILL: wf-recorder needs to flush and write the
// container trailer or the file is unplayable.
recProc.signal(2);
}
// ── Execution ───────────────────────────────────────────────────────────
// geom: "" for the full output, otherwise a grim geometry string.
function shootRegion(geom: string): void {
// Capital "From" matches the files GNOME already left in
// ~/Pictures/Screenshots, so old and new shots sort together.
const name = "Screenshot From " + root._stamp() + ".png";
const args = ["sh", "-c", root._shotScript, "qs-capture", root.shotDir, name];
if (root.showPointer)
args.push("-c");
if (geom !== "")
args.push("-g", geom);
else if (root._outputName !== "")
args.push("-o", root._outputName);
Quickshell.execDetached(args);
}
function recordRegion(geom: string): void {
if (recProc.running)
return;
// .mp4 rather than GNOME's .webm because Settings.recorderArgs encodes
// h264 on the AMD VAAPI device.
const name = "Screencast From " + root._stamp() + ".mp4";
root.recordingPath = root.recDir + "/" + name;
// mkdir + exec so the PID we later SIGINT is wf-recorder itself and not
// the shell wrapping it.
let cmd = ["sh", "-c", 'mkdir -p "$1" && shift && exec "$@"', "qs-capture", root.recDir, "wf-recorder", "-y"];
cmd = cmd.concat(Settings.recorderArgs.split(" ").filter(a => a !== ""));
if (geom !== "")
cmd.push("-g", geom);
else if (root._outputName !== "")
cmd.push("-o", root._outputName);
cmd.push("-f", root.recordingPath);
root.recordingSeconds = 0;
recProc.command = cmd;
recProc.running = true;
}
// Convenience wrappers used by the overlay, in layout-global logical px.
function shootRect(x: real, y: real, w: real, h: real): void {
root.shootRegion(root._geom(x, y, w, h));
}
function recordRect(x: real, y: real, w: real, h: real): void {
root.recordRegion(root._geom(x, y, w, h));
}
// Fire whatever the picker currently has selected, then dismiss it.
// rect is null for "the whole output".
function commit(rect: var): void {
ShellState.close();
root._dropFreeze();
// Let the overlay actually unmap before anything reads the screen,
// otherwise the dimming layer ends up in the picture.
commitDelay.rect = rect;
commitDelay.record = root.recordMode;
commitDelay.intelligence = root.intelligenceMode;
commitDelay.restart();
}
// ── Window enumeration ──────────────────────────────────────────────────
// Quickshell's HyprlandToplevel exposes no geometry, so this shells out.
function refreshWindows(): void {
clientsProc.running = false;
clientsProc.running = true;
}
readonly property string _outputName: {
const m = Hyprland.focusedMonitor;
return m ? m.name : "";
}
// ── Internals ───────────────────────────────────────────────────────────
// Shot pipeline: capture, copy to the clipboard, then notify with the
// GNOME-style follow-up actions. Runs detached so a lingering
// `notify-send --wait` never blocks the shell.
// $1 = directory, $2 = filename, $3.. = extra grim args
readonly property string _shotScript: 'd=$1; n=$2; shift 2
mkdir -p "$d" || exit 1
p="$d/$n"
if ! grim "$@" "$p"; then
notify-send -a Screenshot -u critical "Screenshot failed" "grim could not capture the screen"
exit 1
fi
wl-copy --type image/png < "$p"
qs ipc call status-events screenshot "$p" >/dev/null 2>&1 || true
act=$(notify-send -a Screenshot -i "$p" -A open=Open -A annotate=Annotate -A folder=Folder "Screenshot captured" "$n")
case "$act" in
open) xdg-open "$p" ;;
annotate) satty --filename "$p" --output-filename "$p" --copy-command wl-copy --early-exit all ;;
folder) xdg-open "$d" ;;
esac'
readonly property string _recDoneScript: 'p=$1
d=$(dirname "$p")
act=$(notify-send -a Screencast -A open=Open -A folder=Folder "Screen recording saved" "$(basename "$p")")
case "$act" in
open) xdg-open "$p" ;;
folder) xdg-open "$d" ;;
esac'
function _grabFreeze(): void {
root._prevFreezePath = root._freezePath;
root._freezePath = "";
// Unique name per open: QML caches Image sources by URL, so reusing one
// path would repaint the previous frame.
const path = Quickshell.cachePath("capture-freeze-" + Date.now() + ".ppm");
// PPM, not PNG: this is a 4500x3000 grab and libpng would spend the
// better part of a second compressing an image we throw away seconds
// later. PPM is a raw dump, so the freeze appears immediately.
// -o pins the grab to the focused output so the picker's coordinate
// space is exactly this window's.
const cmd = ["sh", "-c", 'mkdir -p "$(dirname "$1")" && d=$1 && shift && exec grim -t ppm "$@" "$d"', "qs-capture", path];
if (root._outputName !== "")
cmd.push("-o", root._outputName);
freezeProc.pendingPath = path;
freezeProc.command = cmd;
freezeProc.running = false;
freezeProc.running = true;
}
function _dropFreeze(): void {
const stale = root._freezePath;
root._freezePath = "";
if (stale !== "")
Quickshell.execDetached(["rm", "-f", stale]);
}
Timer {
id: commitDelay
property var rect: null
property bool record: false
property bool intelligence: false
// A handful of frames at 60Hz, enough for the compositor to recomposite
// the output without the overlay on it.
interval: 90
onTriggered: {
const r = commitDelay.rect;
const geom = r ? root._geom(r.x, r.y, r.width, r.height) : "";
if (commitDelay.intelligence)
ScreenIntelligence.analyzeRegion(geom, root._outputName);
else if (commitDelay.record)
root.recordRegion(geom);
else
root.shootRegion(geom);
commitDelay.rect = null;
}
}
Process {
id: freezeProc
property string pendingPath: ""
onExited: (code, status) => {
if (code === 0) {
root._freezePath = freezeProc.pendingPath;
} else {
// No wlr-screencopy (or no compositor at all): still open the
// picker so Selection/Window mode remain usable, just without
// the frozen backdrop.
root._freezePath = "";
console.warn("Capture: grim freeze failed with exit code", code);
}
if (root._prevFreezePath !== "") {
Quickshell.execDetached(["rm", "-f", root._prevFreezePath]);
root._prevFreezePath = "";
}
ShellState.open("capture");
}
}
Process {
id: clientsProc
command: ["hyprctl", "-j", "clients"]
stdout: StdioCollector {
onStreamFinished: {
const focused = Hyprland.focusedWorkspace;
let out = [];
try {
const raw = JSON.parse(this.text);
for (const c of raw) {
if (!c.mapped || c.hidden)
continue;
if (!c.size || c.size[0] <= 0 || c.size[1] <= 0)
continue;
// Only the workspace the user is looking at. If
// Hyprland hasn't reported a focused workspace yet,
// show everything rather than nothing.
if (focused && c.workspace && c.workspace.id !== focused.id)
continue;
out.push({
address: c.address || "",
appId: c.class || "",
title: c.title || "",
order: c.focusHistoryID === undefined ? 999 : c.focusHistoryID,
x: c.at[0],
y: c.at[1],
w: c.size[0],
h: c.size[1]
});
}
// Ascending focusHistoryID == front to back, so a hit test
// walking this list picks the topmost window first.
out.sort((a, b) => a.order - b.order);
} catch (e) {
// Not running under Hyprland, or hyprctl printed its
// "HYPRLAND_INSTANCE_SIGNATURE not set!" error. Window mode
// simply shows nothing instead of throwing.
out = [];
}
root.windows = out;
}
}
}
Process {
id: activeWindowProc
command: ["hyprctl", "-j", "activewindow"]
stdout: StdioCollector {
onStreamFinished: {
try {
const c = JSON.parse(this.text);
if (c && c.at && c.size && c.size[0] > 0)
root.shootRect(c.at[0], c.at[1], c.size[0], c.size[1]);
else
root.shootRegion("");
} catch (e) {
// No Hyprland / no focused window: fall back to the whole
// screen rather than doing nothing.
root.shootRegion("");
}
}
}
}
Process {
id: recProc
onExited: (code, status) => {
recTimer.stop();
const path = root.recordingPath;
// SIGINT gives exit code 2 (or 130 through a shell); both mean the
// user pressed stop and the file was finalised normally. Any other
// code means wf-recorder died or errored before finalising, so the
// file may be missing or truncated -- never report success then.
const clean = code === 2 || code === 130;
if (path !== "") {
if (clean) {
StatusEvents.publish({
key: "capture-recording",
glyph: "\u{F044A}",
title: "Screen recording saved",
detail: path.split("/").pop(),
tone: "ok",
priority: StatusEvents.importantPriority,
actionId: "open-path",
actionData: path
});
Quickshell.execDetached(["sh", "-c", root._recDoneScript, "qs-capture", path]);
} else {
StatusEvents.publish({
key: "capture-recording",
glyph: "\u{F044A}",
title: "Screen recording failed",
detail: "wf-recorder exited unexpectedly (code " + code + ")",
tone: "warn",
priority: StatusEvents.importantPriority
});
}
}
root.recordingPath = "";
root.recordingSeconds = 0;
}
onStarted: {
root.recordingSeconds = 0;
recTimer.start();
StatusEvents.publish({
key: "capture-recording",
glyph: "\u{F044A}",
title: "Screen recording started",
detail: "Panama is capturing the selected area",
tone: "danger",
priority: StatusEvents.criticalPriority,
actionId: "open-activity"
});
}
}
// Only ticks while a recording is live -- nothing in this shell repaints
// when idle.
Timer {
id: recTimer
interval: 1000
repeat: true
onTriggered: root.recordingSeconds += 1
}
}