Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,10 @@ omacast doctor # check ever

Press **Alt+Space**. Super+Space stays the Omarchy menu.

Run hotkey setup inside a running Hyprland session. It checks active bindings,
so a default removed with `hl.unbind("ALT + SPACE")` does not block setup.
Reload Hyprland after removing a conflicting binding, then rerun setup.

`omacast setup hyprland` lists what it will change and asks before changing it. It writes
`~/.config/hypr/omacast.lua` and appends one `require("hypr.omacast")` line to
`~/.config/hypr/hyprland.lua`; it never edits Omarchy's own files. To use another key, edit
Expand Down
23 changes: 19 additions & 4 deletions bin/omacast
Original file line number Diff line number Diff line change
Expand Up @@ -219,10 +219,25 @@ cmd_setup_hyprland() {
local main="$hypr_dir/hyprland.lua" file="$hypr_dir/omacast.lua" legacy="$hypr_dir/$LEGACY_NAME.lua"
[[ -f $main ]] || die "no $main; this setup targets Lua-configured Hyprland (hyprctl version: $(hyprctl version 2>/dev/null | head -1))"

local conflicts
conflicts=$(grep -rnE '"ALT \+ SPACE"|ALT, ?SPACE' "$hypr_dir" "${OMARCHY_PATH:-/usr/share/omarchy}/default/hypr" 2>/dev/null |
grep -vF -e "$file:" -e "$legacy:" | grep -vE '^\S+:[0-9]+:\s*(--|#)')
if [[ -n $conflicts ]]; then
# Read the loaded bindings: config files also contain disabled defaults,
# unbind calls and backups, none of which reserve a shortcut.
local bindings conflicts own=false old=false
[[ -f $file ]] && own=true
[[ -f $legacy ]] && old=true
bindings=$(hyprctl -j binds 2>/dev/null) || die "could not read active Hyprland bindings; run setup inside a running Hyprland session"
conflicts=$(jq -ce --argjson own "$own" --argjson old "$old" '
if type != "array" then error("expected a binding array") else
map(select(.modmask == 8 and
(((.key // "") | ascii_downcase) == "space" or .keycode == 65) and
((.submap // "") == "" or .submap_universal == true or .submap_universal == "true")) |
select((
(.dispatcher == "__lua" or .dispatcher == "global") and
(($own and .description == "omacast launcher") or
($old and .description == "oma-cast launcher"))
) | not))
end
' <<<"$bindings" 2>/dev/null) || die "could not read active Hyprland bindings; expected JSON from hyprctl -j binds"
if [[ $conflicts != '[]' ]]; then
echo "Alt+Space is already bound:" >&2
echo "$conflicts" >&2
die "unbind it first, or write $file yourself with another key"
Expand Down
136 changes: 136 additions & 0 deletions tests/hyprland-setup.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
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 { spawnSync } = require("node:child_process")

const cli = path.join(__dirname, "../bin/omacast")

function fixture(t, bindings = [], { output, status = 0 } = {}) {
const home = fs.mkdtempSync(path.join(os.tmpdir(), "omacast-hotkey-"))
t.after(() => fs.rmSync(home, { recursive: true, force: true }))
const hypr = path.join(home, ".config/hypr")
const defaults = path.join(home, "omarchy/default/hypr")
const bin = path.join(home, "bin")
for (const dir of [hypr, defaults, bin]) fs.mkdirSync(dir, { recursive: true })
const main = 'require("hypr.bindings")\n'
fs.writeFileSync(path.join(hypr, "hyprland.lua"), main)
fs.writeFileSync(path.join(home, "bindings.json"), output ?? JSON.stringify(bindings))
fs.writeFileSync(path.join(bin, "hyprctl"), `#!/bin/bash
if [[ $1 == -j && $2 == binds ]]; then
cat "$HOME/bindings.json"
exit ${status}
fi
if [[ $1 == reload ]]; then touch "$HOME/reloaded"; exit 0; fi
exit 1
`, { mode: 0o755 })
return {
home, hypr, defaults, main,
run: () => spawnSync("bash", [cli, "setup", "hyprland", "--yes"], {
encoding: "utf8",
env: { ...process.env, HOME: home, XDG_CONFIG_HOME: path.join(home, ".config"),
OMARCHY_PATH: path.join(home, "omarchy"), PATH: bin + ":" + process.env.PATH }
})
}
}

function unchanged(f) {
assert.equal(fs.readFileSync(path.join(f.hypr, "hyprland.lua"), "utf8"), f.main)
assert.ok(!fs.existsSync(path.join(f.hypr, "omacast.lua")))
assert.ok(!fs.existsSync(path.join(f.home, "reloaded")))
}

test("setup ignores a default binding removed by a user override", (t) => {
const f = fixture(t)
fs.writeFileSync(path.join(f.defaults, "dictation.lua"),
'o.bind("ALT + SPACE", "Toggle dictation", "omarchy-dictation toggle")\n')
fs.writeFileSync(path.join(f.hypr, "bindings.lua"), 'hl.unbind("ALT + SPACE")\n')
const r = f.run()
assert.equal(r.status, 0, r.stdout + r.stderr)
assert.match(fs.readFileSync(path.join(f.hypr, "omacast.lua"), "utf8"), /omacast:toggle/)
assert.match(fs.readFileSync(path.join(f.hypr, "hyprland.lua"), "utf8"), /require\("hypr.omacast"\)/)
assert.ok(fs.existsSync(path.join(f.home, "reloaded")))
})

for (const key of [{ key: "space" }, { key: "SPACE" }, { key: "", keycode: 65 }]) {
test(`setup rejects an active Alt+Space binding ${JSON.stringify(key)}`, (t) => {
const f = fixture(t, [{ modmask: 8, submap: "", description: "Toggle dictation", ...key }])
const r = f.run()
assert.notEqual(r.status, 0)
assert.match(r.stderr, /Alt\+Space is already bound/)
assert.match(r.stderr, /Toggle dictation/)
unchanged(f)
})
}

test("setup ignores other modifier combinations and bindings in other submaps", (t) => {
const f = fixture(t, [
{ modmask: 64, key: "space", submap: "" },
{ modmask: 72, key: "space", submap: "" },
{ modmask: 8, key: "space", submap: "resize" }
])
const r = f.run()
assert.equal(r.status, 0, r.stdout + r.stderr)
})

test("setup rejects a universal binding from another submap", (t) => {
const f = fixture(t, [{ modmask: 8, key: "space", submap: "resize",
submap_universal: "true", description: "Universal shortcut" }])
const r = f.run()
assert.notEqual(r.status, 0)
assert.match(r.stderr, /Universal shortcut/)
unchanged(f)
})

test("setup does not trust an omacast description without an installed hotkey file", (t) => {
const f = fixture(t, [{ modmask: 8, key: "space", submap: "", dispatcher: "__lua",
description: "omacast launcher" }])
assert.notEqual(f.run().status, 0)
unchanged(f)
})

test("an existing omacast shortcut does not hide a second active binding", (t) => {
const f = fixture(t, [
{ modmask: 8, key: "space", submap: "", dispatcher: "__lua", description: "omacast launcher" },
{ modmask: 8, key: "space", submap: "", dispatcher: "__lua", description: "Toggle dictation" }
])
const content = 'o.bind("ALT + SPACE", "omacast launcher", hl.dsp.global("omacast:toggle"))\n'
fs.writeFileSync(path.join(f.hypr, "omacast.lua"), content)
const r = f.run()
assert.notEqual(r.status, 0)
assert.match(r.stderr, /Toggle dictation/)
assert.equal(fs.readFileSync(path.join(f.hypr, "omacast.lua"), "utf8"), content)
assert.equal(fs.readFileSync(path.join(f.hypr, "hyprland.lua"), "utf8"), f.main)
assert.ok(!fs.existsSync(path.join(f.home, "reloaded")))
})

for (const name of ["omacast", "oma-cast"]) {
test(`setup can rerun with the existing ${name} shortcut active`, (t) => {
const f = fixture(t, [{ modmask: 8, key: "SPACE", submap: "", dispatcher: "__lua",
description: `${name} launcher`, arg: "104" }])
const file = path.join(f.hypr, `${name}.lua`)
fs.writeFileSync(file, `o.bind("ALT + SPACE", "${name} launcher", hl.dsp.global("${name}:toggle"))\n`)
fs.writeFileSync(path.join(f.hypr, "hyprland.lua"), `require("hypr.${name}")\n`)
const r = f.run()
assert.equal(r.status, 0, r.stdout + r.stderr)
fs.writeFileSync(path.join(f.home, "bindings.json"), JSON.stringify([
{ modmask: 8, key: "SPACE", submap: "", dispatcher: "__lua",
description: "omacast launcher", arg: "104" }
]))
const again = f.run()
assert.equal(again.status, 0, again.stdout + again.stderr)
const main = fs.readFileSync(path.join(f.hypr, "hyprland.lua"), "utf8")
assert.equal(main.split('require("hypr.omacast")').length - 1, 1)
})
}

for (const options of [{ status: 1 }, { output: "not json" }, { output: "{}" }, { output: "" }]) {
test(`setup leaves config untouched when bindings cannot be read ${JSON.stringify(options)}`, (t) => {
const f = fixture(t, [], options)
const r = f.run()
assert.notEqual(r.status, 0)
assert.match(r.stderr, /could not read active Hyprland bindings/)
unchanged(f)
})
}
2 changes: 1 addition & 1 deletion tests/privacy.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ test("setup hyprland keeps a private legacy oma-cast.lua private", (t) => {
fs.writeFileSync(hypr + "/hyprland.lua", 'require("hypr.oma-cast")\n')
fs.writeFileSync(hypr + "/oma-cast.lua", 'o.bind("ALT + SPACE", "oma-cast launcher", hl.dsp.global("oma-cast:toggle"))\n-- token = "private"\n')
fs.chmodSync(hypr + "/oma-cast.lua", 0o600)
fs.writeFileSync(bin + "/hyprctl", "#!/bin/bash\nexit 0\n", { mode: 0o755 })
fs.writeFileSync(bin + "/hyprctl", '#!/bin/bash\nif [[ $1 == -j && $2 == binds ]]; then echo "[]"; fi\nexit 0\n', { mode: 0o755 })
const r = spawnSync("bash", ["-c", 'umask 022; exec "$@"', "umask-022", path.join(root, "bin/omacast"), "setup", "hyprland", "--yes"], {
env: { HOME: home, XDG_CONFIG_HOME: home + "/.config", OMARCHY_PATH: home + "/omarchy", PATH: bin + ":" + process.env.PATH },
encoding: "utf8"
Expand Down