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:
parent
2556ed197e
commit
a40582165a
32
.github/workflows/desktop-photos-helper.yml
vendored
Normal file
32
.github/workflows/desktop-photos-helper.yml
vendored
Normal 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
|
||||
12
.github/workflows/release-desktop.yml
vendored
12
.github/workflows/release-desktop.yml
vendored
@ -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'
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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 };
|
||||
|
||||
@ -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[];
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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"),
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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
6
pnpm-lock.yaml
generated
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user