lgug2z https://lgug2z.com Zola en Thu, 22 Jan 2026 17:59:40 -0700 I Started Identifying Corporate Devices in My Software Thu, 22 Jan 2026 17:59:40 -0700 lgug2z 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:&#x2F;&#x2F;lobste.rs&#x2F;s&#x2F;vd7dgj&#x2F;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&amp;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 2025 Sun, 04 Jan 2026 13:28:07 -0700 lgug2z 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 Project Mon, 29 Dec 2025 01:36:54 -0800 lgug2z 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 Automatic Tue, 09 Dec 2025 00:00:00 +0000 lgug2z 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 -0800 lgug2z 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>&quot;</span><span style="color:#a3be8c;">C-unwind</span><span>&quot; </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!(&quot;</span><span style="color:#a3be8c;">display: {display_id} resized</span><span>&quot;); </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!(&quot;</span><span style="color:#a3be8c;">display: {display_id} added</span><span>&quot;); </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!(&quot;</span><span style="color:#a3be8c;">display: {display_id} removed</span><span>&quot;); </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>() -&gt; Result&lt;(), CoreGraphicsError&gt; { </span><span> tracing::info!(&quot;</span><span style="color:#a3be8c;">registering display reconfiguration listener callback</span><span>&quot;); </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 =&gt; Ok(()), </span><span> err =&gt; 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>(&amp;</span><span style="color:#b48ead;">mut </span><span style="color:#bf616a;">self</span><span>) { </span><span> tracing::info!(&quot;</span><span style="color:#a3be8c;">removing display reconfiguration listener callback</span><span>&quot;); </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 =&gt; {} </span><span> error =&gt; { </span><span> tracing::error!( </span><span> &quot;</span><span style="color:#a3be8c;">failed to remove display reconfiguration listener callback {error}</span><span>&quot; </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;">&quot;</span><span style="color:#96b5b4;">\n</span><span style="color:#a3be8c;">[</span><span>\(timestamp)</span><span style="color:#a3be8c;">] Display reconfiguration event received!&quot;</span><span>) </span><span> print(</span><span style="color:#a3be8c;">&quot; Display ID: </span><span>\(displayID)</span><span style="color:#a3be8c;">&quot;</span><span>) </span><span> print(</span><span style="color:#a3be8c;">&quot; Flags raw value: </span><span>\(flags.rawValue)</span><span style="color:#a3be8c;">&quot;</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;">&quot;BeginConfiguration&quot;</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;">&quot;Moved&quot;</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;">&quot;SetMain&quot;</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;">&quot;SetMode&quot;</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;">&quot;Add&quot;</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;">&quot;Remove&quot;</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;">&quot;Enabled&quot;</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;">&quot;Disabled&quot;</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;">&quot;Mirror&quot;</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;">&quot;UnMirror&quot;</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;">&quot;DesktopShapeChanged&quot;</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;">&quot; Flags: (none/unknown)&quot;</span><span>) </span><span> } </span><span style="color:#b48ead;">else</span><span> { </span><span> print(</span><span style="color:#a3be8c;">&quot; Flags: </span><span>\(flagDescriptions.joined(separator: </span><span style="color:#a3be8c;">&quot;, &quot;</span><span>)</span><span style="color:#a3be8c;">)&quot;</span><span>) </span><span> } </span><span> </span><span> fflush(stdout) </span><span>} </span><span> </span><span>print(</span><span style="color:#a3be8c;">&quot;=== Display Reconfiguration Listener Test ===&quot;</span><span>) </span><span>print(</span><span style="color:#a3be8c;">&quot;macOS version: </span><span>\(ProcessInfo.processInfo.operatingSystemVersionString)</span><span style="color:#a3be8c;">&quot;</span><span>) </span><span>print(</span><span style="color:#a3be8c;">&quot;&quot;</span><span>) </span><span> </span><span style="color:#b48ead;">let</span><span> app = NSApplication.shared </span><span>print(</span><span style="color:#a3be8c;">&quot;NSApplication.shared initialized: </span><span>\(app)</span><span style="color:#a3be8c;">&quot;</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, &amp;displays, &amp;displayCount) </span><span> </span><span>print(</span><span style="color:#a3be8c;">&quot;</span><span style="color:#96b5b4;">\n</span><span style="color:#a3be8c;">Currently active displays: </span><span>\(displayCount)</span><span style="color:#a3be8c;">&quot;</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>..&lt;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;">&quot; 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;">&quot;</span><span>) </span><span>} </span><span> </span><span>print(</span><span style="color:#a3be8c;">&quot;</span><span style="color:#96b5b4;">\n</span><span style="color:#a3be8c;">Registering CGDisplayReconfigurationCallback...&quot;</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;">&quot;✓ Callback registered successfully (CGError.success)&quot;</span><span>) </span><span>} </span><span style="color:#b48ead;">else</span><span> { </span><span> print(</span><span style="color:#a3be8c;">&quot;✗ Failed to register callback: </span><span>\(result.rawValue)</span><span style="color:#a3be8c;">&quot;</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;">&quot;</span><span style="color:#96b5b4;">\n</span><span style="color:#a3be8c;">&gt;&gt;&gt; Listening for display changes. Try:&quot;</span><span>) </span><span>print(</span><span style="color:#a3be8c;">&quot;</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;">&quot;</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: &lt;NSApplication: 0x11080cfa0&gt; </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>&gt;&gt;&gt; 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: &lt;NSApplication: 0x10d47f180&gt; </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>&gt;&gt;&gt; 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 Blog Fri, 14 Nov 2025 16:11:23 -0800 lgug2z 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:&#x2F;&#x2F;m.mtlynch.io&#x2F;@michael&#x2F;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>{{&lt;tweet id="12345"&gt;}}</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&#x27;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> &quot;</span><span style="color:#a3be8c;">https://m.mtlynch.io/@michael/115538492543985760</span><span>&quot;: { </span><span> &quot;</span><span style="color:#a3be8c;">title</span><span>&quot;: &quot;</span><span style="color:#a3be8c;">michael</span><span>&quot;, </span><span> &quot;</span><span style="color:#a3be8c;">source_display</span><span>&quot;: &quot;</span><span style="color:#a3be8c;">m.mtlynch.io</span><span>&quot;, </span><span> &quot;</span><span style="color:#a3be8c;">source_url</span><span>&quot;: &quot;</span><span style="color:#a3be8c;">https://m.mtlynch.io/@michael/115538492543985760</span><span>&quot;, </span><span> &quot;</span><span style="color:#a3be8c;">content</span><span>&quot;: &quot;</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 `{{&lt;tweet id=</span><span style="color:#96b5b4;">\&quot;</span><span style="color:#a3be8c;">12345</span><span style="color:#96b5b4;">\&quot;</span><span style="color:#a3be8c;">&gt;}}` 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&#39;s rate limiting something or the user deleted their tweet. I&#39;ve resorted to just screenshotting, which feels sloppy.</span><span>&quot; </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;">&lt;!-- library.html --&gt; </span><span> </span><span>{% if url %} </span><span> {% set library = load_data(path=&quot;library.json&quot;) %} </span><span> {% set quote_data = library | get(key=url) %} </span><span> {% set use_markdown = markdown | default(value=false) %} </span><span> </span><span> &lt;</span><span style="color:#bf616a;">div </span><span style="color:#d08770;">class</span><span>=&quot;</span><span style="color:#a3be8c;">notado-quote</span><span>&quot; </span><span> </span><span style="color:#d08770;">style</span><span>=&quot;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 %}&quot;&gt; </span><span> </span><span> &lt;</span><span style="color:#bf616a;">div </span><span style="color:#d08770;">style</span><span>=&quot;padding-bottom: </span><span style="color:#d08770;">1.25rem</span><span>&quot;&gt; </span><span> &lt;</span><span style="color:#bf616a;">div </span><span style="color:#d08770;">style</span><span>=&quot;display: flex; gap: </span><span style="color:#d08770;">0.75rem</span><span>&quot;&gt; </span><span> &lt;</span><span style="color:#bf616a;">div </span><span style="color:#d08770;">style</span><span>=&quot;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&quot;&gt; </span><span> &lt;</span><span style="color:#bf616a;">p </span><span style="color:#d08770;">style</span><span>=&quot;text-overflow: ellipsis; </span><span> overflow: hidden; </span><span> white-space: nowrap; </span><span> margin: </span><span style="color:#d08770;">0em</span><span>&quot;&gt; </span><span> {{ quote_data.title }} </span><span> &lt;/</span><span style="color:#bf616a;">p</span><span>&gt; </span><span> &lt;</span><span style="color:#bf616a;">p </span><span style="color:#d08770;">style</span><span>=&quot;color: </span><span style="color:#96b5b4;">var</span><span>(--text-1); margin: </span><span style="color:#d08770;">0em</span><span>&quot;&gt; </span><span> {% if quote_data.source_url %} </span><span> &lt;</span><span style="color:#bf616a;">a </span><span style="color:#d08770;">href</span><span>=&quot;</span><span style="color:#a3be8c;">{{ quote_data.source_url }}</span><span>&quot;&gt;{{ quote_data.source_display }}&lt;/</span><span style="color:#bf616a;">a</span><span>&gt; </span><span> {% else %} </span><span> {{ quote_data.source_display | split(pat=&quot; - &quot;) | first }} </span><span> {% endif %} </span><span> &lt;/</span><span style="color:#bf616a;">p</span><span>&gt; </span><span> &lt;/</span><span style="color:#bf616a;">div</span><span>&gt; </span><span> </span><span> &lt;</span><span style="color:#bf616a;">div </span><span style="color:#d08770;">style</span><span>=&quot;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&quot;&gt; </span><span> &lt;</span><span style="color:#bf616a;">a </span><span style="color:#d08770;">class</span><span>=&quot;</span><span style="color:#a3be8c;">notado-icon</span><span>&quot; </span><span style="color:#d08770;">href</span><span>=&quot;</span><span style="color:#a3be8c;">https://notado.app</span><span>&quot; </span><span style="color:#d08770;">style</span><span>=&quot;border: none&quot;&gt; </span><span> &lt;</span><span style="color:#bf616a;">img </span><span style="color:#d08770;">style</span><span>=&quot;height: </span><span style="color:#d08770;">3rem</span><span>; </span><span> width: </span><span style="color:#d08770;">3rem</span><span>&quot; </span><span> </span><span style="color:#d08770;">src</span><span>=&quot;</span><span style="color:#a3be8c;">https://notado.app/static/notado-icon.png</span><span>&quot; </span><span> </span><span style="color:#d08770;">alt</span><span>=&quot;</span><span style="color:#a3be8c;">notado</span><span>&quot; /&gt; </span><span> &lt;/</span><span style="color:#bf616a;">a</span><span>&gt; </span><span> &lt;/</span><span style="color:#bf616a;">div</span><span>&gt; </span><span> &lt;/</span><span style="color:#bf616a;">div</span><span>&gt; </span><span> &lt;/</span><span style="color:#bf616a;">div</span><span>&gt; </span><span> </span><span> {# djlint:off #} </span><span> {% if use_markdown %} </span><span> &lt;</span><span style="color:#bf616a;">div</span><span>&gt;{{ quote_data.content | markdown(inline=true) | safe }}&lt;/</span><span style="color:#bf616a;">div</span><span>&gt; </span><span> {% else %} </span><span> &lt;</span><span style="color:#bf616a;">div </span><span style="color:#d08770;">style</span><span>=&quot;white-space: pre-line&quot;&gt;{{ quote_data.content }}&lt;/</span><span style="color:#bf616a;">div</span><span>&gt; </span><span> {% endif %} </span><span> {# djlint:on #} </span><span> &lt;/</span><span style="color:#bf616a;">div</span><span>&gt; </span><span> </span><span> {% if caption %} </span><span> &lt;</span><span style="color:#bf616a;">div </span><span style="color:#d08770;">class</span><span>=&quot;</span><span style="color:#a3be8c;">notado-quote-caption</span><span>&quot; </span><span> </span><span style="color:#d08770;">style</span><span>=&quot;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>&quot;&gt; </span><span> {{ caption }} </span><span> &lt;/</span><span style="color:#bf616a;">div</span><span>&gt; </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&amp;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&amp;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>=&quot;</span><span style="color:#a3be8c;">https://m.mtlynch.io/@michael/115538492543985760</span><span>&quot;, </span><span> </span><span style="color:#bf616a;">caption</span><span>=&quot;</span><span style="color:#a3be8c;">Michael is on to something... Embedding his comment here didn&#39;t trigger a HTTP request when this article was built!</span><span>&quot;, </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 Shortcodes Thu, 13 Nov 2025 20:23:49 -0800 lgug2z 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 &quot;url&quot; }} </span><span>{{ $url := .Get &quot;url&quot; }} </span><span>{{ $limit := .Get &quot;limit&quot; }} </span><span> </span><span>{{ with resources.GetRemote $url | transform.Unmarshal }} </span><span>{{ range first $limit .channel.item }} </span><span>&lt;</span><span style="color:#bf616a;">div </span><span style="color:#d08770;">style</span><span>=&quot;padding: </span><span style="color:#d08770;">10px</span><span>; background-color: </span><span style="color:#96b5b4;">#373641</span><span>; word-wrap: break-word&quot;&gt; </span><span> {{ $type := (printf &quot;%T&quot; .content) }} </span><span> {{ if eq $type &quot;[]interface {}&quot; }} </span><span> </span><span> {{ range .content }} </span><span> {{ if index . &quot;-url&quot; }} </span><span> {{ $url := index . &quot;-url&quot; }} </span><span> </span><span> {{ if hasSuffix $url &quot;mp4&quot; }} </span><span> &lt;</span><span style="color:#bf616a;">video </span><span style="color:#d08770;">controls style</span><span>=&quot;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>;&quot;&gt; </span><span> &lt;</span><span style="color:#bf616a;">source </span><span style="color:#d08770;">src</span><span>=&quot;</span><span style="color:#a3be8c;">{{ $url }}</span><span>&quot;&gt; </span><span> &lt;/</span><span style="color:#bf616a;">video</span><span>&gt; </span><span> {{ else }} </span><span> &lt;</span><span style="color:#bf616a;">img </span><span style="color:#d08770;">src</span><span>=&quot;</span><span style="color:#a3be8c;">{{ $url }}</span><span>&quot;&gt; </span><span> {{ end }} </span><span> {{ end }} </span><span> {{ end }} </span><span> </span><span> {{ else }} </span><span> </span><span> {{ if index .content &quot;-url&quot; }} </span><span> {{ $url := index .content &quot;-url&quot; }} </span><span> </span><span> {{ if hasSuffix $url &quot;mp4&quot; }} </span><span> &lt;</span><span style="color:#bf616a;">video </span><span style="color:#d08770;">controls style</span><span>=&quot;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>;&quot;&gt; </span><span> &lt;</span><span style="color:#bf616a;">source </span><span style="color:#d08770;">src</span><span>=&quot;</span><span style="color:#a3be8c;">{{ $url }}</span><span>&quot;&gt; </span><span> &lt;/</span><span style="color:#bf616a;">video</span><span>&gt; </span><span> {{ else }} </span><span> &lt;</span><span style="color:#bf616a;">img </span><span style="color:#d08770;">src</span><span>=&quot;</span><span style="color:#a3be8c;">{{ $url }}</span><span>&quot;&gt; </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> &lt;</span><span style="color:#bf616a;">p</span><span>&gt;(&lt;</span><span style="color:#bf616a;">a </span><span style="color:#d08770;">href</span><span>=&quot;</span><span style="color:#a3be8c;">{{ .link }}</span><span>&quot;&gt;View&lt;/</span><span style="color:#bf616a;">a</span><span>&gt;)&lt;/</span><span style="color:#bf616a;">p</span><span>&gt; </span><span>&lt;/</span><span style="color:#bf616a;">div</span><span>&gt; </span><span>&lt;</span><span style="color:#bf616a;">br </span><span>/&gt; </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>&lt;</span><span style="color:#bf616a;">item</span><span>&gt; </span><span> &lt;</span><span style="color:#bf616a;">guid </span><span style="color:#d08770;">isPermaLink</span><span>=&quot;</span><span style="color:#a3be8c;">true</span><span>&quot;&gt;https://hachyderm.io/@LGUG2Z/115543625965938161&lt;/</span><span style="color:#bf616a;">guid</span><span>&gt; </span><span> &lt;</span><span style="color:#bf616a;">link</span><span>&gt;https://hachyderm.io/@LGUG2Z/115543625965938161&lt;/</span><span style="color:#bf616a;">link</span><span>&gt; </span><span> &lt;</span><span style="color:#bf616a;">pubDate</span><span>&gt;Thu, 13 Nov 2025 17:43:36 +0000&lt;/</span><span style="color:#bf616a;">pubDate</span><span>&gt; </span><span> &lt;</span><span style="color:#bf616a;">description</span><span>&gt;</span><span style="color:#8fa1b3;">&amp;</span><span style="color:#d08770;">lt;</span><span>p</span><span style="color:#8fa1b3;">&amp;</span><span style="color:#d08770;">gt;</span><span>Wild how often I</span><span style="color:#8fa1b3;">&amp;</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;">&amp;</span><span style="color:#d08770;">lt;</span><span>/p</span><span style="color:#8fa1b3;">&amp;</span><span style="color:#d08770;">gt;</span><span>&lt;/</span><span style="color:#bf616a;">description</span><span>&gt; </span><span> &lt;</span><span style="color:#bf616a;">media:content </span><span style="color:#d08770;">url</span><span>=&quot;</span><span style="color:#a3be8c;">https://media.hachyderm.io/media_attachments/files/115/543/625/239/633/063/original/b3acc14a489784cb.png</span><span>&quot; </span><span style="color:#d08770;">type</span><span>=&quot;</span><span style="color:#a3be8c;">image/png</span><span>&quot; </span><span style="color:#d08770;">fileSize</span><span>=&quot;</span><span style="color:#a3be8c;">40820</span><span>&quot; </span><span style="color:#d08770;">medium</span><span>=&quot;</span><span style="color:#a3be8c;">image</span><span>&quot;&gt; </span><span> &lt;</span><span style="color:#bf616a;">media:rating </span><span style="color:#d08770;">scheme</span><span>=&quot;</span><span style="color:#a3be8c;">urn:simple</span><span>&quot;&gt;nonadult&lt;/</span><span style="color:#bf616a;">media:rating</span><span>&gt; </span><span> &lt;/</span><span style="color:#bf616a;">media:content</span><span>&gt; </span><span>&lt;/</span><span style="color:#bf616a;">item</span><span>&gt; </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>=&quot;</span><span style="color:#a3be8c;">xml</span><span>&quot;) %} </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> &lt;div style=&quot;word-wrap: break-word&quot;&gt; </span><span> </span><span> {% </span><span style="color:#b48ead;">if </span><span style="color:#bf616a;">item</span><span>[&quot;</span><span style="color:#a3be8c;">media:content</span><span>&quot;] %} </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>[&quot;</span><span style="color:#a3be8c;">media:content</span><span>&quot;] %} </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>=&quot;</span><span style="color:#a3be8c;">.</span><span>&quot;) | </span><span style="color:#bf616a;">last </span><span>== &quot;</span><span style="color:#a3be8c;">mp4</span><span>&quot; %} </span><span> &lt;video controls style=&quot;width: 100% !important; height: auto !important;&quot;&gt; </span><span> &lt;source src=&quot;{{ </span><span style="color:#bf616a;">media_url </span><span>}}&quot;&gt; </span><span> &lt;/video&gt; </span><span> {% </span><span style="color:#b48ead;">else </span><span>%} </span><span> &lt;img src=&quot;{{ </span><span style="color:#bf616a;">media_url </span><span>}}&quot; style=&quot;max-width: 100%; height: auto;&quot;&gt; </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>=&quot;</span><span style="color:#a3be8c;">.</span><span>&quot;) | </span><span style="color:#bf616a;">last </span><span>== &quot;</span><span style="color:#a3be8c;">mp4</span><span>&quot; %} </span><span> &lt;video controls style=&quot;width: 100% !important; height: auto !important;&quot;&gt; </span><span> &lt;source src=&quot;{{ </span><span style="color:#bf616a;">media_url </span><span>}}&quot;&gt; </span><span> &lt;/video&gt; </span><span> {% </span><span style="color:#b48ead;">else </span><span>%} </span><span> &lt;img src=&quot;{{ </span><span style="color:#bf616a;">media_url </span><span>}}&quot; style=&quot;max-width: 100%; height: auto;&quot;&gt; </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> &lt;p&gt; </span><span> (&lt;a href=&quot;{{ </span><span style="color:#bf616a;">item</span><span>.</span><span style="color:#bf616a;">link </span><span>}}&quot;&gt;View&lt;/a&gt;) </span><span> &lt;/p&gt; </span><span> &lt;/div&gt; </span><span> &lt;br /&gt; </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>&lt;</span><span style="color:#bf616a;">s:Envelope </span><span style="color:#d08770;">xmlns:a</span><span>=&quot;</span><span style="color:#a3be8c;">http://www.w3.org/2005/08/addressing</span><span>&quot; </span><span style="color:#d08770;">xmlns:s</span><span>=&quot;</span><span style="color:#a3be8c;">http://www.w3.org/2003/05/soap-envelope</span><span>&quot;&gt; </span><span> &lt;</span><span style="color:#bf616a;">s:Header</span><span>&gt; </span><span> &lt;</span><span style="color:#bf616a;">a:Action </span><span style="color:#d08770;">s:mustUnderstand</span><span>=&quot;</span><span style="color:#a3be8c;">1</span><span>&quot;&gt;http://redacted.redacted.com/redacted/redacted/StartUsage&lt;/</span><span style="color:#bf616a;">a:Action</span><span>&gt; </span><span> &lt;</span><span style="color:#bf616a;">a:MessageID</span><span>&gt;urn:uuid:15aea56f-7690-467c-8d6b-145f64cd2747&lt;/</span><span style="color:#bf616a;">a:MessageID</span><span>&gt; </span><span> &lt;</span><span style="color:#bf616a;">a:ReplyTo</span><span>&gt; </span><span> &lt;</span><span style="color:#bf616a;">a:Address</span><span>&gt;http://www.w3.org/2005/08/addressing/anonymous&lt;/</span><span style="color:#bf616a;">a:Address</span><span>&gt; </span><span> &lt;/</span><span style="color:#bf616a;">a:ReplyTo</span><span>&gt; </span><span> &lt;</span><span style="color:#bf616a;">a:To </span><span style="color:#d08770;">s:mustUnderstand</span><span>=&quot;</span><span style="color:#a3be8c;">1</span><span>&quot;&gt;sb://redacted.servicebus.windows.net/redacted/90f791da-dc1e-49c9-a55c-dfd8ad4398ee/&lt;/</span><span style="color:#bf616a;">a:To</span><span>&gt; </span><span> &lt;/</span><span style="color:#bf616a;">s:Header</span><span>&gt; </span><span> &lt;</span><span style="color:#bf616a;">s:Body</span><span>&gt; </span><span> &lt;</span><span style="color:#bf616a;">StartUsage </span><span style="color:#d08770;">xmlns</span><span>=&quot;</span><span style="color:#a3be8c;">http://redacted.redacted.com/redacted/</span><span>&quot;/&gt; </span><span> &lt;/</span><span style="color:#bf616a;">s:Body</span><span>&gt; </span><span>&lt;/</span><span style="color:#bf616a;">s:Envelope</span><span>&gt; </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> &quot;</span><span style="color:#a3be8c;">Envelope</span><span>&quot;: { </span><span> &quot;</span><span style="color:#a3be8c;">Body</span><span>&quot;: { </span><span> &quot;</span><span style="color:#a3be8c;">StartUsage</span><span>&quot;: {} </span><span> }, </span><span> &quot;</span><span style="color:#a3be8c;">Header</span><span>&quot;: { </span><span> &quot;</span><span style="color:#a3be8c;">Action</span><span>&quot;: { </span><span> &quot;</span><span style="color:#a3be8c;">#text</span><span>&quot;: &quot;</span><span style="color:#a3be8c;">http://redacted.redacted.com/redacted/redacted/StartUsage</span><span>&quot;, </span><span> &quot;</span><span style="color:#a3be8c;">@s:mustUnderstand</span><span>&quot;: </span><span style="color:#d08770;">1 </span><span> }, </span><span> &quot;</span><span style="color:#a3be8c;">MessageID</span><span>&quot;: &quot;</span><span style="color:#a3be8c;">urn:uuid:15aea56f-7690-467c-8d6b-145f64cd2747</span><span>&quot;, </span><span> &quot;</span><span style="color:#a3be8c;">ReplyTo</span><span>&quot;: { </span><span> &quot;</span><span style="color:#a3be8c;">Address</span><span>&quot;: &quot;</span><span style="color:#a3be8c;">http://www.w3.org/2005/08/addressing/anonymous</span><span>&quot; </span><span> }, </span><span> &quot;</span><span style="color:#a3be8c;">To</span><span>&quot;: { </span><span> &quot;</span><span style="color:#a3be8c;">#text</span><span>&quot;: &quot;</span><span style="color:#a3be8c;">sb://redacted.servicebus.windows.net/redacted/90f791da-dc1e-49c9-a55c-dfd8ad4398ee/</span><span>&quot;, </span><span> &quot;</span><span style="color:#a3be8c;">@s:mustUnderstand</span><span>&quot;: </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>=&quot;</span><span style="color:#a3be8c;">xml</span><span>&quot;) %} </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> &lt;div style=&quot;word-wrap: break-word&quot;&gt; </span><span> {% </span><span style="color:#b48ead;">if </span><span style="color:#bf616a;">item</span><span>[&quot;</span><span style="color:#a3be8c;">content</span><span>&quot;] %} </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>[&quot;</span><span style="color:#a3be8c;">content</span><span>&quot;] %} </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>[&quot;</span><span style="color:#a3be8c;">@url</span><span>&quot;] </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>[&quot;</span><span style="color:#a3be8c;">@url</span><span>&quot;] %} </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>[&quot;</span><span style="color:#a3be8c;">@url</span><span>&quot;] %} </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>=&quot;</span><span style="color:#a3be8c;">.</span><span>&quot;) | </span><span style="color:#bf616a;">last </span><span>== &quot;</span><span style="color:#a3be8c;">mp4</span><span>&quot; %} </span><span> &lt;video controls style=&quot;width: 100% !important; height: auto !important;&quot;&gt; </span><span> &lt;source src=&quot;{{ </span><span style="color:#bf616a;">media_url </span><span>}}&quot;&gt; </span><span> &lt;/video&gt; </span><span> {% </span><span style="color:#b48ead;">else </span><span>%} </span><span> &lt;img src=&quot;{{ </span><span style="color:#bf616a;">media_url </span><span>}}&quot; style=&quot;max-width: 100%; height: auto;&quot;&gt; </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>[&quot;</span><span style="color:#a3be8c;">@url</span><span>&quot;] %} </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>[&quot;</span><span style="color:#a3be8c;">@url</span><span>&quot;] %} </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>=&quot;</span><span style="color:#a3be8c;">.</span><span>&quot;) | </span><span style="color:#bf616a;">last </span><span>== &quot;</span><span style="color:#a3be8c;">mp4</span><span>&quot; %} </span><span> &lt;video controls style=&quot;width: 100% !important; height: auto !important;&quot;&gt; </span><span> &lt;source src=&quot;{{ </span><span style="color:#bf616a;">media_url </span><span>}}&quot;&gt; </span><span> &lt;/video&gt; </span><span> {% </span><span style="color:#b48ead;">else </span><span>%} </span><span> &lt;img src=&quot;{{ </span><span style="color:#bf616a;">media_url </span><span>}}&quot; style=&quot;max-width: 100%; height: auto;&quot;&gt; </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> &lt;p&gt; </span><span> (&lt;a href=&quot;{{ </span><span style="color:#bf616a;">item</span><span>.</span><span style="color:#bf616a;">link </span><span>}}&quot;&gt;View&lt;/a&gt;) </span><span> &lt;/p&gt; </span><span> &lt;/div&gt; </span><span> &lt;br /&gt; </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 Websites Tue, 11 Nov 2025 23:55:45 -0800 lgug2z 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:&#x2F;&#x2F;news.ycombinator.com&#x2F;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&#x27;s theme you&#x27;re running a small intersection of hugo versions and can&#x27;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&#x27;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;">&lt;!-- quote.html --&gt; </span><span> </span><span>{% if url %} </span><span> {% set quote_data = load_data(url=url ~ &quot;/json&quot;, format=&quot;json&quot;) %} </span><span> </span><span> &lt;</span><span style="color:#bf616a;">div </span><span style="color:#d08770;">class</span><span>=&quot;</span><span style="color:#a3be8c;">notado-quote</span><span>&quot; </span><span> </span><span style="color:#d08770;">style</span><span>=&quot;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 %}&quot;&gt; </span><span> </span><span> &lt;</span><span style="color:#bf616a;">div </span><span style="color:#d08770;">style</span><span>=&quot;padding-bottom: </span><span style="color:#d08770;">1.25rem</span><span>&quot;&gt; </span><span> &lt;</span><span style="color:#bf616a;">div </span><span style="color:#d08770;">style</span><span>=&quot;display: flex; gap: </span><span style="color:#d08770;">0.75rem</span><span>&quot;&gt; </span><span> &lt;</span><span style="color:#bf616a;">div </span><span style="color:#d08770;">style</span><span>=&quot;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&quot;&gt; </span><span> &lt;</span><span style="color:#bf616a;">p </span><span style="color:#d08770;">style</span><span>=&quot;text-overflow: ellipsis; </span><span> overflow: hidden; </span><span> white-space: nowrap; </span><span> margin: </span><span style="color:#d08770;">0em</span><span>&quot;&gt; </span><span> {{ quote_data.title }} </span><span> &lt;/</span><span style="color:#bf616a;">p</span><span>&gt; </span><span> &lt;</span><span style="color:#bf616a;">p </span><span style="color:#d08770;">style</span><span>=&quot;color: </span><span style="color:#96b5b4;">var</span><span>(--text-1); margin: </span><span style="color:#d08770;">0em</span><span>&quot;&gt; </span><span> {% if quote_data.source_url %} </span><span> &lt;</span><span style="color:#bf616a;">a </span><span style="color:#d08770;">href</span><span>=&quot;</span><span style="color:#a3be8c;">{{ quote_data.source_url }}</span><span>&quot;&gt;{{ quote_data.source_display }}&lt;/</span><span style="color:#bf616a;">a</span><span>&gt; </span><span> {% else %} </span><span> {{ quote_data.source_display | split(pat=&quot; - &quot;) | first }} </span><span> {% endif %} </span><span> &lt;/</span><span style="color:#bf616a;">p</span><span>&gt; </span><span> &lt;/</span><span style="color:#bf616a;">div</span><span>&gt; </span><span> </span><span> &lt;</span><span style="color:#bf616a;">div </span><span style="color:#d08770;">style</span><span>=&quot;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&quot;&gt; </span><span> &lt;</span><span style="color:#bf616a;">a </span><span style="color:#d08770;">class</span><span>=&quot;</span><span style="color:#a3be8c;">notado-icon</span><span>&quot; </span><span style="color:#d08770;">href</span><span>=&quot;</span><span style="color:#a3be8c;">https://notado.app</span><span>&quot; </span><span style="color:#d08770;">style</span><span>=&quot;border: none&quot;&gt; </span><span> &lt;</span><span style="color:#bf616a;">img </span><span style="color:#d08770;">style</span><span>=&quot;height: </span><span style="color:#d08770;">3rem</span><span>; </span><span> width: </span><span style="color:#d08770;">3rem</span><span>&quot; </span><span> </span><span style="color:#d08770;">src</span><span>=&quot;</span><span style="color:#a3be8c;">https://notado.app/static/notado-icon.png</span><span>&quot; </span><span> </span><span style="color:#d08770;">alt</span><span>=&quot;</span><span style="color:#a3be8c;">notado</span><span>&quot; /&gt; </span><span> &lt;/</span><span style="color:#bf616a;">a</span><span>&gt; </span><span> &lt;/</span><span style="color:#bf616a;">div</span><span>&gt; </span><span> &lt;/</span><span style="color:#bf616a;">div</span><span>&gt; </span><span> &lt;/</span><span style="color:#bf616a;">div</span><span>&gt; </span><span> </span><span> {# djlint:off #} </span><span> &lt;</span><span style="color:#bf616a;">div </span><span style="color:#d08770;">style</span><span>=&quot;white-space: pre-line&quot;&gt;{{ quote_data.content }}&lt;/</span><span style="color:#bf616a;">div</span><span>&gt; </span><span> {# djlint:on #} </span><span> &lt;/</span><span style="color:#bf616a;">div</span><span>&gt; </span><span> </span><span> {% if caption %} </span><span> &lt;</span><span style="color:#bf616a;">div </span><span style="color:#d08770;">class</span><span>=&quot;</span><span style="color:#a3be8c;">notado-quote-caption</span><span>&quot; </span><span> </span><span style="color:#d08770;">style</span><span>=&quot;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>&quot;&gt; </span><span> {{ caption }} </span><span> &lt;/</span><span style="color:#bf616a;">div</span><span>&gt; </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>=&quot;</span><span style="color:#a3be8c;">https://notado.app/quote/fec984fa0089c5f1a1f31b6a95a71d36</span><span>&quot;, </span><span> </span><span style="color:#bf616a;">caption</span><span>=&quot;</span><span style="color:#a3be8c;">This is using the shortcode I&#39;m going to talk about! (and also why I got fed up with Hugo...)</span><span>&quot; </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:&#x2F;&#x2F;lobste.rs&#x2F;s&#x2F;rzskjk&#x2F;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:&#x2F;&#x2F;tildes.net&#x2F;~tech&#x2F;17xe&#x2F;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&#x2F;or because some crazy guy brought some scrolls up to a mountain. There are probably many scrolls that we don&#x27;t know about because they weathered away. We just find the lucky ones. It&#x27;s chance. Analog may last longer than digital data but I wouldn&#x27;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:&#x2F;&#x2F;old.reddit.com&#x2F;r&#x2F;patientgamers&#x2F;comments&#x2F;udzo11&#x2F;i_miss_the_days_of_server_browsers_and_community&#x2F;i6lga1o&#x2F;">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&#x27;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&#x27;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&#x27;s top Sniper. The time when the server&#x27;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&#x27;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:&#x2F;&#x2F;programming.dev&#x2F;comment&#x2F;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, &quot;is the policy to increase the number of days we must spend in our open-plan office kind of undermining this?&quot;. Literally all of our directors just shifted on their seats hoping another would answer that. Eventually, HR director stated &quot;Not at all, that&#x27;s what headphones are for!&quot; 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:&#x2F;&#x2F;discourse.nixos.org&#x2F;t&#x2F;should-organizations-relating-to-the-defense-sector-being-able-to-sponsor-nixos&#x2F;41252&#x2F;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&#x2F;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:&#x2F;&#x2F;defcon.social&#x2F;@corbden&#x2F;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&#x27;t hard when you&#x27;ve got the capacity and capability to do what is needed, and you&#x27;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&#x27;s why nobody likes work. We&#x27;re wired to like work, but we&#x27;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:&#x2F;&#x2F;bsky.app&#x2F;profile&#x2F;hmsnofun.bsky.social&#x2F;post&#x2F;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&#x27;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&#x27;t interested in the process, then i don&#x27;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:&#x2F;&#x2F;twitter.com&#x2F;mitchellh&#x2F;status&#x2F;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&#x27;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:&#x2F;&#x2F;www.youtube.com&#x2F;watch?v=k0J0Dxf5JKc&amp;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&#x27;t like either of these things since a game you buy once and can play forever with nigh-infinite content doesn&#x27;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 Nix Fri, 07 Nov 2025 17:42:49 -0800 lgug2z 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 Software Tue, 28 Oct 2025 17:59:40 -0700 lgug2z 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 Repositories Sun, 26 Oct 2025 13:42:52 -0700 lgug2z 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 Manager Sat, 20 Sep 2025 10:42:45 -0700 lgug2z 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> wpm Mon, 07 Apr 2025 16:06:37 -0700 lgug2z 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 Licensing Sun, 30 Mar 2025 08:45:49 -0700 lgug2z 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 Mythology Sun, 23 Mar 2025 14:32:45 -0700 lgug2z 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 Software Tue, 18 Mar 2025 20:39:30 -0700 lgug2z 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 Me Tue, 18 Mar 2025 17:32:55 -0700 lgug2z 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 Licenses Sat, 01 Feb 2025 18:32:25 -0800 lgug2z 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 Paywall Sun, 19 Jan 2025 14:43:16 -0800 lgug2z 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&amp;page=0&amp;prefix=true&amp;query=ai%20crawler&amp;sort=byDate&amp;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 2024 Mon, 30 Dec 2024 13:28:07 -0700 lgug2z 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:&#x2F;&#x2F;www.youtube.com&#x2F;embed&#x2F;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&amp;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> Komorebi Mon, 30 Dec 2024 00:00:00 +0000 lgug2z 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 &lt;PATH ON YOUR MACHINE&gt;/icul.validation </span><span> </span><span>{ </span><span> &quot;email&quot;: &quot;[email protected]&quot;, </span><span> &quot;hasValidSubscription&quot;: true, </span><span> &quot;timestamp&quot;: 1768927207, </span><span> &quot;currentEndPeriod&quot;: 1771519207, </span><span> &quot;signature&quot;: &quot;xyz&quot; </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 &lt;PATH ON YOUR MACHINE&gt;/icul.validation </span><span> </span><span>{ </span><span> &quot;email&quot;: &quot;[email protected]&quot;, &lt;-- license associated with the employee </span><span> &quot;hasValidSubscription&quot;: true, </span><span> &quot;timestamp&quot;: 1768927207, </span><span> &quot;currentEndPeriod&quot;: 1771519207, </span><span> &quot;managedBy&quot;: &quot;[email protected]&quot;, &lt;-- but managed by the corporate account </span><span> &quot;signature&quot;: &quot;xyz&quot; </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:&#x2F;&#x2F;www.reddit.com&#x2F;r&#x2F;desktops&#x2F;comments&#x2F;1rsi3ub&#x2F;comment&#x2F;oaac2nk&#x2F;">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:&#x2F;&#x2F;www.reddit.com&#x2F;r&#x2F;kde&#x2F;comments&#x2F;1rd01lo&#x2F;comment&#x2F;oa9tkkh&#x2F;">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:&#x2F;&#x2F;www.reddit.com&#x2F;r&#x2F;Windows11&#x2F;comments&#x2F;1r92c8f&#x2F;comment&#x2F;o69v4k8&#x2F;">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:&#x2F;&#x2F;www.reddit.com&#x2F;r&#x2F;ultrawidemasterrace&#x2F;comments&#x2F;1r10q4f&#x2F;comment&#x2F;o59ogzd&#x2F;">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:&#x2F;&#x2F;www.reddit.com&#x2F;r&#x2F;niri&#x2F;comments&#x2F;1r1mn9d&#x2F;comment&#x2F;o4qzhct&#x2F;">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:&#x2F;&#x2F;mastodon.social&#x2F;@Jefverbeeck&#x2F;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:&#x2F;&#x2F;old.reddit.com&#x2F;r&#x2F;pcmasterrace&#x2F;comments&#x2F;1qt9ncx&#x2F;unpopular_opinion_while_windows_isnt_perfect&#x2F;o3a0to9&#x2F;">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:&#x2F;&#x2F;www.reddit.com&#x2F;r&#x2F;pcmasterrace&#x2F;comments&#x2F;1qt9ncx&#x2F;comment&#x2F;o3a0to9&#x2F;">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:&#x2F;&#x2F;bsky.app&#x2F;profile&#x2F;drk.wtf&#x2F;post&#x2F;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:&#x2F;&#x2F;bsky.app&#x2F;profile&#x2F;orels1.bsky.social&#x2F;post&#x2F;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:&#x2F;&#x2F;www.reddit.com&#x2F;r&#x2F;ExperiencedDevs&#x2F;comments&#x2F;1qoypt0&#x2F;comment&#x2F;o2cr6yv&#x2F;">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:&#x2F;&#x2F;www.reddit.com&#x2F;r&#x2F;furry&#x2F;comments&#x2F;1qeauny&#x2F;comment&#x2F;nzx03dm&#x2F;">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:&#x2F;&#x2F;www.reddit.com&#x2F;r&#x2F;Windows11&#x2F;comments&#x2F;1qh1stt&#x2F;comment&#x2F;o0hbm1d&#x2F;">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:&#x2F;&#x2F;www.reddit.com&#x2F;r&#x2F;pcmasterrace&#x2F;comments&#x2F;1qeagv5&#x2F;comment&#x2F;nzx7y2v&#x2F;">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:&#x2F;&#x2F;www.reddit.com&#x2F;r&#x2F;pcmasterrace&#x2F;comments&#x2F;1qeagv5&#x2F;comment&#x2F;o021vjd&#x2F;">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:&#x2F;&#x2F;bsky.app&#x2F;profile&#x2F;tjseer.bsky.social&#x2F;post&#x2F;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:&#x2F;&#x2F;www.reddit.com&#x2F;r&#x2F;desksetup&#x2F;comments&#x2F;1owcdko&#x2F;comment&#x2F;noxbyp0&#x2F;">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:&#x2F;&#x2F;www.reddit.com&#x2F;r&#x2F;bapcsalescanada&#x2F;comments&#x2F;1ppav4t&#x2F;comment&#x2F;num5pxb&#x2F;">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:&#x2F;&#x2F;old.reddit.com&#x2F;r&#x2F;desktops&#x2F;comments&#x2F;1kl8qr1&#x2F;komorebi_with_scrolling_layout_komoniri&#x2F;mshgtpa&#x2F;">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:&#x2F;&#x2F;old.reddit.com&#x2F;r&#x2F;rust&#x2F;comments&#x2F;1nimfdf&#x2F;building_a_tiling_window_manager_for_macos_in_rust&#x2F;nek95jb&#x2F;">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:&#x2F;&#x2F;old.reddit.com&#x2F;r&#x2F;unixporn&#x2F;comments&#x2F;1nm2nhv&#x2F;oc_building_my_own_twm_again&#x2F;nfazidn&#x2F;">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:&#x2F;&#x2F;old.reddit.com&#x2F;r&#x2F;UsabilityPorn&#x2F;comments&#x2F;1nlpsah&#x2F;komorebi_now_on_macos&#x2F;nfsjliu&#x2F;">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:&#x2F;&#x2F;old.reddit.com&#x2F;r&#x2F;desktops&#x2F;comments&#x2F;1obd5jv&#x2F;showing_off_my_work_setup&#x2F;nktfweb&#x2F;">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:&#x2F;&#x2F;old.reddit.com&#x2F;r&#x2F;linuxsucks&#x2F;comments&#x2F;1o8j1xs&#x2F;can_we_make_it_easy_for_the_peoples_of_the_earth&#x2F;nkobflr&#x2F;">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:&#x2F;&#x2F;www.reddit.com&#x2F;r&#x2F;Windows11&#x2F;comments&#x2F;1l3yldy&#x2F;curious_do_you_use_virtual_desktops&#x2F;mwakqao&#x2F;">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:&#x2F;&#x2F;twitter.com&#x2F;oliveiratheone&#x2F;status&#x2F;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:&#x2F;&#x2F;www.reddit.com&#x2F;r&#x2F;desktops&#x2F;comments&#x2F;1kzil7x&#x2F;comment&#x2F;mv5oub9&#x2F;">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:&#x2F;&#x2F;www.reddit.com&#x2F;r&#x2F;desktops&#x2F;comments&#x2F;1kzil7x&#x2F;comment&#x2F;mv5tjc1&#x2F;">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:&#x2F;&#x2F;www.reddit.com&#x2F;r&#x2F;desktops&#x2F;comments&#x2F;1kwxefo&#x2F;comment&#x2F;muq7jzc&#x2F;">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:&#x2F;&#x2F;twitter.com&#x2F;pivotkraner&#x2F;status&#x2F;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:&#x2F;&#x2F;twitter.com&#x2F;oliviere_&#x2F;status&#x2F;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:&#x2F;&#x2F;twitter.com&#x2F;tommyfalkowski&#x2F;status&#x2F;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:&#x2F;&#x2F;www.youtube.com&#x2F;watch?v=MuR1-w-rLZI&amp;lc=UgyWXDpqXqaJhQqWhyB4AaABAg&amp;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:&#x2F;&#x2F;youtube.com&#x2F;watch?v=MuR1-w-rLZI&amp;lc=UgxLhTIJW6mR6m5_6294AaABAg.AIUuQW1nBEqAIW6zlNcSVb&amp;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:&#x2F;&#x2F;old.reddit.com&#x2F;r&#x2F;sysadmin&#x2F;comments&#x2F;1kte19p&#x2F;it_how_much_do_you_earn_share_if_its_not_a_secret&#x2F;mtv3dyf&#x2F;">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:&#x2F;&#x2F;www.youtube.com&#x2F;watch?v=MuR1-w-rLZI&amp;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:&#x2F;&#x2F;news.ycombinator.com&#x2F;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:&#x2F;&#x2F;www.reddit.com&#x2F;r&#x2F;desktops&#x2F;comments&#x2F;1kpvf8x&#x2F;comment&#x2F;mt50iyg&#x2F;">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:&#x2F;&#x2F;www.reddit.com&#x2F;r&#x2F;arch&#x2F;comments&#x2F;1kq0a90&#x2F;would_you_believe_me_if_i_said_this_is&#x2F;mt4g581&#x2F;">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:&#x2F;&#x2F;www.reddit.com&#x2F;r&#x2F;linuxsucks&#x2F;comments&#x2F;1kqaaz5&#x2F;comment&#x2F;mt72r53&#x2F;">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:&#x2F;&#x2F;github.com&#x2F;LGUG2Z&#x2F;komorebi&#x2F;issues&#x2F;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:&#x2F;&#x2F;github.com&#x2F;LGUG2Z&#x2F;komorebi&#x2F;issues&#x2F;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:&#x2F;&#x2F;github.com&#x2F;LGUG2Z&#x2F;komorebi&#x2F;issues&#x2F;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:&#x2F;&#x2F;github.com&#x2F;LGUG2Z&#x2F;komorebi&#x2F;issues&#x2F;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:&#x2F;&#x2F;github.com&#x2F;LGUG2Z&#x2F;komorebi&#x2F;issues&#x2F;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:&#x2F;&#x2F;github.com&#x2F;LGUG2Z&#x2F;komorebi&#x2F;issues&#x2F;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:&#x2F;&#x2F;github.com&#x2F;LGUG2Z&#x2F;komorebi&#x2F;issues&#x2F;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:&#x2F;&#x2F;github.com&#x2F;LGUG2Z&#x2F;komorebi&#x2F;issues&#x2F;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:&#x2F;&#x2F;github.com&#x2F;LGUG2Z&#x2F;komorebi&#x2F;issues&#x2F;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:&#x2F;&#x2F;github.com&#x2F;LGUG2Z&#x2F;komorebi&#x2F;issues&#x2F;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:&#x2F;&#x2F;github.com&#x2F;LGUG2Z&#x2F;komorebi&#x2F;issues&#x2F;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:&#x2F;&#x2F;github.com&#x2F;LGUG2Z&#x2F;komorebi&#x2F;issues&#x2F;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:&#x2F;&#x2F;bsky.app&#x2F;profile&#x2F;notnite.com&#x2F;post&#x2F;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:&#x2F;&#x2F;hachyderm.io&#x2F;@mattkotsenas&#x2F;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:&#x2F;&#x2F;fosstodon.org&#x2F;@lopeztel&#x2F;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:&#x2F;&#x2F;mastodon.social&#x2F;@BonfireAtNight&#x2F;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:&#x2F;&#x2F;www.reddit.com&#x2F;r&#x2F;pop_os&#x2F;comments&#x2F;1i4npv8&#x2F;is_it_possible_to_make_windows_11_look_and_feel_a&#x2F;m7xqgya&#x2F;">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:&#x2F;&#x2F;www.youtube.com&#x2F;watch?v=2mm0WAkG-Qs&amp;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:&#x2F;&#x2F;www.youtube.com&#x2F;watch?v=cBnLIwMtv8g&amp;lc=Ugzn-lMox55pvpAFo0Z4AaABAg&amp;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:&#x2F;&#x2F;www.youtube.com&#x2F;watch?v=cBnLIwMtv8g&amp;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:&#x2F;&#x2F;www.youtube.com&#x2F;watch?v=cBnLIwMtv8g&amp;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:&#x2F;&#x2F;www.youtube.com&#x2F;watch?v=Ece_NCcgiMY&amp;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:&#x2F;&#x2F;www.youtube.com&#x2F;watch?v=Ece_NCcgiMY&amp;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:&#x2F;&#x2F;www.reddit.com&#x2F;r&#x2F;desktops&#x2F;comments&#x2F;1kjelaa&#x2F;everforest_didnt_know_this_could_be_done_on_w11&#x2F;mrq6hsi&#x2F;">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:&#x2F;&#x2F;www.reddit.com&#x2F;r&#x2F;Windows11&#x2F;comments&#x2F;1kjg34a&#x2F;windows_can_look_really_good_with_some_effort&#x2F;mrs024w&#x2F;">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:&#x2F;&#x2F;news.ycombinator.com&#x2F;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:&#x2F;&#x2F;www.youtube.com&#x2F;watch?v=cBnLIwMtv8g&amp;lc=UgwGUt4GY7fzM09x6oh4AaABAg&amp;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:&#x2F;&#x2F;www.youtube.com&#x2F;watch?v=cBnLIwMtv8g&amp;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:&#x2F;&#x2F;www.youtube.com&#x2F;watch?v=cBnLIwMtv8g&amp;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:&#x2F;&#x2F;www.youtube.com&#x2F;watch?v=cBnLIwMtv8g&amp;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:&#x2F;&#x2F;www.youtube.com&#x2F;watch?v=cBnLIwMtv8g&amp;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:&#x2F;&#x2F;www.youtube.com&#x2F;watch?v=cBnLIwMtv8g&amp;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:&#x2F;&#x2F;www.youtube.com&#x2F;watch?v=cBnLIwMtv8g&amp;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:&#x2F;&#x2F;www.youtube.com&#x2F;watch?v=hDDxtvpjpHs&amp;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:&#x2F;&#x2F;www.youtube.com&#x2F;watch?v=hDDxtvpjpHs&amp;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:&#x2F;&#x2F;www.youtube.com&#x2F;watch?v=hDDxtvpjpHs&amp;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:&#x2F;&#x2F;www.youtube.com&#x2F;watch?v=hDDxtvpjpHs&amp;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:&#x2F;&#x2F;www.youtube.com&#x2F;watch?v=iaZWBmVzd0A&amp;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:&#x2F;&#x2F;www.youtube.com&#x2F;watch?v=Tkn0sY_pkzI&amp;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:&#x2F;&#x2F;www.youtube.com&#x2F;watch?v=6LcjPozTGpQ&amp;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:&#x2F;&#x2F;www.youtube.com&#x2F;watch?v=yDyuVBqj2qk&amp;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:&#x2F;&#x2F;www.youtube.com&#x2F;watch?v=yDyuVBqj2qk&amp;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:&#x2F;&#x2F;www.youtube.com&#x2F;watch?v=6LcjPozTGpQ&amp;lc=UgzTwHcKkBOSa0WB_ph4AaABAg&amp;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:&#x2F;&#x2F;www.youtube.com&#x2F;watch?v=6LcjPozTGpQ&amp;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:&#x2F;&#x2F;www.youtube.com&#x2F;watch?v=PLz3eqQDufw&amp;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:&#x2F;&#x2F;www.youtube.com&#x2F;watch?v=PLz3eqQDufw&amp;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:&#x2F;&#x2F;www.youtube.com&#x2F;watch?v=H9-_c1egQ4g&amp;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:&#x2F;&#x2F;www.youtube.com&#x2F;watch?v=H9-_c1egQ4g&amp;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:&#x2F;&#x2F;www.youtube.com&#x2F;watch?v=MnFtATdRpBE&amp;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:&#x2F;&#x2F;www.youtube.com&#x2F;watch?v=n7xZb2KilKY&amp;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:&#x2F;&#x2F;www.youtube.com&#x2F;watch?v=n7xZb2KilKY&amp;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:&#x2F;&#x2F;www.youtube.com&#x2F;watch?v=n7xZb2KilKY&amp;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:&#x2F;&#x2F;www.youtube.com&#x2F;watch?v=n7xZb2KilKY&amp;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:&#x2F;&#x2F;www.youtube.com&#x2F;watch?v=n7xZb2KilKY&amp;lc=Ugxv_zu-Me2LmTB-miV4AaABAg&amp;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:&#x2F;&#x2F;www.youtube.com&#x2F;watch?v=n7xZb2KilKY&amp;lc=UgzoEnN3QqiJtH8aRWR4AaABAg&amp;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:&#x2F;&#x2F;www.youtube.com&#x2F;watch?v=MMZUAtHbTYY&amp;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:&#x2F;&#x2F;www.youtube.com&#x2F;watch?v=MMZUAtHbTYY&amp;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:&#x2F;&#x2F;www.youtube.com&#x2F;watch?v=eH4FSAD3dYs&amp;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:&#x2F;&#x2F;www.youtube.com&#x2F;watch?v=eH4FSAD3dYs&amp;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:&#x2F;&#x2F;www.youtube.com&#x2F;watch?v=eH4FSAD3dYs&amp;lc=UgxA_1NFtnntbdayFDt4AaABAg">View</a>) </p> </div> </div> </div> My First LLM Programming Experience Tue, 19 Nov 2024 15:51:55 -0800 lgug2z 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>&lt;</span><span style="color:#bf616a;">link </span><span style="color:#d08770;">href</span><span>=&quot;</span><span style="color:#a3be8c;">https://unpkg.com/bulma-prefers-dark</span><span>&quot; </span><span style="color:#d08770;">rel</span><span>=&quot;</span><span style="color:#a3be8c;">stylesheet</span><span>&quot; </span><span style="color:#d08770;">type</span><span>=&quot;</span><span style="color:#a3be8c;">text/css</span><span>&quot;/&gt; </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>&lt;</span><span style="color:#bf616a;">div </span><span style="color:#d08770;">class</span><span>=&quot;</span><span style="color:#a3be8c;">flex</span><span>&quot;&gt; </span><span> &lt;</span><span style="color:#bf616a;">a </span><span style="color:#d08770;">class</span><span>=&quot;</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>&quot; </span><span style="color:#d08770;">disabled</span><span>=&quot;&quot;&gt; </span><span> Previous </span><span> &lt;/</span><span style="color:#bf616a;">a</span><span>&gt; </span><span>&lt;/</span><span style="color:#bf616a;">div</span><span>&gt; </span></code></pre> <pre data-lang="html" style="background-color:#2b303b;color:#c0c5ce;" class="language-html "><code class="language-html" data-lang="html"><span>&lt;</span><span style="color:#bf616a;">a </span><span style="color:#d08770;">href</span><span>=&quot;</span><span style="color:#a3be8c;">/library?title=Mohammed%20El-Kurd%20%28%40m7mdkurd%29</span><span style="color:#8fa1b3;">&amp;</span><span style="color:#d08770;">amp;</span><span style="color:#a3be8c;">sort=latest</span><span style="color:#8fa1b3;">&amp;</span><span style="color:#d08770;">amp;</span><span style="color:#a3be8c;">count=any</span><span>&quot; </span><span style="color:#8fa1b3;">id</span><span>=&quot;</span><span style="color:#a3be8c;">library-title</span><span>&quot;&gt; </span><span> &lt;</span><span style="color:#bf616a;">div </span><span style="color:#d08770;">class</span><span>=&quot;</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>&quot;&gt; </span><span> Mohammed El-Kurd (@m7mdkurd) </span><span> &lt;/</span><span style="color:#bf616a;">div</span><span>&gt; </span><span>&lt;/</span><span style="color:#bf616a;">a</span><span>&gt; </span></code></pre> <pre data-lang="html" style="background-color:#2b303b;color:#c0c5ce;" class="language-html "><code class="language-html" data-lang="html"><span>&lt;</span><span style="color:#bf616a;">div </span><span style="color:#d08770;">class</span><span>=&quot;</span><span style="color:#a3be8c;">mt-1 flex shadow-sm gap-2</span><span>&quot;&gt; </span><span> &lt;</span><span style="color:#bf616a;">span </span><span style="color:#d08770;">class</span><span>=&quot;</span><span style="color:#a3be8c;">inline-flex items-center px-3 border border-gray-300 bg-gray-50 sm:text-sm</span><span>&quot;&gt; </span><span> IF </span><span> &lt;/</span><span style="color:#bf616a;">span</span><span>&gt; </span><span> &lt;</span><span style="color:#bf616a;">select </span><span style="color:#d08770;">class</span><span>=&quot;</span><span style="color:#a3be8c;">tselect w-full</span><span>&quot; </span><span style="color:#d08770;">name</span><span>=&quot;</span><span style="color:#a3be8c;">field</span><span>&quot; </span><span style="color:#d08770;">onmouseup</span><span>=&quot;</span><span style="color:#bf616a;">generateTaggingRule</span><span>()&quot;&gt; </span><span> &lt;</span><span style="color:#bf616a;">option </span><span style="color:#d08770;">value</span><span>=&quot;</span><span style="color:#a3be8c;">content</span><span>&quot;&gt; </span><span> content </span><span> &lt;/</span><span style="color:#bf616a;">option</span><span>&gt; </span><span> &lt;</span><span style="color:#bf616a;">option </span><span style="color:#d08770;">value</span><span>=&quot;</span><span style="color:#a3be8c;">url</span><span>&quot;&gt; </span><span> url </span><span> &lt;/</span><span style="color:#bf616a;">option</span><span>&gt; </span><span> &lt;</span><span style="color:#bf616a;">option </span><span style="color:#d08770;">value</span><span>=&quot;</span><span style="color:#a3be8c;">title</span><span>&quot;&gt; </span><span> title </span><span> &lt;/</span><span style="color:#bf616a;">option</span><span>&gt; </span><span> &lt;/</span><span style="color:#bf616a;">select</span><span>&gt; </span><span>&lt;/</span><span style="color:#bf616a;">div</span><span>&gt; </span></code></pre> <pre data-lang="html" style="background-color:#2b303b;color:#c0c5ce;" class="language-html "><code class="language-html" data-lang="html"><span>&lt;</span><span style="color:#bf616a;">a </span><span style="color:#d08770;">href</span><span>=&quot;</span><span style="color:#a3be8c;">/feeds/jado/capitalism</span><span>&quot; </span><span style="color:#8fa1b3;">id</span><span>=&quot;</span><span style="color:#a3be8c;">library-title</span><span>&quot;&gt; </span><span> &lt;</span><span style="color:#bf616a;">div </span><span style="color:#d08770;">class</span><span>=&quot;</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>&quot;&gt; </span><span> &lt;</span><span style="color:#bf616a;">h3 </span><span style="color:#d08770;">class</span><span>=&quot;</span><span style="color:#a3be8c;">group-hover:text-white group-focus:text-white</span><span>&quot;&gt; </span><span> Capitalism </span><span> &lt;/</span><span style="color:#bf616a;">h3</span><span>&gt; </span><span> &lt;</span><span style="color:#bf616a;">p </span><span style="color:#d08770;">class</span><span>=&quot;</span><span style="color:#a3be8c;">text-gray-500 group-hover:text-white group-focus:text-white</span><span>&quot;&gt; </span><span> Thoughts on how capitalism intersects with work, art, technology, urban </span><span> planning, life and love from around the internet </span><span> &lt;/</span><span style="color:#bf616a;">p</span><span>&gt; </span><span> &lt;/</span><span style="color:#bf616a;">div</span><span>&gt; </span><span>&lt;/</span><span style="color:#bf616a;">a</span><span>&gt; </span></code></pre> <blockquote> <p>The <code>&lt;span&gt;</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>&lt;</span><span style="color:#bf616a;">div </span><span style="color:#d08770;">class</span><span>=&quot;</span><span style="color:#a3be8c;">mt-1 flex shadow-sm gap-2</span><span>&quot;&gt; </span><span> &lt;</span><span style="color:#bf616a;">span </span><span style="color:#d08770;">class</span><span>=&quot;</span><span style="color:#a3be8c;">inline-flex items-center px-3 border border-gray-300 bg-gray-50 sm:text-sm</span><span>&quot;&gt; </span><span> IF </span><span> &lt;/</span><span style="color:#bf616a;">span</span><span>&gt; </span><span> &lt;</span><span style="color:#bf616a;">select </span><span style="color:#d08770;">class</span><span>=&quot;</span><span style="color:#a3be8c;">tselect w-full</span><span>&quot; </span><span style="color:#d08770;">name</span><span>=&quot;</span><span style="color:#a3be8c;">field</span><span>&quot; </span><span style="color:#d08770;">onmouseup</span><span>=&quot;</span><span style="color:#bf616a;">generateTaggingRule</span><span>()&quot;&gt; </span><span> &lt;</span><span style="color:#bf616a;">option </span><span style="color:#d08770;">value</span><span>=&quot;</span><span style="color:#a3be8c;">content</span><span>&quot;&gt; </span><span> content </span><span> &lt;/</span><span style="color:#bf616a;">option</span><span>&gt; </span><span> &lt;</span><span style="color:#bf616a;">option </span><span style="color:#d08770;">value</span><span>=&quot;</span><span style="color:#a3be8c;">url</span><span>&quot;&gt; </span><span> url </span><span> &lt;/</span><span style="color:#bf616a;">option</span><span>&gt; </span><span> &lt;</span><span style="color:#bf616a;">option </span><span style="color:#d08770;">value</span><span>=&quot;</span><span style="color:#a3be8c;">title</span><span>&quot;&gt; </span><span> title </span><span> &lt;/</span><span style="color:#bf616a;">option</span><span>&gt; </span><span> &lt;/</span><span style="color:#bf616a;">select</span><span>&gt; </span><span>&lt;/</span><span style="color:#bf616a;">div</span><span>&gt; </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;">&gt; </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>&lt;head&gt;</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>&lt;</span><span style="color:#bf616a;">meta </span><span style="color:#d08770;">content</span><span>=&quot;</span><span style="color:#a3be8c;">#111827</span><span>&quot; </span><span style="color:#d08770;">media</span><span>=&quot;</span><span style="color:#a3be8c;">(prefers-color-scheme: dark)</span><span>&quot; </span><span style="color:#d08770;">name</span><span>=&quot;</span><span style="color:#a3be8c;">theme-color</span><span>&quot;/&gt; </span><span>&lt;</span><span style="color:#bf616a;">meta </span><span style="color:#d08770;">content</span><span>=&quot;</span><span style="color:#a3be8c;">#ffffff</span><span>&quot; </span><span style="color:#d08770;">media</span><span>=&quot;</span><span style="color:#a3be8c;">(prefers-color-scheme: light)</span><span>&quot; </span><span style="color:#d08770;">name</span><span>=&quot;</span><span style="color:#a3be8c;">theme-color</span><span>&quot;/&gt; </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&amp;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 Comments Fri, 21 Jun 2024 20:36:22 -0700 lgug2z 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 &quot;news.ycombinator.com&quot; TAG &quot;source:hn&quot; </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:&#x2F;&#x2F;news.ycombinator.com&#x2F;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&amp;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 2023 Mon, 08 Apr 2024 17:28:07 -0700 lgug2z 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&amp;lc=UgxD-A_WVP0GrDRjwnZ4AaABAg">chill</a> <a href="https://www.youtube.com/watch?v=Bv8ZUchoOng&amp;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&amp;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 Unstable Tue, 12 Mar 2024 21:13:34 -0700 lgug2z 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&amp;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> &quot;</span><span style="color:#a3be8c;">services/misc/atuin.nix</span><span>&quot; </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> &quot;</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>&quot; </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&amp;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 Actions Wed, 17 Jan 2024 10:30:40 -0800 lgug2z 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&amp;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:&#x2F;&#x2F;www.youtube.com&#x2F;embed&#x2F;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>: &quot;</span><span style="color:#a3be8c;">build</span><span>&quot; </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.&quot;https://${{ secrets.GH_TOKEN }}@github.com&quot;.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>&gt;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 &quot;${{ secrets.GIT_CRYPT_KEY }}&quot; | base64 --decode &gt; 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>= [&quot;</span><span style="color:#a3be8c;">aarch64-linux</span><span>&quot;]; </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 == &#39;aarch64-linux&#39; </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://&lt;your fly app name&gt;.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 &quot;machine &lt;your fly app name&gt;.fly.dev password ${{ secrets.ATTIC_TOKEN }}&quot; | sudo tee /etc/nix/netrc &gt; /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://&lt;your fly app name&gt;.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>: &quot;</span><span style="color:#a3be8c;">build</span><span>&quot; </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 == &#39;aarch64-linux&#39; </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 &quot;machine &lt;your fly app name&gt;.fly.dev password ${{ secrets.ATTIC_TOKEN }}&quot; | sudo tee /etc/nix/netrc &gt; /dev/null </span><span style="color:#a3be8c;"> git config --global url.&quot;https://${{ secrets.GH_TOKEN }}@github.com&quot;.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://&lt;your fly app name&gt;.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 &quot;${{ secrets.GIT_CRYPT_KEY }}&quot; | base64 --decode &gt; 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://&lt;your fly app name&gt;.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&amp;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.io Tue, 16 Jan 2024 16:40:58 -0800 lgug2z 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>= &quot;</span><span style="color:#a3be8c;">[::]:8080</span><span>&quot; </span><span style="color:#bf616a;">token-hs256-secret-base64 </span><span>= &quot;</span><span style="color:#a3be8c;">&lt;generate this with openssl rand 64 | base64 -w0&gt;</span><span>&quot; </span><span> </span><span>[database] </span><span style="color:#bf616a;">url </span><span>= &quot;</span><span style="color:#a3be8c;">sqlite:///data/attic.db?mode=rwc</span><span>&quot; </span><span> </span><span>[storage] </span><span style="color:#bf616a;">bucket </span><span>= &quot;</span><span style="color:#a3be8c;">&lt;your bucket name&gt;</span><span>&quot; </span><span style="color:#bf616a;">type </span><span>= &quot;</span><span style="color:#a3be8c;">s3</span><span>&quot; </span><span style="color:#bf616a;">region </span><span>= &quot;</span><span style="color:#a3be8c;">auto</span><span>&quot; </span><span style="color:#bf616a;">endpoint </span><span>= &quot;</span><span style="color:#a3be8c;">https://&lt;your cloudflare account id&gt;.r2.cloudflarestorage.com</span><span>&quot; </span><span> </span><span>[storage.credentials] </span><span style="color:#bf616a;">access_key_id </span><span>= &quot;</span><span style="color:#a3be8c;">&lt;your access key id&gt;</span><span>&quot; </span><span style="color:#bf616a;">secret_access_key </span><span>= &quot;</span><span style="color:#a3be8c;">&lt;your secret access key&gt;</span><span>&quot; </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>= &quot;</span><span style="color:#a3be8c;">zstd</span><span>&quot; </span><span> </span><span>[garbage-collection] </span><span style="color:#bf616a;">interval </span><span>= &quot;</span><span style="color:#a3be8c;">12 hours</span><span>&quot; </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 [&quot;</span><span style="color:#a3be8c;">-f</span><span>&quot;, &quot;</span><span style="color:#a3be8c;">/attic/server.toml</span><span>&quot;, &quot;</span><span style="color:#a3be8c;">--mode</span><span>&quot;, &quot;</span><span style="color:#a3be8c;">monolithic</span><span>&quot;] </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>= &quot;</span><span style="color:#a3be8c;">&lt;pick your own fly app name&gt;</span><span>&quot; </span><span style="color:#bf616a;">primary_region </span><span>= &quot;</span><span style="color:#a3be8c;">sea</span><span>&quot; </span><span> </span><span>[mounts] </span><span> </span><span style="color:#bf616a;">source </span><span>= &quot;</span><span style="color:#a3be8c;">atticdata</span><span>&quot; </span><span> </span><span style="color:#bf616a;">destination </span><span>= &quot;</span><span style="color:#a3be8c;">/data</span><span>&quot; </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>= [&quot;</span><span style="color:#a3be8c;">app</span><span>&quot;] </span><span> </span><span>[services.concurrency] </span><span> </span><span style="color:#bf616a;">type </span><span>= &quot;</span><span style="color:#a3be8c;">connections</span><span>&quot; </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>&#39;</span><span style="color:#a3be8c;">.[].id</span><span>&#39; </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>&#39;</span><span style="color:#a3be8c;">atticadm make-token --sub &quot;$YOUR_PREFERRED_USERNAME&quot; --validity &quot;10y&quot; --pull &quot;*&quot; --push &quot;*&quot; --create-cache &quot;*&quot; --configure-cache &quot;*&quot; --configure-cache-retention &quot;*&quot; --destroy-cache &quot;*&quot; --delete &quot;*&quot; -f /attic/server.toml</span><span>&#39; </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 &lt;your fly app name&gt;.fly.dev </span><span>password &lt;your attic token&gt; </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> &quot;</span><span style="color:#a3be8c;">https://nix-community.cachix.org?priority=41</span><span>&quot; </span><span style="color:#65737e;"># this is a useful public cache! </span><span> &quot;</span><span style="color:#a3be8c;">https://numtide.cachix.org?priority=42</span><span>&quot; </span><span style="color:#65737e;"># this is also a useful public cache! </span><span> &quot;</span><span style="color:#a3be8c;">https://&lt;your fly app name&gt;.fly.dev/system?priority=43</span><span>&quot; </span><span> ]; </span><span> </span><span style="color:#d08770;">trusted-public-keys </span><span>= [ </span><span> &quot;</span><span style="color:#a3be8c;">nix-community.cachix.org-1:mB9FSh9qf2dCimDSUo8Zy7bkq5CX+/rkCWyvRCYg3Fs=</span><span>&quot; </span><span> &quot;</span><span style="color:#a3be8c;">numtide.cachix.org-1:2ps1kLBUWjxIneOy1Ik6cQjb41X0iXVXeHigGmycPPE=</span><span>&quot; </span><span> &quot;</span><span style="color:#a3be8c;">&lt;your cache public key&gt;</span><span>&quot; </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>.&quot;</span><span style="color:#a3be8c;">attic/netrc</span><span>&quot;.</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 Server Sun, 14 Jan 2024 08:43:51 -0800 lgug2z 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>= &quot;</span><span style="color:#a3be8c;">mastodon-lgug2z</span><span>&quot;; </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>= &quot;</span><span style="color:#a3be8c;">LGUG2Z</span><span>&quot;; </span><span> </span><span style="color:#d08770;">repo </span><span>= &quot;</span><span style="color:#a3be8c;">mastodon</span><span>&quot;; </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>= &quot;</span><span style="color:#a3be8c;">b24adb69fa41a580fa2781a44661b7b707e3f765</span><span>&quot;; </span><span> </span><span style="color:#d08770;">hash </span><span>= &quot;</span><span style="color:#a3be8c;">sha256-BvcvUAcIW5lYT1gTrKsIVIbmDQpAE3KxOiLLWoUtYhw=</span><span>&quot;; </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>= &quot;</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>&quot;; </span><span> }); </span><span> }); </span><span> </span><span> </span><span style="color:#d08770;">localDomain </span><span>= &quot;</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>&quot;; </span><span> </span><span style="color:#d08770;">extraConfig </span><span>= { </span><span> </span><span style="color:#d08770;">WEB_DOMAIN </span><span>= &quot;</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>&quot;; </span><span> </span><span style="color:#d08770;">SINGLE_USER_MODE </span><span>= &quot;</span><span style="color:#a3be8c;">true</span><span>&quot;; </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>= &quot;&quot;; </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>= [&quot;</span><span style="color:#a3be8c;">mastodon</span><span>&quot;]; </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>[&quot;</span><span style="color:#a3be8c;">/var/lib/caddy</span><span>&quot; &quot;</span><span style="color:#a3be8c;">/run/mastodon-web</span><span>&quot;]; </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>.&quot;</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>&quot;.</span><span style="color:#d08770;">extraConfig </span><span>= &#39;&#39; </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 &quot;max-age=31536000;&quot; </span><span style="color:#a3be8c;"> } </span><span style="color:#a3be8c;"> </span><span style="color:#a3be8c;"> header /emoji/* Cache-Control &quot;public, max-age=31536000, immutable&quot; </span><span style="color:#a3be8c;"> header /packs/* Cache-Control &quot;public, max-age=31536000, immutable&quot; </span><span style="color:#a3be8c;"> header /system/accounts/avatars/* Cache-Control &quot;public, max-age=31536000, immutable&quot; </span><span style="color:#a3be8c;"> header /system/media_attachments/files/* Cache-Control &quot;public, max-age=31536000, immutable&quot; </span><span style="color:#a3be8c;"> </span><span>&#39;&#39;; </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 Immigrant Fri, 12 Jan 2024 08:51:27 -0800 lgug2z 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&amp;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 WireGuard Tue, 09 Jan 2024 15:06:54 -0800 lgug2z 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 &quot;samira&quot; -&gt; returns the uid for the &quot;samira&quot; 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>&quot;</span><span style="color:#a3be8c;">1000</span><span>&quot; </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 &quot;users&quot; -&gt; returns the gid for the &quot;users&quot; 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>&quot;</span><span style="color:#a3be8c;">100</span><span>&quot; </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>= &quot;</span><span style="color:#a3be8c;">&lt;YOUR PREFERRED USERNAME&gt;</span><span>&quot;; </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&#39;t be able to SSH </span><span> </span><span style="color:#d08770;">publicKeys </span><span>= [ </span><span> &quot;</span><span style="color:#a3be8c;">&lt;YOUR PUBLIC KEY&gt;</span><span>&quot; </span><span> &quot;</span><span style="color:#a3be8c;">&lt;OPTIONALLY ANY OTHER PUBLIC KEYS OF YOURS&gt;</span><span>&quot; </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>= [&quot;</span><span style="color:#a3be8c;">veth*</span><span>&quot;]; </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>= &quot;</span><span style="color:#a3be8c;">plex-on-hetz</span><span>&quot;; </span><span> </span><span> </span><span style="color:#65737e;"># This is so that users in the &quot;wheel&quot; group don&#39;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>= &quot;</span><span style="color:#a3be8c;">prohibit-password</span><span>&quot;; </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 &quot;root&quot; 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 &quot;wheel&quot; and &quot;docker&quot; groups </span><span> </span><span style="color:#65737e;"># In the &quot;wheel&quot; group, you don&#39;t need to use your password for sudo </span><span> </span><span style="color:#65737e;"># In the &quot;docker&quot; group, you don&#39;t need to use sudo for docker commands </span><span> </span><span style="color:#d08770;">extraGroups </span><span>= [&quot;</span><span style="color:#a3be8c;">wheel</span><span>&quot; &quot;</span><span style="color:#a3be8c;">docker</span><span>&quot;]; </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&#39;t get locked out by mistake </span><span> </span><span style="color:#d08770;">ignoreIP </span><span>= [&quot;</span><span style="color:#a3be8c;">&lt;YOUR HOME IP ADDRESS&gt;</span><span>&quot;]; </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>= &quot;</span><span style="color:#a3be8c;">docker</span><span>&quot;; </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>= &quot;</span><span style="color:#a3be8c;">qmcgaw/gluetun:v3.37.0</span><span>&quot;; </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> &quot;</span><span style="color:#a3be8c;">32400:32400</span><span>&quot; </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>= &quot;</span><span style="color:#a3be8c;">mullvad</span><span>&quot;; </span><span> </span><span style="color:#d08770;">VPN_TYPE </span><span>= &quot;</span><span style="color:#a3be8c;">wireguard</span><span>&quot;; </span><span> </span><span style="color:#65737e;"># </span><span style="font-weight:bold;color:#ebcb8b;">FIXME</span><span style="color:#65737e;">: Don&#39;t forget to add your connection details here! </span><span> </span><span style="color:#d08770;">WIREGUARD_PRIVATE_KEY </span><span>= &quot;</span><span style="color:#a3be8c;">&lt;Interface.PrivateKey in your downloaded conf file&gt;</span><span>&quot;; </span><span> </span><span style="color:#d08770;">WIREGUARD_ADDRESSES </span><span>= &quot;</span><span style="color:#a3be8c;">&lt;Interface.Address in your downloaded conf file&gt;</span><span>&quot;; </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> &quot;</span><span style="color:#a3be8c;">--cap-add=NET_ADMIN</span><span>&quot; </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>= &quot;</span><span style="color:#a3be8c;">plexinc/pms-docker:1.32.8.7639-fb6452ebf</span><span>&quot;; </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>&quot;</span><span style="color:#a3be8c;">users</span><span>&quot;; </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;"># &quot;/path/to/your/plex-nixos/config/dir:/config/Library/Application\ Support&quot; </span><span> &quot;</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>&quot; </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> &quot;</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>&quot; </span><span> &quot;</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>&quot; </span><span> &quot;</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>&quot; </span><span> ]; </span><span> </span><span style="color:#65737e;"># This is to make sure that this container won&#39;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>= [&quot;</span><span style="color:#a3be8c;">gluetun</span><span>&quot;]; </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&#39;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> &quot;</span><span style="color:#a3be8c;">--network=container:gluetun</span><span>&quot; </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 Ban Mon, 08 Jan 2024 14:59:14 -0800 lgug2z 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&amp;_m=df59a9eb59db44d2a0dbdbd0ebb61ee5&amp;_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>&quot;</span><span style="color:#a3be8c;">1000</span><span>&quot; </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>&quot;</span><span style="color:#a3be8c;">100</span><span>&quot; </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>= &quot;</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>&quot;; </span><span> </span><span style="color:#d08770;">plexData </span><span>= &quot;</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>&quot;; </span><span> </span><span style="color:#d08770;">remote </span><span>= &quot;</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>&quot;; </span><span> </span><span style="color:#d08770;">mount </span><span>= &quot;</span><span style="color:#a3be8c;">gsuite-encrypted</span><span>&quot;; </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>= &quot;</span><span style="color:#a3be8c;">22.05</span><span>&quot;; </span><span> </span><span style="color:#d08770;">time</span><span>.</span><span style="color:#d08770;">timeZone </span><span>= &quot;</span><span style="color:#a3be8c;">America/Los_Angeles</span><span>&quot;; </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>= &quot;</span><span style="color:#a3be8c;">/mnt</span><span>&quot;; </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> &quot;</span><span style="color:#a3be8c;">d &#39;</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;">&#39; 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>&quot; </span><span> &quot;</span><span style="color:#a3be8c;">d &#39;</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;">&#39; 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>&quot; </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>= &quot;</span><span style="color:#a3be8c;">1.32.8.7639-fb6452ebf</span><span>&quot;; </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>= &quot;</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>&quot;; </span><span> </span><span style="color:#d08770;">sha256 </span><span>= &quot;</span><span style="color:#a3be8c;">sha256-jdGVAdvm7kjxTP3CQ5w6dKZbfCRwSy9TrtxRHaV0/cs=</span><span>&quot;; </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>= &quot;</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>&quot;; </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>= &quot;</span><span style="color:#a3be8c;">users</span><span>&quot;; </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>= [&quot;</span><span style="color:#a3be8c;">multi-user.target</span><span>&quot;]; </span><span> </span><span style="color:#d08770;">serviceConfig </span><span>= { </span><span> </span><span style="color:#d08770;">ExecStartPre </span><span>= &quot;</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>&quot;; </span><span> </span><span style="color:#d08770;">ExecStart </span><span>= &#39;&#39; </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 &#39;</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&#39; </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;">.&quot;</span><span style="font-style:italic;color:#a3be8c;">rclone/rclone.conf</span><span style="font-style:italic;color:#c0c5ce;">&quot;.</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;">&quot;</span><span style="font-style:italic;color:#a3be8c;">users</span><span style="font-style:italic;color:#c0c5ce;">&quot;</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>&#39;&#39;; </span><span> </span><span style="color:#d08770;">ExecStop </span><span>= &quot;</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>&quot;; </span><span> </span><span style="color:#d08770;">Type </span><span>= &quot;</span><span style="color:#a3be8c;">notify</span><span>&quot;; </span><span> </span><span style="color:#d08770;">Restart </span><span>= &quot;</span><span style="color:#a3be8c;">always</span><span>&quot;; </span><span> </span><span style="color:#d08770;">RestartSec </span><span>= &quot;</span><span style="color:#a3be8c;">10s</span><span>&quot;; </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>.&quot;</span><span style="color:#a3be8c;">rclone/environment</span><span>&quot;.</span><span style="color:#bf616a;">path</span><span>]; </span><span> </span><span style="color:#d08770;">Environment </span><span>= [&quot;</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>&quot;]; </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>= [&quot;</span><span style="color:#a3be8c;">multi-user.target</span><span>&quot;]; </span><span> </span><span style="color:#d08770;">serviceConfig </span><span>= { </span><span> </span><span style="color:#d08770;">ExecStartPre </span><span>= &quot;</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>&quot;; </span><span> </span><span style="color:#d08770;">ExecStart </span><span>= &#39;&#39; </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 &#39;</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&#39; </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;">.&quot;</span><span style="font-style:italic;color:#a3be8c;">rclone/rclone.conf</span><span style="font-style:italic;color:#c0c5ce;">&quot;.</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;">&quot;</span><span style="font-style:italic;color:#a3be8c;">users</span><span style="font-style:italic;color:#c0c5ce;">&quot;</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>&#39;&#39;; </span><span> </span><span style="color:#d08770;">ExecStop </span><span>= &quot;</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>&quot;; </span><span> </span><span style="color:#d08770;">Type </span><span>= &quot;</span><span style="color:#a3be8c;">notify</span><span>&quot;; </span><span> </span><span style="color:#d08770;">Restart </span><span>= &quot;</span><span style="color:#a3be8c;">always</span><span>&quot;; </span><span> </span><span style="color:#d08770;">RestartSec </span><span>= &quot;</span><span style="color:#a3be8c;">10s</span><span>&quot;; </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>.&quot;</span><span style="color:#a3be8c;">rclone/environment</span><span>&quot;.</span><span style="color:#bf616a;">path</span><span>]; </span><span> </span><span style="color:#d08770;">Environment </span><span>= [&quot;</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>&quot;]; </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 -&gt; Remote Access -&gt; Enable Remote Access</code> since <code>192.168.111.1/24</code> addresses appear as "remote" to Plex</li> <li>For <code>Settings -&gt; Network -&gt; LAN Networks</code> I set <code>192.168.111.1/24</code></li> <li>For <code>Settings -&gt; Network -&gt; 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>= &quot;</span><span style="color:#a3be8c;">daily</span><span>&quot;; </span><span> </span><span style="color:#d08770;">serviceConfig </span><span>= { </span><span> </span><span style="color:#d08770;">Type </span><span>= &quot;</span><span style="color:#a3be8c;">oneshot</span><span>&quot;; </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>= &quot;</span><span style="color:#a3be8c;">users</span><span>&quot;; </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>.&quot;</span><span style="color:#a3be8c;">rclone/environment</span><span>&quot;.</span><span style="color:#bf616a;">path</span><span>]; </span><span> </span><span style="color:#d08770;">ExecStart </span><span>= &#39;&#39; </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;">.&quot;</span><span style="font-style:italic;color:#a3be8c;">rclone/rclone.conf</span><span style="font-style:italic;color:#c0c5ce;">&quot;.</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;"> &quot;</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&quot; \ </span><span style="color:#a3be8c;"> &quot;</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&quot; \ </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>&#39;&#39;; </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-Shell Mon, 04 Dec 2023 19:02:54 -0800 lgug2z 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&amp;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> &quot;</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>&quot; </span><span> &quot;</span><span style="color:#a3be8c;">nixos-config=/etc/nixos/configuration.nix</span><span>&quot; </span><span> &quot;</span><span style="color:#a3be8c;">/nix/var/nix/profiles/per-user/root/channels</span><span>&quot; </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 NixOS Thu, 16 Nov 2023 17:18:17 +0000 lgug2z 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>= &quot;</span><span style="color:#a3be8c;">sorry robots :(</span><span>&quot;; </span><span> </span><span style="color:#d08770;">userName </span><span>= &quot;</span><span style="color:#a3be8c;">LGUG2Z</span><span>&quot;; </span><span> </span><span style="color:#d08770;">extraConfig </span><span>= { </span><span> </span><span style="color:#d08770;">url </span><span>= { </span><span> &quot;</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>&quot; = { </span><span> </span><span style="color:#d08770;">insteadOf </span><span>= &quot;</span><span style="color:#a3be8c;">https://github.com</span><span>&quot;; </span><span> }; </span><span> &quot;</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>&quot; = { </span><span> </span><span style="color:#d08770;">insteadOf </span><span>= &quot;</span><span style="color:#a3be8c;">https://gitlab.com</span><span>&quot;; </span><span> }; </span><span> }; </span><span> </span><span style="color:#d08770;">push </span><span>= { </span><span> </span><span style="color:#d08770;">default </span><span>= &quot;</span><span style="color:#a3be8c;">current</span><span>&quot;; </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>= &quot;</span><span style="color:#a3be8c;">diff3</span><span>&quot;; </span><span> }; </span><span> </span><span style="color:#d08770;">diff </span><span>= { </span><span> </span><span style="color:#d08770;">colorMoved </span><span>= &quot;</span><span style="color:#a3be8c;">default</span><span>&quot;; </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>= &quot;</span><span style="color:#a3be8c;">/nix/store/68szslq3sv2gqlc5qzniwh1pf8hn70m2-delta-0.15.1/bin/delta</span><span>&quot; </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>= &quot;</span><span style="color:#a3be8c;">default</span><span>&quot; </span><span> </span><span>[interactive] </span><span> </span><span style="color:#bf616a;">diffFilter </span><span>= &quot;</span><span style="color:#a3be8c;">/nix/store/68szslq3sv2gqlc5qzniwh1pf8hn70m2-delta-0.15.1/bin/delta --color-only</span><span>&quot; </span><span> </span><span>[merge] </span><span> </span><span style="color:#bf616a;">conflictstyle </span><span>= &quot;</span><span style="color:#a3be8c;">diff3</span><span>&quot; </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>= &quot;</span><span style="color:#a3be8c;">current</span><span>&quot; </span><span> </span><span>[url</span><span style="background-color:#bf616a;color:#2b303b;"> </span><span>&quot;</span><span style="color:#a3be8c;">https://oauth2:[email protected]</span><span>&quot;] </span><span> </span><span style="color:#bf616a;">insteadOf </span><span>= &quot;</span><span style="color:#a3be8c;">https://github.com</span><span>&quot; </span><span> </span><span>[url</span><span style="background-color:#bf616a;color:#2b303b;"> </span><span>&quot;</span><span style="color:#a3be8c;">https://oauth2:[email protected]</span><span>&quot;] </span><span> </span><span style="color:#bf616a;">insteadOf </span><span>= &quot;</span><span style="color:#a3be8c;">https://gitlab.com</span><span>&quot; </span><span> </span><span>[user] </span><span> </span><span style="color:#bf616a;">email </span><span>= &quot;</span><span style="color:#a3be8c;">sorry robots :(</span><span>&quot; </span><span> </span><span style="color:#bf616a;">name </span><span>= &quot;</span><span style="color:#a3be8c;">LGUG2Z</span><span>&quot; </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>&quot;</span><span style="color:#a3be8c;">sync-dots</span><span>&quot; &#39;&#39; </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 &#39;s/\/nix\/store\/.*\/bin\///g&#39; /mnt/c/Users/LGUG2Z/.gitconfig </span><span style="color:#a3be8c;"> </span><span>&#39;&#39;; </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&amp;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 Overview Tue, 14 Nov 2023 05:25:13 +0000 lgug2z 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>&gt;./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>&gt;./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> &quot;</span><span style="color:#a3be8c;">github</span><span>&quot;: { </span><span> &quot;</span><span style="color:#a3be8c;">oauth_token</span><span>&quot;: &quot;</span><span style="color:#a3be8c;">ghp_...</span><span>&quot; </span><span> }, </span><span> &quot;</span><span style="color:#a3be8c;">gitlab</span><span>&quot;: { </span><span> &quot;</span><span style="color:#a3be8c;">oauth_token</span><span>&quot;: &quot;</span><span style="color:#a3be8c;">glpat-...</span><span>&quot; </span><span> }, </span><span> &quot;</span><span style="color:#a3be8c;">tailscale</span><span>&quot;: { </span><span> &quot;</span><span style="color:#a3be8c;">authkey</span><span>&quot;: &quot;</span><span style="color:#a3be8c;">tskey-auth-...</span><span>&quot; </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>= &quot;</span><span style="color:#a3be8c;">My NixOS configurations</span><span>&quot;; </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>= &quot;</span><span style="color:#a3be8c;">github:nixos/nixpkgs/nixos-23.05</span><span>&quot;; </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>= &quot;</span><span style="color:#a3be8c;">github:nixos/nixpkgs/nixos-unstable</span><span>&quot;; </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>&quot;</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>&quot;); </span><span> </span><span style="color:#b48ead;">in </span><span>{ </span><span> </span><span style="color:#65737e;"># make sure to &quot;inherit secrets;&quot; 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> &quot;</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>&quot; = { </span><span> </span><span style="color:#d08770;">insteadOf </span><span>= &quot;</span><span style="color:#a3be8c;">https://github.com</span><span>&quot;; </span><span> }; </span><span> &quot;</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>&quot; = { </span><span> </span><span style="color:#d08770;">insteadOf </span><span>= &quot;</span><span style="color:#a3be8c;">https://gitlab.com</span><span>&quot;; </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>.&quot;</span><span style="color:#a3be8c;">.npmrc</span><span>&quot;.</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>= &quot;</span><span style="color:#a3be8c;">ssh-rsa AAAA....</span><span>&quot;; </span><span> </span><span style="color:#d08770;">remote_server_key </span><span>= &quot;</span><span style="color:#a3be8c;">ssh-rsa AAAA....</span><span>&quot;; </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> &quot;</span><span style="color:#a3be8c;">guest_accounts.json.age</span><span>&quot;.</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>= &quot;</span><span style="color:#a3be8c;">github:ryantm/agenix</span><span>&quot;; </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>= &quot;</span><span style="color:#a3be8c;">x86_64-linux</span><span>&quot;; </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>.&quot;</span><span style="color:#a3be8c;">guest_accounts.json</span><span>&quot;.</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> &quot;</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;">.&quot;</span><span style="font-style:italic;color:#a3be8c;">guest_accounts.json</span><span style="font-style:italic;color:#c0c5ce;">&quot;.</span><span style="font-style:italic;color:#bf616a;">path</span><span style="font-style:italic;color:#ab7967;">}</span><span>&quot; </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> &quot;</span><span style="color:#a3be8c;">NITTER_CONF_FILE=/var/lib/private/nitter/nitter.conf</span><span>&quot; </span><span> &quot;</span><span style="color:#a3be8c;">NITTER_ACCOUNTS_FILE=%d/guest_account.json</span><span>&quot; </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>.&quot;</span><span style="color:#a3be8c;">update-lgug2z-com</span><span>&quot; = { </span><span> </span><span style="color:#d08770;">startAt </span><span>= &quot;</span><span style="color:#a3be8c;">hourly</span><span>&quot;; </span><span> </span><span style="color:#d08770;">serviceConfig </span><span>= { </span><span> </span><span style="color:#d08770;">Type </span><span>= &quot;</span><span style="color:#a3be8c;">oneshot</span><span>&quot;; </span><span> </span><span style="color:#d08770;">ExecStart </span><span>= &#39;&#39; </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 &quot;</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;">&#39;&#39;$</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;">.&quot;</span><span style="font-style:italic;color:#a3be8c;">cloudflare_account_id</span><span style="font-style:italic;color:#c0c5ce;">&quot;.</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;">&#39;&#39;$</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;">.&quot;</span><span style="font-style:italic;color:#a3be8c;">cloudflare_pages_api_token</span><span style="font-style:italic;color:#c0c5ce;">&quot;.</span><span style="font-style:italic;color:#bf616a;">path</span><span style="font-style:italic;color:#ab7967;">}</span><span style="color:#a3be8c;">)&quot; </span><span style="color:#a3be8c;"> </span><span>&#39;&#39;; </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>=&quot;</span><span style="color:#a3be8c;">LOCALE_ARCHIVE=/nix/store/fzm1flvb7zmz3ij4sscn521shz2f76jh-glibc-locales-2.37-45/lib/locale/locale-archive</span><span>&quot; </span><span style="color:#bf616a;">Environment</span><span>=&quot;</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>&quot; </span><span style="color:#bf616a;">Environment</span><span>=&quot;</span><span style="color:#a3be8c;">TZDIR=/nix/store/951696yxqlphz378fx126wjnrih08mz3-tzdata-2023c/share/zoneinfo</span><span>&quot; </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 &quot;/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)&quot;</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;">&amp;</span><span>remote </span><span style="color:#a3be8c;">age1... </span><span> - </span><span style="color:#b48ead;">&amp;</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;"> &quot;id&quot;: &quot;some-id&quot;, </span><span style="color:#a3be8c;"> &quot;token&quot;: &quot;some-token&quot;, </span><span style="color:#a3be8c;"> &quot;grants&quot;: [&quot;some&quot; &quot;grants&quot;], </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>= &quot;</span><span style="color:#a3be8c;">github:Mic92/sops-nix</span><span>&quot;; </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>= &quot;</span><span style="color:#a3be8c;">x86_64-linux</span><span>&quot;; </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>= [&quot;</span><span style="color:#a3be8c;">/etc/ssh/ssh_host_ed25519_key</span><span>&quot;]; </span><span> </span><span style="color:#d08770;">secrets </span><span>= { </span><span> &quot;</span><span style="color:#a3be8c;">github/oauth_token</span><span>&quot; = {}; </span><span> &quot;</span><span style="color:#a3be8c;">gitlab/oauth_token</span><span>&quot; = {}; </span><span> &quot;</span><span style="color:#a3be8c;">tailscale/authkey</span><span>&quot; = {}; </span><span> &quot;</span><span style="color:#a3be8c;">guest_accounts.json</span><span>&quot; = {}; </span><span> &quot;</span><span style="color:#a3be8c;">npmrc</span><span>&quot; = { </span><span> </span><span style="color:#d08770;">owner </span><span>= &quot;</span><span style="color:#a3be8c;">youruser</span><span>&quot;; </span><span> </span><span style="color:#d08770;">path </span><span>= &quot;</span><span style="color:#a3be8c;">/home/youruser/.npmrc</span><span>&quot;; </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 Services Sat, 11 Nov 2023 19:08:18 +0000 lgug2z 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>= &quot;</span><span style="color:#a3be8c;">ssh-rsa AAAA....</span><span>&quot;; </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> &quot;</span><span style="color:#a3be8c;">guest_accounts.json.age</span><span>&quot;.</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&amp;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 ? &quot;</span><span style="color:#a3be8c;">/home/&lt;YOUR_MOST_COMMON_USERNAME&gt;</span><span>&quot;, </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> &quot;</span><span style="color:#a3be8c;">/etc/ssh/ssh_host_ed25519_key</span><span>&quot; </span><span> &quot;</span><span style="color:#a3be8c;">/etc/ssh/ssh_host_rsa_key</span><span>&quot; </span><span> &quot;</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>&quot; </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/&lt;YOUR_USERNAME&gt;</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> &quot;</span><span style="color:#a3be8c;">guest_accounts.json</span><span>&quot;.</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> =&gt; linked with one or more public keys</li> <li>Step 2: <code>guest_accounts.json.age</code> file =&gt; 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 =&gt; 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> &quot;</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;">.&quot;</span><span style="font-style:italic;color:#a3be8c;">guest_accounts.json</span><span style="font-style:italic;color:#c0c5ce;">&quot;.</span><span style="font-style:italic;color:#bf616a;">path</span><span style="font-style:italic;color:#ab7967;">}</span><span>&quot; </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> &quot;</span><span style="color:#a3be8c;">NITTER_CONF_FILE=/var/lib/private/nitter/nitter.conf</span><span>&quot; </span><span> &quot;</span><span style="color:#a3be8c;">NITTER_ACCOUNTS_FILE=%d/guest_account.json</span><span>&quot; </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 NixOS Fri, 10 Nov 2023 19:44:15 +0000 lgug2z 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>= &quot;</span><span style="color:#a3be8c;">guest_accounts</span><span>&quot;; </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>= &quot;</span><span style="color:#a3be8c;">zedeus</span><span>&quot;; </span><span> </span><span style="color:#d08770;">repo </span><span>= &quot;</span><span style="color:#a3be8c;">nitter</span><span>&quot;; </span><span> </span><span style="color:#d08770;">rev </span><span>= &quot;</span><span style="color:#a3be8c;">eaedd2aee7be6bc3dd2dceee09dc93052d0046f4</span><span>&quot;; </span><span> </span><span style="color:#d08770;">hash </span><span>= &quot;</span><span style="color:#a3be8c;">sha256-px0wyCYiI03DefIIF9+Xr95ChyASvg9N//cARFyRM5I=</span><span>&quot;; </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>= &quot;</span><span style="color:#a3be8c;">oauth</span><span>&quot;; </span><span> </span><span style="color:#d08770;">version </span><span>= &quot;</span><span style="color:#a3be8c;">b8c163b0d9cfad6d29ce8c1fb394e5f47182ee1c</span><span>&quot;; </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>= &quot;</span><span style="color:#a3be8c;">CORDEA</span><span>&quot;; </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>= &quot;</span><span style="color:#a3be8c;">0k5slyzjngbdr6g0b0dykhqmaf8r8n2klbkg2gpid4ckm8hg62v5</span><span>&quot;; </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>= &quot;</span><span style="color:#a3be8c;">sha1</span><span>&quot;; </span><span> </span><span style="color:#d08770;">version </span><span>= &quot;</span><span style="color:#a3be8c;">92ccc5800bb0ac4865b275a2ce3c1544e98b48bc</span><span>&quot;; </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>= &quot;</span><span style="color:#a3be8c;">onionhammer</span><span>&quot;; </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>= &quot;</span><span style="color:#a3be8c;">sha256-tWHouIa6AFRmbvJaMsoWKNZX7bzqd3Je1kJ4rVHb+wM=</span><span>&quot;; </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>= &quot;</span><span style="color:#a3be8c;">subdomain.your.website</span><span>&quot;; </span><span> </span><span style="color:#d08770;">server</span><span>.</span><span style="color:#d08770;">port </span><span>= </span><span style="color:#a3be8c;">&lt;CUSTOM_PORT&gt;</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> &quot;</span><span style="color:#a3be8c;">https://subdomain.your.website</span><span>&quot;.</span><span style="color:#d08770;">extraConfig </span><span>= &#39;&#39; </span><span style="color:#a3be8c;"> reverse_proxy 127.0.0.1:&lt;CUSTOM_PORT&gt; </span><span style="color:#a3be8c;"> </span><span>&#39;&#39;; </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> &quot;</span><span style="color:#a3be8c;">NITTER_CONF_FILE=/var/lib/private/nitter/nitter.conf</span><span>&quot; </span><span> &quot;</span><span style="color:#a3be8c;">NITTER_ACCOUNTS_FILE=/var/lib/private/nitter/guest_accounts.json</span><span>&quot; </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 Sharing Wed, 08 Nov 2023 16:06:39 +0000 lgug2z 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&#x2F;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&#x2F;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&lt;String&gt;) -&gt; Result&lt;Response&lt;Body&gt;, AppError&gt; { </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!(&quot;</span><span style="color:#a3be8c;">about:blank</span><span>&quot;), </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>(&amp;format!(&quot;</span><span style="color:#a3be8c;">https://your.website/export-view/</span><span style="color:#d08770;">{id}</span><span>&quot;))?; </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>(&quot;</span><span style="color:#a3be8c;">#element-to-screenshot</span><span>&quot;)?; </span><span> </span><span> </span><span style="color:#b48ead;">let</span><span> element = tab.</span><span style="color:#96b5b4;">find_element</span><span>(&quot;</span><span style="color:#a3be8c;">#element-to-screenshot</span><span>&quot;)?; </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>(&quot;</span><span style="color:#a3be8c;">Content-Type</span><span>&quot;, &quot;</span><span style="color:#a3be8c;">image/png</span><span>&quot;) </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>) -&gt; Response { </span><span> ( </span><span> StatusCode::</span><span style="color:#d08770;">INTERNAL_SERVER_ERROR</span><span>, </span><span> format!(&quot;</span><span style="color:#a3be8c;">Something went wrong: </span><span style="color:#d08770;">{}</span><span>&quot;, </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>&lt;E&gt; From&lt;E&gt; </span><span style="color:#b48ead;">for </span><span>AppError </span><span style="color:#b48ead;">where </span><span> E: Into&lt;anyhow::Error&gt;, </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) -&gt; </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>= &quot;</span><span style="color:#a3be8c;">screenshotter</span><span>&quot;; </span><span> </span><span style="color:#d08770;">after </span><span>= [ </span><span> &quot;</span><span style="color:#a3be8c;">network.target</span><span>&quot; </span><span> ]; </span><span> </span><span> </span><span style="color:#d08770;">wantedBy </span><span>= [&quot;</span><span style="color:#a3be8c;">multi-user.target</span><span>&quot;]; </span><span> </span><span style="color:#d08770;">serviceConfig </span><span>= { </span><span> </span><span style="color:#d08770;">Type </span><span>= &quot;</span><span style="color:#a3be8c;">simple</span><span>&quot;; </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> &quot;</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>&quot; </span><span> ]; </span><span> </span><span style="color:#d08770;">ExecStart </span><span>= &quot;</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>&quot;; </span><span> </span><span style="color:#d08770;">Restart </span><span>= &quot;</span><span style="color:#a3be8c;">on-failure</span><span>&quot;; </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> &quot;</span><span style="color:#a3be8c;">https://screenshotter.your.website</span><span>&quot;.</span><span style="color:#d08770;">extraConfig </span><span>= &quot;</span><span style="color:#a3be8c;">reverse_proxy 127.0.0.1:&lt;SCREENSHOTTER_PORT&gt;</span><span>&quot;; </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 Gaza Wed, 01 Nov 2023 18:35:58 +0000 lgug2z 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 &quot;twitter.com/JoeBiden&quot; &quot;x.com/JoeBiden&quot; &quot;twitter.com/POTUS&quot; &quot;x.com/POTUS&quot; TAG &quot;dnc&quot; &quot;joe-biden&quot; </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 =&gt; 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 Support Mon, 30 Oct 2023 18:30:38 +0000 lgug2z 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&#39;t able to place installed binaries in the $PATH reliably 🤦 </span><span> </span><span>❯ &amp; &quot;</span><span style="color:#a3be8c;">C:\Program Files\Yubico\YubiKey Manager\ykman.exe</span><span>&quot; 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>= &quot;</span><span style="color:#a3be8c;">https://raw.githubusercontent.com/dorssel/usbipd-win/v3.1.0/Usbipd/wsl-scripts/auto-attach.sh</span><span>&quot;; </span><span> </span><span style="color:#d08770;">hash </span><span>= &quot;</span><span style="color:#a3be8c;">sha256-KJ0tEuY+hDJbBQtJj8nSNk17FHqdpDWTpy9/DLqUFaM=</span><span>&quot;; </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>&quot;</span><span style="color:#a3be8c;">USB/IP integration</span><span>&quot;; </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>= [&quot;</span><span style="color:#a3be8c;">4-1</span><span>&quot;]; </span><span> </span><span style="color:#d08770;">description </span><span>= &quot;</span><span style="color:#a3be8c;">Auto attach devices with provided Bus IDs.</span><span>&quot;; </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>&amp;&amp; </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>= &#39;&#39; </span><span style="color:#a3be8c;"> SUBSYSTEM==&quot;usb&quot;, MODE=&quot;0666&quot; </span><span style="color:#a3be8c;"> KERNEL==&quot;hidraw*&quot;, SUBSYSTEM==&quot;hidraw&quot;, TAG+=&quot;uaccess&quot;, MODE=&quot;0666&quot; </span><span style="color:#a3be8c;"> </span><span>&#39;&#39;; </span><span> }; </span><span> </span><span> </span><span style="color:#d08770;">systemd </span><span>= { </span><span> </span><span style="color:#d08770;">services</span><span>.&quot;</span><span style="color:#a3be8c;">usbip-auto-attach@</span><span>&quot; = { </span><span> </span><span style="color:#d08770;">description </span><span>= &quot;</span><span style="color:#a3be8c;">Auto attach device having busid %i with usbip</span><span>&quot;; </span><span> </span><span style="color:#d08770;">after </span><span>= [&quot;</span><span style="color:#a3be8c;">network.target</span><span>&quot;]; </span><span> </span><span> </span><span style="color:#d08770;">scriptArgs </span><span>= &quot;</span><span style="color:#a3be8c;">%i</span><span>&quot;; </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>= &#39;&#39; </span><span style="color:#a3be8c;"> busid=&quot;$1&quot; </span><span style="color:#a3be8c;"> ip=&quot;$(grep nameserver /etc/resolv.conf | cut -d&#39; &#39; -f2)&quot; </span><span style="color:#a3be8c;"> </span><span style="color:#a3be8c;"> echo &quot;Starting auto attach for busid $busid on $ip.&quot; </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;"> &quot;$ip&quot; &quot;$busid&quot; </span><span style="color:#a3be8c;"> </span><span>&#39;&#39;; </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: &quot;</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>&quot;) </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>= [&quot;</span><span style="color:#a3be8c;">9-4</span><span>&quot;]; </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:&#x2F;&#x2F;www.youtube.com&#x2F;embed&#x2F;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 Developers Fri, 22 Sep 2023 14:37:24 +0000 lgug2z 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>*?_-.[]~=/&amp;;!#$%^(){}&lt;&gt;</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>*?[]~=&amp;;!#$%^(){}&lt;&gt; </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>= &#39;&#39; </span><span style="color:#a3be8c;"> WORDCHARS=&#39;*?[]~=&amp;;!#$%^(){}&lt;&gt;&#39; </span><span style="color:#a3be8c;"> </span><span>&#39;&#39;; </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 Management Tue, 08 Aug 2023 16:58:54 +0000 lgug2z 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:&#x2F;&#x2F;www.youtube.com&#x2F;embed&#x2F;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;">&quot;&quot;&quot;Send a message to komorebi&quot;&quot;&quot; </span><span> subprocess.</span><span style="color:#bf616a;">check_output</span><span>(</span><span style="color:#b48ead;">f</span><span>&quot;</span><span style="color:#a3be8c;">komorebic.exe </span><span>{command}&quot;, </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>(&quot;</span><span style="color:#a3be8c;">focus left</span><span>&quot;) </span><span>focus right: user.</span><span style="color:#bf616a;">komorebic</span><span>(&quot;</span><span style="color:#a3be8c;">focus right</span><span>&quot;) </span><span>focus up: user.</span><span style="color:#bf616a;">komorebic</span><span>(&quot;</span><span style="color:#a3be8c;">focus up</span><span>&quot;) </span><span>focus down: user.</span><span style="color:#bf616a;">komorebic</span><span>(&quot;</span><span style="color:#a3be8c;">focus down</span><span>&quot;) </span><span> </span><span>move left: user.</span><span style="color:#bf616a;">komorebic</span><span>(&quot;</span><span style="color:#a3be8c;">move left</span><span>&quot;) </span><span>move right: user.</span><span style="color:#bf616a;">komorebic</span><span>(&quot;</span><span style="color:#a3be8c;">move right</span><span>&quot;) </span><span>move down: user.</span><span style="color:#bf616a;">komorebic</span><span>(&quot;</span><span style="color:#a3be8c;">move down</span><span>&quot;) </span><span>move up: user.</span><span style="color:#bf616a;">komorebic</span><span>(&quot;</span><span style="color:#a3be8c;">move up</span><span>&quot;) </span><span> </span><span style="color:#96b5b4;">next </span><span>workspace: user.</span><span style="color:#bf616a;">komorebic</span><span>(&quot;</span><span style="color:#a3be8c;">cycle-workspace next</span><span>&quot;) </span><span>previous workspace: user.</span><span style="color:#bf616a;">komorebic</span><span>(&quot;</span><span style="color:#a3be8c;">cycle-workspace previous</span><span>&quot;) </span><span> </span><span>toggle monocle: user.</span><span style="color:#bf616a;">komorebic</span><span>(&quot;</span><span style="color:#a3be8c;">toggle-monocle</span><span>&quot;) </span><span>promote: user.</span><span style="color:#bf616a;">komorebic</span><span>(&quot;</span><span style="color:#a3be8c;">promote</span><span>&quot;) </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 Manager Tue, 25 Jul 2023 13:41:46 +0000 lgug2z 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:&#x2F;&#x2F;user-images.githubusercontent.com&#x2F;13164844&#x2F;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 --------&gt; bspc &lt;------&gt; 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 --------&gt; komorebic &lt;------&gt; 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 &quot;Firefox.exe&quot; 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> &quot;</span><span style="color:#a3be8c;">app_specific_configuration_path</span><span>&quot;: &quot;</span><span style="color:#a3be8c;">$Env:USERPROFILE/applications.yaml</span><span>&quot;, </span><span> &quot;</span><span style="color:#a3be8c;">window_hiding_behaviour</span><span>&quot;: &quot;</span><span style="color:#a3be8c;">Cloak</span><span>&quot;, </span><span> &quot;</span><span style="color:#a3be8c;">cross_monitor_move_behaviour</span><span>&quot;: &quot;</span><span style="color:#a3be8c;">Insert</span><span>&quot;, </span><span> &quot;</span><span style="color:#a3be8c;">alt_focus_hack</span><span>&quot;: </span><span style="color:#d08770;">true</span><span>, </span><span> &quot;</span><span style="color:#a3be8c;">default_workspace_padding</span><span>&quot;: </span><span style="color:#d08770;">20</span><span>, </span><span> &quot;</span><span style="color:#a3be8c;">default_container_padding</span><span>&quot;: </span><span style="color:#d08770;">20</span><span>, </span><span> &quot;</span><span style="color:#a3be8c;">active_window_border</span><span>&quot;: </span><span style="color:#d08770;">false</span><span>, </span><span> &quot;</span><span style="color:#a3be8c;">active_window_border_colours</span><span>&quot;: { </span><span> &quot;</span><span style="color:#a3be8c;">single</span><span>&quot;: { &quot;</span><span style="color:#a3be8c;">r</span><span>&quot;: </span><span style="color:#d08770;">66</span><span>, &quot;</span><span style="color:#a3be8c;">g</span><span>&quot;: </span><span style="color:#d08770;">165</span><span>, &quot;</span><span style="color:#a3be8c;">b</span><span>&quot;: </span><span style="color:#d08770;">245 </span><span>}, </span><span> &quot;</span><span style="color:#a3be8c;">stack</span><span>&quot;: { &quot;</span><span style="color:#a3be8c;">r</span><span>&quot;: </span><span style="color:#d08770;">256</span><span>, &quot;</span><span style="color:#a3be8c;">g</span><span>&quot;: </span><span style="color:#d08770;">165</span><span>, &quot;</span><span style="color:#a3be8c;">b</span><span>&quot;: </span><span style="color:#d08770;">66 </span><span>}, </span><span> &quot;</span><span style="color:#a3be8c;">monocle</span><span>&quot;: { &quot;</span><span style="color:#a3be8c;">r</span><span>&quot;: </span><span style="color:#d08770;">255</span><span>, &quot;</span><span style="color:#a3be8c;">g</span><span>&quot;: </span><span style="color:#d08770;">51</span><span>, &quot;</span><span style="color:#a3be8c;">b</span><span>&quot;: </span><span style="color:#d08770;">153 </span><span>} </span><span> }, </span><span> &quot;</span><span style="color:#a3be8c;">monitors</span><span>&quot;: [ </span><span> { </span><span> &quot;</span><span style="color:#a3be8c;">workspaces</span><span>&quot;: [ </span><span> { &quot;</span><span style="color:#a3be8c;">name</span><span>&quot;: &quot;</span><span style="color:#a3be8c;">I</span><span>&quot;, &quot;</span><span style="color:#a3be8c;">layout</span><span>&quot;: &quot;</span><span style="color:#a3be8c;">BSP</span><span>&quot; }, </span><span> { &quot;</span><span style="color:#a3be8c;">name</span><span>&quot;: &quot;</span><span style="color:#a3be8c;">II</span><span>&quot;, &quot;</span><span style="color:#a3be8c;">layout</span><span>&quot;: &quot;</span><span style="color:#a3be8c;">VerticalStack</span><span>&quot; }, </span><span> { &quot;</span><span style="color:#a3be8c;">name</span><span>&quot;: &quot;</span><span style="color:#a3be8c;">III</span><span>&quot;, &quot;</span><span style="color:#a3be8c;">layout</span><span>&quot;: &quot;</span><span style="color:#a3be8c;">HorizontalStack</span><span>&quot; }, </span><span> { &quot;</span><span style="color:#a3be8c;">name</span><span>&quot;: &quot;</span><span style="color:#a3be8c;">IV</span><span>&quot;, &quot;</span><span style="color:#a3be8c;">layout</span><span>&quot;: &quot;</span><span style="color:#a3be8c;">UltrawideVerticalStack</span><span>&quot; }, </span><span> { &quot;</span><span style="color:#a3be8c;">name</span><span>&quot;: &quot;</span><span style="color:#a3be8c;">V</span><span>&quot;, &quot;</span><span style="color:#a3be8c;">layout</span><span>&quot;: &quot;</span><span style="color:#a3be8c;">Rows</span><span>&quot; } </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:&#x2F;&#x2F;www.youtube.com&#x2F;embed&#x2F;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:&#x2F;&#x2F;www.youtube.com&#x2F;embed&#x2F;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 Development Fri, 21 Jul 2023 19:17:20 +0000 lgug2z 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>&quot;</span><span style="color:#a3be8c;">https://nixos.org/channels/nixos-unstable/nixexprs.tar.xz</span><span>&quot;) {}</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>= &quot;</span><span style="color:#a3be8c;">https://github.com/NixOS/nixpkgs/archive/8ad5e8132c5dcf977e308e7bf5517cc6cc0bf7d8.tar.gz</span><span>&quot;; </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>= &quot;</span><span style="color:#a3be8c;">notado</span><span>&quot;; </span><span> </span><span> </span><span style="color:#d08770;">MEILI_MASTER_KEY </span><span>= &quot;</span><span style="color:#a3be8c;">default</span><span>&quot;; </span><span> </span><span style="color:#d08770;">MEILI_DB_PATH </span><span>= &quot;</span><span style="color:#a3be8c;">data.ms</span><span>&quot;; </span><span> </span><span style="color:#d08770;">PGDATA </span><span>= &quot;</span><span style="color:#a3be8c;">data.pg</span><span>&quot;; </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 -&gt; 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 &quot;88b 888P&quot; d88P&quot; 888 &quot;88b </span><span>meilisearch | 888 Y8P 888 88888888 888 888 888 &quot;Y8888b. 88888888 .d888888 888 888 888 888 </span><span>meilisearch | 888 &quot; 888 Y8b. 888 888 888 X88 Y8b. 888 888 888 Y88b. 888 888 </span><span>meilisearch | 888 888 &quot;Y8888 888 888 888 88888P&#39; &quot;Y8888 &quot;Y888888 888 &quot;Y8888P 888 888 </span><span>meilisearch | </span><span>meilisearch | Config file path: &quot;none&quot; </span><span>meilisearch | Database path: &quot;data.ms&quot; </span><span>meilisearch | Server listening on: &quot;http://localhost:7700&quot; </span><span>meilisearch | Environment: &quot;development&quot; </span><span>meilisearch | Commit SHA: &quot;unknown&quot; </span><span>meilisearch | Commit date: &quot;unknown&quot; </span><span>meilisearch | Package version: &quot;1.0.2&quot; </span><span>meilisearch | </span><span>meilisearch | A master key has been set. Requests to Meilisearch won&#39;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 | &gt;&gt; --master-key YYzxDVu7YNEHyozuGU2itFVW-vnkvAzQVbCMCeOxZzI &lt;&lt; </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 &quot;::1&quot;, port 5432 </span><span>postgres | 2023-07-21 19:30:20.554 UTC [2073407] LOG: listening on IPv4 address &quot;127.0.0.1&quot;, 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 &quot;/tmp/.s.PGSQL.5432&quot; </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 &amp;&amp; </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 &amp;&amp; </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 &amp;&amp; </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 &amp;&amp; </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 &amp;&amp; </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 -&gt; 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, Archiving Mon, 17 Jul 2023 15:17:59 +0000 lgug2z 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&amp;page=0&amp;prefix=true&amp;query=pinboard%20archiving&amp;sort=byDate&amp;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 Hole Sun, 16 Jul 2023 03:39:35 +0000 lgug2z 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 Highlighting Sat, 13 May 2023 07:25:26 -0700 lgug2z 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:&#x2F;&#x2F;www.youtube.com&#x2F;embed&#x2F;-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:&#x2F;&#x2F;www.youtube.com&#x2F;embed&#x2F;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&#x2F;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 Reviews Thu, 11 May 2023 16:12:17 -0700 lgug2z 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> &quot;</span><span style="color:#a3be8c;">permissions</span><span>&quot;: [ </span><span> &quot;</span><span style="color:#a3be8c;">activeTab</span><span>&quot;, </span><span> &quot;</span><span style="color:#a3be8c;">contextMenus</span><span>&quot;, </span><span> &quot;</span><span style="color:#a3be8c;">notifications</span><span>&quot;, </span><span> &quot;</span><span style="color:#a3be8c;">cookies</span><span>&quot;, </span><span> &quot;</span><span style="color:#a3be8c;">https://&lt;SPECIFIC_WEBSITE_RUN_BY_ME&gt;/</span><span>&quot; </span><span> ] </span><span>} </span></code></pre> <p>I request the cookies permission for <code>https://&lt;SPECIFIC_WEBSITE_RUN_BY_ME&gt;/</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: &quot;</span><span style="color:#a3be8c;">POST</span><span>&quot;, </span><span> headers: { &quot;</span><span style="color:#a3be8c;">Content-Type</span><span>&quot;: &quot;</span><span style="color:#a3be8c;">application/json</span><span>&quot; }, </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: &quot;</span><span style="color:#a3be8c;">sid</span><span>&quot;, </span><span> url: &quot;</span><span style="color:#a3be8c;">https://&lt;SPECIFIC_WEBSITE_RUN_BY_ME&gt;</span><span>&quot;, </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 Website Tue, 09 May 2023 15:26:54 -0700 lgug2z 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:&#x2F;&#x2F;www.youtube.com&#x2F;embed&#x2F;\_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>&quot;</span><span style="color:#a3be8c;">url</span><span>&quot; }} </span><span> {{ $</span><span style="color:#bf616a;">url </span><span>:= .</span><span style="color:#bf616a;">Get </span><span>&quot;</span><span style="color:#a3be8c;">url</span><span>&quot; }} </span><span> {{ $</span><span style="color:#bf616a;">limit </span><span>:= .</span><span style="color:#bf616a;">Get </span><span>&quot;</span><span style="color:#a3be8c;">limit</span><span>&quot; }} </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> &lt;div style=&quot;font-size: small; padding: 10px; background-color: #373641; word-wrap: break-word;&quot;&gt; </span><span> &lt;p&gt; </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> &lt;/p&gt; </span><span> &lt;p&gt;(&lt;a href=&quot;{{ .</span><span style="color:#bf616a;">link </span><span>}}&quot;&gt;View&lt;/a&gt;)&lt;/p&gt; </span><span> &lt;/div&gt; </span><span> &lt;br /&gt; </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>{{ &lt;</span><span style="color:#bf616a;">notado url</span><span>=&quot;</span><span style="color:#a3be8c;">https://notado.app/rss/jado/software-development/feed.rss</span><span>&quot; </span><span style="color:#bf616a;">limit</span><span>=&quot;</span><span style="color:#a3be8c;">3</span><span>&quot;&gt; }} </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> &quot;</span><span style="color:#a3be8c;">https://api.cloudflare.com/client/v4/accounts/{account_id}/pages/projects/{project_name}/deployments</span><span>&quot;; </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: &quot;</span><span style="color:#a3be8c;">POST</span><span>&quot;, </span><span> headers: { </span><span> &quot;</span><span style="color:#a3be8c;">Content-Type</span><span>&quot;: &quot;</span><span style="color:#a3be8c;">application/json;charset=UTF-8</span><span>&quot;, </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>.&quot;</span><span style="color:#a3be8c;">update-lgug2z-com</span><span>&quot; = { </span><span> </span><span style="color:#d08770;">startAt </span><span>= &quot;</span><span style="color:#a3be8c;">hourly</span><span>&quot;; </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>= &quot;</span><span style="color:#a3be8c;">oneshot</span><span>&quot;; </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>= &#39;&#39; </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;">&#39;&#39;$</span><span style="color:#a3be8c;">{CLOUDFLARE_API_TOKEN} </span><span style="color:#a3be8c;"> </span><span>&#39;&#39;; </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 Aesthetics Thu, 01 Dec 2022 00:00:00 +0000 lgug2z 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:&#x2F;&#x2F;substackcdn.com&#x2F;image&#x2F;fetch&#x2F;f_auto,q_auto:good,fl_progressive:steep&#x2F;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&amp;utm_medium=usertext&amp;utm_name=bodyweightfitness&amp;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 Packages Mon, 28 Nov 2022 00:00:00 +0000 lgug2z 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:&#x2F;&#x2F;substackcdn.com&#x2F;image&#x2F;fetch&#x2F;f_auto,q_auto:good,fl_progressive:steep&#x2F;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:&#x2F;&#x2F;substackcdn.com&#x2F;image&#x2F;fetch&#x2F;f_auto,q_auto:good,fl_progressive:steep&#x2F;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:&#x2F;&#x2F;substackcdn.com&#x2F;image&#x2F;fetch&#x2F;f_auto,q_auto:good,fl_progressive:steep&#x2F;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>&quot;</span><span style="color:#a3be8c;">https://github.com/NixOS/nixpkgs/archive/b5b7bd6ebba2a165e33726b570d7ab35177cf951.tar.gz</span><span>&quot;) {}</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>= &quot;</span><span style="color:#a3be8c;">jadeiqbal.com</span><span>&quot;; </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:&#x2F;&#x2F;substackcdn.com&#x2F;image&#x2F;fetch&#x2F;w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep&#x2F;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 Twitter Tue, 15 Nov 2022 00:00:00 +0000 lgug2z 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:&#x2F;&#x2F;substackcdn.com&#x2F;image&#x2F;fetch&#x2F;f_auto,q_auto:good,fl_progressive:steep&#x2F;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 Online Thu, 10 Nov 2022 00:00:00 +0000 lgug2z 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:&#x2F;&#x2F;substackcdn.com&#x2F;image&#x2F;fetch&#x2F;f_auto,q_auto:good,fl_progressive:steep&#x2F;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:&#x2F;&#x2F;substackcdn.com&#x2F;image&#x2F;fetch&#x2F;f_auto,q_auto:good,fl_progressive:steep&#x2F;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 Fiction Sun, 06 Nov 2022 00:00:00 +0000 lgug2z 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 Cult Fri, 04 Nov 2022 00:00:00 +0000 lgug2z 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 Language Tue, 01 Nov 2022 00:00:00 +0000 lgug2z 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&amp;_t=8X0Rgd2eQ39&amp;is_from_webapp=v1&amp;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 Communities Fri, 28 Oct 2022 00:00:00 +0000 lgug2z 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 Powerlifting Sun, 19 Nov 2017 11:11:11 +0100 lgug2z 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 Surplus Tue, 20 Jun 2017 08:49:42 +0100 lgug2z 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 Scrolling Wed, 03 May 2017 14:06:38 +0100 lgug2z 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 Italian Fri, 07 Apr 2017 20:23:22 +0100 lgug2z 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 Systems Mon, 27 Mar 2017 12:12:05 +0100 lgug2z 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 Magnification Sun, 26 Mar 2017 10:07:25 +0100 lgug2z 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 Goals Tue, 21 Mar 2017 07:53:47 +0000 lgug2z 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 Vacation Fri, 17 Mar 2017 07:55:21 +0000 lgug2z 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> Blockers Thu, 16 Mar 2017 14:29:59 +0000 lgug2z 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 Terraform Tue, 14 Mar 2017 12:23:53 +0000 lgug2z 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&amp;*">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 &quot;certificate&quot; {} </span><span>variable &quot;deployer&quot; {} </span><span>variable &quot;domain&quot; {} </span><span>variable &quot;duplicate_content_penalty_secret&quot; {} </span><span>variable &quot;region&quot; {} </span></code></pre> <p><code>terraform.tfvars</code></p> <pre style="background-color:#2b303b;color:#c0c5ce;"><code><span>certificate = &quot;ARN of your certificate&quot; </span><span>deployer = &quot;Name of your deploying IAM user&quot; </span><span>domain = &quot;yourdomain.com&quot; </span><span>duplicate_content_penalty_secret = &quot;Some random value&quot; </span><span>region = &quot;AWS region code&quot; </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 &quot;aws&quot; { </span><span> region = &quot;${var.region}&quot; </span><span>} </span><span> </span><span>module &quot;deployment_user&quot; { </span><span> source = &quot;./iam&quot; </span><span> </span><span> deployment_user_name = &quot;${var.deployment_user_name}&quot; </span><span> domain = &quot;${var.domain}&quot; </span><span>} </span><span> </span><span>module &quot;site_bare_url&quot; { </span><span> source = &quot;./site_bare_url&quot; </span><span> </span><span> acm_certificate_arn = &quot;${var.certificate_arn}&quot; </span><span> domain = &quot;${var.domain}&quot; </span><span> duplicate_content_penalty_secret = &quot;${var.duplicate_content_penalty_secret}&quot; </span><span> not_found_response_path = &quot;/404.html&quot; </span><span>} </span><span> </span><span>module &quot;site_www_url&quot; { </span><span> source = &quot;./site_www_url&quot; </span><span> </span><span> acm_certificate_arn = &quot;${var.certificate_arn}&quot; </span><span> deployer = &quot;${var.deployment_user_name}&quot; </span><span> domain = &quot;www.${var.domain}&quot; </span><span> duplicate_content_penalty_secret = &quot;${var.duplicate_content_penalty_secret}&quot; </span><span> target = &quot;${var.domain}&quot; </span><span>} </span><span> </span><span>module &quot;route_53&quot; { </span><span> source = &quot;./route_53&quot; </span><span> </span><span> bare_url_domain = &quot;${var.domain}&quot; </span><span> bare_url_target = &quot;${module.site_bare_url.website_cdn_hostname}&quot; </span><span> bare_url_cdn_hosted_zone_id = &quot;${module.site_bare_url.website_cdn_zone_id}&quot; </span><span> </span><span> www_url_domain = &quot;www.${var.domain}&quot; </span><span> www_url_target = &quot;${module.site_www_url.website_cdn_hostname}&quot; </span><span> www_url_cdn_hosted_zone_id = &quot;${module.site_www_url.website_cdn_zone_id}&quot; </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> &quot;Version&quot;: &quot;2012-10-17&quot;, </span><span> &quot;Statement&quot;: [ </span><span> { </span><span> &quot;Action&quot;: [ </span><span> &quot;s3:ListBucket&quot; </span><span> ], </span><span> &quot;Effect&quot;: &quot;Allow&quot;, </span><span> &quot;Resource&quot;: &quot;arn:aws:s3:::${bucket_name}&quot; </span><span> }, </span><span> { </span><span> &quot;Action&quot;: [ </span><span> &quot;s3:DeleteObject&quot;, </span><span> &quot;s3:GetObject&quot;, </span><span> &quot;s3:GetObjectAcl&quot;, </span><span> &quot;s3:ListBucket&quot;, </span><span> &quot;s3:PutObject&quot;, </span><span> &quot;s3:PutObjectAcl&quot; </span><span> ], </span><span> &quot;Effect&quot;: &quot;Allow&quot;, </span><span> &quot;Resource&quot;: &quot;arn:aws:s3:::${bucket_name}/*&quot; </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 &quot;aws_iam_user&quot; &quot;deployment_user&quot; { </span><span> name = &quot;${var.deployment_user_name}&quot; </span><span>} </span><span> </span><span>resource &quot;aws_iam_access_key&quot; &quot;deployment_user_access_key&quot; { </span><span> user = &quot;${aws_iam_user.deployment_user.name}&quot; </span><span>} </span><span> </span><span>data &quot;template_file&quot; &quot;deployment_policy_template_file&quot; { </span><span> template = &quot;${file(&quot;${path.module}/deployment_policy.json&quot;)}&quot; </span><span> vars { </span><span> bucket_name = &quot;${var.domain}&quot; </span><span> } </span><span>} </span><span> </span><span>resource &quot;aws_iam_policy&quot; &quot;deployment_policy&quot; { </span><span> name = &quot;${replace(&quot;${var.domain}&quot;,&quot;.&quot;,&quot;-&quot;)}-deployment-policy&quot; </span><span> path = &quot;/&quot; </span><span> description = &quot;Policy allowing to publish a new version of the website to the S3 bucket&quot; </span><span> policy = &quot;${data.template_file.deployment_policy_template_file.rendered}&quot; </span><span>} </span><span> </span><span>resource &quot;aws_iam_policy_attachment&quot; &quot;deployment_policy_attachment&quot; { </span><span> name = &quot;${replace(&quot;${var.domain}&quot;,&quot;.&quot;,&quot;-&quot;)}-deployment-policy-attachment&quot; </span><span> users = [&quot;${aws_iam_user.deployment_user.name}&quot;] </span><span> policy_arn = &quot;${aws_iam_policy.deployment_policy.arn}&quot; </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> &quot;Version&quot;: &quot;2012-10-17&quot;, </span><span> &quot;Statement&quot;: [ </span><span> { </span><span> &quot;Sid&quot;: &quot;PublicReadAccess&quot;, </span><span> &quot;Principal&quot;: { </span><span> &quot;AWS&quot;: &quot;*&quot; </span><span> }, </span><span> &quot;Effect&quot;: &quot;Allow&quot;, </span><span> &quot;Action&quot;: [ </span><span> &quot;s3:GetObject&quot; </span><span> ], </span><span> &quot;Resource&quot;: &quot;arn:aws:s3:::${bucket_name}/*&quot;, </span><span> &quot;Condition&quot;: { </span><span> &quot;StringEquals&quot;: { </span><span> &quot;aws:UserAgent&quot;: &quot;${secret}&quot; </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 &quot;template_file&quot; &quot;bucket_policy_template_file&quot; { </span><span> template = &quot;${file(&quot;${path.module}/website_bucket_policy.json&quot;)}&quot; </span><span> vars { </span><span> bucket_name = &quot;${var.domain}&quot; </span><span> secret = &quot;${var.duplicate_content_penalty_secret}&quot; </span><span> } </span><span>} </span><span> </span><span>resource &quot;aws_s3_bucket&quot; &quot;bare_url_bucket&quot; { </span><span> bucket = &quot;${var.domain}&quot; </span><span> policy = &quot;${data.template_file.bucket_policy_template_file.rendered}&quot; </span><span> force_destroy = true </span><span> </span><span> website { </span><span> index_document = &quot;index.html&quot; </span><span> error_document = &quot;404.html&quot; </span><span> } </span><span> </span><span> tags { </span><span> Name = &quot;Bare URL bucket for static site ${var.domain}&quot; </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 &quot;aws_cloudfront_distribution&quot; &quot;bare_url_cdn&quot; { </span><span> enabled = true </span><span> price_class = &quot;PriceClass_200&quot; </span><span> http_version = &quot;http1.1&quot; </span><span> </span><span> &quot;origin&quot; { </span><span> origin_id = &quot;S3-${aws_s3_bucket.bare_url_bucket.id}&quot; </span><span> domain_name = &quot;${aws_s3_bucket.bare_url_bucket.website_endpoint}&quot; </span><span> custom_origin_config { </span><span> origin_protocol_policy = &quot;http-only&quot; </span><span> http_port = &quot;80&quot; </span><span> https_port = &quot;443&quot; </span><span> origin_ssl_protocols = [ </span><span> &quot;TLSv1&quot;] </span><span> } </span><span> custom_header { </span><span> name = &quot;User-Agent&quot; </span><span> value = &quot;${var.duplicate_content_penalty_secret}&quot; </span><span> } </span><span> } </span><span> default_root_object = &quot;index.html&quot; </span><span> custom_error_response { </span><span> error_code = &quot;404&quot; </span><span> error_caching_min_ttl = &quot;360&quot; </span><span> response_code = &quot;200&quot; </span><span> response_page_path = &quot;/404.html&quot; </span><span> } </span><span> &quot;default_cache_behavior&quot; { </span><span> allowed_methods = [ </span><span> &quot;GET&quot;, </span><span> &quot;HEAD&quot;, </span><span> &quot;DELETE&quot;, </span><span> &quot;OPTIONS&quot;, </span><span> &quot;PATCH&quot;, </span><span> &quot;POST&quot;, </span><span> &quot;PUT&quot;] </span><span> cached_methods = [ </span><span> &quot;GET&quot;, </span><span> &quot;HEAD&quot;] </span><span> &quot;forwarded_values&quot; { </span><span> query_string = false </span><span> cookies { </span><span> forward = &quot;none&quot; </span><span> } </span><span> } </span><span> min_ttl = &quot;0&quot; </span><span> default_ttl = &quot;300&quot; </span><span> max_ttl = &quot;1200&quot; </span><span> target_origin_id = &quot;S3-${aws_s3_bucket.bare_url_bucket.id}&quot; </span><span> viewer_protocol_policy = &quot;redirect-to-https&quot; </span><span> compress = true </span><span> } </span><span> &quot;restrictions&quot; { </span><span> &quot;geo_restriction&quot; { </span><span> restriction_type = &quot;none&quot; </span><span> } </span><span> } </span><span> &quot;viewer_certificate&quot; { </span><span> acm_certificate_arn = &quot;${var.acm_certificate_arn}&quot; </span><span> ssl_support_method = &quot;sni-only&quot; </span><span> minimum_protocol_version = &quot;TLSv1&quot; </span><span> } </span><span> aliases = [ </span><span> &quot;${var.domain}&quot;] </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 &quot;website_cdn_hostname&quot; { </span><span> value = &quot;${aws_cloudfront_distribution.bare_url_cdn.domain_name}&quot; </span><span>} </span><span> </span><span>output &quot;website_cdn_zone_id&quot; { </span><span> value = &quot;${aws_cloudfront_distribution.bare_url_cdn.hosted_zone_id}&quot; </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> &quot;Version&quot;:&quot;2012-10-17&quot;, </span><span> &quot;Statement&quot;:[ </span><span> { </span><span> &quot;Sid&quot;:&quot;PublicReadAccess&quot;, </span><span> &quot;Principal&quot;:{ </span><span> &quot;AWS&quot;:&quot;*&quot; </span><span> }, </span><span> &quot;Effect&quot;:&quot;Allow&quot;, </span><span> &quot;Action&quot;:[ </span><span> &quot;s3:GetObject&quot; </span><span> ], </span><span> &quot;Resource&quot;:&quot;arn:aws:s3:::${bucket_name}/*&quot; </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 &quot;template_file&quot; &quot;bucket_policy_template_file&quot; { </span><span> template = &quot;${file(&quot;${path.module}/website_redirect_bucket_policy.json&quot;)}&quot; </span><span> vars { </span><span> bucket_name = &quot;${var.domain}&quot; </span><span> } </span><span>} </span><span> </span><span>resource &quot;aws_s3_bucket&quot; &quot;www_url_bucket&quot; { </span><span> bucket = &quot;${var.domain}&quot; </span><span> policy = &quot;${data.template_file.bucket_policy_template_file.rendered}&quot; </span><span> force_destroy = true </span><span> </span><span> website { </span><span> redirect_all_requests_to = &quot;https://${var.target}&quot; </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 &quot;aws_cloudfront_distribution&quot; &quot;www_url_cdn&quot; { </span><span> enabled = true </span><span> price_class = &quot;PriceClass_200&quot; </span><span> http_version = &quot;http1.1&quot; </span><span> </span><span> &quot;origin&quot; { </span><span> origin_id = &quot;S3-${aws_s3_bucket.www_url_bucket.id}&quot; </span><span> domain_name = &quot;${aws_s3_bucket.www_url_bucket.website_endpoint}&quot; </span><span> custom_origin_config { </span><span> origin_protocol_policy = &quot;http-only&quot; </span><span> http_port = &quot;80&quot; </span><span> https_port = &quot;443&quot; </span><span> origin_ssl_protocols = [ </span><span> &quot;TLSv1&quot;] </span><span> } </span><span> custom_header { </span><span> name = &quot;User-Agent&quot; </span><span> value = &quot;${var.duplicate_content_penalty_secret}&quot; </span><span> } </span><span> } </span><span> default_root_object = &quot;index.html&quot; </span><span> custom_error_response { </span><span> error_code = &quot;404&quot; </span><span> error_caching_min_ttl = &quot;360&quot; </span><span> response_code = &quot;200&quot; </span><span> response_page_path = &quot;/404.html&quot; </span><span> } </span><span> &quot;default_cache_behavior&quot; { </span><span> allowed_methods = [ </span><span> &quot;GET&quot;, </span><span> &quot;HEAD&quot;, </span><span> &quot;DELETE&quot;, </span><span> &quot;OPTIONS&quot;, </span><span> &quot;PATCH&quot;, </span><span> &quot;POST&quot;, </span><span> &quot;PUT&quot;] </span><span> cached_methods = [ </span><span> &quot;GET&quot;, </span><span> &quot;HEAD&quot;] </span><span> &quot;forwarded_values&quot; { </span><span> query_string = false </span><span> cookies { </span><span> forward = &quot;none&quot; </span><span> } </span><span> } </span><span> min_ttl = &quot;0&quot; </span><span> default_ttl = &quot;300&quot; </span><span> max_ttl = &quot;1200&quot; </span><span> target_origin_id = &quot;S3-${aws_s3_bucket.www_url_bucket.id}&quot; </span><span> viewer_protocol_policy = &quot;redirect-to-https&quot; </span><span> compress = true </span><span> } </span><span> &quot;restrictions&quot; { </span><span> &quot;geo_restriction&quot; { </span><span> restriction_type = &quot;none&quot; </span><span> } </span><span> } </span><span> &quot;viewer_certificate&quot; { </span><span> acm_certificate_arn = &quot;${var.acm_certificate_arn}&quot; </span><span> ssl_support_method = &quot;sni-only&quot; </span><span> minimum_protocol_version = &quot;TLSv1&quot; </span><span> } </span><span> aliases = [ </span><span> &quot;${var.domain}&quot;] </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 &quot;website_cdn_hostname&quot; { </span><span> value = &quot;${aws_cloudfront_distribution.www_url_cdn.domain_name}&quot; </span><span>} </span><span> </span><span>output &quot;website_cdn_zone_id&quot; { </span><span> value = &quot;${aws_cloudfront_distribution.www_url_cdn.hosted_zone_id}&quot; </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 &quot;aws_route53_zone&quot; &quot;main_zone&quot; { </span><span> name = &quot;${var.bare_url_domain}&quot; </span><span>} </span><span> </span><span>resource &quot;aws_route53_record&quot; &quot;bare_url_cdn_alias_record&quot; { </span><span> zone_id = &quot;${aws_route53_zone.main_zone.zone_id}&quot; </span><span> name = &quot;${var.bare_url_domain}&quot; </span><span> type = &quot;A&quot; </span><span> </span><span> alias { </span><span> name = &quot;${var.bare_url_target}&quot; </span><span> zone_id = &quot;${var.bare_url_cdn_hosted_zone_id}&quot; </span><span> evaluate_target_health = false </span><span> } </span><span>} </span><span> </span><span>resource &quot;aws_route53_record&quot; &quot;www_url_cdn_alias_record&quot; { </span><span> zone_id = &quot;${aws_route53_zone.main_zone.zone_id}&quot; </span><span> name = &quot;${var.www_url_domain}&quot; </span><span> type = &quot;A&quot; </span><span> </span><span> alias { </span><span> name = &quot;${var.www_url_target}&quot; </span><span> zone_id = &quot;${var.www_url_cdn_hosted_zone_id}&quot; </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> Italian Tue, 28 Feb 2017 05:10:41 +0000 lgug2z 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> Comfort Sun, 26 Feb 2017 09:22:29 +0000 lgug2z 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 Editing Wed, 22 Feb 2017 07:05:33 +0000 lgug2z 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 Days Mon, 20 Feb 2017 07:22:54 +0000 lgug2z 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> Expectations Sat, 18 Feb 2017 00:00:00 +0000 lgug2z 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 Consumption Tue, 14 Feb 2017 00:00:00 +0000 lgug2z 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> Length Mon, 13 Feb 2017 00:00:00 +0000 lgug2z 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> Portability Sun, 29 Jan 2017 00:00:00 +0000 lgug2z 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> Expectations Sat, 14 Jan 2017 00:00:00 +0000 lgug2z 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 Time Mon, 26 Dec 2016 00:00:00 +0000 lgug2z 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>