Imported from RonnyPfannschmidt/gtk-pass-ng (
AGENTS.md). Install upstream withnpx skills add RonnyPfannschmidt/gtk-pass-ng. Copyright stays with the author.
Working on GTKPass
GTKPass is a GTK4/Libadwaita frontend over pluggable password backends, not a
password manager of its own. Backends are discovered through the
gtkpass.backends entry point group.
Never read real passwords
The single rule that matters most. Development code, tests, probes and one-off
scripts must never open ~/.password-store or the user's keyring. Whatever they
print lands in a terminal, a CI log, or an AI assistant's transcript, and a
decrypted password cannot be un-disclosed.
- The backends refuse the real store, and the keyring, whenever the code is
running out of a checkout — which is everything you will ever run here.
safety.running_from_checkout()asks the installed distribution: an editable install recordsdir_info.editablein itsdirect_url.json, and that is the only signal that means editable rather than resembling it. The metadata is believed only when it describes the module actually running, so a checkout ahead of an installed release onsys.pathis still a checkout. An installed build is allowed, because refusing there would only mean every package shipping a wrapper to undo it. - GTKPass has to be installed to run at all.
safety.require_installed()runs on import ofgtkpassand refuses a barePYTHONPATH=srcprocess, which has no metadata and so settles nothing about what is executing. A leftoversrc/gtkpass.egg-infodoes not count as an install either: it is a build artefact inside a source tree, it carries nodirect_url.json, and before it was excluded it made aPYTHONPATHrun look like a packaged one and opened the guard. Usemake sync. GTKPASS_ALLOW_REAL_STOREoverrides that in both directions.run_app.shsets it to 1, launching a checkout being the one case where the checkout really is the application. If you are reaching for that variable anywhere else, stop.make devstorecreates a throwaway store under.dev/with invented passwords and its own GPG key. Use it for manual testing and screenshots. It drops a.gtkpass-scratch-storemarker, which is what lets the guard open that store while still refusing everything else.make run-devlaunches against it with the guard still armed. It passesGTKPASS_ALLOW_REAL_STORE=0on purpose: it goes throughrun_app.sh, which opts in, and without turning that back off the development run would be the one thing running unguarded.- The test suite clears the variable in
conftest.py, so an exported value in your shell cannot re-enable it for a run.
The keyring is covered too, not just the file stores.
SecretServiceBackend.is_available() opens the user's default collection and
can prompt them to unlock it, so without the opt-in it reports unavailable
instead of probing, and create() refuses outright.
Never print a decrypted value. PasswordEntry.__repr__ is redacted on purpose:
the generated dataclass repr would have put plaintext into every log line,
traceback and pytest assertion diff that rendered one. Do not undo that, and do
not add a __str__ or a log line that defeats it.
Test first
Write the failing test, run it, watch it fail, then make it pass. Not "when appropriate" — the previous wording said that and it never once happened, which is why a syntax error and a backend that could not be instantiated both survived seven months in the tree.
The backend conformance suite in tests/test_backend_contract.py is the
definition of done for backend work.
UI lives in Blueprint
Widgets are declared in src/gtkpass/ui/blueprints/*.blp and loaded as
templates. Edit the .blp, run make ui, commit both files, and never hand-edit
a .ui — it is generated. A test parses every module and fails on widget
construction in Python; models such as Gio.ListStore are exempt.
That includes list and column view rows, which are declared as a
BuilderListItemFactory template rather than built in a factory callback — see
password_list.blp. Those bindings only run when a row is built, so a broken
one leaves the model correct and the view empty; at least one test has to
present the widget and read back what it rendered.
Other things worth knowing
- Import GI namespaces from
gtkpass._gi, which pins the versions once. Never callgi.require_versionanywhere else. - Nothing outside
backends/manager.pyimports a backend module directly. - The window loads its backends asynchronously, so a test that wants one has to
turn the main loop until it arrives --
loaded_windowandlisted_windowintests/test_window.pyare the two places that wait. Nothing slow may be added toGTKPassWindow.__init__: a constructor that runs git or talks to D-Bus is a window that does not appear. - What the manager hands out is a
SerializedBackend, not the backend itself. A method added to the backend contract has to be forwarded there too, or it will answer for the proxy instead of for the backend; the contract suite checks that. - Backends must never re-encrypt a store to a changed recipient set. Reporting
the change is
backends/recipients.py; acting on it ispass init, and doing it automatically would carry out the attack it exists to detect. - Application identity lives in
gtkpass/config.py. The D-Bus name, desktop file, icon, AppStream id and GSettings schema all have to stay the same string. Gio.Settings.new()on a missing schema callsg_error()and aborts the process without a traceback, so go throughconfig.get_settings().- Do not add dependencies without discussion. In particular not
keyring,GitPython,qrcode,pilloworopencv: an earlier version of this file prescribed all of them and none were ever used.pyotpis the one that has since become arguable, OTP being planned — argue it rather than assume it, because RFC 6238 overhmacandhashlibis a short function with no dependency at all. - An installed build must work with nothing set in its environment. There is no
launcher script in any package, so anything the application needs arranged, it
arranges itself — see
safety.running_from_checkout(),config.schema_source()andfrozen.configure_environment(), which is the same rule reaching Windows: a PyInstaller bundle carries its own GTK and its own compiled schema, on paths GLib has no reason to look at. - Not everything is Linux. The Windows build is a frozen bundle over a pinned
gvsbuild GTK stack (
packaging/windows/,docs/WINDOWS.md), and anything platform-specific added to a backend or topyproject.tomlneeds a marker or a guard rather than an assumption.secretstorageis the worked example.
Commands
make help lists them. make check runs lint, format and types via pre-commit;
make test runs the suite through scripts/headless-session.sh, which gives it
its own X server, its own D-Bus session and its own XDG_RUNTIME_DIR. All three
are deliberate. GDK ignores DISPLAY whenever WAYLAND_DISPLAY is set, and a
desktop session exports GDK_BACKEND=wayland itself, so xvfb alone put every
window on the real screen and let a clipboard test overwrite whatever the
developer had copied; conftest sets the same thing again for a bare pytest.
The runtime directory is the one that cannot move into conftest: a shared
XDG_RUNTIME_DIR means the test bus activates a second xdg-document-portal
over the real session's /run/user/$UID/doc, and tears that mount down on exit
— after which no flatpak on the machine launches, while the portal service still
reports itself healthy. Every headless call site goes through the wrapper, and
tests/test_headless_isolation.py fails if one stops; DEVELOPMENT.md has the
whole story.
make test runs against whatever PYTHON names, defaulting to this checkout's
environment; CI overrides it, so the jobs run this target rather than a copy of
it. make build && make test-wheel is the CI wheel job here. Which interpreter
make sync builds the environment against is worked out by
scripts/system-python.sh, which imports gi rather than assuming
/usr/bin/python3 — assuming it builds an environment that fails later, from
inside the application. make sync SYSTEM_PYTHON=... names one, and that is
checked too.
CI packages first and tests the packages: it builds the wheel, sdist and RPMs,
then runs this suite against each of them installed, over two Fedora releases.
It also freezes the Windows bundle out of that same wheel and checks it, built
and again installed; that job is non-blocking on purpose, and docs/WINDOWS.md
says why.
make test here runs against the editable install instead, which is faster and
what you want while working -- but it is not what CI checks, so a failure that
only appears once something is packaged will appear there and not here.
make sync installs the pre-commit hook (make hooks on its own if the
environment already exists), and that hook is what catches an unformatted commit
before it is made rather than after.
uv run outside the Makefile re-resolves the environment and tries to build
PyGObject and pycairo, which fails. Set UV_NO_SYNC=1, as the Makefile does.