<feed xmlns="http://www.w3.org/2005/Atom">
	<author>
		<name>
			David v.Knobelsdorff
		</name>
	</author>
	<title type="text">vknobelsdorff.com</title>
	<id>https://vknobelsdorff.com</id>
	<updated>2026-04-01T11:01:52Z</updated>
	<link href="https://vknobelsdorff.com/atom.xml" rel="self">
	</link>
	<entry>
		<title type="text">Reorganizing my dotfiles</title>
		<id>https://vknobelsdorff.com/automation/organizing-dotfiles</id>
		<link href="https://vknobelsdorff.com/automation/organizing-dotfiles" rel="alternate">
		</link>
		<updated>2026-04-01T11:01:52Z</updated>
		<summary type="text">
			Using stow to bootstrap my dotfiles while supporting different machines
		</summary>
		<content type="html">
			&lt;p&gt;
	With the latest additions to my homelab I needed to reorganize my dotfiles and improve the process of bootstrapping a new machine with the required tools and configuration.
&lt;/p&gt;
&lt;h2&gt;
	The challenge
&lt;/h2&gt;
&lt;p&gt;
	Managing dotfiles across multiple machines is straightforward until the machines start diverging. My setup spans a personal MacBook, a work MacBook, and a Linux machine. They share a lot of configuration but differ in identity (email, signing keys), OS specifics, and machine-specific tooling. I wanted one repository that handles all of them without a mess of conditionals scattered through every config file.
&lt;/p&gt;
&lt;h2&gt;
	The structure
&lt;/h2&gt;
&lt;p&gt;
	The solution is a four-layer directory hierarchy managed by &lt;a href=&quot;https://www.gnu.org/software/stow/&quot;&gt;GNU Stow&lt;/a&gt;. Each layer is applied on top of the previous one:
&lt;/p&gt;
&lt;figure class=&quot;highlight&quot;&gt;
	&lt;pre&gt;&lt;code class=&quot;language-&quot;&gt;00base/          # applied to every machine
01os/&amp;lt;os&amp;gt;/       # macos or linux
02identity/&amp;lt;id&amp;gt;/ # personal or work
03hosts/&amp;lt;host&amp;gt;/  # machine-specific overrides
&lt;/code&gt;&lt;/pre&gt;
&lt;/figure&gt;
&lt;p&gt;
	Within each layer, packages are plain directories named after the tool they configure. A package contains the files in the same relative path they should end up at in &lt;code&gt;$HOME&lt;/code&gt;. For example, &lt;code&gt;00base/eza/.config/eza/&lt;/code&gt; maps to &lt;code&gt;~/.config/eza/&lt;/code&gt;. Stow creates symlinks, so the actual files always live in the dotfiles repository.
&lt;/p&gt;
&lt;p&gt;
	The &lt;code&gt;.stowrc&lt;/code&gt; file at the root sets the target and a few ignore patterns:
&lt;/p&gt;
&lt;figure class=&quot;highlight&quot;&gt;
	&lt;pre&gt;&lt;code class=&quot;language-&quot;&gt;--target=&amp;quot;~&amp;quot;
--ignore=&amp;apos;.DS_Store&amp;apos;
--ignore=&amp;apos;\.gitkeep$&amp;apos;
--no-folding
&lt;/code&gt;&lt;/pre&gt;
&lt;/figure&gt;
&lt;p&gt;&lt;code&gt;--no-folding&lt;/code&gt; is important: without it Stow may symlink an entire directory rather than the files inside it, which breaks the layering because a later layer cannot add files inside a directory that is itself a symlink.
&lt;/p&gt;
&lt;h2&gt;
	The install script
&lt;/h2&gt;
&lt;p&gt;
	The &lt;code&gt;install&lt;/code&gt; script at the root of the repository handles bootstrapping. It auto-detects the hostname and OS, maps them to an identity, and stows all four layers in order:
&lt;/p&gt;
&lt;figure class=&quot;highlight&quot;&gt;
	&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;stow_all_in &amp;quot;00base&amp;quot;
stow_all_in &amp;quot;01os/$OS_GROUP&amp;quot;
stow_all_in &amp;quot;02identity/$IDENTITY&amp;quot;
stow_all_in &amp;quot;03hosts/$HOST_KEY&amp;quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;/figure&gt;
&lt;p&gt;
	The host-to-identity mapping is a simple associative array at the top of the script:
&lt;/p&gt;
&lt;figure class=&quot;highlight&quot;&gt;
	&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;declare -A HOST_TO_IDENTITY
HOST_TO_IDENTITY=(
  [work-mbp]=work
  [mbp-dvk]=personal
  [arch-mbp]=personal
)
&lt;/code&gt;&lt;/pre&gt;
&lt;/figure&gt;
&lt;p&gt;
	Hostname normalization converts the raw &lt;code&gt;hostname -s&lt;/code&gt; output to lowercase with hyphens, making it case-insensitive and consistent across platforms.
&lt;/p&gt;
&lt;p&gt;
	Before stowing a package the script unstows it first (&lt;code&gt;stow -D&lt;/code&gt;). This ensures stale symlinks from a previous run are cleaned up before new ones are created. Any package directory prefixed with &lt;code&gt;_&lt;/code&gt; is skipped entirely, which is useful for work-in-progress packages that should not be deployed yet.
&lt;/p&gt;
&lt;p&gt;
	The script also supports a few useful flags:
&lt;/p&gt;
&lt;figure class=&quot;highlight&quot;&gt;
	&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;./install --dry-run            # preview without making changes
./install -p &amp;quot;01os/macos/bin&amp;quot;  # stow a single package manually
./install -H mbp-dvk           # override the detected hostname
&lt;/code&gt;&lt;/pre&gt;
&lt;/figure&gt;
&lt;h2&gt;
	Adding a new machine
&lt;/h2&gt;
&lt;p&gt;
	To add a new machine you add a mapping in the &lt;code&gt;HOST_TO_IDENTITY&lt;/code&gt; array, create a &lt;code&gt;03hosts/&amp;lt;hostname&amp;gt;/&lt;/code&gt; directory with any machine-specific packages, and run &lt;code&gt;./install&lt;/code&gt;. If the machine uses a new OS or identity that does not yet have a layer directory, those are created too. Machines that share all configuration with an existing host can skip the host layer entirely and rely only on the base, OS, and identity layers.
&lt;/p&gt;
&lt;h2&gt;
	Cleaning up
&lt;/h2&gt;
&lt;p&gt;
	I also added a &lt;code&gt;clean-env&lt;/code&gt; script which removes all symlinks by running &lt;code&gt;stow -D&lt;/code&gt; against every known package across all layers. It covers all currently known hosts so it handles cleanup on any of the registered machines.
&lt;/p&gt;
&lt;p&gt;
	This structure keeps the repository tidy: every file has an obvious home, the layers compose predictably, and bootstrapping a new machine is a single command.
&lt;/p&gt;
&lt;h2&gt;
	Expanding the concept
&lt;/h2&gt;
&lt;p&gt;
	Since the concept worked quite well I applied the same pattern inside individual tool configurations, most notably zsh.
&lt;/p&gt;
&lt;p&gt;
	The three main zsh entry points (&lt;code&gt;.zshenv&lt;/code&gt;, &lt;code&gt;.zprofile&lt;/code&gt;, &lt;code&gt;.zshrc&lt;/code&gt;) each contain nothing but a sourcing loop:
&lt;/p&gt;
&lt;figure class=&quot;highlight&quot;&gt;
	&lt;pre&gt;&lt;code class=&quot;language-zsh&quot;&gt;setopt null_glob
for conf in &amp;quot;$HOME/.zshrc.d/&amp;quot;*.zsh; do
  source &amp;quot;${conf}&amp;quot;
done
unsetopt null_glob
unset conf
&lt;/code&gt;&lt;/pre&gt;
&lt;/figure&gt;
&lt;p&gt;&lt;code&gt;.zshenv&lt;/code&gt; sources everything in &lt;code&gt;~/.zshenv.d/&lt;/code&gt;, &lt;code&gt;.zprofile&lt;/code&gt; sources &lt;code&gt;~/.zprofile.d/&lt;/code&gt;, and &lt;code&gt;.zshrc&lt;/code&gt; sources &lt;code&gt;~/.zshrc.d/&lt;/code&gt;. The &lt;code&gt;null_glob&lt;/code&gt; option prevents an error when a directory is empty or does not exist. Each file in those directories is a standalone numbered snippet, which means different stow layers can drop files into the same directory and they compose at shell startup without any of them knowing about the others.
&lt;/p&gt;
&lt;p&gt;
	The base layer (&lt;code&gt;00base/zsh/&lt;/code&gt;) provides the numbered files that cover the universal configuration:
&lt;/p&gt;
&lt;figure class=&quot;highlight&quot;&gt;
	&lt;pre&gt;&lt;code class=&quot;language-&quot;&gt;.zshenv.d/00-path.zsh       # PATH setup
.zshenv.d/01-tools.zsh      # tool environment variables
.zshenv.d/02-editor.zsh     # EDITOR / VISUAL

.zprofile.d/01-tools.zsh    # login-time tool init (homebrew, etc.)

.zshrc.d/00-zinit.zsh       # plugin manager and plugins
.zshrc.d/02-theme.zsh       # prompt theme
.zshrc.d/03-tools.zsh       # interactive tool init (fzf, zoxide, etc.)
.zshrc.d/04-completions.zsh # completion setup
.zshrc.d/05-functions.zsh   # autoloaded functions
.zshrc.d/06-history.zsh     # history settings
.zshrc.d/07-shell-options.zsh
.zshrc.d/10-alias.zsh       # universal aliases
.zshrc.d/11-keybindings.zsh # key bindings
.zshrc.d/12-prompt.zsh      # prompt init
&lt;/code&gt;&lt;/pre&gt;
&lt;/figure&gt;
&lt;p&gt;
	The numeric prefix controls load order. Low numbers run first, leaving the high end of the namespace for identity and host overlays.
&lt;/p&gt;
&lt;p&gt;
	The work identity (&lt;code&gt;02identity/work/zsh/&lt;/code&gt;) stows a single file into that same directory:
&lt;/p&gt;
&lt;figure class=&quot;highlight&quot;&gt;
	&lt;pre&gt;&lt;code class=&quot;language-&quot;&gt;.zshrc.d/99-work.zsh
&lt;/code&gt;&lt;/pre&gt;
&lt;/figure&gt;
&lt;p&gt;
	That file is sourced last and adds everything that only makes sense on a work machine. Because it runs at number 99 it can safely override or extend anything set earlier.
&lt;/p&gt;
&lt;p&gt;
	The personal host overlay (&lt;code&gt;03hosts/mbp-dvk/zsh/&lt;/code&gt;) follows the same pattern with its own &lt;code&gt;99-personal.zsh&lt;/code&gt;.
&lt;/p&gt;
&lt;p&gt;
	Adding a new work-only shell feature means creating or editing a file in &lt;code&gt;02identity/work/zsh/.zshrc.d/&lt;/code&gt;. Adding something machine-specific goes into &lt;code&gt;03hosts/&amp;lt;hostname&amp;gt;/zsh/.zshrc.d/&lt;/code&gt;. Neither touches the base configuration, and the ordering guarantees the base is always established before any overlay runs.
&lt;/p&gt;
		</content>
	</entry>
	<entry>
		<title type="text">Leveraging advanced gitconfig customization options</title>
		<id>https://vknobelsdorff.com/automation/gitconfig-includes</id>
		<link href="https://vknobelsdorff.com/automation/gitconfig-includes" rel="alternate">
		</link>
		<updated>2026-04-01T11:00:30Z</updated>
		<summary type="text">
			Using the includeIf option to streamline and organize my gitconfig
		</summary>
		<content type="html">
			&lt;p&gt;
	The &lt;code&gt;.gitconfig&lt;/code&gt; was a configuration file I never gave much attention. When I migrated away from GitHub to self-hosting on Forgejo (see &lt;a href=&quot;/homelab/moving-away-from-github/&quot;&gt;Moving away from GitHub&lt;/a&gt;) the need arose to better organize the configurations deployed across my various machines. Reading through the documentation I found one option in particular I was not aware of: conditional includes.
&lt;/p&gt;
&lt;h2&gt;
	The problem
&lt;/h2&gt;
&lt;p&gt;
	I use multiple git forges. On my personal machine I push to both GitHub and my self-hosted Forgejo instance. On my work machine I additionally push to a corporate GitLab. Each forge has its own email address, signing key, and commit settings. I wanted all of this handled automatically without having to set it per repository.
&lt;/p&gt;
&lt;h2&gt;
	includeIf to the rescue
&lt;/h2&gt;
&lt;p&gt;
	Git&apos;s &lt;code&gt;includeIf&lt;/code&gt; directive lets you pull in a separate config file only when a condition is met. The condition I use is &lt;code&gt;hasconfig:remote.*.url&lt;/code&gt;, which matches against any remote URL in the current repository:
&lt;/p&gt;
&lt;figure class=&quot;highlight&quot;&gt;
	&lt;pre&gt;&lt;code class=&quot;language-gitconfig&quot;&gt;[include]
    path = ~/.gitconfig-base
[includeIf &amp;quot;hasconfig:remote.*.url:git@github.com*/**&amp;quot;]
    path = ~/.gitconfig-github
[includeIf &amp;quot;hasconfig:remote.*.url:git@mydomain.tld*/**&amp;quot;]
    path = ~/.gitconfig-forgejo
[includeIf &amp;quot;hasconfig:remote.*.url:ssh://git@corporate.tld/**&amp;quot;]
    path = ~/.gitconfig-work
&lt;/code&gt;&lt;/pre&gt;
&lt;/figure&gt;
&lt;p&gt;
	The base config is always included first and sets the defaults: my default name, email, delta pager settings, rebase options, push behavior, and so on. The conditional includes then override only what needs to differ per forge, mainly the email and SSH signing key:
&lt;/p&gt;
&lt;figure class=&quot;highlight&quot;&gt;
	&lt;pre&gt;&lt;code class=&quot;language-gitconfig&quot;&gt;# .gitconfig-forgejo
[user]
    name = ...
    email = ...
    signingkey = ssh-ed25519 AAAAC3...
[commit]
    verbose = true
    gpgsign = true
&lt;/code&gt;&lt;/pre&gt;
&lt;/figure&gt;
&lt;h2&gt;
	The ordering matters
&lt;/h2&gt;
&lt;p&gt;
	The base config deliberately turns off commit signing (&lt;code&gt;gpgsign = false&lt;/code&gt;). The conditional includes then turn it back on only for the forges where I have signing keys set up. Because git applies includes in order and later values override earlier ones, placing the conditional includes after the base include ensures the per-forge settings win.
&lt;/p&gt;
&lt;h2&gt;
	Conclusion
&lt;/h2&gt;
&lt;p&gt;
	This setup means I never touch per-repository git config for identity. Cloning a repo from GitHub automatically uses the GitHub email and signing key. Cloning from Forgejo picks up the Forgejo settings. Everything is driven by the remote URL.
&lt;/p&gt;
		</content>
	</entry>
	<entry>
		<title type="text">Quickly switch network profiles on macOS</title>
		<id>https://vknobelsdorff.com/macos/quickly-switch-network-profiles-on-macos</id>
		<link href="https://vknobelsdorff.com/macos/quickly-switch-network-profiles-on-macos" rel="alternate">
		</link>
		<updated>2026-04-01T10:59:50Z</updated>
		<summary type="text">
			Creating a custom script to switch between different network environment
		</summary>
		<content type="html">
			&lt;p&gt;
	My network infrastructure is, thanks to my homelab, probably a bit more involved than for the average user. I maintain a VPN network, custom DNS servers, and both Wi-Fi and Ethernet connections. When switching workplaces I constantly have to switch network settings. macOS offers Network Locations which first looked like the answer, but they are too limited for my needs. So I built a small script around the existing macOS CLIs.
&lt;/p&gt;
&lt;h2&gt;
	What I needed
&lt;/h2&gt;
&lt;p&gt;
	My typical network contexts each require a different combination of settings:
&lt;/p&gt;
&lt;ul&gt;
	&lt;li&gt;
		&lt;p&gt;&lt;strong&gt;Home&lt;/strong&gt;: Wi-Fi on DHCP, DNS pointing at my homelab resolvers, VPN off
		&lt;/p&gt;
	&lt;/li&gt;
	&lt;li&gt;
		&lt;p&gt;&lt;strong&gt;Work&lt;/strong&gt;: Wi-Fi on DHCP, system default DNS, VPN off
		&lt;/p&gt;
	&lt;/li&gt;
	&lt;li&gt;
		&lt;p&gt;&lt;strong&gt;Home Ethernet&lt;/strong&gt;: Ethernet on DHCP, custom DNS, Wi-Fi off
		&lt;/p&gt;
	&lt;/li&gt;
	&lt;li&gt;
		&lt;p&gt;&lt;strong&gt;Modem Management&lt;/strong&gt;: Ethernet with a manual static IP for accessing my modem&apos;s admin interface, no custom DNS
		&lt;/p&gt;
	&lt;/li&gt;
	&lt;li&gt;
		&lt;p&gt;&lt;strong&gt;VPN&lt;/strong&gt;: Wi-Fi on DHCP, VPN on
		&lt;/p&gt;
	&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;
	Network Locations can store some of this but not all of it. DNS overrides and VPN toggling are outside what they handle cleanly.
&lt;/p&gt;
&lt;h2&gt;
	The script
&lt;/h2&gt;
&lt;p&gt;
	I created a script called &lt;code&gt;netprofile&lt;/code&gt;. It wraps three macOS tools: &lt;code&gt;networksetup&lt;/code&gt; for IP and DNS configuration, &lt;code&gt;scutil&lt;/code&gt; for VPN control, and service validation using &lt;code&gt;networksetup -listallnetworkservices&lt;/code&gt;.
&lt;/p&gt;
&lt;p&gt;
	The script is built around a set of composable low-level functions:
&lt;/p&gt;
&lt;figure class=&quot;highlight&quot;&gt;
	&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;set_dns &amp;quot;Wi-Fi&amp;quot; &amp;quot;192.168.1.10&amp;quot; &amp;quot;192.168.1.11&amp;quot;    # set DNS servers
set_dns &amp;quot;Wi-Fi&amp;quot; &amp;quot;empty&amp;quot;                          # clear DNS back to default
set_wifi &amp;quot;Wi-Fi&amp;quot; dhcp                            # enable Wi-Fi with DHCP
set_wifi &amp;quot;Wi-Fi&amp;quot; off                             # turn Wi-Fi off
set_ethernet &amp;quot;Ethernet USB&amp;quot; manual &amp;quot;192.168.254.99&amp;quot; &amp;quot;255.255.255.0&amp;quot; &amp;quot;192.168.254.1&amp;quot;
set_vpn &amp;quot;homelab&amp;quot; on
set_vpn &amp;quot;homelab&amp;quot; off
&lt;/code&gt;&lt;/pre&gt;
&lt;/figure&gt;
&lt;p&gt;
	Each function handles only one concern. &lt;code&gt;set_wifi&lt;/code&gt; calls &lt;code&gt;networksetup -setairportpower&lt;/code&gt; and optionally &lt;code&gt;networksetup -setdhcp&lt;/code&gt; or &lt;code&gt;networksetup -setmanual&lt;/code&gt;. &lt;code&gt;set_vpn&lt;/code&gt; uses &lt;code&gt;scutil --nc start&lt;/code&gt; and &lt;code&gt;scutil --nc stop&lt;/code&gt;, always stopping before starting to ensure a clean state.
&lt;/p&gt;
&lt;p&gt;
	The profiles themselves are just &lt;code&gt;case&lt;/code&gt; branches in &lt;code&gt;apply_profile&lt;/code&gt; that call these primitives:
&lt;/p&gt;
&lt;figure class=&quot;highlight&quot;&gt;
	&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;Home)
    set_wifi &amp;quot;$WIFI_SERVICE&amp;quot; dhcp
    set_ethernet &amp;quot;$ETH_SERVICE&amp;quot; off
    set_dns &amp;quot;$WIFI_SERVICE&amp;quot; &amp;quot;$DNS01&amp;quot; &amp;quot;$DNS02&amp;quot;
    set_vpn &amp;quot;$VPN_NAME&amp;quot; off
    ;;
&lt;/code&gt;&lt;/pre&gt;
&lt;/figure&gt;
&lt;h2&gt;
	Service validation
&lt;/h2&gt;
&lt;p&gt;
	Before applying any profile the script checks that the named services actually exist on the current machine:
&lt;/p&gt;
&lt;figure class=&quot;highlight&quot;&gt;
	&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;check_service_exists() {
  local service=&amp;quot;$1&amp;quot;
  if ! networksetup -listallnetworkservices | sed &amp;apos;1d&amp;apos; | grep -Eq &amp;quot;^\*?$service$&amp;quot;; then
    err &amp;quot;Network service &amp;apos;$service&amp;apos; not found.&amp;quot;
    exit 1
  fi
}
&lt;/code&gt;&lt;/pre&gt;
&lt;/figure&gt;
&lt;p&gt;
	The &lt;code&gt;sed &amp;apos;1d&amp;apos;&lt;/code&gt; strips the header line from &lt;code&gt;networksetup&lt;/code&gt; output. The regex accounts for inactive services, which &lt;code&gt;networksetup&lt;/code&gt; prefixes with an asterisk.
&lt;/p&gt;
&lt;h2&gt;
	Usage
&lt;/h2&gt;
&lt;p&gt;
	The script supports three modes:
&lt;/p&gt;
&lt;figure class=&quot;highlight&quot;&gt;
	&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;netprofile              # interactive select menu
netprofile Home         # apply profile directly
netprofile --list       # print available profiles
&lt;/code&gt;&lt;/pre&gt;
&lt;/figure&gt;
&lt;p&gt;
	The interactive mode uses bash&apos;s built-in &lt;code&gt;select&lt;/code&gt; which presents a numbered menu. This is useful when running from a launcher or when I forget the exact profile name.
&lt;/p&gt;
&lt;p&gt;
	The service names (&lt;code&gt;Wi-Fi&lt;/code&gt;, &lt;code&gt;Ethernet USB&lt;/code&gt;) and the VPN name (&lt;code&gt;homelab&lt;/code&gt;) are defined as variables at the top of the script. Adjusting for a different machine means changing those four lines.
&lt;/p&gt;
&lt;h2&gt;
	Alfred integration
&lt;/h2&gt;
&lt;p&gt;
	Typing &lt;code&gt;netprofile Home&lt;/code&gt; in a terminal is already fast, but I wanted to trigger it without leaving whatever I was doing. Alfred is my launcher for everything, so the natural step was to build a small workflow around the script.
&lt;/p&gt;
&lt;p&gt;
	&lt;img alt=&quot;An image showing an Alfred workflow for switching between different ssh hosts.&quot; loading=&quot;lazy&quot; src=&quot;/img/quickly-switch-network-profiles-on-macos/alfred-workflow.png&quot; title=&quot;An image showing an Alfred workflow for switching between different ssh hosts.&quot;/&gt;
&lt;/p&gt;
&lt;p&gt;
	The workflow uses a List Filter as its input. Each profile name (&lt;code&gt;Home&lt;/code&gt;, &lt;code&gt;Work&lt;/code&gt;, &lt;code&gt;Home Ethernet&lt;/code&gt;, &lt;code&gt;Modem Management&lt;/code&gt;, &lt;code&gt;VPN&lt;/code&gt;) is an entry in the list with a matching keyword and subtitle. Alfred&apos;s fuzzy matching means typing &lt;code&gt;hom&lt;/code&gt; is enough to surface both &lt;code&gt;Home&lt;/code&gt; and &lt;code&gt;Home Ethernet&lt;/code&gt; instantly.
&lt;/p&gt;
&lt;p&gt;
	The List Filter passes the selected profile title to a Run Script action:
&lt;/p&gt;
&lt;figure class=&quot;highlight&quot;&gt;
	&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;/usr/local/bin/netprofile &amp;quot;$1&amp;quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;/figure&gt;
&lt;p&gt;
	Using the full path to the script avoids any &lt;code&gt;PATH&lt;/code&gt; issues since Alfred does not source the shell environment. The script runs, applies the profile, and exits silently. A Post Notification action at the end of the chain sends a brief macOS notification confirming which profile was activated, so there is visible feedback without having to open a terminal.
&lt;/p&gt;
&lt;p&gt;
	The result is: open Alfred, type two or three characters, press &lt;code&gt;Enter&lt;/code&gt;, and the network switches. The whole interaction takes about two seconds.
&lt;/p&gt;
		</content>
	</entry>
	<entry>
		<title type="text">Building a custom fzf picker</title>
		<id>https://vknobelsdorff.com/automation/ssh-picker-with-fzf</id>
		<link href="https://vknobelsdorff.com/automation/ssh-picker-with-fzf" rel="alternate">
		</link>
		<updated>2026-04-01T10:58:44Z</updated>
		<summary type="text">
			How I create an ssh picker using fzf
		</summary>
		<content type="html">
			&lt;p&gt;
	With the addition of more machines into my homelab my SSH config grew quite large. SSHing into one of my machines is something I do daily, and while the config and keys make it easy, I wanted something faster. I already use fzf heavily for terminal history and directory switching. Since fzf follows the Unix philosophy and does exactly one thing well, fuzzy finding, what gets searched is entirely up to you. So I wrote a small script that fuzzy-finds SSH targets from my config.
&lt;/p&gt;
&lt;h2&gt;
	Parsing the SSH config
&lt;/h2&gt;
&lt;p&gt;
	The script reads &lt;code&gt;~/.ssh/config&lt;/code&gt; and extracts all non-wildcard &lt;code&gt;Host&lt;/code&gt; entries using awk:
&lt;/p&gt;
&lt;figure class=&quot;highlight&quot;&gt;
	&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;mapfile -t hosts &amp;lt; &amp;lt;(
  awk &amp;apos;/^Host / {for(i=2;i&amp;lt;=NF;i++) if ($i !~ /\*/) print $i}&amp;apos; &amp;quot;$ssh_config&amp;quot;
)
&lt;/code&gt;&lt;/pre&gt;
&lt;/figure&gt;
&lt;p&gt;
	The &lt;code&gt;!~ /\*/&lt;/code&gt; filter drops catch-all patterns like &lt;code&gt;Host *&lt;/code&gt; which are configuration defaults rather than actual targets. Each matching token becomes an element in the &lt;code&gt;hosts&lt;/code&gt; array.
&lt;/p&gt;
&lt;h2&gt;
	The picker
&lt;/h2&gt;
&lt;p&gt;
	With the host list ready, fzf takes over. The script behaves differently depending on the environment.
&lt;/p&gt;
&lt;p&gt;
	Inside tmux, it uses &lt;code&gt;fzf-tmux&lt;/code&gt; to open a centered popup at 40% of the screen:
&lt;/p&gt;
&lt;figure class=&quot;highlight&quot;&gt;
	&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;selected_host=$(printf &amp;apos;%s\n&amp;apos; &amp;quot;${hosts[@]}&amp;quot; | fzf-tmux -p 40%,40% \
  --gutter=&amp;apos; &amp;apos; \
  --color=current-fg:blue \
  --color=hl:yellow \
  --no-sort \
  --border-label &amp;apos; ssh hosts &amp;apos; \
  --prompt &amp;apos;&amp;gt; &amp;apos; \
)
&lt;/code&gt;&lt;/pre&gt;
&lt;/figure&gt;
&lt;p&gt;
	Outside tmux, it falls back to inline fzf with &lt;code&gt;--height=40% --reverse&lt;/code&gt;.
&lt;/p&gt;
&lt;h2&gt;
	Opening the session
&lt;/h2&gt;
&lt;p&gt;
	Once a host is selected, the script opens it in a named tmux session:
&lt;/p&gt;
&lt;figure class=&quot;highlight&quot;&gt;
	&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;if [[ -n &amp;quot;${TMUX-}&amp;quot; ]]; then
  tmux new-session -d -s &amp;quot;$selected_host&amp;quot; &amp;quot;ssh $selected_host&amp;quot;
  tmux switch-client -t &amp;quot;$selected_host&amp;quot;
else
  exec tmux new-session -s &amp;quot;$selected_host&amp;quot; &amp;quot;ssh $selected_host&amp;quot;
fi
&lt;/code&gt;&lt;/pre&gt;
&lt;/figure&gt;
&lt;p&gt;
	When already inside tmux, it creates the session detached first and then switches to it. This avoids the nested tmux situation while still landing in a dedicated session per host. When called from outside tmux it starts a new tmux session directly in the foreground.
&lt;/p&gt;
&lt;h2&gt;
	WezTerm integration
&lt;/h2&gt;
&lt;p&gt;
	If the script is running inside a &lt;a href=&quot;https://wezterm.org/&quot;&gt;WezTerm&lt;/a&gt; pane (detected via &lt;code&gt;$WEZTERM_PANE&lt;/code&gt; and &lt;code&gt;$WEZTERM_UNIX_SOCKET&lt;/code&gt;), it delegates to WezTerm&apos;s own SSH workspace picker instead by sending a user-var event via an OSC escape sequence:
&lt;/p&gt;
&lt;figure class=&quot;highlight&quot;&gt;
	&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;printf &amp;apos;\033]1337;SetUserVar=open-wezterm-ssh-picker=%s\007&amp;apos; &amp;quot;$(printf &amp;apos;1&amp;apos; | base64)&amp;quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;/figure&gt;
&lt;p&gt;
	This triggers a Lua plugin on the WezTerm side that handles the picker natively, keeping the experience consistent with the rest of the WezTerm workspace workflow.
	
	I also went ahead and created a keybinding for it so I can quickly toggle the picker:
&lt;/p&gt;
&lt;figure class=&quot;highlight&quot;&gt;
	&lt;pre&gt;&lt;code class=&quot;language-lua&quot;&gt;local wezterm = require(&amp;quot;wezterm&amp;quot;)
local sessionizer = require(&amp;quot;plugins.sessionizer&amp;quot;)

local M = {}

local function spawn_ssh_workspace(window, pane, host)
  local ws_name = &amp;quot;ssh:&amp;quot; .. host
  window:perform_action(
    wezterm.action.SwitchToWorkspace({
      name = ws_name,
      spawn = {
        label = &amp;quot;SSH &amp;quot; .. host,
        cwd = wezterm.home_dir,
        args = { &amp;quot;ssh&amp;quot;, host },
      },
    }),
    pane
  )
end

local ssh_workspace_schema = {
  options = {
    title = &amp;quot;Choose SSH Host&amp;quot;,
    prompt = &amp;quot;SSH host: &amp;quot;,
    always_fuzzy = true,
    callback = function(inner_window, inner_pane, id, _)
      if id then spawn_ssh_workspace(inner_window, inner_pane, id) end
    end,
  },
  sessionizer.SSHHosts {},
}

function M.run_picker(window, pane)
  window:perform_action(sessionizer.show(ssh_workspace_schema), pane)
end

function M.show_picker()
  return wezterm.action_callback(M.run_picker)
end

wezterm.on(&amp;quot;ssh_workspace_picker&amp;quot;, function(window, pane)
  M.run_picker(window, pane)
end)

wezterm.on(&amp;quot;user-var-changed&amp;quot;, function(window, pane, name, value)
  if name == &amp;quot;open-wezterm-ssh-picker&amp;quot; then
    M.run_picker(window, pane)
  end
end)

return M

// ...
table.insert(config.keys, { key = &amp;apos;s&amp;apos;, mods = &amp;apos;SUPER|SHIFT&amp;apos;, action = ssh_picker.show_picker() })
&lt;/code&gt;&lt;/pre&gt;
&lt;/figure&gt;
&lt;p&gt;
	The result is a beautiful, easily accessible and fast SSH picker in my favourite terminal emulator:
&lt;/p&gt;
&lt;p&gt;
	&lt;img alt=&quot;An image showing a SSH picker running natively in the WezTerm emulator.&quot; loading=&quot;lazy&quot; src=&quot;/img/ssh-picker-with-fzf/ssh-picker-in-wezterm.png&quot; title=&quot;An image showing a SSH picker running natively in the WezTerm emulator.&quot;/&gt;
&lt;/p&gt;
		</content>
	</entry>
	<entry>
		<title type="text">Collapsible component in SwiftUI</title>
		<id>https://vknobelsdorff.com/swift/collapsible-component-in-swiftui</id>
		<link href="https://vknobelsdorff.com/swift/collapsible-component-in-swiftui" rel="alternate">
		</link>
		<updated>2026-04-01T10:32:42Z</updated>
		<summary type="text">
			The suprisingly hard challenge of building a collapsible component in SwiftUI
		</summary>
		<content type="html">
			&lt;p&gt;
	As already stated in my previous posts I wanted to match the look and feel of &lt;a href=&quot;https://www.reeder.app/classic/&quot;&gt;Reeder Classic&lt;/a&gt; for my personal RSS reader app. One rather tricky challenge I faced was to recreate the collapsible component to expand or collapse a folder of rss feeds inside a List. Since then I found this excellent blog post which seems to solve this exact issue: &lt;a href=&quot;https://nerdyak.tech/development/2026/03/16/expand-animation-in-SwiftUI-List.html&quot;&gt;Expanding Animations in SwiftUI Lists&lt;/a&gt;. Unfortunately this was not published when I tackled this problem and I came up with a different solution.
&lt;/p&gt;
&lt;p&gt;
	Without further ado lets look at the implementation and highlight some of the key components:
&lt;/p&gt;
&lt;figure class=&quot;highlight&quot;&gt;
	&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;import SwiftUI

extension EnvironmentValues {
    @Entry var dsCollapsibleDepth: Int = 0
}

struct DSCollapsible&amp;lt;Header: View, Content: View&amp;gt;: View {
    @Environment(\.theme) private var theme
    @Environment(\.dsCollapsibleDepth) private var depth
    
    @Binding private var isExpanded: Bool
    private let action: (() -&amp;gt; Void)?
    private let header: Header
    private let content: () -&amp;gt; Content
    private let accessibilityLabel: () -&amp;gt; Text
    
    init(
        isExpanded: Binding&amp;lt;Bool&amp;gt;,
        action: (() -&amp;gt; Void)? = nil,
        accessibilityLabel: @escaping () -&amp;gt; Text,
        @ViewBuilder header: () -&amp;gt; Header,
        @ViewBuilder content: @escaping () -&amp;gt; Content
    ) {
        self._isExpanded = isExpanded
        self.action = action
        self.accessibilityLabel = accessibilityLabel
        self.header = header()
        self.content = content
    }
    
    private func headerAction() {
        if let action {
            action()
        } else {
            withAnimation(theme.tokens.animation.collapsible) {
                isExpanded.toggle()
            }
        }
    }
    
    var body: some View {
        VStack(alignment: .leading, spacing: 0) {
            Button(action: headerAction) {
                HStack(spacing: 0) {
                    HStack {
                        header
                            .font(theme.tokens.font.button)
                            .foregroundStyle(.text.primary)
                        Spacer()
                    }
                    .padding(.leading, theme.tokens.spacing.s + CGFloat(depth) * theme.tokens.spacing.s)
                    .padding(.vertical, theme.tokens.spacing.s)
                    .frame(maxWidth: .infinity, minHeight: 44)

                    DSIcon(&amp;quot;chevron.right&amp;quot;)
                        .font(theme.tokens.font.button)
                        .foregroundStyle(.text.secondary)
                        .rotationEffect(.degrees(isExpanded ? 90 : 0))
                        .animation(theme.tokens.animation.collapsible, value: isExpanded)
                        .padding(.trailing, theme.tokens.spacing.s)
                        .padding(.vertical, theme.tokens.spacing.s)
                        .frame(minHeight: 44)
                        // When there is a navigation action, intercept chevron taps
                        // so they only toggle expand/collapse, not navigate.
                        .highPriorityGesture(action != nil ? TapGesture().onEnded {
                            withAnimation(theme.tokens.animation.collapsible) {
                                isExpanded.toggle()
                            }
                        } : nil)
                }
                .contentShape(.rect)
            }
            .buttonStyle(DSListItemStyle())
            .accessibilityLabel(accessibilityLabel())
            .accessibilityAddTraits(.isButton)
            .accessibilityValue(isExpanded ? Text(&amp;quot;Expanded&amp;quot;) : Text(&amp;quot;Collapsed&amp;quot;))
            .accessibilityAction(named: isExpanded ? Text(&amp;quot;Collapse&amp;quot;) : Text(&amp;quot;Expand&amp;quot;)) {
                withAnimation(theme.tokens.animation.collapsible) { isExpanded.toggle() }
            }
            
            VStack(spacing: 0) {
                if isExpanded {
                    VStack(spacing: 0) {
                        content()
                    }
                    .environment(\.dsCollapsibleDepth, depth + 1)
                    .transition(.opacity.combined(with: .move(edge: .top)))
                }
            }
            .clipped()
        }
        .dsSectionCardStyle(.transparent)
    }
}

struct DSCollapsibleSection&amp;lt;Content: View&amp;gt;: View {
    @State private var isExpanded: Bool
    private let title: TextContent
    private let action: (() -&amp;gt; Void)?
    private let content: () -&amp;gt; Content
    
    init(
        _ title: LocalizedStringKey,
        initiallyExpanded: Bool = true,
        action: (() -&amp;gt; Void)? = nil,
        @ViewBuilder content: @escaping () -&amp;gt; Content
    ) {
        self.title = .localized(title)
        self._isExpanded = State(initialValue: initiallyExpanded)
        self.action = action
        self.content = content
    }
    
    init(
        verbatim title: String,
        initiallyExpanded: Bool = true,
        action: (() -&amp;gt; Void)? = nil,
        @ViewBuilder content: @escaping () -&amp;gt; Content
    ) {
        self.title = .verbatim(title)
        self._isExpanded = State(initialValue: initiallyExpanded)
        self.action = action
        self.content = content
    }
    
    @_disfavoredOverload
    init(
        _ title: String,
        initiallyExpanded: Bool = true,
        action: (() -&amp;gt; Void)? = nil,
        @ViewBuilder content: @escaping () -&amp;gt; Content
    ) {
        self.title = .verbatim(title)
        self._isExpanded = State(initialValue: initiallyExpanded)
        self.action = action
        self.content = content
    }
    
    var body: some View {
        DSCollapsible(isExpanded: $isExpanded, action: action, accessibilityLabel: { title.textView }) {
            title.textView
        } content: {
            content()
        }
        .dsSectionCardStyle(.transparent)
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;/figure&gt;
&lt;p&gt;
	The non obvious solution to animation issues I had is replicated in these few lines:
&lt;/p&gt;
&lt;figure class=&quot;highlight&quot;&gt;
	&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;VStack(spacing: 0) {
    if isExpanded {
        VStack(spacing: 0) {
            content()
        }
        .environment(\.dsCollapsibleDepth, depth + 1)
        .transition(.opacity.combined(with: .move(edge: .top)))
    }
}
.clipped()
&lt;/code&gt;&lt;/pre&gt;
&lt;/figure&gt;
&lt;p&gt;
	Without the clipping of the outer &lt;code&gt;VStack&lt;/code&gt; the expanded content would always animate in weird and non obvious ways. The fix is to provide a stable anchor to the SwiftUI layout system which is rendered regardless of the &lt;code&gt;isExpanded&lt;/code&gt; flag.
&lt;/p&gt;
&lt;p&gt;
	Also you might have noticed the &lt;code&gt;dsCollapsibleDepth&lt;/code&gt; environment value. This environment value allows me to nest collapsible components.  For each nesting the depth is increased by 1 and the rendered item then can read this information and use it for setting the correct padding:
&lt;/p&gt;
&lt;figure class=&quot;highlight&quot;&gt;
	&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;struct DSListItem&amp;lt;Content: View&amp;gt;: View {
    var body: some View {
        Button(action: action) {
            content
                .frame(maxWidth: .infinity, alignment: .leading)
                .padding(.leading, theme.tokens.spacing.s + CGFloat(depth) * theme.tokens.spacing.s) // &amp;lt;-- this is the important part
                .padding(.trailing, theme.tokens.spacing.s)
                .padding(.vertical, theme.tokens.spacing.s)
                .frame(minHeight: 44)
        }
        .containerValue(\.dsSuppressTrailingDivider, true)
        .buttonStyle(DSListItemStyle())
        .dsSectionCardStyle(.transparent)
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;/figure&gt;
&lt;p&gt;
	With this in place we can nest this component freely:
&lt;/p&gt;
&lt;figure class=&quot;highlight&quot;&gt;
	&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;DSList {
    Section {
        DSCollapsibleSection(&amp;quot;Folders&amp;quot;) {
            DSCollapsibleSection(&amp;quot;Tech&amp;quot;) {
                DSListItem(action: {}) {
                    Text(&amp;quot;Some feed name&amp;quot;)
                }
            }
        }
    } header: {
        Text(&amp;quot;Nested Collapsible&amp;quot;)
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;/figure&gt;
&lt;p&gt;
	If we would use this component in the builtin &lt;code&gt;List&lt;/code&gt; component from SwiftUI I think we would need to apply the fixes from the referenced blog post above. (e.g. using an Animatable view that can animate its height). Since I am using my custom DSList component (showcased in &lt;a href=&quot;/ios/swiftui-containers-custom-list&quot;&gt;SwiftUI Container API&lt;/a&gt;) this works flawlessly however.
&lt;/p&gt;
&lt;p&gt;
	I really had hoped to be able to use the existing &lt;code&gt;DisclosureGroup&lt;/code&gt; API but unfortunately Apple does not provide any customization possibilities for it. A custom DisclosureGroup style would be a very welcome addition to the API surface in my opinion. Until then I am using my custom component.
&lt;/p&gt;
&lt;p&gt;
	If you want to see the component in action and how it behaves head over to my other post &lt;a href=&quot;/ios/personal-rss-reader-ios&quot;&gt;Building an iOS RSS reader&lt;/a&gt; where I included a demo video of the application.
&lt;/p&gt;
		</content>
	</entry>
	<entry>
		<title type="text">SwiftUI Container API</title>
		<id>https://vknobelsdorff.com/swift/swiftui-containers-custom-list</id>
		<link href="https://vknobelsdorff.com/swift/swiftui-containers-custom-list" rel="alternate">
		</link>
		<updated>2026-04-01T10:08:32Z</updated>
		<summary type="text">
			Leveraging the SwiftUI Container API to build a custom list component
		</summary>
		<content type="html">
			&lt;p&gt;
	For my personal RSS reader app I wanted to match the look and feel of &lt;a href=&quot;https://www.reeder.app/classic/&quot;&gt;Reeder Classic&lt;/a&gt;. This turned out to be not a trivial task as the customization possibilities especially for the List component are quite restricted. Fortunately Apple did provide us with some API to mitigate this, in the form of custom SwiftUI containers.
&lt;/p&gt;
&lt;p&gt;
	Previously, the only option available was to use collections of a specific data type and a &lt;code&gt;@ViewBuilder&lt;/code&gt; closure. Now with the latest additions, SwiftUI offers new initializers for &lt;code&gt;ForEach&lt;/code&gt; and &lt;code&gt;Group&lt;/code&gt; that provide greater flexibility:
&lt;/p&gt;
&lt;figure class=&quot;highlight&quot;&gt;
	&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;extension ForEach {
    public init&amp;lt;V&amp;gt;(subviews view: V, @ViewBuilder content: @escaping (Subview) -&amp;gt; Content) where Data == ForEachSubviewCollection&amp;lt;Content&amp;gt;, ID == Subview.ID, Content : View, V : View
}

extension ForEach {
    public init&amp;lt;V&amp;gt;(sections view: V, @ViewBuilder content: @escaping (SectionConfiguration) -&amp;gt; Content) where Data == ForEachSectionCollection&amp;lt;Content&amp;gt;, ID == SectionConfiguration.ID, Content : View, V : View
}

extension Group {
    public init&amp;lt;V&amp;gt;(subviews view: V, @ViewBuilder transform: @escaping (SubviewsCollection) -&amp;gt; Result) where Content == GroupElementsOfContent&amp;lt;Base, Result&amp;gt;, Base : View, Result : View
}

extension Group {
    public init&amp;lt;Base, Result&amp;gt;(sections view: Base, @ViewBuilder transform: @escaping (SectionCollection) -&amp;gt; Result) where Content == GroupSectionsOfContent&amp;lt;Base, Result&amp;gt;, Base : View, Result : View
}
&lt;/code&gt;&lt;/pre&gt;
&lt;/figure&gt;
&lt;p&gt;
	So lets create our custom List component around these new APIs:
&lt;/p&gt;
&lt;figure class=&quot;highlight&quot;&gt;
	&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;struct DSList&amp;lt;Content: View&amp;gt;: View {
    @Environment(\.theme) private var theme
    private let content: Content

    init(
        @ViewBuilder content: () -&amp;gt; Content
    ) {
        self.content = content()
    }

    var body: some View {
        ScrollView {
            LazyVStack(alignment: .leading, spacing: theme.tokens.spacing.l) {
                ForEach(sections: content) { section in
                    ThemedSection {
                        section.content
                    } header: {
                        section.header
                    } footer: {
                        section.footer
                    }
                }
            }
            .padding(.horizontal, theme.tokens.spacing.m)
            .padding(.vertical, theme.tokens.spacing.m)
        }
        .scrollBounceBehavior(.always)
        .background(.bg.primary, ignoresSafeAreaEdges: .all)
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;/figure&gt;
&lt;p&gt;
	Here you can see the new &lt;code&gt;ForEach(sections:)&lt;/code&gt; API in practice. It allows us to extract and manage subviews grouped by &lt;code&gt;Section&lt;/code&gt;.
&lt;/p&gt;
&lt;p&gt;
	Lets zoom into the implementation of &lt;code&gt;ThemedSection&lt;/code&gt; to understand the whole layout:
&lt;/p&gt;
&lt;figure class=&quot;highlight&quot;&gt;
	&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;struct ThemedSection&amp;lt;Content: View, Header: View, Footer: View&amp;gt;: View {
    @Environment(\.theme) private var theme
    @State private var cardStyle: DSSectionCardStyle = .card

    private let content: Content
    private let header: Header
    private let footer: Footer

    init(
        @ViewBuilder content: () -&amp;gt; Content,
        @ViewBuilder header: () -&amp;gt; Header,
        @ViewBuilder footer: () -&amp;gt; Footer
    ) {
        self.content = content()
        self.header = header()
        self.footer = footer()
    }

    var body: some View {
        VStack(alignment: .leading, spacing: theme.tokens.spacing.s) {
            header
                .font(theme.tokens.font.text.headline)
                .foregroundStyle(.text.muted)
                .textCase(.uppercase)

            cardContent

            footer
                .font(theme.tokens.font.text.description)
                .foregroundStyle(.text.muted)
        }
        .onPreferenceChange(DSSectionCardStylePreference.self) { style in
            cardStyle = style
        }
    }
    
    @ViewBuilder
    private var cardContent: some View {
        switch cardStyle {
        case .card:
            ThemedCardContent(content: content)
                .background(.bg.surface)
                .clipShape(
                    RoundedRectangle(
                        cornerRadius: theme.tokens.radius.m,
                        style: .continuous
                    )
                )
        case .transparent:
            ThemedCardContent(content: content)
        }
    }
}

extension ThemedSection where Header == EmptyView, Footer == EmptyView {
    init(@ViewBuilder content: () -&amp;gt; Content) {
        self.init(content: content, header: { EmptyView() }, footer: { EmptyView() })
    }
}

extension ThemedSection where Footer == EmptyView {
    init(
        @ViewBuilder content: () -&amp;gt; Content,
        @ViewBuilder header: () -&amp;gt; Header
    ) {
        self.init(content: content, header: header, footer: { EmptyView() })
    }
}

extension ThemedSection where Header == EmptyView {
    init(
        @ViewBuilder content: () -&amp;gt; Content,
        @ViewBuilder footer: () -&amp;gt; Footer
    ) {
        self.init(content: content, header: { EmptyView() }, footer: footer)
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;/figure&gt;
&lt;figure class=&quot;highlight&quot;&gt;
	&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;struct ThemedCardContent&amp;lt;Content: View&amp;gt;: View {
    @Environment(\.theme) private var theme
    let content: Content

    var body: some View {
        Group(subviews: content) { subviews in
            let rows = Array(subviews)
            VStack(spacing: 0) {
                ForEach(0..&amp;lt;rows.count, id: \.self) { index in
                    subviews[index]

                    let isLast = index == subviews.count - 1
                    let suppressed = subviews[index]
                        .containerValues
                        .dsSuppressTrailingDivider

                    if !isLast &amp;amp;&amp;amp; !suppressed {
                        Divider()
                            .foregroundStyle(.border.subtle)
                            .padding(.leading, theme.tokens.spacing.m)
                    }
                }
            }
        }
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;/figure&gt;
&lt;p&gt;
	Again we make use of the new API this time &lt;code&gt;Group(subviews:)&lt;/code&gt;. With this new API we can iterate over each subview inside a given section.  You might have noticed another new addition in this implementation: Container Values.
&lt;/p&gt;
&lt;p&gt;
	SwiftUI introduces &lt;code&gt;ContainerValues&lt;/code&gt;, a powerful tool for creating container-specific modifiers. Unlike &lt;code&gt;EnvironmentValues&lt;/code&gt; or &lt;code&gt;Preferences&lt;/code&gt;, &lt;code&gt;ContainerValues&lt;/code&gt; are accessible only by the direct container, making them ideal for container-specific customizations.
&lt;/p&gt;
&lt;p&gt;
	By default our new custom list component will draw a separator for every row unless its the last row. However in my implementation I want the possibility to suppress the drawing of the Divider and I can do so by leveraging the new Container Values API:
&lt;/p&gt;
&lt;figure class=&quot;highlight&quot;&gt;
	&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;extension ContainerValues {
    @Entry var dsSuppressTrailingDivider: Bool = false
}

struct DSListItem: View {
	var body: some View {
		Button {
			// ...
		}
		.containerValue(\.dsSuppressTrailingDivider, true)
        .buttonStyle(.listItem)
        .dsSectionCardStyle(.transparent)
	}
}
&lt;/code&gt;&lt;/pre&gt;
&lt;/figure&gt;
&lt;p&gt;
	We now have a custom list component which renders a list using &lt;code&gt;ScrollView&lt;/code&gt; and &lt;code&gt;LazyVStack&lt;/code&gt; with arbitrary content. We can customize the look and feel however we like. We also can set specific behaviour through Container Values and the API looks very &quot;SwiftUI-y&quot;:
&lt;/p&gt;
&lt;figure class=&quot;highlight&quot;&gt;
	&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;DSList {
    Section {
        DSListItem(action: {}) {
            HStack {
                Text(&amp;quot;Unread&amp;quot;)
                    .font(.headline)
                Spacer()
                Text(&amp;quot;20&amp;quot;)
                    .font(theme.tokens.font.text.default)
                    .foregroundStyle(.secondary)
            }
            .font(theme.tokens.font.button)
            .foregroundStyle(.text.primary, .text.secondary)
        }
    } header: {
        Text(&amp;quot;Folders&amp;quot;)
    }
    
    // ...
}
&lt;/code&gt;&lt;/pre&gt;
&lt;/figure&gt;
&lt;p&gt;
	Do you also already worked with this rather new API? What do you think is missing? Let me know.
&lt;/p&gt;
&lt;p&gt;
	If you are curious how this component looks in action head over to my article &lt;a href=&quot;/ios/personal-rss-reader-ios/&quot;&gt;Building an iOS RSS reader&lt;/a&gt; which provides a short demo video showcasing it.
&lt;/p&gt;
		</content>
	</entry>
	<entry>
		<title type="text">Adaptive Theming in SwiftUI</title>
		<id>https://vknobelsdorff.com/swift/adaptive-theming-in-swiftui</id>
		<link href="https://vknobelsdorff.com/swift/adaptive-theming-in-swiftui" rel="alternate">
		</link>
		<updated>2026-04-01T09:45:18Z</updated>
		<summary type="text">
			How to build a dynamic theming system in SwiftUI
		</summary>
		<content type="html">
			&lt;p&gt;
	One key aspect of my new personal RSS reader application is its dynamic theming capability. When I saw the themes &lt;a href=&quot;https://www.terrygodier.com/current&quot;&gt;Current&lt;/a&gt; provides I knew I wanted something similar for my own personal project.
&lt;/p&gt;
&lt;p&gt;
	Fortunately SwiftUI comes with a variety of APIs which make this possible to achieve in a clean way. A few requirements up front:
&lt;/p&gt;
&lt;ol&gt;
	&lt;li&gt;
		&lt;p&gt;
			Themes should switch dynamically
		&lt;/p&gt;
	&lt;/li&gt;
	&lt;li&gt;
		&lt;p&gt;
			Themes should be available in a light and dark variant
		&lt;/p&gt;
	&lt;/li&gt;
	&lt;li&gt;
		&lt;p&gt;
			Variants of a theme should switch based on the user preferred color scheme
		&lt;/p&gt;
	&lt;/li&gt;
	&lt;li&gt;
		&lt;p&gt;
			I want easy access to the theme primitives in the code
		&lt;/p&gt;
	&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;
	Lets start with the data model for the theme:
&lt;/p&gt;
&lt;figure class=&quot;highlight&quot;&gt;
	&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;struct Theme: Hashable {
    let name: String
    let description: ThemeDescription
    let tokens: UiTokens
    
    init(name: String, description: ThemeDescription, tokens: UiTokens) {
        self.name = name
        self.description = description
        self.tokens = tokens
    }
    
    static let paper: Theme = .init(
        name: &amp;quot;Paper&amp;quot;,
        description: .init(
            light: NSLocalizedString(&amp;quot;Warm parchment tones inspired by printed books. The default palette, perfect for a cozy, literary reading experience.&amp;quot;, comment: &amp;quot;&amp;quot;),
            dark: NSLocalizedString(&amp;quot;Warm charcoal with amber accents, like reading by candlelight. Ideal for evening reading sessions.&amp;quot;, comment: &amp;quot;&amp;quot;)
        ),
        tokens: .init(
            color: .init(
                bg: .init(
                    primary: .init(light: Color(hex: &amp;quot;#FAF8F5&amp;quot;)!, dark: Color(hex: &amp;quot;#1C1915&amp;quot;)!),
                    // ...
                ),
                // ...
            )
        )
    )
}

struct ThemeDescription: Hashable {
    let light: String
    let dark: String

    func resolved(for colorScheme: ColorScheme) -&amp;gt; String {
        colorScheme == .dark ? dark : light
    }
}

struct UiTokens: Hashable {
    let color: ColorToken
    // ...
}

struct ColorToken: Hashable {
    let bg: Background
    // ...
    
    struct Background: Hashable {
        let primary: ThemeAdaptiveStyle&amp;lt;Color&amp;gt;
        // ...
    }
}
// ...
&lt;/code&gt;&lt;/pre&gt;
&lt;/figure&gt;
&lt;p&gt;
	Looking at this model you might notice the usage of &lt;code&gt;ThemeAdaptiveStyle&lt;/code&gt; so what is this exactly? It&apos;s my wrapper struct to a light and a dark variant for a given color token:
&lt;/p&gt;
&lt;figure class=&quot;highlight&quot;&gt;
	&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;struct ThemeAdaptiveStyle&amp;lt;Style: Sendable &amp;amp; Hashable&amp;gt;: Sendable, Hashable {
    let light: Style
    let dark: Style
}

extension ThemeAdaptiveStyle {
    func resolved(for colorScheme: ColorScheme) -&amp;gt; Style {
        colorScheme == .dark ? dark : light
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;/figure&gt;
&lt;p&gt;
	With this in place I can then proceed to create a custom SwiftUI &lt;code&gt;ShapeStyle&lt;/code&gt;:
&lt;/p&gt;
&lt;figure class=&quot;highlight&quot;&gt;
	&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;struct BgShapeStyle: ShapeStyle {
    nonisolated(unsafe) let keyPath: KeyPath&amp;lt;Theme, ThemeAdaptiveStyle&amp;lt;Color&amp;gt;&amp;gt;

    func resolve(in environment: EnvironmentValues) -&amp;gt; some ShapeStyle {
        environment.theme[keyPath: keyPath].resolved(for: environment.colorScheme)
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;/figure&gt;
&lt;p&gt;
	To make this available we need an extension on &lt;code&gt;ShapeStyle&lt;/code&gt;:
&lt;/p&gt;
&lt;figure class=&quot;highlight&quot;&gt;
	&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;struct BgNamespace {
    var primary: BgShapeStyle { .init(keyPath: \.tokens.color.bg.primary) }
// ...
}

extension ShapeStyle where Self == BgShapeStyle {
    static var bg: BgNamespace { .init() }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;/figure&gt;
&lt;p&gt;
	Now we are able to set the primary background color via &lt;code&gt;.background(.bg.primary)&lt;/code&gt; and it will automatically adapt to the light or dark variant thanks to the resolving of the environment color scheme in the custom shape style.
&lt;/p&gt;
&lt;p&gt;
	This won&apos;t compile currently as the custom shape style uses &lt;code&gt;environment.theme&lt;/code&gt; to access the currently selected  theme so lets add this key:
&lt;/p&gt;
&lt;figure class=&quot;highlight&quot;&gt;
	&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;extension EnvironmentValues {
    @Entry var theme = Theme.paper
}
&lt;/code&gt;&lt;/pre&gt;
&lt;/figure&gt;
&lt;p&gt;
	Everything thats left is to a) persist the selected theme and color scheme and b) provide a way to set this for the user:
&lt;/p&gt;
&lt;figure class=&quot;highlight&quot;&gt;
	&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;@MainActor final class AppSettings: ObservableObject {
    private static let defaultTheme: Theme = .paper

    @Published var theme: Theme = defaultTheme
    @AppStorage private var themeName: String
    @AppStorage var colorScheme: ColorScheme?

    init(store: UserDefaults = .standard) {
        _themeName = AppStorage(wrappedValue: Self.defaultTheme.name, &amp;quot;themeName&amp;quot;, store: store)
        _colorScheme = AppStorage(&amp;quot;colorScheme&amp;quot;, store: store)

        theme = Theme.allCases.first { $0.name == themeName } ?? Self.defaultTheme
    }
    
    func setTheme(_ theme: Theme) {
        self.theme = theme
        self.themeName = theme.name
    }
}

// NOTE: This allows @AppStorage to store an optional ColorScheme
extension ColorScheme: @retroactive RawRepresentable {
    public init?(rawValue: Int) {
        guard let userInterfaceStyle = UIUserInterfaceStyle(rawValue: rawValue),
              userInterfaceStyle != .unspecified,
              let colorScheme = ColorScheme(userInterfaceStyle)
        else {
            return nil
        }

        self = colorScheme
    }
    
    public var rawValue: Int {
        let userInterfaceStyle = UIUserInterfaceStyle(self)
        return userInterfaceStyle.rawValue
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;/figure&gt;
&lt;p&gt;
	With the theme and the color scheme stored in the &lt;code&gt;UserDefaults&lt;/code&gt; we can use this new &lt;code&gt;AppSettings&lt;/code&gt; class to store and retrieve the persisted values. I created a custom binding for this:
&lt;/p&gt;
&lt;figure class=&quot;highlight&quot;&gt;
	&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;private var selectedTheme: Binding&amp;lt;Theme&amp;gt; {
    .init {
        appSettings.theme
    } set: { theme in
        appSettings.setTheme(theme)
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;/figure&gt;
&lt;p&gt;
	Make sure to set the environment variables as early as possible to the persisted ones in your application:
&lt;/p&gt;
&lt;figure class=&quot;highlight&quot;&gt;
	&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;@StateObject private var appSettings: AppSettings = .live
@Environment(\.colorScheme) private var colorScheme
    
.environment(\.theme, appSettings.theme)
.preferredColorScheme(appSettings.colorScheme ?? colorScheme)
.tint(appSettings.theme.tokens.color.text.accent.resolved(for: colorScheme))
&lt;/code&gt;&lt;/pre&gt;
&lt;/figure&gt;
&lt;p&gt;
	The &lt;code&gt;?? colorScheme&lt;/code&gt; is needed in case the colorScheme is nil when we want to apply the system/automatic color scheme mode. Make sure to also apply this to opened sheets as they sometimes do not update correctly otherwise.
&lt;/p&gt;
&lt;p&gt;
	This is quite a lot of code but ultimately we have a dynamic theming system at hand which we can easily extend and which adapts to the users preferences. Using our theme constants couldn&apos;t be easier as it works exactly like builtin SwiftUI shape styles.
&lt;/p&gt;
&lt;p&gt;
	If you are curious how this theming solution looks like in practice head over to my article &lt;a href=&quot;/ios/personal-rss-reader-ios/&quot;&gt;Building an iOS RSS reader&lt;/a&gt; which provides a short demo video showcasing the theming in action.
&lt;/p&gt;
		</content>
	</entry>
	<entry>
		<title type="text">Building an iOS RSS reader</title>
		<id>https://vknobelsdorff.com/ios/personal-rss-reader-ios</id>
		<link href="https://vknobelsdorff.com/ios/personal-rss-reader-ios" rel="alternate">
		</link>
		<updated>2026-04-01T08:52:08Z</updated>
		<summary type="text">
			A quick rundown of things I learned while building my own personal RSS reader for the iOS platform
		</summary>
		<content type="html">
			&lt;p&gt;
	I felt the urge to build my own RSS reader for quite a while. However a lot of perfectly valid options exist, especially for iOS in my opinion.
&lt;/p&gt;
&lt;p&gt;
	For example &lt;a href=&quot;https://netnewswire.com/&quot;&gt;NetNewsWire&lt;/a&gt; is a great piece of software and even better it&apos;s open source!
	
	Until now, I&apos;ve been an avid user of the &lt;a href=&quot;https://www.reeder.app/classic/&quot;&gt;Reeder Classic&lt;/a&gt;. Two features, in particular, made it difficult for me to switch to any other client:
&lt;/p&gt;
&lt;ol&gt;
	&lt;li&gt;
		&lt;p&gt;&lt;strong&gt;Navigation/User Experience:&lt;/strong&gt; I think Reeder truly excels in this regard.
		&lt;/p&gt;
	&lt;/li&gt;
	&lt;li&gt;
		&lt;p&gt;&lt;strong&gt;Instapaper integration:&lt;/strong&gt; For my read-it-later needs I used Instapaper. Having my saved articles in the same application and enjoying the same reading experience as my RSS feeds was very important for me.
		&lt;/p&gt;
	&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;
	So why would I start and build my own client?
	
	As part of my ongoing effort to replace hosted services with my own self-hosted alternatives I decided to migrate all my saved articles from Instapaper to &lt;a href=&quot;https://readeck.org/en/&quot;&gt;Readeck&lt;/a&gt;. Unfortunately no client exists yet which can both display RSS feeds as well as my saved articles from Readeck. Of course I could consume Readeck articles via a RSS feed but then I would not be able to mark them as read easily. Because of this and because I thought it would be a fun challenge I fired up Xcode and started a new project.
&lt;/p&gt;
&lt;p&gt;
	The current version is usable for my needs though it feels a bit rough around the edges. In terms of design and user experience I stayed close to the choices made by Reeder Classic and I incorporated the themes from the new cool kid on the block (&lt;a href=&quot;https://www.terrygodier.com/current&quot;&gt;Current&lt;/a&gt;). Please note this is a purely personal project. Yes I borrowed most of the themes from this application but I have no intention to ever release it publicly. It’s meant solely for my personal use. The combination of supported services (Miniflux and Readeck) means it probably wouldn’t attract many customers regardless.
&lt;/p&gt;
&lt;p&gt;
	In the upcoming posts I will share some of the challenges I faced building this application with SwiftUI and some interesting things I learned along the way. For now, all that’s left is to share a short demo video:
&lt;/p&gt;
&lt;div class=&quot;yt-container&quot;&gt;
    &lt;iframe
     class=&quot;responsive-iframe&quot;
     src=&quot;https://www.youtube.com/embed/TOsKhpujH_g&quot;
     frameborder=&quot;0&quot;
     allow=&quot;accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture&quot;
     allowfullscreen&gt;
    &lt;/iframe&gt;
&lt;/div&gt;
		</content>
	</entry>
	<entry>
		<title type="text">Proxmox Disaster Recovery</title>
		<id>https://vknobelsdorff.com/homelab/proxmox-disaster-recovery</id>
		<link href="https://vknobelsdorff.com/homelab/proxmox-disaster-recovery" rel="alternate">
		</link>
		<updated>2026-04-01T08:35:35Z</updated>
		<summary type="text">
			How to recover data from a Proxmox VE Host in case of an emergency
		</summary>
		<content type="html">
			&lt;p&gt;
	I wrote about the newest addition to my homelab with the purchase of the JetKVM.
	
	I set things up and everything seemed to be working just fine but I always like to practice for an emergency when it comes to backups or data recovery. When an issue arrises I want to know what I am doing and that things will work. So here is a quick rundown of how I tested accessing my Proxmox VE host when for example after a kernel update or after changes to the GRUB bootloader the web UI/ssh won&apos;t be accessible:
&lt;/p&gt;
&lt;ol&gt;
	&lt;li&gt;
		&lt;p&gt;
			Boot with &lt;a href=&quot;https://www.proxmox.com/en/downloads/proxmox-virtual-environment&quot;&gt;Proxmox ISO&lt;/a&gt; through the JetKVM and its Virtual Media feature
		&lt;/p&gt;
	&lt;/li&gt;
	&lt;li&gt;
		&lt;p&gt;
			In the installer select Advanced Options &gt;  Install Proxmox VE (Debug Mode)
		&lt;/p&gt;
	&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;
	In the console
&lt;/h2&gt;
&lt;p&gt;
	Now with the installer started in Debug Mode a console is available which we can use:
&lt;/p&gt;
&lt;figure class=&quot;highlight&quot;&gt;
	&lt;pre&gt;&lt;code class=&quot;language-sh&quot;&gt;exit
/sbin/modprobe zfs # load zfs
mkdir /RESCUE # create mount point
zpool import -f -R  /RESCUE rpool
&lt;/code&gt;&lt;/pre&gt;
&lt;/figure&gt;
&lt;p&gt;
	Please note this is specific to my ZFS pool setup and might differ for you if you either do not use ZFS or have a different pool name in your installation.
&lt;/p&gt;
&lt;p&gt;
	Now make the necessary changes in the directory &lt;code&gt;/RESCUE&lt;/code&gt; to fix the installation.
	&lt;br/&gt;
	Then start Proxmox again via:
&lt;/p&gt;
&lt;figure class=&quot;highlight&quot;&gt;
	&lt;pre&gt;&lt;code class=&quot;language-sh&quot;&gt;cd /
zpool export rpool
reboot
&lt;/code&gt;&lt;/pre&gt;
&lt;/figure&gt;
&lt;p&gt;
	Lets just hope I will never need this for my production server but when I do I will be more than happy to have tested it works as expected.
&lt;/p&gt;
		</content>
	</entry>
	<entry>
		<title type="text">Moving away from GitHub</title>
		<id>https://vknobelsdorff.com/homelab/moving-away-from-github</id>
		<link href="https://vknobelsdorff.com/homelab/moving-away-from-github" rel="alternate">
		</link>
		<updated>2026-03-31T08:16:59Z</updated>
		<summary type="text">
			How I migrated from GitHub to my self-hosted Forgejo instance and moved my blog away from GitHub Pages
		</summary>
		<content type="html">
			&lt;p&gt;
	As part of my ongoing effort to gain more digital independence I decided to move away all my code from GitHub earlier this year. Up until then I was heavily invested in using GitHub but the shift towards AI scanning my code and the outages of the service with increasing frequency were the last straw that break&apos;s the camels back.
&lt;/p&gt;
&lt;h2&gt;
	Migrating the repositories
&lt;/h2&gt;
&lt;p&gt;
	No migration without a good plan. So I started to look what I would need to move over first.
	
	The repositories were pretty straightforward. I created a simple little script that would do the following:
&lt;/p&gt;
&lt;ol&gt;
	&lt;li&gt;
		&lt;p&gt;
			Get all private and public repositories via the &lt;a href=&quot;https://cli.github.com/&quot;&gt;GitHub CLI&lt;/a&gt;.
		&lt;/p&gt;
	&lt;/li&gt;
	&lt;li&gt;
		&lt;p&gt;
			Use the Forgejo API (&lt;code&gt;/api/repos/migrate&lt;/code&gt;) to migrate each of the repos
		&lt;/p&gt;
	&lt;/li&gt;
	&lt;li&gt;
		&lt;p&gt;
			Delete the repository on GitHub via the GitHub CLI
		&lt;/p&gt;
	&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;
	However this simple migrating left one thing to be missed: my blog.
	
	Previously I was using GitHub Pages to host my blog. Of course using GitHub pages is a totally valid option but I wanted to migrate everything and therefore looked for alternatives. I found the &lt;a href=&quot;https://docs.codeberg.org/codeberg-pages/&quot;&gt;Codeberg Pages&lt;/a&gt; offering but at the time of my migration this was not very stable and I wasn&apos;t so sure whether I should rely on it. Instead I opted for just hosting it by my own.
&lt;/p&gt;
&lt;h2&gt;
	Migrating GitHub Pages to nginx
&lt;/h2&gt;
&lt;p&gt;
	To keep things simple I used nginx with certbot for SSL certificate renewal as I already knew these tools and did not want to invest any further time into looking at alternatives like &lt;a href=&quot;https://caddyserver.com/docs/quick-starts/reverse-proxy&quot;&gt;Caddy&lt;/a&gt;. In the end this is a very low volume static website and nginx will handle it totally fine. Since this post is about the migration to Forgejo I won&apos;t go much into detail here but I basically installed certbot and nginx, setup certbot with a cron job to automatically renew my certificate via LetsEncrypt and configured nginx with a basic HTTPS only configuration to serve a folder of static html files.
&lt;/p&gt;
&lt;p&gt;
	With automatic certificate renewing, the webserver and the firewall in place I could already upload a simple test HTML file and it worked flawlessly. However one key component was still missing: Deploying the actual blog.
&lt;/p&gt;
&lt;h2&gt;
	Deploying the blog via Forgejo CI
&lt;/h2&gt;
&lt;p&gt;
	One thing that I always liked about using GitHub Pages was the integration with GitHub actions to automatically deploy my blog whenever I make any changes. I use a custom static site generator I build in the programming language Swift which turns markdown files I write into the HTML you can see when browsing this very blog.
&lt;/p&gt;
&lt;p&gt;
	Since Forgejo Workflows are more or less compatible with GitHub actions creating the actual workflow configuration was pretty straightforward.
&lt;/p&gt;
&lt;figure class=&quot;highlight&quot;&gt;
	&lt;pre&gt;&lt;code class=&quot;language-yml&quot;&gt;# .forgejo/workflows/deploy.yml
on:
  push:
    branches:
      - main

enable-email-notifications: true

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
        - name: Checkout 🛎️
          uses: actions/checkout@v4 # https://code.forgejo.org/actions/checkout@v4
        
        - uses: actions/setup-swift@v2 # https://github.com/swift-actions/setup-swift@v2
          with:
            swift-version: &amp;quot;6.2&amp;quot;
          name: Setup Swift
       
        - name: Get Swift version
          run: swift --version
        
        - name: Build Site 🔧
          run: swift run
          shell: sh
      
        - name: Verify build output
          run: ls -la Site/
          shell: sh

        - name: Install rsync
          run: |
            apt-get update
            apt-get -yq install rsync
          shell: sh
      
        - name: Deploy via rsync
          run: |
            mkdir -p ~/.ssh
            echo &amp;quot;${{ secrets.SSH_PRIVATE_KEY }}&amp;quot; &amp;gt; ~/.ssh/id_rsa
            chmod 600 ~/.ssh/id_rsa
            rsync -avz -vv --progress --delete -e &amp;quot;ssh -p ${{ secrets.REMOTE_PORT }} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null&amp;quot; Site/ ${{ secrets.REMOTE_USER }}@${{ secrets.REMOTE_HOST }}:${{ secrets.REMOTE_PATH }}
          shell: sh
&lt;/code&gt;&lt;/pre&gt;
&lt;/figure&gt;
&lt;p&gt;
	The workflow builds the site with the Swift compiler and then writes the output via rsync to my web server. I had some issues getting the forgejo runner setup correctly especially finding and hosting the correct docker image for the runner (ubuntu-latest in this example configuration).
	
	Eventually I figured it out and then went one step ahead and replaced the actions above from pointing to remote URLs to my own actions repository.
	
	To consume these actions without the need to specify the host I updated my forgejo app instance configuration with the following:
&lt;/p&gt;
&lt;figure class=&quot;highlight&quot;&gt;
	&lt;pre&gt;&lt;code class=&quot;language-ini&quot;&gt;# gitea/conf/app.ini
[actions]
ENABLED = true
DEFAULT_ACTIONS_URL = https://mydomain.tld
&lt;/code&gt;&lt;/pre&gt;
&lt;/figure&gt;
&lt;p&gt;
	This way I keep full control of what is running in my CI and am again a bit more independent in this whole process.
&lt;/p&gt;
&lt;p&gt;
	Let me know whether you recently did something similar. Did you encounter any issues? Do you have any questions regarding further details about the migration process? I am happy to hear from you.
&lt;/p&gt;
		</content>
	</entry>
</feed>