From 77c6511b269ef450d52ebc35f19d4637dc1a0fa2 Mon Sep 17 00:00:00 2001 From: WojciechSulocki-Gif Date: Wed, 12 Aug 2026 23:55:31 +0200 Subject: [PATCH 1/5] feat: add Clipper plugin --- clipper/README.md | 126 ++ clipper/panel.luau | 999 +++++++++++++ clipper/plugin.toml | 142 ++ clipper/service.luau | 1288 +++++++++++++++++ clipper/shortcut.luau | 20 + .../tests/service_global_database_test.lua | 146 ++ clipper/tests/service_parser_test.lua | 367 +++++ clipper/thumbnail.webp | Bin 0 -> 28182 bytes clipper/translations/en.json | 178 +++ clipper/widget.luau | 19 + 10 files changed, 3285 insertions(+) create mode 100644 clipper/README.md create mode 100644 clipper/panel.luau create mode 100644 clipper/plugin.toml create mode 100644 clipper/service.luau create mode 100644 clipper/shortcut.luau create mode 100644 clipper/tests/service_global_database_test.lua create mode 100644 clipper/tests/service_parser_test.lua create mode 100644 clipper/thumbnail.webp create mode 100644 clipper/translations/en.json create mode 100644 clipper/widget.luau diff --git a/clipper/README.md b/clipper/README.md new file mode 100644 index 00000000..411b2e42 --- /dev/null +++ b/clipper/README.md @@ -0,0 +1,126 @@ +# Clipper + +Clipper combines searchable clipboard history, persistent pinned clips, image previews, and freely arranged notecards in one fullscreen Noctalia workspace. + +## Plugin + +| Field | Value | +| --- | --- | +| ID | `blackbartblues/clipper` | +| Entries | Bar widget: `widget`; panel: `panel`; service: `service`; shortcut: `shortcut` | + +## Requirements + +Clipper runs on Wayland and uses the following commands: + +- `cliphist` stores, lists, decodes, deletes, and clears clipboard history. +- `wl-copy` and `wl-paste` are provided by `wl-clipboard`; they copy entries and, in private mode, watch text and image clipboard changes. +- `wtype` sends the paste shortcut after Clipper restores the previously focused window. +- `hyprctl`, `niri`, and `nc` provide focus capture and restoration on Hyprland, niri, and MangoWC respectively. Only the command for the active compositor is used; `nc` is the MangoWC client. + +The history and notecard workspace works without compositor-specific focus restoration. The **Paste** action requires the relevant compositor command and `wtype`; **Copy** does not. + +## Usage + +Add the `widget` entry to a bar to open Clipper with a click. You can also add the `shortcut` entry under **Settings → Control Center shortcuts** and bind it in your compositor. For example, the panel can be assigned to `Super`+`V`. + +Open, close, or toggle the panel directly: + +```sh +noctalia msg panel-open blackbartblues/clipper:panel +noctalia msg panel-close blackbartblues/clipper:panel +noctalia msg panel-toggle blackbartblues/clipper:panel +``` + +The panel has three working areas: + +- **Pinned clips** keeps selected text and images outside normal history retention. Copy or paste a pin, or remove it when it is no longer needed. +- **Notecards** provides editable sticky notes. Notes save automatically, can overlap, move freely, change color, and export as text files. +- **Clipboard history** searches and filters text, images, colors, links, code, and file paths. Use **Copy** to place an entry on the clipboard or **Paste** to restore the window focused before Clipper opened and paste there. + +In the history area, drag a card by its top bar into the pinned column. Notecards are moved by their top bar so their title and body remain directly editable. + +## Settings + +| Setting | Type | Default | Description | +| --- | --- | --- | --- | +| `database_mode` | `select` | `private` | Uses Clipper's isolated cliphist database or the default global cliphist database. Delete and clear affect the shared database in global mode. | +| `manage_watchers` | `bool` | `true` | Starts Clipper-owned text and image watchers in private mode. Hidden and ignored in global mode. | +| `history_limit` | `int` | `200` | Maximum entries loaded by the service. In private mode it also sets Clipper's cliphist retention limit. | +| `enable_notecards` | `bool` | `true` | Shows or hides the notecard canvas. Existing notes remain stored when hidden. | +| `max_pinned` | `int` | `20` | Maximum number of persistent pinned entries. | +| `auto_paste_delay` | `int` | `300` | Delay in milliseconds before restoring the previous window and sending `Ctrl`+`Shift`+`V`. | +| `history_cards` | `int` | `30` | Maximum cards per history page; the panel reduces the count when the available width is smaller. | +| `panel_margin_percent` | `int` | `0` | Margin around the fullscreen workspace as a percentage of the active display. | +| `show_panel_background` | `bool` | `false` | Draws a background behind the complete workspace. Pinned clips and history keep their own opaque surfaces. | +| `card_color` | `color` | `surface_variant` | Base theme role or custom color for text history cards; recognized content types retain distinct accents. | +| `pinned_color` | `color` | `primary` | Theme role or custom color for pinned cards. | +| `notes_color` | `color` | `#FFD54F` | Initial paper color for new notecards. Each note can cycle through the built-in paper palette. | +| `show_close_button` | `bool` | `true` | Shows the close action in the panel header. | + +## IPC + +Panel-level IPC controls the current panel view. The panel entry must be open for these events to have an active instance: + +```sh +noctalia msg plugin blackbartblues/clipper:panel all refresh +noctalia msg plugin blackbartblues/clipper:panel all search 'query text' +noctalia msg plugin blackbartblues/clipper:panel all filter image +noctalia msg plugin blackbartblues/clipper:panel all new-note +``` + +`filter` accepts `all`, `text`, `image`, `color`, `link`, `code`, or `file`. + +The singleton service exposes every data operation. Clipboard history IDs are the numeric IDs shown by `cliphist list`; pin and note IDs are available in the shared snapshot described below. + +```sh +# History +noctalia msg plugin blackbartblues/clipper:service all refresh +noctalia msg plugin blackbartblues/clipper:service all copy 42 +noctalia msg plugin blackbartblues/clipper:service all paste 42 +noctalia msg plugin blackbartblues/clipper:service all pin 42 +noctalia msg plugin blackbartblues/clipper:service all delete 42 +noctalia msg plugin blackbartblues/clipper:service all clear + +# Pinned clips +noctalia msg plugin blackbartblues/clipper:service all copy-pinned pin-1700000000-1 +noctalia msg plugin blackbartblues/clipper:service all paste-pinned pin-1700000000-1 +noctalia msg plugin blackbartblues/clipper:service all unpin pin-1700000000-1 + +# Notecards +noctalia msg plugin blackbartblues/clipper:service all create-note +noctalia msg plugin blackbartblues/clipper:service all update-note '{"id":"note-1700000000-1","title":"Title","content":"Body"}' +noctalia msg plugin blackbartblues/clipper:service all move-note '{"id":"note-1700000000-1","x":160,"y":240}' +noctalia msg plugin blackbartblues/clipper:service all reorder-note '{"id":"note-1700000000-1","target_index":1}' +noctalia msg plugin blackbartblues/clipper:service all cycle-note-color note-1700000000-1 +noctalia msg plugin blackbartblues/clipper:service all export-note note-1700000000-1 +noctalia msg plugin blackbartblues/clipper:service all delete-note note-1700000000-1 +``` + +Automations can also send the same request shape used internally by the panel: + +```sh +noctalia msg plugin blackbartblues/clipper:service all request \ + '{"operation":"activate","id":"42","paste":false}' +``` + +Supported `operation` values are `refresh`, `activate`, `pin`, `delete`, `wipe`, `unpin`, `copy_pinned`, `create_note`, `update_note`, `move_note`, `reorder_note`, `cycle_note_color`, `export_note`, and `delete_note`. IPC is asynchronous: the CLI confirms dispatch, while the result is published to Noctalia state as `clipper_result`. + +The service publishes these state keys for integrations: + +- `clipper_snapshot`: revision, status, error, current history metadata, pins, notecards, watcher ownership, and total item count. +- `clipper_result`: the most recent request ID, operation, success flag, error code, and operation-specific fields. +- `clipper_panel_open`: whether the Clipper panel is open. + +## Internationalization + +English is the source language and runtime fallback in `translations/en.json`. All visible panel labels, actions, statuses, notifications, widget text, shortcut text, setting labels, and setting descriptions use translation keys. Additional locales are supplied through Noctalia Translate rather than committed as machine translations. + +## Notes + +- Private history, pinned payloads, notecard metadata, and decoded image thumbnails are stored in Noctalia's plugin data directory. Notecard exports are written to `~/Documents/Clipper`. +- Global database mode follows cliphist's normal database resolution, including `XDG_CACHE_HOME`. Clipper does not start duplicate watchers in this mode; an external cliphist watcher must already populate the database. +- **Delete** and **Clear history** permanently modify the selected database. In global mode this also affects other clipboard frontends using that database. +- Clipboard bytes are streamed between `cliphist`, files, and `wl-copy`; history payloads are not decoded into Luau. Preview metadata and cached image thumbnails are bounded. +- The plugin makes no network requests. It spawns only the commands listed under **Requirements**, plus standard shell utilities used to supervise watchers and bounded subprocesses. +- Automatic paste currently restores focus on Hyprland, niri, and MangoWC. Unsupported compositors can still use **Copy** and paste manually. diff --git a/clipper/panel.luau b/clipper/panel.luau new file mode 100644 index 00000000..196c814e --- /dev/null +++ b/clipper/panel.luau @@ -0,0 +1,999 @@ +--!nonstrict +-- Clipper — full-screen clipboard workspace for Noctalia V5. +-- +-- The service owns cliphist and persistent data. This panel keeps only +-- presentation state and bounded form drafts. The three-zone composition is +-- intentionally inherited from Clipper V4: pins on the left, notes in the +-- workspace, and a compact clipboard shelf along the bottom. + +local SNAPSHOT_KEY = "clipper_snapshot" +local REQUEST_KEY = "clipper_request" +local PANEL_OPEN_KEY = "clipper_panel_open" +local NOTE_DRAG_TYPE = "clipper/note" +local HISTORY_DRAG_TYPE = "clipper/history" + +local snapshot = { + revision = 0, + status = "loading", + error = "", + items = {}, + pinned = {}, + notes = {}, + watchers_running = false, + total = 0, +} + +local searchQuery = "" +local activeFilter = "all" +local historyPage = 1 +local requestCounter = 0 +local clearConfirm = false +local noteDrafts = {} +local noteDirty = {} +local noteSaveAfter = {} + +local NOTE_PAPER_SIZE = 286 +local NOTE_AUTOSAVE_SECONDS = 1 +local NOTE_PAPER_COLORS = { + yellow = "#FFD54F", + pink = "#F48FB1", + blue = "#90CAF9", + green = "#A5D6A7", + lavender = "#CE93D8", +} +local LEGACY_NOTE_PAPER_COLORS = { + ["tertiary"] = NOTE_PAPER_COLORS.yellow, + ["tertiary/0.22"] = NOTE_PAPER_COLORS.yellow, + ["secondary"] = NOTE_PAPER_COLORS.pink, + ["secondary/0.22"] = NOTE_PAPER_COLORS.pink, + ["primary"] = NOTE_PAPER_COLORS.blue, + ["primary/0.18"] = NOTE_PAPER_COLORS.blue, + ["surface_variant"] = NOTE_PAPER_COLORS.green, + ["surface"] = NOTE_PAPER_COLORS.lavender, + ["#FFD54F66"] = NOTE_PAPER_COLORS.yellow, + ["#F48FB166"] = NOTE_PAPER_COLORS.pink, + ["#90CAF966"] = NOTE_PAPER_COLORS.blue, + ["#A5D6A766"] = NOTE_PAPER_COLORS.green, + ["#CE93D866"] = NOTE_PAPER_COLORS.lavender, +} + +local env = getfenv() +local previousDynamicCallbacks = {} +local currentDynamicCallbacks = {} + +local function tr(key, args) + return noctalia.tr(key, args) +end + +local function asString(value) + if value == nil then return "" end + return tostring(value) +end + +local function asArray(value) + return type(value) == "table" and value or {} +end + +local function clamp(value, low, high) + if value < low then return low end + if value > high then return high end + return value +end + +local function focusedOutputSize() + local outputs = noctalia.outputs() + local fallbackWidth = 1920 + local fallbackHeight = 1080 + for _, output in ipairs(type(outputs) == "table" and outputs or {}) do + local width = tonumber(output.width) + local height = tonumber(output.height) + if width ~= nil and width > 0 then fallbackWidth = width end + if height ~= nil and height > 0 then fallbackHeight = height end + if output.focused == true then return fallbackWidth, fallbackHeight end + end + return fallbackWidth, fallbackHeight +end + +local function normalized(value) + return noctalia.string.trim(asString(value)):lower() +end + +local function shortHash(value) + local hash = 5381 + value = asString(value) + for index = 1, #value do + hash = (hash * 33 + value:byte(index)) % 4294967296 + end + return string.format("%08x", hash) +end + +local function dynamicCallback(prefix, stableId, callback) + local name = "clipper_" .. prefix .. "_" .. shortHash(stableId) + env[name] = callback + currentDynamicCallbacks[name] = true + return name +end + +local function beginDynamicCallbacks() + currentDynamicCallbacks = {} +end + +local function finishDynamicCallbacks() + for name, _ in pairs(previousDynamicCallbacks) do + if currentDynamicCallbacks[name] ~= true then + env[name] = nil + end + end + previousDynamicCallbacks = currentDynamicCallbacks +end + +local function cfg(key, fallback) + local value = noctalia.getConfig(key) + if value == nil then return fallback end + return value +end + +local function panelGeometry() + local width, height = focusedOutputSize() + local percent = clamp(tonumber(cfg("panel_margin_percent", 0)) or 0, 0, 10) + local marginH = math.floor(width * percent / 100 + 0.5) + local marginV = math.floor(height * percent / 100 + 0.5) + return { + marginH = marginH, + marginV = marginV, + contentWidth = math.max(520, width - (2 * marginH) - 24), + radius = percent > 0 and 16 or 0, + } +end + +local function request(operation, fields) + requestCounter += 1 + local payload = { + request_id = tostring(os.time()) .. "-" .. tostring(requestCounter), + operation = operation, + } + if type(fields) == "table" then + for key, value in pairs(fields) do payload[key] = value end + end + noctalia.state.set(REQUEST_KEY, payload) +end + +local function itemKind(item) + local declared = normalized(item.kind) + if declared ~= "" then return declared end + if item.is_image == true then return "image" end + + local preview = noctalia.string.trim(asString(item.preview)) + local lower = preview:lower() + if preview:match("^#%x%x%x%x%x%x$") or preview:match("^#%x%x%x$") + or lower:match("^rgba?%s*%(") then + return "color" + end + if lower:match("^https?://") then return "link" end + if preview:match("^/") or preview:match("^~/") or lower:match("^file://") then return "file" end + if lower:find("function", 1, true) or lower:find("import ", 1, true) + or lower:find("const ", 1, true) or lower:find("class ", 1, true) + or lower:find("def ", 1, true) then + return "code" + end + return "text" +end + +local FILTERS = { + { id = "all", glyph = "clipboard", key = "panel.filters.all" }, + { id = "text", glyph = "align-left", key = "panel.filters.text" }, + { id = "image", glyph = "photo", key = "panel.filters.images" }, + { id = "color", glyph = "palette", key = "panel.filters.colors" }, + { id = "link", glyph = "link", key = "panel.filters.links" }, + { id = "code", glyph = "code", key = "panel.filters.code" }, + { id = "file", glyph = "file", key = "panel.filters.files" }, +} + +local KIND_GLYPHS = { + text = "align-left", + image = "photo", + color = "palette", + link = "link", + code = "code", + file = "file", +} + +local KIND_CARD_COLORS = { + image = "tertiary/0.32", + color = "primary/0.30", + link = "secondary/0.30", + code = "warning/0.24", + file = "error/0.18", +} + +local KIND_ACCENTS = { + text = "on_surface_variant", + image = "tertiary", + color = "primary", + link = "secondary", + code = "warning", + file = "error", +} + +local HISTORY_CARD_SIZE = 184 + +local function kindLabel(kind) + local key = "panel.kinds." .. kind + return tr(key) +end + +local function filterItems() + local query = normalized(searchQuery) + local result = {} + for _, item in ipairs(asArray(snapshot.items)) do + local kind = itemKind(item) + local matchesKind = activeFilter == "all" or kind == activeFilter + local matchesQuery = query == "" or normalized(item.preview):find(query, 1, true) ~= nil + if matchesKind and matchesQuery then + local copy = {} + for key, value in pairs(item) do copy[key] = value end + copy.kind = kind + result[#result + 1] = copy + end + end + return result +end + +local function filterCount(kind) + if kind == "all" then return #asArray(snapshot.items) end + local count = 0 + for _, item in ipairs(asArray(snapshot.items)) do + if itemKind(item) == kind then count += 1 end + end + return count +end + +local function previewText(item, limit) + local text = asString(item.preview):gsub("\r", ""):gsub("\t", " ") + if #text > limit then return text:sub(1, limit - 1) .. "…" end + return text +end + +local function cardFill(kind) + return KIND_CARD_COLORS[kind] or cfg("card_color", "surface_variant") +end + +local function cardAccent(kind) + return KIND_ACCENTS[kind] or "primary" +end + +local function sendActivate(item, paste) + request("activate", { id = asString(item.id), paste = paste == true }) + panel.close() +end + +local function historyCard(item, width) + local itemId = asString(item.id) + local kind = item.kind or itemKind(item) + local activate = dynamicCallback("activate", itemId, function() + sendActivate(item, false) + end) + local activatePaste = dynamicCallback("paste", itemId, function() + sendActivate(item, true) + end) + local pin = dynamicCallback("pin", itemId, function() + request("pin", { id = itemId }) + end) + local remove = dynamicCallback("delete", itemId, function() + request("delete", { id = itemId }) + end) + + local body + if item.image_path ~= nil and item.image_path ~= "" then + body = ui.image({ + key = "history-image-" .. itemId, + path = item.image_path, + width = width - 20, + height = 86, + radius = 8, + fit = "contain", + onClick = activate, + }) + else + body = ui.column({ + key = "history-body-" .. itemId, + fill = "surface/0.22", + radius = 8, + height = 86, + padding = 6, + justify = "center", + }, { + ui.label({ + text = kind == "image" and tr("panel.image_preview_pending") or previewText(item, 120), + color = "on_surface", + fontSize = 10, + maxLines = 4, + width = width - 32, + }), + }) + end + + local dragHandleWidth = math.max(64, width - 88) + local dragHandle = ui.dragSource({ + key = "history-source-" .. itemId, + width = dragHandleWidth, + height = 28, + align = "center", + dragType = HISTORY_DRAG_TYPE, + payload = itemId, + previewAncestor = 2, + liftFromLayout = true, + radius = 6, + tooltip = tr("panel.actions.drag_to_pin"), + }, { + ui.row({ width = dragHandleWidth, gap = 4, align = "center" }, { + ui.glyph({ name = "grip-vertical", size = 12, color = "on_surface_variant" }), + ui.glyph({ name = KIND_GLYPHS[kind] or "clipboard", size = 14, color = cardAccent(kind) }), + ui.label({ text = kindLabel(kind), color = "on_surface_variant", fontSize = 9, flexGrow = 1 }), + }), + }) + + local card = ui.column({ + key = "history-card-" .. itemId, + width = width, + height = width, + padding = 8, + gap = 7, + fill = cardFill(kind), + radius = 12, + border = cardAccent(kind) .. "/0.58", + borderWidth = 1, + }, { + ui.row({ gap = 5, align = "center" }, { + dragHandle, + ui.button({ + glyph = "pin", variant = "ghost", width = 28, height = 28, + tooltip = tr("panel.actions.pin"), onClick = pin, + }), + ui.button({ + glyph = "trash", variant = "ghost", width = 28, height = 28, + tooltip = tr("panel.actions.delete"), onClick = remove, + }), + }), + body, + ui.row({ gap = 5, align = "center", height = 30 }, { + ui.button({ + text = tr("panel.actions.copy"), glyph = "copy", variant = "ghost", height = 30, + onClick = activate, flexGrow = 1, + }), + ui.button({ + text = tr("panel.actions.paste"), glyph = "clipboard-check", variant = "ghost", height = 30, + onClick = activatePaste, flexGrow = 1, + }), + }), + }) + return card +end + +local function pinnedCard(item) + local itemId = asString(item.id) + local kind = item.kind or itemKind(item) + local copy = dynamicCallback("copy_pin", itemId, function() + request("copy_pinned", { id = itemId, paste = false }) + panel.close() + end) + local paste = dynamicCallback("paste_pin", itemId, function() + request("copy_pinned", { id = itemId, paste = true }) + panel.close() + end) + local unpin = dynamicCallback("unpin", itemId, function() + request("unpin", { id = itemId }) + end) + + local previewNode + if item.image_path ~= nil and item.image_path ~= "" then + previewNode = ui.image({ + path = item.image_path, width = 210, height = 92, radius = 8, fit = "contain", onClick = copy, + }) + else + previewNode = ui.label({ + text = previewText(item, 150), color = "on_surface", fontSize = 10, maxLines = 5, width = 210, + }) + end + + return ui.column({ + key = "pinned-" .. itemId, + padding = 9, + gap = 7, + fill = cfg("pinned_color", "primary"), + radius = 12, + border = "outline/0.42", + borderWidth = 1, + }, { + ui.row({ gap = 5, align = "center" }, { + ui.glyph({ name = KIND_GLYPHS[kind] or "pin", size = 14, color = "secondary" }), + ui.label({ text = kindLabel(kind), fontSize = 9, color = "on_surface_variant", flexGrow = 1 }), + ui.button({ glyph = "unpin", variant = "ghost", controlSize = "sm", tooltip = tr("panel.actions.unpin"), onClick = unpin }), + }), + previewNode, + ui.row({ gap = 5, align = "center" }, { + ui.button({ + text = tr("panel.actions.copy"), glyph = "copy", variant = "ghost", controlSize = "sm", + onClick = copy, flexGrow = 1, + }), + ui.button({ + text = tr("panel.actions.paste"), glyph = "clipboard-check", variant = "ghost", controlSize = "sm", + onClick = paste, flexGrow = 1, + }), + }), + }) +end + +local function draftFor(note) + local id = asString(note.id) + local draft = noteDrafts[id] + if draft == nil then + draft = { title = asString(note.title), content = asString(note.content) } + noteDrafts[id] = draft + end + return draft +end + +local function notePaperFill(note) + local color = asString(note.color) + if color == "" then color = asString(cfg("notes_color", NOTE_PAPER_COLORS.yellow)) end + return LEGACY_NOTE_PAPER_COLORS[color] or color +end + +local function notePaperBorder(note) + local red, green, blue = notePaperFill(note):match("^#(%x%x)(%x%x)(%x%x)$") + if red == nil then return "on_surface/0.16" end + local function soften(channel) + local value = tonumber(channel, 16) or 0 + return math.floor(value + ((255 - value) * 0.42) + 0.5) + end + return string.format("#%02X%02X%02X", soften(red), soften(green), soften(blue)) +end + +local function saveNoteDraft(noteId) + local draft = noteDrafts[noteId] + if draft == nil or noteDirty[noteId] ~= true then return false end + request("update_note", { id = noteId, title = draft.title, content = draft.content }) + noteDirty[noteId] = nil + noteSaveAfter[noteId] = nil + return true +end + +local function scheduleNoteSave(noteId) + noteDirty[noteId] = true + noteSaveAfter[noteId] = os.time() + NOTE_AUTOSAVE_SECONDS +end + +local function noteCard(note, index) + local noteId = asString(note.id) + local draft = draftFor(note) + local function bringToFront() + local highestZ = -1 + local topNoteId = "" + for _, candidate in ipairs(asArray(snapshot.notes)) do + local candidateZ = tonumber(candidate.z) or 0 + if candidateZ >= highestZ then + highestZ = candidateZ + topNoteId = asString(candidate.id) + end + end + if topNoteId == noteId then return end + request("move_note", { + id = noteId, + x = tonumber(note.x) or (24 + ((index - 1) * 36)), + y = tonumber(note.y) or (24 + ((index - 1) * 28)), + }) + end + local focus = dynamicCallback("note_focus", noteId, bringToFront) + local titleChanged = dynamicCallback("note_title", noteId, function(value) + draft.title = asString(value) + scheduleNoteSave(noteId) + render() + end) + local contentChanged = dynamicCallback("note_content", noteId, function(value) + draft.content = asString(value) + scheduleNoteSave(noteId) + render() + end) + local save = dynamicCallback("note_save", noteId, function() + if saveNoteDraft(noteId) then render() end + end) + local remove = dynamicCallback("note_delete", noteId, function() + request("delete_note", { id = noteId }) + noteDrafts[noteId] = nil + noteDirty[noteId] = nil + noteSaveAfter[noteId] = nil + end) + local export = dynamicCallback("note_export", noteId, function() + bringToFront() + request("export_note", { id = noteId }) + end) + local cycle = dynamicCallback("note_color", noteId, function() + bringToFront() + request("cycle_note_color", { id = noteId }) + end) + + local noteDragHandle = ui.dragSource({ + key = "note-source-" .. noteId, + width = 24, + height = 28, + align = "center", + justify = "center", + dragType = NOTE_DRAG_TYPE, + payload = noteId, + previewAncestor = 2, + liftFromLayout = true, + radius = 5, + tooltip = tr("panel.notes.drag_hint"), + }, { + ui.glyph({ name = "grip-vertical", size = 15, color = "on_surface_variant" }), + }) + + local headerChildren = { + noteDragHandle, + ui.input({ + key = "note-title-" .. noteId, + value = draft.title, + placeholder = tr("panel.notes.untitled"), + onChange = titleChanged, + onSubmit = save, + onPress = focus, + controlSize = "sm", + fontSize = 14, + frameVisible = false, + flexGrow = 1, + }), + ui.button({ + glyph = "palette", + variant = "ghost", + controlSize = "sm", + tooltip = tr("panel.notes.change_color"), + onClick = cycle, + }), + ui.button({ + glyph = "file-export", + variant = "ghost", + controlSize = "sm", + tooltip = tr("panel.notes.export"), + onClick = export, + }), + ui.button({ + glyph = "trash", + variant = "ghost", + controlSize = "sm", + tooltip = tr("panel.notes.delete"), + onClick = remove, + }), + } + + local noteBody = ui.input({ + key = "note-content-" .. noteId, + value = draft.content, + placeholder = tr("panel.notes.content_placeholder"), + multiline = true, + height = 142, + flexGrow = 1, + fontSize = 12, + frameVisible = false, + onChange = contentChanged, + onSubmit = save, + onPress = focus, + }) + + local paper = ui.column({ + key = "note-card-content-" .. noteId, + width = NOTE_PAPER_SIZE, + height = NOTE_PAPER_SIZE, + padding = 12, + gap = 7, + fill = notePaperFill(note), + radius = 5, + border = notePaperBorder(note), + borderWidth = 1, + onClick = focus, + }, { + ui.row({ key = "note-header-" .. noteId, gap = 5, align = "center" }, headerChildren), + noteBody, + }) + + local content = ui.column({ + key = "note-paper-wrap-" .. noteId, + width = NOTE_PAPER_SIZE + 4, + gap = 0, + }, { + ui.row({ key = "note-paper-side-shadow-" .. noteId, gap = 0, align = "end" }, { + paper, + ui.box({ + key = "note-side-shadow-" .. noteId, + width = 4, + height = NOTE_PAPER_SIZE - 5, + fill = "shadow/0.16", + radius = 2, + softness = 1, + }), + }), + ui.row({ key = "note-bottom-shadow-row-" .. noteId, height = 4, justify = "end" }, { + ui.box({ + key = "note-bottom-shadow-" .. noteId, + width = NOTE_PAPER_SIZE - 5, + height = 4, + fill = "shadow/0.20", + radius = 2, + softness = 1, + }), + }), + }) + + return ui.column({ + key = "note-position-" .. noteId, + position = "absolute", + x = tonumber(note.x) or (24 + ((index - 1) * 36)), + y = tonumber(note.y) or (24 + ((index - 1) * 28)), + zIndex = tonumber(note.z) or index, + width = NOTE_PAPER_SIZE + 4, + height = NOTE_PAPER_SIZE + 4, + }, { content }) +end + +local function headerNode() + local statusText + if snapshot.status == "error" then + statusText = tr("panel.status.error") + elseif snapshot.status == "loading" then + statusText = tr("panel.status.loading") + elseif snapshot.watchers_running == true then + statusText = tr("panel.status.live") + else + statusText = tr("panel.status.manual") + end + + local children = { + ui.row({ gap = 9, align = "center", flexGrow = 1 }, { + ui.glyph({ name = "clipboard", size = 25, color = "primary" }), + ui.column({ gap = 1 }, { + ui.label({ text = tr("panel.title"), fontSize = 17, fontWeight = "bold" }), + ui.label({ text = statusText, fontSize = 9, color = "on_surface_variant" }), + }), + }), + ui.button({ glyph = "refresh", variant = "ghost", tooltip = tr("panel.actions.refresh"), onClick = "onRefresh" }), + ui.button({ glyph = "settings", variant = "ghost", tooltip = tr("panel.actions.settings"), onClick = "onSettings" }), + } + if cfg("show_close_button", true) == true then + children[#children + 1] = ui.button({ glyph = "close", variant = "ghost", tooltip = tr("panel.actions.close"), onClick = "onCloseClicked" }) + end + return ui.row({ gap = 8, align = "center", paddingH = 4 }, children) +end + +local function pinnedPane() + local cards = {} + for _, item in ipairs(asArray(snapshot.pinned)) do cards[#cards + 1] = pinnedCard(item) end + if #cards == 0 then + cards[1] = ui.column({ align = "center", gap = 8, padding = 18 }, { + ui.glyph({ name = "pin", size = 26, color = "on_surface_variant" }), + ui.label({ text = tr("panel.pinned.empty"), color = "on_surface_variant", textAlign = "center", maxLines = 2 }), + }) + end + + local pane = ui.column({ + key = "pinned-pane", + width = 250, + flexGrow = 1, + padding = 10, + gap = 9, + fill = "surface_variant", + radius = 14, + border = "outline/0.38", + borderWidth = 1, + }, { + ui.row({ gap = 7, align = "center" }, { + ui.glyph({ name = "pin", size = 17, color = "secondary" }), + ui.label({ text = tr("panel.pinned.title"), fontSize = 12, fontWeight = "bold", flexGrow = 1 }), + ui.label({ text = tostring(#asArray(snapshot.pinned)), color = "on_surface_variant", fontSize = 9 }), + }), + ui.scroll({ flexGrow = 1, fill = "surface", radius = 10 }, cards), + }) + return ui.dropZone({ + key = "pinned-drop-zone", + accepts = { HISTORY_DRAG_TYPE }, + value = "pin", + onDrop = "onPinDrop", + direction = "column", + align = "stretch", + hitSlop = 24, + radius = 14, + border = "primary/0.7", + borderWidth = 1, + }, { pane }) +end + +local function notesPane() + local noteList = asArray(snapshot.notes) + local canvasChildren = {} + if #noteList == 0 then + canvasChildren[1] = ui.column({ align = "center", justify = "center", gap = 9, padding = 28, flexGrow = 1 }, { + ui.glyph({ name = "notes", size = 36, color = "tertiary" }), + ui.label({ text = tr("panel.notes.empty"), fontSize = 13, fontWeight = "bold" }), + ui.label({ text = tr("panel.notes.empty_hint"), color = "on_surface_variant", maxLines = 2, textAlign = "center" }), + ui.button({ text = tr("panel.notes.create"), glyph = "add", onClick = "onCreateNote" }), + }) + else + for index, note in ipairs(noteList) do canvasChildren[#canvasChildren + 1] = noteCard(note, index) end + end + + local canvas = ui.dropZone({ + key = "notes-canvas", + accepts = { NOTE_DRAG_TYPE }, + value = "canvas", + onDrop = "onNoteDrop", + direction = "column", + flexGrow = 1, + clipChildren = true, + }, canvasChildren) + + return ui.column({ + key = "notes-pane", + flexGrow = 1, + padding = 10, + gap = 9, + }, { + ui.row({ gap = 7, align = "center", justify = "end" }, { + ui.label({ text = tr("panel.notes.count", { count = #asArray(snapshot.notes) }), color = "on_surface_variant", fontSize = 9 }), + ui.button({ text = tr("panel.notes.create"), glyph = "add", controlSize = "sm", onClick = "onCreateNote" }), + }), + canvas, + }) +end + +local function filtersNode() + local buttons = {} + for _, filter in ipairs(FILTERS) do + local filterId = filter.id + buttons[#buttons + 1] = ui.button({ + key = "filter-" .. filterId, + text = tr(filter.key) .. " " .. tostring(filterCount(filterId)), + glyph = filter.glyph, + variant = activeFilter == filterId and "primary" or "ghost", + controlSize = "sm", + onClick = dynamicCallback("filter", filterId, function() + activeFilter = filterId + historyPage = 1 + render() + end), + }) + end + return ui.row({ gap = 4, align = "center" }, buttons) +end + +local function historyPane(usableWidth) + local filtered = filterItems() + local desiredPerPage = clamp(tonumber(cfg("history_cards", 30)) or 30, 3, 30) + local widthLimited = math.max(3, math.floor((usableWidth + 8) / (HISTORY_CARD_SIZE + 8))) + local perPage = math.min(desiredPerPage, widthLimited) + local pages = math.max(1, math.ceil(#filtered / perPage)) + historyPage = clamp(historyPage, 1, pages) + local first = (historyPage - 1) * perPage + 1 + local last = math.min(#filtered, first + perPage - 1) + local cards = {} + for index = first, last do cards[#cards + 1] = historyCard(filtered[index], HISTORY_CARD_SIZE) end + if #cards == 0 then + cards[1] = ui.column({ align = "center", gap = 6, padding = 18, flexGrow = 1 }, { + ui.glyph({ name = searchQuery ~= "" and "search-off" or "clipboard-off", size = 26, color = "on_surface_variant" }), + ui.label({ + text = (searchQuery ~= "" or activeFilter ~= "all") and tr("panel.history.no_matches") or tr("panel.history.empty"), + color = "on_surface_variant", + }), + }) + end + + local clearText = clearConfirm and tr("panel.history.confirm_clear") or tr("panel.history.clear") + return ui.column({ + key = "history-pane", + height = 305, + padding = 10, + gap = 8, + fill = "surface_variant", + radius = 14, + border = "outline/0.42", + borderWidth = 1, + }, { + ui.row({ gap = 8, align = "center" }, { + ui.glyph({ name = "history", size = 17, color = "primary" }), + ui.label({ text = tr("panel.history.title"), fontSize = 12, fontWeight = "bold" }), + ui.input({ + key = "history-search", + value = searchQuery, + placeholder = tr("panel.history.search"), + onChange = "onSearchChanged", + controlSize = "sm", + flexGrow = 1, + }), + ui.button({ + text = clearText, + glyph = clearConfirm and "alert-triangle" or "trash", + variant = clearConfirm and "destructive" or "ghost", + controlSize = "sm", + onClick = "onClearHistory", + }), + }), + filtersNode(), + ui.row({ gap = 8, align = "start", flexGrow = 1 }, cards), + ui.row({ gap = 7, align = "center", justify = "center" }, { + ui.button({ glyph = "chevron-left", variant = "ghost", controlSize = "sm", enabled = historyPage > 1, onClick = "onPreviousPage" }), + ui.label({ text = tr("panel.history.page", { page = historyPage, pages = pages }), color = "on_surface_variant", fontSize = 9 }), + ui.button({ glyph = "chevron-right", variant = "ghost", controlSize = "sm", enabled = historyPage < pages, onClick = "onNextPage" }), + }), + }) +end + +function render() + beginDynamicCallbacks() + local geometry = panelGeometry() + local showPanelBackground = cfg("show_panel_background", false) == true + local body = { + headerNode(), + ui.row({ key = "workspace", gap = 10, flexGrow = 1, align = "stretch" }, { + pinnedPane(), + notesPane(), + }), + historyPane(geometry.contentWidth), + } + if snapshot.error ~= nil and snapshot.error ~= "" then + table.insert(body, 2, ui.row({ + gap = 7, align = "center", padding = 8, fill = "error/0.14", radius = 10, + }, { + ui.glyph({ name = "alert-triangle", size = 15, color = "error" }), + ui.label({ text = tr("notifications." .. asString(snapshot.error)), color = "error", maxLines = 2, flexGrow = 1 }), + })) + end + + panel.render(ui.column({ + key = "clipper-viewport", + flexGrow = 1, + align = "stretch", + paddingH = geometry.marginH, + paddingV = geometry.marginV, + }, { + ui.column({ + key = "clipper-root", + flexGrow = 1, + align = "stretch", + padding = 12, + gap = 10, + fill = showPanelBackground and "surface/0.96" or "#00000000", + radius = geometry.radius, + }, body), + })) + finishDynamicCallbacks() +end + +function onOpen() + noctalia.state.set(PANEL_OPEN_KEY, true) + panel.setWantsSecondTicks(true) + request("refresh") + render() +end + +function onClose() + noctalia.state.set(PANEL_OPEN_KEY, false) + for noteId, _ in pairs(noteDirty) do + saveNoteDraft(noteId) + end +end + +function update() + local now = os.time() + local due = {} + for noteId, deadline in pairs(noteSaveAfter) do + if deadline <= now then due[#due + 1] = noteId end + end + local saved = false + for _, noteId in ipairs(due) do + if saveNoteDraft(noteId) then saved = true end + end + if saved then render() end +end + +function onRefresh() + request("refresh") +end + +function onSettings() + noctalia.runAsync("noctalia msg settings-open plugins") +end + +function onCloseClicked() + panel.close() +end + +function onSearchChanged(value) + searchQuery = asString(value) + historyPage = 1 + clearConfirm = false + render() +end + +function onPreviousPage() + historyPage = math.max(1, historyPage - 1) + render() +end + +function onNextPage() + historyPage += 1 + render() +end + +function onClearHistory() + if not clearConfirm then + clearConfirm = true + render() + return + end + clearConfirm = false + request("wipe") +end + +function onCreateNote() + request("create_note") +end + +function onNoteDrop(payload, target, x, y) + local noteId = asString(payload) + local dropX = tonumber(x) + local dropY = tonumber(y) + if noteId == "" or target ~= "canvas" or dropX == nil or dropY == nil then return end + request("move_note", { id = noteId, x = dropX, y = dropY }) +end + +function onPinDrop(payload, _target) + local itemId = asString(payload) + if itemId:match("^%d+$") == nil then return end + request("pin", { id = itemId }) +end + +function onIpc(event, payload) + if event == "refresh" then + request("refresh") + elseif event == "search" then + searchQuery = asString(payload) + historyPage = 1 + render() + elseif event == "filter" then + local value = normalized(payload) + for _, filter in ipairs(FILTERS) do + if filter.id == value then + activeFilter = value + historyPage = 1 + render() + break + end + end + elseif event == "new-note" then + onCreateNote() + end +end + +noctalia.state.watch(SNAPSHOT_KEY, function(value) + if type(value) ~= "table" then return end + snapshot = value + + local present = {} + for _, note in ipairs(asArray(snapshot.notes)) do + local noteId = asString(note.id) + present[noteId] = true + if noteDirty[noteId] ~= true then + noteDrafts[noteId] = { title = asString(note.title), content = asString(note.content) } + end + end + for noteId, _ in pairs(noteDrafts) do + if present[noteId] ~= true then + noteDrafts[noteId] = nil + noteDirty[noteId] = nil + noteSaveAfter[noteId] = nil + end + end + render() +end) + +local initial = noctalia.state.get(SNAPSHOT_KEY) +if type(initial) == "table" then snapshot = initial end +render() diff --git a/clipper/plugin.toml b/clipper/plugin.toml new file mode 100644 index 00000000..f9c37b50 --- /dev/null +++ b/clipper/plugin.toml @@ -0,0 +1,142 @@ +id = "blackbartblues/clipper" +name = "Clipper" +version = "0.1.0" +plugin_api = 5 +author = "blackbartblues" +license = "MIT" +dependencies = ["cliphist", "wl-copy", "wl-paste", "wtype", "hyprctl", "niri", "nc"] +tags = ["bar", "panel", "service", "shortcut", "productivity", "utility", "hyprland", "niri", "mangowc"] +icon = "clipboard" +description = "Clipboard history, pinned clips, and quick notes in a searchable panel." + +[[setting]] +key = "database_mode" +type = "select" +label_key = "settings.database_mode.label" +description_key = "settings.database_mode.description" +default = "private" +options = [ + { value = "private", label_key = "settings.database_mode.options.private" }, + { value = "global", label_key = "settings.database_mode.options.global" }, +] + +[[setting]] +key = "manage_watchers" +type = "bool" +label_key = "settings.manage_watchers.label" +description_key = "settings.manage_watchers.description" +default = true +visible_when = { key = "database_mode", values = ["private"] } + +[[setting]] +key = "history_limit" +type = "int" +label_key = "settings.history_limit.label" +description_key = "settings.history_limit.description" +default = 200 +min = 10 +max = 2000 +step = 10 + +[[setting]] +key = "enable_notecards" +type = "bool" +label_key = "settings.enable_notecards.label" +description_key = "settings.enable_notecards.description" +default = true + +[[setting]] +key = "max_pinned" +type = "int" +label_key = "settings.max_pinned.label" +description_key = "settings.max_pinned.description" +default = 20 +min = 1 +max = 100 +step = 1 + +[[setting]] +key = "auto_paste_delay" +type = "int" +label_key = "settings.auto_paste_delay.label" +description_key = "settings.auto_paste_delay.description" +default = 300 +min = 0 +max = 5000 +step = 50 + +[[setting]] +key = "history_cards" +type = "int" +label_key = "settings.history_cards.label" +description_key = "settings.history_cards.description" +default = 30 +min = 3 +max = 30 +step = 1 + +[[setting]] +key = "panel_margin_percent" +type = "int" +label_key = "settings.panel_margin_percent.label" +description_key = "settings.panel_margin_percent.description" +default = 0 +min = 0 +max = 10 +step = 1 + +[[setting]] +key = "show_panel_background" +type = "bool" +label_key = "settings.show_panel_background.label" +description_key = "settings.show_panel_background.description" +default = false + +[[setting]] +key = "card_color" +type = "color" +label_key = "settings.card_color.label" +description_key = "settings.card_color.description" +default = "surface_variant" + +[[setting]] +key = "pinned_color" +type = "color" +label_key = "settings.pinned_color.label" +description_key = "settings.pinned_color.description" +default = "primary" + +[[setting]] +key = "notes_color" +type = "color" +label_key = "settings.notes_color.label" +description_key = "settings.notes_color.description" +default = "#FFD54F" + +[[setting]] +key = "show_close_button" +type = "bool" +label_key = "settings.show_close_button.label" +description_key = "settings.show_close_button.description" +default = true + +[[widget]] +id = "widget" +entry = "widget.luau" + +[[panel]] +id = "panel" +entry = "panel.luau" +width = "fill" +height = "fill" +placement = "floating" +decorated = false +position = "center" + +[[service]] +id = "service" +entry = "service.luau" + +[[shortcut]] +id = "shortcut" +entry = "shortcut.luau" diff --git a/clipper/service.luau b/clipper/service.luau new file mode 100644 index 00000000..0895bbab --- /dev/null +++ b/clipper/service.luau @@ -0,0 +1,1288 @@ +--!nonstrict +-- Clipper backend for Noctalia v5. +-- +-- Shared-state contract: +-- clipper_snapshot { revision, status, error, items, pinned, notes, +-- watchers_running, total } +-- clipper_request { request_id, operation, id?, paste? } +-- clipper_result { request_id, operation, ok, error, id?, ... } +-- clipper_panel_open boolean +-- +-- The service never decodes clipboard payloads into Lua. `cliphist list` +-- provides bounded metadata, image previews are decoded directly into a +-- plugin-owned cache, and activation streams bytes directly to wl-copy. + +local SNAPSHOT_KEY = "clipper_snapshot" +local REQUEST_KEY = "clipper_request" +local RESULT_KEY = "clipper_result" +local PANEL_OPEN_KEY = "clipper_panel_open" + +local DEFAULT_HISTORY_LIMIT = 100 +local MIN_HISTORY_LIMIT = 1 +local MAX_HISTORY_LIMIT = 2000 +local PREVIEW_WIDTH = 240 +local REFRESH_TIMEOUT_MS = 5000 +local ACTION_TIMEOUT_MS = 5000 +local ID_MAX_BYTES = 20 +local REQUEST_ID_MAX_BYTES = 128 +local IPC_PAYLOAD_MAX_BYTES = 512 * 1024 +local MAX_PIN_TEXT_BYTES = 1024 * 1024 +local MAX_PIN_IMAGE_BYTES = 10 * 1024 * 1024 +local MAX_THUMBNAIL_BYTES = 25 * 1024 * 1024 +local MAX_NOTES = 20 +local MAX_NOTE_TITLE_BYTES = 200 +local MAX_NOTE_CONTENT_BYTES = 256 * 1024 +local MAX_NOTE_COORDINATE = 100000 +local DATABASE_MODE_PRIVATE = "private" +local DATABASE_MODE_GLOBAL = "global" +local ipcRequestSequence = 0 + +local dataDir, dataDirError = noctalia.pluginDataDir() +local privateDatabasePath = dataDir ~= nil and (dataDir .. "/cliphist.db") or nil +if dataDir == nil then + -- The plugin data directory owns the private database, pins and notes. Its + -- absence must never redirect private data to /tmp or the global database. + noctalia.log("clipper: pluginDataDir unavailable: " .. tostring(dataDirError or "unknown")) +end + +local function tr(key, args) + return noctalia.tr(key, args) +end + +local function cfg(key) + return noctalia.getConfig(key) +end + +local function shellQuote(value) + return "'" .. tostring(value):gsub("'", "'\\''") .. "'" +end + +local function boundedString(value, maximum) + if type(value) ~= "string" then return "" end + if #value <= maximum then return value end + return value:sub(1, maximum) +end + +local function atomicWriteJson(path, value) + if dataDir == nil then return false end + local encoded = noctalia.json.encode(value, true) + if type(encoded) ~= "string" then return false end + local temporary = path .. ".tmp" + local ok = noctalia.writeFile(temporary, encoded) + if ok ~= true then return false end + local renamed = noctalia.renameFile(temporary, path) + if renamed ~= true then + noctalia.removeFile(temporary) + return false + end + return true +end + +local function readJson(path) + local raw = noctalia.readFile(path) + if type(raw) ~= "string" or raw == "" then return nil end + local ok, decoded = pcall(noctalia.json.decode, raw) + if not ok or type(decoded) ~= "table" then return nil end + return decoded +end + +local function boundedInteger(value, fallback, minimum, maximum) + local number = tonumber(value) + if number == nil then return fallback end + number = math.floor(number) + return math.max(minimum, math.min(maximum, number)) +end + +local function noteCoordinate(value, fallback) + local number = tonumber(value) + if number == nil then return fallback end + return math.max(0, math.min(MAX_NOTE_COORDINATE, math.floor(number + 0.5))) +end + +local function historyLimit() + return boundedInteger(cfg("history_limit"), DEFAULT_HISTORY_LIMIT, MIN_HISTORY_LIMIT, MAX_HISTORY_LIMIT) +end + +local function databaseMode() + return cfg("database_mode") == DATABASE_MODE_GLOBAL + and DATABASE_MODE_GLOBAL or DATABASE_MODE_PRIVATE +end + +local function cliphistEnvironment() + local variables = {} + if databaseMode() == DATABASE_MODE_PRIVATE then + if privateDatabasePath == nil then return nil end + variables[#variables + 1] = "CLIPHIST_DB_PATH=" .. shellQuote(privateDatabasePath) + end + variables[#variables + 1] = "CLIPHIST_MAX_ITEMS=" .. tostring(historyLimit()) + variables[#variables + 1] = "CLIPHIST_PREVIEW_WIDTH=" .. tostring(PREVIEW_WIDTH) + return table.concat(variables, " ") +end + +local function pasteDelayMs() + return boundedInteger(cfg("auto_paste_delay"), 300, 0, 5000) +end + +local function cleanPreview(value) + if type(value) ~= "string" then return "" end + -- cliphist already folds whitespace, but control bytes (for example a UTF-16 + -- clipboard preview containing NULs) must not be copied into shared state. + return (value:gsub("%c", " ")) +end + +local IMAGE_MIMES = { + png = "image/png", + jpg = "image/jpeg", + jpeg = "image/jpeg", + gif = "image/gif", + webp = "image/webp", + bmp = "image/bmp", + tiff = "image/tiff", +} + +local IMAGE_EXTENSIONS = { + ["image/png"] = "png", + ["image/jpeg"] = "jpg", + ["image/gif"] = "gif", + ["image/webp"] = "webp", + ["image/bmp"] = "bmp", + ["image/tiff"] = "tiff", + ["image/*"] = "img", +} + +local SAFE_MIMES = { + ["text/plain"] = true, + ["image/png"] = true, + ["image/jpeg"] = true, + ["image/gif"] = true, + ["image/webp"] = true, + ["image/bmp"] = true, + ["image/tiff"] = true, + ["image/*"] = true, +} +local SAFE_KINDS = { + text = true, image = true, color = true, link = true, code = true, file = true, +} + +local NOTE_COLORS = { + "#FFD54F", + "#F48FB1", + "#90CAF9", + "#A5D6A7", + "#CE93D8", +} + +local function opaqueNoteColor(value) + if type(value) == "string" and value:match("^#%x%x%x%x%x%x%x%x$") then + return value:sub(1, 7) + end + return value +end + +local function validColor(value, fallback) + if type(value) ~= "string" or #value > 64 then return fallback end + if value:match("^#%x%x%x%x%x%x$") or value:match("^#%x%x%x%x%x%x%x%x$") then return value end + local role, alpha = value:match("^([a-z_]+)(/[%d.]+)?$") + local roles = { + primary = true, secondary = true, tertiary = true, surface = true, + surface_variant = true, + } + if role == nil or roles[role] ~= true then return fallback end + if alpha ~= nil then + local number = tonumber(alpha:sub(2)) + if number == nil or number < 0 or number > 1 then return fallback end + end + return value +end + +local function classifyPreview(preview) + local lower = preview:lower() + if lower:find("[[ binary data ", 1, true) ~= 1 then + if preview:match("^#%x%x%x%x%x%x$") or preview:match("^#%x%x%x$") + or lower:match("^rgba?%s*%(") then + return "color", false, "text/plain" + end + if lower:match("^https?://") then return "link", false, "text/plain" end + if preview:match("^/") or preview:match("^~/") or lower:match("^file://") then + return "file", false, "text/plain" + end + if lower:find("function", 1, true) or lower:find("import ", 1, true) + or lower:find("const ", 1, true) or lower:find("class ", 1, true) + or lower:find("def ", 1, true) then + return "code", false, "text/plain" + end + return "text", false, "text/plain" + end + local format = lower:match("^%[%[ binary data .- ([%w+_.-]+) %d+x%d+ %]%]$") + return "image", true, IMAGE_MIMES[format or ""] or "image/*" +end + +local function parseList(output, limit) + local items = {} + local idSet = {} + if type(output) ~= "string" then return items, idSet, false end + + local truncated = false + for line in (output .. "\n"):gmatch("([^\n]*)\n") do + if line ~= "" then + local id, preview = line:match("^(%d+)\t(.*)$") + if id ~= nil and #id <= ID_MAX_BYTES and not idSet[id] then + if #items >= limit then + truncated = true + break + end + preview = cleanPreview(preview) + local kind, isImage, mime = classifyPreview(preview) + items[#items + 1] = { + id = id, + preview = preview, + kind = kind, + is_image = isImage, + mime = mime, + } + idSet[id] = true + end + end + end + return items, idSet, truncated +end + +local pinsDir = dataDir ~= nil and (dataDir .. "/pins") or nil +local thumbnailsDir = dataDir ~= nil and (dataDir .. "/thumbnails") or nil +local pinsFile = dataDir ~= nil and (dataDir .. "/pinned.json") or nil +local notesFile = dataDir ~= nil and (dataDir .. "/notes.json") or nil +local exportDir = noctalia.expandPath("~/Documents/Clipper") +local pinnedItems = {} +local notes = {} +local persistentCounter = 0 + +local function nextPersistentId(prefix) + persistentCounter = persistentCounter + 1 + return prefix .. "-" .. tostring(os.time()) .. "-" .. tostring(persistentCounter) +end + +local function validPersistentId(value, prefix) + return type(value) == "string" and #value <= 64 + and value:match("^" .. prefix .. "%-%d+%-%d+$") ~= nil +end + +local function pinPath(id) + if pinsDir == nil or not validPersistentId(id, "pin") then return nil end + return pinsDir .. "/" .. id .. ".bin" +end + +local function savePinned() + return pinsFile ~= nil and atomicWriteJson(pinsFile, { version = 1, items = pinnedItems }) +end + +local function saveNotes() + return notesFile ~= nil and atomicWriteJson(notesFile, { version = 1, notes = notes }) +end + +local function loadPersistentData() + if dataDir == nil then return end + noctalia.mkdirAll(dataDir) + noctalia.mkdirAll(pinsDir) + + local storedPins = readJson(pinsFile) + if storedPins ~= nil and type(storedPins.items) == "table" then + local maximum = boundedInteger(cfg("max_pinned"), 20, 1, 100) + for _, item in ipairs(storedPins.items) do + if #pinnedItems >= maximum then break end + local id = type(item) == "table" and item.id or nil + local path = pinPath(id) + local mime = type(item) == "table" and item.mime or nil + if path ~= nil and SAFE_MIMES[mime] == true and noctalia.fileExists(path) then + local isImage = type(item.is_image) == "boolean" and item.is_image or mime:find("image/", 1, true) == 1 + pinnedItems[#pinnedItems + 1] = { + id = id, + preview = boundedString(item.preview, PREVIEW_WIDTH * 4), + kind = SAFE_KINDS[item.kind] == true and item.kind or (isImage and "image" or "text"), + mime = mime, + is_image = isImage, + image_path = isImage and path or "", + pinned_at = tonumber(item.pinned_at) or 0, + } + end + end + end + + local storedNotes = readJson(notesFile) + if storedNotes ~= nil and type(storedNotes.notes) == "table" then + for _, note in ipairs(storedNotes.notes) do + if #notes >= MAX_NOTES then break end + if type(note) == "table" and validPersistentId(note.id, "note") then + notes[#notes + 1] = { + id = note.id, + title = boundedString(note.title, MAX_NOTE_TITLE_BYTES), + content = boundedString(note.content, MAX_NOTE_CONTENT_BYTES), + color = opaqueNoteColor(validColor(note.color, "#FFD54F")), + x = noteCoordinate(note.x, 24 + (#notes * 36)), + y = noteCoordinate(note.y, 24 + (#notes * 28)), + z = boundedInteger(note.z, #notes + 1, 1, MAX_NOTE_COORDINATE), + created_at = tonumber(note.created_at) or 0, + updated_at = tonumber(note.updated_at) or 0, + } + end + end + end +end + +local revision = 0 +local snapshotStatus = "idle" +local snapshotError = "" +local snapshotItems = {} +local snapshotIds = {} +local watchersRunning = false +local panelOpen = noctalia.state.get(PANEL_OPEN_KEY) == true +local snapshotDirty = true +local refreshInFlight = false +local refreshQueued = false +local actionBusy = false +local lastHandledRequestId = "" +local pendingPaste = nil +local pasteTarget = nil +local pasteCaptureGeneration = 0 +local thumbnailQueue = {} +local thumbnailRunning = false +local thumbnailCache = {} +local thumbnailCleanupQueue = {} + +local function environmentValue(key) + if type(noctalia.getenv) ~= "function" then return "" end + local value = noctalia.getenv(key) + return type(value) == "string" and value or "" +end + +local function decodeCommandJson(result) + if result == nil or result.timedOut == true or result.exitCode ~= 0 + or type(result.stdout) ~= "string" or result.stdout == "" then + return nil + end + local ok, decoded = pcall(noctalia.json.decode, result.stdout) + return ok and type(decoded) == "table" and decoded or nil +end + +local function capturePasteTarget() + pasteCaptureGeneration = pasteCaptureGeneration + 1 + local generation = pasteCaptureGeneration + pasteTarget = nil + + if environmentValue("HYPRLAND_INSTANCE_SIGNATURE") ~= "" + and noctalia.commandExists("hyprctl") then + noctalia.runAsync("hyprctl -j activewindow", function(result) + if generation ~= pasteCaptureGeneration then return end + local decoded = decodeCommandJson(result) + local address = decoded ~= nil and decoded.address or nil + if type(address) == "string" and address:match("^0x[%da-fA-F]+$") then + pasteTarget = { compositor = "hyprland", id = address } + end + end, 2000) + return + end + + if environmentValue("NIRI_SOCKET") ~= "" and noctalia.commandExists("niri") then + noctalia.runAsync("niri msg -j focused-window", function(result) + if generation ~= pasteCaptureGeneration then return end + local decoded = decodeCommandJson(result) + local id = decoded ~= nil and tonumber(decoded.id) or nil + if id ~= nil and id >= 0 and math.floor(id) == id then + pasteTarget = { compositor = "niri", id = tostring(id) } + end + end, 2000) + return + end + + local mangoSocket = environmentValue("MANGO_INSTANCE_SIGNATURE") + if mangoSocket ~= "" and noctalia.commandExists("nc") then + local command = "printf '%s\\n' 'get all-clients' | nc -U " .. shellQuote(mangoSocket) + noctalia.runAsync(command, function(result) + if generation ~= pasteCaptureGeneration then return end + local decoded = decodeCommandJson(result) + local clients = decoded ~= nil and decoded.clients or nil + if type(clients) ~= "table" then return end + for _, client in ipairs(clients) do + if type(client) == "table" and client.is_focused == true then + local id = tostring(client.id or "") + if id:match("^%d+$") then + pasteTarget = { compositor = "mango", id = id } + return + end + end + end + end, 2000) + end +end + +local function pasteFocusCommand() + if type(pasteTarget) ~= "table" then return nil end + local id = tostring(pasteTarget.id or "") + if pasteTarget.compositor == "hyprland" and id:match("^0x[%da-fA-F]+$") + and noctalia.commandExists("hyprctl") then + local target = "address:" .. id + local luaFocus = "hl.dsp.focus({window = \"" .. target .. "\"})" + return "(hyprctl dispatch focuswindow " .. shellQuote(target) + .. " || hyprctl eval " .. shellQuote(luaFocus) .. ")" + end + if pasteTarget.compositor == "niri" and id:match("^%d+$") + and noctalia.commandExists("niri") then + return "niri msg action focus-window --id " .. id + end + if pasteTarget.compositor == "mango" and id:match("^%d+$") + and noctalia.commandExists("nc") then + local mangoSocket = environmentValue("MANGO_INSTANCE_SIGNATURE") + if mangoSocket ~= "" then + return "printf '%s\\n' " .. shellQuote("dispatch focusid client," .. id) + .. " | nc -U " .. shellQuote(mangoSocket) + end + end + return nil +end + +local function publishSnapshot(status, errorCode, items, ids) + if status ~= nil then snapshotStatus = status end + if errorCode ~= nil then snapshotError = errorCode end + if items ~= nil then snapshotItems = items end + if ids ~= nil then snapshotIds = ids end + revision = revision + 1 + noctalia.state.set(SNAPSHOT_KEY, { + revision = revision, + status = snapshotStatus, + error = snapshotError, + items = snapshotItems, + pinned = pinnedItems, + notes = notes, + watchers_running = watchersRunning, + total = #snapshotItems, + }) +end + +local function publishResult(requestId, operation, ok, errorCode, extra) + local result = { + request_id = requestId, + operation = operation, + ok = ok == true, + error = errorCode or "", + } + if type(extra) == "table" then + for key, value in pairs(extra) do result[key] = value end + end + noctalia.state.set(RESULT_KEY, result) +end + +local function thumbnailPath(id, mime) + if thumbnailsDir == nil then return nil end + if type(id) ~= "string" or id:match("^%d+$") == nil then return nil end + local extension = IMAGE_EXTENSIONS[mime] or "img" + return thumbnailsDir .. "/" .. id .. "." .. extension +end + +local function initializeThumbnailCache() + if thumbnailsDir == nil or noctalia.mkdirAll(thumbnailsDir) ~= true + or type(noctalia.listDir) ~= "function" then return end + local entries = noctalia.listDir(thumbnailsDir) + if type(entries) ~= "table" then return end + for _, name in ipairs(entries) do + local id = type(name) == "string" and name:match("^(%d+)%.[%w]+$") or nil + local temporaryId = type(name) == "string" and name:match("^(%d+)%.[%w]+%.tmp$") or nil + if temporaryId ~= nil then + noctalia.removeFile(thumbnailsDir .. "/" .. name) + elseif id ~= nil then + thumbnailCache[id] = thumbnailsDir .. "/" .. name + end + end +end + +local startNextThumbnail + +startNextThumbnail = function() + if thumbnailRunning or #thumbnailQueue == 0 then return end + local job = table.remove(thumbnailQueue, 1) + local environment = cliphistEnvironment() + if environment == nil then return end + + thumbnailRunning = true + local temporaryPath = job.path .. ".tmp" + local command = environment .. " cliphist decode " .. shellQuote(job.id) + .. " > " .. shellQuote(temporaryPath) + local started = noctalia.runAsync(command, function(result) + thumbnailRunning = false + local info = noctalia.fileInfo(temporaryPath) + local valid = result ~= nil and result.timedOut ~= true and result.exitCode == 0 + and type(info) == "table" and tonumber(info.size) ~= nil + and info.size > 0 and info.size <= MAX_THUMBNAIL_BYTES + if valid and noctalia.renameFile(temporaryPath, job.path) == true then + if snapshotIds[job.id] == true then + thumbnailCache[job.id] = job.path + for _, item in ipairs(snapshotItems) do + if item.id == job.id and item.is_image == true then + item.image_path = job.path + publishSnapshot("ready", "") + break + end + end + end + else + noctalia.removeFile(temporaryPath) + end + startNextThumbnail() + end, ACTION_TIMEOUT_MS) + if not started then + thumbnailRunning = false + noctalia.removeFile(temporaryPath) + startNextThumbnail() + end +end + +local function prepareThumbnails(items, ids) + thumbnailQueue = {} + if thumbnailsDir == nil then return end + for id, path in pairs(thumbnailCache) do + if ids[id] ~= true then + thumbnailCache[id] = nil + thumbnailCleanupQueue[#thumbnailCleanupQueue + 1] = path + end + end + for _, item in ipairs(items) do + if item.is_image == true then + local path = thumbnailPath(item.id, item.mime) + local cachedPath = thumbnailCache[item.id] + if cachedPath ~= nil then + item.image_path = cachedPath + elseif path ~= nil then + thumbnailQueue[#thumbnailQueue + 1] = { + id = item.id, + path = path, + } + end + end + end +end + +local function notifyError(key) + noctalia.notifyError(tr("title"), tr("notifications." .. key)) +end + +local refreshHistory + +local function finishRefresh() + refreshInFlight = false + if refreshQueued then + refreshQueued = false + refreshHistory() + end +end + +refreshHistory = function() + if refreshInFlight then + refreshQueued = true + return + end + if not noctalia.commandExists("cliphist") then + snapshotDirty = true + publishSnapshot("error", "cliphist_unavailable") + return + end + local environment = cliphistEnvironment() + if environment == nil then + snapshotDirty = true + publishSnapshot("error", "data_dir_unavailable") + return + end + + local limit = historyLimit() + -- One extra valid row lets the parser detect truncation while stdout remains + -- bounded by (history_limit + 1) previews. + local command = environment .. " cliphist list | head -n " .. tostring(limit + 1) + refreshInFlight = true + publishSnapshot("loading", "") + local started = noctalia.runAsync(command, function(result) + local stderr = result ~= nil and tostring(result.stderr or "") or "" + if result == nil or result.timedOut == true or result.exitCode ~= 0 + or stderr:match("%S") ~= nil then + snapshotDirty = true + publishSnapshot("error", result ~= nil and result.timedOut == true + and "refresh_timeout" or "refresh_failed") + finishRefresh() + return + end + local items, ids = parseList(result.stdout or "", limit) + snapshotDirty = false + prepareThumbnails(items, ids) + publishSnapshot("ready", "", items, ids) + startNextThumbnail() + finishRefresh() + end, REFRESH_TIMEOUT_MS) + if not started then + refreshInFlight = false + snapshotDirty = true + publishSnapshot("error", "refresh_start_failed") + end +end + +local function validRequestId(value) + if type(value) == "number" then value = tostring(value) end + if type(value) ~= "string" or value == "" or #value > REQUEST_ID_MAX_BYTES + or value:find("%c") ~= nil then + return nil + end + return value +end + +local function validCliphistId(value) + if type(value) == "number" and value >= 0 and value % 1 == 0 then value = tostring(value) end + if type(value) ~= "string" or value == "" or #value > ID_MAX_BYTES + or value:match("^%d+$") == nil then + return nil + end + return value +end + +local function runAction(requestId, operation, id, command, onSuccess) + actionBusy = true + local started = noctalia.runAsync(command, function(result) + actionBusy = false + if result == nil or result.timedOut == true or result.exitCode ~= 0 then + local errorCode = result ~= nil and result.timedOut == true + and (operation .. "_timeout") or (operation .. "_failed") + publishResult(requestId, operation, false, errorCode, { id = id }) + notifyError(errorCode) + return + end + if onSuccess ~= nil then onSuccess() end + end, ACTION_TIMEOUT_MS) + if not started then + actionBusy = false + local errorCode = operation .. "_start_failed" + publishResult(requestId, operation, false, errorCode, { id = id }) + notifyError(errorCode) + end +end + +local function schedulePaste(requestId, id) + if pendingPaste ~= nil then return false, "paste_busy" end + if not noctalia.commandExists("wtype") then + notifyError("wtype_unavailable") + return false, "wtype_unavailable" + end + local focusCommand = pasteFocusCommand() + if focusCommand == nil then + notifyError("focus_target_unavailable") + return false, "focus_target_unavailable" + end + pendingPaste = { + request_id = requestId, + id = id, + focus_command = focusCommand, + deadline = os.clock() + pasteDelayMs() / 1000, + } + noctalia.setUpdateInterval(50) + return true, "" +end + +local function handleActivate(requestId, id, paste) + if paste and pendingPaste ~= nil then + publishResult(requestId, "activate", false, "paste_busy", { id = id }) + return + end + local command = cliphistEnvironment() .. " cliphist decode " .. shellQuote(id) .. " | wl-copy" + runAction(requestId, "activate", id, command, function() + local pasteScheduled = false + local pasteError = "" + if paste then + pasteScheduled, pasteError = schedulePaste(requestId, id) + end + publishResult(requestId, "activate", true, "", { + id = id, + paste_requested = paste, + paste_scheduled = pasteScheduled, + paste_error = pasteError, + }) + snapshotDirty = true + if panelOpen then refreshHistory() end + end) +end + +local function findById(items, id) + for index, item in ipairs(items) do + if item.id == id then return item, index end + end + return nil, nil +end + +local function handlePin(requestId, id) + local source = nil + for _, item in ipairs(snapshotItems) do + if item.id == id then source = item break end + end + if source == nil then + publishResult(requestId, "pin", false, "item_not_found", { id = id }) + return + end + local maximum = boundedInteger(cfg("max_pinned"), 20, 1, 100) + if #pinnedItems >= maximum then + publishResult(requestId, "pin", false, "max_pinned", { id = id }) + notifyError("max_pinned") + return + end + if dataDir == nil then + publishResult(requestId, "pin", false, "data_dir_unavailable", { id = id }) + notifyError("data_dir_unavailable") + return + end + + local pinnedId = nextPersistentId("pin") + local path = pinPath(pinnedId) + local command = cliphistEnvironment() .. " cliphist decode " .. shellQuote(id) .. " > " .. shellQuote(path) + runAction(requestId, "pin", id, command, function() + local info = noctalia.fileInfo(path) + local maximumSize = source.is_image == true and MAX_PIN_IMAGE_BYTES or MAX_PIN_TEXT_BYTES + if type(info) ~= "table" or tonumber(info.size) == nil or info.size <= 0 or info.size > maximumSize then + noctalia.removeFile(path) + publishResult(requestId, "pin", false, "pin_too_large", { id = id }) + notifyError("pin_too_large") + return + end + local pinned = { + id = pinnedId, + preview = boundedString(source.preview, PREVIEW_WIDTH * 4), + kind = source.kind, + mime = SAFE_MIMES[source.mime] == true and source.mime or "text/plain", + is_image = source.is_image == true, + image_path = source.is_image == true and path or "", + pinned_at = os.time(), + } + pinnedItems[#pinnedItems + 1] = pinned + if not savePinned() then + table.remove(pinnedItems, #pinnedItems) + noctalia.removeFile(path) + publishResult(requestId, "pin", false, "pin_save_failed", { id = id }) + notifyError("pin_save_failed") + return + end + publishSnapshot("ready", "") + publishResult(requestId, "pin", true, "", { id = id, pinned_id = pinnedId }) + noctalia.notify(tr("title"), tr("notifications.pinned")) + end) +end + +local function handleUnpin(requestId, id) + local _, index = findById(pinnedItems, id) + if index == nil then + publishResult(requestId, "unpin", false, "item_not_found", { id = id }) + return + end + local removed = table.remove(pinnedItems, index) + if not savePinned() then + table.insert(pinnedItems, index, removed) + publishResult(requestId, "unpin", false, "pin_save_failed", { id = id }) + notifyError("pin_save_failed") + return + end + local path = pinPath(id) + if path ~= nil then noctalia.removeFile(path) end + publishSnapshot("ready", "") + publishResult(requestId, "unpin", true, "", { id = id }) + noctalia.notify(tr("title"), tr("notifications.unpinned")) +end + +local function handleCopyPinned(requestId, id, paste) + local item = findById(pinnedItems, id) + local path = pinPath(id) + if item == nil or path == nil or not noctalia.fileExists(path) then + publishResult(requestId, "copy_pinned", false, "item_not_found", { id = id }) + return + end + if paste and pendingPaste ~= nil then + publishResult(requestId, "copy_pinned", false, "paste_busy", { id = id }) + return + end + local mime = SAFE_MIMES[item.mime] == true and item.mime or "text/plain" + local command = "wl-copy --type " .. shellQuote(mime) .. " < " .. shellQuote(path) + runAction(requestId, "copy_pinned", id, command, function() + local pasteScheduled = false + local pasteError = "" + if paste then pasteScheduled, pasteError = schedulePaste(requestId, id) end + publishResult(requestId, "copy_pinned", true, "", { + id = id, + paste_requested = paste, + paste_scheduled = pasteScheduled, + paste_error = pasteError, + }) + end) +end + +local function handleCreateNote(requestId) + if dataDir == nil then + publishResult(requestId, "create_note", false, "data_dir_unavailable") + notifyError("data_dir_unavailable") + return + end + if #notes >= MAX_NOTES then + publishResult(requestId, "create_note", false, "max_notes") + notifyError("max_notes") + return + end + local id = nextPersistentId("note") + local now = os.time() + local note = { + id = id, + title = "", + content = "", + color = opaqueNoteColor(validColor(cfg("notes_color"), "#FFD54F")), + x = 24 + (#notes * 36), + y = 24 + (#notes * 28), + z = #notes + 1, + created_at = now, + updated_at = now, + } + notes[#notes + 1] = note + if not saveNotes() then + table.remove(notes, #notes) + publishResult(requestId, "create_note", false, "note_save_failed") + notifyError("note_save_failed") + return + end + publishSnapshot("ready", "") + publishResult(requestId, "create_note", true, "", { id = id }) +end + +local function handleMoveNote(requestId, id, raw) + local note = findById(notes, id) + if note == nil then + publishResult(requestId, "move_note", false, "note_not_found", { id = id }) + return + end + local oldX, oldY, oldZ = note.x, note.y, note.z + local highestZ = 0 + for _, candidate in ipairs(notes) do highestZ = math.max(highestZ, tonumber(candidate.z) or 0) end + note.x = noteCoordinate(raw.x, oldX or 0) + note.y = noteCoordinate(raw.y, oldY or 0) + note.z = math.min(MAX_NOTE_COORDINATE, highestZ + 1) + if not saveNotes() then + note.x, note.y, note.z = oldX, oldY, oldZ + publishResult(requestId, "move_note", false, "note_save_failed", { id = id }) + notifyError("note_save_failed") + return + end + publishSnapshot("ready", "") + publishResult(requestId, "move_note", true, "", { id = id, x = note.x, y = note.y, z = note.z }) +end + +local function handleUpdateNote(requestId, id, raw) + local note = findById(notes, id) + if note == nil then + publishResult(requestId, "update_note", false, "note_not_found", { id = id }) + return + end + local oldTitle, oldContent, oldUpdated = note.title, note.content, note.updated_at + note.title = boundedString(raw.title, MAX_NOTE_TITLE_BYTES) + note.content = boundedString(raw.content, MAX_NOTE_CONTENT_BYTES) + note.updated_at = os.time() + if not saveNotes() then + note.title, note.content, note.updated_at = oldTitle, oldContent, oldUpdated + publishResult(requestId, "update_note", false, "note_save_failed", { id = id }) + notifyError("note_save_failed") + return + end + publishSnapshot("ready", "") + publishResult(requestId, "update_note", true, "", { id = id }) +end + +local function handleDeleteNote(requestId, id) + local _, index = findById(notes, id) + if index == nil then + publishResult(requestId, "delete_note", false, "note_not_found", { id = id }) + return + end + local removed = table.remove(notes, index) + if not saveNotes() then + table.insert(notes, index, removed) + publishResult(requestId, "delete_note", false, "note_save_failed", { id = id }) + notifyError("note_save_failed") + return + end + publishSnapshot("ready", "") + publishResult(requestId, "delete_note", true, "", { id = id }) +end + +local function handleReorderNote(requestId, id, targetIndex) + local note, sourceIndex = findById(notes, id) + targetIndex = tonumber(targetIndex) + if note == nil or targetIndex == nil then + publishResult(requestId, "reorder_note", false, "note_not_found", { id = id }) + return + end + targetIndex = math.max(1, math.min(#notes, math.floor(targetIndex))) + if sourceIndex ~= targetIndex then + table.remove(notes, sourceIndex) + table.insert(notes, targetIndex, note) + if not saveNotes() then + table.remove(notes, targetIndex) + table.insert(notes, sourceIndex, note) + publishResult(requestId, "reorder_note", false, "note_save_failed", { id = id }) + notifyError("note_save_failed") + return + end + end + publishSnapshot("ready", "") + publishResult(requestId, "reorder_note", true, "", { id = id }) +end + +local function handleCycleNoteColor(requestId, id) + local note = findById(notes, id) + if note == nil then + publishResult(requestId, "cycle_note_color", false, "note_not_found", { id = id }) + return + end + local current = 0 + for index, color in ipairs(NOTE_COLORS) do + if note.color == color then current = index break end + end + local oldColor = note.color + note.color = NOTE_COLORS[(current % #NOTE_COLORS) + 1] + note.updated_at = os.time() + if not saveNotes() then + note.color = oldColor + publishResult(requestId, "cycle_note_color", false, "note_save_failed", { id = id }) + notifyError("note_save_failed") + return + end + publishSnapshot("ready", "") + publishResult(requestId, "cycle_note_color", true, "", { id = id }) +end + +local function handleExportNote(requestId, id) + local note = findById(notes, id) + if note == nil then + publishResult(requestId, "export_note", false, "note_not_found", { id = id }) + return + end + if type(exportDir) ~= "string" or exportDir == "" or noctalia.mkdirAll(exportDir) ~= true then + publishResult(requestId, "export_note", false, "note_export_failed", { id = id }) + notifyError("note_export_failed") + return + end + local fileName = "notecard-" .. os.date("%Y%m%d-%H%M%S") .. "-" .. id .. ".txt" + local path = exportDir .. "/" .. fileName + if noctalia.writeFile(path, note.content) ~= true then + publishResult(requestId, "export_note", false, "note_export_failed", { id = id }) + notifyError("note_export_failed") + return + end + publishResult(requestId, "export_note", true, "", { id = id, path = path }) + noctalia.notify(tr("title"), tr("notifications.note_exported", { path = path })) +end + +local function handleDelete(requestId, id) + if snapshotIds[id] ~= true then + publishResult(requestId, "delete", false, "item_not_found", { id = id }) + return + end + -- cliphist delete extracts only the numeric prefix from each stdin line; do + -- not interpolate or pipe the user's preview/content back through the shell. + local command = "printf '%s\\n' " .. shellQuote(id) .. " | " .. cliphistEnvironment() .. " cliphist delete" + runAction(requestId, "delete", id, command, function() + publishResult(requestId, "delete", true, "", { id = id }) + noctalia.notify(tr("title"), tr("notifications.deleted")) + snapshotDirty = true + refreshHistory() + end) +end + +local function handleWipe(requestId) + runAction(requestId, "wipe", nil, cliphistEnvironment() .. " cliphist wipe", function() + snapshotDirty = false + snapshotItems = {} + snapshotIds = {} + publishSnapshot("ready", "", snapshotItems, snapshotIds) + publishResult(requestId, "wipe", true, "") + noctalia.notify(tr("title"), tr("notifications.wiped")) + end) +end + +local function handleRequest(raw) + if type(raw) ~= "table" then return end + local requestId = validRequestId(raw.request_id) + local operation = type(raw.operation) == "string" and raw.operation or "" + if requestId == nil then + publishResult("", operation, false, "invalid_request_id") + return + end + if requestId == lastHandledRequestId then return end + lastHandledRequestId = requestId + + if operation == "refresh" then + refreshHistory() + publishResult(requestId, operation, true, "") + return + end + local supported = { + activate = true, delete = true, wipe = true, pin = true, + unpin = true, copy_pinned = true, + create_note = true, update_note = true, delete_note = true, + reorder_note = true, move_note = true, cycle_note_color = true, export_note = true, + } + if supported[operation] ~= true then + publishResult(requestId, operation, false, "invalid_operation") + return + end + if actionBusy then + publishResult(requestId, operation, false, "busy") + return + end + + if operation == "create_note" then + handleCreateNote(requestId) + return + end + if operation == "update_note" or operation == "delete_note" + or operation == "reorder_note" or operation == "move_note" or operation == "cycle_note_color" + or operation == "export_note" then + local id = type(raw.id) == "string" and raw.id or "" + if not validPersistentId(id, "note") then + publishResult(requestId, operation, false, "note_not_found", { id = id }) + return + end + if operation == "update_note" then + handleUpdateNote(requestId, id, raw) + elseif operation == "delete_note" then + handleDeleteNote(requestId, id) + elseif operation == "reorder_note" then + handleReorderNote(requestId, id, raw.target_index) + elseif operation == "move_note" then + handleMoveNote(requestId, id, raw) + elseif operation == "cycle_note_color" then + handleCycleNoteColor(requestId, id) + else + handleExportNote(requestId, id) + end + return + end + + if operation == "unpin" or operation == "copy_pinned" then + local id = type(raw.id) == "string" and raw.id or "" + if not validPersistentId(id, "pin") then + publishResult(requestId, operation, false, "item_not_found", { id = id }) + return + end + if operation == "unpin" then + handleUnpin(requestId, id) + else + if not noctalia.commandExists("wl-copy") then + publishResult(requestId, operation, false, "wl_copy_unavailable", { id = id }) + notifyError("wl_copy_unavailable") + return + end + local paste = raw.paste == true + handleCopyPinned(requestId, id, paste) + end + return + end + + if cliphistEnvironment() == nil then + publishResult(requestId, operation, false, "data_dir_unavailable") + notifyError("data_dir_unavailable") + return + end + if not noctalia.commandExists("cliphist") then + publishResult(requestId, operation, false, "cliphist_unavailable") + notifyError("cliphist_unavailable") + return + end + if operation == "wipe" then + handleWipe(requestId) + return + end + + local id = validCliphistId(raw.id) + if id == nil then + publishResult(requestId, operation, false, "invalid_id") + notifyError("invalid_id") + return + end + if operation == "activate" then + if not noctalia.commandExists("wl-copy") then + publishResult(requestId, operation, false, "wl_copy_unavailable", { id = id }) + notifyError("wl_copy_unavailable") + return + end + local paste = raw.paste == true + handleActivate(requestId, id, paste) + elseif operation == "pin" then + handlePin(requestId, id) + else + handleDelete(requestId, id) + end +end + +local function ipcRequestId() + ipcRequestSequence = ipcRequestSequence + 1 + return "ipc-" .. tostring(os.time()) .. "-" .. tostring(ipcRequestSequence) +end + +local function trimmedPayload(payload) + if type(payload) ~= "string" then return "" end + return payload:match("^%s*(.-)%s*$") or "" +end + +local function decodeIpcObject(payload) + payload = trimmedPayload(payload) + if payload == "" or #payload > IPC_PAYLOAD_MAX_BYTES then return nil end + local ok, decoded = pcall(noctalia.json.decode, payload) + if not ok or type(decoded) ~= "table" then return nil end + return decoded +end + +local function dispatchIpcRequest(operation, fields) + fields = type(fields) == "table" and fields or {} + fields.request_id = ipcRequestId() + fields.operation = operation + handleRequest(fields) +end + +-- Service IPC is intentionally a thin adapter over handleRequest. This keeps +-- CLI/automation calls subject to the same allowlist, ID validation, size +-- limits, dependency checks and busy-state handling as requests from the UI. +function onIpc(event, payload) + local noPayloadOperations = { + refresh = "refresh", + clear = "wipe", + ["create-note"] = "create_note", + } + local idOperations = { + copy = { operation = "activate", paste = false }, + paste = { operation = "activate", paste = true }, + pin = { operation = "pin" }, + delete = { operation = "delete" }, + unpin = { operation = "unpin" }, + ["copy-pinned"] = { operation = "copy_pinned", paste = false }, + ["paste-pinned"] = { operation = "copy_pinned", paste = true }, + ["delete-note"] = { operation = "delete_note" }, + ["cycle-note-color"] = { operation = "cycle_note_color" }, + ["export-note"] = { operation = "export_note" }, + } + local jsonOperations = { + ["update-note"] = "update_note", + ["move-note"] = "move_note", + ["reorder-note"] = "reorder_note", + } + + if event == "request" then + local request = decodeIpcObject(payload) + if request == nil then + dispatchIpcRequest("", {}) + return + end + -- Never accept an externally supplied request ID: the service owns IPC + -- correlation IDs and therefore cannot accidentally deduplicate calls. + local operation = type(request.operation) == "string" and request.operation or "" + dispatchIpcRequest(operation, request) + return + end + + local operation = noPayloadOperations[event] + if operation ~= nil then + dispatchIpcRequest(operation, {}) + return + end + + local idOperation = idOperations[event] + if idOperation ~= nil then + dispatchIpcRequest(idOperation.operation, { + id = trimmedPayload(payload), + paste = idOperation.paste == true, + }) + return + end + + operation = jsonOperations[event] + if operation ~= nil then + local request = decodeIpcObject(payload) + if request == nil then + dispatchIpcRequest(operation, {}) + return + end + dispatchIpcRequest(operation, request) + return + end + + dispatchIpcRequest("", {}) +end + +local function onWatcherLine(line) + -- The wrapper emits one fixed marker after cliphist successfully stores a + -- clipboard change. Never interpret process output as clipboard content. + if line ~= "__clipper_changed__" then return end + snapshotDirty = true + if panelOpen then refreshHistory() end +end + +local function startWatchers() + -- Global mode consumes the database managed by the desktop session. Starting + -- another pair of storing watchers would race and duplicate that ownership. + if databaseMode() == DATABASE_MODE_GLOBAL then return end + if cfg("manage_watchers") ~= true then return end + if not noctalia.commandExists("cliphist") or not noctalia.commandExists("wl-paste") then + notifyError("watchers_unavailable") + return + end + + local environment = cliphistEnvironment() + if environment == nil then + notifyError("data_dir_unavailable") + return + end + local worker = shellQuote(environment .. " cliphist store && printf '%s\\n' __clipper_changed__") + -- One lifecycle-owned stream supervises both MIME watchers. The host kills + -- its process group on plugin reload/disable, and the trap also reaps both + -- children on normal termination. + local command = "text_pid=; image_pid=; " + .. "wl-paste --type text --watch sh -c " .. worker .. " & text_pid=$!; " + .. "wl-paste --type image --watch sh -c " .. worker .. " & image_pid=$!; " + .. "trap 'kill $text_pid $image_pid 2>/dev/null; wait $text_pid $image_pid 2>/dev/null' EXIT HUP INT TERM; " + .. "wait $text_pid $image_pid" + watchersRunning = noctalia.runStream(command, onWatcherLine) == true + if not watchersRunning then notifyError("watchers_start_failed") end +end + +noctalia.state.watch(PANEL_OPEN_KEY, function(value) + panelOpen = value == true + if panelOpen then + capturePasteTarget() + if snapshotDirty then refreshHistory() end + end +end) + +noctalia.state.watch(REQUEST_KEY, handleRequest) + +function update() + if #thumbnailCleanupQueue > 0 then + noctalia.removeFile(table.remove(thumbnailCleanupQueue, 1)) + end + if pendingPaste == nil or os.clock() < pendingPaste.deadline then return end + local paste = pendingPaste + pendingPaste = nil + noctalia.setUpdateInterval(1000) + local command = paste.focus_command .. " || exit 70; sleep 0.05; wtype -M ctrl -M shift v" + local started = noctalia.runAsync(command, function(result) + if result == nil or result.exitCode ~= 0 or result.timedOut == true then + local errorCode = result ~= nil and result.exitCode == 70 + and "focus_restore_failed" + or (result ~= nil and result.timedOut == true and "paste_timeout" or "paste_failed") + notifyError(errorCode) + end + end, ACTION_TIMEOUT_MS) + if not started then notifyError("paste_start_failed") end +end + +initializeThumbnailCache() +loadPersistentData() +startWatchers() +local initialError = databaseMode() == DATABASE_MODE_PRIVATE and dataDir == nil + and "data_dir_unavailable" or "" +publishSnapshot(snapshotStatus, initialError) +if panelOpen then + capturePasteTarget() + refreshHistory() +end diff --git a/clipper/shortcut.luau b/clipper/shortcut.luau new file mode 100644 index 00000000..83fa9be8 --- /dev/null +++ b/clipper/shortcut.luau @@ -0,0 +1,20 @@ +--!nonstrict + +local PANEL_ID = "blackbartblues/clipper:panel" + +local function render() + shortcut.setLabel(noctalia.tr("shortcut.label")) + shortcut.setIcon("clipboard") + shortcut.setActive(false) + shortcut.setEnabled(true) +end + +function onClick() + noctalia.togglePanel(PANEL_ID) +end + +function onConfigChanged() + render() +end + +render() diff --git a/clipper/tests/service_global_database_test.lua b/clipper/tests/service_global_database_test.lua new file mode 100644 index 00000000..18207714 --- /dev/null +++ b/clipper/tests/service_global_database_test.lua @@ -0,0 +1,146 @@ +local stateValues = { clipper_panel_open = false } +local stateWatchers = {} +local asyncCalls = {} +local streamCalls = {} +local files = {} +local config = { + database_mode = "global", + history_limit = 3, + manage_watchers = true, + auto_paste_delay = 300, + max_pinned = 20, + notes_color = "#112233", +} + +local globalCliphistCommand = "CLIPHIST_MAX_ITEMS=3 CLIPHIST_PREVIEW_WIDTH=240 cliphist" + +noctalia = { + pluginDataDir = function() return "/virtual/clipper-data", nil end, + expandPath = function(path) return path:gsub("^~", "/virtual/home") end, + getConfig = function(key) return config[key] end, + json = { + encode = function(_value) return "{}" end, + decode = function(_value) return {} end, + }, + readFile = function(path) return files[path] end, + writeFile = function(path, value) files[path] = value return true end, + renameFile = function(from, to) + if files[from] == nil then return false end + files[to] = files[from] + files[from] = nil + return true + end, + removeFile = function(path) files[path] = nil return true end, + mkdirAll = function(_path) return true end, + listDir = function(_path) return {} end, + fileExists = function(path) return files[path] ~= nil end, + fileInfo = function(path) + local value = files[path] + if value == nil then return nil end + return { size = #value, isDir = false, mtime = 0 } + end, + commandExists = function(command) + return command == "cliphist" or command == "wl-paste" + or command == "wl-copy" or command == "wtype" + end, + state = { + get = function(key) return stateValues[key] end, + set = function(key, value) + stateValues[key] = value + if stateWatchers[key] ~= nil then stateWatchers[key](value) end + end, + watch = function(key, callback) stateWatchers[key] = callback end, + }, + runAsync = function(command, callback, timeout) + asyncCalls[#asyncCalls + 1] = { command = command, callback = callback, timeout = timeout } + return true + end, + runStream = function(command, callback) + streamCalls[#streamCalls + 1] = { command = command, callback = callback } + return true + end, + setUpdateInterval = function(value) stateValues.update_interval = value end, + tr = function(key) return key end, + notify = function(_title, _body) end, + notifyError = function(_title, _body) end, + log = function(_message) end, +} + +local function request(value) + noctalia.state.set("clipper_request", value) + return stateValues.clipper_result +end + +local function takeAsync() + assert(#asyncCalls > 0, "missing asynchronous call") + return table.remove(asyncCalls, 1) +end + +local function completeNext(result) + local call = takeAsync() + call.callback(result or { exitCode = 0, stdout = "", stderr = "", timedOut = false }) + return call +end + +local function assertGlobalCommand(command) + assert(command:find("CLIPHIST_DB_PATH", 1, true) == nil, "global command forced a private database") + assert(command:find("/virtual/clipper-data/cliphist.db", 1, true) == nil, "global command used private data") +end + +assert(loadfile("service.luau"))() + +do + local snapshot = stateValues.clipper_snapshot + assert(snapshot.status == "idle") + assert(snapshot.watchers_running == false, "global mode claimed ownership of external watchers") + assert(#streamCalls == 0, "global mode started duplicate clipboard watchers") +end + +-- Listing uses cliphist's standard global database resolution. The mock only +-- records this command; it never accesses the user's real database. +noctalia.state.set("clipper_panel_open", true) +assert(#asyncCalls == 1) +assert(asyncCalls[1].command == globalCliphistCommand .. " list | head -n 4") +assertGlobalCommand(asyncCalls[1].command) +completeNext({ exitCode = 0, stdout = "42\tglobal preview\n", stderr = "", timedOut = false }) +assert(stateValues.clipper_snapshot.items[1].id == "42") +noctalia.state.set("clipper_panel_open", false) + +request({ request_id = "global-activate", operation = "activate", id = "42", paste = false }) +assert(#asyncCalls == 1) +assert(asyncCalls[1].command == globalCliphistCommand .. " decode '42' | wl-copy") +assertGlobalCommand(asyncCalls[1].command) +completeNext() + +request({ request_id = "global-pin", operation = "pin", id = "42" }) +assert(#asyncCalls == 1) +local pinCall = asyncCalls[1] +assert(pinCall.command:find(globalCliphistCommand .. " decode '42' > ", 1, true) == 1) +assertGlobalCommand(pinCall.command) +local pinPath = pinCall.command:match(" > '([^']+)'$") +assert(pinPath ~= nil) +files[pinPath] = "global payload copied into private pin storage" +completeNext() +assert(stateValues.clipper_result.ok == true) + +request({ request_id = "global-delete", operation = "delete", id = "42" }) +assert(#asyncCalls == 1) +assert(asyncCalls[1].command == "printf '%s\\n' '42' | " .. globalCliphistCommand .. " delete") +assertGlobalCommand(asyncCalls[1].command) +completeNext() +-- A successful delete always verifies the resulting history with the same DB. +assert(#asyncCalls == 1) +assert(asyncCalls[1].command == globalCliphistCommand .. " list | head -n 4") +assertGlobalCommand(asyncCalls[1].command) +completeNext() + +request({ request_id = "global-wipe", operation = "wipe" }) +assert(#asyncCalls == 1) +assert(asyncCalls[1].command == globalCliphistCommand .. " wipe") +assertGlobalCommand(asyncCalls[1].command) +completeNext() +assert(stateValues.clipper_result.ok == true) + +assert(#asyncCalls == 0) +assert(#streamCalls == 0) +print("clipper global database tests: ok") diff --git a/clipper/tests/service_parser_test.lua b/clipper/tests/service_parser_test.lua new file mode 100644 index 00000000..acebe986 --- /dev/null +++ b/clipper/tests/service_parser_test.lua @@ -0,0 +1,367 @@ +local stateValues = { clipper_panel_open = false } +local stateWatchers = {} +local asyncCalls = {} +local streamCalls = {} +local notifications = {} +local files = {} +local mockEnv = {} +local config = { + -- database_mode is deliberately omitted: missing or invalid values must + -- preserve the backward-compatible private database. + history_limit = 3, + manage_watchers = true, + auto_paste_delay = 300, + max_pinned = 20, + notes_color = "#11223366", +} +local fakeClock = 10 +local cliphistCommand = "CLIPHIST_DB_PATH='/virtual/clipper-data/cliphist.db' CLIPHIST_MAX_ITEMS=3 CLIPHIST_PREVIEW_WIDTH=240 cliphist" + +local realClock = os.clock +os.clock = function() return fakeClock end + +noctalia = { + pluginDataDir = function() return "/virtual/clipper-data", nil end, + expandPath = function(path) return path:gsub("^~", "/virtual/home") end, + getConfig = function(key) return config[key] end, + json = { + encode = function(_value) return "{}" end, + decode = function(value) + if value:find('"address":"0xabc123"', 1, true) ~= nil then + return { address = "0xabc123" } + end + local operation = value:match('"operation"%s*:%s*"([^"]+)"') + local id = value:match('"id"%s*:%s*"([^"]+)"') + local title = value:match('"title"%s*:%s*"([^"]*)"') + local content = value:match('"content"%s*:%s*"([^"]*)"') + local x = tonumber(value:match('"x"%s*:%s*([%d%.%-]+)')) + local y = tonumber(value:match('"y"%s*:%s*([%d%.%-]+)')) + local targetIndex = tonumber(value:match('"target_index"%s*:%s*(%d+)')) + if operation ~= nil or id ~= nil then + return { + operation = operation, id = id, title = title, content = content, + x = x, y = y, target_index = targetIndex, + } + end + return {} + end, + }, + getenv = function(key) return mockEnv[key] end, + readFile = function(path) return files[path] end, + writeFile = function(path, value) files[path] = value return true end, + renameFile = function(from, to) + if files[from] == nil then return false end + files[to] = files[from] + files[from] = nil + return true + end, + removeFile = function(path) files[path] = nil return true end, + mkdirAll = function(_path) return true end, + listDir = function(path) + local entries = {} + local prefix = path .. "/" + for filePath, _ in pairs(files) do + if filePath:sub(1, #prefix) == prefix then + local name = filePath:sub(#prefix + 1) + if name:find("/", 1, true) == nil then entries[#entries + 1] = name end + end + end + return entries + end, + fileExists = function(path) return files[path] ~= nil end, + fileInfo = function(path) + local value = files[path] + if value == nil then return nil end + return { size = #value, isDir = false, mtime = 0 } + end, + commandExists = function(command) + return command == "cliphist" or command == "wl-paste" + or command == "wl-copy" or command == "wtype" or command == "hyprctl" + end, + state = { + get = function(key) return stateValues[key] end, + set = function(key, value) + stateValues[key] = value + if stateWatchers[key] ~= nil then stateWatchers[key](value) end + end, + watch = function(key, callback) stateWatchers[key] = callback end, + }, + runAsync = function(command, callback, timeout) + asyncCalls[#asyncCalls + 1] = { command = command, callback = callback, timeout = timeout } + return true + end, + runStream = function(command, callback) + streamCalls[#streamCalls + 1] = { command = command, callback = callback } + return true + end, + setUpdateInterval = function(value) stateValues.update_interval = value end, + tr = function(key) return key end, + notify = function(title, body) notifications[#notifications + 1] = { title, body } end, + notifyError = function(title, body) notifications[#notifications + 1] = { title, body } end, + log = function(_message) end, +} + +local function request(value) + noctalia.state.set("clipper_request", value) + return stateValues.clipper_result +end + +local function completeNext(result) + assert(#asyncCalls > 0, "missing asynchronous call") + local call = table.remove(asyncCalls, 1) + call.callback(result or { exitCode = 0, stdout = "", stderr = "", timedOut = false }) + return call +end + +assert(loadfile("service.luau"))() + +do + local snapshot = stateValues.clipper_snapshot + assert(snapshot.status == "idle", "service eagerly loaded history") + assert(snapshot.watchers_running == true, "managed watchers were not reported") + assert(type(snapshot.items) == "table" and #snapshot.items == 0) + assert(type(snapshot.pinned) == "table" and #snapshot.pinned == 0) + assert(type(snapshot.notes) == "table" and #snapshot.notes == 0) + assert(snapshot.total == 0) + assert(#asyncCalls == 0, "history refreshed while panel was closed") + assert(#streamCalls == 1, "text and image watchers must share one lifecycle stream") + assert(streamCalls[1].command:find("wl%-paste %-%-type text %-%-watch") ~= nil) + assert(streamCalls[1].command:find("wl%-paste %-%-type image %-%-watch") ~= nil) +end + +-- Watcher noise is ignored, and a valid event only dirties the closed panel. +streamCalls[1].callback("not clipboard content") +streamCalls[1].callback("__clipper_changed__") +assert(#asyncCalls == 0, "closed panel refreshed after watcher event") + +-- Opening the panel performs one bounded query. +noctalia.state.set("clipper_panel_open", true) +assert(#asyncCalls == 1) +assert(asyncCalls[1].command == cliphistCommand .. " list | head -n 4") +assert(asyncCalls[1].timeout == 5000) +assert(stateValues.clipper_snapshot.status == "loading") + +local syntheticList = table.concat({ + "42\talpha preview", + "not-an-id\tignored", + "42\tduplicate ignored", + "43\t[[ binary data 30 KiB png 681x170 ]]", + "44\tcontains\0control", + "45\tfourth valid row is beyond the configured snapshot", +}, "\n") .. "\n" +completeNext({ exitCode = 0, stdout = syntheticList, stderr = "", timedOut = false }) + +do + local snapshot = stateValues.clipper_snapshot + assert(snapshot.status == "ready" and snapshot.error == "") + assert(snapshot.total == 3 and #snapshot.items == 3, "history limit was not enforced") + assert(snapshot.items[1].id == "42" and snapshot.items[1].kind == "text") + assert(snapshot.items[1].is_image == false and snapshot.items[1].mime == "text/plain") + assert(snapshot.items[2].id == "43" and snapshot.items[2].kind == "image") + assert(snapshot.items[2].is_image == true and snapshot.items[2].mime == "image/png") + assert(snapshot.items[3].preview:find("%c") == nil, "control byte escaped into shared state") +end + +-- Image history entries are decoded outside Lua into a bounded, plugin-owned +-- cache. The snapshot is republished only after the temporary file is valid. +assert(#asyncCalls == 1, "image thumbnail decode was not queued") +local thumbnailPath = "/virtual/clipper-data/thumbnails/43.png" +assert(asyncCalls[1].command == cliphistCommand .. " decode '43' > '" .. thumbnailPath .. ".tmp'") +files[thumbnailPath .. ".tmp"] = "synthetic png bytes" +completeNext() +assert(files[thumbnailPath] == "synthetic png bytes") +assert(stateValues.clipper_snapshot.items[2].image_path == thumbnailPath) +assert(#asyncCalls == 0) + +-- Refreshes coalesce while one callback is in flight. +local firstRefresh = request({ request_id = "refresh-1", operation = "refresh" }) +assert(firstRefresh.ok == true and #asyncCalls == 1) +local secondRefresh = request({ request_id = "refresh-2", operation = "refresh" }) +assert(secondRefresh.ok == true and #asyncCalls == 1, "refresh callbacks were not bounded") +completeNext({ exitCode = 0, stdout = "42\talpha preview\n", stderr = "", timedOut = false }) +assert(#asyncCalls == 1, "queued explicit refresh was lost") +completeNext({ exitCode = 0, stdout = "42\talpha preview\n", stderr = "", timedOut = false }) +assert(#asyncCalls == 0) + +noctalia.state.set("clipper_panel_open", false) + +-- Numeric validation rejects shell metacharacters before spawning anything. +local invalid = request({ request_id = "bad-id", operation = "activate", id = "42; touch /tmp/no" }) +assert(invalid.ok == false and invalid.error == "invalid_id") +assert(#asyncCalls == 0) + +-- Activation streams cliphist directly to wl-copy and does not include a preview. +local activate = request({ request_id = "activate-1", operation = "activate", id = "42", paste = false }) +assert(#asyncCalls == 1) +assert(asyncCalls[1].command == cliphistCommand .. " decode '42' | wl-copy") +assert(asyncCalls[1].command:find("alpha preview", 1, true) == nil) +completeNext() +activate = stateValues.clipper_result +assert(activate.ok == true and activate.operation == "activate") +assert(activate.paste_requested == false and activate.paste_scheduled == false) + +-- Explicit paste schedules one bounded timer and invokes the V4 shortcut only +-- after restoring the window that was focused when the panel opened. +mockEnv.HYPRLAND_INSTANCE_SIGNATURE = "test-instance" +noctalia.state.set("clipper_panel_open", true) +assert(#asyncCalls == 2, "panel open did not capture focus and refresh history") +assert(asyncCalls[1].command == "hyprctl -j activewindow") +completeNext({ exitCode = 0, stdout = '{"address":"0xabc123"}', stderr = "", timedOut = false }) +assert(#asyncCalls == 1 and asyncCalls[1].command == cliphistCommand .. " list | head -n 4") +completeNext({ exitCode = 0, stdout = "42\talpha preview\n", stderr = "", timedOut = false }) +noctalia.state.set("clipper_panel_open", false) + +request({ request_id = "activate-paste", operation = "activate", id = 42, paste = true }) +completeNext() +assert(stateValues.clipper_result.paste_scheduled == true) +assert(stateValues.update_interval == 50) +update() +assert(#asyncCalls == 0, "paste ran before its deadline") +fakeClock = 10.31 +update() +assert(#asyncCalls == 1) +assert(asyncCalls[1].command == "(hyprctl dispatch focuswindow 'address:0xabc123' || hyprctl eval 'hl.dsp.focus({window = \"address:0xabc123\"})') || exit 70; sleep 0.05; wtype -M ctrl -M shift v") +completeNext() +assert(stateValues.update_interval == 1000) + +-- Pinning stores decoded bytes outside Lua and publishes only bounded metadata. +request({ request_id = "pin-1", operation = "pin", id = "42" }) +assert(#asyncCalls == 1 and asyncCalls[1].command:find(cliphistCommand .. " decode '42' > ", 1, true) == 1) +local pinnedPath = asyncCalls[1].command:match(" > '([^']+)'$") +assert(pinnedPath ~= nil) +files[pinnedPath] = "persisted clipboard bytes" +completeNext() +local pinned = stateValues.clipper_snapshot.pinned[1] +assert(pinned ~= nil and pinned.preview == "alpha preview") +assert(pinned.id:match("^pin%-%d+%-%d+$") ~= nil) +assert(files[pinnedPath] ~= nil, "pin payload was not retained") + +request({ request_id = "copy-pin-1", operation = "copy_pinned", id = pinned.id, paste = false }) +assert(#asyncCalls == 1) +assert(asyncCalls[1].command == "wl-copy --type 'text/plain' < '" .. pinnedPath .. "'") +completeNext() +assert(stateValues.clipper_result.ok == true) + +request({ request_id = "unpin-1", operation = "unpin", id = pinned.id }) +assert(#stateValues.clipper_snapshot.pinned == 0) +assert(files[pinnedPath] == nil, "unpin did not remove its private payload") + +-- Notecards are synchronous, bounded, persistent service mutations. +request({ request_id = "note-create-1", operation = "create_note" }) +local firstNote = stateValues.clipper_snapshot.notes[1] +assert(firstNote ~= nil and firstNote.color == "#112233") +assert(firstNote.x == 24 and firstNote.y == 24 and firstNote.z == 1) +request({ + request_id = "note-update-1", operation = "update_note", id = firstNote.id, + title = "Title", content = "Body", +}) +assert(stateValues.clipper_snapshot.notes[1].title == "Title") +assert(stateValues.clipper_snapshot.notes[1].content == "Body") +request({ request_id = "note-color-1", operation = "cycle_note_color", id = firstNote.id }) +assert(stateValues.clipper_snapshot.notes[1].color == "#FFD54F") +request({ request_id = "note-create-2", operation = "create_note" }) +local secondNote = stateValues.clipper_snapshot.notes[2] +request({ + request_id = "note-reorder-1", operation = "reorder_note", id = secondNote.id, target_index = 1, +}) +assert(stateValues.clipper_snapshot.notes[1].id == secondNote.id) +request({ + request_id = "note-move-1", operation = "move_note", id = firstNote.id, x = 412.4, y = 237.7, +}) +assert(firstNote.x == 412 and firstNote.y == 238) +assert(firstNote.z > secondNote.z) +request({ request_id = "note-export-1", operation = "export_note", id = firstNote.id }) +assert(stateValues.clipper_result.ok == true) +assert(type(stateValues.clipper_result.path) == "string") +assert(files[stateValues.clipper_result.path] == "Body") +request({ request_id = "note-delete-1", operation = "delete_note", id = firstNote.id }) +assert(#stateValues.clipper_snapshot.notes == 1) + +-- Delete sends only the validated ID to cliphist, never preview/content. +local deleted = request({ request_id = "delete-1", operation = "delete", id = "42" }) +assert(#asyncCalls == 1) +assert(asyncCalls[1].command == "printf '%s\\n' '42' | " .. cliphistCommand .. " delete") +assert(asyncCalls[1].command:find("alpha preview", 1, true) == nil) +completeNext() +deleted = stateValues.clipper_result +assert(deleted.ok == true and deleted.operation == "delete") +-- A successful mutation forces a bounded refresh even with the panel closed. +assert(#asyncCalls == 1 and asyncCalls[1].command:find("cliphist list", 1, true) ~= nil) +completeNext({ exitCode = 0, stdout = "", stderr = "", timedOut = false }) + +local wiped = request({ request_id = "wipe-1", operation = "wipe" }) +assert(#asyncCalls == 1 and asyncCalls[1].command == cliphistCommand .. " wipe") +completeNext() +wiped = stateValues.clipper_result +assert(wiped.ok == true and wiped.operation == "wipe") +assert(stateValues.clipper_snapshot.total == 0 and #stateValues.clipper_snapshot.items == 0) + +-- Duplicate request IDs are idempotent. +request({ request_id = "wipe-1", operation = "wipe" }) +assert(#asyncCalls == 0) + +-- A list-side error is not misreported as an empty, ready history. +request({ request_id = "refresh-error", operation = "refresh" }) +completeNext({ exitCode = 0, stdout = "", stderr = "opening db failed", timedOut = false }) +assert(stateValues.clipper_snapshot.status == "error") +assert(stateValues.clipper_snapshot.error == "refresh_failed") + +-- IPC events reuse the service request dispatcher instead of bypassing its +-- validation. Named events cover every UI operation; structured note edits +-- and the generic request event accept bounded JSON objects. +onIpc("refresh", "") +assert(stateValues.clipper_result.operation == "refresh" and stateValues.clipper_result.ok == true) +completeNext({ exitCode = 0, stdout = "42\tIPC preview\n", stderr = "", timedOut = false }) + +onIpc("copy", " 42 ") +assert(#asyncCalls == 1 and asyncCalls[1].command == cliphistCommand .. " decode '42' | wl-copy") +completeNext() +assert(stateValues.clipper_result.operation == "activate") + +local invalidIdEvents = { + { "paste", "activate", "invalid_id" }, + { "pin", "pin", "invalid_id" }, + { "delete", "delete", "invalid_id" }, + { "unpin", "unpin", "item_not_found" }, + { "copy-pinned", "copy_pinned", "item_not_found" }, + { "paste-pinned", "copy_pinned", "item_not_found" }, + { "delete-note", "delete_note", "note_not_found" }, + { "cycle-note-color", "cycle_note_color", "note_not_found" }, + { "export-note", "export_note", "note_not_found" }, +} +for _, event in ipairs(invalidIdEvents) do + onIpc(event[1], "not-an-id") + assert(stateValues.clipper_result.operation == event[2]) + assert(stateValues.clipper_result.ok == false and stateValues.clipper_result.error == event[3]) +end + +local noteCount = #stateValues.clipper_snapshot.notes +onIpc("create-note", "") +assert(stateValues.clipper_result.operation == "create_note" and stateValues.clipper_result.ok == true) +assert(#stateValues.clipper_snapshot.notes == noteCount + 1) +local ipcNote = stateValues.clipper_snapshot.notes[#stateValues.clipper_snapshot.notes] + +onIpc("update-note", '{"id":"' .. ipcNote.id .. '","title":"IPC title","content":"IPC body"}') +assert(stateValues.clipper_result.operation == "update_note" and stateValues.clipper_result.ok == true) +assert(ipcNote.title == "IPC title" and ipcNote.content == "IPC body") + +onIpc("move-note", '{"id":"' .. ipcNote.id .. '","x":125,"y":250}') +assert(stateValues.clipper_result.operation == "move_note" and stateValues.clipper_result.ok == true) +assert(ipcNote.x == 125 and ipcNote.y == 250) + +onIpc("reorder-note", '{"id":"' .. ipcNote.id .. '","target_index":1}') +assert(stateValues.clipper_result.operation == "reorder_note" and stateValues.clipper_result.ok == true) + +onIpc("request", '{"operation":"cycle_note_color","id":"' .. ipcNote.id .. '"}') +assert(stateValues.clipper_result.operation == "cycle_note_color" and stateValues.clipper_result.ok == true) + +onIpc("clear", "") +assert(#asyncCalls == 1 and asyncCalls[1].command == cliphistCommand .. " wipe") +completeNext() +assert(stateValues.clipper_result.operation == "wipe" and stateValues.clipper_result.ok == true) + +onIpc("unknown-event", "") +assert(stateValues.clipper_result.ok == false and stateValues.clipper_result.error == "invalid_operation") + +os.clock = realClock +print("clipper service parser tests: ok") diff --git a/clipper/thumbnail.webp b/clipper/thumbnail.webp new file mode 100644 index 0000000000000000000000000000000000000000..fb34887f2c9fa4c1d0b8962063f66d05fa565c97 GIT binary patch literal 28182 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 NVJwdl9P3dq004KU@Xr7M literal 0 HcmV?d00001 diff --git a/clipper/translations/en.json b/clipper/translations/en.json new file mode 100644 index 00000000..e6941ff7 --- /dev/null +++ b/clipper/translations/en.json @@ -0,0 +1,178 @@ +{ + "title": "Clipper", + "widget": { + "tooltip": "Open Clipper" + }, + "shortcut": { + "label": "Clipboard history" + }, + "panel": { + "title": "Clipper", + "image_preview_pending": "Binary image stored in clipboard history", + "status": { + "error": "Clipboard history is unavailable", + "loading": "Loading clipboard history", + "live": "Clipboard watching is active", + "manual": "Clipboard watchers are managed externally" + }, + "actions": { + "close": "Close Clipper", + "copy": "Copy", + "copy_hint": "Click to copy. Right-click to copy and paste.", + "paste": "Paste", + "drag_to_pin": "Drag to the pinned area", + "delete": "Delete", + "pin": "Pin", + "refresh": "Refresh clipboard history", + "settings": "Open Clipper settings", + "unpin": "Unpin" + }, + "filters": { + "all": "All", + "text": "Text", + "images": "Images", + "colors": "Colors", + "links": "Links", + "code": "Code", + "files": "Files" + }, + "kinds": { + "text": "Text", + "image": "Image", + "color": "Color", + "link": "Link", + "code": "Code", + "file": "File" + }, + "pinned": { + "title": "Pinned clips", + "empty": "Pin a clipboard entry to keep it close at hand." + }, + "notes": { + "title": "Notecards", + "count": "{count} notes", + "create": "New note", + "empty": "No notes yet", + "empty_hint": "Create a notecard for text you want to keep and edit.", + "untitled": "Untitled note", + "content_placeholder": "Write a note...", + "change_color": "Change note color", + "export": "Export note", + "delete": "Delete note", + "save": "Save", + "saved": "Saved", + "unsaved": "Unsaved changes", + "drag_hint": "Drag to reorder this note" + }, + "history": { + "title": "Clipboard history", + "search": "Search clipboard history...", + "empty": "Clipboard history is empty.", + "no_matches": "No clipboard entries match this search.", + "clear": "Clear history", + "confirm_clear": "Clear everything?", + "page": "Page {page} of {pages}" + } + }, + "notifications": { + "cliphist_unavailable": "cliphist is unavailable. Install it or check your PATH.", + "wl_copy_unavailable": "wl-copy is unavailable. Install wl-clipboard or check your PATH.", + "wtype_unavailable": "The clip was copied, but wtype is unavailable for automatic paste.", + "focus_target_unavailable": "The clip was copied, but the previously focused window could not be identified.", + "focus_restore_failed": "The clip was copied, but the previously focused window could not be restored.", + "watchers_unavailable": "Clipboard watchers require cliphist and wl-paste.", + "watchers_start_failed": "Clipboard watchers could not be started.", + "refresh_failed": "Clipboard history could not be loaded.", + "refresh_timeout": "Loading clipboard history timed out.", + "refresh_start_failed": "The clipboard history process could not be started.", + "activate_failed": "The clipboard entry could not be copied.", + "activate_timeout": "Copying the clipboard entry timed out.", + "activate_start_failed": "The clipboard copy process could not be started.", + "pin_failed": "The clipboard entry could not be pinned.", + "pin_timeout": "Pinning the clipboard entry timed out.", + "pin_start_failed": "The pinning process could not be started.", + "pin_too_large": "This clipboard entry is too large to pin.", + "pin_save_failed": "The pinned entry could not be saved.", + "max_pinned": "The configured pinned-entry limit has been reached.", + "pinned": "Clipboard entry pinned.", + "unpinned": "Clipboard entry unpinned.", + "copy_pinned_failed": "The pinned entry could not be copied.", + "copy_pinned_timeout": "Copying the pinned entry timed out.", + "copy_pinned_start_failed": "The pinned copy process could not be started.", + "delete_failed": "The clipboard entry could not be deleted.", + "delete_timeout": "Deleting the clipboard entry timed out.", + "delete_start_failed": "The clipboard delete process could not be started.", + "wipe_failed": "Clipboard history could not be cleared.", + "wipe_timeout": "Clearing clipboard history timed out.", + "wipe_start_failed": "The clipboard clear process could not be started.", + "paste_failed": "The paste shortcut could not be sent.", + "paste_timeout": "Sending the paste shortcut timed out.", + "paste_start_failed": "The automatic paste process could not be started.", + "invalid_id": "The selected clipboard entry is no longer valid.", + "data_dir_unavailable": "Clipper's private data directory is unavailable. Pins and notes are disabled.", + "max_notes": "The notecard limit has been reached.", + "note_save_failed": "The notecard could not be saved.", + "note_export_failed": "The notecard could not be exported.", + "note_exported": "Notecard exported to {path}.", + "deleted": "Clipboard entry deleted.", + "wiped": "Clipboard history cleared." + }, + "settings": { + "database_mode": { + "label": "Clipboard history database", + "description": "Use Clipper's isolated database or the default cliphist database shared with other clipboard tools. In global mode, delete and clear actions modify the shared history.", + "options": { + "private": "Private Clipper database", + "global": "Global cliphist database" + } + }, + "manage_watchers": { + "label": "Manage clipboard watchers", + "description": "Start and supervise the text and image watchers used to populate Clipper's private database." + }, + "history_limit": { + "label": "History limit", + "description": "Maximum number of entries loaded in the panel. In private mode, this also limits the entries retained by Clipper." + }, + "enable_notecards": { + "label": "Enable notecards", + "description": "Show quick notes alongside clipboard history and pinned clips." + }, + "max_pinned": { + "label": "Maximum pinned clips", + "description": "Maximum number of clipboard entries that can be pinned." + }, + "auto_paste_delay": { + "label": "Paste delay", + "description": "Delay in milliseconds before restoring the previous window and sending the paste shortcut." + }, + "history_cards": { + "label": "Maximum cards per history page", + "description": "Maximum number of clipboard cards per page. Clipper fills the available width and reduces the count automatically on narrower displays." + }, + "panel_margin_percent": { + "label": "Panel edge margin", + "description": "Margin around the fullscreen Clipper workspace, as a percentage of the active display." + }, + "show_panel_background": { + "label": "Show panel background", + "description": "Draw one background behind the entire Clipper workspace. Pinned clips and clipboard history always keep their own surfaces." + }, + "card_color": { + "label": "History card color", + "description": "Noctalia theme role or custom color used for clipboard history cards." + }, + "pinned_color": { + "label": "Pinned card color", + "description": "Noctalia theme role or custom color used for pinned clipboard cards." + }, + "notes_color": { + "label": "Notecard color", + "description": "Noctalia theme role or custom color used for quick notes." + }, + "show_close_button": { + "label": "Show close button", + "description": "Show a close action in the Clipper panel header." + } + } +} diff --git a/clipper/widget.luau b/clipper/widget.luau new file mode 100644 index 00000000..39f6d901 --- /dev/null +++ b/clipper/widget.luau @@ -0,0 +1,19 @@ +--!nonstrict + +local PANEL_ID = "blackbartblues/clipper:panel" + +local function render() + barWidget.setGlyph("clipboard") + barWidget.setText("") + barWidget.setTooltip(noctalia.tr("widget.tooltip")) +end + +function onClick() + noctalia.togglePanel(PANEL_ID) +end + +function onConfigChanged() + render() +end + +render() From e773e88272b2071e14b9dd6343f8609bf262b9fb Mon Sep 17 00:00:00 2001 From: WojciechSulocki-Gif Date: Thu, 13 Aug 2026 00:01:23 +0200 Subject: [PATCH 2/5] fix: require input frame visibility API --- clipper/plugin.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/clipper/plugin.toml b/clipper/plugin.toml index f9c37b50..a802871b 100644 --- a/clipper/plugin.toml +++ b/clipper/plugin.toml @@ -1,7 +1,7 @@ id = "blackbartblues/clipper" name = "Clipper" version = "0.1.0" -plugin_api = 5 +plugin_api = 27 author = "blackbartblues" license = "MIT" dependencies = ["cliphist", "wl-copy", "wl-paste", "wtype", "hyprctl", "niri", "nc"] From f406222effb2cf302569476f9041a9fca1468a53 Mon Sep 17 00:00:00 2001 From: WojciechSulocki-Gif Date: Thu, 13 Aug 2026 00:09:12 +0200 Subject: [PATCH 3/5] fix: validate panel decoration and refresh thumbnail --- .../scripts/test_validate_plugins.py | 25 ++++++++++++++++++ .github/workflows/scripts/validate-plugins.py | 4 +++ clipper/thumbnail.webp | Bin 28182 -> 64280 bytes 3 files changed, 29 insertions(+) diff --git a/.github/workflows/scripts/test_validate_plugins.py b/.github/workflows/scripts/test_validate_plugins.py index 805e9ade..74ff2d00 100644 --- a/.github/workflows/scripts/test_validate_plugins.py +++ b/.github/workflows/scripts/test_validate_plugins.py @@ -87,6 +87,31 @@ def test_rejects_description_over_limit(self) -> None: self.assertIn("at or below 120", errors[0]) +class PanelDecorationTests(unittest.TestCase): + def validate(self, value: object) -> list[str]: + validator = validate_plugins.Validator(Path("/repo")) + validator.validate_panel_fields( + Path("/repo/example/plugin.toml"), + "panel[0]", + {"decorated": value}, + 27, + ) + return validator.errors + + def test_decorated_is_an_allowed_panel_field(self) -> None: + self.assertIn("decorated", validate_plugins.ENTRY_FIELDS["panel"]) + + def test_accepts_boolean_decoration(self) -> None: + self.assertEqual(self.validate(False), []) + self.assertEqual(self.validate(True), []) + + def test_rejects_non_boolean_decoration(self) -> None: + self.assertEqual( + self.validate("false"), + ["example/plugin.toml: panel[0]: decorated must be a bool"], + ) + + class PluginConfigAccessorTests(unittest.TestCase): def test_accepts_universal_accessor(self) -> None: self.assertEqual( diff --git a/.github/workflows/scripts/validate-plugins.py b/.github/workflows/scripts/validate-plugins.py index 56ea927b..311e8f0a 100644 --- a/.github/workflows/scripts/validate-plugins.py +++ b/.github/workflows/scripts/validate-plugins.py @@ -169,6 +169,7 @@ "keyboard_focus", "persistent", "capture_keys", + "decorated", }, "desktop_widget": BASE_ENTRY_FIELDS | {"setting"}, "service": BASE_ENTRY_FIELDS, @@ -770,6 +771,9 @@ def validate_panel_fields( if "open_near_click" in entry and not isinstance(entry["open_near_click"], bool): self.add_context_error(manifest_path, context, "open_near_click must be a bool") + if "decorated" in entry and not isinstance(entry["decorated"], bool): + self.add_context_error(manifest_path, context, "decorated must be a bool") + if "dismiss_on_outside_click" in entry: if not is_int(plugin_api) or plugin_api < 8: self.add_context_error( diff --git a/clipper/thumbnail.webp b/clipper/thumbnail.webp index fb34887f2c9fa4c1d0b8962063f66d05fa565c97..0bab997bc978cfd75f5ffcaefd98623be776822e 100644 GIT binary patch literal 64280 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%0A4-6b^gf0&N&iySH*;D~IZ) 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 literal 28182 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 NVJwdl9P3dq004KU@Xr7M From b2bf33ce3a02251066665fa3556dc3bc828870e0 Mon Sep 17 00:00:00 2001 From: WojciechSulocki-Gif Date: Thu, 13 Aug 2026 00:12:20 +0200 Subject: [PATCH 4/5] docs: add Clipper preview and screenshot --- clipper/README.md | 10 ++++++++++ clipper/screenshots/panel.webp | Bin 0 -> 15054 bytes 2 files changed, 10 insertions(+) create mode 100644 clipper/screenshots/panel.webp diff --git a/clipper/README.md b/clipper/README.md index 411b2e42..4c94669c 100644 --- a/clipper/README.md +++ b/clipper/README.md @@ -2,6 +2,16 @@ Clipper combines searchable clipboard history, persistent pinned clips, image previews, and freely arranged notecards in one fullscreen Noctalia workspace. +## Preview + +![Clipper plugin preview](thumbnail.webp) + +### Panel + +![Clipper panel with pinned clips, notecards, and clipboard history](screenshots/panel.webp) + +The desktop behind the transparent workspace is blurred in the screenshot; the Clipper surfaces remain unchanged. + ## Plugin | Field | Value | diff --git a/clipper/screenshots/panel.webp b/clipper/screenshots/panel.webp new file mode 100644 index 0000000000000000000000000000000000000000..a14da9c604e7d74e951b72275852bb2e4df9ae0e GIT binary patch literal 15054 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 literal 0 HcmV?d00001 From 3faaa9d5eeefeba0b0a453cc9bdd1ffdbb39d31a Mon Sep 17 00:00:00 2001 From: WojciechSulocki-Gif Date: Thu, 13 Aug 2026 00:14:04 +0200 Subject: [PATCH 5/5] feat: protect global clipboard history from clearing --- clipper/README.md | 9 ++-- clipper/panel.luau | 45 ++++++++++--------- clipper/service.luau | 5 +++ .../tests/service_global_database_test.lua | 15 ++++--- clipper/translations/en.json | 3 +- 5 files changed, 47 insertions(+), 30 deletions(-) diff --git a/clipper/README.md b/clipper/README.md index 4c94669c..613f952c 100644 --- a/clipper/README.md +++ b/clipper/README.md @@ -23,7 +23,7 @@ The desktop behind the transparent workspace is blurred in the screenshot; the C Clipper runs on Wayland and uses the following commands: -- `cliphist` stores, lists, decodes, deletes, and clears clipboard history. +- `cliphist` stores, lists, decodes, and deletes clipboard history. Clipper only clears its private database. - `wl-copy` and `wl-paste` are provided by `wl-clipboard`; they copy entries and, in private mode, watch text and image clipboard changes. - `wtype` sends the paste shortcut after Clipper restores the previously focused window. - `hyprctl`, `niri`, and `nc` provide focus capture and restoration on Hyprland, niri, and MangoWC respectively. Only the command for the active compositor is used; `nc` is the MangoWC client. @@ -54,7 +54,7 @@ In the history area, drag a card by its top bar into the pinned column. Notecard | Setting | Type | Default | Description | | --- | --- | --- | --- | -| `database_mode` | `select` | `private` | Uses Clipper's isolated cliphist database or the default global cliphist database. Delete and clear affect the shared database in global mode. | +| `database_mode` | `select` | `private` | Uses Clipper's isolated cliphist database or the default global cliphist database. Individual entries can be deleted globally, but clearing the complete shared database is disabled. | | `manage_watchers` | `bool` | `true` | Starts Clipper-owned text and image watchers in private mode. Hidden and ignored in global mode. | | `history_limit` | `int` | `200` | Maximum entries loaded by the service. In private mode it also sets Clipper's cliphist retention limit. | | `enable_notecards` | `bool` | `true` | Shows or hides the notecard canvas. Existing notes remain stored when hidden. | @@ -90,6 +90,7 @@ noctalia msg plugin blackbartblues/clipper:service all copy 42 noctalia msg plugin blackbartblues/clipper:service all paste 42 noctalia msg plugin blackbartblues/clipper:service all pin 42 noctalia msg plugin blackbartblues/clipper:service all delete 42 +# Private database mode only noctalia msg plugin blackbartblues/clipper:service all clear # Pinned clips @@ -114,7 +115,7 @@ noctalia msg plugin blackbartblues/clipper:service all request \ '{"operation":"activate","id":"42","paste":false}' ``` -Supported `operation` values are `refresh`, `activate`, `pin`, `delete`, `wipe`, `unpin`, `copy_pinned`, `create_note`, `update_note`, `move_note`, `reorder_note`, `cycle_note_color`, `export_note`, and `delete_note`. IPC is asynchronous: the CLI confirms dispatch, while the result is published to Noctalia state as `clipper_result`. +Supported `operation` values are `refresh`, `activate`, `pin`, `delete`, `wipe`, `unpin`, `copy_pinned`, `create_note`, `update_note`, `move_note`, `reorder_note`, `cycle_note_color`, `export_note`, and `delete_note`. `wipe` is rejected in global database mode. IPC is asynchronous: the CLI confirms dispatch, while the result is published to Noctalia state as `clipper_result`. The service publishes these state keys for integrations: @@ -130,7 +131,7 @@ English is the source language and runtime fallback in `translations/en.json`. A - Private history, pinned payloads, notecard metadata, and decoded image thumbnails are stored in Noctalia's plugin data directory. Notecard exports are written to `~/Documents/Clipper`. - Global database mode follows cliphist's normal database resolution, including `XDG_CACHE_HOME`. Clipper does not start duplicate watchers in this mode; an external cliphist watcher must already populate the database. -- **Delete** and **Clear history** permanently modify the selected database. In global mode this also affects other clipboard frontends using that database. +- **Delete** permanently modifies the selected database and therefore affects other clipboard frontends in global mode. **Clear history** is only available for Clipper's private database. - Clipboard bytes are streamed between `cliphist`, files, and `wl-copy`; history payloads are not decoded into Luau. Preview metadata and cached image thumbnails are bounded. - The plugin makes no network requests. It spawns only the commands listed under **Requirements**, plus standard shell utilities used to supervise watchers and bounded subprocesses. - Automatic paste currently restores focus on Hyprland, niri, and MangoWC. Unsupported compositors can still use **Copy** and paste manually. diff --git a/clipper/panel.luau b/clipper/panel.luau index 196c814e..f055d112 100644 --- a/clipper/panel.luau +++ b/clipper/panel.luau @@ -785,7 +785,30 @@ local function historyPane(usableWidth) }) end - local clearText = clearConfirm and tr("panel.history.confirm_clear") or tr("panel.history.clear") + local historyHeader = { + ui.glyph({ name = "history", size = 17, color = "primary" }), + ui.label({ text = tr("panel.history.title"), fontSize = 12, fontWeight = "bold" }), + ui.input({ + key = "history-search", + value = searchQuery, + placeholder = tr("panel.history.search"), + onChange = "onSearchChanged", + controlSize = "sm", + flexGrow = 1, + }), + } + if cfg("database_mode", "private") ~= "global" then + local clearText = clearConfirm and tr("panel.history.confirm_clear") or tr("panel.history.clear") + historyHeader[#historyHeader + 1] = ui.button({ + text = clearText, + glyph = clearConfirm and "alert-triangle" or "trash", + variant = clearConfirm and "destructive" or "ghost", + controlSize = "sm", + onClick = "onClearHistory", + }) + else + clearConfirm = false + end return ui.column({ key = "history-pane", height = 305, @@ -796,25 +819,7 @@ local function historyPane(usableWidth) border = "outline/0.42", borderWidth = 1, }, { - ui.row({ gap = 8, align = "center" }, { - ui.glyph({ name = "history", size = 17, color = "primary" }), - ui.label({ text = tr("panel.history.title"), fontSize = 12, fontWeight = "bold" }), - ui.input({ - key = "history-search", - value = searchQuery, - placeholder = tr("panel.history.search"), - onChange = "onSearchChanged", - controlSize = "sm", - flexGrow = 1, - }), - ui.button({ - text = clearText, - glyph = clearConfirm and "alert-triangle" or "trash", - variant = clearConfirm and "destructive" or "ghost", - controlSize = "sm", - onClick = "onClearHistory", - }), - }), + ui.row({ gap = 8, align = "center" }, historyHeader), filtersNode(), ui.row({ gap = 8, align = "start", flexGrow = 1 }, cards), ui.row({ gap = 7, align = "center", justify = "center" }, { diff --git a/clipper/service.luau b/clipper/service.luau index 0895bbab..3bc26a98 100644 --- a/clipper/service.luau +++ b/clipper/service.luau @@ -1027,6 +1027,11 @@ local function handleRequest(raw) publishResult(requestId, operation, false, "invalid_operation") return end + if operation == "wipe" and databaseMode() == DATABASE_MODE_GLOBAL then + publishResult(requestId, operation, false, "global_wipe_disabled") + notifyError("global_wipe_disabled") + return + end if actionBusy then publishResult(requestId, operation, false, "busy") return diff --git a/clipper/tests/service_global_database_test.lua b/clipper/tests/service_global_database_test.lua index 18207714..721f27cc 100644 --- a/clipper/tests/service_global_database_test.lua +++ b/clipper/tests/service_global_database_test.lua @@ -135,11 +135,16 @@ assertGlobalCommand(asyncCalls[1].command) completeNext() request({ request_id = "global-wipe", operation = "wipe" }) -assert(#asyncCalls == 1) -assert(asyncCalls[1].command == globalCliphistCommand .. " wipe") -assertGlobalCommand(asyncCalls[1].command) -completeNext() -assert(stateValues.clipper_result.ok == true) +assert(#asyncCalls == 0, "global wipe spawned cliphist") +assert(stateValues.clipper_result.ok == false) +assert(stateValues.clipper_result.operation == "wipe") +assert(stateValues.clipper_result.error == "global_wipe_disabled") + +onIpc("clear", "") +assert(#asyncCalls == 0, "global IPC clear spawned cliphist") +assert(stateValues.clipper_result.ok == false) +assert(stateValues.clipper_result.operation == "wipe") +assert(stateValues.clipper_result.error == "global_wipe_disabled") assert(#asyncCalls == 0) assert(#streamCalls == 0) diff --git a/clipper/translations/en.json b/clipper/translations/en.json index e6941ff7..f55a4d7f 100644 --- a/clipper/translations/en.json +++ b/clipper/translations/en.json @@ -109,6 +109,7 @@ "paste_timeout": "Sending the paste shortcut timed out.", "paste_start_failed": "The automatic paste process could not be started.", "invalid_id": "The selected clipboard entry is no longer valid.", + "global_wipe_disabled": "Clearing history is disabled while Clipper uses the global cliphist database.", "data_dir_unavailable": "Clipper's private data directory is unavailable. Pins and notes are disabled.", "max_notes": "The notecard limit has been reached.", "note_save_failed": "The notecard could not be saved.", @@ -120,7 +121,7 @@ "settings": { "database_mode": { "label": "Clipboard history database", - "description": "Use Clipper's isolated database or the default cliphist database shared with other clipboard tools. In global mode, delete and clear actions modify the shared history.", + "description": "Use Clipper's isolated database or the default cliphist database shared with other clipboard tools. In global mode, individual entries can be deleted, but clearing the complete shared history is disabled.", "options": { "private": "Private Clipper database", "global": "Global cliphist database"