@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,616 @@
|
|
|
1
|
+
// CharnelTransport - P2P transport for Tauri apps
|
|
2
|
+
//
|
|
3
|
+
// uses Tauri IPC commands to make P2P requests via the server's
|
|
4
|
+
// app iroh endpoint. no WASM needed.
|
|
5
|
+
|
|
6
|
+
import type { BlobData, Transport, TransportResponse } from "./transport.js";
|
|
7
|
+
import type {
|
|
8
|
+
CloseReason,
|
|
9
|
+
EventFilter,
|
|
10
|
+
JobEvent,
|
|
11
|
+
JobStateSnapshot,
|
|
12
|
+
} from "./codegen/schema.js";
|
|
13
|
+
import { JobEventsStreamClosed } from "./CharnelLocalTransport.js";
|
|
14
|
+
|
|
15
|
+
// tauri invoke function type
|
|
16
|
+
type InvokeFn = (cmd: string, args?: unknown) => Promise<unknown>;
|
|
17
|
+
|
|
18
|
+
// webkitgtk (linux) requires HTTP/HTTPS URLs for Cache API keys.
|
|
19
|
+
// wrap bare blobIds with a synthetic URL prefix.
|
|
20
|
+
function cacheKey(blobId: string): string {
|
|
21
|
+
return `https://blob.local/${blobId}`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// tauri invoke is dynamically imported to avoid bundling in browser builds
|
|
25
|
+
let invoke: InvokeFn | null = null;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* initialize tauri invoke function
|
|
29
|
+
* call this before using CharnelTransport
|
|
30
|
+
*/
|
|
31
|
+
async function ensureInvoke(): Promise<InvokeFn> {
|
|
32
|
+
if (invoke) return invoke;
|
|
33
|
+
try {
|
|
34
|
+
const tauri = await import("@tauri-apps/api/core");
|
|
35
|
+
invoke = tauri.invoke as InvokeFn;
|
|
36
|
+
return invoke;
|
|
37
|
+
} catch {
|
|
38
|
+
throw new Error("@tauri-apps/api not available - not running in Tauri");
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* check if tauri is available
|
|
44
|
+
*/
|
|
45
|
+
export function isCharnelAvailable(): boolean {
|
|
46
|
+
return typeof window !== "undefined" && "__TAURI__" in window;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// default cache name if none provided
|
|
50
|
+
const DEFAULT_CACHE_NAME = "freqhole-blobs-v1";
|
|
51
|
+
|
|
52
|
+
// in-memory url cache for revocation
|
|
53
|
+
const urlCache = new Map<string, string>();
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* decode base64 string to Uint8Array
|
|
57
|
+
* handles both standard and URL-safe base64 encoding
|
|
58
|
+
*/
|
|
59
|
+
function base64ToBytes(base64: string): Uint8Array {
|
|
60
|
+
// convert URL-safe base64 to standard base64
|
|
61
|
+
let standardBase64 = base64.replace(/-/g, "+").replace(/_/g, "/");
|
|
62
|
+
// add padding if needed
|
|
63
|
+
const padLen = (4 - (standardBase64.length % 4)) % 4;
|
|
64
|
+
standardBase64 += "=".repeat(padLen);
|
|
65
|
+
|
|
66
|
+
const binaryString = atob(standardBase64);
|
|
67
|
+
const bytes = new Uint8Array(binaryString.length);
|
|
68
|
+
for (let i = 0; i < binaryString.length; i++) {
|
|
69
|
+
bytes[i] = binaryString.charCodeAt(i);
|
|
70
|
+
}
|
|
71
|
+
return bytes;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* encode Uint8Array to base64 string
|
|
76
|
+
*/
|
|
77
|
+
function bytesToBase64(bytes: Uint8Array): string {
|
|
78
|
+
let binary = "";
|
|
79
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
80
|
+
binary += String.fromCharCode(bytes[i]);
|
|
81
|
+
}
|
|
82
|
+
return btoa(binary);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* CharnelTransport - P2P transport using Tauri IPC commands
|
|
87
|
+
* implements Transport interface for use with FreqholeClient
|
|
88
|
+
*/
|
|
89
|
+
export class CharnelTransport implements Transport {
|
|
90
|
+
private peerAddr: string;
|
|
91
|
+
private nodeId: string | null = null;
|
|
92
|
+
private readonly cacheName: string;
|
|
93
|
+
|
|
94
|
+
constructor(peerAddr: string, cacheName?: string) {
|
|
95
|
+
this.peerAddr = peerAddr;
|
|
96
|
+
this.cacheName = cacheName ?? DEFAULT_CACHE_NAME;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* initialize transport - must be called before use
|
|
101
|
+
*/
|
|
102
|
+
async init(): Promise<void> {
|
|
103
|
+
const inv = await ensureInvoke();
|
|
104
|
+
|
|
105
|
+
const available = (await inv("p2p_is_available")) as boolean;
|
|
106
|
+
if (!available) {
|
|
107
|
+
throw new Error("P2P not available - federation endpoint not running");
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
this.nodeId = (await inv("p2p_get_node_id")) as string;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* get local node_id (returns null if not yet initialized)
|
|
115
|
+
*/
|
|
116
|
+
getNodeId(): string | null {
|
|
117
|
+
return this.nodeId;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* make an API request via P2P
|
|
122
|
+
*/
|
|
123
|
+
async request(
|
|
124
|
+
method: string,
|
|
125
|
+
path: string,
|
|
126
|
+
body?: string,
|
|
127
|
+
): Promise<TransportResponse> {
|
|
128
|
+
const inv = await ensureInvoke();
|
|
129
|
+
|
|
130
|
+
// intentionally no per-error debug log here: the underlying
|
|
131
|
+
// p2p_proxy_request command already traces failures on the rust
|
|
132
|
+
// side, and callers (auth-status, health-check, etc.) decide
|
|
133
|
+
// whether to surface the error. logging at this layer fires once
|
|
134
|
+
// per request per failed peer and drowns the console for any
|
|
135
|
+
// peer that's temporarily unreachable.
|
|
136
|
+
const result = (await inv("p2p_proxy_request", {
|
|
137
|
+
peerAddr: this.peerAddr,
|
|
138
|
+
method,
|
|
139
|
+
path,
|
|
140
|
+
body: body ?? null,
|
|
141
|
+
})) as { status: number; body: string };
|
|
142
|
+
|
|
143
|
+
return result;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* upload via P2P
|
|
148
|
+
*
|
|
149
|
+
* for music uploads, returns an error directing callers to use uploadByPath()
|
|
150
|
+
* which uses iroh-blobs pull model (file path -> FsStore import -> remote pull).
|
|
151
|
+
* for non-music uploads (images), uses base64 encoding (small enough to be fine).
|
|
152
|
+
*/
|
|
153
|
+
async upload(path: string, formData: FormData): Promise<TransportResponse> {
|
|
154
|
+
// extract file from form data
|
|
155
|
+
const file = formData.get("file") as File | null;
|
|
156
|
+
if (!file) {
|
|
157
|
+
return {
|
|
158
|
+
status: 400,
|
|
159
|
+
body: JSON.stringify({
|
|
160
|
+
success: false,
|
|
161
|
+
message: "no file provided",
|
|
162
|
+
errors: [
|
|
163
|
+
{
|
|
164
|
+
error_type: "bad_request",
|
|
165
|
+
title: "bad request",
|
|
166
|
+
detail: "no file provided",
|
|
167
|
+
},
|
|
168
|
+
],
|
|
169
|
+
}),
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// for music uploads, import bytes into iroh-blobs store and use the
|
|
174
|
+
// blake3 pull model (same as uploadByPath but from in-memory bytes).
|
|
175
|
+
// this supports Android where file picker returns File objects, not paths.
|
|
176
|
+
if (path === "/api/upload/music") {
|
|
177
|
+
return this.uploadMusicViaBytes(file);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// for non-music uploads (images etc), use base64 (small enough)
|
|
181
|
+
return this.uploadViaBase64(path, file, formData);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* upload a file by filesystem path via P2P using iroh-blobs pull model
|
|
186
|
+
*
|
|
187
|
+
* 1. imports file into local FsStore (gets blake3 hash)
|
|
188
|
+
* 2. tells remote peer to pull the blob via iroh-blobs
|
|
189
|
+
* 3. remote peer downloads verified, writes to disk, creates import job
|
|
190
|
+
*/
|
|
191
|
+
async uploadByPath(
|
|
192
|
+
path: string,
|
|
193
|
+
filePath: string,
|
|
194
|
+
metadata?: Record<string, unknown>,
|
|
195
|
+
): Promise<TransportResponse> {
|
|
196
|
+
const inv = await ensureInvoke();
|
|
197
|
+
|
|
198
|
+
// only use iroh-blobs for music uploads
|
|
199
|
+
if (path === "/api/upload/music") {
|
|
200
|
+
console.debug("[P2P] uploadByPath: importing blob from", filePath);
|
|
201
|
+
// import file into local FsStore -> get blake3 hash
|
|
202
|
+
const blake3 = (await inv("p2p_import_blob", { filePath })) as string;
|
|
203
|
+
console.debug("[P2P] uploadByPath: imported blob, blake3 =", blake3);
|
|
204
|
+
|
|
205
|
+
// build request body for the remote peer
|
|
206
|
+
const body: Record<string, unknown> = {
|
|
207
|
+
blake3,
|
|
208
|
+
filename:
|
|
209
|
+
filePath.split("/").pop() || filePath.split("\\").pop() || "music",
|
|
210
|
+
...metadata,
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
// tell the remote peer to pull the blob from us
|
|
214
|
+
return this.request(
|
|
215
|
+
"POST",
|
|
216
|
+
"/api/upload/music-by-blake3",
|
|
217
|
+
JSON.stringify(body),
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// for non-music uploads, send path + metadata via proxy_request
|
|
222
|
+
const body: Record<string, unknown> = {
|
|
223
|
+
file_path: filePath,
|
|
224
|
+
...metadata,
|
|
225
|
+
};
|
|
226
|
+
return this.request("POST", path, JSON.stringify(body));
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* fallback upload via base64 encoding
|
|
231
|
+
* used for non-music uploads (images are small enough)
|
|
232
|
+
*/
|
|
233
|
+
private async uploadViaBase64(
|
|
234
|
+
path: string,
|
|
235
|
+
file: File,
|
|
236
|
+
formData: FormData,
|
|
237
|
+
): Promise<TransportResponse> {
|
|
238
|
+
const bytes = new Uint8Array(await file.arrayBuffer());
|
|
239
|
+
const base64 = bytesToBase64(bytes);
|
|
240
|
+
|
|
241
|
+
const body: Record<string, unknown> = {
|
|
242
|
+
data: base64,
|
|
243
|
+
filename: file.name,
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
// include associate_with if present
|
|
247
|
+
const associateWithStr = formData.get("associate_with") as string | null;
|
|
248
|
+
if (associateWithStr) {
|
|
249
|
+
try {
|
|
250
|
+
body.associate_with = JSON.parse(associateWithStr);
|
|
251
|
+
} catch {
|
|
252
|
+
// ignore parse errors
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// send via proxy_request — routes through offal dispatch on the remote peer
|
|
257
|
+
return this.request("POST", path, JSON.stringify(body));
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* upload music via in-memory bytes using iroh-blobs pull model
|
|
262
|
+
*
|
|
263
|
+
* streams the File to the local blobs store in bounded chunks via
|
|
264
|
+
* p2p_import_begin / p2p_import_chunk / p2p_import_finish, then tells the
|
|
265
|
+
* remote peer to pull via blake3. chunking is required on Android, where
|
|
266
|
+
* tauri IPC is JSON-only and a single large base64 payload OOMs the
|
|
267
|
+
* webview; it also keeps memory bounded on both sides (the receiver
|
|
268
|
+
* accumulates chunks in a temp file on disk, not in memory).
|
|
269
|
+
*/
|
|
270
|
+
private async uploadMusicViaBytes(
|
|
271
|
+
file: File,
|
|
272
|
+
): Promise<TransportResponse> {
|
|
273
|
+
const inv = await ensureInvoke();
|
|
274
|
+
|
|
275
|
+
console.debug("[P2P] uploadMusicViaBytes: streaming file", file.name, file.size, "bytes");
|
|
276
|
+
|
|
277
|
+
// ~4MB raw per chunk -> ~5.5MB base64 per IPC call, well within limits.
|
|
278
|
+
const CHUNK_SIZE = 4 * 1024 * 1024;
|
|
279
|
+
|
|
280
|
+
const uploadId = (await inv("p2p_import_begin")) as string;
|
|
281
|
+
try {
|
|
282
|
+
for (let offset = 0; offset < file.size; offset += CHUNK_SIZE) {
|
|
283
|
+
const slice = file.slice(offset, Math.min(offset + CHUNK_SIZE, file.size));
|
|
284
|
+
const chunkBytes = new Uint8Array(await slice.arrayBuffer());
|
|
285
|
+
const b64 = bytesToBase64(chunkBytes);
|
|
286
|
+
await inv("p2p_import_chunk", { uploadId, data: b64 });
|
|
287
|
+
}
|
|
288
|
+
} catch (err) {
|
|
289
|
+
// best-effort cleanup of the partial temp file on the receiver side.
|
|
290
|
+
try {
|
|
291
|
+
await inv("p2p_import_abort", { uploadId });
|
|
292
|
+
} catch {
|
|
293
|
+
// ignore abort failures
|
|
294
|
+
}
|
|
295
|
+
throw err;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// finalize: import accumulated temp file into the blobs store -> blake3.
|
|
299
|
+
const blake3 = (await inv("p2p_import_finish", { uploadId })) as string;
|
|
300
|
+
console.debug("[P2P] uploadMusicViaBytes: imported blob, blake3 =", blake3);
|
|
301
|
+
|
|
302
|
+
// tell the remote peer to pull the blob from us
|
|
303
|
+
const body = { blake3, filename: file.name };
|
|
304
|
+
return this.request(
|
|
305
|
+
"POST",
|
|
306
|
+
"/api/upload/music-by-blake3",
|
|
307
|
+
JSON.stringify(body),
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* fetch a blob via P2P
|
|
313
|
+
* if blake3 is provided, uses iroh-blobs verified streaming.
|
|
314
|
+
* for blobs without blake3 (images, waveforms, thumbnails), uses proxy_request
|
|
315
|
+
* to fetch base64-encoded data from the peer's /api/blobs/{id}/data endpoint.
|
|
316
|
+
*/
|
|
317
|
+
async fetchBlob(blobId: string, blake3?: string): Promise<BlobData> {
|
|
318
|
+
const inv = await ensureInvoke();
|
|
319
|
+
const tauri = await import("@tauri-apps/api/core");
|
|
320
|
+
const onProgress = new tauri.Channel<{ bytes_downloaded: number }>();
|
|
321
|
+
|
|
322
|
+
if (blake3) {
|
|
323
|
+
// blake3 known — use verified iroh-blobs download
|
|
324
|
+
const result = (await inv("p2p_fetch_blob_verified", {
|
|
325
|
+
peerAddr: this.peerAddr,
|
|
326
|
+
blake3Hash: blake3,
|
|
327
|
+
onProgress,
|
|
328
|
+
})) as { data: string; content_type: string | null; size: number };
|
|
329
|
+
|
|
330
|
+
const bytes = base64ToBytes(result.data);
|
|
331
|
+
return {
|
|
332
|
+
data: bytes,
|
|
333
|
+
contentType: result.content_type ?? "audio/mpeg",
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// no blake3 — try proxy_request to get blob data from database
|
|
338
|
+
// this is the primary path for images (waveforms, thumbnails) stored in the database
|
|
339
|
+
try {
|
|
340
|
+
const result = await this.request("GET", `/api/blobs/${blobId}/data`);
|
|
341
|
+
if (result.status === 200) {
|
|
342
|
+
const parsed = JSON.parse(result.body);
|
|
343
|
+
if (parsed.success && parsed.data?.data) {
|
|
344
|
+
const bytes = base64ToBytes(parsed.data.data);
|
|
345
|
+
const contentType = parsed.data.mime || "application/octet-stream";
|
|
346
|
+
return { data: bytes, contentType };
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
} catch (e) {
|
|
350
|
+
const errorMessage = e instanceof Error ? e.message : String(e);
|
|
351
|
+
console.warn(
|
|
352
|
+
`[CharnelTransport] proxy blob data request failed, falling back to verified download: ${errorMessage}`,
|
|
353
|
+
);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// fallback: ask the peer to compute blake3, then do verified download
|
|
357
|
+
const result = (await inv("p2p_fetch_blob_verified_by_id", {
|
|
358
|
+
peerAddr: this.peerAddr,
|
|
359
|
+
blobId,
|
|
360
|
+
onProgress,
|
|
361
|
+
})) as {
|
|
362
|
+
data: string;
|
|
363
|
+
content_type: string | null;
|
|
364
|
+
size: number;
|
|
365
|
+
blake3: string;
|
|
366
|
+
};
|
|
367
|
+
|
|
368
|
+
const bytes = base64ToBytes(result.data);
|
|
369
|
+
return {
|
|
370
|
+
data: bytes,
|
|
371
|
+
contentType: result.content_type ?? "application/octet-stream",
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* get a URL for a blob - caches in Cache API
|
|
377
|
+
* if blake3 provided, uses verified iroh-blobs download
|
|
378
|
+
*/
|
|
379
|
+
async getBlobUrl(blobId: string, blake3?: string): Promise<string> {
|
|
380
|
+
// check in-memory cache first
|
|
381
|
+
const cached = urlCache.get(blobId);
|
|
382
|
+
if (cached) {
|
|
383
|
+
return cached;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// check Cache API (use HTTP URL key for webkitgtk compatibility)
|
|
387
|
+
const cache = await caches.open(this.cacheName);
|
|
388
|
+
const cachedResponse = await cache.match(cacheKey(blobId));
|
|
389
|
+
|
|
390
|
+
if (cachedResponse) {
|
|
391
|
+
const blob = await cachedResponse.blob();
|
|
392
|
+
const url = URL.createObjectURL(blob);
|
|
393
|
+
urlCache.set(blobId, url);
|
|
394
|
+
return url;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// fetch via P2P and cache (pass blake3 for verified download)
|
|
398
|
+
const blobData = await this.fetchBlob(blobId, blake3);
|
|
399
|
+
const blob = new Blob([blobData.data.slice().buffer], {
|
|
400
|
+
type: blobData.contentType,
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
// store in Cache API (HTTP URL key for webkitgtk compatibility)
|
|
404
|
+
const response = new Response(blob, {
|
|
405
|
+
headers: { "Content-Type": blobData.contentType },
|
|
406
|
+
});
|
|
407
|
+
await cache.put(cacheKey(blobId), response);
|
|
408
|
+
|
|
409
|
+
// create object URL
|
|
410
|
+
const url = URL.createObjectURL(blob);
|
|
411
|
+
urlCache.set(blobId, url);
|
|
412
|
+
return url;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* revoke a blob URL
|
|
417
|
+
*/
|
|
418
|
+
revokeBlobUrl(blobId: string): void {
|
|
419
|
+
const url = urlCache.get(blobId);
|
|
420
|
+
if (url) {
|
|
421
|
+
URL.revokeObjectURL(url);
|
|
422
|
+
urlCache.delete(blobId);
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* clear all blob URLs
|
|
428
|
+
*/
|
|
429
|
+
clearBlobUrls(): void {
|
|
430
|
+
for (const url of urlCache.values()) {
|
|
431
|
+
URL.revokeObjectURL(url);
|
|
432
|
+
}
|
|
433
|
+
urlCache.clear();
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* fetch server image (public, no auth required)
|
|
438
|
+
* used during "add remote" flow before user is authenticated
|
|
439
|
+
*/
|
|
440
|
+
async fetchHelloImage(): Promise<BlobData | null> {
|
|
441
|
+
const inv = await ensureInvoke();
|
|
442
|
+
|
|
443
|
+
console.debug("[P2P] fetchHelloImage: requesting from peer", this.peerAddr);
|
|
444
|
+
try {
|
|
445
|
+
const result = (await inv("p2p_fetch_hello_image", {
|
|
446
|
+
peerAddr: this.peerAddr,
|
|
447
|
+
})) as { data: string; content_type: string | null };
|
|
448
|
+
|
|
449
|
+
// decode base64 data
|
|
450
|
+
const bytes = base64ToBytes(result.data);
|
|
451
|
+
|
|
452
|
+
return {
|
|
453
|
+
data: bytes,
|
|
454
|
+
contentType: result.content_type ?? "image/png",
|
|
455
|
+
};
|
|
456
|
+
} catch (e) {
|
|
457
|
+
console.error("fetchHelloImage failed:", e);
|
|
458
|
+
return null;
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// -----------------------------------------------------------------
|
|
463
|
+
// job events (remote path via freqhole-events/1 ALPN)
|
|
464
|
+
//
|
|
465
|
+
// routes through the `jobs_events_snapshot` / `jobs_events_subscribe`
|
|
466
|
+
// tauri commands, passing `targetPeer` so charnel dials the remote
|
|
467
|
+
// peer instead of the in-process broker.
|
|
468
|
+
// -----------------------------------------------------------------
|
|
469
|
+
|
|
470
|
+
async snapshotJobEvents(filter?: EventFilter): Promise<JobStateSnapshot[]> {
|
|
471
|
+
const inv = await ensureInvoke();
|
|
472
|
+
const out = (await inv("jobs_events_snapshot", {
|
|
473
|
+
filter: filter ?? null,
|
|
474
|
+
targetPeer: this.peerAddr,
|
|
475
|
+
})) as JobStateSnapshot[];
|
|
476
|
+
return out;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
subscribeJobEvents(
|
|
480
|
+
filter?: EventFilter,
|
|
481
|
+
signal?: AbortSignal,
|
|
482
|
+
): AsyncIterable<JobEvent> {
|
|
483
|
+
return charnelRemoteJobEventsIterable(this.peerAddr, filter, signal);
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// frame shape emitted by the rust-side `JobsEventsFrame` — mirrors
|
|
488
|
+
// `CharnelLocalTransport.ts`.
|
|
489
|
+
type JobsEventsFrame =
|
|
490
|
+
| { kind: "event"; evt: JobEvent }
|
|
491
|
+
| { kind: "closed"; reason: CloseReason };
|
|
492
|
+
|
|
493
|
+
async function* charnelRemoteJobEventsIterable(
|
|
494
|
+
peerAddr: string,
|
|
495
|
+
filter: EventFilter | undefined,
|
|
496
|
+
signal: AbortSignal | undefined,
|
|
497
|
+
): AsyncGenerator<JobEvent, void, void> {
|
|
498
|
+
const { invoke: inv, Channel } = await import("@tauri-apps/api/core");
|
|
499
|
+
|
|
500
|
+
const queue: JobsEventsFrame[] = [];
|
|
501
|
+
let wake: (() => void) | null = null;
|
|
502
|
+
let closed = false;
|
|
503
|
+
const waitForFrame = () =>
|
|
504
|
+
new Promise<void>((resolve) => {
|
|
505
|
+
wake = () => {
|
|
506
|
+
wake = null;
|
|
507
|
+
resolve();
|
|
508
|
+
};
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
const channel = new Channel<JobsEventsFrame>();
|
|
512
|
+
channel.onmessage = (frame: JobsEventsFrame) => {
|
|
513
|
+
queue.push(frame);
|
|
514
|
+
if (frame.kind === "closed") closed = true;
|
|
515
|
+
wake?.();
|
|
516
|
+
};
|
|
517
|
+
|
|
518
|
+
let sessionId: string;
|
|
519
|
+
try {
|
|
520
|
+
sessionId = (await inv("jobs_events_subscribe", {
|
|
521
|
+
filter: filter ?? null,
|
|
522
|
+
events: channel,
|
|
523
|
+
targetPeer: peerAddr,
|
|
524
|
+
})) as string;
|
|
525
|
+
} catch (err) {
|
|
526
|
+
throw err instanceof Error ? err : new Error(String(err));
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
const onAbort = () => {
|
|
530
|
+
closed = true;
|
|
531
|
+
inv("jobs_events_unsubscribe", { sessionId }).catch(() => {});
|
|
532
|
+
wake?.();
|
|
533
|
+
};
|
|
534
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
535
|
+
|
|
536
|
+
try {
|
|
537
|
+
while (true) {
|
|
538
|
+
if (queue.length === 0) {
|
|
539
|
+
if (closed) return;
|
|
540
|
+
if (signal?.aborted) return;
|
|
541
|
+
await waitForFrame();
|
|
542
|
+
continue;
|
|
543
|
+
}
|
|
544
|
+
const frame = queue.shift()!;
|
|
545
|
+
if (frame.kind === "closed") {
|
|
546
|
+
const reasonKind = (frame.reason as { kind: string }).kind ?? "internal";
|
|
547
|
+
if (reasonKind === "client_unsubscribed") return;
|
|
548
|
+
throw new JobEventsStreamClosed(frame.reason);
|
|
549
|
+
}
|
|
550
|
+
yield frame.evt;
|
|
551
|
+
}
|
|
552
|
+
} finally {
|
|
553
|
+
signal?.removeEventListener("abort", onAbort);
|
|
554
|
+
if (!closed) {
|
|
555
|
+
try {
|
|
556
|
+
await inv("jobs_events_unsubscribe", { sessionId });
|
|
557
|
+
} catch {
|
|
558
|
+
// session may have already been torn down; nothing to do.
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
// transport cache - reuse instances per peer
|
|
565
|
+
const transportCache = new Map<string, CharnelTransport>();
|
|
566
|
+
|
|
567
|
+
/**
|
|
568
|
+
* get or create a CharnelTransport for a peer (async)
|
|
569
|
+
* initializes transport before returning
|
|
570
|
+
*/
|
|
571
|
+
export async function createCharnelTransport(
|
|
572
|
+
peerAddr: string,
|
|
573
|
+
cacheName?: string,
|
|
574
|
+
): Promise<CharnelTransport> {
|
|
575
|
+
// include cacheName in cache key so different remotes get different transports
|
|
576
|
+
const cacheKey = cacheName ? `${peerAddr}:${cacheName}` : peerAddr;
|
|
577
|
+
const existing = transportCache.get(cacheKey);
|
|
578
|
+
if (existing) {
|
|
579
|
+
return existing;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
const transport = new CharnelTransport(peerAddr, cacheName);
|
|
583
|
+
await transport.init();
|
|
584
|
+
transportCache.set(cacheKey, transport);
|
|
585
|
+
return transport;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
/**
|
|
589
|
+
* get or create a CharnelTransport (alias for createCharnelTransport)
|
|
590
|
+
*/
|
|
591
|
+
export async function getCharnelTransport(
|
|
592
|
+
peerAddr: string,
|
|
593
|
+
cacheName?: string,
|
|
594
|
+
): Promise<CharnelTransport> {
|
|
595
|
+
return createCharnelTransport(peerAddr, cacheName);
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
/**
|
|
599
|
+
* get local node_id from tauri
|
|
600
|
+
*/
|
|
601
|
+
export async function getCharnelNodeId(): Promise<string> {
|
|
602
|
+
const inv = await ensureInvoke();
|
|
603
|
+
return (await inv("p2p_get_node_id")) as string;
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
/**
|
|
607
|
+
* check if P2P is available in tauri
|
|
608
|
+
*/
|
|
609
|
+
export async function isCharnelP2PAvailable(): Promise<boolean> {
|
|
610
|
+
try {
|
|
611
|
+
const inv = await ensureInvoke();
|
|
612
|
+
return (await inv("p2p_is_available")) as boolean;
|
|
613
|
+
} catch {
|
|
614
|
+
return false;
|
|
615
|
+
}
|
|
616
|
+
}
|