Skip to the contentSkip to the navigation
RootsGitHubMenu
On this page

Targets

One app source, six targets, chosen with roots build --target <name>. Platform differences live in the target classes of the build (src/build/targets/*.lisp), the platform adapters (runtime/platform/<target>.lisp) and your per-target files (x.term.ts, x.mobile.ts …), never in ifs in components. See build.md for the pipeline, roots.lisp and the dev server.

Maturity (Roots 0.1) #

Honest status, per target. "Verified" means an automated end-to-end test in src/build/tests/ (e2e.lisp, native.lisp, binary.lisp) runs it; "structural" means the files are generated and checked, but the app has not been run on the real platform. Nothing is stable yet: stable will require CI on that platform (XP-9).

TargetMaturityVerified automaticallyNot verified / known limits
webbetaChromium (Playwright): both examples and a new project load with zero console errors under the strict CSP; add, toggle, filter, remove, persistence across reload, router; roots dev serves the same flow and reloads on saveFirefox and Safari not run (no engines on the build machine; set ROOTS_E2E_BROWSER)
pwabetaChromium: the service worker registers and controls the page, an offline reload works with the stored data, the update flow (new version waits → updates.onAvailable → apply() → reload on the new version)install prompts and iOS Safari (no push, storage may be evicted unless installed) not run
extalphaChromium MV3: the output loads unpacked, the popup works, data lands in chrome.storage.local, no CSP errorsFirefox not run (the manifest carries browser_specific_settings.gecko); popup only: no background worker, content scripts or options page yet
androidalpha (built)Roots shell: --native builds a debug APK of examples/todo with Gradle (aapt2: package, SDK levels, launcher, no cleartext, assets); the shell's JUnit tests (router, JSON, policies); the bridge script in Chromium; the web build in Chromium with and without RootsNativenot run on a device or an emulator; the WebView/Activity code is compiled, not exercised
iosalpha (structural)Roots shell: the Xcode project (project.pbxproj parsed back and checked); the Foundation-only Swift (router, JSON, sqlite, storage) compiled and tested with swiftc on Linux; the bridge and the web build in Chromiumnot built or run in Xcode/WKWebView (needs macOS + Xcode); UIKit/WebKit code checked as text only
termbetaunder a pseudo-terminal: the frame, typing, Tab/Enter, persistence to the JSON file; roots run and roots dev --target term (restart on change); --binary (Bun) under a ptyWindows terminals not run; <style> blocks are ignored; images show their alt text

The Capacitor shell (:native (:shell :capacitor)) stays alpha (structural): its project files are checked and its www/ runs the todo flow in Chromium; it has not been built.

roots doctor prints which toolchains the current machine has, per target and shell. On the machine these docs were tested on (Linux, Node 22, Java 21, Gradle 8.14.3, Android SDK with android-36, no macOS): web, pwa, ext and term build and run; android builds its APK; ios generates its Xcode project, and --native stops with B150 and says what is missing.

web #

roots build --target web          # → dist/web/
roots dev                         # http://localhost:5173 with live reload
dist/web/
  index.html        strict CSP, no inline scripts or styles, integrity hashes (app.css, app.js, chunks/)
  app.js            the bundle: the runtime modules this app imports, your modules and the
                    bootstrap (createApp(App).use(…).mount(backend, "#app")), tree-shaken, minified
  app.css           theme + base styles + used utilities + component styles
  theme.js          only when the app imports `theme` from roots/platform: a few lines, loaded
                    first, that show the remembered light/dark choice before the first paint
  chunks/…          only with dynamic import(): the modules shared between the page and a target
  runtime/data/…    only with roots/data: the worker bundle, sqlite-wasm/ (data.md)
  …                 public/ files

With --no-bundle (or :bundle nil), the layout roots dev serves: app.js is the bootstrap alone, src/… your modules compiled to ES modules, runtime/… the runtime modules this app imports (core, dom, router, platform/web), npm/<name>@<version>/… the packages, every import rewritten to a relative .js path (build.md).

The page's Content-Security-Policy (a <meta> here, also sent as a header by the dev server):

default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data: blob:;
object-src 'none'; base-uri 'none'; form-action 'self';
require-trusted-types-for 'script'; trusted-types 'none'

Images may come from the app's own files and from data: and blob: URLs, so a chart drawn on a canvas, a picked or pasted file (URL.createObjectURL(file)), a camera frame and an inline SVG cover work without configuration:

<Image :src="URL.createObjectURL(file)" alt="Your photo" />
<Image src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' …%3E" alt="Cover" />

This opens nothing: such an image is made of bytes the page already holds, so nothing is fetched and nothing leaves the device; an image runs no script, an SVG one included; and data: and blob: stay refused for scripts, styles, frames, fonts and media. An image from another origin is still blocked until roots.lisp lists that origin (next section).

so the same files work on CSP-strict hosting and inside an extension. The output is plain static files: any static server works (no rewrites needed with the default hash router).

The policy, and what roots.lisp may add to it #

The build writes the policy; nobody should have to edit index.html. Two things are yours to say, under :csp in roots.lisp:

:csp (:connect-src ("https://api.example.com" "wss://live.example.com")   ; fetch, XHR, WebSocket, EventSource
      :img-src     ("https://cdn.example.com" "data:")                     ; also :font-src, :media-src
      :frame-src   ("https://player.example.com")
      :trusted-types t)                                                    ; the default
  • Sources. Remote images and APIs are blocked until you list their origin. Listing :img-src replaces the default image sources with 'self' and your list: add "data:" and "blob:" to it if the app still needs them, as in the example. A source is an origin, https://host[:port][/path/] or wss://…, optionally https://*.example.com (a wildcard in front of a name of at least two labels, not *.com); http:// and ws:// only for localhost and 127.0.0.1; data: and blob: only for images, fonts and media. Anything else is the error B105: 'unsafe-inline', 'unsafe-eval', a bare *, a whole scheme (https:), or text that could end the directive. script-src, style-src, object-src and base-uri cannot be extended: all script and style of a Roots app comes from its own files. The extension's manifest policy and the page in a native shell get the same sources. (roots dev sends its own header as well, and a browser applies both policies.)
  • Trusted Types. require-trusted-types-for 'script' makes the browser itself refuse a string given to an HTML or script sink (innerHTML, srcdoc, eval, a script URL): the property the template compiler and the runtime already keep ("no HTML insertion", templates.md) is then enforced for every script in the page, in the browsers that implement it (Chromium, and others as they ship it). trusted-types 'none' means no policy can be created to get around it. A library that needs innerHTML does not run under this; :csp (:trusted-types nil) leaves the two directives out. They are written for web, pwa and the Roots native shells. A build that ships roots/data's SQLite worker allows one policy, trusted-types roots-data: starting a Worker from a URL is itself a script sink, and that policy passes the worker's own URL and nothing else. They are not written (yet) for the extension popup and the Capacitor shell, which were not run under them.

What a <meta> policy cannot do, and a static host should add as response headers:

Content-Security-Policy: frame-ancestors 'none'     # or 'self': who may show the app in a frame (clickjacking)
X-Content-Type-Options: nosniff
Cache-Control: no-cache                             # for index.html (and sw.js, see pwa)

frame-ancestors is ignored in a <meta> element by every browser, so a static build has no protection of its own against being framed. The dev server sends these headers.

Integrity: app.css, the bundle and the chunks it imports statically are pinned with integrity hashes in index.html. Chunks reached only through import() are not (there is no place in the page to pin them), and a browser without modulepreload loads static imports unpinned. Everything is same-origin, so this is about a host or CDN that serves a changed file, not about script injection; the PWA's service worker pins every file (below).

public/ #

public/ is copied as it is, with three exceptions that are about what must not be published:

  • Symbolic links are never followed. A link inside public/ is skipped with the warning B163. public/ itself, a parent directory named in :public, or icon.png being a link is the error B163: the build would otherwise publish files from outside the project. The same holds for native/android/ and native/ios/.
  • Dotfiles are never copied.
  • Keys and secrets are skipped with the warning B164: files ending in .pem .key .p12 .pfx .crt .der .jks .keystore .p8 .mobileprovision .ppk .kdbx .env, files named id_rsa, id_dsa, id_ecdsa, id_ed25519, and any file whose first lines are a private key (PEM -----BEGIN … PRIVATE KEY-----, an OpenPGP private key block, a PuTTY key), whatever it is called. This is a net for mistakes, not a secret scanner: an API token in a .json file is published like any other file.

pwa #

roots build --target pwa          # → dist/pwa/ (serve over HTTPS, or http://localhost)

web, plus:

  • manifest.webmanifest: name, short name, description, start_url/scope ./, display (:display, default standalone), theme_color (:theme-color, default the theme's primary), background_color, icons.

  • icons/icon-192.png, icons/icon-512.png: drawn from the theme colour and the title's first letter when there is no icon.png (a tiny PNG encoder in the toolchain).

  • sw.js: precaches every built file (the bundle, app.css, the manifest, the icons, chunks and the roots/data files when there are any) in a cache named after the app's scope, its name and a hash of the files' contents; serves same-origin GETs from the cache (the app works offline and is always one consistent version), tries the network for unknown navigations and falls back to the app shell; deletes the app's older caches on activation (only its own: two Roots apps under different paths of one origin do not touch each other).

  • The cache is the build, checked. sw.js lists every file with the SHA-256 the build computed. On install the worker fetches each file past the browser's HTTP cache (cache: "no-cache") with that hash as integrity, so a stale copy, a truncated download or a file changed on the way is refused by the browser. One file that is missing or different fails the whole install: nothing of that version is kept (a cache the running version already serves from is never deleted), the version that runs keeps running, the page logs roots: this version of the app was not installed for offline use: ./app.css could not be stored: …, and updates.onError((message) => …) hands the app the same text. The next visit tries again.

  • What your host must do: serve sw.js and index.html with Cache-Control: no-cache (so a new release is seen at all), and serve the files unchanged: a host or CDN feature that rewrites HTML, CSS or JavaScript (minification, script injection, e-mail obfuscation) makes the install fail, by design. The other files may be cached for as long as you like.

  • Files the host keeps to itself are published but not precached, because a precache entry that answers 404 stops the install: _headers, _redirects, _routes.json, _worker.js, CNAME, netlify.toml, vercel.json, now.json, firebase.json and staticwebapp.config.json at the top of public/. Name others (or files too large to store up front) yourself: :pwa (:precache-exclude ("downloads/" "big-video.mp4")); a directory ends in /. Excluded files still load from the network when the app asks.

  • Under the page's Trusted Types policy the worker's URL is a script URL, so the PWA page allows one policy, trusted-types roots-sw, which roots/platform creates and which passes exactly ./sw.js.

  • Update flow (XP-8). A new version installs in the background and waits; it never takes over a running app by itself. The app decides when, with a banner in its shell (the examples do this in App.vue / App.lisp: "A new version is ready" and a Reload button):

    <script setup lang="ts">
    import { ref } from "roots/runtime"
    import { updates } from "roots/platform"
    const update = ref<{ apply(): void } | null>(null)
    updates.onAvailable((handle) => { update.value = handle })   // PWA only; never fires elsewhere
    </script>
    <template>
      <Box v-if="update" class="flex-row items-center gap-2">
        <Text class="flex-1">A new version is ready</Text>
        <Button @click="update.apply()">Reload</Button>
      </Box>
    </template>

    updates.onError((message) => …) reports a version that could not be installed (see above); it returns a function that removes the listener, like onAvailable.

    storage.persist() asks the browser to keep the app's data (call it after a user action). On iOS, web push and durable storage need the app to be installed to the home screen.

ext #

roots build --target ext          # → dist/ext/

A Manifest V3 extension whose action popup is the app:

dist/ext/
  manifest.json     MV3: action.default_popup = popup.html, icons, permissions, CSP
  popup.html        the app page (same strict CSP)
  app.js app.css icons/        the bundle, the stylesheet, the icons (src/ runtime/ with --no-bundle)
  • Minimal permissions: only :permissions (default ["storage"], which backs roots/platform storage through chrome.storage.local; it shows no install warning). openUrl uses chrome.tabs.create, which needs no permission. The popup is a web page: script that gets into it has every permission of the manifest. The build therefore warns (B158) about each permission that reaches beyond the popup, tabs, scripting, webRequest, cookies, history, nativeMessaging and the like, and about host patterns (<all_urls>, https://*.example.com/*), and writes the manifest as you asked.
  • CSP-clean (XP-6): content_security_policy.extension_pages is the strict base policy above (plus the sources of :csp, without the Trusted Types directives); there is no eval/new Function in the runtime or the compiled code (the e2e test scans every shipped file).
  • No background service worker and no remote code.
  • Firefox: browser_specific_settings.gecko.id is derived from :id (com.example.todo → todo@example.com) or set with :gecko-id; set :browsers (:chrome) to leave it out.
  • Popup size: :popup-width / :popup-height (CSS pixels).

Load it: Chrome → chrome://extensions → Developer mode → Load unpacked → dist/ext. Firefox → about:debugging → This Firefox → Load Temporary Add-on → dist/ext/manifest.json.

ios / android #

roots build --target android          # → dist/android/: a Gradle project (Kotlin, WebView)
roots build --target android --native # + Gradle assembleDebug → app/build/outputs/apk/debug/app-debug.apk
roots build --target ios              # → dist/ios/: an Xcode project (Swift, WKWebView)
roots build --target ios --native     # + xcodebuild for the simulator (macOS with Xcode 15+)

By default both targets build Roots' own native shells: a thin native project around the web build, which talks to native code through RootsNative and allow-listed plugins (sqlite, storage, clipboard, browser, app, and your own; by default the ones the app uses, see below). Everything about them, the bridge ABI, writing plugins, the security model and signing: native.md.

dist/android/                                    dist/ios/
  app/src/main/assets/www/   the web build         App/www/                 the web build
    index.html app.js app.css …                      index.html app.js app.css …
  app/src/main/java/…        shell + plugins       App/*.swift App/Plugins/ shell + plugins
  app/build.gradle.kts AndroidManifest.xml …       App.xcodeproj Info.plist Assets.xcassets

The web build inside the shell is the same bundled output as dist/web/ (its app.js holds the platform adapter of the shell: runtime/platform/android.lisp / ios.lisp, or the Capacitor ones); the shells serve any path by extension, so --no-bundle works there too.

roots/platform there uses the native plugins (runtime/platform/android.lisp, ios.lisp) and falls back to the browser APIs when there is no RootsNative.

Requirements: Android needs JDK 17+, Gradle 8.13+ (or Android Studio) and the Android SDK (ANDROID_HOME, licenses accepted); iOS needs macOS with Xcode 15 or newer.

What the shells protect, and what they do not #

The full model is in native.md; these are the parts a build decides.

  • Trust boundary. All script in the app's own page is trusted with every allow-listed plugin. The bridge keeps other origins and frames away from native code (RootsNative exists in the main frame of the app origin only; on Android the shell also answers a frame's message with BRIDGE_UNAVAILABLE), not script that runs in the app. What keeps foreign script out of the app is the page's policy above and the template compiler.
  • The plugin allow-list is what the app uses. Without :native (:plugins (…)) the build compiles in storage and app, sqlite when the app uses roots/data, clipboard when a module imports clipboard from roots/platform, and browser when one imports openUrl (all of them when a module takes roots/platform as a whole: import * as, import()), and prints the list after the build. An app that calls RootsNative.call("clipboard", …) directly, or has plugins of its own, lists its plugins. A plugin name is lowercase letters and digits in parts separated by single dashes; two names that would be the same class are the error B105.
  • Backups. The app's data stays on the device: Android gets android:allowBackup="false" and data-extraction rules that exclude everything from cloud backup and from the copy to a new device; iOS marks Application Support (the sqlite plugin's databases) and Library/WebKit (the web view's storage) as excluded from iCloud and computer backups. :native (:backup t) turns backups on. Two limits: on iOS the storage plugin keeps its values in UserDefaults, which iOS always backs up; and neither storage nor sqlite is a secret store (there is no Keychain/Keystore plugin yet): do not keep long-lived tokens or keys in them.
  • Leaving the app. A navigation to http(s), mailto: or tel: never loads in the web view. It is handed to the browser, mail or phone app only when the user asked for it: on Android when the navigation has a user gesture, on iOS when it is a link the user tapped (mailto: and tel: also from script, which is how openUrl reaches them). Script that sets location.href or calls window.open on its own does not open another app; use openUrl from roots/platform in an event handler. Long-press link previews are off on iOS.
  • The bridge version. The injected script says which bridge ABI it speaks; a shell that hears another version refuses that page's calls (BRIDGE_UNAVAILABLE), and roots/platform uses RootsNative only when its version is "1" (otherwise it behaves as in a browser).
  • Logs. The shells log schemes and hosts, never whole URLs, and at most the first characters of anything the page chose.
  • android.permission.INTERNET is always requested, although the default policy lets the page load nothing from the network: the WebView needs it as soon as :csp names an API or an image host, and a WebView without it has not been tested with the asset loader.
  • Not verified on a device: the Android MainActivity/WebView layer and the iOS UIKit/WebKit layer are compiled or read, not exercised (see the maturity table); the gesture and backup rules above are in that part.

Where the native build's code comes from #

--native downloads code that ends up in your app, and Roots' own package manager rules (cooldown, no install scripts, lockfile: packages.md) do not apply to it:

  • Gradle resolves the Android Gradle Plugin, Kotlin, androidx and your :android-dependencies (and what they depend on) from Google's and Maven Central's repositories by version, without checksums. To pin them, let Gradle write its verification metadata once and keep it in the project; the build copies it into every generated project:

    roots build --target android
    cd dist/android && gradle --write-verification-metadata sha256 assembleDebug testDebugUnitTest
    mkdir -p ../../native/android/gradle && cp gradle/verification-metadata.xml ../../native/android/gradle/

    From then on Gradle refuses an artifact whose SHA-256 is not in native/android/gradle/verification-metadata.xml. Write it again when Roots or your dependencies change versions, and review the difference.

  • Capacitor: package.json pins the direct dependencies to exact versions; what they depend on floats, and there is no lockfile until you commit one. --native runs npm install --ignore-scripts, so no package runs code at install time; use the same flag by hand.

  • Xcode: the iOS shell has no third-party dependencies (system frameworks and the system's SQLite).

Capacitor #

:native (:shell :capacitor)                  ; both targets, or per target: :ios (:shell :capacitor)

builds a Capacitor 8 project instead:

dist/ios/
  www/                    the web build, with roots/platform's Capacitor adapter
  capacitor.config.json   { appId (from :app-id or :id), appName, webDir: "www" }
  package.json            pinned: @capacitor/core, cli, ios|android 8.6.0,
                          @capacitor/preferences 8.0.1, clipboard 8.0.1, browser 8.0.5
  README.md               the next commands and this machine's toolchain check
cd dist/ios && npm install --ignore-scripts && npx cap add ios && npx cap open ios          # macOS + Xcode 16+
cd dist/android && npm install --ignore-scripts && npx cap add android && npx cap open android   # Android Studio, JDK 21

After each rebuild, npx cap sync <platform> (or --native, which runs npm install --ignore-scripts and npx cap add|sync). Rebuilding never deletes the native ios//android/ folders or node_modules: the build only removes files it wrote itself.

roots/platform there (capacitor-ios.lisp, capacitor-android.lisp) uses the Capacitor plugins when they are installed (they are in package.json) through the window.Capacitor bridge, and falls back to the browser APIs. @capacitor/local-notifications is not added by default (it adds a notification permission); add it to the target's :plugins and notify uses it. Capacitor 8 needs Node.js 22+; iOS builds need Xcode 16 (Swift Package Manager, no CocoaPods).

term #

roots run                         # build and run (dist/term/app.mjs)
roots dev --target term           # rebuild and restart on every change
node dist/term/app.mjs            # the output runs on its own (Node 22+)
roots build --target term --binary   # + dist/term/<name>: one executable file (Bun adapter)
dist/term/
  app.mjs           #!/usr/bin/env node — the bundle: runtime (including runtime/term, never
                    runtime/dom), your modules and createApp(App).use(…).mount(backend); the
                    node: / bun: imports stay imports, one per module at the top
  package.json      { "type": "module", "bin": { "<name>": "./app.mjs" } }

With --no-bundle (the layout of roots dev --target term and roots run): app.mjs is the bootstrap, src/… and runtime/… the modules.

The terminal backend draws the same components with box-drawing characters; utilities map to cells (one spacing step = one cell or row). term: classes apply only here, and vertical spacing usually needs one: gap-3 is three blank rows in a terminal, so write p-4 gap-3 term:p-1 term:gap-0 (terminal.md). <style> blocks are ignored with a warning, and the classes they define are dropped without one, so a template shared with the web can use them. A page taller than the terminal scrolls (Page Up/Down, the wheel, the focus), screen.columns / screen.rows of roots/platform are the terminal's size, and theme.set("dark" | "light" | "system") switches the colors (terminal.md). Storage is a JSON file in $XDG_CONFIG_HOME/<name>/ (default ~/.config/<name>/).

--binary runs bun build --compile on app.mjs: dist/term/<name> runs without Node (about 100 MB: it carries the Bun runtime). Without Bun the build warns (B156) and the Node app is all there is.

Capability differences (XP-4) #

Featurewebpwaextios / androidterm
works offlinenoyesyes (packaged)yes (packaged)yes
update prompt—updates.onAvailable, updates.onErrorstore / reloadstorereinstall
persistent storagebest effort (persist())best effort (persist())yesyesyes
notificationsafter permissionafter permission (iOS: installed only)after permissionCapacitor plugin; browser API in the Roots shellnotify-send / osascript
clipboard readneeds permission and focussamesameclipboard pluginneeds a clipboard tool
hover / mouseyesyesyestouchmouse where the terminal reports it
imagesyesyesyesyesalt text
data: / blob: imagesyesyesyesyesalt text
light / darkthe system's, or theme.setsamesamesamethe terminal's (COLORFGBG), or theme.set
screen sizeviewport, CSS pixelssamethe popupsamecolumns × rows