mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-10-09 15:58:34 +00:00
c5ac85ae5b
* Auto-refresh OneDrive sync token and prompt reconnect on dead refresh token OneDrive cloud sync (#1189) broke for users on 1.1.20 and only recovered after disconnecting and re-authorizing. Two gaps caused this: 1. Refreshed tokens were never persisted. When OneDriveAdapter silently refreshed the access token mid-session, the rotated tokens lived only in the adapter's in-memory state — CloudSyncManager never read them back or saved them. Microsoft consumer refresh tokens rotate on every refresh and invalidate the previous one, so the next app launch loaded a stale, rotated-out refresh token. Eventually that stored token was dead and sync failed, forcing a manual reconnect. Fix: OneDriveAdapter now exposes setOnTokensRefreshed(); the manager wires it so every silent refresh writes the rotated tokens back into provider state and encrypted storage (attachTokenRefreshPersistence / persistRefreshedProviderTokens), keeping the stored refresh token current. 2. A genuinely dead refresh token surfaced as a raw, generic error with no guidance. The bridge now detects invalid_grant / interaction_required / consent_required / login_required on refresh and tags the error with a stable marker. OneDriveAdapter normalizes these to OneDriveReauthRequiredError; the marker survives IPC and error re-wrapping so the condition stays detectable, and the UI strips it to show a clean "OneDrive session expired, please reconnect." message. Shared marker + detection/clean helpers live in domain/sync.ts so the bridge, adapter, and UI use one source of truth. Scope is limited to OneDrive; other providers (Gist/iCloud/Google/WebDAV/S3) are untouched. Tests: OneDriveAdapter refresh-persistence + reauth detection, manager token-persistence wiring, and bridge invalid_grant tagging. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Drop OneDrive to a reconnect state when its refresh token is dead codex review: detecting a dead refresh token was not enough. A provider in `error` state that still holds tokens stays "ready for sync" (isProviderReadyForSync), and syncAllProviders resets such providers back to `connected` and retries — so auto-sync kept hammering the dead refresh token and the user never got a stable reconnect prompt. Now, when a sync/download error indicates OneDrive reauth is required, the manager clears the stale tokens and tears down the cached adapter (handleProviderReauthRequired), leaving the provider in an error state with no credentials. With no tokens, isProviderReadyForSync returns false so auto-sync stops retrying, and the card shows a clean "please reconnect" message with a Connect button. The account is preserved for display; the error message is stripped of the internal marker. Wired into the syncToProvider / uploadToProvider / downloadFromProvider error paths. Added tests for the clear-on-reauth behavior and provider/error scoping. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Clear OneDrive reauth during inspection paths; include service tests in npm test codex review round 2: P2 — A dead OneDrive refresh token can also surface during startup remote inspection and the syncAllProviders preflight conflict check, both of which run through inspectProviderRemoteState. That path swallowed the error into an {error} tuple without clearing the stale tokens, so the provider stayed retryable. Wired the reauth handler into inspectProviderRemoteState's catch, covering sync preflight, syncAll preflight, and startup inspection in one place. Made the handler idempotent so the operation's own catch can also call it without re-saving. P3 — New tests live under infrastructure/services/, which npm test's globs did not cover (the pre-existing syncAllStorageMethods.test.ts was also uncovered). Added infrastructure/services/*.test.ts and infrastructure/services/*/*.test.ts to the test script. Full suite: 1400 tests, 0 failures. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * test: drop unmatched services test glob from npm test The npm test script listed both infrastructure/services/*.test.ts and the nested infrastructure/services/*/*.test.ts. No .test.ts files live directly under infrastructure/services/ (the OneDrive adapter and cloudSync tests are in adapters/ and cloudSync/ subdirs), so the flat glob never matched. Under a default POSIX shell (sh/bash without nullglob), an unmatched glob is passed through literally, so node --test received the raw pattern. On Node versions without test-runner glob support this aborts with "Could not find '.../infrastructure/services/*.test.ts'" before any test runs, breaking npm test. Remove the redundant flat glob; the nested glob already covers the new OneDrive adapter and cloudSync tests. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
599 lines
21 KiB
TypeScript
599 lines
21 KiB
TypeScript
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
|
|
|
|
import { EncryptionService } from '../EncryptionService';
|
|
import { createAdapter, type CloudAdapter } from '../adapters';
|
|
import type GitHubAdapter from '../adapters/GitHubAdapter';
|
|
import type GoogleDriveAdapter from '../adapters/GoogleDriveAdapter';
|
|
import type OneDriveAdapter from '../adapters/OneDriveAdapter';
|
|
import { createSyncedFileSignature as createSyncedFileSignatureCore } from '../syncSignature.js';
|
|
import { decideRemoteChanged } from '../syncAnchorDecision.js';
|
|
import type {
|
|
CloudProvider,
|
|
OAuthTokens,
|
|
ProviderAccount,
|
|
ProviderConnection,
|
|
S3Config,
|
|
SyncedFile,
|
|
SyncPayload,
|
|
WebDAVConfig,
|
|
} from '../../../domain/sync';
|
|
import type {
|
|
ProviderSyncAnchor,
|
|
StartProviderAuthResult,
|
|
} from '../CloudSyncManager';
|
|
|
|
const SYNC_REMOTE_ANCHOR_STORAGE_KEY = 'netcatty_sync_remote_anchor_v1';
|
|
|
|
export async function startProviderAuthImpl(this: any,
|
|
provider: CloudProvider,
|
|
redirectUri?: string
|
|
): Promise<StartProviderAuthResult> {
|
|
if (provider === 'webdav' || provider === 's3') {
|
|
throw new Error('Provider requires manual configuration');
|
|
}
|
|
const authAttemptId = ++this.providerAuthAttemptSeq[provider];
|
|
this.providerAuthRestoreState[provider] = {
|
|
attemptId: authAttemptId,
|
|
connection: { ...this.state.providers[provider] },
|
|
adapter: this.adapters.get(provider) ?? null,
|
|
};
|
|
const adapter = await createAdapter(provider);
|
|
if (!this.isActiveAuthAttempt(provider, authAttemptId)) {
|
|
throw new Error(`${provider} auth superseded`);
|
|
}
|
|
this.adapters.set(provider, adapter);
|
|
|
|
this.updateProviderStatus(provider, 'connecting');
|
|
try {
|
|
if (provider === 'github') {
|
|
// GitHub uses Device Flow
|
|
const ghAdapter = adapter as GitHubAdapter;
|
|
const deviceFlow = await ghAdapter.startAuth();
|
|
|
|
return {
|
|
type: 'device_code',
|
|
data: { ...deviceFlow, authAttemptId },
|
|
};
|
|
} else {
|
|
// Google and OneDrive use PKCE with redirect
|
|
if (!redirectUri) {
|
|
throw new Error(
|
|
`startProviderAuth('${provider}') requires a redirectUri — ` +
|
|
'call prepareOAuthCallback on the bridge first and pass its redirectUri through.'
|
|
);
|
|
}
|
|
|
|
if (provider === 'google') {
|
|
const gdAdapter = adapter as GoogleDriveAdapter;
|
|
const url = await gdAdapter.startAuth(redirectUri);
|
|
return { type: 'url', data: { url, redirectUri, authAttemptId } };
|
|
} else {
|
|
const odAdapter = adapter as OneDriveAdapter;
|
|
const url = await odAdapter.startAuth(redirectUri);
|
|
return { type: 'url', data: { url, redirectUri, authAttemptId } };
|
|
}
|
|
}
|
|
} catch (error) {
|
|
if (!this.isActiveAuthAttempt(provider, authAttemptId)) {
|
|
throw error;
|
|
}
|
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
console.error(`[CloudSync] ${provider} connect failed`, {
|
|
error: errorMessage,
|
|
});
|
|
this.updateProviderStatus(provider, 'error', errorMessage);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
export async function completeGitHubAuthImpl(this: any,
|
|
deviceCode: string,
|
|
interval: number,
|
|
expiresAt: number,
|
|
onPending?: () => void,
|
|
signal?: AbortSignal,
|
|
authAttemptId?: number
|
|
): Promise<void> {
|
|
if (authAttemptId != null && !this.isActiveAuthAttempt('github', authAttemptId)) {
|
|
throw new Error('github auth superseded');
|
|
}
|
|
const adapter = this.adapters.get('github');
|
|
if (!adapter) {
|
|
throw new Error('GitHub adapter not initialized');
|
|
}
|
|
|
|
const ghAdapter = adapter as GitHubAdapter;
|
|
|
|
try {
|
|
// Snapshot the prior account BEFORE we overwrite providers[provider].
|
|
// Used as a fallback for the same-account comparison when the persisted
|
|
// accountId key is absent (e.g., first re-auth after upgrading to this
|
|
// version, where the key didn't exist yet).
|
|
const previousAccount = this.state.providers.github?.account;
|
|
|
|
const tokens = await ghAdapter.completeAuth(deviceCode, interval, expiresAt, onPending, signal);
|
|
if (authAttemptId != null && !this.isActiveAuthAttempt('github', authAttemptId)) {
|
|
throw new Error('github auth superseded');
|
|
}
|
|
const resourceId = await ghAdapter.initializeSync(signal);
|
|
|
|
if (authAttemptId != null && !this.isActiveAuthAttempt('github', authAttemptId)) {
|
|
throw new Error('github auth superseded');
|
|
}
|
|
|
|
++this.providerDecryptSeq.github;
|
|
this.state.providers.github = {
|
|
...this.state.providers.github,
|
|
status: 'connected',
|
|
tokens,
|
|
account: ghAdapter.accountInfo || undefined,
|
|
};
|
|
|
|
if (resourceId) {
|
|
this.state.providers.github.resourceId = resourceId;
|
|
}
|
|
|
|
await this.saveProviderConnection('github', this.state.providers.github, authAttemptId);
|
|
if (authAttemptId != null && !this.isActiveAuthAttempt('github', authAttemptId)) {
|
|
throw new Error('github auth superseded');
|
|
}
|
|
|
|
// Only clear the merge base if the authenticated account identity differs
|
|
// from the previously-stored one. See notes in completePKCEAuth.
|
|
const newId = ghAdapter.accountInfo?.id ?? null;
|
|
const previousId = this.loadProviderAccountId('github') ?? previousAccount?.id ?? null;
|
|
const sameAccount = newId !== null && previousId !== null && newId === previousId;
|
|
if (!sameAccount) {
|
|
this.removeFromStorage(this.syncBaseKey('github'));
|
|
this.clearSyncAnchor('github');
|
|
}
|
|
if (newId) {
|
|
this.saveProviderAccountId('github', newId);
|
|
}
|
|
|
|
this.emit({
|
|
type: 'AUTH_COMPLETED',
|
|
provider: 'github',
|
|
account: ghAdapter.accountInfo!,
|
|
});
|
|
this.providerAuthRestoreState.github = null;
|
|
} catch (error) {
|
|
if (authAttemptId != null && !this.isActiveAuthAttempt('github', authAttemptId)) {
|
|
throw error;
|
|
}
|
|
if (error instanceof Error && error.message.includes('auth superseded')) {
|
|
throw error;
|
|
}
|
|
if (error instanceof Error && error.name === 'AbortError') {
|
|
this.resetProviderStatus('github', authAttemptId);
|
|
throw error;
|
|
}
|
|
this.resetProviderStatus('github', authAttemptId);
|
|
this.setProviderError('github', String(error));
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
export async function completePKCEAuthImpl(this: any,
|
|
provider: 'google' | 'onedrive',
|
|
code: string,
|
|
redirectUri: string,
|
|
authAttemptId?: number
|
|
): Promise<void> {
|
|
if (authAttemptId != null && !this.isActiveAuthAttempt(provider, authAttemptId)) {
|
|
throw new Error(`${provider} auth superseded`);
|
|
}
|
|
const adapter = this.adapters.get(provider);
|
|
if (!adapter) {
|
|
throw new Error(`${provider} adapter not initialized`);
|
|
}
|
|
|
|
try {
|
|
// Snapshot the prior account BEFORE we overwrite providers[provider].
|
|
// Used as a fallback for the same-account comparison when the persisted
|
|
// accountId key is absent (e.g., first re-auth after upgrading to this
|
|
// version, where the key didn't exist yet).
|
|
const previousAccount = this.state.providers[provider]?.account;
|
|
|
|
let tokens: OAuthTokens;
|
|
let account;
|
|
|
|
if (provider === 'google') {
|
|
const gdAdapter = adapter as GoogleDriveAdapter;
|
|
tokens = await gdAdapter.completeAuth(code, redirectUri);
|
|
account = gdAdapter.accountInfo;
|
|
} else {
|
|
const odAdapter = adapter as OneDriveAdapter;
|
|
tokens = await odAdapter.completeAuth(code, redirectUri);
|
|
account = odAdapter.accountInfo;
|
|
}
|
|
|
|
if (authAttemptId != null && !this.isActiveAuthAttempt(provider, authAttemptId)) {
|
|
throw new Error(`${provider} auth superseded`);
|
|
}
|
|
|
|
const resourceId = await adapter.initializeSync();
|
|
|
|
if (authAttemptId != null && !this.isActiveAuthAttempt(provider, authAttemptId)) {
|
|
throw new Error(`${provider} auth superseded`);
|
|
}
|
|
|
|
++this.providerDecryptSeq[provider];
|
|
this.state.providers[provider] = {
|
|
...this.state.providers[provider],
|
|
status: 'connected',
|
|
tokens,
|
|
account: account || undefined,
|
|
};
|
|
|
|
if (resourceId) {
|
|
this.state.providers[provider].resourceId = resourceId;
|
|
}
|
|
|
|
await this.saveProviderConnection(provider, this.state.providers[provider], authAttemptId);
|
|
if (authAttemptId != null && !this.isActiveAuthAttempt(provider, authAttemptId)) {
|
|
throw new Error(`${provider} auth superseded`);
|
|
}
|
|
|
|
// Only clear the merge base if the authenticated account identity differs
|
|
// from the previously-stored one. Same-account re-auth preserves the base
|
|
// so the next sync computes correct local-deletions instead of treating
|
|
// it as "first sync" and resurrecting zombie entries via null-base union.
|
|
const newId = account?.id ?? null;
|
|
const previousId = this.loadProviderAccountId(provider) ?? previousAccount?.id ?? null;
|
|
const sameAccount = newId !== null && previousId !== null && newId === previousId;
|
|
if (!sameAccount) {
|
|
this.removeFromStorage(this.syncBaseKey(provider));
|
|
this.clearSyncAnchor(provider);
|
|
}
|
|
if (newId) {
|
|
this.saveProviderAccountId(provider, newId);
|
|
}
|
|
|
|
this.emit({
|
|
type: 'AUTH_COMPLETED',
|
|
provider,
|
|
account: account!,
|
|
});
|
|
this.providerAuthRestoreState[provider] = null;
|
|
} catch (error) {
|
|
if (authAttemptId != null && !this.isActiveAuthAttempt(provider, authAttemptId)) {
|
|
throw error;
|
|
}
|
|
if (error instanceof Error && error.message.includes('auth superseded')) {
|
|
throw error;
|
|
}
|
|
this.resetProviderStatus(provider, authAttemptId);
|
|
this.setProviderError(provider, String(error));
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
export async function connectConfigProviderImpl(this: any,
|
|
provider: 'webdav' | 's3',
|
|
config: WebDAVConfig | S3Config
|
|
): Promise<void> {
|
|
const adapter = await createAdapter(provider, undefined, undefined, config);
|
|
this.adapters.set(provider, adapter);
|
|
this.updateProviderStatus(provider, 'connecting');
|
|
|
|
try {
|
|
const resourceId = await adapter.initializeSync();
|
|
const account = adapter.accountInfo || this.buildAccountFromConfig(provider, config);
|
|
|
|
++this.providerDecryptSeq[provider];
|
|
this.state.providers[provider] = {
|
|
provider,
|
|
status: 'connected',
|
|
config,
|
|
account,
|
|
resourceId: resourceId || undefined,
|
|
};
|
|
|
|
await this.saveProviderConnection(provider, this.state.providers[provider]);
|
|
// Clear merge base when (re)configuring to a different endpoint/bucket
|
|
this.removeFromStorage(this.syncBaseKey(provider));
|
|
this.clearSyncAnchor(provider);
|
|
this.emit({
|
|
type: 'AUTH_COMPLETED',
|
|
provider,
|
|
account,
|
|
});
|
|
} catch (error) {
|
|
this.updateProviderStatus(provider, 'error', String(error));
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
export function resetProviderStatusImpl(this: any,provider: CloudProvider, authAttemptId?: number): void {
|
|
const restoreState = this.providerAuthRestoreState[provider];
|
|
if (
|
|
authAttemptId != null &&
|
|
restoreState &&
|
|
restoreState.attemptId !== authAttemptId
|
|
) {
|
|
return;
|
|
}
|
|
|
|
if (restoreState) {
|
|
this.state.providers[provider] = { ...restoreState.connection };
|
|
if (restoreState.adapter) {
|
|
this.adapters.set(provider, restoreState.adapter);
|
|
} else {
|
|
this.adapters.delete(provider);
|
|
}
|
|
this.notifyStateChange();
|
|
} else if (this.state.providers[provider]?.status === 'connecting') {
|
|
this.updateProviderStatus(provider, 'disconnected');
|
|
return;
|
|
}
|
|
if (!restoreState || authAttemptId == null || restoreState.attemptId === authAttemptId) {
|
|
this.providerAuthRestoreState[provider] = null;
|
|
}
|
|
}
|
|
|
|
export function setProviderErrorImpl(this: any,provider: CloudProvider, error: string): void {
|
|
this.updateProviderStatus(provider, 'error', error);
|
|
}
|
|
|
|
export function clearConnectingStatusImpl(this: any,provider: CloudProvider): void {
|
|
if (this.state.providers[provider]?.status !== 'connecting') {
|
|
return;
|
|
}
|
|
this.updateProviderStatus(provider, 'disconnected');
|
|
}
|
|
|
|
export function clearProviderErrorImpl(this: any,provider: CloudProvider): void {
|
|
const connection = this.state.providers[provider];
|
|
if (!connection?.error && connection?.status !== 'error') {
|
|
return;
|
|
}
|
|
this.state.providers[provider] = {
|
|
...connection,
|
|
status: connection.status === 'error' ? 'disconnected' : connection.status,
|
|
error: undefined,
|
|
};
|
|
this.notifyStateChange();
|
|
}
|
|
|
|
export function cancelProviderAuthAttemptImpl(this: any,provider: CloudProvider, authAttemptId?: number): void {
|
|
if (
|
|
authAttemptId != null &&
|
|
!this.isActiveAuthAttempt(provider, authAttemptId)
|
|
) {
|
|
return;
|
|
}
|
|
this.resetProviderStatus(provider, authAttemptId);
|
|
++this.providerAuthAttemptSeq[provider];
|
|
const restoreState = this.providerAuthRestoreState[provider];
|
|
if (!restoreState || authAttemptId == null || restoreState.attemptId === authAttemptId) {
|
|
this.providerAuthRestoreState[provider] = null;
|
|
}
|
|
}
|
|
|
|
export async function disconnectProviderImpl(this: any,provider: CloudProvider): Promise<void> {
|
|
this.cancelProviderAuthAttempt(provider);
|
|
const adapter = this.adapters.get(provider);
|
|
if (adapter) {
|
|
adapter.signOut();
|
|
this.adapters.delete(provider);
|
|
}
|
|
|
|
++this.providerDecryptSeq[provider];
|
|
this.state.providers[provider] = {
|
|
provider,
|
|
status: 'disconnected',
|
|
};
|
|
|
|
await this.saveProviderConnection(provider, this.state.providers[provider]);
|
|
// Clear the merge base for this provider so reconnecting to a different
|
|
// account/resource doesn't reuse an unrelated snapshot
|
|
this.removeFromStorage(this.syncBaseKey(provider));
|
|
this.clearSyncAnchor(provider);
|
|
this.removeFromStorage(this.providerAccountIdKey(provider));
|
|
// Reset BLOCKED state if it was present — disconnect implicitly resolves
|
|
// any pending shrink-block warning since there's no provider to push to.
|
|
this.exitBlockedState();
|
|
if (this.state.syncState === 'BLOCKED') {
|
|
this.state.syncState = 'IDLE';
|
|
}
|
|
this.notifyStateChange(); // Ensure UI updates immediately after disconnect
|
|
}
|
|
|
|
export function updateProviderStatusImpl(this: any,
|
|
provider: CloudProvider,
|
|
status: ProviderConnection['status'],
|
|
error?: string
|
|
): void {
|
|
// Bump sequence to invalidate any in-flight async decrypt for this provider
|
|
++this.providerDecryptSeq[provider];
|
|
this.state.providers[provider] = {
|
|
...this.state.providers[provider],
|
|
status,
|
|
error,
|
|
};
|
|
this.notifyStateChange(); // Notify UI of status change
|
|
}
|
|
|
|
export function isActiveAuthAttemptImpl(this: any,provider: CloudProvider, authAttemptId: number): boolean {
|
|
return this.providerAuthAttemptSeq[provider] === authAttemptId;
|
|
}
|
|
|
|
export function buildAccountFromConfigImpl(this: any,
|
|
provider: 'webdav' | 's3',
|
|
config: WebDAVConfig | S3Config
|
|
): ProviderAccount {
|
|
if (provider === 'webdav') {
|
|
const endpoint = (config as WebDAVConfig).endpoint;
|
|
return { id: endpoint, name: endpoint };
|
|
}
|
|
const s3 = config as S3Config;
|
|
return { id: `${s3.bucket}@${s3.endpoint}`, name: `${s3.bucket} (${s3.region})` };
|
|
}
|
|
|
|
export function syncAnchorKeyImpl(this: any,provider: CloudProvider): string {
|
|
return `${SYNC_REMOTE_ANCHOR_STORAGE_KEY}_${provider}`;
|
|
}
|
|
|
|
export function createSyncedFileSignatureImpl(this: any,syncedFile: SyncedFile | null): Promise<string | null> {
|
|
return createSyncedFileSignatureCore(syncedFile);
|
|
}
|
|
|
|
export function loadSyncAnchorImpl(this: any,provider: CloudProvider): ProviderSyncAnchor | null {
|
|
return this.loadFromStorage<ProviderSyncAnchor>(this.syncAnchorKey(provider));
|
|
}
|
|
|
|
export async function saveSyncAnchorImpl(this: any,
|
|
provider: CloudProvider,
|
|
syncedFile: SyncedFile | null,
|
|
resourceId?: string | null,
|
|
): Promise<void> {
|
|
this.saveToStorage(this.syncAnchorKey(provider), {
|
|
signature: await this.createSyncedFileSignature(syncedFile),
|
|
version: syncedFile?.meta.version ?? 0,
|
|
updatedAt: syncedFile?.meta.updatedAt ?? 0,
|
|
deviceId: syncedFile?.meta.deviceId,
|
|
resourceId: resourceId ?? this.state.providers[provider].resourceId ?? null,
|
|
observedAt: Date.now(),
|
|
} satisfies ProviderSyncAnchor);
|
|
}
|
|
|
|
export function clearSyncAnchorImpl(this: any,provider?: CloudProvider): void {
|
|
if (provider) {
|
|
this.removeFromStorage(this.syncAnchorKey(provider));
|
|
return;
|
|
}
|
|
for (const p of ['github', 'google', 'onedrive', 'webdav', 's3'] as const) {
|
|
this.removeFromStorage(this.syncAnchorKey(p));
|
|
}
|
|
}
|
|
|
|
export async function inspectProviderRemoteStateImpl(this: any,
|
|
provider: CloudProvider,
|
|
adapter: CloudAdapter,
|
|
): Promise<{
|
|
remoteChanged: boolean;
|
|
remoteFile: SyncedFile | null;
|
|
error?: string;
|
|
}> {
|
|
try {
|
|
const remoteFile = await adapter.download();
|
|
const currentSignature = await this.createSyncedFileSignature(remoteFile);
|
|
const anchor = this.loadSyncAnchor(provider);
|
|
const currentResourceId = adapter.resourceId || this.state.providers[provider].resourceId || null;
|
|
|
|
const decision = decideRemoteChanged({
|
|
currentSignature,
|
|
currentResourceId,
|
|
anchor,
|
|
hasRemoteFile: Boolean(remoteFile),
|
|
});
|
|
|
|
return {
|
|
remoteChanged: decision.remoteChanged,
|
|
remoteFile,
|
|
};
|
|
} catch (error) {
|
|
// A dead OneDrive refresh token surfaces here during sync preflight,
|
|
// syncAll preflight, and startup inspection. Clear the stale credentials
|
|
// so the provider drops to a reconnect state instead of being retried.
|
|
if (typeof this.handleProviderReauthRequired === 'function') {
|
|
this.handleProviderReauthRequired(provider, error);
|
|
}
|
|
return {
|
|
remoteChanged: false,
|
|
remoteFile: null,
|
|
error: String(error),
|
|
};
|
|
}
|
|
}
|
|
|
|
export async function checkProviderConflictImpl(this: any,
|
|
provider: CloudProvider,
|
|
adapter: CloudAdapter
|
|
): Promise<{
|
|
conflict: boolean;
|
|
remoteFile?: SyncedFile;
|
|
}> {
|
|
const inspection = await this.inspectProviderRemoteState(provider, adapter);
|
|
if (inspection.error) {
|
|
throw new Error(inspection.error);
|
|
}
|
|
return {
|
|
conflict: inspection.remoteChanged && Boolean(inspection.remoteFile),
|
|
remoteFile: inspection.remoteFile ?? undefined,
|
|
};
|
|
}
|
|
|
|
export async function inspectProviderRemoteImpl(this: any,provider: CloudProvider): Promise<{
|
|
remoteChanged: boolean;
|
|
remoteFile: SyncedFile | null;
|
|
payload: SyncPayload | null;
|
|
}> {
|
|
if (this.state.securityState !== 'UNLOCKED' || !this.masterPassword) {
|
|
throw new Error('Vault is locked');
|
|
}
|
|
|
|
const adapter = await this.getConnectedAdapter(provider);
|
|
const inspection = await this.inspectProviderRemoteState(provider, adapter);
|
|
if (inspection.error) {
|
|
throw new Error(inspection.error);
|
|
}
|
|
|
|
if (!inspection.remoteFile) {
|
|
return {
|
|
remoteChanged: inspection.remoteChanged,
|
|
remoteFile: null,
|
|
payload: null,
|
|
};
|
|
}
|
|
|
|
return {
|
|
remoteChanged: inspection.remoteChanged,
|
|
remoteFile: inspection.remoteFile,
|
|
payload: await EncryptionService.decryptPayload(inspection.remoteFile, this.masterPassword),
|
|
};
|
|
}
|
|
|
|
export async function commitRemoteInspectionImpl(this: any,
|
|
provider: CloudProvider,
|
|
remoteFile: SyncedFile,
|
|
payload: SyncPayload,
|
|
opts: { recordDownload?: boolean } = {},
|
|
): Promise<void> {
|
|
const adapter = await this.getConnectedAdapter(provider);
|
|
const resourceId = adapter.resourceId || this.state.providers[provider].resourceId || null;
|
|
if (resourceId && this.state.providers[provider].resourceId !== resourceId) {
|
|
++this.providerDecryptSeq[provider];
|
|
this.state.providers[provider] = {
|
|
...this.state.providers[provider],
|
|
resourceId,
|
|
};
|
|
}
|
|
|
|
this.state.localVersion = remoteFile.meta.version;
|
|
this.state.localUpdatedAt = remoteFile.meta.updatedAt;
|
|
this.state.remoteVersion = remoteFile.meta.version;
|
|
this.state.remoteUpdatedAt = remoteFile.meta.updatedAt;
|
|
this.state.providers[provider].lastSync = Date.now();
|
|
this.state.providers[provider].lastSyncVersion = remoteFile.meta.version;
|
|
|
|
await this.saveSyncBase(payload, provider);
|
|
this.saveSyncConfig();
|
|
await this.saveSyncAnchor(provider, remoteFile, resourceId);
|
|
await this.saveProviderConnection(provider, this.state.providers[provider]);
|
|
if (opts.recordDownload === true) {
|
|
this.addSyncHistoryEntry({
|
|
timestamp: Date.now(),
|
|
provider,
|
|
action: 'download',
|
|
success: true,
|
|
localVersion: remoteFile.meta.version,
|
|
remoteVersion: remoteFile.meta.version,
|
|
deviceName: remoteFile.meta.deviceName,
|
|
});
|
|
}
|
|
this.notifyStateChange();
|
|
}
|