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; /** Asks for Photos access on first use, then indexes and uploads what the server needs. */ sync: (request: { spaceId: string | null }) => Promise; cancel: () => Promise; 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; } 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 } | 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." }; }