@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.
- package/LICENSE.md +7 -0
- package/browser/src/common-harness/host-imports.ts +430 -0
- package/browser/src/common-harness/interop.ts +39 -0
- package/browser/src/common-harness/managed-harness-bitmap-host.ts +92 -0
- package/browser/src/common-harness/managed-harness-fetch-host.ts +201 -0
- package/browser/src/common-harness/managed-harness-file-host.ts +1101 -0
- package/browser/src/common-harness/managed-harness-file-payloads.ts +143 -0
- package/browser/src/common-harness/managed-harness-file-types.ts +106 -0
- package/browser/src/common-harness/managed-harness-session.ts +15 -0
- package/browser/src/common-harness/managed-harness.ts +1323 -0
- package/browser/src/common-harness/managed-history.ts +168 -0
- package/browser/src/common-harness/persisted-restore-policy.ts +50 -0
- package/browser/src/common-harness/persisted-ui-state-controller.ts +309 -0
- package/browser/src/common-harness/text-session-bridge.ts +452 -0
- package/browser/src/common-harness/types.ts +205 -0
- package/browser/src/common-harness/ui-chrome.ts +191 -0
- package/browser/src/common-harness/ui-imports.ts +529 -0
- package/browser/src/common-harness/wasm-module-cache.ts +47 -0
- package/browser/src/common-harness.ts +27 -0
- package/browser/src/file-processing-worker.ts +89 -0
- package/browser/src/host-events.ts +97 -0
- package/browser/src/host-services.ts +203 -0
- package/browser/src/index.ts +62 -0
- package/browser/src/persisted-ui-state.ts +206 -0
- package/browser/src/routed-harness.ts +198 -0
- package/browser/src/worker-bootstrap.ts +483 -0
- package/browser/src/worker-manager.ts +230 -0
- package/browser/src/worker-types.ts +50 -0
- package/package.json +89 -0
- package/scripts/build-demo-as.sh +91 -0
- package/scripts/build.sh +325 -0
- package/scripts/generate-host-events.ts +175 -0
- package/scripts/generate-host-services.ts +157 -0
- package/src/Fui.ts +205 -0
- package/src/FuiExports.ts +55 -0
- package/src/FuiPrimitives.ts +15 -0
- package/src/FuiWorker.ts +3 -0
- package/src/FuiWorkerExports.ts +6 -0
- package/src/bindings/ui.ts +531 -0
- package/src/color.ts +86 -0
- package/src/controls/AntiSelectionArea.ts +23 -0
- package/src/controls/Button.ts +750 -0
- package/src/controls/Checkbox.ts +181 -0
- package/src/controls/ContextMenu.ts +885 -0
- package/src/controls/ControlTemplateSet.ts +37 -0
- package/src/controls/Dialog.ts +355 -0
- package/src/controls/Dropdown.ts +856 -0
- package/src/controls/Form.ts +110 -0
- package/src/controls/NavLink.ts +211 -0
- package/src/controls/Popup.ts +129 -0
- package/src/controls/ProgressBar.ts +180 -0
- package/src/controls/RadioButton.ts +135 -0
- package/src/controls/RadioGroup.ts +244 -0
- package/src/controls/SelectionArea.ts +75 -0
- package/src/controls/Slider.ts +471 -0
- package/src/controls/Switch.ts +132 -0
- package/src/controls/TextArea.ts +20 -0
- package/src/controls/TextInput.ts +7 -0
- package/src/controls/index.ts +18 -0
- package/src/controls/internal/ButtonPresenter.ts +95 -0
- package/src/controls/internal/CheckboxIndicatorPresenter.ts +93 -0
- package/src/controls/internal/DropdownChevronPresenter.ts +67 -0
- package/src/controls/internal/DropdownFieldPresenter.ts +110 -0
- package/src/controls/internal/DropdownOptionRowPresenter.ts +82 -0
- package/src/controls/internal/PopupPresenter.ts +198 -0
- package/src/controls/internal/PressableIndicatorPresenter.ts +32 -0
- package/src/controls/internal/PressableLabeledControl.ts +221 -0
- package/src/controls/internal/RadioIndicatorPresenter.ts +73 -0
- package/src/controls/internal/SliderPresenter.ts +157 -0
- package/src/controls/internal/SwitchIndicatorPresenter.ts +72 -0
- package/src/controls/internal/TextInputCore.ts +695 -0
- package/src/controls/internal/TextInputPresenter.ts +72 -0
- package/src/controls/templating.ts +54 -0
- package/src/core/Action.ts +94 -0
- package/src/core/Actions.ts +37 -0
- package/src/core/Animation.ts +412 -0
- package/src/core/Application.ts +328 -0
- package/src/core/Assets.ts +264 -0
- package/src/core/AttachedProperties.ts +32 -0
- package/src/core/Bitmap.ts +70 -0
- package/src/core/BoundCallback.ts +104 -0
- package/src/core/Callbacks.ts +17 -0
- package/src/core/ContextMenuManager.ts +466 -0
- package/src/core/DebugApi.ts +30 -0
- package/src/core/Disposable.ts +10 -0
- package/src/core/DragDropManager.ts +179 -0
- package/src/core/DragGesture.ts +184 -0
- package/src/core/DynamicAssetIds.ts +24 -0
- package/src/core/Errors.ts +48 -0
- package/src/core/EventRouter.ts +408 -0
- package/src/core/ExternalDropManager.ts +122 -0
- package/src/core/Fetch.ts +264 -0
- package/src/core/FetchFfi.ts +15 -0
- package/src/core/File.ts +1002 -0
- package/src/core/FocusAdornerManager.ts +263 -0
- package/src/core/FocusVisibility.ts +36 -0
- package/src/core/FrameScheduler.ts +28 -0
- package/src/core/KeyboardScroll.ts +161 -0
- package/src/core/KeyboardScrollTracker.ts +386 -0
- package/src/core/Logger.ts +80 -0
- package/src/core/Navigation.ts +13 -0
- package/src/core/Node.ts +1708 -0
- package/src/core/PersistedState.ts +102 -0
- package/src/core/PersistedUiState.ts +142 -0
- package/src/core/Platform.ts +219 -0
- package/src/core/Signal.ts +89 -0
- package/src/core/Theme.ts +365 -0
- package/src/core/Timers.ts +129 -0
- package/src/core/ToolTip.ts +122 -0
- package/src/core/ToolTipManager.ts +459 -0
- package/src/core/Transitions.ts +34 -0
- package/src/core/Typography.ts +204 -0
- package/src/core/Worker.ts +196 -0
- package/src/core/bind.ts +37 -0
- package/src/core/event_exports.ts +596 -0
- package/src/core/ffi.ts +728 -0
- package/src/host-services/runtime.ts +25 -0
- package/src/nodes/FlexBox.ts +789 -0
- package/src/nodes/GradientStop.ts +9 -0
- package/src/nodes/Grid.ts +183 -0
- package/src/nodes/Image.ts +189 -0
- package/src/nodes/Portal.ts +14 -0
- package/src/nodes/RichText.ts +312 -0
- package/src/nodes/ScrollBar.ts +570 -0
- package/src/nodes/ScrollBox.ts +415 -0
- package/src/nodes/ScrollState.ts +10 -0
- package/src/nodes/ScrollView.ts +511 -0
- package/src/nodes/Svg.ts +142 -0
- package/src/nodes/Text.ts +145 -0
- package/src/nodes/TextCore.ts +558 -0
- package/src/nodes/VirtualList.ts +431 -0
- package/src/nodes/helpers.ts +25 -0
- package/src/nodes/index.ts +14 -0
- package/src/tsconfig.json +7 -0
- package/src/worker/Worker.ts +169 -0
- package/src/worker/WorkerJob.ts +65 -0
- package/src/worker/ffi.ts +23 -0
|
@@ -0,0 +1,1101 @@
|
|
|
1
|
+
import type { BridgeRuntime } from '@effindomv2/runtime';
|
|
2
|
+
import { computeModifiers, getPointerPosition } from '@effindomv2/runtime';
|
|
3
|
+
|
|
4
|
+
import { writeExternalDropPayload, writeFileListPayload, writeWriterPayload } from './managed-harness-file-payloads';
|
|
5
|
+
import {
|
|
6
|
+
EXTERNAL_DRAG_EVENT_DROP,
|
|
7
|
+
EXTERNAL_DRAG_EVENT_ENTER,
|
|
8
|
+
EXTERNAL_DRAG_EVENT_LEAVE,
|
|
9
|
+
EXTERNAL_DRAG_EVENT_OVER,
|
|
10
|
+
EXTERNAL_DROP_ITEM_KIND_FILE,
|
|
11
|
+
FILE_CAPABILITY_CHUNKED_READ,
|
|
12
|
+
FILE_CAPABILITY_CHUNKED_WRITE,
|
|
13
|
+
FILE_CAPABILITY_NATIVE_SAVE_PICKER,
|
|
14
|
+
FILE_CAPABILITY_OPEN,
|
|
15
|
+
FILE_CAPABILITY_PROCESS_WORKER_SAVE,
|
|
16
|
+
FILE_CAPABILITY_READ,
|
|
17
|
+
FILE_CAPABILITY_SAVE,
|
|
18
|
+
FILE_SAVE_MODE_DOWNLOAD,
|
|
19
|
+
FILE_SAVE_MODE_NATIVE_PICKER,
|
|
20
|
+
FILE_STATUS_CANCELLED,
|
|
21
|
+
FILE_STATUS_ERROR,
|
|
22
|
+
FILE_STATUS_SUCCESS,
|
|
23
|
+
type ActiveFileProcessingRecord,
|
|
24
|
+
type ActiveFileWriterRecord,
|
|
25
|
+
type ExternalHarnessDropItem,
|
|
26
|
+
type FileProcessingWorkerCancelMessage,
|
|
27
|
+
type FileProcessingWorkerNextMessage,
|
|
28
|
+
type FileProcessingWorkerOutboundMessage,
|
|
29
|
+
type FileProcessingWorkerStartMessage,
|
|
30
|
+
type SavePickerWindow,
|
|
31
|
+
type StoredFileRecord,
|
|
32
|
+
type WritableFileStreamLike,
|
|
33
|
+
} from './managed-harness-file-types';
|
|
34
|
+
import type { HarnessAppSession } from './managed-harness-session';
|
|
35
|
+
|
|
36
|
+
interface ManagedHarnessFileHostDependencies {
|
|
37
|
+
getCurrentSession(): HarnessAppSession | null;
|
|
38
|
+
getRuntime(): BridgeRuntime;
|
|
39
|
+
readAppUtf8(ptr: number, len: number): string;
|
|
40
|
+
readAppBytes(ptr: number, len: number): Uint8Array;
|
|
41
|
+
writeTextCallbackPayload(session: HarnessAppSession, text: string, context: string): number;
|
|
42
|
+
describeHarnessError(error: unknown): string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const encoder = new TextEncoder();
|
|
46
|
+
const FILE_PROCESSING_WORKER_URL = new URL('./file-processing-worker.js', import.meta.url).toString();
|
|
47
|
+
|
|
48
|
+
function copyBytesToArrayBuffer(bytes: Uint8Array): ArrayBuffer {
|
|
49
|
+
const copied = new Uint8Array(bytes.byteLength);
|
|
50
|
+
copied.set(bytes);
|
|
51
|
+
return copied.buffer as ArrayBuffer;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function supportsNativeSavePicker(): boolean {
|
|
55
|
+
return typeof (window as SavePickerWindow).showSaveFilePicker === 'function';
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function getFileCapabilities(): number {
|
|
59
|
+
let flags = FILE_CAPABILITY_OPEN | FILE_CAPABILITY_READ | FILE_CAPABILITY_SAVE | FILE_CAPABILITY_CHUNKED_READ;
|
|
60
|
+
if (supportsNativeSavePicker()) {
|
|
61
|
+
flags |= FILE_CAPABILITY_CHUNKED_WRITE | FILE_CAPABILITY_NATIVE_SAVE_PICKER;
|
|
62
|
+
flags |= FILE_CAPABILITY_PROCESS_WORKER_SAVE;
|
|
63
|
+
}
|
|
64
|
+
return flags;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function resolveSuggestedName(suggestedName: string, fileExtension: string): string {
|
|
68
|
+
const trimmedName = suggestedName.trim();
|
|
69
|
+
const trimmedExtension = fileExtension.trim();
|
|
70
|
+
if (trimmedName.length === 0) {
|
|
71
|
+
if (trimmedExtension.length > 0) {
|
|
72
|
+
return `export${trimmedExtension.startsWith('.') ? trimmedExtension : `.${trimmedExtension}`}`;
|
|
73
|
+
}
|
|
74
|
+
return 'export.bin';
|
|
75
|
+
}
|
|
76
|
+
if (trimmedExtension.length === 0) {
|
|
77
|
+
return trimmedName;
|
|
78
|
+
}
|
|
79
|
+
const normalizedExtension = trimmedExtension.startsWith('.') ? trimmedExtension : `.${trimmedExtension}`;
|
|
80
|
+
return trimmedName.endsWith(normalizedExtension) ? trimmedName : `${trimmedName}${normalizedExtension}`;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function abortWritableStream(stream: WritableFileStreamLike): Promise<void> {
|
|
84
|
+
if (typeof stream.abort === 'function') {
|
|
85
|
+
await stream.abort();
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
await stream.close();
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function createManagedHarnessFileHost(dependencies: ManagedHarnessFileHostDependencies) {
|
|
92
|
+
let nextStoredBrowserFileId = 1;
|
|
93
|
+
let nextFileWriterId = 1;
|
|
94
|
+
let nextExternalDropItemId = 1;
|
|
95
|
+
const storedBrowserFiles = new Map<string, File>();
|
|
96
|
+
const activeFileWriters = new Map<string, ActiveFileWriterRecord>();
|
|
97
|
+
const activeFileProcessingRequests = new Map<number, ActiveFileProcessingRecord>();
|
|
98
|
+
const cancelledFileProcessingRequestIds = new Set<number>();
|
|
99
|
+
let activeExternalDropItems: Array<ExternalHarnessDropItem> = [];
|
|
100
|
+
|
|
101
|
+
function emitFilePickResult(
|
|
102
|
+
session: HarnessAppSession | null,
|
|
103
|
+
requestId: number,
|
|
104
|
+
status: number,
|
|
105
|
+
files: readonly StoredFileRecord[] = [],
|
|
106
|
+
message = '',
|
|
107
|
+
): void {
|
|
108
|
+
if (session === null) {
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
const payloadLength = status === FILE_STATUS_SUCCESS
|
|
112
|
+
? writeFileListPayload(session, files)
|
|
113
|
+
: dependencies.writeTextCallbackPayload(session, message, 'File picker result');
|
|
114
|
+
session.exports.__fui_on_file_pick_result(
|
|
115
|
+
requestId,
|
|
116
|
+
status,
|
|
117
|
+
payloadLength > 0 ? session.textBufferPtr : 0,
|
|
118
|
+
payloadLength,
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function emitFileReadResult(
|
|
123
|
+
session: HarnessAppSession | null,
|
|
124
|
+
requestId: number,
|
|
125
|
+
status: number,
|
|
126
|
+
offsetBytes: bigint,
|
|
127
|
+
fileSizeBytes: bigint,
|
|
128
|
+
bytes: Uint8Array | null = null,
|
|
129
|
+
message = '',
|
|
130
|
+
): void {
|
|
131
|
+
if (session === null) {
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
let payloadLength = 0;
|
|
135
|
+
if (status === FILE_STATUS_SUCCESS) {
|
|
136
|
+
payloadLength = bytes?.length ?? 0;
|
|
137
|
+
if (payloadLength > session.textBufferSize) {
|
|
138
|
+
throw new Error('File read result exceeds the shared AssemblyScript text buffer.');
|
|
139
|
+
}
|
|
140
|
+
if (payloadLength > 0 && bytes !== null) {
|
|
141
|
+
new Uint8Array(session.memory.buffer, session.textBufferPtr, payloadLength).set(bytes);
|
|
142
|
+
}
|
|
143
|
+
} else {
|
|
144
|
+
payloadLength = dependencies.writeTextCallbackPayload(session, message, 'File read failure');
|
|
145
|
+
}
|
|
146
|
+
session.exports.__fui_on_file_read_result(
|
|
147
|
+
requestId,
|
|
148
|
+
status,
|
|
149
|
+
offsetBytes,
|
|
150
|
+
fileSizeBytes,
|
|
151
|
+
payloadLength > 0 ? session.textBufferPtr : 0,
|
|
152
|
+
payloadLength,
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function emitFileSaveResult(
|
|
157
|
+
session: HarnessAppSession | null,
|
|
158
|
+
requestId: number,
|
|
159
|
+
status: number,
|
|
160
|
+
writtenBytes: bigint,
|
|
161
|
+
fileName = '',
|
|
162
|
+
mode = FILE_SAVE_MODE_DOWNLOAD,
|
|
163
|
+
message = '',
|
|
164
|
+
): void {
|
|
165
|
+
if (session === null) {
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
const payloadLength = status === FILE_STATUS_SUCCESS
|
|
169
|
+
? writeWriterPayload(session, mode, fileName)
|
|
170
|
+
: dependencies.writeTextCallbackPayload(session, message, 'File save failure');
|
|
171
|
+
session.exports.__fui_on_file_save_result(
|
|
172
|
+
requestId,
|
|
173
|
+
status,
|
|
174
|
+
writtenBytes,
|
|
175
|
+
payloadLength > 0 ? session.textBufferPtr : 0,
|
|
176
|
+
payloadLength,
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function emitFileWriterCreated(
|
|
181
|
+
session: HarnessAppSession | null,
|
|
182
|
+
requestId: number,
|
|
183
|
+
status: number,
|
|
184
|
+
writerId = '',
|
|
185
|
+
fileName = '',
|
|
186
|
+
mode = FILE_SAVE_MODE_NATIVE_PICKER,
|
|
187
|
+
message = '',
|
|
188
|
+
): void {
|
|
189
|
+
if (session === null) {
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
const payloadLength = status === FILE_STATUS_SUCCESS
|
|
193
|
+
? writeWriterPayload(session, mode, writerId, fileName)
|
|
194
|
+
: dependencies.writeTextCallbackPayload(session, message, 'File writer creation failure');
|
|
195
|
+
session.exports.__fui_on_file_writer_created(
|
|
196
|
+
requestId,
|
|
197
|
+
status,
|
|
198
|
+
payloadLength > 0 ? session.textBufferPtr : 0,
|
|
199
|
+
payloadLength,
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function emitFileWriteResult(
|
|
204
|
+
session: HarnessAppSession | null,
|
|
205
|
+
requestId: number,
|
|
206
|
+
status: number,
|
|
207
|
+
writtenBytes: bigint,
|
|
208
|
+
totalWrittenBytes: bigint,
|
|
209
|
+
message = '',
|
|
210
|
+
): void {
|
|
211
|
+
if (session === null) {
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
const payloadLength = status === FILE_STATUS_SUCCESS
|
|
215
|
+
? 0
|
|
216
|
+
: dependencies.writeTextCallbackPayload(session, message, 'File write failure');
|
|
217
|
+
session.exports.__fui_on_file_write_result(
|
|
218
|
+
requestId,
|
|
219
|
+
status,
|
|
220
|
+
writtenBytes,
|
|
221
|
+
totalWrittenBytes,
|
|
222
|
+
payloadLength > 0 ? session.textBufferPtr : 0,
|
|
223
|
+
payloadLength,
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function emitFileFinishResult(
|
|
228
|
+
session: HarnessAppSession | null,
|
|
229
|
+
requestId: number,
|
|
230
|
+
status: number,
|
|
231
|
+
writtenBytes: bigint,
|
|
232
|
+
fileName = '',
|
|
233
|
+
mode = FILE_SAVE_MODE_NATIVE_PICKER,
|
|
234
|
+
message = '',
|
|
235
|
+
): void {
|
|
236
|
+
if (session === null) {
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
const payloadLength = status === FILE_STATUS_SUCCESS
|
|
240
|
+
? writeWriterPayload(session, mode, fileName)
|
|
241
|
+
: dependencies.writeTextCallbackPayload(session, message, 'File writer finish failure');
|
|
242
|
+
session.exports.__fui_on_file_finish_result(
|
|
243
|
+
requestId,
|
|
244
|
+
status,
|
|
245
|
+
writtenBytes,
|
|
246
|
+
payloadLength > 0 ? session.textBufferPtr : 0,
|
|
247
|
+
payloadLength,
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function emitFileWorkerProcessProgress(
|
|
252
|
+
session: HarnessAppSession | null,
|
|
253
|
+
requestId: number,
|
|
254
|
+
processedBytes: bigint,
|
|
255
|
+
totalBytes: bigint,
|
|
256
|
+
outputFileName: string | null,
|
|
257
|
+
): void {
|
|
258
|
+
if (session === null) {
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
const payloadLength = dependencies.writeTextCallbackPayload(
|
|
262
|
+
session,
|
|
263
|
+
outputFileName ?? '',
|
|
264
|
+
'File worker process progress',
|
|
265
|
+
);
|
|
266
|
+
session.exports.__fui_on_file_worker_process_progress(
|
|
267
|
+
requestId,
|
|
268
|
+
processedBytes,
|
|
269
|
+
totalBytes,
|
|
270
|
+
payloadLength > 0 ? session.textBufferPtr : 0,
|
|
271
|
+
payloadLength,
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function emitFileWorkerProcessChunk(
|
|
276
|
+
session: HarnessAppSession | null,
|
|
277
|
+
requestId: number,
|
|
278
|
+
offsetBytes: bigint,
|
|
279
|
+
fileSizeBytes: bigint,
|
|
280
|
+
bytes: Uint8Array,
|
|
281
|
+
): void {
|
|
282
|
+
if (session === null) {
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
const payloadLength = bytes.length;
|
|
286
|
+
if (payloadLength > session.textBufferSize) {
|
|
287
|
+
throw new Error('File worker process chunk exceeds the shared AssemblyScript text buffer.');
|
|
288
|
+
}
|
|
289
|
+
if (payloadLength > 0) {
|
|
290
|
+
new Uint8Array(session.memory.buffer, session.textBufferPtr, payloadLength).set(bytes);
|
|
291
|
+
}
|
|
292
|
+
session.exports.__fui_on_file_worker_process_chunk(
|
|
293
|
+
requestId,
|
|
294
|
+
offsetBytes,
|
|
295
|
+
fileSizeBytes,
|
|
296
|
+
payloadLength > 0 ? session.textBufferPtr : 0,
|
|
297
|
+
payloadLength,
|
|
298
|
+
);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function emitFileWorkerProcessComplete(
|
|
302
|
+
session: HarnessAppSession | null,
|
|
303
|
+
requestId: number,
|
|
304
|
+
processedBytes: bigint,
|
|
305
|
+
outputFileName: string | null,
|
|
306
|
+
): void {
|
|
307
|
+
if (session === null) {
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
const payloadLength = dependencies.writeTextCallbackPayload(
|
|
311
|
+
session,
|
|
312
|
+
outputFileName ?? '',
|
|
313
|
+
'File worker process completion',
|
|
314
|
+
);
|
|
315
|
+
session.exports.__fui_on_file_worker_process_complete(
|
|
316
|
+
requestId,
|
|
317
|
+
processedBytes,
|
|
318
|
+
payloadLength > 0 ? session.textBufferPtr : 0,
|
|
319
|
+
payloadLength,
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function emitFileWorkerProcessError(
|
|
324
|
+
session: HarnessAppSession | null,
|
|
325
|
+
requestId: number,
|
|
326
|
+
status: number,
|
|
327
|
+
message: string,
|
|
328
|
+
): void {
|
|
329
|
+
if (session === null) {
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
const payloadLength = dependencies.writeTextCallbackPayload(session, message, 'File worker process failure');
|
|
333
|
+
session.exports.__fui_on_file_worker_process_error(
|
|
334
|
+
requestId,
|
|
335
|
+
status,
|
|
336
|
+
payloadLength > 0 ? session.textBufferPtr : 0,
|
|
337
|
+
payloadLength,
|
|
338
|
+
);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function cleanupFileProcessingRequest(requestId: number): void {
|
|
342
|
+
cancelledFileProcessingRequestIds.delete(requestId);
|
|
343
|
+
const record = activeFileProcessingRequests.get(requestId);
|
|
344
|
+
if (record === undefined) {
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
record.worker.terminate();
|
|
348
|
+
activeFileProcessingRequests.delete(requestId);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
async function failFileProcessingRequest(record: ActiveFileProcessingRecord, status: number, message: string): Promise<void> {
|
|
352
|
+
cleanupFileProcessingRequest(record.requestId);
|
|
353
|
+
if (record.stream !== null) {
|
|
354
|
+
try {
|
|
355
|
+
await abortWritableStream(record.stream);
|
|
356
|
+
} catch {
|
|
357
|
+
// Ignore cleanup failures after surfacing the original worker-processing error.
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
if (dependencies.getCurrentSession() === record.session) {
|
|
361
|
+
emitFileWorkerProcessError(record.session, record.requestId, status, message);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
async function handleFileProcessingWorkerMessage(
|
|
366
|
+
record: ActiveFileProcessingRecord,
|
|
367
|
+
message: FileProcessingWorkerOutboundMessage,
|
|
368
|
+
): Promise<void> {
|
|
369
|
+
if (!activeFileProcessingRequests.has(record.requestId)) {
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
if (message.type === 'error') {
|
|
373
|
+
await failFileProcessingRequest(record, FILE_STATUS_ERROR, message.message);
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
if (record.cancelled) {
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
if (record.saveToPickedFile) {
|
|
380
|
+
const stream = record.stream;
|
|
381
|
+
if (stream === null) {
|
|
382
|
+
await failFileProcessingRequest(record, FILE_STATUS_ERROR, 'Worker file processing lost its target stream.');
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
try {
|
|
386
|
+
await stream.write(message.bytes);
|
|
387
|
+
if (dependencies.getCurrentSession() === record.session) {
|
|
388
|
+
emitFileWorkerProcessProgress(
|
|
389
|
+
record.session,
|
|
390
|
+
record.requestId,
|
|
391
|
+
BigInt(message.copiedBytes),
|
|
392
|
+
BigInt(message.totalBytes),
|
|
393
|
+
record.targetFileName,
|
|
394
|
+
);
|
|
395
|
+
}
|
|
396
|
+
if (message.copiedBytes >= message.totalBytes) {
|
|
397
|
+
await stream.close();
|
|
398
|
+
if (dependencies.getCurrentSession() === record.session) {
|
|
399
|
+
emitFileWorkerProcessComplete(
|
|
400
|
+
record.session,
|
|
401
|
+
record.requestId,
|
|
402
|
+
BigInt(message.totalBytes),
|
|
403
|
+
record.targetFileName,
|
|
404
|
+
);
|
|
405
|
+
}
|
|
406
|
+
cleanupFileProcessingRequest(record.requestId);
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
const nextMessage: FileProcessingWorkerNextMessage = { type: 'next' };
|
|
410
|
+
record.worker.postMessage(nextMessage);
|
|
411
|
+
} catch (error: unknown) {
|
|
412
|
+
await failFileProcessingRequest(record, FILE_STATUS_ERROR, dependencies.describeHarnessError(error));
|
|
413
|
+
}
|
|
414
|
+
return;
|
|
415
|
+
}
|
|
416
|
+
if (dependencies.getCurrentSession() === record.session) {
|
|
417
|
+
emitFileWorkerProcessChunk(
|
|
418
|
+
record.session,
|
|
419
|
+
record.requestId,
|
|
420
|
+
BigInt(message.offsetBytes),
|
|
421
|
+
BigInt(message.totalBytes),
|
|
422
|
+
new Uint8Array(message.bytes),
|
|
423
|
+
);
|
|
424
|
+
emitFileWorkerProcessProgress(
|
|
425
|
+
record.session,
|
|
426
|
+
record.requestId,
|
|
427
|
+
BigInt(message.copiedBytes),
|
|
428
|
+
BigInt(message.totalBytes),
|
|
429
|
+
null,
|
|
430
|
+
);
|
|
431
|
+
if (message.copiedBytes >= message.totalBytes) {
|
|
432
|
+
emitFileWorkerProcessComplete(
|
|
433
|
+
record.session,
|
|
434
|
+
record.requestId,
|
|
435
|
+
BigInt(message.totalBytes),
|
|
436
|
+
null,
|
|
437
|
+
);
|
|
438
|
+
cleanupFileProcessingRequest(record.requestId);
|
|
439
|
+
return;
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
const nextMessage: FileProcessingWorkerNextMessage = { type: 'next' };
|
|
443
|
+
record.worker.postMessage(nextMessage);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function cancelFileProcessingRequest(requestId: number): void {
|
|
447
|
+
cancelledFileProcessingRequestIds.add(requestId);
|
|
448
|
+
const record = activeFileProcessingRequests.get(requestId);
|
|
449
|
+
if (record === undefined) {
|
|
450
|
+
return;
|
|
451
|
+
}
|
|
452
|
+
record.cancelled = true;
|
|
453
|
+
const cancelMessage: FileProcessingWorkerCancelMessage = { type: 'cancel' };
|
|
454
|
+
record.worker.postMessage(cancelMessage);
|
|
455
|
+
cleanupFileProcessingRequest(requestId);
|
|
456
|
+
if (record.stream !== null) {
|
|
457
|
+
void abortWritableStream(record.stream).catch(() => {
|
|
458
|
+
// Ignore cancellation cleanup failures.
|
|
459
|
+
});
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
function storeBrowserFile(file: File, prefix: string): StoredFileRecord {
|
|
464
|
+
const id = `${prefix}-${String(nextStoredBrowserFileId++)}`;
|
|
465
|
+
storedBrowserFiles.set(id, file);
|
|
466
|
+
return {
|
|
467
|
+
id,
|
|
468
|
+
file,
|
|
469
|
+
};
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
function snapshotStoredBrowserFile(file: File, prefix: string): ExternalHarnessDropItem {
|
|
473
|
+
const stored = storeBrowserFile(file, prefix);
|
|
474
|
+
return {
|
|
475
|
+
id: stored.id,
|
|
476
|
+
kind: EXTERNAL_DROP_ITEM_KIND_FILE,
|
|
477
|
+
name: file.name,
|
|
478
|
+
mimeType: file.type.length > 0 ? file.type : null,
|
|
479
|
+
sizeBytes: file.size,
|
|
480
|
+
};
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
function clearActiveExternalDropItems(): void {
|
|
484
|
+
activeExternalDropItems = [];
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
function snapshotExternalDropItems(dataTransfer: DataTransfer | null): Array<ExternalHarnessDropItem> {
|
|
488
|
+
if (dataTransfer === null) {
|
|
489
|
+
return [];
|
|
490
|
+
}
|
|
491
|
+
const files = Array.from(dataTransfer.files ?? []);
|
|
492
|
+
if (files.length > 0) {
|
|
493
|
+
return files.map((file) => snapshotStoredBrowserFile(file, 'external-drop'));
|
|
494
|
+
}
|
|
495
|
+
const itemEntries = Array.from(dataTransfer.items ?? []);
|
|
496
|
+
const fileEntries = itemEntries.filter((item) => item.kind === 'file');
|
|
497
|
+
if (fileEntries.length > 0) {
|
|
498
|
+
return fileEntries.map((item, index) => ({
|
|
499
|
+
id: `external-drop-${String(nextExternalDropItemId++)}`,
|
|
500
|
+
kind: EXTERNAL_DROP_ITEM_KIND_FILE,
|
|
501
|
+
name: `Dropped file ${String(index + 1)}`,
|
|
502
|
+
mimeType: item.type.length > 0 ? item.type : null,
|
|
503
|
+
sizeBytes: 0,
|
|
504
|
+
}));
|
|
505
|
+
}
|
|
506
|
+
const dragTypes = Array.from(dataTransfer.types ?? []);
|
|
507
|
+
if (dragTypes.includes('Files')) {
|
|
508
|
+
return [{
|
|
509
|
+
id: `external-drop-${String(nextExternalDropItemId++)}`,
|
|
510
|
+
kind: EXTERNAL_DROP_ITEM_KIND_FILE,
|
|
511
|
+
name: 'Dropped file',
|
|
512
|
+
mimeType: null,
|
|
513
|
+
sizeBytes: 0,
|
|
514
|
+
}];
|
|
515
|
+
}
|
|
516
|
+
return [];
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
function getExternalDropItems(dataTransfer: DataTransfer | null, reuseActive: boolean): Array<ExternalHarnessDropItem> {
|
|
520
|
+
if (reuseActive && activeExternalDropItems.length > 0) {
|
|
521
|
+
return activeExternalDropItems;
|
|
522
|
+
}
|
|
523
|
+
const items = snapshotExternalDropItems(dataTransfer);
|
|
524
|
+
activeExternalDropItems = items;
|
|
525
|
+
return items;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
function mapExternalDropEffect(effect: number): DataTransfer['dropEffect'] {
|
|
529
|
+
if ((effect & 2) !== 0) {
|
|
530
|
+
return 'move';
|
|
531
|
+
}
|
|
532
|
+
if ((effect & 1) !== 0) {
|
|
533
|
+
return 'copy';
|
|
534
|
+
}
|
|
535
|
+
if ((effect & 4) !== 0) {
|
|
536
|
+
return 'link';
|
|
537
|
+
}
|
|
538
|
+
return 'none';
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
function dispatchExternalDragEvent(
|
|
542
|
+
eventType: number,
|
|
543
|
+
event: DragEvent,
|
|
544
|
+
options: { readonly handle?: bigint; readonly reuseActiveItems?: boolean } = {},
|
|
545
|
+
): number {
|
|
546
|
+
const session = dependencies.getCurrentSession();
|
|
547
|
+
if (
|
|
548
|
+
session === null ||
|
|
549
|
+
session.textBufferPtr === 0 ||
|
|
550
|
+
session.textBufferSize === 0
|
|
551
|
+
) {
|
|
552
|
+
if (eventType === EXTERNAL_DRAG_EVENT_LEAVE || eventType === EXTERNAL_DRAG_EVENT_DROP) {
|
|
553
|
+
clearActiveExternalDropItems();
|
|
554
|
+
}
|
|
555
|
+
return 0;
|
|
556
|
+
}
|
|
557
|
+
const items = eventType === EXTERNAL_DRAG_EVENT_LEAVE
|
|
558
|
+
? activeExternalDropItems
|
|
559
|
+
: getExternalDropItems(
|
|
560
|
+
event.dataTransfer,
|
|
561
|
+
eventType === EXTERNAL_DRAG_EVENT_DROP ? false : (options.reuseActiveItems !== false),
|
|
562
|
+
);
|
|
563
|
+
if (items.length === 0 && eventType !== EXTERNAL_DRAG_EVENT_LEAVE) {
|
|
564
|
+
return 0;
|
|
565
|
+
}
|
|
566
|
+
const runtime = dependencies.getRuntime();
|
|
567
|
+
const position = getPointerPosition(runtime.canvas, event);
|
|
568
|
+
const handle = options.handle ?? runtime.getHandleFromPoint(position.x, position.y);
|
|
569
|
+
const payloadLength = items.length > 0 ? writeExternalDropPayload(session, items) : 0;
|
|
570
|
+
const effect = session.exports.__fui_on_external_drag_event(
|
|
571
|
+
eventType,
|
|
572
|
+
handle,
|
|
573
|
+
position.x,
|
|
574
|
+
position.y,
|
|
575
|
+
computeModifiers(event),
|
|
576
|
+
payloadLength > 0 ? session.textBufferPtr : 0,
|
|
577
|
+
payloadLength,
|
|
578
|
+
);
|
|
579
|
+
if (eventType === EXTERNAL_DRAG_EVENT_LEAVE || eventType === EXTERNAL_DRAG_EVENT_DROP) {
|
|
580
|
+
clearActiveExternalDropItems();
|
|
581
|
+
}
|
|
582
|
+
return effect;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
function cancelAllForSession(session: HarnessAppSession | null): void {
|
|
586
|
+
clearActiveExternalDropItems();
|
|
587
|
+
for (const [writerId, record] of activeFileWriters.entries()) {
|
|
588
|
+
if (session !== null && record.session !== session) {
|
|
589
|
+
continue;
|
|
590
|
+
}
|
|
591
|
+
activeFileWriters.delete(writerId);
|
|
592
|
+
void abortWritableStream(record.stream).catch(() => {
|
|
593
|
+
// Ignore disposal cleanup failures.
|
|
594
|
+
});
|
|
595
|
+
}
|
|
596
|
+
for (const [requestId, record] of activeFileProcessingRequests.entries()) {
|
|
597
|
+
if (session !== null && record.session !== session) {
|
|
598
|
+
continue;
|
|
599
|
+
}
|
|
600
|
+
cancelFileProcessingRequest(requestId);
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
return {
|
|
605
|
+
cancelAllForSession,
|
|
606
|
+
dispatchExternalDragEvent,
|
|
607
|
+
mapExternalDropEffect,
|
|
608
|
+
imports: {
|
|
609
|
+
fui_file_capabilities(): number {
|
|
610
|
+
return getFileCapabilities();
|
|
611
|
+
},
|
|
612
|
+
fui_file_pick(requestId: number, acceptPtr: number, acceptLen: number, multiple: boolean): void {
|
|
613
|
+
const session = dependencies.getCurrentSession();
|
|
614
|
+
if (session === null) {
|
|
615
|
+
return;
|
|
616
|
+
}
|
|
617
|
+
const accept = dependencies.readAppUtf8(acceptPtr, acceptLen);
|
|
618
|
+
const host = document.body ?? document.documentElement;
|
|
619
|
+
const input = document.createElement('input');
|
|
620
|
+
input.type = 'file';
|
|
621
|
+
input.multiple = multiple;
|
|
622
|
+
if (accept.length > 0) {
|
|
623
|
+
input.accept = accept;
|
|
624
|
+
}
|
|
625
|
+
input.tabIndex = -1;
|
|
626
|
+
input.style.position = 'fixed';
|
|
627
|
+
input.style.left = '-10000px';
|
|
628
|
+
input.style.top = '0';
|
|
629
|
+
input.style.opacity = '0';
|
|
630
|
+
host.appendChild(input);
|
|
631
|
+
let finished = false;
|
|
632
|
+
const complete = (status: number, files: readonly StoredFileRecord[] = [], message = '') => {
|
|
633
|
+
if (finished) {
|
|
634
|
+
return;
|
|
635
|
+
}
|
|
636
|
+
finished = true;
|
|
637
|
+
window.removeEventListener('focus', handleFocus, true);
|
|
638
|
+
input.remove();
|
|
639
|
+
if (dependencies.getCurrentSession() !== session) {
|
|
640
|
+
return;
|
|
641
|
+
}
|
|
642
|
+
emitFilePickResult(session, requestId, status, files, message);
|
|
643
|
+
};
|
|
644
|
+
const handleFocus = () => {
|
|
645
|
+
window.setTimeout(() => {
|
|
646
|
+
if (finished) {
|
|
647
|
+
return;
|
|
648
|
+
}
|
|
649
|
+
const selected = Array.from(input.files ?? []).map((file) => storeBrowserFile(file, 'picked-file'));
|
|
650
|
+
if (selected.length > 0) {
|
|
651
|
+
complete(FILE_STATUS_SUCCESS, selected);
|
|
652
|
+
return;
|
|
653
|
+
}
|
|
654
|
+
complete(FILE_STATUS_CANCELLED, [], 'File picker cancelled.');
|
|
655
|
+
}, 0);
|
|
656
|
+
};
|
|
657
|
+
input.addEventListener('change', () => {
|
|
658
|
+
const selected = Array.from(input.files ?? []).map((file) => storeBrowserFile(file, 'picked-file'));
|
|
659
|
+
complete(FILE_STATUS_SUCCESS, selected);
|
|
660
|
+
}, { once: true });
|
|
661
|
+
window.addEventListener('focus', handleFocus, true);
|
|
662
|
+
input.click();
|
|
663
|
+
},
|
|
664
|
+
fui_file_read_chunk(requestId: number, fileIdPtr: number, fileIdLen: number, offsetBytes: bigint | number, maxBytes: number): void {
|
|
665
|
+
const session = dependencies.getCurrentSession();
|
|
666
|
+
if (session === null) {
|
|
667
|
+
return;
|
|
668
|
+
}
|
|
669
|
+
const fileId = dependencies.readAppUtf8(fileIdPtr, fileIdLen);
|
|
670
|
+
const sourceFile = storedBrowserFiles.get(fileId);
|
|
671
|
+
if (sourceFile === undefined) {
|
|
672
|
+
emitFileReadResult(session, requestId, FILE_STATUS_ERROR, 0n, 0n, null, `Unknown browser file "${fileId}".`);
|
|
673
|
+
return;
|
|
674
|
+
}
|
|
675
|
+
const numericOffset = typeof offsetBytes === 'bigint' ? Number(offsetBytes) : offsetBytes;
|
|
676
|
+
if (!Number.isFinite(numericOffset) || numericOffset < 0) {
|
|
677
|
+
emitFileReadResult(session, requestId, FILE_STATUS_ERROR, 0n, BigInt(sourceFile.size), null, 'File read offset was invalid.');
|
|
678
|
+
return;
|
|
679
|
+
}
|
|
680
|
+
const safeOffset = Math.min(sourceFile.size, Math.floor(numericOffset));
|
|
681
|
+
const clampedMaxBytes = Math.max(1, Math.min(Math.floor(maxBytes), session.textBufferSize));
|
|
682
|
+
void sourceFile.slice(safeOffset, safeOffset + clampedMaxBytes).arrayBuffer().then((buffer) => {
|
|
683
|
+
if (dependencies.getCurrentSession() !== session) {
|
|
684
|
+
return;
|
|
685
|
+
}
|
|
686
|
+
emitFileReadResult(
|
|
687
|
+
session,
|
|
688
|
+
requestId,
|
|
689
|
+
FILE_STATUS_SUCCESS,
|
|
690
|
+
BigInt(safeOffset),
|
|
691
|
+
BigInt(sourceFile.size),
|
|
692
|
+
new Uint8Array(buffer),
|
|
693
|
+
);
|
|
694
|
+
}).catch((error: unknown) => {
|
|
695
|
+
if (dependencies.getCurrentSession() !== session) {
|
|
696
|
+
return;
|
|
697
|
+
}
|
|
698
|
+
emitFileReadResult(
|
|
699
|
+
session,
|
|
700
|
+
requestId,
|
|
701
|
+
FILE_STATUS_ERROR,
|
|
702
|
+
BigInt(safeOffset),
|
|
703
|
+
BigInt(sourceFile.size),
|
|
704
|
+
null,
|
|
705
|
+
error instanceof Error ? error.message : String(error),
|
|
706
|
+
);
|
|
707
|
+
});
|
|
708
|
+
},
|
|
709
|
+
fui_file_save_text(
|
|
710
|
+
requestId: number,
|
|
711
|
+
suggestedNamePtr: number,
|
|
712
|
+
suggestedNameLen: number,
|
|
713
|
+
mimeTypePtr: number,
|
|
714
|
+
mimeTypeLen: number,
|
|
715
|
+
fileExtensionPtr: number,
|
|
716
|
+
fileExtensionLen: number,
|
|
717
|
+
textPtr: number,
|
|
718
|
+
textLen: number,
|
|
719
|
+
): void {
|
|
720
|
+
const session = dependencies.getCurrentSession();
|
|
721
|
+
if (session === null) {
|
|
722
|
+
return;
|
|
723
|
+
}
|
|
724
|
+
const suggestedName = resolveSuggestedName(
|
|
725
|
+
dependencies.readAppUtf8(suggestedNamePtr, suggestedNameLen),
|
|
726
|
+
dependencies.readAppUtf8(fileExtensionPtr, fileExtensionLen),
|
|
727
|
+
);
|
|
728
|
+
const mimeType = dependencies.readAppUtf8(mimeTypePtr, mimeTypeLen);
|
|
729
|
+
const text = dependencies.readAppUtf8(textPtr, textLen);
|
|
730
|
+
const encoded = encoder.encode(text);
|
|
731
|
+
const finishDownload = () => {
|
|
732
|
+
const blob = new Blob([encoded], {
|
|
733
|
+
type: mimeType.length > 0 ? mimeType : undefined,
|
|
734
|
+
});
|
|
735
|
+
const url = URL.createObjectURL(blob);
|
|
736
|
+
const anchor = document.createElement('a');
|
|
737
|
+
anchor.href = url;
|
|
738
|
+
anchor.download = suggestedName;
|
|
739
|
+
document.body?.appendChild(anchor);
|
|
740
|
+
anchor.click();
|
|
741
|
+
anchor.remove();
|
|
742
|
+
window.setTimeout(() => URL.revokeObjectURL(url), 0);
|
|
743
|
+
if (dependencies.getCurrentSession() === session) {
|
|
744
|
+
emitFileSaveResult(session, requestId, FILE_STATUS_SUCCESS, BigInt(encoded.length), suggestedName, FILE_SAVE_MODE_DOWNLOAD);
|
|
745
|
+
}
|
|
746
|
+
};
|
|
747
|
+
const savePicker = (window as SavePickerWindow).showSaveFilePicker;
|
|
748
|
+
if (typeof savePicker !== 'function') {
|
|
749
|
+
finishDownload();
|
|
750
|
+
return;
|
|
751
|
+
}
|
|
752
|
+
void savePicker({ suggestedName }).then((handle) => handle.createWritable().then(async (stream) => {
|
|
753
|
+
await stream.write(text);
|
|
754
|
+
await stream.close();
|
|
755
|
+
if (dependencies.getCurrentSession() === session) {
|
|
756
|
+
emitFileSaveResult(
|
|
757
|
+
session,
|
|
758
|
+
requestId,
|
|
759
|
+
FILE_STATUS_SUCCESS,
|
|
760
|
+
BigInt(encoded.length),
|
|
761
|
+
handle.name ?? suggestedName,
|
|
762
|
+
FILE_SAVE_MODE_NATIVE_PICKER,
|
|
763
|
+
);
|
|
764
|
+
}
|
|
765
|
+
})).catch((error: unknown) => {
|
|
766
|
+
if (dependencies.getCurrentSession() !== session) {
|
|
767
|
+
return;
|
|
768
|
+
}
|
|
769
|
+
emitFileSaveResult(
|
|
770
|
+
session,
|
|
771
|
+
requestId,
|
|
772
|
+
error instanceof DOMException && error.name === 'AbortError' ? FILE_STATUS_CANCELLED : FILE_STATUS_ERROR,
|
|
773
|
+
0n,
|
|
774
|
+
'',
|
|
775
|
+
FILE_SAVE_MODE_NATIVE_PICKER,
|
|
776
|
+
error instanceof Error ? error.message : String(error),
|
|
777
|
+
);
|
|
778
|
+
});
|
|
779
|
+
},
|
|
780
|
+
fui_file_save_bytes(
|
|
781
|
+
requestId: number,
|
|
782
|
+
suggestedNamePtr: number,
|
|
783
|
+
suggestedNameLen: number,
|
|
784
|
+
mimeTypePtr: number,
|
|
785
|
+
mimeTypeLen: number,
|
|
786
|
+
fileExtensionPtr: number,
|
|
787
|
+
fileExtensionLen: number,
|
|
788
|
+
bytesPtr: number,
|
|
789
|
+
bytesLen: number,
|
|
790
|
+
): void {
|
|
791
|
+
const session = dependencies.getCurrentSession();
|
|
792
|
+
if (session === null) {
|
|
793
|
+
return;
|
|
794
|
+
}
|
|
795
|
+
const suggestedName = resolveSuggestedName(
|
|
796
|
+
dependencies.readAppUtf8(suggestedNamePtr, suggestedNameLen),
|
|
797
|
+
dependencies.readAppUtf8(fileExtensionPtr, fileExtensionLen),
|
|
798
|
+
);
|
|
799
|
+
const mimeType = dependencies.readAppUtf8(mimeTypePtr, mimeTypeLen);
|
|
800
|
+
const bytes = dependencies.readAppBytes(bytesPtr, bytesLen);
|
|
801
|
+
const copiedBytes = copyBytesToArrayBuffer(bytes);
|
|
802
|
+
const finishDownload = () => {
|
|
803
|
+
const blob = new Blob([copiedBytes], {
|
|
804
|
+
type: mimeType.length > 0 ? mimeType : undefined,
|
|
805
|
+
});
|
|
806
|
+
const url = URL.createObjectURL(blob);
|
|
807
|
+
const anchor = document.createElement('a');
|
|
808
|
+
anchor.href = url;
|
|
809
|
+
anchor.download = suggestedName;
|
|
810
|
+
document.body?.appendChild(anchor);
|
|
811
|
+
anchor.click();
|
|
812
|
+
anchor.remove();
|
|
813
|
+
window.setTimeout(() => URL.revokeObjectURL(url), 0);
|
|
814
|
+
if (dependencies.getCurrentSession() === session) {
|
|
815
|
+
emitFileSaveResult(session, requestId, FILE_STATUS_SUCCESS, BigInt(bytes.length), suggestedName, FILE_SAVE_MODE_DOWNLOAD);
|
|
816
|
+
}
|
|
817
|
+
};
|
|
818
|
+
const savePicker = (window as SavePickerWindow).showSaveFilePicker;
|
|
819
|
+
if (typeof savePicker !== 'function') {
|
|
820
|
+
finishDownload();
|
|
821
|
+
return;
|
|
822
|
+
}
|
|
823
|
+
void savePicker({ suggestedName }).then((handle) => handle.createWritable().then(async (stream) => {
|
|
824
|
+
await stream.write(copiedBytes);
|
|
825
|
+
await stream.close();
|
|
826
|
+
if (dependencies.getCurrentSession() === session) {
|
|
827
|
+
emitFileSaveResult(
|
|
828
|
+
session,
|
|
829
|
+
requestId,
|
|
830
|
+
FILE_STATUS_SUCCESS,
|
|
831
|
+
BigInt(bytes.length),
|
|
832
|
+
handle.name ?? suggestedName,
|
|
833
|
+
FILE_SAVE_MODE_NATIVE_PICKER,
|
|
834
|
+
);
|
|
835
|
+
}
|
|
836
|
+
})).catch((error: unknown) => {
|
|
837
|
+
if (dependencies.getCurrentSession() !== session) {
|
|
838
|
+
return;
|
|
839
|
+
}
|
|
840
|
+
emitFileSaveResult(
|
|
841
|
+
session,
|
|
842
|
+
requestId,
|
|
843
|
+
error instanceof DOMException && error.name === 'AbortError' ? FILE_STATUS_CANCELLED : FILE_STATUS_ERROR,
|
|
844
|
+
0n,
|
|
845
|
+
'',
|
|
846
|
+
FILE_SAVE_MODE_NATIVE_PICKER,
|
|
847
|
+
error instanceof Error ? error.message : String(error),
|
|
848
|
+
);
|
|
849
|
+
});
|
|
850
|
+
},
|
|
851
|
+
fui_file_create_writer(
|
|
852
|
+
requestId: number,
|
|
853
|
+
suggestedNamePtr: number,
|
|
854
|
+
suggestedNameLen: number,
|
|
855
|
+
_mimeTypePtr: number,
|
|
856
|
+
_mimeTypeLen: number,
|
|
857
|
+
fileExtensionPtr: number,
|
|
858
|
+
fileExtensionLen: number,
|
|
859
|
+
): void {
|
|
860
|
+
const session = dependencies.getCurrentSession();
|
|
861
|
+
if (session === null) {
|
|
862
|
+
return;
|
|
863
|
+
}
|
|
864
|
+
const savePicker = (window as SavePickerWindow).showSaveFilePicker;
|
|
865
|
+
if (typeof savePicker !== 'function') {
|
|
866
|
+
emitFileWriterCreated(session, requestId, FILE_STATUS_ERROR, '', '', FILE_SAVE_MODE_NATIVE_PICKER, 'Chunked file writers require the native save picker.');
|
|
867
|
+
return;
|
|
868
|
+
}
|
|
869
|
+
const suggestedName = resolveSuggestedName(
|
|
870
|
+
dependencies.readAppUtf8(suggestedNamePtr, suggestedNameLen),
|
|
871
|
+
dependencies.readAppUtf8(fileExtensionPtr, fileExtensionLen),
|
|
872
|
+
);
|
|
873
|
+
void savePicker({ suggestedName }).then((handle) => handle.createWritable().then((stream) => {
|
|
874
|
+
const writerId = `writer-${String(nextFileWriterId++)}`;
|
|
875
|
+
activeFileWriters.set(writerId, {
|
|
876
|
+
id: writerId,
|
|
877
|
+
session,
|
|
878
|
+
fileName: handle.name ?? suggestedName,
|
|
879
|
+
mode: FILE_SAVE_MODE_NATIVE_PICKER,
|
|
880
|
+
stream,
|
|
881
|
+
writtenBytes: 0,
|
|
882
|
+
});
|
|
883
|
+
if (dependencies.getCurrentSession() === session) {
|
|
884
|
+
emitFileWriterCreated(
|
|
885
|
+
session,
|
|
886
|
+
requestId,
|
|
887
|
+
FILE_STATUS_SUCCESS,
|
|
888
|
+
writerId,
|
|
889
|
+
handle.name ?? suggestedName,
|
|
890
|
+
FILE_SAVE_MODE_NATIVE_PICKER,
|
|
891
|
+
);
|
|
892
|
+
}
|
|
893
|
+
})).catch((error: unknown) => {
|
|
894
|
+
if (dependencies.getCurrentSession() !== session) {
|
|
895
|
+
return;
|
|
896
|
+
}
|
|
897
|
+
emitFileWriterCreated(
|
|
898
|
+
session,
|
|
899
|
+
requestId,
|
|
900
|
+
error instanceof DOMException && error.name === 'AbortError' ? FILE_STATUS_CANCELLED : FILE_STATUS_ERROR,
|
|
901
|
+
'',
|
|
902
|
+
'',
|
|
903
|
+
FILE_SAVE_MODE_NATIVE_PICKER,
|
|
904
|
+
error instanceof Error ? error.message : String(error),
|
|
905
|
+
);
|
|
906
|
+
});
|
|
907
|
+
},
|
|
908
|
+
fui_file_writer_write_text(requestId: number, writerIdPtr: number, writerIdLen: number, textPtr: number, textLen: number): void {
|
|
909
|
+
const session = dependencies.getCurrentSession();
|
|
910
|
+
if (session === null) {
|
|
911
|
+
return;
|
|
912
|
+
}
|
|
913
|
+
const writerId = dependencies.readAppUtf8(writerIdPtr, writerIdLen);
|
|
914
|
+
const record = activeFileWriters.get(writerId);
|
|
915
|
+
if (record === undefined) {
|
|
916
|
+
emitFileWriteResult(session, requestId, FILE_STATUS_ERROR, 0n, 0n, `Unknown file writer "${writerId}".`);
|
|
917
|
+
return;
|
|
918
|
+
}
|
|
919
|
+
const text = dependencies.readAppUtf8(textPtr, textLen);
|
|
920
|
+
const encodedLength = encoder.encode(text).length;
|
|
921
|
+
void record.stream.write(text).then(() => {
|
|
922
|
+
record.writtenBytes += encodedLength;
|
|
923
|
+
if (dependencies.getCurrentSession() === session) {
|
|
924
|
+
emitFileWriteResult(
|
|
925
|
+
session,
|
|
926
|
+
requestId,
|
|
927
|
+
FILE_STATUS_SUCCESS,
|
|
928
|
+
BigInt(encodedLength),
|
|
929
|
+
BigInt(record.writtenBytes),
|
|
930
|
+
);
|
|
931
|
+
}
|
|
932
|
+
}).catch((error: unknown) => {
|
|
933
|
+
if (dependencies.getCurrentSession() === session) {
|
|
934
|
+
emitFileWriteResult(session, requestId, FILE_STATUS_ERROR, 0n, BigInt(record.writtenBytes), error instanceof Error ? error.message : String(error));
|
|
935
|
+
}
|
|
936
|
+
});
|
|
937
|
+
},
|
|
938
|
+
fui_file_writer_write_bytes(requestId: number, writerIdPtr: number, writerIdLen: number, bytesPtr: number, bytesLen: number): void {
|
|
939
|
+
const session = dependencies.getCurrentSession();
|
|
940
|
+
if (session === null) {
|
|
941
|
+
return;
|
|
942
|
+
}
|
|
943
|
+
const writerId = dependencies.readAppUtf8(writerIdPtr, writerIdLen);
|
|
944
|
+
const record = activeFileWriters.get(writerId);
|
|
945
|
+
if (record === undefined) {
|
|
946
|
+
emitFileWriteResult(session, requestId, FILE_STATUS_ERROR, 0n, 0n, `Unknown file writer "${writerId}".`);
|
|
947
|
+
return;
|
|
948
|
+
}
|
|
949
|
+
const bytes = dependencies.readAppBytes(bytesPtr, bytesLen);
|
|
950
|
+
const copiedBytes = copyBytesToArrayBuffer(bytes);
|
|
951
|
+
void record.stream.write(copiedBytes).then(() => {
|
|
952
|
+
record.writtenBytes += bytes.length;
|
|
953
|
+
if (dependencies.getCurrentSession() === session) {
|
|
954
|
+
emitFileWriteResult(
|
|
955
|
+
session,
|
|
956
|
+
requestId,
|
|
957
|
+
FILE_STATUS_SUCCESS,
|
|
958
|
+
BigInt(bytes.length),
|
|
959
|
+
BigInt(record.writtenBytes),
|
|
960
|
+
);
|
|
961
|
+
}
|
|
962
|
+
}).catch((error: unknown) => {
|
|
963
|
+
if (dependencies.getCurrentSession() === session) {
|
|
964
|
+
emitFileWriteResult(session, requestId, FILE_STATUS_ERROR, 0n, BigInt(record.writtenBytes), error instanceof Error ? error.message : String(error));
|
|
965
|
+
}
|
|
966
|
+
});
|
|
967
|
+
},
|
|
968
|
+
fui_file_writer_finish(requestId: number, writerIdPtr: number, writerIdLen: number): void {
|
|
969
|
+
const session = dependencies.getCurrentSession();
|
|
970
|
+
if (session === null) {
|
|
971
|
+
return;
|
|
972
|
+
}
|
|
973
|
+
const writerId = dependencies.readAppUtf8(writerIdPtr, writerIdLen);
|
|
974
|
+
const record = activeFileWriters.get(writerId);
|
|
975
|
+
if (record === undefined) {
|
|
976
|
+
emitFileFinishResult(session, requestId, FILE_STATUS_ERROR, 0n, '', FILE_SAVE_MODE_NATIVE_PICKER, `Unknown file writer "${writerId}".`);
|
|
977
|
+
return;
|
|
978
|
+
}
|
|
979
|
+
activeFileWriters.delete(writerId);
|
|
980
|
+
void record.stream.close().then(() => {
|
|
981
|
+
if (dependencies.getCurrentSession() === session) {
|
|
982
|
+
emitFileFinishResult(
|
|
983
|
+
session,
|
|
984
|
+
requestId,
|
|
985
|
+
FILE_STATUS_SUCCESS,
|
|
986
|
+
BigInt(record.writtenBytes),
|
|
987
|
+
record.fileName,
|
|
988
|
+
record.mode,
|
|
989
|
+
);
|
|
990
|
+
}
|
|
991
|
+
}).catch((error: unknown) => {
|
|
992
|
+
if (dependencies.getCurrentSession() === session) {
|
|
993
|
+
emitFileFinishResult(
|
|
994
|
+
session,
|
|
995
|
+
requestId,
|
|
996
|
+
FILE_STATUS_ERROR,
|
|
997
|
+
BigInt(record.writtenBytes),
|
|
998
|
+
'',
|
|
999
|
+
record.mode,
|
|
1000
|
+
error instanceof Error ? error.message : String(error),
|
|
1001
|
+
);
|
|
1002
|
+
}
|
|
1003
|
+
});
|
|
1004
|
+
},
|
|
1005
|
+
fui_file_process_worker_start(
|
|
1006
|
+
requestId: number,
|
|
1007
|
+
fileIdPtr: number,
|
|
1008
|
+
fileIdLen: number,
|
|
1009
|
+
suggestedNamePtr: number,
|
|
1010
|
+
suggestedNameLen: number,
|
|
1011
|
+
chunkBytes: number,
|
|
1012
|
+
saveToPickedFile: boolean,
|
|
1013
|
+
): void {
|
|
1014
|
+
const session = dependencies.getCurrentSession();
|
|
1015
|
+
if (session === null) {
|
|
1016
|
+
return;
|
|
1017
|
+
}
|
|
1018
|
+
if (typeof Worker !== 'function') {
|
|
1019
|
+
emitFileWorkerProcessError(session, requestId, FILE_STATUS_ERROR, 'Worker file processing requires browser Worker support.');
|
|
1020
|
+
return;
|
|
1021
|
+
}
|
|
1022
|
+
const fileId = dependencies.readAppUtf8(fileIdPtr, fileIdLen);
|
|
1023
|
+
const sourceFile = storedBrowserFiles.get(fileId);
|
|
1024
|
+
if (sourceFile === undefined) {
|
|
1025
|
+
emitFileWorkerProcessError(session, requestId, FILE_STATUS_ERROR, `Unknown browser file "${fileId}".`);
|
|
1026
|
+
return;
|
|
1027
|
+
}
|
|
1028
|
+
const suggestedName = resolveSuggestedName(dependencies.readAppUtf8(suggestedNamePtr, suggestedNameLen), '');
|
|
1029
|
+
const safeChunkBytes = Math.max(1, Math.floor(chunkBytes));
|
|
1030
|
+
const startWorker = (targetFileName: string | null, stream: WritableFileStreamLike | null): void => {
|
|
1031
|
+
const worker = new Worker(FILE_PROCESSING_WORKER_URL);
|
|
1032
|
+
const record: ActiveFileProcessingRecord = {
|
|
1033
|
+
requestId,
|
|
1034
|
+
session,
|
|
1035
|
+
sourceFileName: sourceFile.name,
|
|
1036
|
+
targetFileName,
|
|
1037
|
+
totalBytes: sourceFile.size,
|
|
1038
|
+
worker,
|
|
1039
|
+
stream,
|
|
1040
|
+
saveToPickedFile,
|
|
1041
|
+
cancelled: false,
|
|
1042
|
+
};
|
|
1043
|
+
activeFileProcessingRequests.set(requestId, record);
|
|
1044
|
+
worker.addEventListener('message', (event: MessageEvent<FileProcessingWorkerOutboundMessage>) => {
|
|
1045
|
+
void handleFileProcessingWorkerMessage(record, event.data);
|
|
1046
|
+
});
|
|
1047
|
+
worker.addEventListener('error', (event: ErrorEvent) => {
|
|
1048
|
+
void failFileProcessingRequest(
|
|
1049
|
+
record,
|
|
1050
|
+
FILE_STATUS_ERROR,
|
|
1051
|
+
event.message.length > 0 ? event.message : 'Worker file processing crashed.',
|
|
1052
|
+
);
|
|
1053
|
+
});
|
|
1054
|
+
const startMessage: FileProcessingWorkerStartMessage = {
|
|
1055
|
+
type: 'start',
|
|
1056
|
+
file: sourceFile,
|
|
1057
|
+
chunkSize: safeChunkBytes,
|
|
1058
|
+
};
|
|
1059
|
+
worker.postMessage(startMessage);
|
|
1060
|
+
};
|
|
1061
|
+
if (!saveToPickedFile) {
|
|
1062
|
+
startWorker(null, null);
|
|
1063
|
+
return;
|
|
1064
|
+
}
|
|
1065
|
+
if (!supportsNativeSavePicker()) {
|
|
1066
|
+
emitFileWorkerProcessError(session, requestId, FILE_STATUS_ERROR, 'Worker file processing requires the native save picker.');
|
|
1067
|
+
return;
|
|
1068
|
+
}
|
|
1069
|
+
const savePicker = (window as SavePickerWindow).showSaveFilePicker;
|
|
1070
|
+
if (typeof savePicker !== 'function') {
|
|
1071
|
+
emitFileWorkerProcessError(session, requestId, FILE_STATUS_ERROR, 'Worker file processing requires the native save picker.');
|
|
1072
|
+
return;
|
|
1073
|
+
}
|
|
1074
|
+
void savePicker({ suggestedName }).then((handle) => handle.createWritable().then((stream) => {
|
|
1075
|
+
if (dependencies.getCurrentSession() !== session || cancelledFileProcessingRequestIds.has(requestId)) {
|
|
1076
|
+
void abortWritableStream(stream).catch(() => {
|
|
1077
|
+
// Ignore cleanup failures after the session moved on.
|
|
1078
|
+
});
|
|
1079
|
+
cancelledFileProcessingRequestIds.delete(requestId);
|
|
1080
|
+
return;
|
|
1081
|
+
}
|
|
1082
|
+
startWorker(handle.name ?? suggestedName, stream);
|
|
1083
|
+
})).catch((error: unknown) => {
|
|
1084
|
+
cancelledFileProcessingRequestIds.delete(requestId);
|
|
1085
|
+
if (dependencies.getCurrentSession() !== session) {
|
|
1086
|
+
return;
|
|
1087
|
+
}
|
|
1088
|
+
emitFileWorkerProcessError(
|
|
1089
|
+
session,
|
|
1090
|
+
requestId,
|
|
1091
|
+
error instanceof DOMException && error.name === 'AbortError' ? FILE_STATUS_CANCELLED : FILE_STATUS_ERROR,
|
|
1092
|
+
dependencies.describeHarnessError(error),
|
|
1093
|
+
);
|
|
1094
|
+
});
|
|
1095
|
+
},
|
|
1096
|
+
fui_file_process_worker_cancel(requestId: number): void {
|
|
1097
|
+
cancelFileProcessingRequest(requestId);
|
|
1098
|
+
},
|
|
1099
|
+
},
|
|
1100
|
+
};
|
|
1101
|
+
}
|