Fork the desktop off Omarchy as a self-contained system
This commit is contained in:
@@ -0,0 +1,508 @@
|
||||
function stripJsonc(raw) {
|
||||
return String(raw || "")
|
||||
.replace(/^\s*\/\/[^\n]*(\n|$)/gm, "")
|
||||
.replace(/,(\s*[}\]])/g, "$1")
|
||||
}
|
||||
|
||||
function normalizeAliases(value) {
|
||||
if (Array.isArray(value)) return value.filter(function(v) { return v })
|
||||
if (typeof value === "string" && value) return [value]
|
||||
return []
|
||||
}
|
||||
|
||||
function normalizeItem(id, raw) {
|
||||
var value = raw || {}
|
||||
var aliases = normalizeAliases(value.aliases)
|
||||
var parent = value.parent
|
||||
if (parent === undefined)
|
||||
parent = id.indexOf(".") >= 0 ? id.split(".").slice(0, -1).join(".") : "root"
|
||||
if (id === "root") parent = ""
|
||||
|
||||
var kind = value.action ? "action" : (value.target ? "link" : "menu")
|
||||
|
||||
return {
|
||||
id: id,
|
||||
parent: parent,
|
||||
kind: kind,
|
||||
icon: value.icon || "",
|
||||
iconFont: value.iconFont || "",
|
||||
label: value.label || id,
|
||||
title: value.title || "",
|
||||
target: value.target || "",
|
||||
description: value.description || "",
|
||||
action: value.action || "",
|
||||
provider: value.provider || "",
|
||||
aliases: aliases,
|
||||
when: value.when || "",
|
||||
checked: value.checked || ""
|
||||
}
|
||||
}
|
||||
|
||||
function parseMenuJsonc(raw) {
|
||||
var stripped = stripJsonc(raw)
|
||||
if (!stripped.trim()) return []
|
||||
|
||||
var parsed
|
||||
try {
|
||||
parsed = JSON.parse(stripped)
|
||||
} catch (e) {
|
||||
return []
|
||||
}
|
||||
if (typeof parsed !== "object" || parsed === null) return []
|
||||
|
||||
var source = (parsed.items && typeof parsed.items === "object" && !Array.isArray(parsed.items))
|
||||
? parsed.items
|
||||
: parsed
|
||||
var out = []
|
||||
for (var id in source) {
|
||||
var entry = source[id]
|
||||
if (!entry || typeof entry !== "object" || Array.isArray(entry)) continue
|
||||
out.push(normalizeItem(id, entry))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function mergeMenuSources(defaultItems, userItems) {
|
||||
var nextItems = ({})
|
||||
var nextOrder = []
|
||||
var sources = [defaultItems || [], userItems || []]
|
||||
|
||||
for (var s = 0; s < sources.length; s++) {
|
||||
var src = sources[s]
|
||||
for (var i = 0; i < src.length; i++) {
|
||||
var entry = src[i]
|
||||
if (!entry || !entry.id) continue
|
||||
if (!nextItems[entry.id]) nextOrder.push(entry.id)
|
||||
var prior = nextItems[entry.id] || {}
|
||||
var merged = {}
|
||||
for (var k in prior) merged[k] = prior[k]
|
||||
for (var k2 in entry) merged[k2] = entry[k2]
|
||||
merged.id = entry.id
|
||||
nextItems[entry.id] = merged
|
||||
}
|
||||
}
|
||||
|
||||
if (!nextItems.root) {
|
||||
nextItems.root = { id: "root", parent: "", kind: "menu", icon: "", iconFont: "", label: "Go", title: "", target: "", description: "", aliases: [], when: "", checked: "", action: "", provider: "" }
|
||||
nextOrder.unshift("root")
|
||||
}
|
||||
for (var k3 = 0; k3 < nextOrder.length; k3++) nextItems[nextOrder[k3]].order = k3
|
||||
|
||||
return {
|
||||
items: nextItems,
|
||||
itemOrder: nextOrder
|
||||
}
|
||||
}
|
||||
|
||||
// Both merges below return fresh items/itemOrder objects for the caller to
|
||||
// assign in one go. They must never write into the maps they are handed: those
|
||||
// live in QML `var` properties, and an in-place write into such an object is
|
||||
// occasionally dropped by the engine — the key lands with an undefined value.
|
||||
// A lost write used to leave an id in itemOrder with no item behind it, and
|
||||
// the next merge then kept that orphan and appended a second row for the same
|
||||
// app, so the launcher listed it twice (and again on every later rescan).
|
||||
|
||||
// Swaps every app row for the current set. Rows keep the order they arrive in;
|
||||
// ids already claimed (including duplicate desktop ids) are listed once.
|
||||
function mergeAppRows(items, itemOrder, appRows) {
|
||||
var source = items || ({})
|
||||
var order = Array.isArray(itemOrder) ? itemOrder : []
|
||||
var rows = Array.isArray(appRows) ? appRows : []
|
||||
var nextItems = ({})
|
||||
var nextOrder = []
|
||||
|
||||
for (var i = 0; i < order.length; i++) {
|
||||
var id = order[i]
|
||||
var existing = source[id]
|
||||
// Orphans (an id with no item) are dropped rather than carried forward,
|
||||
// so a single lost write cannot compound into a duplicate row.
|
||||
if (!existing || existing.kind === "app") continue
|
||||
nextItems[id] = existing
|
||||
nextOrder.push(id)
|
||||
}
|
||||
|
||||
for (var j = 0; j < rows.length; j++) {
|
||||
var row = rows[j]
|
||||
if (!row || !row.id || nextItems[row.id]) continue
|
||||
row.order = nextOrder.length
|
||||
nextItems[row.id] = row
|
||||
nextOrder.push(row.id)
|
||||
}
|
||||
|
||||
return { items: nextItems, itemOrder: nextOrder }
|
||||
}
|
||||
|
||||
// Swaps the rows one provider contributed, leaving every other item untouched.
|
||||
// Rows carry the id of the submenu that produced them, so a provider that runs
|
||||
// again drops its previous batch — a plugin that was just enabled disappears
|
||||
// from the Enable list — without disturbing static children declared in JSONC.
|
||||
function swapProviderRows(items, itemOrder, menuId, rows) {
|
||||
var source = items || ({})
|
||||
var order = Array.isArray(itemOrder) ? itemOrder : []
|
||||
var incoming = Array.isArray(rows) ? rows : []
|
||||
var nextItems = ({})
|
||||
var nextOrder = []
|
||||
|
||||
for (var i = 0; i < order.length; i++) {
|
||||
var id = order[i]
|
||||
var existing = source[id]
|
||||
if (!existing || existing.providerMenu === menuId) continue
|
||||
nextItems[id] = existing
|
||||
nextOrder.push(id)
|
||||
}
|
||||
|
||||
for (var j = 0; j < incoming.length; j++) {
|
||||
var row = incoming[j]
|
||||
if (!row || !row.id || nextItems[row.id]) continue
|
||||
row.providerMenu = menuId
|
||||
row.order = nextOrder.length
|
||||
nextItems[row.id] = row
|
||||
nextOrder.push(row.id)
|
||||
}
|
||||
|
||||
return { items: nextItems, itemOrder: nextOrder }
|
||||
}
|
||||
|
||||
function item(items, id) {
|
||||
return items && items[id] ? items[id] : null
|
||||
}
|
||||
|
||||
// Routes may name a real id (`system`, `setup.power`) or an alias declared in
|
||||
// JSONC (`power-menu`, `settings`). An exact id beats any alias, and app rows
|
||||
// are never routable: their aliases carry .desktop Keywords and GenericName
|
||||
// for search, so an installed application could otherwise shadow a menu route
|
||||
// (htop ships `Keywords=system;...`). Unknown strings fall through as the
|
||||
// literal input so misspellings still attempt to open that id.
|
||||
function resolveRoute(items, itemOrder, input) {
|
||||
var raw = String(input || "").toLowerCase().replace(/_/g, "-")
|
||||
if (!raw || raw === "go" || raw === "menu") return "root"
|
||||
if (item(items, raw)) return raw
|
||||
var order = Array.isArray(itemOrder) ? itemOrder : []
|
||||
for (var i = 0; i < order.length; i++) {
|
||||
var entry = item(items, order[i])
|
||||
if (!entry || entry.kind === "app" || !entry.aliases) continue
|
||||
for (var j = 0; j < entry.aliases.length; j++) {
|
||||
var alias = String(entry.aliases[j] || "").toLowerCase().replace(/_/g, "-")
|
||||
if (alias === raw) return entry.id
|
||||
}
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
function slugify(value) {
|
||||
return String(value || "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "item"
|
||||
}
|
||||
|
||||
function depthFor(items, id) {
|
||||
var depth = 0
|
||||
var current = item(items, id)
|
||||
var guard = 0
|
||||
|
||||
while (current && current.parent && current.parent !== "root" && guard < 32) {
|
||||
depth += 1
|
||||
current = item(items, current.parent)
|
||||
guard += 1
|
||||
}
|
||||
|
||||
return depth
|
||||
}
|
||||
|
||||
function pathFor(items, id) {
|
||||
var labels = []
|
||||
var current = item(items, id)
|
||||
var guard = 0
|
||||
|
||||
while (current && current.id !== "root" && guard < 32) {
|
||||
labels.unshift(current.label)
|
||||
current = item(items, current.parent)
|
||||
guard += 1
|
||||
}
|
||||
|
||||
return labels.join(" › ")
|
||||
}
|
||||
|
||||
function parentPathFor(items, id) {
|
||||
var entry = item(items, id)
|
||||
if (!entry || !entry.parent || entry.parent === "root") return ""
|
||||
return pathFor(items, entry.parent)
|
||||
}
|
||||
|
||||
function isDescendantOf(items, id, ancestorId) {
|
||||
if (ancestorId === "root") return id !== "root"
|
||||
|
||||
var current = item(items, id)
|
||||
var guard = 0
|
||||
while (current && current.parent && guard < 32) {
|
||||
if (current.parent === ancestorId) return true
|
||||
current = item(items, current.parent)
|
||||
guard += 1
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
function childCount(items, itemOrder, id) {
|
||||
var count = 0
|
||||
var order = Array.isArray(itemOrder) ? itemOrder : []
|
||||
for (var i = 0; i < order.length; i++) {
|
||||
var entry = item(items, order[i])
|
||||
if (entry && entry.parent === id) count += 1
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
function isVisible(items, itemOrder, whenResults, entry, depth) {
|
||||
if (!entry) return false
|
||||
if (entry.when && whenResults && whenResults[entry.id] === false) return false
|
||||
if (entry.kind !== "menu" && entry.kind !== "link") return true
|
||||
if (entry.provider) return true
|
||||
|
||||
var guard = depth || 0
|
||||
if (guard >= 32) return false
|
||||
|
||||
var target = entry.kind === "link" ? entry.target : entry.id
|
||||
var order = Array.isArray(itemOrder) ? itemOrder : []
|
||||
for (var i = 0; i < order.length; i++) {
|
||||
var child = item(items, order[i])
|
||||
if (child && child.parent === target && isVisible(items, itemOrder, whenResults, child, guard + 1)) return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
function labelFor(entry, checkedResults) {
|
||||
if (!entry) return ""
|
||||
if (entry.checked && checkedResults && checkedResults[entry.id]) return entry.label + " ✓"
|
||||
return entry.label
|
||||
}
|
||||
|
||||
function searchableToken(value) {
|
||||
return String(value || "").replace(/[._-]+/g, " ")
|
||||
}
|
||||
|
||||
function leafIdFor(id) {
|
||||
var parts = String(id || "").split(".")
|
||||
return parts.length > 0 ? parts[parts.length - 1] : id
|
||||
}
|
||||
|
||||
function nameSearchText(entry) {
|
||||
if (!entry) return ""
|
||||
var aliases = []
|
||||
var values = Array.isArray(entry.aliases) ? entry.aliases : []
|
||||
for (var i = 0; i < values.length; i++) aliases.push(searchableToken(values[i]))
|
||||
return [entry.label, searchableToken(leafIdFor(entry.id)), aliases.join(" ")].join(" ").toLowerCase()
|
||||
}
|
||||
|
||||
function termInSearchWords(term, text) {
|
||||
var words = String(text || "").toLowerCase().split(/\s+/)
|
||||
for (var i = 0; i < words.length; i++) {
|
||||
if (words[i] === term) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function descriptionTextMatches(query, text) {
|
||||
var terms = String(query || "").toLowerCase().trim().split(/\s+/)
|
||||
for (var i = 0; i < terms.length; i++) {
|
||||
if (terms[i] && !termInSearchWords(terms[i], text)) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function matchesQuery(entry, query, visible) {
|
||||
if (!entry || entry.id === "root") return false
|
||||
if (!visible) return false
|
||||
|
||||
var nameText = nameSearchText(entry)
|
||||
var descriptionText = String(entry.description || "").toLowerCase()
|
||||
var terms = String(query || "").toLowerCase().trim().split(/\s+/)
|
||||
|
||||
for (var i = 0; i < terms.length; i++) {
|
||||
if (!terms[i]) continue
|
||||
if (nameText.indexOf(terms[i]) >= 0) continue
|
||||
if (termInSearchWords(terms[i], descriptionText)) continue
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
function searchScore(items, entry, query) {
|
||||
var needle = String(query || "").toLowerCase().trim()
|
||||
var label = entry.label.toLowerCase()
|
||||
var nameText = nameSearchText(entry)
|
||||
var descriptionText = String(entry.description || "").toLowerCase()
|
||||
var score = 80
|
||||
|
||||
if (label === needle) score = entry.parent === "root" ? 2 : 0
|
||||
// An installed app whose name contains the query as a whole word ("zen"
|
||||
// for Zen Browser) beats exact-labeled menu entries like Install > Zen.
|
||||
else if (entry.kind === "app" && label.split(/\s+/).indexOf(needle) >= 0) score = 0
|
||||
else if (label.indexOf(needle) === 0) score = 10
|
||||
else if (label.indexOf(needle) >= 0) score = 30
|
||||
else if (nameText.indexOf(needle) >= 0) score = 40
|
||||
else if (descriptionTextMatches(needle, descriptionText)) score = 60
|
||||
|
||||
if (entry.kind === "menu" || entry.kind === "link") score -= 2
|
||||
// App rows sort after all menu items, so they lose the tiebreak below to an
|
||||
// equal match. Outrank those, but stay inside the tier so better ones win.
|
||||
if (entry.kind === "app") score -= 5
|
||||
|
||||
return score * 1000 + depthFor(items, entry.id) * 25 + entry.order
|
||||
}
|
||||
|
||||
function displayRow(items, itemOrder, checkedResults, entry, detail, score, section) {
|
||||
var target = entry.kind === "link" ? entry.target : entry.id
|
||||
return {
|
||||
itemId: entry.id,
|
||||
kind: entry.kind,
|
||||
icon: entry.icon,
|
||||
iconFont: entry.iconFont || "",
|
||||
appIcon: entry.appIcon || "",
|
||||
appId: entry.appId || "",
|
||||
label: labelFor(entry, checkedResults),
|
||||
target: target,
|
||||
detail: detail || "",
|
||||
path: pathFor(items, entry.id),
|
||||
childCount: (entry.kind === "menu" || entry.kind === "link") ? childCount(items, itemOrder, target) : 0,
|
||||
action: entry.action || "",
|
||||
provider: entry.provider || "",
|
||||
score: score || 0,
|
||||
section: section || ""
|
||||
}
|
||||
}
|
||||
|
||||
// Commands a `checked:` expression reads a value out of. Every sibling row
|
||||
// asks the same one -- Defaults > Browser has seven rows all comparing
|
||||
// against `blob-default-browser` -- so the batch runs it once and the rows
|
||||
// read the captured answer.
|
||||
//
|
||||
// The capture has to be eager. These are read inside `$(...)`, and a value
|
||||
// cached while one expression runs lives in that subshell only, so a lazy
|
||||
// memo never survives to the expression after it.
|
||||
var GUARD_READERS = [
|
||||
"blob-default-browser",
|
||||
"blob-default-editor",
|
||||
"blob-default-terminal",
|
||||
"blob-network-dns"
|
||||
]
|
||||
|
||||
// Package and command presence account for most of what the guards ask, and
|
||||
// asked one at a time they are almost all fork: the shipped menu spends over
|
||||
// a second on them. Answer them inside the guard process instead. These
|
||||
// shadow the real commands for the batch only, so they have to agree with
|
||||
// them everywhere, including for no arguments at all (present is true of
|
||||
// nothing, missing is not).
|
||||
//
|
||||
// `pacman -Q` resolves a name through what installed packages provide, not
|
||||
// just what they are called -- with gvim installed it reports `vim` as
|
||||
// present -- so the set has to carry provides too, or `install.editor.vim`
|
||||
// comes back and offers to install what is already there. A version
|
||||
// constraint (`bash>=1`) is not a name any set can answer, so it goes to
|
||||
// pacman itself; no shipped guard writes one.
|
||||
//
|
||||
// `pacman -Qi` wraps a long list across continuation lines whenever COLUMNS
|
||||
// is set in the environment, which a login shell may well have done, so the
|
||||
// parser follows the indented lines rather than reading the first one and
|
||||
// dropping half of what is installed.
|
||||
function guardHelpers() {
|
||||
return 'declare -A __blob_pkgs=()\n'
|
||||
+ 'mapfile -t __blob_pkg_names < <({ pacman -Qq; LC_ALL=C pacman -Qi'
|
||||
+ " | awk '/^[A-Za-z]/ { provides = ($0 ~ /^Provides/); sub(/^[^:]*: /, \"\") }"
|
||||
+ ' provides && $0 != "None" { n = split($0, p, " ");'
|
||||
+ ' for (i = 1; i <= n; i++) { sub(/[<>=].*/, "", p[i]); print p[i] } }\'; } 2>/dev/null)\n'
|
||||
+ 'for __blob_pkg in "${__blob_pkg_names[@]}"; do __blob_pkgs[$__blob_pkg]=1; done\n'
|
||||
+ '__blob_pkg_has() { [[ -n ${__blob_pkgs[$1]-} ]] && return 0; '
|
||||
+ '[[ $1 == *[\\<\\>=]* ]] && { pacman -Q "$1" &>/dev/null; return; }; return 1; }\n'
|
||||
+ 'blob-pkg-present() { local p; for p in "$@"; do __blob_pkg_has "$p" || return 1; done; return 0; }\n'
|
||||
+ 'blob-pkg-missing() { local p; for p in "$@"; do __blob_pkg_has "$p" || return 0; done; return 1; }\n'
|
||||
+ 'blob-cmd-present() { local c; for c in "$@"; do command -v "$c" &>/dev/null || return 1; done; return 0; }\n'
|
||||
+ 'blob-cmd-missing() { local c; for c in "$@"; do command -v "$c" &>/dev/null || return 0; done; return 1; }\n'
|
||||
}
|
||||
|
||||
// Substitute the captured answer into the expression rather than shadowing
|
||||
// the reader with a function. `$(reader)` and the variable holding what it
|
||||
// printed are interchangeable -- both strip trailing newlines, both split the
|
||||
// same way unquoted -- while a function would also catch `command -v reader`,
|
||||
// `VAR=x reader`, and every other form, and answer those wrong. Anything but
|
||||
// the plain substitution is left alone to run the real command.
|
||||
function guardPrelude(guards) {
|
||||
var prelude = guardHelpers()
|
||||
|
||||
for (var i = 0; i < GUARD_READERS.length; i++) {
|
||||
// The guards arrive already substituted, so what marks a reader as wanted
|
||||
// is the slot standing in for it, not the call it replaced.
|
||||
if (guards.indexOf(guardReaderSlot(i)) < 0) continue
|
||||
// `|| :` so a reader that exits nonzero cannot take the batch down with
|
||||
// it under a login shell that turned on errexit.
|
||||
prelude += "__blob_read_" + i + "=$(" + GUARD_READERS[i] + " 2>/dev/null) || :\n"
|
||||
}
|
||||
|
||||
return prelude
|
||||
}
|
||||
|
||||
function guardReaderSlot(index) {
|
||||
return "${__blob_read_" + index + "}"
|
||||
}
|
||||
|
||||
function substituteGuardReaders(expression) {
|
||||
for (var i = 0; i < GUARD_READERS.length; i++)
|
||||
expression = expression.split("$(" + GUARD_READERS[i] + ")").join(guardReaderSlot(i))
|
||||
|
||||
return expression
|
||||
}
|
||||
|
||||
function guardLine(id, tag, expression) {
|
||||
return "if { " + substituteGuardReaders(expression) + "; } >/dev/null 2>&1; then echo "
|
||||
+ id + ":" + tag + ":1; else echo " + id + ":" + tag + ":0; fi\n"
|
||||
}
|
||||
|
||||
// One bash script for every `when:` and `checked:` in the menu, reporting
|
||||
// `<id>:<w|c>:<0|1>` per line. Speed is the whole point: the menu opens on
|
||||
// the last evaluation's answers, so however long this takes is how long a row
|
||||
// can contradict the state it describes.
|
||||
function guardScript(items) {
|
||||
var guards = ""
|
||||
var ids = Object.keys(items || {})
|
||||
|
||||
for (var i = 0; i < ids.length; i++) {
|
||||
var entry = items[ids[i]]
|
||||
if (!entry) continue
|
||||
if (entry.when) guards += guardLine(ids[i], "w", entry.when)
|
||||
if (entry.checked) guards += guardLine(ids[i], "c", entry.checked)
|
||||
}
|
||||
|
||||
return guards ? guardPrelude(guards) + guards : ""
|
||||
}
|
||||
|
||||
if (typeof module !== "undefined") {
|
||||
module.exports = {
|
||||
guardReaders: GUARD_READERS,
|
||||
guardScript: guardScript,
|
||||
stripJsonc: stripJsonc,
|
||||
normalizeAliases: normalizeAliases,
|
||||
normalizeItem: normalizeItem,
|
||||
parseMenuJsonc: parseMenuJsonc,
|
||||
mergeMenuSources: mergeMenuSources,
|
||||
mergeAppRows: mergeAppRows,
|
||||
swapProviderRows: swapProviderRows,
|
||||
item: item,
|
||||
resolveRoute: resolveRoute,
|
||||
slugify: slugify,
|
||||
depthFor: depthFor,
|
||||
pathFor: pathFor,
|
||||
parentPathFor: parentPathFor,
|
||||
isDescendantOf: isDescendantOf,
|
||||
childCount: childCount,
|
||||
isVisible: isVisible,
|
||||
labelFor: labelFor,
|
||||
searchableToken: searchableToken,
|
||||
leafIdFor: leafIdFor,
|
||||
nameSearchText: nameSearchText,
|
||||
termInSearchWords: termInSearchWords,
|
||||
descriptionTextMatches: descriptionTextMatches,
|
||||
matchesQuery: matchesQuery,
|
||||
searchScore: searchScore,
|
||||
displayRow: displayRow
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user