DEV Community

Cover image for Running untrusted single-file HTML as a phone app: sandbox, exact alarms, and a 5-method bridge
Vladimir Fedorov
Vladimir Fedorov

Posted on

Running untrusted single-file HTML as a phone app: sandbox, exact alarms, and a 5-method bridge

In July I asked Claude for a workday timer — 45 minutes seated, two minutes at the pull-up bar, 45 standing, lunch, repeat. One HTML file, zero dependencies, worked perfectly in a desktop browser. By lunch on a phone it had failed at its one job: the phone never rang. Screen off → tab frozen → setTimeout dead. Web Notifications on mobile are a shade entry at best, and only while the tab lives. Push needs a server. The Notification Triggers API — the one spec that was exactly this — died in an origin trial.

So I built Kapsula: a small Android/iOS app that runs one HTML file (pasted code, a file, or a URL) as a project with real OS alarms. This post is about the three technical decisions that shaped it — the sandbox, the bridge, and the alarm plumbing — plus the bugs I hit on real devices.

Demo: paste code → Run → 3 min → lock-screen notification

Constraint zero: no server, ever

I wanted this project to survive on evenings and weekends, which meant: no backend, no accounts, no sync, no analytics. Everything below follows from that constraint. It rules out push entirely — all notifications must be local, scheduled into the OS. It also means the untrusted code a user pastes from an AI chat must be contained client-side.

The sandbox: an iframe without allow-same-origin

User code runs in:

<iframe sandbox="allow-scripts allow-forms allow-modals allow-popups">
Enter fullscreen mode Exit fullscreen mode

The deliberate omission is allow-same-origin. The document gets an opaque origin, which buys a lot for free:

  • no access to the shell's localStorage, cookies, IndexedDB — or anything else; its origin is unique and empty;
  • parent.document is unreachable;
  • network requests out of the sandbox are blocked (deliberate for now; kapsula.fetch with per-project permission is planned).

One consequence hurts: localStorage inside the sandbox doesn't survive a restart, because the opaque origin is new every time. So persistent state goes through the bridge only, keyed by project. The other consequence is the whole point: code I've never read physically cannot reach beyond its frame except through the five methods I expose.

The bridge: postMessage RPC with a 15-second timeout

The shell injects kapsula-client.js into the <head>. It's a plain RPC over postMessage: every call gets an id, replies come back as {kapsula: true, type: 'reply', id, ok, result}, and calls await a hello handshake before flying. The whole API:

kapsula.app.info()                          // {name, projectId, platform, version, lang, limits}
kapsula.notify.schedule({at, title, body})  // {id} — exact OS alarm
kapsula.notify.cancel(id?)                  // no id = cancel all yours
kapsula.notify.list()
kapsula.sound.play('beep' | 'triple' | 'alarm')
kapsula.haptics.vibrate(ms?)
kapsula.storage.get(key) / set(key, value) / remove(key)
Enter fullscreen mode Exit fullscreen mode

Why so small: each method is something a browser tab cannot do. Everything a tab can do (DOM, timers, canvas, Web Audio) is not duplicated. A small API has a second advantage: it fits in a prompt, and the code is written by LLMs — more on that below.

The integration contract for a mini-app is one line:

const K = window.kapsula || null;   // null in a plain browser
if (K) await K.notify.schedule({ at: endAt, title: 'Tea is ready' });
Enter fullscreen mode Exit fullscreen mode

The same file works unchanged as a web page on desktop and as an app on the phone.

Android alarms: exact, and three real-device bugs

Alarms are local notifications with allowWhileIdle plus USE_EXACT_ALARM (auto-granted to alarm apps on Android 13+). On a real Samsung (Note20 Ultra, Android 13) delivery is second-exact — 13 ms between scheduled and shown in the logs — including when the app process is dead; the system revives it.

The bugs were more interesting than the happy path:

  1. The silent repeat alarm. The plugin sets FLAG_ONLY_ALERT_ONCE, and a project's notification id is a fixed slot number. If the previous notification is still sitting in the shade, the next one with the same id counts as an update — and updates don't ring. The vibration I did feel turned out to be Samsung's NotificationReminder, not my notification. Fix: removeDeliveredNotifications with the same id right before scheduling.

  2. Android remembers deleted channels. I changed a channel's sound, deleted the channel, recreated it with the same id — and got the old sound. Channel settings survive deletion. So channel ids carry a version suffix (kapsula.<project>.v2) that bumps when the sound changes; old channels are left alone because scheduled notifications may still point at them.

  3. Battery optimization. The classic OEM problem: an "optimized" app's alarms can drift or drop. Kapsula shows an amber hint card linking to the system screen. Samsung detail: the app is only visible in that list after switching the filter to "All" — that sentence had to go into the hint text.

iOS: the sound lives on the notification

No channels on iOS — the sound is attached per notification (sound: 'kapsula_alarm.wav', file in the bundle), trigger is calendar-based. Honest note: the simulator doesn't play notification sounds, so the first audible test happened on TestFlight on a real iPhone.

Non-code surprise: answering "yes" to unrestricted web access in App Store Connect (URL projects can load anything) sets the age rating to 16+ automatically. Google Play's IARC does the same and lands on 18+. Fine for this audience, but know it going in.

The state rule that AI-generated code always violates

The most common bug in LLM-written mini-apps: state in JS variables and relative time (setTimeout(fn, 3*60*1000)). The host may recreate the sandbox at any moment — for example when the user taps the notification and the project reopens. Everything in memory is gone, and a "3 minute" timer restarts from zero.

The rule is three lines:

  1. State lives in kapsula.storage only — and the UI renders from it.
  2. Time is absolute: store endAt, never "seconds left".
  3. cancel before every schedule.

The tea-timer example is 60 lines and shows all three: https://kapsula.app/gallery

Teaching the model: a prompt instead of an SDK

Since the code is written by ChatGPT and Claude rather than humans, the most important artifact isn't the app — it's the public API reference and a prompt. https://kapsula.app/prompt tells the model the sandbox rules (no localStorage, no external fetch, inline everything) and the bridge methods; the output works in a desktop browser and in the app. The bridge client is open source (kapsula-client on npm / github.com/Fedorov191/kapsula-client) — not because anyone npm-installs it (the shell injects it), but so the types and docs have a stable address that crawlers and models can cite.

What's deliberately missing

No network out of the sandbox (yet), no background scripts without UI (Android WorkManager's ≥15-minute granularity and iOS's "whenever the system feels like it" can't honestly be presented as parity), no custom sounds (see channel gotcha above), no cloud. Next up: a deep link + share-sheet entry so chat-to-phone takes ten seconds, a dev panel surfacing JS errors from the sandbox, and kapsula.fetch with per-project permission.

Kapsula is free (one own project + demo), on Google Play and the App Store — links at https://kapsula.app. I'm the author; questions about the sandbox or the alarm plumbing welcome. Reports from Xiaomi/Huawei devices especially — Samsung is tested live, the rest of the OEM zoo is only as good as your bug reports.

Top comments (0)