<?xml version="1.0" encoding="utf-8" ?>
<feed xmlns="http://www.w3.org/2005/Atom">
  <title>CodeJam</title>
  <subtitle>Hey, I’m Val, welcome to my blog!</subtitle>
  <link href="https://www.codejam.info/feed.xml" rel="self" />
  <link href="https://www.codejam.info/" />
  <id>https://www.codejam.info/</id>
  <updated>2026-06-29T22:23:30.841Z</updated>
  <author>
    <name>Val</name>
  </author>
  <entry>
    <title>Generate subtitles for audio/video on Mac</title>
    <link href="https://www.codejam.info/2026/06/subtitles-audio-video-mac.html" />
    <id>https://www.codejam.info/2026/06/subtitles-audio-video-mac.html</id>
    <updated>2026-06-29T07:00:00.000Z</updated>
    <content type="html"><![CDATA[<p>I started posting daily <a href="https://x.com/valeriangalliat">on X</a>,
including, sometimes, videos. And it turns out X isn’t able to
automatically generate captions for the video like other platforms do.
They do give the option to upload a SRT file though.</p>
<p>Since I want to keep things minimal I’m gonna use that option, as I
don’t want to get down the rabbit hole of burning stylized captions in
the video itself like you see all over short form content. That’s for
another day.</p>
<p>Most of the options to create subtitles from audio require to call a
paid transcription API, like OpenAI’s <code>gpt-4o-transcribe</code> or ElevenLabs.
But I’m not paying to caption my random yapping on X, thank you.</p>
<h2 id="the-best-local-option" tabindex="-1"><a class="header-anchor" href="https://www.codejam.info/2026/06/subtitles-audio-video-mac.html#the-best-local-option"><span>The best local option</span></a></h2>
<p>As far as I can tell the best open-source model for this that can be run
locally is <a href="https://github.com/openai/whisper">OpenAI Whisper</a>,
originally released in 2022, with a v3 update in 2023.</p>
<p>While not state of the art, we can run this locally for free and it
performed perfectly in my case of speaking English with a heavy French
accent (which often defeats transcription models including the ones of
Slack and YouTube).</p>
<p>The following is for a Silicon Mac using Apple MLX for performance:</p>
<pre><code class="hljs language-sh">brew install pipx
pipx install mlx-whisper
</code></pre>
<p>Then:</p>
<pre><code class="hljs language-sh">mlx_whisper your-video.mp4 --model mlx-community/whisper-large-v3-mlx -f srt
</code></pre>
<p>This will produce a SRT file next to the input video.</p>
<p>You can see how it performs with the captions on <a href="https://x.com/valeriangalliat/status/2071720584397176976">this post on X</a>
(spoiler: it’s good).</p>
<h2 id="turbo-version" tabindex="-1"><a class="header-anchor" href="https://www.codejam.info/2026/06/subtitles-audio-video-mac.html#turbo-version"><span>Turbo version</span></a></h2>
<p>It’s also possible to use the Whisper v3 Turbo variant that OpenAI released in
2024, which is <a href="https://huggingface.co/openai/whisper-large-v3-turbo">“way faster at the expense of a minor quality degradation”</a>.</p>
<pre><code class="hljs language-sh">mlx_whisper your-video.mp4 --model mlx-community/whisper-large-v3-turbo -f srt
</code></pre>
<p>However I had some slight glitch with it that didn’t happen in the
normal version. Also it seems to make longer lines in captions, and I
like the way the non-Turbo version splits lines better.</p>
<h2 id="non-mlx" tabindex="-1"><a class="header-anchor" href="https://www.codejam.info/2026/06/subtitles-audio-video-mac.html#non-mlx"><span>Non-MLX</span></a></h2>
<p>For the non-MLX version if you need. This should run on any Mac.</p>
<pre><code class="hljs language-sh">brew install openai-whisper
whisper your-video.mpk --model large --output_format srt
whisper your-video.mpk --model turbo --output_format srt
</code></pre>
<h2 id="even-faster" tabindex="-1"><a class="header-anchor" href="https://www.codejam.info/2026/06/subtitles-audio-video-mac.html#even-faster"><span>Even faster?</span></a></h2>
<p>For even faster transcription, I heard about
<a href="https://github.com/mustafaaljadery/lightning-whisper-mlx">lightning-whisper-mlx</a>,
but didn’t get to try it as it doesn’t come with a CLI. Need to call it
from Python instead, and I’m lazy to do that given that <code>mlx_whisper</code>
is way fast enough for my short videos anyway.</p>
<p>Let me know how it goes if you try it. ✌️</p>
<section class="post-footer">
  <h3>Want to leave a comment?</h3>
  <p>
    Join the discussion on <a href="https://x.com/valeriangalliat/status/2071720584397176976">X</a> or send me an <a href="mailto:val@codejam.info">email</a>! 💌<br>
    This post helped you? <a href="https://ko-fi.com/funkyval">Buy me a coffee</a>! 🍻
  </p>
</section>
]]></content>
  </entry>
  <entry>
    <title>LG UltraFine 4K display randomly flickering</title>
    <link href="https://www.codejam.info/2026/06/lg-ultrafine-4k-display-randomly-flickering.html" />
    <id>https://www.codejam.info/2026/06/lg-ultrafine-4k-display-randomly-flickering.html</id>
    <updated>2026-06-29T07:00:00.000Z</updated>
    <content type="html"><![CDATA[<p>I have a <a href="https://www.lg.com/ca_en/monitors/uhd-4k-5k/24md4klb-b/">LG UltraFine 4K</a>
monitor (24MD4KLB-B, the one made in partnership with Apple in 2019 for deep
macOS integration).</p>
<p>This year it randomly started flickering. Literally once second it was
OK, and the next it was flickering. It’s been plugged the same way for
years (I use it with a Lightning cable on my MacBook, which also powers
the laptop). Nothing changed.</p>
<p>Tried a bunch of things including unplugging and re-plugging the
display, both from the laptop and from the power outlet, as well as
restarting the computer. No change.</p>
<p>Flickers varies from the whole screen flashing black at a fast rate, or
some specific portions of the screen flashing with more of a “noise”
pattern? And more recently, ghosting of what I had on screen at the time
it started flickering.</p>
<p>The only report I found is <a href="https://www.reddit.com/r/MacOS/comments/1m275y6/lg_ultrafine_4k_monitor_blinking_like_crazy_only/">this Reddit post</a>
but sadly no solution there (until I posted mine more recently).</p>
<h2 id="solution" tabindex="-1"><a class="header-anchor" href="https://www.codejam.info/2026/06/lg-ultrafine-4k-display-randomly-flickering.html#solution"><span>Solution</span></a></h2>
<p>The only thing that seems to reliably fix this for me is to <strong>plug the
laptop with a power adapter</strong>, instead of relying on the screen to power
and charge it.</p>
<p>It’s not instant but it seems after leaving the computer plugged for a
few minutes or sometimes a few hours, the flickering is gone.</p>
<p>Then you can use the screen for power again, and in my experience it’s
fine again for weeks or even months until the next instance of
flickering. 🤷</p>
<section class="post-footer">
  <h3>Want to leave a comment?</h3>
  <p>
    Start a conversation on <a href="https://x.com/valeriangalliat">X</a> or send me an <a href="mailto:val@codejam.info">email</a>! 💌<br>
    This post helped you? <a href="https://ko-fi.com/funkyval">Buy me a coffee</a>! 🍻
  </p>
</section>
]]></content>
  </entry>
  <entry>
    <title>How much is Composer 2.5 subsidized in Cursor?</title>
    <link href="https://www.codejam.info/2026/06/how-much-is-composer-2-5-subsidized-in-cursor.html" />
    <id>https://www.codejam.info/2026/06/how-much-is-composer-2-5-subsidized-in-cursor.html</id>
    <updated>2026-06-23T07:00:00.000Z</updated>
    <content type="html"><![CDATA[<h2 id="tldr" tabindex="-1"><a class="header-anchor" href="https://www.codejam.info/2026/06/how-much-is-composer-2-5-subsidized-in-cursor.html#tldr"><span>TLDR</span></a></h2>
<p>According to my own usage, 10x. If you need more than 10% extra usage
from the Pro plan, don’t use on-demand pricing, get a second Pro plan.
If you need more than 10% extra of that second one, get Pro+.</p>
<div class="note">
<p>In this post:</p>
<ul>
<li><a href="https://www.codejam.info/2026/06/how-much-is-composer-2-5-subsidized-in-cursor.html#what-i-think-of-composer-2-5">What I think of Composer 2.5</a></li>
<li><a href="https://www.codejam.info/2026/06/how-much-is-composer-2-5-subsidized-in-cursor.html#composer-2-5-vs-composer-2-5-fast-vs-auto">Composer 2.5 vs. Composer 2.5 (Fast) vs. Auto</a></li>
<li><a href="https://www.codejam.info/2026/06/how-much-is-composer-2-5-subsidized-in-cursor.html#what-happens-when-you-run-out-of-auto-composer-quota">What happens when you run out of “Auto + Composer” quota?</a></li>
<li><a href="https://www.codejam.info/2026/06/how-much-is-composer-2-5-subsidized-in-cursor.html#how-much-inference-do-we-get-on-the-20-pro-plan">How much inference do we get on the $20 Pro plan?</a></li>
<li><a href="https://www.codejam.info/2026/06/how-much-is-composer-2-5-subsidized-in-cursor.html#should-i-enable-on-demand-usage-or-upgrade-my-subscription">Should I enable on-demand usage or upgrade my subscription?</a></li>
<li><a href="https://www.codejam.info/2026/06/how-much-is-composer-2-5-subsidized-in-cursor.html#conclusion">Conclusion</a></li>
</ul>
</div>
<h2 id="context" tabindex="-1"><a class="header-anchor" href="https://www.codejam.info/2026/06/how-much-is-composer-2-5-subsidized-in-cursor.html#context"><span>Context</span></a></h2>
<p>I’ve been using Composer 2.5 in Cursor since it came out on May 18,
2026.</p>
<p>I actually had no clue. I had just ran out of Codex quota and wanted
to try Cursor again to see how much output I can get from the “Auto”
mode in the subscription.</p>
<p>By pure coincidence, the day I want to subscribe, they release Composer
2.5, a significant update to their own model (where “own” means
reinforcement learning fine-tuning of Kimi K2.5).</p>
<h2 id="what-i-think-of-composer-2-5" tabindex="-1"><a class="header-anchor" href="https://www.codejam.info/2026/06/how-much-is-composer-2-5-subsidized-in-cursor.html#what-i-think-of-composer-2-5"><span>What I think of Composer 2.5</span></a></h2>
<p>I was instantly impressed by the quality to price feel of that model.
It’s one of the cheapest models available, yet it seems to rival
GPT-5.5 and Opus 4.8 when it comes to coding? Although it’s tricky to
really evaluate that because Composer 2.5 is only available through
Cursor, so it’s not possible to rank it on traditional benchmarks like
<a href="https://arena.ai/leaderboard/code">Arena AI’s Code Arena</a>.</p>
<p>Instead we have to rely on Cursor’s own <a href="https://cursor.com/cursorbench">CursorBench</a>.
To be fair their scoring of other models is not out of ordinary compared
to other coding benchmarks, and the fact the benchmark is private means
that it can’t be “gamed” by optimizing for it (except by Cursor
themselves 🙃). So if we can’t necessarily trust it for Composer’s
performance (it could be trained to perform well on that benchmark
specifically without that translating in real-world performance), it
might actually be a really good benchmark to compare other models.</p>
<p>That being said in day to day use in real projects, not just toy demos
or throwaway stuff, I’ve been pretty happy. The quality is here and I
get a fuckton more usage than I was getting with Codex for a similar
price.</p>
<p>And that’s despite, with Codex, me trying to aggressively limit the
spend by using low thinking most of the time, and older models like
GPT-5.3-Codex. Which also resulted in poorer quality and more
frustration.</p>
<p>In comparison with Composer 2.5, I stick it to the normal mode (not the
“fast” mode they select by default), which is already insanely fast
compared to anything else I’m used to (Codex and OpenCode Go), and I
don’t have to think about what model or level of thinking to select for
every prompt, and deal with the resulting variable quality output. I get
consistent quality output and more usage than the competition at the
same price point.</p>
<h2 id="composer-2-5-vs-composer-2-5-fast-vs-auto" tabindex="-1"><a class="header-anchor" href="https://www.codejam.info/2026/06/how-much-is-composer-2-5-subsidized-in-cursor.html#composer-2-5-vs-composer-2-5-fast-vs-auto"><span>Composer 2.5 vs. Composer 2.5 (Fast) vs. Auto</span></a></h2>
<p>Although there’s no way to know for sure, it feels like picking “Auto”
in Cursor often results in using Composer 2.5. Unclear whether it’s in
fast mode or not though.</p>
<p>When it comes to pricing, here’s <a href="https://cursor.com/docs/models-and-pricing">what they charge</a>
(price per 1M tokens):</p>
<table>
<thead>
<tr>
<th>Model</th>
<th style="text-align:right">Input</th>
<th style="text-align:right">Cache Read</th>
<th style="text-align:right">Output</th>
</tr>
</thead>
<tbody>
<tr>
<td>Composer 2.5</td>
<td style="text-align:right">$0.5</td>
<td style="text-align:right">$0.2</td>
<td style="text-align:right">$2.5</td>
</tr>
<tr>
<td>Composer 2.5 (Fast)</td>
<td style="text-align:right">$3</td>
<td style="text-align:right">$0.5</td>
<td style="text-align:right">$15</td>
</tr>
<tr>
<td>Auto</td>
<td style="text-align:right">$1.25</td>
<td style="text-align:right">$0.25</td>
<td style="text-align:right">$6</td>
</tr>
</tbody>
</table>
<p>So Composer 2.5 (Fast) is 6 times more expensive than Composer 2.5 (it’s
not clear to me that it’s 6 times faster though, as far as I’m concerned
if I’m not in a rush I’m happy with the normal mode).</p>
<p>As for Auto, it’s 2.5 times more expensive than Composer 2.5, and it
seems that you get Composer 2.5 at the end anyway. Maybe a bit faster
but no guarantee to be Composer 2.5 (Fast) speed all the time either.</p>
<p>When it comes to the subscription, there’s no indication that the usage
you get is proportional to the public pricing of those models. But it
would seem logical to me that using a cheaper option results in less
quota usage than using a more expensive option.</p>
<p>So as far as I’m concerned since I want to maximize my quota usage, I’ve
been using Composer 2.5 explicitly (not through Auto, and not the fast
mode) most of the time.</p>
<h2 id="what-happens-when-you-run-out-of-auto-composer-quota" tabindex="-1"><a class="header-anchor" href="https://www.codejam.info/2026/06/how-much-is-composer-2-5-subsidized-in-cursor.html#what-happens-when-you-run-out-of-auto-composer-quota"><span>What happens when you run out of “Auto + Composer” quota?</span></a></h2>
<p>When you max out your “Auto + Composer” quota, if you still have API
quota left, Cursor starts to dip into this for Auto and Composer
requests.</p>
<p>On the $20 plan, Cursor says that the API quota includes “at least $20
of API usage”, so you can expect that from this pool once you start
using it. In my experience it’s indeed a bit more, see below.</p>
<h2 id="how-much-inference-do-we-get-on-the-20-pro-plan" tabindex="-1"><a class="header-anchor" href="https://www.codejam.info/2026/06/how-much-is-composer-2-5-subsidized-in-cursor.html#how-much-inference-do-we-get-on-the-20-pro-plan"><span>How much inference do we get on the $20 Pro plan?</span></a></h2>
<p>Your mileage may vary, but here’s what it came out for me after one
month of using (mostly) Composer 2.5.</p>
<p><strong>Auto + Composer:</strong></p>
<table>
<thead>
<tr>
<th>Model</th>
<th style="text-align:right">Events</th>
<th style="text-align:right">Input</th>
<th style="text-align:right">Cache</th>
<th style="text-align:right">Output</th>
<th style="text-align:right">Total</th>
<th style="text-align:right">API-equiv</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>auto</code></td>
<td style="text-align:right">211</td>
<td style="text-align:right">5.73M</td>
<td style="text-align:right">87.33M</td>
<td style="text-align:right">905.7K</td>
<td style="text-align:right">93.96M</td>
<td style="text-align:right">$34.42</td>
</tr>
<tr>
<td><code>composer-2.5</code></td>
<td style="text-align:right">1,716</td>
<td style="text-align:right">32.03M</td>
<td style="text-align:right">495.52M</td>
<td style="text-align:right">5.35M</td>
<td style="text-align:right">532.89M</td>
<td style="text-align:right">$128.48</td>
</tr>
<tr>
<td><code>composer-2.5-fast</code></td>
<td style="text-align:right">3</td>
<td style="text-align:right">34.7K</td>
<td style="text-align:right">84.4K</td>
<td style="text-align:right">2.4K</td>
<td style="text-align:right">121.5K</td>
<td style="text-align:right">$0.18</td>
</tr>
<tr>
<td><strong>Subtotal</strong></td>
<td style="text-align:right"><strong>1,930</strong></td>
<td style="text-align:right"><strong>37.79M</strong></td>
<td style="text-align:right"><strong>582.93M</strong></td>
<td style="text-align:right"><strong>6.25M</strong></td>
<td style="text-align:right"><strong>626.97M</strong></td>
<td style="text-align:right"><strong>$163.09</strong></td>
</tr>
</tbody>
</table>
<p><strong>API:</strong></p>
<table>
<thead>
<tr>
<th>Model</th>
<th style="text-align:right">Events</th>
<th style="text-align:right">Input</th>
<th style="text-align:right">Cache</th>
<th style="text-align:right">Output</th>
<th style="text-align:right">Total</th>
<th style="text-align:right">API-equiv</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>claude-4.6-sonnet-medium-thinking</code></td>
<td style="text-align:right">20</td>
<td style="text-align:right">867.1K</td>
<td style="text-align:right">9.20M</td>
<td style="text-align:right">146.8K</td>
<td style="text-align:right">10.22M</td>
<td style="text-align:right">$8.21</td>
</tr>
<tr>
<td><code>claude-fable-5-thinking-high</code></td>
<td style="text-align:right">1</td>
<td style="text-align:right">32.1K</td>
<td style="text-align:right">0</td>
<td style="text-align:right">339</td>
<td style="text-align:right">32.4K</td>
<td style="text-align:right">$0.42</td>
</tr>
<tr>
<td><code>composer-2.5</code></td>
<td style="text-align:right">341</td>
<td style="text-align:right">7.29M</td>
<td style="text-align:right">140.37M</td>
<td style="text-align:right">1.16M</td>
<td style="text-align:right">148.82M</td>
<td style="text-align:right">$34.61</td>
</tr>
<tr>
<td><code>gpt-5.3-codex</code></td>
<td style="text-align:right">1</td>
<td style="text-align:right">43.9K</td>
<td style="text-align:right">426.1K</td>
<td style="text-align:right">3.4K</td>
<td style="text-align:right">473.4K</td>
<td style="text-align:right">$0.20</td>
</tr>
<tr>
<td><code>gpt-5.5</code></td>
<td style="text-align:right">11</td>
<td style="text-align:right">161.4K</td>
<td style="text-align:right">2.95M</td>
<td style="text-align:right">8.9K</td>
<td style="text-align:right">3.12M</td>
<td style="text-align:right">$2.55</td>
</tr>
<tr>
<td><strong>Subtotal</strong></td>
<td style="text-align:right"><strong>374</strong></td>
<td style="text-align:right"><strong>8.39M</strong></td>
<td style="text-align:right"><strong>152.95M</strong></td>
<td style="text-align:right"><strong>1.31M</strong></td>
<td style="text-align:right"><strong>162.66M</strong></td>
<td style="text-align:right"><strong>$45.99</strong></td>
</tr>
</tbody>
</table>
<p><strong>Free:</strong></p>
<p>After I used 100% of my subscription quota, everything kept working and
appeared as “free” in the billing usage log. I’m assuming that’s falling
back to their Hobby (free) plan once the subscription is exhausted,
which has much lower limits (although I didn’t have time to reach them
this time).</p>
<table>
<thead>
<tr>
<th>Model</th>
<th style="text-align:right">Events</th>
<th style="text-align:right">Input</th>
<th style="text-align:right">Cache</th>
<th style="text-align:right">Output</th>
<th style="text-align:right">Total</th>
<th style="text-align:right">API-equiv</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>auto</code></td>
<td style="text-align:right">3</td>
<td style="text-align:right">41.3K</td>
<td style="text-align:right">135.4K</td>
<td style="text-align:right">1.9K</td>
<td style="text-align:right">178.6K</td>
<td style="text-align:right">$0.10</td>
</tr>
<tr>
<td><code>composer-2.5</code></td>
<td style="text-align:right">76</td>
<td style="text-align:right">1.49M</td>
<td style="text-align:right">27.60M</td>
<td style="text-align:right">198.4K</td>
<td style="text-align:right">29.29M</td>
<td style="text-align:right">$6.76</td>
</tr>
<tr>
<td><strong>Subtotal</strong></td>
<td style="text-align:right"><strong>79</strong></td>
<td style="text-align:right"><strong>1.53M</strong></td>
<td style="text-align:right"><strong>27.74M</strong></td>
<td style="text-align:right"><strong>200.3K</strong></td>
<td style="text-align:right"><strong>29.47M</strong></td>
<td style="text-align:right"><strong>$6.86</strong></td>
</tr>
</tbody>
</table>
<p><strong>Grand total:</strong></p>
<table>
<thead>
<tr>
<th style="text-align:right">Events</th>
<th style="text-align:right">Input</th>
<th style="text-align:right">Cache</th>
<th style="text-align:right">Output</th>
<th style="text-align:right">Total</th>
<th style="text-align:right">API-equiv</th>
</tr>
</thead>
<tbody>
<tr>
<td style="text-align:right"><strong>2,383</strong></td>
<td style="text-align:right"><strong>47.72M</strong></td>
<td style="text-align:right"><strong>763.62M</strong></td>
<td style="text-align:right"><strong>7.77M</strong></td>
<td style="text-align:right"><strong>819.10M</strong></td>
<td style="text-align:right"><strong>$215.94</strong></td>
</tr>
</tbody>
</table>
<p>So if my computations are correct, the $20 plan gave me $208 worth of
tokens. $163 of that was in the Auto + Composer pool, and the remaining
$46 was in the API pool. (And $7 Hobby fallback.)</p>
<h2 id="should-i-enable-on-demand-usage-or-upgrade-my-subscription" tabindex="-1"><a class="header-anchor" href="https://www.codejam.info/2026/06/how-much-is-composer-2-5-subsidized-in-cursor.html#should-i-enable-on-demand-usage-or-upgrade-my-subscription"><span>Should I enable on-demand usage or upgrade my subscription?</span></a></h2>
<p>Cursor has an option to enable on-demand spending, where usage is billed
at API price after you exhaust your subscription.</p>
<p>When it comes to using Composer 2.5, the subscription gives a 10x boost
in tokens compared to API prices.</p>
<p>The next subscription tier is the $60 Pro+ plan which comes with 3x the
usage, or you can get a second $20 Pro plan (to get obviously 2x the
usage).</p>
<p>This gives us a table like this (based on my own 1 month data, your
mileage may vary):</p>
<table>
<thead>
<tr>
<th style="text-align:right">Expected usage</th>
<th>Best choice</th>
</tr>
</thead>
<tbody>
<tr>
<td style="text-align:right">Less than 10% over</td>
<td>On-demand</td>
</tr>
<tr>
<td style="text-align:right">More than 10% over</td>
<td>Second $20 Pro plan</td>
</tr>
<tr>
<td style="text-align:right">More than 210% over</td>
<td>$60 Pro+ plan</td>
</tr>
</tbody>
</table>
<p>If you run out of the $20 plan, first you might as well max out the
Hobby (free) quota as well (worth at least $7 apparently).</p>
<p>Then if you need 10% or less extra usage compared to your Pro plan,
on-demand is worth it.</p>
<p>More than that, get a second $20 Pro plan.</p>
<p>If you max out that second plan, the same 10% rule applies for upgrading
to Pro+.</p>
<h2 id="conclusion" tabindex="-1"><a class="header-anchor" href="https://www.codejam.info/2026/06/how-much-is-composer-2-5-subsidized-in-cursor.html#conclusion"><span>Conclusion</span></a></h2>
<p>Composer 2.5 appears to be quite subsidized, where a $20 plan gives over
$200 worth of tokens, about 10 times more than what you pay for.</p>
<p>Though it’s not necessarily as subsidized as other subscriptions, as
there’s reports of people getting between $2,000 and $8,000 (???) worth
of tokens with the $200 Claude plan.</p>
<p>But because I’m the one paying this subscription and not my employer or
some VC, and because I don’t have unlimited money, I find Composer 2.5
is a lot more interesting for my current usage.</p>
<section class="post-footer">
  <h3>Want to leave a comment?</h3>
  <p>
    Join the discussion on <a href="https://x.com/valeriangalliat/status/2069449766061404473">X</a> or send me an <a href="mailto:val@codejam.info">email</a>! 💌<br>
    This post helped you? <a href="https://ko-fi.com/funkyval">Buy me a coffee</a>! 🍻
  </p>
</section>
]]></content>
  </entry>
  <entry>
    <title>AWS S3 flaky 403 on HEAD after upload</title>
    <link href="https://www.codejam.info/2026/06/aws-s3-flaky-403-head-after-upload.html" />
    <id>https://www.codejam.info/2026/06/aws-s3-flaky-403-head-after-upload.html</id>
    <updated>2026-06-10T07:00:00.000Z</updated>
    <content type="html"><![CDATA[<h2 id="tldr" tabindex="-1"><a class="header-anchor" href="https://www.codejam.info/2026/06/aws-s3-flaky-403-head-after-upload.html#tldr"><span>TLDR</span></a></h2>
<p><code>HEAD</code> flaky? Use <code>GET</code> with <code>Range: bytes=0-0</code> instead.</p>
<h2 id="context" tabindex="-1"><a class="header-anchor" href="https://www.codejam.info/2026/06/aws-s3-flaky-403-head-after-upload.html#context"><span>Context</span></a></h2>
<p>Had a wild bug with S3 recently. Let’s start with some context.</p>
<p>I’m currently building <a href="https://cloudmotion.dev/">Cloudmotion</a>,
a hosted Remotion Lambda.  <a href="https://www.remotion.dev/">Remotion</a> is the
<abbr title="Greatest of all time">GOAT</abbr> when it comes to making
programmatic videos, and <a href="https://www.remotion.dev/lambda">Remotion Lambda</a>
is the fastest way to render those videos in the cloud at scale.
<strong>Cloudmotion is that but I deal with AWS so you don’t have to.</strong></p>
<p>Part of the challenge is supporting <em>any</em> Remotion version. There’s
currently <a href="https://www.npmjs.com/package/remotion?activeTab=versions">over 1,200</a>
published versions on npm, and new versions are released about every
other day.</p>
<p>In order to do that, the first time a new version is requested, I
trigger a AWS CodeBuild job that prepares the Lambda source, and uploads
a ZIP file to S3 that the function can be created from. In the <code>finally</code>
phase of the buildspec, I send a webhook to notify the app to continue
the provisioning with the newly built artifact.</p>
<p>The app then does a <code>HEAD</code> requests to check that the ZIP file exists
and then creates the Lambda from it. This is where things go sideways.</p>
<div class="note">
<p><strong>Note:</strong> writing this post, I’m realizing I don’t need that <code>HEAD</code> call
in the first place. I can just optimistically try to create the Lambda
assuming the ZIP file is there (normally, it should), and let the call
fail otherwise.</p>
<p>However I have other cases where I <em>do</em> want that <code>HEAD</code> call to resume
provisioning from an existing artifact instead of starting a new build
all over. So it’s not all lost, phew.</p>
</div>
<h2 id="flaky-head-response" tabindex="-1"><a class="header-anchor" href="https://www.codejam.info/2026/06/aws-s3-flaky-403-head-after-upload.html#flaky-head-response"><span>Flaky <code>HEAD</code> response</span></a></h2>
<p>I’m always calling the S3 API with the same, valid, credentials. So I’m
expecting 2 answers: 404 when the file doesn’t exist, and 200 when it
does.</p>
<p>However here’s some logs I’ve observed:</p>
<table>
<thead>
<tr>
<th>Time</th>
<th>Event</th>
<th>result</th>
</tr>
</thead>
<tbody>
<tr>
<td>20:23:29</td>
<td><code>HEAD</code></td>
<td>404</td>
</tr>
<tr>
<td>20:23:31</td>
<td><code>aws s3 cp</code></td>
<td>success</td>
</tr>
<tr>
<td><strong>20:23:33</strong></td>
<td><strong><code>HEAD</code></strong></td>
<td><strong>403</strong></td>
</tr>
<tr>
<td>20:23:34</td>
<td><code>HEAD</code></td>
<td>200</td>
</tr>
</tbody>
</table>
<p>And another weird instance:</p>
<table>
<thead>
<tr>
<th>Time</th>
<th>Event</th>
<th>result</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>03:52:04</strong></td>
<td><strong><code>HEAD</code></strong></td>
<td><strong>403</strong></td>
</tr>
<tr>
<td>03:52:11</td>
<td><code>HEAD</code></td>
<td>404</td>
</tr>
<tr>
<td>03:52:27</td>
<td><code>aws s3 cp</code></td>
<td>success</td>
</tr>
<tr>
<td>03:52:28</td>
<td><code>HEAD</code></td>
<td>200</td>
</tr>
</tbody>
</table>
<p>So while at first it felt like it could be due to doing the <code>HEAD</code> call
<em>really shortly</em> after the upload succeeded, that second instance
actually shows us that even calls that expect a 404 can get a wild 403
instead, with the same credentials and permissions that got a 404 and
eventually a 200 seconds later.</p>
<h2 id="a-note-about-eventual-consistency" tabindex="-1"><a class="header-anchor" href="https://www.codejam.info/2026/06/aws-s3-flaky-403-head-after-upload.html#a-note-about-eventual-consistency"><span>A note about eventual consistency</span></a></h2>
<p>S3 has <a href="https://aws.amazon.com/s3/consistency/">“strong read-after-write consistency”</a>
<a href="https://aws.amazon.com/blogs/aws/amazon-s3-update-strong-read-after-write-consistency/">since 2020</a>,
so it’s been a while there’s no more eventual consistency to deal with
when calling S3.</p>
<p>That being said I’m now realizing they say:</p>
<blockquote>
<p>Effective immediately, all S3 <code>GET</code>, <code>PUT</code>, and <code>LIST</code> operations
[…] are now strongly consistent.</p>
</blockquote>
<p>This doesn’t include <code>HEAD</code>, so maybe <code>HEAD</code> is still eventually
consistent? But even with eventual consistency I’d expect a 404 after a
successful upload that eventually becomes a 200. In no way eventual
consistency should result in a 403 with valid credentials and
appropriate IAM permissions.</p>
<h2 id="workaround" tabindex="-1"><a class="header-anchor" href="https://www.codejam.info/2026/06/aws-s3-flaky-403-head-after-upload.html#workaround"><span>Workaround</span></a></h2>
<p>I never managed to understand the root cause of why those <code>HEAD</code> calls
randomly return a 403.</p>
<p><strong>Instead I switched to doing <code>GET</code> calls with <code>Range: bytes=0-0</code>.</strong>
Returns a 404 consistently when the file doens’t exist, and a 206
(partial content) when it does.</p>
<p>Bonus is that the <code>GET</code> method allows the S3 API to return proper XML
error responses, whereas <code>HEAD</code> by design doesn’t have a HTTP body and
thus can’t communicate any useful error information (especially in the
case of those 403s).</p>
<section class="post-footer">
  <h3>Want to leave a comment?</h3>
  <p>
    Start a conversation on <a href="https://x.com/valeriangalliat">X</a> or send me an <a href="mailto:val@codejam.info">email</a>! 💌<br>
    This post helped you? <a href="https://ko-fi.com/funkyval">Buy me a coffee</a>! 🍻
  </p>
</section>
]]></content>
  </entry>
  <entry>
    <title>xcrun stapler could not validate ticket, error 65</title>
    <link href="https://www.codejam.info/2026/06/xcrun-stapler-could-not-validate-ticket-error-65.html" />
    <id>https://www.codejam.info/2026/06/xcrun-stapler-could-not-validate-ticket-error-65.html</id>
    <updated>2026-06-04T07:00:00.000Z</updated>
    <content type="html"><![CDATA[<p>When publishing a macOS app, you need to go through code signing and
“notarization” of the app and DMG file to avoid “unverified developer”
warnings.</p>
<p>This is well integrated in Xcode, and frameworks like Electron and Tauri
also have direct support for this (see <a href="https://www.codejam.info/2024/05/sign-notarize-electron-app-macos.html">How to sign and notarize an Electron app</a> and <a href="https://tauri.app/distribute/sign/macos/">Tauri macOS code signing</a>).</p>
<p>But the app I’m building, <a href="https://evetools.app/en/tzbar">TZBar</a>, is
native (no Electron or Tauri), but small enough that I didn’t want to
bother with Xcode at all.  Just plain <code>swift build</code>. This also meant
doing the code signing and notarizaiton <a href="https://github.com/EveToolsHQ/TZBar/blob/efef3f52bfecf226ece9477a6f43a4639acf5d19/Makefile#L41-L65">from the CLI</a>,
which luckily is well documented in <a href="https://stackoverflow.com/a/64733472">this Stack Overflow answer</a>.</p>
<p>That’s how I learnt about the stapling process. While notarization means
uploading your app for Apple to scan and approve it, that “verification
ticket” can be “stapled” onto the app file itself so that the
verification can be done offline on users devices.</p>
<p>This is optional since when connected to the internet, the system will
just check the app’s signature for notarization status online, and users
who just downloaded your app are typically connected to the internet. 😂</p>
<p>Anyway, it’s done with this command:</p>
<pre><code class="hljs language-sh">xcrun stapler staple App.dmg
</code></pre>
<h2 id="error-65" tabindex="-1"><a class="header-anchor" href="https://www.codejam.info/2026/06/xcrun-stapler-could-not-validate-ticket-error-65.html#error-65"><span>Error 65</span></a></h2>
<p>However the above command failed with:</p>
<pre><code class="hljs language-console"><span class="hljs-meta prompt_">$ </span><span class="language-bash">xcrun stapler staple App.dmg</span>
Processing: ~/App.dmg
Could not validate ticket for ~/App.dmg
The staple and validate action failed! Error 65.
</code></pre>
<p>Sounds like this error happens for many unrelated reasons with no way to
know exactly what’s wrong…</p>
<p>In my case, the Apple root certificates were set to “always trust”
instead of “system defaults” (probably from attempts to troubleshoot
signing issues of past apps I worked on).</p>
<p>This is apparently a problem, and the stapler will fail if the
certificates not set to “system defaults”.</p>
<p>So go in <strong>Keychain Access &gt; System Roots</strong>, and for <strong>Apple Root</strong>,
<strong>G2</strong>, <strong>G3</strong> and <strong>WWDR</strong>, make sure to select <strong>System Defaults</strong> if
it’s not the case already.</p>
<p>If this doesn’t solve it for you, <a href="https://github.com/electron/notarize/issues/120#issuecomment-3215070977">this GitHub answer</a>
describes a few more steps that you can use to troubleshoot “health” of
the signing setup.</p>
<pre><code class="hljs language-console"><span class="hljs-meta prompt_">$ </span><span class="language-bash">xcrun stapler staple App.dmg</span>
The staple and validate action worked!
</code></pre>
<p>Better!</p>
<section class="post-footer">
  <h3>Want to leave a comment?</h3>
  <p>
    Start a conversation on <a href="https://x.com/valeriangalliat">X</a> or send me an <a href="mailto:val@codejam.info">email</a>! 💌<br>
    This post helped you? <a href="https://ko-fi.com/funkyval">Buy me a coffee</a>! 🍻
  </p>
</section>
]]></content>
  </entry>
  <entry>
    <title>Supabase with Colima: error from daemon docker.sock operation not supported</title>
    <link href="https://www.codejam.info/2026/06/supabase-colima-docker-sock-operation-not-supported.html" />
    <id>https://www.codejam.info/2026/06/supabase-colima-docker-sock-operation-not-supported.html</id>
    <updated>2026-06-01T07:00:00.000Z</updated>
    <content type="html"><![CDATA[<h2 id="tldr" tabindex="-1"><a class="header-anchor" href="https://www.codejam.info/2026/06/supabase-colima-docker-sock-operation-not-supported.html#tldr"><span>TLDR</span></a></h2>
<pre><code class="hljs language-sh"><span class="hljs-built_in">sudo</span> <span class="hljs-built_in">ln</span> -s ~/.colima/default/docker.sock /var/run/docker.sock
<span class="hljs-built_in">export</span> DOCKER_HOST=<span class="hljs-string">&#x27;unix:///var/run/docker.sock&#x27;</span>
</code></pre>
<h2 id="context" tabindex="-1"><a class="header-anchor" href="https://www.codejam.info/2026/06/supabase-colima-docker-sock-operation-not-supported.html#context"><span>Context</span></a></h2>
<p>Working on a Supabase project, wanna run the whole stack locally using
<code>supabase start</code>.</p>
<p>Supabase local development environment is based on Docker containers, so
we need a runtime for it. <a href="https://supabase.com/docs/guides/local-development">Docs</a>
mention Docker Desktop, Rancher Desktop, Podman and OrbStack.</p>
<p>But I like to be different and I want to use
<a href="https://github.com/abiosoft/colima">Colima</a>. 😂</p>
<p>However <code>supabase start</code> fails with:</p>
<pre><code class="hljs">failed to start docker container &quot;supabase_vector_myapp&quot;:
Error response from daemon: error while creating mount source path &#x27;~/.colima/default/docker.sock&#x27;:
mkdir ~/.colima/default/docker.sock: operation not supported
</code></pre>
<p>Found the same error on <a href="https://www.reddit.com/r/Supabase/comments/1nlh0p1/comment/nfw87m7/">this Reddit post</a>
with the following fix:</p>
<pre><code class="hljs language-sh"><span class="hljs-built_in">ln</span> -s ~/.colima/default/docker.sock /tmp/docker.sock
<span class="hljs-built_in">export</span> DOCKER_HOST=<span class="hljs-string">&#x27;unix:///tmp/docker.sock&#x27;</span> 
</code></pre>
<p>But in my case, while this goes a bit further, I just get another error.
(And the post is archived so I can’t even mention the final solution
there, oh well.)</p>
<pre><code class="hljs">ERROR source{component_kind=&quot;source&quot; component_id=docker_host component_type=docker_logs}:
vector::sources::docker_logs: Listing currently running containers failed.
error=HyperLegacyError {
  err: hyper_util::client::legacy::Error(Connect, Os {
    code: 111,
    kind: ConnectionRefused,
    message: &quot;Connection refused&quot;
  })
}
</code></pre>
<p>Nothing came up online for this error so hopefully this blog post will help
if anyone runs into this.</p>
<p>However I stumbled more generic threads of trying to
<a href="https://github.com/abiosoft/colima/issues/365">configure Colima to use the default Docker socket</a>,
where the way to do this is:</p>
<pre><code class="hljs language-sh"><span class="hljs-built_in">sudo</span> <span class="hljs-built_in">ln</span> -s ~/.colima/default/docker.sock /var/run/docker.sock
<span class="hljs-built_in">export</span> DOCKER_HOST=<span class="hljs-string">&#x27;unix:///var/run/docker.sock&#x27;</span>
</code></pre>
<p>So same as before but with <code>/var/run/docker.sock</code> instead of <code>/tmp/docker.sock</code>.</p>
<p>That worked flawlessly for me!</p>
<section class="post-footer">
  <h3>Want to leave a comment?</h3>
  <p>
    Start a conversation on <a href="https://x.com/valeriangalliat">X</a> or send me an <a href="mailto:val@codejam.info">email</a>! 💌<br>
    This post helped you? <a href="https://ko-fi.com/funkyval">Buy me a coffee</a>! 🍻
  </p>
</section>
]]></content>
  </entry>
  <entry>
    <title>Astro ESLint with pnpm 11: return outside of function</title>
    <link href="https://www.codejam.info/2026/06/astro-eslint-pnpm-11-return-outside-function.html" />
    <id>https://www.codejam.info/2026/06/astro-eslint-pnpm-11-return-outside-function.html</id>
    <updated>2026-06-01T07:00:00.000Z</updated>
    <content type="html"><![CDATA[<h2 id="tldr" tabindex="-1"><a class="header-anchor" href="https://www.codejam.info/2026/06/astro-eslint-pnpm-11-return-outside-function.html#tldr"><span>TLDR</span></a></h2>
<p><code>pnpm add -D @typescript-eslint/parser</code> so <code>eslint-plugin-astro</code>
picks it up instead of Espree. Mandatory for TypeScript, but also helps
with things like early <code>return</code> in frontmatter even in plain JS.</p>
<h2 id="context" tabindex="-1"><a class="header-anchor" href="https://www.codejam.info/2026/06/astro-eslint-pnpm-11-return-outside-function.html#context"><span>Context</span></a></h2>
<p>After upgrading to pnpm 11, ESLint started flagging every <code>.astro</code> page
that returns early from the frontmatter:</p>
<pre><code class="hljs">src/pages/login.astro
  9:3  error  Parsing error: &#x27;return&#x27; outside of function
</code></pre>
<p>The line points to something like this:</p>
<pre><code class="hljs language-tsx">---
<span class="hljs-keyword">export</span> <span class="hljs-keyword">const</span> prerender = <span class="hljs-literal">false</span>

<span class="hljs-keyword">if</span> (<span class="hljs-title class_">Astro</span>.<span class="hljs-property">locals</span>.<span class="hljs-property">user</span>) {
  <span class="hljs-keyword">return</span> <span class="hljs-title class_">Astro</span>.<span class="hljs-title function_">redirect</span>(<span class="hljs-string">&#x27;/&#x27;</span>)
}
---

<span class="language-xml"><span class="hljs-tag">&lt;<span class="hljs-name">Login</span> /&gt;</span></span>
</code></pre>
<p>To lint Astro files I use <a href="https://www.npmjs.com/package/eslint-plugin-astro">eslint-plugin-astro</a>
with a pretty basic config.</p>
<p>Same error <a href="https://github.com/ota-meshi/eslint-plugin-astro/issues/459">reported on GitHub</a>,
still open at time of writing, no resolution except for disabling the
rule entirely.</p>
<h2 id="works-from-cli-but-not-from-editor" tabindex="-1"><a class="header-anchor" href="https://www.codejam.info/2026/06/astro-eslint-pnpm-11-return-outside-function.html#works-from-cli-but-not-from-editor"><span>Works from CLI but not from editor</span></a></h2>
<p>Running ESLint from a pnpm script i.e. <code>pnpm eslint</code> actually still
worked. Only inside Cursor with the ESLint extension I had the issue.</p>
<p>Nothing specific to Cursor though, figured out just using the
programmatic API with <code>new ESLint().lintFiles()</code> failed with the same
error.</p>
<p>Turns out that pnpm wraps <code>node_modules/.bin/eslint</code> and sets
<code>NODE_PATH</code> to <code>node_modules/.pnpm/node_modules</code>, a flat bucket of
symlinks to everything installed in the project.</p>
<p>Sounds like we have a dependency graph problem. Something is missing an
explicit link and that’s why it only works when everything is hoisted.</p>
<h2 id="pnpm-history-of-hoisting-of-eslint" tabindex="-1"><a class="header-anchor" href="https://www.codejam.info/2026/06/astro-eslint-pnpm-11-return-outside-function.html#pnpm-history-of-hoisting-of-eslint"><span>pnpm history of hoisting of ESLint</span></a></h2>
<p>Interestingly, pnpm 10 used to <a href="https://github.com/pnpm/pnpm/issues/8878#issuecomment-2546442011">hoist <code>*eslint*</code> by default</a>.</p>
<p>This was because ESLint old config format used to have stuff like
<code>plugins: ['astro']</code> that had ESLint itself translate this to
<code>require('eslint-plugin-astro')</code>. This notoriously doesn’t work with
pnpm style of <code>node_modules</code> where packages only see their explicit
dependencies and not everything in the entire project.</p>
<p>Since then, ESLint moved to <a href="https://eslint.org/docs/latest/use/configure/migration-guide">flat config</a>
that doesn’t rely on those implicit dependencies.</p>
<p>Everything in my project uses ESLint flat config so this wasn’t the
issue, but this default hoisting of <code>*eslint*</code> packages was still
helping me when it comes to my Astro parsing.</p>
<h2 id="root-cause" tabindex="-1"><a class="header-anchor" href="https://www.codejam.info/2026/06/astro-eslint-pnpm-11-return-outside-function.html#root-cause"><span>Root cause</span></a></h2>
<p><code>eslint-plugin-astro</code> probes for <code>@typescript-eslint/parser</code> at load
time (optional, not an explicit hard dependency). When it cannot
resolve the package, it falls back to the Espree parser.</p>
<p>Espree seems to treat frontmatter <code>return</code> as a module-level return and
errors. <code>@typescript-eslint/parser</code> does not.</p>
<h2 id="rtfm-kinda" tabindex="-1"><a class="header-anchor" href="https://www.codejam.info/2026/06/astro-eslint-pnpm-11-return-outside-function.html#rtfm-kinda"><span>RTFM, kinda</span></a></h2>
<p>Guess what? <code>eslint-plugin-astro</code> <a href="https://www.npmjs.com/package/eslint-plugin-astro#user-content--installation">documents</a>:</p>
<blockquote>
<p>If you write TypeScript in Astro components, you also need to install
the <code>@typescript-eslint/parser</code>:</p>
<pre><code class="hljs language-sh">npm install --save-dev @typescript-eslint/parser
</code></pre>
</blockquote>
<p>I do use TypeScript, although the above failing example was
JavaScript-only. Docs frame it as a TypeScript dependency, but in
practice <code>@typescript-eslint/parser</code> seems to play better with astro
files in general, even JavaScript, at least when early <code>return</code> is
involved.</p>
<p>So with pnpm:</p>
<pre><code class="hljs language-sh">pnpm add -D @typescript-eslint/parser
</code></pre>
<section class="post-footer">
  <h3>Want to leave a comment?</h3>
  <p>
    Start a conversation on <a href="https://x.com/valeriangalliat">X</a> or send me an <a href="mailto:val@codejam.info">email</a>! 💌<br>
    This post helped you? <a href="https://ko-fi.com/funkyval">Buy me a coffee</a>! 🍻
  </p>
</section>
]]></content>
  </entry>
  <entry>
    <title>How to manually trigger Cloud Tasks in the Firebase emulator</title>
    <link href="https://useflame.app/en/blog/how-to-manually-trigger-cloud-tasks-in-the-emulator" />
    <id>https://useflame.app/en/blog/how-to-manually-trigger-cloud-tasks-in-the-emulator</id>
    <updated>2026-04-24T07:00:00.000Z</updated>
  </entry>
  <entry>
    <title>How to manually publish messages to the Firebase Pub/Sub emulator</title>
    <link href="https://useflame.app/en/blog/how-to-manually-publish-messages-to-the-pubsub-emulator" />
    <id>https://useflame.app/en/blog/how-to-manually-publish-messages-to-the-pubsub-emulator</id>
    <updated>2026-04-24T07:00:00.000Z</updated>
  </entry>
  <entry>
    <title>How to manually trigger Firebase Functions in the emulator</title>
    <link href="https://useflame.app/en/blog/how-to-manually-trigger-firebase-functions-in-the-emulator" />
    <id>https://useflame.app/en/blog/how-to-manually-trigger-firebase-functions-in-the-emulator</id>
    <updated>2026-04-24T07:00:00.000Z</updated>
  </entry>
  <entry>
    <title>Comparing GPT-5.4, Opus 4.6, GLM-5.1, Kimi K2.5, MiMo V2 Pro and MiniMax M2.7</title>
    <link href="https://www.codejam.info/2026/04/comparing-gpt-5-4-opus-4-6-glm-5-1-kimi-k2-5-mimo-v2-pro-and-minimax-m2-7.html" />
    <id>https://www.codejam.info/2026/04/comparing-gpt-5-4-opus-4-6-glm-5-1-kimi-k2-5-mimo-v2-pro-and-minimax-m2-7.html</id>
    <updated>2026-04-14T07:00:00.000Z</updated>
    <content type="html"><![CDATA[<p>So listen, I was just trying to eject a drive and I ended up spending
the day benchmarking LLMs.</p>
<p>In a hurry? Jump to the <a href="https://www.codejam.info/2026/04/comparing-gpt-5-4-opus-4-6-glm-5-1-kimi-k2-5-mimo-v2-pro-and-minimax-m2-7.html#getting-the-models-to-review-each-other">results</a>
or the <a href="https://www.codejam.info/2026/04/comparing-gpt-5-4-opus-4-6-glm-5-1-kimi-k2-5-mimo-v2-pro-and-minimax-m2-7.html#a-surprising-finding-on-lack-of-biases">conclusion</a>. Otherwise make some tea, relax and
read on.</p>
<p>It started with this error:</p>
<figure class="center">
  <img alt="Disk wasn’t ejected because one or more programs may be using it" srcset="../../img/2026/04/disk-not-ejected.png 2x">
</figure>
<p>Usually I just <code>lsof /Volumes/Data</code> and kill whatever is causing the
issue (99% Spotlight or QuickLook).</p>
<p>But you know what, I’m at the point where I need to renew my AI coding
subscription and after a while on Claude in Cursor and a bit of GPT 5.4
in Codex, I wanted to see what’s out there, and especially compare the
big two to the open weights models available through OpenCode Go.</p>
<p>After all, Cursor based their Composer 2 model on Kimi K2.5 so it’s
probably decent, and I’ve heard good things about GLM-5.1.</p>
<p>So I decided to compare those models to build a native macOS app to make
the above experience a bit nicer.</p>
<ul>
<li>Show external drives and allow to unmount them.</li>
<li>When unmounting from the app, show a popup with what’s blocking
and allow to kill the process.</li>
<li>If unmounting from the normal macOS context menu, the app should also
detect a disk that failed to unmount, and show our popup.</li>
</ul>
<p>Simple as that.</p>
<p>When it comes the planning and building experience, I’ll document
<em>vibes</em> only, because nothing really stood out here. And this is all
that matters these days anyway doesn’t it?</p>
<p>As for code analysis and final ranking, it’s a bit more thorough, don’t
worry.</p>
<h2 id="planning" tabindex="-1"><a class="header-anchor" href="https://www.codejam.info/2026/04/comparing-gpt-5-4-opus-4-6-glm-5-1-kimi-k2-5-mimo-v2-pro-and-minimax-m2-7.html#planning"><span>Planning</span></a></h2>
<p>I ran all models through OpenCode. Started in planning mode, and
answered all questions they asked if any.</p>
<p>They all had a pretty similar plan. Slight variations in irrelevant
details, but everything was reasonable when it comes to the underlying
commands and APIs to built upon.</p>
<p>The only one that stood out was MiMo, kernel API instead of shelling out
to <code>lsof</code>. Do what you want with that but I don’t really care.</p>
<p>It’s a tie for me on this aspect.</p>
<h2 id="building" tabindex="-1"><a class="header-anchor" href="https://www.codejam.info/2026/04/comparing-gpt-5-4-opus-4-6-glm-5-1-kimi-k2-5-mimo-v2-pro-and-minimax-m2-7.html#building"><span>Building</span></a></h2>
<p>OpenCode lets the model run shell commands so they were all able to
compile the app and iterate on build failures on their own.</p>
<p>This means when the models were done, the code was compiling. It’s a tie
again.</p>
<h2 id="runtime" tabindex="-1"><a class="header-anchor" href="https://www.codejam.info/2026/04/comparing-gpt-5-4-opus-4-6-glm-5-1-kimi-k2-5-mimo-v2-pro-and-minimax-m2-7.html#runtime"><span>Runtime</span></a></h2>
<p>Despite compiling successfully, a few of them crashed as soon as I
launched the app.</p>
<p>When you make a web app, most harnesses allow browser use for the LLM to
test its own output <em>live</em>. This gives us the same success loop we had
with compile-time errors.</p>
<p>But there’s no such thing for native apps yet.</p>
<p>Nothing to blame the actual models on though.</p>
<p>After fixing those, all apps had one of two outcomes:</p>
<ul>
<li>Can’t find any disk.</li>
<li>Finds disks but crashes when trying to eject.</li>
</ul>
<p>For all of them I could just have keep the vibe coding loop until it
works. <small>(I did not, I have some real work to do and as you can notice,
I’m spending way too much time on this already.)</small></p>
<p>But as far as I’m concerned it’s more or less a tie. Maybe they didn’t
work for different reasons, but it would take a comparable amount of
effort to fix.</p>
<div class="note">
<p><strong>Note:</strong> nothing new here. If you can give the LLM a tool to test its
work end to end, you spend less time writing “doesn’t work, pls fix”.
Too bad it’s not a readily available option here.</p>
</div>
<h2 id="the-code" tabindex="-1"><a class="header-anchor" href="https://www.codejam.info/2026/04/comparing-gpt-5-4-opus-4-6-glm-5-1-kimi-k2-5-mimo-v2-pro-and-minimax-m2-7.html#the-code"><span>The code</span></a></h2>
<p>Let’s take the hypothesis for a minute that code quality, as subjective
as it is, is still relevant.</p>
<p><strong>Then, I have a slight preference for the output of GLM-5.1 and
GPT-5.4.</strong></p>
<p>Both are more lean and easier to reason about than the other ones.</p>
<p>This is usually synonymous with the software being less buggy <em>now</em>, as
well as less buggy <em>in the future</em> when we add/change stuff around. And
in my experience this seems to apply to both LLMs and humans.</p>
<p>But I’m sure you can steer all of them to output a clean and
maintainable app by prompting a bit more, that is, if you know how to
code and care about the code at all.</p>
<h2 id="getting-the-models-to-review-each-other" tabindex="-1"><a class="header-anchor" href="https://www.codejam.info/2026/04/comparing-gpt-5-4-opus-4-6-glm-5-1-kimi-k2-5-mimo-v2-pro-and-minimax-m2-7.html#getting-the-models-to-review-each-other"><span>Getting the models to review each other</span></a></h2>
<p>That was a fun experiment. I asked the LLMs to review each other’s work,
rank it, and score the cleanliness and technical approach.</p>
<p><strong>I was worried this was gonna be a slop spiral but the results were not
as over the place as I expected. 👀</strong></p>
<p><strong>GPT-5.4</strong> and <strong>Opus 4.6</strong>, the two frontier proprietary models,
nearly always came up in the top 3, with an edge for GPT.</p>
<p><strong>MiMo V2 Pro</strong> also had a tendency to rank quite high, close to the two
leaders, even if the consensus wasn’t as striking.</p>
<p>The caveat is that it scored on the low side for cleanliness, despite
standing out for technical approach.</p>
<p><strong>GLM-5.1</strong> had a pretty consistent 4th place, but it ranked really high
on cleanliness specifically.</p>
<p>This means it’s a decent choice is you want relatively clean code out of
the box and don’t mind giving it guidance when it comes to technical
approach.</p>
<p>This is actually something that works best for me. I’d rather steer the
technical approach if it means I don’t have to refine output quality as
much.</p>
<p><strong>Kimi K2.5</strong> and <strong>MiniMax M2.7</strong> were consistently at the bottom, and
in that order. Which is pretty consistent with their cost.</p>
<p>Here’s the raw data. Row is candidate, column is judge. Cleanliness and
technical approach are rated out of 10.</p>
<h3 id="absolute-rank" tabindex="-1"><a class="header-anchor" href="https://www.codejam.info/2026/04/comparing-gpt-5-4-opus-4-6-glm-5-1-kimi-k2-5-mimo-v2-pro-and-minimax-m2-7.html#absolute-rank"><span>Absolute rank</span></a></h3>
<table>
<thead>
<tr>
<th></th>
<th>Overall</th>
<th>GPT-5.4</th>
<th>Opus 4.6</th>
<th>GLM-5.1</th>
<th>Kimi K2.5</th>
<th>MiMo V2 Pro</th>
<th>MiniMax M2.7</th>
</tr>
</thead>
<tbody>
<tr>
<td>GPT-5.4</td>
<td>🥇</td>
<td>1</td>
<td>2</td>
<td>2</td>
<td>2</td>
<td>1</td>
<td>2</td>
</tr>
<tr>
<td>Opus 4.6</td>
<td>🥈</td>
<td>3</td>
<td>3</td>
<td>3</td>
<td>1</td>
<td>3</td>
<td>1</td>
</tr>
<tr>
<td>MiMo V2 Pro</td>
<td>🥉</td>
<td>2</td>
<td>1</td>
<td>1</td>
<td>3</td>
<td>4</td>
<td>6</td>
</tr>
<tr>
<td>GLM-5.1</td>
<td>4</td>
<td>4</td>
<td>4</td>
<td>4</td>
<td>4</td>
<td>2</td>
<td>3</td>
</tr>
<tr>
<td>Kimi K2.5</td>
<td>5</td>
<td>5</td>
<td>5</td>
<td>5</td>
<td>5</td>
<td>5</td>
<td>4</td>
</tr>
<tr>
<td>MiniMax M2.7</td>
<td>6</td>
<td>6</td>
<td>6</td>
<td>6</td>
<td>6</td>
<td>6</td>
<td>5</td>
</tr>
</tbody>
</table>
<h3 id="cleanliness" tabindex="-1"><a class="header-anchor" href="https://www.codejam.info/2026/04/comparing-gpt-5-4-opus-4-6-glm-5-1-kimi-k2-5-mimo-v2-pro-and-minimax-m2-7.html#cleanliness"><span>Cleanliness</span></a></h3>
<table>
<thead>
<tr>
<th></th>
<th>Average</th>
<th>GPT-5.4</th>
<th>GLM-5.1</th>
<th>Kimi K2.5</th>
<th>MiMo V2 Pro</th>
<th>MiniMax M2.7</th>
</tr>
</thead>
<tbody>
<tr>
<td>GPT-5.4</td>
<td>8.4</td>
<td>8.5</td>
<td>8</td>
<td>9</td>
<td>8.5</td>
<td>8</td>
</tr>
<tr>
<td>GLM-5.1</td>
<td>7.8</td>
<td>8</td>
<td>8</td>
<td>8</td>
<td>8</td>
<td>7</td>
</tr>
<tr>
<td>Opus 4.6</td>
<td>7.7</td>
<td>6.5</td>
<td>6</td>
<td>9</td>
<td>8</td>
<td>9</td>
</tr>
<tr>
<td>Kimi K2.5</td>
<td>6.5</td>
<td>6</td>
<td>5</td>
<td>7</td>
<td>7.5</td>
<td>7</td>
</tr>
<tr>
<td>MiMo V2 Pro</td>
<td>6.3</td>
<td>6.5</td>
<td>7</td>
<td>7</td>
<td>7</td>
<td>4</td>
</tr>
<tr>
<td>MiniMax M2.7</td>
<td>5.2</td>
<td>4</td>
<td>4</td>
<td>6</td>
<td>7</td>
<td>5</td>
</tr>
</tbody>
</table>
<h3 id="technical-approach" tabindex="-1"><a class="header-anchor" href="https://www.codejam.info/2026/04/comparing-gpt-5-4-opus-4-6-glm-5-1-kimi-k2-5-mimo-v2-pro-and-minimax-m2-7.html#technical-approach"><span>Technical approach</span></a></h3>
<table>
<thead>
<tr>
<th></th>
<th>Average</th>
<th>GPT-5.4</th>
<th>GLM-5.1</th>
<th>Kimi K2.5</th>
<th>MiMo V2 Pro</th>
<th>MiniMax M2.7</th>
</tr>
</thead>
<tbody>
<tr>
<td>GPT-5.4</td>
<td>8.2</td>
<td>9</td>
<td>8</td>
<td>9</td>
<td>7</td>
<td>8</td>
</tr>
<tr>
<td>Opus 4.6</td>
<td>7.9</td>
<td>8</td>
<td>7</td>
<td>9</td>
<td>7.5</td>
<td>8</td>
</tr>
<tr>
<td>MiMo V2 Pro</td>
<td>7.4</td>
<td>8.5</td>
<td>9</td>
<td>8</td>
<td>7.5</td>
<td>4</td>
</tr>
<tr>
<td>GLM-5.1</td>
<td>6.4</td>
<td>6</td>
<td>5</td>
<td>7</td>
<td>7</td>
<td>7</td>
</tr>
<tr>
<td>Kimi K2.5</td>
<td>5.6</td>
<td>4</td>
<td>6</td>
<td>7</td>
<td>6</td>
<td>5</td>
</tr>
<tr>
<td>MiniMax M2.7</td>
<td>5.2</td>
<td>3</td>
<td>5</td>
<td>7</td>
<td>6</td>
<td>5</td>
</tr>
</tbody>
</table>
<h2 id="a-surprising-finding-on-lack-of-biases" tabindex="-1"><a class="header-anchor" href="https://www.codejam.info/2026/04/comparing-gpt-5-4-opus-4-6-glm-5-1-kimi-k2-5-mimo-v2-pro-and-minimax-m2-7.html#a-surprising-finding-on-lack-of-biases"><span>A surprising finding on (lack of) biases</span></a></h2>
<p>The most impressing thing for me was that models didn’t seem to be
biased by their own performance??</p>
<p>All models gave their own output a score that was either very close to,
or lower than their average score. Even when the average score was low.</p>
<p>GPT-5.4 is the only one that ranked itself first, but guess what, the
consensus also agreed on that. 😎</p>
<h2 id="conclusion" tabindex="-1"><a class="header-anchor" href="https://www.codejam.info/2026/04/comparing-gpt-5-4-opus-4-6-glm-5-1-kimi-k2-5-mimo-v2-pro-and-minimax-m2-7.html#conclusion"><span>Conclusion</span></a></h2>
<p>I’m gonna keep using GLM-5.1 via OpenCode Go for the rest of the month
or until I run out of usage. Then switch to GPT-5.4 via Codex Plus
to see if it’s worth the extra $$$, both in terms of quality and
quantity.</p>
<p>Though by then I’ll probably have 5 other models to test. 😂🙃</p>
<h2 id="meta-conclusion" tabindex="-1"><a class="header-anchor" href="https://www.codejam.info/2026/04/comparing-gpt-5-4-opus-4-6-glm-5-1-kimi-k2-5-mimo-v2-pro-and-minimax-m2-7.html#meta-conclusion"><span>Meta conclusion</span></a></h2>
<p>Comparing models is peak procrastination. Any of the top models will do
just fine.</p>
<p>And I guess GLM and MiMo are now playing in that league at a fraction of
the cost.</p>
<p>If you have a clear idea what you want, you’ll need to steer all
of them in one way or another. Just pick one and get back to work.</p>
<section class="post-footer">
  <h3>Want to leave a comment?</h3>
  <p>
    Join the discussion on <a href="https://x.com/valeriangalliat/status/2044253775809130821">X</a> or send me an <a href="mailto:val@codejam.info">email</a>! 💌<br>
    This post helped you? <a href="https://ko-fi.com/funkyval">Buy me a coffee</a>! 🍻
  </p>
</section>
]]></content>
  </entry>
  <entry>
    <title>Cross-compiling a Tauri app for x86-64 Linux from an ARM Mac (no emulation ⚡️)</title>
    <link href="https://www.codejam.info/2026/04/cross-compiling-tauri-linux-arm-mac.html" />
    <id>https://www.codejam.info/2026/04/cross-compiling-tauri-linux-arm-mac.html</id>
    <updated>2026-04-14T07:00:00.000Z</updated>
    <content type="html"><![CDATA[<p>Context: I’m building an app called <a href="https://useflame.app/">Flame</a>
with <a href="https://v2.tauri.app/">Tauri</a>, and I work from a M-series Mac.</p>
<p>We initially released it only on Mac as a MVP. Some folks on Reddit
showed interest but were on Windows and Linux. I’m pretty eager to get
more hands on the product in those early stages, to gather feedback and
learn from users where to take it from there, so making it cross
platform early on sounded like a quick win.</p>
<h2 id="quick-note-on-the-windows-build" tabindex="-1"><a class="header-anchor" href="https://www.codejam.info/2026/04/cross-compiling-tauri-linux-arm-mac.html#quick-note-on-the-windows-build"><span>Quick note on the Windows build</span></a></h2>
<p>Not the topic of this post but I might as well throw that here in case
it’s useful to anyone. Building for Windows from Mac turned out pretty
easy following <a href="https://v2.tauri.app/distribute/windows-installer/#build-windows-apps-on-linux-and-macos">this guide</a>.
In short:</p>
<pre><code class="hljs language-sh">brew install nsis llvm
<span class="hljs-built_in">export</span> PATH=<span class="hljs-string">&quot;/opt/homebrew/opt/llvm/bin:<span class="hljs-variable">$PATH</span>&quot;</span>
rustup target add x86_64-pc-windows-msvc
cargo install --locked cargo-xwin
<span class="hljs-built_in">export</span> PATH=<span class="hljs-string">&quot;<span class="hljs-variable">$HOME</span>/.cargo/bin:<span class="hljs-variable">$PATH</span>&quot;</span>
tauri build --runner cargo-xwin --target x86_64-pc-windows-msvc
</code></pre>
<p>This works like a charm, signed auto updates and all.</p>
<p>Signing the app to avoid the SmartScreen warning is not handled out of
the box by Tauri when cross-compiling (it only knows to use
<code>signtool.exe</code> from a Windows host), but you can configure a custom
signing command like <a href="https://github.com/mtrojnar/osslsigncode">osslsigncode</a>
and do it from Mac or Linux as well (I haven’t tried it).</p>
<h2 id="what-about-github-actions" tabindex="-1"><a class="header-anchor" href="https://www.codejam.info/2026/04/cross-compiling-tauri-linux-arm-mac.html#what-about-github-actions"><span>What about GitHub Actions</span></a></h2>
<p>Yeah honestly, should have just done that. I got nerd sniped by wanting
to compile for all platforms from my own machine without renting
hardware in the cloud. And the idea of generating a working x86-64
Windows and Linux build from my ARM Mac with near-native build
performance (so without using QEMU) sounded kinda cool to me.</p>
<p>But really, Tauri has <a href="https://v2.tauri.app/distribute/pipelines/github/">everything ready to use GitHub Actions</a>
and output binaries for all platforms, it’s a no brainer.</p>
<p>And the free tier for GitHub Actions is high enough that unless you’re
releasing updates every other day, you probably won’t need to pay for
it? The Mac runner is by far the most expensive so you could also go a
long way building for Mac from your dev machine and delegating only
Windows and Linux to GitHub Actions.</p>
<h2 id="ok-but-i-m-here-for-cross-compiling-linux-remember" tabindex="-1"><a class="header-anchor" href="https://www.codejam.info/2026/04/cross-compiling-tauri-linux-arm-mac.html#ok-but-i-m-here-for-cross-compiling-linux-remember"><span>OK but I’m here for cross-compiling Linux, remember?</span></a></h2>
<p>Oh yeah, sorry. I got distracted. 😂</p>
<p>The strategy:</p>
<ul>
<li><strong>Use macOS Virtualization framework to run a ARM Linux
VM at near-native performance.</strong></li>
<li><strong>Inside that VM, configure Linux to cross-compile the app for
x86-64.</strong></li>
<li><strong>Use Rosetta from Linux (yeah this is a
thing) to run any x86-64 binaries we need along the way at also
near-native performance.</strong></li>
</ul>
<h2 id="creating-the-vm-with-lima" tabindex="-1"><a class="header-anchor" href="https://www.codejam.info/2026/04/cross-compiling-tauri-linux-arm-mac.html#creating-the-vm-with-lima"><span>Creating the VM with Lima</span></a></h2>
<p>We use <a href="https://lima-vm.io/">Lima</a> to manage a headless Linux VM.</p>
<pre><code class="hljs language-sh">brew install lima

limactl create --name=linux-builder \
    --vm-type=vz --rosetta \
    --cpus=6 --memory=8 --disk=60 \
    --containerd=none

limactl start linux-builder
</code></pre>
<ul>
<li>
<p><a href="https://lima-vm.io/docs/config/vmtype/vz/"><code>--vm-type=vz</code></a> to make
sure we use macOS Virtualization framework. It’s the default in Lima
but we’re explicit about it.</p>
<p>Virtualization framework only works for same-architecture VMs, so here
we’re making an ARM Linux VM, even though we want to build for x86-64.</p>
</li>
<li>
<p><a href="https://lima-vm.io/docs/config/multi-arch/#fast-mode-2"><code>--rosetta</code></a>
to use Apple Rosetta to translate x86-64 instructions to ARM on the
fly, for binaries we run inside the VM.</p>
</li>
<li>
<p><code>--containerd=none</code> because we don’t need containerd (not running
Docker here).</p>
</li>
</ul>
<h2 id="provisioning-the-vm" tabindex="-1"><a class="header-anchor" href="https://www.codejam.info/2026/04/cross-compiling-tauri-linux-arm-mac.html#provisioning-the-vm"><span>Provisioning the VM</span></a></h2>
<p>Now we’ll run a shell inside the VM to provision it with everything we
need to cross-compile a Tauri app.</p>
<pre><code class="hljs language-sh">limactl shell linux-builder
</code></pre>
<h3 id="add-amd64-architecture" tabindex="-1"><a class="header-anchor" href="https://www.codejam.info/2026/04/cross-compiling-tauri-linux-arm-mac.html#add-amd64-architecture"><span>Add AMD64 architecture</span></a></h3>
<pre><code class="hljs language-sh"><span class="hljs-comment"># No interactive prompts</span>
<span class="hljs-built_in">export</span> DEBIAN_FRONTEND=noninteractive

<span class="hljs-comment"># Enable AMD64 multi-arch</span>
<span class="hljs-built_in">sudo</span> dpkg --add-architecture amd64

<span class="hljs-comment"># VM defaults to `ports.ubuntu.com` which only has ARM64 sources.</span>
<span class="hljs-comment"># We need to add `archive.ubuntu.com` for AMD64.</span>
CODENAME=<span class="hljs-string">&quot;<span class="hljs-subst">$(. /etc/os-release &amp;&amp; echo <span class="hljs-string">&quot;<span class="hljs-variable">${VERSION_CODENAME}</span>&quot;</span>)</span>&quot;</span>

<span class="hljs-built_in">sudo</span> <span class="hljs-built_in">tee</span> /etc/apt/sources.list.d/ubuntu-amd64.list &gt; /dev/null &lt;&lt; <span class="hljs-string">EOF
deb [arch=amd64] http://archive.ubuntu.com/ubuntu ${CODENAME} main restricted universe multiverse
deb [arch=amd64] http://archive.ubuntu.com/ubuntu ${CODENAME}-updates main restricted universe multiverse
deb [arch=amd64] http://archive.ubuntu.com/ubuntu ${CODENAME}-backports main restricted universe multiverse
deb [arch=amd64] http://security.ubuntu.com/ubuntu ${CODENAME}-security main restricted universe multiverse
EOF</span>

<span class="hljs-comment"># Constrain the native sources to ARM64 so APT doesn&#x27;t try to fetch</span>
<span class="hljs-comment"># AMD64 packages from it.</span>
<span class="hljs-built_in">sudo</span> sed -i <span class="hljs-string">&#x27;/^Architectures:/d&#x27;</span> /etc/apt/sources.list.d/ubuntu.sources
<span class="hljs-built_in">sudo</span> sed -i <span class="hljs-string">&#x27;s/^Types: deb/Types: deb\nArchitectures: arm64/&#x27;</span> /etc/apt/sources.list.d/ubuntu.sources

<span class="hljs-built_in">sudo</span> apt update
</code></pre>
<h3 id="install-system-dependencies" tabindex="-1"><a class="header-anchor" href="https://www.codejam.info/2026/04/cross-compiling-tauri-linux-arm-mac.html#install-system-dependencies"><span>Install system dependencies</span></a></h3>
<pre><code class="hljs language-sh">PKGS=<span class="hljs-string">&quot;git ca-certificates&quot;</span>

<span class="hljs-comment"># Tauri prerequisites from &lt;https://v2.tauri.app/start/prerequisites/#linux&gt;.</span>
<span class="hljs-comment"># Added AMD64 suffix for cross-compilation.</span>
PKGS=<span class="hljs-string">&quot;<span class="hljs-variable">$PKGS</span>
build-essential curl wget file
libwebkit2gtk-4.1-dev:amd64 libxdo-dev:amd64 libssl-dev:amd64
libayatana-appindicator3-dev:amd64 librsvg2-dev:amd64&quot;</span>

<span class="hljs-comment"># Tauri needs the AMD64 version of `xdg-open` to bundle in the app</span>
PKGS=<span class="hljs-string">&quot;<span class="hljs-variable">$PKGS</span> xdg-utils:amd64&quot;</span>

<span class="hljs-comment"># Cross-compilation toolchain</span>
PKGS=<span class="hljs-string">&quot;<span class="hljs-variable">$PKGS</span> gcc-x86-64-linux-gnu g++-x86-64-linux-gnu&quot;</span>

<span class="hljs-built_in">sudo</span> apt install -y --no-install-recommends <span class="hljs-variable">$PKGS</span>
</code></pre>
<h3 id="install-rust" tabindex="-1"><a class="header-anchor" href="https://www.codejam.info/2026/04/cross-compiling-tauri-linux-arm-mac.html#install-rust"><span>Install Rust</span></a></h3>
<p>Native ARM Rust with x86-64 cross-compilation target.</p>
<pre><code class="hljs language-sh"><span class="hljs-comment"># `sh -s` to force reading from stdin and allow passing arguments without a</span>
<span class="hljs-comment"># filename. Configure the installer to be non-interactive (default yes) not</span>
<span class="hljs-comment"># modify the # `PATH` (we do it ourselves) and install stable.</span>
curl --proto <span class="hljs-string">&#x27;=https&#x27;</span> --tlsv1.2 -sSf https://sh.rustup.rs \
    | sh -s -- -y --no-modify-path --default-toolchain stable

<span class="hljs-built_in">echo</span> <span class="hljs-string">&#x27;export PATH=&quot;$HOME/.cargo/bin:$PATH&quot;&#x27;</span> &gt;&gt; ~/.profile
<span class="hljs-built_in">export</span> PATH=<span class="hljs-string">&quot;<span class="hljs-variable">$HOME</span>/.cargo/bin:<span class="hljs-variable">${PATH}</span>&quot;</span>

rustup target add x86_64-unknown-linux-gnu
</code></pre>
<h3 id="install-node-js" tabindex="-1"><a class="header-anchor" href="https://www.codejam.info/2026/04/cross-compiling-tauri-linux-arm-mac.html#install-node-js"><span>Install Node.js</span></a></h3>
<p>I like to use <a href="https://asdf-vm.com/">asdf</a> for this.</p>
<pre><code class="hljs language-sh">ASDF_URL=$(curl -fsS https://api.github.com/repos/asdf-vm/asdf/releases/latest \
    | grep -o <span class="hljs-string">&#x27;&quot;browser_download_url&quot;: *&quot;[^&quot;]*linux-arm64\.tar\.gz&quot;&#x27;</span> \
    | grep -o <span class="hljs-string">&#x27;https://[^&quot;]*&#x27;</span>)

curl -fsSL <span class="hljs-string">&quot;<span class="hljs-variable">$ASDF_URL</span>&quot;</span> | <span class="hljs-built_in">sudo</span> tar -xz -C /usr/local/bin asdf

<span class="hljs-built_in">echo</span> <span class="hljs-string">&#x27;export ASDF_DATA_DIR=&quot;$HOME/.asdf&quot;&#x27;</span> &gt;&gt; ~/.profile
<span class="hljs-built_in">echo</span> <span class="hljs-string">&#x27;export PATH=&quot;$ASDF_DATA_DIR/shims:$PATH&quot;&#x27;</span> &gt;&gt; ~/.profile

<span class="hljs-built_in">export</span> ASDF_DATA_DIR=<span class="hljs-string">&quot;<span class="hljs-variable">$HOME</span>/.asdf&quot;</span>
<span class="hljs-built_in">export</span> PATH=<span class="hljs-string">&quot;<span class="hljs-variable">$ASDF_DATA_DIR</span>/shims:<span class="hljs-variable">$PATH</span>&quot;</span>

asdf plugin add nodejs

<span class="hljs-comment"># Optionally pnpm or whatever package manager you use</span>
<span class="hljs-comment"># asdf plugin add pnpm</span>

<span class="hljs-comment"># Assuming we already have a `.tool-versions`</span>
asdf install
</code></pre>
<h3 id="rosetta-appimage-support" tabindex="-1"><a class="header-anchor" href="https://www.codejam.info/2026/04/cross-compiling-tauri-linux-arm-mac.html#rosetta-appimage-support"><span>Rosetta AppImage support</span></a></h3>
<p>This was the biggest culprit of this whole setup.</p>
<p>Tauri build uses <a href="https://github.com/linuxdeploy/linuxdeploy">linuxdeploy</a>
to build AppImages, and linuxdeploy itself is distributed as an
<a href="https://en.wikipedia.org/wiki/AppImage">AppImage</a>.</p>
<p>Lima with <code>--rosetta</code> configures <code>binfmt_misc</code> to use Rosetta for a
particular ELF signature. However their pattern is not compatible with
AppImages.</p>
<p>Normal ELF format normally uses <code>0x00 0x00</code> for bytes 8-9
(<code>EI_ABIVERSION</code> and the start of <code>EI_PAD</code>), but the <a href="https://github.com/AppImage/AppImageSpec/blob/master/draft.md#image-format">AppImage format</a>
repurposes them as a magic identification bytes “AI” (<code>0x41 0x49</code>).</p>
<p>We need to patch <a href="https://github.com/lima-vm/lima/blob/10d0287227bfdd067dfd770cf4977a4fa9ac8c21/pkg/driver/vz/boot.Linux/05-rosetta-volume.sh#L20">Lima’s <code>binfmt_misc</code> pattern</a>
to handle this so we can use Rosetta for x86-64 AppImages like the
linuxdeploy one.</p>
<p>Here’s the ELF <a href="https://refspecs.linuxfoundation.org/elf/gabi4+/ch4.eheader.html#elfid">identification bytes</a>:</p>
<table>
<thead>
<tr>
<th>Name</th>
<th>Offset</th>
<th>Purpose</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>EI_MAG0</code></td>
<td>0</td>
<td>File identification</td>
</tr>
<tr>
<td><code>EI_MAG1</code></td>
<td>1</td>
<td>File identification</td>
</tr>
<tr>
<td><code>EI_MAG2</code></td>
<td>2</td>
<td>File identification</td>
</tr>
<tr>
<td><code>EI_MAG3</code></td>
<td>3</td>
<td>File identification</td>
</tr>
<tr>
<td><code>EI_CLASS</code></td>
<td>4</td>
<td>File class</td>
</tr>
<tr>
<td><code>EI_DATA</code></td>
<td>5</td>
<td>Data encoding</td>
</tr>
<tr>
<td><code>EI_VERSION</code></td>
<td>6</td>
<td>File version</td>
</tr>
<tr>
<td><code>EI_OSABI</code></td>
<td>7</td>
<td>Operating system/ABI identification</td>
</tr>
<tr>
<td><code>EI_ABIVERSION</code></td>
<td>8</td>
<td>ABI version</td>
</tr>
<tr>
<td><code>EI_PAD</code></td>
<td>9</td>
<td>Start of padding bytes</td>
</tr>
<tr>
<td><code>EI_NIDENT</code></td>
<td>16</td>
<td>Size of <code>e_ident[]</code></td>
</tr>
</tbody>
</table>
<p>Lima’s mask is strict on <code>EI_ABIVERSION</code> and <code>EI_PAD</code>, and this causes
it to exclude AppImage binaries. We can change the mask to ignore those
bytes and match AppImages.</p>
<details>
<summary>Show the bytes table</summary>
<table>
<thead>
<tr>
<th>Offset</th>
<th>Before</th>
<th>After</th>
</tr>
</thead>
<tbody>
<tr>
<td>0</td>
<td><code>ff</code></td>
<td><code>ff</code></td>
</tr>
<tr>
<td>1</td>
<td><code>ff</code></td>
<td><code>ff</code></td>
</tr>
<tr>
<td>2</td>
<td><code>ff</code></td>
<td><code>ff</code></td>
</tr>
<tr>
<td>3</td>
<td><code>ff</code></td>
<td><code>ff</code></td>
</tr>
<tr>
<td>4</td>
<td><code>ff</code></td>
<td><code>ff</code></td>
</tr>
<tr>
<td>5</td>
<td><code>fe</code></td>
<td><code>fe</code></td>
</tr>
<tr>
<td>6</td>
<td><code>fe</code></td>
<td><code>fe</code></td>
</tr>
<tr>
<td>7</td>
<td><code>00</code></td>
<td><code>00</code></td>
</tr>
<tr>
<td><strong>8</strong></td>
<td><strong><code>ff</code></strong></td>
<td><strong><code>00</code></strong></td>
</tr>
<tr>
<td><strong>9</strong></td>
<td><strong><code>ff</code></strong></td>
<td><strong><code>00</code></strong></td>
</tr>
<tr>
<td><strong>10</strong></td>
<td><strong><code>ff</code></strong></td>
<td><strong><code>00</code></strong></td>
</tr>
<tr>
<td><strong>11</strong></td>
<td><strong><code>ff</code></strong></td>
<td><strong><code>00</code></strong></td>
</tr>
<tr>
<td><strong>12</strong></td>
<td><strong><code>ff</code></strong></td>
<td><strong><code>00</code></strong></td>
</tr>
<tr>
<td><strong>13</strong></td>
<td><strong><code>ff</code></strong></td>
<td><strong><code>00</code></strong></td>
</tr>
<tr>
<td><strong>14</strong></td>
<td><strong><code>ff</code></strong></td>
<td><strong><code>00</code></strong></td>
</tr>
<tr>
<td><strong>15</strong></td>
<td><strong><code>ff</code></strong></td>
<td><strong><code>00</code></strong></td>
</tr>
<tr>
<td>16</td>
<td><code>fe</code></td>
<td><code>fe</code></td>
</tr>
<tr>
<td>17</td>
<td><code>ff</code></td>
<td><code>ff</code></td>
</tr>
<tr>
<td>18</td>
<td><code>ff</code></td>
<td><code>ff</code></td>
</tr>
<tr>
<td>19</td>
<td><code>ff</code></td>
<td><code>ff</code></td>
</tr>
</tbody>
</table>
</details>
<pre><code class="hljs language-sh"><span class="hljs-built_in">sudo</span> <span class="hljs-built_in">tee</span> /etc/binfmt.d/rosetta.conf &gt; /dev/null &lt;&lt; <span class="hljs-string">EOF
# Ignore \`EI_ABIVERSION\` and \`EI_PAD\` for AppImage support
:rosetta:M::\x7fELF\x02\x01\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x02\x00\x3e\x00:\xff\xff\xff\xff\xff\xfe\xfe\x00\x00\x00\x00\x00\x00\x00\x00\x00\xfe\xff\xff\xff:/mnt/lima-rosetta/rosetta:OCF
EOF</span>

<span class="hljs-comment"># Re-read all `binfmt.d` entries to activate the new rule immediately</span>
<span class="hljs-built_in">sudo</span> systemctl restart systemd-binfmt
</code></pre>
<h2 id="building-the-appimage" tabindex="-1"><a class="header-anchor" href="https://www.codejam.info/2026/04/cross-compiling-tauri-linux-arm-mac.html#building-the-appimage"><span>Building the AppImage</span></a></h2>
<p>The VM is now provisioned. The following we run for every build.</p>
<p>First, we sync the code in a VM-private directory, because we don’t want
the Linux <code>node_modules</code> installation to mess up our macOS host
<code>node_modules</code>.</p>
<pre><code class="hljs language-sh">VM_APP_DIR=<span class="hljs-string">&quot;/home/<span class="hljs-variable">$USER</span>.guest/my-app&quot;</span>

limactl shell linux-builder -- rsync -a --delete \
    --exclude=<span class="hljs-string">&#x27;node_modules&#x27;</span> \
    --exclude=<span class="hljs-string">&#x27;src-tauri/target&#x27;</span> \
    ./ <span class="hljs-string">&quot;<span class="hljs-variable">$VM_APP_DIR</span>/&quot;</span>
</code></pre>
<p>If we’re signing the build and <code>TAURI_SIGNING_PRIVATE_KEY</code> refers to a
file on the host side, we need to convert it to a plain string so the VM
has access.</p>
<pre><code class="hljs language-sh"><span class="hljs-keyword">if</span> [ -f <span class="hljs-string">&quot;<span class="hljs-variable">${TAURI_SIGNING_PRIVATE_KEY}</span>&quot;</span> ]; <span class="hljs-keyword">then</span>
    TAURI_SIGNING_PRIVATE_KEY=<span class="hljs-string">&quot;<span class="hljs-subst">$(cat <span class="hljs-string">&quot;<span class="hljs-variable">$TAURI_SIGNING_PRIVATE_KEY</span>&quot;</span>)</span>&quot;</span>
<span class="hljs-keyword">fi</span>
</code></pre>
<p>Then we set cross-compilation environment variables, and configure Lima
to forward them to the VM.</p>
<pre><code class="hljs language-sh"><span class="hljs-built_in">export</span> PKG_CONFIG_ALLOW_CROSS=1
<span class="hljs-built_in">export</span> PKG_CONFIG_PATH=<span class="hljs-string">&quot;/usr/lib/x86_64-linux-gnu/pkgconfig:/usr/share/pkgconfig&quot;</span>
<span class="hljs-built_in">export</span> CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_LINKER=x86_64-linux-gnu-gcc

<span class="hljs-comment"># We force AppImages to extract to a temp dir instead of FUSE-mounting, because</span>
<span class="hljs-comment"># the kernel&#x27;s `compat_ioctl` layer does not fully translate x86-64 FUSE ioctls</span>
<span class="hljs-comment"># to ARM64. This allows to run x86-64 AppImages like linuxdeploy.</span>
<span class="hljs-built_in">export</span> APPIMAGE_EXTRACT_AND_RUN=1

<span class="hljs-built_in">export</span> LIMA_SHELLENV_ALLOW=<span class="hljs-string">&quot;PKG_CONFIG_ALLOW_CROSS,PKG_CONFIG_PATH,CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_LINKER,TAURI_SIGNING_PRIVATE_KEY,TAURI_SIGNING_PRIVATE_KEY_PASSWORD,APPIMAGE_EXTRACT_AND_RUN&quot;</span>
</code></pre>
<p>Install dependencies (adapt to your package manager).</p>
<pre><code class="hljs language-sh">limactl shell --workdir=<span class="hljs-string">&quot;<span class="hljs-variable">$VM_APP_DIR</span>&quot;</span> linux-builder -- pnpm install
</code></pre>
<p>Actually build.</p>
<pre><code class="hljs language-sh">limactl shell --preserve-env --workdir=<span class="hljs-string">&quot;<span class="hljs-variable">$VM_APP_DIR</span>&quot;</span> linux-builder -- \
    pnpm <span class="hljs-built_in">exec</span> tauri build <span class="hljs-string">&quot;<span class="hljs-variable">$@</span>&quot;</span>
</code></pre>
<p>Finally copy the artifacts back to host.</p>
<pre><code class="hljs language-sh">BUNDLE_DIR=<span class="hljs-string">&quot;src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage&quot;</span>

<span class="hljs-built_in">mkdir</span> -p <span class="hljs-string">&quot;<span class="hljs-variable">$PWD</span>/<span class="hljs-variable">$BUNDLE_DIR</span>&quot;</span>

limactl copy --recursive \
    <span class="hljs-string">&quot;linux-builder:<span class="hljs-variable">$VM_APP_DIR</span>/<span class="hljs-variable">$BUNDLE_DIR</span>/&quot;</span> \
    <span class="hljs-string">&quot;<span class="hljs-variable">$PWD</span>/<span class="hljs-variable">$BUNDLE_DIR</span>/&quot;</span>

version=$(jq -r <span class="hljs-string">&#x27;.version&#x27;</span> app/package.json)

<span class="hljs-keyword">for</span> f <span class="hljs-keyword">in</span> <span class="hljs-string">&quot;<span class="hljs-variable">$BUNDLE_DIR</span>/&quot;</span>*<span class="hljs-string">&quot;_<span class="hljs-variable">${version}</span>_amd64.AppImage&quot;</span>*; <span class="hljs-keyword">do</span>
    <span class="hljs-built_in">echo</span> <span class="hljs-string">&quot;  <span class="hljs-variable">$PWD</span>/<span class="hljs-variable">$f</span>&quot;</span>
<span class="hljs-keyword">done</span>
</code></pre>
<h2 id="wrapping-up" tabindex="-1"><a class="header-anchor" href="https://www.codejam.info/2026/04/cross-compiling-tauri-linux-arm-mac.html#wrapping-up"><span>Wrapping up</span></a></h2>
<p>Mainly because of the Lima default Rosetta <code>binfmt_misc</code> mask that
rejects AppImages magic bytes, this was harder than I expected.</p>
<p>Again, should have really just used GitHub Actions for that. 😅</p>
<p>But hey, this was kinda fun, and I find it “aesthetic” to be able to
make a x86-64 Linux build from an ARM Mac without emulation.</p>
<section class="post-footer">
  <h3>Want to leave a comment?</h3>
  <p>
    Join the discussion on <a href="https://x.com/valeriangalliat/status/2044190593543483532">X</a> or send me an <a href="mailto:val@codejam.info">email</a>! 💌<br>
    This post helped you? <a href="https://ko-fi.com/funkyval">Buy me a coffee</a>! 🍻
  </p>
</section>
]]></content>
  </entry>
  <entry>
    <title>Run Astro middleware in front of static pages (Cloudflare Workers)</title>
    <link href="https://www.codejam.info/2026/02/astro-middleware-static-pages.html" />
    <id>https://www.codejam.info/2026/02/astro-middleware-static-pages.html</id>
    <updated>2026-02-11T08:00:00.000Z</updated>
    <content type="html"><![CDATA[<div class="note">
<p><strong>Update:</strong> Astro v6 and <code>@astrojs/cloudflare</code> v13 simplify a few things
described below, including how you configure a custom worker entrypoint.
See <a href="https://www.codejam.info/2026/02/astro-middleware-static-pages.html#astro-v6-update">Astro v6</a>!</p>
</div>
<p>Astro allows to prerender pages as static assets, so everything is
compiled at build time and can be served super quick.</p>
<p>But also, Astro has the concept of a middleware, that allows to run
custom logic in front of every request, which can be handy for things
like auth, redirects, proxying and more.</p>
<p>The problem? <a href="https://github.com/withastro/roadmap/discussions/869">The middleware is not run for static pages</a>.</p>
<h2 id="why-run-the-middleware-on-static-pages" tabindex="-1"><a class="header-anchor" href="https://www.codejam.info/2026/02/astro-middleware-static-pages.html#why-run-the-middleware-on-static-pages"><span>Why run the middleware on static pages?</span></a></h2>
<p>In many cases this is not a problem, because you may not often need to
run custom logic in front of static pages. After all if those pages
needed custom logic per request, they’d probably be dynamic.</p>
<p>Pages behind auth are most likely dynamic so the content can be
contextual to the logged-in user. As for redirects and proxies, they
usually make sense <em>when no actual page</em> matches the URL, and Astro runs
the middleware in both those cases.</p>
<p>However my use case is a bit different. I’d like to get a sense of what
pages are visited on my site, and I don’t like the idea of client-side
tracking, partly because of the privacy stigma, and partly because “you
can’t trust the client”.</p>
<h3 id="unnecessary-note-on-client-side-tracking" tabindex="-1"><a class="header-anchor" href="https://www.codejam.info/2026/02/astro-middleware-static-pages.html#unnecessary-note-on-client-side-tracking"><span>Unnecessary note on client-side tracking</span></a></h3>
<p>At that point it seems that mostly everyone on the internet does
client-side tracking, including every business I ever worked with. Yet I
never encountered cases where the data got heavily manipulated.</p>
<p>The only anomalies I ever noticed are the occasional pen test with all
sorts of injection attempts, but those typically just get ignored at
ingestion because the data is broken, or at worst, it breaks queries
because corrupted data <em>was</em> ingested (read: data type mismatch,
hopefully we know how to avoid SQL injections by now).</p>
<p>However it seems that most people have something else to do than
programmatically sending well formatted but fake tracking data on public
endpoints for the mere pleasure of causing chaos for someone else.</p>
<p>Despite that, I’m still allergic to client-side tracking because <em>on
paper</em> this is all still possible.</p>
<h4 id="unnecessary-note-on-http-logs" tabindex="-1"><a class="header-anchor" href="https://www.codejam.info/2026/02/astro-middleware-static-pages.html#unnecessary-note-on-http-logs"><span>Unnecessary note on HTTP logs</span></a></h4>
<p>You could say I can achieve all this with HTTP logs without bothering
with an edge worker, and you’d be 100% right. However,
Cloudflare <a href="https://developers.cloudflare.com/logs/logpush/#availability">only gives access to HTTP logs to Enterprise customers</a>
and this is not exactly an interesting option for me right now.</p>
<p>If not for that it would definitely be my favorite solution.</p>
<h2 id="running-backend-code-in-front-of-static-pages" tabindex="-1"><a class="header-anchor" href="https://www.codejam.info/2026/02/astro-middleware-static-pages.html#running-backend-code-in-front-of-static-pages"><span>Running backend code in front of static pages</span></a></h2>
<p>Back to the topic. Sadly there’s no generic way I found to run the
Astro middleware in front of static pages. This means the solution is
gonna be dependent on your <em>adapter</em>. In my case, I’m using the
<a href="https://docs.astro.build/en/guides/integrations-guide/cloudflare/">Cloudflare adapter</a>.</p>
<p>We have two layers to deal with here. Out of the box the logic is as
follows:</p>
<ul>
<li>Cloudflare Workers:
<ul>
<li>If there’s a static asset that matches the URL, serve that.</li>
<li>Otherwise, run the Astro <code>worker.js</code>.</li>
</ul>
</li>
<li>Astro <code>worker.js</code>:
<ul>
<li>If there’s a static asset that matches the URL, serve that.</li>
<li>Otherwise, run the user-provided middleware.</li>
</ul>
</li>
</ul>
<h2 id="the-cloudflare-part" tabindex="-1"><a class="header-anchor" href="https://www.codejam.info/2026/02/astro-middleware-static-pages.html#the-cloudflare-part"><span>The Cloudflare part</span></a></h2>
<p>In order to mitigate the <em>first</em> layer (Cloudflare), we need to
configure the runtime to run the worker code in front to some or all
static assets. This is done in <code>wrangler.jsonc</code> using the
<code>run_worker_first</code> directive
(<a href="https://developers.cloudflare.com/workers/static-assets/routing/worker-script/#run-your-worker-script-first">relevant</a>
<a href="https://developers.cloudflare.com/workers/static-assets/binding/#run_worker_first">docs</a>):</p>
<pre><code class="hljs language-json"><span class="hljs-punctuation">{</span>
  <span class="hljs-attr">&quot;assets&quot;</span><span class="hljs-punctuation">:</span> <span class="hljs-punctuation">{</span>
    <span class="hljs-attr">&quot;binding&quot;</span><span class="hljs-punctuation">:</span> <span class="hljs-string">&quot;ASSETS&quot;</span><span class="hljs-punctuation">,</span>
    <span class="hljs-attr">&quot;directory&quot;</span><span class="hljs-punctuation">:</span> <span class="hljs-string">&quot;./dist&quot;</span><span class="hljs-punctuation">,</span>
    <span class="hljs-attr">&quot;run_worker_first&quot;</span><span class="hljs-punctuation">:</span> <span class="hljs-punctuation">[</span>
      <span class="hljs-string">&quot;/&quot;</span><span class="hljs-punctuation">,</span>
      <span class="hljs-string">&quot;/en&quot;</span><span class="hljs-punctuation">,</span>
      <span class="hljs-string">&quot;/en/*&quot;</span><span class="hljs-punctuation">,</span>
      <span class="hljs-string">&quot;/fr&quot;</span><span class="hljs-punctuation">,</span>
      <span class="hljs-string">&quot;/fr/*&quot;</span><span class="hljs-punctuation">,</span>
    <span class="hljs-punctuation">]</span><span class="hljs-punctuation">,</span>
<span class="hljs-punctuation">}</span>
</code></pre>
<p>In this case I force the worker to run for the index, as well as <code>/en</code>,
<code>/fr</code> and anything under.</p>
<p>This means for other assets like JS/CSS/images, we still skip the
worker, but for the static pages I have that match those paths,
Cloudflare will run the edge worker.</p>
<h2 id="the-astro-part" tabindex="-1"><a class="header-anchor" href="https://www.codejam.info/2026/02/astro-middleware-static-pages.html#the-astro-part"><span>The Astro part</span></a></h2>
<p>Now we have Cloudflare run the Astro worker in front of static pages,
but it’s still not enough, because the Astro Cloudflare adapter
<a href="https://github.com/withastro/astro/blob/8780ff2926d59ed196c70032d2ae274b8415655c/packages/integrations/cloudflare/src/utils/handler.ts#L53-L56">skips our middleware</a>
anyway when a static asset matches.</p>
<pre><code class="hljs language-ts"><span class="hljs-keyword">if</span> (app.<span class="hljs-property">manifest</span>.<span class="hljs-property">assets</span>.<span class="hljs-title function_">has</span>(requestPathname)) {
  <span class="hljs-keyword">return</span> env.<span class="hljs-property">ASSETS</span>.<span class="hljs-title function_">fetch</span>(request.<span class="hljs-property">url</span>.<span class="hljs-title function_">replace</span>(<span class="hljs-regexp">/\.html$/</span>, <span class="hljs-string">&#x27;&#x27;</span>))
}
</code></pre>
<p>In order to solve that, we can’t use the Astro middleware anymore.
Instead we need to configure a custom entry point for the worker. This
means we now control the top-level worker code and run our logic there,
regardless what the adapter decides to do.</p>
<p>This is done with <a href="https://docs.astro.build/en/guides/integrations-guide/cloudflare/#workerentrypoint"><code>workerEntryPoint</code> option</a>
in <code>astro.config.mjs</code>:</p>
<pre><code class="hljs language-js"><span class="hljs-keyword">import</span> { defineConfig } <span class="hljs-keyword">from</span> <span class="hljs-string">&#x27;astro/config&#x27;</span>
<span class="hljs-keyword">import</span> cloudflare <span class="hljs-keyword">from</span> <span class="hljs-string">&#x27;@astrojs/cloudflare&#x27;</span>

<span class="hljs-keyword">export</span> <span class="hljs-keyword">default</span> <span class="hljs-title function_">defineConfig</span>({
  <span class="hljs-comment">// ...</span>

  <span class="hljs-attr">adapter</span>: <span class="hljs-title function_">cloudflare</span>({
    <span class="hljs-attr">workerEntryPoint</span>: {
      <span class="hljs-attr">path</span>: <span class="hljs-string">&#x27;src/worker.ts&#x27;</span>,
    },
  }),

  <span class="hljs-comment">// ...</span>
})
</code></pre>
<p>Where <code>src/worker.ts</code> is a custom Cloudflare Worker entry file
<a href="https://docs.astro.build/en/guides/integrations-guide/cloudflare/#creating-a-custom-cloudflare-worker-entry-file">as documented here</a>:</p>
<pre><code class="hljs language-ts"><span class="hljs-keyword">import</span> <span class="hljs-keyword">type</span> { <span class="hljs-title class_">SSRManifest</span> } <span class="hljs-keyword">from</span> <span class="hljs-string">&#x27;astro&#x27;</span>
<span class="hljs-keyword">import</span> { <span class="hljs-title class_">App</span> } <span class="hljs-keyword">from</span> <span class="hljs-string">&#x27;astro/app&#x27;</span>

<span class="hljs-keyword">import</span> { handle } <span class="hljs-keyword">from</span> <span class="hljs-string">&#x27;@astrojs/cloudflare/handler&#x27;</span>

<span class="hljs-keyword">type</span> <span class="hljs-title class_">Env</span> = {
  [<span class="hljs-attr">key</span>: <span class="hljs-built_in">string</span>]: <span class="hljs-built_in">unknown</span>
  <span class="hljs-attr">ASSETS</span>: {
    <span class="hljs-attr">fetch</span>: <span class="hljs-function">(<span class="hljs-params"><span class="hljs-attr">req</span>: <span class="hljs-title class_">Request</span> | <span class="hljs-built_in">string</span></span>) =&gt;</span> <span class="hljs-title class_">Promise</span>&lt;<span class="hljs-title class_">Response</span>&gt;
  }
}

<span class="hljs-keyword">export</span> <span class="hljs-keyword">function</span> <span class="hljs-title function_">createExports</span>(<span class="hljs-params"><span class="hljs-attr">manifest</span>: <span class="hljs-title class_">SSRManifest</span></span>) {
  <span class="hljs-keyword">const</span> app = <span class="hljs-keyword">new</span> <span class="hljs-title class_">App</span>(manifest)

  <span class="hljs-keyword">const</span> <span class="hljs-attr">fetch</span>: <span class="hljs-title class_">ExportedHandlerFetchHandler</span>&lt;<span class="hljs-title class_">Env</span>&gt; = <span class="hljs-title function_">async</span> (request, env, ctx) =&gt; {
    <span class="hljs-keyword">const</span> url = <span class="hljs-keyword">new</span> <span class="hljs-title function_">URL</span>(request.<span class="hljs-property">url</span>)
    <span class="hljs-keyword">const</span> { pathname, search } = url

    <span class="hljs-comment">// Do anything before Astro handles the request</span>

    <span class="hljs-keyword">const</span> response = <span class="hljs-keyword">await</span> <span class="hljs-title function_">handle</span>(manifest, app, request, env, ctx)

    <span class="hljs-comment">// Do anything after</span>

    <span class="hljs-keyword">return</span> response
  }

  <span class="hljs-keyword">return</span> {
    <span class="hljs-attr">default</span>: {
      fetch,
    } <span class="hljs-keyword">satisfies</span> <span class="hljs-title class_">ExportedHandler</span>&lt;<span class="hljs-title class_">Env</span>&gt;,
  }
}
</code></pre>
<h2 id="what-about-development" tabindex="-1"><a class="header-anchor" href="https://www.codejam.info/2026/02/astro-middleware-static-pages.html#what-about-development"><span>What about development?</span></a></h2>
<p>The <code>workerEntryPoint</code> is great for production, but <code>astro dev</code> won’t
pick up on that. So if you need any of this logic to <em>also</em> run in
development, you need to abstract it and also include it in
<code>middleware.ts</code>.</p>
<p>This works fine even for static pages because Astro do run the
middleware when generating static pages, it just outputs a warning if
you try to access things like request headers.</p>
<p>In my case, I chose to move all my production logic in <code>worker.ts</code>, so I
don’t rely on the middleware whatsoever in production. I use a
conditional export like follows in order to keep the middleware only in
development, where it mimics what <code>worker.ts</code> otherwise does in
production.</p>
<pre><code class="hljs language-ts"><span class="hljs-keyword">import</span> <span class="hljs-keyword">type</span> { <span class="hljs-title class_">MiddlewareHandler</span> } <span class="hljs-keyword">from</span> <span class="hljs-string">&#x27;astro&#x27;</span>

<span class="hljs-keyword">const</span> <span class="hljs-attr">handler</span>: <span class="hljs-title class_">MiddlewareHandler</span> = <span class="hljs-title function_">async</span> (context, next) =&gt; {
  <span class="hljs-comment">// ...</span>
}

<span class="hljs-keyword">export</span> <span class="hljs-keyword">const</span> onRequest = <span class="hljs-keyword">import</span>.<span class="hljs-property">meta</span>.<span class="hljs-property">env</span>.<span class="hljs-property">DEV</span> ? handler : <span class="hljs-literal">undefined</span>
</code></pre>
<h2 id="astro-v6-update" tabindex="-1"><a class="header-anchor" href="https://www.codejam.info/2026/02/astro-middleware-static-pages.html#astro-v6-update"><span>Astro v6 update</span></a></h2>
<p><a href="https://docs.astro.build/en/guides/integrations-guide/cloudflare/#upgrading-to-v13-and-astro-6">Astro v6 and <code>@astrojs/cloudflare</code> v13</a>
change a few things (for the best). <a href="https://www.codejam.info/2026/02/astro-middleware-static-pages.html#the-cloudflare-part">The Cloudflare part</a>
(<code>run_worker_first</code> in <code>wrangler.jsonc</code>) is unchanged.</p>
<p>The <code>workerEntryPoint</code> adapter option was removed. Instead, simpler, we
just point <code>main</code> in <code>wrangler.jsonc</code> directly to our custom worker
file:</p>
<pre><code class="hljs language-json"><span class="hljs-punctuation">{</span>
  <span class="hljs-attr">&quot;main&quot;</span><span class="hljs-punctuation">:</span> <span class="hljs-string">&quot;./src/worker.ts&quot;</span>
<span class="hljs-punctuation">}</span>
</code></pre>
<p>Inside there the syntax is slightly different since we’re in a raw
Cloudflare Worker environment, not wrapped by Astro.</p>
<pre><code class="hljs language-ts"><span class="hljs-keyword">import</span> { handle } <span class="hljs-keyword">from</span> <span class="hljs-string">&#x27;@astrojs/cloudflare/handler&#x27;</span>

<span class="hljs-keyword">type</span> <span class="hljs-title class_">Env</span> = {
  [<span class="hljs-attr">key</span>: <span class="hljs-built_in">string</span>]: <span class="hljs-built_in">unknown</span>
  <span class="hljs-attr">ASSETS</span>: {
    <span class="hljs-attr">fetch</span>: <span class="hljs-function">(<span class="hljs-params"><span class="hljs-attr">req</span>: <span class="hljs-title class_">Request</span> | <span class="hljs-built_in">string</span></span>) =&gt;</span> <span class="hljs-title class_">Promise</span>&lt;<span class="hljs-title class_">Response</span>&gt;
  }
}

<span class="hljs-keyword">export</span> <span class="hljs-keyword">default</span> {
  <span class="hljs-keyword">async</span> <span class="hljs-title function_">fetch</span>(<span class="hljs-params">request, env, ctx</span>) {
    <span class="hljs-keyword">const</span> url = <span class="hljs-keyword">new</span> <span class="hljs-title function_">URL</span>(request.<span class="hljs-property">url</span>)
    <span class="hljs-keyword">const</span> { pathname, search } = url

    <span class="hljs-comment">// Do anything before Astro handles the request</span>

    <span class="hljs-keyword">const</span> response = <span class="hljs-keyword">await</span> <span class="hljs-title function_">handle</span>(request, env, ctx)

    <span class="hljs-comment">// Do anything after</span>

    <span class="hljs-keyword">return</span> response
  },
} <span class="hljs-keyword">satisfies</span> <span class="hljs-title class_">ExportedHandler</span>&lt;<span class="hljs-title class_">Env</span>&gt;
</code></pre>
<p>As for development, <code>astro dev</code> now runs on Cloudflare’s <code>workerd</code> using
the same entrypoint as production, so no need for the <a href="https://www.codejam.info/2026/02/astro-middleware-static-pages.html#what-about-development">middleware workaround</a>
anymore. Sweet!</p>
<h2 id="wrapping-up" tabindex="-1"><a class="header-anchor" href="https://www.codejam.info/2026/02/astro-middleware-static-pages.html#wrapping-up"><span>Wrapping up</span></a></h2>
<p>In short: configure <code>run_worker_first</code> on Cloudflare so it runs the
worker in front of static pages, then use a custom <code>workerEntryPoint</code>
(Astro v5) or a custom <code>main</code> in <code>wrangler.jsonc</code> (Astro v6) so you get
full control over the worker, and can run code <em>outside of the
middleware</em> (which does not run for static pages).</p>
<section class="post-footer">
  <h3>Want to leave a comment?</h3>
  <p>
    Join the discussion on <a href="https://x.com/valeriangalliat/status/2021761871104430267">X</a> or send me an <a href="mailto:val@codejam.info">email</a>! 💌<br>
    This post helped you? <a href="https://ko-fi.com/funkyval">Buy me a coffee</a>! 🍻
  </p>
</section>
]]></content>
  </entry>
  <entry>
    <title>Cloudflare Workers choke on asdf .tool-versions</title>
    <link href="https://www.codejam.info/2026/02/cloudflare-workers-choke-asdf-tool-versions.html" />
    <id>https://www.codejam.info/2026/02/cloudflare-workers-choke-asdf-tool-versions.html</id>
    <updated>2026-02-10T08:00:00.000Z</updated>
    <content type="html"><![CDATA[<p>So you have a project you want to deploy to Cloudflare Workers, and you
happen to have a <code>.tool-versions</code> file to describe your dependencies,
even just a simple one like:</p>
<pre><code class="hljs">nodejs 24.13.1
</code></pre>
<p>Then your Cloudflare deploy fails with:</p>
<pre><code class="hljs">Initializing build environment...
Success: Finished initializing build environment
Cloning repository...
Found a .tool-versions file in repository root. Installing dependencies.
Restoring from dependencies cache
Restoring from build output cache
Failed: error occurred while installing tools or dependencies
</code></pre>
<p>There’s a
<a href="https://community.cloudflare.com/t/undocumented-tool-versions-support/855532">couple</a>
<a href="https://community.cloudflare.com/t/cloudflare-workers-deploy-crashes-on-tool-versions/871072">threads</a>
on
<a href="https://community.cloudflare.com/t/how-to-disable-automatic-installs-of-tool-versions-and-package-json/570450">Cloudflare</a>
<a href="https://community.cloudflare.com/t/support-pnpm-in-tool-versions/645235">Community</a>
about this and similar <code>.tool-versions</code> issues. They get closed after 15
days without any answer, with the oldest one from 2023 and still no
solution to this day.</p>
<p>The fact Cloudflare Workers (and Cloudflare Pages) look at
<code>.tool-versions</code> is undocumented, it so happens that it chokes on even
the most basic <code>.tool-versions</code> possible, so it essentially means the
mere presence of this file in your project will break your build on
Cloudflare, without any way to turn off this behavior (like forcing
Cloudflare to ignore that file). As reported in <a href="https://community.cloudflare.com/t/how-to-disable-automatic-installs-of-tool-versions-and-package-json/570450">this issue</a>,
the <code>SKIP_DEPENDENCY_INSTALL</code> environment variable does <em>not</em> help with
this behavior.</p>
<h2 id="the-solution" tabindex="-1"><a class="header-anchor" href="https://www.codejam.info/2026/02/cloudflare-workers-choke-asdf-tool-versions.html#the-solution"><span>The solution</span></a></h2>
<p>So what’s left? Well, I had to remove <code>.tool-versions</code> from my
repository.</p>
<p>Instead, I moved it to <code>.tool-versions.template</code>, and when setting up
the repo in any environment that <em>actually</em> supports <code>.tool-versions</code>, I
just <code>cp .tool-versions.template .tool-versions</code> (with <code>.tool-versions</code>
being in <code>.gitignore</code>).</p>
<h2 id="setting-the-correct-node-js-version-on-cloudflare" tabindex="-1"><a class="header-anchor" href="https://www.codejam.info/2026/02/cloudflare-workers-choke-asdf-tool-versions.html#setting-the-correct-node-js-version-on-cloudflare"><span>Setting the correct Node.js version on Cloudflare</span></a></h2>
<p>As for Cloudflare, in order to set the proper versions, I’m using the
<code>NODE_VERSION</code> and <code>PNPM_VERSION</code> build environment variables. (Also
<code>NPM_VERSION</code> and <code>YARN_VERSION</code> depending on your package manager of
choice).</p>
<pre><code class="hljs">NODE_VERSION=24.13.1
PNPM_VERSION=10.29.2
</code></pre>
<p>This is, of course, also undocumented, but it works!</p>
<section class="post-footer">
  <h3>Want to leave a comment?</h3>
  <p>
    Join the discussion on <a href="https://x.com/valeriangalliat/status/2021294413893574883">X</a> or send me an <a href="mailto:val@codejam.info">email</a>! 💌<br>
    This post helped you? <a href="https://ko-fi.com/funkyval">Buy me a coffee</a>! 🍻
  </p>
</section>
]]></content>
  </entry>
  <entry>
    <title>The next chapter</title>
    <link href="https://www.codejam.info/2026/01/the-next-chapter.html" />
    <id>https://www.codejam.info/2026/01/the-next-chapter.html</id>
    <updated>2026-01-25T08:00:00.000Z</updated>
    <content type="html"><![CDATA[<p>Just over two years since the <a href="https://www.codejam.info/2024/01/now.html">last update</a>. Time
flies, heh?</p>
<h2 id="squamish" tabindex="-1"><a class="header-anchor" href="https://www.codejam.info/2026/01/the-next-chapter.html#squamish"><span>Squamish</span></a></h2>
<p>I’m well settled in Squamish now. Two years already!</p>
<p>As an introvert and working remote, it was a bit of a slow start
rebuilding social circles from scratch. It really takes being
intentional about it. <a href="https://www.facebook.com/groups/southcoasttouring/">Facebook</a>
<a href="https://www.facebook.com/groups/1322245267842175/">groups</a> and the
<a href="https://getoak.app/">Oak app</a> are pretty sweet, and believe it or not,
talking to strangers <abbr title="In real life">IRL</abbr> also works!
The good thing is that the more people you know, the more people you
meet, so it gets pretty effortless over time.</p>
<figure class="grid grid-3 grid-min-2">
  <img alt="Shovelnose" title="Shovelnose" srcset="../../img/2026/01/now/touring/01.webp 2x" loading="lazy">
  <img alt="Decker" title="Decker" srcset="../../img/2026/01/now/touring/02.webp 2x" loading="lazy">
  <img alt="Snow pit" srcset="../../img/2026/01/now/touring/03.webp 2x" loading="lazy">
</figure>
<h2 id="mountains" tabindex="-1"><a class="header-anchor" href="https://www.codejam.info/2026/01/the-next-chapter.html#mountains"><span>Mountains 🏔️</span></a></h2>
<p>In 2025 I’ve been out in the mountains like crazy, and hit 130,000
meters of elevation gain all sports combined (ski touring, trail
running, gravel and mountain biking). It’s my biggest elevation gain
year lifetime, 30,000 meters more than 2024. 💪</p>
<p>That includes scrambling Sky Pilot, climbing Star Check (3 times lol),
Cloudburst, Goat Ridge scramble, Blackcomb Peak ridge scramble, a
marathon to Castle Towers, Gentian Peak and Panorama Ridge, Ossa
Mountain, Rainbow Mountain, the Armchair Traverse (Mount Cook and Mount
Weart), Locomotive Mountain and its loop scramble, Coliseum Mountain,
and finishing with the Howe Sound Crest Trail (with extra Harvey,
Brunswick and West Lion lol get rekt). What a year!</p>
<figure class="grid grid-3 grid-min-2">
  <img alt="Fissile Peak" title="Fissile Peak" srcset="../../img/2026/01/now/mountains/01.webp 2x" loading="lazy">
  <img alt="Whistler Peak" title="Whistler Peak" srcset="../../img/2026/01/now/mountains/02.webp 2x" loading="lazy">
  <img alt="Ledge Mountain" title="Ledge Mountain" srcset="../../img/2026/01/now/mountains/03.webp 2x" loading="lazy">
  <img alt="Decker Mountain" title="Decker Mountain" srcset="../../img/2026/01/now/mountains/04.webp 2x" loading="lazy">
  <img alt="A whisky jack at Crystal Hut" title="A whisky jack at Crystal Hut" srcset="../../img/2026/01/now/mountains/05.webp 2x" loading="lazy">
  <img alt="Mount Cayley view from Brandywine" title="Mount Cayley view from Brandywine" srcset="../../img/2026/01/now/mountains/06.webp 2x" loading="lazy">
  <img alt="Intergalactic (Diamond Head)" title="Intergalactic (Diamond Head)" srcset="../../img/2026/01/now/mountains/07.webp 2x" loading="lazy">
  <img alt="Intergalactic (Diamond Head)" title="Intergalactic (Diamond Head)" srcset="../../img/2026/01/now/mountains/08.webp 2x" loading="lazy">
  <img alt="Sky Pilot and Copilot" title="Sky Pilot and Copilot" srcset="../../img/2026/01/now/mountains/09.webp 2x" loading="lazy">
  <img alt="Castle Towers" title="Castle Towers" srcset="../../img/2026/01/now/mountains/10.webp 2x" loading="lazy">
  <img alt="Garibaldi view from Helm Glacier saddle" title="Garibaldi view from Helm Glacier saddle" srcset="../../img/2026/01/now/mountains/11.webp 2x" loading="lazy">
  <img alt="Castle Towers summit" title="Castle Towers summit" srcset="../../img/2026/01/now/mountains/12.webp 2x" loading="lazy">
  <img alt="Castle Towers view from the summit" title="Castle Towers view from the summit" srcset="../../img/2026/01/now/mountains/13.webp 2x" loading="lazy">
  <img alt="Panorama Ridge and Black Tusk view from Castle Towers" title="Panorama Ridge and Black Tusk view from Castle Towers" srcset="../../img/2026/01/now/mountains/14.webp 2x" loading="lazy">
  <img alt="Pelion view from Ossa Mountain" title="Pelion view from Ossa Mountain" srcset="../../img/2026/01/now/mountains/15.webp 2x" loading="lazy">
  <img alt="Garibaldi Lake" title="Garibaldi Lake" srcset="../../img/2026/01/now/mountains/16.webp 2x" loading="lazy">
  <img alt="Garibaldi Lake" title="Garibaldi Lake" srcset="../../img/2026/01/now/mountains/17.webp 2x" loading="lazy">
  <img alt="Mount Weart view from Armchair Traverse" title="Mount Weart view from Armchair Traverse" srcset="../../img/2026/01/now/mountains/18.webp 2x" loading="lazy">
  <img alt="Lynn Ridge" title="Lynn Ridge" srcset="../../img/2026/01/now/mountains/19.webp 2x" loading="lazy">
  <img alt="Brunswick Mountain" title="Brunswick Mountain" srcset="../../img/2026/01/now/mountains/20.webp 2x" loading="lazy">
  <img alt="Atwell view from Slhanay" title="Atwell view from Slhanay" srcset="../../img/2026/01/now/mountains/21.webp 2x" loading="lazy">
</figure>
<h2 id="about-part-time-work" tabindex="-1"><a class="header-anchor" href="https://www.codejam.info/2026/01/the-next-chapter.html#about-part-time-work"><span>About part-time work</span></a></h2>
<p>If you’re wondering how I go in the mountains that much, you need to
know I’ve been working part-time as a software contractor since 2021.
Going part-time was the best thing I’ve ever done in my career (other
than connecting a ping-pong table to the internet 🏓) and I’m incredibly
grateful to have worked with companies who gave me that opportunity.</p>
<p>Maybe I should write more about this because it’s still really uncommon
in the industry, but believe it or not, <strong>those years have also been the
most productive of my career</strong>.</p>
<p>It’s definitely a combination of factors. Despite working only 20 hours
a week:</p>
<ul>
<li>I’m naturally more experienced now than I was years ago.</li>
<li>I have been intentional about working with small companies (read: less
than 10 people) where I can have a sizable impact, without being
slowed down by corporate friction, processes and politics.</li>
<li><a href="https://en.wikipedia.org/wiki/Parkinson%27s_law">Parkinson’s law</a> is a thing (“work expands so as to fill the time available”).</li>
<li>And of course since a few years and increasingly, using AI.</li>
</ul>
<p>Still, this means that there’s a sweet spot for time-to-productivity
ratio, especially when it comes to creative work like building software,
and in many cases I’m not convinced that it’s at or above 40 hours.</p>
<figure class="grid grid-min-2 grid-fit">
  <img alt="Laptop with a view" title="Sea to Sky Gondola" srcset="../../img/2026/01/now/laptop/01.webp 2x" loading="lazy">
  <img alt="Laptop with a view" title="Sea to Sky Gondola" srcset="../../img/2026/01/now/laptop/02.webp 2x" loading="lazy">
</figure>
<h2 id="travel" tabindex="-1"><a class="header-anchor" href="https://www.codejam.info/2026/01/the-next-chapter.html#travel"><span>Travel ✈️</span></a></h2>
<h3 id="mexico" tabindex="-1"><a class="header-anchor" href="https://www.codejam.info/2026/01/the-next-chapter.html#mexico"><span>Mexico 🇲🇽</span></a></h3>
<p>Went to Mexico for a work retreat.</p>
<figure class="grid grid-3 grid-min-2">
  <img alt="Tenochtitlan" title="Tenochtitlan" srcset="../../img/2026/01/now/mexico/01.webp 2x" loading="lazy">
  <img alt="Tenochtitlan" title="Tenochtitlan" srcset="../../img/2026/01/now/mexico/02.webp 2x" loading="lazy">
  <img alt="Turkish coffee (don’t ask)" title="Turkish coffee (don’t ask)" srcset="../../img/2026/01/now/mexico/03.webp 2x" loading="lazy">
  <img alt="Business meeting" title="Business meeting" srcset="../../img/2026/01/now/mexico/04.webp 2x" loading="lazy">
  <img alt="Mexico City" title="Mexico City" srcset="../../img/2026/01/now/mexico/05.webp 2x" loading="lazy">
  <img alt="Metropolitan Cathedral, view from an overpriced rooftop" title="Metropolitan Cathedral, view from an overpriced rooftop" srcset="../../img/2026/01/now/mexico/06.webp 2x" loading="lazy">
</figure>
<h3 id="montreal" tabindex="-1"><a class="header-anchor" href="https://www.codejam.info/2026/01/the-next-chapter.html#montreal"><span>Montreal</span></a></h3>
<p>Did a pit stop by Montreal to catch up with friends and the 🔥 food
scene there.</p>
<figure class="grid grid-3 grid-min-2">
  <img alt="BBQ" srcset="../../img/2026/01/now/montreal/01.webp 2x" loading="lazy">
  <img alt="La Belle Tacos" title="La Belle Tacos" srcset="../../img/2026/01/now/montreal/02.webp 2x" loading="lazy">
  <img alt="Döner Istanbul" title="Döner Istanbul" srcset="../../img/2026/01/now/montreal/03.webp 2x" loading="lazy">
</figure>
<h3 id="toulouse" tabindex="-1"><a class="header-anchor" href="https://www.codejam.info/2026/01/the-next-chapter.html#toulouse"><span>Toulouse</span></a></h3>
<p>Did a quick pit stop in Toulouse, which I visited for the first time,
since my flight landed there.</p>
<figure class="grid grid-3 grid-min-2">
  <img alt="Toulouse street" srcset="../../img/2026/01/now/toulouse/01.webp 2x" loading="lazy">
  <img alt="Toulouse Ferris wheel" srcset="../../img/2026/01/now/toulouse/02.webp 2x" loading="lazy">
  <img alt="Place du Capitole" title="Place du Capitole" srcset="../../img/2026/01/now/toulouse/03.webp 2x" loading="lazy">
</figure>
<h3 id="andorra" tabindex="-1"><a class="header-anchor" href="https://www.codejam.info/2026/01/the-next-chapter.html#andorra"><span>Andorra 🇦🇩</span></a></h3>
<p>Visited friends in Andorra, also first time for me there. There’s some
sick mountain biking and trail running. Lots of ridges to scramble,
everywhere, and all the mountains are connected by trails, so it feels
like there’s really no limits to the terrain!</p>
<figure class="grid grid-3 grid-min-2">
  <img alt="Coma Pedrosa" title="Coma Pedrosa" srcset="../../img/2026/01/now/andorra/01.webp 2x" loading="lazy">
  <img alt="Vall de Sorteny" title="Vall de Sorteny" srcset="../../img/2026/01/now/andorra/02.webp 2x" loading="lazy">
  <img alt="Arcalís" title="Arcalís" srcset="../../img/2026/01/now/andorra/03.webp 2x" loading="lazy">
  <img alt="Arcalís" title="Arcalís" srcset="../../img/2026/01/now/andorra/04.webp 2x" loading="lazy">
  <img alt="Arcalís" title="Arcalís" srcset="../../img/2026/01/now/andorra/05.webp 2x" loading="lazy">
  <img alt="Coma Pedrosa" title="Coma Pedrosa" srcset="../../img/2026/01/now/andorra/06.webp 2x" loading="lazy">
  <img alt="Font Blanca" title="Font Blanca" srcset="../../img/2026/01/now/andorra/07.webp 2x" loading="lazy">
  <img alt="Hippa!" title="Hippa!" srcset="../../img/2026/01/now/andorra/08.webp 2x" loading="lazy">
  <img alt="Arinsal bike park" title="Arinsal bike park" srcset="../../img/2026/01/now/andorra/09.webp 2x" loading="lazy">
</figure>
<h3 id="camargue" tabindex="-1"><a class="header-anchor" href="https://www.codejam.info/2026/01/the-next-chapter.html#camargue"><span>Camargue 🦩</span></a></h3>
<p>Another pit stop at Saintes-Maries-de-la-Mer to cut a long drive in
half. Also first time here, very pretty!</p>
<figure class="grid grid-3 grid-min-2">
  <img alt="Beach" srcset="../../img/2026/01/now/camargue/01.webp 2x" loading="lazy">
  <img alt="Notre Dame de la Mer" title="Notre Dame de la Mer" srcset="../../img/2026/01/now/camargue/02.webp 2x" loading="lazy">
  <img alt="Flamingos!" title="Flamingos!" srcset="../../img/2026/01/now/camargue/03.webp 2x" loading="lazy">
</figure>
<h3 id="metz-and-nancy" tabindex="-1"><a class="header-anchor" href="https://www.codejam.info/2026/01/the-next-chapter.html#metz-and-nancy"><span>Metz &amp; Nancy</span></a></h3>
<p>Finally some family time around Metz and Nancy.</p>
<figure class="grid grid-3 grid-min-2">
  <img alt="Metz cathedral" title="Metz cathedral" srcset="../../img/2026/01/now/metz-1.webp 2x" loading="lazy">
  <img alt="Place Stanislas" title="Place Stanislas" srcset="../../img/2026/01/now/nancy-1.webp 2x" loading="lazy">
  <img alt="Nancy cathedral" title="Nancy cathedral" srcset="../../img/2026/01/now/nancy-2.webp 2x" loading="lazy">
</figure>
<h3 id="cakes" tabindex="-1"><a class="header-anchor" href="https://www.codejam.info/2026/01/the-next-chapter.html#cakes"><span>Cakes 🍰</span></a></h3>
<p>I also happened to turn 30 on that trip. While I don’t believe the
specific number <em>means</em> much at all, I still see no reason to not treat
myself to 3 different cakes. 😜</p>
<figure class="grid grid-3 grid-min-2">
  <img alt="Cake" srcset="../../img/2026/01/now/cake/01.webp 2x" loading="lazy">
  <img alt="Cake" srcset="../../img/2026/01/now/cake/02.webp 2x" loading="lazy">
  <img alt="Cake" srcset="../../img/2026/01/now/cake/03.webp 2x" loading="lazy">
</figure>
<h3 id="nova-scotia" tabindex="-1"><a class="header-anchor" href="https://www.codejam.info/2026/01/the-next-chapter.html#nova-scotia"><span>Nova Scotia 🦀</span></a></h3>
<p>I bundled all the above destinations together in July. Nova Scotia was a
separate trip.</p>
<p>A good friend of mine was getting married there, and I got to stand at
his wedding. Took that as an opportunity to go explore around the Cape
Breton and particularly ride the Cabot Trail on my friend’s road bike
that I borrowed. Around 300 km and 3,000 meters of elevation that I
split in two days. It was beautiful.</p>
<figure class="grid grid-3 grid-min-2">
  <img alt="Cape Smokey" title="Cape Smokey" srcset="../../img/2026/01/now/nova-scotia/01.webp 2x" loading="lazy">
  <img alt="Pleasant Bay" title="Pleasant Bay" srcset="../../img/2026/01/now/nova-scotia/02.webp 2x" loading="lazy">
  <img alt="Chéticamp" title="Chéticamp" srcset="../../img/2026/01/now/nova-scotia/03.webp 2x" loading="lazy">
</figure>
<h2 id="shortest-move-ever" tabindex="-1"><a class="header-anchor" href="https://www.codejam.info/2026/01/the-next-chapter.html#shortest-move-ever"><span>Shortest move ever</span></a></h2>
<p>At the end of the summer, my landlord sold the place I was renting, and
I had to move. I got lucky and found a place in the same building just
across the floor the same day I got the notice. Still can’t believe this
happened. 🙏</p>
<p>This made for a pretty easy and quite ridiculous move, with me dragging
my stuff across the hallway.</p>
<figure class="grid grid-min-2 grid-fit">
  <img alt="Moving" srcset="../../img/2026/01/now/move/01.webp 2x" loading="lazy">
  <img alt="Moving" srcset="../../img/2026/01/now/move/02.webp 2x" loading="lazy">
</figure>
<h2 id="the-big-jump" tabindex="-1"><a class="header-anchor" href="https://www.codejam.info/2026/01/the-next-chapter.html#the-big-jump"><span>The big jump 🏃</span></a></h2>
<p>Remember how I was writing about part-time work earlier? Well all good
things have an end, and this was not an option anymore at the company I
had been working until then.</p>
<p>Going full-time right before ski season didn’t feel exactly right, and
it really felt like a sign for me to 1. take some proper time off and 2.
get out of my comfort zone and try something a bit scarier.</p>
<p>The time off consisted of a fuckton of climbing, then skiing. Being in
BC, we eventually got a few of the usual warm storms where it rains all
the way to the top of mountains and destroy the skiing conditions until
it snows again. <strong>This gave me some solid windows to start building
again.</strong></p>
<p>We got together with my friend <a href="https://www.benoitzohar.com/">Ben</a> and
started <a href="https://evetools.app/">EveTools</a>, where we make apps for
developers.</p>
<p>The first app is <a href="https://useflame.app/">Flame</a>, and its goal is
to remove as much friction as possible from the Firebase local
development experience, especially when it comes to dealing with the
<a href="https://firebase.google.com/docs/emulator-suite">emulators</a>.</p>
<p>We’ve been working with Firebase quite extensively for over 3 years now,
so we have a pretty good understanding of the pain points of the current
experience. There’s a lot to do, and I hope we can have a positive
impact on that platform.</p>
<figure class="center">
  <img alt="Flame" srcset="../../img/2026/01/now/flame.webp 2x" loading="lazy">
</figure>
<p>At the time of writing it’s in private beta, but we’re planning on
opening it up pretty soon! If you find about Flame via this post,
<a href="https://www.codejam.info/val.html#contact">shoot me an email</a> and I’ll send you a discount! 🫶</p>
<hr>
<p>Two interesting realizations since this happened.</p>
<h3 id="buildingception" tabindex="-1"><a class="header-anchor" href="https://www.codejam.info/2026/01/the-next-chapter.html#buildingception"><span>Buildingception</span></a></h3>
<p>First, building a product, and doing so end to end (read: not just the
tech), reveals <em>a lot</em> more pain points and problems to be solved. Some
specific to the stack we work with, and some broader ones too. And the
good news is, a lot of them can be solved with more tech!</p>
<p>In other words, the more I build, the more ideas I get about what to
build next.</p>
<p>Granted it’s not the best way to source ideas if you’re optimizing for
maximum revenue, but it seems to be doing a decent job at optimizing for
maximum motivation, and I do enjoy that for the time being.</p>
<h3 id="so-much-for-part-time" tabindex="-1"><a class="header-anchor" href="https://www.codejam.info/2026/01/the-next-chapter.html#so-much-for-part-time"><span>So much for part-time</span></a></h3>
<p>Second, is that despite advocating for the benefits of part-time work
for the past 4 years, including higher up in this article, I’m actually
having a <em>really</em> good time working every single day right now, weekends
included. <strong>Working on your own thing does hit different.</strong></p>
<p>I hope my fellow coastal BC skiers don’t read this, but I’m lowkey
grateful for this rain-into-dry-spell event a few weeks ago, so that I
feel no guilt and <abbr title="Fear of missing out">FOMO</abbr>
to be so much on my laptop.</p>
<p>Ultimately, I still believe in part-time work, and in fact, being an
independent developer is my current best bet on building that lifestyle
back one day or another. But it also seems like a futile dream until I
generate any sustainable income.</p>
<p>Let’s see where the snow takes me…</p>
<h2 id="on-coffee" tabindex="-1"><a class="header-anchor" href="https://www.codejam.info/2026/01/the-next-chapter.html#on-coffee"><span>On coffee ☕️</span></a></h2>
<p>I’m back to drinking caffeine, after years of being on decaf. 🤷‍♀️</p>
<p>Seems to pair well with the business hustling for now. We’ll see if I
build up a tolerance again. If it stops being a net positive I’ll
readjust.</p>
<p>And weirdly, after being into specialty coffee for years (read: small
batch micro roaster beans I weigh and grind before brewing), I’m back
with my moka pot and sous vide Italian pre-ground. Who would have
thought?</p>
<figure class="center">
  <img alt="Moka Pot with Italian coffee" srcset="../../img/2026/01/now/coffee.webp 2x" loading="lazy">
</figure>
<h2 id="a-few-words-on-2024" tabindex="-1"><a class="header-anchor" href="https://www.codejam.info/2026/01/the-next-chapter.html#a-few-words-on-2024"><span>A few words on 2024</span></a></h2>
<p>I know, I know, I skipped the routine update last year. To be fair there
was no important update to write about, which is probably why I didn’t
feel the need to write. Still it included many cool adventures and trips
that I might as well slide in this post.</p>
<p>First there was the northern lights. Took the camera out for the
occasion.</p>
<div class="oversized">
  <figure class="grid grid-2">
    <img alt="Northern lights" srcset="../../img/2026/01/now/northern-lights/01.webp 2x" loading="lazy">
    <img alt="Northern lights" srcset="../../img/2026/01/now/northern-lights/02.webp 2x" loading="lazy">
  </figure>
</div>
<p>Of course, the usual local mountain adventures. Including skiing
Panorama Ridge, scrambling West Lion and Black Tusk, Mount Currie,
running a 60K to Diamond Head and Mamquam Lake, Sigurd Peak, biking the
Triple Crown (Cypress, Grouse and Seymour), and finishing with a
marathon to Overlord Mountain!</p>
<figure class="grid grid-min-2">
  <img style="--ratio: calc(720 / 2000)" alt="Black Tusk" title="Black Tusk" srcset="../../img/2026/01/now/mountains-2024/02.webp 2x" loading="lazy">
  <img style="--ratio: calc(1280 / 2000)" alt="Garibaldi Lake" title="Garibaldi Lake" srcset="../../img/2026/01/now/mountains-2024/01.webp 2x" loading="lazy">
  <img style="--ratio: calc(1280 / 2000)" alt="Watersprite Lake" title="Watersprite Lake" srcset="../../img/2026/01/now/mountains-2024/03.webp 2x" loading="lazy">
  <img style="--ratio: calc(720 / 2000)" alt="Anderson Lake" title="Anderson Lake" srcset="../../img/2026/01/now/mountains-2024/04.webp 2x" loading="lazy">
  <img style="--ratio: calc(720 / 2000)" alt="Summit of West Lion" title="Summit of West Lion" srcset="../../img/2026/01/now/mountains-2024/05.webp 2x" loading="lazy">
  <img style="--ratio: calc(1280 / 2000)" alt="Panorama Ridge and Garibaldi Lake view from Black Tusk" title="Panorama Ridge and Garibaldi Lake view from Black Tusk" srcset="../../img/2026/01/now/mountains-2024/06.webp 2x" loading="lazy">
  <img style="--ratio: calc(1280 / 2000)" alt="Black Tusk view from Panorama Ridge" title="Black Tusk view from Panorama Ridge" srcset="../../img/2026/01/now/mountains-2024/07.webp 2x" loading="lazy">
  <img style="--ratio: calc(720 / 2000)" alt="Garibaldi Lake" title="Garibaldi Lake" srcset="../../img/2026/01/now/mountains-2024/08.webp 2x" loading="lazy">
  <img style="--ratio: calc(720 / 2000)" alt="Garibaldi cluster and glacier" title="Garibaldi cluster and glacier" srcset="../../img/2026/01/now/mountains-2024/09.webp 2x" loading="lazy">
  <img style="--ratio: calc(1280 / 2000)" alt="Castle Towers view from Musical Bumps" title="Castle Towers view from Musical Bumps" srcset="../../img/2026/01/now/mountains-2024/10.webp 2x" loading="lazy">
</figure>
<p>Went to Jackson Hole for a work retreat, and took some extra time off
there to explore the mountains and do a lot of road and gravel biking,
including riding around Yellowstone, with a colleague who’s an absolute
triathlon machine. Those few weeks are probably the most volume I’ve
done in a continuous stretch to this day, although the Andorra trip from
last year was a close contender.</p>
<figure class="grid grid-min-2">
  <img style="--ratio: calc(1280 / 2000)" alt="The Tetons view from Jackson Lake" title="The Tetons view from Jackson Lake" srcset="../../img/2026/01/now/wyoming/01.webp 2x" loading="lazy">
  <img style="--ratio: calc(720 / 2000)" alt="The Tetons view from Cascade Canyon" title="The Tetons view from Cascade Canyon" srcset="../../img/2026/01/now/wyoming/02.webp 2x" loading="lazy">
  <img style="--ratio: calc(720 / 2000)" alt="4Runner in front of the Tetons" title="4Runner in front of the Tetons" srcset="../../img/2026/01/now/wyoming/04.webp 2x" loading="lazy">
  <img style="--ratio: calc(1280 / 2000)" alt="The Tetons view from Cascade Canyon" title="The Tetons view from Cascade Canyon" srcset="../../img/2026/01/now/wyoming/03.webp 2x" loading="lazy">
  <img style="--ratio: calc(1280 / 2000)" alt="Yellowstone" title="Yellowstone" srcset="../../img/2026/01/now/wyoming/05.webp 2x" loading="lazy">
  <img style="--ratio: calc(720 / 2000)" alt="Yellowstone" title="Yellowstone" srcset="../../img/2026/01/now/wyoming/06.webp 2x" loading="lazy">
  <img style="--ratio: calc(720 / 2000)" alt="Grand Teton view from the saddle" title="Grand Teton view from the saddle" srcset="../../img/2026/01/now/wyoming/08.webp 2x" loading="lazy">
  <img style="--ratio: calc(1280 / 2000)" alt="Middle Teton view from the Grand Teton approach" title="Middle Teton view from the Grand Teton approach" srcset="../../img/2026/01/now/wyoming/07.webp 2x" loading="lazy">
  <img style="--ratio: calc(1280 / 2000)" alt="The Tetons view from Cascade Canyon" title="The Tetons view from Cascade Canyon" srcset="../../img/2026/01/now/wyoming/09.webp 2x" loading="lazy">
  <img style="--ratio: calc(720 / 2000)" alt="Icefloe Lake" title="Icefloe Lake" srcset="../../img/2026/01/now/wyoming/10.webp 2x" loading="lazy">
</figure>
<p>Also went sailing in Majorca with a few friends!</p>
<figure class="grid grid-3 grid-min-2">
  <img alt="Puig de Massanella" title="Puig de Massanella" srcset="../../img/2026/01/now/majorca/01.webp 2x" loading="lazy">
  <img alt="Sant Elm" title="Sant Elm" srcset="../../img/2026/01/now/majorca/02.webp 2x" loading="lazy">
  <img alt="Sant Elm" title="Sant Elm" srcset="../../img/2026/01/now/majorca/03.webp 2x" loading="lazy">
  <img alt="San Miguel 0.0% on a sailboat" srcset="../../img/2026/01/now/majorca/04.webp 2x" loading="lazy">
  <img alt="Cabrera" title="Cabrera" srcset="../../img/2026/01/now/majorca/05.webp 2x" loading="lazy">
  <img alt="Cabrera" title="Cabrera" srcset="../../img/2026/01/now/majorca/06.webp 2x" loading="lazy">
  <img alt="Cabrera" title="Cabrera" srcset="../../img/2026/01/now/majorca/07.webp 2x" loading="lazy">
  <img alt="Cabrera" title="Cabrera" srcset="../../img/2026/01/now/majorca/08.webp 2x" loading="lazy">
  <img alt="Cala Figuera" title="Cala Figuera" srcset="../../img/2026/01/now/majorca/09.webp 2x" loading="lazy">
  <img alt="Cala Figuera" title="Cala Figuera" srcset="../../img/2026/01/now/majorca/10.webp 2x" loading="lazy">
  <img alt="Sunset on a sailboat" srcset="../../img/2026/01/now/majorca/11.webp 2x" loading="lazy">
  <img alt="Montgrí Castle" title="Montgrí Castle" srcset="../../img/2026/01/now/majorca/12.webp 2x" loading="lazy">
</figure>
<p>And finished by some trail running in the Vosges.</p>
<figure class="grid grid-3 grid-min-2">
  <img alt="Moss on a log" srcset="../../img/2026/01/now/vosges/01.webp 2x" loading="lazy">
  <img alt="Hohneck" title="Hohneck" srcset="../../img/2026/01/now/vosges/02.webp 2x" loading="lazy">
  <img alt="Schiessrothried Lake" title="Schiessrothried Lake" srcset="../../img/2026/01/now/vosges/03.webp 2x" loading="lazy">
  <img alt="Col du Schaeferthal" title="Col du Schaeferthal" srcset="../../img/2026/01/now/vosges/04.webp 2x" loading="lazy">
  <img alt="Croziflette" title="Croziflette" srcset="../../img/2026/01/now/vosges/05.webp 2x" loading="lazy">
  <img alt="Grand Ballon" title="Grand Ballon" srcset="../../img/2026/01/now/vosges/06.webp 2x" loading="lazy">
</figure>
<h2 id="on-reflecting" tabindex="-1"><a class="header-anchor" href="https://www.codejam.info/2026/01/the-next-chapter.html#on-reflecting"><span>On reflecting</span></a></h2>
<p>Putting together all those photos is pretty time consuming, but honestly
it feels valuable to take that time to reflect on the past and really
appreciate everything that I experienced.</p>
<p>It’s easy to get caught up in the fast paced train of life and never
take a step back to contemplate the big picture. Those “now page”
updates help me do exactly that.</p>
]]></content>
  </entry>
  <entry>
    <title>macOS app showing scrollbars to some users only</title>
    <link href="https://www.codejam.info/2025/06/macos-app-scrollbars-some-users-only.html" />
    <id>https://www.codejam.info/2025/06/macos-app-scrollbars-some-users-only.html</id>
    <updated>2025-06-25T07:00:00.000Z</updated>
    <content type="html"><![CDATA[<h2 id="tldr" tabindex="-1"><a class="header-anchor" href="https://www.codejam.info/2025/06/macos-app-scrollbars-some-users-only.html#tldr"><span>TLDR</span></a></h2>
<p>macOS has a setting where it shows fixed scrollbars to people using a
mouse, while people using a trackpad have tiny “floating” scrollbars
that are entirely hidden when not actively scrolling.</p>
<p>You can find it in <strong>Appearance &gt; Show scroll bars</strong> and either force it
to always on or always off (or keep it auto).</p>
<h2 id="story-time" tabindex="-1"><a class="header-anchor" href="https://www.codejam.info/2025/06/macos-app-scrollbars-some-users-only.html#story-time"><span>Story time</span></a></h2>
<p>While developing a macOS app (in my case with Electron, although this
problem is not specific to Electron), I encountered a wild bug.</p>
<p>Some users reported having scrollbars visible in different parts of the
app, and looking quite “off” visually:</p>
<figure class="center">
  <img alt="Scrollbars" srcset="../../img/2025/06/macos-scrollbars/scrollbars.png 2x">
</figure>
<p>However on my side it looked just fine:</p>
<figure class="center">
  <img alt="No scrollbars" srcset="../../img/2025/06/macos-scrollbars/no-scrollbars.png 2x">
</figure>
<p>I could not understand what was causing this. After all, it was
happening across the same version of Electron, Chromium, of the app
itself, and of macOS!</p>
<p>It’s only when another developer reported having the bug that I jumped
on a call with him to debug the issue.</p>
<p>While messing with the dev tools, one thing jumped out to me: inside his
dev tools, scrollbars were visible at all times:</p>
<figure class="center">
  <img alt="Dev tools scrollbars" srcset="../../img/2025/06/macos-scrollbars/devtools-scrollbars.png 2x">
</figure>
<p>While on my side I only had floating scrollbars while I was scrolling,
and they would disappear entirely otherwise:</p>
<figure class="center">
  <img alt="Dev tools floating scrollbars" srcset="../../img/2025/06/macos-scrollbars/devtools-floating.png 2x">
</figure>
<p>Same version of macOS as well!</p>
<p>What was going on?</p>
<p><strong>It turns out the culprit was that my colleague was using a mouse,
while I was using the trackpad of my laptop.</strong></p>
<p>What the mouse vs. trackpad have to do with this? Meet this setting:</p>
<figure class="center">
  <img alt="macOS scrollbars settings" srcset="../../img/2025/06/macos-scrollbars/macos-settings.png 2x">
</figure>
<p>By default, macOS shows scrollbars at all times when using a mouse, but
uses floating scrollbars when using a trackpad!</p>
<p>Now I could force this setting to “Always” to reproduce the issue
locally, and update the app to look OK even for people who use a mouse. 🙃</p>
<p>In many places, we had unnecessary <code>overflow: scroll</code> in places where
<code>overflow: auto</code> would do, and a few places where we had to hide
scrollbars entirely with <code>overflow: hidden</code>.</p>
<p>It was easy to make those silly mistakes when all the devs working on
the app were using a trackpad. 😂</p>
<h2 id="bonus-dark-mode-scrollbars" tabindex="-1"><a class="header-anchor" href="https://www.codejam.info/2025/06/macos-app-scrollbars-some-users-only.html#bonus-dark-mode-scrollbars"><span>Bonus: dark mode scrollbars</span></a></h2>
<p>For the places where we did want scrollbars, there was a remaining
issue: we force our app in dark mode, but the scrollbars were showing in
light mode nevertheless!</p>
<p>The way we force our Electron app in dark mode is by doing:</p>
<pre><code class="hljs language-js"><span class="hljs-keyword">import</span> { nativeTheme } <span class="hljs-keyword">from</span> <span class="hljs-string">&#x27;electron&#x27;</span>

nativeTheme.<span class="hljs-property">themeSource</span> = <span class="hljs-string">&#x27;dark&#x27;</span>
</code></pre>
<p>However in order for native scrollbars to show in dark mode, we also had
to add the following to our HTML file:</p>
<pre><code class="hljs language-html"><span class="hljs-tag">&lt;<span class="hljs-name">body</span> <span class="hljs-attr">style</span>=<span class="hljs-string">&quot;color-scheme: dark&quot;</span>&gt;</span><span class="hljs-tag">&lt;/<span class="hljs-name">body</span>&gt;</span>
</code></pre>
<p>(This is not the place where we actually needed scrollbars, but for lack
of a better screenshot, here’s what it would look like with both
directions scrollbars:)</p>
<figure class="center">
  <img alt="Scrollbars dark mode" srcset="../../img/2025/06/macos-scrollbars/scrollbars-dark.png 2x">
</figure>
<section class="post-footer">
  <h3>Want to leave a comment?</h3>
  <p>
    Join the discussion on <a href="https://x.com/valeriangalliat/status/1938046832753750198">X</a> or send me an <a href="mailto:val@codejam.info">email</a>! 💌<br>
    This post helped you? <a href="https://ko-fi.com/funkyval">Buy me a coffee</a>! 🍻
  </p>
</section>
]]></content>
  </entry>
  <entry>
    <title>GitHub Action hanging on Electron macOS app code signing</title>
    <link href="https://www.codejam.info/2025/06/github-action-hanging-macos-app-code-signing.html" />
    <id>https://www.codejam.info/2025/06/github-action-hanging-macos-app-code-signing.html</id>
    <updated>2025-06-22T07:00:00.000Z</updated>
    <content type="html"><![CDATA[<p>There’s quite a bunch of resources about how to set up macOS app code
signing on GitHub Actions:</p>
<ul>
<li><a href="https://dev.to/rwwagner90/signing-electron-apps-with-github-actions-4cof">Signing Electron apps with GitHub Actions</a></li>
<li><a href="https://docs.github.com/en/actions/use-cases-and-examples/deploying/installing-an-apple-certificate-on-macos-runners-for-xcode-development">Installing an Apple certificate on macOS runners for Xcode development</a></li>
<li><a href="https://brunoscheufler.com/blog/2023-11-12-setting-up-hosted-macos-github-actions-workflows-for-electron-builds">Setting up hosted macOS GitHub Actions workflows for Electron
builds</a></li>
</ul>
<p>But what to do if your GitHub workflow just hangs forever at the
Electron signing step?</p>
<p>This appears to be a symptom of not properly configuring the macOS
Keychain in the CI environment. Sadly there could be a bunch of reasons
for that and the hanging doesn’t really tell us which one specifically
is a problem.</p>
<p>There’s <a href="https://github.com/electron/forge/issues/3315">two</a>
<a href="https://github.com/electron/packager/issues/701">issues</a> I found with
that hanging problem, and the solutions there may or may not solve the
problem for you.</p>
<p>What I’m gonna suggest though is really double checking the code you’re
using to import the signing keys based on all the links I shared above,
and see if you’re missing something.</p>
<p>If it can help, here’s the code that ended up working for me:</p>
<pre><code class="hljs language-yaml"><span class="hljs-attr">name:</span> <span class="hljs-string">Release</span>

<span class="hljs-attr">on:</span>
  <span class="hljs-attr">push:</span>
    <span class="hljs-attr">branches:</span>
      <span class="hljs-bullet">-</span> <span class="hljs-string">main</span>

<span class="hljs-attr">jobs:</span>
  <span class="hljs-attr">release:</span>
    <span class="hljs-attr">runs-on:</span> <span class="hljs-string">macos-latest</span>

    <span class="hljs-attr">steps:</span>
      <span class="hljs-bullet">-</span> <span class="hljs-attr">uses:</span> <span class="hljs-string">actions/checkout@v4</span>
      <span class="hljs-bullet">-</span> <span class="hljs-attr">uses:</span> <span class="hljs-string">actions/setup-node@v4</span>
      <span class="hljs-bullet">-</span> <span class="hljs-attr">uses:</span> <span class="hljs-string">swift-actions/setup-swift@v2</span>

      <span class="hljs-bullet">-</span> <span class="hljs-attr">name:</span> <span class="hljs-string">Import</span> <span class="hljs-string">certificate</span>
        <span class="hljs-attr">env:</span>
          <span class="hljs-attr">PRIVATE_KEY:</span> <span class="hljs-string">${{</span> <span class="hljs-string">secrets.PRIVATE_KEY</span> <span class="hljs-string">}}</span>
          <span class="hljs-attr">PRIVATE_KEY_PASSWORD:</span> <span class="hljs-string">${{</span> <span class="hljs-string">secrets.PRIVATE_KEY_PASSWORD</span> <span class="hljs-string">}}</span>
          <span class="hljs-attr">CERTIFICATE:</span> <span class="hljs-string">${{</span> <span class="hljs-string">secrets.CERTIFICATE</span> <span class="hljs-string">}}</span>
        <span class="hljs-attr">run:</span> <span class="hljs-string">|
          # Create a temporary keychain
          security create-keychain -p &quot;&quot; build.keychain
</span>
          <span class="hljs-comment"># Set it as default for the user session</span>
          <span class="hljs-string">security</span> <span class="hljs-string">default-keychain</span> <span class="hljs-string">-s</span> <span class="hljs-string">build.keychain</span>

          <span class="hljs-string">security</span> <span class="hljs-string">unlock-keychain</span> <span class="hljs-string">-p</span> <span class="hljs-string">&quot;&quot;</span> <span class="hljs-string">build.keychain</span>

          <span class="hljs-comment"># Set it to lock in 1 hour (should be long enough, and probs longer than the macOS default that could be too short)</span>
          <span class="hljs-string">security</span> <span class="hljs-string">set-keychain-settings</span> <span class="hljs-string">-t</span> <span class="hljs-number">3600</span> <span class="hljs-string">-l</span> <span class="hljs-string">~/Library/Keychains/build.keychain</span>

          <span class="hljs-string">mkdir</span> <span class="hljs-string">-p</span> <span class="hljs-string">~/certificates</span>
          <span class="hljs-string">cd</span> <span class="hljs-string">~/certificates</span>

          <span class="hljs-string">echo</span> <span class="hljs-string">&quot;$PRIVATE_KEY&quot;</span> <span class="hljs-string">|</span> <span class="hljs-string">base64</span> <span class="hljs-string">--decode</span> <span class="hljs-string">&gt;</span> <span class="hljs-string">&quot;My App.p12&quot;</span>
          <span class="hljs-string">echo</span> <span class="hljs-string">&quot;CERTIFICATE&quot;</span> <span class="hljs-string">|</span> <span class="hljs-string">base64</span> <span class="hljs-string">--decode</span> <span class="hljs-string">&gt;</span> <span class="hljs-string">&quot;My App.cer&quot;</span>

          <span class="hljs-string">security</span> <span class="hljs-string">import</span> <span class="hljs-string">&quot;My App.p12&quot;</span> <span class="hljs-string">-k</span> <span class="hljs-string">build.keychain</span> <span class="hljs-string">-P</span> <span class="hljs-string">&quot;$PRIVATE_KEY_PASSWORD&quot;</span> <span class="hljs-string">-T</span> <span class="hljs-string">/usr/bin/codesign</span>
          <span class="hljs-string">security</span> <span class="hljs-string">import</span> <span class="hljs-string">&quot;My App.cer&quot;</span> <span class="hljs-string">-k</span> <span class="hljs-string">build.keychain</span> <span class="hljs-string">-T</span> <span class="hljs-string">/usr/bin/codesign</span>

          <span class="hljs-comment"># Check certificates</span>
          <span class="hljs-string">security</span> <span class="hljs-string">find-identity</span> <span class="hljs-string">-v</span> <span class="hljs-string">-p</span> <span class="hljs-string">codesigning</span> <span class="hljs-string">build.keychain</span>

          <span class="hljs-comment"># Add keychain to search list</span>
          <span class="hljs-string">security</span> <span class="hljs-string">list-keychains</span> <span class="hljs-string">-d</span> <span class="hljs-string">user</span> <span class="hljs-string">-s</span> <span class="hljs-string">build.keychain</span>
          <span class="hljs-string">security</span> <span class="hljs-string">set-key-partition-list</span> <span class="hljs-string">-S</span> <span class="hljs-string">apple-tool:,apple:,codesign:</span> <span class="hljs-string">-s</span> <span class="hljs-string">-k</span> <span class="hljs-string">&quot;&quot;</span> <span class="hljs-string">build.keychain</span>

      <span class="hljs-comment"># ...</span>
</code></pre>
<p>Here, <code>PRIVATE_KEY</code> is a Base64-encoded version of the <code>.p12</code> private
key, password protected by <code>PRIVATE_KEY_PASSWORD</code>.</p>
<p><code>CERTIFICATE</code> is a Base64-encoded version of the <code>.cer</code> file that Apple
provided for your application.</p>
<p>Hope that helps!</p>
<section class="post-footer">
  <h3>Want to leave a comment?</h3>
  <p>
    Start a conversation on <a href="https://x.com/valeriangalliat">X</a> or send me an <a href="mailto:val@codejam.info">email</a>! 💌<br>
    This post helped you? <a href="https://ko-fi.com/funkyval">Buy me a coffee</a>! 🍻
  </p>
</section>
]]></content>
  </entry>
  <entry>
    <title>USB iPhone screen recording in Swift</title>
    <link href="https://www.codejam.info/2025/06/usb-iphone-screen-recording-swift.html" />
    <id>https://www.codejam.info/2025/06/usb-iphone-screen-recording-swift.html</id>
    <updated>2025-06-22T07:00:00.000Z</updated>
    <content type="html"><![CDATA[<p>When you plug in an iPhone to a Mac via USB, QuickTime allows you to
select the iPhone screen as a video recording source.</p>
<p>This is neat, but what if you want to do do the same thing from your own
app?</p>
<p>I’ve had to do this recently, so this blog post will compile everything
I learnt about and especially the undocumented quirks I encountered and
worked around.</p>
<h2 id="kcmiohardwarepropertyallowscreencapturedevices" tabindex="-1"><a class="header-anchor" href="https://www.codejam.info/2025/06/usb-iphone-screen-recording-swift.html#kcmiohardwarepropertyallowscreencapturedevices"><span><code>kCMIOHardwarePropertyAllowScreenCaptureDevices</code></span></a></h2>
<p>The very first thing you need is to enable
<a href="https://developer.apple.com/documentation/coremediaio/kcmiohardwarepropertyallowscreencapturedevices"><code>kCMIOHardwarePropertyAllowScreenCaptureDevices</code></a>.</p>
<p>This is a “hardware property” (whatever that means) that, when set,
allows the current process to access USB-connected mobile devices for
screen recording.</p>
<p>You can find
<a href="https://gist.github.com/samjoch/d06f7fb39b2cbbca087ddcb1af59b28e">many</a>
<a href="https://nadavrub.wordpress.com/2015/07/06/macos-media-capture-using-coremediaio/">flavors</a>
of how to do this online, and here’s mine anyway:</p>
<pre><code class="hljs language-swift"><span class="hljs-keyword">import</span> CoreMediaIO

<span class="hljs-comment">// Sets the &quot;hardware&quot; prop that allows to discover USB mobile devices for screen recording.</span>
<span class="hljs-keyword">func</span> <span class="hljs-title function_">allowScreenCaptureDevices</span>() {
  <span class="hljs-keyword">let</span> element: <span class="hljs-type">CMIOObjectPropertyElement</span>
  <span class="hljs-keyword">if</span> <span class="hljs-keyword">#available</span>(<span class="hljs-keyword">macOS</span> <span class="hljs-number">12.0</span>, <span class="hljs-operator">*</span>) {
    element <span class="hljs-operator">=</span> <span class="hljs-type">CMIOObjectPropertyElement</span>(kCMIOObjectPropertyElementMain)
  } <span class="hljs-keyword">else</span> {
    element <span class="hljs-operator">=</span> <span class="hljs-type">CMIOObjectPropertyElement</span>(kCMIOObjectPropertyElementMaster)
  }

  <span class="hljs-keyword">var</span> prop <span class="hljs-operator">=</span> <span class="hljs-type">CMIOObjectPropertyAddress</span>(
    mSelector: <span class="hljs-type">CMIOObjectPropertySelector</span>(kCMIOHardwarePropertyAllowScreenCaptureDevices),
    mScope: <span class="hljs-type">CMIOObjectPropertyScope</span>(kCMIOObjectPropertyScopeGlobal),
    mElement: element)

  <span class="hljs-keyword">var</span> allow: <span class="hljs-type">UInt32</span> <span class="hljs-operator">=</span> <span class="hljs-number">1</span>
  <span class="hljs-keyword">let</span> dataSize: <span class="hljs-type">UInt32</span> <span class="hljs-operator">=</span> <span class="hljs-number">4</span>
  <span class="hljs-keyword">let</span> zero: <span class="hljs-type">UInt32</span> <span class="hljs-operator">=</span> <span class="hljs-number">0</span>

  <span class="hljs-type">CMIOObjectSetPropertyData</span>(
    <span class="hljs-type">CMIOObjectID</span>(kCMIOObjectSystemObject), <span class="hljs-operator">&amp;</span>prop, zero, <span class="hljs-literal">nil</span>, dataSize, <span class="hljs-operator">&amp;</span>allow)
}
</code></pre>
<p>This will allow you to discover USB mobile devices as part of your usual
<code>AVCaptureDevice.DiscoverySession</code>.</p>
<p>Now while this code uses a relatively verbose low-level old C interface
(because it’s the only way to do this right now), it’s fairly
straightforward. It’s spiritually equivalent to doing
<code>kCMIOHardwarePropertyAllowScreenCaptureDevices = 1</code> (no shit).</p>
<p>But this hardware property is not as innocent as it looks, and I’m about
to infodump on you everything I found out about it. Brace yourselves (or
skip to the next section until you encounter weird issues and need to
come back here 😂).</p>
<h2 id="it-s-not-instant" tabindex="-1"><a class="header-anchor" href="https://www.codejam.info/2025/06/usb-iphone-screen-recording-swift.html#it-s-not-instant"><span>It’s not instant</span></a></h2>
<p>When you set <code>kCMIOHardwarePropertyAllowScreenCaptureDevices</code>, the
effect is not instant, meaning if you do a
<code>AVCaptureDevice.DiscoverySession</code> right after, you’re basically
guaranteed to <em>not</em> see the connected USB mobile devices.</p>
<p>This is not necessarily a problem. For example if your Swift process is
long-running, you set that prop first thing on boot, but you actually
list the devices later on upon user interaction, everything will be
fine.</p>
<p>However if you’re working with a CLI (i.e. <code>my-cli list-devices</code>
/ <code>my-cli record-device &lt;device&gt;</code>), or simply need access to the
mobile devices immediately upon starting the app, this is not gonna cut
it.</p>
<p>After setting the prop, the devices are gonna take up to a few seconds
to “show up”, and you can listen to the <code>AVCaptureDeviceWasConnected</code>
notification from the <code>NotificationCenter</code> to know about it. There’s a
good example for that in <a href="https://gist.github.com/samjoch/d06f7fb39b2cbbca087ddcb1af59b28e">this Gist</a>.</p>
<pre><code class="hljs language-swift"><span class="hljs-comment">// See &quot;The get devices warmup side-effect&quot; below for why this is necessary...</span>
<span class="hljs-keyword">let</span> <span class="hljs-keyword">_</span> <span class="hljs-operator">=</span> <span class="hljs-type">AVCaptureDevice</span>.devices()

<span class="hljs-type">NotificationCenter</span>.default
  .addObserver(
    forName: <span class="hljs-type">NSNotification</span>.<span class="hljs-type">Name</span>.<span class="hljs-type">AVCaptureDeviceWasConnected</span>, object: <span class="hljs-literal">nil</span>, queue: <span class="hljs-literal">nil</span>
  ) { (notif) -&gt; <span class="hljs-type">Void</span> <span class="hljs-keyword">in</span>
    <span class="hljs-keyword">let</span> device <span class="hljs-operator">=</span> notif.object<span class="hljs-operator">!</span> <span class="hljs-keyword">as!</span> <span class="hljs-type">AVCaptureDevice</span>
    <span class="hljs-comment">// ...</span>
  }
</code></pre>
<p>This works, but it also means there’s no way to tell immediately that
<em>no</em> device is currently connected. This is a problem if you want
to implement <code>my-cli list-devices</code>. Your best option is to time out
after a few seconds, but it’s not ideal because of the added delay when
no device is connected…</p>
<h2 id="the-get-devices-warmup-side-effect" tabindex="-1"><a class="header-anchor" href="https://www.codejam.info/2025/06/usb-iphone-screen-recording-swift.html#the-get-devices-warmup-side-effect"><span>The get devices warmup side-effect</span></a></h2>
<p>This one is super sneaky and I wasted a lot of time on it. It turns out
that if you don’t call an API to list the devices, i.e. the deprecated
<code>AVCaptureDevice.devices</code>, or now a proper
<code>AVCaptureDevice.DiscoverySession</code>, the <code>AVCaptureDeviceWasConnected</code>
notification will never arrive.</p>
<p>So you need to start a <code>DiscoverySession</code> first, expecting to get 0
devices back (because you just set the hardware prop and its effect is
not instant), just to “warm up” the system, so that it will actually
send the notification.</p>
<p>In the <a href="https://gist.github.com/samjoch/d06f7fb39b2cbbca087ddcb1af59b28e#file-avcapturedevice-playground-swift-L38">Gist</a>
I linked earlier, the <code>print(&quot;\(AVCaptureDevice.devices().count)&quot;)</code> line
is actually <em>significant</em> and the code will <em>not</em> work without it:</p>
<pre><code class="hljs language-swift"><span class="hljs-keyword">func</span> <span class="hljs-title function_">start</span>() {
  <span class="hljs-built_in">print</span>(<span class="hljs-string">&quot;<span class="hljs-subst">\(AVCaptureDevice.devices().count)</span>&quot;</span>)

  <span class="hljs-type">NotificationCenter</span>.default
    .addObserver(
      forName: <span class="hljs-type">NSNotification</span>.<span class="hljs-type">Name</span>.<span class="hljs-type">AVCaptureDeviceWasConnected</span>, object: <span class="hljs-literal">nil</span>, queue: <span class="hljs-literal">nil</span>
    ) { (notif) -&gt; <span class="hljs-type">Void</span> <span class="hljs-keyword">in</span>
      <span class="hljs-keyword">self</span>.iosDeviceAttached(device: notif.object<span class="hljs-operator">!</span> <span class="hljs-keyword">as!</span> <span class="hljs-type">AVCaptureDevice</span>)
    }
}
</code></pre>
<p>It’s not just the innocent debug print that it seems. The fact it calls
<code>AVCaptureDevice.devices</code> is what allows to warm up the system and for
the notification to actually be sent later on. Without it, the
notification will <em>never</em> arrive.</p>
<p>I like to make it a bit more explicit with:</p>
<pre><code class="hljs language-swift"><span class="hljs-comment">// We don&#x27;t need the data but this appears to be required to &quot;warm up&quot;</span>
<span class="hljs-comment">// the system. If we don&#x27;t make the system call to get devices first,</span>
<span class="hljs-comment">// we can&#x27;t discover new devices with `AVCaptureDeviceWasConnected`. 🤷</span>
<span class="hljs-keyword">let</span> <span class="hljs-keyword">_</span> <span class="hljs-operator">=</span> <span class="hljs-type">AVCaptureDevice</span>.devices()
</code></pre>
<h2 id="it-s-rate-limited" tabindex="-1"><a class="header-anchor" href="https://www.codejam.info/2025/06/usb-iphone-screen-recording-swift.html#it-s-rate-limited"><span>It’s rate limited?</span></a></h2>
<p>This one also made me pull my hair out for a while. So I start my app
that sets the above hardware prop, listen to device connected
notifications, and can see the iPhone available for screen recording.</p>
<p>Then I iterate on my code, maybe add some logging or write come code to
actually start capturing the video feed, and then restart the app.</p>
<p>And then, not only setting
<code>kCMIOHardwarePropertyAllowScreenCaptureDevices</code> takes <em>a few long
blocking seconds</em> to complete, but on top of that I don’t ever get any
device connected notification despite the iPhone being plugged in!</p>
<p>It appears to me that setting this prop is somehow rate limited. I would
need to wait around a minute before launching my CLI again in order for
it to behave “normally” (where setting the prop is near-instant, and I
do get a notification for the plugged-in devices).</p>
<p>However, and that’s where it gets interesting, I noticed that if any
other process on the computer also sets that same hardware property
(i.e. QuickTime), and that process stays running in the background, then
my CLI would reliably work every single time, even if I launch it many
times in a short time span. So it’s like that “rate limit” is really an
issue if my CLI is the <em>only</em> process on the system to set that prop.</p>
<p>So what did I do? I made a <code>my-cli background</code> command that literally
only sets the <code>kCMIOHardwarePropertyAllowScreenCaptureDevices</code> prop,
then sleeps indefinitely. Ran that in the background, then could do
<code>my-cli list-devices</code> and so on as much as I wanted.</p>
<p>Wasn’t gonna cut it for me for production, but at least that was useful
during development to allow me to iterate quickly.</p>
<h2 id="actual-device-recording" tabindex="-1"><a class="header-anchor" href="https://www.codejam.info/2025/06/usb-iphone-screen-recording-swift.html#actual-device-recording"><span>Actual device recording</span></a></h2>
<p>I won’t go in much details here because there’s actually no quirks on
that side of things. It’s just your typical <code>AVFoundation</code> recording
which is very well covered online already.</p>
<p>First we get the external devices via a <code>DiscoverySession</code>:</p>
<pre><code class="hljs language-swift"><span class="hljs-keyword">let</span> devices <span class="hljs-operator">=</span> <span class="hljs-type">AVCaptureDevice</span>.<span class="hljs-type">DiscoverySession</span>(
  deviceTypes: [.external],
  <span class="hljs-comment">// Muxed type seems to be a decent way to distinguish USB connected</span>
  <span class="hljs-comment">// mobile devices from other external devices like e.g. &quot;OBS Virtual Camera&quot;.</span>
  mediaType: .muxed,
  position: .unspecified
).devices
</code></pre>
<p>This returns a list of <code>AVCaptureDevice</code>. Alternatively if we have the
ID of a mobile device already:</p>
<pre><code class="hljs language-swift"><span class="hljs-keyword">let</span> device <span class="hljs-operator">=</span> <span class="hljs-type">AVCaptureDevice</span>(uniqueID: <span class="hljs-string">&quot;...&quot;</span>)
</code></pre>
<p>Then we make an input from that device:</p>
<pre><code class="hljs language-swift"><span class="hljs-keyword">let</span> deviceInput <span class="hljs-operator">=</span> <span class="hljs-keyword">try</span> <span class="hljs-type">AVCaptureDeviceInput</span>(device: device)
</code></pre>
<p>And the rest is the usual <a href="https://developer.apple.com/documentation/avfoundation/setting-up-a-capture-session"><code>AVCaptureSession</code> protocol</a>.</p>
<h2 id="wrapping-up" tabindex="-1"><a class="header-anchor" href="https://www.codejam.info/2025/06/usb-iphone-screen-recording-swift.html#wrapping-up"><span>Wrapping up</span></a></h2>
<p>If you encountered any if the quirks above, I hope that it helped you
work around them and hopefully you didn’t waste as much time on this as
I did. Happy device recording!</p>
<section class="post-footer">
  <h3>Want to leave a comment?</h3>
  <p>
    Start a conversation on <a href="https://x.com/valeriangalliat">X</a> or send me an <a href="mailto:val@codejam.info">email</a>! 💌<br>
    This post helped you? <a href="https://ko-fi.com/funkyval">Buy me a coffee</a>! 🍻
  </p>
</section>
]]></content>
  </entry>
  <entry>
    <title>Turborepo: don’t buffer logs on GitHub Actions</title>
    <link href="https://www.codejam.info/2025/04/turborepo-buffer-logs-github-actions.html" />
    <id>https://www.codejam.info/2025/04/turborepo-buffer-logs-github-actions.html</id>
    <updated>2025-04-23T07:00:00.000Z</updated>
    <content type="html"><![CDATA[<p>When using <code>turbo run</code> inside a GitHub workflow, Turborepo buffers the
logs by package so it’s all neatly sorted in the output.</p>
<p>This is a nice feature when you’re looking at the logs after a run is
completed.</p>
<p>However when you’re trying to debug a timing-based issue <em>live</em>, this
makes it really annoying because the logs are buffered the entire time
the process runs and only spit out at the end! And then we can’t see
which package logged what first because the logs got grouped by package
instead of being in a merged stream.</p>
<p>I ended up dissecting the source of Turborepo to figure out what causes
this behavior inside GitHub Actions. <a href="https://github.com/vercel/turborepo/blob/90369bd86cd11ae59d5f94f60bcdbe49313d065f/crates/turborepo-ci/src/vendors.rs#L262-L285">I found it</a>.</p>
<p>It’s triggered by the <code>GITHUB_ACTIONS</code> environment variable being
present.</p>
<p>So running <code>unset GITHUB_ACTIONS</code> before running <code>turbo run</code> turns off
this behavior!</p>
<p>In theory that should suffice but I had the following in my notes so
I’ll also leave it here in case it helps:</p>
<pre><code class="hljs language-sh"><span class="hljs-built_in">unset</span> $(<span class="hljs-built_in">env</span> | grep RUNNER | <span class="hljs-built_in">cut</span> -d= -f1)
<span class="hljs-built_in">unset</span> $(<span class="hljs-built_in">env</span> | grep GITHUB | <span class="hljs-built_in">cut</span> -d= -f1)
</code></pre>
<section class="post-footer">
  <h3>Want to leave a comment?</h3>
  <p>
    Start a conversation on <a href="https://x.com/valeriangalliat">X</a> or send me an <a href="mailto:val@codejam.info">email</a>! 💌<br>
    This post helped you? <a href="https://ko-fi.com/funkyval">Buy me a coffee</a>! 🍻
  </p>
</section>
]]></content>
  </entry>
  <entry>
    <title>Firestore Algolia full index operation error 400 or 403</title>
    <link href="https://www.codejam.info/2025/04/firestore-algolia-full-index-error.html" />
    <id>https://www.codejam.info/2025/04/firestore-algolia-full-index-error.html</id>
    <updated>2025-04-23T07:00:00.000Z</updated>
    <content type="html"><![CDATA[<p>I was testing a change to the Algolia setup in a staging environment and
needed to perform a full index.</p>
<h2 id="triggering-a-full-index" tabindex="-1"><a class="header-anchor" href="https://www.codejam.info/2025/04/firestore-algolia-full-index-error.html#triggering-a-full-index"><span>Triggering a full index</span></a></h2>
<p>BTW to trigger a full index, either change the Algolia extension config
from the Firebase console, and make sure that the <strong>Full Index existing
documents</strong> field is set to true.</p>
<p>If it’s already on true (so that won’t trigger an actual config change)
and you don’t want to change anything else in the config, you can set it
to false, wait 5 minutes for it to deploy, then set it to true again
(lol). Or, better, go in Google Cloud Console, in Cloud Task, and for
the <code>ext-firestore-algolia-search-executeFullIndexOperation</code> task queue,
select <strong>Actions &gt; Force a task run</strong>.</p>
<h2 id="errors-during-full-index" tabindex="-1"><a class="header-anchor" href="https://www.codejam.info/2025/04/firestore-algolia-full-index-error.html#errors-during-full-index"><span>Errors during full index</span></a></h2>
<p>I was looking at the
<code>ext-firestore-algolia-search-executeFullIndexOperation</code> Cloud Function
logs to monitor the index operation, and after a few minutes it choked
with a 400 error (but I’ve also seen 403).</p>
<p>The logs are really unhelpful because the request body gets logged
entirely first, but in my case it’s too big truncates the log line, and
so if this function logs the response body (which I’m not even sure),
it’s not accessible because the line got truncated in the request part.</p>
<p>It took me longer than I’m willing to admit to figure that, but I found
out that there’s a <strong>API Monitoring</strong> section on Algolia where we can
<strong>Search API Logs</strong> and I could see the 400 and 403 errors there!</p>
<p>In my case the errors were:</p>
<pre><code class="hljs language-json"><span class="hljs-punctuation">{</span>
  <span class="hljs-attr">&quot;message&quot;</span><span class="hljs-punctuation">:</span> <span class="hljs-string">&quot;Record at the position ... objectID=... is too big size=.../10000 bytes. Please have a look at https://www.algolia.com/doc/guides/sending-and-managing-data/prepare-your-data/in-depth/index-and-records-size-and-usage-limitations/#record-size-limits&quot;</span><span class="hljs-punctuation">,</span>
  <span class="hljs-attr">&quot;status&quot;</span><span class="hljs-punctuation">:</span> <span class="hljs-number">400</span>
<span class="hljs-punctuation">}</span>
</code></pre>
<pre><code class="hljs language-json"><span class="hljs-punctuation">{</span>
  <span class="hljs-attr">&quot;message&quot;</span><span class="hljs-punctuation">:</span> <span class="hljs-string">&quot;You have exceeded your Record quota. You’ll need to change your plan for more capacity, or delete records. See more details at https://www.algolia.com/account/billing/details?applicationId=...&quot;</span><span class="hljs-punctuation">,</span>
  <span class="hljs-attr">&quot;status&quot;</span><span class="hljs-punctuation">:</span> <span class="hljs-number">403</span>
<span class="hljs-punctuation">}</span>
</code></pre>
<p>That’s much more useful.</p>
<p>Turns out my staging env had too many records already for its plan (free
plan) so I couldn’t sync more during the full index, and in another case
the record I was trying to write was too big for the free plan max
record size.</p>
<p>I wasn’t having the error in prod because we use a paid plan there.</p>
<p>So the solution was simple. Upgrade staging to a paid plan so I can do
my testing!</p>
<p>After that I triggered a full index again and it worked just fine. 🙏</p>
<h2 id="also-queue-name-length" tabindex="-1"><a class="header-anchor" href="https://www.codejam.info/2025/04/firestore-algolia-full-index-error.html#also-queue-name-length"><span>Also: queue name length</span></a></h2>
<p>Another reason the full index could fail, as reported by a reader (a
friend who independently stumbled upon my blog, love when this happens
lol), is if you give a name to the extension that’s over 33 characters,
because after that the queue name gets truncated and the extension can’t
find the queue.</p>
<p>In my example above the full queue name is
<code>ext-firestore-algolia-search-executeFullIndexOperation</code>, and we control
the <code>firestore-algolia-search</code> part of the name. That’s what can’t be
more than 33 characters. If it is, then you get something like
<code>ext-firestore-algolia-search-workspaces-executeFullIndexOperati</code> which
Algolia can’t handle.</p>
<section class="post-footer">
  <h3>Want to leave a comment?</h3>
  <p>
    Start a conversation on <a href="https://x.com/valeriangalliat">X</a> or send me an <a href="mailto:val@codejam.info">email</a>! 💌<br>
    This post helped you? <a href="https://ko-fi.com/funkyval">Buy me a coffee</a>! 🍻
  </p>
</section>
]]></content>
  </entry>
</feed>
