Files
omarchycn/shell/plugins/notifications/NotificationLogic.js
T
Ryan Hughes bf2013e6f3 Make --exec take the command as rest-of-line words
Replace --exec-arg with an ergonomic --exec that consumes the rest of the line
as the click command. The caller's shell tokenizes the words into discrete
arguments before the tool sees them, and the shell runs them as positional
parameters (never a re-parsed string), so safety is identical to the argv form
while the call sites read naturally: `--exec omarchy toggle something`.

Crucially the tool never splits a string itself — a single quoted whole-command
argument is rejected and points at the unquoted form, because whitespace-
splitting a string hands argument boundaries to whoever controls its content
(the injection we are avoiding). --exec must come last; migrate every caller.
2026-08-23 14:26:25 -04:00

391 lines
14 KiB
JavaScript

function isChromiumDerived(app, appIcon) {
var source = (String(app || "") + "\n" + String(appIcon || "")).toLowerCase()
return source.indexOf("chrom") >= 0 || source.indexOf("brave") >= 0 ||
source.indexOf("vivaldi") >= 0 || source.indexOf("microsoft-edge") >= 0 ||
source.indexOf("opera") >= 0
}
function sanitizeBody(body, app, appIcon) {
var text = String(body || "").replace(/<img[^>]*>/gi, "")
if (!isChromiumDerived(app, appIcon)) return text
return text
.replace(/^\s*<a\b[^>]*>\s*(?:https?:\/\/|www\.)?(?:[a-z0-9-]+\.)+[a-z]{2,}(?::\d+)?(?:\/[^<\s]*)?\s*<\/a>\s*/i, "")
.replace(/^\s*(?:https?:\/\/|www\.)?(?:[a-z0-9-]+\.)+[a-z]{2,}(?::\d+)?(?:\/\S*)?\s+/i, "")
}
function summaryStartsWithGlyph(summary) {
var text = String(summary || "").replace(/^\s+/, "")
if (!text) return false
var offset = 1
var first = text.charCodeAt(0)
if (first >= 0xd800 && first <= 0xdbff && text.length > 1) offset = 2
var spaces = 0
while (offset < text.length && text.charAt(offset) === " ") {
spaces++
offset++
}
return spaces >= 2
}
function shouldBypassDnd(notification, criticalUrgency) {
var appName = String((notification && notification.appName) || "")
if (appName === "omarchy-action") return true
return appName === "notify-send" && notification && notification.urgency === criticalUrgency
}
function isEphemeralApp(appName) {
var name = String(appName || "")
return name === "notify-send" || name === "omarchy-action"
}
function stringHint(hints, name) {
try {
if (hints) {
var value = hints[name]
if (value !== undefined && value !== null) return String(value)
}
} catch (e) {
}
return ""
}
function glyphFromHints(hints) {
return stringHint(hints, "omarchy-glyph")
}
// The click action: a JSON argv string from omarchy-notification-send
// --exec. Carried as data so a toast restored after a shell restart stays
// clickable (a libnotify action can't — its sender is gone). Run via
// Util.execArgv as bash positional parameters, never a shell string, so
// attacker-controlled values (a title, a filename) can't become commands.
function execArgvFromHints(hints) {
return stringHint(hints, "omarchy-exec-argv")
}
// Validate a persisted omarchy-exec-argv into a runnable argv, or null. Fails
// closed so a malformed/hostile hint never reaches a shell: must be a non-empty
// JSON array of strings whose program is present and not a leading-dash option.
function parseExecArgv(value) {
var text = String(value || "")
if (!text) return null
var parsed
try {
parsed = JSON.parse(text)
} catch (e) {
return null
}
if (!Array.isArray(parsed) || parsed.length === 0) return null
for (var i = 0; i < parsed.length; i++) {
if (typeof parsed[i] !== "string") return null
}
if (!parsed[0] || parsed[0].charAt(0) === "-") return null
return parsed
}
function shouldRenderCompactGlyph(glyph, iconSource, singleLineToast) {
return String(glyph || "").length > 0 && String(iconSource || "").length === 0 && !!singleLineToast
}
function snapshotOf(notification, timestamp) {
var n = notification || {}
var id = n.id || 0
var expireTimeout = Number(n.expireTimeout || 0)
if (!isFinite(expireTimeout) || expireTimeout < 0) expireTimeout = 0
return {
id: id,
originalId: id,
app: n.appName || "",
appIcon: n.appIcon || "",
summary: String(n.summary || ""),
body: n.body || "",
image: n.image || "",
glyph: glyphFromHints(n.hints),
execArgv: execArgvFromHints(n.hints),
urgency: n.urgency,
expireTimeout: expireTimeout,
timestamp: timestamp === undefined ? Date.now() : timestamp
}
}
// Everything the popup card draws, and therefore everything an in-place
// update has to write through to the row and its file.
var POPUP_ROLES = ["app", "appIcon", "summary", "body", "image", "glyph", "execArgv", "urgency", "expireTimeout"]
function popupRoles() {
return POPUP_ROLES
}
// Whether a refresh has anything to write. Each property a client updates
// emits its own signal, and the catch-up refresh after a row is inserted
// usually finds the object exactly as it was snapshotted — without this,
// one update would rewrite the file several times over.
function popupRowChanged(row, updated) {
var current = row || {}
var next = updated || {}
for (var i = 0; i < POPUP_ROLES.length; i++) {
var role = POPUP_ROLES[i]
if (current[role] !== next[role]) return true
}
return false
}
// A client updating a notification through replaces_id keeps the identity of
// the popup it took over: the file name is the timestamp and id the popup was
// first persisted under, and the restore, replace and archive paths all key
// off that name. Only what the card draws comes from the updated object.
function replacementSnapshot(notification, originalId, timestamp) {
var updated = snapshotOf(notification, timestamp)
updated.id = originalId
updated.originalId = originalId
return updated
}
function historyEntry(value, normalUrgency) {
var e = value || {}
return {
id: e.id || 0,
originalId: e.originalId || e.id || 0,
app: e.app || "",
appIcon: e.appIcon || "",
summary: e.summary || "",
body: e.body || "",
image: e.image || "",
glyph: e.glyph || "",
execArgv: e.execArgv || "",
urgency: typeof e.urgency === "number" ? e.urgency : normalUrgency,
expireTimeout: 0,
timestamp: e.timestamp || 0
}
}
// notifications.json holds nothing but the last-set DND preference now that
// history is a directory of files. Older versions kept `pending`/`past`
// (and, older still, `entries`) arrays in there; their presence is reported
// so the service can rewrite the file without the dead payload.
function parseSettings(raw) {
var text = String(raw || "").trim()
if (!text) return { error: false, dnd: null, legacy: false }
try {
var parsed = JSON.parse(text)
return {
error: false,
dnd: parsed && typeof parsed.dnd === "boolean" ? parsed.dnd : null,
legacy: !!(parsed && (parsed.pending || parsed.past || parsed.entries))
}
} catch (e) {
return { error: true, errorMessage: String(e), dnd: null, legacy: false }
}
}
// ---------------------------------------------------- popup persistence
//
// Each on-screen popup is mirrored to its own file under
// ~/.local/state/omarchy/notifications/ so toasts survive shell restarts
// (e.g. the restart `omarchy-update` performs). The file exists exactly as
// long as the popup is on screen: it is written when the toast appears and
// moved into the history/ subdirectory when the toast expires, is dismissed,
// or its action is invoked. History is those moved files, newest last-10.
function popupEntry(value, normalUrgency) {
var entry = historyEntry(value, normalUrgency)
var expire = Number((value || {}).expireTimeout || 0)
if (!isFinite(expire) || expire < 0) expire = 0
entry.expireTimeout = expire
// Absolute expiry deadline, set only when a restore resets a surviving
// popup's display lifetime. Kept out of the entry entirely when unset so
// restored rows match the roles of freshly received ones.
var deadline = Number((value || {}).deadline || 0)
if (isFinite(deadline) && deadline > 0) entry.deadline = deadline
return entry
}
function popupFileName(entry) {
return imageStem(entry) + ".json"
}
// ---------------------------------------------------- persisted images
//
// A notification's images only exist while it is live: Chromium-family
// senders (all Omarchy web apps) delete their scoped /tmp files on close,
// and image-data hints surface as in-process image:// URLs that die with
// the server object. Persisted entries therefore reference their own
// copies, named by the entry's file stem so cleanup can find them from
// the JSON file name alone.
var PERSISTED_IMAGE_ROLES = ["appIcon", "image"]
function imageStem(entry) {
var e = entry || {}
return String(e.timestamp || 0) + "-" + String(e.originalId || 0)
}
// The filesystem path behind a file-backed image value, or "" for anything
// a copy can't capture: themed icon names, in-process image:// URLs, empty.
function localImageFile(value) {
var s = String(value || "")
if (s.indexOf("file://") === 0) {
s = s.slice(7)
try { s = decodeURIComponent(s) } catch (e) {}
}
return s.charAt(0) === "/" ? s : ""
}
// The entry as it should hit the disk, plus the copies that make it true.
// File-backed images redirect to their copy under imagesDir; dead image://
// URLs drop to "" (the card falls back to the app icon). Already-redirected
// values map onto themselves and produce no copy, keeping restores no-ops.
function persistablePopup(entry, imagesDir) {
var e = entry || {}
var out = {}
for (var key in e) out[key] = e[key]
var copies = []
for (var i = 0; i < PERSISTED_IMAGE_ROLES.length; i++) {
var role = PERSISTED_IMAGE_ROLES[i]
var value = String(out[role] || "")
if (!value) continue
var source = localImageFile(value)
if (source) {
var copy = String(imagesDir || "") + imageStem(e) + "-" + role
if (source !== copy) copies.push({ from: source, to: copy })
out[role] = "file://" + copy
} else if (value.indexOf("image://") === 0) {
out[role] = ""
}
}
return { entry: out, copies: copies }
}
function serializePopup(entry, normalUrgency) {
// Compact (single-line) on purpose: restore cats every file together and
// parses line by line, which only works when each file is one line.
return JSON.stringify(popupEntry(entry, normalUrgency))
}
// Parse the concatenation of every persisted popup file into entries,
// newest-first. Deliberately NO dedupe by originalId: ids restart from 1
// with every server process, so two files sharing an id are usually
// different generations — dropping the older one would silently discard a
// restored critical alert the moment a fresh notification reuses its id.
// The one case that leaves a genuine duplicate (a crash between a
// replacement's write and the replaced file's delete) merely re-shows a
// superseded toast, which expires or is dismissed and cleans itself up.
function parsePopupFiles(raw, normalUrgency) {
var lines = String(raw || "").split("\n")
var entries = []
for (var i = 0; i < lines.length; i++) {
var line = lines[i].trim()
if (!line) continue
try {
var value = JSON.parse(line)
if (value && typeof value === "object") entries.push(popupEntry(value, normalUrgency))
} catch (e) {
// A torn write from a crash mid-save — skip the line, keep the rest.
}
}
entries.sort(function(a, b) { return (b.timestamp || 0) - (a.timestamp || 0) })
return entries
}
// A persisted popup whose lifetime already ran out would have expired on
// screen had the shell kept running, so it is not restored. duration 0 means
// the popup never expires (critical urgency) and always survives restarts.
// A restore-reset deadline outranks the original timestamp: without it, a
// second restart would judge a re-shown toast by a clock that no longer
// governs its display and drop it while it is still on screen.
function popupExpired(entry, duration, now) {
var deadline = Number((entry || {}).deadline || 0)
if (isFinite(deadline) && deadline > 0) return Number(now) >= deadline
var lifetime = Number(duration || 0)
if (!isFinite(lifetime) || lifetime <= 0) return false
return (Number(now) - Number((entry || {}).timestamp || 0)) >= lifetime
}
function popupPlacement(barPosition, barClearance, gapsOut) {
var position = String(barPosition || "top")
var clearance = Number(barClearance)
var gap = Number(gapsOut)
if (!isFinite(clearance)) clearance = 0
if (!isFinite(gap)) gap = 0
return {
anchors: { top: true, bottom: false, left: false, right: true },
margins: {
top: position === "top" ? clearance : gap,
bottom: gap,
left: gap,
right: position === "right" ? clearance : gap
}
}
}
// The archived files are the history. They are read back exactly like the
// live popup files, then normalized into history rows: replaying a toast
// must not inherit the original's expire timeout or restore deadline, so it
// gets the standard on-screen lifetime for its urgency instead.
//
// liveRows are the toasts still on screen when the replay was asked for.
// They belong in it — they're the newest notifications there are — but the
// directory read races their archival, so they're carried across by hand and
// keyed by file name (timestamp + id) to drop the copy the read already saw.
function historyRows(raw, liveRows, normalUrgency, limit) {
var max = limit === undefined || limit === null ? 10 : Number(limit)
if (isNaN(max)) max = 10
max = Math.max(0, max)
var out = []
var seen = {}
function collect(rows) {
for (var i = 0; i < rows.length; i++) {
var entry = rows[i]
if (!entry) continue
var key = popupFileName(entry)
if (seen[key]) continue
seen[key] = true
out.push(historyEntry(entry, normalUrgency))
}
}
collect(Array.isArray(liveRows) ? liveRows : [])
collect(parsePopupFiles(raw, normalUrgency))
out.sort(function(a, b) { return (b.timestamp || 0) - (a.timestamp || 0) })
return out.slice(0, max)
}
if (typeof module !== "undefined") {
module.exports = {
isChromiumDerived: isChromiumDerived,
sanitizeBody: sanitizeBody,
summaryStartsWithGlyph: summaryStartsWithGlyph,
shouldBypassDnd: shouldBypassDnd,
isEphemeralApp: isEphemeralApp,
stringHint: stringHint,
glyphFromHints: glyphFromHints,
execArgvFromHints: execArgvFromHints,
parseExecArgv: parseExecArgv,
shouldRenderCompactGlyph: shouldRenderCompactGlyph,
snapshotOf: snapshotOf,
popupRoles: popupRoles,
popupRowChanged: popupRowChanged,
replacementSnapshot: replacementSnapshot,
historyEntry: historyEntry,
parseSettings: parseSettings,
historyRows: historyRows,
popupEntry: popupEntry,
popupFileName: popupFileName,
imageStem: imageStem,
localImageFile: localImageFile,
persistablePopup: persistablePopup,
serializePopup: serializePopup,
parsePopupFiles: parsePopupFiles,
popupExpired: popupExpired,
popupPlacement: popupPlacement
}
}