{
    "componentChunkName": "component---src-templates-blog-detail-tsx",
    "path": "/blog/tauri-desktop-app/",
    "result": {"data":{"site":{"siteMetadata":{"siteTitleShort":"Developer Portfolio"}},"markdownRemark":{"id":"73b5037d-2aed-5a36-a986-dd0b2eccd703","excerpt":"The browser was never going to do this So I built a desktop application. It’s called Semitone, it’s not public yet, and it’s a React app inside Tauri 2 with a…","html":"<h2>The browser was never going to do this</h2>\n<p class=\"lead\">Take a photo of a printed sheet-music part, get it back transposed into a different key and clef, with every accent and repeat intact, and print it. That's the whole product. It needs a 200 MB recognition engine written in Java, a PDF rasteriser, a local web server for the phone, and the ability to spawn and kill processes. None of that runs in a tab.</p>\n<p>So I built a desktop application. It’s called Semitone, it’s not public yet, and it’s a React app inside Tauri 2 with a Rust backend that does everything the browser can’t. This post is the Tauri half: what a React developer needs to know, what I’d do again, and the bits that ate whole days.</p>\n<p>The example that started the project is a <code class=\"language-text\">1. Flügelhorn in B</code> part from a brass-band folder that has to be played from bass clef in C. Today the options are transposing by hand, re-entering it in a notation editor, or not playing. I’ll leave the music side for another post, because the desktop side is plenty.</p>\n<h3>What Tauri actually is</h3>\n<p>Tauri gives you a native window with a system webview in it (WebKit on macOS, WebView2 on Windows) and a Rust process behind it. Your frontend is whatever you’d build for the web. Mine is React 19, Vite, <a href=\"/blog/tailwind-css-v4/\">Tailwind v4</a> and shadcn/ui, with Vitest for tests, and if you removed Tauri from the repository it would still start in a browser and render every screen. The Rust side exposes functions the frontend can call, and that’s the entire contract.</p>\n<div class=\"gatsby-highlight\" data-language=\"sh\"><pre class=\"language-sh\"><code class=\"language-sh\">npm create tauri-app@latest</code></pre></div>\n<p>That scaffolds both halves. The frontend lives in <code class=\"language-text\">src/</code>, the native side in <code class=\"language-text\">src-tauri/</code>, and <code class=\"language-text\">tauri.conf.json</code> describes the window, the bundle and the security policy. The dev loop is <code class=\"language-text\">npm run tauri dev</code>, which starts Vite on a fixed port and opens a window pointed at it. Hot reload works. Rust changes recompile and restart the window, which takes a few seconds and is the one place where the loop is slower than a web project.</p>\n<p>Two Vite settings matter and the template sets both. The port is fixed and strict, because Tauri expects to find the dev server where it said it would be, and Vite is told to ignore <code class=\"language-text\">src-tauri/</code> so a Rust edit doesn’t trigger a frontend rebuild.</p>\n<h3>Commands, the door between the two worlds</h3>\n<p>A command is a Rust function with an attribute on it:</p>\n<div class=\"gatsby-highlight\" data-language=\"rust\"><pre class=\"language-rust\"><code class=\"language-rust\"><span class=\"token attribute attr-name\">#[tauri::command]</span>\n<span class=\"token keyword\">pub</span> <span class=\"token keyword\">fn</span> <span class=\"token function-definition function\">host_info</span><span class=\"token punctuation\">(</span><span class=\"token punctuation\">)</span> <span class=\"token punctuation\">-></span> <span class=\"token class-name\">HostInfo</span> <span class=\"token punctuation\">{</span>\n    <span class=\"token class-name\">HostInfo</span> <span class=\"token punctuation\">{</span>\n        platform<span class=\"token punctuation\">:</span> <span class=\"token namespace\">std<span class=\"token punctuation\">::</span>env<span class=\"token punctuation\">::</span>consts<span class=\"token punctuation\">::</span></span><span class=\"token constant\">OS</span><span class=\"token punctuation\">.</span><span class=\"token function\">to_string</span><span class=\"token punctuation\">(</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">,</span>\n        app_version<span class=\"token punctuation\">:</span> <span class=\"token macro property\">env!</span><span class=\"token punctuation\">(</span><span class=\"token string\">\"CARGO_PKG_VERSION\"</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">.</span><span class=\"token function\">to_string</span><span class=\"token punctuation\">(</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">,</span>\n    <span class=\"token punctuation\">}</span>\n<span class=\"token punctuation\">}</span></code></pre></div>\n<p>Register it in the builder, and the frontend calls it by name:</p>\n<div class=\"gatsby-highlight\" data-language=\"ts\"><pre class=\"language-ts\"><code class=\"language-ts\"><span class=\"token keyword\">import</span> <span class=\"token punctuation\">{</span> invoke <span class=\"token punctuation\">}</span> <span class=\"token keyword\">from</span> <span class=\"token string\">'@tauri-apps/api/core'</span><span class=\"token punctuation\">;</span>\n\n<span class=\"token keyword\">const</span> info <span class=\"token operator\">=</span> <span class=\"token keyword\">await</span> <span class=\"token generic-function\"><span class=\"token function\">invoke</span><span class=\"token generic class-name\"><span class=\"token operator\">&lt;</span>HostInfo<span class=\"token operator\">></span></span></span><span class=\"token punctuation\">(</span><span class=\"token string\">'host_info'</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span></code></pre></div>\n<p>Arguments and return values go through JSON, so anything that derives <code class=\"language-text\">Serialize</code> on the Rust side arrives as a plain object. The naming convention differs between the two languages, so nearly every struct I return carries <code class=\"language-text\">#[serde(rename_all = \"camelCase\")]</code>, and errors are enums tagged with a <code class=\"language-text\">code</code> field so the frontend can switch on them rather than parse sentences.</p>\n<p>Long work must not run on the thread that owns the window. Marking a command <code class=\"language-text\">async</code> puts it on Tauri’s runtime, and CPU-bound work goes one step further to the blocking pool:</p>\n<div class=\"gatsby-highlight\" data-language=\"rust\"><pre class=\"language-rust\"><code class=\"language-rust\"><span class=\"token attribute attr-name\">#[tauri::command]</span>\n<span class=\"token keyword\">pub</span> <span class=\"token keyword\">async</span> <span class=\"token keyword\">fn</span> <span class=\"token function-definition function\">export_pdf</span><span class=\"token punctuation\">(</span>svg_pages<span class=\"token punctuation\">:</span> <span class=\"token class-name\">Vec</span><span class=\"token operator\">&lt;</span><span class=\"token class-name\">String</span><span class=\"token operator\">></span><span class=\"token punctuation\">,</span> destination<span class=\"token punctuation\">:</span> <span class=\"token class-name\">String</span><span class=\"token punctuation\">)</span> <span class=\"token punctuation\">-></span> <span class=\"token class-name\">Result</span><span class=\"token operator\">&lt;</span><span class=\"token punctuation\">(</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">,</span> <span class=\"token class-name\">String</span><span class=\"token operator\">></span> <span class=\"token punctuation\">{</span>\n    <span class=\"token namespace\">tauri<span class=\"token punctuation\">::</span>async_runtime<span class=\"token punctuation\">::</span></span><span class=\"token function\">spawn_blocking</span><span class=\"token punctuation\">(</span><span class=\"token keyword\">move</span> <span class=\"token closure-params\"><span class=\"token closure-punctuation punctuation\">|</span><span class=\"token closure-punctuation punctuation\">|</span></span> <span class=\"token punctuation\">{</span>\n        <span class=\"token keyword\">let</span> pdf <span class=\"token operator\">=</span> <span class=\"token function\">svg_pages_to_pdf</span><span class=\"token punctuation\">(</span><span class=\"token operator\">&amp;</span>svg_pages<span class=\"token punctuation\">)</span><span class=\"token punctuation\">.</span><span class=\"token function\">map_err</span><span class=\"token punctuation\">(</span><span class=\"token closure-params\"><span class=\"token closure-punctuation punctuation\">|</span>e<span class=\"token closure-punctuation punctuation\">|</span></span> e<span class=\"token punctuation\">.</span><span class=\"token function\">to_string</span><span class=\"token punctuation\">(</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">)</span><span class=\"token operator\">?</span><span class=\"token punctuation\">;</span>\n        <span class=\"token namespace\">std<span class=\"token punctuation\">::</span>fs<span class=\"token punctuation\">::</span></span><span class=\"token function\">write</span><span class=\"token punctuation\">(</span><span class=\"token operator\">&amp;</span>destination<span class=\"token punctuation\">,</span> pdf<span class=\"token punctuation\">)</span><span class=\"token punctuation\">.</span><span class=\"token function\">map_err</span><span class=\"token punctuation\">(</span><span class=\"token closure-params\"><span class=\"token closure-punctuation punctuation\">|</span>e<span class=\"token closure-punctuation punctuation\">|</span></span> e<span class=\"token punctuation\">.</span><span class=\"token function\">to_string</span><span class=\"token punctuation\">(</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">)</span>\n    <span class=\"token punctuation\">}</span><span class=\"token punctuation\">)</span>\n    <span class=\"token punctuation\">.</span><span class=\"token keyword\">await</span>\n    <span class=\"token punctuation\">.</span><span class=\"token function\">map_err</span><span class=\"token punctuation\">(</span><span class=\"token closure-params\"><span class=\"token closure-punctuation punctuation\">|</span>e<span class=\"token closure-punctuation punctuation\">|</span></span> e<span class=\"token punctuation\">.</span><span class=\"token function\">to_string</span><span class=\"token punctuation\">(</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">)</span><span class=\"token operator\">?</span>\n<span class=\"token punctuation\">}</span></code></pre></div>\n<p>Laying out a twenty-page part is not something to do on a thread other commands are waiting on, and the frontend’s progress bar is one of those commands.</p>\n<p>For the other direction, Rust to frontend, there are events. The recognition engine runs for minutes and writes a log, so the Rust side follows the log and emits progress events that the React side subscribes to with <code class=\"language-text\">listen</code>. Same mechanism when the phone submits a photo, and when a second document is double-clicked while the app is already open.</p>\n<h3>The rule I’d keep even if I threw everything else away</h3>\n<p>Nothing in the frontend imports <code class=\"language-text\">@tauri-apps/*</code> except one folder, <code class=\"language-text\">src/platform/</code>. It exports a <code class=\"language-text\">Host</code> interface, every <code class=\"language-text\">invoke</code> in the application lives behind it, and the rest of the app asks for <code class=\"language-text\">getHost()</code> and doesn’t know what answers.</p>\n<p>The reason is that a web version is a plausible future, and with this boundary it is one module swap: an HTTP-backed host instead of a Tauri-backed one. Without the boundary it’s a rewrite. So it is enforced by ESLint rather than remembered:</p>\n<div class=\"gatsby-highlight\" data-language=\"jsx\"><pre class=\"language-jsx\"><code class=\"language-jsx\"><span class=\"token punctuation\">{</span>\n  <span class=\"token literal-property property\">files</span><span class=\"token operator\">:</span> <span class=\"token punctuation\">[</span><span class=\"token string\">'src/**/*.{ts,tsx}'</span><span class=\"token punctuation\">]</span><span class=\"token punctuation\">,</span>\n  <span class=\"token literal-property property\">ignores</span><span class=\"token operator\">:</span> <span class=\"token punctuation\">[</span><span class=\"token string\">'src/platform/**'</span><span class=\"token punctuation\">]</span><span class=\"token punctuation\">,</span>\n  <span class=\"token literal-property property\">rules</span><span class=\"token operator\">:</span> <span class=\"token punctuation\">{</span>\n    <span class=\"token string-property property\">'no-restricted-imports'</span><span class=\"token operator\">:</span> <span class=\"token punctuation\">[</span><span class=\"token string\">'error'</span><span class=\"token punctuation\">,</span> <span class=\"token punctuation\">{</span>\n      <span class=\"token literal-property property\">patterns</span><span class=\"token operator\">:</span> <span class=\"token punctuation\">[</span><span class=\"token punctuation\">{</span>\n        <span class=\"token literal-property property\">group</span><span class=\"token operator\">:</span> <span class=\"token punctuation\">[</span><span class=\"token string\">'@tauri-apps/*'</span><span class=\"token punctuation\">]</span><span class=\"token punctuation\">,</span>\n        <span class=\"token literal-property property\">message</span><span class=\"token operator\">:</span> <span class=\"token string\">'Reach the host through src/platform instead of calling Tauri directly.'</span><span class=\"token punctuation\">,</span>\n      <span class=\"token punctuation\">}</span><span class=\"token punctuation\">]</span><span class=\"token punctuation\">,</span>\n    <span class=\"token punctuation\">}</span><span class=\"token punctuation\">]</span><span class=\"token punctuation\">,</span>\n  <span class=\"token punctuation\">}</span><span class=\"token punctuation\">,</span>\n<span class=\"token punctuation\">}</span></code></pre></div>\n<p>The second lint rule in that config is the one I’d recommend to anyone shipping a product in two languages. <code class=\"language-text\">react/jsx-no-literals</code> with <code class=\"language-text\">noStrings: true</code> makes a hardcoded user-facing string a lint error. Every string comes from a catalog through <code class=\"language-text\">t()</code>, the English catalog is the authoritative set of keys, and the German type is derived from it, so a missing translation is a type error rather than a blank label found by a user. I wrote about switching to <a href=\"/blog/biome/\">Biome</a> last year and I still like it. This project stayed on ESLint because both of those rules are the reason the codebase has the shape it has, and I wasn’t going to give them up for a faster linter.</p>\n<h3>Capabilities, or why your dialog doesn’t open</h3>\n<p>Tauri 2 replaced the old allowlist with capabilities. A JSON file per window says which permissions the webview gets, and by default it gets almost nothing:</p>\n<div class=\"gatsby-highlight\" data-language=\"json\"><pre class=\"language-json\"><code class=\"language-json\"><span class=\"token punctuation\">{</span>\n  <span class=\"token property\">\"identifier\"</span><span class=\"token operator\">:</span> <span class=\"token string\">\"default\"</span><span class=\"token punctuation\">,</span>\n  <span class=\"token property\">\"windows\"</span><span class=\"token operator\">:</span> <span class=\"token punctuation\">[</span><span class=\"token string\">\"main\"</span><span class=\"token punctuation\">]</span><span class=\"token punctuation\">,</span>\n  <span class=\"token property\">\"permissions\"</span><span class=\"token operator\">:</span> <span class=\"token punctuation\">[</span>\n    <span class=\"token string\">\"core:default\"</span><span class=\"token punctuation\">,</span>\n    <span class=\"token string\">\"dialog:allow-save\"</span><span class=\"token punctuation\">,</span>\n    <span class=\"token string\">\"dialog:allow-open\"</span><span class=\"token punctuation\">,</span>\n    <span class=\"token string\">\"dialog:allow-ask\"</span><span class=\"token punctuation\">,</span>\n    <span class=\"token string\">\"dialog:allow-message\"</span><span class=\"token punctuation\">,</span>\n    <span class=\"token string\">\"core:window:allow-destroy\"</span>\n  <span class=\"token punctuation\">]</span>\n<span class=\"token punctuation\">}</span></code></pre></div>\n<p>This is the first thing to look at when a plugin call silently does nothing. Your own commands don’t need entries here, but every plugin API does, and the error when you forget is not always loud. The model is sound: the webview is running content you control today and possibly content you don’t tomorrow, and a save dialog it wasn’t granted is a save dialog it can’t open.</p>\n<p>The content security policy lives next to it in <code class=\"language-text\">tauri.conf.json</code>. Mine needed <code class=\"language-text\">'wasm-unsafe-eval'</code> in <code class=\"language-text\">script-src</code> because the engraving library is WebAssembly, <code class=\"language-text\">blob:</code> in <code class=\"language-text\">worker-src</code> because it runs in a worker, and the <code class=\"language-text\">asset:</code> protocol in <code class=\"language-text\">img-src</code> so the app can show a page it wrote to disk. Each of those fails silently when it’s missing: a blank pane and nothing in the console.</p>\n<h3>Bundling a 200 MB Java program you are not allowed to touch</h3>\n<p>Recognition is done by Audiveris, an open-source optical music recognition engine. It is written in Java, it ships with its own trimmed runtime, and it is AGPL. Semitone is not.</p>\n<p>That combination is workable only under strict process separation, and I wrote the rules down before writing the code. Audiveris runs as a subprocess. Arguments go in on the command line, MusicXML comes back out of a directory, and that is the whole interface. No JVM linked into my process, no calls into its classes, no parsing its project files, no source patches, no forks. The launcher and its runtime are vendored into the bundle by a script that downloads the exact upstream release, checks it against the digest upstream publishes, and unpacks it without modification.</p>\n<div class=\"gatsby-highlight\" data-language=\"sh\"><pre class=\"language-sh\"><code class=\"language-sh\">npm run audiveris:vendor</code></pre></div>\n<p>It is deliberately absent from the licence inventory, because the inventory describes what is compiled or bundled <em>into</em> Semitone and the whole position rests on the answer being “none of it”. The position itself is written up for a lawyer to read before anyone pays for this. I’m a developer, not a lawyer, and the honest version of that sentence is that I built the process boundary as narrow as it can be and then stopped guessing.</p>\n<p>On the Rust side the interesting problems are the ones every subprocess has and nobody plans for. A run takes minutes, so the log is followed to make the wait legible. The user can cancel, so the child is in its own process group and the signal reaches the whole tree; on Windows that means <code class=\"language-text\">taskkill</code>, an external program that can be missing, with <code class=\"language-text\">Child::kill</code> as the fallback. Quitting during a run must not leave an orphan JVM behind. And the working directory is removed on every path out, so a cancelled job leaves nothing on disk.</p>\n<h3>The engraver, and a different licence</h3>\n<p>Engraving, turning MusicXML into a picture of music, is done by Verovio, compiled to WebAssembly and running in a Web Worker so the UI stays responsive. Verovio is LGPL, and its WASM is inlined into its JavaScript as a string, so bundling it into the app chunk would be a lot closer to static linking than to dynamic.</p>\n<p>The answer is to not bundle it. A script copies the two files verbatim out of <code class=\"language-text\">node_modules</code> into <code class=\"language-text\">public/</code>, where Vite treats them as static assets loaded at runtime, and it fails the build if the version in <code class=\"language-text\">package.json</code> isn’t pinned exactly. It runs on <code class=\"language-text\">predev</code> and <code class=\"language-text\">prebuild</code>, so nobody has to remember it.</p>\n<p>I mention both of these because licensing is the part of a desktop app that a web developer has never had to think about. On the web you serve, you don’t distribute. The moment you ship a binary, every dependency is something you’re conveying, and the difference between “an aggregate” and “a combined work” decides whether you have to publish your own source.</p>\n<h3>The licence gate</h3>\n<p>Which is why there’s a CI job for it. Two scripts generate an inventory of every npm package and every Rust crate that ends up in the bundle, with its licence text, and a third checks them against a list of what we permit. Not what we recognise, what we permit. AGPL, SSPL, Commons Clause, non-commercial terms and packages with no licence declared all fail the build, and so does anything nobody has thought of yet, because it isn’t on the list.</p>\n<div class=\"gatsby-highlight\" data-language=\"sh\"><pre class=\"language-sh\"><code class=\"language-sh\">npm run licences:npm\nnpm run licences:cargo\nnpm run licences:check</code></pre></div>\n<p>The committed inventories have to match the tree, so CI regenerates them and diffs. The app shows the whole inventory under a Licences entry in the header, which is about 750 kB of licence text in a chunk of its own that Vite would otherwise warn about on every build.</p>\n<p>It also shaped the Rust dependencies more than I expected. The PDF rasteriser is <code class=\"language-text\">hayro</code>, a pure Rust crate, because the obvious choices are MuPDF and Poppler and both are copyleft, and the third is a C++ blob I’d have to vendor per platform. Most crates have <code class=\"language-text\">default-features = false</code>, partly for size and partly because the default feature set of an image library is twenty decoders, every one of them a parser running on a file a stranger sent you.</p>\n<h3>Things I measured instead of assumed</h3>\n<p>The release profile has <code class=\"language-text\">panic = \"unwind\"</code> where the template had <code class=\"language-text\">abort</code>. Abort is smaller. But the app runs two parsers over files that arrive from outside, images and PDFs, and the “we show a message rather than crash” behaviour only exists if a panicking task can be caught. With abort, the same panic takes the process down with the user’s corrected part in it, unsaved since the last thirty-second autosave. I measured the cost: 9.27 MB to 11.05 MB, plus 19 percent on my binary. Against the 200 MB engine sitting next to it, that’s under one percent of the download. Crash safety on hostile input is worth nine tenths of one percent.</p>\n<p>The updater was the other one. Tauri’s updater doesn’t patch, it replaces the whole bundle. So an update from 0.1.0 to 0.2.0 downloads 101.5 MiB, of which 93 MiB is a recognition engine that hasn’t changed. Semitone’s own binary, frontend, fonts and engraver come to 8.4 MiB compressed. The update is twelve times larger than it needs to be, and for now that stands, because splitting the engine into its own installable piece is a real build and the release cadence doesn’t justify it yet. The updater itself added 4.6 MiB to the binary, which is what an HTTP client, a TLS stack and a tar reader weigh. It’s written down with the numbers so the next person doesn’t re-measure it.</p>\n<h3>CI, and the release pipeline that runs with no secrets</h3>\n<p>Four jobs. A fast one on Ubuntu with no Rust toolchain: format, lint, typecheck, tests, the fixture and inventory checks, the licence gate. A Rust one, also on Ubuntu because it’s the cheapest place to run Clippy with <code class=\"language-text\">-D warnings</code> and the unit tests, including a golden-PDF test that catches print regressions nobody would otherwise notice until they printed. A build matrix for the two platforms we ship, macOS on Apple Silicon and Windows x64, which vendors the engine, builds the installer, weighs it against a committed size budget and uploads it. And a nightly job on macOS that runs the real engine on a real picture of music and checks that MusicXML comes out, because that’s too slow and too large for every push. The Windows build only exists in CI. You cannot build it on a Mac.</p>\n<p>I’ve written about <a href=\"/blog/docker/\">containerising a Next.js app</a> and the shape here is the same one: the artefact you test is the artefact you ship, or the test is theatre.</p>\n<p>The release workflow is the part I’m most pleased with, for a reason that has nothing to do with code. It runs end to end with <strong>no secrets configured at all</strong>. No signing certificate, no Apple account, no updater key. It builds both installers, weighs them, labels them <code class=\"language-text\">UNSIGNED</code> in the artefact name, and refuses to publish. A release pipeline nobody can exercise until the certificates arrive is a pipeline nobody has tested, and the day the certificates arrive is the worst possible day to find out it doesn’t work. The label has three states, not two, because <code class=\"language-text\">signed-not-notarized</code> on macOS still stops at Gatekeeper with a different message and the same outcome.</p>\n<p>Two small things from that pipeline that I’d have paid to know in advance. The macOS runner’s bash is 3.2, where expanding an empty array under <code class=\"language-text\">set -u</code> is an error, which the modern bash on your own machine will never show you. And Tauri’s <code class=\"language-text\">.dmg</code> step drives Finder over AppleScript to lay out the window, which stalls on a permission prompt the first time you run it on a machine. <code class=\"language-text\">CI=true</code> skips that cosmetic step, and the runner sets it for you, so this only ever bites locally.</p>\n<h3>The phone</h3>\n<p>One feature that’s worth a paragraph because the obvious approach is wrong. You can photograph a part with your phone and have it land in the app: the app starts a small web server on the LAN, shows a QR code, and the phone opens the page. My first instinct was a live camera preview with <code class=\"language-text\">getUserMedia</code>. That requires a secure context, a phone hitting <code class=\"language-text\">http://192.168.x.x:port</code> doesn’t have one, and a self-signed certificate produces a full-page warning that reads as a broken app. A plain <code class=\"language-text\">&lt;input type=\"file\" capture=\"environment\"></code> needs no secure context and uses the phone’s own, better, camera app. Less code and a nicer photo 🎺.</p>\n<h3>What isn’t done</h3>\n<p>The thing I should say plainly: the go/no-go for this whole product, whether correcting recognition output on real photographs actually beats transposing by hand, has not been run. It needs eight to ten real photographed band parts and a half-day against a protocol that’s written and ready. Everything downstream of it is built. That order is backwards and I know it. The MusicXML-in, transposed-part-out path stands on its own if the answer is no, which is the excuse I’m using.</p>\n<p>Two release blockers are open and named: the updater has no public key yet, and the AGPL written offer inside the bundle still names a placeholder address. Both need a person, not a build.</p>\n<p>Would I use Tauri again? Yes, without much hesitation. The window is the OS’s own webview, the binary is small, the command boundary is simple, and Rust on the other side of it is a good deal more pleasant than I expected for a job that is mostly “run this program and read its output”. The parts that cost time were never Tauri. They were the parts of shipping software to strangers’ machines that the web had let me forget about.</p>\n<p>‘Till next time!</p>","rawMarkdownBody":"\n## The browser was never going to do this\n\n<p class=\"lead\">Take a photo of a printed sheet-music part, get it back transposed into a different key and clef, with every accent and repeat intact, and print it. That's the whole product. It needs a 200 MB recognition engine written in Java, a PDF rasteriser, a local web server for the phone, and the ability to spawn and kill processes. None of that runs in a tab.</p>\n\nSo I built a desktop application. It's called Semitone, it's not public yet, and it's a React app inside Tauri 2 with a Rust backend that does everything the browser can't. This post is the Tauri half: what a React developer needs to know, what I'd do again, and the bits that ate whole days.\n\nThe example that started the project is a `1. Flügelhorn in B` part from a brass-band folder that has to be played from bass clef in C. Today the options are transposing by hand, re-entering it in a notation editor, or not playing. I'll leave the music side for another post, because the desktop side is plenty.\n\n### What Tauri actually is\n\nTauri gives you a native window with a system webview in it (WebKit on macOS, WebView2 on Windows) and a Rust process behind it. Your frontend is whatever you'd build for the web. Mine is React 19, Vite, [Tailwind v4](/blog/tailwind-css-v4/) and shadcn/ui, with Vitest for tests, and if you removed Tauri from the repository it would still start in a browser and render every screen. The Rust side exposes functions the frontend can call, and that's the entire contract.\n\n```sh\nnpm create tauri-app@latest\n```\n\nThat scaffolds both halves. The frontend lives in `src/`, the native side in `src-tauri/`, and `tauri.conf.json` describes the window, the bundle and the security policy. The dev loop is `npm run tauri dev`, which starts Vite on a fixed port and opens a window pointed at it. Hot reload works. Rust changes recompile and restart the window, which takes a few seconds and is the one place where the loop is slower than a web project.\n\nTwo Vite settings matter and the template sets both. The port is fixed and strict, because Tauri expects to find the dev server where it said it would be, and Vite is told to ignore `src-tauri/` so a Rust edit doesn't trigger a frontend rebuild.\n\n### Commands, the door between the two worlds\n\nA command is a Rust function with an attribute on it:\n\n```rust\n#[tauri::command]\npub fn host_info() -> HostInfo {\n    HostInfo {\n        platform: std::env::consts::OS.to_string(),\n        app_version: env!(\"CARGO_PKG_VERSION\").to_string(),\n    }\n}\n```\n\nRegister it in the builder, and the frontend calls it by name:\n\n```ts\nimport { invoke } from '@tauri-apps/api/core';\n\nconst info = await invoke<HostInfo>('host_info');\n```\n\nArguments and return values go through JSON, so anything that derives `Serialize` on the Rust side arrives as a plain object. The naming convention differs between the two languages, so nearly every struct I return carries `#[serde(rename_all = \"camelCase\")]`, and errors are enums tagged with a `code` field so the frontend can switch on them rather than parse sentences.\n\nLong work must not run on the thread that owns the window. Marking a command `async` puts it on Tauri's runtime, and CPU-bound work goes one step further to the blocking pool:\n\n```rust\n#[tauri::command]\npub async fn export_pdf(svg_pages: Vec<String>, destination: String) -> Result<(), String> {\n    tauri::async_runtime::spawn_blocking(move || {\n        let pdf = svg_pages_to_pdf(&svg_pages).map_err(|e| e.to_string())?;\n        std::fs::write(&destination, pdf).map_err(|e| e.to_string())\n    })\n    .await\n    .map_err(|e| e.to_string())?\n}\n```\n\nLaying out a twenty-page part is not something to do on a thread other commands are waiting on, and the frontend's progress bar is one of those commands.\n\nFor the other direction, Rust to frontend, there are events. The recognition engine runs for minutes and writes a log, so the Rust side follows the log and emits progress events that the React side subscribes to with `listen`. Same mechanism when the phone submits a photo, and when a second document is double-clicked while the app is already open.\n\n### The rule I'd keep even if I threw everything else away\n\nNothing in the frontend imports `@tauri-apps/*` except one folder, `src/platform/`. It exports a `Host` interface, every `invoke` in the application lives behind it, and the rest of the app asks for `getHost()` and doesn't know what answers.\n\nThe reason is that a web version is a plausible future, and with this boundary it is one module swap: an HTTP-backed host instead of a Tauri-backed one. Without the boundary it's a rewrite. So it is enforced by ESLint rather than remembered:\n\n```js\n{\n  files: ['src/**/*.{ts,tsx}'],\n  ignores: ['src/platform/**'],\n  rules: {\n    'no-restricted-imports': ['error', {\n      patterns: [{\n        group: ['@tauri-apps/*'],\n        message: 'Reach the host through src/platform instead of calling Tauri directly.',\n      }],\n    }],\n  },\n}\n```\n\nThe second lint rule in that config is the one I'd recommend to anyone shipping a product in two languages. `react/jsx-no-literals` with `noStrings: true` makes a hardcoded user-facing string a lint error. Every string comes from a catalog through `t()`, the English catalog is the authoritative set of keys, and the German type is derived from it, so a missing translation is a type error rather than a blank label found by a user. I wrote about switching to [Biome](/blog/biome/) last year and I still like it. This project stayed on ESLint because both of those rules are the reason the codebase has the shape it has, and I wasn't going to give them up for a faster linter.\n\n### Capabilities, or why your dialog doesn't open\n\nTauri 2 replaced the old allowlist with capabilities. A JSON file per window says which permissions the webview gets, and by default it gets almost nothing:\n\n```json\n{\n  \"identifier\": \"default\",\n  \"windows\": [\"main\"],\n  \"permissions\": [\n    \"core:default\",\n    \"dialog:allow-save\",\n    \"dialog:allow-open\",\n    \"dialog:allow-ask\",\n    \"dialog:allow-message\",\n    \"core:window:allow-destroy\"\n  ]\n}\n```\n\nThis is the first thing to look at when a plugin call silently does nothing. Your own commands don't need entries here, but every plugin API does, and the error when you forget is not always loud. The model is sound: the webview is running content you control today and possibly content you don't tomorrow, and a save dialog it wasn't granted is a save dialog it can't open.\n\nThe content security policy lives next to it in `tauri.conf.json`. Mine needed `'wasm-unsafe-eval'` in `script-src` because the engraving library is WebAssembly, `blob:` in `worker-src` because it runs in a worker, and the `asset:` protocol in `img-src` so the app can show a page it wrote to disk. Each of those fails silently when it's missing: a blank pane and nothing in the console.\n\n### Bundling a 200 MB Java program you are not allowed to touch\n\nRecognition is done by Audiveris, an open-source optical music recognition engine. It is written in Java, it ships with its own trimmed runtime, and it is AGPL. Semitone is not.\n\nThat combination is workable only under strict process separation, and I wrote the rules down before writing the code. Audiveris runs as a subprocess. Arguments go in on the command line, MusicXML comes back out of a directory, and that is the whole interface. No JVM linked into my process, no calls into its classes, no parsing its project files, no source patches, no forks. The launcher and its runtime are vendored into the bundle by a script that downloads the exact upstream release, checks it against the digest upstream publishes, and unpacks it without modification.\n\n```sh\nnpm run audiveris:vendor\n```\n\nIt is deliberately absent from the licence inventory, because the inventory describes what is compiled or bundled *into* Semitone and the whole position rests on the answer being \"none of it\". The position itself is written up for a lawyer to read before anyone pays for this. I'm a developer, not a lawyer, and the honest version of that sentence is that I built the process boundary as narrow as it can be and then stopped guessing.\n\nOn the Rust side the interesting problems are the ones every subprocess has and nobody plans for. A run takes minutes, so the log is followed to make the wait legible. The user can cancel, so the child is in its own process group and the signal reaches the whole tree; on Windows that means `taskkill`, an external program that can be missing, with `Child::kill` as the fallback. Quitting during a run must not leave an orphan JVM behind. And the working directory is removed on every path out, so a cancelled job leaves nothing on disk.\n\n### The engraver, and a different licence\n\nEngraving, turning MusicXML into a picture of music, is done by Verovio, compiled to WebAssembly and running in a Web Worker so the UI stays responsive. Verovio is LGPL, and its WASM is inlined into its JavaScript as a string, so bundling it into the app chunk would be a lot closer to static linking than to dynamic.\n\nThe answer is to not bundle it. A script copies the two files verbatim out of `node_modules` into `public/`, where Vite treats them as static assets loaded at runtime, and it fails the build if the version in `package.json` isn't pinned exactly. It runs on `predev` and `prebuild`, so nobody has to remember it.\n\nI mention both of these because licensing is the part of a desktop app that a web developer has never had to think about. On the web you serve, you don't distribute. The moment you ship a binary, every dependency is something you're conveying, and the difference between \"an aggregate\" and \"a combined work\" decides whether you have to publish your own source.\n\n### The licence gate\n\nWhich is why there's a CI job for it. Two scripts generate an inventory of every npm package and every Rust crate that ends up in the bundle, with its licence text, and a third checks them against a list of what we permit. Not what we recognise, what we permit. AGPL, SSPL, Commons Clause, non-commercial terms and packages with no licence declared all fail the build, and so does anything nobody has thought of yet, because it isn't on the list.\n\n```sh\nnpm run licences:npm\nnpm run licences:cargo\nnpm run licences:check\n```\n\nThe committed inventories have to match the tree, so CI regenerates them and diffs. The app shows the whole inventory under a Licences entry in the header, which is about 750 kB of licence text in a chunk of its own that Vite would otherwise warn about on every build.\n\nIt also shaped the Rust dependencies more than I expected. The PDF rasteriser is `hayro`, a pure Rust crate, because the obvious choices are MuPDF and Poppler and both are copyleft, and the third is a C++ blob I'd have to vendor per platform. Most crates have `default-features = false`, partly for size and partly because the default feature set of an image library is twenty decoders, every one of them a parser running on a file a stranger sent you.\n\n### Things I measured instead of assumed\n\nThe release profile has `panic = \"unwind\"` where the template had `abort`. Abort is smaller. But the app runs two parsers over files that arrive from outside, images and PDFs, and the \"we show a message rather than crash\" behaviour only exists if a panicking task can be caught. With abort, the same panic takes the process down with the user's corrected part in it, unsaved since the last thirty-second autosave. I measured the cost: 9.27 MB to 11.05 MB, plus 19 percent on my binary. Against the 200 MB engine sitting next to it, that's under one percent of the download. Crash safety on hostile input is worth nine tenths of one percent.\n\nThe updater was the other one. Tauri's updater doesn't patch, it replaces the whole bundle. So an update from 0.1.0 to 0.2.0 downloads 101.5 MiB, of which 93 MiB is a recognition engine that hasn't changed. Semitone's own binary, frontend, fonts and engraver come to 8.4 MiB compressed. The update is twelve times larger than it needs to be, and for now that stands, because splitting the engine into its own installable piece is a real build and the release cadence doesn't justify it yet. The updater itself added 4.6 MiB to the binary, which is what an HTTP client, a TLS stack and a tar reader weigh. It's written down with the numbers so the next person doesn't re-measure it.\n\n### CI, and the release pipeline that runs with no secrets\n\nFour jobs. A fast one on Ubuntu with no Rust toolchain: format, lint, typecheck, tests, the fixture and inventory checks, the licence gate. A Rust one, also on Ubuntu because it's the cheapest place to run Clippy with `-D warnings` and the unit tests, including a golden-PDF test that catches print regressions nobody would otherwise notice until they printed. A build matrix for the two platforms we ship, macOS on Apple Silicon and Windows x64, which vendors the engine, builds the installer, weighs it against a committed size budget and uploads it. And a nightly job on macOS that runs the real engine on a real picture of music and checks that MusicXML comes out, because that's too slow and too large for every push. The Windows build only exists in CI. You cannot build it on a Mac.\n\nI've written about [containerising a Next.js app](/blog/docker/) and the shape here is the same one: the artefact you test is the artefact you ship, or the test is theatre.\n\nThe release workflow is the part I'm most pleased with, for a reason that has nothing to do with code. It runs end to end with **no secrets configured at all**. No signing certificate, no Apple account, no updater key. It builds both installers, weighs them, labels them `UNSIGNED` in the artefact name, and refuses to publish. A release pipeline nobody can exercise until the certificates arrive is a pipeline nobody has tested, and the day the certificates arrive is the worst possible day to find out it doesn't work. The label has three states, not two, because `signed-not-notarized` on macOS still stops at Gatekeeper with a different message and the same outcome.\n\nTwo small things from that pipeline that I'd have paid to know in advance. The macOS runner's bash is 3.2, where expanding an empty array under `set -u` is an error, which the modern bash on your own machine will never show you. And Tauri's `.dmg` step drives Finder over AppleScript to lay out the window, which stalls on a permission prompt the first time you run it on a machine. `CI=true` skips that cosmetic step, and the runner sets it for you, so this only ever bites locally.\n\n### The phone\n\nOne feature that's worth a paragraph because the obvious approach is wrong. You can photograph a part with your phone and have it land in the app: the app starts a small web server on the LAN, shows a QR code, and the phone opens the page. My first instinct was a live camera preview with `getUserMedia`. That requires a secure context, a phone hitting `http://192.168.x.x:port` doesn't have one, and a self-signed certificate produces a full-page warning that reads as a broken app. A plain `<input type=\"file\" capture=\"environment\">` needs no secure context and uses the phone's own, better, camera app. Less code and a nicer photo 🎺.\n\n### What isn't done\n\nThe thing I should say plainly: the go/no-go for this whole product, whether correcting recognition output on real photographs actually beats transposing by hand, has not been run. It needs eight to ten real photographed band parts and a half-day against a protocol that's written and ready. Everything downstream of it is built. That order is backwards and I know it. The MusicXML-in, transposed-part-out path stands on its own if the answer is no, which is the excuse I'm using.\n\nTwo release blockers are open and named: the updater has no public key yet, and the AGPL written offer inside the bundle still names a placeholder address. Both need a person, not a build.\n\nWould I use Tauri again? Yes, without much hesitation. The window is the OS's own webview, the binary is small, the command boundary is simple, and Rust on the other side of it is a good deal more pleasant than I expected for a job that is mostly \"run this program and read its output\". The parts that cost time were never Tauri. They were the parts of shipping software to strangers' machines that the web had let me forget about.\n\n'Till next time!\n","frontmatter":{"title":"A Desktop App with Tauri 2","date":"22. August, 2026","description":"Building a desktop app with Tauri 2, React and Rust. The command boundary, capabilities, bundling a 200 MB Java engine, licence gates, and the release pipeline.","category":"Develop","cover":{"childImageSharp":{"gatsbyImageData":{"layout":"fixed","backgroundColor":"#181828","images":{"fallback":{"src":"/static/8a4ee1fc729bd6d3159a35b28a486917/1619f/tauri-desktop-app.png","srcSet":"/static/8a4ee1fc729bd6d3159a35b28a486917/1619f/tauri-desktop-app.png 960w","sizes":"960px"},"sources":[{"srcSet":"/static/8a4ee1fc729bd6d3159a35b28a486917/0a27d/tauri-desktop-app.webp 960w","type":"image/webp","sizes":"960px"}]},"width":960,"height":653}}}},"fields":{"slug":"/2026-08-22_tauri-desktop-app/"}}},"pageContext":{"slug":"/2026-08-22_tauri-desktop-app/","previous":{"fields":{"slug":"/2026-07-31_godot-for-web-developers/"},"frontmatter":{"title":"Godot for Web Developers"}},"next":null}},
    "staticQueryHashes": ["1711471402","674253978"]}