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 folder | In the package |
|---|---|
index.html.oql | index.html - whatever it emitted |
app.js.oql | app.js |
prices.json.oql | prices.json |
splash.png.oql | splash.png - emitted bytes, written as bytes |
styles.css | styles.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.
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.
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.
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
APPLICATE TYPE "android/apk" FROM "/root/appfolder/" TO "/root/new.apk" SET ?file
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 string | Output |
|---|---|
| android/apk | Android APK - direct install |
| android/aab | Android App Bundle - Play Store submission |
| windows/exe | Windows portable - a zip holding the app and its shell, extract and run |
| windows/installer | Windows installer - NSIS, per-user, no administrator rights needed |
| windows/msix | Windows 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/app | macOS application bundle - an Xcode project, built on a Mac |
| ios/app | iOS application package - an Xcode project, built on a Mac |
| linux/app | Linux binary |
| linux/appimage | Linux AppImage - portable |
| linux/deb | Debian package |
| linux/rpm | RPM package |
| web/pwa | Progressive Web App - manifest and service worker, in place |
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
| Target | Typical size | Typical time | What the device supplies |
|---|---|---|---|
| linux/deb, linux/rpm | under 50 KB | about a second | WebKitGTK, GTK3 |
| linux/appimage | about 220 KB | about two seconds | WebKitGTK, GTK3 |
| android/apk | under a megabyte | under a minute | The system WebView |
| windows/exe | about 450 KB | about a second | WebView2 and the .NET Desktop Runtime |
| windows/installer | about 480 KB | a few seconds | WebView2 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 here | What it means |
|---|---|
| The Swift shell | WKWebView, the onDevice bridge, splash handling, the same behaviour every other platform has |
| Your generators, run | Every .oql executed and its output baked in, exactly as for any other target |
| The JavaScript, rewritten | Every onDevice.enter call awaited, same as everywhere else |
| Info.plist | Carrying exactly the usage descriptions your code’s commands require, and nothing more |
| The icon set | Every size Apple asks for, generated from your icon |
| The project file | A 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.
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 this | You need |
|---|---|
| Run it on your own device | Xcode and a free Apple ID. The app is signed for seven days at a time. |
| TestFlight or the App Store | An Apple Developer Program membership, currently $99 a year. |
| Distribute a Mac app outside the store | A 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
| Modifier | Syntax | Description |
|---|---|---|
| SIGN WITH | SIGN WITH "/root/keys/my.keystore" | Signing key, keystore, certificate or provisioning profile. Omit for the Ocalt-managed signer. |
| IDENTIFIER | IDENTIFIER "com.ocalt.myapp" | Bundle identifier / package name |
| TITLE | TITLE "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.
| Key | What it holds |
|---|---|
ok | true when the build produced what it promised. false and the rest of the object says why. |
kind | The target, echoed back. |
path | Where the result landed in your namespace. |
size | Bytes. Zero for the targets that produce a folder rather than a file. |
version | The version string it was built with. |
seconds | How long it took. |
generated | Every .oql that ran, what it became, and how many bytes it emitted. |
permissions | The capabilities your code asks for, derived from it. |
error | Present only on failure. A sentence, not a stack trace. |
note | Present when a target needs something said about it - see below. |
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.
| Key | What it holds |
|---|---|
project | The name of the .xcodeproj inside the zip. |
note | Why it is a project rather than an app, and where to read the rest. |
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 adds | Why |
|---|---|
manifest.json | Name, colours, orientation and icons. A browser reads it to decide what an installed copy is called and looks like. |
sw.js | A 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.png | Generated 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 page | A <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:
| Key | What it holds |
|---|---|
wrote | The files it added to the folder: the manifest and the service worker. |
generators_in_place | Any .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. |
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.
{
"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.
| Stage | What is shown | How long |
|---|---|---|
| Cold start | The static image from splash_screen in the manifest | Until the WebView exists, typically under a second |
| Handover | splash.html, if the folder has one | Until the app dismisses it |
| Running | index.html | The 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.
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.
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');
<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
| Key | Meaning |
|---|---|
splash_screen | The static image for the cold-start gap. A PNG in the folder. |
splash_background | Colour behind that image while it is shown. Defaults to white. |
splash_html | The page to hand over to. Defaults to splash.html when one is present. |
splash_timeout | Seconds 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.
| Command | Shape | Behaviour |
|---|---|---|
| SPLASH PROGRESS <n> [WITH "<text>"] | Fire | Sends a percentage and an optional line to the splash page. |
| SPLASH DISMISS | Fire | Removes 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:
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:
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.
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
| Shape | What it does | Returns |
|---|---|---|
| Fire | Dispatches the call and carries on. Nothing to wait for. | Nothing |
| Halt and fire | Waits for the device to answer, then continues on the next line. | A value, or null |
| Stream | Attaches a live native surface or a repeating source. | Nothing - a surface is not a value |
Fire
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
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.
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.
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.
?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:
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.
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"');
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:
| Key | Type | What it is |
|---|---|---|
id | string | The handle every other DRIVE command takes. Stable for this drive on this device. |
name | string | What to show a person: Home, Pictures, Downloads, an SD card's label. |
path | string | Where it sits on the device. Shown, not used - commands take id. |
free | number | Bytes available. Zero where the platform does not report it. |
DRIVE ?d FILE LIST ?path returns an array. Each entry:
| Key | Type | What it is |
|---|---|---|
name | string | The entry's own name, not a path. Join it to the folder you listed. |
is_dir | bool | True for a folder. Check it before reading, and recurse on it to walk a tree. |
size | number | Bytes. Zero for a folder. |
modified | number | Unix timestamp of the last write. |
DRIVE ?d FILE READ ?path and DRIVE PICK return one object:
| Key | Type | What it is |
|---|---|---|
data | bytes | The file's contents, as bytes. Pass it straight to a FILE WRITE, an upload, or an <img>. |
size | number | How many bytes that is. |
mime | string | What the platform thinks it is. |
name | string | PICK only. The file's name, which you did not supply. |
path | string | PICK 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.
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');
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.
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.
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');
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.
| Command | Shape | Behaviour |
|---|---|---|
| TRAY ICON "<path>" | Fire | Creates the icon on first call, changes it after. |
| TRAY TITLE "<text>" | Fire | Sets the tooltip. |
| TRAY NOTIFY "<title>" "<message>" | Fire | Notification anchored to the icon. |
| TRAY REMOVE | Fire | Removes the icon. |
| TRAY MENU ?menu | Halt | Shows a context menu, returns the item picked. |
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.
var photo = onDevice.enter('CAMERA CAPTURE');
if (photo === null) {
show('No photo — camera access was declined.');
} else {
upload(photo.data);
}
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
| Command | Compiled in | Shape |
|---|---|---|
| NOTIFY | notifications | Fire |
| VIBRATE | vibrate | Fire |
| BROWSER OPEN | none | Fire |
| CLIPBOARD WRITE | none | Fire |
| CALL | none | Fire |
| CALL NOW | call_phone | Fire |
| MESSAGE COMPOSE | none | Fire |
| TRAY ICON / TITLE / NOTIFY / REMOVE | none | Fire |
| CAMERA CAPTURE / VIDEO CAPTURE | camera | Halt |
| MICROPHONE RECORD | microphone | Halt |
| GPS | location | Halt |
| BLUETOOTH | bluetooth + location | Halt |
| WIFI | wifi + location | Halt |
| NFC | nfc | Halt |
| SENSOR / SENSORS LIST / ORIENTATION | sensors | Halt |
| BIOMETRIC PROMPT | biometric | Halt |
| DRIVE ... | storage | Halt |
| DRIVE PICK | none | Halt |
| CONTACTS PICK | contacts | Halt |
| CALENDAR LIST | calendar | Halt |
| SCREENSHOT | screen_capture | Halt |
| CLIPBOARD READ | none | Halt |
| BATTERY / NETWORK / DEVICE | none | Halt |
| TRAY MENU | none | Halt |
| CAMERA VIEW INLINE | camera | Stream |
| SCREEN RECORD | screen_capture | Stream |
| SENSOR ... WATCH | sensors | Stream |
| GPS WATCH | location + background_location | Stream |
| BLUETOOTH ... NOTIFY | bluetooth + location | Stream |
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:
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.
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
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
APPLICATE TYPE "web/pwa" AT "/root/sites/mysite/" SET ?out
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