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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
209 changes: 206 additions & 3 deletions Backend.qml
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import QtQuick
import Quickshell
import Quickshell.Io
import Quickshell.Wayland
import qs.Commons
import "core/Rpc.js" as Rpc
import "core/Paths.js" as Paths
Expand All @@ -12,6 +13,8 @@ import "core/Frecency.js" as Frecency
import "core/OmarchyMenu.js" as OmarchyMenu
import "core/RootSearch.js" as RootSearch
import "core/Calculator.js" as Calculator
import "core/ClipboardHistory.js" as ClipboardHistory
import "core/Themes.js" as Themes
import "services"

Item {
Expand All @@ -35,7 +38,7 @@ Item {
property var candidates: []
property var candidatesById: ({})
property int candidatesRevision: 0
readonly property bool loading: menuSource.loading || nativeSearch.pending !== null
readonly property bool loading: menuSource.loading || themeSource.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
Expand Down Expand Up @@ -95,6 +98,10 @@ Item {
for (var i = 0; i < apps.length; i++) if (apps[i] && apps[i].id) next.push(RootSearch.appCandidate(apps[i]))
next = next.concat(RootSearch.menuCandidates(menuSource.tree, menuSource.guards, menuSource.providerItems, backend.deps))
for (var b = 0; b < RootSearch.BUILTINS.length; b++) next.push(RootSearch.builtinCandidate(RootSearch.BUILTINS[b]))
// A built-in that replaces an Omarchy command hides that command.
var superseded = ({})
RootSearch.BUILTINS.forEach(function(d) { (d.supersedes || []).forEach(function(id) { superseded[id] = true }) })
next = next.filter(function(c) { return !superseded[c.id] })
var byId = ({})
for (var j = 0; j < next.length; j++) byId[next[j].id] = next[j]
var entries = FleaSearch.entries(next, Ranker.transliterate)
Expand Down Expand Up @@ -202,6 +209,129 @@ Item {
return null
}

// ----------------------------------------------------- clipboard history

// Our own write from the history comes back through the watcher; it moves
// the entry up without counting as a new copy.
property string clipboardSelfHash: ""
property double clipboardSelfAt: 0

// The focused app: the source of a copy, and where a paste lands.
function activeApp() {
var t = ToplevelManager.activeToplevel
var appId = t ? String(t.appId || "") : ""
if (!appId) return null
var entry = DesktopEntries.heuristicLookup(appId)
return { appId: appId, name: entry && entry.name ? String(entry.name) : appId }
}

function clipboardEntries() { return clipboardStore.loaded ? clipboardStore.doc.entries : [] }

function clipboardEntry(id) {
var key = String(id).replace(/^clipboard:/, "")
var list = backend.clipboardEntries()
for (var i = 0; i < list.length; i++) if (list[i].id === key) return list[i]
return null
}

// Only files inside our clipboard directory, whatever the store says.
function deleteClipboardFiles(paths) {
var dir = backend.paths.clipboard + "/"
var mine = paths.filter(function(p) { return p.indexOf(dir) === 0 && p.indexOf("/..") < 0 })
if (mine.length) Quickshell.execDetached(["rm", "-f", "--"].concat(mine))
}

// Applies mutate(entries) → entries, then retention, and deletes the files
// no entry uses any more.
function updateClipboard(mutate) {
if (!clipboardStore.loaded) throw new Error("Clipboard History is not loaded")
var orphans = []
clipboardStore.update(function(doc) {
var before = doc.entries
var pruned = ClipboardHistory.prune(mutate(before), Date.now(), backend.config.clipboardHistoryDuration)
var kept = ({})
pruned.entries.forEach(function(e) { kept[e.id] = true })
var gone = before.filter(function(e) { return !kept[e.id] }).concat(pruned.removed)
orphans = ClipboardHistory.orphanedPaths(gone, pruned.entries)
doc.entries = pruned.entries
})
backend.deleteClipboardFiles(orphans)
backend.emit("clipboard.stateChanged", null)
}

function pruneClipboard() {
if (!clipboardStore.loaded) return
if (ClipboardHistory.prune(clipboardStore.doc.entries, Date.now(), backend.config.clipboardHistoryDuration).removed.length)
backend.updateClipboard(function(entries) { return entries })
}

function recordClipboard(obs) {
if (!clipboardStore.loaded || !obs || !obs.hash) return
// The selection that was there when the watcher started is old news.
if (obs.initial && backend.clipboardEntries().some(function(e) { return e.hash === obs.hash })) return
delete obs.initial
var source = backend.activeApp()
if (ClipboardHistory.isDisabledSource(source, backend.config.clipboardDisabledApplications)) {
backend.deleteClipboardFiles(ClipboardHistory.orphanedPaths([obs], backend.clipboardEntries()))
return
}
obs.source = source
var now = Date.now()
var self = obs.hash === backend.clipboardSelfHash && now - backend.clipboardSelfAt < 5000
var id = now.toString(36) + Math.floor(Math.random() * 1e6).toString(36)
backend.updateClipboard(function(entries) { return ClipboardHistory.add(entries, obs, now, id, self).entries })
}

// Puts an entry back on the clipboard, pasting it into the focused app if asked.
function writeClipboardEntry(e, paste) {
backend.clipboardSelfHash = e.hash
backend.clipboardSelfAt = Date.now()
var mime = e.mime === "text/uri-list" || e.type === "image" ? e.mime : ""
if (e.path) clipboard.copyFile(e.path, e.mime || "text/plain", paste)
else if (paste) clipboard.paste(e.text, mime)
else clipboard.copy(e.text, mime)
}

function clipboardAction(action, id, confirmed) {
if (action === "delete-all-entries") {
if (!confirmed) return { confirm: { title: "Delete All Entries", message: "This removes every unpinned entry from Clipboard History. Pinned entries are kept.", primary: "Delete All", destructive: true } }
backend.updateClipboard(function(entries) { return entries.filter(function(e) { return typeof e.pinned === "number" }) })
return { toast: { style: "success", title: "History cleared" } }
}
var e = backend.clipboardEntry(id)
if (!e) throw new Error("No such clipboard entry")
switch (action) {
case "open":
case "paste":
backend.writeClipboardEntry(e, true)
return { close: true }
case "copy":
backend.writeClipboardEntry(e, false)
return { close: true, hud: "Copied to Clipboard" }
case "open-link":
var files = ClipboardHistory.filePaths(e.text)
var target = files.length ? files[0] : String(e.text || "").trim()
if (/^https?:/i.test(target)) Util.execArgv(["omarchy-launch-browser", target])
else Util.execArgv(["xdg-open", target])
return { close: true }
case "pin":
case "unpin":
backend.updateClipboard(function(entries) { return ClipboardHistory.setPinned(entries, e.id, action === "pin") })
return { toast: { style: "success", title: action === "pin" ? "Pinned" : "Unpinned" } }
case "delete-entry":
backend.updateClipboard(function(entries) { return entries.filter(function(x) { return x.id !== e.id }) })
return { toast: { style: "success", title: "Entry deleted" } }
}
throw new Error("Unknown action " + action)
}

// ----------------------------------------------------------------- themes

function themeEntry(id) {
var slug = String(id).replace(/^theme:/, "")
return themeSource.themes.filter(function(t) { return t.slug === slug })[0] || null
}

// ------------------------------------------------------------- execution

function summonPlugin(id, payload) {
Expand Down Expand Up @@ -292,6 +422,7 @@ Item {
return { close: true }
}
backend.recordVisit(id, query)
if (candidate.run.push && candidate.run.push.kind === "theme") themeSource.refresh()
if (candidate.run.push) return { push: candidate.run.push }
if (candidate.run.summon) backend.summonPlugin(candidate.run.summon, candidate.run.payload || "{}")
return { close: true }
Expand Down Expand Up @@ -428,6 +559,9 @@ Item {
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 === "theme")
sections = Themes.sections(themeSource.themes, themeSource.current, query)
else if (view && view.kind === "clipboard") sections = ClipboardHistory.sections(backend.clipboardEntries(), query, String(data && data.filter || "all"), ctx.nowMs)
else if (view && view.kind === "calculator-history") sections = RootSearch.historySections(calculatorStore.doc.history, query, ctx.nowMs, Calculator.filterHistory)
else {
ctx.filter = String(data && data.filter || "all")
Expand All @@ -442,6 +576,17 @@ Item {
var rowId = String(data && data.id || "")
if (rowId === RootSearch.CALCULATOR_ID) return RootSearch.actionsFor({ kind: "calculator" })
if (rowId.indexOf("calculator-history:") === 0) return RootSearch.actionsFor({ kind: "calculator-history" })
if (rowId.indexOf("theme:") === 0) {
var shown = backend.themeEntry(rowId)
if (!shown) throw new Error("No such theme")
return Themes.actionsFor(shown, themeSource.current)
}
if (rowId.indexOf("clipboard:") === 0) {
var clip = backend.clipboardEntry(rowId)
if (!clip) throw new Error("No such clipboard entry")
var app = backend.activeApp()
return ClipboardHistory.actionsFor(clip, app ? app.name : "")
}
var candidate = backend.decorated(rowId)
if (!candidate) {
var fallback = RootSearch.FALLBACKS.filter(function(f) { return f.id === data.id })[0]
Expand All @@ -461,6 +606,10 @@ Item {
var entry = backend.historyEntry(rowId)
return entry ? RootSearch.aboutFor(RootSearch.historyCandidate(entry, nowMs), nowMs) : []
}
if (rowId.indexOf("clipboard:") === 0) {
var clip = backend.clipboardEntry(rowId)
return clip ? ClipboardHistory.detail(clip).info : []
}
var candidate = backend.decorated(rowId)
if (!candidate) {
var f = RootSearch.FALLBACKS.filter(function(x) { return x.id === rowId })[0]
Expand All @@ -470,6 +619,14 @@ Item {
return RootSearch.aboutFor(candidate, nowMs)
},
performAction: function(data) {
if (data && String(data.id).indexOf("theme:") === 0) {
var theme = backend.themeEntry(data.id)
if (!theme) throw new Error("No such theme")
themeSource.apply(theme.slug)
return { close: true }
}
if (data && String(data.id).indexOf("clipboard:") === 0)
return backend.clipboardAction(String(data.action || "open"), data.id, !!data.confirmed)
// Destructive commands come back as a confirmation unless confirmed.
if (!(data && data.confirmed)) {
var confirm = RootSearch.confirmationFor(String(data && data.id || ""), String(data && data.action || "open"))
Expand Down Expand Up @@ -520,6 +677,30 @@ Item {
}
})

// Names from Raycast's clipboard namespace (ref 02 §2); getDetail feeds
// the detail pane [D].
Rpc.register(backend.registry, "clipboard", {
getAll: function() { return backend.clipboardEntries() },
getDetail: function(data) {
var e = backend.clipboardEntry(String(data && data.id || ""))
return e ? ClipboardHistory.detail(e) : null
},
copy: function(data) { return backend.clipboardAction("copy", String(data && data.id || ""), true) },
paste: function(data) { return backend.clipboardAction("paste", String(data && data.id || ""), true) },
pin: function(data) { return backend.clipboardAction("pin", String(data && data.id || ""), true) },
unpin: function(data) { return backend.clipboardAction("unpin", String(data && data.id || ""), true) },
"delete": function(data) { return backend.clipboardAction("delete-entry", String(data && data.id || ""), true) },
deleteAll: function() { return backend.clipboardAction("delete-all-entries", "", true) }
})

Rpc.register(backend.registry, "themes", {
getAll: function() { return { current: themeSource.current, themes: themeSource.themes } },
getDetail: function(data) {
var t = backend.themeEntry(String(data && data.id || ""))
return t ? Themes.detail(t, themeSource.current) : null
}
})

Rpc.register(backend.registry, "frecency", {
visit: function(data) { backend.recordVisit(String(data.id), String(data.query || "")); return null },
get: function(data) {
Expand Down Expand Up @@ -548,11 +729,12 @@ Item {
function onValuesChanged() { rebuildTimer.restart() }
}

Component.onDestruction: { settingsStore.flush(); frecencyStore.flush(); calculatorStore.flush() }
Component.onDestruction: { settingsStore.flush(); frecencyStore.flush(); calculatorStore.flush(); clipboardStore.flush() }
onNotification: function(method, data) {
if (method === "window.visibilityChanged" && data && data.visible) {
menuSource.refresh()
calculatorData.refresh()
backend.pruneClipboard()
}
}

Expand All @@ -564,6 +746,18 @@ Item {
onFailed: function(message) { backend.log("error", "clipboard", message, null) }
}

ClipboardWatcher {
directory: backend.directoriesReady && clipboardStore.loaded ? backend.paths.clipboard : ""
enabled: backend.config.clipboardHistory
onCaptured: function(observation) { backend.recordClipboard(observation) }
onMessage: function(level, text) { backend.log(level, "clipboard", text, null) }
}

ThemeSource {
id: themeSource
onLoaded: backend.emit("themes.changed", null)
}

AppFocuser {
id: appFocuser
onLaunchRequested: function(candidate) { backend.launchApp(candidate) }
Expand All @@ -574,7 +768,7 @@ Item {
id: mkdirs
// 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]
command: Paths.setupArgv(backend.paths, [Schema.documents.settings.file, Schema.documents.frecency.file, Schema.documents.calculator.file, Schema.documents.clipboard.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 })
Expand Down Expand Up @@ -612,6 +806,15 @@ Item {
onMessage: function(level, text, metadata) { backend.log(level, "store", text, metadata) }
}

JsonStore {
id: clipboardStore
ready: backend.directoriesReady
path: backend.paths.db + "/" + Schema.documents.clipboard.file
migrations: Schema.documents.clipboard.migrations
onMessage: function(level, text, metadata) { backend.log(level, "store", text, metadata) }
onLoadedChanged: if (loaded) backend.pruneClipboard()
}

CalculatorData {
id: calculatorData
cacheDir: backend.directoriesReady ? backend.paths.cache : ""
Expand Down
2 changes: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,8 @@ 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 (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 ↵.
- **Clipboard History** (Phase 3, SPEC §5.3/§8.2, `core/ClipboardHistory.js`, `builtin:clipboard-history`, global shortcut `omacast:clipboard`): `services/ClipboardWatcher.qml` runs one `wl-paste --watch` whose constant bash script skips `CLIPBOARD_STATE != data` and `x-kde-passwordManagerHint`, prefers an `image/*` offer, keeps `text/uri-list` for `file://` copies, and prints one JSON line per copy. Images and text ≥ 30,720 bytes go to `~/.local/share/omacast/clipboard/` by MD5; short text travels in the JSON line. The backend adds the source (`ToplevelManager.activeToplevel` + `DesktopEntries.heuristicLookup`), drops disabled apps (`clipboardDisabledApplications`), dedups by hash (copy count, last copied), and prunes by `clipboardHistoryDuration` (default `P1W`, pins kept) plus a 1000-unpinned cap [D], deleting orphaned files. The first line after the watcher starts is skipped if already known, and our own writes from the history (5 s hash window) don't count as copies. UI: 40/60 list/detail (`ui/DetailPane.qml`: text, image or colour swatch, then Information rows), sections Pinned/Today/Yesterday/Earlier [I], type dropdown on Ctrl+P. Actions: Paste (↵, via `ClipboardWriter.copyFile` for files), Copy (Ctrl+↵), Open link/file (Ctrl+O), Pin (Ctrl+Shift+P), Delete (Ctrl+D), Delete All unpinned (Ctrl+Shift+D, confirmed). RPC: `clipboard.getAll|getDetail|copy|paste|pin|unpin|delete|deleteAll`. Not done: OCR, Paste Sequentially, rich-text representations (only plain text, URI lists and one image type are kept), edit/rename, Save as Snippet, moving pins, image dimensions. `omacast:clipboard` toggles: a second press closes the view.
- **Switch Theme** (`builtin:switch-theme`, `core/Themes.js`, `services/ThemeSource.qml`; Raycast's command name, Omarchy's themes [D]): the list is Omarchy's own picker rows (`omarchy-theme-switcher --print-rows`, "<preview>\t<thumbnail>", ~40 ms), reloaded on each push; the current theme is the watched `~/.local/state/omarchy/current/theme.name`. List/detail layout with the preview image (a video preview falls back to its thumbnail), opens on the current theme, ↵ runs `omarchy-theme-set <slug>`. Rows have no icon (`iconless` in ResultRow). BUILTINS `supersedes: ["omarchy:appearance.theme"]` drops Omarchy's theme picker from the candidates. Global shortcut `omacast:theme` toggles it (README shows binding it to Super+Ctrl+T in place of Omarchy's theme menu). RPC: `themes.getAll|getDetail`. Not done: live preview while moving through the list, palette swatches.
- **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 privacy coverage (`node --test tests/*.test.js`).

Expand Down
23 changes: 23 additions & 0 deletions Launcher.qml
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,13 @@ Item {
else root.show()
}

// A command's own hotkey toggles it: a second press while its view is on
// screen closes the launcher, as the launcher hotkey does.
function toggleView(commandId, kind) {
if (root.opened && launcher.view.kind === kind) root.hide()
else root.run(commandId)
}

function run(commandId) {
// `omacast run` is explicit, so it skips the confirmation alert.
var r = backend.callSync("rootSearch.performAction", { id: commandId, action: "open", query: "", confirmed: true })
Expand Down Expand Up @@ -92,6 +99,22 @@ Item {
onPressed: root.toggle()
}

// Toggles Switch Theme directly: hl.dsp.global("omacast:theme").
GlobalShortcut {
appid: "omacast"
name: "theme"
description: "Toggle omacast Switch Theme"
onPressed: root.toggleView("builtin:switch-theme", "theme")
}

// Toggles Clipboard History directly: hl.dsp.global("omacast:clipboard").
GlobalShortcut {
appid: "omacast"
name: "clipboard"
description: "Toggle omacast Clipboard History"
onPressed: root.toggleView("builtin:clipboard-history", "clipboard")
}

IpcHandler {
target: "omacast"

Expand Down
Loading