Bridges
| Bridge | Routes | Events |
|---|---|---|
| host (built in) | GET /_bridge/host, POST /_bridge/host/apply-update |
|
| settings (built in) | GET/DELETE settings/{local|secure}, GET/PUT/DELETE settings/{scope}/{key} |
|
| files (built in) | GET files, GET files/{root}/list, GET files/{root}/info, GET/PUT files/{root}/content, POST files/{root}/append, POST files/{root}/directory, DELETE files/{root}/entry, POST files/{root}/move, POST files/{root}/copy, with paths in ?path= |
|
| AppSupport | GET app/info, POST/DELETE app/orientation, POST app/browser, POST app/map, POST app/settings, GET app/store, POST app/store/open, POST app/store/review, POST app/share, POST app/haptics, POST/DELETE app/vibrate, GET app/connectivity, GET app/battery, GET app/screen, PUT/DELETE app/screen/keep-awake, GET/PUT/DELETE app/clipboard, GET app/startup, POST/DELETE app/startup/registration, POST app/startup/settings |
app.orientation, app.culture, app.timezone, app.connectivity, app.battery, app.energysaver |
| GPS | GET gps/status, POST gps/access, GET gps/last, GET gps/current, GET/POST/DELETE gps/listener |
gps.reading |
| Geofences | GET geofences/status, POST geofences/access, GET/POST/DELETE geofences/regions, DELETE geofences/regions/{id}, GET geofences/regions/{id}/state |
geofence.status |
| Motion activity | GET motion/status, POST motion/access, GET motion/current, GET/POST/DELETE motion/listener |
motion.activity |
| Bluetooth LE | GET ble/status, POST ble/access, POST/DELETE ble/scan, GET ble/peripherals[/{uuid}], POST/DELETE …/connection, GET …/rssi, GET …/services, GET …/characteristics, GET/PUT …/characteristics/{c}, POST/DELETE …/notifications |
ble.scan, ble.status, ble.notification, ble.error |
| OBD-II | GET obd/status, GET obd/commands, POST obd/scan, GET obd/adapters, POST/DELETE obd/connection, POST obd/command, POST obd/raw, GET obd/vin, GET/DELETE obd/dtc, POST/DELETE obd/monitor |
obd.reading, obd.disconnected |
| Wi-Fi | GET wifi, POST wifi/access, GET wifi/networks, GET wifi/current, POST/DELETE wifi/connection, GET wifi/known, DELETE wifi/known?id=, GET/PUT wifi/radio, GET/POST/DELETE wifi/hotspot, GET wifi/hotspot/clients |
wifi.changed, wifi.hotspot |
| Discovery | POST discovery/{mdns,ssdp,wsd}/search, POST discovery/{mdns,ssdp,wsd}/browse, GET discovery/mdns/resolve, GET discovery/wsd/resolve, GET discovery/ssdp/description?udn=, POST discovery/{mdns,ssdp,wsd}/publications, GET discovery/browses, DELETE discovery/browses/{id}, GET discovery/publications, DELETE discovery/publications/{id} |
discovery.mdns, discovery.ssdp, discovery.wsd, discovery.error, discovery.stopped |
| Notifications | GET notifications, POST notifications/access, POST notifications/send, GET notifications/pending, DELETE notifications[?scope=], DELETE notifications/{id}, GET/PUT notifications/badge, GET/POST notifications/channels, DELETE notifications/channels/{id} |
notification.entry, notification.received |
| HTTP transfers | GET/POST/DELETE transfers, GET/DELETE transfers/{id}, POST transfers/{id}/pause, POST transfers/{id}/resume |
transfer.progress, transfer.completed, transfer.failed, transfer.cancelled |
| App links | GET/DELETE links/pending |
app.link |
| Health | GET health, POST health/access, GET/POST health/samples/{type}, POST/DELETE health/listeners/{type} |
health.reading, health.stopped |
| Speech | GET speech/status, POST speech/access, POST speech/recognize, GET/POST/DELETE speech/listener, POST/DELETE speech/speak, GET speech/voices?culture=, GET speech/cultures |
speech.partial, speech.result, speech.keyword, speech.ended, speech.spoken, speech.error |
| Contacts | GET contacts, POST contacts/access, GET/POST contacts/items, GET/PUT/DELETE contacts/items/{id}, GET contacts/items/{id}/photo |
|
| Calendar | GET calendar, POST calendar/access, GET calendar/calendars, GET/POST calendar/events, GET/PUT/DELETE calendar/events/{id} |
|
| Photos | GET photos, POST photos/access, POST photos/pick, GET photos/library, GET photos/library/{id}/thumbnail, POST photos/library/{id}/export |
|
| Folders | GET folders, POST folders/pick, DELETE folders/{root} |
|
| Tray icon | GET/POST/DELETE tray, GET/PUT/DELETE tray/{id}, PUT/DELETE tray/{id}/menu, POST tray/{id}/menu/show, POST tray/{id}/notification, PUT/DELETE tray/{id}/animation |
tray.click, tray.menu |
The routes are the wire protocol. A page doesn’t build them by hand: every bridge has a typed client, in C# for Blazor and in TypeScript for everything else, generated from one declaration so the two can’t drift — see Typed clients.
@inject IAppBridge App@inject IGpsBridge Gps
var info = await App.GetInfoAsync();await using var readings = await Gps.OnReadingAsync(reading => { position = reading; return Task.CompletedTask; });import { AppBridge, GpsBridge } from "@shinyorg/appdevicebridge";
const info = await new AppBridge().getInfo();const stop = new GpsBridge().onReading(reading => console.log(reading.latitude));Errors return { "code": "...", "message": "..." }. The clients throw BridgeException (C#) or BridgeError
(TypeScript) carrying the status and code, so pages can switch on either.
Wi-Fi: what works depends on the platform. iOS can’t scan, and Android can’t toggle the radio.
GET /_bridge/wifi lists the platform’s capabilities, and any call it lacks returns 501. Linux
uses NetworkManager through Shiny.Net.Wifi.Linux, which the bridge picks automatically.
wifi.changed only runs while a page is listening. Permissions come from Shiny.Net.Wifi: location on
Android; the Access Wi-Fi Information and Hotspot Configuration entitlements on iOS.
Discovery:
- Searching:
searchreturns everything seen duringscanMs, 5 s by default and 30 s at most. - Browsing: each
browsestreams results as events. At most 8 run at once, and they stop when the page’s last event stream closes. - Publishing:
publicationskeep advertising until deleted. At most 16. - Platform setup: on iOS and Mac Catalyst, list every browsed service type in
NSBonjourServicesand setNSLocalNetworkUsageDescription. On Android, SSDP and WS-Discovery needCHANGE_WIFI_MULTICAST_STATE.
Device: sharing, haptics, connectivity, battery, the screen and the clipboard come from .NET MAUI
Essentials, so each head’s own build decides what works. A feature a backend lacks returns 501 on its
own, and the rest keep working. Files are shared by the same { root, path } as the files bridge:
await new AppBridge().share({ files: [{ root: "data", path: "photos/cat.jpg" }] });app.connectivity, app.battery and app.energysaver only run while a page is listening. On Android,
vibration needs VIBRATE, and battery needs BATTERY_STATS in the manifest (without it, GET app/battery
returns 403). vibrate is capped at 5 seconds.
Motion activity: walking, running, cycling, driving or stationary, from the OS’s activity recognition.
There’s no history, only the latest reading and live ones. AddLocationBridges() doesn’t include it,
because it needs its own setup: NSMotionUsageDescription on iOS (the permission request crashes without it),
and ACTIVITY_RECOGNITION with Google Play Services on Android. Other platforms return 501.
OBD-II:
- Adapters: ELM327 and OBDLink, one at a time.
scanfinds Bluetooth LE adapters, or with"transport": "wifi"probes the addresses Wi-Fi adapters ship with.connectiontakes theperipheralUuidfrom a scan, or a Wi-Fihostandport. The host must be a loopback, private or link-local IP address. - Reading:
commandtakes a name fromGET obd/commands(engineRpm,vehicleSpeed,coolantTemperature…) and answers the decoded value with its unit.POST obd/rawsends a read-only request: modes 01, 02, 03, 05, 06, 07, 09, 0A or 22, or an informational AT command. Commands are serialized, because ELM327 is half-duplex. - Trouble codes:
GET obd/dtcreturns stored, pending and permanent codes. A list isnullwhen the vehicle doesn’t support that mode.DELETE obd/dtcneeds?confirm=true, because clearing codes also resets the emissions readiness monitors. - Monitoring:
monitorpolls up to 10 commands, every 250 ms at most often, and sends each result asobd.reading. It stops when the page’s last event stream closes. After three rounds with no answers the connection is dropped withobd.disconnected. - Platform setup: Bluetooth as for the Bluetooth LE bridge. Wi-Fi adapters need
NSLocalNetworkUsageDescriptionon iOS and Mac Catalyst. On Android, the app has to bind to the adapter’s network, which has no internet. Linux supports Wi-Fi adapters only.
HTTP transfers:
- Queuing:
POST /_bridge/transferswith atype(Download,UploadMultipartorUploadRaw), aurl, and a file asrootpluspath, the same names/_bridge/filesuses. Uploads can addmethod,headers,formDataNameand a small multipartbody. Downloads can pass"overwrite": false. - Downloads land whole: the file is written beside its destination under a hidden name and moved into place when it completes. The destination is checked again at that moment.
- Limits: http and https only. Loopback URLs are refused unless you change
AllowUrl. Connection, length and proxy headers are refused. At mostMaxTransfers(32) are queued at once. - Ownership: the page only lists and cancels its own transfers.
DELETE /_bridge/transfersleaves the native app’s transfers running. - Finishing in the background:
transfer.completedandtransfer.failedreach the page, or background.js when no page is open. Progress is an event only, at most everyProgressInterval(250 ms) per transfer. - Platform setup: Android needs
FOREGROUND_SERVICE_DATA_SYNC. On iOS and Mac Catalyst, overrideHandleEventsForBackgroundUrlin the app delegate and pass it toShiny.Hosting.Host.Lifecycle.OnHandleEventsForBackgroundUrl, or transfers that finish while the app is suspended wait for the next launch. macOS and Linux run transfers in-process only.
App links: links to the app become routes in the web app. A custom scheme’s host is the first path segment,
so myapp://orders/42 becomes /orders/42. An https link on a listed host keeps its path, so
https://app.example.com/orders/42 also becomes /orders/42. Set MapRoute to map links another way.
- Other links: any other scheme or host is left to the native app.
- Route checks: a route must be a local page path. Routes that aren’t, including anything under
/_bridgeor/_host, are refused, whateverMapRoutereturns. - Delivery: the page gets
app.linkwhile it’s open.DELETE /_bridge/links/pendingreturns the latest link and removes it. Call it at boot and on each event, so a link is acted on exactly once. - Cold start: with
NavigateOnColdStart, a link that launched the app becomes the first page loaded, and is consumed. - Background: links never go to
background.js.
const links = new LinksBridge();
async function openPendingLink() { const link = await links.consume(); if (link) router.push(link.route);}
openPendingLink();links.onLink(openPendingLink);Platform setup:
- Android: a
SingleTopmain activity withACTION_VIEWintent filters, plusassetlinks.jsonfor https. - Apple:
CFBundleURLTypes, plus the Associated Domains entitlement andapple-app-site-associationfor https. - maui-labs AppKit: the head overrides
OpenUrlsand callsAppLinks.Receive, because the launching link arrives beforeMauiProgram. - Windows: a protocol registration; redirect activations to the first instance for links while running.
- Linux:
x-scheme-handlerin the .desktop file; cold starts only.
Health:
- Platforms: HealthKit on iOS, Health Connect on Android. Other platforms return
501. On Android without Health Connect, every call exceptGET healthreturns503. - Access: ask for access before reading, one entry per type. On iOS, HealthKit never reveals a read
denial:
grantedcan betrueand reads still come back empty. On Android, reading a type that wasn’t granted returns403. - Types:
GET healthlists every type, with its unit and whether it’s bucketed. - Reads: numeric types and blood pressure are totalled or averaged into
minutes,hoursordaysbuckets. Cycle tracking, workouts and nutrition come back as individual records. A read covers at most 366 days and 2,000 buckets. - Writes:
POST health/samples/{type}takesstart,endand the fields for the type:value,systolic/diastolic,flow,workoutand so on. - Listeners: a listener sends
health.readingfor new samples. At most 8 run at once, and they stop when the page’s last event stream closes. - Privacy: health values are never logged.
- Platform setup: on iOS, add the HealthKit entitlement plus
NSHealthShareUsageDescriptionandNSHealthUpdateUsageDescription. On Android, set minSdk 26, add aandroid.permission.health.*permission for each record type, the Health Connect<queries>entry, and theVIEW_PERMISSION_USAGEactivity-alias. MainActivity also needs a filter forandroidx.health.ACTION_SHOW_PERMISSIONS_RATIONALE.
const health = new HealthBridge();await health.requestAccess({ permissions: [{ type: "StepCount", access: "Read" }] });
const today = new Date(); today.setHours(0, 0, 0, 0);const steps = await health.getSamples("StepCount", today, new Date(), { interval: "Hours" });Speech: built on Shiny.Speech, which is still a prerelease package.
- Recognizing once:
recognizelistens until a pause and returns{ "text": … }. It gives up aftertimeoutMs: 15 s by default, 60 s at most.textisnullif nothing was heard. - Dictation:
POST listenerkeeps the microphone open and streamsspeech.partialandspeech.resultevents until you delete it. It also stops when the page’s last event stream closes, so a page that goes away can’t leave the microphone on.speech.endedsays why it stopped:Stopped,PageClosedorError. - One microphone: a recognition and a listener can’t run at once. The second gets
409microphone_busy. - Speaking:
speakwaits until the text has been spoken, unless you pass"wait": false, which returns204straight away and raisesspeech.spokenwhen done. A new utterance interrupts the current one. Text is capped at 4,000 characters. - Platforms: Linux has no OS speech engine, so its endpoints return
501. To use a cloud provider or Whisper there, passo => o.RegisterSpeechServices = falseand register your ownISpeechToTextService/ITextToSpeechService. - Platform setup: Android needs
RECORD_AUDIO, plus a<queries>entry forandroid.intent.action.TTS_SERVICE. Apple platforms needNSSpeechRecognitionUsageDescriptionandNSMicrophoneUsageDescription, plus thecom.apple.security.device.audio-inputentitlement for sandboxed apps.
Contacts:
- Platforms: Android and iOS. Shiny.Contacts has no Mac Catalyst, macOS, Windows or Linux backend, so there
every endpoint returns
501. - Listing:
GET contacts/items?search=&offset=&limit=returns one page (50 by default, 500 at most) andhasMore.searchmatches names, phone numbers and emails. - Photos: photos never go in the JSON.
hasPhotosays whether one exists, andGET contacts/items/{id}/photo?size=full|thumbnailreturns the image bytes. - Writing:
PUTchanges only the properties you send. An empty list clears one. - iOS: reading
noteandrelationshipsneeds thecom.apple.developer.contacts.notesentitlement. Without it they come back empty.
Calendar:
- Platforms: Android, iOS, Mac Catalyst, macOS and Windows.
- Access:
POST calendar/accesstakesReadWrite(the default),ReadOnlyorWriteOnly. Write-only access (iOS 17+) is reported asRestricted. - Listing:
GET calendar/eventsrequiresstartandend, at most 366 days apart. Paging works like contacts. - Reminders:
reminderMinutescounts minutes before the start. - Read-only fields: attendees, the organizer and recurrence can be read but not written.
- Read-only calendars: writing to one returns
403 read_only, and so does writing to a system calendar on Windows, which only allows writes to app-owned calendars. - Deleting:
DELETE calendar/events/{id}?series=trueremoves the rest of a recurring series rather than one occurrence. - Mac Catalyst, sandboxed macOS: also need the
com.apple.security.personal-information.calendarsentitlement. Without it, access is denied without a prompt.
Both bridges return 403 access_denied until access has been granted.
Startup: part of the app bridge, because the same package is behind it. GET /_bridge/app/startup says
whether the app launches when the user logs in. Windows writes it under HKCU\…\CurrentVersion\Run
(unpackaged apps only — the OS virtualizes that key for MSIX), macOS 13+ submits the running bundle to
SMAppService, and Linux writes ~/.config/autostart/{Identifier}.desktop. Mobile has no such list, so it
answers { "supported": false, "state": "NotSupported" } and the rest return 501. state is read back from
the OS every time rather than remembered, because the user can turn a registered app off in Task Manager,
System Settings or Login Items without the app hearing about it — which is also why Enabled is not the only
success: DisabledByUser, DisabledByPolicy and RequiresApproval mean the user has to finish the job in the
OS, and POST app/startup/settings opens the screen where they do. Pass arguments your app can recognise on an
OS-started launch:
builder.AddAppSupportBridge(startup: o => o.Arguments.Add("--autostart"));Photos:
- Picker:
POST photos/pickshows the system photo picker — no permission needed — and copies what the user chose into a file root (cacheby default) underphotos/. The answer lists each as a{ root, path }the page reads through the files bridge. An empty list means the user cancelled. Every head has a picker. - Library: browse every photo on the device, newest first:
GET photos/library?offset=&limit=(200 at most),GET photos/library/{id}/thumbnail?size=for a JPEG that fits a square (32–1024 px), andPOST photos/library/{id}/exportto copy the original into a file root. It needs access —POST photos/access— and answers403without it. - Platforms: PhotoKit on iOS, Mac Catalyst and macOS; MediaStore on Android; the user’s Pictures folder on
Windows. Linux has no photo library, so the library endpoints return
501there. - Platform setup:
NSPhotoLibraryUsageDescriptionon Apple platforms, plus thecom.apple.security.personal-information.photos-libraryentitlement where the app is sandboxed.READ_MEDIA_IMAGESon Android 13 and later,READ_EXTERNAL_STORAGEbefore.
Folders:
- Picking:
POST folders/pickwith{ "root": "documents" }shows the platform’s folder picker. The folder becomes a file root under that name —/_bridge/files/documents/…— and answers204if the user cancels. Picking again under the same name replaces the folder. The app’s own roots can’t be replaced. - Remembered: picked folders come back as roots every time the app starts, until
DELETE folders/{root}forgets one.GET folderslists them, withavailable: falsefor one that was moved, deleted or had its access revoked since. - Platforms: Apple platforms keep a security-scoped bookmark; Android keeps a persisted Storage Access Framework grant; Windows and Linux (GTK’s file dialog) keep the path.
- Android folders aren’t paths. The files bridge reads and writes them through the Storage Access Framework, but bridges that hand the OS a file path — sharing, transfers, notification images — refuse them.
Tray icon: the system tray on Windows, the menu bar on macOS, the status notifier area on Linux —
desktop only, 501 elsewhere.
- Naming an icon: use
PUT /_bridge/tray/mainrather thanPOST /_bridge/tray. The first call creates the icon and later ones adopt it, so a page reload or an applied update doesn’t stack up a second icon. APUTchanges only the properties it sends;""clearstooltip,titleorbadge. - Images: either
{ "root": "data", "path": "icons/tray.png" }— the same file roots the files bridge takes, so the page can’t point the tray at anything it couldn’t already read — or{ "data": "…" }holding base64, with or without adata:URI prefix, which is how a page ships an icon it drew itself. Set"templateImage": truefor a black-with-alpha image macOS and Linux tint for light and dark menu bars. - Menus: each entry is
Item,Check,SeparatororSubmenu, and itsidis what comes back ontray.menu; omit it and one is assigned. Ids must be unique across the whole menu. - Clicks reach the web app either way:
tray.clickandtray.menugo out as events and as calls the page handles when it is open andbackground.jshandles when it is not — which is the case a tray menu exists for.tray.clickisn’t raised on Linux, where the app indicator handles clicks itself and opens the menu on its own (somenu/showis a no-op there). - Lifetime: icons outlive the page and are removed when the app shuts down, or by
DELETE /_bridge/tray/{id}.MaxIcons(4) caps how many exist at once.
await new TrayBridge().put("main", { tooltip: "Field App", templateImage: true, icon: { root: "data", path: "icons/tray.png" }, menu: { items: [ { id: "open", label: "Open" }, { type: "Separator" }, { id: "sync", type: "Check", label: "Sync", checked: true } ] }});

