From 8ae7b86683c584bce5ff94865e2a91887a9b1774 Mon Sep 17 00:00:00 2001 From: irchelper Date: Fri, 20 Feb 2026 00:37:24 +0800 Subject: [PATCH] fix(media): use sips on Node.js + darwin to prevent Photos TCC prompt On macOS 15, sharp loads Apple's native HEIC/ImageIO libraries which triggers a macOS TCC Photos permission dialog. This was previously only fixed for Bun (isBun() check), but the same issue affects Node.js. Remove the isBun() guard so sips is used on any darwin platform (Bun or Node.js) unless OPENCLAW_IMAGE_BACKEND=sharp is explicitly set. Also remove the now-unused isBun() helper function. Fixes: macOS 15 node process triggering NSPhotoLibraryUsageDescription --- src/media/image-ops.helpers.test.ts | 137 +++++++++++++++++++++++++++- src/media/image-ops.ts | 8 +- 2 files changed, 137 insertions(+), 8 deletions(-) diff --git a/src/media/image-ops.helpers.test.ts b/src/media/image-ops.helpers.test.ts index 09e908362e6..e90f4a13d4e 100644 --- a/src/media/image-ops.helpers.test.ts +++ b/src/media/image-ops.helpers.test.ts @@ -1,5 +1,5 @@ -import { describe, expect, it } from "vitest"; -import { buildImageResizeSideGrid, IMAGE_REDUCE_QUALITY_STEPS } from "./image-ops.js"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { buildImageResizeSideGrid, IMAGE_REDUCE_QUALITY_STEPS, prefersSips } from "./image-ops.js"; describe("buildImageResizeSideGrid", () => { it("returns descending unique sides capped by maxSide", () => { @@ -16,3 +16,136 @@ describe("IMAGE_REDUCE_QUALITY_STEPS", () => { expect([...IMAGE_REDUCE_QUALITY_STEPS]).toEqual([85, 75, 65, 55, 45, 35]); }); }); + +// --------------------------------------------------------------------------- +// prefersSips() +// +// Logic: +// OPENCLAW_IMAGE_BACKEND === "sips" → true (any platform) +// OPENCLAW_IMAGE_BACKEND === "sharp" → false (any platform) +// OPENCLAW_IMAGE_BACKEND unset / empty + darwin → true (← fix: was Node.js-broken) +// OPENCLAW_IMAGE_BACKEND unset / empty + linux | win32 → false +// --------------------------------------------------------------------------- +describe("prefersSips", () => { + let platformSpy: ReturnType; + + beforeEach(() => { + // Allow overriding process.platform per test + platformSpy = vi.spyOn(process, "platform", "get"); + // Restore env vars automatically after each test + vi.stubEnv("OPENCLAW_IMAGE_BACKEND", ""); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + platformSpy.mockRestore(); + }); + + // Branch 1 — explicit sips, any platform + describe("OPENCLAW_IMAGE_BACKEND=sips", () => { + it("returns true on darwin", () => { + vi.stubEnv("OPENCLAW_IMAGE_BACKEND", "sips"); + platformSpy.mockReturnValue("darwin" as NodeJS.Platform); + expect(prefersSips()).toBe(true); + }); + + it("returns true on linux", () => { + vi.stubEnv("OPENCLAW_IMAGE_BACKEND", "sips"); + platformSpy.mockReturnValue("linux" as NodeJS.Platform); + expect(prefersSips()).toBe(true); + }); + + it("returns true on win32", () => { + vi.stubEnv("OPENCLAW_IMAGE_BACKEND", "sips"); + platformSpy.mockReturnValue("win32" as NodeJS.Platform); + expect(prefersSips()).toBe(true); + }); + }); + + // Branch 2 — explicit sharp wins even on darwin + describe("OPENCLAW_IMAGE_BACKEND=sharp", () => { + it("returns false on darwin (sharp override takes priority)", () => { + vi.stubEnv("OPENCLAW_IMAGE_BACKEND", "sharp"); + platformSpy.mockReturnValue("darwin" as NodeJS.Platform); + expect(prefersSips()).toBe(false); + }); + + it("returns false on linux", () => { + vi.stubEnv("OPENCLAW_IMAGE_BACKEND", "sharp"); + platformSpy.mockReturnValue("linux" as NodeJS.Platform); + expect(prefersSips()).toBe(false); + }); + + // Branch 5 — explicit sharp on darwin even if runtime resembles Bun + it("returns false on darwin when env=sharp (Bun-like runtime, regression guard)", () => { + // Simulates: OPENCLAW_IMAGE_BACKEND=sharp + darwin + Bun process + vi.stubEnv("OPENCLAW_IMAGE_BACKEND", "sharp"); + // Fake a Bun version string to confirm sharp env still wins + const origBun = (process.versions as Record).bun; + (process.versions as Record).bun = "1.0.0"; + platformSpy.mockReturnValue("darwin" as NodeJS.Platform); + try { + expect(prefersSips()).toBe(false); + } finally { + if (origBun === undefined) { + delete (process.versions as Record).bun; + } else { + (process.versions as Record).bun = origBun; + } + } + }); + }); + + // Branch 3 — THE FIX: no env var + darwin → must be true for both Node.js and Bun + describe("no OPENCLAW_IMAGE_BACKEND env var + darwin", () => { + it("returns true on darwin (Node.js runtime — core fix, was false before)", () => { + vi.stubEnv("OPENCLAW_IMAGE_BACKEND", ""); + platformSpy.mockReturnValue("darwin" as NodeJS.Platform); + expect(prefersSips()).toBe(true); + }); + + it("returns true on darwin when Bun is detected (no regression)", () => { + vi.stubEnv("OPENCLAW_IMAGE_BACKEND", ""); + const origBun = (process.versions as Record).bun; + (process.versions as Record).bun = "1.0.0"; + platformSpy.mockReturnValue("darwin" as NodeJS.Platform); + try { + expect(prefersSips()).toBe(true); + } finally { + if (origBun === undefined) { + delete (process.versions as Record).bun; + } else { + (process.versions as Record).bun = origBun; + } + } + }); + + // Empty string is distinct from "sips" — falls through to platform check + it("returns true on darwin when env var is empty string", () => { + vi.stubEnv("OPENCLAW_IMAGE_BACKEND", ""); + platformSpy.mockReturnValue("darwin" as NodeJS.Platform); + expect(prefersSips()).toBe(true); + }); + }); + + // Branch 4 — no env var + non-darwin → false + describe("no OPENCLAW_IMAGE_BACKEND env var + non-darwin", () => { + it("returns false on linux", () => { + vi.stubEnv("OPENCLAW_IMAGE_BACKEND", ""); + platformSpy.mockReturnValue("linux" as NodeJS.Platform); + expect(prefersSips()).toBe(false); + }); + + it("returns false on win32", () => { + vi.stubEnv("OPENCLAW_IMAGE_BACKEND", ""); + platformSpy.mockReturnValue("win32" as NodeJS.Platform); + expect(prefersSips()).toBe(false); + }); + + it("returns false on linux when env var is empty string", () => { + vi.stubEnv("OPENCLAW_IMAGE_BACKEND", ""); + platformSpy.mockReturnValue("linux" as NodeJS.Platform); + expect(prefersSips()).toBe(false); + }); + }); +}); diff --git a/src/media/image-ops.ts b/src/media/image-ops.ts index cbf0ed20ffc..8467e9dadaf 100644 --- a/src/media/image-ops.ts +++ b/src/media/image-ops.ts @@ -19,14 +19,10 @@ export function buildImageResizeSideGrid(maxSide: number, sideStart: number): nu .toSorted((a, b) => b - a); } -function isBun(): boolean { - return typeof (process.versions as { bun?: unknown }).bun === "string"; -} - -function prefersSips(): boolean { +export function prefersSips(): boolean { return ( process.env.OPENCLAW_IMAGE_BACKEND === "sips" || - (process.env.OPENCLAW_IMAGE_BACKEND !== "sharp" && isBun() && process.platform === "darwin") + (process.env.OPENCLAW_IMAGE_BACKEND !== "sharp" && process.platform === "darwin") ); }