diff --git a/apps/x/apps/main/src/deeplink.ts b/apps/x/apps/main/src/deeplink.ts index aaaaa3bc..68877975 100644 --- a/apps/x/apps/main/src/deeplink.ts +++ b/apps/x/apps/main/src/deeplink.ts @@ -39,6 +39,8 @@ export function extractDeepLinkFromArgv(argv: readonly string[]): string | null export function dispatchUrl(url: string): void { if (parseAction(url)) { void dispatchAction(url); + } else if (parsePickerCompletion(url)) { + void dispatchPickerCompletion(url); } else if (parseOAuthCompletion(url)) { void dispatchOAuthCompletion(url); } else { @@ -158,6 +160,41 @@ async function dispatchOAuthCompletion(url: string): Promise { await completeRowboatGoogleConnect(parsed.state); } +// --- Managed OAuth-redirect Picker completion --- + +interface PickerCompletion { + state: string; +} + +/** + * Match rowboat://oauth/google/picker/done?session=. Distinct from the + * connect completion above (oauth/google/done) by the extra `picker` segment. + */ +function parsePickerCompletion(url: string): PickerCompletion | null { + if (!url.startsWith(URL_PREFIX)) return null; + const rest = url.slice(URL_PREFIX.length); + const queryIdx = rest.indexOf("?"); + const path = queryIdx >= 0 ? rest.slice(0, queryIdx) : rest; + const parts = path.split("/").filter(Boolean); + if (parts.length !== 4) return null; + if (parts[0] !== "oauth" || parts[1] !== "google" || parts[2] !== "picker" || parts[3] !== "done") return null; + const params = new URLSearchParams(queryIdx >= 0 ? rest.slice(queryIdx + 1) : ""); + const state = params.get("session"); + return state ? { state } : null; +} + +async function dispatchPickerCompletion(url: string): Promise { + const parsed = parsePickerCompletion(url); + if (!parsed) return; + + const win = mainWindowRef; + if (win && !win.isDestroyed()) focusWindow(win); + + // Lazy-import to keep deeplink.ts free of the picker's OAuth/knowledge deps. + const { completeManagedGooglePick } = await import("./google-picker-managed.js"); + await completeManagedGooglePick(parsed.state); +} + function focusWindow(win: BrowserWindow): void { if (win.isMinimized()) win.restore(); win.show(); diff --git a/apps/x/apps/main/src/google-picker-managed.ts b/apps/x/apps/main/src/google-picker-managed.ts new file mode 100644 index 00000000..a9790ad9 --- /dev/null +++ b/apps/x/apps/main/src/google-picker-managed.ts @@ -0,0 +1,120 @@ +import { shell, BrowserWindow } from 'electron'; +import { getWebappUrl } from '@x/core/dist/config/remote-config.js'; +import { claimPickedFilesViaBackend } from '@x/core/dist/auth/google-backend-oauth.js'; +import { importGoogleDocWithToken } from '@x/core/dist/knowledge/google_docs.js'; +import type { GoogleDocListItem } from '@x/core/dist/knowledge/google_docs.js'; + +// Managed (rowboat-mode) OAuth-redirect Picker. Unlike BYOK, the OAuth runs on +// the Rowboat backend with the COMPANY Google client — the desktop never holds +// a client_id/secret or an API key. The desktop just opens the start URL, waits +// for the deep link, claims the picked file ids, and downloads them with the +// user's EXISTING managed Google token (which already holds drive.file from the +// main connect). No Picker API key, appId, ngrok, or local OAuth. +// +// Backend contract (Rowboat webapp/api — NOT this repo). Mirrors the existing +// managed Google-connect (start URL → park under session → deep-link back): +// +// GET ${webappUrl}/oauth/google/picker/start +// Runs Google OAuth with the company client, scope=drive.file ONLY, +// trigger_onepick=true, prompt=consent. Tied to the logged-in web +// session (cookies), exactly like /oauth/google/start. +// +// GET ${webappUrl}/oauth/google/picker/callback +// Google returns `picked_file_ids` (+ code). Park the ids under a +// one-shot `session` ticket, then deep-link the desktop: +// rowboat://oauth/google/picker/done?session= +// (No need to exchange the code: the file is granted to the company +// client, so the desktop's existing managed token can read it.) +// +// POST ${API_URL}/v1/google-oauth/claim-picked body { session } +// Authenticated with the user's Rowboat bearer. Returns +// { fileIds: string[], tokens: { access_token, ... } } — a fresh +// drive.file token minted during the picker's own authorization. + +export interface ManagedPickResult { + path: string; + doc: GoogleDocListItem; +} + +interface PendingPick { + targetFolder: string; + resolve: (result: ManagedPickResult | null) => void; + reject: (error: Error) => void; + timer: NodeJS.Timeout; +} + +// Single in-flight pick (matches the one-at-a-time OAuth flow model). The deep +// link can't carry our targetFolder, so we stash it here for completion. +let pending: PendingPick | null = null; +const TIMEOUT_MS = 10 * 60 * 1000; + +function clearPending(): void { + if (pending) { + clearTimeout(pending.timer); + pending = null; + } +} + +function focusApp(): void { + const win = BrowserWindow.getAllWindows()[0]; + if (win) { + if (win.isMinimized()) win.restore(); + win.focus(); + } +} + +/** + * Open the managed picker in the browser and resolve once the deep link comes + * back with the user's selection (or null on cancel/timeout). The actual import + * happens in completeManagedGooglePick, fired by the deep-link dispatcher. + */ +export async function startManagedGooglePick(targetFolder: string): Promise { + // Supersede any abandoned flow so a stale deep link can't resolve this one. + if (pending) { + const stale = pending; + clearPending(); + stale.resolve(null); + } + + const webappUrl = await getWebappUrl(); + return await new Promise((resolve, reject) => { + const timer = setTimeout(() => { + if (pending) { + clearPending(); + resolve(null); + } + }, TIMEOUT_MS); + pending = { targetFolder, resolve, reject, timer }; + void shell.openExternal(`${webappUrl}/oauth/google/picker/start`); + }); +} + +/** + * Deep-link handler for rowboat://oauth/google/picker/done?session=. + * Claims the picked file ids from the backend and imports the first one with + * the existing managed token, resolving the promise startManagedGooglePick + * returned. + */ +export async function completeManagedGooglePick(session: string): Promise { + const current = pending; + if (!current) { + console.warn('[Picker] managed pick completion with no pending flow (timed out or already handled)'); + return; + } + clearPending(); + focusApp(); + + try { + const { fileIds, accessToken } = await claimPickedFilesViaBackend(session); + if (fileIds.length === 0 || !accessToken) { + current.resolve(null); + return; + } + // Download with the picker's own fresh drive.file token (the main + // connection doesn't carry drive.file). + const result = await importGoogleDocWithToken(fileIds[0], current.targetFolder, accessToken); + current.resolve(result); + } catch (error) { + current.reject(error instanceof Error ? error : new Error(String(error))); + } +} diff --git a/apps/x/apps/main/src/ipc.ts b/apps/x/apps/main/src/ipc.ts index 3c035b95..632ee247 100644 --- a/apps/x/apps/main/src/ipc.ts +++ b/apps/x/apps/main/src/ipc.ts @@ -52,7 +52,8 @@ 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, getGoogleAccessToken, syncGoogleDocDown, syncGoogleDocUp, getGoogleDocLink } from '@x/core/dist/knowledge/google_docs.js'; +import { getGoogleDocsConnectionStatus, importGoogleDoc, syncGoogleDocDown, syncGoogleDocUp, getGoogleDocLink } from '@x/core/dist/knowledge/google_docs.js'; +import { startManagedGooglePick } from './google-picker-managed.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,115 +816,6 @@ export function setupIpcHandlers() { 'google-docs:getStatus': async () => { return getGoogleDocsConnectionStatus(); }, - 'google-docs:getAccessToken': async () => { - return { accessToken: await getGoogleAccessToken() }; - }, - 'google-docs:openPicker': async (_event, args) => { - const { accessToken, apiKey } = args; - // Run the Picker in the user's real system browser (Chrome) rather than - // inside Electron. Google's Picker / sign-in 403s in an Electron window - // (non-standard browser), but works in a real browser. We serve the - // Picker page from a localhost server, open it via the OS browser, and - // the page reports the selection back to that same server (OAuth-style - // loopback). Token/key are injected server-side so they never hit history. - const DOC_MIME = 'application/vnd.google-apps.document'; - const DOCX_MIME = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'; - - // setAppId is REQUIRED for the drive.file scope: it tells Google which - // Cloud project (app) the picked file should be shared with. Without it, - // the selected file is never granted to our OAuth client and the later - // export/download 404s. The project number is the prefix of the OAuth - // client id (e.g. "916714831831-xxx.apps.googleusercontent.com"). - let appId = args.appId; - if (!appId) { - try { - const oauthJson = JSON.parse( - await fs.readFile(path.join(WorkDir, 'config', 'oauth.json'), 'utf8') - ); - const cid: string = oauthJson?.providers?.google?.clientId ?? ''; - const proj = cid.split('-')[0]; - if (/^\d+$/.test(proj)) appId = proj; - } catch { /* fall through — picker still works for native Google Docs */ } - } - console.log(`[Picker] opening with appId=${appId ?? '(none)'} apiKey=${apiKey ? 'set' : 'none'}`); - const pickerHtml = `Choose a document to sync - -
Loading Google Picker…
- -`; - - const donePage = `Done - -

✓ Selection sent to Rowboat

You can close this tab and return to the app.

`; - - const { createServer } = await import('node:http'); - - return new Promise<{ id: string; name: string; mimeType: string } | null>((resolve) => { - let settled = false; - const finish = (result: { id: string; name: string; mimeType: string } | null) => { - if (settled) return; - settled = true; - server.close(); - // Bring the app back to the foreground after the browser hand-off. - const w = BrowserWindow.getAllWindows()[0]; - if (w) { if (w.isMinimized()) w.restore(); w.focus(); } - resolve(result); - }; - - const server = createServer((req, res) => { - const u = new URL(req.url ?? '/', 'http://localhost'); - if (u.pathname === '/result') { - res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); - res.end(donePage); - if (u.searchParams.get('action') === 'picked') { - finish({ - id: u.searchParams.get('fileId') ?? '', - name: u.searchParams.get('name') ?? '', - mimeType: u.searchParams.get('mimeType') ?? '', - }); - } else { - finish(null); - } - return; - } - res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); - res.end(pickerHtml); - }); - - server.listen(0, '127.0.0.1', () => { - const port = (server.address() as { port: number }).port; - // Opens in the user's default browser (Chrome) — a trusted browser - // for Google, so the Picker and any sign-in work without the 403. - shell.openExternal(`http://localhost:${port}/`); - }); - - // Safety: don't leak the server/promise if the user never finishes. - setTimeout(() => finish(null), 5 * 60 * 1000); - }); - }, 'google-docs:import': async (_event, args) => { console.log(`[GoogleDocs] import fileId=${args.fileId} -> ${args.targetFolder}`); try { @@ -935,6 +827,17 @@ gapi.load('picker',function(){ throw err; } }, + // Managed (rowboat-mode) OAuth-redirect Picker: the Rowboat backend runs the + // pick with the company Google client; the desktop opens the start URL, + // waits for the deep link, and imports the picked doc with the existing + // managed token. No API key, appId, or local credentials. + 'google-docs:pickViaManaged': async (_event, args) => { + console.log(`[GoogleDocs] managed pick -> ${args.targetFolder}`); + const result = await startManagedGooglePick(args.targetFolder); + if (!result) return null; + console.log(`[GoogleDocs] managed pick import OK -> ${result.path}`); + return result; + }, 'google-docs:refreshSnapshot': async (_event, args) => { return syncGoogleDocDown(args.path); }, 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 79fd83fe..6c67b7d5 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 @@ -9,18 +9,8 @@ import { DialogTitle, } from '@/components/ui/dialog' 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 { getStoredPickerApiKey, setStoredPickerApiKey } from '@/lib/google-picker' import { toast } from '@/lib/toast' -type GoogleDocsStatus = { - connected: boolean - hasRequiredScopes: boolean - missingScopes: string[] -} - type GoogleDocPickerDialogProps = { open: boolean targetFolder: string @@ -34,138 +24,62 @@ export function GoogleDocPickerDialog({ onOpenChange, onImported, }: GoogleDocPickerDialogProps) { - const [status, setStatus] = useState(null) - const [connecting, setConnecting] = useState(false) + // The managed picker runs its own drive.file OAuth in the browser, gated on + // the Rowboat web session. So the only desktop prerequisite is being signed + // in to Rowboat — it needs NO prior Google connection and NO drive.file scope + // on the main grant (the picker grants drive.file per-file as you choose). + const [signedIn, setSignedIn] = useState(null) const [opening, setOpening] = useState(false) const [error, setError] = useState(null) - const [byokOpen, setByokOpen] = useState(false) - const [apiKey, setApiKey] = useState('') - const canPick = Boolean(status?.connected && status.hasRequiredScopes) const targetLabel = useMemo(() => targetFolder.replace(/^knowledge\/?/, '') || 'knowledge', [targetFolder]) const loadStatus = useCallback(async () => { - setError(null) try { - const result = await window.ipc.invoke('google-docs:getStatus', null) - setStatus(result) + const account = await window.ipc.invoke('account:getRowboat', null) + setSignedIn(account.signedIn) + setError(null) } catch (err) { - setStatus(null) - setError(err instanceof Error ? err.message : 'Failed to check Google connection') + setSignedIn(null) + setError(err instanceof Error ? err.message : 'Failed to check your Rowboat sign-in') } }, []) useEffect(() => { if (!open) return - setError(null) - setApiKey(getStoredPickerApiKey()) void loadStatus() }, [loadStatus, open]) - const handleConnect = useCallback(async () => { - setConnecting(true) - setError(null) - try { - const result = await window.ipc.invoke('oauth:connect', { provider: 'google' }) - if (!result.success) { - setError(result.error ?? 'Failed to start Google connection') - } else { - toast('Finish Google connection in the browser, then reopen the picker.', 'info') - } - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to start Google connection') - } finally { - setConnecting(false) - } - }, []) - - // 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) - setConnecting(true) - setError(null) - void window.ipc.invoke('oauth:connect', { provider: 'google', clientId, clientSecret }) - .then((result) => { - if (!result.success) setError(result.error ?? 'Failed to start Google connection') - else toast('Finish Google consent in the browser…', 'info') - }) - .catch((err) => setError(err instanceof Error ? err.message : 'Failed to start Google connection')) - .finally(() => setConnecting(false)) - }, []) - - // Re-check scopes as soon as a Google connection completes in the browser. - useEffect(() => { - if (!open) return - const cleanup = window.ipc.on('oauth:didConnect', (event) => { - if (event.provider !== 'google') return - void loadStatus() - }) - return cleanup - }, [open, loadStatus]) - const handleChoose = useCallback(async () => { setError(null) - const key = apiKey.trim() - if (!key) { - setError('Enter your Google Picker API key first.') - return - } - 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 - } - - // Open the Picker in the user's real browser (Chrome) via a localhost - // loopback in the main process. Google 403s the Picker inside Electron; - // a real browser is a trusted context. Close our modal during the hand-off. + // Managed pick: the Rowboat backend runs the whole grant + pick in the + // browser with the company Google client, then deep-links the selection + // back. No API key, BYOK creds, or redirect URL to configure. Close our + // modal during the hand-off. onOpenChange(false) - toast('Continue in your browser to choose a document…', 'info') - let picked: { id: string; name: string; mimeType: string } | null = null + toast('Continue in your browser: grant access and pick a document…', 'info') + let result: { path: string; doc: { name: string } } | null = null try { - picked = await window.ipc.invoke('google-docs:openPicker', { - accessToken, - apiKey: key || undefined, - }) + result = await window.ipc.invoke('google-docs:pickViaManaged', { targetFolder }) } catch (err) { setOpening(false) toast(err instanceof Error ? err.message : 'Failed to open the Google Picker', 'error') return } - if (!picked) { + if (!result) { setOpening(false) return } - try { - const result = await window.ipc.invoke('google-docs:import', { fileId: picked.id, targetFolder }) - toast(`Added “${picked.name}”`, 'success') - onImported(result.path) - } catch (err) { - toast(err instanceof Error ? err.message : 'Failed to import the document', 'error') - } finally { - setOpening(false) - } - }, [apiKey, targetFolder, onImported, onOpenChange]) + toast(`Added “${result.doc.name}”`, 'success') + onImported(result.path) + setOpening(false) + }, [targetFolder, onImported, onOpenChange]) return ( - <> @@ -176,7 +90,7 @@ export function GoogleDocPickerDialog({
- {!status && error ? ( + {signedIn === null && error ? (
{error}
- ) : !status ? ( + ) : signedIn === null ? (
- Checking Google connection... + Checking your Rowboat sign-in…
- ) : !canPick ? ( -
+ ) : !signedIn ? ( +
- To choose a document, Rowboat needs per-file Drive access (the drive.file scope). + Sign in to Rowboat to add Google Docs from Drive. The picker uses your + Rowboat account — no Google credentials or API key needed.
- {status.missingScopes.length > 0 && ( -
- Missing scopes: {status.missingScopes.join(', ')} -
- )} -
- - -
-

- 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. -

+
) : (
@@ -219,31 +120,13 @@ export function GoogleDocPickerDialog({ 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. -

-
+

+ You'll continue in your browser to grant access and choose a document — no + API key or setup needed. +

{error && ( -
-
- {error} -
- +
+ {error}
)}
- - ) } diff --git a/apps/x/apps/renderer/src/lib/google-picker.ts b/apps/x/apps/renderer/src/lib/google-picker.ts deleted file mode 100644 index d22a36b9..00000000 --- a/apps/x/apps/renderer/src/lib/google-picker.ts +++ /dev/null @@ -1,118 +0,0 @@ -// 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 - setOrigin: (o: string) => 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) - .setMimeTypes(`${DOC_MIME},${DOCX_MIME}`) - - const builder = new picker.PickerBuilder() - .addView(view) - .setOrigin(window.location.protocol + '//' + window.location.host) - .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/google-backend-oauth.ts b/apps/x/packages/core/src/auth/google-backend-oauth.ts index b3d77c42..1b1e1fb5 100644 --- a/apps/x/packages/core/src/auth/google-backend-oauth.ts +++ b/apps/x/packages/core/src/auth/google-backend-oauth.ts @@ -107,6 +107,29 @@ export async function claimTokensViaBackend(state: string): Promise return toOAuthTokens(body); } +/** + * Claim what the user selected in the managed OAuth-redirect Picker, parked + * under `session` by the webapp picker callback. Returns the picked file ids + * plus a fresh drive.file access token — the picker runs a standalone + * drive.file authorization (the main connection doesn't carry drive.file), so + * the desktop downloads the picked files with this token, not the main one. + */ +export async function claimPickedFilesViaBackend( + session: string, +): Promise<{ fileIds: string[]; accessToken: string }> { + const res = await postWithBearer("/v1/google-oauth/claim-picked", { session }); + if (!res.ok) { + const err = await readError(res); + throw new Error(`claim picked files failed: ${res.status} ${err.error ?? ""}`.trim()); + } + const body = (await res.json()) as { fileIds?: unknown; tokens?: { access_token?: unknown } }; + const fileIds = Array.isArray(body.fileIds) + ? body.fileIds.filter((id): id is string => typeof id === "string" && id.length > 0) + : []; + const accessToken = typeof body.tokens?.access_token === "string" ? body.tokens.access_token : ""; + return { fileIds, accessToken }; +} + /** * Refresh an access token via the api. Preserves caller's `refreshToken` and * `existingScopes` when Google omits them on the refresh response. diff --git a/apps/x/packages/core/src/knowledge/google_docs.ts b/apps/x/packages/core/src/knowledge/google_docs.ts index a2268ede..7d5d6f07 100644 --- a/apps/x/packages/core/src/knowledge/google_docs.ts +++ b/apps/x/packages/core/src/knowledge/google_docs.ts @@ -127,27 +127,43 @@ async function getDriveClient() { return google.drive({ version: 'v3', auth }); } +// Build a Drive client from a raw OAuth access token, bypassing the stored +// connection. Used by the OAuth-redirect Picker (trigger_onepick), which runs +// its own standalone drive.file authorization and hands back a fresh token — +// no stored connection, Picker API key, or appId involved. Read/export only; +// the token is short-lived and used immediately, so no refresh is wired up. +function driveClientFromToken(accessToken: string) { + const auth = new google.auth.OAuth2(); + auth.setCredentials({ access_token: accessToken }); + return google.drive({ version: 'v3', auth }); +} + // Get the file as .docx bytes: a native Google Doc is exported; an uploaded -// Word file is downloaded as-is. -async function fetchAsDocx(fileId: string, mimeType: string | undefined): Promise { - const driveClient = await getDriveClient(); +// Word file is downloaded as-is. Pass `driveClient` to use a specific token +// (e.g. the OAuth-redirect Picker's); omit it to use the stored connection. +async function fetchAsDocx( + fileId: string, + mimeType: string | undefined, + driveClient?: drive.Drive, +): Promise { + const dc = driveClient ?? await getDriveClient(); if (!mimeType || mimeType === GOOGLE_DOC_MIME) { - const result = await driveClient.files.export( + const result = await dc.files.export( { fileId, mimeType: DOCX_MIME }, { responseType: 'arraybuffer' }, ); return Buffer.from(result.data as ArrayBuffer); } - const result = await driveClient.files.get( + const result = await dc.files.get( { fileId, alt: 'media', supportsAllDrives: true }, { responseType: 'arraybuffer' }, ); return Buffer.from(result.data as ArrayBuffer); } -async function getDocMetadata(fileId: string): Promise { - const driveClient = await getDriveClient(); - const result = await driveClient.files.get({ +async function getDocMetadata(fileId: string, driveClient?: drive.Drive): Promise { + const dc = driveClient ?? await getDriveClient(); + const result = await dc.files.get({ fileId, fields: 'id,name,webViewLink,modifiedTime,mimeType,owners(displayName,emailAddress)', supportsAllDrives: true, @@ -195,21 +211,26 @@ export async function getGoogleDocsConnectionStatus(): Promise<{ return GoogleClientFactory.getCredentialStatus([...GOOGLE_DOC_SCOPES]); } -/** - * 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 { - // getClient() refreshes an expired token when it can. If it returns a token - // that's still past expiry, the refresh failed (e.g. no refresh_token) — hand - // back null rather than a dead token, so the UI prompts a reconnect instead - // of silently passing an expired token to the Picker (which 403s on it). - const auth = await GoogleClientFactory.getClient(); - const token = auth?.credentials?.access_token ?? null; - if (!token) return null; - const expiry = auth?.credentials?.expiry_date; - if (typeof expiry === 'number' && expiry <= Date.now()) return null; - return token; +// Write the exported .docx bytes into the knowledge folder and record the +// Drive link. Shared by both import paths (stored connection / explicit token). +async function writeDocxAndLink( + doc: GoogleDocListItem, + bytes: Buffer, + targetFolder: string, +): Promise { + const relPath = await uniqueDocxPath(targetFolder, doc.name); + const absPath = resolveWorkspacePath(relPath); + await fs.mkdir(path.dirname(absPath), { recursive: true }); + await fs.writeFile(absPath, bytes); + await setLink(relPath, { + id: doc.id, + url: doc.url, + title: doc.name, + syncedAt: new Date().toISOString(), + mimeType: doc.mimeType, + remoteModifiedTime: doc.modifiedTime ?? undefined, + }); + return relPath; } /** Import a Google Doc as a local .docx and register the link. */ @@ -223,18 +244,26 @@ export async function importGoogleDoc(fileId: string, targetFolder: string): Pro const doc = await getDocMetadata(fileId); const bytes = await fetchAsDocx(fileId, doc.mimeType); - const relPath = await uniqueDocxPath(targetFolder, doc.name); - const absPath = resolveWorkspacePath(relPath); - await fs.mkdir(path.dirname(absPath), { recursive: true }); - await fs.writeFile(absPath, bytes); - await setLink(relPath, { - id: doc.id, - url: doc.url, - title: doc.name, - syncedAt: new Date().toISOString(), - mimeType: doc.mimeType, - remoteModifiedTime: doc.modifiedTime ?? undefined, - }); + const relPath = await writeDocxAndLink(doc, bytes, targetFolder); + return { path: relPath, doc }; +} + +/** + * Import a Google Doc using an explicit OAuth access token instead of the + * stored Google connection. Powers the OAuth-redirect Picker (trigger_onepick): + * that flow runs its own standalone drive.file authorization, so the picked + * file is granted to a fresh token that has no other scopes and isn't persisted + * as the user's main connection. No Picker API key or appId is involved. + */ +export async function importGoogleDocWithToken( + fileId: string, + targetFolder: string, + accessToken: string, +): Promise<{ path: string; doc: GoogleDocListItem }> { + const driveClient = driveClientFromToken(accessToken); + const doc = await getDocMetadata(fileId, driveClient); + const bytes = await fetchAsDocx(fileId, doc.mimeType, driveClient); + const relPath = await writeDocxAndLink(doc, bytes, targetFolder); return { path: relPath, doc }; } diff --git a/apps/x/packages/shared/src/ipc.ts b/apps/x/packages/shared/src/ipc.ts index 4dc3e61a..05844e7b 100644 --- a/apps/x/packages/shared/src/ipc.ts +++ b/apps/x/packages/shared/src/ipc.ts @@ -650,27 +650,6 @@ const ipcSchemas = { missingScopes: z.array(z.string()), }), }, - // Live Google OAuth access token for driving the Google Picker in the renderer. - 'google-docs:getAccessToken': { - req: z.null(), - res: z.object({ - accessToken: z.string().nullable(), - }), - }, - // Open a Google Picker in a dedicated BrowserWindow (avoids session-cookie - // issues when running the Picker widget inside the renderer iframe). - 'google-docs:openPicker': { - req: z.object({ - accessToken: z.string(), - apiKey: z.string().optional(), - appId: z.string().optional(), - }), - res: z.object({ - id: z.string(), - name: z.string(), - mimeType: z.string(), - }).nullable(), - }, 'google-docs:import': { req: z.object({ fileId: z.string().min(1), @@ -687,6 +666,24 @@ const ipcSchemas = { }), }), }, + // Managed OAuth-redirect Picker: the Rowboat backend runs the pick with the + // company Google client; the desktop opens the start URL, waits for the deep + // link, and imports with the existing managed token. No API key or BYOK creds. + 'google-docs:pickViaManaged': { + req: z.object({ + targetFolder: RelPath, + }), + res: z.object({ + path: RelPath, + doc: z.object({ + id: z.string(), + name: z.string(), + url: z.string(), + modifiedTime: z.string().nullable(), + owner: z.string().nullable(), + }), + }).nullable(), + }, 'google-docs:refreshSnapshot': { req: z.object({ path: RelPath,