@effindomv2/fui-as 0.1.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.
Files changed (137) hide show
  1. package/LICENSE.md +7 -0
  2. package/browser/src/common-harness/host-imports.ts +430 -0
  3. package/browser/src/common-harness/interop.ts +39 -0
  4. package/browser/src/common-harness/managed-harness-bitmap-host.ts +92 -0
  5. package/browser/src/common-harness/managed-harness-fetch-host.ts +201 -0
  6. package/browser/src/common-harness/managed-harness-file-host.ts +1101 -0
  7. package/browser/src/common-harness/managed-harness-file-payloads.ts +143 -0
  8. package/browser/src/common-harness/managed-harness-file-types.ts +106 -0
  9. package/browser/src/common-harness/managed-harness-session.ts +15 -0
  10. package/browser/src/common-harness/managed-harness.ts +1323 -0
  11. package/browser/src/common-harness/managed-history.ts +168 -0
  12. package/browser/src/common-harness/persisted-restore-policy.ts +50 -0
  13. package/browser/src/common-harness/persisted-ui-state-controller.ts +309 -0
  14. package/browser/src/common-harness/text-session-bridge.ts +452 -0
  15. package/browser/src/common-harness/types.ts +205 -0
  16. package/browser/src/common-harness/ui-chrome.ts +191 -0
  17. package/browser/src/common-harness/ui-imports.ts +529 -0
  18. package/browser/src/common-harness/wasm-module-cache.ts +47 -0
  19. package/browser/src/common-harness.ts +27 -0
  20. package/browser/src/file-processing-worker.ts +89 -0
  21. package/browser/src/host-events.ts +97 -0
  22. package/browser/src/host-services.ts +203 -0
  23. package/browser/src/index.ts +62 -0
  24. package/browser/src/persisted-ui-state.ts +206 -0
  25. package/browser/src/routed-harness.ts +198 -0
  26. package/browser/src/worker-bootstrap.ts +483 -0
  27. package/browser/src/worker-manager.ts +230 -0
  28. package/browser/src/worker-types.ts +50 -0
  29. package/package.json +89 -0
  30. package/scripts/build-demo-as.sh +91 -0
  31. package/scripts/build.sh +325 -0
  32. package/scripts/generate-host-events.ts +175 -0
  33. package/scripts/generate-host-services.ts +157 -0
  34. package/src/Fui.ts +205 -0
  35. package/src/FuiExports.ts +55 -0
  36. package/src/FuiPrimitives.ts +15 -0
  37. package/src/FuiWorker.ts +3 -0
  38. package/src/FuiWorkerExports.ts +6 -0
  39. package/src/bindings/ui.ts +531 -0
  40. package/src/color.ts +86 -0
  41. package/src/controls/AntiSelectionArea.ts +23 -0
  42. package/src/controls/Button.ts +750 -0
  43. package/src/controls/Checkbox.ts +181 -0
  44. package/src/controls/ContextMenu.ts +885 -0
  45. package/src/controls/ControlTemplateSet.ts +37 -0
  46. package/src/controls/Dialog.ts +355 -0
  47. package/src/controls/Dropdown.ts +856 -0
  48. package/src/controls/Form.ts +110 -0
  49. package/src/controls/NavLink.ts +211 -0
  50. package/src/controls/Popup.ts +129 -0
  51. package/src/controls/ProgressBar.ts +180 -0
  52. package/src/controls/RadioButton.ts +135 -0
  53. package/src/controls/RadioGroup.ts +244 -0
  54. package/src/controls/SelectionArea.ts +75 -0
  55. package/src/controls/Slider.ts +471 -0
  56. package/src/controls/Switch.ts +132 -0
  57. package/src/controls/TextArea.ts +20 -0
  58. package/src/controls/TextInput.ts +7 -0
  59. package/src/controls/index.ts +18 -0
  60. package/src/controls/internal/ButtonPresenter.ts +95 -0
  61. package/src/controls/internal/CheckboxIndicatorPresenter.ts +93 -0
  62. package/src/controls/internal/DropdownChevronPresenter.ts +67 -0
  63. package/src/controls/internal/DropdownFieldPresenter.ts +110 -0
  64. package/src/controls/internal/DropdownOptionRowPresenter.ts +82 -0
  65. package/src/controls/internal/PopupPresenter.ts +198 -0
  66. package/src/controls/internal/PressableIndicatorPresenter.ts +32 -0
  67. package/src/controls/internal/PressableLabeledControl.ts +221 -0
  68. package/src/controls/internal/RadioIndicatorPresenter.ts +73 -0
  69. package/src/controls/internal/SliderPresenter.ts +157 -0
  70. package/src/controls/internal/SwitchIndicatorPresenter.ts +72 -0
  71. package/src/controls/internal/TextInputCore.ts +695 -0
  72. package/src/controls/internal/TextInputPresenter.ts +72 -0
  73. package/src/controls/templating.ts +54 -0
  74. package/src/core/Action.ts +94 -0
  75. package/src/core/Actions.ts +37 -0
  76. package/src/core/Animation.ts +412 -0
  77. package/src/core/Application.ts +328 -0
  78. package/src/core/Assets.ts +264 -0
  79. package/src/core/AttachedProperties.ts +32 -0
  80. package/src/core/Bitmap.ts +70 -0
  81. package/src/core/BoundCallback.ts +104 -0
  82. package/src/core/Callbacks.ts +17 -0
  83. package/src/core/ContextMenuManager.ts +466 -0
  84. package/src/core/DebugApi.ts +30 -0
  85. package/src/core/Disposable.ts +10 -0
  86. package/src/core/DragDropManager.ts +179 -0
  87. package/src/core/DragGesture.ts +184 -0
  88. package/src/core/DynamicAssetIds.ts +24 -0
  89. package/src/core/Errors.ts +48 -0
  90. package/src/core/EventRouter.ts +408 -0
  91. package/src/core/ExternalDropManager.ts +122 -0
  92. package/src/core/Fetch.ts +264 -0
  93. package/src/core/FetchFfi.ts +15 -0
  94. package/src/core/File.ts +1002 -0
  95. package/src/core/FocusAdornerManager.ts +263 -0
  96. package/src/core/FocusVisibility.ts +36 -0
  97. package/src/core/FrameScheduler.ts +28 -0
  98. package/src/core/KeyboardScroll.ts +161 -0
  99. package/src/core/KeyboardScrollTracker.ts +386 -0
  100. package/src/core/Logger.ts +80 -0
  101. package/src/core/Navigation.ts +13 -0
  102. package/src/core/Node.ts +1708 -0
  103. package/src/core/PersistedState.ts +102 -0
  104. package/src/core/PersistedUiState.ts +142 -0
  105. package/src/core/Platform.ts +219 -0
  106. package/src/core/Signal.ts +89 -0
  107. package/src/core/Theme.ts +365 -0
  108. package/src/core/Timers.ts +129 -0
  109. package/src/core/ToolTip.ts +122 -0
  110. package/src/core/ToolTipManager.ts +459 -0
  111. package/src/core/Transitions.ts +34 -0
  112. package/src/core/Typography.ts +204 -0
  113. package/src/core/Worker.ts +196 -0
  114. package/src/core/bind.ts +37 -0
  115. package/src/core/event_exports.ts +596 -0
  116. package/src/core/ffi.ts +728 -0
  117. package/src/host-services/runtime.ts +25 -0
  118. package/src/nodes/FlexBox.ts +789 -0
  119. package/src/nodes/GradientStop.ts +9 -0
  120. package/src/nodes/Grid.ts +183 -0
  121. package/src/nodes/Image.ts +189 -0
  122. package/src/nodes/Portal.ts +14 -0
  123. package/src/nodes/RichText.ts +312 -0
  124. package/src/nodes/ScrollBar.ts +570 -0
  125. package/src/nodes/ScrollBox.ts +415 -0
  126. package/src/nodes/ScrollState.ts +10 -0
  127. package/src/nodes/ScrollView.ts +511 -0
  128. package/src/nodes/Svg.ts +142 -0
  129. package/src/nodes/Text.ts +145 -0
  130. package/src/nodes/TextCore.ts +558 -0
  131. package/src/nodes/VirtualList.ts +431 -0
  132. package/src/nodes/helpers.ts +25 -0
  133. package/src/nodes/index.ts +14 -0
  134. package/src/tsconfig.json +7 -0
  135. package/src/worker/Worker.ts +169 -0
  136. package/src/worker/WorkerJob.ts +65 -0
  137. package/src/worker/ffi.ts +23 -0
@@ -0,0 +1,1323 @@
1
+ import { instantiate } from '@assemblyscript/loader';
2
+
3
+ import type { BridgeRuntime, EffinDomCallbacks, WasmHandleLike } from '@effindomv2/runtime';
4
+ import { listHostEventMethods, type HostEventsDefinition, type NormalizedHostEventMethod } from '../host-events';
5
+ import { createHostServiceImportModule, getHostServiceImportNames, type HostServicesDefinition } from '../host-services';
6
+ import { createWorkerManager } from '../worker-manager';
7
+ import {
8
+ addUiPointer as addRuntimeUiPointer,
9
+ normalizePointer,
10
+ toBigIntHandle,
11
+ toNumberHandle,
12
+ zeroPointer,
13
+ type AppHandleLike,
14
+ } from './interop';
15
+ import { createHostImportModule } from './host-imports';
16
+ import { createManagedHarnessBitmapHost } from './managed-harness-bitmap-host';
17
+ import { createManagedHarnessFileHost } from './managed-harness-file-host';
18
+ import {
19
+ EXTERNAL_DRAG_EVENT_DROP,
20
+ EXTERNAL_DRAG_EVENT_ENTER,
21
+ EXTERNAL_DRAG_EVENT_LEAVE,
22
+ EXTERNAL_DRAG_EVENT_OVER,
23
+ } from './managed-harness-file-types';
24
+ import { createManagedHarnessFetchHost } from './managed-harness-fetch-host';
25
+ import type { HarnessAppSession } from './managed-harness-session';
26
+ import {
27
+ canBrowserNavigateBack,
28
+ canBrowserNavigateForward,
29
+ ensureManagedHistoryInitialized,
30
+ navigateBrowserBack,
31
+ navigateBrowserForward,
32
+ pushManagedHistoryEntry,
33
+ readManagedHistoryState,
34
+ replaceManagedHistoryEntry,
35
+ syncManagedHistoryPop,
36
+ } from './managed-history';
37
+ import { PersistedUiStateController } from './persisted-ui-state-controller';
38
+ import { TextSessionBridge } from './text-session-bridge';
39
+ import type {
40
+ HarnessAppOptions,
41
+ HarnessContext,
42
+ HarnessController,
43
+ HarnessDebugApi,
44
+ HarnessExports,
45
+ HarnessNavigationMode,
46
+ HarnessOptions,
47
+ HarnessState,
48
+ ManagedHarnessOptions,
49
+ } from './types';
50
+ import { createUiImportModule } from './ui-imports';
51
+ import { HarnessUiChrome, waitForFrame } from './ui-chrome';
52
+
53
+ type AutoHarnessExports = HarnessExports & {
54
+ __runApp?: () => void;
55
+ __disposeApp?: () => void;
56
+ };
57
+
58
+ function tryResolveNavigationTarget(target: string): URL | null {
59
+ try {
60
+ return new URL(target, window.location.href);
61
+ } catch {
62
+ return null;
63
+ }
64
+ }
65
+
66
+ function toAppRoute(url: URL): string {
67
+ return `${url.pathname}${url.search}${url.hash}`;
68
+ }
69
+
70
+ const decoder = new TextDecoder();
71
+ const encoder = new TextEncoder();
72
+ const harnessUiChrome = new HarnessUiChrome();
73
+
74
+ function setLoadingOverlay(state: 'loading' | 'error', title: string, detail: string): void {
75
+ harnessUiChrome.setLoadingOverlay(state, title, detail);
76
+ }
77
+
78
+ function hideLoadingOverlay(): void {
79
+ harnessUiChrome.hideLoadingOverlay();
80
+ }
81
+
82
+ function describeHarnessError(error: unknown): string {
83
+ return error instanceof Error ? error.message : String(error);
84
+ }
85
+
86
+ function reportHarnessErrorOverlay(title: string, detail: string, error: unknown): void {
87
+ console.error(error instanceof Error ? error.stack ?? error : error);
88
+ setLoadingOverlay('error', title, detail);
89
+ const windowWithHarnessError = window as Window & { __fuiAsError?: string; __fuiAsReady?: boolean };
90
+ windowWithHarnessError.__fuiAsReady = false;
91
+ windowWithHarnessError.__fuiAsError = detail;
92
+ }
93
+
94
+ function defaultRunHarnessApp(exports: HarnessExports): void {
95
+ const autoExports = exports as AutoHarnessExports;
96
+ if (typeof autoExports.__runApp !== 'function') {
97
+ throw new Error(
98
+ 'startHarness default run requires an exported __runApp(). Provide run(...) if your entrypoint uses a different symbol.',
99
+ );
100
+ }
101
+ autoExports.__runApp();
102
+ }
103
+
104
+ function defaultOnDispose(exports: HarnessExports): void {
105
+ (exports as AutoHarnessExports).__disposeApp?.();
106
+ }
107
+
108
+ function defaultOnStateUpdated(state: HarnessState): void {
109
+ (window as Window & { __fuiAsState?: HarnessState }).__fuiAsState = state;
110
+ }
111
+
112
+ function defaultOnReady(): void {
113
+ const windowWithHarnessState = window as Window & { __fuiAsReady?: boolean; __fuiAsError?: string };
114
+ windowWithHarnessState.__fuiAsReady = true;
115
+ delete windowWithHarnessState.__fuiAsError;
116
+ }
117
+
118
+ function defaultOnError(error: unknown): void {
119
+ const windowWithHarnessState = window as Window & { __fuiAsReady?: boolean; __fuiAsError?: string };
120
+ windowWithHarnessState.__fuiAsReady = false;
121
+ windowWithHarnessState.__fuiAsError = describeHarnessError(error);
122
+ }
123
+
124
+ function setUrlPreviewText(text: string): void {
125
+ harnessUiChrome.setUrlPreviewText(text);
126
+ }
127
+
128
+ function detectPlatformFamily(): number {
129
+ return harnessUiChrome.detectPlatformFamily();
130
+ }
131
+
132
+ function detectCoarsePointer(): boolean {
133
+ return harnessUiChrome.detectCoarsePointer();
134
+ }
135
+
136
+ function getCanvasSizeSource(canvas: HTMLCanvasElement): HTMLElement | HTMLCanvasElement {
137
+ return harnessUiChrome.getCanvasSizeSource(canvas);
138
+ }
139
+
140
+ export function startHarness<Exports extends HarnessExports>(options: HarnessOptions<Exports>): void {
141
+ const loadOptions: HarnessAppOptions<Exports> = {
142
+ ...options,
143
+ run: options.run ?? ((exports) => defaultRunHarnessApp(exports)),
144
+ onStateUpdated: options.onStateUpdated ?? defaultOnStateUpdated,
145
+ onReady: options.onReady ?? (() => defaultOnReady()),
146
+ onDispose: options.onDispose ?? ((exports) => defaultOnDispose(exports)),
147
+ };
148
+ const onError = options.onError ?? defaultOnError;
149
+ startManagedHarness({
150
+ async onReady(controller): Promise<void> {
151
+ await controller.loadApp(loadOptions);
152
+ },
153
+ onError,
154
+ });
155
+ }
156
+
157
+ export function startManagedHarness(options: ManagedHarnessOptions): void {
158
+ let cleanup = () => {
159
+ delete window.__fui_debug;
160
+ };
161
+ setLoadingOverlay(
162
+ 'loading',
163
+ 'Teaching the pixels their lines...',
164
+ 'The runtime orchestra is tuning up behind the canvas.',
165
+ );
166
+ const bridge = window.EffinDomBrowserBridge;
167
+ if (bridge === undefined) {
168
+ throw new Error('EffinDomBrowserBridge is unavailable.');
169
+ }
170
+ if (typeof Worker !== 'function') {
171
+ throw new Error('FUI-AS requires browser Worker support.');
172
+ }
173
+ const bridgeState = bridge;
174
+ void bridgeState.ready.then(async (initialRuntime: BridgeRuntime) => {
175
+ ensureManagedHistoryInitialized();
176
+ const debugLogsEnabled = new URLSearchParams(window.location.search).get('debug-logs') === '1';
177
+ const darkModeQuery = window.matchMedia('(prefers-color-scheme: dark)');
178
+ let runtime = initialRuntime;
179
+ let currentSession: HarnessAppSession | null = null;
180
+ let navigationHandler: ((target: URL, mode: HarnessNavigationMode) => void | Promise<void>) | null = null;
181
+ let harnessFrameQueued = false;
182
+ let appFlushRequested = false;
183
+ const hostTimers = new Map<number, number>();
184
+ let lastHandledUrlHref = window.location.href;
185
+ let latestCommandWords: number[] = [];
186
+ let latestRootHandle: string | null = null;
187
+ const wasmByteCache = new Map<string, Promise<ArrayBuffer>>();
188
+ const wasmModuleCache = new Map<string, Promise<WebAssembly.Module>>();
189
+ const pendingGradients = new Map<string, {
190
+ startX: number;
191
+ startY: number;
192
+ endX: number;
193
+ endY: number;
194
+ stopCount: number;
195
+ offsets: number[];
196
+ colors: number[];
197
+ }>();
198
+ setUrlPreviewText('');
199
+
200
+ function getCurrentSession(): HarnessAppSession {
201
+ if (currentSession === null) {
202
+ throw new Error('No AssemblyScript app is currently mounted.');
203
+ }
204
+ return currentSession;
205
+ }
206
+
207
+ function getCurrentMemory(): WebAssembly.Memory {
208
+ return getCurrentSession().memory;
209
+ }
210
+
211
+ const persistedUiStateController = new PersistedUiStateController();
212
+ let textBridge: TextSessionBridge;
213
+
214
+ function readAppUtf8(ptr: number, len: number): string {
215
+ return textBridge.readAppUtf8(ptr, len);
216
+ }
217
+
218
+ function readAppFloats(ptr: number, count: number): Float32Array {
219
+ return textBridge.readAppFloats(ptr, count);
220
+ }
221
+
222
+ function readAppBytes(ptr: number, len: number): Uint8Array {
223
+ return textBridge.readAppBytes(ptr, len);
224
+ }
225
+
226
+ function readAppTextParts(ptr: number, len: number): string[] {
227
+ return textBridge.readAppTextParts(ptr, len);
228
+ }
229
+
230
+ function writeTextCallbackPayload(session: HarnessAppSession, text: string, context: string): number {
231
+ return textBridge.writeTextCallbackPayload(session, text, context);
232
+ }
233
+
234
+ function writeWorkerTextCallbackPayload(session: {
235
+ readonly memory: WebAssembly.Memory;
236
+ readonly textBufferPtr: number;
237
+ readonly textBufferSize: number;
238
+ }, text: string, context: string): number {
239
+ return textBridge.writeWorkerTextCallbackPayload(session, text, context);
240
+ }
241
+
242
+ function writeTextToSessionBuffer(session: HarnessAppSession, text: string): number {
243
+ return textBridge.writeTextToSessionBuffer(session, text);
244
+ }
245
+
246
+ function writeAppFloat32(ptr: number, value: number): void {
247
+ textBridge.writeAppFloat32(ptr, value);
248
+ }
249
+
250
+ function writeAppUint32(ptr: number, value: number): void {
251
+ textBridge.writeAppUint32(ptr, value);
252
+ }
253
+
254
+ function writeAppUtf8(ptr: number, capacity: number, text: string, context: string): number {
255
+ return textBridge.writeAppUtf8(ptr, capacity, text, context);
256
+ }
257
+
258
+ function withUiUtf8(
259
+ text: string,
260
+ callback: (ptr: WasmHandleLike | number, len: number) => void,
261
+ ): void {
262
+ textBridge.withUiUtf8(text, callback);
263
+ }
264
+
265
+ function withUiGridData(
266
+ values: Float32Array,
267
+ types: Uint8Array,
268
+ callback: (valuesPtr: WasmHandleLike | number, typesPtr: WasmHandleLike | number) => void,
269
+ ): void {
270
+ textBridge.withUiGridData(values, types, callback);
271
+ }
272
+
273
+ function withUiGradientData(
274
+ offsets: Float32Array,
275
+ colors: Uint32Array,
276
+ callback: (offsetsPtr: WasmHandleLike | number, colorsPtr: WasmHandleLike | number) => void,
277
+ ): void {
278
+ textBridge.withUiGradientData(offsets, colors, callback);
279
+ }
280
+
281
+ const bitmapHost = createManagedHarnessBitmapHost({
282
+ getRuntime: () => runtime,
283
+ readAppBytes,
284
+ notifyBitmapChanged(): void {
285
+ appFlushRequested = true;
286
+ runtime.requestFrame();
287
+ queueHarnessFrame();
288
+ },
289
+ });
290
+ bitmapHost.installReplay(runtime);
291
+
292
+ async function settleCurrentSessionAfterRestore(context: string): Promise<void> {
293
+ const session = currentSession;
294
+ if (session === null) {
295
+ return;
296
+ }
297
+ for (let iteration = 0; iteration < 2; iteration += 1) {
298
+ session.exports.__flushRenders();
299
+ runtime.flushPendingCommit();
300
+ await waitForFrame();
301
+ }
302
+ persistedUiStateController.restoreCurrentPersistedUiState(
303
+ `${context} after initial paint`,
304
+ currentSession?.exports.__fui_restore_persisted_ui_state,
305
+ );
306
+ for (let iteration = 0; iteration < 2; iteration += 1) {
307
+ session.exports.__flushRenders();
308
+ runtime.flushPendingCommit();
309
+ await waitForFrame();
310
+ }
311
+ }
312
+
313
+ function updateState(): void {
314
+ currentSession?.onStateUpdated?.({
315
+ commandWordCount: latestCommandWords.length,
316
+ commandWords: latestCommandWords,
317
+ rootHandle: latestRootHandle,
318
+ });
319
+ }
320
+
321
+ function queueHarnessFrame(): void {
322
+ if (harnessFrameQueued) {
323
+ return;
324
+ }
325
+ harnessFrameQueued = true;
326
+ requestAnimationFrame(() => {
327
+ requestAnimationFrame(() => {
328
+ latestCommandWords = Array.from(runtime.extractCommandBuffer());
329
+ updateState();
330
+ harnessFrameQueued = false;
331
+ });
332
+ });
333
+ }
334
+
335
+ textBridge = new TextSessionBridge(() => runtime, getCurrentMemory, queueHarnessFrame);
336
+
337
+ function addUiPointer(ptr: WasmHandleLike | number, byteOffset: number): WasmHandleLike | number {
338
+ return addRuntimeUiPointer(runtime, ptr, byteOffset);
339
+ }
340
+
341
+ function queuePersistedUiStateWork<T>(work: () => Promise<T>): Promise<T> {
342
+ return persistedUiStateController.queuePersistedUiStateWork(work);
343
+ }
344
+
345
+ async function loadPopPersistedSnapshot(context: string, routeHref: string = window.location.href) {
346
+ return persistedUiStateController.loadPopPersistedSnapshot(context, routeHref);
347
+ }
348
+
349
+ async function loadInitialPersistedSnapshot(context: string) {
350
+ return persistedUiStateController.loadInitialPersistedSnapshot(context);
351
+ }
352
+
353
+ async function saveCurrentHistoryEntrySnapshot(context: string): Promise<string | null> {
354
+ return persistedUiStateController.saveCurrentHistoryEntrySnapshot(
355
+ context,
356
+ currentSession?.exports.__fui_capture_persisted_ui_state,
357
+ );
358
+ }
359
+
360
+ async function saveRouteHeadSnapshotForHref(routeHref: string, context: string): Promise<string | null> {
361
+ return persistedUiStateController.saveRouteHeadSnapshotForHref(
362
+ routeHref,
363
+ context,
364
+ currentSession?.exports.__fui_capture_persisted_ui_state,
365
+ );
366
+ }
367
+
368
+ async function ensureCurrentHistoryEntrySnapshot(context: string): Promise<string | null> {
369
+ return persistedUiStateController.ensureCurrentHistoryEntrySnapshot(
370
+ context,
371
+ currentSession?.exports.__fui_capture_persisted_ui_state,
372
+ );
373
+ }
374
+
375
+ function hydrateCurrentPersistedEntries(snapshot: unknown): void {
376
+ persistedUiStateController.hydrateCurrentPersistedEntries(snapshot as never);
377
+ }
378
+
379
+ function restoreCurrentPersistedUiState(context: string): void {
380
+ persistedUiStateController.restoreCurrentPersistedUiState(
381
+ context,
382
+ currentSession?.exports.__fui_restore_persisted_ui_state,
383
+ );
384
+ }
385
+
386
+ function resetUiState(): void {
387
+ pendingGradients.clear();
388
+ latestCommandWords = [];
389
+ latestRootHandle = null;
390
+ textBridge.clearState();
391
+ updateState();
392
+ }
393
+
394
+ function cancelHostTimer(timerId: number): void {
395
+ const timeoutId = hostTimers.get(timerId);
396
+ if (timeoutId === undefined) {
397
+ return;
398
+ }
399
+ window.clearTimeout(timeoutId);
400
+ hostTimers.delete(timerId);
401
+ }
402
+
403
+ function cancelAllHostTimers(): void {
404
+ for (const timeoutId of hostTimers.values()) {
405
+ window.clearTimeout(timeoutId);
406
+ }
407
+ hostTimers.clear();
408
+ }
409
+
410
+ function readHostAccentColor(): number {
411
+ return harnessUiChrome.readHostAccentColor();
412
+ }
413
+
414
+ function notifyRouteChanged(session: HarnessAppSession | null, route: string): void {
415
+ if (
416
+ session === null ||
417
+ session.textBufferPtr === 0 ||
418
+ session.textBufferSize === 0
419
+ ) {
420
+ return;
421
+ }
422
+ const encoded = encoder.encode(route);
423
+ if (encoded.length > session.textBufferSize) {
424
+ throw new Error('Route text exceeds the shared AssemblyScript text buffer.');
425
+ }
426
+ if (encoded.length > 0) {
427
+ const memory = new Uint8Array(session.memory.buffer, session.textBufferPtr, encoded.length);
428
+ memory.set(encoded);
429
+ }
430
+ session.exports.__fui_on_route_changed(session.textBufferPtr, encoded.length);
431
+ }
432
+
433
+ function notifyRouteForCurrentLocation(session: HarnessAppSession | null = currentSession): void {
434
+ notifyRouteChanged(session, `${window.location.pathname}${window.location.search}${window.location.hash}`);
435
+ }
436
+
437
+ const fileHost = createManagedHarnessFileHost({
438
+ getCurrentSession: () => currentSession,
439
+ getRuntime: () => runtime,
440
+ readAppUtf8,
441
+ readAppBytes,
442
+ writeTextCallbackPayload,
443
+ describeHarnessError,
444
+ });
445
+
446
+ const fetchHost = createManagedHarnessFetchHost({
447
+ getCurrentSession: () => currentSession,
448
+ readAppUtf8,
449
+ readAppBytes,
450
+ readAppTextParts,
451
+ writeTextCallbackPayload,
452
+ describeHarnessError,
453
+ });
454
+
455
+ const workerManager = createWorkerManager({
456
+ scriptBaseUrl: import.meta.url,
457
+ getCurrentSession: () => currentSession,
458
+ getCurrentWorkerHostServices: () => currentSession?.workerHostServices,
459
+ writeTextCallbackPayload: writeWorkerTextCallbackPayload,
460
+ });
461
+ function notifyViewport(session: HarnessAppSession | null = currentSession): void {
462
+ if (session === null) {
463
+ return;
464
+ }
465
+ const rect = runtime.canvas.getBoundingClientRect();
466
+ const width = rect.width > 0 ? rect.width : runtime.canvas.width;
467
+ const height = rect.height > 0 ? rect.height : runtime.canvas.height;
468
+ session.exports.__fui_on_viewport_changed(width, height);
469
+ }
470
+
471
+ function notifySystemTheme(session: HarnessAppSession | null = currentSession, isDark = darkModeQuery.matches): void {
472
+ if (session === null) {
473
+ return;
474
+ }
475
+ session.exports.__fui_on_system_dark_mode_changed(isDark);
476
+ }
477
+
478
+ function encodeHostEventCallArgs(
479
+ session: HarnessAppSession,
480
+ method: NormalizedHostEventMethod,
481
+ args: readonly unknown[],
482
+ ): Array<unknown> {
483
+ if (args.length != method.args.length) {
484
+ throw new Error(`Host event ${method.serviceName}.${method.methodName} expected ${String(method.args.length)} args but received ${String(args.length)}.`);
485
+ }
486
+ const callArgs: Array<unknown> = [];
487
+ let byteOffset = 0;
488
+ for (let index = 0; index < method.args.length; index += 1) {
489
+ const type = method.args[index];
490
+ const arg = args[index];
491
+ const context = `Host event ${method.serviceName}.${method.methodName} arg ${String(index)}`;
492
+ if (type === 'string') {
493
+ if (typeof arg !== 'string') {
494
+ throw new Error(`${context} must be a string.`);
495
+ }
496
+ const encoded = encoder.encode(arg);
497
+ if (encoded.length > 0) {
498
+ if (session.textBufferPtr === 0 || byteOffset + encoded.length > session.textBufferSize) {
499
+ throw new Error(`${context} exceeds the shared AssemblyScript text buffer.`);
500
+ }
501
+ const memory = new Uint8Array(session.memory.buffer, session.textBufferPtr + byteOffset, encoded.length);
502
+ memory.set(encoded);
503
+ callArgs.push(session.textBufferPtr + byteOffset, encoded.length);
504
+ byteOffset += encoded.length;
505
+ } else {
506
+ callArgs.push(0, 0);
507
+ }
508
+ continue;
509
+ }
510
+ if (type === 'bool') {
511
+ if (typeof arg !== 'boolean') {
512
+ throw new Error(`${context} must be a boolean.`);
513
+ }
514
+ callArgs.push(arg ? 1 : 0);
515
+ continue;
516
+ }
517
+ if (typeof arg !== 'number' || Number.isNaN(arg)) {
518
+ throw new Error(`${context} must be a number.`);
519
+ }
520
+ if (type === 'i32') {
521
+ if (!Number.isInteger(arg) || arg < -2147483648 || arg > 2147483647) {
522
+ throw new Error(`${context} must be a signed 32-bit integer.`);
523
+ }
524
+ }
525
+ callArgs.push(arg);
526
+ }
527
+ return callArgs;
528
+ }
529
+
530
+ function disposeHostEventDisposers(session: HarnessAppSession): void {
531
+ const disposers = session.hostEventDisposers;
532
+ while (disposers.length > 0) {
533
+ const dispose = disposers.pop();
534
+ dispose?.();
535
+ }
536
+ }
537
+
538
+ function connectHostEvents<Exports extends HarnessExports>(
539
+ session: HarnessAppSession,
540
+ exports: Exports,
541
+ hostEvents: HostEventsDefinition | undefined,
542
+ ): void {
543
+ const exportRecord = exports as unknown as Record<string, unknown>;
544
+ for (const method of listHostEventMethods(hostEvents)) {
545
+ const exportedHandler = exportRecord[method.exportName];
546
+ if (typeof exportedHandler !== 'function') {
547
+ console.error(
548
+ `[fui_host_event] Missing wasm export "${method.exportName}" for ${method.serviceName}.${method.methodName}.`,
549
+ );
550
+ continue;
551
+ }
552
+ const dispose = method.subscribe((...args: readonly unknown[]) => {
553
+ if (currentSession !== session) {
554
+ return;
555
+ }
556
+ const activeHandler = exportRecord[method.exportName];
557
+ if (typeof activeHandler !== 'function') {
558
+ console.error(
559
+ `[fui_host_event] Lost wasm export "${method.exportName}" while dispatching ${method.serviceName}.${method.methodName}.`,
560
+ );
561
+ return;
562
+ }
563
+ try {
564
+ const callArgs = encodeHostEventCallArgs(session, method, args);
565
+ (activeHandler as (...rawArgs: Array<unknown>) => void)(...callArgs);
566
+ } catch (error: unknown) {
567
+ const message = error instanceof Error ? error.stack ?? error.message : String(error);
568
+ console.error(
569
+ `[fui_host_event] Dispatch failed for ${method.serviceName}.${method.methodName}: ${message}`,
570
+ );
571
+ throw error;
572
+ }
573
+ });
574
+ if (typeof dispose === 'function') {
575
+ session.hostEventDisposers.push(dispose);
576
+ }
577
+ }
578
+ }
579
+
580
+ function notifySvgLoaded(session: HarnessAppSession | null, svgId: number, width: number, height: number): void {
581
+ if (session === null) {
582
+ return;
583
+ }
584
+ session.exports.__fui_on_svg_loaded(svgId, width, height);
585
+ }
586
+
587
+ function notifySvgFailed(session: HarnessAppSession | null, svgId: number, error: string): void {
588
+ if (
589
+ session === null ||
590
+ session.textBufferPtr === 0 ||
591
+ session.textBufferSize === 0
592
+ ) {
593
+ return;
594
+ }
595
+ const length = writeTextToSessionBuffer(session, error);
596
+ session.exports.__fui_on_svg_failed(svgId, session.textBufferPtr, length);
597
+ }
598
+
599
+ function notifyTextureLoaded(session: HarnessAppSession | null, textureId: number, width: number, height: number): void {
600
+ if (session === null) {
601
+ return;
602
+ }
603
+ session.exports.__fui_on_texture_loaded(textureId, width, height);
604
+ }
605
+
606
+ function notifyTextureFailed(session: HarnessAppSession | null, textureId: number, error: string): void {
607
+ if (
608
+ session === null ||
609
+ session.textBufferPtr === 0 ||
610
+ session.textBufferSize === 0
611
+ ) {
612
+ return;
613
+ }
614
+ const length = writeTextToSessionBuffer(session, error);
615
+ session.exports.__fui_on_texture_failed(textureId, session.textBufferPtr, length);
616
+ }
617
+
618
+ async function handleSameOriginNavigation(target: URL, mode: HarnessNavigationMode): Promise<void> {
619
+ const previousUrlHref = lastHandledUrlHref;
620
+ if (mode !== 'pop') {
621
+ await queuePersistedUiStateWork(() => saveCurrentHistoryEntrySnapshot(`navigating ${mode} to ${target.href}`));
622
+ } else if (previousUrlHref !== target.href) {
623
+ await queuePersistedUiStateWork(() => saveRouteHeadSnapshotForHref(
624
+ previousUrlHref,
625
+ `leaving ${previousUrlHref} via ${mode} to ${target.href}`,
626
+ ));
627
+ }
628
+ if (navigationHandler !== null) {
629
+ await navigationHandler(target, mode);
630
+ lastHandledUrlHref = target.href;
631
+ return;
632
+ }
633
+ if (mode === 'push') {
634
+ pushManagedHistoryEntry(target);
635
+ } else if (mode === 'replace') {
636
+ replaceManagedHistoryEntry(target);
637
+ }
638
+ const targetSnapshot = mode === 'pop'
639
+ ? await queuePersistedUiStateWork(() => loadPopPersistedSnapshot(`navigating ${mode} to ${target.href}`, target.href))
640
+ : null;
641
+ hydrateCurrentPersistedEntries(targetSnapshot);
642
+ notifyRouteChanged(currentSession, toAppRoute(target));
643
+ if (targetSnapshot !== null) {
644
+ restoreCurrentPersistedUiState(`navigating ${mode} to ${target.href}`);
645
+ await settleCurrentSessionAfterRestore(`navigating ${mode} to ${target.href}`);
646
+ }
647
+ await queuePersistedUiStateWork(() => ensureCurrentHistoryEntrySnapshot(`navigating ${mode} to ${target.href}`));
648
+ lastHandledUrlHref = target.href;
649
+ }
650
+
651
+ function handleSameOriginNavigationFailure(target: URL, mode: HarnessNavigationMode, error: unknown): void {
652
+ const route = toAppRoute(target);
653
+ const detail = `Failed to load ${mode === 'pop' ? 'history route' : 'route'} ${route}: ${describeHarnessError(error)}`;
654
+ reportHarnessErrorOverlay('The render raccoons chewed through a cable.', detail, error);
655
+ options.onError?.(error);
656
+ }
657
+
658
+ function navigateWithinDocument(rawTarget: string, openInNewTab: boolean): void {
659
+ const target = tryResolveNavigationTarget(rawTarget);
660
+ if (target === null) {
661
+ throw new Error(`Invalid navigation target: ${rawTarget}`);
662
+ }
663
+ if (openInNewTab) {
664
+ const anchor = document.createElement('a');
665
+ anchor.href = target.href;
666
+ anchor.target = '_blank';
667
+ anchor.rel = 'noopener';
668
+ anchor.hidden = true;
669
+ (document.body ?? document.documentElement).appendChild(anchor);
670
+ anchor.click();
671
+ anchor.remove();
672
+ return;
673
+ }
674
+ const isWebUrl = target.protocol === 'http:' || target.protocol === 'https:';
675
+ if (isWebUrl && target.origin === window.location.origin) {
676
+ void handleSameOriginNavigation(target, 'push').catch((error: unknown) => {
677
+ handleSameOriginNavigationFailure(target, 'push', error);
678
+ });
679
+ return;
680
+ }
681
+ window.location.assign(target.href);
682
+ }
683
+
684
+ async function flushDebugInteraction(session: HarnessAppSession): Promise<void> {
685
+ session.exports.__flushRenders();
686
+ while (appFlushRequested) {
687
+ appFlushRequested = false;
688
+ session.exports.__flushRenders();
689
+ }
690
+ const words = runtime.flushPendingCommit();
691
+ latestCommandWords = words === null ? [] : Array.from(words);
692
+ updateState();
693
+ await waitForFrame();
694
+ updateState();
695
+ }
696
+
697
+ function syncUiHostCapabilities(): void {
698
+ runtime.ui._ui_set_coarse_pointer_mode(harnessUiChrome.detectCoarsePointer() ? 1 : 0);
699
+ runtime.ui._ui_set_platform_family(harnessUiChrome.detectPlatformFamily());
700
+ }
701
+
702
+ function createAppImports(hostServices: HostServicesDefinition | undefined) {
703
+ const hostServiceImports = createHostServiceImportModule(hostServices, {
704
+ readString: readAppUtf8,
705
+ writeString: writeAppUtf8,
706
+ });
707
+ return {
708
+ effindom_v2_ui: createUiImportModule({
709
+ getRuntime: () => runtime,
710
+ readAppUtf8,
711
+ readAppFloats,
712
+ readAppBytes,
713
+ withUiUtf8,
714
+ withUiGridData,
715
+ withUiGradientData,
716
+ zeroPointer: () => zeroPointer(runtime),
717
+ normalizePointer: (ptr) => normalizePointer(runtime, ptr),
718
+ getCurrentMemory,
719
+ setLatestRootHandle(rootHandle: string | null): void {
720
+ latestRootHandle = rootHandle;
721
+ },
722
+ updateState,
723
+ queueHarnessFrame,
724
+ syncUiHostCapabilities,
725
+ resetUiState,
726
+ pendingGradients,
727
+ }),
728
+ fui_host: {
729
+ ...createHostImportModule({
730
+ getRuntime: () => runtime,
731
+ getCurrentSession,
732
+ getCurrentSessionOrNull: () => currentSession,
733
+ setAppFlushRequested(value: boolean): void {
734
+ appFlushRequested = value;
735
+ },
736
+ queueHarnessFrame,
737
+ uiChrome: harnessUiChrome,
738
+ readAppUtf8,
739
+ writeAppFloat32,
740
+ writeAppUint32,
741
+ writeAppUtf8,
742
+ textBridge,
743
+ persistedUiStateController,
744
+ navigateWithinDocument,
745
+ canBrowserNavigateBack,
746
+ canBrowserNavigateForward,
747
+ navigateBrowserBack,
748
+ navigateBrowserForward,
749
+ cancelHostTimer,
750
+ getHostTimer(timerId: number): number | undefined {
751
+ return hostTimers.get(timerId);
752
+ },
753
+ setHostTimer(timerId: number, timeoutId: number): void {
754
+ hostTimers.set(timerId, timeoutId);
755
+ },
756
+ deleteHostTimer(timerId: number): void {
757
+ hostTimers.delete(timerId);
758
+ },
759
+ workerManager,
760
+ debugLogsEnabled,
761
+ notifySvgLoaded,
762
+ notifySvgFailed,
763
+ notifyTextureLoaded,
764
+ notifyTextureFailed,
765
+ }),
766
+ ...bitmapHost.imports,
767
+ ...fileHost.imports,
768
+ },
769
+ fui_fetch_host: fetchHost.imports,
770
+ fui_host_service: hostServiceImports,
771
+ };
772
+ }
773
+
774
+ const callbacks: EffinDomCallbacks = window.__effindomCallbacks ?? {};
775
+ const previousPointerCallback = callbacks.onPointerEventWithCoords;
776
+ callbacks.onPointerEventWithCoords = (type, handle, x, y, modifiers) => {
777
+ previousPointerCallback?.(type, handle, x, y, modifiers);
778
+ const session = currentSession;
779
+ if (session !== null) {
780
+ session.exports.__fui_on_pointer_event(type, toBigIntHandle(handle), x, y, modifiers ?? 0);
781
+ }
782
+ };
783
+ const previousBeforeContextMenuHitTest = callbacks.onBeforeContextMenuHitTest;
784
+ callbacks.onBeforeContextMenuHitTest = () => {
785
+ previousBeforeContextMenuHitTest?.();
786
+ const session = currentSession;
787
+ if (session !== null) {
788
+ session.exports.__fui_hide_active_context_menu();
789
+ }
790
+ runtime.commitFrame();
791
+ runtime.flushPendingCommit();
792
+ queueHarnessFrame();
793
+ };
794
+ const previousContextMenu = callbacks.onContextMenu;
795
+ callbacks.onContextMenu = (handle, x, y) => {
796
+ previousContextMenu?.(handle, x, y);
797
+ const session = currentSession;
798
+ if (session !== null) {
799
+ session.exports.__fui_on_context_menu(toBigIntHandle(handle), x, y);
800
+ }
801
+ };
802
+ const previousFocusChanged = callbacks.onFocusChanged;
803
+ callbacks.onFocusChanged = (handle, isFocused) => {
804
+ previousFocusChanged?.(handle, isFocused);
805
+ const session = currentSession;
806
+ if (session !== null) {
807
+ session.exports.__fui_on_focus_changed(toBigIntHandle(handle), isFocused);
808
+ }
809
+ };
810
+ const previousTextChanged = callbacks.onTextChanged;
811
+ callbacks.onTextChanged = (handle, text) => {
812
+ previousTextChanged?.(handle, text);
813
+ textBridge.recordTextChanged(toBigIntHandle(handle), text);
814
+ const session = currentSession;
815
+ if (
816
+ session === null ||
817
+ session.textBufferPtr === 0 ||
818
+ session.textBufferSize === 0
819
+ ) {
820
+ return;
821
+ }
822
+ const length = writeTextCallbackPayload(session, text, 'Text changed payload');
823
+ session.exports.__fui_on_text_changed(
824
+ toBigIntHandle(handle),
825
+ length > 0 ? session.textBufferPtr : 0,
826
+ length,
827
+ );
828
+ };
829
+ const previousTextReplaced = callbacks.onTextReplaced;
830
+ callbacks.onTextReplaced = (handle, start, end, text) => {
831
+ previousTextReplaced?.(handle, start, end, text);
832
+ textBridge.recordTextReplaced(toBigIntHandle(handle), start, end, text);
833
+ const session = currentSession;
834
+ if (
835
+ session === null ||
836
+ session.textBufferPtr === 0 ||
837
+ session.textBufferSize === 0
838
+ ) {
839
+ return;
840
+ }
841
+ const length = writeTextCallbackPayload(session, text, 'Text replacement payload');
842
+ session.exports.__fui_on_text_replaced(
843
+ toBigIntHandle(handle),
844
+ start,
845
+ end,
846
+ length > 0 ? session.textBufferPtr : 0,
847
+ length,
848
+ );
849
+ };
850
+ const previousSelectionChanged = callbacks.onSelectionChanged;
851
+ callbacks.onSelectionChanged = (handle, start, end) => {
852
+ previousSelectionChanged?.(handle, start, end);
853
+ textBridge.recordSelectionChanged(toBigIntHandle(handle), start, end);
854
+ const session = currentSession;
855
+ if (session !== null) {
856
+ session.exports.__fui_on_selection_changed(toBigIntHandle(handle), start, end);
857
+ }
858
+ };
859
+ const previousCrossSelectionChanged = callbacks.onCrossSelectionChanged;
860
+ callbacks.onCrossSelectionChanged = (handle, text) => {
861
+ previousCrossSelectionChanged?.(handle, text);
862
+ const session = currentSession;
863
+ if (
864
+ session === null ||
865
+ session.textBufferPtr === 0 ||
866
+ session.textBufferSize === 0
867
+ ) {
868
+ return;
869
+ }
870
+ const length = writeTextCallbackPayload(session, text, 'Cross-selection payload');
871
+ session.exports.__fui_on_cross_selection_changed(toBigIntHandle(handle), session.textBufferPtr, length);
872
+ };
873
+ const previousKeyEvent = callbacks.onKeyEventWithKey;
874
+ callbacks.onKeyEventWithKey = (type, key, modifiers) => {
875
+ const previousHandled = previousKeyEvent?.(type, key, modifiers) === true;
876
+ const session = currentSession;
877
+ if (session === null || session.keyBufferPtr === 0) {
878
+ return previousHandled;
879
+ }
880
+ const encoded = encoder.encode(key);
881
+ if (encoded.length > 256) {
882
+ return previousHandled;
883
+ }
884
+ const memory = new Uint8Array(session.memory.buffer, session.keyBufferPtr, encoded.length);
885
+ memory.set(encoded);
886
+ return previousHandled || session.exports.__fui_on_key_event(type, session.keyBufferPtr, encoded.length, modifiers) !== 0;
887
+ };
888
+ const previousScroll = callbacks.onScroll;
889
+ callbacks.onScroll = (handle, offsetX, offsetY, contentWidth, contentHeight, viewportWidth, viewportHeight) => {
890
+ previousScroll?.(handle, offsetX, offsetY, contentWidth, contentHeight, viewportWidth, viewportHeight);
891
+ const session = currentSession;
892
+ if (session !== null) {
893
+ session.exports.__fui_on_scroll(
894
+ toBigIntHandle(handle),
895
+ offsetX,
896
+ offsetY,
897
+ contentWidth,
898
+ contentHeight,
899
+ viewportWidth,
900
+ viewportHeight,
901
+ );
902
+ }
903
+ };
904
+ window.__effindomCallbacks = callbacks;
905
+
906
+ const handleViewportChange = () => {
907
+ notifyViewport();
908
+ };
909
+ window.addEventListener('resize', handleViewportChange);
910
+
911
+ const handleDarkModeChange = (event: MediaQueryListEvent) => {
912
+ notifySystemTheme(currentSession, event.matches);
913
+ };
914
+ darkModeQuery.addEventListener('change', handleDarkModeChange);
915
+
916
+ const handlePopState = () => {
917
+ const target = new URL(window.location.href);
918
+ syncManagedHistoryPop(target);
919
+ void handleSameOriginNavigation(target, 'pop').catch((error: unknown) => {
920
+ handleSameOriginNavigationFailure(target, 'pop', error);
921
+ });
922
+ };
923
+ window.addEventListener('popstate', handlePopState);
924
+
925
+ const dismissTransientUi = () => {
926
+ const session = currentSession;
927
+ if (session !== null) {
928
+ session.exports.__fui_hide_active_context_menu();
929
+ }
930
+ runtime.clearPointerHover();
931
+ setUrlPreviewText('');
932
+ };
933
+ const handleWindowBlur = () => {
934
+ dismissTransientUi();
935
+ };
936
+ const handleCanvasBlur = () => {
937
+ dismissTransientUi();
938
+ };
939
+ const handleCanvasDragEnter = (event: DragEvent) => {
940
+ const effect = fileHost.dispatchExternalDragEvent(EXTERNAL_DRAG_EVENT_ENTER, event, { reuseActiveItems: false });
941
+ if (effect === 0) {
942
+ return;
943
+ }
944
+ event.preventDefault();
945
+ if (event.dataTransfer !== null) {
946
+ event.dataTransfer.dropEffect = fileHost.mapExternalDropEffect(effect);
947
+ }
948
+ };
949
+ const handleCanvasDragOver = (event: DragEvent) => {
950
+ const effect = fileHost.dispatchExternalDragEvent(EXTERNAL_DRAG_EVENT_OVER, event);
951
+ if (effect === 0) {
952
+ return;
953
+ }
954
+ event.preventDefault();
955
+ if (event.dataTransfer !== null) {
956
+ event.dataTransfer.dropEffect = fileHost.mapExternalDropEffect(effect);
957
+ }
958
+ };
959
+ const handleCanvasDragLeave = (event: DragEvent) => {
960
+ const effect = fileHost.dispatchExternalDragEvent(EXTERNAL_DRAG_EVENT_LEAVE, event, { handle: 0n });
961
+ if (effect !== 0) {
962
+ event.preventDefault();
963
+ }
964
+ };
965
+ const handleCanvasDrop = (event: DragEvent) => {
966
+ const effect = fileHost.dispatchExternalDragEvent(EXTERNAL_DRAG_EVENT_DROP, event);
967
+ if (effect === 0) {
968
+ return;
969
+ }
970
+ event.preventDefault();
971
+ if (event.dataTransfer !== null) {
972
+ event.dataTransfer.dropEffect = fileHost.mapExternalDropEffect(effect);
973
+ }
974
+ };
975
+ const handleVisibilityChange = () => {
976
+ if (document.visibilityState !== 'visible') {
977
+ dismissTransientUi();
978
+ void queuePersistedUiStateWork(() => saveCurrentHistoryEntrySnapshot('visibility change'));
979
+ }
980
+ };
981
+ const handlePageHide = () => {
982
+ void queuePersistedUiStateWork(() => saveCurrentHistoryEntrySnapshot('page hide'));
983
+ };
984
+ const canvasDragEnterListener: EventListener = (event) => {
985
+ handleCanvasDragEnter(event as DragEvent);
986
+ };
987
+ const canvasDragOverListener: EventListener = (event) => {
988
+ handleCanvasDragOver(event as DragEvent);
989
+ };
990
+ const canvasDragLeaveListener: EventListener = (event) => {
991
+ handleCanvasDragLeave(event as DragEvent);
992
+ };
993
+ const canvasDropListener: EventListener = (event) => {
994
+ handleCanvasDrop(event as DragEvent);
995
+ };
996
+ const externalDragTargets: Array<HTMLElement | HTMLCanvasElement> = [];
997
+ const registerExternalDragTarget = (target: HTMLElement | HTMLCanvasElement | null) => {
998
+ if (target === null) {
999
+ return;
1000
+ }
1001
+ for (let index = 0; index < externalDragTargets.length; index += 1) {
1002
+ if (externalDragTargets[index] === target) {
1003
+ return;
1004
+ }
1005
+ }
1006
+ externalDragTargets.push(target);
1007
+ };
1008
+ registerExternalDragTarget(runtime.canvas);
1009
+ registerExternalDragTarget(runtime.canvas.parentElement);
1010
+ registerExternalDragTarget(getCanvasSizeSource(runtime.canvas));
1011
+ window.addEventListener('blur', handleWindowBlur);
1012
+ runtime.canvas.addEventListener('blur', handleCanvasBlur);
1013
+ for (let index = 0; index < externalDragTargets.length; index += 1) {
1014
+ const target = externalDragTargets[index];
1015
+ target?.addEventListener('dragenter', canvasDragEnterListener);
1016
+ target?.addEventListener('dragover', canvasDragOverListener);
1017
+ target?.addEventListener('dragleave', canvasDragLeaveListener);
1018
+ target?.addEventListener('drop', canvasDropListener);
1019
+ }
1020
+ document.addEventListener('visibilitychange', handleVisibilityChange);
1021
+ window.addEventListener('pagehide', handlePageHide);
1022
+
1023
+ cleanup = () => {
1024
+ workerManager.terminateAll();
1025
+ setUrlPreviewText('');
1026
+ window.removeEventListener('resize', handleViewportChange);
1027
+ darkModeQuery.removeEventListener('change', handleDarkModeChange);
1028
+ window.removeEventListener('popstate', handlePopState);
1029
+ window.removeEventListener('blur', handleWindowBlur);
1030
+ runtime.canvas.removeEventListener('blur', handleCanvasBlur);
1031
+ for (let index = 0; index < externalDragTargets.length; index += 1) {
1032
+ const target = externalDragTargets[index];
1033
+ target?.removeEventListener('dragenter', canvasDragEnterListener);
1034
+ target?.removeEventListener('dragover', canvasDragOverListener);
1035
+ target?.removeEventListener('dragleave', canvasDragLeaveListener);
1036
+ target?.removeEventListener('drop', canvasDropListener);
1037
+ }
1038
+ document.removeEventListener('visibilitychange', handleVisibilityChange);
1039
+ window.removeEventListener('pagehide', handlePageHide);
1040
+ delete window.__fui_debug;
1041
+ };
1042
+
1043
+ const debugApi: HarnessDebugApi = {
1044
+ async flush(): Promise<void> {
1045
+ await flushDebugInteraction(getCurrentSession());
1046
+ },
1047
+ async pointerEvent(type: number, handle: WasmHandleLike, x: number, y: number, modifiers = 0): Promise<void> {
1048
+ const session = getCurrentSession();
1049
+ const debugPointerEvent = session.exports.__fui_debug_pointer_event;
1050
+ if (debugPointerEvent === undefined) {
1051
+ throw new Error('Debug pointer events are not available for this app.');
1052
+ }
1053
+ debugPointerEvent(type, toBigIntHandle(handle), x, y, modifiers);
1054
+ await flushDebugInteraction(session);
1055
+ },
1056
+ async focusChanged(handle: WasmHandleLike, focused: boolean): Promise<void> {
1057
+ const session = getCurrentSession();
1058
+ const debugFocusChanged = session.exports.__fui_debug_focus_changed;
1059
+ if (debugFocusChanged === undefined) {
1060
+ throw new Error('Debug focus changes are not available for this app.');
1061
+ }
1062
+ debugFocusChanged(toBigIntHandle(handle), focused);
1063
+ await flushDebugInteraction(session);
1064
+ },
1065
+ async keyEvent(type: number, key: string, modifiers = 0): Promise<void> {
1066
+ const session = getCurrentSession();
1067
+ const debugKeyEvent = session.exports.__fui_debug_key_event;
1068
+ if (session.keyBufferPtr === 0 || debugKeyEvent === undefined) {
1069
+ throw new Error('Debug key events are not available for this app.');
1070
+ }
1071
+ const encoded = encoder.encode(key);
1072
+ if (encoded.length > 256) {
1073
+ throw new Error('Debug key event exceeds the shared AssemblyScript key buffer.');
1074
+ }
1075
+ const memory = new Uint8Array(session.memory.buffer, session.keyBufferPtr, encoded.length);
1076
+ memory.set(encoded);
1077
+ debugKeyEvent(type, session.keyBufferPtr, encoded.length, modifiers);
1078
+ await flushDebugInteraction(session);
1079
+ },
1080
+ async navigateTo(target: string): Promise<void> {
1081
+ navigateWithinDocument(target, false);
1082
+ },
1083
+ async scroll(
1084
+ handle: WasmHandleLike,
1085
+ offsetX: number,
1086
+ offsetY: number,
1087
+ contentWidth: number,
1088
+ contentHeight: number,
1089
+ viewportWidth: number,
1090
+ viewportHeight: number,
1091
+ ): Promise<void> {
1092
+ const session = getCurrentSession();
1093
+ const debugScroll = session.exports.__fui_debug_scroll;
1094
+ if (debugScroll === undefined) {
1095
+ throw new Error('Debug scroll events are not available for this app.');
1096
+ }
1097
+ debugScroll(
1098
+ toBigIntHandle(handle),
1099
+ offsetX,
1100
+ offsetY,
1101
+ contentWidth,
1102
+ contentHeight,
1103
+ viewportWidth,
1104
+ viewportHeight,
1105
+ );
1106
+ await flushDebugInteraction(session);
1107
+ },
1108
+ };
1109
+ window.__fui_debug = debugApi;
1110
+
1111
+ async function fetchWasmBytes(wasmPath: string): Promise<ArrayBuffer> {
1112
+ const cached = wasmByteCache.get(wasmPath);
1113
+ if (cached !== undefined) {
1114
+ return cached;
1115
+ }
1116
+ const fetchPromise = fetch(wasmPath, { cache: 'no-store' }).then(async (response) => {
1117
+ if (!response.ok) {
1118
+ throw new Error(`Failed to load wasm app: ${wasmPath}`);
1119
+ }
1120
+ return response.arrayBuffer();
1121
+ });
1122
+ wasmByteCache.set(wasmPath, fetchPromise);
1123
+ return fetchPromise;
1124
+ }
1125
+
1126
+ async function loadWasmModule(wasmPath: string): Promise<WebAssembly.Module> {
1127
+ const cached = wasmModuleCache.get(wasmPath);
1128
+ if (cached !== undefined) {
1129
+ return cached;
1130
+ }
1131
+ const compilePromise = fetchWasmBytes(wasmPath).then((bytes) => WebAssembly.compile(bytes));
1132
+ wasmModuleCache.set(wasmPath, compilePromise);
1133
+ return compilePromise;
1134
+ }
1135
+
1136
+ function validateAppImports(wasmModule: WebAssembly.Module, hostServices: HostServicesDefinition | undefined): void {
1137
+ const allowedHostServiceImports = getHostServiceImportNames(hostServices);
1138
+ for (const imported of WebAssembly.Module.imports(wasmModule)) {
1139
+ if (imported.kind !== 'function') {
1140
+ throw new Error(`App import ${imported.module}.${imported.name} is not allowed.`);
1141
+ }
1142
+ if (
1143
+ imported.module === 'effindom_v2_ui' ||
1144
+ imported.module === 'fui_host' ||
1145
+ imported.module === 'fui_fetch_host' ||
1146
+ imported.module === 'env'
1147
+ ) {
1148
+ continue;
1149
+ }
1150
+ if (imported.module === 'fui_host_service' && allowedHostServiceImports.has(imported.name)) {
1151
+ continue;
1152
+ }
1153
+ throw new Error(`App import ${imported.module}.${imported.name} is not allowed.`);
1154
+ }
1155
+ }
1156
+
1157
+ async function unloadApp(): Promise<void> {
1158
+ const session = currentSession;
1159
+ if (session === null) {
1160
+ return;
1161
+ }
1162
+ disposeHostEventDisposers(session);
1163
+ session.onDispose?.(session.exports);
1164
+ fetchHost.cancelAllForSession(session);
1165
+ fileHost.cancelAllForSession(session);
1166
+ currentSession = null;
1167
+ workerManager.terminateAll();
1168
+ appFlushRequested = false;
1169
+ cancelAllHostTimers();
1170
+ bitmapHost.clearTextures(runtime);
1171
+ runtime.setAppFrameHandler(null);
1172
+ runtime.setCapturedPointerHandle(null);
1173
+ runtime.clearPointerHover();
1174
+ runtime.canvas.style.cursor = 'default';
1175
+ setUrlPreviewText('');
1176
+ runtime.core._ed_clear_focus_state?.();
1177
+ runtime.core._ed_clear_text_input_state?.();
1178
+ runtime.core._ed_reset_scene();
1179
+ runtime.ui._ui_reset();
1180
+ syncUiHostCapabilities();
1181
+ resetUiState();
1182
+ runtime.resetLogs();
1183
+ runtime.commitFrame();
1184
+ queueHarnessFrame();
1185
+ runtime.flushPendingCommit();
1186
+ await waitForFrame();
1187
+ }
1188
+
1189
+ async function recreateRuntime(): Promise<BridgeRuntime> {
1190
+ const session = currentSession;
1191
+ if (session !== null) {
1192
+ disposeHostEventDisposers(session);
1193
+ session.onDispose?.(session.exports);
1194
+ fetchHost.cancelAllForSession(session);
1195
+ fileHost.cancelAllForSession(session);
1196
+ currentSession = null;
1197
+ }
1198
+ workerManager.terminateAll();
1199
+ appFlushRequested = false;
1200
+ cancelAllHostTimers();
1201
+ bitmapHost.clearTextures(runtime);
1202
+ runtime.setAppFrameHandler(null);
1203
+ runtime.setCapturedPointerHandle(null);
1204
+ runtime.clearPointerHover();
1205
+ setUrlPreviewText('');
1206
+ latestCommandWords = [];
1207
+ latestRootHandle = null;
1208
+ updateState();
1209
+ runtime = await bridgeState.recreateRuntime();
1210
+ bitmapHost.installReplay(runtime);
1211
+ syncUiHostCapabilities();
1212
+ resetUiState();
1213
+ return runtime;
1214
+ }
1215
+
1216
+ async function loadApp<Exports extends HarnessExports>(
1217
+ loadOptions: HarnessAppOptions<Exports>,
1218
+ ): Promise<HarnessContext<Exports>> {
1219
+ if (loadOptions.showLoadingOverlay !== false) {
1220
+ setLoadingOverlay(
1221
+ 'loading',
1222
+ 'Winding up the tiny widget clockwork...',
1223
+ `Loading ${loadOptions.wasmPath}`,
1224
+ );
1225
+ }
1226
+ await unloadApp();
1227
+ const restoredSnapshot = await queuePersistedUiStateWork(() => {
1228
+ switch (loadOptions.persistedRestoreMode ?? 'initial') {
1229
+ case 'none':
1230
+ return Promise.resolve(null);
1231
+ case 'pop':
1232
+ return loadPopPersistedSnapshot(`loading ${loadOptions.wasmPath}`);
1233
+ case 'initial':
1234
+ default:
1235
+ return loadInitialPersistedSnapshot(`loading ${loadOptions.wasmPath}`);
1236
+ }
1237
+ });
1238
+ hydrateCurrentPersistedEntries(restoredSnapshot);
1239
+ const wasmModule = await loadWasmModule(loadOptions.wasmPath);
1240
+ validateAppImports(wasmModule, loadOptions.hostServices);
1241
+ const instance = await instantiate(wasmModule, createAppImports(loadOptions.hostServices));
1242
+ const exports = instance.exports as unknown as Exports;
1243
+ const keyBufferPtr = exports.__fui_key_buffer();
1244
+ const textBufferPtr = exports.__fui_text_buffer();
1245
+ const textBufferSize = textBufferPtr !== 0 ? exports.__fui_text_buffer_size() : 0;
1246
+ const session: HarnessAppSession = {
1247
+ exports,
1248
+ memory: exports.memory,
1249
+ keyBufferPtr,
1250
+ textBufferPtr,
1251
+ textBufferSize,
1252
+ hostEventDisposers: [],
1253
+ workerHostServices: loadOptions.workerHostServices,
1254
+ onStateUpdated: loadOptions.onStateUpdated,
1255
+ onDispose: loadOptions.onDispose === undefined
1256
+ ? undefined
1257
+ : (activeExports: HarnessExports) => {
1258
+ loadOptions.onDispose?.(activeExports as Exports);
1259
+ },
1260
+ };
1261
+ currentSession = session;
1262
+ notifyRouteForCurrentLocation(session);
1263
+ runtime.setAppFrameHandler((timestampMs: number) => {
1264
+ if (currentSession !== session) {
1265
+ return;
1266
+ }
1267
+ exports.__fui_on_frame(timestampMs);
1268
+ appFlushRequested = false;
1269
+ exports.__flushRenders();
1270
+ });
1271
+ runtime.resetLogs();
1272
+ loadOptions.run(exports);
1273
+ connectHostEvents(session, exports, loadOptions.hostEvents);
1274
+ runtime.runAppFrameHandler(performance.now());
1275
+ notifyViewport(session);
1276
+ notifySystemTheme(session);
1277
+ if (restoredSnapshot !== null) {
1278
+ restoreCurrentPersistedUiState(`loading ${loadOptions.wasmPath}`);
1279
+ await settleCurrentSessionAfterRestore(`loading ${loadOptions.wasmPath}`);
1280
+ }
1281
+ const context: HarnessContext<Exports> = {
1282
+ runtime,
1283
+ exports,
1284
+ waitForFrame,
1285
+ };
1286
+ await loadOptions.onReady?.(context);
1287
+ runtime.clearPointerHover();
1288
+ runtime.refreshPointerHover();
1289
+ runtime.flushPendingCommit();
1290
+ await waitForFrame();
1291
+ await queuePersistedUiStateWork(() => ensureCurrentHistoryEntrySnapshot(`loading ${loadOptions.wasmPath}`));
1292
+ lastHandledUrlHref = window.location.href;
1293
+ updateState();
1294
+ hideLoadingOverlay();
1295
+ return context;
1296
+ }
1297
+
1298
+ const controller: HarnessController = {
1299
+ get runtime() {
1300
+ return runtime;
1301
+ },
1302
+ waitForFrame,
1303
+ loadApp,
1304
+ unloadApp,
1305
+ recreateRuntime,
1306
+ setSameOriginNavigationHandler(handler) {
1307
+ navigationHandler = handler;
1308
+ },
1309
+ };
1310
+
1311
+ await options.onReady?.(controller);
1312
+ }).catch((error: unknown) => {
1313
+ cleanup();
1314
+ const message = describeHarnessError(error);
1315
+ reportHarnessErrorOverlay(
1316
+ 'The render raccoons chewed through a cable.',
1317
+ message,
1318
+ error,
1319
+ );
1320
+ options.onError?.(error);
1321
+ throw error;
1322
+ });
1323
+ }