refactor(cloud-sync): simplify OAuth callback flow

This commit is contained in:
bincxz committed 2026-04-23 14:51:50 +08:00
1 parent c721591466
commit 78fc4628b9
7 files changed
+389 -112

No files matched your search

+23 -19
View File
@@ -146,6 +146,7 @@ const getSnapshot = (): SyncManagerState => {
export const useCloudSync = (): CloudSyncHook => {
// Use useSyncExternalStore for real-time state sync across all components
const state = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
const activeOAuthSessionIdRef = useRef<string | null>(null);
// Auto-unlock: if a master key exists, retrieve the persisted password (Electron safeStorage)
// and unlock silently so users don't have to manage a LOCKED state in the UI.
@@ -262,7 +263,7 @@ export const useCloudSync = (): CloudSyncHook => {
if (result.type !== 'device_code') {
throw new Error('Unexpected auth type');
}
return result.data as DeviceFlowState;
return result.data;
}, []);
const completeGitHubAuth = useCallback(async (
@@ -279,30 +280,29 @@ export const useCloudSync = (): CloudSyncHook => {
const bridge = netcattyBridge.get();
const prepare = bridge?.prepareOAuthCallback;
const awaitCallback = bridge?.awaitOAuthCallback;
if (!prepare || !awaitCallback) {
const openExternal = bridge?.openExternal;
if (!prepare || !awaitCallback || !openExternal) {
throw new Error('OAuth bridge is unavailable');
}
// Bind the loopback callback server first so we know which port to put
// in the provider's redirect_uri (#823: 45678 may be in use).
const prepared = await prepare();
const redirectUri = prepared.redirectUri;
const { redirectUri, sessionId } = await prepare();
activeOAuthSessionIdRef.current = sessionId;
let authStarted = false;
try {
const result = await manager.startProviderAuth(provider, redirectUri);
if (result.type !== 'url') {
throw new Error('Unexpected auth type');
}
authStarted = true;
const data = result.data as { url: string; redirectUri: string };
const data = result.data;
const adapter = manager.getAdapter(provider) as
| { getPKCEState?: () => string | null }
| undefined;
const expectedState = adapter?.getPKCEState?.() || undefined;
const callbackPromise = awaitCallback(expectedState);
const callbackPromise = awaitCallback(expectedState, sessionId);
// Use system browser to avoid white-screen issues in popup windows (#563).
// Race: if browser launch fails, surface the error immediately.
@@ -310,9 +310,9 @@ export const useCloudSync = (): CloudSyncHook => {
const browserPromise = new Promise<never>((_resolve, reject) => {
openTimer = setTimeout(async () => {
try {
await bridge?.openExternal(data.url);
await openExternal(data.url);
} catch (err) {
bridge?.cancelOAuthCallback?.();
bridge?.cancelOAuthCallback?.(sessionId);
reject(
err instanceof Error
? err
@@ -331,16 +331,20 @@ export const useCloudSync = (): CloudSyncHook => {
return data.url;
} catch (err) {
// If we bound a server but never reached awaitCallback (or it failed
// before resolving), release the port so the next attempt can bind.
if (!authStarted) {
try {
await bridge?.cancelOAuthCallback?.();
} catch {
// Best-effort cleanup
}
const ownsActiveSession = activeOAuthSessionIdRef.current === sessionId;
try {
await bridge?.cancelOAuthCallback?.(sessionId);
} catch {
// Best-effort cleanup
}
if (ownsActiveSession) {
manager.resetProviderStatus(provider);
}
throw err;
} finally {
if (activeOAuthSessionIdRef.current === sessionId) {
activeOAuthSessionIdRef.current = null;
}
}
},
[]
@@ -380,7 +384,7 @@ export const useCloudSync = (): CloudSyncHook => {
const cancelOAuthConnect = useCallback(() => {
const bridge = netcattyBridge.get();
bridge?.cancelOAuthCallback?.();
bridge?.cancelOAuthCallback?.(activeOAuthSessionIdRef.current ?? undefined);
}, []);
// ========== Settings ==========
+48 -9
View File
@@ -1152,6 +1152,40 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
} | null>(null);
const [historyPreviewLoading, setHistoryPreviewLoading] = useState(false);
const [historyError, setHistoryError] = useState<string | null>(null);
const [pendingConnectProvider, setPendingConnectProvider] = useState<CloudProvider | null>(null);
const pendingConnectProviderRef = useRef<CloudProvider | null>(null);
const hasConnectingProvider = (Object.values(sync.providers) as Array<{ status: string }>).some(
(provider) => provider.status === 'connecting'
);
const isConnectDisabled = (provider: CloudProvider): boolean => {
if (pendingConnectProvider && pendingConnectProvider !== provider) {
return true;
}
if (pendingConnectProvider === provider) {
return true;
}
if (hasConnectingProvider && sync.providers[provider].status !== 'connecting') {
return true;
}
return sync.hasAnyConnectedProvider && !isProviderReadyForSync(sync.providers[provider]);
};
const beginPendingConnect = (provider: CloudProvider): boolean => {
if (pendingConnectProviderRef.current) {
return false;
}
pendingConnectProviderRef.current = provider;
setPendingConnectProvider(provider);
return true;
};
const endPendingConnect = (provider: CloudProvider) => {
if (pendingConnectProviderRef.current !== provider) return;
pendingConnectProviderRef.current = null;
setPendingConnectProvider((current) => (current === provider ? null : current));
};
// Change master key dialog
const [showChangeKeyDialog, setShowChangeKeyDialog] = useState(false);
@@ -1275,6 +1309,7 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
// Connect GitHub (disconnect others first - single provider only)
const handleConnectGitHub = async () => {
if (!beginPendingConnect('github')) return;
try {
await disconnectOtherProviders('github');
const deviceFlow = await sync.connectGitHub();
@@ -1300,42 +1335,46 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
sync.resetProviderStatus('github');
const message = getNetworkErrorMessage(error, t('common.unknownError'));
toast.error(message, t('cloudSync.connect.github.failedTitle'));
} finally {
endPendingConnect('github');
}
};
// Connect Google (disconnect others first - single provider only)
const handleConnectGoogle = async () => {
if (!beginPendingConnect('google')) return;
try {
await disconnectOtherProviders('google');
await sync.connectGoogle();
// Note: Auth flow is handled automatically by oauthBridge
toast.info(t('cloudSync.connect.browserContinue'));
} catch (error) {
// Reset provider status so button is clickable again (without tearing down existing connections)
sync.resetProviderStatus('google');
const msg = error instanceof Error ? error.message : t('common.unknownError');
// Don't show toast for user-initiated cancellation (popup closed)
if (!msg.includes('cancelled')) {
toast.error(msg, t('cloudSync.connect.google.failedTitle'));
}
} finally {
endPendingConnect('google');
}
};
// Connect OneDrive (disconnect others first - single provider only)
const handleConnectOneDrive = async () => {
if (!beginPendingConnect('onedrive')) return;
try {
await disconnectOtherProviders('onedrive');
await sync.connectOneDrive();
// Note: Auth flow is handled automatically by oauthBridge
toast.info(t('cloudSync.connect.browserContinue'));
} catch (error) {
// Reset provider status so button is clickable again (without tearing down existing connections)
sync.resetProviderStatus('onedrive');
const msg = error instanceof Error ? error.message : t('common.unknownError');
// Don't show toast for user-initiated cancellation (popup closed)
if (!msg.includes('cancelled')) {
toast.error(msg, t('cloudSync.connect.onedrive.failedTitle'));
}
} finally {
endPendingConnect('onedrive');
}
};
@@ -1673,7 +1712,7 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
account={sync.providers.github.account}
lastSync={sync.providers.github.lastSync}
error={sync.providers.github.error}
disabled={sync.hasAnyConnectedProvider && !isProviderReadyForSync(sync.providers.github)}
disabled={isConnectDisabled('github')}
onConnect={handleConnectGitHub}
onDisconnect={() => sync.disconnectProvider('github')}
onSync={() => handleSync('github')}
@@ -1697,7 +1736,7 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
account={sync.providers.google.account}
lastSync={sync.providers.google.lastSync}
error={sync.providers.google.error}
disabled={sync.hasAnyConnectedProvider && !isProviderReadyForSync(sync.providers.google)}
disabled={isConnectDisabled('google')}
onConnect={handleConnectGoogle}
onCancelConnect={sync.cancelOAuthConnect}
onDisconnect={() => sync.disconnectProvider('google')}
@@ -1714,7 +1753,7 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
account={sync.providers.onedrive.account}
lastSync={sync.providers.onedrive.lastSync}
error={sync.providers.onedrive.error}
disabled={sync.hasAnyConnectedProvider && !isProviderReadyForSync(sync.providers.onedrive)}
disabled={isConnectDisabled('onedrive')}
onConnect={handleConnectOneDrive}
onCancelConnect={sync.cancelOAuthConnect}
onDisconnect={() => sync.disconnectProvider('onedrive')}
@@ -1731,7 +1770,7 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
account={sync.providers.webdav.account}
lastSync={sync.providers.webdav.lastSync}
error={sync.providers.webdav.error}
disabled={sync.hasAnyConnectedProvider && !isProviderReadyForSync(sync.providers.webdav)}
disabled={isConnectDisabled('webdav')}
onEdit={openWebdavDialog}
onConnect={openWebdavDialog}
onDisconnect={() => sync.disconnectProvider('webdav')}
@@ -1748,7 +1787,7 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
account={sync.providers.s3.account}
lastSync={sync.providers.s3.lastSync}
error={sync.providers.s3.error}
disabled={sync.hasAnyConnectedProvider && !isProviderReadyForSync(sync.providers.s3)}
disabled={isConnectDisabled('s3')}
onEdit={openS3Dialog}
onConnect={openS3Dialog}
onDisconnect={() => sync.disconnectProvider('s3')}
+174 -73
View File
@@ -8,17 +8,11 @@
*/
const http = require("node:http");
const url = require("node:url");
let server = null;
let activePort = null;
let requestHandler = null;
let pendingResolve = null;
let pendingReject = null;
let pendingExpectedState = null;
let serverTimeout = null;
let activeSession = null;
let nextSessionId = 0;
const PREFERRED_OAUTH_PORT = 45678;
const OAUTH_CALLBACK_PATH = "/oauth/callback";
const OAUTH_TIMEOUT = 5 * 60 * 1000; // 5 minutes
const escapeHtml = (value) =>
@@ -192,30 +186,50 @@ const renderOAuthPage = ({ title, message, detail, status, autoClose }) => {
</html>`;
};
function handleOAuthRequest(req, res) {
const parsedUrl = url.parse(req.url, true);
function handleOAuthRequest(session, req, res) {
const parsedUrl = new URL(req.url, "http://127.0.0.1");
// Only handle the callback path
if (parsedUrl.pathname !== "/oauth/callback") {
if (parsedUrl.pathname !== OAUTH_CALLBACK_PATH) {
res.writeHead(404, { "Content-Type": "text/html; charset=utf-8" });
res.end("<h1>404 Not Found</h1>");
return;
}
const { code, state, error, error_description } = parsedUrl.query;
const code = parsedUrl.searchParams.get("code");
const state = parsedUrl.searchParams.get("state");
const error = parsedUrl.searchParams.get("error");
const errorDescription = parsedUrl.searchParams.get("error_description");
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
// We deliberately ignore callbacks until awaitOAuthCallback() has armed the
// session. In the real flow the browser only opens after that point, so any
// earlier localhost hit is stale/noise and must not consume the active retry.
if (!session.resolve || !session.reject) {
res.end(
renderOAuthPage({
title: "Authorization Not Ready",
message: "This sign-in request is not active yet. Return to Netcatty and try again.",
status: "warning",
})
);
return;
}
if (error) {
res.end(
renderOAuthPage({
title: "Authorization Failed",
message: "We could not complete the sign-in flow.",
detail: error_description || error || "Unknown error",
detail: errorDescription || error || "Unknown error",
status: "error",
})
);
rejectPending(new Error(error_description || error || "Authorization failed"));
finishSessionWithError(
session,
new Error(errorDescription || error || "Authorization failed")
);
return;
}
@@ -227,11 +241,11 @@ function handleOAuthRequest(req, res) {
status: "error",
})
);
rejectPending(new Error("Missing authorization code"));
finishSessionWithError(session, new Error("Missing authorization code"));
return;
}
if (pendingExpectedState && state !== pendingExpectedState) {
if (session.expectedState && state !== session.expectedState) {
res.end(
renderOAuthPage({
title: "Security Check Failed",
@@ -239,7 +253,7 @@ function handleOAuthRequest(req, res) {
status: "error",
})
);
rejectPending(new Error("State mismatch - possible CSRF attack"));
finishSessionWithError(session, new Error("State mismatch - possible CSRF attack"));
return;
}
@@ -251,21 +265,78 @@ function handleOAuthRequest(req, res) {
})
);
const resolve = pendingResolve;
cleanup();
resolveSession(session, { code, state });
}
function resolveSession(session, payload) {
if (!session) return;
const resolve = session.resolve;
disposeSession(session);
if (resolve) {
resolve({ code, state });
resolve(payload);
}
}
function rejectPending(err) {
const reject = pendingReject;
cleanup();
function rejectSession(session, err) {
if (!session) return;
const reject = session.reject;
disposeSession(session);
if (reject) {
reject(err);
}
}
function finishSessionWithError(session, err) {
if (!session) return;
if (session.reject) {
rejectSession(session, err);
return;
}
storeSessionError(session, err);
}
function disposeSession(session) {
if (!session) return;
if (session.timeout) {
clearTimeout(session.timeout);
session.timeout = null;
}
session.resolve = null;
session.reject = null;
session.expectedState = null;
session.error = null;
closeServer(session);
if (activeSession === session) {
activeSession = null;
}
}
function storeSessionError(session, err) {
if (!session) return;
if (session.timeout) {
clearTimeout(session.timeout);
session.timeout = null;
}
session.resolve = null;
session.reject = null;
session.expectedState = null;
session.error = err;
closeServer(session);
}
function armSessionTimeout(session) {
session.timeout = setTimeout(() => {
rejectSession(
session,
new Error("OAuth timeout - user did not complete authorization in time")
);
}, OAUTH_TIMEOUT);
}
/**
* Try to bind an HTTP server on the loopback interface. Prefers
* PREFERRED_OAUTH_PORT; if the port is already in use (EADDRINUSE) or the OS
@@ -274,14 +345,14 @@ function rejectPending(err) {
*
* @returns {Promise<{server: http.Server, port: number}>}
*/
function bindLoopbackServer() {
function bindLoopbackServer(session) {
const ports = [PREFERRED_OAUTH_PORT, 0];
return (async () => {
let lastErr;
for (const port of ports) {
try {
const s = http.createServer((req, res) => {
if (requestHandler) requestHandler(req, res);
handleOAuthRequest(session, req, res);
});
await new Promise((resolve, reject) => {
const onError = (err) => {
@@ -319,28 +390,54 @@ function bindLoopbackServer() {
* @returns {Promise<{port: number, redirectUri: string}>}
*/
async function prepareOAuthCallback() {
// Close any previously-prepared server (e.g. from an abandoned flow).
closeServer();
// Replace any previously-prepared flow so it cannot leak a bound port or
// leave an await Promise hanging forever.
rejectSession(activeSession, new Error("OAuth flow cancelled"));
const bound = await bindLoopbackServer();
server = bound.server;
activePort = bound.port;
requestHandler = handleOAuthRequest;
const session = {
id: `oauth-${++nextSessionId}`,
server: null,
port: null,
resolve: null,
reject: null,
expectedState: null,
timeout: null,
error: null,
};
activeSession = session;
armSessionTimeout(session);
let bound;
try {
bound = await bindLoopbackServer(session);
} catch (err) {
disposeSession(session);
throw err;
}
session.server = bound.server;
session.port = bound.port;
if (activeSession !== session || session.error) {
const err = session.error || new Error("OAuth flow cancelled");
disposeSession(session);
throw err;
}
// Don't let the callback server itself keep the process alive — the
// awaitOAuthCallback Promise is what should pin the event loop. In Electron
// main this is a no-op (the UI keeps things alive), but in tests and CLI
// contexts it means the process can exit cleanly between runs.
if (typeof server.unref === "function") server.unref();
if (typeof session.server.unref === "function") session.server.unref();
server.on("error", (err) => {
session.server.on("error", (err) => {
console.error("OAuth server error:", err);
rejectPending(err);
rejectSession(session, err);
});
const redirectUri = `http://127.0.0.1:${activePort}/oauth/callback`;
const redirectUri = `http://127.0.0.1:${session.port}${OAUTH_CALLBACK_PATH}`;
console.log(`OAuth callback server listening on ${redirectUri}`);
return { port: activePort, redirectUri };
return { sessionId: session.id, port: session.port, redirectUri };
}
/**
@@ -348,28 +445,43 @@ async function prepareOAuthCallback() {
* server. Must be called after prepareOAuthCallback().
*
* @param {string} [expectedState] - State parameter to validate
* @param {string} [sessionId] - Session returned by prepareOAuthCallback
* @returns {Promise<{code: string, state?: string}>}
*/
function awaitOAuthCallback(expectedState) {
function awaitOAuthCallback(expectedState, sessionId) {
return new Promise((resolve, reject) => {
if (!server) {
const session = activeSession;
if (!session) {
reject(new Error("OAuth callback server not prepared"));
return;
}
if (sessionId && session.id !== sessionId) {
reject(new Error("OAuth flow cancelled"));
return;
}
// Only one await may be outstanding at a time.
if (pendingResolve || pendingReject) {
if (session.resolve || session.reject) {
reject(new Error("An OAuth callback is already in progress"));
return;
}
pendingResolve = resolve;
pendingReject = reject;
pendingExpectedState = expectedState || null;
serverTimeout = setTimeout(() => {
rejectPending(
new Error("OAuth timeout - user did not complete authorization in time")
);
}, OAUTH_TIMEOUT);
if (session.error) {
const err = session.error;
disposeSession(session);
reject(err);
return;
}
if (!session.server) {
reject(new Error("OAuth callback server not prepared"));
return;
}
session.resolve = resolve;
session.reject = reject;
session.expectedState = expectedState || null;
});
}
@@ -380,17 +492,20 @@ function awaitOAuthCallback(expectedState) {
* popup fallback.
*/
function getActiveOAuthPort() {
return activePort;
return activeSession?.port ?? null;
}
/**
* Cancel pending OAuth flow
*/
function cancelOAuthCallback() {
rejectPending(new Error("OAuth flow cancelled"));
function cancelOAuthCallback(sessionId) {
if (!activeSession) return;
if (sessionId && activeSession.id !== sessionId) return;
finishSessionWithError(activeSession, new Error("OAuth flow cancelled"));
}
function closeServer() {
function closeServer(session) {
const server = session?.server;
if (server) {
try {
// closeAllConnections (Node 18.2+) forces any keep-alive sockets shut
@@ -407,25 +522,11 @@ function closeServer() {
} catch {
// Ignore
}
server = null;
}
activePort = null;
requestHandler = null;
}
/**
* Clean up pending state and close the server. Called after the code is
* delivered, on error, and on cancellation.
*/
function cleanup() {
if (serverTimeout) {
clearTimeout(serverTimeout);
serverTimeout = null;
if (session) {
session.server = null;
session.port = null;
}
pendingResolve = null;
pendingReject = null;
pendingExpectedState = null;
closeServer();
}
/**
@@ -437,12 +538,12 @@ function setupOAuthBridge(ipcMain) {
return prepareOAuthCallback();
});
ipcMain.handle("oauth:awaitCallback", async (_event, expectedState) => {
return awaitOAuthCallback(expectedState);
ipcMain.handle("oauth:awaitCallback", async (_event, expectedState, sessionId) => {
return awaitOAuthCallback(expectedState, sessionId);
});
ipcMain.handle("oauth:cancelCallback", async () => {
cancelOAuthCallback();
ipcMain.handle("oauth:cancelCallback", async (_event, sessionId) => {
cancelOAuthCallback(sessionId);
});
}
+131
View File
@@ -192,6 +192,137 @@ test("cancelOAuthCallback releases the bound port", async () => {
}
});
test("callbacks that arrive before awaitOAuthCallback do not consume the session", async () => {
const { bridge, cleanup } = await freshModule();
try {
const { port } = await bridge.prepareOAuthCallback();
const earlyStatus = await fetchCallback(port, { code: "stale-code", state: "stale-state" });
assert.equal(earlyStatus, 200);
assert.equal(bridge.getActiveOAuthPort(), port);
const pending = bridge.awaitOAuthCallback("expected-state");
const status = await fetchCallback(port, {
code: "fresh-code",
state: "expected-state",
});
assert.equal(status, 200);
const result = await pending;
assert.equal(result.code, "fresh-code");
} finally {
await cleanup();
}
});
test("cancelOAuthCallback before awaitOAuthCallback is replayed as a cancellation", async () => {
const { bridge, cleanup } = await freshModule();
try {
const { sessionId } = await bridge.prepareOAuthCallback();
bridge.cancelOAuthCallback(sessionId);
await assert.rejects(bridge.awaitOAuthCallback(undefined, sessionId), /cancelled/);
} finally {
await cleanup();
}
});
test("cancelOAuthCallback during prepare rejects the pending prepare", async () => {
const { bridge, cleanup } = await freshModule();
try {
const pendingPrepare = bridge.prepareOAuthCallback();
bridge.cancelOAuthCallback();
await assert.rejects(pendingPrepare, /cancelled/);
assert.equal(bridge.getActiveOAuthPort(), null);
} finally {
await cleanup();
}
});
test("cancelOAuthCallback ignores stale session ids", async () => {
const { bridge, cleanup } = await freshModule();
try {
const stale = await bridge.prepareOAuthCallback();
const fresh = await bridge.prepareOAuthCallback();
bridge.cancelOAuthCallback(stale.sessionId);
assert.equal(bridge.getActiveOAuthPort(), fresh.port);
const pending = bridge.awaitOAuthCallback(undefined, fresh.sessionId);
const status = await fetchCallback(fresh.port, { code: "fresh-code" });
assert.equal(status, 200);
const result = await pending;
assert.equal(result.code, "fresh-code");
} finally {
await cleanup();
}
});
test("awaitOAuthCallback rejects stale session ids instead of attaching to the new flow", async () => {
const { bridge, cleanup } = await freshModule();
try {
const stale = await bridge.prepareOAuthCallback();
const fresh = await bridge.prepareOAuthCallback();
await assert.rejects(
bridge.awaitOAuthCallback(undefined, stale.sessionId),
/cancelled/
);
const pending = bridge.awaitOAuthCallback(undefined, fresh.sessionId);
const status = await fetchCallback(fresh.port, { code: "fresh-code" });
assert.equal(status, 200);
const result = await pending;
assert.equal(result.code, "fresh-code");
} finally {
await cleanup();
}
});
test("concurrent prepareOAuthCallback calls cancel the superseded attempt", async () => {
const { bridge, cleanup } = await freshModule();
try {
const stalePrepare = bridge.prepareOAuthCallback();
const staleRejects = assert.rejects(stalePrepare, /cancelled/);
const freshPrepare = bridge.prepareOAuthCallback();
const fresh = await freshPrepare;
await staleRejects;
const pending = bridge.awaitOAuthCallback(undefined, fresh.sessionId);
const status = await fetchCallback(fresh.port, { code: "fresh-code" });
assert.equal(status, 200);
const result = await pending;
assert.equal(result.code, "fresh-code");
} finally {
await cleanup();
}
});
test("prepareOAuthCallback replaces an in-flight await cleanly", async () => {
const { bridge, cleanup } = await freshModule();
try {
const stale = await bridge.prepareOAuthCallback();
const stalePending = bridge.awaitOAuthCallback(undefined, stale.sessionId);
const staleRejects = assert.rejects(stalePending, /cancelled/);
const fresh = await bridge.prepareOAuthCallback();
await staleRejects;
const nextPending = bridge.awaitOAuthCallback(undefined, fresh.sessionId);
const status = await fetchCallback(fresh.port, { code: "fresh-code" });
assert.equal(status, 200);
const result = await nextPending;
assert.equal(result.code, "fresh-code");
} finally {
await cleanup();
}
});
test("getActiveOAuthPort is null before prepare and after cleanup", async () => {
const { bridge, cleanup } = await freshModule();
try {
+3 -2
View File
@@ -963,8 +963,9 @@ const api = {
// port (which may differ from the preferred 45678 if it was in use) and
// embed it into the provider's redirect_uri before opening the browser.
prepareOAuthCallback: () => ipcRenderer.invoke("oauth:prepareCallback"),
awaitOAuthCallback: (expectedState) => ipcRenderer.invoke("oauth:awaitCallback", expectedState),
cancelOAuthCallback: () => ipcRenderer.invoke("oauth:cancelCallback"),
awaitOAuthCallback: (expectedState, sessionId) =>
ipcRenderer.invoke("oauth:awaitCallback", expectedState, sessionId),
cancelOAuthCallback: (sessionId) => ipcRenderer.invoke("oauth:cancelCallback", sessionId),
// GitHub Device Flow (proxied via main process to avoid CORS)
githubStartDeviceFlow: (options) => ipcRenderer.invoke("netcatty:github:deviceFlow:start", options),
Vendored
+3 -3
View File
@@ -607,9 +607,9 @@ declare global {
// back to an OS-assigned free port if busy). The caller then builds the
// OAuth URL against `redirectUri`, opens the browser, and finally awaits
// the code via `awaitOAuthCallback`.
prepareOAuthCallback?(): Promise<{ port: number; redirectUri: string }>;
awaitOAuthCallback?(expectedState?: string): Promise<{ code: string; state?: string }>;
cancelOAuthCallback?(): Promise<void>;
prepareOAuthCallback?(): Promise<{ sessionId: string; port: number; redirectUri: string }>;
awaitOAuthCallback?(expectedState?: string, sessionId?: string): Promise<{ code: string; state?: string }>;
cancelOAuthCallback?(sessionId?: string): Promise<void>;
// GitHub Device Flow (cloud sync)
githubStartDeviceFlow?(options?: { clientId?: string; scope?: string }): Promise<{
+7 -6
View File
@@ -37,7 +37,7 @@ import packageJson from '../../package.json';
import { EncryptionService } from './EncryptionService';
import { createAdapter, type CloudAdapter } from './adapters';
import { localStorageAdapter } from '../persistence/localStorageAdapter';
import type { GitHubAdapter } from './adapters/GitHubAdapter';
import type { DeviceFlowState, GitHubAdapter } from './adapters/GitHubAdapter';
import type { GoogleDriveAdapter } from './adapters/GoogleDriveAdapter';
import type { OneDriveAdapter } from './adapters/OneDriveAdapter';
import {
@@ -97,6 +97,10 @@ interface ProviderSyncAnchor {
observedAt: number;
}
export type StartProviderAuthResult =
| { type: 'device_code'; data: DeviceFlowState }
| { type: 'url'; data: { url: string; redirectUri: string } };
// ============================================================================
// CloudSyncManager Class
// ============================================================================
@@ -704,10 +708,7 @@ export class CloudSyncManager {
async startProviderAuth(
provider: CloudProvider,
redirectUri?: string
): Promise<{
type: 'device_code' | 'url';
data: unknown;
}> {
): Promise<StartProviderAuthResult> {
if (provider === 'webdav' || provider === 's3') {
throw new Error('Provider requires manual configuration');
}
@@ -730,7 +731,7 @@ export class CloudSyncManager {
if (!redirectUri) {
throw new Error(
`startProviderAuth('${provider}') requires a redirectUri — ` +
'call preparePKCECallback on the bridge first and pass its redirectUri through.'
'call prepareOAuthCallback on the bridge first and pass its redirectUri through.'
);
}