<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom"><generator uri="https://jekyllrb.com/" version="4.3.4">Jekyll</generator><link href="https://nathanfriend.com/feed.xml" rel="self" type="application/atom+xml"/><link href="https://nathanfriend.com/" rel="alternate" type="text/html"/><updated>2025-01-06T15:22:52-05:00</updated><id>https://nathanfriend.com/feed.xml</id><title type="html">Nathan Friend</title><subtitle>Remote Software Engineer at Stripe and cellist based out of Ontario. Previously at GitLab. Fascinated with building usable, delightful software.</subtitle><entry><title type="html">Useful things I’ve 3D printed</title><link href="https://nathanfriend.com/2024/11/25/useful-things-ive-3d-printed.html" rel="alternate" type="text/html" title="Useful things I’ve 3D printed"/><published>2024-11-25T08:48:41-05:00</published><updated>2024-11-25T08:48:41-05:00</updated><id>https://nathanfriend.com/2024/11/25/useful-things-ive-3d-printed</id><content type="html" xml:base="https://nathanfriend.com/2024/11/25/useful-things-ive-3d-printed.html"><![CDATA[<p>3D printers are <em>ridiculously</em> cool. There’s something about seeing a digital object slowly manifest in the real world that is pure magic.</p> <p>But they’re also <em>useful</em>. Being able to custom manufacture replacement parts can save otherwise perfectly-working equipment from the landfill.</p> <p>I’ve written before about some of the things I’ve 3D printed, including my <a href="/https/nathanfriend.com/2024/05/31/zoom-light.html">Zoom light</a> and my <a href="/https/nathanfriend.com/2023/06/26/i-built-a-weird-keyboard.html">weird keyboard</a>; here are some other useful things I’ve printed:</p> <h2 id="monitor-arm-adapter">Monitor arm adapter</h2> <p>My main monitor - a <a href="https://www.samsung.com/nz/business/monitors/flat/uhd-lu28e590ds-xy/">Samsung U28E590D</a> - has a terrible design flaw. Its <a href="https://en.wikipedia.org/wiki/Flat_Display_Mounting_Interface">VESA mount</a> is at the very top of the monitor! This means that when connected to my <a href="https://store.hermanmiller.com/office-furniture-desk-accessories-organization/jarvis-dual-monitor-arm/100348287.html?lang=en_CA&amp;sku=100348287">monitor arms</a>, it sits much too low, even with the arm at its maximum height.</p> <p>While searching for solutions, I ran across <a href="https://www.monlines.com/portfolio-item/monlines-v014-vesa-adapter-for-samsung-u28e590d/">this adapter</a>, which effectively moves the VESA connection near the bottom of the monitor. Sadly, I couldn’t find a way to purchase it in Canada. So I decided to design and print my own!</p> <p>I originally printed the adapter in PETG, as PETG is strong and flexible and usually a better choice for functional prints. However, it was a little <em>too</em> flexible - it caused my monitor to bounce around in a sickening way whenever I bumped my desk. I printed another in PLA (unfortunately the only color I had in enough quantity was a bright blue), which is much stiffer and has mostly eliminated the wobble.</p> <figure> <img loading="lazy" src="/https/nathanfriend.com/assets/img/useful-things-ive-3d-printed/vesa_adapter.jpg" alt="The adapter, printed in blue PLA"/> <figcaption>The adapter, printed in blue PLA</figcaption> </figure> <figure> <img loading="lazy" src="/https/nathanfriend.com/assets/img/useful-things-ive-3d-printed/vesa_adapter_attached.jpg" alt="The adapter, attached to my monitor"/> <figcaption>Attached to the monitor and arm</figcaption> </figure> <figure> <img loading="lazy" src="/https/nathanfriend.com/assets/img/useful-things-ive-3d-printed/desk.jpg" alt="An image of my monitor without my 3D printed adapter"/> <figcaption>My workspace post-adapter. The monitor now sits comfortably at eye-level.</figcaption> </figure> <p><em>Thingiverse link: <a href="https://www.thingiverse.com/thing:6845708">https://www.thingiverse.com/thing:6845708</a></em></p> <h2 id="sodastream-bottle-stabilizer">SodaStream bottle stabilizer</h2> <p>I drink way too much carbonated water, usually straight from the SodaStream bottle. The bottles <em>look</em> cool, but their tiny bases resulted in many a messy spill.</p> <figure> <img loading="lazy" src="/https/nathanfriend.com/assets/img/useful-things-ive-3d-printed/sodastream_bottle.jpg" alt="A SodaStream bottle"/> <figcaption>The very stylish and tippable SodaStream bottle</figcaption> </figure> <p>I modeled a base using the exact curvature of the bottle’s bottom, resulting in a <em>nearly</em> untippable receptacle.</p> <figure> <img loading="lazy" src="/https/nathanfriend.com/assets/img/useful-things-ive-3d-printed/sodastream_holder.jpg" alt="The 3D-printed SodaStream bottle holder"/> <figcaption>The curvature exactly matches the shape of the SodaStream bottle</figcaption> </figure> <figure> <img loading="lazy" src="/https/nathanfriend.com/assets/img/useful-things-ive-3d-printed/sodastream_holder_with_bottle.jpg" alt="The SodaStream bottle in the holder"/> <figcaption>100% less tippage</figcaption> </figure> <p>As silly as it sounds, this simple print might be the most-used 3D printed object in my house.</p> <p><em>Thingiverse link: <a href="https://www.thingiverse.com/thing:5422282">https://www.thingiverse.com/thing:5422282</a></em></p> <h2 id="conduit-cover">Conduit cover</h2> <p>When we finished our basement, I installed a conduit in the wall with the idea that maybe someday I’d set up a ceiling-mounted projector and need a clean way to route the cables.</p> <figure> <img loading="lazy" src="/https/nathanfriend.com/assets/img/useful-things-ive-3d-printed/conduit_in_wall.jpg" alt="The conduit in the wall, before drywall was installed"/> <figcaption>The conduit in the wall, before drywall was installed</figcaption> </figure> <p>This left some unseemly holes in our wall once the drywall was in place:</p> <figure> <img loading="lazy" src="/https/nathanfriend.com/assets/img/useful-things-ive-3d-printed/conduit_no_cover.jpg" alt="A hole in the drywall where the conduit opens"/> <figcaption>The opening of the conduit in the ceiling</figcaption> </figure> <p>I modeled and printed covers for each end of the conduit:</p> <figure> <img loading="lazy" src="/https/nathanfriend.com/assets/img/useful-things-ive-3d-printed/conduit_with_cover.jpg" alt="The conduit cover in place"/> <figcaption>The cover in place</figcaption> </figure> <figure> <img loading="lazy" src="/https/nathanfriend.com/assets/img/useful-things-ive-3d-printed/conduit_with_cover_2.jpg" alt="The second conduit cover in place"/> <figcaption>The floor version</figcaption> </figure> <p>They weren’t the cleanest prints, but it’s a huge improvement over the previous look!</p> <p><em>Thingiverse link: <a href="https://www.thingiverse.com/thing:6847090">https://www.thingiverse.com/thing:6847090</a></em></p> <h2 id="dryer-lint-filter-handle">Dryer lint filter handle</h2> <p>One of my earliest functional prints was a new lint filter handle for my in-laws’ ancient dryer. The original handle had snapped off years ago and they had been using pliers to pull it out since then!</p> <figure> <img loading="lazy" src="/https/nathanfriend.com/assets/img/useful-things-ive-3d-printed/lint_trap_without_handle.jpg" alt="The lint filter without a handle"/> <figcaption>The broken lint filter</figcaption> </figure> <figure> <img loading="lazy" src="/https/nathanfriend.com/assets/img/useful-things-ive-3d-printed/lint_trap_with_handle.jpg" alt="The lint filter with a handle"/> <figcaption>All fixed!</figcaption> </figure> <p><em>Thingiverse link: <a href="https://www.thingiverse.com/thing:6847164">https://www.thingiverse.com/thing:6847164</a></em></p> <h2 id="dryer-lint-trap-seal">Dryer lint trap seal</h2> <p>We had our laundry room drywalled, and as part of this work, the contractor installed <a href="https://www.amazon.ca/Vortex-Powerfan-inch-White-LT4/dp/B09C11F77P">this terrible in-wall lint trap</a>. Why is it so terrible? It has no latch, so every time the dryer starts, the filter pushes out of the wall, filling the room (and the entire house) with dryer lint dust!</p> <p>I tried gluing some magnets to both the wall and the filter, hoping this would keep it in place, but the magnets were too finicky and didn’t hold it flush against the wall. In the end, we just taped it to the wall, which only kind of worked.</p> <figure> <img loading="lazy" src="/https/nathanfriend.com/assets/img/useful-things-ive-3d-printed/terrible_lint_trap.jpg" alt="The original, terrible lint trap"/> <figcaption>The original lint trap, or as I like to call it, the Dust-o-Matic 3000™</figcaption> </figure> <p>After replacing the tape for the nth time, I resigned myself to a more long-term solution. I designed a mount which I printed in PETG and attached to the wall around the existing filter.</p> <figure> <img loading="lazy" src="/https/nathanfriend.com/assets/img/useful-things-ive-3d-printed/lint_trap_with_mount.jpg" alt="The lint trap with the 3D-printed mount installed"/> <figcaption>The mount installed around the lint trap</figcaption> </figure> <p>I printed a cover that press-fit over this mount in TPU, a flexible, almost rubbery plastic:</p> <figure> <img loading="lazy" src="/https/nathanfriend.com/assets/img/useful-things-ive-3d-printed/lint_trap_with_mount_and_cover.jpg" alt="The lint trap with the 3D-printed mount and coverr installed"/> <figcaption>The cover attached to the mount</figcaption> </figure> <p><em>Thingiverse link: <a href="https://www.thingiverse.com/thing:6876991">https://www.thingiverse.com/thing:6876991</a></em></p> <h2 id="tv-wall-mount-conduit">TV wall mount conduit</h2> <p>When I wall-mounted our TV, I wanted a clean look without any visible cables. This involved cutting two holes through the wall behind the TV so that the cables could be run into the storage room behind the wall and then back to the cabinet underneath the TV:</p> <figure> <img loading="lazy" src="/https/nathanfriend.com/assets/img/useful-things-ive-3d-printed/tv_mount_two_holes.jpg" alt="Two holes in the wall, ready for the conduit insert"/> <figcaption>I let my son help me drill the holes the drywall; I think it was the highlight of his year</figcaption> </figure> <p>To make this tidier, I printed two conduits that exactly matched the depth of the wall.</p> <figure> <img loading="lazy" src="/https/nathanfriend.com/assets/img/useful-things-ive-3d-printed/tv_mount_on_print_bed.jpg" alt="The conduit on the 3D printer bed"/> <figcaption>A conduit and end cap</figcaption> </figure> <p>This made running the cables much easier and cleaner.</p> <figure> <img loading="lazy" src="/https/nathanfriend.com/assets/img/useful-things-ive-3d-printed/tv_mount_conduit_installed.jpg" alt="The conduit on the 3D printer bed"/> <figcaption>The conduit installed in the wall</figcaption> </figure> <p>The end result is a floating TV that seemingly requires no power source or video cables!</p> <figure> <img loading="lazy" src="/https/nathanfriend.com/assets/img/useful-things-ive-3d-printed/tv_mounted.jpg" alt="The TV mounted on the wall, showing a Mario game being played"/> <figcaption>The end result</figcaption> </figure> <p>(You might also notice the LED backlighting; this is an ambient lighting setup I installed that is powered by <a href="https://github.com/hyperion-project/hyperion.ng">Hyperion</a>, which could be the subject of a blog post of its own!)</p> <h2 id="3d-printer-enclosure">3D printer enclosure</h2> <p>I built my own 3D printer enclosure to help stabilize the ambient temperature during prints (especially for plastic prone to warping, like PETG).</p> <figure> <img loading="lazy" src="/https/nathanfriend.com/assets/img/useful-things-ive-3d-printed/enclosure.jpg" alt="A 3D printer enclosure"/> <figcaption>My printer enclosure</figcaption> </figure> <p>While the structure itself was mainly built from wood scraps, most of the joints, hinges, and other interesting pieces were 3D printed in white PETG.</p> <figure> <img loading="lazy" src="/https/nathanfriend.com/assets/img/useful-things-ive-3d-printed/enclosure_hinges.jpg" alt="A 3D-printed hinge"/> <figcaption>A "print-in-place" hinge (which I did not design)</figcaption> </figure> <figure> <img loading="lazy" src="/https/nathanfriend.com/assets/img/useful-things-ive-3d-printed/enclosure_pieces.jpg" alt="A variety of 3D-printed parts for the enclosure"/> <figcaption>Some other various pieces (which I did design)</figcaption> </figure> <p><em>Printables link for the hinge: <a href="https://www.printables.com/model/120766-print-in-place-geared-hinge">https://www.printables.com/model/120766-print-in-place-geared-hinge</a></em></p> <h2 id="laptop-stand">Laptop stand</h2> <p>A friend needed a laptop stand, so I printed him one!</p> <figure> <img loading="lazy" src="/https/nathanfriend.com/assets/img/useful-things-ive-3d-printed/laptop_stand.jpg" alt="A laptop stand"/> <figcaption>The stand</figcaption> </figure> <figure> <img loading="lazy" src="/https/nathanfriend.com/assets/img/useful-things-ive-3d-printed/laptop_stand_with_laptop.jpg" alt="A laptop stand with a laptop"/> <figcaption>The stand, in action</figcaption> </figure> <p>Unlike most of these other models, I didn’t design this one myself; I found it <a href="https://all3dp.com/2/3d-printed-laptop-stand-3d-models/">here</a>.</p> <p><em>Thingiverse link: <a href="https://www.thingiverse.com/thing:3708825">https://www.thingiverse.com/thing:3708825</a></em></p> <h2 id="yeti-adapter">Yeti adapter</h2> <p>Another friend had a Yeti can insulator that was designed for 16 oz cans and was therefore too long for standard 12 oz cans. I printed a spacer so that the insulator could work with either.</p> <figure> <img loading="lazy" src="/https/nathanfriend.com/assets/img/useful-things-ive-3d-printed/yeti_adapter.jpg" alt="A hexagonal spacer with the words 'YETI' on it"/> <figcaption>Appropriately branded, of course</figcaption> </figure> <h2 id="clamping-mitre-box">Clamping mitre box</h2> <p>I wanted a large mitre box to help me make straight wood cuts:</p> <figure> <img loading="lazy" src="/https/nathanfriend.com/assets/img/useful-things-ive-3d-printed/mitre_box.jpg" alt="A large mitre box with a saw in it"/> <figcaption>Mitre box</figcaption> </figure> <p>While it was fun to model and print, it was ultimately a failure as it broke within the first five minutes of usage.</p> <p><em>Thingiverse link: <a href="https://www.thingiverse.com/thing:5380012">https://www.thingiverse.com/thing:5380012</a></em></p> <h2 id="desiccant-containers">Desiccant containers</h2> <p>3D printer filament needs to be kept dry; it naturally absorbs humidity from the air which can negatively impact print quality. I printed some <a href="https://en.wikipedia.org/wiki/Desiccant">desiccant</a> containers that I keep alongside my filament spools in a sealed plastic tub.</p> <figure> <img loading="lazy" src="/https/nathanfriend.com/assets/img/useful-things-ive-3d-printed/desiccant_container.jpg" alt="A cylindrical desiccant container"/> <figcaption>Container for desiccant beads</figcaption> </figure> <p>Although, to be honest, I don’t think they are doing much good; the desiccant beads are supposed to be recharged in the oven or microwave every few months, and ain’t nobody got time for that.</p> <p>Instead, I now dry individual filament spools using a <a href="https://www.amazon.ca/gp/product/B00LNDYJ2I">food dehydrator like this</a>.</p> <h2 id="poster-frame">Poster frame</h2> <p>I got this funky poster as part of a swag package from Stripe. I modeled some matching low-poly poster holders that clamp together with magnets to display in my home office.</p> <figure> <img loading="lazy" src="/https/nathanfriend.com/assets/img/useful-things-ive-3d-printed/poster_frame.jpg" alt="A poster hanging in a frame on a wall"/> <figcaption>To be honest, I'm not really sure what it's portraying? But it looks cool.</figcaption> </figure> <p><em>Thingiverse link: <a href="https://www.thingiverse.com/thing:6848192">https://www.thingiverse.com/thing:6848192</a></em></p> <h2 id="smoke-alarm-cover">Smoke alarm cover</h2> <p>Our smoke and carbon monoxide detectors expired, so I replaced the individual units with a single, combined detector. This left an empty hole in the ceiling of each room, so I printed a simple cover that snapped into the existing bracket.</p> <figure> <img loading="lazy" src="/https/nathanfriend.com/assets/img/useful-things-ive-3d-printed/smoke_detector_cover.jpg" alt="A circular cover for a smoke detector"/> <figcaption>The cover</figcaption> </figure> <figure> <img loading="lazy" src="/https/nathanfriend.com/assets/img/useful-things-ive-3d-printed/smoke_detector_cover_installed.jpg" alt="A circular cover for a smoke detector, installed in the existing bracket"/> <figcaption>When installed in the old detector's bracket</figcaption> </figure> <p><em>Thingiverse link: <a href="https://www.thingiverse.com/thing:6449765">https://www.thingiverse.com/thing:6449765</a></em></p> <h2 id="cabinet-legs">Cabinet legs</h2> <p>We bought an IKEA BESTÅ TV cabinet, but discovered the doors got caught in our shag area rug. I printed some simple legs to elevate the cabinet just enough to allow the doors to operate unobstructed.</p> <figure> <img loading="lazy" src="/https/nathanfriend.com/assets/img/useful-things-ive-3d-printed/cabinet_legs.jpg" alt="A TV cabinet with legs highlighted by red circle annotations"/> <figcaption>TV cabinet with legs</figcaption> </figure> <p><em>Thingiverse link: <a href="https://www.thingiverse.com/thing:6848214">https://www.thingiverse.com/thing:6848214</a></em></p> <h2 id="coaster">Coaster</h2> <p>While not <em>strictly</em> functional, I printed some <a href="https://link.com/">Link</a>-themed drink coasters for myself and my team at Stripe.</p> <figure> <img loading="lazy" src="/https/nathanfriend.com/assets/img/useful-things-ive-3d-printed/coaster.jpg" alt="A drink coaster with the Stripe Link logo embedded in it"/> <figcaption>A Linkoaster™</figcaption> </figure> <figure> <img loading="lazy" src="/https/nathanfriend.com/assets/img/useful-things-ive-3d-printed/coasters.jpg" alt="Lots of drink coasters with the Stripe Link logo embedded in it"/> <figcaption>A flock of 12 Linkoaster™s</figcaption> </figure> <h2 id="google-wifi-wall-mount">Google WiFi wall mount</h2> <p>I designed a wall mount for a Google WiFi access point. The fit wasn’t quite right due to my not noticing the top of the puck is slightly smaller than the bottom, but a well-placed wad of sticky tack did the trick.</p> <figure> <img loading="lazy" src="/https/nathanfriend.com/assets/img/useful-things-ive-3d-printed/google_wifi_holder_2.jpg" alt="A wall-mounted Google WiFi puck"/> <figcaption>Wall-mounted and sticky tack'd</figcaption> </figure> <p><em>Thingiverse link: <a href="https://www.thingiverse.com/thing:6903032">https://www.thingiverse.com/thing:6903032</a></em></p> <h2 id="and-more-to-come">And more to come!</h2> <p>I plan on treating this post as an evergreen log of the functional things I’ve printed. Be sure to revisit!</p>]]></content><author><name></name></author><summary type="html"><![CDATA[3D printers are ridiculously cool. There’s something about seeing a digital object slowly manifest in the real world that is pure magic.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://nathanfriend.com/assets/img/useful-things-ive-3d-printed/vesa_adapter.jpg"/><media:content medium="image" url="https://nathanfriend.com/assets/img/useful-things-ive-3d-printed/vesa_adapter.jpg" xmlns:media="http://search.yahoo.com/mrss/"/></entry><entry><title type="html">GitLab Pages with multiple domains</title><link href="https://nathanfriend.com/2024/10/14/gitab-pages-with-multiple-domains.html" rel="alternate" type="text/html" title="GitLab Pages with multiple domains"/><published>2024-10-14T08:41:01-04:00</published><updated>2024-10-14T08:41:01-04:00</updated><id>https://nathanfriend.com/2024/10/14/gitab-pages-with-multiple-domains</id><content type="html" xml:base="https://nathanfriend.com/2024/10/14/gitab-pages-with-multiple-domains.html"><![CDATA[<p>I recently migrated the website you are on now from <a href="https://aws.amazon.com/">AWS</a> to <a href="https://docs.gitlab.com/ee/user/project/pages/">GitLab Pages</a>.</p> <figure> <img loading="lazy" src="/https/nathanfriend.com/assets/img/gitab-pages-with-multiple-domains/redirect.jpg" alt="An abstract, AI-generated graphic of a bunch of arrows all circling each other"/> <figcaption>Obligatory AI-generated image. This is Meta AI's vision of what a bunch of HTTP redirects look like, which will be relevant in a bit.</figcaption> </figure> <p>While part of my motivation was to simplify my <a href="https://gitlab.com/nfriend/website-3.0-docker">bespoke Docker build</a>, the primary reason was because <a href="https://www.theverge.com/2024/4/10/24126291/amazon-stop-paying-developers-alexa-skills">Amazon ended their Alexa Developer Rewards Program</a> which had been funding my website hosting for over half a decade. (I don’t blame them, only a tiny fraction of the free credits went towards hosting <a href="https://gitlab.com/nfriend/days-until">my one semi-popular and now defunct Alexa Skill</a>.) As a result, I began owing AWS about $30 USD/month, which is a bit much for this barebones website and a smattering of side projects.</p> <p>Since the vast majority of my content is static, I decided to go all in on <a href="https://docs.gitlab.com/ee/user/project/pages/">GitLab Pages</a>, my favorite (free!) static hosting service.</p> <p><em>(<strong>Disclaimer:</strong> I’m a bit biased since <a href="/https/nathanfriend.com/2018/12/04/the-next-chapter.html">I used to work for GitLab</a> and even implemented <a href="https://gitlab.com/gitlab-org/gitlab-pages/-/merge_requests/458">some improvements to GitLab Pages</a> while I was there.)</em></p> <p>It was all going swimmingly until I began repointing my DNS records from the AWS EC2 instance to GitLab Pages. I have three domains that all point to this website:</p> <ul> <li><a href="https://nathanfriend.com">nathanfriend.com</a></li> <li><a href="https://nathanfriend.dev">nathanfriend.dev</a></li> <li><a href="https://nathanfriend.io">nathanfriend.io</a></li> </ul> <p>At the time, the first two redirected to the third (I’ve since decided to instead centralize on <code class="language-plaintext highlighter-rouge">.com</code> since <code class="language-plaintext highlighter-rouge">.io</code> domains are <a href="https://news.ycombinator.com/item?id=29403773">getting expensive lately</a> and I’d like to retire it someday.)</p> <p>This <em>should</em> be easy to do with GitLab pages, since GitLab <a href="https://docs.gitlab.com/ee/user/project/pages/redirects.html#domain-level-redirects">supports domain-level redirects via a Netlify-style <code class="language-plaintext highlighter-rouge">_redirects</code> file</a>. But a quick test configuration like this didn’t seem to work:</p> <div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>https://nathanfriend.io/*  https://nathanfriend.com/:splat 301
https://nathanfriend.dev/* https://nathanfriend.com/:splat 301
</code></pre></div></div> <h2 id="the-problem">The problem</h2> <p>GitLab Pages only checks the rules in <code class="language-plaintext highlighter-rouge">_redirects</code> if no file matches the request path. From <a href="https://docs.gitlab.com/ee/user/project/pages/redirects.html#files-override-redirects">their docs</a>:</p> <blockquote> <p>Files take priority over redirects. If a file exists on disk, GitLab Pages serves the file instead of your redirect. For example, if the files <code class="language-plaintext highlighter-rouge">hello.html</code> and <code class="language-plaintext highlighter-rouge">world.html</code> exist, and the <code class="language-plaintext highlighter-rouge">_redirects</code> file contains the following line, the redirect is ignored because <code class="language-plaintext highlighter-rouge">hello.html</code> exists:</p> <div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>/project-slug/hello.html /project-slug/world.html 302
</code></pre></div> </div> <p>GitLab does not support Netlify <a href="https://docs.netlify.com/routing/redirects/rewrites-proxies/#shadowing">force option</a> to change this behavior.</p> </blockquote> <p>Unfortunately, this makes it impossible to fully redirect one domain to another using a <code class="language-plaintext highlighter-rouge">_redirects</code> rule. Using my example, if a request is made to <code class="language-plaintext highlighter-rouge">https://nathanfriend.dev</code>, GitLab Pages will consider this a file match to <code class="language-plaintext highlighter-rouge">index.html</code> and immediately serve the page with an <code class="language-plaintext highlighter-rouge">HTTP 200</code> instead of processing the <code class="language-plaintext highlighter-rouge">301</code> redirect as specified in the <code class="language-plaintext highlighter-rouge">_redirects</code> file.</p> <p>At this point, I was considering dumping GitLab Pages altogether and switching to something like Netlify. Not being able to redirect my domains is a dealbreaker; not only is having multiple live domains annoying, but it’s considered bad SEO practice. From <a href="https://developers.google.com/search/blog/2008/09/demystifying-duplicate-content-penalty">a Google blog</a>:</p> <blockquote> <ul> <li>Don’t create multiple pages, subdomains, or domains with substantially <a href="https://developers.google.com/search/docs/advanced/guidelines/duplicate-content">duplicate content.</a></li> </ul> </blockquote> <p>Fortunately, I came up with a hacky-yet-satisfying solution.</p> <h2 id="the-solution">The solution</h2> <p>Since GitLab Pages redirects only work when no matching file is found, the solution is to create a <em>separate</em> Pages site with no content at all. Then, point any domain or subdomain that should redirect at this second Pages site, and include a <code class="language-plaintext highlighter-rouge">_redirects</code> file with all the corresponding configuration.</p> <p>This is exactly what I’ve done in <a href="https://gitlab.com/nfriend/nathanfriend.com-redirector/">gitlab.com/nfriend/nathanfriend.com-redirector</a>, which is an empty project that deploy <a href="https://gitlab.com/nfriend/nathanfriend.com-redirector/-/blob/main/public/_redirects?ref_type=heads">a single <code class="language-plaintext highlighter-rouge">_redirects</code> file</a> that looks like this:</p> <div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>https://nathanfriend.io/*      https://nathanfriend.com/:splat 301
https://www.nathanfriend.io/*  https://nathanfriend.com/:splat 301
https://nathanfriend.dev/*     https://nathanfriend.com/:splat 301
https://www.nathanfriend.dev/* https://nathanfriend.com/:splat 301
https://www.nathanfriend.com/* https://nathanfriend.com/:splat 301
</code></pre></div></div> <p>Now, any request that comes in to one of these non-canonical domains will be forwarded to this Pages site and subsequently redirected to the canonical domain.</p> <p>I’m reasonably happy with this solution, although I’d much prefer for GitLab to simply add support <a href="https://docs.netlify.com/routing/redirects/rewrites-proxies/#shadowing">Netlify’s <code class="language-plaintext highlighter-rouge">force</code> option</a> and avoid the extra complexity altogether. Please upvote <a href="https://gitlab.com/gitlab-org/gitlab-pages/-/issues/1134">my feature request issue</a>!</p>]]></content><author><name></name></author><summary type="html"><![CDATA[I recently migrated the website you are on now from AWS to GitLab Pages.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://nathanfriend.com/assets/img/gitab-pages-with-multiple-domains/redirect.jpg"/><media:content medium="image" url="https://nathanfriend.com/assets/img/gitab-pages-with-multiple-domains/redirect.jpg" xmlns:media="http://search.yahoo.com/mrss/"/></entry><entry><title type="html">Zoom light</title><link href="https://nathanfriend.com/2024/05/31/zoom-light.html" rel="alternate" type="text/html" title="Zoom light"/><published>2024-05-31T18:09:00-04:00</published><updated>2024-05-31T18:09:00-04:00</updated><id>https://nathanfriend.com/2024/05/31/zoom-light</id><content type="html" xml:base="https://nathanfriend.com/2024/05/31/zoom-light.html"><![CDATA[<p>I built a light that glows when I’m on a Zoom call.</p> <figure> <img loading="lazy" src="/https/nathanfriend.com/assets/img/zoom-light/animated.webp" alt="An animation of the Zoom light in action"/> </figure> <p><br/></p> <p>I recently had a blast <a href="/https/nathanfriend.com/2023/06/26/i-built-a-weird-keyboard.html">building my own keyboard</a>. As part of that project, I connected individually-addressable LED strips to the two microcontrollers inside each half. It was way easier than I expected and the end result was fantastic!</p> <p>This new project was the perfect excuse to play around with these LED strips again. I wanted to engineer a way to allow my family to know I was on a Zoom call without having to open my office door. I had a vision of a nondescript, unobtrusive object that would only reveal its purpose when turned on.</p> <p>I decided to build a light with a partially-transparent shade; the shade would be thick enough to obscure any pattern on its inside when dark, but would clearly reveal its inner pattern when lit.</p> <h2 id="how-it-works">How it works</h2> <p>On a technical level, here’s how it works:</p> <ul> <li>A script on my MacBook listens for Zoom calls to begin</li> <li>When it detects a Zoom call has started, the script makes a network request to <code class="language-plaintext highlighter-rouge">http://zoomlight/api/led/on</code></li> <li>A web server running on a wireless Raspberry Pi Pico inside the light receives the request and turns on the LED strip</li> </ul> <h2 id="build-log">Build log</h2> <h3 id="step-1-hello-world">Step 1: Hello world</h3> <p>This was my first time working with a Raspberry Pi Pico, so my first task was to just get some code running. Before too long I was able to toggle the on-board LED on and off:</p> <figure> <img loading="lazy" src="/https/nathanfriend.com/assets/img/zoom-light/on_board_led_side_by_side.jpg" alt="A side-by-side showing the on-board LED both on and off"/> </figure> <p><br/></p> <h3 id="step-2-pico-web-server">Step 2: Pico web server</h3> <p>Now that I had code compiling and running, I wanted to prove out the idea of running a web server on the wireless Pico.</p> <p>At first, I hand-coded my own web server, which looked something <a href="https://gitlab.com/nfriend/zoom-light/-/blob/3775855bb3be1061513290f782dfd00e5f66b15e/server.py">like this</a> and actually worked okay. But I then discovered <a href="https://github.com/miguelgrinberg/microdot">microdot</a> and threw away my fragile, bespoke implementation for this much more fully-featured library.</p> <p>I could now toggle the on-board LED remotely!</p> <h3 id="step-3-the-sk6812s">Step 3: The SK6812s</h3> <p>I soldered some jumper cables to an SK6812 LED strip and connected it to the Pico. With some help from the <a href="https://github.com/blaz-r/pi_pico_neopixel">neopixel library</a>, I had a remotely-controllable LED strip!</p> <figure> <img loading="lazy" src="/https/nathanfriend.com/assets/img/zoom-light/led_strip_side_by_side.jpg" alt="A side-by-side showing the LED strip off, white, and rainbow"/> </figure> <p><br/></p> <h3 id="step-4-the-prints">Step 4: The prints</h3> <p>With the digital problems solved, I pivoted to the analog half of this project. I designed a case and shade in <a href="https://www.freecad.org/">FreeCAD</a> and 3D printed some prototypes using both PLA and PETG.</p> <figure> <img loading="lazy" src="/https/nathanfriend.com/assets/img/zoom-light/shade_prototypes.jpg" alt="Two prototypes of the shade, one in PETG and one in PLA"/> </figure> <p><br/></p> <p>These first attempts were okay, but neither were perfect. Both had some printing deformities that were noticeable when backlit. In addition, the shade wasn’t <em>quite</em> deep enough to allow the light from the LED strip to diffuse, causing the center of the light to be noticeably brighter than the top and bottom edges.</p> <figure> <img loading="lazy" src="/https/nathanfriend.com/assets/img/zoom-light/diffusion_issues.jpg" alt="One of the prototypes backlit, showing not enough light diffusion"/> </figure> <p><br/></p> <p>I increased the depth a bit and printed a new version of both the casing and the shade in PETG. I got lucky; both were some of the cleanest prints I’ve ever managed!</p> <figure> <img loading="lazy" src="/https/nathanfriend.com/assets/img/zoom-light/clean_prints.jpg" alt="The casing and the shade on the printer bed"/> </figure> <p><br/></p> <p>I usually struggle with PETG, but the 3D printing gods smiled on me that day. (Well, days. Each one took ~18 hours to print.)</p> <figure> <img loading="lazy" src="/https/nathanfriend.com/assets/img/zoom-light/shade_and_casing.jpg" alt="The casing and the shade on floor"/> </figure> <p><br/></p> <h3 id="step-5-piecing-it-together">Step 5: Piecing it together</h3> <p>The end was in sight. I fixed the LED strip and Pico to the inside of the casing using some hot glue.</p> <figure> <img loading="lazy" src="/https/nathanfriend.com/assets/img/zoom-light/open_on_floor.jpg" alt="The casing with the LED strip and Pico installed"/> </figure> <p><br/></p> <h3 id="step-6-drilling-holes">Step 6: Drilling holes</h3> <p>It was time to mount it. After some obsessive measuring, I drilled a hole all the way through the wall above my office door (for the power cord) and mounted the casing using command strips.</p> <figure> <img loading="lazy" src="/https/nathanfriend.com/assets/img/zoom-light/mounted.jpg" alt="The casing mounted above my office door"/> </figure> <p><br/></p> <p>I popped on the shade and had a working light that I could manually trigger with <code class="language-plaintext highlighter-rouge">curl</code>!</p> <figure> <img loading="lazy" src="/https/nathanfriend.com/assets/img/zoom-light/working_side_by_side.jpg" alt="The finished light, off, on, and rainbow"/> </figure> <p><br/></p> <h3 id="step-7-making-it-automatic">Step 7: Making it automatic</h3> <p>The final step was to automate the light so that it automatically turned on when I entered a Zoom call and turned off when I exited.</p> <p>I experimented with a few different approaches and ultimately ended up using a rather unsatisfying method of polling every 5 seconds for the number of open Zoom ports:</p> <div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">#!/usr/bin/env bash</span>

<span class="c"># How often to poll for Zoom status, in seconds</span>
<span class="nv">INTERVAL</span><span class="o">=</span>5

<span class="c"># Function to execute the command and process its output</span>
monitor_zoom<span class="o">()</span> <span class="o">{</span>
    <span class="nv">current_state</span><span class="o">=</span><span class="s1">'unknown'</span>

    <span class="k">while </span><span class="nb">true</span><span class="p">;</span> <span class="k">do</span>
        <span class="c"># output will be an integer representing the number of open Zoom ports</span>
        <span class="nv">output</span><span class="o">=</span><span class="si">$(</span>lsof <span class="nt">-i</span> 4UDP | <span class="nb">grep </span>zoom | <span class="nb">awk</span> <span class="s1">'END{print NR}'</span><span class="si">)</span>

        <span class="k">if</span> <span class="o">[[</span> <span class="nv">$output</span> <span class="nt">-gt</span> 2 <span class="o">&amp;&amp;</span> <span class="nv">$current_state</span> <span class="o">!=</span> <span class="s2">"on"</span> <span class="o">]]</span><span class="p">;</span> <span class="k">then</span>
            <span class="c"># In practice, when on a Zoom call, $output seems to always be 6</span>

            <span class="nv">current_state</span><span class="o">=</span><span class="s2">"on"</span>
            <span class="nb">echo</span> <span class="s2">"Turning light on"</span>
            curl <span class="nt">-X</span> POST http://zoomlight/api/led/on
        <span class="k">elif</span> <span class="o">[[</span> <span class="nv">$output</span> <span class="nt">-le</span> 2 <span class="o">&amp;&amp;</span> <span class="nv">$current_state</span> <span class="o">!=</span> <span class="s2">"off"</span> <span class="o">]]</span><span class="p">;</span> <span class="k">then</span>
            <span class="c"># In practice, when not on a Zoom call, $output seems to always be 1</span>

            <span class="nv">current_state</span><span class="o">=</span><span class="s2">"off"</span>
            <span class="nb">echo</span> <span class="s2">"Turning light off"</span>
            curl <span class="nt">-X</span> POST http://zoomlight/api/led/off
        <span class="k">fi

        </span><span class="nb">sleep</span> <span class="nv">$INTERVAL</span>
    <span class="k">done</span>
<span class="o">}</span>

<span class="nb">echo</span> <span class="s2">"Watching for Zoom meetings in the background with PID: </span><span class="nv">$$</span><span class="s2">"</span>

<span class="c"># Start the monitoring</span>
monitor_zoom
</code></pre></div></div> <p>(Does anyone know of a more elegant solution? I’d love to hear it!)</p> <p>I set up this script to run every time my machine starts (<a href="https://stackoverflow.com/a/13372744/1063392">like this</a>).</p> <h3 id="step-8-adding-a-frontend">Step 8: Adding a frontend</h3> <p>Since the Pico was already running a web server, I added a simple frontend that could be accessed through a web browser.</p> <p>The website shows the current status of the light (via the color of the header block) and provides a way to manually control the color of the light.</p> <figure> <img loading="lazy" src="/https/nathanfriend.com/assets/img/zoom-light/frontend.jpg" alt="Screenshots of the frontend application served by the Pico"/> </figure> <p><br/></p> <p>It uses my favorite component library, <a href="https://wiredjs.com/">Wired Elements</a>.</p> <h2 id="final-thoughts">Final thoughts</h2> <p>Overall, I’m <em>very</em> happy with how this turned out. The light looks great and turns on and off exactly when it is supposed to!</p> <figure> <img loading="lazy" src="/https/nathanfriend.com/assets/img/zoom-light/final_side_by_side.jpg" alt="The finished, mounted light, both white and rainbow"/> </figure> <p><br/></p> <h2 id="linksresources">Links/resources</h2> <ul> <li>All source files on GitLab: <a href="https://gitlab.com/nfriend/zoom-light">https://gitlab.com/nfriend/zoom-light</a></li> <li>3D models on Thingiverse: <a href="https://www.thingiverse.com/thing:6644694">https://www.thingiverse.com/thing:6644694</a></li> <li><code class="language-plaintext highlighter-rouge">microdot</code>, the Python web server library I use on the Pico: <a href="https://github.com/miguelgrinberg/microdot">https://github.com/miguelgrinberg/microdot</a></li> <li><code class="language-plaintext highlighter-rouge">neopixel</code>, the library that interfaces with the LED strip on the Pico: <a href="https://github.com/blaz-r/pi_pico_neopixel">https://github.com/blaz-r/pi_pico_neopixel</a></li> </ul>]]></content><author><name></name></author><summary type="html"><![CDATA[I built a light that glows when I’m on a Zoom call.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://nathanfriend.com/assets/img/zoom-light/rainbow_side_view.jpg"/><media:content medium="image" url="https://nathanfriend.com/assets/img/zoom-light/rainbow_side_view.jpg" xmlns:media="http://search.yahoo.com/mrss/"/></entry><entry><title type="html">I built a weird keyboard</title><link href="https://nathanfriend.com/2023/06/26/i-built-a-weird-keyboard.html" rel="alternate" type="text/html" title="I built a weird keyboard"/><published>2023-06-26T20:00:00-04:00</published><updated>2023-06-26T20:00:00-04:00</updated><id>https://nathanfriend.com/2023/06/26/i-built-a-weird-keyboard</id><content type="html" xml:base="https://nathanfriend.com/2023/06/26/i-built-a-weird-keyboard.html"><![CDATA[<figure> <img loading="lazy" src="/https/nathanfriend.com/assets/img/i-built-a-weird-keyboard/half_rainbow.jpg" alt="A picture of half a Dactyl Manuform keyboard"/> </figure> <p><br/>I spent most of my free time over the last 10 months building this bizarre keyboard from scratch. It’s a <a href="https://github.com/abstracthat/dactyl-manuform">Dactyl Manuform</a> - a split keyboard with a highly sculpted design that is somehow simulataneously the ugliest and most eye-catching object I’ve ever seen.</p> <p>The goal of this keyboard design is to place keys exactly along each finger’s natural axis of motion. The consequences of this approach are downward-sloping (A.K.A “tented”) rows to match the natural angle of the wrists, a deeper middle finger column to compensate for this finger’s relative length, an offset pinky column to minimize stretching, and a thumb cluster with multiple keys to take advantage of its opposable nature.</p> <p>There’s a reason most keyboards don’t look like this. It’s difficult to mass-produce curved keyboards since they can’t use the stiff, flat <a href="https://en.wikipedia.org/wiki/Printed_circuit_board">PCBs</a> that most keyboards use to wire the keys to the microcontroller. Every Dactyl Manuform is a unique piece of art, painstakingly hand-wired by a human being. Here’s how I built mine:</p> <h2 id="build-log">Build log</h2> <p>The first step was to design and 3D print the case. There are a number of Dactyl Manuform model generators out there; I ended up using <a href="https://github.com/ibnuda/dactyl-keyboard/tree/refaktor">this one</a> since it had a few extra features I wanted (e.g. wide pinky keys). Some things I was looking for in my design:</p> <ul> <li>A key layout similar to the <a href="https://ergodox-ez.com/">ErgoDox EZ</a> keyboards I already own</li> <li>An aggressive tent angle (I went with π/8 = 22.5°)</li> <li><a href="https://en.wikipedia.org/wiki/Hot_swapping#Keyboards">Hot-swap</a> sockets</li> </ul> <p>I gave up on the hot-swap sockets after failing to coax the generator script to produce valid hot-swap socket holders. I’m glad I did - in the end I don’t think they would have worked anyway.</p> <p>Once I had a model that looked good on the screen, I printed a draft version to see it in real life.</p> <figure> <img loading="lazy" src="/https/nathanfriend.com/assets/img/i-built-a-weird-keyboard/draft.jpg" alt="A draft print of the right half"/> <figcaption>Printed with a .8mm nozzle, .32mm layer height, and lightning infill</figcaption> </figure> <p><br/></p> <p>Overall, I was really happy with this first draft. I only made a few tweaks to the model before printing the real thing:</p> <ol> <li>I enabled the “external microcontroller” option, which let me kick the can on deciding which microcontroller and connection types to use</li> <li>I added my own screw holes; the holes generated by the script were awkwardly placed</li> </ol> <p>To mount the base plates to the body of the keyboard, I used <a href="https://a.co/d/6wMZmwo">these heat set inserts</a>. It’s a neat system; the threaded inserts are melted into the 3D print using a soldering gun, producing threaded screw holes much stronger and smaller than anything that would be possible using only 3D printing.</p> <p>To determine the ideal hole size for these inserts, I made a test print.</p> <figure> <img loading="lazy" src="/https/nathanfriend.com/assets/img/i-built-a-weird-keyboard/insert_tester.jpg" alt="A photo of a print for determining the correct insert tolerance"/> <figcaption>A test print with insert holes ranging from 3.8mm to 4.2mm in diameter</figcaption> </figure> <p><br/></p> <p>I’m glad I tested this; <em>all</em> of the hole options were too small! A second print with bigger holes was more successful.</p> <figure> <img loading="lazy" src="/https/nathanfriend.com/assets/img/i-built-a-weird-keyboard/insert_tester_after.jpg" alt="A photo of another insert hole test print; this one includes installed screws"/> <figcaption>A second test print with insert holes ranging from 4.2mm to 4.6mm in diameter. I decided to go with 4.5mm</figcaption> </figure> <p><br/></p> <p>With this last detail resolved, I began printing the real halves. I used my favorite material - wood PLA - which looks (and even smells!) quite similar to real wood, once properly post-processed.</p> <figure> <img loading="lazy" src="/https/nathanfriend.com/assets/img/i-built-a-weird-keyboard/cura.jpg" alt="A screenshot of the final STL being sliced in Cura"/> <figcaption>The final STL file being sliced in Cura. 24 hours per half!</figcaption> </figure> <p><br/></p> <figure> <img loading="lazy" src="/https/nathanfriend.com/assets/img/i-built-a-weird-keyboard/on_print_bed.jpg" alt="A picture of the left half of the keyboard on the 3D printer bed"/> <figcaption>The left half, fresh off the printer</figcaption> </figure> <p><br/></p> <figure> <img loading="lazy" src="/https/nathanfriend.com/assets/img/i-built-a-weird-keyboard/printed_no_sanding.jpg" alt="A picture of both halves of the keyboard"/> <figcaption>Both halves printed and slightly cleaned up</figcaption> </figure> <p><br/></p> <p>I melted the inserts into the holes, which was nerve-wracking; one bad insert would have likely ruined the whole print. It was awkward to try and hold the keyboard <em>and</em> the insert in place while pressing the insert into the plastic with the soldering gun. Somehow I managed to install all ten without issue!</p> <figure> <img loading="lazy" src="/https/nathanfriend.com/assets/img/i-built-a-weird-keyboard/insert_installed.jpg" alt="A picture of an installed heat-set insert"/> <figcaption>An installed heat-set insert</figcaption> </figure> <p><br/></p> <p>I ordered the transparent acrylic base plates from <a href="https://www.ponoko.com/">ponoko.com</a> and was quite happy with the result.</p> <figure> <img loading="lazy" src="/https/nathanfriend.com/assets/img/i-built-a-weird-keyboard/acrylic_plate.jpg" alt="The bottom plate of the keyboard, made from acrylic"/> <figcaption>Acrylic base plate from <a href="https://www.ponoko.com/">ponoko.com</a></figcaption> </figure> <p><br/></p> <p>Next up was post-processing. I sanded each half with 80-grit sandpaper, which was a <em>ton</em> of work - there are a lot of nooks and crannies that make this a tedious job.</p> <figure> <img loading="lazy" src="/https/nathanfriend.com/assets/img/i-built-a-weird-keyboard/sanded_side.jpg" alt="A picture of both keyboard halves, sanded"/> <figcaption>Both halves, sanded</figcaption> </figure> <p><br/></p> <p>In order to sand the inside of the key holes, I printed a little attachment that I could wrap with sandpaper and fit on my screwdriver.</p> <figure> <img loading="lazy" src="/https/nathanfriend.com/assets/img/i-built-a-weird-keyboard/screw_driver.jpg" alt="A screwdriver with a 3D-printed accessory installed"/> <figcaption>My custom SuperSander™ (patent pending)</figcaption> </figure> <p><br/></p> <p>Normally this would be an unnecessary step - no one sees the inside of the key holes - but the fit was a <em>bit</em> too tight; most of the holes required some sanding before the switch would fit properly. (This is why I’m glad I didn’t bother with hot-swap sockets - the fit is so tight, I’d never be able to get the switches out anyway.)</p> <figure> <img loading="lazy" src="/https/nathanfriend.com/assets/img/i-built-a-weird-keyboard/sanded_top.jpg" alt="Another picture of both keyboard halves, sanded"/> <figcaption>Another shot, because sanding these took too much time not to show off</figcaption> </figure> <p><br/></p> <p>The next step was to stain and clear coat the prints. I used this <a href="https://www.homedepot.ca/product/1000844900">gel stain</a> and <a href="https://a.co/d/do2gUQm">glossy polyurethane</a> to give the prints a rich, polished wood color.</p> <figure> <img loading="lazy" src="/https/nathanfriend.com/assets/img/i-built-a-weird-keyboard/finish_complete_one_side.jpg" alt="A picture of a keyboard half, finished with stain and polyurethane"/> <figcaption>The finished product</figcaption> </figure> <p><br/></p> <figure> <img loading="lazy" src="/https/nathanfriend.com/assets/img/i-built-a-weird-keyboard/finish_complete_one_side_closeup.jpg" alt="Another picture of a keyboard half from a different angle, finished with stain and polyurethane"/> <figcaption>Some nice faux woodgrain</figcaption> </figure> <p><br/></p> <p>This was a time consuming step, as each half required three coats of stain (minimum 24 hours to dry per coat) and at least 3 coats of polyurethane (a few hours to dry per coat). I did this in the dead of winter which made drying these in the outdoors challenging (a space heater may have been involved).</p> <p>The hard work paid off, though - I’m really happy with how these look! The layer lines even give the illusion of a wood grain.</p> <p>The next decision was which key switches to use. I’m a clicky switch guy; the noisier the better. I bought a Kailh switch tester so I could make an informed decision and decided on Kailh Box Whites.</p> <figure> <img loading="lazy" src="/https/nathanfriend.com/assets/img/i-built-a-weird-keyboard/switch_tester.jpg" alt="Two keyboard switch testers"/> <figcaption>Regular and low-profile Kailh switch testers. Not pictured: Gateron and Boba testers</figcaption> </figure> <p><br/></p> <p>Compared to other clicky switches (e.g. Cherry MX Blues), Box Whites are <em>extra</em> clicky (they click <em>twice</em> per key press). Perfect for working remotely! If I ever make a silent/office-friendly version of this board, I’ll go with Boba U4 Silents.</p> <p>At this point, I was able to set the switches and keycaps in place and get a feel for what it would be like to type on this monstrosity. I’ll admit it felt about as weird as it looks.</p> <figure> <img loading="lazy" src="/https/nathanfriend.com/assets/img/i-built-a-weird-keyboard/finish_complete_both_sides.jpg" alt="A picture of the keyboard; half has just the switches installed, and the other half has both switches and key caps installed"/> <figcaption>Switches and keycaps (temporarily) installed</figcaption> </figure> <p><br/></p> <p>There was one last detail to work out before I could begin wiring up the halves. I wanted to install a rotary encoder (“volume knob”) on each half, but these don’t click into a standard keyboard hole out of the box. I had to 3D print a special adapter for each.</p> <figure> <img loading="lazy" src="/https/nathanfriend.com/assets/img/i-built-a-weird-keyboard/encoder_adapters.jpg" alt="3D-printed adapters for rotary encoders"/> <figcaption>The end result, with chunks of glass attached to the bottoms</figcaption> </figure> <p><br/></p> <p>I printed these with PETG and learned the hard way to <em>always</em> use glue stick when printing with PETG. The adapters adhesed to the bed so strongly they took chunks out of my glass bed when I finally pried them off. I was able to salvage the adapters with some sanding, but the printer bed was unfortunately beyond repair.</p> <figure> <img loading="lazy" src="/https/nathanfriend.com/assets/img/i-built-a-weird-keyboard/broken_bed.jpg" alt="A picture of the 3D printer bed with chips in it from the overly-adhesive PETG print"/> <figcaption>😭</figcaption> </figure> <p><br/></p> <p>Despite their rather violent effects on my printer, the adapters did their job quite nicely!</p> <figure> <img loading="lazy" src="/https/nathanfriend.com/assets/img/i-built-a-weird-keyboard/encoders_installed.jpg" alt="Two pictures of rotary encoders; one without a cap and one with a cap"/> <figcaption>Rotary encoder with and without the cap</figcaption> </figure> <p><br/></p> <p>Finally, it was time to start wiring it up! First, I wrapped diodes around one pin on each switch.</p> <figure> <img loading="lazy" src="/https/nathanfriend.com/assets/img/i-built-a-weird-keyboard/diodes_installed.jpg" alt="A keyboard half with diodes bent around one pin of the switch"/> <figcaption>Diodes wrapped</figcaption> </figure> <p><br/></p> <p>I soldered the diodes into place and snipped the extra leg.</p> <figure> <img loading="lazy" src="/https/nathanfriend.com/assets/img/i-built-a-weird-keyboard/diodes_soldered.jpg" alt="A keyboard half with diodes soldered to one pin of the switch"/> <figcaption>Diodes soldered and snipped</figcaption> </figure> <p><br/></p> <p>I soldered the remaining diode legs together to form the rows of the <a href="https://en.wikipedia.org/wiki/Keyboard_matrix_circuit">key matrix</a>.</p> <figure> <img loading="lazy" src="/https/nathanfriend.com/assets/img/i-built-a-weird-keyboard/diodes_soldered_together.jpg" alt="A keyboard half with diodes soldered together to form rows of the keyboard matrix"/> <figcaption>Diode legs soldered together into rows</figcaption> </figure> <p><br/></p> <p>I used small, individual pieces of insulated wire to form the columns.</p> <figure> <img loading="lazy" src="/https/nathanfriend.com/assets/img/i-built-a-weird-keyboard/columns_soldered.jpg" alt="A keyboard half with wires soldered to the switches to form columns of the keyboard matrix"/> <figcaption>Don't look too closely; I'm really bad at soldering</figcaption> </figure> <p><br/></p> <p>I installed <a href="https://en.wikipedia.org/wiki/Jump_wire">DuPont connectors</a> so I didn’t have to solder directly to the microcontroller. This saved me a <em>lot</em> of headache since it took quite a bit of trial and error to get all pins in the right spot.</p> <figure> <img loading="lazy" src="/https/nathanfriend.com/assets/img/i-built-a-weird-keyboard/dupont_connectors_attached.jpg" alt="A keyboard half with DuPont connectors installed to each row and column of the keyboard matrix"/> <figcaption>DuPont connectors installed</figcaption> </figure> <p><br/></p> <p>I flashed a basic <a href="https://qmk.fm/">QMK</a> firmware to the microcontroller and had the incredibly satisfying experience of seeing a letter appear on the screen when I pressed a key. I also got the LED strip working!</p> <figure> <img loading="lazy" src="/https/nathanfriend.com/assets/img/i-built-a-weird-keyboard/it_lives.jpg" alt="A keyboard half with a LED strip lit in rainbow colors"/> <figcaption>IT LIVES!!</figcaption> </figure> <p><br/></p> <p>I was getting <em>really</em> close at this point. I designed a custom holder for the microcontroller since the one that was supposed to be compatible with my case didn’t fit for some reason.</p> <figure> <img loading="lazy" src="/https/nathanfriend.com/assets/img/i-built-a-weird-keyboard/holder.jpg" alt="A 3D printed holder for the microcontroller"/> <figcaption>Custom-designed microcontroller holder</figcaption> </figure> <p><br/></p> <p>I spliced some wires together since a few of the microcontroller pins had to be shared by more than one connection.</p> <figure> <img loading="lazy" src="/https/nathanfriend.com/assets/img/i-built-a-weird-keyboard/spaghetti.jpg" alt="A mess of wires soldered together"/> <figcaption>I'm frankly shocked this thing works</figcaption> </figure> <p><br/></p> <p>After assembling all the pieces, a bit of software configuration, and a lot of trial and error… I had a working keyboard!</p> <figure> <img loading="lazy" src="/https/nathanfriend.com/assets/img/i-built-a-weird-keyboard/desk.jpg" alt="A picture of the finished keyboard on a desk"/> <figcaption>Finally. Done.</figcaption> </figure> <p><br/></p> <figure> <img loading="lazy" src="/https/nathanfriend.com/assets/img/i-built-a-weird-keyboard/bottom_and_side.jpg" alt="A picture of the finished keyboard, with one half showing the bottom and one half showing the side"/> <figcaption>This was way too much work.</figcaption> </figure> <p><br/></p> <figure> <img loading="lazy" src="/https/nathanfriend.com/assets/img/i-built-a-weird-keyboard/pink.jpg" alt="Another picture of the finished keyboard, with pink underglow"/> <figcaption>Never again. Probably.</figcaption> </figure> <p><br/></p> <figure> <video controls="" width="500"> <source src="/https/nathanfriend.com/assets/img/i-built-a-weird-keyboard/alert.mp4"/> </video> <figcaption>ALERT</figcaption> </figure> <p><br/></p> <h2 id="how-does-it-feel">How does it feel?</h2> <p>Weird, but good! I’ve only been typing on it for a few work days, so my muscle memory hasn’t fully adjusted. I keep reaching for keys in the wrong places; in particular, my fingers naturally stretch too far when reaching for the bottom row. I also made a few modifications to my <a href="https://gitlab.com/nfriend/qmk_firmware/-/blob/nfriend-dactyl-manuform/keyboards/handwired/dactyl_manuform/5x7/keymaps/nfriend/keymap.c">QMK layout</a> to take advantage of the more accessible thumb clusters compared to my ErgoDox EZ. I think I’ll really like it once I’m used to it.</p> <h2 id="was-it-worth-it">Was it worth it?</h2> <p>Umm… I think so? The end result was fantastic, but it was an insane amount of work. I don’t recommend this project to anyone who isn’t interested in the process itself. If you’re just looking for a great ergonomic keyboard, I’d recommend buying an <a href="https://ergodox-ez.com/">ErgoDox EZ</a>, a <a href="https://www.zsa.io/moonlander/">Moonlander</a>, a <a href="https://kinesis-ergo.com/shop/adv360/">Kinesis Advantage360</a> or a <a href="https://ohkeycaps.com/products/built-to-order-dactyl-manuform-keyboard">prebuilt Dactyl Manuform</a>, all of which will cost about the same as this project (see below).</p> <h2 id="cost-breakdown">Cost breakdown</h2> <p>Cost of all items, including tax and shipping.</p> <table> <thead> <tr> <th>Description</th> <th>Cost (CAD)</th> <th>Link</th> </tr> </thead> <tbody> <tr> <td>Wood PLA filament for 3D printed case</td> <td>$40.44</td> <td><a href="https://a.co/d/eUWep4w">amazon.ca</a></td> </tr> <tr> <td>Kailh switch tester</td> <td>$21.46</td> <td><a href="https://www.aliexpress.com/item/32898546644.html">aliexpress.com</a></td> </tr> <tr> <td>Kailh low-profile switch tester</td> <td>$9.68</td> <td><a href="https://www.aliexpress.com/item/4001204818828.html">aliexpress.com</a></td> </tr> <tr> <td>Pro Micro controller (x2)</td> <td>$44.98</td> <td><a href="https://a.co/d/1GsSJXg">amazon.ca</a></td> </tr> <tr> <td>Kailh BOX White switches (x90)</td> <td>$51.26</td> <td><a href="https://www.aliexpress.com/item/1005004522909300.html">aliexpress.com</a></td> </tr> <tr> <td>M3 heat-seated inserts (x100)</td> <td>$12.42</td> <td><a href="https://a.co/d/6wMZmwo">amazon.ca</a></td> </tr> <tr> <td>M3 screws (x100)</td> <td>$12.02</td> <td><a href="https://a.co/d/enX1CBA">amazon.ca</a></td> </tr> <tr> <td>EC11 rotary encoder (x4)</td> <td>$15.80</td> <td><a href="https://a.co/d/eAp729Y">amazon.ca</a></td> </tr> <tr> <td>LED strip (1m)</td> <td>$16.37</td> <td><a href="https://www.aliexpress.com/item/32682015405.html">aliexpress.com</a></td> </tr> <tr> <td>1N4148 Diode (x100)</td> <td>$8.80</td> <td><a href="https://www.digikey.ca/en/products/detail/onsemi/1N4148/458603">digikey.ca</a></td> </tr> <tr> <td>Reset button (x3)</td> <td>$10.82</td> <td><a href="https://www.digikey.ca/en/products/detail/e-switch/PS1057ABLK/46305">digikey.ca</a></td> </tr> <tr> <td>22AWG Wire (25’)</td> <td>$7.24</td> <td><a href="https://www.digikey.ca/en/products/detail/sparkfun-electronics/PRT-08866/6833926">digikey.ca</a></td> </tr> <tr> <td>TRRS jack, female (x3)</td> <td>$12.65</td> <td><a href="https://www.digikey.ca/en/products/detail/switchcraft-inc/35RASMT5CHNTRX/16569698">digikey.ca</a></td> </tr> <tr> <td>Jumper wire (x60)</td> <td>$11.74</td> <td><a href="https://www.digikey.ca/en/products/detail/sparkfun-electronics/PRT-12796/5993861">digikey.ca</a></td> </tr> <tr> <td>Soldering iron</td> <td>$59.87</td> <td><a href="https://www.homedepot.ca/product/1001649527">homedepot.ca</a></td> </tr> <tr> <td>Solder</td> <td>$28.23</td> <td><a href="https://www.homedepot.ca/product/1001652943">homedepot.ca</a></td> </tr> <tr> <td>Wire stripper</td> <td>$11.29</td> <td><a href="https://www.canadiantire.ca/en/pdp/mastercraft-20-30-gauge-wire-stripper-comfort-grip-handles-high-carbon-heat-treated-steel-0584508p.html?loc=plp">canadiantire.ca</a></td> </tr> <tr> <td>Acrylic base plate (x4)</td> <td>$54.88</td> <td><a href="https://www.ponoko.com/">ponoko.com</a></td> </tr> <tr> <td>Gel wood stain</td> <td>$19.93</td> <td><a href="https://www.homedepot.ca/product/1000844900">homedepot.ca</a></td> </tr> <tr> <td>Glossy polyurethane</td> <td>$27.11</td> <td><a href="https://a.co/d/do2gUQm">amazon.ca</a></td> </tr> <tr> <td>Keycap set (x2)</td> <td>$57.32</td> <td><a href="https://www.amazon.ca/gp/product/B0B3QQ2468">amazon.ca</a></td> </tr> <tr> <td>Electrical tape</td> <td>$5.37</td> <td><a href="https://www.amazon.ca/dp/B001AXD0EY">amazon.ca</a></td> </tr> <tr> <td>Rubber feet</td> <td>$13.55</td> <td><a href="https://www.amazon.ca/gp/product/B08GLMTL77">amazon.ca</a></td> </tr> <tr> <td><strong>Only keyboard materials</strong></td> <td><strong>$450.93</strong></td> <td>≈ $340 USD</td> </tr> <tr> <td><strong>All items</strong> (including tools, testers, etc.)</td> <td><strong>$553.23</strong></td> <td>≈ $417 USD</td> </tr> </tbody> </table> <p>As you can see, building your own keyboard is <em>not</em> a good way to save money 💸</p> <h2 id="linksresources">Links/resources</h2> <ul> <li>All <code class="language-plaintext highlighter-rouge">.stl</code> and <code class="language-plaintext highlighter-rouge">.svg</code> files: <a href="https://www.thingiverse.com/thing:6099418">https://www.thingiverse.com/thing:6099418</a></li> <li>A helpful Hacker News comment: <a href="https://news.ycombinator.com/item?id=23445208">https://news.ycombinator.com/item?id=23445208</a></li> <li>The generator I used to generate the keyboard case model: <a href="https://github.com/ibnuda/dactyl-keyboard/tree/refaktor">https://github.com/ibnuda/dactyl-keyboard/tree/refaktor</a></li> <li>The firmware that powers the keyboard: <a href="https://qmk.fm/">https://qmk.fm/</a></li> <li>Some helpul build logs: <ul> <li><a href="https://sachee.medium.com/building-my-first-keyboard-and-you-can-too-512c0f8a4c5f">https://sachee.medium.com/building-my-first-keyboard-and-you-can-too-512c0f8a4c5f</a></li> <li><a href="https://youtu.be/UerP5bxGL3c">https://youtu.be/UerP5bxGL3c</a> (and subsequent videos)</li> <li><a href="https://nickgreen.info/dactyl-manuform-build-log/">https://nickgreen.info/dactyl-manuform-build-log/</a></li> <li><a href="https://medium.com/swlh/complete-idiot-guide-for-building-a-dactyl-manuform-keyboard-53454845b065">https://medium.com/swlh/complete-idiot-guide-for-building-a-dactyl-manuform-keyboard-53454845b065</a></li> </ul> </li> </ul>]]></content><author><name></name></author><summary type="html"><![CDATA[I spent most of my free time over the last 10 months building this bizarre keyboard from scratch. It’s a Dactyl Manuform - a split keyboard with a highly sculpted design that is somehow simulataneously the ugliest and most eye-catching object I’ve ever seen.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://nathanfriend.com/assets/img/i-built-a-weird-keyboard/half_rainbow.jpg"/><media:content medium="image" url="https://nathanfriend.com/assets/img/i-built-a-weird-keyboard/half_rainbow.jpg" xmlns:media="http://search.yahoo.com/mrss/"/></entry><entry><title type="html">Wordle Bot</title><link href="https://nathanfriend.com/2022/01/25/wordle.html" rel="alternate" type="text/html" title="Wordle Bot"/><published>2022-01-25T05:25:10-05:00</published><updated>2022-01-25T05:25:10-05:00</updated><id>https://nathanfriend.com/2022/01/25/wordle</id><content type="html" xml:base="https://nathanfriend.com/2022/01/25/wordle.html"><![CDATA[<p>Like everyone else, I’ve been obsessed with <a href="https://www.powerlanguage.co.uk/wordle/">Wordle</a> lately.</p> <p>I was <em>really</em> bad at it when I began. My original strategy was to use the first 3 or 4 guesses to uncover as many letters as possible, and then use the last few guesses to unscramble the solution. This turned out to be a terrible approach, and I failed most of the challenges.</p> <p>This got me thinking: what <em>is</em> the optimal Wordle strategy?</p> <p>And <em>that</em> immediately led to a second thought: can I automate it?</p> <p>The answer is a resounding <strong>of course</strong>! I present to you: <a href="https://twitter.com/bot_wordle/">Wordle Bot</a>!</p> <figure> <img src="/https/nathanfriend.com/assets/img/wordle/wordle-bot.png" alt="The Wordle Bot logo"/> </figure> <p><br/></p> <h2 id="how-it-works">How it works</h2> <p>The general strategy is to repeatedly pick the word that is most likely to reveal new letters. To do this, I use the frequency of each letter in the set of possible solutions to assign a score to each potential guess, and choose the option with the highest score.</p> <p>The set of all possible solutions is conveniently hardcoded in Wordle’s source code, so my first step is <a href="https://gitlab.com/nfriend/wordle-solver/-/blob/a2e90c02e6acbf4c002356dfe1fca9851c6915e2/src/index.ts#L2">to simply hardcode this list into my own script</a>.</p> <p>Next, <a href="https://gitlab.com/nfriend/wordle-solver/-/blob/a2e90c02e6acbf4c002356dfe1fca9851c6915e2/src/index.ts#L4-19">I loop through each potential solution</a> and count how many times each letter occurs. This results in a mapping like this:</p> <div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"e"</span><span class="p">:</span><span class="w"> </span><span class="mi">1233</span><span class="p">,</span><span class="w">
  </span><span class="nl">"a"</span><span class="p">:</span><span class="w"> </span><span class="mi">979</span><span class="p">,</span><span class="w">
  </span><span class="nl">"r"</span><span class="p">:</span><span class="w"> </span><span class="mi">899</span><span class="p">,</span><span class="w">
  </span><span class="nl">"o"</span><span class="p">:</span><span class="w"> </span><span class="mi">754</span><span class="p">,</span><span class="w">
  </span><span class="nl">"t"</span><span class="p">:</span><span class="w"> </span><span class="mi">729</span><span class="p">,</span><span class="w">
  </span><span class="nl">"l"</span><span class="p">:</span><span class="w"> </span><span class="mi">719</span><span class="p">,</span><span class="w">
  </span><span class="nl">"i"</span><span class="p">:</span><span class="w"> </span><span class="mi">671</span><span class="p">,</span><span class="w">
  </span><span class="nl">"s"</span><span class="p">:</span><span class="w"> </span><span class="mi">669</span><span class="p">,</span><span class="w">
  </span><span class="nl">"n"</span><span class="p">:</span><span class="w"> </span><span class="mi">575</span><span class="p">,</span><span class="w">
  </span><span class="nl">"c"</span><span class="p">:</span><span class="w"> </span><span class="mi">477</span><span class="p">,</span><span class="w">
  </span><span class="nl">"u"</span><span class="p">:</span><span class="w"> </span><span class="mi">467</span><span class="p">,</span><span class="w">
  </span><span class="nl">"y"</span><span class="p">:</span><span class="w"> </span><span class="mi">425</span><span class="p">,</span><span class="w">
  </span><span class="nl">"d"</span><span class="p">:</span><span class="w"> </span><span class="mi">393</span><span class="p">,</span><span class="w">
  </span><span class="nl">"h"</span><span class="p">:</span><span class="w"> </span><span class="mi">389</span><span class="p">,</span><span class="w">
  </span><span class="nl">"p"</span><span class="p">:</span><span class="w"> </span><span class="mi">367</span><span class="p">,</span><span class="w">
  </span><span class="nl">"m"</span><span class="p">:</span><span class="w"> </span><span class="mi">316</span><span class="p">,</span><span class="w">
  </span><span class="nl">"g"</span><span class="p">:</span><span class="w"> </span><span class="mi">311</span><span class="p">,</span><span class="w">
  </span><span class="nl">"b"</span><span class="p">:</span><span class="w"> </span><span class="mi">281</span><span class="p">,</span><span class="w">
  </span><span class="nl">"f"</span><span class="p">:</span><span class="w"> </span><span class="mi">230</span><span class="p">,</span><span class="w">
  </span><span class="nl">"k"</span><span class="p">:</span><span class="w"> </span><span class="mi">210</span><span class="p">,</span><span class="w">
  </span><span class="nl">"w"</span><span class="p">:</span><span class="w"> </span><span class="mi">195</span><span class="p">,</span><span class="w">
  </span><span class="nl">"v"</span><span class="p">:</span><span class="w"> </span><span class="mi">153</span><span class="p">,</span><span class="w">
  </span><span class="nl">"z"</span><span class="p">:</span><span class="w"> </span><span class="mi">40</span><span class="p">,</span><span class="w">
  </span><span class="nl">"x"</span><span class="p">:</span><span class="w"> </span><span class="mi">37</span><span class="p">,</span><span class="w">
  </span><span class="nl">"q"</span><span class="p">:</span><span class="w"> </span><span class="mi">29</span><span class="p">,</span><span class="w">
  </span><span class="nl">"j"</span><span class="p">:</span><span class="w"> </span><span class="mi">27</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div> <p>So, for example, a word like “HELLO” would score 3,814 (389 + 1,233 + 719 + 719 + 754).</p> <p>Using this method, the bot would <em>always</em> start with the word “EERIE”, due to its high score of 5,269 (1,233 + 1,233 + 899 + 671 + 1,233).</p> <p>However, this actually isn’t a very useful guess! It only gives us information about 3 letters. A more strategic method is to pick the highest scoring word <em>that contains no repeating letters</em>. Using this approach, the best first play is “LATER”, which still scores quite high (4,559 = 719 + 979 + 729 + 1,233 + 899) <em>and</em> provides information about 5 different letters.</p> <p>(Side note: Even though I know I <em>should</em> start with “LATER” when playing on my own, I can’t help but start with “ADIEU” - too many vowels to pass up!)</p> <p>Each subsequent guess is generated using the same algorithm, except that the list of possible solutions is shrunk by eliminating all options that contradict the results of previous guesses. A side effect of this elimination method is that it effectively plays the game on Hard Mode. (Hard Mode = “Any revealed hints must be used in subsequent guesses”).</p> <p>In later guesses, it’s possible there <em>won’t</em> be a valid option that includes no repeating letters, so in this case the bot <a href="https://gitlab.com/nfriend/wordle-solver/-/blob/a2e90c02e6acbf4c002356dfe1fca9851c6915e2/src/index.ts#L170">falls back to the highest-scoring word regardless of letter repetition</a>.</p> <h2 id="demo">Demo</h2> <p>Here’s an example of the script working in <a href="https://en.wikipedia.org/wiki/Bookmarklet">bookmarklet</a> form:</p> <figure> <img src="/https/nathanfriend.com/assets/img/wordle/wordle-demo.gif" alt="A demo of Wordle being solved by the script in bookmarklet form"/> </figure> <p>(Instruction for setting up this script as a bookmarklet can be found <a href="https://gitlab.com/nfriend/wordle-solver#usage">here</a>.)</p> <h2 id="how-good-is-it">How good is it?</h2> <p>The short answer is: quite good! (<em>Much</em> better than me.) But not perfect!</p> <p>At the time of writing, Wordle Bot has solved 220 puzzles. Its guess distribution looks like this:</p> <figure> <img src="/https/nathanfriend.com/assets/img/wordle/guess-distribution.png" alt="A chart showing Wordle Bot's guess distribution"/> </figure> <p>80% of the time, Wordle Bot can solve the daily puzzle in 4 tries or less. Not bad!</p> <p>But why isn’t it perfect? I was intrigued by the two puzzles it failed to solve. In both cases, Wordle Bot failed in a similar fashion. By guess 3, it had nailed down letters 2 - 5. It spent the remaining guesses cycling through different starting letters, and failed to find the correct one before it ran out of guesses.</p> <p>Here is its attempt at puzzle 113:</p> <figure> <img src="/https/nathanfriend.com/assets/img/wordle/puzzle-113.jpg" alt="A screenshot of Wordle Bot's solution to puzzle 113"/> <figcaption>Wordle 113</figcaption> </figure> <p>Even though the correct solution (“HATCH”) scores higher than all the options it tried (“PATCH”, “MATCH”, “BATCH”, and “WATCH”), it chose not to guess “HATCH” since it includes a repeated letter (“H”). As mentioned earlier, the algorithm always prefers guesses without repetition if they exist.</p> <p>A similar thing happened on day 123:</p> <figure> <img src="/https/nathanfriend.com/assets/img/wordle/puzzle-123.jpg" alt="A screenshot of Wordle Bot's solution to puzzle 123"/> <figcaption>Wordle 123</figcaption> </figure> <p>In this case, the solution “JAUNT” scored lower than all the options it tried (“DAUNT”, “HAUNT”, “GAUNT”, and “VAUNT”), so it simply ran out of time.</p> <h2 id="bringing-the-bot-to-life">Bringing the bot to life</h2> <p>Turning the script into a Twitter bot was just a matter of automation.</p> <p>First, I built a <a href="https://gitlab.com/nfriend/wordle-solver/-/blob/a2e90c02e6acbf4c002356dfe1fca9851c6915e2/twitter-bot/index.js">Puppeteer script</a> to launch Wordle in an instance of headless Chrome, inject my bookmarklet script, and solve the puzzle. Once it’s finished, the script <a href="https://gitlab.com/nfriend/wordle-solver/-/blob/a2e90c02e6acbf4c002356dfe1fca9851c6915e2/twitter-bot/screenshot-and-upload-to-imgur.js">takes a screenshot, uploads it to Imgur</a>, and <a href="https://gitlab.com/nfriend/wordle-solver/-/blob/a2e90c02e6acbf4c002356dfe1fca9851c6915e2/twitter-bot/index.js#L182-188">tweets the results</a>:</p> <blockquote class="twitter-tweet"><p lang="en" dir="ltr">Wordle 220 3/6<br/><br/>⬜🟨⬜⬜🟩<br/>🟩⬜⬜🟩🟩<br/>🟩🟩🟩🟩🟩<br/><br/>Full solution [<a href="https://twitter.com/hashtag/SPOILER?src=hash&amp;ref_src=twsrc%5Etfw">#SPOILER</a>!]: <a href="https://t.co/kXojDt5u3i">https://t.co/kXojDt5u3i</a></p>&mdash; Wordle Bot (@bot_wordle) <a href="https://twitter.com/bot_wordle/status/1485940275180711940?ref_src=twsrc%5Etfw">January 25, 2022</a></blockquote> <script async="" src="https://platform.twitter.com/widgets.js" charset="utf-8"></script> <p>Then, I configured a <a href="https://docs.gitlab.com/ee/ci/pipelines/schedules.html">scheduled GitLab pipeline</a> to run <a href="https://gitlab.com/nfriend/wordle-solver/-/blob/a2e90c02e6acbf4c002356dfe1fca9851c6915e2/.gitlab-ci.yml">the project’s pipeline</a> every day at 3:00 AM ET.</p> <p>Even though the end result is fairly simple, I ran into a number of speed bumps along the way:</p> <ul> <li>Wordle shares its results by copying them to the clipboard, but I couldn’t figure out a way to access the clipboard’s contents inside Puppeteer (it always returned my <em>system</em> clipboard contents instead). To get around this, I had to <a href="https://gitlab.com/nfriend/wordle-solver/-/blob/a2e90c02e6acbf4c002356dfe1fca9851c6915e2/twitter-bot/index.js#L101-152">build the “share” text myself</a>.</li> <li>The <a href="https://github.com/KenEucker/imgur">Imgur JavaScript library</a> I use required Node 14+, but <a href="https://github.com/alekzonder/docker-puppeteer">the Puppeteer Docker image</a> I was using inside my GitLab pipeline was stuck on Node 12. Switching to <a href="https://github.com/buildkite/docker-puppeteer"><code class="language-plaintext highlighter-rouge">buildkite/puppeteer</code></a> fixed this.</li> <li>The pipeline would randomly fail while trying to connect to the Wordle website: <code class="language-plaintext highlighter-rouge">Error: net::ERR_NETWORK_CHANGED at https://www.powerlanguage.co.uk/wordle/</code>. I “solved” this by adding a <a href="https://docs.gitlab.com/ee/ci/yaml/#timeout"><code class="language-plaintext highlighter-rouge">timeout</code></a> and <a href="https://docs.gitlab.com/ee/ci/yaml/#retry"><code class="language-plaintext highlighter-rouge">retry</code></a> to <a href="https://gitlab.com/nfriend/wordle-solver/-/blob/a2e90c02e6acbf4c002356dfe1fca9851c6915e2/.gitlab-ci.yml#L21-22">my GitLab job</a>.</li> </ul> <h2 id="absurdal">Absurdal</h2> <p>Oh, did I mention the bot is also surprisingly good at solving the <a href="https://qntm.org/files/wordle/index.html">Absurdal</a> variant?</p> <figure> <img src="/https/nathanfriend.com/assets/img/wordle/absurdal-demo.gif" alt="A demo of Absurdal being solved by the script in bookmarklet form"/> </figure> <p><br/></p> <h2 id="links">Links</h2> <ul> <li>Wordle Solver GitLab project: <a href="https://gitlab.com/nfriend/wordle-solver">https://gitlab.com/nfriend/wordle-solver</a></li> <li>Wordle Bot Twitter account: <a href="https://twitter.com/bot_wordle/">https://twitter.com/bot_wordle/</a></li> <li>Another Wordle solver I discovered after building my version that uses an almost identical algorithm: <a href="https://lockwood.dev/wordle/python/2022/01/23/wordle-solved-average-3-64.html">https://lockwood.dev/wordle/python/2022/01/23/wordle-solved-average-3-64.html</a></li> <li>An open-source Wordle Clone written in React, which I later discovered was written by my coworker’s partner! <a href="https://github.com/hannahcode/wordle">https://github.com/hannahcode/wordle</a></li> </ul>]]></content><author><name></name></author><summary type="html"><![CDATA[Like everyone else, I’ve been obsessed with Wordle lately.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://nathanfriend.com/assets/img/wordle/wordle-bot.png"/><media:content medium="image" url="https://nathanfriend.com/assets/img/wordle/wordle-bot.png" xmlns:media="http://search.yahoo.com/mrss/"/></entry><entry><title type="html">Herding Gits</title><link href="https://nathanfriend.com/2021/08/26/herding-gits.html" rel="alternate" type="text/html" title="Herding Gits"/><published>2021-08-26T19:42:44-04:00</published><updated>2021-08-26T19:42:44-04:00</updated><id>https://nathanfriend.com/2021/08/26/herding-gits</id><content type="html" xml:base="https://nathanfriend.com/2021/08/26/herding-gits.html"><![CDATA[<p>Juggling multiple Git identities can be tricky.</p> <figure> <img src="/https/nathanfriend.com/assets/img/herding-gits/git-mosaic.png" alt="A screenshot of the Git logo"/> </figure> <p>For example, at <a href="https://stripe.com/">Stripe</a>, we encourage developers to create a separate GitHub account for Stripe-related open source activity. For me, this means I now own both a <a href="https://github.com/nfriend/"><code class="language-plaintext highlighter-rouge">nfriend</code></a> <em>and</em> a <a href="https://github.com/nfriend-stripe/"><code class="language-plaintext highlighter-rouge">nfriend-stripe</code></a> GitHub profile.</p> <p>While setting up my dev environment, I had a few goals:</p> <ul> <li>Use both Git identities on the same machine</li> <li>Sign commits with separate GPG keys</li> <li>Connect to remotes using different SSH keys</li> <li>Have all of this 👆 happen automatically without me having to think about it</li> </ul> <p><strong>Good news!</strong> This is possible with a little <code class="language-plaintext highlighter-rouge">.gitconfig</code> magic ✨</p> <h2 id="the-setup">The setup</h2> <p><em>Note: The instructions below rely on Git’s <a href="https://git-scm.com/docs/git-config#_conditional_includes">conditional includes</a>, which are only available in Git 2.13 and beyond.</em></p> <h3 id="1-set-up-separate-ssh-and-gpg-keys-for-each-identity">1. Set up separate SSH and GPG keys for each identity</h3> <p>I won’t go into details since this is already covered in great detail by other tutorials. GitHub’s tutorials are particularly well-presented:</p> <ul> <li><a href="https://docs.github.com/en/github/authenticating-to-github/connecting-to-github-with-ssh/generating-a-new-ssh-key-and-adding-it-to-the-ssh-agent">Generating a new SSH key and adding it to the ssh-agent</a></li> <li><a href="https://docs.github.com/en/github/authenticating-to-github/managing-commit-signature-verification/signing-commits">Signing commits</a></li> </ul> <h3 id="2-create-separate-directories-for-each-identity">2. Create separate directories for each identity</h3> <p>For example, a <code class="language-plaintext highlighter-rouge">~/github-personal</code> and a <code class="language-plaintext highlighter-rouge">~/github-work</code> directory.</p> <h3 id="3-create-a-gitconfig_include-file-in-each">3. Create a <code class="language-plaintext highlighter-rouge">.gitconfig_include</code> file in each</h3> <p>Inside each of these new directories, create a new file named <code class="language-plaintext highlighter-rouge">.gitconfig_include</code> with the following content:</p> <div class="language-ini highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nn">[user]</span>
  <span class="py">name</span> <span class="p">=</span> <span class="s">Your Name</span>
  <span class="py">email</span> <span class="p">=</span> <span class="s">your-name@example.com</span>
  <span class="py">signingkey</span> <span class="p">=</span> <span class="s">0123456789ABCDEF</span>

<span class="nn">[core]</span>
  <span class="py">sshCommand</span> <span class="p">=</span> <span class="s">ssh -i ~/.ssh/id_rsa_example -F /dev/null</span>
</code></pre></div></div> <p>Update each file with the name, email, and signing key for the corresponding Git identity.</p> <p>Additionally, update the command in the <code class="language-plaintext highlighter-rouge">sshCommand</code> option to reference the appropriate key file.</p> <h3 id="4-reference-these-files-from-the-global-gitconfig">4. Reference these files from the global <code class="language-plaintext highlighter-rouge">.gitconfig</code></h3> <p>In your global <code class="language-plaintext highlighter-rouge">.gitconfig</code> (i.e. <code class="language-plaintext highlighter-rouge">~/.gitconfig</code>), configure Git to conditionally include the correct <code class="language-plaintext highlighter-rouge">.gitconfig_include</code> file based on the current directory:</p> <div class="language-ini highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nn">[includeIf "gitdir:~/github-personal/"]</span>
  <span class="py">path</span> <span class="p">=</span> <span class="s">~/github-personal/.gitconfig_include</span>

<span class="nn">[includeIf "gitdir:~/github-work/"]</span>
  <span class="py">path</span> <span class="p">=</span> <span class="s">~/github-work/.gitconfig_include</span>
</code></pre></div></div> <h3 id="5-test-it">5. Test it!</h3> <p>Create a test project with both identities. Ensure you can:</p> <ol> <li>Clone the repository from the remote</li> <li>Make a commit</li> <li>Push the commit to the remote</li> </ol> <p>If you’re using a web UI like GitLab or GitHub, check to see that your commits are being signed correctly and are labeled as “Verified”:</p> <figure> <img src="/https/nathanfriend.com/assets/img/herding-gits/commit-verified.png" alt="A screenshot of GitLab showing a 'Verified' label next to a commit"/> </figure> <p><br/></p> <h2 id="helpful-links">Helpful links</h2> <p>Some things I found helpful while setting this up:</p> <ul> <li>Setting <code class="language-plaintext highlighter-rouge">.gitconfig</code> settings on a per-directory basis: <a href="https://stackoverflow.com/a/48088291/1063392">https://stackoverflow.com/a/48088291/1063392</a></li> <li>Using Git’s <code class="language-plaintext highlighter-rouge">core.sshCommand</code> configuration to select an SSH key: <a href="https://superuser.com/a/912281/144803">https://superuser.com/a/912281/144803</a></li> </ul> <h2 id="feedback">Feedback</h2> <p>Thoughts? Let me know in <a href="https://gitlab.com/nfriend/website-3.0/-/issues/7">this GitLab issue</a>!</p>]]></content><author><name></name></author><summary type="html"><![CDATA[Juggling multiple Git identities can be tricky.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://nathanfriend.com/assets/img/herding-gits/git-mosaic.png"/><media:content medium="image" url="https://nathanfriend.com/assets/img/herding-gits/git-mosaic.png" xmlns:media="http://search.yahoo.com/mrss/"/></entry><entry><title type="html">It’s finally here! 🎉</title><link href="https://nathanfriend.com/2021/05/07/its-finally-here.html" rel="alternate" type="text/html" title="It’s finally here! 🎉"/><published>2021-05-07T00:01:01-04:00</published><updated>2021-05-07T00:01:01-04:00</updated><id>https://nathanfriend.com/2021/05/07/its-finally-here</id><content type="html" xml:base="https://nathanfriend.com/2021/05/07/its-finally-here.html"><![CDATA[<p>It’s been almost seven years since <a href="https://nathanfriend.com/inspiral-web/">Inspiral Web</a> was released.</p> <p>For most of those seven years, the site included a “Get the app” button that popped open a message that read “Inspiral Web isn’t mobile friendly… yet” and included a link to join the mailing list.</p> <p>Over 25,000 people joined that mailing list. The sheer number of interested people, combined with just a <em>bit</em> of pandemic cabin fever, spurred me to <em>finally</em> begin work in earnest on the mobile app.</p> <p>After five months of feverish, bleary-eyed, nights-and-weekends coding, I’m thrilled to announce that Inspiral is now available for iOS and Android!</p> <figure> <img src="/https/nathanfriend.com/assets/img/its-finally-here/inspiral.jpg" alt="A screenshot of the Inspiral app"/> </figure> <p>Give it a try on your platform of choice:</p> <p style="display: flex;"> <a style="flex: 1 1 0; padding-right: 0.5rem; min-width: 0" href="https://play.google.com/store/apps/details?id=io.nathanfriend.inspiral"> <img style="width: 100%" src="/https/nathanfriend.com/assets/img/its-finally-here/google-play-badge.svg" alt="Get it on Google Play"/> </a> <a style="flex: 1 1 0; padding-left: 0.5rem; min-width: 0" href="https://apps.apple.com/us/app/inspiral-gear-art/id1558340425"> <img style="width: 100%" src="/https/nathanfriend.com/assets/img/its-finally-here/app-store-badge.svg" alt="Download on the App Store"/> </a> </p> <p>Or, <a href="https://inspiral.nathanfriend.com/">check out the app’s website,</a> which includes links to the app’s social media presence.</p> <p>The app’s <a href="https://gitlab.com/nfriend/inspiral">source code</a> is public. If you find issues or have a feature request, please <a href="https://gitlab.com/nfriend/inspiral/-/issues">open an issue</a>!</p>]]></content><author><name></name></author><summary type="html"><![CDATA[It’s been almost seven years since Inspiral Web was released.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://nathanfriend.com/assets/img/its-finally-here/inspiral.jpg"/><media:content medium="image" url="https://nathanfriend.com/assets/img/its-finally-here/inspiral.jpg" xmlns:media="http://search.yahoo.com/mrss/"/></entry><entry><title type="html">Capturing Alexa Errors with Sentry and GitLab</title><link href="https://nathanfriend.com/2020/11/18/capturing-alexa-errors-with-sentry-and-gitlab.html" rel="alternate" type="text/html" title="Capturing Alexa Errors with Sentry and GitLab"/><published>2020-11-18T09:22:10-05:00</published><updated>2020-11-18T09:22:10-05:00</updated><id>https://nathanfriend.com/2020/11/18/capturing-alexa-errors-with-sentry-and-gitlab</id><content type="html" xml:base="https://nathanfriend.com/2020/11/18/capturing-alexa-errors-with-sentry-and-gitlab.html"><![CDATA[<p>Diagnosing issues with a live Alexa skill can be tricky.</p> <figure> <img src="/https/nathanfriend.com/assets/img/capturing-alexa-errors-with-sentry-and-gitlab/uh-oh.jpg?v=2" alt="The Amazon Echo logo with a speech bubble saying &quot;Uh oh&quot;"/> </figure> <p>Most users who run into issues will simply uninstall your skill. A few unusually inspired users may even leave helpful reviews like this:</p> <figure> <img class="bordered" src="/https/nathanfriend.com/assets/img/capturing-alexa-errors-with-sentry-and-gitlab/this-skill-is-broken.png" alt="An Amazon review that says &qout;This skill is broken&qout;"/> </figure> <p>How do you go about figuring out what’s wrong?</p> <p>By plugging a few open source tools together, you can get great visibility into what’s going wrong.</p> <h2 id="1-implement-an-errorhandler-in-your-skill-code">1. Implement an <code class="language-plaintext highlighter-rouge">ErrorHandler</code> in your skill code</h2> <p>First, you’ll need a way to globally catch errors in your Alexa skill. The ASK SDK provides an <code class="language-plaintext highlighter-rouge">ErrorHandler</code> interface that does just this. Create a new file for your <code class="language-plaintext highlighter-rouge">ErrorHandler</code> implementation:</p> <div class="language-ts highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// lambda/src/handlers/ErrorHandler.ts</span>

<span class="k">import</span> <span class="o">*</span> <span class="kd">as </span><span class="nx">Alexa</span> <span class="k">from</span> <span class="dl">'</span><span class="s1">ask-sdk-core</span><span class="dl">'</span><span class="p">;</span>

<span class="k">export</span> <span class="kd">class</span> <span class="nc">ErrorHandler</span> <span class="k">implements</span> <span class="nx">Alexa</span><span class="p">.</span><span class="nx">ErrorHandler</span> <span class="p">{</span>
  <span class="nf">canHandle</span><span class="p">()</span> <span class="p">{</span>
    <span class="c1">// Handle _all_ exceptions</span>
    <span class="k">return</span> <span class="kc">true</span><span class="p">;</span>
  <span class="p">}</span>
  <span class="nf">handle</span><span class="p">(</span><span class="nx">handlerInput</span><span class="p">:</span> <span class="nx">Alexa</span><span class="p">.</span><span class="nx">HandlerInput</span><span class="p">,</span> <span class="nx">error</span><span class="p">:</span> <span class="nb">Error</span><span class="p">)</span> <span class="p">{</span>
    <span class="nx">console</span><span class="p">.</span><span class="nf">log</span><span class="p">(</span><span class="s2">`~~~~ Error handled: </span><span class="p">${</span><span class="nx">error</span><span class="p">.</span><span class="nx">stack</span><span class="p">}</span><span class="s2">`</span><span class="p">);</span>

    <span class="kd">const</span> <span class="nx">speech</span> <span class="o">=</span> <span class="dl">'</span><span class="s1">Sorry, something went wrong! Can you please try again?</span><span class="dl">'</span><span class="p">;</span>

    <span class="k">return</span> <span class="nx">handlerInput</span><span class="p">.</span><span class="nx">responseBuilder</span>
      <span class="p">.</span><span class="nf">speak</span><span class="p">(</span><span class="nx">speech</span><span class="p">)</span>
      <span class="p">.</span><span class="nf">reprompt</span><span class="p">(</span><span class="nx">speech</span><span class="p">)</span>
      <span class="p">.</span><span class="nf">getResponse</span><span class="p">();</span>
  <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div> <p>(I’m using <a href="https://www.typescriptlang.org/">TypeScript</a> in this example, but a vanilla JS implementation shouldn’t be much different.)</p> <p>Next, register this error handler in your skill’s entrypoint:</p> <div class="language-ts highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// lambda/src/index.ts</span>

<span class="k">import</span> <span class="o">*</span> <span class="kd">as </span><span class="nx">Alexa</span> <span class="k">from</span> <span class="dl">'</span><span class="s1">ask-sdk-core</span><span class="dl">'</span><span class="p">;</span>
<span class="k">import</span> <span class="p">{</span> <span class="nx">ErrorHandler</span> <span class="p">}</span> <span class="k">from</span> <span class="dl">'</span><span class="s1">./handlers/ErrorHandler</span><span class="dl">'</span><span class="p">;</span>

<span class="k">export</span> <span class="kd">const</span> <span class="nx">handler</span> <span class="o">=</span> <span class="nx">Alexa</span><span class="p">.</span><span class="nx">SkillBuilders</span><span class="p">.</span><span class="nf">custom</span><span class="p">()</span>
  <span class="p">.</span><span class="nf">addRequestHandlers</span><span class="p">(</span><span class="cm">/* ...your request handlers here...  */</span><span class="p">)</span>
  <span class="p">.</span><span class="nf">addErrorHandlers</span><span class="p">(</span><span class="k">new</span> <span class="nc">ErrorHandler</span><span class="p">())</span> <span class="c1">// ← add this</span>
  <span class="p">.</span><span class="nf">lambda</span><span class="p">();</span>
</code></pre></div></div> <p>This already gets you pretty close! If anything goes wrong, you’ll have a nice stack trace in your <a href="https://aws.amazon.com/cloudwatch/">CloudWatch</a> logs, and the user will get a polite message informing them something went wrong.</p> <h2 id="2-ship-error-details-to-sentry">2. Ship error details to Sentry</h2> <p><a href="https://sentry.io/welcome/">Sentry</a> is an open source monitoring platform that does a great job of tracking and organizing software errors.</p> <p>After creating a free account at <a href="https://sentry.io/">https://sentry.io</a>, create a new Sentry project with the “Node.js” platform option. Install the dependencies it recommends:</p> <div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>yarn add @sentry/node @sentry/tracing
</code></pre></div></div> <p>Next, set up <code class="language-plaintext highlighter-rouge">@sentry/node</code> with the tracking info it needs. You can do this at the beginning of every Alexa request by creating a new <a href="https://developer.amazon.com/blogs/alexa/post/0e2015e1-8be3-4513-94cb-da000c2c9db0/what-s-new-with-request-and-response-interceptors-in-the-alexa-skills-kit-sdk-for-node-js">request interceptor</a>:</p> <div class="language-ts highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// lambda/src/interceptors/SentryInterceptor.ts</span>

<span class="k">import</span> <span class="o">*</span> <span class="kd">as </span><span class="nx">Alexa</span> <span class="k">from</span> <span class="dl">'</span><span class="s1">ask-sdk-core</span><span class="dl">'</span><span class="p">;</span>
<span class="k">import</span> <span class="o">*</span> <span class="kd">as </span><span class="nx">Sentry</span> <span class="k">from</span> <span class="dl">'</span><span class="s1">@sentry/node</span><span class="dl">'</span><span class="p">;</span>

<span class="k">export</span> <span class="kd">class</span> <span class="nc">SentryInterceptor</span> <span class="k">implements</span> <span class="nx">Alexa</span><span class="p">.</span><span class="nx">RequestInterceptor</span> <span class="p">{</span>
  <span class="k">async</span> <span class="nf">process</span><span class="p">()</span> <span class="p">{</span>
    <span class="nx">Sentry</span><span class="p">.</span><span class="nf">init</span><span class="p">({</span>
      <span class="na">dsn</span><span class="p">:</span> <span class="dl">'</span><span class="s1">&lt;your DSN here&gt;</span><span class="dl">'</span><span class="p">,</span>
      <span class="na">tracesSampleRate</span><span class="p">:</span> <span class="mf">1.0</span><span class="p">,</span>
    <span class="p">});</span>
  <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div> <p>(Your <a href="https://docs.sentry.io/product/sentry-basics/dsn-explainer/">Sentry DSN</a> will be provided to you when setting up your Sentry project.)</p> <p>Don’t forget to register this interceptor, similar to how you registered your <code class="language-plaintext highlighter-rouge">ErrorHandler</code> above:</p> <div class="language-ts highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// lambda/src/index.ts</span>

<span class="k">import</span> <span class="o">*</span> <span class="kd">as </span><span class="nx">Alexa</span> <span class="k">from</span> <span class="dl">'</span><span class="s1">ask-sdk-core</span><span class="dl">'</span><span class="p">;</span>
<span class="k">import</span> <span class="p">{</span> <span class="nx">ErrorHandler</span> <span class="p">}</span> <span class="k">from</span> <span class="dl">'</span><span class="s1">./handlers/ErrorHandler</span><span class="dl">'</span><span class="p">;</span>

<span class="k">export</span> <span class="kd">const</span> <span class="nx">handler</span> <span class="o">=</span> <span class="nx">Alexa</span><span class="p">.</span><span class="nx">SkillBuilders</span><span class="p">.</span><span class="nf">custom</span><span class="p">()</span>
  <span class="p">.</span><span class="nf">addRequestHandlers</span><span class="p">(</span><span class="cm">/* ...your request handlers here...  */</span><span class="p">)</span>
  <span class="p">.</span><span class="nf">addRequestInterceptors</span><span class="p">(</span><span class="k">new</span> <span class="nc">SentryInterceptor</span><span class="p">())</span> <span class="c1">// ← add this</span>
  <span class="p">.</span><span class="nf">addErrorHandlers</span><span class="p">(</span><span class="k">new</span> <span class="nc">ErrorHandler</span><span class="p">())</span>
  <span class="p">.</span><span class="nf">lambda</span><span class="p">();</span>
</code></pre></div></div> <p>Finally, in the error handler you created earlier, send the error to Sentry:</p> <div class="language-ts highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// lambda/src/handlers/ErrorHandler.ts</span>

<span class="k">import</span> <span class="o">*</span> <span class="kd">as </span><span class="nx">Alexa</span> <span class="k">from</span> <span class="dl">'</span><span class="s1">ask-sdk-core</span><span class="dl">'</span><span class="p">;</span>
<span class="k">import</span> <span class="o">*</span> <span class="kd">as </span><span class="nx">Sentry</span> <span class="k">from</span> <span class="dl">'</span><span class="s1">@sentry/node</span><span class="dl">'</span><span class="p">;</span> <span class="c1">// ← add this</span>

<span class="k">export</span> <span class="kd">class</span> <span class="nc">ErrorHandler</span> <span class="k">implements</span> <span class="nx">Alexa</span><span class="p">.</span><span class="nx">ErrorHandler</span> <span class="p">{</span>
  <span class="nf">canHandle</span><span class="p">()</span> <span class="p">{</span>
    <span class="c1">// Handle _all_ exceptions</span>
    <span class="k">return</span> <span class="kc">true</span><span class="p">;</span>
  <span class="p">}</span>
  <span class="nf">handle</span><span class="p">(</span><span class="nx">handlerInput</span><span class="p">:</span> <span class="nx">Alexa</span><span class="p">.</span><span class="nx">HandlerInput</span><span class="p">,</span> <span class="nx">error</span><span class="p">:</span> <span class="nb">Error</span><span class="p">)</span> <span class="p">{</span>
    <span class="nx">console</span><span class="p">.</span><span class="nf">log</span><span class="p">(</span><span class="s2">`~~~~ Error handled: </span><span class="p">${</span><span class="nx">error</span><span class="p">.</span><span class="nx">stack</span><span class="p">}</span><span class="s2">`</span><span class="p">);</span>

    <span class="nx">Sentry</span><span class="p">.</span><span class="nf">captureException</span><span class="p">(</span><span class="nx">error</span><span class="p">);</span> <span class="c1">// ← also add this</span>

    <span class="kd">const</span> <span class="nx">speech</span> <span class="o">=</span> <span class="dl">'</span><span class="s1">Sorry, something went wrong! Can you please try again?</span><span class="dl">'</span><span class="p">;</span>

    <span class="k">return</span> <span class="nx">handlerInput</span><span class="p">.</span><span class="nx">responseBuilder</span>
      <span class="p">.</span><span class="nf">speak</span><span class="p">(</span><span class="nx">speech</span><span class="p">)</span>
      <span class="p">.</span><span class="nf">reprompt</span><span class="p">(</span><span class="nx">speech</span><span class="p">)</span>
      <span class="p">.</span><span class="nf">getResponse</span><span class="p">();</span>
  <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div> <p>That’s it! Now all that’s left is to…</p> <h2 id="3-test-it">3. Test it!</h2> <p>In your <code class="language-plaintext highlighter-rouge">LaunchRequest</code> handler, do something silly like this:</p> <div class="language-ts highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">const</span> <span class="nx">anObject</span><span class="p">:</span> <span class="kr">any</span> <span class="o">=</span> <span class="p">{};</span>
<span class="nx">anObject</span><span class="p">.</span><span class="nf">aMethodThatDoesntExist</span><span class="p">();</span>
</code></pre></div></div> <p>(I’m explicitly specifying <code class="language-plaintext highlighter-rouge">any</code> here, otherwise TypeScript won’t let me get away with this!)</p> <p>Deploy your skill and give it a spin!</p> <div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>❯ ask dialog
User  <span class="o">&gt;</span> Open &lt;your skill name here&gt;
Alexa <span class="o">&gt;</span> Sorry, something went wrong! Can you please try again?
</code></pre></div></div> <p>Jump back into your Sentry project - you should now be the proud owner of a new issue:</p> <figure> <img class="bordered" src="/https/nathanfriend.com/assets/img/capturing-alexa-errors-with-sentry-and-gitlab/sentry-screenshot.png" alt="A screenshot of the Sentry dashboard with a new issue"/> </figure> <p><br/></p> <h2 id="4-integrate-sentry-with-gitlab-optional">4. Integrate Sentry with GitLab <em>(optional)</em></h2> <p>If you host you skill’s code on <a href="https://about.gitlab.com/">GitLab</a> you can take advantage of GitLab’s <a href="https://docs.gitlab.com/ee/operations/error_tracking.html">first-class Sentry integration</a> to see error details directly in your GitLab project:</p> <ol> <li>From your GitLab project, navigate to <strong>Settings &gt; Operations</strong></li> <li>Expand the <strong>Error tracking</strong> section</li> <li>Check the <strong>Active</strong> checkbox</li> <li>Assuming you are using Sentry’s hosted solution, enter <code class="language-plaintext highlighter-rouge">https://sentry.io/</code> in the <strong>Sentry API URL</strong> field</li> <li>Paste your Sentry auth token into the <strong>Auth Token</strong> field. To generate an auth token in Sentry: <ol> <li>Navigate to your Sentry dashboard</li> <li>Click the ▼ next to your name and select <strong>API keys</strong></li> <li>Click <strong>Create New Token</strong></li> <li>Leave the default scopes as they are and click <strong>Create Token</strong></li> <li>Copy the big string of gibberish</li> </ol> </li> <li>Jump back to GitLab and click <strong>Connect</strong> and select your Sentry project</li> <li>Click <strong>Save changes</strong></li> </ol> <p>That’s it! Navigate to <strong>Operations &gt; Error Tracking</strong>. You should see the same set of Sentry errors nicely displayed inside your GitLab project.</p> <figure> <img class="bordered" src="/https/nathanfriend.com/assets/img/capturing-alexa-errors-with-sentry-and-gitlab/gitlab-error-tracking-screenshot.png?v=2" alt="A screenshot of GitLab's Error Tracking page showing an issue's details"/> </figure> <p><br/></p> <h2 id="source">Source</h2> <p>See this code in action at <a href="https://gitlab.com/nfriend/days-until">https://gitlab.com/nfriend/days-until</a>, or give my <a href="https://www.amazon.com/dp/B0759KJ8D2">Days Until</a> skill a try for yourself. <a href="https://alexa-skills.amazon.com/apis/custom/skills/amzn1.ask.skill.a904b878-9342-4031-9e86-a81ac29bddca/launch">Here’s a quick link to the skill!</a></p>]]></content><author><name></name></author><summary type="html"><![CDATA[Diagnosing issues with a live Alexa skill can be tricky.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://nathanfriend.com/assets/img/capturing-alexa-errors-with-sentry-and-gitlab/uh-oh.jpg?v=2"/><media:content medium="image" url="https://nathanfriend.com/assets/img/capturing-alexa-errors-with-sentry-and-gitlab/uh-oh.jpg?v=2" xmlns:media="http://search.yahoo.com/mrss/"/></entry><entry><title type="html">Ridiculous Refs</title><link href="https://nathanfriend.com/2019/10/19/ridiculous-refs.html" rel="alternate" type="text/html" title="Ridiculous Refs"/><published>2019-10-19T18:27:01-04:00</published><updated>2019-10-19T18:27:01-04:00</updated><id>https://nathanfriend.com/2019/10/19/ridiculous-refs</id><content type="html" xml:base="https://nathanfriend.com/2019/10/19/ridiculous-refs.html"><![CDATA[<p>How many different special characters can you jam into a <a href="https://git-scm.com/book/en/v2/Git-Internals-Git-References">Git ref</a> name (i.e. the full name of a <a href="https://git-scm.com/book/en/v2/Git-Basics-Tagging">tag</a> or a <a href="https://git-scm.com/book/en/v2/Git-Branching-Basic-Branching-and-Merging">branch</a>) before Git will complain?</p> <figure> <img src="/https/nathanfriend.com/assets/img/ridiculous-refs/mind-blown.png" alt="The git logo with a exploding head inside"/> <figcaption>As if Git isn't complicated enough <em>without</em> testing its limits.</figcaption> </figure> <p>The answer is… a <em>lot</em>.</p> <p>Here’s a list of ref names that Git considers valid, ranging from pedestrian to ludicrous:</p> <ol> <li><code class="language-plaintext highlighter-rouge">a/b</code></li> <li><code class="language-plaintext highlighter-rouge">A/B</code></li> <li><code class="language-plaintext highlighter-rouge">a/b/c/d</code></li> <li><code class="language-plaintext highlighter-rouge">a/b.c</code></li> <li><code class="language-plaintext highlighter-rouge">0/1/2</code></li> <li><code class="language-plaintext highlighter-rouge">!/"#%&amp;'{}+,-.&lt;&gt;;=@]_`{|}</code></li> <li><code class="language-plaintext highlighter-rouge">🙂🚀😂🇺🇸💩🇨🇦💯👍❤️/🤦</code></li> <li><code class="language-plaintext highlighter-rouge">(╯°□°)/╯︵┻━┻</code></li> <li><code class="language-plaintext highlighter-rouge">¯|_(ツ)_/¯</code></li> </ol> <p>The opposite is a little less flashy, but equally useful. All of these are <em>invalid</em> ref names:</p> <ol> <li><code class="language-plaintext highlighter-rouge">a</code>: At least one <code class="language-plaintext highlighter-rouge">/</code> is required</li> <li><code class="language-plaintext highlighter-rouge">a/.b</code>: Slash-separated components can’t begin with a <code class="language-plaintext highlighter-rouge">.</code></li> <li><code class="language-plaintext highlighter-rouge">a/b.</code>: No ending with a <code class="language-plaintext highlighter-rouge">.</code></li> <li><code class="language-plaintext highlighter-rouge">a/.lock</code>: No ending with <code class="language-plaintext highlighter-rouge">.lock</code></li> <li><code class="language-plaintext highlighter-rouge">a/b..c</code>: Consecutive dots (<code class="language-plaintext highlighter-rouge">..</code>) are not allowed</li> <li><code class="language-plaintext highlighter-rouge">a/~^:?*[\</code>: None of the characters after <code class="language-plaintext highlighter-rouge">a/</code> are allowed</li> <li><code class="language-plaintext highlighter-rouge">a/b/</code>: No ending with a slash</li> <li><code class="language-plaintext highlighter-rouge">/a/b</code>: No beginning with a slash</li> <li><code class="language-plaintext highlighter-rouge">a/b//c</code>: No consecutive slashes</li> <li><code class="language-plaintext highlighter-rouge">a/@{</code>: The <code class="language-plaintext highlighter-rouge">@{</code> sequence is not allowed</li> </ol> <p>There’s some nuance to this second list - some of these rules can be relaxed in special situations. For the complete specification, check out <a href="https://git-scm.com/docs/git-check-ref-format">the documentation for the <code class="language-plaintext highlighter-rouge">check-ref-format</code> command</a>.</p> <h2 id="wait-ive-created-lots-of-branches-and-tags-that-dont-contain-a-">Wait, I’ve created lots of branches and tags that don’t contain a <code class="language-plaintext highlighter-rouge">/</code>!</h2> <p>Right! When you create a branch named <code class="language-plaintext highlighter-rouge">my-feature</code>, Git actually creates a ref named <code class="language-plaintext highlighter-rouge">refs/heads/my-feature</code>.</p> <p>This is a bit of a tangent, but what Git is <em>actually</em> doing under the hood is creating a new file named <code class="language-plaintext highlighter-rouge">my-feature</code> inside your repo’s <code class="language-plaintext highlighter-rouge">.git</code> directory at <code class="language-plaintext highlighter-rouge">.git/refs/heads/my-feature</code>. You can see this for yourself by opening up <code class="language-plaintext highlighter-rouge">.git/refs/heads</code> in a file explorer. Understanding this makes it more obvious why the <code class="language-plaintext highlighter-rouge">/</code> character is required.</p> <h2 id="why-should-i-care-about-this">Why should I care about this?</h2> <p>If all you do with Git is <code class="language-plaintext highlighter-rouge">pull</code>, <code class="language-plaintext highlighter-rouge">commit</code>, <code class="language-plaintext highlighter-rouge">push</code>, and maybe the occasional <code class="language-plaintext highlighter-rouge">rebase</code>, you can ignore these edge cases. Just keep using nice, simple, boring names like <code class="language-plaintext highlighter-rouge">my-feature-branch</code> or <code class="language-plaintext highlighter-rouge">v1.2</code>.</p> <p>However, if you’re building a tool that <em>interacts</em> with Git refs, you might want to throw a few of the crazier names listed above into your test cases.</p> <p>In fact, this was my motivation for compiling these lists. I built <a href="https://gitlab.com/gitlab-org/gitlab/merge_requests/18761">a new GitLab Issue search feature that involved Git tag names</a>, and I needed to make sure it handled any tag name the user threw at it. I was hoping to find something like the <a href="https://github.com/minimaxir/big-list-of-naughty-strings">Big List of Naughty Strings</a> for Git refs, but I couldn’t find anything beyond Git’s technical documentation.</p> <h2 id="your-list-is-wrong">Your list is wrong!</h2> <p>I wouldn’t be surprised! <a href="https://gitlab.com/nfriend/website-3.0/issues/new?issue[title]=There%27s%20a%20problem%20with%20your%20Ridiculous%20Refs%20list!">Please let me know by opening an issue!</a></p> <h2 id="referencesattributions">References/Attributions</h2> <p>The “exploding head” icon was created by Anniken &amp; Andreas from the Noun Project.</p>]]></content><author><name></name></author><summary type="html"><![CDATA[How many different special characters can you jam into a Git ref name (i.e. the full name of a tag or a branch) before Git will complain?]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://nathanfriend.com/assets/img/ridiculous-refs/mind-blown.png"/><media:content medium="image" url="https://nathanfriend.com/assets/img/ridiculous-refs/mind-blown.png" xmlns:media="http://search.yahoo.com/mrss/"/></entry><entry><title type="html">PDF Gotchas with Headless Chrome</title><link href="https://nathanfriend.com/2019/04/15/pdf-gotchas-with-headless-chrome.html" rel="alternate" type="text/html" title="PDF Gotchas with Headless Chrome"/><published>2019-04-15T05:20:55-04:00</published><updated>2019-04-15T05:20:55-04:00</updated><id>https://nathanfriend.com/2019/04/15/pdf-gotchas-with-headless-chrome</id><content type="html" xml:base="https://nathanfriend.com/2019/04/15/pdf-gotchas-with-headless-chrome.html"><![CDATA[<p>Generating PDF reports is one of those features that <em>every</em> enterprise developer will implement at some point in their career. I had my turn on a project with my previous employer. After exploring the available options, I settled on using <a href="https://developers.google.com/web/updates/2017/04/headless-chrome">Chrome’s headless mode</a> to render HTML and save the result as a PDF.</p> <figure> <img src="/https/nathanfriend.com/assets/img/pdf-gotchas-with-headless-chrome/minifigure-chrome.jpg" alt="A headless minifigure and a Chrome logo"/> <figcaption>"Headless Chrome" just sounds so.... gruesome.</figcaption> </figure> <p>This approach seems kind of weird and a bit overkill at first, but it has a number of pretty huge advantages:</p> <ul> <li>You can build your PDFs using the most popular layout system in the world (HTML/CSS)</li> <li>Anything that can be rendered in a webpage can be used in a PDF, including: <ul> <li>images</li> <li>custom fonts</li> <li>links</li> <li>3<sup>rd</sup> party JavaScript libraries (think visualization libraries like <a href="https://d3js.org/">D3.js</a> or graphing libraries like <a href="https://developers.google.com/chart/">Google Charts</a>)</li> <li><em>etc…</em></li> </ul> </li> <li>If you’re building a web application, you can reuse components from your application in your PDF</li> <li>Printing to a PDF is a <a href="https://developers.google.com/web/updates/2017/04/headless-chrome#create_a_pdf_dom">supported use case</a> of Chrome’s headless mode</li> <li>Google’s own <a href="https://pptr.dev/">Puppeteer</a> library gives you full control over the headless instance of Chrome</li> <li>You can develop your PDF layout in Chrome - with full access to Chrome’s dev tools - instead of continually regenerating the PDF to see changes</li> </ul> <p>It’s not all unicorns and rainbows, though. Below are a few of the gotchas I discovered while building a real PDF using headless Chrome.</p> <h2 id="headers-and-footers-cant-use-external-resources">Headers and footers can’t use external resources</h2> <p>This is the big one. If you try and place an <code class="language-plaintext highlighter-rouge">&lt;img&gt;</code> tag in your header or footer (a pretty common use case for a header or footer):</p> <div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">&lt;img</span> <span class="na">src=</span><span class="s">"/assets/logo.jpg"</span> <span class="nt">/&gt;</span>
</code></pre></div></div> <p>…your image won’t show up. This is because Chrome won’t make any requests for external resources that appear in the header or footer templates.</p> <p>One workaround is to encode the image into the template as a base64’d string:</p> <div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">&lt;img</span> <span class="na">src=</span><span class="s">"data:image/png;base64, iVBORw0KGg..."</span> <span class="nt">/&gt;</span>
</code></pre></div></div> <p>I’ve found <a href="https://www.base64-image.de/">this site</a> handy for converting an image into an <code class="language-plaintext highlighter-rouge">&lt;img&gt;</code>-compatible base64 string.</p> <h2 id="headers-and-footers-dont-inherit-styles-from-the-rest-of-the-page">Headers and footers don’t inherit styles from the rest of the page</h2> <p>Headers and footers are specified at PDF render time by passing HTML strings to the <code class="language-plaintext highlighter-rouge">page.pdf()</code> method:</p> <div class="language-ts highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nx">page</span><span class="p">.</span><span class="nf">pdf</span><span class="p">({</span>
  <span class="na">headerTemplate</span><span class="p">:</span> <span class="dl">'</span><span class="s1">&lt;h1&gt;This is the header!&lt;/h1&gt;</span><span class="dl">'</span><span class="p">,</span>
  <span class="na">footerTemplate</span><span class="p">:</span> <span class="dl">'</span><span class="s1">&lt;h1&gt;This is the footer!&lt;/h1&gt;</span><span class="dl">'</span><span class="p">,</span>
<span class="p">});</span>
</code></pre></div></div> <p>These templates are rendered in a separate context than the content of the webpage. Because of this, the CSS styles that apply to the content won’t apply to the header and the footer. Any styles that apply to the content of your PDF that you would like to also apply to your header and footer must be repeated in each of your header and footer templates. And unfortunately, you can’t just reference a common stylesheet using a <code class="language-plaintext highlighter-rouge">&lt;link&gt;</code> element - see point #1 above.</p> <h2 id="headers-and-footers-require-explicit-margins-to-be-visible">Headers and footers require explicit margins to be visible</h2> <p>This one took me a while to figure out. Chrome won’t automatically resize your content to make space for the header and footer templates. You’ll need to make space for your header and footer by specifying a fixed margin at the top and bottom of your page:</p> <div class="language-ts highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nx">page</span><span class="p">.</span><span class="nf">pdf</span><span class="p">({</span>
  <span class="na">headerTemplate</span><span class="p">:</span> <span class="dl">'</span><span class="s1">&lt;h1&gt;This is the header!&lt;/h1&gt;</span><span class="dl">'</span><span class="p">,</span>
  <span class="na">footerTemplate</span><span class="p">:</span> <span class="dl">'</span><span class="s1">&lt;h1&gt;This is the footer!&lt;/h1&gt;</span><span class="dl">'</span><span class="p">,</span>
  <span class="na">margin</span><span class="p">:</span> <span class="p">{</span>
    <span class="na">top</span><span class="p">:</span> <span class="dl">'</span><span class="s1">100px</span><span class="dl">'</span><span class="p">,</span>
    <span class="na">bottom</span><span class="p">:</span> <span class="dl">'</span><span class="s1">50px</span><span class="dl">'</span><span class="p">,</span>
  <span class="p">},</span>
<span class="p">});</span>
</code></pre></div></div> <p>Without these margins, the content will be rendered on top of your header and footer, leaving you wondering why your header and footer templates aren’t showing up.</p> <h2 id="page-breaks-can-be-a-pain">Page breaks can be a pain</h2> <p>CSS provides some rules that determine where a page break should be placed when printing, for example:</p> <div class="language-css highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">@media</span> <span class="n">print</span> <span class="p">{</span>
  <span class="nc">.page</span> <span class="p">{</span>
    <span class="nl">page-break-after</span><span class="p">:</span> <span class="nb">always</span><span class="p">;</span>
  <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div> <p>These rules work - but they can be finicky. You may run into problem when trying to page break inside of<sup>[1]</sup>:</p> <ul> <li>tables</li> <li>floating elements</li> <li>inline-block elements</li> <li>block elements with borders</li> </ul> <p>I also had issues using <code class="language-plaintext highlighter-rouge">page-break-after</code> inside of a flexbox layout.</p> <h2 id="some-advanced-layouts-simply-arent-possible">Some advanced layouts simply aren’t possible</h2> <p>There are a few edge cases - mostly dealing with headers/footers and page wrapping - that you simply can’t control. For example, want to place a special footer only on pages 2, 4, and 7? Not possible. (If it is, <a href="mailto:hello@nathanfriend.com">let me know how!</a>)</p> <h2 id="the-page-needs-to-finish-loading">The page needs to finish loading</h2> <p>If the page being screenshotted requires time to load, (for example, if the page has JavaScript that makes an AJAX request for some data), you’ll need to wait for this initialization to complete before triggering the screenshot. If you simply screenshot the page right after the initial load, your PDF will be filled with loading bars and missing data.</p> <p>I worked around this by setting a global flag in the webpage once all initialization work is finished:</p> <div class="language-ts highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// in the web page</span>
<span class="k">async</span> <span class="nf">init</span><span class="p">()</span> <span class="p">{</span>
    <span class="kd">const</span> <span class="nx">data</span> <span class="o">=</span> <span class="k">await</span> <span class="k">this</span><span class="p">.</span><span class="nx">dataService</span><span class="p">.</span><span class="nf">getData</span><span class="p">();</span>
    <span class="kd">const</span> <span class="nx">user</span> <span class="o">=</span> <span class="k">await</span> <span class="k">this</span><span class="p">.</span><span class="nx">userService</span><span class="p">.</span><span class="nf">getUserProfile</span><span class="p">();</span>

    <span class="c1">// ...etc...</span>

    <span class="nb">window</span><span class="p">.</span><span class="nx">isReadyForPDF</span> <span class="o">=</span> <span class="kc">true</span><span class="p">;</span>
<span class="p">}</span>
</code></pre></div></div> <p>Then, using Puppeteer’s <a href="https://pptr.dev/#?product=Puppeteer&amp;version=v1.10.0&amp;show=api-pagewaitforfunctionpagefunction-options-args"><code class="language-plaintext highlighter-rouge">page.waitForFunction()</code></a> method, we can wait for this global variable to bet set:</p> <div class="language-ts highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// on the server</span>
<span class="k">await</span> <span class="nx">page</span><span class="p">.</span><span class="nf">waitForFunction</span><span class="p">(</span><span class="dl">'</span><span class="s1">window.isReadyForPDF</span><span class="dl">'</span><span class="p">);</span>
<span class="c1">// now we know the page is ready for a screenshot</span>
</code></pre></div></div> <h2 id="the-page-might-require-authentication">The page might require authentication</h2> <p>If the page you’re screenshotting is part of a web application, it’s likely there’s an authentication step that’s required to view the page. This can be a bit of a pain to work around, but fortunately, Puppeteer provides enough control to programmatically log in to the application:</p> <div class="language-ts highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">await</span> <span class="nx">page</span><span class="p">.</span><span class="nf">waitForSelector</span><span class="p">(</span><span class="dl">'</span><span class="s1">#username</span><span class="dl">'</span><span class="p">);</span>
<span class="k">await</span> <span class="nx">page</span><span class="p">.</span><span class="nf">waitForSelector</span><span class="p">(</span><span class="dl">'</span><span class="s1">#password</span><span class="dl">'</span><span class="p">);</span>

<span class="k">await</span> <span class="nx">page</span><span class="p">.</span><span class="nf">evaluate</span><span class="p">(()</span> <span class="o">=&gt;</span> <span class="p">{</span>
  <span class="nb">document</span><span class="p">.</span><span class="nf">querySelector</span><span class="p">(</span><span class="dl">'</span><span class="s1">#username</span><span class="dl">'</span><span class="p">).</span><span class="nx">value</span> <span class="o">=</span> <span class="dl">'</span><span class="s1">my-username</span><span class="dl">'</span><span class="p">;</span>
  <span class="nb">document</span><span class="p">.</span><span class="nf">querySelector</span><span class="p">(</span><span class="dl">'</span><span class="s1">#password</span><span class="dl">'</span><span class="p">).</span><span class="nx">value</span> <span class="o">=</span> <span class="dl">'</span><span class="s1">my-password</span><span class="dl">'</span><span class="p">;</span>
  <span class="nb">document</span><span class="p">.</span><span class="nf">querySelector</span><span class="p">(</span><span class="dl">'</span><span class="s1">#log-in-button</span><span class="dl">'</span><span class="p">).</span><span class="nf">click</span><span class="p">();</span>
<span class="p">});</span>
</code></pre></div></div> <p>There are some downsides to this approach, though:</p> <ul> <li>You now need to maintain a system account for logging in to the application for PDF screenshots</li> <li>The extra login step adds quite a bit of time to the PDF generation process</li> <li>Your PDF generation is now dependent on your authentication/authorization system</li> <li>It just feels kind of wrong</li> </ul> <hr/> <p>Disclaimer: my PDF generator was written in .NET Core, so I actually used a library called <a href="https://www.puppeteersharp.com/">Puppeteer Sharp</a> which aims to replicate the API of the official <a href="https://pptr.dev/">Puppeteer library</a> (which runs on Node). Some of the code examples above might be slightly off since I translated them from C♯ into JavaScript.</p> <hr/> <p><strong>References/Attributions</strong></p> <p>[1]: <a href="https://stackoverflow.com/a/26265549/1063392">https://stackoverflow.com/a/26265549/1063392</a></p> <p>Minifigure/Chrome image from <a href="https://hackernoon.com/so-many-testing-frameworks-so-little-time-b03c707b8f90">https://hackernoon.com/so-many-testing-frameworks-so-little-time-b03c707b8f90</a></p>]]></content><author><name></name></author><summary type="html"><![CDATA[Generating PDF reports is one of those features that every enterprise developer will implement at some point in their career. I had my turn on a project with my previous employer. After exploring the available options, I settled on using Chrome’s headless mode to render HTML and save the result as a PDF.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://nathanfriend.com/assets/img/pdf-gotchas-with-headless-chrome/minifigure-chrome.jpg"/><media:content medium="image" url="https://nathanfriend.com/assets/img/pdf-gotchas-with-headless-chrome/minifigure-chrome.jpg" xmlns:media="http://search.yahoo.com/mrss/"/></entry></feed>