<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Mohammed Elsayed Ammar</title>
    <description>The latest articles on DEV Community by Mohammed Elsayed Ammar (@mammar).</description>
    <link>https://dev.to/mammar</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3935281%2F9ca82280-924c-4d1a-b1e0-317fae226f0a.jpeg</url>
      <title>DEV Community: Mohammed Elsayed Ammar</title>
      <link>https://dev.to/mammar</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/mammar"/>
    <language>en</language>
    <item>
      <title>Why vdu_controls launched three times on KDE (a Qt single-instance guard)</title>
      <dc:creator>Mohammed Elsayed Ammar</dc:creator>
      <pubDate>Tue, 02 Jun 2026 11:30:47 +0000</pubDate>
      <link>https://dev.to/mammar/why-vducontrols-launched-three-times-on-kde-a-qt-single-instance-guard-lc8</link>
      <guid>https://dev.to/mammar/why-vducontrols-launched-three-times-on-kde-a-qt-single-instance-guard-lc8</guid>
      <description>&lt;p&gt;A few weeks ago I &lt;a href="https://dev.to/blog/vdu-controls-philips-evnia-brightness-slider-fix/"&gt;sent my first PR&lt;/a&gt; to &lt;code&gt;vdu_controls&lt;/code&gt;, a small Qt tray app for controlling external monitors over DDC/CI. That one was a parser bug. This is the second, and it started the same way every good bug does: I noticed something on my own machine that shouldn't be possible.&lt;/p&gt;

&lt;p&gt;I opened a terminal one morning, ran &lt;code&gt;ps&lt;/code&gt;, and found &lt;strong&gt;three&lt;/strong&gt; copies of &lt;code&gt;vdu_controls&lt;/code&gt; running at once.&lt;/p&gt;

&lt;p&gt;Not three windows. Three independent processes, each holding the same I2C bus, each ready to fight the others over my monitor's brightness register. The app was perfectly happy to be started any number of times — and on KDE, it turned out, the desktop was starting it any number of times.&lt;/p&gt;

&lt;p&gt;This is a writeup of how a missing six-line check becomes a triplicate process, how a Linux desktop actually launches your apps at login, and the difference between a guard that exits silently and one that does the polite thing.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;vdu_controls&lt;/code&gt; had no single-instance guard. On KDE, &lt;em&gt;two&lt;/em&gt; independent channels start it at every login — the XDG autostart entry and the session-restore manager — and clicking the app menu while it's hiding in the tray starts a third. Nothing deduplicated them, at either the app layer or the desktop layer.&lt;/p&gt;

&lt;p&gt;I added a &lt;code&gt;QLocalServer&lt;/code&gt;-based guard: the first instance listens on a per-user socket; a second launch connects, asks the first to surface its window, and exits. ~40 lines plus a headless test suite. &lt;a href="https://github.com/digitaltrails/vdu_controls/pull/130" rel="noopener noreferrer"&gt;digitaltrails/vdu_controls#130&lt;/a&gt;, merged. The interesting part isn't the patch — it's that the bug didn't live in any one file. It lived in the gaps between four systems that each behaved correctly on their own.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Desktop:&lt;/strong&gt; KDE Plasma on X11&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The tool:&lt;/strong&gt; &lt;code&gt;vdu_controls&lt;/code&gt; — a Qt tray app (the closest thing Linux has to Windows' Twinkle Tray) that reads/writes monitor controls via &lt;code&gt;ddcutil&lt;/code&gt;. Runs from a tray icon, usually started at login.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The symptom:&lt;/strong&gt; three live processes after a normal login + one menu click.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The symptom, in detail
&lt;/h2&gt;

&lt;p&gt;Here's the actual &lt;code&gt;ps&lt;/code&gt; output that started it (trimmed to the columns that matter):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;mohammed    4608    3718  python3 .../vdu_controls
mohammed    4780    4199  python3 .../vdu_controls -session 1013a135... -name vdu_controls
mohammed  353933    4336  python3 .../vdu_controls
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three processes. The middle one is the tell — it was launched with &lt;code&gt;-session &amp;lt;id&amp;gt; -name vdu_controls&lt;/code&gt;, which is the Qt session-management handshake. The other two have no such argument. So these weren't forks of one launcher; they came from &lt;em&gt;different&lt;/em&gt; launchers.&lt;/p&gt;

&lt;p&gt;The next column to look at is the parent PID, and that's where it got interesting. Walking each PPID up the tree:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;4608&lt;/code&gt; → parented to &lt;code&gt;systemd --user&lt;/code&gt; (the XDG autostart channel)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;4780&lt;/code&gt; → parented to &lt;code&gt;ksmserver&lt;/code&gt; (KDE's session-restore manager)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;353933&lt;/code&gt; → parented to &lt;code&gt;plasmashell&lt;/code&gt; (I'd clicked the app in the menu)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Three processes, three &lt;em&gt;different&lt;/em&gt; parents. This is the most important diagnostic in the whole story, and it's the same move as last time: &lt;strong&gt;find the boundary between what's working and what isn't.&lt;/strong&gt; Last time it was "the CLI tool reads brightness fine, so the bug is in the GUI's parser." This time the process tree said it plainly — the app wasn't spawning duplicates of itself. Three separate, legitimate launchers were each starting it cleanly, and none of them knew about the others.&lt;/p&gt;

&lt;p&gt;So the question stopped being "what's wrong with &lt;code&gt;vdu_controls&lt;/code&gt;?" and became two questions:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Why does KDE start the same app from more than one place at login?&lt;/li&gt;
&lt;li&gt;Why doesn't anything — the app, or the desktop — notice it's already running?&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Background: how a Linux desktop launches your apps
&lt;/h2&gt;

&lt;p&gt;If you've never looked at desktop autostart, the short version is that "start this app once when I log in" is not a single mechanism. It's several, and they don't coordinate. Skip ahead if this is familiar.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. The XDG autostart spec
&lt;/h3&gt;

&lt;p&gt;The cross-desktop standard. Drop a &lt;code&gt;.desktop&lt;/code&gt; file in &lt;code&gt;~/.config/autostart/&lt;/code&gt; and your session's autostart launcher runs it at login. On a modern systemd-based session that launcher is &lt;code&gt;systemd --user&lt;/code&gt;, which wraps each entry in a transient unit (mine showed up as &lt;code&gt;app-vdu_controls@autostart.service&lt;/code&gt;). This is channel one. &lt;code&gt;vdu_controls&lt;/code&gt; installs exactly such an entry when you tick "Start at login."&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Session restore
&lt;/h3&gt;

&lt;p&gt;Separately, KDE's session manager (&lt;code&gt;ksmserver&lt;/code&gt;) can save the set of running apps when you log out and restore them when you log back in. An app that speaks the X Session Management Protocol gets relaunched with &lt;code&gt;-session &amp;lt;id&amp;gt;&lt;/code&gt; so it can recover its prior state. Qt apps support this &lt;strong&gt;automatically&lt;/strong&gt; — you get it for free just by being a &lt;code&gt;QApplication&lt;/code&gt;. This is channel two, and it fires &lt;em&gt;independently&lt;/em&gt; of channel one.&lt;/p&gt;

&lt;p&gt;That's the crux: if you enabled autostart &lt;strong&gt;and&lt;/strong&gt; logged out with the app running, KDE faithfully restores it from saved session &lt;strong&gt;and&lt;/strong&gt; the autostart entry fires — because from each subsystem's point of view, it's doing exactly the one job it was asked to do. Neither is wrong. They're just both right at the same time.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. D-Bus activation — the dedupe most apps rely on
&lt;/h3&gt;

&lt;p&gt;Here's the layer that's &lt;em&gt;supposed&lt;/em&gt; to prevent this, and the reason most apps don't have the problem. A &lt;code&gt;.desktop&lt;/code&gt; file can declare:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="py"&gt;DBusActivatable&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When it does, the launcher doesn't &lt;code&gt;exec&lt;/code&gt; the binary directly — it asks D-Bus to activate the app's well-known bus name. D-Bus name ownership is exclusive: if the app already owns the name, activation routes to the running instance (typically raising its window) instead of starting a new process. GNOME's &lt;code&gt;GApplication&lt;/code&gt; leans on exactly this. It's single-instance behaviour handed to you by the desktop, for free, &lt;em&gt;if you opt in&lt;/em&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. The manual launch
&lt;/h3&gt;

&lt;p&gt;And none of the above touches the case where you just click the icon in the application menu. That's &lt;code&gt;plasmashell&lt;/code&gt; doing a plain &lt;code&gt;exec&lt;/code&gt;. No session protocol, no D-Bus activation — a brand new process, every click.&lt;/p&gt;

&lt;p&gt;So a desktop gives you at least three ways to start "the same" app, and the only one that deduplicates is D-Bus activation, which you have to explicitly opt into.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the guard wasn't
&lt;/h2&gt;

&lt;p&gt;Two places could have stopped this. I checked both.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The app.&lt;/strong&gt; I grepped for any single-instance mechanism:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;git &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s2"&gt;"QLocalServer|QLockFile|QSharedMemory|single.?instance|already.?running|fcntl&lt;/span&gt;&lt;span class="se"&gt;\.&lt;/span&gt;&lt;span class="s2"&gt;(flock|lockf)"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One hit, in an unrelated log filter. Nothing else. &lt;code&gt;vdu_controls_application.main()&lt;/code&gt; unconditionally constructs &lt;code&gt;QApplication(sys.argv)&lt;/code&gt; and proceeds to build the controller and window. There's no lock, no socket, no bus-name check. Start it twice, get two of everything.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The desktop file.&lt;/strong&gt; I read the shipped &lt;code&gt;vdu_controls.desktop&lt;/code&gt;. Generic — no &lt;code&gt;DBusActivatable=true&lt;/code&gt;, no &lt;code&gt;X-GNOME-SingleApplication=true&lt;/code&gt;, nothing. So the desktop layer couldn't dedupe either, because the app never told it to.&lt;/p&gt;

&lt;p&gt;That closed the loop. The app can't guard itself, the desktop isn't asked to, and KDE's autostart and session-restore channels are independent by design. Every login, the duplicates pile up. Nothing here is malfunctioning — the bug is the &lt;em&gt;absence&lt;/em&gt; of a mechanism, sitting in the space between four systems that each work fine alone.&lt;/p&gt;

&lt;h2&gt;
  
  
  Design choice: lock file vs. socket
&lt;/h2&gt;

&lt;p&gt;Two clean ways to make a Qt app single-instance:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;QLockFile&lt;/code&gt;&lt;/strong&gt; — acquire a per-user lock at startup; if it's already held, exit. Smaller diff, dead simple. But the second launch can only &lt;em&gt;die quietly&lt;/em&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;QLocalServer&lt;/code&gt; / &lt;code&gt;QLocalSocket&lt;/code&gt;&lt;/strong&gt; — the first instance listens on a named socket; the second connects, optionally sends a message, and exits. A few more lines, but the second launch can &lt;em&gt;talk&lt;/em&gt; to the first.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The deciding factor was the tray. This is a tray app — its normal resting state is "running, no window visible." With a lock file, here's the failure mode: the one instance is alive but minimised to the tray, you click the menu entry to bring it up, the new process grabs-the-lock-fails-exits-silently… and nothing appears. You clicked, and the desktop did nothing. That's exactly the behaviour users hate, and it's what well-behaved tray apps (Slack, KeePassXC, Telegram) deliberately avoid: a second launch &lt;em&gt;raises the existing window&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;QLocalServer&lt;/code&gt; buys that for the cost of a small message protocol, so I picked it. No new dependencies either — &lt;code&gt;QLocalServer&lt;/code&gt;/&lt;code&gt;QLocalSocket&lt;/code&gt; live in &lt;code&gt;QtNetwork&lt;/code&gt;, which the app already imports.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;

&lt;p&gt;About 40 lines in &lt;code&gt;src/vdu_controls/vdu_controls_application.py&lt;/code&gt;. Three pieces.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A server that listens and emits a signal.&lt;/strong&gt; The running instance owns a &lt;code&gt;SingleInstanceServer&lt;/code&gt;; when a peer connects, it drains the connection and emits a Qt signal:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;SingleInstanceServer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;QObject&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;activate_requested&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;pyqtSignal&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;__init__&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;server_name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;parent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;QObject&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="nf"&gt;super&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;__init__&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;parent&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_server&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;QtNetwork&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;QLocalServer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_server&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;newConnection&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_on_new_connection&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_server&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;listen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;server_name&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="c1"&gt;# listen() failed - typically a stale socket from a crashed prior run. Remove and retry.
&lt;/span&gt;            &lt;span class="n"&gt;QtNetwork&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;QLocalServer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;removeServer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;server_name&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_server&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;listen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;server_name&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
                &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;warning&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Single-instance guard could not bind &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;server_name&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_server&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;errorString&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_on_new_connection&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_server&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;hasPendingConnections&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
            &lt;span class="n"&gt;sock&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_server&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;nextPendingConnection&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;sock&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="n"&gt;sock&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;readAll&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
                &lt;span class="n"&gt;sock&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;disconnectFromServer&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;activate_requested&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;emit&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;A probe the second instance runs before doing any real work:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_activate_running_instance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;server_name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timeout_ms&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="c1"&gt;# Returns True if a peer accepted the request (caller should exit), False if none was reachable.
&lt;/span&gt;    &lt;span class="n"&gt;sock&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;QtNetwork&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;QLocalSocket&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;sock&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connectToServer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;server_name&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;sock&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;waitForConnected&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;timeout_ms&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;
    &lt;span class="n"&gt;sock&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;write&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;b&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;activate&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;sock&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;flush&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;sock&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;waitForBytesWritten&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;timeout_ms&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;sock&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;disconnectFromServer&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Wiring it into &lt;code&gt;main()&lt;/code&gt;, in the right spot.&lt;/strong&gt; Placement matters: the guard goes &lt;em&gt;after&lt;/em&gt; the one-shot CLI operations (&lt;code&gt;--install&lt;/code&gt;, &lt;code&gt;--uninstall&lt;/code&gt;, &lt;code&gt;--detailed-help&lt;/code&gt;) — those should always run regardless of whether an instance is up — but &lt;em&gt;before&lt;/em&gt; the expensive controller/window construction, so a duplicate exits before touching any hardware:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;single_instance_name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;vdu_controls-&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;geteuid&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;_activate_running_instance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;single_instance_name&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;info&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Another &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;APPNAME&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; instance is already running; activated it and exiting.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;single_instance_server&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;SingleInstanceServer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;single_instance_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;parent&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and once the window exists, the signal is hooked straight to the method that already knew how to surface it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;main_window&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;VduAppWindow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;main_config&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;main_controller&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;single_instance_server&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;activate_requested&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;partial&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;main_window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;show_main_window&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;show_main_window()&lt;/code&gt; already did the full &lt;code&gt;show()&lt;/code&gt; → &lt;code&gt;raise_()&lt;/code&gt; → &lt;code&gt;activateWindow()&lt;/code&gt; dance, including un-hiding from the tray. I didn't have to write any of the "bring it to front" logic — it was already there, just never reachable from a second process. (A small bonus: that line had carried a &lt;code&gt;# may need to assign this to a variable to prevent garbage collection?&lt;/code&gt; note for ages because the window object was never bound to a name. Wiring the signal forced me to bind it as &lt;code&gt;main_window&lt;/code&gt;, quietly settling the question.)&lt;/p&gt;

&lt;p&gt;A few deliberate details:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Per-user socket.&lt;/strong&gt; The name is keyed on the effective UID (&lt;code&gt;f"vdu_controls-{os.geteuid()}"&lt;/code&gt;), so two users on the same machine each get their own guard and never collide. On Linux, Qt places the named socket under the user's runtime directory (&lt;code&gt;$XDG_RUNTIME_DIR&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Stale-socket recovery.&lt;/strong&gt; If the previous instance was &lt;code&gt;SIGKILL&lt;/code&gt;ed (or the box lost power), the socket file can outlive it and &lt;code&gt;listen()&lt;/code&gt; fails with "address in use." The &lt;code&gt;removeServer&lt;/code&gt; + retry handles that, so a crash never permanently wedges the guard.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Honest trade-offs
&lt;/h2&gt;

&lt;p&gt;This is a small fix to a hobby project, and it has edges worth naming.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;There's a race window.&lt;/strong&gt; Between the &lt;code&gt;_activate_running_instance&lt;/code&gt; probe (which finds no peer) and the &lt;code&gt;listen()&lt;/code&gt; call that claims the socket, two launches fired in the same microsecond could both decide they're first. The window is microsecond-scale; the channels it's defending against — autostart vs. session-restore — fire hundreds of milliseconds apart at login. So in practice it's well covered, but I'm not going to pretend it's a true atomic compare-and-swap. A &lt;code&gt;QLockFile&lt;/code&gt; would close it completely, at the cost of the tray UX above. I chose the UX.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It's fix-vs-fix only.&lt;/strong&gt; The guard detects other &lt;em&gt;guarded&lt;/em&gt; instances. The very first time you upgrade into this version while old, unguarded copies are still running, those copies are invisible to it — they aren't listening on the socket. The duplicates clear on the next clean login. There's no way around this without the old code having shipped the fix, which is just the nature of additive guards.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Testing
&lt;/h2&gt;

&lt;p&gt;I wrote a headless IPC test suite — &lt;code&gt;tests/test_single_instance.py&lt;/code&gt;, 4 tests, ~140 lines, runs in under a second with &lt;code&gt;QT_QPA_PLATFORM=offscreen&lt;/code&gt; so it needs no display:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;No existing instance&lt;/strong&gt; → &lt;code&gt;_activate_running_instance&lt;/code&gt; returns &lt;code&gt;False&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Activation fires the signal&lt;/strong&gt; → connecting to a live &lt;code&gt;SingleInstanceServer&lt;/code&gt; emits &lt;code&gt;activate_requested&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Two sequential activations&lt;/strong&gt; → the signal fires both times (the server keeps serving).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Stale-socket recovery&lt;/strong&gt; → spawn a subprocess that binds a &lt;code&gt;QLocalServer&lt;/code&gt; on the name, &lt;code&gt;SIGKILL&lt;/code&gt; it so it can't clean up, then assert a fresh &lt;code&gt;SingleInstanceServer&lt;/code&gt; still binds via the &lt;code&gt;removeServer&lt;/code&gt; + retry path.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That fourth one is the test I'm happiest with, because it reproduces the exact crash-recovery scenario in CI instead of trusting that the fallback "probably works." &lt;code&gt;SIGKILL&lt;/code&gt; (not &lt;code&gt;SIGTERM&lt;/code&gt;) specifically, so no atexit cleanup runs and the socket file is guaranteed orphaned.&lt;/p&gt;

&lt;p&gt;And then the part no automated test replaces — I drove it by hand on KDE Plasma X11: cold start (window appears, guard binds), second launch (the running window pops to the front, the duplicate exits), and kill-then-relaunch (recovery via the stale-socket path). All three behaved.&lt;/p&gt;

&lt;h2&gt;
  
  
  The maintainer
&lt;/h2&gt;

&lt;p&gt;I filed &lt;a href="https://github.com/digitaltrails/vdu_controls/issues/129" rel="noopener noreferrer"&gt;issue #129&lt;/a&gt; first — &lt;code&gt;ps&lt;/code&gt; output, the parent-process breakdown, the &lt;code&gt;git grep&lt;/code&gt; showing no existing guard — then &lt;a href="https://github.com/digitaltrails/vdu_controls/pull/130" rel="noopener noreferrer"&gt;PR #130&lt;/a&gt; referencing it, in two commits: one for the fix, one for the test.&lt;/p&gt;

&lt;p&gt;Michael Hamilton (&lt;code&gt;digitaltrails&lt;/code&gt;) merged within hours, and his reply was the kind of thing that makes you want to keep contributing:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Thanks again for these changes, good contributions are few and far between, so they're much appreciated when they appear.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;He thought out loud about the test suite, too — candid about the fact that this is a retirement hobby ("an alternative to indoor/bad-weather hobbies such as crossword-puzzles and gaming") and that he's genuinely undecided about committing to maintain a test suite long-term, since the usual failure mode of tests is that they rot. Fair. I'd rather a maintainer be honest about that than merge tests he'll resent in a year.&lt;/p&gt;

&lt;p&gt;Two nice follow-ups came straight out of the merge:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;He added a config option to &lt;strong&gt;disable&lt;/strong&gt; the guard — because he sometimes runs more than one version side-by-side while developing. Obvious in hindsight, and exactly the right instinct: a single-instance guard should have an escape hatch for the person who &lt;em&gt;wants&lt;/em&gt; multiple instances.&lt;/li&gt;
&lt;li&gt;He floated extending the socket beyond a bare &lt;code&gt;"activate"&lt;/code&gt; message into a small command channel — e.g. &lt;code&gt;restore-preset 'my preset name'&lt;/code&gt; — since &lt;code&gt;vdu_controls&lt;/code&gt; already activates presets via UNIX signals and a socket is a richer pipe. I'd built a doorbell; he immediately saw it could be an intercom. If I take a swing at it, that's a third PR.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What I took away
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Some bugs don't live in a file.&lt;/strong&gt; Last time the bug was a literal character in a regex. This time there was nothing wrong to &lt;em&gt;point at&lt;/em&gt; — every component did its job. The defect was the missing contract between them, and you only see those by stepping back from the code to the system: the process tree, the launch channels, the desktop spec. Grep finds bugs that exist; it can't find the guard that was never written.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The process tree is a debugging tool.&lt;/strong&gt; &lt;code&gt;ps&lt;/code&gt; with parent PIDs told me, in one screen, that this wasn't a fork bug — it was three legitimate launchers with no referee. I'd have wasted an hour in the Python if I'd started there instead.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pick the failure mode your users will actually hit.&lt;/strong&gt; &lt;code&gt;QLockFile&lt;/code&gt; is objectively simpler and closes a race this socket approach technically leaves open. I shipped the socket anyway, because the real-world event isn't a microsecond-perfect double-launch — it's someone clicking a tray app's menu and expecting a window. Optimise for the failure that happens, not the one that's elegant to prevent.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Write the test for the scary path.&lt;/strong&gt; The stale-socket recovery is the one branch that only runs after something already went wrong (a crash). That's precisely the code you can't afford to leave unexercised, so it got the most deliberate test in the suite.&lt;/p&gt;

&lt;h2&gt;
  
  
  Links
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;PR: &lt;a href="https://github.com/digitaltrails/vdu_controls/pull/130" rel="noopener noreferrer"&gt;https://github.com/digitaltrails/vdu_controls/pull/130&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Issue: &lt;a href="https://github.com/digitaltrails/vdu_controls/issues/129" rel="noopener noreferrer"&gt;https://github.com/digitaltrails/vdu_controls/issues/129&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;My first PR to this project: &lt;a href="https://dev.to/blog/vdu-controls-philips-evnia-brightness-slider-fix/"&gt;the 0..1 brightness slider bug&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;vdu_controls&lt;/code&gt;: &lt;a href="https://github.com/digitaltrails/vdu_controls" rel="noopener noreferrer"&gt;https://github.com/digitaltrails/vdu_controls&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;XDG autostart spec: &lt;a href="https://specifications.freedesktop.org/autostart-spec/latest/" rel="noopener noreferrer"&gt;https://specifications.freedesktop.org/autostart-spec/latest/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;D-Bus activation in &lt;code&gt;.desktop&lt;/code&gt; files: &lt;a href="https://specifications.freedesktop.org/desktop-entry-spec/latest/dbus.html" rel="noopener noreferrer"&gt;https://specifications.freedesktop.org/desktop-entry-spec/latest/dbus.html&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>linux</category>
      <category>opensource</category>
      <category>python</category>
      <category>qt</category>
    </item>
    <item>
      <title>Building Clipman — a clipboard manager for Wayland that respects you</title>
      <dc:creator>Mohammed Elsayed Ammar</dc:creator>
      <pubDate>Sat, 23 May 2026 00:28:27 +0000</pubDate>
      <link>https://dev.to/mammar/building-clipman-a-clipboard-manager-for-wayland-that-respects-you-2bl2</link>
      <guid>https://dev.to/mammar/building-clipman-a-clipboard-manager-for-wayland-that-respects-you-2bl2</guid>
      <description>&lt;p&gt;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 &lt;code&gt;Win+V&lt;/code&gt;: 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.&lt;/p&gt;

&lt;p&gt;So I built one. It's called Clipman, it's on PyPI as &lt;code&gt;clipman-clipboard&lt;/code&gt;, 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 &lt;a href="https://github.com/MohammedEl-sayedAhmed/clipman" rel="noopener noreferrer"&gt;MohammedEl-sayedAhmed/clipman&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;This writeup is the story of the parts that took the longest to get right: how a clipboard manager can even &lt;em&gt;work&lt;/em&gt; 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Wayland deliberately does not let one app spy on another app's clipboard. Building a clipboard &lt;em&gt;manager&lt;/em&gt; 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 &lt;code&gt;Meta.Selection&lt;/code&gt;'s &lt;code&gt;owner-changed&lt;/code&gt; signal and forwards new entries over D-Bus to a Python &lt;em&gt;daemon&lt;/em&gt; (a background process that runs continuously, with no UI of its own).&lt;/li&gt;
&lt;li&gt;The daemon stores history in &lt;code&gt;~/.local/share/clipman/clipman.db&lt;/code&gt; — 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 (&lt;code&gt;Super+V&lt;/code&gt;) and the extension can both talk to it.&lt;/li&gt;
&lt;li&gt;Privacy: incognito mode, regex-based sensitive-content detection with 30-second auto-clear, restrictive Unix file modes (&lt;code&gt;0o700&lt;/code&gt; on the data directory means "only the owning user may even open it"; &lt;code&gt;0o600&lt;/code&gt; on image files means "only the owning user may read or write"), parameterised SQL, no &lt;code&gt;shell=True&lt;/code&gt;, no telemetry. The only network egress is one anonymous &lt;code&gt;GET&lt;/code&gt; per day to the GitHub Releases API to check for a newer version, and it is opt-out.&lt;/li&gt;
&lt;li&gt;The project ships through five channels (PyPI, Snap, AUR, &lt;code&gt;.deb&lt;/code&gt;, &lt;code&gt;.rpm&lt;/code&gt;) 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.&lt;/li&gt;
&lt;li&gt;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.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The pain point
&lt;/h2&gt;

&lt;p&gt;Linux does not ship with a clipboard manager out of the box. There's the &lt;em&gt;clipboard&lt;/em&gt; — the thing the kernel and your compositor implement so &lt;code&gt;Ctrl+C&lt;/code&gt; in one window and &lt;code&gt;Ctrl+V&lt;/code&gt; in another do the right thing — but there is no &lt;em&gt;history&lt;/em&gt;, no panel, no pinned entries, no search. If you copy something and then copy something else, the first thing is gone. The Windows &lt;code&gt;Win+V&lt;/code&gt; 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 &lt;code&gt;clipman&lt;/code&gt; (an older tool that this project is unrelated to).&lt;/p&gt;

&lt;p&gt;Three things broke that historical answer:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Wayland's security model.&lt;/strong&gt; 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 (&lt;a href="https://wayland-devel.freedesktop.narkive.com/SSrj4U4S/passive-and-active-attacks-via-x11-is-wayland-any-better" rel="noopener noreferrer"&gt;wayland-devel: passive and active attacks via X11&lt;/a&gt;).&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;GNOME's default keybinding for &lt;code&gt;Super+V&lt;/code&gt;&lt;/strong&gt; opens the notification message tray, not a clipboard. Most users have never heard of &lt;code&gt;Super+V&lt;/code&gt; because nothing useful happens when they press it.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Older clipboard managers' implementation strategies don't survive Wayland.&lt;/strong&gt; Polling &lt;code&gt;wl-paste&lt;/code&gt; in a tight loop wastes power, flickers focus on some compositors, and races against legitimate paste targets. Subscribing to X11 selection events via &lt;code&gt;xclip&lt;/code&gt; or XFixes is a non-starter; there is no equivalent X11 selection bus under Wayland for a non-privileged client to observe.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Existing Wayland-aware tools (&lt;code&gt;wl-clipboard&lt;/code&gt;'s &lt;code&gt;wl-paste --watch&lt;/code&gt;, &lt;code&gt;clipman-wayland&lt;/code&gt;, 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).&lt;/p&gt;

&lt;p&gt;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 &lt;em&gt;is&lt;/em&gt; sensitive.&lt;/p&gt;

&lt;h2&gt;
  
  
  Background, in seven short sections
&lt;/h2&gt;

&lt;p&gt;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, &lt;strong&gt;skip ahead to &lt;em&gt;The architecture&lt;/em&gt;.&lt;/strong&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Wayland vs X11
&lt;/h3&gt;

&lt;p&gt;X11 is the historical Linux display protocol. It dates back to 1984: every graphical application connects to a long-running &lt;em&gt;X server&lt;/em&gt;, 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.&lt;/p&gt;

&lt;p&gt;Wayland, designed in 2008 and gradually deployed across distros since, replaces the X server with a single &lt;em&gt;compositor&lt;/em&gt; 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 (&lt;a href="https://theserverhost.com/blog/post/x11-vs-wayland" rel="noopener noreferrer"&gt;Wayland vs X11 comparison&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;For a clipboard &lt;em&gt;manager&lt;/em&gt;, 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.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. What D-Bus is
&lt;/h3&gt;

&lt;p&gt;D-Bus is the local message bus that freedesktop.org defined for desktop programs to talk to each other (&lt;a href="https://dbus.freedesktop.org/doc/dbus-specification.html" rel="noopener noreferrer"&gt;D-Bus specification&lt;/a&gt;). 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 &lt;em&gt;bus name&lt;/em&gt; (something like &lt;code&gt;org.gnome.Shell&lt;/code&gt;), 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.&lt;/p&gt;

&lt;p&gt;There are two buses on every Linux system. The &lt;em&gt;system bus&lt;/em&gt; is for OS-level services that all users share — NetworkManager, systemd-logind, udisks. The &lt;em&gt;session bus&lt;/em&gt; is per logged-in user, and is where every desktop app lives — GNOME Shell, the notifications service, the screenshot tool, your password manager.&lt;/p&gt;

&lt;p&gt;Clipman lives entirely on the session bus. Nothing it does crosses to the system bus or requires root, which keeps the blast radius small.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. What a GNOME Shell extension is
&lt;/h3&gt;

&lt;p&gt;GNOME Shell is the program that draws the top bar, the activities overview, and the workspace switcher. Under Wayland it is &lt;em&gt;also&lt;/em&gt; 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 &lt;code&gt;gjs&lt;/code&gt;: essentially SpiderMonkey (the JavaScript engine from Firefox) wired up to GObject, so the JavaScript can call native GNOME libraries directly.&lt;/p&gt;

&lt;p&gt;A GNOME Shell extension is a JavaScript module that gets loaded into that runtime at session startup (&lt;a href="https://gjs.guide/extensions/" rel="noopener noreferrer"&gt;GJS extension guide&lt;/a&gt;). 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.&lt;/p&gt;

&lt;p&gt;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 &lt;em&gt;not&lt;/em&gt; 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).&lt;/p&gt;

&lt;h3&gt;
  
  
  4. SemVer for an app
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://semver.org/spec/v2.0.0.html" rel="noopener noreferrer"&gt;Semantic Versioning 2.0.0&lt;/a&gt; numbers releases as &lt;code&gt;MAJOR.MINOR.PATCH&lt;/code&gt;: 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.&lt;/p&gt;

&lt;p&gt;What it has &lt;em&gt;instead&lt;/em&gt; is contracts external to its own code:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The D-Bus methods other processes call.&lt;/li&gt;
&lt;li&gt;The SQLite schema sitting in the user's home directory, which future versions of the daemon and any external tooling have to read.&lt;/li&gt;
&lt;li&gt;The supported range of Python and GNOME Shell versions that downstream packagers build against.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://github.com/MohammedEl-sayedAhmed/clipman/blob/main/docs/adr/0010-versioning-policy.md" rel="noopener noreferrer"&gt;ADR 0010&lt;/a&gt; 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.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. PyPI, Snap, AUR, &lt;code&gt;.deb&lt;/code&gt;/&lt;code&gt;.rpm&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;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:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;PyPI&lt;/strong&gt; is the Python language package index. &lt;code&gt;pip install clipman-clipboard&lt;/code&gt; resolves a wheel and drops it into a Python environment. Native to anyone who already has &lt;code&gt;pip&lt;/code&gt;; useless to anyone who doesn't.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Snap&lt;/strong&gt; 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.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;AUR&lt;/strong&gt; is the &lt;a href="https://wiki.archlinux.org/title/Arch_User_Repository" rel="noopener noreferrer"&gt;Arch User Repository&lt;/a&gt;. It does &lt;em&gt;not&lt;/em&gt; host binaries. It hosts &lt;code&gt;PKGBUILD&lt;/code&gt; build scripts that helpers like &lt;code&gt;yay&lt;/code&gt; or &lt;code&gt;paru&lt;/code&gt; execute locally to compile the package from upstream sources, then install via &lt;code&gt;pacman&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;.deb&lt;/code&gt;&lt;/strong&gt; and &lt;strong&gt;&lt;code&gt;.rpm&lt;/code&gt;&lt;/strong&gt; are the native package formats for Debian/Ubuntu and Fedora/RHEL respectively. Installed with &lt;code&gt;apt install ./clipman_*.deb&lt;/code&gt; or &lt;code&gt;dnf install ./clipman-*.rpm&lt;/code&gt;, they drop files into system paths under &lt;code&gt;/usr/&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;No single channel reaches everyone — Arch users don't &lt;code&gt;pip install&lt;/code&gt;, PyPI users don't keep &lt;code&gt;snapd&lt;/code&gt; running, Fedora users want an &lt;code&gt;rpm -i&lt;/code&gt;. The release pipeline builds and ships through all four on every tag.&lt;/p&gt;

&lt;h3&gt;
  
  
  6. OIDC trusted publishing for PyPI
&lt;/h3&gt;

&lt;p&gt;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 &lt;code&gt;secrets:&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;PyPI's &lt;em&gt;trusted publishing&lt;/em&gt; replaces that long-lived token with a short-lived OIDC token minted on the fly per upload (&lt;a href="https://docs.pypi.org/trusted-publishers/" rel="noopener noreferrer"&gt;PyPI Trusted Publishers docs&lt;/a&gt;). 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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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 &lt;a href="https://github.com/MohammedEl-sayedAhmed/clipman/blob/main/docs/adr/0004-pypi-trusted-publishing-oidc.md" rel="noopener noreferrer"&gt;ADR 0004&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  7. SHA-pinning GitHub Actions
&lt;/h3&gt;

&lt;p&gt;A workflow line like &lt;code&gt;uses: actions/checkout@v4&lt;/code&gt; looks up the &lt;code&gt;v4&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;This is not theoretical. In 2025 it happened to &lt;code&gt;tj-actions/changed-files&lt;/code&gt;: 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 (&lt;a href="https://www.stepsecurity.io/blog/pinning-github-actions-for-enhanced-security-a-complete-guide" rel="noopener noreferrer"&gt;incident writeup&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;The mitigation is to pin every third-party action to its full 40-character commit SHA, like &lt;code&gt;uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7&lt;/code&gt;. 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 &lt;a href="https://github.com/MohammedEl-sayedAhmed/clipman/blob/main/docs/adr/0003-sha-pin-github-actions.md" rel="noopener noreferrer"&gt;ADR 0003&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;End of background.&lt;/p&gt;

&lt;h2&gt;
  
  
  The architecture
&lt;/h2&gt;

&lt;p&gt;Clipman is two cooperating processes plus a database. There is a daemon — a long-running background process with no UI of its own (&lt;code&gt;clipman.py&lt;/code&gt; plus the &lt;code&gt;clipman/&lt;/code&gt; Python package) — that runs as a &lt;code&gt;systemd --user&lt;/code&gt; service and owns the popup window, the storage, the settings, and the D-Bus surface. There is a GNOME Shell extension (&lt;code&gt;extension/extension.js&lt;/code&gt;) that lives inside the running Shell process and watches the clipboard. They talk over D-Bus on the session bus.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fkbmmn3tkaxqdtsrqzwps.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fkbmmn3tkaxqdtsrqzwps.png" alt="Clipman architecture diagram" width="800" height="518"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Source: &lt;a href="https://github.com/MohammedEl-sayedAhmed/clipman/blob/main/ARCHITECTURE.md" rel="noopener noreferrer"&gt;ARCHITECTURE.md&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Why our own extension instead of &lt;code&gt;wl-paste --watch&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;The first answer I tried was &lt;code&gt;wl-paste --watch&lt;/code&gt; from the &lt;a href="https://github.com/bugaevc/wl-clipboard" rel="noopener noreferrer"&gt;wl-clipboard&lt;/a&gt; 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:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;It's a subprocess. On every clipboard change, it has to be re-invoked or kept resident; either way, the daemon process tree grows.&lt;/li&gt;
&lt;li&gt;On some GNOME versions it briefly steals focus from the foreground app, producing a visible flicker.&lt;/li&gt;
&lt;li&gt;It cannot observe clipboard changes inside XWayland-hosted apps (VSCode, Electron) reliably.&lt;/li&gt;
&lt;li&gt;It is the answer for compositors that &lt;em&gt;don't&lt;/em&gt; have a clipboard extension; it shouldn't be the primary path on GNOME.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The better answer is to listen &lt;em&gt;inside&lt;/em&gt; the compositor. Mutter (the GNOME compositor) exposes a &lt;code&gt;Meta.Selection&lt;/code&gt; object with an &lt;code&gt;owner-changed&lt;/code&gt; signal that fires every time the clipboard owner changes — that is, every time something is copied (&lt;a href="https://gnome.pages.gitlab.gnome.org/mutter/meta/class.Selection.html" rel="noopener noreferrer"&gt;Meta.Selection reference&lt;/a&gt;). A GNOME Shell extension can subscribe to that signal directly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// extension/extension.js — enable()&lt;/span&gt;
&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;_selection&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;global&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;display&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_selection&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;_ownerChangedId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;_selection&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;owner-changed&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;_onOwnerChanged&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;bind&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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 — &lt;code&gt;text/plain;charset=utf-8&lt;/code&gt; is "UTF-8 text", &lt;code&gt;image/png&lt;/code&gt; 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 &lt;code&gt;text/plain;charset=utf-8&lt;/code&gt;, X11-era apps prefer &lt;code&gt;UTF8_STRING&lt;/code&gt;, and a few hold-outs only set the bare &lt;code&gt;STRING&lt;/code&gt;. So the extension tries them in priority order, falling through to the next on each miss:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// extension/extension.js&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;mimeTypes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;text/plain;charset=utf-8&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;UTF8_STRING&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;text/plain&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;STRING&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;];&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Whichever label produces non-empty bytes wins, and the result is forwarded to the daemon over D-Bus. Full file: &lt;a href="https://github.com/MohammedEl-sayedAhmed/clipman/blob/main/extension/extension.js" rel="noopener noreferrer"&gt;extension/extension.js&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;There is also a 150 ms debounce on the read. Some apps update the clipboard several times in rapid succession during a single &lt;code&gt;Ctrl+C&lt;/code&gt; (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.&lt;/p&gt;

&lt;p&gt;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 (&lt;code&gt;org.gnome.Shell.Extensions.clipman&lt;/code&gt;) and, if it isn't present, spawns &lt;code&gt;wl-paste --watch echo CLIP_CHANGED&lt;/code&gt; as a fallback. The fallback is in &lt;a href="https://github.com/MohammedEl-sayedAhmed/clipman/blob/main/clipman/clipboard_monitor.py" rel="noopener noreferrer"&gt;&lt;code&gt;clipman/clipboard_monitor.py&lt;/code&gt;&lt;/a&gt;; it parses sentinel lines off the subprocess's stdout via &lt;code&gt;GLib.io_add_watch&lt;/code&gt;, 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.&lt;/p&gt;

&lt;h3&gt;
  
  
  The D-Bus contract
&lt;/h3&gt;

&lt;p&gt;The daemon's interface lives at bus name &lt;code&gt;com.clipman.Daemon&lt;/code&gt;, object path &lt;code&gt;/com/clipman/Daemon&lt;/code&gt;, interface &lt;code&gt;com.clipman.Daemon&lt;/code&gt;. The full surface is six methods:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Method&lt;/th&gt;
&lt;th&gt;Signature&lt;/th&gt;
&lt;th&gt;Who calls it&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Toggle()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;() → ()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The &lt;code&gt;Super+V&lt;/code&gt; keybinding, via &lt;code&gt;launcher.sh&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Show()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;() → ()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;(Manual &lt;code&gt;gdbus call&lt;/code&gt; users)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Hide()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;() → ()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;(Manual &lt;code&gt;gdbus call&lt;/code&gt; users)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Quit()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;() → ()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The uninstaller&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;NewEntry(s content_type, s content)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;(ss) → ()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The extension (or &lt;code&gt;wl-paste --watch&lt;/code&gt; fallback) every time the clipboard changes&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;D-Bus types are written compactly: each letter in the signature names one argument's type. &lt;code&gt;s&lt;/code&gt; is a string. &lt;code&gt;(ss)&lt;/code&gt; means "two strings in"; &lt;code&gt;()&lt;/code&gt; on the right means "nothing comes back". So &lt;code&gt;NewEntry&lt;/code&gt; takes a content-type string ("text" or "image") plus the actual content, and returns nothing. The implementation lives in &lt;a href="https://github.com/MohammedEl-sayedAhmed/clipman/blob/main/clipman/dbus_service.py" rel="noopener noreferrer"&gt;&lt;code&gt;clipman/dbus_service.py&lt;/code&gt;&lt;/a&gt; and does nothing except marshal between D-Bus and the GTK window / monitor.&lt;/p&gt;

&lt;p&gt;The extension exposes a complementary interface at &lt;code&gt;org.gnome.Shell.Extensions.clipman&lt;/code&gt;. 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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;SimulatePaste(s mode) → ()    /* mode ∈ {auto, ctrl-v, ctrl-shift-v, shift-insert} */
MoveWindowToCursor(s title) → ()
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;s mode&lt;/code&gt; argument is new. The earlier shape was &lt;code&gt;SimulatePaste()&lt;/code&gt; 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 &lt;code&gt;DBusException&lt;/code&gt;, so a freshly upgraded daemon paired with an unupgraded extension still pastes correctly. The full reasoning is in &lt;a href="https://github.com/MohammedEl-sayedAhmed/clipman/blob/main/docs/adr/0005-paste-mode-as-dbus-arg.md" rel="noopener noreferrer"&gt;ADR 0005&lt;/a&gt;, and the extension's &lt;code&gt;metadata.json&lt;/code&gt; bumped from &lt;code&gt;version: 4&lt;/code&gt; to &lt;code&gt;version: 5&lt;/code&gt; to mark the D-Bus contract change for downstream consumers.&lt;/p&gt;

&lt;p&gt;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 &lt;code&gt;gdbus call&lt;/code&gt; examples per method, is in &lt;a href="https://github.com/MohammedEl-sayedAhmed/clipman/blob/main/docs/dbus-api.md" rel="noopener noreferrer"&gt;docs/dbus-api.md&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  What lives on disk
&lt;/h3&gt;

&lt;p&gt;Everything is under &lt;code&gt;~/.local/share/clipman/&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;clipman.db&lt;/code&gt; — 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.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;images/&lt;/code&gt; — image clipboard payloads written one file per content hash. The schema is &lt;code&gt;&amp;lt;hash&amp;gt;.&amp;lt;ext&amp;gt;&lt;/code&gt;; the daemon validates magic bytes (PNG, JPEG, GIF, BMP, WebP) before saving.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The schema is plain: an &lt;code&gt;entries&lt;/code&gt; table (&lt;code&gt;id&lt;/code&gt;, &lt;code&gt;content_type&lt;/code&gt;, &lt;code&gt;content_text&lt;/code&gt;, &lt;code&gt;image_path&lt;/code&gt;, &lt;code&gt;content_hash UNIQUE&lt;/code&gt;, &lt;code&gt;pinned&lt;/code&gt;, &lt;code&gt;created_at&lt;/code&gt;, &lt;code&gt;accessed_at&lt;/code&gt;, &lt;code&gt;sensitive&lt;/code&gt;), a &lt;code&gt;snippets&lt;/code&gt; table for user-defined named snippets, and a &lt;code&gt;settings&lt;/code&gt; table of typed key/value pairs.&lt;/p&gt;

&lt;p&gt;Deduplication is content-addressed via SHA256. If you copy the same string twice, the second insert collides on the unique hash and bumps &lt;code&gt;accessed_at&lt;/code&gt; instead of duplicating. The query that builds the history view orders by &lt;code&gt;accessed_at DESC&lt;/code&gt;, 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Privacy &amp;amp; security choices
&lt;/h2&gt;

&lt;p&gt;The premise — &lt;em&gt;this app stores a record of everything you copy&lt;/em&gt; — 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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The data directory is &lt;code&gt;0o700&lt;/code&gt;. Image files are &lt;code&gt;0o600&lt;/code&gt;.&lt;/strong&gt; Those are Unix permission modes written in octal: three digits for &lt;em&gt;owner / group / others&lt;/em&gt;, each digit a bitmask of read (4), write (2), execute (1). &lt;code&gt;0o700&lt;/code&gt; means "owner has full access; nobody else can even list the directory". &lt;code&gt;0o600&lt;/code&gt; means "owner can read and write the file; nobody else can do anything with it". The daemon applies these with &lt;code&gt;chmod&lt;/code&gt; (the Unix call to change a file's permissions) on every startup, even if the directory pre-existed, so a relaxed &lt;code&gt;umask&lt;/code&gt; cannot quietly widen them. New image files are created with &lt;code&gt;os.open(..., O_CREAT, 0o600)&lt;/code&gt; rather than the default &lt;code&gt;open()&lt;/code&gt;, because that's the only way to set the mode atomically — opening with the default and &lt;code&gt;chmod&lt;/code&gt;-ing after leaves a brief window where the file exists with the user's &lt;code&gt;umask&lt;/code&gt; mode.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Sensitive entries auto-clear from the system clipboard 30 seconds after copy.&lt;/strong&gt; Detection lives in &lt;a href="https://github.com/MohammedEl-sayedAhmed/clipman/blob/main/clipman/clipboard_monitor.py" rel="noopener noreferrer"&gt;&lt;code&gt;clipman/clipboard_monitor.py&lt;/code&gt;&lt;/a&gt; and is a deliberately blunt regex-style match — it errs on the side of flagging &lt;em&gt;more&lt;/em&gt; things as sensitive, not fewer. The triggers include:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Known token prefixes: &lt;code&gt;ghp_&lt;/code&gt;, &lt;code&gt;gho_&lt;/code&gt;, &lt;code&gt;ghs_&lt;/code&gt;, &lt;code&gt;github_pat_&lt;/code&gt;, &lt;code&gt;sk-&lt;/code&gt;, &lt;code&gt;sk_live_&lt;/code&gt;, &lt;code&gt;pk_live_&lt;/code&gt;, &lt;code&gt;eyJ&lt;/code&gt; (JWT), &lt;code&gt;xox&lt;/code&gt; (Slack), &lt;code&gt;AKIA&lt;/code&gt; (AWS access keys), &lt;code&gt;AIza&lt;/code&gt; (Google API keys), &lt;code&gt;npm_&lt;/code&gt;, &lt;code&gt;-----BEGIN&lt;/code&gt; (PEM blocks), and &lt;code&gt;Bearer&lt;/code&gt; (HTTP Authorization-header tokens).&lt;/li&gt;
&lt;li&gt;Database connection strings: &lt;code&gt;postgresql://&lt;/code&gt;, &lt;code&gt;mysql://&lt;/code&gt;, &lt;code&gt;mongodb://&lt;/code&gt;, &lt;code&gt;redis://&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;SSH public-key prefixes: &lt;code&gt;ssh-rsa&lt;/code&gt;, &lt;code&gt;ssh-ed25519&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;A heuristic for "looks like a password": single-line, 8–128 chars, no whitespace, contains three of {lowercase, uppercase, digit, punctuation}.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A flagged entry gets stored with &lt;code&gt;sensitive = 1&lt;/code&gt;, hidden from the searchable history, and the daemon's &lt;code&gt;delete_expired_sensitive&lt;/code&gt; job removes it from the database 30 seconds after capture. Incognito mode disables capture entirely with a toggle in the status bar.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;There is no &lt;code&gt;shell=True&lt;/code&gt; anywhere.&lt;/strong&gt; 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. &lt;strong&gt;All SQL is parameterised.&lt;/strong&gt; Backup imports — a feature most apps don't think to harden — reject SQLite URIs that try &lt;code&gt;file:&lt;/code&gt; injection tricks, reject databases that contain triggers or views (which can execute on read), and validate image magic bytes on every imported attachment.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The update check&lt;/strong&gt; is the one network thing the daemon does. With the setting enabled, once every 24 hours the daemon's update-check thread issues &lt;em&gt;one&lt;/em&gt; anonymous &lt;code&gt;GET https://api.github.com/repos/MohammedEl-sayedAhmed/clipman/releases/latest&lt;/code&gt; with &lt;code&gt;User-Agent: clipman/&amp;lt;version&amp;gt;&lt;/code&gt; and a 5-second timeout. No body, no query parameters, no cookies, no identifiers, no referer. It reads &lt;code&gt;tag_name&lt;/code&gt; out of the JSON, compares it to &lt;code&gt;clipman.__version__&lt;/code&gt;, and stores the result in the same SQLite &lt;code&gt;settings&lt;/code&gt; table that holds the rest of your preferences. The full posture from &lt;a href="https://github.com/MohammedEl-sayedAhmed/clipman/blob/main/docs/adr/0007-in-app-update-notifications.md" rel="noopener noreferrer"&gt;ADR 0007&lt;/a&gt;:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;No telemetry.&lt;/em&gt; The check must not send any user data, identifiers, cookies, or anything beyond what an anonymous web visitor would fetch.&lt;br&gt;
&lt;em&gt;No auto-update.&lt;/em&gt; We notify and link; we don't download or install.&lt;br&gt;
&lt;em&gt;Opt-out friendly.&lt;/em&gt; Snap users in particular don't need this — the Snap Store already refreshes installed snaps — so it should default off there.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;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 &lt;a href="https://github.com/MohammedEl-sayedAhmed/clipman/blob/main/docs/threat-model.md" rel="noopener noreferrer"&gt;docs/threat-model.md&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Distribution as a problem in itself
&lt;/h2&gt;

&lt;p&gt;Linux package distribution does not have a single answer. It has at least five.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;PyPI&lt;/strong&gt; (&lt;code&gt;pip install clipman-clipboard&lt;/code&gt;) 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 (&lt;code&gt;python3-gi&lt;/code&gt;, &lt;code&gt;python3-dbus&lt;/code&gt;, &lt;code&gt;gir1.2-gtk-3.0&lt;/code&gt;, &lt;code&gt;wl-clipboard&lt;/code&gt;), 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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Snap&lt;/strong&gt; (&lt;code&gt;sudo snap install clipman&lt;/code&gt;) 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 (&lt;a href="https://snapcraft.io/docs/how-to-guides/manage-snaps/manage-updates/" rel="noopener noreferrer"&gt;Snapcraft: Manage updates&lt;/a&gt;). 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 &lt;em&gt;only&lt;/em&gt; 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 &lt;code&gt;desktop&lt;/code&gt; plug. Snap installs default the update check OFF — the store is already pushing updates, no need to double up.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;AUR&lt;/strong&gt; (&lt;code&gt;yay -S clipman-clipboard&lt;/code&gt;) 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 (&lt;a href="https://wiki.archlinux.org/title/Arch_User_Repository" rel="noopener noreferrer"&gt;ArchWiki: Arch User Repository&lt;/a&gt;). 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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;.deb&lt;/code&gt; and &lt;code&gt;.rpm&lt;/code&gt;&lt;/strong&gt; are produced by the release workflow using &lt;a href="https://github.com/jordansissel/fpm" rel="noopener noreferrer"&gt;&lt;code&gt;fpm&lt;/code&gt;&lt;/a&gt; and attached to the GitHub Release page. They install the Python module, &lt;code&gt;/usr/bin/clipman&lt;/code&gt;, the &lt;code&gt;.desktop&lt;/code&gt; file and the icon system-wide, but they do &lt;em&gt;not&lt;/em&gt; install the per-user GNOME Shell extension or the &lt;code&gt;Super+V&lt;/code&gt; keybinding — those are user-scoped and stay out of system packages. A &lt;code&gt;.deb&lt;/code&gt; user runs &lt;code&gt;./install.sh&lt;/code&gt; once after install to finish the per-user setup. This is documented in the README.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The GNOME Extensions website&lt;/strong&gt; (&lt;a href="https://extensions.gnome.org/extension/9407/clipman-clipboard-monitor/" rel="noopener noreferrer"&gt;extensions.gnome.org&lt;/a&gt;) 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 &lt;code&gt;EGO-A-005 (manual_review): direct clipboard access via St.Clipboard.get_default() requires reviewer scrutiny&lt;/code&gt; — which is &lt;em&gt;correct&lt;/em&gt;, 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.&lt;/p&gt;

&lt;p&gt;No single channel is sufficient. PyPI users don't install snaps; Arch users don't &lt;code&gt;pip install&lt;/code&gt;; Snap users want a one-click install; Fedora users want an &lt;code&gt;rpm -i&lt;/code&gt;. The build matrix is the cost of being installable.&lt;/p&gt;

&lt;h2&gt;
  
  
  CI/CD as a security surface, not just plumbing
&lt;/h2&gt;

&lt;p&gt;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 &lt;a href="https://github.com/MohammedEl-sayedAhmed/clipman/blob/main/docs/ci-cd.md" rel="noopener noreferrer"&gt;docs/ci-cd.md&lt;/a&gt;. The decisions worth talking about here:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;SHA-pinning every action.&lt;/strong&gt; Every &lt;code&gt;uses:&lt;/code&gt; line in &lt;code&gt;.github/workflows/&lt;/code&gt; points to a 40-character commit SHA, with a trailing &lt;code&gt;# v1.2.3&lt;/code&gt; 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 &lt;a href="https://github.com/MohammedEl-sayedAhmed/clipman/blob/main/docs/adr/0003-sha-pin-github-actions.md" rel="noopener noreferrer"&gt;ADR 0003&lt;/a&gt;; the recent industry context is in StepSecurity's writeup of the &lt;a href="https://www.stepsecurity.io/blog/pinning-github-actions-for-enhanced-security-a-complete-guide" rel="noopener noreferrer"&gt;&lt;code&gt;tj-actions/changed-files&lt;/code&gt; compromise&lt;/a&gt;, where workflows that used &lt;code&gt;@v44&lt;/code&gt; instead of a SHA executed an attacker's code and printed all their secrets to the build log.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Annotated-tag SHA vs commit SHA.&lt;/strong&gt; This one bit me. Git tags can be either &lt;em&gt;lightweight&lt;/em&gt; (a pointer directly to a commit) or &lt;em&gt;annotated&lt;/em&gt; (a tag &lt;em&gt;object&lt;/em&gt; with metadata that points to a commit). &lt;code&gt;git rev-parse v1.2.3&lt;/code&gt; 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 &lt;code&gt;pypa/gh-action-pypi-publish&lt;/code&gt; — 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 &lt;code&gt;Unable to find image&lt;/code&gt;. The fix was to switch the pin to the commit SHA returned by &lt;code&gt;git rev-parse v1.14.0^{commit}&lt;/code&gt;. The same error mode bit OpenSSF's Scorecard action in PR #22. The lesson: pin to commits, verify with &lt;code&gt;^{commit}&lt;/code&gt;, and Docker-based actions are the trip wire that surfaces the mistake.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;CodeQL baseline ratchet.&lt;/strong&gt; &lt;a href="https://docs.github.com/en/code-security/code-scanning/managing-your-code-scanning-configuration/codeql-query-suites" rel="noopener noreferrer"&gt;CodeQL&lt;/a&gt;'s &lt;code&gt;security-and-quality&lt;/code&gt; query suite surfaces roughly eighteen pre-existing informational findings on &lt;code&gt;main&lt;/code&gt; (best-effort &lt;code&gt;except: pass&lt;/code&gt; blocks, cyclic imports, module-level prints) that are intentional and not defects. Out of the box those findings appear as annotations on &lt;em&gt;every&lt;/em&gt; PR's &lt;em&gt;Files changed&lt;/em&gt; tab, including PRs that don't touch the affected files, which trains reviewers to ignore the annotations entirely. The mitigation is a &lt;em&gt;baseline ratchet&lt;/em&gt;: keep a fingerprint list of findings that exist on &lt;code&gt;main&lt;/code&gt; on a dedicated orphan branch &lt;code&gt;security-baseline&lt;/code&gt;, and fail a PR only if it introduces a fingerprint not in that list. New regressions block; pre-existing noise doesn't. The &lt;code&gt;security-baseline&lt;/code&gt; branch is auto-refreshed on push to &lt;code&gt;main&lt;/code&gt; and protected against manual tampering by a &lt;code&gt;baseline-guard&lt;/code&gt; workflow that auto-reverts unauthorised pushes and opens a labelled security issue. Recorded in &lt;a href="https://github.com/MohammedEl-sayedAhmed/clipman/blob/main/docs/adr/0002-baseline-ratchet-for-codeql.md" rel="noopener noreferrer"&gt;ADR 0002&lt;/a&gt; and refined by &lt;a href="https://github.com/MohammedEl-sayedAhmed/clipman/blob/main/docs/adr/0008-ratchet-fingerprint-strategy.md" rel="noopener noreferrer"&gt;ADR 0008&lt;/a&gt; (which switched the fingerprint format from &lt;code&gt;rule:file:line&lt;/code&gt; to SARIF &lt;code&gt;partialFingerprints.primaryLocationLineHash&lt;/code&gt;, so unrelated line-shifts above an existing finding don't read as new findings).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step-Security &lt;code&gt;harden-runner&lt;/code&gt;&lt;/strong&gt; is the first step on every job, with &lt;code&gt;egress-policy: audit&lt;/code&gt;. In audit mode the action installs eBPF hooks at the kernel level that log every outbound network connection from the runner (&lt;a href="https://docs.stepsecurity.io/harden-runner" rel="noopener noreferrer"&gt;StepSecurity docs&lt;/a&gt;) 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, &lt;code&gt;.deb&lt;/code&gt;, and &lt;code&gt;.rpm&lt;/code&gt; is large enough that I haven't audited it yet.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;OIDC trusted publishing for PyPI&lt;/strong&gt; (&lt;a href="https://github.com/MohammedEl-sayedAhmed/clipman/blob/main/docs/adr/0004-pypi-trusted-publishing-oidc.md" rel="noopener noreferrer"&gt;ADR 0004&lt;/a&gt;). 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 &lt;a href="https://pypi.org/manage/account/publishing/" rel="noopener noreferrer"&gt;PyPI publishing settings page&lt;/a&gt; per project, which is unavoidable but only happens once.&lt;/p&gt;

&lt;p&gt;The full release pipeline DAG, the secrets matrix, the &lt;code&gt;harden-runner&lt;/code&gt; audit semantics, the SHA-pinning policy, and the debug playbook live in &lt;a href="https://github.com/MohammedEl-sayedAhmed/clipman/blob/main/docs/ci-cd.md" rel="noopener noreferrer"&gt;docs/ci-cd.md&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Real bugs that shipped (and what they taught me)
&lt;/h2&gt;

&lt;p&gt;A list, in order of how surprised I was.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;libfuse2 → libfuse2t64 (PR #33).&lt;/strong&gt; The release pipeline includes an AppImage build, which depends on &lt;code&gt;libfuse2&lt;/code&gt; at run time and at build time. Ubuntu 24.04 — the runner image we use — renamed &lt;code&gt;libfuse2&lt;/code&gt; to &lt;code&gt;libfuse2t64&lt;/code&gt; as part of the &lt;a href="https://docs.appimage.org/user-guide/troubleshooting/fuse.html" rel="noopener noreferrer"&gt;64-bit time_t transition&lt;/a&gt;, so the workflow's &lt;code&gt;apt install libfuse2&lt;/code&gt; started failing with "no installation candidate" on the runner roll-forward. The fix is &lt;code&gt;apt install libfuse2t64 || apt install libfuse2&lt;/code&gt;, fallback chain on the rename. Lesson: even a fully SHA-pinned action stack is not insulated from the &lt;em&gt;runner image&lt;/em&gt; changing under it; the runner image has its own roll-forward calendar.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Metadata-Version 2.4 vs older twine (PR #36).&lt;/strong&gt; &lt;a href="https://twine.readthedocs.io/en/stable/changelog.html" rel="noopener noreferrer"&gt;Twine&lt;/a&gt; is the canonical tool for uploading Python packages to PyPI. The wheel we build for 1.0.5 declared &lt;code&gt;Metadata-Version: 2.4&lt;/code&gt; because the modern build backend supports the new license fields, but the version of &lt;code&gt;pypa/gh-action-pypi-publish&lt;/code&gt; 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 &lt;em&gt;commit&lt;/em&gt; 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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Settings-panel "clicks do nothing" on Wayland (1.0.6).&lt;/strong&gt; 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 &lt;code&gt;focus-out-event&lt;/code&gt; (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 &lt;code&gt;focus-out&lt;/code&gt; on the parent window, which hides the popup, which means the click event never reaches the Switch at all. The fix is in &lt;a href="https://github.com/MohammedEl-sayedAhmed/clipman/blob/main/clipman/window.py" rel="noopener noreferrer"&gt;&lt;code&gt;clipman/window.py&lt;/code&gt;&lt;/a&gt;: treat "focus moved to a descendant of the popup itself" as not-really-a-focus-out and ignore it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_on_focus_out&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;widget&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_ignore_focus_out&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;
    &lt;span class="c1"&gt;# On some Wayland compositors, clicking certain interactive
&lt;/span&gt;    &lt;span class="c1"&gt;# widgets inside the popup (notably Gtk.Switch and combo-box
&lt;/span&gt;    &lt;span class="c1"&gt;# popovers) briefly transfers keyboard focus to a transient
&lt;/span&gt;    &lt;span class="c1"&gt;# surface, which sends a focus-out to the parent window. If
&lt;/span&gt;    &lt;span class="c1"&gt;# we treat that as "the popup lost focus to another window"
&lt;/span&gt;    &lt;span class="c1"&gt;# and hide ourselves, the original click event never reaches
&lt;/span&gt;    &lt;span class="c1"&gt;# the widget — the user perceives the entire settings panel
&lt;/span&gt;    &lt;span class="c1"&gt;# as unresponsive. Guard: only hide when the new focus owner
&lt;/span&gt;    &lt;span class="c1"&gt;# is genuinely outside the popup tree.
&lt;/span&gt;    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;new_focus&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_focus&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;Exception&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;new_focus&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;new_focus&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;new_focus&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;is_ancestor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;
    &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;hide&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Lesson: when a click "does nothing", suspect the focus model before suspecting the click handler.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The SSH key compromise.&lt;/strong&gt; 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 (&lt;code&gt;AUR_SSH_PRIVATE_KEY&lt;/code&gt;). 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 (&lt;code&gt;id_ed25519_aur&lt;/code&gt;, 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 &lt;code&gt;~/.ssh/authorized_keys&lt;/code&gt; on AUR, and &lt;code&gt;shred&lt;/code&gt;'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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Versioning and deprecation
&lt;/h2&gt;

&lt;p&gt;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"?&lt;/p&gt;

&lt;p&gt;For Clipman it's three things, written down in &lt;a href="https://github.com/MohammedEl-sayedAhmed/clipman/blob/main/docs/adr/0010-versioning-policy.md" rel="noopener noreferrer"&gt;ADR 0010&lt;/a&gt;:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The two D-Bus interfaces&lt;/strong&gt; — &lt;code&gt;com.clipman.Daemon&lt;/code&gt; and &lt;code&gt;org.gnome.Shell.Extensions.clipman&lt;/code&gt; — and their method signatures.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The SQLite schema&lt;/strong&gt; at &lt;code&gt;~/.local/share/clipman/clipman.db&lt;/code&gt;, including the &lt;code&gt;settings&lt;/code&gt; table key names.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The supported-versions matrix&lt;/strong&gt; — Python 3.10–3.12, GNOME Shell 45–48, Ubuntu 22.04+ — and the GTK3 toolkit choice.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;A MAJOR bump (e.g. &lt;code&gt;2.0.0&lt;/code&gt;) 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 &lt;code&gt;SimulatePaste(s mode)&lt;/code&gt; one (the precedent that established the pattern, &lt;a href="https://github.com/MohammedEl-sayedAhmed/clipman/blob/main/docs/adr/0005-paste-mode-as-dbus-arg.md" rel="noopener noreferrer"&gt;ADR 0005&lt;/a&gt;). A PATCH means anything else.&lt;/p&gt;

&lt;p&gt;Internal Python modules like &lt;code&gt;clipman.database&lt;/code&gt; or &lt;code&gt;clipman.window&lt;/code&gt; are &lt;em&gt;not&lt;/em&gt; 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.&lt;/p&gt;

&lt;p&gt;The extension's &lt;code&gt;metadata.json&lt;/code&gt; &lt;code&gt;version&lt;/code&gt; 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Documentation as a first-class artifact
&lt;/h2&gt;

&lt;p&gt;The repo has &lt;a href="https://github.com/MohammedEl-sayedAhmed/clipman/tree/main/docs/adr" rel="noopener noreferrer"&gt;ten ADRs&lt;/a&gt; covering every notable architecture decision; a top-level &lt;a href="https://github.com/MohammedEl-sayedAhmed/clipman/blob/main/ARCHITECTURE.md" rel="noopener noreferrer"&gt;ARCHITECTURE.md&lt;/a&gt;; a &lt;a href="https://github.com/MohammedEl-sayedAhmed/clipman/blob/main/GOVERNANCE.md" rel="noopener noreferrer"&gt;GOVERNANCE.md&lt;/a&gt; that names the maintainer and the decision-making process; a &lt;a href="https://github.com/MohammedEl-sayedAhmed/clipman/blob/main/docs/maintaining.md" rel="noopener noreferrer"&gt;maintaining.md&lt;/a&gt; with the release flow, branch hygiene, Dependabot triage and packaging notes; a &lt;a href="https://github.com/MohammedEl-sayedAhmed/clipman/blob/main/docs/ci-cd.md" rel="noopener noreferrer"&gt;ci-cd.md&lt;/a&gt; with the workflow inventory, release DAG and secrets matrix; a &lt;a href="https://github.com/MohammedEl-sayedAhmed/clipman/blob/main/docs/dbus-api.md" rel="noopener noreferrer"&gt;dbus-api.md&lt;/a&gt; with worked &lt;code&gt;gdbus call&lt;/code&gt; examples per method; a &lt;a href="https://github.com/MohammedEl-sayedAhmed/clipman/blob/main/docs/threat-model.md" rel="noopener noreferrer"&gt;threat-model.md&lt;/a&gt;; a &lt;a href="https://github.com/MohammedEl-sayedAhmed/clipman/blob/main/docs/translating.md" rel="noopener noreferrer"&gt;translating.md&lt;/a&gt; for the gettext workflow; a &lt;a href="https://github.com/MohammedEl-sayedAhmed/clipman/blob/main/CHANGELOG.md" rel="noopener noreferrer"&gt;Keep-a-Changelog&lt;/a&gt; CHANGELOG; plus CONTRIBUTING, SECURITY and a Contributor Covenant code of conduct.&lt;/p&gt;

&lt;p&gt;That is a lot of words for a one-person project. The honest reasons:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;I will forget. A year from now I will not remember why &lt;code&gt;metadata.json&lt;/code&gt; is at &lt;code&gt;version: 5&lt;/code&gt; while the product is at &lt;code&gt;1.0.6&lt;/code&gt;. The ADRs are the only way to find out without re-litigating the decision.&lt;/li&gt;
&lt;li&gt;Downstream packagers and translators need a place that isn't "open an issue and ask". &lt;code&gt;docs/translating.md&lt;/code&gt; is the difference between a translator submitting a PR and a translator giving up; &lt;code&gt;docs/maintaining.md&lt;/code&gt; is what lets someone else cut a release without me on a call.&lt;/li&gt;
&lt;li&gt;The CI harness is genuinely complex. If I am the only person who knows what &lt;code&gt;baseline-guard.yml&lt;/code&gt; is for, the harness only works as long as my memory does, which is short.&lt;/li&gt;
&lt;li&gt;Writing decisions down forces me to reread them. Half the time, writing the ADR is when I notice the decision was wrong.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The cost is more concentrated than it looks. One of the ADRs (0007, the update-check posture) genuinely shipped &lt;em&gt;alongside&lt;/em&gt; 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.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F99yme5new8ouzfme6str.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F99yme5new8ouzfme6str.png" alt="Clipman dark theme — Catppuccin Mocha" width="382" height="531"&gt;&lt;/a&gt;&lt;br&gt;
&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Feadxlcya5v8qp9e4ty9d.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Feadxlcya5v8qp9e4ty9d.png" alt="Clipman light theme — Catppuccin Latte" width="382" height="531"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Dark and light themes (Catppuccin Mocha / Latte). Source: &lt;a href="https://github.com/MohammedEl-sayedAhmed/clipman" rel="noopener noreferrer"&gt;repository README&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What's next
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;KDE support.&lt;/strong&gt; 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 (&lt;code&gt;wl-paste --watch&lt;/code&gt;) 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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Themes beyond Catppuccin.&lt;/strong&gt; The current dark/light pair is Catppuccin Mocha / Latte. The CSS is a template with &lt;code&gt;$variable&lt;/code&gt; placeholders so a third-party theme is a 30-line file, but I haven't documented how to write one yet.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Image annotation.&lt;/strong&gt; 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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Privacy-preserving sync across machines.&lt;/strong&gt; 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reflection / lessons
&lt;/h2&gt;

&lt;p&gt;A few things stuck with me building this.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The choice of where to listen is the whole architecture.&lt;/strong&gt; Everything downstream of "subscribe to &lt;code&gt;Meta.Selection&lt;/code&gt;'s &lt;code&gt;owner-changed&lt;/code&gt; inside the Shell process" is mechanical. Everything downstream of "poll &lt;code&gt;wl-paste&lt;/code&gt; 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 &lt;code&gt;Meta.Selection&lt;/code&gt; 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 &lt;em&gt;can&lt;/em&gt; do this, and how do I become its client?" is worth a long time at the whiteboard.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;SemVer for an end-user app is a contract with downstreams, not users.&lt;/strong&gt; Users mostly don't read your version number. AUR maintainers, snap rebuilders, distro packagers, translators — they read it constantly. Writing the policy down (&lt;a href="https://github.com/MohammedEl-sayedAhmed/clipman/blob/main/docs/adr/0010-versioning-policy.md" rel="noopener noreferrer"&gt;ADR 0010&lt;/a&gt;) is a kindness to the people whose job it is to ship your code.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;SHA pins protect a future me that doesn't exist yet.&lt;/strong&gt; It would have been faster to use &lt;code&gt;@v4&lt;/code&gt; 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, &lt;em&gt;if and only if&lt;/em&gt; 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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The release pipeline is more of the product than I expected.&lt;/strong&gt; Half the work of shipping 1.0.5 wasn't the new features — it was making the release reproducible across PyPI, Snap, AUR, &lt;code&gt;.deb&lt;/code&gt;, &lt;code&gt;.rpm&lt;/code&gt;, 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 &lt;em&gt;pipeline&lt;/em&gt; was wrong, and a separate patch had to ship to fix the pipeline before the features could actually reach PyPI.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The privacy posture matters more than the feature list.&lt;/strong&gt; 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 &lt;code&gt;0o700&lt;/code&gt; on the data dir, the sensitive-content auto-clear, the &lt;em&gt;one&lt;/em&gt; documented network egress, and the audit trail of decisions in &lt;code&gt;docs/adr/&lt;/code&gt;. The features get you tried; the posture gets you kept.&lt;/p&gt;

&lt;h2&gt;
  
  
  Links
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Repository: &lt;a href="https://github.com/MohammedEl-sayedAhmed/clipman" rel="noopener noreferrer"&gt;https://github.com/MohammedEl-sayedAhmed/clipman&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;PyPI: &lt;a href="https://pypi.org/project/clipman-clipboard/" rel="noopener noreferrer"&gt;https://pypi.org/project/clipman-clipboard/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;GNOME Extensions: &lt;a href="https://extensions.gnome.org/extension/9407/clipman-clipboard-monitor/" rel="noopener noreferrer"&gt;https://extensions.gnome.org/extension/9407/clipman-clipboard-monitor/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;AUR: &lt;a href="https://aur.archlinux.org/packages/clipman-clipboard" rel="noopener noreferrer"&gt;https://aur.archlinux.org/packages/clipman-clipboard&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Snap Store: &lt;a href="https://snapcraft.io/clipman" rel="noopener noreferrer"&gt;https://snapcraft.io/clipman&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Architecture decisions: &lt;a href="https://github.com/MohammedEl-sayedAhmed/clipman/tree/main/docs/adr" rel="noopener noreferrer"&gt;https://github.com/MohammedEl-sayedAhmed/clipman/tree/main/docs/adr&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;D-Bus reference: &lt;a href="https://github.com/MohammedEl-sayedAhmed/clipman/blob/main/docs/dbus-api.md" rel="noopener noreferrer"&gt;https://github.com/MohammedEl-sayedAhmed/clipman/blob/main/docs/dbus-api.md&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Threat model: &lt;a href="https://github.com/MohammedEl-sayedAhmed/clipman/blob/main/docs/threat-model.md" rel="noopener noreferrer"&gt;https://github.com/MohammedEl-sayedAhmed/clipman/blob/main/docs/threat-model.md&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;CI/CD inventory: &lt;a href="https://github.com/MohammedEl-sayedAhmed/clipman/blob/main/docs/ci-cd.md" rel="noopener noreferrer"&gt;https://github.com/MohammedEl-sayedAhmed/clipman/blob/main/docs/ci-cd.md&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>linux</category>
      <category>opensource</category>
      <category>python</category>
      <category>wayland</category>
    </item>
    <item>
      <title>How I fixed a 0..1 brightness slider in vdu_controls (Philips Evnia DDC/CI bug)</title>
      <dc:creator>Mohammed Elsayed Ammar</dc:creator>
      <pubDate>Sat, 16 May 2026 21:11:26 +0000</pubDate>
      <link>https://dev.to/mammar/how-i-fixed-a-01-brightness-slider-in-vducontrols-philips-evnia-ddcci-bug-4fdg</link>
      <guid>https://dev.to/mammar/how-i-fixed-a-01-brightness-slider-in-vducontrols-philips-evnia-ddcci-bug-4fdg</guid>
      <description>&lt;p&gt;I plugged a new monitor into my Kubuntu laptop last week. The brightness slider in the tray utility I use only had two settings: black, and almost-black. Not 0 to 100. Not a continuous gradient. Just two positions.&lt;/p&gt;

&lt;p&gt;The monitor itself was fine. The cable was fine. Every other monitor on the same machine worked normally. So I started pulling on the thread.&lt;/p&gt;

&lt;p&gt;A few hours later I had: a one-line cause, a 21-line patch, a test fixture, and my first open-source PR merged upstream.&lt;/p&gt;

&lt;p&gt;This is a writeup of what the bug actually was, how a monitor talks to a computer at all, and what I learned chasing it.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;p&gt;A Philips Evnia 27M2N5500Q reports the same VCP code (the standardized "brightness" control) twice in its &lt;strong&gt;capability string&lt;/strong&gt; — once correctly, then again inside a manufacturer-specific section with garbage values. Combined with an unescaped &lt;code&gt;.&lt;/code&gt; in a regex inside &lt;code&gt;vdu_controls&lt;/code&gt; (a KDE GUI for controlling external monitors), this made the GUI think brightness was a 0..1 control instead of 0..100.&lt;/p&gt;

&lt;p&gt;The fix is in two small parts. The PR was merged into &lt;a href="https://github.com/digitaltrails/vdu_controls/pull/128" rel="noopener noreferrer"&gt;digitaltrails/vdu_controls#128&lt;/a&gt;. Read on for the actually-interesting part.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Laptop:&lt;/strong&gt; Kubuntu 24.04, KDE Plasma 5.27 on X11, Intel iGPU&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The new monitor:&lt;/strong&gt; Philips Evnia 27M2N5500Q, 27" 2560x1440, connected over HDMI&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The tool:&lt;/strong&gt; &lt;code&gt;vdu_controls&lt;/code&gt; — a small Qt tray app that lets you adjust brightness/contrast/etc. on external monitors. It's the closest thing Linux has to Windows' Twinkle Tray.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;After plugging the monitor in, the tray UI showed two sliders — one per external monitor. The Lenovo on DisplayPort had a normal 0..100 brightness slider. The Philips on HDMI did not. Its slider had two positions, the value field showed &lt;code&gt;1&lt;/code&gt;, and dragging it from one end to the other produced exactly two states: full off (0) and almost-off (1).&lt;/p&gt;

&lt;h2&gt;
  
  
  First-pass diagnosis: where exactly is it broken?
&lt;/h2&gt;

&lt;p&gt;Before debugging &lt;em&gt;any&lt;/em&gt; GUI bug, the first question is: &lt;strong&gt;is the underlying mechanism broken, or just the UI?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I dropped to the shell:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;ddcutil &lt;span class="nt"&gt;--display&lt;/span&gt; 1 getvcp 10
VCP code 0x10 &lt;span class="o"&gt;(&lt;/span&gt;Brightness&lt;span class="o"&gt;)&lt;/span&gt;: current value &lt;span class="o"&gt;=&lt;/span&gt; 83, max value &lt;span class="o"&gt;=&lt;/span&gt; 100
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;ddcutil&lt;/code&gt; is the canonical command-line tool for talking to monitors over DDC/CI. It reported the brightness correctly — current 83, maximum 100. The monitor itself was reporting a continuous 0..100 range to the OS. The bug had to be somewhere between that response and what the GUI rendered.&lt;/p&gt;

&lt;p&gt;That narrows things down enormously. Whatever was wrong, it was in user-space, in Python, in the parts I could read.&lt;/p&gt;

&lt;h2&gt;
  
  
  Background, in four short sections
&lt;/h2&gt;

&lt;p&gt;Before I show what was actually broken, here's the protocol stack involved. If you already know DDC/CI, MCCS, and VCP codes, skip ahead.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. A monitor is a tiny embedded computer
&lt;/h3&gt;

&lt;p&gt;A modern monitor isn't just a glass panel and a backlight. It runs firmware. That firmware controls:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The backlight intensity&lt;/li&gt;
&lt;li&gt;Contrast, color balance, gamma curves&lt;/li&gt;
&lt;li&gt;Which physical input is active (HDMI-1 / HDMI-2 / DisplayPort)&lt;/li&gt;
&lt;li&gt;The on-screen menu (OSD) you see when you press the button on the back&lt;/li&gt;
&lt;li&gt;Sometimes audio, USB hub switching, HDR mode, KVM&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You normally interact with all this through the OSD menu. The problem with OSD menus is that you have to physically reach around to a button on the back of every monitor you own. So manufacturers and standards bodies agreed on a way for &lt;em&gt;the computer&lt;/em&gt; to control these things over the video cable.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. DDC and DDC/CI
&lt;/h3&gt;

&lt;p&gt;VESA — the same standards body behind DisplayPort and EDID — defined a protocol called &lt;strong&gt;DDC&lt;/strong&gt; (Display Data Channel). It uses spare wires in the video cable to carry a tiny side-channel for the monitor and the computer to talk to each other.&lt;/p&gt;

&lt;p&gt;Originally DDC was one-way: the monitor told the computer about itself (its name, supported resolutions, refresh rates). That packet of self-description is called &lt;strong&gt;EDID&lt;/strong&gt;. It's how your OS knows your monitor is a "Philips 27M2N5500Q" without you typing it in.&lt;/p&gt;

&lt;p&gt;Then VESA extended DDC to be two-way and called the extension &lt;strong&gt;DDC/CI&lt;/strong&gt; — Display Data Channel &lt;strong&gt;Command Interface&lt;/strong&gt;. Now the computer could also &lt;em&gt;send commands&lt;/em&gt;: "set brightness to 50", "switch input to HDMI-2", "what's your current contrast?". That's the protocol everything in this story rides on.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. VCP codes and MCCS
&lt;/h3&gt;

&lt;p&gt;To make DDC/CI useful across manufacturers, VESA also standardized which commands exist, in a document called &lt;strong&gt;MCCS&lt;/strong&gt; (Monitor Control Command Set). Each control gets a numeric code called a &lt;strong&gt;VCP code&lt;/strong&gt; (Virtual Control Panel). A handful of examples:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Code&lt;/th&gt;
&lt;th&gt;Meaning&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;0x10&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Brightness&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;0x12&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Contrast&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;0x14&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Color preset (sRGB, 6500K, 9300K…)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;0x60&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Input source&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;0x62&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Audio speaker volume&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;0xD6&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Power mode&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Crucially, each VCP code has a &lt;strong&gt;type&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Continuous (C):&lt;/strong&gt; a number on a range. Brightness &lt;code&gt;0x10&lt;/code&gt; is C — pick any value between 0 and a maximum the monitor reports (usually 100). Like a slider.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Non-Continuous (NC):&lt;/strong&gt; pick from a fixed list. Input source &lt;code&gt;0x60&lt;/code&gt; is NC — only specific values like &lt;code&gt;0x11 = HDMI-1&lt;/code&gt;, &lt;code&gt;0x12 = HDMI-2&lt;/code&gt;, &lt;code&gt;0x0F = DisplayPort-1&lt;/code&gt; mean anything. Like a dropdown.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This distinction determines whether a UI should render the control as a slider or as a dropdown. Hold onto it — it matters later.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. The capability string
&lt;/h3&gt;

&lt;p&gt;When the computer first talks to a monitor over DDC/CI, it asks: &lt;strong&gt;"which VCP codes do you support?"&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The monitor replies with a text blob called the &lt;strong&gt;capability string&lt;/strong&gt;. Mine looks like this (trimmed):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Model: 27M2N5500Q
MCCS version: 2.2
VCP Features:
   Feature: 10 (Brightness)
   Feature: 12 (Contrast)
   Feature: 14 (Select color preset)
      Values: 02 04 05 06 08 0B
   Feature: 60 (Input Source)
      Values: 11 12 0F
   ...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read that as: "I support brightness (continuous, no value list needed), contrast (continuous), color preset (these specific options), input source (these specific options)…"&lt;/p&gt;

&lt;p&gt;Continuous features have no &lt;code&gt;Values:&lt;/code&gt; line. Non-continuous features have a &lt;code&gt;Values:&lt;/code&gt; line listing the allowed discrete values. The presence or absence of that sub-block is how a parser decides which type each feature is.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. The two tools in this story
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;ddcutil&lt;/code&gt;&lt;/strong&gt; — the command-line client. Opens &lt;code&gt;/dev/i2c-N&lt;/code&gt; (the kernel's interface to the tiny serial bus inside your video cable) and speaks DDC/CI directly. Lets you do &lt;code&gt;ddcutil --display 1 setvcp 10 50&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;vdu_controls&lt;/code&gt;&lt;/strong&gt; — a Qt tray GUI built on top of &lt;code&gt;ddcutil&lt;/code&gt;. It calls &lt;code&gt;ddcutil capabilities&lt;/code&gt; once per monitor at startup, parses the capability string, and renders sliders or dropdowns based on what each feature's type turns out to be. When you drag a slider, it shells out to &lt;code&gt;ddcutil setvcp&lt;/code&gt; to push the new value.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  So where was the bug?
&lt;/h2&gt;

&lt;p&gt;I ran &lt;code&gt;ddcutil capabilities&lt;/code&gt; on the Philips and the output was 217 lines long. Most of it was unremarkable. But:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Line  18:   Feature: 10 (Brightness)
Line  19:   Feature: 12 (Contrast)
...
Line 178:   Feature: E2 (Manufacturer specific feature)
Line 179:   Feature: A0 (6 axis hue control: Magenta)
Line 180:   Feature: 10 (Brightness)
Line 181:      Values: 00 01 02 03 04 (interpretation unavailable)
Line 182:   Feature: E2 (Manufacturer specific feature)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;Feature: 10 (Brightness)&lt;/code&gt; appears &lt;strong&gt;twice&lt;/strong&gt;. The first time, correctly, with no &lt;code&gt;Values:&lt;/code&gt; block — meaning standard continuous brightness, 0..100. The second time, 160 lines later, deep inside what looks like a manufacturer-specific section (between &lt;code&gt;Feature: A0&lt;/code&gt; and another &lt;code&gt;Feature: E2&lt;/code&gt;), it shows up again &lt;em&gt;with&lt;/em&gt; a &lt;code&gt;Values:&lt;/code&gt; line full of garbage: &lt;code&gt;00 01 02 03 04&lt;/code&gt;. Those aren't real values for anything — they're noise from a section of the firmware that should have stayed private.&lt;/p&gt;

&lt;p&gt;That's bug #1: the firmware is leaking manufacturer-internal data into the standardized VCP section of the capability string.&lt;/p&gt;

&lt;p&gt;But buggy firmware on its own doesn't break a GUI. The next question was: how did &lt;code&gt;vdu_controls&lt;/code&gt; react to this?&lt;/p&gt;

&lt;h2&gt;
  
  
  Reading the parser
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;vdu_controls&lt;/code&gt;' capability parser lives in &lt;code&gt;_parse_capabilities&lt;/code&gt;. Stripped down, it looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;feature_map&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;feature_text&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;capabilities_text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt; Feature: &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;feature_match&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;_FEATURE_PATTERN&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;feature_text&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;vcp_code&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;feature_match&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;group&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="c1"&gt;# ... figure out vcp_type and values ...
&lt;/span&gt;        &lt;span class="n"&gt;feature_map&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;vcp_code&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;VcpCapability&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;vcp_code&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...)&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;feature_map&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things to notice:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;feature_map&lt;/code&gt; is a dict keyed by VCP code. If the same code is parsed twice, &lt;strong&gt;the second assignment silently overwrites the first.&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;The type-classification logic (Continuous vs Non-Continuous) is based on whether a &lt;code&gt;Values:&lt;/code&gt; block was found &lt;em&gt;for that occurrence&lt;/em&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;So when the Philips' cap string was fed in, here's what happened:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;First pass through &lt;code&gt;Feature: 10&lt;/code&gt;: no &lt;code&gt;Values:&lt;/code&gt; block → classified as Continuous → stored as "brightness, 0..(max from getvcp)" → good.&lt;/li&gt;
&lt;li&gt;Second pass through &lt;code&gt;Feature: 10&lt;/code&gt;: has a &lt;code&gt;Values:&lt;/code&gt; block → classified as Non-Continuous → stored as "brightness, discrete options 00/01/02/03/04" → &lt;strong&gt;overwrites the first entry.&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;By the time the GUI built its widget, the brightness feature in &lt;code&gt;feature_map&lt;/code&gt; was the corrupted second copy.&lt;/p&gt;

&lt;h2&gt;
  
  
  The second bug, hiding in plain sight
&lt;/h2&gt;

&lt;p&gt;That alone would have rendered brightness as a discrete dropdown (with weird options 00–04). But I was seeing a &lt;em&gt;slider&lt;/em&gt; — just stuck at 0..1. Why a slider at all if it was classified as Non-Continuous?&lt;/p&gt;

&lt;p&gt;Because there was a second, completely separate bug.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;vdu_controls&lt;/code&gt; has a special case for monitors that report a &lt;em&gt;restricted&lt;/em&gt; continuous range. Some panels physically can't go below 20% brightness without flickering, and they signal this by reporting their &lt;code&gt;Values:&lt;/code&gt; like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Feature: 10 (Brightness)
   Values: 20..90
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's a range, not a list. The parser tries to match it with a regex:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;_RANGE_PATTERN&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;compile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;Values:\s+([0-9]+)..([0-9]+)&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you don't see the bug, look harder. The &lt;code&gt;..&lt;/code&gt; in the middle of the pattern was meant to be two literal dots. But in regex syntax, &lt;code&gt;.&lt;/code&gt; is a metacharacter meaning &lt;em&gt;any character whatsoever&lt;/em&gt;. So &lt;code&gt;..&lt;/code&gt; actually matches &lt;strong&gt;any two characters&lt;/strong&gt;, not two dots.&lt;/p&gt;

&lt;p&gt;When that regex was applied to the Philips' garbage &lt;code&gt;Values: 00 01 02 03 04 (interpretation unavailable)&lt;/code&gt;, it matched:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;00&lt;/code&gt; → first capture group&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;0&lt;/code&gt; (space + the next &lt;code&gt;0&lt;/code&gt;, both matched by the unescaped &lt;code&gt;..&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;1&lt;/code&gt; → second capture group&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The parser then thought: "ah, this is a restricted-range continuous feature, from 0 to 1." That's where the 0..1 slider came from. The monitor was reporting &lt;code&gt;Values: 00 01 02 03 04&lt;/code&gt;, and a regex bug turned that into "range 0..1".&lt;/p&gt;

&lt;p&gt;So the full causal chain is:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The Philips firmware double-lists &lt;code&gt;Feature: 10&lt;/code&gt; and dumps garbage values on the second copy.&lt;/li&gt;
&lt;li&gt;A regex bug interprets that garbage as a &lt;em&gt;restricted range&lt;/em&gt; of 0..1.&lt;/li&gt;
&lt;li&gt;The dict-overwrite means the corrupted range definition wins over the correct one.&lt;/li&gt;
&lt;li&gt;The widget renders a 0..1 slider.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Three layers of bug stacked on top of each other to produce one terrible UX.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;

&lt;p&gt;The PR I sent adds two defensive guards in &lt;code&gt;_parse_capabilities&lt;/code&gt;:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Guard A — trust the standard for known-continuous codes.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;vdu_controls&lt;/code&gt; already has an internal table that maps VCP codes to their MCCS-defined types. It knows &lt;code&gt;0x10&lt;/code&gt; is brightness and that brightness is Continuous. So: if the cap string shows up with a stray &lt;code&gt;Values:&lt;/code&gt; block &lt;em&gt;for a code we already know is continuous&lt;/em&gt;, ignore the values list, trust the standard. Don't let firmware noise reclassify brightness as a dropdown.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Guard B — keep the first occurrence of any duplicate Feature line.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If the same &lt;code&gt;Feature: XX&lt;/code&gt; appears twice, keep the first parse and log a warning instead of silently overwriting. For known-supported codes (the user-visible ones), log a &lt;code&gt;WARNING&lt;/code&gt;. For unknown manufacturer codes, log an &lt;code&gt;INFO&lt;/code&gt; so the noise stays out of the warning stream.&lt;/p&gt;

&lt;p&gt;The two guards are complementary: A handles the case where there's only one occurrence but it has bad values; B handles the case where there are duplicates regardless of values.&lt;/p&gt;

&lt;p&gt;Both together, total diff: 21 insertions, 1 deletion. About half of those lines are comments explaining &lt;em&gt;why&lt;/em&gt;, because the next person to look at this code in five years deserves to know what the Philips firmware is doing.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the maintainer caught
&lt;/h2&gt;

&lt;p&gt;When I submitted the PR, the maintainer (Michael Hamilton) reviewed it within hours. While reading my test fixture's log output, he spotted &lt;em&gt;another&lt;/em&gt; bug — the &lt;code&gt;_RANGE_PATTERN&lt;/code&gt; regex from above. He fixed it independently in a follow-up commit:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight diff"&gt;&lt;code&gt;&lt;span class="gd"&gt;- _RANGE_PATTERN = re.compile(r'Values:\s+([0-9]+)..([0-9]+)')
&lt;/span&gt;&lt;span class="gi"&gt;+ _RANGE_PATTERN = re.compile(r'Values:\s+([0-9]+)[.][.]([0-9]+)')
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;[.][.]&lt;/code&gt; is a regex idiom for "literal dot followed by literal dot" — a character class containing only one character (the dot) is the same as escaping the dot. Now the pattern only matches actual range syntax (&lt;code&gt;20..90&lt;/code&gt;) and leaves discrete values alone.&lt;/p&gt;

&lt;p&gt;His fix is a one-character change in spirit. Mine is structurally larger. The two are orthogonal — neither is sufficient on its own to handle every variant of this class of firmware quirk, but together they cover the space.&lt;/p&gt;

&lt;p&gt;Open source at its best, honestly: a contributor's test fixture surfaces an unrelated latent bug, and the maintainer catches it in review.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I took away
&lt;/h2&gt;

&lt;p&gt;A few things stuck with me after this.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reading code you didn't write is the most underrated programming skill.&lt;/strong&gt; This whole patch is ~10 lines of actual logic. The hours went into reading &lt;code&gt;vdu_controls&lt;/code&gt;' 12,000 lines of Python until I understood the dataflow well enough to know &lt;em&gt;where&lt;/em&gt; the bug had to live.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Always check the boundary between the working layer and the broken one.&lt;/strong&gt; The fact that &lt;code&gt;ddcutil getvcp 10&lt;/code&gt; returned the right answer while the GUI didn't was the most important diagnostic in the whole session. It collapsed the search space from "the entire stack from monitor to pixels" to "Python code I can grep".&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Firmware lies.&lt;/strong&gt; This isn't a &lt;code&gt;vdu_controls&lt;/code&gt; bug at root — it's a &lt;code&gt;vdu_controls&lt;/code&gt; &lt;em&gt;vulnerability&lt;/em&gt; to a Philips firmware bug. Defensive parsing isn't optional when you're reading data you didn't generate. Half the diff is comments because the right comment in the right place is the difference between "this code is weirdly defensive" and "this code is defensive &lt;em&gt;for a reason and here is the reason&lt;/em&gt;".&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Real OSS maintainers are gracious.&lt;/strong&gt; Michael's review was thoughtful, asked good questions, considered alternatives out loud, credited the contributor, and merged. That's a model worth copying when I'm ever on the other side of a PR.&lt;/p&gt;

&lt;h2&gt;
  
  
  Links
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;PR: &lt;a href="https://github.com/digitaltrails/vdu_controls/pull/128" rel="noopener noreferrer"&gt;https://github.com/digitaltrails/vdu_controls/pull/128&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Bug report: &lt;a href="https://github.com/digitaltrails/vdu_controls/issues/127" rel="noopener noreferrer"&gt;https://github.com/digitaltrails/vdu_controls/issues/127&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Maintainer's follow-up regex fix: &lt;a href="https://github.com/digitaltrails/vdu_controls/commit/6d72a377" rel="noopener noreferrer"&gt;&lt;code&gt;6d72a377&lt;/code&gt;&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;vdu_controls&lt;/code&gt;: &lt;a href="https://github.com/digitaltrails/vdu_controls" rel="noopener noreferrer"&gt;https://github.com/digitaltrails/vdu_controls&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;ddcutil&lt;/code&gt;: &lt;a href="https://www.ddcutil.com/" rel="noopener noreferrer"&gt;https://www.ddcutil.com/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;VESA MCCS 2.2 spec (paywalled, but described in the ddcutil docs)&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>linux</category>
      <category>opensource</category>
      <category>python</category>
      <category>debugging</category>
    </item>
  </channel>
</rss>
