feat(google-docs): managed OAuth-redirect Picker (no API key, no BYOK) (#620)

* feat(google-docs): managed OAuth-redirect Picker (no API key, no BYOK)

Adds the managed (rowboat-mode) Google Docs picker via Google's trigger_onepick
flow. The Rowboat backend runs a standalone drive.file OAuth with the company
client, renders the Picker inside the browser consent screen, and deep-links the
selection back; the desktop downloads the picked doc with the fresh drive.file
token the backend returns. No Picker API key, appId, or BYOK credentials on the
desktop.

- core: importGoogleDocWithToken downloads a picked doc with an explicit token;
  fetch/metadata helpers take an optional Drive client and share writeDocxAndLink.
  claimPickedFilesViaBackend claims the parked file ids + token from the api.
- main: google-picker-managed.ts opens the backend start URL and resolves on the
  rowboat://oauth/google/picker/done deep link; deeplink.ts routes that completion.
- ipc: google-docs:pickViaManaged.
- renderer: the picker dialog gates on Rowboat sign-in (the picker grants
  drive.file per-file, so no pre-existing connection or scope is required).

Backend contract: rowboatlabs/rowboatx-backend#7
(GET /oauth/google/picker/{start,callback}, POST /v1/google-oauth/claim-picked).

* chore(google-docs): remove the dead API-key/system-browser Picker

The managed picker replaced the only consumer (the picker dialog), so the
experimental API-key Picker is now unused. Removes:
- main: google-docs:openPicker handler (system-browser loopback Picker)
- shared: google-docs:openPicker + google-docs:getAccessToken IPC schemas
- core: getGoogleAccessToken (token plumbing for the client-side Picker)
- renderer: lib/google-picker.ts (Picker JS SDK loader)

Kept GoogleClientIdModal / google-credentials-store — still used by the
general BYOK Google connect in onboarding, connectors, and settings.
This commit is contained in:
gagan 2026-06-22 13:57:37 -07:00 committed by GitHub
parent 875b65d279
commit 67b521489c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 314 additions and 447 deletions

View file

@ -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<void> {
await completeRowboatGoogleConnect(parsed.state);
}
// --- Managed OAuth-redirect Picker completion ---
interface PickerCompletion {
state: string;
}
/**
* Match rowboat://oauth/google/picker/done?session=<state>. 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<void> {
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();

View file

@ -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=<state>
// (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<ManagedPickResult | null> {
// 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<ManagedPickResult | null>((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=<state>.
* 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<void> {
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)));
}
}

View file

@ -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 = `<!DOCTYPE html><html><head><meta charset="utf-8"><title>Choose a document to sync</title>
<style>body{margin:0;background:#fff;display:flex;align-items:center;justify-content:center;height:100vh;font-family:sans-serif;color:#555}
#msg{font-size:15px}</style></head>
<body><div id="msg">Loading Google Picker</div>
<script src="https://apis.google.com/js/api.js"></script>
<script>
gapi.load('picker',function(){
var view=new google.picker.DocsView()
.setIncludeFolders(false)
.setMimeTypes('${DOC_MIME},${DOCX_MIME}');
var b=new google.picker.PickerBuilder()
.addView(view)
.setOAuthToken(${JSON.stringify(accessToken)})
.setTitle('Choose a document to sync')
.setCallback(function(d){
if(d.action===google.picker.Action.PICKED&&d.docs&&d.docs[0]){
var f=d.docs[0];
window.location.href='/result?action=picked&fileId='+encodeURIComponent(f.id)+'&name='+encodeURIComponent(f.name)+'&mimeType='+encodeURIComponent(f.mimeType);
} else if(d.action===google.picker.Action.CANCEL){
window.location.href='/result?action=cancel';
}
});
${apiKey ? `b.setDeveloperKey(${JSON.stringify(apiKey)});` : ''}
${appId ? `b.setAppId(${JSON.stringify(appId)});` : ''}
document.getElementById('msg').style.display='none';
b.build().setVisible(true);
});
</script></body></html>`;
const donePage = `<!DOCTYPE html><html><head><meta charset="utf-8"><title>Done</title>
<style>body{font-family:sans-serif;text-align:center;margin-top:120px;color:#444}</style></head>
<body><h2> Selection sent to Rowboat</h2><p>You can close this tab and return to the app.</p></body></html>`;
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);
},

View file

@ -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<GoogleDocsStatus | null>(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<boolean | null>(null)
const [opening, setOpening] = useState(false)
const [error, setError] = useState<string | null>(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 (
<>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="flex max-h-[min(720px,calc(100vh-4rem))] max-w-lg flex-col gap-0 overflow-hidden p-0">
<DialogHeader className="shrink-0 border-b border-border px-5 py-4">
@ -176,7 +90,7 @@ export function GoogleDocPickerDialog({
</DialogHeader>
<div className="flex min-h-0 flex-1 flex-col">
{!status && error ? (
{signedIn === null && error ? (
<div className="flex min-h-[280px] flex-1 flex-col items-center justify-center gap-4 px-8 text-center">
<div className="max-w-sm text-sm text-destructive">{error}</div>
<Button variant="outline" onClick={() => void loadStatus()}>
@ -184,34 +98,21 @@ export function GoogleDocPickerDialog({
Retry
</Button>
</div>
) : !status ? (
) : signedIn === null ? (
<div className="flex min-h-[280px] flex-1 items-center justify-center text-sm text-muted-foreground">
<Loader2 className="mr-2 size-4 animate-spin" />
Checking Google connection...
Checking your Rowboat sign-in
</div>
) : !canPick ? (
<div className="flex min-h-[300px] flex-1 flex-col items-center justify-center gap-4 overflow-y-auto px-8 py-8 text-center">
) : !signedIn ? (
<div className="flex min-h-[300px] flex-1 flex-col items-center justify-center gap-4 px-8 py-8 text-center">
<div className="max-w-sm text-sm text-muted-foreground">
To choose a document, Rowboat needs per-file Drive access (the <code>drive.file</code> scope).
Sign in to Rowboat to add Google Docs from Drive. The picker uses your
Rowboat account no Google credentials or API key needed.
</div>
{status.missingScopes.length > 0 && (
<div className="max-w-md rounded-md border border-border bg-muted/30 px-3 py-2 text-left text-xs text-muted-foreground">
Missing scopes: {status.missingScopes.join(', ')}
</div>
)}
<div className="flex w-full max-w-xs flex-col gap-2">
<Button onClick={() => setByokOpen(true)} disabled={connecting}>
{connecting ? <Loader2 className="size-4 animate-spin" /> : <RefreshCw className="size-4" />}
Connect with your Google credentials
</Button>
<Button variant="outline" onClick={handleConnect} disabled={connecting}>
Use managed Google sign-in
</Button>
</div>
<p className="max-w-sm text-xs text-muted-foreground">
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&nbsp;API and Picker&nbsp;API enabled.
</p>
<Button variant="outline" onClick={() => void loadStatus()}>
<RefreshCw className="size-4" />
I&apos;ve signed in retry
</Button>
</div>
) : (
<div className="flex min-h-[300px] flex-1 flex-col items-center justify-center gap-4 px-8 py-8 text-center">
@ -219,31 +120,13 @@ export function GoogleDocPickerDialog({
Pick a Google Doc or Word file from your Drive. It imports as an editable
<code> .docx</code> and stays linked for two-way sync.
</div>
<div className="flex w-full max-w-sm flex-col gap-1.5 text-left">
<label htmlFor="picker-api-key" className="text-xs font-medium text-muted-foreground">
Google Picker API key
</label>
<Input
id="picker-api-key"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
placeholder="AIza…"
className="font-mono text-xs"
/>
<p className="text-xs text-muted-foreground">
From your Google Cloud project (APIs &amp; Services Credentials API key),
with the Picker&nbsp;API enabled. Stored locally.
</p>
</div>
<p className="max-w-sm text-xs text-muted-foreground">
You&apos;ll continue in your browser to grant access and choose a document no
API key or setup needed.
</p>
{error && (
<div className="flex max-w-sm flex-col items-center gap-2">
<div className="w-full rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{error}
</div>
<Button variant="outline" size="sm" onClick={() => setByokOpen(true)} disabled={connecting}>
<RefreshCw className="size-4" />
Reconnect Google
</Button>
<div className="w-full max-w-sm rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{error}
</div>
)}
<Button onClick={() => void handleChoose()} disabled={opening}>
@ -255,12 +138,5 @@ export function GoogleDocPickerDialog({
</div>
</DialogContent>
</Dialog>
<GoogleClientIdModal
open={byokOpen}
onOpenChange={setByokOpen}
onSubmit={handleByokSubmit}
description="Enter a Google OAuth client (Desktop app) with the Drive API and Picker API enabled."
/>
</>
)
}

View file

@ -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<void> | null = null
function loadPickerApi(): Promise<void> {
if (pickerLoaded) return pickerLoaded
pickerLoaded = new Promise<void>((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<HTMLScriptElement>(`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<void> {
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)
}

View file

@ -107,6 +107,29 @@ export async function claimTokensViaBackend(state: string): Promise<OAuthTokens>
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.

View file

@ -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<Buffer> {
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<Buffer> {
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<GoogleDocListItem> {
const driveClient = await getDriveClient();
const result = await driveClient.files.get({
async function getDocMetadata(fileId: string, driveClient?: drive.Drive): Promise<GoogleDocListItem> {
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<string | null> {
// 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<string> {
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 };
}

View file

@ -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,