From a40582165af3a4328eb42606c0dafb5294dac8df Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 18:15:53 +0000 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_0138YqDj3ttfayRwuMKKrqVj --- .github/workflows/desktop-photos-helper.yml | 32 ++++++++++ .github/workflows/release-desktop.yml | 12 ++++ apps/desktop/package.json | 15 ++++- apps/desktop/src/main.ts | 67 ++++++++++++++++++++- apps/desktop/src/media-ipc.ts | 27 +++------ apps/desktop/src/media-photos-helper.ts | 9 +-- apps/desktop/src/media-photos-sync.ts | 27 +++------ apps/desktop/src/preload.cjs | 12 ++++ apps/desktop/src/preload.test.ts | 8 +++ packages/contracts/src/desktop.ts | 44 ++++++++++++++ pnpm-lock.yaml | 6 ++ 11 files changed, 214 insertions(+), 45 deletions(-) create mode 100644 .github/workflows/desktop-photos-helper.yml diff --git a/.github/workflows/desktop-photos-helper.yml b/.github/workflows/desktop-photos-helper.yml new file mode 100644 index 0000000..0ca9077 --- /dev/null +++ b/.github/workflows/desktop-photos-helper.yml @@ -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 diff --git a/.github/workflows/release-desktop.yml b/.github/workflows/release-desktop.yml index bac3e64..b734d5d 100644 --- a/.github/workflows/release-desktop.yml +++ b/.github/workflows/release-desktop.yml @@ -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' diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 575a833..9ac6e33 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -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", diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 304e998..37b8f27 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -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 = 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; diff --git a/apps/desktop/src/media-ipc.ts b/apps/desktop/src/media-ipc.ts index bb8e109..98fec49 100644 --- a/apps/desktop/src/media-ipc.ts +++ b/apps/desktop/src/media-ipc.ts @@ -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; - /** 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 type { PhotosSourceStatus, PhotosSyncOutcome, RakazoDesktopMediaPhotos }; export interface MediaIpcEvent { sender: { send(channel: string, payload: unknown): void; isDestroyed(): boolean }; diff --git a/apps/desktop/src/media-photos-helper.ts b/apps/desktop/src/media-photos-helper.ts index f10e402..56cec57 100644 --- a/apps/desktop/src/media-photos-helper.ts +++ b/apps/desktop/src/media-photos-helper.ts @@ -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[]; diff --git a/apps/desktop/src/media-photos-sync.ts b/apps/desktop/src/media-photos-sync.ts index 72ea3f9..332b9d7 100644 --- a/apps/desktop/src/media-photos-sync.ts +++ b/apps/desktop/src/media-photos-sync.ts @@ -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; diff --git a/apps/desktop/src/preload.cjs b/apps/desktop/src/preload.cjs index 14e5c50..d453090 100644 --- a/apps/desktop/src/preload.cjs +++ b/apps/desktop/src/preload.cjs @@ -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"), diff --git a/apps/desktop/src/preload.test.ts b/apps/desktop/src/preload.test.ts index f5891c0..135d3ad 100644 --- a/apps/desktop/src/preload.test.ts +++ b/apps/desktop/src/preload.test.ts @@ -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", diff --git a/packages/contracts/src/desktop.ts b/packages/contracts/src/desktop.ts index 53b78aa..94d5e2e 100644 --- a/packages/contracts/src/desktop.ts +++ b/packages/contracts/src/desktop.ts @@ -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; + /** 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 RakazoDesktop { /** Only the isolated local settings window is authorized to call this bridge. */ localSettings?: { @@ -64,6 +106,8 @@ export interface RakazoDesktop { pickDirectory: () => Promise; }; platform: string; + /** Present on desktops that ship the Apple Photos helper. */ + media?: { photos: RakazoDesktopMediaPhotos }; window: { close: () => Promise; minimize: () => Promise; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 51dbf6b..f4167b1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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