@swiftbrowser/web 0.9.0 → 0.9.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/app/index.html +3 -0
- package/node/dist/plugins/playground.d.ts +6 -3
- package/node/dist/plugins/playground.d.ts.map +1 -1
- package/node/dist/plugins/playground.js +32 -12
- package/node/dist/plugins/playground.js.map +1 -1
- package/node/dist/src/playground/files.d.ts +6 -0
- package/node/dist/src/playground/files.d.ts.map +1 -1
- package/node/dist/src/playground/files.js.map +1 -1
- package/node/dist/src/playground/sections.d.ts +5 -0
- package/node/dist/src/playground/sections.d.ts.map +1 -0
- package/node/dist/src/playground/sections.js +88 -0
- package/node/dist/src/playground/sections.js.map +1 -0
- package/package.json +6 -1
- package/plugins/playground.ts +30 -12
- package/src/main.ts +6 -2
- package/src/page.css +16 -0
- package/src/playground/app.tsx +311 -0
- package/src/playground/assets.ts +53 -134
- package/src/playground/build.ts +30 -19
- package/src/playground/download.ts +125 -0
- package/src/playground/files.ts +6 -0
- package/src/playground/freeze.ts +105 -0
- package/src/playground/main.tsx +45 -0
- package/src/playground/playground.css +192 -29
- package/src/playground/read-once.ts +75 -0
- package/src/playground/sections.ts +77 -0
- package/src/playground/session.ts +117 -0
- package/src/playground/tool-host.ts +163 -0
- package/src/playground/tool-worker.ts +67 -0
- package/src/playground/worker.ts +41 -17
- package/src/playground/main.ts +0 -107
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// The playground's downloads, each kept in the Cache API under its URL,
|
|
2
|
+
// which a version makes immutable, so a compile needs no request after the
|
|
3
|
+
// first visit. The tools come brotli-compressed, as does a file too big for
|
|
4
|
+
// the site, and no browser's DecompressionStream reads brotli: a small
|
|
5
|
+
// WebAssembly decoder unpacks them as they arrive, and the cache keeps them
|
|
6
|
+
// unpacked. Other files the server compresses in transit.
|
|
7
|
+
|
|
8
|
+
import { isBrotli } from './files';
|
|
9
|
+
import { withoutCustomSections } from './sections';
|
|
10
|
+
|
|
11
|
+
const CACHE = 'swiftbrowser-playground';
|
|
12
|
+
/** Requests in flight at once for the SDK's and kit's files. */
|
|
13
|
+
export const CONCURRENCY = 24;
|
|
14
|
+
|
|
15
|
+
type Brotli = typeof import('brotli-dec-wasm/web');
|
|
16
|
+
let brotli: Promise<Brotli> | null = null;
|
|
17
|
+
|
|
18
|
+
/** The brotli decoder, loaded on first use. */
|
|
19
|
+
function brotliDecoder(): Promise<Brotli> {
|
|
20
|
+
brotli ??= Promise.all([import('brotli-dec-wasm/web'), import('brotli-dec-wasm/web/bg.wasm?url')]).then(async ([decoder, wasm]) => {
|
|
21
|
+
await decoder.default({ module_or_path: wasm.default });
|
|
22
|
+
return decoder;
|
|
23
|
+
});
|
|
24
|
+
return brotli;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** A stream that unpacks brotli as its input arrives. */
|
|
28
|
+
function brotliStream(decoder: Brotli): TransformStream<Uint8Array, Uint8Array> {
|
|
29
|
+
const stream = new decoder.BrotliDecStream();
|
|
30
|
+
let code: number = decoder.BrotliStreamResultCode.NeedsMoreInput;
|
|
31
|
+
return new TransformStream({
|
|
32
|
+
transform(chunk, controller) {
|
|
33
|
+
let input = chunk;
|
|
34
|
+
do {
|
|
35
|
+
const result = stream.dec(input, 1 << 22);
|
|
36
|
+
if (result.buf.length > 0) controller.enqueue(result.buf);
|
|
37
|
+
input = input.subarray(result.input_offset);
|
|
38
|
+
code = result.code;
|
|
39
|
+
} while (code === decoder.BrotliStreamResultCode.NeedsMoreOutput);
|
|
40
|
+
},
|
|
41
|
+
flush() {
|
|
42
|
+
stream.free();
|
|
43
|
+
if (code !== decoder.BrotliStreamResultCode.ResultSuccess) throw new Error('a brotli file ended early');
|
|
44
|
+
},
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* A file the playground downloads. A tool's or plugin's module is kept
|
|
50
|
+
* without its custom sections (sections.ts), under a key of its own.
|
|
51
|
+
*/
|
|
52
|
+
export interface Download {
|
|
53
|
+
url: string;
|
|
54
|
+
module?: boolean;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Where the cache keeps a download: its URL, which a version makes immutable. */
|
|
58
|
+
export const cacheKey = (file: Download): string => (file.module ? `${file.url}?custom-sections=none` : file.url);
|
|
59
|
+
|
|
60
|
+
export const openCache = async (): Promise<Cache | null> => (typeof caches === 'undefined' ? null : caches.open(CACHE).catch(() => null));
|
|
61
|
+
|
|
62
|
+
/** A download from the network, unpacked as it arrives when it is brotli (`isBrotli`). */
|
|
63
|
+
async function fetchFile(file: Download): Promise<Response> {
|
|
64
|
+
const response = await fetch(file.url);
|
|
65
|
+
if (!response.ok || !response.body) throw new Error(`${file.url}: HTTP ${response.status}`);
|
|
66
|
+
if (!isBrotli(file.url) && !file.module) return response;
|
|
67
|
+
let body: ReadableStream<Uint8Array> = response.body;
|
|
68
|
+
if (isBrotli(file.url)) body = body.pipeThrough(brotliStream(await brotliDecoder()));
|
|
69
|
+
if (file.module) body = body.pipeThrough(withoutCustomSections());
|
|
70
|
+
const type = file.module || /\.wasm\.(br|brotli)$/.test(file.url) ? 'application/wasm' : 'application/octet-stream';
|
|
71
|
+
return new Response(body, { headers: { 'content-type': type } });
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* A download, from the cache, which takes it from the network first when it
|
|
76
|
+
* has not got it. The cache writes it out as it arrives and the page reads
|
|
77
|
+
* it back, so a file is never held twice: a phone's browser runs out of
|
|
78
|
+
* memory long before a desktop's (a tee would buffer a whole compiler for
|
|
79
|
+
* the slower reader). Of the headers only the type is kept
|
|
80
|
+
* (compileStreaming wants it).
|
|
81
|
+
*/
|
|
82
|
+
export async function download(cache: Cache | null, file: Download): Promise<Response> {
|
|
83
|
+
const hit = await cache?.match(cacheKey(file));
|
|
84
|
+
if (hit) return hit;
|
|
85
|
+
const response = await fetchFile(file);
|
|
86
|
+
if (!cache) return response;
|
|
87
|
+
const headers = { 'content-type': response.headers.get('content-type') ?? 'application/octet-stream' };
|
|
88
|
+
const stored = await cache.put(cacheKey(file), new Response(response.body, { headers })).then(() => cache.match(cacheKey(file)), () => undefined);
|
|
89
|
+
// A cache that is full or refuses: the network again, straight to the page.
|
|
90
|
+
return stored ?? fetchFile(file);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export const downloadBytes = async (cache: Cache | null, file: Download): Promise<Uint8Array<ArrayBuffer>> => new Uint8Array(await (await download(cache, file)).arrayBuffer());
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Puts a download in the cache, without keeping it, unless the cache has it.
|
|
97
|
+
* A cache that refuses it leaves it to the network, where a tool's worker
|
|
98
|
+
* downloads it from (tool-host.ts).
|
|
99
|
+
*/
|
|
100
|
+
export async function prefetch(cache: Cache | null, file: Download): Promise<void> {
|
|
101
|
+
if (!cache || (await cache.match(cacheKey(file)))) return;
|
|
102
|
+
const response = await fetchFile(file);
|
|
103
|
+
const headers = { 'content-type': response.headers.get('content-type') ?? 'application/octet-stream' };
|
|
104
|
+
await cache.put(cacheKey(file), new Response(response.body, { headers })).catch(() => {});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export async function compileModule(response: Response): Promise<WebAssembly.Module> {
|
|
108
|
+
if (typeof WebAssembly.compileStreaming === 'function' && (response.headers.get('content-type') ?? '').includes('application/wasm')) {
|
|
109
|
+
return WebAssembly.compileStreaming(response);
|
|
110
|
+
}
|
|
111
|
+
return WebAssembly.compile(await response.arrayBuffer());
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** `work` over `items`, at most `limit` at once, in order. */
|
|
115
|
+
export async function pooled<T, R>(items: readonly T[], limit: number, work: (item: T) => Promise<R>): Promise<R[]> {
|
|
116
|
+
const results: R[] = new Array(items.length);
|
|
117
|
+
let next = 0;
|
|
118
|
+
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, async () => {
|
|
119
|
+
while (next < items.length) {
|
|
120
|
+
const index = next++;
|
|
121
|
+
results[index] = await work(items[index]!);
|
|
122
|
+
}
|
|
123
|
+
}));
|
|
124
|
+
return results;
|
|
125
|
+
}
|
package/src/playground/files.ts
CHANGED
|
@@ -41,6 +41,12 @@ export interface PlaygroundManifest {
|
|
|
41
41
|
sdk: Record<string, string[]>;
|
|
42
42
|
/** The kit's files a compile reads, relative to @swiftbrowser/kit (`wasm32-unknown-wasip1/...`). */
|
|
43
43
|
kit: string[];
|
|
44
|
+
/**
|
|
45
|
+
* SDK files (paths as in `sdk`) the manifest carries itself, as text: a
|
|
46
|
+
* CDN refuses some names (jsDelivr a `.lnk`, as the SDK names its
|
|
47
|
+
* static-executable flags).
|
|
48
|
+
*/
|
|
49
|
+
inline?: Record<string, string>;
|
|
44
50
|
/**
|
|
45
51
|
* The SwiftBrowser sources the kit was built from (@swiftbrowser/swift's
|
|
46
52
|
* `sourcesHash`), when build-assets.ts could tell: the page compares it
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// The app as it looks, without the app: what the frame shows while a
|
|
2
|
+
// compile runs. The running app (its page, its module and its memory) would
|
|
3
|
+
// stay beside the compiler's and the linker's, which a phone's browser has
|
|
4
|
+
// not got room for; a copy of its document, scripts gone, takes next to
|
|
5
|
+
// nothing. What a copy of the markup would lose is carried over: the rules
|
|
6
|
+
// of the page's own sheets (a script may have inserted them), what the user
|
|
7
|
+
// typed, canvases and images whose blob: URLs go with the app, and how far
|
|
8
|
+
// each part scrolled.
|
|
9
|
+
|
|
10
|
+
/** The attribute a frozen copy's scrolled elements carry their offsets in, `left,top`. */
|
|
11
|
+
const SCROLL = 'data-sb-frozen-scroll';
|
|
12
|
+
|
|
13
|
+
/** A document of `doc` as it is now, with no scripts, as HTML; null when there is no app in it. */
|
|
14
|
+
export function frozenCopy(doc: Document): string | null {
|
|
15
|
+
if (!doc.body || doc.location.href === 'about:blank') return null;
|
|
16
|
+
const copy = doc.documentElement.cloneNode(true) as HTMLElement;
|
|
17
|
+
const pairs = (selector: string): [Element, Element][] => {
|
|
18
|
+
const live = [...doc.documentElement.querySelectorAll(selector)];
|
|
19
|
+
const copied = [...copy.querySelectorAll(selector)];
|
|
20
|
+
return live.map((element, index) => [element, copied[index]!]);
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
for (const [live, copied] of pairs('input, textarea, select')) {
|
|
24
|
+
if (live instanceof HTMLInputElement) {
|
|
25
|
+
copied.setAttribute('value', live.value);
|
|
26
|
+
if (live.checked) copied.setAttribute('checked', '');
|
|
27
|
+
else copied.removeAttribute('checked');
|
|
28
|
+
} else if (live instanceof HTMLTextAreaElement) {
|
|
29
|
+
copied.textContent = live.value;
|
|
30
|
+
} else if (live instanceof HTMLSelectElement) {
|
|
31
|
+
[...(copied as HTMLSelectElement).options].forEach((option, index) => option.toggleAttribute('selected', index === live.selectedIndex));
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
// A canvas's pixels and a blob: image are not in the markup.
|
|
35
|
+
const picture = (source: CanvasImageSource & { width: number; height: number }, width: number, height: number): string | null => {
|
|
36
|
+
try {
|
|
37
|
+
const canvas = doc.createElement('canvas');
|
|
38
|
+
canvas.width = width;
|
|
39
|
+
canvas.height = height;
|
|
40
|
+
canvas.getContext('2d')?.drawImage(source, 0, 0);
|
|
41
|
+
return canvas.toDataURL();
|
|
42
|
+
} catch {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
for (const [live, copied] of pairs('canvas')) {
|
|
47
|
+
const url = picture(live as HTMLCanvasElement, (live as HTMLCanvasElement).width, (live as HTMLCanvasElement).height);
|
|
48
|
+
if (!url) continue;
|
|
49
|
+
const image = doc.createElement('img');
|
|
50
|
+
for (const { name, value } of [...copied.attributes]) image.setAttribute(name, value);
|
|
51
|
+
image.src = url;
|
|
52
|
+
copied.replaceWith(image);
|
|
53
|
+
}
|
|
54
|
+
for (const [live, copied] of pairs('img')) {
|
|
55
|
+
const image = live as HTMLImageElement;
|
|
56
|
+
if (!image.currentSrc.startsWith('blob:') || !image.complete || image.naturalWidth === 0) continue;
|
|
57
|
+
const url = picture(image, image.naturalWidth, image.naturalHeight);
|
|
58
|
+
if (url) {
|
|
59
|
+
copied.setAttribute('src', url);
|
|
60
|
+
copied.removeAttribute('srcset');
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
for (const [live, copied] of pairs('*')) {
|
|
64
|
+
if (live.scrollTop || live.scrollLeft) copied.setAttribute(SCROLL, `${live.scrollLeft},${live.scrollTop}`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const root = doc.scrollingElement;
|
|
68
|
+
if (root && (root.scrollTop || root.scrollLeft)) copy.setAttribute(SCROLL, `${root.scrollLeft},${root.scrollTop}`);
|
|
69
|
+
|
|
70
|
+
// The rules of the sheets in the page, as they are now (a script may have
|
|
71
|
+
// inserted some), inline. A linked sheet stays a link: its relative URLs
|
|
72
|
+
// are its own, and the browser has it cached.
|
|
73
|
+
const rules: string[] = [];
|
|
74
|
+
const styles = [...doc.querySelectorAll('style')];
|
|
75
|
+
const copiedStyles = [...copy.querySelectorAll('style')];
|
|
76
|
+
for (const sheet of [...doc.styleSheets, ...doc.adoptedStyleSheets]) {
|
|
77
|
+
if (sheet.href) continue;
|
|
78
|
+
try {
|
|
79
|
+
rules.push([...sheet.cssRules].map((rule) => rule.cssText).join('\n'));
|
|
80
|
+
const index = styles.indexOf(sheet.ownerNode as HTMLStyleElement);
|
|
81
|
+
if (index >= 0) copiedStyles[index]!.remove();
|
|
82
|
+
} catch {
|
|
83
|
+
// A sheet whose rules cannot be read keeps its element.
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
for (const element of copy.querySelectorAll('script, link[rel="modulepreload"], link[rel="preload"]')) element.remove();
|
|
87
|
+
const head = copy.querySelector('head') ?? copy.insertBefore(doc.createElement('head'), copy.firstChild);
|
|
88
|
+
const style = doc.createElement('style');
|
|
89
|
+
style.textContent = rules.join('\n');
|
|
90
|
+
head.prepend(style);
|
|
91
|
+
// Its URLs as the app's page had them.
|
|
92
|
+
const base = doc.createElement('base');
|
|
93
|
+
base.href = doc.baseURI;
|
|
94
|
+
head.prepend(base);
|
|
95
|
+
copy.querySelector('body')?.setAttribute('inert', '');
|
|
96
|
+
return `<!doctype html>\n${copy.outerHTML}`;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Scrolls a frozen copy's elements back to where the app had them, once it has loaded. */
|
|
100
|
+
export function restoreScroll(doc: Document): void {
|
|
101
|
+
for (const element of doc.querySelectorAll(`[${SCROLL}]`)) {
|
|
102
|
+
const [left, top] = element.getAttribute(SCROLL)!.split(',').map(Number);
|
|
103
|
+
element.scrollTo(left ?? 0, top ?? 0);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/// <reference path="../vite-env.d.ts" />
|
|
2
|
+
// The playground page: an app's sources in an editor, compiled and linked in
|
|
3
|
+
// a worker by the WebAssembly Swift compiler (session.ts, worker.ts), and run
|
|
4
|
+
// by the renderer's own page in a frame. The page itself is SwiftUI views
|
|
5
|
+
// from @swiftbrowser/react (app.tsx). Counter's sources come with the page,
|
|
6
|
+
// so after the first visit's downloads nothing reaches a server.
|
|
7
|
+
|
|
8
|
+
import '../styles.css';
|
|
9
|
+
import './playground.css';
|
|
10
|
+
import { SwiftUIView } from '@swiftbrowser/react';
|
|
11
|
+
import { useSyncExternalStore } from 'react';
|
|
12
|
+
import { createRoot } from 'react-dom/client';
|
|
13
|
+
import { Playground, TOUCH } from './app';
|
|
14
|
+
import { Session } from './session';
|
|
15
|
+
|
|
16
|
+
const sources = import.meta.glob('../../../Examples/Counter/Sources/**/*.swift', { query: '?raw', import: 'default', eager: true }) as Record<string, string>;
|
|
17
|
+
const APP = 'Counter';
|
|
18
|
+
|
|
19
|
+
const source = {
|
|
20
|
+
...__SB_PLAYGROUND__,
|
|
21
|
+
npm: new URL(__SB_PLAYGROUND__.npm, location.href).href,
|
|
22
|
+
...(__SB_PLAYGROUND__.packages
|
|
23
|
+
? { packages: Object.fromEntries(Object.entries(__SB_PLAYGROUND__.packages).map(([name, base]) => [name, new URL(base, location.href).href])) }
|
|
24
|
+
: {}),
|
|
25
|
+
};
|
|
26
|
+
const files = Object.fromEntries(Object.entries(sources).map(([file, text]) => [file.replace(/^.*\/Sources\//, ''), text]));
|
|
27
|
+
// A touch device runs the app full screen in its pane; elsewhere the
|
|
28
|
+
// simulator shows the app page's iPhone (app.tsx).
|
|
29
|
+
const session = new Session(source, APP, TOUCH ? 'device' : 'frame');
|
|
30
|
+
const scheme = matchMedia('(prefers-color-scheme: dark)');
|
|
31
|
+
const watchScheme = (change: () => void): (() => void) => {
|
|
32
|
+
scheme.addEventListener('change', change);
|
|
33
|
+
return () => scheme.removeEventListener('change', change);
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
function Root() {
|
|
37
|
+
const dark = useSyncExternalStore(watchScheme, () => scheme.matches);
|
|
38
|
+
return (
|
|
39
|
+
<SwiftUIView className="pg-root" colorScheme={dark ? 'dark' : 'light'} safeArea={0}>
|
|
40
|
+
<Playground session={session} appName={APP} files={files} />
|
|
41
|
+
</SwiftUIView>
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
createRoot(document.getElementById('root')!).render(<Root />);
|
|
@@ -1,31 +1,194 @@
|
|
|
1
|
-
:
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
1
|
+
/* The playground's window (app.tsx): SwiftUI views fill it; the editor and
|
|
2
|
+
the simulator are DOM in their hosts, styled with the renderer's tokens so
|
|
3
|
+
they follow dark mode. */
|
|
4
|
+
html,
|
|
5
|
+
body,
|
|
6
|
+
#root,
|
|
7
|
+
.pg-root {
|
|
8
|
+
margin: 0;
|
|
9
|
+
height: 100%;
|
|
10
|
+
}
|
|
11
|
+
/* The page behind the views, which a phone's browser tints its bars with:
|
|
12
|
+
the system background of the appearance the views follow (main.tsx). The
|
|
13
|
+
tokens are the island's, so not the body's to read. */
|
|
14
|
+
body {
|
|
15
|
+
background: #ffffff;
|
|
16
|
+
overscroll-behavior: none;
|
|
10
17
|
}
|
|
11
18
|
@media (prefers-color-scheme: dark) {
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
19
|
+
body {
|
|
20
|
+
background: #000000;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
.pg-pane {
|
|
25
|
+
height: 100%;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/* The editor: a gutter and the text, issues below, a status bar at the foot. */
|
|
29
|
+
.pg-editor {
|
|
30
|
+
font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif;
|
|
31
|
+
display: flex;
|
|
32
|
+
flex-direction: column;
|
|
33
|
+
height: 100%;
|
|
34
|
+
min-width: 0;
|
|
35
|
+
background: var(--sb-color-system-background);
|
|
36
|
+
color: var(--sb-color-primary);
|
|
37
|
+
--pg-mono: ui-monospace, 'SF Mono', SFMono-Regular, Menlo, Consolas, monospace;
|
|
38
|
+
}
|
|
39
|
+
.pg-code {
|
|
40
|
+
position: relative;
|
|
41
|
+
display: flex;
|
|
42
|
+
flex: 1;
|
|
43
|
+
min-height: 0;
|
|
44
|
+
}
|
|
45
|
+
.pg-gutter,
|
|
46
|
+
.pg-code textarea {
|
|
47
|
+
margin: 0;
|
|
48
|
+
padding: 12px 0;
|
|
49
|
+
font: 13px/20px var(--pg-mono);
|
|
50
|
+
}
|
|
51
|
+
.pg-gutter {
|
|
52
|
+
flex: none;
|
|
53
|
+
width: 44px;
|
|
54
|
+
padding-right: 10px;
|
|
55
|
+
overflow: hidden;
|
|
56
|
+
text-align: right;
|
|
57
|
+
color: var(--sb-color-tertiary-label);
|
|
58
|
+
user-select: none;
|
|
59
|
+
white-space: pre;
|
|
60
|
+
}
|
|
61
|
+
.pg-code textarea {
|
|
62
|
+
flex: 1;
|
|
63
|
+
min-width: 0;
|
|
64
|
+
padding-left: 6px;
|
|
65
|
+
padding-right: 12px;
|
|
66
|
+
border: none;
|
|
67
|
+
outline: none;
|
|
68
|
+
resize: none;
|
|
69
|
+
background: transparent;
|
|
70
|
+
color: inherit;
|
|
71
|
+
tab-size: 4;
|
|
72
|
+
white-space: pre;
|
|
73
|
+
caret-color: var(--sb-color-accent);
|
|
74
|
+
}
|
|
75
|
+
#warning {
|
|
76
|
+
margin: 0;
|
|
77
|
+
padding: 8px 12px;
|
|
78
|
+
background: color-mix(in srgb, var(--sb-color-yellow) 18%, transparent);
|
|
79
|
+
border-bottom: 1px solid var(--sb-color-separator);
|
|
80
|
+
font-size: 13px;
|
|
81
|
+
}
|
|
82
|
+
#diagnostics {
|
|
83
|
+
flex: none;
|
|
84
|
+
max-height: 38%;
|
|
85
|
+
margin: 0;
|
|
86
|
+
padding: 10px 12px;
|
|
87
|
+
overflow: auto;
|
|
88
|
+
border-top: 1px solid var(--sb-color-separator);
|
|
89
|
+
background: var(--sb-color-secondary-system-background);
|
|
90
|
+
font: 12px/1.45 var(--pg-mono);
|
|
91
|
+
white-space: pre-wrap;
|
|
92
|
+
}
|
|
93
|
+
#diagnostics.pg-errors {
|
|
94
|
+
color: var(--sb-color-red);
|
|
95
|
+
}
|
|
96
|
+
.pg-statusbar {
|
|
97
|
+
flex: none;
|
|
98
|
+
display: flex;
|
|
99
|
+
align-items: center;
|
|
100
|
+
gap: 12px;
|
|
101
|
+
height: 26px;
|
|
102
|
+
padding: 0 12px;
|
|
103
|
+
border-top: 1px solid var(--sb-color-separator);
|
|
104
|
+
background: var(--sb-color-secondary-system-background);
|
|
105
|
+
color: var(--sb-color-secondary);
|
|
106
|
+
font-size: 11px;
|
|
107
|
+
white-space: nowrap;
|
|
108
|
+
}
|
|
109
|
+
#status {
|
|
110
|
+
flex: 1;
|
|
111
|
+
min-width: 0;
|
|
112
|
+
overflow: hidden;
|
|
113
|
+
text-overflow: ellipsis;
|
|
114
|
+
}
|
|
115
|
+
.pg-issues {
|
|
116
|
+
flex: none;
|
|
117
|
+
padding: 1px 8px;
|
|
118
|
+
border: none;
|
|
119
|
+
border-radius: 999px;
|
|
120
|
+
background: color-mix(in srgb, var(--sb-color-yellow) 28%, transparent);
|
|
121
|
+
color: var(--sb-color-primary);
|
|
122
|
+
font: inherit;
|
|
123
|
+
cursor: pointer;
|
|
124
|
+
}
|
|
125
|
+
.pg-issues.pg-has-errors {
|
|
126
|
+
background: color-mix(in srgb, var(--sb-color-red) 22%, transparent);
|
|
127
|
+
}
|
|
128
|
+
.pg-issues[aria-expanded='true'] {
|
|
129
|
+
outline: 1px solid var(--sb-color-separator);
|
|
130
|
+
}
|
|
131
|
+
.pg-position {
|
|
132
|
+
font-variant-numeric: tabular-nums;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/* The simulator: the app's own page (its iPhone, or full screen on a touch
|
|
136
|
+
device) in a frame, with the compiler's progress floating over it. */
|
|
137
|
+
.pg-simulator {
|
|
138
|
+
position: relative;
|
|
139
|
+
height: 100%;
|
|
140
|
+
background: var(--sb-color-secondary-system-background);
|
|
141
|
+
}
|
|
142
|
+
.pg-simulator {
|
|
143
|
+
overflow: hidden;
|
|
144
|
+
}
|
|
145
|
+
.pg-simulator iframe {
|
|
146
|
+
position: absolute;
|
|
147
|
+
left: 50%;
|
|
148
|
+
display: block;
|
|
149
|
+
border: none;
|
|
150
|
+
transform-origin: top center;
|
|
151
|
+
color-scheme: normal;
|
|
152
|
+
}
|
|
153
|
+
.pg-simulator.pg-full iframe {
|
|
154
|
+
inset: 0;
|
|
155
|
+
left: 0;
|
|
156
|
+
width: 100%;
|
|
157
|
+
height: 100%;
|
|
158
|
+
}
|
|
159
|
+
.pg-activity {
|
|
160
|
+
position: absolute;
|
|
161
|
+
top: 12px;
|
|
162
|
+
left: 50%;
|
|
163
|
+
transform: translateX(-50%);
|
|
164
|
+
display: flex;
|
|
165
|
+
align-items: center;
|
|
166
|
+
gap: 8px;
|
|
167
|
+
max-width: calc(100% - 32px);
|
|
168
|
+
padding: 8px 14px;
|
|
169
|
+
border-radius: 999px;
|
|
170
|
+
background: var(--sb-color-material-base, rgba(255, 255, 255, 0.8));
|
|
171
|
+
-webkit-backdrop-filter: blur(20px) saturate(1.8);
|
|
172
|
+
backdrop-filter: blur(20px) saturate(1.8);
|
|
173
|
+
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
|
|
174
|
+
color: var(--sb-color-primary);
|
|
175
|
+
font-size: 13px;
|
|
176
|
+
white-space: nowrap;
|
|
177
|
+
overflow: hidden;
|
|
178
|
+
text-overflow: ellipsis;
|
|
179
|
+
pointer-events: none;
|
|
180
|
+
}
|
|
181
|
+
.pg-spinner {
|
|
182
|
+
flex: none;
|
|
183
|
+
width: 12px;
|
|
184
|
+
height: 12px;
|
|
185
|
+
border: 2px solid var(--sb-color-tertiary-label);
|
|
186
|
+
border-top-color: var(--sb-color-accent);
|
|
187
|
+
border-radius: 50%;
|
|
188
|
+
animation: pg-spin 0.8s linear infinite;
|
|
189
|
+
}
|
|
190
|
+
@keyframes pg-spin {
|
|
191
|
+
to {
|
|
192
|
+
transform: rotate(360deg);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// Inputs a tool reads whole once, dropped as soon as it has. The linker
|
|
2
|
+
// reads each archive into its own memory and keeps it there, so after that
|
|
3
|
+
// the worker's copy is a second one, a hundred megabytes for an app's
|
|
4
|
+
// archives, at the point where a phone's browser has least to spare.
|
|
5
|
+
|
|
6
|
+
import { File, OpenFile, wasi } from '@bjorn3/browser_wasi_shim';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* A read-only file whose bytes go once every open of it is closed and one
|
|
10
|
+
* read all of them. Its size stays. Opening it after that fails
|
|
11
|
+
* (`onReopen` is told), since its bytes are gone: the tool has to run again
|
|
12
|
+
* with them kept.
|
|
13
|
+
*/
|
|
14
|
+
export class ReadOnceFile extends File {
|
|
15
|
+
private readonly length: bigint;
|
|
16
|
+
private open = 0;
|
|
17
|
+
private wholeRead = false;
|
|
18
|
+
released = false;
|
|
19
|
+
|
|
20
|
+
constructor(data: Uint8Array, private readonly onReopen: () => void) {
|
|
21
|
+
// The buffer, not the view, so the File keeps the bytes rather than a copy.
|
|
22
|
+
super(data.byteOffset === 0 && data.byteLength === data.buffer.byteLength ? data.buffer : data, { readonly: true });
|
|
23
|
+
this.length = BigInt(data.byteLength);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
override get size(): bigint {
|
|
27
|
+
return this.length;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
override path_open(oflags: number, fs_rights_base: bigint, fd_flags: number): { ret: number; fd_obj: OpenFile } {
|
|
31
|
+
if (this.released) {
|
|
32
|
+
this.onReopen();
|
|
33
|
+
return { ret: wasi.ERRNO_IO, fd_obj: null as unknown as OpenFile };
|
|
34
|
+
}
|
|
35
|
+
const opened = super.path_open(oflags, fs_rights_base, fd_flags);
|
|
36
|
+
if (opened.ret !== wasi.ERRNO_SUCCESS) return opened;
|
|
37
|
+
this.open += 1;
|
|
38
|
+
return { ret: opened.ret, fd_obj: new ReadOnceOpenFile(this) };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** How an open of it ended: whether it read every byte. */
|
|
42
|
+
closed(readAll: boolean): void {
|
|
43
|
+
this.open -= 1;
|
|
44
|
+
this.wholeRead ||= readAll;
|
|
45
|
+
if (this.open === 0 && this.wholeRead) {
|
|
46
|
+
this.data = new Uint8Array(0);
|
|
47
|
+
this.released = true;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
class ReadOnceOpenFile extends OpenFile {
|
|
53
|
+
private bytesRead = 0;
|
|
54
|
+
|
|
55
|
+
constructor(private readonly once: ReadOnceFile) {
|
|
56
|
+
super(once);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
override fd_read(size: number): { ret: number; data: Uint8Array } {
|
|
60
|
+
const result = super.fd_read(size);
|
|
61
|
+
this.bytesRead += result.data.byteLength;
|
|
62
|
+
return result;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
override fd_pread(size: number, offset: bigint): { ret: number; data: Uint8Array } {
|
|
66
|
+
const result = super.fd_pread(size, offset);
|
|
67
|
+
this.bytesRead += result.data.byteLength;
|
|
68
|
+
return result;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
override fd_close(): number {
|
|
72
|
+
this.once.closed(BigInt(this.bytesRead) >= this.once.size);
|
|
73
|
+
return super.fd_close();
|
|
74
|
+
}
|
|
75
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// A WebAssembly module without its custom sections, as it streams in. A
|
|
2
|
+
// tool's or plugin's run reads none of them, but a browser keeps them beside
|
|
3
|
+
// the compiled code: the function names are a quarter of the compiler and of
|
|
4
|
+
// the linker, and a macro plugin carries its DWARF.
|
|
5
|
+
|
|
6
|
+
/** Passes a module through, dropping each custom section (id 0). */
|
|
7
|
+
export function withoutCustomSections(): TransformStream<Uint8Array, Uint8Array> {
|
|
8
|
+
// The preamble's bytes still to pass, then a section header's bytes so far
|
|
9
|
+
// (its id and LEB128 size), then the section's bytes to pass or to drop.
|
|
10
|
+
let preamble = 8;
|
|
11
|
+
let header: number[] = [];
|
|
12
|
+
let pass = 0;
|
|
13
|
+
let drop = 0;
|
|
14
|
+
return new TransformStream({
|
|
15
|
+
transform(chunk, controller) {
|
|
16
|
+
let at = 0;
|
|
17
|
+
while (at < chunk.length) {
|
|
18
|
+
if (preamble > 0 || pass > 0) {
|
|
19
|
+
const count = Math.min(preamble > 0 ? preamble : pass, chunk.length - at);
|
|
20
|
+
controller.enqueue(chunk.subarray(at, at + count));
|
|
21
|
+
if (preamble > 0) preamble -= count;
|
|
22
|
+
else pass -= count;
|
|
23
|
+
at += count;
|
|
24
|
+
} else if (drop > 0) {
|
|
25
|
+
const count = Math.min(drop, chunk.length - at);
|
|
26
|
+
drop -= count;
|
|
27
|
+
at += count;
|
|
28
|
+
} else {
|
|
29
|
+
const byte = chunk[at++]!;
|
|
30
|
+
header.push(byte);
|
|
31
|
+
if (header.length === 1 || byte & 0x80) {
|
|
32
|
+
if (header.length > 6) throw new Error('a WebAssembly section size is longer than five bytes');
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
let size = 0;
|
|
36
|
+
for (let i = header.length - 1; i >= 1; i--) size = size * 128 + (header[i]! & 0x7f);
|
|
37
|
+
if (header[0] === 0) drop = size;
|
|
38
|
+
else {
|
|
39
|
+
controller.enqueue(new Uint8Array(header));
|
|
40
|
+
pass = size;
|
|
41
|
+
}
|
|
42
|
+
header = [];
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
},
|
|
46
|
+
flush() {
|
|
47
|
+
if (preamble > 0 || pass > 0 || drop > 0 || header.length > 0) throw new Error('a WebAssembly module ended early');
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** `module` without its custom sections, at once: what `withoutCustomSections` streams. */
|
|
53
|
+
export function stripCustomSections(module: Uint8Array): Uint8Array {
|
|
54
|
+
const kept: Uint8Array[] = [module.subarray(0, 8)];
|
|
55
|
+
let at = 8;
|
|
56
|
+
while (at < module.length) {
|
|
57
|
+
const start = at;
|
|
58
|
+
const id = module[at++]!;
|
|
59
|
+
let size = 0;
|
|
60
|
+
for (let shift = 0; ; shift += 7) {
|
|
61
|
+
if (at >= module.length) throw new Error('a WebAssembly module ended early');
|
|
62
|
+
const byte = module[at++]!;
|
|
63
|
+
size += (byte & 0x7f) * 2 ** shift;
|
|
64
|
+
if (!(byte & 0x80)) break;
|
|
65
|
+
}
|
|
66
|
+
if (at + size > module.length) throw new Error('a WebAssembly module ended early');
|
|
67
|
+
if (id !== 0) kept.push(module.subarray(start, at + size));
|
|
68
|
+
at += size;
|
|
69
|
+
}
|
|
70
|
+
const out = new Uint8Array(kept.reduce((total, part) => total + part.length, 0));
|
|
71
|
+
let offset = 0;
|
|
72
|
+
for (const part of kept) {
|
|
73
|
+
out.set(part, offset);
|
|
74
|
+
offset += part.length;
|
|
75
|
+
}
|
|
76
|
+
return out;
|
|
77
|
+
}
|