Files
omarchycn/shell/Commons/Util.qml
T
Ryan Hughes d2fd2e11c6 Run argv click actions through a login shell as positional params
Quickshell.execDetached(argv) ran the click target with only the shell
process's stripped environment, so GUI actions like the screenshot editor
(tensaku-edit) — resolved on the login-shell PATH the old `bash -lc` string
exec provided — stopped launching on click.

Run the argv through `bash -lc 'exec "$@"'` instead: the script text is a
constant and the arguments are passed as positional parameters, which bash
expands without re-tokenizing or re-evaluating, so injection safety is intact
while PATH and session env match the old behavior exactly.
2026-08-23 12:25:48 -04:00

160 lines
5.9 KiB
QML

pragma Singleton
import Quickshell
import QtQuick
// Shared utility helpers used across plugins. Pure functions only — no
// state. Anything stateful belongs on Color, Style, or a service.
QtObject {
id: root
function clamp(value, min, max) {
var n = Number(value)
if (!isFinite(n)) return min
return Math.max(min, Math.min(max, n))
}
function clampAlpha(value) {
return clamp(value, 0, 1)
}
function wheelSteps(accumulator, delta) {
// Some mouse/compositor combinations scale a single notch well beyond
// Qt's conventional 120 units. Keep one event to one step while still
// accumulating the smaller deltas emitted by touchpads.
delta = Math.max(-120, Math.min(120, delta))
if (accumulator * delta < 0) accumulator = 0
var total = accumulator + delta
var steps = total < 0 ? Math.ceil(total / 120) : Math.floor(total / 120)
return { steps: steps, remainder: total - steps * 120 }
}
// Compose a base color with an opacity. Accepts a color object or a hex
// string; null/undefined yields transparent black at the requested alpha.
function alpha(c, opacity) {
var a = clampAlpha(opacity)
if (!c) return Qt.rgba(0, 0, 0, a)
if (typeof c === "string") c = Qt.color(c)
return Qt.rgba(c.r, c.g, c.b, a)
}
// file:// URL with each path segment percent-encoded so spaces and
// special chars in user paths don't break Image.source.
function fileUrl(path) {
if (!path) return ""
return "file://" + String(path).split("/").map(encodeURIComponent).join("/")
}
// Single-quote a string for bash. The replace handles embedded single
// quotes by closing, escaping, and re-opening the literal.
function shellQuote(value) {
return "'" + String(value || "").replace(/'/g, "'\\''") + "'"
}
function execDetached(command) {
Quickshell.execDetached(["bash", "-lc", command])
}
// Run an argv vector safely: the script text is the constant `exec "$@"`, so
// the arguments only ever land in bash's positional parameters, which it
// expands without re-tokenizing or re-evaluating — a value carrying
// attacker-controlled data ($(id), a filename, a title) stays one literal
// argument and can never turn into a command. The login shell (-l) is what
// makes this a drop-in for execDetached: click actions launch GUI apps
// (tensaku, mpv, xdg-open) that need the same PATH and session environment the
// login shell set up. Prefer this over execDetached for any command assembled
// from untrusted input.
function execArgv(argv) {
Quickshell.execDetached(["bash", "-lc", 'exec "$@"', "bash"].concat(argv))
}
function isPlainObject(value) {
return value !== null && typeof value === "object" && !Array.isArray(value)
}
function canonicalWidgetId(id) {
return String(id || "")
}
// Best-effort base64 decode. Returns "" on parse failure rather than
// surfacing garbage downstream.
function decodeBase64(value) {
var s = String(value || "")
if (!s) return ""
try { return Qt.atob(s) } catch (e) { return "" }
}
function cloneJson(value) {
return JSON.parse(JSON.stringify(value === undefined ? null : value))
}
// Parse the last line of a custom-module / indicator process output as
// waybar-style JSON ({text, class, tooltip, ...}). Falls back to {text: raw}
// when the output isn't JSON, and {} for empty output.
function parseModuleJson(raw) {
var text = String(raw || "").trim()
if (!text) return {}
var lines = text.split("\n")
try {
return JSON.parse(lines[lines.length - 1])
} catch (e) {
return { text: text }
}
}
// Standard Qt text-editing keys shared by every searchable panel's filter:
// Backspace delete previous character
// Ctrl+Backspace delete previous word (Qt DeleteStartOfWord)
// Ctrl+U clear the whole field
// True only when the event would actually change the text, so an empty
// filter never swallows the key — panels keep their own empty-filter
// fallbacks (e.g. menu back-navigation) in later branches.
function editsFilter(event, text) {
if (!text) return false
// Alt/Meta-modified sequences belong to other shortcuts — never edit here.
if (event.modifiers & (Qt.AltModifier | Qt.MetaModifier)) return false
if (event.key === Qt.Key_U) // Ctrl+U only (not Ctrl+Shift+U → Unicode input)
return event.modifiers === Qt.ControlModifier
return event.key === Qt.Key_Backspace // plain, Shift, or Ctrl Backspace
}
// New filter text after applying an edit key. Assumes editsFilter(event, text).
function editedFilter(event, text) {
if (event.key === Qt.Key_U) return "" // Ctrl+U: clear
if (event.modifiers & Qt.ControlModifier) // Ctrl+Backspace: word
return text.replace(/\s+$/, "").replace(/\S+$/, "")
return text.slice(0, -1) // Backspace: char
}
// Layout normalization shared by bar config consumers
// so the two never drift. Entries are deep-cloned to decouple from the
// input config; consumers can mutate without leaking back to shell.json.
function normalizeLayoutEntry(entry) {
if (typeof entry === "string") return { id: canonicalWidgetId(entry) }
if (isPlainObject(entry) && entry.id) {
var copy = cloneJson(entry)
copy.id = canonicalWidgetId(copy.id)
return copy
}
return null
}
function normalizeLayoutSection(list) {
if (!Array.isArray(list)) return []
var out = []
for (var i = 0; i < list.length; i++) {
var e = normalizeLayoutEntry(list[i])
if (e) out.push(e)
}
return out
}
function normalizeLayout(layout) {
var src = isPlainObject(layout) ? layout : {}
return {
left: normalizeLayoutSection(src.left),
center: normalizeLayoutSection(src.center),
right: normalizeLayoutSection(src.right)
}
}
}