feat(google-docs): pick docs via system-browser Google Picker

Runs the Picker in the user's real browser (it 403s inside Electron), sets appId so the drive.file grant attaches to the picked file, and downloads + opens the selected doc.
This commit is contained in:
Gagancreates 2026-06-09 01:29:05 +05:30
parent bfcffa7d3a
commit 875b65d279
4 changed files with 162 additions and 17 deletions

View file

@ -818,8 +818,122 @@ export function setupIpcHandlers() {
'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) => {
return importGoogleDoc(args.fileId, args.targetFolder);
console.log(`[GoogleDocs] import fileId=${args.fileId} -> ${args.targetFolder}`);
try {
const result = await importGoogleDoc(args.fileId, args.targetFolder);
console.log(`[GoogleDocs] import OK -> ${result.path}`);
return result;
} catch (err) {
console.error('[GoogleDocs] import FAILED:', err instanceof Error ? err.message : err);
throw err;
}
},
'google-docs:refreshSnapshot': async (_event, args) => {
return syncGoogleDocDown(args.path);

View file

@ -12,7 +12,7 @@ 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 { openGooglePicker, getStoredPickerApiKey, setStoredPickerApiKey } from '@/lib/google-picker'
import { getStoredPickerApiKey, setStoredPickerApiKey } from '@/lib/google-picker'
import { toast } from '@/lib/toast'
type GoogleDocsStatus = {
@ -131,24 +131,34 @@ export function GoogleDocPickerDialog({
return
}
// Hand off to Google's Picker; close our modal so it isn't trapped behind it.
// 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.
onOpenChange(false)
toast('Continue in your browser to choose a document…', 'info')
let picked: { id: string; name: string; mimeType: string } | null = null
try {
await openGooglePicker({
picked = await window.ipc.invoke('google-docs:openPicker', {
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')
}
},
apiKey: key || undefined,
})
} catch (err) {
setOpening(false)
toast(err instanceof Error ? err.message : 'Failed to open the Google Picker', 'error')
return
}
if (!picked) {
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)
}
@ -226,8 +236,14 @@ export function GoogleDocPickerDialog({
</p>
</div>
{error && (
<div className="max-w-sm rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{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>
)}
<Button onClick={() => void handleChoose()} disabled={opening}>

View file

@ -21,6 +21,7 @@ type PickerCallbackData = {
type PickerBuilder = {
addView: (v: PickerView) => PickerBuilder
setOrigin: (o: string) => PickerBuilder
setOAuthToken: (t: string) => PickerBuilder
setDeveloperKey: (k: string) => PickerBuilder
setAppId: (id: string) => PickerBuilder
@ -96,11 +97,11 @@ export async function openGooglePicker(opts: {
const view = new picker.DocsView()
.setIncludeFolders(false)
.setOwnedByMe(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) => {

View file

@ -657,6 +657,20 @@ const ipcSchemas = {
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),