Tools: Building Clipman — a clipboard manager for Wayland that respects you (2026)

Tools: Building Clipman — a clipboard manager for Wayland that respects you (2026)

The pain point

Background, in seven short sections

1. Wayland vs X11

2. What D-Bus is

3. What a GNOME Shell extension is

4. SemVer for an app

5. PyPI, Snap, AUR, .deb/.rpm

6. OIDC trusted publishing for PyPI

7. SHA-pinning GitHub Actions

The architecture

Why our own extension instead of wl-paste --watch

The D-Bus contract

What lives on disk

Privacy & security choices

Distribution as a problem in itself

CI/CD as a security surface, not just plumbing

Real bugs that shipped (and what they taught me)

Versioning and deprecation

Documentation as a first-class artifact

What's next

Reflection / lessons I copy things all day. A line from a terminal into a doc, a token from a doc into a terminal, an OTP from an authenticator into a browser, a URL from chat into a code comment. On Windows the muscle memory is Win+V: a small panel pops up with the last few things I copied and I pick one. On Linux there isn't a built-in equivalent. There are tools, but the ones I tried either flicker the screen, miss copies, leak passwords into a long-lived history file, or stop working the moment a Wayland session starts. So I built one. It's called Clipman, it's on PyPI as clipman-clipboard, on the Snap Store, on the AUR, and on the GNOME Extensions website. It works on Ubuntu 22.04 and up with GNOME 46–48 on Wayland. The source is at MohammedEl-sayedAhmed/clipman. This writeup is the story of the parts that took the longest to get right: how a clipboard manager can even work under Wayland's security model, the GNOME Shell extension that does the actual listening, the privacy choices, the five-channel distribution sprawl, and the CI/CD harness underneath it. It's also the writeup I want to read a year from now when I've forgotten why each piece is there. Linux does not ship with a clipboard manager out of the box. There's the clipboard — the thing the kernel and your compositor implement so Ctrl+C in one window and Ctrl+V in another do the right thing — but there is no history, no panel, no pinned entries, no search. If you copy something and then copy something else, the first thing is gone. The Windows Win+V panel that does keep history is a desktop-environment feature, not a kernel one, and Linux desktop environments historically delegated it to third-party utilities like Clipit, copyq, gpaste, or clipman (an older tool that this project is unrelated to). Three things broke that historical answer: Wayland's security model. Under X11, any client could read any other client's clipboard at will — the protocol exposed selections globally and the trust model assumed every connected client was friendly. Under Wayland, the compositor mediates clipboard access, and the protocol only hands clipboard contents to the application that is currently focused. That is a deliberate, named improvement: keylogging, screen scraping, and clipboard snooping by random apps are all blocked at the protocol level rather than by social convention (wayland-devel: passive and active attacks via X11). GNOME's default keybinding for Super+V opens the notification message tray, not a clipboard. Most users have never heard of Super+V because nothing useful happens when they press it. Older clipboard managers' implementation strategies don't survive Wayland. Polling wl-paste in a tight loop wastes power, flickers focus on some compositors, and races against legitimate paste targets. Subscribing to X11 selection events via xclip or XFixes is a non-starter; there is no equivalent X11 selection bus under Wayland for a non-privileged client to observe. Existing Wayland-aware tools (wl-clipboard's wl-paste --watch, clipman-wayland, copyq's Wayland mode) are a real improvement, but each compromises somewhere — extra processes, focus stealing, flicker, missed copies in XWayland apps, or none of the privacy posture I wanted (auto-clear of sensitive content, restrictive permissions, no telemetry of any kind). I wanted a tool that was Wayland-first, didn't flicker, didn't poll, didn't ship its own browser-class runtime — no Electron (the framework that bundles a private copy of Chromium with each app — that's what makes VSCode, Slack, and Discord large) — and treated the data on disk like it might be sensitive, because if you copy passwords twice a day for a year, your history file is sensitive. A short tour of the pieces the rest of this post relies on. If you already know D-Bus, Wayland, GNOME Shell extensions, and SemVer, skip ahead to The architecture. X11 is the historical Linux display protocol. It dates back to 1984: every graphical application connects to a long-running X server, and the server brokers input, drawing, and clipboard selections between clients. The trust model is simple — everyone connected to the X server is assumed to be a friend. Concretely, that means any X client can read any other client's keystrokes, scrape its window contents, or inspect its clipboard, with no permission check involved. That assumption made sense in academia, where the people sharing a session knew each other; it stopped making sense the moment desktop Linux started running browsers, untrusted GUIs, and proprietary apps side by side. Wayland, designed in 2008 and gradually deployed across distros since, replaces the X server with a single compositor process. The same program that draws your desktop is also the only thing applications can talk to. Apps no longer share a bus with each other. Reading another application's input, window pixels, or clipboard now requires an explicit grant from the compositor, usually tied to user focus or a user gesture (Wayland vs X11 comparison). For a clipboard manager, that's both the win and the problem. The win is that nobody can quietly siphon what's on your clipboard. The problem is that a clipboard manager's entire job is to read the clipboard, all the time. So it can't be just any app sitting on the side — it has to be the compositor itself, or something the compositor explicitly trusts. D-Bus is the local message bus that freedesktop.org defined for desktop programs to talk to each other (D-Bus specification). It's used everywhere on Linux: GNOME Shell, NetworkManager, the screenshot tool, the notifications service, and password managers all expose D-Bus interfaces. A program owns a bus name (something like org.gnome.Shell), exports objects at well-known paths under that name, and any other program can call methods on those objects. The signatures are typed, the calls are synchronous, and the whole thing acts as a local RPC layer for the desktop. There are two buses on every Linux system. The system bus is for OS-level services that all users share — NetworkManager, systemd-logind, udisks. The session bus is per logged-in user, and is where every desktop app lives — GNOME Shell, the notifications service, the screenshot tool, your password manager. Clipman lives entirely on the session bus. Nothing it does crosses to the system bus or requires root, which keeps the blast radius small. GNOME Shell is the program that draws the top bar, the activities overview, and the workspace switcher. Under Wayland it is also the compositor for the entire GNOME session — the privileged process from the previous section — through an underlying C library called Mutter that handles the window management, the Wayland protocol implementation, and the input plumbing. Mutter does the low-level work; GNOME Shell adds the user interface on top, written in JavaScript on a runtime called gjs: essentially SpiderMonkey (the JavaScript engine from Firefox) wired up to GObject, so the JavaScript can call native GNOME libraries directly. A GNOME Shell extension is a JavaScript module that gets loaded into that runtime at session startup (GJS extension guide). Because it runs inside the Shell, it has the same reach the Shell does. It can listen for window-manager events, synthesize keystrokes through Clutter's virtual input devices, observe clipboard selections, and own D-Bus names. That reach is also why installing or upgrading an extension prompts you to log out and back in: there's no graceful way for a running Shell to swap out JavaScript modules it has already loaded. This matters in two ways. First, an extension is the natural home for "watch the global clipboard" — it's exactly the kind of code the compositor already trusts. Second, extensions are emphatically not browser extensions; the right mental model is closer to "a kernel module for your desktop" than to "userscript". A misbehaving extension can do real damage, which is why GNOME's extensions website reviews each one manually before publishing (more on that later). Semantic Versioning 2.0.0 numbers releases as MAJOR.MINOR.PATCH: MAJOR for incompatible changes, MINOR for backward-compatible additions, PATCH for bug fixes. For a library that's a clean specification — "incompatible" means "the API other code imports". Clipman is an end-user application; nobody is supposed to import it as a library, so the equivalent surface isn't a Python module. What it has instead is contracts external to its own code: ADR 0010 writes those out as the contracts SemVer covers for clipman specifically. The point is so AUR maintainers and distro packagers can tell from a tag alone whether a release needs a rebuild against new system libraries, a one-shot schema migration, or a new extension version. Unlike most operating systems, Linux doesn't have one app store. It has several, each with its own audience, mental model, and trade-offs. Clipman ships through four of them: No single channel reaches everyone — Arch users don't pip install, PyPI users don't keep snapd running, Fedora users want an rpm -i. The release pipeline builds and ships through all four on every tag. The conventional way to publish a Python package from CI is to mint a long-lived PyPI API token, paste it into a GitHub repository secret, and use it from a workflow's upload step. That works, and it's still how most projects do it. But the token sits in secrets storage until you manually rotate it, any workflow that requests secrets: access can read it, and if the repository's secrets ever leak — through a compromised dependency in a workflow, an accidental log dump, anything — an attacker can publish to PyPI as you until you notice and revoke. PyPI's trusted publishing replaces that long-lived token with a short-lived OIDC token minted on the fly per upload (PyPI Trusted Publishers docs). The setup is one-time and out of band: in the PyPI project's publishing settings, you tell PyPI to trust GitHub's OpenID Connect issuer — but only for a specific repository plus workflow file plus deployment environment. At publish time the workflow asks GitHub for a fresh OIDC token (valid for minutes), hands it to PyPI, and PyPI verifies the claims embedded in it — the repo, the workflow, the environment — match the configured policy before accepting the upload. The end state: there is no long-lived PyPI credential in this repo or in GitHub Secrets at all. A repository-wide secrets leak cannot publish to PyPI; an attacker would have to compromise GitHub's OIDC infrastructure itself. Clipman publishes this way per ADR 0004. A workflow line like uses: actions/checkout@v4 looks up the v4 tag every time the workflow runs — GitHub resolves it against whatever commit the upstream maintainer has the tag pointing at right now, and executes that commit's code. If the upstream maintainer's account is compromised and someone force-pushes the tag to a malicious commit, the next time your workflow runs it will execute the attacker's code, with whatever access (secrets, OIDC tokens, write permissions) the workflow has been granted. This is not theoretical. In 2025 it happened to tj-actions/changed-files: every workflow that referenced the action by tag instead of a SHA exfiltrated its caller's secrets to public build logs the next time it ran (incident writeup). The mitigation is to pin every third-party action to its full 40-character commit SHA, like uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7. A commit SHA is content-addressed — it's derived from the commit's contents, so an attacker can't "move" it the way they can move a tag. Forging a new commit with the same SHA would require a SHA-1 collision. The cost is staleness: SHAs do not move, so upstream security fixes do not reach the workflow until Dependabot opens a bump PR and someone reviews it. Clipman pins every action this way per ADR 0003. Clipman is two cooperating processes plus a database. There is a daemon — a long-running background process with no UI of its own (clipman.py plus the clipman/ Python package) — that runs as a systemd --user service and owns the popup window, the storage, the settings, and the D-Bus surface. There is a GNOME Shell extension (extension/extension.js) that lives inside the running Shell process and watches the clipboard. They talk over D-Bus on the session bus. The first answer I tried was wl-paste --watch from the wl-clipboard project. It's a small CLI that exits when the clipboard changes and lets you run a script per change. That works, until it doesn't: The better answer is to listen inside the compositor. Mutter (the GNOME compositor) exposes a Meta.Selection object with an owner-changed signal that fires every time the clipboard owner changes — that is, every time something is copied (Meta.Selection reference). A GNOME Shell extension can subscribe to that signal directly: When the signal fires the extension reads the new content. A MIME type is the label an application attaches to a piece of clipboard data so other apps know how to interpret it — text/plain;charset=utf-8 is "UTF-8 text", image/png is "a PNG image", and so on. The catch is that different apps name the same UTF-8 text under different labels for historical reasons: GTK apps prefer text/plain;charset=utf-8, X11-era apps prefer UTF8_STRING, and a few hold-outs only set the bare STRING. So the extension tries them in priority order, falling through to the next on each miss: Whichever label produces non-empty bytes wins, and the result is forwarded to the daemon over D-Bus. Full file: extension/extension.js. There is also a 150 ms debounce on the read. Some apps update the clipboard several times in rapid succession during a single Ctrl+C (Electron apps are repeat offenders), and reading too eagerly returns an empty or stale buffer. Waiting 150 ms before reading lets the new owner settle. For compositors that don't run this extension — KDE Plasma, Sway, Hyprland, and the rest of the non-GNOME desktops on Linux — the daemon still works: on startup it checks for the extension's D-Bus name (org.gnome.Shell.Extensions.clipman) and, if it isn't present, spawns wl-paste --watch echo CLIP_CHANGED as a fallback. The fallback is in clipman/clipboard_monitor.py; it parses sentinel lines off the subprocess's stdout via GLib.io_add_watch, restarts up to five times on crash, and otherwise stays out of the way. The extension is preferred where it's available; the fallback is the consolation prize. The daemon's interface lives at bus name com.clipman.Daemon, object path /com/clipman/Daemon, interface com.clipman.Daemon. The full surface is six methods: D-Bus types are written compactly: each letter in the signature names one argument's type. s is a string. (ss) means "two strings in"; () on the right means "nothing comes back". So NewEntry takes a content-type string ("text" or "image") plus the actual content, and returns nothing. The implementation lives in clipman/dbus_service.py and does nothing except marshal between D-Bus and the GTK window / monitor. The extension exposes a complementary interface at org.gnome.Shell.Extensions.clipman. The daemon calls into it to ask the Shell — which has the privileged Clutter virtual-keyboard device, and the daemon does not — to synthesise the paste keystroke after the user clicks a history entry: The s mode argument is new. The earlier shape was SimulatePaste() with no argument and Ctrl+V hard-coded; users wanted to choose between Ctrl+V, Ctrl+Shift+V, and Shift+Insert (the X11 terminal convention). Rather than add a method per mode, I added one string argument and made the daemon retry against the old no-arg signature on DBusException, so a freshly upgraded daemon paired with an unupgraded extension still pastes correctly. The full reasoning is in ADR 0005, and the extension's metadata.json bumped from version: 4 to version: 5 to mark the D-Bus contract change for downstream consumers. Both interfaces are unauthenticated. Access is gated by the user's session bus, which is the same trust boundary as GNOME Shell itself — any process running as the same UID can already call into the Shell, and adding our own authentication layer would be theatre. The full reference, with worked gdbus call examples per method, is in docs/dbus-api.md. Everything is under ~/.local/share/clipman/: The schema is plain: an entries table (id, content_type, content_text, image_path, content_hash UNIQUE, pinned, created_at, accessed_at, sensitive), a snippets table for user-defined named snippets, and a settings table of typed key/value pairs. Deduplication is content-addressed via SHA256. If you copy the same string twice, the second insert collides on the unique hash and bumps accessed_at instead of duplicating. The query that builds the history view orders by accessed_at DESC, so re-copying an entry brings it to the top without bloating the table — small thing, but it means a user who reflexively re-copies the same lines all day doesn't watch their history get drowned. The premise — this app stores a record of everything you copy — makes its privacy choices the most consequential thing about it. I want to be able to tell a friend "yes, install this, it's fine" without crossing my fingers. The data directory is 0o700. Image files are 0o600. Those are Unix permission modes written in octal: three digits for owner / group / others, each digit a bitmask of read (4), write (2), execute (1). 0o700 means "owner has full access; nobody else can even list the directory". 0o600 means "owner can read and write the file; nobody else can do anything with it". The daemon applies these with chmod (the Unix call to change a file's permissions) on every startup, even if the directory pre-existed, so a relaxed umask cannot quietly widen them. New image files are created with os.open(..., O_CREAT, 0o600) rather than the default open(), because that's the only way to set the mode atomically — opening with the default and chmod-ing after leaves a brief window where the file exists with the user's umask mode. Sensitive entries auto-clear from the system clipboard 30 seconds after copy. Detection lives in clipman/clipboard_monitor.py and is a deliberately blunt regex-style match — it errs on the side of flagging more things as sensitive, not fewer. The triggers include: A flagged entry gets stored with sensitive = 1, hidden from the searchable history, and the daemon's delete_expired_sensitive job removes it from the database 30 seconds after capture. Incognito mode disables capture entirely with a toggle in the status bar. There is no shell=True anywhere. Every subprocess invocation in the codebase uses an argument list, so a path with quotes or a string with newlines or anything else weird can't reshape the command. All SQL is parameterised. Backup imports — a feature most apps don't think to harden — reject SQLite URIs that try file: injection tricks, reject databases that contain triggers or views (which can execute on read), and validate image magic bytes on every imported attachment. The update check is the one network thing the daemon does. With the setting enabled, once every 24 hours the daemon's update-check thread issues one anonymous GET https://api.github.com/repos/MohammedEl-sayedAhmed/clipman/releases/latest with User-Agent: clipman/<version> and a 5-second timeout. No body, no query parameters, no cookies, no identifiers, no referer. It reads tag_name out of the JSON, compares it to clipman.__version__, and stores the result in the same SQLite settings table that holds the rest of your preferences. The full posture from ADR 0007: No telemetry. The check must not send any user data, identifiers, cookies, or anything beyond what an anonymous web visitor would fetch.

No auto-update. We notify and link; we don't download or install.

Opt-out friendly. Snap users in particular don't need this — the Snap Store already refreshes installed snaps — so it should default off there. That is the only egress. There is no analytics, no crash reporter, no telemetry pixel. The full assets/adversaries breakdown — what Clipman defends against, and what is intentionally out of scope (cold-boot forensics, kernel keyloggers as the same UID) — is in docs/threat-model.md. Linux package distribution does not have a single answer. It has at least five. PyPI (pip install clipman-clipboard) is the most direct: the daemon is a Python application, so a wheel is the most natural artifact. It needs four system packages that pip cannot install (python3-gi, python3-dbus, gir1.2-gtk-3.0, wl-clipboard), so the README has a copy-pasteable apt line above it. PyPI installs default to update-checking ON; the user installed by name and is responsible for upgrades. Snap (sudo snap install clipman) is the most user-friendly: one command, the snap is signed, and the Snap Store auto-refreshes installed snaps four times a day by default (Snapcraft: Manage updates). The catch is confinement: strict confinement blocks the snap's processes from talking to anything outside the sandbox, including the GNOME Shell extension running in the host session. Solution: the snap ships only the daemon; the user installs the extension separately from the GNOME Extensions website. The two halves still meet on the host session bus, which is allowed across the snap boundary by the desktop plug. Snap installs default the update check OFF — the store is already pushing updates, no need to double up. AUR (yay -S clipman-clipboard) is for Arch users. The AUR is a community-driven recipe repository — the published artifact is a PKGBUILD that builds the package from source on the user's machine, not a binary (ArchWiki: Arch User Repository). Updating an AUR package means pushing a new commit to the AUR-side git repo, which the release workflow does automatically via SSH after every tagged release. .deb and .rpm are produced by the release workflow using fpm and attached to the GitHub Release page. They install the Python module, /usr/bin/clipman, the .desktop file and the icon system-wide, but they do not install the per-user GNOME Shell extension or the Super+V keybinding — those are user-scoped and stay out of system packages. A .deb user runs ./install.sh once after install to finish the per-user setup. This is documented in the README. The GNOME Extensions website (extensions.gnome.org) hosts the extension zip. EGO has its own review pipeline: an automated linter called Shexli flags patterns that need human attention before the extension is published. The first time I uploaded the extension, Shexli flagged it with EGO-A-005 (manual_review): direct clipboard access via St.Clipboard.get_default() requires reviewer scrutiny — which is correct, that is exactly what the extension does, and the human reviewer waved it through after reading the source. Every clipboard-related extension on EGO triggers the same finding; it's a "make sure a reviewer looks at this" gate, not a rejection. No single channel is sufficient. PyPI users don't install snaps; Arch users don't pip install; Snap users want a one-click install; Fedora users want an rpm -i. The build matrix is the cost of being installable. The other place a clipboard manager can fail its users is supply chain. If my GitHub credentials are compromised and an attacker pushes a release, every PyPI/Snap/AUR auto-refreshing install runs whatever they shipped. The CI/CD harness is the thing that has to make that hard, and the full per-workflow inventory and DAG is at docs/ci-cd.md. The decisions worth talking about here: SHA-pinning every action. Every uses: line in .github/workflows/ points to a 40-character commit SHA, with a trailing # v1.2.3 comment so a human can read it. Dependabot opens weekly PRs to bump the pins, and reviewing one means checking that the new SHA actually corresponds to the version in the comment. The reasoning is recorded in ADR 0003; the recent industry context is in StepSecurity's writeup of the tj-actions/changed-files compromise, where workflows that used @v44 instead of a SHA executed an attacker's code and printed all their secrets to the build log. Annotated-tag SHA vs commit SHA. This one bit me. Git tags can be either lightweight (a pointer directly to a commit) or annotated (a tag object with metadata that points to a commit). git rev-parse v1.2.3 on an annotated tag returns the SHA of the tag object, not the SHA of the commit. Most actions are happy to be referenced by either, but Docker-based actions — including pypa/gh-action-pypi-publish — resolve the SHA through their container registry, which only knows about commit SHAs. In v1.0.5 the PyPI publish job had been pinned to the tag-object SHA and failed with Unable to find image. The fix was to switch the pin to the commit SHA returned by git rev-parse v1.14.0^{commit}. The same error mode bit OpenSSF's Scorecard action in PR #22. The lesson: pin to commits, verify with ^{commit}, and Docker-based actions are the trip wire that surfaces the mistake. CodeQL baseline ratchet. CodeQL's security-and-quality query suite surfaces roughly eighteen pre-existing informational findings on main (best-effort except: pass blocks, cyclic imports, module-level prints) that are intentional and not defects. Out of the box those findings appear as annotations on every PR's Files changed tab, including PRs that don't touch the affected files, which trains reviewers to ignore the annotations entirely. The mitigation is a baseline ratchet: keep a fingerprint list of findings that exist on main on a dedicated orphan branch security-baseline, and fail a PR only if it introduces a fingerprint not in that list. New regressions block; pre-existing noise doesn't. The security-baseline branch is auto-refreshed on push to main and protected against manual tampering by a baseline-guard workflow that auto-reverts unauthorised pushes and opens a labelled security issue. Recorded in ADR 0002 and refined by ADR 0008 (which switched the fingerprint format from rule:file:line to SARIF partialFingerprints.primaryLocationLineHash, so unrelated line-shifts above an existing finding don't read as new findings). Step-Security harden-runner is the first step on every job, with egress-policy: audit. In audit mode the action installs eBPF hooks at the kernel level that log every outbound network connection from the runner (StepSecurity docs) without blocking anything. The audit log is the forensic trail if something does slip through. "Block" mode would refuse unknown egress entirely, which is the eventual goal, but enabling block requires an allow-list and the allow-list for a build that fans out to PyPI, Snap, AUR, .deb, and .rpm is large enough that I haven't audited it yet. OIDC trusted publishing for PyPI (ADR 0004). There is no long-lived PyPI API token in this repo or in GitHub Secrets; PyPI accepts a per-job OIDC token minted by GitHub at publish time, scoped to the specific repository, workflow, environment, and job. A repo-wide secrets leak cannot push to PyPI; an attacker would have to compromise the GitHub OIDC infrastructure itself, or rename the workflow file to match the trusted-publisher configuration. The trade-off is one manual setup step at the PyPI publishing settings page per project, which is unavoidable but only happens once. The full release pipeline DAG, the secrets matrix, the harden-runner audit semantics, the SHA-pinning policy, and the debug playbook live in docs/ci-cd.md. A list, in order of how surprised I was. libfuse2 → libfuse2t64 (PR #33). The release pipeline includes an AppImage build, which depends on libfuse2 at run time and at build time. Ubuntu 24.04 — the runner image we use — renamed libfuse2 to libfuse2t64 as part of the 64-bit time_t transition, so the workflow's apt install libfuse2 started failing with "no installation candidate" on the runner roll-forward. The fix is apt install libfuse2t64 || apt install libfuse2, fallback chain on the rename. Lesson: even a fully SHA-pinned action stack is not insulated from the runner image changing under it; the runner image has its own roll-forward calendar. Metadata-Version 2.4 vs older twine (PR #36). Twine is the canonical tool for uploading Python packages to PyPI. The wheel we build for 1.0.5 declared Metadata-Version: 2.4 because the modern build backend supports the new license fields, but the version of pypa/gh-action-pypi-publish we had pinned (v1.12.2) shipped a twine old enough that it rejected the wheel on upload. Bumping the action to v1.14.0 fixed it, but the pin had to be the commit SHA, which is the gotcha from the previous section. Lesson: action pins go stale; packaging metadata standards keep moving. Read the action's release notes when Dependabot bumps it. Settings-panel "clicks do nothing" on Wayland (1.0.6). Several settings widgets — the Switch for incognito mode, the combo box for paste mode, the shortcut-capture dialog — used to silently swallow clicks on some Wayland compositors. The popup is a borderless GTK window that hides itself on focus-out-event (so clicking outside it closes it, like a real popover). But on some compositors, clicking the Switch widget briefly transfers keyboard focus to a transient surface for the click-handling, which fires focus-out on the parent window, which hides the popup, which means the click event never reaches the Switch at all. The fix is in clipman/window.py: treat "focus moved to a descendant of the popup itself" as not-really-a-focus-out and ignore it: Lesson: when a click "does nothing", suspect the focus model before suspecting the click handler. The SSH key compromise. Not a bug in Clipman's code — a bug in my workflow. The release pipeline has to push commits to the AUR over SSH, which means an SSH private key lives in a GitHub repository secret (AUR_SSH_PRIVATE_KEY). At one point I had that key file open in an editor with an AI assistant integration enabled; the assistant's "file picker" piped the file contents into a chat session, which logged them. I rotated immediately: generated a new dedicated ed25519 key (id_ed25519_aur, separated from my main identity so it can be revoked without affecting other access), registered the new public key on the AUR maintainer account, removed the compromised key from GitHub Actions secrets and from ~/.ssh/authorized_keys on AUR, and shred'd the local files. No release had been pushed using the compromised key — the rotation was precautionary — but the incident is the reason I now treat "open a private key in an editor with assistant access" as the same kind of mistake as "paste a secret into a chat window". Same outcome, different surface. SemVer is straightforward for libraries: the public API is the surface that matters. For an end-user application with no public Python API, what is the "public API"? For Clipman it's three things, written down in ADR 0010: A MAJOR bump (e.g. 2.0.0) means removing a D-Bus method, renaming a settings key without a backward-compatible shim, dropping a supported Python version, dropping a GNOME Shell version, relocating the data directory, or moving from GTK3 to GTK4. A MINOR means an additive change behind a try-with-arg / retry-without-arg fallback like the SimulatePaste(s mode) one (the precedent that established the pattern, ADR 0005). A PATCH means anything else. Internal Python modules like clipman.database or clipman.window are not a public API. They will change in any release, including patch releases, with no deprecation cycle. If you import clipman as a library, you're doing it at your own risk. The extension's metadata.json version integer is a separate concept from the product tag and exists for downstream consumers of the extension's D-Bus interface. It bumps only on D-Bus contract changes; that's why the extension is at version 5 while the product is at 1.0.6. The repo has ten ADRs covering every notable architecture decision; a top-level ARCHITECTURE.md; a GOVERNANCE.md that names the maintainer and the decision-making process; a maintaining.md with the release flow, branch hygiene, Dependabot triage and packaging notes; a ci-cd.md with the workflow inventory, release DAG and secrets matrix; a dbus-api.md with worked gdbus call examples per method; a threat-model.md; a translating.md for the gettext workflow; a Keep-a-Changelog CHANGELOG; plus CONTRIBUTING, SECURITY and a Contributor Covenant code of conduct. That is a lot of words for a one-person project. The honest reasons: The cost is more concentrated than it looks. One of the ADRs (0007, the update-check posture) genuinely shipped alongside the change it describes, in the same feature PR. Most of the others were written after the fact, in a couple of docs-sweep PRs in May. The bulk of the prose docs — ARCHITECTURE, GOVERNANCE, maintaining, threat-model, ci-cd, dbus-api, translating — landed in a single afternoon on 2026-05-22 as seven back-to-back PRs in about fifteen minutes of merge time. So it wasn't continuous discipline; it was one extended sitting where I forced myself to write down what I'd been carrying in my head, while it was still fresh. KDE support. KDE Plasma's clipboard is implemented by Klipper, which is conceptually similar to our extension/daemon split but uses a different KWayland protocol surface. The fallback path (wl-paste --watch) works on KDE today but loses some XWayland-app coverage that the GNOME extension provides natively. A small KWayland equivalent of the GNOME extension is the right answer; it's on the long-tail roadmap. Themes beyond Catppuccin. The current dark/light pair is Catppuccin Mocha / Latte. The CSS is a template with $variable placeholders so a third-party theme is a 30-line file, but I haven't documented how to write one yet. Image annotation. The clipboard already stores images; the popup lets you preview them; adding crop/annotate would let Clipman replace a screenshot-and-annotate workflow on the same keystroke. Privacy-preserving sync across machines. This is the hardest one. The whole privacy posture above relies on the data never leaving the machine. Adding sync without giving that up means end-to-end encryption with a key the user controls, which means key management, which means a UX I haven't designed yet. It is on the long list, not the short list. A few things stuck with me building this. The choice of where to listen is the whole architecture. Everything downstream of "subscribe to Meta.Selection's owner-changed inside the Shell process" is mechanical. Everything downstream of "poll wl-paste in a loop" is a permanent rearguard action against flicker and missed copies and battery drain. The five hours I spent reading Mutter's source to find Meta.Selection are responsible for half the apparent quality of this app. When something feels like it should be impossible on a given platform, the question "what's the privileged thing that can do this, and how do I become its client?" is worth a long time at the whiteboard. SemVer for an end-user app is a contract with downstreams, not users. Users mostly don't read your version number. AUR maintainers, snap rebuilders, distro packagers, translators — they read it constantly. Writing the policy down (ADR 0010) is a kindness to the people whose job it is to ship your code. SHA pins protect a future me that doesn't exist yet. It would have been faster to use @v4 everywhere and let GitHub re-resolve on every run. The cost of SHA-pinning is real (uglier diffs, more Dependabot PRs, the annotated-tag-SHA gotcha I hit in v1.0.5). The value is paid out in a single moment, if and only if an upstream maintainer's account gets compromised — and even one prevented incident pays for the entire cost. This is the canonical shape of a security investment, and it's a hard one to feel good about while you're doing it. The release pipeline is more of the product than I expected. Half the work of shipping 1.0.5 wasn't the new features — it was making the release reproducible across PyPI, Snap, AUR, .deb, .rpm, AppImage, and the extension bundle, in one tag push, without long-lived secrets, with a CHANGELOG section that's both human-readable and machine-extractable. None of that is visible to a user. All of it is the difference between "I can ship a security fix today" and "I can ship a security fix in a week if I clear my evening". The 1.0.5 / 1.0.6 split is exactly an instance of this: the user-visible change set is identical, but the pipeline was wrong, and a separate patch had to ship to fix the pipeline before the features could actually reach PyPI. The privacy posture matters more than the feature list. When I show this to a friend, the thing they remember a week later isn't the search, or the pinning, or the snippets. It's "oh, the one that doesn't send my passwords anywhere". That is the brand of the project, and it is the brand because of choices like 0o700 on the data dir, the sensitive-content auto-clear, the one documented network egress, and the audit trail of decisions in docs/adr/. The features get you tried; the posture gets you kept. Templates let you quickly answer FAQs or store snippets for re-use. Hide child comments as well For further actions, you may consider blocking this person and/or reporting abuse

Code Block

Copy

// extension/extension.js — enable() this._selection = global.display.get_selection(); this._ownerChangedId = this._selection.connect( 'owner-changed', this._onOwnerChanged.bind(this) ); // extension/extension.js — enable() this._selection = global.display.get_selection(); this._ownerChangedId = this._selection.connect( 'owner-changed', this._onOwnerChanged.bind(this) ); // extension/extension.js — enable() this._selection = global.display.get_selection(); this._ownerChangedId = this._selection.connect( 'owner-changed', this._onOwnerChanged.bind(this) ); // extension/extension.js const mimeTypes = [ 'text/plain;charset=utf-8', 'UTF8_STRING', 'text/plain', 'STRING', ]; // extension/extension.js const mimeTypes = [ 'text/plain;charset=utf-8', 'UTF8_STRING', 'text/plain', 'STRING', ]; // extension/extension.js const mimeTypes = [ 'text/plain;charset=utf-8', 'UTF8_STRING', 'text/plain', 'STRING', ]; SimulatePaste(s mode) → () /* mode ∈ {auto, ctrl-v, ctrl-shift-v, shift-insert} */ MoveWindowToCursor(s title) → () SimulatePaste(s mode) → () /* mode ∈ {auto, ctrl-v, ctrl-shift-v, shift-insert} */ MoveWindowToCursor(s title) → () SimulatePaste(s mode) → () /* mode ∈ {auto, ctrl-v, ctrl-shift-v, shift-insert} */ MoveWindowToCursor(s title) → () def _on_focus_out(self, widget, event): if self._ignore_focus_out: return False # On some Wayland compositors, clicking certain interactive # widgets inside the popup (notably Gtk.Switch and combo-box # popovers) briefly transfers keyboard focus to a transient # surface, which sends a focus-out to the parent window. If # we treat that as "the popup lost focus to another window" # and hide ourselves, the original click event never reaches # the widget — the user perceives the entire settings panel # as unresponsive. Guard: only hide when the new focus owner # is genuinely outside the popup tree. try: new_focus = self.get_focus() except Exception: new_focus = None if new_focus is not None and new_focus.is_ancestor(self): return False self.hide() return False def _on_focus_out(self, widget, event): if self._ignore_focus_out: return False # On some Wayland compositors, clicking certain interactive # widgets inside the popup (notably Gtk.Switch and combo-box # popovers) briefly transfers keyboard focus to a transient # surface, which sends a focus-out to the parent window. If # we treat that as "the popup lost focus to another window" # and hide ourselves, the original click event never reaches # the widget — the user perceives the entire settings panel # as unresponsive. Guard: only hide when the new focus owner # is genuinely outside the popup tree. try: new_focus = self.get_focus() except Exception: new_focus = None if new_focus is not None and new_focus.is_ancestor(self): return False self.hide() return False def _on_focus_out(self, widget, event): if self._ignore_focus_out: return False # On some Wayland compositors, clicking certain interactive # widgets inside the popup (notably Gtk.Switch and combo-box # popovers) briefly transfers keyboard focus to a transient # surface, which sends a focus-out to the parent window. If # we treat that as "the popup lost focus to another window" # and hide ourselves, the original click event never reaches # the widget — the user perceives the entire settings panel # as unresponsive. Guard: only hide when the new focus owner # is genuinely outside the popup tree. try: new_focus = self.get_focus() except Exception: new_focus = None if new_focus is not None and new_focus.is_ancestor(self): return False self.hide() return False - Wayland deliberately does not let one app spy on another app's clipboard. Building a clipboard manager on top of that takes a privileged listener that the user has explicitly enabled — in our case, a small GNOME Shell extension that subscribes to Meta.Selection's owner-changed signal and forwards new entries over D-Bus to a Python daemon (a background process that runs continuously, with no UI of its own). - The daemon stores history in ~/.local/share/clipman/clipman.db — a SQLite database in WAL mode, so writers don't block readers — deduplicates by SHA256, and exposes a tiny D-Bus surface so the keybinding (Super+V) and the extension can both talk to it. - Privacy: incognito mode, regex-based sensitive-content detection with 30-second auto-clear, restrictive Unix file modes (0o700 on the data directory means "only the owning user may even open it"; 0o600 on image files means "only the owning user may read or write"), parameterised SQL, no shell=True, no telemetry. The only network egress is one anonymous GET per day to the GitHub Releases API to check for a newer version, and it is opt-out. - The project ships through five channels (PyPI, Snap, AUR, .deb, .rpm) plus the GNOME Extensions website, with a CI/CD harness that SHA-pins every third-party action, publishes to PyPI via OIDC trusted publishing instead of a long-lived token, and ratchets CodeQL findings so pre-existing noise can't drown out a new regression. - All of which is more work than the surface implies — and every paragraph below was a thing I had to actually figure out, not a thing I read about and copied. - Wayland's security model. Under X11, any client could read any other client's clipboard at will — the protocol exposed selections globally and the trust model assumed every connected client was friendly. Under Wayland, the compositor mediates clipboard access, and the protocol only hands clipboard contents to the application that is currently focused. That is a deliberate, named improvement: keylogging, screen scraping, and clipboard snooping by random apps are all blocked at the protocol level rather than by social convention (wayland-devel: passive and active attacks via X11). - GNOME's default keybinding for Super+V opens the notification message tray, not a clipboard. Most users have never heard of Super+V because nothing useful happens when they press it. - Older clipboard managers' implementation strategies don't survive Wayland. Polling wl-paste in a tight loop wastes power, flickers focus on some compositors, and races against legitimate paste targets. Subscribing to X11 selection events via xclip or XFixes is a non-starter; there is no equivalent X11 selection bus under Wayland for a non-privileged client to observe. - The D-Bus methods other processes call. - The SQLite schema sitting in the user's home directory, which future versions of the daemon and any external tooling have to read. - The supported range of Python and GNOME Shell versions that downstream packagers build against. - PyPI is the Python language package index. pip install clipman-clipboard resolves a wheel and drops it into a Python environment. Native to anyone who already has pip; useless to anyone who doesn't. - Snap is Canonical's application store. It publishes a single signed bundle that runs under strict confinement on the user's machine, and the Snap Store auto-refreshes installed snaps in the background — no user action required. - AUR is the Arch User Repository. It does not host binaries. It hosts PKGBUILD build scripts that helpers like yay or paru execute locally to compile the package from upstream sources, then install via pacman. - .deb and .rpm are the native package formats for Debian/Ubuntu and Fedora/RHEL respectively. Installed with apt install ./clipman_*.deb or dnf install ./clipman-*.rpm, they drop files into system paths under /usr/. - It's a subprocess. On every clipboard change, it has to be re-invoked or kept resident; either way, the daemon process tree grows. - On some GNOME versions it briefly steals focus from the foreground app, producing a visible flicker. - It cannot observe clipboard changes inside XWayland-hosted apps (VSCode, Electron) reliably. - It is the answer for compositors that don't have a clipboard extension; it shouldn't be the primary path on GNOME. - clipman.db — SQLite with WAL (write-ahead logging) journaling on. In WAL mode, SQLite writes new data to a side file first and merges it into the main database later, which means readers don't block writers and vice versa. That matters here because the popup window reads history rows on every refresh while the daemon is writing new entries arriving from D-Bus callbacks; without WAL the two would serialise. - images/ — image clipboard payloads written one file per content hash. The schema is <hash>.<ext>; the daemon validates magic bytes (PNG, JPEG, GIF, BMP, WebP) before saving. - Known token prefixes: ghp_, gho_, ghs_, github_pat_, sk-, sk_live_, pk_live_, eyJ (JWT), xox (Slack), AKIA (AWS access keys), AIza (Google API keys), npm_, -----BEGIN (PEM blocks), and Bearer (HTTP Authorization-header tokens). - Database connection strings: postgresql://, mysql://, mongodb://, redis://. - SSH public-key prefixes: ssh-rsa, ssh-ed25519. - A heuristic for "looks like a password": single-line, 8–128 chars, no whitespace, contains three of {lowercase, uppercase, digit, punctuation}. - The two D-Bus interfaces — com.clipman.Daemon and org.gnome.Shell.Extensions.clipman — and their method signatures. - The SQLite schema at ~/.local/share/clipman/clipman.db, including the settings table key names. - The supported-versions matrix — Python 3.10–3.12, GNOME Shell 45–48, Ubuntu 22.04+ — and the GTK3 toolkit choice. - I will forget. A year from now I will not remember why metadata.json is at version: 5 while the product is at 1.0.6. The ADRs are the only way to find out without re-litigating the decision. - Downstream packagers and translators need a place that isn't "open an issue and ask". docs/translating.md is the difference between a translator submitting a PR and a translator giving up; docs/maintaining.md is what lets someone else cut a release without me on a call. - The CI harness is genuinely complex. If I am the only person who knows what baseline-guard.yml is for, the harness only works as long as my memory does, which is short. - Writing decisions down forces me to reread them. Half the time, writing the ADR is when I notice the decision was wrong. - Repository: https://github.com/MohammedEl-sayedAhmed/clipman - PyPI: https://pypi.org/project/clipman-clipboard/ - GNOME Extensions: https://extensions.gnome.org/extension/9407/clipman-clipboard-monitor/ - AUR: https://aur.archlinux.org/packages/clipman-clipboard - Snap Store: https://snapcraft.io/clipman - Architecture decisions: https://github.com/MohammedEl-sayedAhmed/clipman/tree/main/docs/adr - D-Bus reference: https://github.com/MohammedEl-sayedAhmed/clipman/blob/main/docs/dbus-api.md - Threat model: https://github.com/MohammedEl-sayedAhmed/clipman/blob/main/docs/threat-model.md - CI/CD inventory: https://github.com/MohammedEl-sayedAhmed/clipman/blob/main/docs/ci-cd.md