diff --git a/Backend.qml b/Backend.qml index 8895670..dc356b9 100644 --- a/Backend.qml +++ b/Backend.qml @@ -244,7 +244,7 @@ Item { } function openMenu(item) { - if (item.provider && !OmarchyMenu.PROVIDERS[item.provider]) { + if (item.provider && !OmarchyMenu.providerSpec(item.provider)) { backend.summonPlugin("omarchy.menu", JSON.stringify({ menu: item.id })) return { close: true } } diff --git a/core/OmarchyMenu.js b/core/OmarchyMenu.js index 38d394e..ff54da9 100644 --- a/core/OmarchyMenu.js +++ b/core/OmarchyMenu.js @@ -10,6 +10,23 @@ function parentOf(id) { return dot < 0 ? "root" : id.slice(0, dot) } +// A provider is the name of one in PROVIDERS or, as Omarchy's menu allows, an +// extension's own object: a `command` printing `label\tvalue\tcurrent\tdescription` +// lines and an `action` run with `{value}` replaced by the chosen row's quoted +// value. Anything else is dropped, so a malformed entry stays an empty submenu. +function normalizeProvider(value) { + if (typeof value === "string") return value + if (!value || typeof value !== "object") return "" + if (typeof value.command !== "string" || !value.command.trim()) return "" + if (typeof value.action !== "string" || value.action.indexOf("{value}") < 0) return "" + return { + command: value.command, + action: value.action, + icon: typeof value.icon === "string" ? value.icon : "", + volatile: value.volatile === true + } +} + function normalizeItem(id, raw) { var aliases = Array.isArray(raw.aliases) ? raw.aliases.filter(function(a) { return typeof a === "string" && a }) : (typeof raw.aliases === "string" && raw.aliases ? [raw.aliases] : []) @@ -24,7 +41,7 @@ function normalizeItem(id, raw) { description: String(raw.description || ""), action: String(raw.action || ""), target: String(raw.target || ""), - provider: String(raw.provider || ""), + provider: normalizeProvider(raw.provider), aliases: aliases, when: String(raw.when || ""), checked: String(raw.checked || ""), @@ -179,8 +196,26 @@ function slugify(value) { return String(value || "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "item" } -function providerItems(menuId, providerName, output) { - var spec = PROVIDERS[providerName] +// Every {value} in an extension provider's action becomes the row's value, +// quoted, so a value can never break out of the command. +function providerAction(template, value) { + return String(template || "").split("{value}").join(shellQuote(value)) +} + +// A named provider comes from PROVIDERS; an extension's object becomes the same +// kind of spec, so both run and turn into rows alike. +function providerSpec(provider) { + if (!provider) return null + if (typeof provider === "string") return PROVIDERS[provider] || null + return { + script: provider.command, + icon: provider.icon, + actionFor: function(value) { return providerAction(provider.action, value) } + } +} + +function providerItems(menuId, provider, output) { + var spec = providerSpec(provider) if (!spec) return [] var rows = [] var taken = {} @@ -192,11 +227,12 @@ function providerItems(menuId, providerName, output) { var label = parts[0] || "" var value = parts[1] || label var current = parts[2] || "" + var description = parts[3] || "" if (!label) continue var id = menuId + "." + slugify(value) while (taken[id]) id += "-" taken[id] = true - var item = normalizeItem(id, { icon: spec.icon, label: label, action: spec.actionFor(value) }) + var item = normalizeItem(id, { icon: spec.icon, label: label, description: description, action: spec.actionFor(value) }) item.parent = menuId item.isCurrent = value === current rows.push(item) @@ -221,6 +257,8 @@ if (typeof module !== "undefined") { parseGuardOutput: parseGuardOutput, shellQuote: shellQuote, PROVIDERS: PROVIDERS, + providerAction: providerAction, + providerSpec: providerSpec, providerItems: providerItems, summonAction: summonAction, slugify: slugify diff --git a/services/OmarchyMenuSource.qml b/services/OmarchyMenuSource.qml index b2653f1..0217030 100644 --- a/services/OmarchyMenuSource.qml +++ b/services/OmarchyMenuSource.qml @@ -126,7 +126,7 @@ Item { var out = [] for (var i = 0; i < source.tree.order.length; i++) { var item = source.tree.items[source.tree.order[i]] - if (item.provider && OmarchyMenu.PROVIDERS[item.provider]) out.push(item.id) + if (OmarchyMenu.providerSpec(item.provider)) out.push(item.id) } return out } @@ -143,7 +143,7 @@ Item { } var menuId = source.providerQueue[0] source.providerQueue = source.providerQueue.slice(1) - var spec = OmarchyMenu.PROVIDERS[source.tree.items[menuId].provider] + var spec = OmarchyMenu.providerSpec(source.tree.items[menuId].provider) providerProc.menuId = menuId providerProc.collected = "" providerProc.command = ["bash", "-lc", spec.script] diff --git a/tests/omarchy-menu.test.js b/tests/omarchy-menu.test.js index 0810176..1ac5f06 100644 --- a/tests/omarchy-menu.test.js +++ b/tests/omarchy-menu.test.js @@ -69,6 +69,53 @@ test("provider output becomes action rows with quoted commands", () => { assert.equal(rows[1].parent, "style.font") }) +test("an extension can declare its own provider object", () => { + const { items } = M.parse(`{ + "tabs": {"label":"Tabs","provider":{"command":"list-tabs","action":"focus-tab {value}","icon":"T","volatile":true,"extra":1}}, + "a": {"provider":{"action":"run {value}"}}, + "b": {"provider":{"command":"ls","action":"run"}}, + "c": {"provider":7}, + "d": {"provider":{"command":"ls","action":"run {value}","volatile":"yes"}} + }`) + const byId = Object.fromEntries(items.map((i) => [i.id, i])) + assert.deepEqual(byId["tabs"].provider, { command: "list-tabs", action: "focus-tab {value}", icon: "T", volatile: true }) + assert.equal(byId["tabs"].kind, "menu") + assert.equal(byId["a"].provider, "", "a provider without a command is dropped") + assert.equal(byId["b"].provider, "", "a provider whose action ignores the value is dropped") + assert.equal(byId["c"].provider, "", "a provider that is neither a name nor an object is dropped") + assert.equal(byId["d"].provider.volatile, false, "only true is volatile") + assert.equal(M.providerSpec("fonts"), M.PROVIDERS.fonts) + assert.equal(M.providerSpec("nope"), null) +}) + +test("an extension provider object survives the merge with the default menu", () => { + const user = M.parse(`{"style.font": {"provider":{"command":"ls","action":"set {value}"}}}`).items + const tree = M.buildTree(M.parse(sample).items, user) + assert.equal(tree.items["style.font"].provider.command, "ls") + assert.equal(tree.items["style.font"].label, "Font") +}) + +test("extension provider rows run the chosen value as one literal word", () => { + const provider = { command: "list-tabs", action: "printf '%s' {value}", icon: "T" } + const hostile = "it's $(touch /tmp/oma-cast-pwned) `id` \\ \"q\"" + const rows = M.providerItems("tabs", provider, `Inbox\ttab-1\ttab-2\tWork · mail.example\nHostile\t${hostile}\n\nCurrent\ttab-2\ttab-2\n`) + assert.deepEqual(rows.map((r) => r.label), ["Inbox", "Hostile", "Current"]) + assert.equal(rows[0].description, "Work · mail.example") + assert.equal(rows[1].description, "") + assert.equal(rows[0].icon, "T") + assert.equal(rows[2].isCurrent, true) + assert.equal(rows[0].parent, "tabs") + for (const [row, value] of [[rows[0], "tab-1"], [rows[1], hostile]]) { + assert.equal(execFileSync("bash", ["-c", row.action], { encoding: "utf8" }), value) + } +}) + +test("an extension provider's command runs and feeds its rows", () => { + const spec = M.providerSpec({ command: "printf 'A\\ta\\n'", action: "echo {value}" }) + const out = execFileSync("bash", ["-c", spec.script], { encoding: "utf8" }) + assert.deepEqual(M.providerItems("x", { command: spec.script, action: "echo {value}" }, out).map((r) => r.action), ["echo 'a'"]) +}) + test("only a bare summon is run in-process", () => { assert.deepEqual(M.summonAction("omarchy-shell shell summon omarchy.clipboard"), { id: "omarchy.clipboard", payload: "{}" }) assert.deepEqual(M.summonAction(`omarchy-shell shell summon omarchy.image-picker '{"source":"themes"}'`), { id: "omarchy.image-picker", payload: '{"source":"themes"}' })