@timurproko/a1 0.1.8-dev.287 → 0.1.8-dev.289

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 (30) hide show
  1. package/dist/contracts/owned-ui/image-attachments.d.ts +9 -0
  2. package/dist/contracts/owned-ui/image-attachments.js +19 -1
  3. package/dist/integrations/pi/components/owned-editor-ux.d.ts +6 -0
  4. package/dist/integrations/pi/components/owned-editor-ux.js +71 -0
  5. package/dist/integrations/pi/components/shell-editor-autocomplete.js +2 -0
  6. package/dist/integrations/pi/components/shell-shared-facade.d.ts +6 -1
  7. package/dist/integrations/pi/session-ui/clipboard-image.d.ts +2 -2
  8. package/dist/integrations/pi/session-ui/clipboard-image.js +10 -5
  9. package/dist/integrations/pi/session-ui/image-preparation-client.d.ts +20 -0
  10. package/dist/integrations/pi/session-ui/image-preparation-client.js +145 -0
  11. package/dist/integrations/pi/session-ui/image-preparation.d.ts +18 -0
  12. package/dist/integrations/pi/session-ui/image-preparation.js +126 -0
  13. package/dist/integrations/pi/session-ui/image-source.d.ts +13 -0
  14. package/dist/integrations/pi/session-ui/image-source.js +171 -0
  15. package/dist/integrations/pi/session-ui/image-worker.d.ts +17 -0
  16. package/dist/integrations/pi/session-ui/image-worker.js +23 -0
  17. package/dist/integrations/pi/session-ui/prompt-chips.d.ts +10 -1
  18. package/dist/integrations/pi/session-ui/prompt-chips.js +111 -4
  19. package/dist/integrations/pi/session-ui/session-shell-root.d.ts +7 -3
  20. package/dist/integrations/pi/session-ui/session-shell-root.js +18 -1
  21. package/dist/integrations/pi/session-ui/session-shell.js +67 -22
  22. package/dist/integrations/pi/session-ui/system-clipboard.d.ts +2 -2
  23. package/dist/integrations/pi/session-ui/system-clipboard.js +16 -14
  24. package/dist/native/darwin-arm64/manifest.json +1 -1
  25. package/dist/native/linux-x64/manifest.json +3 -3
  26. package/dist/native/linux-x64/process-guardian +0 -0
  27. package/dist/native/win32-x64/manifest.json +2 -2
  28. package/dist/native/win32-x64/process-guardian.exe +0 -0
  29. package/dist/runtime-payload-inventory.json +22 -18
  30. package/package.json +2 -1
@@ -7,6 +7,15 @@ declare const MESSAGES: {
7
7
  readonly "image-count": "A prompt supports at most 8 images. Remove an attachment before adding another.";
8
8
  readonly "image-data": "Image data is invalid. Paste a valid image again.";
9
9
  readonly "image-mime": "Image MIME type is invalid. Paste a supported image again.";
10
+ readonly "image-source-size": "Source image exceeds 20 MiB. Reduce the source size and paste it again.";
11
+ readonly "image-pixels": "Source image exceeds 40 million pixels or a 32768-pixel dimension. Reduce its dimensions and paste again.";
12
+ readonly "image-conversion": "This image cannot be safely resized. Paste a PNG or JPEG screenshot instead.";
13
+ readonly "image-codec": "Image preparation is unavailable. Check the installation and paste again.";
14
+ readonly "image-output": "Image cannot fit the output limits without excessive quality loss. Crop it and paste again.";
15
+ readonly "image-timeout": "Image preparation timed out. Remove the failed image and paste again.";
16
+ readonly "image-busy": "Image preparation is full (8 pending images). Wait or remove an image before pasting again.";
17
+ readonly "image-canceled": "Image preparation was canceled. Paste the image again.";
18
+ readonly "image-pending": "Image is still preparing. Wait before submitting.";
10
19
  };
11
20
  /** Trusted, payload-free diagnostics for user-correctable attachment failures. */
12
21
  export declare class ImageAttachmentError extends TypeError {
@@ -6,6 +6,15 @@ const MESSAGES = {
6
6
  "image-count": "A prompt supports at most 8 images. Remove an attachment before adding another.",
7
7
  "image-data": "Image data is invalid. Paste a valid image again.",
8
8
  "image-mime": "Image MIME type is invalid. Paste a supported image again.",
9
+ "image-source-size": "Source image exceeds 20 MiB. Reduce the source size and paste it again.",
10
+ "image-pixels": "Source image exceeds 40 million pixels or a 32768-pixel dimension. Reduce its dimensions and paste again.",
11
+ "image-conversion": "This image cannot be safely resized. Paste a PNG or JPEG screenshot instead.",
12
+ "image-codec": "Image preparation is unavailable. Check the installation and paste again.",
13
+ "image-output": "Image cannot fit the output limits without excessive quality loss. Crop it and paste again.",
14
+ "image-timeout": "Image preparation timed out. Remove the failed image and paste again.",
15
+ "image-busy": "Image preparation is full (8 pending images). Wait or remove an image before pasting again.",
16
+ "image-canceled": "Image preparation was canceled. Paste the image again.",
17
+ "image-pending": "Image is still preparing. Wait before submitting.",
9
18
  };
10
19
  /** Trusted, payload-free diagnostics for user-correctable attachment failures. */
11
20
  export class ImageAttachmentError extends TypeError {
@@ -23,6 +32,8 @@ export function assertImageEncodedSize(data) {
23
32
  if (Math.ceil(data.length / 4) * 4 > MAX_IMAGE_DATA_BYTES)
24
33
  throw new ImageAttachmentError("image-size");
25
34
  }
35
+ const VALIDATED_IMAGES = new WeakSet();
36
+ const BASE64_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
26
37
  /** Final command admission also covers restored and deferred non-clipboard inputs. */
27
38
  export function assertPromptImages(images) {
28
39
  if (!Array.isArray(images) || images.length > MAX_PROMPT_IMAGES)
@@ -30,13 +41,20 @@ export function assertPromptImages(images) {
30
41
  for (const image of images) {
31
42
  if (!image || image.type !== "image")
32
43
  throw new ImageAttachmentError("image-data");
44
+ if (VALIDATED_IMAGES.has(image))
45
+ continue;
33
46
  assertImageEncodedSize(image.data);
34
47
  if (!/^[A-Za-z0-9+/]+={0,2}$/.test(image.data) || image.data.length % 4 !== 0
35
- || Buffer.from(image.data, "base64").toString("base64") !== image.data) {
48
+ || (image.data.endsWith("==") && (BASE64_ALPHABET.indexOf(image.data.at(-3) ?? "") & 15) !== 0)
49
+ || (image.data.endsWith("=") && !image.data.endsWith("==") && (BASE64_ALPHABET.indexOf(image.data.at(-2) ?? "") & 3) !== 0)) {
36
50
  throw new ImageAttachmentError("image-data");
37
51
  }
38
52
  if (typeof image.mimeType !== "string" || image.mimeType.length > 256 || !/^image\/[a-z0-9.+-]+$/i.test(image.mimeType)) {
39
53
  throw new ImageAttachmentError("image-mime");
40
54
  }
55
+ // Security: cache only immutable own data properties, never frozen objects with changing getters.
56
+ if (Object.isFrozen(image) && ["type", "data", "mimeType"].every(key => Object.getOwnPropertyDescriptor(image, key)?.value === image[key])) {
57
+ VALIDATED_IMAGES.add(image);
58
+ }
41
59
  }
42
60
  }
@@ -15,6 +15,7 @@ export interface OwnedEditorUxInterceptor {
15
15
  hasSelection?(): boolean;
16
16
  ownsPointer?(): boolean;
17
17
  pasteClipboard?(): boolean;
18
+ cancelPendingPastes?(): void;
18
19
  }
19
20
  export interface OwnedEditorPointerEvent {
20
21
  readonly kind: "press" | "motion" | "release";
@@ -36,6 +37,7 @@ export declare class OwnedEditorUxInterception {
36
37
  handleInput(data: string): void;
37
38
  render(width: number): string[];
38
39
  reset(): void;
40
+ cancelPendingPastes(): void;
39
41
  handlePointer(event: OwnedEditorPointerEvent): boolean;
40
42
  hasSelection(): boolean;
41
43
  ownsPointer(): boolean;
@@ -44,6 +46,10 @@ export declare class OwnedEditorUxInterception {
44
46
  export interface PromptSelectionUxOptions {
45
47
  readonly copyText: (text: string) => void;
46
48
  readonly readClipboardContent: () => Promise<PiShellClipboardContent | null>;
49
+ readonly beginClipboardPaste?: () => {
50
+ readonly marker: string;
51
+ readonly result: Promise<string>;
52
+ };
47
53
  readonly transformPastedContent: (content: PiShellClipboardContent) => string;
48
54
  readonly atomicRanges: (line: string) => readonly PiShellEditorTextRange[];
49
55
  readonly expandCopiedText: (text: string) => string;
@@ -31,6 +31,10 @@ export class OwnedEditorUxInterception {
31
31
  for (const interceptor of this.interceptors)
32
32
  interceptor.reset();
33
33
  }
34
+ cancelPendingPastes() {
35
+ for (const interceptor of this.interceptors)
36
+ interceptor.cancelPendingPastes?.();
37
+ }
34
38
  handlePointer(event) {
35
39
  return this.interceptors.some(interceptor => interceptor.handlePointer?.(event) === true);
36
40
  }
@@ -60,6 +64,7 @@ class PromptSelectionInterceptor {
60
64
  #lastClick;
61
65
  #redoStack = [];
62
66
  #selectionRevision = 0;
67
+ #pasteGeneration = 0;
63
68
  #wordDirection;
64
69
  #geometry;
65
70
  constructor(editor, keybindings, options) {
@@ -245,6 +250,7 @@ class PromptSelectionInterceptor {
245
250
  this.#redoStack = [];
246
251
  this.#selectionRevision += 1;
247
252
  }
253
+ cancelPendingPastes() { this.#pasteGeneration += 1; }
248
254
  hasSelection() {
249
255
  return this.#activeRange() !== undefined;
250
256
  }
@@ -404,6 +410,20 @@ class PromptSelectionInterceptor {
404
410
  this.#requestRender();
405
411
  }
406
412
  #pasteFromClipboard() {
413
+ if (this.options.beginClipboardPaste !== undefined) {
414
+ const generation = this.#pasteGeneration;
415
+ const restore = this.#orderedSelection() === undefined ? "" : this.#selectedText();
416
+ const paste = this.options.beginClipboardPaste();
417
+ if (this.#orderedSelection() !== undefined)
418
+ this.#replaceSelection(paste.marker);
419
+ else
420
+ this.editor.insertTextAtCursor(paste.marker);
421
+ this.#requestRender();
422
+ const replace = (text) => { if (generation === this.#pasteGeneration)
423
+ this.#replacePasteMarker(paste.marker, text || restore); };
424
+ void paste.result.then(replace).catch(() => replace(restore));
425
+ return;
426
+ }
407
427
  const revision = this.#selectionRevision;
408
428
  const selection = this.#orderedSelection();
409
429
  const atomicFocus = this.#atomicFocus();
@@ -428,6 +448,44 @@ class PromptSelectionInterceptor {
428
448
  this.#requestRender();
429
449
  }).catch(() => { });
430
450
  }
451
+ #replacePasteMarker(marker, replacement) {
452
+ const current = this.editor.getText();
453
+ const from = current.indexOf(marker);
454
+ if (from < 0)
455
+ return;
456
+ const to = from + marker.length;
457
+ const text = normalizeInsertedText(replacement);
458
+ const lines = editorState(this.editor).lines;
459
+ const remap = (position) => {
460
+ const offset = positionOffset(lines, position);
461
+ return offset <= from ? offset : offset >= to ? offset + text.length - marker.length : from + text.length;
462
+ };
463
+ const cursor = remap(this.#cursor());
464
+ const selection = this.#selection === undefined ? undefined : { anchor: remap(this.#selection.anchor), head: remap(this.#selection.head) };
465
+ const next = current.slice(0, from) + text + current.slice(to);
466
+ // Compatibility: completing a paste updates its provisional undo snapshots, not the user's undo history.
467
+ editorState(this.editor).lines = next.split("\n");
468
+ const undo = Reflect.get(this.editor, "undoStack");
469
+ const snapshots = typeof undo === "object" && undo !== null ? Reflect.get(undo, "stack") : undefined;
470
+ if (Array.isArray(snapshots))
471
+ for (const snapshot of snapshots) {
472
+ const state = typeof snapshot === "object" && snapshot !== null ? Reflect.get(snapshot, "state") : undefined;
473
+ if (isEditorState(state))
474
+ replaceSnapshotMarker(state, marker, text);
475
+ }
476
+ for (const snapshot of this.#redoStack) {
477
+ const state = { lines: snapshot.text.split("\n"), cursorLine: snapshot.cursor.line, cursorCol: snapshot.cursor.col };
478
+ replaceSnapshotMarker(state, marker, text);
479
+ snapshot.text = state.lines.join("\n");
480
+ snapshot.cursor = { line: state.cursorLine, col: state.cursorCol };
481
+ }
482
+ this.#setCursor(positionAtOffset(next, cursor));
483
+ this.editor.onChange?.(this.editor.getText());
484
+ this.editor.invalidate();
485
+ if (selection !== undefined)
486
+ this.#selection = { anchor: positionAtOffset(next, selection.anchor), head: positionAtOffset(next, selection.head) };
487
+ this.#requestRender();
488
+ }
431
489
  #replaceSelection(text) {
432
490
  const selection = this.#orderedSelection();
433
491
  if (selection === undefined) {
@@ -825,6 +883,19 @@ function positionAtOffset(text, requestedOffset) {
825
883
  const before = text.slice(0, offset).split("\n");
826
884
  return { line: before.length - 1, col: (before.at(-1) ?? "").length };
827
885
  }
886
+ function replaceSnapshotMarker(state, marker, replacement) {
887
+ const text = state.lines.join("\n");
888
+ const from = text.indexOf(marker);
889
+ if (from < 0)
890
+ return;
891
+ const cursor = positionOffset(state.lines, { line: state.cursorLine, col: state.cursorCol });
892
+ const offset = cursor <= from ? cursor : cursor >= from + marker.length ? cursor + replacement.length - marker.length : from + replacement.length;
893
+ const next = text.slice(0, from) + replacement + text.slice(from + marker.length);
894
+ const position = positionAtOffset(next, offset);
895
+ state.lines = next.split("\n");
896
+ state.cursorLine = position.line;
897
+ state.cursorCol = position.col;
898
+ }
828
899
  function normalizeInsertedText(text) {
829
900
  return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n").replace(/\t/g, " ");
830
901
  }
@@ -54,6 +54,7 @@ export function createPiShellEditor(options) {
54
54
  createPromptSelectionInterceptor(editor, keybindings, {
55
55
  copyText: options.onCopyText ?? (() => { }),
56
56
  readClipboardContent: options.readClipboardContent ?? (async () => null),
57
+ ...(options.beginClipboardPaste === undefined ? {} : { beginClipboardPaste: options.beginClipboardPaste }),
57
58
  transformPastedContent: options.transformPastedContent ?? (content => content.kind === "text" ? content.text : ""),
58
59
  atomicRanges: options.editorAtomicRanges ?? (() => []),
59
60
  expandCopiedText: options.expandCopiedEditorText ?? (text => text),
@@ -202,6 +203,7 @@ export function createPiShellEditor(options) {
202
203
  ownsPointer: () => editorUx?.ownsPointer() ?? false,
203
204
  handlePointer: event => editorUx?.handlePointer(event) ?? false,
204
205
  pasteClipboard: () => editorUx?.pasteClipboard() ?? false,
206
+ cancelPendingPastes: () => editorUx?.cancelPendingPastes(),
205
207
  };
206
208
  }
207
209
  function autocompleteCommand(command, addition) {
@@ -47,6 +47,7 @@ export interface PiShellEditorPort extends PiShellComponentPort {
47
47
  ownsPointer(): boolean;
48
48
  handlePointer(event: PiShellEditorPointerEvent): boolean;
49
49
  pasteClipboard(): boolean;
50
+ cancelPendingPastes?(): void;
50
51
  }
51
52
  export interface PiShellAutocompleteCommand {
52
53
  readonly name: string;
@@ -135,7 +136,11 @@ export interface PiShellEditorOptions {
135
136
  readonly onDequeue?: (() => void) | undefined;
136
137
  readonly onPromptSuggestionAccepted?: (text: string) => void;
137
138
  readonly onCopyText?: (text: string) => void;
138
- readonly readClipboardContent?: () => Promise<PiShellClipboardContent | null>;
139
+ readonly readClipboardContent?: (signal?: AbortSignal) => Promise<PiShellClipboardContent | null>;
140
+ readonly beginClipboardPaste?: () => {
141
+ readonly marker: string;
142
+ readonly result: Promise<string>;
143
+ };
139
144
  readonly transformPastedContent?: (content: PiShellClipboardContent) => string;
140
145
  readonly editorAtomicRanges?: (line: string) => readonly PiShellEditorTextRange[];
141
146
  readonly expandCopiedEditorText?: (text: string) => string;
@@ -7,5 +7,5 @@ export interface ClipboardImageData {
7
7
  * Clipboard adapters are untrusted at this boundary, so reject representations
8
8
  * that Node's permissive base64 decoder would otherwise partially accept.
9
9
  */
10
- export declare function canonicalizeClipboardImage(image: ClipboardImageData): ClipboardImageData | null;
11
- export declare function canonicalizeStandardBase64(value: string): string | null;
10
+ export declare function canonicalizeClipboardImage(image: ClipboardImageData, source?: boolean): ClipboardImageData | null;
11
+ export declare function canonicalizeStandardBase64(value: string, source?: boolean): string | null;
@@ -1,18 +1,21 @@
1
- import { assertImageEncodedSize } from "../../../contracts/owned-ui/index.js";
1
+ import { ImageAttachmentError, MAX_IMAGE_DATA_BYTES } from "../../../contracts/owned-ui/index.js";
2
+ import { MAX_SOURCE_IMAGE_BYTES } from "./image-source.js";
2
3
  const STANDARD_BASE64 = /^[A-Za-z0-9+/]*={0,2}$/u;
3
4
  /**
4
5
  * Converts valid padded or unpadded standard base64 into its unique padded form.
5
6
  * Clipboard adapters are untrusted at this boundary, so reject representations
6
7
  * that Node's permissive base64 decoder would otherwise partially accept.
7
8
  */
8
- export function canonicalizeClipboardImage(image) {
9
- const data = canonicalizeStandardBase64(image.data);
9
+ export function canonicalizeClipboardImage(image, source = false) {
10
+ const data = canonicalizeStandardBase64(image.data, source);
10
11
  return data === null ? null : { data, mimeType: image.mimeType };
11
12
  }
12
- export function canonicalizeStandardBase64(value) {
13
+ export function canonicalizeStandardBase64(value, source = false) {
13
14
  if (typeof value !== "string" || value.length === 0)
14
15
  return null;
15
- assertImageEncodedSize(value);
16
+ const maximum = source ? Math.ceil(MAX_SOURCE_IMAGE_BYTES / 3) * 4 : MAX_IMAGE_DATA_BYTES;
17
+ if (Math.ceil(value.length / 4) * 4 > maximum)
18
+ throw new ImageAttachmentError(source ? "image-source-size" : "image-size");
16
19
  if (!STANDARD_BASE64.test(value))
17
20
  return null;
18
21
  const paddingStart = value.indexOf("=");
@@ -26,6 +29,8 @@ export function canonicalizeStandardBase64(value) {
26
29
  return null;
27
30
  const padded = `${core}${"=".repeat(requiredPadding)}`;
28
31
  const decoded = Buffer.from(padded, "base64");
32
+ if (source && decoded.length > MAX_SOURCE_IMAGE_BYTES)
33
+ throw new ImageAttachmentError("image-source-size");
29
34
  if (decoded.length === 0)
30
35
  return null;
31
36
  const canonical = decoded.toString("base64");
@@ -0,0 +1,20 @@
1
+ import type { PiShellClipboardContent } from "../components/index.js";
2
+ import type { ImagePreparationLimits, PreparedImage } from "./image-preparation.js";
3
+ import type { ImageWorkerRequest } from "./image-worker.js";
4
+ export type PreparedClipboardContent = {
5
+ readonly kind: "text";
6
+ readonly text: string;
7
+ } | ({
8
+ readonly kind: "image";
9
+ } & PreparedImage) | null;
10
+ export interface ImagePasteJob {
11
+ readonly result: Promise<PreparedClipboardContent>;
12
+ cancel(): void;
13
+ }
14
+ /** Owns bounded paste acquisition and one off-thread conversion, canceled on session disposal. */
15
+ export declare class ImagePreparationClient {
16
+ #private;
17
+ start(read?: (signal: AbortSignal) => Promise<PiShellClipboardContent | null>, limits?: ImagePreparationLimits): ImagePasteJob;
18
+ dispose(): Promise<void>;
19
+ }
20
+ export declare function runImageWorker<T>(request: ImageWorkerRequest, signal: AbortSignal): Promise<T>;
@@ -0,0 +1,145 @@
1
+ import { Worker } from "node:worker_threads";
2
+ import { ImageAttachmentError } from "../../../contracts/owned-ui/index.js";
3
+ import { IMAGE_PREPARATION_MS, MAX_SOURCE_IMAGE_BYTES } from "./image-source.js";
4
+ /** Owns bounded paste acquisition and one off-thread conversion, canceled on session disposal. */
5
+ export class ImagePreparationClient {
6
+ #active = new Set();
7
+ #queue = [];
8
+ #stopping = new Set();
9
+ #converting = false;
10
+ #disposed = false;
11
+ start(read, limits = {}) {
12
+ const controller = new AbortController();
13
+ if (this.#disposed || this.#active.size >= 8) {
14
+ const result = Promise.reject(new ImageAttachmentError(this.#disposed ? "image-canceled" : "image-busy"));
15
+ void result.catch(() => { });
16
+ return { result, cancel: () => { } };
17
+ }
18
+ this.#active.add(controller);
19
+ const deadline = setTimeout(() => controller.abort(new ImageAttachmentError("image-timeout")), IMAGE_PREPARATION_MS);
20
+ const result = new Promise((resolve, reject) => {
21
+ controller.signal.addEventListener("abort", () => reject(abortError(controller.signal)), { once: true });
22
+ // Concurrency: the pending marker gets a render opportunity before any acquisition starts.
23
+ setImmediate(() => {
24
+ if (controller.signal.aborted)
25
+ return;
26
+ const acquire = read === undefined
27
+ ? runImageWorker({ kind: "clipboard" }, controller.signal)
28
+ : Promise.resolve().then(() => read(controller.signal));
29
+ void acquire.then(content => {
30
+ if (controller.signal.aborted)
31
+ return;
32
+ if (content?.kind !== "image") {
33
+ resolve(content);
34
+ return;
35
+ }
36
+ if (Math.ceil(content.data.length / 4) * 3 > MAX_SOURCE_IMAGE_BYTES + 2)
37
+ throw new ImageAttachmentError("image-source-size");
38
+ this.#queue.push({ signal: controller.signal, run: async () => {
39
+ try {
40
+ const image = await runImageWorker({ kind: "prepare", source: content, limits }, controller.signal);
41
+ resolve({ kind: "image", ...image });
42
+ }
43
+ catch (error) {
44
+ reject(error);
45
+ }
46
+ } });
47
+ this.#drain();
48
+ }).catch(reject);
49
+ });
50
+ }).finally(() => {
51
+ clearTimeout(deadline);
52
+ this.#active.delete(controller);
53
+ const stopped = waitForImageWorkers(controller.signal);
54
+ this.#stopping.add(stopped);
55
+ void stopped.finally(() => this.#stopping.delete(stopped));
56
+ const index = this.#queue.findIndex(item => item.signal === controller.signal);
57
+ if (index >= 0)
58
+ this.#queue.splice(index, 1);
59
+ });
60
+ void result.catch(() => { });
61
+ return { result, cancel: () => controller.abort(new ImageAttachmentError("image-canceled")) };
62
+ }
63
+ async dispose() {
64
+ this.#disposed = true;
65
+ for (const controller of this.#active)
66
+ controller.abort(new ImageAttachmentError("image-canceled"));
67
+ this.#queue.length = 0;
68
+ await Promise.resolve();
69
+ await Promise.all(this.#stopping);
70
+ }
71
+ #drain() {
72
+ if (this.#converting)
73
+ return;
74
+ const next = this.#queue.shift();
75
+ if (next === undefined)
76
+ return;
77
+ if (next.signal.aborted) {
78
+ this.#drain();
79
+ return;
80
+ }
81
+ this.#converting = true;
82
+ void next.run().finally(() => { this.#converting = false; this.#drain(); });
83
+ }
84
+ }
85
+ const ACTIVE_IMAGE_WORKERS = new Map();
86
+ async function waitForImageWorkers(signal) {
87
+ await Promise.all([...ACTIVE_IMAGE_WORKERS].filter(([, owner]) => owner === signal)
88
+ .map(([worker]) => new Promise(resolve => { worker.once("exit", () => resolve()); })));
89
+ }
90
+ export function runImageWorker(request, signal) {
91
+ return new Promise((resolve, reject) => {
92
+ if (signal.aborted) {
93
+ reject(abortError(signal));
94
+ return;
95
+ }
96
+ if (ACTIVE_IMAGE_WORKERS.size >= 8) {
97
+ reject(new ImageAttachmentError("image-busy"));
98
+ return;
99
+ }
100
+ // Compatibility: production uses the emitted sibling; source tests load TypeScript through tsx.
101
+ const source = import.meta.url.endsWith(".ts");
102
+ const entry = new URL(source ? "./image-worker.ts" : "./image-worker.js", import.meta.url);
103
+ const worker = source
104
+ ? new Worker(`import('tsx/esm/api').then(({ tsImport }) => tsImport(${JSON.stringify(entry.href)}, ${JSON.stringify(import.meta.url)}))`, { eval: true, workerData: request, stdout: true, stderr: true })
105
+ : new Worker(entry, { workerData: request, stdout: true, stderr: true });
106
+ // Security: native/codec output is not trusted diagnostics and must never reach the terminal or logs.
107
+ worker.stdout.resume();
108
+ worker.stderr.resume();
109
+ ACTIVE_IMAGE_WORKERS.set(worker, signal);
110
+ let outcome;
111
+ let stop;
112
+ const requestStop = () => {
113
+ if (stop !== undefined)
114
+ return;
115
+ worker.postMessage("cancel");
116
+ // Security: allow clipboard subprocess abort first; a busy codec cannot handle messages.
117
+ stop = setTimeout(() => { void worker.terminate().catch(() => { }); }, 50);
118
+ stop.unref();
119
+ };
120
+ const abort = () => { outcome = { error: abortError(signal) }; requestStop(); };
121
+ signal.addEventListener("abort", abort, { once: true });
122
+ worker.once("message", (message) => {
123
+ if (!signal.aborted)
124
+ outcome = message?.ok === true
125
+ ? { value: message.value }
126
+ : { error: new ImageAttachmentError(message?.code ?? "image-codec") };
127
+ requestStop();
128
+ });
129
+ worker.once("error", () => { outcome = { error: new ImageAttachmentError("image-codec") }; requestStop(); });
130
+ worker.once("exit", () => {
131
+ ACTIVE_IMAGE_WORKERS.delete(worker);
132
+ if (stop !== undefined)
133
+ clearTimeout(stop);
134
+ signal.removeEventListener("abort", abort);
135
+ // Concurrency: release the conversion slot only after the old executor has actually stopped.
136
+ if (outcome === undefined || outcome.error !== undefined)
137
+ reject(outcome?.error ?? new ImageAttachmentError("image-codec"));
138
+ else
139
+ resolve(outcome.value);
140
+ });
141
+ });
142
+ }
143
+ function abortError(signal) {
144
+ return signal.reason instanceof ImageAttachmentError ? signal.reason : new ImageAttachmentError("image-canceled");
145
+ }
@@ -0,0 +1,18 @@
1
+ export interface ImagePreparationLimits {
2
+ readonly encodedBytes?: number;
3
+ readonly decodedBytes?: number;
4
+ readonly maxDimension?: number;
5
+ }
6
+ export interface PreparedImage {
7
+ readonly data: string;
8
+ readonly mimeType: string;
9
+ readonly width: number;
10
+ readonly height: number;
11
+ readonly transformed: boolean;
12
+ }
13
+ export declare const IMAGE_TARGET_BASE64_BYTES: number;
14
+ export declare const IMAGE_TARGET_DECODED_BYTES: number;
15
+ export declare function prepareImage(source: {
16
+ readonly data: string;
17
+ readonly mimeType: string;
18
+ }, limits?: ImagePreparationLimits): Promise<PreparedImage>;
@@ -0,0 +1,126 @@
1
+ import { isMainThread } from "node:worker_threads";
2
+ import { ImageAttachmentError } from "../../../contracts/owned-ui/index.js";
3
+ import { canonicalizeClipboardImage } from "./clipboard-image.js";
4
+ import { sourceImageInfo } from "./image-source.js";
5
+ export const IMAGE_TARGET_BASE64_BYTES = 4.5 * 1024 * 1024;
6
+ export const IMAGE_TARGET_DECODED_BYTES = 5 * 1024 * 1024;
7
+ export async function prepareImage(source, limits = {}) {
8
+ // Performance: an async signature does not move synchronous WASM work off the interactive thread.
9
+ if (isMainThread)
10
+ throw new Error("Image preparation requires a worker");
11
+ const canonical = canonicalizeClipboardImage(source, true);
12
+ if (canonical === null)
13
+ throw new ImageAttachmentError("image-data");
14
+ const bytes = Buffer.from(canonical.data, "base64");
15
+ const metadata = sourceImageInfo(bytes, canonical.mimeType);
16
+ const encodedLimit = Math.min(IMAGE_TARGET_BASE64_BYTES - 1, positive(limits.encodedBytes, Infinity));
17
+ const decodedLimit = Math.min(IMAGE_TARGET_DECODED_BYTES, positive(limits.decodedBytes, Infinity));
18
+ const maxDimension = positive(limits.maxDimension, Infinity);
19
+ const fits = (length) => length <= decodedLimit && 4 * Math.ceil(length / 3) <= encodedLimit;
20
+ if (fits(bytes.length) && Math.max(metadata.width, metadata.height) <= maxDimension) {
21
+ return { ...canonical, width: metadata.width, height: metadata.height, transformed: false };
22
+ }
23
+ if (metadata.conversionUnsafe)
24
+ throw new ImageAttachmentError("image-conversion");
25
+ const photon = await import("@silvia-odwyer/photon-node").catch(() => { throw new ImageAttachmentError("image-codec"); });
26
+ let image;
27
+ try {
28
+ image = photon.PhotonImage.new_from_byteslice(bytes);
29
+ if (image.get_width() !== metadata.width || image.get_height() !== metadata.height)
30
+ throw new ImageAttachmentError("image-data");
31
+ if (metadata.orientation !== 1) {
32
+ const raw = image.get_raw_pixels();
33
+ const oriented = orientPixels(raw, metadata.width, metadata.height, metadata.orientation);
34
+ image.free();
35
+ image = undefined;
36
+ image = new photon.PhotonImage(oriented.pixels, oriented.width, oriented.height);
37
+ }
38
+ const width = image.get_width(), height = image.get_height();
39
+ let scale = Math.min(1, 2000 / Math.max(width, height), maxDimension / Math.max(width, height));
40
+ const floor = Math.min(Math.max(width, height), 1024, maxDimension);
41
+ for (let level = 0; level < 4; level++, scale *= 0.75) {
42
+ const w = Math.max(1, Math.round(width * scale)), h = Math.max(1, Math.round(height * scale));
43
+ if (Math.max(w, h) < floor)
44
+ break;
45
+ const resized = photon.resize(image, w, h, photon.SamplingFilter.Lanczos3);
46
+ let opaque;
47
+ try {
48
+ const png = resized.get_bytes();
49
+ if (fits(png.length))
50
+ return result(png, "image/png", w, h);
51
+ const pixels = resized.get_raw_pixels();
52
+ for (let p = 0; p < pixels.length; p += 4) {
53
+ const alpha = (pixels[p + 3] ?? 255) / 255;
54
+ for (let c = 0; c < 3; c++)
55
+ pixels[p + c] = Math.round((pixels[p + c] ?? 0) * alpha + 255 * (1 - alpha));
56
+ pixels[p + 3] = 255;
57
+ }
58
+ opaque = new photon.PhotonImage(pixels, w, h);
59
+ for (const quality of [85, 70, 55, 40]) {
60
+ const jpeg = opaque.get_bytes_jpeg(quality);
61
+ if (fits(jpeg.length))
62
+ return result(jpeg, "image/jpeg", w, h);
63
+ }
64
+ }
65
+ finally {
66
+ opaque?.free();
67
+ resized.free();
68
+ }
69
+ }
70
+ throw new ImageAttachmentError("image-output");
71
+ }
72
+ catch (error) {
73
+ if (error instanceof ImageAttachmentError)
74
+ throw error;
75
+ throw new ImageAttachmentError("image-conversion");
76
+ }
77
+ finally {
78
+ image?.free();
79
+ }
80
+ }
81
+ function positive(value, fallback) {
82
+ if (value === undefined)
83
+ return fallback;
84
+ if (!Number.isFinite(value) || value < 1)
85
+ throw new ImageAttachmentError("image-output");
86
+ return Math.floor(value);
87
+ }
88
+ function result(bytes, mimeType, width, height) {
89
+ return { data: Buffer.from(bytes).toString("base64"), mimeType, width, height, transformed: true };
90
+ }
91
+ function orientPixels(pixels, width, height, orientation) {
92
+ const swapped = orientation >= 5;
93
+ const outputWidth = swapped ? height : width, outputHeight = swapped ? width : height;
94
+ const output = new Uint8Array(pixels.length);
95
+ for (let y = 0; y < height; y++)
96
+ for (let x = 0; x < width; x++) {
97
+ let dx = x, dy = y;
98
+ if (orientation === 2)
99
+ dx = width - 1 - x;
100
+ if (orientation === 3) {
101
+ dx = width - 1 - x;
102
+ dy = height - 1 - y;
103
+ }
104
+ if (orientation === 4)
105
+ dy = height - 1 - y;
106
+ if (orientation === 5) {
107
+ dx = y;
108
+ dy = x;
109
+ }
110
+ if (orientation === 6) {
111
+ dx = height - 1 - y;
112
+ dy = x;
113
+ }
114
+ if (orientation === 7) {
115
+ dx = height - 1 - y;
116
+ dy = width - 1 - x;
117
+ }
118
+ if (orientation === 8) {
119
+ dx = y;
120
+ dy = width - 1 - x;
121
+ }
122
+ const from = (y * width + x) * 4, to = (dy * outputWidth + dx) * 4;
123
+ output.set(pixels.subarray(from, from + 4), to);
124
+ }
125
+ return { pixels: output, width: outputWidth, height: outputHeight };
126
+ }
@@ -0,0 +1,13 @@
1
+ export declare const MAX_SOURCE_IMAGE_BYTES: number;
2
+ export declare const MAX_SOURCE_PIXELS = 40000000;
3
+ export declare const MAX_SOURCE_DIMENSION = 32768;
4
+ export declare const IMAGE_PREPARATION_MS = 15000;
5
+ export interface ImageSourceInfo {
6
+ readonly width: number;
7
+ readonly height: number;
8
+ readonly mimeType: string;
9
+ readonly orientation: number;
10
+ readonly conversionUnsafe: boolean;
11
+ }
12
+ export declare function assertSourceBytes(length: number): void;
13
+ export declare function sourceImageInfo(bytes: Uint8Array, mimeType: string): ImageSourceInfo;