Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions apps/x/apps/main/src/deeplink.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ export function extractDeepLinkFromArgv(argv: readonly string[]): string | null
export function dispatchUrl(url: string): void {
if (parseAction(url)) {
void dispatchAction(url);
} else if (parsePickerCompletion(url)) {
void dispatchPickerCompletion(url);
} else if (parseOAuthCompletion(url)) {
void dispatchOAuthCompletion(url);
} else {
Expand Down Expand Up @@ -158,6 +160,41 @@ async function dispatchOAuthCompletion(url: string): Promise<void> {
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 {
if (win.isMinimized()) win.restore();
win.show();
Expand Down
120 changes: 120 additions & 0 deletions apps/x/apps/main/src/google-picker-managed.ts
Original file line number Diff line number Diff line change
@@ -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)));
}
}
123 changes: 13 additions & 110 deletions apps/x/apps/main/src/ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,8 @@ import { getAccessToken } from '@x/core/dist/auth/tokens.js';
import { getRowboatConfig } from '@x/core/dist/config/rowboat.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 { 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 { getInstallationId } from '@x/core/dist/analytics/installation.js';
import { API_URL } from '@x/core/dist/config/env.js';
Expand Down Expand Up @@ -815,115 +816,6 @@ export function setupIpcHandlers() {
'google-docs:getStatus': async () => {
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) => {
console.log(`[GoogleDocs] import fileId=${args.fileId} -> ${args.targetFolder}`);
try {
Expand All @@ -935,6 +827,17 @@ gapi.load('picker',function(){
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) => {
return syncGoogleDocDown(args.path);
},
Expand Down
Loading