The audit's second tier: values that were measurements of the author's desktop, shipped to every machine as if they were defaults. Settings greeted every human as Gabriel; it now greets whoever accountsservice says is signed in, and nobody when it says nothing. The weather shipped his home coordinates and confidently reported his forecast anywhere on earth; it now ships unset, fetches nothing until a location is chosen, and the location row says so. The GTK bookmarks carried seven /home/gib paths and his file server into every file dialog; they are now generated per machine from a template and gitignored -- Nautilus edits the instance freely, the way settings.ini already worked one file over. Web search routed through his personal bang redirector; the engine is now the webSearchUrl preference with a DuckDuckGo default, read by both the script command and the suggestions extension, which the launcher-search contract already pins to one another. The GPU vitals path defaulted to his card1 and lost the readout on any machine enumerated differently; a machine with exactly one GPU now adopts it. And the Containers and Snapshots pages hide once a scan proves their backing stack absent, instead of rendering permanently empty on machines that never had podman or snapper. Lesser residue swept in the same pass: the DP-2 hyprpaper block one machine needed, the author's username-typo expansions (moved to his personal seed in user/, where personal content belongs), a capture fallback into /home/gib, and a parity table asserting one machine's hardware as fact. Claude-Session: https://claude.ai/code/session_01Epx9ZC1gwm81K3jm9x9CKh
478 lines
20 KiB
QML
478 lines
20 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") || ""
|
|
|
|
// Both folders are settings now rather than a choice of three, so both can
|
|
// name somewhere outside home -- a second drive, a network mount. Prefixing
|
|
// $HOME unconditionally, as this did, would have turned /mnt/captures into
|
|
// ~/mnt/captures and written screenshots somewhere nobody looks.
|
|
function _resolve(folder: string): string {
|
|
const trimmed = String(folder ?? "").trim();
|
|
if (trimmed === "")
|
|
return root._home;
|
|
if (trimmed.startsWith("/"))
|
|
return trimmed;
|
|
if (trimmed.startsWith("~/"))
|
|
return root._home + trimmed.slice(1);
|
|
return root._home + "/" + trimmed;
|
|
}
|
|
|
|
readonly property string shotDir: root._resolve(Settings.screenshotDir)
|
|
readonly property string recDir: root._resolve(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
|
|
}
|
|
}
|