feat(media): wire Apple Photos sync into the desktop app

Move the Photos bridge types into the desktop contract, expose
rakazoDesktop.media.photos from the preload, connect the sync runner in the
main process through an authenticated oRPC client, package and sign the
helper, and build and test it on macOS CI.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0138YqDj3ttfayRwuMKKrqVj
This commit is contained in:
Claude 2026-10-03 18:15:53 +00:00
parent 2556ed197e
commit a40582165a
No known key found for this signature in database
11 changed files with 214 additions and 45 deletions

View File

@ -0,0 +1,32 @@
name: desktop-photos-helper
on:
pull_request:
paths: &photos-helper-paths
- ".github/workflows/desktop-photos-helper.yml"
- "apps/desktop/native/photos-helper/**"
push:
branches: [main]
paths: *photos-helper-paths
permissions:
contents: read
concurrency:
group: desktop-photos-helper-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
swift:
name: Photos helper build and XCTest
runs-on: [self-hosted, evoo-ci, macOS]
timeout-minutes: 20
steps:
- uses: actions/checkout@v5
with:
persist-credentials: false
- name: Build and test
working-directory: apps/desktop/native/photos-helper
run: |
set -euo pipefail
swift build -c release
swift test

View File

@ -145,6 +145,14 @@ jobs:
umask 077
printf '%s\n' "$APPLE_API_KEY_P8" > "$APPLE_API_KEY"
chmod 600 "$APPLE_API_KEY"
- name: Build and test the universal Photos helper
if: runner.os == 'macOS'
working-directory: apps/desktop/native/photos-helper
run: |
set -euo pipefail
swift test
swift build -c release --arch arm64 --arch x86_64
lipo -verify_arch .build/apple/Products/Release/photos-helper arm64 x86_64
- name: Package signed and notarized universal macOS app
if: runner.os == 'macOS'
env:
@ -174,6 +182,10 @@ jobs:
grep -Fqx "provider: github" "$app/Contents/Resources/app-update.yml"
grep -Fqx "owner: elie222" "$app/Contents/Resources/app-update.yml"
grep -Fqx "repo: rakazo" "$app/Contents/Resources/app-update.yml"
helper="$app/Contents/Resources/bin/photos-helper"
codesign --verify --strict --verbose=2 "$helper"
codesign -d --entitlements - --xml "$helper" | grep -Fq "com.apple.security.personal-information.photos-library"
/usr/libexec/PlistBuddy -c 'Print :NSPhotoLibraryUsageDescription' "$app/Contents/Info.plist"
- name: Require Windows signing credentials
if: runner.os == 'Windows'

View File

@ -16,6 +16,8 @@
"release": "pnpm --filter @rakazo/web build && pnpm build && electron-builder --publish never"
},
"dependencies": {
"@orpc/client": "1.15.0",
"@orpc/contract": "1.15.0",
"@rakazo/contracts": "workspace:*",
"electron-updater": "6.8.9"
},
@ -61,7 +63,18 @@
"dmg",
"zip"
],
"notarize": true
"notarize": true,
"entitlements": "native/photos-helper/entitlements.mac.plist",
"entitlementsInherit": "native/photos-helper/entitlements.mac.plist",
"extendInfo": {
"NSPhotoLibraryUsageDescription": "Rakazo adds your photos and videos to the media library on your Rakazo server."
},
"extraResources": [
{
"from": "native/photos-helper/.build/apple/Products/Release/photos-helper",
"to": "bin/photos-helper"
}
]
},
"win": {
"icon": "assets/icon.ico",

View File

@ -2,7 +2,10 @@ import { randomBytes } from "node:crypto";
import { existsSync } from "node:fs";
import { readFile, stat, writeFile } from "node:fs/promises";
import path from "node:path";
import type { DesktopReachability, DesktopSetup } from "@rakazo/contracts";
import { createORPCClient } from "@orpc/client";
import { RPCLink } from "@orpc/client/fetch";
import type { ContractRouterClient } from "@orpc/contract";
import type { AppContract, DesktopReachability, DesktopSetup } from "@rakazo/contracts";
import { LOCAL_SETTINGS_PAGE } from "@rakazo/contracts/local-settings";
import {
app,
@ -31,6 +34,14 @@ import {
stackDir,
stackResourceDir,
} from "./local-stack.js";
import type { MediaIpcEvent } from "./media-ipc.js";
import { registerMediaIpc } from "./media-ipc.js";
import { createPhotosHelper, resolvePhotosHelperBinary } from "./media-photos-helper.js";
import {
createChunkUploader,
createPhotosSyncFileStore,
createPhotosSyncRunner,
} from "./media-photos-sync.js";
import { oauthCallbackFrom } from "./oauth-callback.js";
import {
bundledRendererCandidates,
@ -1129,6 +1140,60 @@ app.whenReady().then(async () => {
);
},
);
const photosBinary = resolvePhotosHelperBinary({
platform: process.platform,
packaged: app.isPackaged,
resourcesPath: process.resourcesPath,
appPath: app.getAppPath(),
exists: existsSync,
});
const photosHelper = photosBinary ? createPhotosHelper(photosBinary) : null;
const mediaIpc = registerMediaIpc({
ipcMain,
platform: process.platform,
helper: photosHelper,
isTrusted: (raw: MediaIpcEvent) => {
const event = raw as Electron.IpcMainInvokeEvent;
return fromMainWindow(event) && event.senderFrame === event.sender.mainFrame;
},
runSync: photosHelper
? createPhotosSyncRunner({
helper: photosHelper,
store: createPhotosSyncFileStore(userDataDir),
tempRoot: app.getPath("temp"),
label: "Apple Photos",
connect: async (spaceId) => {
if (currentTargetUrl === null) throw new Error("No server is open.");
const origin = new URL(currentTargetUrl).origin;
const target = (await resolveSessionForTarget(currentTargetUrl)).value;
const fetchWithSession = (input: string | Request, init?: RequestInit) =>
target.fetch(input, {
...init,
credentials: "include",
bypassCustomProtocolHandlers: true,
});
const api: ContractRouterClient<AppContract> = createORPCClient(
new RPCLink({
url: `${origin}/rpc`,
fetch: (request, init) => {
const headers = new Headers(request.headers);
if (spaceId) headers.set("x-rakazo-space-id", spaceId);
return fetchWithSession(new Request(request, { headers }), init);
},
}),
);
return {
origin,
api,
uploadChunk: createChunkUploader({ origin, spaceId, fetch: fetchWithSession }),
};
},
})
: async () => {
throw new Error("Apple Photos is unavailable.");
},
});
app.on("before-quit", mediaIpc.cancel);
ipcMain.handle("desktop.platform", () => process.platform);
ipcMain.handle("desktop.files.pickDirectory", async (event) => {
if (!fromMainWindow(event)) return null;

View File

@ -1,6 +1,12 @@
import type { PhotosAuthorization, PhotosHelper } from "./media-photos-helper.js";
import type {
PhotosSourceStatus,
PhotosSyncOutcome,
PhotosSyncProgress,
PhotosSyncResult,
RakazoDesktopMediaPhotos,
} from "@rakazo/contracts";
import type { 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";
/*
@ -15,22 +21,7 @@ export const MEDIA_PHOTOS_CHANNELS = {
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 type { PhotosSourceStatus, PhotosSyncOutcome, RakazoDesktopMediaPhotos };
export interface MediaIpcEvent {
sender: { send(channel: string, payload: unknown): void; isDestroyed(): boolean };

View File

@ -2,7 +2,7 @@ import { spawn as nodeSpawn } from "node:child_process";
import { realpath } from "node:fs/promises";
import path from "node:path";
import type { Readable } from "node:stream";
import type { ClientMediaItem } from "@rakazo/contracts";
import type { ClientMediaItem, PhotosAuthorization } from "@rakazo/contracts";
import { ClientMediaItemSchema } from "@rakazo/contracts";
/*
@ -11,12 +11,7 @@ import { ClientMediaItemSchema } from "@rakazo/contracts";
* the app uses it, so a malformed or unexpected answer fails instead of being guessed at.
*/
export type PhotosAuthorization =
| "authorized"
| "limited"
| "denied"
| "restricted"
| "notDetermined";
export type { PhotosAuthorization };
export interface PhotosListPage {
items: ClientMediaItem[];

View File

@ -2,7 +2,14 @@ import { createHash, randomUUID } from "node:crypto";
import { createReadStream } from "node:fs";
import { mkdtemp, open, rm, stat } from "node:fs/promises";
import path from "node:path";
import type { ClientMediaItem, MediaAsset, MediaUpload, mediaRpcShapes } from "@rakazo/contracts";
import type {
ClientMediaItem,
MediaAsset,
MediaUpload,
mediaRpcShapes,
PhotosSyncProgress,
PhotosSyncResult,
} from "@rakazo/contracts";
import type { PhotosHelper } from "./media-photos-helper.js";
import { PHOTOS_MAX_PAGE } from "./media-photos-helper.js";
import { readPrivateFile, writePrivateFile } from "./setup-store.js";
@ -197,23 +204,7 @@ export function createChunkUploader(options: {
// Sync
// ---------------------------------------------------------------------------
export interface PhotosSyncProgress {
phase: "authorizing" | "indexing" | "uploading";
/** Items pushed in this run. */
indexed: number;
uploadsDone: number;
uploadsTotal: number;
}
export interface PhotosSyncResult {
indexed: number;
created: number;
updated: number;
unchanged: number;
uploaded: number;
/** True when this run listed the whole library instead of reading changes. */
fullListing: boolean;
}
export type { PhotosSyncProgress, PhotosSyncResult };
export interface PhotosSyncOptions {
helper: PhotosHelper;

View File

@ -9,6 +9,18 @@ contextBridge.exposeInMainWorld("rakazoDesktop", {
request: (pathname, body) =>
ipcRenderer.invoke("desktop.localSettings.request", pathname, body),
},
media: {
photos: {
status: () => ipcRenderer.invoke("desktop.media.photos.status"),
sync: (request) => ipcRenderer.invoke("desktop.media.photos.sync", request),
cancel: () => ipcRenderer.invoke("desktop.media.photos.cancel"),
onProgress: (listener) => {
const handler = (_event, progress) => listener(progress);
ipcRenderer.on("desktop.media.photos.progress", handler);
return () => ipcRenderer.off("desktop.media.photos.progress", handler);
},
},
},
window: {
close: () => ipcRenderer.invoke("desktop.window.close"),
minimize: () => ipcRenderer.invoke("desktop.window.minimize"),

View File

@ -34,6 +34,7 @@ describe("desktop preload bridge", () => {
expect(Object.keys(bridge).sort()).toEqual([
"files",
"localSettings",
"media",
"oauth",
"platform",
"update",
@ -46,6 +47,12 @@ describe("desktop preload bridge", () => {
"toggleMaximize",
]);
expect(Object.keys(bridge.update).sort()).toEqual(["check", "download", "install", "state"]);
expect(Object.keys(bridge.media?.photos ?? {}).sort()).toEqual([
"cancel",
"onProgress",
"status",
"sync",
]);
await bridge.files?.pickDirectory();
await bridge.oauth.open?.("https://provider.example.com/authorize");
@ -79,6 +86,7 @@ describe("desktop preload bridge", () => {
expect(Object.keys(bridge).sort()).toEqual([
"files",
"localSettings",
"media",
"oauth",
"platform",
"update",

View File

@ -54,6 +54,48 @@ export interface RakazoDesktopBrowserPreview {
};
}
export type PhotosAuthorization =
| "authorized"
| "limited"
| "denied"
| "restricted"
| "notDetermined";
export type PhotosSourceStatus =
| { available: false }
| { available: true; authorization: PhotosAuthorization; syncing: boolean };
export interface PhotosSyncProgress {
phase: "authorizing" | "indexing" | "uploading";
/** Items pushed in this run. */
indexed: number;
uploadsDone: number;
uploadsTotal: number;
}
export interface PhotosSyncResult {
indexed: number;
created: number;
updated: number;
unchanged: number;
uploaded: number;
/** True when this run listed the whole library instead of reading changes. */
fullListing: boolean;
}
export type PhotosSyncOutcome =
| { ok: true; result: PhotosSyncResult }
| { ok: false; code: string; message: string };
/** Renderer-facing bridge, exposed as `rakazoDesktop.media.photos` on macOS desktops. */
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 RakazoDesktop {
/** Only the isolated local settings window is authorized to call this bridge. */
localSettings?: {
@ -64,6 +106,8 @@ export interface RakazoDesktop {
pickDirectory: () => Promise<string | null>;
};
platform: string;
/** Present on desktops that ship the Apple Photos helper. */
media?: { photos: RakazoDesktopMediaPhotos };
window: {
close: () => Promise<void>;
minimize: () => Promise<void>;

6
pnpm-lock.yaml generated
View File

@ -97,6 +97,12 @@ importers:
apps/desktop:
dependencies:
'@orpc/client':
specifier: 1.15.0
version: 1.15.0(@opentelemetry/api@1.9.1)
'@orpc/contract':
specifier: 1.15.0
version: 1.15.0(@opentelemetry/api@1.9.1)
'@rakazo/contracts':
specifier: workspace:*
version: link:../../packages/contracts