From 82ba3dd4f8ab7f6d506a0ed932af29e035fbb69f Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 05:44:14 -0700 Subject: [PATCH 1/3] fix(mobile): preserve composer input failures Co-authored-by: codex --- apps/mobile/src/lib/composerImages.test.ts | 24 +++- apps/mobile/src/lib/composerImages.ts | 134 +++++++++++++++++---- 2 files changed, 134 insertions(+), 24 deletions(-) diff --git a/apps/mobile/src/lib/composerImages.test.ts b/apps/mobile/src/lib/composerImages.test.ts index 40e00a271f7..ef8e6f241aa 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,25 @@ 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/missing.png"; + 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", + uri, + cause: missingFileCause, + message: `Composer image operation read-pasted-image failed for ${uri}.`, + }), + ); + + warn.mockRestore(); + }); }); diff --git a/apps/mobile/src/lib/composerImages.ts b/apps/mobile/src/lib/composerImages.ts index 13b53af724e..d89e8fe40d6 100644 --- a/apps/mobile/src/lib/composerImages.ts +++ b/apps/mobile/src/lib/composerImages.ts @@ -3,6 +3,7 @@ import { PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, type UploadChatImageAttachment, } from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; import { uuidv4 } from "./uuid"; export interface DraftComposerImageAttachment extends UploadChatImageAttachment { @@ -12,6 +13,30 @@ 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", + ]), + uri: Schema.NullOr(Schema.String), + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Composer image operation ${this.operation} failed${this.uri === null ? "" : ` for ${this.uri}`}.`; + } +} + function estimateBase64ByteSize(base64: string): number { const padding = base64.endsWith("==") ? 2 : base64.endsWith("=") ? 1 : 0; return Math.floor((base64.length * 3) / 4) - padding; @@ -20,16 +45,24 @@ 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", + uri: null, + 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", + uri: null, + cause, + }); } } @@ -49,14 +82,20 @@ 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", + uri: null, + cause, + }); + }); if (!permission.granted) { return { images: [], @@ -64,13 +103,21 @@ 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", + uri: null, + cause, + }); + }); if (result.canceled) { return { @@ -127,16 +174,24 @@ 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", + uri: null, + cause, + }); + }); + if (hasImage) { if (remainingSlots <= 0) { return { images: [], @@ -144,7 +199,13 @@ 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", + uri: null, + cause, + }); + }); if (!image) { return { images: [], @@ -180,8 +241,21 @@ 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", + uri: null, + cause, + }); + }); + if (hasText) { + const text = await clipboard.getStringAsync().catch((cause) => { + throw new ComposerImageOperationError({ + operation: "read-clipboard-text", + uri: null, + cause, + }); + }); return { images: [], text: text.length > 0 ? text : null, @@ -260,8 +334,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", + uri, + cause, + }), + ); } finally { if (ownedTemporaryFile) { try { @@ -269,8 +350,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", + uri, + cause, + }), + ); } } } From 2890d5c83b10c45e1c8e3dcedf645b78721aa83a Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 09:07:48 -0700 Subject: [PATCH 2/3] fix(mobile): redact composer image URIs Co-authored-by: codex --- apps/mobile/src/lib/composerImages.test.ts | 11 +++++--- apps/mobile/src/lib/composerImages.ts | 30 +++++++++++++--------- 2 files changed, 26 insertions(+), 15 deletions(-) diff --git a/apps/mobile/src/lib/composerImages.test.ts b/apps/mobile/src/lib/composerImages.test.ts index ef8e6f241aa..0e047fee842 100644 --- a/apps/mobile/src/lib/composerImages.test.ts +++ b/apps/mobile/src/lib/composerImages.test.ts @@ -94,7 +94,7 @@ describe("native pasted image cleanup", () => { }); it("reports structured context when reading a pasted image fails", async () => { - const uri = "file:///private/var/mobile/photos/missing.png"; + const uri = "file:///private/var/mobile/photos/signed-secret-token/missing.png?token=private"; const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined); await expect( @@ -105,11 +105,16 @@ describe("native pasted image cleanup", () => { expect.objectContaining({ _tag: "ComposerImageOperationError", operation: "read-pasted-image", - uri, + uriLength: uri.length, + uriProtocol: "file:", cause: missingFileCause, - message: `Composer image operation read-pasted-image failed for ${uri}.`, + 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 d89e8fe40d6..d629302cf13 100644 --- a/apps/mobile/src/lib/composerImages.ts +++ b/apps/mobile/src/lib/composerImages.ts @@ -28,12 +28,13 @@ export class ComposerImageOperationError extends Schema.TaggedErrorClass { throw new ComposerImageOperationError({ operation: "request-media-library-permission", - uri: null, cause, }); }); @@ -114,7 +112,6 @@ export async function pickComposerImages(input: { readonly existingCount: number .catch((cause) => { throw new ComposerImageOperationError({ operation: "launch-image-library", - uri: null, cause, }); }); @@ -187,7 +184,6 @@ export async function pasteComposerClipboard(input: { readonly existingCount: nu const hasImage = await clipboard.hasImageAsync().catch((cause) => { throw new ComposerImageOperationError({ operation: "check-clipboard-image", - uri: null, cause, }); }); @@ -202,7 +198,6 @@ export async function pasteComposerClipboard(input: { readonly existingCount: nu const image = await clipboard.getImageAsync({ format: "png" }).catch((cause) => { throw new ComposerImageOperationError({ operation: "read-clipboard-image", - uri: null, cause, }); }); @@ -244,7 +239,6 @@ export async function pasteComposerClipboard(input: { readonly existingCount: nu const hasText = await clipboard.hasStringAsync().catch((cause) => { throw new ComposerImageOperationError({ operation: "check-clipboard-text", - uri: null, cause, }); }); @@ -252,7 +246,6 @@ export async function pasteComposerClipboard(input: { readonly existingCount: nu const text = await clipboard.getStringAsync().catch((cause) => { throw new ComposerImageOperationError({ operation: "read-clipboard-text", - uri: null, cause, }); }); @@ -304,6 +297,19 @@ export function isOwnedPastedImageUri(uri: string): boolean { } } +function describeComposerImageUri(uri: string) { + let uriProtocol: string | undefined; + try { + uriProtocol = new URL(uri).protocol || undefined; + } catch { + // Malformed URIs still retain a nonsecret input length for diagnostics. + } + return { + uriLength: uri.length, + ...(uriProtocol === undefined ? {} : { uriProtocol }), + }; +} + export async function convertPastedImagesToAttachments(input: { readonly uris: ReadonlyArray; readonly existingCount: number; @@ -339,7 +345,7 @@ export async function convertPastedImagesToAttachments(input: { "[composer-images] failed to read pasted image", new ComposerImageOperationError({ operation: "read-pasted-image", - uri, + ...describeComposerImageUri(uri), cause, }), ); @@ -355,7 +361,7 @@ export async function convertPastedImagesToAttachments(input: { "[composer-images] failed to remove temporary pasted image", new ComposerImageOperationError({ operation: "remove-pasted-image", - uri, + ...describeComposerImageUri(uri), cause, }), ); From 3f2c8459cff18da10f9455aec9a8aed614d1be90 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 09:18:11 -0700 Subject: [PATCH 3/3] Reuse shared URL diagnostics Co-authored-by: codex --- apps/mobile/src/lib/composerImages.ts | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/apps/mobile/src/lib/composerImages.ts b/apps/mobile/src/lib/composerImages.ts index d629302cf13..edb5f43dda3 100644 --- a/apps/mobile/src/lib/composerImages.ts +++ b/apps/mobile/src/lib/composerImages.ts @@ -3,6 +3,7 @@ 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"; @@ -298,15 +299,10 @@ export function isOwnedPastedImageUri(uri: string): boolean { } function describeComposerImageUri(uri: string) { - let uriProtocol: string | undefined; - try { - uriProtocol = new URL(uri).protocol || undefined; - } catch { - // Malformed URIs still retain a nonsecret input length for diagnostics. - } + const diagnostics = getUrlDiagnostics(uri); return { - uriLength: uri.length, - ...(uriProtocol === undefined ? {} : { uriProtocol }), + uriLength: diagnostics.inputLength, + ...(diagnostics.protocol === undefined ? {} : { uriProtocol: diagnostics.protocol }), }; }