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
136 lines
5.1 KiB
TypeScript
136 lines
5.1 KiB
TypeScript
import type { PhotosAuthorization, PhotosHelper } from "./media-photos-helper.js";
|
|
import { PhotosHelperError } from "./media-photos-helper.js";
|
|
import type { PhotosSyncProgress, PhotosSyncResult } from "./media-photos-sync.js";
|
|
import { PhotosSyncError } from "./media-photos-sync.js";
|
|
|
|
/*
|
|
* IPC for the Apple Photos source. The renderer only expresses intent (sync, cancel)
|
|
* and renders state; the main process owns the helper, the session and the upload.
|
|
*/
|
|
|
|
export const MEDIA_PHOTOS_CHANNELS = {
|
|
status: "desktop.media.photos.status",
|
|
sync: "desktop.media.photos.sync",
|
|
cancel: "desktop.media.photos.cancel",
|
|
progress: "desktop.media.photos.progress",
|
|
} as const;
|
|
|
|
export type PhotosSourceStatus =
|
|
| { available: false }
|
|
| { available: true; authorization: PhotosAuthorization; syncing: boolean };
|
|
|
|
export type PhotosSyncOutcome =
|
|
| { ok: true; result: PhotosSyncResult }
|
|
| { ok: false; code: string; message: string };
|
|
|
|
/** Renderer-facing bridge, exposed as `rakazoDesktop.media.photos`. */
|
|
export interface RakazoDesktopMediaPhotos {
|
|
status: () => Promise<PhotosSourceStatus>;
|
|
/** Asks for Photos access on first use, then indexes and uploads what the server needs. */
|
|
sync: (request: { spaceId: string | null }) => Promise<PhotosSyncOutcome>;
|
|
cancel: () => Promise<void>;
|
|
onProgress: (listener: (progress: PhotosSyncProgress) => void) => () => void;
|
|
}
|
|
|
|
export interface MediaIpcEvent {
|
|
sender: { send(channel: string, payload: unknown): void; isDestroyed(): boolean };
|
|
}
|
|
|
|
export interface MediaIpcMain {
|
|
handle(channel: string, listener: (event: MediaIpcEvent, ...args: unknown[]) => unknown): void;
|
|
}
|
|
|
|
export interface MediaIpcOptions {
|
|
ipcMain: MediaIpcMain;
|
|
platform: string;
|
|
/** Null when the helper binary is missing (unbuilt dev checkout). */
|
|
helper: PhotosHelper | null;
|
|
/** Same gate as the other app-window handlers: main window, main frame. */
|
|
isTrusted: (event: MediaIpcEvent) => boolean;
|
|
runSync: (request: {
|
|
spaceId: string | null;
|
|
signal: AbortSignal;
|
|
onProgress: (progress: PhotosSyncProgress) => void;
|
|
}) => Promise<PhotosSyncResult>;
|
|
}
|
|
|
|
const UNAVAILABLE = "Apple Photos is only available in the macOS app.";
|
|
const SPACE_ID = /^[A-Za-z0-9_-]{1,200}$/;
|
|
|
|
export function parseSyncRequest(value: unknown): { spaceId: string | null } {
|
|
if (typeof value !== "object" || value === null) throw new Error("Invalid sync request.");
|
|
const spaceId = (value as { spaceId?: unknown }).spaceId ?? null;
|
|
if (spaceId !== null && (typeof spaceId !== "string" || !SPACE_ID.test(spaceId))) {
|
|
throw new Error("Invalid sync request.");
|
|
}
|
|
return { spaceId };
|
|
}
|
|
|
|
/** Registers the handlers; `cancel()` stops a running sync, e.g. before quit. */
|
|
export function registerMediaIpc(options: MediaIpcOptions): { cancel: () => void } {
|
|
const available = options.platform === "darwin" && options.helper !== null;
|
|
let running: { controller: AbortController; done: Promise<PhotosSyncOutcome> } | null = null;
|
|
|
|
const requireTrusted = (event: MediaIpcEvent) => {
|
|
if (!options.isTrusted(event)) throw new Error("Not allowed.");
|
|
};
|
|
|
|
options.ipcMain.handle(MEDIA_PHOTOS_CHANNELS.status, async (event) => {
|
|
requireTrusted(event);
|
|
if (!available) return { available: false } satisfies PhotosSourceStatus;
|
|
try {
|
|
const authorization = await options.helper!.authorize(false);
|
|
return { available: true, authorization, syncing: running !== null };
|
|
} catch (error) {
|
|
if (error instanceof PhotosHelperError && error.code === "unavailable") {
|
|
return { available: false } satisfies PhotosSourceStatus;
|
|
}
|
|
throw error;
|
|
}
|
|
});
|
|
|
|
options.ipcMain.handle(MEDIA_PHOTOS_CHANNELS.sync, (event, request: unknown) => {
|
|
requireTrusted(event);
|
|
if (!available) throw new Error(UNAVAILABLE);
|
|
const { spaceId } = parseSyncRequest(request);
|
|
// A second click joins the running sync instead of starting a parallel one.
|
|
if (running) return running.done;
|
|
const controller = new AbortController();
|
|
const done = options
|
|
.runSync({
|
|
spaceId,
|
|
signal: controller.signal,
|
|
onProgress: (progress) => {
|
|
if (!event.sender.isDestroyed()) {
|
|
event.sender.send(MEDIA_PHOTOS_CHANNELS.progress, progress);
|
|
}
|
|
},
|
|
})
|
|
.then(
|
|
(result): PhotosSyncOutcome => ({ ok: true, result }),
|
|
(error: unknown): PhotosSyncOutcome => outcomeForError(error),
|
|
)
|
|
.finally(() => {
|
|
if (running?.controller === controller) running = null;
|
|
});
|
|
running = { controller, done };
|
|
return done;
|
|
});
|
|
|
|
options.ipcMain.handle(MEDIA_PHOTOS_CHANNELS.cancel, (event) => {
|
|
requireTrusted(event);
|
|
if (!available) throw new Error(UNAVAILABLE);
|
|
running?.controller.abort();
|
|
});
|
|
|
|
return { cancel: () => running?.controller.abort() };
|
|
}
|
|
|
|
/** Only our own fixed messages reach the renderer; anything else is generic. */
|
|
export function outcomeForError(error: unknown): PhotosSyncOutcome {
|
|
if (error instanceof PhotosSyncError || error instanceof PhotosHelperError) {
|
|
return { ok: false, code: error.code, message: error.message };
|
|
}
|
|
return { ok: false, code: "failed", message: "Apple Photos sync failed." };
|
|
}
|