@k2b/cloud 0.28.0 → 0.29.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/package.json +7 -5
- package/src/_internal/cli-plugins.ts +18 -0
- package/src/_internal/define-app.ts +21 -5
- package/src/_internal/registry-validation.ts +30 -1
- package/src/_internal/runtime-context.ts +1 -0
- package/src/access/PermissionEditor.tsx +11 -4
- package/src/ai/assistant-models.ts +25 -4
- package/src/ai/audio-tool.ts +3 -10
- package/src/ai/capabilities.ts +35 -9
- package/src/ai/chat/blocks.tsx +97 -17
- package/src/ai/chat/builtin-tools.tsx +3 -1
- package/src/ai/chat/composer-adapter.ts +8 -0
- package/src/ai/chat/live-turn.browser-harness.tsx +12 -3
- package/src/ai/chat/message-actions.tsx +1 -1
- package/src/ai/chat/message-utils.ts +28 -6
- package/src/ai/chat/messages.ts +16 -0
- package/src/ai/chat/presentation.tsx +1 -8
- package/src/ai/chat/tool-names.ts +157 -0
- package/src/ai/chat/turn-layout.ts +12 -0
- package/src/ai/chat/turn-view.tsx +15 -7
- package/src/ai/chat-tasks.ts +16 -3
- package/src/ai/code-mode-skill.ts +4 -4
- package/src/ai/compaction.ts +22 -3
- package/src/ai/default-tools.ts +9 -2
- package/src/ai/executor.ts +127 -40
- package/src/ai/file-media-type.ts +4 -0
- package/src/ai/files-store.ts +17 -10
- package/src/ai/http.ts +11 -10
- package/src/ai/index.ts +1 -0
- package/src/ai/memories.ts +2 -0
- package/src/ai/memory-learning.ts +59 -5
- package/src/ai/memory-workflow-evidence.ts +35 -10
- package/src/ai/projects-routes.ts +5 -1
- package/src/ai/projects.ts +5 -1
- package/src/ai/protocol.ts +34 -1
- package/src/ai/provider.ts +16 -1
- package/src/ai/quota-provider.ts +4 -1
- package/src/ai/routes.ts +20 -10
- package/src/ai/settings.ts +54 -17
- package/src/ai/skill-seeds.ts +11 -3
- package/src/ai/store.ts +56 -20
- package/src/ai/structured.ts +2 -1
- package/src/ai/system-prompt.ts +5 -1
- package/src/ai/transcription.ts +58 -13
- package/src/ai/turn-failure.ts +5 -1
- package/src/ai/turn-guidance.ts +505 -0
- package/src/ai/types.ts +1 -0
- package/src/ai/ui.tsx +1 -0
- package/src/api/admin-core-settings.ts +16 -1
- package/src/api/me.ts +42 -1
- package/src/browser/FileChooser.tsx +71 -295
- package/src/browser/FileSaver.tsx +407 -0
- package/src/browser/app-badges.ts +16 -0
- package/src/browser/choose-files.browser-harness.tsx +22 -6
- package/src/browser/choose-files.tsx +23 -36
- package/src/browser/file-chooser-messages.ts +64 -0
- package/src/browser/file-providers.ts +258 -8
- package/src/browser/files.tsx +26 -3
- package/src/browser/notifications.ts +37 -1
- package/src/browser/provider-folder.tsx +301 -0
- package/src/browser/provider-list.ts +39 -0
- package/src/browser/save-files.browser-harness.tsx +35 -0
- package/src/browser/save-files.tsx +107 -0
- package/src/contracts/app.ts +10 -0
- package/src/contracts/commands.ts +3 -1
- package/src/contracts/notification-group.ts +6 -0
- package/src/contracts/notification-types.ts +6 -0
- package/src/contracts/registry.ts +1 -0
- package/src/contracts/user-notifications.ts +74 -0
- package/src/server/middleware/request-logger.ts +11 -2
- package/src/services/accounts/avatar.ts +5 -2
- package/src/services/image-metadata.ts +438 -0
- package/src/services/index.ts +1 -0
- package/src/services/logging/redaction.ts +50 -0
- package/src/services/mandates/index.ts +4 -2
- package/src/services/mandates/policy.ts +20 -0
- package/src/services/notifications/browser.ts +48 -15
- package/src/services/notifications/dispatcher.ts +31 -1
- package/src/services/notifications/platform.ts +32 -4
- package/src/services/notifications/preview.ts +23 -0
- package/src/services/notifications/quiet.ts +153 -0
- package/src/services/notifications/user.ts +6 -0
- package/src/services/pdf/template-preview.ts +2 -1
- package/src/services/settings/app.ts +3 -0
- package/src/services/settings/store.ts +4 -1
- package/src/services/weather/geo.ts +6 -2
- package/src/shared/ai-platform-prompt.ts +2 -2
- package/src/shared/redirect.ts +2 -1
- package/src/shared/template-rendering.ts +120 -7
- package/src/ssr/AppBadge.tsx +30 -0
- package/src/ssr/AppLaunchpadPanel.tsx +47 -26
- package/src/ssr/IslandError.tsx +33 -0
- package/src/ssr/Layout.tsx +3 -0
- package/src/ssr/LayoutHelp.tsx +4 -2
- package/src/ssr/RailApps.island.tsx +9 -2
- package/src/ssr/app-badges.ts +93 -0
- package/src/ssr/rail-messages.ts +4 -0
- package/src/ssr/rail-navigation.ts +2 -0
- package/src/styles/effects.css +23 -0
- package/src/styles/file-chooser.css +25 -0
- package/src/styles/utilities-navigation.css +41 -0
|
@@ -1,12 +1,17 @@
|
|
|
1
1
|
import { checkMimeType } from "@k2b/stdlib/browser";
|
|
2
2
|
import { invokeCapabilityWithDataSchema, listCapabilityCatalog } from "../capabilities/client";
|
|
3
|
+
import { readCapabilityResponse } from "../capabilities/response";
|
|
3
4
|
import { transferCapabilityStream } from "../capabilities/streams";
|
|
4
5
|
import type { CapabilityCatalogApp, CapabilityClientError, CapabilityHttpOptions } from "../capabilities/types";
|
|
5
|
-
import { CapabilityErrorSchema } from "../contracts/capabilities";
|
|
6
|
+
import { CapabilityErrorSchema, type CapabilityStream, capabilityResultSchema } from "../contracts/capabilities";
|
|
7
|
+
import { CapabilityStreamStatusSchema } from "../contracts/capability-streams";
|
|
6
8
|
import {
|
|
7
9
|
type FileProviderEntry,
|
|
10
|
+
type FileProviderIssue,
|
|
8
11
|
FileProviderListDataSchema,
|
|
9
12
|
FileProviderReadDataSchema,
|
|
13
|
+
FileProviderSaveDataSchema,
|
|
14
|
+
FileProviderSaveInputSchema,
|
|
10
15
|
fileProviderIssues,
|
|
11
16
|
} from "../contracts/file-provider";
|
|
12
17
|
import { LOCALE_HEADER } from "../shared/locale";
|
|
@@ -20,6 +25,8 @@ export type FileProviderSource = {
|
|
|
20
25
|
read: string;
|
|
21
26
|
/** The provider's read limit; a consumer combines it with its own. */
|
|
22
27
|
maxBytes: number;
|
|
28
|
+
/** The provider's `save` Action and its write limit, when it stores new files. */
|
|
29
|
+
save?: { id: string; maxBytes: number };
|
|
23
30
|
};
|
|
24
31
|
|
|
25
32
|
export type FileProviderCaller = Pick<CapabilityHttpOptions, "fetch" | "baseUrl"> & { locale: string };
|
|
@@ -55,11 +62,11 @@ const failure = (error: CapabilityClientError): FileProviderError =>
|
|
|
55
62
|
new FileProviderError(error.code, error.message, typeof navigator !== "undefined" && navigator.onLine === false ? 0 : error.status);
|
|
56
63
|
|
|
57
64
|
/** One unreadable manifest leaves out that application, not the whole list. */
|
|
58
|
-
const
|
|
65
|
+
const issuesOf = (app: CapabilityCatalogApp): FileProviderIssue[] | null => {
|
|
59
66
|
try {
|
|
60
|
-
return
|
|
67
|
+
return fileProviderIssues(app.manifest);
|
|
61
68
|
} catch {
|
|
62
|
-
return
|
|
69
|
+
return null;
|
|
63
70
|
}
|
|
64
71
|
};
|
|
65
72
|
|
|
@@ -71,9 +78,12 @@ export const fileProviderSources = (apps: readonly CapabilityCatalogApp[], local
|
|
|
71
78
|
apps
|
|
72
79
|
.flatMap((app) => {
|
|
73
80
|
const declaration = app.manifest.fileProvider;
|
|
74
|
-
|
|
81
|
+
const issues = declaration ? issuesOf(app) : null;
|
|
82
|
+
if (!declaration || !issues || issues.some((issue) => issue.function !== "save")) return [];
|
|
75
83
|
const read = app.manifest.queries.find((query) => query.localId === declaration.read);
|
|
76
84
|
if (!read?.stream) return [];
|
|
85
|
+
// A `save` that does not fit leaves a provider to choose from, not one to save into.
|
|
86
|
+
const save = issues.length === 0 ? app.manifest.actions.find((action) => action.localId === declaration.save) : undefined;
|
|
77
87
|
return [
|
|
78
88
|
{
|
|
79
89
|
appId: app.appId,
|
|
@@ -82,12 +92,16 @@ export const fileProviderSources = (apps: readonly CapabilityCatalogApp[], local
|
|
|
82
92
|
list: declaration.list,
|
|
83
93
|
read: declaration.read,
|
|
84
94
|
maxBytes: read.stream.maxBytes,
|
|
95
|
+
...(save?.stream ? { save: { id: save.localId, maxBytes: save.stream.maxBytes } } : {}),
|
|
85
96
|
},
|
|
86
97
|
];
|
|
87
98
|
})
|
|
88
99
|
.sort((a, b) => a.name.localeCompare(b.name, locale));
|
|
89
100
|
|
|
90
|
-
/**
|
|
101
|
+
/**
|
|
102
|
+
* Reads every catalog page; the catalog is the only discovery, there is no provider route. A refusal keeps its status
|
|
103
|
+
* (401 or 403), so the page's file choosing can tell a visitor without providers from a failed load.
|
|
104
|
+
*/
|
|
91
105
|
export const loadFileProviders = async (caller: FileProviderCaller, signal?: AbortSignal): Promise<FileProviderSource[]> => {
|
|
92
106
|
const apps: CapabilityCatalogApp[] = [];
|
|
93
107
|
let cursor: string | undefined;
|
|
@@ -102,7 +116,7 @@ export const loadFileProviders = async (caller: FileProviderCaller, signal?: Abo
|
|
|
102
116
|
return fileProviderSources(apps, caller.locale);
|
|
103
117
|
};
|
|
104
118
|
|
|
105
|
-
export type FileProviderPage = { items: FileProviderEntry[]; next: string | null };
|
|
119
|
+
export type FileProviderPage = { items: FileProviderEntry[]; next: string | null; writable: boolean };
|
|
106
120
|
|
|
107
121
|
/** One folder page. Without `parent` it is the provider's root. */
|
|
108
122
|
export const listProviderFolder = async (
|
|
@@ -128,7 +142,7 @@ export const listProviderFolder = async (
|
|
|
128
142
|
options(caller),
|
|
129
143
|
);
|
|
130
144
|
if (!result.ok) throw failure(result.error);
|
|
131
|
-
return { items: result.data.data.items, next: result.data.data.next };
|
|
145
|
+
return { items: result.data.data.items, next: result.data.data.next, writable: result.data.data.writable };
|
|
132
146
|
};
|
|
133
147
|
|
|
134
148
|
export type EntryProblem = "type" | "size";
|
|
@@ -215,3 +229,239 @@ export const eachLimited = async <T>(items: readonly T[], limit: number, work: (
|
|
|
215
229
|
};
|
|
216
230
|
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, run));
|
|
217
231
|
};
|
|
232
|
+
|
|
233
|
+
/** What a consumer offers to save: the bytes, or a same-origin URL the page reads them from with its session. */
|
|
234
|
+
export type SaveFileSource = {
|
|
235
|
+
/** The file name to save under. Path separators and control characters become `_`. */
|
|
236
|
+
name: string;
|
|
237
|
+
/** The bytes, or a same-origin URL such as the app's own download route. */
|
|
238
|
+
content: Blob | string;
|
|
239
|
+
/** Defaults to the Blob's or the response's type, then `application/octet-stream`. */
|
|
240
|
+
mediaType?: string;
|
|
241
|
+
/** The size when it is known before reading, so a file above the provider's limit is refused before it downloads. */
|
|
242
|
+
size?: number;
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
/** A file the provider created, with the provider's `open` link when it returned one. */
|
|
246
|
+
export type SavedProviderFile = { id: string; name: string; size: number; href?: string };
|
|
247
|
+
|
|
248
|
+
const nameSchema = FileProviderSaveInputSchema.shape.name;
|
|
249
|
+
/** Whether a provider accepts this as a file name: one path segment without control characters, not `.` or `..`. */
|
|
250
|
+
export const isSaveableName = (name: string): boolean => nameSchema.safeParse(name).success;
|
|
251
|
+
|
|
252
|
+
/** A name every provider accepts: separators and control characters become `_`, and an empty name becomes `file`. */
|
|
253
|
+
export const saveableName = (name: string): string => {
|
|
254
|
+
const cleaned = name
|
|
255
|
+
.replace(/[/\\\u0000-\u001f\u007f]/g, "_")
|
|
256
|
+
.trim()
|
|
257
|
+
.slice(0, 255);
|
|
258
|
+
return isSaveableName(cleaned) ? cleaned : "file";
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
/** The name a conflict suggests next: `Report.pdf` becomes `Report (2).pdf`, and `Report (2).pdf` becomes `Report (3).pdf`. */
|
|
262
|
+
export const nextFreeName = (name: string): string => {
|
|
263
|
+
const dot = name.lastIndexOf(".");
|
|
264
|
+
const [stem, extension] = dot > 0 ? [name.slice(0, dot), name.slice(dot)] : [name, ""];
|
|
265
|
+
const numbered = /^(.*) \((\d{1,9})\)$/.exec(stem);
|
|
266
|
+
const [base, count] = numbered ? [numbered[1]!, Number(numbered[2]) + 1] : [stem, 2];
|
|
267
|
+
const suffix = ` (${count})${extension}`;
|
|
268
|
+
return `${base.slice(0, Math.max(1, 255 - suffix.length))}${suffix}`;
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
/** The contract takes a printable media type without parameters; anything else is sent as `application/octet-stream`. */
|
|
272
|
+
const saveMediaType = (value: string | null | undefined): string => {
|
|
273
|
+
const type = value?.split(";", 1)[0]?.trim().toLowerCase();
|
|
274
|
+
return type && /^[\x21-\x7e]{1,255}$/.test(type) ? type : "application/octet-stream";
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
const tooLarge = () => new FileProviderError("FILE_TOO_LARGE", "The file is larger than allowed", 413);
|
|
278
|
+
const offline = () => typeof navigator !== "undefined" && navigator.onLine === false;
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Reads what a consumer offers into a Blob, never more than `maxBytes`: a known `size` or `content-length` above it
|
|
282
|
+
* fails before any byte moves, and a body that grows past it is cancelled. Aborting the signal cancels the read.
|
|
283
|
+
*/
|
|
284
|
+
export const readSaveSource = async (
|
|
285
|
+
source: SaveFileSource,
|
|
286
|
+
options: Pick<CapabilityHttpOptions, "fetch"> & {
|
|
287
|
+
maxBytes: number;
|
|
288
|
+
signal?: AbortSignal;
|
|
289
|
+
onProgress?: (loaded: number, total: number) => void;
|
|
290
|
+
},
|
|
291
|
+
): Promise<Blob> => {
|
|
292
|
+
if (source.content instanceof Blob) {
|
|
293
|
+
if (source.content.size > options.maxBytes) throw tooLarge();
|
|
294
|
+
const type = saveMediaType(source.mediaType ?? source.content.type);
|
|
295
|
+
return source.content.type === type ? source.content : new Blob([source.content], { type });
|
|
296
|
+
}
|
|
297
|
+
if (source.size !== undefined && source.size > options.maxBytes) throw tooLarge();
|
|
298
|
+
let response: Response;
|
|
299
|
+
try {
|
|
300
|
+
response = await (options.fetch ?? fetch)(source.content, { credentials: "same-origin", signal: options.signal });
|
|
301
|
+
} catch (error) {
|
|
302
|
+
if (options.signal?.aborted) throw error;
|
|
303
|
+
throw new FileProviderError("SOURCE_UNAVAILABLE", "The file could not be read", offline() ? 0 : 503);
|
|
304
|
+
}
|
|
305
|
+
if (!response.ok || !response.body) {
|
|
306
|
+
await response.body?.cancel().catch(() => undefined);
|
|
307
|
+
throw new FileProviderError("SOURCE_UNAVAILABLE", `The file could not be read (status ${response.status})`, response.status);
|
|
308
|
+
}
|
|
309
|
+
const announced = Number(response.headers.get("content-length") ?? Number.NaN);
|
|
310
|
+
if (announced > options.maxBytes) {
|
|
311
|
+
await response.body.cancel().catch(() => undefined);
|
|
312
|
+
throw tooLarge();
|
|
313
|
+
}
|
|
314
|
+
const total = Number.isFinite(announced) && announced > 0 ? announced : (source.size ?? 0);
|
|
315
|
+
const reader = response.body.getReader();
|
|
316
|
+
const chunks: Uint8Array<ArrayBuffer>[] = [];
|
|
317
|
+
let loaded = 0;
|
|
318
|
+
options.onProgress?.(0, total);
|
|
319
|
+
try {
|
|
320
|
+
for (;;) {
|
|
321
|
+
const { done, value } = await reader.read();
|
|
322
|
+
options.signal?.throwIfAborted();
|
|
323
|
+
if (done) break;
|
|
324
|
+
loaded += value.byteLength;
|
|
325
|
+
if (loaded > options.maxBytes) throw tooLarge();
|
|
326
|
+
chunks.push(value);
|
|
327
|
+
options.onProgress?.(loaded, Math.max(total, loaded));
|
|
328
|
+
}
|
|
329
|
+
} catch (error) {
|
|
330
|
+
await reader.cancel().catch(() => undefined);
|
|
331
|
+
if (error instanceof FileProviderError || options.signal?.aborted) throw error;
|
|
332
|
+
throw new FileProviderError("SOURCE_UNAVAILABLE", "The file could not be read", offline() ? 0 : 503);
|
|
333
|
+
}
|
|
334
|
+
return new Blob(chunks, { type: saveMediaType(source.mediaType ?? response.headers.get("content-type")) });
|
|
335
|
+
};
|
|
336
|
+
|
|
337
|
+
const SaveResultSchema = capabilityResultSchema(FileProviderSaveDataSchema, { consumer: true });
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Sends a write stream's body. Browsers report upload progress only for XMLHttpRequest, so the page uses it; a caller
|
|
341
|
+
* with its own `fetch`, such as a test, sends through that transport instead.
|
|
342
|
+
*/
|
|
343
|
+
const sendWriteStream = (
|
|
344
|
+
stream: CapabilityStream,
|
|
345
|
+
body: Blob,
|
|
346
|
+
caller: FileProviderCaller,
|
|
347
|
+
signal: AbortSignal | undefined,
|
|
348
|
+
onProgress: (loaded: number) => void,
|
|
349
|
+
): Promise<Response> => {
|
|
350
|
+
if (caller.fetch || typeof XMLHttpRequest === "undefined")
|
|
351
|
+
return transferCapabilityStream(stream, "write", { ...options(caller), body, signal });
|
|
352
|
+
return new Promise((resolve, reject) => {
|
|
353
|
+
const request = new XMLHttpRequest();
|
|
354
|
+
const abort = () => request.abort();
|
|
355
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
356
|
+
const settle = () => signal?.removeEventListener("abort", abort);
|
|
357
|
+
request.open("POST", `${(caller.baseUrl ?? "/api").replace(/\/$/, "")}/capabilities/v1/streams/write`);
|
|
358
|
+
request.setRequestHeader("x-cloud-stream-id", stream.id);
|
|
359
|
+
request.setRequestHeader("content-type", "application/octet-stream");
|
|
360
|
+
request.setRequestHeader(LOCALE_HEADER, caller.locale);
|
|
361
|
+
request.upload.onprogress = (event) => onProgress(event.loaded);
|
|
362
|
+
request.onload = () => {
|
|
363
|
+
settle();
|
|
364
|
+
if (request.status < 200 || request.status > 599) return reject(new TypeError("The write was interrupted"));
|
|
365
|
+
resolve(new Response(request.responseText, { status: request.status, headers: { "content-type": "application/json" } }));
|
|
366
|
+
};
|
|
367
|
+
request.onerror = () => {
|
|
368
|
+
settle();
|
|
369
|
+
reject(new TypeError("The write was interrupted"));
|
|
370
|
+
};
|
|
371
|
+
request.onabort = () => {
|
|
372
|
+
settle();
|
|
373
|
+
reject(signal?.reason ?? new DOMException("The write was cancelled", "AbortError"));
|
|
374
|
+
};
|
|
375
|
+
request.send(body);
|
|
376
|
+
});
|
|
377
|
+
};
|
|
378
|
+
|
|
379
|
+
/** A completed write's receipt: the provider's file and its `open` link. */
|
|
380
|
+
const receipt = (data: unknown, name: string, size: number): SavedProviderFile => {
|
|
381
|
+
const parsed = SaveResultSchema.safeParse(data);
|
|
382
|
+
if (!parsed.success) throw new FileProviderError("INVALID_APP_RESPONSE", "The provider returned an invalid receipt", 502);
|
|
383
|
+
const file = parsed.data.data.file;
|
|
384
|
+
const href = parsed.data.links?.find((link) => link.rel === "open")?.href;
|
|
385
|
+
return { id: file?.id ?? "", name: file?.name ?? name, size: file?.size ?? size, ...(href ? { href } : {}) };
|
|
386
|
+
};
|
|
387
|
+
|
|
388
|
+
/** Core and gateways answer a write that broke off with these; whether the provider committed it is unknown. */
|
|
389
|
+
const INTERRUPTED_WRITE = [499, 502, 504];
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Creates one file in a provider folder: the provider's `save` Action opens a write stream, the bytes follow, and the
|
|
393
|
+
* receipt names the file. A taken name fails with `FILE_PROVIDER_NAME_CONFLICT`, from the Action or from the write.
|
|
394
|
+
* `idempotencyKey` belongs to this name and folder: retrying with it never creates a second file. `onStream` reports
|
|
395
|
+
* that the Action opened a stream, so bytes may follow under this key. A write whose answer was lost, or that broke
|
|
396
|
+
* off with 499, 502, or 504, is looked up through the stream's status before it fails; a cancelled one is aborted.
|
|
397
|
+
*/
|
|
398
|
+
export const saveProviderFile = async (
|
|
399
|
+
provider: FileProviderSource,
|
|
400
|
+
input: { parent: string; name: string; body: Blob; idempotencyKey: string },
|
|
401
|
+
caller: FileProviderCaller & {
|
|
402
|
+
signal?: AbortSignal;
|
|
403
|
+
onStream?: () => void;
|
|
404
|
+
onProgress?: (loaded: number, total: number) => void;
|
|
405
|
+
},
|
|
406
|
+
): Promise<SavedProviderFile> => {
|
|
407
|
+
if (!provider.save) throw new FileProviderError("NOT_FOUND", "This app does not store files", 404);
|
|
408
|
+
if (input.body.size > provider.save.maxBytes) throw tooLarge();
|
|
409
|
+
const opened = await invokeCapabilityWithDataSchema(
|
|
410
|
+
{
|
|
411
|
+
appId: provider.appId,
|
|
412
|
+
capabilityId: provider.save.id,
|
|
413
|
+
kind: "action",
|
|
414
|
+
input: { parent: input.parent, name: input.name, mediaType: saveMediaType(input.body.type), size: input.body.size },
|
|
415
|
+
idempotencyKey: input.idempotencyKey,
|
|
416
|
+
signal: caller.signal,
|
|
417
|
+
},
|
|
418
|
+
FileProviderSaveDataSchema,
|
|
419
|
+
options(caller),
|
|
420
|
+
);
|
|
421
|
+
if (!opened.ok) throw failure(opened.error);
|
|
422
|
+
const stream = opened.data.stream;
|
|
423
|
+
if (stream?.direction !== "write" || stream.size !== input.body.size)
|
|
424
|
+
throw new FileProviderError("INVALID_APP_RESPONSE", "The provider returned no matching write stream", 502);
|
|
425
|
+
caller.onStream?.();
|
|
426
|
+
const control = (verb: "status" | "abort") =>
|
|
427
|
+
transferCapabilityStream(stream, verb, { ...options(caller), signal: verb === "status" ? caller.signal : undefined });
|
|
428
|
+
const abort = () => void control("abort").catch(() => undefined);
|
|
429
|
+
caller.onProgress?.(0, stream.size);
|
|
430
|
+
let response: Response | undefined;
|
|
431
|
+
try {
|
|
432
|
+
response = await sendWriteStream(stream, input.body, caller, caller.signal, (loaded) => caller.onProgress?.(loaded, stream.size));
|
|
433
|
+
} catch (error) {
|
|
434
|
+
if (caller.signal?.aborted) {
|
|
435
|
+
abort();
|
|
436
|
+
throw error;
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
let lost = new FileProviderError("APP_UNAVAILABLE", "The file could not be saved", offline() ? 0 : 503);
|
|
440
|
+
if (response) {
|
|
441
|
+
const answer = await readCapabilityResponse(response, SaveResultSchema);
|
|
442
|
+
if (answer.ok) {
|
|
443
|
+
caller.onProgress?.(stream.size, stream.size);
|
|
444
|
+
return receipt(answer.data, input.name, input.body.size);
|
|
445
|
+
}
|
|
446
|
+
if (!INTERRUPTED_WRITE.includes(answer.error.status)) throw failure(answer.error);
|
|
447
|
+
lost = failure(answer.error);
|
|
448
|
+
}
|
|
449
|
+
// The bytes may have arrived and only the answer was lost; the stream's status knows.
|
|
450
|
+
const status = await control("status").then(
|
|
451
|
+
async (answer) => {
|
|
452
|
+
const body: unknown = await answer.json().catch(() => null);
|
|
453
|
+
return { ok: answer.ok, status: answer.status, body };
|
|
454
|
+
},
|
|
455
|
+
() => null,
|
|
456
|
+
);
|
|
457
|
+
const state = status?.ok ? CapabilityStreamStatusSchema.safeParse(status.body) : null;
|
|
458
|
+
if (state?.success && state.data.state === "completed") return receipt(state.data.result, input.name, input.body.size);
|
|
459
|
+
if (caller.signal?.aborted) {
|
|
460
|
+
abort();
|
|
461
|
+
throw caller.signal.reason;
|
|
462
|
+
}
|
|
463
|
+
// The status's own refusal, such as a taken name or a lost permission, says more than the interrupted write.
|
|
464
|
+
const refused = status && !status.ok ? CapabilityErrorSchema.safeParse(status.body) : null;
|
|
465
|
+
if (status && refused?.success) throw failure({ ...refused.data, status: status.status });
|
|
466
|
+
throw lost;
|
|
467
|
+
};
|
package/src/browser/files.tsx
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
import { createFileChoosing } from "./choose-files";
|
|
2
2
|
import { loadFileProviders } from "./file-providers";
|
|
3
|
+
import { createProviderList } from "./provider-list";
|
|
4
|
+
import { createFileSaving } from "./save-files";
|
|
3
5
|
|
|
4
6
|
export type { ChooseFilesOptions } from "./FileChooser";
|
|
7
|
+
export { SAVE_FILES_ICON, type SavedFile, type SaveFileSource, type SaveFilesOptions } from "./save-files";
|
|
5
8
|
|
|
6
9
|
const pageLocale = () => (typeof document === "undefined" ? "en" : document.documentElement.lang || "en");
|
|
7
|
-
const
|
|
10
|
+
const providers = createProviderList(() => loadFileProviders({ locale: pageLocale() }));
|
|
11
|
+
const saving = createFileSaving(providers);
|
|
8
12
|
|
|
9
13
|
/**
|
|
10
14
|
* Lets the user choose files from this device or from any Cloud application that offers files.
|
|
@@ -14,10 +18,29 @@ const choosing = createFileChoosing(() => loadFileProviders({ locale: pageLocale
|
|
|
14
18
|
* through its stream, at most two at a time, and never above `min(maxBytes, provider limit)`. Hand the result
|
|
15
19
|
* to your existing upload path: it resolves the chosen `File`s, or `[]` when the user cancels.
|
|
16
20
|
*/
|
|
17
|
-
export const chooseFiles =
|
|
21
|
+
export const chooseFiles = createFileChoosing(providers);
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Lets the user save copies of files into any Cloud application that stores files, such as Files.
|
|
25
|
+
*
|
|
26
|
+
* Pass each file's name and its bytes as a `Blob`, or a same-origin URL such as your download route; the dialog
|
|
27
|
+
* reads a URL only after the person chose a folder. The person picks the app and a folder, then each file is
|
|
28
|
+
* created there through the app's `save`, at most two at a time and never above the app's limit. Saving never
|
|
29
|
+
* replaces a file: a taken name asks for another. It resolves the saved files, and a confirmation links to them.
|
|
30
|
+
*/
|
|
31
|
+
export const saveFiles = saving.saveFiles;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The label of a save action for menus: "Save to Files" when one app stores files, otherwise "Save to…". Pass the
|
|
35
|
+
* request locale; `name` names one file, and `all` saves every file of a list.
|
|
36
|
+
*/
|
|
37
|
+
export const saveFilesLabel = saving.label;
|
|
38
|
+
|
|
39
|
+
/** An icon button that calls `saveFiles(files())`, labelled with `saveFilesLabel`. Takes the props of `IconButton`. */
|
|
40
|
+
export const SaveFilesButton = saving.SaveFilesButton;
|
|
18
41
|
|
|
19
42
|
// Discovery starts while the page is idle, so the first click already knows whether providers exist.
|
|
20
43
|
if (typeof window !== "undefined") {
|
|
21
44
|
const idle = window.requestIdleCallback ?? ((callback: () => void) => window.setTimeout(callback, 1_000));
|
|
22
|
-
idle(() =>
|
|
45
|
+
idle(() => providers.prefetch());
|
|
23
46
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { notifications as nativeNotifications } from "@k2b/stdlib/browser";
|
|
2
2
|
import { apiClient } from "../clients/core";
|
|
3
|
-
import type { BrowserPushSubscription } from "../contracts";
|
|
3
|
+
import type { BrowserPushSubscription, NotificationQuietState } from "../contracts";
|
|
4
|
+
import { notificationGroupTag } from "../contracts/notification-group";
|
|
4
5
|
import { withNotificationTimeout } from "./notification-timeout";
|
|
5
6
|
|
|
6
7
|
const SERVICE_WORKER_PATH = "/service-worker.js";
|
|
@@ -120,6 +121,41 @@ const currentState = async (registration?: ServiceWorkerRegistration): Promise<B
|
|
|
120
121
|
export const browserNotificationClient = {
|
|
121
122
|
state: currentState,
|
|
122
123
|
|
|
124
|
+
/**
|
|
125
|
+
* Whether do not disturb or quiet hours hold back the signed-in person's browser notifications right now, for
|
|
126
|
+
* every app. For display only; Cloud applies quiet time when it delivers. Reload it once `until` has passed.
|
|
127
|
+
*/
|
|
128
|
+
quietState: async (): Promise<NotificationQuietState> => {
|
|
129
|
+
const response = await apiClient.me.notifications.quiet.$get();
|
|
130
|
+
if (!response.ok) throw await responseError(response, "Failed to load the notification quiet state.");
|
|
131
|
+
return (await response.json()).state;
|
|
132
|
+
},
|
|
133
|
+
|
|
134
|
+
/** Close this application's group on this device without opting into notifications. */
|
|
135
|
+
closeGroup: async (appId: string, group: string): Promise<void> => {
|
|
136
|
+
const tag = notificationGroupTag(appId, group);
|
|
137
|
+
if (!tag || typeof navigator === "undefined" || !navigator.serviceWorker?.getRegistration) return;
|
|
138
|
+
try {
|
|
139
|
+
const registration = await navigator.serviceWorker.getRegistration(SERVICE_WORKER_SCOPE);
|
|
140
|
+
if (!registration?.getNotifications || new URL(registration.scope).pathname !== SERVICE_WORKER_SCOPE) return;
|
|
141
|
+
const notifications = await registration.getNotifications({ tag });
|
|
142
|
+
for (const notification of notifications) notification.close();
|
|
143
|
+
} catch {
|
|
144
|
+
// Existing registrations and native notifications may become unavailable.
|
|
145
|
+
}
|
|
146
|
+
},
|
|
147
|
+
|
|
148
|
+
/** Set or clear this device's app badge where the browser supports it. */
|
|
149
|
+
setBadge: async (count: number): Promise<void> => {
|
|
150
|
+
if (typeof navigator === "undefined" || !Number.isSafeInteger(count) || count < 0) return;
|
|
151
|
+
try {
|
|
152
|
+
if (count === 0) await navigator.clearAppBadge?.();
|
|
153
|
+
else await navigator.setAppBadge?.(count);
|
|
154
|
+
} catch {
|
|
155
|
+
// Badging is optional and can be refused by the browser.
|
|
156
|
+
}
|
|
157
|
+
},
|
|
158
|
+
|
|
123
159
|
/** Register the worker and rebind an existing subscription to the signed-in user. Never prompts. */
|
|
124
160
|
refreshExisting: async (): Promise<BrowserNotificationState> => {
|
|
125
161
|
const capability = support();
|