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

TypeScript
// "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.

TypeScript
Service(name: string, params?: object): Service    // a client; never throws
svc.call(method: string, ...args): Promise<any>     // the native side's answer

Service(...) 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.

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

TypeScript
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()
Note

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.

TypeScript
svc.on(event: string, cb: (data?) => void): this
svc.off(event: string, cb: (data?) => void): this

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

TypeScript
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 minute

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

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

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

TypeScript
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