<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="3.9.3">Jekyll</generator><link href="https://gofranz.com/feed.xml" rel="self" type="application/atom+xml" /><link href="https://gofranz.com/" rel="alternate" type="text/html" /><updated>2026-03-12T20:24:13+00:00</updated><id>https://gofranz.com/feed.xml</id><title type="html">Franz Geffke</title><subtitle>Full-Stack Developer, AI Orchestrator, ½ Machine - Personal website and blog covering web development, AI integration, system architecture, and technology insights</subtitle><entry><title type="html">OpenPGP Web Key Directory on S3 and CloudFront</title><link href="https://gofranz.com/blog/openpgp-web-key-directory-on-s3-cloudfront/" rel="alternate" type="text/html" title="OpenPGP Web Key Directory on S3 and CloudFront" /><published>2026-03-05T00:00:00+00:00</published><updated>2026-03-05T00:00:00+00:00</updated><id>https://gofranz.com/blog/openpgp-web-key-directory-on-s3-cloudfront</id><content type="html" xml:base="https://gofranz.com/blog/openpgp-web-key-directory-on-s3-cloudfront/"><![CDATA[<p>If you’ve ever exchanged PGP-encrypted email, you know the awkward dance: you need someone’s public key before you can write to them, and they need yours. Keyservers exist, but they’re clunky and not everyone publishes there. Web Key Directory (WKD) is a simpler approach — your email client fetches the key directly from your domain over HTTPS. No keyserver, no manual import.</p>

<p>Thunderbird, KMail, GnuPG, Proton Mail, and a growing list of clients support it out of the box. Once set up, anyone composing an encrypted email to you gets your key automatically.</p>

<p>Here’s how I set it up for <code class="language-plaintext highlighter-rouge">mail@gofranz.com</code>, hosted on S3 with CloudFront.</p>

<h2 id="how-wkd-works">How WKD works</h2>

<p>WKD maps an email address to a URL. The local part (before the <code class="language-plaintext highlighter-rouge">@</code>) gets SHA-1 hashed and Z-Base-32 encoded into a 32-character string. That string becomes the filename, served from a well-known path on your domain.</p>

<p>There are two methods:</p>

<table>
  <thead>
    <tr>
      <th>Method</th>
      <th>URL pattern</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><strong>Direct</strong></td>
      <td><code class="language-plaintext highlighter-rouge">https://example.com/.well-known/openpgpkey/hu/&lt;hash&gt;?l=&lt;local&gt;</code></td>
    </tr>
    <tr>
      <td><strong>Advanced</strong></td>
      <td><code class="language-plaintext highlighter-rouge">https://openpgpkey.example.com/.well-known/openpgpkey/example.com/hu/&lt;hash&gt;?l=&lt;local&gt;</code></td>
    </tr>
  </tbody>
</table>

<p>The direct method is simpler — no subdomain, no extra TLS certificate. Most clients try the advanced method first, then fall back to direct.</p>

<h2 id="prerequisites">Prerequisites</h2>

<ul>
  <li>A domain with HTTPS (required — WKD won’t work without it)</li>
  <li>GnuPG 2.1.12+ installed locally</li>
  <li>Your PGP key in your local keyring</li>
  <li>An S3 bucket + CloudFront distribution serving your site</li>
</ul>

<h2 id="step-1-get-your-wkd-hash">Step 1: Get your WKD hash</h2>

<p>GnuPG can compute the hash for you:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>gpg <span class="nt">--with-wkd-hash</span> <span class="nt">-k</span> yourmail@example.com
</code></pre></div></div>

<p>In the output, look for a line formatted as <code class="language-plaintext highlighter-rouge">hash@domain</code> right below your uid — the part before the <code class="language-plaintext highlighter-rouge">@</code> is your WKD hash. For <code class="language-plaintext highlighter-rouge">mail@gofranz.com</code>, mine is <code class="language-plaintext highlighter-rouge">dizb37aqa5h4skgu7jf1xjr4q71w4paq</code>.</p>

<h2 id="step-2-create-the-directory-structure">Step 2: Create the directory structure</h2>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">mkdir</span> <span class="nt">-p</span> .well-known/openpgpkey/hu
<span class="nb">touch</span> .well-known/openpgpkey/policy
</code></pre></div></div>

<p>The empty <code class="language-plaintext highlighter-rouge">policy</code> file signals that WKD is available on this domain. Without it, clients won’t look further.</p>

<h2 id="step-3-export-your-key">Step 3: Export your key</h2>

<p>Export the <strong>binary</strong> format — not ASCII-armored:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>gpg <span class="nt">--export</span> yourmail@example.com <span class="o">&gt;</span> .well-known/openpgpkey/hu/&lt;your-hash&gt;
</code></pre></div></div>

<h2 id="step-4-jekyll-configuration">Step 4: Jekyll configuration</h2>

<p>If you’re using Jekyll (or any static site generator that ignores dotfiles), make sure <code class="language-plaintext highlighter-rouge">.well-known</code> gets included in the build output. For Jekyll, add to <code class="language-plaintext highlighter-rouge">_config.yml</code>:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">include</span><span class="pi">:</span>
  <span class="pi">-</span> <span class="s">.well-known</span>
</code></pre></div></div>

<h2 id="step-5-s3-upload-with-correct-content-type">Step 5: S3 upload with correct Content-Type</h2>

<p>S3 won’t guess the right content type for an extensionless binary file. Upload the WKD files with an explicit content type <strong>before</strong> your general <code class="language-plaintext highlighter-rouge">s3 sync</code> — this way sync sees the files already exist and skips them, preserving the correct metadata:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>aws s3 <span class="nb">cp </span>_site/.well-known/openpgpkey/hu/ s3://your-bucket/.well-known/openpgpkey/hu/ <span class="se">\</span>
  <span class="nt">--recursive</span> <span class="nt">--content-type</span> <span class="s2">"application/octet-stream"</span> <span class="nt">--profile</span> your-profile
aws s3 <span class="nb">sync </span>_site/ s3://your-bucket/ <span class="nt">--delete</span> <span class="nt">--profile</span> your-profile
</code></pre></div></div>

<h2 id="step-6-cloudfront-cors-headers">Step 6: CloudFront CORS headers</h2>

<p>WKD requires <code class="language-plaintext highlighter-rouge">Access-Control-Allow-Origin: *</code> on responses. CloudFront doesn’t add this by default.</p>

<ol>
  <li>Go to <strong>CloudFront → Policies → Response headers policies</strong> and create a new policy (I called mine <code class="language-plaintext highlighter-rouge">WKD-CORS</code>)</li>
  <li>Enable <strong>CORS</strong> and set <code class="language-plaintext highlighter-rouge">Access-Control-Allow-Origin</code> to all origins</li>
  <li>Go to your <strong>Distribution → Behaviors</strong> and create a new behavior:
    <ul>
      <li>Path pattern: <code class="language-plaintext highlighter-rouge">/.well-known/openpgpkey/*</code></li>
      <li>Origin: your S3 origin</li>
      <li>Response headers policy: <code class="language-plaintext highlighter-rouge">WKD-CORS</code></li>
    </ul>
  </li>
</ol>

<h2 id="step-7-dns--wildcard-gotcha">Step 7: DNS — wildcard gotcha</h2>

<p>If your domain has a wildcard DNS record (<code class="language-plaintext highlighter-rouge">*.example.com</code>), clients trying the advanced method will get a response from the wildcard instead of a proper “not found.” This can break the fallback to the direct method.</p>

<p>The fix: add a TXT record for <code class="language-plaintext highlighter-rouge">_openpgpkey.example.com</code> (the value can be empty). This tells clients that the advanced method isn’t available, and they should use direct.</p>

<h2 id="verify">Verify</h2>

<p>Deploy your site, invalidate CloudFront, and test:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>gpg <span class="nt">--auto-key-locate</span> clear,wkd <span class="nt">--locate-keys</span> yourmail@example.com
</code></pre></div></div>

<p>There are also web-based checkers — <a href="https://wkd.dp42.dev/">wkd.dp42.dev</a> (open-source) and <a href="https://webkeydirectory.com/">webkeydirectory.com</a>. Note that some checkers report a missing CORS header even when it’s working — CloudFront only returns <code class="language-plaintext highlighter-rouge">Access-Control-Allow-Origin</code> when the request includes an <code class="language-plaintext highlighter-rouge">Origin</code> header, which is standard behavior. Real email clients send this header.</p>

<h2 id="who-supports-wkd">Who supports WKD?</h2>

<p>Adoption is broader than you might expect:</p>

<table>
  <thead>
    <tr>
      <th>Software</th>
      <th>Type</th>
      <th>Since</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>GnuPG</td>
      <td>CLI</td>
      <td>2.1.12 (2016)</td>
    </tr>
    <tr>
      <td>Thunderbird</td>
      <td>Email client</td>
      <td>78 (2020)</td>
    </tr>
    <tr>
      <td>KMail</td>
      <td>Email client</td>
      <td>~2018 (via GnuPG)</td>
    </tr>
    <tr>
      <td>Delta Chat</td>
      <td>Email client</td>
      <td>~2022</td>
    </tr>
    <tr>
      <td>Mailvelope</td>
      <td>Browser extension</td>
      <td>4.x (~2020)</td>
    </tr>
    <tr>
      <td>Proton Mail</td>
      <td>Provider</td>
      <td>~2020</td>
    </tr>
    <tr>
      <td>Posteo</td>
      <td>Provider</td>
      <td>~2019</td>
    </tr>
    <tr>
      <td>mailbox.org</td>
      <td>Provider</td>
      <td>~2020</td>
    </tr>
  </tbody>
</table>

<p>Proton Mail is probably the largest deployment — every <code class="language-plaintext highlighter-rouge">@protonmail.com</code> and <code class="language-plaintext highlighter-rouge">@proton.me</code> address has WKD set up automatically.</p>

<h2 id="caveats">Caveats</h2>

<ul>
  <li><strong>One key per email.</strong> WKD serves a single key per address. If you have multiple keys, only one gets published.</li>
  <li><strong>No revocation propagation.</strong> If you revoke your key, you need to manually update or remove the file. There’s no automatic mechanism.</li>
  <li><strong>HTTPS required.</strong> Self-signed certificates won’t work.</li>
  <li><strong>The advanced method needs a subdomain.</strong> If you want to support it, you need <code class="language-plaintext highlighter-rouge">openpgpkey.example.com</code> with a valid TLS certificate. For most personal domains, the direct method is sufficient.</li>
</ul>

<p>It took about 15 minutes to set up, and now anyone with a WKD-capable client can find my key automatically. One less reason to skip encryption.</p>]]></content><author><name>Franz Geffke</name></author><category term="[&quot;Tools&quot;]" /><category term="gpg" /><category term="security" /><category term="aws" /><summary type="html"><![CDATA[If you’ve ever exchanged PGP-encrypted email, you know the awkward dance: you need someone’s public key before you can write to them, and they need yours. Keyservers exist, but they’re clunky and not everyone publishes there. Web Key Directory (WKD) is a simpler approach — your email client fetches the key directly from your domain over HTTPS. No keyserver, no manual import.]]></summary></entry><entry><title type="html">Screen Sharing on Niri/Wayland with Guix</title><link href="https://gofranz.com/blog/screen-sharing-on-niri-wayland-with-guix/" rel="alternate" type="text/html" title="Screen Sharing on Niri/Wayland with Guix" /><published>2026-03-04T00:00:00+00:00</published><updated>2026-03-04T00:00:00+00:00</updated><id>https://gofranz.com/blog/screen-sharing-on-niri-wayland-with-guix</id><content type="html" xml:base="https://gofranz.com/blog/screen-sharing-on-niri-wayland-with-guix/"><![CDATA[<p>This is a follow-up to my earlier post on <a href="/tools/2025/09/15/screen-sharing-on-sway-wayland-guix.html">Screen Sharing on Sway/Wayland with Guix</a>. I’ve since switched from Sway to <a href="https://github.com/YaLTeR/niri">Niri</a> — a scrollable-tiling Wayland compositor — and the screen sharing setup is a bit different.</p>

<p>The main change: Niri implements the <code class="language-plaintext highlighter-rouge">org.gnome.Mutter.ScreenCast</code> D-Bus interface, so you use <code class="language-plaintext highlighter-rouge">xdg-desktop-portal-gnome</code> instead of the <code class="language-plaintext highlighter-rouge">wlr</code> portal.</p>

<h3 id="1-portal-configuration">1. Portal Configuration</h3>

<p>Create or update your <code class="language-plaintext highlighter-rouge">portals.conf</code>:</p>

<div class="language-toml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nn">[preferred]</span>
<span class="py">default</span><span class="p">=</span><span class="err">gtk</span>
<span class="c"># use gnome portal for screen sharing (niri compatible)</span>
<span class="py">org.freedesktop.impl.portal.ScreenCast</span><span class="p">=</span><span class="err">gnome</span>
<span class="py">org.freedesktop.impl.portal.Screenshot</span><span class="p">=</span><span class="err">gnome</span>
</code></pre></div></div>

<h3 id="2-niri-startup-commands">2. Niri Startup Commands</h3>

<p>In your <code class="language-plaintext highlighter-rouge">niri.kdl</code> config, add these startup commands:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>// Export Wayland display and desktop session to D-Bus
spawn-at-startup "dbus-update-activation-environment" "--systemd" "WAYLAND_DISPLAY" "XDG_CURRENT_DESKTOP=niri"

// Restart portal-gnome after niri's ScreenCast D-Bus interface is ready
spawn-at-startup "sh" "-c" "while ! busctl --user status org.gnome.Mutter.ScreenCast &gt;/dev/null 2&gt;&amp;1; do sleep 0.2; done; pkill -f xdg-desktop-portal-gnome"
</code></pre></div></div>

<p>The second command is the critical bit: <code class="language-plaintext highlighter-rouge">xdg-desktop-portal-gnome</code> often starts before Niri’s ScreenCast interface is available. This polls until the interface is ready, then restarts the portal so it picks up the screencasting capability.</p>

<h3 id="3-required-packages">3. Required Packages</h3>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>xdg-desktop-portal-gnome
xdg-desktop-portal-gtk
wayland-protocols
slurp
</code></pre></div></div>

<h3 id="4-chrome--webrtc-black-screen-fix">4. Chrome / WebRTC Black Screen Fix</h3>

<p>If you’re getting a black screen when sharing in Chrome or other Chromium-based browsers, it’s because they don’t handle DMA-BUF formats properly. There’s a <a href="https://github.com/YaLTeR/niri/pull/1791">Niri PR #1791</a> that adds a SHM (Shared Memory) fallback for PipeWire screencasting.</p>

<p>I’ve packaged this as <code class="language-plaintext highlighter-rouge">niri-shm</code> in my <a href="https://github.com/franzos/panther">panther</a> Guix channel:</p>

<div class="language-scheme highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">;; in px/packages/wm.scm</span>
<span class="p">(</span><span class="nf">define-public</span> <span class="nv">niri-shm</span>
  <span class="p">(</span><span class="nf">package</span>
    <span class="p">(</span><span class="nf">inherit</span> <span class="nv">niri</span><span class="p">)</span>
    <span class="p">(</span><span class="nf">name</span> <span class="s">"niri-shm"</span><span class="p">)</span>
    <span class="p">(</span><span class="nf">source</span>
     <span class="p">(</span><span class="nf">origin</span>
       <span class="p">(</span><span class="nf">inherit</span> <span class="p">(</span><span class="nf">package-source</span> <span class="nv">niri</span><span class="p">))</span>
       <span class="p">(</span><span class="nf">patches</span> <span class="p">(</span><span class="nb">list</span> <span class="p">(</span><span class="nf">local-file</span> <span class="s">"patches/niri-shm-support.patch"</span><span class="p">)))))</span>
    <span class="p">(</span><span class="nf">synopsis</span> <span class="s">"Niri with SHM screencast support"</span><span class="p">)</span>
    <span class="p">(</span><span class="nf">description</span>
     <span class="s">"Niri scrollable-tiling Wayland compositor with shared memory (SHM)
fallback for PipeWire screencasting.  This fixes screen sharing in browsers
like Chrome that cannot handle DMA-BUF formats."</span><span class="p">)))</span>
</code></pre></div></div>

<p>Use <code class="language-plaintext highlighter-rouge">niri-shm</code> in place of <code class="language-plaintext highlighter-rouge">niri</code> in your Guix Home config.</p>

<h3 id="5-guix-home">5. Guix Home</h3>

<p>Putting it all together:</p>

<div class="language-scheme highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">(</span><span class="nf">home-environment</span>
 <span class="p">(</span><span class="nf">packages</span>
  <span class="p">(</span><span class="nf">cons*</span> <span class="nv">niri-shm</span>
  <span class="p">(</span><span class="nf">specifications-&gt;packages</span>
   <span class="p">(</span><span class="nb">list</span>
         <span class="s">"xdg-desktop-portal-gnome"</span>
         <span class="s">"xdg-desktop-portal-gtk"</span>
         <span class="s">"wayland-protocols"</span>
         <span class="s">"slurp"</span>
         <span class="s">"pipewire"</span>
         <span class="s">"wireplumber"</span>
         <span class="c1">;; ...</span>
         <span class="p">))))</span>
 <span class="p">(</span><span class="nf">services</span>
  <span class="p">(</span><span class="nb">append</span> <span class="p">(</span><span class="nb">list</span>
        <span class="p">(</span><span class="nf">service</span> <span class="nv">home-xdg-configuration-files-service-type</span>
                  <span class="o">`</span><span class="p">((</span><span class="s">"niri/config.kdl"</span> <span class="o">,</span><span class="p">(</span><span class="nf">local-file</span> <span class="s">"niri.kdl"</span><span class="p">))</span>
                    <span class="p">(</span><span class="s">"xdg-desktop-portal/portals.conf"</span> <span class="o">,</span><span class="p">(</span><span class="nf">local-file</span> <span class="s">"portals.conf"</span><span class="p">))))</span>
        <span class="p">(</span><span class="nf">simple-service</span> <span class="ss">'env-vars</span> <span class="nv">home-environment-variables-service-type</span>
                        <span class="o">`</span><span class="p">((</span><span class="s">"XDG_CURRENT_DESKTOP"</span> <span class="o">.</span> <span class="s">"niri"</span><span class="p">)</span>
                          <span class="p">(</span><span class="s">"XDG_SESSION_DESKTOP"</span> <span class="o">.</span> <span class="s">"niri"</span><span class="p">)</span>
                          <span class="p">(</span><span class="s">"XDG_SESSION_TYPE"</span> <span class="o">.</span> <span class="s">"wayland"</span><span class="p">)</span>
                          <span class="p">(</span><span class="s">"ELECTRON_OZONE_PLATFORM_HINT"</span> <span class="o">.</span> <span class="s">"wayland"</span><span class="p">)</span>
                          <span class="p">(</span><span class="s">"MOZ_ENABLE_WAYLAND"</span> <span class="o">.</span> <span class="s">"1"</span><span class="p">)</span>
                          <span class="p">(</span><span class="s">"NIXOS_OZONE_WL"</span> <span class="o">.</span> <span class="s">"1"</span><span class="p">)</span>
                          <span class="p">(</span><span class="s">"GDK_BACKEND"</span> <span class="o">.</span> <span class="s">"wayland"</span><span class="p">)</span>
                          <span class="p">(</span><span class="s">"CLUTTER_BACKEND"</span> <span class="o">.</span> <span class="s">"wayland"</span><span class="p">)))</span>
        <span class="p">(</span><span class="nf">service</span> <span class="nv">home-dbus-service-type</span><span class="p">)</span>
        <span class="p">(</span><span class="nf">service</span> <span class="nv">home-pipewire-service-type</span>
                 <span class="p">(</span><span class="nf">home-pipewire-configuration</span>
                  <span class="p">(</span><span class="nf">pipewire</span> <span class="nv">pipewire</span><span class="p">)</span>
                  <span class="p">(</span><span class="nf">wireplumber</span> <span class="nv">wireplumber</span><span class="p">))))</span>
        <span class="nv">%base-home-services</span><span class="p">)))</span>
</code></pre></div></div>

<h3 id="testing">Testing</h3>

<p>Use the <a href="https://mozilla.github.io/webrtc-landing/gum_test.html">Mozilla WebRTC Test Page</a> to verify screen sharing works. You should get a prompt to select a screen or window — if the preview shows your desktop, you’re good.</p>

<p>For my full config, checkout <a href="https://github.com/franzos/dotfiles/tree/master/home">my dotfiles</a>.</p>]]></content><author><name>Franz Geffke</name></author><category term="[&quot;Tools&quot;]" /><category term="guix" /><category term="wayland" /><summary type="html"><![CDATA[This is a follow-up to my earlier post on Screen Sharing on Sway/Wayland with Guix. I’ve since switched from Sway to Niri — a scrollable-tiling Wayland compositor — and the screen sharing setup is a bit different.]]></summary></entry><entry><title type="html">Web Rendering in Iced — What Actually Works</title><link href="https://gofranz.com/blog/web-rendering-in-iced-what-actually-works/" rel="alternate" type="text/html" title="Web Rendering in Iced — What Actually Works" /><published>2026-02-23T12:00:00+00:00</published><updated>2026-02-23T12:00:00+00:00</updated><id>https://gofranz.com/blog/web-rendering-in-iced-what-actually-works</id><content type="html" xml:base="https://gofranz.com/blog/web-rendering-in-iced-what-actually-works/"><![CDATA[<p>Rendering web content sounds easy at first. You take some HTML and CSS, draw it on screen, handle a few clicks. But the moment you try to do this inside a native GUI toolkit — without a browser — things get complicated fast.</p>

<p><a href="https://iced.rs/">Iced</a> is a Rust GUI framework that’s been gaining traction. It’s declarative, Elm-inspired, and compiles to native code. What it doesn’t have is a built-in way to render HTML. I wanted to fix that.</p>

<p>I picked up an existing project that had integrated Ultralight — a proprietary rendering engine — and started building <a href="https://crates.io/crates/iced_webview_v2">iced_webview</a>. I ended up integrating four different rendering backends, which gave me a pretty good picture of where web rendering in Rust actually stands.</p>

<h2 id="the-backends">The Backends</h2>

<h3 id="1-litehtml--the-lightweight-option">1. litehtml — The Lightweight Option</h3>

<p><img src="/assets/images/blog/web-rendering-in-iced_litehtml.png" alt="litehtml" /></p>

<p><a href="http://www.litehtml.com/">litehtml</a> is a lightweight C++ HTML/CSS renderer. I <a href="https://github.com/franzos/litehtml-rs">built Rust bindings</a> for it and published them to crates.io — so it’s a pure crates.io dependency, just <code class="language-plaintext highlighter-rouge">cargo build</code> and go.</p>

<p>I built out HTTP fetching, image loading, link navigation, text selection, and CSS <code class="language-plaintext highlighter-rouge">@import</code> resolution on top of it. For simple content, it’s pretty decent. Think HTML emails, documentation pages, basic content display.</p>

<p>The thing is — litehtml renders a subset of CSS. It has basic flexbox support but no grid, no JavaScript. It draws the full document to a pixel buffer, and the Iced widget handles scrolling from there.</p>

<p>Things like litehtml are lightweight and capable — until you need anything more complex. For a help viewer or a formatted content panel, it’s a solid choice. For anything resembling a modern web page, you’ll hit walls fast.</p>

<div class="language-toml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nn">[dependencies]</span>
<span class="nn">iced_webview_v2</span> <span class="o">=</span> <span class="p">{</span> <span class="py">version</span> <span class="p">=</span> <span class="s">"0.1"</span><span class="p">,</span> <span class="py">features</span> <span class="p">=</span> <span class="nn">["litehtml"]</span> <span class="p">}</span>
</code></pre></div></div>

<h3 id="2-blitz--pure-rust-modern-css">2. Blitz — Pure Rust, Modern CSS</h3>

<p><img src="/assets/images/blog/web-rendering-in-iced_blitz.png" alt="Blitz" /></p>

<p><a href="https://github.com/DioxusLabs/blitz">Blitz</a> is DioxusLabs’ Rust-native renderer. It uses Firefox’s Stylo engine for CSS resolution and Taffy for layout. That means flexbox and grid support out of the box, all in pure Rust.</p>

<p>Like litehtml, Blitz renders the full document to a buffer and the widget handles scrolling. No JavaScript, no incremental rendering, and <code class="language-plaintext highlighter-rouge">:hover</code> CSS styles aren’t visually applied — the hover state is tracked (cursor changes work), but the full CPU re-render is skipped for performance. For static content with modern layouts, it’s a significant step up from litehtml.</p>

<p>Blitz is a git-only dependency — you need to build from the repo, not crates.io:</p>

<div class="language-toml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nn">[dependencies]</span>
<span class="nn">iced_webview_v2</span> <span class="o">=</span> <span class="p">{</span> <span class="py">version</span> <span class="p">=</span> <span class="s">"0.1"</span><span class="p">,</span> <span class="py">default-features</span> <span class="p">=</span> <span class="kc">false</span><span class="p">,</span> <span class="py">features</span> <span class="p">=</span> <span class="nn">["blitz"]</span> <span class="p">}</span>
</code></pre></div></div>

<h3 id="3-servo--the-full-browser-engine">3. Servo — The Full Browser Engine</h3>

<p><img src="/assets/images/blog/web-rendering-in-iced_servo.png" alt="Servo" /></p>

<p><a href="https://servo.org/">Servo</a> is an experimental browser engine, originally from Mozilla Research and now hosted at Linux Foundation Europe. HTML5, CSS3, JavaScript via SpiderMonkey — the full stack. I thought Servo would be the answer. It turns out the story is more nuanced than that.</p>

<p>Servo required a fundamentally different rendering approach. Instead of rendering to a pixel buffer that the widget scrolls, Servo manages its own viewport — I had to build a shader widget that uploads the engine’s pixel buffers to a persistent GPU texture whenever Servo signals a new frame is ready.</p>

<p>When it works, it works great. Pages render correctly, JavaScript executes, the layout matches what you’d expect from a browser. But SpiderMonkey crashes on pages with heavy JavaScript. Not every time - just often enough that you can’t ship it for general use.</p>

<p>On top of that, Servo pulls in a massive dependency tree and produces binaries in the 50-150 MB range. Text selection works visually within Servo (including Ctrl+C via its internal clipboard handling), but the selection can’t be queried from the embedding API. And since both Servo and Blitz depend on Stylo, I had to use Blitz from source instead of crates.io to get matching versions.</p>

<p>Like Blitz, Servo is a git-only dependency — build from the repo:</p>

<div class="language-toml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nn">[dependencies]</span>
<span class="nn">iced_webview_v2</span> <span class="o">=</span> <span class="p">{</span> <span class="py">version</span> <span class="p">=</span> <span class="s">"0.1"</span><span class="p">,</span> <span class="py">default-features</span> <span class="p">=</span> <span class="kc">false</span><span class="p">,</span> <span class="py">features</span> <span class="p">=</span> <span class="nn">["servo"]</span> <span class="p">}</span>
</code></pre></div></div>

<h3 id="4-cef--chromium--the-pragmatic-choice">4. CEF / Chromium — The Pragmatic Choice</h3>

<p><img src="/assets/images/blog/web-rendering-in-iced_cef.png" alt="CEF" /></p>

<p>The <a href="https://bitbucket.org/chromiumembedded/cef">Chromium Embedded Framework</a>, accessed through Tauri’s <code class="language-plaintext highlighter-rouge">cef-rs</code> bindings, is the fourth backend. It’s not Rust-native. It downloads 200-300 MB of Chromium binaries at build time. It runs a multi-process architecture that requires subprocess handling. And it’s the only option right now if you need full web compatibility in Iced without proprietary licensing.</p>

<p>Like Servo, CEF uses the shader widget path — off-screen rendering mode where the engine manages scrolling and sends viewport-sized frames to the GPU. I had to disable GPU compositing for OSR mode and handle Wayland detection to get it running reliably. On Guix, I created a separate manifest with FHS emulation because CEF’s binaries expect standard library paths.</p>

<p>It’s heavy. It’s not elegant. But it renders everything.</p>

<div class="language-toml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nn">[dependencies]</span>
<span class="nn">iced_webview_v2</span> <span class="o">=</span> <span class="p">{</span> <span class="py">version</span> <span class="p">=</span> <span class="s">"0.1"</span><span class="p">,</span> <span class="py">features</span> <span class="p">=</span> <span class="nn">["cef"]</span> <span class="p">}</span>
</code></pre></div></div>

<h2 id="two-rendering-paths">Two Rendering Paths</h2>

<p>There are two fundamentally different rendering approaches across these backends:</p>

<ol>
  <li><strong>Image handle path</strong> (litehtml, Blitz): The engine renders the entire document to a pixel buffer. The Iced widget handles scrolling by adjusting which portion of the buffer is visible. Simple, predictable, easy to integrate.</li>
  <li><strong>Shader widget path</strong> (Servo, CEF): The engine manages its own viewport and scrolling. The widget receives viewport-sized frames via direct GPU texture updates.</li>
</ol>

<p>The split exists because lightweight engines are just layout libraries — they produce pixels and that’s it. Full browser engines have their own event loops, compositing, and need to own the viewport for things like JS scroll events, <code class="language-plaintext highlighter-rouge">position: fixed</code>, and sticky elements.</p>

<p>The <code class="language-plaintext highlighter-rouge">Engine</code> trait abstraction in <code class="language-plaintext highlighter-rouge">iced_webview</code> handles this difference. You swap backends with a feature flag and the widget handles whichever path the backend uses.</p>

<h2 id="comparison">Comparison</h2>

<table>
  <thead>
    <tr>
      <th> </th>
      <th>litehtml</th>
      <th>Blitz</th>
      <th>Servo</th>
      <th>CEF</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><strong>CSS Support</strong></td>
      <td>Basic (flexbox, no grid)</td>
      <td>Modern (flexbox, grid)</td>
      <td>Full CSS3</td>
      <td>Full CSS3</td>
    </tr>
    <tr>
      <td><strong>JavaScript</strong></td>
      <td>No</td>
      <td>No</td>
      <td>Yes (SpiderMonkey)</td>
      <td>Yes (V8)</td>
    </tr>
    <tr>
      <td><strong>Rendering</strong></td>
      <td>Buffer + widget scroll</td>
      <td>Buffer + widget scroll</td>
      <td>Shader / GPU texture</td>
      <td>Shader / GPU texture</td>
    </tr>
    <tr>
      <td><strong>Binary Size</strong></td>
      <td>Small</td>
      <td>Moderate</td>
      <td>50-150 MB</td>
      <td>200-300 MB</td>
    </tr>
    <tr>
      <td><strong>Rust Toolchain</strong></td>
      <td>Stable (1.90+)</td>
      <td>Stable (1.90+)</td>
      <td>Stable (1.90+)</td>
      <td>Stable (1.90+)</td>
    </tr>
    <tr>
      <td><strong>crates.io</strong></td>
      <td>Yes</td>
      <td>No (git dep)</td>
      <td>No (git dep)</td>
      <td>Yes</td>
    </tr>
    <tr>
      <td><strong>Stability</strong></td>
      <td>Stable</td>
      <td>Stable</td>
      <td>Crashes (JS-heavy pages)</td>
      <td>Stable</td>
    </tr>
    <tr>
      <td><strong>Text Selection</strong></td>
      <td>Yes</td>
      <td>In blitz-dom, not yet wired</td>
      <td>Yes (engine-managed, not queryable)</td>
      <td>Yes</td>
    </tr>
  </tbody>
</table>

<h2 id="where-things-stand">Where Things Stand</h2>

<p>I’ve published <code class="language-plaintext highlighter-rouge">iced_webview</code> to <a href="https://crates.io/crates/iced_webview_v2">crates.io</a> with litehtml as the default backend. It’s the only option for a pure crates.io dependency. For anything beyond basic HTML/CSS, you opt into Blitz, Servo, or CEF via feature flags.</p>

<p>Litehtml is good enough for simple content. Blitz covers the modern CSS gap without leaving the Rust ecosystem. Servo has the most promise long-term but isn’t stable enough yet. CEF is the pragmatic choice when you need things to just work.</p>

<p>If you’re building an Iced application that needs to display web content, you now have options. Do take these with a grain of salt — this is early-stage work, and each backend has its rough edges and things are changing fast.</p>

<p>Check out the <a href="https://github.com/franzos/iced_webview_v2">GitHub repo</a> or grab it from <a href="https://crates.io/crates/iced_webview_v2">crates.io</a>.</p>]]></content><author><name>Franz Geffke</name></author><category term="[&quot;Tools&quot;]" /><category term="rust" /><category term="iced" /><category term="gui" /><category term="webview" /><summary type="html"><![CDATA[Rendering web content sounds easy at first. You take some HTML and CSS, draw it on screen, handle a few clicks. But the moment you try to do this inside a native GUI toolkit — without a browser — things get complicated fast.]]></summary></entry><entry><title type="html">Run an IOTA Node on Guix</title><link href="https://gofranz.com/blog/run-an-iota-node-on-guix/" rel="alternate" type="text/html" title="Run an IOTA Node on Guix" /><published>2026-01-27T00:00:00+00:00</published><updated>2026-01-27T00:00:00+00:00</updated><id>https://gofranz.com/blog/run-an-iota-node-on-guix</id><content type="html" xml:base="https://gofranz.com/blog/run-an-iota-node-on-guix/"><![CDATA[<p>Here’s how to get a full node running.</p>

<h2 id="networks">Networks</h2>

<p>IOTA runs three networks:</p>

<table>
  <thead>
    <tr>
      <th>Network</th>
      <th>RPC Endpoint</th>
      <th>Use</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Mainnet</td>
      <td><code class="language-plaintext highlighter-rouge">https://api.mainnet.iota.cafe:443</code></td>
      <td>Production</td>
    </tr>
    <tr>
      <td>Testnet</td>
      <td><code class="language-plaintext highlighter-rouge">https://api.testnet.iota.cafe:443</code></td>
      <td>Pre-production</td>
    </tr>
    <tr>
      <td>Devnet</td>
      <td><code class="language-plaintext highlighter-rouge">https://api.devnet.iota.cafe:443</code></td>
      <td>Development</td>
    </tr>
  </tbody>
</table>

<h2 id="setup">Setup</h2>

<p>Create the config directory and get the starter config:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">mkdir</span> <span class="nt">-p</span> /etc/iota <span class="o">&amp;&amp;</span> <span class="nb">cd</span> /etc/iota
curl <span class="nt">-L</span> https://fullnode-docker-setup.iota.org/mainnet | <span class="nb">tar</span> <span class="nt">-zx</span>
<span class="nb">mv </span>data/config/fullnode.yaml <span class="nb">.</span>
</code></pre></div></div>

<p>Download the genesis and migration files:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># mainnet, testnet</span>
curl <span class="nt">-fLJ</span> https://dbfiles.mainnet.iota.cafe/genesis.blob <span class="nt">-o</span> genesis.blob
<span class="c"># mainnet</span>
curl <span class="nt">-fLJ</span> https://dbfiles.mainnet.iota.cafe/migration.blob <span class="nt">-o</span> migration.blob
</code></pre></div></div>

<p>For testnet, replace <code class="language-plaintext highlighter-rouge">mainnet</code> with <code class="language-plaintext highlighter-rouge">testnet</code> in the URLs above. Testnet doesn’t need the <code class="language-plaintext highlighter-rouge">migration.blob</code>.</p>

<h2 id="configuration">Configuration</h2>

<p>The default config uses <code class="language-plaintext highlighter-rouge">/opt/iota</code> which is a Docker convention. On Guix, we use <code class="language-plaintext highlighter-rouge">/var/lib</code> for service data. Edit <code class="language-plaintext highlighter-rouge">/etc/iota/fullnode.yaml</code>:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># Database</span>
<span class="na">db-path</span><span class="pi">:</span> <span class="s2">"</span><span class="s">/var/lib/iota/db"</span>

<span class="c1"># Genesis files</span>
<span class="na">genesis</span><span class="pi">:</span>
  <span class="na">genesis-file-location</span><span class="pi">:</span> <span class="s2">"</span><span class="s">/etc/iota/genesis.blob"</span>
<span class="na">migration-tx-data-path</span><span class="pi">:</span> <span class="s2">"</span><span class="s">/etc/iota/migration.blob"</span>  <span class="c1"># mainnet only</span>

<span class="c1"># Your public hostname - peers need this to find you</span>
<span class="na">p2p-config</span><span class="pi">:</span>
  <span class="na">external-address</span><span class="pi">:</span> <span class="s">/dns/your-hostname.example.com/udp/8084</span>
</code></pre></div></div>

<p>Leave the rest (seed peers, archive fallback) as-is.</p>

<h2 id="ports">Ports</h2>

<p>Make sure these are open:</p>

<table>
  <thead>
    <tr>
      <th>Port</th>
      <th>Protocol</th>
      <th>Purpose</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>8084</td>
      <td>UDP</td>
      <td>P2P sync</td>
    </tr>
    <tr>
      <td>9000</td>
      <td>TCP</td>
      <td>JSON-RPC</td>
    </tr>
  </tbody>
</table>

<h2 id="test-run">Test Run</h2>

<p>Before setting up the service, you can run the node manually to make sure everything works:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>guix shell iota <span class="nt">--</span> iota-node <span class="nt">--config-path</span> /etc/iota/fullnode.yaml
</code></pre></div></div>

<p>Or with debug logging:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>guix shell iota <span class="nt">--</span> sh <span class="nt">-c</span> <span class="s1">'RUST_LOG="info,iota_core=debug,consensus=debug" iota-node --config-path /etc/iota/fullnode.yaml'</span>
</code></pre></div></div>

<p>Once you see checkpoints coming in, you’re good to set up the service.</p>

<h2 id="system-service">System Service</h2>

<p>Add the service:</p>

<div class="language-scheme highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">(</span><span class="nf">use-modules</span> <span class="p">(</span><span class="nf">px</span> <span class="nv">services</span> <span class="nv">iota</span><span class="p">))</span>

<span class="p">(</span><span class="nf">services</span>
 <span class="p">(</span><span class="nf">cons*</span> <span class="p">(</span><span class="nf">service</span> <span class="nv">iota-node-service-type</span>
                 <span class="p">(</span><span class="nf">iota-node-configuration</span>
                  <span class="p">(</span><span class="nf">config-file</span> <span class="s">"/etc/iota/fullnode.yaml"</span><span class="p">)))</span>
        <span class="nv">%base-services</span><span class="p">))</span>
</code></pre></div></div>

<p>The service creates an <code class="language-plaintext highlighter-rouge">iota</code> user, sets up directories, and handles log rotation.</p>

<h2 id="start">Start</h2>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">sudo </span>guix system reconfigure /etc/system.scm
</code></pre></div></div>

<p>The node starts automatically. Check on it:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">sudo </span>herd status iota-node
<span class="nb">sudo tail</span> <span class="nt">-f</span> /var/log/iota-node.log
</code></pre></div></div>

<h2 id="verify">Verify</h2>

<p>Query the node to see if it’s syncing:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>curl <span class="nt">-X</span> POST http://localhost:9000 <span class="se">\</span>
  <span class="nt">-H</span> <span class="s2">"Content-Type: application/json"</span> <span class="se">\</span>
  <span class="nt">-d</span> <span class="s1">'{"jsonrpc":"2.0","method":"iota_getLatestCheckpointSequenceNumber","id":1}'</span>
</code></pre></div></div>

<p>The checkpoint number should increase over time. Initial sync from genesis is slow - IOTA provides snapshots at <a href="https://dbfiles.iota.org">dbfiles.iota.org</a> if you want to speed things up.</p>

<h2 id="cli">CLI</h2>

<p>The package also includes the <code class="language-plaintext highlighter-rouge">iota</code> CLI:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>guix shell iota <span class="nt">--</span> iota client
</code></pre></div></div>

<hr />

<p>For validator nodes (staking, key generation), see the <a href="https://docs.iota.org/operator">IOTA operator docs</a>.<br />
The IOTA package is available from my Guix channel: <a href="https://codeberg.org/gofranz/panther">panther</a>.</p>]]></content><author><name>Franz Geffke</name></author><category term="[&quot;Tools&quot;]" /><category term="blockchain" /><category term="iota" /><category term="guix" /><summary type="html"><![CDATA[Here’s how to get a full node running.]]></summary></entry><entry><title type="html">Build React Native Android Apps on Guix in 5 Minutes</title><link href="https://gofranz.com/blog/react-native-android-on-guix-without-docker/" rel="alternate" type="text/html" title="Build React Native Android Apps on Guix in 5 Minutes" /><published>2026-01-12T00:00:00+00:00</published><updated>2026-01-12T00:00:00+00:00</updated><id>https://gofranz.com/blog/react-native-android-on-guix-without-docker</id><content type="html" xml:base="https://gofranz.com/blog/react-native-android-on-guix-without-docker/"><![CDATA[<p>In a <a href="/blog/react-native-on-guix">previous post</a>, I shared how to set up React Native development on Guix using Docker. It works, but there’s a simpler way.</p>

<p>Guix has a neat trick: <code class="language-plaintext highlighter-rouge">guix shell --container --emulate-fhs</code>. Android SDK binaries expect libraries at standard paths like <code class="language-plaintext highlighter-rouge">/lib64/ld-linux-x86-64.so.2</code>, which don’t exist on Guix. The <code class="language-plaintext highlighter-rouge">--emulate-fhs</code> flag creates these paths, so cmake, ninja, and the NDK toolchain just work. No Docker required.</p>

<h2 id="setup">Setup</h2>

<p>First, download the Android SDK. Guix packages <code class="language-plaintext highlighter-rouge">sdkmanager</code> from the F-Droid project:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">export </span><span class="nv">ANDROID_HOME</span><span class="o">=</span><span class="nv">$PWD</span>/android-sdk
<span class="nb">mkdir</span> <span class="nt">-p</span> <span class="nv">$ANDROID_HOME</span>

guix shell sdkmanager <span class="nt">--</span> sh <span class="nt">-c</span> <span class="s2">"
  export ANDROID_HOME='</span><span class="nv">$ANDROID_HOME</span><span class="s2">'
  echo y | sdkmanager --licenses
  sdkmanager 'platforms;android-36' 'build-tools;35.0.0' 'ndk;27.1.12297006'
"</span>
</code></pre></div></div>

<h2 id="build">Build</h2>

<p>Now build your APK or AAB:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>guix shell <span class="nt">--container</span> <span class="nt">--emulate-fhs</span> <span class="nt">--network</span> <span class="nt">--pure</span> <span class="se">\</span>
  <span class="nt">--share</span><span class="o">=</span><span class="s2">"</span><span class="nv">$HOME</span><span class="s2">/.gradle"</span><span class="o">=</span><span class="s2">"</span><span class="nv">$HOME</span><span class="s2">/.gradle"</span> <span class="se">\</span>
  <span class="nt">--share</span><span class="o">=</span><span class="s2">"</span><span class="nv">$ANDROID_HOME</span><span class="s2">"</span><span class="o">=</span><span class="s2">"</span><span class="nv">$ANDROID_HOME</span><span class="s2">"</span> <span class="se">\</span>
  <span class="nt">--share</span><span class="o">=</span><span class="s2">"</span><span class="nv">$PWD</span><span class="s2">"</span><span class="o">=</span><span class="s2">"</span><span class="nv">$PWD</span><span class="s2">"</span> <span class="se">\</span>
  openjdk@17:jdk node pnpm coreutils bash <span class="nb">grep sed</span> <span class="se">\</span>
  glibc gcc-toolchain zlib which findutils diffutils <span class="se">\</span>
  <span class="nt">--</span> sh <span class="nt">-c</span> <span class="s2">"
    cd '</span><span class="nv">$PWD</span><span class="s2">'
    export ANDROID_HOME='</span><span class="nv">$ANDROID_HOME</span><span class="s2">'
    export JAVA_HOME=</span><span class="se">\$</span><span class="s2">(dirname </span><span class="se">\$</span><span class="s2">(dirname </span><span class="se">\$</span><span class="s2">(readlink -f </span><span class="se">\$</span><span class="s2">(which java))))
    unset C_INCLUDE_PATH CPLUS_INCLUDE_PATH CPATH
    pnpm install
    cd android &amp;&amp; ./gradlew --no-daemon assembleRelease
"</span>
</code></pre></div></div>

<p>Output: <code class="language-plaintext highlighter-rouge">android/app/build/outputs/apk/release/app-release.apk</code></p>

<p>For an AAB (Play Store), use <code class="language-plaintext highlighter-rouge">bundleRelease</code> instead of <code class="language-plaintext highlighter-rouge">assembleRelease</code>.</p>

<h2 id="run-on-device">Run on Device</h2>

<p>For live development with hot reload, you need three things: ADB server on the host, Metro bundler, and the app running on your device.</p>

<p>First, download platform-tools and start the ADB server on your host:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>guix shell sdkmanager <span class="nt">--</span> sh <span class="nt">-c</span> <span class="s2">"
  export ANDROID_HOME='</span><span class="nv">$ANDROID_HOME</span><span class="s2">'
  sdkmanager 'platform-tools'
"</span>

<span class="nv">$ANDROID_HOME</span>/platform-tools/adb start-server
<span class="nv">$ANDROID_HOME</span>/platform-tools/adb devices  <span class="c"># verify device is connected</span>
</code></pre></div></div>

<p>Start Metro bundler in one terminal:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>guix shell node pnpm <span class="nt">--</span> npx react-native start
</code></pre></div></div>

<p>Run on device in another terminal:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>guix shell <span class="nt">--container</span> <span class="nt">--emulate-fhs</span> <span class="nt">--network</span> <span class="nt">--pure</span> <span class="se">\</span>
  <span class="nt">--share</span><span class="o">=</span><span class="s2">"</span><span class="nv">$HOME</span><span class="s2">/.gradle"</span><span class="o">=</span><span class="s2">"</span><span class="nv">$HOME</span><span class="s2">/.gradle"</span> <span class="se">\</span>
  <span class="nt">--share</span><span class="o">=</span><span class="s2">"</span><span class="nv">$ANDROID_HOME</span><span class="s2">"</span><span class="o">=</span><span class="s2">"</span><span class="nv">$ANDROID_HOME</span><span class="s2">"</span> <span class="se">\</span>
  <span class="nt">--share</span><span class="o">=</span><span class="s2">"</span><span class="nv">$PWD</span><span class="s2">"</span><span class="o">=</span><span class="s2">"</span><span class="nv">$PWD</span><span class="s2">"</span> <span class="se">\</span>
  <span class="nt">--share</span><span class="o">=</span>/tmp<span class="o">=</span>/tmp <span class="se">\</span>
  openjdk@17:jdk node pnpm coreutils bash <span class="nb">grep sed</span> <span class="se">\</span>
  glibc gcc-toolchain zlib which findutils diffutils <span class="se">\</span>
  <span class="nt">--</span> sh <span class="nt">-c</span> <span class="s2">"
    cd '</span><span class="nv">$PWD</span><span class="s2">'
    export ANDROID_HOME='</span><span class="nv">$ANDROID_HOME</span><span class="s2">'
    export JAVA_HOME=</span><span class="se">\$</span><span class="s2">(dirname </span><span class="se">\$</span><span class="s2">(dirname </span><span class="se">\$</span><span class="s2">(readlink -f </span><span class="se">\$</span><span class="s2">(which java))))
    export ADB_SERVER_SOCKET=tcp:localhost:5037
    unset C_INCLUDE_PATH CPLUS_INCLUDE_PATH CPATH
    npx react-native run-android
"</span>
</code></pre></div></div>

<p>The key additions here:</p>
<ul>
  <li><code class="language-plaintext highlighter-rouge">--share=/tmp=/tmp</code> shares the tmp directory where ADB stores socket info</li>
  <li><code class="language-plaintext highlighter-rouge">ADB_SERVER_SOCKET=tcp:localhost:5037</code> tells ADB inside the container to connect to the host’s running server instead of starting its own</li>
</ul>

<p>The app will build, install to your device, and connect to Metro for live reload.</p>

<h2 id="whats-happening">What’s happening?</h2>

<p>A few key flags make this work:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">--emulate-fhs</code> creates <code class="language-plaintext highlighter-rouge">/lib64/ld-linux-x86-64.so.2</code> and standard FHS paths that Android SDK binaries expect</li>
  <li><code class="language-plaintext highlighter-rouge">--pure</code> gives you a clean environment without inherited variables</li>
  <li><code class="language-plaintext highlighter-rouge">--network</code> allows Gradle to download dependencies</li>
  <li><code class="language-plaintext highlighter-rouge">unset C_INCLUDE_PATH</code> is extra insurance against header conflicts</li>
</ul>

<p>The container shares your gradle cache, SDK, and project directory, so subsequent builds are fast.</p>

<h2 id="signed-release">Signed Release</h2>

<p>For a signed release, pass the keystore details as Gradle properties:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>guix shell <span class="nt">--container</span> <span class="nt">--emulate-fhs</span> <span class="nt">--network</span> <span class="nt">--pure</span> <span class="se">\</span>
  <span class="nt">--share</span><span class="o">=</span><span class="s2">"</span><span class="nv">$HOME</span><span class="s2">/.gradle"</span><span class="o">=</span><span class="s2">"</span><span class="nv">$HOME</span><span class="s2">/.gradle"</span> <span class="se">\</span>
  <span class="nt">--share</span><span class="o">=</span><span class="s2">"</span><span class="nv">$ANDROID_HOME</span><span class="s2">"</span><span class="o">=</span><span class="s2">"</span><span class="nv">$ANDROID_HOME</span><span class="s2">"</span> <span class="se">\</span>
  <span class="nt">--share</span><span class="o">=</span><span class="s2">"</span><span class="nv">$PWD</span><span class="s2">"</span><span class="o">=</span><span class="s2">"</span><span class="nv">$PWD</span><span class="s2">"</span> <span class="se">\</span>
  <span class="nt">--share</span><span class="o">=</span><span class="s2">"/path/to/keys"</span><span class="o">=</span><span class="s2">"/path/to/keys"</span> <span class="se">\</span>
  openjdk@17:jdk node pnpm coreutils bash <span class="nb">grep sed</span> <span class="se">\</span>
  glibc gcc-toolchain zlib which findutils diffutils <span class="se">\</span>
  <span class="nt">--</span> sh <span class="nt">-c</span> <span class="s2">"
    cd '</span><span class="nv">$PWD</span><span class="s2">'
    export ANDROID_HOME='</span><span class="nv">$ANDROID_HOME</span><span class="s2">'
    export JAVA_HOME=</span><span class="se">\$</span><span class="s2">(dirname </span><span class="se">\$</span><span class="s2">(dirname </span><span class="se">\$</span><span class="s2">(readlink -f </span><span class="se">\$</span><span class="s2">(which java))))
    unset C_INCLUDE_PATH CPLUS_INCLUDE_PATH CPATH
    pnpm install
    cd android &amp;&amp; ./gradlew --no-daemon </span><span class="se">\</span><span class="s2">
      -PMYAPP_UPLOAD_STORE_FILE=/path/to/keys/release.keystore </span><span class="se">\</span><span class="s2">
      -PMYAPP_UPLOAD_STORE_PASSWORD=yourpassword </span><span class="se">\</span><span class="s2">
      -PMYAPP_UPLOAD_KEY_ALIAS=youralias </span><span class="se">\</span><span class="s2">
      -PMYAPP_UPLOAD_KEY_PASSWORD=yourpassword </span><span class="se">\</span><span class="s2">
      -PuseReleaseSigning=true </span><span class="se">\</span><span class="s2">
      bundleRelease
"</span>
</code></pre></div></div>

<h2 id="troubleshooting">Troubleshooting</h2>

<p><strong>Gradle permission errors</strong>: If you’ve previously run Docker builds as root, the gradle cache may have wrong permissions:</p>
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">sudo rm</span> <span class="nt">-rf</span> ~/.gradle/native/
</code></pre></div></div>

<p><strong>CMake crashes (exit 139)</strong>: Re-download the SDK cmake:</p>
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">rm</span> <span class="nt">-rf</span> android-sdk/cmake
<span class="c"># It re-downloads on next build</span>
</code></pre></div></div>

<p><strong>Header conflicts / stubs-32.h errors</strong>: Make sure you’re using <code class="language-plaintext highlighter-rouge">--pure</code> and unsetting the C include paths.</p>

<h2 id="conclusion">Conclusion</h2>

<p>This approach eliminates Docker from the React Native build process on Guix. The FHS container gives you the compatibility you need, while keeping everything in user space. Build times are comparable to Docker, and you have one less layer to debug.</p>]]></content><author><name>Franz Geffke</name></author><category term="[&quot;Tools&quot;]" /><category term="guix" /><category term="react-native" /><category term="android" /><summary type="html"><![CDATA[In a previous post, I shared how to set up React Native development on Guix using Docker. It works, but there’s a simpler way.]]></summary></entry><entry><title type="html">The Perfect Linux Setup: Guix on a Framework Laptop</title><link href="https://gofranz.com/blog/the-perfect-linux-setup-guix-framework/" rel="alternate" type="text/html" title="The Perfect Linux Setup: Guix on a Framework Laptop" /><published>2026-01-02T01:00:00+00:00</published><updated>2026-01-02T01:00:00+00:00</updated><id>https://gofranz.com/blog/the-perfect-linux-setup-guix-framework</id><content type="html" xml:base="https://gofranz.com/blog/the-perfect-linux-setup-guix-framework/"><![CDATA[<p>After years of configuration tweaking, I think I’ve finally landed on a setup that feels complete. Everything works together. My Framework laptop (AMD) boots to a fingerprint scan, I sudo with a touch of my YubiKey, themes switch automatically at sunset, and my entire configuration syncs effortlessly between this, and my older ThinkPad.</p>

<p>Here’s the stack that makes it happen.</p>

<h2 id="guix-system-and-home">Guix System and Home</h2>

<p><a href="https://guix.gnu.org/">GNU Guix</a> is both the operating system and the secret sauce. Unlike traditional distros where configuration sprawls across <code class="language-plaintext highlighter-rouge">/etc</code>, shell scripts, and <code class="language-plaintext highlighter-rouge">.config</code> files, Guix declares everything in Scheme. The entire system state - services, packages, dotfiles - lives in version-controlled <code class="language-plaintext highlighter-rouge">.scm</code> files.</p>

<p>This makes sharing configuration between machines trivial. My ThinkPad and Framework both inherit from a common base:</p>

<div class="language-scheme highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">(</span><span class="nf">use-modules</span> <span class="p">(</span><span class="nf">px</span> <span class="nv">system</span> <span class="nv">config</span><span class="p">))</span>

<span class="p">(</span><span class="nf">px-desktop-os</span>
 <span class="p">(</span><span class="nf">operating-system</span>
  <span class="p">(</span><span class="nf">host-name</span> <span class="s">"framework"</span><span class="p">)</span>
  <span class="p">(</span><span class="nf">timezone</span> <span class="s">"Europe/Lisbon"</span><span class="p">)</span>
  <span class="c1">;; Machine-specific bits go here</span>
  <span class="p">))</span>
</code></pre></div></div>

<p>The machine-specific stuff (AMD vs Intel GPU, LUKS encryption, power profiles) layers on top.</p>

<h2 id="fingerprint-login">Fingerprint Login</h2>

<p>The Framework’s fingerprint reader works with <code class="language-plaintext highlighter-rouge">fprintd</code>, but requires PAM configuration to actually use it for login. In Guix, this means adding a PAM extension:</p>

<div class="language-scheme highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">(</span><span class="nf">simple-service</span> <span class="ss">'fprintd-pam-login</span>
  <span class="nv">pam-root-service-type</span>
  <span class="p">(</span><span class="nb">list</span> <span class="p">(</span><span class="nf">pam-extension</span>
         <span class="p">(</span><span class="nf">transformer</span>
          <span class="p">(</span><span class="k">lambda</span> <span class="p">(</span><span class="nf">pam</span><span class="p">)</span>
            <span class="p">(</span><span class="k">if</span> <span class="p">(</span><span class="nb">member</span> <span class="p">(</span><span class="nf">pam-service-name</span> <span class="nv">pam</span><span class="p">)</span> <span class="o">'</span><span class="p">(</span><span class="s">"greetd"</span> <span class="s">"swaylock"</span><span class="p">))</span>
                <span class="p">(</span><span class="nf">pam-service</span>
                 <span class="p">(</span><span class="nf">inherit</span> <span class="nv">pam</span><span class="p">)</span>
                 <span class="p">(</span><span class="nf">auth</span> <span class="p">(</span><span class="nb">cons</span> <span class="p">(</span><span class="nf">pam-entry</span>
                              <span class="p">(</span><span class="nf">control</span> <span class="s">"sufficient"</span><span class="p">)</span>
                              <span class="p">(</span><span class="nf">module</span> <span class="p">(</span><span class="nf">file-append</span> <span class="nv">fprintd</span> <span class="s">"/lib/security/pam_fprintd.so"</span><span class="p">)))</span>
                             <span class="p">(</span><span class="nf">pam-service-auth</span> <span class="nv">pam</span><span class="p">))))</span>
                <span class="nv">pam</span><span class="p">))))))</span>
</code></pre></div></div>

<p>After enrolling prints with <code class="language-plaintext highlighter-rouge">fprintd-enroll</code>, login and screen unlock accept my fingerprint.</p>

<h2 id="sudo-with-yubikey">Sudo with YubiKey</h2>

<p>For elevated commands, I’ve configured PAM to accept a YubiKey challenge-response instead of typing a password. First, program slot 2 with a challenge-response credential:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>guix shell python-yubikey-manager <span class="nt">--</span> ykman otp chalresp <span class="nt">--touch</span> 2 &lt;your-secret-hex&gt;
</code></pre></div></div>

<p>Then add the PAM extension for sudo:</p>

<div class="language-scheme highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">(</span><span class="nf">simple-service</span> <span class="ss">'yubico-pam-sudo</span>
  <span class="nv">pam-root-service-type</span>
  <span class="p">(</span><span class="nb">list</span> <span class="p">(</span><span class="nf">pam-extension</span>
         <span class="p">(</span><span class="nf">transformer</span>
          <span class="p">(</span><span class="k">lambda</span> <span class="p">(</span><span class="nf">pam</span><span class="p">)</span>
            <span class="p">(</span><span class="k">if</span> <span class="p">(</span><span class="nb">member</span> <span class="p">(</span><span class="nf">pam-service-name</span> <span class="nv">pam</span><span class="p">)</span> <span class="o">'</span><span class="p">(</span><span class="s">"sudo"</span><span class="p">))</span>
                <span class="p">(</span><span class="nf">pam-service</span>
                 <span class="p">(</span><span class="nf">inherit</span> <span class="nv">pam</span><span class="p">)</span>
                 <span class="p">(</span><span class="nf">auth</span> <span class="p">(</span><span class="nb">cons</span> <span class="p">(</span><span class="nf">pam-entry</span>
                              <span class="p">(</span><span class="nf">control</span> <span class="s">"sufficient"</span><span class="p">)</span>
                              <span class="p">(</span><span class="nf">module</span> <span class="p">(</span><span class="nf">file-append</span> <span class="nv">yubico-pam</span> <span class="s">"/lib/security/pam_yubico.so"</span><span class="p">))</span>
                              <span class="p">(</span><span class="nf">arguments</span> <span class="o">'</span><span class="p">(</span><span class="s">"mode=challenge-response"</span><span class="p">)))</span>
                             <span class="p">(</span><span class="nf">pam-service-auth</span> <span class="nv">pam</span><span class="p">))))</span>
                <span class="nv">pam</span><span class="p">))))))</span>
</code></pre></div></div>

<p>Now <code class="language-plaintext highlighter-rouge">sudo</code> just needs a tap - more secure and more convenient than passwords.</p>

<h2 id="automatic-darklight-themes">Automatic Dark/Light Themes</h2>

<p>I <a href="/tools/2025/11/02/automatic-dark-light-theme-switching-sway-guix.html">wrote about this previously</a> - Darkman switches themes at sunrise and sunset. What’s changed since then is coverage. The theme switch now propagates to:</p>

<ul>
  <li><strong>GTK apps</strong> (Thunar, GNOME apps) via gsettings</li>
  <li><strong>Foot terminal</strong> with custom shell scripts</li>
  <li><strong>Dunst notifications</strong></li>
  <li><strong>VSCode</strong> via jq-based settings manipulation</li>
  <li><strong>Waybar</strong> with separate CSS files</li>
  <li><strong>Niri</strong> window manager focus rings</li>
  <li><strong>Swaylock</strong></li>
</ul>

<p>Everything shifts from light to dark at sunset without any manual intervention. The scripts live in <code class="language-plaintext highlighter-rouge">~/.local/share/dark-mode.d/</code> and <code class="language-plaintext highlighter-rouge">~/.local/share/light-mode.d/</code>.</p>

<h2 id="chrome-hardware-acceleration">Chrome Hardware Acceleration</h2>

<p>Getting Chrome to actually use the GPU on Linux takes some trial and error. I’ve got a custom <code class="language-plaintext highlighter-rouge">.desktop</code> launcher that forces Vulkan and VA-API:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>google-chrome <span class="se">\</span>
  <span class="nt">--use-angle</span><span class="o">=</span>vulkan <span class="se">\</span>
  <span class="nt">--enable-features</span><span class="o">=</span>VaapiVideoDecoder,VaapiVideoEncoder,VaapiIgnoreDriverChecks,Vulkan,VulkanFromANGLE,DefaultANGLEVulkan,UseMultiPlaneFormatForHardwareVideo
</code></pre></div></div>

<p>Combined with environment variables for the AMD GPU:</p>

<div class="language-scheme highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">(</span><span class="s">"LIBVA_DRIVER_NAME"</span> <span class="o">.</span> <span class="s">"radeonsi"</span><span class="p">)</span>
<span class="p">(</span><span class="s">"ANGLE_DEFAULT_PLATFORM"</span> <span class="o">.</span> <span class="s">"vulkan"</span><span class="p">)</span>
</code></pre></div></div>

<p>This enables things like blurred backgrounds in Google Meets and smooth video playback.</p>

<h2 id="bluetooth-audio-that-works">Bluetooth Audio That Works</h2>

<p>Bluetooth headphones on Linux have historically been painful. WirePlumber’s default configuration often picks the wrong codec or profile. I’ve tuned it for high-quality audio:</p>

<div class="language-lua highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">bluez_monitor</span><span class="p">.</span><span class="n">properties</span> <span class="o">=</span> <span class="p">{</span>
  <span class="p">[</span><span class="s2">"bluez5.enable-sbc-xq"</span><span class="p">]</span> <span class="o">=</span> <span class="kc">true</span><span class="p">,</span>
  <span class="p">[</span><span class="s2">"bluez5.enable-msbc"</span><span class="p">]</span> <span class="o">=</span> <span class="kc">true</span><span class="p">,</span>
  <span class="p">[</span><span class="s2">"bluez5.enable-hw-volume"</span><span class="p">]</span> <span class="o">=</span> <span class="kc">true</span><span class="p">,</span>
  <span class="p">[</span><span class="s2">"bluez5.codecs"</span><span class="p">]</span> <span class="o">=</span> <span class="s2">"[ sbc sbc_xq aac ]"</span><span class="p">,</span>
  <span class="p">[</span><span class="s2">"bluez5.profile"</span><span class="p">]</span> <span class="o">=</span> <span class="s2">"a2dp-sink"</span><span class="p">,</span>
<span class="p">}</span>
</code></pre></div></div>

<p>After initial pairing, my Nothing Ear (2024) Bluetooth headphones connect automatically, and the audio codec changes depending on whether I’m on a call, or listening to music.</p>

<h2 id="power-management">Power Management</h2>

<p>The Framework AMD runs the Power Profiles Daemon with aggressive power saving:</p>

<div class="language-scheme highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">(</span><span class="nf">kernel-arguments</span>
 <span class="o">'</span><span class="p">(</span><span class="s">"pcie_aspm.policy=powersupersave"</span>
   <span class="s">"amdgpu.ppfeaturemask=0xffffffff"</span><span class="p">))</span>
</code></pre></div></div>

<p>After 60 minutes of suspend, it hibernates to disk automatically.</p>

<p>On the ThinkPad, TLP handles the same job with Intel-specific tweaks.</p>

<h2 id="secrets-with-tomb">Secrets with Tomb</h2>

<p>Both machines need access to the same secrets - GPG keys, KeePass database, AWS credentials. I use <a href="https://dyne.org/software/tomb/">Tomb</a> to store these in an encrypted container that syncs between machines via Syncthing.</p>

<p>The tomb is encrypted with a GPG key stored on my YubiKey, so unlocking is just another tap:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>tomb open .secrets.tomb <span class="nt">-k</span> .secrets.tomb.key <span class="nt">-gR</span> A1B2C3D4E5F6G7H8 <span class="nt">-f</span>
</code></pre></div></div>

<p>Once mounted, bind mounts put everything in place - <code class="language-plaintext highlighter-rouge">~/.gnupg</code>, <code class="language-plaintext highlighter-rouge">~/.aws</code>, the KeePass database. Same secrets, same workflow, on either machine.</p>

<h2 id="whats-shared-whats-not">What’s Shared, What’s Not</h2>

<p>Both machines pull from the same dotfiles repo. Common configuration includes:</p>

<ul>
  <li>Darkman theme scripts</li>
  <li>Niri compositor config</li>
  <li>Mail stack (aerc + isync + msmtp)</li>
  <li>Bluetooth audio tuning</li>
  <li>GTK themes and fonts</li>
</ul>

<p>Machine-specific config stays in separate files:</p>

<ul>
  <li>GPU drivers and firmware</li>
  <li>Power management (TLP vs PPD)</li>
  <li>Display scaling (Framework’s 3:2 screen needs 1.5x)</li>
  <li>Encryption keys and hardware IDs</li>
</ul>

<p>Guix’s module system makes this separation clean.</p>

<h2 id="the-result">The Result</h2>

<p>My laptop boots, I scan my finger, and I’m in a consistent environment whether I’m on the Framework or ThinkPad. Updates pull automatically via unattended upgrades. Theme switches happen without thinking about it. Hardware acceleration works. Audio sounds good.</p>

<p>Is it perfect? Probably not - there’s always something to tweak. But it’s the closest I’ve gotten to a Linux setup that stays out of my way.</p>

<p>The full configuration lives at <a href="https://github.com/franzos/dotfiles">github.com/franzos/dotfiles</a>.</p>]]></content><author><name>Franz Geffke</name></author><category term="[&quot;Tools&quot;]" /><category term="guix" /><category term="framework" /><category term="linux" /><summary type="html"><![CDATA[After years of configuration tweaking, I think I’ve finally landed on a setup that feels complete. Everything works together. My Framework laptop (AMD) boots to a fingerprint scan, I sudo with a touch of my YubiKey, themes switch automatically at sunset, and my entire configuration syncs effortlessly between this, and my older ThinkPad.]]></summary></entry><entry><title type="html">Jail Claude Code with Guix Shell Containers</title><link href="https://gofranz.com/blog/jail-claude-code-with-guix-shell-container/" rel="alternate" type="text/html" title="Jail Claude Code with Guix Shell Containers" /><published>2026-01-02T00:00:00+00:00</published><updated>2026-01-02T00:00:00+00:00</updated><id>https://gofranz.com/blog/jail-claude-code-with-guix-shell-container</id><content type="html" xml:base="https://gofranz.com/blog/jail-claude-code-with-guix-shell-container/"><![CDATA[<p>In a <a href="/dev/isolate-claude-code-for-rust-development-with-docker/">previous post</a>, I covered running Claude Code in Docker containers. But on Guix, there’s a simpler approach: <code class="language-plaintext highlighter-rouge">guix shell --container</code>.</p>

<h2 id="why-isolate">Why Isolate?</h2>

<p>Claude Code has broad file system access by default. While useful, it can easily go sideways. Containers limit the blast radius.</p>

<h2 id="the-command">The Command</h2>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>guix shell <span class="nt">--container</span> <span class="se">\</span>
  <span class="nt">--expose</span><span class="o">=</span><span class="nv">$HOME</span>/.gitconfig<span class="o">=</span><span class="nv">$HOME</span>/.gitconfig <span class="se">\</span>
  <span class="nt">--share</span><span class="o">=</span><span class="nv">$HOME</span>/.claude<span class="o">=</span><span class="nv">$HOME</span>/.claude <span class="se">\</span>
  <span class="nt">--share</span><span class="o">=</span><span class="nv">$HOME</span>/.claude.json<span class="o">=</span><span class="nv">$HOME</span>/.claude.json <span class="se">\</span>
  <span class="nt">--share</span><span class="o">=</span><span class="nv">$HOME</span>/.config/claude<span class="o">=</span><span class="nv">$HOME</span>/.config/claude <span class="se">\</span>
  <span class="nt">--share</span><span class="o">=</span><span class="nv">$HOME</span>/.cache/pnpm<span class="o">=</span><span class="nv">$HOME</span>/.cache/pnpm <span class="se">\</span>
  <span class="nt">--share</span><span class="o">=</span><span class="nv">$HOME</span>/.local/share/pnpm<span class="o">=</span><span class="nv">$HOME</span>/.local/share/pnpm <span class="se">\</span>
  <span class="nt">--expose</span><span class="o">=</span><span class="nv">$XDG_RUNTIME_DIR</span><span class="o">=</span><span class="nv">$XDG_RUNTIME_DIR</span> <span class="se">\</span>
  <span class="nt">--preserve</span><span class="o">=</span><span class="s1">'^DBUS_SESSION_BUS_ADDRESS'</span> <span class="se">\</span>
  <span class="nt">--preserve</span><span class="o">=</span><span class="s1">'^COLORTERM'</span> <span class="se">\</span>
  <span class="nt">--share</span><span class="o">=</span><span class="nv">$PWD</span><span class="o">=</span><span class="nv">$PWD</span> <span class="se">\</span>
  <span class="nt">--network</span> <span class="se">\</span>
  coreutils bash <span class="nb">grep sed </span>gawk git node pnpm gh dunst <span class="se">\</span>
  <span class="nt">--</span> pnpm dlx @anthropic-ai/claude-code <span class="nt">--dangerously-skip-permissions</span>
</code></pre></div></div>

<h2 id="what-each-flag-does">What Each Flag Does</h2>

<p><strong>Container Basics</strong></p>
<ul>
  <li><code class="language-plaintext highlighter-rouge">--container</code>: Creates an isolated environment with its own filesystem view</li>
  <li><code class="language-plaintext highlighter-rouge">--network</code>: Enables network access (required for API calls)</li>
  <li><code class="language-plaintext highlighter-rouge">--share=$PWD=$PWD</code>: Gives Claude read/write access to your current project directory</li>
</ul>

<p><strong>Claude Configuration (Required)</strong></p>
<ul>
  <li><code class="language-plaintext highlighter-rouge">--share=$HOME/.claude</code>: Claude’s persistent configuration</li>
  <li><code class="language-plaintext highlighter-rouge">--share=$HOME/.claude.json</code>: Claude’s settings file</li>
  <li><code class="language-plaintext highlighter-rouge">--share=$HOME/.config/claude</code>: Additional Claude config</li>
</ul>

<p><strong>Git Access</strong></p>
<ul>
  <li><code class="language-plaintext highlighter-rouge">--expose=$HOME/.gitconfig</code>: Read-only access to git config (for commits, author info)</li>
</ul>

<p><strong>Optional: Avoid Re-downloading Claude</strong></p>
<ul>
  <li><code class="language-plaintext highlighter-rouge">--share=$HOME/.cache/pnpm</code>: Shares pnpm cache</li>
  <li><code class="language-plaintext highlighter-rouge">--share=$HOME/.local/share/pnpm</code>: Shares pnpm store</li>
</ul>

<p>Without these, <code class="language-plaintext highlighter-rouge">pnpm dlx</code> downloads Claude Code fresh each time.</p>

<p><strong>Optional: Desktop Notifications</strong></p>
<ul>
  <li><code class="language-plaintext highlighter-rouge">--expose=$XDG_RUNTIME_DIR</code>: Exposes the runtime directory</li>
  <li><code class="language-plaintext highlighter-rouge">--preserve='^DBUS_SESSION_BUS_ADDRESS'</code>: Preserves D-Bus for notifications</li>
  <li><code class="language-plaintext highlighter-rouge">dunst</code> in packages: Notification daemon</li>
</ul>

<p>These let Claude trigger desktop notifications when tasks complete.</p>

<p><strong>Terminal Colors</strong></p>
<ul>
  <li><code class="language-plaintext highlighter-rouge">--preserve='^COLORTERM'</code>: Maintains color support in the container</li>
</ul>

<h2 id="the-result">The Result</h2>

<p>Claude Code runs with full access to your project directory but nothing else. It can’t touch your home directory, other projects, or system files. If something goes wrong, the damage stays contained.</p>

<h2 id="guix-home-alias">Guix Home Alias</h2>

<p>Add an alias to your <code class="language-plaintext highlighter-rouge">home-bash-service-type</code> configuration:</p>

<div class="language-scheme highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">(</span><span class="nf">service</span> <span class="nv">home-bash-service-type</span>
  <span class="p">(</span><span class="nf">home-bash-configuration</span>
    <span class="p">(</span><span class="nf">aliases</span>
      <span class="o">'</span><span class="p">((</span><span class="s">"ccj"</span> <span class="o">.</span> <span class="s">"guix shell --container --expose=$HOME/.gitconfig=$HOME/.gitconfig --share=$HOME/.claude=$HOME/.claude --share=$HOME/.claude.json=$HOME/.claude.json --share=$HOME/.config/claude=$HOME/.config/claude --share=$HOME/.cache/pnpm=$HOME/.cache/pnpm --share=$HOME/.local/share/pnpm=$HOME/.local/share/pnpm --expose=$XDG_RUNTIME_DIR=$XDG_RUNTIME_DIR --preserve='^DBUS_SESSION_BUS_ADDRESS$' --preserve='^COLORTERM$' --share=$PWD=$PWD --network coreutils bash grep sed gawk git node pnpm gh dunst -- pnpm dlx @anthropic-ai/claude-code --dangerously-skip-permissions"</span><span class="p">)))))</span>
</code></pre></div></div>

<p>Reconfigure with <code class="language-plaintext highlighter-rouge">guix home reconfigure</code>, then run <code class="language-plaintext highlighter-rouge">ccj</code> from any project directory.</p>]]></content><author><name>Franz Geffke</name></author><category term="[&quot;Tools&quot;]" /><category term="guix" /><category term="claude" /><category term="security" /><summary type="html"><![CDATA[In a previous post, I covered running Claude Code in Docker containers. But on Guix, there’s a simpler approach: guix shell --container.]]></summary></entry><entry><title type="html">Supercharge Guix Shell with direnv</title><link href="https://gofranz.com/blog/supercharge-guix-shell-with-direnv/" rel="alternate" type="text/html" title="Supercharge Guix Shell with direnv" /><published>2025-12-07T00:00:00+00:00</published><updated>2025-12-07T00:00:00+00:00</updated><id>https://gofranz.com/blog/supercharge-guix-shell-with-direnv</id><content type="html" xml:base="https://gofranz.com/blog/supercharge-guix-shell-with-direnv/"><![CDATA[<p>In a <a href="/blog/customize-guix-shell-environment">previous post</a>, I showed how to customize Guix shell environments using manifests. The approach works, but requires you to manually run <code class="language-plaintext highlighter-rouge">guix shell -m manifest.scm</code> every time you enter a project. That gets old fast.</p>

<p>Enter direnv.</p>

<h2 id="what-is-direnv">What is direnv?</h2>

<p>direnv is a shell extension that loads and unloads environment variables based on the current directory. When you <code class="language-plaintext highlighter-rouge">cd</code> into a project with an <code class="language-plaintext highlighter-rouge">.envrc</code> file, direnv automatically sets up the environment. When you leave, it cleans up.</p>

<p>For Guix users, this means automatic shell environments per project - no more typing <code class="language-plaintext highlighter-rouge">guix shell</code> every time.</p>

<h2 id="setup">Setup</h2>

<p>First, install direnv. On Guix, add it to your profile or system packages:</p>

<div class="language-scheme highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">(</span><span class="nf">packages</span>
 <span class="p">(</span><span class="nf">cons*</span> <span class="nv">direnv</span>
        <span class="c1">;; your other packages</span>
        <span class="nv">%base-packages</span><span class="p">))</span>
</code></pre></div></div>

<p>Then, hook direnv into your shell. Add the following to your <code class="language-plaintext highlighter-rouge">.bashrc</code>:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># direnv (.envrc)</span>
_direnv_hook<span class="o">()</span> <span class="o">{</span>
  <span class="nb">local </span><span class="nv">previous_exit_status</span><span class="o">=</span><span class="nv">$?</span><span class="p">;</span>
  <span class="nb">trap</span> <span class="nt">--</span> <span class="s1">''</span> SIGINT<span class="p">;</span>
  <span class="nb">eval</span> <span class="s2">"</span><span class="si">$(</span>direnv <span class="nb">export </span>bash<span class="si">)</span><span class="s2">"</span><span class="p">;</span>
  <span class="nb">trap</span> - SIGINT<span class="p">;</span>
  <span class="k">return</span> <span class="nv">$previous_exit_status</span><span class="p">;</span>
<span class="o">}</span><span class="p">;</span>
<span class="k">if</span> <span class="o">[[</span> <span class="s2">";</span><span class="k">${</span><span class="nv">PROMPT_COMMAND</span><span class="p">[*]</span><span class="k">:-}</span><span class="s2">;"</span> <span class="o">!=</span> <span class="k">*</span><span class="s2">";_direnv_hook;"</span><span class="k">*</span> <span class="o">]]</span><span class="p">;</span> <span class="k">then
  if</span> <span class="o">[[</span> <span class="s2">"</span><span class="si">$(</span><span class="nb">declare</span> <span class="nt">-p</span> PROMPT_COMMAND 2&gt;&amp;1<span class="si">)</span><span class="s2">"</span> <span class="o">==</span> <span class="s2">"declare -a"</span><span class="k">*</span> <span class="o">]]</span><span class="p">;</span> <span class="k">then
    </span><span class="nv">PROMPT_COMMAND</span><span class="o">=(</span>_direnv_hook <span class="s2">"</span><span class="k">${</span><span class="nv">PROMPT_COMMAND</span><span class="p">[@]</span><span class="k">}</span><span class="s2">"</span><span class="o">)</span>
  <span class="k">else
    </span><span class="nv">PROMPT_COMMAND</span><span class="o">=</span><span class="s2">"_direnv_hook</span><span class="k">${</span><span class="nv">PROMPT_COMMAND</span>:+<span class="p">;</span><span class="nv">$PROMPT_COMMAND</span><span class="k">}</span><span class="s2">"</span>
  <span class="k">fi
fi</span>
</code></pre></div></div>

<p>Restart your shell or run <code class="language-plaintext highlighter-rouge">source ~/.bashrc</code>.</p>

<h2 id="simple-example">Simple Example</h2>

<p>For projects with straightforward dependencies, a one-liner <code class="language-plaintext highlighter-rouge">.envrc</code> does the trick:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Guix development environment with node and pnpm</span>
<span class="nb">eval</span> <span class="s2">"</span><span class="si">$(</span>guix shell node pnpm <span class="nt">--search-paths</span><span class="si">)</span><span class="s2">"</span>
</code></pre></div></div>

<p>When you <code class="language-plaintext highlighter-rouge">cd</code> into this directory, direnv will prompt you to allow the <code class="language-plaintext highlighter-rouge">.envrc</code>:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>direnv: error .envrc is blocked. Run <span class="sb">`</span>direnv allow<span class="sb">`</span> to approve its content.
</code></pre></div></div>

<p>Run <code class="language-plaintext highlighter-rouge">direnv allow</code> once, and you’re set. Every subsequent visit automatically loads the environment.</p>

<h2 id="complex-example-with-manifests">Complex Example with Manifests</h2>

<p>For projects with custom package configurations (like the OpenSSL trick from my previous post), combine direnv with a manifest:</p>

<p>Create a <code class="language-plaintext highlighter-rouge">manifest.scm</code>:</p>

<div class="language-scheme highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">(</span><span class="nf">use-modules</span> <span class="p">(</span><span class="nf">guix</span> <span class="nv">profiles</span><span class="p">)</span>
             <span class="p">(</span><span class="nf">guix</span> <span class="nv">packages</span><span class="p">)</span>
             <span class="p">(</span><span class="nf">guix</span> <span class="nv">search-paths</span><span class="p">)</span>
             <span class="p">(</span><span class="nf">gnu</span> <span class="nv">packages</span> <span class="nv">node</span><span class="p">)</span>
             <span class="p">(</span><span class="nf">gnu</span> <span class="nv">packages</span> <span class="nv">rust</span><span class="p">)</span>
             <span class="p">(</span><span class="nf">gnu</span> <span class="nv">packages</span> <span class="nv">commencement</span><span class="p">)</span>
             <span class="p">(</span><span class="nf">gnu</span> <span class="nv">packages</span> <span class="nv">tls</span><span class="p">)</span>
             <span class="p">(</span><span class="nf">gnu</span> <span class="nv">packages</span> <span class="nv">databases</span><span class="p">))</span>

<span class="c1">;; Create a custom OpenSSL package that exports OPENSSL_DIR</span>
<span class="p">(</span><span class="k">define</span> <span class="nv">openssl-with-env-dir</span>
  <span class="p">(</span><span class="nf">package</span>
    <span class="p">(</span><span class="nf">inherit</span> <span class="nv">openssl</span><span class="p">)</span>
    <span class="p">(</span><span class="nf">name</span> <span class="s">"openssl"</span><span class="p">)</span>
    <span class="p">(</span><span class="nf">native-search-paths</span>
     <span class="p">(</span><span class="nb">append</span> <span class="p">(</span><span class="nf">package-native-search-paths</span> <span class="nv">openssl</span><span class="p">)</span>
             <span class="p">(</span><span class="nb">list</span> <span class="p">(</span><span class="nf">search-path-specification</span>
                    <span class="p">(</span><span class="nf">variable</span> <span class="s">"OPENSSL_DIR"</span><span class="p">)</span>
                    <span class="p">(</span><span class="nf">files</span> <span class="o">'</span><span class="p">(</span><span class="s">"."</span><span class="p">))</span>
                    <span class="p">(</span><span class="nf">file-type</span> <span class="ss">'directory</span><span class="p">)</span>
                    <span class="p">(</span><span class="nf">separator</span> <span class="no">#f</span><span class="p">)))))))</span>

<span class="c1">;; Create a custom GCC package that exports CC and LD_LIBRARY_PATH</span>
<span class="p">(</span><span class="k">define</span> <span class="nv">gcc-with-env-cc</span>
  <span class="p">(</span><span class="nf">package</span>
    <span class="p">(</span><span class="nf">inherit</span> <span class="nv">gcc-toolchain</span><span class="p">)</span>
    <span class="p">(</span><span class="nf">name</span> <span class="s">"gcc-toolchain"</span><span class="p">)</span>
    <span class="p">(</span><span class="nf">native-search-paths</span>
     <span class="p">(</span><span class="nb">append</span> <span class="p">(</span><span class="nf">package-native-search-paths</span> <span class="nv">gcc-toolchain</span><span class="p">)</span>
             <span class="p">(</span><span class="nb">list</span> <span class="p">(</span><span class="nf">search-path-specification</span>
                    <span class="p">(</span><span class="nf">variable</span> <span class="s">"CC"</span><span class="p">)</span>
                    <span class="p">(</span><span class="nf">files</span> <span class="o">'</span><span class="p">(</span><span class="s">"bin/gcc"</span><span class="p">))</span>
                    <span class="p">(</span><span class="nf">file-type</span> <span class="ss">'regular</span><span class="p">)</span>
                    <span class="p">(</span><span class="nf">separator</span> <span class="no">#f</span><span class="p">))</span>
                   <span class="p">(</span><span class="nf">search-path-specification</span>
                    <span class="p">(</span><span class="nf">variable</span> <span class="s">"LD_LIBRARY_PATH"</span><span class="p">)</span>
                    <span class="p">(</span><span class="nf">files</span> <span class="o">'</span><span class="p">(</span><span class="s">"lib"</span><span class="p">))</span>
                    <span class="p">(</span><span class="nf">file-type</span> <span class="ss">'directory</span><span class="p">)</span>
                    <span class="p">(</span><span class="nf">separator</span> <span class="s">":"</span><span class="p">)))))))</span>

<span class="p">(</span><span class="nf">packages-&gt;manifest</span>
 <span class="p">(</span><span class="nb">list</span> <span class="nv">node</span>
       <span class="nv">pnpm</span>
       <span class="nv">rust</span>
       <span class="p">(</span><span class="nb">list</span> <span class="nv">rust</span> <span class="s">"cargo"</span><span class="p">)</span>
       <span class="nv">rust-analyzer</span>
       <span class="nv">gcc-with-env-cc</span>
       <span class="nv">openssl-with-env-dir</span>
       <span class="nv">postgresql</span><span class="p">))</span>
</code></pre></div></div>

<p>Then reference it in your <code class="language-plaintext highlighter-rouge">.envrc</code>:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Guix development environment</span>
<span class="k">if</span> <span class="o">[[</span> <span class="nt">-d</span> /run/current-system <span class="o">]]</span><span class="p">;</span> <span class="k">then
  </span><span class="nb">eval</span> <span class="s2">"</span><span class="si">$(</span>guix shell <span class="nt">-m</span> manifest.scm <span class="nt">--search-paths</span><span class="si">)</span><span class="s2">"</span>
<span class="k">fi</span>
</code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">/run/current-system</code> check ensures this only runs on Guix systems - useful if you share your project with developers on other distros.</p>

<h2 id="conclusion">Conclusion</h2>

<p>The combination of direnv and Guix shell environments removes the friction from project-specific tooling. You define your dependencies once, and they’re automatically available whenever you work on the project.</p>]]></content><author><name>Franz Geffke</name></author><category term="[&quot;Tools&quot;]" /><category term="guix" /><category term="shell" /><category term="direnv" /><summary type="html"><![CDATA[In a previous post, I showed how to customize Guix shell environments using manifests. The approach works, but requires you to manually run guix shell -m manifest.scm every time you enter a project. That gets old fast.]]></summary></entry><entry><title type="html">Automatic Dark/Light Theme Switching on Sway with Guix</title><link href="https://gofranz.com/blog/automatic-dark-light-theme-switching-sway-guix/" rel="alternate" type="text/html" title="Automatic Dark/Light Theme Switching on Sway with Guix" /><published>2025-11-02T00:00:00+00:00</published><updated>2025-11-02T00:00:00+00:00</updated><id>https://gofranz.com/blog/automatic-dark-light-theme-switching-sway-guix</id><content type="html" xml:base="https://gofranz.com/blog/automatic-dark-light-theme-switching-sway-guix/"><![CDATA[<p>I’ve been meaning to set up automatic theme switching for a while now. Manually toggling between dark and light themes gets old fast, especially when I’m working across different times of day. Enter darkman - a simple daemon that handles this automatically.</p>

<h2 id="what-is-darkman">What is Darkman?</h2>

<p><a href="https://darkman.whynothugo.nl/">Darkman</a> is a framework for dark-mode and light-mode transitions. It automatically switches themes at sunrise and sunset based on your location, and lets you manually toggle when needed. The beauty is in its simplicity: it runs scripts when switching modes, so you can control exactly what changes.</p>

<h2 id="guix-home-setup">Guix Home Setup</h2>

<p>On Guix, darkman integrates via a home service. Add this to your home configuration:</p>

<div class="language-scheme highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">(</span><span class="nf">service</span> <span class="nv">home-darkman-service-type</span>
         <span class="p">(</span><span class="nf">home-darkman-configuration</span>
          <span class="p">(</span><span class="nf">latitude</span> <span class="mf">38.7</span><span class="p">)</span>
          <span class="p">(</span><span class="nf">longitude</span> <span class="mf">-9.2</span><span class="p">)</span>
          <span class="p">(</span><span class="nf">use-geoclue?</span> <span class="no">#f</span><span class="p">)))</span>
</code></pre></div></div>

<p>Set your latitude and longitude to get accurate sunrise/sunset times. I disable geoclue since I don’t need dynamic location tracking.</p>

<h2 id="what-gets-switched">What Gets Switched</h2>

<p>Darkman will handle:</p>

<ul>
  <li><strong>GTK applications</strong>: LibreOffice, Thunar, file managers (Yaru-dark ↔ Yaru theme)</li>
  <li><strong>Foot terminal</strong>: Instant color switching via UNIX signals</li>
  <li><strong>Dunst notifications</strong>: Background and foreground colors</li>
</ul>

<h2 id="the-implementation">The Implementation</h2>

<p>Darkman executes scripts from two directories:</p>
<ul>
  <li><code class="language-plaintext highlighter-rouge">~/.local/share/dark-mode.d/</code> - runs when switching to dark mode</li>
  <li><code class="language-plaintext highlighter-rouge">~/.local/share/light-mode.d/</code> - runs when switching to light mode</li>
</ul>

<p>For GTK apps, two separate mechanisms are needed - not all applications respect both:</p>

<h3 id="1-portald-bus-for-modern-apps">1. Portal/D-Bus (for modern apps)</h3>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>gsettings <span class="nb">set </span>org.gnome.desktop.interface color-scheme <span class="s1">'prefer-dark'</span>
gsettings <span class="nb">set </span>org.gnome.desktop.interface gtk-theme <span class="s1">'Yaru-dark'</span>
</code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">color-scheme</code> setting broadcasts via D-Bus to the <a href="https://flatpak.github.io/xdg-desktop-portal/">FreeDesktop portal</a> (<code class="language-plaintext highlighter-rouge">org.freedesktop.appearance.color-scheme</code>). Modern applications - Firefox, Chrome, VSCode, Electron apps - listen to this portal and switch automatically.</p>

<p>The <code class="language-plaintext highlighter-rouge">gtk-theme</code> setting tells GTK applications which theme to use (e.g., <code class="language-plaintext highlighter-rouge">Yaru-dark</code> vs <code class="language-plaintext highlighter-rouge">Yaru</code>). For light mode, use <code class="language-plaintext highlighter-rouge">prefer-light</code> and remove the <code class="language-plaintext highlighter-rouge">-dark</code> suffix from the theme name.</p>

<h3 id="2-gtk-3-settingsini-for-legacy-apps">2. GTK-3 settings.ini (for legacy apps)</h3>

<p>Some applications like Thunar read <code class="language-plaintext highlighter-rouge">~/.config/gtk-3.0/settings.ini</code> directly instead of using D-Bus:</p>

<div class="language-ini highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nn">[Settings]</span>
<span class="py">gtk-theme-name</span> <span class="p">=</span> <span class="s">Yaru-dark</span>
<span class="py">gtk-application-prefer-dark-theme</span> <span class="p">=</span> <span class="s">1</span>
</code></pre></div></div>

<p>The darkman script copies a template to this location:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">TEMPLATE</span><span class="o">=</span><span class="s2">"</span><span class="nv">$HOME</span><span class="s2">/.local/share/gtk-themes/settings-dark.ini"</span>
<span class="nv">GTK3_CONFIG</span><span class="o">=</span><span class="s2">"</span><span class="nv">$HOME</span><span class="s2">/.config/gtk-3.0/settings.ini"</span>
<span class="nb">cp</span> <span class="s2">"</span><span class="nv">$TEMPLATE</span><span class="s2">"</span> <span class="s2">"</span><span class="nv">$GTK3_CONFIG</span><span class="s2">"</span>
</code></pre></div></div>

<p>For light mode, set <code class="language-plaintext highlighter-rouge">gtk-theme-name = Yaru</code> and <code class="language-plaintext highlighter-rouge">gtk-application-prefer-dark-theme = 0</code>.</p>

<p>For the Foot terminal I run <code class="language-plaintext highlighter-rouge">foot --server</code> and connect with <code class="language-plaintext highlighter-rouge">footclient</code>. This lets me use signals for instant theme switching - one signal updates the server and all connected terminals without restart:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Dark mode: SIGUSR1 switches to [colors] section</span>
<span class="nb">kill</span> <span class="nt">-SIGUSR1</span> <span class="si">$(</span>pgrep <span class="nt">-x</span> foot<span class="si">)</span> 2&gt;/dev/null <span class="o">||</span> <span class="nb">true</span>

<span class="c"># Light mode: SIGUSR2 switches to [colors2] section</span>
<span class="nb">kill</span> <span class="nt">-SIGUSR2</span> <span class="si">$(</span>pgrep <span class="nt">-x</span> foot<span class="si">)</span> 2&gt;/dev/null <span class="o">||</span> <span class="nb">true</span>
</code></pre></div></div>

<p>Your <code class="language-plaintext highlighter-rouge">foot.ini</code> just needs both color sections defined, with <code class="language-plaintext highlighter-rouge">initial-color-theme=1</code> to start dark.</p>

<h2 id="usage">Usage</h2>

<p>Once configured, darkman runs automatically:</p>
<ul>
  <li>Switches to light mode at sunrise</li>
  <li>Switches to dark mode at sunset</li>
  <li>Manual toggle with <code class="language-plaintext highlighter-rouge">darkman toggle</code> (I bind this to <code class="language-plaintext highlighter-rouge">Mod+T</code> in Sway)</li>
</ul>

<p>Test it works:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>darkman toggle
</code></pre></div></div>

<p>Everything should switch instantly - GTK apps, terminal colors, notifications.</p>

<h2 id="full-implementation">Full Implementation</h2>

<p>The complete setup with all scripts and configurations is in my <a href="https://github.com/franzos/dotfiles">dotfiles repository</a>.</p>]]></content><author><name>Franz Geffke</name></author><category term="[&quot;Tools&quot;]" /><category term="guix" /><category term="sway" /><category term="darkman" /><summary type="html"><![CDATA[I’ve been meaning to set up automatic theme switching for a while now. Manually toggling between dark and light themes gets old fast, especially when I’m working across different times of day. Enter darkman - a simple daemon that handles this automatically.]]></summary></entry><entry><title type="html">Screen Sharing on Sway/Wayland with Guix</title><link href="https://gofranz.com/blog/screen-sharing-on-sway-wayland-guix/" rel="alternate" type="text/html" title="Screen Sharing on Sway/Wayland with Guix" /><published>2025-09-15T01:00:00+01:00</published><updated>2025-09-15T01:00:00+01:00</updated><id>https://gofranz.com/blog/screen-sharing-on-sway-wayland-guix</id><content type="html" xml:base="https://gofranz.com/blog/screen-sharing-on-sway-wayland-guix/"><![CDATA[<p>This week I got tired enough, of not being able to share my screen on sway/wayland, that I decided to fix it.
Here’s some of what worked; You probably don’t need all of this, but it generally improves your wayland experience.</p>

<p>(1) Include this in your sway config:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">exec </span>systemctl <span class="nt">--user</span> import-environment WAYLAND_DISPLAY XDG_CURRENT_DESKTOP
<span class="nb">exec </span>dbus-update-activation-environment <span class="nt">--systemd</span> WAYLAND_DISPLAY <span class="nv">XDG_CURRENT_DESKTOP</span><span class="o">=</span>sway
</code></pre></div></div>

<p>(2) Make sure these packages are installed:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">xdg-desktop-portal-wlr</code></li>
  <li><code class="language-plaintext highlighter-rouge">xdg-desktop-portal-gtk</code> (probably not necessary)</li>
  <li><code class="language-plaintext highlighter-rouge">slurp</code>: helper to select the screen to share</li>
</ul>

<p>(3) Create <code class="language-plaintext highlighter-rouge">portals.conf</code> config:</p>

<div class="language-toml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nn">[preferred]</span>
<span class="c"># use xdg-desktop-portal-gtk for every portal interface</span>
<span class="py">default</span><span class="p">=</span><span class="err">gtk</span>
<span class="c"># Settings portal for theme detection</span>
<span class="py">org.freedesktop.impl.portal.Settings</span><span class="p">=</span><span class="err">gtk</span>
<span class="c"># except for the xdg-desktop-portal-wlr supplied interfaces</span>
<span class="py">org.freedesktop.impl.portal.ScreenCast</span><span class="p">=</span><span class="err">wlr</span>
<span class="py">org.freedesktop.impl.portal.Screenshot</span><span class="p">=</span><span class="err">wlr</span>
</code></pre></div></div>

<p>(4) Tie it all together, using guix home (or manually):</p>

<div class="language-scheme highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">(</span><span class="nf">services</span>
<span class="p">(</span><span class="nb">append</span> <span class="p">(</span><span class="nb">list</span>
      <span class="c1">;; other services</span>
      <span class="p">(</span><span class="nf">service</span> <span class="nv">home-xdg-configuration-files-service-type</span>
                <span class="o">`</span><span class="p">((</span><span class="s">"sway/config"</span> <span class="o">,</span><span class="p">(</span><span class="nf">local-file</span> <span class="s">"sway"</span><span class="p">))</span>
                  <span class="p">(</span><span class="s">"xdg-desktop-portal/portals.conf"</span> <span class="o">,</span><span class="p">(</span><span class="nf">local-file</span> <span class="s">"portals.conf"</span><span class="p">))))</span>
      <span class="p">(</span><span class="nf">simple-service</span> <span class="ss">'env-vars</span> <span class="nv">home-environment-variables-service-type</span>
                      <span class="o">`</span><span class="p">((</span><span class="s">"QT_QPA_PLATFORM"</span> <span class="o">.</span> <span class="s">"wayland;xcb"</span><span class="p">)</span>
                        <span class="p">(</span><span class="s">"SDL_VIDEODRIVER"</span> <span class="o">.</span> <span class="s">"wayland"</span><span class="p">)</span>
                        <span class="p">(</span><span class="s">"XDG_CURRENT_DESKTOP"</span> <span class="o">.</span> <span class="s">"sway"</span><span class="p">)</span>
                        <span class="p">(</span><span class="s">"XDG_SESSION_DESKTOP"</span> <span class="o">.</span> <span class="s">"sway"</span><span class="p">)</span>
                        <span class="p">(</span><span class="s">"XDG_SESSION_TYPE"</span> <span class="o">.</span> <span class="s">"wayland"</span><span class="p">)</span>
                        <span class="p">(</span><span class="s">"ELECTRON_OZONE_PLATFORM_HINT"</span> <span class="o">.</span> <span class="s">"wayland"</span><span class="p">)</span>
                        <span class="p">(</span><span class="s">"MOZ_ENABLE_WAYLAND"</span> <span class="o">.</span> <span class="s">"1"</span><span class="p">)</span>
                        <span class="p">(</span><span class="s">"NIXOS_OZONE_WL"</span> <span class="o">.</span> <span class="s">"1"</span><span class="p">)</span>
                        <span class="p">(</span><span class="s">"GDK_BACKEND"</span> <span class="o">.</span> <span class="s">"wayland"</span><span class="p">)</span>
                        <span class="p">(</span><span class="s">"CLUTTER_BACKEND"</span> <span class="o">.</span> <span class="s">"wayland"</span><span class="p">)))</span>
      <span class="p">(</span><span class="nf">service</span> <span class="nv">home-dbus-service-type</span><span class="p">)</span>
      <span class="p">(</span><span class="nf">service</span> <span class="nv">home-pipewire-service-type</span><span class="p">))</span>
      <span class="nv">%base-home-services</span><span class="p">))</span>
</code></pre></div></div>

<p>To test if it’s working, use this <a href="https://mozilla.github.io/webrtc-landing/gum_test.html">Test Page</a>.</p>

<p>Feel free to checkout <a href="https://github.com/franzos/dotfiles/tree/master/home">my dotfiles</a> for more details.</p>]]></content><author><name>Franz Geffke</name></author><category term="[&quot;Tools&quot;]" /><category term="llm" /><summary type="html"><![CDATA[This week I got tired enough, of not being able to share my screen on sway/wayland, that I decided to fix it. Here’s some of what worked; You probably don’t need all of this, but it generally improves your wayland experience.]]></summary></entry></feed>