Platform defines a target-agnostic API interface consumed by the app layer (packages/app). App
code does not conditionally implement logic based on the platform - it simply calls the method.
If said API is not available? Then it's a noop. Never perform conditional logic like that inside
app - though in some cases it's necessary, like not rendering a part of UI if the platform does
not support it. The contract and its adapters live in packages/platform. The adapter concept
and the three-context client model are described in
Clients architecture.
Here's an example of the Platform interface (subject to change):
export interface Platform {
readonly target: "web" | "desktop" | "mobile"
readonly notifications: NotificationPort
readonly tray: TrayPort | null // null on web/widget
readonly audioDevices: AudioDevicePort
readonly deepLinks: DeepLinkPort | null // null in the browser
readonly fileTransfer: FileTransferPort
readonly dns: DnsPort | null // null in the browser
readonly historyCache: HistoryCachePort | null // IndexedDB (web) / SQLite (desktop); null when storage is blocked
}App code can call the usePlatform composable anywhere, which exposes the globally injected
interface based on the active platform. This is what keeps the component tree
environment-agnostic and the app package headlessly testable.
The factory adapter simply nulls out what the current platform cannot do:
export function createWebPlatform(): Platform {
return {
target: "web",
notifications: createNotificationPort(),
tray: null, // no native tray in the browser
audioDevices: createAudioDevicePort(),
deepLinks: null, // no orbit:// handler in the browser
fileTransfer: createFileTransferPort(),
dns: null, // resolver endpoint used instead
historyCache: createIndexedDbCachePort(), // null if IndexedDB is unavailable
}
}The HistoryCachePort contract and its per-environment adapters are specified in
Local Cache.
Each capability port maps to a concrete implementation per environment:
| Capability | Desktop (desktop.ts) | Web (web.ts) |
|---|---|---|
| System tray + badge | Native OS tray via Tauri plugin | document.title badge count; favicon overlay |
| OS notifications | Tauri notification plugin | Web Notifications API (with permission prompt) |
| Audio device management | cubeb via Rust IPC command |
MediaDevices API (navigator.mediaDevices.enumerateDevices()) |
orbit:// URI handling |
Registered OS scheme; Tauri single-instance focus | Stubbed - not available in browser |
| DNS SRV resolution | Rust DNS resolver via IPC | Not available; user enters host directly or a server-side resolver endpoint is used |
| File I/O / large IPC | Tauri custom protocol handler | Standard fetch + pre-signed S3 URLs |
Everything outside this table is shared and runs identically in both environments: the wasm core (IRC logic, Satellite/WebRTC session handling) and the app package (VUI components, Pinia stores, message rendering). The mobile adapter reuses the desktop adapter and overrides only what differs.
The platform adapters allow applications to differ only in their initialization - the main.ts
file.
// apps/web/src/main.ts
import { createOrbitApp } from "app"
import { createWebPlatform } from "platform"
import App from "./App.vue"
// Platform exposes the create web platform factory
const platform = createWebPlatform()
// The app package exposes an enhanced Vue app creation composable which automatically provides the platform, routing, and globals
const app = createOrbitApp(App, platform)
// This is where the application starts, for safety we define it explicitly
app.mount("#app")The desktop and mobile entrypoints are the same four lines with a different adapter
(createDesktopPlatform(), the mobile factory extending it). The adapter is provided once,
before mount, so every component and composable downstream can usePlatform() synchronously
without guards.
The app component itself is a pass-through into the app package:
<!-- apps/web/src/App.vue -->
<script setup lang="ts">
import { OrbitApp } from "app"
</script>
<template>
<OrbitApp />
</template>platform.target is the single source of truth for "which environment am I in". Consequences:
- Components and stores that need to branch on environment read
usePlatform().target - The
<div class="ob-root" :style="--ob-platform: ${platform.target}">exposes the target to CSS for environment-specific styling without any JS branching.
- No platform imports inside
app. A lint boundary forbidding@tauri-apps/*outsidepackages/platform, and discouraging rawnavigator.*/window.*capability access outside the adapters, is the cheap mechanical guard. If app code needs a capability, add a port to the contract. - No deep imports across packages. Consume
appandplatformthrough their package entrypoints (packages/*/src/index.ts) - Adapters own all the messy parts. Permission prompts,
enumerateDevices(), anchor-click downloads, Tauri IPC - all of it lives in their respective packages
Because the only environment dependency is the injected Platform, the app package is tested
headlessly against a mock adapter:
const platform: Platform = {
target: "web",
notifications: { requestPermission: async () => true, notify: async () => {} },
tray: null,
audioDevices: { enumerate: async () => [], onChange: () => () => {} },
deepLinks: null,
fileTransfer: { download: async () => {} },
dns: null,
historyCache: null,
}The seam reduces "support a new platform" to a mechanical checklist:
- Add
packages/platform/src/<target>.tsexporting a factory that returns aPlatformwith the righttargetand ports (reuse an existing adapter and override only what differs; the mobile adapter extendsdesktop.ts). - Add
apps/<target>/with a four-linemain.tsthat injects the new adapter. - If the target unlocks a brand-new capability, add a port to the contract in
platformand implement it across the existing adapters (nullwhere unavailable).
No change to the component tree, stores, or routing is required.
- Clients architecture - the client family and adapter concept
- Local Cache - the
HistoryCachePortcontract and per-environment backing stores - Monorepo - where the packages and entrypoints live