@freqhole/api-client 0.1.30
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/auth-test.html +651 -0
- package/package.json +42 -0
- package/src/AdminClient.ts +154 -0
- package/src/CharnelLocalTransport.ts +598 -0
- package/src/CharnelTransport.ts +616 -0
- package/src/FreqholeClient.ts +261 -0
- package/src/WasmTransport.ts +1077 -0
- package/src/automerge/IrohNetworkAdapter.ts +727 -0
- package/src/automerge/index.ts +14 -0
- package/src/codegen/admin_commands.ts +61 -0
- package/src/codegen/routes.ts +213 -0
- package/src/codegen/schema.ts +6721 -0
- package/src/domains/admin.ts +97 -0
- package/src/domains/app.ts +98 -0
- package/src/domains/auth.ts +191 -0
- package/src/domains/favorites.types.ts +52 -0
- package/src/domains/jobs.ts +48 -0
- package/src/domains/music.ts +1935 -0
- package/src/domains/types.ts +19 -0
- package/src/domains/upload.ts +214 -0
- package/src/index.ts +48 -0
- package/src/permissions.ts +468 -0
- package/src/playlistz/convert.ts +267 -0
- package/src/playlistz/index.ts +6 -0
- package/src/playlistz/mutations.ts +181 -0
- package/src/playlistz/protocol.ts +222 -0
- package/src/playlistz/schema.ts +138 -0
- package/src/playlistz/shareLink.ts +76 -0
- package/src/storage/blobs.ts +272 -0
- package/src/storage/identity.ts +190 -0
- package/src/storage/index.ts +5 -0
- package/src/storage/webLocks.ts +92 -0
- package/src/test/automerge.ts +796 -0
- package/src/test/coverage.ts +105 -0
- package/src/test/fixtures.ts +460 -0
- package/src/test/integration.ts +320 -0
- package/src/test/playlistz.ts +631 -0
- package/src/test/stateful.ts +561 -0
- package/src/test/storage.ts +443 -0
- package/src/test.ts +75 -0
- package/src/transport.ts +391 -0
- package/src/utils.ts +79 -0
- package/src/webauthn.ts +228 -0
- package/tsconfig.json +22 -0
- package/vite.config.js +9 -0
|
@@ -0,0 +1,598 @@
|
|
|
1
|
+
// CharnelLocalTransport - local grimoire dispatch via Tauri IPC
|
|
2
|
+
//
|
|
3
|
+
// bypasses HTTP entirely for local server access in Tauri apps.
|
|
4
|
+
// calls grimoire::api::dispatch directly via the api_call IPC command.
|
|
5
|
+
// uses Tauri's asset protocol for blob/audio file access (no HTTP streaming).
|
|
6
|
+
|
|
7
|
+
import type { Transport, TransportResponse, BlobData } from "./transport.js";
|
|
8
|
+
import type {
|
|
9
|
+
CloseReason,
|
|
10
|
+
EventFilter,
|
|
11
|
+
JobEvent,
|
|
12
|
+
JobStateSnapshot,
|
|
13
|
+
} from "./codegen/schema.js";
|
|
14
|
+
|
|
15
|
+
// tauri invoke function type
|
|
16
|
+
type InvokeFn = (cmd: string, args?: unknown) => Promise<unknown>;
|
|
17
|
+
|
|
18
|
+
// tauri invoke is dynamically imported to avoid bundling in browser builds
|
|
19
|
+
let invoke: InvokeFn | null = null;
|
|
20
|
+
let convertFileSrc: ((path: string) => string) | null = null;
|
|
21
|
+
|
|
22
|
+
// webkitgtk (linux) can't play asset:// URLs in <audio> elements.
|
|
23
|
+
// detect once at module level so we can use blob: URLs as a workaround.
|
|
24
|
+
//
|
|
25
|
+
// historically there was a second workaround here — an embedded http
|
|
26
|
+
// loopback server (`media_server_info` ipc + `server::media_server`)
|
|
27
|
+
// that served blobs via plain http. it has been removed in favor of
|
|
28
|
+
// the rodio backend (see `client/spume/src/music/services/audio/`),
|
|
29
|
+
// which bypasses the html `<audio>` element entirely on linux. when
|
|
30
|
+
// rodio is *not* enabled and we're on linux, we fall back to the
|
|
31
|
+
// blob: object url path below.
|
|
32
|
+
const isLinuxWebKit = typeof navigator !== "undefined" && navigator.userAgent.includes("Linux");
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* initialize tauri invoke function
|
|
36
|
+
*/
|
|
37
|
+
async function ensureInvoke(): Promise<InvokeFn> {
|
|
38
|
+
if (invoke) return invoke;
|
|
39
|
+
try {
|
|
40
|
+
const tauri = await import("@tauri-apps/api/core");
|
|
41
|
+
invoke = tauri.invoke as InvokeFn;
|
|
42
|
+
// also grab convertFileSrc for blob URLs
|
|
43
|
+
convertFileSrc = tauri.convertFileSrc;
|
|
44
|
+
return invoke;
|
|
45
|
+
} catch {
|
|
46
|
+
throw new Error("@tauri-apps/api not available - not running in Tauri");
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* response shape from api_call command (matches GrimoireResponse)
|
|
52
|
+
*/
|
|
53
|
+
interface ApiResponse {
|
|
54
|
+
success: boolean;
|
|
55
|
+
message: string;
|
|
56
|
+
data?: unknown;
|
|
57
|
+
errors?: Array<{ error_type: string; title: string; detail: string }>;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* CharnelLocalTransport - local dispatch via Tauri IPC
|
|
62
|
+
*
|
|
63
|
+
* implements Transport interface for use with FreqholeClient.
|
|
64
|
+
* routes requests through grimoire::api::dispatch via the api_call command.
|
|
65
|
+
* uses Tauri's asset protocol for blob/audio file access.
|
|
66
|
+
*/
|
|
67
|
+
export class CharnelLocalTransport implements Transport {
|
|
68
|
+
// cache blob paths to avoid repeated IPC calls
|
|
69
|
+
private blobPathCache = new Map<string, { path: string; mime?: string }>();
|
|
70
|
+
// cache object URLs for db-stored blobs (no local path)
|
|
71
|
+
private blobObjectUrlCache = new Map<string, string>();
|
|
72
|
+
// single audio blob URL for linux workaround (revoke-on-replace to avoid memory leak)
|
|
73
|
+
private audioBlobUrl: { blobId: string; url: string } | null = null;
|
|
74
|
+
|
|
75
|
+
constructor(_baseUrl: string) {
|
|
76
|
+
// baseUrl no longer needed - all requests go through IPC.
|
|
77
|
+
// (used to also kick off an embedded http loopback server probe
|
|
78
|
+
// here; that server has been removed in favor of the rodio backend.)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* make an API request via dispatch
|
|
83
|
+
*/
|
|
84
|
+
async request(_method: string, path: string, body?: string): Promise<TransportResponse> {
|
|
85
|
+
const inv = await ensureInvoke();
|
|
86
|
+
|
|
87
|
+
// parse body string to JSON value (or null for no body)
|
|
88
|
+
let jsonBody: unknown = null;
|
|
89
|
+
if (body) {
|
|
90
|
+
try {
|
|
91
|
+
jsonBody = JSON.parse(body);
|
|
92
|
+
} catch {
|
|
93
|
+
jsonBody = {};
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
try {
|
|
98
|
+
// call dispatch via IPC
|
|
99
|
+
const response = (await inv("api_call", {
|
|
100
|
+
path,
|
|
101
|
+
body: jsonBody,
|
|
102
|
+
})) as ApiResponse;
|
|
103
|
+
|
|
104
|
+
// check for route_not_found - this is now a real error
|
|
105
|
+
const errorType = response.errors?.[0]?.error_type;
|
|
106
|
+
if (!response.success && errorType === "route_not_found") {
|
|
107
|
+
console.error(`[CharnelLocalTransport] route not found: ${path}`);
|
|
108
|
+
return {
|
|
109
|
+
status: 404,
|
|
110
|
+
body: JSON.stringify(response),
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// return the full GrimoireResponse - same format as HTTP
|
|
115
|
+
// FreqholeClient expects { data: ... } wrapper and extracts .data
|
|
116
|
+
if (response.success) {
|
|
117
|
+
return {
|
|
118
|
+
status: 200,
|
|
119
|
+
body: JSON.stringify(response),
|
|
120
|
+
};
|
|
121
|
+
} else {
|
|
122
|
+
// map error to HTTP-like status
|
|
123
|
+
const status = errorTypeToStatus(errorType ?? "internal_error");
|
|
124
|
+
return {
|
|
125
|
+
status,
|
|
126
|
+
body: JSON.stringify(response),
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
} catch (err) {
|
|
130
|
+
// IPC error - treat as network error
|
|
131
|
+
return {
|
|
132
|
+
status: 0,
|
|
133
|
+
body: JSON.stringify({
|
|
134
|
+
error: err instanceof Error ? err.message : "IPC error",
|
|
135
|
+
}),
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* upload - converts FormData to base64 JSON and calls dispatch
|
|
142
|
+
*
|
|
143
|
+
* for tauri-local, we use wait_for_completion to block until the job
|
|
144
|
+
* finishes, avoiding the need for polling from the client side.
|
|
145
|
+
*/
|
|
146
|
+
async upload(path: string, formData: FormData): Promise<TransportResponse> {
|
|
147
|
+
// extract file and other fields from FormData
|
|
148
|
+
const file = formData.get("file") as File | null;
|
|
149
|
+
if (!file) {
|
|
150
|
+
return {
|
|
151
|
+
status: 400,
|
|
152
|
+
body: JSON.stringify({
|
|
153
|
+
success: false,
|
|
154
|
+
message: "no file provided",
|
|
155
|
+
errors: [{ error_type: "bad_request", title: "bad request", detail: "no file provided" }],
|
|
156
|
+
}),
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// read file as base64
|
|
161
|
+
const arrayBuffer = await file.arrayBuffer();
|
|
162
|
+
const base64 = btoa(
|
|
163
|
+
new Uint8Array(arrayBuffer).reduce((data, byte) => data + String.fromCharCode(byte), "")
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
// build JSON body for dispatch
|
|
167
|
+
const body: Record<string, unknown> = {
|
|
168
|
+
data: base64,
|
|
169
|
+
filename: file.name,
|
|
170
|
+
// tauri-local optimization: wait for job to complete instead of returning job_id
|
|
171
|
+
// this eliminates the need for client-side polling
|
|
172
|
+
wait_for_completion: true,
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
// include associate_with if present
|
|
176
|
+
const associationStr = formData.get("associate_with");
|
|
177
|
+
if (associationStr && typeof associationStr === "string") {
|
|
178
|
+
try {
|
|
179
|
+
body.associate_with = JSON.parse(associationStr);
|
|
180
|
+
} catch {
|
|
181
|
+
// ignore parse errors
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// call through normal request path
|
|
186
|
+
return this.request("POST", path, JSON.stringify(body));
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* upload by filesystem path - skips base64 encoding
|
|
191
|
+
* uses tauri dialog plugin to get paths, passes directly to backend
|
|
192
|
+
*/
|
|
193
|
+
async uploadByPath(
|
|
194
|
+
path: string,
|
|
195
|
+
filePath: string,
|
|
196
|
+
metadata?: Record<string, unknown>,
|
|
197
|
+
): Promise<TransportResponse> {
|
|
198
|
+
const body: Record<string, unknown> = {
|
|
199
|
+
file_path: filePath,
|
|
200
|
+
wait_for_completion: true,
|
|
201
|
+
...metadata,
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
return this.request("POST", path, JSON.stringify(body));
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* fetch blob - get file path via IPC, convert to asset URL, fetch via browser
|
|
209
|
+
* falls back to /api/blobs/{id}/data for db-stored blobs without local paths
|
|
210
|
+
*
|
|
211
|
+
* `blobId` MUST be a `media_blobz.id` short pk (7-16 hex chars).
|
|
212
|
+
* sha256 / blake3 hashes are NOT valid here - they will hit the
|
|
213
|
+
* `id = ?` lookup, miss, and return "blob not found". if the
|
|
214
|
+
* caller only has a sha256, they need to resolve it to a
|
|
215
|
+
* media_blob_id first (e.g. via the song record) before calling.
|
|
216
|
+
*/
|
|
217
|
+
async fetchBlob(blobId: string, _blake3?: string): Promise<BlobData> {
|
|
218
|
+
await ensureInvoke();
|
|
219
|
+
|
|
220
|
+
// check if we have a cached object URL (db-stored blob)
|
|
221
|
+
const cachedObjectUrl = this.blobObjectUrlCache.get(blobId);
|
|
222
|
+
if (cachedObjectUrl) {
|
|
223
|
+
const fetchResponse = await fetch(cachedObjectUrl);
|
|
224
|
+
const arrayBuffer = await fetchResponse.arrayBuffer();
|
|
225
|
+
return {
|
|
226
|
+
data: new Uint8Array(arrayBuffer),
|
|
227
|
+
contentType: fetchResponse.headers.get("content-type") ?? "application/octet-stream",
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// try to get path from cache or IPC
|
|
232
|
+
let pathInfo = this.blobPathCache.get(blobId);
|
|
233
|
+
if (!pathInfo) {
|
|
234
|
+
const response = await this.request("GET", `/api/blobs/${blobId}/path`, undefined);
|
|
235
|
+
if (response.status === 200) {
|
|
236
|
+
const parsed = JSON.parse(response.body);
|
|
237
|
+
if (parsed.data?.path) {
|
|
238
|
+
pathInfo = { path: parsed.data.path, mime: parsed.data.mime };
|
|
239
|
+
this.blobPathCache.set(blobId, pathInfo);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// check for no_local_path error - fall back to /data endpoint
|
|
244
|
+
if (!pathInfo) {
|
|
245
|
+
const parsed = JSON.parse(response.body);
|
|
246
|
+
const isNoLocalPath = parsed.errors?.some((e: { error_type: string }) => e.error_type === "no_local_path");
|
|
247
|
+
if (isNoLocalPath || response.status !== 200) {
|
|
248
|
+
return this.fetchBlobData(blobId);
|
|
249
|
+
}
|
|
250
|
+
throw new Error(`failed to get blob path: ${response.body}`);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
if (!convertFileSrc) {
|
|
255
|
+
throw new Error("convertFileSrc not available");
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// convert to asset URL and fetch via browser
|
|
259
|
+
const assetUrl = convertFileSrc(pathInfo.path);
|
|
260
|
+
const fetchResponse = await fetch(assetUrl);
|
|
261
|
+
const arrayBuffer = await fetchResponse.arrayBuffer();
|
|
262
|
+
|
|
263
|
+
return {
|
|
264
|
+
data: new Uint8Array(arrayBuffer),
|
|
265
|
+
contentType: pathInfo.mime ?? "application/octet-stream",
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* fetch blob data via /api/blobs/{id}/data - for db-stored blobs
|
|
271
|
+
* returns base64-decoded data and creates/caches object URL
|
|
272
|
+
*/
|
|
273
|
+
private async fetchBlobData(blobId: string): Promise<BlobData> {
|
|
274
|
+
const response = await this.request("GET", `/api/blobs/${blobId}/data`, undefined);
|
|
275
|
+
if (response.status !== 200) {
|
|
276
|
+
throw new Error(`failed to get blob data: ${response.body}`);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const parsed = JSON.parse(response.body);
|
|
280
|
+
if (!parsed.data?.data) {
|
|
281
|
+
throw new Error(`blob data not found: ${blobId}`);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// decode base64 to Uint8Array
|
|
285
|
+
const binaryString = atob(parsed.data.data);
|
|
286
|
+
const bytes = new Uint8Array(binaryString.length);
|
|
287
|
+
for (let i = 0; i < binaryString.length; i++) {
|
|
288
|
+
bytes[i] = binaryString.charCodeAt(i);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const mime = parsed.data.mime ?? "application/octet-stream";
|
|
292
|
+
|
|
293
|
+
// create and cache object URL for future use
|
|
294
|
+
const blob = new Blob([bytes], { type: mime });
|
|
295
|
+
const objectUrl = URL.createObjectURL(blob);
|
|
296
|
+
this.blobObjectUrlCache.set(blobId, objectUrl);
|
|
297
|
+
|
|
298
|
+
return {
|
|
299
|
+
data: bytes,
|
|
300
|
+
contentType: mime,
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* get blob URL — preference order:
|
|
306
|
+
* 1. cached object URL (db-stored blobs)
|
|
307
|
+
* 2. tauri asset:// (via `convertFileSrc`) on macos/windows
|
|
308
|
+
* 3. linux fallback: fetch via asset:// and wrap in a blob: object URL
|
|
309
|
+
* (webkitgtk can't stream asset:// into `<audio>`)
|
|
310
|
+
*
|
|
311
|
+
* note: when the rodio audio backend is enabled (charnel + opt-in)
|
|
312
|
+
* playback bypasses html `<audio>` entirely and reads files via
|
|
313
|
+
* filesystem path — this method is unused for that path.
|
|
314
|
+
*/
|
|
315
|
+
getBlobUrl(blobId: string, _blake3?: string): string | Promise<string> {
|
|
316
|
+
// check object URL cache first (db-stored blobs)
|
|
317
|
+
const cachedObjectUrl = this.blobObjectUrlCache.get(blobId);
|
|
318
|
+
if (cachedObjectUrl) {
|
|
319
|
+
console.debug(`[CharnelLocalTransport] blob ${blobId}: object-url cache`);
|
|
320
|
+
return cachedObjectUrl;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// on linux without rodio, we MUST go async to wrap in a blob:
|
|
324
|
+
// url (asset:// can't stream into <audio> on webkitgtk).
|
|
325
|
+
if (isLinuxWebKit) {
|
|
326
|
+
console.debug(`[CharnelLocalTransport] blob ${blobId}: linux fallback (async)`);
|
|
327
|
+
return this.getBlobUrlAsync(blobId);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// check path cache (filesystem blobs) — direct asset:// url
|
|
331
|
+
const cached = this.blobPathCache.get(blobId);
|
|
332
|
+
if (cached && convertFileSrc) {
|
|
333
|
+
const url = convertFileSrc(cached.path);
|
|
334
|
+
console.debug(`[CharnelLocalTransport] blob ${blobId}: asset:// (cached) -> ${url}`);
|
|
335
|
+
return url;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// need to fetch path (or data) first
|
|
339
|
+
// console.debug(`[CharnelLocalTransport] blob ${blobId}: async path lookup`);
|
|
340
|
+
return this.getBlobUrlAsync(blobId);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* async version of getBlobUrl for uncached blobs
|
|
345
|
+
* tries path first, falls back to /data for db-stored blobs
|
|
346
|
+
*/
|
|
347
|
+
private async getBlobUrlAsync(blobId: string): Promise<string> {
|
|
348
|
+
await ensureInvoke(); // ensure convertFileSrc is loaded
|
|
349
|
+
|
|
350
|
+
const response = await this.request("GET", `/api/blobs/${blobId}/path`, undefined);
|
|
351
|
+
|
|
352
|
+
if (response.status === 200) {
|
|
353
|
+
const parsed = JSON.parse(response.body);
|
|
354
|
+
if (parsed.data?.path) {
|
|
355
|
+
// cache path for future use
|
|
356
|
+
this.blobPathCache.set(blobId, { path: parsed.data.path, mime: parsed.data.mime });
|
|
357
|
+
|
|
358
|
+
if (!convertFileSrc) {
|
|
359
|
+
throw new Error("convertFileSrc not available");
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// on linux without media server: fall back to blob: workaround.
|
|
363
|
+
// routes audio + image differently:
|
|
364
|
+
// - audio: single-slot cache (revoke-on-replace) since audio
|
|
365
|
+
// blobs are large and we only play one at a time
|
|
366
|
+
// - other (images / waveforms / cover art): per-blob cache so
|
|
367
|
+
// multiple `<img>` and css `background-image` urls coexist
|
|
368
|
+
// across the playerbar, queue sidebar, etc.
|
|
369
|
+
if (isLinuxWebKit) {
|
|
370
|
+
return this.createBlobObjectUrl(blobId, parsed.data.path, parsed.data.mime);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
return convertFileSrc(parsed.data.path);
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// check for no_local_path - fall back to /data endpoint
|
|
378
|
+
const parsed = JSON.parse(response.body);
|
|
379
|
+
const isNoLocalPath = parsed.errors?.some((e: { error_type: string }) => e.error_type === "no_local_path");
|
|
380
|
+
|
|
381
|
+
if (isNoLocalPath) {
|
|
382
|
+
// fetch blob data and create object URL
|
|
383
|
+
await this.fetchBlobData(blobId);
|
|
384
|
+
const objectUrl = this.blobObjectUrlCache.get(blobId);
|
|
385
|
+
if (objectUrl) {
|
|
386
|
+
return objectUrl;
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
throw new Error(`failed to get blob path: ${response.body}`);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* create a blob: object URL by fetching via asset:// protocol.
|
|
395
|
+
* used on linux where webkitgtk can't play asset:// in `<audio>`
|
|
396
|
+
* elements (and historically also where the embedded http loopback
|
|
397
|
+
* server stood in for the same workaround).
|
|
398
|
+
*
|
|
399
|
+
* mime-aware caching:
|
|
400
|
+
* - `audio/*`: single-slot cache, revoke-on-replace. audio blobs
|
|
401
|
+
* are large (often tens of MB) and only one ever plays at a
|
|
402
|
+
* time, so leaking the rest is wasteful.
|
|
403
|
+
* - everything else (images, waveforms, cover art): stored in
|
|
404
|
+
* `blobObjectUrlCache` keyed by blob id so multiple `<img>` /
|
|
405
|
+
* css `background-image` references coexist without one
|
|
406
|
+
* revoking another.
|
|
407
|
+
*/
|
|
408
|
+
private async createBlobObjectUrl(blobId: string, localPath: string, mime?: string): Promise<string> {
|
|
409
|
+
if (!convertFileSrc) {
|
|
410
|
+
throw new Error("convertFileSrc not available");
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
const isAudio = (mime ?? "").startsWith("audio/");
|
|
414
|
+
const effectiveMime = mime ?? (isAudio ? "audio/mpeg" : "application/octet-stream");
|
|
415
|
+
|
|
416
|
+
const assetUrl = convertFileSrc(localPath);
|
|
417
|
+
const resp = await fetch(assetUrl);
|
|
418
|
+
const arrayBuffer = await resp.arrayBuffer();
|
|
419
|
+
const blob = new Blob([arrayBuffer], { type: effectiveMime });
|
|
420
|
+
const objectUrl = URL.createObjectURL(blob);
|
|
421
|
+
|
|
422
|
+
if (isAudio) {
|
|
423
|
+
// revoke previous single-slot audio url (if any) so we don't
|
|
424
|
+
// leak large buffers as the user moves between tracks.
|
|
425
|
+
if (this.audioBlobUrl) {
|
|
426
|
+
URL.revokeObjectURL(this.audioBlobUrl.url);
|
|
427
|
+
}
|
|
428
|
+
this.audioBlobUrl = { blobId, url: objectUrl };
|
|
429
|
+
} else {
|
|
430
|
+
// per-blob cache so e.g. the playerbar's waveform and the queue
|
|
431
|
+
// sidebar's matching waveform share a single object url.
|
|
432
|
+
this.blobObjectUrlCache.set(blobId, objectUrl);
|
|
433
|
+
}
|
|
434
|
+
return objectUrl;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// -----------------------------------------------------------------
|
|
438
|
+
// job events (local ipc shortcut)
|
|
439
|
+
//
|
|
440
|
+
// skips the iroh `freqhole-events/1` hop entirely when charnel is
|
|
441
|
+
// talking to the in-process grimoire server. uses the
|
|
442
|
+
// `jobs_events_snapshot` / `jobs_events_subscribe` /
|
|
443
|
+
// `jobs_events_unsubscribe` tauri commands.
|
|
444
|
+
//
|
|
445
|
+
// remote-targeting (open EVENTS_ALPN bistream against a peer) is
|
|
446
|
+
// deferred — when it lands, charnel will pick local-vs-remote based
|
|
447
|
+
// on the "currently-targeted remote peer" config.
|
|
448
|
+
// -----------------------------------------------------------------
|
|
449
|
+
|
|
450
|
+
async snapshotJobEvents(filter?: EventFilter): Promise<JobStateSnapshot[]> {
|
|
451
|
+
const inv = await ensureInvoke();
|
|
452
|
+
const out = (await inv("jobs_events_snapshot", {
|
|
453
|
+
filter: filter ?? null,
|
|
454
|
+
})) as JobStateSnapshot[];
|
|
455
|
+
return out;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
subscribeJobEvents(
|
|
459
|
+
filter?: EventFilter,
|
|
460
|
+
signal?: AbortSignal,
|
|
461
|
+
): AsyncIterable<JobEvent> {
|
|
462
|
+
return charnelJobEventsIterable(filter, signal);
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/**
|
|
467
|
+
* map error_type to HTTP status code
|
|
468
|
+
*/
|
|
469
|
+
function errorTypeToStatus(errorType: string): number {
|
|
470
|
+
switch (errorType) {
|
|
471
|
+
case "unauthorized":
|
|
472
|
+
return 401;
|
|
473
|
+
case "forbidden":
|
|
474
|
+
return 403;
|
|
475
|
+
case "not_found":
|
|
476
|
+
case "route_not_found":
|
|
477
|
+
return 404;
|
|
478
|
+
case "bad_request":
|
|
479
|
+
return 400;
|
|
480
|
+
default:
|
|
481
|
+
return 500;
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/**
|
|
486
|
+
* create a CharnelLocalTransport instance
|
|
487
|
+
* @param baseUrl - ignored (kept for API compatibility)
|
|
488
|
+
*/
|
|
489
|
+
export function createCharnelLocalTransport(baseUrl: string): CharnelLocalTransport {
|
|
490
|
+
return new CharnelLocalTransport(baseUrl);
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// =====================================================================
|
|
494
|
+
// job events iterator (tauri channel -> async iterable adapter)
|
|
495
|
+
// =====================================================================
|
|
496
|
+
|
|
497
|
+
/**
|
|
498
|
+
* frame shape emitted by the rust-side `JobsEventsFrame`. mirrors
|
|
499
|
+
* `EventsServerMsg` from `events_protocol.rs` but flattened — no
|
|
500
|
+
* per-bistream correlation id (per-channel on tauri makes it unneeded).
|
|
501
|
+
*/
|
|
502
|
+
type JobsEventsFrame =
|
|
503
|
+
| { kind: "event"; evt: JobEvent }
|
|
504
|
+
| { kind: "closed"; reason: CloseReason };
|
|
505
|
+
|
|
506
|
+
async function* charnelJobEventsIterable(
|
|
507
|
+
filter: EventFilter | undefined,
|
|
508
|
+
signal: AbortSignal | undefined,
|
|
509
|
+
): AsyncGenerator<JobEvent, void, void> {
|
|
510
|
+
const inv = await ensureInvoke();
|
|
511
|
+
// tauri `Channel` is constructed dynamically — same `@tauri-apps/api/core`
|
|
512
|
+
// module that exports `invoke`. dynamic import here mirrors the pattern
|
|
513
|
+
// above (the transport may also run in a non-tauri browser context where
|
|
514
|
+
// the module is absent).
|
|
515
|
+
const { Channel } = await import("@tauri-apps/api/core");
|
|
516
|
+
|
|
517
|
+
// bounded queue of frames the rust side has pushed but js hasn't
|
|
518
|
+
// consumed yet. wakers signal new arrivals.
|
|
519
|
+
const queue: JobsEventsFrame[] = [];
|
|
520
|
+
let wake: (() => void) | null = null;
|
|
521
|
+
let closed = false;
|
|
522
|
+
const waitForFrame = () =>
|
|
523
|
+
new Promise<void>((resolve) => {
|
|
524
|
+
wake = () => {
|
|
525
|
+
wake = null;
|
|
526
|
+
resolve();
|
|
527
|
+
};
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
const channel = new Channel<JobsEventsFrame>();
|
|
531
|
+
channel.onmessage = (frame: JobsEventsFrame) => {
|
|
532
|
+
queue.push(frame);
|
|
533
|
+
if (frame.kind === "closed") closed = true;
|
|
534
|
+
wake?.();
|
|
535
|
+
};
|
|
536
|
+
|
|
537
|
+
let sessionId: string;
|
|
538
|
+
try {
|
|
539
|
+
sessionId = (await inv("jobs_events_subscribe", {
|
|
540
|
+
filter: filter ?? null,
|
|
541
|
+
events: channel,
|
|
542
|
+
})) as string;
|
|
543
|
+
} catch (err) {
|
|
544
|
+
throw err instanceof Error ? err : new Error(String(err));
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
const onAbort = () => {
|
|
548
|
+
closed = true;
|
|
549
|
+
inv("jobs_events_unsubscribe", { sessionId }).catch(() => {});
|
|
550
|
+
wake?.();
|
|
551
|
+
};
|
|
552
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
553
|
+
|
|
554
|
+
try {
|
|
555
|
+
while (true) {
|
|
556
|
+
if (queue.length === 0) {
|
|
557
|
+
if (closed) return;
|
|
558
|
+
if (signal?.aborted) return;
|
|
559
|
+
await waitForFrame();
|
|
560
|
+
continue;
|
|
561
|
+
}
|
|
562
|
+
const frame = queue.shift()!;
|
|
563
|
+
if (frame.kind === "closed") {
|
|
564
|
+
// surface the close reason as a typed error so consumers can
|
|
565
|
+
// distinguish `Lagged` (re-snapshot + reconnect) from other
|
|
566
|
+
// terminal conditions.
|
|
567
|
+
const reasonKind =
|
|
568
|
+
(frame.reason as { kind: string }).kind ?? "internal";
|
|
569
|
+
if (reasonKind === "client_unsubscribed") return;
|
|
570
|
+
throw new JobEventsStreamClosed(frame.reason);
|
|
571
|
+
}
|
|
572
|
+
yield frame.evt;
|
|
573
|
+
}
|
|
574
|
+
} finally {
|
|
575
|
+
signal?.removeEventListener("abort", onAbort);
|
|
576
|
+
if (!closed) {
|
|
577
|
+
try {
|
|
578
|
+
await inv("jobs_events_unsubscribe", { sessionId });
|
|
579
|
+
} catch {
|
|
580
|
+
// session may have already been torn down server-side; nothing
|
|
581
|
+
// to do.
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
/**
|
|
588
|
+
* thrown by the subscribe iterator on any non-`client_unsubscribed`
|
|
589
|
+
* `CloseReason`. exported so consumers can `instanceof`-check before
|
|
590
|
+
* deciding to reconnect.
|
|
591
|
+
*/
|
|
592
|
+
export class JobEventsStreamClosed extends Error {
|
|
593
|
+
constructor(public readonly reason: CloseReason) {
|
|
594
|
+
const kind = (reason as { kind: string }).kind ?? "internal";
|
|
595
|
+
super(`job events stream closed: ${kind}`);
|
|
596
|
+
this.name = "JobEventsStreamClosed";
|
|
597
|
+
}
|
|
598
|
+
}
|