lgug2z
https://lgug2z.com
ZolaenThu, 22 Jan 2026 17:59:40 -0700I Started Identifying Corporate Devices in My SoftwareThu, 22 Jan 2026 17:59:40 -0700lgug2z
https://lgug2z.com/articles/i-started-identifying-corporate-devices-in-my-software/
https://lgug2z.com/articles/i-started-identifying-corporate-devices-in-my-software/<p><a href="https://lgug2z.com/articles/normalize-identifying-corporate-devices-in-your-software/">Normalize Identifying Corporate Devices in Your
Software</a>
did the numbers on <a href="https://news.ycombinator.com/item?id=45747938">HackerNews</a>
and <a href="https://lobste.rs/s/vd7dgj/normalize_identifying_corporate">Lobsters</a> back
in October.</p>
<p>There were a couple of great comments from people who "got it", and it also
inspired people to start <a href="https://bnb.im/posts/is-mdm/">creating libraries to detect MDM
enrollment</a>.</p>
<div class="notado-quote"
style="border: 1px solid var(--border-color);
background-color: var(--bg-primary) !important;
position: relative;
margin-block: 1em;
border-radius: 5px;
padding-left: 1rem;
padding-right: 1rem;
padding-top: 1.25rem;
padding-bottom: 1.25rem;
margin-bottom: 0em;
border-bottom-right-radius: 0px !important;
border-bottom-left-radius: 0px !important;
">
<div style="padding-bottom: 1.25rem">
<div style="display: flex; gap: 0.75rem">
<div style="min-width: 0;
flex: 1 1 0%;
display: flex;
flex-direction: column;
justify-content: center">
<p style="text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
margin: 0em">
henrycatalinismith
</p>
<p style="color: var(--text-1); margin: 0em">
<a href="https://lobste.rs/s/vd7dgj/normalize_identifying_corporate#c_ufrklw">lobste.rs</a>
</p>
</div>
<div style="flex-shrink: 0;
display: flex;
flex-direction: row-reverse;
align-items: center">
<a class="notado-icon" href="https://notado.app" style="border: none">
<img style="height: 3rem;
width: 3rem"
src="https://notado.app/static/notado-icon.png"
alt="notado" />
</a>
</div>
</div>
</div>
<div>Extremely poetic to use detection of the presence of corporate spyware as leverage for redistribution of wealth, love this.</div>
</div>
<div class="notado-quote-caption"
style="border: 1px solid var(--border-color);
background-color: var(--bg-2) !important;
position: relative;
margin-bottom: 1em;
border-bottom-left-radius: 5px;
border-bottom-right-radius: 5px;
padding-left: 1rem;
padding-right: 1rem;
padding-top: 0.5rem;
padding-bottom: 0.5rem">
Someone who gets it
</div>
<p>Predictably, there was also a great deal of naysaying. And once again,
predictably, it was all unfounded.</p>
<p>In <a href="https://lgug2z.com/articles/on-open-source-mythology/">On Open Source
Mythology</a> I contrasted
the popular myths that a project will not see adoption or contributions without
using an OSI-approved license with real data points from my experience building
<a href="https://github.com/LGUG2Z/komorebi">komorebi</a>.</p>
<p>In this article, I will contrast the myth that attempting to identify corporate
devices will have a net negative impact on a project with real data points from
my experience implementing corporate device identification in komorebi.</p>
<ul>
<li>
<p>Corporate device identification was rolled out in <a href="https://github.com/LGUG2Z/komorebi/releases/tag/v0.1.39">komorebi
v0.1.39</a> on 12/10/2025</p>
</li>
<li>
<p>In addition to the release notes, I included a prominent explanation <a href="https://github.com/LGUG2Z/komorebi?tab=readme-ov-file#note-students-using-devices-enrolled-in-mobile-device-management-mdm">at the
top of the project
README</a>
for the largest group of non-corporate users enrolled in some form of MDM who
would likely see a prompt</p>
</li>
<li>
<p>There have been <a href="https://tooomm.github.io/github-release-stats/?username=LGUG2Z&repository=komorebi">around 17k downloads of
v0.1.39</a>
just over a month later, which looks consistent with the download numbers of
previous releases</p>
</li>
<li>
<p>There have been 26 new annual Individual Commerical Use License (ICUL)
purchases since v0.1.39 rolled out - as I write this post there are 118 active
ICUL subscriptions which represent every subscription, monthly and annual,
created and maintained since January 2025</p>
</li>
<li>
<p>These 26 post-v0.1.39 ICUL subscriptions represent over 20% of the total
number of current active ICUL subscriptions</p>
</li>
<li>
<p>I have added overrides for 43 users who run the software on devices either
provided by their educational institutions, or which require BYOD MDM
enrollment to access university resources</p>
</li>
<li>
<p>I have not kept track of the exact number of people who were surprised to see
that their devices were enrolled in some form of BYOD MDM without their
knowledge <a href="https://bsky.app/profile/lgug2z.com/post/3mbmnlwhgxk2y">due to dark UI
patterns</a>, but it is
significantly higher than I would have expected, and the Discord community is
now helping people in this situation take back control of their personal
devices</p>
</li>
</ul>
<p>On the whole, this has been a very pleasant experience for me as a maintainer.</p>
<p>It has been so great to exchange emails and have real interpersonal interactions
both with students around the world using my software and corporate users who
have recently purchased licenses.</p>
<p>The feedback from users has been unanimously supportive, and many people in the
wider software development community have reached out both publicly and
privately to congratulate me on the positive outcome of this experiment.</p>
<p>One user even reached out to let me know after I had tweaked the corporate
device identification heuristics on a nightly release that it was no longer
detecting his corporate device so that I could fix this regression.</p>
<p>As a result of the success of corporate device identification in komorebi, this
month I have made enough money from this project to cover my rent for the first
time since I wrote the first line of code for komorebi's predecessor
<a href="https://github.com/LGUG2Z/yatta">yatta</a> in 2020.</p>
<p>(If you're interested in the financials of the project, check out the <a href="https://lgug2z.com/articles/komorebi-financial-breakdown-for-2025/">2025
financial
breakdown</a>)</p>
<p>Perhaps this is not such an eye-catching feat when we see AI startups raising
tens and hundreds of millions of dollars every other week, but it is a
significant milestone for me.</p>
<p>I believe this milestone holds also holds a special importance in proving out a
viable alternative for independent software developers to the open source model
of:</p>
<ul>
<li>Arbitrarily setting the Exchange-Value of their works to zero through fear of
becoming excommunicado in their software social circles</li>
<li>Watching corporations exploit the <a href="https://www.bloomberg.com/opinion/articles/2024-02-26/open-source-software-is-worth-a-lot-more-than-you-pay-for-it">incredible
Use-Value</a>
of their works and begging for scraps</li>
<li>Relying on mutual aid in the form of sponsorships from people who themselves
are usually also a layoff and a medical emergency away from financial ruin</li>
</ul>
<p>So what's next?</p>
<ul>
<li>I'll be looking at ways to improve the experience of implementing overrides
for students</li>
<li><a href="https://www.youtube.com/watch?v=u3eJcsa_MJk">komorebi for Mac</a> will be made
public before the end of January 2026 and will include corporate device
identification from the very first release</li>
<li>I'll continue working behind the scenes with independent software developers
who are interested exploring the post-open source licensing space</li>
</ul>
<hr />
<p>If you have any questions or comments you can reach out to me on
<a href="https://bsky.app/profile/lgug2z.com">Bluesky</a> and
<a href="https://hachyderm.io/@LGUG2Z">Mastodon</a>.</p>
<p>If you're interested in what I read to come up with software like komorebi, you
can subscribe to my <a href="https://notado.app/feeds/jado/software-development">Software Development RSS
feed</a>.</p>
<p>If you'd like to watch me writing code while explaining what I'm doing, you can
also <a href="https://www.youtube.com/channel/UCeai3-do-9O4MNy9_xjO6mg?sub_confirmation=1">subscribe to my YouTube
channel</a>.</p>
<p>If you would like early access to <a href="https://www.youtube.com/watch?v=u3eJcsa_MJk">komorebi for
Mac</a>, you can sponsor me on
<a href="https://github.com/sponsors/LGUG2Z">GitHub</a>.</p>
komorebi Financial Breakdown for 2025Sun, 04 Jan 2026 13:28:07 -0700lgug2z
https://lgug2z.com/articles/komorebi-financial-breakdown-for-2025/
https://lgug2z.com/articles/komorebi-financial-breakdown-for-2025/<p>For the past two years (<a href="../github-sponsorship-breakdown-for-2023">2023</a>,
<a href="../github-sponsorship-breakdown-for-2024">2024</a>) I have been sharing annual
sponsorship breakdowns for <a href="https://github.com/LGUG2Z/komorebi">komorebi</a>.</p>
<p>komorebi is published under the <a href="https://github.com/LGUG2Z/komorebi-license">Komorebi
License</a> (building on the foundation
laid by the <a href="https://polyformproject.org/licenses/">PolyForm Project</a>), an
<a href="../educational-source-software">educational source</a> license which does not
permit any kind of commercial use.</p>
<p>In January 2025 I introduced the long-requested <a href="https://lgug2z.com/software/komorebi">Individual Commercial Use
License</a> (ICUL), which will also be factored in
to this year's financial breakdown.</p>
<h2 id="the-numbers"><a class="zola-anchor" href="#the-numbers" aria-label="Anchor link for: the-numbers">The Numbers</a></h2>
<p>In 2025 I received <strong>$12,070</strong> for my work on komorebi.</p>
<table><thead><tr><th>Source</th><th>Total</th><th>Subscribers (EOY)</th></tr></thead><tbody>
<tr><td>ICUL</td><td>$7877</td><td>95</td></tr>
<tr><td>GitHub Sponsors</td><td>$3629</td><td>67</td></tr>
<tr><td>Ko-Fi</td><td>$358</td><td>4</td></tr>
<tr><td>YouTube</td><td>$206</td><td>6328</td></tr>
</tbody></table>
<h2 id="github-sponsors"><a class="zola-anchor" href="#github-sponsors" aria-label="Anchor link for: github-sponsors">GitHub Sponsors</a></h2>
<p>Within a couple of months of the availability of the ICUL, the number of license
holders had surpassed the number of GitHub Sponsors, and this trend shows no
sign of reversing.</p>
<p>Generally I am happy with this because I would much rather that the majority of
funding for komorebi come from corporate expense reimbursements than the
salaries of workers.</p>
<p>In the last quarter of 2025 I started working on komorebi for Mac and made early
access available as a perk to GitHub sponsors on the $10/month and $20/month
tiers, which accounts for a significant portion of the $3629 figure attributed
to GitHub Sponsors.</p>
<p>There were a number of people who generously paid for 12 months of the
sponsorship tier up-front (I didn't know this was even possible until recently).</p>
<p>There were also many curious people who paid $10/month for one month of access
before cancelling who either 1) found the current build stable enough to last
them until the public release or 2) felt that komorebi's model wasn't a good fit
for their workflow.</p>
<p>I think there is definitely more to explore in the area of giving sponsors early
access to nightly releases and WIP features before they become generally
available to the public in an official versioned release.</p>
<p>I am convinced that Sponsors remains GitHub's killer feature at this point, and
that this is a table-stakes feature for any serious competitor in this space.</p>
<h2 id="youtube"><a class="zola-anchor" href="#youtube" aria-label="Anchor link for: youtube">YouTube</a></h2>
<p>I made fewer videos this year and spent time exploring different formats which
were more time consuming, but more personally rewarding for me.</p>
<p>The videos which got the most views (and ad revenue) were the scripted tutorial
and quickstart videos, which I guess I should probably put more time into this
year.</p>
<p>I'm less interested in YouTube as a source of funding for komorebi now and more
interested in the creative medium of video.</p>
<h2 id="ko-fi"><a class="zola-anchor" href="#ko-fi" aria-label="Anchor link for: ko-fi">Ko-Fi</a></h2>
<p>I mainly introduced Ko-Fi upon request when GitHub Sponsors made some changes
which made it difficult for users in Europe to continue sponsoring on that
platform.</p>
<p>It has a nice integration with Discord, but outside of that I didn't really
engage much with it.</p>
<h2 id="individual-commercial-use-license"><a class="zola-anchor" href="#individual-commercial-use-license" aria-label="Anchor link for: individual-commercial-use-license">Individual Commercial Use License</a></h2>
<p>Before introducing the ICUL, both my research and my experience in 2023 and 2024
had demonstrated that:</p>
<ol>
<li>Corporations do not make voluntary financial contributions to software that
improves the working conditions of their employees</li>
<li>Voluntary contributions in the form of sponsorship from individuals do not
scale and are not sustainable in an environment where workers are a mass
layoff and a medical emergency away from having their life savings wiped out</li>
</ol>
<p>So I introduced the ICUL with a few thoughts in mind:</p>
<ol>
<li>I want to normalize making employers pay for the ICUL</li>
<li>The ICUL should function as a mechanism to empower end users to mediate
corporate contributions to the financial sustainability of independent
software</li>
<li>If this goes well, the number of licenses should surpass the number of GitHub
sponsors</li>
<li>If this goes well, other independent developers will start reaching out to me
to learn more</li>
</ol>
<p>The numbers from this year are very encouraging. There are currently 95 active
ICULs and with the impending release of komorebi for Mac, I see that number
crossing 100 within a month or two.</p>
<p>Many ICUL users have successfully had their licensing costs either reimbursed
after paying out of pocket, or have had their licenses purchased on their behalf
directly by their employers.</p>
<p>Unsurprisingly, many licenses have been purchased for workers in highly regulated
industries.</p>
<p>More surprisingly, licenses have also been purchased by independent contractors,
who told me that they simply increased their rate slightly to cover the cost of
the tools required to do what they were contracted for.</p>
<p>In December 2025 both komorebi for Windows and komorebi for Mac started
<a href="../normalize-identifying-corporate-devices-in-your-software">identifying potential corporate devices based on MDM enrollment
status</a>.
This resulted in a wave of new ICUL purchases and a total payout of $1375 at the
end of the month (~17% of the 2025 ICUL total in the table above).</p>
<p>This month, to coincide with the upcoming release of komorebi for Mac (and once
again, upon request), I am also introducing a new multi-platform variant of the
ICUL which covers both komorebi for Windows and komorebi for Mac at 1.5x the
price of a single-platform license.</p>
<h2 id="the-big-question"><a class="zola-anchor" href="#the-big-question" aria-label="Anchor link for: the-big-question">The Big Question</a></h2>
<p>I assume many people will have clicked to read this article with some variation
of the question "can I make a living independently selling my own software?" in
mind.</p>
<p>I have been working on komorebi since 2020, and for me the answer to that
question is still "no"; I still need a full-time job to be able to pay my rent and
to have health insurance coverage.</p>
<p>However, when I look back at the last 3 years, the progress made towards this
goal that many of us in this industry have is undeniable.</p>
<table><thead><tr><th>Year</th><th>Total</th></tr></thead><tbody>
<tr><td>2023</td><td>$593</td></tr>
<tr><td>2024</td><td>$1861</td></tr>
<tr><td>2025</td><td>$12,070</td></tr>
</tbody></table>
<p>I think that I am on to something, and I think that my approach to building a
financially sustainable independent software project is one that can be
reproduced by other independent software developers focused on developer and/or
power-user tooling.</p>
<hr />
<p>If you have any questions or comments you can reach out to me on
<a href="https://bsky.app/profile/lgug2z.com">Bluesky</a> and
<a href="https://hachyderm.io/@LGUG2Z">Mastodon</a>.</p>
<p>If you're interested in what I read to come up with solutions like this, you
can subscribe to my <a href="https://notado.app/feeds/jado/software-development">Software Development RSS
feed</a>.</p>
<p>If you'd like to watch me writing code while explaining what I'm doing, you can
also <a href="https://www.youtube.com/channel/UCeai3-do-9O4MNy9_xjO6mg?sub_confirmation=1">subscribe to my YouTube
channel</a>.</p>
<p>If you would like early access to <a href="https://www.youtube.com/watch?v=u3eJcsa_MJk">komorebi for
Mac</a>, you can sponsor me on
<a href="https://github.com/sponsors/LGUG2Z">GitHub</a>.</p>
Consider a Nix Flake for your windows-rs ProjectMon, 29 Dec 2025 01:36:54 -0800lgug2z
https://lgug2z.com/articles/consider-a-nix-flake-for-your-windows-rs-project/
https://lgug2z.com/articles/consider-a-nix-flake-for-your-windows-rs-project/<p>I have been working hard on getting <a href="https://www.youtube.com/watch?v=u3eJcsa_MJk">komorebi for
Mac</a> ready for the <a href="https://www.youtube.com/watch?v=MMSRfcHjXlo">initial public
release in January 2026</a>, and a
large part of that work has focused on improving the documentation and creating
a new unified docs website for both komorebi for Windows and komorebi for Mac.</p>
<p>I am all-in on generating documentation from rustdoc, for both komorebi for
Windows and komorebi for Mac.</p>
<p>I'm so deep in on this that not only am I generating <a href="https://komorebi-starlight.lgug2z.workers.dev/reference/komorebi-macos/">configuration API
documentation</a>
in Markdown, I am also generating <a href="https://gist.github.com/LGUG2Z/aceff1c7bb55e543be583735bc3a5cf0">Nix module
options</a> for
komorebi for Mac users to be able to define their configuration in Nix with
completions, documentation, and automatic JSONSchema validation of the
generated JSON file.</p>
<p>In order to enable the generation of better documentation from rustdoc, I have
been making a lot of updates to my rustdoc comments across both codebases.</p>
<p>The thing that sucks about having two similar codebases for two different
platforms which implement a common configuration API is keeping documentation
in sync.</p>
<p>(<em>I think the future of komorebi is probably going to be a single repo for
both komorebi for Windows and komorebi for Mac, but that's a whole separate
article...</em>)</p>
<p>To do this I have spent way too much time switching inputs on my display to go
from my Mac Mini to my PC and back. Although this is made significantly easier
by having a cross-platform tiling window manager with all the same semantics
and key bindings on both platforms, it still sucks and takes me out of my flow
state.</p>
<p>The reason for having to do this display input switching dance is that
cross-compilation of Rust projects that depend on the Windows MSVC toolchain is
a pain; I can't (or rather, couldn't - past tense) just make an equivalent
change in the komorebi for Windows repo checked out on the Mac Mini and run
<code>cargo clippy</code> to make sure everything is okay, or <code>cargo run</code> to inspect the
output of one of the documentation generation commands.</p>
<p>I reached my breaking point with this workflow today and decided to tackle it
head on: by adding a <code>flake.nix</code> to komorebi for Windows which allows me to
both build the entire project and run all of those <code>cargo</code> with the Windows
MSVC toolchain on both NixOS and macOS.</p>
<p>I took the <code>flake.nix</code> from komorebi for Mac as a template and went diving into
projects like <a href="https://github.com/rust-cross/cargo-xwin">cargo-xwin</a> and the
discussions in <a href="https://github.com/ipetkov/crane/discussions/555">crane</a>, and
eventually got something working.</p>
<p>You can check it out here:
<a href="https://github.com/LGUG2Z/komorebi/blob/6e36b816699ef3f1c7ea3e9ab70f78dca9fe6008/flake.nix">LGUG2Z/komorebi/flake.nix@6e36b816699ef3f1c7ea3e9ab70f78dca9fe6008</a></p>
<p>One of my favorite components to set up in a flake is
<a href="https://github.com/numtide/treefmt-nix"><code>treefmt-nix</code></a>, and integrating all of
the formatters and linters it provides for different languages into <code>nix flake check</code>.</p>
<p>So once I got the <code>flake.nix</code> for komorebi for Windows working, I ran <code>nix fmt</code>
followed by <code>nix flake check</code>, and I was very happy to see everything "just
work" - as evidenced by all of the TOML files in the repo suddenly being
formatted correctly (I had not been running <code>taplo</code> on Windows).</p>
<p>The initial lift to get this working is quite big (hopefully a little less now
that you can copy and paste from my example), but I can already feel that this
is going to have a huge payoff on the medium-to-long term as I work on both
komorebi for Windows and komorebi for Mac concurrently and make refactors which
will extract common duplicated code out of both codebases.</p>
<p>Even if you are not going cross-platform with whatever you're building (yet, or
ever), being able to leverage flake checks, dev shells and excellent tooling
like <code>treefmt-nix</code> in WSL can have a significant positive impact on the
ergonomic experience of a long-lived project.</p>
<hr />
<p>If you have any questions or comments you can reach out to me on
<a href="https://bsky.app/profile/lgug2z.com">Bluesky</a> and
<a href="https://hachyderm.io/@LGUG2Z">Mastodon</a>.</p>
<p>If you're interested in what I read to come up with solutions like this, you
can subscribe to my <a href="https://notado.app/feeds/jado/software-development">Software Development RSS
feed</a>.</p>
<p>If you'd like to watch me writing code while explaining what I'm doing, you can
also <a href="https://www.youtube.com/channel/UCeai3-do-9O4MNy9_xjO6mg?sub_confirmation=1">subscribe to my YouTube
channel</a>.</p>
<p>If you would like early access to <a href="https://www.youtube.com/watch?v=u3eJcsa_MJk">komorebi for
Mac</a>, you can sponsor me on
<a href="https://github.com/sponsors/LGUG2Z">GitHub</a>.</p>
Rescue Complex AutomaticTue, 09 Dec 2025 00:00:00 +0000lgug2z
https://lgug2z.com/music/rescue-complex-automatic/
https://lgug2z.com/music/rescue-complex-automatic/<p><img src="https://lgug2z.com/music/rescue-complex-automatic/./cover.png" alt="Rescue Complex Automatic" />
Cover art by <a href="https://www.instagram.com/zhrzwdart">Zahra al Zuwayed</a></p>
<p>Purchase on <a href="https://virgoimpact.bandcamp.com/album/rescue-complex-automatic">Bandcamp</a></p>
<p>Stream on <a href="https://music.apple.com/us/album/rescue-complex-automatic-ep/1860048097">Apple Music</a></p>
<p>Stream on <a href="https://open.spotify.com/album/1B0OaG612uQHjx6OEirIdF?si=xnlkxQ8kSZy22iKkXsbekg">Spotify</a></p>
Is this a Core Graphics Framework Bug in macOS Tahoe?Wed, 26 Nov 2025 01:43:15 -0800lgug2z
https://lgug2z.com/articles/is-this-a-core-graphics-framework-bug-in-macos-tahoe/
https://lgug2z.com/articles/is-this-a-core-graphics-framework-bug-in-macos-tahoe/<p>I've been daily driving <a href="https://www.youtube.com/watch?v=u3eJcsa_MJk">my tiling window manager for
macOS</a> on macOS Sequoia for a few
months now and things have been pretty stable.</p>
<p>I've had a few reports of issues on macOS Tahoe, but it wasn't until setting up
a new machine that came with Tahoe preinstalled that I was able to reproduce and
debug them myself.</p>
<p>One of the things that a tiling window manager should do is listen for changes
to connected displays. The main changes that should be handled gracefully are
changes in screen resolution, scaling, and display (dis)connection.</p>
<p>The code below is for a <code>DisplayReconfigurationListener</code> which emits events to
another component when any of the above changes are communicated by the
operating system.</p>
<h2 id="existing-code-in-rust"><a class="zola-anchor" href="#existing-code-in-rust" aria-label="Anchor link for: existing-code-in-rust">Existing Code in Rust</a></h2>
<pre data-lang="rust" style="background-color:#2b303b;color:#c0c5ce;" class="language-rust "><code class="language-rust" data-lang="rust"><span style="color:#b48ead;">use crate</span><span>::core_graphics::error::CoreGraphicsError;
</span><span style="color:#b48ead;">use crate</span><span>::monitor_reconciliator;
</span><span style="color:#b48ead;">use crate</span><span>::monitor_reconciliator::MonitorNotification;
</span><span style="color:#b48ead;">use </span><span>objc2_core_graphics::CGDirectDisplayID;
</span><span style="color:#b48ead;">use </span><span>objc2_core_graphics::CGDisplayChangeSummaryFlags;
</span><span style="color:#b48ead;">use </span><span>objc2_core_graphics::CGDisplayRegisterReconfigurationCallback;
</span><span style="color:#b48ead;">use </span><span>objc2_core_graphics::CGDisplayRemoveReconfigurationCallback;
</span><span style="color:#b48ead;">use </span><span>std::ffi::</span><span style="color:#b48ead;">c_void</span><span>;
</span><span>
</span><span style="color:#b48ead;">pub struct </span><span>DisplayReconfigurationListener {}
</span><span>
</span><span style="color:#b48ead;">unsafe extern </span><span>"</span><span style="color:#a3be8c;">C-unwind</span><span>" </span><span style="color:#b48ead;">fn </span><span style="color:#8fa1b3;">callback</span><span>(
</span><span> </span><span style="color:#bf616a;">display_id</span><span>: CGDirectDisplayID,
</span><span> </span><span style="color:#bf616a;">flags</span><span>: CGDisplayChangeSummaryFlags,
</span><span> </span><span style="color:#bf616a;">_user_info</span><span>: </span><span style="color:#b48ead;">*mut</span><span> c_void,
</span><span>) {
</span><span> </span><span style="color:#b48ead;">if</span><span> flags.</span><span style="color:#96b5b4;">contains</span><span>(CGDisplayChangeSummaryFlags::DesktopShapeChangedFlag) {
</span><span> tracing::debug!("</span><span style="color:#a3be8c;">display: {display_id} resized</span><span>");
</span><span> monitor_reconciliator::send_notification(MonitorNotification::Resize(display_id));
</span><span> }
</span><span>
</span><span> </span><span style="color:#b48ead;">if</span><span> flags.</span><span style="color:#96b5b4;">contains</span><span>(CGDisplayChangeSummaryFlags::AddFlag) {
</span><span> tracing::debug!("</span><span style="color:#a3be8c;">display: {display_id} added</span><span>");
</span><span> monitor_reconciliator::send_notification(MonitorNotification::DisplayConnectionChange(
</span><span> display_id,
</span><span> ));
</span><span> }
</span><span>
</span><span> </span><span style="color:#b48ead;">if</span><span> flags.</span><span style="color:#96b5b4;">contains</span><span>(CGDisplayChangeSummaryFlags::RemoveFlag) {
</span><span> tracing::debug!("</span><span style="color:#a3be8c;">display: {display_id} removed</span><span>");
</span><span> monitor_reconciliator::send_notification(MonitorNotification::DisplayConnectionChange(
</span><span> display_id,
</span><span> ));
</span><span> }
</span><span>}
</span><span>
</span><span style="color:#b48ead;">impl </span><span>DisplayReconfigurationListener {
</span><span> </span><span style="color:#b48ead;">pub fn </span><span style="color:#8fa1b3;">init</span><span>() -> Result<(), CoreGraphicsError> {
</span><span> tracing::info!("</span><span style="color:#a3be8c;">registering display reconfiguration listener callback</span><span>");
</span><span>
</span><span> </span><span style="color:#b48ead;">unsafe </span><span>{
</span><span> </span><span style="color:#b48ead;">match </span><span>CoreGraphicsError::from(CGDisplayRegisterReconfigurationCallback(
</span><span> Some(callback),
</span><span> std::ptr::null_mut(),
</span><span> )) {
</span><span> CoreGraphicsError::Success => Ok(()),
</span><span> err => Err(err),
</span><span> }
</span><span> }
</span><span> }
</span><span>}
</span><span>
</span><span style="color:#b48ead;">impl </span><span>Drop </span><span style="color:#b48ead;">for </span><span>DisplayReconfigurationListener {
</span><span> </span><span style="color:#b48ead;">fn </span><span style="color:#8fa1b3;">drop</span><span>(&</span><span style="color:#b48ead;">mut </span><span style="color:#bf616a;">self</span><span>) {
</span><span> tracing::info!("</span><span style="color:#a3be8c;">removing display reconfiguration listener callback</span><span>");
</span><span>
</span><span> </span><span style="color:#b48ead;">unsafe </span><span>{
</span><span> </span><span style="color:#b48ead;">match </span><span>CoreGraphicsError::from(CGDisplayRemoveReconfigurationCallback(
</span><span> Some(callback),
</span><span> std::ptr::null_mut(),
</span><span> )) {
</span><span> CoreGraphicsError::Success => {}
</span><span> error => {
</span><span> tracing::error!(
</span><span> "</span><span style="color:#a3be8c;">failed to remove display reconfiguration listener callback {error}</span><span>"
</span><span> )
</span><span> }
</span><span> }
</span><span> }
</span><span> }
</span><span>}
</span></code></pre>
<p>When initialized, a call is made to
<a href="https://developer.apple.com/documentation/coregraphics/cgdisplayregisterreconfigurationcallback(_:_:)"><code>CGDisplayRegisterReconfigurationCallback</code></a>
to register the callback which listens for specific
<code>CGDisplayChangeSummaryFlags</code> and then emits appropriate events to another
component which handles the state changes required.</p>
<p>After adding some debug logs at the beginning of the callback, it was clear that
the callback was not being triggered at all on macOS Tahoe. Strange.</p>
<h2 id="reproduction-in-swift"><a class="zola-anchor" href="#reproduction-in-swift" aria-label="Anchor link for: reproduction-in-swift">Reproduction in Swift</a></h2>
<p>I wanted to make sure this wasn't a "Rust thing" so I put together something in
Swift (which I presume has first-class support on this platform) to validate
that registered callbacks were being triggered on Sequoia but not Tahoe.</p>
<pre data-lang="swift" style="background-color:#2b303b;color:#c0c5ce;" class="language-swift "><code class="language-swift" data-lang="swift"><span style="color:#b48ead;">import </span><span>Foundation
</span><span style="color:#b48ead;">import </span><span>CoreGraphics
</span><span style="color:#b48ead;">import </span><span>AppKit
</span><span>
</span><span style="color:#b48ead;">func </span><span>displayReconfigurationCallback(
</span><span> displayID: CGDirectDisplayID,
</span><span> flags: CGDisplayChangeSummaryFlags,
</span><span> userInfo: UnsafeMutableRawPointer?
</span><span>) {
</span><span> </span><span style="color:#b48ead;">let</span><span> timestamp = ISO8601DateFormatter().string(from: Date())
</span><span> print(</span><span style="color:#a3be8c;">"</span><span style="color:#96b5b4;">\n</span><span style="color:#a3be8c;">[</span><span>\(timestamp)</span><span style="color:#a3be8c;">] Display reconfiguration event received!"</span><span>)
</span><span> print(</span><span style="color:#a3be8c;">" Display ID: </span><span>\(displayID)</span><span style="color:#a3be8c;">"</span><span>)
</span><span> print(</span><span style="color:#a3be8c;">" Flags raw value: </span><span>\(flags.rawValue)</span><span style="color:#a3be8c;">"</span><span>)
</span><span>
</span><span> </span><span style="color:#b48ead;">var</span><span> flagDescriptions: [String] = []
</span><span>
</span><span> </span><span style="color:#b48ead;">if</span><span> flags.contains(.beginConfigurationFlag) {
</span><span> flagDescriptions.append(</span><span style="color:#a3be8c;">"BeginConfiguration"</span><span>)
</span><span> }
</span><span> </span><span style="color:#b48ead;">if</span><span> flags.contains(.movedFlag) {
</span><span> flagDescriptions.append(</span><span style="color:#a3be8c;">"Moved"</span><span>)
</span><span> }
</span><span> </span><span style="color:#b48ead;">if</span><span> flags.contains(.setMainFlag) {
</span><span> flagDescriptions.append(</span><span style="color:#a3be8c;">"SetMain"</span><span>)
</span><span> }
</span><span> </span><span style="color:#b48ead;">if</span><span> flags.contains(.setModeFlag) {
</span><span> flagDescriptions.append(</span><span style="color:#a3be8c;">"SetMode"</span><span>)
</span><span> }
</span><span> </span><span style="color:#b48ead;">if</span><span> flags.contains(.addFlag) {
</span><span> flagDescriptions.append(</span><span style="color:#a3be8c;">"Add"</span><span>)
</span><span> }
</span><span> </span><span style="color:#b48ead;">if</span><span> flags.contains(.removeFlag) {
</span><span> flagDescriptions.append(</span><span style="color:#a3be8c;">"Remove"</span><span>)
</span><span> }
</span><span> </span><span style="color:#b48ead;">if</span><span> flags.contains(.enabledFlag) {
</span><span> flagDescriptions.append(</span><span style="color:#a3be8c;">"Enabled"</span><span>)
</span><span> }
</span><span> </span><span style="color:#b48ead;">if</span><span> flags.contains(.disabledFlag) {
</span><span> flagDescriptions.append(</span><span style="color:#a3be8c;">"Disabled"</span><span>)
</span><span> }
</span><span> </span><span style="color:#b48ead;">if</span><span> flags.contains(.mirrorFlag) {
</span><span> flagDescriptions.append(</span><span style="color:#a3be8c;">"Mirror"</span><span>)
</span><span> }
</span><span> </span><span style="color:#b48ead;">if</span><span> flags.contains(.unMirrorFlag) {
</span><span> flagDescriptions.append(</span><span style="color:#a3be8c;">"UnMirror"</span><span>)
</span><span> }
</span><span> </span><span style="color:#b48ead;">if</span><span> flags.contains(.desktopShapeChangedFlag) {
</span><span> flagDescriptions.append(</span><span style="color:#a3be8c;">"DesktopShapeChanged"</span><span>)
</span><span> }
</span><span>
</span><span> </span><span style="color:#b48ead;">if</span><span> flagDescriptions.isEmpty {
</span><span> print(</span><span style="color:#a3be8c;">" Flags: (none/unknown)"</span><span>)
</span><span> } </span><span style="color:#b48ead;">else</span><span> {
</span><span> print(</span><span style="color:#a3be8c;">" Flags: </span><span>\(flagDescriptions.joined(separator: </span><span style="color:#a3be8c;">", "</span><span>)</span><span style="color:#a3be8c;">)"</span><span>)
</span><span> }
</span><span>
</span><span> fflush(stdout)
</span><span>}
</span><span>
</span><span>print(</span><span style="color:#a3be8c;">"=== Display Reconfiguration Listener Test ==="</span><span>)
</span><span>print(</span><span style="color:#a3be8c;">"macOS version: </span><span>\(ProcessInfo.processInfo.operatingSystemVersionString)</span><span style="color:#a3be8c;">"</span><span>)
</span><span>print(</span><span style="color:#a3be8c;">""</span><span>)
</span><span>
</span><span style="color:#b48ead;">let</span><span> app = NSApplication.shared
</span><span>print(</span><span style="color:#a3be8c;">"NSApplication.shared initialized: </span><span>\(app)</span><span style="color:#a3be8c;">"</span><span>)
</span><span>
</span><span style="color:#b48ead;">let</span><span> maxDisplays: UInt32 = </span><span style="color:#d08770;">16
</span><span style="color:#b48ead;">var</span><span> displays = [CGDirectDisplayID](repeating: </span><span style="color:#d08770;">0</span><span>, count: Int(maxDisplays))
</span><span style="color:#b48ead;">var</span><span> displayCount: UInt32 = </span><span style="color:#d08770;">0
</span><span>CGGetActiveDisplayList(maxDisplays, &displays, &displayCount)
</span><span>
</span><span>print(</span><span style="color:#a3be8c;">"</span><span style="color:#96b5b4;">\n</span><span style="color:#a3be8c;">Currently active displays: </span><span>\(displayCount)</span><span style="color:#a3be8c;">"</span><span>)
</span><span style="color:#b48ead;">for</span><span> i </span><span style="color:#b48ead;">in </span><span style="color:#d08770;">0</span><span>..<Int(displayCount) {
</span><span> </span><span style="color:#b48ead;">let</span><span> id = displays[i]
</span><span> </span><span style="color:#b48ead;">let</span><span> bounds = CGDisplayBounds(id)
</span><span> </span><span style="color:#b48ead;">let</span><span> isMain = CGDisplayIsMain(id) != </span><span style="color:#d08770;">0
</span><span> print(</span><span style="color:#a3be8c;">" Display </span><span>\(i)</span><span style="color:#a3be8c;">: ID=</span><span>\(id)</span><span style="color:#a3be8c;">, bounds=</span><span>\(bounds)</span><span style="color:#a3be8c;">, isMain=</span><span>\(isMain)</span><span style="color:#a3be8c;">"</span><span>)
</span><span>}
</span><span>
</span><span>print(</span><span style="color:#a3be8c;">"</span><span style="color:#96b5b4;">\n</span><span style="color:#a3be8c;">Registering CGDisplayReconfigurationCallback..."</span><span>)
</span><span style="color:#b48ead;">let</span><span> result = CGDisplayRegisterReconfigurationCallback(displayReconfigurationCallback, </span><span style="color:#d08770;">nil</span><span>)
</span><span>
</span><span style="color:#b48ead;">if</span><span> result == .success {
</span><span> print(</span><span style="color:#a3be8c;">"✓ Callback registered successfully (CGError.success)"</span><span>)
</span><span>} </span><span style="color:#b48ead;">else</span><span> {
</span><span> print(</span><span style="color:#a3be8c;">"✗ Failed to register callback: </span><span>\(result.rawValue)</span><span style="color:#a3be8c;">"</span><span>)
</span><span> exit(</span><span style="color:#d08770;">1</span><span>)
</span><span>}
</span><span>
</span><span>print(</span><span style="color:#a3be8c;">"</span><span style="color:#96b5b4;">\n</span><span style="color:#a3be8c;">>>> Listening for display changes. Try:"</span><span>)
</span><span>print(</span><span style="color:#a3be8c;">"</span><span style="color:#96b5b4;">\n</span><span style="color:#a3be8c;">Press Ctrl+C to exit.</span><span style="color:#96b5b4;">\n</span><span style="color:#a3be8c;">"</span><span>)
</span><span>
</span><span>RunLoop.current.run()
</span></code></pre>
<h2 id="results-on-macos-sequoia"><a class="zola-anchor" href="#results-on-macos-sequoia" aria-label="Anchor link for: results-on-macos-sequoia">Results on macOS Sequoia</a></h2>
<pre data-lang="txt" style="background-color:#2b303b;color:#c0c5ce;" class="language-txt "><code class="language-txt" data-lang="txt"><span>=== Display Reconfiguration Listener Test ===
</span><span>macOS version: Version 15.6.1 (Build 24G90)
</span><span>
</span><span>NSApplication.shared initialized: <NSApplication: 0x11080cfa0>
</span><span>
</span><span>Currently active displays: 1
</span><span> Display 0: ID=3, bounds=(0.0, 0.0, 3840.0, 1620.0), isMain=true
</span><span>
</span><span>Registering CGDisplayReconfigurationCallback...
</span><span>✓ Callback registered successfully (CGError.success)
</span><span>
</span><span>>>> Listening for display changes
</span><span>
</span><span>Press Ctrl+C to exit.
</span><span>
</span><span>[2025-11-25T23:57:23Z] Display reconfiguration event received!
</span><span> Display ID: 3
</span><span> Flags raw value: 1
</span><span> Flags: BeginConfiguration
</span><span>
</span><span>[2025-11-25T23:57:23Z] Display reconfiguration event received!
</span><span> Display ID: 3
</span><span> Flags raw value: 4104
</span><span> Flags: SetMode, DesktopShapeChanged
</span><span>
</span><span>[2025-11-25T23:57:35Z] Display reconfiguration event received!
</span><span> Display ID: 3
</span><span> Flags raw value: 1
</span><span> Flags: BeginConfiguration
</span><span>
</span><span>[2025-11-25T23:57:36Z] Display reconfiguration event received!
</span><span> Display ID: 3
</span><span> Flags raw value: 4104
</span><span> Flags: SetMode, DesktopShapeChanged
</span></code></pre>
<h2 id="results-on-macos-tahoe"><a class="zola-anchor" href="#results-on-macos-tahoe" aria-label="Anchor link for: results-on-macos-tahoe">Results on macOS Tahoe</a></h2>
<pre data-lang="txt" style="background-color:#2b303b;color:#c0c5ce;" class="language-txt "><code class="language-txt" data-lang="txt"><span>=== Display Reconfiguration Listener Test ===
</span><span>macOS version: Version 26.1 (Build 25B78)
</span><span>
</span><span>NSApplication.shared initialized: <NSApplication: 0x10d47f180>
</span><span>
</span><span>Currently active displays: 1
</span><span> Display 0: ID=2, bounds=(0.0, 0.0, 3840.0, 1620.0), isMain=true
</span><span>
</span><span>Registering CGDisplayReconfigurationCallback...
</span><span>✓ Callback registered successfully (CGError.success)
</span><span>
</span><span>>>> Listening for display changes. Try:
</span><span>
</span><span>Press Ctrl+C to exit.
</span></code></pre>
<h2 id="what-next"><a class="zola-anchor" href="#what-next" aria-label="Anchor link for: what-next">What next?</a></h2>
<p>The <a href="https://developer.apple.com/documentation/coregraphics/cgdisplayregisterreconfigurationcallback(_:_:)">Apple Developer
website</a>
continues to mark <code>CGDisplayRegisterReconfigurationCallback</code> as supported on
<code>macOS 10.3+</code>, and there are no deprecation warnings or notes of additional
permissions required to call this API as of Tahoe.</p>
<p>It looks like it is also possible to register for various <code>NSNotification</code>s such
as
<a href="https://developer.apple.com/documentation/appkit/nsapplication/didchangescreenparametersnotification"><code>didChangeScreenParametersNotification</code></a>
which can also be mapped to the behaviors described by
<a href="https://developer.apple.com/documentation/coregraphics/cgdisplaychangesummaryflags"><code>CGDisplayChangeSummaryFlags</code></a>
with a little trial and error, and this should work for both Sequoia and Tahoe.</p>
<p>The imposter syndrome that never really goes away is telling me that I probably
missed something somewhere, which is why I'm sharing these details - hopefully
someone can shed some light on where I may have gone wrong.</p>
<p>But if that's not the case, it would be great if the folks at Apple could just...
stop breaking things.</p>
<hr />
<p>If you have any questions or comments you can reach out to me on
<a href="https://bsky.app/profile/lgug2z.com">Bluesky</a> and
<a href="https://hachyderm.io/@LGUG2Z">Mastodon</a>.</p>
<p>If you're interested in what I read to analyze problems like this, you
can subscribe to my <a href="https://notado.app/feeds/jado/software-development">Software Development RSS
feed</a>.</p>
<p>If you'd like to watch me writing code while explaining what I'm doing, you can
also <a href="https://www.youtube.com/channel/UCeai3-do-9O4MNy9_xjO6mg?sub_confirmation=1">subscribe to my YouTube
channel</a>.</p>
<p>If you would like early access to <a href="https://www.youtube.com/watch?v=u3eJcsa_MJk">komorebi for
Mac</a>, you can sponsor me on
<a href="https://github.com/sponsors/LGUG2Z">GitHub</a>.</p>
Version Control External Content Referenced in Your BlogFri, 14 Nov 2025 16:11:23 -0800lgug2z
https://lgug2z.com/articles/version-control-external-content-referenced-in-your-blog/
https://lgug2z.com/articles/version-control-external-content-referenced-in-your-blog/<p>It seems like most blogs I read these days are built using a static site
generator (SSG).</p>
<p>Some of these SSGs <a href="https://gohugo.io/shortcodes/x/">come with shortcodes</a> for
embedding content from popular websites, others don't, but shortcodes are
generally the accepted way of embedding a reference to something someone else
said on the internet in one of your posts.</p>
<p>I <a href="/articles/im-done-writing-shortcodes-for-other-websites">recently wrote</a>
about how brittle I find these shortcodes that call out to external websites,
and instead wrote <em>One Shortcode to Rule Them All</em> which references things
directly from my own personal knowledge base.</p>
<p>But we can still do better.</p>
<div class="notado-quote"
style="border: 1px solid var(--border-color);
background-color: var(--bg-primary) !important;
position: relative;
margin-block: 1em;
border-radius: 5px;
padding-left: 1rem;
padding-right: 1rem;
padding-top: 1.25rem;
padding-bottom: 1.25rem;
margin-bottom: 0em;
border-bottom-right-radius: 0px !important;
border-bottom-left-radius: 0px !important;
">
<div style="padding-bottom: 1.25rem">
<div style="display: flex; gap: 0.75rem">
<div style="min-width: 0;
flex: 1 1 0%;
display: flex;
flex-direction: column;
justify-content: center">
<p style="text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
margin: 0em">
michael
</p>
<p style="color: var(--text-1); margin: 0em">
<a href="https://m.mtlynch.io/@michael/115538492543985760">m.mtlynch.io</a>
</p>
</div>
<div style="flex-shrink: 0;
display: flex;
flex-direction: row-reverse;
align-items: center">
<a class="notado-icon" href="https://notado.app" style="border: none">
<img style="height: 3rem;
width: 3rem"
src="https://notado.app/static/notado-icon.png"
alt="notado" />
</a>
</div>
</div>
</div>
<div>@LGUG2Z I wish that SSGs would store the data from the remote URL in a file you could keep under version control. e.g., for tweets, I do something like <code>{{<tweet id="12345">}}</code> and on first build, it downloads the profile image, name, tweet content, and timestamp and renders that, and it works forever.</p>
<p>I keep finding broken tweets on my Hugo site because Twitter's rate limiting something or the user deleted their tweet. I've resorted to just screenshotting, which feels sloppy.</div>
</div>
<div class="notado-quote-caption"
style="border: 1px solid var(--border-color);
background-color: var(--bg-2) !important;
position: relative;
margin-bottom: 1em;
border-bottom-left-radius: 5px;
border-bottom-right-radius: 5px;
padding-left: 1rem;
padding-right: 1rem;
padding-top: 0.5rem;
padding-bottom: 0.5rem">
Michael is on to something... Embedding his comment here didn't trigger a HTTP request when this article was built!
</div>
<p>This is also something that I have thought about before, but writing a
middleware layer to cache referenced content from disparate sources to plug in
to SSG codebases I'm not really familiar with was never an idea that really
pulled me in.</p>
<p>I too had previously resorted to screenshotting and embedding images, but that
process doesn't <em>feel</em> good, and I don't like adding more and more image files to
my git repo. My requirements are also a bit simpler because I don't really care
about rendering a user's profile picture from an external site or showing a
timestamp.</p>
<p>Since I'm now pulling in all my quoted external content from a single data
source, I can implement a pre-build step which will allow me to version control
all of the external content referenced in my articles. I just need three things:</p>
<ol>
<li>A list of sources</li>
</ol>
<pre data-lang="txt" style="background-color:#2b303b;color:#c0c5ce;" class="language-txt "><code class="language-txt" data-lang="txt"><span># sources
</span><span>
</span><span>https://m.mtlynch.io/@michael/115538492543985760
</span></code></pre>
<ol start="2">
<li>A file which maps those sources to the data required to render them</li>
</ol>
<pre data-lang="json" style="background-color:#2b303b;color:#c0c5ce;" class="language-json "><code class="language-json" data-lang="json"><span style="color:#65737e;">// library.json
</span><span>{
</span><span> "</span><span style="color:#a3be8c;">https://m.mtlynch.io/@michael/115538492543985760</span><span>": {
</span><span> "</span><span style="color:#a3be8c;">title</span><span>": "</span><span style="color:#a3be8c;">michael</span><span>",
</span><span> "</span><span style="color:#a3be8c;">source_display</span><span>": "</span><span style="color:#a3be8c;">m.mtlynch.io</span><span>",
</span><span> "</span><span style="color:#a3be8c;">source_url</span><span>": "</span><span style="color:#a3be8c;">https://m.mtlynch.io/@michael/115538492543985760</span><span>",
</span><span> "</span><span style="color:#a3be8c;">content</span><span>": "</span><span style="color:#a3be8c;">@LGUG2Z I wish that SSGs would store the data from the remote URL in a file you could keep under version control. e.g., for tweets, I do something like `{{<tweet id=</span><span style="color:#96b5b4;">\"</span><span style="color:#a3be8c;">12345</span><span style="color:#96b5b4;">\"</span><span style="color:#a3be8c;">>}}` and on first build, it downloads the profile image, name, tweet content, and timestamp and renders that, and it works forever.</span><span style="color:#96b5b4;">\n\n</span><span style="color:#a3be8c;">I keep finding broken tweets on my Hugo site because Twitter's rate limiting something or the user deleted their tweet. I've resorted to just screenshotting, which feels sloppy.</span><span>"
</span><span> }
</span><span>}
</span></code></pre>
<ol start="3">
<li>A shortcode which can look up and render the data using the source URL</li>
</ol>
<pre data-lang="html" style="background-color:#2b303b;color:#c0c5ce;" class="language-html "><code class="language-html" data-lang="html"><span style="color:#65737e;"><!-- library.html -->
</span><span>
</span><span>{% if url %}
</span><span> {% set library = load_data(path="library.json") %}
</span><span> {% set quote_data = library | get(key=url) %}
</span><span> {% set use_markdown = markdown | default(value=false) %}
</span><span>
</span><span> <</span><span style="color:#bf616a;">div </span><span style="color:#d08770;">class</span><span>="</span><span style="color:#a3be8c;">notado-quote</span><span>"
</span><span> </span><span style="color:#d08770;">style</span><span>="border: </span><span style="color:#d08770;">1px </span><span>solid </span><span style="color:#96b5b4;">var</span><span>(--border-color);
</span><span> background-color: </span><span style="color:#96b5b4;">var</span><span>(--bg-primary) </span><span style="color:#b48ead;">!important</span><span>;
</span><span> position: relative;
</span><span> margin-block: </span><span style="color:#d08770;">1em</span><span>;
</span><span> border-radius: </span><span style="color:#d08770;">5px</span><span>;
</span><span> padding-left: </span><span style="color:#d08770;">1rem</span><span>;
</span><span> padding-right: </span><span style="color:#d08770;">1rem</span><span>;
</span><span> padding-top: </span><span style="color:#d08770;">1.25rem</span><span>;
</span><span> padding-bottom: </span><span style="color:#d08770;">1.25rem</span><span>;
</span><span> {% if caption %} margin-bottom: </span><span style="color:#d08770;">0em</span><span>;
</span><span> border-bottom-right-radius: </span><span style="color:#d08770;">0px </span><span style="color:#b48ead;">!important</span><span>;
</span><span> border-bottom-left-radius: </span><span style="color:#d08770;">0px </span><span style="color:#b48ead;">!important</span><span>;
</span><span> {% endif %}">
</span><span>
</span><span> <</span><span style="color:#bf616a;">div </span><span style="color:#d08770;">style</span><span>="padding-bottom: </span><span style="color:#d08770;">1.25rem</span><span>">
</span><span> <</span><span style="color:#bf616a;">div </span><span style="color:#d08770;">style</span><span>="display: flex; gap: </span><span style="color:#d08770;">0.75rem</span><span>">
</span><span> <</span><span style="color:#bf616a;">div </span><span style="color:#d08770;">style</span><span>="min-width: </span><span style="color:#d08770;">0</span><span>;
</span><span> flex: </span><span style="color:#d08770;">1 1 0%</span><span>;
</span><span> display: flex;
</span><span> flex-direction: column;
</span><span> justify-content: center">
</span><span> <</span><span style="color:#bf616a;">p </span><span style="color:#d08770;">style</span><span>="text-overflow: ellipsis;
</span><span> overflow: hidden;
</span><span> white-space: nowrap;
</span><span> margin: </span><span style="color:#d08770;">0em</span><span>">
</span><span> {{ quote_data.title }}
</span><span> </</span><span style="color:#bf616a;">p</span><span>>
</span><span> <</span><span style="color:#bf616a;">p </span><span style="color:#d08770;">style</span><span>="color: </span><span style="color:#96b5b4;">var</span><span>(--text-1); margin: </span><span style="color:#d08770;">0em</span><span>">
</span><span> {% if quote_data.source_url %}
</span><span> <</span><span style="color:#bf616a;">a </span><span style="color:#d08770;">href</span><span>="</span><span style="color:#a3be8c;">{{ quote_data.source_url }}</span><span>">{{ quote_data.source_display }}</</span><span style="color:#bf616a;">a</span><span>>
</span><span> {% else %}
</span><span> {{ quote_data.source_display | split(pat=" - ") | first }}
</span><span> {% endif %}
</span><span> </</span><span style="color:#bf616a;">p</span><span>>
</span><span> </</span><span style="color:#bf616a;">div</span><span>>
</span><span>
</span><span> <</span><span style="color:#bf616a;">div </span><span style="color:#d08770;">style</span><span>="flex-shrink: </span><span style="color:#d08770;">0</span><span>;
</span><span> display: flex;
</span><span> flex-direction: row-reverse;
</span><span> align-items: center">
</span><span> <</span><span style="color:#bf616a;">a </span><span style="color:#d08770;">class</span><span>="</span><span style="color:#a3be8c;">notado-icon</span><span>" </span><span style="color:#d08770;">href</span><span>="</span><span style="color:#a3be8c;">https://notado.app</span><span>" </span><span style="color:#d08770;">style</span><span>="border: none">
</span><span> <</span><span style="color:#bf616a;">img </span><span style="color:#d08770;">style</span><span>="height: </span><span style="color:#d08770;">3rem</span><span>;
</span><span> width: </span><span style="color:#d08770;">3rem</span><span>"
</span><span> </span><span style="color:#d08770;">src</span><span>="</span><span style="color:#a3be8c;">https://notado.app/static/notado-icon.png</span><span>"
</span><span> </span><span style="color:#d08770;">alt</span><span>="</span><span style="color:#a3be8c;">notado</span><span>" />
</span><span> </</span><span style="color:#bf616a;">a</span><span>>
</span><span> </</span><span style="color:#bf616a;">div</span><span>>
</span><span> </</span><span style="color:#bf616a;">div</span><span>>
</span><span> </</span><span style="color:#bf616a;">div</span><span>>
</span><span>
</span><span> {# djlint:off #}
</span><span> {% if use_markdown %}
</span><span> <</span><span style="color:#bf616a;">div</span><span>>{{ quote_data.content | markdown(inline=true) | safe }}</</span><span style="color:#bf616a;">div</span><span>>
</span><span> {% else %}
</span><span> <</span><span style="color:#bf616a;">div </span><span style="color:#d08770;">style</span><span>="white-space: pre-line">{{ quote_data.content }}</</span><span style="color:#bf616a;">div</span><span>>
</span><span> {% endif %}
</span><span> {# djlint:on #}
</span><span> </</span><span style="color:#bf616a;">div</span><span>>
</span><span>
</span><span> {% if caption %}
</span><span> <</span><span style="color:#bf616a;">div </span><span style="color:#d08770;">class</span><span>="</span><span style="color:#a3be8c;">notado-quote-caption</span><span>"
</span><span> </span><span style="color:#d08770;">style</span><span>="border: </span><span style="color:#d08770;">1px </span><span>solid </span><span style="color:#96b5b4;">var</span><span>(--border-color);
</span><span> background-color: </span><span style="color:#96b5b4;">var</span><span>(--bg-2) </span><span style="color:#b48ead;">!important</span><span>;
</span><span> position: relative;
</span><span> margin-bottom: </span><span style="color:#d08770;">1em</span><span>;
</span><span> border-bottom-left-radius: </span><span style="color:#d08770;">5px</span><span>;
</span><span> border-bottom-right-radius: </span><span style="color:#d08770;">5px</span><span>;
</span><span> padding-left: </span><span style="color:#d08770;">1rem</span><span>;
</span><span> padding-right: </span><span style="color:#d08770;">1rem</span><span>;
</span><span> padding-top: </span><span style="color:#d08770;">0.5rem</span><span>;
</span><span> padding-bottom: </span><span style="color:#d08770;">0.5rem</span><span>">
</span><span> {{ caption }}
</span><span> </</span><span style="color:#bf616a;">div</span><span>>
</span><span> {% endif %}
</span><span>{% endif %}
</span><span>
</span></code></pre>
<p>The first two pieces are the really interesting ones.</p>
<p>I maintain a <a href="https://github.com/LGUG2Z/notado-cli">CLI tool for my knowledge
base</a> which I extended with a command
which takes a list of source URLs and updates a library file.</p>
<pre data-lang="txt" style="background-color:#2b303b;color:#c0c5ce;" class="language-txt "><code class="language-txt" data-lang="txt"><span>❯ notado-cli quote-gen
</span><span>Adding quote data for https://bsky.app/profile/hmsnofun.bsky.social/post/3lmr6sm5k4k2b to library.json
</span><span>Adding quote data for https://defcon.social/@corbden/113473397794111625 to library.json
</span><span>Adding quote data for https://discourse.nixos.org/t/should-organizations-relating-to-the-defense-sector-being-able-to-sponsor-nixos/41252/6 to library.json
</span><span>Adding quote data for https://lobste.rs/s/rzskjk/i_think_i_m_done_thinking_about_genai_for#c_l9x7we to library.json
</span><span>Adding quote data for https://old.reddit.com/r/patientgamers/comments/udzo11/i_miss_the_days_of_server_browsers_and_community/i6lga1o/ to library.json
</span><span>Adding quote data for https://programming.dev/comment/5789966 to library.json
</span><span>Adding quote data for https://tildes.net/~tech/17xe/permanent_archival_formats_do_they_exist#comment-9feo to library.json
</span><span>Adding quote data for https://twitter.com/mitchellh/status/1744850961309597855?s=12 to library.json
</span><span>Adding quote data for https://www.youtube.com/watch?v=k0J0Dxf5JKc&lc=Ugwsmg7c0JpYiXyADQV4AaABAg to library.json
</span></code></pre>
<p>Whenever a new source is added and the command is run again, requests will only
be made to fill in the library file with new data:</p>
<pre data-lang="txt" style="background-color:#2b303b;color:#c0c5ce;" class="language-txt "><code class="language-txt" data-lang="txt"><span>❯ notado-cli quote-gen
</span><span>Skipping https://bsky.app/profile/hmsnofun.bsky.social/post/3lmr6sm5k4k2b as data is already in library.json
</span><span>Skipping https://defcon.social/@corbden/113473397794111625 as data is already in library.json
</span><span>Skipping https://discourse.nixos.org/t/should-organizations-relating-to-the-defense-sector-being-able-to-sponsor-nixos/41252/6 as data is already in library.json
</span><span>Skipping https://lobste.rs/s/rzskjk/i_think_i_m_done_thinking_about_genai_for#c_l9x7we as data is already in library.json
</span><span>Adding quote data for https://m.mtlynch.io/@michael/115538492543985760 to library.json
</span><span>Skipping https://news.ycombinator.com/item?id=41841873 as data is already in library.json
</span><span>Skipping https://old.reddit.com/r/patientgamers/comments/udzo11/i_miss_the_days_of_server_browsers_and_community/i6lga1o/ as data is already in library.json
</span><span>Skipping https://programming.dev/comment/5789966 as data is already in library.json
</span><span>Skipping https://tildes.net/~tech/17xe/permanent_archival_formats_do_they_exist#comment-9feo as data is already in library.json
</span><span>Skipping https://twitter.com/mitchellh/status/1744850961309597855?s=12 as data is already in library.json
</span><span>Skipping https://www.youtube.com/watch?v=k0J0Dxf5JKc&lc=Ugwsmg7c0JpYiXyADQV4AaABAg as data is already in library.json
</span></code></pre>
<p>In order to make diffs more pleasant after updates to the library file, I
decided to serialize it using a
<a href="https://doc.rust-lang.org/stable/std/collections/struct.BTreeMap.html">BTreeMap</a>
so that the serialized JSON object will be sorted alphabetically by the URL
keys.</p>
<p>With all these pieces in place, I can call my new shortcode to render this data
without making any outgoing HTTP requests at build-time... Just like I did
earlier in this article!</p>
<pre data-lang="jinja2" style="background-color:#2b303b;color:#c0c5ce;" class="language-jinja2 "><code class="language-jinja2" data-lang="jinja2"><span>{{
</span><span> </span><span style="color:#bf616a;">library</span><span>(
</span><span> </span><span style="color:#bf616a;">url</span><span>="</span><span style="color:#a3be8c;">https://m.mtlynch.io/@michael/115538492543985760</span><span>",
</span><span> </span><span style="color:#bf616a;">caption</span><span>="</span><span style="color:#a3be8c;">Michael is on to something... Embedding his comment here didn't trigger a HTTP request when this article was built!</span><span>",
</span><span> </span><span style="color:#bf616a;">markdown</span><span>=</span><span style="color:#d08770;">true
</span><span> )
</span><span>}}
</span></code></pre>
<p>One of my greatest pleasures in life is <a href="https://www.youtube.com/watch?v=Ehft-UvLxbo">building my own
tools</a> which work the way that I
want them to work.</p>
<p>The initial feeling of gratification upon their completion soon fuels even more
creativity in me as I slowly uncover new ways to integrate all of the different
tools that I have built to target more and more of the papercuts that are
just waiting to be addressed in the background of my life.</p>
<hr />
<p>If you have any questions or comments you can reach out to me on
<a href="https://bsky.app/profile/lgug2z.com">Bluesky</a> and
<a href="https://hachyderm.io/@LGUG2Z">Mastodon</a>.</p>
<p>If you're interested in what I read to come up with solutions like this, you
can subscribe to my <a href="https://notado.app/feeds/jado/software-development">Software Development RSS
feed</a>.</p>
<p>If you'd like to watch me writing code while explaining what I'm doing, you can
also <a href="https://www.youtube.com/channel/UCeai3-do-9O4MNy9_xjO6mg?sub_confirmation=1">subscribe to my YouTube
channel</a>.</p>
<p>If you would like early access to <a href="https://www.youtube.com/watch?v=u3eJcsa_MJk">komorebi for
Mac</a>, you can sponsor me on
<a href="https://github.com/sponsors/LGUG2Z">GitHub</a>.</p>
Subtleties to Consider When Parsing RSS Feeds in Zola ShortcodesThu, 13 Nov 2025 20:23:49 -0800lgug2z
https://lgug2z.com/articles/subtleties-to-consider-when-parsing-rss-feeds-in-zola-shortcodes/
https://lgug2z.com/articles/subtleties-to-consider-when-parsing-rss-feeds-in-zola-shortcodes/<p>I have steadily been working on migrating this website to
<a href="https://getzola.org">Zola</a>, and <a href="/articles/im-done-writing-shortcodes-for-other-websites">documenting parts of the
process</a> which I think
might be valuable for others who want to do the same in the future.</p>
<p>On the <a href="https://old.lgug2z.com">previous version</a> of this website, I used a Hugo
shortcode to display the last 3 posts from my Mastodon account on the <a href="/">landing
page</a>.</p>
<p>The shortcode rendered each Mastodon post differently depending on whether or
not it had one or more pieces of attached media such as images and videos.</p>
<p>(warning: it ain't pretty)</p>
<pre data-lang="html" style="background-color:#2b303b;color:#c0c5ce;" class="language-html "><code class="language-html" data-lang="html"><span>{{ if .Get "url" }}
</span><span>{{ $url := .Get "url" }}
</span><span>{{ $limit := .Get "limit" }}
</span><span>
</span><span>{{ with resources.GetRemote $url | transform.Unmarshal }}
</span><span>{{ range first $limit .channel.item }}
</span><span><</span><span style="color:#bf616a;">div </span><span style="color:#d08770;">style</span><span>="padding: </span><span style="color:#d08770;">10px</span><span>; background-color: </span><span style="color:#96b5b4;">#373641</span><span>; word-wrap: break-word">
</span><span> {{ $type := (printf "%T" .content) }}
</span><span> {{ if eq $type "[]interface {}" }}
</span><span>
</span><span> {{ range .content }}
</span><span> {{ if index . "-url" }}
</span><span> {{ $url := index . "-url" }}
</span><span>
</span><span> {{ if hasSuffix $url "mp4" }}
</span><span> <</span><span style="color:#bf616a;">video </span><span style="color:#d08770;">controls style</span><span>="width: </span><span style="color:#d08770;">100% </span><span style="color:#b48ead;">!important</span><span>; height: auto </span><span style="color:#b48ead;">!important</span><span>;">
</span><span> <</span><span style="color:#bf616a;">source </span><span style="color:#d08770;">src</span><span>="</span><span style="color:#a3be8c;">{{ $url }}</span><span>">
</span><span> </</span><span style="color:#bf616a;">video</span><span>>
</span><span> {{ else }}
</span><span> <</span><span style="color:#bf616a;">img </span><span style="color:#d08770;">src</span><span>="</span><span style="color:#a3be8c;">{{ $url }}</span><span>">
</span><span> {{ end }}
</span><span> {{ end }}
</span><span> {{ end }}
</span><span>
</span><span> {{ else }}
</span><span>
</span><span> {{ if index .content "-url" }}
</span><span> {{ $url := index .content "-url" }}
</span><span>
</span><span> {{ if hasSuffix $url "mp4" }}
</span><span> <</span><span style="color:#bf616a;">video </span><span style="color:#d08770;">controls style</span><span>="width: </span><span style="color:#d08770;">100% </span><span style="color:#b48ead;">!important</span><span>; height: auto </span><span style="color:#b48ead;">!important</span><span>;">
</span><span> <</span><span style="color:#bf616a;">source </span><span style="color:#d08770;">src</span><span>="</span><span style="color:#a3be8c;">{{ $url }}</span><span>">
</span><span> </</span><span style="color:#bf616a;">video</span><span>>
</span><span> {{ else }}
</span><span> <</span><span style="color:#bf616a;">img </span><span style="color:#d08770;">src</span><span>="</span><span style="color:#a3be8c;">{{ $url }}</span><span>">
</span><span> {{ end }}
</span><span> {{ end }}
</span><span>
</span><span> {{ end }}
</span><span>
</span><span> {{ if .description }}
</span><span> {{ .description | safeHTML }}
</span><span> {{ end }}
</span><span> <</span><span style="color:#bf616a;">p</span><span>>(<</span><span style="color:#bf616a;">a </span><span style="color:#d08770;">href</span><span>="</span><span style="color:#a3be8c;">{{ .link }}</span><span>">View</</span><span style="color:#bf616a;">a</span><span>>)</</span><span style="color:#bf616a;">p</span><span>>
</span><span></</span><span style="color:#bf616a;">div</span><span>>
</span><span><</span><span style="color:#bf616a;">br </span><span>/>
</span><span>{{ end }}
</span><span>{{ end }}
</span><span>
</span><span>{{ end }}
</span></code></pre>
<p>Below is what an <code>Item</code> entry for a post that has attached media looks like.
Notably, it contains a <code>media:content</code> with <code>url</code>, <code>type</code>, <code>fileSize</code> and
<code>medium</code> attributes.</p>
<pre data-lang="xml" style="background-color:#2b303b;color:#c0c5ce;" class="language-xml "><code class="language-xml" data-lang="xml"><span><</span><span style="color:#bf616a;">item</span><span>>
</span><span> <</span><span style="color:#bf616a;">guid </span><span style="color:#d08770;">isPermaLink</span><span>="</span><span style="color:#a3be8c;">true</span><span>">https://hachyderm.io/@LGUG2Z/115543625965938161</</span><span style="color:#bf616a;">guid</span><span>>
</span><span> <</span><span style="color:#bf616a;">link</span><span>>https://hachyderm.io/@LGUG2Z/115543625965938161</</span><span style="color:#bf616a;">link</span><span>>
</span><span> <</span><span style="color:#bf616a;">pubDate</span><span>>Thu, 13 Nov 2025 17:43:36 +0000</</span><span style="color:#bf616a;">pubDate</span><span>>
</span><span> <</span><span style="color:#bf616a;">description</span><span>></span><span style="color:#8fa1b3;">&</span><span style="color:#d08770;">lt;</span><span>p</span><span style="color:#8fa1b3;">&</span><span style="color:#d08770;">gt;</span><span>Wild how often I</span><span style="color:#8fa1b3;">&</span><span style="color:#d08770;">amp;</span><span>#39;m being asked if komorebi is currently on or will in the future be coming to Linux</span><span style="color:#8fa1b3;">&</span><span style="color:#d08770;">lt;</span><span>/p</span><span style="color:#8fa1b3;">&</span><span style="color:#d08770;">gt;</span><span></</span><span style="color:#bf616a;">description</span><span>>
</span><span> <</span><span style="color:#bf616a;">media:content </span><span style="color:#d08770;">url</span><span>="</span><span style="color:#a3be8c;">https://media.hachyderm.io/media_attachments/files/115/543/625/239/633/063/original/b3acc14a489784cb.png</span><span>" </span><span style="color:#d08770;">type</span><span>="</span><span style="color:#a3be8c;">image/png</span><span>" </span><span style="color:#d08770;">fileSize</span><span>="</span><span style="color:#a3be8c;">40820</span><span>" </span><span style="color:#d08770;">medium</span><span>="</span><span style="color:#a3be8c;">image</span><span>">
</span><span> <</span><span style="color:#bf616a;">media:rating </span><span style="color:#d08770;">scheme</span><span>="</span><span style="color:#a3be8c;">urn:simple</span><span>">nonadult</</span><span style="color:#bf616a;">media:rating</span><span>>
</span><span> </</span><span style="color:#bf616a;">media:content</span><span>>
</span><span></</span><span style="color:#bf616a;">item</span><span>>
</span></code></pre>
<p>My first pass at translating this shortcode for Zola looked like this.</p>
<pre data-lang="jinja2" style="background-color:#2b303b;color:#c0c5ce;" class="language-jinja2 "><code class="language-jinja2" data-lang="jinja2"><span>{% </span><span style="color:#b48ead;">if </span><span style="color:#bf616a;">url </span><span>%}
</span><span> {% </span><span style="color:#b48ead;">set </span><span style="color:#bf616a;">feed_data </span><span>= </span><span style="color:#bf616a;">load_data</span><span>(</span><span style="color:#bf616a;">url</span><span>=</span><span style="color:#bf616a;">url</span><span>, </span><span style="color:#bf616a;">format</span><span>="</span><span style="color:#a3be8c;">xml</span><span>") %}
</span><span> {% </span><span style="color:#b48ead;">set </span><span style="color:#bf616a;">items </span><span>= </span><span style="color:#bf616a;">feed_data</span><span>.</span><span style="color:#bf616a;">rss</span><span>.</span><span style="color:#bf616a;">channel</span><span>.</span><span style="color:#bf616a;">item </span><span>%}
</span><span> {% </span><span style="color:#b48ead;">set </span><span style="color:#bf616a;">limit </span><span>= </span><span style="color:#bf616a;">limit </span><span>| </span><span style="color:#bf616a;">default</span><span>(</span><span style="color:#bf616a;">value</span><span>=3) %}
</span><span>
</span><span> {% </span><span style="color:#b48ead;">for </span><span style="color:#bf616a;">item </span><span style="color:#b48ead;">in </span><span style="color:#bf616a;">items </span><span>| </span><span style="color:#bf616a;">slice</span><span>(</span><span style="color:#bf616a;">end</span><span>=</span><span style="color:#bf616a;">limit</span><span>) %}
</span><span> <div style="word-wrap: break-word">
</span><span>
</span><span> {% </span><span style="color:#b48ead;">if </span><span style="color:#bf616a;">item</span><span>["</span><span style="color:#a3be8c;">media:content</span><span>"] %}
</span><span> {% </span><span style="color:#b48ead;">set </span><span style="color:#bf616a;">media_content </span><span>= </span><span style="color:#bf616a;">item</span><span>["</span><span style="color:#a3be8c;">media:content</span><span>"] %}
</span><span>
</span><span> {% </span><span style="color:#b48ead;">if </span><span style="color:#bf616a;">media_content </span><span style="color:#b48ead;">is </span><span style="color:#bf616a;">iterable </span><span style="color:#b48ead;">and </span><span style="color:#bf616a;">media_content</span><span>.</span><span style="color:#bf616a;">url </span><span style="color:#b48ead;">is </span><span style="color:#bf616a;">not defined </span><span>%}
</span><span> {% </span><span style="color:#b48ead;">for </span><span style="color:#bf616a;">media_item </span><span style="color:#b48ead;">in </span><span style="color:#bf616a;">media_content </span><span>%}
</span><span> {% </span><span style="color:#b48ead;">if </span><span style="color:#bf616a;">media_item</span><span>.</span><span style="color:#bf616a;">url </span><span>%}
</span><span> {% </span><span style="color:#b48ead;">set </span><span style="color:#bf616a;">media_url </span><span>= </span><span style="color:#bf616a;">media_item</span><span>.</span><span style="color:#bf616a;">url </span><span>%}
</span><span>
</span><span> {% </span><span style="color:#b48ead;">if </span><span style="color:#bf616a;">media_url </span><span>| </span><span style="color:#bf616a;">split</span><span>(</span><span style="color:#bf616a;">pat</span><span>="</span><span style="color:#a3be8c;">.</span><span>") | </span><span style="color:#bf616a;">last </span><span>== "</span><span style="color:#a3be8c;">mp4</span><span>" %}
</span><span> <video controls style="width: 100% !important; height: auto !important;">
</span><span> <source src="{{ </span><span style="color:#bf616a;">media_url </span><span>}}">
</span><span> </video>
</span><span> {% </span><span style="color:#b48ead;">else </span><span>%}
</span><span> <img src="{{ </span><span style="color:#bf616a;">media_url </span><span>}}" style="max-width: 100%; height: auto;">
</span><span> {% </span><span style="color:#b48ead;">endif </span><span>%}
</span><span> {% </span><span style="color:#b48ead;">endif </span><span>%}
</span><span> {% </span><span style="color:#b48ead;">endfor </span><span>%}
</span><span> {% </span><span style="color:#b48ead;">else </span><span>%}
</span><span> {% </span><span style="color:#b48ead;">if </span><span style="color:#bf616a;">media_content</span><span>.</span><span style="color:#bf616a;">url </span><span>%}
</span><span> {% </span><span style="color:#b48ead;">set </span><span style="color:#bf616a;">media_url </span><span>= </span><span style="color:#bf616a;">media_content</span><span>.</span><span style="color:#bf616a;">url </span><span>%}
</span><span>
</span><span> {% </span><span style="color:#b48ead;">if </span><span style="color:#bf616a;">media_url </span><span>| </span><span style="color:#bf616a;">split</span><span>(</span><span style="color:#bf616a;">pat</span><span>="</span><span style="color:#a3be8c;">.</span><span>") | </span><span style="color:#bf616a;">last </span><span>== "</span><span style="color:#a3be8c;">mp4</span><span>" %}
</span><span> <video controls style="width: 100% !important; height: auto !important;">
</span><span> <source src="{{ </span><span style="color:#bf616a;">media_url </span><span>}}">
</span><span> </video>
</span><span> {% </span><span style="color:#b48ead;">else </span><span>%}
</span><span> <img src="{{ </span><span style="color:#bf616a;">media_url </span><span>}}" style="max-width: 100%; height: auto;">
</span><span> {% </span><span style="color:#b48ead;">endif </span><span>%}
</span><span> {% </span><span style="color:#b48ead;">endif </span><span>%}
</span><span> {% </span><span style="color:#b48ead;">endif </span><span>%}
</span><span> {% </span><span style="color:#b48ead;">endif </span><span>%}
</span><span>
</span><span> {% </span><span style="color:#b48ead;">if </span><span style="color:#bf616a;">item</span><span>.</span><span style="color:#bf616a;">description </span><span>%}
</span><span> {{ </span><span style="color:#bf616a;">item</span><span>.</span><span style="color:#bf616a;">description </span><span>| </span><span style="color:#bf616a;">safe </span><span>}}
</span><span> {% </span><span style="color:#b48ead;">endif </span><span>%}
</span><span>
</span><span> <p>
</span><span> (<a href="{{ </span><span style="color:#bf616a;">item</span><span>.</span><span style="color:#bf616a;">link </span><span>}}">View</a>)
</span><span> </p>
</span><span> </div>
</span><span> <br />
</span><span> {% </span><span style="color:#b48ead;">endfor </span><span>%}
</span><span>{% </span><span style="color:#b48ead;">endif </span><span>%}
</span></code></pre>
<p>I thought everything was working because at the time, my recent posts did not
contain any media attachments. Today however, I noticed that a media attachment
I expected to see rendered with a post was not there.</p>
<p>After a little manual debugging to see if the <code>{% if item["media:content"] %}</code>
clause ever returned <code>true</code> (it didn't) and trying different variations of the
<code>media:content</code> key to try and access that data in the RSS <code>Item</code>, I decided to
just take a look at how <code>xml</code> data is deserialized and reserialized in Zola.</p>
<script src=https://lgug2z.com/js/mermaid.js></script>
<pre class="mermaid">
graph LR
A[Zola] --> B[Tera]
B --> C[quickxml_to_serde]
</pre>
<p>I quickly found an <a href="https://github.com/AlecTroemel/quickxml_to_serde/issues/27">open
issue</a> in the
<code>quickxml_to_serde</code> repo which demonstrated exactly what was happening in the
serialization process: namespace prefixes (like our <code>media:</code> were getting
silently dropped, and additionally, attributes like <code>url</code> were being prefixed
with <code>@</code>.</p>
<pre data-lang="xml" style="background-color:#2b303b;color:#c0c5ce;" class="language-xml "><code class="language-xml" data-lang="xml"><span><</span><span style="color:#bf616a;">s:Envelope </span><span style="color:#d08770;">xmlns:a</span><span>="</span><span style="color:#a3be8c;">http://www.w3.org/2005/08/addressing</span><span>" </span><span style="color:#d08770;">xmlns:s</span><span>="</span><span style="color:#a3be8c;">http://www.w3.org/2003/05/soap-envelope</span><span>">
</span><span> <</span><span style="color:#bf616a;">s:Header</span><span>>
</span><span> <</span><span style="color:#bf616a;">a:Action </span><span style="color:#d08770;">s:mustUnderstand</span><span>="</span><span style="color:#a3be8c;">1</span><span>">http://redacted.redacted.com/redacted/redacted/StartUsage</</span><span style="color:#bf616a;">a:Action</span><span>>
</span><span> <</span><span style="color:#bf616a;">a:MessageID</span><span>>urn:uuid:15aea56f-7690-467c-8d6b-145f64cd2747</</span><span style="color:#bf616a;">a:MessageID</span><span>>
</span><span> <</span><span style="color:#bf616a;">a:ReplyTo</span><span>>
</span><span> <</span><span style="color:#bf616a;">a:Address</span><span>>http://www.w3.org/2005/08/addressing/anonymous</</span><span style="color:#bf616a;">a:Address</span><span>>
</span><span> </</span><span style="color:#bf616a;">a:ReplyTo</span><span>>
</span><span> <</span><span style="color:#bf616a;">a:To </span><span style="color:#d08770;">s:mustUnderstand</span><span>="</span><span style="color:#a3be8c;">1</span><span>">sb://redacted.servicebus.windows.net/redacted/90f791da-dc1e-49c9-a55c-dfd8ad4398ee/</</span><span style="color:#bf616a;">a:To</span><span>>
</span><span> </</span><span style="color:#bf616a;">s:Header</span><span>>
</span><span> <</span><span style="color:#bf616a;">s:Body</span><span>>
</span><span> <</span><span style="color:#bf616a;">StartUsage </span><span style="color:#d08770;">xmlns</span><span>="</span><span style="color:#a3be8c;">http://redacted.redacted.com/redacted/</span><span>"/>
</span><span> </</span><span style="color:#bf616a;">s:Body</span><span>>
</span><span></</span><span style="color:#bf616a;">s:Envelope</span><span>>
</span></code></pre>
<pre data-lang="json" style="background-color:#2b303b;color:#c0c5ce;" class="language-json "><code class="language-json" data-lang="json"><span>{
</span><span> "</span><span style="color:#a3be8c;">Envelope</span><span>": {
</span><span> "</span><span style="color:#a3be8c;">Body</span><span>": {
</span><span> "</span><span style="color:#a3be8c;">StartUsage</span><span>": {}
</span><span> },
</span><span> "</span><span style="color:#a3be8c;">Header</span><span>": {
</span><span> "</span><span style="color:#a3be8c;">Action</span><span>": {
</span><span> "</span><span style="color:#a3be8c;">#text</span><span>": "</span><span style="color:#a3be8c;">http://redacted.redacted.com/redacted/redacted/StartUsage</span><span>",
</span><span> "</span><span style="color:#a3be8c;">@s:mustUnderstand</span><span>": </span><span style="color:#d08770;">1
</span><span> },
</span><span> "</span><span style="color:#a3be8c;">MessageID</span><span>": "</span><span style="color:#a3be8c;">urn:uuid:15aea56f-7690-467c-8d6b-145f64cd2747</span><span>",
</span><span> "</span><span style="color:#a3be8c;">ReplyTo</span><span>": {
</span><span> "</span><span style="color:#a3be8c;">Address</span><span>": "</span><span style="color:#a3be8c;">http://www.w3.org/2005/08/addressing/anonymous</span><span>"
</span><span> },
</span><span> "</span><span style="color:#a3be8c;">To</span><span>": {
</span><span> "</span><span style="color:#a3be8c;">#text</span><span>": "</span><span style="color:#a3be8c;">sb://redacted.servicebus.windows.net/redacted/90f791da-dc1e-49c9-a55c-dfd8ad4398ee/</span><span>",
</span><span> "</span><span style="color:#a3be8c;">@s:mustUnderstand</span><span>": </span><span style="color:#d08770;">1
</span><span> }
</span><span> }
</span><span> }
</span><span>}
</span></code></pre>
<p>With this new knowledge in hand, I was able to update my shortcode to correctly handle Mastodon posts with media attachments.</p>
<pre data-lang="jinja2" style="background-color:#2b303b;color:#c0c5ce;" class="language-jinja2 "><code class="language-jinja2" data-lang="jinja2"><span>{% </span><span style="color:#b48ead;">if </span><span style="color:#bf616a;">url </span><span>%}
</span><span> {% </span><span style="color:#b48ead;">set </span><span style="color:#bf616a;">feed_data </span><span>= </span><span style="color:#bf616a;">load_data</span><span>(</span><span style="color:#bf616a;">url</span><span>=</span><span style="color:#bf616a;">url</span><span>, </span><span style="color:#bf616a;">format</span><span>="</span><span style="color:#a3be8c;">xml</span><span>") %}
</span><span> {% </span><span style="color:#b48ead;">set </span><span style="color:#bf616a;">items </span><span>= </span><span style="color:#bf616a;">feed_data</span><span>.</span><span style="color:#bf616a;">rss</span><span>.</span><span style="color:#bf616a;">channel</span><span>.</span><span style="color:#bf616a;">item </span><span>%}
</span><span> {% </span><span style="color:#b48ead;">set </span><span style="color:#bf616a;">limit </span><span>= </span><span style="color:#bf616a;">limit </span><span>| </span><span style="color:#bf616a;">default</span><span>(</span><span style="color:#bf616a;">value</span><span>=3) %}
</span><span>
</span><span> {% </span><span style="color:#b48ead;">for </span><span style="color:#bf616a;">item </span><span style="color:#b48ead;">in </span><span style="color:#bf616a;">items </span><span>| </span><span style="color:#bf616a;">slice</span><span>(</span><span style="color:#bf616a;">end</span><span>=</span><span style="color:#bf616a;">limit</span><span>) %}
</span><span> <div style="word-wrap: break-word">
</span><span> {% </span><span style="color:#b48ead;">if </span><span style="color:#bf616a;">item</span><span>["</span><span style="color:#a3be8c;">content</span><span>"] %}
</span><span> {% </span><span style="color:#b48ead;">set </span><span style="color:#bf616a;">media_content </span><span>= </span><span style="color:#bf616a;">item</span><span>["</span><span style="color:#a3be8c;">content</span><span>"] %}
</span><span>
</span><span> {% </span><span style="color:#b48ead;">if </span><span style="color:#bf616a;">media_content </span><span style="color:#b48ead;">is </span><span style="color:#bf616a;">iterable </span><span style="color:#b48ead;">and </span><span style="color:#bf616a;">media_content</span><span>["</span><span style="color:#a3be8c;">@url</span><span>"] </span><span style="color:#b48ead;">is </span><span style="color:#bf616a;">not defined </span><span>%}
</span><span> {% </span><span style="color:#b48ead;">for </span><span style="color:#bf616a;">media_item </span><span style="color:#b48ead;">in </span><span style="color:#bf616a;">media_content </span><span>%}
</span><span> {% </span><span style="color:#b48ead;">if </span><span style="color:#bf616a;">media_item</span><span>["</span><span style="color:#a3be8c;">@url</span><span>"] %}
</span><span> {% </span><span style="color:#b48ead;">set </span><span style="color:#bf616a;">media_url </span><span>= </span><span style="color:#bf616a;">media_item</span><span>["</span><span style="color:#a3be8c;">@url</span><span>"] %}
</span><span>
</span><span> {% </span><span style="color:#b48ead;">if </span><span style="color:#bf616a;">media_url </span><span>| </span><span style="color:#bf616a;">split</span><span>(</span><span style="color:#bf616a;">pat</span><span>="</span><span style="color:#a3be8c;">.</span><span>") | </span><span style="color:#bf616a;">last </span><span>== "</span><span style="color:#a3be8c;">mp4</span><span>" %}
</span><span> <video controls style="width: 100% !important; height: auto !important;">
</span><span> <source src="{{ </span><span style="color:#bf616a;">media_url </span><span>}}">
</span><span> </video>
</span><span> {% </span><span style="color:#b48ead;">else </span><span>%}
</span><span> <img src="{{ </span><span style="color:#bf616a;">media_url </span><span>}}" style="max-width: 100%; height: auto;">
</span><span> {% </span><span style="color:#b48ead;">endif </span><span>%}
</span><span> {% </span><span style="color:#b48ead;">endif </span><span>%}
</span><span> {% </span><span style="color:#b48ead;">endfor </span><span>%}
</span><span> {% </span><span style="color:#b48ead;">else </span><span>%}
</span><span> {% </span><span style="color:#b48ead;">if </span><span style="color:#bf616a;">media_content</span><span>["</span><span style="color:#a3be8c;">@url</span><span>"] %}
</span><span> {% </span><span style="color:#b48ead;">set </span><span style="color:#bf616a;">media_url </span><span>= </span><span style="color:#bf616a;">media_content</span><span>["</span><span style="color:#a3be8c;">@url</span><span>"] %}
</span><span>
</span><span> {% </span><span style="color:#b48ead;">if </span><span style="color:#bf616a;">media_url </span><span>| </span><span style="color:#bf616a;">split</span><span>(</span><span style="color:#bf616a;">pat</span><span>="</span><span style="color:#a3be8c;">.</span><span>") | </span><span style="color:#bf616a;">last </span><span>== "</span><span style="color:#a3be8c;">mp4</span><span>" %}
</span><span> <video controls style="width: 100% !important; height: auto !important;">
</span><span> <source src="{{ </span><span style="color:#bf616a;">media_url </span><span>}}">
</span><span> </video>
</span><span> {% </span><span style="color:#b48ead;">else </span><span>%}
</span><span> <img src="{{ </span><span style="color:#bf616a;">media_url </span><span>}}" style="max-width: 100%; height: auto;">
</span><span> {% </span><span style="color:#b48ead;">endif </span><span>%}
</span><span> {% </span><span style="color:#b48ead;">endif </span><span>%}
</span><span> {% </span><span style="color:#b48ead;">endif </span><span>%}
</span><span> {% </span><span style="color:#b48ead;">endif </span><span>%}
</span><span>
</span><span> {% </span><span style="color:#b48ead;">if </span><span style="color:#bf616a;">item</span><span>.</span><span style="color:#bf616a;">description </span><span>%}
</span><span> {{ </span><span style="color:#bf616a;">item</span><span>.</span><span style="color:#bf616a;">description </span><span>| </span><span style="color:#bf616a;">safe </span><span>}}
</span><span> {% </span><span style="color:#b48ead;">endif </span><span>%}
</span><span>
</span><span> <p>
</span><span> (<a href="{{ </span><span style="color:#bf616a;">item</span><span>.</span><span style="color:#bf616a;">link </span><span>}}">View</a>)
</span><span> </p>
</span><span> </div>
</span><span> <br />
</span><span> {% </span><span style="color:#b48ead;">endfor </span><span>%}
</span><span>{% </span><span style="color:#b48ead;">endif </span><span>%}
</span><span>
</span></code></pre>
<hr />
<p>If you have any questions or comments you can reach out to me on
<a href="https://bsky.app/profile/lgug2z.com">Bluesky</a> and
<a href="https://hachyderm.io/@LGUG2Z">Mastodon</a>.</p>
<p>If you're interested in what I read to come up with solutions like this, you
can subscribe to my <a href="https://notado.app/feeds/jado/software-development">Software Development RSS
feed</a>.</p>
<p>If you'd like to watch me writing code while explaining what I'm doing, you can
also <a href="https://www.youtube.com/channel/UCeai3-do-9O4MNy9_xjO6mg?sub_confirmation=1">subscribe to my YouTube
channel</a>.</p>
<p>If you would like early access to <a href="https://www.youtube.com/watch?v=u3eJcsa_MJk">komorebi for
Mac</a>, you can sponsor me on
<a href="https://github.com/sponsors/LGUG2Z">GitHub</a>.</p>
I'm Done Writing Shortcodes for Other WebsitesTue, 11 Nov 2025 23:55:45 -0800lgug2z
https://lgug2z.com/articles/im-done-writing-shortcodes-for-other-websites/
https://lgug2z.com/articles/im-done-writing-shortcodes-for-other-websites/<p>After finally having reached my limit with the number of breaking changes I'm
faced with every time I update <code>hugo</code>, I decided to migrate my website over
to <a href="https://getzola.org"><code>zola</code></a>.</p>
<div class="notado-quote"
style="border: 1px solid var(--border-color);
background-color: var(--bg-primary) !important;
position: relative;
margin-block: 1em;
border-radius: 5px;
padding-left: 1rem;
padding-right: 1rem;
padding-top: 1.25rem;
padding-bottom: 1.25rem;
margin-bottom: 0em;
border-bottom-right-radius: 0px !important;
border-bottom-left-radius: 0px !important;
">
<div style="padding-bottom: 1.25rem">
<div style="display: flex; gap: 0.75rem">
<div style="min-width: 0;
flex: 1 1 0%;
display: flex;
flex-direction: column;
justify-content: center">
<p style="text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
margin: 0em">
rodgerd
</p>
<p style="color: var(--text-1); margin: 0em">
<a href="https://news.ycombinator.com/item?id=41841873">news.ycombinator.com</a>
</p>
</div>
<div style="flex-shrink: 0;
display: flex;
flex-direction: row-reverse;
align-items: center">
<a class="notado-icon" href="https://notado.app" style="border: none">
<img style="height: 3rem;
width: 3rem"
src="https://notado.app/static/notado-icon.png"
alt="notado" />
</a>
</div>
</div>
</div>
<div style="white-space: pre-line">Yeah, I adopted Hugo and now I regret it. Every release seems to break themes, and many theme maintainers give up trying to keep current, so if you use someone else's theme you're running a small intersection of hugo versions and can't rely on packaged releases.
A static site generator that has somehow managed to be more inconvenient than many CMSes.</div>
</div>
<div class="notado-quote-caption"
style="border: 1px solid var(--border-color);
background-color: var(--bg-2) !important;
position: relative;
margin-bottom: 1em;
border-bottom-left-radius: 5px;
border-bottom-right-radius: 5px;
padding-left: 1rem;
padding-right: 1rem;
padding-top: 0.5rem;
padding-bottom: 0.5rem">
This is using the shortcode I'm going to talk about! (and also why I got fed up with Hugo...)
</div>
<p>During this migration, the shortcodes for commentary websites like Twitter
struck me as particularly brittle.</p>
<p>After wasting a bunch of time trying to migrate over a number of such shortcodes
used in my past posts, I remembered that any text content I would ever want to
share from another website, highlight or comment, is already archived in my
<a href="https://notado.app">Notado</a> account.</p>
<p>This website already features a number of integrations with my Notado account,
such as the <a href="/articles/embedding-rss-feeds-on-my-static-website">topic-specific
feeds</a> on my <a href="/">landing page</a>,
and the <a href="/software/komorebi">user testimonials for my software</a>.</p>
<p>So why not just make <em>One Shortcode to Rule Them All</em> which I can use in all of my posts?</p>
<pre data-lang="html" style="background-color:#2b303b;color:#c0c5ce;" class="language-html "><code class="language-html" data-lang="html"><span style="color:#65737e;"><!-- quote.html -->
</span><span>
</span><span>{% if url %}
</span><span> {% set quote_data = load_data(url=url ~ "/json", format="json") %}
</span><span>
</span><span> <</span><span style="color:#bf616a;">div </span><span style="color:#d08770;">class</span><span>="</span><span style="color:#a3be8c;">notado-quote</span><span>"
</span><span> </span><span style="color:#d08770;">style</span><span>="border: </span><span style="color:#d08770;">1px </span><span>solid </span><span style="color:#96b5b4;">var</span><span>(--border-color);
</span><span> background-color: </span><span style="color:#96b5b4;">var</span><span>(--bg-primary) </span><span style="color:#b48ead;">!important</span><span>;
</span><span> position: relative;
</span><span> margin-block: </span><span style="color:#d08770;">1em</span><span>;
</span><span> border-radius: </span><span style="color:#d08770;">5px</span><span>;
</span><span> padding-left: </span><span style="color:#d08770;">1rem</span><span>;
</span><span> padding-right: </span><span style="color:#d08770;">1rem</span><span>;
</span><span> padding-top: </span><span style="color:#d08770;">1.25rem</span><span>;
</span><span> padding-bottom: </span><span style="color:#d08770;">1.25rem</span><span>;
</span><span> {% if caption %} margin-bottom: </span><span style="color:#d08770;">0em</span><span>;
</span><span> border-bottom-right-radius: </span><span style="color:#d08770;">0px </span><span style="color:#b48ead;">!important</span><span>;
</span><span> border-bottom-left-radius: </span><span style="color:#d08770;">0px </span><span style="color:#b48ead;">!important</span><span>;
</span><span> {% endif %}">
</span><span>
</span><span> <</span><span style="color:#bf616a;">div </span><span style="color:#d08770;">style</span><span>="padding-bottom: </span><span style="color:#d08770;">1.25rem</span><span>">
</span><span> <</span><span style="color:#bf616a;">div </span><span style="color:#d08770;">style</span><span>="display: flex; gap: </span><span style="color:#d08770;">0.75rem</span><span>">
</span><span> <</span><span style="color:#bf616a;">div </span><span style="color:#d08770;">style</span><span>="min-width: </span><span style="color:#d08770;">0</span><span>;
</span><span> flex: </span><span style="color:#d08770;">1 1 0%</span><span>;
</span><span> display: flex;
</span><span> flex-direction: column;
</span><span> justify-content: center">
</span><span> <</span><span style="color:#bf616a;">p </span><span style="color:#d08770;">style</span><span>="text-overflow: ellipsis;
</span><span> overflow: hidden;
</span><span> white-space: nowrap;
</span><span> margin: </span><span style="color:#d08770;">0em</span><span>">
</span><span> {{ quote_data.title }}
</span><span> </</span><span style="color:#bf616a;">p</span><span>>
</span><span> <</span><span style="color:#bf616a;">p </span><span style="color:#d08770;">style</span><span>="color: </span><span style="color:#96b5b4;">var</span><span>(--text-1); margin: </span><span style="color:#d08770;">0em</span><span>">
</span><span> {% if quote_data.source_url %}
</span><span> <</span><span style="color:#bf616a;">a </span><span style="color:#d08770;">href</span><span>="</span><span style="color:#a3be8c;">{{ quote_data.source_url }}</span><span>">{{ quote_data.source_display }}</</span><span style="color:#bf616a;">a</span><span>>
</span><span> {% else %}
</span><span> {{ quote_data.source_display | split(pat=" - ") | first }}
</span><span> {% endif %}
</span><span> </</span><span style="color:#bf616a;">p</span><span>>
</span><span> </</span><span style="color:#bf616a;">div</span><span>>
</span><span>
</span><span> <</span><span style="color:#bf616a;">div </span><span style="color:#d08770;">style</span><span>="flex-shrink: </span><span style="color:#d08770;">0</span><span>;
</span><span> display: flex;
</span><span> flex-direction: row-reverse;
</span><span> align-items: center">
</span><span> <</span><span style="color:#bf616a;">a </span><span style="color:#d08770;">class</span><span>="</span><span style="color:#a3be8c;">notado-icon</span><span>" </span><span style="color:#d08770;">href</span><span>="</span><span style="color:#a3be8c;">https://notado.app</span><span>" </span><span style="color:#d08770;">style</span><span>="border: none">
</span><span> <</span><span style="color:#bf616a;">img </span><span style="color:#d08770;">style</span><span>="height: </span><span style="color:#d08770;">3rem</span><span>;
</span><span> width: </span><span style="color:#d08770;">3rem</span><span>"
</span><span> </span><span style="color:#d08770;">src</span><span>="</span><span style="color:#a3be8c;">https://notado.app/static/notado-icon.png</span><span>"
</span><span> </span><span style="color:#d08770;">alt</span><span>="</span><span style="color:#a3be8c;">notado</span><span>" />
</span><span> </</span><span style="color:#bf616a;">a</span><span>>
</span><span> </</span><span style="color:#bf616a;">div</span><span>>
</span><span> </</span><span style="color:#bf616a;">div</span><span>>
</span><span> </</span><span style="color:#bf616a;">div</span><span>>
</span><span>
</span><span> {# djlint:off #}
</span><span> <</span><span style="color:#bf616a;">div </span><span style="color:#d08770;">style</span><span>="white-space: pre-line">{{ quote_data.content }}</</span><span style="color:#bf616a;">div</span><span>>
</span><span> {# djlint:on #}
</span><span> </</span><span style="color:#bf616a;">div</span><span>>
</span><span>
</span><span> {% if caption %}
</span><span> <</span><span style="color:#bf616a;">div </span><span style="color:#d08770;">class</span><span>="</span><span style="color:#a3be8c;">notado-quote-caption</span><span>"
</span><span> </span><span style="color:#d08770;">style</span><span>="border: </span><span style="color:#d08770;">1px </span><span>solid </span><span style="color:#96b5b4;">var</span><span>(--border-color);
</span><span> background-color: </span><span style="color:#96b5b4;">var</span><span>(--bg-2) </span><span style="color:#b48ead;">!important</span><span>;
</span><span> position: relative;
</span><span> margin-bottom: </span><span style="color:#d08770;">1em</span><span>;
</span><span> border-bottom-left-radius: </span><span style="color:#d08770;">5px</span><span>;
</span><span> border-bottom-right-radius: </span><span style="color:#d08770;">5px</span><span>;
</span><span> padding-left: </span><span style="color:#d08770;">1rem</span><span>;
</span><span> padding-right: </span><span style="color:#d08770;">1rem</span><span>;
</span><span> padding-top: </span><span style="color:#d08770;">0.5rem</span><span>;
</span><span> padding-bottom: </span><span style="color:#d08770;">0.5rem</span><span>">
</span><span> {{ caption }}
</span><span> </</span><span style="color:#bf616a;">div</span><span>>
</span><span> {% endif %}
</span><span>{% endif %}
</span><span>
</span></code></pre>
<p>(I'm sure there are a bunch of unncessary <code>div</code>s and CSS styles in there, and
that the whole thing could probably be drastically simplified - feel free to @
me with any suggestions)</p>
<p>I can call this shortcode anywhere in my articles. For example, here is how I
added the Hacker News comment at the top of this article:</p>
<pre data-lang="jinja2" style="background-color:#2b303b;color:#c0c5ce;" class="language-jinja2 "><code class="language-jinja2" data-lang="jinja2"><span>{{
</span><span> </span><span style="color:#bf616a;">quote</span><span>(
</span><span> </span><span style="color:#bf616a;">url</span><span>="</span><span style="color:#a3be8c;">https://notado.app/quote/fec984fa0089c5f1a1f31b6a95a71d36</span><span>",
</span><span> </span><span style="color:#bf616a;">caption</span><span>="</span><span style="color:#a3be8c;">This is using the shortcode I'm going to talk about! (and also why I got fed up with Hugo...)</span><span>"
</span><span> )
</span><span>}}
</span></code></pre>
<p>Besides Hacker News, this works for sharing content from every other source
supported by Notado - I'll share examples at the end.</p>
<p>For such a long time, I have saved highlights and comments from across the
internet with the intention of expanding on them with my thoughts on this site,
but stupid and brittle things constantly breaking would take me out of my flow,
again and again, to the point where I built up a reluctance to even trying to
get started writing.</p>
<p>It feels <em>so good</em> to find the right level of abstraction and integration.</p>
<p>I am no longer filled with dread at the thought of opening up the repo of this
website to find the build breaking with a new version of <code>hugo</code>, having to
perform the most insane Go template surgery on a theme, and shortcodes for other
websites no longer rendering.</p>
<h2 id="examples"><a class="zola-anchor" href="#examples" aria-label="Anchor link for: examples">Examples</a></h2>
<p>Here are some examples from my Notado account of comments and highlights from
different sources rendered using my <code>quote</code> shortcode.</p>
<h3 id="lobsters"><a class="zola-anchor" href="#lobsters" aria-label="Anchor link for: lobsters">Lobsters</a></h3>
<div class="notado-quote"
style="border: 1px solid var(--border-color);
background-color: var(--bg-primary) !important;
position: relative;
margin-block: 1em;
border-radius: 5px;
padding-left: 1rem;
padding-right: 1rem;
padding-top: 1.25rem;
padding-bottom: 1.25rem;
margin-bottom: 0em;
border-bottom-right-radius: 0px !important;
border-bottom-left-radius: 0px !important;
">
<div style="padding-bottom: 1.25rem">
<div style="display: flex; gap: 0.75rem">
<div style="min-width: 0;
flex: 1 1 0%;
display: flex;
flex-direction: column;
justify-content: center">
<p style="text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
margin: 0em">
mattgreenrocks
</p>
<p style="color: var(--text-1); margin: 0em">
<a href="https://lobste.rs/s/rzskjk/i_think_i_m_done_thinking_about_genai_for#c_l9x7we">lobste.rs</a>
</p>
</div>
<div style="flex-shrink: 0;
display: flex;
flex-direction: row-reverse;
align-items: center">
<a class="notado-icon" href="https://notado.app" style="border: none">
<img style="height: 3rem;
width: 3rem"
src="https://notado.app/static/notado-icon.png"
alt="notado" />
</a>
</div>
</div>
</div>
<div style="white-space: pre-line">Yep. I arrived at a similar conclusion.
Life is hard, but doing The Work (in whatever subject(s) that is for you) is one way to find solace and unmediated joy. It’s not just any work (hence the capitalization), but work that is good for me to do. I can apply myself to a challenge, work with other people to build things, and learn more about it in an endless cycle. It’s an infinite game. Anything that gets in the way of that cycle is harmful *for me:* more management than dev, politicking for better titles, even screwing around with my dev environment for too long. It’s about the work of shipping, refining, and learning. When that gets out of balance I become highly vulnerable to anxiety.
Where do LLMs fit there? Certainly not in the core loop! Part of The Work necessitates some time spent frustrated: that’s a primary indication of learning (and very easy to forget). I’m okay with them at the periphery of it, or sometimes spitballing things with them.
But there’s a real joy to applying your skills, working through frustration and feeling stuck, and gaining confidence that I would never want to outsource to an LLM. It’s essential for human flourishing.</div>
</div>
<div class="notado-quote-caption"
style="border: 1px solid var(--border-color);
background-color: var(--bg-2) !important;
position: relative;
margin-bottom: 1em;
border-bottom-left-radius: 5px;
border-bottom-right-radius: 5px;
padding-left: 1rem;
padding-right: 1rem;
padding-top: 0.5rem;
padding-bottom: 0.5rem">
Working through frustration brings the joy of accomplishment in all forms of art
</div>
<h3 id="tildes"><a class="zola-anchor" href="#tildes" aria-label="Anchor link for: tildes">Tildes</a></h3>
<div class="notado-quote"
style="border: 1px solid var(--border-color);
background-color: var(--bg-primary) !important;
position: relative;
margin-block: 1em;
border-radius: 5px;
padding-left: 1rem;
padding-right: 1rem;
padding-top: 1.25rem;
padding-bottom: 1.25rem;
margin-bottom: 0em;
border-bottom-right-radius: 0px !important;
border-bottom-left-radius: 0px !important;
">
<div style="padding-bottom: 1.25rem">
<div style="display: flex; gap: 0.75rem">
<div style="min-width: 0;
flex: 1 1 0%;
display: flex;
flex-direction: column;
justify-content: center">
<p style="text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
margin: 0em">
xk3
</p>
<p style="color: var(--text-1); margin: 0em">
<a href="https://tildes.net/~tech/17xe/permanent_archival_formats_do_they_exist#comment-9feo">tildes.net</a>
</p>
</div>
<div style="flex-shrink: 0;
display: flex;
flex-direction: row-reverse;
align-items: center">
<a class="notado-icon" href="https://notado.app" style="border: none">
<img style="height: 3rem;
width: 3rem"
src="https://notado.app/static/notado-icon.png"
alt="notado" />
</a>
</div>
</div>
</div>
<div style="white-space: pre-line">All information *eventually* gives way to entropy.
The only real solution is constant refresh, validation, and multiple copies of data. The only reason we know about many historical things is due to multiple copies of organisms existing and we are able to find the lucky ones that were preserved in tree sap.
Likewise, we are lucky to find old stories because they were passed down through constant rehearsal in oral tradition and/or because some crazy guy brought some scrolls up to a mountain. There are probably many scrolls that we don't know about because they weathered away. We just find the lucky ones. It's chance.
Analog may last longer than digital data but I wouldn't trust just one type of storage. If you care about something you need to have multiple copies and validate that the data is still readable every year or at the very least 5-10 years. If (VHS) players are no longer manufactured you also need to consider that aspect as well.
Even stone tablets have bitrot at a timescale of months if the conditions are right</div>
</div>
<div class="notado-quote-caption"
style="border: 1px solid var(--border-color);
background-color: var(--bg-2) !important;
position: relative;
margin-bottom: 1em;
border-bottom-left-radius: 5px;
border-bottom-right-radius: 5px;
padding-left: 1rem;
padding-right: 1rem;
padding-top: 0.5rem;
padding-bottom: 0.5rem">
Oral tradition as the original distributed information archival mechanism
</div>
<h3 id="reddit"><a class="zola-anchor" href="#reddit" aria-label="Anchor link for: reddit">Reddit</a></h3>
<div class="notado-quote"
style="border: 1px solid var(--border-color);
background-color: var(--bg-primary) !important;
position: relative;
margin-block: 1em;
border-radius: 5px;
padding-left: 1rem;
padding-right: 1rem;
padding-top: 1.25rem;
padding-bottom: 1.25rem;
margin-bottom: 0em;
border-bottom-right-radius: 0px !important;
border-bottom-left-radius: 0px !important;
">
<div style="padding-bottom: 1.25rem">
<div style="display: flex; gap: 0.75rem">
<div style="min-width: 0;
flex: 1 1 0%;
display: flex;
flex-direction: column;
justify-content: center">
<p style="text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
margin: 0em">
szthesquid
</p>
<p style="color: var(--text-1); margin: 0em">
<a href="https://old.reddit.com/r/patientgamers/comments/udzo11/i_miss_the_days_of_server_browsers_and_community/i6lga1o/">old.reddit.com</a>
</p>
</div>
<div style="flex-shrink: 0;
display: flex;
flex-direction: row-reverse;
align-items: center">
<a class="notado-icon" href="https://notado.app" style="border: none">
<img style="height: 3rem;
width: 3rem"
src="https://notado.app/static/notado-icon.png"
alt="notado" />
</a>
</div>
</div>
</div>
<div style="white-space: pre-line">This is one of the big reasons my whole Team Fortress 2 group can't go back to the game. We played *thousands* of hours together in its early years. At the time, some of us had 100% achievement completion.
We played on our local Toronto servers and got to know all the regulars. It's *such* a different game when you play with a community who recognizes player and clan names by sight and develop respect and rivalries.
The time when we skilled up enough to contend with the server's top Sniper.
The time when the server's best medic attached himself to me for the whole game because he recognized me as the best offensive player on the team.
The times when we'd all hop on the same team and other players would switch teams specifically to play with or against us.
The times when all talk was enabled and we could hear the other team get demoralized and panicked when they realized that our buddy was playing Spy this game.
Checking the server leaderboards after the session and realizing our whole group had broken into the top 100.
The feeling that if we went out for a walk and ran into another Team Fortress player on the street, we might actually recognize their user name.
Quick play with randos will never be the same.</div>
</div>
<div class="notado-quote-caption"
style="border: 1px solid var(--border-color);
background-color: var(--bg-2) !important;
position: relative;
margin-bottom: 1em;
border-bottom-left-radius: 5px;
border-bottom-right-radius: 5px;
padding-left: 1rem;
padding-right: 1rem;
padding-top: 0.5rem;
padding-bottom: 0.5rem">
I get very sad thinking about the loss of the online CS, TF and RA community servers I used to play in as a teenager
</div>
<h3 id="lemmy"><a class="zola-anchor" href="#lemmy" aria-label="Anchor link for: lemmy">Lemmy</a></h3>
<div class="notado-quote"
style="border: 1px solid var(--border-color);
background-color: var(--bg-primary) !important;
position: relative;
margin-block: 1em;
border-radius: 5px;
padding-left: 1rem;
padding-right: 1rem;
padding-top: 1.25rem;
padding-bottom: 1.25rem;
margin-bottom: 0em;
border-bottom-right-radius: 0px !important;
border-bottom-left-radius: 0px !important;
">
<div style="padding-bottom: 1.25rem">
<div style="display: flex; gap: 0.75rem">
<div style="min-width: 0;
flex: 1 1 0%;
display: flex;
flex-direction: column;
justify-content: center">
<p style="text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
margin: 0em">
bugsmith
</p>
<p style="color: var(--text-1); margin: 0em">
<a href="https://programming.dev/comment/5789966">programming.dev</a>
</p>
</div>
<div style="flex-shrink: 0;
display: flex;
flex-direction: row-reverse;
align-items: center">
<a class="notado-icon" href="https://notado.app" style="border: none">
<img style="height: 3rem;
width: 3rem"
src="https://notado.app/static/notado-icon.png"
alt="notado" />
</a>
</div>
</div>
</div>
<div style="white-space: pre-line">I particularly enjoyed a recent company meeting that spent considerable time talking about the importance of flow state. It had an awkward pregnant pause when someone (usually very quiet) unmuted to ask, "is the policy to increase the number of days we must spend in our open-plan office kind of undermining this?". Literally all of our directors just shifted on their seats hoping another would answer that.
Eventually, HR director stated "Not at all, that's what headphones are for!"
Which was particularly delightful, as our tech director had only 20 minutes before stated how he would like to discourage people sitting in the office in silos with their headphones on.</div>
</div>
<div class="notado-quote-caption"
style="border: 1px solid var(--border-color);
background-color: var(--bg-2) !important;
position: relative;
margin-bottom: 1em;
border-bottom-left-radius: 5px;
border-bottom-right-radius: 5px;
padding-left: 1rem;
padding-right: 1rem;
padding-top: 0.5rem;
padding-bottom: 0.5rem">
We work in the stupidest timeline
</div>
<h3 id="discourse"><a class="zola-anchor" href="#discourse" aria-label="Anchor link for: discourse">Discourse</a></h3>
<div class="notado-quote"
style="border: 1px solid var(--border-color);
background-color: var(--bg-primary) !important;
position: relative;
margin-block: 1em;
border-radius: 5px;
padding-left: 1rem;
padding-right: 1rem;
padding-top: 1.25rem;
padding-bottom: 1.25rem;
margin-bottom: 0em;
border-bottom-right-radius: 0px !important;
border-bottom-left-radius: 0px !important;
">
<div style="padding-bottom: 1.25rem">
<div style="display: flex; gap: 0.75rem">
<div style="min-width: 0;
flex: 1 1 0%;
display: flex;
flex-direction: column;
justify-content: center">
<p style="text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
margin: 0em">
jakehamilton
</p>
<p style="color: var(--text-1); margin: 0em">
<a href="https://discourse.nixos.org/t/should-organizations-relating-to-the-defense-sector-being-able-to-sponsor-nixos/41252/6">discourse.nixos.org</a>
</p>
</div>
<div style="flex-shrink: 0;
display: flex;
flex-direction: row-reverse;
align-items: center">
<a class="notado-icon" href="https://notado.app" style="border: none">
<img style="height: 3rem;
width: 3rem"
src="https://notado.app/static/notado-icon.png"
alt="notado" />
</a>
</div>
</div>
</div>
<div style="white-space: pre-line">I do not want any of my work associated with arms dealers and/or the deaths of others. Having any sponsorship from a military entity steps over that line. I would much prefer that the NixOS Foundation set ethical guidelines for accepting sponsorship. I do not believe it is absurd to suggest that we shouldn’t take money from people who are responsible for the slaughter of others. Rejecting sponsorship from weapons manufacturers should be a reasonable standard.
I would like to be clear here: this is a significant problem for myself and many others and will result in community fracture if it is not resolved. I’m sure we would all prefer to not split into two groups of “people okay with killing others” and “people who think killing others is bad”.</div>
</div>
<div class="notado-quote-caption"
style="border: 1px solid var(--border-color);
background-color: var(--bg-2) !important;
position: relative;
margin-bottom: 1em;
border-bottom-left-radius: 5px;
border-bottom-right-radius: 5px;
padding-left: 1rem;
padding-right: 1rem;
padding-top: 0.5rem;
padding-bottom: 0.5rem">
I added support for Discourse to Notado just so I could save this comment
</div>
<h3 id="mastodon"><a class="zola-anchor" href="#mastodon" aria-label="Anchor link for: mastodon">Mastodon</a></h3>
<div class="notado-quote"
style="border: 1px solid var(--border-color);
background-color: var(--bg-primary) !important;
position: relative;
margin-block: 1em;
border-radius: 5px;
padding-left: 1rem;
padding-right: 1rem;
padding-top: 1.25rem;
padding-bottom: 1.25rem;
margin-bottom: 0em;
border-bottom-right-radius: 0px !important;
border-bottom-left-radius: 0px !important;
">
<div style="padding-bottom: 1.25rem">
<div style="display: flex; gap: 0.75rem">
<div style="min-width: 0;
flex: 1 1 0%;
display: flex;
flex-direction: column;
justify-content: center">
<p style="text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
margin: 0em">
corbden
</p>
<p style="color: var(--text-1); margin: 0em">
<a href="https://defcon.social/@corbden/113473397794111625">defcon.social</a>
</p>
</div>
<div style="flex-shrink: 0;
display: flex;
flex-direction: row-reverse;
align-items: center">
<a class="notado-icon" href="https://notado.app" style="border: none">
<img style="height: 3rem;
width: 3rem"
src="https://notado.app/static/notado-icon.png"
alt="notado" />
</a>
</div>
</div>
</div>
<div style="white-space: pre-line">Work isn't hard when you've got the capacity and capability to do what is needed, and you're in a good mood. Work is fulfilling when it is purposeful and not frustrating.
The conditions under which many of us were taught to work were not ideal, and only prepared us to tolerate without complaint the conditions under which we were expected to perform in our adult lives, conditions usually bad ONLY because the powerful need to abuse, and not for any necessary reason.
That's why nobody likes work. We're wired to like work, but we've been made to dislike it.
Because the cruelty is the point. Exploitation is the point. Dominance is the point.</div>
</div>
<div class="notado-quote-caption"
style="border: 1px solid var(--border-color);
background-color: var(--bg-2) !important;
position: relative;
margin-bottom: 1em;
border-bottom-left-radius: 5px;
border-bottom-right-radius: 5px;
padding-left: 1rem;
padding-right: 1rem;
padding-top: 0.5rem;
padding-bottom: 0.5rem">
Capitalism: The Cruelty is the Point
</div>
<h3 id="bluesky"><a class="zola-anchor" href="#bluesky" aria-label="Anchor link for: bluesky">Bluesky</a></h3>
<div class="notado-quote"
style="border: 1px solid var(--border-color);
background-color: var(--bg-primary) !important;
position: relative;
margin-block: 1em;
border-radius: 5px;
padding-left: 1rem;
padding-right: 1rem;
padding-top: 1.25rem;
padding-bottom: 1.25rem;
margin-bottom: 0em;
border-bottom-right-radius: 0px !important;
border-bottom-left-radius: 0px !important;
">
<div style="padding-bottom: 1.25rem">
<div style="display: flex; gap: 0.75rem">
<div style="min-width: 0;
flex: 1 1 0%;
display: flex;
flex-direction: column;
justify-content: center">
<p style="text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
margin: 0em">
hmsnofun.bsky.social
</p>
<p style="color: var(--text-1); margin: 0em">
<a href="https://bsky.app/profile/hmsnofun.bsky.social/post/3lmr6sm5k4k2b">bsky.app</a>
</p>
</div>
<div style="flex-shrink: 0;
display: flex;
flex-direction: row-reverse;
align-items: center">
<a class="notado-icon" href="https://notado.app" style="border: none">
<img style="height: 3rem;
width: 3rem"
src="https://notado.app/static/notado-icon.png"
alt="notado" />
</a>
</div>
</div>
</div>
<div style="white-space: pre-line">the labor of art is mostly invisible. what you don't see in a finished piece is all the hours of thought it required, all the little discoveries and surprises and tragedies of production. art IS the process. if you aren't interested in the process, then i don't really know why you want to be artist</div>
</div>
<div class="notado-quote-caption"
style="border: 1px solid var(--border-color);
background-color: var(--bg-2) !important;
position: relative;
margin-bottom: 1em;
border-bottom-left-radius: 5px;
border-bottom-right-radius: 5px;
padding-left: 1rem;
padding-right: 1rem;
padding-top: 0.5rem;
padding-bottom: 0.5rem">
This also rings true for my experience building software
</div>
<h3 id="twitter"><a class="zola-anchor" href="#twitter" aria-label="Anchor link for: twitter">Twitter</a></h3>
<div class="notado-quote"
style="border: 1px solid var(--border-color);
background-color: var(--bg-primary) !important;
position: relative;
margin-block: 1em;
border-radius: 5px;
padding-left: 1rem;
padding-right: 1rem;
padding-top: 1.25rem;
padding-bottom: 1.25rem;
margin-bottom: 0em;
border-bottom-right-radius: 0px !important;
border-bottom-left-radius: 0px !important;
">
<div style="padding-bottom: 1.25rem">
<div style="display: flex; gap: 0.75rem">
<div style="min-width: 0;
flex: 1 1 0%;
display: flex;
flex-direction: column;
justify-content: center">
<p style="text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
margin: 0em">
mitchellh
</p>
<p style="color: var(--text-1); margin: 0em">
<a href="https://twitter.com/mitchellh/status/1744850961309597855?s=12">twitter.com</a>
</p>
</div>
<div style="flex-shrink: 0;
display: flex;
flex-direction: row-reverse;
align-items: center">
<a class="notado-icon" href="https://notado.app" style="border: none">
<img style="height: 3rem;
width: 3rem"
src="https://notado.app/static/notado-icon.png"
alt="notado" />
</a>
</div>
</div>
</div>
<div style="white-space: pre-line">For open source in particular, the misbalance that always made me sad was when an issue reporter spends 30 seconds writing an issue that's going to take a maintainer (working for free) hours, days, weeks to resolve and maintain, then gets mad when its not fixed quickly. 🤔</div>
</div>
<div class="notado-quote-caption"
style="border: 1px solid var(--border-color);
background-color: var(--bg-2) !important;
position: relative;
margin-bottom: 1em;
border-bottom-left-radius: 5px;
border-bottom-right-radius: 5px;
padding-left: 1rem;
padding-right: 1rem;
padding-top: 0.5rem;
padding-bottom: 0.5rem">
I have long since stopped accepting drive-by issues on my public GitHub projects
</div>
<h3 id="youtube"><a class="zola-anchor" href="#youtube" aria-label="Anchor link for: youtube">YouTube</a></h3>
<div class="notado-quote"
style="border: 1px solid var(--border-color);
background-color: var(--bg-primary) !important;
position: relative;
margin-block: 1em;
border-radius: 5px;
padding-left: 1rem;
padding-right: 1rem;
padding-top: 1.25rem;
padding-bottom: 1.25rem;
margin-bottom: 0em;
border-bottom-right-radius: 0px !important;
border-bottom-left-radius: 0px !important;
">
<div style="padding-bottom: 1.25rem">
<div style="display: flex; gap: 0.75rem">
<div style="min-width: 0;
flex: 1 1 0%;
display: flex;
flex-direction: column;
justify-content: center">
<p style="text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
margin: 0em">
QuantaStarfire
</p>
<p style="color: var(--text-1); margin: 0em">
<a href="https://www.youtube.com/watch?v=k0J0Dxf5JKc&lc=Ugwsmg7c0JpYiXyADQV4AaABAg">www.youtube.com</a>
</p>
</div>
<div style="flex-shrink: 0;
display: flex;
flex-direction: row-reverse;
align-items: center">
<a class="notado-icon" href="https://notado.app" style="border: none">
<img style="height: 3rem;
width: 3rem"
src="https://notado.app/static/notado-icon.png"
alt="notado" />
</a>
</div>
</div>
</div>
<div style="white-space: pre-line">I feel like the decline of the arena shooter can also be attributed to the lack of dedicated servers and the lack of editing tools, because both of these things together are what have allowed games like DOOM and Quake to continue to exist for almost three decades now.
Dedicated servers allow people to form small communities of regular players, while editors allow people to create new modes and levels for the game to keep things fresh.
Publishers don't like either of these things since a game you buy once and can play forever with nigh-infinite content doesn't generate the nigh-infinite revenue they want, so of course those features had to either be taken out back and shot, or at least be put under very heavy developer control.</div>
</div>
<div class="notado-quote-caption"
style="border: 1px solid var(--border-color);
background-color: var(--bg-2) !important;
position: relative;
margin-bottom: 1em;
border-bottom-left-radius: 5px;
border-bottom-right-radius: 5px;
padding-left: 1rem;
padding-right: 1rem;
padding-top: 0.5rem;
padding-bottom: 0.5rem">
Community sacrificed at the altar of corporate greed
</div>
<h3 id="kindle"><a class="zola-anchor" href="#kindle" aria-label="Anchor link for: kindle">Kindle</a></h3>
<div class="notado-quote"
style="border: 1px solid var(--border-color);
background-color: var(--bg-primary) !important;
position: relative;
margin-block: 1em;
border-radius: 5px;
padding-left: 1rem;
padding-right: 1rem;
padding-top: 1.25rem;
padding-bottom: 1.25rem;
margin-bottom: 0em;
border-bottom-right-radius: 0px !important;
border-bottom-left-radius: 0px !important;
">
<div style="padding-bottom: 1.25rem">
<div style="display: flex; gap: 0.75rem">
<div style="min-width: 0;
flex: 1 1 0%;
display: flex;
flex-direction: column;
justify-content: center">
<p style="text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
margin: 0em">
Ada Palmer
</p>
<p style="color: var(--text-1); margin: 0em">
Too Like the Lightning: Book One of Terra Ignota
</p>
</div>
<div style="flex-shrink: 0;
display: flex;
flex-direction: row-reverse;
align-items: center">
<a class="notado-icon" href="https://notado.app" style="border: none">
<img style="height: 3rem;
width: 3rem"
src="https://notado.app/static/notado-icon.png"
alt="notado" />
</a>
</div>
</div>
</div>
<div style="white-space: pre-line">I told you, reader, that Utopia does not give up on dreams. When a Utopian dies, of anything, the cause is marked and not forgotten until solved. A fall? They rebuild the site to make it safe. A criminal? They do not rest until he is rendered harmless. An illness? It is researched until cured, regardless of the time, the cost, over generations if need be. A car crash? They create their separate system, slower, less efficient, costing hours, but which has never cost a single life. Even for suicide they track the cause, and so, patiently, blade by blade, disarm Death. Death, of course, has many weapons, and, if they have deprived him of a hundred million, he still has enough at hand to keep them mortal. For now.</div>
</div>
<div class="notado-quote-caption"
style="border: 1px solid var(--border-color);
background-color: var(--bg-2) !important;
position: relative;
margin-bottom: 1em;
border-bottom-left-radius: 5px;
border-bottom-right-radius: 5px;
padding-left: 1rem;
padding-right: 1rem;
padding-top: 0.5rem;
padding-bottom: 0.5rem">
A highlight from one of my favorite book series which I read on Kindle
</div>
<h3 id="kobo"><a class="zola-anchor" href="#kobo" aria-label="Anchor link for: kobo">Kobo</a></h3>
<div class="notado-quote"
style="border: 1px solid var(--border-color);
background-color: var(--bg-primary) !important;
position: relative;
margin-block: 1em;
border-radius: 5px;
padding-left: 1rem;
padding-right: 1rem;
padding-top: 1.25rem;
padding-bottom: 1.25rem;
margin-bottom: 0em;
border-bottom-right-radius: 0px !important;
border-bottom-left-radius: 0px !important;
">
<div style="padding-bottom: 1.25rem">
<div style="display: flex; gap: 0.75rem">
<div style="min-width: 0;
flex: 1 1 0%;
display: flex;
flex-direction: column;
justify-content: center">
<p style="text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
margin: 0em">
Hwang Bo-Reum
</p>
<p style="color: var(--text-1); margin: 0em">
Welcome to the Hyunam-Dong Bookshop: The Heart-Warming Korean Sensation
</p>
</div>
<div style="flex-shrink: 0;
display: flex;
flex-direction: row-reverse;
align-items: center">
<a class="notado-icon" href="https://notado.app" style="border: none">
<img style="height: 3rem;
width: 3rem"
src="https://notado.app/static/notado-icon.png"
alt="notado" />
</a>
</div>
</div>
</div>
<div style="white-space: pre-line">I had thought of work as stairs. Stairs to climb to reach the top. Now, I see work as food. Food that you need every day. Food that makes a difference to my body, my heart, my mental health, and my soul. There is food you just shove down your throat, and food that you eat with care and sincerity. I want to be one who takes great care in eating simple food. Not for anyone, but for myself.</div>
</div>
<div class="notado-quote-caption"
style="border: 1px solid var(--border-color);
background-color: var(--bg-2) !important;
position: relative;
margin-bottom: 1em;
border-bottom-left-radius: 5px;
border-bottom-right-radius: 5px;
padding-left: 1rem;
padding-right: 1rem;
padding-top: 0.5rem;
padding-bottom: 0.5rem">
I build software first and foremost for myself, to address my own needs
</div>
<hr />
<p>If you have any questions or comments you can reach out to me on
<a href="https://bsky.app/profile/lgug2z.com">Bluesky</a> and
<a href="https://hachyderm.io/@LGUG2Z">Mastodon</a>.</p>
<p>If you're interested in what I read to come up with solutions like this, you
can subscribe to my <a href="https://notado.app/feeds/jado/software-development">Software Development RSS
feed</a>.</p>
<p>If you'd like to watch me writing code while explaining what I'm doing, you can
also <a href="https://www.youtube.com/channel/UCeai3-do-9O4MNy9_xjO6mg?sub_confirmation=1">subscribe to my YouTube
channel</a>.</p>
<p>If you would like early access to <a href="https://www.youtube.com/watch?v=u3eJcsa_MJk">komorebi for
Mac</a>, you can sponsor me on
<a href="https://github.com/sponsors/LGUG2Z">GitHub</a>.</p>
Exposing Git Information in Rust Binaries Built With NixFri, 07 Nov 2025 17:42:49 -0800lgug2z
https://lgug2z.com/articles/exposing-git-information-in-rust-binaries-built-with-nix/
https://lgug2z.com/articles/exposing-git-information-in-rust-binaries-built-with-nix/<p>When you have software out in the wild being used by lots of people, it's
inevitable that there will be pretty large spread across the versions of your
software being used.</p>
<p>If that software is aimed towards technical folks and power users, or if you
publish nightly releases, in addition to the spread across official version
releases, you will also have many people running on any number of commit hashes.</p>
Normalize Identifying Corporate Devices in Your SoftwareTue, 28 Oct 2025 17:59:40 -0700lgug2z
https://lgug2z.com/articles/normalize-identifying-corporate-devices-in-your-software/
https://lgug2z.com/articles/normalize-identifying-corporate-devices-in-your-software/<p>If you dual-license your software in such a way that it requires a paid license
for commercial use, here are two code blobs for you.</p>
Using Homebrew to Distribute Early Access Binaries from Private Github RepositoriesSun, 26 Oct 2025 13:42:52 -0700lgug2z
https://lgug2z.com/articles/using-homebrew-to-distribute-early-access-binaries-from-private-github-repositories/
https://lgug2z.com/articles/using-homebrew-to-distribute-early-access-binaries-from-private-github-repositories/<p>Building for macOS has been... interesting. After <a href="https://www.youtube.com/watch?v=58JUgGSyIcs">falling into the trap of
Microsoft's 10x price gouging for macOS runners on GitHub
Actions</a> and ultimately switching
to using the Mac Mini under my television as a self-hosted runner, the next
thing I wanted to do was distribute my build artifacts.</p>
I Want a Cross-Platform Tiling Window ManagerSat, 20 Sep 2025 10:42:45 -0700lgug2z
https://lgug2z.com/articles/i-want-a-cross-platform-tiling-window-manager/
https://lgug2z.com/articles/i-want-a-cross-platform-tiling-window-manager/<p>One of the great things about building your own tools is that you get to have
your desired workflow and user experience with very few compromises.</p>
wpmMon, 07 Apr 2025 16:06:37 -0700lgug2z
https://lgug2z.com/software/wpm/
https://lgug2z.com/software/wpm/<p><a href="https://github.com/LGUG2Z/wpm">wpm</a> is a process manager for Windows,
written in the Rust programming language, published under the
<a href="https://github.com/LGUG2Z/komorebi-license">Komorebi License</a>.</p>
<p>The <a href="https://github.com/LGUG2Z/komorebi-license">Komorebi License</a> does
not permit any kind of commercial use (i.e. using <code>wpm</code> at work).</p>
<p>A dedicated Individual Commercial Use License is available for those who want
to use wpm at work.</p>
<p>The Individual Commercial Use License adds "Commercial Use" as a "Permitted
Use" for the licensed individual only, for the duration of a valid paid license
subscription only. All provisions and restrictions enumerated in the Komorebi
License continue to apply.</p>
<table><thead><tr><th>Billing Period</th><th>Price</th><th>Link</th></tr></thead><tbody>
<tr><td>Monthly</td><td>$15</td><td><a href="https://buy.stripe.com/bIY9E4bfxfyweWc002">Purchase</a></td></tr>
<tr><td>Annual</td><td>$165</td><td><a href="https://buy.stripe.com/cN2cQgfvN3POcO46or">Purchase</a></td></tr>
</tbody></table>
<p>Assuming 252 working days per year, 21 average working days per month, and 40
hour working weeks, the license cost works out to $0.71 per working day when
billed monthly, and $0.65 per working day when billed annually.</p>
<p>When purchasing a license subscription, you are optionally encouraged to enter
your GitHub username; this makes it easier for me to identify issues created by
users with license subscriptions on the wpm GitHub repo.</p>
<p>All invoices and receipts, as well as options to update your payment method or
cancel your subscription are available on the <a href="https://billing.stripe.com/p/login/eVaeWkfl9aU4gKc6oo">customer
portal</a>.</p>
<p>License subscriptions can also be cancelled at any time by emailing me
(<em>lgug2z</em> at <em>fastmail</em> dot <em>com</em>) with the subject "wpm License
Subscription Cancellation".</p>
<p>If necessary, please also include the email address which was used to create
the subscribtion in the email body. Cancelled license subscriptions will remain
valid until the end of the paid billing period.</p>
<p>There are no refunds, prorated or otherwise, for purchased subscriptions - if
there are doubts about the suitability of the software for your use case, you
are encouraged to first evaluate the software for personal use on a personal
device, or to purchase a monthly subscription rather than an annual subscription.</p>
<p>Many employers reimburse software licensing costs, so don't forget to submit a
reimbursement request to your employer with your receipt(s).</p>
<p>If your employer accepts the reimbursement request, please reach out to me so
that I can keep this page updated with a list of employers known to reimburse
license costs for wpm - this is useful information for all workers to
have.</p>
<p>If you are an employer or acting on behalf of an employer as a procurer to
purchase a license for an employee, please give the name of the employee for
whom the license is being purchased, but enter an organizational email
address. The organizational email address can be used by the employer or
procurer to manage the subscription on the customer portal.</p>
<p>Licenses purchased by or on behalf of an employer may be transferred from one
employee to another by emailing me.</p>
On Evils in Software LicensingSun, 30 Mar 2025 08:45:49 -0700lgug2z
https://lgug2z.com/articles/on-evils-in-software-licensing/
https://lgug2z.com/articles/on-evils-in-software-licensing/<p>The <a href="https://www.json.org/license.html">JSON License</a> famously includes a
provision stating that "The Software shall be used for Good, not Evil".</p>
<p>This article explores why such provisions are not useful or meaningful in the
greater software licensing conversation.</p>
On Open Source MythologySun, 23 Mar 2025 14:32:45 -0700lgug2z
https://lgug2z.com/articles/on-open-source-mythology/
https://lgug2z.com/articles/on-open-source-mythology/<p>There are two points of popular open source mythology this post will share my
experience with:</p>
<ul>
<li>People won't use your project if you don't use an Open Source
Initiative-approved license</li>
<li>People won't contribute to to your project if you don't use an Open Source
Initiative-approved license</li>
</ul>
Educational Source SoftwareTue, 18 Mar 2025 20:39:30 -0700lgug2z
https://lgug2z.com/articles/educational-source-software/
https://lgug2z.com/articles/educational-source-software/<p>A program is educational source (edu source) software if:</p>
<ul>
<li>The source code is available to individuals for personal use, modification,
learning and reference</li>
</ul>
So You Want to Discuss Open Source Software Licensing With MeTue, 18 Mar 2025 17:32:55 -0700lgug2z
https://lgug2z.com/articles/so-you-want-to-discuss-open-source-software-licensing-with-me/
https://lgug2z.com/articles/so-you-want-to-discuss-open-source-software-licensing-with-me/<p>You're likely here because you've discovered that I generally refuse to use
open source licenses for my popular software projects, and you want to engage
me in a discussion.</p>
<p>Here is a list of things I consider before accepting an invitation from someone
to engage in a discussion about why I do not use open source software licenses.</p>
My First Month Selling Commercial Use Software LicensesSat, 01 Feb 2025 18:32:25 -0800lgug2z
https://lgug2z.com/articles/my-first-month-selling-commercial-use-software-licenses/
https://lgug2z.com/articles/my-first-month-selling-commercial-use-software-licenses/<p>I made $901.49 in my first month selling commercial use software licenses.</p>
<p>This $901.49 came from 14 customers, 6 of whom purchased monthly license
subscriptions and 8 of whom purchased annual license subscriptions.</p>
In the Age of AI Crawlers, I Have Chosen to PaywallSun, 19 Jan 2025 14:43:16 -0800lgug2z
https://lgug2z.com/articles/in-the-age-of-ai-crawlers-i-have-chosen-to-paywall/
https://lgug2z.com/articles/in-the-age-of-ai-crawlers-i-have-chosen-to-paywall/<p>People who make useful content and services intended for interactive human use
available for free have written at length about the ongoing issues with <a href="https://hn.algolia.com/?dateRange=pastYear&page=0&prefix=true&query=ai%20crawler&sort=byDate&type=story">AI
crawlers</a>
scouring the web in an insatiable search for new training data.</p>
<p>Some people are coming up with <a href="https://zadzmo.org/code/nepenthes/">interesting technical
solutions</a> to the problems posed by AI
crawlers, but I have ultimately opted for a much simpler solution: a paywall.</p>
<p><a href="https://kulli.sh">Kullish</a> is a project that I operated free of charge for all
users from 2020 until January 2025.</p>
<p>Kullish is a <a href="https://lgug2z.com/articles/finding-interesting-comments-discussions-and-communities-online/">"bring your own links" comment
aggregator</a>;
you give it a link, and it will find all of the comments it can on that link
from the most popular discussion forums on the internet, and show them to you
on one page.</p>
<p>Before AI companies started slurping up all of the user-generated text content
they could find on the internet, I built Kullish simply because I wanted to
expose myself to multiple viewpoints on the topics I was reading about.</p>
<p>Today, AI crawlers scrape and rescrape every single link they have ever encountered
endlessly in the hopes of finding new content to train models on. The "Latest"
and "Popular Today" links on <a href="https://kulli.sh">Kullish</a> are no exception.</p>
<p>Kullish runs as a single instance on modest hardware (I have been paying for
this since 2020 out of pocket, after all) serving all users, and there is a
very polite and respectful rate limiter in place ensuring that the discussion
forums from which comments are being aggregated never get overwhelmed with
user requests.</p>
<p>This was a fine approach while Kullish was being used primarily by humans, but
as the AI crawlers kept coming back, ignoring the <code>robots.txt</code> file, and making
endless requests to aggregate comments for old links, the performance of the
site began to suffer for human users.</p>
<p>Starting today, Kullish will require a $60/year subscription. If you enjoyed
using Kullish for free these past years, I hope you will consider the
subscription.</p>
<p>If you never had the chance to use Kullish while it was free, there is an
<a href="https://kulli.sh/example">example search</a> for you to see how it works.</p>
GitHub Sponsorship Breakdown for 2024Mon, 30 Dec 2024 13:28:07 -0700lgug2z
https://lgug2z.com/articles/github-sponsorship-breakdown-for-2024/
https://lgug2z.com/articles/github-sponsorship-breakdown-for-2024/<p>In 2024 I received $1861.88 in sponsorships via GitHub Sponsors for my work on
<a href="https://github.com/LGUG2Z/komorebi"><code>komorebi</code></a>, the tiling window manager for
Windows, which has continued to grow beyond my wildest dreams; it currently has
<a href="https://github.com/LGUG2Z/komorebi">10k stars on GitHub</a>, 106k downloads and
almost 2000 members in <a href="https://discord.gg/mGkn66PHkx">the community Discord</a>.</p>
<p><img src="https://user-images.githubusercontent.com/13164844/184027064-f5a6cec2-2865-4d65-a549-a1f1da589abf.png" alt="komorebi" /></p>
<p>Below is the monthly breakdown of the sponsorship payouts received in 2024:</p>
<table><thead><tr><th>Month</th><th>Payout</th></tr></thead><tbody>
<tr><td>January</td><td>$92.35</td></tr>
<tr><td>February</td><td>$115.48</td></tr>
<tr><td>March</td><td>$143.03</td></tr>
<tr><td>April</td><td>$150.30</td></tr>
<tr><td>May</td><td>$188.39</td></tr>
<tr><td>June</td><td>$158.49</td></tr>
<tr><td>July</td><td>$122.83</td></tr>
<tr><td>August</td><td>$144.12</td></tr>
<tr><td>September</td><td>$179.28</td></tr>
<tr><td>October</td><td>$116.09</td></tr>
<tr><td>November</td><td>$229.00</td></tr>
<tr><td>December</td><td>$222.52</td></tr>
</tbody></table>
<p>In <a href="../github-sponsorship-breakdown-for-2023">the post covering 2023</a>, I wrote:</p>
<blockquote>
<p>It's unlikely that I'll hit the $100 minimum payout threshold on the channel in
2024, but technically, the work I put into the YouTube channel in 2023 has
opened the possibility for users to financially support the project in a
passive way by subscribing to the channel and watching videos.</p>
</blockquote>
<p>In February 2024 my <a href="https://youtube.com/@LGUG2Z">YouTube channel</a> became
monetized. I have continued posting live development and release overview
videos to the channel throughout this year, and I was able to receive one
minimum payout of $100 in October.</p>
<p>I used this payout to purchase a $100 ad campaign on an updated <a href="https://www.youtube.com/watch?v=MMZUAtHbTYY">komorebi
quickstart video</a>, which directly
gained the channel 208 new subscribers ($0.48 per new subscriber).</p>
<div
style="position: relative; padding-bottom: 56.25%; height: 0; overflow: hidden;">
<iframe allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share; fullscreen"
loading="eager"
referrerpolicy="strict-origin-when-cross-origin"
src="https://www.youtube.com/embed/8xevum_CMGk"
style="position: absolute; top: 0; left: 0; width: 100%; height: 100%; border:0;"
title="YouTube video"
allowfullscreen></iframe>
</div>
<p>In late 2024 I finally took the jump to switch from the <a href="https://polyformproject.org/licenses/strict/1.0.0/">PolyForm Strict
License</a> to the <a href="https://github.com/LGUG2Z/komorebi-license">Komorebi
License</a>, which continues to be a
personal use-only educational source license prohibiting commerical use, while
explicitly allowing changes for personal use and upstream contributions (which
the PolyForm Strict License was lacking).</p>
<p>In 2025 I will be introducing the long-requested and long-awaited <a href="https://lgug2z.com/software/komorebi">Individual
Commercial Use License</a> as a paid
subscription which will cover the most significant use case of "using
<code>komorebi</code> at <code>$DAYJOB</code>", and hopefully reduce headaches for compliance
departments.</p>
<p>I remain so grateful to every single person who has ever financially sponsored
<code>komorebi</code>. Thank you for joining me on this wild ride!</p>
<hr />
<p>If you have any questions or comments you can reach out to me on
<a href="https://bsky.app/profile/lgug2z.com">Bluesky</a> and
<a href="https://hachyderm.io/@LGUG2Z">Mastodon</a>.</p>
<p>If you're interested in what I read to come up with software like komorebi, you
can subscribe to my <a href="https://notado.app/feeds/jado/software-development">Software Development RSS
feed</a>.</p>
<p>If you'd like to watch me writing code while explaining what I'm doing, you can
also <a href="https://www.youtube.com/channel/UCeai3-do-9O4MNy9_xjO6mg?sub_confirmation=1">subscribe to my YouTube
channel</a>.</p>
<p>If you found this content valuable, or if you are a happy user of the
<a href="https://github.com/LGUG2Z/komorebi"><code>komorebi</code></a> tiling window manager or my
<a href="https://github.com/search?q=owner%3ALGUG2Z+nixos&type=repositories">NixOS starter
templates</a>,
please consider sponsoring me on <a href="https://github.com/sponsors/LGUG2Z">GitHub</a>
or tipping me on <a href="https://ko-fi.com/lgug2z">Ko-fi</a>.</p>
KomorebiMon, 30 Dec 2024 00:00:00 +0000lgug2z
https://lgug2z.com/software/komorebi/
https://lgug2z.com/software/komorebi/<p><a href="https://github.com/LGUG2Z/komorebi">komorebi</a> for Windows and komorebi for Mac
are tiling window managers written in the Rust programming language, published
under the <a href="https://github.com/LGUG2Z/komorebi-license">Komorebi License</a>.</p>
<p>The <a href="https://github.com/LGUG2Z/komorebi-license">Komorebi License</a> does
not permit any kind of commercial use (i.e. using <code>komorebi</code> at work).</p>
<p>A dedicated Individual Commercial Use License is available for those who want
to use either komorebi for Windows or komorebi for Mac at work.</p>
<p>Separate license purchases are required for komorebi for Windows and komorebi
for macOS.</p>
<p>The Individual Commercial Use License adds "Commercial Use" as a "Permitted
Use" for the licensed individual only, for the duration of a valid paid license
subscription only. All provisions and restrictions enumerated in the Komorebi
License continue to apply.</p>
<p>The komorebi for Windows Individual Commercial Use License is also valid for
<a href="https://github.com/LGUG2Z/whkd">whkd</a> and <a href="https://github.com/LGUG2Z/masir">masir</a>.</p>
<p>There are currently <a href="https://lgug2z.com/software/komorebi/license-tracker"><strong>
121
</strong> active individual commercial use
licenses</a>.</p>
<h2 id="komorebi-for-windows-single-platform-license"><a class="zola-anchor" href="#komorebi-for-windows-single-platform-license" aria-label="Anchor link for: komorebi-for-windows-single-platform-license">komorebi for Windows (single-platform license)</a></h2>
<table><thead><tr><th>Billing Period</th><th>Price</th><th>Link</th></tr></thead><tbody>
<tr><td>Monthly</td><td>$10</td><td><a href="https://buy.stripe.com/28og2s3N53PO5lC7ss">Purchase</a></td></tr>
<tr><td>Annual</td><td>$110</td><td><a href="https://buy.stripe.com/cN203u3N5cmk8xO6op">Purchase</a></td></tr>
</tbody></table>
<h2 id="komorebi-for-mac-single-platform-license"><a class="zola-anchor" href="#komorebi-for-mac-single-platform-license" aria-label="Anchor link for: komorebi-for-mac-single-platform-license">komorebi for Mac (single-platform license)</a></h2>
<table><thead><tr><th>Billing Period</th><th>Price</th><th>Link</th></tr></thead><tbody>
<tr><td>Monthly</td><td>$10</td><td><a href="https://buy.stripe.com/cNi28qcrzdCAgHC3XSdnW05">Purchase</a></td></tr>
<tr><td>Annual</td><td>$110</td><td><a href="https://buy.stripe.com/6oU4gygHPaqo1MI7a4dnW04">Purchase</a></td></tr>
</tbody></table>
<h2 id="komorebi-for-windows-macos-multi-platform-license"><a class="zola-anchor" href="#komorebi-for-windows-macos-multi-platform-license" aria-label="Anchor link for: komorebi-for-windows-macos-multi-platform-license">komorebi for Windows + macOS (multi-platform license)</a></h2>
<table><thead><tr><th>Billing Period</th><th>Price</th><th>Link</th></tr></thead><tbody>
<tr><td>Monthly</td><td>$15</td><td><a href="https://buy.stripe.com/cNi6oG77faqo9fa3XSdnW06">Purchase</a></td></tr>
<tr><td>Annual</td><td>$180</td><td><a href="https://buy.stripe.com/dRm28q0IRbusbnigKEdnW07">Purchase</a></td></tr>
</tbody></table>
<p>Assuming 252 working days per year, 21 average working days per month, and 40
hour working weeks, the single-platform license cost works out to $0.48 per
working day when billed monthly, and $0.44 per working day when billed
annually.</p>
<p>When purchasing a license subscription, you are optionally encouraged to enter
your GitHub username; this makes it easier for me to identify issues created by
users with license subscriptions on the Komorebi GitHub repo.</p>
<p>All invoices and receipts, as well as options to update your payment method,
upgrade from a single-platform license to a multi-platform license, or cancel
your subscription, are available on the <a href="https://billing.stripe.com/p/login/eVaeWkfl9aU4gKc6oo">customer
portal</a>.</p>
<p>License subscriptions can also be cancelled at any time by emailing me
(<em>lgug2z</em> at <em>fastmail</em> dot <em>com</em>) with the subject "Komorebi License
Subscription Cancellation".</p>
<p>If necessary, please also include the email address which was used to create
the subscribtion in the email body. Cancelled license subscriptions will remain
valid until the end of the paid billing period.</p>
<p>There are no refunds, prorated or otherwise, for purchased subscriptions - if
there are doubts about the suitability of the software for your use case, you
are encouraged to first evaluate the software for personal use on a personal
device, or to purchase a monthly subscription rather than an annual subscription.</p>
<p>Many employers reimburse software licensing costs, so don't forget to submit a
reimbursement request to your employer with your receipt(s).</p>
<h2 id="activating-the-license"><a class="zola-anchor" href="#activating-the-license" aria-label="Anchor link for: activating-the-license">Activating the License</a></h2>
<p>To activate the license, users should run the <code>komorebic license</code> command with
the email address associated with their license.</p>
<pre data-lang="txt" style="background-color:#2b303b;color:#c0c5ce;" class="language-txt "><code class="language-txt" data-lang="txt"><span>❯ komorebic license [email protected]
</span><span>Individual commercial use license validation successful
</span><span>Local validation file saved to <PATH ON YOUR MACHINE>/icul.validation
</span><span>
</span><span>{
</span><span> "email": "[email protected]",
</span><span> "hasValidSubscription": true,
</span><span> "timestamp": 1768927207,
</span><span> "currentEndPeriod": 1771519207,
</span><span> "signature": "xyz"
</span><span>}
</span></code></pre>
<h2 id="mapping-licenses-from-corporate-licensing-accounts"><a class="zola-anchor" href="#mapping-licenses-from-corporate-licensing-accounts" aria-label="Anchor link for: mapping-licenses-from-corporate-licensing-accounts">Mapping Licenses from Corporate Licensing Accounts</a></h2>
<p>If you are an employer or acting on behalf of an employer as a procurer to
purchase a license for an employee, you may wish to purchase and manage the
license with an organizational email address (i.e. [email protected]).</p>
<p>In this case, proceed with the purchase using the organizational email, and
then email me with the email address and the name of the employee for whom the
license is being purchased, so that the purchased license can be associated
with the intended employee.</p>
<p>The organizational email address can then be used by the employer or procurer
to manage the subscription on the <a href="https://billing.stripe.com/p/login/eVaeWkfl9aU4gKc6oo">customer
portal</a>, while the
employee can activate their license using their own email address.</p>
<pre data-lang="txt" style="background-color:#2b303b;color:#c0c5ce;" class="language-txt "><code class="language-txt" data-lang="txt"><span>❯ komorebic license [email protected]
</span><span>Individual commercial use license validation successful
</span><span>Local validation file saved to <PATH ON YOUR MACHINE>/icul.validation
</span><span>
</span><span>{
</span><span> "email": "[email protected]", <-- license associated with the employee
</span><span> "hasValidSubscription": true,
</span><span> "timestamp": 1768927207,
</span><span> "currentEndPeriod": 1771519207,
</span><span> "managedBy": "[email protected]", <-- but managed by the corporate account
</span><span> "signature": "xyz"
</span><span>}
</span></code></pre>
<p>Licenses purchased by or on behalf of an employer may be transferred from one
employee to another by emailing me.</p>
<h2 id="user-testimonials-powered-by-notado"><a class="zola-anchor" href="#user-testimonials-powered-by-notado" aria-label="Anchor link for: user-testimonials-powered-by-notado">User Testimonials <span style="font-size: small; font-weight: normal;">(<a href="https://notado.app/feeds/jado/komorebi-testimonials">Powered by Notado</a>)</span></a></h2>
<style>
.tcontainer {
display: flex;
flex-flow: row wrap;
}
.tcontainer>div {
width: 26%;
margin-bottom: 25px;
}
@media (max-width: 600px) {
.tcontainer {
flex: 1 1 auto;
}
.tcontainer>div {
width: 100%;
}
}
</style>
<div class="tcontainer">
<div style="padding: 10px 30px;
word-wrap: break-word;
display: flex;
flex-direction: column;
justify-content: space-between;
min-height: 150px">
<div style="display: flex;
align-items: center;
justify-content: center;
flex-grow: 1">
<p style="width: 100%;">
then maybe start ricing on windows first with tools like komorebi for window tiling manager and topbar like yasb they really teach how ricing happens in and will really gonna help you if you want to switch to hyprland setup like mine. I've did the same.
</p>
</div>
<div style="align-self: flex-end; margin-top: auto;">
<p>
(<a href="https://www.reddit.com/r/desktops/comments/1rsi3ub/comment/oaac2nk/">View</a>)
</p>
</div>
</div>
<div style="padding: 10px 30px;
word-wrap: break-word;
display: flex;
flex-direction: column;
justify-content: space-between;
min-height: 150px">
<div style="display: flex;
align-items: center;
justify-content: center;
flex-grow: 1">
<p style="width: 100%;">
Well I prefer mainly using the mouse. And with the toggle well if it was possible both maybe a keyboard shortcut for individual window and a toggle between layouts and toggle to turn off completely the tilling when needed i liked how komorebi handled it on windows before i switched to linux
</p>
</div>
<div style="align-self: flex-end; margin-top: auto;">
<p>
(<a href="https://www.reddit.com/r/kde/comments/1rd01lo/comment/oa9tkkh/">View</a>)
</p>
</div>
</div>
<div style="padding: 10px 30px;
word-wrap: break-word;
display: flex;
flex-direction: column;
justify-content: space-between;
min-height: 150px">
<div style="display: flex;
align-items: center;
justify-content: center;
flex-grow: 1">
<p style="width: 100%;">
Been loving komorebi since coming across it
</p>
</div>
<div style="align-self: flex-end; margin-top: auto;">
<p>
(<a href="https://www.reddit.com/r/Windows11/comments/1r92c8f/comment/o69v4k8/">View</a>)
</p>
</div>
</div>
<div style="padding: 10px 30px;
word-wrap: break-word;
display: flex;
flex-direction: column;
justify-content: space-between;
min-height: 150px">
<div style="display: flex;
align-items: center;
justify-content: center;
flex-grow: 1">
<p style="width: 100%;">
You’ll be fine. You can try Komorebi which is an amazing Windows Tiling Manager. If you aren’t familiar with code/github..etc it’s pretty easy to set up and you can change border styles.
</p>
</div>
<div style="align-self: flex-end; margin-top: auto;">
<p>
(<a href="https://www.reddit.com/r/ultrawidemasterrace/comments/1r10q4f/comment/o59ogzd/">View</a>)
</p>
</div>
</div>
<div style="padding: 10px 30px;
word-wrap: break-word;
display: flex;
flex-direction: column;
justify-content: space-between;
min-height: 150px">
<div style="display: flex;
align-items: center;
justify-content: center;
flex-grow: 1">
<p style="width: 100%;">
maybe komorebi can help!
</p>
</div>
<div style="align-self: flex-end; margin-top: auto;">
<p>
(<a href="https://www.reddit.com/r/niri/comments/1r1mn9d/comment/o4qzhct/">View</a>)
</p>
</div>
</div>
<div style="padding: 10px 30px;
word-wrap: break-word;
display: flex;
flex-direction: column;
justify-content: space-between;
min-height: 150px">
<div style="display: flex;
align-items: center;
justify-content: center;
flex-grow: 1">
<p style="width: 100%;">
@LGUG2Z I applaud this post-open source approach and I wish more open source projects would recognise this difference too.
</p>
</div>
<div style="align-self: flex-end; margin-top: auto;">
<p>
(<a href="https://mastodon.social/@Jefverbeeck/116042336655100223">View</a>)
</p>
</div>
</div>
<div style="padding: 10px 30px;
word-wrap: break-word;
display: flex;
flex-direction: column;
justify-content: space-between;
min-height: 150px">
<div style="display: flex;
align-items: center;
justify-content: center;
flex-grow: 1">
<p style="width: 100%;">
yea. pretty neat tool whenever I use windows. it's rare - but komorebi makes it OK.
</p>
</div>
<div style="align-self: flex-end; margin-top: auto;">
<p>
(<a href="https://old.reddit.com/r/pcmasterrace/comments/1qt9ncx/unpopular_opinion_while_windows_isnt_perfect/o3a0to9/">View</a>)
</p>
</div>
</div>
<div style="padding: 10px 30px;
word-wrap: break-word;
display: flex;
flex-direction: column;
justify-content: space-between;
min-height: 150px">
<div style="display: flex;
align-items: center;
justify-content: center;
flex-grow: 1">
<p style="width: 100%;">
yea. pretty neat tool whenever I use windows. it's rare - but komorebi makes it OK.
</p>
</div>
<div style="align-self: flex-end; margin-top: auto;">
<p>
(<a href="https://www.reddit.com/r/pcmasterrace/comments/1qt9ncx/comment/o3a0to9/">View</a>)
</p>
</div>
</div>
<div style="padding: 10px 30px;
word-wrap: break-word;
display: flex;
flex-direction: column;
justify-content: space-between;
min-height: 150px">
<div style="display: flex;
align-items: center;
justify-content: center;
flex-grow: 1">
<p style="width: 100%;">
So pro. Drove me crazy w/ other managers.
</p>
</div>
<div style="align-self: flex-end; margin-top: auto;">
<p>
(<a href="https://bsky.app/profile/drk.wtf/post/3mdvlbf4s222e">View</a>)
</p>
</div>
</div>
<div style="padding: 10px 30px;
word-wrap: break-word;
display: flex;
flex-direction: column;
justify-content: space-between;
min-height: 150px">
<div style="display: flex;
align-items: center;
justify-content: center;
flex-grow: 1">
<p style="width: 100%;">
I often bounce off these kinds of systems due to configuration complexity and upkeep with all the apps I have, but this one has been really easy to use.<br /><br />So if you ever wanted to try a tiling window manager on Windows - I highly recommend checking it out
</p>
</div>
<div style="align-self: flex-end; margin-top: auto;">
<p>
(<a href="https://bsky.app/profile/orels1.bsky.social/post/3mdrhvmnbi22k">View</a>)
</p>
</div>
</div>
<div style="padding: 10px 30px;
word-wrap: break-word;
display: flex;
flex-direction: column;
justify-content: space-between;
min-height: 150px">
<div style="display: flex;
align-items: center;
justify-content: center;
flex-grow: 1">
<p style="width: 100%;">
God damn that's cool as fuck dude
</p>
</div>
<div style="align-self: flex-end; margin-top: auto;">
<p>
(<a href="https://www.reddit.com/r/ExperiencedDevs/comments/1qoypt0/comment/o2cr6yv/">View</a>)
</p>
</div>
</div>
<div style="padding: 10px 30px;
word-wrap: break-word;
display: flex;
flex-direction: column;
justify-content: space-between;
min-height: 150px">
<div style="display: flex;
align-items: center;
justify-content: center;
flex-grow: 1">
<p style="width: 100%;">
I was setting up my dual boot with CachyOS and hyprland but I gave up on it because like ALL of the creative programs I use and games I play run better on windows (or don't run at all on arch)<br /><br />Instead I got windows iot Enterprise LTSC, debloated it even more with Talon and then riced it with Komorebi, Windhawk, and a whole bunch of third party programs. <br />Going really nice so far.<br /><br />Anyway, nicely drawn! :)
</p>
</div>
<div style="align-self: flex-end; margin-top: auto;">
<p>
(<a href="https://www.reddit.com/r/furry/comments/1qeauny/comment/nzx03dm/">View</a>)
</p>
</div>
</div>
<div style="padding: 10px 30px;
word-wrap: break-word;
display: flex;
flex-direction: column;
justify-content: space-between;
min-height: 150px">
<div style="display: flex;
align-items: center;
justify-content: center;
flex-grow: 1">
<p style="width: 100%;">
Komorebi + Komorebi Bar + Flow Launcher (or the Power Toys one) means I can set the Taskbar to autohide and forget about it and the Start menu entirely.
</p>
</div>
<div style="align-self: flex-end; margin-top: auto;">
<p>
(<a href="https://www.reddit.com/r/Windows11/comments/1qh1stt/comment/o0hbm1d/">View</a>)
</p>
</div>
</div>
<div style="padding: 10px 30px;
word-wrap: break-word;
display: flex;
flex-direction: column;
justify-content: space-between;
min-height: 150px">
<div style="display: flex;
align-items: center;
justify-content: center;
flex-grow: 1">
<p style="width: 100%;">
I FOUND MY PEOPLE. dude I rock this and I fucking love it. Have had this set up for almost 2 years now.
<br />
<br />I found komorebi tiling windows manager about a year ago and this whole set up is ideal now
</p>
</div>
<div style="align-self: flex-end; margin-top: auto;">
<p>
(<a href="https://www.reddit.com/r/pcmasterrace/comments/1qeagv5/comment/nzx7y2v/">View</a>)
</p>
</div>
</div>
<div style="padding: 10px 30px;
word-wrap: break-word;
display: flex;
flex-direction: column;
justify-content: space-between;
min-height: 150px">
<div style="display: flex;
align-items: center;
justify-content: center;
flex-grow: 1">
<p style="width: 100%;">
I have searched for exactly what it does so many times over the years with the various monitor configurations I have had. I guess I never searched for the right words until recently! It just works so well and everything I interact with on my screen just goes where I would expect it to be at all times. I love komorebi
</p>
</div>
<div style="align-self: flex-end; margin-top: auto;">
<p>
(<a href="https://www.reddit.com/r/pcmasterrace/comments/1qeagv5/comment/o021vjd/">View</a>)
</p>
</div>
</div>
<div style="padding: 10px 30px;
word-wrap: break-word;
display: flex;
flex-direction: column;
justify-content: space-between;
min-height: 150px">
<div style="display: flex;
align-items: center;
justify-content: center;
flex-grow: 1">
<p style="width: 100%;">
Thank you for designing Komorebi, Jeezy! It's the only MS Windows tiling manager I'll use now. I believe over last few weeks I tried all the ones on Github and the MS Store<br /><br />It's closest to the setup I'll be using in Linux with KDE Plasma and Bismuth or Krohnkite, Sway or Wayland, but better in ways
</p>
</div>
<div style="align-self: flex-end; margin-top: auto;">
<p>
(<a href="https://bsky.app/profile/tjseer.bsky.social/post/3mcegghz54c2c">View</a>)
</p>
</div>
</div>
<div style="padding: 10px 30px;
word-wrap: break-word;
display: flex;
flex-direction: column;
justify-content: space-between;
min-height: 150px">
<div style="display: flex;
align-items: center;
justify-content: center;
flex-grow: 1">
<p style="width: 100%;">
I used to do 3 for game development and honestly it was all just distractions. 1 fast 27” 1440p for coding and gaming is all I need to focus. Komorebi makes it all possible with putting apps on different workspaces I can swap to instantly or tile when needed. In this age of infinite distraction, less is more.
</p>
</div>
<div style="align-self: flex-end; margin-top: auto;">
<p>
(<a href="https://www.reddit.com/r/desksetup/comments/1owcdko/comment/noxbyp0/">View</a>)
</p>
</div>
</div>
<div style="padding: 10px 30px;
word-wrap: break-word;
display: flex;
flex-direction: column;
justify-content: space-between;
min-height: 150px">
<div style="display: flex;
align-items: center;
justify-content: center;
flex-grow: 1">
<p style="width: 100%;">
I did enjoy powertoys fancyzones (and just overall, powertoys and sysinternals are the two groups doing everything top tier in Windows!).
<br />
<br />However I recently moved back to a tiling manager that I find pretty mature now called Komorebi, generally with various layouts.
<br />
<br />The dev is awesome, komorebi works well, and the only challenge is getting my brain used to it now :)
</p>
</div>
<div style="align-self: flex-end; margin-top: auto;">
<p>
(<a href="https://www.reddit.com/r/bapcsalescanada/comments/1ppav4t/comment/num5pxb/">View</a>)
</p>
</div>
</div>
<div style="padding: 10px 30px;
word-wrap: break-word;
display: flex;
flex-direction: column;
justify-content: space-between;
min-height: 150px">
<div style="display: flex;
align-items: center;
justify-content: center;
flex-grow: 1">
<p style="width: 100%;">
this video alone makes me what to invest more effort into configuring komorebi
</p>
</div>
<div style="align-self: flex-end; margin-top: auto;">
<p>
(<a href="https://old.reddit.com/r/desktops/comments/1kl8qr1/komorebi_with_scrolling_layout_komoniri/mshgtpa/">View</a>)
</p>
</div>
</div>
<div style="padding: 10px 30px;
word-wrap: break-word;
display: flex;
flex-direction: column;
justify-content: space-between;
min-height: 150px">
<div style="display: flex;
align-items: center;
justify-content: center;
flex-grow: 1">
<p style="width: 100%;">
Loved using komorebi on a windows machine, would love something like this for Linux!
</p>
</div>
<div style="align-self: flex-end; margin-top: auto;">
<p>
(<a href="https://old.reddit.com/r/rust/comments/1nimfdf/building_a_tiling_window_manager_for_macos_in_rust/nek95jb/">View</a>)
</p>
</div>
</div>
<div style="padding: 10px 30px;
word-wrap: break-word;
display: flex;
flex-direction: column;
justify-content: space-between;
min-height: 150px">
<div style="display: flex;
align-items: center;
justify-content: center;
flex-grow: 1">
<p style="width: 100%;">
based, I started my linux twm journey because of komorebi :D
</p>
</div>
<div style="align-self: flex-end; margin-top: auto;">
<p>
(<a href="https://old.reddit.com/r/unixporn/comments/1nm2nhv/oc_building_my_own_twm_again/nfazidn/">View</a>)
</p>
</div>
</div>
<div style="padding: 10px 30px;
word-wrap: break-word;
display: flex;
flex-direction: column;
justify-content: space-between;
min-height: 150px">
<div style="display: flex;
align-items: center;
justify-content: center;
flex-grow: 1">
<p style="width: 100%;">
I'm honestly glad to see Komorebi getting ported. I don't use Win or Mac anymore on bare metal anymore but on the off chance I need to use a VM this is what I use to keep myself from going insane.
</p>
</div>
<div style="align-self: flex-end; margin-top: auto;">
<p>
(<a href="https://old.reddit.com/r/UsabilityPorn/comments/1nlpsah/komorebi_now_on_macos/nfsjliu/">View</a>)
</p>
</div>
</div>
<div style="padding: 10px 30px;
word-wrap: break-word;
display: flex;
flex-direction: column;
justify-content: space-between;
min-height: 150px">
<div style="display: flex;
align-items: center;
justify-content: center;
flex-grow: 1">
<p style="width: 100%;">
literally cannot use windows without it. ❤️🔥
</p>
</div>
<div style="align-self: flex-end; margin-top: auto;">
<p>
(<a href="https://old.reddit.com/r/desktops/comments/1obd5jv/showing_off_my_work_setup/nktfweb/">View</a>)
</p>
</div>
</div>
<div style="padding: 10px 30px;
word-wrap: break-word;
display: flex;
flex-direction: column;
justify-content: space-between;
min-height: 150px">
<div style="display: flex;
align-items: center;
justify-content: center;
flex-grow: 1">
<p style="width: 100%;">
Komorebi is absolutely amazing. Massive saving grace. Its only fault is the fact that it kept me on windows longer than I should have been on it.
</p>
</div>
<div style="align-self: flex-end; margin-top: auto;">
<p>
(<a href="https://old.reddit.com/r/linuxsucks/comments/1o8j1xs/can_we_make_it_easy_for_the_peoples_of_the_earth/nkobflr/">View</a>)
</p>
</div>
</div>
<div style="padding: 10px 30px;
word-wrap: break-word;
display: flex;
flex-direction: column;
justify-content: space-between;
min-height: 150px">
<div style="display: flex;
align-items: center;
justify-content: center;
flex-grow: 1">
<p style="width: 100%;">
used to use virtual desktops
<br />
<br />since 2 months ago, i switched to komorebi and use its built in workspaces thing, never looked back
</p>
</div>
<div style="align-self: flex-end; margin-top: auto;">
<p>
(<a href="https://www.reddit.com/r/Windows11/comments/1l3yldy/curious_do_you_use_virtual_desktops/mwakqao/">View</a>)
</p>
</div>
</div>
<div style="padding: 10px 30px;
word-wrap: break-word;
display: flex;
flex-direction: column;
justify-content: space-between;
min-height: 150px">
<div style="display: flex;
align-items: center;
justify-content: center;
flex-grow: 1">
<p style="width: 100%;">
outra coisa, eu usei a maior parte do tempo linux com o maravilhoso i3 como window manager, não tem uma opção que seja exatamente igual a ele para o windows, mas o Komorebi chega bem perto, a configuração é bem parecida e to curtindo bastante - tem alguns problemas com algumas aplicações, mas funciona super bem com a maioria
</p>
</div>
<div style="align-self: flex-end; margin-top: auto;">
<p>
(<a href="https://twitter.com/oliveiratheone/status/1929205620625789172">View</a>)
</p>
</div>
</div>
<div style="padding: 10px 30px;
word-wrap: break-word;
display: flex;
flex-direction: column;
justify-content: space-between;
min-height: 150px">
<div style="display: flex;
align-items: center;
justify-content: center;
flex-grow: 1">
<p style="width: 100%;">
Komorebi is rly nice
</p>
</div>
<div style="align-self: flex-end; margin-top: auto;">
<p>
(<a href="https://www.reddit.com/r/desktops/comments/1kzil7x/comment/mv5oub9/">View</a>)
</p>
</div>
</div>
<div style="padding: 10px 30px;
word-wrap: break-word;
display: flex;
flex-direction: column;
justify-content: space-between;
min-height: 150px">
<div style="display: flex;
align-items: center;
justify-content: center;
flex-grow: 1">
<p style="width: 100%;">
Komorebi is really good but read the docs as it's more functional, like on Linux.
</p>
</div>
<div style="align-self: flex-end; margin-top: auto;">
<p>
(<a href="https://www.reddit.com/r/desktops/comments/1kzil7x/comment/mv5tjc1/">View</a>)
</p>
</div>
</div>
<div style="padding: 10px 30px;
word-wrap: break-word;
display: flex;
flex-direction: column;
justify-content: space-between;
min-height: 150px">
<div style="display: flex;
align-items: center;
justify-content: center;
flex-grow: 1">
<p style="width: 100%;">
I love komorebi
</p>
</div>
<div style="align-self: flex-end; margin-top: auto;">
<p>
(<a href="https://www.reddit.com/r/desktops/comments/1kwxefo/comment/muq7jzc/">View</a>)
</p>
</div>
</div>
<div style="padding: 10px 30px;
word-wrap: break-word;
display: flex;
flex-direction: column;
justify-content: space-between;
min-height: 150px">
<div style="display: flex;
align-items: center;
justify-content: center;
flex-grow: 1">
<p style="width: 100%;">
Y'all don't know how good it feels for the left side of my brain to have something that runs and works and looks exactly the way I like.<br /><br />I'm glad I found komorebi for Windows, because I was going insane over the default floating DWM. Now I need to try alacritty.
</p>
</div>
<div style="align-self: flex-end; margin-top: auto;">
<p>
(<a href="https://twitter.com/pivotkraner/status/1925027930435981609?s=12">View</a>)
</p>
</div>
</div>
<div style="padding: 10px 30px;
word-wrap: break-word;
display: flex;
flex-direction: column;
justify-content: space-between;
min-height: 150px">
<div style="display: flex;
align-items: center;
justify-content: center;
flex-grow: 1">
<p style="width: 100%;">
Instalei komorebi no windows q delicinha (falta isso aqui 🤏 pra mergulhar no hyprland)
</p>
</div>
<div style="align-self: flex-end; margin-top: auto;">
<p>
(<a href="https://twitter.com/oliviere_/status/1925355386816577786?s=12">View</a>)
</p>
</div>
</div>
<div style="padding: 10px 30px;
word-wrap: break-word;
display: flex;
flex-direction: column;
justify-content: space-between;
min-height: 150px">
<div style="display: flex;
align-items: center;
justify-content: center;
flex-grow: 1">
<p style="width: 100%;">
If you decide to stick with Windows: Check out komorebi, it's an awesome tiling window manager that makes Windows 1000x times more useable. Combine that with WSL and you could even use it as your main work OS this way
</p>
</div>
<div style="align-self: flex-end; margin-top: auto;">
<p>
(<a href="https://twitter.com/tommyfalkowski/status/1926366459686527184?s=12">View</a>)
</p>
</div>
</div>
<div style="padding: 10px 30px;
word-wrap: break-word;
display: flex;
flex-direction: column;
justify-content: space-between;
min-height: 150px">
<div style="display: flex;
align-items: center;
justify-content: center;
flex-grow: 1">
<p style="width: 100%;">
Great app, great dev and friendly community not only for komorebi but for whole dev/geek customization for windows!
</p>
</div>
<div style="align-self: flex-end; margin-top: auto;">
<p>
(<a href="https://www.youtube.com/watch?v=MuR1-w-rLZI&lc=UgyWXDpqXqaJhQqWhyB4AaABAg&pp=0gcJCSMANpG00pGi">View</a>)
</p>
</div>
</div>
<div style="padding: 10px 30px;
word-wrap: break-word;
display: flex;
flex-direction: column;
justify-content: space-between;
min-height: 150px">
<div style="display: flex;
align-items: center;
justify-content: center;
flex-grow: 1">
<p style="width: 100%;">
@LGUG2Z You've built a crazy powerful productivity manager. Thank you so much!
</p>
</div>
<div style="align-self: flex-end; margin-top: auto;">
<p>
(<a href="https://youtube.com/watch?v=MuR1-w-rLZI&lc=UgxLhTIJW6mR6m5_6294AaABAg.AIUuQW1nBEqAIW6zlNcSVb&si=wa9nny0LJ12ELGCz">View</a>)
</p>
</div>
</div>
<div style="padding: 10px 30px;
word-wrap: break-word;
display: flex;
flex-direction: column;
justify-content: space-between;
min-height: 150px">
<div style="display: flex;
align-items: center;
justify-content: center;
flex-grow: 1">
<p style="width: 100%;">
Komorebi Window Manager — There's a few WM's out there that work on win11 but this one is good for me, it's simple and lightweight. I just need multiple window functionality and automatic tiling. This is huge for me because windows multiple desktop features are almost good but not good enough, sluggish and no automatic tiling.
</p>
</div>
<div style="align-self: flex-end; margin-top: auto;">
<p>
(<a href="https://old.reddit.com/r/sysadmin/comments/1kte19p/it_how_much_do_you_earn_share_if_its_not_a_secret/mtv3dyf/">View</a>)
</p>
</div>
</div>
<div style="padding: 10px 30px;
word-wrap: break-word;
display: flex;
flex-direction: column;
justify-content: space-between;
min-height: 150px">
<div style="display: flex;
align-items: center;
justify-content: center;
flex-grow: 1">
<p style="width: 100%;">
Love the pace at which this project improves! I can't live without it.
</p>
</div>
<div style="align-self: flex-end; margin-top: auto;">
<p>
(<a href="https://www.youtube.com/watch?v=MuR1-w-rLZI&lc=UgzRHIHbToKuyleiRVx4AaABAg">View</a>)
</p>
</div>
</div>
<div style="padding: 10px 30px;
word-wrap: break-word;
display: flex;
flex-direction: column;
justify-content: space-between;
min-height: 150px">
<div style="display: flex;
align-items: center;
justify-content: center;
flex-grow: 1">
<p style="width: 100%;">
I've been using it for about 5 months with great success. I'm a Hyprland user when I'm on my personal machine, but for windows Komorebi has let me keep my muscle memory and workflow largely intact.
</p>
</div>
<div style="align-self: flex-end; margin-top: auto;">
<p>
(<a href="https://news.ycombinator.com/item?id=44044056">View</a>)
</p>
</div>
</div>
<div style="padding: 10px 30px;
word-wrap: break-word;
display: flex;
flex-direction: column;
justify-content: space-between;
min-height: 150px">
<div style="display: flex;
align-items: center;
justify-content: center;
flex-grow: 1">
<p style="width: 100%;">
Komorebi! best tiling manager for windows, if you have 11 i recommend disabling the built in feature for tiling windows 11 comes with and using komorebi instead, way better
</p>
</div>
<div style="align-self: flex-end; margin-top: auto;">
<p>
(<a href="https://www.reddit.com/r/desktops/comments/1kpvf8x/comment/mt50iyg/">View</a>)
</p>
</div>
</div>
<div style="padding: 10px 30px;
word-wrap: break-word;
display: flex;
flex-direction: column;
justify-content: space-between;
min-height: 150px">
<div style="display: flex;
align-items: center;
justify-content: center;
flex-grow: 1">
<p style="width: 100%;">
Another Komorebi enjoyer in the wild. I use the default Komorebi bar tho, I think is enough to me.
</p>
</div>
<div style="align-self: flex-end; margin-top: auto;">
<p>
(<a href="https://www.reddit.com/r/arch/comments/1kq0a90/would_you_believe_me_if_i_said_this_is/mt4g581/">View</a>)
</p>
</div>
</div>
<div style="padding: 10px 30px;
word-wrap: break-word;
display: flex;
flex-direction: column;
justify-content: space-between;
min-height: 150px">
<div style="display: flex;
align-items: center;
justify-content: center;
flex-grow: 1">
<p style="width: 100%;">
I also use komorebi with no bar and with mouse follow focus. Perfect combo. Never felt like I'm missing linux.
</p>
</div>
<div style="align-self: flex-end; margin-top: auto;">
<p>
(<a href="https://www.reddit.com/r/linuxsucks/comments/1kqaaz5/comment/mt72r53/">View</a>)
</p>
</div>
</div>
<div style="padding: 10px 30px;
word-wrap: break-word;
display: flex;
flex-direction: column;
justify-content: space-between;
min-height: 150px">
<div style="display: flex;
align-items: center;
justify-content: center;
flex-grow: 1">
<p style="width: 100%;">
First, Thank you for the awesome work, it is now the best tiling wm i have find in windows!
</p>
</div>
<div style="align-self: flex-end; margin-top: auto;">
<p>
(<a href="https://github.com/LGUG2Z/komorebi/issues/738">View</a>)
</p>
</div>
</div>
<div style="padding: 10px 30px;
word-wrap: break-word;
display: flex;
flex-direction: column;
justify-content: space-between;
min-height: 150px">
<div style="display: flex;
align-items: center;
justify-content: center;
flex-grow: 1">
<p style="width: 100%;">
Komorebi is an awesome piece of software and it makes me happy I was able to contribute to it in a small way! Working with Rust for the first time was interesting as well!
</p>
</div>
<div style="align-self: flex-end; margin-top: auto;">
<p>
(<a href="https://github.com/LGUG2Z/komorebi/issues/1226#issuecomment-2746140792">View</a>)
</p>
</div>
</div>
<div style="padding: 10px 30px;
word-wrap: break-word;
display: flex;
flex-direction: column;
justify-content: space-between;
min-height: 150px">
<div style="display: flex;
align-items: center;
justify-content: center;
flex-grow: 1">
<p style="width: 100%;">
Hi, Firstly thank you very much for this awesome piece of software! I use it everyday!
</p>
</div>
<div style="align-self: flex-end; margin-top: auto;">
<p>
(<a href="https://github.com/LGUG2Z/komorebi/issues/660">View</a>)
</p>
</div>
</div>
<div style="padding: 10px 30px;
word-wrap: break-word;
display: flex;
flex-direction: column;
justify-content: space-between;
min-height: 150px">
<div style="display: flex;
align-items: center;
justify-content: center;
flex-grow: 1">
<p style="width: 100%;">
thx and keep up the good work, this software is truly awesome !!
</p>
</div>
<div style="align-self: flex-end; margin-top: auto;">
<p>
(<a href="https://github.com/LGUG2Z/komorebi/issues/263">View</a>)
</p>
</div>
</div>
<div style="padding: 10px 30px;
word-wrap: break-word;
display: flex;
flex-direction: column;
justify-content: space-between;
min-height: 150px">
<div style="display: flex;
align-items: center;
justify-content: center;
flex-grow: 1">
<p style="width: 100%;">
In any case, thanks a lot for this project. I've been using it daily for a few months now and it's amazing!
</p>
</div>
<div style="align-self: flex-end; margin-top: auto;">
<p>
(<a href="https://github.com/LGUG2Z/komorebi/issues/975">View</a>)
</p>
</div>
</div>
<div style="padding: 10px 30px;
word-wrap: break-word;
display: flex;
flex-direction: column;
justify-content: space-between;
min-height: 150px">
<div style="display: flex;
align-items: center;
justify-content: center;
flex-grow: 1">
<p style="width: 100%;">
First of all, I would like to thank you all for this amazing software, it works very well and is very easy to use.
</p>
</div>
<div style="align-self: flex-end; margin-top: auto;">
<p>
(<a href="https://github.com/LGUG2Z/komorebi/issues/914">View</a>)
</p>
</div>
</div>
<div style="padding: 10px 30px;
word-wrap: break-word;
display: flex;
flex-direction: column;
justify-content: space-between;
min-height: 150px">
<div style="display: flex;
align-items: center;
justify-content: center;
flex-grow: 1">
<p style="width: 100%;">
First of all, thank you for this amazing piece of software. It has helped a lot when trying to transition from Linux to Windows.
</p>
</div>
<div style="align-self: flex-end; margin-top: auto;">
<p>
(<a href="https://github.com/LGUG2Z/komorebi/issues/225">View</a>)
</p>
</div>
</div>
<div style="padding: 10px 30px;
word-wrap: break-word;
display: flex;
flex-direction: column;
justify-content: space-between;
min-height: 150px">
<div style="display: flex;
align-items: center;
justify-content: center;
flex-grow: 1">
<p style="width: 100%;">
First of all, thank you for your very quick replies and for developing this project. This project is absolutely AMAZING ❤️
</p>
</div>
<div style="align-self: flex-end; margin-top: auto;">
<p>
(<a href="https://github.com/LGUG2Z/komorebi/issues/913#issuecomment-2227032262">View</a>)
</p>
</div>
</div>
<div style="padding: 10px 30px;
word-wrap: break-word;
display: flex;
flex-direction: column;
justify-content: space-between;
min-height: 150px">
<div style="display: flex;
align-items: center;
justify-content: center;
flex-grow: 1">
<p style="width: 100%;">
I have been using komorebi for the past few days and I must say it is really amazing. I have used bug.n a long time ago, but I stopped using it as it was not actively maintained anymore and had a lot of issues. I was amazed to see how much more responsive and feature rich komorebi is.
</p>
</div>
<div style="align-self: flex-end; margin-top: auto;">
<p>
(<a href="https://github.com/LGUG2Z/komorebi/issues/363#issuecomment-1598324953">View</a>)
</p>
</div>
</div>
<div style="padding: 10px 30px;
word-wrap: break-word;
display: flex;
flex-direction: column;
justify-content: space-between;
min-height: 150px">
<div style="display: flex;
align-items: center;
justify-content: center;
flex-grow: 1">
<p style="width: 100%;">
I was planning on GitHub sponsoring you for the awesome work you are doing but it turns out Paypal is not longer supported by GitHub. Therefore I donated 10$ to Fresh start refugee instead. Thank you so much for your work!
</p>
</div>
<div style="align-self: flex-end; margin-top: auto;">
<p>
(<a href="https://github.com/LGUG2Z/komorebi/issues/363#issuecomment-1445378174">View</a>)
</p>
</div>
</div>
<div style="padding: 10px 30px;
word-wrap: break-word;
display: flex;
flex-direction: column;
justify-content: space-between;
min-height: 150px">
<div style="display: flex;
align-items: center;
justify-content: center;
flex-grow: 1">
<p style="width: 100%;">
All in all, amazing job with this tool! So glad to see we've finally got some tilling options out there for Windows. Also glad to see it's in Rust. Hoping this language continues to gain traction.
</p>
</div>
<div style="align-self: flex-end; margin-top: auto;">
<p>
(<a href="https://github.com/LGUG2Z/komorebi/issues/94#issuecomment-1007825560">View</a>)
</p>
</div>
</div>
<div style="padding: 10px 30px;
word-wrap: break-word;
display: flex;
flex-direction: column;
justify-content: space-between;
min-height: 150px">
<div style="display: flex;
align-items: center;
justify-content: center;
flex-grow: 1">
<p style="width: 100%;">
That would be great! And thank you for the work that you do on this amazing WM. It's very nice to have a reliable tiling window manager in Windows.
</p>
</div>
<div style="align-self: flex-end; margin-top: auto;">
<p>
(<a href="https://github.com/LGUG2Z/komorebi/issues/1191#issuecomment-2556326132">View</a>)
</p>
</div>
</div>
<div style="padding: 10px 30px;
word-wrap: break-word;
display: flex;
flex-direction: column;
justify-content: space-between;
min-height: 150px">
<div style="display: flex;
align-items: center;
justify-content: center;
flex-grow: 1">
<p style="width: 100%;">
this is giving me the urge to go set up komorebi again and rice my windows. stop making good software you're going to make me go down a rabbithole again /j
</p>
</div>
<div style="align-self: flex-end; margin-top: auto;">
<p>
(<a href="https://bsky.app/profile/notnite.com/post/3lp2vwfk6vc2g">View</a>)
</p>
</div>
</div>
<div style="padding: 10px 30px;
word-wrap: break-word;
display: flex;
flex-direction: column;
justify-content: space-between;
min-height: 150px">
<div style="display: flex;
align-items: center;
justify-content: center;
flex-grow: 1">
<p style="width: 100%;">
@LGUG2Z I'm still very much a newbie, so I don't think I've done anything that immediately jumps out or "wows" anyone yet. I'm also in a part of Microsoft that's much less homogeneous and runs a lot of container-based services, so folks are already more familiar with Linux in general.<br /><br />No one has asked a "but why" type question yet, but I have gotten a couple of questions about how "integrated" it is. Meaning like how well does komorebi handle MDI apps like Office and Visual Studio, can you mix komorebi with built-in shortcuts, etc. (my answer there is so far really good but not 100% perfect. If I can figure out some repro cases I'll file bugs 🙃).<br /><br />Since finding komorebi …
</p>
</div>
<div style="align-self: flex-end; margin-top: auto;">
<p>
(<a href="https://hachyderm.io/@mattkotsenas/113929842234027643">View</a>)
</p>
</div>
</div>
<div style="padding: 10px 30px;
word-wrap: break-word;
display: flex;
flex-direction: column;
justify-content: space-between;
min-height: 150px">
<div style="display: flex;
align-items: center;
justify-content: center;
flex-grow: 1">
<p style="width: 100%;">
Ideally I wouldn't be using #windows but #corporateLife demands it, #komorebi makes the experience a lot like having a proper #tilingWindowManager in linux https://github.com/LGUG2Z/komorebi
</p>
</div>
<div style="align-self: flex-end; margin-top: auto;">
<p>
(<a href="https://fosstodon.org/@lopeztel/113588105008682951">View</a>)
</p>
</div>
</div>
<div style="padding: 10px 30px;
word-wrap: break-word;
display: flex;
flex-direction: column;
justify-content: space-between;
min-height: 150px">
<div style="display: flex;
align-items: center;
justify-content: center;
flex-grow: 1">
<p style="width: 100%;">
Since I'm currently using Windows a bit more outside of work, I thought it was a good time to finally set up Komorebi, the Tiling Window Manager for Windows. If the thought of using Windows fills you with dread—Komorebi is awesome enough to change your mind! <br /><br />An intuitive configuration, the full bandwidth of wm functionality, and @LGUG2Z 's perfectly on-point introduction videos make it surprisingly easy to get into. Easily competes with comparable options for Linux.<br /><br />#windows #komorebi
</p>
</div>
<div style="align-self: flex-end; margin-top: auto;">
<p>
(<a href="https://mastodon.social/@BonfireAtNight/113640279148775476">View</a>)
</p>
</div>
</div>
<div style="padding: 10px 30px;
word-wrap: break-word;
display: flex;
flex-direction: column;
justify-content: space-between;
min-height: 150px">
<div style="display: flex;
align-items: center;
justify-content: center;
flex-grow: 1">
<p style="width: 100%;">
I've used komorebi and yasb as a tiling window manager and nice status bar on Win11. The features aren't the same, but it's better than default Windows.
</p>
</div>
<div style="align-self: flex-end; margin-top: auto;">
<p>
(<a href="https://www.reddit.com/r/pop_os/comments/1i4npv8/is_it_possible_to_make_windows_11_look_and_feel_a/m7xqgya/">View</a>)
</p>
</div>
</div>
<div style="padding: 10px 30px;
word-wrap: break-word;
display: flex;
flex-direction: column;
justify-content: space-between;
min-height: 150px">
<div style="display: flex;
align-items: center;
justify-content: center;
flex-grow: 1">
<p style="width: 100%;">
I love that you are posting a log of all this. I just got started getting your program configured and its absolutely amazing. Never realized I could get a good tiling WM working on windows like this
</p>
</div>
<div style="align-self: flex-end; margin-top: auto;">
<p>
(<a href="https://www.youtube.com/watch?v=2mm0WAkG-Qs&lc=UgwmCcCNf4jhDK6F_fR4AaABAg">View</a>)
</p>
</div>
</div>
<div style="padding: 10px 30px;
word-wrap: break-word;
display: flex;
flex-direction: column;
justify-content: space-between;
min-height: 150px">
<div style="display: flex;
align-items: center;
justify-content: center;
flex-grow: 1">
<p style="width: 100%;">
Can't believe it took me this long to find this. Thanks, this will help tons on desktops I have to use windows for
</p>
</div>
<div style="align-self: flex-end; margin-top: auto;">
<p>
(<a href="https://www.youtube.com/watch?v=cBnLIwMtv8g&lc=Ugzn-lMox55pvpAFo0Z4AaABAg&pp=0gcJCSMANpG00pGi">View</a>)
</p>
</div>
</div>
<div style="padding: 10px 30px;
word-wrap: break-word;
display: flex;
flex-direction: column;
justify-content: space-between;
min-height: 150px">
<div style="display: flex;
align-items: center;
justify-content: center;
flex-grow: 1">
<p style="width: 100%;">
As an I3 user, This is something I've been looking forward to, for Windows, for a long time. Thank you!
</p>
</div>
<div style="align-self: flex-end; margin-top: auto;">
<p>
(<a href="https://www.youtube.com/watch?v=cBnLIwMtv8g&lc=Ugzp0AMtP6KuC3F6O3t4AaABAg">View</a>)
</p>
</div>
</div>
<div style="padding: 10px 30px;
word-wrap: break-word;
display: flex;
flex-direction: column;
justify-content: space-between;
min-height: 150px">
<div style="display: flex;
align-items: center;
justify-content: center;
flex-grow: 1">
<p style="width: 100%;">
This is awesome. Thank you for your hard work.
</p>
</div>
<div style="align-self: flex-end; margin-top: auto;">
<p>
(<a href="https://www.youtube.com/watch?v=cBnLIwMtv8g&lc=UgwGUt4GY7fzM09x6oh4AaABAg">View</a>)
</p>
</div>
</div>
<div style="padding: 10px 30px;
word-wrap: break-word;
display: flex;
flex-direction: column;
justify-content: space-between;
min-height: 150px">
<div style="display: flex;
align-items: center;
justify-content: center;
flex-grow: 1">
<p style="width: 100%;">
Just found your project, IT'S A BLESS for windows users. Keep up your incredible work!
</p>
</div>
<div style="align-self: flex-end; margin-top: auto;">
<p>
(<a href="https://www.youtube.com/watch?v=Ece_NCcgiMY&lc=UgyRZvDRC65klomOEmJ4AaABAg">View</a>)
</p>
</div>
</div>
<div style="padding: 10px 30px;
word-wrap: break-word;
display: flex;
flex-direction: column;
justify-content: space-between;
min-height: 150px">
<div style="display: flex;
align-items: center;
justify-content: center;
flex-grow: 1">
<p style="width: 100%;">
Just came upon your software, as a dwm user this is so awesome. Keep up the good work ill definitely be using this from now on
</p>
</div>
<div style="align-self: flex-end; margin-top: auto;">
<p>
(<a href="https://www.youtube.com/watch?v=Ece_NCcgiMY&lc=UgwzS2lYlO-9Z2h4h_R4AaABAg">View</a>)
</p>
</div>
</div>
<div style="padding: 10px 30px;
word-wrap: break-word;
display: flex;
flex-direction: column;
justify-content: space-between;
min-height: 150px">
<div style="display: flex;
align-items: center;
justify-content: center;
flex-grow: 1">
<p style="width: 100%;">
Yes! I was using glazewm until 3 days ago actually when I finally gave up trying to get it to work with yasb and also making the top bar look good.
<br />
<br />Switching was quite easy and all the shortcuts were the same and also configurable.
</p>
</div>
<div style="align-self: flex-end; margin-top: auto;">
<p>
(<a href="https://www.reddit.com/r/desktops/comments/1kjelaa/everforest_didnt_know_this_could_be_done_on_w11/mrq6hsi/">View</a>)
</p>
</div>
</div>
<div style="padding: 10px 30px;
word-wrap: break-word;
display: flex;
flex-direction: column;
justify-content: space-between;
min-height: 150px">
<div style="display: flex;
align-items: center;
justify-content: center;
flex-grow: 1">
<p style="width: 100%;">
I think screens are configurable on komorebi but I'm not sure. Komorebi and glazewm for me were really similar but komorebi just seemed to work better with the top bar yasb.
</p>
</div>
<div style="align-self: flex-end; margin-top: auto;">
<p>
(<a href="https://www.reddit.com/r/Windows11/comments/1kjg34a/windows_can_look_really_good_with_some_effort/mrs024w/">View</a>)
</p>
</div>
</div>
<div style="padding: 10px 30px;
word-wrap: break-word;
display: flex;
flex-direction: column;
justify-content: space-between;
min-height: 150px">
<div style="display: flex;
align-items: center;
justify-content: center;
flex-grow: 1">
<p style="width: 100%;">
Thank you for your hard work on Komorebi. I have been following the project for some time and we've crossed paths in comment sections before. I use a Mac at work now or I would still be using it. You're fighting the good fight for tiling WM enthusiasts everywhere!
</p>
</div>
<div style="align-self: flex-end; margin-top: auto;">
<p>
(<a href="https://news.ycombinator.com/item?id=33229725">View</a>)
</p>
</div>
</div>
<div style="padding: 10px 30px;
word-wrap: break-word;
display: flex;
flex-direction: column;
justify-content: space-between;
min-height: 150px">
<div style="display: flex;
align-items: center;
justify-content: center;
flex-grow: 1">
<p style="width: 100%;">
This is awesome. Thank you for your hard work.
</p>
</div>
<div style="align-self: flex-end; margin-top: auto;">
<p>
(<a href="https://www.youtube.com/watch?v=cBnLIwMtv8g&lc=UgwGUt4GY7fzM09x6oh4AaABAg&pp=0gcJCSMANpG00pGi">View</a>)
</p>
</div>
</div>
<div style="padding: 10px 30px;
word-wrap: break-word;
display: flex;
flex-direction: column;
justify-content: space-between;
min-height: 150px">
<div style="display: flex;
align-items: center;
justify-content: center;
flex-grow: 1">
<p style="width: 100%;">
yes yes komorebi is great
</p>
</div>
<div style="align-self: flex-end; margin-top: auto;">
<p>
(<a href="https://www.youtube.com/watch?v=cBnLIwMtv8g&lc=Ugx9GZM9AWP0ZhKGcsF4AaABAg">View</a>)
</p>
</div>
</div>
<div style="padding: 10px 30px;
word-wrap: break-word;
display: flex;
flex-direction: column;
justify-content: space-between;
min-height: 150px">
<div style="display: flex;
align-items: center;
justify-content: center;
flex-grow: 1">
<p style="width: 100%;">
Fantastic. Work perfect thank you very much. One of the things i dont like work in windows is for dont have tiling and good keys for mange virtual desktops. I dont have excuse now :D Only need tiling Autostart for windows :P
<br />
<br />Very good job. Congratulations!!
</p>
</div>
<div style="align-self: flex-end; margin-top: auto;">
<p>
(<a href="https://www.youtube.com/watch?v=cBnLIwMtv8g&lc=Ugzi-1VP9rnzwRBQ0b14AaABAg">View</a>)
</p>
</div>
</div>
<div style="padding: 10px 30px;
word-wrap: break-word;
display: flex;
flex-direction: column;
justify-content: space-between;
min-height: 150px">
<div style="display: flex;
align-items: center;
justify-content: center;
flex-grow: 1">
<p style="width: 100%;">
Windlrland. Thank you for sharing & making our dreams on Windows 11 a reality.
</p>
</div>
<div style="align-self: flex-end; margin-top: auto;">
<p>
(<a href="https://www.youtube.com/watch?v=cBnLIwMtv8g&lc=UgygxoBBKRvaLezsf8J4AaABAg">View</a>)
</p>
</div>
</div>
<div style="padding: 10px 30px;
word-wrap: break-word;
display: flex;
flex-direction: column;
justify-content: space-between;
min-height: 150px">
<div style="display: flex;
align-items: center;
justify-content: center;
flex-grow: 1">
<p style="width: 100%;">
So glad I just found this. On my Linux laptop I use monadx and I've always wished for a windows tiling manager.
</p>
</div>
<div style="align-self: flex-end; margin-top: auto;">
<p>
(<a href="https://www.youtube.com/watch?v=cBnLIwMtv8g&lc=Ugy5xLHCOCkLu3-sWcR4AaABAg">View</a>)
</p>
</div>
</div>
<div style="padding: 10px 30px;
word-wrap: break-word;
display: flex;
flex-direction: column;
justify-content: space-between;
min-height: 150px">
<div style="display: flex;
align-items: center;
justify-content: center;
flex-grow: 1">
<p style="width: 100%;">
Can't believe it took me this long to find this. Thanks, this will help tons on desktops I have to use windows for
</p>
</div>
<div style="align-self: flex-end; margin-top: auto;">
<p>
(<a href="https://www.youtube.com/watch?v=cBnLIwMtv8g&lc=Ugzn-lMox55pvpAFo0Z4AaABAg">View</a>)
</p>
</div>
</div>
<div style="padding: 10px 30px;
word-wrap: break-word;
display: flex;
flex-direction: column;
justify-content: space-between;
min-height: 150px">
<div style="display: flex;
align-items: center;
justify-content: center;
flex-grow: 1">
<p style="width: 100%;">
I recently discovered tiling window managers and although I haven't made the switch to Linux just yet, I believe this is a fantastic solution for Windows users. Additionally, as a Mac user, I also use yabai, and I must say that Komorebi is a seamless solution for anyone using Windows.
</p>
</div>
<div style="align-self: flex-end; margin-top: auto;">
<p>
(<a href="https://www.youtube.com/watch?v=cBnLIwMtv8g&lc=Ugxx36g8siERn-3nokd4AaABAg">View</a>)
</p>
</div>
</div>
<div style="padding: 10px 30px;
word-wrap: break-word;
display: flex;
flex-direction: column;
justify-content: space-between;
min-height: 150px">
<div style="display: flex;
align-items: center;
justify-content: center;
flex-grow: 1">
<p style="width: 100%;">
Definitely gonna use this if i need to switch away from linux for a minute
</p>
</div>
<div style="align-self: flex-end; margin-top: auto;">
<p>
(<a href="https://www.youtube.com/watch?v=hDDxtvpjpHs&lc=Ugyp-S_pblotgcKHlER4AaABAg">View</a>)
</p>
</div>
</div>
<div style="padding: 10px 30px;
word-wrap: break-word;
display: flex;
flex-direction: column;
justify-content: space-between;
min-height: 150px">
<div style="display: flex;
align-items: center;
justify-content: center;
flex-grow: 1">
<p style="width: 100%;">
As an fan of I3 tiling on linux I rlly love your work, well done !
</p>
</div>
<div style="align-self: flex-end; margin-top: auto;">
<p>
(<a href="https://www.youtube.com/watch?v=hDDxtvpjpHs&lc=UgwKRIb8lxzOGEAsM714AaABAg">View</a>)
</p>
</div>
</div>
<div style="padding: 10px 30px;
word-wrap: break-word;
display: flex;
flex-direction: column;
justify-content: space-between;
min-height: 150px">
<div style="display: flex;
align-items: center;
justify-content: center;
flex-grow: 1">
<p style="width: 100%;">
I switched back and forth between linux and windows for a while and what kept me on linux was the customisation capabilities but with aops like these i can replicate my old linux experience with all my apps on windows, thanks for developing this ❤
</p>
</div>
<div style="align-self: flex-end; margin-top: auto;">
<p>
(<a href="https://www.youtube.com/watch?v=hDDxtvpjpHs&lc=Ugz5s9sFmSRJ9KtE01t4AaABAg">View</a>)
</p>
</div>
</div>
<div style="padding: 10px 30px;
word-wrap: break-word;
display: flex;
flex-direction: column;
justify-content: space-between;
min-height: 150px">
<div style="display: flex;
align-items: center;
justify-content: center;
flex-grow: 1">
<p style="width: 100%;">
I really appreciate the work you've put into the development of komorebi. I would really like to sponsor, but unfortunately I'm still a student who makes no money. But regardless I believe this project is really amazing, thanks again for all the work you've put into it ❤
</p>
</div>
<div style="align-self: flex-end; margin-top: auto;">
<p>
(<a href="https://www.youtube.com/watch?v=hDDxtvpjpHs&lc=UgytLiT-xPaJfHnzdqV4AaABAg">View</a>)
</p>
</div>
</div>
<div style="padding: 10px 30px;
word-wrap: break-word;
display: flex;
flex-direction: column;
justify-content: space-between;
min-height: 150px">
<div style="display: flex;
align-items: center;
justify-content: center;
flex-grow: 1">
<p style="width: 100%;">
Great! I'm glad how this project is maturing. Thank you for all the work you put into it. I've been running Komorebi on my system for a couple of months now and I don't think i'm going back. Congratulations on the new release! 👏👏👏
</p>
</div>
<div style="align-self: flex-end; margin-top: auto;">
<p>
(<a href="https://www.youtube.com/watch?v=iaZWBmVzd0A&lc=UgyBBl0O9UxP_StBC-x4AaABAg">View</a>)
</p>
</div>
</div>
<div style="padding: 10px 30px;
word-wrap: break-word;
display: flex;
flex-direction: column;
justify-content: space-between;
min-height: 150px">
<div style="display: flex;
align-items: center;
justify-content: center;
flex-grow: 1">
<p style="width: 100%;">
I so happy with komorebi. I use it everyday.
</p>
</div>
<div style="align-self: flex-end; margin-top: auto;">
<p>
(<a href="https://www.youtube.com/watch?v=Tkn0sY_pkzI&lc=Ugx1TA2NddC1On8sBKV4AaABAg">View</a>)
</p>
</div>
</div>
<div style="padding: 10px 30px;
word-wrap: break-word;
display: flex;
flex-direction: column;
justify-content: space-between;
min-height: 150px">
<div style="display: flex;
align-items: center;
justify-content: center;
flex-grow: 1">
<p style="width: 100%;">
Coming from Linux, having to work in Windows for work, I cannot thank you and the work you do enough. Like you said in the video, I hate not having a tilling window manager and Komorebi is a great solution!
</p>
</div>
<div style="align-self: flex-end; margin-top: auto;">
<p>
(<a href="https://www.youtube.com/watch?v=6LcjPozTGpQ&lc=UgzTwHcKkBOSa0WB_ph4AaABAg">View</a>)
</p>
</div>
</div>
<div style="padding: 10px 30px;
word-wrap: break-word;
display: flex;
flex-direction: column;
justify-content: space-between;
min-height: 150px">
<div style="display: flex;
align-items: center;
justify-content: center;
flex-grow: 1">
<p style="width: 100%;">
I just started using Komorebi and man this tool is awesome hands down the best thing about windows now knowing how annoying its been historically having no ez tiling manager to use. Keep up the awesome work sorry to hear about the hard fork guy.
</p>
</div>
<div style="align-self: flex-end; margin-top: auto;">
<p>
(<a href="https://www.youtube.com/watch?v=yDyuVBqj2qk&lc=UgzNAee62HRkTxuZc8h4AaABAg">View</a>)
</p>
</div>
</div>
<div style="padding: 10px 30px;
word-wrap: break-word;
display: flex;
flex-direction: column;
justify-content: space-between;
min-height: 150px">
<div style="display: flex;
align-items: center;
justify-content: center;
flex-grow: 1">
<p style="width: 100%;">
Thank you so much. In my Linux DE, I use qtile and I was wondering if there is tiling wm similar to i3 or qtile in Windows. I tried several apps in my Windows OS and so far komorebi is the best. I think I'd stick with it going forward. BTW, I believe komorebi is the first program running in my computer, which is written in Rust :D Great job!
</p>
</div>
<div style="align-self: flex-end; margin-top: auto;">
<p>
(<a href="https://www.youtube.com/watch?v=yDyuVBqj2qk&lc=UgwVXDNUm9xCOhVK83x4AaABAg">View</a>)
</p>
</div>
</div>
<div style="padding: 10px 30px;
word-wrap: break-word;
display: flex;
flex-direction: column;
justify-content: space-between;
min-height: 150px">
<div style="display: flex;
align-items: center;
justify-content: center;
flex-grow: 1">
<p style="width: 100%;">
Coming from Linux, having to work in Windows for work, I cannot thank you and the work you do enough. Like you said in the video, I hate not having a tilling window manager and Komorebi is a great solution!
</p>
</div>
<div style="align-self: flex-end; margin-top: auto;">
<p>
(<a href="https://www.youtube.com/watch?v=6LcjPozTGpQ&lc=UgzTwHcKkBOSa0WB_ph4AaABAg&pp=0gcJCSMANpG00pGi">View</a>)
</p>
</div>
</div>
<div style="padding: 10px 30px;
word-wrap: break-word;
display: flex;
flex-direction: column;
justify-content: space-between;
min-height: 150px">
<div style="display: flex;
align-items: center;
justify-content: center;
flex-grow: 1">
<p style="width: 100%;">
Can't wait to see where this goes, been dying to try Hyprland but I'm forced to use Win 11 for work and this, combined with an Arch WSL neovim setup, is the closest I've gotten to a workflow I actually enjoy. Hope we'll see manual tiling soon because I did try GlazeWM and while it's awesome in it's own right, I feel like Komorebi handles multiple displays better, heck, the only thing I'm really missing from Glaze is the little top bar.
</p>
</div>
<div style="align-self: flex-end; margin-top: auto;">
<p>
(<a href="https://www.youtube.com/watch?v=6LcjPozTGpQ&lc=Ugw5bHCAJ-K0VDoZqqV4AaABAg">View</a>)
</p>
</div>
</div>
<div style="padding: 10px 30px;
word-wrap: break-word;
display: flex;
flex-direction: column;
justify-content: space-between;
min-height: 150px">
<div style="display: flex;
align-items: center;
justify-content: center;
flex-grow: 1">
<p style="width: 100%;">
Excellent project! This makes windows more bearable to use
</p>
</div>
<div style="align-self: flex-end; margin-top: auto;">
<p>
(<a href="https://www.youtube.com/watch?v=PLz3eqQDufw&lc=UgxQIisXcNlQZGasx7l4AaABAg">View</a>)
</p>
</div>
</div>
<div style="padding: 10px 30px;
word-wrap: break-word;
display: flex;
flex-direction: column;
justify-content: space-between;
min-height: 150px">
<div style="display: flex;
align-items: center;
justify-content: center;
flex-grow: 1">
<p style="width: 100%;">
Man this project is what makes me use windows now thank you so much for all the work you do
</p>
</div>
<div style="align-self: flex-end; margin-top: auto;">
<p>
(<a href="https://www.youtube.com/watch?v=PLz3eqQDufw&lc=Ugxxw1hT0WdLaRHxJG54AaABAg">View</a>)
</p>
</div>
</div>
<div style="padding: 10px 30px;
word-wrap: break-word;
display: flex;
flex-direction: column;
justify-content: space-between;
min-height: 150px">
<div style="display: flex;
align-items: center;
justify-content: center;
flex-grow: 1">
<p style="width: 100%;">
Not gonna lie, this all looks like a giant PITA when all I wanted was to move windows to specific fancy zones with each having its own shortcuts. <br /><br />But watching your (superchill & efficient) videos has shown that the suffering will be worth it & offer more than I knew I wanted. <br /><br />I’d still love to be ignorant, but I appreciate the learning experience & the config generator too. <br /><br />Thanks for komorebi, thanks for making it free & thanks for holding my hand along the way.
</p>
</div>
<div style="align-self: flex-end; margin-top: auto;">
<p>
(<a href="https://www.youtube.com/watch?v=H9-_c1egQ4g&lc=UgwjRHbiydcYbAQKqfV4AaABAg">View</a>)
</p>
</div>
</div>
<div style="padding: 10px 30px;
word-wrap: break-word;
display: flex;
flex-direction: column;
justify-content: space-between;
min-height: 150px">
<div style="display: flex;
align-items: center;
justify-content: center;
flex-grow: 1">
<p style="width: 100%;">
I LOVE this. Thought FancyZones was good already until komorebi
</p>
</div>
<div style="align-self: flex-end; margin-top: auto;">
<p>
(<a href="https://www.youtube.com/watch?v=H9-_c1egQ4g&lc=Ugw8XhijjfPQdBiLx654AaABAg">View</a>)
</p>
</div>
</div>
<div style="padding: 10px 30px;
word-wrap: break-word;
display: flex;
flex-direction: column;
justify-content: space-between;
min-height: 150px">
<div style="display: flex;
align-items: center;
justify-content: center;
flex-grow: 1">
<p style="width: 100%;">
I really love your work. I switched to windows and hated it but having Komorebi had made me love windows little bit more. Thanks
</p>
</div>
<div style="align-self: flex-end; margin-top: auto;">
<p>
(<a href="https://www.youtube.com/watch?v=MnFtATdRpBE&lc=Ugw2kynvaMFGE9xMo_R4AaABAg">View</a>)
</p>
</div>
</div>
<div style="padding: 10px 30px;
word-wrap: break-word;
display: flex;
flex-direction: column;
justify-content: space-between;
min-height: 150px">
<div style="display: flex;
align-items: center;
justify-content: center;
flex-grow: 1">
<p style="width: 100%;">
Komorebi -- when you have to use Windows but *really* miss Linux. Makes me feel like I'm using a Stardock product from yesteryear. <3 so much.
</p>
</div>
<div style="align-self: flex-end; margin-top: auto;">
<p>
(<a href="https://www.youtube.com/watch?v=n7xZb2KilKY&lc=UgzJkkj_QDeNQR5Q__54AaABAg">View</a>)
</p>
</div>
</div>
<div style="padding: 10px 30px;
word-wrap: break-word;
display: flex;
flex-direction: column;
justify-content: space-between;
min-height: 150px">
<div style="display: flex;
align-items: center;
justify-content: center;
flex-grow: 1">
<p style="width: 100%;">
And people say Windows is bad! Good luck doing all this on a Mac!!?! Really wish I could use this thing!<br /><br />BTW; just saw you reach 2k subscribers; congrats!
</p>
</div>
<div style="align-self: flex-end; margin-top: auto;">
<p>
(<a href="https://www.youtube.com/watch?v=n7xZb2KilKY&lc=UgzRCme9m1OG4iom6oV4AaABAg">View</a>)
</p>
</div>
</div>
<div style="padding: 10px 30px;
word-wrap: break-word;
display: flex;
flex-direction: column;
justify-content: space-between;
min-height: 150px">
<div style="display: flex;
align-items: center;
justify-content: center;
flex-grow: 1">
<p style="width: 100%;">
Thank you so SO much for the font customization options added to the stackbar. I've been using the new version for a couple of days now, running flawlessly on my older PC. Amazing job, dude!
</p>
</div>
<div style="align-self: flex-end; margin-top: auto;">
<p>
(<a href="https://www.youtube.com/watch?v=n7xZb2KilKY&lc=UgxXz3CElirDewSNbTR4AaABAg">View</a>)
</p>
</div>
</div>
<div style="padding: 10px 30px;
word-wrap: break-word;
display: flex;
flex-direction: column;
justify-content: space-between;
min-height: 150px">
<div style="display: flex;
align-items: center;
justify-content: center;
flex-grow: 1">
<p style="width: 100%;">
Just got introduced with komorebi and your work after searching for yabai alternatives on Windows. Oh man, the work is incredible. I'm an artist and know nothing about programming, but it looks quite friendly - especially with your tutorials (thanks for GUI btw). I feel like if you add status bar / polybar feature It'll definitely be a gamechanger. Good luck bro
</p>
</div>
<div style="align-self: flex-end; margin-top: auto;">
<p>
(<a href="https://www.youtube.com/watch?v=n7xZb2KilKY&lc=UgxjmIodbi1pQssLRwp4AaABAg">View</a>)
</p>
</div>
</div>
<div style="padding: 10px 30px;
word-wrap: break-word;
display: flex;
flex-direction: column;
justify-content: space-between;
min-height: 150px">
<div style="display: flex;
align-items: center;
justify-content: center;
flex-grow: 1">
<p style="width: 100%;">
I'm a Chinese-Japanese translator, not a programmer, but I can't imagine how to use a computer without komorebi! Thank you for your excellent work 🤗
</p>
</div>
<div style="align-self: flex-end; margin-top: auto;">
<p>
(<a href="https://www.youtube.com/watch?v=n7xZb2KilKY&lc=Ugxv_zu-Me2LmTB-miV4AaABAg&pp=0gcJCSMANpG00pGi">View</a>)
</p>
</div>
</div>
<div style="padding: 10px 30px;
word-wrap: break-word;
display: flex;
flex-direction: column;
justify-content: space-between;
min-height: 150px">
<div style="display: flex;
align-items: center;
justify-content: center;
flex-grow: 1">
<p style="width: 100%;">
I'm seriously so grateful for all your hard work! Just found out about Komorebi yesterday and I still can't believe Windows can look so beautiful (and it's cool to see how recent this project is)
</p>
</div>
<div style="align-self: flex-end; margin-top: auto;">
<p>
(<a href="https://www.youtube.com/watch?v=n7xZb2KilKY&lc=UgzoEnN3QqiJtH8aRWR4AaABAg&pp=0gcJCSMANpG00pGi">View</a>)
</p>
</div>
</div>
<div style="padding: 10px 30px;
word-wrap: break-word;
display: flex;
flex-direction: column;
justify-content: space-between;
min-height: 150px">
<div style="display: flex;
align-items: center;
justify-content: center;
flex-grow: 1">
<p style="width: 100%;">
This is one of the very best Tiling Managers I've tried on any OS. The developer is incredibly kind and talented. If you're on Windows, this is a must have! Thanks Jeezy!!
</p>
</div>
<div style="align-self: flex-end; margin-top: auto;">
<p>
(<a href="https://www.youtube.com/watch?v=MMZUAtHbTYY&lc=UgyZMja0MAexLVvy4vl4AaABAg">View</a>)
</p>
</div>
</div>
<div style="padding: 10px 30px;
word-wrap: break-word;
display: flex;
flex-direction: column;
justify-content: space-between;
min-height: 150px">
<div style="display: flex;
align-items: center;
justify-content: center;
flex-grow: 1">
<p style="width: 100%;">
Just have to say a big thank you for the Komorebi tiling wm - I run a surface pro 11 with arm cpu and can effortlessly compile and run both komorebi and the whkd. Work flawlessly on my older SQ3 surface too
</p>
</div>
<div style="align-self: flex-end; margin-top: auto;">
<p>
(<a href="https://www.youtube.com/watch?v=MMZUAtHbTYY&lc=UgyjbLpAqo7ZKMNw0JR4AaABAg">View</a>)
</p>
</div>
</div>
<div style="padding: 10px 30px;
word-wrap: break-word;
display: flex;
flex-direction: column;
justify-content: space-between;
min-height: 150px">
<div style="display: flex;
align-items: center;
justify-content: center;
flex-grow: 1">
<p style="width: 100%;">
Windows is actually workable all because of this man.
<br />
<br />Hats off to you bro.
</p>
</div>
<div style="align-self: flex-end; margin-top: auto;">
<p>
(<a href="https://www.youtube.com/watch?v=eH4FSAD3dYs&lc=Ugznvs0C7dAy51xNEEh4AaABAg">View</a>)
</p>
</div>
</div>
<div style="padding: 10px 30px;
word-wrap: break-word;
display: flex;
flex-direction: column;
justify-content: space-between;
min-height: 150px">
<div style="display: flex;
align-items: center;
justify-content: center;
flex-grow: 1">
<p style="width: 100%;">
legendary<br /><br />you've been making my windows workflow bearable since last year<br /><br />thank you for your service
</p>
</div>
<div style="align-self: flex-end; margin-top: auto;">
<p>
(<a href="https://www.youtube.com/watch?v=eH4FSAD3dYs&lc=UgxX_Md5IdzB4DOJIH94AaABAg">View</a>)
</p>
</div>
</div>
<div style="padding: 10px 30px;
word-wrap: break-word;
display: flex;
flex-direction: column;
justify-content: space-between;
min-height: 150px">
<div style="display: flex;
align-items: center;
justify-content: center;
flex-grow: 1">
<p style="width: 100%;">
Thank you so much for Komorebi, I can't imagine my desktop without it now. Using it brings so much joy as well. Looking forward for bar improvements!
</p>
</div>
<div style="align-self: flex-end; margin-top: auto;">
<p>
(<a href="https://www.youtube.com/watch?v=eH4FSAD3dYs&lc=UgxA_1NFtnntbdayFDt4AaABAg">View</a>)
</p>
</div>
</div>
</div>
My First LLM Programming ExperienceTue, 19 Nov 2024 15:51:55 -0800lgug2z
https://lgug2z.com/articles/my-first-llm-programming-experience/
https://lgug2z.com/articles/my-first-llm-programming-experience/<h2 id="the-big-mistake"><a class="zola-anchor" href="#the-big-mistake" aria-label="Anchor link for: the-big-mistake">The big mistake</a></h2>
<p>Using Tailwind CSS for <a href="https://notado.app">Notado</a> is the single biggest
mistake I have ever made in my career as a developer.</p>
<p>I cannot overstate the amount of technical debt that this has introduced, and
how it has compounded over the years since this decision was taken. Never use
this for a one-man SaaS project.</p>
<p>One of the biggest consequences of this mistake was that introducing dark mode
immediately became a non-trivial task, especially when compared to
<a href="https://kulli.sh">Kullish</a>, where I had the good sense to use
<a href="https://bulma.io">Bulma</a>, and adding dark mode was the
simple case of adding a single <code>link</code> element to my existing base HTML template:</p>
<pre data-lang="html" style="background-color:#2b303b;color:#c0c5ce;" class="language-html "><code class="language-html" data-lang="html"><span><</span><span style="color:#bf616a;">link </span><span style="color:#d08770;">href</span><span>="</span><span style="color:#a3be8c;">https://unpkg.com/bulma-prefers-dark</span><span>" </span><span style="color:#d08770;">rel</span><span>="</span><span style="color:#a3be8c;">stylesheet</span><span>" </span><span style="color:#d08770;">type</span><span>="</span><span style="color:#a3be8c;">text/css</span><span>"/>
</span></code></pre>
<h2 id="i-m-not-really-interested-in-llms-to-help-me-write-code"><a class="zola-anchor" href="#i-m-not-really-interested-in-llms-to-help-me-write-code" aria-label="Anchor link for: i-m-not-really-interested-in-llms-to-help-me-write-code">I'm not really interested in LLMs to help me write code</a></h2>
<p>I personally have not had much use for LLMs while writing code, and I
definitely don't want LLM-powered code suggestions integrated into my text
editor. This would remove a significant part of the "fun" of programming for
me.</p>
<p>Nevertheless, I kept hearing that LLMs were great for "boring" and tedious
programming tasks, and since Tailwind still seems to be recommended everywhere
alongside React, I was pretty confident that most LLMs would have a good
understanding of the library.</p>
<h2 id="using-an-llm-for-the-tedious-task-of-implementing-dark-mode-with-tailwind"><a class="zola-anchor" href="#using-an-llm-for-the-tedious-task-of-implementing-dark-mode-with-tailwind" aria-label="Anchor link for: using-an-llm-for-the-tedious-task-of-implementing-dark-mode-with-tailwind">Using an LLM for the tedious task of implementing dark mode with Tailwind</a></h2>
<p>Despite having acknowledged and accepted that using Tailwind was a mistake, I
have no interest in redesigning Notado to remove it.</p>
<p>Notado is a finished product of which I am now primarily an end user. I rarely
return to the codebase, and when I do, it's to fix a broken integration test or
to add support for a new micro-blogging platform.</p>
<p>Given these facts, I thought it would be an interesting exercise to give an LLM
my <code>main.css</code> file and ask it to come up with a dark theme which would be
applied automatically depending on a user's system settings.</p>
<p>I went with <a href="https://claude.ai">Claude.ai</a>, for no particular reason besides
hearing people talk positively about it on the internet.</p>
<h2 id="trial-and-error"><a class="zola-anchor" href="#trial-and-error" aria-label="Anchor link for: trial-and-error">Trial and error</a></h2>
<p>The conversation between Claude and I:</p>
<h3 id="me"><a class="zola-anchor" href="#me" aria-label="Anchor link for: me">Me</a></h3>
<blockquote>
<p>In a project I am using <code>"tailwindcss": "^3.1.8"</code></p>
<p>This is my <code>main.css</code> file</p>
<p><code>main.css code snippet</code></p>
<p>This is a bright color scheme.</p>
<p>I would like for you to propose a dark color scheme which can be
automatically enabled if a user has a preference for dark mode set on their
system.</p>
</blockquote>
<h3 id="claude"><a class="zola-anchor" href="#claude" aria-label="Anchor link for: claude">Claude</a></h3>
<blockquote>
<p>I'll help you create a dark mode version of your color scheme using
Tailwind's dark mode feature. By default, Tailwind uses the
<code>prefers-color-scheme</code> media query, so it will automatically switch based on
system preferences.</p>
<p><code>updated main.css code snippet</code></p>
<p>Key changes I've made for the dark theme:</p>
<ul>
<li>Background colors:
<ul>
<li>Changed light grays to darker variants (gray-800, gray-700)</li>
<li>Adjusted border colors to be visible on dark backgrounds</li>
</ul>
</li>
<li>Text colors:
<ul>
<li>Changed dark text to light variants</li>
<li>Adjusted blue links to be more visible (blue-400)</li>
<li>Made secondary text slightly lighter (gray-400)</li>
</ul>
</li>
<li>Interactive elements:
<ul>
<li>Modified hover and focus states to be visible on dark backgrounds</li>
<li>Adjusted shadow colors for dark mode</li>
<li>Made sure form elements (inputs, selects) have proper dark backgrounds</li>
</ul>
</li>
<li>Contrast considerations:
<ul>
<li>Ensured text remains readable on dark backgrounds</li>
<li>Maintained proper contrast ratios for accessibility</li>
<li>Kept interactive elements clearly distinguishable</li>
</ul>
</li>
</ul>
<p>To enable this, make sure your tailwind.config.js has dark mode enabled. It
should have <code>darkMode: 'media'</code>.</p>
<p>Would you like me to adjust any of the color combinations or make any other
changes to the dark theme?</p>
</blockquote>
<h3 id="me-1"><a class="zola-anchor" href="#me-1" aria-label="Anchor link for: me-1">Me</a></h3>
<blockquote>
<p>This is a good start, however the background color is still white and a lot
of the text inside divs and paragraphs is still black.</p>
</blockquote>
<h3 id="claude-1"><a class="zola-anchor" href="#claude-1" aria-label="Anchor link for: claude-1">Claude</a></h3>
<blockquote>
<p>Ah yes, let's add base background and text colors for the dark theme by
setting default dark mode styles for the body and common elements. Here's the
updated CSS:</p>
<p><code>updated main.css code snippet</code></p>
<p>Key changes made:</p>
<ol>
<li>Added base styles for <code>body</code> element with dark background and text colors</li>
<li>Added default dark mode text colors for common elements (<code>p</code>, <code>div</code>, <code>span</code>)</li>
<li>Updated background colors for form elements and containers to use <code>gray-800</code></li>
<li>Ensured all text elements have appropriate dark mode colors</li>
<li>Added dark backgrounds to elements that previously inherited the white background</li>
</ol>
<p>The color scheme now uses:</p>
<ul>
<li><code>gray-900</code> as the main background color</li>
<li><code>gray-800</code> for elevated elements (inputs, cards, etc.)</li>
<li><code>gray-100</code> for primary text</li>
<li><code>gray-200</code>/<code>gray-300</code> for secondary text</li>
<li><code>gray-700</code> for borders</li>
</ul>
<p>All interactive elements should now be clearly visible and accessible in dark
mode. Would you like me to adjust any of these colors or make other changes?</p>
</blockquote>
<h3 id="me-2"><a class="zola-anchor" href="#me-2" aria-label="Anchor link for: me-2">Me</a></h3>
<blockquote>
<p>This is looking even better, however there are still a few elements which
have white backgrounds. I'll provide some examples of them taken directly
from the rendered HTML.</p>
</blockquote>
<pre data-lang="html" style="background-color:#2b303b;color:#c0c5ce;" class="language-html "><code class="language-html" data-lang="html"><span><</span><span style="color:#bf616a;">div </span><span style="color:#d08770;">class</span><span>="</span><span style="color:#a3be8c;">flex</span><span>">
</span><span> <</span><span style="color:#bf616a;">a </span><span style="color:#d08770;">class</span><span>="</span><span style="color:#a3be8c;">bg-white py-2 px-4 border border-gray-300 shadow-md text-sm font-medium text-gray-700 focus:border-gray-600 hover:border-gray-600</span><span>" </span><span style="color:#d08770;">disabled</span><span>="">
</span><span> Previous
</span><span> </</span><span style="color:#bf616a;">a</span><span>>
</span><span></</span><span style="color:#bf616a;">div</span><span>>
</span></code></pre>
<pre data-lang="html" style="background-color:#2b303b;color:#c0c5ce;" class="language-html "><code class="language-html" data-lang="html"><span><</span><span style="color:#bf616a;">a </span><span style="color:#d08770;">href</span><span>="</span><span style="color:#a3be8c;">/library?title=Mohammed%20El-Kurd%20%28%40m7mdkurd%29</span><span style="color:#8fa1b3;">&</span><span style="color:#d08770;">amp;</span><span style="color:#a3be8c;">sort=latest</span><span style="color:#8fa1b3;">&</span><span style="color:#d08770;">amp;</span><span style="color:#a3be8c;">count=any</span><span>" </span><span style="color:#8fa1b3;">id</span><span>="</span><span style="color:#a3be8c;">library-title</span><span>">
</span><span> <</span><span style="color:#bf616a;">div </span><span style="color:#d08770;">class</span><span>="</span><span style="color:#a3be8c;">bg-notado-blue text-white px-4 py-3 sm:px-6 sm:w-full text-sm font-medium border-b</span><span>">
</span><span> Mohammed El-Kurd (@m7mdkurd)
</span><span> </</span><span style="color:#bf616a;">div</span><span>>
</span><span></</span><span style="color:#bf616a;">a</span><span>>
</span></code></pre>
<pre data-lang="html" style="background-color:#2b303b;color:#c0c5ce;" class="language-html "><code class="language-html" data-lang="html"><span><</span><span style="color:#bf616a;">div </span><span style="color:#d08770;">class</span><span>="</span><span style="color:#a3be8c;">mt-1 flex shadow-sm gap-2</span><span>">
</span><span> <</span><span style="color:#bf616a;">span </span><span style="color:#d08770;">class</span><span>="</span><span style="color:#a3be8c;">inline-flex items-center px-3 border border-gray-300 bg-gray-50 sm:text-sm</span><span>">
</span><span> IF
</span><span> </</span><span style="color:#bf616a;">span</span><span>>
</span><span> <</span><span style="color:#bf616a;">select </span><span style="color:#d08770;">class</span><span>="</span><span style="color:#a3be8c;">tselect w-full</span><span>" </span><span style="color:#d08770;">name</span><span>="</span><span style="color:#a3be8c;">field</span><span>" </span><span style="color:#d08770;">onmouseup</span><span>="</span><span style="color:#bf616a;">generateTaggingRule</span><span>()">
</span><span> <</span><span style="color:#bf616a;">option </span><span style="color:#d08770;">value</span><span>="</span><span style="color:#a3be8c;">content</span><span>">
</span><span> content
</span><span> </</span><span style="color:#bf616a;">option</span><span>>
</span><span> <</span><span style="color:#bf616a;">option </span><span style="color:#d08770;">value</span><span>="</span><span style="color:#a3be8c;">url</span><span>">
</span><span> url
</span><span> </</span><span style="color:#bf616a;">option</span><span>>
</span><span> <</span><span style="color:#bf616a;">option </span><span style="color:#d08770;">value</span><span>="</span><span style="color:#a3be8c;">title</span><span>">
</span><span> title
</span><span> </</span><span style="color:#bf616a;">option</span><span>>
</span><span> </</span><span style="color:#bf616a;">select</span><span>>
</span><span></</span><span style="color:#bf616a;">div</span><span>>
</span></code></pre>
<pre data-lang="html" style="background-color:#2b303b;color:#c0c5ce;" class="language-html "><code class="language-html" data-lang="html"><span><</span><span style="color:#bf616a;">a </span><span style="color:#d08770;">href</span><span>="</span><span style="color:#a3be8c;">/feeds/jado/capitalism</span><span>" </span><span style="color:#8fa1b3;">id</span><span>="</span><span style="color:#a3be8c;">library-title</span><span>">
</span><span> <</span><span style="color:#bf616a;">div </span><span style="color:#d08770;">class</span><span>="</span><span style="color:#a3be8c;">group bg-white px-4 py-3 sm:px-6 sm:w-full text-sm font-medium focus:bg-notado-blue hover:bg-notado-blue hover:text-white focus:text-white border-b</span><span>">
</span><span> <</span><span style="color:#bf616a;">h3 </span><span style="color:#d08770;">class</span><span>="</span><span style="color:#a3be8c;">group-hover:text-white group-focus:text-white</span><span>">
</span><span> Capitalism
</span><span> </</span><span style="color:#bf616a;">h3</span><span>>
</span><span> <</span><span style="color:#bf616a;">p </span><span style="color:#d08770;">class</span><span>="</span><span style="color:#a3be8c;">text-gray-500 group-hover:text-white group-focus:text-white</span><span>">
</span><span> Thoughts on how capitalism intersects with work, art, technology, urban
</span><span> planning, life and love from around the internet
</span><span> </</span><span style="color:#bf616a;">p</span><span>>
</span><span> </</span><span style="color:#bf616a;">div</span><span>>
</span><span></</span><span style="color:#bf616a;">a</span><span>>
</span></code></pre>
<blockquote>
<p>The <code><span></code> in this next one has a white background:</p>
</blockquote>
<pre data-lang="html" style="background-color:#2b303b;color:#c0c5ce;" class="language-html "><code class="language-html" data-lang="html"><span><</span><span style="color:#bf616a;">div </span><span style="color:#d08770;">class</span><span>="</span><span style="color:#a3be8c;">mt-1 flex shadow-sm gap-2</span><span>">
</span><span> <</span><span style="color:#bf616a;">span </span><span style="color:#d08770;">class</span><span>="</span><span style="color:#a3be8c;">inline-flex items-center px-3 border border-gray-300 bg-gray-50 sm:text-sm</span><span>">
</span><span> IF
</span><span> </</span><span style="color:#bf616a;">span</span><span>>
</span><span> <</span><span style="color:#bf616a;">select </span><span style="color:#d08770;">class</span><span>="</span><span style="color:#a3be8c;">tselect w-full</span><span>" </span><span style="color:#d08770;">name</span><span>="</span><span style="color:#a3be8c;">field</span><span>" </span><span style="color:#d08770;">onmouseup</span><span>="</span><span style="color:#bf616a;">generateTaggingRule</span><span>()">
</span><span> <</span><span style="color:#bf616a;">option </span><span style="color:#d08770;">value</span><span>="</span><span style="color:#a3be8c;">content</span><span>">
</span><span> content
</span><span> </</span><span style="color:#bf616a;">option</span><span>>
</span><span> <</span><span style="color:#bf616a;">option </span><span style="color:#d08770;">value</span><span>="</span><span style="color:#a3be8c;">url</span><span>">
</span><span> url
</span><span> </</span><span style="color:#bf616a;">option</span><span>>
</span><span> <</span><span style="color:#bf616a;">option </span><span style="color:#d08770;">value</span><span>="</span><span style="color:#a3be8c;">title</span><span>">
</span><span> title
</span><span> </</span><span style="color:#bf616a;">option</span><span>>
</span><span> </</span><span style="color:#bf616a;">select</span><span>>
</span><span></</span><span style="color:#bf616a;">div</span><span>>
</span></code></pre>
<blockquote>
<p>In addition, the borders on some divs are pure white, but I would like for
them to also have the same look as these borders in the compiled output in
dark mode:</p>
</blockquote>
<pre data-lang="css" style="background-color:#2b303b;color:#c0c5ce;" class="language-css "><code class="language-css" data-lang="css"><span style="color:#b48ead;">@media </span><span>(prefers-color-scheme: dark) {
</span><span> </span><span style="color:#8fa1b3;">#navigation</span><span style="color:#b48ead;">> </span><span style="color:#8fa1b3;">:</span><span style="color:#b48ead;">not(</span><span style="color:#8fa1b3;">[</span><span style="color:#d08770;">hidden</span><span style="color:#8fa1b3;">]</span><span style="color:#b48ead;">)~ </span><span style="color:#8fa1b3;">:</span><span style="color:#b48ead;">not(</span><span style="color:#8fa1b3;">[</span><span style="color:#d08770;">hidden</span><span style="color:#8fa1b3;">]</span><span style="color:#b48ead;">) </span><span>{
</span><span> --tw-divide-opacity: </span><span style="color:#d08770;">1</span><span>;
</span><span> border-color: </span><span style="color:#96b5b4;">rgb</span><span>(</span><span style="color:#d08770;">55 65 81</span><span> / var(--tw-divide-opacity));
</span><span> }
</span><span>
</span><span> </span><span style="color:#8fa1b3;">#navigation </span><span>{
</span><span> --tw-border-opacity: </span><span style="color:#d08770;">1</span><span>;
</span><span> border-color: </span><span style="color:#96b5b4;">rgb</span><span>(</span><span style="color:#d08770;">55 65 81</span><span> / var(--tw-border-opacity));
</span><span> }
</span><span>}
</span></code></pre>
<h3 id="claude-2"><a class="zola-anchor" href="#claude-2" aria-label="Anchor link for: claude-2">Claude</a></h3>
<blockquote>
<p>I'll update the CSS to handle these specific cases. The issue is that some
elements have explicit <code>bg-white</code> classes that need to be overridden, and we
need to handle inline border styles.</p>
<p><code>updated main.css code snippet</code></p>
<p>Key changes made:</p>
<ol>
<li>Added a direct override for the <code>bg-white</code> class in dark mode</li>
<li>Added specific handling for elements with inline border styles</li>
<li>Updated the library title elements using an ID selector</li>
<li>Added specific selectors for any element with a <code>bg-white</code> class</li>
<li>Enhanced group hover/focus states for text colors</li>
<li>Made sure navigation links have consistent border colors even with inline styles</li>
<li>Added more specific handling for footer elements</li>
</ol>
<p>The changes ensure:</p>
<ul>
<li>All white backgrounds transition to <code>gray-800</code> in dark mode</li>
<li>All borders are consistently <code>gray-700</code> in dark mode</li>
<li>Text colors properly transition on hover/focus states</li>
<li>Inline styles are properly overridden in dark mode</li>
</ul>
<p>Would you like me to make any adjustments to these styles?</p>
</blockquote>
<h3 id="me-3"><a class="zola-anchor" href="#me-3" aria-label="Anchor link for: me-3">Me</a></h3>
<blockquote>
<p>When I visit the site on iPhone in dark mode, the top notch still shows
white.</p>
<p>How can I make it match the dark mode colors?</p>
</blockquote>
<h3 id="claude-3"><a class="zola-anchor" href="#claude-3" aria-label="Anchor link for: claude-3">Claude</a></h3>
<blockquote>
<p>To handle the iPhone notch (and other similar areas) in dark mode, you'll
need to add a meta tag to your HTML and set the correct color scheme. Here's
what you need to add to your HTML <code><head></code> section:</p>
</blockquote>
<pre data-lang="html" style="background-color:#2b303b;color:#c0c5ce;" class="language-html "><code class="language-html" data-lang="html"><span><</span><span style="color:#bf616a;">meta </span><span style="color:#d08770;">content</span><span>="</span><span style="color:#a3be8c;">#111827</span><span>" </span><span style="color:#d08770;">media</span><span>="</span><span style="color:#a3be8c;">(prefers-color-scheme: dark)</span><span>" </span><span style="color:#d08770;">name</span><span>="</span><span style="color:#a3be8c;">theme-color</span><span>"/>
</span><span><</span><span style="color:#bf616a;">meta </span><span style="color:#d08770;">content</span><span>="</span><span style="color:#a3be8c;">#ffffff</span><span>" </span><span style="color:#d08770;">media</span><span>="</span><span style="color:#a3be8c;">(prefers-color-scheme: light)</span><span>" </span><span style="color:#d08770;">name</span><span>="</span><span style="color:#a3be8c;">theme-color</span><span>"/>
</span></code></pre>
<blockquote>
<p>The #111827 color value corresponds to Tailwind's gray-900 which we're using
for our dark mode background. If you're using a different color for your dark
mode background, you should adjust this hex value accordingly.</p>
<p>This will make the iPhone notch area match your site's theme in both light
and dark modes. The browser will automatically switch between these colors
based on the user's system preferences.</p>
</blockquote>
<h2 id="end-result"><a class="zola-anchor" href="#end-result" aria-label="Anchor link for: end-result">End result</a></h2>
<p>I am happy with the end result on both desktop and mobile.</p>
<figure class="left">
<img src="images/notado.png" style="max-height:1000px">
<figcaption class="center">A side by side comparison of the dark and light themes</figcaption>
</figure>
<h2 id="the-experience"><a class="zola-anchor" href="#the-experience" aria-label="Anchor link for: the-experience">The experience</a></h2>
<p>I was pleasantly surprised by my experience iterating on this problem with
Claude. Perhaps others with more experience working with LLMs might have tips
or approaches that are more efficient than mine, but I found this basic flow to
work well:</p>
<ul>
<li>Define the task and the constraints within which the task must be completed
<ul>
<li>Specify the versions of dependencies you are working with (exact Tailwind version)</li>
<li>Specify any behavior you are expecting (automatically apply based on system settings)</li>
</ul>
</li>
<li>Share the <em>relevant</em> context (there probably isn't a need to dump the entire
<code>src</code> dir of a project)</li>
<li>Iterate first by describing any edge cases that need addressing</li>
<li>If description is not enough, augment description with reference output code artifacts</li>
</ul>
<p>I don't see myself using this approach very often (except whenever I next have
to touch Tailwind again), but I think I can vouch for its ability to help when
faced with boring, tedious programming tasks.</p>
<h2 id="fin"><a class="zola-anchor" href="#fin" aria-label="Anchor link for: fin">Fin</a></h2>
<p>If you have any questions or comments you can reach out to me on
<a href="https://hachyderm.io/@LGUG2Z">Mastodon</a>,
<a href="https://bsky.app/profile/lgug2z.com">Bluesky</a> or
<a href="https://twitter.com/JeezyCodes">Twitter</a>.</p>
<p>If you're interested in what I read to come up with software like Notado, you
can subscribe to my <a href="https://notado.app/feeds/jado/software-development">Software Development RSS
feed</a>.</p>
<p>If you'd like to watch me writing code while explaining what I'm doing, you can
also <a href="https://www.youtube.com/channel/UCeai3-do-9O4MNy9_xjO6mg?sub_confirmation=1">subscribe to my YouTube
channel</a>.</p>
<p>If you found this content valuable, or if you are a happy user of the
<a href="https://github.com/LGUG2Z/komorebi"><code>komorebi</code></a> tiling window manager or my
<a href="https://github.com/search?q=owner%3ALGUG2Z+nixos&type=repositories">NixOS starter
templates</a>,
please consider sponsoring me on <a href="https://github.com/sponsors/LGUG2Z">GitHub</a>
or tipping me on <a href="https://ko-fi.com/lgug2z">Ko-fi</a>.</p>
Best of Hacker News CommentsFri, 21 Jun 2024 20:36:22 -0700lgug2z
https://lgug2z.com/articles/best-of-hacker-news-comments/
https://lgug2z.com/articles/best-of-hacker-news-comments/<p>Earlier this week I saw an Ask HN post titled <a href="https://news.ycombinator.com/item?id=40719253">"What are some iconic comments
on HN?"</a></p>
<p>I have been saving comments from Hacker News on a variety of subjects and topics
since 2020 when I finally ditched traditional URL-focused bookmarking services
and created <a href="https://notado.app">Notado</a> to be able to save, archive and
organize comments on discussion websites quickly and easily.</p>
<p>Returning readers will already know that I publish a number of <a href="https://notado.app/feeds/jado">topic-specific
RSS feeds</a> from Notado, and my latest saved
highlights and comments on those feeds are always available on the
<a href="https://lgug2z.com">homepage</a>.</p>
<p>At the time of writing this post, I have over 700 comments from Hacker News
saved in my Notado account.</p>
<p>Now is as good a time as ever to publish an RSS feed to share my personal
collection of saved <a href="https://notado.app/feeds/jado/hacker-news-comments">Hacker News
Comments</a> with others.</p>
<p><a href="https://notado.app/feeds/jado/hacker-news-comments">This feed</a> is
automatically updated with every comment I save from Hacker News; comments I
agree with, comments I disagree with, comments that make me think, comments
that make me laugh, comments that make me feel nostalgic, and all the comments
in between.</p>
<p>This is achieved by creating an automatic tagging rule of <code>source:hn</code> for
anything saved from <code>news.ycombinator.com</code>, and then creating a Notado Feed
which which is automatically populated using the <code>source:hn</code> tag.</p>
<pre data-lang="txt" style="background-color:#2b303b;color:#c0c5ce;" class="language-txt "><code class="language-txt" data-lang="txt"><span>IF url CONTAINS "news.ycombinator.com" TAG "source:hn"
</span></code></pre>
<p>You can subscribe to this feed with any good old fashioned RSS feed reader to
receive updates whenever I save a new HN comment.</p>
<div class="notado-quote"
style="border: 1px solid var(--border-color);
background-color: var(--bg-primary) !important;
position: relative;
margin-block: 1em;
border-radius: 5px;
padding-left: 1rem;
padding-right: 1rem;
padding-top: 1.25rem;
padding-bottom: 1.25rem;
margin-bottom: 0em;
border-bottom-right-radius: 0px !important;
border-bottom-left-radius: 0px !important;
">
<div style="padding-bottom: 1.25rem">
<div style="display: flex; gap: 0.75rem">
<div style="min-width: 0;
flex: 1 1 0%;
display: flex;
flex-direction: column;
justify-content: center">
<p style="text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
margin: 0em">
clavicat
</p>
<p style="color: var(--text-1); margin: 0em">
<a href="https://news.ycombinator.com/item?id=29567808">news.ycombinator.com</a>
</p>
</div>
<div style="flex-shrink: 0;
display: flex;
flex-direction: row-reverse;
align-items: center">
<a class="notado-icon" href="https://notado.app" style="border: none">
<img style="height: 3rem;
width: 3rem"
src="https://notado.app/static/notado-icon.png"
alt="notado" />
</a>
</div>
</div>
</div>
<div style="white-space: pre-line">We are barbarians occupying a city built by an advanced civilization, marveling at the hot baths but know nothing about how their builders keep them running. One day, the baths will drain and anyone who remembers how to fill them up will have died.</div>
</div>
<div class="notado-quote-caption"
style="border: 1px solid var(--border-color);
background-color: var(--bg-2) !important;
position: relative;
margin-bottom: 1em;
border-bottom-left-radius: 5px;
border-bottom-right-radius: 5px;
padding-left: 1rem;
padding-right: 1rem;
padding-top: 0.5rem;
padding-bottom: 0.5rem">
An old comment that I still think about
</div>
<p>If you have any questions or comments you can reach out to me on
<a href="https://hachyderm.io/@LGUG2Z">Mastodon</a>.</p>
<p>If you're interested in what I read to come up with software like Notado, you
can subscribe to my <a href="https://notado.app/feeds/jado/software-development">Software Development RSS
feed</a>.</p>
<p>If you'd like to watch me writing code while explaining what I'm doing, you can
also <a href="https://www.youtube.com/channel/UCeai3-do-9O4MNy9_xjO6mg?sub_confirmation=1">subscribe to my YouTube
channel</a>.</p>
<p>If you found this content valuable, or if you are a happy user of the
<a href="https://github.com/LGUG2Z/komorebi"><code>komorebi</code></a> tiling window manager or my
<a href="https://github.com/search?q=owner%3ALGUG2Z+nixos&type=repositories">NixOS starter
templates</a>,
please consider sponsoring me on <a href="https://github.com/sponsors/LGUG2Z">GitHub</a>
or tipping me on <a href="https://ko-fi.com/lgug2z">Ko-fi</a>.</p>
GitHub Sponsorship Breakdown for 2023Mon, 08 Apr 2024 17:28:07 -0700lgug2z
https://lgug2z.com/articles/github-sponsorship-breakdown-for-2023/
https://lgug2z.com/articles/github-sponsorship-breakdown-for-2023/<blockquote>
<p>Certainly, no contributors get into projects with the sole purpose to get a
financial gain out of them. Open source has never been about money either.
But for you as an author, the lack of funds to sustain your ideas and pay for
even a small portion of the time you're spending on them is—I'm not going to
lie—devastating. It may not be your concern at first but it will inevitably
become one when your ideas gain popularity, demanding significantly more time
than there are hours in a day.</p>
<p><a href="https://kettanaito.com/blog/the-dark-side-of-open-source">Source</a></p>
</blockquote>
<p>In 2023 I received $593.02 in sponsorships via GitHub Sponsors for my work on
<a href="https://github.com/LGUG2Z/komorebi"><code>komorebi</code></a>, the tiling window manager for
Windows, which has continued to grow beyond my wildest dreams; it currently has
<a href="https://github.com/LGUG2Z/komorebi">6.5k stars on GitHub</a>, 57k downloads and
almost 1000 members in <a href="https://discord.gg/mGkn66PHkx">the community Discord</a>.</p>
<p><img src="https://user-images.githubusercontent.com/13164844/184027064-f5a6cec2-2865-4d65-a549-a1f1da589abf.png" alt="komorebi" /></p>
<p>Below is the monthly breakdown of the sponsorships received last year:</p>
<table><thead><tr><th>Month</th><th>Payout</th></tr></thead><tbody>
<tr><td>January</td><td>$27.61</td></tr>
<tr><td>February</td><td>$26.77</td></tr>
<tr><td>March</td><td>$35.71</td></tr>
<tr><td>April</td><td>$81.00</td></tr>
<tr><td>May</td><td>$46.00</td></tr>
<tr><td>June</td><td>$32.00</td></tr>
<tr><td>July</td><td>$31.00</td></tr>
<tr><td>August</td><td>$33.32</td></tr>
<tr><td>September</td><td>$52.83</td></tr>
<tr><td>October</td><td>$66.37</td></tr>
<tr><td>November</td><td>$82.00</td></tr>
<tr><td>December</td><td>$78.41</td></tr>
</tbody></table>
<p>I was laid off in November (and remained unemployed until late February 2024),
which saw an increased number of both one-time sponsorships and monthly
sponsorships.</p>
<p>In 2023 I also started posting <a href="https://youtube.com/@LGUG2Z">live development videos on
YouTube</a>, inspired by <a href="https://www.youtube.com/@awesomekling">Andreas
Kling</a>'s videos developing SerenityOS.</p>
<p>Although my YouTube channel remained unmonetized in 2023, I was able to grow
the channel to 1.5k subscribers, and in February 2024, with over 4000 hours of
watch time, the channel reached "full monetization" status.</p>
<p>It's unlikely that I'll hit the $100 minimum payout threshold on the channel in
2024, but technically, the work I put into the YouTube channel in 2023 has
opened the possibility for users to financially support the project in a
passive way by subscribing to the channel and watching videos.</p>
<p>I initially thought that the channel would mainly attract viewers who wanted to
learn more about writing and maintaining a large, complex piece of software in
Rust.</p>
<p>It turns out that many people just love to tune in for the
"<a href="https://www.youtube.com/watch?v=dIwpTK6fyLk&lc=UgxD-A_WVP0GrDRjwnZ4AaABAg">chill</a>
<a href="https://www.youtube.com/watch?v=Bv8ZUchoOng&lc=UgwIz3uiBA_njcuwjpp4AaABAg">vibes</a>".</p>
<p>There are a number of high profile success stories of people who have been able
to make a living from voluntary contributions to their open source work, and if
this is a path that you, dear reader, would like to pursue, I would urge you to
first reflect on Bo Burnham's advice:</p>
<blockquote>
<p>I would say don't take advice from people like me who have gotten very
lucky. We're very biased. You know, like Taylor Swift telling you to follow
your dreams is like a lottery winner saying, 'Liquidize your assets; buy
Powerball tickets - it works!'</p>
<p><a href="https://www.youtube.com/watch?v=q-JgG0ECp2U">Source</a></p>
</blockquote>
<p>Tiling window managers in general are very "sticky" pieces of software, and
<code>komorebi</code> is no exception. Once you start using it and it <em>clicks</em>, it becomes
an essential part of how you interact with computers.</p>
<p>Much like <a href="https://notado.app">notado.app</a> and <a href="https://kulli.sh">kullish</a>, I
will continue to develop <code>komorebi</code> regardless of the amount of financial
sponsorship the project receives simply because <em>I too</em> am stuck in its
stickiness! It is such an essential part of how I interact with my computer
that I will always be compelled to develop it indefinitely.</p>
<p>I am so grateful to every single person who has ever financially sponsored
<code>komorebi</code>. I wish I had more to give back to you for your support than just
shout outs at the end of my YouTube videos.</p>
<hr />
<p>If you have any questions or comments you can reach out to me on
<a href="https://twitter.com/JeezyCodes">Twitter</a> and
<a href="https://hachyderm.io/@LGUG2Z">Mastodon</a>.</p>
<p>If you're interested in what I read to come up with software like komorebi, you
can subscribe to my <a href="https://notado.app/feeds/jado/software-development">Software Development RSS
feed</a>.</p>
<p>If you'd like to watch me writing code while explaining what I'm doing, you can
also <a href="https://www.youtube.com/channel/UCeai3-do-9O4MNy9_xjO6mg?sub_confirmation=1">subscribe to my YouTube
channel</a>.</p>
<p>If you found this content valuable, or if you are a happy user of the
<a href="https://github.com/LGUG2Z/komorebi"><code>komorebi</code></a> tiling window manager or my
<a href="https://github.com/search?q=owner%3ALGUG2Z+nixos&type=repositories">NixOS starter
templates</a>,
please consider sponsoring me on <a href="https://github.com/sponsors/LGUG2Z">GitHub</a>
or tipping me on <a href="https://ko-fi.com/lgug2z">Ko-fi</a>.</p>
Selectively Using Service Modules from NixOS UnstableTue, 12 Mar 2024 21:13:34 -0700lgug2z
https://lgug2z.com/articles/selectivey-using-service-modules-from-nixos-unstable/
https://lgug2z.com/articles/selectivey-using-service-modules-from-nixos-unstable/<p>A few weeks ago I ran <code>nix flake update</code> to get the latest versions of CLI
tools that I regularly use from <code>nixos-unstable</code>.</p>
<p><a href="https://atuin.sh"><code>atuin</code></a> is one of those tools which I started using
relatively recently and quickly became a huge fan of.</p>
<p>I run it on all of my machines, and I can't overstate how amazing it is to have
all of my shell history across all of my machines synced. I also self-host the
<code>atuin</code> server, because why not?</p>
<p>While I typically install my CLI tools from <code>nixos-unstable</code>, with services
that I self-host I tend to be a little more conservative and run them from the
latest stable release, which is currenly <code>nixos-23.11</code>.</p>
<p>The <code>atuin</code> CLI tool had some database migrations as part of the <code>v18</code> release
that made it incompatible with servers running <code>v17</code>, which is what my
self-hosted <code>atuin</code> server is running from <code>nixos-23.11</code>.</p>
<p>Usually this would be a non-issue since most service modules in Nixpkgs have a
<code>package</code> option that lets you override the package used for a service.
However, unfortunately for the <code>atuin</code> service on <code>nixos-23.11</code> <a href="https://github.com/NixOS/nixpkgs/blob/nixos-23.11/nixos/modules/services/misc/atuin.nix">this is not
the
case</a>
(though a <code>package</code> override <a href="https://github.com/NixOS/nixpkgs/blob/3030f185ba6a4bf4f18b87f345f104e6a6961f34/nixos/modules/services/misc/atuin.nix#L11">has now been
added</a>
in <code>nixos-unstable</code>!)</p>
<p>As long as you have <code>nixos-unstable</code> as an input on your flake (hint: all my
<a href="https://github.com/search?q=owner%3ALGUG2Z+nixos&type=repositories">NixOS starter
templates</a>
have this!) it's possible for you to grab a single service module from
<code>nixos-unstable</code> while keeping the rest of your services running against
<code>nixos-23.11</code> (or whatever the latest stable release is when you're reading
this).</p>
<pre data-lang="nix" style="background-color:#2b303b;color:#c0c5ce;" class="language-nix "><code class="language-nix" data-lang="nix"><span>{
</span><span> pkgs,
</span><span> inputs,
</span><span> ...
</span><span style="color:#8fa1b3;">}</span><span>: {
</span><span> </span><span style="color:#65737e;"># first we need to disable the service module coming from nixpkgs
</span><span> </span><span style="color:#65737e;"># the path we give here is relative to the nixos/modules/ dir
</span><span> </span><span style="color:#d08770;">disabledModules </span><span>= [
</span><span> "</span><span style="color:#a3be8c;">services/misc/atuin.nix</span><span>"
</span><span> ];
</span><span>
</span><span> </span><span style="color:#65737e;"># then we add an import of the same module from nixpkgs-unstable
</span><span> </span><span style="color:#d08770;">imports </span><span>= [
</span><span> "</span><span style="font-style:italic;color:#ab7967;">${</span><span style="font-style:italic;color:#bf616a;">inputs</span><span style="font-style:italic;color:#c0c5ce;">.</span><span style="font-style:italic;color:#bf616a;">nixpkgs-unstable</span><span style="font-style:italic;color:#ab7967;">}</span><span style="color:#a3be8c;">/nixos/modules/services/misc/atuin.nix</span><span>"
</span><span> ];
</span><span>
</span><span> </span><span style="color:#65737e;"># now this service definition block refers to the module as defined in
</span><span> </span><span style="color:#65737e;"># inputs.nixos-unstable! pretty cool!
</span><span> </span><span style="color:#d08770;">services</span><span>.</span><span style="color:#d08770;">atuin </span><span>= {
</span><span> </span><span style="color:#d08770;">enable </span><span>= </span><span style="color:#d08770;">true</span><span>;
</span><span> </span><span style="color:#d08770;">package </span><span>= </span><span style="color:#bf616a;">pkgs</span><span>.</span><span style="color:#bf616a;">unstable</span><span>.</span><span style="color:#bf616a;">atuin</span><span>;
</span><span> </span><span style="color:#d08770;">openRegistration </span><span>= </span><span style="color:#d08770;">false</span><span>;
</span><span> };
</span><span>}
</span></code></pre>
<p>If you're interested in what I read to come up with solutions like this one,
you can subscribe to my <a href="https://notado.app/feeds/jado/software-development">Software Development RSS
feed</a>.</p>
<p>If you'd like to watch me writing code while explaining what I'm doing, you can
also <a href="https://www.youtube.com/channel/UCeai3-do-9O4MNy9_xjO6mg?sub_confirmation=1">subscribe to my YouTube
channel</a>.</p>
<p>If you found this content valuable, or if you are a happy user of the
<a href="https://github.com/LGUG2Z/komorebi"><code>komorebi</code></a> tiling window manager or my
<a href="https://github.com/search?q=owner%3ALGUG2Z+nixos&type=repositories">NixOS starter
templates</a>,
please consider sponsoring me on <a href="https://github.com/sponsors/LGUG2Z">GitHub</a>
or tipping me on <a href="https://ko-fi.com/lgug2z">Ko-fi</a>.</p>
Building and Privately Caching x86 and aarch64 NixOS Systems with Github ActionsWed, 17 Jan 2024 10:30:40 -0800lgug2z
https://lgug2z.com/articles/building-and-privately-caching-x86-and-aarch64-nixos-systems-on-github-actions/
https://lgug2z.com/articles/building-and-privately-caching-x86-and-aarch64-nixos-systems-on-github-actions/<p>In the <a href="https://lgug2z.com/articles/deploying-a-cloudflare-r2-backed-nix-binary-cache-attic-on-fly-io/">previous
article</a>
we walked through how to set up our very own <a href="https://github.com/zhaofengli/attic/">Nix binary
cache</a>.</p>
<p>It's great being able to run <code>attic push system /run/current-system</code> on
whichever machine we are currently using, but the the chances are that if you
use Nix to manage your system configurations, you have a system configuration
monorepo, and depending on how many machines and architectures you are
targeting, it can quickly become tiresome to manually push to the cache from
each of them.</p>
<p>My configuration repo presently has 4 targets:</p>
<ul>
<li>NixOS VM that runs on WSL (x86_64)</li>
<li>Bare metal Hetzner server (x86_64)</li>
<li>Raspberry Pi running in my home network (aarch64)</li>
<li>Production instance of https://notado.app (x86_64)</li>
</ul>
<h2 id="building-nixos-system-configurations"><a class="zola-anchor" href="#building-nixos-system-configurations" aria-label="Anchor link for: building-nixos-system-configurations">Building NixOS system configurations</a></h2>
<p>NixOS system configurations are essentially build artifacts. Since they are
build artifacts, it makes sense to build, cache and push them in CI pipelines,
right? Right!</p>
<p>Although <a href="https://www.youtube.com/watch?v=9qljpi5jiMQ">GitHub Actions is
awful</a>, it can be made
siginificantly less awful by installing Nix immediately after we run
<code>actions/checkout@v4</code>. Once we have Nix installed, we can side-step the poorly
maintained npm-esque "actions" ecosystem almost entirely.</p>
<p>If you have <a href="https://www.youtube.com/channel/UCeai3-do-9O4MNy9_xjO6mg?sub_confirmation=1">subscribed to my YouTube
channel</a>
you may have seen a video where I built the system configurations for my <a href="https://github.com/search?q=owner%3ALGUG2Z+nixos&type=repositories">NixOS
starter
templates</a>
on GitHub Actions.</p>
<div
style="position: relative; padding-bottom: 56.25%; height: 0; overflow: hidden;">
<iframe allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share; fullscreen"
loading="eager"
referrerpolicy="strict-origin-when-cross-origin"
src="https://www.youtube.com/embed/TR0C76I59AI"
style="position: absolute; top: 0; left: 0; width: 100%; height: 100%; border:0;"
title="YouTube video"
allowfullscreen></iframe>
</div>
<p>Here is an example <code>build.yml</code> from the
<a href="https://github.com/LGUG2Z/nixos-hetzner-robot-starter"><code>nixos-hetzner-robot-starter</code></a>
template.</p>
<pre data-lang="yaml" style="background-color:#2b303b;color:#c0c5ce;" class="language-yaml "><code class="language-yaml" data-lang="yaml"><span style="color:#bf616a;">name</span><span>: "</span><span style="color:#a3be8c;">build</span><span>"
</span><span>
</span><span style="color:#bf616a;">jobs</span><span>:
</span><span> </span><span style="color:#bf616a;">build</span><span>:
</span><span> </span><span style="color:#bf616a;">runs-on</span><span>: </span><span style="color:#a3be8c;">ubuntu-latest
</span><span> </span><span style="color:#bf616a;">steps</span><span>:
</span><span> - </span><span style="color:#bf616a;">uses</span><span>: </span><span style="color:#a3be8c;">actions/checkout@v4
</span><span> - </span><span style="color:#bf616a;">uses</span><span>: </span><span style="color:#a3be8c;">DeterminateSystems/nix-installer-action@main
</span><span> - </span><span style="color:#bf616a;">uses</span><span>: </span><span style="color:#a3be8c;">DeterminateSystems/magic-nix-cache-action@main
</span><span> - </span><span style="color:#bf616a;">run</span><span>: </span><span style="color:#a3be8c;">nix build -L .#deploy.nodes.robot.profiles.system.path
</span></code></pre>
<p>Painless, right? We just checkout the repo, install Nix, optionally install
<code>magic-nix-cache</code>, and then run a <code>nix build</code> command!</p>
<h2 id="building-more-complex-nixos-system-configurations"><a class="zola-anchor" href="#building-more-complex-nixos-system-configurations" aria-label="Anchor link for: building-more-complex-nixos-system-configurations">Building more complex NixOS system configurations</a></h2>
<p>Starter templates are pretty easy to build, but once we start adding things
like secrets and packages from private repositories, different architecture
targets etc. to our system configurations, naturally we'll need to adapt our
<code>build.yml</code></p>
<h3 id="handling-private-repositories"><a class="zola-anchor" href="#handling-private-repositories" aria-label="Anchor link for: handling-private-repositories">Handling private repositories</a></h3>
<p>The <code>nix-installer-action</code> has a handy input where you can put a generated
GitHub token with access to the private repositories you need. This will be
used whenever the build needs to pull something like a flake input from one of
your private repos. When you add this as a GitHub Actions secret, note that you
won't be able to use the <code>GITHUB_</code> prefix for the name as that is reserved.</p>
<p>Additionally, you can also configure <code>git</code> to use this token, which is useful
if you need to check out any private submodules in your configuration repo
before you can run <code>nix build</code>.</p>
<pre data-lang="yaml" style="background-color:#2b303b;color:#c0c5ce;" class="language-yaml "><code class="language-yaml" data-lang="yaml"><span>- </span><span style="color:#bf616a;">uses</span><span>: </span><span style="color:#a3be8c;">DeterminateSystems/nix-installer-action@main
</span><span> </span><span style="color:#bf616a;">with</span><span>:
</span><span> </span><span style="color:#bf616a;">github-token</span><span>: </span><span style="color:#a3be8c;">${{ secrets.GH_TOKEN }}
</span><span>- </span><span style="color:#bf616a;">run</span><span>: </span><span style="color:#b48ead;">|
</span><span style="color:#a3be8c;"> git config --global url."https://${{ secrets.GH_TOKEN }}@github.com".insteadOf https://github.com
</span></code></pre>
<h3 id="handling-secrets-encrypted-with-git-crypt"><a class="zola-anchor" href="#handling-secrets-encrypted-with-git-crypt" aria-label="Anchor link for: handling-secrets-encrypted-with-git-crypt">Handling secrets encrypted with git-crypt</a></h3>
<p>While secrets encrypted with <code>sops-nix</code> are skipped over gracefully during a
system build, <code>git-crypt</code>ed files that are used in a system configuration will
result in a build error if they are not decrypted.</p>
<p>We can handle this scenario by exporting a symmetric key, encoding it in
base64, adding it as a secret variable in GitHub Actions and then decoding it
to decrypt the relevant files in our build job.</p>
<pre data-lang="bash" style="background-color:#2b303b;color:#c0c5ce;" class="language-bash "><code class="language-bash" data-lang="bash"><span style="color:#bf616a;">git-crypt</span><span> export-key ./exported-key
</span><span style="color:#bf616a;">cat</span><span> exported-key | </span><span style="color:#bf616a;">base64 --encode </span><span>>encoded-key
</span><span>
</span><span style="color:#65737e;"># Store the value of `encoded-key` as a secret variable in GitHub Actions
</span></code></pre>
<p>With the encoded key in place as a GitHub Actions secret, we can add a step to
decode it and decrypt any <code>git-crypt</code>ed files in our repo.</p>
<pre data-lang="yaml" style="background-color:#2b303b;color:#c0c5ce;" class="language-yaml "><code class="language-yaml" data-lang="yaml"><span>- </span><span style="color:#bf616a;">run</span><span>: </span><span style="color:#b48ead;">|
</span><span style="color:#a3be8c;"> echo "${{ secrets.GIT_CRYPT_KEY }}" | base64 --decode > key
</span><span style="color:#a3be8c;"> nix profile install nixpkgs#git-crypt
</span><span style="color:#a3be8c;"> git-crypt unlock key
</span></code></pre>
<h3 id="handling-different-architectures"><a class="zola-anchor" href="#handling-different-architectures" aria-label="Anchor link for: handling-different-architectures">Handling different architectures</a></h3>
<p>Typically if you want to build packages for an <code>aarch64-linux</code> machine on an
<code>x86_64-linux</code> machine, you just need to specify that architecture as an
emulated system.</p>
<pre data-lang="nix" style="background-color:#2b303b;color:#c0c5ce;" class="language-nix "><code class="language-nix" data-lang="nix"><span>{
</span><span> </span><span style="color:#d08770;">boot</span><span>.</span><span style="color:#d08770;">binfmt</span><span>.</span><span style="color:#d08770;">emulatedSystems </span><span>= ["</span><span style="color:#a3be8c;">aarch64-linux</span><span>"];
</span><span>}
</span></code></pre>
<p>I came across <a href="https://thewagner.net/blog/2023/11/20/building-nix-packages-for-the-raspberry-pi-with-github-actions/">an article by David
Wagner</a>
which showed how to replicate this in GitHub Actions with a mixture of
<a href="https://github.com/marketplace/actions/docker-setup-qemu"><code>docker-setup-qemu</code></a>
and specifying <code>aarch64-linux</code> as an <code>extra-platform</code> in <code>nix.conf</code>.</p>
<pre data-lang="yaml" style="background-color:#2b303b;color:#c0c5ce;" class="language-yaml "><code class="language-yaml" data-lang="yaml"><span style="color:#bf616a;">jobs</span><span>:
</span><span> </span><span style="color:#bf616a;">build</span><span>:
</span><span> </span><span style="color:#bf616a;">runs-on</span><span>: </span><span style="color:#a3be8c;">ubuntu-latest
</span><span> </span><span style="color:#bf616a;">steps</span><span>:
</span><span> - </span><span style="color:#bf616a;">uses</span><span>: </span><span style="color:#a3be8c;">actions/checkout@v4
</span><span> - </span><span style="color:#bf616a;">uses</span><span>: </span><span style="color:#a3be8c;">docker/setup-qemu-action@v3
</span><span> - </span><span style="color:#bf616a;">uses</span><span>: </span><span style="color:#a3be8c;">DeterminateSystems/nix-installer-action@main
</span><span> </span><span style="color:#bf616a;">with</span><span>:
</span><span> </span><span style="color:#bf616a;">extra-conf</span><span>: </span><span style="color:#b48ead;">|
</span><span style="color:#a3be8c;"> extra-platforms = aarch64-linux
</span></code></pre>
<p>This is great, but we don't really need to run <code>docker-setup-qemu</code> when we are
building <code>x86_64-linux</code> systems, so let's tweak this a little.</p>
<p>We can introduce a matrix which contains each of our machines and their
respective platforms, and use that information to only run the
<code>docker-setup-qemu</code> step if we are building an <code>aarch64</code> system.</p>
<pre data-lang="yaml" style="background-color:#2b303b;color:#c0c5ce;" class="language-yaml "><code class="language-yaml" data-lang="yaml"><span style="color:#bf616a;">jobs</span><span>:
</span><span> </span><span style="color:#bf616a;">build</span><span>:
</span><span> </span><span style="color:#bf616a;">runs-on</span><span>: </span><span style="color:#a3be8c;">ubuntu-latest
</span><span> </span><span style="color:#bf616a;">strategy</span><span>:
</span><span> </span><span style="color:#bf616a;">matrix</span><span>:
</span><span> </span><span style="color:#bf616a;">machine</span><span>:
</span><span> - </span><span style="color:#bf616a;">host</span><span>: </span><span style="color:#a3be8c;">nixsl
</span><span> </span><span style="color:#bf616a;">platform</span><span>: </span><span style="color:#a3be8c;">x86-64-linux
</span><span> - </span><span style="color:#bf616a;">host</span><span>: </span><span style="color:#a3be8c;">hetzner
</span><span> </span><span style="color:#bf616a;">platform</span><span>: </span><span style="color:#a3be8c;">x86-64-linux
</span><span> - </span><span style="color:#bf616a;">host</span><span>: </span><span style="color:#a3be8c;">notado
</span><span> </span><span style="color:#bf616a;">platform</span><span>: </span><span style="color:#a3be8c;">x86-64-linux
</span><span> - </span><span style="color:#bf616a;">host</span><span>: </span><span style="color:#a3be8c;">pi3
</span><span> </span><span style="color:#bf616a;">platform</span><span>: </span><span style="color:#a3be8c;">aarch64-linux
</span><span> </span><span style="color:#bf616a;">steps</span><span>:
</span><span> - </span><span style="color:#bf616a;">uses</span><span>: </span><span style="color:#a3be8c;">actions/checkout@v4
</span><span> - </span><span style="color:#bf616a;">if</span><span>: </span><span style="color:#a3be8c;">matrix.machine.platform == 'aarch64-linux'
</span><span> </span><span style="color:#bf616a;">uses</span><span>: </span><span style="color:#a3be8c;">docker/setup-qemu-action@v3
</span></code></pre>
<h3 id="configuring-access-to-our-attic-binary-cache"><a class="zola-anchor" href="#configuring-access-to-our-attic-binary-cache" aria-label="Anchor link for: configuring-access-to-our-attic-binary-cache">Configuring access to our Attic binary cache</a></h3>
<p>If you recall from the previous article, in order to interact with our Attic binary cache, we had to do three things.</p>
<ul>
<li>Log in with our token to be able to push</li>
</ul>
<pre data-lang="yaml" style="background-color:#2b303b;color:#c0c5ce;" class="language-yaml "><code class="language-yaml" data-lang="yaml"><span>- </span><span style="color:#bf616a;">run</span><span>: </span><span style="color:#b48ead;">|
</span><span style="color:#a3be8c;"> nix run github:zhaofengli/attic#default login fly https://<your fly app name>.fly.dev ${{ secrets.ATTIC_TOKEN }}
</span></code></pre>
<ul>
<li>Add our token to a <code>netrc</code> file to be authorized to query</li>
</ul>
<pre data-lang="yaml" style="background-color:#2b303b;color:#c0c5ce;" class="language-yaml "><code class="language-yaml" data-lang="yaml"><span>- </span><span style="color:#bf616a;">run</span><span>: </span><span style="color:#b48ead;">|
</span><span style="color:#a3be8c;"> sudo mkdir -p /etc/nix
</span><span style="color:#a3be8c;"> echo "machine <your fly app name>.fly.dev password ${{ secrets.ATTIC_TOKEN }}" | sudo tee /etc/nix/netrc > /dev/null
</span></code></pre>
<ul>
<li>Update our <code>substituters</code> and <code>trusted-public-keys</code> to be able to query during builds</li>
</ul>
<pre data-lang="yaml" style="background-color:#2b303b;color:#c0c5ce;" class="language-yaml "><code class="language-yaml" data-lang="yaml"><span>- </span><span style="color:#bf616a;">uses</span><span>: </span><span style="color:#a3be8c;">DeterminateSystems/nix-installer-action@main
</span><span> </span><span style="color:#bf616a;">with</span><span>:
</span><span> </span><span style="color:#bf616a;">extra-conf</span><span>: </span><span style="color:#b48ead;">|
</span><span style="color:#a3be8c;"> substituters = https://<your fly app name>.fly.dev/system?priority=43 https://nix-community.cachix.org?priority=41 https://numtide.cachix.org?priority=42 https://cache.nixos.org/
</span><span style="color:#a3be8c;"> trusted-public-keys = cache.nixos.org-1:6NCHdD59X431o0gWypbMrAURkbJ16ZPMQFGspcDShjY= system:5M8uBPjS68HTadSbeCs0Jiu0Z1tJBNdahtKBCXhl+Z0= nix-community.cachix.org-1:mB9FSh9qf2dCimDSUo8Zy7bkq5CX+/rkCWyvRCYg3Fs= numtide.cachix.org-1:2ps1kLBUWjxIneOy1Ik6cQjb41X0iXVXeHigGmycPPE=
</span></code></pre>
<h3 id="putting-it-all-together"><a class="zola-anchor" href="#putting-it-all-together" aria-label="Anchor link for: putting-it-all-together">Putting it all together</a></h3>
<pre data-lang="yaml" style="background-color:#2b303b;color:#c0c5ce;" class="language-yaml "><code class="language-yaml" data-lang="yaml"><span style="color:#bf616a;">name</span><span>: "</span><span style="color:#a3be8c;">build</span><span>"
</span><span>
</span><span style="color:#bf616a;">jobs</span><span>:
</span><span> </span><span style="color:#bf616a;">build</span><span>:
</span><span> </span><span style="color:#bf616a;">runs-on</span><span>: </span><span style="color:#a3be8c;">ubuntu-latest
</span><span> </span><span style="color:#bf616a;">strategy</span><span>:
</span><span> </span><span style="color:#bf616a;">fail-fast</span><span>: </span><span style="color:#d08770;">false
</span><span> </span><span style="color:#65737e;"># Here we specify the matrix of our hosts and their target platform architectures
</span><span> </span><span style="color:#bf616a;">matrix</span><span>:
</span><span> </span><span style="color:#bf616a;">machine</span><span>:
</span><span> - </span><span style="color:#bf616a;">host</span><span>: </span><span style="color:#a3be8c;">nixsl
</span><span> </span><span style="color:#bf616a;">platform</span><span>: </span><span style="color:#a3be8c;">x86-64-linux
</span><span> - </span><span style="color:#bf616a;">host</span><span>: </span><span style="color:#a3be8c;">hetzner
</span><span> </span><span style="color:#bf616a;">platform</span><span>: </span><span style="color:#a3be8c;">x86-64-linux
</span><span> - </span><span style="color:#bf616a;">host</span><span>: </span><span style="color:#a3be8c;">notado
</span><span> </span><span style="color:#bf616a;">platform</span><span>: </span><span style="color:#a3be8c;">x86-64-linux
</span><span> - </span><span style="color:#bf616a;">host</span><span>: </span><span style="color:#a3be8c;">pi3
</span><span> </span><span style="color:#bf616a;">platform</span><span>: </span><span style="color:#a3be8c;">aarch64-linux
</span><span>
</span><span> </span><span style="color:#bf616a;">steps</span><span>:
</span><span> - </span><span style="color:#bf616a;">uses</span><span>: </span><span style="color:#a3be8c;">actions/checkout@v4
</span><span>
</span><span> </span><span style="color:#65737e;"># We only run this if we are building an aarch64-linux system
</span><span> - </span><span style="color:#bf616a;">if</span><span>: </span><span style="color:#a3be8c;">matrix.machine.platform == 'aarch64-linux'
</span><span> </span><span style="color:#bf616a;">uses</span><span>: </span><span style="color:#a3be8c;">docker/setup-qemu-action@v3
</span><span>
</span><span> </span><span style="color:#65737e;"># We make our netrc file that is used to make authorized requests to Attic
</span><span> </span><span style="color:#65737e;"># We also make sure that we use our custom GitHub token if we need to clone submodules or anything like that
</span><span> - </span><span style="color:#bf616a;">run</span><span>: </span><span style="color:#b48ead;">|
</span><span style="color:#a3be8c;"> sudo mkdir -p /etc/nix
</span><span style="color:#a3be8c;"> echo "machine <your fly app name>.fly.dev password ${{ secrets.ATTIC_TOKEN }}" | sudo tee /etc/nix/netrc > /dev/null
</span><span style="color:#a3be8c;"> git config --global url."https://${{ secrets.GH_TOKEN }}@github.com".insteadOf https://github.com
</span><span style="color:#a3be8c;">
</span><span> - </span><span style="color:#bf616a;">uses</span><span>: </span><span style="color:#a3be8c;">DeterminateSystems/nix-installer-action@main
</span><span> </span><span style="color:#bf616a;">with</span><span>:
</span><span> </span><span style="color:#65737e;"># We set our custom GitHub token for any private flake inputs we might have
</span><span> </span><span style="color:#bf616a;">github-token</span><span>: </span><span style="color:#a3be8c;">${{ secrets.GH_TOKEN }}
</span><span> </span><span style="color:#65737e;"># We add all the config for extra platforms, other binary caches and to raise the number of connections that can be made
</span><span> </span><span style="color:#bf616a;">extra-conf</span><span>: </span><span style="color:#b48ead;">|
</span><span style="color:#a3be8c;"> fallback = true
</span><span style="color:#a3be8c;"> http-connections = 128
</span><span style="color:#a3be8c;"> max-substitution-jobs = 128
</span><span style="color:#a3be8c;"> extra-platforms = aarch64-linux
</span><span style="color:#a3be8c;"> substituters = https://<your fly app name>.fly.dev/system?priority=43 https://nix-community.cachix.org?priority=41 https://numtide.cachix.org?priority=42 https://cache.nixos.org/
</span><span style="color:#a3be8c;"> trusted-public-keys = cache.nixos.org-1:6NCHdD59X431o0gWypbMrAURkbJ16ZPMQFGspcDShjY= system:5M8uBPjS68HTadSbeCs0Jiu0Z1tJBNdahtKBCXhl+Z0= nix-community.cachix.org-1:mB9FSh9qf2dCimDSUo8Zy7bkq5CX+/rkCWyvRCYg3Fs= numtide.cachix.org-1:2ps1kLBUWjxIneOy1Ik6cQjb41X0iXVXeHigGmycPPE=
</span><span> - </span><span style="color:#bf616a;">uses</span><span>: </span><span style="color:#a3be8c;">DeterminateSystems/magic-nix-cache-action@main
</span><span> </span><span style="color:#65737e;"># We make sure that any git-crypted files are decrypted before we begin to build
</span><span> - </span><span style="color:#bf616a;">run</span><span>: </span><span style="color:#b48ead;">|
</span><span style="color:#a3be8c;"> echo "${{ secrets.GIT_CRYPT_KEY }}" | base64 --decode > key
</span><span style="color:#a3be8c;"> nix profile install nixpkgs#git-crypt
</span><span style="color:#a3be8c;"> git-crypt unlock key
</span><span style="color:#a3be8c;">
</span><span> </span><span style="color:#65737e;"># We build each system in a separate job, targeting the configuration using matrix.machine.host
</span><span> </span><span style="color:#65737e;"># Once built, we login to Attic and push the built system to our `system` cache!
</span><span> - </span><span style="color:#bf616a;">name</span><span>: </span><span style="color:#a3be8c;">Build and push system
</span><span> </span><span style="color:#bf616a;">run</span><span>: </span><span style="color:#b48ead;">|
</span><span style="color:#a3be8c;"> nix build .#nixosConfigurations.${{ matrix.machine.host }}.config.system.build.toplevel
</span><span style="color:#a3be8c;"> nix run github:zhaofengli/attic#default login fly https://<your fly app name>.fly.dev ${{ secrets.ATTIC_TOKEN }}
</span><span style="color:#a3be8c;"> nix run github:zhaofengli/attic#default push system result -j 2
</span></code></pre>
<p>If you have any questions or comments you can reach out to me on
<a href="https://twitter.com/JeezyCodes">Twitter</a> and
<a href="https://hachyderm.io/@LGUG2Z">Mastodon</a>.</p>
<p>If you're interested in what I read to come up with solutions like this one,
you can subscribe to my <a href="https://notado.app/feeds/jado/software-development">Software Development RSS
feed</a>.</p>
<p>If you'd like to watch me writing code while explaining what I'm doing, you can
also <a href="https://www.youtube.com/channel/UCeai3-do-9O4MNy9_xjO6mg?sub_confirmation=1">subscribe to my YouTube
channel</a>.</p>
<p>If you found this content valuable, or if you are a happy user of the
<a href="https://github.com/LGUG2Z/komorebi"><code>komorebi</code></a> tiling window manager or my
<a href="https://github.com/search?q=owner%3ALGUG2Z+nixos&type=repositories">NixOS starter
templates</a>,
please consider sponsoring me on <a href="https://github.com/sponsors/LGUG2Z">GitHub</a>
or tipping me on <a href="https://ko-fi.com/lgug2z">Ko-fi</a>.</p>
Deploying a Cloudflare R2-Backed Nix Binary Cache (Attic!) on Fly.ioTue, 16 Jan 2024 16:40:58 -0800lgug2z
https://lgug2z.com/articles/deploying-a-cloudflare-r2-backed-nix-binary-cache-attic-on-fly-io/
https://lgug2z.com/articles/deploying-a-cloudflare-r2-backed-nix-binary-cache-attic-on-fly-io/<p>I have tried running the <a href="https://github.com/zhaofengli/attic">Attic Nix Binary
Cache</a> on my Hetzner dedicated server in
Germany a few times in the past, but the peering issues and the latency to
Xfinity in Seattle have always made me throw my hands up in frustration.</p>
<p>This morning I noticed <a href="https://github.com/zhaofengli/attic/issues/1#issuecomment-1368527062">a
comment</a>
by Zhaofeng on the repo issue tracker.</p>
<blockquote>
<p>As a NixOS aficionado myself, I begrudgingly admit that I've been running my
instance on fly.io 😛</p>
</blockquote>
<p>I'm not sure if this is comment is still current, but hey, if Zhaofeng is/was
running his binary cache on <a href="https://fly.io/">fly.io</a>, there's no reason why we
can't too, right?</p>
<h2 id="storage"><a class="zola-anchor" href="#storage" aria-label="Anchor link for: storage">Storage</a></h2>
<p>While Attic does support local storage, I figured I'd use Cloudflare's R2 as a
storage backend, both as an excuse to try out the <a href="https://developers.cloudflare.com/r2/pricing/">free
tier</a> (10GB), and because it'll
be easy enough to lift-and-shift if I ever decide to move the server from
fly.io.</p>
<p>It's easy enough to create a new R2 bucket and grab a read-write API token
scoped to the bucket on the <a href="https://dash.cloudflare.com">Cloudflare Dashboard</a>.</p>
<h2 id="server-configuration"><a class="zola-anchor" href="#server-configuration" aria-label="Anchor link for: server-configuration">Server Configuration</a></h2>
<p>Once we have our credentials, we can put together a configuration file for the
Attic server based on the <a href="https://github.com/zhaofengli/attic/blob/e6bedf1869f382cfc51b69848d6e09d51585ead6/server/src/config-template.toml">example
config</a>.</p>
<pre data-lang="toml" style="background-color:#2b303b;color:#c0c5ce;" class="language-toml "><code class="language-toml" data-lang="toml"><span style="color:#bf616a;">listen </span><span>= "</span><span style="color:#a3be8c;">[::]:8080</span><span>"
</span><span style="color:#bf616a;">token-hs256-secret-base64 </span><span>= "</span><span style="color:#a3be8c;"><generate this with openssl rand 64 | base64 -w0></span><span>"
</span><span>
</span><span>[database]
</span><span style="color:#bf616a;">url </span><span>= "</span><span style="color:#a3be8c;">sqlite:///data/attic.db?mode=rwc</span><span>"
</span><span>
</span><span>[storage]
</span><span style="color:#bf616a;">bucket </span><span>= "</span><span style="color:#a3be8c;"><your bucket name></span><span>"
</span><span style="color:#bf616a;">type </span><span>= "</span><span style="color:#a3be8c;">s3</span><span>"
</span><span style="color:#bf616a;">region </span><span>= "</span><span style="color:#a3be8c;">auto</span><span>"
</span><span style="color:#bf616a;">endpoint </span><span>= "</span><span style="color:#a3be8c;">https://<your cloudflare account id>.r2.cloudflarestorage.com</span><span>"
</span><span>
</span><span>[storage.credentials]
</span><span style="color:#bf616a;">access_key_id </span><span>= "</span><span style="color:#a3be8c;"><your access key id></span><span>"
</span><span style="color:#bf616a;">secret_access_key </span><span>= "</span><span style="color:#a3be8c;"><your secret access key></span><span>"
</span><span>
</span><span>[chunking]
</span><span style="color:#bf616a;">nar-size-threshold </span><span>= </span><span style="color:#d08770;">65536
</span><span style="color:#bf616a;">min-size </span><span>= </span><span style="color:#d08770;">16384
</span><span style="color:#bf616a;">avg-size </span><span>= </span><span style="color:#d08770;">65536
</span><span style="color:#bf616a;">max-size </span><span>= </span><span style="color:#d08770;">262144
</span><span>
</span><span>[compression]
</span><span style="color:#bf616a;">type </span><span>= "</span><span style="color:#a3be8c;">zstd</span><span>"
</span><span>
</span><span>[garbage-collection]
</span><span style="color:#bf616a;">interval </span><span>= "</span><span style="color:#a3be8c;">12 hours</span><span>"
</span></code></pre>
<p>One thing to note is that we are storing our SQLite database file at
<code>/data/attic.db</code> - this is going to be a fly volume that we'll create soon.</p>
<p>You should probably <code>git-crypt</code> this file (or encrypt it with <code>sops</code> or <code>age</code>)
in your configuration repo since it has sensitive credentials.</p>
<p><strong>Not sure about how to handle secrets in NixOS configuration
repos? I have a big old article all about <a href="https://lgug2z.com/articles/handling-secrets-in-nixos-an-overview/">handling secrets in
NixOS</a>!</strong></p>
<h2 id="dockerfile"><a class="zola-anchor" href="#dockerfile" aria-label="Anchor link for: dockerfile">Dockerfile</a></h2>
<p>Luckily, there is an automatically published Docker image available for us to
extend. There isn't much to do here except bring in our configration file,
reference it when we start <code>atticd</code> and ensure it's running in <code>monolithic</code>
mode.</p>
<pre data-lang="dockerfile" style="background-color:#2b303b;color:#c0c5ce;" class="language-dockerfile "><code class="language-dockerfile" data-lang="dockerfile"><span style="color:#b48ead;">FROM</span><span> ghcr.io/zhaofengli/attic:latest
</span><span style="color:#b48ead;">COPY</span><span> ./server.toml /attic/server.toml
</span><span style="color:#b48ead;">EXPOSE </span><span>8080
</span><span>CMD ["</span><span style="color:#a3be8c;">-f</span><span>", "</span><span style="color:#a3be8c;">/attic/server.toml</span><span>", "</span><span style="color:#a3be8c;">--mode</span><span>", "</span><span style="color:#a3be8c;">monolithic</span><span>"]
</span></code></pre>
<h2 id="fly-config"><a class="zola-anchor" href="#fly-config" aria-label="Anchor link for: fly-config">Fly Config</a></h2>
<p>The last piece of the deployment puzzle is to put together a <code>fly.toml</code> file for our
<code>atticd</code> instance.</p>
<p>Let's start by ensuring we have a volume created in our desired region.</p>
<pre data-lang="bash" style="background-color:#2b303b;color:#c0c5ce;" class="language-bash "><code class="language-bash" data-lang="bash"><span style="color:#bf616a;">fly</span><span> volume create atticdata</span><span style="color:#bf616a;"> -r</span><span> sea</span><span style="color:#bf616a;"> -n</span><span> 1
</span></code></pre>
<p>The volume can be called whatever we want, we just need to make sure to update
the <code>source</code> in the <code>[mounts]</code> section in our <code>fly.toml</code> file.</p>
<pre data-lang="toml" style="background-color:#2b303b;color:#c0c5ce;" class="language-toml "><code class="language-toml" data-lang="toml"><span style="color:#bf616a;">app </span><span>= "</span><span style="color:#a3be8c;"><pick your own fly app name></span><span>"
</span><span style="color:#bf616a;">primary_region </span><span>= "</span><span style="color:#a3be8c;">sea</span><span>"
</span><span>
</span><span>[mounts]
</span><span> </span><span style="color:#bf616a;">source </span><span>= "</span><span style="color:#a3be8c;">atticdata</span><span>"
</span><span> </span><span style="color:#bf616a;">destination </span><span>= "</span><span style="color:#a3be8c;">/data</span><span>"
</span><span>
</span><span>[http_service]
</span><span> </span><span style="color:#bf616a;">internal_port </span><span>= </span><span style="color:#d08770;">8080
</span><span> </span><span style="color:#bf616a;">force_https </span><span>= </span><span style="color:#d08770;">true
</span><span> </span><span style="color:#bf616a;">auto_stop_machines </span><span>= </span><span style="color:#d08770;">true
</span><span> </span><span style="color:#bf616a;">auto_start_machines </span><span>= </span><span style="color:#d08770;">true
</span><span> </span><span style="color:#bf616a;">min_machines_running </span><span>= </span><span style="color:#d08770;">0
</span><span> </span><span style="color:#bf616a;">processes </span><span>= ["</span><span style="color:#a3be8c;">app</span><span>"]
</span><span>
</span><span>[services.concurrency]
</span><span> </span><span style="color:#bf616a;">type </span><span>= "</span><span style="color:#a3be8c;">connections</span><span>"
</span><span> </span><span style="color:#bf616a;">hard_limit </span><span>= </span><span style="color:#d08770;">1000
</span><span> </span><span style="color:#bf616a;">soft_limit </span><span>= </span><span style="color:#d08770;">1000
</span></code></pre>
<p>That's pretty much it, time to <code>fly deploy</code>! (I do this with <code>--ha=false</code> since
I don't want to create more than one machine)</p>
<h2 id="generating-an-attic-token"><a class="zola-anchor" href="#generating-an-attic-token" aria-label="Anchor link for: generating-an-attic-token">Generating an Attic token</a></h2>
<p>We'll need to execute a command on the newly deployed fly.io machine to
generate a "godmode" token for ourselves. Let's start by getting the machine
ID.</p>
<pre data-lang="bash" style="background-color:#2b303b;color:#c0c5ce;" class="language-bash "><code class="language-bash" data-lang="bash"><span style="color:#bf616a;">fly</span><span> machine list</span><span style="color:#bf616a;"> --json </span><span>| </span><span style="color:#bf616a;">jq --raw-output </span><span>'</span><span style="color:#a3be8c;">.[].id</span><span>'
</span></code></pre>
<p>Then we can call the <code>atticadm</code> command on the fly.io machine via <code>fly machine exec</code> to generate our token.</p>
<pre data-lang="bash" style="background-color:#2b303b;color:#c0c5ce;" class="language-bash "><code class="language-bash" data-lang="bash"><span style="color:#bf616a;">fly</span><span> machine exec $</span><span style="color:#bf616a;">YOUR_MACHINE_ID </span><span>'</span><span style="color:#a3be8c;">atticadm make-token --sub "$YOUR_PREFERRED_USERNAME" --validity "10y" --pull "*" --push "*" --create-cache "*" --configure-cache "*" --configure-cache-retention "*" --destroy-cache "*" --delete "*" -f /attic/server.toml</span><span>'
</span></code></pre>
<p>We need to keep this token somewhere safe and preferably encrypted. I keep mine
encrypted with <code>sops-nix</code> in my NixOS configuration monorepo and have it
decrypted and mounted to <code>/run/secrets/attic/token</code> on machines that have been
given access to decrypt it.</p>
<h2 id="logging-into-attic"><a class="zola-anchor" href="#logging-into-attic" aria-label="Anchor link for: logging-into-attic">Logging into Attic</a></h2>
<p>This part is pretty simple! We can call the server whatever we like in our
configuration; here "fly" will do. The final two arguments are the URL
generated for our fly.io app and our access token.</p>
<pre data-lang="bash" style="background-color:#2b303b;color:#c0c5ce;" class="language-bash "><code class="language-bash" data-lang="bash"><span style="color:#65737e;"># if pkgs.attic is installed in environment.systemPackages
</span><span style="color:#bf616a;">attic</span><span> login fly https://$</span><span style="color:#bf616a;">YOUR_FLY_APP_NAME</span><span>.fly.dev $(</span><span style="color:#bf616a;">cat</span><span> /run/secrets/attic/token)
</span><span>
</span><span style="color:#65737e;"># if pkgs.attic is not installed, we can always run it directly from the flake
</span><span style="color:#bf616a;">nix</span><span> run github:zhaofengli/attic#default login fly https://$</span><span style="color:#bf616a;">YOUR_FLY_APP_NAME</span><span>.fly.dev $(</span><span style="color:#bf616a;">cat</span><span> /run/secrets/attic/token)
</span></code></pre>
<h2 id="pushing-to-our-attic-cache"><a class="zola-anchor" href="#pushing-to-our-attic-cache" aria-label="Anchor link for: pushing-to-our-attic-cache">Pushing to our Attic cache</a></h2>
<p>Now that we have the <code>atticd</code> cache server running on fly.io and our computer
authenticated with a token that allows us to push, let's set up a cache and
push our entire(!) system configuration.</p>
<p>Note: In my case 256MB of RAM was not enough to keep up with everything I
wanted to push! I suggest scaling the RAM to at least 512MB, if not 1GB with
<code>fly scale memory 512</code>. The snippet below assumes that you have bumped the RAM
to 512MB which <em>should</em> be able to handle three concurrent jobs (<code>-j 3</code>)
without the machine giving "out of memory" errors and restarting the <code>atticd</code>
process.</p>
<pre data-lang="bash" style="background-color:#2b303b;color:#c0c5ce;" class="language-bash "><code class="language-bash" data-lang="bash"><span style="color:#65737e;"># if pkgs.attic is installed in environment.systemPackages
</span><span style="color:#bf616a;">attic</span><span> cache create system</span><span style="color:#bf616a;"> -j</span><span> 3
</span><span style="color:#bf616a;">attic</span><span> push system /run/current-system</span><span style="color:#bf616a;"> -j</span><span> 3
</span><span>
</span><span style="color:#65737e;"># if pkgs.attic is not installed, we can always run it directly from the flake
</span><span style="color:#bf616a;">nix</span><span> run github:zhaofengli/attic#default cache create system</span><span style="color:#bf616a;"> -j</span><span> 3
</span><span style="color:#bf616a;">nix</span><span> run github:zhaofengli/attic#default push system /run/current-system</span><span style="color:#bf616a;"> -j</span><span> 3
</span></code></pre>
<h2 id="configuring-nixos-to-use-our-attic-binary-cache"><a class="zola-anchor" href="#configuring-nixos-to-use-our-attic-binary-cache" aria-label="Anchor link for: configuring-nixos-to-use-our-attic-binary-cache">Configuring NixOS to use our Attic binary cache</a></h2>
<p>There are two things that we need to do before we can configure NixOS to use
our new binary cache.</p>
<p>First, we need to get the public key of our <code>system</code> cache.</p>
<pre data-lang="bash" style="background-color:#2b303b;color:#c0c5ce;" class="language-bash "><code class="language-bash" data-lang="bash"><span style="color:#bf616a;">attic</span><span> cache info system
</span></code></pre>
<p>Next, we need to create a <code>netrc</code> file which contains our <code>attic</code> token. The
format looks like this:</p>
<pre style="background-color:#2b303b;color:#c0c5ce;"><code><span>machine <your fly app name>.fly.dev
</span><span>password <your attic token>
</span></code></pre>
<p>Since this file once again contains sensitive information, if you want to store
this in your configuration repo, I recommend encrypting it. In the example
below, I have added the <code>netrc</code> file contents via <code>sops-nix</code>, which mounts the
decrypted contents to <code>/run/secrets/attic/netrc</code> for machines that have been
given decryption access.</p>
<pre data-lang="nix" style="background-color:#2b303b;color:#c0c5ce;" class="language-nix "><code class="language-nix" data-lang="nix"><span>{
</span><span> </span><span style="color:#d08770;">nix </span><span>= {
</span><span> </span><span style="color:#d08770;">settings </span><span>= {
</span><span> </span><span style="color:#d08770;">substituters </span><span>= [
</span><span> "</span><span style="color:#a3be8c;">https://nix-community.cachix.org?priority=41</span><span>" </span><span style="color:#65737e;"># this is a useful public cache!
</span><span> "</span><span style="color:#a3be8c;">https://numtide.cachix.org?priority=42</span><span>" </span><span style="color:#65737e;"># this is also a useful public cache!
</span><span> "</span><span style="color:#a3be8c;">https://<your fly app name>.fly.dev/system?priority=43</span><span>"
</span><span> ];
</span><span> </span><span style="color:#d08770;">trusted-public-keys </span><span>= [
</span><span> "</span><span style="color:#a3be8c;">nix-community.cachix.org-1:mB9FSh9qf2dCimDSUo8Zy7bkq5CX+/rkCWyvRCYg3Fs=</span><span>"
</span><span> "</span><span style="color:#a3be8c;">numtide.cachix.org-1:2ps1kLBUWjxIneOy1Ik6cQjb41X0iXVXeHigGmycPPE=</span><span>"
</span><span> "</span><span style="color:#a3be8c;"><your cache public key></span><span>"
</span><span> ];
</span><span>
</span><span> </span><span style="color:#d08770;">netrc-file </span><span>= </span><span style="color:#bf616a;">config</span><span>.</span><span style="color:#bf616a;">sops</span><span>.</span><span style="color:#bf616a;">secrets</span><span>."</span><span style="color:#a3be8c;">attic/netrc</span><span>".</span><span style="color:#bf616a;">path</span><span>;
</span><span> };
</span><span> };
</span><span>}
</span></code></pre>
<p>Now, when our NixOS machines are rebuilt:</p>
<ul>
<li>First the official NixOS cache (with a priority of 40) will be checked</li>
<li>Next, the <code>nix-community</code> public cache (with a priority of 41) will be checked</li>
<li>Next, the <code>numtide</code> public cache (with a priority of 42) will be checked</li>
<li>Finally, our private cache (with a priority of 43) will be checked</li>
<li>If there are no cache hits at all, the package will be built from source</li>
</ul>
<p>What we have at this point is pretty damn cool.</p>
<p><strong>In the next article we'll make this even cooler still</strong>, by setting up GitHub
Actions jobs <a href="https://hachyderm.io/@LGUG2Z/111767749455052075">to build each of our NixOS system configurations whenever we push
a new commit, and push the outputs of each those builds to our private <code>system</code>
cache</a>!</p>
<p>If you have any questions or comments you can reach out to me on
<a href="https://twitter.com/JeezyCodes">Twitter</a> and
<a href="https://hachyderm.io/@LGUG2Z">Mastodon</a>.</p>
<p>If you're interested in what I read to come up with solutions like this one,
you can subscribe to my <a href="https://notado.app/feeds/jado/software-development">Software Development RSS
feed</a>.</p>
<p>If you'd like to watch me writing code while explaining what I'm doing, you can
also <a href="https://www.youtube.com/channel/UCeai3-do-9O4MNy9_xjO6mg?sub_confirmation=1">subscribe to my YouTube
channel</a>.</p>
<p>If you found this content valuable, or if you are a happy user of
<a href="https://github.com/LGUG2Z/komorebi"><code>komorebi</code></a> or my <a href="https://github.com/LGUG2Z">NixOS starter
templates</a>, please consider sponsoring me on
<a href="https://github.com/sponsors/LGUG2Z">GitHub</a> or tipping me on
<a href="https://ko-fi.com/lgug2z">Ko-fi</a>.</p>
Cloudflare and NixOS Tips When Deploying a Personal Mastodon ServerSun, 14 Jan 2024 08:43:51 -0800lgug2z
https://lgug2z.com/articles/cloudflare-and-nixos-tips-when-deploying-a-personal-mastodon-server/
https://lgug2z.com/articles/cloudflare-and-nixos-tips-when-deploying-a-personal-mastodon-server/<p>For the most part I feel very much at home on the
<a href="https://hachyderm.io">Hachyderm</a> Mastodon server; it's probably the best
social media experience that I can remember having and I have had the pleasure
of interacting with so many cool and impassioned people there.</p>
<p>Hachyderm implements the default 500 character post limit which is <a href="https://github.com/mastodon/mastodon/pull/27629">hard-coded
into the Mastodon codebase</a>
and as of writing these, seems unlikely to ever be made configurable.</p>
<p>Every now and then, especially when adding summaries to long (1hr+) live
programming videos that I share across the Fediverse, I come up against that
limit.</p>
<p>At the end of last year, I had an idea: Why don't I just self-host my
own Mastodon instance that allows for posts that are longer than 500 chars,
make longer posts on that account, and then boost them from my main account on
Hachyderm?</p>
<p>Sounds easy, right? Well...</p>
<h2 id="using-my-domain"><a class="zola-anchor" href="#using-my-domain" aria-label="Anchor link for: using-my-domain">Using my domain</a></h2>
<p>I wanted to be able to use my current domain so that I could be looked up as
<a href="https://hachyderm.io/@[email protected]">@[email protected]</a>. This is actually
quite well documented and can be done by setting the
<a href="https://docs.joinmastodon.org/admin/config/#web_domain"><code>WEB_DOMAIN</code></a>
environment variable.</p>
<blockquote>
<p><code>WEB_DOMAIN</code> is an optional environment variable allowing the installation of
Mastodon on one domain, while having the users’ handles on a different
domain, e.g. addressing users as <code>@[email protected]</code> but accessing Mastodon
on <code>mastodon.example.com</code>. This may be useful if your domain name is already
used for a different website but you still want to use it as a Mastodon
identifier because it looks better or shorter.</p>
<p>To install Mastodon on <code>mastodon.example.com</code> in such a way it can serve
<code>@[email protected]</code>, set <code>LOCAL_DOMAIN</code> to <code>example.com</code> and <code>WEB_DOMAIN</code> to
<code>mastodon.example.com</code>. This also requires additional configuration on the
server hosting <code>example.com</code> to redirect requests from
<code>https://example.com/.well-known/webfinger</code> to
<code>https://mastodon.example.com/.well-known/webfinger</code>.</p>
</blockquote>
<p>In my case, I set <code>WEB_DOMAIN</code> to <code>social.lgug2z.com</code> and <code>LOCAL_DOMAIN</code> to
<code>lgug2z.com</code>.</p>
<p>However, <code>lgug2z.com</code> currently hosts a Hugo website that is deployed to
<a href="https://pages.cloudflare.com/">Cloudflare Pages</a>.</p>
<p>"No problem", I thought to myself, "I'll just set up a redirect rule."</p>
<p>This was in fact, a big problem, as after hours of trying to debug federation
issues between my new instance and other Mastodon servers, I realized that
<strong>Cloudflare's <a href="https://developers.cloudflare.com/rules/url-forwarding/">Redirect
Rules</a> do nothing on a
URI path where a Cloudflare Pages site is deployed!</strong></p>
<p>It would have been nice if this edge case was <strong>clearly</strong> documented somewhere
by Cloudflare.</p>
<p>My solution for this was to just add a <code>.well-known/webfinger</code> file to the
<code>static</code> folder of my Hugo site and populate it with the JSON payload returned
from
https://social.lgug2z.com/.well-known/webfinger?resource=acct:[email protected].
While this is not a particularly elegant solution, it does the job for a
single-user Mastodon instance.</p>
<h2 id="deploying-a-custom-build-of-mastodon-on-nixos"><a class="zola-anchor" href="#deploying-a-custom-build-of-mastodon-on-nixos" aria-label="Anchor link for: deploying-a-custom-build-of-mastodon-on-nixos">Deploying a custom build of Mastodon on NixOS</a></h2>
<p>As previously mentioned, the 500 character post limit is hard-coded in the
Mastodon codebase.</p>
<p>There is a <a href="https://1010.gitlab.io/posts/customize-mastodon-character-limit/">very detailed
post</a> about
how to change the source code to set a higher character limit that is kept up
to date by <a href="https://fosstodon.org/@chris">@[email protected]</a>, which
helpfully includes git patch files.</p>
<p>I initially tried overriding <code>pkgs.mastodon</code> both directly in the service
definition and via a NixOS overlay to apply this patch, to no avail.</p>
<p>When I tried forking Mastodon and applying the latest patch to the v4.2.3
release, the patch application failed, so I just cut myself a <code>release/v4.2.3</code>
branch on my fork and <a href="https://github.com/LGUG2Z/mastodon/tree/release/v4.2.3">made the changes
there</a>.</p>
<p>I once again set an override, this time for <code>src</code>, to build Mastodon from this
revision on my fork, and managed to get a little bit further. Now, querying
https://social.lgug2z.com/api/v2/instance showed that
<code>configuration.statuses.max_characters</code> was indeed set to my new value of <code>5000</code>,
but this was not reflected in the UI.</p>
<p>Digging around in my generated Caddyfile at <code>/etc/caddy/caddy_config</code>, showed
that although I was referencing <code>cfg.package</code> (instead of <code>pkgs.mastodon</code>) from
my NixOS module when configuring Caddy, I was still being routed to the
frontend files of <code>pkgs.mastodon</code> without my <code>src</code> override.</p>
<p>After a lot of trial, error and GitHub code searches prefixed with <code>lang:nix</code>,
I found <a href="https://github.com/SuperSandro2000/nixos-modules/blob/9166e1b0fb8cac669b09bc2d05e02323e1dcfaa1/modules/mastodon.nix#L38">some
code</a>
by <a href="https://c3d2.social/@sandro">@[email protected]</a> which suggested that in
addition to overriding <code>src</code>, I should also override <code>mastodonModules</code> in order
to ensure that the Mastodon UI would be served not from <code>pkgs.mastodon</code>, but
from my override.</p>
<p>This was indeed the missing piece, and once I also added an override for
<code>mastodonModules.src</code>, I was able to see the updated UI with the new 5000
maximum character post limit!</p>
<h2 id="my-mastodon-nix-module"><a class="zola-anchor" href="#my-mastodon-nix-module" aria-label="Anchor link for: my-mastodon-nix-module">My mastodon.nix module</a></h2>
<p>If you are reading this article because you're trying to achieve something similarap:w
,
here is my <code>mastodon.nix</code> module which uses Caddy as a reverse proxy to serve a
custom build of Mastodon on a subdomain (in my case, <code>social.lgug2z.com</code>) while
allowing your server to show up on the Fediverse as the root domain (in my
case, <code>[email protected]</code>).</p>
<pre data-lang="nix" style="background-color:#2b303b;color:#c0c5ce;" class="language-nix "><code class="language-nix" data-lang="nix"><span>{
</span><span> config,
</span><span> pkgs,
</span><span> domain,
</span><span> ...
</span><span style="color:#8fa1b3;">}</span><span>: </span><span style="color:#b48ead;">let
</span><span> </span><span style="color:#d08770;">cfg </span><span>= </span><span style="color:#bf616a;">config</span><span>.</span><span style="color:#bf616a;">services</span><span>.</span><span style="color:#bf616a;">mastodon</span><span>;
</span><span style="color:#b48ead;">in </span><span>{
</span><span> </span><span style="color:#d08770;">services</span><span>.</span><span style="color:#d08770;">mastodon </span><span>= {
</span><span> </span><span style="color:#d08770;">enable </span><span>= </span><span style="color:#d08770;">true</span><span>;
</span><span> </span><span style="color:#d08770;">package </span><span>= </span><span style="color:#bf616a;">pkgs</span><span>.</span><span style="color:#bf616a;">mastodon</span><span>.</span><span style="color:#bf616a;">overrideAttrs </span><span>(_: </span><span style="color:#b48ead;">let
</span><span> </span><span style="color:#d08770;">pname </span><span>= "</span><span style="color:#a3be8c;">mastodon-lgug2z</span><span>";
</span><span> </span><span style="color:#d08770;">src </span><span>= </span><span style="color:#bf616a;">pkgs</span><span>.</span><span style="color:#bf616a;">fetchFromGitHub </span><span>{
</span><span> </span><span style="color:#d08770;">owner </span><span>= "</span><span style="color:#a3be8c;">LGUG2Z</span><span>";
</span><span> </span><span style="color:#d08770;">repo </span><span>= "</span><span style="color:#a3be8c;">mastodon</span><span>";
</span><span> </span><span style="color:#65737e;"># forked from v4.2.3 with max chars set to 5000
</span><span> </span><span style="color:#d08770;">rev </span><span>= "</span><span style="color:#a3be8c;">b24adb69fa41a580fa2781a44661b7b707e3f765</span><span>";
</span><span> </span><span style="color:#d08770;">hash </span><span>= "</span><span style="color:#a3be8c;">sha256-BvcvUAcIW5lYT1gTrKsIVIbmDQpAE3KxOiLLWoUtYhw=</span><span>";
</span><span> };
</span><span> </span><span style="color:#b48ead;">in </span><span>{
</span><span> </span><span style="color:#b48ead;">inherit </span><span style="color:#d08770;">src pname</span><span>;
</span><span> </span><span style="color:#d08770;">mastodonModules </span><span>= </span><span style="color:#bf616a;">pkgs</span><span>.</span><span style="color:#bf616a;">mastodon</span><span>.</span><span style="color:#bf616a;">mastodonModules</span><span>.</span><span style="color:#bf616a;">overrideAttrs </span><span>(_: {
</span><span> </span><span style="color:#b48ead;">inherit </span><span style="color:#d08770;">src</span><span>;
</span><span> </span><span style="color:#d08770;">pname </span><span>= "</span><span style="font-style:italic;color:#ab7967;">${</span><span style="font-style:italic;color:#bf616a;">pname</span><span style="font-style:italic;color:#ab7967;">}</span><span style="color:#a3be8c;">-modules</span><span>";
</span><span> });
</span><span> });
</span><span>
</span><span> </span><span style="color:#d08770;">localDomain </span><span>= "</span><span style="font-style:italic;color:#ab7967;">${</span><span style="font-style:italic;color:#bf616a;">domain</span><span style="font-style:italic;color:#ab7967;">}</span><span>";
</span><span> </span><span style="color:#d08770;">extraConfig </span><span>= {
</span><span> </span><span style="color:#d08770;">WEB_DOMAIN </span><span>= "</span><span style="color:#a3be8c;">social.</span><span style="font-style:italic;color:#ab7967;">${</span><span style="font-style:italic;color:#bf616a;">domain</span><span style="font-style:italic;color:#ab7967;">}</span><span>";
</span><span> </span><span style="color:#d08770;">SINGLE_USER_MODE </span><span>= "</span><span style="color:#a3be8c;">true</span><span>";
</span><span> };
</span><span> </span><span style="color:#d08770;">configureNginx </span><span>= </span><span style="color:#d08770;">false</span><span>;
</span><span> </span><span style="color:#d08770;">smtp</span><span>.</span><span style="color:#d08770;">fromAddress </span><span>= "";
</span><span> </span><span style="color:#d08770;">streamingProcesses </span><span>= </span><span style="color:#d08770;">1</span><span>;
</span><span> };
</span><span>
</span><span> </span><span style="color:#d08770;">networking</span><span>.</span><span style="color:#d08770;">firewall</span><span>.</span><span style="color:#d08770;">allowedTCPPorts </span><span>= [</span><span style="color:#d08770;">80 443</span><span>];
</span><span>
</span><span> </span><span style="color:#d08770;">users</span><span>.</span><span style="color:#d08770;">users</span><span>.</span><span style="color:#d08770;">caddy</span><span>.</span><span style="color:#d08770;">extraGroups </span><span>= ["</span><span style="color:#a3be8c;">mastodon</span><span>"];
</span><span> </span><span style="color:#d08770;">systemd</span><span>.</span><span style="color:#d08770;">services</span><span>.</span><span style="color:#d08770;">caddy</span><span>.</span><span style="color:#d08770;">serviceConfig</span><span>.</span><span style="color:#d08770;">ReadWriteDirectories </span><span>= </span><span style="color:#bf616a;">pkgs</span><span>.</span><span style="color:#bf616a;">lib</span><span>.</span><span style="color:#bf616a;">mkForce </span><span>["</span><span style="color:#a3be8c;">/var/lib/caddy</span><span>" "</span><span style="color:#a3be8c;">/run/mastodon-web</span><span>"];
</span><span>
</span><span> </span><span style="color:#d08770;">services</span><span>.</span><span style="color:#d08770;">caddy </span><span>= {
</span><span> </span><span style="color:#d08770;">enable </span><span>= </span><span style="color:#d08770;">true</span><span>;
</span><span> </span><span style="color:#d08770;">virtualHosts</span><span>."</span><span style="font-style:italic;color:#ab7967;">${</span><span style="font-style:italic;color:#bf616a;">cfg</span><span style="font-style:italic;color:#c0c5ce;">.</span><span style="font-style:italic;color:#bf616a;">extraConfig</span><span style="font-style:italic;color:#c0c5ce;">.</span><span style="font-style:italic;color:#bf616a;">WEB_DOMAIN</span><span style="font-style:italic;color:#ab7967;">}</span><span>".</span><span style="color:#d08770;">extraConfig </span><span>= ''
</span><span style="color:#a3be8c;"> handle_path /system/* {
</span><span style="color:#a3be8c;"> file_server * {
</span><span style="color:#a3be8c;"> root /var/lib/mastodon/public-system
</span><span style="color:#a3be8c;"> }
</span><span style="color:#a3be8c;"> }
</span><span style="color:#a3be8c;">
</span><span style="color:#a3be8c;"> handle /api/v1/streaming/* {
</span><span style="color:#a3be8c;"> reverse_proxy unix//run/mastodon-streaming/streaming.socket
</span><span style="color:#a3be8c;"> }
</span><span style="color:#a3be8c;">
</span><span style="color:#a3be8c;"> route * {
</span><span style="color:#a3be8c;"> file_server * {
</span><span style="color:#a3be8c;"> root </span><span style="font-style:italic;color:#ab7967;">${</span><span style="font-style:italic;color:#bf616a;">cfg</span><span style="font-style:italic;color:#c0c5ce;">.</span><span style="font-style:italic;color:#bf616a;">package</span><span style="font-style:italic;color:#ab7967;">}</span><span style="color:#a3be8c;">/public
</span><span style="color:#a3be8c;"> pass_thru
</span><span style="color:#a3be8c;"> }
</span><span style="color:#a3be8c;"> reverse_proxy * unix//run/mastodon-web/web.socket
</span><span style="color:#a3be8c;"> }
</span><span style="color:#a3be8c;">
</span><span style="color:#a3be8c;"> handle_errors {
</span><span style="color:#a3be8c;"> root * </span><span style="font-style:italic;color:#ab7967;">${</span><span style="font-style:italic;color:#bf616a;">cfg</span><span style="font-style:italic;color:#c0c5ce;">.</span><span style="font-style:italic;color:#bf616a;">package</span><span style="font-style:italic;color:#ab7967;">}</span><span style="color:#a3be8c;">/public
</span><span style="color:#a3be8c;"> rewrite 500.html
</span><span style="color:#a3be8c;"> file_server
</span><span style="color:#a3be8c;"> }
</span><span style="color:#a3be8c;">
</span><span style="color:#a3be8c;"> encode gzip
</span><span style="color:#a3be8c;">
</span><span style="color:#a3be8c;"> header /* {
</span><span style="color:#a3be8c;"> Strict-Transport-Security "max-age=31536000;"
</span><span style="color:#a3be8c;"> }
</span><span style="color:#a3be8c;">
</span><span style="color:#a3be8c;"> header /emoji/* Cache-Control "public, max-age=31536000, immutable"
</span><span style="color:#a3be8c;"> header /packs/* Cache-Control "public, max-age=31536000, immutable"
</span><span style="color:#a3be8c;"> header /system/accounts/avatars/* Cache-Control "public, max-age=31536000, immutable"
</span><span style="color:#a3be8c;"> header /system/media_attachments/files/* Cache-Control "public, max-age=31536000, immutable"
</span><span style="color:#a3be8c;"> </span><span>'';
</span><span> };
</span><span>}
</span></code></pre>
<p>If you have any questions or comments you can reach out to me on
<a href="https://twitter.com/JeezyCodes">Twitter</a> and
<a href="https://hachyderm.io/@LGUG2Z">Mastodon</a>.</p>
<p>If you're interested in what I read to come up with solutions like this one,
you can subscribe to my <a href="https://notado.app/feeds/jado/software-development">Software Development RSS
feed</a>.</p>
<p>If you'd like to watch me writing code while explaining what I'm doing, you can
also <a href="https://www.youtube.com/channel/UCeai3-do-9O4MNy9_xjO6mg?sub_confirmation=1">subscribe to my YouTube
channel</a>.</p>
<p>If you found this content valuable, or if you are a happy user of
<a href="https://github.com/LGUG2Z/komorebi"><code>komorebi</code></a> or my <a href="https://github.com/LGUG2Z">NixOS starter
templates</a>, please consider sponsoring me on
<a href="https://github.com/sponsors/LGUG2Z">GitHub</a> or tipping me on
<a href="https://ko-fi.com/lgug2z">Ko-fi</a>.</p>
Unemployment in the USA as a Recent ImmigrantFri, 12 Jan 2024 08:51:27 -0800lgug2z
https://lgug2z.com/articles/unemployment-in-the-usa-as-a-recent-immigrant/
https://lgug2z.com/articles/unemployment-in-the-usa-as-a-recent-immigrant/<h1 id="the-layoff"><a class="zola-anchor" href="#the-layoff" aria-label="Anchor link for: the-layoff">The Layoff</a></h1>
<p>Along with 25% of my former colleagues, I was laid off by
<a href="https://beamery.com">Beamery</a> <a href="https://www.glassdoor.com/Reviews/Beamery-Reviews-E1427417.htm?sort.sortType=OR&sort.ascending=true">(glassdoor reviews)</a>
immediately before "American Thanksgiving".</p>
<p>I was provided with a number of informational pamphlets at the end of a
5-minute layoff call at 9am which was scheduled with less than 15 minutes of
notice.</p>
<p>The following Wednesday, I was at the neighborhood social dance, trying to take
my mind off the stress of job hunting in Q4 and interview preparation with some
live music.</p>
<p>One of the great things about finding hobbies and building community outside of
tech is that it extends your social circle to include so many lovely people
from different backgrounds and walks of life.</p>
<h1 id="the-research"><a class="zola-anchor" href="#the-research" aria-label="Anchor link for: the-research">The Research</a></h1>
<p>I got talking to someone, also an immigrant, who told me that they had recently
learned that legal permanent residents in the US can apply for unemployment
benefits without jeopardizing their immigration status.</p>
<p>Until this point I had not thought about claiming unemployment benefits; I had
never claimed them at any point in my life, in any country I had lived in. I
realized that I had somehow along the way allowed myself to be convinced that
even if I wanted to apply, I wouldn't be eligible because I am not a citizen.</p>
<p>Upon further reading, I learned that unemployment:</p>
<ul>
<li>Is considered an "earned benefit" by the USCIS, which excludes it from
<a href="https://www.federalregister.gov/documents/2022/09/09/2022-18867/public-charge-ground-of-inadmissibility">"public
charge"</a>
considerations</li>
<li>Is funded by employers who pay <a href="https://www.irs.gov/taxtopics/tc759">Federal Unemployment
Tax</a> (FUTA) and <a href="https://esd.wa.gov/employer-taxes">State Unemployment
Tax</a> (SUTA)</li>
<li><a href="https://esd.wa.gov/unemployment/eligibility-and-general-questions">Can be claimed</a> even by laid-off workers who have signed severance agreements</li>
</ul>
<p>None of the informational pamphlets provided to me by Beamery mentioned any of
this information.</p>
<p>Why? It turns out that the rate at which employers have to pay FUTA and SUTA
can increase if the number of workers who have been laid off by an employer
claiming unemployment increases.</p>
<p>Naturally, I reached out to as many as my former colleagues in the US as
possible to share this information, as it seems like many US citizens are also
a little hazy on the specifics of unemployment eligibility.</p>
<h1 id="the-experience"><a class="zola-anchor" href="#the-experience" aria-label="Anchor link for: the-experience">The Experience</a></h1>
<p>I was fortunate to have secured multiple offers within a month of being laid
off. I accepted and signed one of those offers last week. So how does this work
with unemployment?</p>
<p>Well, in WA, you are required to file a weekly claim online demonstrating that
you have been looking for work and have applied for 3 jobs in the past week.
This was easy enough for me to do at the end of 2023 as I was applying and
interviewing actively.</p>
<p>I received an email last week information me that I had to attend a
"Reemployment Services and Eligibility Assessment" appointment with a
"WorkSource Specialist" within ~2 weeks or I would be required to pay back all
unemployment benefits I had received to date.</p>
<p>I scheduled the call and attended this week, taking the opportunity to inform
the Employment Security Department employee I met with that I had accepted a
job offer, was going through the background check and was due to start working
again next month.</p>
<p>It turns out that there is no procedure in place for what I must assume is at
least a somewhat common situation of the start date of a new job being a few
weeks in the future.</p>
<p>I was informed that I need to continue applying for at least 3 jobs every week
in order to be able to file the online weekly claims. And now I need to attend
another one of these meetings a week before I start working to show that I have
looked into Labor Market Information about my career path and used jobscan.co
to scan my resume for ATS-compatibility.</p>
<p>I wonder how many people are left in a situation where they have to send out
what I can only characterize as "bogus" job applications multiple times a week
to continue receiving unemployment income until their official job start date,
and how much of everyone's time gets wasted in this process.</p>
Circumventing Network Bans with WireGuardTue, 09 Jan 2024 15:06:54 -0800lgug2z
https://lgug2z.com/articles/circumventing-network-bans-with-wireguard/
https://lgug2z.com/articles/circumventing-network-bans-with-wireguard/<p>Before this week, it had been a long time since I visited the <a href="https://old.reddit.com/r/PleX">Plex
subreddit</a>.</p>
<p>I shared my <a href="https://lgug2z.com/articles/rethinking-plex-hosting-after-the-hetzner-ban/">last
article</a>
there, which was a technical write-up of moving my Plex instance from a Hetzner
auction server to a virtual machine running on hardware in my home network, and
the considerations that influenced the migration.</p>
<p>It didn't take long for me to realize that a culture of hostility towards even
the <em>mention</em> of Hetzner or other cloud hosting providers has strongly taken
root since Plex announced it's blanket network ban on IP ranges associated with
Hetzner data centers.</p>
<p>I saw many posts and comments of users asking about issues with their Plex
instances that had for years been working without issue on Hetzner servers
until this past October when Plex enacted their very poorly communicated
network ban, which hit a significant number of customers like myself who had
paid for a lifetime Plex Pass.</p>
<p>Although I myself am not pursuing this option for reasons outlined in my last
article, I wanted to share a clear and detailed example of how to circumvent
Plex's ban on IPs originating from Hetzner data centers (because gatekeeping is
for losers).</p>
<h1 id="wireguard-vpn-connection-details"><a class="zola-anchor" href="#wireguard-vpn-connection-details" aria-label="Anchor link for: wireguard-vpn-connection-details">WireGuard VPN Connection Details</a></h1>
<p>This can work with any WireGuard VPN provider (even with your own WireGuard
server on another machine!) but for the sake of simplicity I have chosen to use
<a href="https://mullvad.net">Mullvad</a> as the reference in this tutorial.</p>
<ul>
<li>Go to <a href="https://mullvad.net">mullvad.net</a> and open an account
<ul>
<li>This is actually a very cool process; no email, no details, they just
provide you a secret account ID</li>
</ul>
</li>
<li>Once you have an account, navigate to <a href="https://mullvad.net/en/account">"Add time to your
account"</a>
<ul>
<li>You can just add 1 month of time for 5 EUR if you want to try out the quality of their service</li>
</ul>
</li>
<li>Head over to the <a href="https://mullvad.net/en/account/wireguard-config">WireGuard Configuration</a> page
<ul>
<li>Select "Linux" and then hit "Generate key"</li>
<li>Select a Country, City and Server for your exit location (bottom of the page)</li>
<li>Scroll down a little more to hit "Download file" and get your authentication details
<ul>
<li>You'll only be able to do this once! Make sure you do it before you navigate away</li>
</ul>
</li>
</ul>
</li>
</ul>
<p>At this point you'll have a <code>.conf</code> file containing the fields
<code>Interface.PrivateKey</code> and <code>Interface.Address</code> which you'll need later.</p>
<h1 id="server-configuration"><a class="zola-anchor" href="#server-configuration" aria-label="Anchor link for: server-configuration">Server Configuration</a></h1>
<p>Below is a fully annotated NixOS server configuration which sets some sane
server defaults, configures SSH access, firewall rules, and brings up a Plex
container which sends all outgoing requests through a WireGuard VPN using your
new connection details.</p>
<p>This server configuration does not include hardware configuration, which is
naturally prone to variation, especially on auction servers, however it should
not be too difficult to adapt my
<a href="https://github.com/LGUG2Z/nixos-hetzner-robot-starter"><code>nixos-hetzner-robot-starter</code></a>
template (<a href="https://www.youtube.com/watch?v=nlX8g0NXW1M">video walkthrough</a>) to
work with your server's hardware.</p>
<pre data-lang="nix" style="background-color:#2b303b;color:#c0c5ce;" class="language-nix "><code class="language-nix" data-lang="nix"><span style="color:#8fa1b3;">{</span><span>config</span><span style="color:#8fa1b3;">}</span><span>: </span><span style="color:#b48ead;">let
</span><span> </span><span style="color:#65737e;"># These are helper functions to look up uids and gids
</span><span> </span><span style="color:#65737e;"># which take a single string argument
</span><span> </span><span style="color:#65737e;">#
</span><span> </span><span style="color:#65737e;"># eg. uid "samira" -> returns the uid for the "samira" user
</span><span> </span><span style="color:#d08770;">uid </span><span>= username:
</span><span> </span><span style="color:#b48ead;">if </span><span style="color:#bf616a;">config</span><span>.</span><span style="color:#bf616a;">users</span><span>.</span><span style="color:#bf616a;">users</span><span>.</span><span style="font-style:italic;color:#ab7967;">${</span><span style="font-style:italic;color:#bf616a;">username</span><span style="font-style:italic;color:#ab7967;">}</span><span>.</span><span style="color:#bf616a;">uid </span><span>== </span><span style="color:#d08770;">null
</span><span> </span><span style="color:#b48ead;">then </span><span>"</span><span style="color:#a3be8c;">1000</span><span>"
</span><span> </span><span style="color:#b48ead;">else </span><span style="color:#96b5b4;">toString </span><span style="color:#bf616a;">config</span><span>.</span><span style="color:#bf616a;">users</span><span>.</span><span style="color:#bf616a;">users</span><span>.</span><span style="font-style:italic;color:#ab7967;">${</span><span style="font-style:italic;color:#bf616a;">username</span><span style="font-style:italic;color:#ab7967;">}</span><span>.</span><span style="color:#bf616a;">uid</span><span>;
</span><span>
</span><span> </span><span style="color:#65737e;"># eg. gid "users" -> returns the gid for the "users" group
</span><span> </span><span style="color:#d08770;">gid </span><span>= group:
</span><span> </span><span style="color:#b48ead;">if </span><span style="color:#bf616a;">config</span><span>.</span><span style="color:#bf616a;">users</span><span>.</span><span style="color:#bf616a;">groups</span><span>.</span><span style="font-style:italic;color:#ab7967;">${</span><span style="font-style:italic;color:#bf616a;">group</span><span style="font-style:italic;color:#ab7967;">}</span><span>.</span><span style="color:#bf616a;">gid </span><span>== </span><span style="color:#d08770;">null
</span><span> </span><span style="color:#b48ead;">then </span><span>"</span><span style="color:#a3be8c;">100</span><span>"
</span><span> </span><span style="color:#b48ead;">else </span><span style="color:#96b5b4;">toString </span><span style="color:#bf616a;">config</span><span>.</span><span style="color:#bf616a;">users</span><span>.</span><span style="color:#bf616a;">groups</span><span>.</span><span style="font-style:italic;color:#ab7967;">${</span><span style="font-style:italic;color:#bf616a;">group</span><span style="font-style:italic;color:#ab7967;">}</span><span>.</span><span style="color:#bf616a;">gid</span><span>;
</span><span>
</span><span> </span><span style="color:#65737e;"># </span><span style="font-weight:bold;color:#ebcb8b;">FIXME</span><span style="color:#65737e;">: Set your username for this server
</span><span> </span><span style="color:#d08770;">username </span><span>= "</span><span style="color:#a3be8c;"><YOUR PREFERRED USERNAME></span><span>";
</span><span>
</span><span> </span><span style="color:#65737e;"># </span><span style="font-weight:bold;color:#ebcb8b;">FIXME</span><span style="color:#65737e;">: Set this or you won't be able to SSH
</span><span> </span><span style="color:#d08770;">publicKeys </span><span>= [
</span><span> "</span><span style="color:#a3be8c;"><YOUR PUBLIC KEY></span><span>"
</span><span> "</span><span style="color:#a3be8c;"><OPTIONALLY ANY OTHER PUBLIC KEYS OF YOURS></span><span>"
</span><span> ];
</span><span style="color:#b48ead;">in </span><span>{
</span><span> </span><span style="color:#65737e;"># This stops Docker interference with dhcp
</span><span> </span><span style="color:#d08770;">networking</span><span>.</span><span style="color:#d08770;">dhcpcd</span><span>.</span><span style="color:#d08770;">denyInterfaces </span><span>= ["</span><span style="color:#a3be8c;">veth*</span><span>"];
</span><span>
</span><span> </span><span style="color:#65737e;"># This allows incoming connections on port 32400 for Plex
</span><span> </span><span style="color:#d08770;">networking</span><span>.</span><span style="color:#d08770;">firewall</span><span>.</span><span style="color:#d08770;">allowedTCPPorts </span><span>= [</span><span style="color:#d08770;">32400</span><span>];
</span><span>
</span><span> </span><span style="color:#65737e;"># This sets the hostname of the server, this can be anything you like
</span><span> </span><span style="color:#d08770;">networking</span><span>.</span><span style="color:#d08770;">hostName </span><span>= "</span><span style="color:#a3be8c;">plex-on-hetz</span><span>";
</span><span>
</span><span> </span><span style="color:#65737e;"># This is so that users in the "wheel" group don't need to
</span><span> </span><span style="color:#65737e;"># enter their password for sudo commands
</span><span> </span><span style="color:#d08770;">security</span><span>.</span><span style="color:#d08770;">sudo</span><span>.</span><span style="color:#d08770;">wheelNeedsPassword </span><span>= </span><span style="color:#d08770;">false</span><span>;
</span><span>
</span><span> </span><span style="color:#65737e;"># This enables SSH access to the server and only allows
</span><span> </span><span style="color:#65737e;"># root SSH connections with SSH keys, never with passwords
</span><span> </span><span style="color:#d08770;">services</span><span>.</span><span style="color:#d08770;">openssh </span><span>= {
</span><span> </span><span style="color:#d08770;">enable </span><span>= </span><span style="color:#d08770;">true</span><span>;
</span><span> </span><span style="color:#d08770;">settings</span><span>.</span><span style="color:#d08770;">PermitRootLogin </span><span>= "</span><span style="color:#a3be8c;">prohibit-password</span><span>";
</span><span> };
</span><span>
</span><span> </span><span style="color:#65737e;"># This sets the SSH public key(s) you can use to connect
</span><span> </span><span style="color:#65737e;"># to the server with the "root" user
</span><span> </span><span style="color:#d08770;">users</span><span>.</span><span style="color:#d08770;">users</span><span>.</span><span style="color:#d08770;">root</span><span>.</span><span style="color:#d08770;">openssh</span><span>.</span><span style="color:#d08770;">authorizedKeys</span><span>.</span><span style="color:#d08770;">keys </span><span>= </span><span style="color:#bf616a;">publicKeys</span><span>;
</span><span>
</span><span> </span><span style="color:#65737e;"># This creates your user on the server
</span><span> </span><span style="color:#d08770;">users</span><span>.</span><span style="color:#d08770;">users</span><span>.</span><span style="font-style:italic;color:#ab7967;">${</span><span style="font-style:italic;color:#bf616a;">username</span><span style="font-style:italic;color:#ab7967;">} </span><span>= {
</span><span> </span><span style="color:#65737e;"># This specifies that you are a user that gets home directory
</span><span> </span><span style="color:#d08770;">isNormalUser </span><span>= </span><span style="color:#d08770;">true</span><span>;
</span><span> </span><span style="color:#65737e;"># This adds your user to the "wheel" and "docker" groups
</span><span> </span><span style="color:#65737e;"># In the "wheel" group, you don't need to use your password for sudo
</span><span> </span><span style="color:#65737e;"># In the "docker" group, you don't need to use sudo for docker commands
</span><span> </span><span style="color:#d08770;">extraGroups </span><span>= ["</span><span style="color:#a3be8c;">wheel</span><span>" "</span><span style="color:#a3be8c;">docker</span><span>"];
</span><span>
</span><span> </span><span style="color:#65737e;"># This sets the SSH public key(s) you can use to connect
</span><span> </span><span style="color:#65737e;"># to the server with your user
</span><span> </span><span style="color:#d08770;">openssh</span><span>.</span><span style="color:#d08770;">authorizedKeys</span><span>.</span><span style="color:#d08770;">keys </span><span>= </span><span style="color:#bf616a;">publicKeys</span><span>;
</span><span> };
</span><span>
</span><span> </span><span style="color:#65737e;"># This is a service that bans hosts and IPs that produce authentication
</span><span> </span><span style="color:#65737e;"># errors when trying to SSH multiple times in quick succession
</span><span> </span><span style="color:#d08770;">services</span><span>.</span><span style="color:#d08770;">fail2ban </span><span>= {
</span><span> </span><span style="color:#d08770;">enable </span><span>= </span><span style="color:#d08770;">true</span><span>;
</span><span> </span><span style="color:#65737e;"># </span><span style="font-weight:bold;color:#ebcb8b;">FIXME</span><span style="color:#65737e;">: Add this so you don't get locked out by mistake
</span><span> </span><span style="color:#d08770;">ignoreIP </span><span>= ["</span><span style="color:#a3be8c;"><YOUR HOME IP ADDRESS></span><span>"];
</span><span> };
</span><span>
</span><span> </span><span style="color:#65737e;"># This enables Docker, makes sure it runs when the server starts
</span><span> </span><span style="color:#65737e;"># and automatically prunes dangling resources to keep space free
</span><span> </span><span style="color:#d08770;">virtualisation</span><span>.</span><span style="color:#d08770;">docker </span><span>= {
</span><span> </span><span style="color:#d08770;">enable </span><span>= </span><span style="color:#d08770;">true</span><span>;
</span><span> </span><span style="color:#d08770;">enableOnBoot </span><span>= </span><span style="color:#d08770;">true</span><span>;
</span><span> </span><span style="color:#d08770;">autoPrune</span><span>.</span><span style="color:#d08770;">enable </span><span>= </span><span style="color:#d08770;">true</span><span>;
</span><span> };
</span><span>
</span><span> </span><span style="color:#65737e;"># This is where you can add Docker containers, you can think of this
</span><span> </span><span style="color:#65737e;"># block as being conceptually similar to docker-compose in some ways
</span><span> </span><span style="color:#d08770;">virtualisation</span><span>.</span><span style="color:#d08770;">oci-containers </span><span>= {
</span><span> </span><span style="color:#d08770;">backend </span><span>= "</span><span style="color:#a3be8c;">docker</span><span>";
</span><span>
</span><span> </span><span style="color:#65737e;"># This is the gluetun container: https://github.com/qdm12/gluetun
</span><span> </span><span style="color:#65737e;"># gluetun is a thin docker container for multiple VPN providers that
</span><span> </span><span style="color:#65737e;"># supports WireGuard
</span><span> </span><span style="color:#d08770;">containers</span><span>.</span><span style="color:#d08770;">gluetun </span><span>= {
</span><span> </span><span style="color:#65737e;"># This is so that the container starts automatically after server reboots
</span><span> </span><span style="color:#d08770;">autoStart </span><span>= </span><span style="color:#d08770;">true</span><span>;
</span><span> </span><span style="color:#d08770;">image </span><span>= "</span><span style="color:#a3be8c;">qmcgaw/gluetun:v3.37.0</span><span>";
</span><span> </span><span style="color:#65737e;"># This is so that we can access Plex on http://localhost:32400
</span><span> </span><span style="color:#65737e;"># from the server, ie. outside of the container
</span><span> </span><span style="color:#d08770;">ports </span><span>= [
</span><span> "</span><span style="color:#a3be8c;">32400:32400</span><span>"
</span><span> ];
</span><span> </span><span style="color:#65737e;"># This is where you enter authentication information you get from Mullvad
</span><span> </span><span style="color:#d08770;">environment </span><span>= {
</span><span> </span><span style="color:#d08770;">VPN_SERVICE_PROVIDER </span><span>= "</span><span style="color:#a3be8c;">mullvad</span><span>";
</span><span> </span><span style="color:#d08770;">VPN_TYPE </span><span>= "</span><span style="color:#a3be8c;">wireguard</span><span>";
</span><span> </span><span style="color:#65737e;"># </span><span style="font-weight:bold;color:#ebcb8b;">FIXME</span><span style="color:#65737e;">: Don't forget to add your connection details here!
</span><span> </span><span style="color:#d08770;">WIREGUARD_PRIVATE_KEY </span><span>= "</span><span style="color:#a3be8c;"><Interface.PrivateKey in your downloaded conf file></span><span>";
</span><span> </span><span style="color:#d08770;">WIREGUARD_ADDRESSES </span><span>= "</span><span style="color:#a3be8c;"><Interface.Address in your downloaded conf file></span><span>";
</span><span> };
</span><span> </span><span style="color:#65737e;"># This capability is required by gluetun
</span><span> </span><span style="color:#d08770;">extraOptions </span><span>= [
</span><span> "</span><span style="color:#a3be8c;">--cap-add=NET_ADMIN</span><span>"
</span><span> ];
</span><span> };
</span><span>
</span><span> </span><span style="color:#65737e;"># This is the Plex container
</span><span> </span><span style="color:#d08770;">containers</span><span>.</span><span style="color:#d08770;">plex </span><span>= {
</span><span> </span><span style="color:#65737e;"># This is so that the container starts automatically after server reboots
</span><span> </span><span style="color:#d08770;">autoStart </span><span>= </span><span style="color:#d08770;">true</span><span>;
</span><span> </span><span style="color:#d08770;">image </span><span>= "</span><span style="color:#a3be8c;">plexinc/pms-docker:1.32.8.7639-fb6452ebf</span><span>";
</span><span> </span><span style="color:#65737e;"># This is so that the `plex` user inside of the container has the same
</span><span> </span><span style="color:#65737e;"># UID and GID as your user to avoid permissions issues with any directories
</span><span> </span><span style="color:#65737e;"># that are mounted
</span><span> </span><span style="color:#d08770;">environment </span><span>= {
</span><span> </span><span style="color:#d08770;">PLEX_UID </span><span>= </span><span style="color:#bf616a;">uid username</span><span>;
</span><span> </span><span style="color:#d08770;">PLEX_GID </span><span>= </span><span style="color:#bf616a;">gid </span><span>"</span><span style="color:#a3be8c;">users</span><span>";
</span><span> };
</span><span> </span><span style="color:#d08770;">volumes </span><span>= [
</span><span> </span><span style="color:#65737e;"># This is so that the configuration can be persisted outside of the container
</span><span> </span><span style="color:#65737e;"># in the ${HOME}/plex directory on the server
</span><span> </span><span style="color:#65737e;">#
</span><span> </span><span style="color:#65737e;"># NOTE: If you are migrating an instance started with `services.plex.enable` you
</span><span> </span><span style="color:#65737e;"># will need to set this as
</span><span> </span><span style="color:#65737e;"># "/path/to/your/plex-nixos/config/dir:/config/Library/Application\ Support"
</span><span> "</span><span style="color:#a3be8c;">/home/</span><span style="font-style:italic;color:#ab7967;">${</span><span style="font-style:italic;color:#bf616a;">username</span><span style="font-style:italic;color:#ab7967;">}</span><span style="color:#a3be8c;">/plex:/config</span><span>"
</span><span> </span><span style="color:#65737e;"># This is where you share your media files that are under your user account
</span><span> </span><span style="color:#65737e;"># on the server with the Plex container so they can be seen, indexed and played
</span><span> </span><span style="color:#65737e;">#
</span><span> </span><span style="color:#65737e;"># NOTE: If you are migrating an instance started with `services.plex.enable` you
</span><span> </span><span style="color:#65737e;"># will need to make sure the paths inside the container (on the right hand side of the :)
</span><span> </span><span style="color:#65737e;"># match the paths that your Plex instance running as a NixOS service reference
</span><span> </span><span style="color:#65737e;">#
</span><span> </span><span style="color:#65737e;"># </span><span style="font-weight:bold;color:#ebcb8b;">TODO</span><span style="color:#65737e;">: Whatever makes sense for you
</span><span> "</span><span style="color:#a3be8c;">/home/</span><span style="font-style:italic;color:#ab7967;">${</span><span style="font-style:italic;color:#bf616a;">username</span><span style="font-style:italic;color:#ab7967;">}</span><span style="color:#a3be8c;">/path/to/tv:/data/tv</span><span>"
</span><span> "</span><span style="color:#a3be8c;">/home/</span><span style="font-style:italic;color:#ab7967;">${</span><span style="font-style:italic;color:#bf616a;">username</span><span style="font-style:italic;color:#ab7967;">}</span><span style="color:#a3be8c;">/path/to/movies:/data/movies</span><span>"
</span><span> "</span><span style="color:#a3be8c;">/home/</span><span style="font-style:italic;color:#ab7967;">${</span><span style="font-style:italic;color:#bf616a;">username</span><span style="font-style:italic;color:#ab7967;">}</span><span style="color:#a3be8c;">/path/to/music:/data/music</span><span>"
</span><span> ];
</span><span> </span><span style="color:#65737e;"># This is to make sure that this container won't start until the `gluetun` container
</span><span> </span><span style="color:#65737e;"># has started and is healthy
</span><span> </span><span style="color:#d08770;">dependsOn </span><span>= ["</span><span style="color:#a3be8c;">gluetun</span><span>"];
</span><span> </span><span style="color:#65737e;"># This is to make sure that network requests from this container go through the Mullvad
</span><span> </span><span style="color:#65737e;"># WireGuard VPN running in the `gluetun` container
</span><span> </span><span style="color:#65737e;">#
</span><span> </span><span style="color:#65737e;"># This is the key part that allows us to circumvent Plex's Hetzner network ban, because
</span><span> </span><span style="color:#65737e;"># it ensures that requests to https://*.plex.tv endpoints look like they are coming
</span><span> </span><span style="color:#65737e;"># from whichever WireGuard server we are connected to in the `gluetun` container
</span><span> </span><span style="color:#65737e;">#
</span><span> </span><span style="color:#65737e;"># This setup also ensures that other processes running on the server can continue
</span><span> </span><span style="color:#65737e;"># sending outgoing HTTP requests normally without going through the VPN
</span><span> </span><span style="color:#d08770;">extraOptions </span><span>= [
</span><span> "</span><span style="color:#a3be8c;">--network=container:gluetun</span><span>"
</span><span> ];
</span><span> };
</span><span> };
</span><span>}
</span></code></pre>
<p>If you have any questions or comments you can reach out to me on
<a href="https://twitter.com/JeezyCodes">Twitter</a> and
<a href="https://hachyderm.io/@LGUG2Z">Mastodon</a>.</p>
<p>If you're interested in what I read to come up with solutions like this one,
you can subscribe to my <a href="https://notado.app/feeds/jado/software-development">Software Development RSS
feed</a>.</p>
<p>If you'd like to watch me writing code while explaining what I'm doing, you can
also <a href="https://www.youtube.com/channel/UCeai3-do-9O4MNy9_xjO6mg?sub_confirmation=1">subscribe to my YouTube
channel</a>.</p>
<p>If you found this content valuable, or if you are a happy user of
<a href="https://github.com/LGUG2Z/komorebi"><code>komorebi</code></a> or my <a href="https://github.com/LGUG2Z">NixOS starter
templates</a>, please consider sponsoring me on
<a href="https://github.com/sponsors/LGUG2Z">GitHub</a> or tipping me on
<a href="https://ko-fi.com/lgug2z">Ko-fi</a>.</p>
<p><strong>Mullvad</strong> and <strong>Hetzner</strong>: <a href="mailto:[email protected]">Please feel free</a> to give me some free VPN time /
compute power for all this free positive PR ;)</p>
Rethinking Plex Hosting After the Hetzner BanMon, 08 Jan 2024 14:59:14 -0800lgug2z
https://lgug2z.com/articles/rethinking-plex-hosting-after-the-hetzner-ban/
https://lgug2z.com/articles/rethinking-plex-hosting-after-the-hetzner-ban/<p>Last October, Plex started <a href="https://links.plex.tv/e/evib?_t=3bef3c50f13f4320a9e31b8be79c6ad2&_m=df59a9eb59db44d2a0dbdbd0ebb61ee5&_e=xDXT7pYRisN-4z7uioydETDUiISVYQQUM0Y_a8MKNPZP6DN9yUpJaWCmlnGMyQQb">blocking access to instances running on servers
hosted by
Hetzner</a>.</p>
<p>I have a Hetzner <a href="https://www.hetzner.com/sb">Auction server</a> that I renew
every year or so to make use of newer hardware, which I use to run various
workloads, from <a href="https://kulli.sh">web</a> <a href="https://baytyab.com">services</a>, to
<a href="https://lgug2z.com/articles/embedding-rss-feeds-on-my-static-website/">scheduled
jobs</a>
and self-hosted instances of <a href="https://lgug2z.com/articles/a-working-instance-of-nitter-on-nixos/">privacy-friendly alternative web frontends like
Nitter</a>.</p>
<p>Another one of those workloads, until recently, was <a href="https://plex.tv">Plex</a>.</p>
<p>I didn't have the time to put too much effort into getting around the Hetzner
network ban when it was first implemented, so I just started running
<a href="https://jellyfin.org/">Jellyfin</a> instead. I even made a video demonstrating
how easy it was to get <a href="https://www.youtube.com/watch?v=UYNHEzK7CSw">Jellyfin up and running on a
VPS</a>.</p>
<h1 id="the-initial-view-from-europe"><a class="zola-anchor" href="#the-initial-view-from-europe" aria-label="Anchor link for: the-initial-view-from-europe">The Initial View from Europe</a></h1>
<p>Since getting laid off I finally had some time to try and think about how to
get Plex working again.</p>
<p>My circumstances have changed since I first started hosting Plex back in the
early 2010s:</p>
<ul>
<li>I no longer travel for work (previously, I was an <a href="https://www.icrc.org">ICRC field
delegate</a> and later, for a time, <a href="https://thoughtworks.com">a software
development consultant</a>)</li>
<li>I now have a fixed address and a desktop computer(!)</li>
<li>I no longer live/work/travel in Europe</li>
<li>I now have a lot of spare computer parts lying around the house</li>
</ul>
<p>When I still lived in Europe, hosting Plex on Hetzner made a lot of sense
because it was incredibly fast (both in terms of raw bandwidth and transcoding
speed) when serving content in Europe and SWANA, and because I only had a
laptop that I carried with me when I traveled for work.</p>
<p>The flow of network requests at that time were pretty simple:</p>
<blockquote>
<div align="center">
Me (Anywhere)
<p>🔻</p>
<p>Plex (Hetzner Data Center)</p>
<p>🔻</p>
<p>Google Workspace (Rclone)</p>
<p>🔻</p>
<p>Plex (Hetzner Data Center)</p>
<p>🔻</p>
<p>Me (Anywhere)</p>
</div>
</blockquote>
<h1 id="the-current-view-from-washington"><a class="zola-anchor" href="#the-current-view-from-washington" aria-label="Anchor link for: the-current-view-from-washington">The Current View from Washington</a></h1>
<p>Upon moving to the US, I was shocked at the poor quality of service provided by
ISPs here. After a lot of reading, I came to realize that my own ISP, Xfinity,
in particular was/is known for incredibly poor peering with Hetzner data centers.</p>
<p>To get around the poor peering, I proxied my requests to Plex through a VPS
running in the US, and this worked quite well, despite the complexity creep.</p>
<p>The flow of network requests at that time became:</p>
<blockquote>
<div align="center">
Me (Home)
<p>🔻</p>
<p>VPS (US Cloud Provider)</p>
<p>🔻</p>
<p>Plex (Hetzner Data Center)</p>
<p>🔻</p>
<p>Google Workspace (Rclone)</p>
<p>🔻</p>
<p>Plex (Hetzner Data Center)</p>
<p>🔻</p>
<p>VPS (US Cloud Provider)</p>
<p>🔻</p>
<p>Me (Home)</p>
</div>
</blockquote>
<h1 id="storage-considerations"><a class="zola-anchor" href="#storage-considerations" aria-label="Anchor link for: storage-considerations">Storage Considerations</a></h1>
<p>Last summer, Google Workspace, which I was using to host the media served by my
Plex instance, had a pricing restructure <a href="https://old.reddit.com/r/DataHoarder/comments/13fasuz/google_workspace_unlimited_storage_its_over/">and effectively did away with their
"unlimited" storage
option</a>.</p>
<p>This wasn't too bad for me as I had less than 15TB of data in total on my
Google Workspace account which was still possible to host on a Google Workspace
with three active user accounts.</p>
<h1 id="first-steps-forward"><a class="zola-anchor" href="#first-steps-forward" aria-label="Anchor link for: first-steps-forward">First Steps Forward</a></h1>
<p>Once I have started working again (and after I have restored my emergency fund)
I would like to start buying some hard drives and setting up a local storage
solution at home, making use of the old computer parts I have collected over
the years.</p>
<p>Until then, with my media storage still all on Google Workspace, I think it
makes sense to reduce the complexity of my current setup by removing the VPS
that is being used to proxy my media streaming requests from the Hetzner
server.</p>
<p>And since we are removing the VPS, why not just run the media server locally on
a virtual machine for now too, with the storage mounted via
<a href="https://rclone.org"><code>rclone</code></a> from Google Workspace?</p>
<p>The flow of network requests can now be simplified again:</p>
<blockquote>
<div align="center">
Me (Home)
<p>🔻</p>
<p>Plex (Home)</p>
<p>🔻</p>
<p>Google Workspace (Rclone)</p>
<p>🔻</p>
<p>Plex (Home)</p>
<p>🔻</p>
<p>Me (Home)</p>
</div>
</blockquote>
<p>This works nicely because US ISPs generally don't have peering issues with
Google Workspace, so we can avoid the extra hop, while also benefiting from
the traffic being served from the Plex server to the Plex client at home over
the LAN.</p>
<h1 id="virtual-machine-setup"><a class="zola-anchor" href="#virtual-machine-setup" aria-label="Anchor link for: virtual-machine-setup">Virtual Machine Setup</a></h1>
<p>My main desktop computer at home runs Windows 11 and a bunch of VMs on WSL2.</p>
<p>I figure that for now, until I am ready to assemble a dedicated server at home
(I still need a motherboard and a case in addition to the hard drives), I can
create another WSL2 VM running NixOS, which will make the eventual migration to
bare metal a largely lift-and-shift operation.</p>
<p>This is how my Plex VM on WSL2 is configured, based on my various <a href="https://github.com/LGUG2Z">NixOS
starter templates</a>:</p>
<pre data-lang="nix" style="background-color:#2b303b;color:#c0c5ce;" class="language-nix "><code class="language-nix" data-lang="nix"><span>{
</span><span> config,
</span><span> pkgs,
</span><span> username,
</span><span> ...
</span><span style="color:#8fa1b3;">}</span><span>: </span><span style="color:#b48ead;">let
</span><span> </span><span style="color:#d08770;">uid </span><span>= username:
</span><span> </span><span style="color:#b48ead;">if </span><span style="color:#bf616a;">config</span><span>.</span><span style="color:#bf616a;">users</span><span>.</span><span style="color:#bf616a;">users</span><span>.</span><span style="font-style:italic;color:#ab7967;">${</span><span style="font-style:italic;color:#bf616a;">username</span><span style="font-style:italic;color:#ab7967;">}</span><span>.</span><span style="color:#bf616a;">uid </span><span>== </span><span style="color:#d08770;">null
</span><span> </span><span style="color:#b48ead;">then </span><span>"</span><span style="color:#a3be8c;">1000</span><span>"
</span><span> </span><span style="color:#b48ead;">else </span><span style="color:#96b5b4;">toString </span><span style="color:#bf616a;">config</span><span>.</span><span style="color:#bf616a;">users</span><span>.</span><span style="color:#bf616a;">users</span><span>.</span><span style="font-style:italic;color:#ab7967;">${</span><span style="font-style:italic;color:#bf616a;">username</span><span style="font-style:italic;color:#ab7967;">}</span><span>.</span><span style="color:#bf616a;">uid</span><span>;
</span><span>
</span><span> </span><span style="color:#d08770;">gid </span><span>= group:
</span><span> </span><span style="color:#b48ead;">if </span><span style="color:#bf616a;">config</span><span>.</span><span style="color:#bf616a;">users</span><span>.</span><span style="color:#bf616a;">groups</span><span>.</span><span style="font-style:italic;color:#ab7967;">${</span><span style="font-style:italic;color:#bf616a;">group</span><span style="font-style:italic;color:#ab7967;">}</span><span>.</span><span style="color:#bf616a;">gid </span><span>== </span><span style="color:#d08770;">null
</span><span> </span><span style="color:#b48ead;">then </span><span>"</span><span style="color:#a3be8c;">100</span><span>"
</span><span> </span><span style="color:#b48ead;">else </span><span style="color:#96b5b4;">toString </span><span style="color:#bf616a;">config</span><span>.</span><span style="color:#bf616a;">users</span><span>.</span><span style="color:#bf616a;">groups</span><span>.</span><span style="font-style:italic;color:#ab7967;">${</span><span style="font-style:italic;color:#bf616a;">group</span><span style="font-style:italic;color:#ab7967;">}</span><span>.</span><span style="color:#bf616a;">gid</span><span>;
</span><span>
</span><span> </span><span style="color:#b48ead;">inherit </span><span>(</span><span style="color:#bf616a;">config</span><span>.</span><span style="color:#bf616a;">users</span><span>.</span><span style="color:#bf616a;">users</span><span>.</span><span style="font-style:italic;color:#ab7967;">${</span><span style="font-style:italic;color:#bf616a;">username</span><span style="font-style:italic;color:#ab7967;">}</span><span>) </span><span style="color:#d08770;">home</span><span>;
</span><span>
</span><span> </span><span style="color:#d08770;">media </span><span>= "</span><span style="font-style:italic;color:#ab7967;">${</span><span style="font-style:italic;color:#bf616a;">home</span><span style="font-style:italic;color:#ab7967;">}</span><span style="color:#a3be8c;">/media</span><span>";
</span><span> </span><span style="color:#d08770;">plexData </span><span>= "</span><span style="font-style:italic;color:#ab7967;">${</span><span style="font-style:italic;color:#bf616a;">home</span><span style="font-style:italic;color:#ab7967;">}</span><span style="color:#a3be8c;">/plex</span><span>";
</span><span> </span><span style="color:#d08770;">remote </span><span>= "</span><span style="font-style:italic;color:#ab7967;">${</span><span style="font-style:italic;color:#bf616a;">media</span><span style="font-style:italic;color:#ab7967;">}</span><span style="color:#a3be8c;">/remote</span><span>";
</span><span> </span><span style="color:#d08770;">mount </span><span>= "</span><span style="color:#a3be8c;">gsuite-encrypted</span><span>";
</span><span style="color:#b48ead;">in </span><span>{
</span><span> </span><span style="color:#d08770;">imports </span><span>= [
</span><span> </span><span style="color:#a3be8c;">../modules/nix/base.nix
</span><span> </span><span style="color:#a3be8c;">../modules/nix/linux.nix
</span><span> ];
</span><span>
</span><span> </span><span style="color:#d08770;">system</span><span>.</span><span style="color:#d08770;">stateVersion </span><span>= "</span><span style="color:#a3be8c;">22.05</span><span>";
</span><span> </span><span style="color:#d08770;">time</span><span>.</span><span style="color:#d08770;">timeZone </span><span>= "</span><span style="color:#a3be8c;">America/Los_Angeles</span><span>";
</span><span>
</span><span> </span><span style="color:#d08770;">wsl </span><span>= {
</span><span> </span><span style="color:#d08770;">enable </span><span>= </span><span style="color:#d08770;">true</span><span>;
</span><span> </span><span style="color:#d08770;">wslConf</span><span>.</span><span style="color:#d08770;">automount</span><span>.</span><span style="color:#d08770;">root </span><span>= "</span><span style="color:#a3be8c;">/mnt</span><span>";
</span><span> </span><span style="color:#d08770;">wslConf</span><span>.</span><span style="color:#d08770;">interop</span><span>.</span><span style="color:#d08770;">appendWindowsPath </span><span>= </span><span style="color:#d08770;">false</span><span>;
</span><span> </span><span style="color:#d08770;">wslConf</span><span>.</span><span style="color:#d08770;">network</span><span>.</span><span style="color:#d08770;">generateHosts </span><span>= </span><span style="color:#d08770;">true</span><span>;
</span><span> </span><span style="color:#d08770;">defaultUser </span><span>= </span><span style="color:#bf616a;">username</span><span>;
</span><span> </span><span style="color:#d08770;">startMenuLaunchers </span><span>= </span><span style="color:#d08770;">true</span><span>;
</span><span> </span><span style="color:#d08770;">docker-desktop</span><span>.</span><span style="color:#d08770;">enable </span><span>= </span><span style="color:#d08770;">false</span><span>;
</span><span> </span><span style="color:#d08770;">interop</span><span>.</span><span style="color:#d08770;">register </span><span>= </span><span style="color:#d08770;">true</span><span>;
</span><span> };
</span><span>
</span><span> </span><span style="color:#d08770;">environment</span><span>.</span><span style="color:#d08770;">systemPackages </span><span>= [
</span><span> (</span><span style="color:#96b5b4;">import </span><span style="color:#a3be8c;">../pkgs/win32yank.nix </span><span>{</span><span style="color:#b48ead;">inherit </span><span style="color:#d08770;">pkgs</span><span>;})
</span><span> ];
</span><span>
</span><span> </span><span style="color:#d08770;">systemd</span><span>.</span><span style="color:#d08770;">tmpfiles</span><span>.</span><span style="color:#d08770;">rules </span><span>= [
</span><span> "</span><span style="color:#a3be8c;">d '</span><span style="font-style:italic;color:#ab7967;">${</span><span style="font-style:italic;color:#bf616a;">media</span><span style="font-style:italic;color:#ab7967;">}</span><span style="color:#a3be8c;">' 0755 </span><span style="font-style:italic;color:#ab7967;">${</span><span style="font-style:italic;color:#bf616a;">username</span><span style="font-style:italic;color:#ab7967;">}</span><span style="color:#a3be8c;"> users - -</span><span>"
</span><span> "</span><span style="color:#a3be8c;">d '</span><span style="font-style:italic;color:#ab7967;">${</span><span style="font-style:italic;color:#bf616a;">plexData</span><span style="font-style:italic;color:#ab7967;">}</span><span style="color:#a3be8c;">' 0755 </span><span style="font-style:italic;color:#ab7967;">${</span><span style="font-style:italic;color:#bf616a;">username</span><span style="font-style:italic;color:#ab7967;">}</span><span style="color:#a3be8c;"> users - -</span><span>"
</span><span> ];
</span><span>
</span><span> </span><span style="color:#d08770;">services</span><span>.</span><span style="color:#d08770;">plex </span><span>= </span><span style="color:#b48ead;">let
</span><span> </span><span style="color:#d08770;">plexPass </span><span>= </span><span style="color:#bf616a;">pkgs</span><span>.</span><span style="color:#bf616a;">plex</span><span>.</span><span style="color:#bf616a;">override </span><span>{
</span><span> </span><span style="color:#d08770;">plexRaw </span><span>= </span><span style="color:#bf616a;">pkgs</span><span>.</span><span style="color:#bf616a;">plexRaw</span><span>.</span><span style="color:#bf616a;">overrideAttrs </span><span>(_: </span><span style="color:#b48ead;">rec </span><span>{
</span><span> </span><span style="color:#d08770;">version </span><span>= "</span><span style="color:#a3be8c;">1.32.8.7639-fb6452ebf</span><span>";
</span><span> </span><span style="color:#d08770;">src </span><span>= </span><span style="color:#bf616a;">pkgs</span><span>.</span><span style="color:#bf616a;">fetchurl </span><span>{
</span><span> </span><span style="color:#d08770;">url </span><span>= "</span><span style="color:#a3be8c;">https://downloads.plex.tv/plex-media-server-new/</span><span style="font-style:italic;color:#ab7967;">${</span><span style="font-style:italic;color:#bf616a;">version</span><span style="font-style:italic;color:#ab7967;">}</span><span style="color:#a3be8c;">/debian/plexmediaserver_</span><span style="font-style:italic;color:#ab7967;">${</span><span style="font-style:italic;color:#bf616a;">version</span><span style="font-style:italic;color:#ab7967;">}</span><span style="color:#a3be8c;">_amd64.deb</span><span>";
</span><span> </span><span style="color:#d08770;">sha256 </span><span>= "</span><span style="color:#a3be8c;">sha256-jdGVAdvm7kjxTP3CQ5w6dKZbfCRwSy9TrtxRHaV0/cs=</span><span>";
</span><span> };
</span><span> });
</span><span> };
</span><span> </span><span style="color:#b48ead;">in </span><span>{
</span><span> </span><span style="color:#d08770;">enable </span><span>= </span><span style="color:#d08770;">true</span><span>;
</span><span> </span><span style="color:#d08770;">dataDir </span><span>= "</span><span style="font-style:italic;color:#ab7967;">${</span><span style="font-style:italic;color:#bf616a;">plexData</span><span style="font-style:italic;color:#ab7967;">}</span><span>";
</span><span> </span><span style="color:#d08770;">user </span><span>= </span><span style="color:#bf616a;">username</span><span>;
</span><span> </span><span style="color:#d08770;">group </span><span>= "</span><span style="color:#a3be8c;">users</span><span>";
</span><span> </span><span style="color:#d08770;">openFirewall </span><span>= </span><span style="color:#d08770;">true</span><span>;
</span><span> </span><span style="color:#d08770;">package </span><span>= </span><span style="color:#bf616a;">plexPass</span><span>;
</span><span> };
</span><span>
</span><span> </span><span style="color:#d08770;">systemd</span><span>.</span><span style="color:#d08770;">services</span><span>.</span><span style="color:#d08770;">remote_tv </span><span>= {
</span><span> </span><span style="color:#d08770;">wantedBy </span><span>= ["</span><span style="color:#a3be8c;">multi-user.target</span><span>"];
</span><span> </span><span style="color:#d08770;">serviceConfig </span><span>= {
</span><span> </span><span style="color:#d08770;">ExecStartPre </span><span>= "</span><span style="color:#a3be8c;">/run/wrappers/bin/sudo -u </span><span style="font-style:italic;color:#ab7967;">${</span><span style="font-style:italic;color:#bf616a;">username</span><span style="font-style:italic;color:#ab7967;">}</span><span style="color:#a3be8c;"> /run/current-system/sw/bin/mkdir -p </span><span style="font-style:italic;color:#ab7967;">${</span><span style="font-style:italic;color:#bf616a;">remote</span><span style="font-style:italic;color:#ab7967;">}</span><span style="color:#a3be8c;">/tv</span><span>";
</span><span> </span><span style="color:#d08770;">ExecStart </span><span>= ''
</span><span style="color:#a3be8c;"> </span><span style="font-style:italic;color:#ab7967;">${</span><span style="font-style:italic;color:#bf616a;">pkgs</span><span style="font-style:italic;color:#c0c5ce;">.</span><span style="font-style:italic;color:#bf616a;">rclone</span><span style="font-style:italic;color:#ab7967;">}</span><span style="color:#a3be8c;">/bin/rclone mount '</span><span style="font-style:italic;color:#ab7967;">${</span><span style="font-style:italic;color:#bf616a;">mount</span><span style="font-style:italic;color:#ab7967;">}</span><span style="color:#a3be8c;">:media/TV Shows' </span><span style="font-style:italic;color:#ab7967;">${</span><span style="font-style:italic;color:#bf616a;">remote</span><span style="font-style:italic;color:#ab7967;">}</span><span style="color:#a3be8c;">/tv \
</span><span style="color:#a3be8c;"> --config=</span><span style="font-style:italic;color:#ab7967;">${</span><span style="font-style:italic;color:#bf616a;">config</span><span style="font-style:italic;color:#c0c5ce;">.</span><span style="font-style:italic;color:#bf616a;">sops</span><span style="font-style:italic;color:#c0c5ce;">.</span><span style="font-style:italic;color:#bf616a;">secrets</span><span style="font-style:italic;color:#c0c5ce;">."</span><span style="font-style:italic;color:#a3be8c;">rclone/rclone.conf</span><span style="font-style:italic;color:#c0c5ce;">".</span><span style="font-style:italic;color:#bf616a;">path</span><span style="font-style:italic;color:#ab7967;">}</span><span style="color:#a3be8c;"> \
</span><span style="color:#a3be8c;"> --read-only \
</span><span style="color:#a3be8c;"> --allow-other \
</span><span style="color:#a3be8c;"> --allow-non-empty \
</span><span style="color:#a3be8c;"> --uid </span><span style="font-style:italic;color:#ab7967;">${</span><span style="font-style:italic;color:#bf616a;">uid username</span><span style="font-style:italic;color:#ab7967;">}</span><span style="color:#a3be8c;"> \
</span><span style="color:#a3be8c;"> --gid </span><span style="font-style:italic;color:#ab7967;">${</span><span style="font-style:italic;color:#bf616a;">gid </span><span style="font-style:italic;color:#c0c5ce;">"</span><span style="font-style:italic;color:#a3be8c;">users</span><span style="font-style:italic;color:#c0c5ce;">"</span><span style="font-style:italic;color:#ab7967;">}</span><span style="color:#a3be8c;"> \
</span><span style="color:#a3be8c;"> --log-level=INFO \
</span><span style="color:#a3be8c;"> --buffer-size=50M \
</span><span style="color:#a3be8c;"> --drive-acknowledge-abuse=true \
</span><span style="color:#a3be8c;"> --vfs-cache-mode full \
</span><span style="color:#a3be8c;"> --vfs-cache-max-size 100G \
</span><span style="color:#a3be8c;"> --vfs-read-chunk-size=32M \
</span><span style="color:#a3be8c;"> --vfs-read-chunk-size-limit=256M
</span><span style="color:#a3be8c;"> </span><span>'';
</span><span> </span><span style="color:#d08770;">ExecStop </span><span>= "</span><span style="color:#a3be8c;">/run/wrappers/bin/fusermount3 -u </span><span style="font-style:italic;color:#ab7967;">${</span><span style="font-style:italic;color:#bf616a;">remote</span><span style="font-style:italic;color:#ab7967;">}</span><span style="color:#a3be8c;">/tv</span><span>";
</span><span> </span><span style="color:#d08770;">Type </span><span>= "</span><span style="color:#a3be8c;">notify</span><span>";
</span><span> </span><span style="color:#d08770;">Restart </span><span>= "</span><span style="color:#a3be8c;">always</span><span>";
</span><span> </span><span style="color:#d08770;">RestartSec </span><span>= "</span><span style="color:#a3be8c;">10s</span><span>";
</span><span> </span><span style="color:#d08770;">EnvironmentFile </span><span>= [</span><span style="color:#bf616a;">config</span><span>.</span><span style="color:#bf616a;">sops</span><span>.</span><span style="color:#bf616a;">secrets</span><span>."</span><span style="color:#a3be8c;">rclone/environment</span><span>".</span><span style="color:#bf616a;">path</span><span>];
</span><span> </span><span style="color:#d08770;">Environment </span><span>= ["</span><span style="color:#a3be8c;">PATH=</span><span style="font-style:italic;color:#ab7967;">${</span><span style="font-style:italic;color:#bf616a;">pkgs</span><span style="font-style:italic;color:#c0c5ce;">.</span><span style="font-style:italic;color:#bf616a;">fuse</span><span style="font-style:italic;color:#ab7967;">}</span><span style="color:#a3be8c;">/bin:/run/wrappers/bin:$PATH</span><span>"];
</span><span> };
</span><span> };
</span><span>
</span><span> </span><span style="color:#d08770;">systemd</span><span>.</span><span style="color:#d08770;">services</span><span>.</span><span style="color:#d08770;">remote_movies </span><span>= {
</span><span> </span><span style="color:#d08770;">wantedBy </span><span>= ["</span><span style="color:#a3be8c;">multi-user.target</span><span>"];
</span><span> </span><span style="color:#d08770;">serviceConfig </span><span>= {
</span><span> </span><span style="color:#d08770;">ExecStartPre </span><span>= "</span><span style="color:#a3be8c;">/run/wrappers/bin/sudo -u </span><span style="font-style:italic;color:#ab7967;">${</span><span style="font-style:italic;color:#bf616a;">username</span><span style="font-style:italic;color:#ab7967;">}</span><span style="color:#a3be8c;"> /run/current-system/sw/bin/mkdir -p </span><span style="font-style:italic;color:#ab7967;">${</span><span style="font-style:italic;color:#bf616a;">remote</span><span style="font-style:italic;color:#ab7967;">}</span><span style="color:#a3be8c;">/movies</span><span>";
</span><span> </span><span style="color:#d08770;">ExecStart </span><span>= ''
</span><span style="color:#a3be8c;"> </span><span style="font-style:italic;color:#ab7967;">${</span><span style="font-style:italic;color:#bf616a;">pkgs</span><span style="font-style:italic;color:#c0c5ce;">.</span><span style="font-style:italic;color:#bf616a;">rclone</span><span style="font-style:italic;color:#ab7967;">}</span><span style="color:#a3be8c;">/bin/rclone mount '</span><span style="font-style:italic;color:#ab7967;">${</span><span style="font-style:italic;color:#bf616a;">mount</span><span style="font-style:italic;color:#ab7967;">}</span><span style="color:#a3be8c;">:media/Movies' </span><span style="font-style:italic;color:#ab7967;">${</span><span style="font-style:italic;color:#bf616a;">remote</span><span style="font-style:italic;color:#ab7967;">}</span><span style="color:#a3be8c;">/movies \
</span><span style="color:#a3be8c;"> --config=</span><span style="font-style:italic;color:#ab7967;">${</span><span style="font-style:italic;color:#bf616a;">config</span><span style="font-style:italic;color:#c0c5ce;">.</span><span style="font-style:italic;color:#bf616a;">sops</span><span style="font-style:italic;color:#c0c5ce;">.</span><span style="font-style:italic;color:#bf616a;">secrets</span><span style="font-style:italic;color:#c0c5ce;">."</span><span style="font-style:italic;color:#a3be8c;">rclone/rclone.conf</span><span style="font-style:italic;color:#c0c5ce;">".</span><span style="font-style:italic;color:#bf616a;">path</span><span style="font-style:italic;color:#ab7967;">}</span><span style="color:#a3be8c;"> \
</span><span style="color:#a3be8c;"> --read-only \
</span><span style="color:#a3be8c;"> --allow-other \
</span><span style="color:#a3be8c;"> --allow-non-empty \
</span><span style="color:#a3be8c;"> --uid </span><span style="font-style:italic;color:#ab7967;">${</span><span style="font-style:italic;color:#bf616a;">uid username</span><span style="font-style:italic;color:#ab7967;">}</span><span style="color:#a3be8c;"> \
</span><span style="color:#a3be8c;"> --gid </span><span style="font-style:italic;color:#ab7967;">${</span><span style="font-style:italic;color:#bf616a;">gid </span><span style="font-style:italic;color:#c0c5ce;">"</span><span style="font-style:italic;color:#a3be8c;">users</span><span style="font-style:italic;color:#c0c5ce;">"</span><span style="font-style:italic;color:#ab7967;">}</span><span style="color:#a3be8c;"> \
</span><span style="color:#a3be8c;"> --log-level=INFO \
</span><span style="color:#a3be8c;"> --buffer-size=50M \
</span><span style="color:#a3be8c;"> --drive-acknowledge-abuse=true \
</span><span style="color:#a3be8c;"> --vfs-cache-mode full \
</span><span style="color:#a3be8c;"> --vfs-cache-max-size 100G \
</span><span style="color:#a3be8c;"> --vfs-read-chunk-size=32M \
</span><span style="color:#a3be8c;"> --vfs-read-chunk-size-limit=256M
</span><span style="color:#a3be8c;"> </span><span>'';
</span><span> </span><span style="color:#d08770;">ExecStop </span><span>= "</span><span style="color:#a3be8c;">/run/wrappers/bin/fusermount3 -u </span><span style="font-style:italic;color:#ab7967;">${</span><span style="font-style:italic;color:#bf616a;">remote</span><span style="font-style:italic;color:#ab7967;">}</span><span style="color:#a3be8c;">/movies</span><span>";
</span><span> </span><span style="color:#d08770;">Type </span><span>= "</span><span style="color:#a3be8c;">notify</span><span>";
</span><span> </span><span style="color:#d08770;">Restart </span><span>= "</span><span style="color:#a3be8c;">always</span><span>";
</span><span> </span><span style="color:#d08770;">RestartSec </span><span>= "</span><span style="color:#a3be8c;">10s</span><span>";
</span><span> </span><span style="color:#d08770;">EnvironmentFile </span><span>= [</span><span style="color:#bf616a;">config</span><span>.</span><span style="color:#bf616a;">sops</span><span>.</span><span style="color:#bf616a;">secrets</span><span>."</span><span style="color:#a3be8c;">rclone/environment</span><span>".</span><span style="color:#bf616a;">path</span><span>];
</span><span> </span><span style="color:#d08770;">Environment </span><span>= ["</span><span style="color:#a3be8c;">PATH=</span><span style="font-style:italic;color:#ab7967;">${</span><span style="font-style:italic;color:#bf616a;">pkgs</span><span style="font-style:italic;color:#c0c5ce;">.</span><span style="font-style:italic;color:#bf616a;">fuse</span><span style="font-style:italic;color:#ab7967;">}</span><span style="color:#a3be8c;">/bin:/run/wrappers/bin:$PATH</span><span>"];
</span><span> };
</span><span> };
</span><span>}
</span></code></pre>
<h1 id="plex-connectivity"><a class="zola-anchor" href="#plex-connectivity" aria-label="Anchor link for: plex-connectivity">Plex Connectivity</a></h1>
<p>With Plex running in a WSL2 VM, it will have an IP address matching
<code>172.*.*.*</code>, which differs from my LAN's <code>192.168.111.1/24</code> address range, so
there are a few values that we need to add in the settings.</p>
<ul>
<li><code>Settings -> Remote Access -> Enable Remote Access</code> since <code>192.168.111.1/24</code>
addresses appear as "remote" to Plex</li>
<li>For <code>Settings -> Network -> LAN Networks</code> I set <code>192.168.111.1/24</code></li>
<li>For <code>Settings -> Network -> Custom server access URLs</code> I set
<code>http://192.168.111.221:32400</code>, where <code>192.168.111.221</code> is the static address
of the machine the Plex VM is running on</li>
<li>I grab the address of the VM from <code>/etc/resolv.conf</code> and set a <code>netsh</code> rule
so that requests to <code>192.168.111.221:32400</code> are forwarded on to the VM
<ul>
<li><code>sudo netsh interface portproxy add v4tov4 listenport=32400 listenaddress=0.0.0.0 connectport=32400 connectaddress=172.28.96.1</code></li>
</ul>
</li>
</ul>
<p>One thing to note however, is that the <code>connectaddress</code> changes when the host
is restarted. This is very rare for me so I don't mind updating this by hand
every now and then.</p>
<h1 id="keeping-the-data-fresh"><a class="zola-anchor" href="#keeping-the-data-fresh" aria-label="Anchor link for: keeping-the-data-fresh">Keeping the Data Fresh</a></h1>
<p>Due to the poor quality of internet service in my apartment and across the US
in general, it makes sense to keep network-heavy workloads running on the
Hetzner server.</p>
<p>For a long time, I have used <a href="https://github.com/trapexit/mergerfs"><code>mergerfs</code></a>
to create a consolidated view on the server of <code>remote</code> files on Google
Workspace and <code>local</code> files that "appear" on the server from time to time.</p>
<table><thead><tr><th>directory</th><th>description</th></tr></thead><tbody>
<tr><td>media/remote/tv</td><td>Google Workspace files mounted via rclone</td></tr>
<tr><td>media/local/tv</td><td>Local hard drive files</td></tr>
<tr><td>media/merged/tv</td><td>All the files! 🎉</td></tr>
</tbody></table>
<p>The media files under <code>merged</code> are what Plex and Jellyfin running on the server
look at, providing a consolidated view of newer files on the hard drive and
older files mounted from Google Workspace, and every day a <code>systemd</code> timer runs
to move any files older than 5 days to Google Workspace, freeing up space on
the local hard drive.</p>
<pre data-lang="nix" style="background-color:#2b303b;color:#c0c5ce;" class="language-nix "><code class="language-nix" data-lang="nix"><span>{
</span><span> </span><span style="color:#d08770;">systemd</span><span>.</span><span style="color:#d08770;">services</span><span>.</span><span style="color:#d08770;">move_tv </span><span>= {
</span><span> </span><span style="color:#d08770;">enable </span><span>= </span><span style="color:#bf616a;">enableCron</span><span>;
</span><span> </span><span style="color:#d08770;">startAt </span><span>= "</span><span style="color:#a3be8c;">daily</span><span>";
</span><span> </span><span style="color:#d08770;">serviceConfig </span><span>= {
</span><span> </span><span style="color:#d08770;">Type </span><span>= "</span><span style="color:#a3be8c;">oneshot</span><span>";
</span><span> </span><span style="color:#d08770;">User </span><span>= </span><span style="color:#bf616a;">username</span><span>;
</span><span> </span><span style="color:#d08770;">Group </span><span>= "</span><span style="color:#a3be8c;">users</span><span>";
</span><span> </span><span style="color:#d08770;">EnvironmentFile </span><span>= [</span><span style="color:#bf616a;">config</span><span>.</span><span style="color:#bf616a;">sops</span><span>.</span><span style="color:#bf616a;">secrets</span><span>."</span><span style="color:#a3be8c;">rclone/environment</span><span>".</span><span style="color:#bf616a;">path</span><span>];
</span><span> </span><span style="color:#d08770;">ExecStart </span><span>= ''
</span><span style="color:#a3be8c;"> </span><span style="font-style:italic;color:#ab7967;">${</span><span style="font-style:italic;color:#bf616a;">pkgs</span><span style="font-style:italic;color:#c0c5ce;">.</span><span style="font-style:italic;color:#bf616a;">rclone</span><span style="font-style:italic;color:#ab7967;">}</span><span style="color:#a3be8c;">/bin/rclone \
</span><span style="color:#a3be8c;"> --config=</span><span style="font-style:italic;color:#ab7967;">${</span><span style="font-style:italic;color:#bf616a;">config</span><span style="font-style:italic;color:#c0c5ce;">.</span><span style="font-style:italic;color:#bf616a;">sops</span><span style="font-style:italic;color:#c0c5ce;">.</span><span style="font-style:italic;color:#bf616a;">secrets</span><span style="font-style:italic;color:#c0c5ce;">."</span><span style="font-style:italic;color:#a3be8c;">rclone/rclone.conf</span><span style="font-style:italic;color:#c0c5ce;">".</span><span style="font-style:italic;color:#bf616a;">path</span><span style="font-style:italic;color:#ab7967;">}</span><span style="color:#a3be8c;"> \
</span><span style="color:#a3be8c;"> --drive-upload-cutoff 1000T \
</span><span style="color:#a3be8c;"> --tpslimit 5 \
</span><span style="color:#a3be8c;"> move \
</span><span style="color:#a3be8c;"> "</span><span style="font-style:italic;color:#ab7967;">${</span><span style="font-style:italic;color:#bf616a;">local</span><span style="font-style:italic;color:#ab7967;">}</span><span style="color:#a3be8c;">/tv" \
</span><span style="color:#a3be8c;"> "</span><span style="font-style:italic;color:#ab7967;">${</span><span style="font-style:italic;color:#bf616a;">mount</span><span style="font-style:italic;color:#ab7967;">}</span><span style="color:#a3be8c;">:media/TV Shows" \
</span><span style="color:#a3be8c;"> --log-file </span><span style="font-style:italic;color:#ab7967;">${</span><span style="font-style:italic;color:#bf616a;">home</span><span style="font-style:italic;color:#ab7967;">}</span><span style="color:#a3be8c;">/logs/upload_tv.log \
</span><span style="color:#a3be8c;"> --delete-empty-src-dirs \
</span><span style="color:#a3be8c;"> --fast-list \
</span><span style="color:#a3be8c;"> --stats-one-line \
</span><span style="color:#a3be8c;"> -v \
</span><span style="color:#a3be8c;"> --min-age 5d
</span><span style="color:#a3be8c;"> </span><span>'';
</span><span> };
</span><span> };
</span><span>}
</span></code></pre>
<p>These days it's exceedingly rare that new files "appear" on my server given that
there are so many family subscriptions of Netflix, Hulu etc. going around in
our extended family.</p>
<p>Nevertheless, given that Plex is now running in a VM on my home network, I have
removed the <code>--min-age 5d</code> restriction from my <code>systemd</code> timers so that
whatever new files may "appear" on the server on any given day are always moved
to Google Workspace within 24 hours.</p>
<h1 id="thinking-ahead"><a class="zola-anchor" href="#thinking-ahead" aria-label="Anchor link for: thinking-ahead">Thinking Ahead</a></h1>
<p>When I am able to buy the remaining parts to put together a dedicated home
server, the migration path seems pretty clear:</p>
<ul>
<li>Zip up the <code>plex</code> folder containing my Plex server configuration</li>
<li>Provision the new server with a NixOS template based on the existing VM
configuration</li>
<li>Unzip the Plex server configuration on the dedicated server before enabling
the <code>plex</code> service</li>
<li>Remove the VM-specific network configuration from the Plex server settings</li>
<li>Slowly sync my media from Google Workspace to the dedicated server</li>
<li>Once the sync is complete, add a new <a href="https://rclone.org/sftp/">SFTP remote</a>
remote to my <code>rclone</code> configuration for new files to also be pushed directly
to the dedicated server at home from Hetzner</li>
</ul>
<p>If you have any questions or comments you can reach out to me on
<a href="https://twitter.com/JeezyCodes">Twitter</a> and
<a href="https://hachyderm.io/@LGUG2Z">Mastodon</a>.</p>
<p>If you're interested in what I read to come up with solutions like this one,
you can subscribe to my <a href="https://notado.app/feeds/jado/software-development">Software Development RSS
feed</a>.</p>
<p>If you'd like to watch me writing code while explaining what I'm doing, you can
also <a href="https://www.youtube.com/channel/UCeai3-do-9O4MNy9_xjO6mg?sub_confirmation=1">subscribe to my YouTube
channel</a>.</p>
<p>If you found this content valuable, or if you are a happy user of
<a href="https://github.com/LGUG2Z/komorebi"><code>komorebi</code></a> or my <a href="https://github.com/LGUG2Z">NixOS starter
templates</a>, please consider sponsoring me on
<a href="https://github.com/sponsors/LGUG2Z">GitHub</a> or tipping me on
<a href="https://ko-fi.com/lgug2z">Ko-fi</a>.</p>
Set your NIX_PATH to your System Flake's Nixpkgs for a More Predictable Nix-ShellMon, 04 Dec 2023 19:02:54 -0800lgug2z
https://lgug2z.com/articles/set-your-nix-path-to-your-system-flakes-nixpkgs-for-a-more-predictable-nix-shell/
https://lgug2z.com/articles/set-your-nix-path-to-your-system-flakes-nixpkgs-for-a-more-predictable-nix-shell/<p>I came across <a href="https://old.reddit.com/r/NixOS/comments/18ak81y/noob_question_which_version_of_nixpkgs_does_a/">an interesting
thread</a>
on the NixOS subreddit today that helped me fix a problem that I didn't even
know I had with my NixOS system configuration.</p>
<p>Every now and then, I'll try to quickly do <a href="https://nixos.org/manual/nix/stable/command-ref/nix-shell"><code>nix-shell -p somepackage</code></a>, and
it will fail, because <code>somepackage</code> couldn't be found.</p>
<p>I go and check <a href="https://search.nixos.org">https://search.nixos.org</a> and it's
there. Huh. Weird.</p>
<p>So I just go and add it to my
<a href="https://search.nixos.org/options?channel=unstable&show=environment.systemPackages"><code>environment.systemPackages</code></a>
to install it, and remove it if it turns out I don't really need it.</p>
<p>This alternative step works because my system configuration flake is pointing
to a different version of nixpkgs than the <code>nix-shell</code> command.</p>
<p>So what's the solution? Let's make <code>nix-shell</code> use the same version of nixpkgs
as the system configuration flake!</p>
<pre data-lang="nix" style="background-color:#2b303b;color:#c0c5ce;" class="language-nix "><code class="language-nix" data-lang="nix"><span style="color:#8fa1b3;">{</span><span>inputs, ...</span><span style="color:#8fa1b3;">}</span><span>: {
</span><span> </span><span style="color:#d08770;">nix </span><span>= {
</span><span> </span><span style="color:#d08770;">registry </span><span>= {
</span><span> </span><span style="color:#d08770;">nixpkgs </span><span>= {
</span><span> </span><span style="color:#d08770;">flake </span><span>= </span><span style="color:#bf616a;">inputs</span><span>.</span><span style="color:#bf616a;">nixpkgs</span><span>;
</span><span> };
</span><span> };
</span><span>
</span><span> </span><span style="color:#d08770;">nixPath </span><span>= [
</span><span> "</span><span style="color:#a3be8c;">nixpkgs=</span><span style="font-style:italic;color:#ab7967;">${</span><span style="font-style:italic;color:#bf616a;">inputs</span><span style="font-style:italic;color:#c0c5ce;">.</span><span style="font-style:italic;color:#bf616a;">nixpkgs</span><span style="font-style:italic;color:#c0c5ce;">.</span><span style="font-style:italic;color:#bf616a;">outPath</span><span style="font-style:italic;color:#ab7967;">}</span><span>"
</span><span> "</span><span style="color:#a3be8c;">nixos-config=/etc/nixos/configuration.nix</span><span>"
</span><span> "</span><span style="color:#a3be8c;">/nix/var/nix/profiles/per-user/root/channels</span><span>"
</span><span> ];
</span><span> };
</span><span>}
</span></code></pre>
<p>Of course, if you also have a <code>nixpkgs-unstable</code> input in your flake and would
rather that <code>nix-shell -p</code> always pulled in packages from there, you can
reference <code>nixpkgs-unstable</code> here as well without forcing your whole system
configuration to track the changes on the <code>nixpkgs</code> master branch.</p>
<p>If you have any questions you can reach out to me on
<a href="https://twitter.com/JeezyCodes">Twitter</a> and
<a href="https://hachyderm.io/@LGUG2Z">Mastodon</a>.</p>
<p>If you're interested in what I read to come up with solutions like this one,
you can subscribe to my <a href="https://notado.app/feeds/jado/software-development">Software Development RSS
feed</a>.</p>
<p>If you'd like to watch me writing code while explaining what I'm doing, you can
also <a href="https://www.youtube.com/channel/UCeai3-do-9O4MNy9_xjO6mg?sub_confirmation=1">subscribe to my YouTube
channel</a>.</p>
<hr />
<p>On 11/14/2023 I was impacted by large scale layoffs at my previous employer. I
am currently looking for work. I am an experienced SRE with a strong passion
for developer enablement, as well as an accomplished open-source developer with
a portfolio of popular Rust and Go projects on
<a href="https://github.com/LGUG2Z">GitHub</a>. Please reach out if you are hiring for a
role that you think I'd be a good fit for.</p>
<p>If you found this content valuable or if you are a happy user of
<a href="https://github.com/LGUG2Z/komorebi"><code>komorebi</code></a>, please consider sponsoring me
on <a href="https://github.com/sponsors/LGUG2Z">GitHub</a> or tipping me on
<a href="https://ko-fi.com/lgug2z">Ko-fi</a> to help me through this uncertain period.</p>
Managing Dotfiles on Windows 11 With NixOSThu, 16 Nov 2023 17:18:17 +0000lgug2z
https://lgug2z.com/articles/managing-dotfiles-on-windows-11-with-nixos/
https://lgug2z.com/articles/managing-dotfiles-on-windows-11-with-nixos/<p>I have a confession to make. Until yesterday, I did not have any form of
dotfiles management or versioning for my Windows 11 machine. Yes, I, the person
who wrote <a href="https://github.com/LGUG2Z/komorebi/">an entire tiling window manager for Windows from scratch in
Rust</a>, did not manage my dots.</p>
<p>I had to sheepishly admit this on more than one occasion in the project
<a href="https://discord.gg/mGkn66PHkx">Discord server</a> when people would watch my
<a href="https://www.youtube.com/playlist?list=PLllZnrEJu89Cpu4tMO8LAg1m6gWYWLSGJ">live programming
videos</a>
and then ask if I could share my Windows dotfiles repo.</p>
<p>I became a full time <a href="https://nixos.org">NixOS</a> user earlier this year. I tried
running it as my main OS for a few months, but the Linux desktop experience
still leaves a lot to be desired, and one of the downsides of writing a tiling
window manager that does everything that you want it to do exactly the way you
want it to do it, is that going back to other tiling wms that you thought were
great before becomes harder.</p>
<p>So I settled on running Windows 11 with
<a href="https://github.com/LGUG2Z/komorebi"><code>komorebi</code></a> as my "desktop environment"
over <a href="https://github.com/LGUG2Z/nixos-wsl-starter">NixOS running in WSL</a>. It
also doesn't hurt that I don't have to jump through any hoops to get Premiere
Pro, Ableton or Elgato software working, or play video games (though
admittedly, the gaming experience on Linux is getting better and better every
day since the release of the Steam Deck.)</p>
<p>Naturally, all my dotfiles are versioned as part of my NixOS configuration
flake repo.</p>
<p>While making a tutorial video showing <a href="https://www.youtube.com/watch?v=CbDVUjbqIhc">how to set up a remote NixOS server to
successfully accept VSCode remote
sessions</a>, I had to copy my SSH
key from the NixOS VM to Windows. This gave me an idea.</p>
<p>Why not just use NixOS to copy over the dotfiles generated for me by
<a href="https://github.com/nix-community/home-manager"><code>home-manager</code></a> to Windows?</p>
<p>This is what I set about doing.</p>
<p>Let's take my <code>.gitconfig</code> as an example. I use <code>home-manager</code> to generate my
<code>.gitconfig</code> file rather than writing one by hand, bringing it into the
<code>/nix/store</code> and then linking it into my <code>$HOME</code> directory.</p>
<pre data-lang="nix" style="background-color:#2b303b;color:#c0c5ce;" class="language-nix "><code class="language-nix" data-lang="nix"><span>{
</span><span> </span><span style="color:#d08770;">programs</span><span>.</span><span style="color:#d08770;">git </span><span>= {
</span><span> </span><span style="color:#d08770;">enable </span><span>= </span><span style="color:#d08770;">true</span><span>;
</span><span> </span><span style="color:#d08770;">package </span><span>= </span><span style="color:#bf616a;">pkgs</span><span>.</span><span style="color:#bf616a;">unstable</span><span>.</span><span style="color:#bf616a;">git</span><span>;
</span><span> </span><span style="color:#d08770;">delta</span><span>.</span><span style="color:#d08770;">enable </span><span>= </span><span style="color:#d08770;">true</span><span>;
</span><span> </span><span style="color:#d08770;">delta</span><span>.</span><span style="color:#d08770;">options </span><span>= {
</span><span> </span><span style="color:#d08770;">line-numbers </span><span>= </span><span style="color:#d08770;">true</span><span>;
</span><span> </span><span style="color:#d08770;">side-by-side </span><span>= </span><span style="color:#d08770;">true</span><span>;
</span><span> </span><span style="color:#d08770;">navigate </span><span>= </span><span style="color:#d08770;">true</span><span>;
</span><span> };
</span><span> </span><span style="color:#d08770;">userEmail </span><span>= "</span><span style="color:#a3be8c;">sorry robots :(</span><span>";
</span><span> </span><span style="color:#d08770;">userName </span><span>= "</span><span style="color:#a3be8c;">LGUG2Z</span><span>";
</span><span> </span><span style="color:#d08770;">extraConfig </span><span>= {
</span><span> </span><span style="color:#d08770;">url </span><span>= {
</span><span> "</span><span style="color:#a3be8c;">https://oauth2:</span><span style="font-style:italic;color:#ab7967;">${</span><span style="font-style:italic;color:#bf616a;">secrets</span><span style="font-style:italic;color:#c0c5ce;">.</span><span style="font-style:italic;color:#bf616a;">github</span><span style="font-style:italic;color:#c0c5ce;">.</span><span style="font-style:italic;color:#bf616a;">personal_access_token</span><span style="font-style:italic;color:#ab7967;">}</span><span style="color:#a3be8c;">@github.com</span><span>" = {
</span><span> </span><span style="color:#d08770;">insteadOf </span><span>= "</span><span style="color:#a3be8c;">https://github.com</span><span>";
</span><span> };
</span><span> "</span><span style="color:#a3be8c;">https://oauth2:</span><span style="font-style:italic;color:#ab7967;">${</span><span style="font-style:italic;color:#bf616a;">secrets</span><span style="font-style:italic;color:#c0c5ce;">.</span><span style="font-style:italic;color:#bf616a;">gitlab</span><span style="font-style:italic;color:#c0c5ce;">.</span><span style="font-style:italic;color:#bf616a;">personal_access_token</span><span style="font-style:italic;color:#ab7967;">}</span><span style="color:#a3be8c;">@gitlab.com</span><span>" = {
</span><span> </span><span style="color:#d08770;">insteadOf </span><span>= "</span><span style="color:#a3be8c;">https://gitlab.com</span><span>";
</span><span> };
</span><span> };
</span><span> </span><span style="color:#d08770;">push </span><span>= {
</span><span> </span><span style="color:#d08770;">default </span><span>= "</span><span style="color:#a3be8c;">current</span><span>";
</span><span> </span><span style="color:#d08770;">autoSetupRemote </span><span>= </span><span style="color:#d08770;">true</span><span>;
</span><span> };
</span><span> </span><span style="color:#d08770;">merge </span><span>= {
</span><span> </span><span style="color:#d08770;">conflictstyle </span><span>= "</span><span style="color:#a3be8c;">diff3</span><span>";
</span><span> };
</span><span> </span><span style="color:#d08770;">diff </span><span>= {
</span><span> </span><span style="color:#d08770;">colorMoved </span><span>= "</span><span style="color:#a3be8c;">default</span><span>";
</span><span> };
</span><span> };
</span><span> };
</span><span>}
</span></code></pre>
<p>There are a couple things to note here. First is that I use
<a href="https://github.com/dandavison/delta"><code>delta</code></a> for diffs with some specific
options, and second is that I inject my personal access tokens for GitHub and
GitLab from a <code>git-crypt</code>ed file into my config for HTTPS clones.</p>
<p>(If you're interested in different secrets managements strategies, you can
check out <a href="https://lgug2z.com/articles/handling-secrets-in-nixos-an-overview/">this dedicated
article</a>!)</p>
<p>The output of that Nix expression looks like this:</p>
<pre data-lang="toml" style="background-color:#2b303b;color:#c0c5ce;" class="language-toml "><code class="language-toml" data-lang="toml"><span>[core]
</span><span> </span><span style="color:#bf616a;">pager </span><span>= "</span><span style="color:#a3be8c;">/nix/store/68szslq3sv2gqlc5qzniwh1pf8hn70m2-delta-0.15.1/bin/delta</span><span>"
</span><span>
</span><span>[delta]
</span><span> </span><span style="color:#bf616a;">line-numbers </span><span>= </span><span style="color:#d08770;">true
</span><span> </span><span style="color:#bf616a;">navigate </span><span>= </span><span style="color:#d08770;">true
</span><span> </span><span style="color:#bf616a;">side-by-side </span><span>= </span><span style="color:#d08770;">true
</span><span>
</span><span>[diff]
</span><span> </span><span style="color:#bf616a;">colorMoved </span><span>= "</span><span style="color:#a3be8c;">default</span><span>"
</span><span>
</span><span>[interactive]
</span><span> </span><span style="color:#bf616a;">diffFilter </span><span>= "</span><span style="color:#a3be8c;">/nix/store/68szslq3sv2gqlc5qzniwh1pf8hn70m2-delta-0.15.1/bin/delta --color-only</span><span>"
</span><span>
</span><span>[merge]
</span><span> </span><span style="color:#bf616a;">conflictstyle </span><span>= "</span><span style="color:#a3be8c;">diff3</span><span>"
</span><span>
</span><span>[push]
</span><span> </span><span style="color:#bf616a;">autoSetupRemote </span><span>= </span><span style="color:#d08770;">true
</span><span> </span><span style="color:#bf616a;">default </span><span>= "</span><span style="color:#a3be8c;">current</span><span>"
</span><span>
</span><span>[url</span><span style="background-color:#bf616a;color:#2b303b;"> </span><span>"</span><span style="color:#a3be8c;">https://oauth2:[email protected]</span><span>"]
</span><span> </span><span style="color:#bf616a;">insteadOf </span><span>= "</span><span style="color:#a3be8c;">https://github.com</span><span>"
</span><span>
</span><span>[url</span><span style="background-color:#bf616a;color:#2b303b;"> </span><span>"</span><span style="color:#a3be8c;">https://oauth2:[email protected]</span><span>"]
</span><span> </span><span style="color:#bf616a;">insteadOf </span><span>= "</span><span style="color:#a3be8c;">https://gitlab.com</span><span>"
</span><span>
</span><span>[user]
</span><span> </span><span style="color:#bf616a;">email </span><span>= "</span><span style="color:#a3be8c;">sorry robots :(</span><span>"
</span><span> </span><span style="color:#bf616a;">name </span><span>= "</span><span style="color:#a3be8c;">LGUG2Z</span><span>"
</span></code></pre>
<p>Setting <code>delta.enable = true;</code> means that the <code>delta</code> binary referenced in the
configuration will point to a symlink in <code>/nix/store</code>. Obviously this won't
work on Windows, so we'll have to make a few changes.</p>
<p>I started by writing this shell script package and adding it to <code>systemPackages</code>.</p>
<pre data-lang="nix" style="background-color:#2b303b;color:#c0c5ce;" class="language-nix "><code class="language-nix" data-lang="nix"><span style="color:#8fa1b3;">{</span><span>...</span><span style="color:#8fa1b3;">}</span><span>: </span><span style="color:#b48ead;">let
</span><span> </span><span style="color:#d08770;">sync-dots </span><span>= </span><span style="color:#bf616a;">pkgs</span><span>.</span><span style="color:#bf616a;">writeShellScriptBin </span><span>"</span><span style="color:#a3be8c;">sync-dots</span><span>" ''
</span><span style="color:#a3be8c;"> cp ~/.config/git/config /mnt/c/Users/LGUG2Z/.gitconfig
</span><span style="color:#a3be8c;"> </span><span style="font-style:italic;color:#ab7967;">${</span><span style="font-style:italic;color:#bf616a;">pkgs</span><span style="font-style:italic;color:#c0c5ce;">.</span><span style="font-style:italic;color:#bf616a;">gnused</span><span style="font-style:italic;color:#ab7967;">}</span><span style="color:#a3be8c;">/bin/sed -i 's/\/nix\/store\/.*\/bin\///g' /mnt/c/Users/LGUG2Z/.gitconfig
</span><span style="color:#a3be8c;"> </span><span>'';
</span><span style="color:#b48ead;">in </span><span>{
</span><span> </span><span style="color:#d08770;">environment</span><span>.</span><span style="color:#d08770;">systemPackages </span><span>= [
</span><span> </span><span style="color:#bf616a;">sync-dots
</span><span> ];
</span><span>}
</span></code></pre>
<p>This shell script copies the file symlinked from <code>/nix/store</code> to its final
location under my <code>$HOME</code> directory to my <code>$Env:USERPROFILE</code> in Windows. Once
the file has been copied, we run <code>sed</code> to remove <code>/nix/store/*/</code> throughout the
file. This can also be further refined by adding <code>$USERPROFILE</code> as an
environment variable in WSL using
<a href="https://devblogs.microsoft.com/commandline/share-environment-vars-between-wsl-and-windows/"><code>WSLENV</code></a>
and referencing that, but it was good enough to test out at this point.</p>
<p>So I ran the command, and it worked as expected. Magic! I then went about
adding the rest of my generated dotfiles that are of use in Windows in this
<code>sync-dots</code> package, and even moving a few dots that I use only on Windows into
my flake configuration repo to version and copy back in the same way.</p>
<p>It would also be possible to have this script run as a part of
<a href="https://search.nixos.org/options?channel=unstable&show=system.userActivationScripts"><code>system.userActivationScripts</code></a>
so that every time the NixOS system is rebuilt for an update, the files are
copied over again to ensure that the latest versions are also on Windows every
time, however for now this is so infrequent that I'd prefer to keep
<code>systemuserActivationScripts</code> as small as possible and just manually run
<code>sync-dots</code> whenever I need to.</p>
<p>I am very happy with how this experiment turned out, as I had no appetite to
manage the same dotfiles in two different ways for different operating systems,
and in my experience so far, <code>home-manager</code> is the golden standard for dotfiles
management.</p>
<p>Now if only there were a nice simple way to cross-compile a bunch of core
packages for Windows from NixOS and then copy them over to Windows
automatically...</p>
<p>If you have any questions you can reach out to me on
<a href="https://twitter.com/JeezyCodes">Twitter</a> and
<a href="https://hachyderm.io/@LGUG2Z">Mastodon</a>.</p>
<p>If you're interested in what I read to come up with solutions like this one,
you can subscribe to my <a href="https://notado.app/feeds/jado/software-development">Software Development RSS
feed</a>.</p>
<p>If you'd like to watch me writing code while explaining what I'm doing, you can
also <a href="https://www.youtube.com/channel/UCeai3-do-9O4MNy9_xjO6mg?sub_confirmation=1">subscribe to my YouTube
channel</a>.</p>
<hr />
<p>On 11/14/2023 I was impacted by large scale layoffs at my previous employer. I
am currently looking for work. I am an experienced SRE with a strong passion
for developer enablement, as well as an accomplished open-source developer with
a portfolio of popular Rust and Go projects on
<a href="https://github.com/LGUG2Z">GitHub</a>. Please reach out if you are hiring for a
role that you think I'd be a good fit for.</p>
<p>If you found this content valuable or if you are a happy user of
<a href="https://github.com/LGUG2Z/komorebi"><code>komorebi</code></a>, please consider sponsoring me
on <a href="https://github.com/sponsors/LGUG2Z">GitHub</a> or tipping me on
<a href="https://ko-fi.com/lgug2z">Ko-fi</a> to help me through this uncertain period.</p>
Handling Secrets in NixOS: An OverviewTue, 14 Nov 2023 05:25:13 +0000lgug2z
https://lgug2z.com/articles/handling-secrets-in-nixos-an-overview/
https://lgug2z.com/articles/handling-secrets-in-nixos-an-overview/<p>There are a number of different approaches available for NixOS users to handle
secrets. The most popular tend to be
<a href="https://github.com/AGWA/git-crypt"><code>git-crypt</code></a>,
<a href="https://github.com/ryantm/agenix"><code>agenix</code></a> and
<a href="https://github.com/Mic92/sops-nix"><code>sops-nix</code></a>. But which one should you use?</p>
<p>To hopefully help you in answering this question for yourself, here is an
overview of a few common use cases and what I think is most appropriate for
each.</p>
<h2 id="managing-your-own-physical-machines"><a class="zola-anchor" href="#managing-your-own-physical-machines" aria-label="Anchor link for: managing-your-own-physical-machines">Managing Your Own Physical Machines</a></h2>
<p>Maybe you have a desktop, a Macbook and a Raspberry Pi which you are managing
from a single NixOS flake repo. Maybe you even have a NixOS dedicated server
somewhere running in a datacenter which functions as your media server running
Plex or Jellyfin.</p>
<p>If you are primarily using NixOS on your own physical machines, used
exclusively by you, <em>and</em> you want to be able to publish your flake repo
publicly, I think you can get pretty far with <code>git-crypt</code>.</p>
<p>I have been a happy user of <code>git-crypt</code> for a long time, even before I started
using NixOS, and naturally it was my first instinct to use when setting up my
own configuration flake repo.</p>
<p>Using <code>git-crypt</code> will ensure that your secret files are encrypted when you
push your repo to a remote like GitHub, however, using this approach means that
your secrets will end up in <code>/nix/store</code> unencrypted, which is readable to all
users on a machine. If you're exclusively managing your own physical machines,
this isn't really an issue for you to worry about.</p>
<p>Here is how I'd suggest getting started with <code>git-crypt</code> for a personal flake
repo:</p>
<p>Initialize the repo with <code>git-crypt init</code>, make a directory dedicated to
secrets, and use a <code>.gitattributes</code> file to ensure that every file that you
create in that <code>secrets</code> subdirectory will always be encrypted.</p>
<pre style="background-color:#2b303b;color:#c0c5ce;"><code><span>secrets/** filter=git-crypt diff=git-crypt
</span></code></pre>
<p>Since the only user of this personal flake repository with access to decrypt
secrets will be you, it's more convenient to export a symmetric secret key and
base64 encode it so that you can throw it in 1Password or something similar.</p>
<pre data-lang="bash" style="background-color:#2b303b;color:#c0c5ce;" class="language-bash "><code class="language-bash" data-lang="bash"><span style="color:#bf616a;">git-crypt</span><span> export-key ./secret-key
</span><span style="color:#bf616a;">cat</span><span> ./secret-key | </span><span style="color:#bf616a;">base64 --encode </span><span>>./secret-key-base64
</span></code></pre>
<p>You can store the value of <code>./secret-key-base64</code> in your password manager, and
if you ever need to decrypt the files in this repo on another machine, you can
just decode the key before using it.</p>
<pre data-lang="bash" style="background-color:#2b303b;color:#c0c5ce;" class="language-bash "><code class="language-bash" data-lang="bash"><span style="color:#bf616a;">pbpaste </span><span>| </span><span style="color:#bf616a;">base64 --decode </span><span>>./secret-key
</span><span style="color:#bf616a;">git-crypt</span><span> unlock ./secret-key
</span></code></pre>
<p>Up until this point, this is all pretty basic stuff. Next let's take a look at
how we can ergonomically handle secrets within a flake.</p>
<p>I like to have a single <code>secrets.json</code> file that is structured with top-level
keys describing what the secret(s) relate to:</p>
<pre data-lang="json" style="background-color:#2b303b;color:#c0c5ce;" class="language-json "><code class="language-json" data-lang="json"><span>{
</span><span> "</span><span style="color:#a3be8c;">github</span><span>": {
</span><span> "</span><span style="color:#a3be8c;">oauth_token</span><span>": "</span><span style="color:#a3be8c;">ghp_...</span><span>"
</span><span> },
</span><span> "</span><span style="color:#a3be8c;">gitlab</span><span>": {
</span><span> "</span><span style="color:#a3be8c;">oauth_token</span><span>": "</span><span style="color:#a3be8c;">glpat-...</span><span>"
</span><span> },
</span><span> "</span><span style="color:#a3be8c;">tailscale</span><span>": {
</span><span> "</span><span style="color:#a3be8c;">authkey</span><span>": "</span><span style="color:#a3be8c;">tskey-auth-...</span><span>"
</span><span> }
</span><span>}
</span></code></pre>
<p>Then, at the top-most level of my flake I declare a variable called "secrets"
which reads the values from this file and deserializes them into a Nix object.</p>
<p>This <code>secrets</code> variable can then be passed as a member of <code>specialArgs</code> to make
it available to the various NixOS system configurations.</p>
<pre data-lang="nix" style="background-color:#2b303b;color:#c0c5ce;" class="language-nix "><code class="language-nix" data-lang="nix"><span>{
</span><span> </span><span style="color:#d08770;">description </span><span>= "</span><span style="color:#a3be8c;">My NixOS configurations</span><span>";
</span><span>
</span><span> </span><span style="color:#d08770;">inputs</span><span>.</span><span style="color:#d08770;">nixpkgs</span><span>.</span><span style="color:#d08770;">url </span><span>= "</span><span style="color:#a3be8c;">github:nixos/nixpkgs/nixos-23.05</span><span>";
</span><span> </span><span style="color:#d08770;">inputs</span><span>.</span><span style="color:#d08770;">nixpkgs-unstable</span><span>.</span><span style="color:#d08770;">url </span><span>= "</span><span style="color:#a3be8c;">github:nixos/nixpkgs/nixos-unstable</span><span>";
</span><span>
</span><span> </span><span style="color:#d08770;">outputs </span><span>= inputs: </span><span style="color:#b48ead;">let
</span><span> </span><span style="color:#d08770;">secrets </span><span>= </span><span style="color:#d08770;">builtins</span><span>.</span><span style="color:#bf616a;">fromJSON </span><span>(</span><span style="color:#d08770;">builtins</span><span>.</span><span style="color:#bf616a;">readFile </span><span>"</span><span style="font-style:italic;color:#ab7967;">${</span><span style="font-style:italic;color:#bf616a;">self</span><span style="font-style:italic;color:#ab7967;">}</span><span style="color:#a3be8c;">/secrets/secrets.json</span><span>");
</span><span> </span><span style="color:#b48ead;">in </span><span>{
</span><span> </span><span style="color:#65737e;"># make sure to "inherit secrets;" in the nixpkgs.lib.nixosSystem.specialArgs object for each of your machines
</span><span> };
</span><span>}
</span></code></pre>
<p>In particular, I find this very useful and easy to use inside of
<a href="https://github.com/nix-community/home-manager"><code>home-manager</code></a> to do things
like set URL overrides in my <code>gitconfig</code> so HTTPs clones of private
repositories automatically use an oauth token.</p>
<pre data-lang="nix" style="background-color:#2b303b;color:#c0c5ce;" class="language-nix "><code class="language-nix" data-lang="nix"><span style="color:#8fa1b3;">{</span><span>secrets, ...</span><span style="color:#8fa1b3;">}</span><span>: {
</span><span> </span><span style="color:#d08770;">programs</span><span>.</span><span style="color:#d08770;">git </span><span>= {
</span><span> </span><span style="color:#d08770;">enable </span><span>= </span><span style="color:#d08770;">true</span><span>;
</span><span> </span><span style="color:#d08770;">extraConfig </span><span>= {
</span><span> </span><span style="color:#d08770;">url </span><span>= {
</span><span> "</span><span style="color:#a3be8c;">https://oauth2:</span><span style="font-style:italic;color:#ab7967;">${</span><span style="font-style:italic;color:#bf616a;">secrets</span><span style="font-style:italic;color:#c0c5ce;">.</span><span style="font-style:italic;color:#bf616a;">github</span><span style="font-style:italic;color:#c0c5ce;">.</span><span style="font-style:italic;color:#bf616a;">oauth_token</span><span style="font-style:italic;color:#ab7967;">}</span><span style="color:#a3be8c;">@github.com</span><span>" = {
</span><span> </span><span style="color:#d08770;">insteadOf </span><span>= "</span><span style="color:#a3be8c;">https://github.com</span><span>";
</span><span> };
</span><span> "</span><span style="color:#a3be8c;">https://oauth2:</span><span style="font-style:italic;color:#ab7967;">${</span><span style="font-style:italic;color:#bf616a;">secrets</span><span style="font-style:italic;color:#c0c5ce;">.</span><span style="font-style:italic;color:#bf616a;">gitlab</span><span style="font-style:italic;color:#c0c5ce;">.</span><span style="font-style:italic;color:#bf616a;">oauth_token</span><span style="font-style:italic;color:#ab7967;">}</span><span style="color:#a3be8c;">@gitlab.com</span><span>" = {
</span><span> </span><span style="color:#d08770;">insteadOf </span><span>= "</span><span style="color:#a3be8c;">https://gitlab.com</span><span>";
</span><span> };
</span><span> };
</span><span> };
</span><span> };
</span><span>}
</span></code></pre>
<p>You can also reference other encrypted files in the <code>secrets</code> dir and use
<code>home-manager</code> to move them into place for you. For example, with an <code>.npmrc</code>
file:</p>
<pre data-lang="nix" style="background-color:#2b303b;color:#c0c5ce;" class="language-nix "><code class="language-nix" data-lang="nix"><span style="color:#8fa1b3;">{</span><span>secrets, ...</span><span style="color:#8fa1b3;">}</span><span>: {
</span><span> </span><span style="color:#d08770;">home</span><span>.</span><span style="color:#d08770;">username</span><span>.</span><span style="color:#d08770;">LGUG2Z</span><span>.</span><span style="color:#d08770;">file</span><span>."</span><span style="color:#a3be8c;">.npmrc</span><span>".</span><span style="color:#d08770;">source </span><span>= </span><span style="color:#a3be8c;">./secrets/.npmrc</span><span>;
</span><span>}
</span></code></pre>
<h2 id="providing-runtime-secrets-to-remote-machines-and-vms"><a class="zola-anchor" href="#providing-runtime-secrets-to-remote-machines-and-vms" aria-label="Anchor link for: providing-runtime-secrets-to-remote-machines-and-vms">Providing Runtime Secrets to Remote Machines and VMs</a></h2>
<p>So <code>git-crypt</code> works well for personal secrets like GitHub tokens, secrets that
live in configuration files under your <code>$HOME</code> folder, but what about when you
need to provide a runtime secret to a service running on a remote server?
Usually these services run on their own user accounts and groups, and
<code>home-manager</code> is not a good fit for provisioning secrets to these kinds of
non-user system accounts.</p>
<p>In this case, there are two options available: <code>agenix</code> and <code>sops-nix</code>.</p>
<h2 id="agenix"><a class="zola-anchor" href="#agenix" aria-label="Anchor link for: agenix">Agenix</a></h2>
<p><code>agenix</code> is, in my opinion, the simpler of the two to set up, and that's
probably why it has a bigger mindshare in the NixOS community right now.</p>
<p>You get started by creating a dedicated subdirectory and creating a
<code>secrets.nix</code> file inside of it.</p>
<pre data-lang="nix" style="background-color:#2b303b;color:#c0c5ce;" class="language-nix "><code class="language-nix" data-lang="nix"><span style="color:#b48ead;">let
</span><span> </span><span style="color:#d08770;">personal_key </span><span>= "</span><span style="color:#a3be8c;">ssh-rsa AAAA....</span><span>";
</span><span> </span><span style="color:#d08770;">remote_server_key </span><span>= "</span><span style="color:#a3be8c;">ssh-rsa AAAA....</span><span>";
</span><span> </span><span style="color:#d08770;">keys </span><span>= [</span><span style="color:#bf616a;">personal_key remote_server_key</span><span>];
</span><span style="color:#b48ead;">in </span><span>{
</span><span> "</span><span style="color:#a3be8c;">guest_accounts.json.age</span><span>".</span><span style="color:#d08770;">publicKeys </span><span>= </span><span style="color:#bf616a;">keys</span><span>;
</span><span>}
</span></code></pre>
<p>In this file you declare variables for the public keys of the machines that
will need access to the secrets (you can grab these by running <code>ssh-keyscan user@remote-ip</code>), and then create references to files that you will later
create, linking each of them to one or more public keys.</p>
<p>At this point, we have a file, which says "we're gonna make these encrypted
files" and "the private keys for the linked public keys will be able to decrypt
them".</p>
<p>The next step is to create the encrypted file by running <code>agenix -e service_account.json.age</code> in the same subdirectory. This opens up your
<code>$EDITOR</code> for you to type/paste your secret into. When you save and close the
text editor, the file will be encrypted, and this file can be added to <code>git</code>.</p>
<p>If you ever need to add more public keys for other machines that you want to be
able to decrypt these secrets, you can just run <code>agenix -r</code> inside the
subdirectory and commit the changes.</p>
<p>Next, in order to get these encrypted secrets into your flake, follow the
<a href="https://github.com/ryantm/agenix#install-via-flakes"><code>Install via Flakes</code></a>
steps on the project README (expand the content section) to ensure that you
have the <code>agenix.nixosModules.default</code> module loaded, and then start mapping
references to your encrypted files.</p>
<pre data-lang="nix" style="background-color:#2b303b;color:#c0c5ce;" class="language-nix "><code class="language-nix" data-lang="nix"><span>{
</span><span> </span><span style="color:#d08770;">inputs</span><span>.</span><span style="color:#d08770;">agenix</span><span>.</span><span style="color:#d08770;">url </span><span>= "</span><span style="color:#a3be8c;">github:ryantm/agenix</span><span>";
</span><span> </span><span style="color:#65737e;"># ... other inputs
</span><span>
</span><span> </span><span style="color:#d08770;">outputs </span><span>= {
</span><span> self,
</span><span> nixpkgs,
</span><span> agenix,
</span><span> </span><span style="color:#8fa1b3;">}</span><span>: {
</span><span> </span><span style="color:#d08770;">nixosConfigurations</span><span>.</span><span style="color:#d08770;">yourhostname </span><span>= </span><span style="color:#bf616a;">nixpkgs</span><span>.</span><span style="color:#bf616a;">lib</span><span>.</span><span style="color:#bf616a;">nixosSystem </span><span>{
</span><span> </span><span style="color:#d08770;">system </span><span>= "</span><span style="color:#a3be8c;">x86_64-linux</span><span>";
</span><span> </span><span style="color:#d08770;">modules </span><span>= [
</span><span> </span><span style="color:#65737e;"># ... your config
</span><span> </span><span style="color:#bf616a;">agenix</span><span>.</span><span style="color:#bf616a;">nixosModules</span><span>.</span><span style="color:#bf616a;">default
</span><span> {
</span><span> </span><span style="color:#d08770;">age</span><span>.</span><span style="color:#d08770;">secrets</span><span>."</span><span style="color:#a3be8c;">guest_accounts.json</span><span>".</span><span style="color:#d08770;">file </span><span>= </span><span style="color:#a3be8c;">./secrets/guest_accounts.json.age</span><span>;
</span><span> }
</span><span> ];
</span><span> };
</span><span> };
</span><span>}
</span></code></pre>
<p>Unlike <code>git-crypt</code>, this approach means that your secrets will end up in
<code>/nix/store</code> but they will be encrypted there, so even if another user or
process can find the file, they won't be able to make any sense of it.</p>
<p>What about making use of these secrets? Well, there is where you'll need to
change your approach a little if you are coming from <code>git-crypt</code>; you can't
really refer to the contents of these secrets in the <code>/nix/store</code> in your NixOS
configuration (ie. to do string interpolation) because they are encrypted, so
you have to instruct your services to read from the decrypted files in
<code>/run/agenix</code> when they start.</p>
<p>Typically this is done by using systemd's <code>LoadCredential</code> option, which will
make a copy of the decrypted secret in <code>/run/agenix</code> available to a service in
<code>/run/credentials/your-service.service/filename</code>, and then instructing the
service via environment variables to use the file in that location.</p>
<p>Below is an example from <a href="https://lgug2z.com/articles/providing-runtime-secrets-to-nixos-services/">my previous <code>agenix</code>
article</a>
where we loaded a <code>guest_accounts.json</code> secret file to be read by
<code>nitter.service</code>.</p>
<pre data-lang="nix" style="background-color:#2b303b;color:#c0c5ce;" class="language-nix "><code class="language-nix" data-lang="nix"><span>{
</span><span> </span><span style="color:#d08770;">systemd</span><span>.</span><span style="color:#d08770;">services</span><span>.</span><span style="color:#d08770;">nitter</span><span>.</span><span style="color:#d08770;">serviceConfig</span><span>.</span><span style="color:#d08770;">LoadCredential </span><span>= [
</span><span> "</span><span style="color:#a3be8c;">guest_account.json:</span><span style="font-style:italic;color:#ab7967;">${</span><span style="font-style:italic;color:#bf616a;">config</span><span style="font-style:italic;color:#c0c5ce;">.</span><span style="font-style:italic;color:#bf616a;">age</span><span style="font-style:italic;color:#c0c5ce;">.</span><span style="font-style:italic;color:#bf616a;">secrets</span><span style="font-style:italic;color:#c0c5ce;">."</span><span style="font-style:italic;color:#a3be8c;">guest_accounts.json</span><span style="font-style:italic;color:#c0c5ce;">".</span><span style="font-style:italic;color:#bf616a;">path</span><span style="font-style:italic;color:#ab7967;">}</span><span>"
</span><span> ];
</span><span>
</span><span> </span><span style="color:#d08770;">systemd</span><span>.</span><span style="color:#d08770;">services</span><span>.</span><span style="color:#d08770;">nitter</span><span>.</span><span style="color:#d08770;">serviceConfig</span><span>.</span><span style="color:#d08770;">Environment </span><span>= [
</span><span> "</span><span style="color:#a3be8c;">NITTER_CONF_FILE=/var/lib/private/nitter/nitter.conf</span><span>"
</span><span> "</span><span style="color:#a3be8c;">NITTER_ACCOUNTS_FILE=%d/guest_account.json</span><span>"
</span><span> ];
</span><span>}
</span></code></pre>
<p>As you can see, this process is geared very much towards files, so even if you
wanted to encrypt a GitHub OAuth token, you'd have to create a unique file with
<code>agenix -e github_token</code> and then find a way to <code>cat</code> the value of that into
your <code>systemd</code> script or service.</p>
<p>Here is an example from a <code>systemd</code> timer that I use to <a href="https://lgug2z.com/articles/embedding-rss-feeds-on-my-static-website/">periodically update
this
website</a>.</p>
<pre data-lang="nix" style="background-color:#2b303b;color:#c0c5ce;" class="language-nix "><code class="language-nix" data-lang="nix"><span>{
</span><span> </span><span style="color:#d08770;">systemd</span><span>.</span><span style="color:#d08770;">services</span><span>."</span><span style="color:#a3be8c;">update-lgug2z-com</span><span>" = {
</span><span> </span><span style="color:#d08770;">startAt </span><span>= "</span><span style="color:#a3be8c;">hourly</span><span>";
</span><span> </span><span style="color:#d08770;">serviceConfig </span><span>= {
</span><span> </span><span style="color:#d08770;">Type </span><span>= "</span><span style="color:#a3be8c;">oneshot</span><span>";
</span><span> </span><span style="color:#d08770;">ExecStart </span><span>= ''
</span><span style="color:#a3be8c;"> </span><span style="font-style:italic;color:#ab7967;">${</span><span style="font-style:italic;color:#bf616a;">pkgs</span><span style="font-style:italic;color:#c0c5ce;">.</span><span style="font-style:italic;color:#bf616a;">bash</span><span style="font-style:italic;color:#ab7967;">}</span><span style="color:#a3be8c;">/bin/bash -c "</span><span style="font-style:italic;color:#ab7967;">${</span><span style="font-style:italic;color:#bf616a;">pkgs</span><span style="font-style:italic;color:#c0c5ce;">.</span><span style="font-style:italic;color:#bf616a;">httpie</span><span style="font-style:italic;color:#ab7967;">}</span><span style="color:#a3be8c;">/bin/http POST \
</span><span style="color:#a3be8c;"> https://api.cloudflare.com/client/v4/accounts/</span><span style="color:#96b5b4;">''$</span><span style="color:#a3be8c;">(cat </span><span style="font-style:italic;color:#ab7967;">${</span><span style="font-style:italic;color:#bf616a;">config</span><span style="font-style:italic;color:#c0c5ce;">.</span><span style="font-style:italic;color:#bf616a;">age</span><span style="font-style:italic;color:#c0c5ce;">.</span><span style="font-style:italic;color:#bf616a;">secrets</span><span style="font-style:italic;color:#c0c5ce;">."</span><span style="font-style:italic;color:#a3be8c;">cloudflare_account_id</span><span style="font-style:italic;color:#c0c5ce;">".</span><span style="font-style:italic;color:#bf616a;">path</span><span style="font-style:italic;color:#ab7967;">}</span><span style="color:#a3be8c;">)/pages/projects/lgug2z-com/deployments \
</span><span style="color:#a3be8c;"> -A bearer -a </span><span style="color:#96b5b4;">''$</span><span style="color:#a3be8c;">(cat </span><span style="font-style:italic;color:#ab7967;">${</span><span style="font-style:italic;color:#bf616a;">config</span><span style="font-style:italic;color:#c0c5ce;">.</span><span style="font-style:italic;color:#bf616a;">age</span><span style="font-style:italic;color:#c0c5ce;">.</span><span style="font-style:italic;color:#bf616a;">secrets</span><span style="font-style:italic;color:#c0c5ce;">."</span><span style="font-style:italic;color:#a3be8c;">cloudflare_pages_api_token</span><span style="font-style:italic;color:#c0c5ce;">".</span><span style="font-style:italic;color:#bf616a;">path</span><span style="font-style:italic;color:#ab7967;">}</span><span style="color:#a3be8c;">)"
</span><span style="color:#a3be8c;"> </span><span>'';
</span><span> };
</span><span> };
</span><span>}
</span></code></pre>
<p>Note that you have to wrap this in a <code>bash -c</code> call to enable the use of <code>cat</code>.</p>
<p>The final generated <code>systemd</code> timer definition which gets symlinked from
<code>/nix/store</code> looks like this; no secrets exposed!</p>
<pre data-lang="toml" style="background-color:#2b303b;color:#c0c5ce;" class="language-toml "><code class="language-toml" data-lang="toml"><span style="color:#65737e;"># /etc/systemd/system/update-lgug2z-com.service
</span><span>
</span><span>[Unit]
</span><span>
</span><span>[Service]
</span><span style="color:#bf616a;">Environment</span><span>="</span><span style="color:#a3be8c;">LOCALE_ARCHIVE=/nix/store/fzm1flvb7zmz3ij4sscn521shz2f76jh-glibc-locales-2.37-45/lib/locale/locale-archive</span><span>"
</span><span style="color:#bf616a;">Environment</span><span>="</span><span style="color:#a3be8c;">PATH=/nix/store/w8vm09hri2zz7yacryzzzxvsapik4ps4-coreutils-9.1/bin:/nix/store/4cxs4cigh2zdxvma52ygm3mh2igq70iw-findutils-4.9.0/bin:/nix/store/b4in4hmq54h6l34a0v6ha40z97c0lzw2-gnugrep-3.7/bin:/nix/store/4mca20b13q88s6llkr8mc468rh9l9bmr-gnused-4.9/bin:/nix/store/12bynbp6y51j5449l27sy8ycgksd8npk-systemd-253.6/bin:/nix/store/w8vm09hri2zz7yacryzzzxvsapik4ps4-coreutils-9.1/sbin:/nix/store/4cxs4cigh2zdxvma52ygm3mh2igq70iw-findutils-4.9.0/sbin:/nix/store/b4in4hmq54h6l34a0v6ha40z97c0lzw2-gnugrep-3.7/sbin:/nix/store/4mca20b13q88s6llkr8mc468rh9l9bmr-gnused-4.9/sbin:/nix/store/12bynbp6y51j5449l27sy8ycgksd8npk-systemd-253.6/sbin</span><span>"
</span><span style="color:#bf616a;">Environment</span><span>="</span><span style="color:#a3be8c;">TZDIR=/nix/store/951696yxqlphz378fx126wjnrih08mz3-tzdata-2023c/share/zoneinfo</span><span>"
</span><span>
</span><span style="color:#bf616a;">ExecStart</span><span>=</span><span style="background-color:#bf616a;color:#2b303b;">/nix/store/0rwyq0j954a7143p0wzd4rhycny8i967-bash-5.2-p15/bin/bash -c "/nix/store/yzl3gaf8yjd1y8mql9f36yhz2ppgz98g-python3.10-httpie-3.2.2/bin/http POST \</span><span>
</span><span> </span><span style="color:#bf616a;">https</span><span style="background-color:#bf616a;color:#2b303b;">://api.cloudflare.com/client/v4/accounts/$(cat /run/secrets/cloudflare/account_id)/pages/projects/lgug2z-com/deployments \</span><span>
</span><span> </span><span style="color:#bf616a;">-A</span><span style="background-color:#bf616a;color:#2b303b;"> </span><span style="color:#bf616a;">bearer</span><span style="background-color:#bf616a;color:#2b303b;"> </span><span style="color:#bf616a;">-a</span><span style="background-color:#bf616a;color:#2b303b;"> $(cat /run/secrets/cloudflare/pages_api_token)"</span><span>
</span><span>
</span><span style="color:#bf616a;">Type</span><span>=</span><span style="background-color:#bf616a;color:#2b303b;">oneshot</span><span>
</span><span>
</span><span style="color:#65737e;"># /etc/systemd/system/update-lgug2z-com.timer
</span><span>
</span><span>[Unit]
</span><span>
</span><span>[Timer]
</span><span style="color:#bf616a;">OnCalendar</span><span>=</span><span style="background-color:#bf616a;color:#2b303b;">hourly</span><span>
</span></code></pre>
<h2 id="sops-nix"><a class="zola-anchor" href="#sops-nix" aria-label="Anchor link for: sops-nix">Sops-Nix</a></h2>
<p>So <code>agenix</code> lets us get provision secret files for services which remain
encrypted in the <code>/nix/store</code>, but are available decrypted for the specific
services to which we allow access. However the tradeoff here is that we lose
some of the convenience of being able to have smaller secrets such as tokens
all collected together in a single file in our repo.</p>
<p><code>sops-nix</code> is a little trickier to get set up, but offers a way for you to
retain your single encrypted file of secrets as a source of truth, both for
individual secret values and larger secrets files, all while providing clean
<code>git diff</code>s to make it easier to see what has changed.</p>
<p>Getting started requires you to create a <code>.sops.yaml</code> file at the root of your
flake repo.</p>
<pre data-lang="yaml" style="background-color:#2b303b;color:#c0c5ce;" class="language-yaml "><code class="language-yaml" data-lang="yaml"><span style="color:#bf616a;">keys</span><span>:
</span><span> - </span><span style="color:#b48ead;">&</span><span>remote </span><span style="color:#a3be8c;">age1...
</span><span> - </span><span style="color:#b48ead;">&</span><span>personal </span><span style="color:#a3be8c;">age1...
</span><span style="color:#bf616a;">creation_rules</span><span>:
</span><span> - </span><span style="color:#bf616a;">path_regex</span><span>: </span><span style="color:#a3be8c;">secrets/[^/]+\.(yaml|json|env|ini|sops)$
</span><span> </span><span style="color:#bf616a;">key_groups</span><span>:
</span><span> - </span><span style="color:#bf616a;">age</span><span>:
</span><span> - </span><span style="color:#b48ead;">*</span><span style="color:#bf616a;">remote
</span><span> - </span><span style="color:#b48ead;">*</span><span style="color:#bf616a;">personal
</span></code></pre>
<p>Let's walk through this. At the top we define some keys, just like we did in
<code>secrets.nix</code> for <code>agenix</code>. However, the keys here are a little different. You
will need to convert your SSH public keys into <code>age</code> public keys, and it's
simpler to do this using <code>ed25519</code> keys instead of <code>rsa</code> keys.</p>
<p>You can generate these <code>age</code> public keys for remote servers by running
<code>ssh-keygen user@remote-ip | ssh-to-age</code> (you might need to <code>nix-shell -p ssh-to-age</code> first if you don't have the package on your system), and for your
local machine by running <code>ssh-to-age -i ~/.ssh/id_ed25519.pub</code>.</p>
<p>Next is the <code>creation_rules</code> section, where instead of providing explicit names
for each encrypted file like we did in <code>secrets.nix</code> with <code>agenix</code>, we just
define a regex of encryptable files, and the public keys corresponding to the
private keys that we want to allow to decrypt them.</p>
<p>Before we start creating rules, it's important to make sure that we have
<code>ssh-to-age</code> in our system packages and create an <code>.envrc</code> for our flake repo,
otherwise we won't be able to decrypt any of our secrets locally!</p>
<pre data-lang="bash" style="background-color:#2b303b;color:#c0c5ce;" class="language-bash "><code class="language-bash" data-lang="bash"><span style="color:#65737e;"># make this point to wherever your own es25519 ssh key is
</span><span style="color:#b48ead;">export </span><span style="color:#bf616a;">SOPS_AGE_KEY</span><span>=$</span><span style="color:#a3be8c;">(</span><span style="color:#bf616a;">ssh-to-age -i ~</span><span style="color:#a3be8c;">/.ssh/id_ed25519</span><span style="color:#bf616a;"> -private-key</span><span style="color:#a3be8c;">)
</span></code></pre>
<p>With the <code>.sops.yaml</code> and <code>.envrc</code> files created, we can create an encrypted
file in our dedicated subdirectory by running <code>sops secrets/secrets.yaml</code> and
fill it with some values.</p>
<pre data-lang="yaml" style="background-color:#2b303b;color:#c0c5ce;" class="language-yaml "><code class="language-yaml" data-lang="yaml"><span style="color:#bf616a;">github</span><span>:
</span><span> </span><span style="color:#bf616a;">oauth_token</span><span>: </span><span style="color:#a3be8c;">ghp_...
</span><span style="color:#bf616a;">gitlab</span><span>:
</span><span> </span><span style="color:#bf616a;">oauth_token</span><span>: </span><span style="color:#a3be8c;">glpat-...
</span><span style="color:#bf616a;">tailscale</span><span>:
</span><span> </span><span style="color:#bf616a;">authkey</span><span>: </span><span style="color:#a3be8c;">tskey-auth-...`
</span><span style="color:#bf616a;">guest_accounts.json</span><span>: </span><span style="color:#b48ead;">|
</span><span style="color:#a3be8c;"> [
</span><span style="color:#a3be8c;"> {
</span><span style="color:#a3be8c;"> "id": "some-id",
</span><span style="color:#a3be8c;"> "token": "some-token",
</span><span style="color:#a3be8c;"> "grants": ["some" "grants"],
</span><span style="color:#a3be8c;"> }
</span><span style="color:#a3be8c;"> ]
</span><span style="color:#bf616a;">npmrc</span><span>: </span><span style="color:#b48ead;">|
</span><span style="color:#a3be8c;"> //registry.npmjs.org/:_authToken=npm_...
</span><span style="color:#a3be8c;"> //some.other.registry.org/:_authToken=npm_...
</span></code></pre>
<p>Notice that we can store both file contents and individual secrets like tokens
here; this is kind of the best of both worlds from the <code>git-crypt</code> and <code>agenix</code>
approaches, especially if like me you find it convenient to only have to
encrypt a single file.</p>
<p>Again, in order to get these encrypted secrets into your flake, follow the
<a href="https://github.com/Mic92/sops-nix#flakes-current-recommendation"><code>Install sops-nix (Flakes)</code></a>
steps on the project README (expand the content section) to ensure that you
have the <code>sops.nixosModules.default</code> module loaded, and then start mapping
references to your encrypted secretsa.</p>
<pre data-lang="nix" style="background-color:#2b303b;color:#c0c5ce;" class="language-nix "><code class="language-nix" data-lang="nix"><span>{
</span><span> </span><span style="color:#d08770;">inputs</span><span>.</span><span style="color:#d08770;">sops-nix</span><span>.</span><span style="color:#d08770;">url </span><span>= "</span><span style="color:#a3be8c;">github:Mic92/sops-nix</span><span>";
</span><span>
</span><span> </span><span style="color:#d08770;">outputs </span><span>= {
</span><span> self,
</span><span> nixpkgs,
</span><span> sops-nix,
</span><span> </span><span style="color:#8fa1b3;">}</span><span>: {
</span><span> </span><span style="color:#65737e;"># change `yourhostname` to your actual hostname
</span><span> </span><span style="color:#d08770;">nixosConfigurations</span><span>.</span><span style="color:#d08770;">yourhostname </span><span>= </span><span style="color:#bf616a;">nixpkgs</span><span>.</span><span style="color:#bf616a;">lib</span><span>.</span><span style="color:#bf616a;">nixosSystem </span><span>{
</span><span> </span><span style="color:#65737e;"># customize to your system
</span><span> </span><span style="color:#d08770;">system </span><span>= "</span><span style="color:#a3be8c;">x86_64-linux</span><span>";
</span><span> </span><span style="color:#d08770;">modules </span><span>= [
</span><span> </span><span style="color:#65737e;"># ... your config
</span><span> </span><span style="color:#bf616a;">sops-nix</span><span>.</span><span style="color:#bf616a;">nixosModules</span><span>.</span><span style="color:#bf616a;">sops
</span><span> {
</span><span> </span><span style="color:#d08770;">sops </span><span>= {
</span><span> </span><span style="color:#d08770;">defaultSopsFile </span><span>= </span><span style="color:#a3be8c;">./secrets/secrets.yaml</span><span>;
</span><span> </span><span style="color:#d08770;">age</span><span>.</span><span style="color:#d08770;">sshKeyPaths </span><span>= ["</span><span style="color:#a3be8c;">/etc/ssh/ssh_host_ed25519_key</span><span>"];
</span><span> </span><span style="color:#d08770;">secrets </span><span>= {
</span><span> "</span><span style="color:#a3be8c;">github/oauth_token</span><span>" = {};
</span><span> "</span><span style="color:#a3be8c;">gitlab/oauth_token</span><span>" = {};
</span><span> "</span><span style="color:#a3be8c;">tailscale/authkey</span><span>" = {};
</span><span> "</span><span style="color:#a3be8c;">guest_accounts.json</span><span>" = {};
</span><span> "</span><span style="color:#a3be8c;">npmrc</span><span>" = {
</span><span> </span><span style="color:#d08770;">owner </span><span>= "</span><span style="color:#a3be8c;">youruser</span><span>";
</span><span> </span><span style="color:#d08770;">path </span><span>= "</span><span style="color:#a3be8c;">/home/youruser/.npmrc</span><span>";
</span><span> };
</span><span> };
</span><span> };
</span><span> }
</span><span> ];
</span><span> };
</span><span> };
</span><span>}
</span></code></pre>
<p>There is a little more code here, but it's not as bad as it looks. Let's walk
through it. Since we can have multiple files encrypted by <code>sops</code>, <code>sops-nix</code>
helpfully allows us to define a <code>defaultSopsFile</code>, which is useful if you're
like me and you like to just keep a single encrypted file.</p>
<p>Then we have to set the paths to the private keys on the remote machine that
will be used to decrypt the encrypted <code>defaultSopsFile</code> that will end up in the
<code>/nix/store</code>.</p>
<p>Finally, we can declare our secrets. Each secret is output into <code>/run/secrets</code>
into a separate file, with a path that is taken from the object structure. For
example, <code>github.oauth_token</code>'s value will be output to
<code>/run/secrets/github/oauth_token</code>.</p>
<p>We can also use this to our advantage by storing stringified verisons of entire
files in the way that we have for <code>guest_accounts.json</code> and <code>npmrc</code>, to ensure
that these will be output to <code>/run/secrets/guest_accounts.json</code> and
<code>/run/secrets/.npmrc</code> respectively.</p>
<p>Notice that in the case of <code>npmrc</code>, we can actually set the owner for this file
and a location for it to be symlinked to, which is a useful way to replicate
the placing of files in the <code>$HOME</code> directory used in the <code>git-crypt</code> approach
(you can also do this with <code>agenix</code>).</p>
<p>To make these secrets in their decrypted forms available to <code>systemd</code> services,
just follow the same steps outlined for <code>agenix</code>, but reference the path from
<code>sops</code> instead of <code>age</code>.</p>
<h2 id="summary"><a class="zola-anchor" href="#summary" aria-label="Anchor link for: summary">Summary</a></h2>
<p>Hopefully this overview of when you might want to use these different
approaches will help you to decide what is right for your use case. As a little tl;dr:</p>
<ul>
<li>Just managing your own machines? <code>git-crypt</code> is fine</li>
<li>Managing your own machines and remote servers? You'll need <code>agenix</code> or
<code>sops-nix</code> to provide secrets to services</li>
<li>Wanna keep as much as possible in a single encrypted file? <code>sops-nix</code> is
probably the way to go</li>
</ul>
<p>However, keep in mind that you don't have to pick only one of these! For
example, you can use a <code>git-crypt</code>ed <code>secrets.json</code> to populate values on
machines that you use for secret values in your configuration files and <em>also</em>
use <code>agenix</code> or <code>sops-nix</code> to provide secrets to services on remote servers and
VMs!</p>
<p>If you have any questions you can reach out to me on
<a href="https://twitter.com/JeezyCodes">Twitter</a> and
<a href="https://hachyderm.io/@LGUG2Z">Mastodon</a>.</p>
<p>If you're interested in what I read to come up with solutions like this one,
you can subscribe to my <a href="https://notado.app/feeds/jado/software-development">Software Development RSS
feed</a>.</p>
<p>If you'd like to watch me writing code while explaining what I'm doing, you can
also <a href="https://www.youtube.com/channel/UCeai3-do-9O4MNy9_xjO6mg?sub_confirmation=1">subscribe to my YouTube
channel</a>.</p>
<hr />
<p>On 11/14/2023 I was impacted by large scale layoffs at my previous employer. I
am currently looking for work. I am an experienced SRE with a strong passion
for developer enablement. Please reach out if you are hiring for a role that
you think I'd be a good fit for.</p>
<p>If you found this content valuable, please consider sponsoring me on
<a href="https://github.com/sponsors/LGUG2Z">GitHub</a> or tipping me on
<a href="https://ko-fi.com/lgug2z">Ko-fi</a> to help me through this uncertain period.</p>
Providing Runtime Secrets to NixOS ServicesSat, 11 Nov 2023 19:08:18 +0000lgug2z
https://lgug2z.com/articles/providing-runtime-secrets-to-nixos-services/
https://lgug2z.com/articles/providing-runtime-secrets-to-nixos-services/<p>In my <a href="https://lgug2z.com/articles/a-working-instance-of-nitter-on-nixos/">last
post</a>, I
shared how to get a working instance of Nitter deployed on NixOS, but requested
advice on how to best automatically provision the <code>guest_accounts.json</code> runtime
secret file on the target server.</p>
<p>A number of folks reached out to me on Mastodon (thanks
<a href="https://ioc.exchange/@vt52">@[email protected]</a>,
<a href="https://merveilles.town/@aynish">@[email protected]</a>,
<a href="https://schreibt.jetzt/@linus">@[email protected]</a> and
<a href="https://octodon.social/@uep">@[email protected]</a>!) to suggest that I use
<a href="https://github.com/ryantm/agenix"><code>agenix</code></a> to copy encrypted files to the
server and decrypt them in non-world readable directories, and then use
<code>systemd</code>'s <code>LoadCredentials</code> option to make them available to the <code>nitter</code>
service.</p>
<p>Honestly, it took me a while to understand what I was reading on the <code>agenix</code>
README page, which is why I thought I'd do this additional technical write-up
specifically for people who need to use <code>agenix</code> for the first time to
provision runtime secrets for a <code>systemd</code> service.</p>
<h2 id="installing-agenix-and-making-file-references"><a class="zola-anchor" href="#installing-agenix-and-making-file-references" aria-label="Anchor link for: installing-agenix-and-making-file-references">Installing Agenix and Making File References</a></h2>
<p>The first step is to add the input to our <code>flake.nix</code> file and add the
<code>agenix</code> binary the your system, this part is easy enough to follow along with
<a href="https://github.com/ryantm/agenix#install-via-flakes">on the official README</a>.</p>
<p>Next, we need to create a <code>secrets.nix</code> file; the README suggests creating this
in a <code>secrets</code> subdirectory, so we'll go with that.</p>
<pre data-lang="nix" style="background-color:#2b303b;color:#c0c5ce;" class="language-nix "><code class="language-nix" data-lang="nix"><span style="color:#b48ead;">let
</span><span> </span><span style="color:#d08770;">nitter_server_key </span><span>= "</span><span style="color:#a3be8c;">ssh-rsa AAAA....</span><span>";
</span><span> </span><span style="color:#d08770;">keys </span><span>= [</span><span style="color:#bf616a;">nitter_server_key</span><span>];
</span><span style="color:#b48ead;">in </span><span>{
</span><span> "</span><span style="color:#a3be8c;">guest_accounts.json.age</span><span>".</span><span style="color:#d08770;">publicKeys </span><span>= </span><span style="color:#bf616a;">keys</span><span>;
</span><span>}
</span></code></pre>
<p>In this file we add the SSH public key of a corresponding private key that we
expect to be somewhere on the target server (these are typically already
generated for us thanks to the default value of
<a href="https://search.nixos.org/options?channel=23.05&show=services.openssh.hostKeys"><code>services.openssh.hostKeys</code></a>).</p>
<p><em>If you have multiple keys on multiple machines, you can give them different
variable names and collect them all in the <code>keys</code> list (if you will be working
with secrets that need to be deployed to multiple machines).</em></p>
<p>Finally, we associate those public keys to a file reference. Notice that we
haven't actually created any encrypted files yet. We are just making a
reference, and stating that the private keys that correspond to the given
public keys can be used to decrypt whatever file is eventually associated with
that reference.</p>
<h2 id="creating-encrypted-files"><a class="zola-anchor" href="#creating-encrypted-files" aria-label="Anchor link for: creating-encrypted-files">Creating Encrypted Files</a></h2>
<p>Once we have the reference for a file that will be titled
<code>guest_accounts.json.age</code>, we can run <code>agenix -e guest_accounts.json.age</code>,
which will make our <code>$EDITOR</code> open.</p>
<p>Here we can paste in our various Twitter guest account JSON objects. Once done,
we save the file and exit the editor. We now have an encrypted
<code>guest_accounts.json.age</code> file! Make sure to <code>git add</code> it.</p>
<h2 id="getting-the-encrypted-files-on-a-nixos-server"><a class="zola-anchor" href="#getting-the-encrypted-files-on-a-nixos-server" aria-label="Anchor link for: getting-the-encrypted-files-on-a-nixos-server">Getting the Encrypted Files on a NixOS Server</a></h2>
<p>In our <code>flake.nix</code> file we can start by putting together a little helper:</p>
<pre data-lang="nix" style="background-color:#2b303b;color:#c0c5ce;" class="language-nix "><code class="language-nix" data-lang="nix"><span>{
</span><span> </span><span style="color:#d08770;">agenixSecrets </span><span>= {
</span><span> userHome ? "</span><span style="color:#a3be8c;">/home/<YOUR_MOST_COMMON_USERNAME></span><span>",
</span><span> files,
</span><span> </span><span style="color:#8fa1b3;">}</span><span>: {
</span><span> </span><span style="color:#d08770;">age </span><span>= {
</span><span> </span><span style="color:#d08770;">identityPaths </span><span>= [
</span><span> "</span><span style="color:#a3be8c;">/etc/ssh/ssh_host_ed25519_key</span><span>"
</span><span> "</span><span style="color:#a3be8c;">/etc/ssh/ssh_host_rsa_key</span><span>"
</span><span> "</span><span style="font-style:italic;color:#ab7967;">${</span><span style="font-style:italic;color:#bf616a;">userHome</span><span style="font-style:italic;color:#ab7967;">}</span><span style="color:#a3be8c;">/.ssh/id_rsa</span><span>"
</span><span> ];
</span><span>
</span><span> </span><span style="color:#d08770;">secrets </span><span>= </span><span style="color:#bf616a;">files</span><span>;
</span><span> };
</span><span> };
</span><span>}
</span></code></pre>
<p>This is a Nix function which takes a <code>userHome</code> optional argument, which has a
default, and <code>files</code>, which is an object where we link our file references in
<code>secrets.nix</code> to actual encrypted files created by the <code>agenix -e</code> command.</p>
<p>By default, <code>age.identityPaths</code> is populated with the keys created by
<code>services.openssh.hostKeys</code>, which are one RSA key and one ed25519 key. I have
another SSH RSA key that I typically use for my main user account which
migrates with me from machine to machine which I also add to this list right at
the end.</p>
<p>This is built with the <code>userHome</code> variable because this typically differs on
Linux and macOS machines, with the format of the latter being
<code>/Users/<YOUR_USERNAME></code>. This lets us always default to Linux but gives us the
choice to add an override if we ever want to pass a secret encrypted with
<code>agenix</code> to a macOS machine.</p>
<pre data-lang="nix" style="background-color:#2b303b;color:#c0c5ce;" class="language-nix "><code class="language-nix" data-lang="nix"><span>{
</span><span> </span><span style="color:#d08770;">modules </span><span>= [
</span><span> </span><span style="color:#a3be8c;">./machines/remote-server.nix
</span><span> (</span><span style="color:#bf616a;">agenixSecrets </span><span>{
</span><span> </span><span style="color:#d08770;">files </span><span>= {
</span><span> "</span><span style="color:#a3be8c;">guest_accounts.json</span><span>".</span><span style="color:#d08770;">file </span><span>= </span><span style="color:#a3be8c;">./secrets/guest_accounts.json.age</span><span>;
</span><span> };
</span><span> })
</span><span> ];
</span><span>}
</span></code></pre>
<p>We can call this <code>agenixSecrets</code> helper in the <code>modules</code> of our target server
definition, omitting the <code>userHome</code> argument if it's unnecessary, and passing
the <code>files</code> objects which references our newly created
<code>guest_accounts.json.age</code> encrypted file.</p>
<p>The key on the left hand side is the name of the file that will be outputted on
the server (this can be whatever you want!), and we set the <code>file</code> property on
that key to the path of the <em>encrypted</em> file in our flake repository.</p>
<p>The process so far looks like this:</p>
<ul>
<li>Step 1: <code>"guest_accounts.json.age"</code> reference in <code>secrets.nix</code> => linked with
one or more public keys</li>
<li>Step 2: <code>guest_accounts.json.age</code> file => encrypted for the public keys
linked in step 1 with the <code>agenix -e guest_accounts.json.age</code> command</li>
<li>Step 3: <code>"guest_accounts.json"</code> in flake.nix => linked with the encrypted
files created in step 2</li>
</ul>
<p>This means that the file that will be added to the <code>/nix/store</code> on the target
server <em>will still be encrypted</em>. This is important because <code>/nix/store</code> is
world-readable, and typically we don't want every user to be able to dig in
there and find secrets they shouldn't have access to.</p>
<p>If we build and apply these changes to the target server now, we will find our
decrypted file present at <code>/run/agenix/guest_accounts.json</code>. Progress!</p>
<h2 id="passing-encrypted-files-to-a-systemd-service"><a class="zola-anchor" href="#passing-encrypted-files-to-a-systemd-service" aria-label="Anchor link for: passing-encrypted-files-to-a-systemd-service">Passing Encrypted Files to a Systemd Service</a></h2>
<p>Now, for the final piece of the puzzle: making the <code>guest_accounts.json</code> file
available to our <code>nitter</code> <code>systemd</code> service. We can go back to the snippet from
the previous post and update it like this:</p>
<pre data-lang="nix" style="background-color:#2b303b;color:#c0c5ce;" class="language-nix "><code class="language-nix" data-lang="nix"><span>{
</span><span> </span><span style="color:#d08770;">systemd</span><span>.</span><span style="color:#d08770;">services</span><span>.</span><span style="color:#d08770;">nitter</span><span>.</span><span style="color:#d08770;">serviceConfig</span><span>.</span><span style="color:#d08770;">LoadCredential </span><span>= [
</span><span> "</span><span style="color:#a3be8c;">guest_account.json:</span><span style="font-style:italic;color:#ab7967;">${</span><span style="font-style:italic;color:#bf616a;">config</span><span style="font-style:italic;color:#c0c5ce;">.</span><span style="font-style:italic;color:#bf616a;">age</span><span style="font-style:italic;color:#c0c5ce;">.</span><span style="font-style:italic;color:#bf616a;">secrets</span><span style="font-style:italic;color:#c0c5ce;">."</span><span style="font-style:italic;color:#a3be8c;">guest_accounts.json</span><span style="font-style:italic;color:#c0c5ce;">".</span><span style="font-style:italic;color:#bf616a;">path</span><span style="font-style:italic;color:#ab7967;">}</span><span>"
</span><span> ];
</span><span>
</span><span> </span><span style="color:#d08770;">systemd</span><span>.</span><span style="color:#d08770;">services</span><span>.</span><span style="color:#d08770;">nitter</span><span>.</span><span style="color:#d08770;">serviceConfig</span><span>.</span><span style="color:#d08770;">Environment </span><span>= [
</span><span> "</span><span style="color:#a3be8c;">NITTER_CONF_FILE=/var/lib/private/nitter/nitter.conf</span><span>"
</span><span> "</span><span style="color:#a3be8c;">NITTER_ACCOUNTS_FILE=%d/guest_account.json</span><span>"
</span><span> ];
</span><span>}
</span></code></pre>
<p>First we want to override the <code>serviceConfig</code> of <code>services.nitter</code> to add the
<code>LoadCredential</code> option. Most NixOS services run using <code>systemd</code>'s
<code>DynamicUser</code> option, which means that they won't have access to files owned by
<code>root</code> in <code>/run/agenix</code>.</p>
<p>We use <code>LoadCredential</code> to tell <code>systemd</code> to load the credential at the path on
the right hand side of the <code>:</code> to a file accessible by the <code>DynamicUser</code> of
this service with the filename given on the left hand side.</p>
<p>Then, we can finally update the <code>NITTER_ACCOUNTS_FILE</code> environment variable to
point to this file. <code>%d</code> is a templating feature provided by <code>systemd</code> that
will always resolve to the directory where any loaded credentials are placed.
This is typically a directory like <code>/run/credentials/your-service.service</code>, but
the less hard-coding we have to do, the better.</p>
<p>We are now ready to apply all of these changes, and have a working instance
<code>nitter</code> running on our NixOS server with an automatically provisioned runtime
secret file!</p>
<p>Just don't forget to remove the file that you manually placed in the
<code>/var/lib/private/nitter.service</code> directory if you followed along with the
previous post.</p>
<p>If you have any questions you can reach out to me on
<a href="https://twitter.com/JeezyCodes">Twitter</a> and
<a href="https://hachyderm.io/@LGUG2Z">Mastodon</a>.</p>
<p>If you're interested in what I read to come up with solutions like this one,
you can subscribe to my <a href="https://notado.app/feeds/jado/software-development">Software Development RSS
feed</a>.</p>
<p>If you'd like to watch me writing code while explaining what I'm doing, you can
also <a href="https://www.youtube.com/channel/UCeai3-do-9O4MNy9_xjO6mg?sub_confirmation=1">subscribe to my YouTube
channel</a>.</p>
A Working Instance of Nitter on NixOSFri, 10 Nov 2023 19:44:15 +0000lgug2z
https://lgug2z.com/articles/a-working-instance-of-nitter-on-nixos/
https://lgug2z.com/articles/a-working-instance-of-nitter-on-nixos/<p>With all of the various user and developer-hostile changes introduced to
Twitter over the past year, the importance of a user-friendly alternative
frontend for Twitter is greater than ever.</p>
<p>After using public instances of <a href="https://github.com/zedeus/nitter/">Nitter</a> for
a while, I wanted to try hosting my own instance. I thought it would be as
simple as enabling a service in my NixOS configuration:</p>
<pre data-lang="nix" style="background-color:#2b303b;color:#c0c5ce;" class="language-nix "><code class="language-nix" data-lang="nix"><span>{
</span><span> </span><span style="color:#d08770;">services</span><span>.</span><span style="color:#d08770;">nitter</span><span>.</span><span style="color:#d08770;">enable </span><span>= </span><span style="color:#d08770;">true</span><span>;
</span><span>}
</span></code></pre>
<p>Unfortunately, the only builds of Nitter that currently work are taken from a
<a href="https://github.com/zedeus/nitter/tree/guest_accounts">feature development
branch</a> which introduces
changes both in build-time dependencies and runtime dependencies. You can read
the full <a href="https://github.com/zedeus/nitter/issues/983">issue</a> for the details
(including how to generate guest accounts, which you'll need later).</p>
<p>Here are my steps if you want to have a working instance of Nitter running on
your NixOS server as of early November 2023:</p>
<pre data-lang="nix" style="background-color:#2b303b;color:#c0c5ce;" class="language-nix "><code class="language-nix" data-lang="nix"><span>{
</span><span> </span><span style="color:#d08770;">services</span><span>.</span><span style="color:#d08770;">nitter </span><span>= {
</span><span> </span><span style="color:#d08770;">enable </span><span>= </span><span style="color:#d08770;">true</span><span>;
</span><span> </span><span style="color:#d08770;">package </span><span>= </span><span style="color:#bf616a;">pkgs</span><span>.</span><span style="color:#bf616a;">nitter</span><span>.</span><span style="color:#bf616a;">overrideAttrs </span><span>(old: {
</span><span> </span><span style="color:#d08770;">version </span><span>= "</span><span style="color:#a3be8c;">guest_accounts</span><span>";
</span><span> </span><span style="color:#d08770;">src </span><span>= </span><span style="color:#bf616a;">pkgs</span><span>.</span><span style="color:#bf616a;">fetchFromGitHub </span><span>{
</span><span> </span><span style="color:#d08770;">owner </span><span>= "</span><span style="color:#a3be8c;">zedeus</span><span>";
</span><span> </span><span style="color:#d08770;">repo </span><span>= "</span><span style="color:#a3be8c;">nitter</span><span>";
</span><span> </span><span style="color:#d08770;">rev </span><span>= "</span><span style="color:#a3be8c;">eaedd2aee7be6bc3dd2dceee09dc93052d0046f4</span><span>";
</span><span> </span><span style="color:#d08770;">hash </span><span>= "</span><span style="color:#a3be8c;">sha256-px0wyCYiI03DefIIF9+Xr95ChyASvg9N//cARFyRM5I=</span><span>";
</span><span> };
</span><span>
</span><span> </span><span style="color:#d08770;">buildInputs </span><span>=
</span><span> </span><span style="color:#bf616a;">old</span><span>.</span><span style="color:#bf616a;">buildInputs
</span><span> ++ [
</span><span> (</span><span style="color:#bf616a;">pkgs</span><span>.</span><span style="color:#bf616a;">nimPackages</span><span>.</span><span style="color:#bf616a;">buildNimPackage
</span><span> </span><span style="color:#b48ead;">rec </span><span>{
</span><span> </span><span style="color:#d08770;">pname </span><span>= "</span><span style="color:#a3be8c;">oauth</span><span>";
</span><span> </span><span style="color:#d08770;">version </span><span>= "</span><span style="color:#a3be8c;">b8c163b0d9cfad6d29ce8c1fb394e5f47182ee1c</span><span>";
</span><span>
</span><span> </span><span style="color:#d08770;">src </span><span>= </span><span style="color:#bf616a;">pkgs</span><span>.</span><span style="color:#bf616a;">fetchFromGitHub </span><span>{
</span><span> </span><span style="color:#d08770;">owner </span><span>= "</span><span style="color:#a3be8c;">CORDEA</span><span>";
</span><span> </span><span style="color:#d08770;">repo </span><span>= </span><span style="color:#bf616a;">pname</span><span>;
</span><span> </span><span style="color:#d08770;">rev </span><span>= </span><span style="color:#bf616a;">version</span><span>;
</span><span> </span><span style="color:#d08770;">sha256 </span><span>= "</span><span style="color:#a3be8c;">0k5slyzjngbdr6g0b0dykhqmaf8r8n2klbkg2gpid4ckm8hg62v5</span><span>";
</span><span> };
</span><span>
</span><span> </span><span style="color:#d08770;">propagatedBuildInputs </span><span>= [
</span><span> (</span><span style="color:#bf616a;">pkgs</span><span>.</span><span style="color:#bf616a;">nimPackages</span><span>.</span><span style="color:#bf616a;">buildNimPackage
</span><span> </span><span style="color:#b48ead;">rec </span><span>{
</span><span> </span><span style="color:#d08770;">pname </span><span>= "</span><span style="color:#a3be8c;">sha1</span><span>";
</span><span> </span><span style="color:#d08770;">version </span><span>= "</span><span style="color:#a3be8c;">92ccc5800bb0ac4865b275a2ce3c1544e98b48bc</span><span>";
</span><span>
</span><span> </span><span style="color:#d08770;">src </span><span>= </span><span style="color:#bf616a;">pkgs</span><span>.</span><span style="color:#bf616a;">fetchFromGitHub </span><span>{
</span><span> </span><span style="color:#d08770;">owner </span><span>= "</span><span style="color:#a3be8c;">onionhammer</span><span>";
</span><span> </span><span style="color:#d08770;">repo </span><span>= </span><span style="color:#bf616a;">pname</span><span>;
</span><span> </span><span style="color:#d08770;">rev </span><span>= </span><span style="color:#bf616a;">version</span><span>;
</span><span> </span><span style="color:#d08770;">sha256 </span><span>= "</span><span style="color:#a3be8c;">sha256-tWHouIa6AFRmbvJaMsoWKNZX7bzqd3Je1kJ4rVHb+wM=</span><span>";
</span><span> };
</span><span> })
</span><span> ];
</span><span> })
</span><span> ];
</span><span> });
</span><span>
</span><span> </span><span style="color:#d08770;">server</span><span>.</span><span style="color:#d08770;">hostname </span><span>= "</span><span style="color:#a3be8c;">subdomain.your.website</span><span>";
</span><span> </span><span style="color:#d08770;">server</span><span>.</span><span style="color:#d08770;">port </span><span>= </span><span style="color:#a3be8c;"><CUSTOM_PORT></span><span>;
</span><span> };
</span><span>}
</span></code></pre>
<p>In the snippet above we:</p>
<ul>
<li>Override the package to point to the latest commit on the <code>guest_accounts</code> feature branch</li>
<li>Override the <code>buildInputs</code> list to include a new Nitter dependency, the <code>oauth</code> Nim package and its own dependency, the <code>sha1</code> Nim package</li>
<li>Set our hostname and a custom port (the default port is likely to clash with other services we may have running)</li>
</ul>
<p>Next, to make it available on that hostname, we can use Caddy (or Nginx, or whatever else you prefer):</p>
<pre data-lang="nix" style="background-color:#2b303b;color:#c0c5ce;" class="language-nix "><code class="language-nix" data-lang="nix"><span>{
</span><span> </span><span style="color:#d08770;">services</span><span>.</span><span style="color:#d08770;">caddy </span><span>= {
</span><span> </span><span style="color:#d08770;">enable </span><span>= </span><span style="color:#bf616a;">enableWebServer</span><span>;
</span><span> </span><span style="color:#d08770;">virtualHosts </span><span>= {
</span><span> "</span><span style="color:#a3be8c;">https://subdomain.your.website</span><span>".</span><span style="color:#d08770;">extraConfig </span><span>= ''
</span><span style="color:#a3be8c;"> reverse_proxy 127.0.0.1:<CUSTOM_PORT>
</span><span style="color:#a3be8c;"> </span><span>'';
</span><span> };
</span><span> };
</span><span>}
</span></code></pre>
<ul>
<li>Make sure that you add a matching DNS record in your provider which points to the IP address of your server</li>
</ul>
<p>Finally, we need to tell Nitter where to find the new runtime dependency, <code>guest_accounts.json</code>:</p>
<pre data-lang="nix" style="background-color:#2b303b;color:#c0c5ce;" class="language-nix "><code class="language-nix" data-lang="nix"><span>{
</span><span> </span><span style="color:#d08770;">systemd</span><span>.</span><span style="color:#d08770;">services</span><span>.</span><span style="color:#d08770;">nitter</span><span>.</span><span style="color:#d08770;">serviceConfig</span><span>.</span><span style="color:#d08770;">Environment </span><span>= [
</span><span> "</span><span style="color:#a3be8c;">NITTER_CONF_FILE=/var/lib/private/nitter/nitter.conf</span><span>"
</span><span> "</span><span style="color:#a3be8c;">NITTER_ACCOUNTS_FILE=/var/lib/private/nitter/guest_accounts.json</span><span>"
</span><span> ];
</span><span>}
</span></code></pre>
<p>We do this by overriding the <code>Environment</code> setting on the <code>systemd</code> service.
The first entry for <code>NITTER_CONF_FILE</code> is taken from the <a href="https://github.com/NixOS/nixpkgs/blob/41de143fda10e33be0f47eab2bfe08a50f234267/nixos/modules/services/misc/nitter.nix#L341">source file of the
service</a>,
and the second entry is added by us.</p>
<p>I was doing this in a bit of a rush so I didn't automate the creation of the
<code>guest_accounts.json</code> file in <code>/var/lib/private/nitter</code>. You can also do as I
did and open a root shell with <code>sudo su</code> on the server, navigate to the
directory and create the <code>guest_accounts.json</code> file filled with your guest
credentials. (If you can suggest a clean way to provision files in
<code>/var/lib/private/*</code> directories, please reach out!)</p>
<p>If you're doing it like I did, the first time you apply the changes that enable
the service, the <code>nitter</code> service will fail to start because the
<code>guest_accounts.json</code> file doesn't exist yet.</p>
<p>No problem. Hit <code>sudo systemctl stop nitter</code>, ensure that the file is in place,
then <code>sudo systemctl start nitter</code> again when you're ready.</p>
<p>If you have any questions you can reach out to me on
<a href="https://twitter.com/JeezyCodes">Twitter</a> and
<a href="https://hachyderm.io/@LGUG2Z">Mastodon</a>.</p>
<p>If you're interested in what I read to come up with solutions like this one,
you can subscribe to my <a href="https://notado.app/feeds/jado/software-development">Software Development RSS
feed</a>.</p>
<p>If you'd like to watch me writing code while explaining what I'm doing, you can
also <a href="https://www.youtube.com/channel/UCeai3-do-9O4MNy9_xjO6mg?sub_confirmation=1">subscribe to my YouTube
channel</a>.</p>
<p>Edit 11/11/2023: Some folks reached out with advice on how to automate the
creation of the <code>guest_accounts.json</code> secret file; check out <a href="https://lgug2z.com/articles/providing-runtime-secrets-to-nixos-services">this
post</a>
for more info!</p>
Using Rust, Chrome and NixOS to Take Headless Screenshots for Social SharingWed, 08 Nov 2023 16:06:39 +0000lgug2z
https://lgug2z.com/articles/using-rust-chrome-and-nixos-to-take-headless-screenshots-for-social-sharing/
https://lgug2z.com/articles/using-rust-chrome-and-nixos-to-take-headless-screenshots-for-social-sharing/<p>The social media landscape from Twitter and Mastodon to Instagram and TikTok
has, for better or worse, centralized on sharing text highlights and quotes as
images rather than as plain text.</p>
<figure class="left">
<img src="images/screenshot.png"
/>
<figcaption class="center"
>
Now I can share my highlights easily as images on social media!
</figcaption>
</figure>
<p>I like to share my highlights from across the web, which is why I publish
<a href="https://notado.app/jado/feeds">topic-specific RSS feeds</a> for people to
subscribe to.</p>
<p>However, one of the features that I've been missing for a while now on
<a href="https://notado.app">Notado</a> is exporting screenshots for social sharing. This
weekend I finally set aside some time to make this feature a reality.</p>
<h2 id="creating-the-view"><a class="zola-anchor" href="#creating-the-view" aria-label="Anchor link for: creating-the-view">Creating the View</a></h2>
<p>I have a lot of qualms with Tailwind, but it's hard to deny that it's a very
convenient choice for a view that is largely isolated from the rest of a web
application, especially one that exists primarily as an export view.</p>
<p>I was quickly able to get a design together on
<a href="https://play.tailwindcss.com">play.tailwindcss.com</a>, which I then moved over
to a <a href="https://github.com/Keats/tera">Tera HTML template</a> to be rendered by the
Rust web server that powers Notado.</p>
<figure class="left">
<img src="images/tailwindplay.png"
/>
<figcaption class="center"
>
Interactively building the export view design on play.tailwindcss.com
</figcaption>
</figure>
<h2 id="taking-chrome-screenshots-on-a-web-server"><a class="zola-anchor" href="#taking-chrome-screenshots-on-a-web-server" aria-label="Anchor link for: taking-chrome-screenshots-on-a-web-server">Taking Chrome Screenshots on a Web Server</a></h2>
<p>I initially tested out taking screenshots on a headless instance of Chrome
locally using
<a href="https://github.com/sindresorhus/capture-website-cli">capture-website-cli</a>.
This was very helpful in giving me an idea of what was possible, how I should
target the right elements, how to modify the page width to be able to export
longer content properly, etc.</p>
<p>However, this tool had not been packaged in
<a href="https://search.nixos.org">nixpkgs</a>, the repository doesn't even include a
lockfile, and I'm generally not a fan of shelling out blocking commands from a
handler on a web server.</p>
<p>I started taking a look in the Rust ecosystem and came across the
<a href="https://github.com/rust-headless-chrome/rust-headless-chrome"><code>rust-headless-chrome</code></a>
crate.</p>
<blockquote>
<p>A high-level API to control headless Chrome or Chromium over the DevTools
Protocol. It is the Rust equivalent of Puppeteer, a Node library maintained
by the Chrome DevTools team.</p>
</blockquote>
<p>This was just what I was looking for.</p>
<p>As I mentioned above, I'm not a fan of potentially long blocking commands
running inside a handler on a web server, and spinning up Chrome instances on
demand to take screenshots is not exactly easy on the hardware.</p>
<p>I decided to write and deploy a separate microservice called on a larger
dedicated machine that I rent for various miscellaneous workloads.</p>
<p>Below is some pseudocode demonstrating roughly what a screenshot microservice
handler in Axum might look like:</p>
<pre data-lang="rust" style="background-color:#2b303b;color:#c0c5ce;" class="language-rust "><code class="language-rust" data-lang="rust"><span style="color:#b48ead;">use </span><span>axum::body::Body;
</span><span style="color:#b48ead;">use </span><span>axum::extract::Path;
</span><span style="color:#b48ead;">use </span><span>axum::http::StatusCode;
</span><span style="color:#b48ead;">use </span><span>axum::response::Response;
</span><span>
</span><span style="color:#b48ead;">use </span><span>headless_chrome::Browser;
</span><span style="color:#b48ead;">use </span><span>headless_chrome::protocol::cdp::Page::CaptureScreenshotFormatOption;
</span><span style="color:#b48ead;">use </span><span>headless_chrome::protocol::cdp::Target::CreateTarget;
</span><span>
</span><span>async </span><span style="color:#b48ead;">fn </span><span style="color:#8fa1b3;">generate_screenshot</span><span>(Path(</span><span style="color:#bf616a;">id</span><span>): Path<String>) -> Result<Response<Body>, AppError> {
</span><span> </span><span style="color:#b48ead;">let</span><span> browser = Browser::default()?;
</span><span>
</span><span> </span><span style="color:#b48ead;">let</span><span> tab = browser.</span><span style="color:#96b5b4;">new_tab_with_options</span><span>(CreateTarget {
</span><span> url: format!("</span><span style="color:#a3be8c;">about:blank</span><span>"),
</span><span> width: Some(</span><span style="color:#d08770;">1080</span><span>),
</span><span> height: Some(</span><span style="color:#d08770;">5000</span><span>),
</span><span> browser_context_id: None,
</span><span> enable_begin_frame_control: None,
</span><span> new_window: None,
</span><span> background: None,
</span><span> })?;
</span><span>
</span><span> tab.</span><span style="color:#96b5b4;">navigate_to</span><span>(&format!("</span><span style="color:#a3be8c;">https://your.website/export-view/</span><span style="color:#d08770;">{id}</span><span>"))?;
</span><span> tab.</span><span style="color:#96b5b4;">wait_until_navigated</span><span>()?;
</span><span> tab.</span><span style="color:#96b5b4;">wait_for_element</span><span>("</span><span style="color:#a3be8c;">#element-to-screenshot</span><span>")?;
</span><span>
</span><span> </span><span style="color:#b48ead;">let</span><span> element = tab.</span><span style="color:#96b5b4;">find_element</span><span>("</span><span style="color:#a3be8c;">#element-to-screenshot</span><span>")?;
</span><span> element.</span><span style="color:#96b5b4;">scroll_into_view</span><span>()?;
</span><span>
</span><span> </span><span style="color:#b48ead;">let</span><span> box_model = element.</span><span style="color:#96b5b4;">get_box_model</span><span>()?;
</span><span> </span><span style="color:#b48ead;">let mut</span><span> viewport = box_model.</span><span style="color:#96b5b4;">margin_viewport</span><span>();
</span><span> viewport.scale = </span><span style="color:#d08770;">2.0</span><span>;
</span><span>
</span><span> </span><span style="color:#b48ead;">let</span><span> screenshot = tab.</span><span style="color:#96b5b4;">capture_screenshot</span><span>(
</span><span> CaptureScreenshotFormatOption::Png,
</span><span> Some(</span><span style="color:#d08770;">100</span><span>),
</span><span> Some(viewport),
</span><span> </span><span style="color:#d08770;">true</span><span>,
</span><span> )?;
</span><span>
</span><span> Ok(Response::builder()
</span><span> .</span><span style="color:#96b5b4;">status</span><span>(StatusCode::</span><span style="color:#d08770;">OK</span><span>)
</span><span> .</span><span style="color:#96b5b4;">header</span><span>("</span><span style="color:#a3be8c;">Content-Type</span><span>", "</span><span style="color:#a3be8c;">image/png</span><span>")
</span><span> .</span><span style="color:#96b5b4;">body</span><span>(Body::from(screenshot))?)
</span><span>}
</span></code></pre>
<p>Some things to note here:</p>
<ul>
<li>Set a large height so that longer text content can be screenshotted without
unwanted distortions or cutoffs</li>
<li>Set <code>viewport.scale</code> to <code>2.0</code> because the default 1.0 scale doesn't look very
nice when viewed on iPhones</li>
<li>The <code>headless_chrome</code> crate uses <code>anyhow::Error</code> as its error type, so you
can write custom <code>From</code> and <code>IntoResponse</code> implementations on a new type that
wraps it for cleaner error handling with <code>?</code></li>
</ul>
<pre data-lang="rust" style="background-color:#2b303b;color:#c0c5ce;" class="language-rust "><code class="language-rust" data-lang="rust"><span style="color:#b48ead;">use </span><span>axum::http::StatusCode;
</span><span style="color:#b48ead;">use </span><span>axum::response::IntoResponse;
</span><span>
</span><span style="color:#b48ead;">struct </span><span>AppError(anyhow::Error);
</span><span>
</span><span style="color:#b48ead;">impl </span><span>IntoResponse </span><span style="color:#b48ead;">for </span><span>AppError {
</span><span> </span><span style="color:#b48ead;">fn </span><span style="color:#8fa1b3;">into_response</span><span>(</span><span style="color:#bf616a;">self</span><span>) -> Response {
</span><span> (
</span><span> StatusCode::</span><span style="color:#d08770;">INTERNAL_SERVER_ERROR</span><span>,
</span><span> format!("</span><span style="color:#a3be8c;">Something went wrong: </span><span style="color:#d08770;">{}</span><span>", </span><span style="color:#bf616a;">self</span><span>.</span><span style="color:#d08770;">0</span><span>),
</span><span> )
</span><span> .</span><span style="color:#96b5b4;">into_response</span><span>()
</span><span> }
</span><span>}
</span><span>
</span><span style="color:#b48ead;">impl</span><span><E> From<E> </span><span style="color:#b48ead;">for </span><span>AppError
</span><span style="color:#b48ead;">where
</span><span> E: Into<anyhow::Error>,
</span><span>{
</span><span> </span><span style="color:#b48ead;">fn </span><span style="color:#8fa1b3;">from</span><span>(</span><span style="color:#bf616a;">err</span><span>: E) -> </span><span style="color:#b48ead;">Self </span><span>{
</span><span> </span><span style="color:#b48ead;">Self</span><span>(err.</span><span style="color:#96b5b4;">into</span><span>())
</span><span> }
</span><span>}
</span></code></pre>
<h2 id="deploying-the-microservice"><a class="zola-anchor" href="#deploying-the-microservice" aria-label="Anchor link for: deploying-the-microservice">Deploying the Microservice</a></h2>
<p>The entire Notado stack is managed with Nix and deployed on NixOS. This makes
deploying the new microservice pretty simple.</p>
<p>Below is some Nix pseudocode showing how you might set up a <code>systemd</code> service
to run a <code>screenshotter</code> microservice, and <code>caddy</code> to route incoming requests on
the desired subdomain to the <code>screenshotter</code> microservice (make sure you set a
DNS record pointing to the IP address of the machine you're deploying to
otherwise this won't work).</p>
<pre data-lang="nix" style="background-color:#2b303b;color:#c0c5ce;" class="language-nix "><code class="language-nix" data-lang="nix"><span>{
</span><span> </span><span style="color:#d08770;">systemd</span><span>.</span><span style="color:#d08770;">services</span><span>.</span><span style="color:#d08770;">screenshotter </span><span>= {
</span><span> </span><span style="color:#d08770;">description </span><span>= "</span><span style="color:#a3be8c;">screenshotter</span><span>";
</span><span> </span><span style="color:#d08770;">after </span><span>= [
</span><span> "</span><span style="color:#a3be8c;">network.target</span><span>"
</span><span> ];
</span><span>
</span><span> </span><span style="color:#d08770;">wantedBy </span><span>= ["</span><span style="color:#a3be8c;">multi-user.target</span><span>"];
</span><span> </span><span style="color:#d08770;">serviceConfig </span><span>= {
</span><span> </span><span style="color:#d08770;">Type </span><span>= "</span><span style="color:#a3be8c;">simple</span><span>";
</span><span> </span><span style="color:#d08770;">DynamicUser </span><span>= </span><span style="color:#d08770;">true</span><span>;
</span><span> </span><span style="color:#d08770;">Environment </span><span>= [
</span><span> "</span><span style="color:#a3be8c;">PATH=</span><span style="font-style:italic;color:#ab7967;">${</span><span style="font-style:italic;color:#bf616a;">pkgs</span><span style="font-style:italic;color:#c0c5ce;">.</span><span style="font-style:italic;color:#bf616a;">google-chrome</span><span style="font-style:italic;color:#ab7967;">}</span><span style="color:#a3be8c;">/bin:$PATH</span><span>"
</span><span> ];
</span><span> </span><span style="color:#d08770;">ExecStart </span><span>= "</span><span style="font-style:italic;color:#ab7967;">${</span><span style="font-style:italic;color:#bf616a;">pkgs</span><span style="font-style:italic;color:#c0c5ce;">.</span><span style="font-style:italic;color:#bf616a;">your-package</span><span style="font-style:italic;color:#ab7967;">}</span><span style="color:#a3be8c;">/bin/screenshotter</span><span>";
</span><span> </span><span style="color:#d08770;">Restart </span><span>= "</span><span style="color:#a3be8c;">on-failure</span><span>";
</span><span> };
</span><span> };
</span><span>
</span><span> </span><span style="color:#d08770;">services</span><span>.</span><span style="color:#d08770;">caddy </span><span>= {
</span><span> </span><span style="color:#d08770;">enable </span><span>= </span><span style="color:#d08770;">true</span><span>;
</span><span> </span><span style="color:#d08770;">virtualHosts </span><span>= {
</span><span> "</span><span style="color:#a3be8c;">https://screenshotter.your.website</span><span>".</span><span style="color:#d08770;">extraConfig </span><span>= "</span><span style="color:#a3be8c;">reverse_proxy 127.0.0.1:<SCREENSHOTTER_PORT></span><span>";
</span><span> };
</span><span> };
</span><span>}
</span></code></pre>
<p>There is one tiny little quality of life improvement when deploying services on
NixOS that I just cannot live without now; being able to add binaries to the
<code>$PATH</code> of a service without polluting the global <code>$PATH</code> of the system. In the
example above, you can see the directory of the Google Chrome binary is
prepended to the path of the <code>screenshotter</code> microservice.</p>
<p>If you have any questions you can reach out to me on
<a href="https://twitter.com/JeezyCodes">Twitter</a> and
<a href="https://hachyderm.io/@LGUG2Z">Mastodon</a>.</p>
<p>If you're interested in what I read to come up with solutions like this one,
you can subscribe to my <a href="https://notado.app/feeds/jado/software-development">Software Development RSS
feed</a>.</p>
<p>If you'd like to watch me writing code while explaining what I'm doing, you can
also <a href="https://www.youtube.com/channel/UCeai3-do-9O4MNy9_xjO6mg?sub_confirmation=1">subscribe to my YouTube
channel</a>.</p>
Documenting War Crimes in GazaWed, 01 Nov 2023 18:35:58 +0000lgug2z
https://lgug2z.com/articles/documenting-war-crimes-in-gaza/
https://lgug2z.com/articles/documenting-war-crimes-in-gaza/<p>As I write this, the genocide in Gaza being perpetrated against the Palestinian
people continues unabated, with the explicit support of many western
governments and politicians across the political spectrum.</p>
<p>Facilitation of war crimes is generally recognized as a war crime in
International Humanitarian Law, the Rome Statue of the International Criminal
Court and Customary International Humanitarian Law (I know this thanks to my
many years spent as an <a href="https://www.icrc.org/">ICRC</a> field delegate, but you
can do your own further research on this point if you'd prefer).</p>
<p>In an age where public statements facilitating of the war crimes happening in
Gaza are shared primarily on Twitter and in media interviews, the documentation
of these statements is an important issue.</p>
<p>Many people believe that what is on the internet is forever, but one look at
<a href="https://old.reddit.com/r/DataHoarder/">r/DataHoarder</a> will show plentiful
examples of this not necessarily being the case.</p>
<p>If you would like to help document instances of people responsible for
facilitating war crimes in Gaza, keep reading for a step by step guide of how
you can use <a href="https://notado.app">Notado</a> to do this.</p>
<h2 id="getting-started"><a class="zola-anchor" href="#getting-started" aria-label="Anchor link for: getting-started">Getting Started</a></h2>
<ul>
<li>Make an account on <a href="https://notado.app">https://notado.app</a></li>
<li>Install the <a href="https://chrome.google.com/webstore/detail/jmminocgkncmikgaoooddledofafmlnj">Chrome</a> or <a href="https://addons.mozilla.org/en-GB/firefox/addon/notado/">Firefox</a> extension</li>
<li>Install the <a href="https://notado.app/help">iOS Shortcut</a> if you use an iPhone</li>
</ul>
<h2 id="archiving-comments-on-twitter"><a class="zola-anchor" href="#archiving-comments-on-twitter" aria-label="Anchor link for: archiving-comments-on-twitter">Archiving Comments on Twitter</a></h2>
<ul>
<li>In the browser, right click on the "2h", "3h" etc. link at the top of the Tweet which shows how long ago it was posted, then select "Send Comment Link to Notado"</li>
<li>On iOS, press the share button on a Tweet, then "Share via", then select the "Save Comment to Notado" action</li>
<li>If possible, an archival copy of the URL will also be saved to the Web Archive automatically for users with subscriptions
<ul>
<li><a href="https://web.archive.org/web/20231018000445/https://twitter.com/awesomekling/status/1706212702840898032?s=12">Here is an example with a Tweet</a></li>
</ul>
</li>
</ul>
<h2 id="archiving-comments-on-reddit-mastodon-and-lemmy-etc"><a class="zola-anchor" href="#archiving-comments-on-reddit-mastodon-and-lemmy-etc" aria-label="Anchor link for: archiving-comments-on-reddit-mastodon-and-lemmy-etc">Archiving Comments on Reddit, Mastodon and Lemmy etc.</a></h2>
<ul>
<li>In the browser, right click on the comment permalink buttons (on Mastodon this is usually "2h", "3h" etc. like on Twitter), then select "Send Comment Link to Notado"</li>
<li>On iOS, press the share button on a comment, then "Share via", then select the "Save Comment to Notado" action</li>
<li>If possible, an archival copy of the URL will also be saved to the Web Archive for users with subscriptions
<ul>
<li><a href="https://web.archive.org/web/20231022000425/https://old.reddit.com/r/truegaming/comments/17cnutf/from_software_games_have_nearly_eliminated_by/k5r8rfk/">Here is an example with a Reddit comment</a></li>
</ul>
</li>
</ul>
<h2 id="archiving-highlights-from-press-articles"><a class="zola-anchor" href="#archiving-highlights-from-press-articles" aria-label="Anchor link for: archiving-highlights-from-press-articles">Archiving Highlights from Press Articles</a></h2>
<ul>
<li>Highlight the selected text your browser, then select "Send Selection to Notado"</li>
<li>If possible, an archival copy of the URL will also be saved to the Web Archive for users with subscriptions
<ul>
<li><a href="https://web.archive.org/web/20200902233247/https://www.bbc.com/news/world-us-canada-53996159">Here is an example with an article</a></li>
</ul>
</li>
</ul>
<h2 id="why-use-notado-for-this"><a class="zola-anchor" href="#why-use-notado-for-this" aria-label="Anchor link for: why-use-notado-for-this">Why Use Notado for This?</a></h2>
<p>Notado was never intended to be used for something like this. Let's just get
that out of the way first.</p>
<h3 id="metadata-and-archiving"><a class="zola-anchor" href="#metadata-and-archiving" aria-label="Anchor link for: metadata-and-archiving">Metadata and Archiving</a></h3>
<p>When you save Tweets and comments from social media sites with Notado, not only
do they get automatically archived on
<a href="https://web.archive.org">web.archive.org</a>, but they are also enriched with
basic metadata which makes it easy to filter by source and by username.</p>
<p>Additionally, saving this way also ensures that the unique identifiers for each
Tweet, comment etc. are preserved, even if the posts themselves deleted in the
future.</p>
<h3 id="multi-lingual-full-text-search-and-tagging"><a class="zola-anchor" href="#multi-lingual-full-text-search-and-tagging" aria-label="Anchor link for: multi-lingual-full-text-search-and-tagging">Multi-Lingual Full-Text Search and Tagging</a></h3>
<p>While filtering on basic metadata and a powerful, fuzzy multi-lingual full-text
search engine are both helpful, documentation of information like this also
benefits from more granular additional metadata added in the form of tags.</p>
<h3 id="tagging-automation"><a class="zola-anchor" href="#tagging-automation" aria-label="Anchor link for: tagging-automation">Tagging Automation</a></h3>
<p>With Notado you can set rules to automatically tag content that comes from
certain users or websites. For example, you could tag all content saved from
the @POTUS and @JoeBiden Twitter accounts with <code>#dnc</code> and <code>#joebiden</code>:</p>
<pre style="background-color:#2b303b;color:#c0c5ce;"><code><span>IF url CONTAINS ANY OF "twitter.com/JoeBiden" "x.com/JoeBiden" "twitter.com/POTUS" "x.com/POTUS" TAG "dnc" "joe-biden"
</span></code></pre>
<p>Tagging rules can also be use to automatically tag based on
<a href="https://en.wikipedia.org/wiki/Dog_whistle_(politics)">dog-whistles</a> used in
the content saved, or to create a common tag for people with multiple accounts
with different usernames on different platforms.</p>
<p>If there is content that needs to be tagged manually, you can also use tag
aliases to create shortcuts which don't sacrifice the descriptive nature of
your tags, so you could use an alias like this to automatically expand <code>#wcig</code> to
<code>#war-crimes-in-gaza</code>:</p>
<pre style="background-color:#2b303b;color:#c0c5ce;"><code><span>wcig => war-crimes-in-gaza
</span></code></pre>
<h3 id="ease-of-backup"><a class="zola-anchor" href="#ease-of-backup" aria-label="Anchor link for: ease-of-backup">Ease of Backup</a></h3>
<p>Perhaps most importantly, a full backup of your entire library of saved content
is immediately available to you at any time.</p>
<h2 id="caveats"><a class="zola-anchor" href="#caveats" aria-label="Anchor link for: caveats">Caveats</a></h2>
<p>Notado does not currently and will not in the future support the saving of
multimedia content.</p>
<h2 id="should-you-do-this"><a class="zola-anchor" href="#should-you-do-this" aria-label="Anchor link for: should-you-do-this">Should You Do This?</a></h2>
<p>I'll start by saying that I personally am not mentally or emotionally capable
of doing this.</p>
<p>There are very serious concerns around repeated long-term exposure to violent
and dehumanizing content that should not be overlooked or minimized.</p>
<p>If you are an experienced genocide or war crimes researcher who has experience
documenting instances of war crime facilitation, and who is already engaged in
the documentation of war crimes being committed in Gaza, please reach out to me
on <a href="https://twitter.com/JeezyCodes">Twitter</a> and I will be happy to set you up with
a permanently free account on Notado.</p>
Yubikey Passthrough on WSL2 With Full FIDO2 SupportMon, 30 Oct 2023 18:30:38 +0000lgug2z
https://lgug2z.com/articles/yubikey-passthrough-on-wsl2-with-full-fido2-support/
https://lgug2z.com/articles/yubikey-passthrough-on-wsl2-with-full-fido2-support/<p>I recently starting using Yubikeys both to store passkeys which allow me to do
passwordless logins to websites like GitHub, and to SSH into remote servers
with FIDO2.</p>
<p>I have a number of machines at home, but I spend the majority of my time using
a Windows 11 desktop computer running NixOS on WSL2 (in the past I've described
Windows 11 + my tiling window manager
<a href="https://github.com/LGUG2Z/komorebi"><code>komorebi</code></a> as the "desktop environment"
on top of my NixOS WSL2 shell).</p>
<p>Rarely if ever do I make SSH connections from Windows 11 directly; if I am
making an SSH connection it is almost always from NixOS. This posed a problem
for my adoption of FIDO2 SSH with my Yubikeys, because the process is not quite
as simple as just passing through the USB Yubikey to the WSL2 VM.</p>
<p>Below I'll outline the steps to get USB Yubikey passthrough to a NixOS WSL2 VM
working with full FIDO2 support.</p>
<p>Although these steps specifically target NixOS, the underlying information can
be used to produce the same result on the Linux distribution of your choice.</p>
<h2 id="prerequisites-on-windows-11"><a class="zola-anchor" href="#prerequisites-on-windows-11" aria-label="Anchor link for: prerequisites-on-windows-11">Prerequisites on Windows 11</a></h2>
<p>Get started by installing <a href="https://www.yubico.com/support/download/yubikey-manager/">Yubikey
Manager</a> on Windows
and making sure your Yubikey(s) are being recognized:</p>
<pre data-lang="powershell" style="background-color:#2b303b;color:#c0c5ce;" class="language-powershell "><code class="language-powershell" data-lang="powershell"><span>winget install -e --id Yubico.YubikeyManager
</span><span>
</span><span style="color:#65737e;"># Unfortunately, WinGet _still_ isn't able to place installed binaries in the $PATH reliably 🤦
</span><span>
</span><span>❯ & "</span><span style="color:#a3be8c;">C:\Program Files\Yubico\YubiKey Manager\ykman.exe</span><span>" info
</span><span>Device type: YubiKey 5C
</span><span>Serial number: XXXXXXXXXX
</span><span>Firmware version: </span><span style="color:#d08770;">5.4</span><span>.</span><span style="color:#d08770;">3
</span><span>Form factor: Keychain (USB-C)
</span><span>Enabled USB interfaces: OTP, FIDO, CCID
</span><span>
</span><span>Applications
</span><span>OTP Enabled
</span><span>FIDO U2F Enabled
</span><span>FIDO2 Enabled
</span><span>OATH Enabled
</span><span>PIV Enabled
</span><span>OpenPGP Enabled
</span><span>YubiHSM Auth Enabled
</span></code></pre>
<p>Next, install <a href="https://github.com/dorssel/usbipd-win"><code>usbipd-win</code></a>, which is
what we'll use to do the USB passthrough to the WSL2 VM.</p>
<pre data-lang="powershell" style="background-color:#2b303b;color:#c0c5ce;" class="language-powershell "><code class="language-powershell" data-lang="powershell"><span style="color:#65737e;"># For whatever reason, this one does seem to be added to the $PATH correctly 🤷
</span><span>
</span><span>winget install usbipd
</span></code></pre>
<p>In an Administrator PowerShell Terminal, run <code>usbipd list</code> and take note of the
<code>BUSID</code> for your Yubikey, which we will need later:</p>
<pre data-lang="powershell" style="background-color:#2b303b;color:#c0c5ce;" class="language-powershell "><code class="language-powershell" data-lang="powershell"><span>❯ usbipd list
</span><span>Connected:
</span><span>BUSID VID:PID DEVICE STATE
</span><span style="color:#d08770;">9</span><span>-</span><span style="color:#d08770;">4 1050</span><span>:</span><span style="color:#d08770;">0407</span><span> USB Input Device, Microsoft Usbccid Smartcard Reader (WUDF) Not shared
</span></code></pre>
<h2 id="building-and-loading-a-custom-wsl2-linux-kernel"><a class="zola-anchor" href="#building-and-loading-a-custom-wsl2-linux-kernel" aria-label="Anchor link for: building-and-loading-a-custom-wsl2-linux-kernel">Building and Loading a Custom WSL2 Linux Kernel</a></h2>
<p>Now comes the fun part.</p>
<p>In order to enable full USB passthrough with FIDO2 support, we need to compile
a custom WSL2 Linux kernel, because the kernels released by Microsoft are for
whatever reason missing a few key options that we need.</p>
<p>If you've already compiled your own WSL2 Linux Kernels before and are
comfortable with this process, you just need to go and enable <code>HIDDEV</code> and
<code>HIDRAW</code> and then recompile.</p>
<p>Otherwise, you can navigate to my
<a href="https://github.com/LGUG2Z/custom-wsl2-linux-kernel"><code>custom-wsl2-linux-kernel</code></a>
project and download the latest release. This project pulls the latest version
of the offical
<a href="https://github.com/microsoft/WSL2-Linux-Kernel">WSL2-Linux-Kernel</a> released by
Microsoft, enables the required configuration options, builds the kernel on
GitHub Actions, and finally makes the resulting <code>vmlinux</code> file <a href="https://github.com/LGUG2Z/custom-wsl2-linux-kernel/releases">available to
download</a>.</p>
<p>If you are interested in <a href="https://twitter.com/fasterthanlime/status/1708971184358838456">building WSL2 Linux kernels with different options
enabled</a>, you
can fork the project and commit your own edits to the <code>config-wsl</code> file. GitHub
Actions will then start building the kernel for you and store the resulting
<code>vmlinux</code> file as a build artifact for you to download.</p>
<p>Anyway, once you have your <code>vmlinux</code> file, place it in a convenient directory
(I keep mine in <code>$HOME</code> 🤷) and then edit (or create) your <code>~/.wslconfig</code> file
on Windows 11 to instruct WSL2 to use this specific kernel when booting VMs:</p>
<pre data-lang="toml" style="background-color:#2b303b;color:#c0c5ce;" class="language-toml "><code class="language-toml" data-lang="toml"><span>[wsl2]
</span><span style="color:#bf616a;">kernel</span><span>=</span><span style="background-color:#bf616a;color:#2b303b;">C:\\Users\\LGUG2Z\\vmlinux</span><span>
</span></code></pre>
<p>Now make sure you've saved any work you're doing in any WSL2 VMs and run <code>wsl --shutdown</code> in a PowerShell prompt. Then wait for at least 10 seconds after
that command terminates before starting up your NixOS WSL2 VM again.</p>
<h2 id="configuring-nixos-to-automatically-attach-your-yubikey"><a class="zola-anchor" href="#configuring-nixos-to-automatically-attach-your-yubikey" aria-label="Anchor link for: configuring-nixos-to-automatically-attach-your-yubikey">Configuring NixOS to Automatically Attach Your Yubikey</a></h2>
<p>Thankfully, now that we are back in NixOS land the rest of this tutorial is
fairly declarative.</p>
<p>I must give a huge thank you to everyone who has been contributing to the
various threads about this on the
<a href="https://github.com/nix-community/NixOS-WSL">NixOS-WSL</a> repo, in particular the
user <a href="https://github.com/terlar"><code>terlar</code></a> who has put together a <a href="https://github.com/nix-community/NixOS-WSL/pull/263">pending
PR</a> which the Nix code
below is largely based on.</p>
<p>Start by creating a <code>usbip.nix</code> file and storing it wherever feels best in your
flake repo:</p>
<pre data-lang="nix" style="background-color:#2b303b;color:#c0c5ce;" class="language-nix "><code class="language-nix" data-lang="nix"><span>{
</span><span> config,
</span><span> lib,
</span><span> pkgs,
</span><span> ...
</span><span style="color:#8fa1b3;">}</span><span>:
</span><span style="color:#b48ead;">with </span><span style="color:#bf616a;">lib</span><span>; </span><span style="color:#b48ead;">let
</span><span> </span><span style="color:#d08770;">usbipd-win-auto-attach </span><span>= </span><span style="color:#bf616a;">pkgs</span><span>.</span><span style="color:#bf616a;">fetchurl </span><span>{
</span><span> </span><span style="color:#d08770;">url </span><span>= "</span><span style="color:#a3be8c;">https://raw.githubusercontent.com/dorssel/usbipd-win/v3.1.0/Usbipd/wsl-scripts/auto-attach.sh</span><span>";
</span><span> </span><span style="color:#d08770;">hash </span><span>= "</span><span style="color:#a3be8c;">sha256-KJ0tEuY+hDJbBQtJj8nSNk17FHqdpDWTpy9/DLqUFaM=</span><span>";
</span><span> };
</span><span>
</span><span> </span><span style="color:#d08770;">cfg </span><span>= </span><span style="color:#bf616a;">config</span><span>.</span><span style="color:#bf616a;">wsl</span><span>.</span><span style="color:#bf616a;">usbip</span><span>;
</span><span style="color:#b48ead;">in </span><span>{
</span><span> </span><span style="color:#d08770;">options</span><span>.</span><span style="color:#d08770;">wsl</span><span>.</span><span style="color:#d08770;">usbip </span><span>= </span><span style="color:#b48ead;">with </span><span style="color:#bf616a;">types</span><span>; {
</span><span> </span><span style="color:#d08770;">enable </span><span>= </span><span style="color:#bf616a;">mkEnableOption </span><span>"</span><span style="color:#a3be8c;">USB/IP integration</span><span>";
</span><span> </span><span style="color:#d08770;">autoAttach </span><span>= </span><span style="color:#bf616a;">mkOption </span><span>{
</span><span> </span><span style="color:#d08770;">type </span><span>= </span><span style="color:#bf616a;">listOf str</span><span>;
</span><span> </span><span style="color:#d08770;">default </span><span>= [];
</span><span> </span><span style="color:#d08770;">example </span><span>= ["</span><span style="color:#a3be8c;">4-1</span><span>"];
</span><span> </span><span style="color:#d08770;">description </span><span>= "</span><span style="color:#a3be8c;">Auto attach devices with provided Bus IDs.</span><span>";
</span><span> };
</span><span> };
</span><span>
</span><span> </span><span style="color:#d08770;">config </span><span>= </span><span style="color:#bf616a;">mkIf </span><span>(</span><span style="color:#bf616a;">config</span><span>.</span><span style="color:#bf616a;">wsl</span><span>.</span><span style="color:#bf616a;">enable </span><span>&& </span><span style="color:#bf616a;">cfg</span><span>.</span><span style="color:#bf616a;">enable</span><span>) {
</span><span> </span><span style="color:#d08770;">environment</span><span>.</span><span style="color:#d08770;">systemPackages </span><span>= [
</span><span> </span><span style="color:#bf616a;">pkgs</span><span>.</span><span style="color:#bf616a;">linuxPackages</span><span>.</span><span style="color:#bf616a;">usbip
</span><span> </span><span style="color:#bf616a;">pkgs</span><span>.</span><span style="color:#bf616a;">yubikey-manager
</span><span> </span><span style="color:#bf616a;">pkgs</span><span>.</span><span style="color:#bf616a;">libfido2
</span><span> ];
</span><span>
</span><span> </span><span style="color:#d08770;">services</span><span>.</span><span style="color:#d08770;">pcscd</span><span>.</span><span style="color:#d08770;">enable </span><span>= </span><span style="color:#d08770;">true</span><span>;
</span><span> </span><span style="color:#d08770;">services</span><span>.</span><span style="color:#d08770;">udev </span><span>= {
</span><span> </span><span style="color:#d08770;">enable </span><span>= </span><span style="color:#d08770;">true</span><span>;
</span><span> </span><span style="color:#d08770;">packages </span><span>= [</span><span style="color:#bf616a;">pkgs</span><span>.</span><span style="color:#bf616a;">yubikey-personalization</span><span>];
</span><span> </span><span style="color:#d08770;">extraRules </span><span>= ''
</span><span style="color:#a3be8c;"> SUBSYSTEM=="usb", MODE="0666"
</span><span style="color:#a3be8c;"> KERNEL=="hidraw*", SUBSYSTEM=="hidraw", TAG+="uaccess", MODE="0666"
</span><span style="color:#a3be8c;"> </span><span>'';
</span><span> };
</span><span>
</span><span> </span><span style="color:#d08770;">systemd </span><span>= {
</span><span> </span><span style="color:#d08770;">services</span><span>."</span><span style="color:#a3be8c;">usbip-auto-attach@</span><span>" = {
</span><span> </span><span style="color:#d08770;">description </span><span>= "</span><span style="color:#a3be8c;">Auto attach device having busid %i with usbip</span><span>";
</span><span> </span><span style="color:#d08770;">after </span><span>= ["</span><span style="color:#a3be8c;">network.target</span><span>"];
</span><span>
</span><span> </span><span style="color:#d08770;">scriptArgs </span><span>= "</span><span style="color:#a3be8c;">%i</span><span>";
</span><span> </span><span style="color:#d08770;">path </span><span>= [</span><span style="color:#bf616a;">pkgs</span><span>.</span><span style="color:#bf616a;">linuxPackages</span><span>.</span><span style="color:#bf616a;">usbip</span><span>];
</span><span>
</span><span> </span><span style="color:#d08770;">script </span><span>= ''
</span><span style="color:#a3be8c;"> busid="$1"
</span><span style="color:#a3be8c;"> ip="$(grep nameserver /etc/resolv.conf | cut -d' ' -f2)"
</span><span style="color:#a3be8c;">
</span><span style="color:#a3be8c;"> echo "Starting auto attach for busid $busid on $ip."
</span><span style="color:#a3be8c;"> source </span><span style="font-style:italic;color:#ab7967;">${</span><span style="font-style:italic;color:#bf616a;">usbipd-win-auto-attach</span><span style="font-style:italic;color:#ab7967;">}</span><span style="color:#a3be8c;"> "$ip" "$busid"
</span><span style="color:#a3be8c;"> </span><span>'';
</span><span> };
</span><span>
</span><span> </span><span style="color:#d08770;">targets</span><span>.</span><span style="color:#d08770;">multi-user</span><span>.</span><span style="color:#d08770;">wants </span><span>= </span><span style="color:#96b5b4;">map </span><span>(busid: "</span><span style="color:#a3be8c;">usbip-auto-attach@</span><span style="font-style:italic;color:#ab7967;">${</span><span style="font-style:italic;color:#bf616a;">busid</span><span style="font-style:italic;color:#ab7967;">}</span><span style="color:#a3be8c;">.service</span><span>") </span><span style="color:#bf616a;">cfg</span><span>.</span><span style="color:#bf616a;">autoAttach</span><span>;
</span><span> };
</span><span> };
</span><span>}
</span></code></pre>
<p>Then, in the part of your flake that deals with your WSL configuration, import
the file and set the appropriate <code>BUSID</code> for your Yubikey which we determined
earlier:</p>
<pre data-lang="nix" style="background-color:#2b303b;color:#c0c5ce;" class="language-nix "><code class="language-nix" data-lang="nix"><span>{
</span><span> </span><span style="color:#d08770;">imports </span><span>= [
</span><span> </span><span style="color:#65737e;"># Your import path will probably be different
</span><span> </span><span style="color:#a3be8c;">./usbip.nix
</span><span> ];
</span><span>
</span><span> </span><span style="color:#d08770;">wsl </span><span>= {
</span><span> </span><span style="color:#d08770;">usbip </span><span>= {
</span><span> </span><span style="color:#d08770;">enable </span><span>= </span><span style="color:#d08770;">true</span><span>;
</span><span> </span><span style="color:#65737e;"># Replace this with the BUSID for your Yubikey
</span><span> </span><span style="color:#d08770;">autoAttach </span><span>= ["</span><span style="color:#a3be8c;">9-4</span><span>"];
</span><span> };
</span><span> };
</span><span>}
</span></code></pre>
<p>Go ahead and rebuild your system with your updated NixOS configuration, and now
whenever your Yubikey is detected in the port that you have identified, it will
be passed through automatically to your NixOS VM. 🎉</p>
<p>We can test this by running <code>ykman fido credentials list</code> inside of our WSL2 VM:</p>
<pre style="background-color:#2b303b;color:#c0c5ce;"><code><span>❯ ykman fido credentials list
</span><span>Enter your PIN: ************
</span><span>Credential ID RP ID Username Display name
</span><span>abcdefgh... github.com LGUG2Z جاد
</span></code></pre>
<p>Great! You can now use your existing FIDO2 SSH keys from inside your WSL2 VM,
or generate a new key by following the Linux setup instructions for <a href="https://developers.yubico.com/SSH/Securing_SSH_with_FIDO2.html">"Securing
SSH with
FIDO2"</a> on the
Yubico website.</p>
<p>A few things to keep in mind:</p>
<ul>
<li>You're probably going to want to keep two Yubikeys connected to your Windows
machine simultaneously in order to use one in Windows and one in WSL2</li>
<li>If you have both Yubikeys connected simultaneously, and you have set up
<a href="https://support.yubico.com/hc/en-us/articles/360013708460-Yubico-Login-for-Windows-Configuration-Guide">Yubico Login for
Windows</a>,
you'll have to remove one of them when you are logging into the computer
after a restart otherwise the login will fail</li>
<li>Outside of this specific edge case, I haven't encountered any other issues
with keeping two Yubikeys connected simultaneously for daily use</li>
<li>In order to use FIDO2 functionality, you need to explicitly set up a FIDO2
PIN on your Yubikey <a href="https://support.yubico.com/hc/en-us/articles/4402836718866-Understanding-YubiKey-PINs">as one is not set by
default</a></li>
</ul>
<h2 id="i-m-not-sure-i-can-replicate-this-on-ubuntu"><a class="zola-anchor" href="#i-m-not-sure-i-can-replicate-this-on-ubuntu" aria-label="Anchor link for: i-m-not-sure-i-can-replicate-this-on-ubuntu">I'm Not Sure I Can Replicate This on Ubuntu...</a></h2>
<p>The main things to note if you are trying to adapt the NixOS configuration
snippets above to work on other Linux distrubtions are:</p>
<ul>
<li>The packages being added to the system environment</li>
<li>The <code>auto-attach.sh</code> script being pulled from the
<a href="https://github.com/dorssel/usbipd-win"><code>usbipd-win</code></a> project</li>
<li>The <code>udev</code> rules being set for <code>usb</code> and <code>hidraw*</code></li>
<li>The <code>systemd</code> service which calls the <code>auto-attach.sh</code> script and attaches
the device on the given <code>BUSID</code> to the VM</li>
</ul>
<p>Look, I'm not gonna lie, I probably couldn't replicate this with any confidence
on Ubuntu either.</p>
<p>If you'd like to try running NixOS on WSL2, please take a look at my
<a href="https://github.com/LGUG2Z/nixos-wsl-starter"><code>nixos-wsl-starter</code></a> template
which should have you up and running with a useable terminal-powered
development environment in less than 10 minutes.</p>
<p>There is even a video you can follow along with step by step.</p>
<div
style="position: relative; padding-bottom: 56.25%; height: 0; overflow: hidden;">
<iframe allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share; fullscreen"
loading="eager"
referrerpolicy="strict-origin-when-cross-origin"
src="https://www.youtube.com/embed/UmRXXYxq8k4"
style="position: absolute; top: 0; left: 0; width: 100%; height: 100%; border:0;"
title="YouTube video"
allowfullscreen></iframe>
</div>
<p>If you have any questions you can reach out to me on
<a href="https://twitter.com/JeezyCodes">Twitter</a> and
<a href="https://hachyderm.io/@LGUG2Z">Mastodon</a>.</p>
<p>If you're interested in what I read to come up with solutions like this one,
you can subscribe to my <a href="https://notado.app/feeds/jado/software-development">Software Development RSS
feed</a>.</p>
<p>If you'd like to watch me writing code while explaining what I'm doing, you can
also <a href="https://www.youtube.com/channel/UCeai3-do-9O4MNy9_xjO6mg?sub_confirmation=1">subscribe to my YouTube
channel</a>.</p>
Sensible $WORDCHARS for Most DevelopersFri, 22 Sep 2023 14:37:24 +0000lgug2z
https://lgug2z.com/articles/sensible-wordchars-for-most-developers/
https://lgug2z.com/articles/sensible-wordchars-for-most-developers/<p>Have you ever hit <code>alt + backspace</code> while in a <code>zsh</code> session, intending to
delete just the last part of a path, or a word, or an identifier, but ended up
deleting far more than that?</p>
<p>This is probably because you are using <a href="https://zsh.sourceforge.io/Guide/zshguide04.html">the default value for <code>$WORDCHARS</code></a>.</p>
<blockquote>
<p>The default value for $WORDCHARS is</p>
<p><code>*?_-.[]~=/&;!#$%^(){}<></code></p>
<p>i.e. pretty much everything and the kitchen sink. Usually, therefore, you will want to remove characters which you don't want to be considered parts of words</p>
</blockquote>
<p>Each one of those characters is considered part of a "word", and when you hit
<code>alt + backspace</code>, you are signalling your intention to the shell to delete a
"word".</p>
<p>This is why if you have a path like
<code>/home/user1/projects/some-project/src/module1</code> on your terminal input line,
you'll end up deleting the whole path with <code>alt + backspace</code> instead of just
<code>module1</code>.</p>
<p>I think that <em>most</em> developers will probably benefit from removing the following characters from <code>$WORDCHARS</code></p>
<ul>
<li><code>/</code> (we usually want to delete elements of a path, rarely the entire path)</li>
<li><code>-</code> (cli subcommands that are <code>separated-like-this</code> usually require replacing the last N elements to do something similar-but-different)</li>
<li><code>_</code> (same as above, and also useful when grepping <code>variables_like_this</code> or <code>LIKE_THIS</code>)</li>
<li><code>.</code> (often used as a module delimiter, also useful when you just want to change the filetype)</li>
</ul>
<p>This leaves us with our sensible <code>$WORDCHARS</code> value for most developers:</p>
<pre style="background-color:#2b303b;color:#c0c5ce;"><code><span>*?[]~=&;!#$%^(){}<>
</span></code></pre>
<p>You can export this new <code>$WORDCHARS</code> value in your <code>.zshrc</code> files on the
various machines you use.</p>
<p>Alternatively, if you manage your machines with <a href="https://nixos.org/">NixOS</a>,
you can set this as the new default for all of them using a common piece of
<a href="https://rycee.gitlab.io/home-manager/options.html#opt-programs.zsh.initExtra">home-manager
configuration</a>:</p>
<pre data-lang="nix" style="background-color:#2b303b;color:#c0c5ce;" class="language-nix "><code class="language-nix" data-lang="nix"><span>{
</span><span> </span><span style="color:#d08770;">programs</span><span>.</span><span style="color:#d08770;">zsh</span><span>.</span><span style="color:#d08770;">initExtra </span><span>= ''
</span><span style="color:#a3be8c;"> WORDCHARS='*?[]~=&;!#$%^(){}<>'
</span><span style="color:#a3be8c;"> </span><span>'';
</span><span>}
</span></code></pre>
<p>If you have any questions you can reach out to me on
<a href="https://hachyderm.io/@LGUG2Z">Mastodon</a> and
<a href="https://twitter.com/JeezyCodes">Twitter</a>.</p>
<p>If you're interested in what I read to come up with solutions like this one,
you can subscribe to my <a href="https://notado.app/feeds/jado/software-development">Software Development RSS
feed</a>.</p>
<p>If you'd like to watch me writing code while explaining what I'm doing, you can
also <a href="https://www.youtube.com/channel/UCeai3-do-9O4MNy9_xjO6mg?sub_confirmation=1">subscribe to my YouTube
channel</a>.</p>
Hands-Free Tiling Window ManagementTue, 08 Aug 2023 16:58:54 +0000lgug2z
https://lgug2z.com/articles/hands-free-tiling-window-management/
https://lgug2z.com/articles/hands-free-tiling-window-management/<p>Last year I read the excellent article <a href="https://www.joshwcomeau.com/blog/hands-free-coding/">"Hands-Free Coding"</a> by <a href="https://www.joshwcomeau.com/">Josh Comeau</a> which went into detail about modifying his programming workflow to use dictation and eye-tracking after developing Cubital Tunnel Syndrome. I highly recommend reading the whole article and watching the demonstration videos of his approach.</p>
<p>This article was my first exposure to the dictation software <a href="https://talonvoice.com">Talon</a>, which is specifically built to be hackable and naturally targets software developers.</p>
<p>If you know anything about me by now, it's that I can't use a computer without a tiling window manager anymore. I struggled so much when I switched to Windows that I ended up writing <a href="https://github.com/LGUG2Z/komorebi">my own tiling window manager, komorebi</a>.</p>
<p>After consuming the examples shared by Josh, I started thinking about how this approach could also be adapted to allow users suffering from repetitive-strain injuries to interact with a tiling window manager using dictaction software like Talon.</p>
<p>Earlier this week I recorded a brief proof-of-concept video showing how directional focusing, window movement and workspace cycling could be achieved using voice commands. Below, I'll share the code that I wrote to achieve the functionality in that demo, and some additional notes, to hopefully help others that may one day need it.</p>
<div
style="position: relative; padding-bottom: 56.25%; height: 0; overflow: hidden;">
<iframe allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share; fullscreen"
loading="eager"
referrerpolicy="strict-origin-when-cross-origin"
src="https://www.youtube.com/embed/SOnHaCEYkUM"
style="position: absolute; top: 0; left: 0; width: 100%; height: 100%; border:0;"
title="YouTube video"
allowfullscreen></iframe>
</div>
<p>Talon has a configuration home directory which varies depending on your operating system. On Windows, it will be under <code>~/AppData/Roaming/talon</code>. Inside that configuration home, there should be a <code>user</code> directory (you can create this if there isn't one there), in which you can add the following code snippets.</p>
<p>This first snippet is a Python script which defines a function called <code>komorebic</code>, which takes a single <code>command</code> argument. The <code>command</code> argument can be any valid <code>komorebic.exe</code> command, all of which can be found by running <code>komorebic.exe --help</code>.</p>
<p>An important thing to note here is that the <code>komorebic.exe</code> commands need to be executed using a <code>subprocess</code> method which allows for the <code>shell=True</code> argument. Without this, on Windows, each time the <code>komorebic</code> function is called, a new Command Prompt will visibly spawn.</p>
<pre data-lang="python" style="background-color:#2b303b;color:#c0c5ce;" class="language-python "><code class="language-python" data-lang="python"><span style="color:#65737e;"># komorebi.py
</span><span>
</span><span style="color:#b48ead;">import </span><span>subprocess
</span><span>
</span><span style="color:#b48ead;">from </span><span>talon </span><span style="color:#b48ead;">import </span><span>Module
</span><span>
</span><span>mod = </span><span style="color:#bf616a;">Module</span><span>()
</span><span>
</span><span>@mod.</span><span style="color:#bf616a;">action_class
</span><span style="color:#b48ead;">class </span><span style="color:#ebcb8b;">Actions</span><span style="color:#eff1f5;">:
</span><span> </span><span style="color:#b48ead;">def </span><span style="color:#8fa1b3;">komorebic</span><span>(</span><span style="color:#bf616a;">command</span><span>: str):
</span><span> </span><span style="color:#65737e;">"""Send a message to komorebi"""
</span><span> subprocess.</span><span style="color:#bf616a;">check_output</span><span>(</span><span style="color:#b48ead;">f</span><span>"</span><span style="color:#a3be8c;">komorebic.exe </span><span>{command}", </span><span style="color:#bf616a;">shell</span><span>=</span><span style="color:#d08770;">True</span><span>)
</span></code></pre>
<p>This second snippet is in Talon's domain-specific language (DSL). On the left-hand side are the voice commands, and on the right hand side are the commands that will get executed.</p>
<p>All of the executed commands call our <code>user.komorebic()</code> function.</p>
<p>I'm sure that this can be made cleaner and DRY-er if you spend more time digging into the grammar of Talon's DSL, but this is enough to get you started with core tiling window management functionality.</p>
<pre data-lang="python" style="background-color:#2b303b;color:#c0c5ce;" class="language-python "><code class="language-python" data-lang="python"><span style="color:#65737e;"># komorebi.talon
</span><span>
</span><span>focus left: user.</span><span style="color:#bf616a;">komorebic</span><span>("</span><span style="color:#a3be8c;">focus left</span><span>")
</span><span>focus right: user.</span><span style="color:#bf616a;">komorebic</span><span>("</span><span style="color:#a3be8c;">focus right</span><span>")
</span><span>focus up: user.</span><span style="color:#bf616a;">komorebic</span><span>("</span><span style="color:#a3be8c;">focus up</span><span>")
</span><span>focus down: user.</span><span style="color:#bf616a;">komorebic</span><span>("</span><span style="color:#a3be8c;">focus down</span><span>")
</span><span>
</span><span>move left: user.</span><span style="color:#bf616a;">komorebic</span><span>("</span><span style="color:#a3be8c;">move left</span><span>")
</span><span>move right: user.</span><span style="color:#bf616a;">komorebic</span><span>("</span><span style="color:#a3be8c;">move right</span><span>")
</span><span>move down: user.</span><span style="color:#bf616a;">komorebic</span><span>("</span><span style="color:#a3be8c;">move down</span><span>")
</span><span>move up: user.</span><span style="color:#bf616a;">komorebic</span><span>("</span><span style="color:#a3be8c;">move up</span><span>")
</span><span>
</span><span style="color:#96b5b4;">next </span><span>workspace: user.</span><span style="color:#bf616a;">komorebic</span><span>("</span><span style="color:#a3be8c;">cycle-workspace next</span><span>")
</span><span>previous workspace: user.</span><span style="color:#bf616a;">komorebic</span><span>("</span><span style="color:#a3be8c;">cycle-workspace previous</span><span>")
</span><span>
</span><span>toggle monocle: user.</span><span style="color:#bf616a;">komorebic</span><span>("</span><span style="color:#a3be8c;">toggle-monocle</span><span>")
</span><span>promote: user.</span><span style="color:#bf616a;">komorebic</span><span>("</span><span style="color:#a3be8c;">promote</span><span>")
</span></code></pre>
<p>I believe that this approach can be replicated for other tiling window managers on other operating systems that expose a CLI which is able to send signals to the twm process such as <code>bspwm</code> on Linux, and <code>yabai</code> on macOS.</p>
<p>Although I do not have the hardware to try this out for myself, I think that a way to take this hands-free setup to the next level could be to enable focus-follows-mouse mode for <code>komorebi</code> and use eye-tracking as a mouse replacement.</p>
<p>In theory, this would allow you to set the window which has input focus just by looking at it.</p>
<p>Since <code>komorebi</code> also supports drag-to-swap (ie. you drag a window across the screen/to a different monitor and release the left mouse button when it's on top of the window that you want to swap it with), I imagine this could also be used to rearrange windows across multiple monitors just by looking at them.</p>
<p>If you have any questions you can reach out to me on <a href="https://hachyderm.io/@LGUG2Z">Mastodon</a> and <a href="https://twitter.com/JeezyCodes">Twitter</a>.</p>
<p>If you're interested in what I read to come up with solutions like this one, you can subscribe to my <a href="https://notado.app/feeds/jado/software-development">Software Development RSS feed</a>.</p>
<p>If you'd like to watch me writing code while explaining what I'm doing, you can also <a href="https://www.youtube.com/channel/UCeai3-do-9O4MNy9_xjO6mg?sub_confirmation=1">subscribe to my YouTube channel</a>.</p>
<p>Finally, if you are a <code>komorebi</code> user and you have the means, please consider <a href="https://github.com/sponsors/LGUG2Z">sponsoring the project</a>. :)</p>
Dynamic vs. Static Config for My Tiling Window ManagerTue, 25 Jul 2023 13:41:46 +0000lgug2z
https://lgug2z.com/articles/dynamic-vs-static-config-for-my-tiling-window-manager/
https://lgug2z.com/articles/dynamic-vs-static-config-for-my-tiling-window-manager/
<figure class="left">
<img src="https://user-images.githubusercontent.com/13164844/184027064-f5a6cec2-2865-4d65-a549-a1f1da589abf.png"
/>
</figure>
<p>For the last few years I have been writing and maintaining a <a href="https://github.com/LGUG2Z/komorebi">tiling window
manager</a> for Windows that has steadily
grown in usage and popularity.</p>
<p>My first exposure to tiling window managers was on macOS with <code>kwm</code> (which was
succeeded by <code>chunkwm</code> and later
<a href="https://github.com/koekeishiya/yabai"><code>yabai</code></a>). Naturally, this meant that
whenever I used Linux, I would reach for
<a href="https://github.com/baskerville/bspwm"><code>bspwm</code></a>.</p>
<p>I am a big proponent of what I call the "bspwm architecture" for tiling window
managers.</p>
<pre style="background-color:#2b303b;color:#c0c5ce;"><code><span> PROCESS SOCKET
</span><span>sxhkd --------> bspc <------> bspwm
</span></code></pre>
<p>Here is that same diagram in the context of my tiling window manager, <a href="https://github.com/LGUG2Z/komorebi"><code>komorebi</code></a>:</p>
<pre style="background-color:#2b303b;color:#c0c5ce;"><code><span> PROCESS SOCKET
</span><span>whkd/ahk --------> komorebic <------> komorebi
</span></code></pre>
<p>In the case of <code>bspwm</code> and <code>yabai</code>, shell scripts can be leveraged thanks to
this architecture to configure the tiling window manager. After all, what
better way to call CLI commands than a shell script?</p>
<p>This was initially the path that I also took with <code>komorebi</code>. It was a little
bit different, perhaps a little clunkier due to the limitations and differences
of CMD and PowerShell vs. <code>bash</code>, but it worked pretty well.</p>
<p>Below is the sample PowerShell script provided to new users to configure <code>komorebi</code>:</p>
<pre data-lang="powershell" style="background-color:#2b303b;color:#c0c5ce;" class="language-powershell "><code class="language-powershell" data-lang="powershell"><span style="color:#b48ead;">if </span><span>(!(</span><span style="color:#96b5b4;">Get-Process</span><span> whkd -ErrorAction SilentlyContinue))
</span><span>{
</span><span> </span><span style="color:#96b5b4;">Start-Process</span><span> whkd -WindowStyle </span><span style="color:#b48ead;">hidden
</span><span>}
</span><span>
</span><span>. $PSScriptRoot\komorebi.generated.ps1
</span><span>
</span><span style="color:#65737e;"># Send the ALT key whenever changing focus to force focus changes
</span><span>komorebic alt-focus-hack enable
</span><span style="color:#65737e;"># Default to cloaking windows when switching workspaces
</span><span>komorebic window-hiding-behaviour cloak
</span><span style="color:#65737e;"># Set cross-monitor move behaviour to insert instead of swap
</span><span>komorebic cross-monitor-</span><span style="color:#96b5b4;">move-behaviour</span><span> insert
</span><span style="color:#65737e;"># Enable hot reloading of changes to this file
</span><span>komorebic </span><span style="color:#96b5b4;">watch-configuration</span><span> enable
</span><span>
</span><span style="color:#65737e;"># Create named workspaces I-V on monitor 0
</span><span>komorebic ensure-named-workspaces </span><span style="color:#d08770;">0</span><span> I II III IV V
</span><span style="color:#65737e;"># You can do the same thing for secondary monitors too
</span><span style="color:#65737e;"># komorebic ensure-named-workspaces 1 A B C D E F
</span><span>
</span><span style="color:#65737e;"># Assign layouts to workspaces, possible values: bsp, columns, rows, vertical-stack, horizontal-stack, ultrawide-vertical-stack
</span><span>komorebic named-workspace-layout I bsp
</span><span>
</span><span style="color:#65737e;"># Set the gaps around the edge of the screen for a workspace
</span><span>komorebic named-workspace-padding I </span><span style="color:#d08770;">20
</span><span style="color:#65737e;"># Set the gaps between the containers for a workspace
</span><span>komorebic named-workspace-container-padding I </span><span style="color:#d08770;">20
</span><span>
</span><span style="color:#65737e;"># You can assign specific apps to named workspaces
</span><span style="color:#65737e;"># komorebic named-workspace-rule exe "Firefox.exe" III
</span><span>
</span><span style="color:#65737e;"># Configure the invisible border dimensions
</span><span>komorebic invisible-borders </span><span style="color:#d08770;">7 0 14 7
</span><span>
</span><span style="color:#65737e;"># Uncomment the next lines if you want a visual border around the active window
</span><span style="color:#65737e;"># komorebic active-window-border-colour 66 165 245 --window-kind single
</span><span style="color:#65737e;"># komorebic active-window-border-colour 256 165 66 --window-kind stack
</span><span style="color:#65737e;"># komorebic active-window-border-colour 255 51 153 --window-kind monocle
</span><span style="color:#65737e;"># komorebic active-window-border enable
</span><span>
</span><span>komorebic </span><span style="color:#96b5b4;">complete-configuration
</span></code></pre>
<p>The first <code>if</code> block, which starts <a href="https://github.com/LGUG2Z/whkd"><code>whkd</code></a> if
it isn't already running, should be conceptually familiar to users of <code>bspwm</code>
and <code>sxhkd</code>.</p>
<p>The next line however, calls another PS script full of generated commands, will
probably be less familiar. Take a moment to have a look at the contents of
<a href="https://github.com/LGUG2Z/komorebi/blob/master/komorebi.generated.ps1"><code>komorebi.generated.ps1</code></a>
before continuing with this article.</p>
<p>One of the major challenges with writing a tiling window manager for Windows is
the extreme lack of uniformity in Windows GUI application development. This
situation has only been made worse in recent years with the proliferation of
Electron applications that explicitly aim to eschew consistency with the
guidelines of the operating system in favour of their own internal
cross-platform consistency.</p>
<p>Quite early on, I made the decision to not hard-code tweaks for various
applications into the codebase, but to expose interfaces for users to be able
to configure their own application-specific tweaks.</p>
<p>I still believe that this is the right design decision, but what is gained by
not having to cut a new release to add compatibility fixes for individual
applications comes with its own set of trade-offs.</p>
<p>If these fixes were hard-coded into the codebase, there would be a natural
centralized location for all of them. Since this is not the case, last year I
created a <a href="https://github.com/LGUG2Z/komorebi-application-specific-configuration">centralized location for application-specific
fixes</a>
which users can contribute to by updating a user-friendly YAML file. It is from
this repository that <code>komorebi.generated.ps1</code> is generated.</p>
<p>If you took a look at the contents of <code>komorebi.generated.ps1</code>, you'll see that
there are hundreds of <code>komorebic</code> configuration commands that will be called.
It's possible to cut these down to just the things you might need, but to my
knowledge, there have not been any users who have done so.</p>
<p>To be able to provide as complete and stable an out-of-the-box experience,
applying these fixes is a must. However, as the list has grown longer, this has
had an negative impact on startup times, and as the list continues to grow, this
will only get worse.</p>
<p>The <code>v0.1.17</code> release of <code>komorebi</code> introduces <a href="https://github.com/LGUG2Z/komorebi/issues/427">static configuration
loading</a>. While part of the
motivation for implementing this feature was user demand (on Windows, for
whatever reason, people seem to be more adverse to the idea of configuration
through scripts in general), for me personally, the biggest motivation was to
reduce the growing startup times.</p>
<p>The sample static configuration file for new users now looks like this:</p>
<pre data-lang="json" style="background-color:#2b303b;color:#c0c5ce;" class="language-json "><code class="language-json" data-lang="json"><span>{
</span><span> "</span><span style="color:#a3be8c;">app_specific_configuration_path</span><span>": "</span><span style="color:#a3be8c;">$Env:USERPROFILE/applications.yaml</span><span>",
</span><span> "</span><span style="color:#a3be8c;">window_hiding_behaviour</span><span>": "</span><span style="color:#a3be8c;">Cloak</span><span>",
</span><span> "</span><span style="color:#a3be8c;">cross_monitor_move_behaviour</span><span>": "</span><span style="color:#a3be8c;">Insert</span><span>",
</span><span> "</span><span style="color:#a3be8c;">alt_focus_hack</span><span>": </span><span style="color:#d08770;">true</span><span>,
</span><span> "</span><span style="color:#a3be8c;">default_workspace_padding</span><span>": </span><span style="color:#d08770;">20</span><span>,
</span><span> "</span><span style="color:#a3be8c;">default_container_padding</span><span>": </span><span style="color:#d08770;">20</span><span>,
</span><span> "</span><span style="color:#a3be8c;">active_window_border</span><span>": </span><span style="color:#d08770;">false</span><span>,
</span><span> "</span><span style="color:#a3be8c;">active_window_border_colours</span><span>": {
</span><span> "</span><span style="color:#a3be8c;">single</span><span>": { "</span><span style="color:#a3be8c;">r</span><span>": </span><span style="color:#d08770;">66</span><span>, "</span><span style="color:#a3be8c;">g</span><span>": </span><span style="color:#d08770;">165</span><span>, "</span><span style="color:#a3be8c;">b</span><span>": </span><span style="color:#d08770;">245 </span><span>},
</span><span> "</span><span style="color:#a3be8c;">stack</span><span>": { "</span><span style="color:#a3be8c;">r</span><span>": </span><span style="color:#d08770;">256</span><span>, "</span><span style="color:#a3be8c;">g</span><span>": </span><span style="color:#d08770;">165</span><span>, "</span><span style="color:#a3be8c;">b</span><span>": </span><span style="color:#d08770;">66 </span><span>},
</span><span> "</span><span style="color:#a3be8c;">monocle</span><span>": { "</span><span style="color:#a3be8c;">r</span><span>": </span><span style="color:#d08770;">255</span><span>, "</span><span style="color:#a3be8c;">g</span><span>": </span><span style="color:#d08770;">51</span><span>, "</span><span style="color:#a3be8c;">b</span><span>": </span><span style="color:#d08770;">153 </span><span>}
</span><span> },
</span><span> "</span><span style="color:#a3be8c;">monitors</span><span>": [
</span><span> {
</span><span> "</span><span style="color:#a3be8c;">workspaces</span><span>": [
</span><span> { "</span><span style="color:#a3be8c;">name</span><span>": "</span><span style="color:#a3be8c;">I</span><span>", "</span><span style="color:#a3be8c;">layout</span><span>": "</span><span style="color:#a3be8c;">BSP</span><span>" },
</span><span> { "</span><span style="color:#a3be8c;">name</span><span>": "</span><span style="color:#a3be8c;">II</span><span>", "</span><span style="color:#a3be8c;">layout</span><span>": "</span><span style="color:#a3be8c;">VerticalStack</span><span>" },
</span><span> { "</span><span style="color:#a3be8c;">name</span><span>": "</span><span style="color:#a3be8c;">III</span><span>", "</span><span style="color:#a3be8c;">layout</span><span>": "</span><span style="color:#a3be8c;">HorizontalStack</span><span>" },
</span><span> { "</span><span style="color:#a3be8c;">name</span><span>": "</span><span style="color:#a3be8c;">IV</span><span>", "</span><span style="color:#a3be8c;">layout</span><span>": "</span><span style="color:#a3be8c;">UltrawideVerticalStack</span><span>" },
</span><span> { "</span><span style="color:#a3be8c;">name</span><span>": "</span><span style="color:#a3be8c;">V</span><span>", "</span><span style="color:#a3be8c;">layout</span><span>": "</span><span style="color:#a3be8c;">Rows</span><span>" }
</span><span> ]
</span><span> }
</span><span> ]
</span><span>}
</span></code></pre>
<p>In addition to being a more visual representation of the desired window manager
state, being able to specify the location to the raw application-specific
configuration YAML file means all those hundreds of fixes can be loaded with a
single atomic command, which results in a massive reduction in startup time.</p>
<p>You can check out the quickstart video for v0.1.17+ on YouTube below, and even
compare it to the <a href="https://www.youtube.com/watch?v=cBnLIwMtv8g">previous quickstart
video</a> to see the startup
performance improvements compared to the previous dynamic configuration
approach.</p>
<div
style="position: relative; padding-bottom: 56.25%; height: 0; overflow: hidden;">
<iframe allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share; fullscreen"
loading="eager"
referrerpolicy="strict-origin-when-cross-origin"
src="https://www.youtube.com/embed/hDDxtvpjpHs"
style="position: absolute; top: 0; left: 0; width: 100%; height: 100%; border:0;"
title="YouTube video"
allowfullscreen></iframe>
</div>
<p>Support for dynamic configuration is not going away any time soon as it is
important to maintain backwards compatibility for existing users. However, for
users that wish to migrate to a static configuration file, a convenient
<code>komorebic</code> command, <code>generate-static-config</code>, has been added to automatically
generate a static configuration file from a running instance of <code>komorebi</code>
configured using a dynamic configuration script.</p>
<div
style="position: relative; padding-bottom: 56.25%; height: 0; overflow: hidden;">
<iframe allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share; fullscreen"
loading="eager"
referrerpolicy="strict-origin-when-cross-origin"
src="https://www.youtube.com/embed/yqCAOJgL3C0"
style="position: absolute; top: 0; left: 0; width: 100%; height: 100%; border:0;"
title="YouTube video"
allowfullscreen></iframe>
</div>
<p>If you have any questions you can reach out to me on <a href="https://hachyderm.io/@LGUG2Z">Mastodon</a> and <a href="https://twitter.com/JeezyCodes">Twitter</a>.</p>
<p>If you're interested in what I read to come up with solutions like this one, you can subscribe to my <a href="https://notado.app/feeds/jado/software-development">Software Development RSS feed</a>.</p>
<p>If you'd like to watch me writing code while explaining what I'm doing, you can
also <a href="https://www.youtube.com/channel/UCeai3-do-9O4MNy9_xjO6mg?sub_confirmation=1">subscribe to my YouTube
channel</a>.</p>
Ditching Docker for Local DevelopmentFri, 21 Jul 2023 19:17:20 +0000lgug2z
https://lgug2z.com/articles/ditching-docker-for-local-development/
https://lgug2z.com/articles/ditching-docker-for-local-development/<p>Earlier this month <a href="https://hachyderm.io/@LGUG2Z/110691389843817387">I mentioned on Mastodon</a> that I was replacing a Docker-based local development environment at my day job with a Nix-based one, orchestrated with <code>overmind</code> and a <code>justfile</code>.</p>
<p>There was quite a lot of interest in particular in how <a href="https://github.com/DarthSim/overmind"><code>overmind</code></a> and <a href="https://github.com/casey/just"><code>just</code></a> could be used to replace a container / compose-based local development.</p>
<p>While I can't share the details of the significantly more complex migration I did at my day job (yet! - I'm working internally on trying to find a way that we can disseminate the learnings publicly), I <em>can</em> share a simplified real-world example that I use for developing <a href="https://notado.app">Notado</a>.</p>
<p>Let's take a look at my <code>shell.nix</code>:</p>
<pre data-lang="nix" style="background-color:#2b303b;color:#c0c5ce;" class="language-nix "><code class="language-nix" data-lang="nix"><span style="color:#8fa1b3;">{</span><span>pkgs ? </span><span style="color:#96b5b4;">import </span><span>(</span><span style="color:#bf616a;">fetchTarball </span><span>"</span><span style="color:#a3be8c;">https://nixos.org/channels/nixos-unstable/nixexprs.tar.xz</span><span>") {}</span><span style="color:#8fa1b3;">}</span><span>:
</span><span style="color:#b48ead;">with </span><span style="color:#bf616a;">pkgs</span><span>; </span><span style="color:#b48ead;">let
</span><span> </span><span style="color:#d08770;">pkgs-2023_03_11 </span><span>= </span><span style="color:#96b5b4;">import </span><span>(</span><span style="color:#d08770;">builtins</span><span>.</span><span style="color:#bf616a;">fetchTarball </span><span>{
</span><span> </span><span style="color:#d08770;">url </span><span>= "</span><span style="color:#a3be8c;">https://github.com/NixOS/nixpkgs/archive/8ad5e8132c5dcf977e308e7bf5517cc6cc0bf7d8.tar.gz</span><span>";
</span><span> }) {};
</span><span>
</span><span> </span><span style="color:#d08770;">meilisearch-1_0_2 </span><span>= </span><span style="color:#bf616a;">pkgs-2023_03_11</span><span>.</span><span style="color:#bf616a;">meilisearch</span><span>;
</span><span style="color:#b48ead;">in
</span><span> </span><span style="color:#bf616a;">mkShell </span><span>{
</span><span> </span><span style="color:#d08770;">name </span><span>= "</span><span style="color:#a3be8c;">notado</span><span>";
</span><span>
</span><span> </span><span style="color:#d08770;">MEILI_MASTER_KEY </span><span>= "</span><span style="color:#a3be8c;">default</span><span>";
</span><span> </span><span style="color:#d08770;">MEILI_DB_PATH </span><span>= "</span><span style="color:#a3be8c;">data.ms</span><span>";
</span><span> </span><span style="color:#d08770;">PGDATA </span><span>= "</span><span style="color:#a3be8c;">data.pg</span><span>";
</span><span>
</span><span> </span><span style="color:#d08770;">buildInputs </span><span>= [
</span><span> </span><span style="color:#bf616a;">alejandra
</span><span> </span><span style="color:#bf616a;">bacon
</span><span> </span><span style="color:#bf616a;">cargo-cache
</span><span> </span><span style="color:#bf616a;">cargo-expand
</span><span> </span><span style="color:#bf616a;">cargo-insta
</span><span> </span><span style="color:#bf616a;">cargo-udeps
</span><span> </span><span style="color:#bf616a;">diesel-cli
</span><span> </span><span style="color:#bf616a;">go
</span><span> </span><span style="color:#bf616a;">just
</span><span> </span><span style="color:#bf616a;">meilisearch-1_0_2
</span><span> </span><span style="color:#bf616a;">nodePackages</span><span>.</span><span style="color:#bf616a;">typescript
</span><span> </span><span style="color:#bf616a;">nodePackages</span><span>.</span><span style="color:#bf616a;">web-ext
</span><span> </span><span style="color:#bf616a;">nodejs
</span><span> </span><span style="color:#bf616a;">openssl
</span><span> </span><span style="color:#bf616a;">overmind
</span><span> </span><span style="color:#bf616a;">pkg-config
</span><span> </span><span style="color:#bf616a;">postgresql_15
</span><span> </span><span style="color:#bf616a;">rustup
</span><span> </span><span style="color:#bf616a;">terraform
</span><span> </span><span style="color:#bf616a;">tmux
</span><span> ];
</span><span> }
</span></code></pre>
<p>This pulls in:</p>
<ul>
<li>Packages for the two main data stores, PostgreSQL and Meilisearch</li>
<li>Tooling related to the main languages used to write Notado (Rust, Go, Typescript)</li>
<li>Orchestration tooling (<code>just</code>, <code>overmind</code>)</li>
</ul>
<p>While also setting some environment variables which ensure that the data directories for the data stores have recognizable names in the root directory of the monorepo.</p>
<p><code>overmind</code> is used in the Notado local development environment to orchestrate data stores. Notado has three services related to the data stores: PostgreSQL, Meilisearch and a PostgreSQL -> Meilisearch listener <a href="https://notado.substack.com/p/how-notado-syncs-data-from-postgresql">which syncs data from the former to the latter</a>.</p>
<p>Here is the <code>Procfile</code> used by <code>overmind</code>:</p>
<pre style="background-color:#2b303b;color:#c0c5ce;"><code><span>meilisearch: meilisearch
</span><span>postgres: postgres -k /tmp
</span></code></pre>
<p>Meilisearch doesn't really need in arguments in our case for local development, and PostgreSQL takes a single <code>-k /tmp</code> flag to set the Unix domain socket location. I don't include the listener here because I don't always need it running and if I'm working on it, I often have to recompile to see new changes, which doesn't make it a good fit to live here.</p>
<p>Running <code>overmind start</code> brings up the processes defined in the <code>Procfile</code> for us, similarly to how <code>docker-compose up</code> might work if we were using a container-based local environment.</p>
<pre style="background-color:#2b303b;color:#c0c5ce;"><code><span>system | Tmux socket name: overmind-notado-sqlAl1e6xKxH6K6Ayr3sU
</span><span>system | Tmux session ID: notado
</span><span>system | Listening at ./.overmind.sock
</span><span>postgres | Started with pid 2073401...
</span><span>meilisearch | Started with pid 2073398...
</span><span>postgres | postgres -k /tmp
</span><span>meilisearch | meilisearch
</span><span>meilisearch |
</span><span>meilisearch | 888b d888 d8b 888 d8b 888
</span><span>meilisearch | 8888b d8888 Y8P 888 Y8P 888
</span><span>meilisearch | 88888b.d88888 888 888
</span><span>meilisearch | 888Y88888P888 .d88b. 888 888 888 .d8888b .d88b. 8888b. 888d888 .d8888b 88888b.
</span><span>meilisearch | 888 Y888P 888 d8P Y8b 888 888 888 88K d8P Y8b "88b 888P" d88P" 888 "88b
</span><span>meilisearch | 888 Y8P 888 88888888 888 888 888 "Y8888b. 88888888 .d888888 888 888 888 888
</span><span>meilisearch | 888 " 888 Y8b. 888 888 888 X88 Y8b. 888 888 888 Y88b. 888 888
</span><span>meilisearch | 888 888 "Y8888 888 888 888 88888P' "Y8888 "Y888888 888 "Y8888P 888 888
</span><span>meilisearch |
</span><span>meilisearch | Config file path: "none"
</span><span>meilisearch | Database path: "data.ms"
</span><span>meilisearch | Server listening on: "http://localhost:7700"
</span><span>meilisearch | Environment: "development"
</span><span>meilisearch | Commit SHA: "unknown"
</span><span>meilisearch | Commit date: "unknown"
</span><span>meilisearch | Package version: "1.0.2"
</span><span>meilisearch |
</span><span>meilisearch | A master key has been set. Requests to Meilisearch won't be authorized unless you provide an authentication key.
</span><span>meilisearch |
</span><span>meilisearch |
</span><span>meilisearch | Meilisearch started with a master key considered unsafe for use in a production environment.
</span><span>meilisearch |
</span><span>meilisearch | A master key of at least 16 bytes will be required when switching to a production environment.
</span><span>meilisearch |
</span><span>meilisearch |
</span><span>meilisearch | We generated a new secure master key for you (you can safely use this token):
</span><span>meilisearch |
</span><span>meilisearch | >> --master-key YYzxDVu7YNEHyozuGU2itFVW-vnkvAzQVbCMCeOxZzI <<
</span><span>meilisearch |
</span><span>meilisearch | Restart Meilisearch with the argument above to use this new and secure master key.
</span><span>meilisearch |
</span><span>meilisearch | Documentation: https://docs.meilisearch.com
</span><span>meilisearch | Source code: https://github.com/meilisearch/meilisearch
</span><span>meilisearch | Contact: https://docs.meilisearch.com/resources/contact.html
</span><span>meilisearch |
</span><span>postgres | 2023-07-21 19:30:20.552 UTC [2073407] LOG: starting PostgreSQL 15.2 on x86_64-pc-linux-gnu, compiled by gcc (GCC) 12.2.0, 64-bit
</span><span>postgres | 2023-07-21 19:30:20.554 UTC [2073407] LOG: listening on IPv6 address "::1", port 5432
</span><span>postgres | 2023-07-21 19:30:20.554 UTC [2073407] LOG: listening on IPv4 address "127.0.0.1", port 5432
</span><span>meilisearch | [2023-07-21T19:30:20Z INFO actix_server::builder] Starting 12 workers
</span><span>meilisearch | [2023-07-21T19:30:20Z INFO actix_server::server] Actix runtime found; starting in Actix runtime
</span><span>postgres | 2023-07-21 19:30:20.560 UTC [2073407] LOG: listening on Unix socket "/tmp/.s.PGSQL.5432"
</span><span>postgres | 2023-07-21 19:30:20.570 UTC [2073433] LOG: database system was shut down at 2023-07-21 01:33:23 UTC
</span><span>postgres | 2023-07-21 19:30:20.585 UTC [2073407] LOG: database system is ready to accept connections
</span></code></pre>
<p>These processes will keep chugging along. You can also have them running in detached mode if you prefer.</p>
<p>The last thing that pulls this together is a <code>justfile</code>, which consists of commands that I run either as one-offs, or for services that I stop and start regularly during development:</p>
<pre data-lang="makefile" style="background-color:#2b303b;color:#c0c5ce;" class="language-makefile "><code class="language-makefile" data-lang="makefile"><span style="color:#8fa1b3;">initdb</span><span>:
</span><span> </span><span style="color:#bf616a;">initdb -D</span><span> data.pg</span><span style="color:#bf616a;"> -U</span><span> postgres
</span><span>
</span><span style="color:#8fa1b3;">migrate</span><span>:
</span><span> </span><span style="color:#96b5b4;">cd</span><span> rust && </span><span style="color:#bf616a;">diesel</span><span> migration run
</span><span>
</span><span style="color:#8fa1b3;">revert</span><span>:
</span><span> </span><span style="color:#96b5b4;">cd</span><span> rust && </span><span style="color:#bf616a;">diesel</span><span> migration revert
</span><span>
</span><span style="color:#8fa1b3;">revert-all</span><span>:
</span><span> </span><span style="color:#96b5b4;">cd</span><span> rust && </span><span style="color:#bf616a;">diesel</span><span> migration revert</span><span style="color:#bf616a;"> --all
</span><span>
</span><span style="color:#8fa1b3;">listener</span><span>:
</span><span> </span><span style="color:#96b5b4;">cd</span><span> go/listener && </span><span style="color:#bf616a;">go</span><span> run main.go
</span><span>
</span><span style="color:#8fa1b3;">notado</span><span>:
</span><span> </span><span style="color:#96b5b4;">cd</span><span> rust/notado && </span><span style="color:#bf616a;">cargo</span><span> run
</span></code></pre>
<ul>
<li><code>initdb</code> is a one-off that is run to initialize a fresh PostgreSQL database, which I need to do whenever I nuke the <code>data.pg</code> directory</li>
<li><code>migrate</code>, <code>revert</code>, and <code>revert-all</code> are database migration commands</li>
<li><code>listener</code> starts the PostgreSQL -> Meilisearch listener which syncs data</li>
<li><code>notado</code> starts the web server</li>
</ul>
<p>That's it, the whole local development environment! Simple, elegant, portable and best of all, no containers!</p>
<p>While I initially used Docker containers to deploy Notado, <a href="https://notado.substack.com/p/how-flyio-and-tailscale-saved-notado">first to a Kubernetes cluster, and then later to Fly.io</a>, I now deploy the binaries built by Nix directly to a server running NixOS and manage the services with <code>systemd</code>.</p>
<p>If you have any questions you can reach out to me on <a href="https://hachyderm.io/@LGUG2Z">Mastodon</a> and <a href="https://twitter.com/JeezyCodes">Twitter</a>.</p>
<p>If you're interested in what I read to come up with solutions like this one, you can subscribe to my <a href="https://notado.app/feeds/jado/software-development">Software Development RSS feed</a>.</p>
<p>If you'd like to watch me writing code while explaining what I'm doing, you can also <a href="https://www.youtube.com/channel/UCeai3-do-9O4MNy9_xjO6mg?sub_confirmation=1">subscribe to my YouTube channel</a>.</p>
<p>Edit: There has been a lot of great discussion going on over at <a href="https://tildes.net/~comp/18h3/ditching_docker_for_local_development">Tildes</a> about <em>why</em> you might want to do something like this, which is also worth reading!</p>
Notado 07/2023 Update: API Price Gouging, New Services, ArchivingMon, 17 Jul 2023 15:17:59 +0000lgug2z
https://lgug2z.com/articles/notado-07-2023-update/
https://lgug2z.com/articles/notado-07-2023-update/<p>The last few months have seen a flurry of changes on major social media websites like Twitter and Reddit with regards to API access. This has also resulted in a number of new competitors seeing rapid growth and becoming new hubs for online discussion.</p>
<p>Let's start with the first point: you will always be able to save Reddit comments and Tweets to Notado by highlighting and saving a text selection. This doesn't require any API access or scraping, and it will always work because it only relies on what is visible in your browser.</p>
<p>However, as current users will know, <a href="https://notado.app">Notado</a> offers a deeper, more ergonomic integration with both Reddit and Twitter (and many other services), which allows users to save comments and Tweets using permalinks. In a desktop browser, this is as simple as a right click on a link. On mobile, this is as simple as a share action on a link.</p>
<p>In this increasingly adversarial atmosphere between social media companies and developers of services and applications which aim to give more control to users over the retrieval of their history of liked/favourited/bookmarked posts, I won't detail the exact mechanisms that currently enable this deeper integration. I will however, assure you that everything is continuing to work as expected and should continue to work as expected in the near future.</p>
<p>On the second point: Notado has added this same level of deeper ergonomic integration, allowing users to save posts using only permalinks, for both Lemmy and BlueSky, and work is currently under way add Threads to this list as well.</p>
<p>The final point that I would like to touch on is archiving. I've had many users ask me about the potential for introducing support for full webpage archiving to Notado. I have been exploring this for some time, and it is a feature that is very hard to get right.</p>
<p>Although Pinboard advertises this feature, <a href="https://hn.algolia.com/?dateRange=all&page=0&prefix=true&query=pinboard%20archiving&sort=byDate&type=comment">the general consensus is that it just doesn't work</a>. I was discussing Pinboard's archiving feature with a friend who may well be one of Pinboard's biggest users this past weekend, and after some digging it turned out that only links they had saved more than 5 weeks ago had been recorded as archived, but nothing that was reported as archived was actually accessible.</p>
<p>The <a href="https://archive.org/web/">Internet Archive's Wayback Machine</a> is probably the best archiver out there, and there is no way that anything that I try to come up with will ever be better in any way.</p>
<p>Starting today, users with an active paid subscription can rest assured that the source websites of their saved highlights, comments and other posts will be archived by the best web archiver known to humanity.</p>
<p>Items in your library will now have a "Web Archive" link which you can follow to view a fully archived version of the source website on the Wayback Machine. Notado will make sure that there is at least one archived copy of everything you save available on the Wayback Machine. New content that you save should have a "Web Archive" link available within 24-48 hours.</p>
<p>If you haven't used Notado before and this sounds interesting to you, feel free to <a href="https://lgug2z.com/articles/the-bookmarking-data-model-is-wrong-for-highlighting/">read more about Notado's content-first bookmarking approach</a> and try the <a href="https://notado.app">30-day free trial</a> (no billing details required).</p>
<p>After the 30-day free trial, Notado is very moderately priced at $1.99/month, and has only a single tier in which all features (archiving, tagging rules, smart feeds, full-text search, unlimited highlights and comments support etc.) are included.</p>
Black HoleSun, 16 Jul 2023 03:39:35 +0000lgug2z
https://lgug2z.com/articles/black-hole/
https://lgug2z.com/articles/black-hole/<p>Earlier this evening I finally had the opportunity to watch Spiderman: Across the Spiderverse, which I enjoyed a lot.</p>
<p>I have been reflecting on how my experience of films like these (films with teenage protagonists) has changed with age.</p>
<p>Even in my mid-to-late 20s, I would often identify clearly with the teenage protagonists, but over the last few years I have increasingly started identifying with the parents of the protagonists. Spiderman: Across the Spiderverse was no exception to this recent trend.</p>
<p>I doubt that younger versions of myself could ever have imagined the extent to which my mental and emotional state would deteriorate as a result of childlessness.</p>
<p>My friends often remark at how busy I keep myself. How I find time to invest in new hobbies. How I find time to create and maintain open source projects. How I find time to dance. How I find time to record videos to post to TikTok and YouTube.</p>
<p>Do I enjoy all of these things that I spend my time doing? I do, very much.</p>
<p>Am I constantly trying to fill the black hole of childlessness that threatens to devour me every morning, day and night? I am, very much.</p>
<p>I would gladly trade it all for parenthood.</p>
The Bookmarking Data Model Is Wrong For HighlightingSat, 13 May 2023 07:25:26 -0700lgug2z
https://lgug2z.com/articles/the-bookmarking-data-model-is-wrong-for-highlighting/
https://lgug2z.com/articles/the-bookmarking-data-model-is-wrong-for-highlighting/<p>The data model for bookmarking and highlighting services past and present can generally be distilled down to the
following:</p>
<pre style="background-color:#2b303b;color:#c0c5ce;"><code><span>* URL
</span><span> * Title
</span><span> * Scraped Content
</span><span> * User Highlight
</span><span> * User Annotation
</span><span> * ... Other Metadata
</span></code></pre>
<p>The bookmark itself is tied to a URL, and anything else related to the bookmark, such as the title, the
scraped content (if the service scrapes on your behalf), highlights and annotations are stored as additional metadata
linked to that URL.</p>
<p>There are some unfortunate restrictions that come with this data model.</p>
<p>Let's take the example of comments.</p>
<p>Comments by their nature are distributed; the same article can be shared on any number of websites for any number of
different users and communities to discuss. Especially in the case of tightly focused communities, the commentary on an
article is often just as valuable as the article itself.</p>
<p>When the data model is anchored around the URL, highlights become tightly coupled to that URL, and this tight coupling
ignores the distributed reality of comments, leaving them with no real place to exist in the data model.</p>
<p>Ideally, highlights made on an article and comments saved about an article should be easy to connect and view
together, because it is the article itself, and not the URL, that is the common denominator (the URL is an imperfect
proxy for the article).</p>
<div
style="position: relative; padding-bottom: 56.25%; height: 0; overflow: hidden;">
<iframe allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share; fullscreen"
loading="eager"
referrerpolicy="strict-origin-when-cross-origin"
src="https://www.youtube.com/embed/-tJNxzH1zCA"
style="position: absolute; top: 0; left: 0; width: 100%; height: 100%; border:0;"
title="YouTube video"
allowfullscreen></iframe>
</div>
<p>What if there is nothing of value in the article itself to highlight, but the discussion of the article contains the
real information of value that you want to save? This is often the case when a commenter debunks bogus claims published
in an article shared on link aggregation websites while simultaneously explaining their method.</p>
<p>If you think about this for long enough, you may also come to the conclusion that I have, that saving comments is also a
form of highlighting, and both comments and highlights can be described in a common data model as "content".</p>
<p>A <a href="https://notado.app">content-first</a> data model solves all of these issues and more, but it requires deliberate thought
and planning when designing a service, and it is not something that can be tacked on at a later stage after starting out
with a bookmarking data model.</p>
<p>In addition to solving these issues, a content-first data model also allows for new and exciting possibilities in the
sharing of the information that we save. Especially if you are a believer in the idea
that <a href="https://www.google.com/search?q=%22knowledge+is+social%22">knowledge is social</a>.</p>
<p>If you have been a subscriber of Instapaper or Pinboard in the past, you may have subscribed to RSS feeds filled with
URLs friends and peers were saving. There is a limited utility in this kind of feed which essentially acts as a
firehose, leaving the significant burden of filtering down to the individual subscriber, who has no way of knowing <em>why</em>
an item was added or saved.</p>
<p>If you have been a subscriber of Readwise in the past, you may have used the link sharing feature to share a scraped
version of a free or a pay-walled article with others, complete with your highlights and annotations. There is again a
limited utility in this kind of sharing which involves significant manual intervention every time to generate a
shareable link and then again share it with N individuals.</p>
<p>Earlier this month I wrote
about <a href="https://lgug2z.com/articles/embedding-rss-feeds-on-my-static-website/">embedding RSS feeds on a static website</a>,
but I didn't get too much into how <a href="https://notado.app/feeds/jado">the feeds themselves</a> were being populated.</p>
<div
style="position: relative; padding-bottom: 56.25%; height: 0; overflow: hidden;">
<iframe allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share; fullscreen"
loading="eager"
referrerpolicy="strict-origin-when-cross-origin"
src="https://www.youtube.com/embed/fgtrBdp2AZQ"
style="position: absolute; top: 0; left: 0; width: 100%; height: 100%; border:0;"
title="YouTube video"
allowfullscreen></iframe>
</div>
<p>With a content-first data model, each individual piece of content can be tagged and classified independently, both
independently as a piece of text, but also independently of its source material. This can be leveraged along with
content-driven tagging rules to publish self-populating RSS feeds, which is how the feeds listed under
the <a href="https://lgug2z.com">"Recent Highlights" section of the homepage</a> of this website are populated.</p>
<figure class="left">
<img src="images/recent_highlights.png"
/>
<figcaption class="center"
>
The 'Recent Highlights' section of the homepage, powered by Notado Feeds
</figcaption>
</figure>
<p>If reading any of this has flipped a switch in your head, I encourage you to try out <a href="https://notado.app">Notado</a>. It is
a simple, text-focused knowledge management and publishing service with no clunky JavaScript frameworks slowing your
machine to a crawl.</p>
<p>If you liked the ethos and design of Pinboard, but feel like the feature set has not kept up with the competition,
there's a pretty good chance you'll like Notado. If you're a hacker who likes to build, Notado also has an extensive and
fully documented GraphQL API for you to play with.</p>
<p>In addition to web highlights on desktop and mobile devices, Notado also supports Kindle highlights (without having to
reauthorize with your Amazon account periodically) and first-class support for saving and importing comments from
popular link aggregator and microblogging platforms such as Reddit, Mastodon, Hacker News, Twitter, YouTube, and more.</p>
<p>Notado comes with a 30-day free trial (no billing details required), after which it is very moderately priced and has
only a single tier in which all features (tagging rules, smart feeds, full-text search, unlimited highlights and comments support etc.) are included.</p>
<table><thead><tr><th>Service</th><th>Monthly Cost (full feature set)</th></tr></thead><tbody>
<tr><td>Notado</td><td><strong>$1.99</strong></td></tr>
<tr><td>Readwise</td><td>$7.99</td></tr>
<tr><td>Pinboard</td><td>$1.83</td></tr>
<tr><td>Instapaper</td><td>$2.99</td></tr>
<tr><td>Raindrop</td><td>$2.33</td></tr>
</tbody></table>
<p>Notado will be around at least as long as I am, as my one and only knowledge management tool for the rest of my life. In
the event that I'm no longer able to run Notado, plans have been made to open source the codebase for people to continue
running their own instances.</p>
The Banal Stupidity of Automated Chrome Store ReviewsThu, 11 May 2023 16:12:17 -0700lgug2z
https://lgug2z.com/articles/the-banal-stupidity-of-automated-chrome-store-reviews/
https://lgug2z.com/articles/the-banal-stupidity-of-automated-chrome-store-reviews/<p>For the past three years I have maintained a browser extension that I publish to both the Firefox and Chrome web stores.</p>
<p>I recently received an automated email from the Chrome web store overlords who had decided I was violating
the <a href="https://developer.chrome.com/docs/webstore/program-policies/permissions/">"Use of permissions"</a> policy by
requesting the <code>cookies</code> permission "without using it".</p>
<p>Here is a snippet from the <code>manifest.json</code> for my extension:</p>
<pre data-lang="json" style="background-color:#2b303b;color:#c0c5ce;" class="language-json "><code class="language-json" data-lang="json"><span>{
</span><span> "</span><span style="color:#a3be8c;">permissions</span><span>": [
</span><span> "</span><span style="color:#a3be8c;">activeTab</span><span>",
</span><span> "</span><span style="color:#a3be8c;">contextMenus</span><span>",
</span><span> "</span><span style="color:#a3be8c;">notifications</span><span>",
</span><span> "</span><span style="color:#a3be8c;">cookies</span><span>",
</span><span> "</span><span style="color:#a3be8c;">https://<SPECIFIC_WEBSITE_RUN_BY_ME>/</span><span>"
</span><span> ]
</span><span>}
</span></code></pre>
<p>I request the cookies permission for <code>https://<SPECIFIC_WEBSITE_RUN_BY_ME>/</code> so that I can transparently use the cookie
set by that website when the user logs in to send authenticated HTTP requests from the extension. Nothing crazy:</p>
<pre data-lang="typescript" style="background-color:#2b303b;color:#c0c5ce;" class="language-typescript "><code class="language-typescript" data-lang="typescript"><span style="color:#b48ead;">function </span><span style="color:#8fa1b3;">createRequest</span><span>(</span><span style="color:#bf616a;">query</span><span>: GraphQLQuery): Request {
</span><span> </span><span style="color:#b48ead;">return </span><span>new Request(`</span><span style="color:#a3be8c;">${</span><span style="color:#bf616a;">BASE_URL</span><span style="color:#a3be8c;">}/gql</span><span>`, {
</span><span> method: "</span><span style="color:#a3be8c;">POST</span><span>",
</span><span> headers: { "</span><span style="color:#a3be8c;">Content-Type</span><span>": "</span><span style="color:#a3be8c;">application/json</span><span>" },
</span><span> body: JSON.</span><span style="color:#96b5b4;">stringify</span><span>(</span><span style="color:#bf616a;">query</span><span>),
</span><span> });
</span><span>}
</span></code></pre>
<p>The email informing me of this "violation" gave me an avenue to appeal. Since this seemed like it was just a little
misunderstanding, I explained how the cookie set by logging in to the website was being used to send authenticated HTTP
requests from the browser extension. Silly me, of course there are no actual humans checking appeals at Google.</p>
<blockquote>
<p>Upon subsequent review, we found that your item is not compliant with our “Use of permissions” policy.</p>
<p>Please find the details of the violation below:</p>
<p>Use of permissions:</p>
<p>• Violation: “cookies” permission is requested, but not used in the code.</p>
<p>• How to rectify: Remove “cookies” permission from the manifest file.</p>
<p>We request you to submit your item for a review with the above corrective actions. Your submission will be approved if
we find it to be compliant with all our policies.</p>
</blockquote>
<p>I could have appealed the rejection, but I knew how this would ultimately have to end.</p>
<pre data-lang="javascript" style="background-color:#2b303b;color:#c0c5ce;" class="language-javascript "><code class="language-javascript" data-lang="javascript"><span style="color:#b48ead;">let </span><span style="color:#bf616a;">sid </span><span>= </span><span style="color:#b48ead;">await </span><span style="color:#bf616a;">browser</span><span>.</span><span style="color:#bf616a;">cookies</span><span>.</span><span style="color:#96b5b4;">get</span><span>({
</span><span> name: "</span><span style="color:#a3be8c;">sid</span><span>",
</span><span> url: "</span><span style="color:#a3be8c;">https://<SPECIFIC_WEBSITE_RUN_BY_ME></span><span>",
</span><span>});
</span><span>
</span><span style="color:#ebcb8b;">console</span><span>.</span><span style="color:#96b5b4;">log</span><span>(</span><span style="color:#bf616a;">sid</span><span>);
</span></code></pre>
<p>And just like that, I once again became compliant with the "Use of permissions" policy.</p>
Embedding RSS Feeds on My Static WebsiteTue, 09 May 2023 15:26:54 -0700lgug2z
https://lgug2z.com/articles/embedding-rss-feeds-on-my-static-website/
https://lgug2z.com/articles/embedding-rss-feeds-on-my-static-website/<p>
<div
style="position: relative; padding-bottom: 56.25%; height: 0; overflow: hidden;">
<iframe allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share; fullscreen"
loading="eager"
referrerpolicy="strict-origin-when-cross-origin"
src="https://www.youtube.com/embed/\_CqedKHV_NQ"
style="position: absolute; top: 0; left: 0; width: 100%; height: 100%; border:0;"
title="YouTube video"
allowfullscreen></iframe>
</div>
<br /></p>
<p>I have wanted to redesign my website for a while now. In fact, I have wanted to redesign my website ever since I saw
<a href="https://nathanwentworth.co">Nathan Wentworth</a>'s website. If you haven't seen it before, check it out. To this day it
remains my favourite personal website on the internet.</p>
<p>My favourite feature on his website has to be the regularly updating feeds; music from https://last.fm, recent posts
from https://mastodon.social, and recent bookmarks from https://pinboard.in.</p>
<p>I looked into the codebase for the website and saw that it was being generated using <a href="https://jekyllrb.com">Jekyll</a>.
Heartened by the idea that such a website could be achieved using a static website generator, I opened up a
new <a href="https://gohugo.io">Hugo</a> project (my <a href="https://lgug2z.com/articles/resurrecting-my-old-website-with-ancient-nixos-packages/">previous website</a> was also
built on Hugo), found a <a href="https://github.com/panr/hugo-theme-terminal">theme that had the right kind of vibe</a> and
proceeded to hack away.</p>
<p>I wantonly mixed and matched <code>gohtml</code> partials and shortcodes with <code>md</code> files liberally sprinkled with raw <code>html</code>
snippets to attain the right balance of rows and columns. Slowly, the kind of high-information-density design
that I have come to love (and contemporary designers love to hate) started to come together.</p>
<p>A few hours later, it was time to bring in my data.</p>
<p>Back in 2020, I grew so frustrated with bookmarking services that <a href="https://notado.app">I decided to create my own</a>. My
big issue with all the incumbents was simple: I didn't want to bookmark URLs, I wanted to bookmark content.</p>
<p>Whether that content was a quote from an article, a highlight from my Kindle, or a comment on HackerNews,
Reddit, or anywhere else, it was <strong>the content</strong> that I cared about, not the URLs and titles, which are really just
pieces of metadata.</p>
<p>Fast-forward to 2023, and I have <a href="https://notado.app/feeds/jado">a number of highlights feeds</a> that I curate on
different topics which people either subscribe to using RSS readers or check in on using a browser.</p>
<p><strong>This</strong> was the data that I really wanted to show on my new website.</p>
<p>Hugo makes adding this sort of functionality surprisingly simple with the use of
user-definable <a href="https://gohugo.io/content-management/shortcodes/">shortcodes</a>.</p>
<p>Here is the shortcode I wrote to display content from my Notado RSS feeds to my website:</p>
<pre data-lang="jinja2" style="background-color:#2b303b;color:#c0c5ce;" class="language-jinja2 "><code class="language-jinja2" data-lang="jinja2"><span>// layouts/shortcodes/notado.html
</span><span>{{ </span><span style="color:#b48ead;">if </span><span>.</span><span style="color:#bf616a;">Get </span><span>"</span><span style="color:#a3be8c;">url</span><span>" }}
</span><span> {{ $</span><span style="color:#bf616a;">url </span><span>:= .</span><span style="color:#bf616a;">Get </span><span>"</span><span style="color:#a3be8c;">url</span><span>" }}
</span><span> {{ $</span><span style="color:#bf616a;">limit </span><span>:= .</span><span style="color:#bf616a;">Get </span><span>"</span><span style="color:#a3be8c;">limit</span><span>" }}
</span><span>
</span><span> {{ </span><span style="color:#bf616a;">with resources</span><span>.</span><span style="color:#bf616a;">GetRemote</span><span> $</span><span style="color:#bf616a;">url </span><span>| </span><span style="color:#bf616a;">transform</span><span>.</span><span style="color:#bf616a;">Unmarshal </span><span>}}
</span><span> {{ </span><span style="color:#bf616a;">range first</span><span> $</span><span style="color:#bf616a;">limit </span><span>.</span><span style="color:#bf616a;">channel</span><span>.</span><span style="color:#bf616a;">item </span><span>}}
</span><span> <div style="font-size: small; padding: 10px; background-color: #373641; word-wrap: break-word;">
</span><span> <p>
</span><span> {{ .</span><span style="color:#bf616a;">description </span><span>| </span><span style="color:#bf616a;">safeHTML </span><span>| </span><span style="color:#bf616a;">truncate</span><span> 300 }}
</span><span> </p>
</span><span> <p>(<a href="{{ .</span><span style="color:#bf616a;">link </span><span>}}">View</a>)</p>
</span><span> </div>
</span><span> <br />
</span><span> {{ </span><span style="color:#bf616a;">end </span><span>}}
</span><span> {{ </span><span style="color:#bf616a;">end </span><span>}}
</span><span>
</span><span>{{ </span><span style="color:#bf616a;">end </span><span>}}
</span></code></pre>
<p>This shortcode checks that a <code>url</code> argument has been given, then loops through all the RSS feed items returned by the
given URL and displays them on a card with some padding and a link back to the original content, limiting the total
number of items rendered using the <code>limit</code> argument.</p>
<p>This shortcode can then be called in the Markdown file for the main page of the website to render the content:</p>
<pre data-lang="jinja2" style="background-color:#2b303b;color:#c0c5ce;" class="language-jinja2 "><code class="language-jinja2" data-lang="jinja2"><span>// Remove the spaces between the curly and angle braces
</span><span>{{ <</span><span style="color:#bf616a;">notado url</span><span>="</span><span style="color:#a3be8c;">https://notado.app/rss/jado/software-development/feed.rss</span><span>" </span><span style="color:#bf616a;">limit</span><span>="</span><span style="color:#a3be8c;">3</span><span>"> }}
</span></code></pre>
<p>At this point, it's possible to embed content from my highlights feeds and this is already a big win! The final step
that
remains is to have this update automatically; not just every time I push a new commit to the website repository.</p>
<p>My website is currently hosted on Cloudflare Pages, and Cloudflare provides an example of how to use a mixture of
JavaScript Workers and Cron Triggers
to <a href="https://developers.cloudflare.com/pages/platform/api/#triggering-a-new-build-every-hour">rebuild and redeploy a Cloudflare Pages website every hour</a>.</p>
<pre data-lang="javascript" style="background-color:#2b303b;color:#c0c5ce;" class="language-javascript "><code class="language-javascript" data-lang="javascript"><span style="color:#b48ead;">const </span><span style="color:#bf616a;">endpoint </span><span>=
</span><span> "</span><span style="color:#a3be8c;">https://api.cloudflare.com/client/v4/accounts/{account_id}/pages/projects/{project_name}/deployments</span><span>";
</span><span>
</span><span style="color:#b48ead;">export default </span><span>{
</span><span> </span><span style="color:#b48ead;">async </span><span style="color:#8fa1b3;">scheduled</span><span>(</span><span style="color:#bf616a;">_</span><span>, </span><span style="color:#bf616a;">env</span><span>) {
</span><span> </span><span style="color:#b48ead;">const </span><span style="color:#bf616a;">init </span><span>= {
</span><span> method: "</span><span style="color:#a3be8c;">POST</span><span>",
</span><span> headers: {
</span><span> "</span><span style="color:#a3be8c;">Content-Type</span><span>": "</span><span style="color:#a3be8c;">application/json;charset=UTF-8</span><span>",
</span><span> Authorization: </span><span style="color:#bf616a;">env</span><span>.</span><span style="color:#bf616a;">API_TOKEN</span><span>,
</span><span> },
</span><span> };
</span><span>
</span><span> </span><span style="color:#b48ead;">await </span><span style="color:#8fa1b3;">fetch</span><span>(</span><span style="color:#bf616a;">endpoint</span><span>, </span><span style="color:#bf616a;">init</span><span>);
</span><span> },
</span><span>};
</span></code></pre>
<p>This feels kind of overkill for me. Fortunately, I have a big old Linux server running NixOS in one of
the <a href="https://hetzner.com">Hetzner</a> data centers, which I like to use to trigger external API calls at regular intervals.</p>
<p>This is how I can add another systemd timer to my NixOS configuration in order to trigger hourly deployments of my
website with the latest data:</p>
<pre data-lang="nix" style="background-color:#2b303b;color:#c0c5ce;" class="language-nix "><code class="language-nix" data-lang="nix"><span>{
</span><span> </span><span style="color:#d08770;">systemd</span><span>.</span><span style="color:#d08770;">services</span><span>."</span><span style="color:#a3be8c;">update-lgug2z-com</span><span>" = {
</span><span> </span><span style="color:#d08770;">startAt </span><span>= "</span><span style="color:#a3be8c;">hourly</span><span>";
</span><span> </span><span style="color:#d08770;">environment </span><span>= {
</span><span> </span><span style="color:#b48ead;">inherit </span><span>(</span><span style="color:#bf616a;">secrets</span><span>) </span><span style="color:#d08770;">CLOUDFLARE_API_TOKEN</span><span>;
</span><span> };
</span><span> </span><span style="color:#d08770;">serviceConfig </span><span>= {
</span><span> </span><span style="color:#d08770;">Type </span><span>= "</span><span style="color:#a3be8c;">oneshot</span><span>";
</span><span> </span><span style="color:#d08770;">DynamicUser </span><span>= </span><span style="color:#d08770;">true</span><span>;
</span><span> </span><span style="color:#d08770;">ExecStart </span><span>= ''
</span><span style="color:#a3be8c;"> </span><span style="font-style:italic;color:#ab7967;">${</span><span style="font-style:italic;color:#bf616a;">pkgs</span><span style="font-style:italic;color:#c0c5ce;">.</span><span style="font-style:italic;color:#bf616a;">httpie</span><span style="font-style:italic;color:#ab7967;">}</span><span style="color:#a3be8c;">/bin/http POST \
</span><span style="color:#a3be8c;"> https://api.cloudflare.com/client/v4/accounts/{account_id}/pages/projects/{project_name}/deployments \
</span><span style="color:#a3be8c;"> -A bearer -a </span><span style="color:#96b5b4;">''$</span><span style="color:#a3be8c;">{CLOUDFLARE_API_TOKEN}
</span><span style="color:#a3be8c;"> </span><span>'';
</span><span> };
</span><span> };
</span><span>}
</span></code></pre>
<p>That's pretty much it. If you landed on this article from a direct link, you can see how this looks when it's all put
together by <a href="/">navigating to the homepage</a>.</p>
Overcoming Mental Barriers to Fitness and AestheticsThu, 01 Dec 2022 00:00:00 +0000lgug2z
https://lgug2z.com/articles/overcoming-mental-barriers-to-fitness-and-aesthetics/
https://lgug2z.com/articles/overcoming-mental-barriers-to-fitness-and-aesthetics/
<figure class="left">
<img src="https://substackcdn.com/image/fetch/f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fbucketeer-e05bbc84-baa3-437e-9518-adb32be77984.s3.amazonaws.com%2Fpublic%2Fimages%2F24f349b7-15ad-4d3b-9292-1aad037676fc_896x512.jpeg"
/>
</figure>
<p>In my mid-twenties my body was in pretty rough shape. I was suffering from chronic pain and had terrible mobility to the
pain where my quality of life and mental state were being severely adversely impacted.</p>
<p>I was also terrified at the idea of entering a gym.</p>
<p>I worked with an <a href="https://harbornechiropractic.co.uk/dr-elizabeth-falco">excellent chiropractor</a> in Birmingham (UK) who
helped to provide significant pain relief and subsequently referred me on to a physical therapist. Through working with
the physical therapist I realised that even without entering a gym, I still had a lot of avenues available to me to
improve the state of my body.</p>
<p>I started on a journey, beginning with
the <a href="https://www.reddit.com/r/bodyweightfitness/wiki/kb/recommended_routine/?utm_source=reddit&utm_medium=usertext&utm_name=bodyweightfitness&utm_content=t5_2tf0a">bodyweightfitness subreddit's recommended routine</a>,
which built my confidence to the point where I was able to enter a gym, and then led me into the world of powerlifting.
I spent many years running bodyweight fitness and powerlifting routines, and while my body started to feel more
youthful, and I became accustomed to a largely chronic pain-free life again, there was something missing in my practice
that I did not want to acknowledge.</p>
<p>Aesthetics.</p>
<p>Having spoken to others who came of age in the 1990s, especially others who did not invest in their bodies in their
teenage years, I realised I'm not alone in having internalised the artificial "jock/nerd" dichotomy pushed so heavily by
pop culture and media at the time.</p>
<p>The impact of this on the journey of restoring my body was that I felt the need to link every kind of exercise with some
form of pseudo-intellectualism. Bodyweight fitness and powerlifting, could, with a little bit of mental gymnastics, be
characterised as the exercise practices of the "thinking man". Bodybuilding? Not so much (at least, in my mind at the
time).</p>
<p>Over the past two years, and over the past year in particular, I have reached a point of acceptance with the idea that
it's perfectly fine to want to be jacked and tan for the sake of being jacked and tan. No pseudo-intellectual
rationalisation required.</p>
<p>It feels good to look good.</p>
<p>I have learned and applied so much from content produced by <a href="https://www.tiktok.com/@jpgcoaching">J.P Gallardo</a>
and <a href="https://www.tiktok.com/@t_nutrition_fitness">Joel Twinem</a> over the past year and as a result I've seen
unprecedented growth, more growth than all my years of powerlifting and bodyweight training combined.</p>
<p>At the same time, I am spending significantly less time in the gym per training session, and I feel like my relationship
with physical exercise has become a lot more joyful now that I am no longer focused on improving my proficiency at
barbell squatting, barbell bench pressing and barbell deadlifting.</p>
<p>In fact, I have not programmed any barbell movements all year.</p>
<p>My big takeaways when optimising for muscle growth this year have been:</p>
<ul>
<li>You only need 2-3 working sets for around 8-10 reps going all the way to failure for any given exercise</li>
<li>The weight should be heavy enough that those last few reps should be 50/50 as to whether or not you'll be able to
complete the rep</li>
<li>It's fine to do controlled half-reps to finish out a set if you've already taken complete reps to failure</li>
<li>You won't need to (or be able to) do more than 4-5 exercises in a training session if you are truly taking each
exercise close to failure</li>
<li>You'll see significantly better growth limiting training sessions to an hour and consistently going to failure
compared to filling a multi-hour training session with junk volume</li>
<li>For most of us regular folks, training to failure is far more important than exercise selection; just hit each body
part with the requisite intensity, eat enough, sleep enough, and the results will come</li>
</ul>
Resurrecting My Old Website With Ancient NixOS PackagesMon, 28 Nov 2022 00:00:00 +0000lgug2z
https://lgug2z.com/articles/resurrecting-my-old-website-with-ancient-nixos-packages/
https://lgug2z.com/articles/resurrecting-my-old-website-with-ancient-nixos-packages/<p>The past few weeks I have been putting off updating <a href="https://jadeiqbal.com">my personal website</a>, which has sat
untouched since October 2019. I've done quite a lot of interesting work since 2019, so finally this past weekend, I
forced myself to sit down, clone the repository, and update it.</p>
<p>My personal website is nothing fancy; just a static website built with <a href="https://gohugo.io/">Hugo</a> and a community theme,
and hosted on S3. This should be simple, right?</p>
<p>Wrong.</p>
<p>After cloning the repository and trying to build the website, I'm told that I'm missing a theme.</p>
<p>For as long as I have used Hugo, the themes ecosystem has been dependent on using Git submodules. I'm not a huge fan of
using them, but it's fine.</p>
<p>My website uses the "Dimension" theme adapted by GitHub user @sethmacleod, which is hosted
at <code>https://github.com/sethmacleod/dimension.git</code> according to my <code>.gitmodules</code> file.</p>
<p>No big deal, I try to pull in the submodule. But wait, what's this? That repository doesn't exist anymore? That user has
deleted their entire GitHub account? Ok…</p>
<p>Let's take a look in the Wayback Machine. Luckily there
is <a href="https://web.archive.org/web/20180613061937/https://github.com/sethmacleod/dimension/">an archived snapshot of the page on GitHub from 13 Jun 2018</a>,
and I can see that even by that point it had 63 forks.</p>
<figure class="left">
<img src="https://substackcdn.com/image/fetch/f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fbucketeer-e05bbc84-baa3-437e-9518-adb32be77984.s3.amazonaws.com%2Fpublic%2Fimages%2Fa1c1787f-c33a-4656-aa19-1dd0175c4a0e_2248x1662.png"
/>
</figure>
<p>Phew. I start trawling through GitHub trying to find a fork and before long I find one I'm happy with. I update the
reference in my <code>.gitmodules</code> file and try again. This time I'm met with a small victory.</p>
<p>Wait. What if this person deletes their fork or their account too? Better fork it myself. Always fork every community
theme that you use.</p>
<p>Time to try building the website again. What's that? A whole bunch of Go functions were not found? <code>GetParam</code>? What?</p>
<p>I am already feeling mentally exhausted by what should have been a trivial task of adding a few links to a Markdown
file.</p>
<p>The last time I built this site with Hugo was in 2019, so maybe there have been some breaking changes in a major version
release since then? What's that, Hugo is still at v0.x.x? Of course it is...</p>
<p>Luckily, I had spent the previous Thursday migrating my old Arch WSL2 VM to a fancy new NixOS WSL2 VM. Time to show me
what you're made of, Nix!</p>
<p>My first stop
was <a href="https://lazamar.github.io/download-specific-package-version-with-nix/">this excellent post by Marcelo Lazaroni on installing old versions of Nix packages</a>,
which culminated in his creation of <a href="https://lazamar.co.uk/nix-versions/">Nix Package Versions</a>, where you can put in
the name of any package and see its
version history.</p>
<figure class="left">
<img src="https://substackcdn.com/image/fetch/f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fbucketeer-e05bbc84-baa3-437e-9518-adb32be77984.s3.amazonaws.com%2Fpublic%2Fimages%2F92e424d9-c3e4-450a-a25b-26b8fa52d8f2_2128x1662.png"
/>
</figure>
<p>Let's see, the last time I successfully built my website was in October 2019,
so <a href="https://github.com/gohugoio/hugo/releases/tag/v0.58.3">v0.58.3 of Hugo</a> which was released on
the 19th of September in 2019 seems like it could be a good candidate. It is pretty easy to find on Nix Package
Versions.</p>
<figure class="left">
<img src="https://substackcdn.com/image/fetch/f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fbucketeer-e05bbc84-baa3-437e-9518-adb32be77984.s3.amazonaws.com%2Fpublic%2Fimages%2Fe6afa202-df17-49b1-8ea3-0fc3ab7cbc5e_2128x1662.png"
/>
</figure>
<p>Now all that's left is to make a <code>shell.nix</code> for my website repository and make sure I install that specific version of
Hugo.</p>
<pre data-lang="nix" style="background-color:#2b303b;color:#c0c5ce;" class="language-nix "><code class="language-nix" data-lang="nix"><span style="color:#8fa1b3;">{</span><span>pkgs ? </span><span style="color:#96b5b4;">import </span><span>(</span><span style="color:#bf616a;">fetchTarball </span><span>"</span><span style="color:#a3be8c;">https://github.com/NixOS/nixpkgs/archive/b5b7bd6ebba2a165e33726b570d7ab35177cf951.tar.gz</span><span>") {}</span><span style="color:#8fa1b3;">}</span><span>:
</span><span style="color:#b48ead;">with </span><span style="color:#bf616a;">pkgs</span><span>;
</span><span> </span><span style="color:#bf616a;">mkShell </span><span>{
</span><span> </span><span style="color:#d08770;">name </span><span>= "</span><span style="color:#a3be8c;">jadeiqbal.com</span><span>";
</span><span>
</span><span> </span><span style="color:#d08770;">buildInputs </span><span>= [
</span><span> </span><span style="color:#bf616a;">hugo </span><span style="color:#65737e;"># 0.58.3 from 2019-11-11
</span><span> </span><span style="color:#bf616a;">terraform
</span><span> ];
</span><span> }
</span></code></pre>
<p>Beautiful. Let's enter the shell and try building again with this ancient version of Hugo… Success!</p>
<figure class="left">
<img src="https://substackcdn.com/image/fetch/w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fbucketeer-e05bbc84-baa3-437e-9518-adb32be77984.s3.amazonaws.com%2Fpublic%2Fimages%2F70799fe1-bd5b-477e-b098-c5475704819d_2128x1662.png"
/>
</figure>
<p>Finally, I can update my website to include links
to <a href="https://notado.app">Notado</a>, <a href="https://kulli.sh">Kullish</a>, <a href="https://github.com/LGUG2Z/komorebi">komorebi</a>, and of
course <a href="https://notado.app/feeds/jado">all the RSS feeds that I now curate</a>.</p>
<p>My three main takeaways from this experience are:</p>
<ul>
<li>Fork everything that you depend on for any public-facing website</li>
<li>If your static site generator decides to release breaking changes, and you are an end-user (and not a theme developer), just pin the binary to the last known working version and forget about it</li>
<li>Every project I work on needs a <code>shell.nix</code></li>
</ul>
One Day on Mastodon vs One Year on TwitterTue, 15 Nov 2022 00:00:00 +0000lgug2z
https://lgug2z.com/articles/one-day-on-mastodon-vs-one-year-on-twitter/
https://lgug2z.com/articles/one-day-on-mastodon-vs-one-year-on-twitter/<p>While watching the implosion of Twitter in real-time over the past week, I noticed more and more people in my Twitter
feed sharing links to their Mastodon accounts.</p>
<p>During that time, https://instances.social was posted to Hacker News as a tool to help people find a Mastodon instance to
register with. My initial experience with this was quite poor.</p>
<p>Later in the week, a thread was started in the Rust subreddit
titled <a href="https://old.reddit.com/r/rust/comments/yrdbbt/rustaceans_worth_following_on_mastodon/">"Rustaceans worth following on Mastodon?</a>"
and saw that quite a lot of people were registered on the Hachyderm Mastodon instance. After spending so long playing
with the filters on https://instances.social to try to find an instance and coming away empty-handed, I decided to just bite the
bullet and <a href="https://hachyderm.io/@LGUG2Z">register an account on Hachyderm</a>.</p>
<p>As I'm writing this post, Hachyderm has around 5000 users, and let me tell you, this feels like a really sweet spot.</p>
<p>Coming from a service like Twitter, it may be tempting to want to register on the biggest or most popular Mastodon
instance to approximate the experience of Twitter (which is after all, a single instance of something where everyone is
registered), but in practice I think most people will find this quite frustrating as it is difficult to become part of a
community or build a network on a supermassive instance.</p>
<p>Before rejoining Twitter last year, I previously had an account and a respectable following in the late 2000s and early
2010s, and I had managed to build an excellent network of people who had shared interests in Afghanistan, the Middle
East and South Asia.</p>
<p>It was around this time that I joined <a href="/articles/coming-to-terms-with-having-been-in-a-cult/">the cult</a>.</p>
<p>Limiting outside contact and systematically reducing the potential and opportunity to build and nurture meaningful
connections outside the group are now commonly understood and widely accepted traits of cults and high-control
groups.</p>
<p>Without going into too much detail, the cult has a dedicated "communications" department that is, among other things,
responsible for policing the individual use of social media, and fosters a culture where members are "gently encouraged"
to police each others' use of social media.</p>
<p>Somebody still on the inside reached out to me last week asking to reconnect on Instagram; they couldn't remember my
Instagram username because, in their own words, <em>"I temporarily deleted all my social media after getting a warning over
my [social media service] account"</em>.</p>
<p>It may sound a little extreme, but this is actually not very uncommon at all. In fact, similar circumstances led me to
delete my own previously established Twitter account. It is only over the past year, many many years after leaving the
cult and rebuilding my life, that I have started to become comfortable with maintaining a social media presence again.</p>
<p>Can you imagine the breadth of the loss, the number of severed connections, snuffed friendships, failed relationships,
the increasing depth of isolation that builds every time somebody is gaslit and pressured into scorching the earth and
deleting every single digital avenue to the outside world while they live in isolated physical environments?</p>
<p>And so it was against this background that I rejoined Twitter last year. It was unfortunately nothing like that service
that I remembered from over a decade ago.</p>
<p>One of the most frustrating parts of my experience on Twitter over the past year was realising just how stacked the
entire platform is against young accounts.</p>
<p>It felt like posting into a black hole, which in turn made it seem improbable, if not completely impossible, to start to
rebuild any kind of community or network on the platform.</p>
<p>Luke Chadwick, who has ~1500 followers on Twitter, also remarked on this aspect of the Twitter experience.</p>
<figure class="left">
<img src="https://substackcdn.com/image/fetch/f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fbucketeer-e05bbc84-baa3-437e-9518-adb32be77984.s3.amazonaws.com%2Fpublic%2Fimages%2Fb6fc6760-794a-4793-85b3-2505dce5b0a6_1304x699.png"
/>
</figure>
<p>After also becoming <a href="https://www.tiktok.com/@jeez_y">an active TikTok user</a> over the past year, this experience was
doubly jarring. On TikTok, I post incredibly niche videos about Afghan culture, music, literature, history and language,
and without any serious effort on my part I have managed to amass thousands of regular viewers who interact with my
videos.</p>
<p>I won't go into the specifics of what I think makes the discoverability experience of TikTok so great here, but if
you're interested, a lot of my thoughts are developed on top of commentary by others that I curate and publish on my RSS
feed
about <a href="https://notado.app/feeds/jado/social-media">social media platforms, user generated content and recommendation algorithms</a>.</p>
<p>Let's just take a look at the numbers.</p>
<p>After a year on Twitter I have 18 followers, it's incredibly rare for my posts to get more than 10 impressions and my
attempts at making or joining conversations on shared interests and topics are rarely fruitful.</p>
<p>After a day on Mastodon, I have 30+ followers, and based on the number of interactions with my posts, way more than 10
impressions, but most importantly for me, my attempts at making or joining conversations on shared interests and topics
have been very fruitful.</p>
<p>I'm not sure how long this will last or how much it has to do with the nature of being a relatively early registrant on
the Hachyderm instance, but it is a huge breath of fresh air, and I think from now on I will just maintain my Twitter
account in life support mode due to the inertia of the platform.</p>
Finding Interesting Comments, Discussions and Communities OnlineThu, 10 Nov 2022 00:00:00 +0000lgug2z
https://lgug2z.com/articles/finding-interesting-comments-discussions-and-communities-online/
https://lgug2z.com/articles/finding-interesting-comments-discussions-and-communities-online/<p>User comments and discussions on the internet have a pretty bad rep these days, to the point where many people have just
opted out of online commentary completely, going as far as using a combination of DNS filtering and browser extensions
to remove them entirely from their browsing experience.</p>
<p>I, on the other hand, have spent the last couple of years refining ways to separate signal from noise in the cacophony
of online user commentary, as I have come to believe that the most valuable knowledge I acquire on a daily basis comes
from commentary and discussion rather than publishing.</p>
<p>My first stop was to figure out a rough list of sources where discussions overlapped with my interests with the help of
Google and word of mouth. I ended up with:</p>
<ul>
<li>Reddit</li>
<li>Hacker News</li>
<li>Lobsters</li>
<li>Tildes</li>
</ul>
<p>Hacker News, Lobsters and Tildes are all generally known for their tight moderation, with the latter two communities
being invite-only (I'm not a member of either), but Reddit as most of us know is kind of messy. You might even be
tempted to stop reading here at the mention of Reddit.</p>
<p>It turns out however that there are in fact a number of tightly moderated popular communities on Reddit with a very high
quality bar for contributions which I was able to find just by Googling around. Some of my favourites include
<a href="https://old.reddit.com/r/AskHistorians">AskHistorians</a>, <a href="https://old.reddit.com/r/AskScience">AskScience</a>
and <a href="https://old.reddit.com/r/AskSocialScience">AskSocialScience</a> (the whole network is great, really).</p>
<p>My next stop, some time later, involved using a combination of
the <a href="https://anvaka.github.io/map-of-reddit">Map of Reddit</a> and
the <a href="https://anvaka.github.io/redsim/">Related Subreddit Finder</a> by <a href="https://www.patreon.com/anvaka">anvaka</a>. By this
time I had found and subscribed to a number of smaller communities, and it was possible for me to plug them into these
tools to find similar communities.</p>
<p>
<figure class="left">
<img src="https://substackcdn.com/image/fetch/f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fbucketeer-e05bbc84-baa3-437e-9518-adb32be77984.s3.amazonaws.com%2Fpublic%2Fimages%2F567dddc2-d2c9-4e85-be82-45596fd05fd3_1812x1720.png"
/>
</figure>
)</p>
<p>Above is an example of the map area returned when querying for the <a href="https://old.reddit.com/r/gzcl">GZCL subreddit</a>,
which is focused on the experiences of users running a number of strength training programmes that build on
the <a href="https://www.amazon.com/GZCL-Method-Cody-Lefever-ebook/dp/B00GLX42U4">GZCL Method</a>.</p>
<p>(I ran a number of those programmes with great success and ended up finally being able to pull a 4-plate deadlift. The
comments, discussions, reports and feedback on that subreddit remain today in my humble opinion an invaluable resource
for anyone with even a passing interest in strength straining)</p>
<p>At this point, I still felt like there was more interesting, quality commentary waiting to be discovered, but at the
same time, I was starting to worry that I was limiting myself to an echo chamber on certain topics.</p>
<p>This led me to my next and (currently) final stop, <a href="https://kulli.sh">Kullish</a>, which searches through a number of link
aggregation and discussion websites (including Reddit) for a URL before providing a single feed of comments from
everywhere.</p>
<p>I often stumble across newer, growing subreddits this way when wanting to get an overview of comments on both niche and
lightning-rod topics. While I don't read every single comment or discussion aggregated here, it's an incredibly useful
way for me to take a look outside my bubble every now and then.</p>
<p>For the most part, whenever I come across an interesting link, I like to run it through Kullish and take a look at the
sources to see if I can find any new, focused, tightly moderated communities in the list of sources.</p>
<figure class="left">
<img src="https://substackcdn.com/image/fetch/f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fbucketeer-e05bbc84-baa3-437e-9518-adb32be77984.s3.amazonaws.com%2Fpublic%2Fimages%2F7f796edc-b7aa-4c73-8c42-3bb11a205463_1680x200.png"
/>
<figcaption class="center"
>
An example of the variety of commentary aggregated on lightning-rod topics
</figcaption>
</figure>
<p>I am quite content these days with how far I've come in separating signal from noise in my own consumption of online
comments and discussions; I have a steady feeds of high-quality content across a wide breadth of both my more popular
and more niche interests, and coupled with the "read it now or read it never" mindset I have developed over the years,
my overall content consumption experience is significantly more relaxed and stress-free.</p>
<p>In an attempt to try and share the fruits of my experiences, this year I have started to curate public RSS feeds of
high-quality commentary on topics that I am interested in. After all, if the rich and the famous can have people to
curate online comments for them, why can't we do something similar for ourselves?</p>
<p>I initially became interested in sharing public feeds of curated online commentary after reading about how
Linus (<a href="https://twitter.com/thesephist">@thesephist</a>) created <a href="https://thesephist.com/posts/lucerne/">Lucerne</a>, which is
a beautiful project that I highly recommend reading about.</p>
<p>Unfortunately not everyone is in a position to create and maintain such a project, but RSS feeds are still pretty
accessible with a fairly low barrier to entry, both on the production and the consumption side.</p>
<p>I know some people try to use Twitter for this general purpose, but honestly I find the experience very poor both as a
producer and as a consumer, as the interesting content too often gets drowned out with low-quality, low-effort, snarky
meta-commentary, and there is no way for most of us to filter by a combination of both topic and user.</p>
<p>Below are the commentary feeds that I am currently curating. If you are also interested in curating feeds of
high-quality commentary on similar or other topics, share them with me in the comments- I would love to check them out!</p>
<ul>
<li><a href="https://notado.app/feeds/jado/software-development">Software development</a> (What makes good and bad software
development experiences)</li>
<li><a href="https://notado.app/feeds/jado/social-media">Social media</a> (The state of different social media services, user
generated content and recommendation algorithms)</li>
<li><a href="https://notado.app/feeds/jado/addiction">Addiction</a> (Living with, overcoming and life after addiction)</li>
<li><a href="https://notado.app/feeds/jado/mental-health">Mental health</a> (Factors impacting our mental health in the modern age)</li>
<li><a href="https://notado.app/feeds/jado/capitalism">Capitalism</a> (How capitalism intersects with work, art, technology, urban
planning, life and love)</li>
</ul>
Making Meaningful Kindle Highlights for FictionSun, 06 Nov 2022 00:00:00 +0000lgug2z
https://lgug2z.com/articles/making-meaningful-kindle-highlights-for-fiction/
https://lgug2z.com/articles/making-meaningful-kindle-highlights-for-fiction/<p>If you find, as I do, the act of physically marking books indescribably (in the literal sense) disrespectful (to the
physical book), you probably felt great the first time you realised that PDFs could be digitally annotated. Equally so
when Amazon's Kindle brought eBooks into the mainstream.</p>
<p>Unfortunately the story of highlights on Kindle has been one of some frustration and disappointment for me, in large
part due to the restrictions placed on liberating highlights from the Amazon ecosystem, and the UX of highlighting on an
e-ink Kindle, which remains to this today clunky, slow and always just a little too imprecise.</p>
<p>At some point Amazon introduced "popular annotations", and it got me thinking about how we highlight fiction. The
majority of the popular annotations that I see when reading fiction can essentially be boiled down to witty one-liners,
or characters paraphrasing a heuristic, social or political view from our world.</p>
<p>I personally don't find these kinds of highlights useful for fiction, especially for fiction series that span multiple
books. In fact, and especially when reading a series that spans multiple books (that may well not have all been written
yet), I always find myself wishing for a synopsis that captures all the main world-building beats I have encountered
so far but may just need a little refresher on.</p>
<p>If you've ever tried reacquainting yourself with a fictional world before starting the next entry in a series you'll
know how much it sucks it accidentally read a spoiler on a fan wiki.</p>
<p>I recently started reading the <a href="https://www.adapalmer.com/series/terra-ignota/">Terra Ignota series by Ada Palmer</a>, and
with this series I am trying a new approach; using my Kindle highlights to create a feed of key, spoiler-free,
world-building and story beats.</p>
<p>Interestingly, I have found myself referring back to these highlights often as I am reading, as primary source material
to reference while my mind works overtime, sucked into this exquisitely crafted universe, racing to piece together the
hidden mysteries sprinkled throughout the texts with the highlighted clues that only start to show their importance as
more players, alliances and motivations are revealed.</p>
<p>The entire end-to-end process is fairly simple:</p>
<ul>
<li>I read a few chapters on my e-ink Kindle and make mental notes of what I think are important passages that help in
understanding the world of the novel and the motivations of its characters</li>
<li>A little later (after a break doing something else) I'll switch to the Kindle application on my laptop, go through
those chapters I read earlier and highlight the passages that I was thinking about — the highlighting experience on a
laptop vs on a Kindle is really incomparable; trying to highlight on a Kindle is such a frustrating experience that it
takes me out of my mental "reading zone"</li>
<li>I import the latest highlights to my <a href="https://notado.app">Notado</a> account, and they get automatically added to a novel
or series-specific feed, which I subscribe to via RSS on my phone to have a quick reference at hand while I'm reading</li>
</ul>
<p>That's pretty much it. If you struggle thinking of what to highlight or how to make meaningful highlights when reading
fiction, I encourage you to give this approach a try and share your experiences with me.</p>
<p>If you want a real-world example of what this approach looks like, you can check out
my <a href="https://notado.app/feeds/jado/terra-ignota">Terra Ignota highlights feed</a> (spoiler-free).</p>
Coming to Terms with Having Been in a CultFri, 04 Nov 2022 00:00:00 +0000lgug2z
https://lgug2z.com/articles/coming-to-terms-with-having-been-in-a-cult/
https://lgug2z.com/articles/coming-to-terms-with-having-been-in-a-cult/<p>I don't think I'm alone in having consumed more documentaries on cults between the years of 2020 and 2022 than I had
ever before in my entire life. There were many great documentaries, but the one that sticks out in my mind even today is
<a href="https://en.wikipedia.org/wiki/The_Vow_(TV_series)">The Vow: A NXIVM Story</a>.</p>
<p>Every time I watch a scene in that or any other documentary about cults in which people are reunited for the first time
outside the cult, every muscle in my body feels the reunion that is happening on the screen.</p>
<p>Every year or so I will receive a message from a former colleague informing me with a palpable sense of relief that they
"got out" (and that I was so lucky to have gotten out when I did).</p>
<p>These are some of the most emotionally draining conversations that I have as an adult, and I'm certain that I will
continue to have them for many years to come as more and more people leave.</p>
<p>I was 20 when I was first recruited by the International Committee of the Red Cross (ICRC). I don't like to talk much
about this time in my life. In fact, the only time that I will really talk about this time in my life is when I get
those messages every other year from people who have just gotten out.</p>
<p>Most people naturally assume that this is down to "the things I saw" when I was sent out on missions in active conflict
zones.</p>
<p>For the most part, I'm happy to let them assume this, because the trauma and the shame of having willingly stayed inside
a cult for so long is just so difficult to talk about.</p>
<p>Even now, I feel the churning in the pit of my stomach as I write this, as I consider what I am capable of writing, of
sharing with people who haven't experienced life inside this or any other cult.</p>
<p>Can I speak about the rampant culture of bullying and hazing that I endured during the first three months of my first
assignment? The premeditated course of extreme social isolation that dictated where, when and what I was "permitted" to
eat? Whose presence I was "permitted" to be in?</p>
<p>Can I speak about the physical tolls that the stress of life within the cult took on my body? The horror of looking in
the mirror to find half of my face paralyzed?</p>
<p>Can I speak about the experience of being repeatedly cornered, threatened, menaced and ostracized over 3 years for
having made the "mistake" in one of my first days on a mission of asking about a rumor concerning a colleague recently
being attacked and severely injured in retaliation for allegedly sexually abusing minors?</p>
<p>As it turns out, I guess I'm still not capable of speaking about my experiences at length or in any meaningful detail
except with others who have gotten out. Not with my spouse. Not with my therapist. Not with my family. Only with others
who managed to get out; we know that it's a cult, our bodies know that it's a cult.</p>
<p><strong>If you have gotten out</strong>: Please reach out to someone. It doesn't matter if it was yesterday or years ago. There is a
loosely disorganized web of us. You don't have to explain the context to us. You don't have to justify anything to us.
We get it. We were there. Maybe not on the same mission, maybe not in the same delegation, sub-delegation or office, but
we were there. You are not alone.</p>
<p><strong>If you are still inside and know you need to get out</strong>: You can do it. I believe in you. Everybody who has ever gotten out
believes in you. Please reach out; there is a good chance that somebody in the loosely disorganized web will be able to
help you. You are not alone.</p>
<p><strong>If you are young and are being scouted</strong>: I know that it seems like the opportunity of a lifetime, and the prospect of
doing "meaningful humanitarian work" seems so alluring, but it's just not worth it. It's not a coincidence that a former
colleague who would scout at universities would jokingly refer to themselves as "the child catcher".</p>
Love and LanguageTue, 01 Nov 2022 00:00:00 +0000lgug2z
https://lgug2z.com/articles/love-and-language/
https://lgug2z.com/articles/love-and-language/<blockquote>
<p>I am the only deaf person in my family. I'm 24 years old and none of them learned ASL or only took one course. They
hardly know the alphabet and can say "bathroom".</p>
<p>I do not go to my family gatherings because no one is able to communicate with me. When they do, they talk REALLY loud
and only ask me "small talk" questions. They are hell bent on me coming around, because "we're a family and we love
you".</p>
<p>I always felt obligated to go and was always drained afterwards due to the amount of concentration.</p>
<p>Communication is my love language and you do not know mine.</p>
<p>...</p>
<p>Learning a new language for someone shows how much you love and care for that person. Just learn.</p>
</blockquote>
<p>You can watch the full video
by <a href="https://www.tiktok.com/@mjdodane">@mjdodane</a> <a href="https://www.tiktok.com/@mjdodane/video/7127661094495276334?_r=1&_t=8X0Rgd2eQ39&is_from_webapp=v1&item_id=7127661094495276334">here</a>.</p>
<p>This video definitely struck a chord with me. It neatly encapsulates a lot of the feelings that I have had around this
topic for many years now.</p>
<p>If your partner, your husband, your wife, does not learn to speak your language, and does not make the effort to speak
it with you, they might love you, but they love you much less than you deserve to be loved.</p>
<p>Most people have their own ideas about what it means to "settle" for somebody in a relationship. This is mine.</p>
<p>My grandparents and parents were immigrants to the United Kingdom. I have watched member after member of my immediate
and extended family across two generations marry out and raise children with people who were not willing to learn their
languages. The sense of loss is palpable and overwhelming.</p>
<p>I am now a first-generation immigrant to the United States. My spouse is also a first-generation immigrant with two
languages of their own. I speak them both pretty well these days, and I'm always looking for ways to improve. Every day,
I ask questions, make mistakes and mishear things that end in rib-aching laughter and beautiful memories. There are
often times when I'll be asked something in English and respond in one of those two languages which have captured,
alongside this beautiful person, my heart.</p>
<p>My own relationship to the languages that I have inherited feels very tumultuous to me, but that doesn't really matter
to my spouse, who has studied them, speaks them freely, fills the house with the sound of their poems and musical
legacies. Hearing those languages spoken to me with love and care breathes new life into old words and fills the cracks
left by multiple generations of exile, diaspora and rupture.</p>
The Pleasure of Accidental CommunitiesFri, 28 Oct 2022 00:00:00 +0000lgug2z
https://lgug2z.com/articles/the-pleasure-of-accidental-communities/
https://lgug2z.com/articles/the-pleasure-of-accidental-communities/<p>It was during the shutdowns of 2020 that I became increasingly frustrated with my Intel MacBook pro. It was slow, loud
and hot; all the things that many people, myself included, dislike about laptops.</p>
<p>I decided to build a PC. After a month or so spent scouring the internet for parts, I had assembled a PC. I ran Pop!_OS
for a while, but this being my first desktop machine in over a decade, I spent a fair about of time dual booting into
Windows to play all the video games that I missed out on as a macOS user.</p>
<p>I was exposed to WSL2, which I came to find was quite a pleasant development environment. My only issue was that I could
not find a tiling window manager for Windows that would bring me close to the productivity that I had with yabai on
macOS.</p>
<p>I decided to build my own.</p>
<p>My first attempt was called <a href="https://github.com/LGUG2Z/yatta">yatta</a>. If you look at the code, I think it's pretty
obvious that this was my first time working with the <a href="https://learn.microsoft.com/en-us/windows/win32/api/">Win32 API</a>
and that this project was largely an exercise of learning
in public.</p>
<p>After I had well and truly coded myself into a corner, surrounded by insurmountable walls comprised of short-sighted
design decisions, I started work on the next iteration of this tiling window manager.</p>
<p>That next iteration was called <a href="https://github.com/LGUG2Z/komorebi">komorebi</a> (named by my wife). Today, komorebi is the
most starred actively developed
tiling window manager for Windows on GitHub, and boasts a pleasant, welcoming community of a couple hundred people on
GitHub and Discord.</p>
<p>I don't quite remember the birth of <a href="https://discord.gg/mGkn66PHkx">komorebi's Discord server</a>; I think someone asked if
there was a server on a GitHub issue and I just made one on the spot, thinking that not many people would join. Before I
knew it, there were over 100 people in the server.</p>
<p>I interact with a lot of people on the GitHub project and the Discord server now. I am amazed at what this accidental
community has been able to achieve.</p>
<ul>
<li>A <a href="https://github.com/LGUG2Z/komorebi/wiki/README-zh">Chinese translation</a> of the project README</li>
<li>The <a href="https://github.com/ScoopInstaller/Extras/pull/7840">addition of the project to the Scoop package manager</a></li>
<li>The <a href="https://github.com/LGUG2Z/komorebi/issues/152">addition of the project to the Winget package manager</a></li>
<li>The <a href="https://github.com/LGUG2Z/komorebi-application-specific-configuration">maintenance of the most comprehensive list of "apps behaving badly" on Windows</a> (and fixes for them!) that I have ever seen</li>
<li>Lots of shared configurations and community-driven troubleshooting</li>
<li>An <a href="https://github.com/denBot/yasb">incredible status bar project for Windows</a></li>
<li>Some super niche collaboration that led to <a href="https://github.com/LGUG2Z/komokana">yet another tool that I can't work without today</a></li>
</ul>
<p>A while ago I realised that it was time to specify some rules for the community on the Discord server (not because of
any specific concerning behaviour, just due to the growth that the server was seeing).</p>
<p>Dear reader, if you ever find yourself in this situation, I encourage you to take a look at
the <a href="https://www.rust-lang.org/policies/code-of-conduct">Rust code of conduct</a> as
an excellent starting point. The komorebi Discord server uses a slightly modified version of this code of conduct and I
believe it is a good fit for chat servers centered around open-source software projects.</p>
<p>Until now, there has only been one instance where I have had to "enforce" the code of conduct on the server. A new user
had joined whose username was not exactly PG. I privately messaged this new user:</p>
<blockquote>
<p>Hello friend, welcome to the Komorebi server!</p>
<p>As with everyone else you are most welcome in the server, but I must ask you to pick a different server-specific
display name for our server, because your current display name conflicts with the server's code of
conduct: https://discord.com/channels/898554690126630914/898554690608967782/1002271373760139374</p>
<p>You can do this by right-clicking on the Komorebi server, clicking "Edit Server Profile" and changing the "nickname"
field on the popup</p>
</blockquote>
<p>That was pretty much it. The user adopted a different server-specific nickname, and life went on.</p>
<p>For many years, I had lamented the death of the online communities that I grew up with as a teenager. The IRC chat rooms
based around Quake 2 and Quake 3: Rocket Arena servers, CounterStrike beta and Team Fortress servers (do you remember
the days before Steam, dear reader?), the phpBB forums centered around my favourite bands, with subforums that discussed
anything and everything, and the Soulseek chat rooms where I first discovered Japanese indie rock.</p>
<p>This new accidental community that has built up around komorebi gives me such pleasant, warm, nostalgic feelings. It is
such a beautiful part of my life today as an adult, and one that I am incredibly grateful for.</p>
<p>If you have lived a life in active addiction as I have, and if you don't know what to do with all the time, energy and
creativity returned to you by the gift of sobriety as I didn't, try building something that you never would have thought
yourself possible of building before. Put it out there for other people to enjoy.</p>
<p>I don't think I would have been able to take this step myself without having first been exposed
to <a href="https://twitter.com/awesomekling">Andreas Kling</a>'s work on <a href="https://serenityos.org/">SerenityOS</a> and the beautiful
community that emerged around it.</p>
<p>Perhaps, dear reader, you too are an addict who is feeling a little overwhelmed by the gift of sobriety. I hope this
encourages you, too, to reassess the things your addict told you you were incapable of, and to take a step towards
building and sharing something beautiful with the world.</p>
Six Months of PowerliftingSun, 19 Nov 2017 11:11:11 +0100lgug2z
https://lgug2z.com/articles/six-months-of-powerlifting/
https://lgug2z.com/articles/six-months-of-powerlifting/<p>Since my last post back in June, I have been steadily eating at a surplus while focusing my training
on powerlifting. During this period I have run three different programmes:</p>
<ul>
<li><a href="https://www.reddit.com/r/Fitness/comments/37ylk5/a_linear_progression_based_ppl_program_for/">Metallicadpa's Push/Pull/Legs
Split</a></li>
<li><a href="https:/www.reddit.com/r/nsuns">nSuns 531 Linear Progression</a></li>
<li><a href="https://www.reddit.com/r/gzcl">GZCL Linear Progression</a></li>
</ul>
<h2 id="metallicapda-s-ppl"><a class="zola-anchor" href="#metallicapda-s-ppl" aria-label="Anchor link for: metallicapda-s-ppl">Metallicapda's PPL</a></h2>
<p>While I was cutting I was running a full body calisthenics-focused programme three-to-four days a
week, but I had been wanting to try a split programme which would allow me to train with more
frequency throughout the week. The idea that was stuck in my head was that more volume throughout
the week would result in more progress at a faster rate, and given that I would be largely starting
from scratch again with the squat, the bench press and the deadlift, this seemed like a good way to
get in a lot of practice of those movements.</p>
<p>I ran the PPL for eight weeks, training six days per week, starting all of the compound lifts with a
20kg bar and working my way up. I am not sure I would have had the patience or would have been able
to swallow my pride enough to do this had I not been working out alone with a power rack set up at
home.</p>
<p>This programme was my first introduction to
<a href="https://www.strongerbyscience.com/weekly-load-progression/">AMRAP</a> (as many reps as possible) sets.
Looking back, this is a very good way to regulate intensity for new lifters who are still getting
comfortable with the big three compound movements. AMRAP sets at lower weights provide both the
opportunity to practice the movements with good form and speed up the technique refinement process,
and also help to get a new lifter in the habit of not continuing past form failure.</p>
<h2 id="nsuns-531-linear-progression"><a class="zola-anchor" href="#nsuns-531-linear-progression" aria-label="Anchor link for: nsuns-531-linear-progression">nSuns 531 Linear Progression</a></h2>
<p>Towards the end of July I was feeling like I needed to put more work into the compound movements and
started exploring other split programmes that I could run. I eventually settled on the five-day
variation of nSuns 531LP, which I ran for twelve weeks.</p>
<p>The nSuns 531LP, when compared to the previous PPL, is quite spartan; the only programming provided
is for the primary compound lifts and their close variations, while accessories are left to the
discretion of the individual. The other big change was the sharp increase in volume; nine sets for
the primary compound movements followed by eight sets of compound movement variations.</p>
<p>Where the PPL was my first introduction to AMRAP sets, nSuns 531LP was my first introduction to the
training max as something distinct from a one-rep max (1RM), and doing sets where weights are
determined as a percentage of a given TM. This meant that during a training session I would be doing
sets that ranged from 65% to 95% of my TM, allowing for more exposure to weights where I could focus
on refining my technique with higher reps while still working on increasing my numbers for each
movement.</p>
<p>I added 22.5 kg to my bench press, 25 kg to my deadlift and 20 kg to my squat during my twelve weeks
running this programme. The progress that I was making also meant that I started lifting past my
initial TM and started increasing my hypothetical TM on a weekly basis as I continued to progress.
At some point however, it started to feel like my TM and my 1RM were uncomfortably close together,
to the point where every week I felt like I was testing my 1RM rather than building strength.</p>
<p>This came to a head when I moved to Italy for work last month and no longer had access to my own
power rack at home. It was no longer practical for me to train five days a week before work, and
even less so in a commercial Italian gym with very few squat racks or power racks.</p>
<h2 id="gzcl-linear-progression"><a class="zola-anchor" href="#gzcl-linear-progression" aria-label="Anchor link for: gzcl-linear-progression">GZCL Linear Progression</a></h2>
<p>By this point I knew that I wanted to run a programme that integrated AMRAP sets and placed an equal
amount of importance on programming for weight progression and programming for technique refinement.
Additionally, I felt that the time was right to move back to a full body programme that would allow
me to train four days a week with rest days in between.</p>
<p>I had been reading about the <a href="https://swoleateveryheight.blogspot.it/2014/07/the-gzcl-method-simplified_13.html">GZCL
method</a> and its
various popular implementations since July and I had known for a while that when I transitioned from
nSuns 531LP, it would most likely be to some form of GZCL programme. There are two popular
full-body GZCL routines out there right now, UHF and GZCLP, both of which were outlined in the
<a href="https://swoleateveryheight.blogspot.it/2016/02/gzcl-applications-adaptations.html">Applications and
Adaptations</a> post
on Cody's blog.</p>
<p>I must admit that I struggled to fully comprehend the knowledge that Cody was laying down (and from
the comments I've read on Reddit, I am not the only one), but after coming across an excellent
<a href="https://imgur.com/WIhiBOy">infographic</a> and its <a href="https://www.reddit.com/r/Fitness/comments/6pjiwd/heres_a_quick_summary_of_the_gzclp_linear/">accompanying
post</a>
explaining GZCLP, I was convinced to continue my training with this programme.</p>
<p>Whereas the majority of my second-tier movements when running nSuns 531LP were variations on
compound movements (sumo deadlift, front squat, close-grip bench press), with GZCLP I am, for now at
least, keeping my first tier and second tier movements the same. When I am working with weights that
are 95%+ of my TM my form can start to get pretty ugly, so I think that extra time spent working on
refining those primary compound movements will be a good investment in time.</p>
<p>With every programme that I have run I have been exposed to a new element of programming for
powerlifting and GZCLP is no different. GZCLP has been my first introduction to programming
progressions for first tier and second tier movements independently. This is very different from the
tightly coupled progression between first tier and second tier movements on nSuns 531LP, where I
found myself stalling regularly with second tier front squats because the progression was directly
linked to the progress that I was making on my first tier squats and deadlifts.</p>
<h2 id="2018-plans"><a class="zola-anchor" href="#2018-plans" aria-label="Anchor link for: 2018-plans">2018 Plans</a></h2>
<p>For now I am very happy with the progress I am making on GZCLP and I can see myself running this
programme into the first quarter of 2018. I plan on gradually bringing some of the variation
compound movements that I was doing on nSuns 531LP back into the mix by the end of January and
reassessing my choice of third tier accessories around the same time.</p>
<p>I would like to complete in a powerlifting meet next year, so I will soon start reading more about
peaking programmes and thinking about what weight class I would like to compete in. Before that
however, I will be stopping my bulk at the end of the year and dropping ~5kg in the first months of
2018 before transitioning to eating at maintenance for most of the Spring.</p>
Transitioning from Deficit to SurplusTue, 20 Jun 2017 08:49:42 +0100lgug2z
https://lgug2z.com/articles/transitioning-from-deficit-to-surplus/
https://lgug2z.com/articles/transitioning-from-deficit-to-surplus/<p>Some time around early May I decided to change up my eating habits. Since January 2016 I have been working on cutting body fat, conquering my appetite and improving my poor eating habits. By May I felt I had reached a point where the returns I was getting from eating at a deficit were diminishing; my stomach was flat, my abs were visible, and my relationship with food had substantially changed for the better.</p>
<p>For the last month and a half I've been trying to eat at a daily caloric surplus of around 500 kcal while increasing the volume of my workouts. The end goal is to gain lean mass which will in turn help me to graduate to more difficult progressions of the exercises that I do as well as helping me to build strength by doing some of those exercises with increasingly heavier weights.</p>
<p>I had not fully anticipated just how tricky the transition from eating at a deficit to eating at a surplus would be. There was initially and to some extent remains a sense of unease at deliberately eating above my TDEE after having spent over a year religiously trying to eat below it. Before I lost weight I was unhappy with how I looked, I suffered from chronic back pain and was fundamentally unable to distinguish hunger from boredom, and strong associations between those things and a high level of food intake still exist in my mind.</p>
<p>Eating at a surplus has led me to gradually modify the ways in which I track my progress. Previously I kept a daily and weekly log of my caloric intake and assigned myself a binary pass/fail mark based on whether or not my total intake for the week was under a set amount of calories, however I quickly found that this does not translate over so well to a bulk where the goal intake is more of a range to be within rather than a static number to be below.</p>
<p>For this reason I have stopped updating <a href="https://everybodywantstorulethe.world/2017/03/tracking-and-magnification.html">the table that I previously wrote about</a> and now use the same calorie intake and weight data to keep an updated graph of my daily and average weekly and monthly weight vs. my daily and average weekly and monthly food intake. Given that there is a limit to the proportion of lean body mass a person can add in a certain time frame, checking this graph regularly helps me regulate my caloric intake on a weekly or sometimes bi-weekly basis, making sure that I minimise fat gain as much as reasonably possible, while still making sure that I'm eating enough to get the most from my added workout volume.</p>
<p>Despite the difficulties in making the mental shift from eating at a deficit to eating at a surplus, on the whole the experience has been very liberating, much in the same way as was the experience when beginning to track and restrict my caloric intake. Learning to reduce my intake and eat at a deficit played a huge role in re-framing my relationship with food and shifting the balance of power and control. I am now learning that it is equally as important to have that same confidence in my eating habits when also eating at maintenance and at a surplus.</p>
Reading Without ScrollingWed, 03 May 2017 14:06:38 +0100lgug2z
https://lgug2z.com/articles/reading-without-scrolling/
https://lgug2z.com/articles/reading-without-scrolling/<p>After switching from an iPhone to an Android device at the end of last year, my <a href="https://everybodywantstorulethe.world/2017/02/content-consumption.html">content consumption workflow changed significantly</a>. I stopped reading articles on my phone due to the poor experience provided by the Android version of the <a href="https://www.instapaper.com/">Instapaper</a> App, and I started checking my RSS feeds once a day on my laptop rather than on my phone.</p>
<p>This new approach to getting through my feeds helped me to remove a lot of the noise from subscriptions, and also got me thinking about whether certain topics really required a feed subscription when I was usually being exposed to the latest on those topics either via word of mouth or just through browsing the front page of Reddit. As a result, it's no longer necessary for me to check <a href="http://reederapp.com/">Reeder</a> every day, and I am currently able to stay on top of my subscriptions by going through them a couple of times a week.</p>
<p>I had been trying to set aside time each morning to make a dent in my Instapaper backlog, and while this worked for a while, the amount of time that I am able to devote to this in the mornings before leaving the house has been steadily decreasing. I am increasingly dealing with more complex topics in my morning study of Italian and Japanese, and similarly I am moving on to more physically demanding progressions of exercises in my morning workouts. Both of these factors are gradually demanding more of my time in the mornings, to the point where I have been feeling that my attempts at working through my Instapaper backlog need to be reassessed.</p>
<p>Some years ago I tried using a Kindle as the main medium from which to consume articles I saved to Instapaper. For a number of reasons the experience didn't quite click with me and I stopped sending articles to my Kindle quite quickly. Until very recently, I hadn't really given much thought to the idea of reading articles on my Kindle.</p>
<p>Every morning I have around fifteen minutes of time while I wait for and travel on the train to work, which upon reflection I decided that I was not really utilising very effectively. I started using this time to read fiction, and I made short work of rereading novels set in the <a href="https://en.wikipedia.org/wiki/Ghost_in_the_Shell:_Stand_Alone_Complex">Ghost in the Shell: Stand Alone Complex</a> universe by <a href="https://www.goodreads.com/author/show/20309.Junichi_Fujisaku">Junichi Fujisaku</a>.</p>
<p>I was surprised at just how much reading I was getting done while waiting for and travelling on trains, though I really shouldn't have been given how much I spend writing and talking about the power and compounding returns of incremental learning and improvement strategies. After finishing the last novel, I started thinking of using that same time to make up for the time I was losing at home in reading my saved articles.</p>
<p>Usually this would have been done on my mobile phone or a tablet, but between the poor Instapaper experience on Android and my wife having permanently taken possession of my iPad, the only other reading device at my disposal was a Kindle. Setting up Instapaper to regularly send a selection of articles to my Kindle was painless process and in a matter of minutes I had a selection of articles from my backlog ready for me on my Kindle, complete with a table of contents to navigate between articles, and links to archive and like articles once I had finished reading them.</p>
<p>I have been reading Instapaper articles on my Kindle for about a week now and I have two immediate observations:</p>
<ul>
<li>Not having to actively select something out my Instapaper backlog to read takes a huge weight off of my shoulders and makes the whole experience more pleasant and less of a chore</li>
<li>My relationship with articles has been deeply coloured by the ubiquity of scrolling on both mobile platforms and browsers</li>
</ul>
<p>This second point was quite surprising to me. When reading on a screen, whether it is a laptop screen, a phone screen or a tablet screen, the role of scrolling is central to the reading experience. It is so fundamental that I often scroll to scan around without thinking, even when I am mid-sentence. I'm willing to bet that my poor impulse control and scrolling habits have a significantly detrimental effect on the total time required for me to finish reading an article or set of articles.</p>
<p>E-ink readers such as the Kindle on the other hand can feel painfully slow when you are trying to scan through large swathes of text separated by multiple page turns. In embracing this slower pace and constraint on the ability to scan around, I have found my attention has been much more focused and attuned to the nuances of the articles that I am reading, and I have realised just how rare my moments of mindful reading of articles are these days.</p>
<p>I believe that this change has been key in marking a mental shift whereby reading saved articles has gone from being an obligation to an act of leisure, and has also revitalised my latent desire to invest time in reading longform writing.</p>
Progressing in ItalianFri, 07 Apr 2017 20:23:22 +0100lgug2z
https://lgug2z.com/articles/progressing-in-italian/
https://lgug2z.com/articles/progressing-in-italian/<h1 id="background"><a class="zola-anchor" href="#background" aria-label="Anchor link for: background">Background</a></h1>
<p>Just over a month ago I decided to start learning Italian. I have previously studied French and Spanish and can speak both languages fairly confidently (though French perhaps more so than Spanish), so I began studying Italian with an idea of what works for me when it comes to the study of Romance languages.</p>
<p><a href="https://everybodywantstorulethe.world/2017/02/italian.html">I had previously written</a> of my desire to compare my experience learning Italian in 2017 to my experiences learning other languages, particularly Romance languages, in previous years when I was still trying to consolidate something resembling a coherent language learning strategy.</p>
<p>What follows is a collection of reflections on my experiences progressing in Italian, ranging from my impressions of resources and materials both familiar and new, to the application of learnings from other domains to language acquisition.</p>
<h1 id="michel-thomas-italian"><a class="zola-anchor" href="#michel-thomas-italian" aria-label="Anchor link for: michel-thomas-italian">Michel Thomas Italian</a></h1>
<p>It was during my study of French that I was first exposed to the <a href="https://www.amazon.co.uk/Michel-Thomas/e/B002PXPMNK/ref=dp_byline_cont_book_1">Michel Thomas</a> audio courses, and also, critically, to the idea that I could learn to recognise patterns and structures in a language solely through listening and responding verbally to drills. I have said on many occasions and continue to say today that the Michel Thomas French Foundation and Advanced courses are the best language learning resources I have ever used.</p>
<p>Given my overwhelmingly positive experiences with the Michel Thomas courses for French and Spanish, I decided to begin my study of Italian with the Michel Thomas Italian Foundation and Advanced courses. The Italian courses more than lived up to the expectations set by my past experiences with the French and Spanish courses and gave me the ability to recognise and construct sentences using every verb tense used in the spoken language.</p>
<p>With these courses I took the approach of listening to them twice a day, not for any specific minimum period of time, in the morning while on the way to work, and in the evening before going to sleep. I made quick work of the Foundation course, finishing it in just over a week, but I found the Advanced course to be more challenging than I anticipated which resulted in me spreading the four discs out over almost three weeks.</p>
<h1 id="actions-and-events-in-the-past"><a class="zola-anchor" href="#actions-and-events-in-the-past" aria-label="Anchor link for: actions-and-events-in-the-past">Actions and Events in the Past</a></h1>
<p>As with the Michel Thomas French courses, the Italian courses do not spend more than a few minutes discussing the <em>passato remoto</em>, often called <em>the simple past tense</em> in English, the common use of which is largely literary or dialectical. Interestingly enough, I had in my head for many years the idea that Italian, like Spanish and unlike French, made liberal use of the simple past tense.</p>
<p>Truth be told, when considering on previous occasions whether or not I should study Italian, this was a key factor every time I decided not to go down that road. This may seem like an odd reason to not learn a language, but it is easy to get a little spoilt after having had the experience of learning and speaking French.</p>
<p>In French, talking about completed actions that occurred at any point in the past requires you to know just two verbs inside out: <em>to have</em> and <em>to be</em>. The latter of these is used specifically when dealing with verbs of motion and reflexive verbs. In practice, this means that regardless of what the action is and who initiated it, you only ever have to modify either the verb <em>to have</em> or the verb <em>to be</em> when constructing a sentence about a completed action that occurred at some point in the past.</p>
<p>Consider the following example sentences with the verb <em>to read</em>:</p>
<table><thead><tr><th>English</th><th>French</th><th>Spanish</th></tr></thead><tbody>
<tr><td>I read a book</td><td>J'ai <strong>lu</strong> un livre</td><td><strong>Leí</strong> un libro</td></tr>
<tr><td>You read a book</td><td>Tu as <strong>lu</strong> un livre</td><td><strong>Leíste</strong> un libro</td></tr>
<tr><td>We have read a book</td><td>Nous avons <strong>lu</strong> un livre</td><td>Hemos <strong>leído</strong> un libro</td></tr>
<tr><td>They had read a book</td><td>Ils avaient <strong>lu</strong> un livre</td><td>Habían <strong>leído</strong> un libro</td></tr>
</tbody></table>
<p>With the French sentences, the form that the action of reading takes at any time in the past, <em>lu</em>, is unchanging. On the other hand, the Spanish sentences always require a different form for the action of reading taking place in the simple past, and these forms change depending on who is performing the action.</p>
<p>Needless to say, the latter case results in greater relative complexity for a language learner at the introductory stages and presents a higher barrier of entry to reaching the stage where one can confidently talk about completed events and actions at multiple points in the past.</p>
<h1 id="assimil-l-italien"><a class="zola-anchor" href="#assimil-l-italien" aria-label="Anchor link for: assimil-l-italien">Assimil L'Italien</a></h1>
<p>After finishing the Michel Thomas Advanced French course, my next port of call was <a href="https://www.assimil.com/">Assimil</a>'s <a href="https://www.amazon.co.uk/French-Ease-Assimil-Method-Books/dp/2700520130/">New French with Ease</a>, which I think it is fair to say is something of a modern classic when it comes to French learning materials. There are a few different approaches to the Assimil courses ranging from the <a href="http://languagegeek.net/2010/05/12/how-to-use-an-assimil-course/">official approach</a> to various personal approaches popularised online. Mine is as follows:</p>
<ul>
<li>Listen to the recording a few times until I don't feel totally lost
<ul>
<li>At this point I just aiming to get the gist of the dialogue, not to understand everything</li>
</ul>
</li>
<li>Listen to the recording while looking at the text</li>
<li>Listen to the recording while reading the text, pausing after each sentence to repeat it aloud</li>
<li>Listen to the recording without looking at the text, pausing after each sentence to transcribe it, reading each word aloud while writing</li>
<li>Listening to the recording without reading the text, pausing after each sentence to repeat it aloud</li>
<li>Read through the lesson notes</li>
<li>Listen to the translation exercises recordings without looking at the text, pausing after each sentence to transcribe it, reaching each word aloud while writing</li>
<li>Mentally translate the translation exercise sentences</li>
<li>Mentally identify the missing words in the fill the blanks exercise</li>
</ul>
<p>Key to note is that I don't follow the idea of doing passive and active phases as per the official approach.</p>
<p>In one of my previous deliberations on whether or not to learn Italian, I went as far as buying Assimil's <a href="https://www.amazon.co.uk/Assimil-Italian-Faux-Debutants-Anne-Marie-2014-01-01/dp/B01K93429W/">L'Italien</a> course from 2004. The language of instruction for this book is French, and the content itself I consider to be simpler, for lack of better word, relative to the <a href="https://www.amazon.co.uk/Italian-Assimil-Language-Learning-Programs/dp/B01182D4C4/">Italian with Ease</a> course from the 1990s. My choice to use L'Italien essentially came down to two factors; that I wanted to use the book that I had previously purchased, and I thought that it would be a good opportunity to read in French.</p>
<p>I am currently twenty-four lessons into the book, which consists of one hundred lessons, and on the whole I am pleased with the course and the pace at which complexity is being introduced. I had an expectation that given the simpler nature of the dialogues in this edition of the course may also have impacted the usefulness of the notes provided for each lesson, however I have been very impressed the way in which the notes of each lesson explain the nuances of the demonstrated sentence construction patterns.</p>
<p>Diverging slightly from the path I took while learning French, I started working through L'Italien while still working through the Michel Thomas Advanced Italian course. I did this in part to maximise the total time I had available to dedicate to Italian study, which I felt I was not utilising well as I became less able to listen to the Advanced course for longer periods of time</p>
<h1 id="glossika-fluency-123-italian"><a class="zola-anchor" href="#glossika-fluency-123-italian" aria-label="Anchor link for: glossika-fluency-123-italian">Glossika Fluency--123 Italian</a></h1>
<p>The past few years have seen me quite disconnected from the various online language learning communities that I used to be active in, and as a result it is only just now that I am learning about <a href="https://glossika.com/">Glossika</a>. A few months ago <a href="https://www.alexstrick.com/">Alex</a> sent me a link to an <a href="https://github.com/xiang/glossika2anki">open source Ruby script</a> for converting Glossika PDFs to <a href="https://apps.ankiweb.net/">Anki</a> decks and a few lines outlining what Glossika courses provide, and while it seemed interesting at the time, I was still in the period where <a href="https://everybodywantstorulethe.world/2017/03/approaching-logographic-writing-systems.html">I was burnt out from struggling with reading progressions in Japanese</a> and as such I didn't give it any further thought.</p>
<p>After finishing the Michel Thomas course I was looking for both another Italian resource and specifically another Italian drilling resource. Now, as much as I love drills, I am terrible at doing them consistently if they are non-verbal. This is where Glossika comes in. Like Assimil, it seems that there are as many approaches to using Glossika as there are people. In the case of Glossika, <a href="https://glossika.com/howto/">there are two canonical approaches outlined on the Glossika website</a>, which are essentially a more passive spaced repetition approach and a more active mass sentences approach.</p>
<p>Given that I first heard of Glossika in relation to Anki, I considered dumping each of the individual sentences into Anki along with the audio and translations. My initial thought was that Anki would allow me more flexibility in randomising my reviews and pruning out early sentences that were 'too simple', but after asking and reading around a little I decided against this.</p>
<p>Anki is an excellent tool which I have used to fill the gaps resulting from a dearth of both general learning materials at the introductory level and more subject-specific materials in the intermediate and advanced levels for less commonly taught languages such as Dari and Pashto. Italian on the other hand has a great wealth of both generic and specialised materials to draw from, including Glossika, and I feel that the sort of time that would be invested in curating an Anki deck, never-ending task in itself, would ultimately be better spent doing activities that improve my intuitive command of the language.</p>
<p>I have settled on using the Glossika Spaced Repetition (GSR) audio files, which are essentially the sentences spoken in English and Italian, separated by a short pause, over and over again, in a randomised order scheduled by a spaced repetition algorithm. The pause after the English sentence is just long enough to say the Italian sentence aloud yourself before it is spoken on the recording if you can say it confidently and without hesitation. I have therefore taken an approach to the GSR audio files which is more active than the recommended usage and has some crossover with the recommended fourth step for users of the Glossika Mass Sentences (GMS) intensive study approach:</p>
<p><em>Try to recall the target sentence in the gap after you hear it in English. Try to say it out loud, and pause if necessary.</em></p>
<p>When hearing a new sentence, I listen to both the English and the Italian readings, then pause and repeat the Italian sentence until I am comfortable with it. I then begin trying to say it aloud independently and without hesitation in Italian directly after the English translation is spoken.</p>
<p>A key point of divergence here is that I try to do this without pausing the recording, fitting my repetition aloud within the recorded pause on the track, as doing so provides me with a consistent level of feedback on my progress towards being able to produce the sentences without hesitation.</p>
<h1 id="embrace-the-boring"><a class="zola-anchor" href="#embrace-the-boring" aria-label="Anchor link for: embrace-the-boring">Embrace the Boring</a></h1>
<p>Glossika is very much the type of language learning resource I didn't know that I had always been waiting for. I have seen some comments criticising Glossika and the template used for its sentences for being 'boring'. I, on the other hand, welcome the boring nature of the Glossika materials. I would probably go as far as to say that the majority of effective adult language learning, well into the mid-intermediate levels, is repeating activities that will get boring and monotonous very quickly.</p>
<p>This doesn't just apply to language learning either-- Want to eat better? Make your weekly food intake explicit, easily available and repeatable. Looking to build muscle? Pick a lifting programme and stick with it instead of doing a different bunch of random exercises every time you go to the gym. <a href="https://gearandgrit.com/fitness-really-is-simple-the-cure-to-fuckarounditis/">Fuckarounditis</a> is real and it is just as applicable in study as it is in exercise.</p>
<p>Having the discipline to continue doing those activities regardless of how motivated you feel at any given time is a key factor in making any lasting and non-trivial progress. I have found that the predictability of the Glossika materials is a great aid in creating solid and sustainable routines to ensure I that I am putting in the grunt work that is ultimately necessary to be able to express my thoughts confidently in Italian.</p>
<h1 id="trust-the-process"><a class="zola-anchor" href="#trust-the-process" aria-label="Anchor link for: trust-the-process">Trust the Process</a></h1>
<p><em>Trust the process</em> is one of those sayings that I have often heard repeated and nodded my head along to positively, but struggled to put into practice. As I initially became more proficient in learning languages I started to believe that I knew more than enough to identify <em>exactly</em> what I needed from whatever resources I got my hands on. Naturally I acted upon that belief, blinded as I was by my own hubris and in the processes missed out on much of the added value that those materials had to offer. That probably seems a little vague, so let's take a concrete example:</p>
<p>When I was learning Spanish I was convinced that I had no use for putting any significant amount of time into becoming familiar with the formal second personal singular forms of <em>usted</em> or the peninsular second person plural forms of <em>vosotros</em>, and as such I put myself at a disadvantage by disregarding materials which I thought spent too much time dwelling on these two areas. Similarly, when going through the Michel Thomas Spanish Foundation course I somewhat stubbornly refused to respond to the drills aloud using any <em>usted</em> forms, which in hindsight hindered the overall effectiveness of the course because of the increased cognitive load required to make those implicit changes from <em>usted</em> to <em>tú</em> all the time.</p>
<p>Since those days learning Spanish over half a decade ago, physical fitness has come to hold an increasingly central role in my life. During my attempts to lose fat and build strength I have been repeatedly humbled , and I have learnt to keep in check the voice in my head that tells me that I know better when I start to get bored when working on foundations and fundamentals.</p>
<p>The learnings from those experiences have at some point along the way started to become increasingly domain-independent, and can now be seen in my approach to studying Italian. A nice way to illustrate this is by revisiting the previous example from my experience learning Spanish and contrasting it with my current experience learning Italian:</p>
<p>Much like Spanish, Italian also has a both formal second person singular forms with <em>Lei</em>, and familiar second person singular forms with <em>tu</em>, and again much like the Michel Thomas Spanish Foundation course, the Italian Foundation course focuses on drills using the formal <em>Lei</em> forms. When going through the Foundation course, I had no problem trusting the process and completing the drills as expected with the <em>Lei</em> forms and I spent practically no time at all throughout the course worrying about which parts of it I thought were and were not relevant.</p>
<h1 id="set-the-right-pace"><a class="zola-anchor" href="#set-the-right-pace" aria-label="Anchor link for: set-the-right-pace">Set the Right Pace</a></h1>
<p>When starting with any new activity, sensible pacing plays a critical role in achieving long-term success. If the pace you choose is too fast, you run the risk of burning out quickly, and if the pace is too slow, you run the risk of increasing the pace out of a mixture of boredom, frustration and ego, to a pace that will be unsustainable as the difficulty curve ramps up. This too is likely result in you eventually burning out. I have typically been prone to the latter of those scenarios in my previous language learning experiences.</p>
<p>Going from having the level of grammatical knowledge imparted by having completed a Michel Thomas Advanced course to an Assimil course can initially feel like a significant step backwards and not a particularly efficient use of time. Faced with this feeling, I have been known to fall into the following pattern:</p>
<ul>
<li>I disregard the received wisdom of doing one lesson a day and begin doing two or three per sitting</li>
<li><em>This is easy!</em> I think to myself, patting myself on the back for being oh-so-clever</li>
<li>After the first two weeks, when the difficulty picks up and I find myself unable keep pace</li>
<li>The implicit expectation of either maintaining or increasing velocity then gradually wears me down</li>
</ul>
<p>Thankfully I have not fallen into this pattern in my current study of Italian and I am enjoying progressing at a leisurely pace that I feel increasingly confident I will be able to maintain as the difficulty curve steepens.</p>
<p>Gaining an appreciation of the importance of pace was also key in leading me to reassess my approach to and expectations of velocity variation and incrementalism in general, which I touched on briefly when writing about <a href="https://everybodywantstorulethe.world/2017/02/non-zero-days.html">restarting my Japanese studies earlier this year after having burnt out at the end of last year</a>.</p>
<h1 id="poor-form-and-humility"><a class="zola-anchor" href="#poor-form-and-humility" aria-label="Anchor link for: poor-form-and-humility">Poor Form and Humility</a></h1>
<p>I will conclude with a brief vignette.</p>
<p>It is the year 2015 and it has finally sunk in that cardio is a terrible way to go about losing fat. I have also begun to understand that you can't outrun a bad diet, and I say <em>outrun</em> specifically because I've yet to realise that this applies equally to any and every other form of exercise. I have been reading about the <a href="https://stronglifts.com">StrongLifts 5x5</a> programme (SL5x5) and I have decided that it is the programme for me and that I will start lifting. I buy a pricey power rack so that I can do big lifts at home and not have to worry about failing a rep.</p>
<p>SL5x5 is based around progressively adding weight to combinations of compound lifts that you do in sets with a low number of reps, which helps to minimise plateaus in the short-to-medium term. The programme recommends starting to work on every lift with just the bar as the baseline and progressively adding weight from that baseline, regardless of starting strength level.</p>
<p>I of course decide that this very sensible recommendation doesn't apply to me and I start performing the lifts with additional weight on the bar from the very beginning. While I make some good initial progress, at a certain point I end up hurting my back as a result of not being able to maintain proper form when I begin doing the lifts with more significant weight. This leads to a period of suffering from chronic back pain, both from trying to lift heavy with poor form and the aggravation of an older back injury. This period also sees me balloon to my all-time highest weight and descend to my all-time lowest activity levels.</p>
<p>The painful lesson I took away from this experience is one that has since been transferred from its original domain and applied to my efforts in language learning, and I have no doubt that it is one which will continue to serve me in many other domains in the decades to come.</p>
Approaching Logographic Writing SystemsMon, 27 Mar 2017 12:12:05 +0100lgug2z
https://lgug2z.com/articles/approaching-logographic-writing-systems/
https://lgug2z.com/articles/approaching-logographic-writing-systems/<p>When I started learning languages in my late teenage years, I began with French and Persian. French, like most European languages, uses an <a href="https://en.wikipedia.org/wiki/Alphabet">alphabetic</a> writing system, whereas Persian, like many languages in the Middle East uses an abjad writing system. I later also learned north Indian languages which use <a href="https://en.wikipedia.org/wiki/Abugida">abugida</a> writing systems, and last year I began learning Japanese which makes use of both <a href="https://en.wikipedia.org/wiki/Syllabary">syllabic</a> and <a href="https://en.wikipedia.org/wiki/Logogram">logographic</a> writing systems.</p>
<p>If you're reading this article you'll be familiar with <em>alphabetic</em> writing systems, where individual symbols generally represent both vowels and consonants. With <em>abjad</em> writing systems, each symbol generally represents a consonant and the responsibility of determining the correct vowels between those consonants falls with the reader, though modern abjad writing systems such as the Arabic and Hebrew alphabets have optional diacritic marks or symbols that can represent some vowels.</p>
<p><em>Abugida</em> writing systems represent consonant and vowel combinations as a unit, usually with the consonant being the anchor symbol and the specific vowel notation being appended to it in some way. In contrast to abugidas, the symbols in <em>syallabic</em> writing systems cannot be split into discrete consonants and vowels, but instead have different symbols for specific consonant-vowel combinations.</p>
<p>The importance of reading in language acquisition is something that <a href="https://www.alexstrick.com/blog/?tag=languages#show-archive">Alex Strick</a> and others have emphasised for some time now, and it has definitely been reflected in my own language learning experiences. When I began learning Japanese last year, after getting a handle on the basics of Japanese grammar with the <a href="http://www.michelthomas.com/learn-japanese.php">Michel Thomas audio courses</a>, I set aside some time to learn the <a href="https://en.wikipedia.org/wiki/Hiragana">Hiragana</a> and <a href="https://en.wikipedia.org/wiki/Katakana">Katakana</a> syllabaries, and steadily began to increase my time spent reading Japanese.</p>
<p>Written Japanese typically consists of a mixture of the two syllabaries, the occasional word written in the Latin alphabet, and words or parts of words written using logograms. Logograms in Japanese are called <a href="https://en.wikipedia.org/wiki/Kanji">Kanji</a>, and they are essentially logographic characters that have been adopted from written Chinese throughout history. The primary difference between logographic symbols and symbols in other writing systems is that logographic symbols represent concepts, not sounds.</p>
<p>There are many introductory resources where Japanese readings are given using only the Hiragana and Katakana syllabaries, and those are the resources that I initially started reading with. Once I was comfortable reading Japanese texts written in Hiragana and Katakana characters, I started working my way through the first volume of the famous <a href="http://genki.japantimes.co.jp/index_en">Genki</a> textbooks. For a while things were going well, with various Kanji characters being introduced as lessons progressed with syllabic readings given alongside them, but very quickly the lack of breadth in my knowledge of Kanji characters became a bottleneck to my progression through the textbook.</p>
<p>I had never been faced with a bottleneck like this before with any of the languages I had previously studied. Exposure to reading had served me incredibly well when learning languages which used any other writing system, by reinforcing my command of the symbols in the case of abjads and abugidas, or reinforcing my understanding of orthographic conventions in languages using alphabets, as well increasing my familiarity with idiomatic sentence structures and expanding my vocabulary.</p>
<p>At first I tried going through resources with a specific focus on learning Kanji characters such as the <a href="https://en.wikipedia.org/wiki/Remembering_the_Kanji_and_Remembering_the_Hanzi">Remembering the Kanji series</a>, but I found that the disconnect between the order in which characters were introduced in these courses and the order in which characters were introduced in readings geared towards those yet to reach an intermediate level was having an adverse effect on both the rate of my overall language acquisition and also my level of motivation.</p>
<p>Soon thereafter I came across <a href="https://www.wanikani.com">WaniKani</a>, an online Kanji learning platform, where the focus is primarily on learning to recognise the characters rather than learning how to write them as well. I began to integrate multiple daily sessions on WaniKani into my Japanese learning schedule, but towards the end of 2016, both due to the of the lack of progress in bypassing the Kanji bottleneck and increasing work-related constraints on my free time, I burnt out and set Japanese aside for a number of months until <a href="https://everybodywantstorulethe.world/2017/02/non-zero-days.html">recently picking it back up again in February</a>.</p>
<p>The main lesson that I have taken away from last year's experience with Japanese is that I need to reach a certain breadth of Kanji knowledge before I will be able to progress beyond the point at which I burnt out. With this lesson in mind, I have decided to put the entirety of my current Japanese study efforts into increasing my character recognition abilities before continuing on with Genki or indeed any other Japanese textbook.</p>
<p>I have continued using WaniKani since clearing my review backlog and now I encounter between three to five new characters a day. I also have enough of a grammatical base in the language to be able to look at isolated example sentences using those characters and understand them, and more crucially, recognise idiomatic usage of the characters that I'm learning in context.</p>
<p>I'm not sure at this point how much longer I'll spend focusing solely on Kanji. I will review a random selection from the Genki readings that I was previously struggling with every three months or so to try and gauge where I'm at and how far I have left to go, but I think that I could stand to benefit from setting specific benchmarks and goals which would provide a clearer indication of when I'm ready to return to the textbooks and start working on graded readings aimed at introducing new grammatical concepts again.</p>
Tracking and MagnificationSun, 26 Mar 2017 10:07:25 +0100lgug2z
https://lgug2z.com/articles/tracking-and-magnification/
https://lgug2z.com/articles/tracking-and-magnification/<p>At the beginning of 2016 I was overweight. My frame since childhood had been wiry, and through adolescence to young adulthood, finding myself a victim of pop culture's false dichotomy of intellectualism/athleticism, my activity levels decreased and my percentage of body fat rose. My activity levels as an undergraduate student were already low, but I was not prepared for just how physically static my life would become when I began working. In retrospect, being overweight at the start of 2016 seems like the natural conclusion to the decisions that I had been making over the previous decade.</p>
<p>From the start to the end of 2016, I managed to shed 20 kg of body fat. The formula for weight loss is pretty simple: expend more calories than you consume. Throughout last year I was following a largely <a href="https://www.reddit.com/r/keto/wiki/faq">ketogenic</a> diet, and I found that increasing the amount of fat that I consume in a given day increased my levels of satiety and resulted in me consuming less calories overall.</p>
<p>For the first few months I did not track my exact caloric intake, but the results clearly showed that I was consuming less calories than my total daily energy expenditure (<a href="https://www.iifym.com/tdee-calculator/">TDEE</a>). As my weight decreased and my basal metabolic rate (<a href="https://en.wikipedia.org/wiki/Basal_metabolic_rate">BMR</a>) adjusted, I eventually found that my caloric intake generally matched my TDEE. This sweet spot is known as maintenance, the caloric intake required to maintain current weight, but as I was not yet done with my fat loss, for me this represented a plateau.</p>
<p>Enter <a href="https://www.myfitnesspal.com">MyFitnessPal</a>. I used whichever the recommended calorie and macro calculator on the <a href="https://www.reddit.com/r/keto">keto subreddit</a> was at the time, entered the information into MFP and began (trying to) track my calories every day. By and large, the approach worked; I began losing weight again, and fast forwarding to the end the year, I had lost a total of 20 kg.</p>
<p>It is now 2017 and I am no longer overweight. I have a new job and a new career, though unfortunately I still spend the majority of my days sat behind a desk. I do the <a href="https://www.reddit.com/r/bodyweightfitness">/r/bodyweightfitness</a> <a href="https://www.youtube.com/watch?v=AB3HhP2GYk0">Recommended Routine</a> three times a week and I am working towards my goal of being able to hold an l-sit on gymnastics rings with good form by the end of the year. Despite the grey hairs and the gradually encroaching hair loss, I am in the best shape of my life both aesthetically and athletically. There are still no doubt years of improvements to be made, but the one that I am finding myself doubling down on after reducing overall weight is reducing my body fat percentage for better aesthetic effect.</p>
<p>People who have worked to lose a visually significant amount of weight will likely be familiar with a very specific scenario; being asked how they did it, explaining that it comes down to calories in vs calories out (CICO), having people disregard that basic law of the universe and then return to them to complain about their lack of progress.</p>
<p>Last week I read a particularly enjoyable article penned in response to such scenarios entitled <a href="http://physiqonomics.com/eating-too-much/"><em>You’re Not losing Fat Because You’re Eating Too Damn Much. Even When You Don’t Think You Are. Let Me Show You.</em></a> by Aadam Ali over at <a href="http://physiqonomics.com/">Physioqnomics</a>. Looking through his other articles, there were two that particularly resonated with me in the context of my current desire to reduce my percentage of body fat, and also in the context of the MFP calorie tracking that I am still keeping up with today. Those two articles were <a href="http://physiqonomics.com/the-art-of-the-fuck-up/"><em>The Art of the Fuck Up</em></a> and <a href="http://physiqonomics.com/magnifying-glass-effect/"><em>The Magnifying Glass Effect</em></a>.</p>
<p>My relationship with MFP has an intense focus on the day as the primary unit of time. I enter the food that I eat on a given day, I try to stay within the calorie limit for that day, and I either succeed or I don't. Due to the magnification of a relatively small unit of time (within the context of weight loss and fat reduction), individual bad days can easily be blown out of proportion, resulting in a destructive downward spiral that is difficult to break free from. I can think of far too many occasions over the past year where I've slipped up and gone over my daily calorie limit by ~200 kcal, only to then get lost in the spiral and continue deliberately overeating (without tracking!) out of the despair induced by that now insignificant 200 kcal surplus for up to a week before getting back on track.</p>
<p>As of this week I have decided to move to a format where I actively track my calories on a weekly as well as a daily basis, and having had a bit of time this weekend, I took last week's data from MFP, which included quite a few bad days, to get an idea of how it would look with this change in perspective.</p>
<table><thead><tr><th></th><th>Monday</th><th>Tuesday</th><th>Wednesday</th><th>Thursday</th><th>Friday</th><th>Saturday</th><th>Sunday</th><th>Total</th></tr></thead><tbody>
<tr><td><strong>Limit</strong></td><td>1584 kcal</td><td>1267 kcal</td><td>1584 kcal</td><td>1267 kcal</td><td>1584 kcal</td><td>1267 kcal</td><td>1267 kcal</td><td><strong>9821 kcal</strong></td></tr>
<tr><td><strong>Actual</strong></td><td>1185 kcal</td><td>1280 kcal</td><td>1265 kcal</td><td>1273 kcal</td><td>2297 kcal</td><td>1356 kcal</td><td>1150 kcal</td><td><strong>9806 kcal</strong></td></tr>
<tr><td></td><td>😀</td><td>🙁</td><td>😀</td><td>🙁</td><td>😞</td><td>🙁</td><td>😀</td><td>😀</td></tr>
</tbody></table>
<p>I work out on Mondays, Wednesdays and Fridays and set my limit at <strong>1584 kcal</strong>, which is roughly maintenance for my current weight. Other days are rest days so I set my limit to <strong>1267 kcal</strong>, which is 20% less than maintenance. Maintenance for the week would be at <strong>11,088 kcal</strong>, but based on the daily calorie limits I had set for the week, I was aiming for my weekly limit to be <strong>9821 kcal</strong> -- a deficit of <strong>1267 kcal</strong> overall.</p>
<p>I have never actually calculated calorie limits weekly before, so putting putting the data together like this was a new and interesting experience. From the table above it is clear that I had more bad days than good days last week, and Friday in particular was quite a bad day. Nevertheless, in reducing the level of magnification from the daily to the weekly, I can see that despite those bad days, I was able to stay under my weekly limit and ended up with an overall deficit of 12%.</p>
<p>For the time being I will continue using MFP for my daily tracking, and I will be using a Google Sheet to consolidate that information to get the weekly, and later, monthly view of my calorie consumption. With this new format, I will also start recalculating my maintenance calories based on my weight at the beginning of every week, and use this information to adjust my calorie limits for workout and rest days on a weekly basis to account for the impact that changes in my weight will be having on my TDEE.</p>
Abstract and Specific GoalsTue, 21 Mar 2017 07:53:47 +0000lgug2z
https://lgug2z.com/articles/abstract-and-specific-goals/
https://lgug2z.com/articles/abstract-and-specific-goals/<p>When I initially set a goal for myself, nine times out of ten it will be abstract and unmeasurable; not particularly useful in the long run, but enough to get me moving in the right direction.</p>
<p>My specific fitness goal for 2017 is to be able to hold an l-sit on gymnastics rings, and this goal is helping to shape my long-term approach to fitness throughout the year. On the other hand, I have not had any sort of equivalently defined goal when it comes to writing; it has essentially just been 'improve writing skills'.</p>
<p>I've previously considered whether writing posts regularly counts as 'enough' of a specific and measurable goal, and I've ultimately come down against the idea because it strikes me as much more of a process than it does than something uniquely identifiable as a goal.</p>
<p>Since my days as an undergraduate humanities student I have always wanted to try my hand at writing a book, but over the years, while I've had some ideas, I've haven't made the time, had the discipline or the drive to follow through.</p>
<p>The task of putting together an entire book remains as daunting as it was back then, but I have settled on a topic that I believe is worth the investment in time to explore in a book format, and so I am replacing my abstract goal of improving my writing skills this year with the specific goal of writing this book.</p>
Work and VacationFri, 17 Mar 2017 07:55:21 +0000lgug2z
https://lgug2z.com/articles/work-and-vacation/
https://lgug2z.com/articles/work-and-vacation/<p>I imagine that summer vacations from school comprised formative periods for many people across the world. Six weeks (or more) without broken education systems breathing down our necks, allowing us to discover all those things that require long, uninterrupted blocks of time to understand and appreciate.</p>
<p>At some point during the last five years I started reading the works of <a href="http://www.fooledbyrandomness.com/">Nassim Taleb</a>. I regularly find myself coming back to one of the more well-known quotes taken from one of his books:</p>
<blockquote>
<p>“The three most harmful addictions are heroin, carbohydrates, and a monthly salary.”</p>
</blockquote>
<p>For a long time when coming back to this quote my focus was on the second addiction, carbohydrates. Lately however, my focus has been shifting to the third addiction. It doesn't take a lot to find <a href="https://www.reddit.com/r/keto/comments/350efa/the_three_most_harmful_addictions_are_heroin/cqzy38j/">people who don't agree</a> with the categorisation of a monthly salary as a harmful addiction, and it is something that I myself was largely indifferent to until very recently because it seemed to have as little direct relevance to my life as the first addiction.</p>
<p>Over the past two years I've been working to create, refine and most importantly, follow routines in my life that are intended to ensure that I get an adequate amount of sustained and deliberate practice to allow me to reach the various goals that I have. Ultimately however, even the most finely-tuned routine has to be squeezed into either the hours before I sit down at my desk in the morning or the hours between leaving my desk and getting into bed in the evening.</p>
<p>This is not a situation that is conducive to being able to regularly achieve a state of <a href="http://calnewport.com/books/deep-work/">deep work</a> with regard to the things that I care about most and the goals that I want to achieve.</p>
<p>With all of the above in mind, I have decided to try resurrecting the spirit of the school summer vacation this year. I plan to take six to eight weeks of unpaid leave from my job this summer to focus both on the things that are important to me personally and the things that I believe will prevent stagnation professionally.</p>
BlockersThu, 16 Mar 2017 14:29:59 +0000lgug2z
https://lgug2z.com/articles/blockers/
https://lgug2z.com/articles/blockers/<p>I spent much of the first half of the this month plugging away at what was my first sizeable piece of technical writing, my previous post on <a href="https://everybodywantstorulethe.world/2017/03/hugo-on-aws-with-terraform.html">using Terraform to set up a new Hugo website on AWS</a>. It took much longer to write than I originally anticipated, and the longer the post remained unfinished, the harder it became to continue working on it.</p>
<p>While a piece of writing taking longer than originally anticipated or intended is not really a problem in and of itself, I found that the unfinished state of the post acted as a blocker preventing me from writing regularly, which in the context of my overall goal to improve my writing skills, is indeed a problem.</p>
<p>As a result of this experience, I have identified that I have a tendency to treat writing in a somewhat monolithic manner without appropriately considering the domain to which the subject matter I'm writing about belongs; writing a technical tutorial requires a different mindset to writing about content consumption workflows, for example.</p>
<p>This links well with another topic that has been on my mind lately: visual communication of technical ideas. I am atrociously underdeveloped in this area as I have recently been discovering, and I think that my approach to improving my technical communication skills could benefit from a more holistic approach which coordinates my efforts in improving my technical writing skills as well as my technical visualisation skills. The exact nature of this approach is still to be determined, but to have identified the need is already a good start.</p>
<p>I find the gaps in my ability communicate technical information frustrating primarily because in my previous career, I was very much at ease communicating domain-specific information and I was largely able to tailor my key messages to a wide range of interlocutors on the fly.</p>
<p>My experience changing careers has been a largely positive one, and I have been served well in the transition by the wealth of transferrable skills I have accumulated over the past decade, but if I am to thrive in this new environment I need to start shifting my attention from the areas where my previous experience has left me in an advantageous position to the areas where I don't have any relevant previous experience to draw from.</p>
Hugo on AWS with TerraformTue, 14 Mar 2017 12:23:53 +0000lgug2z
https://lgug2z.com/articles/hugo-on-aws-with-terraform/
https://lgug2z.com/articles/hugo-on-aws-with-terraform/<h1 id="background"><a class="zola-anchor" href="#background" aria-label="Anchor link for: background">Background</a></h1>
<p>Static generated blogs have seen a surge in popularity over the past few years, as a more cost effective and scalable alternative to traditional publishing platforms such as <a href="https://www.wordpress.com">WordPress</a>. A big reason contributing to why it took me so long to actually start writing online was uncertainty about the publishing medium I should use.</p>
<p><a href="https://ghost.org">Ghost</a> appealed to me for a while, but it felt like overkill for what I wanted to achieve. Most of the latter half of 2016 was spent diving into the ever-expanding <a href="https://facebook.github.io/react/">React</a> ecosystem, and it was during one of those dives that I was first exposed to the idea of static generated blogs by <a href="https://github.com/gatsbyjs/gatsby">Gatsby</a>, a static site generator which is built on the React component model.
I am definitely a proponent of React. I have used it extensively in both personal projects and professional projects that have provided my income stream for most of the past six months. However, it didn't take long for me to come to the conclusion that Gatsby too, was overkill for what I was looking to achieve.</p>
<p>I am nontheless thankful for the brief interlude I had with Gatsby, as it was during this interlude that I came across <a href="https://www.staticgen.com">StaticGen</a>, an incredibly useful website which lists and lets you compare almost all of the currently available open source static site generators available for use today. Over the course of a month I put a number of the various static site generators listsed through their paces before ultimately settling on <a href="https://gohugo.io">Hugo</a>.</p>
<p>The primary factor that led to my selection of Hugo over other static site generators was both the quantity and quality of user-written tutorials focused both on starting from scratch and on migrating from other static site generators as well as traditional publishing platforms.
This factor was especially important as I believe that the <a href="https://gohugo.io/overview/introduction/">documentation</a> for Hugo, while copious, could stand to be significantly improved as it at times feels impenetrable even if you have been using Hugo for a while. Had it not been for the various user-written blog posts <a href="https://www.google.com/#q=hugo+static+generator+tutorial&*">scattered across the internet</a>, I may well have written off Hugo entirely based on the quality of its documentation.</p>
<h1 id="what-this-post-is-and-is-not"><a class="zola-anchor" href="#what-this-post-is-and-is-not" aria-label="Anchor link for: what-this-post-is-and-is-not">What This Post is and is Not</a></h1>
<p>This post is not intended to show how to get up and running with Hugo. There already exists a wealth of online tutorials addressing that topic. This post instead looks at the stage which comes after having a <code>./public</code> folder containing a static generated website; getting it online and in front of people.</p>
<p>One of the potential advantages of going with a static generated website (if a static website fits your individual use-case) is that it does not require running a web server. If you want a website backed by a server to be available 24/7, that means paying for that server for be running 24/7. On the other hand, by using an object store such as Amazon's <a href="https://aws.amazon.com/s3/">S3 service</a> to host your static content, the <a href="https://aws.amazon.com/s3/pricing/">costs incurred</a> are relative to the resources used: you only pay for what you use. This is clearly to the advantage of personal websites such as this one which only bring in a modest amount of traffic at the best of times.</p>
<p>When I first set up this website on AWS, I set up everything manually through the AWS console. It was a painful and uncertain experience which required stitching together steps from a variety of online tutorials that had been published over the span of half a decade.</p>
<p>Since that original setup, I have refined the process of getting a static generated website online, and I now have a simple, version controlled, repeatable and reliable way of spinning up all the required AWS infrastructure for a public-facing website behind https.</p>
<p>This post is about using <a href="https://www.hashicorp.com/">HashiCorp</a>'s <a href="https://www.terraform.io/">Terraform</a> to safely and predictably to automate the creation of the required AWS infrastructure to get any static website online and behind https in minutes.</p>
<h1 id="terraform"><a class="zola-anchor" href="#terraform" aria-label="Anchor link for: terraform">Terraform</a></h1>
<p>Both the <a href="https://www.terraform.io/">website</a> and the <a href="https://www.terraform.io/docs/index.html">documentation</a> for Terraform do a fantastic job of explaining what Terraform does and outlining some of its common use-cases. In a nutshell, Terraform lets you describe your the different pieces of your cloud infrastructure and the relationships between them as versionable code.</p>
<p>For this use-case, in practical terms this means that instead of going through the AWS console to set up all the different pieces, copying and pasting the ARNs of those different pieces in various places to ensure that they are all talking to each other as they should, you can just describe the infrastructure that you want to create as code, and then create links between those pieces using the variables that will hold the values that will be generated by each piece of your infrastructure as it is created.</p>
<h1 id="dependencies"><a class="zola-anchor" href="#dependencies" aria-label="Anchor link for: dependencies">Dependencies</a></h1>
<p>If you are running OS X or macOS, you can use <a href="https://brew.sh/">Homebrew</a> to install the required dependencies:</p>
<pre style="background-color:#2b303b;color:#c0c5ce;"><code><span>brew install awscli
</span><span>brew install terraform
</span></code></pre>
<h1 id="prerequisites"><a class="zola-anchor" href="#prerequisites" aria-label="Anchor link for: prerequisites">Prerequisites</a></h1>
<ul>
<li>You should have an AWS account and <code>aws-cli</code> should be <a href="https://docs.aws.amazon.com/cli/latest/userguide/cli-chap-getting-started.html#cli-quick-configuration">configured</a> on your machine</li>
<li>You should have a domain name registered (this doesn't necessarily have to be with Amazon's <a href="https://aws.amazon.com/route53/">Route 53</a>)</li>
<li>You should request and receive a certificate for your domain from AWS <a href="https://aws.amazon.com/certificate-manager/">Certificate Manager</a></li>
<li>Make sure that in addition to <code>yourdomain.com</code>, <code>*.yourdomain.com</code> and <code>www.yourdomain.com</code> are also covered by the certificate</li>
</ul>
<h1 id="required-infrastructure"><a class="zola-anchor" href="#required-infrastructure" aria-label="Anchor link for: required-infrastructure">Required Infrastructure</a></h1>
<p>At the bare minimum, we will need to create the following:</p>
<ul>
<li>An S3 bucket with a name matching the string of the bare url of your domain (without <code>www.</code> prepended)</li>
<li>An S3 bucket with a name matching the string of the url of your domain with <code>www.</code> prepended</li>
<li>A CloudFront distribution for each of the S3 buckets</li>
<li>A Route 53 Hosted Zone for your domain</li>
<li>An Alias Record in the Route 53 Hosted Zone for each of the CloudFront distributions</li>
</ul>
<h1 id="suggested-infrastructure"><a class="zola-anchor" href="#suggested-infrastructure" aria-label="Anchor link for: suggested-infrastructure">Suggested Infrastructure</a></h1>
<p>I would personally also suggest creating specific IAM user used to deploy the website through a continuous integration and deployment platform such as Wercker. This is a particularly popular method for deploying updates for Hugo users and has a very well established <a href="https://gohugo.io/tutorials/automated-deployments/">procedure</a> to get everything up and running.</p>
<p>In order to deploy from a platform such as Wercker, which essentially listens for <code>git push</code> events to a linked repository and then runs a set of actions whenever those events occur (building the website, uploading it to S3), that platform needs to be able to access resources linked to your AWS account.</p>
<p>It is generally considered sensible to create a specific IAM user with limited access to only the resources required to deploy your website, and to use that user to run actions on your AWS infrastructure rather than a main AWS user account with access to everything.</p>
<h1 id="terraform-variables"><a class="zola-anchor" href="#terraform-variables" aria-label="Anchor link for: terraform-variables">Terraform Variables</a></h1>
<p>In order to get started, we need to have an understanding of what the main top-level variables to be used across our Terraform template are. We need to know the domain name, the ARN of the certificate for that domain, the AWS region that you want to create your infrastructure in and if you are going to be using Wercker, the name of the IAM user that will be used to deploy the website to AWS.</p>
<p>Additionally, because of Google's duplicate content <a href="https://support.google.com/webmasters/answer/66359?hl=en">penalty</a> and the fact that the approach of using a CloudFront distribution in front of an S3 bucket could allow bots to index both the bucket and the distribution, it makes sense to use an approach which protects access to the bucket. <a href="https://github.com/ringods/terraform-website-s3-cloudfront-route53#inputs">My preferred approach</a> is to use custom headers between the distribution and the bucket. Consequently, a random string value can also be defined as a variable to be used in a custom header allowing the distribution access to the bucket.</p>
<p><a href="https://www.terraform.io/intro/getting-started/variables.html">Variables</a> in Terraform need to be defined in a <code>variables.tf</code> file, and once they are defined, you can set them using data stored in a <code>terraform.tfvars</code> file.</p>
<p><code>variables.tf</code></p>
<pre style="background-color:#2b303b;color:#c0c5ce;"><code><span>variable "certificate" {}
</span><span>variable "deployer" {}
</span><span>variable "domain" {}
</span><span>variable "duplicate_content_penalty_secret" {}
</span><span>variable "region" {}
</span></code></pre>
<p><code>terraform.tfvars</code></p>
<pre style="background-color:#2b303b;color:#c0c5ce;"><code><span>certificate = "ARN of your certificate"
</span><span>deployer = "Name of your deploying IAM user"
</span><span>domain = "yourdomain.com"
</span><span>duplicate_content_penalty_secret = "Some random value"
</span><span>region = "AWS region code"
</span></code></pre>
<h1 id="terraform-modules"><a class="zola-anchor" href="#terraform-modules" aria-label="Anchor link for: terraform-modules">Terraform Modules</a></h1>
<p>With top level variables defined and a file that they can be read from created, we can start describing at a high level what our infrastructure will look like using <a href="https://www.terraform.io/docs/modules/usage.html">modules</a> in a top-level <code>main.tf</code> file.</p>
<p>In this file we firstly define a cloud provider, which in this case is AWS, and set the region that we want to use. As your <code>~/.aws/credentials</code> file will be correctly configured by at this point, there is no need to commit your AWS credentials to this file as Terraform will read them from the relevant environment variables on your machine set up by <code>awscli</code>.</p>
<p>From there, we define the outline for:</p>
<ul>
<li>The site as accessed from the bare domain url</li>
<li>The site as accessed from the www domain url, which will redirect to the bare domain url</li>
<li>The Route 53 Hosted Zone for the domain and Alias Records for both the bare and www urls</li>
<li>The IAM user which can be used to deploy the generated website to AWS from Wercker</li>
</ul>
<p><code>main.tf</code></p>
<pre style="background-color:#2b303b;color:#c0c5ce;"><code><span>provider "aws" {
</span><span> region = "${var.region}"
</span><span>}
</span><span>
</span><span>module "deployment_user" {
</span><span> source = "./iam"
</span><span>
</span><span> deployment_user_name = "${var.deployment_user_name}"
</span><span> domain = "${var.domain}"
</span><span>}
</span><span>
</span><span>module "site_bare_url" {
</span><span> source = "./site_bare_url"
</span><span>
</span><span> acm_certificate_arn = "${var.certificate_arn}"
</span><span> domain = "${var.domain}"
</span><span> duplicate_content_penalty_secret = "${var.duplicate_content_penalty_secret}"
</span><span> not_found_response_path = "/404.html"
</span><span>}
</span><span>
</span><span>module "site_www_url" {
</span><span> source = "./site_www_url"
</span><span>
</span><span> acm_certificate_arn = "${var.certificate_arn}"
</span><span> deployer = "${var.deployment_user_name}"
</span><span> domain = "www.${var.domain}"
</span><span> duplicate_content_penalty_secret = "${var.duplicate_content_penalty_secret}"
</span><span> target = "${var.domain}"
</span><span>}
</span><span>
</span><span>module "route_53" {
</span><span> source = "./route_53"
</span><span>
</span><span> bare_url_domain = "${var.domain}"
</span><span> bare_url_target = "${module.site_bare_url.website_cdn_hostname}"
</span><span> bare_url_cdn_hosted_zone_id = "${module.site_bare_url.website_cdn_zone_id}"
</span><span>
</span><span> www_url_domain = "www.${var.domain}"
</span><span> www_url_target = "${module.site_www_url.website_cdn_hostname}"
</span><span> www_url_cdn_hosted_zone_id = "${module.site_www_url.website_cdn_zone_id}"
</span><span>}
</span></code></pre>
<p>Each of these modules has its own <code>main.tf</code> file in the specified source subdirectory. While each of these modules definitions is passing in data either from the top level variables or from the generated outputs of other modules, the files in the source subdirectories will contain the configuration details outlining how our infrastructure should look at behave.</p>
<p>Although not shown specifically below, variables passed in to each module will of course also need to be defined in the <code>./module_source</code>, usually in a separate <code>module_source/variables.tf</code> file, or even in the <code>./module_source/main.tf</code> file itself if it is relatively small.</p>
<h1 id="iam-deployment-user"><a class="zola-anchor" href="#iam-deployment-user" aria-label="Anchor link for: iam-deployment-user">IAM Deployment User</a></h1>
<p>To start with, we want to ensure that our deployment user can only perform the required actions on a specific bucket and nothing else. Terraform's template files provide a simple way to handle this in a sensible and reusable way; specifying the bucket that you want to allow access to using variable interpolation.</p>
<p><code>./iam/deployment_policy.json</code></p>
<pre style="background-color:#2b303b;color:#c0c5ce;"><code><span>{
</span><span> "Version": "2012-10-17",
</span><span> "Statement": [
</span><span> {
</span><span> "Action": [
</span><span> "s3:ListBucket"
</span><span> ],
</span><span> "Effect": "Allow",
</span><span> "Resource": "arn:aws:s3:::${bucket_name}"
</span><span> },
</span><span> {
</span><span> "Action": [
</span><span> "s3:DeleteObject",
</span><span> "s3:GetObject",
</span><span> "s3:GetObjectAcl",
</span><span> "s3:ListBucket",
</span><span> "s3:PutObject",
</span><span> "s3:PutObjectAcl"
</span><span> ],
</span><span> "Effect": "Allow",
</span><span> "Resource": "arn:aws:s3:::${bucket_name}/*"
</span><span> }
</span><span> ]
</span><span>}
</span></code></pre>
<p>Using this template JSON file, in the <code>main.tf</code> file for this module a specific value can be specified to replace the <code>${bucket_name}</code> variable, which will likely come from the top-level <code>${domain}</code> variable defined earlier.</p>
<p>The module as a whole essentially goes through four steps:</p>
<ol>
<li>Create a new IAM user</li>
<li>Create an access key for the new IAM user</li>
<li>Create a policy that only allows access to the bare url website S3 bucket</li>
<li>Attach the new policy to the new IAM user</li>
</ol>
<p><code>./iam/main.tf</code></p>
<pre style="background-color:#2b303b;color:#c0c5ce;"><code><span>resource "aws_iam_user" "deployment_user" {
</span><span> name = "${var.deployment_user_name}"
</span><span>}
</span><span>
</span><span>resource "aws_iam_access_key" "deployment_user_access_key" {
</span><span> user = "${aws_iam_user.deployment_user.name}"
</span><span>}
</span><span>
</span><span>data "template_file" "deployment_policy_template_file" {
</span><span> template = "${file("${path.module}/deployment_policy.json")}"
</span><span> vars {
</span><span> bucket_name = "${var.domain}"
</span><span> }
</span><span>}
</span><span>
</span><span>resource "aws_iam_policy" "deployment_policy" {
</span><span> name = "${replace("${var.domain}",".","-")}-deployment-policy"
</span><span> path = "/"
</span><span> description = "Policy allowing to publish a new version of the website to the S3 bucket"
</span><span> policy = "${data.template_file.deployment_policy_template_file.rendered}"
</span><span>}
</span><span>
</span><span>resource "aws_iam_policy_attachment" "deployment_policy_attachment" {
</span><span> name = "${replace("${var.domain}",".","-")}-deployment-policy-attachment"
</span><span> users = ["${aws_iam_user.deployment_user.name}"]
</span><span> policy_arn = "${aws_iam_policy.deployment_policy.arn}"
</span><span>}
</span></code></pre>
<p>One thing to note with this approach is that <a href="https://www.terraform.io/docs/providers/aws/r/iam_access_key.html">the secret access key is written to the Terraform state file</a>, and this is where you'll have to go in order to retrieve it if you want to deploy your website using this user in Wercker.</p>
<h1 id="s3-and-cloudfront-main-site"><a class="zola-anchor" href="#s3-and-cloudfront-main-site" aria-label="Anchor link for: s3-and-cloudfront-main-site">S3 and CloudFront (Main Site)</a></h1>
<h2 id="s3-bucket"><a class="zola-anchor" href="#s3-bucket" aria-label="Anchor link for: s3-bucket">S3 Bucket</a></h2>
<p>Terraform's template files can again be used to construct a policy in a reusable way for an S3 bucket. With static generated websites on S3, documents need to be publicly readable. Also, if using the custom header approach to avoid Google's duplicate content penalty, the duplicate penalty secret defined earlier can be used here directly.</p>
<p><code>./site_bare_url/website_bucket_policy.json</code></p>
<pre style="background-color:#2b303b;color:#c0c5ce;"><code><span>{
</span><span> "Version": "2012-10-17",
</span><span> "Statement": [
</span><span> {
</span><span> "Sid": "PublicReadAccess",
</span><span> "Principal": {
</span><span> "AWS": "*"
</span><span> },
</span><span> "Effect": "Allow",
</span><span> "Action": [
</span><span> "s3:GetObject"
</span><span> ],
</span><span> "Resource": "arn:aws:s3:::${bucket_name}/*",
</span><span> "Condition": {
</span><span> "StringEquals": {
</span><span> "aws:UserAgent": "${secret}"
</span><span> }
</span><span> }
</span><span> }
</span><span> ]
</span><span>}
</span></code></pre>
<p>When it comes to the bucket itself, all that's really needed is to ensure that it is set up correctly to serve a static website by specifying an index document and an error document.</p>
<p><code>./site_bare_url/main.tf</code></p>
<pre style="background-color:#2b303b;color:#c0c5ce;"><code><span>data "template_file" "bucket_policy_template_file" {
</span><span> template = "${file("${path.module}/website_bucket_policy.json")}"
</span><span> vars {
</span><span> bucket_name = "${var.domain}"
</span><span> secret = "${var.duplicate_content_penalty_secret}"
</span><span> }
</span><span>}
</span><span>
</span><span>resource "aws_s3_bucket" "bare_url_bucket" {
</span><span> bucket = "${var.domain}"
</span><span> policy = "${data.template_file.bucket_policy_template_file.rendered}"
</span><span> force_destroy = true
</span><span>
</span><span> website {
</span><span> index_document = "index.html"
</span><span> error_document = "404.html"
</span><span> }
</span><span>
</span><span> tags {
</span><span> Name = "Bare URL bucket for static site ${var.domain}"
</span><span> }
</span><span>}
</span></code></pre>
<h2 id="cloudfront-distribution"><a class="zola-anchor" href="#cloudfront-distribution" aria-label="Anchor link for: cloudfront-distribution">CloudFront Distribution</a></h2>
<p>The setup of the CloudFront distribution which sits in front of the bucket does a few key things:</p>
<ul>
<li>Sets the S3 bucket hosting the static website pages as the origin</li>
<li>Makes use of the duplicate content penalty secret in a custom header when accessing the S3 bucket</li>
<li>Sets the previously generated Amazon Certificate Manager SSL certificate to use for https</li>
</ul>
<p><code>./site_bare_url/main.tf</code></p>
<pre style="background-color:#2b303b;color:#c0c5ce;"><code><span>resource "aws_cloudfront_distribution" "bare_url_cdn" {
</span><span> enabled = true
</span><span> price_class = "PriceClass_200"
</span><span> http_version = "http1.1"
</span><span>
</span><span> "origin" {
</span><span> origin_id = "S3-${aws_s3_bucket.bare_url_bucket.id}"
</span><span> domain_name = "${aws_s3_bucket.bare_url_bucket.website_endpoint}"
</span><span> custom_origin_config {
</span><span> origin_protocol_policy = "http-only"
</span><span> http_port = "80"
</span><span> https_port = "443"
</span><span> origin_ssl_protocols = [
</span><span> "TLSv1"]
</span><span> }
</span><span> custom_header {
</span><span> name = "User-Agent"
</span><span> value = "${var.duplicate_content_penalty_secret}"
</span><span> }
</span><span> }
</span><span> default_root_object = "index.html"
</span><span> custom_error_response {
</span><span> error_code = "404"
</span><span> error_caching_min_ttl = "360"
</span><span> response_code = "200"
</span><span> response_page_path = "/404.html"
</span><span> }
</span><span> "default_cache_behavior" {
</span><span> allowed_methods = [
</span><span> "GET",
</span><span> "HEAD",
</span><span> "DELETE",
</span><span> "OPTIONS",
</span><span> "PATCH",
</span><span> "POST",
</span><span> "PUT"]
</span><span> cached_methods = [
</span><span> "GET",
</span><span> "HEAD"]
</span><span> "forwarded_values" {
</span><span> query_string = false
</span><span> cookies {
</span><span> forward = "none"
</span><span> }
</span><span> }
</span><span> min_ttl = "0"
</span><span> default_ttl = "300"
</span><span> max_ttl = "1200"
</span><span> target_origin_id = "S3-${aws_s3_bucket.bare_url_bucket.id}"
</span><span> viewer_protocol_policy = "redirect-to-https"
</span><span> compress = true
</span><span> }
</span><span> "restrictions" {
</span><span> "geo_restriction" {
</span><span> restriction_type = "none"
</span><span> }
</span><span> }
</span><span> "viewer_certificate" {
</span><span> acm_certificate_arn = "${var.acm_certificate_arn}"
</span><span> ssl_support_method = "sni-only"
</span><span> minimum_protocol_version = "TLSv1"
</span><span> }
</span><span> aliases = [
</span><span> "${var.domain}"]
</span><span>}
</span></code></pre>
<h2 id="outputs"><a class="zola-anchor" href="#outputs" aria-label="Anchor link for: outputs">Outputs</a></h2>
<p>There are a few outputs from the CloudFront distribution that will be needed a little later when using Route 53 to make the domain point to the distribution. These can be saved either in the <code>main.tf</code> file or in a separate <code>outputs.tf</code> file.</p>
<p><code>./site_bare_url/outputs.tf</code></p>
<pre style="background-color:#2b303b;color:#c0c5ce;"><code><span>output "website_cdn_hostname" {
</span><span> value = "${aws_cloudfront_distribution.bare_url_cdn.domain_name}"
</span><span>}
</span><span>
</span><span>output "website_cdn_zone_id" {
</span><span> value = "${aws_cloudfront_distribution.bare_url_cdn.hosted_zone_id}"
</span><span>}
</span></code></pre>
<h1 id="s3-and-cloudfront-redirect-site"><a class="zola-anchor" href="#s3-and-cloudfront-redirect-site" aria-label="Anchor link for: s3-and-cloudfront-redirect-site">S3 and CloudFront (Redirect Site)</a></h1>
<h2 id="s3-bucket-1"><a class="zola-anchor" href="#s3-bucket-1" aria-label="Anchor link for: s3-bucket-1">S3 Bucket</a></h2>
<p>The S3 bucket for the www-prefixed url will not actually be storing anything; it will just be redirecting users to the primary bare url of the domain behind https.</p>
<p><code>./site_www_url/website_redirect_bucket_policy.json</code></p>
<pre style="background-color:#2b303b;color:#c0c5ce;"><code><span>{
</span><span> "Version":"2012-10-17",
</span><span> "Statement":[
</span><span> {
</span><span> "Sid":"PublicReadAccess",
</span><span> "Principal":{
</span><span> "AWS":"*"
</span><span> },
</span><span> "Effect":"Allow",
</span><span> "Action":[
</span><span> "s3:GetObject"
</span><span> ],
</span><span> "Resource":"arn:aws:s3:::${bucket_name}/*"
</span><span> }
</span><span> ]
</span><span>}
</span></code></pre>
<p><code>./site_www_url/main.tf</code></p>
<pre style="background-color:#2b303b;color:#c0c5ce;"><code><span>data "template_file" "bucket_policy_template_file" {
</span><span> template = "${file("${path.module}/website_redirect_bucket_policy.json")}"
</span><span> vars {
</span><span> bucket_name = "${var.domain}"
</span><span> }
</span><span>}
</span><span>
</span><span>resource "aws_s3_bucket" "www_url_bucket" {
</span><span> bucket = "${var.domain}"
</span><span> policy = "${data.template_file.bucket_policy_template_file.rendered}"
</span><span> force_destroy = true
</span><span>
</span><span> website {
</span><span> redirect_all_requests_to = "https://${var.target}"
</span><span> }
</span><span>}
</span></code></pre>
<h2 id="cloudfront-distribution-1"><a class="zola-anchor" href="#cloudfront-distribution-1" aria-label="Anchor link for: cloudfront-distribution-1">CloudFront Distribution</a></h2>
<p>Setting up the CloudFront distribution for the redirect site will be exactly the same as in the corresponding step for the main site.
<code>./site_www_url/main.tf</code></p>
<pre style="background-color:#2b303b;color:#c0c5ce;"><code><span>resource "aws_cloudfront_distribution" "www_url_cdn" {
</span><span> enabled = true
</span><span> price_class = "PriceClass_200"
</span><span> http_version = "http1.1"
</span><span>
</span><span> "origin" {
</span><span> origin_id = "S3-${aws_s3_bucket.www_url_bucket.id}"
</span><span> domain_name = "${aws_s3_bucket.www_url_bucket.website_endpoint}"
</span><span> custom_origin_config {
</span><span> origin_protocol_policy = "http-only"
</span><span> http_port = "80"
</span><span> https_port = "443"
</span><span> origin_ssl_protocols = [
</span><span> "TLSv1"]
</span><span> }
</span><span> custom_header {
</span><span> name = "User-Agent"
</span><span> value = "${var.duplicate_content_penalty_secret}"
</span><span> }
</span><span> }
</span><span> default_root_object = "index.html"
</span><span> custom_error_response {
</span><span> error_code = "404"
</span><span> error_caching_min_ttl = "360"
</span><span> response_code = "200"
</span><span> response_page_path = "/404.html"
</span><span> }
</span><span> "default_cache_behavior" {
</span><span> allowed_methods = [
</span><span> "GET",
</span><span> "HEAD",
</span><span> "DELETE",
</span><span> "OPTIONS",
</span><span> "PATCH",
</span><span> "POST",
</span><span> "PUT"]
</span><span> cached_methods = [
</span><span> "GET",
</span><span> "HEAD"]
</span><span> "forwarded_values" {
</span><span> query_string = false
</span><span> cookies {
</span><span> forward = "none"
</span><span> }
</span><span> }
</span><span> min_ttl = "0"
</span><span> default_ttl = "300"
</span><span> max_ttl = "1200"
</span><span> target_origin_id = "S3-${aws_s3_bucket.www_url_bucket.id}"
</span><span> viewer_protocol_policy = "redirect-to-https"
</span><span> compress = true
</span><span> }
</span><span> "restrictions" {
</span><span> "geo_restriction" {
</span><span> restriction_type = "none"
</span><span> }
</span><span> }
</span><span> "viewer_certificate" {
</span><span> acm_certificate_arn = "${var.acm_certificate_arn}"
</span><span> ssl_support_method = "sni-only"
</span><span> minimum_protocol_version = "TLSv1"
</span><span> }
</span><span> aliases = [
</span><span> "${var.domain}"]
</span><span>}
</span></code></pre>
<h2 id="outputs-1"><a class="zola-anchor" href="#outputs-1" aria-label="Anchor link for: outputs-1">Outputs</a></h2>
<p>There are a again few outputs from the CloudFront distribution that will be needed for Route 53.</p>
<p><code>./site_www_url/outputs.tf</code></p>
<pre style="background-color:#2b303b;color:#c0c5ce;"><code><span>output "website_cdn_hostname" {
</span><span> value = "${aws_cloudfront_distribution.www_url_cdn.domain_name}"
</span><span>}
</span><span>
</span><span>output "website_cdn_zone_id" {
</span><span> value = "${aws_cloudfront_distribution.www_url_cdn.hosted_zone_id}"
</span><span>}
</span></code></pre>
<h1 id="route-53"><a class="zola-anchor" href="#route-53" aria-label="Anchor link for: route-53">Route 53</a></h1>
<p>Finally, all that is left is to create a Route 53 Hosted Zone for the domain and two A records which will point to each of the CloudFront distributions.</p>
<p>With this in place, every time somebody browses to https://www.yourdomain.com, http://yourdomain.com or http://www.yourdomain.com, they will end up at https://yourdomain.com which will show the static generated website stored in the first S3 bucket.</p>
<p><code>./route_53/main.tf</code></p>
<pre style="background-color:#2b303b;color:#c0c5ce;"><code><span>resource "aws_route53_zone" "main_zone" {
</span><span> name = "${var.bare_url_domain}"
</span><span>}
</span><span>
</span><span>resource "aws_route53_record" "bare_url_cdn_alias_record" {
</span><span> zone_id = "${aws_route53_zone.main_zone.zone_id}"
</span><span> name = "${var.bare_url_domain}"
</span><span> type = "A"
</span><span>
</span><span> alias {
</span><span> name = "${var.bare_url_target}"
</span><span> zone_id = "${var.bare_url_cdn_hosted_zone_id}"
</span><span> evaluate_target_health = false
</span><span> }
</span><span>}
</span><span>
</span><span>resource "aws_route53_record" "www_url_cdn_alias_record" {
</span><span> zone_id = "${aws_route53_zone.main_zone.zone_id}"
</span><span> name = "${var.www_url_domain}"
</span><span> type = "A"
</span><span>
</span><span> alias {
</span><span> name = "${var.www_url_target}"
</span><span> zone_id = "${var.www_url_cdn_hosted_zone_id}"
</span><span> evaluate_target_health = false
</span><span> }
</span><span>}
</span></code></pre>
<h1 id="infrastructure-creation"><a class="zola-anchor" href="#infrastructure-creation" aria-label="Anchor link for: infrastructure-creation">Infrastructure Creation</a></h1>
<p>All that's left is to run <code>terraform apply</code> and watch the in the output in the console as all required infrastructure is created. A bit of patience is required particularly for the complete initialisation of CloudFront distributions, which I have experienced on occasion taking up to an hour.</p>
<p>Once everything is created, the Hugo documentation has a <a href="https://gohugo.io/tutorials/automated-deployments/">tutorial for setting up automated deployments of Hugo generated websites to an S3 bucket</a>. Alternatively it is also possible to <a href="http://www.nathanflath.com/post/blogsetup/">upload the generated pages directly from your computer</a>.</p>
ItalianTue, 28 Feb 2017 05:10:41 +0000lgug2z
https://lgug2z.com/articles/italian/
https://lgug2z.com/articles/italian/<p>Over the weekend it was decided that this year's wedding anniversary would be celebrated somewhere in Italy.</p>
<p>It has been a long time since I last had any extended exposure to Romance languages. My French and Spanish are still fairly strong, though I rarely have the chance to speak either at any great length these days. I've flirted with the idea of learning Italian many times over the years, though I have always ended up deciding not to. This is largely because I felt any professional edge it might offer me would be outweighed by the effort required.</p>
<p>I have since changed my approach to learning languages significantly; whereas previously my language selection process heavily factored in potential professional utility, I now study languages almost exclusively out of personal interest and with little regard for their potential professional application.</p>
<p>Between now and May I will be studying Italian between ten and twenty minutes a day. I am very excited to see how rapidly I can reach a conversational level where I am able to improvise when discussing topics with which I have a strong familiarity.
In particular, I am interested in comparing my previous experiences learning French and Spanish, at a time when my approach to language learning was admittedly quite haphazard, to the experience of learning Italian equipped with an approach informed by over a decade of refinement.</p>
ComfortSun, 26 Feb 2017 09:22:29 +0000lgug2z
https://lgug2z.com/articles/comfort/
https://lgug2z.com/articles/comfort/<p><em>Get comfortable with being uncomfortable</em> is a saying that I hear a lot when I'm around tech people, and particularly non-technical people working in tech. I get the feeling when hearing this saying repeated aloud, that my understanding of this it is quite different to the understanding of the people I hear repeating it.</p>
<p>In a team-based environment, it is important to voice discomfort at the earliest available opportunity. Whether that discomfort is a result of dynamics in the team, gaps in knowledge and understanding, unrealistic expectations, or any number of other things.</p>
<p>Whenever I hear the saying <em>get comfortable with being uncomfortable</em> or some variation of it repeated, the purpose of its repetition is more often than not to discourage and silence instances of people voicing their individual discomfort.</p>
<p>It is hard to address and work on issues that aren't made explicit. The practical use of this saying in tech today largely serves to reinforce the idea that addressing individual discomfort is a primarily individual responsibility. Not only do I think that this is not true, I think it actively promotes poor mental hygiene.</p>
<p>Getting comfortable with being uncomfortable is not a uniquely individual responsibility, it is also a collective responsibility. Part of that collective responsibility entails allowing both yourself and others the space to voice discomforts, and ultimately, ensuring an environment where to be uncomfortable is not considered an inherently shameful state.</p>
Reductive EditingWed, 22 Feb 2017 07:05:33 +0000lgug2z
https://lgug2z.com/articles/reductive-editing/
https://lgug2z.com/articles/reductive-editing/<p>This year one of my goals is to improve my writing skills, and one of the ways I'm working towards that goal is to make sure I write something every day.</p>
<p>January was not terribly successful, but February has been a lot better. Initially I was having a lot of trouble getting started, though that has been mitigated by my efforts to consistently write something every day, even if what I start remains incomplete. Having something already on the screen when I sit down to write every day makes getting started a lot easier than it would be just starting at a blank page.</p>
<p>As a part of my daily writing practice, I naturally also find myself editing along the way. My default approach to editing at this point is reductive rather than additive, which I think is a good thing.
This reductive tendency I have recently noticed when editing my own writing is also one that I have actively worked to strengthen and reinforce in other activities and work I do, such as programming, writing and recording music.</p>
<p>In 2012 I had half a dozen half-finished songs that had remained untouched in Logic Pro for almost two years. During that summer I tried revisiting those songs trying to grasp at whatever it felt was missing from them. At a certain point, out of frustration more than anything, I ended up muting everything except for the guitars and the vocals.
I had been listening to those songs for so long with all the instrumentation built up around them that I had forgotten when they sounded like when I was originally writing them on an acoustic guitar.</p>
<p>Stripping everything down helped me gain another perspective the songs; I stopped thinking about what was missing and started thinking about what was superfluous. I was having such a hard time finishing the recording of those songs because the songs themselves had structural problems that hadn't been properly addressed.</p>
<p>Too often however, I find that my approach to problem solving in various areas is overly additive and could benefit from being more reductive. A significant piece in the puzzle of my ongoing personal development remains the decoupling of learnings and heuristics from the specific domains in which I was first exposed to them and utilising them in a domain-independent way across the greater breadth of activities and areas that I engage in.</p>
Non-Zero DaysMon, 20 Feb 2017 07:22:54 +0000lgug2z
https://lgug2z.com/articles/non-zero-days/
https://lgug2z.com/articles/non-zero-days/<p>At some point over the last two weeks I came across a <a href="https://www.reddit.com/r/NonZeroDay/">subreddit</a> about 'non zero days'. As far as I can tell, the community on Reddit sprung up in response to a comment made by <a href="https://www.reddit.com/r/getdisciplined/comments/1q96b5/i_just_dont_care_about_myself/cdah4af/">ryans01</a>:</p>
<blockquote>
<p>Rule numero uno - There are no more zero days. What's a zero day? A zero day is when you don't do a single fucking thing towards whatever dream or goal or want or whatever that you got going on. No more zeros. I'm not saying you gotta bust an essay out everyday, that's not the point. The point I'm trying to make is that you have to make yourself, promise yourself, that the new SYSTEM you live in is a NON-ZERO system. Didnt' do anything all fucking day and it's 11:58 PM? Write one sentence. One pushup. Read one page of that chapter. One. Because one is non zero. You feel me? When you're in the super vortex of being bummed your pattern of behaviour is keeping the vortex goin, that's what you're used to. Turning into productivity ultimate master of the universe doesn't happen from the vortex. It happens from a massive string of CONSISTENT NON ZEROS. That's rule number one. Do not forget.</p>
</blockquote>
<p>I've been in favour of incrementalism for some time now. The idea itself is incredibly liberating coming from decades of exposure to popular fiction in all its forms which remains obsessed with prodigious narratives.</p>
<p>Too often I'll start something new, a mental or a physical activity, and after maintaining a relatively high velocity in the initial phases of familiarisation with that activity, my velocity will start to take a hit as my engagement deepens past the introductory levels.</p>
<p>A good example of this is my engagement with learning kanji characters. I'm a fan of <a href="https://www.wanikani.com/">WaniKani</a>, which has been a godsend for improving my overall knowledge of kanji characters as well as my ability to roughly decipher character combinations that I might not have specifically come across before. With all the changes in my life since last Autumn, my regular study on WaniKani eventually ground to a complete halt. First I wasn't making as much progress as I set out to in a given day, then I began skipping days, and before I knew it, the number of reviews that I had waiting for me were in the hundreds and I just gave up.</p>
<p>Over the last two weeks I've been doing five minutes of reviews on WaniKani every morning. The first few days came with a pretty high error rate. By the end of the first week my success rate was hovering at around 90% for each review. Yesterday I had cleared my entire review backlog and I found myself at a point where I could start learning new characters again.</p>
<p>My approach to incrementalism so far has been flawed in that I bring the implicit expectation that my velocity will only ever increase, and that every increase in velocity represents a new baseline which I can't let myself slip under.</p>
<p>I'm starting to understand and put into practice the idea that regularity trumps velocity in incrementalism. Ensuring that today isn't a zero day is more important than trying to match the output of yesterday.</p>
ExpectationsSat, 18 Feb 2017 00:00:00 +0000lgug2z
https://lgug2z.com/articles/expectations/
https://lgug2z.com/articles/expectations/<p>Working in the technology sector as a consultant means that I sometimes find myself working with public figures in the tech community. I can't think of many points in my life where I have found myself working with people whose talks I have watched on YouTube or whose writings I have read in various publications on a regular basis.</p>
<p>Naturally the process of following public figures primarily through the lens of the persona that they expose to the world results in expectations being formed which are rooted in the images that those people aim to project of themselves.</p>
<p>This week I've been left quite disappointed and frustrated by the expectations that I had developed of one such person and the reality of working side by side with them in the same team. The main source of my frustration has been the disconnect between the skills and methods that this person publicly evangelises in favour of and the lack of evidence of those same skills and methods in our interactions.</p>
<p>Conversely, there are people who have made a conscious decision to not cultivate public personas that I have been working with in the same time frame, and my interactions with them have consistently culminated in me coming away with a boost to both my knowledge and confidence in my ability to deliver. This has resulted in me thinking about the difference between actively publicising curated aspects of yourself and your work as a brand and letting your work speak for itself (the 'just kill' approach). I'm feeling increasingly wary of the potential echo chamber effect of the former.</p>
<p>Over the years I have seen a number of people enter into this approach with a specific narrative about themselves already in mind and then curate what they publicise about themselves to serve that narrative. It's incredibly easy to believe something that is repeated often enough, and doubly so when you can get other people to repeat it too. Reflection is undoubtedly a key element in growth and development, but one that falls victim to complacency with alarming ease.</p>
<p>The reality is that many desirable skills for people working in tech (particularly so-called 'soft skills') require continuous and deliberate practice. The complacency that can and does result from extended external exposure to our idealised narratives about ourselves lends itself well to ideas about reaching levels where we become exempt from those fundamental requirements.</p>
<p>What I like about the 'just kill' approach is the quality of the feedback it provides and specifically the 360-degree nature of that feedback. The strengths and weaknesses that organically bubble up are not impacted by the home bias of a narrative we are actively working to publicise, and the feedback provides a reliable and continuous mechanism to gauge the discrepancies between our idealised view of ourselves and the observable impact and quality of the work that we produce. One of the key takeaways from looking at the output of this mechanism is that it dispels any notions of linear progression or 'save points' and forces you to confront the reality of regression in the absence of sustained effort.</p>
<p>On the other hand, the 'just kill' approach struggles to scale beyond a group of teams or a specific community, and I imagine this is in part due to the baselines of domain-specific knowledge required to directly understand and appreciate the quality of the work that somebody is producing. I'm still not sure how to deal with this constraint, but no doubt I will be giving the problem more and more thought as my career in the tech industry progresses.</p>
Content ConsumptionTue, 14 Feb 2017 00:00:00 +0000lgug2z
https://lgug2z.com/articles/content-consumption/
https://lgug2z.com/articles/content-consumption/<p>I remember content consumption being quite a simple process before I owned a smartphone. I had some preferred RSS feeds that I subscribed to with a reader of my choice, which I would go through on my laptop whenever I had time and save articles that I was interested in reading later.</p>
<p>In the years since I first started using smartphones I've gone through a few different mobile-first apps such as <a href="https://flipboard.com/">Flipboard</a> and <a href="https://getpocket.com/">Pocket</a>, trying to augment/improve my content consumption workflow, but as of last week it seems I've come back around largely to my pre-smartphone habits.</p>
<p>At the start of 2017, long overdue an upgrade and increasingly disillusioned with Apple's latest offerings, I switched from using an iPhone to using a OnePlus 3T. On the whole the transition has been okay, but the overall quality of Android apps is far below that of iOS apps, and this is doubly true for apps that deal with content consumption. In particular, I have yet to find a truly viable alternative to <a href="http://reederapp.com/">Reeder</a>, and the Android version of <a href="https://www.instapaper.com/">Instapaper</a> is pretty much unusable.</p>
<p>I've recently started working on a project out of town which means I now spend the weekdays in a serviced apartment located about 10 minutes away from the client site. This has been a great opportunity for me to set in motion some solid morning habits and consolidate a good routine between 05:30 when I wake up and around 09:00 when I head to the office.</p>
<p>Frustrated with the experience of trying to sift through my RSS feeds and read saved articles with Instapaper on my OP3T, I have decided to check my RSS feeds once a day in the morning until I reach the RSS equivalent of inbox zero, and follow that up by spending 10 minutes making a dent in some of the articles, particularly long-reads, that have been stuck in my Instapaper queue for some time.</p>
<p>RSS feeds aren't quite what they used to be. The signal to noise ratio seems to have shifted dramatically in favour of noise since I first started using them over a decade ago. I've found that only checking my RSS feeds once a day (with one or two hundred articles waiting for my attention) has been helping me to be more discerning with what I save to read later.</p>
<p>I'm currently reading through <a href="http://calnewport.com/">Cal Newport</a>'s excellent book <a href="https://www.amazon.co.uk/Deep-Work-Focused-Success-Distracted/dp/0349411905/">Deep Work: Rules for Focused Success in a Distracted World</a>, and a big, if slightly vague, takeaway from what I have read so far is that having everything available to you all the time is usually not going to be a good thing for your overall productivity.</p>
<p>Applying that takeaway to my content consumption workflow, I've found that checking my RSS feeds in little chunks throughout the day on my smartphone invariably leads to more junk getting through the net and saved in my Instapaper queue, which in turn makes my Instapaper queue feel more and more insurmountable, which ultimately leads to me reading less both qualitatively and quantitatively.</p>
<p>In short, if you feel like your content consumption habits are getting the better of you, limit the time in which and the mediums you use to expose yourself to your feeds or curation apps and be more discerning in what you decide to save for later. My experience has been that the latter naturally comes a result of the former.</p>
LengthMon, 13 Feb 2017 00:00:00 +0000lgug2z
https://lgug2z.com/articles/length/
https://lgug2z.com/articles/length/<p>Since the start of the year I've been trying to make a concerted effort to write more. I realise that many of my biggest hurdles at the moment can be traced a lack of regularity and discipline, but there has been one thing in particular which has caught my attention recently when I've been going over topics I'd like to write about: length.</p>
<p>I seem to convince myself that if I cannot (or don't think I can) write about a certain topic up to a certain arbitrary length, it is not worth even trying to put together a post on that topic.</p>
<p>Seeing it written out on the screen so plainly, it seems quite stupid.</p>
<p>My approach needs less doubt and more regularity.</p>
PortabilitySun, 29 Jan 2017 00:00:00 +0000lgug2z
https://lgug2z.com/articles/portability/
https://lgug2z.com/articles/portability/<p>The past month has seen me think a lot about portability in development teams and across environments. The first real development project I worked on last year made use of a utility script which provided a unified development environment interface for all of the developers, a <a href="https://github.com/18F/go_script">go script</a>. This was something of a revelation, as it really helped with my onboarding and environment setup, and allowed me to be a productive contributor right away.</p>
<p>The project that I've been working on for the last few months has been a bit trickier with regards to getting anything close to a unified development environment set up across the team. There are a few different reasons for this. I think that people in a position of technical leadership over a development team should take steps to ensure that a team is using the same set of tooling on a billable project, which makes no concessions to individual preferences. If this is not being done, you are handicapped from the get-go when trying to achieve consistency across a team.</p>
<p>As this current project is a greenfield project (and quite a transpilation-heavy one at that) and nobody on the team was familiar with creating utility scripts to ensure a unified development environment interface, developers' interactions with their development environment were fragmented from the very beginning, meaning that as the project grew in complexity, reliably recreating issues across different machines was impossible and significantly impacted confidence in the codebase.</p>
<p>I don't like Ruby. It is one of those languages that just does not click with me. So when I decided to take on the task of writing a go script, I was quite dismayed to find that it all hinged on Ruby and Rake. As the development team was already highly fragmented in terms of their environment setups, the idea of trying to get people to set up a common version of Ruby just to be able to run some wrappers around some groups of commands that they were already getting by running manually seemed like a fool's errand.</p>
<p>Taking the go script from a previous project as an example, I saw that the Bash script that was being used to expose the <code>go</code> commands was essentially a big case statement (and some other stuff) which then went on to run the relevant part of the corresponding Rakefile.</p>
<p>I had been writing a few ad-hoc Bash scripts to deal with daily annoyances in the project, and the thought occurred to me that I should try and integrate them into a go script. No Ruby. No Rake. Just point the relevant case to the corresponding Bash script. As time went on, I integrated more and more Bash scripts, initially all as functions in the same <code>go.sh</code> file; as you might imagine, this soon became quite confusing and difficult to add to.</p>
<p>The next step was to separate all of the functions in the <code>go.sh</code> file into separate files and store them in a <code>.scripts</code> folder. Now the <code>go.sh</code> file was quite literally just a case statement and a usage blurb, and new commands could be added in a very modular way by putting a new file in the <code>.scripts</code> folder and running it in the case statement corresponding to a given <code>go</code> command. I took this a step further by adding both the <code>.scripts</code> folder and the folder containing the <code>go.sh</code> file itself to the PATH of the project using <a href="https://direnv.net/">direnv</a>, which allows for project-specific environment variables without messing up your global shell rc file.</p>
<p>I can think of few things in the development world as universal as Bash. The syntax seems a bit arcane and daunting at first, but if you spend half an hour figuring out how to do the basics it becomes very intuitive very quickly. Having fewer dependencies for something like a unified development environment interface is a good thing, not least because it results in being able to onboard new developers to a project that much more quickly and seamlessly.</p>
<p>Finally, a particularly nice bonus of having <code>go</code> commands stored in discrete Bash scripts has been that it is trivial to make a <code>go</code> command (particularly cleanup commands) run as a stage or a pipeline in our CD server, because it doesn't require anything of our agents besides the ability to run a simple Bash script. If you use something like <a href="https://direnv.net/">direnv</a> which allows you to make liberal use of project-specific environment variables in your Bash scripts that can be mirrored one-to-one in your CD server, this task becomes even more trivial still.</p>
<p>In short, the portability of Bash and the flexibility it can afford teams has had a profound impact on my approach to development.</p>
ExpectationsSat, 14 Jan 2017 00:00:00 +0000lgug2z
https://lgug2z.com/articles/facing-disappointment/
https://lgug2z.com/articles/facing-disappointment/<p>I went through a period last year where I was listening to a lot of podcasts on the Nerdist network. The stand-up comedians who often feature on various Nerdist podcasts will every so often talk about career progression and recognition in the world of stand-up.</p>
<p>A point that has been brought up in those podcasts which continues to resonate with me is to 'just kill' (write good material, perform it well, listen to the crowd's feedback, make adjustments and get a better reaction next time). Forget about everything else. Forget about other stand-up comics who are becoming more well-known than you, whether you think they deserve it or not, whether you think it should be you instead of them. Forget all of it. Just kill. Keep killing, keep iterating, keep improving, and your stand-up material will speak for itself.</p>
<p>I spent the last few weeks building up an opportunity in my head, telling myself how right I was for it, and how right it was for me. This week I learnt that it wasn't going to pan out. Needless to say, I am disappointed. It's alright to feel disappointed, but what matters now is how I face this disappointment. I need to concentrate my focus now on one key goal: just kill.</p>
<p>There is no shortage of opportunities. They are always coming and going. The next time one comes my way it's my responsibility to ensure that I am a better candidate then than I am now, and the way to do that is to just kill and let my work speak for itself.</p>
On Making TimeMon, 26 Dec 2016 00:00:00 +0000lgug2z
https://lgug2z.com/articles/on-making-time/
https://lgug2z.com/articles/on-making-time/<p>Making time is getting harder as the years stack up.</p>
<p>The things that I wish I could make more time for are not particularly extravagant. Reading. Exercising. Preparing lunches and dinners in advance. Writing music. Studying foreign languages. Revisiting video games from my childhood. Diving into the history of cinema. Listening to lengthy post rock records without interruption.</p>
<p>I had a routine that I really liked towards the end of this summer. I woke around 06:00 every morning and began my daily bodyweight workout while listening to a non-fiction audiobook. After my workout I would work through the next section of my Japanese textbook, with some vocabulary and kanji drills before and after. I wouldn't even think about food until noon, because I was practicing intermittent fasting, and I was amazed at how much time in my day had previously been taken up by thinking about food.</p>
<p>The next few hours of the afternoon were spent alternating between coding and reading technical articles and discussions. Somewhere between 15:00 and 16:00 I was ready to call it quits for the day, happy with what I had gotten done and comfortable spending what was left of the day doing whatever I felt like.</p>
<p>I was taking breaks every hour where I would walk around the block. The chronic back pain I had been suffering from was finally becoming a memory rather than my daily reality.</p>
<p>I spend around twenty hours of my week commuting now. While I still get up at 06:00, I'm out of the door before 07:00 which doesn't leave enough time to exercise sensibly and safely. I try to use the morning commute to listen to a few chapters of an audiobook. Initially it was non-fiction, but for some reason I've found that fiction works better with my mood while I'm commuting.</p>
<p>I get home some time between 19:00 and 20:00. The commute on the way home is a lot more draining than the commute on the way to work. By the time I get home I'm ready to collapse, more mentally than physically. All the static sitting and standing throughout the day doesn't do much to help my back, though.</p>
<p>I'm married, so I have to find some time to spend with my spouse, who most nights gets back home after I do. Since I'm up so early I also need to get to sleep relatively early as well. Preferably by 21:00, but at a stretch before 22:00. It's hard for me to sleep well without clearing my mind beforehand. Reading before bed would be nice, and indeed it was before marriage.</p>
<p>This holiday season I'm spending most of my holiday days with my in-laws and away from my hometown, where I find myself increasingly wanting to go for some 'me' time. Usually that time isn't so much downtime as it is time where I can work on something without interruption.</p>
<p>I woke up this morning with back pain after months of relief. The idea of going back to the sort of chronic back pain that I was living with every day this time last year is terrifying. My quality of life has rapidly deteriorated since September, when I started this new job in this new city and moved in with my spouse.</p>
<p>I don't exercise. I eat shit. I'm slowly putting back on the 20kg that I worked all year to shed. I'm not working on improving my Japanese or any of my other second languages. I can't remember the last time I picked up a musical instrument.</p>
<p>The environment that I'm in right now is sucking the life out of me. This city. The commute. The arguments. This filthy air. The constant noise.</p>
<p>Something has to change. I need to get back to summer.</p>