Cross-Platform Pet App Architecture: One Codebase for Phone, Desktop, and Web — PupPal

2026-08-27

Cross-Platform Pet App Architecture: One Codebase for Phone, Desktop, and Web — PupPal

A dog lives in more places than any single device does. Breakfast happens in the kitchen while the owner checks email on a laptop; the afternoon walk happens at the park with a phone in hand; the weekend trip means the dog stays with a grandparent who owns an old Android tablet; and the owner still wants to glance at the morning check-in from a browser at work. A cross-platform pet app has to be where the dog is, not where the SDK happens to be. PupPal is built exactly that way: one Flutter codebase that ships to Android, iOS, Windows, macOS, Linux, and the web, with a single Rust core behind all six. This post walks the real architecture in the puppal/ directory — the capability layer that keeps the code honest, the guards that let dart:io survive on the web, the flutter_rust_bridge setup that generates both native and WASM bindings from one Rust crate, the per-platform payment split, and the CI matrix that builds all six targets from the same source. The pattern is deliberately borrowed from the open-source tools every Flutter developer knows: the one-codebase-everywhere structure of LocalSend, and the account-free sharing model of RustDesk that already anchors PupPal's care codes.

The premise deserves stating plainly, because it shapes every design decision below: the owner's phone is not the pet's only habitat. A photo-based check-in product only works if the photo can be taken and reviewed from whatever device the owner actually has in hand. So the app treats platform differences as a capability question, not a fork — every platform gets the same product, and each one simply reports which hardware and OS affordances it has. Here is how that is wired in the source.

The Cross-Platform Pet App Problem: Your Dog Is Never in One Device

Open the puppal/ directory and the first thing you notice is the full spread of platform folders: android/, ios/, linux/, macos/, web/, and windows/ sitting side by side, all pointing at the same lib/. That layout is the LocalSend pattern — the open-source file-sharing app that runs on Android, iOS, Windows, macOS, and Linux from a single Flutter codebase — applied to pet care. Nothing about the product logic lives in any of those folders; they are thin runners that hand the reins to lib/main.dart and lib/app.dart. The product logic, the AI bridge, the care flows, the i18n assets, everything that makes PupPal PupPal, lives in lib/ and rust/, independent of what it is running on.

Supporting that spread is a version lock most single-platform apps never think about. The repository pins Flutter to the master channel in .fvmrc — PupPal rides the FVM-managed master build, and the justfile documents a Windows desktop debug recipe (fvm-debug-windows) that runs with --enable-flutter-gpu --enable-impeller because the app's 3D scene rendering (via flutter_scene, with its generated hook/build.dart initialized through fvm dart run flutter_scene:init) needs Flutter GPU support. Bleeding-edge Flutter is a deliberate cost: the 3D pet scene, the desktop targets, and the web target all pull features from master, and pinning one version for all six platforms is what keeps the single codebase actually buildable in this repo at all.

The build side treats the six targets as first-class citizens in exactly the same way. The CI file .github/workflows/build.yml is titled "Build Puppal" and its header says the job list plainly: it "builds release artifacts for every supported platform" — Windows (through PowerShell), macOS, Linux, Android, iOS, and Web — each as its own job that can be toggled independently, so a broken toolchain on one platform never blocks the other five. The workflow's security notes explicitly mirror "rustdesk's productized setup": every third-party action pinned to an immutable commit SHA, least-privilege permissions blocks, persist-credentials: false on checkout, SHA256 checksums computed and signed with minisign (Ed25519), and Sigstore/OIDC attestations attached to build artifacts.

Why does the platform spread matter for a pet app specifically? Because care is a relay race. In the PupPal flow, the owner snaps a photo check-in, the app uploads it to the Cloudflare Worker, which stores it in R2 and wakes Hermes Agent for vision analysis, C7 anomaly detection, and a state update. Later, the owner starts a care session and a caregiver types in a nine-digit care code plus PIN — no account, no Hermes, the RustDesk pattern — and that caregiver may be on any device the family owns. The photo check-in deep dive covers the byte path of that photo; this post covers the substrate underneath it: the fact that every participant in that relay — owner on a phone, owner on a desktop, grandmother on an old tablet, even a plain browser — can hold the same app experience because the codebase never forked.

The Capability Layer: How PupPal Keeps One Codebase Honest

The single most important file for cross-platform sanity in this repo is tiny: lib/core/utils/platform_check.dart, 75 lines of pure capability predicates. It is built around one helper, checkPlatform(List<TargetPlatform> platforms, {bool web = false}), which returns true when the current defaultTargetPlatform is in the list — or, when web is true, when kIsWeb is set, because the web has no meaningful TargetPlatform of its own (the browser reports based on the host OS). On top of that helper sit named predicates that read like a feature manifest for each surface:

  • checkPlatformIsDesktop() — Windows, macOS, Linux.
  • checkPlatformHasTray() — Windows, macOS, Linux only. A system tray is a desktop affordance; phones and browsers do not get one.
  • checkPlatformCanReceiveShareIntent() — Android and iOS only. The OS-level share intent exists on mobile; elsewhere the app offers its own share sheet.
  • checkPlatformWithFolderSelect() — every platform except web; browsers forbid picking folders, so the file picker path changes.
  • checkPlatformWithGallery() — Android and iOS only. A curated photo gallery is a mobile OS concept.
  • checkPlatformWithFileSystem() — Linux, Windows, Android, macOS; notably not web and not iOS, which sandboxes its filesystem differently.
  • checkPlatformSupportPayment() — Android, iOS, and macOS only. More on that split below.
  • checkPlatformIsNotWaylandDesktop() — a Linux-specific guard that checks the XDG_SESSION_TYPE environment variable, because a Wayland session cannot use the same screenshot and window paths as an X11 one.

This predicate list is the whole philosophy in miniature: the product never asks "what device is this?", it asks "what can this device do?" The feature set on any platform is the intersection of the product intent and the capability set, computed at runtime, not at compile time. A Windows machine gets the tray icon, file-system access, and desktop dialogs; an iPhone gets share intents, the gallery, and in-app purchase; a browser gets none of the desktop affordances but the same check-in flow, the same care codes, the same photo analysis results.

Alongside the predicates sits lib/core/utils/platform.dart, home of the PlatformTool class — isDesktop(), isMobile(), isWeb(), isWindows(), isMacOS(), isLinux(), operatingSystem(), localeName(). The class looks trivial, but one detail makes it load-bearing: every call into dart:io's Platform is wrapped in a try/catch that returns false (or a safe default) on failure. That is deliberate, and it is the key trick for running one codebase on web and native at once — see the next section. When localeName() fails on the web, it falls back to zh_Hans_CN as the default locale, because the app's i18n layer (slang) resolves the rest.

The capability layer is also where you see the design reacting to real platform quirks as they surface. The photo_grid.dart widget carries a special case: cloud albums get extra handling when hasCloud && !kIsWeb && defaultTargetPlatform == TargetPlatform.windows. A recent commit (fix(home) in the git log) fixed an AI chat page issue where Pro entitlement was misjudged and the desktop dialog width was wrong — desktop windows are wider than phone screens, so dialog layout is a per-platform concern even in a responsive codebase. And the recent voice work (fix(voice): 恢复 Windows/Linux 端侧音色入口) restored the on-device voice entry point for Windows and Linux after it had been gated too aggressively. These are the small, constant corrections that a genuinely multi-platform codebase accumulates — and they all flow through the same capability predicates rather than spawning platform forks.

Surviving dart:io on the Web: Guards, Conditional Imports, and universal_io

The classic enemy of a Flutter app that targets both mobile/desktop and web is dart:io. It simply does not exist on the web — importing it, or touching Platform.isAndroid in a browser build, throws at runtime. PupPal's codebase handles this in three layered ways, and together they are the difference between "one codebase" as a slogan and as a fact.

First, the try/catch guard pattern from PlatformTool. Every Platform.* access in platform.dart sits inside a try/catch, so even code paths that run on the web degrade gracefully instead of crashing. isWeb() returns kIsWeb directly; everything else attempts the native call and returns false when the dart:io implementation throws. This is the pattern that lets a single import of dart:io exist in a file that also runs on the web — a pragmatic middle ground between full platform-conditional code and lawless direct access.

Second, capability gates at the feature level. providers/offline_asr_settings_provider.dart exports showOfflineAsrSectionProvider as !kIsWeb — the offline speech-recognition settings section simply does not render in a browser, because the on-device ASR engine cannot run there. services/voice/voice_style_model.dart gates a voice-style feature on !kIsWeb && (Platform.isAndroid || Platform.isIOS). services/sync/user_identity_service.dart returns 'Web' as the device platform string when kIsWeb is set, rather than attempting the native call. Each of these is the same move: detect the web at the earliest point, pick the safe branch, keep the rest of the code identical.

Third, and most interesting, universal_io. The pubspec pins universal_io: ^2.2.2 with the comment "reliable_http_downloader.dart 跨平台 IO(Web/桌面统一)" — the reliable downloader service imports package:universal_io/io.dart instead of dart:io. universal_io provides a dart:io-compatible surface that resolves to real dart:io on native platforms and to browser-compatible implementations on the web. The downloader uses it for cross-platform IO so that the same retry-and-resume download logic (used for AI model files) works identically when the app hands the download to a browser's fetch machinery or a native socket stack. This is the same technique the LocalSend codebase uses to keep one download path across five operating systems.

The combination is what allows the app to host genuinely heavy offline features — like the sherpa-onnx speech models and the on-device voice synthesis — on desktop and mobile, while the web build simply does not offer them, all without a second codebase. There is a beautiful asymmetry in it: the same check-in photo flow, the same care code entry, the same analysis result screen run everywhere; only the engine of the app changes (native AI on device, cloud AI from the browser), and the capability predicates decide which engine is presented.

One Rust Core on Every Surface: FRB io and Web Bindings

The heart of PupPal is not Dart at all — it is a Rust crate at puppal/rust/ that hosts the AI orchestration engine, gesture and model optimization, the pi-agent runtime, and even a WebRTC stack. The flutter_rust_bridge.yaml at the Flutter project root is three lines:

rust_input: crate::api
rust_root: rust/
dart_output: lib/rust

Point flutter_rust_bridge at the Rust crate's api module, and it generates the Dart bindings into lib/rust/. The result, as it exists in the source tree, is the elegant part: lib/rust/frb_generated.dart (the shared surface) plus two platform variants — frb_generated.io.dart, which imports dart:ffi and flutter_rust_bridge_for_generated_io.dart, and frb_generated.web.dart, which imports flutter_rust_bridge_for_generated_web.dart. One Rust crate, one Dart API, two generated glue layers: native platforms talk to the Rust core through the FFI C ABI; the web build talks to the very same functions compiled to WASM. That is how a cross-platform pet app can run its real AI engine, not a demo stub, on desktop and browser alike.

What does that Rust core actually carry across every platform? The generated bindings expose four API modules: pet_model_optimize (the model-side optimization entry), pi_bridge (the pi-agent bridge — events, files, runtime, session, tools), rtc, and a small simple example surface. Everything is FRB-typed: the Rust side exports its functions through the bridge, and Dart calls them as ordinary typed methods with the same signatures on every platform.

The rtc module deserves its own mention because it is the most aggressive cross-platform decision in the codebase. The Rust file rust/src/api/rtc.rs implements a WebRTC DataChannel point-to-point transport using webrtc-rs, a pure-Rust WebRTC implementation — explicitly built to replace the flutter_webrtc plugin and the libjingle library it dragged in (about 11.5 MB, 95 percent of it an audio/video engine the app never used; the file's header says so in plain words). The module implements only the DataChannel subset: SDP offer/answer negotiation, trickle ICE, text and binary frame send/receive, and send-buffer water level backpressure. Signaling JSON encoding and the family channel's target routing are handled on the Dart side. Internally, all webrtc-rs tasks run on a self-held tokio runtime with two threads, and every FRB-facing function is exposed synchronously (block_on inside) to avoid depending on the FRB async executor's timing; events flow back to Dart through StreamSink, with events that arrived before subscription queued and replayed in order.

Why does a pet check-in app need WebRTC at all? For direct photo transfer. In the family and care flows, photos can go peer-to-peer instead of through the relay — the commit feat(family): 家庭圈重构——一人一家庭 + D1 邀请码 + WebRTC 照片直传 introduced family-circle invite codes with D1 and WebRTC photo direct transfer. The protocol header is explicit about the cross-platform payoff: it is fully compatible with the H5 care page running in a plain browser's native WebRTC and with older app builds using libjingle, so any combination of old and new clients can interop. In other words, the owner on the newest Android build can beam a photo directly to a caregiver looking at the browser-based care page on a laptop — the byte path for that photo is decided at runtime by what both peers support, and the same Rust code compiled to native or WASM negotiates it. The Rust AI engine post covers why the native Rust core exists; this is the part where that core is portable — the Rust → FRB → Flutter bridge is what the bridge optimization post dissects in detail, from bridge events to tool calls to usage tracking.

Payments Without a Storefront: Per-Platform IAP

Nothing exposes platform reality faster than money. PupPal's payment layer is a case study in capability-driven design: the in_app_purchase plugin (Apple and Google billing) covers Android, iOS, and macOS, while Windows, Linux, and the web get their own handlers because they have no storefront billing at all.

The split is encoded in the same predicates from the capability layer: checkPlatformSupportPayment() returns true only for Android, iOS, and macOS, and the IAP service (lib/services/iap/iap_service.dart) explicitly skips its storefront path when kIsWeb is set. Desktop browsers are covered by two dedicated handlers under lib/services/iap/desktop/: stripe_iap_handler.dart and creem_iap_handler.dart. Both expose the same _platformName getter that answers kIsWeb ? 'web' : 'desktop' — because a browser checkout and a desktop-app checkout differ in how the payment page is launched. The Stripe handler, for instance, chooses LaunchMode.platformDefault when running in a browser (opening the hosted checkout in the same tab or a popup, per browser behavior) versus LaunchMode.externalApplication on native desktop (handing off to the system browser).

The important architectural note: the entitlement logic — what Pro unlocks, whose subscription it belongs to — lives once, above the payment handlers, so swapping the billing provider per platform never forks the business logic. Users who pay through the Apple App Store or Google Play, through Stripe on Windows, or through Creem on Linux all land in the same subscription state. The same care features, voice features, and analysis cadence unlock identically, which matters for a product whose users genuinely do move between a phone and a desktop within one day.

Voice and Offline AI: Capabilities, Not Assumptions

Voice is arguably the most unevenly supported feature family in all of Flutter — system TTS quality varies by OS, on-device TTS by architecture, speech recognition by browser. PupPal handles it with a single class, VoiceCapabilities, which is literally defined as "the capability set of voice features on the current platform," and a current() factory that returns the right set per platform so the settings page can show or hide entries dynamically instead of offering dead options.

The matrix, straight from lib/services/voice/voice_capabilities.dart:

  • Web: speech-to-text true (browser engines), system TTS true, but the on-device puppy voice (sherpa-onnx KittenTTS) false, custom system voice false, local audio output false.
  • Android/iOS: everything on — speech-to-text, system TTS, the on-device puppy voice, custom system voice selection, and local audio playback.
  • macOS: fully open as of a 2026-08-25 change — sherpa-onnx ships official prebuilt libraries for macOS, so the on-device voice was un-gated "与 iOS 一致" (consistent with iOS).
  • Windows/Linux: system TTS with caveats (quality varies), on-device voice entries restored by the recent fix commit, and a desktopTts slot reserved for a future native desktop TTS integration.

The on-device voice path is a mini cross-platform saga of its own. The sherpa-onnx engine runs behind the scenes in a background isolate with pre-warming (a commit moved sherpa TTS into a background isolate to fix preview stutter), and the mobile APK slimming story shows how deep platform-specific work goes: a local fork of sherpa_onnx_android_arm64 replaces the upstream prebuilt package so a trimmed libonnxruntime.so (cut to fit the TTS models) can ride inside the APK, taking the runtime from 20.7 MB toward roughly 7 MB. Meanwhile the KittenTTS model download itself goes through the universal_io-backed reliable downloader with mirror ranking and resumable downloads — the same cross-platform IO path discussed above, now carrying multi-hundred-megabyte model files to phones and desktops alike.

What all of this adds up to is the design stance: the platform never dictates the feature; the capability does. A browser gets cloud analysis and system voice; a phone gets everything the silicon can hold, including an on-device puppy voice that reads the daily voice message in the pet's first-person — the feature that the daily voice post describes from the content side. The mobile and desktop builds can even run the full local AI engine; the web build leans on the Cloudflare Worker and Hermes Agent relay, which the worker relay post explains in depth — R2 for photos, KV for subscriptions and rate limits, Durable Objects for the agent session. Cloud-first on the web, local-first on devices, same product contract.

One Build Pipeline for Six Platforms — and What It Buys the Owner

Step back from the modules and the architecture closes into a single argument. The release workflow in .github/workflows/release.yml calls the reusable build workflow, which compiles six distinct artifacts — Windows installer, macOS bundle, Linux package, Android AAB, iOS archive, and the web export — from the same lib/ and rust/. Each job runs independently, each uploads immutable artifacts, and a single isolated release job downloads them all, computes SHA256 checksums, and signs the manifest with minisign. The whole pipeline mirrors how RustDesk productizes its builds: pinned actions, least-privilege permissions, attestations. That is the LocalSend lesson applied at the CI level — platform coverage is a first-class deliverable, not an afterthought.

For a pet owner, the payoff is not architectural trivia; it is a set of concrete freedoms:

  • The check-in photo works everywhere. Phone at the park, laptop in the kitchen, browser at the office — the photo check-in, the analysis result, and the health status are the same product on every surface, because they are literally the same code.
  • Caregivers never need the right device. The care code plus PIN model, combined with a browser-compatible core (WASM Rust, native WebRTC in the browser, H5 care page), means a caregiver with any phone, tablet, or computer — or just a browser — can take over a care session. No app store, no account, no platform requirement beyond "runs a browser."
  • The offline AI follows the hardware. Devices with the storage and the silicon get the on-device engine, including the puppy voice; browsers get the cloud relay. The owner is not punished for owning a desktop instead of a phone.
  • One codebase means one feature set. When the daily review gains a new analysis, or the care handbook gains a section, every platform receives it in the same release — no platform lag, no "coming soon on desktop" feature gaps that split how the family cares for the dog.

So when you open PupPal on a phone to snap the morning photo, then again on a Windows desktop that evening to read the daily review with the tray icon still warm, the two experiences are not siblings — they are the same codebase wearing different capability masks. The Rust core, the FRB bridge, the capability predicates, and the six-job CI pipeline exist so that the question "what device is the owner holding?" never has to be asked by the product logic. The only question that matters is what the dog needs next, and the answer — photo, care code, review, voice — is available on every screen the family owns.

Build PupPal for the phone in your pocket today, and the desktop in your home office tonight — the same dog, the same care, one codebase. That is what a true cross-platform pet app feels like: the platform disappears, and only the pet remains.