Skip to content

Commit b85c4b4

Browse files
authored
Merge pull request #22 from lampmaker/claude/tinywebgpu-mobile-compat-r2agi4
Fix demo pages on subdirectory mounts and add error reporting
2 parents 25e96de + e827760 commit b85c4b4

17 files changed

Lines changed: 714 additions & 44 deletions

README.md

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -460,13 +460,20 @@ no TypeScript build needed, and plain-JS users get it too through their editor.
460460
src/ tinywebgpu.js — the library — and tinywebgpu.d.ts
461461
dist/ the built artifacts, committed so the demo pages can load them
462462
tools/ build-min.mjs, its two configs, and find-repeats.mjs
463-
docs/ the tutorial, API.md, CHANGELOG.md
463+
docs/ the tutorial, webgpu-check.html, API.md, CHANGELOG.md
464464
examples/ the eight demo pages
465465
test/ the test suite
466466
index.html the demo index — the site's landing page
467467
libselect.js the ?lib=full|min|tiny picker the pages share
468+
diag.js puts an uncaught page error on the screen — see "Browser support" below
468469
```
469470

471+
`libselect.js` resolves the build it imports against its own `import.meta.url`, not against the
472+
page's — that is what a dynamic `import()` specifier is resolved against, and this file sits at
473+
the repo root beside `src/` and `dist/`. So the demo pages work whether the site is served from
474+
the root of an origin or from a subdirectory like `/tinywebgpu/`. `npm test` checks that by
475+
resolving every path on every demo page against a subdirectory mount.
476+
470477
To vendor the library, take `src/tinywebgpu.js` (or a file from `dist/`) and drop it next to your
471478
HTML — there is nothing else to fetch. The site is served straight from the repo root by GitHub
472479
Pages, which is why `index.html` and `.nojekyll` live there.
@@ -476,6 +483,21 @@ Pages, which is why `index.html` and `.nojekyll` live there.
476483
WebGPU requires a current browser (Chrome/Edge 113+, Firefox 141+ on Windows, Safari 26+) and
477484
a secure context (https or localhost). No WebGL fallback — this is a WebGPU tool.
478485

486+
On **Android**, WebGPU means Chrome 121+ on Android 12 or newer. Samsung Internet, Firefox for
487+
Android, and the in-app browsers that chat and mail apps open links in have no WebGPU at all, so
488+
a demo opened from a message will not run — open it in Chrome. Where Chrome has WebGPU but the
489+
driver is blocklisted, `requestAdapter()` returns null; `chrome://flags/#enable-unsafe-webgpu`
490+
usually gets past that.
491+
492+
Because a phone has no console, the demo pages do not rely on one. Every page loads `diag.js`
493+
first — a plain, non-module script that turns an uncaught error into a banner on the page:
494+
a missing `navigator.gpu`, a null adapter, a module that failed to load or parse. The banner
495+
carries an environment report (user agent, secure context, adapter, limits) and links to
496+
**[docs/webgpu-check.html](https://lampmaker.github.io/tinywebgpu/docs/webgpu-check.html)**,
497+
a library-free page that compiles and draws one triangle and reports exactly where the chain
498+
broke. That page is the right thing to open — and the right report to paste — when a demo shows
499+
nothing on a device.
500+
479501
## License
480502

481503
MIT.

diag.js

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
// Failure reporting for the demo pages. A classic script, deliberately ES5, deliberately not a
2+
// module: it is loaded from <head> before anything else, so it is already listening when the
3+
// module scripts are parsed and run.
4+
//
5+
// Why it exists: a WebGPU demo that cannot start has nowhere to say so. An uncaught throw in a
6+
// `<script type="module">` — a missing navigator.gpu, a null adapter, a module that failed to
7+
// parse — goes to the console and nothing else, and a phone has no console. The page just sits
8+
// there looking loaded, which reads as "the JavaScript never ran". This turns every one of those
9+
// into a banner on the page, with the environment details needed to tell the cases apart.
10+
//
11+
// The global is TWG_DIAG:
12+
// TWG_DIAG.fail(title, detail) show the banner (first call wins; later ones are ignored)
13+
// TWG_DIAG.report() Promise<string> of the environment report
14+
// TWG_DIAG.shown whether a banner is up
15+
16+
(function () {
17+
'use strict';
18+
19+
var W = window;
20+
if (W.TWG_DIAG) return;
21+
22+
var CHECK = 'webgpu-check.html'; // resolved against this script's own URL below
23+
var checkHref = (function () {
24+
var s = document.currentScript;
25+
try { return new URL('docs/' + CHECK, s ? s.src : location.href).href; } catch (e) { return ''; }
26+
})();
27+
28+
var api = { shown: false };
29+
W.TWG_DIAG = api;
30+
31+
// ---- the environment report ---------------------------------------------------------------
32+
// Everything that decides whether a WebGPU page can run, in the order you would check it by
33+
// hand. The adapter probe is async and best-effort: a browser without navigator.gpu never
34+
// reaches it, and one that hangs on requestAdapter is cut off rather than left pending.
35+
var lines = function (o) {
36+
var out = [], k;
37+
for (k = 0; k < o.length; k++) out.push(o[k][0] + ': ' + o[k][1]);
38+
return out.join('\n');
39+
};
40+
41+
api.report = function () {
42+
var base = [
43+
['page', location.href],
44+
['userAgent', navigator.userAgent],
45+
['secure context', String(W.isSecureContext)],
46+
['navigator.gpu', navigator.gpu ? 'present' : 'MISSING'],
47+
['viewport', W.innerWidth + '×' + W.innerHeight + ' @ dpr ' + (W.devicePixelRatio || 1)],
48+
];
49+
if (!navigator.gpu) return Promise.resolve(lines(base));
50+
51+
var timeout = new Promise(function (res) {
52+
setTimeout(function () { res(null); }, 4000);
53+
});
54+
var probe = Promise.resolve()
55+
.then(function () { return navigator.gpu.requestAdapter(); })
56+
.then(function (a) {
57+
if (!a) { base.push(['adapter', 'NULL — the browser has WebGPU but this device/driver gave no adapter']); return; }
58+
var info = a.info || {};
59+
base.push(['adapter', [info.vendor, info.architecture, info.device, info.description]
60+
.filter(Boolean).join(' / ') || '(no info exposed)']);
61+
base.push(['features', a.features ? a.features.size + ' available' : '?']);
62+
var L = a.limits || {};
63+
base.push(['maxBufferSize', String(L.maxBufferSize)]);
64+
base.push(['maxStorageBufferBindingSize', String(L.maxStorageBufferBindingSize)]);
65+
base.push(['maxComputeInvocationsPerWorkgroup', String(L.maxComputeInvocationsPerWorkgroup)]);
66+
base.push(['maxComputeWorkgroupStorageSize', String(L.maxComputeWorkgroupStorageSize)]);
67+
base.push(['maxStorageBuffersPerShaderStage', String(L.maxStorageBuffersPerShaderStage)]);
68+
})
69+
.catch(function (e) { base.push(['adapter', 'requestAdapter threw: ' + (e && e.message || e)]); });
70+
71+
return Promise.race([probe, timeout]).then(function (r) {
72+
if (r === null) base.push(['adapter', 'requestAdapter did not answer within 4s']);
73+
return lines(base);
74+
});
75+
};
76+
77+
// ---- the banner ---------------------------------------------------------------------------
78+
var CSS =
79+
'#twg-diag{position:fixed;left:0;right:0;top:0;z-index:2147483647;box-sizing:border-box;' +
80+
'max-height:80vh;overflow:auto;padding:.8rem 2.5rem .8rem 1rem;background:#2c2413;color:#f0d79a;' +
81+
'border-bottom:1px solid rgba(240,215,154,.4);' +
82+
'font:13px/1.55 ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;' +
83+
'-webkit-text-size-adjust:100%;text-align:left}' +
84+
'#twg-diag b{color:#ffe9b8}' +
85+
'#twg-diag a{color:#9fc0ff}' +
86+
'#twg-diag pre{white-space:pre-wrap;overflow-wrap:anywhere;margin:.5rem 0 0;' +
87+
'font:11px/1.5 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;color:#d8c79c}' +
88+
'#twg-diag summary{cursor:pointer;margin-top:.5rem;color:#e8c983}' +
89+
'#twg-diag .x{position:absolute;top:.35rem;right:.5rem;background:none;border:0;' +
90+
'color:#f0d79a;font-size:20px;line-height:1;padding:.2rem .45rem;cursor:pointer}';
91+
92+
var mount = function (node) {
93+
var to = document.body || document.documentElement;
94+
to.appendChild(node);
95+
};
96+
97+
api.fail = function (title, detail) {
98+
if (api.shown) return; // the first failure is the interesting one
99+
api.shown = true;
100+
101+
var run = function () {
102+
var style = document.createElement('style');
103+
style.textContent = CSS;
104+
(document.head || document.documentElement).appendChild(style);
105+
106+
var box = document.createElement('div');
107+
box.id = 'twg-diag';
108+
box.setAttribute('role', 'alert');
109+
110+
var close = document.createElement('button');
111+
close.className = 'x';
112+
close.setAttribute('aria-label', 'dismiss');
113+
close.textContent = '×';
114+
close.onclick = function () { box.parentNode.removeChild(box); };
115+
116+
var head = document.createElement('div');
117+
var b = document.createElement('b');
118+
b.textContent = title;
119+
head.appendChild(b);
120+
if (detail) {
121+
head.appendChild(document.createTextNode(' '));
122+
head.appendChild(document.createTextNode(detail));
123+
}
124+
if (checkHref) {
125+
head.appendChild(document.createTextNode(' '));
126+
var a = document.createElement('a');
127+
a.href = checkHref;
128+
a.textContent = 'Run the WebGPU check →';
129+
head.appendChild(a);
130+
}
131+
132+
var det = document.createElement('details');
133+
var sum = document.createElement('summary');
134+
sum.textContent = 'Environment details';
135+
var pre = document.createElement('pre');
136+
pre.textContent = 'collecting…';
137+
det.appendChild(sum);
138+
det.appendChild(pre);
139+
api.report().then(function (t) { pre.textContent = t; });
140+
141+
box.appendChild(close);
142+
box.appendChild(head);
143+
box.appendChild(det);
144+
mount(box);
145+
};
146+
147+
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', run);
148+
else run();
149+
};
150+
151+
// ---- the traps ----------------------------------------------------------------------------
152+
// A module that fails to parse, a module that throws while evaluating, and a rejected promise
153+
// nobody caught all end up here. Without these three, each of them is a silent blank page.
154+
var describe = function (e) {
155+
if (!e) return 'Unknown error.';
156+
if (typeof e === 'string') return e;
157+
return (e.name ? e.name + ': ' : '') + (e.message || String(e));
158+
};
159+
160+
W.addEventListener('error', function (ev) {
161+
// Resource errors (a 404 on <img>/<script src>) do not bubble as ErrorEvent with .error, but
162+
// they do arrive here in the capture phase; only the script-level ones matter for the banner.
163+
if (ev.target && ev.target !== W && ev.target.tagName) {
164+
var tag = ev.target.tagName;
165+
if (tag === 'SCRIPT' || tag === 'LINK') {
166+
var url = ev.target.src || ev.target.href;
167+
api.fail('This page could not load one of its scripts.', url
168+
? 'The browser failed to fetch ' + url + '.'
169+
: 'A module script failed to load or parse — check that the library files under ' +
170+
'src/ and dist/ are being served, and as JavaScript.');
171+
}
172+
return;
173+
}
174+
api.fail('This page stopped with an error.', describe(ev.error || ev.message));
175+
}, true);
176+
177+
W.addEventListener('unhandledrejection', function (ev) {
178+
api.fail('This page stopped with an error.', describe(ev.reason));
179+
});
180+
})();

docs/CHANGELOG.md

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,43 @@ All notable changes to TinyWebGPU. Semver; pre-1.0, minor versions may break API
44

55
## Unreleased
66

7+
**Fixed — the demo pages load their library again when the site is not at the root of its origin**
8+
9+
Every example and the tutorial were broken on <https://lampmaker.github.io/tinywebgpu/>: the page
10+
rendered, the build picker appeared, and nothing else ran. `libselect.js` imported the library
11+
with a specifier built from a `base` argument the page passed in — `'../src/tinywebgpu.js'` from
12+
a page one level down — but a dynamic `import()` resolves its specifier against the *importing
13+
module's* URL, not the document's, and `libselect.js` sits at the repo root. Served from the root
14+
of an origin the leading `..` has nowhere to go and is clamped away, so a local
15+
`python3 -m http.server` resolved it to the right file and never showed the bug. Under GitHub
16+
Pages the site lives at `/tinywebgpu/`, so the same `..` walked out to the domain root and each
17+
page 404'd on `https://lampmaker.github.io/src/tinywebgpu.js`.
18+
19+
`loadLib` now resolves the build against `import.meta.url`, which is correct wherever the site is
20+
mounted and wherever the page sits, and the `base` argument is gone from `loadLib`, `boot` and
21+
every call site. `test/paths.test.mjs` resolves every `src`, `href` and import specifier on the
22+
demo pages the way a browser will — against a mount at `/tinywebgpu/`, which is the case that
23+
fails — and checks each one against the files on disk. It found two more dead links while it was
24+
at it: examples 6 and 7 pointed at `../tutorial.html` rather than `../docs/tutorial.html`.
25+
26+
**Added — a failed start-up says so on the page**
27+
28+
Six of the eight examples called `init()` with no `catch`, so a missing `navigator.gpu`, a null
29+
adapter or the import failure above threw out of the module script into a console — which a phone
30+
does not have, and which the reader of a demo has no reason to open. The page just sat there
31+
looking loaded. `diag.js` is a classic, non-module script every demo page now loads first: it
32+
traps `error` and `unhandledrejection` and puts the failure on screen with an environment report
33+
(user agent, secure context, adapter, limits). Being classic and ahead of the modules, it also
34+
catches a module that fails to load or parse — the case above.
35+
36+
`libselect.js` gained `gpuAdvice()`, which separates "this browser has no WebGPU" from "this
37+
device gave no adapter" and says what to do about each, `reportFailure()`, and `boot()` — picker,
38+
build import and `init()` in one call, with failures reported. Examples 1–5 and 8 use it; 6 and 7
39+
keep their own error strip, filled from the shared reporter. The tutorial reports an adapter
40+
failure once at page level rather than only inside whichever box scrolled into view first.
41+
`docs/webgpu-check.html` is a library-free page that walks `navigator.gpu` → adapter → device →
42+
one compiled and drawn triangle and reports where the chain broke, with a copyable report.
43+
744
**Added — tutorial step 16, "Your own vertex stage, and a depth buffer"**
845

946
The tutorial stopped at compute and fullscreen passes; `makeDraw` lived only in a "where to go

docs/tutorial.html

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@
77
<title>TinyWebGPU — tutorial</title>
88
<meta name="description" content="A step-by-step tutorial for TinyWebGPU: from one fullscreen
99
shader to compute, atomics and a particle system. Every code box on the page really runs.">
10+
<!-- Loaded first and on purpose not a module: it turns an uncaught error in the
11+
module scripts below — no WebGPU, no adapter, a file that failed to load — into
12+
a banner on the page, because a phone has no console to print it to. -->
13+
<script src="../diag.js"></script>
1014
<style>
1115
:root {
1216
color-scheme: light dark;
@@ -187,7 +191,9 @@ <h1>TinyWebGPU, step by step</h1>
187191
<div id="unsupported">
188192
<strong>WebGPU isn’t available in this browser.</strong> The text still reads fine, but no
189193
box will run. You need Chrome/Edge 113+, Firefox 141+ (Windows) or Safari 26+, over https
190-
or localhost.
194+
or localhost. On Android that means Chrome 121+ on Android 12+ — Samsung Internet, Firefox
195+
for Android and the in-app browsers inside chat and mail apps have no WebGPU at all.
196+
<a href="webgpu-check.html">Run the WebGPU check</a> to see what this device reports.
191197
</div>
192198

193199
<div class="note">
@@ -1436,8 +1442,8 @@ <h3>Where to go next</h3>
14361442
// The tutorial itself needs nothing beyond the core, but many boxes read results back,
14371443
// resize their canvas or upload textures — features the stock tiny build drops — so under
14381444
// tiny those boxes are skipped with a note instead of failing on a missing function.
1439-
import { loadLib, explain, missing, TINY_DROPS } from '../libselect.js';
1440-
const { WEBGPU, lib } = await loadLib(['read', 'resize', 'texio', 'depth'], '..');
1445+
import { loadLib, explain, missing, reportFailure, TINY_DROPS } from '../libselect.js';
1446+
const { WEBGPU, lib } = await loadLib(['read', 'resize', 'texio', 'depth']);
14411447

14421448
// What a box's code can use that the tiny build lacks. Checked against the live editor
14431449
// content on every run, so deleting the offending line makes the box runnable again.
@@ -1476,7 +1482,11 @@ <h3>Where to go next</h3>
14761482
const TYPE_NAMES = Object.keys(TYPES);
14771483
const DEFINES = Object.entries({ ...TYPES, ...CONSTS }).map(([t, r]) => `${t} ${r}`).join('\n');
14781484

1479-
const getG = () => (initPromise ||= WEBGPU().init().then(g => (g.defines = DEFINES, g)));
1485+
// A failure here is about the device, not about the box that happened to ask first, so it
1486+
// is reported once at page level as well as in that box's output panel.
1487+
const getG = () => (initPromise ||= WEBGPU().init()
1488+
.then(g => (g.defines = DEFINES, g))
1489+
.catch(e => { reportFailure(e, lib); throw e; }));
14801490

14811491
const useCanvas = canvas => {
14821492
if (!canvas) return;

0 commit comments

Comments
 (0)