From 875b65d27974f2d56b1bdd4113f83a5eeb14cb4d Mon Sep 17 00:00:00 2001
From: Gagancreates
Date: Tue, 9 Jun 2026 01:29:05 +0530
Subject: [PATCH] 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.
---
apps/x/apps/main/src/ipc.ts | 116 +++++++++++++++++-
.../components/google-doc-picker-dialog.tsx | 46 ++++---
apps/x/apps/renderer/src/lib/google-picker.ts | 3 +-
apps/x/packages/shared/src/ipc.ts | 14 +++
4 files changed, 162 insertions(+), 17 deletions(-)
diff --git a/apps/x/apps/main/src/ipc.ts b/apps/x/apps/main/src/ipc.ts
index eb6e1c7a..3c035b95 100644
--- a/apps/x/apps/main/src/ipc.ts
+++ b/apps/x/apps/main/src/ipc.ts
@@ -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 = `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) => {
- 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);
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 d3fbd7dd..79fd83fe 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
@@ -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({