App lifecycle, clipboard & openURL

The app as a whole — going to the background and back, the link it was opened with, the screen orientation, the last-resort back press — plus the clipboard and openURL; all three are globals. openURL hands a URL to the system (the external browser, tel:, sms:, mailto:); a page shown INSIDE the app — a sheet, an OAuth sign-in, a checkout — is Browser.open (Browser). A screen's own back button is screen.onBack (Screens & Router); app.onBack is the fallback when no screen takes it.

  • save state when the app goes to the background
  • open a specific screen from a deep link
  • set the app's own URL scheme (urlScheme in app.json)
  • lock the screen to landscape for a game
  • catch the back button when no screen handles it
  • restart the app from scratch, or leave it
  • move a chat composer above the on-screen keyboard
  • show the app's version on an about screen
  • copy text to the clipboard and paste it
  • open a link in the system browser, call a number, write an email

Platforms: iOS, Android, desktop, the web and the web player; deep links and the orientation lock are the phones' and app.onBack is Android's — their sections say what the other hosts do.

At a glance

TypeScript
const openFromLink = (url: string) => console.log('opened with', url)

app.addEventListener('pause', () => console.log('in the background'))   // pause music, stop watches
app.addEventListener('resume', () => console.log('back in the foreground'))

if (app.launchUrl) openFromLink(app.launchUrl)       // cold-start deep link
app.addEventListener('url', openFromLink)            // …and links arriving while running

UIScreen(
  UIButton(UIText('Copy code')).onClick(() => clipboard.write('LECODES-42')),
  UIButton(UIText('Help')).onClick(() => openURL('https://le.codes/docs')),
).open()

Save state when the app goes to the background: pause / resume

Keep the user's work when they switch away: "pause" fires as the app leaves the foreground, "resume" when it comes back, and app.state says which one is current.

TypeScript
app.state                                   // "active" | "background"  (read-only)
app.addEventListener('pause', () => { })    // the app left the foreground
app.addEventListener('resume', () => { })   // the app is back; frames are running again
app.removeEventListener('pause', callback)  // the same function reference

"pause" fires when the user goes to the home screen or another app (iOS, Android), minimizes the window (desktop — a window that only lost focus keeps running) or hides the browser tab (the web). It is delivered before the host halts the frame loop: the moment to pause music and stop work that should not run blind (a Geolocation watch, polling timers).

Save the user's work as it changes, not in the "pause" handler: on iOS and Android a localStorage write made there is stored only when the app comes back, and is lost if the OS closes the app in the background (Storage). Keep what was typed:

TypeScript
const note = UIInput({ placeholder: 'Note' })
note.value = localStorage.getItem('note') ?? ''               // what the last run saved
note.onChange((text) => localStorage.setItem('note', text))   // saved as it is typed

let poll = setInterval(() => console.log('polling'), 5000)
app.addEventListener('pause', () => clearInterval(poll))      // stop work that should not run blind
app.addEventListener('resume', () => { poll = setInterval(() => console.log('polling'), 5000) })
UIScreen(note).open()

On iOS and Android the app's code pauses when it leaves the foreground: after "pause" nothing runs until "resume" — timers and network results wait, and there is no background task. pause is where to stop work, not where to start it. (A minimized desktop window keeps running; a hidden browser tab is slowed down as the browser decides.)

Note

headless tests never leave the foreground: app.state reads "active" and neither event fires.

Open a specific screen when the app is opened from a link: app.launchUrl is the link the app was started with, and the "url" event brings each link that arrives while it runs.

TypeScript
app.launchUrl                               // string | null  (read-only)
app.addEventListener('url', (url: string) => { })

launchUrl is the URL the app was (most recently) opened with — null for a plain launch. When a link arrives while the app is already running (a warm link), launchUrl updates to the new value and the "url" event fires with it. Handle both, in that order:

TypeScript
const ProductScreen = (id: string) => UIScreen(UIText(`Product ${id}`)).named('product')
Router.init(UIScreen(UIText('Shop')))       // the stack a link pushes onto

const openFromLink = (url: string) => {     // myshop://product/42, or /product/42 on the web
  const id = /product\/([^/?#]+)/.exec(url)?.[1]
  if (id) Router.push(ProductScreen(id))
}
if (app.launchUrl) openFromLink(app.launchUrl)
app.addEventListener('url', openFromLink)

Where a link comes from depends on the host:

  • an iOS or Android app built with lecodes app: a link on its own scheme (urlScheme of app.json, below);
  • the le.codes viewer app: the le.codes link the project was opened with (null for the viewer's own start project); a project's own scheme does not open the viewer;
  • the web (lecodes web): the route of the page's address (the note below);
  • the web player: the full address of the player's page; "url" never fires;
  • desktop and headless tests: none — launchUrl is null unless app.restart(url) set it, and "url" never fires.
Note

on the web (lecodes web) the value is the ROUTE of the page's address — /product/42 for …/#/product/42 — not a full url: parse it as a path. A reload of the page is a launch at the route it shows; the browser's Forward onto a screen that is gone arrives as a "url" event. A screen names its route with url().

Custom URL scheme: urlScheme in app.json

Set the app's own URL scheme — so that myshop://product/42 opens it — with "urlScheme" in app.json:

JSON
{
  "name": "My Shop",
  "urlScheme": "myshop"
}

lecodes app init writes it into the iOS and Android projects it creates, and lecodes app sync restamps it whenever the key changes or goes: the iOS app declares the scheme in Info.plist (CFBundleURLTypes), the Android app in a BROWSABLE intent-filter on its activity. A myshop://… link then opens the app — at a cold start as app.launchUrl, while it runs as the "url" event (the code above). The scheme is lowercase letters, digits, +, - or ., starting with a letter, and never http / https: the CLI refuses those.

The scheme belongs to an app built with lecodes app: the le.codes viewer app opens only its own le.codes links, the web has no scheme (a link is the page's address; its route arrives as app.launchUrl), and desktop registers none. Browser.open(url, { returnUrl: 'myshop://…' }) — an OAuth redirect, a checkout's return — needs the scheme on Android (Browser).

Lock the screen orientation: app.setOrientation

Lock the screen to landscape for a horizontal game, or to portrait:

TypeScript
app.setOrientation(mode: 'landscape' | 'portrait' | 'auto'): void

'landscape' rotates the device to landscape right away and keeps it there whichever way the phone is held (both landscape directions are allowed, so the player can flip it); 'portrait' keeps it upright; 'auto' releases the lock and the device's own rotation rules apply again. A game that is landscape throughout calls app.setOrientation('landscape') once, at top level. One screen of a portrait app locks while it is shown:

TypeScript
const game = UIScreen(UIText('Tilt to steer')).named('game')
  .onOpen(() => app.setOrientation('landscape'))
  .onClose(() => app.setOrientation('auto'))

Router.init(UIScreen(UIButton(UIText('Play')).onClick(() => Router.push(game))))

The lock is per app run: it resets when the app quits or restarts, so the launcher always comes back unlocked and there is nothing to undo. The rotation arrives as an ordinary resize — layouts reflow and a scene's viewport follows; read sizes after the first frame rather than at top level.

Note

iOS and Android rotate; desktop and headless tests ignore the call. On the web it is a best-effort screen.orientation lock, which browsers honor only in fullscreen; in lecodes dev --web the drawn device turns.

Back press: app.onBack

Catch the Android back button when no screen, pager or dialog takes it — the place for app-wide policy such as "press back again to exit". A screen's own back button is screen.onBack (Screens & Router); app.onBack is the fallback when no screen takes it.

TypeScript
app.onBack(callback: (() => void) | null): void   // replaces the previous handler; null removes it

It fires only when nothing else took the press: no shown widget with an onBack (a modal, a popover, a bottom sheet), no onBack on the current screen, every pager at its tab root and the router at its stack root. Without it such a press is ignored — the app does not exit. Press back twice to leave:

TypeScript
let armedAt = 0
app.onBack(() => {
  if (Date.now() - armedAt < 2000) { app.quit(); return }
  armedAt = Date.now()
  toast('Press back again to exit')
})
Note

Android only. iOS has no system back (its edge swipe pops screens and never reaches app.onBack); desktop, the web and headless tests never call it. app.onBackPressed is a deprecated alias.

Restart or leave the app: app.restart / app.quit

Start the app over from scratch, or leave it:

TypeScript
app.restart(url?: string | null)   // re-run this app's bundle in a fresh world — location.reload()
app.quit()                         // leave this app

restart() reboots the app: a fresh world, the same code. Pass a url to reboot with different launch arguments (the new run reads them via app.launchUrl), pass null to reboot with a cleared one, or omit it to replay the current one — the argument mirrors launchUrl's role as the app's "command line". Unsaved state is gone, as after a page reload; localStorage is kept.

quit() inside the le.codes viewer app returns to the viewer's home. An Android app built with lecodes app goes to the background; on iOS, the web and in headless tests the call does nothing (none of them exits an app from code); desktop goes back to its launcher, which reopens a project given on its command line — under lecodes desktop run, a reload. So a "back to le.codes" button can call it unconditionally.

TypeScript
UIScreen(
  UIButton(UIText('Start over')).onClick(() => app.restart(null)),
  UIButton(UIText('Exit')).onClick(() => app.quit()),
).open()

On-screen keyboard height: app.keyboardHeight + the keyboard event {#keyboard}

Move a chat composer above the on-screen keyboard yourself: app.keyboardHeight is how much of the screen the keyboard covers, and the "keyboard" event fires each time that changes.

TypeScript
app.keyboardHeight                          // number  (read-only, logical px; 0 when hidden)
app.addEventListener('keyboard', (height: number, duration: number) => { })

keyboardHeight is the on-screen keyboard's raw overlap with the app viewport — independent of the focused input's keyboardShrink policy. With the default shrink policy the layout already avoids the keyboard, so you rarely need it; its main consumer is overlay mode (keyboardShrink: false), where the app manages its own inset. The event fires on every keyboard frame change; duration is the keyboard's animation in ms (the system's on iOS, 250 in the web player, 0 elsewhere) — pass it to animateTo to move UI in sync:

TypeScript
// chat composer: keyboard overlays the UI, the composer slides itself up
const input = UIInput({ placeholder: 'Message' }).style({ keyboardShrink: false, flexGrow: 1 })
const composer = UIRow(input).style({ position: 'absolute', left: 0, right: 0, bottom: 0 })
app.addEventListener('keyboard', (height, duration) => {
  composer.animateTo({ transform: `translateY(${-height}px)`, duration })
})
UIScreen(composer).open()
Note

iOS, Android and a phone's browser report the keyboard. Desktop does only with app.json desktop.virtualKeyboard (its drawn keyboard) — otherwise it reads 0 and the event never fires. Headless tests simulate a keyboard of 32% of the screen height while an input is focused and, like the web player, lay the screen out above it even under keyboardShrink: false.

The app's version: app.host, app.bundle

Show the app's version on an about screen, or check what the installed build supports before using a feature: app.host is the installed app (the store build), app.bundle the code running in it.

TypeScript
app.host.version      // string | undefined — the installed app: versionName / CFBundleShortVersionString
app.host.build        // number | undefined — versionCode / CFBundleVersion (0 when not numeric)
app.host.sdk          // string — the SDK embedded in the host (a shell's lecodes-ios-sdk / lecodes-android-sdk release)
app.bundle.sdk        // string — the SDK this bundle was compiled with
app.bundle.updatedAt  // string | undefined — when this bundle was compiled (ISO 8601, the `// last-updated:` header line)
app.sdkVersion        // string — the same as app.bundle.sdk, e.g. "2.2.12"
TypeScript
const version = app.host.version ? `${app.host.version} (${app.host.build})` : 'dev'
UIScreen(
  UIText(`Version ${version}`),
  UIText(`SDK ${app.bundle.sdk}`),
).open()

An over-the-air update parts two things: the host is the store build (its binary, its native SDK, its plugins), the bundle is the JavaScript running in it, which a newer publish may have replaced. The SDK's major is the bundle ↔ runtime contract: a host runs only bundles of its own major; minor and patch never gate anything. Code that needs native support added in a later store build checks for it — a plugin with its isSupported (the SDK's wrapper is always in the bundle, so typeof Push says nothing), anything else with app.host.build:

TypeScript
// the push plugin came with a later store build: an older install has no native half for it
if (!Push.isSupported) toast('Update the app from the store to get notifications')

version and build are undefined where the host is not an installed app (desktop, the web, the web player, headless tests); in the le.codes viewer app they are the viewer's own, not the project's. updatedAt is undefined on the web and in headless tests. It is also what the updater compares before replacing a bundle: a downloaded bundle older than the running one never replaces it.

Copy and paste text: clipboard

Copy text to the system clipboard and paste it back. The clipboard holds text only — send an image with share.

TypeScript
clipboard.write(text: string): void         // fire-and-forget
clipboard.read(): Promise<string>           // rejects: no access / denied / nothing readable

write puts text on the system clipboard (a silent no-op where there's no clipboard). read is async because the web gates it behind the browser's permission or paste prompt; expect and handle a rejection — it also rejects when the clipboard holds no text:

TypeScript
const code = UIInput({ placeholder: 'Invite code' })
const copy = UIButton(UIText('Copy')).onClick(() => { clipboard.write(code.value); toast('Copied!') })
const paste = UIButton(UIText('Paste')).onClick(async () => {
  try { code.value = await clipboard.read() }
  catch { toast('Nothing to paste') }
})
UIScreen(code, copy, paste).open()
Note

iOS, Android, desktop (Linux needs xclip or xsel) and the web; in headless tests write does nothing and read rejects.

openURL

Open a link in the external browser, outside the app:

TypeScript
openURL(url: string): void

openURL hands the URL to the system — fire-and-forget, like share: https: opens the browser, any other scheme whatever the system has registered for it (below). Use it for "Terms of service", a help page, or "Rate us" with the store page (itms-apps: on iOS, market: on Android). A page shown INSIDE the app — a sheet, an OAuth sign-in, a checkout — is Browser.open (Browser).

TypeScript
UIScreen(
  UIButton(UIText('Privacy policy')).onClick(() => openURL('https://le.codes/privacy')),
).open()
Note

iOS, Android, desktop (Linux needs xdg-open) and the web, where it opens a new tab; nothing happens in headless tests.

Call, text or email: tel:, sms:, mailto:

Call a phone number, start a text message or write an email: openURL with a tel:, sms: or mailto: URL opens the dialer, the messages app or the mail app with it filled in — nothing is dialed or sent until the user does it.

TypeScript
const call = UIButton(UIText('Call us')).onClick(() => openURL('tel:+15551234567'))
const text = UIButton(UIText('Text us')).onClick(() => openURL('sms:+15551234567'))
const mail = UIButton(UIText('Email support')).onClick(() =>
  openURL(`mailto:help@example.com?subject=${encodeURIComponent('Order 42')}`))
UIScreen(call, text, mail).open()

On iOS a URL that does not parse is dropped silently — percent-encode a subject or a body (encodeURIComponent). Nothing comes back: where no app handles the scheme, the app is not told. mailto: cannot attach a file: send a picture or a video with share.

See also

  • Device — device.online + online/offline events (connectivity)
  • Screens & Router — onBack on screens, the back-press chain
  • Browser — a page inside the app: the system sheet, OAuth, a checkout
  • Geolocation — stop watches in pause
  • Storage — keep small values across restarts (save as they change, not in pause)