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/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; } 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} + {anchor + ? createPortal( +
{ + // 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: "fixed", + ...anchor, + // Above the dialog it opened from (`.modal`, z-index 100). + zIndex: 110, + 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")} + +
, + document.body, + ) + : 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")} +