From 7587b4fa4db7a38ca7247cbf749415d1078c2df8 Mon Sep 17 00:00:00 2001 From: Logan Yang Date: Wed, 13 May 2026 23:05:29 -0700 Subject: [PATCH] fix(mobile): import Buffer from the `buffer` polyfill so it works in WebView (#2443) Mobile Obsidian runs on Capacitor + WebView (no Node.js runtime), so the bare `Buffer` global is undefined. On iOS WebKit this surfaces as the runtime error "Can't find variable: Buffer" the moment the image processing path fires. Desktop is unaffected because Electron exposes Node globals. Two PRs in 3.3.0 introduced direct `Buffer.from(...)` calls in image-handling paths assuming "Buffer is global": - #2403 rewrote src/utils/base64.ts from btoa/atob to bare Buffer.from - #2401 did the same in src/utils.ts safeFetch arrayBuffer branch and src/LLMProviders/BedrockChatModel.ts decodeBase64ToUint8Array Result: any image attachment on mobile fails with "failed to send image, please try again". Confirmed via user log: ERROR Error sending message: Can't find variable: Buffer The fix is an explicit `import { Buffer } from "buffer"` in each affected file. The `buffer` npm package (already in dependencies) is a browser-compatible polyfill that esbuild bundles into main.js, so the same Buffer code path works on desktop (where it's effectively the Electron-provided Buffer) and on mobile (where it's the polyfill). Two lint rules complicate this: - Obsidian's official scorecard flags btoa/atob as deprecated and asks for Buffer.from. That rules out reverting to btoa/atob. - The repo's own `import/no-nodejs-modules` rule blocks `import from "buffer"`. Suppressed inline at each import site with justification, because `buffer` here is the npm polyfill (in package.json dependencies), not Node's built-in being used at runtime. Files touched: - src/utils/base64.ts (arrayBufferToBase64 / base64ToArrayBuffer) This is the call site the image processor hits and the path encryptionService.ts uses for WEBCRYPTO_PREFIX (mobile encryption), so mobile encryption is also fixed transitively. - src/utils.ts (safeFetch arrayBuffer fallback) - src/LLMProviders/BedrockChatModel.ts (decodeBase64ToUint8Array) Regression test: - src/utils/base64.test.ts deletes globalThis.Buffer before exercising the helpers to confirm the imported polyfill binding is what gets used (not a bare global reference). Catches any future regression where someone replaces the import with a bare global usage. Out of scope: - src/encryptionService.ts has Buffer.from references but they sit inside desktop-only branches (`getSafeStorage().isEncryptionAvailable()` / DESKTOP_PREFIX) that mobile never enters. Touching them from a mobile hotfix would expand blast radius unnecessarily. - src/LLMProviders/BedrockChatModel.ts:835 is already guarded with `typeof Buffer !== "undefined"` so mobile skips it; left alone. Bundle size unchanged at 3.3 MB (buffer polyfill is small). Co-authored-by: Claude Opus 4.7 (1M context) --- src/LLMProviders/BedrockChatModel.ts | 7 ++++++ src/utils.ts | 7 ++++++ src/utils/base64.test.ts | 37 ++++++++++++++++++++++++++++ src/utils/base64.ts | 11 ++++++++- 4 files changed, 61 insertions(+), 1 deletion(-) create mode 100644 src/utils/base64.test.ts diff --git a/src/LLMProviders/BedrockChatModel.ts b/src/LLMProviders/BedrockChatModel.ts index 32faff84..22c8fabf 100644 --- a/src/LLMProviders/BedrockChatModel.ts +++ b/src/LLMProviders/BedrockChatModel.ts @@ -1,3 +1,8 @@ +// Reason: `buffer` is the npm polyfill (browser-compatible), bundled by esbuild +// so the same Buffer code path works on desktop (Electron) and mobile (WebView). +// eslint-disable-next-line import/no-nodejs-modules +import { Buffer } from "buffer"; + import { BaseChatModel, type BaseChatModelCallOptions, @@ -793,6 +798,8 @@ export class BedrockChatModel extends BaseChatModel private decodeBase64ToUint8Array(encoded: string): Uint8Array | null { try { + // Reason: Buffer (from the `buffer` polyfill imported above) is the + // cross-platform path — bare global Buffer is undefined in mobile WebView. return new Uint8Array(Buffer.from(encoded, "base64")); } catch { return null; diff --git a/src/utils.ts b/src/utils.ts index 58d3de54..2cc2942d 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,3 +1,8 @@ +// Reason: `buffer` is the npm polyfill (browser-compatible), bundled by esbuild +// so the same Buffer code path works on desktop (Electron) and mobile (WebView). +// eslint-disable-next-line import/no-nodejs-modules +import { Buffer } from "buffer"; + import { Document } from "@/chainFactory"; import { ChainType } from "@/chainType"; import { @@ -883,6 +888,8 @@ export async function safeFetch( return response.arrayBuffer; } const base64 = response.text.replace(/^data:.*;base64,/, ""); + // Reason: Buffer (from the `buffer` polyfill imported above) is the + // cross-platform path — bare global Buffer is undefined in mobile WebView. const buf = Buffer.from(base64, "base64"); return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength) as ArrayBuffer; }, diff --git a/src/utils/base64.test.ts b/src/utils/base64.test.ts new file mode 100644 index 00000000..b52ee559 --- /dev/null +++ b/src/utils/base64.test.ts @@ -0,0 +1,37 @@ +import { arrayBufferToBase64, base64ToArrayBuffer } from "./base64"; + +describe("base64 utils", () => { + it("round-trips a small ArrayBuffer", () => { + const original = new Uint8Array([0, 1, 2, 3, 127, 128, 255]).buffer; + const encoded = arrayBufferToBase64(original); + const decoded = base64ToArrayBuffer(encoded); + expect(new Uint8Array(decoded)).toEqual(new Uint8Array(original)); + }); + + it("matches a known base64 fixture", () => { + const bytes = new TextEncoder().encode("Copilot 🚀"); + expect(arrayBufferToBase64(bytes.buffer as ArrayBuffer)).toBe("Q29waWxvdCDwn5qA"); + }); + + // Reason: regression guard for the 3.3.0 mobile bug. Mobile Obsidian's + // WebView runtime has no Node.js globals — bare `Buffer` is undefined. + // The helpers must import Buffer from the `buffer` npm polyfill (bundled + // by esbuild) and not rely on `globalThis.Buffer`. This test deletes the + // global to make sure the imported binding is what's actually being used. + it("works without relying on globalThis.Buffer (mobile WebView simulation)", () => { + // eslint-disable-next-line obsidianmd/no-global-this -- jsdom test needs to mutate the actual global runtime, not a per-window scope + const g = globalThis as { Buffer?: unknown }; + const originalBuffer = g.Buffer; + try { + delete g.Buffer; + const bytes = new Uint8Array([10, 20, 30, 40]).buffer; + const encoded = arrayBufferToBase64(bytes); + const decoded = base64ToArrayBuffer(encoded); + expect(new Uint8Array(decoded)).toEqual(new Uint8Array(bytes)); + } finally { + if (originalBuffer !== undefined) { + g.Buffer = originalBuffer; + } + } + }); +}); diff --git a/src/utils/base64.ts b/src/utils/base64.ts index 71f0bfdb..818143e5 100644 --- a/src/utils/base64.ts +++ b/src/utils/base64.ts @@ -1,7 +1,16 @@ +// Reason: explicit import from the `buffer` npm polyfill (a browser-compatible +// drop-in, not a runtime use of Node's built-in). Mobile Obsidian's WebView has +// no Node globals, so bare `Buffer` throws "Can't find variable: Buffer" on iOS +// WebKit. esbuild bundles this polyfill into main.js so the same code path +// works on both desktop and mobile. +// eslint-disable-next-line import/no-nodejs-modules +import { Buffer } from "buffer"; + export function arrayBufferToBase64(buffer: ArrayBuffer): string { return Buffer.from(buffer).toString("base64"); } export function base64ToArrayBuffer(base64: string): ArrayBuffer { - return new Uint8Array(Buffer.from(base64, "base64")).buffer; + const buf = Buffer.from(base64, "base64"); + return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength) as ArrayBuffer; }