Client Setup
Attach a device host to the web, iOS, Android and desktop clients, declare the permissions each capability needs, and use the fake host during development
Client Setup
On the client, a DeviceHost receives device requests, runs them, and owns everything the user sees: consent dialogs, pickers, the capture screen and the recording indicator. The renderer isn't involved. It stays a pure patch consumer, and device requests never touch the UI tree.
A client without a device host connects as before and simply offers no
capabilities. On such a connection, supports() returns false on the
server, and requests fail with unavailable.
What a client offers
A native host advertises a capability only when all three of these hold:
- The hardware exists: a camera, a microphone, a Bluetooth LE adapter.
- The app declared what the OS requires: the
Info.plistusage description on iOS, the<uses-permission>entry on Android. - For
mic.recordandbluetooth.scan, the host's indicator can be shown right now. Scans and recordings must run under a visible indicator with a Stop button (see Consent & Security).
Whether the user has granted a permission is never part of this rule. A permission nobody has asked for yet is still advertised, so your app can prompt for it. A refused permission doesn't disappear either, so the advertisement reveals nothing about the user's permission history.
When conditions 1 or 3 change while connected (for example, the app goes to
the background or the indicator overlay is removed), the host sends a fresh
capability list, and supports() on the server follows it. A request that
races such a change fails with unavailable before any hardware opens.
The capabilities negotiated when the connection opened are its ceiling: a
fresh list can withdraw a capability and bring it back, but it can't add
one that the connection didn't negotiate. Such a capability stays
unsupported on both sides until the next connection.
The web host feature-detects browser APIs instead. For example, it offers
bluetooth.select only where Web Bluetooth exists. The desktop host offers
only what it implements (the file capabilities), and only when a file dialog
can be shown.
Web
Install the browser device host:
bun add @hypen-space/device-webPass a WebDeviceHost to RemoteEngine:
import { RemoteEngine } from "@hypen-space/core";
import { WebDeviceHost } from "@hypen-space/device-web";
const url = "wss://app.example.com/ws";
// `origin` is the server origin the consent dialogs name, and the key that
// cooldowns and grants are stored under.
const device = new WebDeviceHost({ origin: new URL(url).origin });
const remote = new RemoteEngine(url, { device });
remote.onPatches((patches) => renderer.applyPatches(patches));
await remote.connect();RemoteEngine sends the host's advertisement in hello, routes device
messages and binary frames to it, and detaches it whenever the socket closes.
All of the host's UI (the consent dialog, the capture dialog, the recording
indicator) is mounted outside your app's DOM tree, under document.body by
default.
| Option | Default | Meaning |
|---|---|---|
origin | required | The authenticated server origin. Prompts name it, and cooldowns are stored under it. They persist only for https:/wss: origins; plaintext origins keep them for the page session only. |
capabilities | every built-in driver | Limit what the host offers |
denialCooldownMs | 30000 | How long after a refusal the same capability can't prompt again |
maxTimeoutMs | 300000 | The client's own ceiling on any request deadline |
maxDownloadBytes | 64 MiB | Largest file.save accepted |
inputProtectionMs | 500 | How long Continue stays disabled after the dialog appears, or after the page regains focus |
stopRecordingWhenHidden | true | End a recording normally when the page becomes hidden |
captureBufferBytes | mic 1 MiB, video 8 MiB | Bytes a live capture may hold while the server grants no credit, before it ends throttled |
mount | document.body | Where the host's UI mounts |
What each capability needs in the browser:
| Capability | Offered when |
|---|---|
gallery.pick, file.pick, file.save, permission.query, permission.request | Always |
camera.capture | getUserMedia exists |
mic.record | getUserMedia and an audio capture backend (AudioWorklet, or the ScriptProcessor fallback) exist |
bluetooth.select | navigator.bluetooth.requestDevice exists (Chromium-based browsers) |
bluetooth.scan | Never: the web has no unrestricted BLE scanning |
Browsers can't set custom headers on a WebSocket upgrade. If your server
configures an authenticate hook, it must accept a credential the browser
sends automatically, such as a session cookie. Camera and microphone access
also require a secure context (https:).
The prebuilt client that RemoteServer serves over HTTP (webClient), and
the Cloudflare serveClient bundle, don't attach a device host. To use device
capabilities on the web, build your own client entry as shown above.
iOS
DeviceHost.iOS() builds a host with the native drivers. Create it once and
pass it to HypenView (or to RemoteEngine):
import SwiftUI
import HypenSwift
@main
struct MyApp: App {
// One host for the app's lifetime: it owns cooldowns, grants and the
// one-prompt-at-a-time gate.
let device = DeviceHost.iOS()
var body: some Scene {
WindowGroup {
HypenView(
url: "wss://app.example.com/ws",
config: RemoteEngineConfig(
// Native clients send no Origin; the server's authenticator admits them.
upgradeHeaders: ["Authorization": "Bearer \(AppAuth.token)"]
),
device: device
)
}
}
}For credentials that rotate, use upgradeHeaderProvider. It's called on
every connection attempt, including reconnects. By default the host takes
its origin from the socket it's attached to. If you pass
DeviceHost.iOS(origin: "https://app.example.com"), the host refuses any
socket whose origin differs.
The iOS drivers: PHPicker (gallery.pick), UIDocumentPickerViewController
(file.pick, and the export picker for file.save), UIImagePickerController
(camera.capture), AVAudioEngine with AVAudioConverter (mic.record),
and CoreBluetooth behind a host-owned chooser sheet (bluetooth.select) and
the host indicator (bluetooth.scan).
Info.plist keys
Asking iOS for a permission without its usage description terminates the app, so the host checks declarations before offering anything:
| Capability | Requires |
|---|---|
gallery.pick, file.pick, file.save | Nothing. PHPicker and the document pickers run out of process. |
camera.capture | A camera and NSCameraUsageDescription. Video also needs NSMicrophoneUsageDescription (checked per request). |
mic.record | An audio input, NSMicrophoneUsageDescription, and a showable indicator |
bluetooth.select | NSBluetoothAlwaysUsageDescription |
bluetooth.scan | NSBluetoothAlwaysUsageDescription and a showable indicator |
permission.query / permission.request | Always offered. Asking about a permission whose key is missing answers unavailable with platformDetail not-declared:<name>. |
Keys used by permission.*: camera → NSCameraUsageDescription,
microphone → NSMicrophoneUsageDescription, photos →
NSPhotoLibraryUsageDescription, bluetooth →
NSBluetoothAlwaysUsageDescription, location →
NSLocationWhenInUseUsageDescription, contacts →
NSContactsUsageDescription. notifications needs no key.
<key>NSCameraUsageDescription</key>
<string>Take a photo for your profile.</string>
<key>NSMicrophoneUsageDescription</key>
<string>Record voice notes.</string>
<key>NSBluetoothAlwaysUsageDescription</key>
<string>Connect to your heart-rate sensor.</string>The activity indicator
Scans and recordings run under the host's own indicator, which shows the
origin and a Stop button. iOS has no system indicator the host can rely on
for a Bluetooth scan, so by default the indicator is a pass-through overlay
window above your app's UI. If the overlay can't be seen (for example, the
scene is in the background), mic.record and bluetooth.scan are withdrawn,
and a running stream stops. To replace the indicator, pass
activityIndicator:. A custom indicator must stay visible with a Stop control
for the whole stream and report whether it can be shown. bluetoothChooser:
replaces the chooser sheet used by bluetooth.select.
Android
AndroidDeviceHost.create(...) builds the host. It is Application-scoped:
it registers activity lifecycle callbacks, follows whichever Activity is in the
foreground, and survives rotation. Create it once and reuse it:
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import space.hypen.renderer.HypenApp
import space.hypen.renderer.device.DeviceHost
import space.hypen.renderer.device.android.AndroidDeviceHost
import space.hypen.renderer.remote.RemoteEngineConfig
const val SERVER_URL = "wss://app.example.com/ws"
object AppDevice {
@Volatile private var host: DeviceHost? = null
fun get(activity: ComponentActivity): DeviceHost =
host ?: synchronized(this) {
host ?: AndroidDeviceHost.create(activity, serverUrl = SERVER_URL).also { host = it }
}
}
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val device = AppDevice.get(this)
setContent {
HypenApp(
url = SERVER_URL,
// Native clients send no Origin; the server's authenticator admits them.
config = RemoteEngineConfig(headers = mapOf("Authorization" to "Bearer ${AppAuth.token}")),
deviceHost = device, // HypenApp also renders the indicator overlay
)
}
}
}create takes these arguments:
| Argument | Default | Meaning |
|---|---|---|
activity | required | Supplies the Application and the initial foreground Activity. No strong reference to it is kept. |
serverUrl | required | Sets the default origin. Each connection is bound to the origin of the URL its engine connects to. |
capabilities | all nine | Limit what the host offers |
activityIndicator | ComposeDeviceActivityIndicator() | The indicator for scans and recordings. Pass null to never offer mic.record or bluetooth.scan. |
configure | identity | Adjust DeviceHostConfig (for example denialCooldownMs, 30 s by default) |
Nothing disposes the host implicitly. Call device.dispose() when device
access is no longer needed. Alternatively, pass
HypenApp(deviceHost = …, disposeDeviceHost = true) if you really want a
host scoped to one screen. For rotating credentials, use
RemoteEngineConfig(headersProvider = { … }). It's evaluated on every
connection attempt.
HypenApp composes the default indicator overlay for you. If you use
RemoteEngine(url, deviceHost = device) directly, place
DeviceActivityOverlay(...) in your UI yourself. mic.record and
bluetooth.scan are offered only while such an overlay is composed on a
started screen. The capabilities in the hello are fixed for the whole
connection, so when the socket opens in the foreground before the overlay
has attached, the engine waits up to 2 seconds for it before sending the
hello. A capability that only becomes available after the hello (for
example, the socket reconnected while the app was in the background) is
unsupported until the next connection. The server agrees: supports() is
false for it and a request fails with unsupported on the server, without
a round trip. The next connection offers it.
Manifest permissions
The renderer library declares no permissions. Its manifest only adds a
private FileProvider (authority ${applicationId}.hypen.device.files,
not exported, with its android.support.FILE_PROVIDER_PATHS meta-data),
which hands the system camera app a temporary file for camera.capture.
You don't need to declare it yourself. If your manifest overrides that
provider entry (tools:node="replace"), keep the meta-data, or every
capture fails with internal (capture-file-provider-misconfigured).
Add the permissions for the capabilities you use. If one is undeclared, the capability isn't offered at all:
| Capability | Declare |
|---|---|
gallery.pick, file.pick, file.save | Nothing. The system pickers need no permission. |
camera.capture | Nothing. The system capture app records under its own permissions. If you do declare CAMERA (or RECORD_AUDIO, for video), the user must grant it before capture, and the host asks through the OS flow. Offered when the device has a camera (FEATURE_CAMERA_ANY). |
mic.record | RECORD_AUDIO. Offered when the device has a microphone and the indicator is ready. |
bluetooth.select | BLUETOOTH_SCAN on API 31+ (ideally with neverForLocation), plus ACCESS_FINE_LOCATION with maxSdkVersion="30" for older devices |
bluetooth.scan | Same as bluetooth.select, and the indicator must be ready |
permission.* | Each runtime permission you'll ask about. Undeclared ones answer unavailable (not-declared:<name>). |
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- mic.record -->
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<!-- bluetooth.select / bluetooth.scan -->
<uses-permission
android:name="android.permission.BLUETOOTH_SCAN"
android:usesPermissionFlags="neverForLocation" />
<uses-permission
android:name="android.permission.ACCESS_FINE_LOCATION"
android:maxSdkVersion="30" />
<!-- Android's legacy install-time Bluetooth permissions for API ≤ 30 -->
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" />
<!-- permission.request("notifications") on API 33+ -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
</manifest>Without neverForLocation, a scan on API 31+ also needs
ACCESS_FINE_LOCATION (declared without maxSdkVersion) and Location
Services turned on. On API 30 and below, Location Services must always be on.
Otherwise the scan answers unavailable (location-services-off). A
Bluetooth adapter that is switched off answers unavailable (adapter-off)
at request time. It doesn't change what's advertised.
The Android permission names behind the portable ones: camera → CAMERA,
microphone → RECORD_AUDIO, location → ACCESS_FINE_LOCATION or
ACCESS_COARSE_LOCATION, notifications → POST_NOTIFICATIONS (API 33+;
earlier versions have no runtime permission), bluetooth → BLUETOOTH_SCAN
(API 31+) or location before that, photos → READ_MEDIA_IMAGES /
READ_MEDIA_VIDEO (API 33+; READ_EXTERNAL_STORAGE before that),
contacts → READ_CONTACTS.
Desktop
The native desktop renderer (hypen-renderer-desktop) is a device host
whenever it runs in remote mode: RemoteModule (and
DesktopApp::connect) attach the native host by default. Its hello
advertises what the desktop implements, it accepts the server's selection,
and it routes device messages and binary frames to the host, never to the
patch path.
use hypen_renderer_desktop::{DesktopApp, RemoteModule, RemoteOptions};
use std::sync::Arc;
let remote = RemoteModule::connect_with(
"wss://app.example.com/ws",
"App",
// Native clients send no Origin; the server's authenticator admits them.
RemoteOptions::default().header("Authorization", format!("Bearer {token}")),
);
DesktopApp::new().module(Arc::new(remote)).run();| Capability | Desktop driver |
|---|---|
gallery.pick | The OS open dialog, filtered to image / video files per mediaTypes; files stream from disk as credit allows |
file.pick | The OS open dialog (multi-select for maxCount > 1); accept becomes the file-type filter; each item carries its file name |
file.save | The OS save dialog is the consent and destination gate; bytes go to a temporary file beside the destination, renamed into place only after the size and SHA-256 verify |
camera.capture | A capture panel over the window with a live preview. Photo: Capture sends one image/jpeg. Video: Record, then Stop, sends H.264 in a fragmented video/mp4, streamed as it is encoded, without an audio track. Cancel is cancelled |
mic.record | A consent dialog, then a recording indicator (origin, elapsed time, Stop) in the window's top-right corner while audio/L16 PCM16 streams at the requested rate and channels. Stop or maxDurationMs ends it as a success |
bluetooth.scan | A consent dialog (remembered for 24 h on wss:// origins), then a scanning indicator with Stop while advertisements stream |
bluetooth.select | A chooser listing a live scan, filtered by namePrefix and services; returns the chosen device |
permission.query, permission.request | macOS reads the OS privacy status for camera, microphone and Bluetooth, and request shows the OS prompt after a consent dialog. Windows reads the Settings › Privacy camera and microphone switches. Linux has no per-app permissions. location, notifications and contacts are unsupported |
The dialogs come from rfd: the XDG desktop portal on Linux (no GTK build
dependency; the portal service must be running), NSOpenPanel /
NSSavePanel on macOS, and the common item dialogs on Windows. Their titles
name the server origin (wss://app.example.com wants to save "report.pdf").
The camera panel, consent dialogs and Bluetooth chooser are modal: they dim
the window, take all input, ignore clicks for 500 ms after appearing (so a
stray click can't approve them), and are reachable by keyboard and screen
readers. The app can't draw over or restyle them. Minimizing the window ends
a recording (as a success) or a scan (cancelled), because its indicator is no
longer visible; a window that is only covered by another one keeps recording. Bluetooth device ids are derived per install and per server
origin, never the raw hardware address.
With no display (no DISPLAY / WAYLAND_DISPLAY on Linux) the host offers
nothing. One dialog is open at a time: a second request meanwhile is
throttled. Dismissing a dialog is cancelled, and a dialog answer that
arrives after the server cancelled the request is thrown away.
RemoteOptions | Default | Meaning |
|---|---|---|
headers / .header(name, value) | none | Extra upgrade headers on every connection attempt, e.g. Authorization |
device / .device(Some(DeviceConfig)) | DeviceConfig::native() | The device host; .device(None) connects UI-only |
DeviceConfig::with_dialogs(...) replaces the dialogs and offers the file
capabilities only; .with_capture(ui, hardware) adds the camera, microphone
and Bluetooth drivers with your own UI and hardware (tests, kiosks,
automation), and .capabilities(&[...]) narrows what is offered.
Build requirements: the camera, mic and bluetooth cargo features are on
by default. On Linux they need pkg-config libasound2-dev libdbus-1-dev libclang-dev g++ to build, and ALSA, BlueZ (bluetoothd) and V4L2 drivers at
runtime. macOS needs no extra packages, but an app bundle must declare
NSCameraUsageDescription, NSMicrophoneUsageDescription and
NSBluetoothAlwaysUsageDescription; without them requests answer
unavailable (not-declared:…). Windows needs the MSVC C++ toolchain. The desktop
also stores the server's rotating resumeToken and presents it on reconnect.
In in-process mode (DesktopApp::module(...) with a local
ModuleInstance) there is no connection and no device plane: the module's
ctx.device() answers every call unavailable (device-disabled). To use
device capabilities from a desktop app, run the module behind a
server with a device plane (the Rust SDK's RemoteSession works in-process on
localhost) and connect to it.
Development: the fake host
@hypen-space/device-fake provides a scripted host for development and
tests. It never touches real hardware:
import { RemoteEngine } from "@hypen-space/core";
import { FakeDeviceHost } from "@hypen-space/device-fake";
const fake = new FakeDeviceHost()
.galleryReturns(jpegBytes)
.permissionsReturn({ camera: "granted", microphone: "denied" })
.micRecords([chunk1, chunk2], { chunkDelayMs: 10 })
.bluetoothSelects({ id: "sensor-1", name: "HR Strap" });
const remote = new RemoteEngine(url, { device: fake.endpoint() });The fake host makes itself obvious:
- It refuses to initialize when
NODE_ENV=production. - It marks every result
simulated: true. Handlers see this asres.simulatedin TypeScript, and as the equivalent flag in the other SDKs. - It shows a "Simulated device" banner whenever a DOM exists.
Built-in simulations cover gallery.pick, camera.capture, mic.record,
bluetooth.select and both permission capabilities. For file.pick,
file.save and bluetooth.scan, register a driver with
.driver(capability, driver).
Server Setup
The device plane is on by default in the TypeScript, Cloudflare, Go, Kotlin, Swift and Rust server SDKs. Configure connection admission, tune options, opt out, and make your first device call
Capability Reference
Parameters, results, consent, deadlines and platform support for gallery.pick, file.pick, file.save, camera.capture, mic.record, bluetooth.select, bluetooth.scan, permission.query and permission.request