<?xml version="1.0" encoding="utf-8" standalone="yes"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/">
  <channel>
    <title>kunat.dev</title>
    <link>https://kunat.dev/</link>
    <description>Recent content on kunat.dev</description>
    <generator>Hugo -- 0.147.5</generator>
    <language>en-us</language>
    <copyright>2025 kunat.dev</copyright>
    <lastBuildDate>Thu, 30 Apr 2026 18:33:50 +0200</lastBuildDate>
    <atom:link href="https://kunat.dev/index.xml" rel="self" type="application/rss+xml" />
    <item>
      <title>PhotoSorter Released</title>
      <link>https://kunat.dev/notes/photosorter-release/</link>
      <pubDate>Thu, 30 Apr 2026 18:33:50 +0200</pubDate>
      <guid>https://kunat.dev/notes/photosorter-release/</guid>
      <description>&lt;p&gt;&lt;em&gt;tl;dr &lt;a href=&#34;https://github.com/bkunat/PhotoSorter&#34; target=&#34;_blank&#34; &gt;PhotoSorter&lt;/a&gt; is a tiny macOS utility for sorting loose images into folders with keyboard shortcuts.&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;&lt;img alt=&#34;PhotoSorter header&#34; loading=&#34;lazy&#34; src=&#34;https://kunat.dev/notes/images/photosorter-release/photosorter-header.jpg&#34;&gt;&lt;/p&gt;
&lt;p&gt;I built PhotoSorter for myself a few months ago because I needed a faster way to clean up folders full of random images.&lt;/p&gt;
&lt;p&gt;By &amp;ldquo;photo library&amp;rdquo; I mean photos managed by Photos on macOS, Google Photos, or any other actual photo management app. PhotoSorter is for everything outside of that. Screenshots, saved memes, random images from Downloads, and all the other stuff that somehow piles up on disk.&lt;/p&gt;</description>
      <content:encoded><![CDATA[<p><em>tl;dr <a href="https://github.com/bkunat/PhotoSorter" target="_blank" >PhotoSorter</a> is a tiny macOS utility for sorting loose images into folders with keyboard shortcuts.</em></p>
<p><img alt="PhotoSorter header" loading="lazy" src="/notes/images/photosorter-release/photosorter-header.jpg"></p>
<p>I built PhotoSorter for myself a few months ago because I needed a faster way to clean up folders full of random images.</p>
<p>By &ldquo;photo library&rdquo; I mean photos managed by Photos on macOS, Google Photos, or any other actual photo management app. PhotoSorter is for everything outside of that. Screenshots, saved memes, random images from Downloads, and all the other stuff that somehow piles up on disk.</p>
<p>It does one thing: you point it at a folder, create a few categories, and sort images one by one using keyboard shortcuts.</p>
<video controls muted playsinline preload="metadata" poster="/notes/images/photosorter-release/photosorter-demo-poster.jpg" style="width: 100%; border-radius: 8px;">
  <source src="/notes/images/photosorter-release/photosorter-demo.mp4" type="video/mp4">
</video>
<p>Each shortcut moves the current image into its matching category folder and advances to the next one. That&rsquo;s it.</p>
<h2 id="why">Why</h2>
<p>Every few months I need to declutter my Desktop and Downloads folders from all kinds of random files, most of which are screenshots.</p>
<p>I&rsquo;m a hoarder. I don&rsquo;t delete stuff. I just put things away for later in case I ever need them.</p>
<p><img alt="A meme about keeping useful stuff because it might be useful someday" loading="lazy" src="/notes/images/photosorter-release/useful-meme.jpg"></p>
<p>Why shouldn&rsquo;t I. Storage is effectively free. At least it felt that way back in 2023 when I was setting up my NAS.</p>
<p><img alt="A screenshot of disk prices" loading="lazy" src="/notes/images/photosorter-release/disk-prices.png"></p>
<p>Technically I could add all those images to my photo library and let Photos sort them for me, but I prefer to keep them separate.</p>
<p>First, I like to keep my photo library clean. Adding hundreds of random screenshots is the opposite of that.</p>
<p>Second, while doing the quarterly image sorting, I often find stuff where some action is required: schedule an appointment, follow up with someone, create a Jira ticket, whatever. Most of it is just screenshots of tweets tho.</p>
<p><img alt="A random saved tweet screenshot" loading="lazy" src="/notes/images/photosorter-release/random-tweet.png"></p>
<p>Before PhotoSorter I did this manually by dragging and dropping images into different folders. At some point I tried using macOS tags in Finder, but those don&rsquo;t support keyboard shortcuts either. Both solutions required way too much mouse movement.</p>
<p>I couldn&rsquo;t find a good existing solution for this exact problem, so I built PhotoSorter.</p>
<p>There are probably a dozen people around the world who need this kind of tool, but I wanted to share it nonetheless. I&rsquo;ve been using it for a while now and it has already saved me a lot of time.</p>
<h2 id="resources">Resources</h2>
<ul>
<li><a href="https://github.com/bkunat/PhotoSorter" target="_blank" >PhotoSorter on GitHub</a></li>
</ul>
]]></content:encoded>
    </item>
    <item>
      <title>Managing Sonarr and Radarr on the Go with Ruddarr for iOS</title>
      <link>https://kunat.dev/notes/ruddarr-ios/</link>
      <pubDate>Sun, 29 Mar 2026 23:08:51 +0200</pubDate>
      <guid>https://kunat.dev/notes/ruddarr-ios/</guid>
      <description>&lt;p&gt;It&amp;rsquo;s been almost three years since I set up &lt;a href=&#34;https://github.com/Sonarr/Sonarr&#34; target=&#34;_blank&#34; &gt;Sonarr&lt;/a&gt; and &lt;a href=&#34;https://github.com/Radarr/Radarr&#34; target=&#34;_blank&#34; &gt;Radarr&lt;/a&gt; on my NAS. Both do an amazing job managing my media library, and the setup has been rock solid.&lt;/p&gt;
&lt;p&gt;The only part I never enjoyed was managing the library on the go.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;If you&amp;rsquo;re building a similar setup, these posts might help:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&#34;https://kunat.dev/notes/my-synology-setup/&#34; &gt;My Synology Setup&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&#34;https://kunat.dev/notes/tailscale-subnet-routers/&#34; &gt;Configuring Tailscale Subnet Routing to Access Docker Containers by LAN IP&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&#34;https://kunat.dev/notes/synology-caddy-reverse-proxy/&#34; &gt;Setting Up Caddy as a Reverse Proxy on Synology NAS&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;&lt;/blockquote&gt;
&lt;h2 id=&#34;the-fix&#34;&gt;The Fix&lt;/h2&gt;
&lt;p&gt;Use &lt;a href=&#34;https://ruddarr.com/&#34; target=&#34;_blank&#34; &gt;Ruddarr&lt;/a&gt;.&lt;/p&gt;</description>
      <content:encoded><![CDATA[<p>It&rsquo;s been almost three years since I set up <a href="https://github.com/Sonarr/Sonarr" target="_blank" >Sonarr</a> and <a href="https://github.com/Radarr/Radarr" target="_blank" >Radarr</a> on my NAS. Both do an amazing job managing my media library, and the setup has been rock solid.</p>
<p>The only part I never enjoyed was managing the library on the go.</p>
<blockquote>
<p>If you&rsquo;re building a similar setup, these posts might help:</p>
<ul>
<li><a href="/notes/my-synology-setup/" >My Synology Setup</a></li>
<li><a href="/notes/tailscale-subnet-routers/" >Configuring Tailscale Subnet Routing to Access Docker Containers by LAN IP</a></li>
<li><a href="/notes/synology-caddy-reverse-proxy/" >Setting Up Caddy as a Reverse Proxy on Synology NAS</a></li>
</ul></blockquote>
<h2 id="the-fix">The Fix</h2>
<p>Use <a href="https://ruddarr.com/" target="_blank" >Ruddarr</a>.</p>
<p><img alt="Ruddarr on iPhone" loading="lazy" src="/notes/images/ruddarr-ios/ruddarr-ios-header.jpg"></p>
<p>My initial plan was to check what clients already exist, decide I don&rsquo;t like any of them, and build something for myself over a weekend.</p>
<p>That plan lasted maybe five minutes.</p>
<p>Ruddarr has pretty much everything I want from a mobile companion app. Browsing feels native, adding new movies and TV shows is fast, and the whole experience is vastly better than using the stock Sonarr and Radarr web UIs on iPhone.</p>
<p>The fact that <a href="https://github.com/ruddarr/app" target="_blank" >it&rsquo;s open source</a> is the cherry on top. The app itself is free, setup takes minutes, and if your instances are already reachable from your phone there&rsquo;s really no reason not to give it a try.</p>
<p>That&rsquo;s it. I thought I&rsquo;d share. If I had known there was a Sonarr and Radarr client that matched my setup this well, I would have been using it from day one.</p>
<h2 id="alternative">Alternative</h2>
<p>If you want one app that supports more of your self-hosted stack, <a href="https://apps.apple.com/app/id1638624921" target="_blank" >Helmarr</a> is worth a look too. Ruddarr is still the better fit for me, but Helmarr goes beyond just Sonarr and Radarr.</p>
]]></content:encoded>
    </item>
    <item>
      <title>Convert PDF to Markdown Locally on macOS</title>
      <link>https://kunat.dev/notes/convert-pdf-to-markdown-on-macos/</link>
      <pubDate>Thu, 12 Feb 2026 12:17:30 +0100</pubDate>
      <guid>https://kunat.dev/notes/convert-pdf-to-markdown-on-macos/</guid>
      <description>&lt;p&gt;Today I needed to convert a PDF to Markdown on macOS. I tried CLIs I could install with &lt;code&gt;brew&lt;/code&gt;, but none of them gave me decent output for structure and formatting. &lt;a href=&#34;https://github.com/datalab-to/marker&#34; target=&#34;_blank&#34; &gt;&lt;code&gt;marker-pdf&lt;/code&gt;&lt;/a&gt; was the first one that consistently gave me clean Markdown, even though it required more work to set up.&lt;/p&gt;
&lt;p&gt;I used this &lt;code&gt;convert.py&lt;/code&gt; script:&lt;/p&gt;
&lt;div class=&#34;highlight&#34;&gt;&lt;pre tabindex=&#34;0&#34; style=&#34;color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;&#34;&gt;&lt;code class=&#34;language-python&#34; data-lang=&#34;python&#34;&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#75715e&#34;&gt;#!/usr/bin/env python3&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#f92672&#34;&gt;import&lt;/span&gt; subprocess
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#f92672&#34;&gt;from&lt;/span&gt; pathlib &lt;span style=&#34;color:#f92672&#34;&gt;import&lt;/span&gt; Path
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#66d9ef&#34;&gt;def&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;convert_pdf_to_markdown&lt;/span&gt;(pdf_file: str) &lt;span style=&#34;color:#f92672&#34;&gt;-&amp;gt;&lt;/span&gt; &lt;span style=&#34;color:#66d9ef&#34;&gt;None&lt;/span&gt;:
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    pdf_path &lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt; Path(pdf_file)&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;expanduser()&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;resolve()
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    &lt;span style=&#34;color:#66d9ef&#34;&gt;if&lt;/span&gt; &lt;span style=&#34;color:#f92672&#34;&gt;not&lt;/span&gt; pdf_path&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;exists():
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;        &lt;span style=&#34;color:#66d9ef&#34;&gt;raise&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;FileNotFoundError&lt;/span&gt;(&lt;span style=&#34;color:#e6db74&#34;&gt;f&lt;/span&gt;&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;PDF not found: &lt;/span&gt;&lt;span style=&#34;color:#e6db74&#34;&gt;{&lt;/span&gt;pdf_path&lt;span style=&#34;color:#e6db74&#34;&gt;}&lt;/span&gt;&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;&lt;/span&gt;)
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    root_dir &lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt; pdf_path&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;parent
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    venv_dir &lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt; root_dir &lt;span style=&#34;color:#f92672&#34;&gt;/&lt;/span&gt; &lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;.venv&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    output_dir &lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt; root_dir &lt;span style=&#34;color:#f92672&#34;&gt;/&lt;/span&gt; &lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;output&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    python_bin &lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt; venv_dir &lt;span style=&#34;color:#f92672&#34;&gt;/&lt;/span&gt; &lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;bin&amp;#34;&lt;/span&gt; &lt;span style=&#34;color:#f92672&#34;&gt;/&lt;/span&gt; &lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;python&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    pip_bin &lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt; venv_dir &lt;span style=&#34;color:#f92672&#34;&gt;/&lt;/span&gt; &lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;bin&amp;#34;&lt;/span&gt; &lt;span style=&#34;color:#f92672&#34;&gt;/&lt;/span&gt; &lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;pip&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    marker_bin &lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt; venv_dir &lt;span style=&#34;color:#f92672&#34;&gt;/&lt;/span&gt; &lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;bin&amp;#34;&lt;/span&gt; &lt;span style=&#34;color:#f92672&#34;&gt;/&lt;/span&gt; &lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;marker_single&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    &lt;span style=&#34;color:#66d9ef&#34;&gt;def&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;run&lt;/span&gt;(cmd: list[str]) &lt;span style=&#34;color:#f92672&#34;&gt;-&amp;gt;&lt;/span&gt; &lt;span style=&#34;color:#66d9ef&#34;&gt;None&lt;/span&gt;:
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;        print(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;+&amp;#34;&lt;/span&gt;, &lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34; &amp;#34;&lt;/span&gt;&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;join(str(c) &lt;span style=&#34;color:#66d9ef&#34;&gt;for&lt;/span&gt; c &lt;span style=&#34;color:#f92672&#34;&gt;in&lt;/span&gt; cmd))
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;        subprocess&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;run(cmd, check&lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt;&lt;span style=&#34;color:#66d9ef&#34;&gt;True&lt;/span&gt;)
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    &lt;span style=&#34;color:#66d9ef&#34;&gt;if&lt;/span&gt; &lt;span style=&#34;color:#f92672&#34;&gt;not&lt;/span&gt; python_bin&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;exists():
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;        run([&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;python3&amp;#34;&lt;/span&gt;, &lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;-m&amp;#34;&lt;/span&gt;, &lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;venv&amp;#34;&lt;/span&gt;, str(venv_dir)])
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    &lt;span style=&#34;color:#66d9ef&#34;&gt;if&lt;/span&gt; &lt;span style=&#34;color:#f92672&#34;&gt;not&lt;/span&gt; marker_bin&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;exists():
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;        run([str(pip_bin), &lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;install&amp;#34;&lt;/span&gt;, &lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;marker-pdf&amp;#34;&lt;/span&gt;])
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    output_dir&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;mkdir(parents&lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt;&lt;span style=&#34;color:#66d9ef&#34;&gt;True&lt;/span&gt;, exist_ok&lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt;&lt;span style=&#34;color:#66d9ef&#34;&gt;True&lt;/span&gt;)
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    run(
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;        [
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;            str(marker_bin),
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;            str(pdf_path),
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;            &lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;--output_format&amp;#34;&lt;/span&gt;,
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;            &lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;markdown&amp;#34;&lt;/span&gt;,
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;            &lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;--output_dir&amp;#34;&lt;/span&gt;,
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;            str(output_dir),
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;        ]
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    )
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#66d9ef&#34;&gt;if&lt;/span&gt; __name__ &lt;span style=&#34;color:#f92672&#34;&gt;==&lt;/span&gt; &lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;__main__&amp;#34;&lt;/span&gt;:
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    convert_pdf_to_markdown(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;path/to/the/pdf/you/want/to/convert.pdf&amp;#34;&lt;/span&gt;)
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Then run:&lt;/p&gt;</description>
      <content:encoded><![CDATA[<p>Today I needed to convert a PDF to Markdown on macOS. I tried CLIs I could install with <code>brew</code>, but none of them gave me decent output for structure and formatting. <a href="https://github.com/datalab-to/marker" target="_blank" ><code>marker-pdf</code></a> was the first one that consistently gave me clean Markdown, even though it required more work to set up.</p>
<p>I used this <code>convert.py</code> script:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-python" data-lang="python"><span style="display:flex;"><span><span style="color:#75715e">#!/usr/bin/env python3</span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">import</span> subprocess
</span></span><span style="display:flex;"><span><span style="color:#f92672">from</span> pathlib <span style="color:#f92672">import</span> Path
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">def</span> <span style="color:#a6e22e">convert_pdf_to_markdown</span>(pdf_file: str) <span style="color:#f92672">-&gt;</span> <span style="color:#66d9ef">None</span>:
</span></span><span style="display:flex;"><span>    pdf_path <span style="color:#f92672">=</span> Path(pdf_file)<span style="color:#f92672">.</span>expanduser()<span style="color:#f92672">.</span>resolve()
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> <span style="color:#f92672">not</span> pdf_path<span style="color:#f92672">.</span>exists():
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">raise</span> <span style="color:#a6e22e">FileNotFoundError</span>(<span style="color:#e6db74">f</span><span style="color:#e6db74">&#34;PDF not found: </span><span style="color:#e6db74">{</span>pdf_path<span style="color:#e6db74">}</span><span style="color:#e6db74">&#34;</span>)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    root_dir <span style="color:#f92672">=</span> pdf_path<span style="color:#f92672">.</span>parent
</span></span><span style="display:flex;"><span>    venv_dir <span style="color:#f92672">=</span> root_dir <span style="color:#f92672">/</span> <span style="color:#e6db74">&#34;.venv&#34;</span>
</span></span><span style="display:flex;"><span>    output_dir <span style="color:#f92672">=</span> root_dir <span style="color:#f92672">/</span> <span style="color:#e6db74">&#34;output&#34;</span>
</span></span><span style="display:flex;"><span>    python_bin <span style="color:#f92672">=</span> venv_dir <span style="color:#f92672">/</span> <span style="color:#e6db74">&#34;bin&#34;</span> <span style="color:#f92672">/</span> <span style="color:#e6db74">&#34;python&#34;</span>
</span></span><span style="display:flex;"><span>    pip_bin <span style="color:#f92672">=</span> venv_dir <span style="color:#f92672">/</span> <span style="color:#e6db74">&#34;bin&#34;</span> <span style="color:#f92672">/</span> <span style="color:#e6db74">&#34;pip&#34;</span>
</span></span><span style="display:flex;"><span>    marker_bin <span style="color:#f92672">=</span> venv_dir <span style="color:#f92672">/</span> <span style="color:#e6db74">&#34;bin&#34;</span> <span style="color:#f92672">/</span> <span style="color:#e6db74">&#34;marker_single&#34;</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">def</span> <span style="color:#a6e22e">run</span>(cmd: list[str]) <span style="color:#f92672">-&gt;</span> <span style="color:#66d9ef">None</span>:
</span></span><span style="display:flex;"><span>        print(<span style="color:#e6db74">&#34;+&#34;</span>, <span style="color:#e6db74">&#34; &#34;</span><span style="color:#f92672">.</span>join(str(c) <span style="color:#66d9ef">for</span> c <span style="color:#f92672">in</span> cmd))
</span></span><span style="display:flex;"><span>        subprocess<span style="color:#f92672">.</span>run(cmd, check<span style="color:#f92672">=</span><span style="color:#66d9ef">True</span>)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> <span style="color:#f92672">not</span> python_bin<span style="color:#f92672">.</span>exists():
</span></span><span style="display:flex;"><span>        run([<span style="color:#e6db74">&#34;python3&#34;</span>, <span style="color:#e6db74">&#34;-m&#34;</span>, <span style="color:#e6db74">&#34;venv&#34;</span>, str(venv_dir)])
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> <span style="color:#f92672">not</span> marker_bin<span style="color:#f92672">.</span>exists():
</span></span><span style="display:flex;"><span>        run([str(pip_bin), <span style="color:#e6db74">&#34;install&#34;</span>, <span style="color:#e6db74">&#34;marker-pdf&#34;</span>])
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    output_dir<span style="color:#f92672">.</span>mkdir(parents<span style="color:#f92672">=</span><span style="color:#66d9ef">True</span>, exist_ok<span style="color:#f92672">=</span><span style="color:#66d9ef">True</span>)
</span></span><span style="display:flex;"><span>    run(
</span></span><span style="display:flex;"><span>        [
</span></span><span style="display:flex;"><span>            str(marker_bin),
</span></span><span style="display:flex;"><span>            str(pdf_path),
</span></span><span style="display:flex;"><span>            <span style="color:#e6db74">&#34;--output_format&#34;</span>,
</span></span><span style="display:flex;"><span>            <span style="color:#e6db74">&#34;markdown&#34;</span>,
</span></span><span style="display:flex;"><span>            <span style="color:#e6db74">&#34;--output_dir&#34;</span>,
</span></span><span style="display:flex;"><span>            str(output_dir),
</span></span><span style="display:flex;"><span>        ]
</span></span><span style="display:flex;"><span>    )
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">if</span> __name__ <span style="color:#f92672">==</span> <span style="color:#e6db74">&#34;__main__&#34;</span>:
</span></span><span style="display:flex;"><span>    convert_pdf_to_markdown(<span style="color:#e6db74">&#34;path/to/the/pdf/you/want/to/convert.pdf&#34;</span>)
</span></span></code></pre></div><p>Then run:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>cd path/to/the/folder/with/convert.py
</span></span><span style="display:flex;"><span>chmod +x convert.py
</span></span><span style="display:flex;"><span>./convert.py
</span></span></code></pre></div><p>Output goes to:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>path/to/the/pdf/folder/output/&lt;pdf-name&gt;/&lt;pdf-name&gt;.md
</span></span></code></pre></div><p>First run will be slower because it creates <code>.venv</code>, installs <code>marker-pdf</code>, and downloads model files.</p>
]]></content:encoded>
    </item>
    <item>
      <title>Synology DSM 7.x: qBittorrent WireGuard/NFTables Errors After Update</title>
      <link>https://kunat.dev/notes/synology-qbittorrent-wireguard-nftables/</link>
      <pubDate>Sat, 24 Jan 2026 13:35:18 +0100</pubDate>
      <guid>https://kunat.dev/notes/synology-qbittorrent-wireguard-nftables/</guid>
      <description>&lt;p&gt;Seeing &lt;code&gt;RTNETLINK answers: Not supported&lt;/code&gt; during &lt;code&gt;init-wireguard&lt;/code&gt;, or &lt;code&gt;Error: Could not process rule: Not supported&lt;/code&gt; with &lt;code&gt;add table inet hotio&lt;/code&gt; when starting &lt;code&gt;ghcr.io/hotio/qbittorrent&lt;/code&gt; on DSM 7.2/7.3? That&amp;rsquo;s Synology&amp;rsquo;s 4.4 kernel lacking nftables support after hotio dropped legacy iptables workarounds.&lt;/p&gt;
&lt;p&gt;Typical failure logs look like this:&lt;/p&gt;
&lt;div class=&#34;highlight&#34;&gt;&lt;pre tabindex=&#34;0&#34; style=&#34;color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;&#34;&gt;&lt;code class=&#34;language-text&#34; data-lang=&#34;text&#34;&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;[VPN] Creating interface [wg0-fix].
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;RTNETLINK answers: Not supported
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Or later during firewall setup:&lt;/p&gt;
&lt;div class=&#34;highlight&#34;&gt;&lt;pre tabindex=&#34;0&#34; style=&#34;color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;&#34;&gt;&lt;code class=&#34;language-text&#34; data-lang=&#34;text&#34;&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;Error: Could not process rule: Not supported
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;add table inet hotio
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;h2 id=&#34;the-fix&#34;&gt;The Fix&lt;/h2&gt;
&lt;p&gt;Pin the image to the last known working WireGuard build from the &lt;a href=&#34;https://github.com/TRaSH-Guides/Synology-Templates/pull/222&#34; target=&#34;_blank&#34; &gt;TRaSH-Guides PR #222&lt;/a&gt;:&lt;/p&gt;</description>
      <content:encoded><![CDATA[<p>Seeing <code>RTNETLINK answers: Not supported</code> during <code>init-wireguard</code>, or <code>Error: Could not process rule: Not supported</code> with <code>add table inet hotio</code> when starting <code>ghcr.io/hotio/qbittorrent</code> on DSM 7.2/7.3? That&rsquo;s Synology&rsquo;s 4.4 kernel lacking nftables support after hotio dropped legacy iptables workarounds.</p>
<p>Typical failure logs look like this:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>[VPN] Creating interface [wg0-fix].
</span></span><span style="display:flex;"><span>RTNETLINK answers: Not supported
</span></span></code></pre></div><p>Or later during firewall setup:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>Error: Could not process rule: Not supported
</span></span><span style="display:flex;"><span>add table inet hotio
</span></span></code></pre></div><h2 id="the-fix">The Fix</h2>
<p>Pin the image to the last known working WireGuard build from the <a href="https://github.com/TRaSH-Guides/Synology-Templates/pull/222" target="_blank" >TRaSH-Guides PR #222</a>:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#f92672">services</span>:
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">qbittorrent</span>:
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">image</span>: <span style="color:#ae81ff">ghcr.io/hotio/qbittorrent:release-e799f87</span>
</span></span></code></pre></div><p>Tradeoffs of pinning an old image:</p>
<ul>
<li>No security or feature updates.</li>
<li>Potential breakage when VPN providers change endpoints or auth flows.</li>
<li>You&rsquo;re stuck on older qBittorrent/libtorrent versions.</li>
<li>This is a temporary workaround, not a real fix.</li>
</ul>
<p>Long term, move the VPN to a dedicated container (for example, <a href="https://github.com/qdm12/gluetun" target="_blank" >gluetun</a> with OpenVPN) and run qBittorrent without VPN logic inside its container. That keeps your NAS kernel limitations out of the qBittorrent image and avoids the nftables requirement.</p>
<h2 id="resources">Resources</h2>
<ul>
<li><a href="https://github.com/TRaSH-Guides/Synology-Templates/pull/222" target="_blank" >TRaSH-Guides Synology Templates PR #222</a></li>
<li><a href="https://github.com/qdm12/gluetun" target="_blank" >gluetun</a></li>
<li><a href="https://hotio.dev/containers/qbittorrent" target="_blank" >hotio qBittorrent container docs</a></li>
</ul>
<p>If you&rsquo;re interested, check out how to set up qBittorrent with PIA on Synology, verify your qBittorrent VPN IP, or add a reverse proxy to your Synology setup:</p>
<ul>
<li><a href="/notes/synology-qbitorrent-vpn-pia/" >Setting Up qBittorrent with Private Internet Access (PIA) VPN on Synology NAS</a></li>
<li><a href="/notes/check-torrent-client-vpn-ip/" >Verifying VPN Status for Docker qBittorrent on Synology</a></li>
<li><a href="/notes/synology-caddy-reverse-proxy/" >Setting Up Caddy as a Reverse Proxy on Synology NAS</a></li>
</ul>
]]></content:encoded>
    </item>
    <item>
      <title>Bypass Cisco Umbrella DNS Filtering on macOS</title>
      <link>https://kunat.dev/notes/cisco-umbrella-baypass/</link>
      <pubDate>Fri, 16 Jan 2026 21:08:01 +0100</pubDate>
      <guid>https://kunat.dev/notes/cisco-umbrella-baypass/</guid>
      <description>&lt;p&gt;Cisco Umbrella can block domains by returning fake DNS answers, which makes a site look down even though it works elsewhere. If you only need access to a few domains, bypass the DNS layer for those domains on macOS.&lt;/p&gt;
&lt;p&gt;&lt;img alt=&#34;cisco umbrella block screen&#34; loading=&#34;lazy&#34; src=&#34;https://kunat.dev/notes/images/137073.jpg&#34; title=&#34;cisco umbrella block screen&#34;&gt;&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;⚠️ Disclaimer: This guide is for educational purposes. Bypassing corporate security controls may violate your company&amp;rsquo;s IT policies. Use responsibly and at your own risk.&lt;/p&gt;</description>
      <content:encoded><![CDATA[<p>Cisco Umbrella can block domains by returning fake DNS answers, which makes a site look down even though it works elsewhere. If you only need access to a few domains, bypass the DNS layer for those domains on macOS.</p>
<p><img alt="cisco umbrella block screen" loading="lazy" src="/notes/images/137073.jpg" title="cisco umbrella block screen"></p>
<blockquote>
<p>⚠️ Disclaimer: This guide is for educational purposes. Bypassing corporate security controls may violate your company&rsquo;s IT policies. Use responsibly and at your own risk.</p></blockquote>
<p>First, confirm the block and grab the real IP from an external resolver:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span><span style="color:#75715e"># What your system resolves (potentially filtered)</span>
</span></span><span style="display:flex;"><span>dig +short example.com
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># What the real IP should be (Cloudflare DNS)</span>
</span></span><span style="display:flex;"><span>dig +short example.com @1.1.1.1
</span></span></code></pre></div><p>If the IPs differ, the domain is being filtered.</p>
<h2 id="the-fix">The Fix</h2>
<h3 id="option-1-etchosts-recommended">Option 1: <code>/etc/hosts</code> (recommended)</h3>
<p>Requires admin access. This bypasses DNS for all apps.</p>
<ol>
<li>
<p>Get the real IP:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>dig +short store.steampowered.com @1.1.1.1
</span></span></code></pre></div></li>
<li>
<p>Add the mapping:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>sudo nano /etc/hosts
</span></span></code></pre></div><p>Example entry:</p>
<pre tabindex="0"><code>23.197.161.221 store.steampowered.com
</code></pre></li>
<li>
<p>Verify:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>ping -c <span style="color:#ae81ff">1</span> store.steampowered.com
</span></span></code></pre></div></li>
</ol>
<h3 id="option-2-proxyman-dns-spoofing">Option 2: Proxyman DNS spoofing</h3>
<p>Useful if you want a toggle and do not want to touch system files. Only works for traffic routed through Proxyman.</p>
<ol>
<li>Open Proxyman and go to <strong>Tools → DNS Settings</strong>.</li>
<li>Add a custom mapping with the real IP.</li>
<li>Enable the mapping and ensure Proxyman is capturing traffic.</li>
<li>Test in your browser.</li>
</ol>
<h2 id="why-this-works">Why This Works</h2>
<p>Cisco Umbrella intercepts DNS on port 53 (often via a local proxy like:
<code>/opt/cisco/secureclient/bin/dnscryptproxy --listenPort=53 --listenAddress=127.0.0.1</code>).</p>
<p>Both methods avoid that lookup: <code>/etc/hosts</code> is checked before DNS, and Proxyman can rewrite DNS answers before they hit the network.</p>
<h2 id="troubleshooting">Troubleshooting</h2>
<ul>
<li>
<p>Confirm Umbrella is running: <code>ps aux | grep -i umbrella</code>.</p>
</li>
<li>
<p>Check your hosts entry: <code>grep -n &quot;steam&quot; /etc/hosts</code>.</p>
</li>
<li>
<p>Test a direct IP mapping with curl:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>curl -I https://store.steampowered.com --resolve <span style="color:#e6db74">&#34;store.steampowered.com:443:23.197.161.221&#34;</span>
</span></span></code></pre></div></li>
<li>
<p>Flush DNS cache if needed:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>sudo dscacheutil -flushcache; sudo killall -HUP mDNSResponder
</span></span></code></pre></div></li>
</ul>
<h2 id="resources">Resources</h2>
<ul>
<li><a href="https://docs.umbrella.com/" target="_blank" >Cisco Umbrella documentation</a></li>
<li><a href="https://1.1.1.1/dns/" target="_blank" >Cloudflare 1.1.1.1 DNS</a></li>
<li><a href="https://proxyman.io/dns-mapping" target="_blank" >Proxyman DNS Mapping</a></li>
</ul>
]]></content:encoded>
    </item>
    <item>
      <title>Finding 300 GB in macOS System Data: Time Machine Local Snapshots</title>
      <link>https://kunat.dev/notes/time-machine-snapshots/</link>
      <pubDate>Sat, 27 Dec 2025 14:20:37 +0100</pubDate>
      <guid>https://kunat.dev/notes/time-machine-snapshots/</guid>
      <description>&lt;p&gt;Mail popped up an alert: “Mail cannot save information about your mailboxes because there isn’t enough space in your home folder.”&lt;/p&gt;
&lt;p&gt;&lt;img alt=&#34;macOS alert&#34; loading=&#34;lazy&#34; src=&#34;https://kunat.dev/notes/images/pasted-image-20251225155159.png&#34; title=&#34;mail alert saying there&amp;#39;s no space left on disk&#34;&gt;&lt;/p&gt;
&lt;p&gt;My first two suspects in these situations are always Xcode junk or a full Trash.&lt;/p&gt;
&lt;p&gt;I opened &lt;a href=&#34;https://www.omnigroup.com/more&#34; target=&#34;_blank&#34; &gt;OmniDiskSweeper&lt;/a&gt; and started scanning. I love it because there is no visual fluff. You scan, you delete, you move on. I cleaned up old runtimes, old Xcodes, and VM images. That saved ~70 GB.&lt;/p&gt;</description>
      <content:encoded><![CDATA[<p>Mail popped up an alert: “Mail cannot save information about your mailboxes because there isn’t enough space in your home folder.”</p>
<p><img alt="macOS alert" loading="lazy" src="/notes/images/pasted-image-20251225155159.png" title="mail alert saying there&#39;s no space left on disk"></p>
<p>My first two suspects in these situations are always Xcode junk or a full Trash.</p>
<p>I opened <a href="https://www.omnigroup.com/more" target="_blank" >OmniDiskSweeper</a> and started scanning. I love it because there is no visual fluff. You scan, you delete, you move on. I cleaned up old runtimes, old Xcodes, and VM images. That saved ~70 GB.</p>
<p>The mystery was not solved though. macOS Settings still showed ~300 GB of &ldquo;System Data.&rdquo; OmniDiskSweeper did not show it.</p>
<p>Next up I tried <a href="https://macpaw.com/cleanmymac" target="_blank" >CleanMyMac X</a>. I usually prefer OmniDiskSweeper, but I was out of ideas. It found some big caches, but nothing close to 300 GB.</p>
<p>So I kept googling and landed on <a href="https://daisydiskapp.com/" target="_blank" >DaisyDisk</a>. It was blazing fast. I also do not hate the UI, but I do not love it either. It is definitely original.</p>
<p>DaisyDisk solved the mystery tho: all that &ldquo;System Data&rdquo; was Time Machine local snapshots.</p>
<p>It clicked. A few weeks earlier I had re-set up my Time Machine backup to a remote drive. The backup started failing, I missed it, and the snapshots kept piling up.</p>
<p>I deleted the local snapshots and freed ~300 GB.</p>
<h2 id="remove-local-snapshots">Remove local snapshots</h2>
<p>I followed this thread:</p>
<p><a href="https://apple.stackexchange.com/questions/340905/how-to-delete-all-local-timemachine-snapshots" target="_blank" >How to delete all local TimeMachine snapshots</a></p>
<p>The commands I used:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>tmutil listlocalsnapshots /
</span></span><span style="display:flex;"><span>sudo tmutil deletelocalsnapshots &lt;snapshot-id&gt;
</span></span></code></pre></div><p>If you want to delete all of them, the thread includes a one-liner. I didn’t need anything local, so I wiped everything and got my disk space back.</p>
]]></content:encoded>
    </item>
    <item>
      <title>Swift Package Manager Mirrors for Local Development</title>
      <link>https://kunat.dev/notes/spm-package-mirroring/</link>
      <pubDate>Tue, 23 Dec 2025 19:50:06 +0100</pubDate>
      <guid>https://kunat.dev/notes/spm-package-mirroring/</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;A dependency mirror refers to an alternate source location which exactly replicates the contents of the original source. [1]&lt;/p&gt;&lt;/blockquote&gt;
&lt;p&gt;I often end up in the same situation: I need to tweak an internal package that a project depends on. The usual options are:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Drag and drop the repo into Xcode (Xcode replaces the remote dependency with the local copy)&lt;/li&gt;
&lt;li&gt;Change &lt;code&gt;Package.swift&lt;/code&gt; to a local path dependency&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Both work, but I prefer a CLI-only workflow. Turns out SPM has a cleaner alternative: &lt;strong&gt;dependency mirroring!&lt;/strong&gt;&lt;/p&gt;</description>
      <content:encoded><![CDATA[<blockquote>
<p>A dependency mirror refers to an alternate source location which exactly replicates the contents of the original source. [1]</p></blockquote>
<p>I often end up in the same situation: I need to tweak an internal package that a project depends on. The usual options are:</p>
<ul>
<li>Drag and drop the repo into Xcode (Xcode replaces the remote dependency with the local copy)</li>
<li>Change <code>Package.swift</code> to a local path dependency</li>
</ul>
<p>Both work, but I prefer a CLI-only workflow. Turns out SPM has a cleaner alternative: <strong>dependency mirroring!</strong></p>
<h2 id="set-a-mirror">Set a Mirror</h2>
<p>Point the remote URL to a local repo:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-shell" data-lang="shell"><span style="display:flex;"><span>swift package config set-mirror --original <span style="color:#e6db74">&#34;THE_ORIGINAL_URL&#34;</span> --mirror <span style="color:#e6db74">&#34;LOCAL_PATH&#34;</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># Example</span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># swift package config set-mirror --original &#34;https://github.com/apple/swift-argument-parser&#34; \</span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">#   --mirror &#34;/Users/user/Developer/swift-argument-parser&#34;</span>
</span></span></code></pre></div><p>To confirm:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-shell" data-lang="shell"><span style="display:flex;"><span>swift package config get-mirror --original <span style="color:#e6db74">&#34;https://github.com/apple/swift-argument-parser&#34;</span>
</span></span></code></pre></div><h2 id="important-notes">Important Notes</h2>
<ul>
<li>A mirror only changes where SPM fetches the repo from. It still resolves a version, branch, or revision.</li>
<li>If you want local edits to show up, you must commit them in the mirrored repo.</li>
<li>Use a branch or revision in <code>Package.swift</code>:</li>
</ul>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-swift" data-lang="swift"><span style="display:flex;"><span>dependencies: [
</span></span><span style="display:flex;"><span>    .package(url: <span style="color:#e6db74">&#34;https://github.com/apple/swift-argument-parser&#34;</span>, branch: <span style="color:#e6db74">&#34;main&#34;</span>),
</span></span><span style="display:flex;"><span>]
</span></span></code></pre></div><ul>
<li>Mirrors are matched by the exact URL string. If your dependency uses the <code>.git</code> suffix, set a mirror for that URL too.</li>
</ul>
<h2 id="remove-the-mirror">Remove the Mirror</h2>
<p>Once you are done, remove it:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-shell" data-lang="shell"><span style="display:flex;"><span>swift package config unset-mirror --original https://github.com/apple/swift-argument-parser
</span></span></code></pre></div><p>If you added a <code>.git</code> mirror, remove that one as well.</p>
<h2 id="closing-thoughts">Closing Thoughts</h2>
<p>Mirrors are not a drop-in replacement for local path dependencies. You still need to commit changes, but in return you get a clean, repeatable CLI workflow that works across projects without touching Xcode.</p>
<h2 id="resources">Resources</h2>
<ul>
<li>[1] <a href="https://github.com/swiftlang/swift-evolution/blob/main/proposals/0219-package-manager-dependency-mirroring.md" target="_blank" >swift-evolution/proposals/0219-package-manager-dependency-mirroring.md at main - swiftlang/swift-evolution</a></li>
</ul>
]]></content:encoded>
    </item>
    <item>
      <title>Instant Platinum: My QR Code Shortcut</title>
      <link>https://kunat.dev/notes/instant-platinum-released/</link>
      <pubDate>Tue, 18 Nov 2025 18:55:23 +0100</pubDate>
      <guid>https://kunat.dev/notes/instant-platinum-released/</guid>
      <description>&lt;p&gt;&lt;em&gt;tl;dr &lt;a href=&#34;https://github.com/bkunat/instant-platinum&#34; target=&#34;_blank&#34; &gt;Instant Platinum&lt;/a&gt; v0.1.0 released! 🚀&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;Instant Platinum is an unofficial client for Fitness Platinum. The audience is tiny: a gym franchise in Kraków and Katowice, Poland. That’s fine. This is mostly for me.&lt;/p&gt;
&lt;p&gt;I can’t stand the official app. It’s not terrible, but it’s bloated with things I don’t care about: booking classes, managing goals, shopping (??). I just want to see my entry QR code as soon as I open the app. Today it takes three taps. Not awful, but not great.&lt;/p&gt;</description>
      <content:encoded><![CDATA[<p><em>tl;dr <a href="https://github.com/bkunat/instant-platinum" target="_blank" >Instant Platinum</a> v0.1.0 released! 🚀</em></p>
<p>Instant Platinum is an unofficial client for Fitness Platinum. The audience is tiny: a gym franchise in Kraków and Katowice, Poland. That’s fine. This is mostly for me.</p>
<p>I can’t stand the official app. It’s not terrible, but it’s bloated with things I don’t care about: booking classes, managing goals, shopping (??). I just want to see my entry QR code as soon as I open the app. Today it takes three taps. Not awful, but not great.</p>
<p>So I built my own thing. Enter Instant Platinum: the app figures out which club you’re near and pops up the right QR code instantly. No clicks required. Works on iOS and watchOS.</p>
<p>Open, scan, done.</p>
<p>It took two evenings to put together. I’ll probably never &ldquo;earn back&rdquo; the time I spent, but I’ll happily trade those hours for fewer moments of frustration.</p>
<p>Since the initial release, it’s morphed into a fun little hobby project. I recently shipped <a href="/notes/platinum-pulse-released/" >Platinum Pulse</a>, which I plan to plug in to show historical occupancy inside Instant Platinum. Next up: widgets and shortcuts so the code is ready before I even think about opening the app.</p>
<p>If you by any chance go to Fitness Platinum, go ahead and try it!</p>
]]></content:encoded>
    </item>
    <item>
      <title>A Small Gym Utility That Became a Fun Side Project</title>
      <link>https://kunat.dev/notes/platinum-pulse-released/</link>
      <pubDate>Fri, 07 Nov 2025 13:47:18 +0100</pubDate>
      <guid>https://kunat.dev/notes/platinum-pulse-released/</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;a href=&#34;https://github.com/bkunat/platinum-pulse&#34; target=&#34;_blank&#34; &gt;Platinum Pulse&lt;/a&gt; is a web app that aggregates live occupancy data for &lt;a href=&#34;https://fitnessplatinium.pl/&#34; target=&#34;_blank&#34; &gt;Fitness Platinum clubs&lt;/a&gt;.&lt;/p&gt;&lt;/blockquote&gt;
&lt;p&gt;&lt;img alt=&#34;screenshot showing platinum pulse dashboard&#34; loading=&#34;lazy&#34; src=&#34;https://kunat.dev/notes/images/platinum-pulse-dashboard.png&#34;&gt;&lt;/p&gt;
&lt;p&gt;Small disclaimer: the audience here is tiny. Fitness Platinum is a gym franchise in Kraków and Katowice, Poland.&lt;/p&gt;
&lt;p&gt;I recently built a tiny iOS app to instantly generate the gym QR codes I need. “Doesn’t your gym have an official iOS app?” It does. It’s just bloated with things I don’t use: booking classes, setting goals, shopping (why?), and it takes way too many taps to reach the only feature I care about.&lt;/p&gt;</description>
      <content:encoded><![CDATA[<blockquote>
<p><a href="https://github.com/bkunat/platinum-pulse" target="_blank" >Platinum Pulse</a> is a web app that aggregates live occupancy data for <a href="https://fitnessplatinium.pl/" target="_blank" >Fitness Platinum clubs</a>.</p></blockquote>
<p><img alt="screenshot showing platinum pulse dashboard" loading="lazy" src="/notes/images/platinum-pulse-dashboard.png"></p>
<p>Small disclaimer: the audience here is tiny. Fitness Platinum is a gym franchise in Kraków and Katowice, Poland.</p>
<p>I recently built a tiny iOS app to instantly generate the gym QR codes I need. “Doesn’t your gym have an official iOS app?” It does. It’s just bloated with things I don’t use: booking classes, setting goals, shopping (why?), and it takes way too many taps to reach the only feature I care about.</p>
<p>All I ever wanted was: show me my QR code with as few clicks as possible. Bonus: show it on my Apple Watch. There’s no official watch app, so I spent an evening hacking my own. I called it <strong>Instant Platinum</strong>: an iOS/watchOS app that pops up the right club’s QR code based on location. Zero taps. I’ve been using it for weeks and love it.</p>
<p>Then I wanted more. Specifically, historical occupancy: handy when visiting new clubs or hitting my usual one at odd hours. PerfectGym’s API exposes live occupancy, but I couldn’t find any route to historical data. Enter Platinum Pulse.</p>
<p>It runs on my Synology, polls live occupancy, and stores it so I can see trends over time. Now I can make a quick, data-driven call about which gym to choose on any given day.</p>
<p>It turned into a fun little project. It’s the first thing I’ve built purely for myself that I actually use IRL; until now, most of my tinkering lived in dev tools and shell scripts. I learned a few new tricks. Fun times!</p>
]]></content:encoded>
    </item>
    <item>
      <title>Monitor Job Boards for Keywords with Changedetection.io</title>
      <link>https://kunat.dev/notes/job-boards-notificatons-changedetection/</link>
      <pubDate>Tue, 28 Oct 2025 19:53:19 +0100</pubDate>
      <guid>https://kunat.dev/notes/job-boards-notificatons-changedetection/</guid>
      <description>&lt;p&gt;Problem? I wanted to get notified about new openings as soon as they come up. Even though I&amp;rsquo;m only interested in a handful of companies, manually checking each careers page gets boring pretty quickly. I’m aware there are some paid tools that solve this exact problem (hello &lt;a href=&#34;https://fluxguard.com/&#34; target=&#34;_blank&#34; &gt;Fluxguard&lt;/a&gt;, &lt;a href=&#34;https://versionista.com&#34; target=&#34;_blank&#34; &gt;Versionista&lt;/a&gt;), but I’m too cheap for that.&lt;/p&gt;
&lt;p&gt;In this short tutorial, we’ll set up monitoring using a self-hosted instance of Changedetection.io to solve this problem.&lt;/p&gt;</description>
      <content:encoded><![CDATA[<p>Problem? I wanted to get notified about new openings as soon as they come up. Even though I&rsquo;m only interested in a handful of companies, manually checking each careers page gets boring pretty quickly. I’m aware there are some paid tools that solve this exact problem (hello <a href="https://fluxguard.com/" target="_blank" >Fluxguard</a>, <a href="https://versionista.com" target="_blank" >Versionista</a>), but I’m too cheap for that.</p>
<p>In this short tutorial, we’ll set up monitoring using a self-hosted instance of Changedetection.io to solve this problem.</p>
<h2 id="prerequisites">Prerequisites</h2>
<ul>
<li>Changedetection.io set up and running (<a href="/notes/changedetection-synology-setup/" >setup guide</a>)</li>
<li>Sockpuppetbrowser configured to enable the Playwright Chromium/JavaScript fetch method (most careers sites require JS)
<ul>
<li><em>The setup guide linked above covers this.</em></li>
</ul>
</li>
</ul>
<h2 id="setup">Setup</h2>
<p>Add the company’s careers page and click <code>Edit &gt; Watch</code>.</p>
<p><img loading="lazy" src="/notes/images/job-boards-notificatons-changedetection-1.jpg" title="Adding a new watch URL"></p>
<p>In the <strong>Request</strong> tab, change the <em>Fetch method</em> to <code>Playwright Chromium/JavaScript</code>. While not all sites require this, most modern careers pages need JavaScript to load properly.</p>
<p><img loading="lazy" src="/notes/images/job-boards-notificatons-changedetection-2.jpg" title="Setting fetch method to Playwright"></p>
<p>Go to the <strong>Filters &amp; Triggers</strong> tab and find <em>Keyword triggers – Trigger/wait for text</em>. Add keywords for the roles you&rsquo;re interested in. Here are the ones I use:</p>
<pre tabindex="0"><code>Apple Engineer
Apple Developer
Apple Programmer
iOS Engineer
iOS Developer
iOS Programmer
Mobile Engineer
Mobile Developer
Mobile Programmer
</code></pre><p><img loading="lazy" src="/notes/images/job-boards-notificatons-changedetection-3.jpg"></p>
<p>Save the watch. For additional companies, clone this watch and update the URL.</p>
<h2 id="notifications">Notifications</h2>
<p>Go to <code>Settings -&gt; Notifications</code> and configure your preferred notification method. Changedetection.io supports 80+ options.</p>
<p>I went with Telegram: it’s straightforward to set up and requires no maintenance.</p>
<p><img loading="lazy" src="/notes/images/job-boards-notificatons-changedetection-4.jpg"></p>
<h2 id="resources">Resources</h2>
<ul>
<li><a href="/notes/changedetection-synology-setup/" >Changedetection.io + Chrome Synology Setup with Docker Compose</a></li>
<li><a href="https://github.com/dgtlmoon/changedetection.io" target="_blank" >Changedetection.io</a></li>
</ul>
]]></content:encoded>
    </item>
    <item>
      <title>Setting Up Decap CMS with Hugo and CloudFlare Pages</title>
      <link>https://kunat.dev/notes/decap-cms-github-hugo-cloudflare-pages/</link>
      <pubDate>Mon, 29 Sep 2025 20:31:00 +0200</pubDate>
      <guid>https://kunat.dev/notes/decap-cms-github-hugo-cloudflare-pages/</guid>
      <description>&lt;p&gt;You have a website built with &lt;a href=&#34;https://gohugo.io/&#34; target=&#34;_blank&#34; &gt;Hugo&lt;/a&gt; hosted on &lt;a href=&#34;https://pages.cloudflare.com/&#34; target=&#34;_blank&#34; &gt;CloudFlare Pages&lt;/a&gt; and you want to integrate &lt;a href=&#34;https://decapcms.org/&#34; target=&#34;_blank&#34; &gt;Decap CMS&lt;/a&gt; to it? Here&amp;rsquo;s how.&lt;/p&gt;
&lt;p&gt;At the end of this article you&amp;rsquo;ll be able to add posts to your website and manage existing ones using a Decap admin panel on your website.&lt;/p&gt;
&lt;h2 id=&#34;prerequisites&#34;&gt;Prerequisites&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;Your website is built using Hugo&lt;/li&gt;
&lt;li&gt;Your website is hosted using CloudFlare pages&lt;/li&gt;
&lt;li&gt;You have a GitHub account (we&amp;rsquo;ll use it for OAuth authentication)&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&#34;how&#34;&gt;How&lt;/h2&gt;
&lt;h3 id=&#34;creating-an-oauth-app-in-github&#34;&gt;Creating an OAuth App in GitHub&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;Go to &lt;a href=&#34;https://github.com/settings/apps&#34; target=&#34;_blank&#34; &gt;Developer Settings&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;OAuth Apps&lt;/li&gt;
&lt;li&gt;New OAuth App&lt;/li&gt;
&lt;li&gt;Fill out application name, Homepage URL and Authorization callback URL as follows&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;img loading=&#34;lazy&#34; src=&#34;https://kunat.dev/notes/images/decap-1-github-oauth.jpg&#34; title=&#34;GitHub OAuth app creation screen&#34;&gt;&lt;/p&gt;</description>
      <content:encoded><![CDATA[<p>You have a website built with <a href="https://gohugo.io/" target="_blank" >Hugo</a> hosted on <a href="https://pages.cloudflare.com/" target="_blank" >CloudFlare Pages</a> and you want to integrate <a href="https://decapcms.org/" target="_blank" >Decap CMS</a> to it? Here&rsquo;s how.</p>
<p>At the end of this article you&rsquo;ll be able to add posts to your website and manage existing ones using a Decap admin panel on your website.</p>
<h2 id="prerequisites">Prerequisites</h2>
<ul>
<li>Your website is built using Hugo</li>
<li>Your website is hosted using CloudFlare pages</li>
<li>You have a GitHub account (we&rsquo;ll use it for OAuth authentication)</li>
</ul>
<h2 id="how">How</h2>
<h3 id="creating-an-oauth-app-in-github">Creating an OAuth App in GitHub</h3>
<ol>
<li>Go to <a href="https://github.com/settings/apps" target="_blank" >Developer Settings</a></li>
<li>OAuth Apps</li>
<li>New OAuth App</li>
<li>Fill out application name, Homepage URL and Authorization callback URL as follows</li>
</ol>
<p><img loading="lazy" src="/notes/images/decap-1-github-oauth.jpg" title="GitHub OAuth app creation screen"></p>
<ol start="5">
<li>Register application</li>
<li>Generate a new client secret (we&rsquo;ll need it in a moment)</li>
</ol>
<h3 id="cloudflare">CloudFlare</h3>
<ol>
<li>Open your <a href="https://dash.cloudflare.com/" target="_blank" >CloudFlare dashboard</a></li>
<li>Compute (workers)</li>
<li>Workers and Pages</li>
<li>Select the project you want to set up your CMS for and open settings</li>
<li>Go to Variables and secrets and select Add</li>
<li>Add the following two secrets</li>
</ol>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-shell" data-lang="shell"><span style="display:flex;"><span>GITHUB_CLIENT_SECRET: &lt;secret that you<span style="color:#e6db74">&#39;ve copied in step 6 above&gt;
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">GITHUB_CLIENT_ID: &lt;Client ID from the OAuth app you&#39;</span>ve created before&gt;
</span></span></code></pre></div><h2 id="adding-decap-to-your-site">Adding Decap to your site</h2>
<ol>
<li>Copy <code>functions</code> directory from <a href="https://github.com/SubhenduX/decap-cms-cloudflare-pages" target="_blank" >this project</a> to the root of your website</li>
<li>Copy <code>static</code> directory from <a href="https://github.com/SubhenduX/decap-cms-cloudflare-pages" target="_blank" >this project</a> to the root of your website</li>
<li>Configure <code>/static/admin/config.yml</code> so that it works for your site. Here&rsquo;s how this config file looks for this site:</li>
</ol>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-yml" data-lang="yml"><span style="display:flex;"><span><span style="color:#f92672">backend</span>:
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">name</span>: <span style="color:#ae81ff">github</span>
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">repo</span>: <span style="color:#ae81ff">bkunat/kunat.dev</span>
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">branch</span>: <span style="color:#ae81ff">main</span>
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">base_url</span>: <span style="color:#ae81ff">https://kunat.dev</span>
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">auth_endpoint</span>: <span style="color:#ae81ff">/api/auth</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">media_folder</span>: <span style="color:#e6db74">&#34;content/notes/images&#34;</span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">public_folder</span>: <span style="color:#e6db74">&#34;/notes/images&#34;</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">collections</span>:
</span></span><span style="display:flex;"><span>  - <span style="color:#f92672">name</span>: <span style="color:#e6db74">&#34;notes&#34;</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">label</span>: <span style="color:#e6db74">&#34;Notes&#34;</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">folder</span>: <span style="color:#e6db74">&#34;content/notes&#34;</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">create</span>: <span style="color:#66d9ef">true</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">slug</span>: <span style="color:#e6db74">&#34;{{slug}}&#34;</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">editor</span>:
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">preview</span>: <span style="color:#66d9ef">false</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">fields</span>:
</span></span><span style="display:flex;"><span>      - { <span style="color:#f92672">label</span>: <span style="color:#e6db74">&#34;Title&#34;</span><span style="color:#f92672">, name</span>: <span style="color:#e6db74">&#34;title&#34;</span><span style="color:#f92672">, widget</span>: <span style="color:#e6db74">&#34;string&#34;</span> }
</span></span><span style="display:flex;"><span>      - { <span style="color:#f92672">label</span>: <span style="color:#e6db74">&#34;Publish Date&#34;</span><span style="color:#f92672">, name</span>: <span style="color:#e6db74">&#34;date&#34;</span><span style="color:#f92672">, widget</span>: <span style="color:#e6db74">&#34;datetime&#34;</span><span style="color:#f92672">, date_format</span>: <span style="color:#e6db74">&#34;YYYY-MM-DD&#34;</span><span style="color:#f92672">, time_format</span>: <span style="color:#e6db74">&#34;HH:mm:ss&#34;</span><span style="color:#f92672">, format</span>: <span style="color:#e6db74">&#34;YYYY-MM-DDTHH:mm:ss+02:00&#34;</span> }
</span></span><span style="display:flex;"><span>      - { <span style="color:#f92672">label</span>: <span style="color:#e6db74">&#34;Draft&#34;</span><span style="color:#f92672">, name</span>: <span style="color:#e6db74">&#34;draft&#34;</span><span style="color:#f92672">, widget</span>: <span style="color:#e6db74">&#34;boolean&#34;</span><span style="color:#f92672">, default</span>: <span style="color:#66d9ef">true</span> }
</span></span><span style="display:flex;"><span>      - { <span style="color:#f92672">label</span>: <span style="color:#e6db74">&#34;Tags&#34;</span><span style="color:#f92672">, name</span>: <span style="color:#e6db74">&#34;tags&#34;</span><span style="color:#f92672">, widget</span>: <span style="color:#e6db74">&#34;list&#34;</span><span style="color:#f92672">, allow_add: true, collapsed: false, field</span>: { <span style="color:#f92672">label</span>: <span style="color:#e6db74">&#34;Tag&#34;</span><span style="color:#f92672">, name</span>: <span style="color:#e6db74">&#34;tag&#34;</span><span style="color:#f92672">, widget</span>: <span style="color:#e6db74">&#34;string&#34;</span> } }
</span></span><span style="display:flex;"><span>      - { <span style="color:#f92672">label</span>: <span style="color:#e6db74">&#34;Body&#34;</span><span style="color:#f92672">, name</span>: <span style="color:#e6db74">&#34;body&#34;</span><span style="color:#f92672">, widget</span>: <span style="color:#e6db74">&#34;markdown&#34;</span> }
</span></span></code></pre></div><ol start="4">
<li>Open <code>yoursite.com/admin</code> and log in using your GitHub account</li>
</ol>
<p><img alt="Decap login screen" loading="lazy" src="/notes/images/decap-2-login.jpg" title="Decap login screen"></p>
<h2 id="success">Success</h2>
<p>After a successful login here&rsquo;s how your site should look like:</p>
<p><img alt="Decap home screen" loading="lazy" src="/notes/images/decap-3-final.jpg" title="Decap home screen"></p>
<p>Recently a non-technical friend asked me to create a site for him. He didn&rsquo;t have any special requirements so to save time I just cloned this site. The final missing piece was adding a CMS that would allow him to easily add new content to the site.</p>
<p>I was so positively surprised by the Decap experience that I ended up adding it for myself. Till now I managed all of my content manually with git. That remains my default way to manage content, but being able to quickly add/edit posts from my iPhone or iPad is a great improvement!</p>
<h2 id="resources">Resources</h2>
<ul>
<li>⭐️ <a href="https://github.com/SubhenduX/decap-cms-cloudflare-pages" target="_blank" >GitHub - SubhenduX/decap-cms-cloudflare-pages: Oauth API to run Decap CMS on Cloudflare Pages</a></li>
<li><a href="https://decapcms.org/docs/basic-steps/" target="_blank" >Basic Steps | Decap CMS | Open-Source Content Management System</a></li>
</ul>
]]></content:encoded>
    </item>
    <item>
      <title>Navigate Obsidian Notes with Arrow Keys</title>
      <link>https://kunat.dev/notes/obsidian-arrows-navigation/</link>
      <pubDate>Fri, 05 Sep 2025 17:13:19 +0200</pubDate>
      <guid>https://kunat.dev/notes/obsidian-arrows-navigation/</guid>
      <description>&lt;p&gt;A few months back I migrated my notes from Bear Notes to Obsidian. The switch has been great - I love what Obsidian offers.&lt;/p&gt;
&lt;p&gt;One feature I missed dearly from Bear Notes was the ability to quickly navigate between notes using up/down arrow keys. This couldn&amp;rsquo;t be replicated using Obsidian hotkeys or plugins, and other people were missing this feature too.&lt;/p&gt;
&lt;p&gt;Turns out Obsidian just added this in version 1.9.10!&lt;/p&gt;</description>
      <content:encoded><![CDATA[<p>A few months back I migrated my notes from Bear Notes to Obsidian. The switch has been great - I love what Obsidian offers.</p>
<p>One feature I missed dearly from Bear Notes was the ability to quickly navigate between notes using up/down arrow keys. This couldn&rsquo;t be replicated using Obsidian hotkeys or plugins, and other people were missing this feature too.</p>
<p>Turns out Obsidian just added this in version 1.9.10!</p>
<h2 id="the-fix">The Fix</h2>
<p>In Obsidian 1.9.10+ you can navigate through your notes using <strong>CMD + up/down arrows</strong>.</p>
<p>Make sure the File Explorer view is active, then hold Command/Ctrl while using the arrow keys to browse through files. The file under the cursor will open automatically.</p>
<h2 id="resources">Resources</h2>
<ul>
<li><a href="https://obsidian.md/changelog/" target="_blank" >Obsidian 1.9.10 Changelog</a></li>
<li><a href="https://www.reddit.com/r/ObsidianMD/comments/xrw3wf/navigate_notes_using_arrow_keys/" target="_blank" >Reddit discussion about missing arrow key navigation</a></li>
<li><a href="https://x.com/kepano/status/1900971833677279611" target="_blank" >Kepano&rsquo;s announcement</a></li>
</ul>
]]></content:encoded>
    </item>
    <item>
      <title>Alfred Snippet Converter v0.3.0</title>
      <link>https://kunat.dev/notes/alfredsnippetconverter-v2/</link>
      <pubDate>Mon, 01 Sep 2025 19:41:31 +0200</pubDate>
      <guid>https://kunat.dev/notes/alfredsnippetconverter-v2/</guid>
      <description>&lt;p&gt;&lt;img alt=&#34;hello there&#34; loading=&#34;lazy&#34; src=&#34;https://kunat.dev/notes/images/alfredsnippetconverter-v2/alfredsnippetconverter-v2-header.jpg&#34;&gt;&lt;/p&gt;
&lt;p&gt;Need your Alfred snippets on iPhone and iPad? Alfred Snippet Converter now supports zip archives, drag &amp;amp; drop, and batch conversion of multiple collections.&lt;/p&gt;
&lt;h2 id=&#34;whats-new&#34;&gt;What&amp;rsquo;s New&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Zip Archive Support&lt;/strong&gt; - Convert collections directly after exporting from Alfred without manual extraction.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Drag &amp;amp; Drop&lt;/strong&gt; - Simply drag your collection files onto the app window.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Batch Processing&lt;/strong&gt; - Convert all your Alfred snippet collections in one operation.&lt;/p&gt;
&lt;p&gt;The tool converts Alfred snippets into macOS text replacements, which sync across all your Apple devices via iCloud. Read the full setup guide in &lt;a href=&#34;https://kunat.dev/notes/alfred-snippets-on-ios/&#34; &gt;Alfred Snippets on iOS&lt;/a&gt;.&lt;/p&gt;</description>
      <content:encoded><![CDATA[<p><img alt="hello there" loading="lazy" src="/notes/images/alfredsnippetconverter-v2/alfredsnippetconverter-v2-header.jpg"></p>
<p>Need your Alfred snippets on iPhone and iPad? Alfred Snippet Converter now supports zip archives, drag &amp; drop, and batch conversion of multiple collections.</p>
<h2 id="whats-new">What&rsquo;s New</h2>
<p><strong>Zip Archive Support</strong> - Convert collections directly after exporting from Alfred without manual extraction.</p>
<p><strong>Drag &amp; Drop</strong> - Simply drag your collection files onto the app window.</p>
<p><strong>Batch Processing</strong> - Convert all your Alfred snippet collections in one operation.</p>
<p>The tool converts Alfred snippets into macOS text replacements, which sync across all your Apple devices via iCloud. Read the full setup guide in <a href="/notes/alfred-snippets-on-ios/" >Alfred Snippets on iOS</a>.</p>
<h2 id="resources">Resources</h2>
<ul>
<li><a href="https://github.com/bkunat/AlfredSnippetConverter" target="_blank" >Download Alfred Snippet Converter</a></li>
</ul>
]]></content:encoded>
    </item>
    <item>
      <title>Fix Plex 401 Unauthorized Error in Homarr Dashboard</title>
      <link>https://kunat.dev/notes/homarr-plex-unauthorized/</link>
      <pubDate>Tue, 12 Aug 2025 18:42:06 +0200</pubDate>
      <guid>https://kunat.dev/notes/homarr-plex-unauthorized/</guid>
      <description>&lt;p&gt;&lt;img loading=&#34;lazy&#34; src=&#34;https://kunat.dev/notes/images/homarr-plex-unauthorized/homarr-plex-unauthorized-header.png&#34;&gt;&lt;/p&gt;
&lt;p&gt;Seeing a red dot with &amp;ldquo;401 unauthorized&amp;rdquo; status for Plex in your Homarr dashboard, even though your Plex server runs perfectly fine? The issue is likely your URL format.&lt;/p&gt;
&lt;p&gt;Instead of using the standard IP:PORT format like other containers, Plex requires the full web interface path.&lt;/p&gt;
&lt;h2 id=&#34;the-fix&#34;&gt;The Fix&lt;/h2&gt;
&lt;p&gt;Change your Plex internal address from:&lt;/p&gt;
&lt;pre tabindex=&#34;0&#34;&gt;&lt;code&gt;192.168.1.100:32400
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;To:&lt;/p&gt;
&lt;pre tabindex=&#34;0&#34;&gt;&lt;code&gt;192.168.1.100:32400/web/index.html#!/
&lt;/code&gt;&lt;/pre&gt;&lt;h2 id=&#34;why-this-path-is-required&#34;&gt;Why This Path is Required&lt;/h2&gt;
&lt;blockquote&gt;
&lt;p&gt;Plex&amp;rsquo;s web interface uses a single-page application architecture with hash-based routing. The &lt;code&gt;/web/index.html#!/&lt;/code&gt; path serves as the entry point to the bundled Plex Web App. This specific URL structure is how Plex handles authentication and routing for external integrations like Homarr.&lt;/p&gt;</description>
      <content:encoded><![CDATA[<p><img loading="lazy" src="/notes/images/homarr-plex-unauthorized/homarr-plex-unauthorized-header.png"></p>
<p>Seeing a red dot with &ldquo;401 unauthorized&rdquo; status for Plex in your Homarr dashboard, even though your Plex server runs perfectly fine? The issue is likely your URL format.</p>
<p>Instead of using the standard IP:PORT format like other containers, Plex requires the full web interface path.</p>
<h2 id="the-fix">The Fix</h2>
<p>Change your Plex internal address from:</p>
<pre tabindex="0"><code>192.168.1.100:32400
</code></pre><p>To:</p>
<pre tabindex="0"><code>192.168.1.100:32400/web/index.html#!/
</code></pre><h2 id="why-this-path-is-required">Why This Path is Required</h2>
<blockquote>
<p>Plex&rsquo;s web interface uses a single-page application architecture with hash-based routing. The <code>/web/index.html#!/</code> path serves as the entry point to the bundled Plex Web App. This specific URL structure is how Plex handles authentication and routing for external integrations like Homarr.</p></blockquote>
<blockquote>
<p>Unlike other services that expose simple API endpoints at their base URL, Plex requires access through its web application interface to properly authenticate and respond to status checks.</p></blockquote>
<ul>
<li><a href="https://github.com/ajnart/homarr/discussions/1407" target="_blank" >Homarr Plex 401 Discussion</a></li>
<li><a href="https://support.plex.tv/articles/201638786-plex-media-server-url-commands/" target="_blank" >Plex Media Server URL Commands | Plex Support</a></li>
</ul>
]]></content:encoded>
    </item>
    <item>
      <title>Fix Key Repeat Issues with Cursor&#39;s Vim Plugin</title>
      <link>https://kunat.dev/notes/cursor-vim-plugin/</link>
      <pubDate>Tue, 12 Aug 2025 18:23:34 +0200</pubDate>
      <guid>https://kunat.dev/notes/cursor-vim-plugin/</guid>
      <description>&lt;p&gt;When using Cursor&amp;rsquo;s Vim plugin on macOS, you might notice that holding down keys like &lt;code&gt;j&lt;/code&gt; or &lt;code&gt;k&lt;/code&gt; for navigation doesn&amp;rsquo;t repeat as expected. Instead of continuous movement, the system shows accent character options or simply doesn&amp;rsquo;t repeat at all.&lt;/p&gt;
&lt;p&gt;This happens because macOS has &amp;ldquo;Press and Hold&amp;rdquo; enabled by default, which interferes with Vim&amp;rsquo;s key repeat functionality.&lt;/p&gt;
&lt;h2 id=&#34;the-fix&#34;&gt;The Fix&lt;/h2&gt;
&lt;p&gt;Run this command in Terminal to disable Press and Hold specifically for Cursor:&lt;/p&gt;</description>
      <content:encoded><![CDATA[<p>When using Cursor&rsquo;s Vim plugin on macOS, you might notice that holding down keys like <code>j</code> or <code>k</code> for navigation doesn&rsquo;t repeat as expected. Instead of continuous movement, the system shows accent character options or simply doesn&rsquo;t repeat at all.</p>
<p>This happens because macOS has &ldquo;Press and Hold&rdquo; enabled by default, which interferes with Vim&rsquo;s key repeat functionality.</p>
<h2 id="the-fix">The Fix</h2>
<p>Run this command in Terminal to disable Press and Hold specifically for Cursor:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>defaults write com.todesktop.230313mzl4w4u92 ApplePressAndHoldEnabled -bool false
</span></span></code></pre></div><p>Restart Cursor for the changes to take effect.</p>
<h2 id="finding-your-bundle-id">Finding Your Bundle ID</h2>
<p>If the command above doesn&rsquo;t work (Cursor updates might change the bundle ID), you can find the correct identifier:</p>
<ol>
<li>Right-click on Cursor in Applications</li>
<li>Select &ldquo;Show Package Contents&rdquo;</li>
<li>Open <code>Contents/Info.plist</code></li>
<li>Look for the <code>CFBundleIdentifier</code> value</li>
</ol>
<p>Replace the bundle ID in the command with your specific identifier.</p>
<h2 id="resources">Resources</h2>
<ul>
<li><a href="https://stackoverflow.com/questions/39972335/how-do-i-press-and-hold-a-key-and-have-it-repeat-in-vscode" target="_blank" >How do I press and hold a key and have it repeat in vscode? | Stack Overflow</a></li>
</ul>
]]></content:encoded>
    </item>
    <item>
      <title>Configuring Tailscale Subnet Routing to Access Docker Containers by LAN IP</title>
      <link>https://kunat.dev/notes/tailscale-subnet-routers/</link>
      <pubDate>Mon, 14 Jul 2025 18:34:57 +0200</pubDate>
      <guid>https://kunat.dev/notes/tailscale-subnet-routers/</guid>
      <description>&lt;p&gt;I&amp;rsquo;ve been running various Docker containers on my Synology NAS for years, and while Tailscale has been excellent for accessing the NAS itself remotely, I found myself frustrated by one specific limitation: I could &lt;strong&gt;only&lt;/strong&gt; access my Docker containers by their LAN IP addresses when I was actually connected to my home network.&lt;/p&gt;
&lt;p&gt;When I was away from home, even though I could reach my Synology via Tailscale address or Magic DNS, trying to access something like &lt;code&gt;192.168.1.144:7878&lt;/code&gt; for Radarr would just time out.&lt;/p&gt;</description>
      <content:encoded><![CDATA[<p>I&rsquo;ve been running various Docker containers on my Synology NAS for years, and while Tailscale has been excellent for accessing the NAS itself remotely, I found myself frustrated by one specific limitation: I could <strong>only</strong> access my Docker containers by their LAN IP addresses when I was actually connected to my home network.</p>
<p>When I was away from home, even though I could reach my Synology via Tailscale address or Magic DNS, trying to access something like <code>192.168.1.144:7878</code> for Radarr would just time out.</p>
<p>That&rsquo;s where <a href="https://tailscale.com/kb/1019/subnets" target="_blank" >Tailscale&rsquo;s subnet routers</a> come in. This feature allows you to access all devices on your home network—including your Docker containers—using their actual LAN IP addresses.</p>
<h2 id="what-were-solving">What We&rsquo;re Solving</h2>
<p>We want to access our Docker containers using their LAN IP addresses consistently, regardless of whether we&rsquo;re connected to our home network or accessing remotely through Tailscale.</p>
<h2 id="prerequisites">Prerequisites</h2>
<ul>
<li>Tailscale already set up on your machine. You can use <a href="https://www.youtube.com/watch?v=qulWDpzdY1E" target="_blank" >this guide</a> to set it up.</li>
</ul>
<h2 id="setting-up-subnet-routing">Setting Up Subnet Routing</h2>
<h3 id="step-1-configure-your-synology-as-a-subnet-router">Step 1: Configure Your Synology as a Subnet Router</h3>
<p>First, you&rsquo;ll need to SSH into your Synology. Once you&rsquo;re connected, run this command:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>sudo tailscale up --advertise-routes<span style="color:#f92672">=</span>192.168.1.0/24 --advertise-exit-node --reset
</span></span></code></pre></div><p><strong>Important:</strong> Make sure to replace <code>192.168.1.0/24</code> with the correct subnet for your network. In my case, my Synology has the IP address <code>192.168.1.144</code>, so I use <code>192.168.1.0/24</code>. You can find your network&rsquo;s subnet by checking your router&rsquo;s configuration or running <code>ip route</code> on your Synology.</p>
<h3 id="step-2-enable-subnet-routing-in-tailscale-admin-console">Step 2: Enable Subnet Routing in Tailscale Admin Console</h3>
<p>The command above advertises your local network routes to Tailscale, but you need to explicitly enable them in the admin console:</p>
<ol>
<li>Go to the Tailscale admin console at <a href="https://login.tailscale.com/admin/machines" target="_blank" >https://login.tailscale.com/admin/machines</a></li>
<li>Find your Synology device in the machines list</li>
<li>Click on the device and select &ldquo;Edit route settings&rdquo;</li>
<li>In the &ldquo;Subnet routers&rdquo; section, check the box next to your advertised route (e.g., <code>192.168.1.0/24</code>)</li>
</ol>
<p><img alt="Xcode build timeline" loading="lazy" src="/notes/images/tailscale-subnet-routers/tailscale-subnet-routers.jpg"></p>
<h2 id="testing-your-setup">Testing Your Setup</h2>
<p>With that configuration complete, you should now be able to access all your Docker containers using their LAN IP addresses, regardless of whether you&rsquo;re connected to your home network or not.</p>
<p>For example, if you have a container running on <code>192.168.1.144:7878</code>, you can access it from anywhere by simply navigating to that address in your browser while connected to Tailscale.</p>
<h2 id="taking-it-further-reverse-proxy-setup">Taking It Further: Reverse Proxy Setup</h2>
<p>You can make this setup even more elegant by implementing a reverse proxy. This allows you to access your services using clean subdomains like <code>radarr.kunat.dev</code> instead of remembering IP addresses and port numbers. You can read more about it <a href="/notes/synology-caddy-reverse-proxy/" >here.</a></p>
<h2 id="resources">Resources</h2>
<ul>
<li><a href="https://tailscale.com/kb/1019/subnets" target="_blank" >Tailscale documentation on subnet routers</a></li>
<li><a href="https://www.youtube.com/watch?v=qulWDpzdY1E" target="_blank" >How To Install And Configure Tailscale On Your Synology Nas</a></li>
<li><a href="/notes/synology-caddy-reverse-proxy/" >Setting Up Caddy as a Reverse Proxy on Synology NAS</a></li>
</ul>
]]></content:encoded>
    </item>
    <item>
      <title>Setting Up Caddy as a Reverse Proxy on Synology NAS</title>
      <link>https://kunat.dev/notes/synology-caddy-reverse-proxy/</link>
      <pubDate>Wed, 09 Jul 2025 15:28:57 +0200</pubDate>
      <guid>https://kunat.dev/notes/synology-caddy-reverse-proxy/</guid>
      <description>&lt;p&gt;Setting up a reverse proxy on your Synology NAS can dramatically improve how you access your Docker containers. Instead of remembering ports for each service, you can use clean subdomains like &lt;code&gt;plex.yourdomain.com&lt;/code&gt; or &lt;code&gt;radarr.yourdomain.com&lt;/code&gt;.&lt;/p&gt;
&lt;h2 id=&#34;whats-caddy&#34;&gt;What&amp;rsquo;s Caddy?&lt;/h2&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;a href=&#34;https://github.com/caddyserver/caddy&#34; target=&#34;_blank&#34; &gt;Caddy&lt;/a&gt; is a modern, open-source web server that excels at reverse proxying. It&amp;rsquo;s lightweight, easy to configure, and handles HTTPS automatically. What sets it apart is its human-readable configuration format and automatic certificate management capabilities.&lt;/p&gt;</description>
      <content:encoded><![CDATA[<p>Setting up a reverse proxy on your Synology NAS can dramatically improve how you access your Docker containers. Instead of remembering ports for each service, you can use clean subdomains like <code>plex.yourdomain.com</code> or <code>radarr.yourdomain.com</code>.</p>
<h2 id="whats-caddy">What&rsquo;s Caddy?</h2>
<blockquote>
<p><a href="https://github.com/caddyserver/caddy" target="_blank" >Caddy</a> is a modern, open-source web server that excels at reverse proxying. It&rsquo;s lightweight, easy to configure, and handles HTTPS automatically. What sets it apart is its human-readable configuration format and automatic certificate management capabilities.</p></blockquote>
<h2 id="why-i-chose-caddy-over-synologys-built-in-solution">Why I Chose Caddy Over Synology&rsquo;s Built-in Solution</h2>
<p>Synology does include a reverse proxy feature, but it has some limitations:</p>
<p><strong>Platform Agnostic</strong>: Caddy runs in Docker, which means it&rsquo;s not tied to Synology&rsquo;s ecosystem. If I ever migrate to a different NAS or server setup, my configuration comes with me.</p>
<p><strong>File-Based Configuration</strong>: This is the big one for me. Synology&rsquo;s reverse proxy requires you to configure each service through their GUI interface. When you&rsquo;re running more than a few containers, this becomes time-consuming. With Caddy, I can define all my services in a single configuration file that&rsquo;s easy to version control and backup.</p>
<p><strong>Better HTTPS Handling</strong>: Caddy&rsquo;s automatic HTTPS capabilities are far superior to what Synology offers out of the box. We won&rsquo;t use those, but I listed it for completeness&rsquo; sake.</p>
<h2 id="goals">Goals</h2>
<p>Access your Docker containers using clean subdomains instead of remembering port numbers. Instead of typing <code>192.168.1.100:9696</code> to reach Prowlarr, you&rsquo;ll use <code>prowlarr.yourdomain.com</code>.</p>
<h2 id="prerequisites">Prerequisites</h2>
<p>Before we dive in, you&rsquo;ll need a few things in place:</p>
<p><strong>Docker Setup</strong>: I&rsquo;m assuming you have Docker set up and running using the <a href="https://trash-guides.info/File-and-Folder-Structure/How-to-set-up/Synology/" target="_blank" >Trash Guides</a>. This isn&rsquo;t strictly required, but I&rsquo;ll assume your directory structure and permissions match what&rsquo;s described in their guide.</p>
<p><strong>Cloudflare Domain Management</strong>: This guide assumes your domain is managed through Cloudflare&rsquo;s dashboard. The DNS records must have the <strong>proxy status enabled</strong> (orange cloud icon). This is crucial for the HTTPS setup we&rsquo;ll implement later.</p>
<p><strong>Local DNS Server</strong>: You&rsquo;ll need to be running your own DNS server like AdGuard Home or Pi-hole. This is required to add a wildcard DNS rewrite rule that points <code>*.yourdomain.com</code> to your Synology&rsquo;s IP address.</p>
<h2 id="setup">Setup</h2>
<h3 id="adding-caddy-to-your-docker-compose">Adding Caddy to Your Docker Compose</h3>
<p>First, let&rsquo;s add Caddy to your existing <code>docker-compose.yml</code> file. Here&rsquo;s the configuration I use:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span>  <span style="color:#f92672">caddy</span>:
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">container_name</span>: <span style="color:#ae81ff">caddy</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">image</span>: <span style="color:#ae81ff">caddy:latest</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">restart</span>: <span style="color:#ae81ff">unless-stopped</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">network_mode</span>: <span style="color:#ae81ff">host</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">environment</span>:
</span></span><span style="display:flex;"><span>      - <span style="color:#ae81ff">TZ=${TZ}</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">volumes</span>:
</span></span><span style="display:flex;"><span>      - <span style="color:#ae81ff">/volume1/docker/appdata/caddy/Caddyfile:/etc/caddy/Caddyfile:ro</span>
</span></span><span style="display:flex;"><span>      - <span style="color:#ae81ff">/volume1/docker/appdata/caddy/data:/data</span>
</span></span><span style="display:flex;"><span>      - <span style="color:#ae81ff">/volume1/docker/appdata/caddy/config:/config</span>
</span></span><span style="display:flex;"><span>      - <span style="color:#ae81ff">/volume1/docker/appdata/caddy/certs:/caddy/certs:ro</span>
</span></span></code></pre></div><h3 id="creating-the-folder-structure">Creating the Folder Structure</h3>
<p>In your <code>appdata</code> directory (<code>/volume1/docker/appdata</code>), create the necessary Caddy directories:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>mkdir -p caddy/<span style="color:#f92672">{</span>certs,config,data<span style="color:#f92672">}</span> <span style="color:#f92672">&amp;&amp;</span> touch caddy/Caddyfile
</span></span></code></pre></div><p>This creates the directory structure that Caddy needs, including directories for certificates, configuration, and data storage, plus the main Caddyfile.</p>
<h3 id="freeing-up-ports-443-and-80">Freeing Up Ports 443 and 80</h3>
<p>Here&rsquo;s something that caught me off guard: Synology automatically binds to ports 443 and 80 even if you&rsquo;re not using their built-in reverse proxy. Since Caddy needs these ports to handle HTTPS and HTTP traffic, we need to free them up.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-shell" data-lang="shell"><span style="display:flex;"><span>sed -i -e <span style="color:#e6db74">&#39;s/80/82/&#39;</span> -e <span style="color:#e6db74">&#39;s/443/444/&#39;</span> /usr/syno/share/nginx/server.mustache /usr/syno/share/nginx/DSM.mustache /usr/syno/share/nginx/WWWService.mustache
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>synosystemctl restart nginx
</span></span></code></pre></div><p>I set up a boot script to handle this automatically on each reboot (Control Panel -&gt; Task Scheduler). While I&rsquo;ve found that this override typically persists between reboots, there&rsquo;s no harm in ensuring it runs at startup. System updates might reset these settings to their defaults, and I&rsquo;d rather be safe than sorry.</p>
<p><img alt="Xcode build timeline" loading="lazy" src="notes/images/synology-caddy-reverse-proxy/scheduled-task.jpg"></p>
<h3 id="setting-up-https-optional-but-recommended">Setting Up HTTPS (Optional but Recommended)</h3>
<p>HTTPS isn&rsquo;t technically required for local-only access, but it eliminates browser warnings. However, there&rsquo;s an important caveat with Caddy&rsquo;s automatic HTTPS feature.</p>
<p><img alt="Xcode build timeline" loading="lazy" src="notes/images/synology-caddy-reverse-proxy/connection-not-private.png"></p>
<p><strong>Why Caddy&rsquo;s Built-in Auto HTTPS Won&rsquo;t Work</strong>: Caddy&rsquo;s automatic HTTPS uses Let&rsquo;s Encrypt, which requires ACME challenges to verify domain ownership. These challenges need your server to be accessible from the internet. If your containers are only accessible through Tailscale or similar private networks (like mine), the ACME validation will fail.</p>
<p><strong>The Solution: Cloudflare Origin CA Certificates</strong>: Since I use Tailscale to access my containers outside my home network, I opted for Cloudflare&rsquo;s Origin CA certificates. These certificates are valid for 15 years and don&rsquo;t require internet accessibility for validation.</p>
<h4 id="generating-cloudflare-origin-certificates">Generating Cloudflare Origin Certificates</h4>
<p>Your domain needs to be using Cloudflare for this to work. Navigate to the Cloudflare Origin CA dashboard and generate a wildcard certificate for your domain. See <a href="https://developers.cloudflare.com/ssl/origin-configuration/origin-ca/" target="_blank" >Cloudflare Docs</a> for step-by-step instructions.</p>
<p>Save the public and private keys to these files:</p>
<ul>
<li><code>cert.pem</code> (public key)</li>
<li><code>key.pem</code> (private key)</li>
</ul>
<p>Move both files to your <code>caddy/certs</code> directory.</p>
<h4 id="adding-the-certificate-to-your-devices">Adding the Certificate to Your Devices</h4>
<p>To eliminate browser security warnings, you&rsquo;ll need to add the public certificate (<code>cert.pem</code>) to your device&rsquo;s trusted certificate store. On macOS, this means adding it to your keychain and marking it as trusted. You&rsquo;ll need to repeat this process for each device you want to use to access your containers.</p>
<p><img alt="Xcode build timeline" loading="lazy" src="/notes/images/synology-caddy-reverse-proxy/cloudflare-origin-cert.jpg"></p>
<h3 id="setting-proper-permissions">Setting Proper Permissions</h3>
<p>Following the Trash Guides approach, set the correct permissions for the docker user:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-shell" data-lang="shell"><span style="display:flex;"><span>sudo chown -R docker:users /volume1/docker/appdata/caddy
</span></span><span style="display:flex;"><span>sudo chmod -R a<span style="color:#f92672">=</span>,a+rX,u+w,g+w /volume1/docker/appdata/caddy
</span></span></code></pre></div><h3 id="configuring-the-caddyfile">Configuring the Caddyfile</h3>
<p>Here&rsquo;s a sample Caddyfile configuration. This is where the magic happens:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#75715e"># Global options</span>
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>	<span style="color:#75715e"># Disable automatic HTTPS since we&#39;re using Cloudflare Origin CA certs</span>
</span></span><span style="display:flex;"><span>	<span style="color:#ae81ff">auto_https off</span>
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># Prowlarr</span>
</span></span><span style="display:flex;"><span><span style="color:#ae81ff">prowlarr.kunat.dev {</span>
</span></span><span style="display:flex;"><span>	<span style="color:#ae81ff">reverse_proxy localhost:9696</span>
</span></span><span style="display:flex;"><span>	<span style="color:#ae81ff">tls /caddy/certs/cert.pem /caddy/certs/key.pem</span>
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># Plex</span>
</span></span><span style="display:flex;"><span><span style="color:#ae81ff">plex.kunat.dev {</span>
</span></span><span style="display:flex;"><span>	<span style="color:#ae81ff">reverse_proxy localhost:32400</span>
</span></span><span style="display:flex;"><span>	<span style="color:#ae81ff">tls /caddy/certs/cert.pem /caddy/certs/key.pem</span>
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># AdGuard</span>
</span></span><span style="display:flex;"><span><span style="color:#ae81ff">adguard.kunat.dev {</span>
</span></span><span style="display:flex;"><span>	<span style="color:#ae81ff">reverse_proxy localhost:3000</span>
</span></span><span style="display:flex;"><span>	<span style="color:#ae81ff">tls /caddy/certs/cert.pem /caddy/certs/key.pem</span>
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># Synology Dashboard (HTTPS backend)</span>
</span></span><span style="display:flex;"><span><span style="color:#ae81ff">synology.kunat.dev {</span>
</span></span><span style="display:flex;"><span>	<span style="color:#ae81ff">reverse_proxy https://localhost:5001 {</span>
</span></span><span style="display:flex;"><span>		<span style="color:#ae81ff">transport http {</span>
</span></span><span style="display:flex;"><span>			<span style="color:#ae81ff">tls_insecure_skip_verify</span>
</span></span><span style="display:flex;"><span>		}
</span></span><span style="display:flex;"><span>	}
</span></span><span style="display:flex;"><span>	<span style="color:#ae81ff">tls /caddy/certs/cert.pem /caddy/certs/key.pem</span>
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># Homarr</span>
</span></span><span style="display:flex;"><span><span style="color:#ae81ff">homarr.kunat.dev {</span>
</span></span><span style="display:flex;"><span>	<span style="color:#ae81ff">reverse_proxy localhost:7575</span>
</span></span><span style="display:flex;"><span>	<span style="color:#ae81ff">tls /caddy/certs/cert.pem /caddy/certs/key.pem</span>
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>Each service follows the same pattern: define the subdomain, specify the reverse proxy target (localhost:port), and point to our TLS certificates.</p>
<h3 id="running-caddy">Running Caddy</h3>
<p>Start Caddy with:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>sudo docker-compose up -d caddy
</span></span></code></pre></div><p>If everything is configured correctly, you should see logs similar to these:</p>
<pre tabindex="0"><code>caddy  | {&#34;level&#34;:&#34;warn&#34;,&#34;ts&#34;:1752004979.244892,&#34;msg&#34;:&#34;failed to set GOMAXPROCS&#34;,&#34;error&#34;:&#34;open /sys/fs/cgroup/cpu/cpu.cfs_quota_us: no such file or directory&#34;}
caddy  | {&#34;level&#34;:&#34;info&#34;,&#34;ts&#34;:1752004979.2452004,&#34;msg&#34;:&#34;GOMEMLIMIT is updated&#34;,&#34;package&#34;:&#34;github.com/KimMachineGun/automemlimit/memlimit&#34;,&#34;GOMEMLIMIT&#34;:18851998924,&#34;previous&#34;:9223372036854775807}
caddy  | {&#34;level&#34;:&#34;info&#34;,&#34;ts&#34;:1752004979.2452545,&#34;msg&#34;:&#34;using config from file&#34;,&#34;file&#34;:&#34;/etc/caddy/Caddyfile&#34;}
caddy  | {&#34;level&#34;:&#34;info&#34;,&#34;ts&#34;:1752004979.247973,&#34;msg&#34;:&#34;adapted config to JSON&#34;,&#34;adapter&#34;:&#34;caddyfile&#34;}
caddy  | {&#34;level&#34;:&#34;info&#34;,&#34;ts&#34;:1752004979.2502022,&#34;logger&#34;:&#34;admin&#34;,&#34;msg&#34;:&#34;admin endpoint started&#34;,&#34;address&#34;:&#34;localhost:2019&#34;,&#34;enforce_origin&#34;:false,&#34;origins&#34;:[&#34;//localhost:2019&#34;,&#34;//[::1]:2019&#34;,&#34;//127.0.0.1:2019&#34;]}
caddy  | {&#34;level&#34;:&#34;info&#34;,&#34;ts&#34;:1752004979.2507207,&#34;logger&#34;:&#34;tls.cache.maintenance&#34;,&#34;msg&#34;:&#34;started background certificate maintenance&#34;,&#34;cache&#34;:&#34;0xc00079c480&#34;}
caddy  | {&#34;level&#34;:&#34;warn&#34;,&#34;ts&#34;:1752004979.2785668,&#34;logger&#34;:&#34;tls&#34;,&#34;msg&#34;:&#34;stapling OCSP&#34;,&#34;error&#34;:&#34;no OCSP stapling for [cloudflare origin certificate *.kunat.dev]: no URL to issuing certificate&#34;}
caddy  | {&#34;level&#34;:&#34;info&#34;,&#34;ts&#34;:1752004979.278835,&#34;logger&#34;:&#34;http.auto_https&#34;,&#34;msg&#34;:&#34;automatic HTTPS is completely disabled for server&#34;,&#34;server_name&#34;:&#34;srv0&#34;}
caddy  | {&#34;level&#34;:&#34;info&#34;,&#34;ts&#34;:1752004979.2799113,&#34;logger&#34;:&#34;http&#34;,&#34;msg&#34;:&#34;enabling HTTP/3 listener&#34;,&#34;addr&#34;:&#34;:443&#34;}
caddy  | {&#34;level&#34;:&#34;info&#34;,&#34;ts&#34;:1752004979.2799726,&#34;msg&#34;:&#34;failed to sufficiently increase receive buffer size (was: 208 kiB, wanted: 7168 kiB, got: 416 kiB). See https://github.com/quic-go/quic-go/wiki/UDP-Buffer-Sizes for details.&#34;}
caddy  | {&#34;level&#34;:&#34;info&#34;,&#34;ts&#34;:1752004979.2800903,&#34;logger&#34;:&#34;http.log&#34;,&#34;msg&#34;:&#34;server running&#34;,&#34;name&#34;:&#34;srv0&#34;,&#34;protocols&#34;:[&#34;h1&#34;,&#34;h2&#34;,&#34;h3&#34;]}
caddy  | {&#34;level&#34;:&#34;info&#34;,&#34;ts&#34;:1752004979.280308,&#34;msg&#34;:&#34;autosaved config (load with --resume flag)&#34;,&#34;file&#34;:&#34;/config/caddy/autosave.json&#34;}
caddy  | {&#34;level&#34;:&#34;info&#34;,&#34;ts&#34;:1752004979.2811198,&#34;msg&#34;:&#34;serving initial configuration&#34;}
caddy  | {&#34;level&#34;:&#34;info&#34;,&#34;ts&#34;:1752004979.38889,&#34;logger&#34;:&#34;tls&#34;,&#34;msg&#34;:&#34;finished cleaning storage units&#34;}
</code></pre><p>The key things to look for are &ldquo;server running&rdquo; and &ldquo;serving initial configuration&rdquo; messages. These indicate that Caddy has successfully started and is ready to handle requests.</p>
<p>Now try accessing one of your Docker containers using its subdomain. If everything is working correctly, you should be able to reach your services through clean URLs like <code>https://prowlarr.yourdomain.com</code>.</p>
<h2 id="troubleshooting">Troubleshooting</h2>
<p><strong>Clean Your DNS Cache</strong>: If you&rsquo;re having trouble accessing your services after setup, try clearing your DNS cache. On macOS, run:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>sudo dscacheutil -flushcache; sudo killall -HUP mDNSResponder
</span></span></code></pre></div><p><strong>Check Your Wildcard DNS</strong>: Make sure your local DNS server (AdGuard Home or Pi-hole) has the wildcard rewrite rule in place. Without <code>*.yourdomain.com -&gt; your_synology_ip</code>, your subdomains won&rsquo;t resolve locally.</p>
<p><img alt="Xcode build timeline" loading="lazy" src="/notes/images/synology-caddy-reverse-proxy/dns-override.jpg"></p>
<p><strong>Verify Certificate Trust</strong>: If you&rsquo;re seeing browser security warnings, double-check that you&rsquo;ve properly installed and trusted the Cloudflare Origin CA certificate on your client devices.</p>
<h2 id="resources">Resources</h2>
<ul>
<li><a href="https://3os.org/infrastructure/synology/disable-dms-listening-on-80-443-ports/#disable-the-synology-nas-dsm-to-listen-on-80-443-ports" target="_blank" >Free 80,443 Ports - 3os</a></li>
<li><a href="https://caddyserver.com/docs/" target="_blank" >Caddy Documentation</a></li>
<li><a href="https://developers.cloudflare.com/ssl/origin-configuration/origin-ca/" target="_blank" >Cloudflare Origin CA Documentation</a></li>
</ul>
<h2 id="conclusion">Conclusion</h2>
<p>Setting up Caddy as a reverse proxy on Synology has been a game-changer for my homelab. The ability to access all my services through clean subdomains makes everything feel more professional and easier to remember. While the initial setup requires some work, especially with the certificate configuration, the long-term benefits are worth it.</p>
<blockquote>
<p><strong>Update 14/07/25:</strong> If you want to access your Docker containers using their LAN IP addresses consistently, regardless of whether we’re connected to our home network or accessing remotely through Tailscale check out <a href="/notes/tailscale-subnet-routers/" >this aritcle</a> on Tailscale subnet routers.</p></blockquote>
]]></content:encoded>
    </item>
    <item>
      <title>How to Use Xcode 16&#39;s Buildable Folders with CocoaPods</title>
      <link>https://kunat.dev/notes/xcode16-folders-pods/</link>
      <pubDate>Wed, 11 Jun 2025 19:11:09 +0200</pubDate>
      <guid>https://kunat.dev/notes/xcode16-folders-pods/</guid>
      <description>&lt;p&gt;A few months ago, I decided to try Xcode&amp;rsquo;s new &lt;a href=&#34;https://dimillian.medium.com/why-you-should-use-xcode-16-buildable-folders-instead-of-groups-6f438611914d&#34; target=&#34;_blank&#34; &gt;buildable folders&lt;/a&gt; feature in my project. However, right after converting all my groups to folders, I discovered that CocoaPods had stopped working. Attempting to install project dependencies resulted in the following error:&lt;/p&gt;
&lt;div class=&#34;highlight&#34;&gt;&lt;pre tabindex=&#34;0&#34; style=&#34;color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;&#34;&gt;&lt;code class=&#34;language-bash&#34; data-lang=&#34;bash&#34;&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;$ pod install
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;❌ ArgumentError - &lt;span style=&#34;color:#f92672&#34;&gt;[&lt;/span&gt;Xcodeproj&lt;span style=&#34;color:#f92672&#34;&gt;]&lt;/span&gt; Unable to find compatibility version string &lt;span style=&#34;color:#66d9ef&#34;&gt;for&lt;/span&gt; object version &lt;span style=&#34;color:#e6db74&#34;&gt;`&lt;/span&gt;70&lt;span style=&#34;color:#e6db74&#34;&gt;`&lt;/span&gt;.
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;At the time, I updated CocoaPods to the latest version, confirmed my project format was set to the latest Xcode version (16.3), and tried again. When the issue persisted, I assumed CocoaPods was incompatible with buildable folders and that I would need to migrate to Swift Package Manager (SPM) to use them. This conclusion seemed to be confirmed by several closed GitHub issues where the suggested workaround was to manually convert folders back to groups.&lt;/p&gt;</description>
      <content:encoded><![CDATA[<p>A few months ago, I decided to try Xcode&rsquo;s new <a href="https://dimillian.medium.com/why-you-should-use-xcode-16-buildable-folders-instead-of-groups-6f438611914d" target="_blank" >buildable folders</a> feature in my project. However, right after converting all my groups to folders, I discovered that CocoaPods had stopped working. Attempting to install project dependencies resulted in the following error:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>$ pod install
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>❌ ArgumentError - <span style="color:#f92672">[</span>Xcodeproj<span style="color:#f92672">]</span> Unable to find compatibility version string <span style="color:#66d9ef">for</span> object version <span style="color:#e6db74">`</span>70<span style="color:#e6db74">`</span>.
</span></span></code></pre></div><p>At the time, I updated CocoaPods to the latest version, confirmed my project format was set to the latest Xcode version (16.3), and tried again. When the issue persisted, I assumed CocoaPods was incompatible with buildable folders and that I would need to migrate to Swift Package Manager (SPM) to use them. This conclusion seemed to be confirmed by several closed GitHub issues where the suggested workaround was to manually convert folders back to groups.</p>
<p>However, it turns out you don&rsquo;t have to wait to use Xcode&rsquo;s buildable folders with CocoaPods! The solution is surprisingly simple: set the <strong>Project Format</strong> in your project&rsquo;s settings to <strong>Xcode 16.0</strong>. Any other selection will trigger the <code>Xcodeproj</code> error.</p>
<p><img alt="hello there" loading="lazy" src="/notes/images/xcode16-folders-pods-header.jpg"></p>
<p>With that single change, you can start taking full advantage of Xcode&rsquo;s buildable folders in your CocoaPods projects!</p>
<h3 id="references">References</h3>
<ul>
<li><a href="https://github.com/CocoaPods/Xcodeproj/pull/985" target="_blank" >Xcodeproj PR #985</a></li>
<li><a href="https://github.com/CocoaPods/CocoaPods/issues/12671#issuecomment-2467142931" target="_blank" >CocoaPods Issue #12671</a></li>
</ul>
]]></content:encoded>
    </item>
    <item>
      <title>Automate YouTube Downloads on Synology with MeTube and Docker</title>
      <link>https://kunat.dev/notes/metube-synology-setup/</link>
      <pubDate>Thu, 05 Jun 2025 18:38:36 +0200</pubDate>
      <guid>https://kunat.dev/notes/metube-synology-setup/</guid>
      <description>Learn how to install MeTube, a web GUI for youtube-dl, on your Synology NAS using Docker. Follow this step-by-step guide to automate downloading YouTube videos directly to your Plex media library.</description>
      <content:encoded><![CDATA[<p><img alt="hello there" loading="lazy" src="/notes/images/metube-synology-setup-header.jpg"></p>
<blockquote>
<p><strong><a href="https://github.com/alexta69/metube" target="_blank" >MeTube</a></strong> is a web GUI for <code>youtube-dl</code>, a command-line tool for downloading videos from YouTube and several hundred other sites.</p></blockquote>
<p>My goal for this project was to set up automation for downloading YouTube videos directly to my Plex library.</p>
<p>The primary reason is that I can&rsquo;t trust myself with YouTube Shorts. Once I open YouTube, I&rsquo;m likely to spend at least a few minutes browsing through them. The simplest solution was to add friction to the process: I removed the YouTube app from all my devices and blocked the domain using my local DNS server.</p>
<p>This worked like a charm. Now, I only visit YouTube every few days to add content that interests me to my Plex library.</p>
<blockquote>
<p>You can read more about setting up your own DNS server <a href="/notes/adguard-home-synology-tailscale/" >here</a>.</p></blockquote>
<h2 id="prerequisites">Prerequisites</h2>
<ul>
<li>‼️ Docker must be installed and set up on your system by following the <a href="https://trash-guides.info/File-and-Folder-Structure/How-to-set-up/Synology/" target="_blank" >TrashGuides setup for Synology</a>.</li>
</ul>
<blockquote>
<p>This guide assumes you have configured common environment variables like <code>PUID</code>, <code>PGID</code>, <code>TZ</code>, <code>DOCKERSTORAGEDIR</code>, and <code>DOCKERCONFDIR</code> as outlined in the TrashGuides.</p></blockquote>
<h2 id="installation-steps">Installation Steps</h2>
<h3 id="1-create-required-directories">1. Create Required Directories</h3>
<p>First, we need to create folders for MeTube&rsquo;s configuration and downloads. One folder will store MeTube&rsquo;s application data, and the other will be the destination for your downloaded videos.</p>
<p>SSH into your Synology NAS and execute the following commands:</p>
<blockquote>
<p>The commands below assume you&rsquo;ve followed the linked guide exactly. Adjust paths like <code>/volume1/data/media/youtube</code> and <code>/volume1/docker/appdata/metube</code> if your storage volume or directory structure differs.</p></blockquote>
<ul>
<li><strong>Folder for video downloads (e.g., your Plex library):</strong>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>sudo mkdir -p /volume1/data/media/youtube
</span></span></code></pre></div></li>
<li><strong>Folder for the MeTube container&rsquo;s configuration:</strong>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>sudo mkdir -p /volume1/docker/appdata/metube
</span></span></code></pre></div></li>
</ul>
<h3 id="2-set-permissions">2. Set Permissions</h3>
<p>Next, execute the following commands via SSH to configure the correct permissions for the newly created directories:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>sudo chown -R docker:users /volume1/data/media/youtube /volume1/docker/appdata/metube
</span></span><span style="display:flex;"><span>sudo chmod -R a<span style="color:#f92672">=</span>,a+rX,u+w,g+w /volume1/data/media/youtube /volume1/docker/appdata/metube
</span></span></code></pre></div><p>This command ensures the <code>docker</code> user has the necessary ownership and read/write permissions.</p>
<h3 id="3-configure-docker-compose">3. Configure Docker Compose</h3>
<p>Append the following service configuration to your main <code>docker-compose.yml</code> file. If you followed the TrashGuides setup, this file is located at <code>/volume1/docker/</code>.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#f92672">services</span>:
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">metube</span>:
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">container_name</span>: <span style="color:#ae81ff">metube</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">image</span>: <span style="color:#ae81ff">ghcr.io/alexta69/metube:latest</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">restart</span>: <span style="color:#ae81ff">unless-stopped</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">logging</span>:
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">driver</span>: <span style="color:#ae81ff">json-file</span>
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">options</span>:
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">max-file</span>: <span style="color:#ae81ff">${DOCKERLOGGING_MAXFILE:-10}</span>
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">max-size</span>: <span style="color:#ae81ff">${DOCKERLOGGING_MAXSIZE:-200m}</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">ports</span>:
</span></span><span style="display:flex;"><span>      - <span style="color:#e6db74">&#34;8081:8081&#34;</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">environment</span>:
</span></span><span style="display:flex;"><span>      - <span style="color:#ae81ff">PUID=${PUID}</span>
</span></span><span style="display:flex;"><span>      - <span style="color:#ae81ff">PGID=${PGID}</span>
</span></span><span style="display:flex;"><span>      - <span style="color:#ae81ff">TZ=${TZ}</span>
</span></span><span style="display:flex;"><span>      - <span style="color:#ae81ff">UMASK=002</span>
</span></span><span style="display:flex;"><span>      - <span style="color:#ae81ff">DOWNLOAD_DIR=/downloads</span>
</span></span><span style="display:flex;"><span>      - <span style="color:#ae81ff">AUDIO_DOWNLOAD_DIR=/downloads</span>
</span></span><span style="display:flex;"><span>      - <span style="color:#ae81ff">STATE_DIR=/config/.metube</span>
</span></span><span style="display:flex;"><span>      - <span style="color:#ae81ff">TEMP_DIR=/downloads/.temp</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">volumes</span>:
</span></span><span style="display:flex;"><span>      - <span style="color:#ae81ff">/etc/localtime:/etc/localtime:ro</span>
</span></span><span style="display:flex;"><span>      - <span style="color:#ae81ff">${DOCKERSTORAGEDIR}/media/youtube:/downloads</span>
</span></span><span style="display:flex;"><span>      - <span style="color:#ae81ff">${DOCKERCONFDIR}/metube:/config</span>
</span></span></code></pre></div><p><strong>Note on Environment Variables and Volumes:</strong></p>
<ul>
<li><code>${DOCKERSTORAGEDIR}/media/youtube</code> maps your Synology&rsquo;s media download folder to the <code>/downloads</code> directory inside the container. Ensure <code>DOCKERSTORAGEDIR</code> is correctly defined (e.g., <code>DOCKERSTORAGEDIR=/volume1/data</code>).</li>
<li><code>${DOCKERCONFDIR}/metube</code> maps your Synology&rsquo;s MeTube appdata folder to the <code>/config</code> directory inside the container. Ensure <code>DOCKERCONFDIR</code> is correctly defined (e.g., <code>DOCKERCONFDIR=/volume1/docker/appdata</code>).</li>
</ul>
<h3 id="4-start-the-container">4. Start the Container</h3>
<p>Navigate to the directory containing your <code>docker-compose.yml</code> file via SSH and run the following command:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>docker compose up -d metube
</span></span></code></pre></div><p>The container will now download the image and start. You can then access the MeTube web interface by navigating to <code>http://&lt;your-synology-ip&gt;:8081</code> in a web browser.</p>
<p>Check out MeTube&rsquo;s <a href="https://github.com/alexta69/metube?tab=readme-ov-file#ios-shortcut" target="_blank" >README</a> for a handy iOS shortcut that allows you share video URLs without opening MeTube!</p>
<h2 id="troubleshooting">Troubleshooting</h2>
<ul>
<li><strong>Port Conflicts:</strong> If port <code>8081</code> is already in use, change the host port in the <code>ports</code> section of your <code>docker-compose.yml</code>. For example, to use port <code>8082</code>, modify the line to <code>- &quot;8082:8081&quot;</code>.</li>
<li><strong>Permission Issues:</strong> If MeTube reports that it cannot write to the download or config directories, double-check the permissions set in Step 2. Also, verify that the <code>PUID</code> and <code>PGID</code> environment variables correspond to a user with write access to those folders.</li>
<li><strong>Container Logs:</strong> If the container fails to start or you encounter other issues, check its logs with this command:
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>docker compose logs metube
</span></span></code></pre></div></li>
</ul>
<h2 id="resources">Resources</h2>
<ul>
<li><a href="https://github.com/alexta69/metube" target="_blank" >MeTube GitHub Repository</a></li>
<li><a href="https://trash-guides.info/File-and-Folder-Structure/How-to-set-up/Synology/" target="_blank" >TrashGuides for Synology Docker Setup</a></li>
</ul>
]]></content:encoded>
    </item>
    <item>
      <title>Adding Swift Package Manager Support to a Legacy Objective-C Project</title>
      <link>https://kunat.dev/notes/spm-support-objc-project/</link>
      <pubDate>Tue, 03 Jun 2025 16:50:49 +0200</pubDate>
      <guid>https://kunat.dev/notes/spm-support-objc-project/</guid>
      <description>&lt;p&gt;I recently needed to add Swift Package Manager support to a legacy package that one of my projects was using. The package was originally distributed with CocoaPods. The primary reason for migrating from CocoaPods to SPM is that CocoaPods entered &lt;a href=&#34;https://blog.cocoapods.org/CocoaPods-Support-Plans/&#34; target=&#34;_blank&#34; &gt;maintenance mode&lt;/a&gt; a few months ago. Removing it will future-proof our projects and allow us to use the latest features, such as the &lt;a href=&#34;https://dimillian.medium.com/why-you-should-use-xcode-16-buildable-folders-instead-of-groups-6f438611914d&#34; target=&#34;_blank&#34; &gt;buildable folders&lt;/a&gt; introduced in Xcode 16.&lt;/p&gt;
&lt;h2 id=&#34;assumptions&#34;&gt;Assumptions&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;Since this was an internal package, I did not need to maintain CocoaPods support. The goal was to replace it entirely with SPM. It is certainly possible to keep both, but that would require some additional work.&lt;/li&gt;
&lt;li&gt;The project I was working on is considered legacy; no one has touched it in about six years. Once this task is complete, I hope no one will need to update it for another half-dozen years. This allowed me to take some shortcuts, like integrating a third-party dependency directly into the codebase instead of spending time updating it to the latest version.&lt;/li&gt;
&lt;li&gt;All internal dependencies of your framework already support SPM.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;With that said, let&amp;rsquo;s get started.&lt;/p&gt;</description>
      <content:encoded><![CDATA[<p>I recently needed to add Swift Package Manager support to a legacy package that one of my projects was using. The package was originally distributed with CocoaPods. The primary reason for migrating from CocoaPods to SPM is that CocoaPods entered <a href="https://blog.cocoapods.org/CocoaPods-Support-Plans/" target="_blank" >maintenance mode</a> a few months ago. Removing it will future-proof our projects and allow us to use the latest features, such as the <a href="https://dimillian.medium.com/why-you-should-use-xcode-16-buildable-folders-instead-of-groups-6f438611914d" target="_blank" >buildable folders</a> introduced in Xcode 16.</p>
<h2 id="assumptions">Assumptions</h2>
<ul>
<li>Since this was an internal package, I did not need to maintain CocoaPods support. The goal was to replace it entirely with SPM. It is certainly possible to keep both, but that would require some additional work.</li>
<li>The project I was working on is considered legacy; no one has touched it in about six years. Once this task is complete, I hope no one will need to update it for another half-dozen years. This allowed me to take some shortcuts, like integrating a third-party dependency directly into the codebase instead of spending time updating it to the latest version.</li>
<li>All internal dependencies of your framework already support SPM.</li>
</ul>
<p>With that said, let&rsquo;s get started.</p>
<h2 id="how">How?</h2>
<p>I&rsquo;ll assume your framework has no internal dependencies that require the same SPM treatment. If it does, start with the bottom-most dependency and work your way up.</p>
<h3 id="external-dependencies">External Dependencies</h3>
<p>The first step is to update all external dependencies to use SPM.</p>
<p>In my case, one of the dependencies was <a href="https://github.com/mxcl/PromiseKit" target="_blank" >PromiseKit</a>, which was stuck six major versions behind the latest release. Since the project was considered legacy and no further development was planned, I decided to cut some corners.</p>
<p>Instead of spending time updating PromiseKit from version 1 through 7, I opted to add SPM support to the version my project was currently using. This approach required significantly less work and proved to be the right choice.</p>
<h3 id="removing-the-old-dependency-manager">Removing the Old Dependency Manager</h3>
<p>With all dependencies migrated to SPM, you should now be able to remove the old dependency manager from your project.</p>
<h3 id="adding-a-swift-package-manifest">Adding a Swift Package Manifest</h3>
<p>Next, initialize a new Swift package in your project&rsquo;s root directory:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>swift package init
</span></span></code></pre></div><p>This will create a <code>Package.swift</code> file. Here is a basic configuration:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-swift" data-lang="swift"><span style="display:flex;"><span><span style="color:#75715e">// swift-tools-version: 6.1</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">import</span> <span style="color:#a6e22e">PackageDescription</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">let</span> package = Package(
</span></span><span style="display:flex;"><span>    name: <span style="color:#e6db74">&#34;MyPackage&#34;</span>,
</span></span><span style="display:flex;"><span>    products: [
</span></span><span style="display:flex;"><span>        .library(
</span></span><span style="display:flex;"><span>            name: <span style="color:#e6db74">&#34;MyPackage&#34;</span>,
</span></span><span style="display:flex;"><span>            targets: [<span style="color:#e6db74">&#34;MyPackage&#34;</span>]),
</span></span><span style="display:flex;"><span>    ],
</span></span><span style="display:flex;"><span>    targets: [
</span></span><span style="display:flex;"><span>        .target(
</span></span><span style="display:flex;"><span>            name: <span style="color:#e6db74">&#34;MyPackage&#34;</span>),
</span></span><span style="display:flex;"><span>        .testTarget(
</span></span><span style="display:flex;"><span>            name: <span style="color:#e6db74">&#34;MyPackageTests&#34;</span>,
</span></span><span style="display:flex;"><span>            dependencies: [<span style="color:#e6db74">&#34;MyPackage&#34;</span>]
</span></span><span style="display:flex;"><span>        ),
</span></span><span style="display:flex;"><span>    ]
</span></span><span style="display:flex;"><span>)
</span></span></code></pre></div><p>Move your project&rsquo;s source files to <code>Sources/MyPackage</code> and try to build the project. You will likely encounter one of the following errors:</p>
<pre tabindex="0"><code>SomeHeaderFile.h:19:9 &#39;AnotherHeaderFile.h&#39; file not found
</code></pre><p>or</p>
<pre tabindex="0"><code>public headers (&#34;include&#34;) directory path for &#39;MyPackage&#39; is invalid or not contained in the target
</code></pre><p>These errors indicate that the compiler cannot locate your project&rsquo;s header files. By default, SPM looks for public headers in an <code>include</code> directory.</p>
<p>Create an <code>include</code> directory inside <code>Sources/MyPackage</code> and move all of your project&rsquo;s public headers there.</p>
<p>If this structure doesn&rsquo;t suit your project, you can define a custom path for your public headers by specifying <code>publicHeadersPath</code> in your <code>Package.swift</code>:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-swift" data-lang="swift"><span style="display:flex;"><span>    targets: [
</span></span><span style="display:flex;"><span>        .target(
</span></span><span style="display:flex;"><span>            name: <span style="color:#e6db74">&#34;MyPackage&#34;</span>,
</span></span><span style="display:flex;"><span>            publicHeadersPath: <span style="color:#e6db74">&#34;some/custom/path&#34;</span>
</span></span><span style="display:flex;"><span>        ),
</span></span><span style="display:flex;"><span>        .testTarget(
</span></span><span style="display:flex;"><span>            name: <span style="color:#e6db74">&#34;MyPackageTests&#34;</span>,
</span></span><span style="display:flex;"><span>            dependencies: [<span style="color:#e6db74">&#34;MyPackage&#34;</span>]
</span></span><span style="display:flex;"><span>        ),
</span></span><span style="display:flex;"><span>    ]
</span></span></code></pre></div><p><strong>How do I locate my project&rsquo;s public headers?</strong></p>
<p>Your project most likely had an Xcode project file (<code>.xcproj</code>) before you started adding SPM support. You can find a list of your public headers in the &ldquo;Build Phases&rdquo; tab, under the &ldquo;Headers&rdquo; section.</p>
<p><img alt="changedetection" loading="lazy" src="/notes/images/spm-objc-public-headers.jpg"></p>
<p>After correctly configuring the public header files, your project might build. If it still doesn&rsquo;t, you may have other header files that need to be discoverable. If that&rsquo;s the case, you&rsquo;ll need to add search paths for your private header files using <code>headerSearchPath</code> in your cSettings:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-swift" data-lang="swift"><span style="display:flex;"><span>.target(
</span></span><span style="display:flex;"><span>    name: <span style="color:#e6db74">&#34;MyPackage&#34;</span>,
</span></span><span style="display:flex;"><span>    cSettings: [
</span></span><span style="display:flex;"><span>        .headerSearchPath(<span style="color:#e6db74">&#34;Payments&#34;</span>),
</span></span><span style="display:flex;"><span>        .headerSearchPath(<span style="color:#e6db74">&#34;Payments/ApplePayPaymentsService&#34;</span>),
</span></span><span style="display:flex;"><span>        .headerSearchPath(<span style="color:#e6db74">&#34;Transactions/Components&#34;</span>)
</span></span><span style="display:flex;"><span>    ]),
</span></span></code></pre></div><p>It&rsquo;s up to you whether you want to group all private header files in a single directory (<code>.headerSearchPath(&quot;Path/To/My/Private/Headers&quot;)</code>) or add all existing directories one by one using their current locations as I did in the snippet above.</p>
<p>With that done, you should now be able to build your project.</p>
<h2 id="troubleshooting">Troubleshooting</h2>
<p>In my case, after migrating all dependencies to SPM and adding the package manifest, I was able to delete the Xcode project file entirely. If that is not the case for you, you might encounter additional issues.</p>
<p>One common problem is the error <code>fatal error: framework '&lt;some_framework&gt;' not found</code> when trying to import an SPM dependency into an Objective-C codebase in a project managed via an Xcode project file (<code>.xcproj</code>).</p>
<p>In this situation, you may need to experiment with your project settings. <a href="https://developer.apple.com/forums/thread/120152" target="_blank" >This thread</a> on the Apple Developer Forums is an good starting point.</p>
<h2 id="conclusion">Conclusion</h2>
<p>Once these steps are completed, you should be able to build your project. If you add your new package as a dependency via SPM, you can import it like any other Swift package. The same rules apply.</p>
<p><strong>Swift:</strong></p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-swift" data-lang="swift"><span style="display:flex;"><span><span style="color:#66d9ef">import</span> <span style="color:#a6e22e">MyPackage</span>
</span></span></code></pre></div><p><strong>Objective-C:</strong></p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-objectivec" data-lang="objectivec"><span style="display:flex;"><span>@import MyPackage;
</span></span></code></pre></div><h2 id="resources">Resources</h2>
<p>What helped me the most was looking at examples of existing Objective-C projects with SPM support. The following repositories are worth exploring:</p>
<ul>
<li><a href="https://github.com/PSPDFKit/PDFXKit" target="_blank" >PSPDFKit/PDFXKit</a>: A drop-in replacement for Apple&rsquo;s PDFKit, powered by the PSPDFKit framework.</li>
<li><a href="https://github.com/AliSoftware/OHHTTPStubs" target="_blank" >AliSoftware/OHHTTPStubs</a>: A library to easily stub your network requests.</li>
<li><a href="https://github.com/Mantle/Mantle" target="_blank" >Mantle/Mantle</a>: Model framework for Cocoa and Cocoa Touch</li>
</ul>
]]></content:encoded>
    </item>
    <item>
      <title>Changedetection.io &#43; Chrome Synology Setup with Docker Compose</title>
      <link>https://kunat.dev/notes/changedetection-synology-setup/</link>
      <pubDate>Thu, 22 May 2025 19:04:18 +0200</pubDate>
      <guid>https://kunat.dev/notes/changedetection-synology-setup/</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Note: This article was originally published 05/2025. Last update 08/2025. Changes:&lt;/p&gt;&lt;/blockquote&gt;
&lt;ul&gt;
&lt;li&gt;Added &lt;code&gt;sockpuppetbrowser&lt;/code&gt; to enable Playwright Chromium/Javascript fetch method&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Changedetection.io&lt;/strong&gt; is a powerful open-source tool that monitors websites for changes. It&amp;rsquo;s incredibly useful for tracking updates on pages that don&amp;rsquo;t offer their own notification systems. This guide will walk you through setting it up on your Synology NAS using Docker Compose.&lt;/p&gt;
&lt;p&gt;&lt;img alt=&#34;changedetection&#34; loading=&#34;lazy&#34; src=&#34;https://kunat.dev/notes/images/changedetection.jpg&#34;&gt;&lt;/p&gt;
&lt;h2 id=&#34;prerequisites&#34;&gt;Prerequisites&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;Docker installed on your system by following the &lt;a href=&#34;https://trash-guides.info/File-and-Folder-Structure/How-to-set-up/Synology/&#34; target=&#34;_blank&#34; &gt;TrashGuides setup for Synology&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&#34;installation&#34;&gt;Installation&lt;/h2&gt;
&lt;p&gt;Append this configuration to your &lt;code&gt;docker-compose.yml&lt;/code&gt; file. If you&amp;rsquo;ve followed the guide mentioned above, this file should be located in your Docker application data directory (e.g., &lt;code&gt;/volume1/docker/appdata&lt;/code&gt;).&lt;/p&gt;</description>
      <content:encoded><![CDATA[<blockquote>
<p>Note: This article was originally published 05/2025. Last update 08/2025. Changes:</p></blockquote>
<ul>
<li>Added <code>sockpuppetbrowser</code> to enable Playwright Chromium/Javascript fetch method</li>
</ul>
<p><strong>Changedetection.io</strong> is a powerful open-source tool that monitors websites for changes. It&rsquo;s incredibly useful for tracking updates on pages that don&rsquo;t offer their own notification systems. This guide will walk you through setting it up on your Synology NAS using Docker Compose.</p>
<p><img alt="changedetection" loading="lazy" src="/notes/images/changedetection.jpg"></p>
<h2 id="prerequisites">Prerequisites</h2>
<ul>
<li>Docker installed on your system by following the <a href="https://trash-guides.info/File-and-Folder-Structure/How-to-set-up/Synology/" target="_blank" >TrashGuides setup for Synology</a>.</li>
</ul>
<h2 id="installation">Installation</h2>
<p>Append this configuration to your <code>docker-compose.yml</code> file. If you&rsquo;ve followed the guide mentioned above, this file should be located in your Docker application data directory (e.g., <code>/volume1/docker/appdata</code>).</p>
<p>This configuration sets up a changedetection container along with sockpuppetbrowser, which enables you to use the <code>Playwright Chromium/Javascript</code> fetch method. It&rsquo;s handy in cases where a site requires JavaScript to load. It&rsquo;s still best to use <code>Basic fast Plaintext/HTTP Client</code> as the default for your change detection watch entries.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span>  <span style="color:#f92672">changedetection</span>:
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">image</span>: <span style="color:#ae81ff">lscr.io/linuxserver/changedetection.io:latest</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">container_name</span>: <span style="color:#ae81ff">changedetection</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">restart</span>: <span style="color:#ae81ff">unless-stopped</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">logging</span>:
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">driver</span>: <span style="color:#ae81ff">json-file</span>
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">options</span>:
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">max-file</span>: <span style="color:#ae81ff">${DOCKERLOGGING_MAXFILE}</span>
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">max-size</span>: <span style="color:#ae81ff">${DOCKERLOGGING_MAXSIZE}</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">environment</span>:
</span></span><span style="display:flex;"><span>      - <span style="color:#ae81ff">PUID=${PUID}</span>
</span></span><span style="display:flex;"><span>      - <span style="color:#ae81ff">PGID=${PGID}</span>
</span></span><span style="display:flex;"><span>      - <span style="color:#ae81ff">TZ=${TZ}</span>
</span></span><span style="display:flex;"><span>      - <span style="color:#ae81ff">PLAYWRIGHT_DRIVER_URL=ws://browser-sockpuppet-chrome:3000</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">volumes</span>:
</span></span><span style="display:flex;"><span>      - <span style="color:#ae81ff">${DOCKERCONFDIR}/changedetection:/config</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">ports</span>:
</span></span><span style="display:flex;"><span>      - <span style="color:#ae81ff">5555</span>:<span style="color:#ae81ff">5000</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">depends_on</span>:
</span></span><span style="display:flex;"><span>      - <span style="color:#ae81ff">browser-sockpuppet-chrome</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">browser-sockpuppet-chrome</span>:
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">hostname</span>: <span style="color:#ae81ff">browser-sockpuppet-chrome</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">image</span>: <span style="color:#ae81ff">dgtlmoon/sockpuppetbrowser:latest</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">container_name</span>: <span style="color:#ae81ff">browser-sockpuppet-chrome</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">cap_add</span>:
</span></span><span style="display:flex;"><span>      - <span style="color:#ae81ff">SYS_ADMIN</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">restart</span>: <span style="color:#ae81ff">unless-stopped</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">logging</span>:
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">driver</span>: <span style="color:#ae81ff">json-file</span>
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">options</span>:
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">max-file</span>: <span style="color:#ae81ff">${DOCKERLOGGING_MAXFILE}</span>
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">max-size</span>: <span style="color:#ae81ff">${DOCKERLOGGING_MAXSIZE}</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">environment</span>:
</span></span><span style="display:flex;"><span>      - <span style="color:#ae81ff">SCREEN_WIDTH=1920</span>
</span></span><span style="display:flex;"><span>      - <span style="color:#ae81ff">SCREEN_HEIGHT=1024</span>
</span></span><span style="display:flex;"><span>      - <span style="color:#ae81ff">SCREEN_DEPTH=16</span>
</span></span><span style="display:flex;"><span>      - <span style="color:#ae81ff">MAX_CONCURRENT_CHROME_PROCESSES=10</span>
</span></span></code></pre></div><p><strong>Note on Environment Variables:</strong></p>
<ul>
<li>The <code>PUID</code>, <code>PGID</code>, and <code>TZ</code> environment variables above are set consistently with your other containers.</li>
</ul>
<p>Start the container by running this command in the directory containing your <code>docker-compose.yml</code> file:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>docker compose up -d
</span></span></code></pre></div><p>This command launches Changedetection.io in detached mode, meaning it runs in the background. You should then be able to access it by navigating to <code>http://&lt;your-synology-ip&gt;:5000</code> in your web browser.</p>
<h2 id="troubleshooting">Troubleshooting</h2>
<p>If you encounter issues accessing Changedetection.io, try running the container without the <code>-d</code> flag (e.g., <code>docker compose up</code>) to view the live logs directly.</p>
<p>During my initial setup, I discovered that the default host port (<code>5000</code>) was already in use on my Synology. You can resolve such a port conflict by updating the <code>host</code> port (the first number in the <code>ports</code> section) like so:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#f92672">ports</span>:
</span></span><span style="display:flex;"><span>  - <span style="color:#f92672">NEW_PORT:5000 # Example</span>: <span style="color:#ae81ff">5001</span>:<span style="color:#ae81ff">5000</span>
</span></span></code></pre></div><blockquote>
<p>The <strong>Container Port</strong> (the right-hand side of the colon, e.g., <code>5000</code> in <code>NEW_PORT:5000</code>) is the port that the application <em>inside</em> the Docker container is programmed to listen on. The Changedetection.io application is configured by its developers to listen on port 5000 within its isolated container environment. Changing this internal container port would require modifying the application&rsquo;s internal configuration, which is generally more complex and unnecessary for resolving a host port conflict.</p></blockquote>
<h2 id="resources">Resources</h2>
<ul>
<li><a href="https://github.com/dgtlmoon/changedetection.io" target="_blank" >Changedetection.io GitHub Repository (includes documentation)</a></li>
<li><a href="https://hub.docker.com/r/linuxserver/changedetection.io" target="_blank" >LinuxServer.io Changedetection.io on Docker Hub</a></li>
</ul>
]]></content:encoded>
    </item>
    <item>
      <title>Setting Up qBittorrent with Private Internet Access (PIA) VPN on Synology NAS</title>
      <link>https://kunat.dev/notes/synology-qbitorrent-vpn-pia/</link>
      <pubDate>Sat, 12 Apr 2025 20:43:57 +0200</pubDate>
      <guid>https://kunat.dev/notes/synology-qbitorrent-vpn-pia/</guid>
      <description>&lt;h2 id=&#34;prerequisites&#34;&gt;Prerequisites&lt;/h2&gt;
&lt;p&gt;Before you start, you should have qBittorrent up and running using &lt;a href=&#34;https://trash-guides.info/File-and-Folder-Structure/How-to-set-up/Synology/&#34; target=&#34;_blank&#34; &gt;this&lt;/a&gt; guide.&lt;/p&gt;
&lt;h2 id=&#34;implementation&#34;&gt;Implementation&lt;/h2&gt;
&lt;p&gt;If you&amp;rsquo;ve used the guide linked above, the qBittorrent container is already running. There are two configuration files that we&amp;rsquo;ll need to edit: &lt;code&gt;.env&lt;/code&gt; and &lt;code&gt;docker-compose.yml&lt;/code&gt;.&lt;/p&gt;
&lt;h3 id=&#34;env&#34;&gt;.env&lt;/h3&gt;
&lt;p&gt;Make sure the &lt;code&gt;LAN_NETWORK&lt;/code&gt; variable is set properly. If you haven&amp;rsquo;t modified it after the initial setup, it should be set to &lt;code&gt;192.168.x.0/24&lt;/code&gt;. Update it so that your Synology&amp;rsquo;s LAN IP is within the address range. Example: since my Synology&amp;rsquo;s LAN IP address is 192.168.1.110, I&amp;rsquo;ve replaced &lt;code&gt;x&lt;/code&gt; with &lt;code&gt;1&lt;/code&gt;: &lt;code&gt;192.168.1.0/24&lt;/code&gt;.&lt;/p&gt;</description>
      <content:encoded><![CDATA[<h2 id="prerequisites">Prerequisites</h2>
<p>Before you start, you should have qBittorrent up and running using <a href="https://trash-guides.info/File-and-Folder-Structure/How-to-set-up/Synology/" target="_blank" >this</a> guide.</p>
<h2 id="implementation">Implementation</h2>
<p>If you&rsquo;ve used the guide linked above, the qBittorrent container is already running. There are two configuration files that we&rsquo;ll need to edit: <code>.env</code> and <code>docker-compose.yml</code>.</p>
<h3 id="env">.env</h3>
<p>Make sure the <code>LAN_NETWORK</code> variable is set properly. If you haven&rsquo;t modified it after the initial setup, it should be set to <code>192.168.x.0/24</code>. Update it so that your Synology&rsquo;s LAN IP is within the address range. Example: since my Synology&rsquo;s LAN IP address is 192.168.1.110, I&rsquo;ve replaced <code>x</code> with <code>1</code>: <code>192.168.1.0/24</code>.</p>
<p>Set <code>VPN_ENABLED</code> to <code>true</code></p>
<p>Set <code>VPN_PROVIDER</code> to <code>pia</code></p>
<blockquote>
<p>Hotio&rsquo;s qBittorrent image also supports Proton VPN out of the box. You should be able to use it with any provider that supports the WireGuard protocol.</p></blockquote>
<p>If you&rsquo;re using PIA or Proton, set <code>PORT_FORWARD</code> to true.</p>
<p>Add the following variables:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-shell" data-lang="shell"><span style="display:flex;"><span>VPN_PIA_USER<span style="color:#f92672">=</span>YOUR_PIA_USERNAME
</span></span><span style="display:flex;"><span>VPN_PIA_PASS<span style="color:#f92672">=</span>YOUR_PIA_PASSWORD
</span></span><span style="display:flex;"><span>VPN_PIA_PREFERRED_REGION<span style="color:#f92672">=</span>belgium <span style="color:#75715e"># optional</span>
</span></span></code></pre></div><p>Since port forwarding is disabled in some of the regions supported by PIA VPN, you might want to also set <code>VPN_PIA_PREFERRED_REGION</code>. You can see all available regions in <code>docker/appdata/qbittorrent/wireguard/pia-regions.json</code>. This file will be created the first time you run the container after setting your PIA username and password.</p>
<h3 id="docker-composeyml">docker-compose.yml</h3>
<p>Uncomment the <code>devices</code> section in <code>qbittorrent</code>:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-yml" data-lang="yml"><span style="display:flex;"><span><span style="color:#f92672">devices</span>:
</span></span><span style="display:flex;"><span>  - <span style="color:#ae81ff">/dev/net/tun:/dev/net/tun</span>
</span></span></code></pre></div><p>Add the following variables to the <code>environment</code> section:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-shell" data-lang="shell"><span style="display:flex;"><span>VPN_PIA_USER<span style="color:#f92672">=</span><span style="color:#e6db74">${</span>VPN_PIA_USER<span style="color:#e6db74">}</span>
</span></span><span style="display:flex;"><span>VPN_PIA_PASS<span style="color:#f92672">=</span><span style="color:#e6db74">${</span>VPN_PIA_PASS<span style="color:#e6db74">}</span>
</span></span><span style="display:flex;"><span>VPN_PIA_PREFERRED_REGION<span style="color:#f92672">=</span><span style="color:#e6db74">${</span>VPN_PIA_PREFERRED_REGION<span style="color:#e6db74">}</span>
</span></span></code></pre></div><p>Here&rsquo;s how your qBittorrent container definition should look:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-shell" data-lang="shell"><span style="display:flex;"><span>qbittorrent:
</span></span><span style="display:flex;"><span>  container_name: qbittorrent
</span></span><span style="display:flex;"><span>  image: hotio/qbittorrent:latest
</span></span><span style="display:flex;"><span>  restart: unless-stopped
</span></span><span style="display:flex;"><span>  logging:
</span></span><span style="display:flex;"><span>    driver: json-file
</span></span><span style="display:flex;"><span>    options:
</span></span><span style="display:flex;"><span>      max-file: <span style="color:#e6db74">${</span>DOCKERLOGGING_MAXFILE<span style="color:#e6db74">}</span>
</span></span><span style="display:flex;"><span>      max-size: <span style="color:#e6db74">${</span>DOCKERLOGGING_MAXSIZE<span style="color:#e6db74">}</span>
</span></span><span style="display:flex;"><span>  labels:
</span></span><span style="display:flex;"><span>    - org.hotio.pullio.update<span style="color:#f92672">=</span><span style="color:#e6db74">${</span>PULLIO_UPDATE<span style="color:#e6db74">}</span>
</span></span><span style="display:flex;"><span>    - org.hotio.pullio.notify<span style="color:#f92672">=</span><span style="color:#e6db74">${</span>PULLIO_NOTIFY<span style="color:#e6db74">}</span>
</span></span><span style="display:flex;"><span>    - org.hotio.pullio.discord.webhook<span style="color:#f92672">=</span><span style="color:#e6db74">${</span>PULLIO_DISCORD_WEBHOOK<span style="color:#e6db74">}</span>
</span></span><span style="display:flex;"><span>  ports:
</span></span><span style="display:flex;"><span>    - <span style="color:#e6db74">${</span>QBITTORRENT_WEBUI_PORT<span style="color:#e6db74">}</span>:<span style="color:#e6db74">${</span>QBITTORRENT_WEBUI_PORT<span style="color:#e6db74">}</span>
</span></span><span style="display:flex;"><span>    - <span style="color:#e6db74">${</span>QBITTORRENT_PRIVOXY_PORT<span style="color:#e6db74">}</span>:<span style="color:#e6db74">${</span>QBITTORRENT_PRIVOXY_PORT<span style="color:#e6db74">}</span>
</span></span><span style="display:flex;"><span>  cap_add:
</span></span><span style="display:flex;"><span>    - NET_ADMIN
</span></span><span style="display:flex;"><span>  devices:                              
</span></span><span style="display:flex;"><span>    - /dev/net/tun:/dev/net/tun         
</span></span><span style="display:flex;"><span>  sysctls:
</span></span><span style="display:flex;"><span>    - net.ipv4.conf.all.src_valid_mark<span style="color:#f92672">=</span><span style="color:#ae81ff">1</span>
</span></span><span style="display:flex;"><span>    - net.ipv6.conf.all.disable_ipv6<span style="color:#f92672">=</span><span style="color:#ae81ff">1</span>  
</span></span><span style="display:flex;"><span>  environment:
</span></span><span style="display:flex;"><span>    - PUID<span style="color:#f92672">=</span><span style="color:#e6db74">${</span>PUID<span style="color:#e6db74">}</span>
</span></span><span style="display:flex;"><span>    - PGID<span style="color:#f92672">=</span><span style="color:#e6db74">${</span>PGID<span style="color:#e6db74">}</span>
</span></span><span style="display:flex;"><span>    - TZ<span style="color:#f92672">=</span><span style="color:#e6db74">${</span>TZ<span style="color:#e6db74">}</span>
</span></span><span style="display:flex;"><span>    - UMASK<span style="color:#f92672">=</span><span style="color:#ae81ff">002</span>
</span></span><span style="display:flex;"><span>    - VPN_ENABLED<span style="color:#f92672">=</span><span style="color:#e6db74">${</span>VPN_ENABLED<span style="color:#e6db74">}</span>
</span></span><span style="display:flex;"><span>    - VPN_FIREWALL_TYPE<span style="color:#f92672">=</span>legacy
</span></span><span style="display:flex;"><span>    - VPN_PROVIDER<span style="color:#f92672">=</span><span style="color:#e6db74">${</span>VPN_PROVIDER<span style="color:#e6db74">}</span>
</span></span><span style="display:flex;"><span>    - VPN_LAN_NETWORK<span style="color:#f92672">=</span><span style="color:#e6db74">${</span>LAN_NETWORK<span style="color:#e6db74">}</span>
</span></span><span style="display:flex;"><span>    - VPN_CONF<span style="color:#f92672">=</span>wg0-fix
</span></span><span style="display:flex;"><span>    - VPN_AUTO_PORT_FORWARD<span style="color:#f92672">=</span><span style="color:#e6db74">${</span>PORT_FORWARD<span style="color:#e6db74">}</span>
</span></span><span style="display:flex;"><span>    - VPN_PIA_USER<span style="color:#f92672">=</span><span style="color:#e6db74">${</span>VPN_PIA_USER<span style="color:#e6db74">}</span>
</span></span><span style="display:flex;"><span>    - VPN_PIA_PASS<span style="color:#f92672">=</span><span style="color:#e6db74">${</span>VPN_PIA_PASS<span style="color:#e6db74">}</span>
</span></span><span style="display:flex;"><span>    - VPN_PIA_PREFERRED_REGION<span style="color:#f92672">=</span><span style="color:#e6db74">${</span>VPN_PIA_PREFERRED_REGION<span style="color:#e6db74">}</span>
</span></span><span style="display:flex;"><span>    - VPN_HEALTHCHECK_ENABLED<span style="color:#f92672">=</span>false
</span></span><span style="display:flex;"><span>    - PRIVOXY_ENABLED<span style="color:#f92672">=</span><span style="color:#e6db74">${</span>QBITTORRENT_ENABLE_PRIVOXY<span style="color:#e6db74">}</span>
</span></span><span style="display:flex;"><span>    - WEBUI_PORTS<span style="color:#f92672">=</span><span style="color:#e6db74">${</span>QBITTORRENT_WEBUI_PORT<span style="color:#e6db74">}</span>/tcp,<span style="color:#e6db74">${</span>QBITTORRENT_WEBUI_PORT<span style="color:#e6db74">}</span>/udp 
</span></span><span style="display:flex;"><span>  dns:
</span></span><span style="display:flex;"><span>    - 1.1.1.1
</span></span><span style="display:flex;"><span>    - 8.8.8.8
</span></span><span style="display:flex;"><span>  volumes:
</span></span><span style="display:flex;"><span>    - /etc/localtime:/etc/localtime:ro
</span></span><span style="display:flex;"><span>    - <span style="color:#e6db74">${</span>DOCKERCONFDIR<span style="color:#e6db74">}</span>/qbittorrent:/config:rw
</span></span><span style="display:flex;"><span>    - <span style="color:#e6db74">${</span>DOCKERSTORAGEDIR<span style="color:#e6db74">}</span>/torrents:/data/torrents:rw
</span></span></code></pre></div><h3 id="setting-up-tun-service">Setting up tun service</h3>
<blockquote>
<p><code>/dev/net/tun</code> is a kernel module that provides a network tunnel interface. On Synology NAS systems, it&rsquo;s used by VPN applications and other networking tools like qBittorrent with VPN functionality.</p></blockquote>
<p><a href="https://amoklauf.ch/posts/synology/tunservice/" target="_blank" >Installing the Tun Service for Synology and VPN</a></p>
<h2 id="testing-changes">Testing Changes</h2>
<p>With everything done, you need to try running the container with the latest changes:
<code>sudo docker-compose up --force-recreate qbittorrent</code></p>
<p>Here&rsquo;s a sample output from a successful startup:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-shell" data-lang="shell"><span style="display:flex;"><span>qbittorrent  | s6-rc: info: service init-wireguard successfully started
</span></span><span style="display:flex;"><span>qbittorrent  | s6-rc: info: service service-qbittorrent: starting
</span></span><span style="display:flex;"><span>qbittorrent  | s6-rc: info: service service-pia: starting
</span></span><span style="display:flex;"><span>qbittorrent  | s6-rc: info: service service-forwarder: starting
</span></span><span style="display:flex;"><span>qbittorrent  | s6-rc: info: service service-qbittorrent successfully started
</span></span><span style="display:flex;"><span>qbittorrent  | s6-rc: info: service service-pia successfully started
</span></span><span style="display:flex;"><span>qbittorrent  | s6-rc: info: service service-forwarder successfully started
</span></span><span style="display:flex;"><span>qbittorrent  | s6-rc: info: service legacy-services: starting
</span></span><span style="display:flex;"><span>qbittorrent  | <span style="color:#f92672">[</span>INF<span style="color:#f92672">]</span> <span style="color:#f92672">[</span>2025-04-12 20:23:44<span style="color:#f92672">]</span> <span style="color:#f92672">[</span>PIA<span style="color:#f92672">]</span> Fetching new forwarded port...
</span></span><span style="display:flex;"><span>qbittorrent  | s6-rc: info: service legacy-services successfully started
</span></span><span style="display:flex;"><span>qbittorrent  | <span style="color:#f92672">[</span>INF<span style="color:#f92672">]</span> <span style="color:#f92672">[</span>2025-04-12 20:23:44<span style="color:#f92672">]</span> <span style="color:#f92672">[</span>PIA<span style="color:#f92672">]</span> Forwarded port will expire at <span style="color:#f92672">[</span>2025-06-14T06:23:44.166007524Z<span style="color:#f92672">]</span>.
</span></span><span style="display:flex;"><span>qbittorrent  | 
</span></span><span style="display:flex;"><span>qbittorrent  | *** Legal Notice ***
</span></span><span style="display:flex;"><span>qbittorrent  | qBittorrent is a file sharing program. When you run a torrent, its data will be made available to others by means of upload. Any content you share is your sole responsibility.
</span></span><span style="display:flex;"><span>qbittorrent  | 
</span></span><span style="display:flex;"><span>qbittorrent  | If you have read the legal notice, you can use command line option <span style="color:#e6db74">`</span>--confirm-legal-notice<span style="color:#e6db74">`</span> to suppress this message.
</span></span><span style="display:flex;"><span>qbittorrent  | 
</span></span><span style="display:flex;"><span>qbittorrent  | WebUI will be started shortly after internal preparations. Please wait...
</span></span><span style="display:flex;"><span>qbittorrent  | 
</span></span><span style="display:flex;"><span>qbittorrent  | <span style="color:#f92672">[</span>INF<span style="color:#f92672">]</span> <span style="color:#f92672">[</span>2025-04-12 20:23:54<span style="color:#f92672">]</span> <span style="color:#f92672">[</span>VPN<span style="color:#f92672">]</span> Forwarded port is <span style="color:#f92672">[</span>REDACTED<span style="color:#f92672">]</span>.
</span></span><span style="display:flex;"><span>qbittorrent  | 
</span></span><span style="display:flex;"><span>qbittorrent  | ******** Information ********
</span></span><span style="display:flex;"><span>qbittorrent  | To control qBittorrent, access the WebUI at: http://localhost:8080
</span></span><span style="display:flex;"><span>qbittorrent  | 
</span></span><span style="display:flex;"><span>qbittorrent  | <span style="color:#f92672">[</span>INF<span style="color:#f92672">]</span> <span style="color:#f92672">[</span>2025-04-12 20:24:04<span style="color:#f92672">]</span> <span style="color:#f92672">[</span>QBITTORRENT<span style="color:#f92672">]</span> Updated forwarded port to <span style="color:#f92672">[</span>REDACTED<span style="color:#f92672">]</span>.
</span></span></code></pre></div><h2 id="troubleshooting">Troubleshooting</h2>
<p>The only issue I&rsquo;ve encountered was with the <code>/dev/net/tun</code> module. It had overly restrictive permission settings:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-shell" data-lang="shell"><span style="display:flex;"><span>ls -al /dev/net/tun
</span></span><span style="display:flex;"><span><span style="color:#75715e"># Overly restrictive; should return `crw-rw---- root root`</span>
</span></span><span style="display:flex;"><span>crw------- <span style="color:#ae81ff">1</span> root root 10, <span style="color:#ae81ff">200</span> Feb <span style="color:#ae81ff">13</span> 02:14 /dev/net/tun
</span></span></code></pre></div><p>The qBittorrent container needs at least <strong>660</strong> permission. You can fix that by executing:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-shell" data-lang="shell"><span style="display:flex;"><span>sudo chmod <span style="color:#ae81ff">660</span> /dev/net/tun  <span style="color:#75715e"># Allow root group access</span>
</span></span></code></pre></div><h2 id="verifying-vpn-works">Verifying VPN Works</h2>
<p>To verify that the qBittorrent container is hidden behind a VPN, you can use <a href="https://ipleak.net/" target="_blank" >IP/DNS Detect</a> (<code>Torrent Address detection</code>).</p>
<h2 id="resources">Resources</h2>
<ul>
<li><a href="https://hotio.dev/containers/qbittorrent/" target="_blank" >hotio/qbittorrent</a></li>
</ul>
]]></content:encoded>
    </item>
    <item>
      <title>Getting Back Up to Speed with Objective-C in 2025</title>
      <link>https://kunat.dev/notes/getting-up-to-speed-with-objectivec/</link>
      <pubDate>Thu, 03 Apr 2025 10:10:52 +0200</pubDate>
      <guid>https://kunat.dev/notes/getting-up-to-speed-with-objectivec/</guid>
      <description>&lt;p&gt;I&amp;rsquo;ve mostly moved away from using Objective-C over the years. While it still appears in some of the projects I work on, it’s been quite some time since I last had to work on a feature written entirely in Objective-C. These days, I typically encounter it when integrating a new feature written in Swift into an older codebase.&lt;/p&gt;
&lt;p&gt;Recently, however, I needed to brush up on Objective-C for a job I was interviewing for.&lt;/p&gt;</description>
      <content:encoded><![CDATA[<p>I&rsquo;ve mostly moved away from using Objective-C over the years. While it still appears in some of the projects I work on, it’s been quite some time since I last had to work on a feature written entirely in Objective-C. These days, I typically encounter it when integrating a new feature written in Swift into an older codebase.</p>
<p>Recently, however, I needed to brush up on Objective-C for a job I was interviewing for.</p>
<p>Below is a list of strategies that helped me quickly get back up to speed with Objective-C in about 6–8 hours. The list is fairly comprehensive—it should refresh your memory and might even teach you something new. That said, if you’re completely new to the language, YMMV.</p>
<h2 id="language-fundamentals-objective-c-for-swift-developers-by-paul-hudson">Language Fundamentals: <em>Objective-C for Swift Developers</em> by Paul Hudson</h2>
<p>I can’t recommend <a href="https://www.hackingwithswift.com/store/objective-c-for-swift-developers" target="_blank" >this book</a> enough. It’s only 150 pages long and covers a wide range of language features and quirks you’ve likely forgotten—<a href="http://fuckingblocksyntax.com" target="_blank" >block syntax, anyone?</a></p>
<p>One tip: have a playground project open in Xcode while you read. If you’re not sure how to create an Objective-C project in Xcode 16, use <a href="/notes/xcode-objc-project-template/" >this guide</a>.</p>
<h2 id="leetcode-challenges">LeetCode Challenges</h2>
<p>LeetCode doesn’t support Objective-C directly in its web interface, but that’s not an issue in the age of LLMs!</p>
<p>Since my goal was to refresh my knowledge of basic language features, I focused primarily on easy challenges. By &ldquo;basic language features,&rdquo; I mean working with strings, arrays, and simple data structures.</p>
<blockquote>
<p>I definitely didn’t want to spend time solving complex problems like counting islands in a binary matrix (looking at you, “Number of Islands II,” problem #305).</p></blockquote>
<p>I selected a few easy problems and worked through them in Xcode. <a href="https://claude.ai/" target="_blank" >Claude</a> was particularly helpful in generating test cases to validate my solutions.</p>
<h2 id="working-with-large-codebases">Working with Large Codebases</h2>
<p>The final exercise was exploring a larger project written entirely in Objective-C. I started with the App Delegate and used an LLM to answer questions about anything that seemed unclear. I focused on understanding how common patterns were implemented—things like singletons, callback-based APIs, and delegate patterns.</p>
<p>Here are some projects I recommend checking out:</p>
<ul>
<li><a href="https://github.com/adium/adium" target="_blank" >Adium</a></li>
<li><a href="https://github.com/Kaakati/iOS-Objective-C-Library" target="_blank" >A curated list of awesome iOS ecosystem libraries written in Objective-C</a></li>
</ul>
<hr>
<p>That’s it! I was genuinely surprised by how much fun this process turned out to be—and also by how much I’d forgotten about Objective-C. Hopefully, this time around, the block syntax will stick with me longer!</p>
]]></content:encoded>
    </item>
    <item>
      <title>Region-Specific Language Control: Implementing Forced Localization in iOS Apps</title>
      <link>https://kunat.dev/notes/removing-unwanted-localization-files/</link>
      <pubDate>Wed, 02 Apr 2025 22:08:53 +0200</pubDate>
      <guid>https://kunat.dev/notes/removing-unwanted-localization-files/</guid>
      <description>&lt;p&gt;My current project has a single target and multiple schemes. Each scheme represents the same app for different countries/regions. It&amp;rsquo;s essentially the same application with minor adjustments made for each of the supported jurisdictions.&lt;/p&gt;
&lt;p&gt;In Xcode projects, you define supported languages at the project level (not scheme level). Because of this structure, each app variant inherits the same language settings.&lt;/p&gt;
&lt;p&gt;My task was to force each app variant to support only the language for the region it was released in. The French app should only support French, no matter what the user&amp;rsquo;s device language is set to, and so on. This decision was made because large portions of the app are web-based and only supported a single language.&lt;/p&gt;</description>
      <content:encoded><![CDATA[<p>My current project has a single target and multiple schemes. Each scheme represents the same app for different countries/regions. It&rsquo;s essentially the same application with minor adjustments made for each of the supported jurisdictions.</p>
<p>In Xcode projects, you define supported languages at the project level (not scheme level). Because of this structure, each app variant inherits the same language settings.</p>
<p>My task was to force each app variant to support only the language for the region it was released in. The French app should only support French, no matter what the user&rsquo;s device language is set to, and so on. This decision was made because large portions of the app are web-based and only supported a single language.</p>
<h2 id="solution">Solution</h2>
<p>There are multiple ways to solve this problem. What follows is the approach that worked best for my particular use case, given the project&rsquo;s current structure:</p>
<p>First, create a <a href="https://nshipster.com/xcconfig/" target="_blank" >build configuration file</a> for each of the schemes and define the <code>LOCALIZATIONS_TO_KEEP</code> variable.</p>
<pre tabindex="0"><code class="language-plist" data-lang="plist">LOCALIZATIONS_TO_KEEP = fr
</code></pre><p>Then, add a new build phase called &ldquo;Remove unused localizations.&rdquo; Remember to position it after the &ldquo;Copy Bundle Resources&rdquo; build phase:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-shell" data-lang="shell"><span style="display:flex;"><span>echo <span style="color:#e6db74">&#34;Keeping only </span><span style="color:#e6db74">${</span>LOCALIZATIONS_TO_KEEP<span style="color:#e6db74">}</span><span style="color:#e6db74">.lproj&#34;</span>
</span></span><span style="display:flex;"><span>echo <span style="color:#e6db74">&#34;Application .app path: </span><span style="color:#e6db74">${</span>CODESIGNING_FOLDER_PATH<span style="color:#e6db74">}</span><span style="color:#e6db74">&#34;</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># Only search in the main app bundle, excluding </span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># Frameworks directory and other dependency folders</span>
</span></span><span style="display:flex;"><span>find <span style="color:#e6db74">&#34;</span><span style="color:#e6db74">${</span>CODESIGNING_FOLDER_PATH<span style="color:#e6db74">}</span><span style="color:#e6db74">&#34;</span> -type d -name <span style="color:#e6db74">&#34;*.lproj&#34;</span> <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span><span style="color:#ae81ff"></span>  -not -path <span style="color:#e6db74">&#34;*/Frameworks/*&#34;</span> <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span><span style="color:#ae81ff"></span>  -not -path <span style="color:#e6db74">&#34;*/PlugIns/*&#34;</span> <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span><span style="color:#ae81ff"></span>  -not -path <span style="color:#e6db74">&#34;*/Watch/*&#34;</span> <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span><span style="color:#ae81ff"></span>  -not -path <span style="color:#e6db74">&#34;*/SwiftPM/*&#34;</span> <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span><span style="color:#ae81ff"></span>  | grep -v <span style="color:#e6db74">&#34;</span><span style="color:#e6db74">${</span>LOCALIZATIONS_TO_KEEP<span style="color:#e6db74">}</span><span style="color:#e6db74">.lproj&#34;</span> <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span><span style="color:#ae81ff"></span>  | xargs rm -rf
</span></span></code></pre></div><blockquote>
<p><code>CODESIGNING_FOLDER_PATH</code> is an environment variable used in Xcode build scripts that represents the path to the app bundle being built. It typically points to the directory where the compiled application and its resources are placed before code signing occurs.</p></blockquote>
<p>You&rsquo;ll want to exclude locations where third-party dependencies might be located, because you can&rsquo;t guarantee that they support the same language you want to force the app to use.</p>
<p>You can verify that the script works by navigating to ${CODESIGNING_FOLDER_PATH} in derived data and checking if the script successfully removed all other localization files.</p>
<h2 id="alternative-solutions">Alternative Solutions</h2>
<p>This issue could be solved by setting <code>CFBundleLocalizations</code> and <code>CFBundleDevelopmentRegion</code> IF my project used separate targets instead of separate schemes for different apps.</p>
<p>Alternatively, I could dynamically select Info.plist files for each of the schemes. I opted against this approach since the project already had build configurations defined for each of the schemes.</p>
]]></content:encoded>
    </item>
    <item>
      <title>Creating a New Objective-C Project in Xcode 16</title>
      <link>https://kunat.dev/notes/xcode-objc-project-template/</link>
      <pubDate>Tue, 25 Mar 2025 17:52:35 +0100</pubDate>
      <guid>https://kunat.dev/notes/xcode-objc-project-template/</guid>
      <description>&lt;p&gt;Update: 26/03/25&lt;/p&gt;
&lt;p&gt;It looks like Objective-C is still an option in the default iOS project template. You can access it after setting the &lt;em&gt;Interface&lt;/em&gt; to &amp;ldquo;Storyboard&amp;rdquo;. I hadn&amp;rsquo;t realized these dropdown menus were interconnected.&lt;/p&gt;
&lt;p&gt;&lt;img alt=&#34;hello there&#34; loading=&#34;lazy&#34; src=&#34;https://kunat.dev/notes/images/xcode-objc-project-template-2.jpg&#34;&gt;&lt;/p&gt;
&lt;p&gt;All credit goes to &lt;a href=&#34;https://douglashill.co&#34; target=&#34;_blank&#34; &gt;Douglas&lt;/a&gt; for spotting this!&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;tl;dr use macOS → Application → Game or Command Line Tool&lt;/p&gt;
&lt;p&gt;&lt;img alt=&#34;hello there&#34; loading=&#34;lazy&#34; src=&#34;https://kunat.dev/notes/images/objc-xcode-project-template.jpg&#34;&gt;&lt;/p&gt;
&lt;p&gt;Recently I wanted to refresh my memory on Objective-C. My first thought was to open Xcode, create a new project with language set to Objective-C and play with it. To my surprise, none of the iOS Application project templates allow you to select any other language than Swift.&lt;/p&gt;</description>
      <content:encoded><![CDATA[<p>Update: 26/03/25</p>
<p>It looks like Objective-C is still an option in the default iOS project template. You can access it after setting the <em>Interface</em> to &ldquo;Storyboard&rdquo;. I hadn&rsquo;t realized these dropdown menus were interconnected.</p>
<p><img alt="hello there" loading="lazy" src="/notes/images/xcode-objc-project-template-2.jpg"></p>
<p>All credit goes to <a href="https://douglashill.co" target="_blank" >Douglas</a> for spotting this!</p>
<hr>
<p>tl;dr use macOS → Application → Game or Command Line Tool</p>
<p><img alt="hello there" loading="lazy" src="/notes/images/objc-xcode-project-template.jpg"></p>
<p>Recently I wanted to refresh my memory on Objective-C. My first thought was to open Xcode, create a new project with language set to Objective-C and play with it. To my surprise, none of the iOS Application project templates allow you to select any other language than Swift.</p>
<p>I (mistakenly) assumed that Apple must have removed Objective-C from all templates altogether. I ended up searching for some open-source Objective-C project that I could use as a starting point.</p>
<p>Later on I learned that Objective-C language options are still alive and well in some templates for macOS.</p>
]]></content:encoded>
    </item>
    <item>
      <title>Temporarily Disable SwiftLint plugin in Swift Packages</title>
      <link>https://kunat.dev/notes/disbale-swiftlint-plugin/</link>
      <pubDate>Tue, 11 Feb 2025 21:44:20 +0100</pubDate>
      <guid>https://kunat.dev/notes/disbale-swiftlint-plugin/</guid>
      <description>&lt;p&gt;I&amp;rsquo;ve noticed that the SwiftLint plugin can add up to 30 seconds to incremental build times. In my case, it nearly doubled the average incremental build time!&lt;/p&gt;
&lt;p&gt;&lt;img alt=&#34;Xcode build timeline&#34; loading=&#34;lazy&#34; src=&#34;https://kunat.dev/notes/images/build-timeline.png&#34;&gt;&lt;/p&gt;
&lt;p&gt;Since there’s no straightforward way to disable SwiftLint across all local packages in a project, I decided on the simplest solution: automating the process of temporarily commenting out SwiftLint from the dependencies list.&lt;/p&gt;
&lt;p&gt;In my projects, to ensure the SwiftLint plugin is automatically added to all package targets, I append the following code to each Swift package manifest:&lt;/p&gt;</description>
      <content:encoded><![CDATA[<p>I&rsquo;ve noticed that the SwiftLint plugin can add up to 30 seconds to incremental build times. In my case, it nearly doubled the average incremental build time!</p>
<p><img alt="Xcode build timeline" loading="lazy" src="/notes/images/build-timeline.png"></p>
<p>Since there’s no straightforward way to disable SwiftLint across all local packages in a project, I decided on the simplest solution: automating the process of temporarily commenting out SwiftLint from the dependencies list.</p>
<p>In my projects, to ensure the SwiftLint plugin is automatically added to all package targets, I append the following code to each Swift package manifest:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-swift" data-lang="swift"><span style="display:flex;"><span><span style="color:#75715e">// Inject base plugins into each target</span>
</span></span><span style="display:flex;"><span>package.targets = package.targets.map { target <span style="color:#66d9ef">in</span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">var</span> plugins = target.plugins ?? []
</span></span><span style="display:flex;"><span>    plugins.append(.plugin(name: <span style="color:#e6db74">&#34;SwiftLintBuildToolPlugin&#34;</span>, package: <span style="color:#e6db74">&#34;SwiftLintPlugins&#34;</span>))
</span></span><span style="display:flex;"><span>    target.plugins = plugins
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> target
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>Here’s the shell script I use to comment out SwiftLint across all packages and create a temporary commit. You can add this to your shell configuration file:</p>
<blockquote>
<p>Make sure your git working tree is clean before running this script. Otherwise, it will include your existing changes in the commit.</p></blockquote>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-shell" data-lang="shell"><span style="display:flex;"><span><span style="color:#75715e"># .zshrc</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># Disable SwiftLint</span>
</span></span><span style="display:flex;"><span>dsl<span style="color:#f92672">()</span> <span style="color:#f92672">{</span>
</span></span><span style="display:flex;"><span>    find . <span style="color:#ae81ff">\(</span> -path <span style="color:#e6db74">&#39;*/.*&#39;</span> -prune <span style="color:#ae81ff">\)</span> -o -type f -name <span style="color:#e6db74">&#34;Package.swift&#34;</span> -print | <span style="color:#66d9ef">while</span> read -r file; <span style="color:#66d9ef">do</span>
</span></span><span style="display:flex;"><span>        sed -i <span style="color:#e6db74">&#39;&#39;</span> <span style="color:#e6db74">&#39;s|plugins.append(.plugin(name: &#34;SwiftLintBuildToolPlugin&#34;, package: &#34;SwiftLintPlugins&#34;))|//plugins.append(.plugin(name: &#34;SwiftLintBuildToolPlugin&#34;, package: &#34;SwiftLintPlugins&#34;))|&#39;</span> <span style="color:#e6db74">&#34;</span>$file<span style="color:#e6db74">&#34;</span> <span style="color:#f92672">&amp;&amp;</span> <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span><span style="color:#ae81ff"></span>        echo <span style="color:#e6db74">&#34;Updated: </span>$file<span style="color:#e6db74">&#34;</span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">done</span>
</span></span><span style="display:flex;"><span>    git add -A <span style="color:#f92672">&amp;&amp;</span> git commit -m <span style="color:#e6db74">&#34;temp: disable swiftlint&#34;</span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">}</span>
</span></span></code></pre></div>]]></content:encoded>
    </item>
    <item>
      <title>Homarr Synology Setup with Docker Compose</title>
      <link>https://kunat.dev/notes/homarr-synology-setup/</link>
      <pubDate>Sat, 04 Jan 2025 19:00:37 +0100</pubDate>
      <guid>https://kunat.dev/notes/homarr-synology-setup/</guid>
      <description>&lt;p&gt;Homarr creates a customizable browser homepage that lets you manage and interact with Docker containers running on your homeserver. Here&amp;rsquo;s my dashboard setup:&lt;/p&gt;
&lt;p&gt;&lt;img alt=&#34;hello there&#34; loading=&#34;lazy&#34; src=&#34;https://kunat.dev/notes/images/homarr-dashboard.png&#34;&gt;&lt;/p&gt;
&lt;h2 id=&#34;prerequisites&#34;&gt;Prerequisites&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;Docker installed on your system (follow &lt;a href=&#34;https://trash-guides.info/File-and-Folder-Structure/How-to-set-up/Synology/&#34; target=&#34;_blank&#34; &gt;this Synology setup guide&lt;/a&gt;)&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&#34;installation&#34;&gt;Installation&lt;/h2&gt;
&lt;p&gt;Append this configuration to your &lt;code&gt;docker-compose.yml&lt;/code&gt; file:&lt;/p&gt;
&lt;div class=&#34;highlight&#34;&gt;&lt;pre tabindex=&#34;0&#34; style=&#34;color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;&#34;&gt;&lt;code class=&#34;language-yaml&#34; data-lang=&#34;yaml&#34;&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;  &lt;span style=&#34;color:#f92672&#34;&gt;homarr&lt;/span&gt;:
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;      &lt;span style=&#34;color:#f92672&#34;&gt;container_name&lt;/span&gt;: &lt;span style=&#34;color:#ae81ff&#34;&gt;homarr&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;      &lt;span style=&#34;color:#f92672&#34;&gt;image&lt;/span&gt;: &lt;span style=&#34;color:#ae81ff&#34;&gt;ghcr.io/ajnart/homarr:latest&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;      &lt;span style=&#34;color:#f92672&#34;&gt;restart&lt;/span&gt;: &lt;span style=&#34;color:#ae81ff&#34;&gt;unless-stopped&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;      &lt;span style=&#34;color:#f92672&#34;&gt;logging&lt;/span&gt;:
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;        &lt;span style=&#34;color:#f92672&#34;&gt;driver&lt;/span&gt;: &lt;span style=&#34;color:#ae81ff&#34;&gt;json-file&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;        &lt;span style=&#34;color:#f92672&#34;&gt;options&lt;/span&gt;:
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;          &lt;span style=&#34;color:#f92672&#34;&gt;max-file&lt;/span&gt;: &lt;span style=&#34;color:#ae81ff&#34;&gt;${DOCKERLOGGING_MAXFILE}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;          &lt;span style=&#34;color:#f92672&#34;&gt;max-size&lt;/span&gt;: &lt;span style=&#34;color:#ae81ff&#34;&gt;${DOCKERLOGGING_MAXSIZE}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;      &lt;span style=&#34;color:#f92672&#34;&gt;environment&lt;/span&gt;:
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;        - &lt;span style=&#34;color:#ae81ff&#34;&gt;PUID=${PUID}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;        - &lt;span style=&#34;color:#ae81ff&#34;&gt;PGID=${PGID}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;        - &lt;span style=&#34;color:#ae81ff&#34;&gt;TZ=${TZ}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;      &lt;span style=&#34;color:#f92672&#34;&gt;volumes&lt;/span&gt;:
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;        - &lt;span style=&#34;color:#ae81ff&#34;&gt;${DOCKERCONFDIR}/homarr/configs:/app/data/configs&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;        - &lt;span style=&#34;color:#ae81ff&#34;&gt;${DOCKERCONFDIR}/homarr/icons:/app/public/icons&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;        - &lt;span style=&#34;color:#ae81ff&#34;&gt;${DOCKERCONFDIR}/homarr/data:/data&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;      &lt;span style=&#34;color:#f92672&#34;&gt;ports&lt;/span&gt;:
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;        - &lt;span style=&#34;color:#ae81ff&#34;&gt;7575&lt;/span&gt;:&lt;span style=&#34;color:#ae81ff&#34;&gt;7575&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Start the container by running this command in your docker directory (typically &lt;code&gt;/volume1/docker/appdata&lt;/code&gt;):&lt;/p&gt;</description>
      <content:encoded><![CDATA[<p>Homarr creates a customizable browser homepage that lets you manage and interact with Docker containers running on your homeserver. Here&rsquo;s my dashboard setup:</p>
<p><img alt="hello there" loading="lazy" src="/notes/images/homarr-dashboard.png"></p>
<h2 id="prerequisites">Prerequisites</h2>
<ul>
<li>Docker installed on your system (follow <a href="https://trash-guides.info/File-and-Folder-Structure/How-to-set-up/Synology/" target="_blank" >this Synology setup guide</a>)</li>
</ul>
<h2 id="installation">Installation</h2>
<p>Append this configuration to your <code>docker-compose.yml</code> file:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span>  <span style="color:#f92672">homarr</span>:
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">container_name</span>: <span style="color:#ae81ff">homarr</span>
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">image</span>: <span style="color:#ae81ff">ghcr.io/ajnart/homarr:latest</span>
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">restart</span>: <span style="color:#ae81ff">unless-stopped</span>
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">logging</span>:
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">driver</span>: <span style="color:#ae81ff">json-file</span>
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">options</span>:
</span></span><span style="display:flex;"><span>          <span style="color:#f92672">max-file</span>: <span style="color:#ae81ff">${DOCKERLOGGING_MAXFILE}</span>
</span></span><span style="display:flex;"><span>          <span style="color:#f92672">max-size</span>: <span style="color:#ae81ff">${DOCKERLOGGING_MAXSIZE}</span>
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">environment</span>:
</span></span><span style="display:flex;"><span>        - <span style="color:#ae81ff">PUID=${PUID}</span>
</span></span><span style="display:flex;"><span>        - <span style="color:#ae81ff">PGID=${PGID}</span>
</span></span><span style="display:flex;"><span>        - <span style="color:#ae81ff">TZ=${TZ}</span>
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">volumes</span>:
</span></span><span style="display:flex;"><span>        - <span style="color:#ae81ff">${DOCKERCONFDIR}/homarr/configs:/app/data/configs</span>
</span></span><span style="display:flex;"><span>        - <span style="color:#ae81ff">${DOCKERCONFDIR}/homarr/icons:/app/public/icons</span>
</span></span><span style="display:flex;"><span>        - <span style="color:#ae81ff">${DOCKERCONFDIR}/homarr/data:/data</span>
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">ports</span>:
</span></span><span style="display:flex;"><span>        - <span style="color:#ae81ff">7575</span>:<span style="color:#ae81ff">7575</span>
</span></span></code></pre></div><p>Start the container by running this command in your docker directory (typically <code>/volume1/docker/appdata</code>):</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>docker compose up -d
</span></span></code></pre></div><p>This launches Homarr in detached mode (running in the background).</p>
<h2 id="resources">Resources</h2>
<ul>
<li><a href="https://homarr.dev/docs/getting-started/installation" target="_blank" >Official Homarr Installation Guide</a></li>
</ul>
]]></content:encoded>
    </item>
    <item>
      <title>Create a Safari Extension to Extract Article Content</title>
      <link>https://kunat.dev/notes/safari-extension-reader-mode/</link>
      <pubDate>Wed, 18 Dec 2024 18:59:56 +0100</pubDate>
      <guid>https://kunat.dev/notes/safari-extension-reader-mode/</guid>
      <description>&lt;p&gt;&lt;img alt=&#34;safari-extension-reader-mode-preview&#34; loading=&#34;lazy&#34; src=&#34;https://kunat.dev/notes/images/safari-extension-reader-mode-preview.png&#34;&gt;&lt;/p&gt;
&lt;p&gt;In this article, I&amp;rsquo;ll show you how to access a web page&amp;rsquo;s content, alter it using JavaScript, and display it using your Safari extension&amp;rsquo;s UI. Specifically, I&amp;rsquo;ll demonstrate how to display an article&amp;rsquo;s content in a popover.&lt;/p&gt;
&lt;p&gt;I often use Large Language Models (LLMs) to summarize articles when I&amp;rsquo;m unsure if they&amp;rsquo;re worth reading. Without the extension we&amp;rsquo;re about to build, I&amp;rsquo;d need to manually trigger Safari&amp;rsquo;s reader mode (⌘ + ⇧ + R), copy all content, and paste it into Claude or ChatGPT. This extension is a handy tool that reduces friction!&lt;/p&gt;</description>
      <content:encoded><![CDATA[<p><img alt="safari-extension-reader-mode-preview" loading="lazy" src="/notes/images/safari-extension-reader-mode-preview.png"></p>
<p>In this article, I&rsquo;ll show you how to access a web page&rsquo;s content, alter it using JavaScript, and display it using your Safari extension&rsquo;s UI. Specifically, I&rsquo;ll demonstrate how to display an article&rsquo;s content in a popover.</p>
<p>I often use Large Language Models (LLMs) to summarize articles when I&rsquo;m unsure if they&rsquo;re worth reading. Without the extension we&rsquo;re about to build, I&rsquo;d need to manually trigger Safari&rsquo;s reader mode (⌘ + ⇧ + R), copy all content, and paste it into Claude or ChatGPT. This extension is a handy tool that reduces friction!</p>
<p>Start by creating a new project using the &ldquo;Safari App Extension&rdquo; template, or add a Safari Extension target to your project. When prompted, set <code>type</code> to Safari App Extension (the default at the time of writing).</p>
<p>After the initial setup is complete, follow <a href="https://www.polpiella.dev/safari-extensions-swiftui" target="_blank" >this guide</a> to modify the template so that you can use SwiftUI to build the UI. Follow all the instructions except for one: do not delete <code>script.js</code>. We&rsquo;ll need it to capture the web page&rsquo;s content.</p>
<p>Once that&rsquo;s done, we&rsquo;ll proceed to get the current web page&rsquo;s content, modify it to extract the article content, and display it. You can check out the complete sample project <a href="https://github.com/bkunat/SafariReaderModeSample" target="_blank" >here</a>.</p>
<ol>
<li><strong>Getting Active Safari Page</strong> (<code>getActiveSafariPage</code>)</li>
</ol>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-swift" data-lang="swift"><span style="display:flex;"><span><span style="color:#66d9ef">let</span> page = <span style="color:#66d9ef">try</span> await getActiveSafariPage()
</span></span></code></pre></div><ul>
<li>Uses Safari&rsquo;s extension API to retrieve the current window, tab, and page</li>
<li>Returns a <code>SFSafariPage</code> object representing the active browser page</li>
</ul>
<ol start="2">
<li><strong>Requesting Content</strong> (<code>requestAndReceiveContent</code>)</li>
</ol>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-swift" data-lang="swift"><span style="display:flex;"><span>page.dispatchMessageToScript(
</span></span><span style="display:flex;"><span>    withName: SafariExtensionMessage.Name.getContent.rawValue,
</span></span><span style="display:flex;"><span>    userInfo: <span style="color:#66d9ef">nil</span>)
</span></span></code></pre></div><ul>
<li>Sends a &ldquo;getContent&rdquo; message to the injected content script</li>
</ul>
<ol start="3">
<li><strong>Content Script Processing</strong> (<code>script.js</code>)</li>
</ol>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-javascript" data-lang="javascript"><span style="display:flex;"><span><span style="color:#66d9ef">function</span> <span style="color:#a6e22e">getPageContent</span>() {
</span></span><span style="display:flex;"><span>    <span style="color:#75715e">// Tries to extract content in this order:
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span>    <span style="color:#75715e">// 1. Structured article data (JSON-LD)
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span>    <span style="color:#75715e">// 2. Article elements with specific selectors
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span>    <span style="color:#75715e">// 3. Fallback to body text
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span>    
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">const</span> <span style="color:#a6e22e">content</span> <span style="color:#f92672">=</span> {
</span></span><span style="display:flex;"><span>        <span style="color:#a6e22e">title</span><span style="color:#f92672">:</span> document.<span style="color:#a6e22e">title</span>,
</span></span><span style="display:flex;"><span>        <span style="color:#a6e22e">body</span><span style="color:#f92672">:</span> <span style="color:#a6e22e">getReaderContent</span>().<span style="color:#a6e22e">trim</span>()
</span></span><span style="display:flex;"><span>    };
</span></span><span style="display:flex;"><span>    
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">safari</span>.<span style="color:#a6e22e">extension</span>.<span style="color:#a6e22e">dispatchMessage</span>(<span style="color:#e6db74">&#34;pageContent&#34;</span>, { <span style="color:#a6e22e">content</span> });
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><ol start="4">
<li><strong>Message Handling</strong> (<code>SafariExtensionHandler</code>)</li>
</ol>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-swift" data-lang="swift"><span style="display:flex;"><span><span style="color:#66d9ef">case</span> .pageContent:
</span></span><span style="display:flex;"><span>    NotificationCenter.<span style="color:#66d9ef">default</span>.post(
</span></span><span style="display:flex;"><span>        name: NSNotification.Name(SafariExtensionMessage.Notification.messageReceived.rawValue),
</span></span><span style="display:flex;"><span>        object: <span style="color:#66d9ef">nil</span>,
</span></span><span style="display:flex;"><span>        userInfo: userInfo
</span></span><span style="display:flex;"><span>    )
</span></span></code></pre></div><ul>
<li>Receives the content message from the script</li>
<li>Posts it to NotificationCenter</li>
</ul>
<ol start="5">
<li><strong>Content Reception</strong> (<code>PopoverViewModel</code>)</li>
</ol>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-swift" data-lang="swift"><span style="display:flex;"><span><span style="color:#66d9ef">for</span> await notification <span style="color:#66d9ef">in</span> NotificationCenter.<span style="color:#66d9ef">default</span>
</span></span><span style="display:flex;"><span>    .notifications(named: NSNotification.Name(SafariExtensionMessage.Notification.messageReceived.rawValue))
</span></span><span style="display:flex;"><span>    .compactMap({ notification <span style="color:#66d9ef">in</span>
</span></span><span style="display:flex;"><span>        <span style="color:#75715e">// Converts notification data to WebPageContent</span>
</span></span><span style="display:flex;"><span>    })
</span></span></code></pre></div><ul>
<li>Listens for the notification with the page content</li>
<li>Converts the received data into a <code>WebPageContent</code> struct</li>
<li>Returns the structured content to be displayed in the popover</li>
</ul>
<h2 id="resources">Resources</h2>
<ul>
<li><a href="https://www.polpiella.dev/safari-extensions-swiftui" target="_blank" >How to build a Safari extension with SwiftUI</a></li>
<li><a href="https://github.com/bkunat/SafariReaderModeSample" target="_blank" >SafariReaderModeSample | GitHub</a></li>
</ul>
]]></content:encoded>
    </item>
    <item>
      <title>Verifying VPN Status for Docker qBittorrent on Synology</title>
      <link>https://kunat.dev/notes/check-torrent-client-vpn-ip/</link>
      <pubDate>Sat, 30 Nov 2024 22:09:34 +0100</pubDate>
      <guid>https://kunat.dev/notes/check-torrent-client-vpn-ip/</guid>
      <description>&lt;p&gt;I run &lt;code&gt;qbittorrent&lt;/code&gt; in a Docker container on my Synology NAS, with VPN configured at the Synology system level (Control Panel -&amp;gt; Network -&amp;gt; Network Interface) rather than the container level. For setup instructions, you can follow &lt;a href=&#34;https://blog.held.codes/how-to-use-mullvad-vpn-on-synology-nas-ed7a2ceb9595&#34; target=&#34;_blank&#34; &gt;this guide&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;Even with killswitch enabled, I wanted to verify beyond the UI&amp;rsquo;s &amp;ldquo;Firewalled&amp;rdquo; status that my torrent traffic was actually routing through the VPN.&lt;/p&gt;
&lt;p&gt;To check this, SSH into your Synology NAS and run:&lt;/p&gt;</description>
      <content:encoded><![CDATA[<p>I run <code>qbittorrent</code> in a Docker container on my Synology NAS, with VPN configured at the Synology system level (Control Panel -&gt; Network -&gt; Network Interface) rather than the container level. For setup instructions, you can follow <a href="https://blog.held.codes/how-to-use-mullvad-vpn-on-synology-nas-ed7a2ceb9595" target="_blank" >this guide</a>.</p>
<p>Even with killswitch enabled, I wanted to verify beyond the UI&rsquo;s &ldquo;Firewalled&rdquo; status that my torrent traffic was actually routing through the VPN.</p>
<p>To check this, SSH into your Synology NAS and run:</p>
<pre tabindex="0"><code>sudo docker exec qbittorrent curl ifconfig.me
</code></pre><p>This command retrieves the container&rsquo;s public IP address. If it matches your VPN IP address rather than your real IP, you&rsquo;ve confirmed that the container&rsquo;s traffic is properly routing through the VPN.</p>
]]></content:encoded>
    </item>
    <item>
      <title>My Synology Setup</title>
      <link>https://kunat.dev/notes/my-synology-setup/</link>
      <pubDate>Wed, 09 Oct 2024 18:13:32 +0200</pubDate>
      <guid>https://kunat.dev/notes/my-synology-setup/</guid>
      <description>&lt;p&gt;I&amp;rsquo;ve been using Synology for the past year, and during this time it&amp;rsquo;s become the center of my home computing. I&amp;rsquo;m still discovering new use cases, so I&amp;rsquo;ll keep this post updated. Today, I want to share how I ended up with this setup - the how and why. Here are the main things I use my NAS for:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Media Server&lt;/strong&gt; - My Synology stores and serves my entire collection of movies, TV shows, and music. Using Plex, I can stream this content to any device, creating a personalized Netflix-like experience.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Usenet / Torrent Client&lt;/strong&gt; - I have the standard *ARR stack setup for managing my media library. I can’t recommend it enough. This suite of apps is a crown jewel of the entire setup.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;HomeBridge&lt;/strong&gt; - HomeBridge allows me to integrate non-HomeKit devices into my Apple HomeKit setup.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Time Machine Backup Destination&lt;/strong&gt; - My Synology serves as a Time Machine backup destination for all my Macs. This is a major improvement over manually connecting external HDDs.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;DNS Server&lt;/strong&gt; - I run AdGuard Home on my Synology as a DNS server. Similar to Pi-hole, it blocks ads across all devices on my network. You can read more about it &lt;a href=&#34;https://kunat.dev/notes/adguard-home-synology-tailscale/#heading-id&#34; &gt;here&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Personal Cloud Storage&lt;/strong&gt; - While I use iCloud for critical documents, my Synology acts as a personal cloud for miscellaneous files. It&amp;rsquo;s a great way to keep less important documents accessible without cluttering my primary cloud storage.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;In the following sections, I&amp;rsquo;ll dive deeper into this setup, providing a high-level overview and sharing useful resources that have helped me create this system.&lt;/p&gt;</description>
      <content:encoded><![CDATA[<p>I&rsquo;ve been using Synology for the past year, and during this time it&rsquo;s become the center of my home computing. I&rsquo;m still discovering new use cases, so I&rsquo;ll keep this post updated. Today, I want to share how I ended up with this setup - the how and why. Here are the main things I use my NAS for:</p>
<ul>
<li><strong>Media Server</strong> - My Synology stores and serves my entire collection of movies, TV shows, and music. Using Plex, I can stream this content to any device, creating a personalized Netflix-like experience.</li>
<li><strong>Usenet / Torrent Client</strong> - I have the standard *ARR stack setup for managing my media library. I can’t recommend it enough. This suite of apps is a crown jewel of the entire setup.</li>
<li><strong>HomeBridge</strong> - HomeBridge allows me to integrate non-HomeKit devices into my Apple HomeKit setup.</li>
<li><strong>Time Machine Backup Destination</strong> - My Synology serves as a Time Machine backup destination for all my Macs. This is a major improvement over manually connecting external HDDs.</li>
<li><strong>DNS Server</strong> - I run AdGuard Home on my Synology as a DNS server. Similar to Pi-hole, it blocks ads across all devices on my network. You can read more about it <a href="/notes/adguard-home-synology-tailscale/#heading-id" >here</a>.</li>
<li><strong>Personal Cloud Storage</strong> - While I use iCloud for critical documents, my Synology acts as a personal cloud for miscellaneous files. It&rsquo;s a great way to keep less important documents accessible without cluttering my primary cloud storage.</li>
</ul>
<p>In the following sections, I&rsquo;ll dive deeper into this setup, providing a high-level overview and sharing useful resources that have helped me create this system.</p>
<h2 id="the-initial-setup">The Initial Setup</h2>
<h3 id="storage-pools--shares--volumes">Storage Pools / Shares / Volumes</h3>
<p>The first question I asked myself when setting up the system was &ldquo;What volume structure should I use?&rdquo; Initially, I aimed for complete separation between documents, media, and backups. This approach would have resulted in a single storage pool with three separate volumes.</p>
<p>However, I learned that creating separate volumes doesn&rsquo;t offer significant advantages in this case (if any). It doesn&rsquo;t provide any real security benefits. Instead, having separate shared folders with properly set up permissions is more than sufficient. So, unless you have a compelling reason to do otherwise, you should default to a single storage pool with a single volume.</p>
<blockquote>
<p>95% of users should use a single SHR pool, with a single BTRFS volume. If you need something else, you&rsquo;re an advanced user with an unusual use case (or just a big nerd who likes to tinker, even when it&rsquo;s slightly harmful).</p>
<p>Some people make multiple pools for different RAID levels for different uses, or making a separate pool with surveillance-class drives for their cameras. IMO, it&rsquo;s not worth that level of control, but it&rsquo;s available. You must make separate pools for HDDs and SSDs.</p>
<p>There&rsquo;s even less reason to use multiple volumes on the same pool. All of the backup, permissions, quota, etc. management is done at the shared folder level, so there just isn&rsquo;t much benefit to multiple volumes, unless you&rsquo;re hitting the 108TB limit and are forced to.</p>
<p><a href="https://www.reddit.com/r/synology/comments/11c1hu4/practical_reasons_for_having_multiple_storage/" target="_blank" >From the synology community on Reddit</a></p></blockquote>
<h3 id="access-control">Access Control</h3>
<p>All of my shared folders use the default permissions, except for <code>docker</code>. The permissions for this directory are described in detail <a href="https://trash-guides.info/Hardlinks/How-to-setup-for/Synology/" target="_blank" >here</a>. The gist of this setup is that the &ldquo;docker&rdquo; user has full access to the &ldquo;docker&rdquo; shared folder and nothing else. All other users can access anything except the docker folder.</p>
<p>I have three accounts in total:</p>
<ul>
<li>An admin account that I only use when I need to make changes to Synology.</li>
<li>My personal account, which I use day-to-day. All of my client devices use it to access documents, media, and backups.</li>
<li>The docker account I mentioned earlier.</li>
</ul>
<h3 id="setting-up-time-machine">Setting up Time Machine</h3>
<p>Use <a href="https://www.youtube.com/watch?v=yQFuJJqqWbc" target="_blank" >this video</a> and you should be good to go.</p>
<h3 id="docker">Docker</h3>
<p>I started my Docker setup with <a href="https://trash-guides.info/Hardlinks/How-to-setup-for/Synology/" target="_blank" >this guide</a> as part of setting up the *ARR stack. It goes into detail on how to set it up from the ground up. Once you&rsquo;re done, it should be fairly easy to build on top of it.</p>
<blockquote>
<p>I still haven&rsquo;t gotten around to setting up automation for updating the containers, so I can&rsquo;t speak on pullio, the solution suggested in the guide above.</p></blockquote>
<h2 id="remote-access">Remote Access</h2>
<p>With the initial setup done, it&rsquo;s time to decide how to access your Synology outside of your home network. In my setup, I wanted to balance security and convenience. That&rsquo;s why I decided against Synology&rsquo;s Quick Connect and went with Tailscale. The free tier is more than enough for my needs, and this setup is beautifully simple to configure.</p>
<p>I wrote <a href="/notes/synology-plex-tailscale/#heading-id" >a separate article</a> on how to set up Plex to work with Tailscale. If you want to learn more about the available options for remote access, <a href="https://www.youtube.com/watch?v=o2ck1g3_k3o" target="_blank" >this video</a> covers the most popular choices.</p>
<h2 id="backup">Backup</h2>
<p>I backup my entire system using the built-in Hyper Backup solution. <a href="https://www.youtube.com/watch?v=W43G5DSZ-Ik" target="_blank" >Here&rsquo;s a tutorial</a> on how to set it up.</p>
<blockquote>
<p>I don&rsquo;t backup media to save space. In the worst-case scenario of two subsequent disk failures, I&rsquo;ll still have all of my most important documents intact. The media library should be fairly simple to rebuild.</p></blockquote>
<p>I have a monthly reminder to connect the external HDD and make a backup. You can configure it so that the external disk will be unmounted when the backup is finished. This makes the process easy and hassle-free. I connect the drive, initiate the backup, and detach it the next morning. You could keep it connected, but I prefer to keep my backup offsite. It&rsquo;s my final safety net when everything else fails.</p>
<h2 id="resources">Resources</h2>
<ul>
<li><a href="https://www.reddit.com/r/synology/comments/11c1hu4/practical_reasons_for_having_multiple_storage/" target="_blank" >Practical reasons for having multiple storage pools and volumes : r/synology</a></li>
<li><a href="/notes/adguard-home-synology-tailscale/#heading-id" >Setting up AdGuard Home on Synology NAS with Tailscale | kunat.dev</a></li>
<li><a href="https://trash-guides.info/Hardlinks/How-to-setup-for/Synology/" target="_blank" >Synology - TRaSH Guides</a></li>
<li><a href="https://www.youtube.com/watch?v=yQFuJJqqWbc" target="_blank" >How to Backup MacOS to Synology NAS using Time Machine (easy) - YouTube</a></li>
<li><a href="/notes/synology-plex-tailscale/#heading-id" >Secure Remote Access to Your Synology Plex Server via Tailscale | kunat.dev</a></li>
<li><a href="https://www.youtube.com/watch?v=o2ck1g3_k3o" target="_blank" >The Complete Guide to Remotely Access Synology NAS - All 5 Options Explained - YouTube</a></li>
<li><a href="https://www.youtube.com/watch?v=W43G5DSZ-Ik" target="_blank" >Do this FIRST - How to Backup Synology NAS to USB Hard Drive (Hyperbackup) - YouTube</a></li>
</ul>
]]></content:encoded>
    </item>
    <item>
      <title>Single-Key App Launching with Karabiner and Hammerspoon</title>
      <link>https://kunat.dev/notes/hammerspoon-applauncher-setup/</link>
      <pubDate>Fri, 13 Sep 2024 16:56:19 +0200</pubDate>
      <guid>https://kunat.dev/notes/hammerspoon-applauncher-setup/</guid>
      <description>&lt;p&gt;For years, I wanted a way to launch my most-used apps with single-key shortcuts. While I knew tools like Hammerspoon could make this possible, I never found a key combination that felt right. I&amp;rsquo;d always end up disabling the setup, missing the functionality of keys like right control, caps lock, or escape.&lt;/p&gt;
&lt;p&gt;Everything changed when I discovered Karabiner Elements&amp;rsquo; ability to create distinct mappings for different key events, such as short presses and long presses. The setup I&amp;rsquo;m about to share lets you launch any mapped app by pressing caps lock plus a specific letter. The best part? You don&amp;rsquo;t lose the caps lock functionality—it still works as normal when pressed on its own.&lt;/p&gt;</description>
      <content:encoded><![CDATA[<p>For years, I wanted a way to launch my most-used apps with single-key shortcuts. While I knew tools like Hammerspoon could make this possible, I never found a key combination that felt right. I&rsquo;d always end up disabling the setup, missing the functionality of keys like right control, caps lock, or escape.</p>
<p>Everything changed when I discovered Karabiner Elements&rsquo; ability to create distinct mappings for different key events, such as short presses and long presses. The setup I&rsquo;m about to share lets you launch any mapped app by pressing caps lock plus a specific letter. The best part? You don&rsquo;t lose the caps lock functionality—it still works as normal when pressed on its own.</p>
<h2 id="prerequisites">Prerequisites</h2>
<p>This setup uses two tools: Karabiner Elements for setting up a hyper key binding and Hammerspoon for setting up the key mapping for specific apps. There&rsquo;s a multitude of solutions to this problem, but I&rsquo;ve found this one to work for me. It&rsquo;s fairly simple and easy to set up in minutes.</p>
<ol>
<li>Install <a href="https://karabiner-elements.pqrs.org/" target="_blank" >Karabiner Elements</a></li>
<li>Install <a href="https://www.hammerspoon.org/" target="_blank" >Hammerspoon</a></li>
</ol>
<h2 id="setting-up-karabiner-elements">Setting up Karabiner Elements</h2>
<p>Navigate to the Complex Modifications tab and install this <a href="https://ke-complex-modifications.pqrs.org/#caps_lock_to_hyper_or_esc_if_alone" target="_blank" >rule</a>. This rule maps the caps lock key to hyper or escape when pressed. You can modify it to your liking.</p>
<p><img alt="description" loading="lazy" src="/notes/images/hammerspoon-applauncher-setup/karabiner-elements-complex-modifications.png"></p>
<h2 id="setting-up-hammerspoon">Setting up Hammerspoon</h2>
<p>Paste the below into your Hammerspoon config and reload it.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-lua" data-lang="lua"><span style="display:flex;"><span>hs.loadSpoon(<span style="color:#e6db74">&#34;AppLauncher&#34;</span>)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">local</span> hyper <span style="color:#f92672">=</span> {<span style="color:#e6db74">&#34;control&#34;</span>, <span style="color:#e6db74">&#34;option&#34;</span>, <span style="color:#e6db74">&#34;cmd&#34;</span>, <span style="color:#e6db74">&#34;shift&#34;</span>}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>spoon.AppLauncher.modifiers <span style="color:#f92672">=</span> hyper
</span></span><span style="display:flex;"><span>spoon.AppLauncher:bindHotkeys(
</span></span><span style="display:flex;"><span>   {
</span></span><span style="display:flex;"><span>    c <span style="color:#f92672">=</span> <span style="color:#e6db74">&#34;Terminal&#34;</span>,
</span></span><span style="display:flex;"><span>    x <span style="color:#f92672">=</span> <span style="color:#e6db74">&#34;Xcode&#34;</span>,
</span></span><span style="display:flex;"><span>    f <span style="color:#f92672">=</span> <span style="color:#e6db74">&#34;Firefox&#34;</span>,
</span></span><span style="display:flex;"><span>    s <span style="color:#f92672">=</span> <span style="color:#e6db74">&#34;Slack&#34;</span>,
</span></span><span style="display:flex;"><span>    m <span style="color:#f92672">=</span> <span style="color:#e6db74">&#34;Music&#34;</span>,
</span></span><span style="display:flex;"><span>    v <span style="color:#f92672">=</span> <span style="color:#e6db74">&#34;Visual Studio Code&#34;</span>,
</span></span><span style="display:flex;"><span>    t <span style="color:#f92672">=</span> <span style="color:#e6db74">&#34;Tower&#34;</span>,
</span></span><span style="display:flex;"><span>    p <span style="color:#f92672">=</span> <span style="color:#e6db74">&#34;Proxyman&#34;</span>,
</span></span><span style="display:flex;"><span>    o <span style="color:#f92672">=</span> <span style="color:#e6db74">&#34;Obsidian&#34;</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">//</span> ...
</span></span><span style="display:flex;"><span>  }
</span></span><span style="display:flex;"><span>)
</span></span></code></pre></div><p>This config uses the <a href="https://www.hammerspoon.org/Spoons/AppLauncher.html" target="_blank" >AppLauncher</a> spoon to set up hotkeys for specific apps. You can adjust them to your liking.</p>
<h2 id="notes">Notes</h2>
<h3 id="single-tool-for-the-job">Single Tool for the Job</h3>
<p>I know it is technically possible to achieve the same result using Hammerspoon alone, but it didn&rsquo;t seem like the right tool for the job to me. If that&rsquo;s not an issue for you, you might want to use this as your starting point:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-lua" data-lang="lua"><span style="display:flex;"><span><span style="color:#66d9ef">local</span> hyper <span style="color:#f92672">=</span> {<span style="color:#e6db74">&#34;ctrl&#34;</span>, <span style="color:#e6db74">&#34;alt&#34;</span>, <span style="color:#e6db74">&#34;cmd&#34;</span>, <span style="color:#e6db74">&#34;shift&#34;</span>}
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">local</span> sendEscape <span style="color:#f92672">=</span> <span style="color:#66d9ef">false</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">local</span> lastMods <span style="color:#f92672">=</span> {}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">-- Function to check if only caps lock was pressed</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">local</span> <span style="color:#66d9ef">function</span> <span style="color:#a6e22e">onlyCapsWasPressed</span>()
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">for</span> k, v <span style="color:#66d9ef">in</span> pairs(lastMods) <span style="color:#66d9ef">do</span>
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">if</span> v <span style="color:#66d9ef">then</span> <span style="color:#66d9ef">return</span> <span style="color:#66d9ef">false</span> <span style="color:#66d9ef">end</span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">end</span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> <span style="color:#66d9ef">true</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">end</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">-- Create and start the eventtap</span>
</span></span><span style="display:flex;"><span>eventtap <span style="color:#f92672">=</span> hs.eventtap.new({hs.eventtap.event.types.flagsChanged, hs.eventtap.event.types.keyDown, hs.eventtap.event.types.keyUp}, <span style="color:#66d9ef">function</span>(event)
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">local</span> newMods <span style="color:#f92672">=</span> event:getFlags()
</span></span><span style="display:flex;"><span>    
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> event:getKeyCode() <span style="color:#f92672">==</span> <span style="color:#ae81ff">57</span> <span style="color:#66d9ef">then</span> <span style="color:#75715e">-- Caps Lock key</span>
</span></span><span style="display:flex;"><span>        <span style="color:#75715e">-- Check if fn is pressed</span>
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">if</span> newMods.fn <span style="color:#66d9ef">then</span>
</span></span><span style="display:flex;"><span>            <span style="color:#75715e">-- Allow normal caps lock behavior when fn is pressed</span>
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">return</span> <span style="color:#66d9ef">false</span>
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">end</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>        <span style="color:#75715e">-- On key down</span>
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">if</span> <span style="color:#f92672">not</span> lastMods.capslock <span style="color:#f92672">and</span> newMods.capslock <span style="color:#66d9ef">then</span>
</span></span><span style="display:flex;"><span>            sendEscape <span style="color:#f92672">=</span> onlyCapsWasPressed()
</span></span><span style="display:flex;"><span>            <span style="color:#75715e">-- Set Hyper key</span>
</span></span><span style="display:flex;"><span>            hs.eventtap.keyStroke(hyper, <span style="color:#e6db74">&#34;&#34;</span>)
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">return</span> <span style="color:#66d9ef">true</span>
</span></span><span style="display:flex;"><span>        <span style="color:#75715e">-- On key up</span>
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">elseif</span> lastMods.capslock <span style="color:#f92672">and</span> <span style="color:#f92672">not</span> newMods.capslock <span style="color:#66d9ef">then</span>
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">if</span> sendEscape <span style="color:#66d9ef">then</span>
</span></span><span style="display:flex;"><span>                hs.eventtap.keyStroke({}, <span style="color:#e6db74">&#34;escape&#34;</span>)
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">end</span>
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">return</span> <span style="color:#66d9ef">true</span>
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">end</span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">end</span>
</span></span><span style="display:flex;"><span>    lastMods <span style="color:#f92672">=</span> newMods
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> <span style="color:#66d9ef">false</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">end</span>)
</span></span><span style="display:flex;"><span>eventtap:start()
</span></span></code></pre></div><h3 id="running-out-of-keys">Running out of Keys</h3>
<p>Ideally, I&rsquo;d want this setup to support two-letter combinations, but I couldn&rsquo;t find a straightforward solution. One idea would be to completely ditch the AppLauncher and use something like the following:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-lua" data-lang="lua"><span style="display:flex;"><span><span style="color:#66d9ef">local</span> hyper <span style="color:#f92672">=</span> {<span style="color:#e6db74">&#34;cmd&#34;</span>, <span style="color:#e6db74">&#34;alt&#34;</span>, <span style="color:#e6db74">&#34;ctrl&#34;</span>, <span style="color:#e6db74">&#34;shift&#34;</span>}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">local</span> appShortcuts <span style="color:#f92672">=</span> {
</span></span><span style="display:flex;"><span>    te <span style="color:#f92672">=</span> <span style="color:#e6db74">&#34;Terminal&#34;</span>,
</span></span><span style="display:flex;"><span>    to <span style="color:#f92672">=</span> <span style="color:#e6db74">&#34;Tower&#34;</span>
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">local</span> lastKeyPress <span style="color:#f92672">=</span> <span style="color:#66d9ef">nil</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">local</span> lastKeyPressTime <span style="color:#f92672">=</span> <span style="color:#ae81ff">0</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>hs.hotkey.bind(hyper, <span style="color:#e6db74">&#34;t&#34;</span>, <span style="color:#66d9ef">function</span>()
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">local</span> now <span style="color:#f92672">=</span> hs.timer.secondsSinceEpoch()
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> lastKeyPress <span style="color:#f92672">and</span> (now <span style="color:#f92672">-</span> lastKeyPressTime) <span style="color:#f92672">&lt;</span> <span style="color:#ae81ff">0.5</span> <span style="color:#66d9ef">then</span>
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">local</span> combo <span style="color:#f92672">=</span> <span style="color:#e6db74">&#34;t&#34;</span> <span style="color:#f92672">..</span> lastKeyPress
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">if</span> appShortcuts[combo] <span style="color:#66d9ef">then</span>
</span></span><span style="display:flex;"><span>            hs.application.launchOrFocus(appShortcuts[combo])
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">end</span>
</span></span><span style="display:flex;"><span>        lastKeyPress <span style="color:#f92672">=</span> <span style="color:#66d9ef">nil</span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">else</span>
</span></span><span style="display:flex;"><span>        lastKeyPress <span style="color:#f92672">=</span> <span style="color:#e6db74">&#34;t&#34;</span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">end</span>
</span></span><span style="display:flex;"><span>    lastKeyPressTime <span style="color:#f92672">=</span> now
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">end</span>)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">for</span> k, _ <span style="color:#66d9ef">in</span> pairs(appShortcuts) <span style="color:#66d9ef">do</span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">local</span> secondKey <span style="color:#f92672">=</span> string.sub(k, <span style="color:#ae81ff">2</span>, <span style="color:#ae81ff">2</span>)
</span></span><span style="display:flex;"><span>    hs.hotkey.bind(hyper, secondKey, <span style="color:#66d9ef">function</span>()
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">local</span> now <span style="color:#f92672">=</span> hs.timer.secondsSinceEpoch()
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">if</span> lastKeyPress <span style="color:#f92672">and</span> (now <span style="color:#f92672">-</span> lastKeyPressTime) <span style="color:#f92672">&lt;</span> <span style="color:#ae81ff">0.5</span> <span style="color:#66d9ef">then</span>
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">local</span> combo <span style="color:#f92672">=</span> lastKeyPress <span style="color:#f92672">..</span> secondKey
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">if</span> appShortcuts[combo] <span style="color:#66d9ef">then</span>
</span></span><span style="display:flex;"><span>                hs.application.launchOrFocus(appShortcuts[combo])
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">end</span>
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">end</span>
</span></span><span style="display:flex;"><span>        lastKeyPress <span style="color:#f92672">=</span> <span style="color:#66d9ef">nil</span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">end</span>)
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">end</span>
</span></span></code></pre></div>]]></content:encoded>
    </item>
    <item>
      <title>Setting up AdGuard Home on Synology NAS with Tailscale</title>
      <link>https://kunat.dev/notes/adguard-home-synology-tailscale/</link>
      <pubDate>Thu, 08 Aug 2024 22:06:39 +0200</pubDate>
      <guid>https://kunat.dev/notes/adguard-home-synology-tailscale/</guid>
      <description>&lt;h1 id=&#34;setting-up-adguard-home-as-a-docker-container-on-synology-nas&#34;&gt;Setting up AdGuard Home as a Docker Container on Synology NAS&lt;/h1&gt;
&lt;p&gt;In this guide, I&amp;rsquo;ll walk you through the steps to set up AdGuard Home (AGH) as a Docker container on a Synology NAS. This setup will allow you to use AGH both inside and outside of your home network.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;a href=&#34;https://github.com/AdguardTeam/AdGuardSDNSFilter&#34; target=&#34;_blank&#34; &gt;AdGuard Home&lt;/a&gt; is a network-wide DNS server that blocks ads and trackers for all devices on your network. It filters unwanted content before it reaches your devices, eliminating the need for individual ad blockers. With customizable rules and open-source flexibility, it offers comprehensive protection and can be installed on various platforms, including Raspberry Pi.&lt;/p&gt;</description>
      <content:encoded><![CDATA[<h1 id="setting-up-adguard-home-as-a-docker-container-on-synology-nas">Setting up AdGuard Home as a Docker Container on Synology NAS</h1>
<p>In this guide, I&rsquo;ll walk you through the steps to set up AdGuard Home (AGH) as a Docker container on a Synology NAS. This setup will allow you to use AGH both inside and outside of your home network.</p>
<blockquote>
<p><a href="https://github.com/AdguardTeam/AdGuardSDNSFilter" target="_blank" >AdGuard Home</a> is a network-wide DNS server that blocks ads and trackers for all devices on your network. It filters unwanted content before it reaches your devices, eliminating the need for individual ad blockers. With customizable rules and open-source flexibility, it offers comprehensive protection and can be installed on various platforms, including Raspberry Pi.</p></blockquote>
<h2 id="prerequisites">Prerequisites</h2>
<ul>
<li>Basic Docker setup running on your Synology NAS (users, permissions, folders)
<ul>
<li>I highly recommend following the <a href="https://trash-guides.info/Hardlinks/How-to-setup-for/Synology/" target="_blank" >Synology | TRaSH Guides</a></li>
</ul>
</li>
</ul>
<p>The following steps assume you have a setup similar (ideally identical) to the one from the article linked above. The most important aspects are user permissions and folder structure.</p>
<h2 id="configuration">Configuration</h2>
<h3 id="env">.env:</h3>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-yml" data-lang="yml"><span style="display:flex;"><span><span style="color:#75715e">## Edit/update your settings that will be used for your docker-compose</span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">## This will only work if you follow exactly the path structure in the Guide!</span>
</span></span><span style="display:flex;"><span><span style="color:#ae81ff">COMPOSE_PROJECT_NAME=trash-guides</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">## Global Settings</span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># Change &#34;/volume1/docker/appdata&#34; to your config path</span>
</span></span><span style="display:flex;"><span><span style="color:#ae81ff">DOCKERCONFDIR=/volume1/docker/appdata</span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># Change &#34;/volume1/data&#34; to your library + torrent/usenet downloads path</span>
</span></span><span style="display:flex;"><span><span style="color:#ae81ff">DOCKERSTORAGEDIR=/volume1/data</span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># Find your PUID/PGID through SSH, run in terminal: id $user</span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># Change $user to the user you created if needed</span>
</span></span><span style="display:flex;"><span><span style="color:#ae81ff">PUID=XXXX</span>
</span></span><span style="display:flex;"><span><span style="color:#ae81ff">PGID=YYY</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># Other app-specific settings and variables</span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># ...</span>
</span></span></code></pre></div><p>This setup doesn&rsquo;t use any environment variables specific to AGH. The key thing is to set PUID and PGID correctly.</p>
<h3 id="docker-composeyml">docker-compose.yml:</h3>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-yml" data-lang="yml"><span style="display:flex;"><span><span style="color:#f92672">version</span>: <span style="color:#e6db74">&#34;3.2&#34;</span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">services</span>:
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">adguardhome</span>:
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">image</span>: <span style="color:#ae81ff">adguard/adguardhome:latest</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">container_name</span>: <span style="color:#ae81ff">adguardhome</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">network_mode</span>: <span style="color:#ae81ff">host</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">volumes</span>:
</span></span><span style="display:flex;"><span>      - <span style="color:#ae81ff">${DOCKERCONFDIR}/adguardhome/work:/opt/adguardhome/work</span>
</span></span><span style="display:flex;"><span>      - <span style="color:#ae81ff">${DOCKERCONFDIR}/adguardhome/conf:/opt/adguardhome/conf</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">restart</span>: <span style="color:#ae81ff">unless-stopped</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">environment</span>:
</span></span><span style="display:flex;"><span>      - <span style="color:#ae81ff">PUID=${PUID}</span>
</span></span><span style="display:flex;"><span>      - <span style="color:#ae81ff">PGID=${PGID}</span>
</span></span><span style="display:flex;"><span>      - <span style="color:#ae81ff">TZ=${TZ}</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># Other containers</span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># ...</span>
</span></span></code></pre></div><h3 id="folder-structure">Folder structure</h3>
<p><img alt="Folder structure" loading="lazy" src="/notes/images/adguard-home-synology-tailscale/folder-structure.png"></p>
<h2 id="initial-setup">Initial Setup</h2>
<p>After running the container for the first time, complete the initial AGH setup:</p>
<ol>
<li>When asked to choose the network interface for DNS requests, select &ldquo;All Interfaces&rdquo;.</li>
<li>Switch the Web Interface port to 3000 if it&rsquo;s not already set.</li>
<li>Set credentials for your admin account.</li>
</ol>
<p>If you encounter issues, refer to <a href="https://drfrankenstein.co.uk/adguard-home-in-container-manager-on-a-synology-nas/" target="_blank" >this guide</a>.</p>
<h2 id="local-network-setup">Local Network Setup</h2>
<p>To use AGH as your DNS server:</p>
<ol>
<li>Set it on your router (preferred method).</li>
<li>If router modification isn&rsquo;t possible, set it manually for each device on your home WiFi network. Here&rsquo;s a <a href="https://support.nordvpn.com/hc/en-us/articles/20398776567313-Change-your-DNS-servers-on-iOS" target="_blank" >guide for iOS</a>.</li>
</ol>
<p>Use your Synology&rsquo;s local network IP address. Update your DHCP settings to ensure your Synology&rsquo;s local IP address doesn&rsquo;t expire.</p>
<p>With this setup, you&rsquo;ll see the benefits of AGH whenever you&rsquo;re connected to your home network.</p>
<h2 id="remote-setup">Remote Setup</h2>
<p>To use AGH when not on your home network, there are several options:</p>
<ul>
<li>Dynamic DNS (DDNS) and port forwarding</li>
<li>Setting up a VPN server on your NAS</li>
<li>Using Cloudflare Tunnel</li>
<li>Tailscale (the method used in this guide)</li>
</ul>
<blockquote>
<p>Tailscale is a modern, user-friendly virtual private network (VPN) solution that leverages the WireGuard protocol to create secure, peer-to-peer connections between devices. It simplifies network configuration by eliminating the need for traditional VPN servers and complex firewall rules.</p></blockquote>
<p>To set up Tailscale:</p>
<ol>
<li>Set custom DNS in Tailscale admin panel (your Synology&rsquo;s tailnet IP address)</li>
<li>Enable &ldquo;override local DNS&rdquo;
<img alt="Tailscale DNS settings" loading="lazy" src="/notes/images/adguard-home-synology-tailscale/tailscale-admin-panel.png"></li>
<li>Set up Tailscale on all devices you plan to use outside your home network</li>
<li><a href="/notes/adguard-home-advanced-data-protection/" >Set up VPN On-Demand</a></li>
</ol>
<p>With these steps, your device will automatically connect to your Tailscale mesh network, which will use AGH as a DNS server.</p>
<h2 id="cleanup">Cleanup</h2>
<p>For Safari users: Consider disabling Advanced Tracking and Fingerprinting Protection, as it can override local DNS settings and interfere with AGH. More details on this issue can be found <a href="/notes/adguard-home-advanced-data-protection/" >here</a>.</p>
<p>Note: Client names may not resolve properly due to Docker&rsquo;s host network mode. If you find a solution to this issue, please share!
<img alt="Client resolution issue" loading="lazy" src="/notes/images/adguard-home-synology-tailscale/adguard-home-dashboard.png"></p>
<h2 id="resources">Resources</h2>
<ul>
<li><a href="https://nicolaslouge.com/post/install-adguard-home-raspberry-pi-enable-remote-acces-via-tailscale/" target="_blank" >Install Adguard Home on a Raspberry Pi 4 and enable remote access with Tailscale | Senior Project Manager in Toronto, Ontario | Nicolas Louge</a></li>
<li><a href="https://akashrajpurohit.com/blog/adguard-home-tailscale-erase-ads-on-the-go/?ref=reddit" target="_blank" >AdGuard Home + Tailscale = Erase Ads on the Go</a></li>
<li><a href="https://drfrankenstein.co.uk/adguard-home-in-container-manager-on-a-synology-nas/" target="_blank" >AdGuard Home in Container Manager on a Synology NAS</a></li>
</ul>
]]></content:encoded>
    </item>
    <item>
      <title>AdGuard Home Not Working in Safari: A Fix for Custom DNS Issues</title>
      <link>https://kunat.dev/notes/adguard-home-advanced-data-protection/</link>
      <pubDate>Wed, 24 Jul 2024 18:56:46 +0200</pubDate>
      <guid>https://kunat.dev/notes/adguard-home-advanced-data-protection/</guid>
      <description>&lt;p&gt;&lt;em&gt;tl;dr: If your custom DNS does not work in Safari, you might want to try disabling Advanced Tracking and Fingerprinting Protection.&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;A few weeks back, I set up AdGuard Home on my Synology NAS. I&amp;rsquo;ve been using it without any issues inside and outside of my home network thanks to Tailscale. There was one issue with this setup though: battery drainage on all of my mobile devices. I noticed that my battery usage went up by 25% on my phone.&lt;/p&gt;</description>
      <content:encoded><![CDATA[<p><em>tl;dr: If your custom DNS does not work in Safari, you might want to try disabling Advanced Tracking and Fingerprinting Protection.</em></p>
<p>A few weeks back, I set up AdGuard Home on my Synology NAS. I&rsquo;ve been using it without any issues inside and outside of my home network thanks to Tailscale. There was one issue with this setup though: battery drainage on all of my mobile devices. I noticed that my battery usage went up by 25% on my phone.</p>
<blockquote>
<p>From what I could find, the increased battery usage might have been caused by a few factors:</p>
<ul>
<li>Constant network connection: Tailscale maintains a persistent VPN connection</li>
<li>Encryption overhead: The VPN encrypts and decrypts all traffic, which requires additional processing power</li>
<li>Background processes: Tailscale might be doing some work in the background</li>
</ul></blockquote>
<p>To solve this issue, I decided to update my config so that I use VPN only when I&rsquo;m not on my home network. My ISP-provided router does not allow me to change the default DNS, so my only option was to manually set it for each device.</p>
<p>Tailscale&rsquo;s VPN On-Demand feature is just the tool for the job. It allows you to connect to your mesh network only when some specific rules are satisfied. For example, with the following configuration, my phone will connect to the VPN only when I&rsquo;m not connected to my home network.</p>
<p><img loading="lazy" src="/notes/images/adguard-home-advanced-data-protection-tailscale.png"></p>
<p>This setup worked beautifully. The battery issue was gone. Tailscale worked as advertised. I could still access my server from anywhere. The only issue was that AdGuard Home seemed to not work in private Safari tabs.</p>
<p>My first suspect was Private Relay. I toggled it on and off, but it didn&rsquo;t fix the issue. The second suspect was the &ldquo;Prevent cross-site tracking&rdquo; setting. Still nothing. Finally, I found <a href="https://discussions.apple.com/thread/255140280?sortBy=rank" target="_blank" >this</a> thread that pointed me in the right direction. Turns out, the issue was caused by the Advanced Tracking and Fingerprinting Protection setting located in Advanced settings. Turning it off fixes the issue instantly.</p>
<h3 id="ios">iOS</h3>
<p><img loading="lazy" src="/notes/images/adguard-home-advanced-data-protection-ios.png"></p>
<h3 id="macos">macOS</h3>
<p><img loading="lazy" src="/notes/images/adguard-home-advanced-data-protection-macos.png"></p>
<h2 id="resources">Resources</h2>
<ul>
<li><a href="https://discussions.apple.com/thread/255140280?sortBy=rank" target="_blank" >iOS 17 private browsing overrides DNS - Apple Community</a></li>
</ul>
]]></content:encoded>
    </item>
    <item>
      <title>Secure Remote Access to Your Synology Plex Server via Tailscale</title>
      <link>https://kunat.dev/notes/synology-plex-tailscale/</link>
      <pubDate>Wed, 03 Jul 2024 14:40:04 +0200</pubDate>
      <guid>https://kunat.dev/notes/synology-plex-tailscale/</guid>
      <description>&lt;p&gt;In this article, I’ll describe how to set up your Plex server so you can access it remotely through Tailscale, with Remote Access completely disabled in Plex server settings.&lt;/p&gt;
&lt;h2 id=&#34;prerequisites&#34;&gt;Prerequisites&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;Have a Plex server running as a Docker container using &lt;a href=&#34;https://trash-guides.info/Hardlinks/How-to-setup-for/Synology/&#34; target=&#34;_blank&#34; &gt;this guide&lt;/a&gt;. When you’re done, you should have a &lt;code&gt;docker-compose.yml&lt;/code&gt; and a &lt;code&gt;.env&lt;/code&gt; file with all your environment variables.&lt;/li&gt;
&lt;li&gt;Have Tailscale set up and running on your Synology. You can use &lt;a href=&#34;https://www.youtube.com/watch?v=qulWDpzdY1E&#34; target=&#34;_blank&#34; &gt;this guide&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote&gt;
&lt;p&gt;Make sure to disable the expiry for the IP address assigned to your Synology NAS in the Tailscale dashboard.&lt;/p&gt;</description>
      <content:encoded><![CDATA[<p>In this article, I’ll describe how to set up your Plex server so you can access it remotely through Tailscale, with Remote Access completely disabled in Plex server settings.</p>
<h2 id="prerequisites">Prerequisites</h2>
<ul>
<li>Have a Plex server running as a Docker container using <a href="https://trash-guides.info/Hardlinks/How-to-setup-for/Synology/" target="_blank" >this guide</a>. When you’re done, you should have a <code>docker-compose.yml</code> and a <code>.env</code> file with all your environment variables.</li>
<li>Have Tailscale set up and running on your Synology. You can use <a href="https://www.youtube.com/watch?v=qulWDpzdY1E" target="_blank" >this guide</a>.</li>
</ul>
<blockquote>
<p>Make sure to disable the expiry for the IP address assigned to your Synology NAS in the Tailscale dashboard.</p></blockquote>
<h2 id="steps">Steps</h2>
<ol>
<li>Set <code>PLEX_ADVERTISE_URL</code> (in the <code>.env</code> file) to your Synology IP address in the Tailscale dashboard and your local network IP address:</li>
</ol>
<pre tabindex="0"><code>## PLEX
# ... other keys
PLEX_ADVERTISE_URL=&#34;http://ds1512p:32400,http://192.168.0.186:32400&#34;
</code></pre><blockquote>
<p>Remember to include port numbers in both IP addresses!</p></blockquote>
<ol start="2">
<li>Restart the Plex container using the latest changes:</li>
</ol>
<pre tabindex="0"><code>sudo docker-compose up --force-recreate plex
</code></pre><ol start="3">
<li>Check if the changes have been applied successfully. “Custom server access URLs” should be set to <code>$PLEX_ADVERTISE_URL</code>.</li>
</ol>
<p><img alt="Plex Server dashboard" loading="lazy" src="/notes/images/synology-plex-tailscale.png"></p>
<h2 id="summary">Summary</h2>
<p>That’s it! With the above setup, you should be able to connect to your Plex server from your local network as well as from anywhere, as long as you’re connected to Tailscale.</p>
<h2 id="resources">Resources</h2>
<ul>
<li><a href="https://community.umbrel.com/t/how-to-run-plex-through-tailscale/14595" target="_blank" >How to run Plex through Tailscale</a></li>
</ul>
]]></content:encoded>
    </item>
    <item>
      <title>Xcode: Missing Package Product (local package)</title>
      <link>https://kunat.dev/notes/xcode-missing-package-product/</link>
      <pubDate>Mon, 24 Jun 2024 18:48:01 +0200</pubDate>
      <guid>https://kunat.dev/notes/xcode-missing-package-product/</guid>
      <description>&lt;p&gt;When adding a local Swift package to your Xcode project, you might encounter the following error:&lt;/p&gt;
&lt;pre tabindex=&#34;0&#34;&gt;&lt;code&gt;Missing package product `PACKAGE_NAME`
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;&lt;img loading=&#34;lazy&#34; src=&#34;https://kunat.dev/notes/images/xcode-missing-package-product.jpg&#34;&gt;&lt;/p&gt;
&lt;p&gt;The issue often occurs when the package you&amp;rsquo;re trying to add is already open in Xcode. To resolve:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Close the package in Xcode.&lt;/li&gt;
&lt;li&gt;Close your main project.&lt;/li&gt;
&lt;li&gt;Reopen your main project.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;I&amp;rsquo;m not sure what might be causing this issue or why reopening the project is required (a clean build won&amp;rsquo;t do).&lt;/p&gt;</description>
      <content:encoded><![CDATA[<p>When adding a local Swift package to your Xcode project, you might encounter the following error:</p>
<pre tabindex="0"><code>Missing package product `PACKAGE_NAME`
</code></pre><p><img loading="lazy" src="/notes/images/xcode-missing-package-product.jpg"></p>
<p>The issue often occurs when the package you&rsquo;re trying to add is already open in Xcode. To resolve:</p>
<ol>
<li>Close the package in Xcode.</li>
<li>Close your main project.</li>
<li>Reopen your main project.</li>
</ol>
<p>I&rsquo;m not sure what might be causing this issue or why reopening the project is required (a clean build won&rsquo;t do).</p>
<p>To learn more about local Swift packages read <a href="../swift-local-packages" >Working with Local Swift Packages</a>.</p>
]]></content:encoded>
    </item>
    <item>
      <title>Keeping Safari Windows Open: A Tab Group Workaround</title>
      <link>https://kunat.dev/notes/keep-safar-window-open/</link>
      <pubDate>Sat, 18 May 2024 18:59:10 +0200</pubDate>
      <guid>https://kunat.dev/notes/keep-safar-window-open/</guid>
      <description>&lt;p&gt;&lt;em&gt;tldr: In order to keep the current Safari window open when you close the last tab, use tab groups.&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;Last month, I once again switched my default web browser to Safari. It has its quirks, but I missed it in a weird way.&lt;/p&gt;
&lt;p&gt;I initially switched to Firefox because of its stellar &lt;a href=&#34;https://addons.mozilla.org/en-US/firefox/addon/vimium-ff/&#34; target=&#34;_blank&#34; &gt;support for vim keybindings&lt;/a&gt; and some other extensions I started to enjoy. Even with all those improvements, I began to miss some niceties that Safari has built-in.&lt;/p&gt;</description>
      <content:encoded><![CDATA[<p><em>tldr: In order to keep the current Safari window open when you close the last tab, use tab groups.</em></p>
<p>Last month, I once again switched my default web browser to Safari. It has its quirks, but I missed it in a weird way.</p>
<p>I initially switched to Firefox because of its stellar <a href="https://addons.mozilla.org/en-US/firefox/addon/vimium-ff/" target="_blank" >support for vim keybindings</a> and some other extensions I started to enjoy. Even with all those improvements, I began to miss some niceties that Safari has built-in.</p>
<blockquote>
<p>With my current setup, I still use Firefox for some websites. Some sites are just hard to use without uBlock Origin. None of Safari&rsquo;s content blockers match its performance.</p></blockquote>
<p>I learned to live with all of Safari&rsquo;s shortcomings, but there is one UX issue that keeps me up at night: <strong>The Safari window always gets closed when you close the last tab</strong> (<code>⌘ + w</code>). As of macOS 14, there seems to be no way to change this default behavior without introducing some other issues.</p>
<p>What irks me even more is that this behavior does not exist on iPadOS. On iPads, the <code>⌘ + w</code> shortcut closes the open window only if the current window has a single empty tab. In all other cases, the Safari window will remain open.</p>
<p>What I&rsquo;ve found recently is that this behavior is different for tab groups. When you close the last tab in a Safari tab group, the window will remain open. Since then, I created my &ldquo;main&rdquo; tab group:</p>
<p><img alt="Safari tab groups with the &ldquo;main&rdquo; tab group" loading="lazy" src="/notes/images/keep-safar-window-open.png"></p>
<blockquote>
<p>This solution has the nice benefit of keeping all your current tabs in sync across all your devices. I know that by default you can still access all your open tabs from any device, but it was always an extra hassle for me to go through the &ldquo;From &lt;device_name&gt;&rdquo; section.</p></blockquote>
]]></content:encoded>
    </item>
    <item>
      <title>Open Any Xcode Project Type with a Shell Script</title>
      <link>https://kunat.dev/notes/shell-script-for-opening-xcode-projects/</link>
      <pubDate>Mon, 29 Apr 2024 19:19:18 +0200</pubDate>
      <guid>https://kunat.dev/notes/shell-script-for-opening-xcode-projects/</guid>
      <description>&lt;p&gt;In my current role, we have around 30 projects distributed across roughly 20 different repositories. Most of them are Swift packages for various features used in our apps.&lt;/p&gt;
&lt;p&gt;This setup requires me to frequently switch between multiple projects. Just a few days ago, I was working on the login flow. This involved modifying two Swift packages and one Xcode project.&lt;/p&gt;
&lt;p&gt;Remembering whether a given project is a Swift package, a workspace (&lt;code&gt;.xcworkspace&lt;/code&gt;), or a standard project (&lt;code&gt;.xcodeproj&lt;/code&gt;) is exhausting. This adds unnecessary complexity and slows down the workflow. It would be better if we had a single command to open all of them at once. Here&amp;rsquo;s a script that does just that:&lt;/p&gt;</description>
      <content:encoded><![CDATA[<p>In my current role, we have around 30 projects distributed across roughly 20 different repositories. Most of them are Swift packages for various features used in our apps.</p>
<p>This setup requires me to frequently switch between multiple projects. Just a few days ago, I was working on the login flow. This involved modifying two Swift packages and one Xcode project.</p>
<p>Remembering whether a given project is a Swift package, a workspace (<code>.xcworkspace</code>), or a standard project (<code>.xcodeproj</code>) is exhausting. This adds unnecessary complexity and slows down the workflow. It would be better if we had a single command to open all of them at once. Here&rsquo;s a script that does just that:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-shell" data-lang="shell"><span style="display:flex;"><span><span style="color:#75715e"># The osp (Open Swift project) function first attempts to open a Package.swift file in the current directory; if it doesn&#39;t exist, it then searches for and opens either a .xcworkspace or a .xcodeproj directory, or outputs an error message if none are found.</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>osp<span style="color:#f92672">()</span> <span style="color:#f92672">{</span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># Check for Package.swift in the current directory</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">if</span> <span style="color:#f92672">[[</span> -f <span style="color:#e6db74">&#34;Package.swift&#34;</span> <span style="color:#f92672">]]</span>; <span style="color:#66d9ef">then</span>
</span></span><span style="display:flex;"><span>open <span style="color:#e6db74">&#34;Package.swift&#34;</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">return</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">fi</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># Look for .xcworkspace directories in the current directory</span>
</span></span><span style="display:flex;"><span>local workspace<span style="color:#f92672">=</span><span style="color:#66d9ef">$(</span>find . -maxdepth <span style="color:#ae81ff">1</span> -name <span style="color:#e6db74">&#34;*.xcworkspace&#34;</span> -type d | head -n 1<span style="color:#66d9ef">)</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">if</span> <span style="color:#f92672">[[</span> -n <span style="color:#e6db74">&#34;</span>$workspace<span style="color:#e6db74">&#34;</span> <span style="color:#f92672">]]</span>; <span style="color:#66d9ef">then</span>
</span></span><span style="display:flex;"><span>open <span style="color:#e6db74">&#34;</span>$workspace<span style="color:#e6db74">&#34;</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">return</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">fi</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># Look for .xcodeproj directories if no .xcworkspace directories were found</span>
</span></span><span style="display:flex;"><span>local project<span style="color:#f92672">=</span><span style="color:#66d9ef">$(</span>find . -maxdepth <span style="color:#ae81ff">1</span> -name <span style="color:#e6db74">&#34;*.xcodeproj&#34;</span> -type d | head -n 1<span style="color:#66d9ef">)</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">if</span> <span style="color:#f92672">[[</span> -n <span style="color:#e6db74">&#34;</span>$project<span style="color:#e6db74">&#34;</span> <span style="color:#f92672">]]</span>; <span style="color:#66d9ef">then</span>
</span></span><span style="display:flex;"><span>open <span style="color:#e6db74">&#34;</span>$project<span style="color:#e6db74">&#34;</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">return</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">fi</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># Output error message if neither file type was found</span>
</span></span><span style="display:flex;"><span>echo <span style="color:#e6db74">&#34;No project or workspace files found&#34;</span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">}</span>
</span></span></code></pre></div><p>To use it, simply append it to your <code>.zshrc</code> file and run <code>source ~/.zshrc</code> or restart the shell.</p>
]]></content:encoded>
    </item>
    <item>
      <title>Quitting Reels: Unveiling Instagram Deep Links</title>
      <link>https://kunat.dev/notes/instagram-dms/</link>
      <pubDate>Sun, 31 Mar 2024 21:41:11 +0200</pubDate>
      <guid>https://kunat.dev/notes/instagram-dms/</guid>
      <description>&lt;p&gt;I’ve noticed that I’ve been wasting a lot of time watching Instagram Reels lately. This would not be a problem if I actually enjoyed the time spent doing it. However, this was not the case. I&amp;rsquo;m not only angry at myself after the fact, but I also usually feel miserable while mindlessly scrolling. It’s just that sometimes I lack the discipline to avoid it altogether.&lt;/p&gt;
&lt;p&gt;Ideally, I’d completely delete it from my phone, but I still need it for texting. It’s borderline impossible to convince some of my friends to switch to a different platform. Also, some of Instagram&amp;rsquo;s messaging features are just nice to have.&lt;/p&gt;</description>
      <content:encoded><![CDATA[<p>I’ve noticed that I’ve been wasting a lot of time watching Instagram Reels lately. This would not be a problem if I actually enjoyed the time spent doing it. However, this was not the case. I&rsquo;m not only angry at myself after the fact, but I also usually feel miserable while mindlessly scrolling. It’s just that sometimes I lack the discipline to avoid it altogether.</p>
<p>Ideally, I’d completely delete it from my phone, but I still need it for texting. It’s borderline impossible to convince some of my friends to switch to a different platform. Also, some of Instagram&rsquo;s messaging features are just nice to have.</p>
<p>Learning from experience, the second-best option in this type of situation is to add friction to the process. Having Reels one click away each time I open the app is way too tempting. Since I’m only ever using it to text people, it would be nice to have it open directly on the Messages screen.</p>
<p>How do I get a complete list of deep links the Instagram iOS app supports?</p>
<p>My initial plan was to jailbreak an iPhone, export the Instagram app, and then use a disassembler like Hopper to inspect the binary. On paper, the binary should contain all the supported deep links. It was only a matter of finding them. And learning how to jailbreak devices. And learning how to work with a disassembler. Seems like a lot of work for a simple shortcut, doesn’t it?</p>
<p>“Why don’t you use <code>/.well-known/apple-app-site-association</code>?” a friend of mine suggested after I shared what I was working on. Even though I was well aware of it, this didn’t come to my mind when looking for solutions.</p>
<blockquote>
<p>The <code>/.well-known/apple-app-site-association</code> is a file used in iOS app development to configure Universal Links. Universal Links allow iOS apps to link directly to content within the app using standard HTTPS URLs, providing a seamless user experience by opening the app directly instead of a web page when the app is installed.</p></blockquote>
<p>This was the perfect tool for the job. After inspecting the file, finding the right deep link was trivial. Long story short, here’s the final shortcut:</p>
<p><a href="https://www.icloud.com/shortcuts/1b15c8091e0744878c3535c1aae01457" target="_blank" >Instagram DMs | iCloud Shortcuts</a></p>
<p>Lesson learned: When something seems like over-engineering, it probably is.</p>
<p>I hope this helps some of you to reduce the number of times you start mindlessly scrolling because you couldn’t resist watching just a few reels before checking your messages!</p>
<h2 id="resources">Resources</h2>
<ul>
<li><a href="https://www.instagram.com/.well-known/apple-app-site-association" target="_blank" >https://www.instagram.com/.well-known/apple-app-site-association</a></li>
<li><a href="https://developer.apple.com/documentation/xcode/supporting-associated-domains" target="_blank" >Supporting associated domains | Apple Developer Documentation</a></li>
</ul>
]]></content:encoded>
    </item>
    <item>
      <title>Working with Local Swift Packages</title>
      <link>https://kunat.dev/notes/swift-local-packages/</link>
      <pubDate>Tue, 27 Feb 2024 21:49:35 +0100</pubDate>
      <guid>https://kunat.dev/notes/swift-local-packages/</guid>
      <description>&lt;p&gt;Imagine you&amp;rsquo;re working on Project A, which relies on Package B, a remote Swift package. To incorporate modifications in B and assess their impact on A, the standard approach requires committing and pushing the changes in B, updating B&amp;rsquo;s version in A, and then rebuilding A to observe the outcomes. This method is slow and cumbersome. There must be a better way!&lt;/p&gt;
&lt;pre tabindex=&#34;0&#34;&gt;&lt;code&gt;Project A
  |
  └──&amp;gt; Swift Package B
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;To speed up and improve the workflow, you can convert the remote dependency B to a local one. This method works whether Project A is a Swift package or an Xcode project (xcodeproj or xcworkspace).&lt;/p&gt;</description>
      <content:encoded><![CDATA[<p>Imagine you&rsquo;re working on Project A, which relies on Package B, a remote Swift package. To incorporate modifications in B and assess their impact on A, the standard approach requires committing and pushing the changes in B, updating B&rsquo;s version in A, and then rebuilding A to observe the outcomes. This method is slow and cumbersome. There must be a better way!</p>
<pre tabindex="0"><code>Project A
  |
  └──&gt; Swift Package B
</code></pre><p>To speed up and improve the workflow, you can convert the remote dependency B to a local one. This method works whether Project A is a Swift package or an Xcode project (xcodeproj or xcworkspace).</p>
<h2 id="xcode-project">Xcode Project</h2>
<p>In this case, Project A has an Xcode project file.</p>
<ol>
<li>Remove the remote dependency from your project.</li>
<li>Re-add it as a local package.</li>
</ol>
<p><img alt="hello there" loading="lazy" src="/notes/images/local_packages_1.png"></p>
<blockquote>
<p>This might seem excessive, as Xcode also supports just dragging and dropping the package into your project, but I’ve found this solution to be flaky. Explicitly removing it works every time, most of the time.</p></blockquote>
<h2 id="swift-package">Swift Package</h2>
<p>If Project A is a Swift package, you&rsquo;ll need to convert B&rsquo;s dependency declaration:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-swift" data-lang="swift"><span style="display:flex;"><span>dependencies: [
</span></span><span style="display:flex;"><span>    .package(url: <span style="color:#e6db74">&#34;https://github.com/TinyCorp/B&#34;</span>, exact: <span style="color:#e6db74">&#34;0.52.3&#34;</span>),
</span></span><span style="display:flex;"><span>    <span style="color:#75715e">// ...</span>
</span></span></code></pre></div><p>&hellip; into this:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-swift" data-lang="swift"><span style="display:flex;"><span>dependencies: [
</span></span><span style="display:flex;"><span>    .package(path: <span style="color:#e6db74">&#34;path/to/B&#34;</span>),
</span></span><span style="display:flex;"><span>    <span style="color:#75715e">// ...</span>
</span></span></code></pre></div><p>Example: given the following file structure, <code>path</code> is <code>../B</code>:</p>
<pre tabindex="0"><code>.
├── A
│   └── Package.swift (we&#39;re here)
└── B
    └── Package.swift
</code></pre><hr>
<p>With this setup, you’re now able to edit dependency B directly inside of A. I was genuinely surprised by how well Xcode handles this workflow.</p>
<h2 id="notes">Notes</h2>
<blockquote>
<p>Make sure the Swift package that you plan to add to your project is not opened in another Xcode window. In some cases, Xcode might fail with a cryptic error when trying to add the local package.</p></blockquote>
]]></content:encoded>
    </item>
    <item>
      <title>Creating Tag-Specific RSS Feeds in Hugo with PaperMod</title>
      <link>https://kunat.dev/notes/hugo-papermod-tag-specific-rss/</link>
      <pubDate>Tue, 30 Jan 2024 21:02:14 +0100</pubDate>
      <guid>https://kunat.dev/notes/hugo-papermod-tag-specific-rss/</guid>
      <description>&lt;p&gt;This website uses the &lt;a href=&#34;https://github.com/adityatelange/hugo-PaperMod&#34; target=&#34;_blank&#34; &gt;PaperMod&lt;/a&gt; theme for Hugo. The default setup offers a primary RSS feed for all posts at &lt;code&gt;yoursite.com/index.xml&lt;/code&gt;. However, I needed more granularity—specifically, RSS feeds for posts tagged with certain keywords.&lt;/p&gt;
&lt;p&gt;After some exploration and experimentation, I discovered that Hugo automatically generates RSS feeds for each tag. TIL! Here&amp;rsquo;s how you can access these tag-specific RSS feeds:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;iOS Tag Feed: &lt;code&gt;https://kunat.dev/tags/ios/index.xml&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Swift Tag Feed: &lt;code&gt;https://kunat.dev/tags/swift/index.xml&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Utils Tag Feed: &lt;code&gt;https://kunat.dev/tags/utils/index.xml&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;</description>
      <content:encoded><![CDATA[<p>This website uses the <a href="https://github.com/adityatelange/hugo-PaperMod" target="_blank" >PaperMod</a> theme for Hugo. The default setup offers a primary RSS feed for all posts at <code>yoursite.com/index.xml</code>. However, I needed more granularity—specifically, RSS feeds for posts tagged with certain keywords.</p>
<p>After some exploration and experimentation, I discovered that Hugo automatically generates RSS feeds for each tag. TIL! Here&rsquo;s how you can access these tag-specific RSS feeds:</p>
<ul>
<li>iOS Tag Feed: <code>https://kunat.dev/tags/ios/index.xml</code></li>
<li>Swift Tag Feed: <code>https://kunat.dev/tags/swift/index.xml</code></li>
<li>Utils Tag Feed: <code>https://kunat.dev/tags/utils/index.xml</code></li>
</ul>
]]></content:encoded>
    </item>
    <item>
      <title>Alfred Snippet Converter</title>
      <link>https://kunat.dev/notes/alfred-snippet-converter/</link>
      <pubDate>Wed, 27 Dec 2023 19:50:24 +0100</pubDate>
      <guid>https://kunat.dev/notes/alfred-snippet-converter/</guid>
      <description>&lt;p&gt;A few months ago, I wrote about &lt;a href=&#34;https://kunat.dev/notes/alfred-snippets-on-ios/&#34; &gt;Alfred Snippets on iOS&lt;/a&gt;. In that article, I described the manual process of converting Alfred text snippets into macOS text replacements.&lt;/p&gt;
&lt;p&gt;Since then, I&amp;rsquo;ve had time to refine this project and add some new features. You can download the final Command Line Interface (CLI) or the macOS app from this link.&lt;/p&gt;
&lt;p&gt;Next on my list is the complete automation of the process of synchronizing Alfred text snippets with macOS text replacements. I&amp;rsquo;m not sure if it&amp;rsquo;s even possible, but there&amp;rsquo;s only one way to find out. I&amp;rsquo;m keeping my fingers crossed that it will take me less time than it did before.&lt;/p&gt;</description>
      <content:encoded><![CDATA[<p>A few months ago, I wrote about <a href="/notes/alfred-snippets-on-ios/" >Alfred Snippets on iOS</a>. In that article, I described the manual process of converting Alfred text snippets into macOS text replacements.</p>
<p>Since then, I&rsquo;ve had time to refine this project and add some new features. You can download the final Command Line Interface (CLI) or the macOS app from this link.</p>
<p>Next on my list is the complete automation of the process of synchronizing Alfred text snippets with macOS text replacements. I&rsquo;m not sure if it&rsquo;s even possible, but there&rsquo;s only one way to find out. I&rsquo;m keeping my fingers crossed that it will take me less time than it did before.</p>
<p>For anyone interested, the tool is available <a href="https://github.com/bkunat/AlfredSnippetConverter" target="_blank" >here</a>!</p>
]]></content:encoded>
    </item>
    <item>
      <title>Automating Adding SPM Plugins</title>
      <link>https://kunat.dev/notes/spm-plugin-update/</link>
      <pubDate>Mon, 18 Dec 2023 18:43:35 +0100</pubDate>
      <guid>https://kunat.dev/notes/spm-plugin-update/</guid>
      <description>&lt;p&gt;&lt;em&gt;How to automate adding plugins to all of your existing (and future) targets in a Swift package&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;TL;DR&lt;/p&gt;
&lt;div class=&#34;highlight&#34;&gt;&lt;pre tabindex=&#34;0&#34; style=&#34;color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;&#34;&gt;&lt;code class=&#34;language-swift&#34; data-lang=&#34;swift&#34;&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;package.targets = package.targets.map { target &lt;span style=&#34;color:#66d9ef&#34;&gt;in&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    &lt;span style=&#34;color:#66d9ef&#34;&gt;var&lt;/span&gt; plugins = target.plugins ?? []
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    plugins.append(.plugin(name: &lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;SwiftLintPlugin&amp;#34;&lt;/span&gt;, package: &lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;SwiftLint&amp;#34;&lt;/span&gt;))
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    target.plugins = plugins
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    &lt;span style=&#34;color:#66d9ef&#34;&gt;return&lt;/span&gt; target
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;}
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;h2 id=&#34;the-problem&#34;&gt;The Problem&lt;/h2&gt;
&lt;p&gt;Last month, I was tasked with adding SwiftFormat and SwiftLint to one of the projects my team maintains. The project is medium-sized and uses Swift Package Manager (SPM) for modularization. It has a separate SPM package for each module. For example:&lt;/p&gt;</description>
      <content:encoded><![CDATA[<p><em>How to automate adding plugins to all of your existing (and future) targets in a Swift package</em></p>
<p>TL;DR</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-swift" data-lang="swift"><span style="display:flex;"><span>package.targets = package.targets.map { target <span style="color:#66d9ef">in</span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">var</span> plugins = target.plugins ?? []
</span></span><span style="display:flex;"><span>    plugins.append(.plugin(name: <span style="color:#e6db74">&#34;SwiftLintPlugin&#34;</span>, package: <span style="color:#e6db74">&#34;SwiftLint&#34;</span>))
</span></span><span style="display:flex;"><span>    target.plugins = plugins
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> target
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><h2 id="the-problem">The Problem</h2>
<p>Last month, I was tasked with adding SwiftFormat and SwiftLint to one of the projects my team maintains. The project is medium-sized and uses Swift Package Manager (SPM) for modularization. It has a separate SPM package for each module. For example:</p>
<ul>
<li>FeatureModules // Directory
<ul>
<li>Login // Swift Package</li>
<li>MyAccount // Swift Package</li>
<li>Dashboard // Swift Package</li>
</ul>
</li>
<li>Core // Directory
<ul>
<li>DomainModels // Swift Package</li>
<li>Networking // Swift Package</li>
</ul>
</li>
</ul>
<p>Each module typically consists of three targets (e.g., Login, LoginTests, LoginTestUtils). The third target&rsquo;s sole purpose is to avoid duplicating testing doubles. You can read more about it <a href="/notes/duplicate-testing-doubles/" >here</a>.</p>
<p>Adding SwiftFormat to the main project and all SPM packages was pretty straightforward. I decided to integrate it at the CI level to ensure all new code changes are properly formatted. Locally, I left this choice up to the maintainers. They can use it through a post-commit git hook or as an Xcode Extension. Depending on your needs, both solutions work great.</p>
<p>Integrating SwiftLint into the base project was also a breeze. What became problematic was integrating it into all the Swift packages. As I mentioned before, we were using a Swift package for each module. As per the documentation:</p>
<blockquote>
<p>Due to limitations with Swift Package Manager plugins, this is only recommended for projects that have a SwiftLint configuration in their root directory, as there is currently no way to pass any additional options to the SwiftLint executable.</p></blockquote>
<p>This limitation would require us to duplicate our SwiftLint config for each package, making future updates to the SwiftLint config error-prone and time-consuming. The same goes for updating the plugin itself.</p>
<h2 id="looking-for-a-solution">Looking For a Solution</h2>
<p>I had two problems to solve:</p>
<ol>
<li>Automating the updating of the config file(s).</li>
<li>Automating the addition of the SwiftLint plugin to all new Swift packages in the future.</li>
</ol>
<p>The first problem is easily solvable with a simple bash script. It would still require writing documentation, but let’s say I’m okay with the trade-off.</p>
<p>The second problem got me looking at Sourcery. However, this solution seemed like overkill from the beginning. Introducing Sourcery to a project just because of SPM limitations seemed like a bad idea.</p>
<p>Back to square one!</p>
<h2 id="solution">Solution</h2>
<p>I started experimenting with the project structure. Instead of creating a new Swift package for each module, I decided to create a single package that worked as a container for all existing modules. Having all the modules defined in a single <code>Package.swift</code> file makes automating the SwiftLint plugin trivial.</p>
<p><img alt="hello there" loading="lazy" src="/notes/images/spm-plugin-update-update-project-structure.png"></p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-swift" data-lang="swift"><span style="display:flex;"><span><span style="color:#75715e">// Inject base plugins into each target</span>
</span></span><span style="display:flex;"><span>package.targets = package.targets.map { target <span style="color:#66d9ef">in</span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">var</span> plugins = target.plugins ?? []
</span></span><span style="display:flex;"><span>    plugins.append(.plugin(name: <span style="color:#e6db74">&#34;SwiftLintPlugin&#34;</span>, package: <span style="color:#e6db74">&#34;SwiftLint&#34;</span>))
</span></span><span style="display:flex;"><span>    target.plugins = plugins
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> target
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>This approach not only solved all of my problems but also required very little work. It involved merging all package definitions into a single one and moving some files around.</p>
<p>This solution won’t work for larger projects. Since my app had only a handful of modules, it worked wonderfully. If you’re working on a mid-sized project with tens of packages, you might consider grouping your modules into a handful of packages like FeatureModules, Models, etc.</p>
<p>I hope this issue will have been resolved long before we hit the wall due to the growing number of modules!</p>
<h2 id="resources">Resources</h2>
<ul>
<li><a href="https://www.swiftystack.com/" target="_blank" >Swifty Stack</a></li>
</ul>
]]></content:encoded>
    </item>
    <item>
      <title>Integrating a Spm Package With Higher Deployment Target</title>
      <link>https://kunat.dev/notes/integrating-a-spm-package-with-higher-deployment-target/</link>
      <pubDate>Mon, 30 Oct 2023 17:50:15 +0100</pubDate>
      <guid>https://kunat.dev/notes/integrating-a-spm-package-with-higher-deployment-target/</guid>
      <description>&lt;p&gt;&lt;em&gt;tl;dr this is currently not possible. See Solution.&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;Last week, I was working on extracting functionality responsible for handling Live Activities into a separate Swift Package Manager (SPM) package. Apple introduced Live Activities with the release of iOS 16.1. My project&amp;rsquo;s deployment target is currently set to iOS 13. As you can imagine, this feature had lots of &lt;code&gt;#if available&lt;/code&gt; checks.&lt;/p&gt;
&lt;p&gt;To make my life a bit easier, I&amp;rsquo;ve decided to set my package&amp;rsquo;s deployment target to iOS 16.1. On paper, this (in combination with &lt;code&gt;#if canImport()&lt;/code&gt;) would allow me to get rid of all the availability checks spread around my codebase.&lt;/p&gt;</description>
      <content:encoded><![CDATA[<p><em>tl;dr this is currently not possible. See Solution.</em></p>
<p>Last week, I was working on extracting functionality responsible for handling Live Activities into a separate Swift Package Manager (SPM) package. Apple introduced Live Activities with the release of iOS 16.1. My project&rsquo;s deployment target is currently set to iOS 13. As you can imagine, this feature had lots of <code>#if available</code> checks.</p>
<p>To make my life a bit easier, I&rsquo;ve decided to set my package&rsquo;s deployment target to iOS 16.1. On paper, this (in combination with <code>#if canImport()</code>) would allow me to get rid of all the availability checks spread around my codebase.</p>
<h2 id="the-initial-plan">The Initial Plan</h2>
<p>My plan was to do something like the following:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-swift" data-lang="swift"><span style="display:flex;"><span><span style="color:#75715e">// Package.swift</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">let</span> package = Package(
</span></span><span style="display:flex;"><span>    name: <span style="color:#e6db74">&#34;SportsWidgets&#34;</span>,
</span></span><span style="display:flex;"><span>    platforms: [.iOS(<span style="color:#e6db74">&#34;16.1&#34;</span>)],
</span></span><span style="display:flex;"><span>    products: [ <span style="color:#75715e">/*...*/</span>],
</span></span><span style="display:flex;"><span>    dependencies: [<span style="color:#75715e">/*...*/</span>],
</span></span><span style="display:flex;"><span>    targets: [<span style="color:#75715e">/*...*/</span>])
</span></span></code></pre></div><div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-swift" data-lang="swift"><span style="display:flex;"><span><span style="color:#75715e">// MyProject.app</span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">#if</span> <span style="color:#75715e">canImport</span>(<span style="color:#75715e">SportsWidgets</span>) 
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">import</span> <span style="color:#a6e22e">SportsWidgets</span> 
</span></span><span style="display:flex;"><span><span style="color:#75715e">#endif</span>
</span></span></code></pre></div><p>This sadly doesn’t work as Xcode won’t see your package as one of the available packages.</p>
<h2 id="solution">Solution</h2>
<p>The workaround is to fork the dependency, lower the deployment target so that it’s less than or equal to your project’s deployment target and manually convert its deployment targets into <strong>@available</strong> conditions throughout its source files.</p>
<h2 id="resources">Resources</h2>
<ul>
<li><a href="https://forums.swift.org/t/using-swift-package-with-minimum-deployment-target-greater-than-target-projects/53725" target="_blank" >Using Swift Package with minimum deployment target greater than target project’s</a></li>
<li><a href="https://forums.swift.org/t/how-does-if-canimport-determine-what-can-be-imported/59330" target="_blank" >How does #if canImport(…) determine what can be imported?</a></li>
</ul>
]]></content:encoded>
    </item>
    <item>
      <title>NextDNS: Breaking Bad Habits</title>
      <link>https://kunat.dev/notes/nextdns/</link>
      <pubDate>Sat, 30 Sep 2023 22:35:25 +0200</pubDate>
      <guid>https://kunat.dev/notes/nextdns/</guid>
      <description>&lt;p&gt;My story begins with a quest for a reliable ad blocker for Safari. I used &lt;a href=&#34;https://apps.apple.com/pl/app/wipr/id1320666476?l=pl&amp;amp;mt=12&#34; target=&#34;_blank&#34; &gt;Wipr&lt;/a&gt; for years, but it started performing poorly. I tried &lt;a href=&#34;https://adguard.com/en/adguard-mac/overview.html&#34; target=&#34;_blank&#34; &gt;AdGuard&lt;/a&gt;, but it required a root certificate for HTTPS traffic, which was a dealbreaker. PiHole seemed complex to configure, so I found &lt;a href=&#34;https://nextdns.io&#34; target=&#34;_blank&#34; &gt;NextDNS&lt;/a&gt;, a DNS-level content blocker promising effective ad and tracker filtering. I decided to try it.&lt;/p&gt;
&lt;h2 id=&#34;nextdns-the-game-changer&#34;&gt;NextDNS: The Game Changer&lt;/h2&gt;
&lt;p&gt;NextDNS turned out to be a game-changer for several reasons. First of all, it’s fairly cheap. The pro plan costs around $2 a month and the free plan is more than enough to try out this product. The setup couldn’t be easier. They offer a few different ways to set it up on all of your devices. The final thing is reliability. I&amp;rsquo;ve been using NextDNS on all of my personal devices for the past six months without encountering any issues. It almost sounds too good to be true.&lt;/p&gt;</description>
      <content:encoded><![CDATA[<p>My story begins with a quest for a reliable ad blocker for Safari. I used <a href="https://apps.apple.com/pl/app/wipr/id1320666476?l=pl&amp;mt=12" target="_blank" >Wipr</a> for years, but it started performing poorly. I tried <a href="https://adguard.com/en/adguard-mac/overview.html" target="_blank" >AdGuard</a>, but it required a root certificate for HTTPS traffic, which was a dealbreaker. PiHole seemed complex to configure, so I found <a href="https://nextdns.io" target="_blank" >NextDNS</a>, a DNS-level content blocker promising effective ad and tracker filtering. I decided to try it.</p>
<h2 id="nextdns-the-game-changer">NextDNS: The Game Changer</h2>
<p>NextDNS turned out to be a game-changer for several reasons. First of all, it’s fairly cheap. The pro plan costs around $2 a month and the free plan is more than enough to try out this product. The setup couldn’t be easier. They offer a few different ways to set it up on all of your devices. The final thing is reliability. I&rsquo;ve been using NextDNS on all of my personal devices for the past six months without encountering any issues. It almost sounds too good to be true.</p>
<p><img alt="hello there" loading="lazy" src="/notes/images/nextdns-logs.png"></p>
<blockquote>
<p>For those of you who value community endorsements, <a href="https://news.ycombinator.com/item?id=23315403" target="_blank" >NextDNS receive a seal of approval form the HackerNews crowd</a>.</p></blockquote>
<p>In short, NextDNS ticked all of the boxes for what I had been searching for in a content blocker.</p>
<h2 id="breaking-bad-habits">Breaking Bad Habits</h2>
<p>While I initially started using NextDNS as an ad blocker, it unexpectedly became a tool to help me break a bad habit. Until recently, I was compulsively browsing websites like LiveLeak just to kill time. It started affecting my productivity. NextDNS came to rescue with its parental control features.</p>
<p>With NextDNS, it’s possible to block specific websites or entire categories of sties, such as Dating or View Streaming. Now, you might wonder why I didn’t simply use Apple’s Screen Time for this purpose. The answer is <strong>friction</strong>. With Screen Time, it’s only a matter of two clicks before you can extend your limit by 15 minutes or turn it off completely for the day. For this use case, Screen Time is just too easy to disable.</p>
<blockquote>
<p>I do use Screen Time for blocking apps. In order to keep myself accountable, I use <a href="https://streaksapp.com" target="_blank" >Streaks App</a>. You can read more about how I use Streaks <a href="/notes/my-productivity-setup/" >here</a>.</p></blockquote>
<p><strong>I&rsquo;ve added friction to the process by blocking relevant sites, enabling 2FA, and storing recovery codes safely, just out of reach from my desk. It&rsquo;s simple but effective. Thanks to NextDNS, I&rsquo;ve successfully conquered a bothersome habit for 138 consecutive days!</strong></p>
<h2 id="conclusion">Conclusion</h2>
<p>NextDNS exceeded my expectations as a content blocker and helped me break a longstanding bad habit. Its affordability, user-friendliness, reliability, and bonus features make it a must-have for improving your online experience and taking control of digital habits. I highly recommend trying NextDNS!</p>
]]></content:encoded>
    </item>
    <item>
      <title>My Alfred Setup</title>
      <link>https://kunat.dev/notes/my-alfred-setup/</link>
      <pubDate>Sun, 13 Aug 2023 19:53:05 +0200</pubDate>
      <guid>https://kunat.dev/notes/my-alfred-setup/</guid>
      <description>&lt;p&gt;&lt;em&gt;Last updated: 13/08/23&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;I’ve relied on Alfred as my daily driver for boosting productivity for years. However, I didn’t switch from Apple’s Spotlight overnight. It took three attempts before I truly embraced its capabilities. In this article I’ll share my Alfred setup and explain my most frequent workflows. Let’s dive in!&lt;/p&gt;
&lt;h2 id=&#34;the-initial-setup&#34;&gt;The Initial Setup&lt;/h2&gt;
&lt;p&gt;I’ve already described the initial setup in detail in &lt;a href=&#34;https://kunat.dev/notes/new-mac-setup/#web&#34; &gt;New Mac Setup&lt;/a&gt; article, so be sure to check it if you’re interested.&lt;/p&gt;</description>
      <content:encoded><![CDATA[<p><em>Last updated: 13/08/23</em></p>
<p>I’ve relied on Alfred as my daily driver for boosting productivity for years. However, I didn’t switch from Apple’s Spotlight overnight. It took three attempts before I truly embraced its capabilities. In this article I’ll share my Alfred setup and explain my most frequent workflows. Let’s dive in!</p>
<h2 id="the-initial-setup">The Initial Setup</h2>
<p>I’ve already described the initial setup in detail in <a href="/notes/new-mac-setup/#web" >New Mac Setup</a> article, so be sure to check it if you’re interested.</p>
<h2 id="snippets-efficiency-at-its-best">Snippets: Efficiency at Its Best</h2>
<p>One of my absolute favorite features of Alfred is Snippets. I rely on them extensively, whether it’s for a quick access to emojis, executing frequent commands, or crafting boilerplate messages. Let’s explore those use cases!</p>
<h3 id="emojis">Emojis</h3>
<p>While there are several popular workflows for emojis, I&rsquo;ve chosen to create snippets for the ones I use most frequently. It&rsquo;s a highly efficient approach, and for those less common emojis, we&rsquo;ll explore workflows later. Curious about using your snippets on mobile devices? Don&rsquo;t miss my guide on <del><a href="/notes/alfred-snippets-on-ios/" >Alfred Snippets on iOS</a></del>.</p>
<h3 id="commands">Commands</h3>
<p>I&rsquo;ve compiled a set of frequently used commands, primarily for directory navigation and running common scripts. Although these tasks could be handled with shell aliases, I prefer having everything neatly organized within Alfred. This setup has proven to be reliable and convenient. It just works.</p>
<h3 id="note-taking">Note Taking</h3>
<p>Markdown is my go-to format for note-taking, and I&rsquo;ve leveraged snippets to automate repetitive tasks. Here are some noteworthy examples:</p>
<ul>
<li><strong>!cb</strong> - Inserts a markdown code block.</li>
<li><strong>!scb</strong> - Inserts a markdown code block with Swift syntax highlighting.</li>
<li><strong>!ds</strong> - Generates a line separator followed by today&rsquo;s date, perfect for organizing notes.</li>
<li><strong>!nd</strong> - Initiates a new daily entry in my TODOs note.</li>
</ul>
<h3 id="and-more">And More</h3>
<p>Snippets also come in handy for quick message replies, composing emails, and inserting code snippets.</p>
<h2 id="workflows-unleash-productivity">Workflows: Unleash Productivity</h2>
<p>The most exhilarating aspect of Alfred is its workflows – a feature that can supercharge your productivity. For a deeper dive into workflows, you can explore them further <del><a href="https://www.alfredapp.com/workflows/" target="_blank" >here</a></del>.</p>
<h3 id="note-taking-1">Note Taking</h3>
<p>Community-built Alfred workflows have streamlined note-taking with popular apps. Personally, I rely on <del><a href="https://github.com/drgrib/alfred-bear" target="_blank" >alfred-bear</a></del> for Bear Notes (personal notes) and <del><a href="https://github.com/hauselin/obsidian-alfred" target="_blank" >obsidian-alfred</a></del> for Obsidian (work-related notes).</p>
<blockquote>
<p>Ideally, I&rsquo;d prefer keeping all my notes in a single place. However, Bear Notes restricts data to your Apple ID, making cross-account sharing a challenge. Obsidian, with its raw markdown files, easily accommodates iCloud folder sharing. Initially, I thought about migrating my personal notes to Obsidian, but the recent major release of <del><a href="https://blog.bear.app/2023/07/bear-2-is-here/" target="_blank" >Bear Notes</a></del> swayed my decision. With new features, it’s just too good of an app to quit using!</p></blockquote>
<p>Both workflows offer similar features (Obsidian one is a bit more complex). They both excel at accessing notes and creating new ones on the fly.</p>
<h3 id="launchers-ready-set-go">Launchers: Ready, Set, Go!</h3>
<p>Launchers are my custom workflows that I use to <em>launch</em> a set of apps for a particular activity:</p>
<ul>
<li>Development: Launches a suite of essential tools and utilities.</li>
<li>Writing: Opens a predefined set of notes and tools.</li>
<li>Investing: Initiates my brokerage account, bank account, and a useful Excel sheet for tracking investments.</li>
</ul>
<p>While I could manage without these, they reduce friction when switching between tasks, ensuring I&rsquo;m just a click away from my next endeavor.</p>
<p><img loading="lazy" src="/notes/images/my-alfred-setup-1.png">
<img loading="lazy" src="/notes/images/my-alfred-setup-2.png"></p>
<h3 id="emoji-beyond-snippets">Emoji: Beyond Snippets</h3>
<p>As previously mentioned, snippets handle the most common emojis, but for the rest, there&rsquo;s <del><a href="https://github.com/jsumners/alfred-emoji/releases" target="_blank" >this</a></del> workflow. It may be a tad slower than snippets, but it&rsquo;s significantly quicker than macOS&rsquo;s built-in emoji picker. Give it a try – you won&rsquo;t want to go back.</p>
<h3 id="shortcuts-effortless-automation">Shortcuts: Effortless Automation</h3>
<p>Certain tasks are best automated using macOS Shortcuts.app. There’s one problem: there’s no easy way to launch them without clicking through the app. However, <a href="https://www.alfredapp.com/blog/guides-and-tutorials/macos-shortcuts-with-alfred-workflow/" target="_blank" >this</a> shortcut allows you to initiate them directly from Alfred.</p>
<h3 id="more-in-store">More in Store</h3>
<p>Explore these miscellaneous workflows for even more functionality:</p>
<ul>
<li><a href="https://github.com/dotson/iTunes-Airplay-Toggle" target="_blank" >AirPlay Toggle</a> - utility for switching between airplay devices</li>
<li><a href="https://github.com/vitorgalvao/link-clean-workflow" target="_blank" >LinkClean</a> - tool for removing extraneous information from URLs</li>
<li><a href="https://github.com/alfredapp/system-settings-workflow/" target="_blank" >System Settings</a> - utility for opening macOS system settings panes</li>
</ul>
<h2 id="wrap-up">Wrap Up</h2>
<p>That concludes my Alfred setup – a straightforward yet highly efficient automation of my daily tasks (at least the most repetitive ones). While there are a few improvements I&rsquo;m considering, the core concept will remain unchanged.</p>
]]></content:encoded>
    </item>
    <item>
      <title>GitLab Code Coverage for Pure Swift Packages</title>
      <link>https://kunat.dev/notes/gitlab-code-coverage/</link>
      <pubDate>Fri, 28 Jul 2023 21:53:46 +0200</pubDate>
      <guid>https://kunat.dev/notes/gitlab-code-coverage/</guid>
      <description>&lt;p&gt;&lt;em&gt;How to use GitLab&amp;rsquo;s built-in test coverage report tool for pure Swift packages?&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;Some test coverage report generation tools do not support “pure” Swift packages. One example is &lt;a href=&#34;https://github.com/SlatherOrg/slather/issues/466&#34; target=&#34;_blank&#34; &gt;Slather&lt;/a&gt;. By pure, I’m referring to packages that contain only the &lt;code&gt;Package.swift&lt;/code&gt; file, without any &lt;code&gt;.xcodeproj&lt;/code&gt; or &lt;code&gt;.xcworkspace&lt;/code&gt; files.&lt;/p&gt;
&lt;p&gt;One solution to this problem was to generate a project file for a Swift package on the fly using Fastlane. This solution has one limitation: it&amp;rsquo;s not possible to generate a project file for a Swift package that contains any resources. When you try doing so, you’ll see &lt;code&gt;Type &#39;Bundle&#39; has no member “module”&lt;/code&gt; error when building.&lt;/p&gt;</description>
      <content:encoded><![CDATA[<p><em>How to use GitLab&rsquo;s built-in test coverage report tool for pure Swift packages?</em></p>
<p>Some test coverage report generation tools do not support “pure” Swift packages. One example is <a href="https://github.com/SlatherOrg/slather/issues/466" target="_blank" >Slather</a>. By pure, I’m referring to packages that contain only the <code>Package.swift</code> file, without any <code>.xcodeproj</code> or <code>.xcworkspace</code> files.</p>
<p>One solution to this problem was to generate a project file for a Swift package on the fly using Fastlane. This solution has one limitation: it&rsquo;s not possible to generate a project file for a Swift package that contains any resources. When you try doing so, you’ll see <code>Type 'Bundle' has no member “module”</code> error when building.</p>
<h2 id="solution">Solution</h2>
<p>What worked was using Fastlane with <code>xcov</code>. Here’s a snippet:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-ruby" data-lang="ruby"><span style="display:flex;"><span>scan(
</span></span><span style="display:flex;"><span>  <span style="color:#e6db74">package_path</span>: <span style="color:#e6db74">&#34;.&#34;</span>,
</span></span><span style="display:flex;"><span>  <span style="color:#75715e"># Schemes generated by Xcode have a &#34;-Package&#34; suffix</span>
</span></span><span style="display:flex;"><span>  <span style="color:#e6db74">scheme</span>: <span style="color:#e6db74">&#34;MyPackage-Package&#34;</span>,
</span></span><span style="display:flex;"><span>  <span style="color:#e6db74">clean</span>: <span style="color:#66d9ef">true</span>,
</span></span><span style="display:flex;"><span>  <span style="color:#e6db74">device</span>: <span style="color:#e6db74">&#34;iPhone 14&#34;</span>,
</span></span><span style="display:flex;"><span>  <span style="color:#e6db74">code_coverage</span>: <span style="color:#66d9ef">true</span>,
</span></span><span style="display:flex;"><span>  <span style="color:#e6db74">result_bundle</span>: <span style="color:#66d9ef">true</span>
</span></span><span style="display:flex;"><span>)
</span></span><span style="display:flex;"><span>xcov(
</span></span><span style="display:flex;"><span>  <span style="color:#e6db74">is_swift_package</span>: <span style="color:#66d9ef">true</span>
</span></span><span style="display:flex;"><span>)
</span></span></code></pre></div><p>Here’s how the <code>xcov</code> output looks like:</p>
<pre tabindex="0"><code>+----------------------+--------+
|[32mxcov Coverage Report[0m    |
+----------------------+--------+
| MyPackage            | 13.75% |
| MyPackageTestUtils   | 0.00%  |
| MyPackageTests       | 22.12% |
| Average Coverage     | 11.96% |
+----------------------+--------+
</code></pre><p>The final piece of the puzzle is displaying the test coverage on the MR page like so:</p>
<p><img alt="hello there" loading="lazy" src="/notes/images/gitlab-code-coverage-pipeline-passed.png"></p>
<p>In order to do that, we’ll need a regex to parse output from <code>xcov</code>. In this case we’re only interested in getting the percentage right beside “MyPackgae”. Here’s a snippet from the <code>.gitlab-ci.yml</code> file:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-yml" data-lang="yml"><span style="display:flex;"><span><span style="color:#f92672">unit_test</span>:
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">stage</span>: <span style="color:#ae81ff">tests</span>
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">script</span>:
</span></span><span style="display:flex;"><span>    - <span style="color:#ae81ff">bundle exec fastlane unit_test</span>
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">coverage</span>: <span style="color:#e6db74">&#39;/\|\s+MyPackage\s+\|\s+\d+\.\d+\%\s+\|/&#39;</span>
</span></span></code></pre></div><h2 id="resources">Resources</h2>
<ul>
<li><a href="https://github.com/fastlane/fastlane/discussions/17362" target="_blank" >Support for SPM package projects in fastlane scan</a></li>
<li><a href="https://augmentedcode.io/2021/04/12/code-coverage-for-swift-packages-with-fastlane/" target="_blank" >Code coverage for Swift Packages with Fastlane</a></li>
<li><a href="https://github.com/fastlane/fastlane/discussions/17362" target="_blank" >Support for SPM package projects in fastlane scan</a></li>
<li><a href="https://docs.gitlab.com/ee/ci/testing/code_coverage.html" target="_blank" >Code coverage | GitLab Docs</a></li>
</ul>
]]></content:encoded>
    </item>
    <item>
      <title>Building Long Term Investment Portfolio in Europe</title>
      <link>https://kunat.dev/notes/investment-portfolio/</link>
      <pubDate>Wed, 21 Jun 2023 21:19:09 +0200</pubDate>
      <guid>https://kunat.dev/notes/investment-portfolio/</guid>
      <description>&lt;p&gt;I’ve been putting off this project for the past ~18 months. Most resources I’ve found focus primarily on US-based investors. Sadly, most of the assets listed in those resources are unavailable to investors located in other parts of the world.&lt;/p&gt;
&lt;p&gt;What came to my rescue was the Banker on Wheels blog. I did not find any other resource that came even close to this one. It was a one-stop shop for my use case. In less than a month, I’ve collected more than enough knowledge to create a portfolio based on my current needs and risk profile.&lt;/p&gt;</description>
      <content:encoded><![CDATA[<p>I’ve been putting off this project for the past ~18 months. Most resources I’ve found focus primarily on US-based investors. Sadly, most of the assets listed in those resources are unavailable to investors located in other parts of the world.</p>
<p>What came to my rescue was the Banker on Wheels blog. I did not find any other resource that came even close to this one. It was a one-stop shop for my use case. In less than a month, I’ve collected more than enough knowledge to create a portfolio based on my current needs and risk profile.</p>
<p>What worked for me was starting with the <a href="https://www.bankeronwheels.com/long-term-investing-strategies-for-financial-independence/" target="_blank" >Portfolio Construction Investing Guide</a> and reading all the linked materials. It took me about 20 hours to empty my reading list. After that, I was set on my goals and expectations for my portfolio.</p>
<h2 id="portfolio-breakdown">Portfolio Breakdown</h2>
<p>You can see my portfolio breakdown below. Keep in mind that my current investment horizon is 7+ years.</p>
<table>
  <thead>
      <tr>
          <th>Type</th>
          <th>Instrument</th>
          <th>ISIN</th>
          <th>%</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>World Equities</td>
          <td>Vanguard FTSE All-World UCITS ETF</td>
          <td>IE00BK5BQT80</td>
          <td>60%</td>
      </tr>
      <tr>
          <td>US Market</td>
          <td>Vanguard S&amp;P 500 UCITS ETF</td>
          <td>IE00BFMXXD54</td>
          <td>10%</td>
      </tr>
      <tr>
          <td>Gold</td>
          <td>iShares Physical Gold ETC</td>
          <td>IE00B4ND3602</td>
          <td>7%</td>
      </tr>
      <tr>
          <td>Aggregate Bonds</td>
          <td>iShares Core Global Aggregate Bond UCITS ETF</td>
          <td>IE00BDBRDM35</td>
          <td>23%</td>
      </tr>
  </tbody>
</table>
<h2 id="resources">Resources</h2>
<ul>
<li><a href="https://www.bankeronwheels.com/" target="_blank" >⭐️ Banker on Wheels</a></li>
<li><a href="https://www.reddit.com/r/Bogleheads/" target="_blank" >/r/Bogleheads/</a></li>
<li><a href="https://www.trackinsight.com/en/etf-screener?search=VIGAX" target="_blank" >Trackinsight - ETF screening and comparison tool</a></li>
<li>ChatGPT</li>
</ul>
<p>I can’t explain how helpful ChatGPT was for me over the past month. Discovering a new domain can be intimidating. The vast array of unfamiliar terms, tools, and acronyms can quickly accumulate, making it challenging to navigate through the intricacies of a new field. ChatGPT has made this experience a slightly smoother journey.</p>
<h2 id="tldr">tl;dr</h2>
<p><strong>Goal:</strong> maximize returns while spending the least possible amount of time managing the investment portfolio.</p>
<p><strong>Problem:</strong> My knowledge of this topic is very basic. The fact that most resources are aimed at US-based investors does not help.</p>
<p><strong>Solution:</strong> <a href="https://www.bankeronwheels.com/" target="_blank" >Banker on Wheels</a></p>
]]></content:encoded>
    </item>
    <item>
      <title>New Mac Setup</title>
      <link>https://kunat.dev/notes/new-mac-setup/</link>
      <pubDate>Mon, 15 May 2023 20:16:21 +0200</pubDate>
      <guid>https://kunat.dev/notes/new-mac-setup/</guid>
      <description>&lt;p&gt;&lt;em&gt;Last updated: 20/05/25&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;This note describes the steps I take when setting up a new Mac. This process could be mostly automated, but I can’t get myself to do it. It takes me around 90 minutes to go through all the steps. Let’s get started!&lt;/p&gt;
&lt;h2 id=&#34;i-prerequisite-files-to-import&#34;&gt;I. Prerequisite Files to Import&lt;/h2&gt;
&lt;p&gt;Before diving into installations, I make sure to have these configuration files ready:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;.gitconfig&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;.zshrc&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&#34;ii-software-installation&#34;&gt;II. Software Installation&lt;/h2&gt;
&lt;p&gt;Apps marked with “🌕” emoji are optional (it depends if it’s a work or a private Mac).&lt;/p&gt;</description>
      <content:encoded><![CDATA[<p><em>Last updated: 20/05/25</em></p>
<p>This note describes the steps I take when setting up a new Mac. This process could be mostly automated, but I can’t get myself to do it. It takes me around 90 minutes to go through all the steps. Let’s get started!</p>
<h2 id="i-prerequisite-files-to-import">I. Prerequisite Files to Import</h2>
<p>Before diving into installations, I make sure to have these configuration files ready:</p>
<ul>
<li><code>.gitconfig</code></li>
<li><code>.zshrc</code></li>
</ul>
<h2 id="ii-software-installation">II. Software Installation</h2>
<p>Apps marked with “🌕” emoji are optional (it depends if it’s a work or a private Mac).</p>
<h3 id="a-homebrew--command-line-tools">A. Homebrew &amp; Command-Line Tools</h3>
<ol>
<li><strong>Install <a href="https://brew.sh/" target="_blank" >HomeBrew</a></strong> (from Web)</li>
<li><strong>Install CLI tools via Brew:</strong></li>
</ol>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>// Set shell to zsh
</span></span><span style="display:flex;"><span>chsh -s /bin/zsh
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>brew install zsh-autosuggestions
</span></span><span style="display:flex;"><span>brew install zsh-syntax-highlighting
</span></span><span style="display:flex;"><span>brew install pure
</span></span><span style="display:flex;"><span>brew install jesseduffield/lazygit/lazygit
</span></span><span style="display:flex;"><span>brew install xcodesorg/made/xcodes
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>// Optional
</span></span><span style="display:flex;"><span>brew install rbenv
</span></span><span style="display:flex;"><span>brew install swiftlint
</span></span><span style="display:flex;"><span>brew install node
</span></span></code></pre></div><h3 id="b-app-store-applications">B. App Store Applications</h3>
<ul>
<li><a href="https://harshil.net/pause" target="_blank" >Pause</a></li>
<li><a href="https://apps.apple.com/pl/app/dynamo/id1445910651?mt=12" target="_blank" >Dynamo</a></li>
<li><a href="https://apps.apple.com/pl/app/pixelmator-pro/id1289583905?mt=12" target="_blank" >Pixelmator Pro</a></li>
</ul>
<h3 id="c-gui-applications-from-web">C. GUI Applications (from Web)</h3>
<ul>
<li><a href="https://www.alfredapp.com" target="_blank" >Alfred</a></li>
<li><a href="https://rectangleapp.com/" target="_blank" >Rectangle</a></li>
<li><a href="https://www.hammerspoon.org/" target="_blank" >Hammerspoon</a></li>
<li><a href="https://karabiner-elements.pqrs.org/" target="_blank" >Karabiner-Elements</a></li>
<li><a href="https://ghostty.org/" target="_blank" >Ghostty</a></li>
<li><a href="https://obsidian.md/download" target="_blank" >Obsidian</a></li>
<li><a href="https://toggl.com/track/time-tracking-mac/" target="_blank" >Toggl</a></li>
<li><a href="https://icemenubar.app/" target="_blank" >Ice</a></li>
<li><a href="https://developer.apple.com/download/all/" target="_blank" >Xcode</a></li>
<li><a href="https://code.visualstudio.com/download" target="_blank" >Visual Studio Code</a></li>
<li><a href="https://freemacsoft.net/appcleaner/" target="_blank" >AppCleaner</a></li>
<li><a href="https://www.logitech.com/en-us/software/logi-options-plus.html" target="_blank" >Logi Options+</a></li>
<li><a href="https://github.com/MonitorControl/MonitorControl" target="_blank" >Monitor Control</a></li>
<li><a href="https://pdfexpert.com/blog/meet-pdf-expert-2-the-best-pdf-editor-for-your-mac" target="_blank" >PDF Expert</a></li>
<li><a href="https://proxyman.com/" target="_blank" >ProxyMan</a></li>
<li><a href="https://boltai.com/" target="_blank" >BoltAI</a></li>
<li>🌕 <a href="https://www.omnigroup.com/more" target="_blank" >OmniDiskSweeper</a></li>
<li>🌕 <a href="https://monodraw.helftone.com" target="_blank" >Monodraw</a></li>
</ul>
<h2 id="iii-application-configuration">III. Application Configuration</h2>
<p>Now, let&rsquo;s configure the installed applications.</p>
<ul>
<li><strong>Alfred</strong>:
<ul>
<li>Disable both shortcuts for Spotlight (Keyboard -&gt; Keyboard Shortcuts &hellip; -&gt; Spotlight -&gt; disable both)</li>
<li>Import preferences from <code>/Users/$USER/Library/Application Support/Alfred/Alfred.alfredpreferences</code></li>
<li>Set global shortcut (⌘+Space)</li>
<li>Enable Clipboard History</li>
<li>Enable: Features -&gt; Snippets “Automatically expand snippets by keyword”</li>
<li>Enable: Features -&gt; Web Bookmarks -&gt; Safari Bookmarks</li>
</ul>
</li>
<li><strong>Rectangle</strong>:
<ul>
<li>Import config</li>
</ul>
</li>
<li><strong>Hammerspoon</strong>:
<ul>
<li>Import config</li>
<li>Install <a href="https://www.hammerspoon.org/Spoons/AppLauncher.html" target="_blank" >AppLauncher</a></li>
</ul>
</li>
<li><strong>Karabiner-Elements</strong>:
<ul>
<li>Import config</li>
</ul>
</li>
<li><strong>Ghostty</strong>:
<ul>
<li>Import config</li>
</ul>
</li>
<li><strong>Obsidian</strong>:
<ul>
<li>In order to make <code>git</code> plugin work:
<ul>
<li><code>git config --global credential.helper osxkeychain</code></li>
<li><code>git pull</code></li>
</ul>
</li>
</ul>
</li>
<li><strong>Visual Studio Code</strong>:
<ul>
<li>Log in to sync plugins and preferences</li>
<li>Fix holding motion keys not working with <code>vim</code> plugin:
<ul>
<li><code>defaults write com.microsoft.VSCode ApplePressAndHoldEnabled -bool false</code></li>
</ul>
</li>
</ul>
</li>
<li><strong>Logi Options+</strong>:
<ul>
<li>Restore from backup</li>
</ul>
</li>
<li><strong>ProxyMan</strong>:
<ul>
<li>Import mapped requests</li>
</ul>
</li>
</ul>
<h2 id="iv-system-preferences-settings">IV. System <del>Preferences</del> Settings</h2>
<p>Let&rsquo;s tweak the system settings.</p>
<ul>
<li>Set desktop background</li>
</ul>
<h3 id="general">General</h3>
<ul>
<li>Set accent color</li>
</ul>
<h3 id="language--region">Language &amp; Region</h3>
<ul>
<li>Set English (United States) as primary language</li>
</ul>
<blockquote>
<p>I simply can’t stand Trash being called Bin.</p></blockquote>
<h3 id="displays">Displays</h3>
<ul>
<li>Adjust display resolution</li>
</ul>
<h3 id="desktop--dock">Desktop &amp; Dock</h3>
<ul>
<li>Desktop &amp; Dock -&gt; Desktop &amp; Stage Manager -&gt; Click wallpaper to reveal desktop -&gt; <code>Only in Stage Manager</code></li>
</ul>
<h3 id="keyboard">Keyboard</h3>
<ul>
<li>Keyboard -&gt; Keyboard Navigation -&gt; <code>Enable</code></li>
</ul>
<blockquote>
<p>This setting enables option selection with the Tab key.</p></blockquote>
<ul>
<li>Keyboard -&gt; Keyboard Shortcuts -&gt; Modifier Keys -&gt; Map Caps Lock to Escape</li>
</ul>
<h3 id="trackpad">Trackpad</h3>
<ul>
<li>Trackpad -&gt; Point &amp; Click -&gt; Tap to click</li>
<li>Trackpad -&gt; Point &amp; Click -&gt; Tracking Speed (increase)</li>
</ul>
<h3 id="sharing">Sharing</h3>
<ul>
<li>Change machine&rsquo;s name</li>
</ul>
<h3 id="login-password">Login Password</h3>
<ul>
<li>Enable “Use Apple Watch to unlock your applications and your Mac”</li>
</ul>
<h3 id="security--privacy">Security &amp; Privacy</h3>
<ul>
<li>Disable “Password Autofill”</li>
<li>Add fingers to Touch ID</li>
</ul>
<h3 id="general-system-settings-panel">General (System Settings Panel)</h3>
<ul>
<li>Set Login Items</li>
</ul>
<h2 id="v-core-macos-application--utility-configuration">V. Core macOS Application &amp; Utility Configuration</h2>
<h3 id="archive-utility">Archive Utility</h3>
<ul>
<li>Preferences -&gt; After expanding: move to trash</li>
</ul>
<h3 id="messages">Messages</h3>
<ul>
<li>Enable message forwarding on iPhone</li>
<li>Enable iCloud sync</li>
</ul>
<h3 id="dock">Dock</h3>
<ul>
<li>Move to right</li>
<li>Adjust zoom and size</li>
<li>Rearrange apps</li>
<li>Enable “Show Percentage” (Battery settings, if referring to menu bar)</li>
</ul>
<h3 id="mail">Mail</h3>
<ul>
<li>Take all accounts online</li>
<li>Rename and rearrange</li>
</ul>
<h3 id="finder">Finder</h3>
<ul>
<li>Rearrange sidebar</li>
<li>Set “Search the current folder”</li>
<li>“Show path bar” (it’s in the menu bar)</li>
<li>Enable “Snap to grid” (right click on empty Desktop, Show View Options -&gt; Sort By -&gt; Snap to Grid)</li>
<li>Disable “Show warning before removing from iCloud Drive”</li>
<li>Set Preferences -&gt; General -&gt; New Finder windows show … -&gt; Desktop</li>
</ul>
<h3 id="time-machine">Time Machine</h3>
<ul>
<li>Create backup</li>
</ul>
<h2 id="conclusion">Conclusion</h2>
<p>That&rsquo;s it. That&rsquo;s the entire setup process. I know there are tools to automate ~90% of it. I plan to do it someday. In the meantime, my next project is to automate syncing things like Alfred preferences and system settings across all of my Macs. Fingers crossed, I&rsquo;ll sit down and do it this summer!</p>
]]></content:encoded>
    </item>
    <item>
      <title>Xcode Development Assets with XcodeGen</title>
      <link>https://kunat.dev/notes/development-assets-xcodegen/</link>
      <pubDate>Mon, 10 Apr 2023 17:05:57 +0200</pubDate>
      <guid>https://kunat.dev/notes/development-assets-xcodegen/</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Development Assets in Xcode that allow developers to provide test data to use within SwiftUI previews and other code during development. Assets marked for development will only be included in debug builds and removed once you create an archive of your app. This solution is preferred to having larger assets in the production binary size. Developers can add different types of data as Development Assets, such as JSON files for mocked network requests, images to use in SwiftUI previews, Core Data sample databases, and &lt;strong&gt;Swift files representing mocked or sample data&lt;/strong&gt;. Swift files are added differently because they use the dead code stripper to avoid the compiler from failing during archiving.&lt;/p&gt;</description>
      <content:encoded><![CDATA[<blockquote>
<p>Development Assets in Xcode that allow developers to provide test data to use within SwiftUI previews and other code during development. Assets marked for development will only be included in debug builds and removed once you create an archive of your app. This solution is preferred to having larger assets in the production binary size. Developers can add different types of data as Development Assets, such as JSON files for mocked network requests, images to use in SwiftUI previews, Core Data sample databases, and <strong>Swift files representing mocked or sample data</strong>. Swift files are added differently because they use the dead code stripper to avoid the compiler from failing during archiving.</p></blockquote>
<p>To define development assets through the XcodeGen config file, use <code>DEVELOPMENT_ASSET_PATHS</code>:</p>
<pre tabindex="0"><code>name: MySampleApp
options:
  bundleId: com.example.MySampleApp
  deploymentTarget: 14.0
  organizationName: Example, Inc.
  developmentLanguage: swift
  useTabs: true
  indentWidth: 4
  tabWidth: 4
  swiftVersion: 5.5
  carthageBuildPath: Carthage/Build/iOS

targets:
  - name: MySampleApp
    type: application
    platform: iOS
    deploymentTarget: 14.0
    sources: 
      - path: MySampleApp
    settings:
      ENABLE_BITCODE: NO
      SWIFT_OPTIMIZATION_LEVEL: -Onone
      SWIFT_ACTIVE_COMPILATION_CONDITIONS: DEBUG
      DEVELOPMENT_ASSET_PATHS: 
         Dashboard/PreviewAssets
         List/Views/PreviewAssets
// ...
</code></pre><h2 id="resources">Resources</h2>
<ul>
<li><a href="https://www.avanderlee.com/xcode/development-assets-preview-catalog/" target="_blank" >Development Assets in Xcode to enrich SwiftUI Previews</a></li>
<li><a href="https://useyourloaf.com/blog/swiftui-preview-data/" target="_blank" >SwiftUI Preview Data</a></li>
</ul>
]]></content:encoded>
    </item>
    <item>
      <title>Connectivity Issues with Logitech MX Keys on macOS Ventura</title>
      <link>https://kunat.dev/notes/mx-keys-ventura/</link>
      <pubDate>Tue, 04 Apr 2023 12:49:12 +0200</pubDate>
      <guid>https://kunat.dev/notes/mx-keys-ventura/</guid>
      <description>&lt;p&gt;After installing macOS Ventura, I began experiencing connectivity issues when using MX Keys keyboard through Bluetooth. The keyboard would disconnect from my Mac whenever it was inactive for a few minutes.&lt;/p&gt;
&lt;p&gt;What made this issue especially frustrating was that it didn&amp;rsquo;t happen every time, and the time it took for the keyboard to reconnect varied widely. It drove me insane.&lt;/p&gt;
&lt;p&gt;I&amp;rsquo;ve been following several threads in &lt;a href=&#34;https://www.reddit.com/r/logitech/&#34; target=&#34;_blank&#34; &gt;r/logitech&lt;/a&gt;, reaching out to Logitech support multiple times, and submitting bug reports through the Logi Options+ app. However, none of these attempts at resolution have been successful.&lt;/p&gt;</description>
      <content:encoded><![CDATA[<p>After installing macOS Ventura, I began experiencing connectivity issues when using MX Keys keyboard through Bluetooth. The keyboard would disconnect from my Mac whenever it was inactive for a few minutes.</p>
<p>What made this issue especially frustrating was that it didn&rsquo;t happen every time, and the time it took for the keyboard to reconnect varied widely. It drove me insane.</p>
<p>I&rsquo;ve been following several threads in <a href="https://www.reddit.com/r/logitech/" target="_blank" >r/logitech</a>, reaching out to Logitech support multiple times, and submitting bug reports through the Logi Options+ app. However, none of these attempts at resolution have been successful.</p>
<p>The solution? Purchase the Logi Bolt USB Receiver. It&rsquo;s disappointing that there are no other options available, and it&rsquo;s frustrating that Logitech is ignoring the complaints. But unfortunately, that&rsquo;s the way it is.</p>
]]></content:encoded>
    </item>
    <item>
      <title>How to Tackle Duplicate Test Doubles Problem in Modular Architecture</title>
      <link>https://kunat.dev/notes/duplicate-testing-doubles/</link>
      <pubDate>Thu, 30 Mar 2023 22:09:54 +0200</pubDate>
      <guid>https://kunat.dev/notes/duplicate-testing-doubles/</guid>
      <description>&lt;p&gt;After SPM asset support has been added to Xcode 12, there is no reason not to use modularization in your iOS projects. It brings value no matter the project and team size.&lt;/p&gt;
&lt;p&gt;However, after some time, you might encounter problems like increased complexity, difficulty with integration testing, or code duplication. Today we’ll focus on the last one, specifically, testing doubles duplication. Let’s jump in!&lt;/p&gt;
&lt;h2 id=&#34;introduction&#34;&gt;Introduction&lt;/h2&gt;
&lt;p&gt;When saying “module” I’m always referring to a group of related targets. One example would be &lt;code&gt;DomainModels&lt;/code&gt; and &lt;code&gt;DomainModelsTests&lt;/code&gt;. The &lt;code&gt;DomainModels&lt;/code&gt; target stores the implementation, while &lt;code&gt;DomainModelsTests&lt;/code&gt; has unit tests code.&lt;/p&gt;</description>
      <content:encoded><![CDATA[<p>After SPM asset support has been added to Xcode 12, there is no reason not to use modularization in your iOS projects. It brings value no matter the project and team size.</p>
<p>However, after some time, you might encounter problems like increased complexity, difficulty with integration testing, or code duplication. Today we’ll focus on the last one, specifically, testing doubles duplication. Let’s jump in!</p>
<h2 id="introduction">Introduction</h2>
<p>When saying “module” I’m always referring to a group of related targets. One example would be <code>DomainModels</code> and <code>DomainModelsTests</code>. The <code>DomainModels</code> target stores the implementation, while <code>DomainModelsTests</code> has unit tests code.</p>
<p>In the sample project for this article, we have the <code>DomainModels</code> package with a single module: <code>DomainModels</code>. Apart from that, we have the <code>FeatureModules</code> package with multiple modules. High-level structure of this setup looks like this:</p>
<pre tabindex="0"><code>├── DomainModels
│   ├── DomainModels
│   └── DomainModelsTests
└── FeatureModules
    ├── Dashboard
    ├── DashboardTests
    ├── AccountOverview
    └── AccountOverviewTests
</code></pre><p>All modules in the <code>FeatureModules</code> package depend on the <code>DomainModels</code> module. Because of that, both <code>DashboardTests</code> and <code>AccountOverviewTests</code> will likely have some duplicate testing doubles. This is not ideal.</p>
<p>There are many possible solutions to that problem. The one that I like the most is creating a separate target in the module just for storing all of its testing doubles. Let’s implement it!</p>
<h2 id="solution">Solution</h2>
<p>The solution is fairly simple. We’ll just add the third target to the <code>DomainModels</code> module: <code>DomainModelsFakes</code> and move all the testing doubles from feature modules there.</p>
<blockquote>
<p>Although the <code>TestingDoubles</code> postfix may be more appropriate for these modules, I prefer the term <code>Fakes</code> as it conveys the same meaning and is shorter. This choice becomes more practical when considering that there will likely be a significant number of these modules.</p></blockquote>
<p>With this new addition, the <code>DomainModels</code> module consists of three targets:</p>
<ul>
<li><code>DomainModels</code></li>
<li><code>DomainModelsTests</code></li>
<li><code>DomainModelsFakes</code></li>
</ul>
<p>Here’s the package definition:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-swift" data-lang="swift"><span style="display:flex;"><span><span style="color:#75715e">// swift-tools-version: 5.7</span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">// The swift-tools-version declares the minimum version of Swift required to build this package.</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">import</span> <span style="color:#a6e22e">PackageDescription</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">let</span> package = Package(
</span></span><span style="display:flex;"><span>    name: <span style="color:#e6db74">&#34;DomainModels&#34;</span>,
</span></span><span style="display:flex;"><span>    platforms: [.iOS(.v16), .macOS(.v13)],
</span></span><span style="display:flex;"><span>    products: [
</span></span><span style="display:flex;"><span>        .library(
</span></span><span style="display:flex;"><span>            name: <span style="color:#e6db74">&#34;DomainModels&#34;</span>,
</span></span><span style="display:flex;"><span>            targets: [<span style="color:#e6db74">&#34;DomainModels&#34;</span>, <span style="color:#e6db74">&#34;DomainModelsFakes&#34;</span>]),
</span></span><span style="display:flex;"><span>    ],
</span></span><span style="display:flex;"><span>    dependencies: [],
</span></span><span style="display:flex;"><span>    targets: [
</span></span><span style="display:flex;"><span>        .target(
</span></span><span style="display:flex;"><span>            name: <span style="color:#e6db74">&#34;DomainModels&#34;</span>,
</span></span><span style="display:flex;"><span>            dependencies: []),
</span></span><span style="display:flex;"><span>        .target(
</span></span><span style="display:flex;"><span>            name: <span style="color:#e6db74">&#34;DomainModelsFakes&#34;</span>,
</span></span><span style="display:flex;"><span>            dependencies: [<span style="color:#e6db74">&#34;DomainModels&#34;</span>]),
</span></span><span style="display:flex;"><span>        .testTarget(
</span></span><span style="display:flex;"><span>            name: <span style="color:#e6db74">&#34;DomainModelsTests&#34;</span>,
</span></span><span style="display:flex;"><span>            dependencies: [<span style="color:#e6db74">&#34;DomainModels&#34;</span>, <span style="color:#e6db74">&#34;DomainModelsFakes&#34;</span>]),
</span></span><span style="display:flex;"><span>    ]
</span></span><span style="display:flex;"><span>)
</span></span></code></pre></div><p>The new target <code>DomainModelsFakes</code> depends on the <code>DomainModels</code> target. We also need to add it as a dependency to the test target to use the testing doubles.</p>
<p>With those changes in place, we can now add <code>DomainModelsFakes</code> as a dependency to feature modules testing targets:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-swift" data-lang="swift"><span style="display:flex;"><span><span style="color:#75715e">// swift-tools-version: 5.7</span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">// The swift-tools-version declares the minimum version of Swift required to build this package.</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">import</span> <span style="color:#a6e22e">PackageDescription</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">let</span> package = Package(
</span></span><span style="display:flex;"><span>    name: <span style="color:#e6db74">&#34;FeatureModules&#34;</span>,
</span></span><span style="display:flex;"><span>    platforms: [.iOS(.v16), .macOS(.v13)],
</span></span><span style="display:flex;"><span>    products: [
</span></span><span style="display:flex;"><span>        .library(
</span></span><span style="display:flex;"><span>            name: <span style="color:#e6db74">&#34;FeatureModules&#34;</span>,
</span></span><span style="display:flex;"><span>            targets: [<span style="color:#e6db74">&#34;Dashboard&#34;</span>, <span style="color:#e6db74">&#34;AccountOverview&#34;</span>]),
</span></span><span style="display:flex;"><span>    ],
</span></span><span style="display:flex;"><span>    dependencies: [
</span></span><span style="display:flex;"><span>        .package(path: <span style="color:#e6db74">&#34;../DomainModels&#34;</span>),
</span></span><span style="display:flex;"><span>    ],
</span></span><span style="display:flex;"><span>    targets: [
</span></span><span style="display:flex;"><span>        .target(
</span></span><span style="display:flex;"><span>            name: <span style="color:#e6db74">&#34;Dashboard&#34;</span>,
</span></span><span style="display:flex;"><span>            dependencies: [
</span></span><span style="display:flex;"><span>                .product(name: <span style="color:#e6db74">&#34;DomainModels&#34;</span>, package: <span style="color:#e6db74">&#34;DomainModels&#34;</span>),
</span></span><span style="display:flex;"><span>            ]),
</span></span><span style="display:flex;"><span>        .testTarget(
</span></span><span style="display:flex;"><span>            name: <span style="color:#e6db74">&#34;DashboardTests&#34;</span>,
</span></span><span style="display:flex;"><span>            dependencies: [
</span></span><span style="display:flex;"><span>                <span style="color:#e6db74">&#34;Dashboard&#34;</span>,
</span></span><span style="display:flex;"><span>                .product(name: <span style="color:#e6db74">&#34;DomainModelsFakes&#34;</span>, package: <span style="color:#e6db74">&#34;DomainModels&#34;</span>),
</span></span><span style="display:flex;"><span>          ]),
</span></span><span style="display:flex;"><span>        .target(
</span></span><span style="display:flex;"><span>            name: <span style="color:#e6db74">&#34;AccountOverview&#34;</span>,
</span></span><span style="display:flex;"><span>            dependencies: [
</span></span><span style="display:flex;"><span>                .product(name: <span style="color:#e6db74">&#34;DomainModels&#34;</span>, package: <span style="color:#e6db74">&#34;DomainModels&#34;</span>),
</span></span><span style="display:flex;"><span>            ]),
</span></span><span style="display:flex;"><span>        .testTarget(
</span></span><span style="display:flex;"><span>            name: <span style="color:#e6db74">&#34;AccountOverviewTests&#34;</span>,
</span></span><span style="display:flex;"><span>            dependencies: [
</span></span><span style="display:flex;"><span>                <span style="color:#e6db74">&#34;AccountOverview&#34;</span>,
</span></span><span style="display:flex;"><span>                .product(name: <span style="color:#e6db74">&#34;DomainModelsFakes&#34;</span>, package: <span style="color:#e6db74">&#34;DomainModels&#34;</span>),
</span></span><span style="display:flex;"><span>          ]),
</span></span><span style="display:flex;"><span>    ]
</span></span><span style="display:flex;"><span>)
</span></span></code></pre></div><h2 id="conclusion">Conclusion</h2>
<p>As our project grows, we may encounter problems such as code duplication, which can lead to increased complexity and difficulty with integration testing. In this article, we focused on the issue of testing doubles duplication and provided a solution to address it by creating a separate target in the module to store all testing doubles. By implementing this solution, we can reduce code duplication and improve the maintainability of our codebase. Do let me know if there are any other solutions worth trying out!</p>
<h2 id="resources">Resources</h2>
<ul>
<li><a href="https://www.youtube.com/watch?v=SXN1eWO5YtI" target="_blank" >“Fewer, Smarter, Faster: Scaling Testing @Spotify” by Vivian Santos &amp; Sami Bouchebaba</a> This talk is the where I’ve found this idea. See the video around 12:00 minute mark.</li>
<li><a href="https://martinfowler.com/bliki/TestDouble.html" target="_blank" >TestDouble</a></li>
</ul>
]]></content:encoded>
    </item>
    <item>
      <title>Kill It With Fire Review</title>
      <link>https://kunat.dev/notes/kill-it-with-fire-review/</link>
      <pubDate>Fri, 17 Mar 2023 13:42:13 +0100</pubDate>
      <guid>https://kunat.dev/notes/kill-it-with-fire-review/</guid>
      <description>&lt;p&gt;“Kill It with Fire” exceeded my expectations. I initially anticipated a boots-on-the-ground review of technical debt handling. What I’ve got instead was almost everything but that.&lt;/p&gt;
&lt;p&gt;The book has changed the way I think about technical debt and how to communicate it upwards. It provides sound advice on various topics, including how to fight tech debt, schedule maintenance work, and communicate with stakeholders. I have not come across any other book that provides this much credible advice on the topic of technical debt without exclusively catering to a particular audience.&lt;/p&gt;</description>
      <content:encoded><![CDATA[<p>“Kill It with Fire” exceeded my expectations. I initially anticipated a boots-on-the-ground review of technical debt handling. What I’ve got instead was almost everything but that.</p>
<p>The book has changed the way I think about technical debt and how to communicate it upwards. It provides sound advice on various topics, including how to fight tech debt, schedule maintenance work, and communicate with stakeholders. I have not come across any other book that provides this much credible advice on the topic of technical debt without exclusively catering to a particular audience.</p>
<h2 id="takeaways">Takeaways</h2>
<p>Fixing the wrong thing is bad, but leaving an unfinished attempt at fixing it is worse. Incomplete initiatives lead to confusing, poorly documented, and difficult-to-maintain systems. If you&rsquo;re intervening early enough that the team hasn&rsquo;t made significant changes yet, it&rsquo;s advisable to halt their efforts.</p>
<p>Occasional outages and system issues, particularly if they&rsquo;re resolved quickly and efficiently, can actually increase user trust and confidence.</p>
<p>Future-proofing systems doesn&rsquo;t entail building them to avoid the need for redesigns or migrations, as that&rsquo;s impossible. Instead, it means constructing and, more importantly, maintaining systems to prevent lengthy modernization projects that disrupt normal operations. The key to future-proofing is to make migrations and redesigns routine tasks that don&rsquo;t require a significant effort.</p>
<p>The more often engineers perform a task, the better they become at it, and the more likely they are to remember that they need to do it and plan accordingly.</p>
<p>It&rsquo;s not necessary to design for scale until you have the scale you need.</p>
<p>We are more productive when we can devote more time to work tasks. When we finish work tasks, any leftover time can be devoted to reducing our technical debt.</p>
<p>Legacy systems may seem like dead-end work to many software engineers, but the truth is that systems that aren&rsquo;t used get shut down. Working on legacy systems is vital because they control millions of people&rsquo;s lives in countless ways. It&rsquo;s not janitorial work, but rather that of a battlefield surgeon. It has been an immense privilege to work among them.</p>
]]></content:encoded>
    </item>
    <item>
      <title>How to Connect Internal Dependencies in Swift Package Manager</title>
      <link>https://kunat.dev/notes/spm-internal-dependencies/</link>
      <pubDate>Thu, 16 Mar 2023 14:30:23 +0100</pubDate>
      <guid>https://kunat.dev/notes/spm-internal-dependencies/</guid>
      <description>&lt;p&gt;Swift Package Manager (SPM) is a powerful tool for modularizing your code and managing dependencies in your projects. If you’re working on a project that uses SPM for modularization, you may need to add internal dependencies to connect different parts of your codebase. In this article, we’ll go over how to add internal dependencies in SPM and configure your &lt;code&gt;Package.swift&lt;/code&gt; file to properly connect them.&lt;/p&gt;
&lt;h2 id=&#34;assumptions&#34;&gt;Assumptions&lt;/h2&gt;
&lt;p&gt;Before we get started, let’s make a few assumptions about your project and its structure:&lt;/p&gt;</description>
      <content:encoded><![CDATA[<p>Swift Package Manager (SPM) is a powerful tool for modularizing your code and managing dependencies in your projects. If you’re working on a project that uses SPM for modularization, you may need to add internal dependencies to connect different parts of your codebase. In this article, we’ll go over how to add internal dependencies in SPM and configure your <code>Package.swift</code> file to properly connect them.</p>
<h2 id="assumptions">Assumptions</h2>
<p>Before we get started, let’s make a few assumptions about your project and its structure:</p>
<ul>
<li>You’re working on a project where modularization is done using SPM.</li>
<li>You have an SPM package that depends on other packages.</li>
</ul>
<p>Here’s how my project structure looks like:</p>
<p><img alt="Project structure" loading="lazy" src="/notes/images/timewise-project-structure.png"></p>
<h2 id="connecting-internal-dependencies">Connecting Internal Dependencies</h2>
<p>To connect the internal dependencies in your Package.swift file, you need to specify the dependencies for your package and the dependencies for your targets. Here&rsquo;s an example <code>Package.swift</code> file that configures the <code>FeatureModules</code> package with its internal dependencies:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-swift" data-lang="swift"><span style="display:flex;"><span><span style="color:#75715e">// swift-tools-version: 5.7</span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">// The swift-tools-version declares the minimum version of Swift required to build this package.</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">import</span> <span style="color:#a6e22e">PackageDescription</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">let</span> package = Package(
</span></span><span style="display:flex;"><span>    name: <span style="color:#e6db74">&#34;FeatureModules&#34;</span>,
</span></span><span style="display:flex;"><span>    platforms: [.iOS(.v16), .macOS(.v13)],
</span></span><span style="display:flex;"><span>    products: [
</span></span><span style="display:flex;"><span>        .library(
</span></span><span style="display:flex;"><span>            name: <span style="color:#e6db74">&#34;FeatureModules&#34;</span>,
</span></span><span style="display:flex;"><span>            targets: [<span style="color:#e6db74">&#34;Dashboard&#34;</span>]),
</span></span><span style="display:flex;"><span>    ],
</span></span><span style="display:flex;"><span>    dependencies: [
</span></span><span style="display:flex;"><span>        <span style="color:#75715e">// Dependencies declare other packages that this package depends on.</span>
</span></span><span style="display:flex;"><span>        <span style="color:#75715e">// .package(url: /* package url */, from: &#34;1.0.0&#34;),</span>
</span></span><span style="display:flex;"><span>        .package(path: <span style="color:#e6db74">&#34;../DomainModels&#34;</span>),
</span></span><span style="display:flex;"><span>        .package(path: <span style="color:#e6db74">&#34;../Resources&#34;</span>),
</span></span><span style="display:flex;"><span>        .package(path: <span style="color:#e6db74">&#34;../Shared&#34;</span>),
</span></span><span style="display:flex;"><span>        .package(path: <span style="color:#e6db74">&#34;../Storage&#34;</span>),
</span></span><span style="display:flex;"><span>    ],
</span></span><span style="display:flex;"><span>    targets: [
</span></span><span style="display:flex;"><span>        .target(
</span></span><span style="display:flex;"><span>            name: <span style="color:#e6db74">&#34;Dashboard&#34;</span>,
</span></span><span style="display:flex;"><span>            dependencies: [
</span></span><span style="display:flex;"><span>                .product(name: <span style="color:#e6db74">&#34;DomainModels&#34;</span>, package: <span style="color:#e6db74">&#34;DomainModels&#34;</span>),
</span></span><span style="display:flex;"><span>                .product(name: <span style="color:#e6db74">&#34;Resources&#34;</span>, package: <span style="color:#e6db74">&#34;Resources&#34;</span>),
</span></span><span style="display:flex;"><span>                .product(name: <span style="color:#e6db74">&#34;Shared&#34;</span>, package: <span style="color:#e6db74">&#34;Shared&#34;</span>),
</span></span><span style="display:flex;"><span>            ]),
</span></span><span style="display:flex;"><span>        .testTarget(
</span></span><span style="display:flex;"><span>            name: <span style="color:#e6db74">&#34;DashboardTests&#34;</span>,
</span></span><span style="display:flex;"><span>            dependencies: [
</span></span><span style="display:flex;"><span>                <span style="color:#e6db74">&#34;Dashboard&#34;</span>,
</span></span><span style="display:flex;"><span>                .product(name: <span style="color:#e6db74">&#34;Storage&#34;</span>, package: <span style="color:#e6db74">&#34;Storage&#34;</span>),
</span></span><span style="display:flex;"><span>          ]),
</span></span><span style="display:flex;"><span>    ]
</span></span><span style="display:flex;"><span>)
</span></span></code></pre></div><h2 id="conclusion">Conclusion</h2>
<p>Connecting internal dependencies in Swift Package Manager is a crucial step when working on modularized projects. By properly configuring the <code>Package.swift</code> file and specifying the dependencies, you can ensure that your code compiles and runs without any issues. Remember to use the .product declaration when specifying dependencies in your target, and to make sure that the package paths are correctly set in the <code>Package.swift</code> file. By following these steps, you can maintain a well-organized project structure and increase code reusability across your codebase.</p>
<h2 id="resources">Resources</h2>
<ul>
<li><a href="https://www.pointfree.co/episodes/ep171-modularization-part-1" target="_blank" >Episode #171: Modularization: Part 1 | Point-Free</a></li>
<li><a href="https://www.pointfree.co/episodes/ep172-modularization-part-2" target="_blank" >Episode #172: Modularization: Part 2 | Point-Free</a></li>
<li><a href="https://betterprogramming.pub/modularize-an-ios-app-with-spm-c3f51f03bb0b" target="_blank" >Modularize an iOS App With SPM | Better Programming</a></li>
</ul>
]]></content:encoded>
    </item>
    <item>
      <title>Recover Corrupted Apfs Drive</title>
      <link>https://kunat.dev/notes/recover-corrupted-apfs-drive/</link>
      <pubDate>Thu, 02 Mar 2023 11:47:55 +0100</pubDate>
      <guid>https://kunat.dev/notes/recover-corrupted-apfs-drive/</guid>
      <description>&lt;h1 id=&#34;how-to-recover-a-corrupted-apfs-encrypted-drive&#34;&gt;How to Recover a Corrupted APFS Encrypted Drive&lt;/h1&gt;
&lt;p&gt;As a Mac user, I have always felt secure knowing that my data is protected with APFS encryption. However, that sense of security was shattered when I was unable to mount a disk and Disk Utility reported it as corrupted. I couldn’t use Disk Utility’s First Aid as it failed to repair the disk. Here is the story of how I recovered a corrupted encrypted APFS drive.&lt;/p&gt;</description>
      <content:encoded><![CDATA[<h1 id="how-to-recover-a-corrupted-apfs-encrypted-drive">How to Recover a Corrupted APFS Encrypted Drive</h1>
<p>As a Mac user, I have always felt secure knowing that my data is protected with APFS encryption. However, that sense of security was shattered when I was unable to mount a disk and Disk Utility reported it as corrupted. I couldn’t use Disk Utility’s First Aid as it failed to repair the disk. Here is the story of how I recovered a corrupted encrypted APFS drive.</p>
<h2 id="trying-out-open-source-solution">Trying out Open-Source Solution</h2>
<p>I knew about paid solutions for data recovery, but I wanted to try looking for open-source solutions. I found a few candidates, including <a href="https://github.com/cugu/afro" target="_blank" >Afro</a>, a file recovery tool for APFS on GitHub. Unfortunately, none of them worked, and I was left with no viable solution.</p>
<h2 id="avoiding-the-99-fee">Avoiding the $99 fee</h2>
<p>There are a handful of apps that can recover files from encrypted APFS drives, but most of them cost around $99. I tried using trials of some of the most popular tools like R-Studio, but I was unable to fit all the files I cared about in the free trial quota.</p>
<h2 id="solutions">Solutions</h2>
<p>Right when I was about to give up and pay the $99 fee when I accidentally found a solution. <a href="https://www.cleverfiles.com" target="_blank" >Disk Drill</a> is part of <a href="https://setapp.com" target="_blank" >Setapp</a>, a subscription-based service that provides access to a collection of curated apps for Mac users. You can sign up for a free week trial and recover all your files using Disk Drill Pro. With Disk Drill, I was able to recover all my files without any issues.</p>
<h2 id="conclusion">Conclusion</h2>
<p>In conclusion, recovering a corrupted encrypted APFS drive can be a daunting task, but there are viable solutions available. While open-source solutions may seem tempting, they may not always work. Paid solutions can be expensive, but Setapp provides a more affordable option with access to a variety of useful apps, including Disk Drill.</p>
<p><em>tl;dr Sign up for a Setapp and use Disk Drill to recover your files for free.</em></p>
]]></content:encoded>
    </item>
    <item>
      <title>My Productivity Setup</title>
      <link>https://kunat.dev/notes/my-productivity-setup/</link>
      <pubDate>Sat, 18 Feb 2023 20:20:55 +0100</pubDate>
      <guid>https://kunat.dev/notes/my-productivity-setup/</guid>
      <description>&lt;p&gt;Today, I wanted to give you a high-level look at what tools I use to organize my work and life and how I use them. The setup I’m about to show you is a product of 5 years of refinement. I still tweak some things, so there’s a high chance it will evolve further in the coming months.&lt;/p&gt;
&lt;p&gt;Before we start, I wanted to mention that I’m deep into the Apple ecosystem. Some of the tools I’ll discuss might be Apple-specific, but I’m sure there are alternatives on other platforms. With that out of our way, let’s jump in!&lt;/p&gt;</description>
      <content:encoded><![CDATA[<p>Today, I wanted to give you a high-level look at what tools I use to organize my work and life and how I use them. The setup I’m about to show you is a product of 5 years of refinement. I still tweak some things, so there’s a high chance it will evolve further in the coming months.</p>
<p>Before we start, I wanted to mention that I’m deep into the Apple ecosystem. Some of the tools I’ll discuss might be Apple-specific, but I’m sure there are alternatives on other platforms. With that out of our way, let’s jump in!</p>
<h2 id="high-level-overview">High-level overview</h2>
<p>My productivity system revolves around three key tools: Notes, Reminders, and Streaks. Each of these serves a unique purpose and works together in harmony. In the following sections, I’ll provide a brief overview of each tool and share some of the most effective ways I use them in my workflows.</p>
<h2 id="reminders">Reminders</h2>
<p>For over a decade, I have relied on Apple’s Reminders app to keep track of my tasks. Although I’ve been curious about Todoist, I have yet to find a compelling reason to switch. In my Reminders workflow, I organize my tasks into four distinct lists:</p>
<p>I use four lists to group my TODO items:</p>
<ul>
<li><strong>General</strong>: This serves as the default list for all new reminders, many of which are created by Siri. I make sure that every item on this list is immediately actionable.</li>
<li><strong>Remember</strong>: I use this list to store all items scheduled for the future that are not yet actionable. By keeping these items separate from my immediate to-do list, I can maintain a clear mind and focus on the tasks at hand.</li>
<li><strong>Work</strong>: This list is a duplicate of the Remember list but exclusively for work-related tasks. I use it to manage my 9-5 job responsibilities.</li>
<li><strong>Scheduled</strong>: This list contains all recurring reminders, such as daily, weekly, or yearly tasks. By keeping them in a separate list, I can easily track their progress and ensure that they are completed on time.</li>
</ul>
<h3 id="shortcuts">Shortcuts</h3>
<p>Linking my reminders to  <a href="https://apple.fandom.com/wiki/Shortcuts" target="_blank" >Shortcuts</a>  has vastly improved my day-to-day experience with the Reminders app. This integration allows for triggering shortcuts using URL schemas on iOS, iPadOS, and macOS (watchOS is not currently supported). You can learn more about it  <a href="https://support.apple.com/en-euro/guide/shortcuts-mac/apda283236d7/mac" target="_blank" >here</a> .
There are several instances where Shortcuts integration perfectly fits my workflow. Here are a few examples:</p>
<ul>
<li>One of my daily reminders is to measure my weight in the morning. The linked Shortcut logs a sample for the Health app and marks the appropriate task in the Streaks app (more on that in the next section). The use-case is simple and saves me a few clicks every day.</li>
<li>Last year, I made meditation a daily habit. In this case, the shortcut starts a 20-minute timer, sets my Focus Mode to Do Not Disturb, and opens my daily session in my meditation app. Currently, I am using  <a href="https://www.wakingup.com/" target="_blank" >Waking Up</a> , but all other major meditation apps also support shortcuts to some extent. Again, this shortcut is simple and saves me a few clicks. Starting my mindfulness session is always one click away!
All other use cases follow the same pattern. As of now, I have eight recurring reminders that have a linked shortcut. Give it a try, it’s amazing!</li>
</ul>
<h3 id="siri">Siri</h3>
<p>Siri might not be the smartest voice assistant, but she does a great job scheduling reminders.</p>
<p>For me, there’s almost no time where I don’t wear my AirPods, or there is not HomePods around. Siri is available to me all the time. That’s why I started making a good use out of her.</p>
<p>There are a few most common scenarios</p>
<ul>
<li>Adding unscheduled things, like groceries:
I often notice that I’m running low on something while cooking. The easiest solution is to ask Siri to create reminder for me.</li>
</ul>
<blockquote>
<p>“Hey Siri, remind me to buy cereal.”</p></blockquote>
<ul>
<li>Adding scheduled items that need to be taken care of this day:</li>
</ul>
<blockquote>
<p>“Hey Siri, remind me to order X in 1 minute.”
This way, when I’m back at my desk, I have my TODOs list ready.</p></blockquote>
<h2 id="streaks">Streaks</h2>
<p><a href="https://streaks.app" target="_blank" >Streaks app</a> is one of my favorite pieces of software I use daily. It’s my first step when creating new habits, breaking bad ones or maintaining the good ones.</p>
<blockquote>
<p>Streaks is the to-do list that helps you form good habits. Every day you complete a task, your streak is extended.</p></blockquote>
<p>Most times, I maintain 12-18 habits. What I found to work the best is to keep a list of 12 TODOs and 6 TODONTs max. Anything else than that feels overwhelming. The time to complete for each streak varies. The shortest ones (e.g. “Take Meds”) take a few seconds, while others  (e.g. “Practice LeetCode Challenges”) take up to 45 mins.</p>
<h2 id="notes">Notes</h2>
<p>Notes are the final piece of the puzzle for me. I use them to store everything that doesn’t fit into Reminders or Streaks. I use them for daily plannings, weekly retorts, planning projects, storing information about things, and many others. In the following sections I’ll describe some of my most common use cases.</p>
<p>I won’t cover my high level system for organizing notes in this article. Maybe next time! I’ll say that I follow the PARA methodology. You can learn more about it in the <a href="https://www.buildingasecondbrain.com/book" target="_blank" >Building a Second Brain book</a>.</p>
<h3 id="app">App</h3>
<p>I’ve been a <a href="https://bear.app" target="_blank" >Bear Notes</a> user for about three years. It’s not perfect, but it gets the job done. Despite its quirks, I enjoy using it. If Was I starting today, I’d consider using <a href="https://obsidian.md/" target="_blank" >Obsidian</a>. It has some features I miss in Bear Notes, but it’s still far from getting me to move all my notes there.</p>
<p>Notion was never an option for me because of the proprietary file format. I like  to keep my notes in Markdown.</p>
<h3 id="daily-todos">Daily TODOs</h3>
<p>That’s how I start my day. I have a note titled “✅ TODO ✅” where I put all my TODOs for the day. I usually split them into two sections: work-related and everything else. Here’s an example day:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-markdown" data-lang="markdown"><span style="display:flex;"><span><span style="color:#75715e">## 16/02/2023
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span>::Finished writing My Productivity Setup:: // 1-line summary of the day
</span></span><span style="display:flex;"><span>// First section: work-related tasks
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">- [ ]</span> Follow up with sam about missing translations
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">- [ ]</span> Review open PRs
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>// Second section: everything else
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">- [ ]</span> Finish writing My Productivity Setup
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">- [ ]</span> Pay bills
</span></span></code></pre></div><h3 id="backlog">Backlog</h3>
<p>For tasks that do not have a specific deadline, I maintain a separate note titled “Task Backlog.” It contains two sections where I group all the tasks that I might consider doing in the future. From past experience, I know that many of these tasks remain incomplete. To avoid clutter, I purge this list every few months.</p>
<h3 id="retros">Retros</h3>
<p>A few years ago, I began conducting yearly retrospectives where I would summarize the past year in a few paragraphs. These retrospectives helped me identify my biggest accomplishments, failures, and overall reflections on the past year. I found them to be a great way to assess my progress towards achieving my goals.
However, I realized that more frequent retrospectives might be beneficial. With a yearly schedule, I often forgot about the goals I had set for myself. To address this, I now conduct weekly retrospectives that take no more than 15 minutes each Sunday. Here’s the note template that I use:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-markdown" data-lang="markdown"><span style="display:flex;"><span># 2023 7/52 // year and week number
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">## Top Priority
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span>* 
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">### Success factors
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span>* 
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">## Biggest Wins
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span>* 
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">## Last Week Reflection
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span>* 
</span></span></code></pre></div><h4 id="top-priority">Top Priority</h4>
<p>Your weekly goal can be anything you want it to be. It can be a system or a specific objective. The only requirement is that it needs to be achievable within a week. If a given task cannot be completed within a week, I try to break it down into smaller assignments. Here are a couple of examples from previous weeks:</p>
<ul>
<li>Dedicate four hours to a side-project</li>
<li>Write a book review for <em>TITLE</em></li>
<li>Learn a new skill or take an online course</li>
</ul>
<p>The possibilities are endless.</p>
<h4 id="success-factors">Success Factors</h4>
<p>ChatGPT:</p>
<blockquote>
<p>Success factors are the key elements or variables that contribute to achieving a desired outcome or goal. These factors can be internal or external to an individual, organization, or project, and may vary depending on the context and the specific goals being pursued.</p></blockquote>
<h4 id="biggest-wins">Biggest Wins</h4>
<p>As the name suggests, this section is dedicated to listing my most significant achievements from the past week.</p>
<h4 id="last-week-reflection">Last Week Reflection</h4>
<p>This section serves as a catch-all for my thoughts and experiences over the past week. I typically spend a few minutes jotting down everything that has been on my mind and then expand on those ideas. Personally, I find this exercise to be quite therapeutic.</p>
<h2 id="resources">Resources</h2>
<ul>
<li><a href="https://www.buildingasecondbrain.com/book" target="_blank" >Building a Second Brain book</a></li>
</ul>
<h2 id="conclusion">Conclusion</h2>
<p>That’s it! That’s the high level overview of my productivity setup.</p>
]]></content:encoded>
    </item>
    <item>
      <title>Handling Edit Menus Without the First Responder</title>
      <link>https://kunat.dev/notes/edit-menu-no-first-responder/</link>
      <pubDate>Tue, 31 Jan 2023 21:12:56 +0100</pubDate>
      <guid>https://kunat.dev/notes/edit-menu-no-first-responder/</guid>
      <description>&lt;h2 id=&#34;context&#34;&gt;Context&lt;/h2&gt;
&lt;p&gt;I&amp;rsquo;m currently developing a chat-based app that requires the implementation of coping messages. Essentially, when a user long-presses a message bubble, an edit menu will appear with the option to &amp;ldquo;Copy.&amp;rdquo; This feature is a common element across most chat applications.&lt;/p&gt;
&lt;p&gt;For the purpose of this article, let&amp;rsquo;s assume that all message bubbles are constructed using a subclass of UITextView called MessageTextView. To make the &amp;ldquo;Copy&amp;rdquo; option appear on a long-press gesture, we&amp;rsquo;ll first need to add the appropriate gesture recognizer:&lt;/p&gt;</description>
      <content:encoded><![CDATA[<h2 id="context">Context</h2>
<p>I&rsquo;m currently developing a chat-based app that requires the implementation of coping messages. Essentially, when a user long-presses a message bubble, an edit menu will appear with the option to &ldquo;Copy.&rdquo; This feature is a common element across most chat applications.</p>
<p>For the purpose of this article, let&rsquo;s assume that all message bubbles are constructed using a subclass of UITextView called MessageTextView. To make the &ldquo;Copy&rdquo; option appear on a long-press gesture, we&rsquo;ll first need to add the appropriate gesture recognizer:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-swift" data-lang="swift"><span style="display:flex;"><span><span style="color:#66d9ef">let</span> textView = MessageTextView()
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">let</span> longPressGesture = UILongPressGestureRecognizer(
</span></span><span style="display:flex;"><span>    target: <span style="color:#66d9ef">self</span>, 
</span></span><span style="display:flex;"><span>    action: <span style="color:#66d9ef">#selector</span>(handleLongPressGesture(recognizer:)))
</span></span><span style="display:flex;"><span>textView.addGestureRecognizer(longPressGesture)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">@objc</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">func</span> <span style="color:#a6e22e">handleLongPressGesture</span>(recognizer: UIGestureRecognizer) {}
</span></span></code></pre></div><p>Next, we&rsquo;ll move on to the second step, which involves implementing the handleLongPressGesture method:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-swift" data-lang="swift"><span style="display:flex;"><span><span style="color:#75715e">// 1</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">guard</span> recognizer.state == .began,
</span></span><span style="display:flex;"><span>      <span style="color:#66d9ef">let</span> recognizerView = recognizer.view,
</span></span><span style="display:flex;"><span>      <span style="color:#66d9ef">let</span> superview = recognizerView.superview <span style="color:#66d9ef">else</span> { <span style="color:#66d9ef">return</span> }
</span></span><span style="display:flex;"><span><span style="color:#75715e">// 2</span>
</span></span><span style="display:flex;"><span>textView?.becomeFirstResponder()
</span></span><span style="display:flex;"><span><span style="color:#75715e">// 3</span>
</span></span><span style="display:flex;"><span>UIMenuController.shared.showMenu(from: superview, rect: recognizerView.frame)
</span></span></code></pre></div><p>Let&rsquo;s now break down the steps necessary to implement this feature:</p>
<ol>
<li>First, we need to obtain the recognizerView and its parent view from the recognizer. We&rsquo;ll need both of these to properly position the edit menu on the screen.</li>
<li>Next, we&rsquo;ll make the edit menu the first responder to ensure that it appears on the screen.</li>
<li>Finally, we&rsquo;ll show the menu.</li>
</ol>
<p>Note that at this point, all available options will be displayed, so we still need to filter out any unnecessary edit menu options. To do this, we&rsquo;ll need to override two methods in our MessageTextView:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-swift" data-lang="swift"><span style="display:flex;"><span><span style="color:#66d9ef">override</span> <span style="color:#66d9ef">public</span> <span style="color:#66d9ef">func</span> <span style="color:#a6e22e">canPerformAction</span>(
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">_</span> action: Selector, 
</span></span><span style="display:flex;"><span>    withSender sender: Any?) -&gt; Bool {
</span></span><span style="display:flex;"><span>    <span style="color:#75715e">// 1</span>
</span></span><span style="display:flex;"><span>    action == <span style="color:#66d9ef">#selector</span>(copy(<span style="color:#66d9ef">_</span>:))
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">@objc</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">override</span> <span style="color:#66d9ef">public</span> <span style="color:#66d9ef">func</span> <span style="color:#a6e22e">copy</span>(<span style="color:#66d9ef">_</span> sender: Any?) {
</span></span><span style="display:flex;"><span>    <span style="color:#75715e">// 2</span>
</span></span><span style="display:flex;"><span>    UIPasteboard.general.string = text
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><ol>
<li>We filter out all other options, except for &ldquo;copy&rdquo;.</li>
<li>As per Apple&rsquo;s <a href="https://developer.apple.com/documentation/uikit/uiresponderstandardeditactions/2354191-copy#" target="_blank" >documentation</a>:</li>
</ol>
<blockquote>
<p>UIKit calls this method when the user selects the Copy command from an editing menu. Your implementation should write the selected content to the pasteboard without removing the selection from your interface.</p></blockquote>
<p>At first glance, it may seem like we&rsquo;ve completed the implementation of this feature. However, there&rsquo;s one more detail that we need to address. Currently, if the keyboard is visible on the screen, the edit menu will be displayed in the wrong location. This occurs because we&rsquo;re calling <code>showMenu(from:rect:)</code> while the keyboard is still visible. Once the keyboard hides, the layout of the conversation view (i.e. the screen where all the messages are displayed) will likely change.</p>
<h2 id="handling-keyboard-events">Handling keyboard events</h2>
<p>One solution to present the edit menu without hiding the keyboard is by using the <code>UIResponder.next</code> property, as described in <a href="https://betterprogramming.pub/uimenucontroller-and-manipulating-the-responder-chain-c06fad73c64b" target="_blank" >this article</a>. However, in my case, this is not feasible due to the modular architecture of the app I&rsquo;m working on. The message composer (the view where the user composes a message) and conversation view (the view where all the message bubbles are displayed) are separate modules, and iOS doesn&rsquo;t provide an easy way to obtain a reference to the current first responder.</p>
<p>Therefore, I found that the best solution for this scenario is to listen for the <code>UIResponder.keyboardDidHideNotification</code> and update the edit menu accordingly. To implement this in <code>MessageTextView</code>:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-swift" data-lang="swift"><span style="display:flex;"><span><span style="color:#75715e">// Text view subclass</span>
</span></span><span style="display:flex;"><span>NotificationCenter.<span style="color:#66d9ef">default</span>.addObserver(
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">self</span>,
</span></span><span style="display:flex;"><span>    selector: <span style="color:#66d9ef">#selector</span>(handleKeyboardDidHideNotification(notification:)),
</span></span><span style="display:flex;"><span>    name: UIResponder.keyboardDidHideNotification,
</span></span><span style="display:flex;"><span>    object: <span style="color:#66d9ef">nil</span>)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">@objc</span>
</span></span><span style="display:flex;"><span>fileprivate <span style="color:#66d9ef">func</span> <span style="color:#a6e22e">handleKeyboardDidHideNotification</span>(notification: Notification) {
</span></span><span style="display:flex;"><span>     UIMenuController.shared.update()
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><h2 id="conclusion">Conclusion</h2>
<p>That’s it! The edit menu will update its position when keyboard hides.</p>
<h3 id="notes">Notes</h3>
<ul>
<li><code>UIMenuController</code> has been deprecated in iOS 16. See <a href="https://developer.apple.com/documentation/uikit/uieditmenuinteraction" target="_blank" >UIEditMenuInteraction</a>.</li>
<li>Remember to check out <a href="https://betterprogramming.pub/uimenucontroller-and-manipulating-the-responder-chain-c06fad73c64b" target="_blank" >Using the UIMenuController and Manipulating the Responder Chain</a></li>
</ul>
]]></content:encoded>
    </item>
    <item>
      <title>Alfred Snippets on iOS</title>
      <link>https://kunat.dev/notes/alfred-snippets-on-ios/</link>
      <pubDate>Wed, 25 Jan 2023 23:59:59 +0100</pubDate>
      <guid>https://kunat.dev/notes/alfred-snippets-on-ios/</guid>
      <description>&lt;p&gt;&lt;a href=&#34;https://www.alfredapp.com&#34; target=&#34;_blank&#34; &gt;Alfred&lt;/a&gt; allows you to save your frequently used text clips as Snippets. Over the past few years I’ve created hundreds of them.&lt;/p&gt;
&lt;p&gt;&lt;img alt=&#34;hello there&#34; loading=&#34;lazy&#34; src=&#34;https://kunat.dev/notes/images/alfred-snippets.png&#34;&gt;&lt;/p&gt;
&lt;p&gt;There’s one problem. Alfred snippets only work on your Mac. Today we’ll focus on how we can convert them into macOS snippets. That way, you’ll be able to use them across all your Apple devices!&lt;/p&gt;
&lt;h2 id=&#34;how&#34;&gt;How&lt;/h2&gt;
&lt;p&gt;Alfred stores snippets as a Zip archive. On macOS, you can open it with the Archive Utility app that’s shipped with the system. After successfully exporting it, you’ll need to convert it from Alfred’s data format to the one used by the system snippets. macOS allows to import keyboard snippets in bulk with Property List format files (&lt;code&gt;plist&lt;/code&gt;).&lt;/p&gt;</description>
      <content:encoded><![CDATA[<p><a href="https://www.alfredapp.com" target="_blank" >Alfred</a> allows you to save your frequently used text clips as Snippets. Over the past few years I’ve created hundreds of them.</p>
<p><img alt="hello there" loading="lazy" src="/notes/images/alfred-snippets.png"></p>
<p>There’s one problem. Alfred snippets only work on your Mac. Today we’ll focus on how we can convert them into macOS snippets. That way, you’ll be able to use them across all your Apple devices!</p>
<h2 id="how">How</h2>
<p>Alfred stores snippets as a Zip archive. On macOS, you can open it with the Archive Utility app that’s shipped with the system. After successfully exporting it, you’ll need to convert it from Alfred’s data format to the one used by the system snippets. macOS allows to import keyboard snippets in bulk with Property List format files (<code>plist</code>).</p>
<p>Here’s how Alfred stores snippets:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-json" data-lang="json"><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">&#34;alfredsnippet&#34;</span> : {
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">&#34;snippet&#34;</span> : <span style="color:#e6db74">&#34;🐛&#34;</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">&#34;uid&#34;</span> : <span style="color:#e6db74">&#34;81F59CDA-FD1E-4D71-AB4C-E4C47212023B&#34;</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">&#34;name&#34;</span> : <span style="color:#e6db74">&#34;bug&#34;</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">&#34;keyword&#34;</span> : <span style="color:#e6db74">&#34;bug&#34;</span>
</span></span><span style="display:flex;"><span>  }
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>… and here’s how macOS stores snippets:</p>
<pre tabindex="0"><code class="language-plist" data-lang="plist">&lt;dict&gt;
	&lt;key&gt;phrase&lt;/key&gt;
	&lt;string&gt;🐛&lt;/string&gt;
	&lt;key&gt;shortcut&lt;/key&gt;
	&lt;string&gt;:bug&lt;/string&gt;
&lt;/dict&gt;
</code></pre><h3 id="steps">Steps</h3>
<ol>
<li>Export a snippet collection from Alfred
<ul>
<li>Alfred -&gt; Features -&gt; Snippets -&gt; Collection -&gt; <em>right-click</em> -&gt; Export</li>
</ul>
</li>
</ol>
<blockquote>
<p>macOS does not allow us to group snippets into collections</p></blockquote>
<ol start="2">
<li>Open the exported file using the Archive Utility app</li>
</ol>
<ul>
<li><em>right-click</em> -&gt; Open With -&gt; Archive Utility</li>
</ul>
<ol start="3">
<li>Convert files from Alfred’s format (<code>json</code>) to macOS format (<code>plist</code>)</li>
</ol>
<blockquote>
<p>For this step I use a script written in Swif. You can download it here: <a href="https://github.com/bkunat/snippet-converter" target="_blank" >bkunat/snippet-converter</a>. Should you decide to use it, please follow instructions in the <code>main.swift</code> file.</p></blockquote>
<ol start="4">
<li>Import the snippets into <code>/System/Library/PreferencePanes/Keyboard.prefPane</code></li>
</ol>
<ul>
<li>System Settings -&gt; Keyboard -&gt; Text Replacements -&gt; <em>drag&amp;drop the created <code>plist</code> file</em></li>
</ul>
<h2 id="notes">Notes</h2>
<ul>
<li>All devices need to be signed into a single iCloud account. Otherwise, macOS won’t sync your snippets.</li>
<li>macOS 13.2</li>
<li>Alfred 5.0.6</li>
</ul>
]]></content:encoded>
    </item>
    <item>
      <title>Projects</title>
      <link>https://kunat.dev/projects/</link>
      <pubDate>Mon, 01 Jan 0001 00:00:00 +0000</pubDate>
      <guid>https://kunat.dev/projects/</guid>
      <description>Selected projects</description>
      <content:encoded><![CDATA[<div class="projects-page">
I like building tools that smooth out daily workflows. Here are a few highlights.
<div class="projects-grid">
  <a class="project-card" href="https://github.com/bkunat/AlfredSnippetConverter" target="_blank" rel="noopener">
    <div class="project-card__eyebrow">macOS · Swift · CLI</div>
    <h3>Alfred Snippet Converter</h3>
    <p>Convert Alfred Text Snippets into native macOS text replacements with both a drag-and-drop app and a CLI that handles multiple collections.</p>
    <div class="project-card__meta">
      <span class="project-card__pill">Merge or split outputs</span>
      <span class="project-card__pill">Zip & folder inputs</span>
      <span class="project-card__pill">macOS 13+</span>
    </div>
    <div class="project-card__cta">View on GitHub →</div>
  </a>
  <a class="project-card" href="https://github.com/bkunat/platinum-pulse" target="_blank" rel="noopener">
    <div class="project-card__eyebrow">TypeScript · Fastify · React</div>
    <h3>Platinum Pulse</h3>
    <p>An unofficial, Dockerized dashboard that aggregates live gym occupancy, historical trends, and predictions with an API plus a React frontend.</p>
    <div class="project-card__meta">
      <span class="project-card__pill">Geolocation sorting</span>
      <span class="project-card__pill">Automated polling</span>
      <span class="project-card__pill">SQLite storage</span>
    </div>
    <div class="project-card__cta">View on GitHub →</div>
  </a>
  <a class="project-card" href="https://github.com/bkunat/instant-platinum" target="_blank" rel="noopener">
    <div class="project-card__eyebrow">iOS · watchOS · SwiftUI</div>
    <h3>Instant Platinum</h3>
    <p>An unofficial native iOS and watchOS companion for instant access to gym QR codes, visit history, and statistics with a dark-mode first design.</p>
    <div class="project-card__meta">
      <span class="project-card__pill">Shared auth across devices</span>
      <span class="project-card__pill">Activity tracking</span>
      <span class="project-card__pill">Offline friendly UI</span>
    </div>
    <div class="project-card__cta">View on GitHub →</div>
  </a>
  <a class="project-card" href="https://github.com/bkunat/SafariReaderModeSample" target="_blank" rel="noopener">
    <div class="project-card__eyebrow">Safari Extension · Swift</div>
    <h3>Safari Reader Mode Sample</h3>
    <p>A Safari App Extension prototype that injects JS to extract article content and display it in a distraction-free popover, Reader Mode style.</p>
    <div class="project-card__meta">
      <span class="project-card__pill">Toolbar button action</span>
      <span class="project-card__pill">JSON-LD parsing</span>
      <span class="project-card__pill">macOS 12+ / Xcode 15+</span>
    </div>
    <div class="project-card__cta">View on GitHub →</div>
  </a>
</div>
</div>
]]></content:encoded>
    </item>
  </channel>
</rss>
