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