Skip to content

Launcher pass - #514

Merged
ItsLemmy merged 36 commits into
noctalia-dev:mainfrom
mellotanica:launcher-pass
Aug 30, 2026
Merged

Launcher pass#514
ItsLemmy merged 36 commits into
noctalia-dev:mainfrom
mellotanica:launcher-pass

Conversation

@mellotanica

@mellotanica mellotanica commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

Plugin

launcher-pass

  • Id: mellotanica/launcher-pass

  • New plugin

  • Update to an existing plugin (version bumped in plugin.toml)

What it does

Browse a GNU Pass password store from the
Noctalia launcher and copy or auto-type passwords, OTP codes, usernames, and any
other field without opening a terminal with Noctalia fuzzy find always on.

External dependencies

pass and pass-otp to retrieve password contents
wtype to perform password content autotyping
find and grep to build and prefilter the a password store index cache used to provide the launcher content list
sleep to implement timeouts on external actions (give time to the pinentry program to show up and unlock password store, or wait for the focus to return to the previous spot before autotyping)

Testing

I tested both explicit prefix input in launcher and documented IPC activation via hyprland keybinding, each setting field was tested after its implementation, using a separate password store to check back and forth that store selection worked as well as the other parameters.
Also I am using the plugin constantly since I relied on previous version on Noctalia v4 up until I updated to v5 beta.

  • Tested on Niri
  • Tested on Hyprland
  • Tested on Sway
  • Tested on another compositor:
  • Noctalia version tested against: 5.0.0 beta-9 (Arch linux 5.0.0_beta.9-3)
  • Plugin API level: 26

Screenshots / Videos

None

Checklist

Ready-for-review requirement: Every box in this section must be checked. If any statement is not true, keep the
pull request as Draft. An explanation does not replace a required check.

  • The directory name matches the part of id after the / in plugin.toml exactly.
  • It ships plugin.toml, README.md, thumbnail.webp, and translations/en.json.
  • README.md follows the
    README template, documents
    every entry id and dependency, and includes exact panel IPC commands and launcher prefixes where applicable.
  • I created thumbnail.webp with the thumbnail generator.
  • version follows semver and is bumped in this PR; plugin_api is the oldest API level this plugin requires.
  • Every non-English translation in this PR uses a locale supported by Noctalia core, and I can read, write, and
    understand that language well enough to review and maintain it (no unreviewed machine/LLM translations).
  • I did not edit catalog.toml; CI generates it.
  • This PR touches exactly one plugin directory.

Code review attestation

Plugins run as trusted, unsandboxed Luau in the user's session. Confirm:
Ready-for-review requirement: Every attestation below must be checked.

  • The code is readable and not obfuscated, minified, or generated.
  • It does not download and execute remote code.
  • Every network call, filesystem write, and spawned process is something the description above accounts for.
  • I have the right to publish this code under the license declared in plugin.toml.

Marco Melletti and others added 30 commits August 27, 2026 11:36
First step of the QML -> Luau rewrite. Adds the v5 plugin skeleton and the
read-only half of the feature; no `pass`/`wtype`/clipboard integration yet.

New files:
- plugin.toml: manifest with the [[launcher_provider]] entry (prefix "pass",
  debounce_ms 200, include_in_global_search false) and the full settings
  schema (storePath, clipTimeout, typeDelay, wtypeDelay). plugin_api = 24,
  the level at which argv-form runAsync lands (highest feature used so far).
- launcher.luau: the provider entry script. onQuery lists the store via
  `find` (maxdepth-1 children for an empty query, recursive *.gpg otherwise),
  parses names the same way the QML did, and filters/scores them with an
  exact port of fuzzyMatch (whitespace-split query, every fragment a
  contiguous substring, score favouring earlier path segments and
  alphabetically-earlier names). Folder drill-in uses a module-local nav
  stack plus launcher.setQuery to re-fire onQuery; a "Go back" row pops it.
  A 5 s per-key cache keeps debounced keystrokes from re-spawning `find`.
  Activating a password entry is a stub notification for now.
- translations/en.json: keys used so far.

Deviations from CLAUDE.md, chosen against the working sibling plugins:
- settings declared as top-level [[setting]] (docs only cover nested settings
  for widget/panel; every sibling launcher plugin uses top-level);
- translations kept as nested JSON (what noctalia.tr + label_key resolve
  against in pass/k8s-status/file-search);
- plugin_api pinned to 24 rather than the 28 ceiling.

Also fixes a latent QML bug: synthesized parent-folder rows in recursive
search now carry a currentPath-prefixed fullPath, so drilling into them
resolves to the right directory.

The v4 .qml files are kept in place for the remaining port steps.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The ported QML segment score made leaf length dominate: it built a
base-27 value per path segment, so a long leaf like "root_new_1"
outscored a shallower ".../root" no matter the per-depth weighting, and
the "_new" variants sorted above the plain entry.

Replace it with rankEntry(), sorting by:
  1. exact segment hits — query fragments that equal a whole path
     segment (fragment "root" == leaf "root" beats "root" as a substring
     of "root_new_1"), so plain entries group above suffixed variants;
  2. depth — fewer path segments first;
  3. full path, alphabetically.

Unlike the QML formula this takes the query into account. It also drops
the per-character arithmetic: a small fragment x segment loop over the
capped grep output. SEG_W / pathScore removed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Folder navigation put find spawn + parse on the critical path between
selecting a row and the list updating (~1s with the debounce on top).

Navigation always drills into a folder visible in the current list, so:
- at module load, warm the store-root listing;
- whenever a browse view is published, fetch its child folders'
  listings in the background (capped at 10, deduped against an
  in-flight set and the browse cache TTL).

Activating a folder then hits a warm cache and renders synchronously,
with no subprocess and no loading-row flash; rendering that view warms
its children in turn, so the effect propagates down the tree. Recursive
search still goes through grep and does not prefetch, so typing a query
does not spawn a burst of find processes.

Removed the debounce timer since cache is improving navigation enough
by itself

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…stubbed)

Implements CLAUDE.md port step "1. Detail mode":

- onActivate on a password-entry row now rewrites the query to a leading
  ":" + entry path instead of stub-notifying immediately. This mirrors the
  trailing-"/" folder marker already used for browse: a sigil never
  produced by normal browsing, so a fresh ">pass " always falls through to
  browse and resets detail state for free (no onOpened()-style hook needed).
- onQuery routes that leading-":" text to a new fetchDetail(), which shows
  a "Decrypting..." row, runs
  `env PASSWORD_STORE_DIR=<dir> pass show <entry>` via runAsync, and
  renders the parsed result. Decrypted entries are cached 60s per path so a
  debounce re-fire doesn't trigger a second pass show / pinentry prompt.
  A failed/timed-out pass show renders a "Go back" + error row.
- parsePassEntry: first non-empty line is the password, later lines split
  on the first ": " into key/value fields (file order), any line
  containing "otpauth://" flags OTP support — per "Behaviour to preserve
  noctalia-dev#5".
- extractUsername implements "Behaviour to implement noctalia-dev#1": pulls the value
  of the first login/user/username field (case-insensitive) out of the
  field list, falling back to the entry's basename; the field is removed
  from the remaining list so it isn't shown twice.
- renderDetailRows orders rows per "Behaviour to implement noctalia-dev#2": Password,
  OTP (only if present), Username, then remaining fields in file order —
  each as a Copy + Type pair, plus "Go back" to the entry's parent folder.
- Row actions carry no field data in their id: each Copy/Type row gets an
  opaque "act:<n>" id (n derived from the row count, so no separate
  counter), and a module-local `detailActions` table maps id -> {verb,
  kind, key, value, label} for onActivate to read. Avoids encoding
  arbitrary field keys/values (which can contain almost anything) into the
  id string; the table is simply rebuilt each render since only one detail
  view is ever live.
- onActivate on an "act:" id is currently a stub noctalia.notify(label,
  "<label> is not implemented yet") — real copy/type/OTP/clipboard-timeout
  /wtype wiring is the next port step.
- Refactored the "Go back" row (shared by browse and detail) into
  goBackRow(parent, subtitle), and factored out parentOf(); renderRows
  updated to use both, no behaviour change there.
- translations/en.json: added action.copy*/type* (password, OTP,
  username, field) and detail.decrypting/error/actionPending keys, all
  used by the new code; removed the now-unused detail.pending stub key.
- CLAUDE.md: updated Port status to reflect detail-mode listing being
  done, row actions being the new stub point, and resolved the detail-mode
  query encoding as an "Open questions" / "Decisions" entry.

Verified by loading launcher.luau in an isolated Luau environment (via
loadstring + setfenv) with mocked noctalia/launcher globals and a canned
`pass show` output, exercising: entry activation -> query rewrite ->
onQuery(":path") -> pass show -> parsed row order/content (password, OTP,
username, url field) -> stub notify on both a fixed and a dynamic-key
action -> "Go back" returning to the correct folder query; plus a
no-OTP/no-login-field entry (username falls back to basename) and a
failed pass show (renders the error row) and a sanity check that plain
browse queries still render after the goBackRow/parentOf refactor.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VggPYgn8B9vM1MtsT6VSZN
…el IPC

The launcher panel is a layer-shell surface with an exclusive keyboard
grab, so the GPG pinentry dialog `pass show` pops on a locked store never
receives focus while the panel is open — same problem the old QML version
fixed by closing/reopening the launcher. There is no noctalia.* plugin API
to hand focus to an arbitrary window, but the installed noctalia binary's
own `noctalia msg --help` documents a panel IPC that does the same job:

  panel-close [id]        "Close the active panel, or close the named
                            panel if it is active" (safe to call even if
                            already closed)
  panel-open  <id> [ctx]   "Open a panel by id, optionally with context
                            (e.g. launcher /emo, control-center audio)"
  panel-toggle <id> [ctx]  same, toggled

fetchDetail now:
  1. closeLauncherPanel() -> `noctalia msg panel-close launcher`
  2. runs `pass show` with the panel down, with an explicit 300s timeout
     (DECRYPT_TIMEOUT_MS) instead of none — pinentry can legitimately
     block a long time on a slow passphrase or a hardware-key touch, and
     this bound exists only as a safety net so a truly wedged gpg-agent
     eventually reopens the panel rather than leaving it closed forever
  3. populates detailCache / calls launcher.setResults from the result
  4. reopenLauncherPanel(text) -> `noctalia msg panel-open launcher
     ">pass " .. text`, so the host re-delivers the exact prior input as a
     fresh onQuery and re-renders from the now-warm cache

Step 3 has to happen before step 4: the reopened panel's re-delivered
onQuery is a second, re-entrant call into fetchDetail, and it must see a
cache hit or it would kick off a second `pass show` (and a second
pinentry prompt) right on top of the first.

closeLauncherPanel/reopenLauncherPanel are factored out for reuse: the
next port step's `pass -c` / `pass otp -c` calls (copy/type actions) can
also hit pinentry if the gpg-agent cache has expired, and should wrap
themselves the same way.

Verified in the same isolated-Luau mock harness as the previous commit,
extended to track panel-close/panel-open calls: confirms close-then-open
ordering, the reopen context is exactly ">pass :<entryPath>", the dance
is skipped entirely on a detailCache hit, and the panel is still reopened
when `pass show` fails. Not yet exercised against a real pinentry prompt
on the live desktop — flagged in CLAUDE.md as worth a manual check.

CLAUDE.md: resolved the "pinentry dance" and "IPC toggle" open questions
(the latter answered by the same panel-* IPC, generalized to any panel
id — this is the v5 analogue of v4's pluginApi.toggleLauncher()).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VggPYgn8B9vM1MtsT6VSZN
The pinentry-dance panel reopen hardcoded ">pass " .. text as the
panel-open "context" argument, but the leading ">" is itself a user
setting (shell.launcher.provider_prefix), not a fixed character — this
dev machine has it configured to "/", confirmed via
`noctalia config export full` (`[shell.launcher] provider_prefix = "/"`).
Reopening with the wrong leader produces a string the host no longer
recognises as a launcher command at all, so the detail view never came
back after pinentry.

launcherLeader() now reads noctalia.getSetting("shell.launcher.provider_prefix")
and falls back to ">" only when that returns nil (an older host without
plugin_api 26 support, per the runtime-api docs: "Requires plugin_api =
26"). reopenLauncherPanel builds the context as
launcherLeader() .. "pass " .. text instead of a hardcoded ">pass ".

plugin_api bumped 24 -> 26 accordingly (getSetting is the new
highest-level feature in use).

Verified in the same isolated-Luau mock harness as the previous two
commits: getSetting mocked to return "/" (matching this machine's real
config) confirms the reopen context is "/pass :<entryPath>", not
">pass :<entryPath>"; a second case with getSetting returning nil
confirms the ">" fallback still works for hosts below plugin_api 26.

CLAUDE.md: documented the shell.launcher.provider_prefix setting and the
plugin_api bump; noted this is the one value in the pinentry dance that
must never be hardcoded.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VggPYgn8B9vM1MtsT6VSZN
…nlocked

Previously fetchDetail always closed the launcher panel before running
`pass show`, even when gpg-agent already had the passphrase cached and no
pinentry dialog was going to appear at all — a pointless close/reopen
flicker on every decrypt.

fetchDetail now races `pass show` against a short, configurable grace
period (new `pinentryGraceMs` setting, default 200ms) instead of closing
the panel unconditionally up front:

  - starts `pass show` (unchanged: DECRYPT_TIMEOUT_MS, no panel touched)
  - simultaneously starts `{"sleep", "<pinentryGraceMs/1000>"}`
  - whichever finishes first decides:
      * `pass show` first  -> `resolved = true`; the sleep callback later
        sees that and does nothing. No pinentry was needed; the panel is
        never closed or reopened.
      * sleep first        -> `panelClosed = true`; closeLauncherPanel()
        runs belatedly. `pass show`'s callback later sees `panelClosed`
        and calls reopenLauncherPanel() once it finally resolves.

Both flags are plain upvalues shared by the two callbacks — safe without
locking since Luau callbacks run to completion before the next one
starts; there's no true concurrency, just two independent subprocesses
racing to finish first.

plugin.toml: new `pinentryGraceMs` int setting (default 200, advanced),
translations/en.json: matching label/desc keys.

Verified in the same isolated-Luau mock harness as the previous commits,
extended with a deferred-callback technique to simulate the slow
(pinentry-needed) path: the mock's `pass show` handler can now stash its
callback instead of firing it immediately, letting a test assert the
panel closes once the grace-period sleep fires while `pass show` is still
pending, then reopens correctly (right context, correct rendered rows)
once the stashed callback is finally invoked. Confirms: a fast decrypt
never touches the panel (3 separate scenarios: normal entry, a second
entry, and a failing-but-fast one); a slow decrypt closes-then-reopens
with the right context; the ">" leader fallback still exercises the dance
correctly; and a configured pinentryGraceMs=500 becomes a 0.5s sleep.

CLAUDE.md: documented the race and updated the "How launcher.luau works
now" / Decisions sections; noted the fifth settings entry.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VggPYgn8B9vM1MtsT6VSZN
The previous commit's plugin.toml landed with default = 500 for the new
pinentryGraceMs setting, inconsistent with the 200ms the user actually
asked for, with launcher.luau's own PINENTRY_GRACE_DEFAULT_MS = 200
fallback constant, and with the translation text ("default: 200"). Fixed
to 200 so the manifest default matches the code and docs.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VggPYgn8B9vM1MtsT6VSZN
The act:<n> rows were all stub notifications. Copy Password now runs for
real; the other Copy/Type actions stay stubs for the next steps.

- onActivate's "act" branch calls runAction(ctx), which dispatches
  verb=copy/kind=password to copyViaPass({"pass","-c",entryPath}, <basename>).
- copyViaPass closes the launcher panel first (same idea as the QML
  version's launcher.close() before `pass -c`: with the panel gone a
  pinentry prompt on a stale gpg-agent cache takes focus on its own, so
  no close/reopen dance is needed), then runs `pass -c` with the
  DECRYPT_TIMEOUT_MS safety net and posts notification.copied /
  notification.copyFailed. The launcher stays closed afterwards, the
  terminal state QML left it in.
- New helpers: positiveInt + clipTimeout resolve the clip-clear timeout
  (clipTimeout setting -> PASSWORD_STORE_CLIP_TIME env -> nil, let pass
  default), per "Behaviour to preserve noctalia-dev#6". passArgv(cmd, withClip)
  builds the `env PASSWORD_STORE_DIR=... [PASSWORD_STORE_CLIP_TIME=...]
  <cmd>` argv; fetchDetail's `pass show` call was refactored onto it.
- en.json: add notification.copied / notification.copyFailed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ABXQ7ZihMJoovXkfYCxZYt
runAction now dispatches verb=copy/kind=otp to
copyViaPass({"pass","otp","-c",entryPath}, <basename>), reusing the same
clip-timeout resolution and close-launcher-first pinentry handling as Copy
Password. The OTP rows still only render when the decrypted body contains
otpauth:// (data.hasOtp). A missing pass-otp extension currently surfaces
as notification.copyFailed; the commandExists probe to hide the rows
instead is still pending.

Copy field / Copy username / all Type actions remain stubs.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ABXQ7ZihMJoovXkfYCxZYt
runAction now dispatches verb=copy/kind=username to copyPlain(ctx.value,
<basename>). ctx.value is data.username, already resolved by
extractUsername (first login/user/username field, case-insensitive; entry
basename as fallback) — the copy path just consumes that single source of
truth.

New helper copyPlain: for values already decrypted in memory there's no
subprocess and no pinentry, so it copies straight through
noctalia.copyToClipboard, posts notification.copied, and closes the
launcher (same terminal state as the pass -c path; QML also closed the
launcher before piping to wl-copy). No clipboard auto-clear here — pass
isn't in the loop — matching QML, which only ran the clearing timer for
pass -c / pass otp -c.

Copy field and every Type action remain stubs.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ABXQ7ZihMJoovXkfYCxZYt
noctalia.copyToClipboard requires a second argument — without it the host
raises "missing argument noctalia-dev#2 to 'copyToClipboard' (string expected)" and
onActivate aborts, so Copy Username copied nothing and showed no notice.
Pass "text/plain" (CLIP_MIME).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ABXQ7ZihMJoovXkfYCxZYt
runAction now dispatches verb=copy/kind=field to copyPlain(ctx.value,
ctx.key): a parsed key: value line is copied verbatim through
noctalia.copyToClipboard, with the field key as the notification body.
Reuses the same plain-copy path as Copy Username (no subprocess, no
pinentry, no clipboard auto-clear), so this completes every Copy action.

Type actions remain stubs.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ABXQ7ZihMJoovXkfYCxZYt
Native auto-paste is out: LauncherPanel::finishActivation() fires it only
when provider.supportsAutoPaste(), which PluginLauncherProvider never
overrides — no manifest key, noctalia.* call, or `noctalia msg` command
exposes it or the virtual-keyboard paste. So typing is done with wtype.

- typeValue(value): runAsync({"sleep", typeDelay/1000}) then
  runAsync({"wtype","-d",wtypeDelay,"--",value}). Argv only — the value
  sits after "--" so a leading "-" is still text; no `printf | wtype -`
  pipe, no shellEscape. Resolves the "wtype stdin" open question.
- typeViaWtype(value): close the launcher (wtype types into the focused
  window) then typeValue.
- typeOtp(entryPath): close launcher -> `pass otp` (no -c) -> typeValue的
  the printed code. `pass otp` can hit pinentry, which has focus since the
  panel is already closed.
- runAction branches on verb (copy/type) then kind.
- wtypeAvailable(): memoised commandExists("wtype"); addAction omits the
  Type row when false, so a host without wtype shows Copy-only rows.
- intSetting(key, default): non-negative int setting (0 valid), for
  typeDelay / wtypeDelay.
- en.json: + notification.typeFailed; - detail.actionPending (the last
  stub-notice user, now gone).
- plugin.toml: + "wtype" to dependencies.

Copy actions are unchanged from the previous pass (restored after the
clipboard-only experiment).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ABXQ7ZihMJoovXkfYCxZYt
Two behaviour changes for detail mode:

1. onActivate on an entry row now sets the query to ":<path> " (trailing
   space) instead of ":<path>", so the just-opened detail view carries an
   empty row filter.

2. onQuery parses the post-":" text as "<entryPath> <rowFilter>" and
   fuzzy-filters the rendered Copy/Type rows by title with the browse
   match rule (whitespace-split, each fragment a case-insensitive
   contiguous substring, order-independent). ":<path> otp" -> only the
   Copy OTP / Type OTP rows.

Details:
- splitDetailQuery(rest): an entry path can contain spaces, so the
  path/filter boundary is resolved against lastDetailPath (new module
  local, set on every fetchDetail) first, then any warm detailCache key
  (longest match wins), else the whole trimmed string is the path. If the
  host ever strips the trailing space, this degrades to "no filter"
  rather than breaking.
- matchesFragments(): factored out of renderRows and reused for the
  detail filter.
- renderDetailRows(entryPath, data, filter): builds all rows (still
  recording detailActions for every one), then keeps only the rows whose
  title matches; empty filter -> all rows. The "Go back" row is filtered
  too (a non-matching filter hides it).
- onQuery now parses the ":" branch off `text` directly, not trim(text),
  so the filter's spacing survives.

Fixes the pre-existing bug where typing anything while a detail view was
open turned the whole query into a bogus entry path and fired a
`pass show` per keystroke.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ABXQ7ZihMJoovXkfYCxZYt
translations/{de,fr,it,es,ja,nl,pt,ru,tr,zh-CN}.json, each with the exact
31-key set of the canonical en.json (nested JSON; noctalia.tr resolves the
dotted keys against the nesting).

- Legacy strings reused from i18n/<locale>.json.
- Translated fresh: detail.decrypting, detail.error,
  notification.copyFailed, notification.typeFailed, action.copyUsername,
  action.typeUsername, settings.pinentryGraceMs.{label,desc}, plus the
  reworded settings.typeDelay.label ("Type delay", not the old misleading
  "Launcher Close Delay") and settings.clipTimeout.desc (no more {value}
  placeholder).
- Dropped legacy-only keys absent from the v5 code: result.passwordEntry,
  settings.tab.{general,advanced}.
- Fixed legacy typos while porting: nl "Gekopiëerd" -> "Gekopieerd",
  pt "predefenido" -> "predefinido".

Key parity across all 11 locale files verified by script. i18n/ stays on
disk until the Cleanup step.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ABXQ7ZihMJoovXkfYCxZYt
Replaces the v4 README (which still referenced Settings.qml labels and the
dead `qs -c noctalia-shell ipc call plugin:launcher-pass toggle`).

Follows the community-plugins README_TEMPLATE.md layout (Plugin table /
Requirements / Usage / Settings / IPC / Notes), matching sibling
pass/README.md:

- `/pass` prefix, with the shell.launcher.provider_prefix caveat.
- Required deps (pass, find, grep; + gpg, wl-clipboard for `pass -c`) vs
  optional (pass-otp for the OTP rows, wtype for the Type rows).
- Detail-view row list + the ":<path> <filter>" row filter, described in
  user terms.
- Copy vs Type behaviour and which copies auto-clear the clipboard.
- pinentry hide/reopen.
- IPC: no custom plugin IPC; `noctalia msg panel-open|toggle|close
  launcher "/pass "` to open into the provider.
- Notes: the native auto-paste limitation, filesystem reads, spawned
  processes, secret handling, no network, no writes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ABXQ7ZihMJoovXkfYCxZYt
Removes the superseded Noctalia v4 files now that plugin.toml +
launcher.luau + translations/ cover the whole feature set:

- Main.qml, LauncherProvider.qml, Settings.qml  (QML entry points)
- manifest.json                                 (-> plugin.toml)
- settings.json                                 (stale local snapshot)
- i18n/                                          (-> translations/, ported
                                                  in the previous commit)

preview.png is kept for now — it's the source for thumbnail.webp (Next
steps noctalia-dev#4) and gets deleted in that commit.

launcher.luau's header comment refreshed: dropped the "Port step 1/2"
framing and the reference to the now-deleted LauncherProvider.qml.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ABXQ7ZihMJoovXkfYCxZYt
Two new advanced select settings shape the Copy/Type row layout in an
entry's detail view:

- detailActionOrder: "copy" (default) or "type" — which verb's row comes
  first for each value.
- detailActionGrouping: "interleaved" (default) pairs each value's Copy
  and Type rows; "grouped" emits every Copy row then every Type row, each
  block in the detailActionOrder direction.

renderDetailRows now assembles a `specs` list in the fixed value order
(password, OTP, username, fields), resolves a `verbs` list from
detailActionOrder ("type" dropped when wtype is absent), then nests
spec x verb either way. addAction became actionRow(entryPath, spec, verb,
n), which returns one row and records its detailActions[act:<n>] context;
the id counter is now an explicit `n` instead of `#rows`.

Default (copy / interleaved) reproduces the previous layout exactly.

plugin.toml: two [[setting]] blocks with inline-table `options`.
Translations: 8 new keys (settings.detailAction{Order,Grouping}.* incl.
.options.*) across all 11 locales; the generator was reworked to also
emit en.json (byte-identical to the hand-maintained file) so all 11 stay
in lockstep. README settings table updated.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ABXQ7ZihMJoovXkfYCxZYt
rankEntry previously scored each matched entry with a Luau double loop
(count fragments equal to a whole path segment). It now sums
noctalia.fuzzyScore(fragment, nameLower) over the query fragments — the
host's native fzy scorer (case-insensitive; exact match = 1024; prefix /
word-boundary / consecutive-run bonuses; longer text penalised) — and
returns that plus path depth for the tiebreak. renderRows' sort key
follows (score desc, depth asc, name asc).

- Matching is unchanged: the `grep -iF` pre-filter and matchesFragments
  still decide which rows appear. fuzzyScore is only the sort key, so
  "Behaviour to preserve noctalia-dev#4" (whitespace-split, each fragment a
  contiguous substring, order-independent, space = wildcard) holds.
- Summed per fragment rather than passed as one fzy pattern, so the match
  stays order-independent and the inter-fragment space keeps acting as a
  wildcard; a fragment fzy rejects contributes 0.
- Moves the per-entry scoring from the Luau interpreter into C, over the
  capped grep hit set — the point of this optimization step for large
  stores.
- No plugin_api bump; fuzzyScore is in the noctalia base lib.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ABXQ7ZihMJoovXkfYCxZYt
Fixes `script callback 'async command callback' exceeded its CPU budget`
(25 ms) on a large store — first for a broad search ("w"), then, after an
interim all-in-Luau index, at load in parseIndex (:227).

Architecture:
- buildIndex writes <pluginDataDir>/store.index once via
  `find … -name '.*' -prune -o -type f -name '*.gpg' -printf '%P\n'
  -o -type d -printf '%P/\n' > file` (shell string, redirected). The
  callback only sets indexReady/indexAt — negligible Luau, safe at load.
  Rebuilt in the background past INDEX_TTL_MS, grepping the current file
  meanwhile.
- onQuery greps that file in a subprocess (off the Luau budget):
  searchGrepCmd = `grep -iF` AND-chain + `head -n 500`; browseGrepCmd =
  `grep -E '^<reEsc folder>/[^/]+/?$'` + `head -n 2000` (root pattern
  `^[^/]+/?$`). Luau parseHits only the capped output.
- renderSearchRows: prerank (cheap pure-Luau, segHits+depth) over every
  hit, then fuzzySum (Σ noctalia.fuzzyScore) over only the top
  SCORE_POOL=64. fuzzyScore ≤ 64×/keystroke, not ~900×.
- renderBrowseRows: sort the already-direct-children hits by basename.

Removed: listCache, parseEntries, searchCmd/browseArgv/browseCacheKey,
folderDir, prefetchBrowse/prefetchChildren/pendingFetch, GREP_CAP/
BROWSE_TTL_MS/SEARCH_TTL_MS, renderRows. Added: shq (back), reEsc,
indexPath, parseHits, browseGrepCmd/searchGrepCmd, renderBrowseRows/
renderSearchRows.

Match semantics unchanged. Measured with real luau against
~/.password-store (843 entries): ~1.5 ms Luau/keystroke, flat over 1000
repeats.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ABXQ7ZihMJoovXkfYCxZYt
Marco Melletti and others added 2 commits August 28, 2026 16:57
The port is complete, so the doc no longer tracks it. Removed: "what this
needs to become", Port status, Next steps, Open questions, the v5 target
file structure sketch, the plugin.toml/translations sketches, the
QML→Luau runtime mapping, and the v4/QML legacy reference.

New structure, ordered for further development:
- Files + plugin_api level
- How launcher.luau works — navigation encoding, index file + grep,
  ranking, detail mode, copy/type actions, the pinentry dance
- Behaviour contract — the invariants not to regress
- Design decisions — dedicated chapter summarising the choices made
  during development, each with its rationale
- Noctalia plugin runtime reference — provider contract, the noctalia.*
  calls actually used, the 25 ms CPU budget, subprocess rules
- Luau conventions & gotchas (kept)
- Working on this plugin — the luau-harness test approach, the
  translations regen rule, the commit workflow

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ABXQ7ZihMJoovXkfYCxZYt
Marco Melletti and others added 2 commits August 28, 2026 17:41
The plugin-store validator rejects `translations/*.json` keys that aren't a
single lowercase segment (no uppercase, no dots) and `label_key` /
`description_key` values in plugin.toml that contain uppercase. Every
camelCase translation key tripped it: `result.goBack`,
`notification.copyFailed`, `action.copyPassword…`, `settings.storePath…`,
etc.

Renamed the offending key segments to kebab-case across en.json, it.json,
plugin.toml (label_key / description_key / option label_key), and the
`noctalia.tr(...)` call sites in launcher.luau. The setting `key` fields
(`storePath`, `detailActionOrder`, …) are unchanged — they're config keys
read by `noctalia.getConfig`, not translation keys, and the validator
doesn't touch them. Nesting and English/Italian text are untouched.

Updated the two now-stale key examples in CLAUDE.md and noted the
kebab-case rule.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R6Xb6bXQJSm8PamMPYa1hA
@mellotanica mellotanica reopened this Aug 28, 2026
@mellotanica
mellotanica marked this pull request as ready for review August 28, 2026 15:42
@mellotanica
mellotanica marked this pull request as draft August 28, 2026 16:21
Marco Melletti and others added 2 commits August 28, 2026 18:30
fetchDetail closes the launcher panel when the pinentry grace period
elapses, then reopened it unconditionally once `pass show` resolved. If
the user dismissed the pinentry dialog (or gpg errored / timed out),
`pass show` exits non-zero, the error row is rendered, but the panel was
still reopened with the detail context — the host re-delivers that as a
fresh onQuery, fetchDetail runs again (the failure was never cached),
starts another `pass show`, and pinentry pops again. Nothing between the
reopen and the next prompt is user-driven, so it never stops.

Reopen the panel only when the decrypt succeeds. A non-zero exit or
timeout now leaves the launcher closed — the graceful exit the user asked
for; retrying is an explicit reopen. The success path (reopen -> warm
detailCache -> render) is unchanged.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wJaioGzCA4WwL1s4Dygj1
@mellotanica
mellotanica marked this pull request as ready for review August 28, 2026 16:36
@frasdl

frasdl commented Aug 28, 2026

Copy link
Copy Markdown

Looks good to me

@ItsLemmy
ItsLemmy merged commit 58e107b into noctalia-dev:main Aug 30, 2026
2 checks passed
@ItsLemmy

Copy link
Copy Markdown
Contributor

Seems like a superset of the existing "Pass" plugin.

  1. non-blocking - launcher-pass/plugin.toml:19
    The manifest dependencies do not cover every spawned binary. sleep is spawned directly (launcher-pass/launcher.luau:764, launcher-pass/launcher.luau:822) and is explicitly called out as an external dependency in the PR description, but dependencies = ["pass", "find", "grep", "pass-otp", "wtype"] omits it. env (launcher-pass/launcher.luau:150, prepended to every pass invocation) and head (launcher-pass/launcher.luau:266, launcher-pass/launcher.luau:274, pipe caps) are also spawned directly and undeclared. All three ship with coreutils on any Linux system, so there is no practical runtime impact; the manifest list simply does not match the PR description or the README "Spawned processes" section (which does list sleep). Suggest adding sleep (and optionally noting that coreutils is assumed for env/head) so the declared dependencies match every spawned binary.

  2. non-blocking - launcher-pass/plugin.toml:65
    The pinentryGraceMs manifest default is 50 ms, but the README (launcher-pass/README.md:80) and both translation strings (launcher-pass/translations/en.json:52, launcher-pass/translations/it.json:52) document the default as 200 ms. The 200 ms figure matches only the in-code fallback constant (launcher-pass/launcher.luau:763) used when the setting is missing or invalid; the shipped default is 50 ms. Users reading the docs will see the wrong default.

  3. non-blocking - launcher-pass/launcher.luau:826
    Type actions pass the secret to wtype as a command-line argument ({ "wtype", "-d", <ms>, "--", value }), so passwords, OTP codes, and field values are visible in the process list for the duration of the typing run (sub-second at default delays). The -- separator correctly prevents option injection and no shell is involved, but the previous QML version fed the value over a pipe and wtype supports a stdin mode, which would avoid argv exposure. Hardening note in a single-user desktop threat model, not a demonstrated exploit path.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants