diff --git a/pass/README.md b/pass/README.md index d7b8267..efcc1ce 100644 --- a/pass/README.md +++ b/pass/README.md @@ -12,7 +12,7 @@ Pass adds password-store search to the Noctalia launcher so you can copy passwor ## Requirements -Install `pass`, `pass-otp`, `gpg`, and `wl-copy` on `PATH`. +Install `pass`, `pass-otp`, `gpg`, `wl-copy`, and `find` 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`. @@ -41,7 +41,7 @@ This plugin does not expose custom IPC actions. It provides launcher provider `s ## 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. +- Spawned processes: the cache service runs `find` asynchronously to index entry paths without blocking Noctalia's plugin runtime. 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/plugin.toml b/pass/plugin.toml index 061d855..b1cfba1 100644 --- a/pass/plugin.toml +++ b/pass/plugin.toml @@ -1,6 +1,6 @@ id = "emrtnn/pass" name = "Pass" -version = "0.1.0" +version = "0.1.1" plugin_api = 3 author = "emrtnn" license = "MIT" @@ -8,7 +8,7 @@ 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"] +dependencies = ["pass", "pass-otp", "gpg", "wl-copy", "find"] [[setting]] key = "refresh_interval" diff --git a/pass/service.luau b/pass/service.luau index e1fb780..d7c823a 100644 --- a/pass/service.luau +++ b/pass/service.luau @@ -1,11 +1,16 @@ --!nonstrict local entries = {} +local scanInFlight = false local function publish() noctalia.state.set("entries", entries) end +local function shellEscape(value) + return "'" .. value:gsub("'", "'\\''") .. "'" +end + local function passwordStoreDir() local dir = noctalia.getenv("PASSWORD_STORE_DIR") @@ -16,56 +21,11 @@ local function passwordStoreDir() 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)) +local function rebuild() + if scanInFlight then return end - for _, name in ipairs(names) do - -- Ignore hidden files/directories (.git, .extensions, .gpg-id, ...) - if name:sub(1, 1) ~= "." then - -- listDir already proved the name exists, so an entry needs no stat. - -- Only non-entry names get one, to decide whether to recurse. - if 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, - }) - - else - local full = dir .. "/" .. name - local info = noctalia.fileInfo(full) - - if info and info.isDir then - scan(full, prefix .. name .. "/") - end - end - end - end -end - -local function rebuild() - entries = {} - local root = passwordStoreDir() if not noctalia.fileExists(root) then @@ -73,13 +33,62 @@ local function rebuild() return end - scan(root) + -- Directory traversal can exceed Noctalia's callback CPU budget on large stores. + -- Run it in a subprocess and only build the small launcher cache in Luau. + local command = "cd " .. shellEscape(root) + .. " && find . -mindepth 1" + .. " \\( -type d -name '.*' -prune \\)" + .. " -o \\( -name '*.gpg' ! -name '.*' -print0 \\)" - table.sort(entries, function(a, b) - return a.title:lower() < b.title:lower() - end) + scanInFlight = true - publish() + local accepted = noctalia.runAsync(command, function(result) + scanInFlight = false + + if result.exitCode ~= 0 then + noctalia.log("Failed to scan " .. root .. ": " .. (result.stderr or "unknown error")) + return + end + + if result.stdoutTruncated then + noctalia.log("Failed to scan " .. root .. ": command output was truncated") + return + end + + local nextEntries = {} + + for file in (result.stdout or ""):gmatch("([^%z]+)%z") do + -- find returns paths as ./folder/entry.gpg. + local path = file:sub(3, -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(nextEntries, { + id = path, + path = path, + title = title, + subtitle = subtitle, + }) + end + + table.sort(nextEntries, function(a, b) + return a.title:lower() < b.title:lower() + end) + + entries = nextEntries + publish() + end, 60000) + + if not accepted then + scanInFlight = false + noctalia.log("Failed to start password-store scan") + end end function update()