The click action of a notification was a free-form shell string run through `bash -lc`, safe only when every sender shell-quoted every interpolated value perfectly. One slip is RCE: a hostile yt-dlp video title forged an output record and injected an mpv option into the click command (mehmetince.net RCE, partially addressed by #7847). Add a parameterized transport: omarchy-notification-send gains --exec-arg (repeatable), encoding a JSON argv into the omarchy-exec-argv hint. The shell runs it with Quickshell.execDetached(argv) and no shell, so data an attacker controls is only ever one argument and can never be reparsed as a command. The shell fails closed on a malformed argv hint. The legacy free-form --exec string is retained but honored only from Omarchy's own omarchy-action toasts, and deprecated. Migrate all in-repo callers (screenshot, screen recording, taildrop receive, migrate-notify, crash-watch, yt-dlp host) to --exec-arg. Update docs and tests.
155 lines
5.5 KiB
QML
155 lines
5.5 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 directly, without a shell. Nothing in the array is
|
|
// reparsed, so an argument carrying attacker-controlled data (a filename, a
|
|
// title) can never turn into a command. Prefer this over execDetached for any
|
|
// command assembled from untrusted input.
|
|
function execArgv(argv) {
|
|
Quickshell.execDetached(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)
|
|
}
|
|
}
|
|
}
|