Skip to the contentSkip to the navigation
RootsGitHubMenu
On this page

Native shells

roots build --target android and --target ios wrap the app's web build in a small native project of Roots' own: Kotlin + WebView on Android, Swift + WKWebView on iOS. The page talks to native code through one JavaScript object, RootsNative, and a fixed message format (the bridge ABI). Native features are plugins: small classes behind an allow-list in roots.lisp. Capacitor is still available as an alternative shell.

 roots.lisp ──► roots build --target android ──► dist/android/   Gradle project (Kotlin)
                roots build --target ios     ──► dist/ios/       Xcode project (Swift)

 ┌──────────────── WebView / WKWebView ────────────────┐      ┌──────────── native ────────────┐
 │ your app (roots/platform, roots/data …)              │      │ Bridge: checks origin, frame,  │
 │      │                                               │ JSON │   allow-list, method          │
 │      ▼                                               │ ───► │      │                         │
 │ RootsNative.call("sqlite", "query", {…})  ◄──────────┼───── │      ▼                         │
 │ RootsNative.on("resume", …)                          │      │ SqlitePlugin  StoragePlugin   │
 └──────────────────────────────────────────────────────┘      │ ClipboardPlugin BrowserPlugin │
   served from the app package: https://appassets.androidplatform.net (Android), roots://localhost (iOS)

Building #

roots build --target android             # dist/android: a Gradle project
roots build --target android --native    # + Gradle assembleDebug → app/build/outputs/apk/debug/app-debug.apk
roots build --target ios                 # dist/ios: App.xcodeproj
roots build --target ios --native        # + xcodebuild (simulator, unsigned): macOS with Xcode 15+ only
roots doctor --target android            # Java, Gradle, Android SDK (platforms, build-tools, licenses)

Android needs JDK 17+ (21 recommended), Gradle 8.13+ (or Android Studio) and the Android SDK with ANDROID_HOME set and the licenses accepted (sdkmanager --licenses); Gradle fetches the platform (android-36) if it is missing. --native uses the project's ./gradlew when there is one (gradle wrapper creates it), else the gradle on PATH. Install the debug APK with adb install -r app/build/outputs/apk/debug/app-debug.apk.

iOS needs macOS with Xcode 15 or newer. Open dist/ios/App.xcodeproj, choose your team under Signing & Capabilities, and run. The project is written by Roots itself (no XcodeGen, no CocoaPods, no Swift packages): one app target, a shared scheme, iOS 15 or newer.

Rebuilding rewrites the files Roots generated and nothing else: Gradle's app/build/, local.properties, Xcode's build/ and xcuserdata survive (.roots-build lists what the build wrote).

What is generated #

dist/android/                                     dist/ios/
  settings.gradle.kts build.gradle.kts              App.xcodeproj/project.pbxproj (+ shared scheme)
  gradle.properties                                 App/Info.plist  App/Assets.xcassets
  app/build.gradle.kts                              App/*.swift              the shell
  app/src/main/AndroidManifest.xml                  App/ShellConfig.swift    names, versions, allow-list
  app/src/main/assets/www/       the web build      App/Plugins/*.swift      allow-listed plugins
  app/src/main/assets/roots/bridge.js               App/RootsBridge.js       RootsNative
  app/src/main/java/dev/roots/shell/   the shell    App/www/                 the web build
  app/src/main/java/dev/roots/plugins/ plugins      README.md
  app/src/main/res/  name, colours, icons, network config
  app/src/test/java/dev/roots/shell/   JVM unit tests of the bridge
  README.md

The sources come from shells/ (bridge/, android/, ios/): files in shell/ and plugins/ are copied as they are, files in templates/ get the app's data ({{key}}, escaped for the file type: XML, Android string resources, Kotlin and Swift string literals).

roots.lisp #

(:name "todo" :title "Todo" :version "1.2.0" :id "com.example.todo"
 :native (:shell :roots                                   ; :roots (default) or :capacitor
          :plugins (:sqlite :storage :app)                 ; the allow-list (default: what the app uses)
          :backup nil                                      ; t: app data goes into device and cloud backups
          :app-id "com.example.todo"                      ; bundle id / application id (default :id)
          :app-name "Todo"                                ; under the icon (default :title)
          :build 12                                       ; versionCode / CFBundleVersion (default 1)
          :android-dependencies ("androidx.biometric:biometric:1.1.0")   ; for your own plugins
          :android-permissions ("android.permission.USE_BIOMETRIC"))
 :targets (:web () :ios () :android (:shell :capacitor)))   ; a target's own :shell wins
KeyDefaultNotes
:shell:rootsalso per target: :ios (:shell :capacitor)
:pluginswhat the app useskeywords or strings: lowercase letters and digits in parts separated by single dashes (local-notifications); two names that give one class are B105. Only these are compiled in. Left out, the list is derived from the app's modules and printed in the build summary: storage and app always, sqlite when roots/data is imported, clipboard / browser when a module imports clipboard / openUrl from roots/platform (all of them for import * as, export * or import() of roots/platform). An app that calls RootsNative.call("clipboard", …) itself, or reaches roots/platform through a computed import(), lists its plugins: a plugin that is not compiled in answers BRIDGE_UNKNOWN_PLUGIN
:backupnilnil: the app's data stays on the device. Android: allowBackup="false" and data-extraction rules that exclude everything from cloud backup and device transfer; iOS: Application Support and Library/WebKit are marked as excluded from backup at launch (the storage plugin's UserDefaults cannot be excluded). t: the platform's backups include the data
:app-id:idper target :app-id wins; an Android id cannot contain a Java keyword (B105)
:app-name:title
:build11 … 2100000000
:android-dependencies()exact group:artifact:version (no +, no ranges)
:android-permissions()the shell itself asks for INTERNET only

The version shown in the stores is :version (iOS gets its numeric part: 1.2.0-beta.1 → 1.2.0).

The bridge ABI (version 1) #

The shell injects this object into the app's own pages, before any of their scripts run:

globalThis.RootsNative: {
  platform: "ios" | "android"
  version: "1"
  call(plugin: string, method: string, args?: object): Promise<unknown>
  on(event: string, handler: (data: unknown) => void): () => void      // returns "remove this handler"
}
  • call resolves with the plugin's result. It rejects with an Error whose name is "RootsNativeError", with a string code and a message: {code, message}.
  • args must be a plain object (default {}) that JSON.stringify can encode.
  • Events: resume (the app comes back to the foreground), pause (it goes to the background), back (Android back button), and events plugins send.
  • Back button: while the page has at least one back handler, the back button is the page's: the shell only sends back. Without one, the shell goes back in the WebView history, and leaves the app when there is none.
  • RootsNative is frozen and cannot be replaced. It is absent in a plain browser, in a Capacitor shell, in every frame (the app's own included) and in pages from any other origin: code that uses it checks for it (roots/platform falls back to the browser APIs). A message that reaches the native side from a frame or a foreign origin all the same is answered BRIDGE_UNAVAILABLE and never reaches a plugin.
  • The ABI version is checked on both sides. The bridge script says its version when the page is ready; a shell that speaks another one answers every call of that page with BRIDGE_UNAVAILABLE. roots/platform and roots/data use RootsNative only when its version is "1", and otherwise behave as in a browser.

Messages are JSON text:

page → native   {"id": 7, "plugin": "sqlite", "method": "query", "args": {"db": 1, "sql": "…", "params": []}}
native → page   {"id": 7, "ok": true, "result": {"columns": ["n"], "rows": [[1]]}}
                {"id": 7, "ok": false, "error": {"code": "SQLITE_ERROR", "message": "no such table: x"}}
                {"event": "resume", "data": …}
page → native   {"control": "ready", "version": "1"}                    once per page
                {"control": "listen", "event": "back", "active": true}  first back handler added / last removed

Transport: Android posts strings on a WebViewCompat.addWebMessageListener channel (RootsNativeChannel); iOS posts to the WKScriptMessageHandlerWithReply named rootsNative and gets the reply as the promise's value; iOS events arrive through callAsyncJavaScript with the message as an argument (never as source text).

Error codes of the bridge (plugins add their own, with their own prefix):

CodeMeaning
BRIDGE_UNKNOWN_PLUGINthe app has no plugin of that name (not in :native :plugins)
BRIDGE_UNKNOWN_METHODthe plugin has no method of that name
BRIDGE_BAD_ARGSargs is not an object, not JSON, or a plugin's argument has the wrong type
BRIDGE_BAD_MESSAGEan empty or non-string plugin or method name, a malformed message
BRIDGE_TOO_LARGEa message over 16 Mi characters
BRIDGE_UNAVAILABLEthe native side refused the message (a frame, another origin, a bridge script of another ABI version) or went away
BRIDGE_INTERNALthe plugin threw an unexpected error (the message says which)

Built-in plugins #

The same methods and results on both platforms.

PluginMethodArgs → result
sqliteopen{name, adopt?} → {db: number}; name is a file name (letters, digits, ., _, -; not ending in -journal, -wal or -shm, which are SQLite's own side files: SQLITE_CANTOPEN) or ":memory:". adopt names an older file that is renamed to name when name does not exist yet (roots/data opens <name>.sqlite3 and adopts <name>)
exec{db, sql} → {}; a script of one or more statements (triggers included)
run{db, sql, params} → {changes, lastInsertRowid}; one statement
query{db, sql, params} → {columns: string[], rows: unknown[][]}; one statement
close{db} → {}
storageget / set / remove / keys{key} → {value: string | null}; {key, value: string} → {}; {key} → {}; → {keys: string[]}
clipboardread / write→ {text} ("" when empty); {text} → {}
browseropen{url} → {}: http and https only (BROWSER_BAD_URL), in the system browser (BROWSER_NO_APP)
appinfo→ {name, version, platform, build} (strings)

sqlite details. params is an array for ? / ?NNN placeholders; values are null, booleans (stored as 1/0), numbers (integers stay integers), strings and {"$bytes": base64} for blobs, which come back the same way. Errors are SQLite's result codes as "SQLITE_…", extended when SQLite reports one (SQLITE_CONSTRAINT_UNIQUE, SQLITE_CONSTRAINT_NOTNULL, SQLITE_CONSTRAINT_FOREIGNKEY …; Android versions whose messages carry no code give the primary one, SQLITE_CONSTRAINT): SQLITE_ERROR (syntax, no such table), SQLITE_MISUSE (a closed handle, several statements in run/query, wrong argument types; on iOS also SQL that contains a NUL character), SQLITE_RANGE (too many params; on iOS also too few: the count must match, while Android binds a missing one as NULL, so roots/data checks the count itself, D113), SQLITE_MISMATCH (a value that is not one of the above), SQLITE_CANTOPEN (a bad name), SQLITE_AUTH (ATTACH and VACUUM INTO: a page stays inside its own database file; iOS refuses them with an authorizer, Android by the statement's first words; plain VACUUM works). Text is bound with its byte length, so a NUL inside a string is stored. Opening a name twice shares the connection. All calls of a plugin run on one worker thread (Android) or serial queue (iOS), in order, so BEGIN … COMMIT through exec stays on one connection (on Android, SAVEPOINT / RELEASE / ROLLBACK TO work on every version, also where android.database.sqlite would take ROLLBACK TO for a full rollback). roots/data (docs/data.md) uses this plugin on mobile. Databases live in the app's private storage (getDatabasePath, Application Support/databases/). Integers beyond ±(2^53 − 1) are returned as decimal strings, never rounded. Limit: Android's cursor window limits a single row to about 2 MB.

storage keeps strings (SharedPreferences roots.storage on Android, UserDefaults keys roots.storage.* on iOS); roots/platform's storage stores JSON text there. Use sqlite for large data.

roots/platform in the shell #

runtime/platform/ios.lisp and android.lisp implement roots/platform over RootsNative (native.lisp): storage → the storage plugin, clipboard → clipboard, openUrl → browser for http(s) (mailto: and tel: navigate, and the shell hands them to Mail or Phone), info.platform → RootsNative.platform, notify → the browser API (no notification plugin yet). Without RootsNative every function falls back to the browser API, so the web build inside the project also runs in a plain browser. The Capacitor shell uses capacitor-ios.lisp / capacitor-android.lisp instead: the build picks the file through the target (target-platform-module), not through ifs.

Writing a plugin #

A plugin is one class with a name, its methods and a handle function. Put it in your app as native/android/<Name>Plugin.kt and native/ios/<Name>Plugin.swift (name haptics → class HapticsPlugin) and list it in :native :plugins. Every .kt/.java file in native/android/ and every .swift file in native/ios/ is compiled into the app (other files there are ignored, B154). A listed plugin without an implementation for the platform being built is B152; a file with the name of a built-in plugin is B153.

// native/android/HapticsPlugin.kt
package dev.roots.plugins

import android.view.HapticFeedbackConstants
import dev.roots.shell.Plugin
import dev.roots.shell.PluginCall
import dev.roots.shell.PluginHost

class HapticsPlugin(private val host: PluginHost) : Plugin {
    override val name = "haptics"
    override val methods = setOf("tap")
    override fun handle(call: PluginCall) = call.settle {
        host.activity.window.decorView.performHapticFeedback(HapticFeedbackConstants.CONFIRM)
        emptyMap<String, Any?>()
    }
}
// native/ios/HapticsPlugin.swift
import UIKit

final class HapticsPlugin: Plugin {
    let name = "haptics"
    let methods: Set<String> = ["tap"]
    init(host: PluginHost) {}
    func handle(_ call: PluginCall) {
        UIImpactFeedbackGenerator(style: .medium).impactOccurred()
        call.resolve()
    }
}
// src/haptics.mobile.ts (src/haptics.ts is the fallback for the other targets)
export async function tap(): Promise<void> {
  const native = (globalThis as any).RootsNative
  if (native) await native.call("haptics", "tap")
}

The contract, the same on both platforms:

  • handle runs on the main thread. Do slow work elsewhere (the sqlite plugin has its own thread/queue) and answer exactly once, from any thread: call.resolve(result), call.reject(code, message), or call.settle { … } (returns the block's value; a PluginError(code, message) rejects with that code, anything else with BRIDGE_INTERNAL). Later answers are ignored.
  • call.args is the JSON object (Kotlin: Map<String, Any?> with Long, Double, String, Boolean, List, Map; Swift: [String: JSONValue]). The helpers call.string("key"), long / int64, boolean, optionalList / optionalArray reject wrong types with the plugin's badArgsCode (default BRIDGE_BAD_ARGS).
  • Results are JSON values (Kotlin: Map, List, String, Long, Double, Boolean, null; Swift: JSONValue, with literals: ["text": .string(s)]).
  • Use your own error-code prefix (HAPTICS_UNAVAILABLE).
  • host.emit(event, data) sends an event to the page (RootsNative.on(event, …)). host.activity (Android) is the activity; on iOS the host is the view controller (cast it to UIViewController to present UI). Keep it weak if the plugin stores it.
  • close() is called when the shell goes away.
  • Gradle dependencies and Android permissions of your plugins go in :native (:android-dependencies (…) :android-permissions (…)).

Security model #

  • The app is local. Android serves assets/www/ at https://appassets.androidplatform.net through WebViewAssetLoader (a secure origin, no file://); file and content access are off, mixed content is refused, cleartext traffic is off for the whole app (usesCleartextTraffic="false" and a network security config). iOS serves the bundle's www/ at roots://localhost through a WKURLSchemeHandler; App Transport Security stays on. Both handlers serve plain relative paths only: no .., empty segments, dotfiles, backslashes or control characters; unknown types are application/octet-stream, with X-Content-Type-Options: nosniff.
  • The page's CSP is unchanged (default-src 'self'; script-src 'self', no inline code), so the app cannot load remote code.
  • Only the app's own main frame reaches native code. Android registers the bridge script and the message channel for the app origin only (addDocumentStartJavaScript, addWebMessageListener), and checks every message's origin and frame again; iOS injects the script into the main frame only and checks frameInfo.isMainFrame and the security origin of every message. No addJavascriptInterface objects exist.
  • Allow-list by construction. Only the plugins in :native :plugins are compiled into the app and registered; any other name is BRIDGE_UNKNOWN_PLUGIN, any method a plugin does not list is BRIDGE_UNKNOWN_METHOD. Messages are parsed by a strict JSON reader with depth and size limits.
  • The web view shows the app only. A navigation to http(s), mailto: or tel: is handed to the system's app (browser, Mail, Phone), and only when the user caused it: Android asks for a user gesture, iOS for a tapped link (mailto: and tel: also from script, which is how openUrl reaches them); script that wants to open a page uses openUrl of roots/platform. Every other scheme (javascript:, file:, intent: …) is dropped; window.open never creates a second web view. The browser plugin opens http(s) only. A crashed renderer restarts the shell instead of the app. (Android's guard sees GET navigations of the main frame; the page's CSP, form-action 'self' included, is what stops the others.)
  • The app's data stays on the device unless roots.lisp says :native (:backup t), and link previews (iOS long-press) are off.
  • Logs name little. The shells log a URL's scheme and host only, and at most the first characters of anything the page chose.
  • Least privilege. The shell's only permission is INTERNET (Android). No storage, camera or location permissions; plugins that need more declare them in roots.lisp.
  • Debugging (Chrome chrome://inspect, Safari Develop) is on in debug builds only.

Signing and release #

Roots never handles signing keys.

  • Android: --native builds the debug APK (signed with the debug key Gradle creates). For Play: in Android Studio, Build → Generate Signed App Bundle, or add a signingConfig to app/build.gradle.kts in a copy you control, or sign app-release-unsigned.apk (gradle assembleRelease) with apksigner. Raise :build for every upload.
  • iOS: choose your team in Xcode (automatic signing), Product → Archive, then distribute. Replace the default icon before submitting (put a 1024×1024 icon.png in the project).

What is verified #

AndroidiOS
project generation✓ files, allow-list, escaping, reproducible (roots test)✓ files; project.pbxproj parsed back: every id defined, every Swift file compiled, resources, settings
native build✓ Gradle 8.14.3 + AGP 8.13.2 + Kotlin 2.2.21, JDK 21, android-36: a debug APK of examples/todo (2.3 MB), checked with aapt2 (package, SDK levels, launcher activity, no cleartext, assets)✗ needs macOS + Xcode: not built here
shell code✓ Kotlin router, JSON, origin/navigation/path policy, SQL splitter, result codes: 28 JUnit tests on the JVM✓ the Foundation-only Swift (router, JSON, policies, sqlite on the real sqlite3 C API, storage, app): 93 checks, compiled with the Swift 6.4 toolchain for Linux; UIKit/WebKit files are checked as text only
sqlite contract✓ the notes example (roots/data) builds to an APK with its native driver and the WASM fallback✓ roots/data's conformance suite (32 tests) through the generated bridge script into the Swift Bridge + SqlitePlugin in another process
bridge JS✓ Chromium, both transports, against a mock native side (47 checks)✓ same
app in the shell✓ the web build in Chromium, with browser fallbacks and over a mock RootsNative (storage goes through the plugin)✓ same web build
on a device or emulator✗ not run (no KVM here)✗ not run

The Android tests need ANDROID_HOME, java and gradle; the Swift tests need swiftc (on PATH or ROOTS_SWIFTC). Without them they print PENDING.

Capacitor #

:native (:shell :capacitor) (or per target) generates the Capacitor 8 project of Roots 0.1 instead: www/, capacitor.config.json, a package.json with pinned versions, and roots/platform over the Capacitor plugins (capacitor-ios.lisp, capacitor-android.lisp). --native runs npm install and npx cap add|sync. See targets.md. RootsNative does not exist there.