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) <noreply@anthropic.com>
This commit is contained in:
Logan Yang 2026-05-13 23:05:29 -07:00 committed by GitHub
parent 280d95ebde
commit 7587b4fa4d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 61 additions and 1 deletions

View file

@ -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<BedrockChatModelCallOptions>
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;

View file

@ -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;
},

37
src/utils/base64.test.ts Normal file
View file

@ -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;
}
}
});
});

View file

@ -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;
}