<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Macos on Charlie Chiang's blog</title><link>https://charlie0129.github.io/blog/categories/macos/</link><description>Recent content in Macos on Charlie Chiang's blog</description><generator>Hugo -- gohugo.io</generator><language>en</language><lastBuildDate>Sun, 16 Aug 2026 16:42:45 +0800</lastBuildDate><atom:link href="https://charlie0129.github.io/blog/categories/macos/index.xml" rel="self" type="application/rss+xml"/><item><title>Building Audio Insight: An Audio Analyzer That Actually Renders Smoothly</title><link>https://charlie0129.github.io/blog/p/building-audio-insight/</link><pubDate>Sun, 16 Aug 2026 16:42:45 +0800</pubDate><guid>https://charlie0129.github.io/blog/p/building-audio-insight/</guid><description>&lt;p>I like audio analyzers. It answers questions that my ears alone cannot answer quickly. Where is that resonance? Is the low end actually mono? How loud is this over the whole track? Is a limiter catching an occasional peak, or working all the time?&lt;/p>
&lt;p>What I do not like is an analyzer whose interface feels slower than the display it runs on.&lt;/p>
&lt;p>On my M1 Max, Excite Audio VISION 4X appeared to top out at roughly 30 FPS, with inconsistent frame timing, while consuming about one CPU core. iZotope Insight 2 looked smoother, but in my experience it was comparatively resource-heavy and expensive. These were observations from my own setup, not controlled benchmarks that apply to every machine, host, and plugin version. Still, they were enough to make me wonder: how difficult would it be to build the analyzer I wanted?&lt;/p>
&lt;p>That became &lt;a class="link" href="https://github.com/charlie0129/audio-insight" target="_blank" rel="noopener"
>Audio Insight&lt;/a>, an open-source AUv2 and VST3 analyzer for macOS. Its first goal is deliberately narrow: show useful measurements, leave the audio unchanged, keep real-time callback work bounded, and make the interface feel native on a high-refresh-rate display.&lt;/p>
&lt;p>&lt;img src="https://charlie0129.github.io/blog/p/building-audio-insight/images/audio-insight-dashboard.png"
width="2400"
height="1600"
srcset="https://charlie0129.github.io/blog/p/building-audio-insight/images/audio-insight-dashboard_hu_65720109d0b02a45.webp 480w, https://charlie0129.github.io/blog/p/building-audio-insight/images/audio-insight-dashboard_hu_bef71f94e6a17226.jpg 480w, https://charlie0129.github.io/blog/p/building-audio-insight/images/audio-insight-dashboard_hu_69cd2a1a5c959a9.webp 1024w, https://charlie0129.github.io/blog/p/building-audio-insight/images/audio-insight-dashboard_hu_90165cad42ea4598.jpg 1024w, https://charlie0129.github.io/blog/p/building-audio-insight/images/audio-insight-dashboard_hu_e5c6a27b39095609.webp 1536w, https://charlie0129.github.io/blog/p/building-audio-insight/images/audio-insight-dashboard_hu_a4aec93b81a0f8da.jpg 1536w, https://charlie0129.github.io/blog/p/building-audio-insight/images/audio-insight-dashboard_hu_6b3fb61cc0a9dbfe.webp 2048w, https://charlie0129.github.io/blog/p/building-audio-insight/images/audio-insight-dashboard_hu_af4a7725d3b77d03.jpg 2048w, https://charlie0129.github.io/blog/p/building-audio-insight/images/audio-insight-dashboard_hu_879369758ebcb0fa.webp 2400w, https://charlie0129.github.io/blog/p/building-audio-insight/images/audio-insight-dashboard_hu_b23cace1a05ee5e4.jpg 2400w"
loading="lazy"
alt="Audio Insight dashboard with all five analyzers"
class="gallery-image"
data-flex-grow="150"
data-flex-basis="360px"
>
&lt;/p>
&lt;p>The project is still early, but it already has a Spectrum, Spectrogram, Peak/RMS meter, stereo vectorscope and correlation meter, and BS.1770 loudness measurements. Four grid-snapped splitters resize the dashboard tiles, the analysis parameters are adjustable, and a built-in metrics panel makes the renderer&amp;rsquo;s behavior visible instead of leaving performance to intuition.&lt;/p>
&lt;p>This post is about how it works, but mostly about the unexpectedly interesting work required to make a meter move smoothly.&lt;/p>
&lt;h2 id="what-an-audio-plugin-actually-does">What an audio plugin actually does
&lt;/h2>&lt;p>People who use plugins often picture them as little applications inside a DAW. That is a useful mental model for the interface, but not for the audio path.&lt;/p>
&lt;p>An AU or VST3 plugin is code loaded by a host (or, in some hosts, a separate hosting service). The host repeatedly gives the plugin a small block of samples by calling its processing function. At 48 kHz with 512-sample blocks, a new block arrives about every 10.7 milliseconds. The plugin has to finish before the hardware needs the result. Missing that deadline can produce a click or dropout.&lt;/p>
&lt;p>Audio Insight is a transparent effect: it observes supported mono or stereo audio and leaves the samples unchanged. Even so, its callback has to follow the same real-time rules as a compressor or synthesizer. It cannot allocate memory, take a lock, wait for another thread, write a log, open a file, call the UI, or ask the GPU to draw something. Any of those operations can take an unpredictable amount of time.&lt;/p>
&lt;p>The resulting design looks like this:&lt;/p>
&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt"> 1
&lt;/span>&lt;span class="lnt"> 2
&lt;/span>&lt;span class="lnt"> 3
&lt;/span>&lt;span class="lnt"> 4
&lt;/span>&lt;span class="lnt"> 5
&lt;/span>&lt;span class="lnt"> 6
&lt;/span>&lt;span class="lnt"> 7
&lt;/span>&lt;span class="lnt"> 8
&lt;/span>&lt;span class="lnt"> 9
&lt;/span>&lt;span class="lnt">10
&lt;/span>&lt;span class="lnt">11
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="cl">host audio callback
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> ↓
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">bounded, non-blocking capture
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> ↓
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">per-instance analysis coordinator
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> ↓
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">shared two-worker analysis pool
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> ↓
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">immutable measurement snapshots
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> ↓
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">display-linked Metal renderer
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>The callback only captures bounded chunks into preallocated storage and updates the few measurements that must inspect every sample. A per-instance coordinator coalesces work, and all instances loaded in the same plugin module share two analysis workers. There is at most one running or queued job per instance, so opening many plugin windows does not create a thread for every visualization.&lt;/p>
&lt;p>The workers publish immutable snapshots. The UI reads the newest complete snapshot whenever it draws; it never waits for analysis to finish. This separation matters. Analysis targets 60 slices per second, but the latest-wins scheduler can skip stale work rather than build a backlog. Meanwhile, a ProMotion display can render at around 120 Hz. The renderer can advance display motion between discrete analysis updates without running twice as many FFTs.&lt;/p>
&lt;p>All five visualizations share one Metal canvas, drawable, command buffer, and render pass. JUCE supplies the plugin shell and cross-format plumbing, while the visual layer is native Metal. Coordinates and layout use logical points, and drawable and text resources follow the current backing scale. The implementation is therefore designed to support both regular-density and Retina displays, including live backing-scale changes.&lt;/p>
&lt;p>Both paths are highly optimized. The shared transform uses &lt;code>juce::dsp::FFT&lt;/code>, which selects Apple&amp;rsquo;s Accelerate/vDSP implementation on macOS, and Spectrum and Spectrogram reuse each calibrated result. On the GPU side, the Spectrogram stores calibrated dB in a circular 16-bit-float (&lt;code>R16Float&lt;/code>) texture: scrolling remaps texture coordinates, while shader controls recolor retained history without another FFT or a whole-texture copy. High display cadence therefore does not multiply the default 60 Hz FFT workload.&lt;/p>
&lt;p>When the editor is closed, there is nothing to display, so capture, analysis, history, display-link callbacks, and Metal submissions stop. Audio still passes through normally. Reopening the editor begins fresh rather than silently spending host resources on invisible history.&lt;/p>
&lt;h2 id="turning-samples-into-pictures">Turning samples into pictures
&lt;/h2>&lt;p>The analyzers share infrastructure, but each one answers a different question. Here is the calculation path in a little more detail.&lt;/p>
&lt;h3 id="spectrum-what-frequencies-exist-now">Spectrum: what frequencies exist now?
&lt;/h3>&lt;p>&lt;img src="https://charlie0129.github.io/blog/p/building-audio-insight/images/audio-insight-spectrum.png"
width="1964"
height="686"
srcset="https://charlie0129.github.io/blog/p/building-audio-insight/images/audio-insight-spectrum_hu_2387749c0d8ba064.webp 480w, https://charlie0129.github.io/blog/p/building-audio-insight/images/audio-insight-spectrum_hu_664cee83eb2c0ad.jpg 480w, https://charlie0129.github.io/blog/p/building-audio-insight/images/audio-insight-spectrum_hu_fbdd5b971d8cb430.webp 1024w, https://charlie0129.github.io/blog/p/building-audio-insight/images/audio-insight-spectrum_hu_deaf9f5d2535fbbb.jpg 1024w, https://charlie0129.github.io/blog/p/building-audio-insight/images/audio-insight-spectrum_hu_c52894cca3685b9.webp 1536w, https://charlie0129.github.io/blog/p/building-audio-insight/images/audio-insight-spectrum_hu_b740d68172460d99.jpg 1536w, https://charlie0129.github.io/blog/p/building-audio-insight/images/audio-insight-spectrum_hu_bdaa1a07fea7d5df.webp 1964w, https://charlie0129.github.io/blog/p/building-audio-insight/images/audio-insight-spectrum_hu_1cda9b55c8a5a78c.jpg 1964w"
loading="lazy"
alt="Audio Insight Spectrum"
class="gallery-image"
data-flex-grow="286"
data-flex-basis="687px"
>
&lt;/p>
&lt;p>The Spectrum takes a short window of recent audio and uses a fast Fourier transform (FFT) to divide it into frequency bins. By default the transform size is \(N=8192\) samples, or about 171 milliseconds at a sample rate \(F_s=48\) kHz. This does &lt;strong>not&lt;/strong> delay the audio by 171 ms; the plugin passes audio through immediately. It means the displayed estimate describes roughly that much recent history.&lt;/p>
&lt;p>The FFT bin centers are separated by \(\Delta f=F_s/N\). With the defaults, that is approximately 5.86 Hz. This number is useful, but it is not the same as saying two tones 5.86 Hz apart can always be resolved: the selected window also determines how widely a tone spreads into nearby bins.&lt;/p>
&lt;p>Before the FFT, samples are multiplied by a periodic five-term flat-top window \(w[n]\). Cutting an arbitrary piece from a continuous waveform creates artificial edges, which spread energy across the spectrum. A window tapers the data to control that leakage. A flat-top window trades some ability to separate nearby tones for better amplitude accuracy, which is a useful default for a measurement tool.&lt;/p>
&lt;p>For channel \(c\), the transform is:&lt;/p>
\[
\begin{aligned}
X_c[k]&amp;=\sum_{n=0}^{N-1}x_c[n]\,w[n]e^{-j2\pi kn/N},\\
f_k&amp;=\frac{kF_s}{N}.
\end{aligned}
\]&lt;p>Audio Insight corrects the window&amp;rsquo;s coherent gain—the amplitude scaling introduced by multiplying by \(w[n]\)—with \(W=\sum_n w[n]\). Real-valued audio has mirrored positive- and negative-frequency FFT bins, but the graph needs only the nonnegative half. In this one-sided view, the DC bin at 0 Hz and the Nyquist bin at \(F_s/2\) use \(1/W\); every bin between them represents both mirrored sides and uses \(2/W\). The calibrated stereo power and level are therefore:&lt;/p>
\[
\begin{aligned}
a_k&amp;=
\begin{cases}
1/W, &amp; k=0\ \text{or}\ k=N/2,\\
2/W, &amp; \text{otherwise},
\end{cases}\\[3pt]
P[k]&amp;=\max_c\left(a_k|X_c[k]|\right)^2,\\
D[k]&amp;=10\log_{10}P[k].
\end{aligned}
\]&lt;p>For mono, the maximum contains only one channel. For stereo, taking the larger channel magnitude avoids first mixing the waveforms to mono, where out-of-phase content could cancel. The calibration makes a bin-centered full-scale sine read 0 dB internally; powers at or below \(10^{-18}\) are displayed at the \(-180\) dB analysis floor.&lt;/p>
&lt;p>Attack and Release then smooth each bin in &lt;strong>linear power&lt;/strong>, not in dB. Given the elapsed time \(\Delta t\) and the selected time constant \(\tau_d\):&lt;/p>
\[
\begin{aligned}
\alpha_d&amp;=
\begin{cases}
0, &amp; d\text{ is Off},\\
e^{-\Delta t/\tau_d}, &amp; d\text{ is enabled},
\end{cases}\\[3pt]
\bar P_t[k]&amp;=\alpha_d\bar P_{t-1}[k]+(1-\alpha_d)P_t[k].
\end{aligned}
\]&lt;p>The direction \(d\) is Attack when \(P_t[k]\geq\bar P_{t-1}[k]\), otherwise Release. An Off direction follows the current FFT immediately. The default Attack is Off, allowing a short burst to appear at once, while the default 250 ms Release lets the trace fall more slowly. Peak hold, when enabled, operates on unsmoothed power instead of \(\bar P\).&lt;/p>
&lt;p>Transforms target a slice rate \(R_s\) using a hop of \(H=\max(1,\operatorname{round}(F_s/R_s))\) new samples. At 48 kHz and 60 slices per second, \(H=800\), so adjacent 8,192-sample windows overlap by about 90.2%. The first result still waits for one complete window, and the latest-wins scheduler may skip stale transforms under load instead of building a backlog.&lt;/p>
&lt;p>&lt;img src="https://charlie0129.github.io/blog/p/building-audio-insight/images/audio-insight-spectrum-settings.png"
width="2400"
height="1600"
srcset="https://charlie0129.github.io/blog/p/building-audio-insight/images/audio-insight-spectrum-settings_hu_9787958d4bb832d.webp 480w, https://charlie0129.github.io/blog/p/building-audio-insight/images/audio-insight-spectrum-settings_hu_e4c42b32afd89577.jpg 480w, https://charlie0129.github.io/blog/p/building-audio-insight/images/audio-insight-spectrum-settings_hu_b65c00a7ce269ec9.webp 1024w, https://charlie0129.github.io/blog/p/building-audio-insight/images/audio-insight-spectrum-settings_hu_4e962807fc7188dc.jpg 1024w, https://charlie0129.github.io/blog/p/building-audio-insight/images/audio-insight-spectrum-settings_hu_e2311e7526ab5e66.webp 1536w, https://charlie0129.github.io/blog/p/building-audio-insight/images/audio-insight-spectrum-settings_hu_c78d3014e83d6dbb.jpg 1536w, https://charlie0129.github.io/blog/p/building-audio-insight/images/audio-insight-spectrum-settings_hu_6528c9c2772ad63b.webp 2048w, https://charlie0129.github.io/blog/p/building-audio-insight/images/audio-insight-spectrum-settings_hu_9bfee6b347dac769.jpg 2048w, https://charlie0129.github.io/blog/p/building-audio-insight/images/audio-insight-spectrum-settings_hu_796a3c92f524a6a9.webp 2400w, https://charlie0129.github.io/blog/p/building-audio-insight/images/audio-insight-spectrum-settings_hu_e5c6f6a008c1f78b.jpg 2400w"
loading="lazy"
alt="Spectrum Attack and Release controls"
class="gallery-image"
data-flex-grow="150"
data-flex-basis="360px"
>
&lt;/p>
&lt;p>Spectrum and Spectrogram use the same continuously adjustable frequency scale. For a frequency \(f\) between \(f_0\) and \(f_1\), the scale control \(s\) blends normalized linear and logarithmic coordinates:&lt;/p>
\[
\begin{aligned}
u_{\mathrm{lin}}(f)&amp;=\frac{f-f_0}{f_1-f_0},\\
u_{\log}(f)&amp;=\frac{\ln(f/f_0)}{\ln(f_1/f_0)},\\
u(f,s)&amp;=(1-s)u_{\mathrm{lin}}(f)+s\,u_{\log}(f).
\end{aligned}
\]&lt;p>Here \(f_0=20\) Hz and \(f_1=\min(20\text{ kHz},F_s/2)\). The default is \(s=0.8\). At \(s=0\), equal distances represent equal numbers of hertz. At \(s=1\), equal ratios such as 100→200 Hz and 1→2 kHz occupy equal distances. Values in between preserve more low-frequency detail without compressing the entire treble into a tiny area. Spectrum uses \(x=u\), while Spectrogram uses \(y=1-u\) so high frequencies appear at the top. Axis labels are chosen dynamically: important anchors win first, then extra candidates fill only the space that remains.&lt;/p>
&lt;h3 id="spectrogram-how-did-the-spectrum-change">Spectrogram: how did the spectrum change?
&lt;/h3>&lt;p>&lt;img src="https://charlie0129.github.io/blog/p/building-audio-insight/images/audio-insight-spectrogram.png"
width="1570"
height="758"
srcset="https://charlie0129.github.io/blog/p/building-audio-insight/images/audio-insight-spectrogram_hu_7f3da03990dee5bf.webp 480w, https://charlie0129.github.io/blog/p/building-audio-insight/images/audio-insight-spectrogram_hu_e61b0ccc823d3d7d.jpg 480w, https://charlie0129.github.io/blog/p/building-audio-insight/images/audio-insight-spectrogram_hu_3e3ec7e159f2c15d.webp 1024w, https://charlie0129.github.io/blog/p/building-audio-insight/images/audio-insight-spectrogram_hu_d8e057932d6094d3.jpg 1024w, https://charlie0129.github.io/blog/p/building-audio-insight/images/audio-insight-spectrogram_hu_ff773f267ecd87d6.webp 1536w, https://charlie0129.github.io/blog/p/building-audio-insight/images/audio-insight-spectrogram_hu_2135715b92aae3fd.jpg 1536w, https://charlie0129.github.io/blog/p/building-audio-insight/images/audio-insight-spectrogram_hu_e23e3c8120487b46.webp 1570w, https://charlie0129.github.io/blog/p/building-audio-insight/images/audio-insight-spectrogram_hu_1b464000e76048f6.jpg 1570w"
loading="lazy"
alt="Audio Insight Spectrogram"
class="gallery-image"
data-flex-grow="207"
data-flex-basis="497px"
>
&lt;/p>
&lt;p>A Spectrum is one slice through time. A Spectrogram keeps those slices and scrolls them sideways, using color for level. Transients become vertical marks, steady tones become horizontal lines, and harmonics become stacks of related lines.&lt;/p>
&lt;p>Each Spectrogram column starts from the same raw power \(P[k]\) as Spectrum, before Spectrum&amp;rsquo;s Attack/Release averaging. Let \(\mathcal K\) contain only usable bin centers from 20 Hz through \(f_1\), and let \(R_f=\min(1024,|\mathcal K|)\) be the texture&amp;rsquo;s frequency-row count. For a usable bin at \(f_k=kF_s/N\), define \(q_k=u(f_k,s)\). Its row is:&lt;/p>
\[
r(k)=\min\left(R_f-1,\left\lfloor R_f q_k\right\rfloor\right).
\]&lt;p>For a row containing one or more bin centers, \(P_r\) is the greatest \(P[k]\) assigned to that row. Taking the maximum, rather than the average, helps a narrow tonal trace survive when several FFT bins land in one display row. If a row contains no bin center—common at low frequencies with a small FFT—the mapper inverse-maps the row center and linearly interpolates the two surrounding bins in power. It is honest interpolation between available samples, not a claim of extra frequency resolution.&lt;/p>
&lt;p>Power at or below \(10^{-18}\) becomes \(-180\) dB; otherwise the mapper stores \(D_r=10\log_{10}P_r\). These values go into a circular Metal texture with one 16-bit floating-point level per cell (R16Float). The texture stores calibrated dB rather than finished colors. In the shader, let \(F\) be the selected floor, \(C\) the ceiling, and \(\eta\) the Color response:&lt;/p>
\[
\begin{aligned}
v&amp;=\operatorname{clamp}\left(
\frac{D_r-F}{C-F},0,1\right),\\
\gamma&amp;=2^\eta,\\
c_{\mathrm{palette}}&amp;=v^\gamma.
\end{aligned}
\]&lt;p>The value \(c_{\mathrm{palette}}\) selects a point in the chosen palette. Response 0 is linear in dB; negative values reveal quieter detail, while positive values suppress low energy and emphasize stronger traces. Because this work happens in the shader, changing palette, range, or response recolors existing history without rerunning the FFT.&lt;/p>
&lt;p>For a history duration \(T\) and requested slice rate \(R_s\), the texture uses \(\min(8192,\lceil TR_s\rceil)\) columns. The default ten seconds at 60 slices per second therefore needs 600 columns. A write index wraps around the texture, and the renderer changes texture coordinates instead of copying the whole image to scroll it. Missing timestamp intervals become black columns rather than stretching old information across time.&lt;/p>
&lt;h3 id="peak-and-rms-how-strong-is-the-signal">Peak and RMS: how strong is the signal?
&lt;/h3>&lt;p>&lt;img src="https://charlie0129.github.io/blog/p/building-audio-insight/images/audio-insight-peak-rms.png"
width="534"
height="686"
srcset="https://charlie0129.github.io/blog/p/building-audio-insight/images/audio-insight-peak-rms_hu_52e90250c2611238.webp 480w, https://charlie0129.github.io/blog/p/building-audio-insight/images/audio-insight-peak-rms_hu_5a4dbf3bd0e0c780.jpg 480w, https://charlie0129.github.io/blog/p/building-audio-insight/images/audio-insight-peak-rms_hu_3a0f949a87b5d6b1.webp 534w, https://charlie0129.github.io/blog/p/building-audio-insight/images/audio-insight-peak-rms_hu_de0186bebe1eb659.jpg 534w"
loading="lazy"
alt="Audio Insight Peak/RMS meter"
class="gallery-image"
data-flex-grow="77"
data-flex-basis="186px"
>
&lt;/p>
&lt;p>Peak and RMS intentionally describe different things.&lt;/p>
&lt;p>Sample peak examines every sample. Its live value has instantaneous attack and a 20 dB/s release:&lt;/p>
\[
\begin{aligned}
\lambda_p&amp;=10^{-20/(20F_s)},\\
p[n]&amp;=\max\left(|x[n]|,\lambda_p p[n-1]\right).
\end{aligned}
\]&lt;p>In other words, a new larger sample wins immediately; otherwise the old indication decays by the amount corresponding to one sample period. A separate hold marker keeps a new maximum for two seconds, then falls at the same 20 dB/s rate. The OVER indicator latches when \(|x[n]|\geq1\), although the label deliberately does not claim that floating-point audio at 0 dBFS proves waveform clipping.&lt;/p>
&lt;p>RMS estimates sustained signal power. With the 300 ms time constant \(\tau=0.300\) s, Audio Insight updates an exponential mean square for every sample:&lt;/p>
\[
\begin{aligned}
\alpha&amp;=e^{-1/(F_s\tau)},\\
q[n]&amp;=\alpha q[n-1]+(1-\alpha)x[n]^2,\\
\operatorname{RMS}[n]&amp;=\sqrt{q[n]},\\
D_{\mathrm{RMS}}[n]&amp;=20\log_{10}\operatorname{RMS}[n].
\end{aligned}
\]&lt;p>This is an exponential response, not a rectangular box containing exactly the latest 300 ms. It also has no AES17 \(+3.01\) dB calibration offset, so a full-scale sine reads approximately \(-3.01\) dBFS RMS. Peak reveals brief extremes; RMS behaves more like a view of sustained energy. The peak remains a &lt;strong>sample peak&lt;/strong>, not an oversampled true-peak/dBTP measurement, so it does not predict a possibly larger value between stored samples.&lt;/p>
&lt;p>These ballistics run on the bounded real-time capture path and inspect every sample. Their meaning therefore does not change if an analysis worker is briefly late.&lt;/p>
&lt;h3 id="stereo-how-are-left-and-right-related">Stereo: how are left and right related?
&lt;/h3>&lt;p>&lt;img src="https://charlie0129.github.io/blog/p/building-audio-insight/images/audio-insight-stereo-correlation.png"
width="576"
height="650"
srcset="https://charlie0129.github.io/blog/p/building-audio-insight/images/audio-insight-stereo-correlation_hu_263c226881c3769f.webp 480w, https://charlie0129.github.io/blog/p/building-audio-insight/images/audio-insight-stereo-correlation_hu_75cef76ae6f2d730.jpg 480w, https://charlie0129.github.io/blog/p/building-audio-insight/images/audio-insight-stereo-correlation_hu_23357ccd719a8e4e.webp 576w, https://charlie0129.github.io/blog/p/building-audio-insight/images/audio-insight-stereo-correlation_hu_81e9cc669ba79053.jpg 576w"
loading="lazy"
alt="Audio Insight Vectorscope and Correlation meter"
class="gallery-image"
data-flex-grow="88"
data-flex-basis="212px"
>
&lt;/p>
&lt;p>The vectorscope turns each stereo sample pair into a point:&lt;/p>
\[
x_{\mathrm{scope}}=\frac{R-L}{2},
\qquad
y_{\mathrm{scope}}=\frac{L+R}{2}.
\]&lt;p>Audio shared equally by both channels has \(x=0\) and lies on the vertical center axis. Opposite-phase audio has \(y=0\) and spreads horizontally. The coordinates remain tied to full scale rather than being normalized independently on every frame, so a quiet signal is not made to look artificially loud.&lt;/p>
&lt;p>The field keeps the latest 250 ms but bounds its GPU data. For \(W_f=\lceil0.25F_s\rceil\) captured frames, the worker selects one pair every:&lt;/p>
\[
d=\left\lceil\frac{W_f}{4096}\right\rceil
\]&lt;p>frames. At 48 kHz, \(W_f=12000\), \(d=3\), and the cloud contains about 4,000 uniformly spaced points. Their opacity fades with age. This decimation changes only the picture; it does not change the correlation measurement.&lt;/p>
&lt;p>The adjacent correlation value uses every sample, with 300 ms exponentially weighted averages:&lt;/p>
\[
\begin{aligned}
\alpha&amp;=e^{-1/(F_s\cdot0.300)},\\
E_n[z]&amp;=\alpha E_{n-1}[z]+(1-\alpha)z[n].
\end{aligned}
\]\[
\rho[n]=\frac{E_n[LR]}{\sqrt{E_n[L^2]E_n[R^2]}}.
\]&lt;p>The three running values \(E[L^2]\), \(E[R^2]\), and \(E[LR]\) advance in source-sample order on the real-time side, and the implementation clamps the final ratio to \([-1,1]\) against numerical error. A value near \(+1\) means the channels are strongly alike, \(0\) means little linear relationship, and a negative value warns that mono playback may cancel important content. If either averaged channel power is below \(10^{-9}\), equivalent to \(-90\) dBFS RMS, correlation is reported as unavailable rather than dividing by a nearly zero value. A genuinely mono input is labeled MONO and likewise does not receive a synthetic \(+1\) correlation.&lt;/p>
&lt;h3 id="loudness-how-loud-does-it-feel-over-time">Loudness: how loud does it feel over time?
&lt;/h3>&lt;p>&lt;img src="https://charlie0129.github.io/blog/p/building-audio-insight/images/audio-insight-loudness.png"
width="384"
height="650"
srcset="https://charlie0129.github.io/blog/p/building-audio-insight/images/audio-insight-loudness_hu_7320b9503e1a0dce.webp 384w, https://charlie0129.github.io/blog/p/building-audio-insight/images/audio-insight-loudness_hu_ca5aed9118a6d0df.jpg 384w"
loading="lazy"
alt="Audio Insight Loudness meters"
class="gallery-image"
data-flex-grow="59"
data-flex-basis="141px"
>
&lt;/p>
&lt;p>Raw peak level is not perceived loudness. Audio Insight implements BS.1770-5 K-weighting with the Momentary, Short-term, and Integrated semantics commonly used with EBU R128. K-weighting is a pair of filters: a high-frequency shelf models the head&amp;rsquo;s acoustic effect, and a high-pass stage reduces the contribution of very low frequencies. The code derives their coefficients for the current sample rate.&lt;/p>
&lt;p>If \(y_c[n]\) is the K-weighted output of channel \(c\), Audio Insight forms the per-sample energy and a window mean:&lt;/p>
\[
\begin{aligned}
e[n]&amp;=\sum_c y_c[n]^2,\\
z_W&amp;=\frac{1}{N_W}\sum_{n\in W}e[n].
\end{aligned}
\]&lt;p>For the supported mono and stereo layouts, every actual channel has unit weight. Mono therefore contributes once; it is never duplicated into synthetic left and right channels. Surround layouts and their channel weights are outside the current scope. Window energy becomes LUFS (Loudness Units relative to Full Scale) using the BS.1770 offset:&lt;/p>
\[
L_W=-0.691+10\log_{10}z_W.
\]&lt;ul>
&lt;li>Momentary loudness covers 400 ms.&lt;/li>
&lt;li>Short-term loudness covers 3 seconds.&lt;/li>
&lt;li>Integrated loudness uses 400 ms blocks completed every 100 ms—75% overlap—from the latest Reset within the current uninterrupted, visible analysis interval.&lt;/li>
&lt;/ul>
&lt;p>Momentary and Short-term are simple ungated window measurements. Integrated loudness applies gates: thresholds that exclude blocks from the long-term average. For each 400 ms block \(i\), let its mean-square energy be \(z_i\) and its loudness be \(L_i=-0.691+10\log_{10}z_i\). The absolute-passing set is:&lt;/p>
\[
\begin{aligned}
\mathcal A&amp;=\{i\mid L_i>-70\ \mathrm{LUFS}\},\\
\mu_{\mathcal A}&amp;=\frac{1}{|\mathcal A|}\sum_{i\in\mathcal A}z_i.
\end{aligned}
\]&lt;p>At each Integrated update, one non-iterative relative threshold is calculated 10 LU below the preliminary absolute-gated mean of the history accumulated so far:&lt;/p>
\[
\begin{aligned}
\Gamma_{\mathrm{rel}}&amp;=-0.691+10\log_{10}\mu_{\mathcal A}-10,\\
\mathcal R&amp;=\{i\in\mathcal A\mid L_i>\Gamma_{\mathrm{rel}}\}.
\end{aligned}
\]&lt;p>Finally:&lt;/p>
\[
\begin{aligned}
\bar z_{\mathcal R}&amp;=\frac{1}{|\mathcal R|}\sum_{i\in\mathcal R}z_i,\\
L_I&amp;=-0.691+10\log_{10}\bar z_{\mathcal R}.
\end{aligned}
\]&lt;p>Both comparisons are strict \(>\), and the relative gate is not iterated repeatedly. This two-stage gate prevents silence and very quiet passages from dragging the program average down indefinitely. The tile&amp;rsquo;s Reset command restarts Integrated loudness while ready Momentary/Short-term values and K-weighting continuity remain intact. Editor reactivation, an audio discontinuity, or a format change resets the complete loudness analyzer.&lt;/p>
&lt;p>The empty cases are explicit too. If \(\mathcal A\) contains no blocks, the preliminary mean and relative gate remain unavailable. If \(\mathcal R\) is empty, Integrated loudness remains \(-\infty\). The implementation never divides by an empty set.&lt;/p>
&lt;p>The implementation does not claim complete EBU Mode compliance: it does not yet include LRA or true peak, for example. The label describes its M/S/I measurement semantics, not a certification.&lt;/p>
&lt;p>There is an interesting performance problem hiding in Integrated loudness. Within one uninterrupted visible measurement, the exact answer can cover 24 hours: up to 864,000 blocks. Rescanning every qualifying block every 100 ms would make the cost grow throughout the measurement.&lt;/p>
&lt;p>The implementation uses a preallocated sorted index called a B+ tree. It contains finite block energies above the absolute gate and keeps aggregate counts and sums in its branches; all completed blocks still count toward the 24-hour limit. A new relative-gate boundary can be answered by finding one boundary leaf and combining a bounded number of branch totals. Capacity for the worst case occupies about 7.25 MiB on arm64, and the structure never allocates while processing.&lt;/p>
&lt;h2 id="smooth-is-a-timing-property-not-an-fps-number">Smooth is a timing property, not an FPS number
&lt;/h2>&lt;p>The renderer uses &lt;code>CAMetalDisplayLink&lt;/code>, which supplies a drawable in step with a display. While visible, Audio Insight requests the active display&amp;rsquo;s exact reported maximum refresh rate, with a 60 Hz fallback. That request is best effort—Core Animation and the compositor still control actual presentation—so measured presentation timestamps are the truth.&lt;/p>
&lt;p>This distinction became important repeatedly. A counter can say 120 callbacks per second while the screen still changes only 60 times. An average can say 120 FPS while an occasional doubled interval makes scrolling visibly hitch. Smoothness is about the complete chain from callback to presentation and about the distribution of frame intervals, not just one large number.&lt;/p>
&lt;p>&lt;img src="https://charlie0129.github.io/blog/images/audio-insight-soundsource.png"
loading="lazy"
alt="Audio Insight running inside SoundSource"
>
&lt;/p>
&lt;h3 id="the-plugin-that-crashed-its-host">The plugin that crashed its host
&lt;/h3>&lt;p>The first AU build appeared for a moment in SoundSource and then disappeared. The host reported only that its Audio Unit hosting service had crashed.&lt;/p>
&lt;p>The detailed log led to an assertion in timed drawable presentation. A normal Metal application may call an API such as timed &lt;code>present&lt;/code>, but a drawable delivered by &lt;code>CAMetalDisplayLink&lt;/code> has different presentation ownership. Combining the two caused the hosting process to assert. The correct sequence is to commit the command buffer and call plain &lt;code>present()&lt;/code> on that drawable, while using the display link&amp;rsquo;s target timestamp only for telemetry and scheduling.&lt;/p>
&lt;p>This is one reason plugin development needs testing in real hosts. SoundSource exposed an API misuse that a successful build or unit test had not.&lt;/p>
&lt;h3 id="why-120-display-callbacks-produced-60-frames">Why 120 display callbacks produced 60 frames
&lt;/h3>&lt;p>After the crash was fixed, the display link was firing close to 120 times per second, but only about 60 frames were submitted. The built-in metrics captured the pattern:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Counter&lt;/th>
&lt;th style="text-align: right">Before the fix&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Display-link callbacks&lt;/td>
&lt;td style="text-align: right">6,453&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Metal submissions&lt;/td>
&lt;td style="text-align: right">3,230&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>GPU-backpressure drops&lt;/td>
&lt;td style="text-align: right">3,223&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Sampled display-link callback rate&lt;/td>
&lt;td style="text-align: right">~111/s&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Sampled Metal submission rate&lt;/td>
&lt;td style="text-align: right">~59.6/s&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The cumulative counters cover the full telemetry epoch; the two rates are a sample from its final roughly 0.25 seconds. Almost exactly every other display-link callback was being rejected by the in-flight buffer pool.&lt;/p>
&lt;p>The surprising part was that the GPU was not necessarily too slow. Reusable vertex buffers were retained until the drawable was actually presented. The compositor may hold a drawable for several refresh periods even after GPU execution has completed, so all reusable buffers became occupied and the next callback had nowhere to write.&lt;/p>
&lt;p>The fix was to separate two lifetimes. GPU command completion now releases the reusable buffers immediately. A small, independent object survives only to correlate the later presentation timestamp. The renderer no longer holds large working resources hostage to compositor timing.&lt;/p>
&lt;p>In a later point-in-time M1 Max capture from another development build, with the Metrics panel visible, the drawable was 2,400×1,496 pixels at 2× backing scale. The run recorded 1,188 display-link callbacks, 1,188 submissions, and zero GPU-backpressure drops. Across the most recent 240 presented intervals, the average was 8.438 ms, or 118.52 Hz; 237 intervals were the normal 8.333 ms and three doubled to 16.667 ms. Telemetry also counted 11 skipped presentations over the run. That capture used the then-selected 16,384-point FFT; today&amp;rsquo;s default is 8,192.&lt;/p>
&lt;p>The capture demonstrates that the buffer-lifetime bottleneck and its GPU-backpressure drops were gone. It is evidence of approximately display-rate presentation in that run, not a perfect-pacing claim or a controlled comparison with another plugin.&lt;/p>
&lt;h3 id="making-60-hz-data-scroll-on-a-120-hz-display">Making 60 Hz data scroll on a 120 Hz display
&lt;/h3>&lt;p>The Spectrogram exposed a second kind of stutter. New analysis columns arrive at 60 Hz. If the image moves forward only when a complete column arrives, it necessarily steps every other frame on a 120 Hz display.&lt;/p>
&lt;p>The solution was not to double the FFT workload. The renderer advances a fractional scroll head from the target presentation clock while keeping the actual dB cells discrete. A one-slice cushion absorbs ordinary analysis scheduling jitter. If a texture upload is briefly busy, the renderer postpones that upload while continuing to draw the rest of the dashboard.&lt;/p>
&lt;p>The result is much smoother motion from the same 60-slices-per-second target. This also explains why raising thread priority would have been the wrong first response: the main issue was the relationship between two clocks, not a shortage of real-time privileges.&lt;/p>
&lt;h3 id="the-random-resets-that-were-not-random">The random resets that were not random
&lt;/h3>&lt;p>During longer sessions, all graphs would occasionally reset. The recovery was intentional—when audio history has a real gap, temporal analyzers must not pretend the samples on either side were adjacent—but the handoff overflowed far too easily even while host audio was continuous. Sequence tracking then correctly detected the resulting loss.&lt;/p>
&lt;p>The original capture queue had 16 logical slots and consumed one for each host callback. Its time capacity therefore depended on the host&amp;rsquo;s block size. A metrics capture reached all 16 ready slots, discarded 20 queued chunks to make room for newer audio, and recorded three consumer discontinuities followed by three Loudness resets.&lt;/p>
&lt;p>The redesigned queue packs audio across callback boundaries into 128 slots of 256 frames, retaining 32,768 frames regardless of host callback size. That is about 683 ms at 48 kHz, 341 ms at 96 kHz, or 171 ms at 192 kHz. The capacity and overflow behavior are covered by implementation tests; longer post-redesign host runs remain part of validation. A sufficiently long stall can still overflow it. When that happens, latest data wins and temporal analyzers reset, because joining unrelated pieces of audio would produce convincing but false measurements.&lt;/p>
&lt;h2 id="building-observability-into-the-plugin">Building observability into the plugin
&lt;/h2>&lt;p>Apple&amp;rsquo;s Metal HUD is useful for applications that enable it before creating their first Metal device. A plugin usually arrives after its host has already done that, so it cannot reliably switch the HUD on from a settings button. I replaced that idea with a built-in performance panel available in Release builds.&lt;/p>
&lt;p>&lt;img src="https://charlie0129.github.io/blog/p/building-audio-insight/images/audio-insight-metrics.png"
width="2400"
height="1600"
srcset="https://charlie0129.github.io/blog/p/building-audio-insight/images/audio-insight-metrics_hu_3e35564a57531db1.webp 480w, https://charlie0129.github.io/blog/p/building-audio-insight/images/audio-insight-metrics_hu_17a99fe9b1840b47.jpg 480w, https://charlie0129.github.io/blog/p/building-audio-insight/images/audio-insight-metrics_hu_a50d2e90557737cb.webp 1024w, https://charlie0129.github.io/blog/p/building-audio-insight/images/audio-insight-metrics_hu_56e8ba0c2aa2fcf5.jpg 1024w, https://charlie0129.github.io/blog/p/building-audio-insight/images/audio-insight-metrics_hu_c16f0ce7449dffc6.webp 1536w, https://charlie0129.github.io/blog/p/building-audio-insight/images/audio-insight-metrics_hu_7ee062fc54661c12.jpg 1536w, https://charlie0129.github.io/blog/p/building-audio-insight/images/audio-insight-metrics_hu_91a2ab2ced51d103.webp 2048w, https://charlie0129.github.io/blog/p/building-audio-insight/images/audio-insight-metrics_hu_5058b4d74de2b309.jpg 2048w, https://charlie0129.github.io/blog/p/building-audio-insight/images/audio-insight-metrics_hu_8f5e390e440110af.webp 2400w, https://charlie0129.github.io/blog/p/building-audio-insight/images/audio-insight-metrics_hu_f2af51aec60caaa4.jpg 2400w"
loading="lazy"
alt="Frame pacing and latency composition in the Metrics panel"
class="gallery-image"
data-flex-grow="150"
data-flex-basis="360px"
>
&lt;/p>
&lt;p>&lt;img src="https://charlie0129.github.io/blog/p/building-audio-insight/images/audio-insight-metrics-details.png"
width="1030"
height="1492"
srcset="https://charlie0129.github.io/blog/p/building-audio-insight/images/audio-insight-metrics-details_hu_8fc88fe5b45184b.webp 480w, https://charlie0129.github.io/blog/p/building-audio-insight/images/audio-insight-metrics-details_hu_6855df2f78d2e266.jpg 480w, https://charlie0129.github.io/blog/p/building-audio-insight/images/audio-insight-metrics-details_hu_7bb34ff9e83af0ff.webp 1024w, https://charlie0129.github.io/blog/p/building-audio-insight/images/audio-insight-metrics-details_hu_ece4367265690785.jpg 1024w, https://charlie0129.github.io/blog/p/building-audio-insight/images/audio-insight-metrics-details_hu_ce43a78db3f44ba9.webp 1030w, https://charlie0129.github.io/blog/p/building-audio-insight/images/audio-insight-metrics-details_hu_392c9d2f73db95ad.jpg 1030w"
loading="lazy"
alt="Metric details"
class="gallery-image"
data-flex-grow="69"
data-flex-basis="165px"
>
&lt;/p>
&lt;p>It reports exact frame pacing over the latest 240 presentation intervals, derived from 241 timestamps; CPU, submit, GPU, and compositor latency composition; display-link scheduling; audio-callback histograms; queue occupancy and discontinuities; analyzer freshness; and raw copyable metrics for offline inspection. The stacked latency bar covers the pipeline from a display-link callback to presentation. It is not a breakdown of an 8.33 ms frame budget: several frames can overlap in flight, so its total can exceed one refresh interval without reducing presentation cadence.&lt;/p>
&lt;p>Graphs move at vblank, headline numbers refresh at no more than 10 Hz, and the full text table refreshes at 4 Hz. That keeps the visual feedback immediate without rebuilding lots of text 120 times per second. Instrumentation turned several vague reports—“it looks a bit laggy,” “it seems to reset”—into specific, actionable failures.&lt;/p>
&lt;h2 id="an-agent-assisted-human-tested-development-loop">An agent-assisted, human-tested development loop
&lt;/h2>&lt;p>I used coding agents to implement much of Audio Insight. That did not remove the need for a tight feedback loop; it made the loop more important.&lt;/p>
&lt;p>The agents could design the threading model, inspect crash logs, add instrumentation, and reason from raw captures. They could build the plugin, but they could not reliably judge how motion felt inside my particular SoundSource setup. At runnable milestones I installed the AU, watched it on the M1 Max, adjusted settings, and returned observations, screenshots, logs, or copied metrics. Those reports led directly to the presentation-lifetime fix, the fractional Spectrogram scroll, dynamic axis labeling, and the queue redesign.&lt;/p>
&lt;p>For visual and real-time software, “the code is correct” and “the product feels right” are different claims. An instrumented implementation plus a person looking at the actual display proved far more useful than guessing at either one in isolation.&lt;/p>
&lt;h2 id="what-is-open-source-today">What is open source today
&lt;/h2>&lt;p>The current code identifies itself as Audio Insight 0.1.0. It targets macOS 15 on arm64 and builds AUv2 and VST3. It uses C++20, CMake, a pinned JUCE submodule for the plugin shell, CPU FFT analysis accelerated by Apple&amp;rsquo;s vDSP on macOS, and a native Metal renderer. Project-owned code is licensed under AGPL-3.0-or-later; JUCE retains its own upstream AGPL terms.&lt;/p>
&lt;p>The current release policy is pragmatic for a small open-source project: builds use ad hoc signing, and Developer ID signing and notarization are out of scope. Users can build from source. For a downloaded bundle, the documented flow is to verify the published checksum, extract it, clear quarantine only on the intended bundle, apply an ad hoc signature, and verify that signature.&lt;/p>
&lt;p>Older macOS versions, Intel/Universal builds, Windows, and AUv3 are architectural possibilities rather than current support promises. Logic compatibility, broader VST3 host coverage, multi-instance stress testing, and several formal performance gates also remain work in progress.&lt;/p>
&lt;p>The source, build instructions, and current limitations are all in the &lt;a class="link" href="https://github.com/charlie0129/audio-insight" target="_blank" rel="noopener"
>Audio Insight repository&lt;/a>. If you use analyzers but have never looked inside one, I hope the code makes the path from samples to pixels a little less mysterious. And if the Spectrogram glides across a 120 Hz display without drawing attention to the renderer, that is exactly the point.&lt;/p></description></item><item><title>Bundling macOS Dynamic Libraries</title><link>https://charlie0129.github.io/blog/p/bundling-macos-dynamic-libraries/</link><pubDate>Mon, 08 Jun 2026 12:25:00 +0800</pubDate><guid>https://charlie0129.github.io/blog/p/bundling-macos-dynamic-libraries/</guid><description>&lt;p>I recently tried to build QEMU on macOS and make the result portable enough that it could still run after uninstalling the Homebrew packages used during the build, or I can move the whole QEMU directory to another machine. I don&amp;rsquo;t want the Homebrew build because it installs too many dependencies on the system, and I want a more self-contained bundle, not a system-wide installation.&lt;/p>
&lt;p>The short version: &lt;strong>fully static linking on macOS is not really the right target&lt;/strong>. macOS binaries still dynamically link against Apple system libraries such as &lt;code>libSystem&lt;/code>. But for tools like QEMU, what I really wanted was not a “pure static binary”; I wanted a self-contained directory like this:&lt;/p>
&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt"> 1
&lt;/span>&lt;span class="lnt"> 2
&lt;/span>&lt;span class="lnt"> 3
&lt;/span>&lt;span class="lnt"> 4
&lt;/span>&lt;span class="lnt"> 5
&lt;/span>&lt;span class="lnt"> 6
&lt;/span>&lt;span class="lnt"> 7
&lt;/span>&lt;span class="lnt"> 8
&lt;/span>&lt;span class="lnt"> 9
&lt;/span>&lt;span class="lnt">10
&lt;/span>&lt;span class="lnt">11
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="cl">_qemu-11.0.1/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">├── bin/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">│ ├── qemu-img
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">│ ├── qemu-system-x86_64
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">│ └── ...
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">└── lib/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> ├── libglib-2.0.0.dylib
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> ├── libgobject-2.0.0.dylib
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> ├── libintl.8.dylib
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> ├── libpcre2-8.0.dylib
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> └── ...
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>The binaries in &lt;code>bin/&lt;/code> should load their non-system dependencies from &lt;code>../lib&lt;/code> (note that it&amp;rsquo;s relative, not absolute, so it will work even if the whole directory is moved), instead of from &lt;code>/opt/homebrew&lt;/code>.&lt;/p>
&lt;p>This post records the process, using QEMU as the example.&lt;/p>
&lt;h2 id="the-original-problem">The original problem
&lt;/h2>&lt;p>I built QEMU roughly like this:&lt;/p>
&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt">1
&lt;/span>&lt;span class="lnt">2
&lt;/span>&lt;span class="lnt">3
&lt;/span>&lt;span class="lnt">4
&lt;/span>&lt;span class="lnt">5
&lt;/span>&lt;span class="lnt">6
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-sh" data-lang="sh">&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># You should refer to https://wiki.qemu.org/Hosts/Mac for the latest build instructions. This is just an example.&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">brew install libffi gettext glib pkg-config zstd &lt;span class="c1"># may not be the exact set of dependencies you need&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">./configure --prefix&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="nv">$HOME&lt;/span>&lt;span class="s2">/bin/_qemu-11.0.1&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">make -j&lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="k">$(&lt;/span>sysctl -n hw.ncpu&lt;span class="k">)&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">make install
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>The build worked. But after uninstalling some Homebrew dependencies, running &lt;code>qemu-img&lt;/code> failed:&lt;/p>
&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt">1
&lt;/span>&lt;span class="lnt">2
&lt;/span>&lt;span class="lnt">3
&lt;/span>&lt;span class="lnt">4
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="cl">dyld[82783]: Library not loaded: /opt/homebrew/opt/glib/lib/libglib-2.0.0.dylib
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> Referenced from: /Users/charlie/bin/_qemu-11.0.1/bin/qemu-img
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> Reason: tried: &amp;#39;/opt/homebrew/opt/glib/lib/libglib-2.0.0.dylib&amp;#39; (no such file)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">zsh: abort qemu-img
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>This means the installed QEMU binary still contains an absolute dependency path:&lt;/p>
&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt">1
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="cl">/opt/homebrew/opt/glib/lib/libglib-2.0.0.dylib
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>We can confirm that with:&lt;/p>
&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt">1
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-sh" data-lang="sh">&lt;span class="line">&lt;span class="cl">otool -L &lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="nv">$PREFIX&lt;/span>&lt;span class="s2">/bin/qemu-img&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>Example output:&lt;/p>
&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt">1
&lt;/span>&lt;span class="lnt">2
&lt;/span>&lt;span class="lnt">3
&lt;/span>&lt;span class="lnt">4
&lt;/span>&lt;span class="lnt">5
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="cl">qemu-img:
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> /opt/homebrew/opt/glib/lib/libglib-2.0.0.dylib (compatibility version 8801.0.0, current version 8801.0.0)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> /opt/homebrew/opt/zstd/lib/libzstd.1.dylib (compatibility version 1.0.0, current version 1.5.7)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> /usr/lib/libSystem.B.dylib (compatibility version 1.0.0, current version 1351.0.0)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> ...
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>The &lt;code>/usr/lib&lt;/code> and &lt;code>/System/Library&lt;/code> entries are fine. They are macOS system libraries. The &lt;code>/opt/homebrew/...&lt;/code> entries are the ones that make the binary depend on my local Homebrew installation.&lt;/p>
&lt;h2 id="why-not-just-build-qemu-fully-static">Why not just build QEMU fully static?
&lt;/h2>&lt;p>On Linux, the natural thought is “just build a static binary”.&lt;/p>
&lt;p>On macOS, that is not usually how things work. macOS does not support the same style of fully static userland binary that Linux users often expect. You can try to statically link some third-party libraries, but the final program will still dynamically link to Apple system libraries.&lt;/p>
&lt;p>So the practical goal is:&lt;/p>
&lt;p>Bundle third-party &lt;code>.dylib&lt;/code> dependencies next to the program, and rewrite Mach-O load commands so the binary loads those local copies.&lt;/p>
&lt;p>This is similar in spirit to what many &lt;code>.app&lt;/code> bundles do, but here I wanted a plain CLI directory layout.&lt;/p>
&lt;h2 id="the-tools-involved">The tools involved
&lt;/h2>&lt;p>macOS Mach-O binaries can be inspected and patched with these tools:&lt;/p>
&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt">1
&lt;/span>&lt;span class="lnt">2
&lt;/span>&lt;span class="lnt">3
&lt;/span>&lt;span class="lnt">4
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-sh" data-lang="sh">&lt;span class="line">&lt;span class="cl">otool -L &amp;lt;file&amp;gt;
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">install_name_tool -change &amp;lt;old&amp;gt; &amp;lt;new&amp;gt; &amp;lt;file&amp;gt;
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">install_name_tool -id &amp;lt;new-id&amp;gt; &amp;lt;dylib&amp;gt;
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">install_name_tool -add_rpath &amp;lt;rpath&amp;gt; &amp;lt;file&amp;gt;
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>&lt;code>otool -L&lt;/code> shows dynamic library load commands.&lt;/p>
&lt;p>For example:&lt;/p>
&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt">1
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-sh" data-lang="sh">&lt;span class="line">&lt;span class="cl">otool -L &lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="nv">$PREFIX&lt;/span>&lt;span class="s2">/bin/qemu-img&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>might show:&lt;/p>
&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt">1
&lt;/span>&lt;span class="lnt">2
&lt;/span>&lt;span class="lnt">3
&lt;/span>&lt;span class="lnt">4
&lt;/span>&lt;span class="lnt">5
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="cl">qemu-img:
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> /opt/homebrew/opt/glib/lib/libglib-2.0.0.dylib (compatibility version 8801.0.0, current version 8801.0.0)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> /opt/homebrew/opt/zstd/lib/libzstd.1.dylib (compatibility version 1.0.0, current version 1.5.7)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> /usr/lib/libSystem.B.dylib (compatibility version 1.0.0, current version 1351.0.0)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> ...
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>Those can be rewritten with:&lt;/p>
&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt">1
&lt;/span>&lt;span class="lnt">2
&lt;/span>&lt;span class="lnt">3
&lt;/span>&lt;span class="lnt">4
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-sh" data-lang="sh">&lt;span class="line">&lt;span class="cl">install_name_tool -change &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> /opt/homebrew/opt/glib/lib/libglib-2.0.0.dylib &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> &lt;span class="s1">&amp;#39;@executable_path/../lib/libglib-2.0.0.dylib&amp;#39;&lt;/span> &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> &lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="nv">$PREFIX&lt;/span>&lt;span class="s2">/bin/qemu-img&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>&lt;code>@executable_path&lt;/code> means “the directory containing the executable being launched”.&lt;/p>
&lt;p>So it will try to find &lt;code>libglib-2.0.0.dylib&lt;/code> in &lt;code>../lib/&lt;/code> relative to the executable, no longer looking in &lt;code>/opt/homebrew&lt;/code>.&lt;/p>
&lt;h2 id="first-attempt-patch-direct-dependencies-only">First attempt: patch direct dependencies only
&lt;/h2>&lt;p>My first attempt was simple:&lt;/p>
&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt">1
&lt;/span>&lt;span class="lnt">2
&lt;/span>&lt;span class="lnt">3
&lt;/span>&lt;span class="lnt">4
&lt;/span>&lt;span class="lnt">5
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-sh" data-lang="sh">&lt;span class="line">&lt;span class="cl">otool -L &lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="nv">$PREFIX&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span>/bin/* &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> &lt;span class="p">|&lt;/span> grep /opt/homebrew &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> &lt;span class="p">|&lt;/span> sort &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> &lt;span class="p">|&lt;/span> uniq &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> &lt;span class="p">|&lt;/span> awk &lt;span class="s1">&amp;#39;{print $1}&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>This found direct Homebrew dependencies used by QEMU binaries, such as:&lt;/p>
&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt">1
&lt;/span>&lt;span class="lnt">2
&lt;/span>&lt;span class="lnt">3
&lt;/span>&lt;span class="lnt">4
&lt;/span>&lt;span class="lnt">5
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="cl">/opt/homebrew/opt/glib/lib/libgio-2.0.0.dylib
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">/opt/homebrew/opt/glib/lib/libglib-2.0.0.dylib
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">/opt/homebrew/opt/glib/lib/libgmodule-2.0.0.dylib
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">/opt/homebrew/opt/glib/lib/libgobject-2.0.0.dylib
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">/opt/homebrew/opt/zstd/lib/libzstd.1.dylib
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>Then I copied those libraries into &lt;code>$PREFIX/lib&lt;/code> and patched the binaries.&lt;/p>
&lt;p>This fixed some errors, but not all of them.&lt;/p>
&lt;p>Running &lt;code>qemu-img&lt;/code> then failed with a different error:&lt;/p>
&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt">1
&lt;/span>&lt;span class="lnt">2
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="cl">dyld[97126]: Library not loaded: /opt/homebrew/opt/gettext/lib/libintl.8.dylib
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> Referenced from: /Users/charlie/bin/_qemu-11.0.1/lib/libglib-2.0.0.dylib
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>So I realized that it is not enough to patch only the binaries. The copied &lt;code>.dylib&lt;/code> files have their own dependencies too.&lt;/p>
&lt;p>In this case:&lt;/p>
&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt">1
&lt;/span>&lt;span class="lnt">2
&lt;/span>&lt;span class="lnt">3
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="cl">qemu-img
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> -&amp;gt; libglib-2.0.0.dylib
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> -&amp;gt; libintl.8.dylib
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>&lt;code>libintl.8.dylib&lt;/code> was not directly referenced by &lt;code>qemu-img&lt;/code>. It was a transitive dependency of &lt;code>libglib&lt;/code>.&lt;/p>
&lt;h2 id="the-correct-model">The correct model
&lt;/h2>&lt;p>The dependency graph looks like this:&lt;/p>
&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt">1
&lt;/span>&lt;span class="lnt">2
&lt;/span>&lt;span class="lnt">3
&lt;/span>&lt;span class="lnt">4
&lt;/span>&lt;span class="lnt">5
&lt;/span>&lt;span class="lnt">6
&lt;/span>&lt;span class="lnt">7
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="cl">bin/qemu-img
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> -&amp;gt; /opt/homebrew/opt/glib/lib/libglib-2.0.0.dylib
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> -&amp;gt; /opt/homebrew/opt/zstd/lib/libzstd.1.dylib
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">lib/libglib-2.0.0.dylib
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> -&amp;gt; /opt/homebrew/opt/gettext/lib/libintl.8.dylib
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> -&amp;gt; /opt/homebrew/opt/pcre2/lib/libpcre2-8.0.dylib
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>So the bundling process must be recursive:&lt;/p>
&lt;ol>
&lt;li>Scan Mach-O files in &lt;code>bin/&lt;/code>.&lt;/li>
&lt;li>Find its &lt;code>/opt/homebrew&lt;/code> dependencies.&lt;/li>
&lt;li>Copy those &lt;code>.dylib&lt;/code> files into &lt;code>lib/&lt;/code>.&lt;/li>
&lt;li>Patch the binaries to refer to the bundled copies (&lt;code>../lib&lt;/code>).&lt;/li>
&lt;li>Scan the copied &lt;code>.dylib&lt;/code> files.&lt;/li>
&lt;li>Copy and patch their dependencies too.&lt;/li>
&lt;li>Repeat until no &lt;code>/opt/homebrew&lt;/code> dependencies remain.&lt;/li>
&lt;/ol>
&lt;h2 id="which-paths-should-be-used">Which paths should be used?
&lt;/h2>&lt;p>For executables in &lt;code>bin/&lt;/code>, I use:&lt;/p>
&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt">1
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="cl">@executable_path/../lib/libfoo.dylib
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>For dylibs inside &lt;code>lib/&lt;/code>, I use:&lt;/p>
&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt">1
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="cl">@loader_path/libfoo.dylib
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;ul>
&lt;li>
&lt;p>&lt;code>@executable_path&lt;/code> is relative to the main executable being launched.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;code>@loader_path&lt;/code> is relative to the Mach-O file that is doing the loading. For dependencies between dylibs, this is what we want:&lt;/p>
&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt">1
&lt;/span>&lt;span class="lnt">2
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="cl">lib/libglib-2.0.0.dylib
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> -&amp;gt; @loader_path/libintl.8.dylib
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;/li>
&lt;/ul>
&lt;p>Since both files are in the same &lt;code>lib/&lt;/code> directory, this resolves correctly.&lt;/p>
&lt;h2 id="the-final-bundling-script">The final bundling script
&lt;/h2>&lt;p>I ended up writing a small Python script called &lt;code>macho-bundle-deps&lt;/code>. You can find it here: &lt;a class="link" href="https://github.com/charlie0129/dotfiles/blob/f15791054e517f6b5afc892312f1db73f331d475/bin/darwin/macho-bundle-deps" target="_blank" rel="noopener"
>https://github.com/charlie0129/dotfiles/blob/f15791054e517f6b5afc892312f1db73f331d475/bin/darwin/macho-bundle-deps&lt;/a>. This is a permanent link to a specific commit, so you may want to check the latest version in the repository.&lt;/p>
&lt;p>Usage:&lt;/p>
&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt">1
&lt;/span>&lt;span class="lnt">2
&lt;/span>&lt;span class="lnt">3
&lt;/span>&lt;span class="lnt">4
&lt;/span>&lt;span class="lnt">5
&lt;/span>&lt;span class="lnt">6
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-sh" data-lang="sh">&lt;span class="line">&lt;span class="cl">&lt;span class="nv">PREFIX&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="nv">$HOME&lt;/span>&lt;span class="s2">/bin/_qemu-11.0.1&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">macho-bundle-deps &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --lib-dir &lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="nv">$PREFIX&lt;/span>&lt;span class="s2">/lib&amp;#34;&lt;/span> &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --prefix /opt/homebrew &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> &lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="nv">$PREFIX&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span>/bin
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>Or, from inside the QEMU prefix:&lt;/p>
&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt">1
&lt;/span>&lt;span class="lnt">2
&lt;/span>&lt;span class="lnt">3
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-sh" data-lang="sh">&lt;span class="line">&lt;span class="cl">&lt;span class="nb">cd&lt;/span> &lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="nv">$HOME&lt;/span>&lt;span class="s2">/bin/_qemu-11.0.1&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">macho-bundle-deps --lib-dir lib bin
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>The script does the following:&lt;/p>
&lt;ul>
&lt;li>finds Mach-O files from the input paths&lt;/li>
&lt;li>scans their dependencies with &lt;code>otool -L&lt;/code>&lt;/li>
&lt;li>copies matching dependencies into &lt;code>--lib-dir&lt;/code>&lt;/li>
&lt;li>patches executable dependencies to &lt;code>@executable_path/../lib/...&lt;/code>&lt;/li>
&lt;li>patches bundled dylib dependencies to &lt;code>@loader_path/...&lt;/code>&lt;/li>
&lt;li>patches copied dylib IDs with &lt;code>install_name_tool -id&lt;/code>&lt;/li>
&lt;li>recursively processes newly copied dylibs&lt;/li>
&lt;li>reports an error if matching absolute dependency paths remain&lt;/li>
&lt;/ul>
&lt;h2 id="verifying-the-result">Verifying the result
&lt;/h2>&lt;p>After running the bundler, I verify that no Homebrew paths remain:&lt;/p>
&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt">1
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-sh" data-lang="sh">&lt;span class="line">&lt;span class="cl">otool -L &lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="nv">$PREFIX&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span>/bin/* &lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="nv">$PREFIX&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span>/lib/*.dylib &lt;span class="p">|&lt;/span> grep /opt/homebrew
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>Now I can uninstall the dependencies from Homebrew, and the bundled QEMU still works:&lt;/p>
&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt">1
&lt;/span>&lt;span class="lnt">2
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-sh" data-lang="sh">&lt;span class="line">&lt;span class="cl">brew uninstall libffi gettext glib
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">qemu-img --help
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;h2 id="notes-and-limitations">Notes and limitations
&lt;/h2>&lt;p>This is not the same as producing a fully static binary.&lt;/p>
&lt;p>The result still depends on macOS system libraries, which is normal:&lt;/p>
&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt">1
&lt;/span>&lt;span class="lnt">2
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="cl">/usr/lib/libSystem.B.dylib
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">/System/Library/Frameworks/...
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>It also does not magically make a binary portable across all macOS versions or CPU architectures. A binary built on Apple Silicon is still an arm64 Mach-O binary unless built otherwise.&lt;/p>
&lt;p>This approach is mainly useful for making a local CLI tool directory self-contained with respect to Homebrew dependencies.&lt;/p>
&lt;h2 id="why-copying-symlinks-should-be-avoided">Why copying symlinks should be avoided
&lt;/h2>&lt;p>Homebrew often has dylib symlinks like:&lt;/p>
&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt">1
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="cl">libintl.dylib -&amp;gt; libintl.8.dylib
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>When bundling, prefer copying the symlink target, not the symlink itself.&lt;/p>
&lt;p>In shell, that means:&lt;/p>
&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt">1
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-sh" data-lang="sh">&lt;span class="line">&lt;span class="cl">cp -L source.dylib &lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="nv">$PREFIX&lt;/span>&lt;span class="s2">/lib/&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>In Python, &lt;code>shutil.copy2(..., follow_symlinks=True)&lt;/code> does the same thing.&lt;/p>
&lt;p>This avoids ending up with a bundled symlink pointing to a non-existent file.&lt;/p>
&lt;h2 id="final-layout">Final layout
&lt;/h2>&lt;p>After bundling, the QEMU directory looks like this:&lt;/p>
&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt"> 1
&lt;/span>&lt;span class="lnt"> 2
&lt;/span>&lt;span class="lnt"> 3
&lt;/span>&lt;span class="lnt"> 4
&lt;/span>&lt;span class="lnt"> 5
&lt;/span>&lt;span class="lnt"> 6
&lt;/span>&lt;span class="lnt"> 7
&lt;/span>&lt;span class="lnt"> 8
&lt;/span>&lt;span class="lnt"> 9
&lt;/span>&lt;span class="lnt">10
&lt;/span>&lt;span class="lnt">11
&lt;/span>&lt;span class="lnt">12
&lt;/span>&lt;span class="lnt">13
&lt;/span>&lt;span class="lnt">14
&lt;/span>&lt;span class="lnt">15
&lt;/span>&lt;span class="lnt">16
&lt;/span>&lt;span class="lnt">17
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="cl">_qemu-11.0.1/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">├── bin/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">│ ├── qemu-img
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">│ ├── qemu-io
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">│ ├── qemu-nbd
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">│ ├── qemu-system-aarch64
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">│ ├── qemu-system-x86_64
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">│ └── ...
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">└── lib/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> ├── libffi.8.dylib
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> ├── libgio-2.0.0.dylib
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> ├── libglib-2.0.0.dylib
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> ├── libgmodule-2.0.0.dylib
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> ├── libgobject-2.0.0.dylib
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> ├── libintl.8.dylib
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> ├── libpcre2-8.0.dylib
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> └── libzstd.1.dylib
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>Now &lt;code>qemu-img&lt;/code> no longer cares whether Homebrew’s &lt;code>glib&lt;/code>, &lt;code>gettext&lt;/code>, &lt;code>pcre2&lt;/code>, or &lt;code>zstd&lt;/code> packages are installed.&lt;/p>
&lt;h2 id="conclusion">Conclusion
&lt;/h2>&lt;p>The main lesson is that macOS dependency bundling is graph traversal, not a one-shot patch.&lt;/p>
&lt;p>Patching only the top-level binaries is not enough. You also need to patch the dylibs that you copied, and then patch the dylibs that those dylibs depend on.&lt;/p>
&lt;p>The final strategy is:&lt;/p>
&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt">1
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="cl">scan -&amp;gt; copy -&amp;gt; patch -&amp;gt; scan copied dylibs -&amp;gt; repeat
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>For executables:&lt;/p>
&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt">1
&lt;/span>&lt;span class="lnt">2
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="cl">/opt/homebrew/.../libfoo.dylib
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> -&amp;gt; @executable_path/../lib/libfoo.dylib
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>For bundled dylibs:&lt;/p>
&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt">1
&lt;/span>&lt;span class="lnt">2
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="cl">/opt/homebrew/.../libbar.dylib
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> -&amp;gt; @loader_path/libbar.dylib
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>And for the dylib’s own install name:&lt;/p>
&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt">1
&lt;/span>&lt;span class="lnt">2
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="cl">/opt/homebrew/.../libfoo.dylib
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> -&amp;gt; @loader_path/libfoo.dylib
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div></description></item><item><title>Fake macOS Version Detected by Homebrew</title><link>https://charlie0129.github.io/blog/p/homebrew-fake-macos-version/</link><pubDate>Tue, 08 Oct 2024 22:08:00 +0800</pubDate><guid>https://charlie0129.github.io/blog/p/homebrew-fake-macos-version/</guid><description>&lt;h2 id="background">Background
&lt;/h2>&lt;p>One particular issue that I encountered when I tried to install some software using Homebrew on my Mac is that Homebrew deprecates support for older macOS versions just like Apple do. Once a macOS version is deprecated, Homebrew will not provide bottles (precompiled binaries) for that version. This means that you have to compile the software from source, which is extremely time-consuming and sometimes error-prone. Imagine installing &lt;code>shell-check&lt;/code> will require to build &lt;code>ghc&lt;/code> from source, which takes hours, and then use &lt;code>ghc&lt;/code> to build &lt;code>shell-check&lt;/code> from source. This is not a good experience.&lt;/p>
&lt;h2 id="solution">Solution
&lt;/h2>&lt;h3 id="pin-homebrewcore-to-an-older-version">Pin homebrew/core to an older version
&lt;/h3>&lt;p>One way to work around this issue is to use a older version of the &lt;code>homebrew/core&lt;/code> tap, such that the bottles are still available for the macOS version you are using because they are built when your macOS version is supported. The downside is that you will be using outdated software. If this is not an issue for you then congratulations. You can achieve this by running the following command:&lt;/p>
&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt">1
&lt;/span>&lt;span class="lnt">2
&lt;/span>&lt;span class="lnt">3
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-console" data-lang="console">&lt;span class="line">&lt;span class="cl">&lt;span class="gp">$&lt;/span> brew tap homebrew/core
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="gp">$&lt;/span> &lt;span class="nb">cd&lt;/span> &lt;span class="k">$(&lt;/span>brew --repo homebrew/core&lt;span class="k">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="gp">$&lt;/span> git reset --hard &amp;lt;commit-hash&amp;gt;
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>Make sure you disable the auto-update of Homebrew so that it does not update the &lt;code>homebrew/core&lt;/code> tap to the latest version. You can do this by running the following command:&lt;/p>
&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt">1
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-console" data-lang="console">&lt;span class="line">&lt;span class="cl">&lt;span class="gp">$&lt;/span> &lt;span class="nb">echo&lt;/span> &lt;span class="s1">&amp;#39;export HOMEBREW_NO_AUTO_UPDATE=1&amp;#39;&lt;/span> &amp;gt;&amp;gt; ~/.zshrc
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;h3 id="fake-macos-version">Fake macOS version
&lt;/h3>&lt;p>Another way is to fake the macOS version that Homebrew detects. This way, Homebrew will provide bottles for the macOS version you are faking. Most software will work and you will be using the latest software. However, this may not work for all software as some software may have dependencies that are not available for the macOS version you are faking. You can achieve this by running the following command:&lt;/p>
&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt">1
&lt;/span>&lt;span class="lnt">2
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-console" data-lang="console">&lt;span class="line">&lt;span class="cl">&lt;span class="gp">$&lt;/span> &lt;span class="nb">export&lt;/span> &lt;span class="nv">HOMEBREW_FAKE_MACOS&lt;/span>&lt;span class="o">=&lt;/span>13.0 &lt;span class="c1"># Choose a macOS version that is supported by Homebrew and close to your macOS version&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="gp">$&lt;/span> brew install xxx
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;h2 id="explanation">Explanation
&lt;/h2>&lt;p>Homebrew actually has an private undocumented API that allows you to fake the macOS version. &lt;a class="link" href="https://github.com/Homebrew/brew/blob/a3d8f4e0e4a22da9990d59cca70bec1e7be726cf/Library/Homebrew/os/mac.rb#L41" target="_blank" rel="noopener"
>https://github.com/Homebrew/brew/blob/a3d8f4e0e4a22da9990d59cca70bec1e7be726cf/Library/Homebrew/os/mac.rb#L41&lt;/a>&lt;/p>
&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt"> 1
&lt;/span>&lt;span class="lnt"> 2
&lt;/span>&lt;span class="lnt"> 3
&lt;/span>&lt;span class="lnt"> 4
&lt;/span>&lt;span class="lnt"> 5
&lt;/span>&lt;span class="lnt"> 6
&lt;/span>&lt;span class="lnt"> 7
&lt;/span>&lt;span class="lnt"> 8
&lt;/span>&lt;span class="lnt"> 9
&lt;/span>&lt;span class="lnt">10
&lt;/span>&lt;span class="lnt">11
&lt;/span>&lt;span class="lnt">12
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-ruby" data-lang="ruby">&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># This can be compared to numerics, strings, or symbols&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># using the standard Ruby Comparable methods.&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">#&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># @api internal&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">sig&lt;/span> &lt;span class="p">{&lt;/span> &lt;span class="n">returns&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="no">MacOSVersion&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">def&lt;/span> &lt;span class="nc">self&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="nf">full_version&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="vi">@full_version&lt;/span> &lt;span class="o">||=&lt;/span> &lt;span class="k">if&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="n">fake_macos&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="no">ENV&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">fetch&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;HOMEBREW_FAKE_MACOS&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="kp">nil&lt;/span>&lt;span class="p">))&lt;/span> &lt;span class="c1"># for Portable Ruby building&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="no">MacOSVersion&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">new&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">fake_macos&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">else&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="no">MacOSVersion&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">new&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="no">VERSION&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">end&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">end&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;h2 id="example">Example
&lt;/h2>&lt;p>I have macOS Monterey (12.0) running, which is just deprecated by Homebrew. I want to install &lt;code>batt&lt;/code> which is not available as a bottle for macOS Monterey.&lt;/p>
&lt;p>If I run &lt;code>brew install batt&lt;/code>, I will get the following error:&lt;/p>
&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt"> 1
&lt;/span>&lt;span class="lnt"> 2
&lt;/span>&lt;span class="lnt"> 3
&lt;/span>&lt;span class="lnt"> 4
&lt;/span>&lt;span class="lnt"> 5
&lt;/span>&lt;span class="lnt"> 6
&lt;/span>&lt;span class="lnt"> 7
&lt;/span>&lt;span class="lnt"> 8
&lt;/span>&lt;span class="lnt"> 9
&lt;/span>&lt;span class="lnt">10
&lt;/span>&lt;span class="lnt">11
&lt;/span>&lt;span class="lnt">12
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-console" data-lang="console">&lt;span class="line">&lt;span class="cl">&lt;span class="gp">$&lt;/span> brew install batt
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="go">Warning: You are using macOS 12.
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="go">We (and Apple) do not provide support for this old version.
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="go">It is expected behaviour that some formulae will fail to build in this old version.
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="go">It is expected behaviour that Homebrew will be buggy and slow.
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="go">Do not create any issues about this on Homebrew&amp;#39;s GitHub repositories.
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="go">Do not create any issues even if you think this message is unrelated.
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="go">Any opened issues will be immediately closed without response.
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="go">Do not ask for help from Homebrew or its maintainers on social media.
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="go">You may ask for help in Homebrew&amp;#39;s discussions but are unlikely to receive a response.
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="go">Try to figure out the problem yourself and submit a fix as a pull request.
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="go">We will review it but may or may not accept it.
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>It&amp;rsquo;s Oct 8, 2024 and the latest macOS version is macOS Sequoia (15.0). So the last 3 supported macOS versions are:&lt;/p>
&lt;ul>
&lt;li>macOS Sequoia (15.0)&lt;/li>
&lt;li>macOS Sonoma (14.0)&lt;/li>
&lt;li>macOS Ventura (13.0)&lt;/li>
&lt;/ul>
&lt;p>You can confirm that by checking out &lt;code>batt&lt;/code>&amp;rsquo;s formula:&lt;/p>
&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt">1
&lt;/span>&lt;span class="lnt">2
&lt;/span>&lt;span class="lnt">3
&lt;/span>&lt;span class="lnt">4
&lt;/span>&lt;span class="lnt">5
&lt;/span>&lt;span class="lnt">6
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-console" data-lang="console">&lt;span class="line">&lt;span class="cl">&lt;span class="gp">$&lt;/span> brew edit batt
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="go">...
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="go">sha256 cellar: :any_skip_relocation, arm64_sequoia: &amp;#34;61bd7790a82f2269b9a0ce1585c57564d03be4e4ac89b8f0d8843c0073a688e6&amp;#34;
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="go">sha256 cellar: :any_skip_relocation, arm64_sonoma: &amp;#34;198bd7bb9a808f0a9e4cb1a31b7e9c2a72d690ba1d07ebddf78b6d0ce0b6dd03&amp;#34;
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="go">sha256 cellar: :any_skip_relocation, arm64_ventura: &amp;#34;6eff598159b263327b8b562ab32f5e5e7157c20f25cbecfa08b48eda794c4c43&amp;#34;
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="go">...
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>Showing only the bottles for macOS Sequoia (15.0), macOS Sonoma (14.0), and macOS Ventura (13.0) are available.&lt;/p>
&lt;p>Sadly my macOS version (Monterey 12.0) is not in the list, but macOS Ventura (13.0) is the closest to macOS Monterey (12.0). So I can fake the macOS version to macOS Ventura (13.0) by running the following command:&lt;/p>
&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt"> 1
&lt;/span>&lt;span class="lnt"> 2
&lt;/span>&lt;span class="lnt"> 3
&lt;/span>&lt;span class="lnt"> 4
&lt;/span>&lt;span class="lnt"> 5
&lt;/span>&lt;span class="lnt"> 6
&lt;/span>&lt;span class="lnt"> 7
&lt;/span>&lt;span class="lnt"> 8
&lt;/span>&lt;span class="lnt"> 9
&lt;/span>&lt;span class="lnt">10
&lt;/span>&lt;span class="lnt">11
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-console" data-lang="console">&lt;span class="line">&lt;span class="cl">&lt;span class="gp">$&lt;/span> &lt;span class="nb">export&lt;/span> &lt;span class="nv">HOMEBREW_FAKE_MACOS&lt;/span>&lt;span class="o">=&lt;/span>13.0
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="gp">$&lt;/span> brew config
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="go">...
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="go">macOS: 13.0-arm64 # YES! I&amp;#39;m now faking macOS Ventura (13.0)
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="go">...
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="go">&lt;/span>&lt;span class="gp">$&lt;/span> brew install batt
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="go">==&amp;gt; Downloading https://ghcr.io/v2/homebrew/core/batt/manifests/0.3.1
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="go">Already downloaded: /Users/charlie/Library/Caches/Homebrew/downloads/86da7d77f0bacb44475e0280eef162148e3b51df8ea44ff9bc16a5a7bba6d39f--batt-0.3.1.bottle_manifest.json
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="go">==&amp;gt; Fetching batt
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="go">==&amp;gt; Downloading https://ghcr.io/v2/homebrew/core/batt/blobs/sha256:6eff598159b263327b8b562ab32f5e5e7157c20f25cbecfa08b48eda794c4c43
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="go">Already downloaded: /Users/charlie/Library/Caches/Homebrew/downloads/24fc002de2ee11096a544dd84ff1033262a5cb627c2b824c915a552bb6a588dd--batt--0.3.1.arm64_ventura.bottle.tar.gz
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>As you can see, Homebrew is downloading the bottle for macOS Ventura (13.0) successfully! (Of course, the log is showing that I have already downloaded earlier, but you get the idea.)&lt;/p>
&lt;p>&lt;code>batt&lt;/code> itself does not rely on any APIs that are not available in my current macOS version, so it works perfectly fine.&lt;/p></description></item><item><title>macOS Nix First Taste</title><link>https://charlie0129.github.io/blog/p/macos-nix-first-taste/</link><pubDate>Thu, 27 Jun 2024 15:13:00 +0800</pubDate><guid>https://charlie0129.github.io/blog/p/macos-nix-first-taste/</guid><description>&lt;h2 id="note">Note
&lt;/h2>&lt;p>Note that this blog currently acts as a reference for me. It is not a comprehensive guide on why and how to use Nix on macOS.&lt;/p>
&lt;p>I chose nix because I am tired of Homebrew&amp;rsquo;s slowness and NOT PROVIDING BOTTLES (BINARY) FOR OLD MACOS VERSIONS.&lt;/p>
&lt;h2 id="install">Install
&lt;/h2>&lt;p>I am not using the official installer because it cannot survive macOS updates. Instead, I am using the Determinate System&amp;rsquo;s Nix Installer.&lt;/p>
&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt">1
&lt;/span>&lt;span class="lnt">2
&lt;/span>&lt;span class="lnt">3
&lt;/span>&lt;span class="lnt">4
&lt;/span>&lt;span class="lnt">5
&lt;/span>&lt;span class="lnt">6
&lt;/span>&lt;span class="lnt">7
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">&lt;span class="nv">ARCH&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="k">$(&lt;/span>uname -m &lt;span class="p">|&lt;/span> sed &lt;span class="s1">&amp;#39;s/arm64/aarch64/&amp;#39;&lt;/span>&lt;span class="k">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">OS&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="k">$(&lt;/span>uname -s &lt;span class="p">|&lt;/span> tr &lt;span class="s1">&amp;#39;[:upper:]&amp;#39;&lt;/span> &lt;span class="s1">&amp;#39;[:lower:]&amp;#39;&lt;/span>&lt;span class="k">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">curl -L -o nix-installer https://github.com/DeterminateSystems/nix-installer/releases/latest/download/nix-installer-&lt;span class="nv">$ARCH&lt;/span>-&lt;span class="nv">$OS&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">chmod +x nix-installer
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Optionally, move nix-installer to your PATH&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># sudo install nix-installer /usr/local/bin&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">./nix-installer install --explain
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;h2 id="pin-nixpkgs">Pin Nixpkgs
&lt;/h2>&lt;p>I don&amp;rsquo;t want to download a tarball and extract it every now and then. I know you can set tarball-ttl to a higher value, but I don&amp;rsquo;t want to do that either. Just pin nixpkgs.&lt;/p>
&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt">1
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">nix registry pin nixpkgs
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>If you are like me in China, you may want to use a mirror.&lt;/p>
&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt">1
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">&lt;span class="nb">echo&lt;/span> &lt;span class="s2">&amp;#34;substituters = https://mirrors.sjtug.sjtu.edu.cn/nix-channels/store/ https://cache.nixos.org/&amp;#34;&lt;/span> &lt;span class="p">|&lt;/span> sudo tee -a /etc/nix/nix.conf
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;h2 id="using">Using
&lt;/h2>&lt;p>I wrote some scripts just to make my life easier.&lt;/p>
&lt;ul>
&lt;li>&lt;a class="link" href="https://github.com/charlie0129/dotfiles/blob/master/bin/common/nix-install" target="_blank" rel="noopener"
>nix-install&lt;/a>&lt;/li>
&lt;li>&lt;a class="link" href="htts://github.com/charlie0129/dotfiles/blob/master/bin/common/nix-list" >nix-list&lt;/a>&lt;/li>
&lt;li>&lt;a class="link" href="htts://github.com/charlie0129/dotfiles/blob/master/bin/common/nix-search" >nix-search&lt;/a>&lt;/li>
&lt;li>&lt;a class="link" href="htts://github.com/charlie0129/dotfiles/blob/master/bin/common/nix-uninstall" >nix-uninstall&lt;/a>&lt;/li>
&lt;/ul>
&lt;h2 id="later-steps">Later Steps
&lt;/h2>&lt;p>There is &lt;a class="link" href="https://github.com/LnL7/nix-darwin" target="_blank" rel="noopener"
>nix-darwin&lt;/a> to make declarative configuration easier. I don&amp;rsquo;t have time to try it yet but I will definitely give it a try when I have time.&lt;/p></description></item></channel></rss>