Service
Service(name) calls your own native code that has no view — Swift or Kotlin in the app shell, a
desktop plugin library. A first-party capability (camera, map, push, location …) has its own typed
plugin page; Service is the raw channel to a service the host registered — your own native plugin
(Native plugins); native code that draws a view is a
NativeView.
- call my own native Swift, Kotlin or desktop code and get the result back
- get events from my native code (a sensor reading, a status change)
- check that the device has my native service before showing the feature
- pass options to the native side when it starts
- send bytes or an object to native code
- stop the native work when the screen closes
- read a native value synchronously, without a promise
Platforms: iOS and Android in a shell built from the project (lecodes app), desktop (a library in the project's plugins/ folder); not in the le.codes viewer app, on the web or in the web player, which carry only the first-party plugins' services, nor in headless tests — check Service.isSupported(name).
At a glance
// "app.battery": a service this app's shell registers
const levelText = UIText('—')
UIScreen(levelText).open()
if (Service.isSupported('app.battery')) {
const battery = Service('app.battery')
battery.on('change', data => { levelText.text = data.level + '%' })
battery.call('level').then(({ level, charging }) => { // the first call opens the session
levelText.text = level + '%' + (charging ? ', charging' : '')
})
} else {
levelText.text = 'Battery level not available here'
}Where a service comes from: the native half
A service is a factory the host registered under a name; it runs when the app opens a session
and answers that session's calls. In an iOS shell, override configure(_:) of the shell's
ViewController and call engine.registerService("app.battery") { params, channel in … }; on
Android, call engine.registerService("app.battery") { params, channel -> … } in MainActivity
after the bundled plugins are registered. On desktop it is a .dll / .so / .dylib in the
project's plugins/ folder that calls register_service. The factory gets the client's
params and a channel to emit events on, and returns the object that serves call and stops
its work on close. Writing that half, per platform: Native plugins.
Call your own native code (Swift, Kotlin, a desktop library): call
call runs a method of your own native code — Swift or Kotlin in the app shell, C or Rust in a
desktop library — and resolves with what it answers.
Service(name: string, params?: object): Service // a client; never throws
svc.call(method: string, ...args): Promise<any> // the native side's answerService(...) only makes the client: the host session opens on the first call(), and params
(JSON data, no bytes) reach the native factory at that moment. Each client is a session of its
own. call rejects when the host has no such service, when the native side reports an error (the
Error's message is the native side's), or when an argument cannot cross (below). An answer the
native side leaves out arrives as undefined.
const status = UIText('Scanning…')
UIScreen(status).open()
const scanner = Service('acme.scanner', { torch: true }) // params: read by the native factory
const main = async () => {
try {
const result = await scanner.call('scan', { timeoutMs: 5000 })
status.text = 'Read: ' + result.code
} catch (e) {
status.text = 'Scan failed: ' + (e as Error).message
}
}
main()What crosses to native code: objects, arrays, bytes
Arguments, answers and event payloads are plain data — what JSON can say — plus Uint8Array for
bytes. As with JSON.stringify, a member that is undefined or a function is left out, a number
that is not finite becomes null, and a class instance crosses as the plain object of its fields.
A cycle (nesting deeper than 64), a typed array of wider elements (Float32Array), a bare
ArrayBuffer, a symbol or a bigint makes call reject before the native side sees the call.
const printer = Service('acme.printer')
const main = async () => {
const header = new Uint8Array([0x1b, 0x40]) // bytes cross as bytes
await printer.call('write', header, { cut: true, copies: 2 })
const reply = await printer.call('readStatus') // a Uint8Array when the native side answers bytes
console.log('status bytes', reply.length)
}
main()a desktop library plugin speaks JSON text, so there a Uint8Array arrives as an
array of numbers and an answer is never bytes.
Receive events from your native code: on / off
A service registered in the app shell can send events at any time — a sensor reading, a status
change — and on receives them. A payload is plain data, or undefined.
svc.on(event: string, cb: (data?) => void): this
svc.off(event: string, cb: (data?) => void): thison only records the listener: events flow while a session is open, and the first call()
opens it. A service that only emits needs a call that starts it.
const count = UIText('0 steps')
UIScreen(count).open()
const steps = Service('app.pedometer')
const onStep = (data: { total: number }) => { count.text = data.total + ' steps' }
steps.on('step', onStep) // listen first: an event can arrive before `then`
steps.call('start').catch(e => { count.text = 'Pedometer failed: ' + e.message })
setTimeout(() => steps.off('step', onStep), 60000) // stop listening after a minuteCheck that the device has your native plugin: Service.isSupported
Before offering a feature that needs your native plugin, check that this host has it:
Service.isSupported(name) is synchronous and true when the host registered a service under
name. Use it to decide what the UI offers, and still catch around calls.
const readDial = UIButton(UIText('Read the dial')).onClick(() => {
Service('acme.dial').call('read')
.then(r => console.log('angle', r.angle))
.catch(e => console.log('dial failed:', e.message))
})
// offer the button only where the host registered the service
UIScreen(Service.isSupported('acme.dial') ? readDial : UIText('No dial on this device')).open()It is false in headless tests and for your own services in the le.codes viewer app, on the web
and in the web player — keep a path that works without the service.
Stop the native work: close
close() closes the host session; the native side stops whatever it was doing (a sensor, a
socket). Listeners stay registered, and a later call() opens a fresh session that delivers to
them again. A session is not tied to a screen: stop it in the screen's onClose. Sessions still
open when the app is reloaded or replaced are closed by the runtime.
const level = UIText('—')
const sensor = Service('app.light')
sensor.on('reading', (d: { lux: number }) => { level.text = d.lux + ' lx' })
const screen = UIScreen(level)
.onOpen(() => { sensor.call('start').catch(e => { level.text = e.message }) })
.onClose(() => { sensor.close() }) // covered, popped or replaced: the sensor stops
Router.init(screen)Name your service
First-party services own bare names (geolocation, push). Name app-local services app.*
(app.battery) and third-party plugin services with a vendor prefix (acme.bluetooth) — bare
names are reserved for the SDK.
A synchronous native value: registerCallback
To read one value synchronously, without a promise, the host can install a plain global function
instead of a service: engine.registerCallback("battery.level", argsCount: 0) { … } on iOS,
engine.registerCallback("battery.level", 0) { … } on Android — the dotted path is where the
function appears. It runs on the JS thread and blocks the frame while it runs. Reach for Service
as soon as you need updates over time, async work or session state.
declare const battery: { level(): number } // installed by the host's registerCallback
const level = typeof battery !== 'undefined' ? battery.level() : -1
console.log('battery level', level)See also
- Native plugins — writing the native half (a desktop library, the mobile halves)
- NativeView — the same channel with a view
- Geolocation — a first-party service with its typed wrapper