<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>Forem: hyerixAI</title>
    <description>The latest articles on Forem by hyerixAI (@hyerixai).</description>
    <link>https://forem.com/hyerixai</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3912958%2Fb33383c6-6006-4d44-ba72-1930bcade69e.png</url>
      <title>Forem: hyerixAI</title>
      <link>https://forem.com/hyerixai</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://forem.com/feed/hyerixai"/>
    <language>en</language>
    <item>
      <title>Shipping a NATS desktop GUI on Tauri v2: build notes</title>
      <dc:creator>hyerixAI</dc:creator>
      <pubDate>Tue, 05 May 2026 07:01:00 +0000</pubDate>
      <link>https://forem.com/hyerixai/shipping-a-nats-desktop-gui-on-tauri-v2-build-notes-3e3p</link>
      <guid>https://forem.com/hyerixai/shipping-a-nats-desktop-gui-on-tauri-v2-build-notes-3e3p</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;What I learned building Hyerix — a Tauri v2 + async-nats desktop app for NATS infrastructure. Covers IPC backpressure, cross-platform signing pain, and the local-first AI architecture.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I built Hyerix — a desktop app for managing NATS infrastructure (JetStream streams, KV buckets, Object Store, consumers, cluster topology). It's Tauri v2 + Rust + React/TypeScript, talking to clusters via async-nats. There's also a natural-language query layer over live cluster state.&lt;/p&gt;

&lt;p&gt;This is the technical retrospective. Not a pitch — if you're building a Tauri app or a NATS tool, hopefully some of these notes save you the time I spent figuring them out.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Launching today on Product Hunt — feedback welcome: &lt;a href="https://www.producthunt.com/products/hyerix" rel="noopener noreferrer"&gt;producthunt.com/products/hyerix&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Why a desktop app
&lt;/h2&gt;

&lt;p&gt;The NATS CLI is excellent for one-shot scripting. It's painful for "I have 80 consumers across 12 streams and I need to find the one that's stuck." Every NATS operator I know has a private library of &lt;code&gt;nats consumer info | jq&lt;/code&gt; incantations they pull out at 2am.&lt;/p&gt;

&lt;p&gt;The visual diff — &lt;em&gt;what changed in the last hour, and which subject filter is responsible&lt;/em&gt; — is the gap. The CLI doesn't give you that without a lot of plumbing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Tauri v2 (not Electron)
&lt;/h2&gt;

&lt;p&gt;Three reasons:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Binary size.&lt;/strong&gt; Release builds clock around 12MB on macOS. Electron's baseline is ~150MB. For a tool people run alongside their normal dev environment, that matters.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rust on the backend.&lt;/strong&gt; I wanted async-nats, not a Node wrapper around it. Rust gives me the same client the NATS team itself uses, no impedance mismatch.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Webview is the right primitive for dense UI.&lt;/strong&gt; Trees, time-series charts, virtualized tables. egui or iced would have been faster to bootstrap but slower to land the visual density. I needed Recharts and react-virtual without rebuilding them.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  async-nats notes
&lt;/h2&gt;

&lt;p&gt;The official Rust client is solid. The JetStream API is well-typed and the streaming consumer iterators map cleanly to Tokio's primitives.&lt;/p&gt;

&lt;p&gt;The thing that bit me: &lt;strong&gt;pull subscription reconnect semantics are subtle.&lt;/strong&gt; On certain disconnect classes you need to explicitly recreate the pull subscription rather than relying on the underlying connection's auto-reconnect. The docs are thin on which classes need this. After a few painful field reports, I ended up with this pattern:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="k"&gt;mut&lt;/span&gt; &lt;span class="n"&gt;pull_sub&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;stream&lt;/span&gt;&lt;span class="nf"&gt;.pull_subscriber&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"durable"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="k"&gt;.await&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;loop&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;match&lt;/span&gt; &lt;span class="n"&gt;pull_sub&lt;/span&gt;&lt;span class="nf"&gt;.next&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="k"&gt;.await&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nf"&gt;Some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;Ok&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;process&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="k"&gt;.await&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nf"&gt;Some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;Err&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="nf"&gt;.is_connection_closed&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;pull_sub&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;stream&lt;/span&gt;&lt;span class="nf"&gt;.pull_subscriber&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"durable"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="k"&gt;.await&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
            &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="nf"&gt;Some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;Err&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;Err&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="nf"&gt;.into&lt;/span&gt;&lt;span class="p"&gt;()),&lt;/span&gt;
        &lt;span class="nb"&gt;None&lt;/span&gt; &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;is_connection_closed()&lt;/code&gt; check is what I wish I'd known earlier.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tauri v2 IPC: streaming live data needs backpressure
&lt;/h2&gt;

&lt;p&gt;Tauri v2's channel API is a real upgrade over v1's emit/listen for live data like consumer lag samples or message rate windows.&lt;/p&gt;

&lt;p&gt;But backpressure is your problem. If the UI is slow to drain a channel and the producer keeps pushing, you'll either OOM the renderer or drop frames. I landed on a small ring buffer per subscription on the Rust side:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="n"&gt;LagBuffer&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;samples&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;VecDeque&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;LagSample&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;capacity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;usize&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;impl&lt;/span&gt; &lt;span class="n"&gt;LagBuffer&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="k"&gt;mut&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sample&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;LagSample&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="py"&gt;.samples&lt;/span&gt;&lt;span class="nf"&gt;.len&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="py"&gt;.capacity&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="py"&gt;.samples&lt;/span&gt;&lt;span class="nf"&gt;.pop_front&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="py"&gt;.samples&lt;/span&gt;&lt;span class="nf"&gt;.push_back&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sample&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Lossy at the head, never blocks the producer. Worst case the chart is a few frames behind reality. For ops tooling that's fine.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;code&gt;tokio::sync::watch&lt;/code&gt; over &lt;code&gt;broadcast&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;I started with &lt;code&gt;tokio::sync::broadcast&lt;/code&gt; for fanning cluster updates to UI subscribers. Switched to &lt;code&gt;tokio::sync::watch&lt;/code&gt; for "latest state" channels — way fewer footguns.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;broadcast&lt;/code&gt; is right when every subscriber must see every value (event-log semantics). &lt;code&gt;watch&lt;/code&gt; is right when subscribers only care about the most recent value (state-replication semantics). Most of the UI is state replication. Picking the wrong primitive cost me a week of &lt;em&gt;"why is this consumer lag updating laggily"&lt;/em&gt; debugging.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cross-platform signing tax
&lt;/h2&gt;

&lt;p&gt;This was the single biggest "looks easy in docs, isn't" surprise. Budget two weeks across all three OSes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Linux&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;webkit2gtk&lt;/code&gt; 4.0 vs 4.1 split. Tauri v2 needs 4.1; older Ubuntu LTS shipped 4.0. Make the &lt;code&gt;.deb&lt;/code&gt; pin the right runtime dep.&lt;/li&gt;
&lt;li&gt;AppImage signing tooling is sparse. I ship a detached &lt;code&gt;.sig&lt;/code&gt; for verification.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;RPM signing&lt;/strong&gt;: Tauri's &lt;code&gt;rpm-rs&lt;/code&gt; produces signatures that newer &lt;code&gt;rpm&lt;/code&gt; + &lt;code&gt;rpm-sequoia&lt;/code&gt; (Ubuntu 22.04+) reject as malformed OpenPGP. Workaround: don't sign in Tauri. Re-sign in CI inside an AlmaLinux 8 container using &lt;code&gt;rpmsign&lt;/code&gt;, which delegates to gpg and produces spec-conformant output. Took several iterations to figure out — same root cause that broke goreleaser's RPM signing in 2.5.1.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;macOS&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Notarization is solved-but-slow. &lt;code&gt;xcrun notarytool submit --wait&lt;/code&gt; takes 1-5 min per artifact. Plan your CI matrix with that in mind.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tauri v2 only notarizes the inner &lt;code&gt;.app&lt;/code&gt;, not the DMG itself.&lt;/strong&gt; If you want offline Gatekeeper to work, submit the DMG separately and staple post-build, then re-upload to whatever distribution channel you use.&lt;/li&gt;
&lt;li&gt;The DMG ships with an embedded SLA license file that hangs &lt;code&gt;hdiutil attach&lt;/code&gt; indefinitely in CI. Verifying the inner &lt;code&gt;.app&lt;/code&gt; instead of mounting the DMG sidesteps it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Windows&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Code signing is the usual nightmare. Azure Trusted Signing was the cheapest path that didn't require a year of EV-cert wait.&lt;/li&gt;
&lt;li&gt;ARM64 builds work fine on &lt;code&gt;windows-latest&lt;/code&gt; runners as long as the Rust toolchain has the target installed.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Local-first AI architecture
&lt;/h2&gt;

&lt;p&gt;The natural-language query layer was the part I was most uncertain about. Cluster state — even structural metadata like consumer names and KV bucket layouts — is sensitive. "We send your whole cluster to OpenAI" is not an acceptable answer.&lt;/p&gt;

&lt;p&gt;The architecture:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The Rust backend already maintains a model of cluster state (streams, consumers, KV buckets, recent metrics) for the UI.&lt;/li&gt;
&lt;li&gt;When a user asks &lt;em&gt;"which consumers have growing pending counts in the orders stream?"&lt;/em&gt;, the LLM receives a structured summary of relevant cluster state — never an unbounded query against the whole cluster.&lt;/li&gt;
&lt;li&gt;The LLM's job is to translate the question into a sequence of API calls Hyerix already supports. It returns a &lt;em&gt;query plan&lt;/em&gt;, not an answer. The plan executes locally against cluster state.&lt;/li&gt;
&lt;li&gt;The LLM never sees message bodies, KV values, or anything in the data path. Structural metadata + numeric metrics only.&lt;/li&gt;
&lt;li&gt;Provider is configurable (OpenAI by default). Off by default.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The architectural commitment: &lt;em&gt;the LLM sees a summary, not the cluster.&lt;/em&gt; That boundary is what customers actually care about.&lt;/p&gt;

&lt;h2&gt;
  
  
  Honest tradeoffs
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Local-first means no team-shared dashboards.&lt;/strong&gt; Each engineer runs their own copy. Hosted/multiplayer mode is the most-requested feature on the roadmap.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Per-machine licensing.&lt;/strong&gt; Fingerprint changes (laptop swap) require re-activation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The AI layer sends cluster schema and metrics&lt;/strong&gt; to whichever LLM provider you configure. If your security policy disallows that, turn it off — the rest of the app still works.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What I'd do differently
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Start with &lt;code&gt;tokio::sync::watch&lt;/code&gt; for state channels instead of working backward from &lt;code&gt;broadcast&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Prototype cross-platform signing in week 1, not week 12.&lt;/li&gt;
&lt;li&gt;Wrap the pull-subscription reconnect logic in a generic helper from day one — I had three copies of that loop before I noticed.&lt;/li&gt;
&lt;li&gt;Make the LLM provider boundary visible in the UI from the start. The AI layer was initially a hidden default; customers wanted explicit &lt;em&gt;"this query is going to provider X"&lt;/em&gt; telemetry.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;If you run NATS in production, there's a 14-day trial: &lt;a href="https://hyerix.ai" rel="noopener noreferrer"&gt;hyerix.ai&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;If you don't run NATS but want to play with the stack: &lt;a href="https://github.com/hyerix/hyerix-demo-cluster" rel="noopener noreferrer"&gt;github.com/hyerix/hyerix-demo-cluster&lt;/a&gt; is a docker-compose 3-node JetStream cluster with synthetic activity. MIT, no telemetry. Useful as a fixture for testing client libraries or monitoring tooling regardless of whether you care about Hyerix.&lt;/p&gt;

&lt;p&gt;If any of these Tauri / async-nats notes save you a week, drop a comment — curious what you're building.&lt;/p&gt;

</description>
      <category>rust</category>
      <category>tauri</category>
      <category>nats</category>
      <category>devops</category>
    </item>
  </channel>
</rss>
