mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-06-24 20:28:16 +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 {
|
||||
if (parseAction(url)) {
|
||||
void dispatchAction(url);
|
||||
} else if (parsePickerCompletion(url)) {
|
||||
void dispatchPickerCompletion(url);
|
||||
} else if (parseOAuthCompletion(url)) {
|
||||
void dispatchOAuthCompletion(url);
|
||||
} else {
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
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 { 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';
|
||||
|
|
@ -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 {
|
||||
|
|
@ -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);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -9,18 +9,8 @@ import {
|
|||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
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'
|
||||
|
||||
type GoogleDocsStatus = {
|
||||
connected: boolean
|
||||
hasRequiredScopes: boolean
|
||||
missingScopes: string[]
|
||||
}
|
||||
|
||||
type GoogleDocPickerDialogProps = {
|
||||
open: boolean
|
||||
targetFolder: string
|
||||
|
|
@ -34,138 +24,62 @@ export function GoogleDocPickerDialog({
|
|||
onOpenChange,
|
||||
onImported,
|
||||
}: GoogleDocPickerDialogProps) {
|
||||
const [status, setStatus] = useState<GoogleDocsStatus | null>(null)
|
||||
const [connecting, setConnecting] = useState(false)
|
||||
// The managed picker runs its own drive.file OAuth in the browser, gated on
|
||||
// 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 [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 loadStatus = useCallback(async () => {
|
||||
setError(null)
|
||||
try {
|
||||
const result = await window.ipc.invoke('google-docs:getStatus', null)
|
||||
setStatus(result)
|
||||
const account = await window.ipc.invoke('account:getRowboat', null)
|
||||
setSignedIn(account.signedIn)
|
||||
setError(null)
|
||||
} catch (err) {
|
||||
setStatus(null)
|
||||
setError(err instanceof Error ? err.message : 'Failed to check Google connection')
|
||||
setSignedIn(null)
|
||||
setError(err instanceof Error ? err.message : 'Failed to check your Rowboat sign-in')
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
setError(null)
|
||||
setApiKey(getStoredPickerApiKey())
|
||||
void loadStatus()
|
||||
}, [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 () => {
|
||||
setError(null)
|
||||
const key = apiKey.trim()
|
||||
if (!key) {
|
||||
setError('Enter your Google Picker API key first.')
|
||||
return
|
||||
}
|
||||
setStoredPickerApiKey(key)
|
||||
setOpening(true)
|
||||
|
||||
let accessToken: string | null = null
|
||||
try {
|
||||
const res = await window.ipc.invoke('google-docs:getAccessToken', null)
|
||||
accessToken = res.accessToken
|
||||
} 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.
|
||||
// Managed pick: the Rowboat backend runs the whole grant + pick in the
|
||||
// browser with the company Google client, then deep-links the selection
|
||||
// back. No API key, BYOK creds, or redirect URL to configure. 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
|
||||
toast('Continue in your browser: grant access and pick a document…', 'info')
|
||||
let result: { path: string; doc: { name: string } } | null = null
|
||||
try {
|
||||
picked = await window.ipc.invoke('google-docs:openPicker', {
|
||||
accessToken,
|
||||
apiKey: key || undefined,
|
||||
})
|
||||
result = await window.ipc.invoke('google-docs:pickViaManaged', { targetFolder })
|
||||
} catch (err) {
|
||||
setOpening(false)
|
||||
toast(err instanceof Error ? err.message : 'Failed to open the Google Picker', 'error')
|
||||
return
|
||||
}
|
||||
|
||||
if (!picked) {
|
||||
if (!result) {
|
||||
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)
|
||||
}
|
||||
}, [apiKey, targetFolder, onImported, onOpenChange])
|
||||
toast(`Added “${result.doc.name}”`, 'success')
|
||||
onImported(result.path)
|
||||
setOpening(false)
|
||||
}, [targetFolder, onImported, onOpenChange])
|
||||
|
||||
return (
|
||||
<>
|
||||
<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">
|
||||
<DialogHeader className="shrink-0 border-b border-border px-5 py-4">
|
||||
|
|
@ -176,7 +90,7 @@ export function GoogleDocPickerDialog({
|
|||
</DialogHeader>
|
||||
|
||||
<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="max-w-sm text-sm text-destructive">{error}</div>
|
||||
<Button variant="outline" onClick={() => void loadStatus()}>
|
||||
|
|
@ -184,34 +98,21 @@ export function GoogleDocPickerDialog({
|
|||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
) : !status ? (
|
||||
) : signedIn === null ? (
|
||||
<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" />
|
||||
Checking Google connection...
|
||||
Checking your Rowboat sign-in…
|
||||
</div>
|
||||
) : !canPick ? (
|
||||
<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">
|
||||
) : !signedIn ? (
|
||||
<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">
|
||||
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>
|
||||
{status.missingScopes.length > 0 && (
|
||||
<div className="max-w-md rounded-md border border-border bg-muted/30 px-3 py-2 text-left text-xs text-muted-foreground">
|
||||
Missing scopes: {status.missingScopes.join(', ')}
|
||||
</div>
|
||||
)}
|
||||
<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>
|
||||
<Button variant="outline" onClick={() => void loadStatus()}>
|
||||
<RefreshCw className="size-4" />
|
||||
I've signed in — retry
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<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
|
||||
<code> .docx</code> and stays linked for two-way sync.
|
||||
</div>
|
||||
<div className="flex w-full max-w-sm flex-col gap-1.5 text-left">
|
||||
<label htmlFor="picker-api-key" className="text-xs font-medium text-muted-foreground">
|
||||
Google Picker API key
|
||||
</label>
|
||||
<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>
|
||||
<p className="max-w-sm text-xs text-muted-foreground">
|
||||
You'll continue in your browser to grant access and choose a document — no
|
||||
API key or setup needed.
|
||||
</p>
|
||||
{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 className="w-full max-w-sm rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<Button onClick={() => void handleChoose()} disabled={opening}>
|
||||
|
|
@ -255,12 +138,5 @@ export function GoogleDocPickerDialog({
|
|||
</div>
|
||||
</DialogContent>
|
||||
</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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* `existingScopes` when Google omits them on the refresh response.
|
||||
|
|
|
|||
|
|
@ -127,27 +127,43 @@ async function getDriveClient() {
|
|||
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
|
||||
// Word file is downloaded as-is.
|
||||
async function fetchAsDocx(fileId: string, mimeType: string | undefined): Promise<Buffer> {
|
||||
const driveClient = await getDriveClient();
|
||||
// Word file is downloaded as-is. Pass `driveClient` to use a specific token
|
||||
// (e.g. the OAuth-redirect Picker's); omit it to use the stored connection.
|
||||
async function fetchAsDocx(
|
||||
fileId: string,
|
||||
mimeType: string | undefined,
|
||||
driveClient?: drive.Drive,
|
||||
): Promise<Buffer> {
|
||||
const dc = driveClient ?? await getDriveClient();
|
||||
if (!mimeType || mimeType === GOOGLE_DOC_MIME) {
|
||||
const result = await driveClient.files.export(
|
||||
const result = await dc.files.export(
|
||||
{ fileId, mimeType: DOCX_MIME },
|
||||
{ responseType: '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 },
|
||||
{ responseType: 'arraybuffer' },
|
||||
);
|
||||
return Buffer.from(result.data as ArrayBuffer);
|
||||
}
|
||||
|
||||
async function getDocMetadata(fileId: string): Promise<GoogleDocListItem> {
|
||||
const driveClient = await getDriveClient();
|
||||
const result = await driveClient.files.get({
|
||||
async function getDocMetadata(fileId: string, driveClient?: drive.Drive): Promise<GoogleDocListItem> {
|
||||
const dc = driveClient ?? await getDriveClient();
|
||||
const result = await dc.files.get({
|
||||
fileId,
|
||||
fields: 'id,name,webViewLink,modifiedTime,mimeType,owners(displayName,emailAddress)',
|
||||
supportsAllDrives: true,
|
||||
|
|
@ -195,21 +211,26 @@ export async function getGoogleDocsConnectionStatus(): Promise<{
|
|||
return GoogleClientFactory.getCredentialStatus([...GOOGLE_DOC_SCOPES]);
|
||||
}
|
||||
|
||||
/**
|
||||
* The live Google OAuth access token, for the renderer to drive the Google
|
||||
* Picker (file selection happens client-side; the app never lists Drive).
|
||||
*/
|
||||
export async function getGoogleAccessToken(): Promise<string | null> {
|
||||
// getClient() refreshes an expired token when it can. If it returns a token
|
||||
// that's still past expiry, the refresh failed (e.g. no refresh_token) — hand
|
||||
// back null rather than a dead token, so the UI prompts a reconnect instead
|
||||
// of silently passing an expired token to the Picker (which 403s on it).
|
||||
const auth = await GoogleClientFactory.getClient();
|
||||
const token = auth?.credentials?.access_token ?? null;
|
||||
if (!token) return null;
|
||||
const expiry = auth?.credentials?.expiry_date;
|
||||
if (typeof expiry === 'number' && expiry <= Date.now()) return null;
|
||||
return token;
|
||||
// Write the exported .docx bytes into the knowledge folder and record the
|
||||
// Drive link. Shared by both import paths (stored connection / explicit token).
|
||||
async function writeDocxAndLink(
|
||||
doc: GoogleDocListItem,
|
||||
bytes: Buffer,
|
||||
targetFolder: string,
|
||||
): Promise<string> {
|
||||
const relPath = await uniqueDocxPath(targetFolder, doc.name);
|
||||
const absPath = resolveWorkspacePath(relPath);
|
||||
await fs.mkdir(path.dirname(absPath), { recursive: true });
|
||||
await fs.writeFile(absPath, bytes);
|
||||
await setLink(relPath, {
|
||||
id: doc.id,
|
||||
url: doc.url,
|
||||
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. */
|
||||
|
|
@ -223,18 +244,26 @@ export async function importGoogleDoc(fileId: string, targetFolder: string): Pro
|
|||
|
||||
const doc = await getDocMetadata(fileId);
|
||||
const bytes = await fetchAsDocx(fileId, doc.mimeType);
|
||||
const relPath = await uniqueDocxPath(targetFolder, doc.name);
|
||||
const absPath = resolveWorkspacePath(relPath);
|
||||
await fs.mkdir(path.dirname(absPath), { recursive: true });
|
||||
await fs.writeFile(absPath, bytes);
|
||||
await setLink(relPath, {
|
||||
id: doc.id,
|
||||
url: doc.url,
|
||||
title: doc.name,
|
||||
syncedAt: new Date().toISOString(),
|
||||
mimeType: doc.mimeType,
|
||||
remoteModifiedTime: doc.modifiedTime ?? undefined,
|
||||
});
|
||||
const relPath = await writeDocxAndLink(doc, bytes, targetFolder);
|
||||
return { path: relPath, doc };
|
||||
}
|
||||
|
||||
/**
|
||||
* Import a Google Doc using an explicit OAuth access token instead of the
|
||||
* stored Google connection. Powers the OAuth-redirect Picker (trigger_onepick):
|
||||
* that flow runs its own standalone drive.file authorization, so the picked
|
||||
* file is granted to a fresh token that has no other scopes and isn't persisted
|
||||
* as the user's main connection. No Picker API key or appId is involved.
|
||||
*/
|
||||
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 };
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -650,27 +650,6 @@ const ipcSchemas = {
|
|||
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': {
|
||||
req: z.object({
|
||||
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': {
|
||||
req: z.object({
|
||||
path: RelPath,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue