@swiftbrowser/web 0.1.0 → 0.2.0
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/README.md +43 -6
- package/app/index.html +11 -0
- package/node/dist/node/apps.d.ts +16 -0
- package/node/dist/node/apps.d.ts.map +1 -1
- package/node/dist/node/apps.js +9 -0
- package/node/dist/node/apps.js.map +1 -1
- package/node/dist/node/config.d.ts +14 -1
- package/node/dist/node/config.d.ts.map +1 -1
- package/node/dist/node/config.js +20 -3
- package/node/dist/node/config.js.map +1 -1
- package/node/dist/node/examples.d.ts +2 -1
- package/node/dist/node/examples.d.ts.map +1 -1
- package/node/dist/node/examples.js +11 -2
- package/node/dist/node/examples.js.map +1 -1
- package/node/dist/plugins/app-manifests.d.ts +4 -1
- package/node/dist/plugins/app-manifests.d.ts.map +1 -1
- package/node/dist/plugins/app-manifests.js +3 -2
- package/node/dist/plugins/app-manifests.js.map +1 -1
- package/node/dist/plugins/asset-catalogs.d.ts +15 -3
- package/node/dist/plugins/asset-catalogs.d.ts.map +1 -1
- package/node/dist/plugins/asset-catalogs.js +111 -13
- package/node/dist/plugins/asset-catalogs.js.map +1 -1
- package/node/dist/plugins/included-sites.d.ts +26 -0
- package/node/dist/plugins/included-sites.d.ts.map +1 -0
- package/node/dist/plugins/included-sites.js +70 -0
- package/node/dist/plugins/included-sites.js.map +1 -0
- package/node/dist/plugins/pdfImages.d.ts +27 -0
- package/node/dist/plugins/pdfImages.d.ts.map +1 -0
- package/node/dist/plugins/pdfImages.js +145 -0
- package/node/dist/plugins/pdfImages.js.map +1 -0
- package/node/dist/plugins/swift-wasm.d.ts +19 -1
- package/node/dist/plugins/swift-wasm.d.ts.map +1 -1
- package/node/dist/plugins/swift-wasm.js +45 -7
- package/node/dist/plugins/swift-wasm.js.map +1 -1
- package/node/dist/plugins/symbolTemplates.d.ts +22 -0
- package/node/dist/plugins/symbolTemplates.d.ts.map +1 -0
- package/node/dist/plugins/symbolTemplates.js +83 -0
- package/node/dist/plugins/symbolTemplates.js.map +1 -0
- package/package.json +4 -2
- package/plugins/app-manifests.ts +3 -2
- package/plugins/asset-catalogs.ts +119 -14
- package/plugins/included-sites.ts +74 -0
- package/plugins/pdfImages.ts +163 -0
- package/plugins/swift-wasm.ts +52 -6
- package/plugins/symbolTemplates.ts +88 -0
- package/public/_headers +7 -0
- package/public/app/oauth-callback.html +62 -0
- package/src/animation.ts +235 -8
- package/src/appFiles.ts +201 -0
- package/src/base64.ts +43 -0
- package/src/bitmapPlan.ts +143 -0
- package/src/bitmapStore.ts +206 -0
- package/src/bitmapWorker.ts +41 -0
- package/src/bitmapWorkerProtocol.ts +15 -0
- package/src/bitmaps.ts +283 -0
- package/src/blockedHosts.ts +29 -0
- package/src/canvasDrawing.ts +361 -0
- package/src/chartDom.ts +2 -2
- package/src/chromeColor.ts +179 -0
- package/src/clipboard.ts +78 -0
- package/src/colorMultiply.ts +93 -0
- package/src/colorPicker.ts +44 -0
- package/src/corsProxy.ts +45 -0
- package/src/gaugeRing.ts +74 -0
- package/src/gif.ts +297 -0
- package/src/hostRequests.ts +426 -0
- package/src/imageOps.ts +592 -0
- package/src/interaction.ts +18 -1
- package/src/landing.ts +2 -2
- package/src/layout/chart.ts +9 -3
- package/src/layout/engine.ts +279 -25
- package/src/layout/index.ts +3 -1
- package/src/layout/text.ts +217 -1
- package/src/layout/types.ts +31 -0
- package/src/layout/typography.ts +24 -7
- package/src/layout/video.ts +24 -0
- package/src/layoutDom.ts +53 -3
- package/src/main.ts +124 -4
- package/src/media.ts +132 -0
- package/src/mock.ts +2 -1
- package/src/notifications.ts +109 -0
- package/src/pathShape.ts +156 -0
- package/src/protocol.ts +761 -12
- package/src/renderer.ts +584 -36
- package/src/richText.ts +65 -0
- package/src/scanner.ts +234 -0
- package/src/scrollTo.ts +123 -0
- package/src/styles.css +212 -7
- package/src/styles.ts +31 -0
- package/src/swiftData.ts +128 -0
- package/src/symbols.ts +564 -21
- package/src/translation.ts +126 -0
- package/src/video.ts +299 -0
- package/src/vite-env.d.ts +6 -0
- package/src/wasm.ts +227 -32
- package/src/webAuth.ts +267 -0
- package/src/webSocket.ts +148 -0
- package/src/zoneinfo.ts +269 -0
package/src/webSocket.ts
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `URLSessionWebSocketTask` over the browser's WebSocket (docs/ops-protocol.md,
|
|
3
|
+
* "WebSockets"). Each call the task makes is a `websocket` request:
|
|
4
|
+
*
|
|
5
|
+
* - `open` connects; it answers `{ socket }` (the socket's id) once the socket
|
|
6
|
+
* is open, and fails like a fetch (`network: …`) when it cannot connect;
|
|
7
|
+
* - `send` sends `string` (text) or `data` (base64 bytes) and answers `{}`;
|
|
8
|
+
* - `receive` answers the next message, `{ string }` or `{ data }`. Messages
|
|
9
|
+
* that arrive with nobody receiving wait in a queue. Once the socket has
|
|
10
|
+
* closed and the queue is empty, it answers `{ closeCode, reason }`;
|
|
11
|
+
* - `close` closes with `code` and `reason` and answers `{}`.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { WebSocketRequestParams } from './protocol';
|
|
15
|
+
|
|
16
|
+
/** The part of `WebSocket` the requests use; tests substitute a fake. */
|
|
17
|
+
export interface WebSocketLike {
|
|
18
|
+
binaryType: string;
|
|
19
|
+
readonly readyState: number;
|
|
20
|
+
onopen: ((event: unknown) => void) | null;
|
|
21
|
+
onmessage: ((event: { data: unknown }) => void) | null;
|
|
22
|
+
onerror: ((event: unknown) => void) | null;
|
|
23
|
+
onclose: ((event: { code: number; reason: string }) => void) | null;
|
|
24
|
+
send(data: string | ArrayBufferLike | Uint8Array): void;
|
|
25
|
+
close(code?: number, reason?: string): void;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export type WebSocketFactory = (url: string, protocols: string[]) => WebSocketLike;
|
|
29
|
+
|
|
30
|
+
export type WebSocketMessage = { string: string } | { data: string } | { closeCode: number; reason: string };
|
|
31
|
+
|
|
32
|
+
interface OpenSocket {
|
|
33
|
+
socket: WebSocketLike;
|
|
34
|
+
queue: WebSocketMessage[];
|
|
35
|
+
waiting: ((message: WebSocketMessage) => void)[];
|
|
36
|
+
closed?: { closeCode: number; reason: string };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function base64(bytes: Uint8Array): string {
|
|
40
|
+
let binary = '';
|
|
41
|
+
for (const byte of bytes) binary += String.fromCharCode(byte);
|
|
42
|
+
return btoa(binary);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function bytes(base64Text: string): Uint8Array {
|
|
46
|
+
const binary = atob(base64Text);
|
|
47
|
+
const out = new Uint8Array(binary.length);
|
|
48
|
+
for (let i = 0; i < binary.length; i += 1) out[i] = binary.charCodeAt(i);
|
|
49
|
+
return out;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The sockets one page has open, by id. */
|
|
53
|
+
export class WebSocketHost {
|
|
54
|
+
private nextID = 1;
|
|
55
|
+
private readonly sockets = new Map<number, OpenSocket>();
|
|
56
|
+
|
|
57
|
+
constructor(private readonly create: WebSocketFactory) {}
|
|
58
|
+
|
|
59
|
+
perform(params: WebSocketRequestParams): Promise<Record<string, unknown>> {
|
|
60
|
+
switch (params.action) {
|
|
61
|
+
case 'open':
|
|
62
|
+
return this.open(params.url ?? '', params.protocols ?? []);
|
|
63
|
+
case 'send':
|
|
64
|
+
return Promise.resolve(this.send(params));
|
|
65
|
+
case 'receive':
|
|
66
|
+
return this.receive(params.socket ?? 0);
|
|
67
|
+
case 'close':
|
|
68
|
+
return Promise.resolve(this.close(params));
|
|
69
|
+
default:
|
|
70
|
+
return Promise.reject(new Error(`unsupported: websocket action ${String((params as { action: unknown }).action)}`));
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
private open(url: string, protocols: string[]): Promise<Record<string, unknown>> {
|
|
75
|
+
return new Promise((resolve, reject) => {
|
|
76
|
+
let socket: WebSocketLike;
|
|
77
|
+
try {
|
|
78
|
+
socket = this.create(url, protocols);
|
|
79
|
+
} catch (error) {
|
|
80
|
+
reject(new Error(`network: ${error instanceof Error ? error.message : String(error)}`));
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
socket.binaryType = 'arraybuffer';
|
|
84
|
+
const id = this.nextID;
|
|
85
|
+
this.nextID += 1;
|
|
86
|
+
const entry: OpenSocket = { socket, queue: [], waiting: [] };
|
|
87
|
+
let opened = false;
|
|
88
|
+
socket.onopen = () => {
|
|
89
|
+
opened = true;
|
|
90
|
+
this.sockets.set(id, entry);
|
|
91
|
+
resolve({ socket: id });
|
|
92
|
+
};
|
|
93
|
+
socket.onmessage = (event) => {
|
|
94
|
+
const data = event.data;
|
|
95
|
+
const message: WebSocketMessage = typeof data === 'string'
|
|
96
|
+
? { string: data }
|
|
97
|
+
: { data: base64(new Uint8Array(data as ArrayBuffer)) };
|
|
98
|
+
this.deliver(entry, message);
|
|
99
|
+
};
|
|
100
|
+
socket.onerror = () => {
|
|
101
|
+
if (!opened) reject(new Error(`network: cannot connect to ${url}`));
|
|
102
|
+
};
|
|
103
|
+
socket.onclose = (event) => {
|
|
104
|
+
if (!opened) {
|
|
105
|
+
reject(new Error(`network: the connection to ${url} closed (${event.code})`));
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
entry.closed = { closeCode: event.code, reason: event.reason };
|
|
109
|
+
for (const wake of entry.waiting.splice(0)) wake(entry.closed);
|
|
110
|
+
};
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
private deliver(entry: OpenSocket, message: WebSocketMessage): void {
|
|
115
|
+
const wake = entry.waiting.shift();
|
|
116
|
+
if (wake) wake(message);
|
|
117
|
+
else entry.queue.push(message);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
private receive(id: number): Promise<Record<string, unknown>> {
|
|
121
|
+
const entry = this.sockets.get(id);
|
|
122
|
+
if (!entry) return Promise.reject(new Error(`network: no open socket ${id}`));
|
|
123
|
+
const queued = entry.queue.shift();
|
|
124
|
+
if (queued) return Promise.resolve(queued);
|
|
125
|
+
if (entry.closed) return Promise.resolve(entry.closed);
|
|
126
|
+
return new Promise((resolve) => entry.waiting.push(resolve));
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
private send(params: WebSocketRequestParams): Record<string, unknown> {
|
|
130
|
+
const entry = this.sockets.get(params.socket ?? 0);
|
|
131
|
+
if (!entry || entry.closed) throw new Error(`network: socket ${params.socket ?? 0} is not open`);
|
|
132
|
+
if (params.string !== undefined) entry.socket.send(params.string);
|
|
133
|
+
else entry.socket.send(bytes(params.data ?? ''));
|
|
134
|
+
return {};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
private close(params: WebSocketRequestParams): Record<string, unknown> {
|
|
138
|
+
const entry = this.sockets.get(params.socket ?? 0);
|
|
139
|
+
if (!entry) return {};
|
|
140
|
+
// Browsers accept 1000 and 3000–4999 from a page; other codes close normally.
|
|
141
|
+
const code = params.code === 1000 || (params.code !== undefined && params.code >= 3000 && params.code <= 4999) ? params.code : 1000;
|
|
142
|
+
entry.socket.close(code, params.reason ?? '');
|
|
143
|
+
this.sockets.delete(params.socket ?? 0);
|
|
144
|
+
const closed = { closeCode: code, reason: params.reason ?? '' };
|
|
145
|
+
for (const wake of entry.waiting.splice(0)) wake(closed);
|
|
146
|
+
return {};
|
|
147
|
+
}
|
|
148
|
+
}
|
package/src/zoneinfo.ts
ADDED
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Time zone files for the app, made from the browser's own time zone data.
|
|
3
|
+
*
|
|
4
|
+
* Foundation's CoreFoundation layer (`ISO8601DateFormatter`, `DateFormatter`
|
|
5
|
+
* with a calendar, `NSCalendar`) reads a zone as a TZif file under
|
|
6
|
+
* `/usr/share/zoneinfo`, as on Linux, and traps when it is missing: with the
|
|
7
|
+
* viewer's time zone in `TZ` (anything but GMT), Ice Cubes and TelemetryDeck
|
|
8
|
+
* stopped in their first date formatting. The page has no zoneinfo files, but
|
|
9
|
+
* `Intl` knows every IANA zone, so the directory mounted there makes each
|
|
10
|
+
* file the first time the app opens it: the zone's UTC offsets from 1970 to
|
|
11
|
+
* 2037, their abbreviations and whether each is daylight time (TZif version 1;
|
|
12
|
+
* later dates keep the last offset). Formatting and calendars themselves run
|
|
13
|
+
* on ICU's own zone data; this file is what CoreFoundation needs to make the
|
|
14
|
+
* zone object they are given.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { Directory, File, type Inode } from '@bjorn3/browser_wasi_shim';
|
|
18
|
+
|
|
19
|
+
/** The first and last instants covered, in seconds (TZif version 1 times are 32-bit). */
|
|
20
|
+
const FIRST = Date.UTC(1970, 0, 1) / 1000;
|
|
21
|
+
const LAST = 2_147_483_647;
|
|
22
|
+
/** Offsets are sampled this often; changes in between are found by bisection. */
|
|
23
|
+
const STEP = 14 * 86_400;
|
|
24
|
+
|
|
25
|
+
/** Whether `zone` is a time zone the browser knows (IANA names and their aliases). */
|
|
26
|
+
export function isTimeZone(zone: string): boolean {
|
|
27
|
+
if (!/^[A-Za-z0-9_+\-/]+$/.test(zone)) return false;
|
|
28
|
+
try {
|
|
29
|
+
new Intl.DateTimeFormat('en-US', { timeZone: zone });
|
|
30
|
+
return true;
|
|
31
|
+
} catch {
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The local time zone's name, as `Intl` reports it. */
|
|
37
|
+
function localZone(): string | null {
|
|
38
|
+
try {
|
|
39
|
+
return Intl.DateTimeFormat().resolvedOptions().timeZone ?? null;
|
|
40
|
+
} catch {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** A function giving `zone`'s offset from UTC in seconds at an instant (seconds since 1970). */
|
|
46
|
+
function offsetFunction(zone: string): (seconds: number) => number {
|
|
47
|
+
if (zone === localZone()) {
|
|
48
|
+
// The page's own zone: `Date` answers without `Intl`'s formatting.
|
|
49
|
+
return (seconds) => -new Date(seconds * 1000).getTimezoneOffset() * 60;
|
|
50
|
+
}
|
|
51
|
+
const format = new Intl.DateTimeFormat('en-US', {
|
|
52
|
+
timeZone: zone,
|
|
53
|
+
hourCycle: 'h23',
|
|
54
|
+
year: 'numeric',
|
|
55
|
+
month: 'numeric',
|
|
56
|
+
day: 'numeric',
|
|
57
|
+
hour: 'numeric',
|
|
58
|
+
minute: 'numeric',
|
|
59
|
+
second: 'numeric',
|
|
60
|
+
});
|
|
61
|
+
return (seconds) => {
|
|
62
|
+
const parts: Record<string, number> = {};
|
|
63
|
+
for (const part of format.formatToParts(new Date(seconds * 1000))) {
|
|
64
|
+
if (part.type !== 'literal') parts[part.type] = Number(part.value);
|
|
65
|
+
}
|
|
66
|
+
const wall = Date.UTC(parts['year']!, parts['month']! - 1, parts['day']!, parts['hour']! % 24, parts['minute']!, parts['second']!) / 1000;
|
|
67
|
+
return Math.round((wall - seconds) / 60) * 60;
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** `zone`'s abbreviation at an instant: `PDT`, or `+0530` where the browser has no letters for it. */
|
|
72
|
+
function abbreviationFunction(zone: string): (seconds: number, offset: number) => string {
|
|
73
|
+
let format: Intl.DateTimeFormat | null = null;
|
|
74
|
+
try {
|
|
75
|
+
format = new Intl.DateTimeFormat('en-US', { timeZone: zone, timeZoneName: 'short' });
|
|
76
|
+
} catch {
|
|
77
|
+
format = null;
|
|
78
|
+
}
|
|
79
|
+
return (seconds, offset) => {
|
|
80
|
+
const name = format?.formatToParts(new Date(seconds * 1000)).find((part) => part.type === 'timeZoneName')?.value ?? '';
|
|
81
|
+
if (/^[A-Za-z]{3,6}$/.test(name)) return name;
|
|
82
|
+
if (offset === 0) return zone === 'UTC' || zone.endsWith('/UTC') ? 'UTC' : 'GMT';
|
|
83
|
+
const sign = offset < 0 ? '-' : '+';
|
|
84
|
+
const minutes = Math.abs(offset) / 60;
|
|
85
|
+
const hh = String(Math.floor(minutes / 60)).padStart(2, '0');
|
|
86
|
+
const mm = minutes % 60;
|
|
87
|
+
return mm === 0 ? `${sign}${hh}` : `${sign}${hh}${String(mm).padStart(2, '0')}`;
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
interface Transition {
|
|
92
|
+
at: number;
|
|
93
|
+
offset: number;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** The instants from 1970 to 2037 at which `offset` changes, and the offset from each. */
|
|
97
|
+
export function transitions(offset: (seconds: number) => number): { initial: number; changes: Transition[] } {
|
|
98
|
+
const initial = offset(FIRST);
|
|
99
|
+
const changes: Transition[] = [];
|
|
100
|
+
let before = initial;
|
|
101
|
+
let from = FIRST;
|
|
102
|
+
while (from < LAST) {
|
|
103
|
+
const to = Math.min(from + STEP, LAST);
|
|
104
|
+
const after = offset(to);
|
|
105
|
+
if (after !== before) {
|
|
106
|
+
// The first second at which the offset is no longer `before`.
|
|
107
|
+
let lo = from;
|
|
108
|
+
let hi = to;
|
|
109
|
+
while (hi - lo > 1) {
|
|
110
|
+
const mid = Math.floor((lo + hi) / 2);
|
|
111
|
+
if (offset(mid) === before) lo = mid;
|
|
112
|
+
else hi = mid;
|
|
113
|
+
}
|
|
114
|
+
changes.push({ at: hi, offset: offset(hi) });
|
|
115
|
+
before = offset(hi);
|
|
116
|
+
// A second change inside the same step is found from here.
|
|
117
|
+
from = hi;
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
from = to;
|
|
121
|
+
}
|
|
122
|
+
return { initial, changes };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** The TZif (version 1) file for `zone`, or null when the browser does not know it. */
|
|
126
|
+
export function tzif(zone: string): Uint8Array | null {
|
|
127
|
+
if (!isTimeZone(zone)) return null;
|
|
128
|
+
const offset = offsetFunction(zone);
|
|
129
|
+
const abbreviation = abbreviationFunction(zone);
|
|
130
|
+
const { initial, changes } = transitions(offset);
|
|
131
|
+
|
|
132
|
+
// Local time types: one per distinct (offset, abbreviation, daylight).
|
|
133
|
+
const types: { offset: number; dst: boolean; abbr: string }[] = [];
|
|
134
|
+
const typeIndex = new Map<string, number>();
|
|
135
|
+
const standardOffset = (seconds: number): number => {
|
|
136
|
+
const year = new Date(seconds * 1000).getUTCFullYear();
|
|
137
|
+
return Math.min(offset(Date.UTC(year, 0, 1) / 1000), offset(Date.UTC(year, 6, 1) / 1000));
|
|
138
|
+
};
|
|
139
|
+
const typeFor = (at: number, value: number): number => {
|
|
140
|
+
const abbr = abbreviation(at, value);
|
|
141
|
+
const dst = value > standardOffset(at);
|
|
142
|
+
const key = `${value}|${dst}|${abbr}`;
|
|
143
|
+
let index = typeIndex.get(key);
|
|
144
|
+
if (index === undefined) {
|
|
145
|
+
index = types.length;
|
|
146
|
+
types.push({ offset: value, dst, abbr });
|
|
147
|
+
typeIndex.set(key, index);
|
|
148
|
+
}
|
|
149
|
+
return index;
|
|
150
|
+
};
|
|
151
|
+
// Type 0 is the one in force before the first transition.
|
|
152
|
+
typeFor(FIRST, initial);
|
|
153
|
+
const changeTypes = changes.map((change) => typeFor(change.at, change.offset));
|
|
154
|
+
|
|
155
|
+
const chars: number[] = [];
|
|
156
|
+
const abbrIndex = new Map<string, number>();
|
|
157
|
+
for (const type of types) {
|
|
158
|
+
if (abbrIndex.has(type.abbr)) continue;
|
|
159
|
+
abbrIndex.set(type.abbr, chars.length);
|
|
160
|
+
for (const c of type.abbr) chars.push(c.charCodeAt(0) & 0x7f);
|
|
161
|
+
chars.push(0);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const size = 44 + changes.length * 5 + types.length * 6 + chars.length;
|
|
165
|
+
const out = new Uint8Array(size);
|
|
166
|
+
const view = new DataView(out.buffer);
|
|
167
|
+
out.set([0x54, 0x5a, 0x69, 0x66], 0); // "TZif", version 1 (byte 4 zero), 15 reserved bytes
|
|
168
|
+
// isutcnt, isstdcnt, leapcnt, timecnt, typecnt, charcnt
|
|
169
|
+
const counts = [0, 0, 0, changes.length, types.length, chars.length];
|
|
170
|
+
counts.forEach((count, i) => view.setUint32(20 + i * 4, count));
|
|
171
|
+
let at = 44;
|
|
172
|
+
for (const change of changes) {
|
|
173
|
+
view.setInt32(at, change.at);
|
|
174
|
+
at += 4;
|
|
175
|
+
}
|
|
176
|
+
for (const index of changeTypes) out[at++] = index;
|
|
177
|
+
for (const type of types) {
|
|
178
|
+
view.setInt32(at, type.offset);
|
|
179
|
+
out[at + 4] = type.dst ? 1 : 0;
|
|
180
|
+
out[at + 5] = abbrIndex.get(type.abbr)!;
|
|
181
|
+
at += 6;
|
|
182
|
+
}
|
|
183
|
+
out.set(chars, at);
|
|
184
|
+
return out;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Every zone name the browser lists, for listing the directory. */
|
|
188
|
+
function listedZones(): string[] {
|
|
189
|
+
const supported = (Intl as { supportedValuesOf?: (key: string) => string[] }).supportedValuesOf;
|
|
190
|
+
const zones = supported ? supported('timeZone') : [];
|
|
191
|
+
return [...new Set([...zones, 'UTC', 'GMT', 'Etc/UTC', 'Etc/GMT'])];
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* The entries under `prefix` (`""` for the root, `America/` …), made when
|
|
196
|
+
* looked up: a zone becomes its file, a name that only leads to zones a
|
|
197
|
+
* directory. Listing the directory lists the zones the browser names.
|
|
198
|
+
*/
|
|
199
|
+
class ZoneEntries extends Map<string, Inode> {
|
|
200
|
+
private listed = false;
|
|
201
|
+
|
|
202
|
+
constructor(private readonly prefix: string) {
|
|
203
|
+
super();
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
override get(name: string): Inode | undefined {
|
|
207
|
+
if (!super.has(name)) this.make(name);
|
|
208
|
+
return super.get(name);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
override has(name: string): boolean {
|
|
212
|
+
return this.get(name) !== undefined;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
private make(name: string): void {
|
|
216
|
+
if (!/^[A-Za-z0-9_+-]+$/.test(name)) return;
|
|
217
|
+
const path = this.prefix + name;
|
|
218
|
+
const data = tzif(path);
|
|
219
|
+
if (data) {
|
|
220
|
+
super.set(name, new File(data, { readonly: true }));
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
if (listedZones().some((zone) => zone.startsWith(`${path}/`))) {
|
|
224
|
+
super.set(name, new Directory(new ZoneEntries(`${path}/`)));
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Every entry, made (only when the app lists the directory). */
|
|
229
|
+
private fill(): void {
|
|
230
|
+
if (this.listed) return;
|
|
231
|
+
this.listed = true;
|
|
232
|
+
for (const zone of listedZones()) {
|
|
233
|
+
if (!zone.startsWith(this.prefix)) continue;
|
|
234
|
+
this.get(zone.slice(this.prefix.length).split('/')[0]!);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
override get size(): number {
|
|
239
|
+
this.fill();
|
|
240
|
+
return super.size;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
override entries(): MapIterator<[string, Inode]> {
|
|
244
|
+
this.fill();
|
|
245
|
+
return super.entries();
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
override keys(): MapIterator<string> {
|
|
249
|
+
this.fill();
|
|
250
|
+
return super.keys();
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// `values()` is not filled: the shim's `Directory` walks it when made, to
|
|
254
|
+
// link its subdirectories; listing (`entries`, `size`) fills.
|
|
255
|
+
|
|
256
|
+
override [Symbol.iterator](): MapIterator<[string, Inode]> {
|
|
257
|
+
return this.entries();
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
override forEach(callback: (value: Inode, key: string, map: Map<string, Inode>) => void, thisArg?: unknown): void {
|
|
261
|
+
this.fill();
|
|
262
|
+
super.forEach(callback, thisArg);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** The contents of `/usr/share/zoneinfo`: every zone the browser knows, made when opened. */
|
|
267
|
+
export function zoneInfoEntries(): Map<string, Inode> {
|
|
268
|
+
return new ZoneEntries('');
|
|
269
|
+
}
|