diff --git a/.gitignore b/.gitignore index 01ebf95f..c979337e 100644 --- a/.gitignore +++ b/.gitignore @@ -66,3 +66,6 @@ commands todo.md @fix_plan.md readme-preview.mjs + +# Uploaded images land here under each session working dir (runtime artifact) +.claude-images/ diff --git a/src/web/public/image-input.js b/src/web/public/image-input.js index 2aec5b0b..c50a6667 100644 --- a/src/web/public/image-input.js +++ b/src/web/public/image-input.js @@ -113,7 +113,14 @@ Object.assign(CodemanApp.prototype, { const paths = []; for (const file of files) { try { - const path = await this._uploadPasteImage(sessionId, file); + // Re-encode to a standard JPEG/PNG before upload. Galleries on some + // phones (notably Android/MIUI) hand back a WebP/HEIF whose filename and + // MIME claim "image/jpeg", which passes the server's extension allowlist + // but fails its magic-byte check ("bytes do not match declared type"). + // Decoding through the browser and re-encoding guarantees the bytes + // match the extension we send. + const normalized = await this._normalizeImageForUpload(file); + const path = await this._uploadPasteImage(sessionId, normalized); paths.push(path); } catch (err) { this.showToast('Upload failed: ' + (err.message || 'unknown error'), 'error'); @@ -145,4 +152,49 @@ Object.assign(CodemanApp.prototype, { return data.path; }, + // Decode an image File through the browser and re-encode it to a format the + // server accepts, so the uploaded bytes always match their declared + // extension. PNG is re-encoded as PNG (preserves transparency); everything + // else (JPEG, WebP, HEIF, unknown) becomes JPEG. Animated GIFs are passed + // through untouched since a canvas would flatten them to one frame. On any + // decode/encode failure the original file is returned unchanged so the server + // still gets a chance (and logs a precise diagnostic). + async _normalizeImageForUpload(file) { + if (file.type === 'image/gif') return file; + + const toPng = file.type === 'image/png'; + const url = URL.createObjectURL(file); + try { + const img = new Image(); + await new Promise((resolve, reject) => { + img.onload = () => resolve(); + img.onerror = () => reject(new Error('decode failed')); + img.src = url; + }); + + const width = img.naturalWidth; + const height = img.naturalHeight; + if (!width || !height) return file; + + const canvas = document.createElement('canvas'); + canvas.width = width; + canvas.height = height; + const ctx = canvas.getContext('2d'); + if (!ctx) return file; + ctx.drawImage(img, 0, 0); + + const mime = toPng ? 'image/png' : 'image/jpeg'; + const blob = await new Promise((resolve) => canvas.toBlob(resolve, mime, 0.92)); + if (!blob) return file; + + const baseName = (file.name || 'image').replace(/\.[^.]+$/, '') || 'image'; + return new File([blob], baseName + (toPng ? '.png' : '.jpg'), { type: mime }); + } catch (err) { + console.warn('Image re-encode failed, uploading original:', err); + return file; + } finally { + URL.revokeObjectURL(url); + } + }, + }); diff --git a/src/web/public/keyboard-accessory.js b/src/web/public/keyboard-accessory.js index 4785eda7..d4132346 100644 --- a/src/web/public/keyboard-accessory.js +++ b/src/web/public/keyboard-accessory.js @@ -5,6 +5,8 @@ * * - KeyboardAccessoryBar (singleton object) — Quick action buttons shown above the virtual * keyboard on mobile: arrow up/down, /init, /clear, /compact, paste, and dismiss. + * The paste button opens a dialog that handles both text paste and image attach + * (native picker + best-effort image paste, routed through app._uploadAndInsertImages). * Destructive actions (/clear, /compact) require double-tap confirmation (2s amber state). * Commands are sent as text + Enter separately for Ink compatibility. * Only initializes on touch devices (MobileDetection.isTouchDevice guard). @@ -264,8 +266,17 @@ const KeyboardAccessoryBar = { }).catch(() => {}); }, - /** Read clipboard and send contents as input */ - /** Show a paste overlay with a textarea for iOS compatibility */ + /** Show a paste overlay for iOS compatibility. + * Handles three input paths from one dialog: + * - Text: long-press the textarea → Paste → Send (unchanged). + * - Image (picker): the "Image" button opens a native file picker + * (accept=image/* → camera / photo library / files), the most reliable + * way to attach a photo on mobile. + * - Image (paste): if the browser exposes image blobs on the textarea's + * paste event, we intercept them and upload directly. Support is spotty + * on mobile, so it is a best-effort enhancement layered on the picker. + * All image paths reuse app._uploadAndInsertImages() (image-input.js), which + * uploads to /api/sessions/:id/paste-image and inserts the saved path. */ pasteFromClipboard() { if (typeof app === 'undefined' || !app.activeSessionId) return; @@ -274,23 +285,61 @@ const KeyboardAccessoryBar = { overlay.className = 'paste-overlay'; overlay.innerHTML = `