diff --git a/shell/Commons/Util.qml b/shell/Commons/Util.qml new file mode 100644 index 00000000..608c0cef --- /dev/null +++ b/shell/Commons/Util.qml @@ -0,0 +1,73 @@ +pragma Singleton +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 + + // 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) { + if (!c) return Qt.rgba(0, 0, 0, opacity) + if (typeof c === "string") c = Qt.color(c) + return Qt.rgba(c.r, c.g, c.b, opacity) + } + + // 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 isPlainObject(value) { + return value !== null && typeof value === "object" && !Array.isArray(value) + } + + // 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)) + } + + // Layout normalization shared by the bar host and the bar settings panel + // 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: entry } + if (isPlainObject(entry) && entry.id) return cloneJson(entry) + 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) + } + } +} diff --git a/shell/Commons/qmldir b/shell/Commons/qmldir index 807c744f..e6145b96 100644 --- a/shell/Commons/qmldir +++ b/shell/Commons/qmldir @@ -1,3 +1,4 @@ module qs.Commons singleton Color 1.0 Color.qml singleton Style 1.0 Style.qml +singleton Util 1.0 Util.qml diff --git a/shell/plugins/app-launcher/AppLauncher.qml b/shell/plugins/app-launcher/AppLauncher.qml index 1022340e..cffc8a26 100644 --- a/shell/plugins/app-launcher/AppLauncher.qml +++ b/shell/plugins/app-launcher/AppLauncher.qml @@ -78,19 +78,11 @@ Item { root.shell.hide((root.manifest && root.manifest.id) || "omarchy.app-launcher") } - function withAlpha(color, alpha) { - return Qt.rgba(color.r, color.g, color.b, alpha) - } - - function fileUrl(path) { - return "file://" + String(path).split("/").map(encodeURIComponent).join("/") - } - function iconSource(icon) { var value = String(icon || "") if (value.length === 0) return Quickshell.iconPath("application-x-executable", true) if (value.indexOf("file://") === 0 || value.indexOf("image://") === 0) return value - if (value.charAt(0) === "/") return root.fileUrl(value) + if (value.charAt(0) === "/") return Util.fileUrl(value) return Quickshell.iconPath(value, true) } @@ -110,10 +102,6 @@ Item { try { return ToplevelManager.toplevels.values.length } catch (e) { return 0 } } - function shellQuote(value) { - return "'" + String(value).replace(/'/g, "'\\''") + "'" - } - function entrySearchText(entry) { if (!entry) return "" var keywords = "" @@ -140,7 +128,7 @@ Item { function hiddenEntryScanCommand() { var desktop = [Quickshell.env("XDG_CURRENT_DESKTOP"), Quickshell.env("XDG_SESSION_DESKTOP"), Quickshell.env("DESKTOP_SESSION")].filter(function(v) { return String(v || "").length > 0 }).join(":") var script = root.omarchyPath + "/shell/scripts/app-launcher-hidden-entries.sh" - return root.shellQuote(script) + " " + root.shellQuote(desktop) + return Util.shellQuote(script) + " " + Util.shellQuote(desktop) } function fuzzyScore(entry, query) { diff --git a/shell/plugins/background/Background.qml b/shell/plugins/background/Background.qml index c10f748f..58f07fff 100644 --- a/shell/plugins/background/Background.qml +++ b/shell/plugins/background/Background.qml @@ -62,13 +62,9 @@ Item { revealProgress = 0 } - function decodePayload(payload) { - try { return Qt.atob(String(payload || "")) } catch (e) { return "" } - } - function setPendingTheme(colorsB64, shellB64) { - pendingColorsRaw = decodePayload(colorsB64) - pendingShellRaw = decodePayload(shellB64) + pendingColorsRaw = Util.decodeBase64(colorsB64) + pendingShellRaw = Util.decodeBase64(shellB64) pendingThemeVersion = backgroundVersion } diff --git a/shell/plugins/bar/Bar.qml b/shell/plugins/bar/Bar.qml index 1436263a..6685ab9d 100644 --- a/shell/plugins/bar/Bar.qml +++ b/shell/plugins/bar/Bar.qml @@ -101,40 +101,14 @@ Item { return /^(top|bottom|left|right)$/.test(next) ? next : "top" } - function shellQuote(value) { - return "'" + String(value).replace(/'/g, "'\\''") + "'" - } - - function fileUrl(path) { - return "file://" + path.split("/").map(encodeURIComponent).join("/") - } - - function isPlainObject(value) { - return value !== null && typeof value === "object" && !Array.isArray(value) - } - - function normalizeLayoutEntry(entry) { - if (typeof entry === "string") return { id: entry } - if (isPlainObject(entry) && entry.id) return entry - return null - } - - function normalizeLayoutSection(list) { - if (!Array.isArray(list)) return [] - var result = [] - for (var i = 0; i < list.length; i++) { - var normalized = normalizeLayoutEntry(list[i]) - if (normalized) result.push(normalized) - } - return result - } - + // Apply tray-pinning on top of the shared layout normalization so the + // bar host and the bar settings panel can't drift on entry shape. function normalizeLayout(layout) { - if (!isPlainObject(layout)) layout = fallbackBarConfig.layout + var normalized = Util.normalizeLayout(Util.isPlainObject(layout) ? layout : fallbackBarConfig.layout) return { - left: pinTrayToInner(normalizeLayoutSection(layout.left), "left"), - center: pinTrayToInner(normalizeLayoutSection(layout.center), "center"), - right: pinTrayToInner(normalizeLayoutSection(layout.right), "right") + left: pinTrayToInner(normalized.left, "left"), + center: pinTrayToInner(normalized.center, "center"), + right: pinTrayToInner(normalized.right, "right") } } @@ -157,7 +131,7 @@ Item { } function applyBarConfig() { - var config = isPlainObject(barConfig) ? barConfig : fallbackBarConfig + var config = Util.isPlainObject(barConfig) ? barConfig : fallbackBarConfig position = normalizePosition(config.position) transparent = config.transparent === true @@ -175,7 +149,7 @@ Item { } function entrySettings(entry) { - if (!isPlainObject(entry)) return {} + if (!Util.isPlainObject(entry)) return {} var copy = {} for (var key in entry) { if (key === "id") continue @@ -186,7 +160,7 @@ Item { function entryId(entry) { if (typeof entry === "string") return entry - if (isPlainObject(entry) && entry.id) return String(entry.id) + if (Util.isPlainObject(entry) && entry.id) return String(entry.id) return "" } @@ -270,7 +244,7 @@ Item { if (!source && customModuleSafeName(name)) source = omarchyConfigDir + "/bar/modules/" + String(name) + ".qml" - return source ? fileUrl(source) : "" + return source ? Util.fileUrl(source) : "" } // First-party widgets are registered with the BarWidgetRegistry at startup. @@ -357,7 +331,7 @@ Item { var nextTransparent = !(root.transparent === true) if (root.shell && typeof root.shell.mutateShellConfig === "function") { root.shell.mutateShellConfig(function(config) { - if (!root.isPlainObject(config.bar)) config.bar = {} + if (!Util.isPlainObject(config.bar)) config.bar = {} config.bar.transparent = nextTransparent }) } else { @@ -470,7 +444,7 @@ Item { var name = value.substring(0, markerIndex).split("/").pop() var iconPath = value.substring(markerIndex + marker.length).split("&")[0] - return fileUrl(iconPath + "/hicolor/16x16/status/" + name + ".png") + return Util.fileUrl(iconPath + "/hicolor/16x16/status/" + name + ".png") } function trayTooltip(item) { @@ -478,7 +452,7 @@ Item { } function focusWorkspace(id) { - root.run("hyprctl dispatch " + shellQuote("hl.dsp.focus({ workspace = \"" + id + "\" })")) + root.run("hyprctl dispatch " + Util.shellQuote("hl.dsp.focus({ workspace = \"" + id + "\" })")) } function clockEntry() { @@ -562,7 +536,7 @@ Item { Process { id: screenRecordingProc - command: ["bash", "-lc", root.shellQuote(root.omarchyPath + "/shell/scripts/indicators/screen-recording.sh")] + command: ["bash", "-lc", Util.shellQuote(root.omarchyPath + "/shell/scripts/indicators/screen-recording.sh")] stdout: StdioCollector { waitForEnd: true onStreamFinished: root.updateIndicator("screenRecording", text) @@ -571,7 +545,7 @@ Item { Process { id: notificationSilencingProc - command: ["bash", "-lc", root.shellQuote(root.omarchyPath + "/shell/scripts/indicators/notification-silencing.sh")] + command: ["bash", "-lc", Util.shellQuote(root.omarchyPath + "/shell/scripts/indicators/notification-silencing.sh")] stdout: StdioCollector { waitForEnd: true onStreamFinished: root.updateIndicator("notifications", text) diff --git a/shell/plugins/bar/widgets/audioPanel.qml b/shell/plugins/bar/widgets/audioPanel.qml index dca97277..1b16caa9 100644 --- a/shell/plugins/bar/widgets/audioPanel.qml +++ b/shell/plugins/bar/widgets/audioPanel.qml @@ -316,8 +316,8 @@ Item { if (!node) return Pipewire.preferredDefaultAudioSink = node if (root.bar && node.id !== undefined && node.name) { - var idArg = root.bar.shellQuote(String(node.id)) - var nameArg = root.bar.shellQuote(String(node.name)) + var idArg = Util.shellQuote(String(node.id)) + var nameArg = Util.shellQuote(String(node.name)) root.bar.run("wpctl set-default " + idArg + " 2>/dev/null || true; " + "pactl set-default-sink " + nameArg + " 2>/dev/null || true; " + "pactl list short sink-inputs 2>/dev/null | awk '{ print $1 }' | while read -r input; do " @@ -329,8 +329,8 @@ Item { if (!node) return Pipewire.preferredDefaultAudioSource = node if (root.bar && node.id !== undefined && node.name) { - var idArg = root.bar.shellQuote(String(node.id)) - var nameArg = root.bar.shellQuote(String(node.name)) + var idArg = Util.shellQuote(String(node.id)) + var nameArg = Util.shellQuote(String(node.name)) root.bar.run("wpctl set-default " + idArg + " 2>/dev/null || true; " + "pactl set-default-source " + nameArg + " 2>/dev/null || true; " + "pactl list short source-outputs 2>/dev/null | awk '{ print $1 }' | while read -r output; do " diff --git a/shell/plugins/bar/widgets/networkPanel.qml b/shell/plugins/bar/widgets/networkPanel.qml index deb71b73..882b7ef0 100644 --- a/shell/plugins/bar/widgets/networkPanel.qml +++ b/shell/plugins/bar/widgets/networkPanel.qml @@ -149,7 +149,7 @@ Item { if (!net) return if (net.connected) { disconnect(net.ssid); return } if (isProtected(net.security) && !net.known) { - var quotedSsid = bar.shellQuote(net.ssid) + var quotedSsid = Util.shellQuote(net.ssid) knownCheck.targetSsid = net.ssid knownCheck.command = ["bash", "-c", ` iwctl known-networks list 2>/dev/null \\ @@ -193,7 +193,7 @@ iwctl known-networks list 2>/dev/null \\ function copyToClipboard(value) { if (!value || !root.bar) return - Quickshell.execDetached(["bash", "-lc", "printf %s " + root.bar.shellQuote(value) + " | wl-copy"]) + Quickshell.execDetached(["bash", "-lc", "printf %s " + Util.shellQuote(value) + " | wl-copy"]) } function networkCommand() { @@ -374,8 +374,8 @@ iwctl station "$station" get-networks rssi-dbms 2>/dev/null \\ } function dnsCommand(provider) { - var command = root.bar ? root.bar.shellQuote(root.bar.omarchyPath + "/bin/omarchy-dns") : "omarchy-dns" - if (provider) command += " " + root.bar.shellQuote(provider) + var command = root.bar ? Util.shellQuote(root.bar.omarchyPath + "/bin/omarchy-dns") : "omarchy-dns" + if (provider) command += " " + Util.shellQuote(provider) return command } @@ -383,8 +383,8 @@ iwctl station "$station" get-networks rssi-dbms 2>/dev/null \\ if (!root.bar || !provider || actionProc.running) return if (provider === "Custom") { - var launcher = root.bar.shellQuote(root.bar.omarchyPath + "/bin/omarchy-launch-floating-terminal-with-presentation") - root.bar.run(launcher + " " + root.bar.shellQuote(root.dnsCommand(provider))) + var launcher = Util.shellQuote(root.bar.omarchyPath + "/bin/omarchy-launch-floating-terminal-with-presentation") + root.bar.run(launcher + " " + Util.shellQuote(root.dnsCommand(provider))) root.closePopout() return } @@ -415,7 +415,7 @@ iwctl station "$station" get-networks rssi-dbms 2>/dev/null \\ } function connectKnown(ssid) { - var quotedSsid = bar.shellQuote(ssid) + var quotedSsid = Util.shellQuote(ssid) runAction("connect", ssid, ` station=$(iwctl station list 2>/dev/null | sed -e 's/\\x1b\\[[0-9;]*m//g' | awk '/^[[:space:]]*wl/ { print $1; exit }') [[ -z $station ]] && { echo "no Wi-Fi station available" >&2; exit 1; } @@ -424,8 +424,8 @@ iwctl --dont-ask station "$station" connect ${quotedSsid} } function connectWithPassphrase(ssid, passphrase) { - var quotedSsid = bar.shellQuote(ssid) - var quotedPass = bar.shellQuote(passphrase) + var quotedSsid = Util.shellQuote(ssid) + var quotedPass = Util.shellQuote(passphrase) runAction("connect", ssid, ` station=$(iwctl station list 2>/dev/null | sed -e 's/\\x1b\\[[0-9;]*m//g' | awk '/^[[:space:]]*wl/ { print $1; exit }') [[ -z $station ]] && { echo "No Wi-Fi station available" >&2; exit 1; } @@ -452,7 +452,7 @@ iwctl station "$station" disconnect // success. If the station isn't on this SSID (or there is no station), // skip straight to forget. function forget(ssid) { - var quotedSsid = bar.shellQuote(ssid) + var quotedSsid = Util.shellQuote(ssid) runAction("forget", ssid, ` station=$(iwctl station list 2>/dev/null | sed -e 's/\\x1b\\[[0-9;]*m//g' | awk '/^[[:space:]]*wl/ { print $1; exit }') if [[ -n $station ]]; then @@ -1098,7 +1098,7 @@ fi // held a reference to the row delegate it could be destroyed by a // model refresh, and a rapid second click would overwrite it and // misroute the first result. - var quotedSsid = root.bar.shellQuote(row.net.ssid) + var quotedSsid = Util.shellQuote(row.net.ssid) knownCheck.targetSsid = row.net.ssid knownCheck.command = ["bash", "-c", ` iwctl known-networks list 2>/dev/null \\ diff --git a/shell/plugins/bar/widgets/weather.qml b/shell/plugins/bar/widgets/weather.qml index 4450fd8d..8e2cde40 100644 --- a/shell/plugins/bar/widgets/weather.qml +++ b/shell/plugins/bar/widgets/weather.qml @@ -552,7 +552,7 @@ Item { // Poll the weather pill text/class every minute. Local to this widget. Process { id: weatherProc - command: ["bash", "-lc", root.bar ? root.bar.shellQuote(root.bar.omarchyPath + "/shell/scripts/weather.sh") : ""] + command: ["bash", "-lc", root.bar ? Util.shellQuote(root.bar.omarchyPath + "/shell/scripts/weather.sh") : ""] stdout: StdioCollector { waitForEnd: true onStreamFinished: root.updateWeather(text) diff --git a/shell/plugins/clipboard-picker/ClipboardPicker.qml b/shell/plugins/clipboard-picker/ClipboardPicker.qml index eb25d512..4089423f 100644 --- a/shell/plugins/clipboard-picker/ClipboardPicker.qml +++ b/shell/plugins/clipboard-picker/ClipboardPicker.qml @@ -53,18 +53,6 @@ Item { else root.open("{}") } - function withAlpha(color, alpha) { - return Qt.rgba(color.r, color.g, color.b, alpha) - } - - function shellQuote(value) { - return "'" + String(value || "").replace(/'/g, "'\\''") + "'" - } - - function fileUrl(path) { - return "file://" + String(path).split("/").map(encodeURIComponent).join("/") - } - function normalizeEntry(value) { if (typeof value === "string") { return value.length > 0 ? { type: "text", text: value } : null @@ -155,7 +143,7 @@ Item { entryType: entry.type, fullText: isImage ? "" : String(entry.text || ""), previewText: isImage ? "Image" : String(entry.text || "").replace(/\s+/g, " "), - previewImage: isImage ? root.fileUrl(entry.path) : "", + previewImage: isImage ? Util.fileUrl(entry.path) : "", path: isImage ? String(entry.path || "") : "", mime: isImage ? String(entry.mime || "image/png") : "text/plain", index: outCount @@ -201,9 +189,9 @@ Item { if (!row) return root.opened = false if (row.entryType === "image") { - Quickshell.execDetached(["bash", "-lc", "wl-copy --type " + root.shellQuote(row.mime) + " < " + root.shellQuote(row.path) + "; sleep 0.15; wtype -M shift -k Insert -m shift 2>/dev/null || true"]) + Quickshell.execDetached(["bash", "-lc", "wl-copy --type " + Util.shellQuote(row.mime) + " < " + Util.shellQuote(row.path) + "; sleep 0.15; wtype -M shift -k Insert -m shift 2>/dev/null || true"]) } else if (row.fullText) { - Quickshell.execDetached(["bash", "-lc", "printf %s " + root.shellQuote(row.fullText) + " | wl-copy; sleep 0.15; wtype -M shift -k Insert -m shift 2>/dev/null || true"]) + Quickshell.execDetached(["bash", "-lc", "printf %s " + Util.shellQuote(row.fullText) + " | wl-copy; sleep 0.15; wtype -M shift -k Insert -m shift 2>/dev/null || true"]) } } @@ -429,8 +417,8 @@ Item { width: parent.width / 2 - root.contentSpacing / 2 height: parent.height radius: root.cornerRadius - color: root.withAlpha(root.background, 0.5) - border.color: root.withAlpha(root.border, 0.1) + color: Util.alpha(root.background, 0.5) + border.color: Util.alpha(root.border, 0.1) border.width: Style.normalBorderWidth clip: true diff --git a/shell/plugins/emoji-picker/EmojiPicker.qml b/shell/plugins/emoji-picker/EmojiPicker.qml index 7af59bac..05245a12 100644 --- a/shell/plugins/emoji-picker/EmojiPicker.qml +++ b/shell/plugins/emoji-picker/EmojiPicker.qml @@ -64,10 +64,6 @@ Item { else root.open("{}") } - function withAlpha(color, alpha) { - return Qt.rgba(color.r, color.g, color.b, alpha) - } - function loadEmojis(raw) { try { var data = JSON.parse(raw) diff --git a/shell/plugins/image-picker/ImagePicker.qml b/shell/plugins/image-picker/ImagePicker.qml index 81a5255b..d53a22b7 100644 --- a/shell/plugins/image-picker/ImagePicker.qml +++ b/shell/plugins/image-picker/ImagePicker.qml @@ -46,14 +46,6 @@ Item { onOpenedChanged: if (!opened) layoutSettled = false - function fileUrl(path) { - return "file://" + path.split("/").map(encodeURIComponent).join("/") - } - - function shellQuote(value) { - return "'" + String(value).replace(/'/g, "'\\''") + "'" - } - function scriptPath(name) { return omarchyPath + "/shell/scripts/" + name } @@ -72,19 +64,6 @@ Item { }) } - // Decode a base64-encoded UTF-8 string sent via IPC. Used for fields that - // would otherwise carry embedded newlines or tabs (image rows, raw colors - // JSON) which bash IPC arguments can't reliably round-trip. - function decodeBase64(value) { - var s = String(value || "") - if (!s) return "" - try { return Qt.atob(s) } catch (e) { return s } - } - - function withAlpha(color, alpha) { - return Qt.rgba(color.r, color.g, color.b, alpha) - } - function currentPath() { if (imageArray.length === 0 || !itemMatches(selectedIndex)) return "" return imageArray[selectedIndex].filePath @@ -176,7 +155,7 @@ Item { if (releaseProc.running || doneFilesToRelease.length === 0) return var path = doneFilesToRelease.shift() - releaseProc.command = ["bash", "-lc", ": > " + shellQuote(path)] + releaseProc.command = ["bash", "-lc", ": > " + Util.shellQuote(path)] releaseProc.running = true } @@ -200,7 +179,7 @@ Item { selectionFile = "" doneFile = "" - applyProc.command = ["bash", "-lc", "printf '%s\\n' " + shellQuote(path) + " > " + shellQuote(activeSelectionFile) + "; : > " + shellQuote(activeDoneFile)] + applyProc.command = ["bash", "-lc", "printf '%s\\n' " + Util.shellQuote(path) + " > " + Util.shellQuote(activeSelectionFile) + "; : > " + Util.shellQuote(activeDoneFile)] applyProc.running = true } @@ -392,7 +371,7 @@ Item { doneFile: string, showLabels: string, filterable: string): string { - var rows = root.decodeBase64(imageRowsB64) + var rows = Util.decodeBase64(imageRowsB64) root.openSelector(imageDirs, rows, selectedImage, selectionFile, doneFile, showLabels, filterable) return "ok" @@ -402,7 +381,7 @@ Item { selectedImage: string, showLabels: string, filterable: string): string { - var rows = root.decodeBase64(imageRowsB64) + var rows = Util.decodeBase64(imageRowsB64) root.preloadRows(rows, selectedImage, showLabels, filterable) return "ok" } @@ -576,7 +555,7 @@ Item { // Load only the initial/visited nearby images, but keep the // source once activated so Qt does not tear textures down as // selection moves through the carousel. - source: item.sourceActivated && item.thumbnailPath ? root.fileUrl(item.thumbnailPath) : "" + source: item.sourceActivated && item.thumbnailPath ? Util.fileUrl(item.thumbnailPath) : "" fillMode: Image.PreserveAspectCrop asynchronous: true cache: true @@ -585,7 +564,7 @@ Item { Rectangle { anchors.fill: parent - color: root.withAlpha(root.dimColor, item.selected ? 0 : 0.42) + color: Util.alpha(root.dimColor, item.selected ? 0 : 0.42) } } @@ -624,7 +603,7 @@ Item { text: root.currentLabel() color: root.foreground style: Text.Outline - styleColor: root.withAlpha(root.dimColor, 0.7) + styleColor: Util.alpha(root.dimColor, 0.7) font.pixelSize: Style.font.display font.weight: Font.DemiBold horizontalAlignment: Text.AlignHCenter @@ -641,7 +620,7 @@ Item { color: root.foreground opacity: 0.85 style: Text.Outline - styleColor: root.withAlpha(root.dimColor, 0.7) + styleColor: Util.alpha(root.dimColor, 0.7) font.pixelSize: Style.font.title horizontalAlignment: Text.AlignHCenter elide: Text.ElideRight diff --git a/shell/plugins/lock/LockView.qml b/shell/plugins/lock/LockView.qml index 34a6f719..8353470b 100644 --- a/shell/plugins/lock/LockView.qml +++ b/shell/plugins/lock/LockView.qml @@ -27,10 +27,9 @@ Item { signal clearFailureRequested() signal wakeRequested() - function withAlpha(color, alpha) { - return Qt.rgba(color.r, color.g, color.b, alpha) - } - + // Cache-busts the lock background by appending `?v=`. Adding a query + // string keeps Image's loader happy while forcing it to reload when the + // user picks a new background mid-session. function fileUrl(path) { if (!path) return "" var encoded = String(path).split("/").map(encodeURIComponent).join("/") diff --git a/shell/plugins/menu/Menu.qml b/shell/plugins/menu/Menu.qml index 097e7010..db427f5d 100644 --- a/shell/plugins/menu/Menu.qml +++ b/shell/plugins/menu/Menu.qml @@ -88,14 +88,6 @@ Item { ? Math.min(contentMargin * 2 + headerHeight + (mode === "input" ? 0 : contentSpacing + visibleRowsHeight), panel.height - Style.gapsOut * 2) : Math.min(Math.max(Style.space(220), contentMargin * 2 + headerHeight + contentSpacing + visibleRowsHeight), panel.height - Style.gapsOut * 2) - function withAlpha(color, alpha) { - return Qt.rgba(color.r, color.g, color.b, alpha) - } - - function shellQuote(value) { - return "'" + String(value || "").replace(/'/g, "'\\''") + "'" - } - function finishRequest(selection) { if (!root.requestActive || !root.doneFile) { root.opened = false @@ -109,9 +101,9 @@ Item { root.doneFile = "" if (selection === null || selection === undefined) { - resultProc.command = ["bash", "-lc", ": > " + root.shellQuote(activeDoneFile)] + resultProc.command = ["bash", "-lc", ": > " + Util.shellQuote(activeDoneFile)] } else { - resultProc.command = ["bash", "-lc", "printf '%s\\n' " + root.shellQuote(selection) + " > " + root.shellQuote(activeSelectionFile) + "; : > " + root.shellQuote(activeDoneFile)] + resultProc.command = ["bash", "-lc", "printf '%s\\n' " + Util.shellQuote(selection) + " > " + Util.shellQuote(activeSelectionFile) + "; : > " + Util.shellQuote(activeDoneFile)] } resultProc.running = true } @@ -1061,7 +1053,7 @@ Item { anchors.rightMargin: Style.space(4) anchors.verticalCenter: parent.verticalCenter height: Style.spacing.hairline - color: root.withAlpha(root.foreground, 0.2) + color: Util.alpha(root.foreground, 0.2) } } diff --git a/shell/plugins/polkit/PolkitAgent.qml b/shell/plugins/polkit/PolkitAgent.qml index 39efa507..5d004312 100644 --- a/shell/plugins/polkit/PolkitAgent.qml +++ b/shell/plugins/polkit/PolkitAgent.qml @@ -37,9 +37,6 @@ Item { readonly property int cardWidth: Math.min(Style.space(312), Math.max(Style.space(260), panel.width - Style.gapsOut * 2)) readonly property int cardHeight: panel.height > 0 ? Math.min(fieldHeight + contentMargin * 2, panel.height - Style.gapsOut * 2) : fieldHeight + contentMargin * 2 - function withAlpha(color, alpha) { - return Qt.rgba(color.r, color.g, color.b, alpha) - } function promptLooksFingerprint(text) { var s = String(text || "").toLowerCase() return s.indexOf("finger") !== -1 || s.indexOf("fprint") !== -1 || s.indexOf("swipe") !== -1 diff --git a/shell/plugins/settings/SettingsPanel.qml b/shell/plugins/settings/SettingsPanel.qml index 2a962255..30d1760f 100644 --- a/shell/plugins/settings/SettingsPanel.qml +++ b/shell/plugins/settings/SettingsPanel.qml @@ -173,33 +173,8 @@ Item { } // ---------------- draft helpers ------------------------------------------ - function cloneJson(value) { return JSON.parse(JSON.stringify(value || null)) } - function isPlainObject(value) { return value !== null && typeof value === "object" && !Array.isArray(value) } - - function normalizeLayoutEntry(entry) { - if (typeof entry === "string") return { id: entry } - if (isPlainObject(entry) && entry.id) return cloneJson(entry) - return null - } - - function normalizeLayout(layout) { - var sections = ["left", "center", "right"] - var result = {} - for (var i = 0; i < sections.length; i++) { - var s = sections[i] - var arr = [] - var src = (layout && layout[s]) || [] - for (var j = 0; j < src.length; j++) { - var entry = normalizeLayoutEntry(src[j]) - if (entry) arr.push(entry) - } - result[s] = arr - } - return result - } - function normalizeDraft(source) { - var bar = isPlainObject(source.bar) ? source.bar : {} + var bar = Util.isPlainObject(source.bar) ? source.bar : {} var plugins = Array.isArray(source.plugins) ? source.plugins.slice() : [] return { version: 1, @@ -208,10 +183,10 @@ Item { transparent: bar.transparent === true, centerAnchor: String(bar.centerAnchor || ""), fontFamily: "monospace", - layout: normalizeLayout(bar.layout || {}) + layout: Util.normalizeLayout(bar.layout || {}) }, plugins: plugins - .map(normalizeLayoutEntry) + .map(Util.normalizeLayoutEntry) .filter(function(e) { if (!e) return false var manifest = root.pluginRegistry ? root.pluginRegistry.installedPlugins[e.id] : null @@ -227,7 +202,7 @@ Item { if (diskText) { try { var parsed = JSON.parse(diskText) - if (isPlainObject(parsed) && parsed.version === 1) defaults = parsed + if (Util.isPlainObject(parsed) && parsed.version === 1) defaults = parsed } catch (e) { console.warn("Bad shell-defaults JSON, falling back to builtin:", e) defaults = builtinShellConfig @@ -240,7 +215,7 @@ Item { if (userText.trim()) { try { var u = JSON.parse(userText) - if (isPlainObject(u) && u.version === 1) source = u + if (Util.isPlainObject(u) && u.version === 1) source = u } catch (e) { console.warn("shell.json parse failed in panel:", e) } @@ -256,7 +231,7 @@ Item { function defaultBarDraft() { var source = defaultConfig - if (!isPlainObject(source) || !isPlainObject(source.bar) || !isPlainObject(source.bar.layout)) { + if (!Util.isPlainObject(source) || !Util.isPlainObject(source.bar) || !Util.isPlainObject(source.bar.layout)) { source = builtinShellConfig } else { var l = source.bar.layout @@ -267,8 +242,8 @@ Item { } function resetBarToDefaults() { - var next = cloneJson(draft) - next.bar = cloneJson(defaultBarDraft()) + var next = Util.cloneJson(draft) + next.bar = Util.cloneJson(defaultBarDraft()) draft = next draftRevision++ persistDraft() @@ -287,7 +262,7 @@ Item { function mutateSection(section, mutator) { var arr = sectionArray(section).slice() mutator(arr) - var nextDraft = cloneJson(draft) + var nextDraft = Util.cloneJson(draft) if (section === "plugins") nextDraft.plugins = arr else nextDraft.bar.layout[section] = arr draft = nextDraft @@ -314,7 +289,7 @@ Item { } function updateEntry(section, index, newEntry) { - mutateSection(section, function(a) { a[index] = cloneJson(newEntry) }) + mutateSection(section, function(a) { a[index] = Util.cloneJson(newEntry) }) } // ---------------- widget catalog ----------------------------------------- @@ -491,7 +466,7 @@ Item { property var widgetDialogEntry: ({}) function openWidgetSettings(sectionKey, entryIndex, entry) { - widgetDialogEntry = root.cloneJson(entry) + widgetDialogEntry = Util.cloneJson(entry) widgetDialogSection = sectionKey widgetDialogIndex = entryIndex widgetDialogVisible = true @@ -509,7 +484,7 @@ Item { function discardWidgetSettings() { widgetDialogVisible = false } function widgetDialogFieldChanged(key, value) { - var copy = root.cloneJson(widgetDialogEntry) + var copy = Util.cloneJson(widgetDialogEntry) copy[key] = value widgetDialogEntry = copy } @@ -777,7 +752,7 @@ Item { fontFamily: root.fontFamily onChanged: function(v) { if (root.draft.bar.position === v) return - var next = root.cloneJson(root.draft) + var next = Util.cloneJson(root.draft) next.bar.position = v root.draft = next root.markDirty() @@ -800,7 +775,7 @@ Item { fontFamily: root.fontFamily cornerRadius: root.cornerRadius onChanged: function(v) { - var next = root.cloneJson(root.draft) + var next = Util.cloneJson(root.draft) next.bar.centerAnchor = v === "(none)" ? "" : v root.draft = next root.markDirty() @@ -817,7 +792,7 @@ Item { fontFamily: root.fontFamily checked: root.draft.bar.transparent === true onClicked: { - var next = root.cloneJson(root.draft) + var next = Util.cloneJson(root.draft) next.bar.transparent = !(next.bar.transparent === true) root.draft = next root.markDirty() diff --git a/shell/services/PluginRegistry.qml b/shell/services/PluginRegistry.qml index 327d1d50..559424ff 100644 --- a/shell/services/PluginRegistry.qml +++ b/shell/services/PluginRegistry.qml @@ -1,6 +1,7 @@ import QtQuick import Quickshell import Quickshell.Io +import qs.Commons // Instance, not a singleton — see BarWidgetRegistry for rationale. QtObject { @@ -29,14 +30,6 @@ QtObject { // ---------------------------------------------------------------- helpers - function isPlainObject(value) { - return value !== null && typeof value === "object" && !Array.isArray(value) - } - - function fileUrl(path) { - return "file://" + String(path).split("/").map(encodeURIComponent).join("/") - } - function isSafeEntryPoint(value) { if (typeof value !== "string" || value.length === 0) return false if (value.charAt(0) === "/") return false @@ -45,7 +38,7 @@ QtObject { } function validateManifest(manifest, sourcePath) { - if (!isPlainObject(manifest)) { + if (!Util.isPlainObject(manifest)) { console.warn("PluginRegistry: manifest is not an object at " + sourcePath) return null } @@ -69,7 +62,7 @@ QtObject { console.warn("PluginRegistry: kinds must be a non-empty array at " + sourcePath) return null } - if (!isPlainObject(manifest.entryPoints)) { + if (!Util.isPlainObject(manifest.entryPoints)) { console.warn("PluginRegistry: entryPoints must be an object at " + sourcePath) return null } @@ -87,7 +80,7 @@ QtObject { } function entryPointUrl(manifest, kind) { - if (!isPlainObject(manifest)) return "" + if (!Util.isPlainObject(manifest)) return "" var ep = manifest.entryPoints ? manifest.entryPoints[kind] : null if (!ep) return "" var dir = manifest.__sourceDir || "" @@ -100,7 +93,7 @@ QtObject { console.warn("PluginRegistry: entry point escapes sourceDir: " + resolved) return "" } - return fileUrl(resolved) + return Util.fileUrl(resolved) } // Enabled = the plugin id is referenced somewhere in shell.json. That can @@ -125,8 +118,8 @@ QtObject { } function findEntryLocation(config, id) { - if (!isPlainObject(config)) return { found: false } - if (isPlainObject(config.bar) && isPlainObject(config.bar.layout)) { + if (!Util.isPlainObject(config)) return { found: false } + if (Util.isPlainObject(config.bar) && Util.isPlainObject(config.bar.layout)) { var sections = ["left", "center", "right"] for (var s = 0; s < sections.length; s++) { var arr = config.bar.layout[sections[s]] @@ -157,8 +150,8 @@ QtObject { var isBarWidget = manifest && Array.isArray(manifest.kinds) && manifest.kinds.indexOf("bar-widget") !== -1 shellConfigMutator(function(config) { // Ensure shape exists. - if (!isPlainObject(config.bar)) config.bar = { layout: { left: [], center: [], right: [] } } - if (!isPlainObject(config.bar.layout)) config.bar.layout = { left: [], center: [], right: [] } + if (!Util.isPlainObject(config.bar)) config.bar = { layout: { left: [], center: [], right: [] } } + if (!Util.isPlainObject(config.bar.layout)) config.bar.layout = { left: [], center: [], right: [] } if (!Array.isArray(config.plugins)) config.plugins = [] var location = findEntryLocation(config, key) if (value && !location.found) { diff --git a/shell/shell.qml b/shell/shell.qml index d5cdbd59..a399dbb9 100644 --- a/shell/shell.qml +++ b/shell/shell.qml @@ -56,21 +56,17 @@ ShellRoot { property var shellConfig: builtinShellConfig property bool suppressUserReload: false - function isPlainObject(value) { - return value !== null && typeof value === "object" && !Array.isArray(value) - } - function applyShellConfig() { // Decide which source is canonical: a valid user shell.json overrides // defaults entirely; otherwise fall back to defaults. We do not deep-merge. - var defaults = isPlainObject(defaultsConfig) ? defaultsConfig : builtinShellConfig + var defaults = Util.isPlainObject(defaultsConfig) ? defaultsConfig : builtinShellConfig var user = null var userText = userConfigFile.text() || "" if (userText.trim()) { try { var parsed = JSON.parse(userText) - if (isPlainObject(parsed) && parsed.version === 1) user = parsed - else if (isPlainObject(parsed)) console.warn("shell.json missing version: 1, using defaults") + if (Util.isPlainObject(parsed) && parsed.version === 1) user = parsed + else if (Util.isPlainObject(parsed)) console.warn("shell.json missing version: 1, using defaults") } catch (e) { console.warn("shell.json parse failed, using defaults:", e) } @@ -87,7 +83,7 @@ ShellRoot { } try { var parsed = JSON.parse(text) - if (isPlainObject(parsed) && parsed.version === 1) defaultsConfig = parsed + if (Util.isPlainObject(parsed) && parsed.version === 1) defaultsConfig = parsed else defaultsConfig = builtinShellConfig } catch (e) { console.warn("shell-defaults.json parse failed, using builtin:", e) @@ -104,7 +100,7 @@ ShellRoot { userConfigFile.setText(JSON.stringify(payload, null, 2) + "\n") } - readonly property var barConfig: shellConfig && isPlainObject(shellConfig.bar) ? shellConfig.bar : builtinShellConfig.bar + readonly property var barConfig: shellConfig && Util.isPlainObject(shellConfig.bar) ? shellConfig.bar : builtinShellConfig.bar FileView { id: defaultsFile path: shell.defaultsPath @@ -262,8 +258,8 @@ ShellRoot { function updateEntryInline(moduleName, settings) { var stripped = String(moduleName) var copy = JSON.parse(JSON.stringify(shellConfig || builtinShellConfig)) - if (!isPlainObject(copy.bar)) copy.bar = { layout: { left: [], center: [], right: [] } } - if (!isPlainObject(copy.bar.layout)) copy.bar.layout = { left: [], center: [], right: [] } + if (!Util.isPlainObject(copy.bar)) copy.bar = { layout: { left: [], center: [], right: [] } } + if (!Util.isPlainObject(copy.bar.layout)) copy.bar.layout = { left: [], center: [], right: [] } if (!Array.isArray(copy.plugins)) copy.plugins = [] var sections = ["left", "center", "right"]