← Build log

Everything That Works in tauri dev But Breaks the Moment You Ship a Real Mac Build

2026-06-24

tauri dev tests your code; it does not test your app — the bundle, the OS permission prompts, and the hardware are a second program you only run when you ship. I learned that over four separate “wait, it worked yesterday” mornings on VoiceCatcher: a microphone that recorded zero samples once bundled, an auto-paste that pasted nothing on the installed app while reporting success, an app icon that quietly went generic, and two bugs that physically could not exist in dev because they needed a stranger’s Mac and a stranger’s audio interface to show up. None of these were code bugs. All of them were ship bugs.

This is the engineering companion to post 147, which was about the signing half of “my app runs on someone else’s Mac.” That post was the toll booth. This one is everything past the toll booth that still goes wrong.

TLDR

What You’ll Gain:

  • The actual reason a Tauri Mac app that works perfectly in npm run tauri dev records silence, pastes nothing, or wears a generic icon once it’s a real .dmg
  • A dev-vs-bundled-vs-installed comparison table so you know which environment a given class of bug can even appear in
  • Copy-paste tccutil reset commands for when macOS caches a stale permission denial against your bundle id
  • Why two of my nastiest bugs were physically impossible to find in dev — and what that means for your test plan
  • Bottom Line Value: Budget a whole second testing pass for the bundled, installed, on-a-machine-that-isn’t-yours version of your app. It is a different program. Treat it like one.

Date-stamped 2026-06-24. Build machine on macOS 26.5.1, app built with Tauri 2, bundle id com.harte.voicecatcher. Honesty up front, same as post 147: VoiceCatcher is in testing, not shipped to customers, and signing is still in progress. Everything below is verified against this project’s git history and live failures, not theory.

The thing nobody warns you about: dev mode is a liar with good intentions

Here’s the mental model that took me too long to build. When you run tauri dev, the OS sees your terminal as the thing asking for the microphone. Your terminal already has a microphone grant — you gave it one months ago for some other tool and forgot. So your app inherits that grant for free. Same with a hundred other ambient bits of trust your dev environment has accumulated like lint in a dryer.

Bundle the app and hand it to someone else, and all of that inherited trust evaporates. The installed .app is a brand-new entity to macOS. It has no inherited mic grant. It has no inherited Accessibility grant. It has whatever icon survived the bundler, not whatever’s in your source tree. And it’s running on hardware you’ve never seen.

Dev mode isn’t lying to be cruel. It’s lying because it’s convenient — it borrows your environment’s trust so you can iterate fast. The bill comes due at bundle time, all at once, usually in front of a tester.

Why does my Tauri app record nothing after I bundle it?

Because macOS silently denies microphone access to any app that doesn’t declare NSMicrophoneUsageDescription in its Info.plist. No prompt. No error. Zero samples. The recording state machine runs perfectly, the timer ticks, the waveform animates — and the buffer is full of silence.

This one genuinely rattled me, because there was nothing to debug. The code was identical to the code that worked in dev five minutes earlier. There was no exception, no log line, no permission dialog denied-by-the-user. Just a transcript that came back empty every single time, on a clean install, while tauri dev kept working like nothing was wrong.

The root cause: the Tauri scaffold never generated an Info.plist, so no usage strings existed in the bundle. macOS treats “no NSMicrophoneUsageDescription key” not as “ask the user” but as “this app has no business with the mic — deny, and don’t even prompt.” Dev mode masked it because the dev process inherited the terminal’s existing TCC mic grant. A clean install has no inherited grant and must declare its own usage. (TCC is Apple’s Transparency, Consent, and Control system — the database behind every “X would like to access your microphone” prompt.)

The fix was 15 lines of plist that tauri-bundler merges into the bundle (commit 6012dc6, catalogued as AUD-015 in ERRORS.md):

<key>NSMicrophoneUsageDescription</key>
<string>VoiceCatcher needs the microphone to record and transcribe your voice.</string>

Verified the key lands in the built DMG’s Contents/Info.plist, and that a clean install now shows the mic prompt and records and transcribes (live-tested 2026-06-19). One gotcha that’ll cost you an afternoon if you don’t know it: if an earlier broken build already cached a TCC denial against your bundle id, the new build inherits the cached “no.” Blow it away:

tccutil reset Microphone com.harte.voicecatcher

Then relaunch and you’ll get the prompt you should have gotten the first time.

Why does auto-paste say it worked but nothing gets pasted?

Because I was sending the keystroke through the wrong door, and macOS attributed it to a helper process instead of to my app. The text reached the clipboard fine. The simulated Cmd+V went into the void. And — this is the cruel part — granting Accessibility to VoiceCatcher didn’t fix it, which sent me chasing a completely wrong theory for a while.

The original implementation pasted via osascript telling “System Events” to send Cmd+V. That path has two problems that only surface on a real install:

  1. It needs both Accessibility and Automation permission, not just Accessibility.
  2. The keystroke is attributed to the System Events helper, not to VoiceCatcher. So the app would show up as enabled under Privacy → Accessibility, and still fail, with osascript is not allowed to send keystrokes (1002). You’re staring at a checkbox that’s checked, next to a feature that doesn’t work. That’s a special kind of maddening.

I’ll admit the embarrassing detour: my first theory was that ad-hoc signing was breaking the grant. Wrong. I burned real time on that before I went and cracked open the legacy Electron version of this app — which pasted reliably even unsigned — and found CGEventCreateKeyboardEvent and AXIsProcessTrustedWithOptions in its asar. The old app never used osascript at all. It posted the keystroke straight from its own process.

So that’s the fix (commits 18f013d and 2cb7fc9, catalogued as AUD-016). Synthesize Cmd+V with CGEventCreateKeyboardEvent and CGEventPost directly from the app’s own process. That needs only Accessibility — no Automation, no helper, no attribution mismatch. And because CGEventPost is silent when the app isn’t trusted (no error, no prompt, no paste — the worst possible failure mode), we now check AXIsProcessTrusted() and, when it’s false, fire the system Accessibility prompt via AXIsProcessTrustedWithOptions({kAXTrustedCheckOptionPrompt: true}) and hand back a clear message: your text is on the clipboard, press ⌘V, here’s where to grant Accessibility.

Two things to pin down, because they’ll bite you:

  • The grant only takes effect after relaunch. You tick the box, nothing changes, you assume it’s broken. It isn’t. Quit and reopen.
  • Unsigned rebuilds get a new identity each time. Every fresh unsigned build looks like a different app to macOS, so you accrue a little graveyard of duplicate “VoiceCatcher” rows in the Accessibility list, only one of which is the current one. Collapse them:
tccutil reset Accessibility com.harte.voicecatcher

This is also a great argument for finishing the signing work in post 147 — a stable signed identity means one row, not a row per rebuild.

Why did my app icon turn generic after bundling?

Because the real, branded icon didn’t survive the bundling step. The app I’d been looking at in dev had the right face. The .app and the .dmg that came out of the bundler had the generic one. The icon lives in the source tree but doesn’t automatically end up stamped on the built artifacts the way you’d assume.

The fix was unglamorous: explicitly stamp the branded icon onto the .app and onto the .dmg as part of the build (commits 343abab and afc8475). Not a deep bug. But a perfect little member of the category — it looks done in dev, it isn’t done in the bundle, and the only way you find out is by looking at the actual shipped artifact instead of the thing your dev server is running.

The two bugs that could not exist in dev

Here’s where it stops being “configure the bundle correctly” and gets genuinely humbling. Two of VoiceCatcher’s nastiest bugs were structurally impossible to encounter in development. Not unlikely. Impossible. They needed a real install or real hardware to exist at all.

AUD-017 — the second recording pasted the previous message. A race in the CGEventTap smart-paste path (the bit that restores your old clipboard after pasting the transcription) meant your second recording could paste the first recording’s text. This needs the real paste-and-restore cycle running on a real install to surface. It’s getting its own deep-dive in post 149.

AUD-018 — Whisper dropped soft, leading words on a 4-channel interface. Plug VoiceCatcher into a Scarlett 2i2 (a 4-channel USB audio interface, a thing plenty of real people own) and Whisper would quietly eat soft or leading words — “auto” would vanish off the front of “auto mode,” and you’d never know it was supposed to be there. I do not own a 4-channel interface as my daily driver. My dev machine’s built-in mic is mono. There is no universe in which I find this bug by running tauri dev on my laptop. It took a specific human plugging in specific hardware. That’s post 150.

I’m not going to explain the fixes here — they each earned their own post. The point of naming them now is the lesson they share: you cannot find this class of bug without testing on real hardware and real installs. There’s no clever local test that substitutes. Your dev machine is one configuration. Your testers, collectively, are a fuzzing harness made of other people’s stuff.

Dev vs. bundled vs. installed-on-a-stranger’s-Mac

Here’s the table I wish I’d had taped to my monitor at the start. This is what I actually observed building VoiceCatcher on macOS 26.5.1 with Tauri 2 — not a permanent law, but the map for this project.

Failuretauri devBundled .dmg on my MacInstalled on a stranger’s Mac
Mic records nothing (no NSMicrophoneUsageDescription)Hidden — inherits terminal’s mic grantOften hidden — my machine had a cached grantAppears — clean machine, no inherited grant, silent denial
Auto-paste silently no-ops (osascript path)Hidden — dev environment’s ambient trustSometimes appearsAppears — needs Accessibility/Automation it doesn’t have
Generic app iconHidden — dev shows source iconAppears — look at the actual artifactAppears
Smart-paste race pastes prior message (AUD-017)Can’t appear — needs real install paste cycleAppearsAppears
Whisper drops soft words on 4-channel interface (AUD-018)Can’t appear — needs the hardwareOnly if you own a Scarlett 2i2Appears — someone’s real interface

The pattern jumps out once it’s in a grid. The leftmost column is the most flattering and the least true. Everything gets more honest as you move right. The rightmost column is the only one that matters, and it’s the only one you can’t run yourself.

The Human Reality

The microphone one got me good. I sat there watching the waveform bounce — the UI was so convincing — and got an empty transcript back, and my first instinct was to suspect Whisper, then the audio buffer, then cpal, then my own resampling code. I went three layers deep into the recording stack looking for a bug that wasn’t in the recording stack. It was a missing string in a file the scaffold never created. Forty minutes of staring at audio code because the failure mode for “macOS denied you the mic” is, infuriatingly, identical to “your audio code recorded silence.” Same empty buffer either way.

And the osascript auto-paste detour, where I blamed signing — that one stung because I was confidently wrong in a documented commit before I corrected it. The fix commit literally says “corrected — earlier theory was wrong.” I left that in the record on purpose. The wrong theory is part of the map.

There’s a specific flavor of dread to bundle bugs that code bugs don’t have. A code bug, you can reproduce on demand, set a breakpoint, watch it happen. A bundle bug happens on a machine you don’t have, in a trust context you can’t see, and reports back to you as a tester saying “yeah it just… didn’t do anything?” You’re debugging through a keyhole, by proxy, on hardware in another building.

What This Means for Us

If you’re shipping a Tauri app — or honestly any native Mac app — to people who aren’t you, internalize the one-liner: the bundle is a second program. Your code passing in dev tells you the code is probably fine. It tells you nothing about whether the app works, because the app is the code plus the bundle plus the OS’s permission decisions plus the hardware it lands on, and three of those four don’t exist until you ship.

The good news, and it is genuinely good news for a solo builder: every one of these has a known fix and a one-line catalog entry now. The mic plist, the CGEventPost paste, the icon stamp — fixed and written down in ERRORS.md so the next person (probably future me) doesn’t re-derive them at 11pm. The two hardware/install-only bugs are why I keep a tester or two with weird audio gear on speed dial. They find things my clean little dev environment is constitutionally incapable of finding.

That’s the whole shift. Stop trusting “works on my machine.” Start trusting “works on a machine that has never met me.” Those are different claims, and only the second one ships.


Next steps you can take today:

  • Build the actual artifact and test that, not your dev server. Run npm run tauri build, install the .dmg on a machine that isn’t your build box, and click every button.
  • Declare your usage strings before you bundle. If your app touches the mic, camera, or sends events, add the matching NS*UsageDescription keys to an Info.plist now. macOS silently denies what you don’t declare.
  • Keep the tccutil resets handy for when a stale grant haunts a rebuild: tccutil reset Microphone com.harte.voicecatcher and tccutil reset Accessibility com.harte.voicecatcher (swap in your own bundle id).
  • Recruit at least one tester with hardware you don’t own. A non-default audio interface, an older Mac, a clean macOS install. They are your only window into the rightmost column of that table.
  • Finish signing (see post 147) so your app has one stable identity instead of a fresh one per unsigned rebuild — it makes the permission story sane.
  • Watch for posts 149 (the smart-paste race) and 150 (the 4-channel word-dropping) for the deep-dives on the two install/hardware-only bugs.