mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-17 16:37:43 +00:00
* feat(sync): integrate onedrive with pkce and operation log sync * fix(sync): add oauth state validation and token refresh concurrency control * fix(sync): refactor onedrive oauth cleanup and reduce download API calls - Replace setInterval-based OAuth state pruning with on-demand _pruneExpiredOAuthStates() to avoid background timer overhead - Optimize downloadFile to read ETag from content response headers first, falling back to a metadata request only when needed (reduces API calls from 2 to 1 in the common case) - Narrow OneDrive class interface from SyncProviderServiceInterface to FileSyncProvider for stronger type guarantees - Accept optional devPath constructor parameter for non-production folder namespacing - Extract isOneDriveClientIdRequired to a helper function in sync-form.const.ts for readability - Change default OneDrive sync folder name to 'Super Productivity' Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(sync): address PR #7523 review feedback for OneDrive sync Critical fixes: - Detect invalid_grant in token refresh responses and clear credentials - Reset _tokenRefreshInFlightPromise in clearAuthCredentials to prevent stale refresh results from overwriting cleared state - Re-read config before persisting refreshed tokens to avoid race with concurrent clearAuthCredentials calls Important fixes: - Extract OAuth state management to shared oauth-state.util.ts to fix circular dependency between OneDrive provider and callback handler - Add 401 retry pattern (_is401Retry) matching Dropbox's approach: on 401, proactively refresh token and retry, only clear credentials if retry fails - Remove unused auth status translation keys (AUTH_SUCCESS, BTN_AUTHENTICATE, BTN_REAUTHENTICATE, REAUTH_SUCCESS, STATUS_CONFIGURED, STATUS_NOT_CONFIGURED) and form props - Remove unused requireAuth parameter from _cfgOrError Minor fixes: - Replace path-exposing log messages with structured logging - Remove unused _formatHttpErrorDetails helper - Return empty ETag fallback in getFileRev instead of throwing - Add folder cache invalidation comment for path changes - Add afterEach to restore global fetch in tests - Add PKCE defence-in-depth comment in auth code dialog Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(sync): use eslint-disable-next-line for @microsoft.graph.conflictBehavior Replace spread+computed-property workaround with a plain property plus an explicit eslint-disable-next-line comment. Clearer intent: the naming violation is a Graph API requirement, not accidental. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(sync): pre-save OneDrive form config in reauth() to prevent MissingCredentialsSPError Re-auth was failing silently on Linux because getAuthHelper() reads clientId from IndexedDB (privateCfg), but the form values were never written before calling configuredAuthForSyncProviderIfNecessary(). - Extract shared BTN_REAUTHENTICATE and REAUTH_SUCCESS translation keys - Add OneDrive to OAUTH_SYNC_PROVIDERS - Pre-save form config in reauth() matching the existing save() flow Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(sync): adapt TooManyRequestsAPIError to new constructor signature after rebase * refactor(sync): migrate OneDrive provider to packages/sync-providers Move OneDrive core logic into the shared sync-providers package, matching the pattern used by Dropbox, WebDAV, and other providers. The app-side code is now a thin adapter with a createOneDriveProvider() factory function. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(sync): re-throw MissingRefreshTokenAPIError from _requestOAuthToken catch block The catch block in _requestOAuthToken was swallowing the MissingRefreshTokenAPIError thrown after clearing credentials on invalid_grant, causing it to fall through to a generic HttpNotOkAPIError instead. Now re-throws MissingRefreshTokenAPIError explicitly. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(sync): address PR #7523 second review critical and important items - Replace _is401Retry instance field with per-call parameter to prevent concurrent 401 races (Critical #1) - Fix _requestOAuthToken catch block to re-throw MissingRefreshTokenAPIError instead of swallowing it (Critical #2) - Fix _parseOAuthCallback defaulting unknown provider to 'unknown' instead of 'dropbox' to prevent misrouting (Critical #3 + Important #6) - Change conflictBehavior from 'replace' to 'fail' to prevent data loss if a file exists with the same name as the folder (Critical #4) - Await in-flight token refresh promise in clearAuthCredentials before clearing, to close the refresh-during-clear race (Important #5) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(sync): address PR #7523 review important and minor items - Extract duplicate OneDrive pre-auth save block into _persistOneDriveFormCfgBeforeAuth() method (Important #7) - Add GET probe in _ensureSyncFolderExists to skip per-segment creation when folder already exists (Important #9) - Redact sensitive params (code, code_verifier, refresh_token, access_token) from token endpoint and Graph error bodies (Important #11) - Remove targetPath from _mapAndThrow error messages to prevent logging user content (Important #12) - Add OAuth state validation in manual paste flow for OneDrive (Important #13) - Fix unknown provider routing in _parseOAuthCallback (Important #14) - Guard Electron IPC listener against post-destroy emissions (Important #15) - Remove dead authStatus Formly field from sync-form.const.ts (Minor) - Simplify Headers builder in _ensureSyncFolderExists (Minor) - Fix spec afterEach to restore original fetch instead of undefined (Minor) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(sync): fix clearAuthCredentials race and add unit tests for OneDrive Fix a race condition in clearAuthCredentials where _tokenRefreshInFlightPromise was nulled AFTER awaiting, causing the invalid_grant handler's clearAuthCredentials to wait on itself. Now captures the promise and nulls the field before awaiting. Add 5 new unit tests covering critical code paths: - 401 token refresh and retry - 401 retry failure with credential clearing - 400 invalid_grant credential clearing - 412 precondition failed mapping - Concurrent token refresh deduplication Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(sync): address PR #7523 third review items - Simplify clearAuthCredentials: null _tokenRefreshInFlightPromise without awaiting to avoid deadlock when called from inside the refresh IIFE - Add superproductivity:// scheme validation in Electron OAuth listener - Fix invalid_grant test to assert MissingRefreshTokenAPIError - Fix folder cache test to match GET-probe optimization - Set setComplete default in beforeEach Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * test(sync): fix OAuthCallbackHandler spec to expect 'unknown' provider The _parseOAuthCallback now returns 'unknown' instead of 'dropbox' for URLs without an explicit provider path segment. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(sync): address PR #7523 fourth review items - Handle generic 401 from token endpoint (clear creds + MissingRefreshTokenAPIError) - Remove user-supplied paths from error constructors (rule 9 compliance) - Soften clearAuthCredentials comment to reflect actual TOCTOU guarantee - Tighten Electron scheme gate to match native path prefix - Add scheme info to Electron rejection log - Fix folder-cache test to assert GET probe count - Remove redundant per-test setComplete stub Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(sync): address PR #7523 fifth review items - Scope invalid_grant/401 credential clearing to refresh_token grant only (bad auth code exchange no longer wipes existing credentials) - Redact OAuth code/state from Electron protocol handler logs - Add @sp/sync-providers/onedrive TS path alias to tsconfig files - Default undefined sync config fields instead of overwriting with undefined - Handle OneDrive in provider-switch branch (preserve encryptKey) - Update sync-config test expectations to match default values Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(sync): address PR #7523 sixth review items - Use If-None-Match: * for null-rev uploads to prevent concurrent overwrite - Guard in-flight refresh against stale credentials (match refreshToken/clientId/tenantId) - Gate OneDrive provider option behind Electron/Native (web CORS unsupported) - Don't clear credentials on transient token endpoint errors (429/5xx) - Preserve null syncProvider instead of defaulting to WebDAV - Hydrate full OneDrive privateCfg on provider switch - Remove duplicate syncInterval/isManualSyncOnly Formly controls - Revert non-English locale changes (zh.json, zh-tw.json) - Redact URL fragments (#) in Electron protocol handler logs - listFiles rethrows non-404 errors instead of swallowing all - Update 401 test expectations for new rethrow behavior Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(sync): guard credential clearing against stale concurrent refresh Prevent a stale in-flight refresh from wiping newer credentials after a concurrent re-auth. The refresh IIFE now throws a plain Error (not MissingRefreshTokenAPIError) when it detects config drift, and all clearAuthCredentials() call sites now verify the stored config still matches before clearing. Also fix listFiles() to handle 404 from _requestJson (which throws HttpNotOkAPIError, not RemoteFileNotFoundAPIError). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(sync): address PR #7523 eighth review items - Replace If-None-Match:* with @microsoft.graph.conflictBehavior=fail query param for upload create-only semantics - Add identity change detection in _persistOneDriveFormCfgBeforeAuth to clear tokens when useCustomApp/clientId/tenantId change - Extract _clearIfConfigMatches helper to guard credential clearing against stale concurrent refresh - Restrict folder probe fallthrough to 404 only, propagate other errors - Restore locale files from upstream master - Centralize IS_ONEDRIVE_SUPPORTED in onedrive-auth-mode.const.ts, use in factory and form config Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(sync): address PR #7523 ninth review items - Paginate listFiles via @odata.nextLink to avoid silent data loss - Fix logger misuse (log -> normal with proper string message) - Replace as any with OneDrivePrivateCfg in dialog-sync-cfg - Throw NoRevAPIError when uploadFile response lacks eTag - Omit undefined optional booleans from global config to prevent overwrite - Move OneDrive dynamic import inside IS_ONEDRIVE_SUPPORTED gate - Require state param for OneDrive full-URL paste (CSRF) - Add id_token to sensitive keys for body redaction Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(sync): add OneDrive type alias to electron tsconfig paths The @sp/sync-providers/onedrive path alias was missing from electron/tsconfig.electron.json, causing the Electron build to fail with "Cannot find module '@sp/sync-providers/onedrive'". The alias was already present in tsconfig.base.json but electron uses its own paths. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
138 lines
4.3 KiB
TypeScript
138 lines
4.3 KiB
TypeScript
import { App, BrowserWindow } from 'electron';
|
|
import { log } from 'electron-log/main';
|
|
import * as path from 'path';
|
|
import { IPC } from './shared-with-frontend/ipc-events.const';
|
|
import { showOrFocus } from './various-shared';
|
|
|
|
export const PROTOCOL_NAME = 'superproductivity';
|
|
export const PROTOCOL_PREFIX = `${PROTOCOL_NAME}://`;
|
|
|
|
// Store pending URLs to process after window is ready
|
|
let pendingUrls: string[] = [];
|
|
|
|
export const processProtocolUrl = (url: string, mainWin: BrowserWindow | null): void => {
|
|
// Redact query params before logging — OAuth code/state are credentials
|
|
const redactedUrl = url.split('?')[0].split('#')[0];
|
|
log('Processing protocol URL:', redactedUrl);
|
|
|
|
// Only process after window is ready
|
|
if (!mainWin || !mainWin.webContents) {
|
|
log('Window not ready, deferring protocol URL processing');
|
|
pendingUrls.push(url);
|
|
|
|
// Process any pending protocol URLs after window is created
|
|
setTimeout(() => {
|
|
processPendingProtocolUrls(mainWin);
|
|
}, 10000);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const urlObj = new URL(url);
|
|
const action = urlObj.hostname;
|
|
const pathParts = urlObj.pathname.split('/').filter(Boolean);
|
|
|
|
log('Protocol action:', action);
|
|
log('Protocol path parts:', pathParts);
|
|
|
|
switch (action) {
|
|
case 'oauth-callback':
|
|
log('Received OAuth callback URL via app protocol');
|
|
if (mainWin && mainWin.webContents) {
|
|
mainWin.webContents.send(IPC.OAUTH_CALLBACK, { url });
|
|
showOrFocus(mainWin);
|
|
}
|
|
break;
|
|
case 'create-task':
|
|
if (pathParts.length > 0) {
|
|
const taskTitle = decodeURIComponent(pathParts[0]);
|
|
log('Creating task with title:', taskTitle);
|
|
|
|
// Send IPC message to create task
|
|
if (mainWin && mainWin.webContents) {
|
|
mainWin.webContents.send(IPC.ADD_TASK_FROM_APP_URI, { title: taskTitle });
|
|
}
|
|
}
|
|
break;
|
|
case 'task-toggle-start':
|
|
// Send IPC message to toggle task start
|
|
if (mainWin && mainWin.webContents) {
|
|
mainWin.webContents.send(IPC.TASK_TOGGLE_START);
|
|
}
|
|
break;
|
|
default:
|
|
log('Unknown protocol action:', action);
|
|
}
|
|
} catch (error) {
|
|
log('Error processing protocol URL:', error);
|
|
}
|
|
};
|
|
|
|
export const processPendingProtocolUrls = (mainWin: BrowserWindow): void => {
|
|
if (pendingUrls.length > 0) {
|
|
log(`Processing ${pendingUrls.length} pending protocol URLs`);
|
|
const urls = [...pendingUrls];
|
|
pendingUrls = [];
|
|
urls.forEach((url) => processProtocolUrl(url, mainWin));
|
|
}
|
|
};
|
|
|
|
export const initializeProtocolHandling = (
|
|
IS_DEV: boolean,
|
|
appInstance: App,
|
|
getMainWindow: () => BrowserWindow | null,
|
|
): void => {
|
|
// Register protocol handler
|
|
if (IS_DEV && process.defaultApp) {
|
|
if (process.argv.length >= 2) {
|
|
const launchArgsForProtocol = [path.resolve(process.argv[1])];
|
|
const userDataDirArg = process.argv.find((arg) =>
|
|
arg.startsWith('--user-data-dir='),
|
|
);
|
|
if (userDataDirArg) {
|
|
launchArgsForProtocol.push(userDataDirArg);
|
|
}
|
|
|
|
appInstance.setAsDefaultProtocolClient(PROTOCOL_NAME, process.execPath, [
|
|
...launchArgsForProtocol,
|
|
]);
|
|
}
|
|
} else {
|
|
appInstance.setAsDefaultProtocolClient(PROTOCOL_NAME);
|
|
}
|
|
|
|
// Handle protocol on Windows/Linux via second instance
|
|
appInstance.on('second-instance', (event, commandLine) => {
|
|
const mainWin = getMainWindow();
|
|
|
|
// Someone tried to run a second instance, we should focus our window instead.
|
|
if (mainWin) {
|
|
showOrFocus(mainWin);
|
|
}
|
|
|
|
// Handle protocol url from second instance
|
|
const url = commandLine.find((arg) => arg.startsWith(PROTOCOL_PREFIX));
|
|
if (url) {
|
|
processProtocolUrl(url, mainWin);
|
|
}
|
|
});
|
|
|
|
// Handle protocol on macOS
|
|
appInstance.on('open-url', (event, url) => {
|
|
if (url.startsWith(PROTOCOL_PREFIX)) {
|
|
event.preventDefault();
|
|
processProtocolUrl(url, getMainWindow());
|
|
}
|
|
});
|
|
|
|
// Handle protocol URL passed as command line argument for testing
|
|
process.argv.forEach((val) => {
|
|
if (val && val.startsWith(PROTOCOL_PREFIX)) {
|
|
log('Protocol URL from command line:', val.split('?')[0].split('#')[0]);
|
|
// Process after app is ready
|
|
appInstance.whenReady().then(() => {
|
|
processProtocolUrl(val, getMainWindow());
|
|
});
|
|
}
|
|
});
|
|
};
|