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).
| Target | Maturity | Verified automatically | Not verified / known limits |
|---|---|---|---|
web | beta | Chromium (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 save | Firefox and Safari not run (no engines on the build machine; set ROOTS_E2E_BROWSER) |
pwa | beta | Chromium: 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 |
ext | alpha | Chromium MV3: the output loads unpacked, the popup works, data lands in chrome.storage.local, no CSP errors | Firefox not run (the manifest carries browser_specific_settings.gecko); popup only: no background worker, content scripts or options page yet |
android | alpha (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 RootsNative | not run on a device or an emulator; the WebView/Activity code is compiled, not exercised |
ios | alpha (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 Chromium | not built or run in Xcode/WKWebView (needs macOS + Xcode); UIKit/WebKit code checked as text only |
term | beta | under 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 pty | Windows 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 reloaddist/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/ filesWith --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-srcreplaces 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/]orwss://…, optionallyhttps://*.example.com(a wildcard in front of a name of at least two labels, not*.com);http://andws://only forlocalhostand127.0.0.1;data:andblob: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-srcandbase-uricannot 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 devsends 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 needsinnerHTMLdoes 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, oricon.pngbeing a link is the error B163: the build would otherwise publish files from outside the project. The same holds fornative/android/andnative/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 namedid_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.jsonfile 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 noicon.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.jslists 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 asintegrity, 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 logsroots: this version of the app was not installed for offline use: ./app.css could not be stored: …, andupdates.onError((message) => …)hands the app the same text. The next visit tries again.What your host must do: serve
sw.jsandindex.htmlwithCache-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.jsonandstaticwebapp.config.jsonat the top ofpublic/. 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, whichroots/platformcreates 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, likeonAvailable.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 backsroots/platformstorage throughchrome.storage.local; it shows no install warning).openUrluseschrome.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,nativeMessagingand 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_pagesis the strict base policy above (plus the sources of:csp, without the Trusted Types directives); there is noeval/new Functionin 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.idis 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.xcassetsThe 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 (
RootsNativeexists in the main frame of the app origin only; on Android the shell also answers a frame's message withBRIDGE_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 instorageandapp,sqlitewhen the app uses roots/data,clipboardwhen a module importsclipboardfromroots/platform, andbrowserwhen one importsopenUrl(all of them when a module takesroots/platformas a whole:import * as,import()), and prints the list after the build. An app that callsRootsNative.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) andLibrary/WebKit(the web view's storage) as excluded from iCloud and computer backups.:native (:backup t)turns backups on. Two limits: on iOS thestorageplugin keeps its values in UserDefaults, which iOS always backs up; and neitherstoragenorsqliteis 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:ortel: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:andtel:also from script, which is howopenUrlreaches them). Script that setslocation.hrefor callswindow.openon its own does not open another app; useopenUrlfromroots/platformin 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), androots/platformusesRootsNativeonly when itsversionis"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.INTERNETis always requested, although the default policy lets the page load nothing from the network: the WebView needs it as soon as:cspnames 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.jsonpins the direct dependencies to exact versions; what they depend on floats, and there is no lockfile until you commit one.--nativerunsnpm 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 checkcd 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 21After 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) #
| Feature | web | pwa | ext | ios / android | term |
|---|---|---|---|---|---|
| works offline | no | yes | yes (packaged) | yes (packaged) | yes |
| update prompt | — | updates.onAvailable, updates.onError | store / reload | store | reinstall |
| persistent storage | best effort (persist()) | best effort (persist()) | yes | yes | yes |
| notifications | after permission | after permission (iOS: installed only) | after permission | Capacitor plugin; browser API in the Roots shell | notify-send / osascript |
| clipboard read | needs permission and focus | same | same | clipboard plugin | needs a clipboard tool |
| hover / mouse | yes | yes | yes | touch | mouse where the terminal reports it |
| images | yes | yes | yes | yes | alt text |
data: / blob: images | yes | yes | yes | yes | alt text |
| light / dark | the system's, or theme.set | same | same | same | the terminal's (COLORFGBG), or theme.set |
screen size | viewport, CSS pixels | same | the popup | same | columns × rows |