Viewing exported HTML on mobile (the WebGPU secure-context rule)#
This is the single most important and most confusing fact about opening an
exported interactive widget (Show4DSTEM, Show3D, Show3DSlices, Show2D,
ShowEDS) on a phone or tablet.
The rule#
Interactive widgets recompute every frame in the browser with WebGPU
(probe -> diffraction pattern, detector -> virtual image, contrast, FFT, etc.).
Browsers expose navigator.gpu only in a secure context: HTTPS, or
localhost/127.0.0.1.
Therefore an exported HTML opened on a phone must be served over HTTPS.
Serving it over plain http://<LAN-or-Tailscale-IP>:<port>/... makes
navigator.gpu undefined, and the widget cannot recompute. It will render the
first frame and the controls, but taps and drags do nothing because the GPU
compute path is missing.
Why this fools everyone:
On a desktop it “just works” because the dev URL is
http://127.0.0.1:<port>and localhost is a secure context, so WebGPU is exposed over plain HTTP there.WebGL is NOT gated by secure context, so a quick GPU test still shows the GPU name and renders. The hardware is obviously present; only the WebGPU API is withheld. People conclude “the phone has no GPU” or “the widget is broken” when the real cause is the insecure origin.
The fix: serve over HTTPS (pick one)#
An exported widget is a single self-contained .html file, so any of these
gives it the secure context WebGPU needs. Ordered by audience.
Option 1: a static HTTPS host (simplest; best for sharing a finished file)#
No server, no tunnel. Upload the .html to anything that serves HTTPS and open
the link on the phone:
Netlify Drop (
app.netlify.com/drop): drag the file in, get an instanthttps://...netlify.app/...URL.GitHub Pages: commit the
.htmlto a Pages-enabled repo ->https://<user>.github.io/<repo>/<file>.html.Cloudflare Pages, Vercel, S3 + CloudFront, etc.
A large Show4DSTEM export can be tens of MB; that is fine for these hosts. This
is the right choice for most people who just want to send someone a working link.
Option 2: Tailscale (best for private/local, nothing public)#
One-time setup:
Install Tailscale on the serving machine (
curl -fsSL https://tailscale.com/install.sh | shthentailscale up) and the Tailscale app on the phone (App Store / Play Store), both signed into the same account (same tailnet).In the admin console (
login.tailscale.com): enable MagicDNS and HTTPS Certificates (one toggle each).
Serve the local port over HTTPS:
tailscale serve --bg --https=443 http://127.0.0.1:<port>
# -> https://<machine>.<tailnet>.ts.net/... (real Let's Encrypt cert)
# stop with: tailscale serve --https=443 off
tailscale cert <machine>.<tailnet>.ts.net succeeding confirms HTTPS is enabled.
Open the https:// URL on the phone with Tailscale ON. It is reachable only
on your tailnet; nothing is exposed to the public internet.
Verified worked example (this lab’s Linux compute box, Tailscale 1.98.4, file
server on :8780). Find your own machine name with tailscale status (the
DNSName field):
$ tailscale serve --bg --https=443 http://127.0.0.1:8780
Available within your tailnet:
https://owner-ms-7e34.tail9632de.ts.net/
|-- proxy http://127.0.0.1:8780
Serve started and running in the background.
$ tailscale serve status
https://owner-ms-7e34.tail9632de.ts.net (tailnet only)
|-- / proxy http://127.0.0.1:8780
The widget then opens on the phone at
https://owner-ms-7e34.tail9632de.ts.net/agent-show/<file>.html (HTTP 200, valid
cert, secure context, so navigator.gpu is exposed). Substitute your own
DNSName and the local port your server listens on.
Option 3: a quick public tunnel (one-off checks)#
cloudflared tunnel --url http://localhost:<port> # prints a https://...trycloudflare.com URL
# or
ngrok http <port> # prints a https URL (free account)
Open the printed https:// URL on the phone. Anyone with the URL can reach it
while the tunnel runs, so use it for quick tests, not sensitive data.
Option 4: localhost (same machine only)#
On the serving machine itself, http://localhost:<port> is already a secure
context, so WebGPU works there. This does not help a phone (a phone is not
localhost).
What does NOT work#
Plain
http://<LAN-IP>orhttp://<tailscale-IP>opened on a phone: insecure origin,navigator.gpuwithheld.Self-signed HTTPS on iOS Safari: the certificate warning prevents a trusted secure context, so WebGPU stays hidden. Use a real cert (Options 1-3).
Browser notes (iOS)#
iOS/iPadOS 18 Safari can do WebGPU, sometimes behind a flag: Settings -> Apps -> Safari -> Advanced -> Feature Flags -> WebGPU = ON.
Brave / Chrome / Firefox on iOS are forced to use WebKit and (Brave especially, via shields) do not expose WebGPU. Use the real Safari app.
Low Power Mode and Lockdown Mode disable WebGPU. Turn both off.
The Safari flag does nothing over plain HTTP: secure context is required first.
Debugging: a self-contained diagnostic page#
Serve this over the SAME origin as the widget and open it on the phone. It separates the four independent failure points.
<!doctype html><meta name=viewport content="width=device-width,initial-scale=1">
<pre id=o style="font:14px monospace;padding:12px">checking...</pre>
<script>(async()=>{
const L=[];
L.push("secureContext: "+window.isSecureContext); // false => served over HTTP, FIX THIS FIRST
let gl=document.createElement("canvas").getContext("webgl2")||document.createElement("canvas").getContext("webgl");
if(gl){const d=gl.getExtension("WEBGL_debug_renderer_info");
L.push("WebGL renderer: "+(d?gl.getParameter(d.UNMASKED_RENDERER_WEBGL):gl.getParameter(gl.RENDERER)));} // names the GPU => hardware present
else L.push("WebGL: none");
if(!navigator.gpu) L.push("navigator.gpu: MISSING"); // missing in secure context => Safari flag off / Brave
else { try{const a=await navigator.gpu.requestAdapter();
L.push("navigator.gpu: present, adapter="+(a?"OK":"null"));} // null => Low Power/Lockdown/blocked
catch(e){L.push("requestAdapter error: "+e);} }
L.push("UA: "+navigator.userAgent); // shows Safari vs Brave
document.getElementById("o").textContent=L.join("\n");
})();</script>
Decision table#
Symptom |
Cause |
Fix |
|---|---|---|
|
Served over plain HTTP to a non-localhost host |
Serve over HTTPS ( |
|
Safari WebGPU flag off, or browser is Brave/Chrome on iOS |
Enable the Safari flag; open in the real Safari app |
|
Low Power Mode / Lockdown Mode / GPU blocked |
Turn both off |
WebGL renderer names a GPU but WebGPU still missing |
Normal: WebGL is not secure-context gated; only proves hardware |
Not the issue; chase the rows above |
Renders first frame, histogram shows, taps do nothing |
Almost always one of the above (no |
Run this page; fix the failing line |
Touch events (separate from WebGPU)#
Even with WebGPU working, touch interaction needs the interactive canvases to use
Pointer Events (onPointerDown/Move/Up + setPointerCapture), not
mouse-only handlers, or single-finger drag will not move the probe on a
touchscreen. The VI/DP/FFT canvases in js/show4dstem/index.tsx follow this;
mirror it for any new interactive canvas. Verify with Chrome touch emulation
(Emulation.setTouchEmulationEnabled, Input.dispatchTouchEvent), but remember
emulation is not iOS WebKit: confirm the real device too.
For repository signoff artifacts, use the maintained handoff helper:
python scripts/widget_phone_handoff.py /tmp/quantem-widget-signoff
It serves the report directory on 0.0.0.0, prints local and Tailscale URLs,
and creates phone-probe.html. Open phone-probe.html on the physical phone
from the same origin as the widget, then drag, pinch, tap, rotate, and scroll.
The server writes phone-events.ndjson with viewport, scroll, pointer, touch,
click, and WebGPU availability events. This does not automate Safari; it gives
the human phone tester and the agent a shared event log for the real device.