HypenHypen
Device Capabilities

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:

  1. The hardware exists: a camera, a microphone, a Bluetooth LE adapter.
  2. The app declared what the OS requires: the Info.plist usage description on iOS, the <uses-permission> entry on Android.
  3. For mic.record and bluetooth.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-web

Pass 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.

OptionDefaultMeaning
originrequiredThe 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.
capabilitiesevery built-in driverLimit what the host offers
denialCooldownMs30000How long after a refusal the same capability can't prompt again
maxTimeoutMs300000The client's own ceiling on any request deadline
maxDownloadBytes64 MiBLargest file.save accepted
inputProtectionMs500How long Continue stays disabled after the dialog appears, or after the page regains focus
stopRecordingWhenHiddentrueEnd a recording normally when the page becomes hidden
captureBufferBytesmic 1 MiB, video 8 MiBBytes a live capture may hold while the server grants no credit, before it ends throttled
mountdocument.bodyWhere the host's UI mounts

What each capability needs in the browser:

CapabilityOffered when
gallery.pick, file.pick, file.save, permission.query, permission.requestAlways
camera.capturegetUserMedia exists
mic.recordgetUserMedia and an audio capture backend (AudioWorklet, or the ScriptProcessor fallback) exist
bluetooth.selectnavigator.bluetooth.requestDevice exists (Chromium-based browsers)
bluetooth.scanNever: 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:

CapabilityRequires
gallery.pick, file.pick, file.saveNothing. PHPicker and the document pickers run out of process.
camera.captureA camera and NSCameraUsageDescription. Video also needs NSMicrophoneUsageDescription (checked per request).
mic.recordAn audio input, NSMicrophoneUsageDescription, and a showable indicator
bluetooth.selectNSBluetoothAlwaysUsageDescription
bluetooth.scanNSBluetoothAlwaysUsageDescription and a showable indicator
permission.query / permission.requestAlways 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:

ArgumentDefaultMeaning
activityrequiredSupplies the Application and the initial foreground Activity. No strong reference to it is kept.
serverUrlrequiredSets the default origin. Each connection is bound to the origin of the URL its engine connects to.
capabilitiesall nineLimit what the host offers
activityIndicatorComposeDeviceActivityIndicator()The indicator for scans and recordings. Pass null to never offer mic.record or bluetooth.scan.
configureidentityAdjust 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:

CapabilityDeclare
gallery.pick, file.pick, file.saveNothing. The system pickers need no permission.
camera.captureNothing. 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.recordRECORD_AUDIO. Offered when the device has a microphone and the indicator is ready.
bluetooth.selectBLUETOOTH_SCAN on API 31+ (ideally with neverForLocation), plus ACCESS_FINE_LOCATION with maxSdkVersion="30" for older devices
bluetooth.scanSame 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();
CapabilityDesktop driver
gallery.pickThe OS open dialog, filtered to image / video files per mediaTypes; files stream from disk as credit allows
file.pickThe OS open dialog (multi-select for maxCount > 1); accept becomes the file-type filter; each item carries its file name
file.saveThe 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.captureA 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.recordA 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.scanA consent dialog (remembered for 24 h on wss:// origins), then a scanning indicator with Stop while advertisements stream
bluetooth.selectA chooser listing a live scan, filtered by namePrefix and services; returns the chosen device
permission.query, permission.requestmacOS 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.

RemoteOptionsDefaultMeaning
headers / .header(name, value)noneExtra 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 as res.simulated in 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).