mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-06-27 20:29:44 +02:00
feat(google-docs): managed OAuth-redirect Picker (no API key, no BYOK) (#620)
* feat(google-docs): managed OAuth-redirect Picker (no API key, no BYOK) Adds the managed (rowboat-mode) Google Docs picker via Google's trigger_onepick flow. The Rowboat backend runs a standalone drive.file OAuth with the company client, renders the Picker inside the browser consent screen, and deep-links the selection back; the desktop downloads the picked doc with the fresh drive.file token the backend returns. No Picker API key, appId, or BYOK credentials on the desktop. - core: importGoogleDocWithToken downloads a picked doc with an explicit token; fetch/metadata helpers take an optional Drive client and share writeDocxAndLink. claimPickedFilesViaBackend claims the parked file ids + token from the api. - main: google-picker-managed.ts opens the backend start URL and resolves on the rowboat://oauth/google/picker/done deep link; deeplink.ts routes that completion. - ipc: google-docs:pickViaManaged. - renderer: the picker dialog gates on Rowboat sign-in (the picker grants drive.file per-file, so no pre-existing connection or scope is required). Backend contract: rowboatlabs/rowboatx-backend#7 (GET /oauth/google/picker/{start,callback}, POST /v1/google-oauth/claim-picked). * chore(google-docs): remove the dead API-key/system-browser Picker The managed picker replaced the only consumer (the picker dialog), so the experimental API-key Picker is now unused. Removes: - main: google-docs:openPicker handler (system-browser loopback Picker) - shared: google-docs:openPicker + google-docs:getAccessToken IPC schemas - core: getGoogleAccessToken (token plumbing for the client-side Picker) - renderer: lib/google-picker.ts (Picker JS SDK loader) Kept GoogleClientIdModal / google-credentials-store — still used by the general BYOK Google connect in onboarding, connectors, and settings.
This commit is contained in:
parent
875b65d279
commit
67b521489c
8 changed files with 314 additions and 447 deletions
|
|
@ -39,6 +39,8 @@ export function extractDeepLinkFromArgv(argv: readonly string[]): string | null
|
||||||
export function dispatchUrl(url: string): void {
|
export function dispatchUrl(url: string): void {
|
||||||
if (parseAction(url)) {
|
if (parseAction(url)) {
|
||||||
void dispatchAction(url);
|
void dispatchAction(url);
|
||||||
|
} else if (parsePickerCompletion(url)) {
|
||||||
|
void dispatchPickerCompletion(url);
|
||||||
} else if (parseOAuthCompletion(url)) {
|
} else if (parseOAuthCompletion(url)) {
|
||||||
void dispatchOAuthCompletion(url);
|
void dispatchOAuthCompletion(url);
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -158,6 +160,41 @@ async function dispatchOAuthCompletion(url: string): Promise<void> {
|
||||||
await completeRowboatGoogleConnect(parsed.state);
|
await completeRowboatGoogleConnect(parsed.state);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Managed OAuth-redirect Picker completion ---
|
||||||
|
|
||||||
|
interface PickerCompletion {
|
||||||
|
state: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Match rowboat://oauth/google/picker/done?session=<state>. Distinct from the
|
||||||
|
* connect completion above (oauth/google/done) by the extra `picker` segment.
|
||||||
|
*/
|
||||||
|
function parsePickerCompletion(url: string): PickerCompletion | null {
|
||||||
|
if (!url.startsWith(URL_PREFIX)) return null;
|
||||||
|
const rest = url.slice(URL_PREFIX.length);
|
||||||
|
const queryIdx = rest.indexOf("?");
|
||||||
|
const path = queryIdx >= 0 ? rest.slice(0, queryIdx) : rest;
|
||||||
|
const parts = path.split("/").filter(Boolean);
|
||||||
|
if (parts.length !== 4) return null;
|
||||||
|
if (parts[0] !== "oauth" || parts[1] !== "google" || parts[2] !== "picker" || parts[3] !== "done") return null;
|
||||||
|
const params = new URLSearchParams(queryIdx >= 0 ? rest.slice(queryIdx + 1) : "");
|
||||||
|
const state = params.get("session");
|
||||||
|
return state ? { state } : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function dispatchPickerCompletion(url: string): Promise<void> {
|
||||||
|
const parsed = parsePickerCompletion(url);
|
||||||
|
if (!parsed) return;
|
||||||
|
|
||||||
|
const win = mainWindowRef;
|
||||||
|
if (win && !win.isDestroyed()) focusWindow(win);
|
||||||
|
|
||||||
|
// Lazy-import to keep deeplink.ts free of the picker's OAuth/knowledge deps.
|
||||||
|
const { completeManagedGooglePick } = await import("./google-picker-managed.js");
|
||||||
|
await completeManagedGooglePick(parsed.state);
|
||||||
|
}
|
||||||
|
|
||||||
function focusWindow(win: BrowserWindow): void {
|
function focusWindow(win: BrowserWindow): void {
|
||||||
if (win.isMinimized()) win.restore();
|
if (win.isMinimized()) win.restore();
|
||||||
win.show();
|
win.show();
|
||||||
|
|
|
||||||
120
apps/x/apps/main/src/google-picker-managed.ts
Normal file
120
apps/x/apps/main/src/google-picker-managed.ts
Normal file
|
|
@ -0,0 +1,120 @@
|
||||||
|
import { shell, BrowserWindow } from 'electron';
|
||||||
|
import { getWebappUrl } from '@x/core/dist/config/remote-config.js';
|
||||||
|
import { claimPickedFilesViaBackend } from '@x/core/dist/auth/google-backend-oauth.js';
|
||||||
|
import { importGoogleDocWithToken } from '@x/core/dist/knowledge/google_docs.js';
|
||||||
|
import type { GoogleDocListItem } from '@x/core/dist/knowledge/google_docs.js';
|
||||||
|
|
||||||
|
// Managed (rowboat-mode) OAuth-redirect Picker. Unlike BYOK, the OAuth runs on
|
||||||
|
// the Rowboat backend with the COMPANY Google client — the desktop never holds
|
||||||
|
// a client_id/secret or an API key. The desktop just opens the start URL, waits
|
||||||
|
// for the deep link, claims the picked file ids, and downloads them with the
|
||||||
|
// user's EXISTING managed Google token (which already holds drive.file from the
|
||||||
|
// main connect). No Picker API key, appId, ngrok, or local OAuth.
|
||||||
|
//
|
||||||
|
// Backend contract (Rowboat webapp/api — NOT this repo). Mirrors the existing
|
||||||
|
// managed Google-connect (start URL → park under session → deep-link back):
|
||||||
|
//
|
||||||
|
// GET ${webappUrl}/oauth/google/picker/start
|
||||||
|
// Runs Google OAuth with the company client, scope=drive.file ONLY,
|
||||||
|
// trigger_onepick=true, prompt=consent. Tied to the logged-in web
|
||||||
|
// session (cookies), exactly like /oauth/google/start.
|
||||||
|
//
|
||||||
|
// GET ${webappUrl}/oauth/google/picker/callback
|
||||||
|
// Google returns `picked_file_ids` (+ code). Park the ids under a
|
||||||
|
// one-shot `session` ticket, then deep-link the desktop:
|
||||||
|
// rowboat://oauth/google/picker/done?session=<state>
|
||||||
|
// (No need to exchange the code: the file is granted to the company
|
||||||
|
// client, so the desktop's existing managed token can read it.)
|
||||||
|
//
|
||||||
|
// POST ${API_URL}/v1/google-oauth/claim-picked body { session }
|
||||||
|
// Authenticated with the user's Rowboat bearer. Returns
|
||||||
|
// { fileIds: string[], tokens: { access_token, ... } } — a fresh
|
||||||
|
// drive.file token minted during the picker's own authorization.
|
||||||
|
|
||||||
|
export interface ManagedPickResult {
|
||||||
|
path: string;
|
||||||
|
doc: GoogleDocListItem;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PendingPick {
|
||||||
|
targetFolder: string;
|
||||||
|
resolve: (result: ManagedPickResult | null) => void;
|
||||||
|
reject: (error: Error) => void;
|
||||||
|
timer: NodeJS.Timeout;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single in-flight pick (matches the one-at-a-time OAuth flow model). The deep
|
||||||
|
// link can't carry our targetFolder, so we stash it here for completion.
|
||||||
|
let pending: PendingPick | null = null;
|
||||||
|
const TIMEOUT_MS = 10 * 60 * 1000;
|
||||||
|
|
||||||
|
function clearPending(): void {
|
||||||
|
if (pending) {
|
||||||
|
clearTimeout(pending.timer);
|
||||||
|
pending = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function focusApp(): void {
|
||||||
|
const win = BrowserWindow.getAllWindows()[0];
|
||||||
|
if (win) {
|
||||||
|
if (win.isMinimized()) win.restore();
|
||||||
|
win.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open the managed picker in the browser and resolve once the deep link comes
|
||||||
|
* back with the user's selection (or null on cancel/timeout). The actual import
|
||||||
|
* happens in completeManagedGooglePick, fired by the deep-link dispatcher.
|
||||||
|
*/
|
||||||
|
export async function startManagedGooglePick(targetFolder: string): Promise<ManagedPickResult | null> {
|
||||||
|
// Supersede any abandoned flow so a stale deep link can't resolve this one.
|
||||||
|
if (pending) {
|
||||||
|
const stale = pending;
|
||||||
|
clearPending();
|
||||||
|
stale.resolve(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
const webappUrl = await getWebappUrl();
|
||||||
|
return await new Promise<ManagedPickResult | null>((resolve, reject) => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
if (pending) {
|
||||||
|
clearPending();
|
||||||
|
resolve(null);
|
||||||
|
}
|
||||||
|
}, TIMEOUT_MS);
|
||||||
|
pending = { targetFolder, resolve, reject, timer };
|
||||||
|
void shell.openExternal(`${webappUrl}/oauth/google/picker/start`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deep-link handler for rowboat://oauth/google/picker/done?session=<state>.
|
||||||
|
* Claims the picked file ids from the backend and imports the first one with
|
||||||
|
* the existing managed token, resolving the promise startManagedGooglePick
|
||||||
|
* returned.
|
||||||
|
*/
|
||||||
|
export async function completeManagedGooglePick(session: string): Promise<void> {
|
||||||
|
const current = pending;
|
||||||
|
if (!current) {
|
||||||
|
console.warn('[Picker] managed pick completion with no pending flow (timed out or already handled)');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
clearPending();
|
||||||
|
focusApp();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { fileIds, accessToken } = await claimPickedFilesViaBackend(session);
|
||||||
|
if (fileIds.length === 0 || !accessToken) {
|
||||||
|
current.resolve(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Download with the picker's own fresh drive.file token (the main
|
||||||
|
// connection doesn't carry drive.file).
|
||||||
|
const result = await importGoogleDocWithToken(fileIds[0], current.targetFolder, accessToken);
|
||||||
|
current.resolve(result);
|
||||||
|
} catch (error) {
|
||||||
|
current.reject(error instanceof Error ? error : new Error(String(error)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -52,7 +52,8 @@ import { getAccessToken } from '@x/core/dist/auth/tokens.js';
|
||||||
import { getRowboatConfig } from '@x/core/dist/config/rowboat.js';
|
import { getRowboatConfig } from '@x/core/dist/config/rowboat.js';
|
||||||
import { runLiveNoteAgent } from '@x/core/dist/knowledge/live-note/runner.js';
|
import { runLiveNoteAgent } from '@x/core/dist/knowledge/live-note/runner.js';
|
||||||
import { listImportantThreads, listEverythingElseThreads, saveMessageBodyHeight, triggerSync as triggerGmailSync, sendThreadReply, archiveThread, trashThread, markThreadRead, getAccountEmail, getConnectionStatus as getGmailConnectionStatus } from '@x/core/dist/knowledge/sync_gmail.js';
|
import { listImportantThreads, listEverythingElseThreads, saveMessageBodyHeight, triggerSync as triggerGmailSync, sendThreadReply, archiveThread, trashThread, markThreadRead, getAccountEmail, getConnectionStatus as getGmailConnectionStatus } from '@x/core/dist/knowledge/sync_gmail.js';
|
||||||
import { getGoogleDocsConnectionStatus, importGoogleDoc, getGoogleAccessToken, syncGoogleDocDown, syncGoogleDocUp, getGoogleDocLink } from '@x/core/dist/knowledge/google_docs.js';
|
import { getGoogleDocsConnectionStatus, importGoogleDoc, syncGoogleDocDown, syncGoogleDocUp, getGoogleDocLink } from '@x/core/dist/knowledge/google_docs.js';
|
||||||
|
import { startManagedGooglePick } from './google-picker-managed.js';
|
||||||
import { liveNoteBus } from '@x/core/dist/knowledge/live-note/bus.js';
|
import { liveNoteBus } from '@x/core/dist/knowledge/live-note/bus.js';
|
||||||
import { getInstallationId } from '@x/core/dist/analytics/installation.js';
|
import { getInstallationId } from '@x/core/dist/analytics/installation.js';
|
||||||
import { API_URL } from '@x/core/dist/config/env.js';
|
import { API_URL } from '@x/core/dist/config/env.js';
|
||||||
|
|
@ -815,115 +816,6 @@ export function setupIpcHandlers() {
|
||||||
'google-docs:getStatus': async () => {
|
'google-docs:getStatus': async () => {
|
||||||
return getGoogleDocsConnectionStatus();
|
return getGoogleDocsConnectionStatus();
|
||||||
},
|
},
|
||||||
'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) => {
|
'google-docs:import': async (_event, args) => {
|
||||||
console.log(`[GoogleDocs] import fileId=${args.fileId} -> ${args.targetFolder}`);
|
console.log(`[GoogleDocs] import fileId=${args.fileId} -> ${args.targetFolder}`);
|
||||||
try {
|
try {
|
||||||
|
|
@ -935,6 +827,17 @@ gapi.load('picker',function(){
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
// Managed (rowboat-mode) OAuth-redirect Picker: the Rowboat backend runs the
|
||||||
|
// pick with the company Google client; the desktop opens the start URL,
|
||||||
|
// waits for the deep link, and imports the picked doc with the existing
|
||||||
|
// managed token. No API key, appId, or local credentials.
|
||||||
|
'google-docs:pickViaManaged': async (_event, args) => {
|
||||||
|
console.log(`[GoogleDocs] managed pick -> ${args.targetFolder}`);
|
||||||
|
const result = await startManagedGooglePick(args.targetFolder);
|
||||||
|
if (!result) return null;
|
||||||
|
console.log(`[GoogleDocs] managed pick import OK -> ${result.path}`);
|
||||||
|
return result;
|
||||||
|
},
|
||||||
'google-docs:refreshSnapshot': async (_event, args) => {
|
'google-docs:refreshSnapshot': async (_event, args) => {
|
||||||
return syncGoogleDocDown(args.path);
|
return syncGoogleDocDown(args.path);
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -9,18 +9,8 @@ import {
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from '@/components/ui/dialog'
|
} from '@/components/ui/dialog'
|
||||||
import { Button } from '@/components/ui/button'
|
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 { getStoredPickerApiKey, setStoredPickerApiKey } from '@/lib/google-picker'
|
|
||||||
import { toast } from '@/lib/toast'
|
import { toast } from '@/lib/toast'
|
||||||
|
|
||||||
type GoogleDocsStatus = {
|
|
||||||
connected: boolean
|
|
||||||
hasRequiredScopes: boolean
|
|
||||||
missingScopes: string[]
|
|
||||||
}
|
|
||||||
|
|
||||||
type GoogleDocPickerDialogProps = {
|
type GoogleDocPickerDialogProps = {
|
||||||
open: boolean
|
open: boolean
|
||||||
targetFolder: string
|
targetFolder: string
|
||||||
|
|
@ -34,138 +24,62 @@ export function GoogleDocPickerDialog({
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
onImported,
|
onImported,
|
||||||
}: GoogleDocPickerDialogProps) {
|
}: GoogleDocPickerDialogProps) {
|
||||||
const [status, setStatus] = useState<GoogleDocsStatus | null>(null)
|
// The managed picker runs its own drive.file OAuth in the browser, gated on
|
||||||
const [connecting, setConnecting] = useState(false)
|
// the Rowboat web session. So the only desktop prerequisite is being signed
|
||||||
|
// in to Rowboat — it needs NO prior Google connection and NO drive.file scope
|
||||||
|
// on the main grant (the picker grants drive.file per-file as you choose).
|
||||||
|
const [signedIn, setSignedIn] = useState<boolean | null>(null)
|
||||||
const [opening, setOpening] = useState(false)
|
const [opening, setOpening] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [byokOpen, setByokOpen] = useState(false)
|
|
||||||
const [apiKey, setApiKey] = useState('')
|
|
||||||
|
|
||||||
const canPick = Boolean(status?.connected && status.hasRequiredScopes)
|
|
||||||
const targetLabel = useMemo(() => targetFolder.replace(/^knowledge\/?/, '') || 'knowledge', [targetFolder])
|
const targetLabel = useMemo(() => targetFolder.replace(/^knowledge\/?/, '') || 'knowledge', [targetFolder])
|
||||||
|
|
||||||
const loadStatus = useCallback(async () => {
|
const loadStatus = useCallback(async () => {
|
||||||
setError(null)
|
|
||||||
try {
|
try {
|
||||||
const result = await window.ipc.invoke('google-docs:getStatus', null)
|
const account = await window.ipc.invoke('account:getRowboat', null)
|
||||||
setStatus(result)
|
setSignedIn(account.signedIn)
|
||||||
|
setError(null)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setStatus(null)
|
setSignedIn(null)
|
||||||
setError(err instanceof Error ? err.message : 'Failed to check Google connection')
|
setError(err instanceof Error ? err.message : 'Failed to check your Rowboat sign-in')
|
||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return
|
if (!open) return
|
||||||
setError(null)
|
|
||||||
setApiKey(getStoredPickerApiKey())
|
|
||||||
void loadStatus()
|
void loadStatus()
|
||||||
}, [loadStatus, open])
|
}, [loadStatus, open])
|
||||||
|
|
||||||
const handleConnect = useCallback(async () => {
|
|
||||||
setConnecting(true)
|
|
||||||
setError(null)
|
|
||||||
try {
|
|
||||||
const result = await window.ipc.invoke('oauth:connect', { provider: 'google' })
|
|
||||||
if (!result.success) {
|
|
||||||
setError(result.error ?? 'Failed to start Google connection')
|
|
||||||
} else {
|
|
||||||
toast('Finish Google connection in the browser, then reopen the picker.', 'info')
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof Error ? err.message : 'Failed to start Google connection')
|
|
||||||
} finally {
|
|
||||||
setConnecting(false)
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
// BYOK: connect Google with the user's own OAuth client, which requests the
|
|
||||||
// drive.file scope locally (managed sign-in can't grant it without a backend
|
|
||||||
// change).
|
|
||||||
const handleByokSubmit = useCallback((clientId: string, clientSecret: string) => {
|
|
||||||
setGoogleCredentials(clientId, clientSecret)
|
|
||||||
setByokOpen(false)
|
|
||||||
setConnecting(true)
|
|
||||||
setError(null)
|
|
||||||
void window.ipc.invoke('oauth:connect', { provider: 'google', clientId, clientSecret })
|
|
||||||
.then((result) => {
|
|
||||||
if (!result.success) setError(result.error ?? 'Failed to start Google connection')
|
|
||||||
else toast('Finish Google consent in the browser…', 'info')
|
|
||||||
})
|
|
||||||
.catch((err) => setError(err instanceof Error ? err.message : 'Failed to start Google connection'))
|
|
||||||
.finally(() => setConnecting(false))
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
// Re-check scopes as soon as a Google connection completes in the browser.
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open) return
|
|
||||||
const cleanup = window.ipc.on('oauth:didConnect', (event) => {
|
|
||||||
if (event.provider !== 'google') return
|
|
||||||
void loadStatus()
|
|
||||||
})
|
|
||||||
return cleanup
|
|
||||||
}, [open, loadStatus])
|
|
||||||
|
|
||||||
const handleChoose = useCallback(async () => {
|
const handleChoose = useCallback(async () => {
|
||||||
setError(null)
|
setError(null)
|
||||||
const key = apiKey.trim()
|
|
||||||
if (!key) {
|
|
||||||
setError('Enter your Google Picker API key first.')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setStoredPickerApiKey(key)
|
|
||||||
setOpening(true)
|
setOpening(true)
|
||||||
|
|
||||||
let accessToken: string | null = null
|
// Managed pick: the Rowboat backend runs the whole grant + pick in the
|
||||||
try {
|
// browser with the company Google client, then deep-links the selection
|
||||||
const res = await window.ipc.invoke('google-docs:getAccessToken', null)
|
// back. No API key, BYOK creds, or redirect URL to configure. Close our
|
||||||
accessToken = res.accessToken
|
// modal during the hand-off.
|
||||||
} catch (err) {
|
|
||||||
setOpening(false)
|
|
||||||
setError(err instanceof Error ? err.message : 'Failed to get a Google access token')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (!accessToken) {
|
|
||||||
setOpening(false)
|
|
||||||
setError('Google access token unavailable — reconnect Google.')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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')
|
toast('Continue in your browser: grant access and pick a document…', 'info')
|
||||||
let picked: { id: string; name: string; mimeType: string } | null = null
|
let result: { path: string; doc: { name: string } } | null = null
|
||||||
try {
|
try {
|
||||||
picked = await window.ipc.invoke('google-docs:openPicker', {
|
result = await window.ipc.invoke('google-docs:pickViaManaged', { targetFolder })
|
||||||
accessToken,
|
|
||||||
apiKey: key || undefined,
|
|
||||||
})
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setOpening(false)
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!picked) {
|
if (!result) {
|
||||||
setOpening(false)
|
setOpening(false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
toast(`Added “${result.doc.name}”`, 'success')
|
||||||
const result = await window.ipc.invoke('google-docs:import', { fileId: picked.id, targetFolder })
|
onImported(result.path)
|
||||||
toast(`Added “${picked.name}”`, 'success')
|
setOpening(false)
|
||||||
onImported(result.path)
|
}, [targetFolder, onImported, onOpenChange])
|
||||||
} catch (err) {
|
|
||||||
toast(err instanceof Error ? err.message : 'Failed to import the document', 'error')
|
|
||||||
} finally {
|
|
||||||
setOpening(false)
|
|
||||||
}
|
|
||||||
}, [apiKey, targetFolder, onImported, onOpenChange])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent className="flex max-h-[min(720px,calc(100vh-4rem))] max-w-lg flex-col gap-0 overflow-hidden p-0">
|
<DialogContent className="flex max-h-[min(720px,calc(100vh-4rem))] max-w-lg flex-col gap-0 overflow-hidden p-0">
|
||||||
<DialogHeader className="shrink-0 border-b border-border px-5 py-4">
|
<DialogHeader className="shrink-0 border-b border-border px-5 py-4">
|
||||||
|
|
@ -176,7 +90,7 @@ export function GoogleDocPickerDialog({
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="flex min-h-0 flex-1 flex-col">
|
<div className="flex min-h-0 flex-1 flex-col">
|
||||||
{!status && error ? (
|
{signedIn === null && error ? (
|
||||||
<div className="flex min-h-[280px] flex-1 flex-col items-center justify-center gap-4 px-8 text-center">
|
<div className="flex min-h-[280px] flex-1 flex-col items-center justify-center gap-4 px-8 text-center">
|
||||||
<div className="max-w-sm text-sm text-destructive">{error}</div>
|
<div className="max-w-sm text-sm text-destructive">{error}</div>
|
||||||
<Button variant="outline" onClick={() => void loadStatus()}>
|
<Button variant="outline" onClick={() => void loadStatus()}>
|
||||||
|
|
@ -184,34 +98,21 @@ export function GoogleDocPickerDialog({
|
||||||
Retry
|
Retry
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
) : !status ? (
|
) : signedIn === null ? (
|
||||||
<div className="flex min-h-[280px] flex-1 items-center justify-center text-sm text-muted-foreground">
|
<div className="flex min-h-[280px] flex-1 items-center justify-center text-sm text-muted-foreground">
|
||||||
<Loader2 className="mr-2 size-4 animate-spin" />
|
<Loader2 className="mr-2 size-4 animate-spin" />
|
||||||
Checking Google connection...
|
Checking your Rowboat sign-in…
|
||||||
</div>
|
</div>
|
||||||
) : !canPick ? (
|
) : !signedIn ? (
|
||||||
<div className="flex min-h-[300px] flex-1 flex-col items-center justify-center gap-4 overflow-y-auto px-8 py-8 text-center">
|
<div className="flex min-h-[300px] flex-1 flex-col items-center justify-center gap-4 px-8 py-8 text-center">
|
||||||
<div className="max-w-sm text-sm text-muted-foreground">
|
<div className="max-w-sm text-sm text-muted-foreground">
|
||||||
To choose a document, Rowboat needs per-file Drive access (the <code>drive.file</code> scope).
|
Sign in to Rowboat to add Google Docs from Drive. The picker uses your
|
||||||
|
Rowboat account — no Google credentials or API key needed.
|
||||||
</div>
|
</div>
|
||||||
{status.missingScopes.length > 0 && (
|
<Button variant="outline" onClick={() => void loadStatus()}>
|
||||||
<div className="max-w-md rounded-md border border-border bg-muted/30 px-3 py-2 text-left text-xs text-muted-foreground">
|
<RefreshCw className="size-4" />
|
||||||
Missing scopes: {status.missingScopes.join(', ')}
|
I've signed in — retry
|
||||||
</div>
|
</Button>
|
||||||
)}
|
|
||||||
<div className="flex w-full max-w-xs flex-col gap-2">
|
|
||||||
<Button onClick={() => setByokOpen(true)} disabled={connecting}>
|
|
||||||
{connecting ? <Loader2 className="size-4 animate-spin" /> : <RefreshCw className="size-4" />}
|
|
||||||
Connect with your Google credentials
|
|
||||||
</Button>
|
|
||||||
<Button variant="outline" onClick={handleConnect} disabled={connecting}>
|
|
||||||
Use managed Google sign-in
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<p className="max-w-sm text-xs text-muted-foreground">
|
|
||||||
Managed sign-in may not grant Drive access yet. If it keeps asking for scopes,
|
|
||||||
connect a Google OAuth client (Desktop app) with the Drive API and Picker API enabled.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex min-h-[300px] flex-1 flex-col items-center justify-center gap-4 px-8 py-8 text-center">
|
<div className="flex min-h-[300px] flex-1 flex-col items-center justify-center gap-4 px-8 py-8 text-center">
|
||||||
|
|
@ -219,31 +120,13 @@ export function GoogleDocPickerDialog({
|
||||||
Pick a Google Doc or Word file from your Drive. It imports as an editable
|
Pick a Google Doc or Word file from your Drive. It imports as an editable
|
||||||
<code> .docx</code> and stays linked for two-way sync.
|
<code> .docx</code> and stays linked for two-way sync.
|
||||||
</div>
|
</div>
|
||||||
<div className="flex w-full max-w-sm flex-col gap-1.5 text-left">
|
<p className="max-w-sm text-xs text-muted-foreground">
|
||||||
<label htmlFor="picker-api-key" className="text-xs font-medium text-muted-foreground">
|
You'll continue in your browser to grant access and choose a document — no
|
||||||
Google Picker API key
|
API key or setup needed.
|
||||||
</label>
|
</p>
|
||||||
<Input
|
|
||||||
id="picker-api-key"
|
|
||||||
value={apiKey}
|
|
||||||
onChange={(e) => setApiKey(e.target.value)}
|
|
||||||
placeholder="AIza…"
|
|
||||||
className="font-mono text-xs"
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
From your Google Cloud project (APIs & Services → Credentials → API key),
|
|
||||||
with the Picker API enabled. Stored locally.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="flex max-w-sm flex-col items-center gap-2">
|
<div className="w-full max-w-sm rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||||
<div className="w-full rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
{error}
|
||||||
{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}>
|
||||||
|
|
@ -255,12 +138,5 @@ export function GoogleDocPickerDialog({
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
<GoogleClientIdModal
|
|
||||||
open={byokOpen}
|
|
||||||
onOpenChange={setByokOpen}
|
|
||||||
onSubmit={handleByokSubmit}
|
|
||||||
description="Enter a Google OAuth client (Desktop app) with the Drive API and Picker API enabled."
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,118 +0,0 @@
|
||||||
// Loader + thin wrapper around the Google Picker JS API. File selection happens
|
|
||||||
// inside Google's hosted Picker (so the app needs only drive.file, not a broad
|
|
||||||
// listing scope); the Picker hands back the chosen file id.
|
|
||||||
|
|
||||||
export type PickedFile = { id: string; name: string; mimeType: string }
|
|
||||||
|
|
||||||
type GapiGlobal = {
|
|
||||||
load: (lib: string, config: { callback: () => void; onerror?: () => void }) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
type PickerView = {
|
|
||||||
setIncludeFolders: (b: boolean) => PickerView
|
|
||||||
setOwnedByMe: (b: boolean) => PickerView
|
|
||||||
setMimeTypes: (m: string) => PickerView
|
|
||||||
}
|
|
||||||
|
|
||||||
type PickerCallbackData = {
|
|
||||||
action: string
|
|
||||||
docs?: Array<{ id: string; name: string; mimeType: string }>
|
|
||||||
}
|
|
||||||
|
|
||||||
type PickerBuilder = {
|
|
||||||
addView: (v: PickerView) => PickerBuilder
|
|
||||||
setOrigin: (o: string) => PickerBuilder
|
|
||||||
setOAuthToken: (t: string) => PickerBuilder
|
|
||||||
setDeveloperKey: (k: string) => PickerBuilder
|
|
||||||
setAppId: (id: string) => PickerBuilder
|
|
||||||
setTitle: (t: string) => PickerBuilder
|
|
||||||
setCallback: (cb: (data: PickerCallbackData) => void) => PickerBuilder
|
|
||||||
build: () => { setVisible: (v: boolean) => void }
|
|
||||||
}
|
|
||||||
|
|
||||||
type GooglePickerNS = {
|
|
||||||
DocsView: new () => PickerView
|
|
||||||
PickerBuilder: new () => PickerBuilder
|
|
||||||
Action: { PICKED: string; CANCEL: string }
|
|
||||||
}
|
|
||||||
|
|
||||||
declare global {
|
|
||||||
interface Window {
|
|
||||||
gapi?: GapiGlobal
|
|
||||||
google?: { picker?: GooglePickerNS }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const API_JS = 'https://apis.google.com/js/api.js'
|
|
||||||
const DOC_MIME = 'application/vnd.google-apps.document'
|
|
||||||
const DOCX_MIME = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
|
|
||||||
const API_KEY_STORAGE = 'rowboat:google-picker-api-key'
|
|
||||||
|
|
||||||
let pickerLoaded: Promise<void> | null = null
|
|
||||||
|
|
||||||
function loadPickerApi(): Promise<void> {
|
|
||||||
if (pickerLoaded) return pickerLoaded
|
|
||||||
pickerLoaded = new Promise<void>((resolve, reject) => {
|
|
||||||
const start = () => {
|
|
||||||
if (!window.gapi) { reject(new Error('Google API failed to initialize')); return }
|
|
||||||
window.gapi.load('picker', {
|
|
||||||
callback: () => resolve(),
|
|
||||||
onerror: () => reject(new Error('Google Picker failed to load')),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
const existing = document.querySelector<HTMLScriptElement>(`script[src="${API_JS}"]`)
|
|
||||||
if (existing) {
|
|
||||||
if (window.gapi) start()
|
|
||||||
else existing.addEventListener('load', start)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const script = document.createElement('script')
|
|
||||||
script.src = API_JS
|
|
||||||
script.async = true
|
|
||||||
script.onload = start
|
|
||||||
script.onerror = () => reject(new Error('Failed to load the Google API script'))
|
|
||||||
document.head.appendChild(script)
|
|
||||||
})
|
|
||||||
return pickerLoaded
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getStoredPickerApiKey(): string {
|
|
||||||
try { return localStorage.getItem(API_KEY_STORAGE) ?? '' } catch { return '' }
|
|
||||||
}
|
|
||||||
|
|
||||||
export function setStoredPickerApiKey(key: string): void {
|
|
||||||
try { localStorage.setItem(API_KEY_STORAGE, key.trim()) } catch { /* ignore */ }
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function openGooglePicker(opts: {
|
|
||||||
accessToken: string
|
|
||||||
apiKey?: string
|
|
||||||
appId?: string
|
|
||||||
onPicked: (file: PickedFile) => void
|
|
||||||
onCancel?: () => void
|
|
||||||
}): Promise<void> {
|
|
||||||
await loadPickerApi()
|
|
||||||
const picker = window.google?.picker
|
|
||||||
if (!picker) throw new Error('Google Picker is unavailable')
|
|
||||||
|
|
||||||
const view = new picker.DocsView()
|
|
||||||
.setIncludeFolders(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) => {
|
|
||||||
if (data.action === picker.Action.PICKED && data.docs?.[0]) {
|
|
||||||
const d = data.docs[0]
|
|
||||||
opts.onPicked({ id: d.id, name: d.name, mimeType: d.mimeType })
|
|
||||||
} else if (data.action === picker.Action.CANCEL) {
|
|
||||||
opts.onCancel?.()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
if (opts.apiKey) builder.setDeveloperKey(opts.apiKey)
|
|
||||||
if (opts.appId) builder.setAppId(opts.appId)
|
|
||||||
builder.build().setVisible(true)
|
|
||||||
}
|
|
||||||
|
|
@ -107,6 +107,29 @@ export async function claimTokensViaBackend(state: string): Promise<OAuthTokens>
|
||||||
return toOAuthTokens(body);
|
return toOAuthTokens(body);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Claim what the user selected in the managed OAuth-redirect Picker, parked
|
||||||
|
* under `session` by the webapp picker callback. Returns the picked file ids
|
||||||
|
* plus a fresh drive.file access token — the picker runs a standalone
|
||||||
|
* drive.file authorization (the main connection doesn't carry drive.file), so
|
||||||
|
* the desktop downloads the picked files with this token, not the main one.
|
||||||
|
*/
|
||||||
|
export async function claimPickedFilesViaBackend(
|
||||||
|
session: string,
|
||||||
|
): Promise<{ fileIds: string[]; accessToken: string }> {
|
||||||
|
const res = await postWithBearer("/v1/google-oauth/claim-picked", { session });
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await readError(res);
|
||||||
|
throw new Error(`claim picked files failed: ${res.status} ${err.error ?? ""}`.trim());
|
||||||
|
}
|
||||||
|
const body = (await res.json()) as { fileIds?: unknown; tokens?: { access_token?: unknown } };
|
||||||
|
const fileIds = Array.isArray(body.fileIds)
|
||||||
|
? body.fileIds.filter((id): id is string => typeof id === "string" && id.length > 0)
|
||||||
|
: [];
|
||||||
|
const accessToken = typeof body.tokens?.access_token === "string" ? body.tokens.access_token : "";
|
||||||
|
return { fileIds, accessToken };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Refresh an access token via the api. Preserves caller's `refreshToken` and
|
* Refresh an access token via the api. Preserves caller's `refreshToken` and
|
||||||
* `existingScopes` when Google omits them on the refresh response.
|
* `existingScopes` when Google omits them on the refresh response.
|
||||||
|
|
|
||||||
|
|
@ -127,27 +127,43 @@ async function getDriveClient() {
|
||||||
return google.drive({ version: 'v3', auth });
|
return google.drive({ version: 'v3', auth });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Build a Drive client from a raw OAuth access token, bypassing the stored
|
||||||
|
// connection. Used by the OAuth-redirect Picker (trigger_onepick), which runs
|
||||||
|
// its own standalone drive.file authorization and hands back a fresh token —
|
||||||
|
// no stored connection, Picker API key, or appId involved. Read/export only;
|
||||||
|
// the token is short-lived and used immediately, so no refresh is wired up.
|
||||||
|
function driveClientFromToken(accessToken: string) {
|
||||||
|
const auth = new google.auth.OAuth2();
|
||||||
|
auth.setCredentials({ access_token: accessToken });
|
||||||
|
return google.drive({ version: 'v3', auth });
|
||||||
|
}
|
||||||
|
|
||||||
// Get the file as .docx bytes: a native Google Doc is exported; an uploaded
|
// Get the file as .docx bytes: a native Google Doc is exported; an uploaded
|
||||||
// Word file is downloaded as-is.
|
// Word file is downloaded as-is. Pass `driveClient` to use a specific token
|
||||||
async function fetchAsDocx(fileId: string, mimeType: string | undefined): Promise<Buffer> {
|
// (e.g. the OAuth-redirect Picker's); omit it to use the stored connection.
|
||||||
const driveClient = await getDriveClient();
|
async function fetchAsDocx(
|
||||||
|
fileId: string,
|
||||||
|
mimeType: string | undefined,
|
||||||
|
driveClient?: drive.Drive,
|
||||||
|
): Promise<Buffer> {
|
||||||
|
const dc = driveClient ?? await getDriveClient();
|
||||||
if (!mimeType || mimeType === GOOGLE_DOC_MIME) {
|
if (!mimeType || mimeType === GOOGLE_DOC_MIME) {
|
||||||
const result = await driveClient.files.export(
|
const result = await dc.files.export(
|
||||||
{ fileId, mimeType: DOCX_MIME },
|
{ fileId, mimeType: DOCX_MIME },
|
||||||
{ responseType: 'arraybuffer' },
|
{ responseType: 'arraybuffer' },
|
||||||
);
|
);
|
||||||
return Buffer.from(result.data as ArrayBuffer);
|
return Buffer.from(result.data as ArrayBuffer);
|
||||||
}
|
}
|
||||||
const result = await driveClient.files.get(
|
const result = await dc.files.get(
|
||||||
{ fileId, alt: 'media', supportsAllDrives: true },
|
{ fileId, alt: 'media', supportsAllDrives: true },
|
||||||
{ responseType: 'arraybuffer' },
|
{ responseType: 'arraybuffer' },
|
||||||
);
|
);
|
||||||
return Buffer.from(result.data as ArrayBuffer);
|
return Buffer.from(result.data as ArrayBuffer);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getDocMetadata(fileId: string): Promise<GoogleDocListItem> {
|
async function getDocMetadata(fileId: string, driveClient?: drive.Drive): Promise<GoogleDocListItem> {
|
||||||
const driveClient = await getDriveClient();
|
const dc = driveClient ?? await getDriveClient();
|
||||||
const result = await driveClient.files.get({
|
const result = await dc.files.get({
|
||||||
fileId,
|
fileId,
|
||||||
fields: 'id,name,webViewLink,modifiedTime,mimeType,owners(displayName,emailAddress)',
|
fields: 'id,name,webViewLink,modifiedTime,mimeType,owners(displayName,emailAddress)',
|
||||||
supportsAllDrives: true,
|
supportsAllDrives: true,
|
||||||
|
|
@ -195,21 +211,26 @@ export async function getGoogleDocsConnectionStatus(): Promise<{
|
||||||
return GoogleClientFactory.getCredentialStatus([...GOOGLE_DOC_SCOPES]);
|
return GoogleClientFactory.getCredentialStatus([...GOOGLE_DOC_SCOPES]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// Write the exported .docx bytes into the knowledge folder and record the
|
||||||
* The live Google OAuth access token, for the renderer to drive the Google
|
// Drive link. Shared by both import paths (stored connection / explicit token).
|
||||||
* Picker (file selection happens client-side; the app never lists Drive).
|
async function writeDocxAndLink(
|
||||||
*/
|
doc: GoogleDocListItem,
|
||||||
export async function getGoogleAccessToken(): Promise<string | null> {
|
bytes: Buffer,
|
||||||
// getClient() refreshes an expired token when it can. If it returns a token
|
targetFolder: string,
|
||||||
// that's still past expiry, the refresh failed (e.g. no refresh_token) — hand
|
): Promise<string> {
|
||||||
// back null rather than a dead token, so the UI prompts a reconnect instead
|
const relPath = await uniqueDocxPath(targetFolder, doc.name);
|
||||||
// of silently passing an expired token to the Picker (which 403s on it).
|
const absPath = resolveWorkspacePath(relPath);
|
||||||
const auth = await GoogleClientFactory.getClient();
|
await fs.mkdir(path.dirname(absPath), { recursive: true });
|
||||||
const token = auth?.credentials?.access_token ?? null;
|
await fs.writeFile(absPath, bytes);
|
||||||
if (!token) return null;
|
await setLink(relPath, {
|
||||||
const expiry = auth?.credentials?.expiry_date;
|
id: doc.id,
|
||||||
if (typeof expiry === 'number' && expiry <= Date.now()) return null;
|
url: doc.url,
|
||||||
return token;
|
title: doc.name,
|
||||||
|
syncedAt: new Date().toISOString(),
|
||||||
|
mimeType: doc.mimeType,
|
||||||
|
remoteModifiedTime: doc.modifiedTime ?? undefined,
|
||||||
|
});
|
||||||
|
return relPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Import a Google Doc as a local .docx and register the link. */
|
/** Import a Google Doc as a local .docx and register the link. */
|
||||||
|
|
@ -223,18 +244,26 @@ export async function importGoogleDoc(fileId: string, targetFolder: string): Pro
|
||||||
|
|
||||||
const doc = await getDocMetadata(fileId);
|
const doc = await getDocMetadata(fileId);
|
||||||
const bytes = await fetchAsDocx(fileId, doc.mimeType);
|
const bytes = await fetchAsDocx(fileId, doc.mimeType);
|
||||||
const relPath = await uniqueDocxPath(targetFolder, doc.name);
|
const relPath = await writeDocxAndLink(doc, bytes, targetFolder);
|
||||||
const absPath = resolveWorkspacePath(relPath);
|
return { path: relPath, doc };
|
||||||
await fs.mkdir(path.dirname(absPath), { recursive: true });
|
}
|
||||||
await fs.writeFile(absPath, bytes);
|
|
||||||
await setLink(relPath, {
|
/**
|
||||||
id: doc.id,
|
* Import a Google Doc using an explicit OAuth access token instead of the
|
||||||
url: doc.url,
|
* stored Google connection. Powers the OAuth-redirect Picker (trigger_onepick):
|
||||||
title: doc.name,
|
* that flow runs its own standalone drive.file authorization, so the picked
|
||||||
syncedAt: new Date().toISOString(),
|
* file is granted to a fresh token that has no other scopes and isn't persisted
|
||||||
mimeType: doc.mimeType,
|
* as the user's main connection. No Picker API key or appId is involved.
|
||||||
remoteModifiedTime: doc.modifiedTime ?? undefined,
|
*/
|
||||||
});
|
export async function importGoogleDocWithToken(
|
||||||
|
fileId: string,
|
||||||
|
targetFolder: string,
|
||||||
|
accessToken: string,
|
||||||
|
): Promise<{ path: string; doc: GoogleDocListItem }> {
|
||||||
|
const driveClient = driveClientFromToken(accessToken);
|
||||||
|
const doc = await getDocMetadata(fileId, driveClient);
|
||||||
|
const bytes = await fetchAsDocx(fileId, doc.mimeType, driveClient);
|
||||||
|
const relPath = await writeDocxAndLink(doc, bytes, targetFolder);
|
||||||
return { path: relPath, doc };
|
return { path: relPath, doc };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -650,27 +650,6 @@ const ipcSchemas = {
|
||||||
missingScopes: z.array(z.string()),
|
missingScopes: z.array(z.string()),
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
// Live Google OAuth access token for driving the Google Picker in the renderer.
|
|
||||||
'google-docs:getAccessToken': {
|
|
||||||
req: z.null(),
|
|
||||||
res: z.object({
|
|
||||||
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),
|
||||||
|
|
@ -687,6 +666,24 @@ const ipcSchemas = {
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
// Managed OAuth-redirect Picker: the Rowboat backend runs the pick with the
|
||||||
|
// company Google client; the desktop opens the start URL, waits for the deep
|
||||||
|
// link, and imports with the existing managed token. No API key or BYOK creds.
|
||||||
|
'google-docs:pickViaManaged': {
|
||||||
|
req: z.object({
|
||||||
|
targetFolder: RelPath,
|
||||||
|
}),
|
||||||
|
res: z.object({
|
||||||
|
path: RelPath,
|
||||||
|
doc: z.object({
|
||||||
|
id: z.string(),
|
||||||
|
name: z.string(),
|
||||||
|
url: z.string(),
|
||||||
|
modifiedTime: z.string().nullable(),
|
||||||
|
owner: z.string().nullable(),
|
||||||
|
}),
|
||||||
|
}).nullable(),
|
||||||
|
},
|
||||||
'google-docs:refreshSnapshot': {
|
'google-docs:refreshSnapshot': {
|
||||||
req: z.object({
|
req: z.object({
|
||||||
path: RelPath,
|
path: RelPath,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue