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({

{error && ( -
- {error} +
+
+ {error} +
+
)}