mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-06-12 19:55:19 +02:00
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:
parent
bfcffa7d3a
commit
875b65d279
4 changed files with 162 additions and 17 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue