Pico Kiln
mobile 7 min read

Pico Kiln - Part 3: From Web to Native with Tauri

One React App, Three Platforms, Zero Mixed Content Drama

Kiln IoT React Tauri IoT Local-First

Most embedded web interfaces are terrible. They’re server-side rendered HTML that requires a full page refresh to see a temperature update. For pico-kiln, I wanted instant state updates, smooth charts, and offline resilience: all from a microcontroller with 264KB of RAM.

The solution: a static React SPA served from the Pico’s flash memory. But then we hit the Mixed Content Problem.

The HTTP/HTTPS Nightmare

Modern browsers enforce a strict security policy: HTTPS pages cannot make HTTP requests. This is called Mixed Content Blocking, and it exists for good reason, but it’s a disaster for local IoT devices.

Here’s the dilemma:

  1. Host React app on HTTPS domain (e.g., https://kiln-app.com) → Browser blocks all requests to http://192.168.1.100 (the Pico)
  2. Add HTTPS to the Pico → Self-signed certificates trigger browser warnings. Let’s Encrypt requires public DNS + port forwarding (absolutely not for a 1200°C device on your network).
  3. Serve React over HTTP from the Pico → Works locally, but can’t deploy to modern hosting (Vercel, Netlify, etc. enforce HTTPS). Also breaks if the user’s network does HTTPS-only DNS resolution.

Every option sucks. Except one.

Enter Tauri: Native Apps, Zero Browser Bullshit

Tauri is a Rust-based framework that compiles web apps into native binaries. Think Electron, but the macOS app is 3.7 MB (not 200 MB), and the Android APK is 29 MB (not 500 MB).

More importantly: Tauri apps bypass browser security policies entirely. The frontend renders in a native WebView (WKWebView on macOS, Android WebView on mobile), but HTTP requests happen at the OS network layer. No Mixed Content errors. No CORS preflight spam. Just clean fetch() to http://192.168.1.100:80.

One Codebase, Three Platforms

The same React app compiles to:

PlatformSizeBuild CommandNotes
BrowserN/Abun devStill works for quick access (served from local pc)
macOS (Universal)3.7 MBbun run tauri:buildIntel + Apple Silicon
Android (ARM64)29 MBbun run tauri:android:buildSupports Android 7.0+

The Tauri config is trivial:

// web/src-tauri/tauri.conf.json
{
  "productName": "kiln",
  "identifier": "com.nivelais.kiln",
  "build": {
    "frontendDist": "../dist",
    "devUrl": "http://localhost:3000"
  },
  "app": {
    "security": {
      "csp": null  // Allow HTTP to local devices
    }
  }
}

The Rust backend is 17 lines of boilerplate. No platform-specific code. No WebView APIs. The React app doesn’t even know it’s running in a native shell.

Android: The Cleartext Challenge

Android 9+ blocks HTTP by default (usesCleartextTraffic=false). Tauri handles this automatically by injecting a network security config:

<!-- Auto-generated by Tauri -->
<network-security-config>
  <base-config cleartextTrafficPermitted="true"/>
</network-security-config>

This is why you can’t just wrap a web app in a WebView. Without this config, the Android OS refuses to make HTTP requests. Browsers have the same restriction, but you can’t override it. Tauri can.

The APK signing is also critical. Android requires apksigner (not jarsigner) for apps with native libraries. Tauri’s build chain handles this:

# Manual signing (if needed)
$ANDROID_HOME/build-tools/36.0.0/apksigner sign \
  --ks ~/kiln-release.keystore \
  --out app-universal-release-signed.apk \
  app-universal-release-unsigned.apk

The final APK includes:

All from bun run tauri:android:build.

Architecture: High-Frequency Polling

WebSockets on the Pico are possible, but they’re expensive. Each open socket consumes ~20KB of RAM (scarce on a microcontroller). Keep-alive packets eat CPU time that should be spent on PID calculations.

Instead, we poll /api/status every 1-2 seconds via fetch(). React Query handles caching, stale state, and automatic retries. If the Pico reboots mid-firing, the UI greys out and shows “Reconnecting…” while preserving the last known state.

// web/src/lib/pico/hooks.ts
export function useKilnStatus() {
  return useQuery({
    queryKey: ['status'],
    queryFn: () => client.getStatus(),
    refetchInterval: (query) => {
      // Poll fast (1s) when running, slow (5s) when idle
      const state = query.state?.data?.state;
      return state === 'RUNNING' ? 1000 : 5000;
    },
    retry: false, // Fail fast to show disconnect
  });
}

The client is a thin wrapper around fetch() with timeout handling:

// web/src/lib/pico/client.ts
export class PicoAPIClient {
  private async request<T>(endpoint: string, options?: RequestInit): Promise<T> {
    const controller = new AbortController();
    const timeoutId = setTimeout(() => controller.abort(), 10000);

    const response = await fetch(`${this.baseURL}${endpoint}`, {
      ...options,
      signal: controller.signal,
    });

    clearTimeout(timeoutId);
    if (!response.ok) throw new PicoAPIError(`HTTP ${response.status}`);
    return response.json();
  }

  async getStatus(): Promise<KilnStatus> {
    return this.request<KilnStatus>("/api/status");
  }

  async runProfile(profileName: string): Promise<RunProfileResponse> {
    return this.request<RunProfileResponse>("/api/run", {
      method: "POST",
      body: JSON.stringify({ profile: profileName }),
    });
  }
}

If the Pico doesn’t respond in 10 seconds (e.g., it’s busy writing a log file to flash), we abort and retry. The UI never hangs.

Local-First State Management

The key insight: the client is the source of truth, not the server. The Pico is a dumb endpoint. It returns JSON. The React app decides what to render.

This is the opposite of traditional web apps, where the server renders HTML and the client is a thin display layer. Here, the Pico could crash mid-firing, and the UI would still show the last 5 minutes of temperature history (cached in React Query).

The connection URL is stored in localStorage. On first launch, the user enters http://192.168.1.100:80. From then on, it persists across sessions. If the Pico’s IP changes (DHCP, new network), they can update it in settings.

This works identically in the browser, macOS app, and Android app. The only difference: Tauri apps don’t enforce Mixed Content rules.

Why Not Just Use the Browser?

You can. But only if you are okay with exposing the pico to the web, or if you have a small server that home that can run a proxy in fromt of the Pico with an SSL certificate.

Because:

The native apps bypass all of this. They’re first-class OS citizens. No security warnings. No browser chrome. Just a clean, native UI that talks to a local device over HTTP.

The Build Process

For macOS:

cd web
bun run tauri:build:universal
# Output: src-tauri/target/release/bundle/macos/kiln.app (3.7 MB)

Tauri compiles the React app to static assets, embeds them in a Rust binary, and cross-compiles to both Intel and Apple Silicon. The final .app bundle is unsigned (users right-click → Open on first launch). For distribution, sign with:

codesign --sign "Developer ID Application: Your Name" --deep kiln.app

For Android:

cd web
# First time: install Rust Android targets
rustup target add aarch64-linux-android armv7-linux-androideabi

# Build APK
bun run tauri:android:build
# Output: src-tauri/gen/android/app/build/outputs/apk/universal/release/
#         app-universal-release-unsigned.apk (29 MB)

The Android build is wild. Tauri:

  1. Compiles React to static assets
  2. Embeds assets in Rust binary
  3. Cross-compiles Rust to ARM64 via NDK
  4. Wraps in Android APK with WebView
  5. Injects cleartext network config
  6. Signs with apksigner

All from one command.

What We Get

A kiln controller UI that:

All from a single React codebase. No Electron bloat. No browser security theater. No certificate management.

The Pico serves JSON over HTTP. The UI consumes it. The platform doesn’t matter.


Tech Stack:

Next up: Part 4: Physics-Based Data Analysis with Python: Phase detection, PID tuning metrics, and thermal modeling from CSV logs.

100%