765 lines
28 KiB
TypeScript
765 lines
28 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 { normalizeDurablePluginSyncCredentialRef } from '../../../domain/sync';
|
||
|
|
import { isPluginCloudProviderId } from '../../../domain/cloudProviderIds';
|
||
|
|
import type {
|
||
|
|
ProviderSyncAnchor,
|
||
|
|
StartProviderAuthResult,
|
||
|
|
} from '../CloudSyncManager';
|
||
|
|
import {
|
||
|
|
registerPluginProviderIdImpl,
|
||
|
|
unregisterPluginProviderIdImpl,
|
||
|
|
} from './stateAndSecurityMethods';
|
||
|
|
|
||
|
|
const SYNC_REMOTE_ANCHOR_STORAGE_KEY = 'netcatty_sync_remote_anchor_v1';
|
||
|
|
|
||
|
|
export function clearProviderMergeStateImpl(this: any, provider: CloudProvider): void {
|
||
|
|
this.removeFromStorage(this.syncBaseKey(provider));
|
||
|
|
this.removeFromStorage(this.convergentProviderBaselineKey(provider));
|
||
|
|
this.clearSyncAnchor(provider);
|
||
|
|
}
|
||
|
|
|
||
|
|
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) {
|
||
|
|
clearProviderMergeStateImpl.call(this, '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) {
|
||
|
|
clearProviderMergeStateImpl.call(this, 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 all trusted merge state when changing endpoint or bucket.
|
||
|
|
clearProviderMergeStateImpl.call(this, provider);
|
||
|
|
this.emit({
|
||
|
|
type: 'AUTH_COMPLETED',
|
||
|
|
provider,
|
||
|
|
account,
|
||
|
|
});
|
||
|
|
} catch (error) {
|
||
|
|
this.updateProviderStatus(provider, 'error', String(error));
|
||
|
|
throw error;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Connect a namespaced plugin sync Provider. Configuration is opaque plugin-
|
||
|
|
* owned JSON; Netcatty still owns encryption and only forwards encrypted objects.
|
||
|
|
*/
|
||
|
|
export async function connectPluginProviderImpl(
|
||
|
|
this: any,
|
||
|
|
providerId: string,
|
||
|
|
configuration: unknown = {},
|
||
|
|
credential?: unknown,
|
||
|
|
): Promise<void> {
|
||
|
|
if (!isPluginCloudProviderId(providerId)) {
|
||
|
|
throw new Error(`Invalid plugin sync provider ID: ${providerId}`);
|
||
|
|
}
|
||
|
|
// Initialize sequence counters before the first status write so cross-window
|
||
|
|
// handlers never see NaN and discard every later update.
|
||
|
|
if (this.providerWriteSeq[providerId] == null || Number.isNaN(this.providerWriteSeq[providerId])) {
|
||
|
|
this.providerWriteSeq[providerId] = 0;
|
||
|
|
}
|
||
|
|
if (this.providerDecryptSeq[providerId] == null || Number.isNaN(this.providerDecryptSeq[providerId])) {
|
||
|
|
this.providerDecryptSeq[providerId] = 0;
|
||
|
|
}
|
||
|
|
if (this.providerDecrypted[providerId] == null) this.providerDecrypted[providerId] = true;
|
||
|
|
if (this.providerAuthAttemptSeq[providerId] == null || Number.isNaN(this.providerAuthAttemptSeq[providerId])) {
|
||
|
|
this.providerAuthAttemptSeq[providerId] = 0;
|
||
|
|
}
|
||
|
|
this.updateProviderStatus(providerId, 'connecting');
|
||
|
|
try {
|
||
|
|
const createPluginStorage = async (id: string) => {
|
||
|
|
if (typeof this.createPluginStorage === 'function') {
|
||
|
|
return this.createPluginStorage(id, {
|
||
|
|
provider: id,
|
||
|
|
status: 'connecting',
|
||
|
|
config: configuration as ProviderConnection['config'],
|
||
|
|
});
|
||
|
|
}
|
||
|
|
const { createPluginSyncIpcHost, isPluginSyncIpcAvailable } = await import(
|
||
|
|
'../adapters/pluginSyncIpcHost'
|
||
|
|
);
|
||
|
|
const { createPluginSyncObjectStorage } = await import('../adapters/pluginSyncObjectStorage');
|
||
|
|
if (!isPluginSyncIpcAvailable()) {
|
||
|
|
throw new Error(`Plugin sync provider ${id} is unavailable (plugin host not enabled)`);
|
||
|
|
}
|
||
|
|
return createPluginSyncObjectStorage({
|
||
|
|
providerId: id,
|
||
|
|
host: createPluginSyncIpcHost(),
|
||
|
|
configuration,
|
||
|
|
credential: credential as import('../adapters/pluginSyncObjectStorage').PluginSyncCredentialRef | undefined,
|
||
|
|
});
|
||
|
|
};
|
||
|
|
const adapter = await createAdapter(
|
||
|
|
providerId,
|
||
|
|
undefined,
|
||
|
|
undefined,
|
||
|
|
undefined,
|
||
|
|
{ createPluginStorage },
|
||
|
|
);
|
||
|
|
// Only cache after initializeSync succeeds so a rejected reconnect cannot
|
||
|
|
// leave a half-authenticated adapter pinned for later auto-sync.
|
||
|
|
let resourceId: string | null;
|
||
|
|
try {
|
||
|
|
resourceId = await adapter.initializeSync();
|
||
|
|
} catch (error) {
|
||
|
|
try { adapter.signOut(); } catch { /* ignore */ }
|
||
|
|
this.adapters.delete(providerId);
|
||
|
|
throw error;
|
||
|
|
}
|
||
|
|
this.adapters.set(providerId, adapter);
|
||
|
|
const account = adapter.accountInfo ?? { id: providerId };
|
||
|
|
if (this.providerDecryptSeq[providerId] == null) this.providerDecryptSeq[providerId] = 0;
|
||
|
|
if (this.providerWriteSeq[providerId] == null) this.providerWriteSeq[providerId] = 0;
|
||
|
|
if (this.providerDecrypted[providerId] == null) this.providerDecrypted[providerId] = true;
|
||
|
|
++this.providerDecryptSeq[providerId];
|
||
|
|
// Capture pre-connect identity before overwriting state.
|
||
|
|
const previous = this.state.providers[providerId];
|
||
|
|
const previousAccountId = previous?.account?.id ?? null;
|
||
|
|
const previousResource = previous?.resourceId ?? null;
|
||
|
|
const configFingerprint = (value: unknown): string => {
|
||
|
|
const normalize = (input: unknown): unknown => {
|
||
|
|
if (Array.isArray(input)) return input.map(normalize);
|
||
|
|
if (input && typeof input === 'object') {
|
||
|
|
return Object.fromEntries(
|
||
|
|
Object.keys(input as Record<string, unknown>)
|
||
|
|
.sort()
|
||
|
|
.map((key) => [key, normalize((input as Record<string, unknown>)[key])]),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
return input;
|
||
|
|
};
|
||
|
|
try {
|
||
|
|
return JSON.stringify(normalize(value ?? null));
|
||
|
|
} catch {
|
||
|
|
return String(value);
|
||
|
|
}
|
||
|
|
};
|
||
|
|
const previousConfigFp = configFingerprint(previous?.config ?? null);
|
||
|
|
const nextAccountId = account?.id ?? null;
|
||
|
|
const nextResource = resourceId || null;
|
||
|
|
const nextConfigFp = configFingerprint(configuration ?? null);
|
||
|
|
this.state.providers[providerId] = {
|
||
|
|
provider: providerId,
|
||
|
|
status: 'connected',
|
||
|
|
config: configuration as ProviderConnection['config'],
|
||
|
|
...((() => {
|
||
|
|
const ref = normalizeDurablePluginSyncCredentialRef(credential);
|
||
|
|
return ref ? { credential: ref } : {};
|
||
|
|
})()),
|
||
|
|
account,
|
||
|
|
resourceId: resourceId || undefined,
|
||
|
|
};
|
||
|
|
registerPluginProviderIdImpl.call(this, providerId);
|
||
|
|
await this.saveProviderConnection(providerId, this.state.providers[providerId]);
|
||
|
|
// Preserve merge base / anchors when reconnecting the same account+resource
|
||
|
|
// and configuration; clear when backend identity or config changes.
|
||
|
|
const previousCredentialFp = `${previous?.credential?.kind ?? ''}\0${previous?.credential?.id ?? ''}\0${previous?.credential?.key ?? ''}`;
|
||
|
|
const nextCredentialFp = `${this.state.providers[providerId].credential?.kind ?? ''}\0${this.state.providers[providerId].credential?.id ?? ''}\0${this.state.providers[providerId].credential?.key ?? ''}`;
|
||
|
|
if (
|
||
|
|
previousAccountId !== nextAccountId
|
||
|
|
|| previousResource !== nextResource
|
||
|
|
|| previousConfigFp !== nextConfigFp
|
||
|
|
|| previousCredentialFp !== nextCredentialFp
|
||
|
|
) {
|
||
|
|
clearProviderMergeStateImpl.call(this, providerId);
|
||
|
|
}
|
||
|
|
this.emit({
|
||
|
|
type: 'AUTH_COMPLETED',
|
||
|
|
provider: providerId,
|
||
|
|
account,
|
||
|
|
});
|
||
|
|
this.notifyStateChange();
|
||
|
|
} catch (error) {
|
||
|
|
this.updateProviderStatus(providerId, '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]);
|
||
|
|
// Explicit disconnect removes the dynamic provider from the restart registry.
|
||
|
|
// Missing plugins with preserved config remain registered and are not coerced away.
|
||
|
|
unregisterPluginProviderIdImpl.call(this, provider);
|
||
|
|
// Clear all trusted merge state so a later account/resource cannot reuse
|
||
|
|
// an unrelated snapshot or convergent baseline.
|
||
|
|
clearProviderMergeStateImpl.call(this, provider);
|
||
|
|
this.removeFromStorage(this.providerAccountIdKey(provider));
|
||
|
|
// Drop OS-backed sync secrets so disconnect does not leave reusable passwords.
|
||
|
|
if (isPluginCloudProviderId(provider)) {
|
||
|
|
try {
|
||
|
|
const { deletePluginSyncSecrets } = await import('../adapters/pluginSyncIpcHost');
|
||
|
|
await deletePluginSyncSecrets({ providerId: provider });
|
||
|
|
} catch {
|
||
|
|
/* best-effort; disconnect still succeeds */
|
||
|
|
}
|
||
|
|
}
|
||
|
|
// 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;
|
||
|
|
}
|
||
|
|
const providers = new Set<CloudProvider>([
|
||
|
|
'github', 'google', 'onedrive', 'webdav', 's3',
|
||
|
|
]);
|
||
|
|
for (const id of Object.keys(this.state?.providers ?? {})) {
|
||
|
|
providers.add(id as CloudProvider);
|
||
|
|
}
|
||
|
|
if (typeof this.listRegisteredPluginProviderIds === 'function') {
|
||
|
|
for (const id of this.listRegisteredPluginProviderIds()) {
|
||
|
|
providers.add(id as CloudProvider);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
for (const p of providers) {
|
||
|
|
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();
|
||
|
|
}
|