diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 36aebcc..608ec62 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,8 +22,19 @@ jobs: - name: Unit tests run: node --test tests/*.test.js + - uses: dtolnay/rust-toolchain@stable + + - name: Search helper + run: | + cargo test --locked --manifest-path search/Cargo.toml + bin/build-search + node --test tests/search-integration.test.js + - name: Shell syntax - run: bash -n bin/oma-cast tests/smoke.sh + run: | + for script in bin/omacast bin/build-search tests/smoke.sh tests/native-transport.sh; do + bash -n "$script" + done - name: Plugin manifest is valid JSON - run: node -e 'JSON.parse(require("fs").readFileSync("plugin/manifest.json", "utf8"))' + run: node -e 'JSON.parse(require("fs").readFileSync("manifest.json", "utf8"))' diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3673463 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/search/target/ +/bin/omacast-search +/bin/.search.* diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..5c256b9 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,5 @@ +# AGENTS.md + +Project instructions for coding agents live in [CLAUDE.md](CLAUDE.md). Read it first. + +(This is a plain file, not a symlink: `omarchy plugin validate` rejects symlinks inside a plugin.) diff --git a/plugin/Backend.qml b/Backend.qml similarity index 89% rename from plugin/Backend.qml rename to Backend.qml index a26fa47..8895670 100644 --- a/plugin/Backend.qml +++ b/Backend.qml @@ -7,10 +7,10 @@ import "core/Paths.js" as Paths import "core/Log.js" as Log import "core/Schema.js" as Schema import "core/Ranker.js" as Ranker +import "core/FleaSearch.js" as FleaSearch import "core/Frecency.js" as Frecency import "core/OmarchyMenu.js" as OmarchyMenu import "core/RootSearch.js" as RootSearch -import "core/Agent.js" as Agent import "core/Calculator.js" as Calculator import "services" @@ -35,7 +35,7 @@ Item { property var candidates: [] property var candidatesById: ({}) property int candidatesRevision: 0 - readonly property bool loading: menuSource.loading || agentLauncher.checking + readonly property bool loading: menuSource.loading || nativeSearch.pending !== null // Bumped when exchange rates or zone offsets arrive, so an open // calculator row re-evaluates. readonly property int calculatorRevision: (calculatorData.rates ? 1 : 0) + calculatorData.ratesFetchedAt + calculatorData.zonesAt @@ -45,6 +45,25 @@ Item { function call(method, data) { return Rpc.call(backend.registry, method, data) } function callSync(method, data) { return Rpc.callSync(backend.registry, method, data) } + function cancelSearch() { nativeSearch.cancel() } + + function searchAsync(data, callback) { + nativeSearch.cancel() + var query = String(data && data.query || "").trim() + var view = data && data.view + if (!query || (view && view.kind !== "root")) { + callback(backend.callSync("rootSearch.search", data)) + return + } + nativeSearch.search(Ranker.transliterate(query.toLowerCase()), backend.searchContext().sensitivity, function(matches) { + callback(backend.callSync("rootSearch.search", Object.assign({}, data, { fleaHits: matches }))) + }) + } + + NativeSearch { + id: nativeSearch + onFailed: function(message) { backend.log("warning", "search", message) } + } function subscribe(method, callback) { return Rpc.subscribe(backend.registry, method, callback) } function emit(method, data) { @@ -78,8 +97,12 @@ Item { for (var b = 0; b < RootSearch.BUILTINS.length; b++) next.push(RootSearch.builtinCandidate(RootSearch.BUILTINS[b])) var byId = ({}) for (var j = 0; j < next.length; j++) byId[next[j].id] = next[j] + var entries = FleaSearch.entries(next, Ranker.transliterate) + nativeSearch.cancel() backend.candidates = next backend.candidatesById = byId + nativeSearch.ids = next.map(function(c) { return c.id }) + nativeSearch.entries = entries backend.candidatesRevision++ } @@ -147,16 +170,16 @@ Item { case "open": case "copy-answer": if (!historyId) backend.saveCalculation(entry) - Util.execArgv(["wl-copy", "--", entry.copy]) + clipboard.copy(entry.copy) return { close: true, hud: "Copied to Clipboard" } case "paste-answer": if (!historyId) backend.saveCalculation(entry) - // Omarchy's own paste: copies, waits for our window to unmap, types. - Util.execArgv(["omarchy-clipboard-paste-text", entry.copy]) + // Copies, waits for our window to unmap, then pastes. + clipboard.paste(entry.copy) return { close: true } case "copy-question-answer": if (!historyId) backend.saveCalculation(entry) - Util.execArgv(["wl-copy", "--", entry.query + " = " + entry.result]) + clipboard.copy(entry.query + " = " + entry.result) return { close: true, hud: "Copied to Clipboard" } case "open-history": return { push: { kind: "calculator-history", title: "Calculator History" } } @@ -220,6 +243,19 @@ 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 launchApp(candidate) { + if (backend.appLibrary) backend.appLibrary.launch(candidate.appId, candidate.title) + else Util.execArgv(["uwsm-app", "--", "gtk-launch", candidate.appId + ".desktop"]) + } + function performAction(data) { var id = String(data && data.id || "") var action = String(data && data.action || "open") @@ -231,8 +267,7 @@ Item { switch (candidate.kind) { case "app": backend.recordVisit(id, query) - if (backend.appLibrary) backend.appLibrary.launch(candidate.appId, candidate.title) - else Util.execArgv(["uwsm-app", "--", "gtk-launch", candidate.appId + ".desktop"]) + appFocuser.open(candidate) return { close: true } case "omarchy-action": if (candidate.disabled) return { toast: { style: "failure", title: candidate.title + " is already set up" } } @@ -241,12 +276,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) || {} @@ -268,12 +303,16 @@ Item { } switch (action) { + case "open-new": + backend.recordVisit(id, query) + backend.launchApp(candidate) + return { close: true } case "open-in-omarchy-menu": backend.summonPlugin("omarchy.menu", JSON.stringify({ menu: backend.omarchyRouteFor(candidate) })) return { close: true } case "copy-command": case "copy-id": - Util.execArgv(["wl-copy", "--", action === "copy-id" ? candidate.appId + ".desktop" : candidate.action]) + clipboard.copy(action === "copy-id" ? candidate.appId + ".desktop" : candidate.action) return { close: true, hud: "Copied to Clipboard" } case "favorite-add": case "favorite-remove": @@ -386,6 +425,7 @@ Item { var query = String(data && data.query || "") var view = data && data.view var ctx = backend.searchContext() + if (data && data.fleaHits) ctx.fleaHits = data.fleaHits var sections if (view && view.kind === "menu") sections = RootSearch.submenuSections(backend.candidates, view.menuId, query, ctx, backend.deps) else if (view && view.kind === "calculator-history") sections = RootSearch.historySections(calculatorStore.doc.history, query, ctx.nowMs, Calculator.filterHistory) @@ -437,10 +477,6 @@ Item { } var fallback = RootSearch.FALLBACKS.filter(function(f) { return f.id === (data && data.id) })[0] if (fallback) { - if (fallback.id === Agent.FALLBACK_ID) { - agentLauncher.start(data.query) - return { pending: true } - } Util.execArgv(["omarchy-launch-browser", RootSearch.fallbackUrl(fallback, data.query)]) return { close: true } } @@ -518,19 +554,28 @@ Item { menuSource.refresh() calculatorData.refresh() } - if (method === "window.visibilityChanged" && data && !data.visible) agentLauncher.cancel() } - AgentLauncher { - id: agentLauncher - onPrepared: function(argv) { backend.emit("agent.launchReady", { argv: argv }) } - onFailed: function(message) { backend.emit("agent.launchFailed", { message: message }) } + // Clipboard text goes over stdin, never argv (other users can read argv). + function copyText(text) { clipboard.copy(text) } + + ClipboardWriter { + id: clipboard + onFailed: function(message) { backend.log("error", "clipboard", message, null) } + } + + AppFocuser { + id: appFocuser + onLaunchRequested: function(candidate) { backend.launchApp(candidate) } } Process { id: mkdirs - command: ["mkdir", "-p"].concat(Paths.directoriesToCreate(backend.paths)) + // Moves directories left under the old name, then creates everything + // owner-only and repairs modes left readable by older builds. + command: Paths.setupArgv(backend.paths, [Schema.documents.settings.file, Schema.documents.frecency.file, Schema.documents.calculator.file] + .map(function(file) { return backend.paths.db + "/" + file })) onExited: function(exitCode) { if (exitCode !== 0) backend.log("error", "paths", "could not create data directories", { exitCode: exitCode }) backend.directoriesReady = true diff --git a/CLAUDE.md b/CLAUDE.md index c2ea441..4f0bfcb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -22,19 +22,20 @@ Spec legend: **[O]** observed in Raycast, **[I]** inferred, **[D]** design decis 1. **Clean room.** Never copy, bundle or redistribute anything from `Raycast.app` (JS, icons, fonts, `.node` modules). Allowed: the public npm `@raycast/api` / `@raycast/utils` packages (check the license of the pinned version) and the MIT `raycast/extensions` repo. 2. **Never call `*.raycast.com`.** No store, account, sync, telemetry or currency APIs. Build extensions from a local `raycast/extensions` clone. 3. **No telemetry.** No Sentry, no PostHog. -4. **No embedded AI features.** The fork's user-authorized exception is an explicit hand-off of root-search text to Omarchy's existing default-agent terminal launcher (SPEC §2.0). No model API, credential handling or automatic submission while typing. Other built-ins remain non-AI. -5. **Port the root-search ranker exactly** from `reference/02-backend.md §4.2–4.5`. Do not substitute fzf, nucleo or fuse.js. +4. **No embedded AI features.** No model API or credential handling. (The default-agent hand-off was removed on 2026-11-02; see SPEC §2.0.) +5. **Port the root-search ranker exactly** from `reference/02-backend.md §4.2–4.5`. Do not substitute fzf, nucleo or fuse.js. The user-authorized flea-search matcher in SPEC §2.0 is the matching exception; personalization still uses this ranker. +6. **No user text in argv; private files stay private.** Queries, calculator text and anything else the user typed go to other processes on stdin (`services/ClipboardWriter.qml`), never as arguments. omacast dirs are `0700` and store files `0600` (SPEC §2.0 "Privacy"). The marketplace review enforces this. ## How to work - Build **phase by phase** following `SPEC.md §12` (Phase 0 → 9). A phase is done only when its **Accept** checks pass. Then move on. - After each phase, update the **Status** section at the bottom of this file: what works, what's stubbed, and any deviations from the spec. - If the reference docs don't answer a question, choose a sensible value, mark it `[I]` or `[D]` in a code comment, and keep going. Raycast.app is not on the Omarchy machine. Re-running the reverse-engineering only works on the Mac where `/Applications/Raycast.app` lives. -- The working name is **`oma-cast`**: plugin id `typecraft.oma-cast`, CLI `oma-cast`, IPC target `oma-cast`, layer namespace `oma-cast`, URL scheme `oma-cast://` (later). Also register `raycast://` for extension deep links (SPEC §5.5). +- The working name is **`omacast`**: plugin id `typecraft.omacast`, CLI `omacast`, IPC target `omacast`, layer namespace `omacast`, URL scheme `omacast://` (later). Also register `raycast://` for extension deep links (SPEC §5.5). ## Architecture: an Omarchy shell plugin (SPEC §2.0) -oma-cast runs **inside `omarchy-shell`**, Omarchy's long-lived Quickshell process, as the plugin `typecraft.oma-cast`. That is how Omarchy builds its own menu, emoji picker and clipboard overlays. **Keep external dependencies near zero:** QML and plain JS only, using what Omarchy already ships (Quickshell, Qt, bash, socat, Hyprland). No Tauri, Node runtime, npm packages or native modules without asking first. The Tauri/Node design in SPEC §2 is kept for reference only. +omacast runs **inside `omarchy-shell`**, Omarchy's long-lived Quickshell process, as the plugin `typecraft.omacast`. That is how Omarchy builds its own menu, emoji picker and clipboard overlays. **Keep external dependencies near zero:** QML and plain JS only, using what Omarchy already ships (Quickshell, Qt, bash, socat, Hyprland). No Tauri, Node runtime, npm packages or native modules without asking first. The Tauri/Node design in SPEC §2 is kept for reference only. To learn the host, read the shell's own code in `$OMARCHY_PATH/shell/` (`/usr/share/omarchy/shell`): - `shell.qml`: plugin loading, `summon`/`hide`/`toggle`, and the scoped `shell` API @@ -47,21 +48,26 @@ To learn the host, read the shell's own code in `$OMARCHY_PATH/shell/` (`/usr/sh ## Repo layout ``` -SPEC.md, reference/ # the spec -plugin/ # the Omarchy shell plugin (what gets linked into ~/.config/omarchy/plugins/) - manifest.json # id typecraft.oma-cast, kind "menu" (so the shell passes shell.appLibrary), keepLoaded - Launcher.qml # entry point: open()/close() from the shell, IpcHandler "oma-cast", GlobalShortcut oma-cast:toggle - Backend.qml # in-process backend: paths, config.json, stores, method registry, notifications - ui/ # launcher surface and (later) views - services/ # QML services that do I/O (JsonStore, OmarchyMenuSource; later clipboard, …) - core/*.js # pure logic, no Qt imports: shared by QML and node tests (module.exports guard) +# The repo root is the plugin: `omarchy plugin add` clones it into ~/.config/omarchy/plugins/typecraft.omacast/ +manifest.json # id typecraft.omacast, kind "menu" (so the shell passes shell.appLibrary), keepLoaded +Launcher.qml # entry point: open()/close() from the shell, IpcHandler "omacast", GlobalShortcut omacast:toggle +Backend.qml # in-process backend: paths, config.json, stores, method registry, notifications +ui/ # launcher surface and (later) views +services/ # QML services that do I/O (JsonStore, OmarchyMenuSource; later clipboard, …) +core/*.js # pure logic, no Qt imports: shared by QML and node tests (module.exports guard) # Ranker, Frecency (ref 02 §4, port exactly), OmarchyMenu, RootSearch, Schema, Migrate, Rpc, Paths, Log -bin/oma-cast # bash CLI -tests/*.test.js # node --test unit tests for plugin/core (ranker, frecency, Omarchy menu, root search, …) +bin/omacast # bash CLI (works from a dev checkout or a marketplace install) +bin/omacast-search # built flea-search helper (gitignored; bin/build-search builds it from search/) +search/ # Rust source of the optional helper; builds into ~/.cache/omacast/search-target +SPEC.md, reference/ # the spec +tests/*.test.js # node --test unit tests for core/ (ranker, frecency, Omarchy menu, root search, …) tests/smoke.sh # live acceptance check against the running shell +LICENSE, README.md # marketplace requirements: root license, install/removal instructions ``` -Rules for `plugin/core/*.js`: one self-contained file per module (QML `import "x.js"` and node `require` must both work, so a module never imports another one). Expose functions through the `if (typeof module !== "undefined") module.exports = …` guard. QML wires the modules together. +The shell loads only the manifest's entry points, so the spec, tests and Rust source ride along harmlessly. Every push to the default branch reaches marketplace users on their next `omarchy plugin update`. `omarchy plugin validate .` must pass and the tree may hold no symlinks. + +Rules for `core/*.js`: one self-contained file per module (QML `import "x.js"` and node `require` must both work, so a module never imports another one). Expose functions through the `if (typeof module !== "undefined") module.exports = …` guard. QML wires the modules together. ## Commands @@ -69,41 +75,41 @@ Rules for `plugin/core/*.js`: one self-contained file per module (QML `import "x node --test tests/*.test.js # unit tests (node's built-in runner; no npm install) mise x act@latest -- act push # run .github/workflows/ci.yml locally in Docker (.actrc maps the WarpBuild label) tests/smoke.sh # live checks: doctor, IPC, toggle latency (<100 ms). Shows/hides the launcher, never types. -omarchy plugin validate plugin +omarchy plugin validate . -bin/oma-cast install # link plugin into ~/.config/omarchy/plugins, enable it, link CLI into ~/.local/bin -bin/oma-cast reload # pick up QML/JS edits (see "Reloading" below) -bin/oma-cast dev # reload on every save under plugin/ (needs inotifywait) -bin/oma-cast uninstall +bin/omacast install # link this checkout into ~/.config/omarchy/plugins, enable it, link CLI into ~/.local/bin +bin/omacast reload # pick up QML/JS edits (see "Reloading" below) +bin/omacast dev # reload on every save (needs inotifywait) +bin/omacast uninstall -oma-cast toggle | show | hide -oma-cast run # Phase 1+ -oma-cast call [json] # any backend method, e.g. oma-cast call system.info -oma-cast doctor -oma-cast setup hyprland # writes ~/.config/hypr/oma-cast.lua (Alt+Space) and requires it from hyprland.lua +omacast toggle | show | hide +omacast run # Phase 1+ +omacast call [json] # any backend method, e.g. omacast call system.info +omacast doctor +omacast setup hyprland [--yes] # asks, then writes ~/.config/hypr/omacast.lua (Alt+Space) and requires it from hyprland.lua -qs log -n -p "$OMARCHY_PATH/shell" -t 50 # shell log; our lines start with [oma-cast] +qs log -n -p "$OMARCHY_PATH/shell" -t 50 # shell log; our lines start with [omacast] ``` -**Reloading.** Quickshell keeps every QML/JS file it has read, keyed by path, for the life of the process, and the shell's inotify watch doesn't follow the symlink into this repo. So editing files under `plugin/` changes nothing until `oma-cast reload` re-links the plugin under a fresh `typecraft.oma-cast.dev-` path. Always reload, then check `qs log` for QML errors, before concluding a change works. A QML error shows up as "Type X unavailable" plus the real cause on the next line. Names that clash with JS globals (such as `escape`) are "Illegal method name". +**Reloading.** Quickshell keeps every QML/JS file it has read, keyed by path, for the life of the process, and the shell's inotify watch doesn't follow the symlink into this repo. So editing files in a checkout changes nothing until `omacast reload` re-links the plugin under a fresh `typecraft.omacast.dev-` path. Always reload, then check `qs log` for QML errors, before concluding a change works. A QML error shows up as "Type X unavailable" plus the real cause on the next line. Names that clash with JS globals (such as `escape`) are "Illegal method name". ## Running on Omarchy Check each of these on the machine instead of assuming it; Omarchy changes often. -- **Hyprland config is Lua** (Hyprland 0.56). `~/.config/hypr/hyprland.lua` `require`s `default.hypr.omarchy` and the user's `hypr.bindings` etc. Bindings use `o.bind("ALT + SPACE", "desc", dispatcher)`. `oma-cast setup hyprland` writes only `~/.config/hypr/oma-cast.lua` and appends one `require("hypr.oma-cast")`. Never edit Omarchy's files. -- **Hotkey.** Super+Space is the Omarchy menu and Super+Alt+Space is the dock's app grid. oma-cast defaults to **Alt+Space** through `hl.dsp.global("oma-cast:toggle")`, so the plugin's GlobalShortcut fires with no process spawn. Users can change their binding in `oma-cast.lua`; never force a Super+Space swap. -- **IPC.** `omarchy-shell args…` answers over the shell's socket (needs `socat`), falling back to `qs ipc`. Our target is `oma-cast`. `IpcHandler` (Quickshell.Io; 4.0.x dropped `ShellIpc`) functions must have typed params and return a string. +- **Hyprland config is Lua** (Hyprland 0.56). `~/.config/hypr/hyprland.lua` `require`s `default.hypr.omarchy` and the user's `hypr.bindings` etc. Bindings use `o.bind("ALT + SPACE", "desc", dispatcher)`. `omacast setup hyprland` writes only `~/.config/hypr/omacast.lua` and appends one `require("hypr.omacast")`. Never edit Omarchy's files. +- **Hotkey.** Super+Space is the Omarchy menu and Super+Alt+Space is the dock's app grid. omacast defaults to **Alt+Space** through `hl.dsp.global("omacast:toggle")`, so the plugin's GlobalShortcut fires with no process spawn. Users can change their binding in `omacast.lua`; never force a Super+Space swap. +- **IPC.** `omarchy-shell args…` answers over the shell's socket (needs `socat`), falling back to `qs ipc`. Our target is `omacast`. `IpcHandler` (Quickshell.Io; 4.0.x dropped `ShellIpc`) functions must have typed params and return a string. - **Theme.** Bind colours to `Commons.Color.menu.*` and sizes to `Style.*`. They follow `omarchy theme set` live; no theme parsing. - **App launching.** Use `shell.appLibrary.launch(id, name)`. It goes through `uwsm-app -- gtk-launch`, sends terminal apps to `omarchy-launch-tui`, and shows Omarchy's launch OSD. The shell only provides `appLibrary` to `menu`-kind plugins. - **Terminals and paste.** Alacritty/Ghostty/Kitty need Ctrl+Shift+V (SPEC §5.3). Read the class from `hyprctl activewindow -j`. - **Notifications** go to the Omarchy shell's notification service (`org.freedesktop.Notifications`). The bar has a tray widget (SNI). -- **Snippet expansion** needs the `input` group or a uinput udev rule. Keep it opt-in; `oma-cast doctor` explains it. +- **Snippet expansion** needs the `input` group or a uinput udev rule. Keep it opt-in; `omacast doctor` explains it. - **Testing on the live desktop.** Don't synthesize keystrokes (`wtype`) unless the launcher is already mapped and focused. Keys sent within about 25 ms of `show` go to the window underneath, which is usually the terminal running Claude. ## Status -Renamed **raylinux → oma-launcher → oma-cast** on 2026-10-19 (plugin `typecraft.oma-cast`, CLI `oma-cast`, IPC target and global shortcut `oma-cast:toggle`, XDG dirs `oma-cast`). The app icon is Omarchy's logo: glyph `\ue900` in the `omarchy` font, the same one the bar's menu button uses. +Renamed **raylinux → oma-launcher → oma-cast** on 2026-10-19, then **oma-cast → omacast** on 2026-11-01 for the Omarchy plugin marketplace (plugin `typecraft.omacast`, display name `omacast`, CLI `omacast`, IPC target `omacast`, global shortcut `omacast:toggle`, XDG dirs `omacast`). Another marketplace submission is called "Oma Cast" and uses IPC target `oma-cast`. The GitHub repo is still `typecraft-dev/oma-cast`. `core/Paths.js` `legacyMoves` moves the old XDG dirs on first start. `bin/omacast install` removes the old plugin links and CLI link, and `setup hyprland` migrates `oma-cast.lua`. The app icon is Omarchy's logo: glyph `\ue900` in the `omarchy` font, the same one the bar's menu button uses. **Phase 1 (Quickshell build): done**, with the gaps listed below. **Phase 0: done.** Omarchy 4.0.0.alpha, Quickshell 0.3.2, Hyprland 0.56.2. @@ -112,15 +118,16 @@ Works: - empty root: Favorites, Suggestions (frecency > 1, top 6), Commands, Applications - query: Results, then `Use "" with…` (Google, DuckDuckGo fallbacks) - **Providers:** apps from `shell.appLibrary`, the whole Omarchy menu (about 400 items, with breadcrumbs, guards, ✓ state and font/power-profile provider rows), and the built-in "Omarchy Menu". +- **Opening an open app [D]:** ↵ on an app focuses its most recently used window, switching to its workspace, and launches only when none is open. Windows match by desktop id, `StartupWMClass`, or the URL Chromium/Brave web app classes are built from (`RootSearch.findAppWindow`). The focus waits for the `omacast` layer to unmap, since Hyprland refuses focus while it holds the keyboard. Focus doesn't raise on Omarchy 4.5's floating workspaces, so the window is then brought to the top, and one minimized to the dock is moved back first, the way the dock does it. Shift+↵ ("Open New Window") always launches. - **Ranking state:** frecency (+100 per launch, 10-day half-life) and learned search terms in `db/frecency.json`. Aliases (exact → top; duplicates rejected), favourites (Ctrl+Shift+F, reorder Ctrl+Alt+↑↓) and disabled commands live in `db/settings.json` `commands`. - **Navigation:** pushed Omarchy submenus (search within them), the alias editor, back with Esc / Ctrl+[ / Backspace on an empty query / the header arrow, and Shift+Esc to pop to root. - **Keyboard:** Ctrl+K action panel (filterable, per-row shortcuts), ↵ / Ctrl+↵ / Ctrl+Shift+↵ for the first three actions, Ctrl+1–9, Ctrl+↑↓ to jump sections, PageUp/PageDown, ↑ on an empty root for search history, Ctrl+Shift+C copy, Alt+Shift+D disable, Ctrl + / − / 0 zoom. - - Navigation bindings (`general.navigationBindings`): `emacs` moves down with Ctrl+N; `vim-motions` moves with Ctrl+J/K and puts the action panel on **Alt+K**. Set it with `oma-cast call settings.updateGeneralSettings '{"navigationBindings":"vim-motions"}'`. + - Navigation bindings (`general.navigationBindings`): `emacs` moves down with Ctrl+N; `vim-motions` moves with Ctrl+J/K and puts the action panel on **Alt+K**. Set it with `omacast call settings.updateGeneralSettings '{"navigationBindings":"vim-motions"}'`. - **Ctrl+P** opens the root dropdown (All / Applications / Commands); emacs Ctrl+P = up only works inside popups. - **Ctrl+T** opens the toast's actions (Copy Error Message on failures, Undo after Disable Command). **Ctrl+Shift+K** opens the About panel for the selected row. - **Argument pills:** "Search Google" / "Search DuckDuckGo" take a query in a pill after the search text (Tab in, Shift+Tab out). - - **Alerts:** Shut Down, Restart, Log Out and Delete All Entries ask first (↵ confirms, Esc cancels). `oma-cast run` skips the alert. -- **Feedback:** toasts in the footer, a HUD through Omarchy's OSD when the window closes, and a 2 px loading bar while guards and providers refresh (on every open, about 250 ms in the background). + - **Alerts:** Shut Down, Restart, Log Out and Delete All Entries ask first (↵ confirms, Esc cancels). `omacast run` skips the alert. +- **Feedback:** toasts in the footer and a HUD through Omarchy's OSD when the window closes; static footer "Loading…" appears only after 500 ms of visible loading, yields to toasts and resets on hide. The header divider stays still. - **Window behaviour:** - The `escapeKeyBehavior` setting works both ways. - Pop-to-root runs after `popToRootTimeout`. @@ -131,7 +138,7 @@ Works: - arithmetic and functions - Soulver-style percentages (`$30 + 20%`, `20% off 50`, `50 as a % of 200`) - ~250 units (`5 ft to cm`, `60 mph * 2 h`, `16px to rem`) - - currencies (ECB rates from frankfurter.dev, cached daily in `~/.cache/oma-cast/currencies.json`; turn off with `"currencyRates": false` in config.json) + - currencies (ECB rates from frankfurter.dev, cached daily in `~/.cache/omacast/currencies.json`; turn off with `"currencyRates": false` in config.json) - dates (`3 days from now`, `days until christmas`) - time zones (`time in tokyo`, `3pm london to nyc`; offsets come from one bash `printf %(%z)T` pass because QML has no Intl) - bases (`255 to hex`) @@ -141,15 +148,15 @@ Works: config.json sets `remToPxRate` (16), `workHoursPerDay` (8) and `clockFormat` (`auto` follows the Qt locale, or `12h` / `24h`). - ↵ copies the answer (HUD), Ctrl+↵ pastes it through `omarchy-clipboard-paste-text`, Ctrl+Shift+C copies "question = answer". Each one saves to **Calculator History** (`db/calculator.json`, 4096 rows / 90 days): a built-in command and pushed view, searchable, Ctrl+D deletes an entry, Ctrl+Shift+D deletes all. RPC: `calculator.calculate|historyGetAll|historyDelete|historyDeleteAll|refreshCurrencies|getCurrencies`. A bare number, word or quantity (`42`, `firefox`, `5 kg`) never produces a row, because the section takes ↵. + ↵ copies the answer (HUD), Ctrl+↵ pastes it (wl-copy on stdin, then Shift+Insert), Ctrl+Shift+C copies "question = answer". Each one saves to **Calculator History** (`db/calculator.json`, 4096 rows / 90 days): a built-in command and pushed view, searchable, Ctrl+D deletes an entry, Ctrl+Shift+D deletes all. RPC: `calculator.calculate|historyGetAll|historyDelete|historyDeleteAll|refreshCurrencies|getCurrencies`. A bare number, word or quantity (`42`, `firefox`, `5 kg`) never produces a row, because the section takes ↵. - **Latency:** show → mapped in 59–66 ms, hide in 24–42 ms on Omarchy 4.0.6 (`tests/smoke.sh`; it was 37–44 ms on the old `OverlayWindow`). -- **Tests:** root search, calculator and default-agent hand-off coverage (`node --test tests/*.test.js`). +- **Tests:** root search, calculator and privacy coverage (`node --test tests/*.test.js`). -**Default-agent hand-off (fork, 2026-10-20):** user-authorized addition outside the original phase plan. The root fallback "Ask default agent" and Ctrl+Alt+Enter pass the query to `omarchy-agent --prompt` as a literal argv element. `AgentLauncher.qml` checks the configured agent/terminal with a bounded, question-free preflight, prevents overlapping submissions and cancels on hide/unload. `Launcher.qml` dismisses the overlay before launching through `Util.execArgv`. Errors leave the query visible. There are no new dependencies, model APIs, credentials or question-history writes. Existing CLI approval behaviour is inherited. Portable checks do not establish live terminal focus, authentication, or provider compatibility; verify those on Omarchy. +**Default-agent hand-off:** removed 2026-11-02 after the marketplace review; it exposed the question in process arguments (SPEC §2.0). Stubbed / not yet: -- No settings window, so you can't re-enable a disabled command except with Undo on the toast (Ctrl+T) or by editing `~/.local/share/oma-cast/db/settings.json` (delete its `enabled: false`). That's Phase 2. -- No deep links (`oma-cast://`); Phase 2 or 9. +- No settings window, so you can't re-enable a disabled command except with Undo on the toast (Ctrl+T) or by editing `~/.local/share/omacast/db/settings.json` (delete its `enabled: false`). That's Phase 2. +- No deep links (`omacast://`); Phase 2 or 9. Won't do (decided 2026-10-22): - Keystroke buffering. Keys sent within ~40 ms of the hotkey go to the window underneath; fixing it needs a parked surface, which Omarchy avoids (SPEC §12 Phase 1). @@ -162,3 +169,10 @@ Deviations from the original spec (SPEC §2.0 and the Phase 1 note in §12): - The Omarchy theme replaces Raycast's 8 themes; the HUD is Omarchy's OSD. Testing lesson: the query survives hide/show until pop-to-root. A live test must clear it first (Ctrl+U), or typed text gets appended to the old query. Pressing ↵ on a stale query once opened real browser tabs. + +**Optional camera preview add-on:** `addons/camera-preview/` is a separate +Omarchy panel plugin using Qt Multimedia, registered in omacast through the +existing user menu extension API. It previews the default camera, supports +selecting another connected camera, and stops capture when hidden or unloaded. +It does not implement the planned Raycast extension runtime. Installation is +opt-in; see its README. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..243a323 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 typecraft and omacast contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/plugin/Launcher.qml b/Launcher.qml similarity index 88% rename from plugin/Launcher.qml rename to Launcher.qml index ce42f89..f14679f 100644 --- a/plugin/Launcher.qml +++ b/Launcher.qml @@ -12,7 +12,7 @@ Item { property var shell: null property var manifest: null - readonly property string pluginId: (root.manifest && root.manifest.id) || "typecraft.oma-cast" + readonly property string pluginId: (root.manifest && root.manifest.id) || "typecraft.omacast" property bool opened: false property double openedAt: 0 @@ -56,7 +56,7 @@ Item { } function run(commandId) { - // `oma-cast run` is explicit, so it skips the confirmation alert. + // `omacast run` is explicit, so it skips the confirmation alert. var r = backend.callSync("rootSearch.performAction", { id: commandId, action: "open", query: "", confirmed: true }) if (!r.ok) return r var outcome = r.data || {} @@ -77,16 +77,6 @@ Item { summon: function(id, payload) { return root.shell ? root.shell.summon(id, payload) : false } } - Connections { - target: backend - function onNotification(method, data) { - if (method !== "agent.launchReady") return - root.hide() - launcher.popToRoot() - Util.execArgv(data.argv) - } - } - LauncherWindow { id: launcher shown: root.opened @@ -96,14 +86,14 @@ Item { } GlobalShortcut { - appid: "oma-cast" + appid: "omacast" name: "toggle" - description: "Toggle oma-cast" + description: "Toggle omacast" onPressed: root.toggle() } IpcHandler { - target: "oma-cast" + target: "omacast" function toggle(): string { root.toggle() diff --git a/README.md b/README.md index 392b025..0a74f11 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,11 @@
-# oma-cast +# omacast **A Raycast-esque launcher for [Omarchy](https://omarchy.org).** It learns what you use, searches your apps and every Omarchy action, and looks like it shipped with your theme. -oma-cast showing suggestions for apps and Omarchy commands +omacast showing suggestions for apps and Omarchy commands
@@ -13,10 +13,10 @@ It learns what you use, searches your apps and every Omarchy action, and looks l ## Why -Omarchy's built-in menu is great at what it does. oma-cast adds what Raycast users miss on Linux: +Omarchy's built-in menu is great at what it does. omacast adds what Raycast users miss on Linux: * **Learns your habits.** Results are ranked with a port of Raycast's root-search algorithm, plus frecency and learned search terms. The things you actually use float to the top, and if you type `ff` and pick Firefox, `ff` keeps meaning Firefox. -* **One search for everything.** Your apps, all ~400 Omarchy menu actions (with breadcrumbs and live ✓ state), web-search fallbacks, and an explicit hand-off to your default coding agent. +* **One search for everything.** Your apps, all ~400 Omarchy menu actions (with breadcrumbs and live ✓ state), and web-search fallbacks. * **Your shortcuts.** Give anything an alias, pin favorites, and hide the commands you never touch. * **Keyboard-first.** A Ctrl+K action panel, Ctrl+1–9 quick picks, search history on ↑, section jumping, and emacs-style navigation. * **Native to Omarchy.** It runs inside `omarchy-shell` as a plugin, with no Electron, no Node and no extra daemons. It opens in about 40 ms and follows your theme live when you run `omarchy theme set`. @@ -41,55 +41,92 @@ Omarchy's built-in menu is great at what it does. oma-cast adds what Raycast use ### Follows your Omarchy theme -There's no theme config. oma-cast binds to Omarchy's own colours, so it changes the moment you switch themes. +There's no theme config. omacast binds to Omarchy's own colours, so it changes the moment you switch themes. -oma-cast in Tokyo Night, Gruvbox, Rose Pine, Flexoki Light, Everforest and Osaka Jade +omacast in Tokyo Night, Gruvbox, Rose Pine, Flexoki Light, Everforest and Osaka Jade ## Install -Requires Omarchy 4 (Hyprland 0.56+, `omarchy-shell`) and `socat`, which Omarchy already ships. +Requires Omarchy 4 (Hyprland 0.56+, `omarchy-shell`). Install it from the +[Omarchy plugin marketplace](https://plugins.omarchy.org), or with: ```sh -git clone https://github.com/typecraft-dev/oma-cast.git -cd oma-cast -bin/oma-cast install # link the plugin into omarchy-shell and the CLI into ~/.local/bin -oma-cast setup hyprland # bind Alt+Space -oma-cast doctor # check everything is wired up +omarchy plugin add https://github.com/typecraft-dev/oma-cast --enable +~/.config/omarchy/plugins/typecraft.omacast/bin/omacast install # link the omacast CLI into ~/.local/bin +omacast setup hyprland # bind Alt+Space +omacast doctor # check everything is wired up ``` -Press **Alt+Space**. Super+Space stays the Omarchy menu. You can change the launcher binding in `~/.config/hypr/oma-cast.lua`. +Press **Alt+Space**. Super+Space stays the Omarchy menu. + +`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 +`omacast.lua`; setup never overwrites one that already exists. To bind it yourself instead, add +this to your Hyprland config: + +```lua +o.bind("ALT + SPACE", "omacast launcher", hl.dsp.global("omacast:toggle")) +``` -To remove it: `oma-cast uninstall`. +Update with `omarchy plugin update typecraft.omacast`. + +### Remove + +```sh +omacast uninstall # asks to remove the hotkey, then runs: omarchy plugin remove typecraft.omacast +``` -## Ask your default agent +Or remove it by hand: `omarchy plugin remove typecraft.omacast`, then delete `~/.config/hypr/omacast.lua` +and the `require("hypr.omacast")` line in `~/.config/hypr/hyprland.lua`, and `~/.local/bin/omacast`. +Your data stays until you delete it: `~/.config/omacast`, `~/.local/share/omacast`, `~/.cache/omacast` +and `~/.local/state/omacast`. -Type a question, select **Ask default agent** under the web-search fallbacks, -and press Enter. **Ctrl+Alt+Enter** sends the current root-search text directly. -This opens a new session with your question in Omarchy's configured default -agent and terminal (Foot, Ghostty, Kitty or Alacritty). +### Dependencies and network access -The action checks your setup first. Missing agent/terminal setup leaves the -launcher open with your question and an error. Closing the launcher during the -check cancels the pending hand-off. Normal app search and Enter selection keep -working as before; typing alone never submits a question. +omacast needs only what Omarchy ships: `omarchy-shell` (Quickshell), Hyprland, bash, `jq` and `socat`. +It adds no packages and no background services. -Requires an Omarchy version with `omarchy-agent-prompt` and -`omarchy-agent --prompt` (verified against `quattro` revision `3ac4365`). Select -your defaults in Omarchy Settings. Agent authentication, accounts, approval -behaviour, session working directory and terminal selection remain owned by -Omarchy and the agent CLI. oma-cast does not store the question in search history, -read credentials, or make model API calls. Successful preflight checks establish -launch prerequisites, not successful sign-in or a running terminal; those still -need live desktop validation. +- **Currency rates.** When you open the launcher and its rates are over 12 hours old, the calculator downloads + ECB exchange rates from `api.frankfurter.dev` (retrying 30 minutes after a failure) and caches them in + `~/.cache/omacast/currencies.json`. This is the only network request omacast makes. Turn it off with + `"currencyRates": false` in `~/.config/omacast/config.json`. +- **Optional search helper.** With Rust (`cargo`) installed, `omacast install` (or `omacast build-search`) + builds the small **flea-search** helper from `search/` on your machine. It matches titles, alternate names, + subtitles and keywords outside the shell's UI thread and tolerates transposed or missing letters. Nothing is + downloaded prebuilt. Without it, omacast uses the built-in matcher, so a marketplace install works without + Rust. Aliases, favorites, learned terms and frecency stay in omacast's ranker either way. If the helper fails + or times out, search falls back to the built-in matcher until the plugin reloads. Rebuild it after + `omarchy plugin update`. + +### Privacy + +Your search history, learned search terms and calculator history stay on your machine, in owner-only +files: omacast creates its `~/.config`, `~/.local/share`, `~/.cache` and `~/.local/state` directories as +`0700` and its database files as `0600`, and tightens any it finds readable by other users. Text you copy +or paste from the calculator goes to `wl-copy` on stdin, never on a command line, so other local users +can't read it from the process list. Paste sends Shift+Insert after copying, as Omarchy's clipboard history does. + +### Upgrading from oma-cast + +The plugin used to be `typecraft.oma-cast`. On first start, omacast moves your `oma-cast` config, data, +cache and state directories to the `omacast` names. In a development checkout, `bin/omacast install` removes +the old plugin link and `~/.local/bin/oma-cast`, and `omacast setup hyprland` moves `oma-cast.lua` to +`omacast.lua`, keeping your key. `omacast doctor` reports anything left over. + +## Optional plugins + +- [Camera preview](addons/camera-preview/README.md): add **Open camera** to check + your webcam in a mirrored preview before a meeting. ## Keyboard | Key | Does | |---|---| | `↵` / `Ctrl+↵` / `Ctrl+Shift+↵` | Run the first, second or third action | +| `Shift+↵` | Open a new window of an app (`↵` switches to the one already open) | | `Ctrl+K` | Open the action panel | | `Super+V` / `Ctrl+V` / `Ctrl+Shift+V` / `Shift+Insert` | Paste text into the query (also accepts clipboard-history selections) | -| `Ctrl+Alt+↵` | Ask the default agent using the current root-search text | | `Ctrl+1`…`9` | Pick a result | | `↑` on an empty search | Search history | | `Ctrl+↑` / `Ctrl+↓` | Jump between sections | @@ -103,15 +140,15 @@ need live desktop validation. ## CLI ```sh -oma-cast toggle | show | hide -oma-cast run # e.g. app:com.mitchellh.ghostty, omarchy:style.theme -oma-cast call [json] # call any backend method -oma-cast doctor +omacast toggle | show | hide +omacast run # e.g. app:com.mitchellh.ghostty, omarchy:appearance.theme +omacast call [json] # call any backend method +omacast doctor ``` ## Roadmap -oma-cast is early. Search, ranking and the Omarchy integration are done. Coming next: +omacast is early. Search, ranking and the Omarchy integration are done. Coming next: - [ ] Settings window - [ ] Script Commands @@ -125,13 +162,17 @@ The full plan is in [`SPEC.md`](SPEC.md). ## Development ```sh +git clone https://github.com/typecraft-dev/oma-cast.git +cd oma-cast +bin/omacast install # symlink this checkout into omarchy-shell instead of a marketplace copy node --test tests/*.test.js # unit tests, no npm install needed -tests/smoke.sh # live checks against the running shell -oma-cast dev # reload on every save +tests/smoke.sh # live checks against the running shell +omacast dev # reload on every save ``` -Quickshell caches QML for the life of the process, so run `oma-cast reload` after editing anything under `plugin/`. See [`CLAUDE.md`](CLAUDE.md) for the layout and conventions. +Remove a marketplace install first (`omarchy plugin remove typecraft.omacast`); both use the same plugin id. +Quickshell caches QML for the life of the process, so run `omacast reload` after editing a checkout. See [`CLAUDE.md`](CLAUDE.md) for the layout and conventions. ## Notes -oma-cast is an independent, clean-room project. It isn't affiliated with or endorsed by Raycast, and it contains no Raycast code or assets. It never contacts Raycast's servers and has no telemetry. Its agent action explicitly hands your question to Omarchy's existing coding-agent launcher; it provides no embedded AI runtime. +omacast is an independent, clean-room project. It isn't affiliated with or endorsed by Raycast, and it contains no Raycast code or assets. It never contacts Raycast's servers and has no telemetry. Its agent action explicitly hands your question to Omarchy's existing coding-agent launcher; it provides no embedded AI runtime. diff --git a/SPEC.md b/SPEC.md index 44a925c..4e3a823 100644 --- a/SPEC.md +++ b/SPEC.md @@ -2,7 +2,7 @@ **Source:** static reverse engineering of Raycast **2.8.2** (`com.raycast.macos`, macOS 26, arm64) installed on this machine, done 2026-10-19. **Target:** a clean-room, feature-equivalent launcher for **Arch Linux**, Wayland first (Hyprland → KDE → GNOME), with an X11 fallback. -**Scope:** all Raycast features **except AI** (AI Chat, Quick AI, AI Commands, MCP, Screen Awareness, Browser-context-for-AI, "Ask X" commands, Dictate-to-AI, Remote). This fork adds a user-authorized explicit hand-off to Omarchy's default agent in its default terminal (§2.0); it does not implement those Raycast AI features. +**Scope:** all Raycast features **except AI** (AI Chat, Quick AI, AI Commands, MCP, Screen Awareness, Browser-context-for-AI, "Ask X" commands, Dictate-to-AI, Remote). This file is the plan. Claude Code should read it first. The `reference/` folder holds the detailed evidence (verbatim schemas, method lists, wire formats), and this spec points into it by section. @@ -25,7 +25,7 @@ Legend: **[O]** = observed in the shipped binaries or JS. **[I]** = inferred. ** 1. **Clean room.** Do not copy, bundle or redistribute anything from `/Applications/Raycast.app`: no frontend JS, `index.mjs`, `@raycast/api/index.js` from the app bundle, icons, PNGs, fonts or `.node` modules. They are proprietary. This spec describes *behaviour and interfaces* so you can write your own implementation. The open-source pieces you can use are the public npm package `@raycast/api` (type definitions and the `ray` CLI; check its license for the version you pin), `@raycast/utils`, and the `raycast/extensions` GitHub repo (MIT), which is the corpus you test compatibility against. 2. **Don't call Raycast's servers.** Store, account, sync, currency rates, translation and telemetry all go to `*.raycast.com`. A third-party client must not use them. Get extensions from the `raycast/extensions` repo and build them locally (§9.8). Use your own or public data sources for everything else. 3. **No telemetry.** Drop Sentry and PostHog completely. -4. **Embedded AI is out of scope.** The explicit default-agent terminal hand-off in §2.0 is the fork's authorized exception. Where another built-in mixes AI with non-AI behaviour (Dictation, Translator), build only the non-AI path and leave a `// AI: not implemented` seam. +4. **Embedded AI is out of scope.** Where another built-in mixes AI with non-AI behaviour (Dictation, Translator), build only the non-AI path and leave a `// AI: not implemented` seam. 5. **Prior art to study, not copy:** Vicinae (an open-source Linux launcher with Raycast-extension compatibility; check its current state), Espanso (text expansion via evdev/uinput), cliphist/clipse (Wayland clipboard history), plocate/fsearch (file index), Ulauncher/Walker/Anyrun (launcher UX on Wayland). --- @@ -73,53 +73,54 @@ Consequences for Linux: ### 2.0 Quickshell build (current) [D, 2026-10-19] -**This supersedes the Tauri + Node design below for Omarchy.** Omarchy's desktop (bar, menus, launcher, notifications, lock screen) runs in one long-lived Quickshell process, `omarchy-shell`, whose features are plugins. oma-cast is one more plugin there, built the way Omarchy builds its own menu and emoji picker. The reasons: +**Search acceleration [D, 2026-10-28].** An optional Rust helper vendors Flea's MIT typed matcher; this is an +approved exception to the QML/JS-only runtime. The launcher searches asynchronously over JSON lines while omacast +still applies personalization, filtering, calculator and fallback sections. Exact, prefix, word, subsequence and +typo matches use Flea's tiers, with shorter titles breaking prefix/word ties after frecency. High sensitivity excludes +typo-only hits; medium and low use Flea's bounded edit budget (one edit from four alphanumeric letters, two from eight). +The old matcher remains the synchronous IPC and failure fallback. Query/view/filter/revision changes and hide cancel +obsolete results; navigation cannot activate an entry while its search is pending. Startup, malformed replies, exit +and timeout all have a bounded fallback. Build-time dependencies are Rust, serde and serde_json; no extra runtime or +system daemon. Flea's source provenance and license live in `search/`. Results update the existing ListModel in place and reuse visible delegates, following Flea 0.3.12's stable-model technique. Unchanged answers produce no model writes. + + +**This supersedes the Tauri + Node design below for Omarchy.** Omarchy's desktop (bar, menus, launcher, notifications, lock screen) runs in one long-lived Quickshell process, `omarchy-shell`, whose features are plugins. omacast is one more plugin there, built the way Omarchy builds its own menu and emoji picker. The reasons: - **No external dependencies.** No Tauri, WebKitGTK, Node runtime, npm packages or native SQLite modules. It needs only what Omarchy already ships: Quickshell, Qt, bash, socat and Hyprland. - **Native look.** The plugin reads the active Omarchy theme's `[menu]` tokens (`qs.Commons.Color`, `Style`), so `omarchy theme set` restyles it live with no theme parser. - **Speed.** The shell process is always warm. A hotkey reaches the plugin as a Hyprland global shortcut with no process spawn, and the CLI goes through the shell's socket (about 5 ms). ``` omarchy-shell (Quickshell, one process) - └─ plugin typecraft.oma-cast (~/.config/omarchy/plugins/ → repo/plugin) - ├─ Launcher.qml lifecycle open()/close(), ShellIpc target "oma-cast", GlobalShortcut oma-cast:toggle - ├─ ui/ launcher surface on Omarchy's OverlayWindow (wlr-layer-shell, namespace "oma-cast") + └─ plugin typecraft.omacast (~/.config/omarchy/plugins/typecraft.omacast: a marketplace clone of the repo, or a link to a checkout) + ├─ Launcher.qml lifecycle open()/close(), ShellIpc target "omacast", GlobalShortcut omacast:toggle + ├─ ui/ launcher surface on Omarchy's OverlayWindow (wlr-layer-shell, namespace "omacast") ├─ Backend.qml the "backend": paths, config.json, stores, method registry ├─ services/ QML services with I/O (JsonStore = FileView + migrations; later clipboard, apps…) └─ core/*.js pure logic, no Qt: Rpc, Paths, Migrate, Log, Schema. Unit-tested with `node --test` -bin/oma-cast bash CLI → omarchy-shell oma-cast (socket), plus install/reload/doctor/setup +bin/omacast bash CLI → omarchy-shell omacast (socket), plus install/reload/doctor/setup ``` How the rest of this spec maps onto it: -- **§3 IPC.** There is no wire protocol between UI and backend; both live in one QML engine. The *method surface* stays: services register `namespace.method` handlers in one registry (`core/Rpc.js`), using Raycast's names (§3.4). `call()` returns a Promise and `callSync()` returns `{ok,data|error}`. Notifications (`window.visibilityChanged`, `settings.generalSettingsChanged`) go through `subscribe()`/`emit()`. External callers use `ShellIpc` (`oma-cast call `). The NDJSON host↔backend contract and supervisor (§3.3) are **deferred to Phase 5**: only Raycast extensions need Node, and they will run as a supervised child `Process` that speaks §3.3/§9.2. +- **§3 IPC.** There is no wire protocol between UI and backend; both live in one QML engine. The *method surface* stays: services register `namespace.method` handlers in one registry (`core/Rpc.js`), using Raycast's names (§3.4). `call()` returns a Promise and `callSync()` returns `{ok,data|error}`. Notifications (`window.visibilityChanged`, `settings.generalSettingsChanged`) go through `subscribe()`/`emit()`. External callers use `ShellIpc` (`omacast call `). The NDJSON host↔backend contract and supervisor (§3.3) are **deferred to Phase 5**: only Raycast extensions need Node, and they will run as a supervised child `Process` that speaks §3.3/§9.2. - **§4 platform.** Omarchy = Hyprland, so Phase 0 targets Hyprland only. Its config is **Lua** (`hyprland.lua`, `o.bind(...)`, `hl.dsp.global(...)`), not `.conf`. - **§5.1 window.** It uses Omarchy's `OverlayWindow`: a fullscreen transparent layer surface with exclusive keyboard focus, and the 750×475 card drawn inside it. Clicking outside the card counts as focus loss. The surface is mapped fresh on every open, which is how Omarchy avoids stale surfaces when monitors change. -- **§6 data.** Persisted state is one versioned JSON document per store under `$XDG_DATA_HOME/oma-cast/db/` (`core/Migrate.js`: numbered migrations, history, atomic writes, never overwrite an unreadable or newer file). There is **no encryption at rest** and no libsecret key: Qt's FileView has no cipher, and full-disk encryption is the Omarchy default. If a store outgrows JSON (clipboard history, file index), revisit with Qt's built-in `QtQuick.LocalStorage` (SQLite) before adding a native dependency. +- **§6 data.** Persisted state is one versioned JSON document per store under `$XDG_DATA_HOME/omacast/db/` (`core/Migrate.js`: numbered migrations, history, atomic writes, never overwrite an unreadable or newer file). There is **no encryption at rest** and no libsecret key: Qt's FileView has no cipher, and full-disk encryption is the Omarchy default. If a store outgrows JSON (clipboard history, file index), revisit with Qt's built-in `QtQuick.LocalStorage` (SQLite) before adding a native dependency. - **§10 frontend.** QML instead of React, with themes from Omarchy's tokens. The 8 Raycast themes (§10.4) become optional palettes over the same tokens. -- **Logging** goes to the shell's log (`qs log -n -p "$OMARCHY_PATH/shell"`, lines prefixed `[oma-cast]`), with the level from `config.json`. There are no separate log files. - -**Default-agent hand-off [D, fork, 2026-10-20].** Non-empty root queries add an -"Ask default agent" row after the web-search fallbacks. Selecting it or pressing -Ctrl+Alt+Enter explicitly submits the current query to `omarchy-agent --prompt` -as one literal argument. Omarchy owns agent/account choice, authentication, -approval flags, working directory and terminal selection via `omarchy-launch-tui` -and `xdg-terminal-exec`. No agent SDK, network endpoint, credentials or new -package is introduced. Reference: Omarchy `quattro` `3ac43659dcbf88e84d951e3ce979b48a60a7d1f2`, -`shell/plugins/agents/Panel.qml` (`startPrompt`) and `bin/omarchy-agent{,-prompt}`. - -`services/AgentLauncher.qml` owns one bounded preflight at a time; core argv -validation and the fixed check script live in `core/Agent.js`. Missing tooling, -unset/uninstalled agent or unavailable terminal reports a visible error without -clearing the query. Hide, timeout or plugin unload cancels the pending request. -On success the entry point closes the overlay, clears the query and forwards -argv using `Util.execArgv`. This checks prerequisites only: authentication and -terminal startup failures remain the existing launcher's responsibility. - -Accept: normal app ranking/Enter selection is unchanged; fallback and shortcut -launch a fresh default-agent session in the selected terminal; quotes, Unicode, -multiline text, shell metacharacters and leading dashes arrive literally; blank -queries never submit; missing setup and cancellation never launch. Portable -fixtures cover argv/preflight and lifecycle; live Omarchy focus, terminal and -account acceptance remains required. Embedded chat and model calls are deferred. +- **Logging** goes to the shell's log (`qs log -n -p "$OMARCHY_PATH/shell"`, lines prefixed `[omacast]`), with the level from `config.json`. There are no separate log files. + +**Default-agent hand-off: removed 2026-11-02 [D].** It passed the question to +`omarchy-agent --prompt`, which hands it to every agent CLI as an argument, so other +local users could read it from `/proc`. The marketplace review blocked that, and +Omarchy has no private prompt transport, so the row, Ctrl+Alt+Enter, +`core/Agent.js` and `services/AgentLauncher.qml` are gone. Revisit if +`omarchy-agent` gains a stdin or file prompt. + +**Privacy [D, 2026-11-02].** No user text (queries, calculator questions or +answers, prompts) may reach any process's argv: clipboard writes go through +`services/ClipboardWriter.qml` on stdin. Startup (`Paths.SETUP_SCRIPT`) creates +the base dirs `0700` and store files `0600` before the first atomic write, and +repairs readable ones with `chmod go-rwx` (never broadening). Covered by +`tests/privacy.test.js` under `umask 022`. The original Tauri design follows, kept for reference and for non-Omarchy targets. @@ -161,7 +162,7 @@ Why this shape: - **A Node backend is unavoidable**: Raycast extensions are Node/React programs. Putting the rest of the business logic there too mirrors Raycast and keeps one language for the services. - Write the host in Rust, not Node, for anything that needs Wayland protocols, uinput/evdev, D-Bus signals or low latency. -**Working name:** `` throughout. Pick one, e.g. `oma-cast`. Use app id `dev..` and binary ``. +**Working name:** `` throughout. Pick one, e.g. `omacast`. Use app id `dev..` and binary ``. --- @@ -649,12 +650,12 @@ Each running menu-bar command owns one SNI item (ksni): - search field with placeholder "Search for apps and commands…" (each view sets its own) - inline argument pills after the measured text width - optional right-side dropdown accessory (Ctrl+P) - - a 2 px loading bar under the header + - a static divider under the header; loading feedback stays in the footer [D, 2026-10-28] - **Body:** sectioned list. - Row: icon 22 px, title, subtitle in secondary colour, and right-aligned accessories in this order: tags/text/date, alias badge, hotkey glyphs, type label ("Application", "Command", "Quicklink", …). - Master-detail split for list detail (~40/60). Grid view (columns 1–8). Empty state with icon, title and description. - **Footer (42 px):** - - left: current context icon + title, or the active toast (style icon, title, message) + - left: current context icon + title, or the active toast (style icon, title, message); show quiet "Loading…" after 500 ms of visible loading, with toast priority and reset on hide [D, 2026-10-28] - right: primary action title + ↵, a separator, "Actions" + Ctrl+K - **Action panel (Ctrl+K):** popover anchored bottom-right, with its own search field and sections (primary, then copy, configure, deeplink, delete). - Each row shows its shortcut. @@ -765,11 +766,11 @@ Repo layout [D]: ``` ### Phase 0: Skeleton and IPC (Quickshell build, §2.0) [D] -- Omarchy shell plugin `typecraft.oma-cast` (`plugin/`, kind `menu` so the shell hands it the shared app library, `keepLoaded`). `bin/oma-cast install` links it into `~/.config/omarchy/plugins/` and enables it. -- Launcher surface (750×475, 66 header, 42 footer) on the active Omarchy theme. It starts hidden and is toggled by `oma-cast toggle`, `omarchy-shell oma-cast toggle` or the global shortcut `oma-cast:toggle` (`oma-cast setup hyprland` binds Alt+Space). +- Omarchy shell plugin `typecraft.omacast` (`plugin/`, kind `menu` so the shell hands it the shared app library, `keepLoaded`). `bin/omacast install` links it into `~/.config/omarchy/plugins/` and enables it. +- Launcher surface (750×475, 66 header, 42 footer) on the active Omarchy theme. It starts hidden and is toggled by `omacast toggle`, `omarchy-shell omacast toggle` or the global shortcut `omacast:toggle` (`omacast setup hyprland` binds Alt+Space). - In-process backend: method registry with Raycast names, `system.healthPing`, `system.healthDatabase`, `system.info`, `system.getPaths`, `settings.getGeneralSettings` / `updateGeneralSettings`, and notifications. The footer shows a live `system.healthPing` round trip. - XDG paths (§6.3), `config.json` (hot reload), logging to the shell log, and versioned JSON stores with a migrations framework. -- **Accept:** `oma-cast toggle` shows and hides in under 100 ms warm, measured from the CLI call to Hyprland's `openlayer`/`closelayer` event (`tests/smoke.sh`). A backend error or an unreadable store never takes the launcher down: calls answer `{ok:false,error}`, and the store goes read-only without overwriting the file, then recovers when the file is fixed. `node --test tests/` covers the registry, paths, migrations and logging. +- **Accept:** `omacast toggle` shows and hides in under 100 ms warm, measured from the CLI call to Hyprland's `openlayer`/`closelayer` event (`tests/smoke.sh`). A backend error or an unreadable store never takes the launcher down: calls answer `{ok:false,error}`, and the store goes read-only without overwriting the file, then recovers when the file is fixed. `node --test tests/` covers the registry, paths, migrations and logging. ### Phase 0 (original Tauri plan, superseded for Omarchy) - Tauri app with a main window. Hidden at start, toggled by ` toggle` (D-Bus single instance). @@ -787,7 +788,7 @@ Repo layout [D]: - Action panel, footer, toasts, HUD window, alerts, navigation stack, all shortcuts in §10.3. - **Accept:** type "fir" → Firefox first. Launching bumps frecency. Alias "ff" → exact top. Ctrl+K opens actions. Esc behaviour matches the setting. Golden tests for the ranker. - **Quickshell build [D, 2026-10-19]:** - - **Omarchy menu.** Every item from `omarchy-menu.jsonc` (default plus the user's `~/.config/omarchy/extensions/` file) is a root-search command (`plugin/core/OmarchyMenu.js`). Its subtitle is the breadcrumb ("Trigger › Toggle"). `when` guards hide rows, `checked` adds ✓, and `disabled` dims a row. The `fonts` and `power-profiles` provider rows are searchable too. Top-level menus show in the empty root's Commands section; deeper items appear only while searching. Opening a submenu pushes it as a view, and Ctrl+↵ opens the item in the original Omarchy menu. + - **Omarchy menu.** Every item from `omarchy-menu.jsonc` (default plus the user's `~/.config/omarchy/extensions/` file) is a root-search command (`core/OmarchyMenu.js`). Its subtitle is the breadcrumb ("Trigger › Toggle"). `when` guards hide rows, `checked` adds ✓, and `disabled` dims a row. The `fonts` and `power-profiles` provider rows are searchable too. Top-level menus show in the empty root's Commands section; deeper items appear only while searching. Opening a submenu pushes it as a view, and Ctrl+↵ opens the item in the original Omarchy menu. - **Apps** come from the shell's `appLibrary`, which handles hidden entries, icons and `uwsm-app` launching. - **Themes.** The 8 Raycast themes and the CSS variables are replaced by the live Omarchy theme tokens (§2.0). - **HUD.** The HUD is Omarchy's OSD (`omarchy-shell osd show`). @@ -798,7 +799,7 @@ Repo layout [D]: - **Ctrl+T** opens the toast's actions. Failure toasts offer "Copy Error Message"; "Disable Command" offers "Undo". - **Ctrl+Shift+K** About panel: `rootSearch.getItemInfo` → `RootSearch.aboutFor`. - **Argument pills.** Builtins may declare `arguments` (`{name, placeholder, required}`). "Search Google" and "Search DuckDuckGo" are root commands with a `query` argument. Tab enters the pills, Shift+Tab / Backspace on an empty pill goes back, ↵ with an empty required argument focuses it. - - **Alerts.** `rootSearch.performAction` returns `{confirm}` for Shut Down / Restart / Log Out and Delete All Entries until called with `confirmed: true`. `oma-cast run` is explicit and skips it. + - **Alerts.** `rootSearch.performAction` returns `{confirm}` for Shut Down / Restart / Log Out and Delete All Entries until called with `confirmed: true`. `omacast run` is explicit and skips it. - **Keystroke buffering: won't do.** A parked surface conflicts with Omarchy's remap-on-open design (note above). - **"fir" → Firefox:** on a list where another title starts with "Fire", the literal port ties at score 8 and the collator decides until Firefox has been launched once (`tests/ranker.test.js`). @@ -857,4 +858,4 @@ Repo layout [D]: - **Snippet expansion needs input-device access.** This is a security trade-off; keep it opt-in, and do the IBus/Fcitx5 engine as the cleaner long-term path. - **Unrecovered numbers** [I]: backend timeouts and restart limits, HUD duration, the Suggestions section size, and window y-offset. Pick sensible values (given above) and tune them. - **Frecency epoch:** Raycast's anchor epoch wasn't confirmed. It doesn't matter for a clone that uses Unix seconds consistently, but convert carefully when importing `frecency` from `.rayconfig` (check whether values look like 2001-epoch seconds, i.e. ≈ 8×10⁸ vs Unix ≈ 1.7×10⁹). -- **Calculator parity.** Soulver-style natural language ("$30 + 20%", "3 days from now", time zones) exceeds `fend`. Consider libqalculate as an alternate engine behind the same interface. **[D] 2026-10-19:** the Quickshell build uses neither. `plugin/core/Calculator.js` is a self-contained JS evaluator that covers these cases (see CLAUDE.md Status). libqalculate isn't an Omarchy dependency, and `qalc` reads "15% of 80" and "3 days from now" differently from Soulver. +- **Calculator parity.** Soulver-style natural language ("$30 + 20%", "3 days from now", time zones) exceeds `fend`. Consider libqalculate as an alternate engine behind the same interface. **[D] 2026-10-19:** the Quickshell build uses neither. `core/Calculator.js` is a self-contained JS evaluator that covers these cases (see CLAUDE.md Status). libqalculate isn't an Omarchy dependency, and `qalc` reads "15% of 80" and "3 days from now" differently from Soulver. diff --git a/addons/camera-preview/CameraPreview.qml b/addons/camera-preview/CameraPreview.qml new file mode 100644 index 0000000..a98bb54 --- /dev/null +++ b/addons/camera-preview/CameraPreview.qml @@ -0,0 +1,111 @@ +import QtQuick +import QtQuick.Controls +import Quickshell +import QtMultimedia +import qs.Commons as Commons + +Item { + id: root + property var shell: null + property var manifest: null + property bool opened: false + + function open(payload) { + root.opened = true + preview.visible = true + } + + function close() { + root.opened = false + preview.visible = false + } + + function dismiss() { + root.close() + if (root.shell) root.shell.hide(root.manifest.id) + } + + Commons.Translations { id: strings; directory: Qt.resolvedUrl("i18n") } + MediaDevices { id: devices } + readonly property var camera: cameraLoader.item + Loader { + id: cameraLoader + active: root.opened && devices.videoInputs.length > 0 + sourceComponent: Component { + Camera { + cameraDevice: devices.defaultVideoInput + active: true + } + } + } + CaptureSession { + camera: root.camera + videoOutput: video + } + + FloatingWindow { + id: preview + property bool wasVisible: false + visible: false + implicitWidth: 960 + implicitHeight: 600 + title: strings.t("title", "Camera preview") + color: Commons.Color.background + onVisibleChanged: { + if (visible) wasVisible = true + else if (wasVisible && root.opened) root.dismiss() + } + + Item { + anchors.fill: parent + focus: true + Keys.onEscapePressed: root.dismiss() + + ComboBox { + id: selector + anchors.top: parent.top + anchors.horizontalCenter: parent.horizontalCenter + visible: devices.videoInputs.length > 1 + model: devices.videoInputs + textRole: "description" + contentItem: Text { + text: selector.displayText + textFormat: Text.PlainText + color: Commons.Color.foreground + verticalAlignment: Text.AlignVCenter + } + delegate: ItemDelegate { + required property var modelData + width: selector.width + contentItem: Text { + text: modelData.description + textFormat: Text.PlainText + color: Commons.Color.foreground + } + } + onActivated: { if (root.camera) root.camera.cameraDevice = devices.videoInputs[currentIndex] } + } + + VideoOutput { + id: video + anchors.top: selector.visible ? selector.bottom : parent.top + anchors.bottom: parent.bottom + anchors.left: parent.left + anchors.right: parent.right + fillMode: VideoOutput.PreserveAspectFit + mirrored: true + } + + Text { + anchors.centerIn: parent + width: parent.width - 48 + horizontalAlignment: Text.AlignHCenter + wrapMode: Text.Wrap + color: Commons.Color.foreground + textFormat: Text.PlainText + visible: devices.videoInputs.length === 0 || (root.camera && root.camera.error !== Camera.NoError) + text: devices.videoInputs.length === 0 ? strings.t("noCamera", "No camera found. Connect a camera and try again.") : strings.t("cameraError", "Could not open the camera. Close any meeting app using it and try again.\n%1").arg(root.camera ? root.camera.errorString : "") + } + } + } +} diff --git a/addons/camera-preview/README.md b/addons/camera-preview/README.md new file mode 100644 index 0000000..1375d5d --- /dev/null +++ b/addons/camera-preview/README.md @@ -0,0 +1,50 @@ +# Camera preview + +An optional Omarchy shell plugin with an **Open camera** command in omacast. +It opens a mirrored live preview of the default camera. If multiple cameras +are connected, a selector lets you choose another one. Escape or closing the +window stops capture and releases the camera. There is no audio or recording. +Close the preview before joining a meeting. + +This uses Omarchy's existing plugin and menu extension APIs. It does not +require omacast's planned Raycast extension runtime, a desktop application +entry, mpv, or a background camera process. + +## Install + +Requires Omarchy 4 and Qt Multimedia, including a working camera backend +and the QtMultimedia QML module. On Arch Linux, install `qt6-multimedia` +and a backend such as `qt6-multimedia-ffmpeg`. +Run these commands from the omacast checkout, which must remain in place: + +```sh +mkdir -p ~/.config/omarchy/plugins +ln -s "$PWD/addons/camera-preview" ~/.config/omarchy/plugins/omacast.camera-preview +omarchy-shell shell rescanPlugins +omarchy plugin enable omacast.camera-preview +``` + +Merge the object in `menu-entry.json` into +`~/.config/omarchy/extensions/omarchy-menu.jsonc`, preserving your existing +entries. Create the file and its parent directory if needed. omacast picks +up menu extensions automatically. Search for **Open camera** and press Enter. + +To test the plugin directly: + +```sh +omarchy-shell shell summon omacast.camera-preview '{}' +``` + +## Remove + +Remove the `camera-preview` entry from your menu extension file, then run: + +```sh +omarchy plugin disable omacast.camera-preview +rm ~/.config/omarchy/plugins/omacast.camera-preview +omarchy-shell shell rescanPlugins +``` + +No connected cameras or a capture failure displays an error in the preview. +Camera permissions and whether another application can share the camera are +managed by the system's Qt Multimedia backend. diff --git a/addons/camera-preview/i18n/en.json b/addons/camera-preview/i18n/en.json new file mode 100644 index 0000000..59b8487 --- /dev/null +++ b/addons/camera-preview/i18n/en.json @@ -0,0 +1,5 @@ +{ + "title": "Camera preview", + "noCamera": "No camera found. Connect a camera and try again.", + "cameraError": "Could not open the camera. Close any meeting app using it and try again.\n%1" +} diff --git a/addons/camera-preview/manifest.json b/addons/camera-preview/manifest.json new file mode 100644 index 0000000..33d4b9d --- /dev/null +++ b/addons/camera-preview/manifest.json @@ -0,0 +1,14 @@ +{ + "schemaVersion": 1, + "id": "omacast.camera-preview", + "name": "Camera preview", + "version": "0.1.0", + "description": "Check your camera before a meeting, without recording or audio", + "kinds": [ + "panel" + ], + "entryPoints": { + "panel": "CameraPreview.qml" + }, + "keepLoaded": true +} diff --git a/addons/camera-preview/menu-entry.json b/addons/camera-preview/menu-entry.json new file mode 100644 index 0000000..e9f06a2 --- /dev/null +++ b/addons/camera-preview/menu-entry.json @@ -0,0 +1,9 @@ +{ + "camera-preview": { + "icon": "󰄀", + "label": "Open camera", + "description": "Check your camera before a meeting", + "aliases": ["camera", "webcam", "preview", "meeting"], + "action": "omarchy-shell shell summon omacast.camera-preview '{}'" + } +} diff --git a/bin/build-search b/bin/build-search new file mode 100755 index 0000000..1993f25 --- /dev/null +++ b/bin/build-search @@ -0,0 +1,16 @@ +#!/bin/bash +set -euo pipefail + +repo=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd -P) +command -v cargo >/dev/null || { echo "omacast: building search needs Rust (cargo)" >&2; exit 1; } +# The build goes outside the plugin folder: omarchy-shell watches that folder +# and would reload the plugin for every file cargo writes. +target_dir=${CARGO_TARGET_DIR:-${XDG_CACHE_HOME:-$HOME/.cache}/omacast/search-target} +[[ $target_dir == /* ]] || target_dir="$PWD/$target_dir" +cargo build --locked --release --manifest-path "$repo/search/Cargo.toml" --target-dir "$target_dir" +temporary=$(mktemp "$repo/bin/.search.XXXXXXXX") +trap 'rm -f -- "$temporary"' EXIT +cp "$target_dir/release/omacast-search" "$temporary" +chmod 755 "$temporary" +mv -f -- "$temporary" "$repo/bin/omacast-search" +echo "built $repo/bin/omacast-search" diff --git a/bin/oma-cast b/bin/oma-cast deleted file mode 100755 index 474d5da..0000000 --- a/bin/oma-cast +++ /dev/null @@ -1,180 +0,0 @@ -#!/bin/bash - -set -uo pipefail - -PLUGIN_ID="typecraft.oma-cast" -IPC_TARGET="oma-cast" - -self=$(readlink -f "${BASH_SOURCE[0]}") -repo=$(cd "$(dirname "$self")/.." && pwd) -plugin_src="$repo/plugin" -plugins_dir="${XDG_CONFIG_HOME:-$HOME/.config}/omarchy/plugins" -plugin_link="$plugins_dir/$PLUGIN_ID" - -installed_links() { - local link - for link in "$plugin_link" "$plugin_link".dev-*; do - [[ -L $link && $(readlink -f "$link") == "$plugin_src" ]] && printf '%s\n' "$link" - done -} - -current_link() { installed_links | tail -n1; } -hypr_dir="${XDG_CONFIG_HOME:-$HOME/.config}/hypr" - -usage() { - cat < [args] - -Launcher - toggle Show or hide the launcher - show | hide Show or hide it - run Open a command directly - call [json] Call a backend method, e.g. oma-cast call system.info - -Setup - install Link the plugin into omarchy-shell, enable it, and link this CLI into ~/.local/bin - uninstall Disable and unlink the plugin - reload Make the shell reload the plugin after editing it - dev Reload the plugin on every save under plugin/ - setup hyprland Bind Alt+Space to the launcher in ~/.config/hypr/oma-cast.lua - doctor Check that the pieces are in place -USAGE -} - -die() { echo "oma-cast: $*" >&2; exit 1; } - -ipc() { omarchy-shell "$IPC_TARGET" "$@"; } - -need_shell() { - command -v omarchy-shell >/dev/null || die "omarchy-shell not found; oma-cast runs inside the Omarchy shell" -} - -cmd_install() { - need_shell - [[ -f $plugin_src/manifest.json ]] || die "plugin source missing at $plugin_src" - mkdir -p "$plugins_dir" - if [[ -e $plugin_link && ! -L $plugin_link ]]; then - die "$plugin_link exists and is not a symlink; move it away first" - fi - ln -sfn "$plugin_src" "$plugin_link" - echo "linked $plugin_link -> $plugin_src" - - mkdir -p "$HOME/.local/bin" - if [[ ! -e $HOME/.local/bin/oma-cast || -L $HOME/.local/bin/oma-cast ]]; then - ln -sfn "$self" "$HOME/.local/bin/oma-cast" - echo "linked ~/.local/bin/oma-cast" - fi - - omarchy-shell -q shell rescanPlugins - local out i - for i in $(seq 1 50); do - out=$(omarchy plugin enable "$PLUGIN_ID" 2>&1) && break - [[ $out == *"not ready"* || $out == *"could not find"* || $out == *"not known"* ]] || break - sleep 0.1 - done - [[ -n $out ]] && echo "$out" - - for i in $(seq 1 100); do - [[ $(omarchy-shell "$IPC_TARGET" ping 2>/dev/null) == "ok" ]] && { echo "oma-cast is running"; return 0; } - sleep 0.1 - done - die "plugin did not answer; check the shell log with: qs log -n -p \"\$OMARCHY_PATH/shell\"" -} - -cmd_uninstall() { - need_shell - omarchy plugin disable "$PLUGIN_ID" 2>/dev/null || true - local link - while IFS= read -r link; do - [[ -n $link ]] && rm "$link" && echo "removed $link" - done < <(installed_links) - omarchy-shell -q shell rescanPlugins - if [[ -L $HOME/.local/bin/oma-cast && $(readlink -f "$HOME/.local/bin/oma-cast") == "$self" ]]; then - rm "$HOME/.local/bin/oma-cast" && echo "removed ~/.local/bin/oma-cast" - fi -} - -cmd_reload() { - [[ -n $(current_link) ]] || die "plugin is not installed; run: oma-cast install" - local old - while IFS= read -r old; do rm "$old"; done < <(installed_links) - ln -s "$plugin_src" "$plugin_link.dev-$(date +%s%N)" -} - -cmd_dev() { - command -v inotifywait >/dev/null || die "inotifywait not found (inotify-tools)" - cmd_reload - echo "watching $plugin_src; Ctrl+C to stop" - inotifywait -m -r -q -e close_write,create,delete,move --format '%w%f' "$plugin_src" | - while read -r path; do - echo "changed ${path#"$plugin_src"/}" - cmd_reload - done -} - -cmd_setup_hyprland() { - [[ -f $hypr_dir/hyprland.lua ]] || die "no $hypr_dir/hyprland.lua; 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 -v "$hypr_dir/oma-cast.lua" | grep -vE '^\S+:[0-9]+:\s*(--|#)') - if [[ -n $conflicts ]]; then - echo "Alt+Space is already bound:" >&2 - echo "$conflicts" >&2 - die "unbind it first, or edit $hypr_dir/oma-cast.lua to use another key" - fi - - cat >"$hypr_dir/oma-cast.lua" <<'LUA' -o.bind("ALT + SPACE", "oma-cast launcher", hl.dsp.global("oma-cast:toggle")) - -LUA - echo "wrote $hypr_dir/oma-cast.lua" - - if ! grep -qE '^\s*require\("hypr\.oma-cast"\)' "$hypr_dir/hyprland.lua"; then - printf '\nrequire("hypr.oma-cast")\n' >>"$hypr_dir/hyprland.lua" - echo "added require(\"hypr.oma-cast\") to $hypr_dir/hyprland.lua" - fi - hyprctl reload >/dev/null && echo "reloaded Hyprland; Alt+Space toggles oma-cast" -} - -cmd_doctor() { - local bad=0 - check() { - local label=$1; shift - if "$@" >/dev/null 2>&1; then printf ' ok %s\n' "$label"; else printf ' FAIL %s\n' "$label"; bad=1; fi - } - echo "oma-cast doctor" - check "omarchy-shell is installed" command -v omarchy-shell - check "omarchy-shell is running" omarchy-shell shell ping - check "socat is installed (fast shell IPC)" command -v socat - check "Hyprland socket is reachable" hyprctl version - check "plugin is linked into $plugins_dir" test -n "$(current_link)" - check "plugin is enabled" bash -c "omarchy plugin list --json 2>/dev/null | grep -q '\"id\":\"$PLUGIN_ID\"[^}]*\"enabled\":true'" - check "plugin answers IPC" bash -c "[[ \$(omarchy-shell $IPC_TARGET ping) == ok ]]" - check "backend answers system.healthPing" bash -c "omarchy-shell $IPC_TARGET call system.healthPing '{}' | grep -q '\"ok\":true'" - if [[ -f $hypr_dir/oma-cast.lua ]]; then - printf ' ok hotkey file %s\n' "$hypr_dir/oma-cast.lua" - else - printf ' info no hotkey yet; run: oma-cast setup hyprland\n' - fi - return $bad -} - -case "${1:-}" in - toggle|show|hide) need_shell; ipc "$1" >/dev/null ;; - run) need_shell; [[ -n ${2:-} ]] || die "usage: oma-cast run "; ipc run "$2" ;; - call) need_shell; [[ -n ${2:-} ]] || die "usage: oma-cast call [json]"; ipc call "$2" "${3:-}" ;; - ping) need_shell; ipc ping ;; - install) cmd_install ;; - uninstall) cmd_uninstall ;; - reload) cmd_reload ;; - dev) cmd_dev ;; - setup) - case "${2:-}" in - hyprland) cmd_setup_hyprland ;; - *) die "usage: oma-cast setup hyprland" ;; - esac - ;; - doctor) cmd_doctor ;; - -h|--help|help|"") usage ;; - *) usage >&2; exit 1 ;; -esac diff --git a/bin/omacast b/bin/omacast new file mode 100755 index 0000000..f6ab97e --- /dev/null +++ b/bin/omacast @@ -0,0 +1,336 @@ +#!/bin/bash + +set -uo pipefail + +PLUGIN_ID="typecraft.omacast" +IPC_TARGET="omacast" +# The name before 2026-11-01; install, setup and doctor clean up after it. +LEGACY_ID="typecraft.oma-cast" +LEGACY_NAME="oma-cast" + +self=$(readlink -f "${BASH_SOURCE[0]}") +repo=$(cd "$(dirname "$self")/.." && pwd -P) +plugins_dir="${XDG_CONFIG_HOME:-$HOME/.config}/omarchy/plugins" +plugin_link="$plugins_dir/$PLUGIN_ID" +hypr_dir="${XDG_CONFIG_HOME:-$HOME/.config}/hypr" +cli_link="$HOME/.local/bin/omacast" + +# A marketplace install (`omarchy plugin add`) is this repo cloned straight into +# the plugins dir. A development checkout lives elsewhere and is symlinked in. +managed() { [[ $repo == "$(cd "$plugins_dir" 2>/dev/null && pwd -P)/$PLUGIN_ID" ]]; } + +installed_links() { + local link + for link in "$plugin_link" "$plugin_link".dev-*; do + [[ -L $link && $(readlink -f "$link") == "$repo" ]] && printf '%s\n' "$link" + done +} + +current_link() { installed_links | tail -n1; } + +usage() { + cat < [args] + +Launcher + toggle Show or hide the launcher + show | hide Show or hide it + run Open a command directly + call [json] Call a backend method, e.g. omacast call system.info + +Setup + install Enable the plugin and link this CLI into ~/.local/bin + (a development checkout is also linked into omarchy-shell) + uninstall Disable and remove the plugin, offering to remove the hotkey + setup hyprland [--yes] Bind Alt+Space to the launcher in ~/.config/hypr/omacast.lua + build-search Build the optional flea-search helper (needs Rust/cargo) + doctor Check that the pieces are in place + +Development checkout + reload Make the shell reload the plugin after editing it + dev Reload the plugin on every save +USAGE +} + +die() { echo "omacast: $*" >&2; exit 1; } + +ipc() { omarchy-shell "$IPC_TARGET" "$@"; } + +need_shell() { + command -v omarchy-shell >/dev/null || die "omarchy-shell not found; omacast runs inside the Omarchy shell" +} + +interactive() { [[ -t 0 && -t 1 ]]; } + +# confirm +confirm() { + (( $2 )) && return 0 + interactive || die "refusing to change your config without confirmation; pass --yes" + if command -v gum >/dev/null; then + gum confirm "$1" + else + local answer + read -r -p "$1 [y/N] " answer + [[ $answer == [yY]* ]] + fi +} + +link_cli() { + mkdir -p "$HOME/.local/bin" + if [[ ! -e $cli_link || -L $cli_link ]]; then + ln -sfn "$self" "$cli_link" + echo "linked $cli_link" + else + echo "left $cli_link alone; it is not a symlink" + fi +} + +# Undo what the oma-cast install left behind: its plugin links, its enable +# entry and its CLI link. Hyprland files are handled by setup hyprland. +remove_legacy_install() { + local link removed=0 + for link in "$plugins_dir/$LEGACY_ID" "$plugins_dir/$LEGACY_ID".dev-*; do + [[ -L $link ]] || continue + rm "$link" && echo "removed old link $link" && removed=1 + done + if omarchy plugin list --json 2>/dev/null | jq -e --arg id "$LEGACY_ID" 'any(.[]; .id == $id and .enabled)' >/dev/null 2>&1 || + jq -e --arg id "$LEGACY_ID" '.plugins // [] | any(.id == $id)' "${XDG_CONFIG_HOME:-$HOME/.config}/omarchy/shell.json" >/dev/null 2>&1; then + omarchy plugin disable "$LEGACY_ID" >/dev/null 2>&1 && echo "disabled $LEGACY_ID" + fi + local old_cli="$HOME/.local/bin/$LEGACY_NAME" + if [[ -L $old_cli && ( ! -e $old_cli || $(readlink "$old_cli") == "$repo"/* ) ]]; then + rm "$old_cli" && echo "removed old $old_cli" + fi + (( removed )) && omarchy-shell -q shell rescanPlugins + return 0 +} + +# Same rule as core/Paths.js legacyMoves, run before build-search creates the +# new cache dir and so stops the plugin from moving the old one. +move_legacy_dirs() { + local base + for base in "${XDG_CONFIG_HOME:-$HOME/.config}" "${XDG_DATA_HOME:-$HOME/.local/share}" \ + "${XDG_CACHE_HOME:-$HOME/.cache}" "${XDG_STATE_HOME:-$HOME/.local/state}"; do + [[ -d $base/$LEGACY_NAME && ! -e $base/omacast ]] || continue + mv -T -- "$base/$LEGACY_NAME" "$base/omacast" && echo "moved $base/$LEGACY_NAME to $base/omacast" + done +} + +cmd_install() { + need_shell + [[ -f $repo/manifest.json ]] || die "plugin manifest missing at $repo" + move_legacy_dirs + if command -v cargo >/dev/null; then + "$repo/bin/build-search" || die "search helper build failed; plugin was not installed" + else + echo "Rust not found; skipping the helper build (using an existing helper or the built-in matcher)" + fi + remove_legacy_install + + if ! managed; then + mkdir -p "$plugins_dir" + if [[ -e $plugin_link && ! -L $plugin_link ]]; then + die "$plugin_link exists and is not a symlink; remove it with: omarchy plugin remove $PLUGIN_ID" + fi + ln -sfn "$repo" "$plugin_link" + echo "linked $plugin_link -> $repo" + fi + link_cli + + omarchy-shell -q shell rescanPlugins + local out i + for i in $(seq 1 50); do + out=$(omarchy plugin enable "$PLUGIN_ID" 2>&1) && break + [[ $out == *"not ready"* || $out == *"could not find"* || $out == *"not known"* ]] || break + sleep 0.1 + done + [[ -n $out ]] && echo "$out" + + for i in $(seq 1 100); do + if [[ $(omarchy-shell "$IPC_TARGET" ping 2>/dev/null) == "ok" ]]; then + echo "omacast is running" + [[ -f $hypr_dir/omacast.lua ]] || echo "bind a hotkey with: omacast setup hyprland" + return 0 + fi + sleep 0.1 + done + die "plugin did not answer; check the shell log with: qs log -n -p \"\$OMARCHY_PATH/shell\"" +} + +cmd_uninstall() { + need_shell + local assume_yes=0 + [[ ${1:-} == --yes || ${1:-} == -y ]] && assume_yes=1 + + if [[ -L $cli_link && $(readlink -f "$cli_link") == "$self" ]]; then + rm "$cli_link" && echo "removed $cli_link" + fi + if [[ -f $hypr_dir/omacast.lua ]]; then + if ! (( assume_yes )) && ! interactive; then + echo "kept the hotkey; remove $hypr_dir/omacast.lua and require(\"hypr.omacast\") yourself, or pass --yes" + elif confirm "Remove the hotkey ($hypr_dir/omacast.lua and its require in hyprland.lua)?" "$assume_yes"; then + sed -i -E '/^\s*require\("hypr\.omacast"\)\s*$/d; /^-- omacast launcher bindings$/d' "$hypr_dir/hyprland.lua" + rm "$hypr_dir/omacast.lua" && echo "removed $hypr_dir/omacast.lua" + hyprctl reload >/dev/null 2>&1 + fi + fi + + if managed; then + local args=() + (( assume_yes )) && args=(--yes) + exec omarchy plugin remove "$PLUGIN_ID" "${args[@]}" + fi + omarchy plugin disable "$PLUGIN_ID" 2>/dev/null || true + local link + while IFS= read -r link; do + [[ -n $link ]] && rm "$link" && echo "removed $link" + done < <(installed_links) + omarchy-shell -q shell rescanPlugins +} + +need_checkout() { + managed && die "$1 is for development checkouts; edits under $repo reload automatically" + [[ -n $(current_link) ]] || die "plugin is not installed; run: omacast install" +} + +cmd_reload() { + need_checkout reload + local old + while IFS= read -r old; do rm "$old"; done < <(installed_links) + ln -s "$repo" "$plugin_link.dev-$(date +%s%N)" +} + +cmd_dev() { + command -v inotifywait >/dev/null || die "inotifywait not found (inotify-tools)" + need_checkout dev + cmd_reload + echo "watching $repo; Ctrl+C to stop" + inotifywait -m -r -q -e close_write,create,delete,move --format '%w%f' \ + --exclude '/(\.git|search|tests|reference|docs)/' "$repo" | + while read -r path; do + echo "changed ${path#"$repo"/}" + cmd_reload + done +} + +cmd_setup_hyprland() { + local assume_yes=0 + [[ ${1:-} == --yes || ${1:-} == -y ]] && assume_yes=1 + 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 + echo "Alt+Space is already bound:" >&2 + echo "$conflicts" >&2 + die "unbind it first, or write $file yourself with another key" + fi + + local legacy_require=0 needs_require=1 plan=() + grep -qE '^\s*require\("hypr\.oma-cast"\)' "$main" && legacy_require=1 + grep -qE '^\s*require\("hypr\.omacast"\)' "$main" && needs_require=0 + if [[ -f $file ]]; then + plan+=("keep your existing $file") + elif [[ -f $legacy ]]; then + plan+=("move $legacy to $file, renaming oma-cast:toggle to omacast:toggle") + else + plan+=("write $file, binding Alt+Space to omacast:toggle") + fi + if (( legacy_require )); then + plan+=("replace require(\"hypr.oma-cast\") with require(\"hypr.omacast\") in $main") + elif (( needs_require )); then + plan+=("append require(\"hypr.omacast\") to $main") + fi + plan+=("reload Hyprland") + + echo "omacast setup hyprland will:" + printf ' - %s\n' "${plan[@]}" + confirm "Make these changes?" "$assume_yes" || die "nothing changed" + + if [[ ! -f $file && -f $legacy ]]; then + # cp -p keeps the old file's mode (it may be private); sed -i keeps it too. + cp -p -- "$legacy" "$file" && + sed -i 's/oma-cast/omacast/g; s/rerunning setup overwrites it/rerunning setup keeps it/' "$file" && + rm -- "$legacy" && echo "moved $legacy to $file" + elif [[ ! -f $file ]]; then + cat >"$file" <<'LUA' +-- Written by `omacast setup hyprland`. Safe to edit; rerunning setup keeps it. +-- Alt+Space reaches the plugin's global shortcut directly, so no process +-- starts per keypress. Omarchy's own menu stays on Super+Space. +o.bind("ALT + SPACE", "omacast launcher", hl.dsp.global("omacast:toggle")) +LUA + echo "wrote $file" + fi + + if (( legacy_require )); then + sed -i -E 's/^(\s*)require\("hypr\.oma-cast"\)/\1require("hypr.omacast")/; s/^-- oma-cast launcher bindings$/-- omacast launcher bindings/' "$main" + echo "updated the require in $main" + elif (( needs_require )); then + printf '\nrequire("hypr.omacast")\n' >>"$main" + echo "added require(\"hypr.omacast\") to $main" + fi + hyprctl reload >/dev/null && echo "reloaded Hyprland; Alt+Space toggles omacast" +} + +cmd_doctor() { + local bad=0 + check() { + local label=$1; shift + if "$@" >/dev/null 2>&1; then printf ' ok %s\n' "$label"; else printf ' FAIL %s\n' "$label"; bad=1; fi + } + echo "omacast doctor" + check "omarchy-shell is installed" command -v omarchy-shell + check "omarchy-shell is running" omarchy-shell shell ping + check "socat is installed (fast shell IPC)" command -v socat + check "Hyprland socket is reachable" hyprctl version + if managed; then + printf ' ok plugin is installed in %s\n' "$repo" + else + check "plugin is linked into $plugins_dir" test -n "$(current_link)" + fi + check "plugin is enabled" bash -c "omarchy plugin list --json 2>/dev/null | jq -e --arg id '$PLUGIN_ID' 'any(.[]; .id == \$id and .enabled)'" + check "plugin answers IPC" bash -c "[[ \$(omarchy-shell $IPC_TARGET ping) == ok ]]" + check "backend answers system.healthPing" bash -c "omarchy-shell $IPC_TARGET call system.healthPing '{}' | grep -q '\"ok\":true'" + if [[ -x $repo/bin/omacast-search ]]; then + printf ' ok flea-search helper is built\n' + else + printf ' info no flea-search helper; using the built-in matcher (build it with: omacast build-search)\n' + fi + local base + for base in "${XDG_CONFIG_HOME:-$HOME/.config}" "${XDG_DATA_HOME:-$HOME/.local/share}" \ + "${XDG_CACHE_HOME:-$HOME/.cache}" "${XDG_STATE_HOME:-$HOME/.local/state}"; do + [[ -d $base/$LEGACY_NAME ]] && printf ' info %s is left from oma-cast; omacast uses %s\n' "$base/$LEGACY_NAME" "$base/omacast" + done + if [[ -f $hypr_dir/$LEGACY_NAME.lua ]] || grep -qE '^\s*require\("hypr\.oma-cast"\)' "$hypr_dir/hyprland.lua" 2>/dev/null; then + printf ' FAIL the hotkey still uses the old oma-cast name; run: omacast setup hyprland\n' + bad=1 + elif [[ -f $hypr_dir/omacast.lua ]]; then + printf ' ok hotkey file %s\n' "$hypr_dir/omacast.lua" + else + printf ' info no hotkey yet; run: omacast setup hyprland\n' + fi + return $bad +} + +case "${1:-}" in + toggle|show|hide) need_shell; ipc "$1" >/dev/null ;; + run) need_shell; [[ -n ${2:-} ]] || die "usage: omacast run "; ipc run "$2" ;; + call) need_shell; [[ -n ${2:-} ]] || die "usage: omacast call [json]"; ipc call "$2" "${3:-}" ;; + ping) need_shell; ipc ping ;; + build-search) move_legacy_dirs; "$repo/bin/build-search" ;; + install) cmd_install ;; + uninstall) cmd_uninstall "${2:-}" ;; + reload) cmd_reload ;; + dev) cmd_dev ;; + setup) + case "${2:-}" in + hyprland) cmd_setup_hyprland "${3:-}" ;; + *) die "usage: omacast setup hyprland [--yes]" ;; + esac + ;; + doctor) cmd_doctor ;; + -h|--help|help|"") usage ;; + *) usage >&2; exit 1 ;; +esac diff --git a/plugin/core/Calculator.js b/core/Calculator.js similarity index 100% rename from plugin/core/Calculator.js rename to core/Calculator.js diff --git a/core/FleaSearch.js b/core/FleaSearch.js new file mode 100644 index 0000000..c5544e5 --- /dev/null +++ b/core/FleaSearch.js @@ -0,0 +1,45 @@ +var MAX_QUERY_LENGTH = 256 +var MAX_REPLY_LENGTH = 8 * 1024 * 1024 + +function entries(candidates, fold) { + function strings(values) { + return Array.isArray(values) ? values.map(function(value) { return fold(String(value || "")) }) : [] + } + return candidates.map(function(c) { + return { + title: fold(String(c.title || "")), subtitle: fold(String(c.subtitle || "")), + alternateTitles: strings(c.alternateTitles), keywords: strings(c.keywords), bundleId: fold(String(c.bundleId || "")) + } + }) +} + +// Replies look like {id: 7, hits: [{index: 0, title: 4000000, alternate: null, subtitle: null, keyword: null}]}. +function reply(line) { + if (line.length > MAX_REPLY_LENGTH) throw new Error("Search reply exceeds the size limit") + var data = JSON.parse(line) + if (!data || !Number.isSafeInteger(data.id)) throw new Error("Search reply has no request id") + return data +} + +function hits(data, ids) { + if (!Array.isArray(data.hits)) throw new Error("Search reply has no hits") + var out = Object.create(null) + for (var i = 0; i < data.hits.length; i++) { + var hit = data.hits[i] + if (!hit || !Number.isInteger(hit.index) || hit.index < 0 || hit.index >= ids.length) + throw new Error("Search reply has an invalid entry index") + var id = ids[hit.index] + if (Object.prototype.hasOwnProperty.call(out, id)) throw new Error("Search reply repeats an entry") + var scores = [hit.title, hit.alternate, hit.subtitle, hit.keyword] + for (var j = 0; j < scores.length; j++) { + var score = scores[j] + if (score !== null && (!Number.isInteger(score) || score <= 0 || score > 5000000)) + throw new Error("Search reply has an invalid score") + } + if (scores.every(function(score) { return score === null })) throw new Error("Search reply contains an empty hit") + out[id] = hit + } + return out +} + +if (typeof module !== "undefined") module.exports = { MAX_QUERY_LENGTH, entries, reply, hits } diff --git a/plugin/core/Frecency.js b/core/Frecency.js similarity index 100% rename from plugin/core/Frecency.js rename to core/Frecency.js diff --git a/plugin/core/Log.js b/core/Log.js similarity index 92% rename from plugin/core/Log.js rename to core/Log.js index 257fd19..8e54bfc 100644 --- a/plugin/core/Log.js +++ b/core/Log.js @@ -11,7 +11,7 @@ function shouldLog(level, threshold) { } function format(level, category, message, metadata) { - var line = "[oma-cast] [" + LEVELS[levelIndex(level)] + "]" + var line = "[omacast] [" + LEVELS[levelIndex(level)] + "]" if (category) line += " [" + category + "]" line += " " + String(message) if (metadata !== undefined && metadata !== null) { diff --git a/plugin/core/Migrate.js b/core/Migrate.js similarity index 100% rename from plugin/core/Migrate.js rename to core/Migrate.js diff --git a/plugin/core/OmarchyMenu.js b/core/OmarchyMenu.js similarity index 95% rename from plugin/core/OmarchyMenu.js rename to core/OmarchyMenu.js index 61645c9..38d394e 100644 --- a/plugin/core/OmarchyMenu.js +++ b/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/core/Paths.js b/core/Paths.js new file mode 100644 index 0000000..75bcdcc --- /dev/null +++ b/core/Paths.js @@ -0,0 +1,95 @@ + +var APP = "omacast" +// The name before 2026-11-01. Its directories move to APP on first start. [D] +var LEGACY_APP = "oma-cast" + +function xdg(env, name, homeFallback) { + var value = String(env(name) || "") + if (value.charAt(0) === "/") return value.replace(/\/+$/, "") + return homeFallback +} + +function resolve(env) { + var home = String(env("HOME") || "").replace(/\/+$/, "") + if (!home) throw new Error("HOME is not set") + var uid = String(env("UID") || "") + var runtimeBase = xdg(env, "XDG_RUNTIME_DIR", uid ? "/run/user/" + uid : "/tmp") + + var config = xdg(env, "XDG_CONFIG_HOME", home + "/.config") + "/" + APP + var data = xdg(env, "XDG_DATA_HOME", home + "/.local/share") + "/" + APP + var cache = xdg(env, "XDG_CACHE_HOME", home + "/.cache") + "/" + APP + var state = xdg(env, "XDG_STATE_HOME", home + "/.local/state") + "/" + APP + + return { + home: home, + config: config, + configFile: config + "/config.json", + scriptCommands: config + "/script-commands", + data: data, + db: data + "/db", + extensions: data + "/extensions", + extensionSupport: data + "/extension-support", + clipboard: data + "/clipboard", + notesAttachments: data + "/notes/attachments", + cache: cache, + state: state, + logs: state + "/logs", + backups: state + "/backups", + runtime: runtimeBase + "/" + APP + } +} + +// [from, to] pairs for the base directories still under LEGACY_APP. +function legacyMoves(paths) { + var suffix = "/" + APP + return [paths.config, paths.data, paths.cache, paths.state].map(function(dir) { + return [dir.slice(0, -suffix.length) + "/" + LEGACY_APP, dir] + }) +} + +function directoriesToCreate(paths) { + return [paths.config, paths.db, paths.cache, paths.logs, paths.backups, paths.runtime] +} + +// Histories and settings are private: other local users must not read them. +// Every base dir is made owner-only; config, data and state are repaired +// recursively (the cache holds only rates and a cargo build, so its 0700 root +// is enough). Store files are created 0600 before the first write, because +// atomic saves keep an existing file's mode but give a new one 0666 & umask. +function privateRoots(paths) { + return [paths.config, paths.data, paths.cache, paths.state, paths.runtime] +} + +function privateTrees(paths) { + return [paths.config, paths.data, paths.state] +} + +// argv after the script name: legacy [from, to] pairs, --, dirs to create, --, +// private roots, --, private trees, --, files to create. chmod go-rwx only +// ever removes access, so a stricter existing mode stays as it is. +var SETUP_SCRIPT = [ + 'umask 077', + 'while [[ $1 != -- ]]; do [[ -d $1 && ! -e $2 ]] && mv -T -- "$1" "$2"; shift 2; done; shift', + 'dirs=(); while [[ $1 != -- ]]; do dirs+=("$1"); shift; done; shift', + 'roots=(); while [[ $1 != -- ]]; do roots+=("$1"); shift; done; shift', + 'trees=(); while [[ $1 != -- ]]; do trees+=("$1"); shift; done; shift', + 'mkdir -p -- "${dirs[@]}" || exit', + 'status=0', + 'chmod go-rwx -- "${roots[@]}" || status=1', + 'find "${trees[@]}" -xdev ! -type l -perm /077 -exec chmod go-rwx -- {} + || status=1', + 'for file; do [[ -e $file || -L $file ]] || : >"$file" || status=1; done', + 'exit $status' +].join("\n") + +function setupArgv(paths, files) { + return ["bash", "-c", SETUP_SCRIPT, "omacast-setup"] + .concat([].concat.apply([], legacyMoves(paths)), ["--"], directoriesToCreate(paths), ["--"], + privateRoots(paths), ["--"], privateTrees(paths), ["--"], files) +} + +if (typeof module !== "undefined") { + module.exports = { + APP: APP, LEGACY_APP: LEGACY_APP, resolve: resolve, legacyMoves: legacyMoves, directoriesToCreate: directoriesToCreate, + privateRoots: privateRoots, privateTrees: privateTrees, SETUP_SCRIPT: SETUP_SCRIPT, setupArgv: setupArgv + } +} diff --git a/plugin/core/Ranker.js b/core/Ranker.js similarity index 91% rename from plugin/core/Ranker.js rename to core/Ranker.js index 92c0731..b7c8fd3 100644 --- a/plugin/core/Ranker.js +++ b/core/Ranker.js @@ -233,11 +233,6 @@ function buildRecord(candidate, query, options) { var tq = transliterate(q) var frecency = typeof c.frecency === "number" && isFinite(c.frecency) ? c.frecency : 1 - var title = transliterate(String(c.title || "")) - if (tq.charAt(0) === "/") title = title.replace("/", " ") - var titleLower = title.toLowerCase() - var alternates = Array.isArray(c.alternateTitles) ? c.alternateTitles.map(function(a) { return String(a || "") }) : [] - var record = { candidate: c, query: q, @@ -265,6 +260,12 @@ function buildRecord(candidate, query, options) { return String(term || "").toLowerCase() === q }) + if (opts.fleaHits) return fleaRecord(record, opts.fleaHits[c.id], frecency, nowMs) + + var title = transliterate(String(c.title || "")) + if (tq.charAt(0) === "/") title = title.replace("/", " ") + var titleLower = title.toLowerCase() + var alternates = Array.isArray(c.alternateTitles) ? c.alternateTitles.map(function(a) { return String(a || "") }) : [] record.title = fuzzyMatch(title, tq, { literalQueryDots: literalDots }) if (record.title === "exact") record.titleMatch = "exact" else if (titleLower.indexOf(q) === 0 || alternates.some(function(a) { return a.toLowerCase().indexOf(q) === 0 })) @@ -317,6 +318,35 @@ function buildRecord(candidate, query, options) { } +var FLEA_EXACT = 5000000 +var FLEA_PREFIX = 4000000 +var FLEA_WORD = 3500000 + +function fleaMatch(score) { + return score === null ? "unmatched" : score === FLEA_EXACT ? "exact" : { score: score, skipped: 0 } +} + +function fleaRecord(record, hit, frecency, nowMs) { + var c = record.candidate, q = record.query + record.flea = true + record.keywordScore = -MAX + record.searchTerm = searchTermMatch(c.searchTerms, q, frecency, c.openedAt, nowMs) + if (hit) { + record.keywordScore = matchScore(fleaMatch(hit.keyword)) + record.title = fleaMatch(hit.title) + record.titleScore = matchScore(record.title) + record.subtitle = fleaMatch(hit.subtitle) + record.subtitleExact = record.subtitle === "exact" + record.subtitleScore = matchScore(record.subtitle) + if (hit.title === FLEA_EXACT || hit.alternate === FLEA_EXACT) record.titleMatch = "exact" + else if (hit.title === FLEA_PREFIX || hit.alternate === FLEA_PREFIX) record.titleMatch = "prefix" + if (hit.alternate !== null) record.titleScore = Math.max(record.titleScore, matchScore(fleaMatch(hit.alternate))) + } + record.textMatched = record.alias !== "unmatched" || !!hit + record.isMatching = !!c.alwaysMatching || record.textMatched + return record +} + function collate(a, b) { return String(a || "").localeCompare(String(b || ""), undefined, { numeric: true, sensitivity: "base" }) } @@ -413,8 +443,14 @@ function compareMatched(a, b, ctx) { var aBest = Math.max(a.titleScore, a.subtitleScore) var bBest = Math.max(b.titleScore, b.subtitleScore) if (aBest !== bBest) return bBest - aBest + if (a.flea && b.flea && aBest === -MAX && a.keywordScore !== b.keywordScore) + return b.keywordScore - a.keywordScore if (a.frecency !== b.frecency) return b.frecency - a.frecency + if (a.flea && (aBest === FLEA_PREFIX || aBest === FLEA_WORD)) { + var shorter = String(a.candidate.title || "").length - String(b.candidate.title || "").length + if (shorter !== 0) return shorter + } if (a.titleScore !== b.titleScore) return b.titleScore - a.titleScore if (a.typePriority !== b.typePriority) return b.typePriority - a.typePriority var h = heuristic(a) - heuristic(b) diff --git a/plugin/core/RootSearch.js b/core/RootSearch.js similarity index 87% rename from plugin/core/RootSearch.js rename to core/RootSearch.js index ad23421..b424a03 100644 --- a/plugin/core/RootSearch.js +++ b/core/RootSearch.js @@ -3,8 +3,7 @@ var SUGGESTIONS_LIMIT = 6 var FALLBACKS = [ { id: "fallback:google", title: "Search Google", icon: "", url: "https://www.google.com/search?q={query}" }, - { id: "fallback:duckduckgo", title: "Search DuckDuckGo", icon: "", url: "https://duckduckgo.com/?q={query}" }, - { id: "fallback:agent", title: "Ask default agent", icon: "󰚩", subtitle: "New terminal session · Ctrl+Alt+Enter" } + { id: "fallback:duckduckgo", title: "Search DuckDuckGo", icon: "", url: "https://duckduckgo.com/?q={query}" } ] // Web searches as root commands with an inline argument pill (ref 04 §2.2 @@ -64,11 +63,62 @@ function appCandidate(entry) { bundleId: desktopId, typeLabel: "Application", appId: desktopId, + windowKeys: appWindowKeys(entry), iconName: String(entry.icon || ""), rootSearch: { when: "always" } } } +// host+path of a web app URL, the shape both sides of the match reduce to. +// The trailing slash is dropped rather than forced: web apps disagree on it. +function webappKey(url) { + return "webapp:" + String(url).replace(/^[a-z]+:\/\//i, "").replace(/\/+$/, "").toLowerCase() +} + +// Keys a Hyprland window class can match for one desktop entry. No single +// one covers everything: StartupWMClass is the declared answer but often +// wrong or absent, the desktop id covers files named after their class, and +// web apps declare no class at all, so their URL is rebuilt from Exec. +function appWindowKeys(entry) { + var keys = [] + function add(key) { if (key && keys.indexOf(key) < 0) keys.push(key) } + add(String(entry.id || "").toLowerCase()) + add(String(entry.startupClass || "").toLowerCase()) + var exec = String(entry.execString || "") + if (!exec && entry.command && entry.command.length) exec = Array.prototype.join.call(entry.command, " ") + if (/--app=|webapp/.test(exec)) { + var url = exec.match(/https?:\/\/[^\s"']+/) + if (url) add(webappKey(url[0])) + } + return keys +} + +// Keys for one window class. Chromium-based browsers build a web app's class +// off its URL and the profile directory: chrome-web.whatsapp.com__-Default -> +// web.whatsapp.com. The profile is whatever the user launched with ("Default", +// "Profile 1" -> Profile_1), so the last dash segment is dropped, not matched. +function windowClassKeys(windowClass) { + var cls = String(windowClass || "").toLowerCase() + var keys = [cls] + var url = cls.replace(/^(brave|chrome|chromium)-/, "") + if (url !== cls) keys.push(webappKey(url.replace(/-[^-]*$/, "").replace(/__/g, "/").replace(/_/g, "/"))) + return keys +} + +// Address of the most recently focused window of an app, or "" when none is +// open. focusHistoryID 0 is the focused window, higher is older. +function findAppWindow(clients, keys) { + if (!keys || !keys.length) return "" + var best = null + for (var i = 0; i < (clients || []).length; i++) { + var c = clients[i] + if (!c || !c.class || c.mapped === false) continue + var matches = windowClassKeys(c.class).some(function(k) { return keys.indexOf(k) >= 0 }) + if (matches && (!best || c.focusHistoryID < best.focusHistoryID)) best = c + } + return best ? String(best.address) : "" +} + function menuCandidate(tree, item, guards, deps) { var crumbs = deps.OmarchyMenu.breadcrumb(tree, item.id) var keywords = item.aliases.map(words).filter(function(k) { return k }) @@ -151,7 +201,7 @@ function isEnabled(candidate, ctx) { } function rankOptions(ctx) { - return { sensitivity: ctx.sensitivity || "medium", nowMs: ctx.nowMs } + return { sensitivity: ctx.sensitivity || "medium", nowMs: ctx.nowMs, fleaHits: ctx.fleaHits || null } } function fallbackRows(query) { @@ -319,6 +369,7 @@ function actionsFor(candidate) { list.push({ id: "open-in-omarchy-menu", title: "Open in Omarchy Menu", shortcut: "Ctrl ↵", section: "primary", icon: "" }) if (candidate.kind === "fallback") return list + if (candidate.kind === "app") list.push({ id: "open-new", title: "Open New Window", shortcut: "Shift ↵", section: "primary", icon: "" }) if (candidate.kind === "app") list.push({ id: "copy-id", title: "Copy Desktop ID", shortcut: "Ctrl Shift C", section: "copy", icon: "" }) if (candidate.kind === "omarchy-action") list.push({ id: "copy-command", title: "Copy Command", shortcut: "Ctrl Shift C", section: "copy", icon: "" }) @@ -433,6 +484,9 @@ if (typeof module !== "undefined") { confirmationFor: confirmationFor, aboutFor: aboutFor, appCandidate: appCandidate, + appWindowKeys: appWindowKeys, + windowClassKeys: windowClassKeys, + findAppWindow: findAppWindow, menuCandidate: menuCandidate, menuCandidates: menuCandidates, builtinCandidate: builtinCandidate, diff --git a/core/Rows.js b/core/Rows.js new file mode 100644 index 0000000..550aee5 --- /dev/null +++ b/core/Rows.js @@ -0,0 +1,20 @@ +function same(current, next) { + var keys = Object.keys(next) + for (var i = 0; i < keys.length; i++) if (current[keys[i]] !== next[keys[i]]) return false + return true +} + +// Keep existing delegates alive across search answers, as Flea 0.3.12's StableModel does for listings. +function update(model, next) { + var shared = Math.min(model.count, next.length) + for (var i = 0; i < shared; i++) if (!same(model.get(i), next[i])) model.set(i, next[i]) + if (model.count > next.length) model.remove(next.length, model.count - next.length) + if (model.count < next.length) model.append(next.slice(model.count)) +} + +// ListModel.get returns a mutable handle, so selection must keep a separate value. +function snapshot(model, index) { + return index >= 0 && index < model.count ? Object.assign({}, model.get(index)) : null +} + +if (typeof module !== "undefined") module.exports = { update, snapshot } diff --git a/plugin/core/Rpc.js b/core/Rpc.js similarity index 100% rename from plugin/core/Rpc.js rename to core/Rpc.js diff --git a/plugin/core/Schema.js b/core/Schema.js similarity index 97% rename from plugin/core/Schema.js rename to core/Schema.js index 66d807b..cdac573 100644 --- a/plugin/core/Schema.js +++ b/core/Schema.js @@ -67,7 +67,7 @@ var configDefaults = { logLevel: "info", keepVisibleOnFocusLoss: false, // Daily ECB reference rates from frankfurter.dev for currency conversion - // (SPEC §8 Calculator). The only network request oma-cast makes. [D] + // (SPEC §8 Calculator). The only network request omacast makes. [D] currencyRates: true, // Calculator preferences named after Raycast's (ref 04 App. B) [O]. remToPxRate: 16, diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..cdb448f --- /dev/null +++ b/manifest.json @@ -0,0 +1,15 @@ +{ + "schemaVersion": 1, + "id": "typecraft.omacast", + "name": "omacast", + "version": "0.1.0", + "author": "typecraft", + "description": "A Raycast-style launcher for Omarchy: apps, every Omarchy action, calculator and frecency ranking", + "kinds": [ + "menu" + ], + "keepLoaded": true, + "entryPoints": { + "menu": "Launcher.qml" + } +} diff --git a/plugin/core/Agent.js b/plugin/core/Agent.js deleted file mode 100644 index 2e53237..0000000 --- a/plugin/core/Agent.js +++ /dev/null @@ -1,24 +0,0 @@ -var FALLBACK_ID = "fallback:agent" - -function launchArgv(query) { - var prompt = String(query === undefined || query === null ? "" : query) - if (!prompt.trim()) throw new Error("Type a question first") - if (prompt.indexOf("\u0000") >= 0) throw new Error("Questions cannot contain a NUL character") - if (prompt.length > 16384) throw new Error("Keep the question under 16,385 characters") - return ["omarchy-agent", "--prompt", prompt] -} - -var CHECK_SCRIPT = [ - 'for tool in omarchy-agent omarchy-agent-prompt omarchy-default-agent omarchy-launch-tui xdg-terminal-exec uwsm-app; do', - ' command -v "$tool" >/dev/null 2>&1 || { printf "Agent launch unavailable: missing %s. Update Omarchy.\\n" "$tool"; exit 1; }', - 'done', - 'agent=$(omarchy-default-agent) || { printf "Could not read the default agent\\n"; exit 1; }', - '[[ -n $agent ]] || { printf "Choose a default agent in Omarchy Settings first\\n"; exit 1; }', - 'command -v "$agent" >/dev/null 2>&1 || { printf "Your default agent is not installed\\n"; exit 1; }', - 'terminal=$(xdg-terminal-exec --print-id 2>/dev/null) || { printf "Choose an installed default terminal in Omarchy Settings\\n"; exit 1; }', - '[[ -n $terminal ]] || { printf "Choose an installed default terminal in Omarchy Settings\\n"; exit 1; }' -].join("\n") - -if (typeof module !== "undefined") { - module.exports = { FALLBACK_ID: FALLBACK_ID, launchArgv: launchArgv, CHECK_SCRIPT: CHECK_SCRIPT } -} diff --git a/plugin/core/Paths.js b/plugin/core/Paths.js deleted file mode 100644 index bae506d..0000000 --- a/plugin/core/Paths.js +++ /dev/null @@ -1,46 +0,0 @@ - -var APP = "oma-cast" - -function xdg(env, name, homeFallback) { - var value = String(env(name) || "") - if (value.charAt(0) === "/") return value.replace(/\/+$/, "") - return homeFallback -} - -function resolve(env) { - var home = String(env("HOME") || "").replace(/\/+$/, "") - if (!home) throw new Error("HOME is not set") - var uid = String(env("UID") || "") - var runtimeBase = xdg(env, "XDG_RUNTIME_DIR", uid ? "/run/user/" + uid : "/tmp") - - var config = xdg(env, "XDG_CONFIG_HOME", home + "/.config") + "/" + APP - var data = xdg(env, "XDG_DATA_HOME", home + "/.local/share") + "/" + APP - var cache = xdg(env, "XDG_CACHE_HOME", home + "/.cache") + "/" + APP - var state = xdg(env, "XDG_STATE_HOME", home + "/.local/state") + "/" + APP - - return { - home: home, - config: config, - configFile: config + "/config.json", - scriptCommands: config + "/script-commands", - data: data, - db: data + "/db", - extensions: data + "/extensions", - extensionSupport: data + "/extension-support", - clipboard: data + "/clipboard", - notesAttachments: data + "/notes/attachments", - cache: cache, - state: state, - logs: state + "/logs", - backups: state + "/backups", - runtime: runtimeBase + "/" + APP - } -} - -function directoriesToCreate(paths) { - return [paths.config, paths.db, paths.cache, paths.logs, paths.backups, paths.runtime] -} - -if (typeof module !== "undefined") { - module.exports = { APP: APP, resolve: resolve, directoriesToCreate: directoriesToCreate } -} diff --git a/plugin/manifest.json b/plugin/manifest.json deleted file mode 100644 index d25c020..0000000 --- a/plugin/manifest.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "schemaVersion": 1, - "id": "typecraft.oma-cast", - "name": "Oma Cast", - "version": "0.0.1", - "author": "typecraft", - "description": "A Raycast-style launcher for Omarchy", - "kinds": [ - "menu" - ], - "keepLoaded": true, - "entryPoints": { - "menu": "Launcher.qml" - } -} diff --git a/plugin/services/AgentLauncher.qml b/plugin/services/AgentLauncher.qml deleted file mode 100644 index 13f780b..0000000 --- a/plugin/services/AgentLauncher.qml +++ /dev/null @@ -1,59 +0,0 @@ -import QtQuick -import Quickshell.Io -import "../core/Agent.js" as Agent - -Item { - id: service - property bool checking: false - property var pendingArgv: null - property string output: "" - signal prepared(var argv) - signal failed(string message) - - function start(query) { - var argv = Agent.launchArgv(query) - if (service.checking) throw new Error("Agent launch is already being checked") - service.pendingArgv = argv - service.output = "" - service.checking = true - timeout.restart() - check.running = true - } - - function cancel() { - service.pendingArgv = null - timeout.stop() - check.running = false - } - - Component.onDestruction: service.cancel() - - Timer { - id: timeout - interval: 5000 - onTriggered: { - service.cancel() - service.failed("Agent launch check timed out. Try again.") - } - } - - Process { - id: check - command: ["bash", "-lc", Agent.CHECK_SCRIPT] - stdout: SplitParser { - onRead: function(line) { service.output = (service.output + line + "\n").slice(0, 400) } - } - onExited: function(exitCode, exitStatus) { - timeout.stop() - var argv = service.pendingArgv - service.pendingArgv = null - service.checking = false - if (!argv) return - if (exitCode !== 0 || exitStatus !== 0) { - service.failed(service.output.trim() || "Could not check the default agent. Update Omarchy and try again.") - return - } - service.prepared(argv) - } - } -} diff --git a/preview.png b/preview.png new file mode 100644 index 0000000..116c36a Binary files /dev/null and b/preview.png differ diff --git a/search/Cargo.lock b/search/Cargo.lock new file mode 100644 index 0000000..98f1365 --- /dev/null +++ b/search/Cargo.lock @@ -0,0 +1,107 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "omacast-search" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.154" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/search/Cargo.toml b/search/Cargo.toml new file mode 100644 index 0000000..451e2e7 --- /dev/null +++ b/search/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "omacast-search" +version = "0.1.0" +edition = "2021" +license = "MIT" +publish = false + +[dependencies] +serde = { version = "1", features = ["derive"] } +serde_json = "1" + +[profile.release] +lto = true diff --git a/search/LICENSE b/search/LICENSE new file mode 100644 index 0000000..ba6c6d1 --- /dev/null +++ b/search/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 GM + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/search/PROVENANCE.md b/search/PROVENANCE.md new file mode 100644 index 0000000..b00c13e --- /dev/null +++ b/search/PROVENANCE.md @@ -0,0 +1,87 @@ +# Matcher provenance + +The matcher comes from [Flea](https://github.com/thisisgm/flea), base commit `7c866397b` with +flea-search's pre-release `wk2` and `H` changes (diff SHA-256 +`942bbf17235ff91e7758ff3ff28d77a6c22924616d96ec343afe45b6d069ad14`). +The hashes below identify the exact upstream file contents used for this snapshot. + +| Source file | Source SHA-256 | +| --- | --- | +| `searchmatch.rs` | `1416164b21d32cfaee769582de04885a3d64e0d5180447a53977969f898b9158` | +| `searchascii.rs` | `a923431abd51f8ed93ad463a895fe1db518771b07b1b196cc6fcfb7b987b8c4c` | +| `searchtypo.rs` | `9edefcf793c1f02e6513c776e59103d4f84e41e6c46d198c591395a5b781470e` | +| `searchbits.rs` | `b7842ff21afbe14065556b82760b4c4185798cb1c8d41e01ed4f7d4896c2b027` | + +`searchascii.rs`, `searchtypo.rs`, and `searchbits.rs` are byte-for-byte copies. +The only change to `searchmatch.rs` is removal of this trailing module reference, +including its preceding blank line, because the snapshot does not include +`searchmatch_tests.rs`: + +```rust +#[cfg(test)] +#[path = "searchmatch_tests.rs"] +pub(crate) mod tests; +``` + +`src/backend/mod.rs` retains the original module hierarchy, including the +`crate::backend::searchtypo` reference. Unused filesystem-search APIs are allowed +at the adapter's module boundary so the vendored files need no warning edits. +The helper's own tests live in `src/engine.rs`, `src/protocol.rs`, and +`tests/protocol.rs`. + +## License + +The vendored source is MIT licensed, copyright (c) 2026 GM. The original MIT +license and notice are retained verbatim in `LICENSE`, SHA-256 +`d2a2191fc6fa3fb43cd6dfe0a7b156ded8f4a5e23c498ef1111788c37ec4f0c4`. + +## Adapter contract + +`omacast-search` uses synchronous NDJSON on stdin and stdout. Each response is +flushed before reading another request. A write, flush, or input I/O failure ends +the process with failure. Unknown object properties are accepted. Requests must +contain exactly one of `entries` or `query` and an unsigned 64-bit integer `id`. +Optional entry fields may be omitted; present nulls and wrong types are rejected. +Errors include `id` when a complete JSON object contains a valid unsigned ID. +Invalid JSON and oversized drained lines have no recoverable ID. Every rejected +request preserves the previously loaded catalog. A successful empty load counts +as loaded; any search before the first successful load fails. + +The caller applies `Ranker.transliterate` to every input string and query once. +The helper performs no transliteration, normalization, trimming, or path search. +Each score comes directly from `Matcher::name_bytes`. Hits retain loaded entry +order and contain explicit numeric or null `title`, `alternate`, `subtitle`, and +`keyword` scores. Entries with no surviving channel are omitted. Alternate and +keyword channels take their maximum score. A subtitle equal to the title after +Unicode lowercasing is not scored separately. Nonempty subtitles contribute both +`title + " " + subtitle` and `subtitle + " " + title` to keywords, even when the +subtitle duplicates the title. Bundle IDs contribute only to queries containing +a literal period. Empty queries return `hits: []` for legacy UI suggestions. + +All channels drop shadow typo scores `<= SHADOW_TYPO` (`992000`). Default +`medium` and `low` use the same upstream matching rules. `high` also drops scores +`< NAME - HIT_SPAN` (`1900000`), retaining all name subsequences and stronger +matches. Upstream name DP scores are bounded by `2000000 +/- 100000`; exact, +prefix, and word scores are `5000000`, `4000000`, and `3500000`. Upstream listed +typo scores are `999000` or `998000`: one edit from four alphanumeric query +characters, two from eight, and no edits when the query starts with a +non-alphanumeric character. Digits and punctuation remain literal. + +Limits are inclusive: 20000 entries per load, 1024 supplied text fields per +entry, 16384 UTF-8 bytes per supplied text field, 256 Unicode scalar values per +query, and 8 MiB per wire line excluding LF. The field count includes the title, +each present subtitle and bundle ID (including empty strings), and every +alternate-title and keyword array item. Generated keyword combinations are not +supplied fields. CR in CRLF counts toward the wire limit. `BufRead::take` limits +line reads to 8 MiB plus one detection byte; oversized lines drain through LF or +EOF using the existing reader buffer, without growing the line allocation. +The last line may omit LF. + +## Build and verification + +Dependencies are `serde` (derive) and `serde_json`; edition is 2021 and release +LTO is enabled. `Cargo.lock` records dependency resolution from the build host. +Run `cargo test --locked` and `cargo build --release --locked`. The integration tests execute the built binary and +cover protocol recovery, reload atomicity, channel scores, optional fields, +reordered fields, Unicode, long requests, and each input limit. Unit tests also +cover matcher tiers, shadow removal, sensitivity, and write/flush failure. diff --git a/search/src/backend/mod.rs b/search/src/backend/mod.rs new file mode 100644 index 0000000..52c4184 --- /dev/null +++ b/search/src/backend/mod.rs @@ -0,0 +1,3 @@ +pub(crate) mod searchbits; +pub(crate) mod searchmatch; +pub(crate) mod searchtypo; diff --git a/search/src/backend/searchascii.rs b/search/src/backend/searchascii.rs new file mode 100644 index 0000000..07089c8 --- /dev/null +++ b/search/src/backend/searchascii.rs @@ -0,0 +1,55 @@ +// ASCII candidates expose folded letters without allocating or expanding each byte to a Letter. +use super::{fold, Letter, Matcher, State, EXACT, NAME, PENDING_NAME, PREFIX, TYPO, WORD}; + +pub(crate) trait Text: Copy { + fn len(self) -> usize; + fn lower(self, at: usize) -> char; + fn boundary(self, at: usize) -> bool; + fn alphabetic(self, at: usize) -> bool { self.lower(at).is_alphabetic() } +} + +impl Text for &[Letter] { + fn len(self) -> usize { self.len() } + fn lower(self, at: usize) -> char { self[at].lower } + fn boundary(self, at: usize) -> bool { self[at].boundary } +} + +#[derive(Clone, Copy)] +pub(crate) struct Ascii<'a>(pub &'a [u8]); + +impl Text for Ascii<'_> { + fn len(self) -> usize { self.0.len() } + fn lower(self, at: usize) -> char { self.0[at].to_ascii_lowercase() as char } + fn boundary(self, at: usize) -> bool { + at == 0 || super::separator(self.0[at - 1] as char) + || (self.0[at].is_ascii_uppercase() && !self.0[at - 1].is_ascii_uppercase()) + } + fn alphabetic(self, at: usize) -> bool { self.0[at].is_ascii_alphabetic() } +} + +pub(super) fn name(matcher: &Matcher, query: &[u8], bytes: &[u8], letters: &mut Vec, state: &mut State, edits: &mut crate::backend::searchtypo::Scratch) -> Option { + if query.is_empty() { return Some(0); } + let hay = Ascii(bytes); + let prefix = |at| bytes.get(at..at + query.len()).is_some_and(|part| part.eq_ignore_ascii_case(query)); + if prefix(0) { return Some(if bytes.len() == query.len() { EXACT } else { PREFIX }); } + let mut matched = 0; + for at in 0..bytes.len() { + if hay.lower(at) as u8 == query[0] && at > 0 && hay.boundary(at) && prefix(at) { return Some(WORD); } + if query.get(matched).copied() == Some(hay.lower(at) as u8) { matched += 1; } + } + if matched == query.len() { + if matcher.defer { return Some(PENDING_NAME); } + fold(bytes, letters); + state.reset(); + state.advance(&matcher.needle, letters, true); + return state.score(NAME); + } + let distance = match matcher.typo.ascii_distance(hay, matcher.budget) { + Some(distance) => distance, + None => { + fold(bytes, letters); + matcher.distance(letters, edits) + } + }; + distance.map(|distance| TYPO - distance as i32 * 1000) +} diff --git a/search/src/backend/searchbits.rs b/search/src/backend/searchbits.rs new file mode 100644 index 0000000..ee5afc4 --- /dev/null +++ b/search/src/backend/searchbits.rs @@ -0,0 +1,23 @@ +// Letter presence is a rejection filter only; unknown bytes always keep the candidate. +pub(crate) const UNKNOWN: u64 = 1 << 63; +const LITERALS: u64 = ((1 << 16) - 1) << 26; + +pub(crate) fn mask(bytes: &[u8]) -> u64 { + bytes.iter().fold(0, |bits, byte| bits | match byte.to_ascii_lowercase() { + b'a'..=b'z' => 1 << (byte.to_ascii_lowercase() - b'a'), + b'0'..=b'9' => 1 << (26 + byte - b'0'), + b'/' => 1 << 36, + b'-' => 1 << 37, + b'_' => 1 << 38, + b'.' => 1 << 39, + b' ' => 1 << 40, + b'+' => 1 << 41, + _ => UNKNOWN, + }) +} + +pub(crate) fn possible(query: u64, candidate: u64, edits: usize) -> bool { + if (query | candidate) & UNKNOWN != 0 { return true; } + let missing = query & !candidate; + missing & LITERALS == 0 && missing.count_ones() as usize <= edits +} diff --git a/search/src/backend/searchmatch.rs b/search/src/backend/searchmatch.rs new file mode 100644 index 0000000..38060c5 --- /dev/null +++ b/search/src/backend/searchmatch.rs @@ -0,0 +1,233 @@ +// Typed search has its own matcher; folder jump keeps the legacy fuzzy scorer. +use super::searchbits; + +pub(crate) const EXACT: i32 = 5_000_000; +pub(crate) const PREFIX: i32 = 4_000_000; +pub(crate) const WORD: i32 = 3_500_000; +pub(crate) const ALIGNED: i32 = 3_000_000; +pub(crate) const NAME: i32 = 2_000_000; +pub(crate) const TYPO: i32 = 1_000_000; +// The best score a shadow typo can take: an answer drops it, narrowing keeps it. +pub(crate) const SHADOW_TYPO: i32 = TYPO - super::searchtypo::SHADOW as i32 * 1000; +// A DP hit moves a score at most this far inside its tier. +pub(crate) const HIT_SPAN: i32 = 100_000; +// Pass-1 markers for rows whose class is known and whose score inside it waits for the DP; a real score is never negative. +pub(crate) const PENDING_ALIGNED: i32 = -3; +pub(crate) const PENDING_NAME: i32 = -2; +// A NAME-class row whose basename alone is not a subsequence, so a parent-state bound can rank it before its DP. +pub(crate) const PENDING_PATH: i32 = -1; +const DEAD: i32 = i32::MIN / 4; +// State::advance's bonuses for a matched letter that continues a run, starts a word, or sits in the basename. +pub(crate) const RUN_BONUS: i32 = 8; +pub(crate) const BOUNDARY_BONUS: i32 = 6; +pub(crate) const BASENAME_BONUS: i32 = 4; + +#[derive(Clone, Copy)] +pub(crate) struct Letter { + pub lower: char, + pub boundary: bool, +} + +pub(crate) fn fold(bytes: &[u8], into: &mut Vec) { + into.clear(); + if bytes.is_ascii() { + let mut before: Option = None; + into.extend(bytes.iter().map(|&c| { + let boundary = before.map_or(true, |p| separator(p as char) || (c.is_ascii_uppercase() && !p.is_ascii_uppercase())); + before = Some(c); + Letter { lower: c.to_ascii_lowercase() as char, boundary } + })); + return; + } + let mut before: Option = None; + for c in String::from_utf8_lossy(bytes).chars() { + let boundary = before.map_or(true, |p| separator(p) || (c.is_uppercase() && !p.is_uppercase())); + into.push(Letter { lower: c.to_lowercase().next().unwrap_or(c), boundary }); + before = Some(c); + } +} + +pub(crate) fn separator(c: char) -> bool { matches!(c, '/' | '-' | '_' | '.' | ' ') } + +fn edit_budget(query: &str) -> usize { + let count = query.chars().filter(|c| c.is_alphanumeric()).count(); + if query.chars().next().is_some_and(|c| !c.is_alphanumeric()) { 0 } + else if count >= 8 { 2 } else if count >= 4 { 1 } else { 0 } +} + +#[derive(Clone)] +pub(crate) struct State { + best: Vec, + end: Vec, + pub hit: i32, +} + +impl State { + pub(crate) fn new(len: usize) -> Self { + let mut best = vec![DEAD; len + 1]; + best[0] = 0; + Self { best, end: vec![DEAD; len + 1], hit: if len == 0 { 0 } else { DEAD } } + } + + pub(crate) fn resize(&mut self, len: usize) { + self.best.resize(len + 1, DEAD); + self.end.resize(len + 1, DEAD); + self.reset(); + } + + #[cfg(test)] + pub(crate) fn heap_bytes(&self) -> usize { (self.best.capacity() + self.end.capacity()) * std::mem::size_of::() } + + pub(crate) fn best(&self) -> &[i32] { &self.best } + + pub(crate) fn copy_from(&mut self, other: &Self) { + self.best.clone_from(&other.best); + self.end.clone_from(&other.end); + self.hit = other.hit; + } + + pub(crate) fn reset(&mut self) { + self.best.fill(DEAD); + self.best[0] = 0; + self.end.fill(DEAD); + self.hit = if self.best.len() == 1 { 0 } else { DEAD }; + } + + // Descending query positions preserve the previous column without allocating another one. + pub(crate) fn advance(&mut self, query: &[char], letters: &[Letter], basename: bool) { + for letter in letters { + for k in (1..=query.len()).rev() { + let matched = if query[k - 1] == letter.lower { + self.best[k - 1].max(self.end[k - 1] + RUN_BONUS) + + i32::from(letter.boundary) * BOUNDARY_BONUS + i32::from(basename) * BASENAME_BONUS + } else { DEAD }; + self.best[k] = (self.best[k] - 1).max(matched); + self.end[k] = matched; + } + self.hit = self.hit.max(self.end[query.len()]); + } + } + + pub(crate) fn score(&self, tier: i32) -> Option { + (self.hit > DEAD / 2).then(|| tier + self.hit.clamp(-HIT_SPAN, HIT_SPAN)) + } + +} + +pub(crate) struct Matcher { + pub needle: Vec, + ascii: Option>, + pub bits: u64, + pub parent_bits: u64, + pub suffix_bits: Vec, + pub budget: usize, + typo: super::searchtypo::Plan, + pub prefix: usize, + parent_typo: super::searchtypo::Plan, + // Pass 1 of a ranked answer: a name or path that matches as a subsequence gets its class marker instead of its DP. + pub defer: bool, +} + +impl Matcher { + pub(crate) fn new(query: &str) -> Self { + let needle: Vec = query.chars().map(|c| c.to_lowercase().next().unwrap_or(c)).collect(); + let budget = edit_budget(query); + let typo = super::searchtypo::Plan::new(&needle); + let prefix = needle.iter().rposition(|&c| c == '/').map_or(0, |at| at + 1); + let parent_typo = super::searchtypo::Plan::new(&needle[..prefix]); + let ascii = needle.iter().all(char::is_ascii).then(|| needle.iter().map(|&c| c as u8).collect()); + let parent: String = needle[..prefix].iter().collect(); + let mut suffix_bits = vec![0; needle.len() + 1]; + let mut encoded = [0; 4]; + for at in (0..needle.len()).rev() { + suffix_bits[at] = suffix_bits[at + 1] | searchbits::mask(needle[at].encode_utf8(&mut encoded).as_bytes()); + } + Self { needle, ascii, bits: searchbits::mask(query.as_bytes()), parent_bits: searchbits::mask(parent.as_bytes()), suffix_bits, budget, typo, prefix, parent_typo, defer: false } + } + + pub(crate) fn deferred(query: &str) -> Self { + Self { defer: true, ..Self::new(query) } + } + + pub(crate) fn can_narrow(before: &str, now: &str) -> bool { + if !now.starts_with(before) { return false; } + let budget = edit_budget(before); + // The first slash enables path typos, so it must not enlarge the match set. + budget == edit_budget(now) + && (budget == 0 || before.contains('/') || !now.contains('/')) + } + + pub(crate) fn name(&self, letters: &[Letter], state: &mut State, edits: &mut super::searchtypo::Scratch) -> Option { + if self.needle.is_empty() { return Some(0); } + let prefix = |at: usize| letters.get(at..at + self.needle.len()).is_some_and(|part| { + part.iter().zip(&self.needle).all(|(letter, query)| letter.lower == *query) + }); + if prefix(0) { return Some(if letters.len() == self.needle.len() { EXACT } else { PREFIX }); } + if (1..letters.len()).any(|at| letters[at].boundary && prefix(at)) { return Some(WORD); } + let mut at = 0; + for letter in letters { + if self.needle.get(at) == Some(&letter.lower) { at += 1; } + } + if at == self.needle.len() { + if self.defer { return Some(PENDING_NAME); } + state.reset(); + state.advance(&self.needle, letters, true); + return state.score(NAME); + } + self.distance(letters, edits).map(|distance| TYPO - distance as i32 * 1000) + } + + pub(crate) fn name_bytes(&self, bytes: &[u8], letters: &mut Vec, state: &mut State, edits: &mut super::searchtypo::Scratch) -> Option { + if let Some(query) = self.ascii.as_ref().filter(|_| bytes.is_ascii()) { + return ascii::name(self, query, bytes, letters, state, edits); + } + fold(bytes, letters); + self.name(letters, state, edits) + } + + pub(crate) fn reach(&self, bytes: &[u8], mut at: usize) -> usize { + if bytes.is_ascii() { + for &byte in bytes { + if self.needle.get(at) == Some(&(byte.to_ascii_lowercase() as char)) { at += 1; } + } + } else { + for c in String::from_utf8_lossy(bytes).chars() { + if self.needle.get(at) == Some(&c.to_lowercase().next().unwrap_or(c)) { at += 1; } + } + } + at + } + + pub(crate) fn distance(&self, letters: &[Letter], scratch: &mut super::searchtypo::Scratch) -> Option { + self.typo.distance(&self.needle, letters, self.budget, scratch) + } + + pub(crate) fn parent_possible(&self, letters: &[Letter], scratch: &mut super::searchtypo::Scratch) -> bool { + self.parent_typo.distance(&self.needle[..self.prefix], letters, self.budget, scratch).is_some() + } + + #[cfg(test)] + pub(crate) fn full(&self, path: &[u8]) -> Option { + let mut letters = Vec::new(); + fold(path, &mut letters); + let base = path.iter().rposition(|&b| b == b'/').map_or(0, |at| at + 1); + let mut leaf = Vec::new(); + fold(&path[base..], &mut leaf); + let mut state = State::new(self.needle.len()); + let name = self.name_bytes(&path[base..], &mut leaf, &mut state, &mut super::searchtypo::Scratch::default()); + if name.is_some_and(|score| score > NAME + HIT_SPAN) || base == 0 { return name; } + state = State::new(self.needle.len()); + let split = letters.len() - leaf.len(); + state.advance(&self.needle, &letters[..split], false); + state.advance(&self.needle, &leaf, true); + let mut score = name.max(state.score(if self.needle.contains(&'/') { ALIGNED } else { NAME })); + if self.needle.contains(&'/') { + score = score.max(self.distance(&letters, &mut super::searchtypo::Scratch::default()) + .map(|distance| TYPO - distance as i32 * 1000)); + } + score + } +} + +#[path = "searchascii.rs"] +pub(crate) mod ascii; diff --git a/search/src/backend/searchtypo.rs b/search/src/backend/searchtypo.rs new file mode 100644 index 0000000..9f7ee2b --- /dev/null +++ b/search/src/backend/searchtypo.rs @@ -0,0 +1,219 @@ +// Banded optimal-string-alignment distance at word starts, with literal digits and punctuation. +use super::searchmatch::{Letter, ascii::{Ascii, Text}}; + +// Each row is two layers: index 0 without a dropped query letter, index 1 with one, which may only end at a word end. +#[derive(Default)] +pub(crate) struct Scratch { + older: [Vec; 2], + previous: [Vec; 2], + current: [Vec; 2], +} + +fn editable(c: char) -> bool { c.is_alphabetic() } + +// A match only a query letter dropped inside a word gives is reported SHADOW edits further: narrowing keeps it, answers never list it. +pub(crate) const SHADOW: usize = 8; + +// The listed distance, else the shadow of an unrestricted one, so the match set still only shrinks as the query grows. +fn reported(best: usize, plain: usize, budget: usize) -> Option { + if best <= budget { Some(best) } else { (plain <= budget).then_some(SHADOW + plain) } +} + +fn word_end(hay: &[Letter], at: usize) -> bool { + at == hay.len() || !editable(hay[at].lower) || hay[at].boundary +} + +impl Scratch { + pub(crate) fn distance(&mut self, query: &[char], hay: &[Letter], budget: usize) -> Option { + if budget == 0 { return None; } + let (mut best, mut plain) = (budget + 1, budget + 1); + for start in 0..hay.len() { + if !hay[start].boundary || hay.len() - start + budget < query.len() { continue; } + let len = (hay.len() - start).min(query.len() + budget); + for layer in 0..2 { + self.older[layer].resize(len + 1, budget + 1); + self.previous[layer].resize(len + 1, budget + 1); + self.current[layer].resize(len + 1, budget + 1); + self.previous[layer].fill(budget + 1); + } + self.previous[0][0] = 0; + for j in 1..=len.min(budget) { + self.previous[0][j] = if editable(hay[start + j - 1].lower) { self.previous[0][j - 1] + 1 } else { budget + 1 }; + } + for i in 1..=query.len() { + for layer in 0..2 { self.current[layer].fill(budget + 1); } + if editable(query[i - 1]) { self.current[1][0] = self.previous[0][0].min(self.previous[1][0]) + 1; } + let mut low = self.current[1][0]; + for j in i.saturating_sub(budget).max(1)..=len.min(i + budget) { + let (a, b) = (query[i - 1], hay[start + j - 1].lower); + for layer in 0..2 { + let previous = &self.previous[layer]; + let mut value = if a == b { previous[j - 1] } + else if editable(a) && editable(b) { previous[j - 1] + 1 } else { budget + 1 }; + if layer == 1 && editable(a) { value = value.min(self.previous[0][j].min(self.previous[1][j]) + 1); } + if editable(b) { value = value.min(self.current[layer][j - 1] + 1); } + if i > 1 && j > 1 && a == hay[start + j - 2].lower && query[i - 2] == b && editable(a) && editable(b) { + value = value.min(self.older[layer][j - 2] + 1); + } + self.current[layer][j] = value.min(budget + 1); + low = low.min(value); + } + } + std::mem::swap(&mut self.older, &mut self.previous); + std::mem::swap(&mut self.previous, &mut self.current); + if low > budget { break; } + if i == query.len() { + for j in query.len().saturating_sub(budget)..=len { + best = best.min(self.previous[0][j]); + plain = plain.min(self.previous[0][j]).min(self.previous[1][j]); + if word_end(hay, start + j) { best = best.min(self.previous[1][j]); } + } + } + } + } + reported(best, plain, budget) + } +} + +// Short ASCII queries use the same edit automaton packed into three bounded bit sets. +pub(crate) struct Plan { + equal: [u64; 128], + letters: u64, + goal: u64, + segments: Vec<(usize, u32)>, + literals: Vec, +} + +impl Plan { + pub(crate) fn new(query: &[char]) -> Self { + let mut plan = Self { equal: [0; 128], letters: 0, goal: 0, segments: Vec::new(), literals: Vec::new() }; + if query.len() >= 64 || !query.iter().all(char::is_ascii) { return plan; } + plan.goal = 1 << query.len(); + let (mut len, mut bits) = (0, 0); + for (at, &c) in query.iter().enumerate() { + plan.equal[c as usize] |= 1 << at; + if editable(c) { + plan.letters |= 1 << at; + len += 1; + bits |= 1 << (c.to_ascii_lowercase() as u8 - b'a'); + } else { + plan.segments.push((len, bits)); + plan.literals.push(c); + (len, bits) = (0, 0); + } + } + plan.segments.push((len, bits)); + plan + } + + // An edit cannot cross a literal, so length and missing-letter costs add across its segments. + fn possible(&self, hay: impl Text, budget: usize) -> bool { + if !self.trailing_possible(hay, budget) { return false; } + for start in 0..hay.len() { + if !hay.boundary(start) { continue; } + let (mut at, mut spent, mut valid) = (start, 0, true); + for (part, &(wanted, required)) in self.segments.iter().enumerate() { + let last = part == self.literals.len(); + let (mut len, mut bits, mut over) = (0usize, 0u32, false); + while at < hay.len() && hay.alphabetic(at) { + if len == wanted + budget { + over = !last; + break; + } + let c = hay.lower(at); + if c.is_ascii_alphabetic() { bits |= 1 << (c.to_ascii_lowercase() as u8 - b'a'); } + len += 1; + at += 1; + } + let length = if last { wanted.saturating_sub(len) } else { wanted.abs_diff(len) }; + spent += length.max((required & !bits).count_ones() as usize); + if over || spent > budget { valid = false; break; } + if last { return true; } + if at == hay.len() || hay.lower(at) != self.literals[part] { + valid = false; + break; + } + at += 1; + } + if valid { return true; } + } + false + } + + // The segment before the last literal must fit immediately before one occurrence of that literal. + fn trailing_possible(&self, hay: impl Text, budget: usize) -> bool { + let Some(&literal) = self.literals.last() else { return true }; + let part = self.literals.len() - 1; + let (wanted, required) = self.segments[part]; + for end in (0..hay.len()).rev() { + if hay.lower(end) != literal { continue; } + let (mut at, mut len, mut bits) = (end, 0usize, 0u32); + while at > 0 && len < wanted + budget && hay.alphabetic(at - 1) { + at -= 1; + len += 1; + let c = hay.lower(at); + if c.is_ascii_alphabetic() { bits |= 1 << (c.to_ascii_lowercase() as u8 - b'a'); } + } + if part > 0 && at > 0 && hay.alphabetic(at - 1) { continue; } + let length = if part == 0 { wanted.saturating_sub(len) } else { wanted.abs_diff(len) }; + if length.max((required & !bits).count_ones() as usize) <= budget { return true; } + } + false + } + + pub(crate) fn distance(&self, query: &[char], hay: &[Letter], budget: usize, scratch: &mut Scratch) -> Option { + if budget == 0 { return None; } + if self.goal == 0 { return scratch.distance(query, hay, budget); } + self.bit_distance(hay, budget) + } + + pub(crate) fn ascii_distance(&self, hay: Ascii<'_>, budget: usize) -> Option> { + if budget == 0 { return Some(None); } + if self.goal == 0 { return None; } + Some(self.bit_distance(hay, budget)) + } + + fn bit_distance(&self, hay: impl Text, budget: usize) -> Option { + if !self.possible(hay, budget) { return None; } + // Layer 0 holds states without a dropped query letter, layer 1 states with one, which may only end at a word end. + let mut start = [[0u64; 3]; 2]; + start[0] = [1; 3]; + for e in 1..=budget { start[1][e] = start[1][e - 1] | (((start[0][e - 1] | start[1][e - 1]) & self.letters) << 1); } + let mut previous = [[0u64; 3]; 2]; + let mut older = [[0u64; 3]; 2]; + let mut last = '\0'; + let mut last_editable = false; + let (mut best, mut plain) = (budget + 1, budget + 1); + for at in 0..hay.len() { + let lower = hay.lower(at); + let editable = hay.alphabetic(at); + if hay.boundary(at) { + for layer in 0..2 { for e in 0..=budget { previous[layer][e] |= start[layer][e]; } } + } + let equal = self.equal.get(lower as usize).copied().unwrap_or(0); + let before = if last_editable { self.equal.get(last as usize).copied().unwrap_or(0) } else { 0 }; + let ends = at + 1 == hay.len() || !hay.alphabetic(at + 1) || hay.boundary(at + 1); + let mut current = [[0u64; 3]; 2]; + for e in 0..=budget { + for layer in 0..2 { + current[layer][e] = (previous[layer][e] & equal) << 1; + if e > 0 { + current[layer][e] |= current[layer][e - 1]; + if editable { + current[layer][e] |= previous[layer][e - 1] | ((previous[layer][e - 1] & self.letters) << 1); + current[layer][e] |= (older[layer][e - 1] & equal & (before >> 1)) << 2; + } + } + } + if e > 0 { current[1][e] |= ((current[0][e - 1] | current[1][e - 1]) & self.letters) << 1; } + if current[0][e] & self.goal != 0 || (ends && current[1][e] & self.goal != 0) { best = best.min(e); } + if (current[0][e] | current[1][e]) & self.goal != 0 { plain = plain.min(e); } + } + older = previous; + previous = current; + last = lower; + last_editable = editable; + } + reported(best, plain, budget) + } +} diff --git a/search/src/engine.rs b/search/src/engine.rs new file mode 100644 index 0000000..c329acc --- /dev/null +++ b/search/src/engine.rs @@ -0,0 +1,277 @@ +use crate::backend::searchmatch::{Letter, Matcher, State, HIT_SPAN, NAME, SHADOW_TYPO}; +use crate::backend::searchtypo::Scratch; +use serde::{Deserialize, Deserializer, Serialize}; + +pub(crate) const MAX_ENTRIES: usize = 20_000; +pub(crate) const MAX_FIELD_BYTES: usize = 16_384; +pub(crate) const MAX_FIELDS: usize = 1_024; +pub(crate) const MAX_QUERY_CHARS: usize = 256; + +// Input: {"title":"Firefox","subtitle":"Browser","alternateTitles":[],"keywords":[],"bundleId":"org.mozilla.firefox"}. +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct Entry { + title: String, + #[serde(default, deserialize_with = "present_string")] + subtitle: Option, + #[serde(default)] + alternate_titles: Vec, + #[serde(default)] + keywords: Vec, + #[serde(default, deserialize_with = "present_string")] + bundle_id: Option, +} + +// An absent optional string is valid; a present null is a shape error. +fn present_string<'de, D: Deserializer<'de>>(deserializer: D) -> Result, D::Error> { + String::deserialize(deserializer).map(Some) +} + +struct Candidate { + title: String, + subtitle: Option, + alternate_titles: Vec, + keywords: Vec, + bundle_id: Option, +} + +impl Entry { + fn validate(&self, index: usize) -> Result<(), String> { + let fields = 1 + usize::from(self.subtitle.is_some()) + + self.alternate_titles.len() + self.keywords.len() + + usize::from(self.bundle_id.is_some()); + if fields > MAX_FIELDS { + return Err(format!("entries[{index}] exceeds {MAX_FIELDS} text fields")); + } + let check = |field: &str, text: &str| { + if text.len() > MAX_FIELD_BYTES { + Err(format!("entries[{index}].{field} exceeds {MAX_FIELD_BYTES} bytes")) + } else { + Ok(()) + } + }; + check("title", &self.title)?; + if let Some(text) = &self.subtitle { + check("subtitle", text)?; + } + if let Some(text) = &self.bundle_id { + check("bundleId", text)?; + } + for (at, text) in self.alternate_titles.iter().enumerate() { + check(&format!("alternateTitles[{at}]"), text)?; + } + for (at, text) in self.keywords.iter().enumerate() { + check(&format!("keywords[{at}]"), text)?; + } + Ok(()) + } + + fn prepare(self) -> Candidate { + let mut keywords = self.keywords; + let subtitle = self.subtitle.filter(|text| !text.is_empty()); + if let Some(text) = &subtitle { + keywords.push(format!("{} {text}", self.title)); + keywords.push(format!("{text} {}", self.title)); + } + let subtitle = subtitle.filter(|text| text.to_lowercase() != self.title.to_lowercase()); + Candidate { + title: self.title, + subtitle, + alternate_titles: self.alternate_titles, + keywords, + bundle_id: self.bundle_id, + } + } +} + +#[derive(Clone, Copy, Default, Deserialize, PartialEq)] +#[serde(rename_all = "lowercase")] +pub(crate) enum Sensitivity { + Low, + #[default] + Medium, + High, +} + +#[derive(Debug, Serialize)] +pub(crate) struct Hit { + pub index: usize, + pub title: Option, + pub alternate: Option, + pub subtitle: Option, + pub keyword: Option, +} + +struct Scorer { + matcher: Matcher, + letters: Vec, + state: State, + edits: Scratch, + sensitivity: Sensitivity, +} + +impl Scorer { + fn new(query: &str, sensitivity: Sensitivity) -> Self { + let matcher = Matcher::new(query); + let state = State::new(matcher.needle.len()); + Self { matcher, letters: Vec::new(), state, edits: Scratch::default(), sensitivity } + } + + fn score(&mut self, text: &str) -> Option { + self.matcher.name_bytes(text.as_bytes(), &mut self.letters, &mut self.state, &mut self.edits) + .filter(|score| *score > SHADOW_TYPO + && (self.sensitivity != Sensitivity::High || *score >= NAME - HIT_SPAN)) + } +} + +#[derive(Default)] +pub(crate) struct Catalog { + entries: Option>, +} + +impl Catalog { + pub(crate) fn load(&mut self, entries: Vec) -> Result { + if entries.len() > MAX_ENTRIES { + return Err(format!("entries exceeds {MAX_ENTRIES} candidates")); + } + for (index, entry) in entries.iter().enumerate() { + entry.validate(index)?; + } + let count = entries.len(); + self.entries = Some(entries.into_iter().map(Entry::prepare).collect()); + Ok(count) + } + + pub(crate) fn search(&self, query: &str, sensitivity: Sensitivity) -> Result, String> { + let entries = self.entries.as_ref().ok_or("search requires a successful load first")?; + if query.chars().count() > MAX_QUERY_CHARS { + return Err(format!("query exceeds {MAX_QUERY_CHARS} characters")); + } + if query.is_empty() { + return Ok(Vec::new()); + } + let mut scorer = Scorer::new(query, sensitivity); + let mut hits = Vec::new(); + for (index, entry) in entries.iter().enumerate() { + let title = scorer.score(&entry.title); + let alternate = entry.alternate_titles.iter().filter_map(|text| scorer.score(text)).max(); + let subtitle = entry.subtitle.as_ref().and_then(|text| scorer.score(text)); + let mut keyword = entry.keywords.iter().filter_map(|text| scorer.score(text)).max(); + if query.contains('.') { + if let Some(bundle_id) = &entry.bundle_id { + keyword = keyword.max(scorer.score(bundle_id)); + } + } + if title.is_some() || alternate.is_some() || subtitle.is_some() || keyword.is_some() { + hits.push(Hit { index, title, alternate, subtitle, keyword }); + } + } + Ok(hits) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::backend::searchmatch::{EXACT, PREFIX, TYPO, WORD}; + use serde_json::json; + + fn catalog(entries: serde_json::Value) -> Catalog { + let mut catalog = Catalog::default(); + catalog.load(serde_json::from_value(entries).unwrap()).unwrap(); + catalog + } + + #[test] + fn name_tiers_and_loaded_order_are_preserved() { + let catalog = catalog(json!([ + {"title":"Fire"}, {"title":"Firefox"}, {"title":"Mozilla Fire"}, + {"title":"first-response"}, {"title":"zzzz"} + ])); + let hits = catalog.search("fire", Sensitivity::Medium).unwrap(); + assert_eq!(hits.iter().map(|hit| hit.index).collect::>(), vec![0, 1, 2, 3]); + assert_eq!(hits[0].title, Some(EXACT)); + assert_eq!(hits[1].title, Some(PREFIX)); + assert_eq!(hits[2].title, Some(WORD)); + assert!((NAME - HIT_SPAN..=NAME + HIT_SPAN).contains(&hits[3].title.unwrap())); + } + + #[test] + fn typo_scores_survive_low_and_medium_but_not_high() { + let catalog = catalog(json!([{ "title":"Firefox" }])); + for query in ["firefoc", "fierfox"] { + for sensitivity in [Sensitivity::Low, Sensitivity::Medium] { + assert_eq!(catalog.search(query, sensitivity).unwrap()[0].title, Some(TYPO - 1000)); + } + assert!(catalog.search(query, Sensitivity::High).unwrap().is_empty()); + } + assert!(!catalog.search("frfx", Sensitivity::High).unwrap().is_empty()); + } + + #[test] + fn shadow_typos_are_never_returned() { + let mut scorer = Scorer::new("firre", Sensitivity::Medium); + let raw = scorer.matcher.name_bytes(b"Firefox", &mut scorer.letters, &mut scorer.state, &mut scorer.edits); + assert!(raw.is_some_and(|score| score <= SHADOW_TYPO)); + assert!(scorer.score("Firefox").is_none()); + let catalog = catalog(json!([ + { "title":"Firefox", "alternateTitles":["Firefox"], "keywords":["Firefox"] }, + { "title":"zzzz", "subtitle":"Firefox" } + ])); + assert!(catalog.search("firre", Sensitivity::Low).unwrap().is_empty()); + } + + #[test] + fn combined_keywords_keep_real_subsequences_when_title_is_only_a_shadow() { + let catalog = catalog(json!([{ "title":"Firefox", "subtitle":"Firefox Tool" }])); + let hits = catalog.search("firre", Sensitivity::High).unwrap(); + assert_eq!(hits.len(), 1); + assert_eq!(hits[0].title, None); + assert_eq!(hits[0].subtitle, None); + assert!((NAME - HIT_SPAN..=NAME + HIT_SPAN).contains(&hits[0].keyword.unwrap())); + } + + #[test] + fn unicode_folding_and_long_query_fallback_work_without_transliteration() { + let long = "é".repeat(65); + let catalog = catalog(json!([{ "title":"Файл" }, { "title":"Café" }, { "title":long }])); + assert_eq!(catalog.search("фАЙЛ", Sensitivity::High).unwrap()[0].title, Some(EXACT)); + assert!((NAME - HIT_SPAN..=NAME + HIT_SPAN).contains(&catalog.search("фл", Sensitivity::High).unwrap()[0].title.unwrap())); + assert_eq!(catalog.search("café", Sensitivity::High).unwrap()[0].title, Some(EXACT)); + assert!(catalog.search("cafe", Sensitivity::High).unwrap().is_empty()); + assert_eq!(catalog.search(&"é".repeat(65), Sensitivity::High).unwrap()[0].title, Some(EXACT)); + } + + #[test] + fn channel_maxima_combinations_and_bundle_gate_are_independent() { + let catalog = catalog(json!([{ "title":"Alpha", "subtitle":"Beta", + "alternateTitles":["Alpha Tool", "Alpha"], "keywords":["Beta Alpha Tool", "Beta Alpha"], + "bundleId":"org.unique.app" }])); + let hits = catalog.search("alpha", Sensitivity::Medium).unwrap(); + assert_eq!(hits[0].title, Some(EXACT)); + assert_eq!(hits[0].alternate, Some(EXACT)); + assert_eq!(hits[0].subtitle, None); + assert_eq!(catalog.search("beta", Sensitivity::Medium).unwrap()[0].subtitle, Some(EXACT)); + assert_eq!(catalog.search("alpha beta", Sensitivity::High).unwrap()[0].keyword, Some(EXACT)); + assert_eq!(catalog.search("beta alpha", Sensitivity::High).unwrap()[0].keyword, Some(EXACT)); + assert!(catalog.search("unique", Sensitivity::High).unwrap().is_empty()); + assert_eq!(catalog.search("org.unique.app", Sensitivity::High).unwrap()[0].keyword, Some(EXACT)); + } + + #[test] + fn repeated_subtitle_is_suppressed_using_unicode_case_folding() { + let catalog = catalog(json!([{ "title":"CAFÉ", "subtitle":"café" }])); + let hits = catalog.search("café", Sensitivity::High).unwrap(); + assert_eq!(hits[0].title, Some(EXACT)); + assert_eq!(hits[0].subtitle, None); + } + + #[test] + fn query_limit_counts_characters_and_field_limit_counts_bytes() { + let catalog = catalog(json!([{ "title":"é".repeat(MAX_FIELD_BYTES / 2) }])); + assert_eq!(catalog.search(&"é".repeat(MAX_QUERY_CHARS), Sensitivity::Medium).unwrap()[0].title, Some(PREFIX)); + assert!(catalog.search(&"é".repeat(MAX_QUERY_CHARS + 1), Sensitivity::Medium).is_err()); + let mut old = Catalog::default(); + assert!(old.load(serde_json::from_value(json!([{ "title":"é".repeat(MAX_FIELD_BYTES / 2 + 1) }])).unwrap()).is_err()); + } +} diff --git a/search/src/main.rs b/search/src/main.rs new file mode 100644 index 0000000..6c0ef4e --- /dev/null +++ b/search/src/main.rs @@ -0,0 +1,21 @@ +// Vendored matcher also exposes filesystem-search APIs unused by this adapter. +#[allow(dead_code)] +mod backend; +mod engine; +mod protocol; + +use std::io::{self, BufWriter}; +use std::process::ExitCode; + +fn main() -> ExitCode { + let stdin = io::stdin(); + let stdout = io::stdout(); + let mut output = BufWriter::new(stdout.lock()); + match protocol::run(stdin.lock(), &mut output) { + Ok(()) => ExitCode::SUCCESS, + Err(error) => { + eprintln!("omacast-search: {error}"); + ExitCode::FAILURE + } + } +} diff --git a/search/src/protocol.rs b/search/src/protocol.rs new file mode 100644 index 0000000..c463a36 --- /dev/null +++ b/search/src/protocol.rs @@ -0,0 +1,160 @@ +use crate::engine::{Catalog, Entry, Hit, Sensitivity}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::io::{self, BufRead, Read, Write}; + +pub(crate) const MAX_LINE_BYTES: usize = 8 * 1024 * 1024; + +// Input: {"id":1,"entries":[{"title":"Firefox"}]}. +#[derive(Deserialize)] +struct LoadRequest { + id: u64, + entries: Vec, +} + +// Input: {"id":2,"query":"fire","sensitivity":"medium"}. +#[derive(Deserialize)] +struct SearchRequest { + id: u64, + query: String, + #[serde(default)] + sensitivity: Sensitivity, +} + +#[derive(Serialize)] +#[serde(untagged)] +enum Response { + Loaded { id: u64, loaded: usize }, + Hits { id: u64, hits: Vec }, + Error { + #[serde(skip_serializing_if = "Option::is_none")] + id: Option, + error: String, + }, +} + +impl Response { + fn error(id: Option, error: impl Into) -> Self { + Self::Error { id, error: error.into() } + } +} + +fn request(value: Value, catalog: &mut Catalog) -> Result { + let object = value.as_object().ok_or("request must be an object")?; + match (object.contains_key("entries"), object.contains_key("query")) { + (true, false) => { + let request: LoadRequest = serde_json::from_value(value) + .map_err(|error| format!("invalid load request: {error}"))?; + let loaded = catalog.load(request.entries)?; + Ok(Response::Loaded { id: request.id, loaded }) + } + (false, true) => { + let request: SearchRequest = serde_json::from_value(value) + .map_err(|error| format!("invalid search request: {error}"))?; + let hits = catalog.search(&request.query, request.sensitivity)?; + Ok(Response::Hits { id: request.id, hits }) + } + _ => Err("request must contain exactly one of entries or query".into()), + } +} + +// Input is one complete JSON object per line; property order and unknown properties are unrestricted. +fn response(line: &[u8], catalog: &mut Catalog) -> Response { + let value: Value = match serde_json::from_slice(line) { + Ok(value) => value, + Err(error) => return Response::error(None, format!("invalid JSON request: {error}")), + }; + let id = value.get("id").and_then(Value::as_u64); + request(value, catalog).unwrap_or_else(|error| Response::error(id, error)) +} + +fn drain_line(reader: &mut impl BufRead) -> io::Result<()> { + loop { + let bytes = reader.fill_buf()?; + if bytes.is_empty() { + return Ok(()); + } + let newline = bytes.iter().position(|&byte| byte == b'\n'); + let consumed = newline.map_or(bytes.len(), |at| at + 1); + reader.consume(consumed); + if newline.is_some() { + return Ok(()); + } + } +} + +pub(crate) fn run(mut reader: impl BufRead, writer: &mut impl Write) -> io::Result<()> { + let mut catalog = Catalog::default(); + let mut line = Vec::new(); + loop { + line.clear(); + let read = reader.by_ref().take((MAX_LINE_BYTES + 1) as u64).read_until(b'\n', &mut line)?; + if read == 0 { + return Ok(()); + } + if line.last() == Some(&b'\n') { + line.pop(); + } + let reply = if line.len() > MAX_LINE_BYTES { + drain_line(&mut reader)?; + Response::error(None, format!("request line exceeds {MAX_LINE_BYTES} bytes")) + } else { + response(&line, &mut catalog) + }; + serde_json::to_writer(&mut *writer, &reply).map_err(|error| { + io::Error::new(error.io_error_kind().unwrap_or(io::ErrorKind::Other), error) + })?; + writer.write_all(b"\n")?; + writer.flush()?; + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::{BufReader, Cursor}; + + struct BrokenWriter { + fail_flush: bool, + } + + impl Write for BrokenWriter { + fn write(&mut self, bytes: &[u8]) -> io::Result { + if self.fail_flush { + Ok(bytes.len()) + } else { + Err(io::Error::new(io::ErrorKind::BrokenPipe, "closed output")) + } + } + + fn flush(&mut self) -> io::Result<()> { + Err(io::Error::new(io::ErrorKind::BrokenPipe, "closed output")) + } + } + + #[test] + fn write_and_flush_failure_stop_before_reading_next_request() { + let first = b"{\"id\":1,\"entries\":[]}\n"; + let mut input = first.to_vec(); + input.extend_from_slice(b"{\"id\":2,\"query\":\"abc\"}\n"); + for fail_flush in [false, true] { + let mut reader = Cursor::new(&input); + let error = run(&mut reader, &mut BrokenWriter { fail_flush }).unwrap_err(); + assert_eq!(error.kind(), io::ErrorKind::BrokenPipe); + assert_eq!(reader.position(), first.len() as u64); + } + } + + #[test] + fn oversized_drain_crosses_buffer_boundaries_and_preserves_following_line() { + let mut input = vec![b'x'; MAX_LINE_BYTES + 37]; + input.extend_from_slice(b"\n{\"id\":2,\"entries\":[]}\n"); + let mut output = Vec::new(); + run(BufReader::with_capacity(7, Cursor::new(input)), &mut output).unwrap(); + let replies: Vec = output.split(|&byte| byte == b'\n').filter(|line| !line.is_empty()) + .map(|line| serde_json::from_slice(line).unwrap()).collect(); + assert_eq!(replies.len(), 2); + assert!(replies[0]["error"].as_str().unwrap().contains("exceeds")); + assert_eq!(replies[1], serde_json::json!({"id":2,"loaded":0})); + } +} diff --git a/search/tests/protocol.rs b/search/tests/protocol.rs new file mode 100644 index 0000000..3a46727 --- /dev/null +++ b/search/tests/protocol.rs @@ -0,0 +1,235 @@ +use serde_json::{json, Value}; +use std::io::Write; +use std::process::{Command, Stdio}; + +const MAX_LINE_BYTES: usize = 8 * 1024 * 1024; +const MAX_FIELD_BYTES: usize = 16_384; +const MAX_FIELDS: usize = 1_024; +const MAX_ENTRIES: usize = 20_000; + +fn exchange(input: &[u8]) -> Vec { + let mut child = Command::new(env!("CARGO_BIN_EXE_omacast-search")) + .stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped()) + .spawn().unwrap(); + child.stdin.take().unwrap().write_all(input).unwrap(); + let output = child.wait_with_output().unwrap(); + assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr)); + assert!(output.stderr.is_empty()); + assert!(output.stdout.ends_with(b"\n")); + output.stdout.split(|&byte| byte == b'\n').filter(|line| !line.is_empty()) + .map(|line| serde_json::from_slice(line).unwrap()).collect() +} + +fn encode(requests: &[Value]) -> Vec { + let mut input = Vec::new(); + for request in requests { + serde_json::to_writer(&mut input, request).unwrap(); + input.push(b'\n'); + } + input +} + +#[test] +fn reordered_fields_unknown_properties_missing_optional_fields_and_numeric_null_scores() { + let output = exchange(concat!( + "{\"future\":true,\"entries\":[{\"future\":{},\"title\":\"Firefox\"},{\"title\":\"zzzz\"}],\"id\":1}\r\n", + "{\"sensitivity\":\"high\",\"query\":\"FIREFOX\",\"id\":18446744073709551615}\n", + "{\"id\":3,\"query\":\"\"}\n" + ).as_bytes()); + assert_eq!(output, vec![ + json!({"id":1,"loaded":2}), + json!({"id":u64::MAX,"hits":[{"index":0,"title":5_000_000,"alternate":null,"subtitle":null,"keyword":null}]}), + json!({"id":3,"hits":[]}), + ]); +} + +#[test] +fn errors_recover_and_invalid_reload_keeps_previous_entries() { + let output = exchange(concat!( + "{\"id\":0,\"query\":\"\"}\n", + "{\"id\":1,\"entries\":[{\"title\":\"Old\"}]}\n", + "{\"id\":2,\"entries\":[{\"title\":\"New\"},{}]}\n", + "{\"id\":3,\"query\":\"old\"}\n", + "not json\n", + "{\"id\":4,\"query\":\"old\",\"entries\":[]}\n", + "{\"id\":5,\"entries\":[{\"title\":\"New\"}]}\n", + "{\"id\":6,\"query\":\"old\"}\n", + "{\"id\":7,\"query\":\"new\"}\n", + "{\"id\":8,\"entries\":[]}\n", + "{\"id\":9,\"query\":\"new\"}" + ).as_bytes()); + for (at, id) in [(0, 0), (2, 2), (5, 4)] { + assert_eq!(output[at]["id"], id); + assert!(output[at]["error"].is_string()); + } + assert!(output[4].get("id").is_none()); + assert!(output[4]["error"].is_string()); + assert_eq!(output[3]["hits"][0]["title"], 5_000_000); + assert_eq!(output[6], json!({"id":5,"loaded":1})); + assert_eq!(output[7], json!({"id":6,"hits":[]})); + assert_eq!(output[8]["hits"][0]["title"], 5_000_000); + assert_eq!(output[9], json!({"id":8,"loaded":0})); + assert_eq!(output[10], json!({"id":9,"hits":[]})); +} + +#[test] +fn strict_shapes_reject_nulls_wrong_types_and_invalid_ids_then_recover() { + let invalid = vec![ + json!({"id":1,"entries":[{}]}), + json!({"id":2,"entries":[{"title":1}]}), + json!({"id":3,"entries":[{"title":"x","subtitle":null}]}), + json!({"id":4,"entries":[{"title":"x","alternateTitles":null}]}), + json!({"id":5,"entries":[{"title":"x","alternateTitles":[1]}]}), + json!({"id":6,"entries":[{"title":"x","keywords":[false]}]}), + json!({"id":7,"entries":[{"title":"x","bundleId":null}]}), + json!({"id":8,"entries":{}}), + json!({"id":9,"entries":null}), + json!({"id":10,"query":null}), + json!({"id":11,"query":1}), + json!({"id":12,"query":"x","sensitivity":"maximum"}), + json!({"id":13,"query":"x","sensitivity":null}), + json!({"id":14}), + json!({"entries":[]}), + json!({"id":-1,"entries":[]}), + json!({"id":1.5,"entries":[]}), + json!({"id":"1","entries":[]}), + json!({"id":null,"entries":[]}), + json!([]), json!(null), json!(false), + ]; + let mut requests = vec![json!({"id":0,"entries":[{"title":"Keep"}]})]; + requests.extend(invalid.iter().cloned()); + requests.push(json!({"id":99,"query":"keep"})); + let output = exchange(&encode(&requests)); + for (request, reply) in invalid.iter().zip(&output[1..]) { + assert!(reply["error"].is_string(), "{request}: {reply}"); + assert_eq!(reply.get("id").and_then(Value::as_u64), request.get("id").and_then(Value::as_u64)); + } + assert_eq!(output.last().unwrap()["hits"][0]["title"], 5_000_000); +} + +#[test] +fn invalid_utf8_blank_line_and_multiple_json_values_do_not_poison_stream() { + let mut input = b"\xff\n\n{} {}\n".to_vec(); + input.extend_from_slice(b"{\"id\":1,\"entries\":[]}\n{\"id\":2,\"query\":\"\"}\n"); + let output = exchange(&input); + assert_eq!(output.len(), 5); + for reply in &output[..3] { + assert!(reply["error"].is_string()); + assert!(reply.get("id").is_none()); + } + assert_eq!(output[3], json!({"id":1,"loaded":0})); + assert_eq!(output[4], json!({"id":2,"hits":[]})); +} + +#[test] +fn default_medium_and_channel_specific_high_filter_use_real_binary() { + let output = exchange(&encode(&[ + json!({"id":1,"entries":[{"title":"Firefox","alternateTitles":["fierfox"],"subtitle":"Browser","keywords":["Browser"]}]}), + json!({"id":2,"query":"fierfox"}), + json!({"id":3,"query":"fierfox","sensitivity":"high"}), + json!({"id":4,"query":"broxser","sensitivity":"low"}), + json!({"id":5,"query":"broxser","sensitivity":"high"}), + ])); + assert_eq!(output[1]["hits"][0]["title"], 999_000); + assert_eq!(output[2]["hits"][0]["title"], Value::Null); + assert_eq!(output[2]["hits"][0]["alternate"], 5_000_000); + assert_eq!(output[3]["hits"][0]["subtitle"], 999_000); + assert_eq!(output[4]["hits"], json!([])); +} + +#[test] +fn every_input_text_field_checks_byte_limit_before_replacing_catalog() { + let oversized = "x".repeat(MAX_FIELD_BYTES + 1); + let invalid = vec![ + json!({"title":oversized}), + json!({"title":"New","subtitle":oversized}), + json!({"title":"New","alternateTitles":[oversized]}), + json!({"title":"New","keywords":[oversized]}), + json!({"title":"New","bundleId":oversized}), + ]; + let mut requests = vec![json!({"id":0,"entries":[{"title":"Keep"}]})]; + for (at, entry) in invalid.into_iter().enumerate() { + requests.push(json!({"id":at + 1,"entries":[entry]})); + } + requests.push(json!({"id":6,"query":"keep"})); + requests.push(json!({"id":7,"entries":[{"title":"x".repeat(MAX_FIELD_BYTES),"subtitle":"y".repeat(MAX_FIELD_BYTES)}]})); + let output = exchange(&encode(&requests)); + for reply in &output[1..6] { + assert!(reply["error"].as_str().unwrap().contains("16384 bytes")); + } + assert_eq!(output[6]["hits"][0]["title"], 5_000_000); + assert_eq!(output[7], json!({"id":7,"loaded":1})); +} + +#[test] +fn text_field_count_includes_title_optional_scalars_and_each_array_item() { + let output = exchange(&encode(&[ + json!({"id":1,"entries":[{"title":"Keep","keywords":vec![""; MAX_FIELDS - 1]}]}), + json!({"id":2,"entries":[{"title":"New","keywords":vec![""; MAX_FIELDS]}]}), + json!({"id":3,"entries":[{"title":"Keep","subtitle":"","bundleId":"","alternateTitles":["",""],"keywords":vec![""; MAX_FIELDS - 5]}]}), + json!({"id":4,"entries":[{"title":"New","subtitle":"","bundleId":"","alternateTitles":["",""],"keywords":vec![""; MAX_FIELDS - 4]}]}), + json!({"id":5,"query":"keep"}), + ])); + assert_eq!(output[0], json!({"id":1,"loaded":1})); + assert!(output[1]["error"].as_str().unwrap().contains("1024 text fields")); + assert_eq!(output[2], json!({"id":3,"loaded":1})); + assert!(output[3]["error"].as_str().unwrap().contains("1024 text fields")); + assert_eq!(output[4]["hits"][0]["title"], 5_000_000); +} + +#[test] +fn long_loads_accept_maximum_entry_count_and_reject_one_more_atomically() { + let output = exchange(&encode(&[ + json!({"id":1,"entries":vec![json!({"title":"x"}); MAX_ENTRIES]}), + json!({"id":2,"entries":vec![json!({"title":"y"}); MAX_ENTRIES + 1]}), + json!({"id":3,"query":"x","sensitivity":"medium"}), + json!({"id":4,"query":"y"}), + json!({"id":5,"entries":[]}), + ])); + assert_eq!(output[0], json!({"id":1,"loaded":MAX_ENTRIES})); + assert!(output[1]["error"].as_str().unwrap().contains("20000 candidates")); + assert_eq!(output[2]["hits"].as_array().unwrap().len(), MAX_ENTRIES); + assert!(output[2]["hits"].as_array().unwrap().iter().all(|hit| hit["title"] == 5_000_000)); + assert_eq!(output[3], json!({"id":4,"hits":[]})); + assert_eq!(output[4], json!({"id":5,"loaded":0})); +} + +#[test] +fn query_character_limit_errors_then_recovers() { + let output = exchange(&encode(&[ + json!({"id":1,"entries":[{"title":"é".repeat(256)}]}), + json!({"id":2,"query":"é".repeat(256)}), + json!({"id":3,"query":"é".repeat(257)}), + json!({"id":4,"query":"é".repeat(256)}), + ])); + assert_eq!(output[1]["hits"][0]["title"], 5_000_000); + assert!(output[2]["error"].as_str().unwrap().contains("256 characters")); + assert_eq!(output[3]["hits"][0]["title"], 5_000_000); +} + +#[test] +fn wire_limit_accepts_boundary_and_drains_oversized_reload_before_recovery() { + let mut input = encode(&[json!({"id":1,"entries":[{"title":"Keep"}]})]); + let mut boundary = b"{\"id\":2,\"query\":\"\",\"padding\":\"".to_vec(); + boundary.resize(MAX_LINE_BYTES - 2, b'x'); + boundary.extend_from_slice(b"\"}"); + assert_eq!(boundary.len(), MAX_LINE_BYTES); + input.extend_from_slice(&boundary); + input.push(b'\n'); + input.extend_from_slice(b"{\"id\":3,\"entries\":[],\"padding\":\""); + input.extend(std::iter::repeat(b'x').take(MAX_LINE_BYTES * 2)); + input.extend_from_slice(b"\"}\n{\"id\":4,\"query\":\"keep\"}\n"); + let output = exchange(&input); + assert_eq!(output.len(), 4); + assert_eq!(output[1], json!({"id":2,"hits":[]})); + assert!(output[2]["error"].as_str().unwrap().contains("8388608 bytes")); + assert!(output[2].get("id").is_none()); + assert_eq!(output[3]["hits"][0]["title"], 5_000_000); +} + +#[test] +fn oversized_final_line_without_newline_reports_one_error() { + let output = exchange(&vec![b'x'; MAX_LINE_BYTES + 1]); + assert_eq!(output.len(), 1); + assert!(output[0]["error"].as_str().unwrap().contains("8388608 bytes")); +} diff --git a/services/AppFocuser.qml b/services/AppFocuser.qml new file mode 100644 index 0000000..3087f8e --- /dev/null +++ b/services/AppFocuser.qml @@ -0,0 +1,86 @@ +import QtQuick +import Quickshell +import Quickshell.Io +import "../core/RootSearch.js" as RootSearch + +// Opening an app that already has a window focuses that window instead of +// starting another instance [D]. Anything that goes wrong on the way (no +// hyprctl, bad JSON, no match) falls back to launching. +Item { + id: service + property var pending: null + signal launchRequested(var candidate) + + function open(candidate) { + if (!candidate.windowKeys || !candidate.windowKeys.length) { + service.launchRequested(candidate) + return + } + if (service.pending) service.launchRequested(service.pending) + service.pending = candidate + clients.running = false + clients.running = true + } + + // Focusing the window is what moves you to its workspace. Hyprland refuses + // focus to any window while the launcher's layer holds exclusive keyboard + // focus, and refocuses the previous window when it unmaps, so wait (up to a + // second) for the "omacast" layer to go before dispatching. The Lua-config + // dispatcher is tried first and the classic keyword form is the fallback; + // the dispatch is retried for half a second in case it still lands early. + // On floating workspaces focus doesn't raise, so the window is then brought + // to the top the way the dock does it; a window minimized to the dock is + // first moved back to the workspace in front of the user. + function focusWindow(address, minimized) { + Quickshell.execDetached(["bash", "-c", + 'for _ in $(seq 20); do' + + ' hyprctl layers -j 2>/dev/null | grep -q \'"namespace": *"omacast"\' || break;' + + ' sleep 0.05;' + + ' done;' + + ' if [[ $1 == 1 ]]; then' + + ' ws=$(hyprctl activeworkspace -j | jq -r .id);' + + ' hyprctl dispatch "hl.dsp.window.move({ workspace = \\"$ws\\", window = \\"address:$0\\" })" >/dev/null 2>&1' + + ' || hyprctl dispatch movetoworkspacesilent "$ws,address:$0" >/dev/null 2>&1;' + + ' fi;' + + ' for _ in 1 2 3 4 5 6 7 8 9 10; do' + + ' hyprctl dispatch "hl.dsp.focus({ window = \\"address:$0\\" })" >/dev/null 2>&1' + + ' || hyprctl dispatch focuswindow "address:$0" >/dev/null 2>&1;' + + ' if [[ $(hyprctl activewindow -j | jq -r .address) == "$0" ]]; then' + + ' hyprctl dispatch "hl.dsp.window.alter_zorder({ mode = \\"top\\", window = \\"address:$0\\" })" >/dev/null 2>&1' + + ' || hyprctl dispatch alterzorder "top,address:$0" >/dev/null 2>&1;' + + ' exit 0;' + + ' fi;' + + ' sleep 0.05;' + + ' done', + address, minimized ? "1" : "0"]) + } + + Process { + id: clients + command: ["hyprctl", "clients", "-j"] + stdout: StdioCollector { + onStreamFinished: { + var candidate = service.pending + service.pending = null + if (!candidate) return + var address = "" + var minimized = false + try { + var list = JSON.parse(text) + address = RootSearch.findAppWindow(list, candidate.windowKeys) + minimized = list.some(function(c) { + return String(c.address) === address && c.workspace && c.workspace.name === "special:minimized" + }) + } catch (e) {} + if (address) service.focusWindow(address, minimized) + else service.launchRequested(candidate) + } + } + onExited: function(exitCode) { + if (exitCode === 0 || !service.pending) return + var candidate = service.pending + service.pending = null + service.launchRequested(candidate) + } + } +} diff --git a/plugin/services/CalculatorData.qml b/services/CalculatorData.qml similarity index 93% rename from plugin/services/CalculatorData.qml rename to services/CalculatorData.qml index f28d0e6..f192d0a 100644 --- a/plugin/services/CalculatorData.qml +++ b/services/CalculatorData.qml @@ -9,7 +9,7 @@ import "../core/Calculator.js" as Calculator Item { id: data - // $XDG_CACHE_HOME/oma-cast; empty until the directory exists. + // $XDG_CACHE_HOME/omacast; empty until the directory exists. property string cacheDir: "" property bool ratesEnabled: true @@ -50,7 +50,8 @@ Item { var xhr = new XMLHttpRequest() data.request = xhr xhr.onreadystatechange = function() { - if (xhr.readyState !== XMLHttpRequest.DONE || data.request !== xhr) return + // data is null once the plugin reloads with this request in flight. + if (!data || xhr.readyState !== XMLHttpRequest.DONE || data.request !== xhr) return data.request = null requestTimeout.stop() var parsed = null @@ -105,7 +106,7 @@ Item { Process { id: zoneProcess - command: ["bash", "-c", "for z in \"$@\"; do TZ=$z printf '%s %(%z)T\\n' \"$z\" -1; done", "oma-cast-zones"].concat(Calculator.zoneList()) + command: ["bash", "-c", "for z in \"$@\"; do TZ=$z printf '%s %(%z)T\\n' \"$z\" -1; done", "omacast-zones"].concat(Calculator.zoneList()) stdout: StdioCollector { onStreamFinished: { var next = ({}) diff --git a/services/ClipboardWriter.qml b/services/ClipboardWriter.qml new file mode 100644 index 0000000..4df6c6c --- /dev/null +++ b/services/ClipboardWriter.qml @@ -0,0 +1,45 @@ +import QtQuick +import Quickshell.Io + +// Copies (and optionally pastes) text without putting it in any process's +// argv, where other local users could read it from /proc. The text goes to +// wl-copy on stdin; the paste is Shift+Insert, the keystroke Omarchy's own +// omarchy-clipboard-paste-text sends for clipboard-history entries. +Item { + id: writer + + property var queued: null + signal failed(string message) + + function copy(text) { writer.send(String(text), false) } + function paste(text) { writer.send(String(text), true) } + + function send(text, paste) { + if (proc.running) { writer.queued = { text: text, paste: paste }; return } + proc.text = text + proc.command = ["bash", "-lc", paste ? writer.pasteScript : writer.copyScript] + proc.stdinEnabled = true + proc.running = true + } + + // Constant scripts: no caller data ever reaches the command line. + readonly property string copyScript: 'exec wl-copy' + // Wait for the launcher to unmap, as omarchy-clipboard-paste-text does. + readonly property string pasteScript: 'wl-copy || exit; sleep 0.15; wtype -M shift -k Insert -m shift 2>/dev/null || true' + + Process { + id: proc + property string text: "" + onStarted: { + write(proc.text) + proc.text = "" + proc.stdinEnabled = false + } + onExited: function(exitCode) { + if (exitCode !== 0) writer.failed("Could not copy to the clipboard") + var next = writer.queued + writer.queued = null + if (next) writer.send(next.text, next.paste) + } + } +} diff --git a/plugin/services/JsonStore.qml b/services/JsonStore.qml similarity index 100% rename from plugin/services/JsonStore.qml rename to services/JsonStore.qml diff --git a/services/NativeSearch.qml b/services/NativeSearch.qml new file mode 100644 index 0000000..7f8f7c5 --- /dev/null +++ b/services/NativeSearch.qml @@ -0,0 +1,99 @@ +import QtQuick +import Quickshell.Io +import "../core/FleaSearch.js" as FleaSearch + +Item { + id: service + property var entries: [] + property var ids: [] + property string executable: decodeURIComponent(Qt.resolvedUrl("../bin/omacast-search").toString().replace(/^file:\/\//, "")) + property bool disabled: false + property bool loaded: false + property int serial: 0 + property int loadId: 0 + property var pending: null + signal failed(string message) + + function cancel() { + service.pending = null + timeout.stop() + } + + function invalidate() { + var request = service.pending + service.cancel() + service.loaded = false + service.loadId = 0 + if (helper.running) service.load() + if (request) request.callback(null) + } + + function search(query, sensitivity, callback) { + service.cancel() + if (service.disabled || query.length > FleaSearch.MAX_QUERY_LENGTH) { callback(null); return } + service.pending = { id: ++service.serial, query: query, sensitivity: sensitivity, callback: callback } + timeout.restart() + if (!helper.running) helper.running = true + else if (service.loaded) service.send() + } + + function load() { + service.loadId = ++service.serial + helper.write(JSON.stringify({ id: service.loadId, entries: service.entries }) + "\n") + } + + function send() { + var request = service.pending + if (request) helper.write(JSON.stringify({ id: request.id, query: request.query, sensitivity: request.sensitivity }) + "\n") + } + + function fail(message) { + var request = service.pending + service.cancel() + service.disabled = true + service.loaded = false + helper.running = false + service.failed(message) + if (request) request.callback(null) + } + + function receive(line) { + try { + var data = FleaSearch.reply(line) + var request = service.pending + if (data.id !== service.loadId && (!request || data.id !== request.id)) return + if (data.error) throw new Error(String(data.error)) + if (data.id === service.loadId) { + if (data.loaded !== service.entries.length) throw new Error("Search helper loaded the wrong entry count") + service.loaded = true + service.send() + return + } + if (!request || data.id !== request.id) return + var matches = FleaSearch.hits(data, service.ids) + service.cancel() + request.callback(matches) + } catch (error) { service.fail(String(error)) } + } + + onEntriesChanged: service.invalidate() + Component.onDestruction: { service.cancel(); helper.running = false } + + Timer { + id: timeout + interval: 1000 + onTriggered: service.fail("Search helper timed out; using the built-in matcher") + } + + Process { + id: helper + // Bash emits a normal process exit when the helper is missing or cannot execute. + command: ["bash", "-c", 'exec -- "$1"', "omacast-search", service.executable] + stdinEnabled: true + stdout: SplitParser { onRead: function(line) { service.receive(line) } } + onStarted: service.load() + onExited: function(exitCode, exitStatus) { + if (!service.disabled) service.fail("Search helper exited; using the built-in matcher") + } + } +} diff --git a/plugin/services/OmarchyMenuSource.qml b/services/OmarchyMenuSource.qml similarity index 55% rename from plugin/services/OmarchyMenuSource.qml rename to services/OmarchyMenuSource.qml index b621b1a..b2653f1 100644 --- a/plugin/services/OmarchyMenuSource.qml +++ b/services/OmarchyMenuSource.qml @@ -17,9 +17,87 @@ Item { property var providerItems: ({}) property bool loading: guardProc.running || providerProc.running property int revision: 0 + property var guardCompiler: null signal message(string level, string text, var metadata) + function createGuardCompiler() { + try { + var path = source.omarchyPath + "/shell/plugins/menu/MenuModel.js" + var url = "file://" + path.split("/").map(encodeURIComponent).join("/") + source.guardCompiler = Qt.createQmlObject( + "import QtQml\nimport " + JSON.stringify(url) + " as Stock\n" + + "QtObject { function compile(items) { return Stock.guardScript(items) } }", + source, "OmarchyGuardCompiler") + } catch (error) { + source.guardCompiler = null + } + } + + Component.onCompleted: { source.createGuardCompiler(); source.refresh() } + Component.onDestruction: if (source.guardCompiler) source.guardCompiler.destroy() + + function guardItems(tree) { + var items = Object.create(null) + for (var i = 0; i < tree.order.length; i++) { + var id = tree.order[i] + if (!Object.prototype.hasOwnProperty.call(tree.items, id)) continue + var item = tree.items[id] + if (item && /^[A-Za-z0-9._-]+$/.test(item.id)) items[item.id] = item + } + return items + } + + function compileGuards(tree, items) { + if (source.guardCompiler) { + try { + var script = source.guardCompiler.compile(items) + if (typeof script === "string") return script + } catch (error) {} + source.guardCompiler.destroy() + source.guardCompiler = null + } + return OmarchyMenu.guardScript(tree) + } + + function guardKeys(items) { + var keys = [] + for (var id in items) { + if (items[id].when) keys.push(id + ":w") + if (items[id].checked) keys.push(id + ":c") + if (items[id].disabled) keys.push(id + ":d") + } + return keys + } + + function finishGuards(exitCode, exitStatus) { + var tree = guardProc.requestTree + var expected = guardProc.expectedGuards + guardProc.requestTree = null + guardProc.expectedGuards = [] + if (!tree) return + if (tree !== source.tree) { source.refresh(); return } + if (exitCode !== 0 || exitStatus !== 0) { + source.message("warning", "Menu guard refresh failed; keeping previous results.", null) + return + } + var received = Object.create(null) + var lines = guardProc.collected.split("\n") + for (var i = 0; i < lines.length; i++) { + // Guard rows look like system.lock:w:1, with one row per requested condition. + var match = /^([A-Za-z0-9._-]+:[wcd]):[01]$/.exec(lines[i].trim()) + if (match) received[match[1]] = true + } + for (var j = 0; j < expected.length; j++) { + if (!received[expected[j]]) { + source.message("warning", "Menu guard refresh was incomplete; keeping previous results.", null) + return + } + } + source.guards = OmarchyMenu.parseGuardOutput(guardProc.collected) + source.revision++ + } + function rebuild() { source.tree = OmarchyMenu.buildTree(source.defaultItems, source.userItems) source.revision++ @@ -28,9 +106,12 @@ Item { function refresh() { if (!guardProc.running) { - var script = OmarchyMenu.guardScript(source.tree) + var items = source.guardItems(source.tree) + var script = source.compileGuards(source.tree, items) if (script) { guardProc.collected = "" + guardProc.requestTree = source.tree + guardProc.expectedGuards = source.guardKeys(items) guardProc.command = ["bash", "-lc", script] guardProc.running = true } @@ -80,11 +161,11 @@ Item { Process { id: guardProc property string collected: "" + property var requestTree: null + property var expectedGuards: [] stdout: SplitParser { onRead: function(line) { guardProc.collected += line + "\n" } } onExited: function(exitCode, exitStatus) { - if (exitStatus !== 0) return - source.guards = OmarchyMenu.parseGuardOutput(guardProc.collected) - source.revision++ + source.finishGuards(exitCode, exitStatus) } } diff --git a/test-search.qml b/test-search.qml new file mode 100644 index 0000000..1d419ba --- /dev/null +++ b/test-search.qml @@ -0,0 +1,41 @@ +import QtQuick +import Quickshell +import "services" + +ShellRoot { + id: root + property bool expectFallback: Quickshell.env("OMACAST_EXPECT_FALLBACK") === "1" + property int at: 0 + property string failureMessage: "" + property string expectedFailure: Quickshell.env("OMACAST_EXPECT_FAILURE") || "" + property var queries: ["chormium", "cafe", "lcok"] + property var expected: ["chrome", "cafe", "lock"] + + function next() { + if (root.at === root.queries.length) { + console.log("NATIVE-SEARCH-PASS") + Qt.quit() + return + } + search.search(root.queries[root.at], "medium", function(hits) { + var okay = root.expectFallback ? hits === null && !!root.failureMessage : hits && hits[root.expected[root.at]] + if (root.expectedFailure && root.failureMessage.indexOf(root.expectedFailure) < 0) okay = false + if (!okay) { console.error("NATIVE-SEARCH-FAIL", root.queries[root.at]); Qt.exit(1); return } + root.at++ + Qt.callLater(root.next) + }) + } + + NativeSearch { + id: search + onFailed: function(message) { root.failureMessage = message; console.log("NATIVE-SEARCH-FAILURE", message) } + ids: ["chrome", "cafe", "lock"] + entries: [{title: "Chromium"}, {title: "Cafe"}, {title: "Lock"}] + } + + Component.onCompleted: { + var override = Quickshell.env("OMACAST_TEST_HELPER") + if (override) search.executable = override + Qt.callLater(root.next) + } +} diff --git a/tests/agent-lifecycle.test.js b/tests/agent-lifecycle.test.js deleted file mode 100644 index b1f885d..0000000 --- a/tests/agent-lifecycle.test.js +++ /dev/null @@ -1,145 +0,0 @@ -const test = require("node:test") -const assert = require("node:assert/strict") -const fs = require("node:fs") -const path = require("node:path") -const vm = require("node:vm") -const Agent = require("../plugin/core/Agent.js") - -const source = fs.readFileSync(path.join(__dirname, "../plugin/services/AgentLauncher.qml"), "utf8") -function body(text, signature, indentation) { - const start = text.indexOf(signature) - assert.ok(start >= 0, signature) - const open = text.indexOf("{", start) + 1 - const end = text.indexOf("\n" + " ".repeat(indentation) + "}", open) - assert.ok(end > open, signature) - return "(function() {\n" + text.slice(open, end) + "\n})()" -} - -function harness() { - const events = [] - const service = { - checking: false, pendingArgv: null, output: "", - prepared: argv => events.push({ argv }), failed: message => events.push({ message }) - } - const check = { running: false } - const timeout = { restart() {}, stop() {} } - const context = vm.createContext({ service, check, timeout, Agent }) - service.cancel = () => vm.runInContext(body(source, "function cancel()", 2), context) - return { - service, check, events, - start(query) { context.query = query; vm.runInContext(body(source, "function start(query)", 2), context) }, - exit(exitCode, exitStatus = 0) { Object.assign(context, { exitCode, exitStatus }); vm.runInContext(body(source, "onExited: function(exitCode, exitStatus)", 4), context) }, - timeout() { vm.runInContext(body(source, "onTriggered:", 4), context) } - } -} - -test("successful preflight hands off exactly once and prevents overlapping submissions", () => { - const h = harness() - h.start("First question?") - assert.throws(() => h.start("Second question?"), /already being checked/) - assert.deepEqual(h.service.pendingArgv, Agent.launchArgv("First question?")) - h.exit(0) - h.exit(0) - assert.deepEqual(h.events, [{ argv: Agent.launchArgv("First question?") }]) - assert.equal(h.service.checking, false) -}) - -test("closing during preflight suppresses stale success and allows retry after exit", () => { - const h = harness() - h.start("Do not launch") - h.service.cancel() - assert.equal(h.check.running, false) - assert.throws(() => h.start("Wait for teardown"), /already being checked/) - h.exit(0) - assert.deepEqual(h.events, []) - h.start("New question") - h.exit(0) - assert.deepEqual(h.events, [{ argv: Agent.launchArgv("New question") }]) -}) - -test("failure, crash and timeout never prepare a terminal launch", () => { - for (const [code, status] of [[1, 0], [0, 1]]) { - const h = harness() - h.start("Question") - h.service.output = "Choose a default agent first\n" - h.exit(code, status) - assert.deepEqual(h.events, [{ message: "Choose a default agent first" }]) - h.start("Retry") - } - const h = harness() - h.start("Timed out") - h.timeout() - h.exit(0) - assert.deepEqual(h.events, [{ message: "Agent launch check timed out. Try again." }]) -}) - -test("showing refreshes calculator data and hiding cancels an agent hand-off", () => { - const text = fs.readFileSync(path.join(__dirname, "../plugin/Backend.qml"), "utf8") - const events = [] - const context = vm.createContext({ - method: "window.visibilityChanged", data: { visible: true }, - menuSource: { refresh: () => events.push("menu") }, - calculatorData: { refresh: () => events.push("calculator") }, - agentLauncher: { cancel: () => events.push("cancel") } - }) - const notify = body(text, "onNotification: function(method, data)", 2) - vm.runInContext(notify, context) - assert.deepEqual(events, ["menu", "calculator"]) - context.data = { visible: false } - vm.runInContext(notify, context) - assert.deepEqual(events, ["menu", "calculator", "cancel"]) - context.data = null - vm.runInContext(notify, context) - context.method = "calculator.historyStateChanged" - context.data = { visible: true } - vm.runInContext(notify, context) - assert.equal(events.length, 3) -}) - -test("entry point dismisses and clears the launcher before starting the terminal", () => { - const text = fs.readFileSync(path.join(__dirname, "../plugin/Launcher.qml"), "utf8") - const events = [] - const argv = Agent.launchArgv("Question") - const context = vm.createContext({ - method: "agent.launchReady", data: { argv }, - root: { hide: () => events.push("hide") }, - launcher: { popToRoot: () => events.push("clear") }, - Util: { execArgv: value => events.push(value) } - }) - vm.runInContext(body(text, "function onNotification(method, data)", 4), context) - assert.deepEqual(events, ["hide", "clear", argv]) - context.method = "agent.launchFailed" - vm.runInContext(body(text, "function onNotification(method, data)", 4), context) - assert.equal(events.length, 3) -}) - -test("agent shortcut is limited to root search and preserves normal Enter actions", () => { - const text = fs.readFileSync(path.join(__dirname, "../plugin/ui/LauncherWindow.qml"), "utf8") - const events = [] - let next = 16 - const Qt = new Proxy({ ControlModifier: 1, ShiftModifier: 2, AltModifier: 4 }, { - get(target, key) { if (!(key in target)) target[key] = next++; return target[key] } - }) - const window = { - atRoot: true, actionPanelOpen: false, view: { kind: "root" }, - askAgent: () => events.push("agent"), performNth: n => events.push(n), - saveAlias: () => events.push("save-alias"), - argFocus: -1, argDefs: [], - isActionPanelKey: () => false, - direction: () => 0 - } - const context = vm.createContext({ Qt, window, event: null }) - function press(modifiers) { - context.event = { key: Qt.Key_Return, modifiers } - vm.runInContext(body(text, "function handleKey(event)", 2), context) - } - press(Qt.ControlModifier | Qt.AltModifier) - press(0) - press(Qt.ControlModifier) - window.atRoot = false - window.view.kind = "menu" - press(Qt.ControlModifier | Qt.AltModifier) - window.view.kind = "alias" - press(Qt.ControlModifier | Qt.AltModifier) - assert.deepEqual(events, ["agent", 0, 1, 1, "save-alias"]) -}) diff --git a/tests/agent.test.js b/tests/agent.test.js deleted file mode 100644 index 2ad711e..0000000 --- a/tests/agent.test.js +++ /dev/null @@ -1,63 +0,0 @@ -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 Agent = require("../plugin/core/Agent.js") - -function fixture(t) { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), "oma-cast-agent-")) - t.after(() => fs.rmSync(dir, { recursive: true, force: true })) - const script = (name, source) => fs.writeFileSync(path.join(dir, name), "#!/bin/bash\n" + source, { mode: 0o700 }) - for (const tool of ["omarchy-agent", "omarchy-agent-prompt", "omarchy-launch-tui", "uwsm-app", "codex"]) script(tool, "exit 0\n") - script("omarchy-default-agent", 'printf "%s" "${TEST_AGENT-codex}"\n') - script("xdg-terminal-exec", '[[ $1 == --print-id ]] || exit 9\nprintf "%s" "${TEST_TERMINAL-foot.desktop}"\n') - const env = { ...process.env, PATH: dir } - return { dir, env, script, check: extra => spawnSync("/bin/bash", ["-c", Agent.CHECK_SCRIPT], { env: { ...env, ...extra }, encoding: "utf8" }) } -} - -test("preflight accepts configured agents and terminal providers without starting a session", t => { - const f = fixture(t) - f.script("omarchy-agent", "exit 99\n") - for (const terminal of ["foot.desktop", "com.mitchellh.ghostty.desktop", "kitty.desktop", "Alacritty.desktop"]) { - const r = f.check({ TEST_TERMINAL: terminal }) - assert.equal(r.status, 0, r.stdout + r.stderr) - assert.equal(r.stdout, "") - } -}) - -test("preflight distinguishes missing tooling, default agent, installation and terminal", t => { - const f = fixture(t) - for (const [env, message] of [ - [{ TEST_AGENT: "" }, /Choose a default agent/], - [{ TEST_AGENT: "not-installed" }, /default agent is not installed/], - [{ TEST_TERMINAL: "" }, /default terminal/] - ]) { - const r = f.check(env) - assert.equal(r.status, 1) - assert.match(r.stdout, message) - } - fs.unlinkSync(path.join(f.dir, "omarchy-agent-prompt")) - assert.match(f.check().stdout, /missing omarchy-agent-prompt/) -}) - -test("questions remain one literal argument, including CLI flags and shell metacharacters", t => { - const f = fixture(t) - f.script("omarchy-agent", '[[ $# == 2 && $1 == --prompt ]] || exit 8\nprintf "%s" "$2"\n') - const marker = path.join(f.dir, "must-not-exist") - for (const prompt of ["--inline", "--pick", "--prompt", " Why? ", 'Quotes: "hello" and \'hi\'', "line one\nline two 🚀", `$(touch ${marker}); \`touch ${marker}\`; >${marker}`]) { - const argv = Agent.launchArgv(prompt) - const r = spawnSync("/bin/bash", ["-c", 'exec "$@"', "bash", ...argv], { env: f.env, encoding: "utf8" }) - assert.equal(r.status, 0, r.stderr) - assert.equal(r.stdout, prompt) - assert.ok(!fs.existsSync(marker)) - } -}) - -test("empty, NUL and oversized prompts fail before any process starts", () => { - for (const query of [null, undefined, "", " \n\t "]) assert.throws(() => Agent.launchArgv(query), /Type a question/) - assert.throws(() => Agent.launchArgv("a\0b"), /NUL/) - assert.throws(() => Agent.launchArgv("x".repeat(16385)), /characters/) - assert.equal(Agent.launchArgv("x".repeat(16384))[2].length, 16384) -}) diff --git a/tests/calculator.test.js b/tests/calculator.test.js index cd69408..bde8917 100644 --- a/tests/calculator.test.js +++ b/tests/calculator.test.js @@ -1,7 +1,7 @@ const test = require("node:test") const assert = require("node:assert/strict") -const C = require("../plugin/core/Calculator.js") -const RS = require("../plugin/core/RootSearch.js") +const C = require("../core/Calculator.js") +const RS = require("../core/RootSearch.js") // Monday 19 Oct 2026, 16:06 in New York (UTC-4). Dates use the process // zone, so pin it. @@ -164,13 +164,13 @@ test("history is newest first, unique, pruned after 90 days and capped", () => { }) test("root search puts the Calculator section first; ↵ copies, Ctrl ↵ pastes", () => { - const deps = { Ranker: require("../plugin/core/Ranker.js"), Frecency: require("../plugin/core/Frecency.js"), OmarchyMenu: require("../plugin/core/OmarchyMenu.js") } + const deps = { Ranker: require("../core/Ranker.js"), Frecency: require("../core/Frecency.js"), OmarchyMenu: require("../core/OmarchyMenu.js") } const ctx = { commands: {}, frecency: {}, nowMs: NOW, sensitivity: "medium", calculation: C.evaluate("2+2", env) } const sections = RS.rootSections(RS.BUILTINS.map(RS.builtinCandidate), "2+2", ctx, deps) assert.equal(sections[0].id, "calculator") assert.equal(sections[0].rows[0].candidate.calcResult, "4") assert.equal(sections.at(-1).id, "fallback-commands") - assert.deepEqual(sections.at(-1).rows.map((r) => r.candidate.id), ["fallback:google", "fallback:duckduckgo", "fallback:agent"]) + assert.deepEqual(sections.at(-1).rows.map((r) => r.candidate.id), ["fallback:google", "fallback:duckduckgo"]) const actions = RS.actionsFor({ kind: "calculator" }).map((a) => a.id) assert.deepEqual(actions.slice(0, 2), ["copy-answer", "paste-answer"]) const calcHistory = RS.rootSections(RS.BUILTINS.map(RS.builtinCandidate), "calc hist", { commands: {}, frecency: {}, nowMs: NOW }, deps) 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/frecency.test.js b/tests/frecency.test.js index 6159b79..b29a798 100644 --- a/tests/frecency.test.js +++ b/tests/frecency.test.js @@ -1,6 +1,6 @@ const test = require("node:test") const assert = require("node:assert/strict") -const F = require("../plugin/core/Frecency.js") +const F = require("../core/Frecency.js") const T = 1_800_000_000 const DAY = 86400 diff --git a/tests/launcher-paste.test.js b/tests/launcher-paste.test.js index bccb929..68e8645 100644 --- a/tests/launcher-paste.test.js +++ b/tests/launcher-paste.test.js @@ -4,7 +4,7 @@ const fs = require("node:fs") const path = require("node:path") const vm = require("node:vm") -const source = fs.readFileSync(path.join(__dirname, "../plugin/ui/LauncherWindow.qml"), "utf8") +const source = fs.readFileSync(path.join(__dirname, "../ui/LauncherWindow.qml"), "utf8") function body(signature) { const start = source.indexOf(signature) @@ -25,7 +25,7 @@ function harness(text) { rebuild: reset => changes.push(reset), isTyping: () => false, isActionPanelKey: () => false, direction: () => 0 } - const actionPanel = { filter: "" } + const actionPanel = { item: { filter: "" } } const clipboardInput = { text: "", clear() { this.text = "" }, paste() { this.text = text } } const context = vm.createContext({ Qt, window, actionPanel, clipboardInput, Util: { editsFilter: () => false } }) window.setQuery = next => { @@ -97,9 +97,9 @@ test("clipboard paste also works in submenu queries and alias editing", () => { test("action-panel paste edits its filter without changing the query", () => { const h = harness("Copy\nResult") h.window.actionPanelOpen = true - h.actionPanel.filter = "Find " + h.actionPanel.item.filter = "Find " h.press("V", h.Qt.ControlModifier) - assert.equal(h.actionPanel.filter, "Find Copy Result") + assert.equal(h.actionPanel.item.filter, "Find Copy Result") assert.equal(h.window.query, "prefix ") assert.deepEqual(h.changes, []) }) diff --git a/tests/loading-feedback.test.js b/tests/loading-feedback.test.js new file mode 100644 index 0000000..30b6474 --- /dev/null +++ b/tests/loading-feedback.test.js @@ -0,0 +1,100 @@ +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 source = fs.readFileSync(path.join(__dirname, "../ui/LauncherWindow.qml"), "utf8") + +// QML block sample: " onBusyChanged: {\n loadingDelay.stop()\n }". +function qmlBlock(signature) { + const start = source.indexOf(signature) + assert.ok(start >= 0, "missing QML block: " + signature) + const end = source.indexOf("\n }", start) + assert.ok(end > start, "unterminated QML block: " + signature) + return source.slice(start, end + 4) +} + +test("loading feedback delays fast work, preserves toasts and resets across hide and reopen", { skip: !process.env.OMACAST_QML_RUNNER }, t => { + const parent = fs.realpathSync(os.tmpdir()) + const root = fs.mkdtempSync(path.join(parent, "omacast-loading-probe-")) + const marker = path.join(root, ".omacast-loading-probe") + fs.writeFileSync(marker, "loading feedback fixture\n") + t.after(() => { + assert.ok(path.isAbsolute(root)) + assert.equal(path.dirname(root), parent) + assert.ok(path.basename(root).startsWith("omacast-loading-probe-")) + assert.equal(fs.readFileSync(marker, "utf8"), "loading feedback fixture\n") + fs.rmSync(root, { recursive: true, force: true }) + }) + const declarations = ["busy", "loadingVisible", "loadingFeedbackDelay"].map(name => { + // Property sample: " readonly property int loadingFeedbackDelay: 500". + const declaration = source.match(new RegExp("^ (?:readonly )?property (?:bool|int) " + name + ":.+$", "m")) + assert.ok(declaration, "missing loading property: " + name) + return declaration[0] + }).join("\n") + // Footer sample: "id: footerStatus\n text: window.toast ? window.toast.title : window.view.title". + const footer = source.match(/id: footerStatus\s+[\s\S]*?text: ([^\n]+)/) + assert.ok(footer, "missing footer status binding") + const env = { ...process.env, HOME: root, TMPDIR: root, QT_QPA_PLATFORM: "offscreen", QML_DISABLE_DISK_CACHE: "1" } + for (const key of ["XDG_CACHE_HOME", "XDG_CONFIG_HOME", "XDG_DATA_HOME", "XDG_STATE_HOME", "XDG_RUNTIME_DIR"]) { + env[key] = path.join(root, key) + fs.mkdirSync(env[key], { mode: 0o700 }) + } + const config = path.join(root, "shell.qml") + fs.writeFileSync(config, `import QtQuick +import Quickshell +ShellRoot { + id: window + property QtObject backend: QtObject { property bool loading: false } + property bool shown: false + property var toast: null + property var view: ({ title: "omacast" }) + ${declarations} + ${qmlBlock("onBusyChanged: {")} + ${qmlBlock("Timer {\n id: loadingDelay")} + property string footerText: ${footer[1]} + property int stage: 0 + function check(ok, detail) { if (!ok) throw new Error(detail) } + property var stages: [ + { wait: 50, run: function() { check(!window.loadingVisible && window.footerText === "omacast", "fast work flashed"); backend.loading = false } }, + { wait: loadingFeedbackDelay + 80, run: function() { check(!window.loadingVisible, "completed work left a pending cue"); backend.loading = true } }, + { wait: loadingFeedbackDelay + 80, run: function() { check(window.loadingVisible && window.footerText === "Loading…", "slow work lacks feedback"); window.toast = { title: "Copied" } } }, + { wait: 30, run: function() { check(window.footerText === "Copied", "toast lost priority"); window.toast = null; backend.loading = false } }, + { wait: 30, run: function() { check(!window.loadingVisible && window.footerText === "omacast", "completion did not restore context"); backend.loading = true } }, + { wait: 100, run: function() { window.shown = false } }, + { wait: loadingFeedbackDelay + 80, run: function() { check(!window.loadingVisible, "hidden window retained feedback"); window.shown = true } }, + { wait: 100, run: function() { check(!window.loadingVisible, "reopen reused an old delay") } }, + { wait: loadingFeedbackDelay + 80, run: function() { check(window.loadingVisible, "reopen lost persistent loading"); window.shown = false } }, + { wait: 30, run: function() { check(!window.loadingVisible, "close retained visible feedback"); backend.loading = false; window.shown = true } }, + { wait: loadingFeedbackDelay + 80, run: function() { check(!window.loadingVisible && window.footerText === "omacast", "idle reopen flashed"); console.log("OMACAST_LOADING_PROBE:OK"); Qt.quit() } } + ] + Timer { + id: tick + onTriggered: { + try { + window.stages[window.stage].run() + window.stage++ + if (window.stage < window.stages.length) { + tick.interval = window.stages[window.stage].wait + tick.restart() + } + } catch (error) { console.error("OMACAST_LOADING_PROBE:FAIL " + error); Qt.quit() } + } + } + Component.onCompleted: Qt.callLater(function() { + window.shown = true + backend.loading = true + tick.interval = window.stages[0].wait + tick.start() + }) +} +`) + const result = spawnSync(process.env.OMACAST_QML_RUNNER, ["-p", config], { cwd: root, env, encoding: "utf8", timeout: 10000, killSignal: "SIGKILL" }) + assert.ifError(result.error) + const output = result.stdout + result.stderr + assert.equal(result.status, 0, output) + assert.match(output, /OMACAST_LOADING_PROBE:OK/) + assert.doesNotMatch(output, /OMACAST_LOADING_PROBE:FAIL/) +}) diff --git a/tests/log.test.js b/tests/log.test.js index 1d74c36..1ac7eee 100644 --- a/tests/log.test.js +++ b/tests/log.test.js @@ -1,6 +1,6 @@ const test = require("node:test") const assert = require("node:assert/strict") -const Log = require("../plugin/core/Log.js") +const Log = require("../core/Log.js") test("levels filter by threshold, unknown levels count as info", () => { assert.ok(Log.shouldLog("error", "info")) @@ -10,6 +10,6 @@ test("levels filter by threshold, unknown levels count as info", () => { }) test("lines carry level, category and one-line metadata", () => { - assert.equal(Log.format("warning", "store", "slow", { ms: 3 }), '[oma-cast] [warning] [store] slow {"ms":3}') - assert.equal(Log.format("info", "", "hi"), "[oma-cast] [info] hi") + assert.equal(Log.format("warning", "store", "slow", { ms: 3 }), '[omacast] [warning] [store] slow {"ms":3}') + assert.equal(Log.format("info", "", "hi"), "[omacast] [info] hi") }) diff --git a/tests/marketplace.test.js b/tests/marketplace.test.js new file mode 100644 index 0000000..f06091e --- /dev/null +++ b/tests/marketplace.test.js @@ -0,0 +1,45 @@ +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") + +// The repo root is what `omarchy plugin add` installs. These mirror +// omarchy-plugin-validate and the marketplace's SUBMISSION.md, so CI catches +// a broken listing without Omarchy installed. +const root = path.join(__dirname, "..") +const manifest = JSON.parse(fs.readFileSync(path.join(root, "manifest.json"), "utf8")) + +test("manifest has every field the marketplace lists", () => { + assert.equal(manifest.schemaVersion, 1) + for (const field of ["id", "name", "version", "author", "description", "kinds", "entryPoints"]) { + assert.ok(manifest[field], `missing ${field}`) + } + assert.equal(manifest.id, "typecraft.omacast") + assert.equal(manifest.name, "omacast") + assert.match(manifest.id, /^[A-Za-z0-9][A-Za-z0-9._-]*$/) + assert.ok(!manifest.id.startsWith("omarchy.")) + assert.ok(manifest.version.length <= 64) +}) + +test("every kind has an entry point that exists", () => { + for (const kind of manifest.kinds) { + const entry = manifest.entryPoints[kind] + assert.ok(entry, `kind ${kind} has no entry point`) + assert.ok(!path.isAbsolute(entry) && !entry.includes("..")) + assert.ok(fs.statSync(path.join(root, entry)).isFile(), `${entry} not found`) + } +}) + +test("root has a README and a license", () => { + assert.ok(fs.existsSync(path.join(root, "README.md"))) + assert.match(fs.readFileSync(path.join(root, "LICENSE"), "utf8"), /^MIT License/) +}) + +const inGit = fs.existsSync(path.join(root, ".git")) + +test("no tracked symlinks, which the shell refuses inside a plugin", { skip: !inGit && "not a git checkout" }, () => { + const files = execFileSync("git", ["ls-files", "-s"], { cwd: root, encoding: "utf8" }) + const links = files.split("\n").filter((line) => line.startsWith("120000")) + assert.deepEqual(links, []) +}) diff --git a/tests/menu-compatibility.test.js b/tests/menu-compatibility.test.js new file mode 100644 index 0000000..e80117d --- /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("../core/OmarchyMenu.js") +const RootSearch = require("../core/RootSearch.js") +const Ranker = require("../core/Ranker.js") +const Frecency = require("../core/Frecency.js") + +const deps = { OmarchyMenu, Ranker, Frecency } +const backendSource = fs.readFileSync(path.join(__dirname, "../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(), "omacast-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/migrate.test.js b/tests/migrate.test.js index 7e14d7b..0e27640 100644 --- a/tests/migrate.test.js +++ b/tests/migrate.test.js @@ -1,7 +1,7 @@ const test = require("node:test") const assert = require("node:assert/strict") -const Migrate = require("../plugin/core/Migrate.js") -const Schema = require("../plugin/core/Schema.js") +const Migrate = require("../core/Migrate.js") +const Schema = require("../core/Schema.js") const migrations = [ { version: 1, name: "init", up: (d) => { d.items = []; return d } }, diff --git a/tests/native-search.test.js b/tests/native-search.test.js new file mode 100644 index 0000000..5d0b6cf --- /dev/null +++ b/tests/native-search.test.js @@ -0,0 +1,143 @@ +const test = require("node:test") +const assert = require("node:assert/strict") +const fs = require("node:fs") +const path = require("node:path") +const vm = require("node:vm") +const F = require("../core/FleaSearch.js") +const R = require("../core/Ranker.js") + +const source = fs.readFileSync(path.join(__dirname, "../services/NativeSearch.qml"), "utf8") +function harness() { + const events = [], writes = [] + const service = { entries: [{ title: "Files" }], ids: ["files"], serial: 0, loadId: 0, loaded: false, disabled: false, pending: null, failed: m => events.push(m) } + const helper = { running: false, write: s => writes.push(JSON.parse(s)) } + const timeout = { active: false, restart() { this.active = true }, stop() { this.active = false } } + const context = vm.createContext({ service, helper, timeout, FleaSearch: F }) + for (const name of ["cancel", "invalidate", "search", "load", "send", "fail", "receive"]) { + const start = source.indexOf(" function " + name + "(") + const end = source.indexOf("\n }", start) + 4 + vm.runInContext(source.slice(start, end).trim(), context) + service[name] = context[name] + } + return { service, helper, timeout, events, writes, reply: obj => service.receive(JSON.stringify(obj)) } +} +const hit = { index: 0, title: 4000000, alternate: null, subtitle: null, keyword: null } + +test("native service waits for load, suppresses stale replies, and cancels hidden queries", () => { + const h = harness(), got = [] + h.service.search("fi", "medium", r => got.push(r)) + const first = h.service.pending.id + h.service.load() + h.reply({ id: h.service.loadId, loaded: 1 }) + assert.equal(h.writes.at(-1).query, "fi") + h.service.search("files", "medium", r => got.push(r)) + h.reply({ id: first, hits: [hit] }) + assert.equal(got.length, 0) + h.reply({ id: h.service.pending.id, hits: [hit] }) + assert.equal(got[0].files.title, 4000000) + assert.equal(h.timeout.active, false) + h.service.search("new", "medium", r => got.push(r)) + const hidden = h.service.pending.id + h.service.cancel() + h.reply({ id: hidden, hits: [hit] }) + assert.equal(got.length, 1) +}) + +test("candidate reload cannot apply a reply using the previous index", () => { + const h = harness(), got = [] + h.service.loaded = true + h.helper.running = true + h.service.search("fi", "medium", r => got.push(r)) + const stale = h.service.pending.id + h.service.entries = [{ title: "Firefox" }] + h.service.ids = ["firefox"] + h.service.invalidate() + assert.deepEqual(got, [null]) + h.reply({ id: stale, hits: [hit] }) + assert.deepEqual(got, [null]) + h.service.search("fire", "medium", r => got.push(r)) + h.reply({ id: h.service.loadId, loaded: 1 }) + h.reply({ id: h.service.pending.id, hits: [hit] }) + assert.equal(got[1].firefox.title, 4000000) +}) + +test("malformed replies, invalid scores and service failures all permit fallback", () => { + for (const reply of ["not json", '{"id":1}', JSON.stringify({ id: 1, hits: [{ ...hit, title: "500" }] })]) { + const h = harness(), got = [] + h.service.search("bad", "medium", r => got.push(r)) + h.service.receive(reply) + assert.deepEqual(got, [null]) + assert.equal(h.service.disabled, true) + assert.equal(h.helper.running, false) + } + const h = harness(), got = [] + h.service.search("one", "medium", r => got.push(r)) + h.service.fail("missing executable or timeout") + h.service.fail("late exit") + h.service.search("two", "medium", r => got.push(r)) + assert.deepEqual(got, [null, null]) +}) + +test("protocol rejects duplicate entries and bounds, and folds native input like the ranker", () => { + assert.throws(() => F.hits({ hits: [hit, hit] }, ["x"]), /repeats/) + assert.throws(() => F.hits({ hits: [{ ...hit, index: 1 }] }, ["x"]), /index/) + assert.throws(() => F.hits({ hits: [{ index: 0 }] }, ["x"]), /score/) + assert.equal(F.entries([{ title: "Café", keywords: ["Straße"] }], R.transliterate)[0].keywords[0], "Strasse") +}) + +test("native scores retain exact aliases, learning, pins, alternate names and frecency", () => { + const c = [ + { id: "long", title: "Firefox Developer Edition", type: "application" }, + { id: "short", title: "Firefox", type: "application" }, + { id: "alias", title: "Unrelated", alias: "fir", type: "application" } + ] + const scores = { long: { ...hit }, short: { ...hit } } + const ids = extra => R.rank(c, "fir", { fleaHits: scores, nowMs: 1000, ...extra }).map(r => r.candidate.id) + assert.deepEqual(ids(), ["alias", "short", "long"]) + c[0].frecency = 100 + assert.deepEqual(ids(), ["alias", "long", "short"]) + c[1].pinPriority = 1 + assert.equal(ids()[0], "short") + delete c[1].pinPriority + c[1].frecency = 100 + assert.equal(ids()[1], "short") + c[0].openedAt = 1000 + c[0].searchTerms = ["fir"] + assert.equal(ids()[1], "long") + scores.short = { ...hit, title: null, alternate: 5000000 } + assert.equal(R.buildRecord(c[1], "fir", { fleaHits: scores }).titleMatch, "exact") +}) + + +test("obsolete errors cannot disable a newer valid request", () => { + const h = harness(), got = [] + h.service.loaded = true + h.helper.running = true + h.service.search("old", "unsupported", r => got.push(r)) + const old = h.service.pending.id + h.service.search("new", "medium", r => got.push(r)) + h.reply({ id: old, error: "unsupported sensitivity" }) + assert.equal(h.service.disabled, false) + assert.equal(got.length, 0) + h.reply({ id: h.service.pending.id, hits: [hit] }) + assert.equal(got.length, 1) +}) + +test("all-null hits trigger fallback instead of phantom results", () => { + const invalid = harness(), got = [] + invalid.service.search("fi", "medium", r => got.push(r)) + invalid.reply({ id: invalid.service.pending.id, hits: [{ ...hit, title: null }] }) + assert.equal(invalid.service.disabled, true) + assert.equal(got.at(-1), null) +}) + +test("keyword-only tiers rank exact ahead of typo while titles keep priority", () => { + const c = [{ id: "typo", title: "Alpha", frecency: 100 }, { id: "exact", title: "Zulu", frecency: 1 }] + const fleaHits = { + typo: { title: null, alternate: null, subtitle: null, keyword: 999000 }, + exact: { title: null, alternate: null, subtitle: null, keyword: 5000000 } + } + assert.deepEqual(R.rank(c, "browser", { fleaHits }).map(r => r.candidate.id), ["exact", "typo"]) + fleaHits.typo.title = 2000001 + assert.equal(R.rank(c, "browser", { fleaHits })[0].candidate.id, "typo") +}) diff --git a/tests/native-transport.sh b/tests/native-transport.sh new file mode 100755 index 0000000..fe69106 --- /dev/null +++ b/tests/native-transport.sh @@ -0,0 +1,52 @@ +#!/bin/bash +set -euo pipefail + +repo=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd -P) +command -v qs >/dev/null || { echo 'native transport tests need Quickshell' >&2; exit 1; } +[[ -x $repo/bin/omacast-search ]] || { echo 'run bin/build-search before native transport tests' >&2; exit 1; } +fixture=$(mktemp -d /tmp/omacast-search.XXXXXXXX) +touch "$fixture/.omacast-search-test" +cleanup() { + [[ $fixture == /tmp/omacast-search.* && -f $fixture/.omacast-search-test ]] || return 1 + rm -rf -- "$fixture" +} +trap cleanup EXIT + +printf '#!/bin/bash\nread -r line\nexit 0\n' > "$fixture/exit0" +printf '#!/bin/bash\nread -r line\nexit 1\n' > "$fixture/exit1" +printf '#!/bin/bash\nread -r line\nprintf "bad json\\n"\nexec sleep 10\n' > "$fixture/malformed" +printf '#!/bin/bash\nread -r line\nexec sleep 10\n' > "$fixture/hang" +printf '#!/bin/bash\nexit 0\n' > "$fixture/nonexecutable" +chmod +x "$fixture/exit0" "$fixture/exit1" "$fixture/malformed" "$fixture/hang" +chmod 600 "$fixture/nonexecutable" + +for name in native missing nonexecutable exit0 exit1 malformed hang; do + helper="$fixture/$name" + fallback=1 + expected="" + case $name in + missing | nonexecutable | exit0 | exit1) expected="exited" ;; + malformed) expected="SyntaxError" ;; + hang) expected="timed out" ;; + esac + if [[ $name == native ]]; then helper="$repo/bin/omacast-search"; fallback=0; fi + OMACAST_TEST_HELPER="$helper" OMACAST_EXPECT_FALLBACK="$fallback" OMACAST_EXPECT_FAILURE="$expected" QT_QPA_PLATFORM=offscreen \ + timeout 8 qs -p "$repo/test-search.qml" > "$fixture/$name.log" 2>&1 || { + cat "$fixture/$name.log" >&2 + echo "native transport test failed: $name" >&2 + exit 1 + } + grep -q NATIVE-SEARCH-PASS "$fixture/$name.log" || { cat "$fixture/$name.log" >&2; exit 1; } + echo "native transport PASS $name" +done + +mkdir -p "$fixture/path with spaces" +cp -R "$repo/plugin" "$fixture/path with spaces/plugin" +cp "$repo/test-search.qml" "$fixture/path with spaces/test-search.qml" +QT_QPA_PLATFORM=offscreen timeout 8 qs -p "$fixture/path with spaces/test-search.qml" > "$fixture/path.log" 2>&1 || { + cat "$fixture/path.log" >&2 + echo 'native transport test failed: default helper path with spaces' >&2 + exit 1 +} +grep -q NATIVE-SEARCH-PASS "$fixture/path.log" || { cat "$fixture/path.log" >&2; exit 1; } +echo 'native transport PASS default helper path with spaces' diff --git a/tests/omarchy-menu.test.js b/tests/omarchy-menu.test.js index e4c2103..0810176 100644 --- a/tests/omarchy-menu.test.js +++ b/tests/omarchy-menu.test.js @@ -1,8 +1,9 @@ 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") +const M = require("../core/OmarchyMenu.js") const sample = `{ // comment line @@ -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/omarchy-native-guards.test.js b/tests/omarchy-native-guards.test.js new file mode 100644 index 0000000..c4ba8f1 --- /dev/null +++ b/tests/omarchy-native-guards.test.js @@ -0,0 +1,332 @@ +const test = require("node:test") +const assert = require("node:assert/strict") +const fs = require("node:fs") +const path = require("node:path") +const vm = require("node:vm") +const { spawnSync } = require("node:child_process") +const { fileURLToPath, pathToFileURL } = require("node:url") +const OmarchyMenu = require("../core/OmarchyMenu.js") + +const sourcePath = path.join(__dirname, "../services/OmarchyMenuSource.qml") +const text = fs.readFileSync(sourcePath, "utf8") +const methods = ["createGuardCompiler", "guardItems", "compileGuards", "guardKeys", "finishGuards", "refresh"] + +// QML block sample: " function refresh() {\n }". +function block(signature, indentation) { + const start = text.indexOf(signature) + assert.ok(start >= 0, signature) + const open = text.indexOf("{", start) + const end = text.indexOf("\n" + " ".repeat(indentation) + "}", open) + indentation + 2 + assert.ok(end > open, signature) + return text.slice(start, end) +} + +function method(name) { + return block("function " + name + "(", 2) +} + +function sampleTree() { + return OmarchyMenu.buildTree(OmarchyMenu.parse(JSON.stringify({ + probe: { when: "true", checked: "false", disabled: "custom-check --literal '*'" }, + other: { when: "false" } + })).items, []) +} + +function harness(factory = () => ({ compile: () => "stock-script", destroy() {} })) { + const imports = [] + const messages = [] + const source = { + omarchyPath: '/opt/omarchy space "double" \'single\' #percent%', + guardCompiler: null, tree: sampleTree(), guards: { previous: true }, revision: 7, + message: (...args) => messages.push(args), providerMenus: () => [], startNextProvider() {} + } + const guardProc = { running: false, collected: "", requestTree: null, expectedGuards: [] } + const context = vm.createContext({ + source, guardProc, providerProc: { running: true }, OmarchyMenu, + Qt: { createQmlObject: (...args) => { imports.push(args); return factory(...args) } } + }) + for (const name of methods) { + vm.runInContext(method(name), context) + source[name] = context[name] + } + return { + source, guardProc, imports, messages, + start() { + vm.runInContext(text.match(/Component.onCompleted: (\{[^\n]+\})/)[1], context) + }, + exit(code, status = 0, output = "probe:w:1\nprobe:c:0\nprobe:d:1\nother:w:0\n") { + guardProc.running = false + guardProc.collected = output + Object.assign(context, { exitCode: code, exitStatus: status }) + const handler = block("onExited: function(exitCode, exitStatus)", 4) + vm.runInContext("(" + handler.slice(handler.indexOf("function")) + ")(exitCode, exitStatus)", context) + }, + destroy() { + vm.runInContext(text.match(/Component.onDestruction: ([^\n]+)/)[1], context) + } + } +} + +test("startup can use the legacy fallback, then imports OEM once and compiles every refresh", () => { + const calls = [] + const h = harness(() => ({ compile: items => { calls.push(items); return "stock-" + calls.length }, destroy() {} })) + const { source, guardProc } = h + assert.equal(source.compileGuards(source.tree, source.guardItems(source.tree)), OmarchyMenu.guardScript(source.tree)) + assert.equal(h.imports.length, 0) + h.start() + assert.equal(h.imports.length, 1) + const [code, parent, name] = h.imports[0] + const url = JSON.parse(code.match(/^import QtQml\nimport ("[^\n]+") as Stock\n/)[1]) + assert.equal(fileURLToPath(url), source.omarchyPath + "/shell/plugins/menu/MenuModel.js") + assert.equal(parent, source) + assert.equal(name, "OmarchyGuardCompiler") + assert.deepEqual(Array.from(guardProc.command), ["bash", "-lc", "stock-1"]) + assert.equal(guardProc.requestTree, source.tree) + assert.deepEqual(Array.from(guardProc.expectedGuards), ["probe:w", "probe:c", "probe:d", "other:w"]) + h.exit(0) + source.tree = sampleTree() + source.tree.items.probe.when = "fresh-custom-check" + source.refresh() + assert.equal(h.imports.length, 1) + assert.equal(calls.length, 2) + assert.equal(calls[1].probe.when, "fresh-custom-check") + assert.equal(guardProc.command[2], "stock-2") +}) + +test("OEM input preserves legacy ID filtering and uses own keys even for prototype names", () => { + let received + const h = harness(() => ({ compile: items => { received = items; return "stock" }, destroy() {} })) + const items = Object.create({ inherited: { id: "inherited", when: "true" } }) + for (const [key, id] of [["probe", "probe"], ["alias", "actual.id"], ["constructor", "constructor"], ["__proto__", "__proto__"], ["hasOwnProperty", "hasOwnProperty"], ["bad", "bad'; echo injected"], ["newline", "bad\nline"], ["empty", ""]]) { + Object.defineProperty(items, key, { value: { id, when: "true" }, enumerable: true }) + } + h.source.tree = { items, order: [...Object.keys(items), "inherited", "toString"] } + h.start() + assert.equal(Object.getPrototypeOf(received), null) + assert.deepEqual(Object.keys(received), ["probe", "actual.id", "constructor", "__proto__", "hasOwnProperty"]) + assert.equal(received.__proto__, items.__proto__) + assert.equal(received.constructor, items.constructor) + assert.equal(Object.getPrototypeOf(items).inherited.id, "inherited") +}) + +test("missing OEM import falls back without retrying each refresh", () => { + const h = harness(() => { throw new Error("No MenuModel.js on this Omarchy") }) + h.start() + assert.equal(h.source.guardCompiler, null) + assert.equal(h.guardProc.command[2], OmarchyMenu.guardScript(h.source.tree)) + h.exit(0) + h.source.refresh() + assert.equal(h.imports.length, 1) + assert.equal(h.guardProc.command[2], OmarchyMenu.guardScript(h.source.tree)) + h.destroy() +}) + +test("throwing or non-string OEM compilers are destroyed once and use unchanged fallback", () => { + for (const result of [new Error("incompatible compiler"), undefined, null, 17, {}, new String("boxed")]) { + let destroyed = 0 + let compiled = 0 + const h = harness(() => ({ + compile() { compiled++; if (result instanceof Error) throw result; return result }, + destroy() { destroyed++ } + })) + h.start() + assert.equal(h.guardProc.command[2], OmarchyMenu.guardScript(h.source.tree)) + assert.equal(h.source.guardCompiler, null) + h.exit(0) + h.source.refresh() + h.destroy() + assert.equal(compiled, 1) + assert.equal(destroyed, 1) + assert.equal(h.imports.length, 1) + } +}) + +test("component destruction releases a working optional compiler", () => { + let destroyed = 0 + const h = harness(() => ({ compile: () => "stock", destroy() { destroyed++ } })) + h.start() + h.destroy() + assert.equal(destroyed, 1) +}) + +test("only successful complete guard responses publish once", () => { + const h = harness() + h.start() + h.exit(0) + assert.deepEqual(h.source.guards, { when: { probe: true, other: false }, checked: { probe: false }, disabled: { probe: true } }) + assert.equal(h.source.revision, 8) + assert.deepEqual(h.messages, []) + h.exit(0) + assert.equal(h.source.revision, 8) +}) + +test("nonzero exit or abnormal termination retains previous guards and warns once", () => { + for (const [code, status] of [[1, 0], [0, 1], [9, 1]]) { + const h = harness() + const previous = h.source.guards + h.start() + h.exit(code, status) + assert.equal(h.source.guards, previous) + assert.equal(h.source.revision, 7) + assert.equal(h.messages.length, 1) + assert.equal(h.messages[0][0], "warning") + assert.match(h.messages[0][1], /refresh failed/) + } +}) + +test("exit zero with missing or malformed rows retains previous guards", () => { + const rows = ["probe:w:1", "probe:c:0", "probe:d:1", "other:w:0"] + for (let missing = 0; missing < rows.length; missing++) { + const h = harness() + const previous = h.source.guards + h.start() + h.exit(0, 0, [...rows.filter((row, i) => i !== missing), rows[(missing + 1) % rows.length], "probe:d:2", "bad id:w:1"].join("\n")) + assert.equal(h.source.guards, previous) + assert.equal(h.source.revision, 7) + assert.equal(h.messages.length, 1) + assert.match(h.messages[0][1], /incomplete/) + } + const empty = harness() + const previous = empty.source.guards + empty.start() + empty.exit(0, 0, "") + assert.equal(empty.source.guards, previous) +}) + +test("expected tags are captured per request rather than read from changed guard fields", () => { + const h = harness() + const previous = h.source.guards + h.start() + h.source.tree.items.probe.disabled = "" + h.exit(0, 0, "probe:w:1\nprobe:c:0\nother:w:0\n") + assert.equal(h.source.guards, previous) + assert.match(h.messages[0][1], /incomplete/) +}) + +test("a response for an old tree is discarded and the current tree is refreshed", () => { + const h = harness() + const previous = h.source.guards + h.start() + const requested = h.guardProc.requestTree + h.source.tree = OmarchyMenu.buildTree(OmarchyMenu.parse('{"latest":{"checked":"true"}}').items, []) + h.source.refresh() + assert.equal(h.guardProc.requestTree, requested) + h.exit(0) + assert.equal(h.source.guards, previous) + assert.equal(h.source.revision, 7) + assert.equal(h.guardProc.requestTree, h.source.tree) + assert.deepEqual(Array.from(h.guardProc.expectedGuards), ["latest:c"]) + assert.equal(h.guardProc.running, true) + h.exit(0, 0, "latest:c:1\n") + assert.deepEqual(h.source.guards, { when: {}, checked: { latest: true }, disabled: {} }) + assert.equal(h.source.revision, 8) +}) + +test("empty guard scripts do not launch a process", () => { + const h = harness(() => ({ compile: () => "", destroy() {} })) + h.source.tree = OmarchyMenu.buildTree([], []) + h.start() + assert.equal(h.guardProc.running, false) + assert.equal(h.guardProc.requestTree, null) +}) + +function sandbox(t) { + const parent = fs.realpathSync("/tmp") + const root = fs.mkdtempSync(path.join(parent, "omacast-guard-probe-")) + const marker = path.join(root, ".omacast-guard-probe") + fs.writeFileSync(marker, "guard import fixtures\n") + t.after(() => { + assert.ok(path.isAbsolute(root)) + assert.equal(path.dirname(root), parent) + assert.ok(path.basename(root).startsWith("omacast-guard-probe-")) + assert.equal(fs.readFileSync(marker, "utf8"), "guard import fixtures\n") + fs.rmSync(root, { recursive: true, force: true }) + }) + return root +} + +// Opt in on Omarchy: OMACAST_QML_RUNNER=/usr/bin/qs node --test tests/omarchy-native-guards.test.js +test("real QML imports present, missing, broken, throwing and quoted-path OEM fixtures", { skip: !process.env.OMACAST_QML_RUNNER }, t => { + const root = sandbox(t) + const paths = {} + const fixtures = { + present: 'function guardScript(items) { return "stock:" + Object.keys(items).sort().join(",") }\n', + missing: null, + broken: 'function guardScript( {\n', + throws: 'function guardScript(items) { throw new Error("compiler failed") }\n', + invalid: 'function guardScript(items) { return 7 }\n', + 'space "double" \'single\' #percent%': 'function guardScript(items) { return "stock:" + Object.keys(items).sort().join(",") }\n' + } + for (const [name, script] of Object.entries(fixtures)) { + paths[name] = path.join(root, name) + const dir = path.join(paths[name], "shell/plugins/menu") + fs.mkdirSync(dir, { recursive: true }) + if (script !== null) fs.writeFileSync(path.join(dir, "MenuModel.js"), script) + } + const env = { ...process.env, HOME: root, TMPDIR: root, QT_QPA_PLATFORM: "offscreen", QML_DISABLE_DISK_CACHE: "1" } + for (const key of ["XDG_CACHE_HOME", "XDG_CONFIG_HOME", "XDG_DATA_HOME", "XDG_STATE_HOME", "XDG_RUNTIME_DIR"]) { + env[key] = path.join(root, key) + fs.mkdirSync(env[key], { mode: 0o700 }) + } + const coreUrl = pathToFileURL(path.join(__dirname, "../core/OmarchyMenu.js")).href + const config = path.join(root, "shell.qml") + fs.writeFileSync(config, `import QtQml +import Quickshell +import ${JSON.stringify(coreUrl)} as OmarchyMenu +ShellRoot { + id: source + property string omarchyPath: "" + property var guardCompiler: null + property var tree: ({ items: ({}), order: [] }) + property var guards: ({}) + property int revision: 0 + property var providerQueue: [] + signal message(string level, string text, var metadata) + property QtObject guardProcess: QtObject { + id: guardProc + property bool running: false + property string collected: "" + property var requestTree: null + property var expectedGuards: [] + property var command: [] + } + property QtObject providerProcess: QtObject { id: providerProc; property bool running: true } + function providerMenus() { return [] } + function startNextProvider() {} + ${methods.map(method).join("\n ")} + ${text.match(/Component.onDestruction: [^\n]+/)[0]} + function check(ok, detail) { if (!ok) throw new Error(detail) } + Component.onCompleted: Qt.callLater(function() { + try { + var paths = ${JSON.stringify(paths)} + source.tree = { order: ["probe", "bad", "__proto__", "constructor", "toString"], items: JSON.parse(${JSON.stringify(JSON.stringify({ + probe: { id: "probe", when: "true" }, bad: { id: "bad'; echo injected", when: "true" }, + ["__proto__"]: { id: "__proto__", checked: "false" }, constructor: { id: "constructor", disabled: "true" } + }))}) } + var items = source.guardItems(source.tree) + check(Object.getPrototypeOf(items) === null, "input map has a prototype") + check(Object.keys(items).sort().join(",") === "__proto__,constructor,probe", "unsafe input IDs") + var fallback = OmarchyMenu.guardScript(source.tree) + check(source.compileGuards(source.tree, items) === fallback, "startup fallback") + for (var name in paths) { + source.omarchyPath = paths[name] + source.createGuardCompiler() + var available = name === "present" || name.indexOf("space ") === 0 + if (name !== "missing" && name !== "broken") check(source.guardCompiler !== null, "import failed: " + name) + var script = source.compileGuards(source.tree, items) + check(script === (available ? "stock:__proto__,constructor,probe" : fallback), "wrong compiler: " + name) + if (source.guardCompiler) { source.guardCompiler.destroy(); source.guardCompiler = null } + } + console.log("OMACAST_GUARD_PROBE:OK") + } catch (error) { console.error("OMACAST_GUARD_PROBE:FAIL " + error) } + Qt.quit() + }) +} +`) + const result = spawnSync(process.env.OMACAST_QML_RUNNER, ["-p", config], { cwd: root, env, encoding: "utf8", timeout: 15000, killSignal: "SIGKILL" }) + assert.ifError(result.error) + const output = result.stdout + result.stderr + assert.equal(result.status, 0, output) + assert.match(output, /OMACAST_GUARD_PROBE:OK/) + assert.doesNotMatch(output, /OMACAST_GUARD_PROBE:FAIL/) +}) diff --git a/tests/paths.test.js b/tests/paths.test.js index ed00154..f7a1d21 100644 --- a/tests/paths.test.js +++ b/tests/paths.test.js @@ -1,19 +1,19 @@ const test = require("node:test") const assert = require("node:assert/strict") -const Paths = require("../plugin/core/Paths.js") +const Paths = require("../core/Paths.js") const envOf = (vars) => (name) => vars[name] || "" test("defaults follow the XDG base directory spec", () => { const p = Paths.resolve(envOf({ HOME: "/home/u", XDG_RUNTIME_DIR: "/run/user/1000" })) - assert.equal(p.config, "/home/u/.config/oma-cast") - assert.equal(p.configFile, "/home/u/.config/oma-cast/config.json") - assert.equal(p.db, "/home/u/.local/share/oma-cast/db") - assert.equal(p.extensions, "/home/u/.local/share/oma-cast/extensions") - assert.equal(p.cache, "/home/u/.cache/oma-cast") - assert.equal(p.logs, "/home/u/.local/state/oma-cast/logs") - assert.equal(p.runtime, "/run/user/1000/oma-cast") - assert.equal(p.scriptCommands, "/home/u/.config/oma-cast/script-commands") + assert.equal(p.config, "/home/u/.config/omacast") + assert.equal(p.configFile, "/home/u/.config/omacast/config.json") + assert.equal(p.db, "/home/u/.local/share/omacast/db") + assert.equal(p.extensions, "/home/u/.local/share/omacast/extensions") + assert.equal(p.cache, "/home/u/.cache/omacast") + assert.equal(p.logs, "/home/u/.local/state/omacast/logs") + assert.equal(p.runtime, "/run/user/1000/omacast") + assert.equal(p.scriptCommands, "/home/u/.config/omacast/script-commands") }) test("absolute XDG overrides win, relative ones are ignored", () => { @@ -23,9 +23,9 @@ test("absolute XDG overrides win, relative ones are ignored", () => { XDG_DATA_HOME: "relative/data", XDG_STATE_HOME: "/st" })) - assert.equal(p.config, "/cfg/oma-cast") - assert.equal(p.data, "/home/u/.local/share/oma-cast") - assert.equal(p.state, "/st/oma-cast") + assert.equal(p.config, "/cfg/omacast") + assert.equal(p.data, "/home/u/.local/share/omacast") + assert.equal(p.state, "/st/omacast") }) test("missing HOME is an error", () => { @@ -35,7 +35,17 @@ test("missing HOME is an error", () => { test("startup creates the persistent dirs and the runtime dir", () => { const p = Paths.resolve(envOf({ HOME: "/h", XDG_RUNTIME_DIR: "/r" })) assert.deepEqual(Paths.directoriesToCreate(p), [ - "/h/.config/oma-cast", "/h/.local/share/oma-cast/db", "/h/.cache/oma-cast", - "/h/.local/state/oma-cast/logs", "/h/.local/state/oma-cast/backups", "/r/oma-cast" + "/h/.config/omacast", "/h/.local/share/omacast/db", "/h/.cache/omacast", + "/h/.local/state/omacast/logs", "/h/.local/state/omacast/backups", "/r/omacast" + ]) +}) + +test("directories under the old oma-cast name move to omacast", () => { + const p = Paths.resolve(envOf({ HOME: "/h", XDG_CONFIG_HOME: "/cfg" })) + assert.deepEqual(Paths.legacyMoves(p), [ + ["/cfg/oma-cast", "/cfg/omacast"], + ["/h/.local/share/oma-cast", "/h/.local/share/omacast"], + ["/h/.cache/oma-cast", "/h/.cache/omacast"], + ["/h/.local/state/oma-cast", "/h/.local/state/omacast"] ]) }) diff --git a/tests/privacy.test.js b/tests/privacy.test.js new file mode 100644 index 0000000..af350cb --- /dev/null +++ b/tests/privacy.test.js @@ -0,0 +1,141 @@ +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 Paths = require("../core/Paths.js") + +// Marketplace review: histories must stay private under a normal umask 022, +// with every ancestor traversable, and no private text may reach any argv. +const root = path.join(__dirname, "..") +const mode = (p) => fs.statSync(p).mode & 0o777 + +function tempHome(t) { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "omacast-privacy-")) + fs.chmodSync(home, 0o755) + t.after(() => fs.rmSync(home, { recursive: true, force: true })) + return home +} + +function runSetup(home) { + const p = Paths.resolve((name) => ({ HOME: home, XDG_RUNTIME_DIR: home + "/run" })[name] || "") + fs.mkdirSync(home + "/run", { mode: 0o755 }) + const files = ["settings.json", "frecency.json", "calculator.json"].map((f) => p.db + "/" + f) + const [cmd, ...args] = Paths.setupArgv(p, files) + const r = spawnSync("bash", ["-c", "umask 022; exec \"$@\"", "umask-022", cmd, ...args], { encoding: "utf8" }) + assert.equal(r.status, 0, r.stderr) + return p +} + +test("first start under umask 022 creates owner-only dirs and 0600 store files", (t) => { + const p = runSetup(tempHome(t)) + for (const dir of Paths.privateRoots(p)) assert.equal(mode(dir), 0o700, dir) + for (const dir of Paths.directoriesToCreate(p)) assert.equal(mode(dir), 0o700, dir) + for (const f of ["settings.json", "frecency.json", "calculator.json"]) assert.equal(mode(p.db + "/" + f), 0o600, f) +}) + +test("an atomic save into a pre-created file keeps it private", (t) => { + const p = runSetup(tempHome(t)) + // QSaveFile copies the target's permissions onto its temp file before the rename. + const target = p.db + "/frecency.json" + const temp = target + ".tmp" + fs.writeFileSync(temp, '{"searchHistory":["secret"]}', { mode: 0o644 }) + fs.chmodSync(temp, mode(target)) + fs.renameSync(temp, target) + assert.equal(mode(target), 0o600) +}) + +test("readable histories from older builds are repaired; stricter modes are kept", (t) => { + const home = tempHome(t) + const db = home + "/.local/share/omacast/db" + fs.mkdirSync(db, { recursive: true, mode: 0o755 }) + fs.chmodSync(home + "/.local/share/omacast", 0o755) + fs.writeFileSync(db + "/frecency.json", "{}", { mode: 0o644 }) + fs.writeFileSync(db + "/calculator.json", "{}", { mode: 0o644 }) + fs.writeFileSync(db + "/settings.json", "{}") + fs.chmodSync(db + "/frecency.json", 0o644) + fs.chmodSync(db + "/calculator.json", 0o664) + fs.chmodSync(db + "/settings.json", 0o400) + const p = runSetup(home) + assert.equal(mode(p.data), 0o700) + assert.equal(mode(p.db), 0o700) + assert.equal(mode(db + "/frecency.json"), 0o600) + assert.equal(mode(db + "/calculator.json"), 0o600) + assert.equal(mode(db + "/settings.json"), 0o400) + assert.equal(fs.readFileSync(db + "/frecency.json", "utf8"), "{}", "repair never rewrites contents") +}) + +test("legacy oma-cast data moves and comes out private", (t) => { + const home = tempHome(t) + const old = home + "/.local/share/oma-cast/db" + fs.mkdirSync(old, { recursive: true }) + fs.chmodSync(home + "/.local/share/oma-cast", 0o755) + fs.writeFileSync(old + "/calculator.json", '{"history":[]}', { mode: 0o644 }) + fs.chmodSync(old + "/calculator.json", 0o644) + const p = runSetup(home) + assert.ok(!fs.existsSync(home + "/.local/share/oma-cast")) + assert.equal(mode(p.data), 0o700) + assert.equal(mode(p.db + "/calculator.json"), 0o600) +}) + +test("setup hyprland keeps a private legacy oma-cast.lua private", (t) => { + const home = tempHome(t) + const hypr = home + "/.config/hypr" + const bin = home + "/bin" + fs.mkdirSync(hypr, { recursive: true }) + fs.mkdirSync(bin) + fs.mkdirSync(home + "/omarchy") + 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 }) + 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" + }) + assert.equal(r.status, 0, r.stdout + r.stderr) + assert.ok(!fs.existsSync(hypr + "/oma-cast.lua")) + assert.equal(mode(hypr + "/omacast.lua"), 0o600) + assert.match(fs.readFileSync(hypr + "/omacast.lua", "utf8"), /omacast:toggle/) +}) + +// The clipboard scripts run with the text on stdin. A fake wl-copy/wtype on +// PATH records each process's /proc cmdline while the text is in flight. +function clipboardScripts() { + const source = fs.readFileSync(path.join(root, "services/ClipboardWriter.qml"), "utf8") + const grab = (name) => source.match(new RegExp("property string " + name + ": '([^']*)'"))[1] + return { copy: grab("copyScript"), paste: grab("pasteScript") } +} + +for (const kind of ["copy", "paste"]) { + test(`calculator ${kind} keeps the text out of every command line`, (t) => { + const home = tempHome(t) + const log = home + "/cmdlines" + const fake = '#!/bin/bash\ntr "\\0" " " >"$LOG"; echo >>"$LOG"\n' + fs.writeFileSync(home + "/wl-copy", fake + 'cat >"$LOG.clipboard"\n', { mode: 0o755 }) + fs.writeFileSync(home + "/wtype", fake, { mode: 0o755 }) + fs.writeFileSync(home + "/sleep", "#!/bin/bash\nexit 0\n", { mode: 0o755 }) + const secret = "1234 * 5678 = 7006652 my private question" + const script = clipboardScripts()[kind] + const r = spawnSync("bash", ["-c", script], { + input: secret, encoding: "utf8", + env: { ...process.env, LOG: log, PATH: home + ":" + process.env.PATH } + }) + assert.equal(r.status, 0, r.stderr) + assert.equal(fs.readFileSync(log + ".clipboard", "utf8"), secret) + const cmdlines = fs.readFileSync(log, "utf8") + assert.ok(!cmdlines.includes("private"), cmdlines) + assert.ok(!script.includes("$"), "the script takes no arguments") + if (kind === "paste") assert.match(cmdlines, /wtype -M shift -k Insert -m shift/) + }) +} + +test("no QML hands clipboard text or prompts to a process's argv", () => { + const files = ["Backend.qml", "Launcher.qml", ...["ui", "services"].flatMap((d) => + fs.readdirSync(path.join(root, d)).filter((f) => f.endsWith(".qml")).map((f) => d + "/" + f))] + for (const file of files) { + const source = fs.readFileSync(path.join(root, file), "utf8") + assert.doesNotMatch(source, /\["wl-copy"|omarchy-clipboard-paste-text"|omarchy-agent/, file) + } +}) diff --git a/tests/ranker.test.js b/tests/ranker.test.js index 19398e0..1c273b1 100644 --- a/tests/ranker.test.js +++ b/tests/ranker.test.js @@ -1,6 +1,6 @@ const test = require("node:test") const assert = require("node:assert/strict") -const R = require("../plugin/core/Ranker.js") +const R = require("../core/Ranker.js") const NOW = 1_800_000_000_000 const DAY_MS = 86_400_000 diff --git a/tests/rootsearch.test.js b/tests/rootsearch.test.js index 429be0a..f745aa4 100644 --- a/tests/rootsearch.test.js +++ b/tests/rootsearch.test.js @@ -1,9 +1,9 @@ const test = require("node:test") const assert = require("node:assert/strict") -const Ranker = require("../plugin/core/Ranker.js") -const Frecency = require("../plugin/core/Frecency.js") -const OmarchyMenu = require("../plugin/core/OmarchyMenu.js") -const RS = require("../plugin/core/RootSearch.js") +const Ranker = require("../core/Ranker.js") +const Frecency = require("../core/Frecency.js") +const OmarchyMenu = require("../core/OmarchyMenu.js") +const RS = require("../core/RootSearch.js") const deps = { Ranker, Frecency, OmarchyMenu } const NOW = Date.UTC(2026, 9, 19, 12) @@ -51,18 +51,7 @@ test("a query always ends with the fallback section", () => { assert.equal(sections.length, 1) assert.equal(sections[0].id, "fallback-commands") assert.equal(sections[0].title, "Use “zzzz-nothing” with…") - assert.deepEqual(titles(sections[0]), ["Search Google", "Search DuckDuckGo", "Ask default agent"]) -}) - -test("agent hand-off is explicit, stays after apps/web search, and only appears at root with a query", () => { - const sections = results("firefox") - assert.equal(sections[0].rows[0].candidate.id, "app:firefox") - const row = sections.at(-1).rows.at(-1).candidate - assert.equal(row.id, "fallback:agent") - assert.match(row.subtitle, /Ctrl\+Alt\+Enter/) - assert.deepEqual(RS.actionsFor(row).map(a => a.id), ["open"]) - assert.ok(!results("").flatMap(s => s.rows).some(r => r.candidate.id === row.id)) - assert.ok(!RS.submenuSections(candidates(), "trigger", "question?", ctx(), deps).flatMap(s => s.rows).some(r => r.candidate.id === row.id)) + assert.deepEqual(titles(sections[0]), ["Search Google", "Search DuckDuckGo"]) }) test("an exact alias puts its command on top (Phase 1 accept)", () => { @@ -180,3 +169,32 @@ test("the about panel describes a row", () => { const calc = RS.aboutFor({ kind: "calculator", calcInput: "2+2", calcResult: "4", calcResultCaption: "Four" }, NOW) assert.deepEqual(calc.map((r) => r.label), ["Question", "Answer", "Type"]) }) + +test("app windows: keys from desktop id, StartupWMClass and web app URL", () => { + assert.deepEqual(RS.appWindowKeys({ id: "sublime_text", startupClass: "subl" }), ["sublime_text", "subl"]) + assert.deepEqual(RS.appWindowKeys({ id: "WhatsApp", execString: '/home/u/.local/bin/chrome-webapp "https://web.whatsapp.com/"' }), ["whatsapp", "webapp:web.whatsapp.com"]) + assert.deepEqual(RS.appWindowKeys({ id: "Chat", command: ["omarchy-launch-webapp", "https://app.parlichat.com/chats"] }), ["chat", "webapp:app.parlichat.com/chats"]) + assert.deepEqual(RS.windowClassKeys("chrome-discord.com__channels_@me-Profile_1"), ["chrome-discord.com__channels_@me-profile_1", "webapp:discord.com/channels/@me"]) + assert.deepEqual(RS.appWindowKeys({ id: "firefox", execString: "firefox https://example.com" }), ["firefox"]) + assert.deepEqual(RS.windowClassKeys("chrome-web.whatsapp.com__-Default"), ["chrome-web.whatsapp.com__-default", "webapp:web.whatsapp.com"]) + assert.deepEqual(RS.windowClassKeys("brave-app.parlichat.com__chats-Default"), ["brave-app.parlichat.com__chats-default", "webapp:app.parlichat.com/chats"]) +}) + +test("app windows: picks the most recently focused mapped window", () => { + const clients = [ + { class: "firefox", address: "0xa", focusHistoryID: 3, mapped: true }, + { class: "Firefox", address: "0xb", focusHistoryID: 1, mapped: true }, + { class: "firefox", address: "0xc", focusHistoryID: 0, mapped: false }, + { class: "chrome-web.whatsapp.com__-Default", address: "0xd", focusHistoryID: 2, mapped: true } + ] + assert.equal(RS.findAppWindow(clients, ["firefox"]), "0xb") + assert.equal(RS.findAppWindow(clients, ["whatsapp", "webapp:web.whatsapp.com"]), "0xd") + assert.equal(RS.findAppWindow(clients, ["signal-desktop"]), "") + assert.equal(RS.findAppWindow(clients, []), "") +}) + +test("actions: apps offer Open New Window on Shift+Enter", () => { + const app = RS.decorate(RS.appCandidate(apps[0]), ctx(), deps) + const openNew = RS.actionsFor(app).find((a) => a.id === "open-new") + assert.equal(openNew.shortcut, "Shift ↵") +}) diff --git a/tests/rows.test.js b/tests/rows.test.js new file mode 100644 index 0000000..372be95 --- /dev/null +++ b/tests/rows.test.js @@ -0,0 +1,52 @@ +const test = require("node:test") +const assert = require("node:assert/strict") +const { update, snapshot } = require("../core/Rows.js") + +test("stable updates keep unchanged rows and batch additions without resetting the model", () => { + const records = [{ itemId: "a", title: "A" }, { itemId: "b", title: "B" }], calls = [] + const first = records[0] + const model = { + get count() { return records.length }, get(i) { return records[i] }, + set(i, data) { calls.push("set"); records[i] = data }, + remove(i, n) { calls.push("remove"); records.splice(i, n) }, + append(data) { calls.push("append"); records.push(...data) } + } + update(model, [{ ...first }, { itemId: "c", title: "C" }, { itemId: "d", title: "D" }]) + assert.equal(records[0], first) + assert.deepEqual(calls, ["set", "append"]) + calls.length = 0 + update(model, [{ ...first }]) + assert.deepEqual(calls, ["remove"]) + assert.equal(records[0], first) + update(model, []) + assert.equal(records.length, 0) +}) + + +test("a failed search removes retained rows before they can be clicked", () => { + const fs = require("node:fs"), path = require("node:path"), vm = require("node:vm") + const source = fs.readFileSync(path.join(__dirname, "../ui/LauncherWindow.qml"), "utf8") + const start = source.indexOf(" function applySearch(") + const body = source.slice(source.indexOf("{", start) + 1, source.indexOf("\n }", start)) + const records = [{ itemId: "old-result" }] + const rows = { get count() { return records.length }, remove(at, n) { records.splice(at, n) } } + let selection = 0, error = "" + const window = { showToast(_, message) { error = message }, select(index) { selection = index } } + vm.runInNewContext("(function() {" + body + "})()", { result: { ok: false, error: "Search unavailable" }, window, rows, Rows: { update } }) + assert.equal(records.length, 0) + assert.equal(selection, -1) + assert.equal(error, "Search unavailable") +}) + + +test("selection is a value snapshot across in-place model changes", () => { + const handle = { itemId: "old", title: "Old", argsJson: "[]" } + const model = { count: 1, get() { return handle } } + const selected = snapshot(model, 0) + handle.itemId = "new" + handle.title = "New" + assert.equal(selected.itemId, "old") + assert.equal(selected.title, "Old") + assert.equal(snapshot(model, 0).itemId, "new") + assert.equal(snapshot(model, -1), null) +}) diff --git a/tests/rpc.test.js b/tests/rpc.test.js index c948b5d..38f76f5 100644 --- a/tests/rpc.test.js +++ b/tests/rpc.test.js @@ -1,6 +1,6 @@ const test = require("node:test") const assert = require("node:assert/strict") -const Rpc = require("../plugin/core/Rpc.js") +const Rpc = require("../core/Rpc.js") function registryWithSystem() { const reg = Rpc.createRegistry() diff --git a/tests/search-integration.test.js b/tests/search-integration.test.js new file mode 100644 index 0000000..8a0e887 --- /dev/null +++ b/tests/search-integration.test.js @@ -0,0 +1,39 @@ +const test = require("node:test") +const assert = require("node:assert/strict") +const fs = require("node:fs") +const path = require("node:path") +const { spawn } = require("node:child_process") +const readline = require("node:readline") +const R = require("../core/Ranker.js") +const F = require("../core/FleaSearch.js") +const Root = require("../core/RootSearch.js") +const Frecency = require("../core/Frecency.js") +const binary = path.join(__dirname, "../bin/omacast-search") + +test("built helper feeds the actual root ranker and sections", { skip: !fs.existsSync(binary), timeout: 10000 }, async t => { + const child = spawn(binary, [], { stdio: ["pipe", "pipe", "inherit"] }) + t.after(() => child.kill()) + const lines = readline.createInterface({ input: child.stdout })[Symbol.asyncIterator]() + const ask = async obj => { child.stdin.write(JSON.stringify(obj) + "\n"); return JSON.parse((await lines.next()).value) } + const candidates = [ + { id: "chrome", title: "Chromium", type: "application", keywords: ["browser"] }, + { id: "cafe", title: "Café", type: "application", alternateTitles: ["Coffee"], subtitle: "Drinks" }, + { id: "lock", title: "Lock", type: "command" } + ].map(c => ({ ...c, rootSearch: { when: "always" } })) + const ids = candidates.map(c => c.id) + assert.equal((await ask({ id: 1, entries: F.entries(candidates, R.transliterate) })).loaded, 3) + for (const [query, expected] of [["chormium", "chrome"], ["cafe", "cafe"], ["coffee", "cafe"], ["drinks", "cafe"], ["browser", "chrome"], ["lcok", "lock"]]) { + const reply = await ask({ id: 2, query: R.transliterate(query), sensitivity: "medium" }) + const fleaHits = F.hits(F.reply(JSON.stringify(reply)), ids) + const ctx = { commands: {}, frecency: {}, nowMs: 1000, filter: "all", fleaHits } + const sections = Root.rootSections(candidates, query, ctx, { Ranker: R, Frecency }) + assert.equal(sections[0].rows[0].candidate.id, expected, query) + assert.equal(sections.at(-1).id, "fallback-commands") + ctx.commands[expected] = { enabled: false } + const filtered = Root.rootSections(candidates, query, ctx, { Ranker: R, Frecency }) + assert.ok(!filtered.flatMap(s => s.rows).some(r => r.candidate.id === expected)) + } + assert.deepEqual((await ask({ id: 3, query: "lcok", sensitivity: "high" })).hits, []) + assert.deepEqual((await ask({ id: 4, query: "" })).hits, []) + child.stdin.end() +}) diff --git a/tests/smoke.sh b/tests/smoke.sh index 0e57ed4..310c0fb 100755 --- a/tests/smoke.sh +++ b/tests/smoke.sh @@ -2,7 +2,7 @@ set -uo pipefail cd "$(dirname "$0")/.." || exit 1 -cli=bin/oma-cast +cli=bin/omacast fail=0 pass() { printf ' ok %s\n' "$1"; } bad() { printf ' FAIL %s\n' "$1"; fail=1; } @@ -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"])') @@ -28,7 +28,7 @@ echo "toggle latency (show -> layer mapped, hide -> unmapped; budget 100 ms)" sock="$XDG_RUNTIME_DIR/hypr/$HYPRLAND_INSTANCE_SIGNATURE/.socket2.sock" events=$(mktemp) socat -u "UNIX-CONNECT:$sock" - 2>/dev/null | while IFS= read -r line; do - case $line in *layer\>\>oma-cast*) echo "$(date +%s%N) ${line%%>>*}" >>"$events" ;; esac + case $line in *layer\>\>omacast*) echo "$(date +%s%N) ${line%%>>*}" >>"$events" ;; esac done & watcher=$! sleep 0.3 diff --git a/plugin/ui/ActionPanel.qml b/ui/ActionPanel.qml similarity index 100% rename from plugin/ui/ActionPanel.qml rename to ui/ActionPanel.qml diff --git a/plugin/ui/Alert.qml b/ui/Alert.qml similarity index 100% rename from plugin/ui/Alert.qml rename to ui/Alert.qml diff --git a/plugin/ui/InfoPanel.qml b/ui/InfoPanel.qml similarity index 100% rename from plugin/ui/InfoPanel.qml rename to ui/InfoPanel.qml diff --git a/plugin/ui/KeyHint.qml b/ui/KeyHint.qml similarity index 100% rename from plugin/ui/KeyHint.qml rename to ui/KeyHint.qml diff --git a/plugin/ui/LauncherWindow.qml b/ui/LauncherWindow.qml similarity index 88% rename from plugin/ui/LauncherWindow.qml rename to ui/LauncherWindow.qml index 24a282e..618a8ad 100644 --- a/plugin/ui/LauncherWindow.qml +++ b/ui/LauncherWindow.qml @@ -4,8 +4,8 @@ import Quickshell.Wayland import qs.Commons import qs.Commons as Commons import qs.Ui -import "../core/Agent.js" as Agent import "../core/RootSearch.js" as RootSearch +import "../core/Rows.js" as Rows // Omarchy 4.0.x dropped qs.Ui.OverlayWindow; this is the same full-screen // overlay layer the shell's own emoji picker uses. @@ -15,6 +15,21 @@ PanelWindow { required property var backend property var appLibrary: null property bool shown: false + readonly property bool busy: window.shown && window.backend.loading + readonly property int loadingFeedbackDelay: 500 + property bool loadingVisible: false + + onBusyChanged: { + window.loadingVisible = false + if (window.busy) loadingDelay.restart() + else loadingDelay.stop() + } + + Timer { + id: loadingDelay + interval: window.loadingFeedbackDelay + onTriggered: window.loadingVisible = window.busy + } signal dismissRequested() @@ -22,7 +37,7 @@ PanelWindow { anchors { top: true; bottom: true; left: true; right: true } color: "transparent" exclusionMode: ExclusionMode.Ignore - WlrLayershell.namespace: "oma-cast" + WlrLayershell.namespace: "omacast" WlrLayershell.layer: WlrLayer.Overlay WlrLayershell.keyboardFocus: WlrKeyboardFocus.Exclusive @@ -43,11 +58,13 @@ PanelWindow { }) readonly property var borderSpec: Border.surfaceSpec("menu", "border", Commons.Color.menu.border, Math.max(1, Style.space(2))) - property var stack: [{ kind: "root", title: "oma-cast", query: "" }] + property var stack: [{ kind: "root", title: "omacast", query: "" }] readonly property var view: window.stack[window.stack.length - 1] property string query: "" property int selectedIndex: -1 property var selectedRow: null + property int searchSerial: 0 + property bool searchPending: false property bool actionPanelOpen: false property var actions: [] property var toast: null @@ -115,6 +132,9 @@ PanelWindow { onShownChanged: { if (window.shown) return + window.searchSerial++ + window.backend.cancelSearch() + window.searchPending = false window.closePopups() var seconds = Number(window.general.popToRootTimeout) if (!(seconds > 0)) window.popToRoot() @@ -127,7 +147,7 @@ PanelWindow { } function popToRoot() { - window.stack = [{ kind: "root", title: "oma-cast", query: "" }] + window.stack = [{ kind: "root", title: "omacast", query: "" }] window.query = "" window.rootFilter = "all" window.rebuild(true) @@ -148,33 +168,46 @@ PanelWindow { var top = next[next.length - 1] window.stack = next window.query = top.query || "" - window.rebuild(false) - if (top.selectedIndex >= 0) window.select(top.selectedIndex) + window.rebuild(false, top.selectedIndex) } - function rebuild(resetSelection) { + function rebuild(resetSelection, restoreIndex) { + var serial = ++window.searchSerial + window.backend.cancelSearch() pointerGate.reset() var keepId = !resetSelection && window.selectedRow ? window.selectedRow.itemId : "" - rows.clear() - if (window.view.kind === "alias") { window.select(-1); return } - var result = window.backend.callSync("rootSearch.search", { query: window.query, view: window.view, filter: window.atRoot ? window.rootFilter : "all" }) + window.searchPending = false + if (window.view.kind === "alias") { Rows.update(rows, []); window.select(-1); return } + window.searchPending = true + var revision = window.backend.candidatesRevision + window.backend.searchAsync({ query: window.query, view: window.view, filter: window.atRoot ? window.rootFilter : "all" }, function(result) { + if (serial !== window.searchSerial || revision !== window.backend.candidatesRevision) return + window.applySearch(result, keepId, restoreIndex) + window.searchPending = false + }) + } + + function applySearch(result, keepId, restoreIndex) { if (!result.ok) { + Rows.update(rows, []) window.showToast("failure", result.error) window.select(-1) return } - var keepIndex = -1 + var keepIndex = -1, next = [] for (var s = 0; s < result.data.length; s++) { var section = result.data[s] - if (section.title || s > 0) rows.append(window.rowData({ id: "section:" + section.id, title: section.title }, "section")) + if (section.title || s > 0) next.push(window.rowData({ id: "section:" + section.id, title: section.title }, "section")) for (var r = 0; r < section.rows.length; r++) { var c = section.rows[r] - if (c.id === keepId) keepIndex = rows.count - rows.append(window.rowData(c, c.kind === "calculator" ? "calculator" : "item")) + if (c.id === keepId) keepIndex = next.length + next.push(window.rowData(c, c.kind === "calculator" ? "calculator" : "item")) } } - window.select(keepIndex >= 0 ? keepIndex : window.nextSelectable(-1, 1)) + Rows.update(rows, next) + if (restoreIndex >= 0 && restoreIndex < rows.count && window.isSelectable(restoreIndex)) window.select(restoreIndex) + else window.select(keepIndex >= 0 ? keepIndex : window.nextSelectable(-1, 1)) } function rowData(c, kind) { @@ -211,8 +244,14 @@ PanelWindow { } function select(index) { + if (index !== window.selectedIndex) { + var previous = list.itemAtIndex(window.selectedIndex) + if (previous) previous.selected = false + } window.selectedIndex = index - window.selectedRow = index >= 0 && index < rows.count ? rows.get(index) : null + var current = list.itemAtIndex(index) + if (current) current.selected = true + window.selectedRow = Rows.snapshot(rows, index) // Argument values belong to one command; a different row starts empty. var id = window.selectedRow ? window.selectedRow.itemId : "" if (id !== window.argsForId) { @@ -260,12 +299,13 @@ PanelWindow { function actionsForSelected() { - if (!window.selectedRow) return [] + if (window.searchPending || !window.selectedRow) return [] var r = window.backend.callSync("rootSearch.getActions", { id: window.selectedRow.itemId }) return r.ok ? r.data : [] } function perform(actionId, index) { + if (window.searchPending) return if (index !== undefined && index >= 0) window.select(index) if (!window.selectedRow) return window.actionPanelOpen = false @@ -315,16 +355,16 @@ PanelWindow { function toggleActionPanel() { if (window.actionPanelOpen) { window.actionPanelOpen = false; return } window.actions = window.actionsForSelected() - actionPanel.filter = "" window.actionPanelOpen = window.actions.length > 0 + if (actionPanel.item) actionPanel.item.filter = "" } function toggleDropdown() { if (window.dropdownOpen || !window.atRoot) { window.dropdownOpen = false; return } - dropdown.filter = "" window.dropdownOpen = true + dropdown.item.filter = "" var at = RootSearch.FILTERS.map(function(f) { return f.id }).indexOf(window.rootFilter) - dropdown.selectedIndex = Math.max(0, at) + dropdown.item.selectedIndex = Math.max(0, at) } function setRootFilter(id) { @@ -345,8 +385,8 @@ PanelWindow { function toggleToastPanel() { if (window.toastPanelOpen) { window.toastPanelOpen = false; toastTimer.restart(); return } if (!window.toast || !window.toast.actions.length) return - toastPanel.filter = "" window.toastPanelOpen = true + toastPanel.item.filter = "" toastTimer.stop() } @@ -357,7 +397,7 @@ PanelWindow { window.toastPanelOpen = false if (!action) return if (action.copy !== undefined) { - Util.execArgv(["wl-copy", "--", action.copy]) + window.backend.copyText(action.copy) window.showToast("success", "Copied to Clipboard") return } @@ -390,11 +430,6 @@ PanelWindow { return false } - function askAgent() { - var r = window.backend.callSync("rootSearch.performAction", { id: Agent.FALLBACK_ID, action: "open", query: window.query }) - if (!r.ok) window.showToast("failure", r.error) - } - function saveAlias() { var r = window.backend.callSync("settings.updateCommandSettings", { commandId: window.view.commandId, alias: window.query }) if (!r.ok) { window.showToast("failure", r.error); return } @@ -439,7 +474,7 @@ PanelWindow { var text = clipboardInput.text.replace(/[\r\n\u2028\u2029]+/g, " ") clipboardInput.clear() if (!text) return - if (window.actionPanelOpen) actionPanel.filter += text + if (window.actionPanelOpen) actionPanel.item.filter += text else if (window.argFocus >= 0) { var name = window.argDefs[window.argFocus].name var values = Object.assign({}, window.argValues) @@ -548,26 +583,27 @@ PanelWindow { } if (window.dropdownOpen) { if (ctrl && !shift && key === Qt.Key_P) { window.dropdownOpen = false; return true } - return window.handlePopupKey(event, dropdown, ctrl, alt) + return window.handlePopupKey(event, dropdown.item, ctrl, alt) } if (window.toastPanelOpen) { if (ctrl && key === Qt.Key_T) { window.toggleToastPanel(); return true } - return window.handlePopupKey(event, toastPanel, ctrl, alt) + return window.handlePopupKey(event, toastPanel.item, ctrl, alt) } if (window.actionPanelOpen) { if (window.isActionPanelKey(ctrl, alt, shift, key)) { window.actionPanelOpen = false; return true } - return window.handlePopupKey(event, actionPanel, ctrl, alt) + return window.handlePopupKey(event, actionPanel.item, ctrl, alt) } if (window.view.kind === "alias") { if (enter) { window.saveAlias(); return true } if (key === Qt.Key_Space) return true } else { - if (window.atRoot && enter && ctrl && alt && !shift) { window.askAgent(); return true } if (ctrl && shift && key === Qt.Key_K) { window.toggleAbout(); return true } if (window.isActionPanelKey(ctrl, alt, shift, key)) { window.toggleActionPanel(); return true } if (ctrl && !shift && key === Qt.Key_P) { window.toggleDropdown(); return true } if (ctrl && !shift && key === Qt.Key_T) { window.toggleToastPanel(); return true } + // Shift+Enter opens a new window for apps; Enter focuses an open one. + if (enter && shift && !ctrl && !alt && window.performIfOffered(["open-new"])) return true if (enter && ctrl && shift) { window.performNth(2); return true } if (enter && ctrl) { window.performNth(1); return true } if (enter) { window.performNth(0); return true } @@ -614,9 +650,6 @@ PanelWindow { Connections { target: window.backend function onCandidatesRevisionChanged() { if (window.shown && !window.actionPanelOpen && !window.alert) window.rebuild(false) } - function onNotification(method, data) { - if (method === "agent.launchFailed" && window.shown) window.showToast("failure", data.message) - } // Exchange rates or zone offsets arrived: re-evaluate a calculation on screen. function onCalculatorRevisionChanged() { if (window.shown && !window.actionPanelOpen && window.query) window.rebuild(false) } } @@ -828,22 +861,6 @@ PanelWindow { height: Style.spacing.hairline color: Util.alpha(window.look.foreground, 0.12) visible: !window.compact - - Rectangle { - id: loader - visible: window.backend.loading && window.shown - height: Math.max(2, Style.space(2)) - width: parent.width / 4 - anchors.verticalCenter: parent.verticalCenter - color: window.look.accent - NumberAnimation on x { - running: loader.visible - loops: Animation.Infinite - from: -loader.width - to: header.width - duration: 900 - } - } } } @@ -865,10 +882,13 @@ PanelWindow { visible: window.view.kind !== "alias" clip: true model: rows + reuseItems: true boundsBehavior: Flickable.StopAtBounds delegate: ResultRow { - selected: index === window.selectedIndex + Component.onCompleted: selected = index === window.selectedIndex + ListView.onReused: selected = index === window.selectedIndex + ListView.onPooled: selected = false look: window.look appLibrary: window.appLibrary zoom: window.zoom @@ -949,9 +969,10 @@ PanelWindow { color: window.toast && window.toast.style === "failure" ? "#e06c75" : window.look.accent } Text { + id: footerStatus anchors.verticalCenter: parent.verticalCenter textFormat: Text.PlainText - text: window.toast ? window.toast.title : window.view.title + text: window.toast ? window.toast.title : window.loadingVisible ? "Loading…" : window.view.title color: window.look.foreground opacity: window.toast ? 0.9 : 0.6 elide: Text.ElideRight @@ -1026,78 +1047,88 @@ PanelWindow { } } - ActionPanel { + Loader { id: actionPanel - visible: window.actionPanelOpen + active: window.actionPanelOpen z: 10 anchors.right: parent.right anchors.rightMargin: card.borderRight + window.px(10) anchors.bottom: footer.top anchors.bottomMargin: window.px(6) - actions: window.actions - look: window.look - zoom: window.zoom - onActivated: function(actionId) { window.perform(actionId) } + sourceComponent: ActionPanel { + actions: window.actions + look: window.look + zoom: window.zoom + onActivated: function(actionId) { window.perform(actionId) } + } } - ActionPanel { + Loader { id: dropdown - visible: window.dropdownOpen + active: window.dropdownOpen z: 10 width: window.px(240) anchors.right: parent.right anchors.rightMargin: card.borderRight + window.px(10) anchors.top: header.bottom anchors.topMargin: window.px(4) - placeholder: "Filter…" - actions: RootSearch.FILTERS.map(function(f) { - return { id: f.id, title: f.title, shortcut: "", section: "filter", icon: f.id === window.rootFilter ? "✓" : " " } - }) - look: window.look - zoom: window.zoom - onActivated: function(id) { window.setRootFilter(id) } + sourceComponent: ActionPanel { + placeholder: "Filter…" + actions: RootSearch.FILTERS.map(function(f) { + return { id: f.id, title: f.title, shortcut: "", section: "filter", icon: f.id === window.rootFilter ? "✓" : " " } + }) + look: window.look + zoom: window.zoom + onActivated: function(id) { window.setRootFilter(id) } + } } - ActionPanel { + Loader { id: toastPanel - visible: window.toastPanelOpen + active: window.toastPanelOpen z: 10 width: window.px(280) anchors.left: parent.left anchors.leftMargin: card.borderLeft + window.px(10) anchors.bottom: footer.top anchors.bottomMargin: window.px(6) - placeholder: "Search toast actions…" - actions: (window.toast ? window.toast.actions : []).map(function(a) { - return { id: a.id, title: a.title, shortcut: "", section: "toast", icon: " " } - }) - look: window.look - zoom: window.zoom - onActivated: function(id) { window.runToastAction(id) } + sourceComponent: ActionPanel { + placeholder: "Search toast actions…" + actions: (window.toast ? window.toast.actions : []).map(function(a) { + return { id: a.id, title: a.title, shortcut: "", section: "toast", icon: " " } + }) + look: window.look + zoom: window.zoom + onActivated: function(id) { window.runToastAction(id) } + } } - InfoPanel { - visible: window.aboutOpen + Loader { + active: window.aboutOpen z: 10 anchors.right: parent.right anchors.rightMargin: card.borderRight + window.px(10) anchors.bottom: footer.top anchors.bottomMargin: window.px(6) - title: window.selectedRow ? window.selectedRow.title : "" - rows: window.aboutRows - look: window.look - zoom: window.zoom + sourceComponent: InfoPanel { + title: window.selectedRow ? window.selectedRow.title : "" + rows: window.aboutRows + look: window.look + zoom: window.zoom + } } - Alert { - visible: !!window.alert + Loader { + active: !!window.alert z: 20 anchors.fill: parent - spec: window.alert ? window.alert.spec : null - look: window.look - zoom: window.zoom - onConfirmed: window.confirmAlert() - onCancelled: window.alert = null + sourceComponent: Alert { + spec: window.alert ? window.alert.spec : null + look: window.look + zoom: window.zoom + onConfirmed: window.confirmAlert() + onCancelled: window.alert = null + } } } } diff --git a/plugin/ui/ResultRow.qml b/ui/ResultRow.qml similarity index 69% rename from plugin/ui/ResultRow.qml rename to ui/ResultRow.qml index 99552b2..ab5a1ec 100644 --- a/plugin/ui/ResultRow.qml +++ b/ui/ResultRow.qml @@ -40,85 +40,88 @@ Item { // The inline calculator (ref 04 §1 Calculator): question on the left, // answer on the right, each with a caption, an arrow between them. - Rectangle { - visible: row.isCalculator + Loader { + active: row.isCalculator anchors.fill: parent - radius: Math.max(Style.cornerRadius, row.px(6)) - color: row.selected ? row.look.selectedBackground : Util.alpha(row.look.foreground, 0.04) - border.width: 1 - border.color: Util.alpha(row.look.foreground, row.selected ? 0 : 0.08) + sourceComponent: Rectangle { + anchors.fill: parent + radius: Math.max(Style.cornerRadius, row.px(6)) + color: row.selected ? row.look.selectedBackground : Util.alpha(row.look.foreground, 0.04) + border.width: 1 + border.color: Util.alpha(row.look.foreground, row.selected ? 0 : 0.08) - Repeater { - model: row.isCalculator ? [ - { text: row.calcInput, caption: row.calcInputCaption, answer: false }, - { text: row.calcResult, caption: row.calcResultCaption, answer: true } - ] : [] + Repeater { + model: row.isCalculator ? [ + { text: row.calcInput, caption: row.calcInputCaption, answer: false }, + { text: row.calcResult, caption: row.calcResultCaption, answer: true } + ] : [] - delegate: Item { - required property var modelData - required property int index - x: index === 0 ? 0 : parent.width / 2 + row.px(20) - width: parent.width / 2 - row.px(20) - height: parent.height - - Text { - id: calcText - textFormat: Text.PlainText - anchors.horizontalCenter: parent.horizontalCenter - anchors.verticalCenter: parent.verticalCenter - anchors.verticalCenterOffset: -row.px(10) - width: parent.width - row.px(24) - horizontalAlignment: Text.AlignHCenter - text: modelData.text - color: modelData.answer && row.selected ? row.look.accent : row.look.foreground - font.family: row.look.fontFamily - font.pixelSize: Math.round(Style.font.heading * 1.25 * row.zoom) - font.weight: modelData.answer ? Font.DemiBold : Font.Normal - fontSizeMode: Text.HorizontalFit - minimumPixelSize: Math.round(Style.font.body * row.zoom) - elide: Text.ElideRight - } - - Rectangle { - visible: modelData.caption.length > 0 - anchors.horizontalCenter: parent.horizontalCenter - anchors.top: calcText.bottom - anchors.topMargin: row.px(8) - width: Math.min(captionText.implicitWidth + row.px(16), parent.width - row.px(24)) - height: captionText.implicitHeight + row.px(6) - radius: height / 2 - color: Util.alpha(row.look.foreground, 0.08) + delegate: Item { + required property var modelData + required property int index + x: index === 0 ? 0 : parent.width / 2 + row.px(20) + width: parent.width / 2 - row.px(20) + height: parent.height Text { - id: captionText - anchors.centerIn: parent - width: Math.min(implicitWidth, parent.parent.width - row.px(40)) + id: calcText textFormat: Text.PlainText - text: modelData.caption - color: row.look.foreground - opacity: 0.7 + anchors.horizontalCenter: parent.horizontalCenter + anchors.verticalCenter: parent.verticalCenter + anchors.verticalCenterOffset: -row.px(10) + width: parent.width - row.px(24) + horizontalAlignment: Text.AlignHCenter + text: modelData.text + color: modelData.answer && row.selected ? row.look.accent : row.look.foreground font.family: row.look.fontFamily - font.pixelSize: Math.round(Style.font.caption * row.zoom) + font.pixelSize: Math.round(Style.font.heading * 1.25 * row.zoom) + font.weight: modelData.answer ? Font.DemiBold : Font.Normal + fontSizeMode: Text.HorizontalFit + minimumPixelSize: Math.round(Style.font.body * row.zoom) elide: Text.ElideRight } + + Rectangle { + visible: modelData.caption.length > 0 + anchors.horizontalCenter: parent.horizontalCenter + anchors.top: calcText.bottom + anchors.topMargin: row.px(8) + width: Math.min(captionText.implicitWidth + row.px(16), parent.width - row.px(24)) + height: captionText.implicitHeight + row.px(6) + radius: height / 2 + color: Util.alpha(row.look.foreground, 0.08) + + Text { + id: captionText + anchors.centerIn: parent + width: Math.min(implicitWidth, parent.parent.width - row.px(40)) + textFormat: Text.PlainText + text: modelData.caption + color: row.look.foreground + opacity: 0.7 + font.family: row.look.fontFamily + font.pixelSize: Math.round(Style.font.caption * row.zoom) + elide: Text.ElideRight + } + } } } - } - Rectangle { - anchors.centerIn: parent - width: row.px(28) - height: row.px(28) - radius: width / 2 - color: Util.alpha(row.look.foreground, 0.08) - Text { + Rectangle { anchors.centerIn: parent - textFormat: Text.PlainText - text: "→" - color: row.look.foreground - opacity: 0.7 - font.family: row.look.fontFamily - font.pixelSize: Math.round(Style.font.body * row.zoom) + width: row.px(28) + height: row.px(28) + radius: width / 2 + color: Util.alpha(row.look.foreground, 0.08) + Text { + anchors.centerIn: parent + textFormat: Text.PlainText + text: "→" + color: row.look.foreground + opacity: 0.7 + font.family: row.look.fontFamily + font.pixelSize: Math.round(Style.font.body * row.zoom) + } } } }