diff --git a/pass/README.md b/pass/README.md new file mode 100644 index 0000000..d7b8267 --- /dev/null +++ b/pass/README.md @@ -0,0 +1,47 @@ +# Pass + +Pass adds password-store search to the Noctalia launcher so you can copy passwords and OTP codes from `pass`. + +## Plugin + +| Field | Value | +| --- | --- | +| ID | `emrtnn/pass` | +| Entries | Launcher provider: `search`; service: `cache` | +| Launcher Prefix | `/pass` | + +## Requirements + +Install `pass`, `pass-otp`, `gpg`, and `wl-copy` on `PATH`. + +A working password store is expected in the same location `pass` uses: `$PASSWORD_STORE_DIR` when that variable is set, otherwise the default `~/.password-store`. OTP copying requires entries that are configured for `pass-otp`. + +## Usage + +Open the Noctalia launcher and type `/pass` to search password-store entries indexed by the `cache` service. Activate a result from launcher provider `search` to copy the password with `pass -c `. + +To copy an OTP code instead, type `/pass otp ` and activate the matching result. For example: + +```text +/pass otp github +``` + +If GPG needs an unlock passphrase, the plugin opens the configured terminal and runs the same copy command there so you can unlock the key interactively. + +## Settings + +| Setting | Type | Default | Description | +| --- | --- | --- | --- | +| `refresh_interval` | `int` | `30` | Seconds between password-store rescans. Minimum `5`, maximum `3600`. | + +## IPC + +This plugin does not expose custom IPC actions. It provides launcher provider `search` and background service `cache` entries only. + +## Notes + +- Filesystem reads: the service recursively scans `$PASSWORD_STORE_DIR` when set, otherwise `~/.password-store`, and indexes non-hidden `*.gpg` file names. It does not read decrypted password contents. +- Spawned processes: activating a password result runs `pass -c `; activating an OTP result runs `pass otp -c `. If GPG reports an unlock failure, the plugin opens a terminal and runs the same command interactively. +- Clipboard/privacy: copied secrets are handled by `pass`/`pass-otp` and the system clipboard tooling, typically including `gpg` and `wl-copy` on Wayland. The plugin stores only entry paths/titles in Noctalia state, not decrypted secrets. +- Network: the plugin makes no network calls. +- Writes: the plugin does not write files directly. `pass`, `pass-otp`, `gpg`, or clipboard tools may update their own runtime files such as agent or clipboard state. diff --git a/pass/launcher.luau b/pass/launcher.luau new file mode 100644 index 0000000..415b13e --- /dev/null +++ b/pass/launcher.luau @@ -0,0 +1,95 @@ +--!nonstrict + +local entries = {} + +local function shellEscape(str) + return "'" .. str:gsub("'", "'\\''") .. "'" +end + +noctalia.state.watch("entries", function(value) + entries = value or {} +end) + +function onQuery(query) + local results = {} + local action = "password" + local search = query + + if query:match("^otp%s+") then + action = "otp" + search = query:gsub("^otp%s+", "") + end + + for _, entry in ipairs(entries) do + local score + + if search == "" then + score = 1 + else + score = noctalia.fuzzyScore(search, entry.path) + end + + if score then + local subtitle = entry.subtitle ~= "" and entry.subtitle or nil + + if action == "otp" then + table.insert(results, { + id = "otp:" .. entry.id, + title = "Copy OTP: " .. entry.title, + subtitle = subtitle, + glyph = "123", + score = score, + }) + else + table.insert(results, { + id = "password:" .. entry.id, + title = entry.title, + subtitle = subtitle, + glyph = "key", + score = score, + }) + end + end + end + + launcher.setResults(query, results) +end + +local function copyWithPass(command, path, successMessage) + local escaped = shellEscape(path) + + noctalia.runAsync(command .. " " .. escaped, function(result) + if result.exitCode == 0 then + noctalia.notify("Pass", successMessage) + return + end + + local stderr = result.stderr or "" + local err = stderr:lower() + + if err:find("gpg") then + noctalia.notify("Pass", noctalia.tr("notification.unlocking")) + + noctalia.runInTerminal(command .. " " .. escaped) + return + end + + if err == "" then + err = noctalia.tr("notification.copy_failed") + else + err = stderr + end + + noctalia.notifyError("Pass", err) + end) +end + +function onActivate(id) + local action, path = id:match("^([^:]+):(.+)$") + + if action == "otp" then + copyWithPass("pass otp -c", path, "OTP code copied") + else + copyWithPass("pass -c", path or id, noctalia.tr("notification.copied")) + end +end diff --git a/pass/plugin.toml b/pass/plugin.toml new file mode 100644 index 0000000..390e25f --- /dev/null +++ b/pass/plugin.toml @@ -0,0 +1,30 @@ +id = "emrtnn/pass" +name = "Pass" +version = "0.0.1" +plugin_api = 3 +author = "emrtnn" +license = "MIT" +deprecated = false +icon = "key" +description = "Search and copy password-store entries from the Noctalia launcher" +tags = ["launcher", "privacy", "productivity", "utility"] +dependencies = ["pass", "pass-otp", "gpg", "wl-copy"] + +[[setting]] +key = "refresh_interval" +type = "int" +label_key = "settings.refresh_interval.label" +description_key = "settings.refresh_interval.description" +default = 30 +min = 5 +max = 3600 + +[[launcher_provider]] +id = "search" +entry = "launcher.luau" +prefix = "pass" +glyph = "key" + +[[service]] +id = "cache" +entry = "service.luau" diff --git a/pass/service.luau b/pass/service.luau new file mode 100644 index 0000000..9da3559 --- /dev/null +++ b/pass/service.luau @@ -0,0 +1,91 @@ +--!nonstrict + +local entries = {} + +local function publish() + noctalia.state.set("entries", entries) +end + +local function passwordStoreDir() + local dir = noctalia.getenv("PASSWORD_STORE_DIR") + + if dir and dir ~= "" then + return noctalia.expandPath(dir) + end + + return noctalia.expandPath("~/.password-store") +end + +local function scan(dir, prefix) + prefix = prefix or "" + + local names, err = noctalia.listDir(dir) + + if not names then + noctalia.log("Failed to list " .. dir .. ": " .. tostring(err)) + return + end + + for _, name in ipairs(names) do + -- Ignore hidden files/directories (.git, .extensions, .gpg-id, ...) + if name:sub(1, 1) ~= "." then + local full = dir .. "/" .. name + local info = noctalia.fileInfo(full) + + if info then + if info.isDir then + scan(full, prefix .. name .. "/") + + elseif name:sub(-4) == ".gpg" then + local path = prefix .. name:sub(1, -5) + + local title = path + local subtitle = "" + + local lastSlash = path:match("^.*()/") + + if lastSlash then + subtitle = path:sub(1, lastSlash - 1) + title = path:sub(lastSlash + 1) + end + + table.insert(entries, { + id = path, + path = path, + title = title, + subtitle = subtitle, + }) + end + end + end + end +end + +local function rebuild() + entries = {} + + local root = passwordStoreDir() + + if not noctalia.fileExists(root) then + noctalia.notifyError("Pass", noctalia.tr("notification.store_not_found")) + return + end + + scan(root) + + table.sort(entries, function(a, b) + return a.title:lower() < b.title:lower() + end) + + publish() +end + +function update() + rebuild() +end + +local refresh = noctalia.getConfig("refresh_interval") or 30 + +noctalia.setUpdateInterval(refresh * 1000) + +rebuild() diff --git a/pass/thumbnail.webp b/pass/thumbnail.webp new file mode 100644 index 0000000..ef06bc8 Binary files /dev/null and b/pass/thumbnail.webp differ diff --git a/pass/translations/en.json b/pass/translations/en.json new file mode 100644 index 0000000..7de8f89 --- /dev/null +++ b/pass/translations/en.json @@ -0,0 +1,14 @@ +{ + "settings": { + "refresh_interval": { + "label": "Refresh interval (seconds)", + "description": "How often to rescan the password store, in seconds." + } + }, + "notification": { + "copied": "Password copied.", + "unlocking": "Unlocking GPG key…", + "copy_failed": "Failed to copy password.", + "store_not_found": "Password store not found." + } +}