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
This commit is contained in:
irchelper 2026-02-20 00:37:24 +08:00
parent 7638052178
commit 8ae7b86683
2 changed files with 137 additions and 8 deletions

View File

@ -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<typeof vi.spyOn>;
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<string, unknown>).bun;
(process.versions as Record<string, unknown>).bun = "1.0.0";
platformSpy.mockReturnValue("darwin" as NodeJS.Platform);
try {
expect(prefersSips()).toBe(false);
} finally {
if (origBun === undefined) {
delete (process.versions as Record<string, unknown>).bun;
} else {
(process.versions as Record<string, unknown>).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<string, unknown>).bun;
(process.versions as Record<string, unknown>).bun = "1.0.0";
platformSpy.mockReturnValue("darwin" as NodeJS.Platform);
try {
expect(prefersSips()).toBe(true);
} finally {
if (origBun === undefined) {
delete (process.versions as Record<string, unknown>).bun;
} else {
(process.versions as Record<string, unknown>).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);
});
});
});

View File

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