Applicate & Packaging

APPLICATE turns a folder into an installable application for any platform. The folder is HTML, CSS, JavaScript and assets. OcaltQL runs at build time and its output is what ships. Inside the app, onDevice reaches the camera, the GPS, Bluetooth and the rest of the hardware.

What an applicated app is

A native shell around a web surface, with one library baked in. There is no OcaltQL interpreter inside the app and nothing calls home to find out what a script means. What ships is markup, styling, script and assets - plus onDevice, which is the only thing in the package that is not ordinary web code.

The shell uses the engine the device already has: WebKitGTK on Linux, WebView2 on Windows, the system WebView on Android, WKWebView on Apple. Nothing is bundled, so an app weighs what its own files weigh plus a few hundred kilobytes - a typical build is under a megabyte, and it builds in seconds rather than minutes.

That makes an applicated app exactly as predictable as a web page. If it works in a browser it works in the app, because it is the same HTML and the same JavaScript. What the app adds is hardware.

OcaltQL runs at build time

A file whose name ends in .oql is executed when the app is built. What it emits becomes the file, and the shipped name is the name with .oql removed.

In the source folderIn the package
index.html.oqlindex.html - whatever it emitted
app.js.oqlapp.js
prices.json.oqlprices.json
splash.png.oqlsplash.png - emitted bytes, written as bytes
styles.cssstyles.css - copied, not executed

Name the output whatever you like; .oql is only the marker that says run this first. A file without it is copied through untouched.

index.html.oql - a catalogue baked in at build time
SELECT ROWS FROM DB "shop" TABLE "products" WHERE "active" IS TRUE SET ?items
AFTER EMIT "<!DOCTYPE html><html><head><link rel='stylesheet' href='app.css'></head><body>"
AFTER FOREACH ?items SET ?p
OPEN
  EMIT "<div class='card'><h3>" & ?p("name") & "</h3><p>R" & ?p("price") & "</p></div>"
CLOSE
AFTER EMIT "<script src='app.js'></script></body></html>"

A build-time script is an ordinary execution. Your databases, your files, your namespace, !GET, !POST, sessions, FETCH, MAIL - everything that works through the API works here, because it is the API. What it cannot reach is the device, for the obvious reason that the device is not present at build time. That is what onDevice is for.

Baked, not live. The catalogue above is fixed at the moment the app was built. An app that needs current data asks for it at run time the way any client does - fetch against https://ql.ocalt.com/api with your identity and API key. See API Reference. Build time is for what does not change between releases.

How long a generator may run

A generator is an ordinary execution, so execution_time governs it, exactly as it governs any other script in your namespace. There is no separate build clock to keep in step with it.

That matters for a folder that generates something expensive - a catalogue built from a large table, a report assembled from several fetches. If a generator is stopped part way, raise execution_time the way you would for any other script. See Control & Config.

One limit, not two. The packager reads whatever execution_time is in force for your namespace and waits at least that long for each generator. A generator is never cut off by the build; it is only ever stopped by the limit you set.

Syntax

Minimal - Ocalt default signer
APPLICATE TYPE "android/apk" FROM "/root/appfolder/" TO "/root/new.apk" SET ?file
Full - custom signing and identity
APPLICATE TYPE "android/apk" FROM "/root/appfolder/" TO "/root/new.apk"
SIGN WITH "/root/keys/my.keystore"
IDENTIFIER "com.ocalt.myapp"
TITLE "MyApp"
SET ?file

Cost

An APPLICATE call costs 64 queries, whatever the target. Each .oql generator in the folder costs one query on top of that, because each one is a real execution. A folder with thirty generated screens costs 64 + 30.

Output Targets

Type stringOutput
android/apkAndroid APK - direct install
android/aabAndroid App Bundle - Play Store submission
windows/exeWindows portable - a zip holding the app and its shell, extract and run
windows/installerWindows installer - NSIS, per-user, no administrator rights needed
windows/msixWindows package: MSIX, signed. Windows installs, updates and removes it, with a Start menu entry and a clean uninstall. For a download people run with no setup, prefer windows/installer; MSIX suits managed fleets and Store submission
macos/appmacOS application bundle - an Xcode project, built on a Mac
ios/appiOS application package - an Xcode project, built on a Mac
linux/appLinux binary
linux/appimageLinux AppImage - portable
linux/debDebian package
linux/rpmRPM package
web/pwaProgressive Web App - manifest and service worker, in place
Platform notes. windows/installer is the no-friction Windows route: a double-click install, per-user, with no certificate step. windows/msix is signed with Ocalt's certificate; a machine installs it once that certificate is trusted, which a managed fleet does centrally, and a package submitted to the Microsoft Store is signed by Microsoft itself. macos/app and ios/app return an Xcode project rather than a finished app, because Apple's toolchain runs only on Apple hardware; the last build and signing step happens on a Mac, and README.txt in the zip walks through it.

web/pwa takes AT rather than FROM and TO: a PWA has no artifact, so it is written into the folder that already serves it.

The Apple targets, macos/app and ios/app, cannot be built on Ocalt's servers. Apple's toolchain runs only on Apple hardware and its licence forbids it anywhere else. That is a licensing wall rather than a missing tool, and no amount of engineering moves it.

What a build costs

TargetTypical sizeTypical timeWhat the device supplies
linux/deb, linux/rpmunder 50 KBabout a secondWebKitGTK, GTK3
linux/appimageabout 220 KBabout two secondsWebKitGTK, GTK3
android/apkunder a megabyteunder a minuteThe system WebView
windows/exeabout 450 KBabout a secondWebView2 and the .NET Desktop Runtime
windows/installerabout 480 KBa few secondsWebView2 and the .NET Desktop Runtime

Sizes are for the shell and its dependencies. Your own assets are added on top, so an app with a hundred megabytes of images is a hundred megabytes plus this.

The Apple Targets

macos/app and ios/app do not hand back an application. They hand back a complete Xcode project, as a zip, ready to open and build.

The reason is not a missing feature. Apple’s toolchain runs only on Apple hardware and its licence forbids running it anywhere else, so no build server that is not a Mac can produce a signed Apple binary. That is a licensing wall, and no amount of engineering moves it.

Everything that does not require a Mac is done for you:

Done hereWhat it means
The Swift shellWKWebView, the onDevice bridge, splash handling, the same behaviour every other platform has
Your generators, runEvery .oql executed and its output baked in, exactly as for any other target
The JavaScript, rewrittenEvery onDevice.enter call awaited, same as everywhere else
Info.plistCarrying exactly the usage descriptions your code’s commands require, and nothing more
The icon setEvery size Apple asks for, generated from your icon
The project fileA real .xcodeproj, plus a project.yml to regenerate it and a build.sh that runs xcodebuild

What is left is the part that needs the hardware: open it, set your signing team, press Run. README.txt inside the zip says how, three ways.

An iOS project from the same folder every other target uses
APPLICATE TYPE "ios/app" FROM "/root/myapp/" TO "/root/myapp-ios.zip"
TITLE "My App"
IDENTIFIER "com.myco.myapp"
SET ?proj
AFTER EMIT ?proj("note")

What the zip holds

MyApp/
  MyApp.xcodeproj/          the project, open this
  project.yml               to regenerate it with xcodegen, if ever needed
  build.sh                  xcodebuild archive, from a terminal
  README.txt                three ways to build it, and what you will need
  MyApp/
    OcaltShell.swift        the shell and the onDevice bridge
    Info.plist              your permissions, derived from your code
    Assets.xcassets/        the icon set
    app/                    your HTML, CSS, JavaScript and assets

What you will need on the Mac side

To do thisYou need
Run it on your own deviceXcode and a free Apple ID. The app is signed for seven days at a time.
TestFlight or the App StoreAn Apple Developer Program membership, currently $99 a year.
Distribute a Mac app outside the storeA Developer ID certificate, and notarisation, both of which come with the same membership.

A hosted Mac runner works as well as a Mac on your desk. The project builds the same way on either.

Modifiers

ModifierSyntaxDescription
SIGN WITHSIGN WITH "/root/keys/my.keystore"Signing key, keystore, certificate or provisioning profile. Omit for the Ocalt-managed signer.
IDENTIFIERIDENTIFIER "com.ocalt.myapp"Bundle identifier / package name
TITLETITLE "MyApp"Display name

If SIGN WITH is omitted, Ocalt generates and manages a signing key per namespace. The app is signed and installable; the identity is Ocalt's rather than yours. Supply a key at any time to override it.

What APPLICATE Returns

SET ?r captures an object describing the build. It is the same shape for every target, so one piece of code can report on all of them.

KeyWhat it holds
oktrue when the build produced what it promised. false and the rest of the object says why.
kindThe target, echoed back.
pathWhere the result landed in your namespace.
sizeBytes. Zero for the targets that produce a folder rather than a file.
versionThe version string it was built with.
secondsHow long it took.
generatedEvery .oql that ran, what it became, and how many bytes it emitted.
permissionsThe capabilities your code asks for, derived from it.
errorPresent only on failure. A sentence, not a stack trace.
notePresent when a target needs something said about it - see below.
A successful Android build
APPLICATE TYPE "android/apk" FROM "/root/myapp/" TO "/root/myapp.apk" SET ?r
AFTER EMIT ?r

(* {"ok":true,"kind":"android/apk","path":"/root/myapp.apk","size":647678,
    "version":"1.0.0","seconds":9.9,
    "generated":[{"from":"index.html.oql","to":"index.html","bytes":133}],
    "permissions":["camera","nfc"],
    "permissions_requested":["android.permission.CAMERA",
                             "android.permission.NFC"]} *)

What the Apple targets return

macos/app and ios/app return the same object, with two differences: path points at a zip holding an Xcode project rather than an installable application, and two extra keys explain that.

KeyWhat it holds
projectThe name of the .xcodeproj inside the zip.
noteWhy it is a project rather than an app, and where to read the rest.
An iOS build
APPLICATE TYPE "ios/app" FROM "/root/myapp/" TO "/root/myapp-ios.zip" SET ?r
AFTER EMIT ?r("project")
AFTER EMIT ?r("note")

(* {"ok":true,"kind":"ios/app","path":"/root/myapp-ios.zip","size":60169,
    "version":"1.0.0","seconds":1.4,"project":"MyApp.xcodeproj",
    "permissions":["camera","nfc"],
    "note":"An Xcode project, not an artifact. Apple's toolchain runs only on
            Apple hardware, so the last step happens on a Mac. README.txt in
            the zip says how."} *)

ok is true. The build did everything it could, and what it produced is a project ready to open. Check project if your script needs to know it got one; check note if it wants to tell somebody why.

Applicating an ordinary website

web/pwa does not need a folder written for Ocalt. Point it at a site you already have - HTML, CSS, JavaScript, whatever it is - and it becomes installable.

What it addsWhy
manifest.jsonName, colours, orientation and icons. A browser reads it to decide what an installed copy is called and looks like.
sw.jsA service worker with a fetch handler, which is what makes a site work offline and what a browser requires before it will offer to install.
icon-192.png, icon-512.pngGenerated from your icon, or a plain mark if the folder has none. An icon the manifest names and the folder lacks is the most common reason an install prompt never appears.
Three lines in each pageA <link rel="manifest">, a theme colour, and a service worker registration. Writing the files is not enough: a manifest nothing links to is never read, and a worker nothing registers never runs.

Your own files are not rewritten beyond those three lines in the <head>, and a page that already has any of them keeps what it has.

The onDevice library is only shipped to a folder that actually calls it. An ordinary website gets a manifest, a worker and its icons, and nothing else.

What web/pwa returns

A PWA has no artifact, so size is zero and path is the folder that serves it. Two extra keys:

KeyWhat it holds
wroteThe files it added to the folder: the manifest and the service worker.
generators_in_placeAny .oql still in the served folder. AT builds in place, so your generators stay where you put them - and Site Mode will run them on request as well as at build time.
Reacting to a failure
APPLICATE TYPE "windows/installer" FROM "/root/myapp/" TO "/root/setup.exe" SET ?r
AFTER IF ?r("ok") IS IDENTICAL TO false
OPEN
  EMIT "Build failed: " & ?r("error")
CLOSE
OR
OPEN
  EMIT "Built " & ?r("path") & " in " & ?r("seconds") & "s"
CLOSE

Manifest File

Put ocalt.manifest.json in the folder root. It is a literal configuration file the packager reads, not OcaltQL.

ocalt.manifest.json
{
  "version_name": "1.0.0",
  "version_code": 1,
  "icon": "appicon.png",
  "icons": {
    "android": "appicon-android.png",
    "ios": "appicon-ios.png",
    "windows": "appicon.ico",
    "macos": "appicon.icns"
  },
  "splash_screen": "splash.png",
  "min_os": "12.0",
  "orientation": "portrait",
  "description": "My app built with OcaltQL",
  "author": "Ocalt (Pty) Ltd"
}

icon is the fallback for any platform with no entry in icons, and defaults to appicon.png in the folder root. version_code is the integer build number stores use to order updates. IDENTIFIER and TITLE on the APPLICATE statement override manifest values.

There is no permissions key. Permissions are derived, not declared - see below.

Splash Screens

Between tapping the icon and the app's first frame there is a gap. Every platform fills it with a static image, because at that moment no WebView exists yet and nothing written in HTML could run. That image is unavoidable and it is brief.

What happens next is yours. Put a splash.html in the folder root and it is shown the instant the WebView is alive, ahead of your first page. It is an ordinary page: styling, animation, a progress bar, a logo that moves, whatever you write. It can be generated, so splash.html.oql works the same way any generator does.

StageWhat is shownHow long
Cold startThe static image from splash_screen in the manifestUntil the WebView exists, typically under a second
Handoversplash.html, if the folder has oneUntil the app dismisses it
Runningindex.htmlThe rest of the session

Without splash.html the static image simply holds until the first page renders, which is what an app with no splash of its own should do.

Dismissing it

The app decides when it is ready, because only the app knows. A splash that guesses with a timer either flashes past before anything has loaded or sits there after everything has.

index.html — dismiss when the work is done
var session = await fetch('https://ql.ocalt.com/api', { ... });
render(session);
onDevice.enter('SPLASH DISMISS');

A splash that is never dismissed is dismissed for you after thirty seconds, so a mistake in one line of startup code cannot leave an app that never opens.

Reporting progress

SPLASH PROGRESS sends a number from 0 to 100 and an optional line of text to the splash page, which receives it as an ordinary event. The splash is a page, so what it does with that is up to it.

index.html
onDevice.enter('SPLASH PROGRESS 20 WITH "Signing in"');
var me = await signIn();
onDevice.enter('SPLASH PROGRESS 60 WITH "Loading your data"');
var rows = await load();
onDevice.enter('SPLASH PROGRESS 100');
render(rows);
onDevice.enter('SPLASH DISMISS');
splash.html
<div id="bar"></div>
<p id="what"></p>
<script>
onDevice.addEventListener('splash:progress', function (p) {
  document.getElementById('bar').style.width = p.percent + '%';
  if (p.text) { document.getElementById('what').textContent = p.text; }
});
</script>

Manifest keys

KeyMeaning
splash_screenThe static image for the cold-start gap. A PNG in the folder.
splash_backgroundColour behind that image while it is shown. Defaults to white.
splash_htmlThe page to hand over to. Defaults to splash.html when one is present.
splash_timeoutSeconds before the splash is dismissed regardless. Defaults to 30.

On web/pwa there is no cold-start gap to fill and no second window: the splash page, if there is one, is shown as an overlay on first load and removed on SPLASH DISMISS, so the same code behaves the same way.

CommandShapeBehaviour
SPLASH PROGRESS <n> [WITH "<text>"]FireSends a percentage and an optional line to the splash page.
SPLASH DISMISSFireRemoves the splash and shows the app.

onDevice

A packaged app sits on a device with a camera, a filesystem, a GPS receiver and a dialler. onDevice is how JavaScript reaches them, natively, rather than through the narrow set of web APIs a WebView exposes.

One entry point:

The whole API surface
var value = onDevice.enter('CAMERA REAR CAPTURE MAXWIDTH 1600');

onDevice.addEventListener('position', function (fix) { ... });
onDevice.removeEventListener('position', handler);

onDevice.available   // true in a packaged app
onDevice.platform    // "android" | "ios" | "windows" | "macos" | "linux" | "web"

The string is a command. Its grammar is a verb followed by modifiers, the same shape the rest of OcaltQL reads in - but it is not OcaltQL and there is no interpreter behind it. It is a small fixed command language for the hardware, and every command in it is listed on this page.

It reads synchronously and does not block

Write it plainly:

No callbacks, no promises, no await
var photo = onDevice.enter('CAMERA CAPTURE');
if (photo) {
  document.querySelector('#preview').src = photo.dataurl;
}

APPLICATE rewrites that at build time. Every onDevice.enter call becomes awaited, every function that contains one becomes async, and every caller of those functions is awaited in turn, all the way out to the event handler or the top level. What ships is asynchronous; what you wrote is not.

So the page keeps rendering while the camera is open. A spinner animates, a video keeps playing, a CSS transition finishes. Nothing is frozen, because nothing is actually waiting on the JavaScript thread.

One shape the build will refuse. A call inside a callback handed to a synchronous higher-order function - items.map(function (x) { return onDevice.enter(...); }) - cannot be rewritten without map collecting promises instead of values. The build stops and names the file and line. Use for (var x of items) instead.

Three shapes

ShapeWhat it doesReturns
FireDispatches the call and carries on. Nothing to wait for.Nothing
Halt and fireWaits for the device to answer, then continues on the next line.A value, or null
StreamAttaches a live native surface or a repeating source.Nothing - a surface is not a value

Fire

Things that do not answer back
onDevice.enter('NOTIFY "Your order has shipped" WITH "Ocalt"');
onDevice.enter('NOTIFY "Your order has shipped" WITH "Ocalt" LINK "/orders/42"');
onDevice.enter('VIBRATE 200');
onDevice.enter('BROWSER OPEN "https://ocalt.com/orders/42"');
onDevice.enter('CLIPBOARD WRITE ' + JSON.stringify(trackingNumber));
onDevice.enter('CALL "+27821234567"');
onDevice.enter('MESSAGE COMPOSE "Running late, be there at 3" TO "+27821234567"');

In NOTIFY the subject is the body - the sentence the person reads - and WITH is the title above it. The body comes first because it changes every time; the title is written once. Omit WITH and the platform uses the app's own name.

NOTIFY is not push. Push is a server reaching a sleeping device; this is the app raising a notification on itself, locally, with no network. Both exist and neither replaces the other.

There is no silent SMS. Sending without the person seeing it needs a permission the Play Store grants only to messaging apps. COMPOSE fills the message in and the person presses send.

CALL opens the dialler with the number in it. CALL NOW dials immediately and costs you call_phone on Android, an equivalent entitlement on iOS, and a harder review. Use NOW when the app genuinely is the thing placing the call - a panic button, a dispatch tool - and the plain form everywhere else.

Halt and fire

The camera
var photo = onDevice.enter('CAMERA CAPTURE');
// photo.data     the image bytes
// photo.dataurl  the same bytes as a data: URL, ready for an <img>
// photo.mime     "image/jpeg"
// photo.width    4032
// photo.height   3024
// photo.size     bytes
// photo.lens     "front" or "rear"

var selfie   = onDevice.enter('CAMERA FRONT CAPTURE');
var document_ = onDevice.enter('CAMERA REAR CAPTURE MAXWIDTH 1600 QUALITY 80');
var clip     = onDevice.enter('CAMERA VIDEO CAPTURE MAXSECONDS 30');

A full-resolution photograph is several megabytes. MAXWIDTH resizes on the device before anything moves, which is almost always what you want.

Location
var fix = onDevice.enter('GPS');
// fix.lat       -26.2041   degrees, negative is south
// fix.lng        28.0473   degrees, negative is west
// fix.accuracy   8.5       metres, radius of confidence
// fix.altitude   1753.2    metres above sea level
// fix.speed      0         metres per second
// fix.heading    142.7     degrees from true north
// fix.time       unix timestamp
// fix.provider   "gps", "network" or "fused"

var good = onDevice.enter('GPS ACCURACY 20 TIMEOUT 30');
if (!good) { show('No usable fix — try outdoors.'); }

accuracy is the number that decides whether a fix is usable. Indoors it is often 50 or worse; outdoors under open sky it settles near 5. A fix is never exact, and checking it beats plotting the point and hoping.

The device filesystem
var drives = onDevice.enter('DRIVE LIST');
for (var d of drives) {
  line(d.name + ' — ' + d.free + ' bytes free');
}

var phone = drives[0];
var files = onDevice.enter('DRIVE ?d FILE LIST ?path',
                           { d: phone.id, path: '/DCIM/Camera' });

for (var f of files) {
  if (f.is_dir) { continue; }
  var photo = onDevice.enter('DRIVE ?d FILE READ ?path',
                             { d: phone.id, path: '/DCIM/Camera/' + f.name });
  upload(f.name, photo.data);
}

DRIVE is the storage; FILE is what you do to it. The file words are the same ones namespace storage uses, because they are the same operations on a different disk.

Bind, do not concatenate. Every operand above is a ?name filled from the second argument. A filename can contain a quote, a bracket or a newline - a phone will happily let one - and building the command by joining strings breaks the moment it does. Binding is not a style preference; it is the only form that survives real filenames.

Walking a folder tree

FILE LIST returns one level, the way ls does. A folder tree is walked by the script, which is a handful of lines and leaves you in control of how deep to go:

Every photo under /DCIM, however deep
function walk(driveId, path, found) {
  var entries = onDevice.enter('DRIVE ?d FILE LIST ?p',
                               { d: driveId, p: path });
  for (var e of entries) {
    var full = path + '/' + e.name;
    if (e.is_dir) { walk(driveId, full, found); }
    else if (/\.(jpe?g|png|heic)$/i.test(e.name)) { found.push(full); }
  }
  return found;
}

var photos = walk(phone.id, '/DCIM', []);
line(photos.length + ' photos');

The rewrite reaches walk through its own recursion, so nothing extra is needed to make that work.

Letting the person choose a file

DRIVE PICK opens the platform's own file chooser and returns the one file the person picked, bytes included.

No path, no permission
var f = onDevice.enter('DRIVE PICK');
if (f) {
  line(f.name + ' — ' + f.size + ' bytes, ' + f.mime);
  upload(f.name, f.data);
}

// Narrowed to one kind
var doc = onDevice.enter('DRIVE PICK TYPE "pdf" TITLE "Choose a statement"');
On a phone, the picker is the permission. A file the person chose needs no storage permission at all, and DRIVE PICK compiles none in. On iOS it is more than a convenience: an app cannot browse the filesystem outside its own sandbox, so DRIVE FILE LIST returns only what the app itself has written. If your app needs a file the person already has, on iOS the picker is the way. On Windows, macOS and Linux it is an ordinary file dialog.

What each object carries

DRIVE LIST returns an array. Each drive:

KeyTypeWhat it is
idstringThe handle every other DRIVE command takes. Stable for this drive on this device.
namestringWhat to show a person: Home, Pictures, Downloads, an SD card's label.
pathstringWhere it sits on the device. Shown, not used - commands take id.
freenumberBytes available. Zero where the platform does not report it.

DRIVE ?d FILE LIST ?path returns an array. Each entry:

KeyTypeWhat it is
namestringThe entry's own name, not a path. Join it to the folder you listed.
is_dirboolTrue for a folder. Check it before reading, and recurse on it to walk a tree.
sizenumberBytes. Zero for a folder.
modifiednumberUnix timestamp of the last write.

DRIVE ?d FILE READ ?path and DRIVE PICK return one object:

KeyTypeWhat it is
databytesThe file's contents, as bytes. Pass it straight to a FILE WRITE, an upload, or an <img>.
sizenumberHow many bytes that is.
mimestringWhat the platform thinks it is.
namestringPICK only. The file's name, which you did not supply.
pathstringPICK only. Where it came from, where the platform reveals that.

DRIVE ?d FILE WRITE ?path CONTENT ?bytes returns true, or null if the write was refused.

The second argument to enter binds values into the command. ?name in the string is replaced by the matching key, as bytes or as a string, with no quoting or escaping to get wrong. Use it for anything that is not a literal you typed yourself.

Contacts, calendar, screen, battery, network, device
var person = onDevice.enter('CONTACTS PICK');            // .name .phone .email
var events = onDevice.enter('CALENDAR LIST FROM "2026-09-01" TO "2026-09-30"');
var shot   = onDevice.enter('SCREENSHOT');               // .data .dataurl .mime
var b      = onDevice.enter('BATTERY');                  // .percent .charging .health .saver
var n      = onDevice.enter('NETWORK');                  // .type .metered .carrier
var d      = onDevice.enter('DEVICE');                   // .model .os .version .locale .timezone .screen
var clip   = onDevice.enter('CLIPBOARD READ');
Bluetooth - BLE, not classic pairing
var found = onDevice.enter('BLUETOOTH SCAN SERVICE "180D" SECONDS 8');
// each: .id .name .rssi .services .paired

if (found.length) {
  var conn = onDevice.enter('BLUETOOTH CONNECT "' + found[0].id + '"');
  if (conn) {
    var svcs = onDevice.enter('BLUETOOTH "' + conn + '" SERVICES');
    var v    = onDevice.enter('BLUETOOTH "' + conn + '" READ SERVICE "180D" CHARACTERISTIC "2A37"');
    // v.value the raw bytes, v.hex the same bytes as hex
    onDevice.enter('BLUETOOTH "' + conn + '" WRITE SERVICE "1523" CHARACTERISTIC "1525" VALUE "01"');
    onDevice.enter('BLUETOOTH "' + conn + '" CLOSE');
  }
}

Bluetooth scanning needs location permission on Android - not because it reads your position, but because a list of nearby radios can be used to work it out. Android treats them as one question, so BLUETOOTH compiles in both.

Wi-Fi, sensors, NFC, biometrics, microphone
var net  = onDevice.enter('WIFI');       // .connected .ssid .bssid .rssi .frequency .ip .security
var nets = onDevice.enter('WIFI SCAN');
onDevice.enter('WIFI JOIN "Depot-Guest" WITH "hunter2"');   // raises the system picker

var have = onDevice.enter('SENSORS LIST');
var a    = onDevice.enter('SENSOR "accelerometer"');   // .x .y .z  m/s²
var o    = onDevice.enter('ORIENTATION');              // .pitch .roll .yaw .compass

var tag  = onDevice.enter('NFC READ TIMEOUT 20');      // .id .type .text .data
onDevice.enter('NFC WRITE "https://ocalt.com/asset/4471"');

var who  = onDevice.enter('BIOMETRIC PROMPT "Confirm this payment"');
// who.verified true/false, who.method "fingerprint"|"face"|"pin",
// who.reason "cancelled"|"no_hardware"|"not_enrolled"|"locked_out"

var audio = onDevice.enter('MICROPHONE RECORD MAXSECONDS 60');  // .data .duration .mime

WIFI reads and suggests; it never switches a network behind the person's back. Both platforms removed that years ago.

Sensor names: accelerometer, gyroscope, magnetometer, barometer, light, proximity, temperature, humidity, steps. A barometer, thermometer and humidity sensor are absent from most phones, and SENSOR on a sensor the handset lacks returns null - the same as a refusal. SENSORS LIST is how you tell those two apart.

Biometrics never hand you a fingerprint or a face. The platform answers one question, is this the enrolled person, and that answer is all that crosses into your code. There is nothing to store and nothing to leak.

Stream

A viewfinder is not a value. Streams attach a native surface, or a repeating source, and deliver through the ordinary event mechanism - onDevice is an EventTarget, the same as anything else you listen to in a browser.

An inline viewfinder
onDevice.enter('CAMERA VIEW INLINE INTO "#viewfinder"');
// The native preview is drawn into that element. It is not a <video> tag
// and not a WebView stream — the surface belongs to the platform and sits
// above the page, positioned and resized to follow that element.

var photo = onDevice.enter('CAMERA CAPTURE');   // captures from the running preview
onDevice.enter('CAMERA VIEW STOP');
A repeating source
onDevice.addEventListener('position', function (fix) {
  marker.move(fix.lat, fix.lng);
});
onDevice.enter('GPS WATCH EVERY 5 SECONDS INTO "position"');
onDevice.enter('GPS WATCH STOP');

onDevice.addEventListener('motion', function (a) { ... });
onDevice.enter('SENSOR "accelerometer" WATCH EVERY 100 MILLISECONDS INTO "motion"');
onDevice.enter('SENSOR "accelerometer" WATCH STOP');

onDevice.addEventListener('heartbeat', function (r) { ... });
onDevice.enter('BLUETOOTH "' + conn + '" NOTIFY SERVICE "180D" CHARACTERISTIC "2A37" INTO "heartbeat"');
onDevice.enter('BLUETOOTH "' + conn + '" NOTIFY STOP CHARACTERISTIC "2A37"');

onDevice.enter('SCREEN RECORD START INTO "screencap"');
var file = onDevice.enter('SCREEN RECORD STOP');

A sensor at 50Hz is fifty values a second. An event is the only sane delivery - returning them one call at a time would miss most of them.

The system tray

A desktop build spends most of its life in the background, and the tray is where it stays reachable.

CommandShapeBehaviour
TRAY ICON "<path>"FireCreates the icon on first call, changes it after.
TRAY TITLE "<text>"FireSets the tooltip.
TRAY NOTIFY "<title>" "<message>"FireNotification anchored to the icon.
TRAY REMOVEFireRemoves the icon.
TRAY MENU ?menuHaltShows a context menu, returns the item picked.
A tray menu
onDevice.enter('TRAY ICON "tray/idle.png"');
onDevice.enter('TRAY TITLE "MyApp — idle"');

var menu = [
  { label: 'Open' },
  { separator: true },
  { label: 'Launch at startup', checked: false },
  { label: 'Recent', icon: 'tray/recent.png', submenu: recentItems },
  { label: 'Quit' }
];
var choice = onDevice.enter('TRAY MENU ?m', { m: menu });
if (choice.label === 'Quit') { ... }

Item keys: label (required except on a separator), icon, checked (its presence makes the item a toggle), disabled, separator (every other key ignored), submenu (nested one level). The return is the picked item's own object, so choice.checked carries a toggle's new state and the app can persist it. Separators and submenu parents are not selectable.

Windows has a standard tray and all five map onto it. macOS has no tray as such; the equivalent is a persistent menu-bar status item, reached by the same commands so no platform-specific code is needed. Linux desktops vary, and where no tray host is available the behaviour is still being defined.

Permission is the person's, not yours

Permissions are derived from your code. At packaging time APPLICATE scans every script for onDevice.enter calls, reads which command each one names, and adds exactly the permissions those commands need. A command you never write ships nothing: no permission requested, no native code generated. This is not an optimisation - an app that asks for location it never uses is rejected by every store, and rightly.

Android and iOS both prompt at first use rather than at install, so the first CAMERA CAPTURE in an app's life raises a system dialog and the person may say no.

Handling a refusal
var photo = onDevice.enter('CAMERA CAPTURE');
if (photo === null) {
  show('No photo — camera access was declined.');
} else {
  upload(photo.data);
}
Asking first
var ok = onDevice.enter('CAMERA PERMISSION');
if (ok === false) {
  show('This needs the camera. Enable it in Settings.');
} else {
  var photo = onDevice.enter('CAMERA CAPTURE');
}
// Every halting command has a PERMISSION form: 'GPS PERMISSION',
// 'CONTACTS PERMISSION', and so on.

A refusal returns null. So does a timeout: a capture takes as long as someone takes to frame a shot, and they may background the app and never come back, so halting commands time out and return null rather than hanging. An unknown command throws - that is a mistake in your code, not an answer from a device, and the two must never look alike.

The full permission table

CommandCompiled inShape
NOTIFYnotificationsFire
VIBRATEvibrateFire
BROWSER OPENnoneFire
CLIPBOARD WRITEnoneFire
CALLnoneFire
CALL NOWcall_phoneFire
MESSAGE COMPOSEnoneFire
TRAY ICON / TITLE / NOTIFY / REMOVEnoneFire
CAMERA CAPTURE / VIDEO CAPTUREcameraHalt
MICROPHONE RECORDmicrophoneHalt
GPSlocationHalt
BLUETOOTHbluetooth + locationHalt
WIFIwifi + locationHalt
NFCnfcHalt
SENSOR / SENSORS LIST / ORIENTATIONsensorsHalt
BIOMETRIC PROMPTbiometricHalt
DRIVE ...storageHalt
DRIVE PICKnoneHalt
CONTACTS PICKcontactsHalt
CALENDAR LISTcalendarHalt
SCREENSHOTscreen_captureHalt
CLIPBOARD READnoneHalt
BATTERY / NETWORK / DEVICEnoneHalt
TRAY MENUnoneHalt
CAMERA VIEW INLINEcameraStream
SCREEN RECORDscreen_captureStream
SENSOR ... WATCHsensorsStream
GPS WATCHlocation + background_locationStream
BLUETOOTH ... NOTIFYbluetooth + locationStream

A WATCH that keeps running while the app is backgrounded needs background_location on top of location, and both stores look at that closely. A watch that stops when the app is backgrounded needs only the plain permission, which is what most apps actually want.

On the web

A web/pwa build has no native shell, so onDevice.available is false and onDevice.platform is "web". The library is still present and still answers: commands with a browser equivalent are routed to it - GPS to Geolocation, CAMERA CAPTURE to a file input with capture, NOTIFY to Notification, VIBRATE to the Vibration API, CLIPBOARD to the async clipboard. Everything else returns null.

That means one codebase covers both, and a script branches only where it genuinely must:

Degrading honestly
if (onDevice.enter('NFC PERMISSION')) {
  var tag = onDevice.enter('NFC READ TIMEOUT 20');
} else {
  showManualEntryForm();   // the web build, and any handset without NFC
}

A worked example

An asset audit: read the tag, photograph the item, record where it is, read the sensor bolted to it, and file the result through the API.

audit.js
function audit() {
  var tag = onDevice.enter('NFC READ TIMEOUT 30');
  if (!tag) { return status('No tag read.'); }

  var photo = onDevice.enter('CAMERA REAR CAPTURE MAXWIDTH 1600');
  var fix   = onDevice.enter('GPS ACCURACY 25 TIMEOUT 20');

  var temp = null;
  var near = onDevice.enter('BLUETOOTH SCAN SERVICE "181A" SECONDS 8');
  if (near.length) {
    var c = onDevice.enter('BLUETOOTH CONNECT "' + near[0].id + '"');
    if (c) {
      temp = onDevice.enter('BLUETOOTH "' + c + '" READ SERVICE "181A" CHARACTERISTIC "2A6E"');
      onDevice.enter('BLUETOOTH "' + c + '" CLOSE');
    }
  }

  ql('INSERT INTO DB "assets" TABLE "audits" ROW "tag" AS !POST(\'tag\') ' +
     'AND "lat" AS !POST(\'lat\') AND "lng" AS !POST(\'lng\') ' +
     'AND "temp" AS !POST(\'temp\')',
     { tag: tag.id, lat: fix ? fix.lat : '', lng: fix ? fix.lng : '',
       temp: temp ? temp.hex : '' });

  onDevice.enter('NOTIFY "Asset recorded" WITH "Audit"');
}

// The ordinary API call. Nothing special about it — this is the same
// request any client makes. See /tutorial/api-reference.
function ql(q, post) {
  return fetch('https://ql.ocalt.com/api', {
    method: 'POST',
    body: new URLSearchParams({
      identity: CONFIG.identity, password: CONFIG.key,
      q: q, post: JSON.stringify(post)
    })
  }).then(function (r) { return r.text(); });
}

This app compiles in nfc, camera, location, bluetooth and notifications. Nothing else, because nothing else is written here.

Examples

Android APK, custom identity
APPLICATE TYPE "android/apk" FROM "/root/myapp/"
TO "/root/releases/myapp-v1.apk"
SIGN WITH "/root/keys/release.keystore"
IDENTIFIER "com.myco.myapp"
TITLE "My App"
SET ?file
PWA, written into the folder that already serves it
APPLICATE TYPE "web/pwa" AT "/root/sites/mysite/" SET ?out
Build every target from one folder
NEW ARRAY ["android/apk" AND "ios/app" AND "windows/exe" AND "linux/deb"] SET ?targets
AFTER FOREACH ?targets SET ?t
OPEN
  SPLIT ?t BY "/" SET ?parts
  AFTER APPLICATE TYPE ?t FROM "/root/myapp/" TO "/root/out/myapp-" & ?parts(0) SET ?f
  AFTER EMIT ?t & " -> " & ?f("path") & "\n"
CLOSE