Adds the macOS photos-helper Swift CLI (PhotoKit listing with stable cursors, persistent change tokens, iCloud-aware original export with a path guard and SHA-256) plus XCTests for its PhotoKit-free core, and the Electron main-process side: a validated helper client, a resumable metadata push and chunked original upload against the media contract, and IPC handlers that stay unavailable off macOS. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0138YqDj3ttfayRwuMKKrqVj
438 lines
15 KiB
TypeScript
438 lines
15 KiB
TypeScript
import { EventEmitter } from "node:events";
|
|
import { mkdtemp, rm, symlink, writeFile } from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import path from "node:path";
|
|
import { PassThrough } from "node:stream";
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import type { HelperProcess, SpawnHelper } from "./media-photos-helper.js";
|
|
import {
|
|
changesArgs,
|
|
createPhotosHelper,
|
|
exportArgs,
|
|
helperEnv,
|
|
listArgs,
|
|
PhotosHelperError,
|
|
parseAssetOutput,
|
|
parseAuthorizeOutput,
|
|
parseChangesOutput,
|
|
parseExportOutput,
|
|
parseHelperError,
|
|
parseListOutput,
|
|
parseProgressLine,
|
|
photosHelperCandidates,
|
|
resolvePhotosHelperBinary,
|
|
runPhotosHelper,
|
|
} from "./media-photos-helper.js";
|
|
|
|
const item = (overrides: Record<string, unknown> = {}) => ({
|
|
externalId: "9F2A61B0-0000-4000-8000-000000000001/L0/001",
|
|
kind: "video",
|
|
capturedAt: "2024-01-02T03:04:05.500Z",
|
|
durationMs: 12_346,
|
|
width: 1920,
|
|
height: 1080,
|
|
fps: null,
|
|
gpsLat: 52.52,
|
|
gpsLon: 13.4,
|
|
deviceModel: null,
|
|
albumNames: ["Trip"],
|
|
isFavorite: true,
|
|
fileName: "IMG_0001.MOV",
|
|
...overrides,
|
|
});
|
|
|
|
const listJson = (items: unknown[], nextCursor: unknown = null, changeToken: unknown = "tok") =>
|
|
JSON.stringify({ items, nextCursor, changeToken });
|
|
|
|
function expectCode(run: () => unknown, code: string) {
|
|
try {
|
|
run();
|
|
} catch (error) {
|
|
expect(error).toBeInstanceOf(PhotosHelperError);
|
|
expect((error as PhotosHelperError).code).toBe(code);
|
|
return;
|
|
}
|
|
throw new Error("expected a PhotosHelperError");
|
|
}
|
|
|
|
describe("photos helper output parsing", () => {
|
|
it("accepts a list page that matches the shared item contract", () => {
|
|
const page = parseListOutput(
|
|
listJson([item(), item({ kind: "photo", durationMs: null })], "c1"),
|
|
2,
|
|
);
|
|
expect(page.nextCursor).toBe("c1");
|
|
expect(page.changeToken).toBe("tok");
|
|
expect(page.items).toHaveLength(2);
|
|
expect(page.items[0]).toEqual(item());
|
|
});
|
|
|
|
it("accepts the last page of an empty library", () => {
|
|
expect(parseListOutput(listJson([], null, null), 500)).toEqual({
|
|
items: [],
|
|
nextCursor: null,
|
|
changeToken: null,
|
|
});
|
|
});
|
|
|
|
it.each([
|
|
["not JSON", "items: []"],
|
|
["an array document", "[]"],
|
|
["truncated JSON", '{"items":[{"externalId":'],
|
|
["missing items", JSON.stringify({ nextCursor: null, changeToken: null })],
|
|
["an item missing a key", listJson([{ ...item(), width: undefined }])],
|
|
["an unknown kind", listJson([item({ kind: "audio" })])],
|
|
["out-of-range GPS", listJson([item({ gpsLat: 91 })])],
|
|
["a non-UTC-parsable date", listJson([item({ capturedAt: "yesterday" })])],
|
|
["a negative duration", listJson([item({ durationMs: -1 })])],
|
|
["an empty external id", listJson([item({ externalId: "" })])],
|
|
["an empty cursor", listJson([item()], "")],
|
|
["a numeric cursor", listJson([item()], 3)],
|
|
["too many items", listJson([item(), item(), item()])],
|
|
])("rejects %s", (_name, text) => {
|
|
expectCode(() => parseListOutput(text, 2), "invalid_output");
|
|
});
|
|
|
|
it("parses authorization states and rejects unknown ones", () => {
|
|
expect(parseAuthorizeOutput('{"status":"limited"}')).toBe("limited");
|
|
expect(parseAuthorizeOutput('{"status":"notDetermined"}')).toBe("notDetermined");
|
|
expectCode(() => parseAuthorizeOutput('{"status":"maybe"}'), "invalid_output");
|
|
expectCode(() => parseAuthorizeOutput(""), "invalid_output");
|
|
});
|
|
|
|
it("parses asset details", () => {
|
|
const detail = parseAssetOutput(
|
|
JSON.stringify({
|
|
item: item(),
|
|
modifiedAt: "2024-02-01T00:00:00.000Z",
|
|
hidden: false,
|
|
resources: [{ type: "video", fileName: "IMG_0001.MOV", uti: "com.apple.quicktime-movie" }],
|
|
}),
|
|
);
|
|
expect(detail.resources[0]?.type).toBe("video");
|
|
expectCode(
|
|
() => parseAssetOutput(JSON.stringify({ item: item(), modifiedAt: null, resources: [] })),
|
|
"invalid_output",
|
|
);
|
|
});
|
|
|
|
it("parses changes and requires a token unless a full relisting is needed", () => {
|
|
expect(
|
|
parseChangesOutput(
|
|
JSON.stringify({
|
|
token: "t2",
|
|
changed: [item()],
|
|
deleted: ["x/L0/001"],
|
|
fullResyncRequired: false,
|
|
}),
|
|
),
|
|
).toMatchObject({ token: "t2", deleted: ["x/L0/001"], fullResyncRequired: false });
|
|
expect(
|
|
parseChangesOutput(
|
|
JSON.stringify({ token: null, changed: [], deleted: [], fullResyncRequired: true }),
|
|
).fullResyncRequired,
|
|
).toBe(true);
|
|
expectCode(
|
|
() =>
|
|
parseChangesOutput(
|
|
JSON.stringify({ token: null, changed: [], deleted: [], fullResyncRequired: false }),
|
|
),
|
|
"invalid_output",
|
|
);
|
|
expectCode(
|
|
() =>
|
|
parseChangesOutput(
|
|
JSON.stringify({ token: "t", changed: [], deleted: [7], fullResyncRequired: false }),
|
|
),
|
|
"invalid_output",
|
|
);
|
|
});
|
|
|
|
it("maps helper error lines to codes", () => {
|
|
const stderr =
|
|
'{"progress":0.5}\n{"error":{"code":"not_authorized","message":"Photos access has not been granted."}}\n';
|
|
const error = parseHelperError(stderr, 77);
|
|
expect(error.code).toBe("not_authorized");
|
|
expect(error.message).toBe("Photos access has not been granted.");
|
|
expect(parseHelperError('{"error":{"code":"internal","message":"x"}}', 70).code).toBe("failed");
|
|
expect(parseHelperError("Segmentation fault", 139).message).toContain("139");
|
|
expect(parseHelperError('{"error":"oops"}', 1).code).toBe("failed");
|
|
});
|
|
|
|
it("reads progress lines only", () => {
|
|
expect(parseProgressLine('{"progress":0.42}')).toBe(0.42);
|
|
expect(parseProgressLine('{"progress":2}')).toBeNull();
|
|
expect(parseProgressLine('{"error":{"code":"usage"}}')).toBeNull();
|
|
expect(parseProgressLine('{"progress":')).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("photos helper export result", () => {
|
|
let dir: string;
|
|
let other: string;
|
|
|
|
beforeEach(async () => {
|
|
dir = await mkdtemp(path.join(tmpdir(), "rakazo-photos-export-"));
|
|
other = await mkdtemp(path.join(tmpdir(), "rakazo-photos-other-"));
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await rm(dir, { recursive: true, force: true });
|
|
await rm(other, { recursive: true, force: true });
|
|
});
|
|
|
|
const result = (file: string, fileName = path.basename(file)) =>
|
|
JSON.stringify({ path: file, fileName, sizeBytes: 3, sha256: "a".repeat(64) });
|
|
|
|
it("accepts a file directly inside the export folder", async () => {
|
|
const file = path.join(dir, "IMG_0001.MOV");
|
|
await writeFile(file, "abc");
|
|
await expect(parseExportOutput(result(file), dir)).resolves.toMatchObject({
|
|
fileName: "IMG_0001.MOV",
|
|
sizeBytes: 3,
|
|
});
|
|
});
|
|
|
|
it("rejects files outside the export folder, including through symlinks", async () => {
|
|
const outside = path.join(other, "secret.txt");
|
|
await writeFile(outside, "abc");
|
|
await expect(parseExportOutput(result(outside), dir)).rejects.toMatchObject({
|
|
code: "path_rejected",
|
|
});
|
|
const link = path.join(dir, "link.MOV");
|
|
await symlink(outside, link);
|
|
await expect(parseExportOutput(result(link), dir)).rejects.toMatchObject({
|
|
code: "path_rejected",
|
|
});
|
|
const nested = path.join(dir, "..", path.basename(other), "secret.txt");
|
|
await expect(parseExportOutput(result(nested), dir)).rejects.toMatchObject({
|
|
code: "path_rejected",
|
|
});
|
|
});
|
|
|
|
it("rejects malformed results", async () => {
|
|
const file = path.join(dir, "a.jpg");
|
|
await writeFile(file, "abc");
|
|
for (const text of [
|
|
JSON.stringify({ path: file, fileName: "a.jpg", sizeBytes: 3, sha256: "XYZ" }),
|
|
JSON.stringify({ path: "a.jpg", fileName: "a.jpg", sizeBytes: 3, sha256: "a".repeat(64) }),
|
|
JSON.stringify({ path: file, fileName: "a.jpg", sizeBytes: -1, sha256: "a".repeat(64) }),
|
|
JSON.stringify({
|
|
path: path.join(dir, "missing.jpg"),
|
|
fileName: "missing.jpg",
|
|
sizeBytes: 3,
|
|
sha256: "a".repeat(64),
|
|
}),
|
|
]) {
|
|
await expect(parseExportOutput(text, dir)).rejects.toMatchObject({ code: "invalid_output" });
|
|
}
|
|
await expect(parseExportOutput(result(file, "b.jpg"), dir)).rejects.toMatchObject({
|
|
code: "path_rejected",
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("photos helper argv", () => {
|
|
it("builds fixed argv arrays", () => {
|
|
expect(listArgs({ cursor: null, limit: 500 })).toEqual(["list", "--limit", "500"]);
|
|
expect(listArgs({ cursor: "abc_-", limit: 10, since: "2024-01-01T00:00:00Z" })).toEqual([
|
|
"list",
|
|
"--limit",
|
|
"10",
|
|
"--cursor",
|
|
"abc_-",
|
|
"--since",
|
|
"2024-01-01T00:00:00Z",
|
|
]);
|
|
expect(exportArgs("ID/L0/001", "/tmp/rakazo-photos-1")).toEqual([
|
|
"export",
|
|
"ID/L0/001",
|
|
"--dest",
|
|
"/tmp/rakazo-photos-1",
|
|
"--original",
|
|
]);
|
|
expect(changesArgs("tok")).toEqual(["changes", "--token", "tok"]);
|
|
});
|
|
|
|
it("keeps shell syntax as inert data and rejects option-like values", () => {
|
|
// No shell runs, so metacharacters stay one literal argument.
|
|
expect(exportArgs("$(rm -rf ~);`x`", "/tmp/a b")[1]).toBe("$(rm -rf ~);`x`");
|
|
expectCode(() => exportArgs("--dest", "/tmp"), "usage");
|
|
expectCode(() => listArgs({ cursor: "--limit", limit: 1 }), "usage");
|
|
expectCode(() => changesArgs("a\nb"), "usage");
|
|
expectCode(() => listArgs({ cursor: null, limit: 501 }), "usage");
|
|
expectCode(() => exportArgs("ID", "relative/dir"), "path_rejected");
|
|
expectCode(() => exportArgs("ID", "/tmp/../etc"), "path_rejected");
|
|
});
|
|
|
|
it("passes only the environment PhotoKit needs", () => {
|
|
expect(
|
|
helperEnv({ HOME: "/Users/example", TMPDIR: "/tmp", OPENAI_API_KEY: "secret", PATH: "/bin" }),
|
|
).toEqual({ HOME: "/Users/example", TMPDIR: "/tmp" });
|
|
});
|
|
});
|
|
|
|
describe("photos helper binary", () => {
|
|
it("is only available on macOS", () => {
|
|
const options = {
|
|
packaged: true,
|
|
resourcesPath: "/App/Resources",
|
|
appPath: "/app",
|
|
exists: () => true,
|
|
};
|
|
expect(resolvePhotosHelperBinary({ ...options, platform: "linux" })).toBeNull();
|
|
expect(resolvePhotosHelperBinary({ ...options, platform: "win32" })).toBeNull();
|
|
expect(resolvePhotosHelperBinary({ ...options, platform: "darwin" })).toBe(
|
|
"/App/Resources/bin/photos-helper",
|
|
);
|
|
});
|
|
|
|
it("falls back to the development build and reports a missing build", () => {
|
|
const options = {
|
|
platform: "darwin",
|
|
packaged: false,
|
|
resourcesPath: "/r",
|
|
appPath: "/repo/apps/desktop",
|
|
};
|
|
expect(photosHelperCandidates(options)).toEqual([
|
|
"/repo/apps/desktop/native/photos-helper/.build/apple/Products/Release/photos-helper",
|
|
"/repo/apps/desktop/native/photos-helper/.build/release/photos-helper",
|
|
]);
|
|
expect(
|
|
resolvePhotosHelperBinary({
|
|
...options,
|
|
exists: (file) => file.endsWith(".build/release/photos-helper"),
|
|
}),
|
|
).toBe("/repo/apps/desktop/native/photos-helper/.build/release/photos-helper");
|
|
expect(resolvePhotosHelperBinary({ ...options, exists: () => false })).toBeNull();
|
|
});
|
|
});
|
|
|
|
class FakeProcess extends EventEmitter implements HelperProcess {
|
|
stdout = new PassThrough();
|
|
stderr = new PassThrough();
|
|
kill = vi.fn(() => {
|
|
queueMicrotask(() => this.emit("close", null));
|
|
return true;
|
|
});
|
|
exit(code: number, stdout = "", stderr = "") {
|
|
if (stdout) this.stdout.write(stdout);
|
|
if (stderr) this.stderr.write(stderr);
|
|
setImmediate(() => this.emit("close", code));
|
|
}
|
|
}
|
|
|
|
function fakeSpawn() {
|
|
const processes: FakeProcess[] = [];
|
|
const spawn = vi.fn<SpawnHelper>(() => {
|
|
const child = new FakeProcess();
|
|
processes.push(child);
|
|
return child;
|
|
});
|
|
return { spawn, processes };
|
|
}
|
|
|
|
describe("photos helper process", () => {
|
|
it("spawns the binary with argv and no shell", async () => {
|
|
const { spawn, processes } = fakeSpawn();
|
|
const helper = createPhotosHelper("/App/Resources/bin/photos-helper", spawn);
|
|
const pending = helper.list({ cursor: null, limit: 2 });
|
|
processes[0]!.exit(0, listJson([item()]));
|
|
await expect(pending).resolves.toMatchObject({ items: [item()] });
|
|
expect(spawn).toHaveBeenCalledWith(
|
|
"/App/Resources/bin/photos-helper",
|
|
["list", "--limit", "2"],
|
|
expect.objectContaining({ shell: false, stdio: ["ignore", "pipe", "pipe"] }),
|
|
);
|
|
});
|
|
|
|
it("maps a failing exit to the helper's error code", async () => {
|
|
const { spawn, processes } = fakeSpawn();
|
|
const pending = createPhotosHelper("/bin/photos-helper", spawn).asset("ID/L0/001");
|
|
processes[0]!.exit(
|
|
66,
|
|
"",
|
|
'{"error":{"code":"not_found","message":"No photo or video with that identifier."}}\n',
|
|
);
|
|
await expect(pending).rejects.toMatchObject({ code: "not_found" });
|
|
});
|
|
|
|
it("reports export progress and validates the result", async () => {
|
|
const dir = await mkdtemp(path.join(tmpdir(), "rakazo-photos-run-"));
|
|
try {
|
|
const file = path.join(dir, "IMG_0001.MOV");
|
|
await writeFile(file, "abc");
|
|
const { spawn, processes } = fakeSpawn();
|
|
const progress: number[] = [];
|
|
const pending = createPhotosHelper("/bin/photos-helper", spawn).exportOriginal("ID", dir, {
|
|
onProgress: (value) => progress.push(value),
|
|
});
|
|
const child = processes[0]!;
|
|
child.stderr.write('{"progress":0}\n{"progress":0.5');
|
|
child.stderr.write('}\nnoise\n{"progress":1}\n');
|
|
child.exit(
|
|
0,
|
|
JSON.stringify({
|
|
path: file,
|
|
fileName: "IMG_0001.MOV",
|
|
sizeBytes: 3,
|
|
sha256: "b".repeat(64),
|
|
}),
|
|
);
|
|
await expect(pending).resolves.toMatchObject({ sizeBytes: 3 });
|
|
expect(progress).toEqual([0, 0.5, 1]);
|
|
} finally {
|
|
await rm(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("kills the helper on abort and on timeout", async () => {
|
|
const { spawn, processes } = fakeSpawn();
|
|
const controller = new AbortController();
|
|
const aborted = runPhotosHelper("/bin/h", ["list"], {
|
|
spawn,
|
|
timeoutMs: 60_000,
|
|
signal: controller.signal,
|
|
});
|
|
controller.abort();
|
|
await expect(aborted).rejects.toMatchObject({ code: "cancelled" });
|
|
expect(processes[0]!.kill).toHaveBeenCalledWith("SIGTERM");
|
|
|
|
const timedOut = runPhotosHelper("/bin/h", ["list"], { spawn, timeoutMs: 5 });
|
|
await expect(timedOut).rejects.toMatchObject({ code: "timeout" });
|
|
expect(processes[1]!.kill).toHaveBeenCalled();
|
|
});
|
|
|
|
it("fails a silent export through the idle limit", async () => {
|
|
const { spawn, processes } = fakeSpawn();
|
|
const pending = runPhotosHelper("/bin/h", ["export"], {
|
|
spawn,
|
|
timeoutMs: 60_000,
|
|
idleTimeoutMs: 5,
|
|
});
|
|
await expect(pending).rejects.toMatchObject({ code: "timeout" });
|
|
expect(processes[0]!.kill).toHaveBeenCalled();
|
|
});
|
|
|
|
it("does not start when already aborted and reports spawn failures", async () => {
|
|
const { spawn, processes } = fakeSpawn();
|
|
const controller = new AbortController();
|
|
controller.abort();
|
|
await expect(
|
|
runPhotosHelper("/bin/h", ["list"], { spawn, timeoutMs: 1000, signal: controller.signal }),
|
|
).rejects.toMatchObject({ code: "cancelled" });
|
|
expect(spawn).not.toHaveBeenCalled();
|
|
|
|
const pending = runPhotosHelper("/bin/h", ["list"], { spawn, timeoutMs: 1000 });
|
|
processes[0]!.emit("error", new Error("ENOENT"));
|
|
await expect(pending).rejects.toMatchObject({ code: "unavailable" });
|
|
});
|
|
|
|
it("rejects oversized output", async () => {
|
|
const { spawn, processes } = fakeSpawn();
|
|
const pending = runPhotosHelper("/bin/h", ["list"], { spawn, timeoutMs: 1000 });
|
|
processes[0]!.stdout.write(Buffer.alloc(33 * 1024 * 1024));
|
|
await expect(pending).rejects.toMatchObject({ code: "invalid_output" });
|
|
});
|
|
});
|