@k2b/cloud 0.28.0 → 0.29.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (112) hide show
  1. package/package.json +9 -6
  2. package/src/_internal/cli-plugins.ts +18 -0
  3. package/src/_internal/define-app.ts +21 -5
  4. package/src/_internal/registry-validation.ts +30 -1
  5. package/src/_internal/runtime-context.ts +1 -0
  6. package/src/access/PermissionEditor.tsx +11 -4
  7. package/src/ai/assistant-models.ts +25 -4
  8. package/src/ai/audio-tool.ts +3 -10
  9. package/src/ai/capabilities.ts +35 -9
  10. package/src/ai/chat/blocks.tsx +259 -197
  11. package/src/ai/chat/builtin-tools.tsx +3 -1
  12. package/src/ai/chat/composer-adapter.ts +8 -0
  13. package/src/ai/chat/live-turn.browser-harness.tsx +16 -3
  14. package/src/ai/chat/message-actions.tsx +1 -1
  15. package/src/ai/chat/message-utils.ts +28 -6
  16. package/src/ai/chat/messages.ts +20 -24
  17. package/src/ai/chat/presentation.tsx +1 -8
  18. package/src/ai/chat/tool-disclosure.tsx +21 -14
  19. package/src/ai/chat/tool-groups.ts +64 -37
  20. package/src/ai/chat/tool-names.ts +157 -0
  21. package/src/ai/chat/turn-layout.ts +14 -2
  22. package/src/ai/chat/turn-view.tsx +132 -70
  23. package/src/ai/chat-tasks.ts +16 -3
  24. package/src/ai/code-mode-skill.ts +4 -4
  25. package/src/ai/compaction.ts +22 -3
  26. package/src/ai/default-tools.ts +9 -2
  27. package/src/ai/executor.ts +127 -40
  28. package/src/ai/file-media-type.ts +4 -0
  29. package/src/ai/files-store.ts +17 -10
  30. package/src/ai/http.ts +11 -10
  31. package/src/ai/index.ts +1 -0
  32. package/src/ai/memories.ts +2 -0
  33. package/src/ai/memory-learning.ts +59 -5
  34. package/src/ai/memory-workflow-evidence.ts +35 -10
  35. package/src/ai/projects-routes.ts +5 -1
  36. package/src/ai/projects.ts +5 -1
  37. package/src/ai/protocol.ts +43 -5
  38. package/src/ai/provider.ts +16 -1
  39. package/src/ai/quota-provider.ts +4 -1
  40. package/src/ai/routes.ts +20 -10
  41. package/src/ai/settings.ts +54 -17
  42. package/src/ai/skill-seeds.ts +11 -3
  43. package/src/ai/store.ts +76 -33
  44. package/src/ai/structured.ts +2 -1
  45. package/src/ai/system-prompt.ts +5 -1
  46. package/src/ai/transcription.ts +58 -13
  47. package/src/ai/turn-failure.ts +5 -1
  48. package/src/ai/turn-guidance.ts +505 -0
  49. package/src/ai/types.ts +3 -1
  50. package/src/ai/ui.tsx +1 -0
  51. package/src/api/admin-core-settings.ts +16 -1
  52. package/src/api/admin-outgoing-mail.ts +18 -0
  53. package/src/api/me.ts +42 -1
  54. package/src/browser/FileChooser.tsx +71 -295
  55. package/src/browser/FileSaver.tsx +407 -0
  56. package/src/browser/app-badges.ts +16 -0
  57. package/src/browser/choose-files.browser-harness.tsx +22 -6
  58. package/src/browser/choose-files.tsx +23 -36
  59. package/src/browser/file-chooser-messages.ts +64 -0
  60. package/src/browser/file-providers.ts +258 -8
  61. package/src/browser/files.tsx +26 -3
  62. package/src/browser/notifications.ts +37 -1
  63. package/src/browser/provider-folder.tsx +301 -0
  64. package/src/browser/provider-list.ts +39 -0
  65. package/src/browser/save-files.browser-harness.tsx +35 -0
  66. package/src/browser/save-files.tsx +107 -0
  67. package/src/cli/admin/outgoing-mail.ts +30 -2
  68. package/src/contracts/app.ts +10 -0
  69. package/src/contracts/commands.ts +3 -1
  70. package/src/contracts/notification-group.ts +6 -0
  71. package/src/contracts/notification-types.ts +6 -0
  72. package/src/contracts/outgoing-mail.ts +56 -11
  73. package/src/contracts/registry.ts +1 -0
  74. package/src/contracts/user-notifications.ts +74 -0
  75. package/src/server/middleware/request-logger.ts +11 -2
  76. package/src/services/accounts/avatar.ts +5 -2
  77. package/src/services/image-metadata.ts +438 -0
  78. package/src/services/index.ts +1 -0
  79. package/src/services/logging/redaction.ts +50 -0
  80. package/src/services/mandates/index.ts +4 -2
  81. package/src/services/mandates/policy.ts +20 -0
  82. package/src/services/notifications/browser.ts +48 -15
  83. package/src/services/notifications/dispatcher.ts +31 -1
  84. package/src/services/notifications/platform.ts +32 -4
  85. package/src/services/notifications/preview.ts +23 -0
  86. package/src/services/notifications/quiet.ts +153 -0
  87. package/src/services/notifications/user.ts +6 -0
  88. package/src/services/outbox.ts +24 -9
  89. package/src/services/outgoing-mail/index.ts +33 -5
  90. package/src/services/outgoing-mail/messages.ts +60 -19
  91. package/src/services/outgoing-mail/store.ts +63 -4
  92. package/src/services/pdf/template-preview.ts +2 -1
  93. package/src/services/settings/app.ts +3 -0
  94. package/src/services/settings/store.ts +4 -1
  95. package/src/services/weather/geo.ts +6 -2
  96. package/src/shared/ai-platform-prompt.ts +2 -2
  97. package/src/shared/network-address.ts +1 -0
  98. package/src/shared/redirect.ts +2 -1
  99. package/src/shared/template-rendering.ts +120 -7
  100. package/src/ssr/AppBadge.tsx +30 -0
  101. package/src/ssr/AppLaunchpadPanel.tsx +47 -26
  102. package/src/ssr/IslandError.tsx +33 -0
  103. package/src/ssr/Layout.tsx +3 -0
  104. package/src/ssr/LayoutHelp.tsx +4 -2
  105. package/src/ssr/RailApps.island.tsx +9 -2
  106. package/src/ssr/app-badges.ts +93 -0
  107. package/src/ssr/rail-messages.ts +4 -0
  108. package/src/ssr/rail-navigation.ts +2 -0
  109. package/src/styles/effects.css +165 -0
  110. package/src/styles/file-chooser.css +25 -0
  111. package/src/styles/utilities-markdown-table.css +5 -1
  112. 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 usable = (app: CapabilityCatalogApp): boolean => {
65
+ const issuesOf = (app: CapabilityCatalogApp): FileProviderIssue[] | null => {
59
66
  try {
60
- return !fileProviderIssues(app.manifest).some((issue) => issue.function !== "save");
67
+ return fileProviderIssues(app.manifest);
61
68
  } catch {
62
- return false;
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
- if (!declaration || !usable(app)) return [];
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
- /** Reads every catalog page; the catalog is the only discovery, there is no provider route. */
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
+ };
@@ -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 choosing = createFileChoosing(() => loadFileProviders({ locale: pageLocale() }));
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 = choosing.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(() => choosing.prefetch());
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();