diff --git a/proton-pass/README.md b/proton-pass/README.md new file mode 100644 index 0000000..4a0b36f --- /dev/null +++ b/proton-pass/README.md @@ -0,0 +1,15 @@ +# Proton Pass + +A Noctalia Launcher plugin that integrates with the Proton Pass CLI. + +## Features + +- Browse your Proton Pass vaults +- Browse items within each vault +- Copy passwords to the clipboard +- Display TOTP codes as a notification (when available) + +## Requirements + +- [proton-pass-cli](https://protonpass.github.io/pass-cli/) installed and available in your `PATH` +- An authenticated Proton Pass CLI session (run `pass-cli login`) diff --git a/proton-pass/launcher.luau b/proton-pass/launcher.luau new file mode 100644 index 0000000..0251758 --- /dev/null +++ b/proton-pass/launcher.luau @@ -0,0 +1,211 @@ +--!nonstrict + +-- Type returned by `pass-cli vault list --output=json` +type Vault = { name: string, vault_id: string, share_id: string } +type Vaults = { [number]: Vault } + +-- Type returned by `pass-cli item list --output=json` +type Item = { id: string, share_id: string, vault_id: string, state: string, title: string, item_type: string } +type Items = { [number]: Item } + +-- Caches to avoid repeated CLI calls while the launcher session is active +local cachedVaults: Vaults = {} +local cachedItems: { [string]: Items } = {} + +-- Utility functions ----------------------------------------------------------- + +-- Search cached items by item id across all cached vaults +local function searchCachedItems(item_id: string): Item? + for _, vault in cachedItems do + for _, item in vault do + if item.id == item_id then + return item + end + end + end + return nil +end + +-- Map + filter: transform each element, include only non-nil results +local function filterMap(sequence: { T }, fn: (T) -> U?) + local out = {} + for _, v in sequence do + local res = fn(v) + if res ~= nil then + table.insert(out, res) + end + end + return out +end + +local function glyphTable(item_type: string) + local lookupTable: { [string]: string } = { + alias = "at", + credit_card = "credit-card", + custom = "note", + identity = "user-circle", + login = "lock", + ssh_key = "key", + wifi = "router", + } + + return lookupTable[item_type] or "question-mark" +end + +-- Proton Pass CLI functions --------------------------------------------------- + +local function getVaultsAsync(callback) + if #cachedVaults ~= 0 then + callback(cachedVaults) + return + end + + noctalia.runAsync(`pass-cli vault list --output=json`, function(result) + if result.exitCode ~= 0 then + noctalia.log("proton-pass: failed to list vaults") + noctalia.notifyError("Proton Pass", noctalia.tr("failed-list-vaults")) + return callback({}) + end + + local decoded = noctalia.json.decode(result.stdout) + local vaults: Vaults = decoded["vaults"] or {} + cachedVaults = vaults + return callback(vaults) + end) +end + +local function getItemsAsync(vaultName: string, callback) + if cachedItems[vaultName] ~= nil then + callback(cachedItems[vaultName]) + return + end + + noctalia.runAsync(`pass-cli item list "{vaultName}" --output=json`, function(result) + if result.exitCode ~= 0 then + -- noctalia.log(`proton-pass: failed to list items in vault: {vaultName}`) + return callback({}) + end + + local decoded = noctalia.json.decode(result.stdout) + local items: Items = decoded["items"] or {} + cachedItems[vaultName] = items + return callback(items) + end) +end + +local function getPasswordAsync(item: Item, callback) + noctalia.runAsync(`pass-cli item view "pass://{item.share_id}/{item.id}/password"`, function(result) + if result.exitCode ~= 0 then + noctalia.log(`proton-pass: no password found for item: {item.title}`) + return + end + + local password: string = result.stdout + callback(password) + end) +end + +local function getItemTotpAsync(item: Item, callback) + noctalia.runAsync(`pass-cli item totp "pass://{item.share_id}/{item.id}" --output=json`, function(result) + if result.exitCode ~= 0 then + -- It's not an error if a TOTP is absent; just log for debugging + -- noctalia.log(`proton-pass: no TOTP code for item: {item.title}`) + return + end + + local decoded = noctalia.json.decode(result.stdout) + local totp: string = decoded["totp"] + callback(totp) + end) +end + +-- Row constructors ------------------------------------------------------------ + +local function vaultRows(pattern: string, callback) + getVaultsAsync(function(vaults) + callback(filterMap(vaults, function(vault) + local score = noctalia.fuzzyScore(pattern, vault.name) + if score == nil then + return nil + end + + return { + id = noctalia.json.encode({ type = "vault", id = vault.name }), + title = vault.name, + glyph = "folder", + score = score, + query = `{vault.name} `, + } + end)) + end) +end + +local function itemRows(vault: string, pattern: string, callback) + getItemsAsync(vault, function(items) + callback(filterMap(items, function(item) + -- Exclude Trashed items before scoring + if item.state ~= "Active" then + return nil + end + + local score = noctalia.fuzzyScore(pattern, item.title) + if score == nil then + return nil + end + + return { + id = noctalia.json.encode({ type = "item", id = item.id }), + title = item.title, + glyph = glyphTable(item.item_type), + score = score, + } + end)) + end) +end + +-- Entry points ---------------------------------------------------------------- + +function onQuery(query: string) + local vault, rest = query:match("^(%S+)%s+(.*)$") + + -- Reset cache when query is empty + if query == "" then + cachedVaults = {} + cachedItems = {} + end + + if not vault then + launcher.setResults(query, { { id = "loading", title = noctalia.tr("loading-vaults"), glyph = "loader" } }) + vaultRows(query, function(rows) + launcher.setResults(query, rows) + end) + else + launcher.setResults(query, { { id = "loading", title = noctalia.tr("loading-items"), glyph = "loader" } }) + itemRows(vault, rest, function(rows) + launcher.setResults(query, rows) + end) + end +end + +function onActivate(_id: string) + local id: { type: string, id: string } = noctalia.json.decode(_id) + + if id.type == "item" then + local item = searchCachedItems(id.id) + + if item == nil then + noctalia.notifyError("Proton Pass", noctalia.tr("item-not-found")) + return + end + + getPasswordAsync(item, function(password) + noctalia.copyToClipboard(password, "text/plain") + noctalia.notify(`{item.title}`, noctalia.tr("copied-password")) + + -- Try to fetch TOTP and notify + getItemTotpAsync(item, function(totp) + noctalia.notify(`{item.title} TOTP`, totp) + end) + end) + end +end diff --git a/proton-pass/plugin.toml b/proton-pass/plugin.toml new file mode 100644 index 0000000..5449e96 --- /dev/null +++ b/proton-pass/plugin.toml @@ -0,0 +1,17 @@ +id = "lucasoe/proton-pass" +name = "Proton Pass" +version = "0.1.0" +min_noctalia = "5.0.0" +author = "LucasOe" +license = "MIT" +icon = "lock" +description = "Access Proton Pass from the launcher" +tags = ["launcher"] +dependencies = ["proton-pass-cli"] + +[[launcher_provider]] +id = "proton-pass" +entry = "launcher.luau" +prefix = "pass" +glyph = "lock" +include_in_global_search = false diff --git a/proton-pass/thumbnail.webp b/proton-pass/thumbnail.webp new file mode 100644 index 0000000..0aef718 Binary files /dev/null and b/proton-pass/thumbnail.webp differ diff --git a/proton-pass/translations/en.json b/proton-pass/translations/en.json new file mode 100644 index 0000000..ed49843 --- /dev/null +++ b/proton-pass/translations/en.json @@ -0,0 +1,7 @@ +{ + "copied-password": "Copied password to clipboard", + "failed-list-vaults": "Failed to list vaults. Are you logged into pass-cli?", + "item-not-found": "Item not found", + "loading-items": "Loading Items…", + "loading-vaults": "Loading Vaults…" +}