From a368e661d9b48284310ebb7dcb9fb95951cef66a Mon Sep 17 00:00:00 2001 From: Davidjayan Date: Thu, 12 Nov 2026 09:20:00 +0530 Subject: [PATCH 1/3] feat(drive): sync recordings and exports to Google Drive Connect a Google account from the new Google Drive dialog (app menu). While sync is on, each new recording is uploaded to an OpenScreen folder in the user's Drive, and each MP4 export of a project replaces that project's Drive file in place, so its link always opens the latest edit. Local files are never touched, and a failed upload never fails the recording or export. - Settings: OAuth client (or OPENSCREEN_GOOGLE_CLIENT_ID/_SECRET), connect, a Sync to Google Drive switch, disconnect. No client ID ships. - Load Project: a Drive icon on synced rows, and a row menu with Share drive link, Edit drive visibility and Unsync from drive. - Main process: loopback OAuth with PKCE and the drive.file scope, resumable streamed uploads, credentials in safeStorage. - Strings in all 15 locales, an architecture doc, manual checklist items, and the FAQ no longer says there is no cloud storage. Co-Authored-By: Claude Opus 5.5 --- electron/drive/google-drive-api.test.ts | 182 ++++++++++ electron/drive/google-drive-api.ts | 160 +++++++++ electron/drive/google-drive-service.test.ts | 313 ++++++++++++++++++ electron/drive/google-drive-service.ts | 282 ++++++++++++++++ electron/drive/google-drive-store.test.ts | 86 +++++ electron/drive/google-drive-store.ts | 164 +++++++++ electron/drive/google-oauth.test.ts | 145 ++++++++ electron/drive/google-oauth.ts | 233 +++++++++++++ electron/ipc/handlers.ts | 9 + electron/ipc/nativeBridge.drive.test.ts | 99 ++++++ electron/ipc/nativeBridge.ts | 87 +++++ src/App.tsx | 6 + src/components/ai-edition/ExportDialog.tsx | 15 + .../ai-edition/GoogleDriveSettings.test.tsx | 96 ++++++ .../ai-edition/GoogleDriveSettings.tsx | 214 ++++++++++++ src/components/ai-edition/Modals.tsx | 99 +++++- src/components/ai-edition/NewEditorShell.tsx | 28 +- .../OpenProjectModal.drive.test.tsx | 151 +++++++++ .../ai-edition/ProjectDriveMenu.tsx | 246 ++++++++++++++ .../ai-edition/ProviderSettings.test.tsx | 1 + src/components/ai-edition/recordingImport.ts | 14 +- .../v4/EditorTopBar.stylePresets.test.tsx | 1 + .../ai-edition/v4/EditorTopBar.test.tsx | 12 + src/components/ai-edition/v4/EditorTopBar.tsx | 12 + src/contexts/EditorDialogsContext.tsx | 2 +- src/i18n/locales/ar/editor.json | 42 +++ src/i18n/locales/cs/editor.json | 42 +++ src/i18n/locales/de/editor.json | 42 +++ src/i18n/locales/en/editor.json | 42 +++ src/i18n/locales/es/editor.json | 42 +++ src/i18n/locales/fr/editor.json | 42 +++ src/i18n/locales/it/editor.json | 42 +++ src/i18n/locales/ja-JP/editor.json | 42 +++ src/i18n/locales/ko-KR/editor.json | 42 +++ src/i18n/locales/pt-BR/editor.json | 42 +++ src/i18n/locales/ru/editor.json | 42 +++ src/i18n/locales/tr/editor.json | 42 +++ src/i18n/locales/vi/editor.json | 42 +++ src/i18n/locales/zh-CN/editor.json | 42 +++ src/i18n/locales/zh-TW/editor.json | 42 +++ src/lib/driveSync.test.ts | 36 ++ src/lib/driveSync.ts | 30 ++ src/native/browserShim.ts | 15 + src/native/client.ts | 46 +++ src/native/contracts.ts | 92 +++++ technical-documentation/README.md | 1 + .../architecture/google-drive-sync.md | 46 +++ .../testing/manual-e2e-checklist.md | 14 + website/docs/faq.md | 2 +- .../current/faq.md | 2 +- .../current/faq.md | 2 +- .../current/faq.md | 2 +- .../current/faq.md | 2 +- .../current/faq.md | 2 +- .../current/faq.md | 2 +- .../current/faq.md | 2 +- 56 files changed, 3562 insertions(+), 21 deletions(-) create mode 100644 electron/drive/google-drive-api.test.ts create mode 100644 electron/drive/google-drive-api.ts create mode 100644 electron/drive/google-drive-service.test.ts create mode 100644 electron/drive/google-drive-service.ts create mode 100644 electron/drive/google-drive-store.test.ts create mode 100644 electron/drive/google-drive-store.ts create mode 100644 electron/drive/google-oauth.test.ts create mode 100644 electron/drive/google-oauth.ts create mode 100644 electron/ipc/nativeBridge.drive.test.ts create mode 100644 src/components/ai-edition/GoogleDriveSettings.test.tsx create mode 100644 src/components/ai-edition/GoogleDriveSettings.tsx create mode 100644 src/components/ai-edition/OpenProjectModal.drive.test.tsx create mode 100644 src/components/ai-edition/ProjectDriveMenu.tsx create mode 100644 src/lib/driveSync.test.ts create mode 100644 src/lib/driveSync.ts create mode 100644 technical-documentation/architecture/google-drive-sync.md diff --git a/electron/drive/google-drive-api.test.ts b/electron/drive/google-drive-api.test.ts new file mode 100644 index 0000000000..c924c72c1d --- /dev/null +++ b/electron/drive/google-drive-api.test.ts @@ -0,0 +1,182 @@ +import { mkdtemp, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { DriveApiError, GoogleDriveApi, videoMimeType } from "./google-drive-api"; + +type Call = { url: string; method: string; headers: Headers; body: unknown }; + +/** A fake Drive: records every call and answers from `routes` in order. */ +function fakeDrive(routes: Array<(call: Call) => Response>) { + const calls: Call[] = []; + const fetchImpl = vi.fn(async (input: RequestInfo | URL, init: RequestInit = {}) => { + const call: Call = { + url: String(input), + method: init.method ?? "GET", + headers: new Headers(init.headers), + body: init.body, + }; + calls.push(call); + const route = routes.shift(); + if (!route) throw new Error(`unexpected call ${call.method} ${call.url}`); + return route(call); + }); + return { + calls, + api: new GoogleDriveApi(async () => "token", fetchImpl as unknown as typeof fetch), + }; +} + +const json = (body: unknown, status = 200) => + new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" } }); + +let video: string; +beforeEach(async () => { + const dir = await mkdtemp(path.join(os.tmpdir(), "drive-api-")); + video = path.join(dir, "take.mp4"); + await writeFile(video, Buffer.alloc(1234, 1)); +}); + +describe("videoMimeType", () => { + it("knows the video containers and nothing else", () => { + expect(videoMimeType("/a/b.MP4")).toBe("video/mp4"); + expect(videoMimeType("/a/b.webm")).toBe("video/webm"); + expect(videoMimeType("/a/b.gif")).toBeNull(); + expect(videoMimeType("/a/b.json")).toBeNull(); + }); +}); + +describe("GoogleDriveApi.upload", () => { + it("creates a new file in the folder through a resumable session", async () => { + const { calls, api } = fakeDrive([ + () => + new Response(null, { + status: 200, + headers: { Location: "https://upload.example/session?upload_id=1" }, + }), + () => json({ id: "file-1", webViewLink: "https://drive/file-1" }), + ]); + const uploaded = await api.upload({ filePath: video, name: "Demo.mp4", folderId: "folder" }); + expect(uploaded).toEqual({ id: "file-1", webViewLink: "https://drive/file-1" }); + expect(calls[0].method).toBe("POST"); + expect(calls[0].url).toContain("/upload/drive/v3/files?uploadType=resumable"); + expect(calls[0].headers.get("authorization")).toBe("Bearer token"); + expect(calls[0].headers.get("x-upload-content-length")).toBe("1234"); + expect(JSON.parse(String(calls[0].body))).toEqual({ + name: "Demo.mp4", + mimeType: "video/mp4", + parents: ["folder"], + }); + expect(calls[1].method).toBe("PUT"); + expect(new URL(calls[1].url).searchParams.get("upload_id")).toBe("1"); + expect(calls[1].headers.get("content-length")).toBe("1234"); + }); + + it("replaces an existing file's content in place, keeping its id", async () => { + const { calls, api } = fakeDrive([ + () => new Response(null, { headers: { Location: "https://upload.example/s?upload_id=2" } }), + () => json({ id: "file-1", webViewLink: "https://drive/file-1" }), + ]); + const uploaded = await api.upload({ filePath: video, name: "Demo.mp4", fileId: "file-1" }); + expect(uploaded.id).toBe("file-1"); + expect(calls[0].method).toBe("PATCH"); + expect(calls[0].url).toContain("/upload/drive/v3/files/file-1?uploadType=resumable"); + // A file keeps its folder: parents are only set when creating. + expect(JSON.parse(String(calls[0].body)).parents).toBeUndefined(); + }); + + it("reports Drive's own error with its status", async () => { + const { api } = fakeDrive([() => json({ error: { message: "File not found: x." } }, 404)]); + const failure = await api + .upload({ filePath: video, name: "Demo.mp4", fileId: "x" }) + .catch((error: unknown) => error); + expect(failure).toBeInstanceOf(DriveApiError); + expect((failure as DriveApiError).status).toBe(404); + expect((failure as Error).message).toBe("File not found: x."); + }); + + it("refuses anything that is not a video", async () => { + const { api } = fakeDrive([]); + await expect(api.upload({ filePath: "/x/notes.txt", name: "n" })).rejects.toThrow(/video/); + }); +}); + +describe("GoogleDriveApi.setVisibility", () => { + it("adds the anyone-with-the-link permission once", async () => { + const { calls, api } = fakeDrive([ + () => json({ permissions: [{ id: "owner", type: "user" }] }), + () => json({ id: "anyoneWithLink" }), + ]); + await api.setVisibility("file-1", "anyone"); + expect(calls[1].method).toBe("POST"); + expect(JSON.parse(String(calls[1].body))).toEqual({ type: "anyone", role: "reader" }); + }); + + it("removes it to make the file private again", async () => { + const { calls, api } = fakeDrive([ + () => + json({ + permissions: [ + { id: "owner", type: "user" }, + { id: "anyoneWithLink", type: "anyone" }, + ], + }), + () => new Response(null, { status: 204 }), + ]); + await api.setVisibility("file-1", "private"); + expect(calls[1].method).toBe("DELETE"); + expect(calls[1].url).toMatch(/permissions\/anyoneWithLink$/); + }); +}); + +describe("GoogleDriveApi.ensureFolder", () => { + it("reuses the folder it created before", async () => { + const { calls, api } = fakeDrive([() => json({ files: [{ id: "folder-1" }] })]); + expect(await api.ensureFolder()).toBe("folder-1"); + expect(calls).toHaveLength(1); + }); + + it("creates it when there is none", async () => { + const { calls, api } = fakeDrive([() => json({ files: [] }), () => json({ id: "folder-2" })]); + expect(await api.ensureFolder()).toBe("folder-2"); + expect(JSON.parse(String(calls[1].body))).toEqual({ + name: "OpenScreen", + mimeType: "application/vnd.google-apps.folder", + }); + }); +}); + +describe("the streamed upload, through Node's own fetch", () => { + it("sends every byte of the file in the PUT", async () => { + const { createServer } = await import("node:http"); + let received = 0; + const server = createServer((req, res) => { + req.on("data", (chunk: Buffer) => { + received += chunk.length; + }); + req.on("end", () => { + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ id: "file-1", webViewLink: "https://drive/file-1" })); + }); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const { port } = server.address() as import("node:net").AddressInfo; + try { + // The session request goes to a fake Drive; the PUT to the session URL is real fetch. + const fetchImpl = (async (input: RequestInfo | URL, init?: RequestInit) => + String(input).startsWith("https://www.googleapis.com/") + ? new Response(null, { + headers: { Location: `http://127.0.0.1:${port}/upload?upload_id=3` }, + }) + : fetch(input, init)) as typeof fetch; + const api = new GoogleDriveApi(async () => "token", fetchImpl); + expect(await api.upload({ filePath: video, name: "Demo.mp4", folderId: "f" })).toEqual({ + id: "file-1", + webViewLink: "https://drive/file-1", + }); + expect(received).toBe(1234); + } finally { + server.close(); + } + }); +}); diff --git a/electron/drive/google-drive-api.ts b/electron/drive/google-drive-api.ts new file mode 100644 index 0000000000..9c5201a11f --- /dev/null +++ b/electron/drive/google-drive-api.ts @@ -0,0 +1,160 @@ +// The handful of Drive v3 REST calls sync needs: a folder, a resumable upload that creates a +// file or replaces the content of an existing one, and the "anyone with the link" permission. +// +// Plain fetch rather than `googleapis`: five endpoints do not justify a large dependency in +// the main process. Replacing a file's content keeps its id, so its link stays the same +// across every export of a project — that is the whole point of updating rather than +// uploading a new file each time. + +import { createReadStream } from "node:fs"; +import { stat } from "node:fs/promises"; +import path from "node:path"; +import { Readable } from "node:stream"; +import type { DriveVisibility } from "../../src/native/contracts"; +import type { FetchLike } from "./google-oauth"; + +const DRIVE_API = "https://www.googleapis.com/drive/v3"; +const DRIVE_UPLOAD = "https://www.googleapis.com/upload/drive/v3"; +export const DRIVE_FOLDER_NAME = "OpenScreen"; +const FOLDER_MIME = "application/vnd.google-apps.folder"; + +const VIDEO_MIME_TYPES: Record = { + ".mp4": "video/mp4", + ".webm": "video/webm", + ".mov": "video/quicktime", + ".mkv": "video/x-matroska", +}; + +/** The video's MIME type, or null for anything sync does not upload. */ +export function videoMimeType(filePath: string): string | null { + return VIDEO_MIME_TYPES[path.extname(filePath).toLowerCase()] ?? null; +} + +export class DriveApiError extends Error { + constructor( + message: string, + readonly status: number, + ) { + super(message); + this.name = "DriveApiError"; + } +} + +export interface DriveUploadedFile { + id: string; + webViewLink: string; +} + +export class GoogleDriveApi { + constructor( + private readonly accessToken: () => Promise, + private readonly fetchImpl: FetchLike = fetch, + ) {} + + private async request(url: string, init: RequestInit = {}): Promise { + const headers = new Headers(init.headers); + headers.set("Authorization", `Bearer ${await this.accessToken()}`); + const response = await this.fetchImpl(url, { ...init, headers }); + if (!response.ok) { + const body = (await response.json().catch(() => null)) as { + error?: { message?: string }; + } | null; + throw new DriveApiError( + body?.error?.message ?? `Google Drive request failed: HTTP ${response.status}`, + response.status, + ); + } + return response; + } + + /** The `OpenScreen` folder this app created, or a new one. `drive.file` only lists files + * the app created, so a same-named folder of the user's own is never picked up. */ + async ensureFolder(): Promise { + const query = `mimeType='${FOLDER_MIME}' and name='${DRIVE_FOLDER_NAME}' and trashed=false`; + const list = (await ( + await this.request(`${DRIVE_API}/files?q=${encodeURIComponent(query)}&fields=files(id)`) + ).json()) as { files?: Array<{ id: string }> }; + const existing = list.files?.[0]?.id; + if (existing) return existing; + const created = (await ( + await this.request(`${DRIVE_API}/files?fields=id`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: DRIVE_FOLDER_NAME, mimeType: FOLDER_MIME }), + }) + ).json()) as { id: string }; + return created.id; + } + + /** + * Uploads `filePath` as a new file in `folderId`, or, with `fileId`, replaces that file's + * content and name in place. Resumable protocol, sent in one PUT: recordings run to + * gigabytes, and a multipart upload would hold the whole file in memory. + */ + async upload(options: { + filePath: string; + name: string; + folderId?: string; + fileId?: string; + }): Promise { + const mimeType = videoMimeType(options.filePath); + if (!mimeType) throw new Error("Only video files are uploaded to Google Drive."); + const { size } = await stat(options.filePath); + const metadata: Record = { name: options.name, mimeType }; + if (!options.fileId && options.folderId) metadata.parents = [options.folderId]; + const session = await this.request( + options.fileId + ? `${DRIVE_UPLOAD}/files/${encodeURIComponent(options.fileId)}?uploadType=resumable` + : `${DRIVE_UPLOAD}/files?uploadType=resumable`, + { + method: options.fileId ? "PATCH" : "POST", + headers: { + "Content-Type": "application/json; charset=UTF-8", + "X-Upload-Content-Type": mimeType, + "X-Upload-Content-Length": String(size), + }, + body: JSON.stringify(metadata), + }, + ); + const location = session.headers.get("location"); + if (!location) throw new DriveApiError("Google Drive returned no upload session.", 0); + const uploadUrl = new URL(location); + uploadUrl.searchParams.set("fields", "id,webViewLink"); + const uploaded = (await ( + await this.request(uploadUrl.toString(), { + method: "PUT", + headers: { "Content-Type": mimeType, "Content-Length": String(size) }, + body: Readable.toWeb(createReadStream(options.filePath)) as ReadableStream, + // Required by Node's fetch for a streamed request body. + duplex: "half", + } as RequestInit) + ).json()) as Partial; + if (!uploaded.id) throw new DriveApiError("Google Drive returned no file id.", 0); + return { + id: uploaded.id, + webViewLink: uploaded.webViewLink ?? `https://drive.google.com/file/d/${uploaded.id}/view`, + }; + } + + /** "anyone": anyone with the link can view. "private": only the owner and people they + * shared with by name, i.e. the "anyone" permission removed. */ + async setVisibility(fileId: string, visibility: DriveVisibility): Promise { + const base = `${DRIVE_API}/files/${encodeURIComponent(fileId)}/permissions`; + const { permissions = [] } = (await ( + await this.request(`${base}?fields=permissions(id,type)`) + ).json()) as { permissions?: Array<{ id: string; type: string }> }; + const anyone = permissions.filter((permission) => permission.type === "anyone"); + if (visibility === "anyone") { + if (anyone.length > 0) return; + await this.request(base, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ type: "anyone", role: "reader" }), + }); + return; + } + for (const permission of anyone) { + await this.request(`${base}/${encodeURIComponent(permission.id)}`, { method: "DELETE" }); + } + } +} diff --git a/electron/drive/google-drive-service.test.ts b/electron/drive/google-drive-service.test.ts new file mode 100644 index 0000000000..9a53784efe --- /dev/null +++ b/electron/drive/google-drive-service.test.ts @@ -0,0 +1,313 @@ +import { mkdtemp, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + DRIVE_CLIENT_ID_ENV, + DRIVE_CLIENT_SECRET_ENV, + driveFileName, + GoogleDriveService, +} from "./google-drive-service"; +import { type DriveCredentialCrypto, GoogleDriveStore } from "./google-drive-store"; +import type { signInWithGoogle } from "./google-oauth"; + +const crypto: DriveCredentialCrypto = { + isEncryptionAvailable: () => true, + encryptString: (plain) => Buffer.from(plain), + decryptString: (encrypted) => encrypted.toString(), +}; + +const json = (body: unknown, status = 200) => + new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" } }); + +/** + * A small in-memory Drive behind fetch: files by id, an upload session per request, the + * token endpoint. Enough to follow a project through create, replace and share. + */ +function fakeGoogle() { + const files = new Map(); + let nextId = 1; + const sessions = new Map(); + const tokenRequests: URLSearchParams[] = []; + let tokenError: { error: string } | null = null; + const fetchImpl = vi.fn(async (input: RequestInfo | URL, init: RequestInit = {}) => { + const url = new URL(String(input)); + const method = init.method ?? "GET"; + if (url.hostname === "oauth2.googleapis.com") { + if (url.pathname === "/revoke") return new Response(null); + const params = new URLSearchParams(String(init.body)); + tokenRequests.push(params); + if (tokenError) return json(tokenError, 400); + return json({ access_token: `access-${tokenRequests.length}`, expires_in: 3600 }); + } + if (url.pathname === "/drive/v3/files" && method === "GET") return json({ files: [] }); + if (url.pathname === "/drive/v3/files" && method === "POST") return json({ id: "folder" }); + const upload = url.pathname.match(/^\/upload\/drive\/v3\/files(?:\/([^/]+))?$/); + if (upload && (method === "POST" || method === "PATCH")) { + const fileId = upload[1] ? decodeURIComponent(upload[1]) : null; + if (fileId && !files.has(fileId)) return json({ error: { message: "File not found" } }, 404); + const session = `s${sessions.size + 1}`; + sessions.set(session, { fileId, name: JSON.parse(String(init.body)).name }); + return new Response(null, { + headers: { Location: `https://upload.example/${session}?upload_id=${session}` }, + }); + } + if (url.hostname === "upload.example" && method === "PUT") { + const session = sessions.get(url.pathname.slice(1)); + if (!session) throw new Error("no session"); + const id = session.fileId ?? `file-${nextId++}`; + const bytes = Number(new Headers(init.headers).get("content-length")); + files.set(id, { name: session.name, bytes, anyone: files.get(id)?.anyone ?? false }); + return json({ id, webViewLink: `https://drive.google.com/file/d/${id}/view` }); + } + const permissions = url.pathname.match(/^\/drive\/v3\/files\/([^/]+)\/permissions(?:\/(.+))?$/); + if (permissions) { + const file = files.get(permissions[1]); + if (!file) return json({ error: { message: "File not found" } }, 404); + if (method === "GET") { + return json({ permissions: file.anyone ? [{ id: "anyoneWithLink", type: "anyone" }] : [] }); + } + file.anyone = method === "POST"; + return method === "POST" + ? json({ id: "anyoneWithLink" }) + : new Response(null, { status: 204 }); + } + throw new Error(`unexpected ${method} ${url}`); + }); + return { + files, + tokenRequests, + fetchImpl: fetchImpl as unknown as typeof fetch, + failTokens: (error: string) => { + tokenError = { error }; + }, + }; +} + +let dir: string; +let take: string; +let exported: string; +beforeEach(async () => { + dir = await mkdtemp(path.join(os.tmpdir(), "drive-service-")); + take = path.join(dir, "recording-1.webm"); + exported = path.join(dir, "Demo.mp4"); + await writeFile(take, Buffer.alloc(100)); + await writeFile(exported, Buffer.alloc(250)); +}); + +function makeService( + options: { + env?: Record; + google?: ReturnType; + } = {}, +) { + const google = options.google ?? fakeGoogle(); + const store = new GoogleDriveStore(dir, crypto); + const signIn = vi.fn(async () => ({ + accessToken: "access-0", + expiresAt: Date.now() + 3_600_000, + refreshToken: "refresh", + email: "me@example.com", + })) as unknown as typeof signInWithGoogle; + const service = new GoogleDriveService({ + store, + openExternal: async () => undefined, + env: options.env ?? {}, + fetchImpl: google.fetchImpl, + signIn, + }); + return { service, store, google, signIn }; +} + +async function connected(options: Parameters[0] = {}) { + const made = makeService(options); + await made.service.setClientCredentials("client-id", "client-secret"); + await made.service.connect(); + return made; +} + +describe("driveFileName", () => { + it("names the file after the project, with the video's own extension", () => { + expect(driveFileName("Demo: v2/final", "/x/out.MP4")).toBe("Demo v2 final.mp4"); + expect(driveFileName(" ", "/x/take.webm")).toBe("OpenScreen recording.webm"); + }); +}); + +describe("GoogleDriveService connection", () => { + it("needs a client before it can connect", async () => { + const { service } = makeService(); + expect(service.getStatus()).toMatchObject({ configured: false, connected: false }); + await expect(service.connect()).rejects.toThrow(/client ID/); + }); + + it("takes the client from the environment over settings", async () => { + const { service, signIn } = makeService({ + env: { [DRIVE_CLIENT_ID_ENV]: "env-id", [DRIVE_CLIENT_SECRET_ENV]: "env-secret" }, + }); + expect(service.getStatus()).toMatchObject({ configured: true, clientFromEnv: true }); + await service.connect(); + expect(signIn).toHaveBeenCalledWith( + { clientId: "env-id", clientSecret: "env-secret" }, + expect.anything(), + ); + }); + + it("turns sync on with the connection, and off again on request", async () => { + const { service } = await connected(); + expect(service.getStatus()).toMatchObject({ + connected: true, + accountEmail: "me@example.com", + syncEnabled: true, + }); + expect((await service.setSyncEnabled(false)).syncEnabled).toBe(false); + }); + + it("cannot turn sync on without a connection", async () => { + const { service } = makeService(); + await expect(service.setSyncEnabled(true)).rejects.toThrow(/Connect/); + }); + + it("forgets the account on disconnect but keeps what is already in Drive", async () => { + const { service } = await connected(); + await service.syncProject("proj_1", "Demo", take); + const status = await service.disconnect(); + expect(status).toMatchObject({ connected: false, syncEnabled: false }); + expect(service.listProjects().proj_1.fileId).toBe("file-1"); + }); +}); + +describe("GoogleDriveService.syncProject", () => { + it("uploads nothing while sync is off", async () => { + const { service, google } = await connected(); + await service.setSyncEnabled(false); + expect(await service.syncProject("proj_1", "Demo", take)).toEqual({ + uploaded: false, + state: null, + }); + expect(google.files.size).toBe(0); + }); + + it("puts each export of a project in the same Drive file, so the link never changes", async () => { + const { service, google } = await connected(); + const first = await service.syncProject("proj_1", "Demo", take); + const second = await service.syncProject("proj_1", "Demo", exported); + expect(first.state?.fileId).toBe("file-1"); + expect(second.state?.fileId).toBe("file-1"); + expect(second.state?.webViewLink).toBe(first.state?.webViewLink); + expect(google.files.size).toBe(1); + expect(google.files.get("file-1")).toMatchObject({ name: "Demo.mp4", bytes: 250 }); + }); + + it("keeps one project's uploads in order when they overlap", async () => { + const { service, google } = await connected(); + const [recording, exportUpload] = await Promise.all([ + service.syncProject("proj_1", "Demo", take), + service.syncProject("proj_1", "Demo", exported), + ]); + expect(recording.state?.fileId).toBe(exportUpload.state?.fileId); + expect(google.files.get("file-1")?.bytes).toBe(250); + }); + + it("gives separate projects separate files", async () => { + const { service } = await connected(); + await service.syncProject("proj_1", "One", take); + await service.syncProject("proj_2", "Two", take); + const states = service.listProjects(); + expect(states.proj_1.fileId).not.toBe(states.proj_2.fileId); + }); + + it("uploads a new file when the old one was deleted in Drive", async () => { + const { service, google } = await connected(); + await service.syncProject("proj_1", "Demo", take); + google.files.delete("file-1"); + const result = await service.syncProject("proj_1", "Demo", exported); + expect(result.state?.fileId).toBe("file-2"); + }); + + it("refuses a missing file before anything reaches Drive", async () => { + const { service, google } = await connected(); + await expect( + service.syncProject("proj_1", "Demo", path.join(dir, "missing.mp4")), + ).rejects.toThrow(); + expect(google.files.size).toBe(0); + expect(service.listProjects().proj_1).toBeUndefined(); + }); + + it("shows a failed upload in the list, and clears it on the next success", async () => { + const google = fakeGoogle(); + const { service, store } = await connected({ google }); + const restarted = new GoogleDriveService({ + store, + openExternal: async () => undefined, + env: {}, + fetchImpl: google.fetchImpl, + }); + google.failTokens("temporarily_unavailable"); + await expect(restarted.syncProject("proj_1", "Demo", take)).rejects.toThrow(); + expect(restarted.listProjects().proj_1).toMatchObject({ + status: "error", + error: "temporarily_unavailable", + }); + expect(restarted.getStatus().connected).toBe(true); + await service.syncProject("proj_1", "Demo", take); + expect(service.listProjects().proj_1.status).toBe("synced"); + }); + + it("refuses a file that is not a video", async () => { + const { service } = await connected(); + await expect(service.syncProject("proj_1", "Demo", path.join(dir, "x.json"))).rejects.toThrow( + /video/, + ); + }); + + it("disconnects when Google says the refresh token was revoked", async () => { + const google = fakeGoogle(); + const { service, store } = await connected({ google }); + // A fresh instance has no access token in memory, so it has to refresh. + const restarted = new GoogleDriveService({ + store, + openExternal: async () => undefined, + env: {}, + fetchImpl: google.fetchImpl, + }); + google.failTokens("invalid_grant"); + await expect(restarted.syncProject("proj_1", "Demo", take)).rejects.toThrow(); + expect(restarted.getStatus().connected).toBe(false); + expect(service).toBeDefined(); + }); +}); + +describe("GoogleDriveService visibility and unsync", () => { + it("shares with anyone with the link, and makes it private again", async () => { + const { service, google } = await connected(); + await service.syncProject("proj_1", "Demo", take); + expect((await service.setVisibility("proj_1", "anyone")).visibility).toBe("anyone"); + expect(google.files.get("file-1")?.anyone).toBe(true); + expect((await service.setVisibility("proj_1", "private")).visibility).toBe("private"); + expect(google.files.get("file-1")?.anyone).toBe(false); + }); + + it("keeps the visibility across later exports", async () => { + const { service } = await connected(); + await service.syncProject("proj_1", "Demo", take); + await service.setVisibility("proj_1", "anyone"); + const after = await service.syncProject("proj_1", "Demo", exported); + expect(after.state?.visibility).toBe("anyone"); + }); + + it("unsyncs for good: the Drive copy stays, later exports stay local", async () => { + const { service, google } = await connected(); + await service.syncProject("proj_1", "Demo", take); + await service.unsync("proj_1"); + expect(service.listProjects().proj_1).toBeUndefined(); + expect(google.files.has("file-1")).toBe(true); + expect((await service.syncProject("proj_1", "Demo", exported)).uploaded).toBe(false); + // Other projects still sync. + expect((await service.syncProject("proj_2", "Other", take)).uploaded).toBe(true); + }); + + it("cannot change the visibility of a project that is not in Drive", async () => { + const { service } = await connected(); + await expect(service.setVisibility("proj_9", "anyone")).rejects.toThrow(/not in Google Drive/); + }); +}); diff --git a/electron/drive/google-drive-service.ts b/electron/drive/google-drive-service.ts new file mode 100644 index 0000000000..bba8eb0d42 --- /dev/null +++ b/electron/drive/google-drive-service.ts @@ -0,0 +1,282 @@ +// Google Drive sync: connect an account, upload a project's recording, and replace that same +// Drive file with each export of the project, so the link the user shared keeps pointing at +// the latest edit. The local files are never touched: Drive holds a copy, not the original. +// +// One Drive file per project. The first upload creates it in the `OpenScreen` folder; every +// later upload for the project replaces its content in place (same file id, same link). Uploads +// for one project run one after another, so an export finishing while the recording is still +// uploading lands on top of it rather than racing it into a second file. + +import { stat } from "node:fs/promises"; +import path from "node:path"; +import type { + DriveProjectState, + DriveStatus, + DriveSyncResult, + DriveVisibility, +} from "../../src/native/contracts"; +import { DriveApiError, GoogleDriveApi, videoMimeType } from "./google-drive-api"; +import type { DriveFileRecord, GoogleDriveStore } from "./google-drive-store"; +import { + type FetchLike, + GoogleAuthError, + type GoogleClient, + type GoogleTokens, + refreshAccessToken, + revokeToken, + signInWithGoogle, +} from "./google-oauth"; + +export const DRIVE_CLIENT_ID_ENV = "OPENSCREEN_GOOGLE_CLIENT_ID"; +export const DRIVE_CLIENT_SECRET_ENV = "OPENSCREEN_GOOGLE_CLIENT_SECRET"; + +export interface GoogleDriveServiceDeps { + store: GoogleDriveStore; + openExternal: (url: string) => Promise; + /** Where the OAuth client env vars are read from. Defaults to `process.env`. */ + env?: Readonly>; + fetchImpl?: FetchLike; + now?: () => number; + /** Swapped in tests; production signs in through the browser. */ + signIn?: typeof signInWithGoogle; +} + +/** Drive's file name: the project title, made safe, with the video's own extension. */ +export function driveFileName(title: string, filePath: string): string { + const clean = title.replace(/[\\/:*?"<>|\r\n]+/g, " ").trim() || "OpenScreen recording"; + return `${clean}${path.extname(filePath).toLowerCase()}`; +} + +export class GoogleDriveService { + private readonly store: GoogleDriveStore; + private readonly env: Readonly>; + private readonly fetchImpl: FetchLike; + private readonly now: () => number; + private readonly signIn: typeof signInWithGoogle; + private readonly api: GoogleDriveApi; + private tokens: GoogleTokens | null = null; + private readonly queues = new Map>(); + private readonly live = new Map< + string, + { status: "uploading" | "error"; error: string | null } + >(); + + constructor(private readonly deps: GoogleDriveServiceDeps) { + this.store = deps.store; + this.env = deps.env ?? process.env; + this.fetchImpl = deps.fetchImpl ?? fetch; + this.now = deps.now ?? Date.now; + this.signIn = deps.signIn ?? signInWithGoogle; + this.api = new GoogleDriveApi(() => this.accessToken(), this.fetchImpl); + } + + /** The env var wins over settings, as it does for the LLM providers' keys. */ + private client(): GoogleClient | null { + const envId = this.env[DRIVE_CLIENT_ID_ENV]?.trim(); + if (envId) { + return { clientId: envId, clientSecret: this.env[DRIVE_CLIENT_SECRET_ENV]?.trim() || null }; + } + const { clientId } = this.store.getSettings(); + if (!clientId) return null; + return { clientId, clientSecret: this.store.getSecrets().clientSecret }; + } + + getStatus(): DriveStatus { + const settings = this.store.getSettings(); + const clientFromEnv = Boolean(this.env[DRIVE_CLIENT_ID_ENV]?.trim()); + const connected = settings.accountEmail !== null; + return { + configured: clientFromEnv || settings.clientId !== null, + clientFromEnv, + clientId: settings.clientId, + connected, + accountEmail: settings.accountEmail, + syncEnabled: connected && settings.syncEnabled, + }; + } + + async setClientCredentials(clientId: string, clientSecret: string): Promise { + const id = clientId.trim(); + if (!id) throw new Error("Enter the OAuth client ID."); + // A different client cannot use the old client's refresh token. + if (id !== this.store.getSettings().clientId) await this.forgetAccount(); + await this.store.setSecrets({ clientSecret: clientSecret.trim() || null }); + await this.store.setSettings({ clientId: id }); + return this.getStatus(); + } + + /** Signs in through the browser. Sync is turned on with the connection: connecting is + * the request to sync, and the toggle is there to turn it off again. */ + async connect(): Promise { + const client = this.client(); + if (!client) throw new Error("Add a Google OAuth client ID first."); + const tokens = await this.signIn(client, { + openExternal: this.deps.openExternal, + fetchImpl: this.fetchImpl, + now: this.now, + }); + if (!tokens.refreshToken) throw new GoogleAuthError("Google returned no refresh token."); + await this.store.setSecrets({ refreshToken: tokens.refreshToken }); + this.tokens = tokens; + await this.store.setSettings({ + accountEmail: tokens.email ?? "Google account", + syncEnabled: true, + folderId: null, + }); + return this.getStatus(); + } + + /** Signs out and revokes the token. Videos already in Drive stay there, and so do their + * links: the project list keeps showing them, and shares keep working. */ + async disconnect(): Promise { + const { refreshToken } = this.store.getSecrets(); + if (refreshToken) await revokeToken(refreshToken, this.fetchImpl).catch(() => undefined); + await this.forgetAccount(); + return this.getStatus(); + } + + private async forgetAccount(): Promise { + this.tokens = null; + if (this.store.getSecrets().refreshToken) await this.store.setSecrets({ refreshToken: null }); + await this.store.setSettings({ accountEmail: null, syncEnabled: false, folderId: null }); + } + + async setSyncEnabled(enabled: boolean): Promise { + if (enabled && !this.getStatus().connected) throw new Error("Connect Google Drive first."); + await this.store.setSettings({ syncEnabled: enabled }); + return this.getStatus(); + } + + private async accessToken(): Promise { + if (this.tokens && this.tokens.expiresAt > this.now()) return this.tokens.accessToken; + const client = this.client(); + const { refreshToken } = this.store.getSecrets(); + if (!client || !refreshToken) throw new Error("Connect Google Drive first."); + try { + this.tokens = await refreshAccessToken(client, refreshToken, this.fetchImpl, this.now()); + } catch (error) { + // Revoked from the Google account page, or expired: say so in settings rather than + // fail every upload with the same error. + if (error instanceof GoogleAuthError && error.needsReconnect) await this.forgetAccount(); + throw error; + } + return this.tokens.accessToken; + } + + private stateOf(projectId: string, record: DriveFileRecord | null): DriveProjectState { + const live = this.live.get(projectId); + return { + projectId, + fileId: record?.fileId ?? null, + webViewLink: record?.webViewLink ?? null, + visibility: record?.visibility ?? "private", + syncedAt: record?.syncedAt ?? null, + status: live?.status ?? "synced", + error: live?.error ?? null, + }; + } + + /** Every project that has a Drive file or an upload under way or failed. */ + listProjects(): Record { + const { files } = this.store.getSettings(); + const ids = new Set([...Object.keys(files), ...this.live.keys()]); + const states: Record = {}; + for (const id of ids) states[id] = this.stateOf(id, files[id] ?? null); + return states; + } + + /** Runs `task` after every earlier one for the same project. */ + private enqueue(projectId: string, task: () => Promise): Promise { + const previous = this.queues.get(projectId) ?? Promise.resolve(); + const next = previous.then(task, task); + const settled = next.catch(() => undefined); + this.queues.set(projectId, settled); + void settled.then(() => { + if (this.queues.get(projectId) === settled) this.queues.delete(projectId); + }); + return next; + } + + /** + * Uploads `filePath` as this project's video: a new Drive file the first time, the same + * file's content replaced every time after. Does nothing while sync is off, or for a + * project the user unsynced. + */ + async syncProject(projectId: string, title: string, filePath: string): Promise { + if (!this.getStatus().syncEnabled || this.store.getSettings().excluded.includes(projectId)) { + return { uploaded: false, state: null }; + } + if (!videoMimeType(filePath)) throw new Error("Only video files are uploaded to Google Drive."); + const info = await stat(filePath); + if (!info.isFile()) throw new Error("The video to upload is not a file."); + return this.enqueue(projectId, async () => { + this.live.set(projectId, { status: "uploading", error: null }); + try { + const existing = this.store.getFile(projectId); + const name = driveFileName(title, filePath); + const uploaded = await this.uploadInPlace(existing, name, filePath); + const record: DriveFileRecord = { + fileId: uploaded.id, + webViewLink: uploaded.webViewLink, + // A replacement file (the old one was deleted in Drive) starts private. + visibility: existing?.fileId === uploaded.id ? existing.visibility : "private", + syncedAt: new Date(this.now()).toISOString(), + }; + await this.store.setFile(projectId, record); + this.live.delete(projectId); + return { uploaded: true, state: this.stateOf(projectId, record) }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + this.live.set(projectId, { status: "error", error: message }); + throw error; + } + }); + } + + private async uploadInPlace(existing: DriveFileRecord | null, name: string, filePath: string) { + if (existing) { + try { + return await this.api.upload({ filePath, name, fileId: existing.fileId }); + } catch (error) { + // Deleted in Drive (or no longer this app's): a new file, and a new link, is the + // only way to keep syncing. + if (!(error instanceof DriveApiError && error.status === 404)) throw error; + } + } + return this.api.upload({ filePath, name, folderId: await this.folderId() }); + } + + private async folderId(): Promise { + const cached = this.store.getSettings().folderId; + if (cached) return cached; + const folderId = await this.api.ensureFolder(); + await this.store.setSettings({ folderId }); + return folderId; + } + + async setVisibility(projectId: string, visibility: DriveVisibility): Promise { + return this.enqueue(projectId, async () => { + const record = this.store.getFile(projectId); + if (!record) throw new Error("This project is not in Google Drive."); + if (!this.getStatus().connected) throw new Error("Connect Google Drive first."); + await this.api.setVisibility(record.fileId, visibility); + const next = { ...record, visibility }; + await this.store.setFile(projectId, next); + return this.stateOf(projectId, next); + }); + } + + /** Stops syncing the project, for good: its later exports stay local. The copy in Drive + * is kept, and so is its link: unsyncing is not a delete, and anyone it was shared with + * can still open it. */ + async unsync(projectId: string): Promise { + await this.enqueue(projectId, async () => { + this.live.delete(projectId); + await this.store.setFile(projectId, null); + const { excluded } = this.store.getSettings(); + if (!excluded.includes(projectId)) { + await this.store.setSettings({ excluded: [...excluded, projectId] }); + } + }); + } +} diff --git a/electron/drive/google-drive-store.test.ts b/electron/drive/google-drive-store.test.ts new file mode 100644 index 0000000000..43fac3112c --- /dev/null +++ b/electron/drive/google-drive-store.test.ts @@ -0,0 +1,86 @@ +import { mkdtemp, readFile, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { beforeEach, describe, expect, it } from "vitest"; +import { type DriveCredentialCrypto, GoogleDriveStore } from "./google-drive-store"; + +// Reversible and visibly not plain text, so a test can tell an encrypted write from a plain one. +const crypto: DriveCredentialCrypto = { + isEncryptionAvailable: () => true, + encryptString: (plain) => Buffer.from(`enc:${Buffer.from(plain).toString("base64")}`), + decryptString: (encrypted) => + Buffer.from(encrypted.toString().replace(/^enc:/, ""), "base64").toString(), +}; + +let dir: string; +beforeEach(async () => { + dir = await mkdtemp(path.join(os.tmpdir(), "drive-store-")); +}); + +describe("GoogleDriveStore", () => { + it("starts off, with nothing connected and nothing synced", () => { + const store = new GoogleDriveStore(dir, crypto); + expect(store.getSettings()).toEqual({ + syncEnabled: false, + clientId: null, + accountEmail: null, + folderId: null, + files: {}, + excluded: [], + }); + expect(store.getSecrets()).toEqual({ refreshToken: null, clientSecret: null }); + }); + + it("persists settings and project files across instances", async () => { + const store = new GoogleDriveStore(dir, crypto); + await store.setSettings({ syncEnabled: true, clientId: "client", accountEmail: "a@b.c" }); + await store.setFile("proj_1", { + fileId: "file-1", + webViewLink: "https://drive.google.com/file/d/file-1/view", + visibility: "anyone", + syncedAt: "2026-11-12T00:00:00.000Z", + }); + const reread = new GoogleDriveStore(dir, crypto); + expect(reread.getSettings().syncEnabled).toBe(true); + expect(reread.getFile("proj_1")?.visibility).toBe("anyone"); + await reread.setFile("proj_1", null); + expect(new GoogleDriveStore(dir, crypto).getFile("proj_1")).toBeNull(); + }); + + it("keeps credentials out of the plain JSON", async () => { + const store = new GoogleDriveStore(dir, crypto); + await store.setSettings({ clientId: "client" }); + await store.setSecrets({ refreshToken: "refresh-secret", clientSecret: "client-secret" }); + const plain = await readFile(path.join(dir, "google-drive.json"), "utf8"); + const encrypted = await readFile(path.join(dir, "google-drive-credentials.enc"), "utf8"); + expect(plain).not.toContain("refresh-secret"); + expect(encrypted).not.toContain("refresh-secret"); + expect(new GoogleDriveStore(dir, crypto).getSecrets()).toEqual({ + refreshToken: "refresh-secret", + clientSecret: "client-secret", + }); + }); + + it("refuses to store credentials without the keychain rather than fall back to plain text", async () => { + const store = new GoogleDriveStore(dir, { ...crypto, isEncryptionAvailable: () => false }); + await expect(store.setSecrets({ refreshToken: "x" })).rejects.toThrow(/safeStorage/); + }); + + it("reads a hand-edited file defensively", async () => { + await writeFile( + path.join(dir, "google-drive.json"), + JSON.stringify({ + syncEnabled: "yes", + clientId: 7, + files: { ok: { fileId: "f", webViewLink: "l", visibility: "public" }, bad: { fileId: 1 } }, + excluded: ["proj_1", 3], + }), + ); + const settings = new GoogleDriveStore(dir, crypto).getSettings(); + expect(settings.syncEnabled).toBe(false); + expect(settings.clientId).toBeNull(); + expect(Object.keys(settings.files)).toEqual(["ok"]); + expect(settings.files.ok.visibility).toBe("private"); + expect(settings.excluded).toEqual(["proj_1"]); + }); +}); diff --git a/electron/drive/google-drive-store.ts b/electron/drive/google-drive-store.ts new file mode 100644 index 0000000000..be2ef29e5a --- /dev/null +++ b/electron/drive/google-drive-store.ts @@ -0,0 +1,164 @@ +// Settings for Google Drive sync: whether it is on, which account is connected, and which +// Drive file each project's video was uploaded to. +// +// Same split as McpSettingsStore and LlmConfigStore: everything that is not a credential is +// plain JSON (`google-drive.json`), the refresh token and the OAuth client secret go through +// the OS keychain (`safeStorage`) into `google-drive-credentials.enc`, never plain JSON. The +// encryption is injected so this file stays testable without Electron. +// +// The credentials are decrypted lazily, on the first upload or connect: on macOS the decrypt +// is a Keychain access, which must not happen at startup for someone who never connected. + +import { readFileSync } from "node:fs"; +import fs from "node:fs/promises"; +import path from "node:path"; +import type { DriveVisibility } from "../../src/native/contracts"; + +export interface DriveCredentialCrypto { + isEncryptionAvailable(): boolean; + encryptString(plain: string): Buffer; + decryptString(encrypted: Buffer): string; +} + +/** Where a project's video lives in Drive. Written once the first upload succeeds. */ +export interface DriveFileRecord { + fileId: string; + webViewLink: string; + visibility: DriveVisibility; + /** ISO time of the last successful upload: the recording, or the latest export over it. */ + syncedAt: string; +} + +export interface DriveSettings { + /** Upload new recordings and exports. Off keeps the connection but uploads nothing. */ + syncEnabled: boolean; + /** The user's own OAuth client (a "Desktop app" client in their Google Cloud project). */ + clientId: string | null; + /** Shown in settings. Its presence is what "connected" means without touching the keychain. */ + accountEmail: string | null; + /** The `OpenScreen` folder uploads go into, created on the first upload. */ + folderId: string | null; + files: Record; + /** Projects the user unsynced: later recordings and exports of them stay local. */ + excluded: string[]; +} + +export interface DriveSecrets { + refreshToken: string | null; + clientSecret: string | null; +} + +const EMPTY_SECRETS: DriveSecrets = { refreshToken: null, clientSecret: null }; + +const nullableString = (value: unknown) => + typeof value === "string" && value.length > 0 ? value : null; + +function parseFiles(value: unknown): Record { + if (!value || typeof value !== "object" || Array.isArray(value)) return {}; + const files: Record = {}; + for (const [projectId, raw] of Object.entries(value as Record)) { + if (!raw || typeof raw !== "object") continue; + const record = raw as Record; + if (typeof record.fileId !== "string" || typeof record.webViewLink !== "string") continue; + files[projectId] = { + fileId: record.fileId, + webViewLink: record.webViewLink, + visibility: record.visibility === "anyone" ? "anyone" : "private", + syncedAt: typeof record.syncedAt === "string" ? record.syncedAt : "", + }; + } + return files; +} + +export class GoogleDriveStore { + private readonly settingsPath: string; + private readonly secretsPath: string; + private settings: DriveSettings; + private secrets: DriveSecrets | null = null; + + constructor( + userDataPath: string, + private readonly crypto: DriveCredentialCrypto, + ) { + this.settingsPath = path.join(userDataPath, "google-drive.json"); + this.secretsPath = path.join(userDataPath, "google-drive-credentials.enc"); + this.settings = this.readSettings(); + } + + private readSettings(): DriveSettings { + try { + const raw = JSON.parse(readFileSync(this.settingsPath, "utf8")) as Record; + return { + syncEnabled: raw.syncEnabled === true, + clientId: nullableString(raw.clientId), + accountEmail: nullableString(raw.accountEmail), + folderId: nullableString(raw.folderId), + files: parseFiles(raw.files), + excluded: Array.isArray(raw.excluded) + ? raw.excluded.filter((id): id is string => typeof id === "string") + : [], + }; + } catch { + return { + syncEnabled: false, + clientId: null, + accountEmail: null, + folderId: null, + files: {}, + excluded: [], + }; + } + } + + getSettings(): DriveSettings { + return { + ...this.settings, + files: { ...this.settings.files }, + excluded: [...this.settings.excluded], + }; + } + + async setSettings(patch: Partial): Promise { + this.settings = { ...this.settings, ...patch }; + await fs.writeFile(this.settingsPath, JSON.stringify(this.settings, null, 2), "utf8"); + return this.getSettings(); + } + + getFile(projectId: string): DriveFileRecord | null { + return this.settings.files[projectId] ?? null; + } + + async setFile(projectId: string, record: DriveFileRecord | null): Promise { + const { [projectId]: _previous, ...rest } = this.settings.files; + await this.setSettings({ files: record ? { ...rest, [projectId]: record } : rest }); + } + + getSecrets(): DriveSecrets { + if (this.secrets) return { ...this.secrets }; + try { + const encrypted = readFileSync(this.secretsPath); + if (this.crypto.isEncryptionAvailable()) { + const parsed = JSON.parse(this.crypto.decryptString(encrypted)) as Record; + this.secrets = { + refreshToken: nullableString(parsed.refreshToken), + clientSecret: nullableString(parsed.clientSecret), + }; + return { ...this.secrets }; + } + } catch { + // Nothing stored yet, or unreadable: the user connects again. + } + this.secrets = { ...EMPTY_SECRETS }; + return { ...this.secrets }; + } + + /** Throws rather than falling back to plain text when the keychain is unavailable. */ + async setSecrets(patch: Partial): Promise { + if (!this.crypto.isEncryptionAvailable()) { + throw new Error("safeStorage is not available on this platform."); + } + const next = { ...this.getSecrets(), ...patch }; + await fs.writeFile(this.secretsPath, this.crypto.encryptString(JSON.stringify(next))); + this.secrets = next; + } +} diff --git a/electron/drive/google-oauth.test.ts b/electron/drive/google-oauth.test.ts new file mode 100644 index 0000000000..5a62374208 --- /dev/null +++ b/electron/drive/google-oauth.test.ts @@ -0,0 +1,145 @@ +import { createHash } from "node:crypto"; +import { describe, expect, it, vi } from "vitest"; +import { + base64Url, + buildAuthUrl, + createPkcePair, + emailFromIdToken, + GoogleAuthError, + refreshAccessToken, + signInWithGoogle, +} from "./google-oauth"; + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +function idToken(claims: Record): string { + return `x.${Buffer.from(JSON.stringify(claims)).toString("base64url")}.y`; +} + +describe("PKCE and the consent URL", () => { + it("derives the challenge as the S256 hash of the verifier", () => { + const { verifier, challenge } = createPkcePair(); + expect(verifier).toMatch(/^[A-Za-z0-9_-]{43}$/); + expect(challenge).toBe(base64Url(createHash("sha256").update(verifier).digest())); + }); + + it("asks only for the files OpenScreen creates, offline, with PKCE", () => { + const url = new URL(buildAuthUrl("client-id", "http://127.0.0.1:5000", "challenge", "state")); + expect(url.searchParams.get("scope")).toBe( + "https://www.googleapis.com/auth/drive.file openid email", + ); + expect(url.searchParams.get("code_challenge_method")).toBe("S256"); + expect(url.searchParams.get("access_type")).toBe("offline"); + expect(url.searchParams.get("redirect_uri")).toBe("http://127.0.0.1:5000"); + }); + + it("reads the email claim, and nothing from garbage", () => { + expect(emailFromIdToken(idToken({ email: "me@example.com" }))).toBe("me@example.com"); + expect(emailFromIdToken("not-a-jwt")).toBeNull(); + expect(emailFromIdToken(undefined)).toBeNull(); + }); +}); + +describe("refreshAccessToken", () => { + it("keeps the refresh token Google did not send back", async () => { + const fetchImpl = vi.fn(async () => jsonResponse({ access_token: "a", expires_in: 3600 })); + const tokens = await refreshAccessToken( + { clientId: "c", clientSecret: "s" }, + "refresh", + fetchImpl as unknown as typeof fetch, + 1_000, + ); + expect(tokens).toMatchObject({ accessToken: "a", refreshToken: "refresh" }); + expect(tokens.expiresAt).toBe(1_000 + 3540 * 1000); + const [, init] = fetchImpl.mock.calls[0] as unknown as [string, RequestInit]; + const body = new URLSearchParams(String(init.body)); + expect(body.get("grant_type")).toBe("refresh_token"); + expect(body.get("client_secret")).toBe("s"); + }); + + it("marks a revoked token as needing a reconnect", async () => { + const fetchImpl = vi.fn(async () => + jsonResponse({ error: "invalid_grant", error_description: "Token has been revoked." }, 400), + ); + const failure = await refreshAccessToken( + { clientId: "c", clientSecret: null }, + "refresh", + fetchImpl as unknown as typeof fetch, + ).catch((error: unknown) => error); + expect(failure).toBeInstanceOf(GoogleAuthError); + expect((failure as GoogleAuthError).needsReconnect).toBe(true); + expect((failure as Error).message).toBe("Token has been revoked."); + }); +}); + +describe("signInWithGoogle", () => { + it("catches the redirect on the loopback server and exchanges the code with the verifier", async () => { + const tokenFetch = vi.fn(async () => + jsonResponse({ + access_token: "access", + refresh_token: "refresh", + expires_in: 3600, + id_token: idToken({ email: "me@example.com" }), + }), + ); + const tokens = await signInWithGoogle( + { clientId: "client", clientSecret: "secret" }, + { + fetchImpl: tokenFetch as unknown as typeof fetch, + openExternal: async (consentUrl) => { + // Play the browser: Google redirects back with the code and the same state. + const consent = new URL(consentUrl); + const redirect = new URL(consent.searchParams.get("redirect_uri") ?? ""); + redirect.searchParams.set("code", "the-code"); + redirect.searchParams.set("state", consent.searchParams.get("state") ?? ""); + const page = await fetch(redirect); + expect(page.status).toBe(200); + }, + }, + ); + expect(tokens).toMatchObject({ + accessToken: "access", + refreshToken: "refresh", + email: "me@example.com", + }); + const [, init] = tokenFetch.mock.calls[0] as unknown as [string, RequestInit]; + const body = new URLSearchParams(String(init.body)); + expect(body.get("code")).toBe("the-code"); + expect(body.get("code_verifier")).toMatch(/^[A-Za-z0-9_-]{43}$/); + expect(body.get("redirect_uri")).toMatch(/^http:\/\/127\.0\.0\.1:\d+$/); + }); + + it("rejects a redirect whose state does not match", async () => { + const tokenFetch = vi.fn(); + await expect( + signInWithGoogle( + { clientId: "client", clientSecret: null }, + { + fetchImpl: tokenFetch as unknown as typeof fetch, + openExternal: async (consentUrl) => { + const consent = new URL(consentUrl); + const redirect = new URL(consent.searchParams.get("redirect_uri") ?? ""); + redirect.searchParams.set("code", "the-code"); + redirect.searchParams.set("state", "forged"); + await fetch(redirect); + }, + }, + ), + ).rejects.toThrow(GoogleAuthError); + expect(tokenFetch).not.toHaveBeenCalled(); + }); + + it("gives up when the user never finishes", async () => { + await expect( + signInWithGoogle( + { clientId: "client", clientSecret: null }, + { openExternal: async () => undefined, timeoutMs: 20 }, + ), + ).rejects.toThrow(/timed out/); + }); +}); diff --git a/electron/drive/google-oauth.ts b/electron/drive/google-oauth.ts new file mode 100644 index 0000000000..91f2147c09 --- /dev/null +++ b/electron/drive/google-oauth.ts @@ -0,0 +1,233 @@ +// Google sign-in for Drive sync: OAuth 2.0 for installed apps, with a loopback redirect and +// PKCE (RFC 7636), the flow Google documents for desktop clients. +// +// No client ID ships with OpenScreen. Shipping someone else's, or our own inside a signed +// installer, is exactly what was removed in 1.8.0 for the LLM providers (see +// llm-provider-auth.ts). The user brings a "Desktop app" OAuth client from their own Google +// Cloud project, entered in settings or set through OPENSCREEN_GOOGLE_CLIENT_ID / +// OPENSCREEN_GOOGLE_CLIENT_SECRET. +// +// The scope is `drive.file`: OpenScreen sees and changes only the files it created, never the +// rest of the user's Drive. + +import { createHash, randomBytes } from "node:crypto"; +import { createServer, type Server } from "node:http"; +import type { AddressInfo } from "node:net"; + +export const GOOGLE_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth"; +export const GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token"; +export const GOOGLE_REVOKE_URL = "https://oauth2.googleapis.com/revoke"; +export const DRIVE_SCOPES = ["https://www.googleapis.com/auth/drive.file", "openid", "email"]; + +/** Long enough to sign in and consent, short enough that a forgotten tab frees the port. */ +const SIGN_IN_TIMEOUT_MS = 5 * 60 * 1000; + +export interface GoogleClient { + clientId: string; + clientSecret: string | null; +} + +export interface GoogleTokens { + accessToken: string; + /** Epoch ms. */ + expiresAt: number; + refreshToken: string | null; + email: string | null; +} + +export type FetchLike = typeof fetch; + +export function base64Url(buffer: Buffer): string { + return buffer.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); +} + +export function createPkcePair(): { verifier: string; challenge: string } { + const verifier = base64Url(randomBytes(32)); + const challenge = base64Url(createHash("sha256").update(verifier).digest()); + return { verifier, challenge }; +} + +export function buildAuthUrl( + clientId: string, + redirectUri: string, + challenge: string, + state: string, +): string { + const url = new URL(GOOGLE_AUTH_URL); + url.searchParams.set("client_id", clientId); + url.searchParams.set("redirect_uri", redirectUri); + url.searchParams.set("response_type", "code"); + url.searchParams.set("scope", DRIVE_SCOPES.join(" ")); + url.searchParams.set("code_challenge", challenge); + url.searchParams.set("code_challenge_method", "S256"); + url.searchParams.set("state", state); + // Offline + consent: a refresh token every time, so a reconnect never ends up without one. + url.searchParams.set("access_type", "offline"); + url.searchParams.set("prompt", "consent"); + return url.toString(); +} + +/** The `email` claim of an ID token. Not verified: it came straight from Google's token + * endpoint over TLS, and it is only ever shown, never trusted for access. */ +export function emailFromIdToken(idToken: unknown): string | null { + if (typeof idToken !== "string") return null; + const payload = idToken.split(".")[1]; + if (!payload) return null; + try { + const claims = JSON.parse(Buffer.from(payload, "base64url").toString("utf8")) as { + email?: unknown; + }; + return typeof claims.email === "string" ? claims.email : null; + } catch { + return null; + } +} + +async function postToken( + fetchImpl: FetchLike, + params: Record, +): Promise> { + const response = await fetchImpl(GOOGLE_TOKEN_URL, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams(params).toString(), + }); + const body = (await response.json().catch(() => ({}))) as Record; + if (!response.ok) { + const reason = + typeof body.error_description === "string" + ? body.error_description + : typeof body.error === "string" + ? body.error + : `HTTP ${response.status}`; + throw new GoogleAuthError(reason, body.error === "invalid_grant"); + } + return body; +} + +export class GoogleAuthError extends Error { + constructor( + message: string, + /** The refresh token was revoked or expired: the user has to connect again. */ + readonly needsReconnect = false, + ) { + super(message); + this.name = "GoogleAuthError"; + } +} + +function tokensFrom(body: Record, now: number): GoogleTokens { + if (typeof body.access_token !== "string") { + throw new GoogleAuthError("Google returned no access token."); + } + const expiresIn = typeof body.expires_in === "number" ? body.expires_in : 3600; + return { + accessToken: body.access_token, + // A minute early, so a token is never used in its last seconds. + expiresAt: now + Math.max(0, expiresIn - 60) * 1000, + refreshToken: typeof body.refresh_token === "string" ? body.refresh_token : null, + email: emailFromIdToken(body.id_token), + }; +} + +export async function refreshAccessToken( + client: GoogleClient, + refreshToken: string, + fetchImpl: FetchLike = fetch, + now = Date.now(), +): Promise { + const params: Record = { + client_id: client.clientId, + refresh_token: refreshToken, + grant_type: "refresh_token", + }; + if (client.clientSecret) params.client_secret = client.clientSecret; + const tokens = tokensFrom(await postToken(fetchImpl, params), now); + return { ...tokens, refreshToken: tokens.refreshToken ?? refreshToken }; +} + +export async function revokeToken(token: string, fetchImpl: FetchLike = fetch): Promise { + await fetchImpl(`${GOOGLE_REVOKE_URL}?token=${encodeURIComponent(token)}`, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + }); +} + +const DONE_PAGE = `OpenScreen + +

OpenScreen is connected to Google Drive

You can close this tab.

`; +const FAILED_PAGE = `OpenScreen + +

Google Drive was not connected

Return to OpenScreen and try again.

`; + +export interface SignInDeps { + openExternal: (url: string) => Promise; + fetchImpl?: FetchLike; + timeoutMs?: number; + now?: () => number; +} + +/** + * Opens Google's consent page in the user's browser and waits for the redirect on a + * one-shot server bound to 127.0.0.1 on a free port. Resolves with the tokens, rejects on a + * denial, a state mismatch or the timeout. The server is closed whatever happens. + */ +export async function signInWithGoogle( + client: GoogleClient, + deps: SignInDeps, +): Promise { + const fetchImpl = deps.fetchImpl ?? fetch; + const { verifier, challenge } = createPkcePair(); + const state = base64Url(randomBytes(16)); + let server: Server | null = null; + let timer: NodeJS.Timeout | null = null; + try { + const code = await new Promise<{ code: string; redirectUri: string }>((resolve, reject) => { + let redirectUri = ""; + server = createServer((req, res) => { + const url = new URL(req.url ?? "/", "http://127.0.0.1"); + // Browsers ask for a favicon too; only the redirect itself settles the flow. + if (url.pathname !== "/") { + res.writeHead(404).end(); + return; + } + const error = url.searchParams.get("error"); + const returnedCode = url.searchParams.get("code"); + const ok = !error && returnedCode && url.searchParams.get("state") === state; + res.writeHead(ok ? 200 : 400, { "Content-Type": "text/html; charset=utf-8" }); + res.end(ok ? DONE_PAGE : FAILED_PAGE); + if (ok) resolve({ code: returnedCode, redirectUri }); + else + reject( + new GoogleAuthError( + error ? `Google sign-in failed: ${error}` : "Google sign-in failed.", + ), + ); + }); + server.on("error", reject); + server.listen(0, "127.0.0.1", () => { + const { port } = (server as Server).address() as AddressInfo; + redirectUri = `http://127.0.0.1:${port}`; + deps + .openExternal(buildAuthUrl(client.clientId, redirectUri, challenge, state)) + .catch(reject); + }); + timer = setTimeout( + () => reject(new GoogleAuthError("Google sign-in timed out.")), + deps.timeoutMs ?? SIGN_IN_TIMEOUT_MS, + ); + }); + const params: Record = { + client_id: client.clientId, + code: code.code, + code_verifier: verifier, + redirect_uri: code.redirectUri, + grant_type: "authorization_code", + }; + if (client.clientSecret) params.client_secret = client.clientSecret; + return tokensFrom(await postToken(fetchImpl, params), deps.now?.() ?? Date.now()); + } finally { + if (timer) clearTimeout(timer); + (server as Server | null)?.close(); + } +} diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index b54ffe87a7..965e3b9a56 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -74,6 +74,8 @@ import { createRunnerHost } from "../cli/runnerHost"; import { hideCountdownOverlay } from "../countdownOverlay"; import { isDiagnosticModeEnabled, mainLogFile } from "../diagnostics/main-log-file"; import { createZip } from "../diagnostics/zip"; +import { GoogleDriveService } from "../drive/google-drive-service"; +import { GoogleDriveStore } from "../drive/google-drive-store"; import { mainT } from "../i18n"; import { getInstallChannel } from "../install-channel"; import { @@ -5159,6 +5161,12 @@ export function registerIpcHandlers( // read on every call: "MCP edits", separate from the in-app agent's "Agent edits", // so turning the server on grants a client read access and nothing more. const mcpSettings = new McpSettingsStore(app.getPath("userData"), safeStorage); + // Google Drive sync. Its store reads only plain JSON here; the keychain is touched on the + // first connect or upload, never at startup (same reason as the LLM store above). + const googleDrive = new GoogleDriveService({ + store: new GoogleDriveStore(app.getPath("userData"), safeStorage), + openExternal: (url) => shell.openExternal(url), + }); const mcpToolDeps: McpToolDeps = { transport: "http", host: new EditorDocumentHost(ipcMain), @@ -5366,6 +5374,7 @@ export function registerIpcHandlers( renameSession(projectId, sessionId, title), deleteAiEditionChatSession: (projectId, sessionId) => deleteSession(projectId, sessionId), getMcpController: () => mcpController, + getGoogleDrive: () => googleDrive, }); // `openscreen mcp` serves the same tools on stdio, from the CLI process. diff --git a/electron/ipc/nativeBridge.drive.test.ts b/electron/ipc/nativeBridge.drive.test.ts new file mode 100644 index 0000000000..8a30a395dc --- /dev/null +++ b/electron/ipc/nativeBridge.drive.test.ts @@ -0,0 +1,99 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { NativeBridgeResponse } from "../../src/native/contracts"; +import type { GoogleDriveService } from "../drive/google-drive-service"; +import { type NativeBridgeContext, registerNativeBridgeHandlers } from "./nativeBridge"; + +const electron = vi.hoisted(() => ({ handle: vi.fn() })); +vi.mock("electron", () => ({ + app: { getAppPath: () => "", isPackaged: false }, + ipcMain: { handle: electron.handle, removeHandler: vi.fn() }, + shell: {}, +})); +// The bridge builds these at registration; none of them is under test here. +vi.mock("../native-bridge/services/compositorViewService", () => ({ + CompositorViewService: class {}, +})); +vi.mock("../native-bridge/services/aiEditionService", () => ({ + AiEditionService: class {}, +})); + +const drive = { + getStatus: vi.fn(() => ({ connected: false })), + syncProject: vi.fn(async () => ({ uploaded: true, state: null })), + setVisibility: vi.fn(async () => ({ visibility: "anyone" })), + unsync: vi.fn(async () => undefined), + setSyncEnabled: vi.fn(async () => ({ syncEnabled: false })), +}; + +function register(withDrive: boolean) { + electron.handle.mockClear(); + registerNativeBridgeHandlers({ + getPlatform: () => "linux", + getAiEditionDocuments: () => ({}), + getAiEditionLlmConfig: () => ({}), + getStylePresets: () => ({}), + getGoogleDrive: withDrive ? () => drive as unknown as GoogleDriveService : undefined, + } as unknown as NativeBridgeContext); + const handler = electron.handle.mock.calls[0]?.[1] as ( + event: unknown, + request: unknown, + ) => Promise; + return (request: unknown) => handler({ sender: {} }, request); +} + +let invoke: (request: unknown) => Promise; +beforeEach(() => { + for (const fn of Object.values(drive)) fn.mockClear(); + invoke = register(true); +}); + +describe("native bridge: drive", () => { + it("passes a valid sync request through", async () => { + const response = await invoke({ + domain: "drive", + action: "syncProject", + payload: { projectId: "proj_1", title: "Demo", path: "/videos/take.mp4" }, + }); + expect(response.ok).toBe(true); + expect(drive.syncProject).toHaveBeenCalledWith("proj_1", "Demo", "/videos/take.mp4"); + }); + + it.each([ + { projectId: "../etc", title: "Demo", path: "/videos/take.mp4" }, + { projectId: "proj_1", title: "Demo", path: "relative/take.mp4" }, + { projectId: "proj_1", title: 3, path: "/videos/take.mp4" }, + ])("refuses a malformed sync request %#", async (payload) => { + const response = await invoke({ domain: "drive", action: "syncProject", payload }); + expect(response).toMatchObject({ ok: false, error: { code: "INVALID_REQUEST" } }); + expect(drive.syncProject).not.toHaveBeenCalled(); + }); + + it("only accepts the two visibilities", async () => { + const bad = await invoke({ + domain: "drive", + action: "setVisibility", + payload: { projectId: "proj_1", visibility: "public" }, + }); + expect(bad).toMatchObject({ ok: false, error: { code: "INVALID_REQUEST" } }); + const good = await invoke({ + domain: "drive", + action: "setVisibility", + payload: { projectId: "proj_1", visibility: "anyone" }, + }); + expect(good.ok).toBe(true); + }); + + it("checks the sync flag's type", async () => { + const response = await invoke({ + domain: "drive", + action: "setSyncEnabled", + payload: { enabled: "yes" }, + }); + expect(response).toMatchObject({ ok: false, error: { code: "INVALID_REQUEST" } }); + }); + + it("answers UNAVAILABLE where there is no Drive service", async () => { + const response = await register(false)({ domain: "drive", action: "getStatus" }); + expect(response).toMatchObject({ ok: false, error: { code: "UNAVAILABLE" } }); + }); +}); diff --git a/electron/ipc/nativeBridge.ts b/electron/ipc/nativeBridge.ts index 4639e68177..a4308a9671 100644 --- a/electron/ipc/nativeBridge.ts +++ b/electron/ipc/nativeBridge.ts @@ -1,3 +1,4 @@ +import path from "node:path"; import { BrowserWindow, ipcMain, shell } from "electron"; import type { AiEditionChatEvent } from "../../src/native/contracts"; import { @@ -68,6 +69,8 @@ export interface NativeBridgeContext { getAiEditionLlmConfig: () => import("../ai-edition/llm-config-store").LlmConfigStore; /** The local MCP server's controller. Absent in the headless CLI. */ getMcpController?: () => import("../mcp/mcp-controller").McpController; + /** Google Drive sync. Absent in the headless CLI: the bridge then answers UNAVAILABLE. */ + getGoogleDrive?: () => import("../drive/google-drive-service").GoogleDriveService; runAiEditionChat: ( projectId: string, sessionId: string, @@ -901,6 +904,90 @@ export function registerNativeBridgeHandlers(context: NativeBridgeContext) { } } + case "drive": { + const action = request.action as string; + const drive = context.getGoogleDrive?.(); + if (!drive) { + return createErrorResponse(requestId, "UNAVAILABLE", "Google Drive is unavailable."); + } + // Renderer input: checked here, so a bad value is a bad request, not an internal error. + const isProjectId = (id: unknown): id is string => + typeof id === "string" && /^[A-Za-z0-9_-]{1,200}$/.test(id); + switch (request.action) { + case "getStatus": + return createSuccessResponse(requestId, drive.getStatus()); + case "setClient": + if ( + typeof request.payload?.clientId !== "string" || + typeof request.payload.clientSecret !== "string" + ) { + return createErrorResponse(requestId, "INVALID_REQUEST", "Invalid OAuth client."); + } + return createSuccessResponse( + requestId, + await drive.setClientCredentials( + request.payload.clientId, + request.payload.clientSecret, + ), + ); + case "connect": + return createSuccessResponse(requestId, await drive.connect()); + case "disconnect": + return createSuccessResponse(requestId, await drive.disconnect()); + case "setSyncEnabled": + if (typeof request.payload?.enabled !== "boolean") { + return createErrorResponse(requestId, "INVALID_REQUEST", "Invalid sync flag."); + } + return createSuccessResponse( + requestId, + await drive.setSyncEnabled(request.payload.enabled), + ); + case "listProjects": + return createSuccessResponse(requestId, drive.listProjects()); + case "syncProject": + if ( + !isProjectId(request.payload?.projectId) || + typeof request.payload.title !== "string" || + typeof request.payload.path !== "string" || + !path.isAbsolute(request.payload.path) + ) { + return createErrorResponse(requestId, "INVALID_REQUEST", "Invalid sync request."); + } + return createSuccessResponse( + requestId, + await drive.syncProject( + request.payload.projectId, + request.payload.title, + request.payload.path, + ), + ); + case "setVisibility": + if ( + !isProjectId(request.payload?.projectId) || + (request.payload.visibility !== "private" && + request.payload.visibility !== "anyone") + ) { + return createErrorResponse(requestId, "INVALID_REQUEST", "Invalid visibility."); + } + return createSuccessResponse( + requestId, + await drive.setVisibility(request.payload.projectId, request.payload.visibility), + ); + case "unsync": + if (!isProjectId(request.payload?.projectId)) { + return createErrorResponse(requestId, "INVALID_REQUEST", "Invalid project."); + } + await drive.unsync(request.payload.projectId); + return createSuccessResponse(requestId, { success: true }); + default: + return createErrorResponse( + requestId, + "UNSUPPORTED_ACTION", + `Unsupported drive action: ${action}`, + ); + } + } + default: return createErrorResponse( requestId, diff --git a/src/App.tsx b/src/App.tsx index 681bb59939..169c49e57b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -32,6 +32,11 @@ const ShortcutsConfigDialog = lazy(() => default: module.ShortcutsConfigDialog, })), ); +const GoogleDriveSettingsDialog = lazy(() => + import("./components/ai-edition/GoogleDriveSettings").then((module) => ({ + default: module.GoogleDriveSettingsDialog, + })), +); const ProviderSettingsDialog = lazy(() => import("./components/ai-edition/ProviderSettings").then((module) => ({ default: module.ProviderSettingsDialog, @@ -151,6 +156,7 @@ export default function App() { + diff --git a/src/components/ai-edition/ExportDialog.tsx b/src/components/ai-edition/ExportDialog.tsx index a4c453c047..b97e087292 100644 --- a/src/components/ai-edition/ExportDialog.tsx +++ b/src/components/ai-edition/ExportDialog.tsx @@ -22,6 +22,7 @@ import type { AxcutDocument } from "@/lib/ai-edition/schema"; import { getEditorSettings } from "@/lib/ai-edition/store/editorSettings"; import { assetCameraSource } from "@/lib/ai-edition/timeline/camera"; import { resolveClipSourceEndSec } from "@/lib/ai-edition/timeline/clipDuration"; +import { syncProjectVideoToDrive } from "@/lib/driveSync"; import { type ExportFormat, type ExportProgress, @@ -461,6 +462,20 @@ export function ExportDialog({ open, onClose, document }: ExportDialogProps) { if (activeExport.current !== job) return; setSavedPath(pickedPath); setPhase("done"); + // The edit replaces the project's video in Google Drive, in the same file, so a + // link already shared now opens this export. GIFs stay local: the Drive file is + // the project's video. Main skips it while sync is off or the project is unsynced. + if (format === "mp4") { + const exportedPath = pickedPath; + void syncProjectVideoToDrive( + document.project.id, + document.project.title, + exportedPath, + ).then((outcome) => { + if (outcome === "uploaded") toast.success(t("drive.exportUploaded")); + else if (outcome === "failed") toast.error(t("drive.uploadFailed")); + }); + } // Reported after the export succeeded, so a cancelled or failed run never counts. // Main owns the whole decision; a failure here simply means no ask, which is the // safe direction for something that may only ever happen once. diff --git a/src/components/ai-edition/GoogleDriveSettings.test.tsx b/src/components/ai-edition/GoogleDriveSettings.test.tsx new file mode 100644 index 0000000000..41dde22be0 --- /dev/null +++ b/src/components/ai-edition/GoogleDriveSettings.test.tsx @@ -0,0 +1,96 @@ +// @vitest-environment jsdom +import "@testing-library/jest-dom"; +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { I18nProvider } from "@/contexts/I18nContext"; +import type { DriveStatus } from "@/native/contracts"; +import { GoogleDriveSettings } from "./GoogleDriveSettings"; + +const drive = vi.hoisted(() => ({ + getStatus: vi.fn(), + setClient: vi.fn(), + connect: vi.fn(), + disconnect: vi.fn(), + setSyncEnabled: vi.fn(), +})); +vi.mock("@/native/client", () => ({ nativeBridgeClient: { drive } })); +vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } })); + +const base: DriveStatus = { + configured: false, + clientFromEnv: false, + clientId: null, + connected: false, + accountEmail: null, + syncEnabled: false, +}; +const configured: DriveStatus = { ...base, configured: true, clientId: "id.apps" }; +const connected: DriveStatus = { + ...configured, + connected: true, + accountEmail: "me@example.com", + syncEnabled: true, +}; + +function renderSettings() { + render( + + + , + ); +} + +beforeEach(() => { + for (const fn of Object.values(drive)) fn.mockReset(); +}); +afterEach(cleanup); + +describe("GoogleDriveSettings", () => { + it("asks for the OAuth client first, then offers to connect", async () => { + drive.getStatus.mockResolvedValue(base); + drive.setClient.mockResolvedValue(configured); + renderSettings(); + const id = await screen.findByLabelText("OAuth client ID"); + fireEvent.change(id, { target: { value: "id.apps" } }); + fireEvent.change(screen.getByLabelText("OAuth client secret"), { target: { value: "s3cret" } }); + fireEvent.click(screen.getByTestId("drive-save-client")); + await waitFor(() => expect(drive.setClient).toHaveBeenCalledWith("id.apps", "s3cret")); + expect(await screen.findByTestId("drive-connect")).toHaveTextContent("Connect to Google Drive"); + }); + + it("does not ask for a client that came from the environment", async () => { + drive.getStatus.mockResolvedValue({ ...base, configured: true, clientFromEnv: true }); + renderSettings(); + expect(await screen.findByTestId("drive-connect")).toBeInTheDocument(); + expect(screen.queryByLabelText("OAuth client ID")).toBeNull(); + expect(screen.queryByText("Change OAuth client")).toBeNull(); + }); + + it("connects, and shows the account with sync on", async () => { + drive.getStatus.mockResolvedValue(configured); + drive.connect.mockResolvedValue(connected); + renderSettings(); + fireEvent.click(await screen.findByTestId("drive-connect")); + expect(await screen.findByText("Connected as me@example.com.")).toBeInTheDocument(); + expect(screen.getByTestId("drive-sync-toggle")).toHaveAttribute("aria-pressed", "true"); + }); + + it("turns syncing off and on with the toggle", async () => { + drive.getStatus.mockResolvedValue(connected); + drive.setSyncEnabled.mockResolvedValue({ ...connected, syncEnabled: false }); + renderSettings(); + fireEvent.click(await screen.findByTestId("drive-sync-toggle")); + await waitFor(() => expect(drive.setSyncEnabled).toHaveBeenCalledWith(false)); + await waitFor(() => + expect(screen.getByTestId("drive-sync-toggle")).toHaveAttribute("aria-pressed", "false"), + ); + }); + + it("disconnects", async () => { + drive.getStatus.mockResolvedValue(connected); + drive.disconnect.mockResolvedValue(configured); + renderSettings(); + fireEvent.click(await screen.findByTestId("drive-disconnect")); + expect(await screen.findByTestId("drive-connect")).toBeInTheDocument(); + }); +}); diff --git a/src/components/ai-edition/GoogleDriveSettings.tsx b/src/components/ai-edition/GoogleDriveSettings.tsx new file mode 100644 index 0000000000..05ef790472 --- /dev/null +++ b/src/components/ai-edition/GoogleDriveSettings.tsx @@ -0,0 +1,214 @@ +// Google Drive settings: the OAuth client to sign in with, the connection, and the switch that +// turns syncing on and off. The sync itself lives in electron/drive/; this only drives it +// through the native bridge. + +import { Check, Loader2 } from "lucide-react"; +import { useCallback, useEffect, useState } from "react"; +import { toast } from "sonner"; +import { useEditorDialogActions, useEditorDialogSection } from "@/contexts/EditorDialogsContext"; +import { useScopedT } from "@/contexts/I18nContext"; +import { nativeBridgeClient } from "@/native/client"; +import type { DriveStatus } from "@/native/contracts"; +import { ModalShell } from "./Modals"; +import styles from "./NewEditorShell.module.css"; + +export function GoogleDriveSettings({ open, onClose }: { open: boolean; onClose: () => void }) { + const te = useScopedT("editor"); + const [status, setStatus] = useState(null); + const [loadFailed, setLoadFailed] = useState(false); + const [busy, setBusy] = useState<"connect" | "other" | null>(null); + const [editingClient, setEditingClient] = useState(false); + const [clientId, setClientId] = useState(""); + const [clientSecret, setClientSecret] = useState(""); + + const adopt = useCallback((next: DriveStatus) => { + setStatus(next); + setClientId(next.clientId ?? ""); + setClientSecret(""); + setEditingClient(false); + }, []); + + useEffect(() => { + if (!open) return; + setLoadFailed(false); + void Promise.resolve() + .then(() => nativeBridgeClient.drive.getStatus()) + .then(adopt, () => setLoadFailed(true)); + }, [open, adopt]); + + const run = async (kind: "connect" | "other", task: () => Promise) => { + setBusy(kind); + try { + adopt(await task()); + } catch (err) { + toast.error(te("drive.updateFailed"), { + description: err instanceof Error ? err.message : String(err), + }); + } finally { + setBusy(null); + } + }; + + const showClientForm = status + ? !status.clientFromEnv && (!status.configured || editingClient) + : false; + + return ( + + {loadFailed ?

{te("drive.unavailable")}

: null} + {status ? ( +
+
+

{te("drive.title")}

+ {status.connected ? ( + + + {te("drive.statusConnected")} + + ) : ( + + {te("drive.statusNotConnected")} + + )} +
+ + {showClientForm ? ( + <> +

{te("drive.clientHint")}

+
+ + setClientId(e.target.value)} + placeholder="1234-abcd.apps.googleusercontent.com" + disabled={busy !== null} + spellCheck={false} + /> +
+
+ + setClientSecret(e.target.value)} + disabled={busy !== null} + spellCheck={false} + /> +
+
+ + {editingClient ? ( + + ) : null} +
+ + ) : status.connected ? ( + <> +

+ {te("drive.connectedAs", { account: status.accountEmail ?? "" })} +

+
+ +
+
+ +
+ + ) : ( + <> +

{te("drive.connectHint")}

+
+ + {status.clientFromEnv ? null : ( + + )} +
+ {busy === "connect" ? ( +

{te("drive.waitingForBrowser")}

+ ) : null} + + )} +
+ ) : null} +
+ ); +} + +/** The dialog's one mount, in App.tsx beside the AI settings dialog. */ +export function GoogleDriveSettingsDialog() { + const section = useEditorDialogSection(); + const { closeDialog } = useEditorDialogActions(); + return ; +} diff --git a/src/components/ai-edition/Modals.tsx b/src/components/ai-edition/Modals.tsx index f835104338..3d6b3517a3 100644 --- a/src/components/ai-edition/Modals.tsx +++ b/src/components/ai-edition/Modals.tsx @@ -7,6 +7,7 @@ import { useRef, useState, } from "react"; +import { toast } from "sonner"; import type { CropRegion } from "@/components/video-editor/types"; import { useScopedT } from "@/contexts/I18nContext"; import type { AxcutClip } from "@/lib/ai-edition/schema"; @@ -14,11 +15,14 @@ import { formatSeconds } from "@/lib/ai-edition/timeline/format"; import type { AiEditionProjectRecordingsResult, AiEditionRecordingsDisposal, + DriveProjectState, + DriveVisibility, } from "@/native/contracts"; import { formatBytes } from "@/utils/formatBytes"; import { getPlatform } from "@/utils/platformUtils"; import { cropDraftFromRegion, cropDraftToPct, previewBoxStyle } from "./cropDraft"; import styles from "./NewEditorShell.module.css"; +import { DriveBadge, ProjectDriveMenu } from "./ProjectDriveMenu"; import { ChoiceRow, Toggle } from "./RightPanes"; import type { VideoSource } from "./VirtualPreview"; @@ -137,6 +141,17 @@ interface OpenProjectModalProps extends BaseModalProps { /** The recordings deleting `id` could also remove. */ loadRecordings: (id: string) => Promise; onBrowse: () => void; + /** Google Drive sync for the rows. Absent: no Drive badge and no Drive menu. */ + drive?: ProjectDriveActions; +} + +/** What the project list needs from Google Drive sync. */ +export interface ProjectDriveActions { + /** Every project with a Drive file, an upload under way, or a failed upload. */ + load: () => Promise>; + setVisibility: (projectId: string, visibility: DriveVisibility) => Promise; + unsync: (projectId: string) => Promise; + copyLink: (link: string) => Promise; } export function OpenProjectModal({ @@ -148,9 +163,63 @@ export function OpenProjectModal({ onDelete, loadRecordings, onBrowse, + drive, }: OpenProjectModalProps) { const t = useScopedT("editor"); const tc = useScopedT("common"); + const [driveStates, setDriveStates] = useState>({}); + useEffect(() => { + if (!open || !drive) return; + let cancelled = false; + void drive + .load() + .then((states) => { + if (!cancelled) setDriveStates(states); + }) + // Without the answer the rows show no Drive badge; the list itself still works. + .catch(() => undefined); + return () => { + cancelled = true; + }; + }, [open, drive]); + const shareDriveLink = (state: DriveProjectState) => { + if (!drive || !state.webViewLink) return; + drive.copyLink(state.webViewLink).then( + () => + toast.success( + t(state.visibility === "anyone" ? "drive.linkCopied" : "drive.linkCopiedPrivate"), + ), + () => toast.error(t("drive.copyFailed")), + ); + }; + const setDriveVisibility = (projectId: string, visibility: DriveVisibility) => { + if (!drive) return; + drive.setVisibility(projectId, visibility).then( + (next) => { + setDriveStates((states) => ({ ...states, [projectId]: next })); + toast.success( + t(visibility === "anyone" ? "drive.visibilityAnyoneDone" : "drive.visibilityPrivateDone"), + ); + }, + (err) => + toast.error(t("drive.visibilityFailed"), { + description: err instanceof Error ? err.message : String(err), + }), + ); + }; + const unsyncFromDrive = (projectId: string) => { + if (!drive) return; + drive.unsync(projectId).then( + () => { + setDriveStates(({ [projectId]: _removed, ...rest }) => rest); + toast.success(t("drive.unsynced")); + }, + (err) => + toast.error(t("drive.unsyncFailed"), { + description: err instanceof Error ? err.message : String(err), + }), + ); + }; const [query, setQuery] = useState(""); // Deleting a project cannot be undone, so the trash icon arms an inline // confirm on its own row instead of deleting on the first click. One row at a @@ -282,6 +351,7 @@ export function OpenProjectModal({ filtered.map((p) => { const isActive = p.id === activeProjectId; const isHighlighted = p.id === highlightedId; + const driveState = driveStates[p.id]; if (p.id === confirmId) { return (
-
- {p.title} +
+ + {p.title} + + {driveState ? : null}
+ {driveState ? ( + shareDriveLink(driveState)} + onSetVisibility={(visibility) => setDriveVisibility(p.id, visibility)} + onUnsync={() => unsyncFromDrive(p.id)} + /> + ) : null} + {open ? ( +
{ + // The dialog closes on Escape too; this one press only closes the menu. + if (e.key === "Escape") { + e.preventDefault(); + e.stopPropagation(); + setOpen(false); + triggerRef.current?.focus(); + return; + } + moveMenuFocus(e); + }} + style={{ + position: "absolute", + right: 0, + top: "calc(100% + 4px)", + zIndex: 10, + minWidth: 220, + padding: 4, + display: "flex", + flexDirection: "column", + borderRadius: "var(--r-md)", + background: "var(--surface)", + border: "1px solid var(--border)", + boxShadow: "0 8px 24px rgba(0,0,0,0.25)", + }} + > + + + {t("drive.shareLink")} + + setVisibilityOpen((v) => !v)} + testId="drive-edit-visibility" + > + + {t("drive.editVisibility")} + + {visibilityOpen ? ( +
+ {VISIBILITIES.map((option) => ( + + ))} +
+ ) : null} + + + {t("drive.unsync")} + +
+ ) : null} +
+ ); +} + +const menuRowStyle: React.CSSProperties = { + display: "flex", + alignItems: "center", + gap: 8, + width: "100%", + padding: "7px 10px", + border: "none", + borderRadius: "var(--r-sm)", + background: "transparent", + color: "var(--fg)", + font: "400 13px/1.3 var(--font-body)", + textAlign: "left", + cursor: "pointer", +}; + +function MenuRow({ + onClick, + disabled, + expanded, + testId, + children, +}: { + onClick: () => void; + disabled?: boolean; + expanded?: boolean; + testId: string; + children: React.ReactNode; +}) { + return ( + + ); +} diff --git a/src/components/ai-edition/ProviderSettings.test.tsx b/src/components/ai-edition/ProviderSettings.test.tsx index 88103e214d..0c43e55068 100644 --- a/src/components/ai-edition/ProviderSettings.test.tsx +++ b/src/components/ai-edition/ProviderSettings.test.tsx @@ -72,6 +72,7 @@ function TopBar({ mode }: { mode: EditorMode }) { renameProject: noop, toggleChat: noop, openProviderSettings: () => openDialog("providers"), + openDriveSettings: () => openDialog("drive"), showAbout: noop, checkForUpdates: noop, undo: noop, diff --git a/src/components/ai-edition/recordingImport.ts b/src/components/ai-edition/recordingImport.ts index b6b3e119ad..dfa271b9a6 100644 --- a/src/components/ai-edition/recordingImport.ts +++ b/src/components/ai-edition/recordingImport.ts @@ -28,6 +28,7 @@ import { appendAutoZoomSuggestions, collectAutoZoomSuggestionsForLatestDocument, } from "@/lib/ai-edition/timeline/apply-auto-zooms"; +import { type DriveSyncOutcome, syncProjectVideoToDrive } from "@/lib/driveSync"; import { nativeBridgeClient } from "@/native/client"; // Fresh recordings used to get cursor-dwell zooms on load (legacy editor @@ -329,6 +330,8 @@ export async function importPendingRecording( * the editor is the only window left to say it. */ onWarning?: (message: string) => void, + /** Called once the take's upload to Google Drive settles; never while sync is off. */ + onDriveUpload?: (outcome: Exclude) => void, ): Promise { const api = window.electronAPI; if (!api) return false; @@ -340,8 +343,17 @@ export async function importPendingRecording( const warning = result.success ? result.warning : undefined; const label = screenPath.split(/[\\/]/).pop() || "Recording"; - await useProjectStore.getState().createProject(`Recording ${new Date().toLocaleString()}`); + const created = await useProjectStore + .getState() + .createProject(`Recording ${new Date().toLocaleString()}`); await useProjectStore.getState().addAsset(screenPath, label); + // The local file is the project's; Drive gets a copy, in the background. Exports of this + // project later replace that copy in place, so the link stays the same. + void syncProjectVideoToDrive(created.project.id, created.project.title, screenPath).then( + (outcome) => { + if (outcome !== "skipped") onDriveUpload?.(outcome); + }, + ); // Mark before the video element can fire `loadedmetadata`. The asset path is // already on the document; waiting until the 60s seed finished let the first // metadata pass consume nothing and the second never arrive. diff --git a/src/components/ai-edition/v4/EditorTopBar.stylePresets.test.tsx b/src/components/ai-edition/v4/EditorTopBar.stylePresets.test.tsx index 320438d1da..9ab9b0c462 100644 --- a/src/components/ai-edition/v4/EditorTopBar.stylePresets.test.tsx +++ b/src/components/ai-edition/v4/EditorTopBar.stylePresets.test.tsx @@ -116,6 +116,7 @@ function renderPane() { renameProject: noop, toggleChat: noop, openProviderSettings: noop, + openDriveSettings: noop, showAbout: noop, checkForUpdates: noop, undo: noop, diff --git a/src/components/ai-edition/v4/EditorTopBar.test.tsx b/src/components/ai-edition/v4/EditorTopBar.test.tsx index b9d920cb52..bd8992b67b 100644 --- a/src/components/ai-edition/v4/EditorTopBar.test.tsx +++ b/src/components/ai-edition/v4/EditorTopBar.test.tsx @@ -49,6 +49,7 @@ function renderTopBar( const onCheckForUpdates = vi.fn(); const onOpenSettings = vi.fn(); const onOpenProviderSettings = vi.fn(); + const onOpenDriveSettings = vi.fn(); const onNewProject = vi.fn(); const onOpenProject = vi.fn(); const onSave = vi.fn(); @@ -74,6 +75,7 @@ function renderTopBar( renameProject: onRename, toggleChat: noop, openProviderSettings: onOpenProviderSettings, + openDriveSettings: onOpenDriveSettings, showAbout: onShowAbout, checkForUpdates: onCheckForUpdates, undo: onUndo, @@ -88,6 +90,7 @@ function renderTopBar( onCheckForUpdates, onOpenSettings, onOpenProviderSettings, + onOpenDriveSettings, onNewProject, onOpenProject, onSave, @@ -233,6 +236,15 @@ describe("AppMenu", () => { expect(screen.queryByRole("menu")).not.toBeInTheDocument(); }); + it("opens the Google Drive dialog and closes behind itself", () => { + const { onOpenDriveSettings, onOpenProviderSettings } = renderTopBar("Demo Project"); + fireEvent.click(screen.getByRole("button", { name: /OpenScreen/ })); + fireEvent.click(screen.getByRole("menuitem", { name: "drive.title" })); + expect(onOpenDriveSettings).toHaveBeenCalledTimes(1); + expect(onOpenProviderSettings).not.toHaveBeenCalled(); + expect(screen.queryByRole("menu")).not.toBeInTheDocument(); + }); + it("routes About to the main process and closes behind itself", () => { const { onShowAbout } = renderTopBar("Demo Project"); fireEvent.click(screen.getByRole("button", { name: /OpenScreen/ })); diff --git a/src/components/ai-edition/v4/EditorTopBar.tsx b/src/components/ai-edition/v4/EditorTopBar.tsx index d85a4189e0..4814f0e47a 100644 --- a/src/components/ai-edition/v4/EditorTopBar.tsx +++ b/src/components/ai-edition/v4/EditorTopBar.tsx @@ -1,6 +1,7 @@ import { ChevronDown, CircleArrowDown, + Cloud, Download, FolderOpen, FolderPlus, @@ -41,6 +42,7 @@ export interface TopBarActions { renameProject: (title: string) => void; toggleChat: () => void; openProviderSettings: () => void; + openDriveSettings: () => void; showAbout: () => void; checkForUpdates: () => void; undo: () => void; @@ -466,6 +468,16 @@ function AppMenu({ actions }: { actions: TopBarActions }) { {tEditor("providerSettings.title")} + - {open ? ( -
{ - // The dialog closes on Escape too; this one press only closes the menu. - if (e.key === "Escape") { - e.preventDefault(); - e.stopPropagation(); - setOpen(false); - triggerRef.current?.focus(); - return; - } - moveMenuFocus(e); - }} - style={{ - position: "absolute", - right: 0, - top: "calc(100% + 4px)", - zIndex: 10, - minWidth: 220, - padding: 4, - display: "flex", - flexDirection: "column", - borderRadius: "var(--r-md)", - background: "var(--surface)", - border: "1px solid var(--border)", - boxShadow: "0 8px 24px rgba(0,0,0,0.25)", - }} - > - - - {t("drive.shareLink")} - - setVisibilityOpen((v) => !v)} - testId="drive-edit-visibility" - > - - {t("drive.editVisibility")} - - {visibilityOpen ? ( -
- {VISIBILITIES.map((option) => ( - - ))} -
- ) : null} - - - {t("drive.unsync")} - -
- ) : null} + {VISIBILITIES.map((option) => ( + + ))} +
+ ) : null} + + + {t("drive.unsync")} + +
, + document.body, + ) + : null} ); } diff --git a/src/native/browserShim.ts b/src/native/browserShim.ts index bd00284ad9..f0fdeaf7d1 100644 --- a/src/native/browserShim.ts +++ b/src/native/browserShim.ts @@ -15,6 +15,7 @@ import { stylePresetFileBaseName, } from "../lib/ai-edition/stylePresets"; import { NativeBridgeRequestError, nativeBridgeClient as realClient } from "./client"; +import type { DriveProjectState, DriveStatus, DriveVisibility } from "./contracts"; function detectBrowserMode(): boolean { if (typeof window === "undefined") return false; @@ -389,6 +390,45 @@ function createShimBridgeClient() { } return m; }; + // Google Drive: one localStorage record for the connection and the projects' Drive files. + type ShimDrive = { + clientId: string | null; + accountEmail: string | null; + syncEnabled: boolean; + projects: Record; + }; + const driveStorageKey = "browser-shim-google-drive-v1"; + const readDrive = (): ShimDrive => { + const empty: ShimDrive = { + clientId: null, + accountEmail: null, + syncEnabled: false, + projects: {}, + }; + try { + const parsed = JSON.parse(localStorage.getItem(driveStorageKey) ?? "{}"); + return parsed && typeof parsed === "object" ? { ...empty, ...parsed } : empty; + } catch { + return empty; + } + }; + const writeDrive = (state: ShimDrive): ShimDrive => { + try { + localStorage.setItem(driveStorageKey, JSON.stringify(state)); + } catch { + // Lost on reload, like any other shim state without storage. + } + return state; + }; + const driveStatus = (state: ShimDrive): DriveStatus => ({ + configured: state.clientId !== null, + clientFromEnv: false, + clientId: state.clientId, + connected: state.accountEmail !== null, + accountEmail: state.accountEmail, + syncEnabled: state.accountEmail !== null && state.syncEnabled, + }); + // Style presets: localStorage stands in for the presets folder, keyed by the same file // base name the real service would use, and re-read on every call as `list` re-reads // the folder. Failures reject with the same `NativeBridgeRequestError` codes the real @@ -794,20 +834,41 @@ function createShimBridgeClient() { Promise.resolve({ currentProjectPath: null, currentVideoPath: null }), loadProjectFile: () => Promise.resolve({ success: false, canceled: true }), }, - // No Google sign-in in a browser tab: the settings section shows Drive as not set up, - // and nothing is ever in Drive. + // No Google sign-in in a browser tab. localStorage stands in for the connection and the + // projects' Drive files, so the dialog and the project list's Drive menu can be driven in + // browser mode; "connecting" signs in a placeholder account, and nothing is uploaded. drive: { - getStatus: () => - Promise.resolve({ - configured: false, - clientFromEnv: false, - clientId: null, - connected: false, - accountEmail: null, - syncEnabled: false, - }), - listProjects: () => Promise.resolve({}), + getStatus: () => Promise.resolve(driveStatus(readDrive())), + setClient: (clientId: string) => + Promise.resolve(driveStatus(writeDrive({ ...readDrive(), clientId: clientId.trim() }))), + connect: () => + Promise.resolve( + driveStatus( + writeDrive({ ...readDrive(), accountEmail: "you@example.com", syncEnabled: true }), + ), + ), + disconnect: () => + Promise.resolve( + driveStatus(writeDrive({ ...readDrive(), accountEmail: null, syncEnabled: false })), + ), + setSyncEnabled: (enabled: boolean) => + Promise.resolve(driveStatus(writeDrive({ ...readDrive(), syncEnabled: enabled }))), + listProjects: () => Promise.resolve(readDrive().projects), syncProject: () => Promise.resolve({ uploaded: false, state: null }), + setVisibility: (projectId: string, visibility: DriveVisibility) => { + const state = readDrive(); + const current = state.projects[projectId]; + if (!current) return Promise.reject(new Error("This project is not in Google Drive.")); + const next = { ...current, visibility }; + writeDrive({ ...state, projects: { ...state.projects, [projectId]: next } }); + return Promise.resolve(next); + }, + unsync: (projectId: string) => { + const state = readDrive(); + const { [projectId]: _removed, ...projects } = state.projects; + writeDrive({ ...state, projects }); + return Promise.resolve({ success: true as const }); + }, }, presets: { list: () => From e1bbdca5d99725ceae4c9cbe87d21ba3ad6bd125 Mon Sep 17 00:00:00 2001 From: Davidjayan Date: Thu, 12 Nov 2026 11:04:37 +0530 Subject: [PATCH 3/3] test(chat): give the copy-message panel's first render 10 s, not 1 s The first test's waitFor used the 1 s default for the panel's first render, which a loaded machine misses: the full suite failed here on main too, while the file alone passed. Co-Authored-By: Claude Opus 5.5 --- .../ai-edition/LeftPanel.copyMessage.test.tsx | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/components/ai-edition/LeftPanel.copyMessage.test.tsx b/src/components/ai-edition/LeftPanel.copyMessage.test.tsx index d9c3d061c2..32b05e6261 100644 --- a/src/components/ai-edition/LeftPanel.copyMessage.test.tsx +++ b/src/components/ai-edition/LeftPanel.copyMessage.test.tsx @@ -163,9 +163,15 @@ async function renderPanelWithAssistantMessage() { , ); // Flush the session load so the assistant bubble (and its copy button) is on screen. - await waitFor(() => { - expect(view.getAllByRole("button", { name: "chat.copyMessage" }).length).toBeGreaterThan(0); - }); + // The first render of the panel is the slow one, and waitFor's 1 s default is not enough + // for it on a loaded machine: the full suite failed here on main as well as on branches, + // while the file alone passed. Same reasoning as `testTimeout` in vitest.config.ts. + await waitFor( + () => { + expect(view.getAllByRole("button", { name: "chat.copyMessage" }).length).toBeGreaterThan(0); + }, + { timeout: 10_000 }, + ); return view; }