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 (
urlSchemein 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
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.
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:
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.)
headless tests never leave the foreground: app.state reads "active" and neither
event fires.
Deep links: app.launchUrl + the url event
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.
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:
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 (urlSchemeof app.json, below); - the le.codes viewer app: the le.codes link the project was opened with (
nullfor 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 —
launchUrlisnullunlessapp.restart(url)set it, and"url"never fires.
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:
{
"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:
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:
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.
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.
app.onBack(callback: (() => void) | null): void // replaces the previous handler; null removes itIt 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:
let armedAt = 0
app.onBack(() => {
if (Date.now() - armedAt < 2000) { app.quit(); return }
armedAt = Date.now()
toast('Press back again to exit')
})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:
app.restart(url?: string | null) // re-run this app's bundle in a fresh world — location.reload()
app.quit() // leave this apprestart() 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.
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.
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:
// 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()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.
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"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:
// 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.
clipboard.write(text: string): void // fire-and-forget
clipboard.read(): Promise<string> // rejects: no access / denied / nothing readablewrite 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:
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()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:
openURL(url: string): voidopenURL 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).
UIScreen(
UIButton(UIText('Privacy policy')).onClick(() => openURL('https://le.codes/privacy')),
).open()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.
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/offlineevents (connectivity) - Screens & Router —
onBackon 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)