diff --git a/README.md b/README.md index 392b025..74e88ef 100644 --- a/README.md +++ b/README.md @@ -104,7 +104,7 @@ need live desktop validation. ```sh oma-cast toggle | show | hide -oma-cast run # e.g. app:com.mitchellh.ghostty, omarchy:style.theme +oma-cast run # e.g. app:com.mitchellh.ghostty, omarchy:appearance.theme oma-cast call [json] # call any backend method oma-cast doctor ``` diff --git a/plugin/Backend.qml b/plugin/Backend.qml index a26fa47..de22eaf 100644 --- a/plugin/Backend.qml +++ b/plugin/Backend.qml @@ -220,6 +220,14 @@ Item { return candidate.kind === "omarchy-link" ? candidate.target : candidate.menuId } + function openMenu(item) { + if (item.provider && !OmarchyMenu.PROVIDERS[item.provider]) { + backend.summonPlugin("omarchy.menu", JSON.stringify({ menu: item.id })) + return { close: true } + } + return { push: { kind: "menu", menuId: item.id, title: item.label } } + } + function performAction(data) { var id = String(data && data.id || "") var action = String(data && data.action || "open") @@ -241,12 +249,12 @@ Item { return { close: true } case "omarchy-menu": backend.recordVisit(id, query) - return { push: { kind: "menu", menuId: candidate.menuId, title: candidate.title } } + return backend.openMenu(menuSource.tree.items[candidate.menuId]) case "omarchy-link": backend.recordVisit(id, query) var target = menuSource.tree.items[candidate.target] if (!target) return { toast: { style: "failure", title: "Menu " + candidate.target + " not found" } } - return { push: { kind: "menu", menuId: target.id, title: target.label } } + return backend.openMenu(target) case "builtin": if (candidate.run.url) { var values = (data && data.arguments) || {} diff --git a/plugin/core/OmarchyMenu.js b/plugin/core/OmarchyMenu.js index 61645c9..38d394e 100644 --- a/plugin/core/OmarchyMenu.js +++ b/plugin/core/OmarchyMenu.js @@ -158,6 +158,11 @@ function shellQuote(value) { } var PROVIDERS = { + "kernels": { + script: "current=$(omarchy-kernel get 2>/dev/null); omarchy-kernel list 2>/dev/null | while read -r k; do [[ -z $k ]] && continue; printf '%s\\t%s\\t%s\\n' \"$k\" \"$k\" \"$current\"; done", + icon: "", + actionFor: function(value) { return "omarchy-kernel set " + shellQuote(value) } + }, "fonts": { script: "current=$(omarchy-font-current 2>/dev/null); omarchy-font-list 2>/dev/null | while read -r f; do [[ -z $f ]] && continue; printf '%s\\t%s\\t%s\\n' \"$f\" \"$f\" \"$current\"; done", icon: "", diff --git a/tests/fixtures/omarchy-menu-v4.0.0.jsonc b/tests/fixtures/omarchy-menu-v4.0.0.jsonc new file mode 100644 index 0000000..02653f2 --- /dev/null +++ b/tests/fixtures/omarchy-menu-v4.0.0.jsonc @@ -0,0 +1,14 @@ +// Selected unchanged rows from omacom/omarchy v4.0.0, default/omarchy/omarchy-menu.jsonc. +{ + "apps": {"icon":"󰀻","label":"Apps","aliases":["app","applications"],"provider":"apps"}, + "trigger": {"icon":"󱓞","label":"Trigger"}, + "style": {"icon":"","label":"Style"}, + "setup": {"icon":"","label":"Setup","aliases":["settings"]}, + "install": {"icon":"󰉉","label":"Install"}, + "remove": {"icon":"󰭌","label":"Remove","aliases":["uninstall"]}, + "update": {"icon":"","label":"Update","aliases":["restart","refresh"]}, + "system": {"icon":"","label":"System","aliases":["power-menu"]}, + "trigger.toggle": {"icon":"󰔎","label":"Toggle","aliases":["toggle","toggles"]}, + "trigger.toggle.nightlight": {"icon":"󰔎","label":"Nightlight","action":"omarchy-toggle-nightlight"}, + "style.font": {"icon":"","label":"Font","provider":"fonts"} +} diff --git a/tests/fixtures/omarchy-menu-v4.5.0-rc2.jsonc b/tests/fixtures/omarchy-menu-v4.5.0-rc2.jsonc new file mode 100644 index 0000000..d40636e --- /dev/null +++ b/tests/fixtures/omarchy-menu-v4.5.0-rc2.jsonc @@ -0,0 +1,17 @@ +// Selected unchanged rows from omacom/omarchy v4.5.0-rc2, default/omarchy/omarchy-menu.jsonc. +{ + "apps": {"icon":"󰀻","label":"Apps","aliases":["app","applications"],"provider":"apps","action":"omarchy-shell omarchy.dock apps"}, + "appearance": {"icon":"󰏘","label":"Appearance","aliases":["style"]}, + "trigger": {"icon":"󱓞","label":"Trigger"}, + "setup": {"icon":"","label":"Setup","aliases":["settings"]}, + "install": {"icon":"󰉉","label":"Install"}, + "remove": {"icon":"󰭌","label":"Remove","aliases":["uninstall"]}, + "update": {"icon":"","label":"Update","aliases":["restart","refresh"]}, + "system": {"icon":"","label":"System","aliases":["power-menu"]}, + "appearance.font": {"icon":"","label":"Font","provider":"fonts"}, + "trigger.toggle": {"icon":"󰔎","label":"Toggle","aliases":["toggle","toggles"]}, + "trigger.toggle.nightlight-on": {"icon":"󰔎","label":"Nightlight On","when":"[[ $(omarchy-toggle-nightlight --status) != *'\"enabled\":true'* ]]","action":"omarchy-toggle-nightlight"}, + "trigger.toggle.nightlight-off": {"icon":"󰔎","label":"Nightlight Off","when":"[[ $(omarchy-toggle-nightlight --status) == *'\"enabled\":true'* ]]","action":"omarchy-toggle-nightlight"}, + "setup.language": {"icon":"","label":"Language","provider":"languages"}, + "update.kernel": {"icon":"","label":"Kernel","when":"(( $(omarchy-kernel list | wc -l) > 1 ))","provider":"kernels"} +} diff --git a/tests/menu-compatibility.test.js b/tests/menu-compatibility.test.js new file mode 100644 index 0000000..14b6306 --- /dev/null +++ b/tests/menu-compatibility.test.js @@ -0,0 +1,112 @@ +const test = require("node:test") +const assert = require("node:assert/strict") +const fs = require("node:fs") +const os = require("node:os") +const path = require("node:path") +const vm = require("node:vm") +const { execFileSync } = require("node:child_process") +const OmarchyMenu = require("../plugin/core/OmarchyMenu.js") +const RootSearch = require("../plugin/core/RootSearch.js") +const Ranker = require("../plugin/core/Ranker.js") +const Frecency = require("../plugin/core/Frecency.js") + +const deps = { OmarchyMenu, Ranker, Frecency } +const backendSource = fs.readFileSync(path.join(__dirname, "../plugin/Backend.qml"), "utf8") + +function backendFunction(name, params, context) { + const signature = "function " + name + "(" + params + ")" + const start = backendSource.indexOf(signature) + assert.ok(start >= 0, signature) + const end = backendSource.indexOf("\n }", start) + 4 + return vm.runInContext("(" + backendSource.slice(start, end) + ")", context) +} + +function harness(tree) { + const summons = [] + const visits = [] + const candidates = RootSearch.menuCandidates(tree, {}, {}, deps) + const backend = { + decorated: id => candidates.find(c => c.id === id), + recordVisit: (id, query) => visits.push({ id, query }), + summonPlugin: (id, payload) => summons.push({ id, payload: JSON.parse(payload) }) + } + const context = vm.createContext({ backend, menuSource: { tree }, OmarchyMenu }) + backend.openMenu = backendFunction("openMenu", "item", context) + const perform = backendFunction("performAction", "data", context) + return { summons, visits, open: id => JSON.parse(JSON.stringify(perform({ id }))) } +} + +for (const [version, appearance] of [["4.0.0", "style"], ["4.5.0-rc2", "appearance"]]) { + const parsed = OmarchyMenu.parse(fs.readFileSync(path.join(__dirname, "fixtures/omarchy-menu-v" + version + ".jsonc"), "utf8")) + const tree = OmarchyMenu.buildTree(parsed.items, []) + + test("Omarchy v" + version + " menu keeps navigation and populates its supported providers", () => { + assert.equal(parsed.error, "") + for (const id of ["apps", "system", appearance, "setup", "install", "remove", "update"]) { + assert.ok(tree.children.root.includes(id), id) + } + const providers = {} + for (const item of parsed.items.filter(i => OmarchyMenu.PROVIDERS[i.provider])) { + providers[item.id] = OmarchyMenu.providerItems(item.id, item.provider, "First\tfirst\tfirst\nSecond\tsecond\tfirst\n") + } + assert.ok(providers[appearance + ".font"]) + if (version === "4.5.0-rc2") assert.ok(providers["update.kernel"]) + const candidates = RootSearch.menuCandidates(tree, {}, providers, deps) + for (const [id, rows] of Object.entries(providers)) { + const sections = RootSearch.submenuSections(candidates, id, "", { commands: {}, frecency: {}, nowMs: Date.now() }, deps) + assert.deepEqual(sections.flatMap(s => s.rows.map(r => r.candidate.id)), rows.map(r => "omarchy:" + r.id)) + assert.equal(candidates.find(c => c.id === "omarchy:" + rows[0].id).checked, true) + assert.equal(candidates.find(c => c.id === "omarchy:" + rows[1].id).checked, false) + } + assert.ok(candidates.some(c => c.title.startsWith("Nightlight"))) + }) + + test("Omarchy v" + version + " unsupported submenus open in the stock menu, including links", () => { + for (const item of parsed.items.filter(i => i.kind === "menu" && i.provider && !OmarchyMenu.PROVIDERS[i.provider])) { + const linked = OmarchyMenu.buildTree(parsed.items, OmarchyMenu.parse(JSON.stringify({ shortcut: { label: "Shortcut", target: item.id } })).items) + for (const id of ["omarchy:" + item.id, "omarchy:shortcut"]) { + const h = harness(linked) + assert.deepEqual(h.open(id), { close: true }) + assert.deepEqual(h.summons, [{ id: "omarchy.menu", payload: { menu: item.id } }]) + assert.equal(h.visits[0].id, id) + } + } + const h = harness(tree) + assert.deepEqual(h.open("omarchy:" + appearance), { push: { kind: "menu", menuId: appearance, title: tree.items[appearance].label } }) + assert.deepEqual(h.open("omarchy:" + appearance + ".font"), { push: { kind: "menu", menuId: appearance + ".font", title: tree.items[appearance + ".font"].label } }) + assert.deepEqual(h.summons, []) + }) +} + +test("an unknown extension provider opens in the stock menu", () => { + const tree = OmarchyMenu.buildTree(OmarchyMenu.parse('{"custom":{"label":"Custom","provider":"future-provider"}}').items, []) + const h = harness(tree) + assert.deepEqual(h.open("omarchy:custom"), { close: true }) + assert.deepEqual(h.summons, [{ id: "omarchy.menu", payload: { menu: "custom" } }]) +}) + +test("kernel rows obey the menu visibility guard and remain searchable when visible", () => { + const parsed = OmarchyMenu.parse(fs.readFileSync(path.join(__dirname, "fixtures/omarchy-menu-v4.5.0-rc2.jsonc"), "utf8")) + const tree = OmarchyMenu.buildTree(parsed.items, []) + assert.ok(tree.items["update.kernel"].when) + const providers = { "update.kernel": OmarchyMenu.providerItems("update.kernel", "kernels", "linux-lts\tlinux-lts\tlinux-lts\n") } + const visible = RootSearch.menuCandidates(tree, { when: { "update.kernel": true } }, providers, deps) + assert.ok(visible.some(c => c.id === "omarchy:update.kernel.linux-lts" && c.checked)) + const hidden = RootSearch.menuCandidates(tree, { when: { "update.kernel": false } }, providers, deps) + assert.ok(!hidden.some(c => c.menuId.startsWith("update.kernel"))) +}) + +test("kernel enumeration reads list/get and set receives the selected value literally", t => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "oma-cast-kernels-")) + t.after(() => fs.rmSync(dir, { recursive: true, force: true })) + fs.writeFileSync(path.join(dir, "omarchy-kernel"), '#!/bin/bash\ncase "$1" in\nget) echo linux-lts ;;\nlist) printf "linux\\nlinux-lts\\n" ;;\nset) printf "%s\\0" "$@" ;;\n*) exit 1 ;;\nesac\n', { mode: 0o755 }) + const env = { ...process.env, PATH: dir + ":" + process.env.PATH } + const output = execFileSync("bash", ["-c", OmarchyMenu.PROVIDERS.kernels.script], { env, encoding: "utf8" }) + const rows = OmarchyMenu.providerItems("update.kernel", "kernels", output) + assert.deepEqual(rows.map(r => [r.label, r.isCurrent, r.action]), [["linux", false, "omarchy-kernel set 'linux'"], ["linux-lts", true, "omarchy-kernel set 'linux-lts'"]]) + const value = "it's $(touch " + path.join(dir, "injected") + ") `id`" + const action = OmarchyMenu.PROVIDERS.kernels.actionFor(value) + assert.equal(execFileSync("bash", ["-c", action], { env, encoding: "utf8" }), "set\0" + value + "\0") + assert.equal(fs.existsSync(path.join(dir, "injected")), false) + assert.deepEqual(OmarchyMenu.providerItems("update.kernel", "kernels", ""), []) +}) diff --git a/tests/omarchy-menu.test.js b/tests/omarchy-menu.test.js index e4c2103..dfbcbdf 100644 --- a/tests/omarchy-menu.test.js +++ b/tests/omarchy-menu.test.js @@ -1,6 +1,7 @@ const test = require("node:test") const assert = require("node:assert/strict") const fs = require("node:fs") +const path = require("node:path") const { execFileSync } = require("node:child_process") const M = require("../plugin/core/OmarchyMenu.js") @@ -74,11 +75,12 @@ test("only a bare summon is run in-process", () => { assert.equal(M.summonAction("omarchy-shell shell summon x; rm -rf ~"), null) }) -const shipped = "/usr/share/omarchy/default/omarchy/omarchy-menu.jsonc" +const shipped = path.join(process.env.OMARCHY_PATH || "/usr/share/omarchy", "default/omarchy/omarchy-menu.jsonc") test("the installed Omarchy menu parses completely", { skip: !fs.existsSync(shipped) }, () => { const r = M.parse(fs.readFileSync(shipped, "utf8")) assert.equal(r.error, "") assert.ok(r.items.length > 300, "expected the full menu, got " + r.items.length) const tree = M.buildTree(r.items, []) - for (const id of ["apps", "system", "style", "setup", "install", "remove", "update"]) assert.ok(tree.children.root.includes(id), id) + for (const id of ["apps", "system", "setup", "install", "remove", "update"]) assert.ok(tree.children.root.includes(id), id) + assert.ok(tree.children.root.includes("style") || tree.children.root.includes("appearance")) }) diff --git a/tests/smoke.sh b/tests/smoke.sh index 0e57ed4..166cbf2 100755 --- a/tests/smoke.sh +++ b/tests/smoke.sh @@ -18,7 +18,7 @@ $cli call settings.getGeneralSettings | grep -q '"popToRootTimeout"' && pass "se echo "root search (Phase 1)" search() { $cli call rootSearch.search "{\"query\":\"$1\"}"; } first() { search "$1" | python3 -I -c 'import json,sys; d=json.load(sys.stdin); print(d["data"][0]["rows"][0]["title"] if d["ok"] and d["data"] else "")'; } -[[ $(first nightlight) == "Nightlight" ]] && pass "Omarchy menu items are searchable (nightlight)" || bad "Omarchy menu items are searchable (nightlight)" +[[ $(first nightlight) =~ ^Nightlight(\ On|\ Off)?$ ]] && pass "Omarchy menu items are searchable (nightlight)" || bad "Omarchy menu items are searchable (nightlight)" [[ $(first "") != "" ]] && pass "empty root has sections" || bad "empty root has sections" search zzzz-no-match | grep -q '"id":"fallback-commands"' && pass "fallback section for unmatched queries" || bad "fallback section for unmatched queries" count=$($cli call system.info | python3 -I -c 'import json,sys; print(json.load(sys.stdin)["data"]["candidates"])')