Virya Signal is one app with two trust boundaries
Fans and staff share one Virya Signal mobile shell, but their credentials, permissions and failure handling are kept separate.
Virya Signal began as a mobile control panel for the band. Staff needed tickets, QR scanning, campaigns and the few operational actions that are painful to do from a laptop at the entrance to a concert.
Then the fan side started to make just as much sense. Confirmation, nearby shows, tickets, rewards and the private wallet already existed in CrowdRelay, so putting them into a small mobile app was useful. Treating fans and staff as the same kind of user wasn't.
So the app has one shell and two trust boundaries.
Fan identity
A fan enters through email confirmation. They provide an address, city, consent and a local PIN. CrowdRelay sends a one-time confirmation token. The app can receive the token directly, extract it from the complete confirmation URL or scan it from a QR code. After confirmation, the backend creates a fan session and the app stores the credential in the encrypted local vault.
The local PIN protects the device copy. It isn't sent to CrowdRelay, and it isn't a second account password posing as a global identity. Losing the app state shouldn't change the fan record on the server; it should require a controlled session recovery flow.
Staff access
Staff access is different. A trusted operator device is paired through a separate route and receives a role-scoped credential. Scanning an admission pass doesn't require the same permission as issuing one. Creating campaigns doesn't imply access to accounting. An owner, an operator and a person helping at the door may use the same app, but the backend still decides which action each session may perform.
This matters more than the visual mode picker. A universal session with a role field in local storage would be convenient until a fan-side bug, a copied backup or an over-broad endpoint crossed the boundary. Separate vault records, commands, API clients and permission checks make that mistake harder to express.
Several ways to confirm
The awkward part was onboarding on a phone. A nicely designed confirmation link is still terrible when the app asks somebody to select sixty-four characters from the middle of it. So the flow is deliberately redundant:
tap the linkor scan the QRor copy the visible tokenor paste the complete URLThe backend accepts the exact token or a confirmation URL containing it. The app normalises the input before sending it and tells the user specifically when a token is malformed, expired or already used. Convenience belongs at the edge; token validity still belongs to CrowdRelay.
Visible failures
Mobile failures needed the same care. A WebView can disappear, camera permission may be denied, a native command may time out and Android may expose the page a moment before the Tauri bridge is ready. I didn't want any of those to look like a silent return to the home screen.
Web UI, native boundary
Tauri gives me a useful implementation boundary here. The WebView owns the fast-moving interface, while Rust commands own native capabilities, encrypted local state and the parts where I want stronger types. That boundary also made serialization bugs impossible to hand-wave away: if an API field may arrive as either text or bytes, the mobile model has to represent that contract explicitly instead of turning a valid response into an opaque deserialization failure.
Virya Signal keeps native calls bounded, shows runtime diagnostics and produces a crash report that can be copied instead of guessed at. The Rust code avoids unwrap and expect in application paths. That won't legislate panics away, but input, storage and network failures deserve explicit states.
Splash screen
The splash screen follows the same rule. It shows the actual Signal animation while the app starts and disappears as soon as the interface is ready. There's no artificial minimum duration to make the logo feel important.
What the two sides share
Sharing one app still saves a lot of work. The design language, API transport, QR scanner, error handling and release process are common. The identities aren't.
One binary can serve both sides of a concert, but the boundary should stay visible even though the code lives in the same repository.
Push delivery health
Native push registration, permission state and installation identity stay on the fan/native side, but delivery health is visible to operators. Virya Signal shows the CrowdRelay push queue next to outbox and provider-delivery health, the web control plane exposes the same state, and the backend publishes matching Prometheus gauges.
So “push enabled” and “push healthy” are no longer the same boolean. Configuration says whether FCM/Web Push can be used; the queue says whether messages are actually progressing.
The Rust side was split along the same trust and responsibility lines: fan commands are separated into push, session/commerce and wallet flows; operator and fan UI modules are split by capability; native models are grouped by contract family; and the WebView bridge keeps its JavaScript FFI runtime separate from the typed Rust client. A source-size contract stops those boundaries from collapsing back into thousand-line controllers.