From d08bf49d5ad7bfcb6f1e95edf784c50a8e2a2a0e Mon Sep 17 00:00:00 2001 From: Gagancreates Date: Mon, 1 Jun 2026 23:47:27 +0530 Subject: [PATCH] feat(google-docs): use Google Picker + drive.file scope instead of full-drive listing --- apps/x/apps/main/src/ipc.ts | 6 +- .../components/google-doc-picker-dialog.tsx | 211 ++++++++---------- apps/x/apps/renderer/src/lib/google-picker.ts | 117 ++++++++++ apps/x/packages/core/src/auth/providers.ts | 8 +- .../core/src/knowledge/google_docs.ts | 46 +--- apps/x/packages/shared/src/ipc.ts | 15 +- 6 files changed, 229 insertions(+), 174 deletions(-) create mode 100644 apps/x/apps/renderer/src/lib/google-picker.ts diff --git a/apps/x/apps/main/src/ipc.ts b/apps/x/apps/main/src/ipc.ts index 75fb8f27..eb6e1c7a 100644 --- a/apps/x/apps/main/src/ipc.ts +++ b/apps/x/apps/main/src/ipc.ts @@ -52,7 +52,7 @@ import { getAccessToken } from '@x/core/dist/auth/tokens.js'; import { getRowboatConfig } from '@x/core/dist/config/rowboat.js'; import { runLiveNoteAgent } from '@x/core/dist/knowledge/live-note/runner.js'; import { listImportantThreads, listEverythingElseThreads, saveMessageBodyHeight, triggerSync as triggerGmailSync, sendThreadReply, archiveThread, trashThread, markThreadRead, getAccountEmail, getConnectionStatus as getGmailConnectionStatus } from '@x/core/dist/knowledge/sync_gmail.js'; -import { getGoogleDocsConnectionStatus, importGoogleDoc, listGoogleDocs, syncGoogleDocDown, syncGoogleDocUp, getGoogleDocLink } from '@x/core/dist/knowledge/google_docs.js'; +import { getGoogleDocsConnectionStatus, importGoogleDoc, getGoogleAccessToken, syncGoogleDocDown, syncGoogleDocUp, getGoogleDocLink } from '@x/core/dist/knowledge/google_docs.js'; import { liveNoteBus } from '@x/core/dist/knowledge/live-note/bus.js'; import { getInstallationId } from '@x/core/dist/analytics/installation.js'; import { API_URL } from '@x/core/dist/config/env.js'; @@ -815,8 +815,8 @@ export function setupIpcHandlers() { 'google-docs:getStatus': async () => { return getGoogleDocsConnectionStatus(); }, - 'google-docs:list': async (_event, args) => { - return listGoogleDocs(args.query); + 'google-docs:getAccessToken': async () => { + return { accessToken: await getGoogleAccessToken() }; }, 'google-docs:import': async (_event, args) => { return importGoogleDoc(args.fileId, args.targetFolder); diff --git a/apps/x/apps/renderer/src/components/google-doc-picker-dialog.tsx b/apps/x/apps/renderer/src/components/google-doc-picker-dialog.tsx index 5876e3bd..d3fbd7dd 100644 --- a/apps/x/apps/renderer/src/components/google-doc-picker-dialog.tsx +++ b/apps/x/apps/renderer/src/components/google-doc-picker-dialog.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useMemo, useState } from 'react' -import { FileText, Loader2, RefreshCw, Search } from 'lucide-react' +import { FileText, Loader2, RefreshCw } from 'lucide-react' import { Dialog, @@ -12,17 +12,9 @@ import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { GoogleClientIdModal } from '@/components/google-client-id-modal' import { setGoogleCredentials } from '@/lib/google-credentials-store' -import { formatRelativeTime } from '@/lib/relative-time' +import { openGooglePicker, getStoredPickerApiKey, setStoredPickerApiKey } from '@/lib/google-picker' import { toast } from '@/lib/toast' -type GoogleDocListItem = { - id: string - name: string - url: string - modifiedTime: string | null - owner: string | null -} - type GoogleDocsStatus = { connected: boolean hasRequiredScopes: boolean @@ -36,11 +28,6 @@ type GoogleDocPickerDialogProps = { onImported: (path: string) => void } -function formatModified(modifiedTime: string | null): string { - if (!modifiedTime) return '' - return formatRelativeTime(modifiedTime) -} - export function GoogleDocPickerDialog({ open, targetFolder, @@ -48,15 +35,13 @@ export function GoogleDocPickerDialog({ onImported, }: GoogleDocPickerDialogProps) { const [status, setStatus] = useState(null) - const [query, setQuery] = useState('') - const [docs, setDocs] = useState([]) - const [loading, setLoading] = useState(false) const [connecting, setConnecting] = useState(false) - const [importingId, setImportingId] = useState(null) + const [opening, setOpening] = useState(false) const [error, setError] = useState(null) const [byokOpen, setByokOpen] = useState(false) + const [apiKey, setApiKey] = useState('') - const canList = Boolean(status?.connected && status.hasRequiredScopes) + const canPick = Boolean(status?.connected && status.hasRequiredScopes) const targetLabel = useMemo(() => targetFolder.replace(/^knowledge\/?/, '') || 'knowledge', [targetFolder]) const loadStatus = useCallback(async () => { @@ -70,36 +55,13 @@ export function GoogleDocPickerDialog({ } }, []) - const loadDocs = useCallback(async (searchQuery: string) => { - setLoading(true) - setError(null) - try { - const result = await window.ipc.invoke('google-docs:list', { query: searchQuery.trim() || undefined }) - setDocs(result.files) - } catch (err) { - setDocs([]) - setError(err instanceof Error ? err.message : 'Failed to load Google Docs') - } finally { - setLoading(false) - } - }, []) - useEffect(() => { if (!open) return - setQuery('') - setDocs([]) setError(null) + setApiKey(getStoredPickerApiKey()) void loadStatus() }, [loadStatus, open]) - useEffect(() => { - if (!open || !canList) return - const timeout = window.setTimeout(() => { - void loadDocs(query) - }, 250) - return () => window.clearTimeout(timeout) - }, [canList, loadDocs, open, query]) - const handleConnect = useCallback(async () => { setConnecting(true) setError(null) @@ -117,9 +79,9 @@ export function GoogleDocPickerDialog({ } }, []) - // BYOK: connect Google with the user's own OAuth client. Unlike the managed - // (rowboat) sign-in, this local flow requests the Drive + Docs scopes, so a - // signed-in user can actually grant Docs access without a backend change. + // BYOK: connect Google with the user's own OAuth client, which requests the + // drive.file scope locally (managed sign-in can't grant it without a backend + // change). const handleByokSubmit = useCallback((clientId: string, clientSecret: string) => { setGoogleCredentials(clientId, clientSecret) setByokOpen(false) @@ -144,38 +106,68 @@ export function GoogleDocPickerDialog({ return cleanup }, [open, loadStatus]) - const handleImport = useCallback(async (doc: GoogleDocListItem) => { - setImportingId(doc.id) + const handleChoose = useCallback(async () => { setError(null) - try { - const result = await window.ipc.invoke('google-docs:import', { - fileId: doc.id, - targetFolder, - }) - toast('Google Doc added', 'success') - onImported(result.path) - onOpenChange(false) - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to import Google Doc') - } finally { - setImportingId(null) + const key = apiKey.trim() + if (!key) { + setError('Enter your Google Picker API key first.') + return } - }, [onImported, onOpenChange, targetFolder]) + setStoredPickerApiKey(key) + setOpening(true) + + let accessToken: string | null = null + try { + const res = await window.ipc.invoke('google-docs:getAccessToken', null) + accessToken = res.accessToken + } catch (err) { + setOpening(false) + setError(err instanceof Error ? err.message : 'Failed to get a Google access token') + return + } + if (!accessToken) { + setOpening(false) + setError('Google access token unavailable — reconnect Google.') + return + } + + // Hand off to Google's Picker; close our modal so it isn't trapped behind it. + onOpenChange(false) + try { + await openGooglePicker({ + accessToken, + apiKey: key, + onPicked: async (file) => { + try { + const result = await window.ipc.invoke('google-docs:import', { fileId: file.id, targetFolder }) + toast(`Added “${file.name}”`, 'success') + onImported(result.path) + } catch (err) { + toast(err instanceof Error ? err.message : 'Failed to import the document', 'error') + } + }, + }) + } catch (err) { + toast(err instanceof Error ? err.message : 'Failed to open the Google Picker', 'error') + } finally { + setOpening(false) + } + }, [apiKey, targetFolder, onImported, onOpenChange]) return ( <> - + Add Google Doc - Select a Google Doc to link into {targetLabel}. + Link a Google Doc or Word file from Drive into {targetLabel}.
{!status && error ? ( -
+
{error}
) : !status ? ( -
+
Checking Google connection...
- ) : !status.connected || !status.hasRequiredScopes ? ( -
+ ) : !canPick ? ( +
- To choose Google Docs, Rowboat needs Drive + Docs access. + To choose a document, Rowboat needs per-file Drive access (the drive.file scope).
{status.missingScopes.length > 0 && (
@@ -207,65 +199,42 @@ export function GoogleDocPickerDialog({

- Managed sign-in may not grant Docs access yet. If it keeps asking for scopes, - connect a Google OAuth client (Desktop app) with the Drive API and Docs API enabled. + Managed sign-in may not grant Drive access yet. If it keeps asking for scopes, + connect a Google OAuth client (Desktop app) with the Drive API and Picker API enabled.

) : ( - <> -
-
- - setQuery(e.target.value)} - placeholder="Search Google Docs" - className="pl-9" - autoFocus - /> -
+
+
+ Pick a Google Doc or Word file from your Drive. It imports as an editable + .docx and stays linked for two-way sync. +
+
+ + setApiKey(e.target.value)} + placeholder="AIza…" + className="font-mono text-xs" + /> +

+ From your Google Cloud project (APIs & Services → Credentials → API key), + with the Picker API enabled. Stored locally. +

- {error && ( -
+
{error}
)} - -
- {loading ? ( -
- - Loading Docs... -
- ) : docs.length === 0 ? ( -
- No Google Docs found. -
- ) : ( -
- {docs.map((doc) => ( - - ))} -
- )} -
- + +
)}
@@ -274,7 +243,7 @@ export function GoogleDocPickerDialog({ open={byokOpen} onOpenChange={setByokOpen} onSubmit={handleByokSubmit} - description="Enter a Google OAuth client (Desktop app) with the Drive API and Docs API enabled to grant Docs access." + description="Enter a Google OAuth client (Desktop app) with the Drive API and Picker API enabled." /> ) diff --git a/apps/x/apps/renderer/src/lib/google-picker.ts b/apps/x/apps/renderer/src/lib/google-picker.ts new file mode 100644 index 00000000..d11a83fb --- /dev/null +++ b/apps/x/apps/renderer/src/lib/google-picker.ts @@ -0,0 +1,117 @@ +// Loader + thin wrapper around the Google Picker JS API. File selection happens +// inside Google's hosted Picker (so the app needs only drive.file, not a broad +// listing scope); the Picker hands back the chosen file id. + +export type PickedFile = { id: string; name: string; mimeType: string } + +type GapiGlobal = { + load: (lib: string, config: { callback: () => void; onerror?: () => void }) => void +} + +type PickerView = { + setIncludeFolders: (b: boolean) => PickerView + setOwnedByMe: (b: boolean) => PickerView + setMimeTypes: (m: string) => PickerView +} + +type PickerCallbackData = { + action: string + docs?: Array<{ id: string; name: string; mimeType: string }> +} + +type PickerBuilder = { + addView: (v: PickerView) => PickerBuilder + setOAuthToken: (t: string) => PickerBuilder + setDeveloperKey: (k: string) => PickerBuilder + setAppId: (id: string) => PickerBuilder + setTitle: (t: string) => PickerBuilder + setCallback: (cb: (data: PickerCallbackData) => void) => PickerBuilder + build: () => { setVisible: (v: boolean) => void } +} + +type GooglePickerNS = { + DocsView: new () => PickerView + PickerBuilder: new () => PickerBuilder + Action: { PICKED: string; CANCEL: string } +} + +declare global { + interface Window { + gapi?: GapiGlobal + google?: { picker?: GooglePickerNS } + } +} + +const API_JS = 'https://apis.google.com/js/api.js' +const DOC_MIME = 'application/vnd.google-apps.document' +const DOCX_MIME = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' +const API_KEY_STORAGE = 'rowboat:google-picker-api-key' + +let pickerLoaded: Promise | null = null + +function loadPickerApi(): Promise { + if (pickerLoaded) return pickerLoaded + pickerLoaded = new Promise((resolve, reject) => { + const start = () => { + if (!window.gapi) { reject(new Error('Google API failed to initialize')); return } + window.gapi.load('picker', { + callback: () => resolve(), + onerror: () => reject(new Error('Google Picker failed to load')), + }) + } + const existing = document.querySelector(`script[src="${API_JS}"]`) + if (existing) { + if (window.gapi) start() + else existing.addEventListener('load', start) + return + } + const script = document.createElement('script') + script.src = API_JS + script.async = true + script.onload = start + script.onerror = () => reject(new Error('Failed to load the Google API script')) + document.head.appendChild(script) + }) + return pickerLoaded +} + +export function getStoredPickerApiKey(): string { + try { return localStorage.getItem(API_KEY_STORAGE) ?? '' } catch { return '' } +} + +export function setStoredPickerApiKey(key: string): void { + try { localStorage.setItem(API_KEY_STORAGE, key.trim()) } catch { /* ignore */ } +} + +export async function openGooglePicker(opts: { + accessToken: string + apiKey?: string + appId?: string + onPicked: (file: PickedFile) => void + onCancel?: () => void +}): Promise { + await loadPickerApi() + const picker = window.google?.picker + if (!picker) throw new Error('Google Picker is unavailable') + + const view = new picker.DocsView() + .setIncludeFolders(false) + .setOwnedByMe(false) + .setMimeTypes(`${DOC_MIME},${DOCX_MIME}`) + + const builder = new picker.PickerBuilder() + .addView(view) + .setOAuthToken(opts.accessToken) + .setTitle('Choose a document to sync') + .setCallback((data) => { + if (data.action === picker.Action.PICKED && data.docs?.[0]) { + const d = data.docs[0] + opts.onPicked({ id: d.id, name: d.name, mimeType: d.mimeType }) + } else if (data.action === picker.Action.CANCEL) { + opts.onCancel?.() + } + }) + if (opts.apiKey) builder.setDeveloperKey(opts.apiKey) + if (opts.appId) builder.setAppId(opts.appId) + builder.build().setVisible(true) +} diff --git a/apps/x/packages/core/src/auth/providers.ts b/apps/x/packages/core/src/auth/providers.ts index 40ef6f4a..a695cf56 100644 --- a/apps/x/packages/core/src/auth/providers.ts +++ b/apps/x/packages/core/src/auth/providers.ts @@ -77,10 +77,10 @@ const providerConfigs: ProviderConfig = { scopes: [ 'https://www.googleapis.com/auth/gmail.modify', 'https://www.googleapis.com/auth/calendar.events.readonly', - // Full Drive access: read/export Google Docs to .docx AND write the edited - // .docx back into the original doc (files.update needs write, which - // drive.readonly does not grant). Covers list/get/export/update. - 'https://www.googleapis.com/auth/drive', + // Per-file Drive access (non-restricted): the user grants read/write to a + // specific doc by choosing it in the Google Picker. Enough to export/ + // download and write back, without the restricted full-drive scope. + 'https://www.googleapis.com/auth/drive.file', ], }, 'fireflies-ai': { diff --git a/apps/x/packages/core/src/knowledge/google_docs.ts b/apps/x/packages/core/src/knowledge/google_docs.ts index cb6d426e..e169bb86 100644 --- a/apps/x/packages/core/src/knowledge/google_docs.ts +++ b/apps/x/packages/core/src/knowledge/google_docs.ts @@ -5,10 +5,11 @@ import { google, drive_v3 as drive } from 'googleapis'; import { resolveWorkspacePath } from '../workspace/workspace.js'; import { GoogleClientFactory } from './google-client-factory.js'; -// Full Drive scope: export Google Docs to .docx (read) and write the edited -// .docx back via files.update (write). drive.readonly can't do the write half. +// Per-file Drive scope (non-restricted). The user picks a doc via the Google +// Picker, which grants this app read/write to that file — enough to export/ +// download it and write edits back, without the restricted full-drive scope. export const GOOGLE_DOC_SCOPES = [ - 'https://www.googleapis.com/auth/drive', + 'https://www.googleapis.com/auth/drive.file', ] as const; export type GoogleDocListItem = { @@ -58,10 +59,6 @@ function sanitizeFilename(name: string): string { return cleaned || 'Google Doc'; } -function escapeDriveQueryValue(value: string): string { - return value.replace(/\\/g, '\\\\').replace(/'/g, "\\'"); -} - function normalizeRel(relPath: string): string { return relPath.replace(/\\/g, '/'); } @@ -198,34 +195,13 @@ export async function getGoogleDocsConnectionStatus(): Promise<{ return GoogleClientFactory.getCredentialStatus([...GOOGLE_DOC_SCOPES]); } -export async function listGoogleDocs(query?: string): Promise<{ files: GoogleDocListItem[] }> { - const status = await getGoogleDocsConnectionStatus(); - if (!status.connected) throw new Error('Google is not connected.'); - if (!status.hasRequiredScopes) throw new Error('Google is missing Drive access. Reconnect Google.'); - - const driveClient = await getDriveClient(); - // Native Google Docs (exportable) and uploaded Word files (downloadable). - const typeClause = `(mimeType='${GOOGLE_DOC_MIME}' or mimeType='${DOCX_MIME}')`; - const clauses = [typeClause, 'trashed=false']; - const trimmed = query?.trim(); - if (trimmed) { - clauses.push(`name contains '${escapeDriveQueryValue(trimmed)}'`); - } - const q = clauses.join(' and '); - const result = await driveClient.files.list({ - q, - pageSize: 25, - orderBy: 'modifiedTime desc', - fields: 'files(id,name,webViewLink,modifiedTime,mimeType,owners(displayName,emailAddress))', - // Also surface docs in shared drives and "Shared with me", not just My Drive. - corpora: 'allDrives', - includeItemsFromAllDrives: true, - supportsAllDrives: true, - }); - - const files = (result.data.files ?? []).map(toGoogleDocListItem).filter((file) => file.id); - console.log(`[GoogleDocs] list q="${q}" → ${files.length} doc(s)`); - return { files }; +/** + * The live Google OAuth access token, for the renderer to drive the Google + * Picker (file selection happens client-side; the app never lists Drive). + */ +export async function getGoogleAccessToken(): Promise { + const auth = await GoogleClientFactory.getClient(); + return auth?.credentials?.access_token ?? null; } /** Import a Google Doc as a local .docx and register the link. */ diff --git a/apps/x/packages/shared/src/ipc.ts b/apps/x/packages/shared/src/ipc.ts index 221c2cfa..97f79e24 100644 --- a/apps/x/packages/shared/src/ipc.ts +++ b/apps/x/packages/shared/src/ipc.ts @@ -650,18 +650,11 @@ const ipcSchemas = { missingScopes: z.array(z.string()), }), }, - 'google-docs:list': { - req: z.object({ - query: z.string().optional(), - }), + // Live Google OAuth access token for driving the Google Picker in the renderer. + 'google-docs:getAccessToken': { + req: z.null(), res: z.object({ - files: z.array(z.object({ - id: z.string(), - name: z.string(), - url: z.string(), - modifiedTime: z.string().nullable(), - owner: z.string().nullable(), - })), + accessToken: z.string().nullable(), }), }, 'google-docs:import': {