mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-07-09 20:52:20 +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 () => {
|
'google-docs:getAccessToken': async () => {
|
||||||
return { accessToken: await getGoogleAccessToken() };
|
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) => {
|
'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) => {
|
'google-docs:refreshSnapshot': async (_event, args) => {
|
||||||
return syncGoogleDocDown(args.path);
|
return syncGoogleDocDown(args.path);
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ import { Button } from '@/components/ui/button'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { GoogleClientIdModal } from '@/components/google-client-id-modal'
|
import { GoogleClientIdModal } from '@/components/google-client-id-modal'
|
||||||
import { setGoogleCredentials } from '@/lib/google-credentials-store'
|
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'
|
import { toast } from '@/lib/toast'
|
||||||
|
|
||||||
type GoogleDocsStatus = {
|
type GoogleDocsStatus = {
|
||||||
|
|
@ -131,24 +131,34 @@ export function GoogleDocPickerDialog({
|
||||||
return
|
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)
|
onOpenChange(false)
|
||||||
|
toast('Continue in your browser to choose a document…', 'info')
|
||||||
|
let picked: { id: string; name: string; mimeType: string } | null = null
|
||||||
try {
|
try {
|
||||||
await openGooglePicker({
|
picked = await window.ipc.invoke('google-docs:openPicker', {
|
||||||
accessToken,
|
accessToken,
|
||||||
apiKey: key,
|
apiKey: key || undefined,
|
||||||
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')
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
setOpening(false)
|
||||||
toast(err instanceof Error ? err.message : 'Failed to open the Google Picker', 'error')
|
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 {
|
} finally {
|
||||||
setOpening(false)
|
setOpening(false)
|
||||||
}
|
}
|
||||||
|
|
@ -226,8 +236,14 @@ export function GoogleDocPickerDialog({
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{error && (
|
{error && (
|
||||||
<div className="max-w-sm rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
<div className="flex max-w-sm flex-col items-center gap-2">
|
||||||
{error}
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
<Button onClick={() => void handleChoose()} disabled={opening}>
|
<Button onClick={() => void handleChoose()} disabled={opening}>
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ type PickerCallbackData = {
|
||||||
|
|
||||||
type PickerBuilder = {
|
type PickerBuilder = {
|
||||||
addView: (v: PickerView) => PickerBuilder
|
addView: (v: PickerView) => PickerBuilder
|
||||||
|
setOrigin: (o: string) => PickerBuilder
|
||||||
setOAuthToken: (t: string) => PickerBuilder
|
setOAuthToken: (t: string) => PickerBuilder
|
||||||
setDeveloperKey: (k: string) => PickerBuilder
|
setDeveloperKey: (k: string) => PickerBuilder
|
||||||
setAppId: (id: string) => PickerBuilder
|
setAppId: (id: string) => PickerBuilder
|
||||||
|
|
@ -96,11 +97,11 @@ export async function openGooglePicker(opts: {
|
||||||
|
|
||||||
const view = new picker.DocsView()
|
const view = new picker.DocsView()
|
||||||
.setIncludeFolders(false)
|
.setIncludeFolders(false)
|
||||||
.setOwnedByMe(false)
|
|
||||||
.setMimeTypes(`${DOC_MIME},${DOCX_MIME}`)
|
.setMimeTypes(`${DOC_MIME},${DOCX_MIME}`)
|
||||||
|
|
||||||
const builder = new picker.PickerBuilder()
|
const builder = new picker.PickerBuilder()
|
||||||
.addView(view)
|
.addView(view)
|
||||||
|
.setOrigin(window.location.protocol + '//' + window.location.host)
|
||||||
.setOAuthToken(opts.accessToken)
|
.setOAuthToken(opts.accessToken)
|
||||||
.setTitle('Choose a document to sync')
|
.setTitle('Choose a document to sync')
|
||||||
.setCallback((data) => {
|
.setCallback((data) => {
|
||||||
|
|
|
||||||
|
|
@ -657,6 +657,20 @@ const ipcSchemas = {
|
||||||
accessToken: z.string().nullable(),
|
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': {
|
'google-docs:import': {
|
||||||
req: z.object({
|
req: z.object({
|
||||||
fileId: z.string().min(1),
|
fileId: z.string().min(1),
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue