diff --git a/apps/mobile/src/lib/composerImages.test.ts b/apps/mobile/src/lib/composerImages.test.ts index 40e00a271f7..0e047fee842 100644 --- a/apps/mobile/src/lib/composerImages.test.ts +++ b/apps/mobile/src/lib/composerImages.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; import { PROVIDER_SEND_TURN_MAX_ATTACHMENTS } from "@t3tools/contracts"; +const missingFileCause = new Error("missing file"); const files = new Map(); vi.mock("expo-file-system", () => ({ @@ -18,7 +19,7 @@ vi.mock("expo-file-system", () => ({ async base64(): Promise { const entry = files.get(this.uri); if (!entry || entry.deleted) { - throw new Error("missing file"); + throw missingFileCause; } return entry.base64; } @@ -91,4 +92,30 @@ describe("native pasted image cleanup", () => { expect(files.get(overflow)?.deleted).toBe(true); expect(files.get(userOwned)?.deleted).toBe(false); }); + + it("reports structured context when reading a pasted image fails", async () => { + const uri = "file:///private/var/mobile/photos/signed-secret-token/missing.png?token=private"; + const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined); + + await expect( + convertPastedImagesToAttachments({ uris: [uri], existingCount: 0 }), + ).resolves.toEqual([]); + expect(warn).toHaveBeenCalledWith( + "[composer-images] failed to read pasted image", + expect.objectContaining({ + _tag: "ComposerImageOperationError", + operation: "read-pasted-image", + uriLength: uri.length, + uriProtocol: "file:", + cause: missingFileCause, + message: `Composer image operation read-pasted-image failed for a file: URI (length ${uri.length}).`, + }), + ); + const error = warn.mock.calls[0]?.[1]; + expect(error).not.toHaveProperty("uri"); + expect(String(error)).not.toContain("signed-secret-token"); + expect(String(error)).not.toContain("token=private"); + + warn.mockRestore(); + }); }); diff --git a/apps/mobile/src/lib/composerImages.ts b/apps/mobile/src/lib/composerImages.ts index 13b53af724e..edb5f43dda3 100644 --- a/apps/mobile/src/lib/composerImages.ts +++ b/apps/mobile/src/lib/composerImages.ts @@ -3,6 +3,8 @@ import { PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, type UploadChatImageAttachment, } from "@t3tools/contracts"; +import { getUrlDiagnostics } from "@t3tools/shared/urlDiagnostics"; +import * as Schema from "effect/Schema"; import { uuidv4 } from "./uuid"; export interface DraftComposerImageAttachment extends UploadChatImageAttachment { @@ -12,6 +14,31 @@ export interface DraftComposerImageAttachment extends UploadChatImageAttachment const OWNED_PASTED_IMAGE_DIRECTORY = "t3-composer-paste"; +export class ComposerImageOperationError extends Schema.TaggedErrorClass()( + "ComposerImageOperationError", + { + operation: Schema.Literals([ + "load-image-picker", + "request-media-library-permission", + "launch-image-library", + "load-clipboard", + "check-clipboard-image", + "read-clipboard-image", + "check-clipboard-text", + "read-clipboard-text", + "read-pasted-image", + "remove-pasted-image", + ]), + uriLength: Schema.optional(Schema.Number), + uriProtocol: Schema.optional(Schema.String), + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Composer image operation ${this.operation} failed${this.uriLength === undefined ? "" : ` for a ${this.uriProtocol ?? "unknown-protocol"} URI (length ${this.uriLength})`}.`; + } +} + function estimateBase64ByteSize(base64: string): number { const padding = base64.endsWith("==") ? 2 : base64.endsWith("=") ? 1 : 0; return Math.floor((base64.length * 3) / 4) - padding; @@ -20,16 +47,22 @@ function estimateBase64ByteSize(base64: string): number { async function loadImagePicker() { try { return await import("expo-image-picker"); - } catch (error) { - throw new Error("Image attachments are unavailable right now.", { cause: error }); + } catch (cause) { + throw new ComposerImageOperationError({ + operation: "load-image-picker", + cause, + }); } } async function loadClipboard() { try { return await import("expo-clipboard"); - } catch (error) { - throw new Error("Clipboard paste is unavailable right now.", { cause: error }); + } catch (cause) { + throw new ComposerImageOperationError({ + operation: "load-clipboard", + cause, + }); } } @@ -49,14 +82,19 @@ export async function pickComposerImages(input: { readonly existingCount: number try { imagePicker = await loadImagePicker(); } catch (error) { + console.warn("[composer-images] image picker unavailable", error); return { images: [], - error: - error instanceof Error ? error.message : "Image attachments are unavailable right now.", + error: "Image attachments are unavailable right now.", }; } - const permission = await imagePicker.requestMediaLibraryPermissionsAsync(); + const permission = await imagePicker.requestMediaLibraryPermissionsAsync().catch((cause) => { + throw new ComposerImageOperationError({ + operation: "request-media-library-permission", + cause, + }); + }); if (!permission.granted) { return { images: [], @@ -64,13 +102,20 @@ export async function pickComposerImages(input: { readonly existingCount: number }; } - const result = await imagePicker.launchImageLibraryAsync({ - mediaTypes: ["images"], - allowsMultipleSelection: true, - selectionLimit: remainingSlots, - base64: true, - quality: 1, - }); + const result = await imagePicker + .launchImageLibraryAsync({ + mediaTypes: ["images"], + allowsMultipleSelection: true, + selectionLimit: remainingSlots, + base64: true, + quality: 1, + }) + .catch((cause) => { + throw new ComposerImageOperationError({ + operation: "launch-image-library", + cause, + }); + }); if (result.canceled) { return { @@ -127,16 +172,23 @@ export async function pasteComposerClipboard(input: { readonly existingCount: nu try { clipboard = await loadClipboard(); } catch (error) { + console.warn("[composer-images] clipboard unavailable", error); return { images: [], text: null, - error: error instanceof Error ? error.message : "Clipboard paste is unavailable right now.", + error: "Clipboard paste is unavailable right now.", }; } const remainingSlots = PROVIDER_SEND_TURN_MAX_ATTACHMENTS - input.existingCount; - if (await clipboard.hasImageAsync()) { + const hasImage = await clipboard.hasImageAsync().catch((cause) => { + throw new ComposerImageOperationError({ + operation: "check-clipboard-image", + cause, + }); + }); + if (hasImage) { if (remainingSlots <= 0) { return { images: [], @@ -144,7 +196,12 @@ export async function pasteComposerClipboard(input: { readonly existingCount: nu error: `You can attach up to ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} images per message.`, }; } - const image = await clipboard.getImageAsync({ format: "png" }); + const image = await clipboard.getImageAsync({ format: "png" }).catch((cause) => { + throw new ComposerImageOperationError({ + operation: "read-clipboard-image", + cause, + }); + }); if (!image) { return { images: [], @@ -180,8 +237,19 @@ export async function pasteComposerClipboard(input: { readonly existingCount: nu }; } - if (await clipboard.hasStringAsync()) { - const text = await clipboard.getStringAsync(); + const hasText = await clipboard.hasStringAsync().catch((cause) => { + throw new ComposerImageOperationError({ + operation: "check-clipboard-text", + cause, + }); + }); + if (hasText) { + const text = await clipboard.getStringAsync().catch((cause) => { + throw new ComposerImageOperationError({ + operation: "read-clipboard-text", + cause, + }); + }); return { images: [], text: text.length > 0 ? text : null, @@ -230,6 +298,14 @@ export function isOwnedPastedImageUri(uri: string): boolean { } } +function describeComposerImageUri(uri: string) { + const diagnostics = getUrlDiagnostics(uri); + return { + uriLength: diagnostics.inputLength, + ...(diagnostics.protocol === undefined ? {} : { uriProtocol: diagnostics.protocol }), + }; +} + export async function convertPastedImagesToAttachments(input: { readonly uris: ReadonlyArray; readonly existingCount: number; @@ -260,8 +336,15 @@ export async function convertPastedImagesToAttachments(input: { dataUrl: `data:${mimeType};base64,${base64}`, previewUri: ownedTemporaryFile ? `data:${mimeType};base64,${base64}` : uri, }); - } catch (error) { - console.warn("Failed to read pasted image", uri, error); + } catch (cause) { + console.warn( + "[composer-images] failed to read pasted image", + new ComposerImageOperationError({ + operation: "read-pasted-image", + ...describeComposerImageUri(uri), + cause, + }), + ); } finally { if (ownedTemporaryFile) { try { @@ -269,8 +352,15 @@ export async function convertPastedImagesToAttachments(input: { if (file.exists) { file.delete(); } - } catch (error) { - console.warn("Failed to remove temporary pasted image", uri, error); + } catch (cause) { + console.warn( + "[composer-images] failed to remove temporary pasted image", + new ComposerImageOperationError({ + operation: "remove-pasted-image", + ...describeComposerImageUri(uri), + cause, + }), + ); } } }