<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0">
<channel>
<title>Coder Legion - Recent posts tagged macos</title>
<link>https://coderlegion.com/tag/macos</link>
<description>Powered by Question2Answer</description>
<item>
<title>Fine in Chrome, broken in Preview: two PDFKit gotchas for anyone who writes PDFs</title>
<link>https://coderlegion.com/32435/fine-in-chrome-broken-in-preview-two-pdfkit-gotchas-for-anyone-who-writes-pdfs</link>
<description>&lt;p&gt;&lt;em&gt;This article was originally published on Hashnode: &lt;a rel=&quot;nofollow&quot; href=&quot;https://wangyan.hashnode.dev/fine-in-chrome-broken-in-preview-two-pdfkit-gotchas-for-anyone-who-writes-pdfs&quot;&gt;Fine in Chrome, broken in Preview&lt;/a&gt;. It is republished here with light edits.&lt;/em&gt;&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;strong&gt;Disclosure:&lt;/strong&gt; we make &lt;a rel=&quot;nofollow&quot; href=&quot;https://lensup.ai/pdf-editor/&quot;&gt;LensUp's PDF editor&lt;/a&gt;, the browser tool where we ran into both bugs, so we have a direct connection to the product mentioned here. This write-up was drafted and edited with AI assistance; the bugs, test results and reproduction scripts come from our own testing, and the scripts are public so you can check every claim yourself.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;We build a PDF editor that runs in the browser. It opens a PDF, lets you add text, a signature or a highlight, fill in form fields, and writes the result back as an incremental update. We checked every save with three renderers: pdf.js (Firefox and many web viewers), pdfium (Chrome) and poppler (most Linux tools). All three were happy.&lt;/p&gt;
&lt;p&gt;Then we rendered the same files with Apple's PDFKit, the engine behind Preview on the Mac, the Files app on iOS and PDFs in Safari. It showed two problems none of the others did: a translucent highlighter that turned into an opaque bar or a sentence that vanished, and form answers printed twice on top of each other.&lt;/p&gt;
&lt;p&gt;Neither problem shows up if you only test on Linux or in Chrome. Here are both, with small scripts that reproduce them on any Mac in a few seconds: &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/ppop123/pdfkit-pitfalls&quot;&gt;github.com/ppop123/pdfkit-pitfalls&lt;/a&gt;.&lt;/p&gt;
&lt;h3 id=&quot;gotcha-1-an-incremental-update-must-use-the-same-cross-reference-form-as-the-file-it-updates&quot;&gt;Gotcha 1: an incremental update must use the same cross-reference form as the file it updates&lt;/h3&gt;
&lt;h4 id=&quot;some-background&quot;&gt;Some background&lt;/h4&gt;
&lt;p&gt;An incremental update appends to a PDF instead of rewriting it. The new and changed objects go at the end, followed by a new cross-reference section that points back to the previous one. That's how signed PDFs stay signed, and it's why saving an edit doesn't mean rewriting the whole file: only the edit is written.&lt;/p&gt;
&lt;p&gt;The cross-reference section comes in two forms. The classic one is an &lt;code&gt;xref&lt;/code&gt; table followed by a &lt;code&gt;trailer&lt;/code&gt;. PDF 1.5 added cross-reference streams (&lt;code&gt;/Type /XRef&lt;/code&gt;), which usually come with object streams (&lt;code&gt;/Type /ObjStm&lt;/code&gt;): many small non-stream objects compressed together into one stream.&lt;/p&gt;
&lt;p&gt;We opened files for incremental update with pdf-lib (the @cantoo/pdf-lib fork, which supports incremental updates) and saved with the defaults, which turn object streams on. So whatever the original file used, our update ended in an XRef stream, with our new dictionaries packed into an object stream.&lt;/p&gt;
&lt;h4 id=&quot;what-pdfkit-does-with-that&quot;&gt;What PDFKit does with that&lt;/h4&gt;
&lt;p&gt;Take a one-page PDF that ends in a classic &lt;code&gt;xref&lt;/code&gt; table and append a highlighter stroke: a yellow rectangle drawn through an ExtGState with 60% opacity and the Multiply blend mode. Written as a table-based update, it renders the same everywhere. Written as an XRef stream with the ExtGState inside an object stream, poppler still draws it correctly, but PDFKit shows an opaque yellow bar where the sentence was.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://raw.githubusercontent.com/ppop123/pdfkit-pitfalls/main/xref-mismatch.png&quot; alt=&quot;The same highlight, appended four ways, rendered by Apple PDFKit and by poppler&quot;&gt;&lt;/p&gt;
&lt;p&gt;Render it with &lt;code&gt;CG_PDF_VERBOSE=1&lt;/code&gt; set, and CoreGraphics says why:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-plaintext&quot;&gt;encountered unexpected object type: 7.
missing or invalid object number.
missing or invalid cross-reference stream.
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;It gives up on the update's XRef stream. Objects the update wrote out on their own are still found: the rectangle itself is drawn. Objects inside the update's object stream are not, and the ExtGState is one of them. Without it the rectangle is painted fully opaque, on top of the text.&lt;/p&gt;
&lt;p&gt;The reverse breaks too. Over an original that ends in an XRef stream, an update written with a classic table gets:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-plaintext&quot;&gt;failed to find start of cross-reference section.
font `Helvetica-7098480789' not found in document.
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This time it's the original's object stream that gets lost, and the font was in it. The highlight is there; the sentence is gone.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Original's last section &lt;/th&gt;&lt;th&gt;Update written as &lt;/th&gt;&lt;th&gt;PDFKit&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;xref&lt;/code&gt; table &lt;/td&gt;&lt;td&gt;&lt;code&gt;xref&lt;/code&gt; table &lt;/td&gt;&lt;td&gt;fine&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;xref&lt;/code&gt; table &lt;/td&gt;&lt;td&gt;XRef stream with object streams &lt;/td&gt;&lt;td&gt;loses the objects in the update's object stream&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;XRef stream &lt;/td&gt;&lt;td&gt;&lt;code&gt;xref&lt;/code&gt; table &lt;/td&gt;&lt;td&gt;loses the objects in the original's object streams&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;XRef stream &lt;/td&gt;&lt;td&gt;XRef stream &lt;/td&gt;&lt;td&gt;fine&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;In our editor's checks, pdf.js, pdfium and poppler accepted all four combinations. That's exactly why a Linux-only test suite never caught it.&lt;/p&gt;
&lt;h4 id=&quot;the-fix&quot;&gt;The fix&lt;/h4&gt;
&lt;p&gt;Look at where the original's last &lt;code&gt;startxref&lt;/code&gt; points. If it lands on the keyword &lt;code&gt;xref&lt;/code&gt;, the last section is a table; otherwise it's a stream. Then write the update in the same form:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;// The form of the original's last cross-reference section: 'table' or 'stream'.
function xrefForm(bytes) {
  const tail = new TextDecoder('latin1').decode(bytes.subarray(Math.max(0, bytes.length - 2048)));
  const found = [...tail.matchAll(/startxref\s+(\d+)/g)];
  const offset = found.length ? Number(found[found.length - 1][1]) : NaN;
  if (!(offset &amp;gt;= 0 &amp;amp;&amp;amp; offset &amp;lt; bytes.length)) return 'table';
  let i = offset;
  while (i &amp;lt; bytes.length &amp;amp;&amp;amp; /\s/.test(String.fromCharCode(bytes[i]))) i += 1;
  return new TextDecoder('latin1').decode(bytes.subarray(i, i + 4)) === 'xref' ? 'table' : 'stream';
}

const doc = await PDFDocument.load(bytes, { forIncrementalUpdate: true });
// … edit …
const saved = await doc.save({ useObjectStreams: xrefForm(bytes) === 'stream' });
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Hybrid-reference files, whose trailer is a table that also points to an XRef stream, end in a table, so they count as &lt;code&gt;'table'&lt;/code&gt; here.&lt;/p&gt;
&lt;h3 id=&quot;gotcha-2-pdfkit-draws-filled-text-fields-itself&quot;&gt;Gotcha 2: PDFKit draws filled text fields itself&lt;/h3&gt;
&lt;h4 id=&quot;what-pdfkit-does&quot;&gt;What PDFKit does&lt;/h4&gt;
&lt;p&gt;A form field's look is supposed to come from its appearance stream (&lt;code&gt;/AP&lt;/code&gt;): a small content stream in which the writer draws the value. For a text field that has a value, PDFKit does something else. It draws the value (&lt;code&gt;/V&lt;/code&gt;) itself with CoreText, in the font named by the field's default appearance string (&lt;code&gt;/DA&lt;/code&gt;), and it drops the text-showing operators (&lt;code&gt;BT … Tj … ET&lt;/code&gt;) from the appearance stream. Everything else in the appearance stream it still paints, such as paths and Form XObjects.&lt;/p&gt;
&lt;p&gt;You can see the first half even with pdf-lib's own appearance. In the first row below, poppler draws pdf-lib's auto-sized Helvetica, while PDFKit ignores it and draws its own rendering of the value at a different size.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://raw.githubusercontent.com/ppop123/pdfkit-pitfalls/main/field-double-draw.png&quot; alt=&quot;One filled text field with three kinds of appearance, rendered by Apple PDFKit and by poppler&quot;&gt;&lt;/p&gt;
&lt;h4 id=&quot;why-that-bit-us&quot;&gt;Why that bit us&lt;/h4&gt;
&lt;p&gt;Our text engine shapes text itself (joining Persian and Arabic, ordering mixed-direction text, falling back between fonts) and draws the result as glyph outlines, so every reader shows exactly the same glyphs. In a field's appearance, those outlines are paths. PDFKit painted the paths and then drew &lt;code&gt;/V&lt;/code&gt; again with CoreText on top: two copies of every answer, as in the second row. On a real IRS W-9 the two copies didn't even line up, because PDFKit left-aligned its copy while our appearance was right-aligned.&lt;/p&gt;
&lt;p&gt;None of these stopped the second drawing: making the field read-only, setting &lt;code&gt;/NeedAppearances&lt;/code&gt; to false, removing the &lt;code&gt;/Tx BMC … EMC&lt;/code&gt; marked content, or changing the font or color in &lt;code&gt;/DA&lt;/code&gt;.&lt;/p&gt;
&lt;h4 id=&quot;the-fix-1&quot;&gt;The fix&lt;/h4&gt;
&lt;p&gt;Write the value as text after all, just not in a real font: use a Type 3 font whose glyph procedures are the same outlines. PDFKit drops text-showing operators, Type 3 included, and draws the value once in its own rendering. pdf.js, pdfium and poppler draw the Type 3 glyphs, which are our outlines. That's the third row.&lt;/p&gt;
&lt;p&gt;The trade-off is that in Preview you get PDFKit's rendering of the value (its font from &lt;code&gt;/DA&lt;/code&gt;, its alignment), not yours. For us that was acceptable, since CoreText shapes Persian correctly.&lt;/p&gt;
&lt;p&gt;Two related notes:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Dropdowns and list boxes are different.&lt;/strong&gt; PDFKit doesn't redraw them, so for choice fields we kept the outlines.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Acrobat had its own problem with outlines.&lt;/strong&gt; Acrobat Reader drew our outline appearances in some forms but left the box blank in others (fields created by pdf-lib, with a &lt;code&gt;/MK&lt;/code&gt; background color). It drew the Type 3 version in both.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;bonus-gotcha-from-acrobat-reader-extensions-ur3-&quot;&gt;Bonus gotcha from Acrobat: Reader extensions (&lt;code&gt;/UR3&lt;/code&gt;)&lt;/h3&gt;
&lt;p&gt;Many official forms, the W-9 among them, carry usage rights (&lt;code&gt;/Perms /UR3&lt;/code&gt;, &quot;Reader extensions&quot;): a signature over the file as issued. Change the file and Acrobat Reader opens it with:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;This document enabled extended features in Adobe Acrobat Reader. The document has been changed since it was created and use of extended features is no longer available.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Every save changes the file, so on save we now remove &lt;code&gt;/UR3&lt;/code&gt; and the older &lt;code&gt;/UR&lt;/code&gt; from &lt;code&gt;/Perms&lt;/code&gt; (and &lt;code&gt;/Perms&lt;/code&gt; itself if nothing else is left). A certification signature (&lt;code&gt;/DocMDP&lt;/code&gt;) stays. iText does the same thing with &lt;code&gt;removeUsageRights&lt;/code&gt;. To be clear about what we've checked: we saw the warning in Acrobat Reader, and we haven't re-tested the fixed files there yet.&lt;/p&gt;
&lt;h3 id=&quot;how-to-test-with-pdfkit-without-clicking-through-preview&quot;&gt;How to test with PDFKit without clicking through Preview&lt;/h3&gt;
&lt;p&gt;PDFKit is scriptable from Swift in about twenty lines: open the file with &lt;code&gt;PDFDocument&lt;/code&gt;, draw page 1 into a bitmap with &lt;code&gt;PDFPage.draw(with:to:)&lt;/code&gt; (form widgets included), and write a PNG. Run it with &lt;code&gt;CG_PDF_VERBOSE=1&lt;/code&gt; and CoreGraphics prints what it didn't like. The repo's &lt;code&gt;render-pdfkit.swift&lt;/code&gt; does exactly that. &lt;code&gt;qlmanage -t&lt;/code&gt;, the Quick Look thumbnailer, goes through PDFKit too.&lt;/p&gt;
&lt;p&gt;If you compare against pdfium from Python: pypdfium2 doesn't draw form fields unless you call &lt;code&gt;pdf.init_forms()&lt;/code&gt; first. Without it, every field looks empty, which is easy to mistake for a bug in your writer.&lt;/p&gt;
&lt;h3 id=&quot;takeaways&quot;&gt;Takeaways&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;Three renderers agreeing on a file doesn't mean the viewer on every Mac and iPhone will. If your PDFs reach Apple devices, render them with PDFKit, in CI on a macOS runner or at least by hand.&lt;/li&gt;
&lt;li&gt;In an incremental update, match the cross-reference form of the file you're updating.&lt;/li&gt;
&lt;li&gt;For a filled text field, PDFKit draws the value itself. Avoid drawing the value into the appearance as paths; text in a Type 3 font works everywhere we tested.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The reproductions, in Node and Swift, are MIT-licensed: &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/ppop123/pdfkit-pitfalls&quot;&gt;github.com/ppop123/pdfkit-pitfalls&lt;/a&gt;.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;About the author: the team behind this post builds &lt;a rel=&quot;nofollow&quot; href=&quot;https://lensup.ai/&quot;&gt;LensUp Scanner&lt;/a&gt;, a free set of browser tools for scanning documents and working with PDFs. If you have seen other PDFKit quirks, the &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/ppop123/pdfkit-pitfalls&quot;&gt;pdfkit-pitfalls repo&lt;/a&gt; is open to issues and pull requests.&lt;/em&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/32435/fine-in-chrome-broken-in-preview-two-pdfkit-gotchas-for-anyone-who-writes-pdfs</guid>
<pubDate>Mon, 26 Oct 2026 00:16:01 +0000</pubDate>
</item>
<item>
<title>The Sending Side of APNs: A Mac Push Tester with Keychain Certificates and URLSession Client Auth</title>
<link>https://coderlegion.com/32175/the-sending-side-apns-mac-push-tester-with-keychain-certificates-urlsession-client-auth</link>
<description>&lt;p&gt;&lt;em&gt;Originally published at &lt;a rel=&quot;nofollow&quot; href=&quot;https://bestlatestapps.com/blog/the-sending-side-of-apns/&quot;&gt;https://bestlatestapps.com/blog/the-sending-side-of-apns/&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Finding push certificates in the Keychain, importing .p12 files, answering APNs's client-certificate challenge in URLSession, building the exact request (path, headers, payload), sandbox vs production, silent pushes, and reading APNs's error responses, all without a server&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://bestlatestapps.com/assets/blog/the-sending-side-of-apns/push-notification-tester-icon-512.jpg&quot; alt=&quot;Push Notification&quot;&gt;&lt;/p&gt;
&lt;p&gt;In my article on &lt;strong&gt;Push Notification – Client&lt;/strong&gt;, I covered the device side of APNs: permission, registration, device tokens and showing what arrives. This is the other end of the pipe.&lt;/p&gt;
&lt;p&gt;When you're building push notifications, the slowest part of the loop is often the sending side. The backend isn't ready. Or it's owned by another team, or every test means deploying a change to a server just to see whether a payload renders. &lt;strong&gt;Push Notification – Tester&lt;/strong&gt; is my Mac app for skipping all of that. You choose a push certificate from Keychain, paste a device token, write a JSON payload, pick Development (sandbox) or Production, and send. There's no server dependency.&lt;/p&gt;
&lt;p&gt;This article shows how a tool like that works under the hood. It's all standard Apple API: the Security framework to find identities, &lt;code&gt;URLSession&lt;/code&gt; to authenticate with a client certificate over HTTP/2, and Apple's documented APNs request format. The protocol facts are taken from Apple's APNs documentation. Where I describe how I'd build something rather than the shipped app, I say so.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;1-two-ways-to-authenticate-with-apns-and-why-a-tester-uses-certificates&quot;&gt;1. Two ways to authenticate with APNs, and why a tester uses certificates&lt;/h3&gt;
&lt;p&gt;APNs accepts two kinds of provider authentication:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Token-based&lt;/strong&gt; (a &lt;code&gt;.p8&lt;/code&gt; signing key): you sign a short-lived JWT and send it in an &lt;code&gt;authorization: bearer …&lt;/code&gt; header. One key works for all your apps.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Certificate-based&lt;/strong&gt; (a push certificate, often exported as &lt;code&gt;.p12&lt;/code&gt;): the TLS connection itself proves who you are, using a client certificate.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Apple's docs show both. For a desktop testing tool, certificates have a practical advantage: developers already have them in their &lt;strong&gt;Keychain&lt;/strong&gt;, and the whole authentication step happens in the TLS handshake. Nothing needs to be signed per request.&lt;/p&gt;
&lt;p&gt;One consequence, straight from Apple's documentation of the request headers: the &lt;code&gt;authorization: bearer &amp;lt;provider_token&amp;gt;&lt;/code&gt; header is for token auth only, and &quot;APNs ignores this header if you use certificate-based authentication.&quot; &lt;code&gt;apns-topic&lt;/code&gt; is still required: &quot;In general, the topic is your app's bundle ID/app ID.&quot; Keep the two modes clearly separate in your code.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;2-finding-push-identities-in-the-keychain&quot;&gt;2. Finding push identities in the Keychain&lt;/h3&gt;
&lt;p&gt;An &lt;strong&gt;identity&lt;/strong&gt; in Keychain terms is a certificate plus its private key, which is what TLS client auth needs. &lt;code&gt;SecItemCopyMatching&lt;/code&gt; with &lt;code&gt;kSecClassIdentity&lt;/code&gt; lists them. I'd filter to push certificates by their subject summary, which for APNs certificates names the push service and your bundle ID:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;import Security
import Foundation

struct PushIdentity: Identifiable, Hashable {
    let id: String              // subject summary, also used as the display name
    let identity: SecIdentity
    var isProbablyPush: Bool { id.localizedCaseInsensitiveContains(&quot;Push&quot;) }
}

enum KeychainIdentities {
    static func all() -&amp;gt; [PushIdentity] {
        let query: [String: Any] = [
            kSecClass as String: kSecClassIdentity,
            kSecReturnRef as String: true,
            kSecMatchLimit as String: kSecMatchLimitAll
        ]
        var result: CFTypeRef?
        guard SecItemCopyMatching(query as CFDictionary, &amp;amp;result) == errSecSuccess,
              let identities = result as? [SecIdentity] else { return [] }

        return identities.compactMap { identity in
            var cert: SecCertificate?
            guard SecIdentityCopyCertificate(identity, &amp;amp;cert) == errSecSuccess, let cert,
                  let summary = SecCertificateCopySubjectSummary(cert) as String? else { return nil }
            return PushIdentity(id: summary, identity: identity)
        }
        .sorted { ($0.isProbablyPush ? 0 : 1, $0.id) &amp;lt; ($1.isProbablyPush ? 0 : 1, $1.id) }
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Sorting push certificates to the top and keeping the others is deliberate. Names vary by certificate type and era, and a tester that hides the one certificate you need is worse than one that shows a few extras.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;3-importing-a-p12-file&quot;&gt;3. Importing a .p12 file&lt;/h3&gt;
&lt;p&gt;The listing mentions &quot;.p12 / .pem workflows&quot;. For a &lt;code&gt;.p12&lt;/code&gt; (PKCS #12), the Security framework does the parsing. &lt;code&gt;SecPKCS12Import&lt;/code&gt; returns the identity inside, given the export password:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;enum P12Import {
    enum Failure: Error { case wrongPassword, noIdentity, status(OSStatus) }

    static func identity(from data: Data, password: String) throws -&amp;gt; SecIdentity {
        let options = [kSecImportExportPassphrase as String: password] as CFDictionary
        var items: CFArray?
        let status = SecPKCS12Import(data as CFData, options, &amp;amp;items)
        switch status {
        case errSecSuccess: break
        case errSecAuthFailed: throw Failure.wrongPassword
        default: throw Failure.status(status)
        }
        guard let first = (items as? [[String: Any]])?.first,
              let identity = first[kSecImportItemIdentity as String] else { throw Failure.noIdentity }
        return identity as! SecIdentity      // CF type bridging: the dictionary value is a SecIdentity
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A clear &quot;wrong password&quot; message matters more than it looks. It's by far the most common failure when importing a &lt;code&gt;.p12&lt;/code&gt;, and a generic error code makes people think the certificate itself is broken.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;.pem&lt;/code&gt; files hold the certificate and key as text. The approach I'd recommend is to convert them to &lt;code&gt;.p12&lt;/code&gt; with &lt;code&gt;openssl pkcs12 -export&lt;/code&gt; and import that. The import path then stays the same either way.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;4-answering-the-client-certificate-challenge-in-urlsession&quot;&gt;4. Answering the client-certificate challenge in URLSession&lt;/h3&gt;
&lt;p&gt;When &lt;code&gt;URLSession&lt;/code&gt; connects to APNs, the server asks for a client certificate. You answer in a delegate method by building a &lt;code&gt;URLCredential&lt;/code&gt; from the identity:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;import Foundation

final class APNsClient: NSObject, URLSessionDelegate {
    private let identity: SecIdentity
    private lazy var session = URLSession(configuration: .ephemeral, delegate: self, delegateQueue: nil)

    init(identity: SecIdentity) { self.identity = identity }

    func urlSession(_ session: URLSession, didReceive challenge: URLAuthenticationChallenge,
                    completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -&amp;gt; Void) {
        switch challenge.protectionSpace.authenticationMethod {
        case NSURLAuthenticationMethodClientCertificate:
            let credential = URLCredential(identity: identity, certificates: nil, persistence: .forSession)
            completionHandler(.useCredential, credential)
        default:
            // Server trust etc.: let the system evaluate Apple's certificate normally.
            completionHandler(.performDefaultHandling, nil)
        }
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Two notes:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Use a session-level delegate.&lt;/strong&gt; The client-certificate challenge is a connection-level challenge, delivered to &lt;code&gt;urlSession(_:didReceive:completionHandler:)&lt;/code&gt;. There's also a task-level variant; implement the session one at minimum.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Don't override server trust.&lt;/strong&gt; &lt;code&gt;.performDefaultHandling&lt;/code&gt; for everything else means the system still validates Apple's server certificate. A testing tool is no excuse to disable TLS validation.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;code&gt;URLSession&lt;/code&gt; negotiates &lt;strong&gt;HTTP/2&lt;/strong&gt; automatically with servers that support it, and APNs requires HTTP/2 and TLS 1.2 or later. You don't need a custom HTTP/2 library.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;5-building-the-request-path-headers-payload&quot;&gt;5. Building the request: path, headers, payload&lt;/h3&gt;
&lt;p&gt;Apple's documentation spells out the request precisely: a &lt;code&gt;POST&lt;/code&gt; to &lt;code&gt;/3/device/&amp;lt;device token&amp;gt;&lt;/code&gt; on one of two hosts. I'd model it directly:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;enum APNsEnvironment: String, CaseIterable {
    case development, production
    var host: String {
        switch self {
        case .development: &quot;api.sandbox.push.apple.com&quot;
        case .production:  &quot;api.push.apple.com&quot;
        }
    }
}

enum PushType: String, CaseIterable { case alert, background }

struct PushRequest {
    var environment: APNsEnvironment
    var deviceToken: String
    var topic: String                 // usually your bundle ID
    var pushType: PushType
    var payloadJSON: String

    func urlRequest() throws -&amp;gt; URLRequest {
        let token = deviceToken.filter(\.isHexDigit).lowercased()   // tolerate pasted spaces and &amp;lt;&amp;gt;
        var request = URLRequest(url: URL(string: &quot;https://\(environment.host)/3/device/\(token)&quot;)!)
        request.httpMethod = &quot;POST&quot;
        request.setValue(topic, forHTTPHeaderField: &quot;apns-topic&quot;)
        request.setValue(pushType.rawValue, forHTTPHeaderField: &quot;apns-push-type&quot;)
        request.setValue(pushType == .background ? &quot;5&quot; : &quot;10&quot;, forHTTPHeaderField: &quot;apns-priority&quot;)
        request.setValue(UUID().uuidString.lowercased(), forHTTPHeaderField: &quot;apns-id&quot;)

        let body = Data(payloadJSON.utf8)
        _ = try JSONSerialization.jsonObject(with: body)          // fail fast on invalid JSON
        guard body.count &amp;lt;= 4096 else { throw PushError.payloadTooLarge(body.count) }
        request.httpBody = body
        return request
    }
}

enum PushError: Error { case payloadTooLarge(Int) }
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Each line maps to Apple's documentation:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Hosts.&lt;/strong&gt; &lt;code&gt;api.sandbox.push.apple.com&lt;/code&gt; is the development server and &lt;code&gt;api.push.apple.com&lt;/code&gt; is production. A device token from a debug build only works against the sandbox, which is the single most common &quot;my push never arrives&quot; cause.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;apns-push-type&lt;/code&gt;.&lt;/strong&gt; It must &quot;accurately reflect the contents of your notification's payload&quot;. It's required for watchOS and recommended for iOS, macOS, tvOS and iPadOS.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Payload size.&lt;/strong&gt; Apple's docs say it's limited to &quot;a maximum size of 4 KB (4096 bytes)&quot;, and must not be compressed. Checking it locally saves a round trip and a &lt;code&gt;413&lt;/code&gt; response.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;apns-id&lt;/code&gt;.&lt;/strong&gt; It's optional, but setting your own UUID makes it easy to match a response to a request in your log.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h3 id=&quot;6-alerts-badges-sounds-and-silent-pushes&quot;&gt;6. Alerts, badges, sounds and silent pushes&lt;/h3&gt;
&lt;p&gt;The listing promises &quot;Custom JSON payloads for alerts, badges, sounds, and silent content&quot;. I'd offer templates for each, since the shape of &lt;code&gt;aps&lt;/code&gt; is where most mistakes happen:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;enum PayloadTemplate: String, CaseIterable {
    case alert, badge, sound, silent

    var json: String {
        switch self {
        case .alert:  #&quot;{ &quot;aps&quot;: { &quot;alert&quot;: { &quot;title&quot;: &quot;Hello&quot;, &quot;body&quot;: &quot;From my Mac&quot; } } }&quot;#
        case .badge:  #&quot;{ &quot;aps&quot;: { &quot;badge&quot;: 3 } }&quot;#
        case .sound:  #&quot;{ &quot;aps&quot;: { &quot;alert&quot;: &quot;Ding&quot;, &quot;sound&quot;: &quot;default&quot; } }&quot;#
        case .silent: #&quot;{ &quot;aps&quot;: { &quot;content-available&quot;: 1 }, &quot;refresh&quot;: &quot;inbox&quot; }&quot;#
        }
    }
    var pushType: PushType { self == .silent ? .background : .alert }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The silent template follows Apple's &quot;Pushing background updates to your app&quot; guide: the &lt;code&gt;aps&lt;/code&gt; dictionary contains &lt;strong&gt;only&lt;/strong&gt; &lt;code&gt;content-available&lt;/code&gt;, with custom keys alongside it, and the request uses &lt;code&gt;apns-push-type: background&lt;/code&gt; with &lt;code&gt;apns-priority: 5&lt;/code&gt;. The same guide warns that the system treats background notifications as low priority and &quot;doesn't guarantee their delivery&quot;, and may throttle them. A tester should say that in the UI, so nobody spends an afternoon debugging a throttled silent push.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;7-sending-and-reading-apns-s-answer&quot;&gt;7. Sending, and reading APNs's answer&lt;/h3&gt;
&lt;p&gt;APNs replies with an HTTP status and, on failure, a small JSON body with a &lt;code&gt;reason&lt;/code&gt;. Surfacing that reason verbatim is the most useful thing a tester does:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;extension APNsClient {
    struct Outcome { let status: Int; let apnsID: String?; let reason: String? }

    func send(_ push: PushRequest) async throws -&amp;gt; Outcome {
        let (data, response) = try await session.data(for: try push.urlRequest())
        let http = response as! HTTPURLResponse
        let reason = (try? JSONSerialization.jsonObject(with: data) as? [String: Any])?[&quot;reason&quot;] as? String
        return Outcome(status: http.statusCode,
                       apnsID: http.value(forHTTPHeaderField: &quot;apns-id&quot;),
                       reason: reason)
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;And a human explanation for the common reasons, taken from Apple's &quot;Handling notification responses from APNs&quot;:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Status / reason &lt;/th&gt;&lt;th&gt;What it means in practice&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;200&lt;/code&gt; &lt;/td&gt;&lt;td&gt;Accepted by APNs. Delivery to the device is a separate step.&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;400 BadDeviceToken&lt;/code&gt; &lt;/td&gt;&lt;td&gt;The token is invalid. Usually a sandbox token sent to production, or the reverse; Apple says to check &quot;that the token matches the environment&quot;.&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;403 BadCertificate&lt;/code&gt; / &lt;code&gt;BadCertificateEnvironment&lt;/code&gt; &lt;/td&gt;&lt;td&gt;The certificate is invalid, or doesn't match the server you chose.&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;410 Unregistered&lt;/code&gt; / &lt;code&gt;ExpiredToken&lt;/code&gt; &lt;/td&gt;&lt;td&gt;&quot;The device token is inactive for the specified topic&quot;, or has expired. Typically the app was deleted or the token changed; stop sending to it.&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;413 PayloadTooLarge&lt;/code&gt; &lt;/td&gt;&lt;td&gt;Over 4 KB.&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;429 TooManyRequests&lt;/code&gt; &lt;/td&gt;&lt;td&gt;Too many pushes to the same token. Apple says you can retry with a delay.&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Apple's guidance is explicit that you shouldn't retry &lt;code&gt;BadDeviceToken&lt;/code&gt;, &lt;code&gt;DeviceTokenNotForTopic&lt;/code&gt;, &lt;code&gt;Forbidden&lt;/code&gt;, &lt;code&gt;ExpiredToken&lt;/code&gt;, &lt;code&gt;Unregistered&lt;/code&gt; or &lt;code&gt;PayloadTooLarge&lt;/code&gt;. A tester can turn that into a &quot;fix this first&quot; hint instead of a Retry button.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;8-real-world-example-push-notification---tester&quot;&gt;8. Real-world example: Push Notification - Tester&lt;/h3&gt;
&lt;p&gt;This is the approach behind &lt;strong&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://apps.apple.com/us/app/push-notification-tester/id1516154713&quot;&gt;Push Notification - Tester&lt;/a&gt;&lt;/strong&gt;. The code above is written for this article, not the app's source.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://bestlatestapps.com/assets/blog/the-sending-side-of-apns/push-notification-tester-screenshot-1.jpg&quot; alt=&quot;Push Notification&quot;&gt;&lt;/p&gt;
&lt;p&gt;At the time of writing, its Mac App Store listing shows:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Free&lt;/strong&gt;, in &lt;strong&gt;Developer Tools&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;First released in &lt;strong&gt;June 2020&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;No ratings yet in the US store&lt;/li&gt;
&lt;li&gt;Requires &lt;strong&gt;macOS 14.6 or later&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;How the listing maps to this article:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;What the listing says &lt;/th&gt;&lt;th&gt;Where it's covered&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;&quot;Select the push certificate directly from Keychain (.p12 / .pem workflows)&quot; &lt;/td&gt;&lt;td&gt;Keychain identities and PKCS #12 import (sections 2–3)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&quot;Send APNs pushes from your Mac with no server dependency&quot; &lt;/td&gt;&lt;td&gt;&lt;code&gt;URLSession&lt;/code&gt; client-certificate auth over HTTP/2 (section 4)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&quot;Paste any device token and send in seconds&quot; &lt;/td&gt;&lt;td&gt;Token clean-up and the request path (section 5)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&quot;Target Development (sandbox) or Production environments&quot; &lt;/td&gt;&lt;td&gt;The two APNs hosts (section 5)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&quot;Custom JSON payloads for alerts, badges, sounds, and silent content&quot; &lt;/td&gt;&lt;td&gt;Payload templates and silent-push rules (section 6)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&quot;Perfect for verifying device tokens, validating certificates…&quot; &lt;/td&gt;&lt;td&gt;Reading APNs's status and reason (section 7)&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;&lt;img src=&quot;https://bestlatestapps.com/assets/blog/the-sending-side-of-apns/push-notification-tester-screenshot-2.jpg&quot; alt=&quot;Push Notification&quot;&gt;&lt;/p&gt;
&lt;p&gt;Pair it with its iOS sibling, &lt;strong&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://apps.apple.com/us/app/push-notification-client/id1516378834&quot;&gt;Push Notification – Client&lt;/a&gt;&lt;/strong&gt;, which shows the device token to paste and displays what arrives. Between the two, you can test both ends without a backend.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;9-before-you-trust-a-push-test-a-checklist&quot;&gt;9. Before you trust a push test: a checklist&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;The environment matches the build.&lt;/strong&gt; Debug and development builds use the sandbox; TestFlight and App Store builds use production.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The topic is the bundle ID&lt;/strong&gt; the certificate was issued for.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;apns-push-type&lt;/code&gt; matches the payload&lt;/strong&gt;: &lt;code&gt;alert&lt;/code&gt; for visible pushes, &lt;code&gt;background&lt;/code&gt; (priority 5) for silent ones.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The payload is valid JSON, under 4,096 bytes&lt;/strong&gt;, and not compressed.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Server trust is never disabled.&lt;/strong&gt; Only answer the client-certificate challenge.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The &lt;code&gt;reason&lt;/code&gt; string is read&lt;/strong&gt;, and non-retryable errors aren't retried.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Silent pushes aren't expected to be guaranteed.&lt;/strong&gt; Throttling is normal.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Certificates expire.&lt;/strong&gt; Check the expiry date when you pick an identity.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;For production backends&lt;/strong&gt;, consider token-based (&lt;code&gt;.p8&lt;/code&gt;) auth. Certificates are handy for testing, but tokens don't need yearly renewal.&lt;/li&gt;
&lt;/ol&gt;
&lt;hr&gt;
&lt;h3 id=&quot;wrapping-up&quot;&gt;Wrapping up&lt;/h3&gt;
&lt;p&gt;Sending a push is less magic than it seems: an identity from the Keychain, a credential in a &lt;code&gt;URLSession&lt;/code&gt; delegate, and a &lt;code&gt;POST&lt;/code&gt; to &lt;code&gt;/3/device/&amp;lt;token&amp;gt;&lt;/code&gt; with four headers and a small JSON body. A good tester makes each step visible and turns APNs's terse reasons into plain advice, so the next time a push &quot;doesn't arrive&quot;, you know within a minute whether it's the token, the environment, the certificate or the payload.&lt;/p&gt;
&lt;p&gt;Try the app, or explore my other work:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Push Notification - Tester (Mac):&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://apps.apple.com/us/app/push-notification-tester/id1516154713&quot;&gt;https://apps.apple.com/us/app/push-notification-tester/id1516154713&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Push Notification – Client (iOS):&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://apps.apple.com/us/app/push-notification-client/id1516378834&quot;&gt;https://apps.apple.com/us/app/push-notification-client/id1516378834&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;All of my apps:&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://apps.apple.com/us/developer/alok-singh/id1508795536&quot;&gt;https://apps.apple.com/us/developer/alok-singh/id1508795536&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;My portfolio:&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://bestlatestapps.com/alok/&quot;&gt;https://bestlatestapps.com/alok/&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;What's the strangest push-delivery bug you've chased? Share it in the comments.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;— Alok Kumar Singh&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Originally published at &lt;a rel=&quot;nofollow&quot; href=&quot;https://bestlatestapps.com/blog/the-sending-side-of-apns/&quot;&gt;https://bestlatestapps.com/blog/the-sending-side-of-apns/&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/32175/the-sending-side-apns-mac-push-tester-with-keychain-certificates-urlsession-client-auth</guid>
<pubDate>Fri, 23 Oct 2026 12:31:01 +0000</pubDate>
</item>
<item>
<title>Best Mac mini for OpenClaw in 2026: Memory, Local Models and Prices</title>
<link>https://coderlegion.com/32047/best-mac-mini-for-openclaw-in-2026-memory-local-models-and-prices</link>
<description>&lt;p&gt;&lt;em&gt;Originally published on the &lt;a rel=&quot;nofollow&quot; href=&quot;https://macyou.co/blog/best-mac-mini-for-openclaw&quot;&gt;Macyou blog&lt;/a&gt;. Disclosure up front: I run Macyou, we rent dedicated Apple Silicon Macs. This post is about the hardware math for OpenClaw, which is the same whether the Mac sits on your desk or in a rack.&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;If OpenClaw talks to a hosted model such as Claude, GPT or Gemini, any Mac mini Apple sells today is enough, the base M6 with 16 GB included. Memory starts to matter only when the model runs on the Mac itself, and then it decides almost everything: which model fits, how much context it gets, and how fast it answers.&lt;/p&gt;
&lt;p&gt;This post runs the numbers for every Mac mini configuration at the context length an agent actually needs, with US list prices after the M6 launch.&lt;/p&gt;
&lt;h3 id=&quot;with-a-hosted-model-any-mac-mini-will-do&quot;&gt;With a hosted model, any Mac mini will do&lt;/h3&gt;
&lt;p&gt;OpenClaw is a gateway. It holds your chat channels, tools, memory files and a browser for web tasks, and sends each turn to the model. With a hosted model the heavy work happens on the provider's servers, so the Mac only runs the gateway, a browser and macOS. The OpenClaw docs set an 8 GiB memory floor for their smallest &lt;em&gt;local&lt;/em&gt; model recipe; a hosted model takes that load off the Mac entirely, so 16 GB leaves plenty of room.&lt;/p&gt;
&lt;p&gt;If that is your setup, buy the cheapest Mac mini and stop reading. The rest is about local models.&lt;/p&gt;
&lt;h3 id=&quot;with-a-local-model-memory-decides&quot;&gt;With a local model, memory decides&lt;/h3&gt;
&lt;p&gt;An agent turn is not a chat message. OpenClaw sends its instructions, the tool list, memory and the conversation with every turn, so the model needs a long context window, and that context takes memory on top of the weights.&lt;/p&gt;
&lt;p&gt;Two lines in the &lt;a rel=&quot;nofollow&quot; href=&quot;https://docs.openclaw.ai/gateway/local-models&quot;&gt;OpenClaw local models docs&lt;/a&gt; set the frame:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;the managed llama.cpp recipes use a &lt;strong&gt;64K context&lt;/strong&gt;;&lt;/li&gt;
&lt;li&gt;the docs advise against small or heavily quantized model variants.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;So the question is not &quot;does the model fit&quot;, it is &quot;does the model fit with 64K of context and room for macOS&quot;.&lt;/p&gt;
&lt;h4 id=&quot;the-arithmetic&quot;&gt;The arithmetic&lt;/h4&gt;
&lt;p&gt;Weights in GB are parameters times bits per weight divided by 8. At Q4_K_M that is about 4.85 bits. Add about 15% for the runtime, add the KV cache for the context, and leave 3 GB for macOS. Generation speed follows memory bandwidth, because every token streams the weights it uses through the memory bus. Mixture of experts models read only their active experts per token, which is why they run faster than their total size suggests.&lt;/p&gt;
&lt;p&gt;Here is the same arithmetic our &lt;a rel=&quot;nofollow&quot; href=&quot;https://macyou.co/mac-llm-calculator&quot;&gt;Mac LLM calculator&lt;/a&gt; uses, small enough to check by hand:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;# Memory and speed for a Q4_K_M model on Apple Silicon
BPW_Q4 = 4.85        # bits per weight for Q4_K_M, including GGUF overhead
RUNTIME = 1.15       # allocator and activations on top of the weights
OS_GB = 3            # left for macOS
EFF = 0.9075         # fitted on measured runs, base M4 and M5 Pro
PER_TOKEN = 0.00325  # fixed cost per token, seconds
MOE_READ = 1.3       # a mixture of experts model reads about 1.3x its active weights

def weights_gb(params_b, bpw=BPW_Q4):
    return params_b * bpw / 8

def needed_gb(params_b, kv_gb):
    return weights_gb(params_b) * RUNTIME + kv_gb + OS_GB

def tokens_per_second(read_gb, bandwidth_gbs):
    return 1 / (read_gb / (bandwidth_gbs * EFF) + PER_TOKEN)

# Qwen3.8 27B, dense, 64K context, M6 with 32 GB (170 GB/s)
print(round(needed_gb(27.8, kv_gb=4), 1))                  # 26.4 GB
print(round(tokens_per_second(weights_gb(27.8), 170), 1))  # 8.9 tok/s

# Qwen3.6 35B A3B, mixture of experts with 3B active, same Mac
print(round(tokens_per_second(weights_gb(3) * MOE_READ, 170)))  # 54 tok/s
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The constants were fitted on measured runs on a base M4 and an M5 Pro, where the worst error was 4.6%. We have not measured an M6 yet, so the M6 figures below are estimates. Measured numbers and the raw JSON are on our &lt;a rel=&quot;nofollow&quot; href=&quot;https://macyou.co/benchmarks&quot;&gt;benchmarks page&lt;/a&gt;.&lt;/p&gt;
&lt;h3 id=&quot;what-fits-on-each-mac-mini-at-a-64k-context&quot;&gt;What fits on each Mac mini at a 64K context&lt;/h3&gt;
&lt;p&gt;US list prices with the smallest SSD for each memory size (256 GB on the M6, 512 GB on the M5 Pro). Base prices from &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.macworld.com/article/3140334&quot;&gt;Macworld&lt;/a&gt;, memory upgrades from &lt;a rel=&quot;nofollow&quot; href=&quot;https://daringfireball.net/2026/08/configurations_and_pricing_for_new_mac_minis_and_mac_studios&quot;&gt;Daring Fireball&lt;/a&gt;, bandwidth from &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.apple.com/mac-mini/specs/&quot;&gt;Apple's specifications&lt;/a&gt;.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Mac mini &lt;/th&gt;&lt;th&gt;US price &lt;/th&gt;&lt;th&gt;Memory bandwidth &lt;/th&gt;&lt;th&gt;Fits at 64K, estimated tok/s&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;M6, 16 GB &lt;/td&gt;&lt;td&gt;$899 &lt;/td&gt;&lt;td&gt;153 GB/s &lt;/td&gt;&lt;td&gt;Llama 3.1 8B (26), Qwen 2.5 7B (27)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;M6, 24 GB &lt;/td&gt;&lt;td&gt;$1,099 &lt;/td&gt;&lt;td&gt;170 GB/s &lt;/td&gt;&lt;td&gt;Gemma 4 26B A4B (44), Qwen 2.5 14B (16)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;M6, 32 GB &lt;/td&gt;&lt;td&gt;$1,299 &lt;/td&gt;&lt;td&gt;170 GB/s &lt;/td&gt;&lt;td&gt;Qwen3.6 35B A3B (54), Qwen3.8 27B (9), Gemma 4 26B A4B (44)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;M5 Pro, 24 GB &lt;/td&gt;&lt;td&gt;$1,699 &lt;/td&gt;&lt;td&gt;307 GB/s &lt;/td&gt;&lt;td&gt;Gemma 4 26B A4B (71), Qwen 2.5 14B (28)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;M5 Pro, 48 GB &lt;/td&gt;&lt;td&gt;$2,299 &lt;/td&gt;&lt;td&gt;307 GB/s &lt;/td&gt;&lt;td&gt;Qwen3.6 35B A3B (85), Gemma 4 31B (14), Qwen3.8 27B (16)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;M5 Pro, 64 GB &lt;/td&gt;&lt;td&gt;$2,699 &lt;/td&gt;&lt;td&gt;307 GB/s &lt;/td&gt;&lt;td&gt;Llama 3.3 70B (6), plus everything above&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Note the price context: the M6 starts at $899, up from $599 for the M4 generation, and Macworld found no refurbished M4 units in Apple's store in October. A lot of &quot;Mac mini for OpenClaw&quot; advice was written for the $599 machine.&lt;/p&gt;
&lt;h4 id=&quot;which-one-to-buy&quot;&gt;Which one to buy&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Hosted model only:&lt;/strong&gt; the M6 with 16 GB, $899.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;A local model on a budget:&lt;/strong&gt; the M6 with 24 GB, $1,099, runs Gemma 4 26B A4B at about 44 tok/s.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The best value for a local agent:&lt;/strong&gt; the M6 with 32 GB, $1,299, adds Qwen3.6 35B A3B at about 54 tok/s and Qwen3.8 27B with the full 64K context.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Headroom and speed:&lt;/strong&gt; the M5 Pro with 48 GB, $2,299, reads memory at 307 GB/s, about 1.8 times the M6, fits Gemma 4 31B as well, and runs Qwen3.6 35B A3B at about 85 tok/s.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;the-wait-before-the-first-word&quot;&gt;The wait before the first word&lt;/h3&gt;
&lt;p&gt;Generation speed is only half of an agent turn. Before the first word, the Mac has to process the whole prompt. On a base M4 we measured prompt processing at 531 to 1,720 tok/s for models from 3B to 14B (Ollama, July 2026). At 600 tok/s, every 10,000 new tokens of instructions, tools and history add about 17 seconds before the reply starts.&lt;/p&gt;
&lt;p&gt;The &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/ggml-org/llama.cpp/tree/master/tools/server&quot;&gt;llama.cpp server&lt;/a&gt; keeps the part of the prompt it has already processed and evaluates only what changed since the previous request, so later turns cost less as long as the start of the prompt stays the same. Prompt caching is on by default; the context size is a flag:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;llama-server -m ./models/your-model-Q4_K_M.gguf -c 65536 --port 8080
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;mac-mini-or-mac-studio&quot;&gt;Mac mini or Mac Studio&lt;/h3&gt;
&lt;p&gt;The Mac mini stops at 64 GB. At the same 64K context, Qwen3.8 Flash Next needs about 131 GB and DeepSeek V4 Flash about 225 GB, which means a Mac Studio with an M5 Ultra and 256 GB. The M5 Max Mac Studio goes up to 128 GB.&lt;/p&gt;
&lt;h3 id=&quot;a-mac-mini-you-already-own&quot;&gt;A Mac mini you already own&lt;/h3&gt;
&lt;p&gt;An M1, M2 or M4 Mac mini runs OpenClaw with a hosted model as well as a new one. For local models the same memory rules apply at lower speed: Gemma 4 26B A4B comes out at about 33 tok/s on an M4 with 24 GB, against about 44 on an M6 with 24 GB.&lt;/p&gt;
&lt;h3 id=&quot;faq&quot;&gt;FAQ&lt;/h3&gt;
&lt;h4 id=&quot;is-16-gb-enough-for-openclaw-&quot;&gt;Is 16 GB enough for OpenClaw?&lt;/h4&gt;
&lt;p&gt;Yes with a hosted model. With a local model at a 64K context, 16 GB holds only 7B and 8B models, and 24 GB is where Gemma 4 26B A4B fits.&lt;/p&gt;
&lt;h4 id=&quot;how-much-memory-does-openclaw-need-&quot;&gt;How much memory does OpenClaw need?&lt;/h4&gt;
&lt;p&gt;The model decides, not the gateway. A hosted model needs no model memory on the Mac. A local model at a 64K context needs about 10 GB for Llama 3.1 8B and about 27 GB for Qwen3.6 35B A3B, plus room for macOS.&lt;/p&gt;
&lt;h4 id=&quot;mac-mini-m4-or-m6-for-openclaw-&quot;&gt;Mac mini M4 or M6 for OpenClaw?&lt;/h4&gt;
&lt;p&gt;With a hosted model the chip makes no practical difference. For local models the M6 reads memory faster, 153 GB/s with 16 GB and 170 GB/s with 24 or 32 GB, against 120 GB/s on the base M4.&lt;/p&gt;
&lt;h4 id=&quot;does-the-same-apply-to-hermes-agent-&quot;&gt;Does the same apply to Hermes Agent?&lt;/h4&gt;
&lt;p&gt;Yes. Hermes Agent from Nous Research installs on macOS, Linux and Windows and works with hosted models or your own endpoint. Pointed at a local model on a Mac mini, it needs the memory in the table above.&lt;/p&gt;
&lt;h3 id=&quot;conclusion&quot;&gt;Conclusion&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;With a &lt;strong&gt;hosted model&lt;/strong&gt;, any current Mac mini runs OpenClaw; the 16 GB M6 is enough.&lt;/li&gt;
&lt;li&gt;With a &lt;strong&gt;local model&lt;/strong&gt;, budget memory for the &lt;strong&gt;64K context&lt;/strong&gt;, not just the weights.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;24 GB&lt;/strong&gt; is the first size that fits Gemma 4 26B A4B at that context.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;32 GB&lt;/strong&gt; adds Qwen3.6 35B A3B and Qwen3.8 27B, the best value for a local agent.&lt;/li&gt;
&lt;li&gt;The &lt;strong&gt;M5 Pro with 48 GB&lt;/strong&gt; buys about 1.8 times the bandwidth of the M6.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Prompt processing&lt;/strong&gt; is the hidden wait in agent turns; keep the start of the prompt stable so the cache helps.&lt;/li&gt;
&lt;li&gt;Above 64 GB, the answer is a &lt;strong&gt;Mac Studio&lt;/strong&gt;.&lt;/li&gt;
&lt;/ul&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/32047/best-mac-mini-for-openclaw-in-2026-memory-local-models-and-prices</guid>
<pubDate>Thu, 22 Oct 2026 08:20:07 +0000</pubDate>
</item>
<item>
<title>Fake Zoom Mac Installer Skips Gatekeeper, Steals Data</title>
<link>https://coderlegion.com/30518/fake-zoom-mac-installer-skips-gatekeeper-steals-data</link>
<description>&lt;h3 id=&quot;what-happened-a-fake-zoom-installer-on-macos&quot;&gt;What Happened: A Fake Zoom Installer on macOS&lt;/h3&gt;
&lt;p&gt;Jamf, a leading provider of Apple‑focused security solutions, recently uncovered a malicious installer masquerading as the official Zoom client for macOS. The installer is distributed through unofficial channels and is deliberately crafted to look identical to Zoom’s legitimate download page. When a user runs the package, the malware performs a “double installation”:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Legitimate Zoom binary&lt;/strong&gt; – The real Zoom client is installed in the expected location, allowing the user to join meetings without suspicion. &lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Hidden infostealer&lt;/strong&gt; – A second payload is dropped alongside Zoom. This component silently monitors the system, captures credentials, browser cookies, and other sensitive data, then exfiltrates the information to a command‑and‑control (C2) server controlled by the attacker.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Jamf’s analysis notes that the installer “uses a sneaky way to bypass Apple’s Gatekeeper protection against malware.” By exploiting a weakness in the notarization process, the malicious package is allowed to run without triggering the usual warnings that macOS users rely on.&lt;/p&gt;
&lt;h3 id=&quot;technical-breakdown-of-the-gatekeeper-bypass&quot;&gt;Technical Breakdown of the Gatekeeper Bypass&lt;/h3&gt;
&lt;p&gt;Gatekeeper is Apple’s first line of defense against unsigned or malicious software. It checks that an app is notarized by Apple and that its code signature matches the published hash. The fake Zoom installer sidesteps these checks through a combination of techniques:&lt;/p&gt;
&lt;h4 id=&quot;1-dual-package-wrapper&quot;&gt;1. Dual‑Package Wrapper&lt;/h4&gt;
&lt;p&gt;The malicious DMG contains two separate installer bundles:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Zoom.pkg&lt;/strong&gt; – A properly signed Zoom package downloaded directly from Zoom’s official servers. Because it is notarized, Gatekeeper passes it without issue. &lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Stealer.pkg&lt;/strong&gt; – An unsigned, malicious package that is embedded inside the same DMG but is not directly presented to Gatekeeper. Instead, the installer script extracts and runs it after the legitimate Zoom.pkg finishes.&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id=&quot;2-post-installation-script-abuse&quot;&gt;2. Post‑Installation Script Abuse&lt;/h4&gt;
&lt;p&gt;During the Zoom installation, a post‑install script is executed with elevated privileges. The script performs the following steps:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Verifies the integrity of the Zoom binary (a decoy check that always succeeds). &lt;/li&gt;
&lt;li&gt;Copies the hidden stealer payload to &lt;code&gt;/Library/Application Support/Zoom/&lt;/code&gt; where it blends in with legitimate Zoom files. &lt;/li&gt;
&lt;li&gt;Registers a launch daemon (&lt;code&gt;com.zoom.helper.plist&lt;/code&gt;) that runs the stealer at login, ensuring persistence.&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id=&quot;3-code-signing-spoofing&quot;&gt;3. Code‑Signing Spoofing&lt;/h4&gt;
&lt;p&gt;The attacker re‑signs the stealer payload with a self‑generated certificate that mimics Zoom’s developer ID. macOS treats the certificate as valid for the duration of the installation, but the signature is never verified against Apple’s notarization service because the payload is never submitted for notarization.&lt;/p&gt;
&lt;p&gt;These tactics collectively allow the malicious code to “fly under the radar” of Gatekeeper, a scenario reminiscent of the Zoom annotation flaw that was recently patched after an AI‑prompt exploit. For more context on how Zoom’s attack surface is being weaponized, see the detailed coverage here: &lt;a rel=&quot;nofollow&quot; href=&quot;https://ltdeveloperblogs.github.io/posts/zoomsday-hack-uncovered-using-fewer-than-20-ai-prompts&quot;&gt;https://ltdeveloperblogs.github.io/posts/zoomsday-hack-uncovered-using-fewer-than-20-ai-prompts&lt;/a&gt;.&lt;/p&gt;
&lt;h3 id=&quot;why-it-matters-for-users-and-enterprises&quot;&gt;Why It Matters for Users and Enterprises&lt;/h3&gt;
&lt;h4 id=&quot;erosion-of-trust-in-trusted-platforms&quot;&gt;Erosion of Trust in Trusted Platforms&lt;/h4&gt;
&lt;p&gt;Apple markets macOS as a secure ecosystem, and Gatekeeper is a cornerstone of that promise. When a widely used application like Zoom becomes a vector for stealthy malware, the perceived safety of the platform erodes. Users may become reluctant to install legitimate software, slowing productivity and increasing support overhead.&lt;/p&gt;
&lt;h4 id=&quot;data-theft-risks&quot;&gt;Data‑Theft Risks&lt;/h4&gt;
&lt;p&gt;The infostealer is capable of harvesting:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Credentials&lt;/strong&gt; – Saved passwords from browsers, keychains, and VPN clients. &lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Corporate Information&lt;/strong&gt; – Documents stored in local folders, meeting recordings, and shared drive links. &lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Session Tokens&lt;/strong&gt; – Tokens for cloud services (e.g., Microsoft 365, Google Workspace) that can be reused for lateral movement within an organization.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;A successful breach can lead to credential stuffing attacks, ransomware deployment, or espionage. Enterprises that rely on Zoom for remote collaboration are especially vulnerable because the legitimate Zoom client provides a convenient foothold for the attacker.&lt;/p&gt;
&lt;h4 id=&quot;compliance-implications&quot;&gt;Compliance Implications&lt;/h4&gt;
&lt;p&gt;Many regulated industries (healthcare, finance, government) must demonstrate that they protect personal data under standards such as HIPAA, GDPR, or PCI‑DSS. An undetected infostealer could constitute a violation, resulting in fines and reputational damage.&lt;/p&gt;
&lt;h3 id=&quot;industry-impact-and-response&quot;&gt;Industry Impact and Response&lt;/h3&gt;
&lt;h4 id=&quot;vendor-reaction&quot;&gt;Vendor Reaction&lt;/h4&gt;
&lt;p&gt;Zoom has issued a statement confirming that its official installers are safe and urging users to download only from the official website. The company is also reviewing its distribution channels to detect and block repackaged installers.&lt;/p&gt;
&lt;p&gt;Apple, for its part, is expected to release an emergency update to Gatekeeper that tightens verification of nested installers. Historically, Apple has responded quickly to similar threats—recall the rapid patch cycle after the “XcodeGhost” incident in 2015.&lt;/p&gt;
&lt;h4 id=&quot;security-community-mobilization&quot;&gt;Security Community Mobilization&lt;/h4&gt;
&lt;p&gt;Jamf’s disclosure has sparked a wave of analysis across the security community. Researchers are scanning popular download mirrors, third‑party app stores, and even corporate intranets for the same DMG hash. The incident also underscores the importance of endpoint detection and response (EDR) solutions that can spot anomalous post‑install scripts.&lt;/p&gt;
&lt;h4 id=&quot;broader-market-trends&quot;&gt;Broader Market Trends&lt;/h4&gt;
&lt;p&gt;The attack highlights a growing trend: &lt;strong&gt;malware that piggybacks on trusted software&lt;/strong&gt;. As users become more security‑aware, attackers increasingly rely on “double‑installation” tactics to blend in. This mirrors the tactics seen in the Chinese Auto Giant Moves to Apple Wallet Car Keys article, where attackers attempted to exploit Apple’s ecosystem in the automotive domain. For a broader view of how Apple’s ecosystem is being targeted, see: &lt;a rel=&quot;nofollow&quot; href=&quot;https://ltdeveloperblogs.github.io/posts/yet-another-major-chinese-car-brand-is-preparing-to-support-car-keys-in-apple-wallet&quot;&gt;https://ltdeveloperblogs.github.io/posts/yet-another-major-chinese-car-brand-is-preparing-to-support-car-keys-in-apple-wallet&lt;/a&gt;.&lt;/p&gt;
&lt;h3 id=&quot;mitigation-and-best-practices&quot;&gt;Mitigation and Best Practices&lt;/h3&gt;
&lt;h4 id=&quot;immediate-steps-for-end-users&quot;&gt;Immediate Steps for End Users&lt;/h4&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Download Only From Official Sources&lt;/strong&gt; – Always obtain Zoom from &lt;a rel=&quot;nofollow&quot; href=&quot;https://zoom.us/download&quot;&gt;https://zoom.us/download&lt;/a&gt; or the Mac App Store. &lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Verify Checksums&lt;/strong&gt; – When a DMG is provided, compare its SHA‑256 hash with the value published on Zoom’s website. &lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Enable Full Disk Encryption&lt;/strong&gt; – FileVault adds a layer of protection if the infostealer attempts to read unencrypted data.&lt;/li&gt;
&lt;/ol&gt;
&lt;h4 id=&quot;enterprise-level-controls&quot;&gt;Enterprise‑Level Controls&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Application Whitelisting&lt;/strong&gt; – Use macOS’s built‑in &lt;code&gt;spctl&lt;/code&gt; tool or a third‑party MDM to allow only signed, notarized Zoom binaries.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Enable Gatekeeper Strict Mode&lt;/strong&gt; – Administrators can enforce the &lt;code&gt;--strict&lt;/code&gt; flag via &lt;code&gt;spctl --master-enable --global-policy&lt;/code&gt; to reject any app that isn’t notarized, even if it’s bundled inside a signed installer. &lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Monitor Launch Daemons&lt;/strong&gt; – Regularly audit &lt;code&gt;/Library/LaunchDaemons/&lt;/code&gt; and &lt;code&gt;~/Library/LaunchAgents/&lt;/code&gt; for unexpected &lt;code&gt;com.zoom.*&lt;/code&gt; plist files. The malicious installer registers a daemon named &lt;code&gt;com.zoom.helper.plist&lt;/code&gt;; its presence outside of Zoom’s official documentation is a red flag. &lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Leverage Endpoint Detection &amp;amp; Response (EDR)&lt;/strong&gt; – Deploy EDR rules that trigger on the creation of files in &lt;code&gt;~/Library/Application Support/Zoom/&lt;/code&gt; that are not part of the official Zoom bundle, or on the execution of unsigned binaries from that directory.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;em&gt;Read the full breakdown originally published at &lt;a rel=&quot;nofollow&quot; href=&quot;https://ltdeveloperblogs.github.io/posts/this-fake-mac-zoom-installer-has-a-sneaky-way-to-bypass-gatekeeper/&quot;&gt;https://ltdeveloperblogs.github.io/posts/this-fake-mac-zoom-installer-has-a-sneaky-way-to-bypass-gatekeeper/&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/30518/fake-zoom-mac-installer-skips-gatekeeper-steals-data</guid>
<pubDate>Fri, 09 Oct 2026 22:46:10 +0000</pubDate>
</item>
<item>
<title>Why Real-Time AI Face Swap Is Moving Beyond the Local GPU</title>
<link>https://coderlegion.com/28961/why-real-time-ai-face-swap-is-moving-beyond-the-local-gpu</link>
<description>&lt;p&gt;A few years ago, “real-time face swap” usually implied a local setup: download the model, install the right runtime, make sure CUDA works, and hope the GPU has enough headroom to keep up with a webcam.&lt;/p&gt;
&lt;p&gt;That model still makes sense for some users. But it is no longer the only practical architecture.&lt;/p&gt;
&lt;p&gt;The more interesting change is that real-time AI video is starting to resemble another category developers already know well: modern LLM products.&lt;/p&gt;
&lt;p&gt;The user interacts locally. The expensive model inference happens somewhere else.&lt;/p&gt;
&lt;h3 id=&quot;we-already-accepted-this-architecture-for-llms&quot;&gt;We already accepted this architecture for LLMs&lt;/h3&gt;
&lt;p&gt;Running AI locally is not new, and smaller language models can work well on consumer hardware. But many of the most capable LLM products are delivered as cloud services for a simple reason: large models are expensive to keep in memory, expensive to run, and expensive to update across millions of different client machines.&lt;/p&gt;
&lt;p&gt;So the browser or app does not try to become the entire AI stack.&lt;/p&gt;
&lt;p&gt;It handles interaction, input, rendering, and some lightweight client-side logic. The heavy inference stays on infrastructure designed for it.&lt;/p&gt;
&lt;p&gt;That separation gives the provider a controlled runtime and gives the user access to models that may not fit comfortably on their own machine.&lt;/p&gt;
&lt;p&gt;Real-time AI video is moving in the same direction.&lt;/p&gt;
&lt;h3 id=&quot;local-inference-was-the-obvious-starting-point&quot;&gt;Local inference was the obvious starting point&lt;/h3&gt;
&lt;p&gt;For live video, local inference has one major advantage: no network round trip.&lt;/p&gt;
&lt;p&gt;A simplified local pipeline looks like this:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;camera
  -&amp;gt; local preprocessing
  -&amp;gt; local GPU inference
  -&amp;gt; output
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That is attractive when latency is the dominant constraint and the user already has suitable hardware.&lt;/p&gt;
&lt;p&gt;But productizing that architecture means inheriting the hardware diversity of every user:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;different GPU vendors and generations;&lt;/li&gt;
&lt;li&gt;different VRAM limits;&lt;/li&gt;
&lt;li&gt;driver and runtime differences;&lt;/li&gt;
&lt;li&gt;CUDA or alternative backend compatibility;&lt;/li&gt;
&lt;li&gt;laptops with integrated graphics;&lt;/li&gt;
&lt;li&gt;Macs with a completely different acceleration stack;&lt;/li&gt;
&lt;li&gt;other GPU-heavy applications competing for the same resources.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;For an open-source tool or a technical user who wants full control, that trade-off can be completely reasonable.&lt;/p&gt;
&lt;p&gt;For a product aimed at ordinary users, it becomes a support problem as much as an inference problem.&lt;/p&gt;
&lt;h3 id=&quot;cloud-inference-changes-who-owns-the-hardware-problem&quot;&gt;Cloud inference changes who owns the hardware problem&lt;/h3&gt;
&lt;p&gt;Move the main model off-device and the pipeline changes:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;camera
  -&amp;gt; encode
  -&amp;gt; network
  -&amp;gt; cloud GPU inference
  -&amp;gt; network
  -&amp;gt; decode / render
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The GPU did not disappear. It changed ownership.&lt;/p&gt;
&lt;p&gt;Instead of every user needing hardware capable of running the current model, the service provider can run inference on a known GPU environment and manage the runtime centrally.&lt;/p&gt;
&lt;p&gt;That has several practical consequences.&lt;/p&gt;
&lt;p&gt;First, model deployment becomes much more predictable. The backend team controls the GPU type, drivers, inference runtime, and model version.&lt;/p&gt;
&lt;p&gt;Second, upgrading the model does not require every user to download a new multi-gigabyte package or reconfigure their local environment.&lt;/p&gt;
&lt;p&gt;Third, the client device can be much less specialized. A normal laptop can participate in the workflow as long as it can capture video, encode/decode it, and maintain a good enough network connection.&lt;/p&gt;
&lt;p&gt;This is very similar to the LLM split: the edge device becomes the interaction surface, not the place where the largest model must live.&lt;/p&gt;
&lt;h3 id=&quot;the-hard-problem-moves-from-compatibility-to-latency&quot;&gt;The hard problem moves from compatibility to latency&lt;/h3&gt;
&lt;p&gt;Cloud inference removes one constraint and introduces another.&lt;/p&gt;
&lt;p&gt;A local system asks:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;Can this GPU process frames fast enough?&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;A cloud system has to ask:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;Can the entire round trip stay fast and stable enough?&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;The latency budget now includes more than model inference:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;capture
  + encode
  + upload
  + scheduling
  + inference
  + return transport
  + decode
  + render
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This is why a fast model does not automatically create a responsive real-time product.&lt;/p&gt;
&lt;p&gt;A 50 ms improvement in inference may not matter much if network jitter occasionally adds 300 ms. Likewise, reducing network time will not help if GPU scheduling becomes the bottleneck during load spikes.&lt;/p&gt;
&lt;p&gt;For developers, this is the architectural trade: &lt;strong&gt;cloud inference converts an edge-hardware problem into a distributed-systems problem.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;That can still be a good trade if the product team is better positioned to manage infrastructure than the end user is to manage an AI workstation.&lt;/p&gt;
&lt;h3 id=&quot;real-time-video-makes-this-harder-than-text&quot;&gt;Real-time video makes this harder than text&lt;/h3&gt;
&lt;p&gt;The LLM analogy is useful, but real-time video raises the difficulty.&lt;/p&gt;
&lt;p&gt;Text requests are discrete. A user sends a prompt and waits for output.&lt;/p&gt;
&lt;p&gt;A webcam never stops producing input.&lt;/p&gt;
&lt;p&gt;If the system falls behind, it cannot simply keep processing an ever-growing queue of old frames. The result may be technically correct but visually stale.&lt;/p&gt;
&lt;p&gt;So a real-time cloud pipeline also has to care about things such as:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;frame freshness;&lt;/li&gt;
&lt;li&gt;bounded buffering;&lt;/li&gt;
&lt;li&gt;network jitter;&lt;/li&gt;
&lt;li&gt;reconnect behavior;&lt;/li&gt;
&lt;li&gt;sustained throughput;&lt;/li&gt;
&lt;li&gt;end-to-end delay rather than isolated inference time.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;This is where real-time AI video stops looking like “call a model API” and starts looking like an interactive streaming system.&lt;/p&gt;
&lt;h3 id=&quot;a-practical-example-livefaceswap-ai&quot;&gt;A practical example: LiveFaceSwap AI&lt;/h3&gt;
&lt;p&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://livefaceswap.ai/&quot;&gt;LiveFaceSwap AI&lt;/a&gt; uses this cloud-inference approach for its main real-time AI workload.&lt;/p&gt;
&lt;p&gt;The user can open the browser, select a camera, and start a live face-swap session without installing the AI model locally or configuring a dedicated NVIDIA GPU for inference.&lt;/p&gt;
&lt;p&gt;The Desktop app adds a system virtual camera so the processed output can be used in software such as OBS, Zoom, or Teams, while the main AI inference still runs in the cloud.&lt;/p&gt;
&lt;p&gt;From a developer perspective, that is the important part of the architecture: the local client owns capture, interaction, and output integration; the cloud owns the heavy AI workload.&lt;/p&gt;
&lt;p&gt;It is not “GPU-free” face swap. It is &lt;strong&gt;local-GPU-independent&lt;/strong&gt; face swap.&lt;/p&gt;
&lt;h3 id=&quot;cloud-will-not-replace-local-inference&quot;&gt;Cloud will not replace local inference&lt;/h3&gt;
&lt;p&gt;There are still strong reasons to run AI locally.&lt;/p&gt;
&lt;p&gt;Local processing can be the better choice when:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;data must remain on the device;&lt;/li&gt;
&lt;li&gt;the application has to work offline;&lt;/li&gt;
&lt;li&gt;the user already owns powerful hardware;&lt;/li&gt;
&lt;li&gt;the model or runtime needs deep customization;&lt;/li&gt;
&lt;li&gt;predictable local latency matters more than broad hardware compatibility.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Cloud inference is not a universal upgrade.&lt;/p&gt;
&lt;p&gt;What is changing is the default assumption.&lt;/p&gt;
&lt;p&gt;As real-time AI models become heavier, a product no longer has to require every user to own hardware that can run them. The provider can concentrate that compute in the cloud and expose the capability to much lighter clients.&lt;/p&gt;
&lt;p&gt;We have already seen this pattern reshape how people use LLMs.&lt;/p&gt;
&lt;p&gt;Real-time AI video is beginning to follow the same path.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/28961/why-real-time-ai-face-swap-is-moving-beyond-the-local-gpu</guid>
<pubDate>Mon, 28 Sep 2026 20:00:30 +0000</pubDate>
</item>
<item>
<title>Application Tres</title>
<link>https://coderlegion.com/28719/application-tres</link>
<description>&lt;p&gt;&lt;img src=&quot;https://coderlegion.com/?qa=blob&amp;amp;qa_blobid=7212847066776350378&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;p&gt;Apple came up with a gesture I use dozens of times a day.&lt;br&gt;
And didn’t add it to its own mouse.&lt;br&gt;
So I had to build it myself.&lt;/p&gt;
&lt;p&gt;I often read texts in languages that aren’t my native language. First it was English. Now it’s Portuguese.&lt;br&gt;
On a MacBook trackpad, there’s a handy gesture: triple-tap a word to open its definition in the system Dictionary.&lt;br&gt;
No context switching: word → definition → keep reading.&lt;/p&gt;
&lt;p&gt;At my desk, I use an Apple Magic Mouse. It has a touch-sensitive surface too, but for some reason, this gesture doesn’t work.&lt;br&gt;
That surprised me. The gesture already exists in macOS, Dictionary already exists, and Magic Mouse supports gestures. Why not just connect the dots?&lt;/p&gt;
&lt;p&gt;That’s how &quot;tres&quot; was born.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&quot;Três&quot; means &quot;three&quot; in Portuguese.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Tres adds triple-tap support to Apple Magic Mouse and triggers the same native Dictionary lookup.&lt;br&gt;
It behaves just like the trackpad gesture and works in any app where macOS supports Look Up: browsers, Preview, Notes, Mail, and more.&lt;/p&gt;
&lt;p&gt;The app is free and open source.&lt;br&gt;
You can install it directly or via Homebrew.&lt;br&gt;
The official website: &lt;a rel=&quot;nofollow&quot; href=&quot;https://izebit.com/tres&quot;&gt;https://izebit.com/tres&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;On the first launch, you’ll need to give the app access under: &lt;/p&gt;
&lt;p&gt;System Settings → Privacy &amp;amp; Security → Accessibility&lt;/p&gt;
&lt;p&gt;Hope it makes your daily reading a little easier.&lt;/p&gt;
</description>
<category>Launches</category>
<guid isPermaLink="true">https://coderlegion.com/28719/application-tres</guid>
<pubDate>Sat, 26 Sep 2026 16:36:35 +0000</pubDate>
</item>
<item>
<title>Keeping a Web-Backed SSH Terminal Alive Across SwiftUI Navigation</title>
<link>https://coderlegion.com/24308/keeping-a-web-backed-ssh-terminal-alive-across-swiftui-navigation</link>
<description>&lt;p&gt;A desktop SSH client can keep the remote connection alive and still give the user the impression that the terminal was reset. The failure often sits one layer above SSH: the UI destroys and rebuilds the terminal surface when the user switches to another section.&lt;/p&gt;
&lt;p&gt;That distinction matters when a macOS app embeds xterm.js in &lt;code&gt;WKWebView&lt;/code&gt;. The SSH process, terminal model, WebKit process, DOM, scrollback, focus chain, and visible geometry all have different lifetimes. Treating them as one SwiftUI view makes normal navigation surprisingly destructive.&lt;/p&gt;
&lt;p&gt;This article describes the design rules we ended up using in a native macOS SSH client.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://nexusshell.app/assets/img/screens/terminal.png&quot; alt=&quot;A native macOS SSH terminal with multiple sessions and a visible server workspace&quot;&gt;&lt;/p&gt;
&lt;h3 id=&quot;why-rebuilding-is-not-a-harmless-refresh&quot;&gt;Why rebuilding is not a harmless refresh&lt;/h3&gt;
&lt;p&gt;A straightforward SwiftUI implementation conditionally creates the selected section:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;if selectedSection == .terminal {
    TerminalSurface()
} else {
    FilesView()
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;When the condition changes, SwiftUI is free to dismantle the terminal subtree. If that subtree owns a &lt;code&gt;WKWebView&lt;/code&gt;, the app can lose much more than a view object:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;the WebContent process and xterm.js DOM may be destroyed;&lt;/li&gt;
&lt;li&gt;scrollback stored only in the browser process disappears;&lt;/li&gt;
&lt;li&gt;alternate-screen applications such as &lt;code&gt;vim&lt;/code&gt;, &lt;code&gt;nano&lt;/code&gt;, or &lt;code&gt;top&lt;/code&gt; cannot be reconstructed from plain text;&lt;/li&gt;
&lt;li&gt;restoring a bounded output snapshot may omit older history;&lt;/li&gt;
&lt;li&gt;WebKit initialization and snapshot replay add visible latency;&lt;/li&gt;
&lt;li&gt;focus and resize events can arrive in a different order after reconstruction.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Keeping the SSH handle alive does not solve any of these UI-state losses. The remote PTY may still be healthy while the local terminal surface has forgotten how to represent it.&lt;/p&gt;
&lt;h3 id=&quot;separate-four-lifetimes&quot;&gt;Separate four lifetimes&lt;/h3&gt;
&lt;p&gt;The useful mental model is to separate at least four things:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Logical session&lt;/strong&gt;: the tab, server identity, name, working directory, and user-facing state.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Transport session&lt;/strong&gt;: the SSH handle and remote PTY that carry input and output.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Terminal renderer&lt;/strong&gt;: the &lt;code&gt;WKWebView&lt;/code&gt;, xterm.js DOM, parser state, and scrollback.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Visible attachment&lt;/strong&gt;: whether that renderer currently participates in the AppKit view hierarchy and responder chain.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Navigation should normally change only the fourth lifetime. Closing a tab can end all four. A network failure may replace the transport while retaining the logical tab and renderer. Conflating those transitions is where most of the surprising behavior begins.&lt;/p&gt;
&lt;h3 id=&quot;mount-once-then-deactivate&quot;&gt;Mount once, then deactivate&lt;/h3&gt;
&lt;p&gt;After the terminal section is opened for the first time, keep its SwiftUI subtree mounted. Other sections can still be created normally, while the terminal surface changes visibility and interaction state:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;if terminalHasLoaded || selectedSection == .terminal {
    TerminalSurface(isActive: selectedSection == .terminal)
        .opacity(selectedSection == .terminal ? 1 : 0)
        .allowsHitTesting(selectedSection == .terminal)
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This preserves the representable and its coordinator instead of asking SwiftUI to rebuild them on every sidebar click. The &lt;code&gt;isActive&lt;/code&gt; value also gives terminal-specific shortcuts, toolbars, and event monitors an explicit lifecycle. Hidden terminal controls should not continue handling keyboard shortcuts while the user is working in Files or Settings.&lt;/p&gt;
&lt;p&gt;Opacity alone, however, is not sufficient for a WebKit-backed terminal.&lt;/p&gt;
&lt;h3 id=&quot;preserve-the-wkwebview-object-not-necessarily-its-attachment&quot;&gt;Preserve the WKWebView object, not necessarily its attachment&lt;/h3&gt;
&lt;p&gt;On macOS, an embedded &lt;code&gt;WKWebView&lt;/code&gt; participates in AppKit focus, drag registration, and window behavior. Keeping it attached but transparent can leave side effects in sections that should have nothing to do with the terminal.&lt;/p&gt;
&lt;p&gt;A more reliable arrangement is:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;let &lt;code&gt;NSViewRepresentable&lt;/code&gt; return a lightweight container;&lt;/li&gt;
&lt;li&gt;let its coordinator hold a strong reference to the &lt;code&gt;WKWebView&lt;/code&gt;;&lt;/li&gt;
&lt;li&gt;attach the web view to the container only while the terminal section is active;&lt;/li&gt;
&lt;li&gt;remove it from the hierarchy while inactive, without releasing the object;&lt;/li&gt;
&lt;li&gt;attach the same object again when the user returns.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The WebKit object, WebContent process, xterm.js DOM, parser state, and scrollback stay alive even while the view is temporarily detached. The rest of the app no longer has an invisible WebKit view interfering with hit testing or drag sessions.&lt;/p&gt;
&lt;p&gt;This is different from serializing terminal text and creating a new renderer. It preserves the renderer itself.&lt;/p&gt;
&lt;h3 id=&quot;hand-focus-back-before-detaching&quot;&gt;Hand focus back before detaching&lt;/h3&gt;
&lt;p&gt;Focus is a correctness issue for terminals, not just polish. If the window's first responder remains inside the web view during a section switch, later keystrokes can be delivered somewhere the user cannot see. In the worst case, a stray character is interpreted by the remote shell.&lt;/p&gt;
&lt;p&gt;Before detaching an active terminal web view:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Check whether the window's first responder is the web view or one of its descendants.&lt;/li&gt;
&lt;li&gt;Explicitly clear or move the first responder.&lt;/li&gt;
&lt;li&gt;Remove the web view from its container.&lt;/li&gt;
&lt;li&gt;Disable terminal-only keyboard monitors and shortcut controls.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;When reattaching, restore focus deliberately after the container is visible. Avoid assuming that SwiftUI's appearance callback and AppKit's responder-chain update happen in the same frame.&lt;/p&gt;
&lt;h3 id=&quot;freeze-hidden-geometry&quot;&gt;Freeze hidden geometry&lt;/h3&gt;
&lt;p&gt;A hidden but mounted view can still receive geometry changes. If its width follows every animation in the visible section, xterm.js may repeatedly run &lt;code&gt;fit()&lt;/code&gt;, and each result may resize the remote PTY.&lt;/p&gt;
&lt;p&gt;That creates a subtle class of bugs: opening a file-transfer panel or animating another page changes the dimensions of a terminal the user is not looking at. Full-screen terminal programs then redraw against &quot;ghost&quot; sizes.&lt;/p&gt;
&lt;p&gt;Record the terminal's last active width and freeze the inactive container at that width. On reattachment:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;align the web view to the visible container once;&lt;/li&gt;
&lt;li&gt;let the renderer's resize observer compute the new rows and columns;&lt;/li&gt;
&lt;li&gt;send one meaningful PTY resize instead of a frame-by-frame storm.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The same rule applies to side panels: reserve their width only in the section that owns them, not at a shared ancestor that also sizes the hidden terminal.&lt;/p&gt;
&lt;h3 id=&quot;keep-output-flowing-without-flooding-the-ui&quot;&gt;Keep output flowing without flooding the UI&lt;/h3&gt;
&lt;p&gt;Detaching the renderer should not pause the SSH transport. Output can continue updating the logical session and renderer while the user visits another section.&lt;/p&gt;
&lt;p&gt;High-volume PTY output needs its own boundary. Read callbacks can arrive hundreds of times per second, while sending every chunk through the main thread and WebKit IPC creates unnecessary contention. A practical policy is:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;deliver sparse output immediately so typing echo feels instant;&lt;/li&gt;
&lt;li&gt;merge continuous bursts into roughly one flush per display frame;&lt;/li&gt;
&lt;li&gt;update the bounded recovery buffer before notifying the renderer;&lt;/li&gt;
&lt;li&gt;keep session logs on a separate path from visual replay.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The recovery buffer remains useful if WebKit really crashes, but it is no longer the normal navigation mechanism.&lt;/p&gt;
&lt;h3 id=&quot;test-transitions-not-just-steady-states&quot;&gt;Test transitions, not just steady states&lt;/h3&gt;
&lt;p&gt;The most valuable tests are sequences:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;start &lt;code&gt;top&lt;/code&gt;, switch to Files, resize the window, and return;&lt;/li&gt;
&lt;li&gt;scroll far back, visit Settings, and confirm the same scrollback remains;&lt;/li&gt;
&lt;li&gt;focus the terminal, switch sections, type immediately, and verify nothing reaches SSH;&lt;/li&gt;
&lt;li&gt;open and close a side panel while the terminal is hidden;&lt;/li&gt;
&lt;li&gt;disconnect while hidden, then return and exercise the reconnect path;&lt;/li&gt;
&lt;li&gt;close a terminal tab while its web view is detached and confirm observers and script handlers are removed.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;These tests cross SwiftUI, AppKit, WebKit, and SSH boundaries. A unit test around one layer will not reveal the ordering failures between them.&lt;/p&gt;
&lt;h3 id=&quot;the-broader-rule&quot;&gt;The broader rule&lt;/h3&gt;
&lt;p&gt;View visibility and resource lifetime are not the same thing. A terminal is a long-lived interactive resource with a UI surface, not a disposable page. Once the logical session, transport, renderer, and visible attachment have separate ownership, navigation becomes predictable: switching sections changes what the user sees without quietly rebuilding the tool they were using.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;strong&gt;Developer disclosure:&lt;/strong&gt; I work on &lt;a rel=&quot;nofollow&quot; href=&quot;https://nexusshell.app/?utm_source=coderlegion&amp;amp;utm_medium=technical_article&amp;amp;utm_campaign=terminal_view_lifetime&quot;&gt;Nexus Shell&lt;/a&gt;, a native macOS SSH workspace. This article is based on implementation and debugging lessons from that work, not a benchmark against other clients.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Editorial note:&lt;/strong&gt; This draft was prepared with AI assistance and reviewed against the current SwiftUI, AppKit, WebKit, and SSH implementation before publication.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/24308/keeping-a-web-backed-ssh-terminal-alive-across-swiftui-navigation</guid>
<pubDate>Sun, 09 Aug 2026 04:53:28 +0000</pubDate>
</item>
<item>
<title>Most macOS malware is loud. PamStealer isn't, and that's a problem for how we detect it.</title>
<link>https://coderlegion.com/21788/most-macos-malware-is-loud-pamstealer-isnt-and-thats-a-problem-for-how-we-detect-it</link>
<description>&lt;p&gt;I want to start with an uncomfortable admission about how a lot of us detect macOS malware: we are betting it will be loud. Our behavioral rules watch for &lt;code&gt;curl&lt;/code&gt; and &lt;code&gt;zsh&lt;/code&gt; spawning under a user session, &lt;code&gt;osascript&lt;/code&gt; reaching for &lt;code&gt;security&lt;/code&gt;, a process tree that announces itself. That bet has paid off for years because commodity macOS stealers were noisy. PamStealer is a commodity stealer, and it took the bet apart on purpose.&lt;/p&gt;
&lt;p&gt;It starts where every Mac user is most conditioned to comply. Someone installs what they think is Maccy, a clipboard manager, and gets a prompt: &quot;Maccy wants to make changes.&quot; They type their password. Most infostealers grab whatever you typed and run. This one runs your password through PAM first -- the Pluggable Authentication Modules stack macOS uses to actually authenticate you -- confirms it is real, and only then decides you are worth robbing. That single check is why Jamf Threat Labs named it PamStealer, and it is a small, precise example of the thing that makes the whole sample worth your time: it does the detectable work through the operating system's own machinery, so the process chains you are watching for never show up.&lt;/p&gt;
&lt;p&gt;Two labs independently tore this campaign down -- Jamf, who named it, and ManageEngine, who track it as &quot;Fake Maccy Stealer.&quot; I will lean on Jamf for the deepest internals and flag where the two diverge, because a couple of the divergences matter.&lt;/p&gt;
&lt;h3 id=&quot;stage-one-a-dropper-that-skips-the-shell&quot;&gt;Stage one: a dropper that skips the shell&lt;/h3&gt;
&lt;p&gt;Delivery is the familiar macOS pattern with a twist. A compiled AppleScript, &lt;code&gt;Maccy.scpt&lt;/code&gt;, ships on a disk image from a lookalike domain (&lt;code&gt;maccyapp[.]com&lt;/code&gt;) and opens in Script Editor. Inside is an obfuscated JXA (JavaScript for Automation) payload that does the download.&lt;/p&gt;
&lt;p&gt;The twist is that it does not shell out for the fetch. A normal AppleScript downloader reaches for &lt;code&gt;curl&lt;/code&gt; or &lt;code&gt;zsh&lt;/code&gt;, and every one of those is a process spawn your rules watch for. This one stages the payload through &lt;code&gt;NSURLSession&lt;/code&gt; and the Objective-C bridge -- native APIs, no subprocess for the network stage.&lt;/p&gt;
&lt;p&gt;It is not processless, though, and this is the part the first wave of coverage got wrong. Before launch it ad-hoc signs the staged bundle:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;codesign -fs - --deep /path/to/staged.app
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That &lt;code&gt;codesign&lt;/code&gt; process, spawned by Script Editor against a bundle in Application Support, is a detection opportunity, and both teardowns flag it. It also drops a &lt;code&gt;.Maccy&lt;/code&gt; marker file and launches hidden, no window, no Dock. So the honest read is narrower than &quot;no artifacts&quot;: it skips the download chain you key on and leaves other artifacts in its place.&lt;/p&gt;
&lt;p&gt;It is also picky about targets. The dropper derives a key from a host fingerprint -- CPU architecture, locale, keyboard layout, time zone -- and the encrypted config only unlocks on a matching machine. ManageEngine notes those checks double as a CIS geo-fence: it aborts on hosts that look Russian or post-Soviet. Names and config values rotate sample to sample while behavior holds, which reads as an automated builder, not hand-editing.&lt;/p&gt;
&lt;h3 id=&quot;stage-two-rust-a-framework-loaded-at-runtime-and-a-very-loud-clipboard&quot;&gt;Stage two: Rust, a framework loaded at runtime, and a very loud clipboard&lt;/h3&gt;
&lt;p&gt;The payload is a stripped, arm64-native Mach-O in Rust -- uncommon in a stealer scene Jamf notes is dominated by Swift, Go, and Objective-C. Most strings decode at runtime, so static triage gets you the linked libraries and the ObjC selectors and not much else.&lt;/p&gt;
&lt;p&gt;The evasion move worth internalizing: instead of linking &lt;code&gt;Security.framework&lt;/code&gt;, it loads it at runtime. Its keychain-access capability never lands in the static import table. If your triage leans on the load commands, you will not see it there. It bundles its own SQLite and reads browser credential, cookie, and wallet-extension databases as files directly, and it encrypts exfil with ChaCha20-Poly1305, so the traffic resists inspection even though the &lt;code&gt;.config&lt;/code&gt; it drops holds the C2 URL in cleartext.&lt;/p&gt;
&lt;p&gt;Then it gets loud. Running as a fake Finder, Jamf's sample read the clipboard by spawning &lt;code&gt;pbpaste&lt;/code&gt; on a loop -- every 10 to 30 seconds, for the whole run. A process posing as Finder, out of Application Support, shelling out to &lt;code&gt;pbpaste&lt;/code&gt; every few seconds is about the loudest signature in the chain. Here is the first divergence to hold: ManageEngine's variant did the same theft in-process through &lt;code&gt;NSPasteboard&lt;/code&gt; with no subprocess at all, so that noise is Jamf's sample, not a law of the campaign.&lt;/p&gt;
&lt;h3 id=&quot;the-password-and-why-pam-beats-dscl-for-the-attacker&quot;&gt;The password, and why PAM beats dscl for the attacker&lt;/h3&gt;
&lt;p&gt;Back to the namesake. Jamf watched it show an &lt;code&gt;NSAlert&lt;/code&gt; with a secure text field, styled like a real authorization request -- &quot;Maccy wants to make changes,&quot; account name pre-filled -- and validate what you type through the PAM API (&lt;code&gt;pam_start&lt;/code&gt; / &lt;code&gt;pam_authenticate&lt;/code&gt; / &lt;code&gt;pam_end&lt;/code&gt;).&lt;/p&gt;
&lt;p&gt;Why that matters for detection is the whole point of the piece. Other commodity stealers confirm a captured password by shelling out; MacSync uses &lt;code&gt;dscl&lt;/code&gt;. That &lt;code&gt;dscl&lt;/code&gt; call is a process you can catch. A PAM check runs in-process and spawns nothing. One less chain in the tree.&lt;/p&gt;
&lt;p&gt;Get it wrong and it re-prompts. Get it right and it throws a decoy -- &quot;'Maccy' is damaged and can't be opened. You should move it to the Trash,&quot; a near-copy of the real Gatekeeper warning -- so you trash the lure and assume a bad download while it is already persistent. Persistence uses login items, modern and legacy (&lt;code&gt;SMAppService&lt;/code&gt; and &lt;code&gt;LSSharedFileList&lt;/code&gt;), and it deliberately avoids the LaunchAgents and LaunchDaemons where detection coverage is densest. It masquerades as Finder with the genuine icon and delays its Full Disk Access request, made under that disguise, by up to 40 minutes so the prompt does not line up with launch.&lt;/p&gt;
&lt;p&gt;Line the choices up and the philosophy is consistent: JXA over the Objective-C bridge instead of &lt;code&gt;curl&lt;/code&gt;, a runtime-loaded &lt;code&gt;Security.framework&lt;/code&gt; instead of a linked one, a PAM check instead of &lt;code&gt;dscl&lt;/code&gt;, login items instead of the LaunchAgents everyone watches, a permission prompt decorrelated from launch.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;It doesn't erase its footprint. It moves the footprint to where the old detections weren't looking.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3 id=&quot;this-is-the-pattern-now-not-the-exception&quot;&gt;This is the pattern now, not the exception&lt;/h3&gt;
&lt;p&gt;Here is my actual position, and I will mark it as opinion rather than a vendor finding: this is where the low end of macOS malware is going, and that should bother you more than a single clever sample would.&lt;/p&gt;
&lt;p&gt;macOS detection leaned for years on the tells of noisy malware because commodity stealers were noisy. PamStealer is commodity -- ManageEngine ties it to a malware-as-a-service pattern, per-host binary rotation and all -- and it is quiet in exactly the places those rules watch. The uncomfortable part is not that one crew got clever. It is that quiet-native tradecraft is showing up in automated-builder, spray-to-a-lookalike-domain malware, which is precisely where techniques stop being exotic and become the baseline. The detection stack we built assumed attackers would announce themselves. That assumption is now a liability, and it is a liability at the cheap end of the market, which is the part that scales.&lt;/p&gt;
&lt;h3 id=&quot;what-this-does-not-solve&quot;&gt;What this does NOT solve&lt;/h3&gt;
&lt;p&gt;I try to end these with the honest part, because it is the part that separates analysis from a press release.&lt;/p&gt;
&lt;p&gt;Going native does not make this thing invisible. It makes it quieter against one class of detection. Everything else is still on the table. Named caches under &lt;code&gt;~/Library/Caches/com.apple.finder.core/&lt;/code&gt;, a &lt;code&gt;.config&lt;/code&gt; with the C2 in cleartext, a &lt;code&gt;codesign&lt;/code&gt; spawn from Script Editor, login-item registration, and in Jamf's sample a &lt;code&gt;pbpaste&lt;/code&gt; loop are all sitting right there. ManageEngine's sample still tripped a VirusTotal engine at first submission. The C2 still has to talk.&lt;/p&gt;
&lt;p&gt;It also does not get past behavioral EDR that watches the right things instead of the loud things -- framework loads, process lineage, a Finder doing something Finder does not do. And it does not get around the root cause, which is human: the entire chain needs someone to download from the wrong domain, run a script they should not, and approve a prompt they should have questioned. &quot;Train the users&quot; has never closed that gap and never will, because installing a clipboard manager off a lookalike domain is a normal act in an environment that makes the fake and the real indistinguishable until it is too late.&lt;/p&gt;
&lt;p&gt;And honestly, the analysis itself does not fully close. Jamf and ManageEngine independently tore down the same campaign and diverge on what PAM is even for: Jamf documents the validate-your-password-before-stealing workflow the name is built on, while ManageEngine describes the binary linking &lt;code&gt;libpam&lt;/code&gt; and infers it could intercept &lt;code&gt;sudo&lt;/code&gt; and system-auth responses. Both saw PAM; they emphasize different uses. The Ethereum JSON-RPC endpoints Jamf found in the decrypted server config, and the one it caught the malware connecting to, have an unknown purpose -- resilient dead-drop or wallet recon, Jamf did not capture the calls and did not guess. If someone tells you they know exactly what this thing does end to end, they are out ahead of the public evidence.&lt;/p&gt;
&lt;h3 id=&quot;so-where-does-that-leave-your-detection-&quot;&gt;So where does that leave your detection?&lt;/h3&gt;
&lt;p&gt;If you want to hunt it, the artifacts that survive are the boring, reliable ones:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;A login item or Background Task Management entry pointing at a Finder or Software Update bundle in a user directory (&lt;code&gt;sudo sfltool dumpbtm&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;A process named Finder running from anywhere other than &lt;code&gt;/System/Library/CoreServices/Finder.app&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Script Editor spawning &lt;code&gt;codesign&lt;/code&gt; against a bundle in &lt;code&gt;~/Library/Application Support/&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;A &quot;Finder&quot; spawning &lt;code&gt;pbpaste&lt;/code&gt; on a loop or reading a Chromium credential database&lt;/li&gt;
&lt;li&gt;An ad-hoc &lt;code&gt;-&lt;/code&gt; code signature where you would expect a Developer ID&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;But the artifacts are not really the argument. Here is what I actually want to know from anyone running macOS detection at scale: are your rules still keyed on shell spawns, or have you moved them to framework loads and process lineage? And be honest with me -- has behavioral detection ever caught one of these native-tradecraft stealers in your environment before the IOCs dropped, or is on-disk matching still doing the real work? I have a suspicion about the answer, and I would genuinely like to be wrong.&lt;/p&gt;
&lt;p&gt;Originally published at &lt;a rel=&quot;nofollow&quot; href=&quot;https://blog.vertexops.org/pamstealer-macos&quot;&gt;blog.vertexops.org&lt;/a&gt;.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/21788/most-macos-malware-is-loud-pamstealer-isnt-and-thats-a-problem-for-how-we-detect-it</guid>
<pubDate>Thu, 02 Jul 2026 22:43:22 +0000</pubDate>
</item>
<item>
<title>Signatures in macOS: Identity, Integrity &amp; Trust</title>
<link>https://coderlegion.com/18553/signatures-in-macos-identity-integrity-trust</link>
<description>&lt;p&gt;Every app on macOS carries a digital signature — and it’s more important than most people realize.&lt;/p&gt;
&lt;p&gt;Here’s what signatures actually do:&lt;/p&gt;
&lt;p&gt;✅ Identity — Confirms who created the app&lt;br&gt;
✅ Integrity — Ensures the app hasn’t been modified or tampered with&lt;br&gt;
✅ Trust — Helps macOS decide whether the software is safe to run&lt;/p&gt;
&lt;p&gt;Apple’s code-signing system works alongside Gatekeeper and notarization to protect users from malicious software and unauthorized changes.&lt;/p&gt;
&lt;p&gt;Why it matters for developers &lt;br&gt;
• Prevents app tampering&lt;br&gt;
• Builds user confidence&lt;br&gt;
• Required for smoother app distribution on macOS&lt;br&gt;
• Essential for enterprise and production apps&lt;/p&gt;
&lt;p&gt;Security isn’t just about writing code — it’s about proving your code can be trusted.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/18553/signatures-in-macos-identity-integrity-trust</guid>
<pubDate>Thu, 21 May 2026 11:25:38 +0000</pubDate>
</item>
<item>
<title>Homebrew: el gestor de paquetes que todo usuario de Mac debería conocer desde el día uno</title>
<link>https://coderlegion.com/14720/homebrew-el-gestor-de-paquetes-que-todo-usuario-de-mac-deberia-conocer-desde-el-dia-uno</link>
<description>&lt;hr&gt;
&lt;p&gt;title: Homebrew: el gestor de paquetes que todo usuario de Mac debería conocer desde el día uno&lt;br&gt;
published: true&lt;/p&gt;
&lt;h3 id=&quot;tags-spanish-macos-terminal-productivity&quot;&gt;tags: spanish, macos, terminal, productivity&lt;/h3&gt;
&lt;p&gt;Hay una herramienta que debería venir preinstalada en cada Mac que sale de fábrica.&lt;/p&gt;
&lt;p&gt;No viene. Y nadie te avisa que existe hasta que llevas meses instalando software de la manera más lenta y tediosa posible.&lt;/p&gt;
&lt;p&gt;Se llama Homebrew. Y cuando la descubres tienes exactamente dos reacciones: &lt;em&gt;&quot;esto es increíble&quot;&lt;/em&gt; seguido inmediatamente de &lt;em&gt;&quot;¿por qué nadie me dijo esto antes?&quot;&lt;/em&gt;&lt;/p&gt;
&lt;h3 id=&quot;-qu-es-homebrew-&quot;&gt;¿Qué es Homebrew?&lt;/h3&gt;
&lt;p&gt;Es un gestor de paquetes para Mac. En lugar de entrar a un sitio web, descargar el instalador, hacer clic en &quot;siguiente, siguiente, siguiente, aceptar&quot; y arrastrar el ícono a Aplicaciones — todo ese proceso se convierte en esto:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;brew install nombre-del-programa
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Un comando. El software queda instalado y listo.&lt;/p&gt;
&lt;h3 id=&quot;c-mo-instalarlo&quot;&gt;Cómo instalarlo&lt;/h3&gt;
&lt;p&gt;Abre la terminal y copia esto:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;/bin/bash -c &quot;$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Verifica que quedó bien:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;brew --version
# Homebrew 4.x.x
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;los-comandos-que-vas-a-usar-el-90-del-tiempo&quot;&gt;Los comandos que vas a usar el 90% del tiempo&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# Instalar herramienta de terminal
brew install git

# Instalar app con interfaz gráfica
brew install --cask iterm2

# Actualizar todo
brew update &amp;amp;&amp;amp; brew upgrade

# Ver qué tienes instalado
brew list

# Desinstalar
brew uninstall nombre-del-paquete

# Limpiar archivos temporales
brew cleanup
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;el-tip-que-cambia-todo-brewfile&quot;&gt;El tip que cambia todo — Brewfile&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# Guarda toda tu lista de paquetes
brew bundle dump

# En un Mac nuevo, instala todo de una vez
brew bundle install
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Configuras un Mac nuevo en 10 minutos en lugar de un día entero. Eso solo ya justifica instalarlo hoy.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt; Artículo completo con todos los detalles, la lista de apps que instalo en cada Mac nuevo y cómo conectar Homebrew con tus dotfiles en &lt;strong&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://www.commandcat.dev/homebrew-mac-guia/&quot;&gt;CommandCat&lt;/a&gt;&lt;/strong&gt; — contenido tech en español sobre macOS, Linux, productividad y cultura geek. &lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/14720/homebrew-el-gestor-de-paquetes-que-todo-usuario-de-mac-deberia-conocer-desde-el-dia-uno</guid>
<pubDate>Tue, 14 Apr 2026 03:27:36 +0000</pubDate>
</item>
<item>
<title>From 1.4s to 53ms: Optimizing zsh Startup on macOS</title>
<link>https://coderlegion.com/11428/from-1-4s-to-53ms-optimizing-zsh-startup-on-macos</link>
<description>&lt;p&gt;Every time I opened a terminal, I waited. Not long — maybe a second and a half — but long enough to notice. Long enough to be annoying. I finally decided to profile my zsh startup, and what I found took it from &lt;strong&gt;1.4 seconds down to 53 milliseconds&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;Here's what I learned.&lt;/p&gt;
&lt;h3 id=&quot;profiling-with-zprof&quot;&gt;Profiling with zprof&lt;/h3&gt;
&lt;p&gt;Zsh has a built-in profiler. Add &lt;code&gt;zmodload zsh/zprof&lt;/code&gt; at the top of your &lt;code&gt;.zshrc&lt;/code&gt; and &lt;code&gt;zprof&lt;/code&gt; at the bottom, then open a new shell:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-zsh&quot;&gt;# top of .zshrc
zmodload zsh/zprof

# ... your config ...

# bottom of .zshrc
zprof
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;My initial profile told a clear story:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Culprit &lt;/th&gt;&lt;th&gt;Time &lt;/th&gt;&lt;th&gt;% of startup&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;NVM (&lt;code&gt;nvm.sh&lt;/code&gt;) &lt;/td&gt;&lt;td&gt;~430ms &lt;/td&gt;&lt;td&gt;31%&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Completion subprocesses (kubectl, helm, gh, ...) &lt;/td&gt;&lt;td&gt;~400ms &lt;/td&gt;&lt;td&gt;29%&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;compinit&lt;/code&gt; (full rebuild every time) &lt;/td&gt;&lt;td&gt;~240ms &lt;/td&gt;&lt;td&gt;17%&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;brew shellenv&lt;/code&gt; &lt;/td&gt;&lt;td&gt;~30ms &lt;/td&gt;&lt;td&gt;2%&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;go env GOPATH&lt;/code&gt; &lt;/td&gt;&lt;td&gt;~20ms &lt;/td&gt;&lt;td&gt;1%&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Everything else &lt;/td&gt;&lt;td&gt;~280ms &lt;/td&gt;&lt;td&gt;20%&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Four of these five are subprocess calls — things like &lt;code&gt;eval &quot;$(brew shellenv)&quot;&lt;/code&gt; or &lt;code&gt;source &amp;lt;(kubectl completion zsh)&lt;/code&gt; that fork a process just to produce some static text. That's the low-hanging fruit.&lt;/p&gt;
&lt;h3 id=&quot;optimization-1-lazy-load-nvm&quot;&gt;Optimization 1: Lazy-load NVM&lt;/h3&gt;
&lt;p&gt;NVM was the single biggest offender. Sourcing &lt;code&gt;nvm.sh&lt;/code&gt; on every shell startup cost ~430ms, and I don't use &lt;code&gt;node&lt;/code&gt; in every terminal session. The fix: wrapper functions that defer loading until you actually call &lt;code&gt;nvm&lt;/code&gt;, &lt;code&gt;node&lt;/code&gt;, &lt;code&gt;npm&lt;/code&gt;, etc.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Before:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-zsh&quot;&gt;export NVM_DIR=&quot;$HOME/.nvm&quot;
[ -s &quot;/opt/homebrew/opt/nvm/nvm.sh&quot; ] &amp;amp;&amp;amp; \. &quot;/opt/homebrew/opt/nvm/nvm.sh&quot;
[ -s &quot;/opt/homebrew/opt/nvm/etc/bash_completion.d/nvm&quot; ] &amp;amp;&amp;amp; \. &quot;/opt/homebrew/opt/nvm/etc/bash_completion.d/nvm&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;After:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-zsh&quot;&gt;export NVM_DIR=&quot;$HOME/.nvm&quot;

_nvm_lazy_load() {
  unfunction nvm node npm npx corepack 2&amp;gt;/dev/null
  [ -s &quot;/opt/homebrew/opt/nvm/nvm.sh&quot; ] &amp;amp;&amp;amp; \. &quot;/opt/homebrew/opt/nvm/nvm.sh&quot;
  [ -s &quot;/opt/homebrew/opt/nvm/etc/bash_completion.d/nvm&quot; ] &amp;amp;&amp;amp; \. &quot;/opt/homebrew/opt/nvm/etc/bash_completion.d/nvm&quot;
}

nvm()      { _nvm_lazy_load; nvm &quot;$@&quot; }
node()     { _nvm_lazy_load; node &quot;$@&quot; }
npm()      { _nvm_lazy_load; npm &quot;$@&quot; }
npx()      { _nvm_lazy_load; npx &quot;$@&quot; }
corepack() { _nvm_lazy_load; corepack &quot;$@&quot; }
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The wrapper functions replace themselves on first call via &lt;code&gt;unfunction&lt;/code&gt;, then delegate to the real command. Cost at startup: zero. Cost on first &lt;code&gt;node&lt;/code&gt; invocation: ~430ms (once).&lt;/p&gt;
&lt;h3 id=&quot;optimization-2-hardcode-static-values&quot;&gt;Optimization 2: Hardcode static values&lt;/h3&gt;
&lt;p&gt;Several lines in my config were spawning subprocesses to compute values that never change:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-zsh&quot;&gt;# Before — subprocess every startup
eval &quot;$(/opt/homebrew/bin/brew shellenv)&quot;
export PATH=&quot;$PATH:$(go env GOPATH)/bin&quot;
. &quot;$HOME/.cargo/env&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;These produce the same output every time. Just paste the result directly:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-zsh&quot;&gt;# After — zero subprocesses
export HOMEBREW_PREFIX=&quot;/opt/homebrew&quot;
export HOMEBREW_CELLAR=&quot;/opt/homebrew/Cellar&quot;
export HOMEBREW_REPOSITORY=&quot;/opt/homebrew&quot;
export PATH=&quot;/opt/homebrew/bin:/opt/homebrew/sbin:$PATH&quot;
[ -z &quot;${MANPATH-}&quot; ] || export MANPATH=&quot;:${MANPATH#:}&quot;
export INFOPATH=&quot;/opt/homebrew/share/info:${INFOPATH:-}&quot;

export GOPATH=&quot;$HOME/go&quot;
export PATH=&quot;$PATH:$GOPATH/bin&quot;

export PATH=&quot;$HOME/.cargo/bin:$PATH&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Leave a comment like &lt;code&gt;# regenerate with: brew shellenv&lt;/code&gt; so future-you knows where the values came from.&lt;/p&gt;
&lt;h3 id=&quot;optimization-3-cache-completions-into-fpath&quot;&gt;Optimization 3: Cache completions into fpath&lt;/h3&gt;
&lt;p&gt;This was the big one. My original config eagerly sourced completions from 12 different tools on every shell startup:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-zsh&quot;&gt;# Before — 12 subprocesses, every startup
command -v kubectl  &amp;amp;&amp;gt;/dev/null &amp;amp;&amp;amp; source &amp;lt;(kubectl completion zsh)
command -v helm     &amp;amp;&amp;gt;/dev/null &amp;amp;&amp;amp; source &amp;lt;(helm completion zsh)
command -v minikube &amp;amp;&amp;gt;/dev/null &amp;amp;&amp;amp; source &amp;lt;(minikube completion zsh)
command -v gh       &amp;amp;&amp;gt;/dev/null &amp;amp;&amp;amp; source &amp;lt;(gh completion -s zsh)
# ... 8 more tools
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Each &lt;code&gt;source &amp;lt;(tool completion zsh)&lt;/code&gt; forks a subprocess AND evaluates thousands of lines of shell code. Minikube's completion alone is 5,000 lines.&lt;/p&gt;
&lt;p&gt;The fix has two parts:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;For completions:&lt;/strong&gt; write them to files in an fpath directory. Compinit loads these lazily — only when you actually press TAB on that command:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-zsh&quot;&gt;ZSH_COMP_CACHE=&quot;$HOME/.zsh-completion-cache&quot;
[[ -d &quot;$ZSH_COMP_CACHE&quot; ]] || mkdir -p &quot;$ZSH_COMP_CACHE&quot;

_cache_fpath() {
  local name=&quot;$1&quot;; shift
  local cache_file=&quot;$ZSH_COMP_CACHE/_$name&quot;
  local -a stale=($cache_file(N.mh+24))
  if [[ ! -f &quot;$cache_file&quot; ]] || (( $#stale )); then
    &quot;$@&quot; &amp;gt; &quot;$cache_file&quot; 2&amp;gt;/dev/null
  fi
}

command -v kubectl &amp;amp;&amp;gt;/dev/null &amp;amp;&amp;amp; _cache_fpath kubectl kubectl completion zsh
command -v helm    &amp;amp;&amp;gt;/dev/null &amp;amp;&amp;amp; _cache_fpath helm    helm completion zsh
# ... etc

fpath=($ZSH_COMP_CACHE $fpath)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;For plugins that must run at startup&lt;/strong&gt; (fzf keybindings, direnv hook, oh-my-posh prompt), cache their init output and &lt;code&gt;zcompile&lt;/code&gt; for faster sourcing:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-zsh&quot;&gt;_cache_source() {
  local name=&quot;$1&quot;; shift
  local cache_file=&quot;$ZSH_COMP_CACHE/$name.zsh&quot;
  local -a stale=($cache_file(N.mh+24))
  if [[ ! -f &quot;$cache_file&quot; ]] || (( $#stale )); then
    &quot;$@&quot; &amp;gt; &quot;$cache_file&quot; 2&amp;gt;/dev/null
    zcompile &quot;$cache_file&quot; 2&amp;gt;/dev/null
  fi
  source &quot;$cache_file&quot;
}

_cache_source fzf fzf --zsh
_cache_source direnv direnv hook zsh
_cache_source oh-my-posh oh-my-posh init zsh --config ~/.poshthemes/theme.omp.json --print
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Both functions use a 24-hour cache expiry via zsh glob qualifiers. Delete &lt;code&gt;~/.zsh-completion-cache&lt;/code&gt; to force a refresh.&lt;/p&gt;
&lt;p&gt;I also cached &lt;code&gt;compinit&lt;/code&gt; itself — a full rebuild only runs once per day, and otherwise &lt;code&gt;compinit -C&lt;/code&gt; skips straight to the dump file:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-zsh&quot;&gt;autoload -Uz compinit
local -a zcompdump_stale=(~/.zcompdump(N.mh+24))
if (( $#zcompdump_stale )); then
  compinit
else
  compinit -C
fi
{ zcompile ~/.zcompdump } &amp;amp;!
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;the-bug-that-almost-ruined-everything&quot;&gt;The bug that almost ruined everything&lt;/h3&gt;
&lt;p&gt;After implementing all of this, I ran &lt;code&gt;time zsh -i -c exit&lt;/code&gt;. The result: &lt;strong&gt;1.59 seconds&lt;/strong&gt;. &lt;em&gt;Slower&lt;/em&gt; than before.&lt;/p&gt;
&lt;p&gt;I profiled again and saw this:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;num  calls                time            self            name
-----------------------------------------------------------------
 1)   15   1180.06  97.34%  1169.02  96.43%  _cache_completion
 2)    1     26.83   2.21%     7.49   0.62%  compinit
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The caching function was taking 97% of startup time across 15 calls. The caches existed on disk but were being &lt;strong&gt;regenerated every single time&lt;/strong&gt;. The staleness check was broken.&lt;/p&gt;
&lt;p&gt;I restructured the approach — separating completions (fpath-based, lazy) from plugins (source-based, eager) — and tried again. Same problem: &lt;code&gt;_cache_fpath&lt;/code&gt; at 72%, &lt;code&gt;compinit&lt;/code&gt; doing full rebuilds.&lt;/p&gt;
&lt;p&gt;The bug was in this line:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-zsh&quot;&gt;if [[ ! -f &quot;$cache_file&quot; || -n &quot;$cache_file&quot;(#qN.mh+24) ]]; then
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This looks reasonable. The glob qualifier &lt;code&gt;(#qN.mh+24)&lt;/code&gt; means &quot;match if the file is older than 24 hours, with N (nullglob) to return empty string if no match.&quot; The &lt;code&gt;-n&lt;/code&gt; test checks if the result is non-empty.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The problem: glob qualifiers don't expand inside &lt;code&gt;[[ ]]&lt;/code&gt;.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Zsh's &lt;code&gt;[[ ]]&lt;/code&gt; conditional construct does not perform filename generation (globbing). The string &lt;code&gt;&quot;$cache_file&quot;(#qN.mh+24)&lt;/code&gt; is treated as the literal path with &lt;code&gt;(#qN.mh+24)&lt;/code&gt; appended as text. Since that string is always non-empty, the condition is &lt;strong&gt;always true&lt;/strong&gt;. Every cache was being regenerated on every startup. The caching was doing nothing.&lt;/p&gt;
&lt;p&gt;The same bug affected the &lt;code&gt;compinit&lt;/code&gt; staleness check:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-zsh&quot;&gt;# Also broken — compinit was doing a full rebuild every time
if [[ -n ~/.zcompdump(#qN.mh+24) ]]; then
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;The fix:&lt;/strong&gt; expand the glob into an array variable first, then check its length:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-zsh&quot;&gt;local -a stale=($cache_file(N.mh+24))
if [[ ! -f &quot;$cache_file&quot; ]] || (( $#stale )); then
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Regular variable assignments DO perform globbing. The &lt;code&gt;(N.mh+24)&lt;/code&gt; qualifier (no &lt;code&gt;#q&lt;/code&gt; prefix needed outside &lt;code&gt;[[ ]]&lt;/code&gt;) expands the glob, and &lt;code&gt;$#stale&lt;/code&gt; gives us the match count. If the file is older than 24 hours, &lt;code&gt;stale&lt;/code&gt; contains one element; otherwise it's empty.&lt;/p&gt;
&lt;p&gt;This is a subtle footgun. The code looks correct, it doesn't produce errors, and the caches &lt;em&gt;are&lt;/em&gt; created — they're just never &lt;em&gt;reused&lt;/em&gt;. Without profiling, you'd never know.&lt;/p&gt;
&lt;h3 id=&quot;result&quot;&gt;Result&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-console&quot;&gt;$ time zsh -i -c exit
zsh -i -c exit  0.03s user 0.02s system 93% cpu 0.053 total
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;53 milliseconds.&lt;/strong&gt; A 96% reduction from 1.4 seconds.&lt;/p&gt;
&lt;p&gt;Here's what each optimization contributed:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Optimization &lt;/th&gt;&lt;th&gt;Savings&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Lazy-load NVM &lt;/td&gt;&lt;td&gt;~430ms&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Cache completions into fpath (lazy compinit) &lt;/td&gt;&lt;td&gt;~500ms&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Cache plugin init scripts + zcompile &lt;/td&gt;&lt;td&gt;~200ms&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Hardcode brew/go/cargo &lt;/td&gt;&lt;td&gt;~50ms&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;compinit -C (cached dump) &lt;/td&gt;&lt;td&gt;~170ms&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Total&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;&lt;strong&gt;~1,350ms&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;The first shell open after 24 hours takes a couple of seconds to regenerate caches, but every subsequent shell is instant. You can force a full refresh anytime:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-zsh&quot;&gt;rm -rf ~/.zsh-completion-cache ~/.zcompdump*
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;takeaways-1&quot;&gt;Takeaways&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Profile first.&lt;/strong&gt; &lt;code&gt;zprof&lt;/code&gt; told me exactly where the time was going. Don't guess.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Subprocess calls add up.&lt;/strong&gt; Each &lt;code&gt;eval $(...)&lt;/code&gt; or &lt;code&gt;source &amp;lt;(...)&lt;/code&gt; forks a process. Twelve of them cost almost a full second.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;fpath &amp;gt; source for completions.&lt;/strong&gt; Compinit loads completion functions lazily from fpath. Don't eagerly source thousands of lines you might never use.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Test your caching actually works.&lt;/strong&gt; A cache that regenerates every time is worse than no cache — it has the overhead of both the generation AND the file I/O.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Glob qualifiers don't work inside &lt;code&gt;[[ ]]&lt;/code&gt;.&lt;/strong&gt; This is the kind of bug that looks correct, produces no errors, and silently destroys your performance. Expand globs into variables first.&lt;/li&gt;
&lt;/ol&gt;
</description>
<category>Tutorials</category>
<guid isPermaLink="true">https://coderlegion.com/11428/from-1-4s-to-53ms-optimizing-zsh-startup-on-macos</guid>
<pubDate>Wed, 11 Feb 2026 13:52:03 +0000</pubDate>
</item>
<item>
<title>Beyond rbenv and pyenv: Achieving Reproducible Dev Setups with Nix</title>
<link>https://coderlegion.com/4184/beyond-rbenv-and-pyenv-achieving-reproducible-dev-setups-with-nix</link>
<description>&lt;p&gt;Setting up a development environment is a painful process, as I &lt;a rel=&quot;nofollow&quot; href=&quot;https://kitfucoda.medium.com/from-dotfile-hacks-to-open-source-my-development-environment-evolution-a771ea7bf8a8&quot;&gt;discussed previously&lt;/a&gt; when detailing how mine evolved to the combination of home-manager and Ubuntu Make. Little did I know, the article gained significant traction and likely resonated with many fellow developers. Coincidentally, I got into a new job earlier in the month, and this necessitated a revision to the setup on a MacOS environment. How did it go? Turbulent would be an appropriate adjective to describe the whole experience, as you may expect.&lt;/p&gt;
&lt;p&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://dev.to/jeffrey04/from-dotfile-hacks-to-open-source-my-development-environment-evolution-1hce&quot;&gt;From Dotfile Hacks to Open Source: My Development Environment Evolution&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn-images-1.medium.com/max/1024/1*t17Pg0TlocDfGtPxS8To3A.png&quot; alt=&quot;&quot;&gt;&lt;br&gt;
&lt;em&gt;A cute illustration from Copilot&lt;/em&gt;&lt;/p&gt;
&lt;h4 id=&quot;the-macos-catalyst-a-new-job-a-new-challenge&quot;&gt;The macOS Catalyst: A New Job, A New Challenge&lt;/h4&gt;
&lt;p&gt;Replicating a development setup over multiple machines can be a hard problem, and ensuring it works on different operating systems significantly increases that complexity. As we delve deeper into the topic, you will see how I shoot myself in the foot repeatedly, and the corresponding steps to recover.&lt;/p&gt;
&lt;p&gt;Why a Mac?&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn-images-1.medium.com/max/1024/0*9L-vGU5dIoVaFnew&quot; alt=&quot;&quot;&gt;&lt;br&gt;
&lt;em&gt;Photo by Claudio Schwarz on Unsplash&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;Before starting my new role, I was asked if I would prefer a Linux machine or a Mac for work. Though I am relatively comfortable with a Linux machine, I picked Mac as I reckoned it is easier to get enterprise apps whenever necessary. I also reiterated this same reason when my coworker and I were pairing up for a task.&lt;/p&gt;
&lt;p&gt;Despite working mostly with Ubuntu, I do have a Intel iMac for video calls and functioning as a second screen for documentation and web project previews. A minimal home-manager setup worked mostly fine, and I assumed expansion of the setup for development would be as painless.&lt;/p&gt;
&lt;p&gt;Oh, how wrong I was.&lt;/p&gt;
&lt;p&gt;As the assigned MacBook Pro became my primary device for work, all the minor problems surfaced. First, my shells were not initialized properly in the terminal emulator. After getting that fixed, I got adventurous and decided to retire rbenv to manage Ruby versions. Coupled with the struggle to set up my code editor, I was amazed how my feet survived all the gunshots.&lt;/p&gt;
&lt;h4 id=&quot;diving-into-the-rabbit-holes-macos-and-interpreter-woes&quot;&gt;Diving into the Rabbit Holes: macOS and Interpreter Woes&lt;/h4&gt;
&lt;p&gt;How hard can it be?&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn-images-1.medium.com/max/1024/0*i3phby67nWZBNKAX&quot; alt=&quot;&quot;&gt;&lt;br&gt;
&lt;em&gt;Photo by Nachristos on Unsplash&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;If one is looking for a statement to best represent underestimation, that would be it. Looking back, the problems look very trivial, but each was a great learning opportunity. Hang on tight, as we are going into a deep dive into all the rabbit holes I fell into with the revised setup, and to learn how to overcome them.&lt;/p&gt;
&lt;p&gt;Compared to Windows, macOS offers a more familiar environment for developers with experience in Linux. We can interact with the system through a similar command-line interface. With a package manager, we can even add different shells not shipped with the operating system, like my favourite &lt;a rel=&quot;nofollow&quot; href=&quot;https://fishshell.com/&quot;&gt;fish shell&lt;/a&gt;. Trying to configure an iTerm2 profile for fish is exactly the first hole I fell into.&lt;/p&gt;
&lt;p&gt;Why would macOS not source .profile in a graphical session?&lt;/p&gt;
&lt;p&gt;Unlike Ubuntu, macOS &lt;a rel=&quot;nofollow&quot; href=&quot;https://ghostty.org/docs/help/macos-login-shells&quot;&gt;does not source &lt;/a&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://ghostty.org/docs/help/macos-login-shells&quot;&gt;.profile in a graphical session&lt;/a&gt;. Most of the time, the script updates the PATH variable, making locally installed applications discoverable. Subsequently, shell initialization scripts (like .bashrc for bash and config.fish for fish) then set up individual application based on the environment defined in .profile.&lt;/p&gt;
&lt;p&gt;Multiple variations was attempted, until I stumbled upon &lt;a rel=&quot;nofollow&quot; href=&quot;https://superuser.com/questions/446925/re-use-profile-for-fish&quot;&gt;a Q&amp;amp;A on superuser&lt;/a&gt;. Thinking it would be useful, I sent the proposed answer to Gemini and asked if it would be applicable for my case. Turns out I only needed a little tweak.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;/bin/sh -lc &quot;exec -l /path/to/fish&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This is a breakdown of the command:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;/bin/sh: This specifies that the command should be executed by the Bourne shell, launching a new instance of this basic shell.&lt;/li&gt;
&lt;li&gt;/bin/sh -l: this tells the shell to launch as a login shell, ensuring it sources standard login files like .profile.&lt;/li&gt;
&lt;li&gt;/bin/sh -c: This tells the shell to execute the command provided in the string that follows.&lt;/li&gt;
&lt;li&gt;exec: This &lt;a rel=&quot;nofollow&quot; href=&quot;https://devconnected.com/understanding-processes-on-linux/#Execute_operation&quot;&gt;crucial command&lt;/a&gt; tells the shell to replace the current /bin/sh process with a new process (in this specific case, the fish shell).&lt;/li&gt;
&lt;li&gt;exec -l: This tells the new shell (e.g., fish) to launch as a login shell itself.&lt;/li&gt;
&lt;li&gt;/path/to/fish: This is the full path to your fish shell executable. With the -l flag in the exec command, this fish shell will then source its own login initialization configuration file (e.g., config.fish).&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Similarly, the command can be adapted to work with byobu, my preferred terminal multiplexer. The -l flag is omitted for the exec command as byobu is not a shell.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;/bin/sh -lc &quot;exec /path/to/byobu new-session&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;For completeness’ sake, the setup for a bash profile is a lot simpler, as it sources .profile and .bashrc properly when launched as a login shell -l itself.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;/path/to/bash -l
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;What a ride.&lt;/p&gt;
&lt;p&gt;Shifting from Python to Ruby as my main development language presented an opportunity to review the toolings involved. Previously I relied on pyenv to provide isolated Python interpreters for individual projects. Eventually, the functionality was replaced by uv, which also manages pre-built CPython distributions as well. Unfortunately, I wasn’t aware of an alternative for Ruby, and had to continue relying on rbenv for the purpose.&lt;/p&gt;
&lt;p&gt;One of the main goals of using home-manager, as discussed in the previous article, was to achieve idempotency and to isolate dev packages from the system package manager (such as apt in Ubuntu). Thus, we need to first define the packages in the home-manager flake file, as shown below:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;packages = with pkgs; [
    ...
    # dev libraries
    openssl
    zlib-ng
    bzip2
    readline
    sqlite
    ncurses
    xz
    tcl
    tk
    xml2
    xmlsec
    libffi
    libtool
    libyaml
    libxml2
    libxslt
  ];
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Essentially, this yields an isolated filesystem namespace that is unrecognized by the operating system. In order to get the compiler to utilize the pulled development package, we need to tell &lt;code&gt;rbenv&lt;/code&gt; where to look for them, as shown in the shell script below:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;#!/usr/bin/env sh

export CPPFLAGS=&quot;-I$HOME/.nix-profile/include $CPPFLAGS&quot;
export CFLAGS=&quot;-I$HOME/.nix-profile/include $CFLAGS&quot;
export LDFLAGS=&quot;-L$HOME/.nix-profile/lib $LDFLAGS&quot;
export PKG_CONFIG_PATH=$HOME/.nix-profile/lib/pkgconfig:$HOME/.nix-profile/share/pkgconfig:$PKG_CONFIG_PATH
export C_INCLUDE_PATH=$HOME/.nix-profile/include:$C_INCLUDE_PATH
export INCLUDE_PATH=$HOME/.nix-profile/include:$INCLUDE_PATH
export LIBRARY_PATH=$HOME/.nix-profile/lib:$LIBRARY_PATH
export LD_LIBRARY_PATH=$HOME/.nix-profile/lib:$LD_LIBRARY_PATH

$HOME/.nix-profile/bin/rbenv install -vf 3.3.5
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;It worked fine, though I always found this a dirty hack, as it required explicitly defining the flags and include paths. There has to be a better way.&lt;/p&gt;
&lt;h4 id=&quot;the-nix-pivot-discovering-a-declarative-path&quot;&gt;The Nix Pivot: Discovering a Declarative Path&lt;/h4&gt;
&lt;p&gt;If not rbenv, then how do we approach Ruby version management? Turns out we can use another feature provided by Nix to build a development environment through a declarative manner. So how is this relevant to the problem we are looking at though?&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn-images-1.medium.com/max/1024/0*uqrvCw46GE479P0z&quot; alt=&quot;&quot;&gt;&lt;br&gt;
&lt;em&gt;Photo by Jonathon B. Carreño on Unsplash&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;A friend of mine had been hinting for a while that I could completely replace rbenv with Nix but I didn’t explore deeper. That changed when I saw the announcement of the gemini-cli app. In the installation guide, it called for npx, but the tool was not available as a Nix package. After some exchanges with Gemini, it suggested I look into nix shell, and the conversation quickly evolved into an introductory session on nix develop.&lt;/p&gt;
&lt;p&gt;Just a side note, gemini-cli was already packaged not long after the announcement, no need for npx. If I were to follow the installation guide, npx could be made available through a nix shell -p nodejs session. In case you missed how this could be related to our original problem, the equivalent for Ruby would be nix shell -p ruby.&lt;/p&gt;
&lt;p&gt;What if I need to install gems that require some special dev libraries?&lt;/p&gt;
&lt;p&gt;You just unlocked the reason the conversation turned into an introduction to nix develop. Like home-manager, the input for the command is a flake file, written in the Nix programming language, declaring the development environment setup. Naturally, this added complexity made me hesitant to commit to this strategy. I was still trying to get more comfortable with home-manager and wanted to take more conservative baby steps in adopting additional features offered by Nix.&lt;/p&gt;
&lt;p&gt;Essentially, there are two important components to be declared in the flake: the list of packages and an initialization shell script. For example, we can define a development environment sporting Ruby 3.3 with the list of packages like so:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;buildInputs = with pkgs; [
  # fish is kept so that fish-specific completions from
  # packages can be made available.
  fish
  # Git is needed for fetching gems from git repositorie
  git
  # The Ruby interpreter (3.3.8 as the time of writing)
  ruby
  # Bundler for managing Ruby gems.
  bundler
  # Common dependencies for building native extensions.
  cmake
  gcc
  gnumake
  # Libraries often required by gems.
  openssl
  pkg-config
  libmysqlclient
  shared-mime-info
  re2
  readline
  zlib
] ++ lib.optionals stdenv.isDarwin [
  # Add macOS-specific dependencies.
  libiconv
];
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Secondly, the initialization script definition&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;shellHook = ''
  # Ruby-specific setup
  export FREEDESKTOP_MIME_TYPES_PATH=&quot;${pkgs.shared-mime-info}/share/mime/packages/freedesktop.org.xml&quot;

  echo &quot;Ruby dev environment activated for ${system}!&quot;
'';
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Incorporating these into the flake file makes nix develop a superior method to set up a development environment. Compared to just listing packages in nix shell, the shellHook component allows more control and further manipulation, enabling us to set up proper environment variables and run initialization commands. Additionally, the declarative nature of a flake file also means it is highly reproducible and can be easily shared.&lt;/p&gt;
&lt;h4 id=&quot;a-harmonious-setup-validation-and-reflection&quot;&gt;A Harmonious Setup: Validation and Reflection&lt;/h4&gt;
&lt;p&gt;Remember I needed to redefine library path and compiler flags to compile Ruby interpreters? That problem eventually came back while I was installing gems for work projects. Long story short, I gave in and adopted nix develop. Does it work out of the box? Does the adoption of nix develop stop me from shooting myself in the foot?&lt;/p&gt;
&lt;p&gt;In order to be cautious, I replicated and adapted the flake for my bigmeow side project, which is still managed by Poetry, and hence relied on pyenv. Fortunately, a satisfactory outcome was achieved after a few iterations. On the other hand, direnv also offers some integration with nix develop. Automated activation of a development environment can be achieved by adding one line to the respective .envrc:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;use flake /path/to/folder # the folder that contains the flake.nix declaration
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn-images-1.medium.com/max/1024/0*axJYZtjNEhYB998Q&quot; alt=&quot;&quot;&gt;&lt;br&gt;
&lt;em&gt;Photo by Sigmund on Unsplash&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;Applying the Ruby counterpart also went rather smoothly. There were some problems in the earlier iterations, but they were mostly trivial, and all it took was a regeneration by Gemini. Compared to the relatively smooth sailing in environment setup, taming my code editor to work with it was a lot more frustrating.&lt;/p&gt;
&lt;p&gt;In recent years, most extensions bridging the code and development tools would often bundle the tool together. For instance, if I install an extension to format Python code with ruff formatter in Visual Studio Code, it would bundle ruff itself in the package. This saves the developer from needing to declare ruff as a dependency of the project.&lt;/p&gt;
&lt;p&gt;Likely due to the highly modular design, the experience was a lot more complicated in the Ruby world. One of my work projects defined an ancient version of the Rubocop formatter, and none of the editor extensions offered in the marketplace would work with it. Forcing the extension to use the bundled gem would fail, because it wouldn’t work with the equally outdated rubocop extension gems (rubocop-rails, rubocop-minitest) installed in the project.&lt;/p&gt;
&lt;p&gt;Updating the lock file is not a practical solution and I was stuck there for a while. Thankfully a coworker shared that I could explore the &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/Shopify/ruby-lsp&quot;&gt;ruby-lsp extension&lt;/a&gt;. Apparently, this extension makes it possible to refer to an alternative Gemfile, i.e. I could have a Gemfile for all the development tools saved outside of the project. In this particular case, I could have a Gemfile like this&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;source &quot;https://rubygems.org&quot;

gem &quot;ruby-lsp&quot;
gem &quot;rubocop&quot;, &quot;1.26.1&quot;
gem &quot;rubocop-rails&quot;
gem &quot;rubocop-minitest&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Solving that yields an functional setup that I was happy enough for work. Looking back, everything seemed rather trivial, but in reality I was stuck for a couple of days. Despite the added complexity, getting all these parts working together felt like putting together puzzle pieces. Getting them configured correctly is giving the same satisfaction as solving a complex jigsaw puzzle.&lt;/p&gt;
&lt;p&gt;Is this increased complexity worth the effort? Would there be a better alternative strategy out there? I am curious to know about them and am open to recommendations and corrections. Meanwhile, I do find the revision to retire version managers a fruitful learning experience. Another part I liked about the current implementation is the elimination of explicit declaration of dev packages to build the interpreters. If you ask me, I would say replacing the version managers and removing dev packages for interpreter compilation with nix develop flake files is totally worth the effort.&lt;/p&gt;
&lt;p&gt;Throughout this journey, Gemini served as a valuable editorial companion, helping refine my prose. However, the voice and code remain entirely my own. I welcome your feedback and recommendations in the comments below, and invite you to subscribe to my &lt;a rel=&quot;nofollow&quot; href=&quot;https://kitfucoda.medium.com/&quot;&gt;Medium&lt;/a&gt; for more content on my development adventures!&lt;/p&gt;
&lt;hr&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/4184/beyond-rbenv-and-pyenv-achieving-reproducible-dev-setups-with-nix</guid>
<pubDate>Sun, 20 Jul 2025 18:59:08 +0000</pubDate>
</item>
<item>
<title>Containerization on macOS vs. Linux: A Security and Isolation Comparison, Including Darwin Containers and OSXIEC</title>
<link>https://coderlegion.com/1638/containerization-macos-security-isolation-comparison-including-darwin-containers-osxiec</link>
<description>&lt;h2 id=&quot;containerization-on-macos-vs-linux-a-comparative-look&quot;&gt;Containerization on macOS vs. Linux: A Comparative Look&lt;/h2&gt;
&lt;p&gt;Containerization has revolutionized application deployment and management, providing lightweight, portable, and isolated environments for running software. While Linux has been the dominant platform for containerization technologies like Docker, macOS introduces unique challenges. In this article, we explore the differences between containerization on macOS and Linux, the role of &lt;strong&gt;System Integrity Protection (SIP)&lt;/strong&gt;, and a deeper look at &lt;strong&gt;OSXIEC&lt;/strong&gt;—a native containerization solution for macOS. Additionally, we'll examine &lt;strong&gt;Darwin Containers&lt;/strong&gt;, a project that ports containers to Darwin-based platforms like macOS but requires to turn off SIP protection, and the implications of disabling SIP.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;containerization-on-macos-vs-linux-key-differences&quot;&gt;Containerization on macOS vs. Linux: Key Differences&lt;/h3&gt;
&lt;h4 id=&quot;1-namespaces-and-kernel-isolation&quot;&gt;1. &lt;strong&gt;Namespaces and Kernel Isolation&lt;/strong&gt;&lt;/h4&gt;
&lt;p&gt;Linux containers leverage the &lt;strong&gt;namespace&lt;/strong&gt; feature of the Linux kernel to isolate processes, file systems, networking, and more. This is essential for creating secure and lightweight containers that don’t interfere with the host system or other containers.&lt;/p&gt;
&lt;p&gt;macOS, however, uses a different Unix-based kernel (Darwin), which does not support Linux namespaces. As a result, macOS containers cannot achieve the same level of isolation as their Linux counterparts. While macOS can achieve some form of containerization, it typically doesn't offer the same degree of process isolation or resource management.&lt;/p&gt;
&lt;h4 id=&quot;2-system-integrity-protection-sip-&quot;&gt;2. &lt;strong&gt;System Integrity Protection (SIP)&lt;/strong&gt;&lt;/h4&gt;
&lt;p&gt;&lt;strong&gt;System Integrity Protection (SIP)&lt;/strong&gt; is a security feature on macOS designed to safeguard the system by preventing even root users from making changes to certain protected files and processes. This limits the ability to tamper with critical parts of the operating system, providing an extra layer of defense against malware or unauthorized system modifications.&lt;/p&gt;
&lt;p&gt;SIP is especially important in the context of containerization. On macOS, containerization tools like &lt;strong&gt;OSXIEC&lt;/strong&gt; respect SIP to ensure the integrity of the host system. While SIP limits certain capabilities, it’s a trade-off for increased system security.&lt;/p&gt;
&lt;p&gt;Disabling SIP, however, removes these protections, allowing greater control over the system. While this can be useful for some containerization solutions that require deeper system access, it exposes the system to higher risks of compromise. Therefore, developers should weigh the potential benefits of turning off SIP against the increased security vulnerabilities.&lt;/p&gt;
&lt;h4 id=&quot;3-file-system-isolation&quot;&gt;3. &lt;strong&gt;File System Isolation&lt;/strong&gt;&lt;/h4&gt;
&lt;p&gt;Linux containers use namespaces to isolate file systems, ensuring that containers only have access to their own files and not to files on the host system or other containers. On macOS, file system isolation isn't as robust, as the system doesn’t provide the same namespace-based separation.&lt;/p&gt;
&lt;p&gt;However, with tools like &lt;strong&gt;OSXIEC&lt;/strong&gt;, developers can still containerize applications in macOS, providing an environment where applications can be isolated from the host system. Nonetheless, a root user on macOS could potentially bypass these restrictions.&lt;/p&gt;
&lt;h4 id=&quot;4-networking-and-virtualization&quot;&gt;4. &lt;strong&gt;Networking and Virtualization&lt;/strong&gt;&lt;/h4&gt;
&lt;p&gt;Linux containers can create complex virtual networks, set up custom bridges, and handle inter-container communication using network namespaces. macOS, however, lacks this level of native networking support, which complicates the creation of isolated virtual networks for containers.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;OSXIEC&lt;/strong&gt; addresses this by offering network containerization features, allowing you to set up VLANs and create isolated networks for containers along with assigning ips to individual containers. While this is an improvement over other macOS containerization solutions, it's not as mature or flexible as Linux container networking, which has been refined over years of development.&lt;/p&gt;
&lt;h4 id=&quot;5-security-and-isolation&quot;&gt;5. &lt;strong&gt;Security and Isolation&lt;/strong&gt;&lt;/h4&gt;
&lt;p&gt;Security in containerized environments is a critical concern, especially when evaluating the level of isolation between containers and the host system. On Linux, containerization is achieved through namespaces and cgroups, which provide strong isolation between containers and the host, limiting potential security risks. However, a root user within a container can potentially access or manipulate certain system resources if the isolation is not properly configured.&lt;/p&gt;
&lt;p&gt;On macOS, containers like &lt;strong&gt;OSXIEC&lt;/strong&gt; offer some level of isolation, but it is less robust compared to Linux-based containers. macOS doesn't have the same namespace-based isolation mechanisms that Linux provides, which makes containers on macOS more vulnerable to security risks. Moreover, while System Integrity Protection (SIP) adds an additional layer of security to prevent unauthorized system modifications, it does not fully mitigate the risks associated with container isolation.&lt;/p&gt;
&lt;p&gt;While &lt;strong&gt;OSXIEC&lt;/strong&gt; containers offer some security features, such as vulnerability scanning, they are inherently less isolated than their Linux counterparts. This means that containers on macOS could be more susceptible to security breaches if not properly managed. Developers and security professionals need to be aware of these limitations and carefully assess the security risks when deploying containers on macOS.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;why-we-use-sip-protecting-macos-systems&quot;&gt;Why We Use SIP: Protecting macOS Systems&lt;/h3&gt;
&lt;p&gt;System Integrity Protection (SIP) is a critical security feature in macOS that prevents unauthorized modifications to critical system files, processes, and directories, even by users with root access. While this may limit some containerization capabilities, it is a necessary security measure to protect against malware and other forms of system compromise.&lt;/p&gt;
&lt;p&gt;In the context of &lt;strong&gt;OSXIEC&lt;/strong&gt;, SIP ensures that the integrity of the macOS host is maintained even while containerized applications are running. Disabling SIP would provide more control over the system but at the cost of reducing security. In environments where security is paramount, it is crucial to maintain SIP to ensure that the system remains protected.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;darwin-containers-porting-containers-to-macos&quot;&gt;Darwin Containers: Porting Containers to macOS&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;Darwin Containers&lt;/strong&gt; is an open-source project aimed at bringing containerization to Darwin-based platforms like macOS. It provides a port of container technologies (like Docker) to macOS by emulating the required kernel functionalities on the Darwin platform. However, it’s important to note that Darwin Containers requires disabling &lt;strong&gt;System Integrity Protection (SIP)&lt;/strong&gt; in order to function.&lt;/p&gt;
&lt;h4 id=&quot;pros-and-cons-of-disabling-sip-for-darwin-containers&quot;&gt;Pros and Cons of Disabling SIP for Darwin Containers&lt;/h4&gt;
&lt;h5 id=&quot;pros-&quot;&gt;Pros:&lt;/h5&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Full Kernel Access&lt;/strong&gt;: Disabling SIP gives Darwin Containers full access to the system’s kernel, allowing it to implement containerization features such as namespace-based isolation and network management that macOS doesn't natively support.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Improved Functionality&lt;/strong&gt;: With SIP disabled, Darwin Containers can work similarly to Docker on Linux, offering better performance and greater flexibility in terms of container features.&lt;/li&gt;
&lt;/ul&gt;
&lt;h5 id=&quot;cons-&quot;&gt;Cons:&lt;/h5&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Security Risks&lt;/strong&gt;: Disabling SIP exposes the system to potential security threats. Without SIP, a compromised application or malicious software could gain elevated privileges and make modifications to critical system files. This increases the risk of system compromise.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;System Stability&lt;/strong&gt;: Turning off SIP can lead to system instability, as certain macOS protections are bypassed. This could result in unforeseen issues, especially if updates or patches are not applied correctly.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Limited to Development Environments&lt;/strong&gt;: Since disabling SIP is generally not recommended for production environments, using Darwin Containers is primarily suitable for development and testing environments where security is less of a concern.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;While &lt;strong&gt;Darwin Containers&lt;/strong&gt; provides a method for running Docker-like containers on macOS, the requirement to disable SIP presents a significant trade-off. For users who value the security and integrity of their macOS system, this may not be an acceptable solution.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;introducing-osxiec-a-secure-native-macos-containerization-solution&quot;&gt;Introducing &lt;strong&gt;OSXIEC&lt;/strong&gt;: A Secure Native macOS Containerization Solution&lt;/h3&gt;
&lt;p&gt;While &lt;strong&gt;Darwin Containers&lt;/strong&gt; offers a Docker-like experience for macOS, it comes with the significant downside of requiring SIP to be disabled. This can leave the system vulnerable to security risks. In contrast, &lt;strong&gt;OSXIEC&lt;/strong&gt; is a native macOS solution that respects SIP, ensuring that users can containerize applications while keeping their systems secure.&lt;/p&gt;
&lt;h4 id=&quot;key-features-of-osxiec-&quot;&gt;Key Features of &lt;strong&gt;OSXIEC&lt;/strong&gt;:&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Native macOS Integration&lt;/strong&gt;: &lt;strong&gt;OSXIEC&lt;/strong&gt; works natively with macOS without requiring the use of virtualization or disabling SIP.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Secure Isolation&lt;/strong&gt;: Containers created with &lt;strong&gt;OSXIEC&lt;/strong&gt; provide a level of isolation, but without the need to turn off critical system protections like SIP.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Networking Support&lt;/strong&gt;: &lt;strong&gt;OSXIEC&lt;/strong&gt; offers container networking, including VLANs and isolated virtual networks.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Security Focused&lt;/strong&gt;: &lt;strong&gt;OSXIEC&lt;/strong&gt; is built with security in mind, ensuring that your macOS system remains safe from tampering while running containerized applications.&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id=&quot;conclusion-1&quot;&gt;Conclusion&lt;/h4&gt;
&lt;p&gt;While &lt;strong&gt;Darwin Containers&lt;/strong&gt; provides an option to bring Docker-like containerization to macOS, it requires disabling &lt;strong&gt;System Integrity Protection (SIP)&lt;/strong&gt;, a feature designed to protect the system from tampering. This trade-off may be acceptable for certain use cases, especially in development environments where security concerns are lower. However, for those who prioritize system integrity, &lt;strong&gt;OSXIEC&lt;/strong&gt; provides a safer alternative by respecting SIP while offering native containerization features.&lt;/p&gt;
&lt;p&gt;Whether you choose &lt;strong&gt;Darwin Containers&lt;/strong&gt; or &lt;strong&gt;OSXIEC&lt;/strong&gt;, it's important to understand the trade-offs between system security and functionality. If you’re looking for a macOS-native containerization solution that maintains high security standards, &lt;strong&gt;OSXIEC&lt;/strong&gt; may be the right choice. However, if you need more advanced container features and are willing to accept the risks associated with disabling SIP, &lt;strong&gt;Darwin Containers&lt;/strong&gt; would be better.&lt;/p&gt;
&lt;p&gt;Interested in learning more about &lt;strong&gt;OSXIEC&lt;/strong&gt;? Check out the &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/Okerew/osxiec&quot;&gt;official repository&lt;/a&gt; and get started with containerizing your macOS applications today.&lt;/p&gt;
&lt;p&gt;Or interested in learning more about &lt;strong&gt;Darwin Containers&lt;/strong&gt;? Check out the &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/darwin-containers/rund&quot;&gt;official repository&lt;/a&gt; and get started with containerizing your macOS applications today with docker though.&lt;/p&gt;
</description>
<category>Open Source Tools</category>
<guid isPermaLink="true">https://coderlegion.com/1638/containerization-macos-security-isolation-comparison-including-darwin-containers-osxiec</guid>
<pubDate>Mon, 24 Mar 2025 21:03:43 +0000</pubDate>
</item>
</channel>
</rss>