Skip to content
Merged

Drive #583

Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
c548f6b
add drive sync up and down
arkml May 27, 2026
b1e597e
add drive button
arkml May 27, 2026
676fb47
google doc icon
arkml May 27, 2026
9456497
icon changes
arkml May 28, 2026
c0427ed
show error state with retry in google doc picker
Gagancreates May 29, 2026
8e6978b
Merge remote-tracking branch 'origin/dev' into drive
Gagancreates May 31, 2026
8463c8b
feat(google-docs): import and sync down as Markdown, record remote re…
Gagancreates May 31, 2026
7e6ee04
feat(google-docs): structure-preserving sync up with remote-conflict …
Gagancreates May 31, 2026
ccdfc0f
feat(google-docs): overwrite-confirm on sync conflict and last-synced…
Gagancreates May 31, 2026
09b0a66
feat(google-docs): store linked docs as .docx, edit in docx editor, s…
Gagancreates Jun 1, 2026
84cfe54
feat(google-docs): offer BYOK connect in picker so signed-in users ca…
Gagancreates Jun 1, 2026
b35bd8f
fix(google-docs): request full drive scope so .docx sync-up can write…
Gagancreates Jun 1, 2026
112bf46
fix(google-docs): search all drives in doc picker, log result count
Gagancreates Jun 1, 2026
8ecd225
feat(google-docs): import native Docs AND uploaded .docx files from D…
Gagancreates Jun 1, 2026
505a9a2
chore(google-docs): drop dev-only test file
Gagancreates Jun 1, 2026
d08bf49
feat(google-docs): use Google Picker + drive.file scope instead of fu…
Gagancreates Jun 1, 2026
bfcffa7
fix(google-oauth): request offline access so BYOK tokens refresh
Gagancreates Jun 8, 2026
875b65d
feat(google-docs): pick docs via system-browser Google Picker
Gagancreates Jun 8, 2026
67b5214
feat(google-docs): managed OAuth-redirect Picker (no API key, no BYOK…
Gagancreates Jun 22, 2026
462023f
Merge origin/dev into drive
Gagancreates Jun 22, 2026
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)));
}
}
36 changes: 36 additions & 0 deletions apps/x/apps/main/src/ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,8 @@ import { runLiveNoteAgent } from '@x/core/dist/knowledge/live-note/runner.js';
import { listImportantThreads, listEverythingElseThreads, saveMessageBodyHeight, triggerSync as triggerGmailSync, sendThreadReply, archiveThread, trashThread, markThreadRead, getAccountEmail, getAccountName, getConnectionStatus as getGmailConnectionStatus } from '@x/core/dist/knowledge/sync_gmail.js';
import { searchContacts as searchGmailContacts, warmContactIndex } from '@x/core/dist/knowledge/gmail_contacts.js';
import { searchSentContacts, warmSentContacts } from '@x/core/dist/knowledge/gmail_sent_contacts.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 @@ -1416,6 +1418,40 @@ export function setupIpcHandlers() {
await versionHistory.restoreFile(args.path, args.oid);
return { ok: true };
},
'google-docs:getStatus': async () => {
return getGoogleDocsConnectionStatus();
},
'google-docs:import': async (_event, args) => {
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;
}
},
// 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);
},
'google-docs:sync': async (_event, args) => {
return syncGoogleDocUp(args.path, { force: args.force });
},
'google-docs:getLink': async (_event, args) => {
return { link: await getGoogleDocLink(args.path) };
},
// Search handler
'search:query': async (_event, args) => {
return search(args.query, args.limit, args.types);
Expand Down
12 changes: 10 additions & 2 deletions apps/x/apps/main/src/oauth-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -420,15 +420,23 @@ export async function connectProvider(provider: string, credentials?: { clientId
scope: scopes.join(' '),
code_challenge: codeChallenge,
state,
// Google only returns a refresh_token when offline access is requested,
// and only re-issues one when re-consent is forced. Without these, a
// BYOK token expires after ~1h with no way to refresh (it goes stale and
// every Google call — including the Picker — starts failing).
...(provider === 'google' ? { access_type: 'offline', prompt: 'consent' } : {}),
});

// Set timeout to clean up abandoned flows (2 minutes)
// Set timeout to clean up abandoned flows. Generous (10 min) because a
// first-time connect can involve creating/locating OAuth credentials in
// the Cloud Console mid-flow; a short window tears down the callback
// server before the user finishes consent, silently dropping the token.
const cleanupTimeout = setTimeout(() => {
if (activeFlow?.state === state) {
console.log(`[OAuth] Cleaning up abandoned OAuth flow for ${provider} (timeout)`);
cancelActiveFlow('timed_out');
}
}, 2 * 60 * 1000);
}, 10 * 60 * 1000);

activeFlow = {
provider,
Expand Down
Loading